diff --git a/README.md b/README.md index 068e0a3..bbb74a2 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 diff --git a/diffgraph/structural.py b/diffgraph/structural.py index 0b6685f..0e768f5 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() @@ -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 @@ -138,7 +146,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") @@ -148,8 +158,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"): @@ -203,12 +223,48 @@ 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": + 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( + caller, + _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, ) @@ -290,6 +346,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. + """ + candidates: List[str] = [] + 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: + 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, @@ -386,13 +486,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 @@ -506,6 +610,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..a7ccdea --- /dev/null +++ b/tests/fixtures/python_calls.json @@ -0,0 +1,42 @@ +[ + { + "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::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", + "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 1dcb041..9e48ad6 100644 --- a/tests/test_structural.py +++ b/tests/test_structural.py @@ -911,3 +911,76 @@ 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" + "def default_call(value=helper()):\n" + " return value\n\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") + + 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) == 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)