diff --git a/libs/openant-core/parsers/zig/call_graph_builder.py b/libs/openant-core/parsers/zig/call_graph_builder.py index fbd6fd59..bca6dda5 100644 --- a/libs/openant-core/parsers/zig/call_graph_builder.py +++ b/libs/openant-core/parsers/zig/call_graph_builder.py @@ -6,7 +6,7 @@ import re from collections import defaultdict -from typing import Dict, Any, List, Set +from typing import Any, Dict, List, Optional, Set from utilities.file_io import write_json @@ -137,37 +137,29 @@ def __init__(self, extractor_output: Dict[str, Any]): self.imports = extractor_output.get("imports", {}) self.repository = extractor_output.get("repository", "") self.parser = Parser(self.ZIG_LANGUAGE) + # Populated by build_call_graph(); read via the canonical API (export/get_statistics/...). + self.call_graph: Dict[str, List[str]] = {} + self.reverse_call_graph: Dict[str, List[str]] = {} - def build(self) -> Dict[str, Any]: - """ - Build the call graph. - - Returns call_graph.json structure with: - - functions (copied from extractor) - - classes (copied from extractor) - - imports (copied from extractor) - - call_graph: {caller_id: [callee_ids]} - - reverse_call_graph: {callee_id: [caller_ids]} + def build_call_graph(self) -> None: + """Build the bidirectional call graph, populating self.call_graph / self.reverse_call_graph. + + Canonical API (parity with the c/php/python/ruby CallGraphBuilder): mutates state and returns + None. Read results via export() / get_statistics() / get_dependencies() / get_callers(). """ call_graph: Dict[str, List[str]] = defaultdict(list) reverse_call_graph: Dict[str, List[str]] = defaultdict(list) - # Build an index of function names to IDs for resolution name_to_ids = self._build_name_index() - # For each function, find calls in its body for func_id, func_info in self.functions.items(): code = func_info.get("code", "") file_path = func_info.get("file_path", "") - # Parse the function code to find call sites calls = self._find_calls_in_code(code) - # Resolve each call to a function ID for call_name in calls: - resolved_ids = self._resolve_call( - call_name, file_path, name_to_ids - ) + resolved_ids = self._resolve_call(call_name, file_path, name_to_ids) for resolved_id in resolved_ids: if resolved_id != func_id: # No self-calls if resolved_id not in call_graph[func_id]: @@ -175,35 +167,85 @@ def build(self) -> Dict[str, Any]: if func_id not in reverse_call_graph[resolved_id]: reverse_call_graph[resolved_id].append(func_id) - # Calculate statistics - total_edges = sum(len(callees) for callees in call_graph.values()) - out_degrees = [len(callees) for callees in call_graph.values()] - avg_out_degree = total_edges / len(self.functions) if self.functions else 0 - max_out_degree = max(out_degrees) if out_degrees else 0 - isolated = len( - [ - f - for f in self.functions - if f not in call_graph and f not in reverse_call_graph - ] - ) + self.call_graph = dict(call_graph) + self.reverse_call_graph = dict(reverse_call_graph) + def build(self) -> Dict[str, Any]: + """Back-compat wrapper: build the graph and return the exported dict. + + Retained because the pipeline (zig/test_pipeline.py) calls build() and consumes its return + value; new code should use build_call_graph() + export() to match the canonical API. + """ + self.build_call_graph() + return self.export() + + def export(self) -> Dict[str, Any]: + """Export the call graph in the canonical schema.""" return { "repository": self.repository, "functions": self.functions, "classes": self.classes, "imports": self.imports, - "call_graph": dict(call_graph), - "reverse_call_graph": dict(reverse_call_graph), - "statistics": { - "total_functions": len(self.functions), - "total_edges": total_edges, - "avg_out_degree": round(avg_out_degree, 2), - "max_out_degree": max_out_degree, - "isolated_functions": isolated, - }, + "call_graph": self.call_graph, + "reverse_call_graph": self.reverse_call_graph, + "statistics": self.get_statistics(), } + def get_statistics(self) -> Dict[str, Any]: + """Compute call-graph statistics (parity with the canonical builders, incl. in-degree).""" + total_edges = sum(len(callees) for callees in self.call_graph.values()) + num_funcs = len(self.functions) + out_degrees = [len(self.call_graph.get(f, [])) for f in self.functions] + in_degrees = [len(self.reverse_call_graph.get(f, [])) for f in self.functions] + isolated = sum( + 1 + for f in self.functions + if not self.call_graph.get(f) and not self.reverse_call_graph.get(f) + ) + return { + "total_functions": num_funcs, + "total_edges": total_edges, + "avg_out_degree": round(total_edges / num_funcs, 2) if num_funcs else 0, + "avg_in_degree": round(total_edges / num_funcs, 2) if num_funcs else 0, + "max_out_degree": max(out_degrees) if out_degrees else 0, + "max_in_degree": max(in_degrees) if in_degrees else 0, + "isolated_functions": isolated, + } + + def get_dependencies(self, func_id: str, depth: Optional[int] = None) -> List[str]: + """Get transitive callees of func_id up to depth (BFS); parity with canonical.""" + max_d = depth if depth is not None else 3 + deps: List[str] = [] + visited = {func_id} + queue = [(func_id, 0)] + while queue: + current, d = queue.pop(0) + if d >= max_d: + continue + for callee in self.call_graph.get(current, []): + if callee not in visited: + visited.add(callee) + deps.append(callee) + queue.append((callee, d + 1)) + return deps + + def get_callers(self, func_id: str, depth: Optional[int] = None) -> List[str]: + """Get transitive callers of func_id up to depth (BFS); parity with canonical.""" + max_d = depth if depth is not None else 3 + callers: List[str] = [] + visited = {func_id} + queue = [(func_id, 0)] + while queue: + current, d = queue.pop(0) + if d >= max_d: + continue + for caller in self.reverse_call_graph.get(current, []): + if caller not in visited: + visited.add(caller) + callers.append(caller) + queue.append((caller, d + 1)) + return callers + def _build_name_index(self) -> Dict[str, List[str]]: """Build index from function names to function IDs.""" name_to_ids: Dict[str, List[str]] = defaultdict(list) @@ -239,25 +281,49 @@ def _extract_calls_from_node( self, node: Node, source: bytes, calls: Set[str] ) -> None: """Recursively extract call sites from AST nodes.""" - # Look for function call expressions - if node.type in ("call_expr", "call_expression", "CallExpr"): - # Get the function being called - for child in node.children: - if child.type in ("identifier", "IDENTIFIER", "field_access"): - call_name = self._get_node_text(child, source) - # Handle method calls (obj.method) - if "." in call_name: - parts = call_name.split(".") - calls.add(parts[-1]) # Add just the method name - calls.add(call_name) # Also add the full qualified name - else: - calls.add(call_name) - break + if node.type in ("call_expression", "call_expr", "CallExpr"): + # The callee is the first child: an `identifier` for a plain call foo(), or a + # `field_expression` for a method/namespaced call obj.method() / mod.func(). + callee = node.children[0] if node.children else None + if callee is not None and callee.type in ("identifier", "IDENTIFIER"): + calls.add(self._get_node_text(callee, source)) + elif callee is not None and callee.type in ("field_expression", "field_access"): + text = self._get_node_text(callee, source) + calls.add(text.split(".")[-1]) # trailing member (method / func name) + calls.add(text) # also the full dotted form + elif node.type == "builtin_function": + # @call(.modifier, realFn, argsTuple): the wrapped function is the real call target; + # other @builtins are filtered out downstream. + self._extract_builtin_call_target(node, source, calls) # Recurse into children for child in node.children: self._extract_calls_from_node(child, source, calls) + def _extract_builtin_call_target( + self, node: Node, source: bytes, calls: Set[str] + ) -> None: + """For Zig `@call(.modifier, fn, args)`, add `fn` as a call target (other @builtins: no-op).""" + builtin = "" + args = None + for child in node.children: + if child.type == "builtin_identifier": + builtin = self._get_node_text(child, source) + elif child.type == "arguments": + args = child + if builtin != "@call" or args is None: + return + # arguments: '(' , <.modifier field_expression> , ',' , , ... + for child in args.children: + if child.type not in ("identifier", "field_expression"): + continue + text = self._get_node_text(child, source) + if text.startswith("."): + continue # the leading `.auto`/`.always_inline` call modifier, not the function + calls.add(text.split(".")[-1]) + calls.add(text) + return + def _find_calls_with_regex(self, code: str) -> Set[str]: """Fallback regex-based call detection.""" calls = set() @@ -305,20 +371,28 @@ def _resolve_call( if same_file: return same_file - # 2. Check imported files + # 2. Check imported files. Match by the imported FILE name (not an unanchored substring), + # and skip non-file stdlib imports (@import("std")/("builtin")/("root")) which would + # otherwise substring-match unrelated candidate paths. file_imports = self.imports.get(caller_file, []) for candidate in candidates: candidate_file = candidate.split(":")[0] for imp in file_imports: - if imp in candidate_file or candidate_file.endswith(imp): + if not imp.endswith(".zig"): + continue # std / builtin / root are not file imports + if candidate_file == imp or candidate_file.endswith("/" + imp): return [candidate] # 3. If unique match, use it if len(candidates) == 1: return candidates - # Multiple matches, return all (conservative) - return candidates + # 4. Ambiguous across multiple files with no import resolving it. Do NOT emit edges to every + # same-named symbol -- that over-connection is a namespace leak (a.deinit() would link to + # every struct's deinit). Resolving the receiver's type needs info the extractor does not + # carry, so the precise target is unknown; return nothing rather than over-connect. + # Trade-off: lowers recall for genuinely-ambiguous bare-name calls to raise precision. + return [] def save_results(self, output_path: str, results: Dict[str, Any]) -> None: """Save call graph to a JSON file.""" diff --git a/libs/openant-core/pyproject.toml b/libs/openant-core/pyproject.toml index bf0377a8..1f23d38a 100644 --- a/libs/openant-core/pyproject.toml +++ b/libs/openant-core/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "tree-sitter-cpp>=0.21.0", "tree-sitter-ruby>=0.21.0", "tree-sitter-php>=0.22.0", + "tree-sitter-zig==1.1.2", ] [project.optional-dependencies] diff --git a/libs/openant-core/requirements.txt b/libs/openant-core/requirements.txt index 966904a8..c96e4bad 100644 --- a/libs/openant-core/requirements.txt +++ b/libs/openant-core/requirements.txt @@ -22,3 +22,4 @@ tree-sitter-c>=0.21.0 tree-sitter-cpp>=0.21.0 tree-sitter-ruby>=0.21.0 tree-sitter-php>=0.22.0 +tree-sitter-zig==1.1.2 diff --git a/libs/openant-core/tests/test_zig_call_graph_builder.py b/libs/openant-core/tests/test_zig_call_graph_builder.py new file mode 100644 index 00000000..cee01d1a --- /dev/null +++ b/libs/openant-core/tests/test_zig_call_graph_builder.py @@ -0,0 +1,82 @@ +"""Regression tests for defects in parsers/zig/call_graph_builder.py. + +API parity: zig exposed build()->Dict + save_results() only, missing the canonical + build_call_graph()->None / export() / get_statistics() / get_dependencies() / get_callers() surface. +Statistics: statistics omitted avg_in_degree / max_in_degree. +@call extraction: @call(.modifier, fn, args) -- a `builtin_function` node -- dropped its + wrapped function, so the real callee was never recorded. +Method-call recall + safe resolution: method calls (`obj.method()`, a `call_expression` over a + `field_expression`) were not extracted at all -- the code checked the non-existent `field_access` + node type -- a recall gap; and _resolve_call returned ALL same-named candidates on a bare-name + collision (a namespace leak). Fixed: extract field_expression callees + + resolve conservatively (return [] when genuinely ambiguous). +Import-file matching: import resolution used an unanchored `imp in candidate_file` substring. +Stdlib import filter: @import("std")/("builtin") leaked in and substring-matched candidate paths. +(Phantom field: `indirect_calls` is a phantom field -- 0 repo hits, by design -- documented, + no code change.) + +Loads the module under a UNIQUE importlib name (call_graph_builder is a basename shared by every parser). +""" +import importlib.util +import sys +from pathlib import Path + +CORE = Path(__file__).resolve().parents[1] +if str(CORE) not in sys.path: + sys.path.insert(0, str(CORE)) +_spec = importlib.util.spec_from_file_location( + "zig_call_graph_builder_isolated", str(CORE / "parsers" / "zig" / "call_graph_builder.py")) +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) +CallGraphBuilder = _mod.CallGraphBuilder + + +def _ext(funcs, imports=None): + return {"functions": funcs, "classes": {}, "imports": imports or {}, "repository": "/r"} + + +# canonical API parity + in-degree statistics +def test_canonical_api_parity_and_in_degree_stats(): + b = CallGraphBuilder(_ext({ + "a.zig:f": {"name": "f", "file_path": "a.zig", "code": "fn f() void { g(); }"}, + "a.zig:g": {"name": "g", "file_path": "a.zig", "code": "fn g() void {}"}, + })) + for m in ("build_call_graph", "export", "get_statistics", "get_dependencies", "get_callers"): + assert hasattr(b, m), f"missing canonical API method: {m}" + b.build_call_graph() # mutates state, returns None + assert b.call_graph.get("a.zig:f") == ["a.zig:g"], b.call_graph + stats = b.export()["statistics"] + assert "avg_in_degree" in stats and "max_in_degree" in stats, stats + assert b.get_dependencies("a.zig:f") == ["a.zig:g"] + assert b.get_callers("a.zig:g") == ["a.zig:f"] + + +# @call extracts the wrapped function +def test_at_call_extracts_wrapped_function(): + out = CallGraphBuilder(_ext({ + "a.zig:f": {"name": "f", "file_path": "a.zig", "code": "fn f() void { @call(.auto, g, .{}); }"}, + "a.zig:g": {"name": "g", "file_path": "a.zig", "code": "fn g() void {}"}, + })).build() + assert out["call_graph"].get("a.zig:f") == ["a.zig:g"], \ + f"@call(.auto, g, ...) should edge to g, got {out['call_graph']}" + + +# method-call recall via field_expression +def test_method_call_extracted_via_field_expression(): + out = CallGraphBuilder(_ext({ + "a.zig:f": {"name": "f", "file_path": "a.zig", "code": "fn f() void { obj.method(); }"}, + "a.zig:method": {"name": "method", "file_path": "a.zig", "code": "fn method() void {}"}, + })).build() + assert out["call_graph"].get("a.zig:f") == ["a.zig:method"], out["call_graph"] + + +# exact-import / stdlib-filter / conservative resolution +def test_resolve_call_exact_import_stdlib_filter_and_conservative(): + b = CallGraphBuilder(_ext({}, imports={"main.zig": ["util.zig", "std"]})) + # (a) exact import-FILE match, not substring: 'util.zig' picks src/util.zig, not myutil.zig_x/ + nm = {"helper": ["myutil.zig_x/x.zig:helper", "src/util.zig:helper"]} + assert b._resolve_call("helper", "main.zig", nm) == ["src/util.zig:helper"] + # (b) 'std' is a non-file import (filtered); two std_*-pathed candidates, none import-resolved -> + # ambiguous -> [] (no namespace-leak over-connection, no stdlib substring match) + nm3 = {"bar": ["lib/std_x.zig:bar", "other/std_y.zig:bar"]} + assert b._resolve_call("bar", "main.zig", nm3) == []