Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixes

- Python closures no longer link calls or method values to a captured receiver type when later replacement makes that type uncertain.
- Python receiver calls no longer link to a stale type after same-line replacement when statement order cannot establish a reliable type.

- JavaScript and TypeScript store actions exported on a later line remain indexed with LF, CRLF or CR line endings.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ Call resolution the fork adds:
- C# namespace `using` directives and `using` aliases apply only inside their enclosing namespace, not to sibling namespaces in the same file (`using static` is still file-wide, as upstream).
- A Java field declared with a qualified type (`outside.Repository`) keeps its qualifier, so calls through it never resolve to an unrelated project class with the same simple name.
- Python `super().method()` resolves along the class's C3 method resolution order, starting after the class itself. Upstream resolves it like `self.method()` and drops the link only when it lands on the calling method.
- Python receivers reassigned on later lines use the latest preceding value in their lexical scope. An unknown replacement leaves the member call unresolved. Calls and method values through a captured receiver stay unlinked when a later replacement makes its invocation-time value uncertain.
- Python receivers reassigned on later lines use the latest preceding value in their lexical scope. An unknown replacement leaves the member call unresolved. Calls and method values through a captured receiver stay unlinked when a later replacement makes its invocation-time value uncertain. Same-line replacements also stay unlinked when statement order cannot establish a reliable receiver type. Unaliased imports that add submodules to the same package preserve that package receiver.
- A `require` call creates no file dependency when a local binding shadows `require`.

**Server endpoints**
Expand Down
52 changes: 52 additions & 0 deletions __tests__/receiver-scope-lookup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,21 @@ def known():
expect(calls('known')).toEqual(['4:run']);
});

it.each([
['module imports', 'import pkg.sub, pkg.other\ndef known():\n return pkg.sub.run()\n', ['3:run']],
['local imports', 'def known():\n import pkg.sub, pkg.other\n return pkg.sub.run()\n', ['3:run']],
['explicit alias', 'import pkg.sub, pkg.other as pkg\ndef known():\n return pkg.sub.run()\n', []],
['value replacement', 'import pkg.sub; pkg = object()\ndef known():\n return pkg.sub.run()\n', []],
])('preserves only compatible same-line Python package bindings for %s', async (_case, app, expected) => {
await project({
'pkg/__init__.py': '',
'pkg/sub.py': 'def run(): return 1\n',
'pkg/other.py': 'def different(): return 2\n',
'app.py': app,
});
expect(calls('known')).toEqual(expected);
});

