Skip to content
Merged
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
22 changes: 18 additions & 4 deletions scripts/parsers/python_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<file>:0:<module>` id with no corresponding graph_nodes row, so
# `list`/`search` stay free of fake `<module>` 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:<module>"

MAX_DEPTH = 200

# Issue #116/#163: iterative DFS walk. The previous recursive form
Expand Down Expand Up @@ -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 <module> 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)
Expand All @@ -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
})

Expand Down
19 changes: 19 additions & 0 deletions scripts/trace_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,7 @@
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()
Expand Down Expand Up @@ -471,6 +472,24 @@

visited.add(neighbor_id)

# Issue #291/#223: a synthetic module-level caller id
# (`<file>:0:<module>`) 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>",
# 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>",

Check failure on line 487 in scripts/trace_engine.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "<module>" 3 times.

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ9gmPG9hTvzRHpmYU6v&open=AZ9gmPG9hTvzRHpmYU6v&pullRequest=292
"module_level": True,
"path": f"{path} → {neighbor_id}",
})
continue

if neighbor_id in node_by_id:
n = node_by_id[neighbor_id]
chain_entry = {
Expand Down
85 changes: 85 additions & 0 deletions tests/test_graph_accuracy_golden.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <module> caller
caller() # module-level call -> synthetic <module> 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)
Expand Down Expand Up @@ -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 <module> caller.

The module-level caller uses the synthetic `<file>:0:<module>` 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="<module>"` 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") == "<module>"]
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")
Expand Down
Loading