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
30 changes: 28 additions & 2 deletions libs/openant-core/core/parser_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,34 @@ def _load_module(name, filename):
if extra_entry_points:
entry_points = entry_points | extra_entry_points

units = dataset.get("units", [])
original_count = len(units)

# Empty-seed safety-net: a zero entry-point seed would prune EVERY unit
# (the BFS frontier starts empty), silently emptying the dataset and
# reporting a 100% reduction as success. That is the dominant failure mode
# for non-web library / stdlib targets, whose ordinary functions are not a
# seedable entry type. Rather than a silent total blackout, degrade to
# pass-through (keep all units, unfiltered) and record a loud warning so the
# degraded result is never silent. Higher-level callers may still seed
# ``extra_entry_points`` to get real filtering.
if not entry_points and original_count > 0:
warning = (
"No entry points detected — reachability cannot seed a frontier. "
"Returning all units unfiltered to avoid a silent blackout; "
f"'{processing_level}' filtering was NOT applied."
)
print(f" [Warning] {warning}", file=sys.stderr)
dataset.setdefault("metadata", {})["reachability_filter"] = {
"original_units": original_count,
"entry_points": 0,
"reachable_units": original_count,
"filtered_out": 0,
"reduction_percentage": 0,
"warning": warning,
}
return dataset

