Skip to content

Commit 1833d13

Browse files
authored
Merge pull request #292 from Wolfvin/fix/issue-291-python-module-level-calls
fix(parser): extract Python module-level calls (closes #291)
2 parents eafd397 + 34f265d commit 1833d13

3 files changed

Lines changed: 122 additions & 4 deletions

File tree

scripts/parsers/python_parser.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,15 @@ def _extract_references_impl(self, content: str, file_path: str) -> Dict[str, Li
149149

150150
source = content.encode('utf-8')
151151

152+
# Issue #291: synthetic source id for module-top-level calls (calls not
153+
# nested inside any function/class body — e.g. `setup_app()` written as
154+
# a bare top-level statement). Mirrors the JS/TS convention from #219:
155+
# a `<file>:0:<module>` id with no corresponding graph_nodes row, so
156+
# `list`/`search` stay free of fake `<module>` entries while ref_count
157+
# (computed from the target side) and `trace --direction up` still see
158+
# the caller. `graph_model.is_module_level_source_id()` recognises it.
159+
module_node_id = f"{file_path}:0:<module>"
160+
152161
MAX_DEPTH = 200
153162

154163
# Issue #116/#163: iterative DFS walk. The previous recursive form
@@ -266,8 +275,13 @@ def _extract_references_impl(self, content: str, file_path: str) -> Dict[str, Li
266275
# Skip decorators - they reference functions but aren't calls
267276
continue
268277

269-
elif node.type == 'call' and fn_id:
270-
# Function call: name(args) or obj.method(args)
278+
elif node.type == 'call':
279+
# Function call: name(args) or obj.method(args).
280+
# Issue #291: when fn_id is None the call sits at module
281+
# top-level (not inside any def/class body) — attribute it to
282+
# the synthetic <module> source id so module-level calls still
283+
# produce a CALLS edge (fixes false dead-code + rc undercount).
284+
source_id = fn_id if fn_id else module_node_id
271285
func_node = node.child_by_field_name('function')
272286
if func_node:
273287
keep_alive.append(func_node)
@@ -285,14 +299,14 @@ def _extract_references_impl(self, content: str, file_path: str) -> Dict[str, Li
285299
if method_name not in PYTHON_SKIP_NAMES:
286300
is_self = obj_name == 'self'
287301
edges.append({
288-
"from": fn_id,
302+
"from": source_id,
289303
"to_fn": method_name,
290304
"via_self": is_self
291305
})
292306
elif func_node.type == 'identifier':
293307
if call_name not in PYTHON_SKIP_NAMES:
294308
edges.append({
295-
"from": fn_id,
309+
"from": source_id,
296310
"to_fn": call_name
297311
})
298312

scripts/trace_engine.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,7 @@ def _bfs_trace_indexed(
377377
max_results: Max entries to return (prevents timeout)
378378
"""
379379
from edge_resolver import get_callers, get_callees
380+
from graph_model import is_module_level_source_id
380381

381382
chain = []
382383
visited: Set[str] = set()
@@ -471,6 +472,24 @@ def _bfs_trace_indexed(
471472

472473
visited.add(neighbor_id)
473474

475+
# Issue #291/#223: a synthetic module-level caller id
476+
# (`<file>:0:<module>`) has no node_by_id row. Without this the
477+
# flat path falls through to the "unknown"/resolved=False branch,
478+
# disagreeing with the graph path (which emits fn="<module>",
479+
# module_level=True). Mirror the graph path exactly so flat==graph.
480+
# Module scope is the top of a file's call hierarchy — do NOT
481+
# enqueue for further BFS.
482+
if is_module_level_source_id(neighbor_id):
483+
chain.append({
484+
"depth": depth,
485+
"direction": direction_label,
486+
"node_id": neighbor_id,
487+
"fn": "<module>",
488+
"module_level": True,
489+
"path": f"{path}{neighbor_id}",
490+
})
491+
continue
492+
474493
if neighbor_id in node_by_id:
475494
n = node_by_id[neighbor_id]
476495
chain_entry = {

tests/test_graph_accuracy_golden.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,9 +153,37 @@ def _callers_of(backend: dict, fn: str) -> set:
153153
"""
154154

155155

156+
_MODULE_LEVEL_CALL_PY = """\
157+
# Guards #291: Python calls at module top level (not inside any def/class) must
158+
# count toward the callee's rc — analogous to #219 for TS/JS. Before #291 the
159+
# Python parser only emitted edges for calls inside a function body, so a
160+
# function called ONLY at module level got rc=0 / status=dead (false positive).
161+
def setup_app():
162+
return 1
163+
164+
165+
def helper():
166+
return 2
167+
168+
169+
def caller():
170+
return helper()
171+
172+
173+
def py_never_called():
174+
# Control: genuinely dead — never called anywhere. Must STAY dead.
175+
return 99
176+
177+
178+
setup_app() # module-level call -> synthetic <module> caller
179+
caller() # module-level call -> synthetic <module> caller
180+
"""
181+
182+
156183
def _build_workspace(tmp_path) -> str:
157184
ws = tmp_path / "golden_ws"
158185
(ws / "src").mkdir(parents=True)
186+
(ws / "src" / "mod_level.py").write_text(_MODULE_LEVEL_CALL_PY)
159187
(ws / "src" / "mod_level.ts").write_text(_MODULE_LEVEL_CALL_TS)
160188
(ws / "src" / "handler.ts").write_text(_ASYNC_HANDLER_TS)
161189
(ws / "src" / "svc.ts").write_text(_SVC_TS)
@@ -279,6 +307,63 @@ def test_same_file_rust_const_not_dead(self, scanned):
279307
f"#220 regression: same-file-used Rust const RED flagged dead by the engine: {red_hits[:1]}"
280308
)
281309

310+
def test_python_module_level_call_counts_toward_rc(self, backend):
311+
"""#291: Python `setup_app`/`caller` called only at module top level.
312+
313+
Both are called via a bare top-level statement (not inside any function
314+
body). Before #291 the Python parser emitted no edge for module-level
315+
calls, so both had rc=0 / status=dead (false positive). Each must now
316+
have rc>=1 with a caller from mod_level.py.
317+
"""
318+
for fn in ("setup_app", "caller"):
319+
rc = _rc(backend, fn)
320+
assert rc >= 1, f"#291 regression: Python {fn} rc={rc}, expected >=1 (module-level call)"
321+
callers = _callers_of(backend, fn)
322+
assert any("mod_level.py" in c for c in callers), (
323+
f"#291: expected a caller from mod_level.py for {fn}, got {callers}"
324+
)
325+
326+
def test_python_module_level_caller_visible_in_trace_up(self, scanned):
327+
"""#291/#223: `trace --direction up setup_app` surfaces the <module> caller.
328+
329+
The module-level caller uses the synthetic `<file>:0:<module>` id (same
330+
format as TS/JS), so `graph_model.is_module_level_source_id()` recognises
331+
it and trace-up emits a `module_level=True` / `fn="<module>"` entry.
332+
"""
333+
ws, _ = scanned
334+
from commands.trace import execute
335+
336+
class _Args:
337+
name = "setup_app"
338+
direction = "up"
339+
depth = 10
340+
domain = "auto"
341+
limit = 20
342+
offset = 0
343+
max_results = 1000
344+
use_graph = True
345+
deep = False
346+
format = "json"
347+
348+
result = execute(_Args(), ws)
349+
up = result.get("chains", {}).get("up", [])
350+
module_callers = [c for c in up if c.get("module_level") or c.get("fn") == "<module>"]
351+
assert module_callers, (
352+
f"#291 regression: module-level caller of setup_app dropped from trace-up. up callers: {up}"
353+
)
354+
355+
def test_python_genuinely_dead_still_detected(self, backend):
356+
"""#291 control: an unreferenced Python function IS still dead (rc 0).
357+
358+
Guards against a fix that inflates rc to hide false positives — a
359+
genuinely-never-called Python function must retain rc 0.
360+
"""
361+
nodes = _nodes_named(backend, "py_never_called")
362+
assert nodes, "control node py_never_called missing"
363+
assert nodes[0].get("ref_count", 0) == 0, (
364+
f"#291 control: py_never_called should have rc 0, got {nodes[0].get('ref_count')}"
365+
)
366+
282367
def test_genuinely_dead_still_detected(self, backend):
283368
"""Control: an unreferenced internal function IS still dead (rc 0)."""
284369
nodes = _nodes_named(backend, "trulyUnusedInternal")

0 commit comments

Comments
 (0)