diff --git a/scripts/parsers/python_parser.py b/scripts/parsers/python_parser.py index 0af6ace8..cf8d4376 100644 --- a/scripts/parsers/python_parser.py +++ b/scripts/parsers/python_parser.py @@ -149,6 +149,15 @@ def _extract_references_impl(self, content: str, file_path: str) -> Dict[str, Li source = content.encode('utf-8') + # Issue #291: synthetic source id for module-top-level calls (calls not + # nested inside any function/class body — e.g. `setup_app()` written as + # a bare top-level statement). Mirrors the JS/TS convention from #219: + # a `:0:` id with no corresponding graph_nodes row, so + # `list`/`search` stay free of fake `` entries while ref_count + # (computed from the target side) and `trace --direction up` still see + # the caller. `graph_model.is_module_level_source_id()` recognises it. + module_node_id = f"{file_path}:0:" + MAX_DEPTH = 200 # 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 # Skip decorators - they reference functions but aren't calls continue - elif node.type == 'call' and fn_id: - # Function call: name(args) or obj.method(args) + elif node.type == 'call': + # Function call: name(args) or obj.method(args). + # Issue #291: when fn_id is None the call sits at module + # top-level (not inside any def/class body) — attribute it to + # the synthetic source id so module-level calls still + # produce a CALLS edge (fixes false dead-code + rc undercount). + source_id = fn_id if fn_id else module_node_id func_node = node.child_by_field_name('function') if func_node: keep_alive.append(func_node) @@ -285,14 +299,14 @@ def _extract_references_impl(self, content: str, file_path: str) -> Dict[str, Li if method_name not in PYTHON_SKIP_NAMES: is_self = obj_name == 'self' edges.append({ - "from": fn_id, + "from": source_id, "to_fn": method_name, "via_self": is_self }) elif func_node.type == 'identifier': if call_name not in PYTHON_SKIP_NAMES: edges.append({ - "from": fn_id, + "from": source_id, "to_fn": call_name }) diff --git a/scripts/trace_engine.py b/scripts/trace_engine.py index 67f76fc2..4e348c23 100755 --- a/scripts/trace_engine.py +++ b/scripts/trace_engine.py @@ -377,6 +377,7 @@ def _bfs_trace_indexed( max_results: Max entries to return (prevents timeout) """ from edge_resolver import get_callers, get_callees + from graph_model import is_module_level_source_id chain = [] visited: Set[str] = set() @@ -471,6 +472,24 @@ def _bfs_trace_indexed( visited.add(neighbor_id) + # Issue #291/#223: a synthetic module-level caller id + # (`:0:`) has no node_by_id row. Without this the + # flat path falls through to the "unknown"/resolved=False branch, + # disagreeing with the graph path (which emits fn="", + # module_level=True). Mirror the graph path exactly so flat==graph. + # Module scope is the top of a file's call hierarchy — do NOT + # enqueue for further BFS. + if is_module_level_source_id(neighbor_id): + chain.append({ + "depth": depth, + "direction": direction_label, + "node_id": neighbor_id, + "fn": "", + "module_level": True, + "path": f"{path} → {neighbor_id}", + }) + continue + if neighbor_id in node_by_id: n = node_by_id[neighbor_id] chain_entry = { diff --git a/tests/test_graph_accuracy_golden.py b/tests/test_graph_accuracy_golden.py index 4d2ebc33..0284f9cf 100644 --- a/tests/test_graph_accuracy_golden.py +++ b/tests/test_graph_accuracy_golden.py @@ -153,9 +153,37 @@ def _callers_of(backend: dict, fn: str) -> set: """ +_MODULE_LEVEL_CALL_PY = """\ +# Guards #291: Python calls at module top level (not inside any def/class) must +# count toward the callee's rc — analogous to #219 for TS/JS. Before #291 the +# Python parser only emitted edges for calls inside a function body, so a +# function called ONLY at module level got rc=0 / status=dead (false positive). +def setup_app(): + return 1 + + +def helper(): + return 2 + + +def caller(): + return helper() + + +def py_never_called(): + # Control: genuinely dead — never called anywhere. Must STAY dead. + return 99 + + +setup_app() # module-level call -> synthetic caller +caller() # module-level call -> synthetic caller +""" + + def _build_workspace(tmp_path) -> str: ws = tmp_path / "golden_ws" (ws / "src").mkdir(parents=True) + (ws / "src" / "mod_level.py").write_text(_MODULE_LEVEL_CALL_PY) (ws / "src" / "mod_level.ts").write_text(_MODULE_LEVEL_CALL_TS) (ws / "src" / "handler.ts").write_text(_ASYNC_HANDLER_TS) (ws / "src" / "svc.ts").write_text(_SVC_TS) @@ -279,6 +307,63 @@ def test_same_file_rust_const_not_dead(self, scanned): f"#220 regression: same-file-used Rust const RED flagged dead by the engine: {red_hits[:1]}" ) + def test_python_module_level_call_counts_toward_rc(self, backend): + """#291: Python `setup_app`/`caller` called only at module top level. + + Both are called via a bare top-level statement (not inside any function + body). Before #291 the Python parser emitted no edge for module-level + calls, so both had rc=0 / status=dead (false positive). Each must now + have rc>=1 with a caller from mod_level.py. + """ + for fn in ("setup_app", "caller"): + rc = _rc(backend, fn) + assert rc >= 1, f"#291 regression: Python {fn} rc={rc}, expected >=1 (module-level call)" + callers = _callers_of(backend, fn) + assert any("mod_level.py" in c for c in callers), ( + f"#291: expected a caller from mod_level.py for {fn}, got {callers}" + ) + + def test_python_module_level_caller_visible_in_trace_up(self, scanned): + """#291/#223: `trace --direction up setup_app` surfaces the caller. + + The module-level caller uses the synthetic `:0:` id (same + format as TS/JS), so `graph_model.is_module_level_source_id()` recognises + it and trace-up emits a `module_level=True` / `fn=""` entry. + """ + ws, _ = scanned + from commands.trace import execute + + class _Args: + name = "setup_app" + direction = "up" + depth = 10 + domain = "auto" + limit = 20 + offset = 0 + max_results = 1000 + use_graph = True + deep = False + format = "json" + + result = execute(_Args(), ws) + up = result.get("chains", {}).get("up", []) + module_callers = [c for c in up if c.get("module_level") or c.get("fn") == ""] + assert module_callers, ( + f"#291 regression: module-level caller of setup_app dropped from trace-up. up callers: {up}" + ) + + def test_python_genuinely_dead_still_detected(self, backend): + """#291 control: an unreferenced Python function IS still dead (rc 0). + + Guards against a fix that inflates rc to hide false positives — a + genuinely-never-called Python function must retain rc 0. + """ + nodes = _nodes_named(backend, "py_never_called") + assert nodes, "control node py_never_called missing" + assert nodes[0].get("ref_count", 0) == 0, ( + f"#291 control: py_never_called should have rc 0, got {nodes[0].get('ref_count')}" + ) + def test_genuinely_dead_still_detected(self, backend): """Control: an unreferenced internal function IS still dead (rc 0).""" nodes = _nodes_named(backend, "trulyUnusedInternal")