# Compute reachable set (BFS forward from entry points)
reachability = ReachabilityAnalyzer(
functions=functions,
Expand All @@ -271,8 +299,6 @@ def _load_module(name, filename):
reachable_ids = reachability.get_all_reachable()

# Filter dataset units and stamp reachability tags
units = dataset.get("units", [])
original_count = len(units)
filtered_units = []
for u in units:
unit_id = u.get("id", "")
Expand Down
7 changes: 5 additions & 2 deletions libs/openant-core/parsers/zig/function_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,9 +269,12 @@ def _classify_function(self, name: str, file_path: str) -> str:
if name in ("init", "create", "new"):
return "constructor"

# Main entry point
# Main entry point. Classify as 'main' (matching the C and Go parsers)
# so the reachability seeder recognises a Zig binary's program entry —
# 'main' is an ENTRY_POINT_TYPE. Returning the generic 'function' here
# left every Zig binary with zero seeded entry points.
if name == "main":
return "function"
return "main"

return "function"

Expand Down
87 changes: 51 additions & 36 deletions libs/openant-core/parsers/zig/test_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,52 +188,67 @@ def apply_processing_filter(


def apply_reachability_filter(call_graph_output: dict, repo_path: str) -> dict:
"""Filter to functions reachable from entry points."""
"""Filter to functions reachable from entry points.

Uses the real EntryPointDetector / ReachabilityAnalyzer contract, matching
the central core/parser_adapter.apply_reachability_filter and the C/Go/PHP/
Ruby/JS sibling pipelines. The previous implementation called an API that
never existed (``EntryPointDetector(repo_path)``, ``detector.detect()``,
``ReachabilityAnalyzer(call_graph_output, entry_points)``,
``analyzer.get_reachable_functions()``); because libs/openant-core is on
sys.path the imports succeeded, so the ``except ImportError`` guard never
fired and the resulting wrong-arity TypeError crashed every Zig parse at
--processing-level reachable.
"""
try:
# Try to import the reachability analyzer
from utilities.agentic_enhancer.entry_point_detector import EntryPointDetector
from utilities.agentic_enhancer.reachability_analyzer import ReachabilityAnalyzer

# Detect entry points
detector = EntryPointDetector(repo_path)
entry_points = detector.detect()

# Analyze reachability
analyzer = ReachabilityAnalyzer(call_graph_output, entry_points)
reachable = analyzer.get_reachable_functions()

# Filter functions to only reachable ones
filtered_functions = {
fid: finfo
for fid, finfo in call_graph_output["functions"].items()
if fid in reachable
}

# Update the output with filtered functions
result = call_graph_output.copy()
result["functions"] = filtered_functions

# Filter call graphs too
result["call_graph"] = {
k: [v for v in vs if v in reachable]
for k, vs in call_graph_output.get("call_graph", {}).items()
if k in reachable
}
result["reverse_call_graph"] = {
k: [v for v in vs if v in reachable]
for k, vs in call_graph_output.get("reverse_call_graph", {}).items()
if k in reachable
}

return result

except ImportError:
print(
" Warning: Reachability analyzer not available, skipping filter",
file=sys.stderr,
)
return call_graph_output

functions = call_graph_output.get("functions", {})
call_graph = call_graph_output.get("call_graph", {})
reverse_call_graph = call_graph_output.get("reverse_call_graph", {})

# Detect entry points structurally (functions carry the snake_case
# 'unit_type' the Zig extractor emits, which the detector reads directly).
detector = EntryPointDetector(functions, call_graph)
entry_points = detector.detect_entry_points()

# Compute the reachable set via reverse-BFS from the entry points.
analyzer = ReachabilityAnalyzer(
functions=functions,
reverse_call_graph=reverse_call_graph,
entry_points=entry_points,
)
reachable = analyzer.get_all_reachable()

# Filter functions to only reachable ones.
filtered_functions = {
fid: finfo for fid, finfo in functions.items() if fid in reachable
}

result = call_graph_output.copy()
result["functions"] = filtered_functions

# Filter the call graphs to the reachable subgraph too.
result["call_graph"] = {
k: [v for v in vs if v in reachable]
for k, vs in call_graph.items()
if k in reachable
}
result["reverse_call_graph"] = {
k: [v for v in vs if v in reachable]
for k, vs in reverse_call_graph.items()
if k in reachable
}

return result


if __name__ == "__main__":
sys.exit(main())
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
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"""Zig `main` classification.

The Zig FunctionExtractor has a dedicated branch for `main` that returned the
generic 'function' unit_type instead of an entry-point type:

# Main entry point
if name == "main":
return "function"

C and Go classify a top-level main as unit_type='main'; Zig folded it into
'function'. With 'main' an ENTRY_POINT_TYPE in the central detector, Zig must emit
'main' so a Zig binary's entry point seeds reachability — otherwise the Zig
classifier is the lone divergent parser and Zig binaries stay blacked out.

function_extractor.py recurs across parser packages, so this module is loaded
under a unique name via importlib to avoid sys.modules collisions with the
C/Python sibling extractors.
"""

import importlib.util
import pathlib

_CORE = pathlib.Path(__file__).resolve().parents[3]
_ZIG_FE = _CORE / "parsers" / "zig" / "function_extractor.py"


def _load_zig_extractor():
spec = importlib.util.spec_from_file_location("isolated_zig_function_extractor", _ZIG_FE)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def _classify(name: str) -> str:
mod = _load_zig_extractor()
# _classify_function only uses (name, file_path); construct a minimal
# extractor (scan_results unused by the classifier).
extractor = mod.FunctionExtractor.__new__(mod.FunctionExtractor)
return extractor._classify_function(name, "src/main.zig")


def test_zig_main_classified_as_main():
assert _classify("main") == "main", (
"Zig must classify a top-level `main` as unit_type='main' (matching C/Go) "
"so it is recognised as a program entry point"
)


def test_zig_main_is_distinct_from_plain_function():
# A regular function still classifies as 'function'; only `main` is special.
assert _classify("handleRequest") == "function"
assert _classify("main") != _classify("handleRequest")


def test_zig_other_classifications_unchanged():
# Guard against over-broadening the fix.
assert _classify("init") == "constructor"
assert _classify("create") == "constructor"
assert _classify("testThing") == "test"
102 changes: 102 additions & 0 deletions libs/openant-core/tests/parsers/zig/test_zig_reachability_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Zig reachability-filter API crash.

parsers/zig/test_pipeline.py apply_reachability_filter was written against an
EntryPointDetector / ReachabilityAnalyzer API that never existed:

detector = EntryPointDetector(repo_path) # ctor needs (functions, call_graph)
entry_points = detector.detect() # real: detect_entry_points()
analyzer = ReachabilityAnalyzer(call_graph_output, entry_points)
# real: (functions, reverse_call_graph, entry_points)
reachable = analyzer.get_reachable_functions() # real: get_all_reachable()

Because test_pipeline.py puts libs/openant-core on sys.path, the two imports
SUCCEED, so the `except ImportError` guard never fires — the wrong-arity call
raises a TypeError that escapes the helper and crashes the whole Zig parse
(exit 1, zero output) at the default --processing-level reachable.

This test calls apply_reachability_filter directly with a tiny call graph whose
`main` is an entry point and which calls a helper; the fixed function must (a)
not raise, and (b) keep the reachable functions (main + helper) while dropping an
unreachable orphan.

test_pipeline.py shares its basename across all six parsers, so it is loaded
under a unique module name via importlib.
"""

import importlib.util
import pathlib
import sys

_CORE = pathlib.Path(__file__).resolve().parents[3]
_ZIG_TP = _CORE / "parsers" / "zig" / "test_pipeline.py"


def _load_zig_pipeline():
# The Zig pipeline does bare local imports (`from repository_scanner import`)
# relative to its own dir, mirroring how it is invoked as a script.
zig_dir = str(_ZIG_TP.parent)
core_dir = str(_CORE)
added = []
for p in (zig_dir, core_dir):
if p not in sys.path:
sys.path.insert(0, p)
added.append(p)
spec = importlib.util.spec_from_file_location("isolated_zig_test_pipeline", _ZIG_TP)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def _call_graph_output():
# main (entry) -> helper ; orphan is unreachable.
return {
"functions": {
"src/main.zig:main": {
"name": "main",
"unit_type": "main",
"code": "pub fn main() void { helper(); }",
},
"src/main.zig:helper": {
"name": "helper",
"unit_type": "function",
"code": "fn helper() void {}",
},
"src/main.zig:orphan": {
"name": "orphan",
"unit_type": "function",
"code": "fn orphan() void {}",
},
},
"call_graph": {
"src/main.zig:main": ["src/main.zig:helper"],
},
"reverse_call_graph": {
"src/main.zig:helper": ["src/main.zig:main"],
},
"statistics": {"total_edges": 1},
}


def test_apply_reachability_filter_does_not_crash_and_seeds_main():
mod = _load_zig_pipeline()
out = mod.apply_reachability_filter(_call_graph_output(), repo_path="/tmp/repo")

fids = set(out["functions"].keys())
assert "src/main.zig:main" in fids, (
"main is an entry point and must survive the reachability filter"
)
assert "src/main.zig:helper" in fids, (
"helper is reachable from main and must survive"
)
assert "src/main.zig:orphan" not in fids, (
"orphan is unreachable and must be filtered out — proving the filter "
"actually ran rather than passing everything through"
)


def test_apply_reachability_filter_filters_call_graph_too():
mod = _load_zig_pipeline()
out = mod.apply_reachability_filter(_call_graph_output(), repo_path="/tmp/repo")
# orphan must not linger in the (reverse) call graphs either.
assert "src/main.zig:orphan" not in out.get("call_graph", {})
assert "src/main.zig:orphan" not in out.get("reverse_call_graph", {})
Loading
Loading