From a186569d2779470c289f6e87dbeb6289fee76e09 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 17 Aug 2026 09:05:51 +0530 Subject: [PATCH 1/5] feat(structural): emit conservative Python call edges --- diffgraph/structural.py | 139 +++++++++++++++++++++++++++++-- tests/fixtures/python_calls.json | 34 ++++++++ tests/test_structural.py | 54 ++++++++++++ 3 files changed, 222 insertions(+), 5 deletions(-) create mode 100644 tests/fixtures/python_calls.json diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 1aef770..f6b0fa0 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -59,6 +59,14 @@ class _Import: snippet: str +@dataclass(frozen=True) +class _Call: + caller: Optional[str] + name: str + line: int + snippet: str + + def _blob(repository: str, oid: Optional[str]) -> Optional[bytes]: if oid is None: return None @@ -128,7 +136,9 @@ def _name_child(node): return next((item for item in node.children if item.type == "identifier"), None) -def _parse_python(content: bytes) -> Tuple[List[_Symbol], List[_Import]]: +def _parse_python( + content: bytes, +) -> Tuple[List[_Symbol], List[_Import], List[_Call], Dict[Optional[str], set]]: # Do not silently replace undecodable source: the warning must identify the # exact side that could not be structurally analyzed. content.decode("utf-8") @@ -138,8 +148,18 @@ def _parse_python(content: bytes) -> Tuple[List[_Symbol], List[_Import]]: symbols: List[_Symbol] = [] imports: List[_Import] = [] + calls: List[_Call] = [] + bindings: Dict[Optional[str], set] = {} symbol_occurrences: Dict[str, int] = {} + def identifiers(node) -> set: + found = set() + if node.type == "identifier": + found.add(_node_text(content, node)) + for child in node.children: + found.update(identifiers(child)) + return found + def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None: next_parents = parents if node.type in ("class_definition", "function_definition"): @@ -193,12 +213,36 @@ def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None: module_node = node.child_by_field_name("module_name") if module_node is not None: imports.append(_Import(_node_text(content, module_node), node.start_point[0] + 1, snippet)) + elif node.type == "call": + function = node.child_by_field_name("function") + if function is not None and function.type == "identifier": + calls.append( + _Call( + parents[-1][0] if parents else None, + _node_text(content, function), + node.start_point[0] + 1, + _node_text(content, node), + ) + ) + + scope = parents[-1][0] if parents else None + if node.type == "parameters" or node.type in ( + "import_statement", "import_from_statement" + ): + bindings.setdefault(scope, set()).update(identifiers(node)) + elif node.type in ("assignment", "annotated_assignment", "for_statement"): + left = node.child_by_field_name("left") + if left is not None: + bindings.setdefault(scope, set()).update(identifiers(left)) for child in node.children: visit(child, next_parents) visit(tree.root_node) - return sorted(symbols, key=lambda s: (s.qualified_name, s.start_line)), sorted( - imports, key=lambda item: (item.line, item.module, item.snippet) + return ( + sorted(symbols, key=lambda s: (s.qualified_name, s.start_line)), + sorted(imports, key=lambda item: (item.line, item.module, item.snippet)), + sorted(calls, key=lambda item: (item.line, item.caller or "", item.name, item.snippet)), + bindings, ) @@ -280,6 +324,50 @@ def _keyed_imports(items: List[_Import]) -> Dict[Tuple[str, int], _Import]: return result +def _resolve_call_target( + call: _Call, + symbols: Dict[str, _Symbol], + bindings: Dict[Optional[str], set], +) -> Optional[str]: + """Resolve only syntax-grounded, same-file Python calls. + + Bare identifiers shadowed by a parameter, assignment, loop target, or import + are deliberately left unresolved. Attribute calls and ambiguous duplicate + definitions are likewise omitted rather than guessed. + """ + if call.name in bindings.get(call.caller, set()): + return None + + candidates: List[str] = [] + if call.caller is not None: + # A function can call a function defined in its own local scope. + candidates.append("{}.{}".format(call.caller, call.name)) + current = symbols.get(call.caller) + parent = current.parent if current is not None else None + while parent is not None: + parent_symbol = symbols.get(parent) + if parent_symbol is None: + break + # Bare names do not resolve to sibling methods through a class. + if parent_symbol.kind == "function": + candidates.append("{}.{}".format(parent, call.name)) + parent = parent_symbol.parent + candidates.append(call.name) + + for candidate in candidates: + matches = [ + qname + for qname, symbol in symbols.items() + if symbol.kind in ("function", "class") + and (qname == candidate or qname.startswith(candidate + "#")) + ] + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + return None + return None + + def analyze_local_diff( repository: str = ".", *, staged: bool = False, pathspecs: Optional[Sequence[str]] = None, @@ -361,13 +449,17 @@ def analyze_local_diff( UnicodeDecodeError, ValueError, RuntimeError, OSError, TypeError ) try: - old_symbols, old_imports = _parse_python(old) if old is not None else ([], []) + old_symbols, old_imports, _old_calls, _old_bindings = ( + _parse_python(old) if old is not None else ([], [], [], {}) + ) except parser_errors as error: warnings.append(_warning("PARSE_FAILURE", entry.old_path or path, "pre-change: {}: {}".format(type(error).__name__, error))) skipped += 1 continue try: - new_symbols, new_imports = _parse_python(new) if new is not None else ([], []) + new_symbols, new_imports, new_calls, new_bindings = ( + _parse_python(new) if new is not None else ([], [], [], {}) + ) except parser_errors as error: warnings.append(_warning("PARSE_FAILURE", path, "post-change: {}: {}".format(type(error).__name__, error))) skipped += 1 @@ -481,6 +573,43 @@ def analyze_local_diff( } ) + call_occurrences: Dict[Tuple[str, str], int] = {} + for call in new_calls: + target_qname = _resolve_call_target(call, new_map, new_bindings) + if target_qname is None: + continue + source = ( + _symbol_id(output_path, call.caller) + if call.caller is not None + else "file::" + output_path + ) + target = _symbol_id(output_path, target_qname) + edge = (source, target) + occurrence = call_occurrences.get(edge, 0) + call_occurrences[edge] = occurrence + 1 + suffix = "" if occurrence == 0 else "#{}".format(occurrence) + relationships.append( + { + "id": "rel::{}->{}{}".format(source, target, suffix), + "kind": "calls", + "source_id": source, + "target_id": target, + "analysis_source": "structural", + "resolution_method": "resolved", + "confidence": None, + "evidence": [ + { + "kind": "call_site", + "file": output_path, + "line_start": call.line, + "line_end": call.line, + "snippet": call.snippet, + "detail": _parser_provenance(entry.new_oid), + } + ], + } + ) + files.sort(key=lambda item: item["path"]) symbols.sort(key=lambda item: item["id"]) relationships.sort(key=lambda item: (item["id"], item["kind"])) diff --git a/tests/fixtures/python_calls.json b/tests/fixtures/python_calls.json new file mode 100644 index 0000000..72d7963 --- /dev/null +++ b/tests/fixtures/python_calls.json @@ -0,0 +1,34 @@ +[ + { + "id": "rel::file::calls.py->sym::calls.py::helper", + "source_id": "file::calls.py", + "target_id": "sym::calls.py::helper", + "resolution_method": "resolved", + "line": 23, + "snippet": "helper()" + }, + { + "id": "rel::sym::calls.py::caller->sym::calls.py::helper", + "source_id": "sym::calls.py::caller", + "target_id": "sym::calls.py::helper", + "resolution_method": "resolved", + "line": 5, + "snippet": "helper()" + }, + { + "id": "rel::sym::calls.py::caller->sym::calls.py::helper#1", + "source_id": "sym::calls.py::caller", + "target_id": "sym::calls.py::helper", + "resolution_method": "resolved", + "line": 6, + "snippet": "helper()" + }, + { + "id": "rel::sym::calls.py::outer->sym::calls.py::outer.nested", + "source_id": "sym::calls.py::outer", + "target_id": "sym::calls.py::outer.nested", + "resolution_method": "resolved", + "line": 11, + "snippet": "nested()" + } +] diff --git a/tests/test_structural.py b/tests/test_structural.py index c272c77..9f31bbe 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -730,3 +730,57 @@ def without_spinner(name, *args, **kwargs): assert result.exit_code == 1 assert "requires additional dependencies" in result.output assert "Traceback" not in result.output + + +def test_python_calls_are_conservative_schema_valid_and_golden(tmp_path): + root = repo(tmp_path) + write( + root, + "calls.py", + "def helper():\n" + " return 1\n\n" + "def caller():\n" + " helper()\n" + " helper()\n\n" + "def outer():\n" + " def nested():\n" + " return 1\n" + " nested()\n\n" + "def parameter_shadow(helper):\n" + " helper()\n\n" + "def assignment_shadow():\n" + " helper = lambda: 2\n" + " helper()\n\n" + "def attribute_call(service):\n" + " service.helper()\n\n" + "helper()\n", + ) + git(root, "add", "calls.py") + + artifact = analyze_local_diff(str(root), staged=True) + assert_valid(artifact) + calls = [item for item in artifact["relationships"] if item["kind"] == "calls"] + actual = [ + { + "id": item["id"], + "source_id": item["source_id"], + "target_id": item["target_id"], + "resolution_method": item["resolution_method"], + "line": item["evidence"][0]["line_start"], + "snippet": item["evidence"][0]["snippet"], + } + for item in calls + ] + golden_path = Path(__file__).parent / "fixtures/python_calls.json" + expected = json.loads(golden_path.read_text()) + if os.environ.get("UPDATE_GOLDEN"): + golden_path.write_text(json.dumps(actual, indent=2) + "\n") + pytest.skip("golden fixture regenerated") + assert actual == expected + + # Parameter/local bindings and attribute dispatch are intentionally not + # guessed. Every emitted edge has exact call-site/parser/blob evidence. + assert len(calls) == 4 + assert all(item["analysis_source"] == "structural" for item in calls) + assert all(item["confidence"] is None for item in calls) + assert all("blob=" in item["evidence"][0]["detail"] for item in calls) From c4feee5fdb0309667b70fa24dd831d3eb34e144f Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 17 Aug 2026 09:06:24 +0530 Subject: [PATCH 2/5] docs: define structural call resolution boundary --- README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 986200a..35dfce5 100644 --- a/README.md +++ b/README.md @@ -109,9 +109,13 @@ this baseline. Other changed files remain in `files[]` and receive a scoped `UNSUPPORTED_LANGUAGE` warning. Syntax/decoding failures receive a scoped `PARSE_FAILURE` warning and do not produce invented symbol changes. Import targets are explicitly labeled unresolved/external; no project-wide resolution -is claimed. Every file records old/new paths, modes, Git object IDs, and content -SHA-256 values in structural evidence, while symbol/relationship evidence names -the parser package, query revision, and source blob identity. +is claimed. Python call edges are emitted only for bare calls that resolve to an +unambiguous function or class in the same file. Attribute dispatch, imported +calls, and names shadowed by parameters or local assignments remain absent +rather than being guessed. Every file records old/new paths, modes, Git object +IDs, and content SHA-256 values in structural evidence, while +symbol/relationship evidence names the parser package, query revision, source +blob identity, and exact call site where applicable. #### CLI and offline contract From a1f159b1e0beffbeb4e624e56c4fcfb5c089a150 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Mon, 17 Aug 2026 09:09:23 +0530 Subject: [PATCH 3/5] fix(structural): attribute default calls to enclosing scope --- diffgraph/structural.py | 14 +++++++++++++- tests/fixtures/python_calls.json | 8 ++++++++ tests/test_structural.py | 4 +++- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/diffgraph/structural.py b/diffgraph/structural.py index f6b0fa0..3e3ff2c 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -216,9 +216,21 @@ def visit(node, parents: Tuple[Tuple[str, str], ...] = ()) -> None: elif node.type == "call": function = node.child_by_field_name("function") if function is not None and function.type == "identifier": + caller = parents[-1][0] if parents else None + ancestor = node.parent + while ancestor is not None: + if ancestor.type in ("class_definition", "function_definition"): + body = ancestor.child_by_field_name("body") + if body is not None and not ( + body.start_byte <= node.start_byte + and node.end_byte <= body.end_byte + ): + caller = parents[-2][0] if len(parents) > 1 else None + break + ancestor = ancestor.parent calls.append( _Call( - parents[-1][0] if parents else None, + caller, _node_text(content, function), node.start_point[0] + 1, _node_text(content, node), diff --git a/tests/fixtures/python_calls.json b/tests/fixtures/python_calls.json index 72d7963..a7ccdea 100644 --- a/tests/fixtures/python_calls.json +++ b/tests/fixtures/python_calls.json @@ -7,6 +7,14 @@ "line": 23, "snippet": "helper()" }, + { + "id": "rel::file::calls.py->sym::calls.py::helper#1", + "source_id": "file::calls.py", + "target_id": "sym::calls.py::helper", + "resolution_method": "resolved", + "line": 26, + "snippet": "helper()" + }, { "id": "rel::sym::calls.py::caller->sym::calls.py::helper", "source_id": "sym::calls.py::caller", diff --git a/tests/test_structural.py b/tests/test_structural.py index 9f31bbe..a367e70 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -753,6 +753,8 @@ def test_python_calls_are_conservative_schema_valid_and_golden(tmp_path): " helper()\n\n" "def attribute_call(service):\n" " service.helper()\n\n" + "def default_call(value=helper()):\n" + " return value\n\n" "helper()\n", ) git(root, "add", "calls.py") @@ -780,7 +782,7 @@ def test_python_calls_are_conservative_schema_valid_and_golden(tmp_path): # Parameter/local bindings and attribute dispatch are intentionally not # guessed. Every emitted edge has exact call-site/parser/blob evidence. - assert len(calls) == 4 + assert len(calls) == 5 assert all(item["analysis_source"] == "structural" for item in calls) assert all(item["confidence"] is None for item in calls) assert all("blob=" in item["evidence"][0]["detail"] for item in calls) From 2842bb450b0a5dd45564ae99dfc746bf2431c75a Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Tue, 18 Aug 2026 15:55:35 +0530 Subject: [PATCH 4/5] fix(structural): version call resolution provenance --- diffgraph/structural.py | 2 +- tests/test_structural.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 4df54d4..b2aef93 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -33,7 +33,7 @@ ) ANALYZER = "diffgraph-python-tree-sitter" -QUERY_VERSION = "python-structure-v1" +QUERY_VERSION = "python-structure-v2" _PARSER_STATE = threading.local() diff --git a/tests/test_structural.py b/tests/test_structural.py index a64aa43..b9713df 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -966,4 +966,5 @@ def test_python_calls_are_conservative_schema_valid_and_golden(tmp_path): assert len(calls) == 5 assert all(item["analysis_source"] == "structural" for item in calls) assert all(item["confidence"] is None for item in calls) + assert all("query=python-structure-v2" in item["evidence"][0]["detail"] for item in calls) assert all("blob=" in item["evidence"][0]["detail"] for item in calls) From 23016fcf7d2851419f9da7ac22bcfb2ae45248f6 Mon Sep 17 00:00:00 2001 From: nia-sg-bot Date: Tue, 18 Aug 2026 17:49:40 +0530 Subject: [PATCH 5/5] fix(structural): honor enclosing lexical shadows --- diffgraph/structural.py | 32 ++++++++++++++++---------------- tests/test_structural.py | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 17 deletions(-) diff --git a/diffgraph/structural.py b/diffgraph/structural.py index b2aef93..0e768f5 100644 --- a/diffgraph/structural.py +++ b/diffgraph/structural.py @@ -357,23 +357,23 @@ def _resolve_call_target( are deliberately left unresolved. Attribute calls and ambiguous duplicate definitions are likewise omitted rather than guessed. """ - if call.name in bindings.get(call.caller, set()): - return None - candidates: List[str] = [] - if call.caller is not None: - # A function can call a function defined in its own local scope. - candidates.append("{}.{}".format(call.caller, call.name)) - current = symbols.get(call.caller) - parent = current.parent if current is not None else None - while parent is not None: - parent_symbol = symbols.get(parent) - if parent_symbol is None: - break - # Bare names do not resolve to sibling methods through a class. - if parent_symbol.kind == "function": - candidates.append("{}.{}".format(parent, call.name)) - parent = parent_symbol.parent + current_name = call.caller + while current_name is not None: + 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"): + if call.name in bindings.get(current_name, set()): + return None + candidates.append("{}.{}".format(current_name, call.name)) + current_name = current.parent + + if call.name in bindings.get(None, set()): + return None candidates.append(call.name) for candidate in candidates: diff --git a/tests/test_structural.py b/tests/test_structural.py index b9713df..9e48ad6 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -936,7 +936,16 @@ def test_python_calls_are_conservative_schema_valid_and_golden(tmp_path): " service.helper()\n\n" "def default_call(value=helper()):\n" " return value\n\n" - "helper()\n", + "helper()\n\n" + "def closure_shadow():\n" + " helper = lambda: 3\n" + " def inner():\n" + " helper()\n\n" + "class MethodShadow:\n" + " def method(self):\n" + " helper = lambda: 4\n" + " def inner():\n" + " helper()\n", ) git(root, "add", "calls.py") @@ -966,5 +975,12 @@ def test_python_calls_are_conservative_schema_valid_and_golden(tmp_path): assert len(calls) == 5 assert all(item["analysis_source"] == "structural" for item in calls) assert all(item["confidence"] is None for item in calls) + shadowed_callers = { + "sym::calls.py::closure_shadow.inner", + "sym::calls.py::MethodShadow.method.inner", + } + symbol_ids = {item["id"] for item in artifact["symbols"]} + assert shadowed_callers <= symbol_ids + assert shadowed_callers.isdisjoint(item["source_id"] for item in calls) assert all("query=python-structure-v2" in item["evidence"][0]["detail"] for item in calls) assert all("blob=" in item["evidence"][0]["detail"] for item in calls)