From ecfa2e62701c76ea76bf9686a756b2beb60789e6 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 14 Sep 2026 15:04:45 +0530 Subject: [PATCH] feat(structural): preserve relative import evidence --- diffgraph/structural.py | 14 +++++++++++++- tests/test_structural.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 6b5c32f..23e04ac 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -340,7 +340,19 @@ def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None: raw, node.start_point[0] + 1, snippet, (binding,) )) else: - module_node = node.child_by_field_name("module_name") + # Tree-sitter exposes absolute modules through ``module_name`` + # but represents ``from .pkg import value`` as a + # ``relative_import`` child. Keep the leading dots instead of + # dropping the import: they are deterministic source evidence + # and distinguish a package-local dependency from an unrelated + # absolute module with the same name. + module_node = ( + node.child_by_field_name("module_name") + or next( + (child for child in node.children if child.type == "relative_import"), + None, + ) + ) if module_node is not None: imported = [] after_import = False diff --git a/tests/test_structural.py b/tests/test_structural.py index 9fcf591..e1fe60b 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -1370,6 +1370,46 @@ def test_explicit_from_import_creates_import_grounded_call_edge(tmp_path): assert "query=python-structure-v2" in call["evidence"][0]["detail"] +def test_relative_from_imports_preserve_package_evidence_and_call_bindings(tmp_path): + """Relative imports are explicit package-local external dependencies. + + The extractor intentionally does not guess whether the target module is in + the changed snapshot, but it must preserve the exact relative spelling and + alias binding so clients can distinguish ``..pkg`` from an absolute module. + """ + root = repo(tmp_path) + write( + root, + "relative_calls.py", + "from ..pkg import execute as run_local\n\n" + "def caller():\n" + " run_local()\n", + ) + git(root, "add", "relative_calls.py") + + artifact = analyze_local_diff(str(root), staged=True) + assert_valid(artifact) + imported = next( + item + for item in artifact["symbols"] + if item["id"] == "sym::relative_calls.py::import::..pkg" + ) + assert imported["name"] == "..pkg" + assert imported["change_kind"] == "added" + import_edge = next( + item + for item in artifact["relationships"] + if item["kind"] == "imports" + ) + assert import_edge["label"] == "unresolved/external module: ..pkg" + calls = [item for item in artifact["relationships"] if item["kind"] == "calls"] + assert len(calls) == 1 + assert calls[0]["source_id"] == "sym::relative_calls.py::caller" + assert calls[0]["target_id"] == "sym::relative_calls.py::import::..pkg" + assert calls[0]["resolution_method"] == "import_grounded" + assert calls[0]["evidence"][0]["snippet"] == "run_local()" + + def test_rebound_import_does_not_create_import_grounded_call_edge(tmp_path): root = repo(tmp_path) write(