it('treats an explicit Python alias matching the package name as a replacement', async () => {
await project({
'pkg/__init__.py': '',
Expand Down Expand Up @@ -128,6 +143,43 @@ outer()()
expect(calls('known').filter(call => call.endsWith('::get')).map(call => call.slice(call.indexOf(':') + 1))).toEqual(expected);
});


it.each([
["local-same-line", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n store = A(); store = B()\n return store.get()\ndef exercise(): return known()\n", "known", []],
["local-reverse", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n store = B(); store = A()\n return store.get()\ndef exercise(): return known()\n", "known", []],
["module-same-line", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\nstore = A(); store = B()\ndef known(): return store.get()\ndef exercise(): return known()\n", "known", []],
["module-unknown", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\nstore = A(); store = object()\ndef known(): return store.get()\ndef exercise(): return known()\n", "known", []],
["lambda-same-line", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef outer():\n store = A()\n known = lambda: store.get(); store = B()\n return known\ndef exercise(): return outer()()\n", "outer", []],
["lambda-two-values", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef outer():\n store = A()\n known = lambda: store.get(); first = known(); store = B(); return [first, known()]\ndef exercise(): return outer()\n", "outer", []],
["stable-same-line", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n store = A(); return store.get()\n", "known", ["A::get"]],
["future-same-line", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n first = store.get(); store = A(); return first\n", "known", []],
["stable-parameter-same-line", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known(store: A): return store.get()\n", "known", ["A::get"]],
["deferred-single-same-line", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n store = A(); run = lambda: store.get(); return run\n", "known", ["A::get"]],
["annotation-first replacement", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n store = A()\n store: B; store = B()\n return store.get()\n", "known", []],
["other annotation before replacement", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n store = A()\n unused: object; store = B()\n return store.get()\n", "known", ["B::get"]],
["other value before bare annotation", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n store = A()\n unused = object(); store: B\n return store.get()\n", "known", ["A::get"]],
["same-line bare annotation", "class A:\n def get(self): return 'A'\nclass B:\n def get(self): return 'B'\n\ndef known():\n store = A()\n store: B; unused = object()\n return store.get()\n", "known", ["A::get"]],
])('keeps same-line Python receiver uncertainty conservative for %s', async (_case, source, caller, expected) => {
await project({ 'app.py': source });
expect(calls(caller).filter(call => call.endsWith('::get')).map(call => call.slice(call.indexOf(':') + 1))).toEqual(expected);
});

it.each([
['replacement', 'store = A(); store = B(); return store.get', []],
['stable', 'store = A(); return store.get', ['A::get']],
])('keeps same-line Python method values conservative for %s', async (_case, body, expected) => {
await project({ 'app.py': `class A:
def get(self): return 1
class B:
def get(self): return 2
def known():
${body}
` });
const from = graph!.getNodesByKind('function').find(n => n.name === 'known')!;
expect(graph!.getOutgoingEdges(from.id).filter(e => e.kind === 'references' && e.metadata?.fnRef === true)
.map(e => graph!.getNode(e.target)!.qualifiedName)).toEqual(expected);
});

it.each(['self', 'cls'])('refuses a captured %s receiver after replacement', async receiver => {
await project({ 'app.py': `class Store:
def get(self): return 1
Expand Down
2 changes: 1 addition & 1 deletion codegraph-kernel/src/resolve/pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,7 @@ if super::lang_scope::is_dart_member_read(r) {

/// The per-ref pipeline: the Rust `::`-path arm, then one route.
pub(super) fn resolve_ref(&mut self, r: &ResolveRefIn) -> Res<ResolveOutcome> {
if self.python_captured_receiver_mutates(r)? { return Ok(ResolveOutcome::unresolved()); }
if self.python_receiver_uncertain(r)? { return Ok(ResolveOutcome::unresolved()); }
if let Some(outcome) = self.resolve_vb_explicit(r)? { return Ok(outcome); }
// Rust pure-`::` path refs (`crate::m::Item`, `a::b::c`): TS
// resolves them through resolveViaImport's module-file arm, which
Expand Down
83 changes: 66 additions & 17 deletions codegraph-kernel/src/resolve/prefilter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -601,10 +601,11 @@ impl KernelResolver {
Ok(Some(latest.unwrap_or(binding).clone()))
}

/// A closure reads its captured cell when invoked, not when defined.
/// Without invocation-order evidence, a later replacement cannot supply
/// one reliable receiver type. A single future initialization is retained.
pub(super) fn python_captured_receiver_mutates(&mut self, site: &ResolveRefIn) -> Res<bool> {
/// Same-line bindings cannot select a reliable replacement by line alone.
/// A closure also reads its captured cell when invoked, not when defined;
/// a later replacement leaves its receiver uncertain. A single future
/// initialization is retained when there is no competing replacement.
pub(super) fn python_receiver_uncertain(&mut self, site: &ResolveRefIn) -> Res<bool> {
if site.language != "python" || !matches!(site.reference_kind.as_str(), "calls" | "references" | "function_ref") {
return Ok(false);
}
Expand All @@ -619,20 +620,51 @@ impl KernelResolver {
let Some(binding) = self.receiver_binding(root, site)? else { return Ok(false) };
let rows = self.bindings(&site.file_path)?;
let mut lines = Vec::new();
let mut same_line_package_import_seen = false;
for row in rows.iter().filter(|row| row.name == root
&& row.scope_start == binding.scope_start && row.scope_end == binding.scope_end)
{
if self.python_annotation_only(row, site) { continue; }
// Importing another submodule retains the same package object.
if row.line != binding.line && binding.kind == "import" && row.kind == "import"
if binding.kind == "import" && row.kind == "import"
&& self.python_package_import(&binding, site) && self.python_package_import(row, site)
{
continue;
if row.line != binding.line || same_line_package_import_seen { continue; }
same_line_package_import_seen = true;
}
lines.push(row.line);
}
let selected = lines.iter().filter(|line| **line <= site.line).max()
.or_else(|| lines.iter().min());
// Binding rows retain lines, not statement offsets. A tied selected
// line cannot reliably identify the assignment, even for direct calls.
if selected.is_some_and(|line| lines.iter().filter(|other| *other == line).count() > 1)
|| (binding.line == site.line && lines.iter().any(|line| *line < site.line))
Comment thread
bompus marked this conversation as resolved.
{
return Ok(true);
}
// Keep a lone same-line assignment only when its value is established
// before the call. A call in its initializer still reads the old value.
if binding.line == site.line {
if let Some(source) = self.read_file(&site.file_path) {
let text = source.text();
if let Some(tree) = self.parsed_tree(&source, site) {
let point = tree_sitter::Point::new((site.line - 1).max(0) as usize, site.column.max(0) as usize);
let mut nodes = vec![tree.root_node()];
while let Some(node) = nodes.pop() {
if node.start_position().row > point.row || node.end_position().row < point.row { continue; }
if node.kind() == "assignment" && node.child_by_field_name("right").is_some()
&& node.child_by_field_name("left").is_some_and(|target|
target.kind() == "identifier" && &text[target.start_byte()..target.end_byte()] == root)
&& node.end_position() >= point
{
return Ok(true);
}
nodes.extend(named_children(node));
}
}
}
}
if !selected.is_some_and(|line| lines.iter().any(|next| *next > site.line.max(*line))) {
return Ok(false);
}
Expand Down Expand Up @@ -689,20 +721,37 @@ impl KernelResolver {
let row = (binding.line - 1).max(0) as usize;
let Some(line) = file.get(row) else { return false };
let trimmed = line.trim_start();
if trimmed.strip_prefix(&binding.name).is_some_and(|tail| tail.trim_start().starts_with('=')) {
return false;
}
if trimmed.strip_prefix(&binding.name).is_some_and(|tail| {
let tail = tail.trim_start();
tail.starts_with('=') && !tail.starts_with("==")
}) { return false; }
let Some(tree) = self.parsed_tree(&file, site) else { return false };
let point = tree_sitter::Point::new(row, line.len() - trimmed.len());
let mut node = tree.root_node().named_descendant_for_point_range(point, point);
while let Some(current) = node {
if current.kind() == "assignment" {
return current.child_by_field_name("type").is_some()
&& current.child_by_field_name("right").is_none();
let text = file.text();
let mut annotation = false;
let mut nodes = vec![tree.root_node()];
while let Some(node) = nodes.pop() {
if node.start_position().row > row || node.end_position().row < row { continue; }
if node.kind() == "assignment" && node.start_position().row == row {
if let Some(target) = node.child_by_field_name("left") {
let mut targets = vec![target];
let mut binds_name = false;
while let Some(target) = targets.pop() {
if matches!(target.kind(), "attribute" | "subscript") { continue; }
if target.kind() == "identifier" && &text[target.start_byte()..target.end_byte()] == binding.name.as_str() {
binds_name = true;
break;
}
targets.extend(named_children(target));
}
if binds_name {
if node.child_by_field_name("right").is_some() { return false; }
annotation |= node.child_by_field_name("type").is_some();
}
}
}
node = current.parent();
nodes.extend(named_children(node));
}
false
annotation
}
}

Expand Down
2 changes: 1 addition & 1 deletion site/src/content/docs/reference/languages.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ Java/Kotlin enum constants with bodies retain their own methods and calls. Expli

Pytest fixture receiver inference uses default test function and file names, or an explicit fixture decorator. Custom collection naming without that source evidence remains unresolved.

Python receiver inference follows the latest preceding assignment on a separate line within its lexical scope. An unknown replacement stays untyped, and a later local assignment in the same function cannot supply a type to an earlier call. A bare annotation does not replace an existing value. Assignments in nested functions do not change the outer receiver. Calls and method values through an outer-scope receiver stay unlinked when a later replacement makes its invocation-time value uncertain. A single future initialization remains supported; this conservative rule can omit valid calls made before a later replacement. It uses the indexed lexical bindings and does not track arbitrary mutation through other closures or dynamic writes.
Python receiver inference follows the latest preceding assignment on a separate line within its lexical scope. An unknown replacement stays untyped, and a later local assignment in the same function cannot supply a type to an earlier call. A bare annotation does not replace an existing value. Assignments in nested functions do not change the outer receiver. Calls and method values through an outer-scope receiver stay unlinked when a later replacement makes its invocation-time value uncertain. A single future initialization remains supported; this conservative rule can omit valid calls made before a later replacement. Multiple bindings on the selected assignment line, or a replacement on the call line after an earlier binding, leave the receiver unlinked. Compatible unaliased imports such as `import pkg.a, pkg.b` bind the same package object and count as one binding. A single same-line constructor assignment before the call remains supported; a call before that assignment stays unlinked. This can omit valid same-line calls whose type is clear at runtime. It uses the indexed lexical bindings and does not track arbitrary mutation through other closures or dynamic writes.

Python `super()` and `super(Cls, self)` calls follow the class's method resolution order to the method it inherits. A call stays unresolved where Python would raise or only runtime could tell: outside a method's own frame (a class body, an f-string, a `@staticmethod`, or a lambda or generator expression for the zero-argument form), with `super` or the named class rebound where the call can see it, under a metaclass that may reorder the bases, past a base the index does not hold, or when a class on the way binds the name other than by `def` or makes it a property.

Expand Down