fix(python): walk enclosing scopes for module member calls (#2447)

(cherry picked from commit e34b753891)
This commit is contained in:
SrijanSriv authored and safishamsi committed 2026-10-13 22:21:43 +01:00
1 parent 22124ba76d
commit edb93314b0
2 files changed
+69 -1

No files matched your search

+1 -1
View File
@@ -4962,7 +4962,7 @@ def _resolve_python_member_calls(
# receiver also matches the local alias bound on that import edge
# (#2082), so an aliased import resolves the same as the bare name.
rkey = _key(receiver)
caller_file = file_of_node.get(caller)
caller_file = _file_node(caller)
file_aliases = import_alias_by_filenode.get(caller_file, {})
mods = [t for t in imported_by_filenode.get(caller_file, ())
if t in contains_children
+68
View File
@@ -1811,6 +1811,74 @@ def test_python_module_call_resolves_when_same_file_imports_function_with_nested
assert edges[0]["confidence"] == "EXTRACTED"
def test_python_module_qualified_call_resolves_from_enclosing_scopes(tmp_path):
"""`mod.func()` inside a class method or a nested function resolves against
the enclosing file's imports (#2447, cause C).
A method hangs off its class via a `method` edge rather than a `contains`
edge from the file node, and a nested function's `contains` parent is the
enclosing function, so looking up `file_of_node.get(caller)` directly missed
the file node's import set in both cases."""
scripts = tmp_path / "scripts"
scripts.mkdir()
mod = scripts / "mod.py"
mod.write_text("def func():\n return 1\n")
caller = scripts / "caller.py"
caller.write_text(
"import mod\n\n"
"class Worker:\n"
" def run(self):\n"
" return mod.func()\n\n"
"class OuterClass:\n"
" class InnerClass:\n"
" def nested_run(self):\n"
" return mod.func()\n\n"
"def outer():\n"
" def inner():\n"
" def deep_inner():\n"
" return mod.func()\n"
" return mod.func() + deep_inner()\n"
" return inner()\n"
)
result = extract([mod, caller], cache_root=tmp_path, root=tmp_path)
nodes = {n["id"]: n for n in result["nodes"]}
def call_edges_from(caller_label: str) -> list[dict]:
return [
e for e in result["edges"]
if e["relation"] == "calls"
and nodes[e["source"]]["label"].lstrip(".").removesuffix("()") == caller_label
and "func" in nodes[e["target"]]["label"]
and "mod.py" in (nodes[e["target"]].get("source_file") or "")
]
for caller_label in ("run", "nested_run", "inner", "deep_inner"):
edges = call_edges_from(caller_label)
assert len(edges) == 1, f"expected {caller_label}->func edge, got {edges}"
assert edges[0]["confidence"] == "EXTRACTED"
# Cause A guard: bare `import mod` at the scan root when `mod.py` lives only
# under `scripts/` still does not resolve from either file scope or a method.
root_caller = tmp_path / "root_caller.py"
root_caller.write_text(
"import mod\n\n"
"def root_use():\n"
" return mod.func()\n\n"
"class RootWorker:\n"
" def root_run(self):\n"
" return mod.func()\n"
)
root_result = extract([mod, root_caller], cache_root=tmp_path, root=tmp_path)
root_nodes = {n["id"]: n for n in root_result["nodes"]}
root_calls = [
e for e in root_result["edges"]
if e["relation"] == "calls"
and "func" in root_nodes.get(e["target"], {}).get("label", "")
and "mod.py" in (root_nodes.get(e["target"], {}).get("source_file") or "")
]
assert root_calls == [], f"unresolvable root import must not link to scripts/mod.py: {root_calls}"
def test_python_from_import_alias_module_call_resolves(tmp_path):
"""`from pkg import mod as alias` must resolve `alias.func()` the same way the
unaliased `from pkg import mod` / `mod.func()` form already does (#2082). The