Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 131 additions & 57 deletions libs/openant-core/parsers/zig/call_graph_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -137,73 +137,115 @@ 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]:
call_graph[func_id].append(resolved_id)
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)
Expand Down Expand Up @@ -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> , ',' , <fn identifier/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()
Expand Down Expand Up @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions libs/openant-core/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions libs/openant-core/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
82 changes: 82 additions & 0 deletions libs/openant-core/tests/test_zig_call_graph_builder.py
Original file line number Diff line number Diff line change
@@ -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) == []
Loading