From 7d74788274ee0824ac7a651e412b8a1ab8bc7fc3 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Sat, 19 Sep 2026 09:06:20 +0530 Subject: [PATCH 1/2] feat(structural): ground function-local import calls --- diffgraph/structural.py | 41 +++++++++++++++++++++++++++------------- tests/test_structural.py | 25 ++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 13 deletions(-) diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 23e04ac..0c51b27 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -83,6 +83,7 @@ class _Import: line: int snippet: str bindings: Tuple[str, ...] + scope: Optional[str] @dataclass(frozen=True) @@ -337,7 +338,8 @@ def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None: # ``import package.submodule`` binds ``package``. binding = raw.split(".", 1)[0] imports.append(_Import( - raw, node.start_point[0] + 1, snippet, (binding,) + raw, node.start_point[0] + 1, snippet, (binding,), + parents[-1][0] if parents else None, )) else: # Tree-sitter exposes absolute modules through ``module_name`` @@ -373,6 +375,7 @@ def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None: node.start_point[0] + 1, snippet, tuple(imported), + parents[-1][0] if parents else None, )) elif not parents and node.type in ( "assignment", "annotated_assignment", "type_alias_statement" @@ -549,7 +552,7 @@ def _resolve_call_target( call: _Call, symbols: Dict[str, _Symbol], bindings: Dict[Optional[str], set], - imported_targets: Dict[str, List[Tuple[int, Optional[str]]]], + imported_targets: Dict[Optional[str], Dict[str, List[Tuple[int, Optional[str]]]]], ) -> Optional[str]: """Resolve only syntax-grounded, same-file Python calls. @@ -568,6 +571,10 @@ def _resolve_call_target( # sibling class attributes or methods. if current.kind in ("function", "method"): if call.name in bindings.get(current_name, set()): + history = imported_targets.get(current_name, {}).get(call.name, []) + visible = [target for line, target in history if line <= call.line] + if visible: + return visible[-1] return None candidates.append("{}.{}".format(current_name, call.name)) current_name = current.parent @@ -577,7 +584,7 @@ def _resolve_call_target( # bindings (for example an assignment) remain intentionally unresolved. # Select the binding visible at this call site rather than applying a # later top-level rebind retroactively. - history = imported_targets.get(call.name, []) + history = imported_targets.get(None, {}).get(call.name, []) visible = [target for line, target in history if line <= call.line] if visible and visible[-1] is not None: return visible[-1] @@ -604,27 +611,35 @@ def _resolve_call_target( def _imported_call_targets( imports: Dict[Tuple[str, int], _Import], module_rebindings: List[Tuple[str, int]], -) -> Dict[str, List[Tuple[int, Optional[str]]]]: - """Map each import binding to its conservative, line-aware history.""" +) -> Dict[Optional[str], Dict[str, List[Tuple[int, Optional[str]]]]]: + """Map import bindings to conservative, lexical and line-aware histories. - targets: Dict[str, List[Tuple[int, Optional[str]]]] = {} - imported_bindings = set() + Imports inside a function bind only that function's local scope. Keeping + that scope alongside the source line permits a direct call to an imported + name without leaking the binding into sibling functions or the module. + """ + + targets: Dict[Optional[str], Dict[str, List[Tuple[int, Optional[str]]]]] = {} + imported_bindings: Dict[Optional[str], set] = {} for (module, occurrence), item in imports.items(): suffix = "" if occurrence == 0 else "#{}".format(occurrence) target = "import::{}{}".format(module, suffix) + scope_targets = targets.setdefault(item.scope, {}) + scope_bindings = imported_bindings.setdefault(item.scope, set()) for binding in item.bindings: # A later import of the same local name is intentionally # unresolved, but calls before it retain the earlier binding. - targets.setdefault(binding, []).append(( - item.line, None if binding in imported_bindings else target + scope_targets.setdefault(binding, []).append(( + item.line, None if binding in scope_bindings else target )) - imported_bindings.add(binding) + scope_bindings.add(binding) for binding, line in module_rebindings: # A declaration, assignment, or loop target replaces the imported # binding only for calls at or after its source line. - targets.setdefault(binding, []).append((line, None)) - for history in targets.values(): - history.sort(key=lambda item: item[0]) + targets.setdefault(None, {}).setdefault(binding, []).append((line, None)) + for scope_targets in targets.values(): + for history in scope_targets.values(): + history.sort(key=lambda item: item[0]) return targets diff --git a/tests/test_structural.py b/tests/test_structural.py index e1fe60b..a9c865b 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -1370,6 +1370,31 @@ def test_explicit_from_import_creates_import_grounded_call_edge(tmp_path): assert "query=python-structure-v2" in call["evidence"][0]["detail"] +def test_function_local_imports_ground_only_their_lexical_calls(tmp_path): + """A local import is exact evidence for its own function, not its siblings.""" + root = repo(tmp_path) + write( + root, + "local_import.py", + "def imports_and_calls():\n" + " from remote.worker import execute as run_remote\n" + " run_remote()\n\n" + "def sibling():\n" + " run_remote()\n", + ) + git(root, "add", "local_import.py") + + artifact = analyze_local_diff(str(root), staged=True) + + assert_valid(artifact) + calls = [item for item in artifact["relationships"] if item["kind"] == "calls"] + assert len(calls) == 1 + assert calls[0]["source_id"] == "sym::local_import.py::imports_and_calls" + assert calls[0]["target_id"] == "sym::local_import.py::import::remote.worker" + assert calls[0]["resolution_method"] == "import_grounded" + assert calls[0]["evidence"][0]["snippet"] == "run_remote()" + + def test_relative_from_imports_preserve_package_evidence_and_call_bindings(tmp_path): """Relative imports are explicit package-local external dependencies. From 6e8f523f3da2d57b44f27445c3c6e6d9506fdb43 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Sat, 19 Sep 2026 13:32:37 +0530 Subject: [PATCH 2/2] fix(structural): resolve class-body imports lexically --- diffgraph/structural.py | 14 +++++++++----- tests/test_structural.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 0c51b27..c775e0e 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -566,17 +566,21 @@ def _resolve_call_target( current = symbols.get(current_name) if current is None: break - # Function and method scopes participate in lexical lookup. Class - # namespaces do not: a bare name in a method never resolves through - # sibling class attributes or methods. - if current.kind in ("function", "method"): + # Function and method scopes participate in lexical lookup. A class + # body also needs its own imports, but a method must never resolve + # through its enclosing class namespace. + is_initial_class_body = ( + current_name == call.caller and current.kind == "class" + ) + if current.kind in ("function", "method") or is_initial_class_body: if call.name in bindings.get(current_name, set()): history = imported_targets.get(current_name, {}).get(call.name, []) visible = [target for line, target in history if line <= call.line] if visible: return visible[-1] return None - candidates.append("{}.{}".format(current_name, call.name)) + if current.kind in ("function", "method"): + candidates.append("{}.{}".format(current_name, call.name)) current_name = current.parent if call.name in bindings.get(None, set()): diff --git a/tests/test_structural.py b/tests/test_structural.py index a9c865b..881c35e 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -1395,6 +1395,38 @@ def test_function_local_imports_ground_only_their_lexical_calls(tmp_path): assert calls[0]["evidence"][0]["snippet"] == "run_remote()" +def test_class_body_imports_do_not_shadow_nested_method_lookups(tmp_path): + """Class-body imports resolve there but remain outside method lexical scope.""" + root = repo(tmp_path) + write( + root, + "class_import.py", + "def enclosing():\n" + " from outer.worker import execute as run_remote\n\n" + " class Worker:\n" + " from class_body.worker import execute as run_remote\n" + " run_remote()\n\n" + " def method(self):\n" + " run_remote()\n", + ) + git(root, "add", "class_import.py") + + artifact = analyze_local_diff(str(root), staged=True) + + assert_valid(artifact) + calls = [item for item in artifact["relationships"] if item["kind"] == "calls"] + assert len(calls) == 2 + assert [item["source_id"] for item in calls] == [ + "sym::class_import.py::enclosing.Worker", + "sym::class_import.py::enclosing.Worker.method", + ] + assert [item["target_id"] for item in calls] == [ + "sym::class_import.py::import::class_body.worker", + "sym::class_import.py::import::outer.worker", + ] + assert all(item["resolution_method"] == "import_grounded" for item in calls) + + def test_relative_from_imports_preserve_package_evidence_and_call_bindings(tmp_path): """Relative imports are explicit package-local external dependencies.