Skip to content

Commit 4a452cd

Browse files
authored
Merge pull request #280 from Wolfvin/test/graph-accuracy-golden-harness
test(graph): golden-fixture accuracy harness for rc/trace/dead-code (closes #277)
2 parents 8eecba6 + a7b98b5 commit 4a452cd

1 file changed

Lines changed: 233 additions & 0 deletions

File tree

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""Golden-fixture accuracy harness for the call graph (issue #277).
2+
3+
WHY THIS EXISTS
4+
---------------
5+
CodeLens's foundation is an accurate call graph, yet `reference_count` / trace /
6+
dead-code broke repeatedly across languages and each regression slipped past CI
7+
until it was found by hand in a real workspace: #210, #219, #220, #222, #223,
8+
#231. Root process gap: nothing locked the graph's output on a known fixture.
9+
10+
This harness scans small deterministic fixtures that reproduce the exact
11+
patterns those bugs lived in, and asserts CONCRETE values (rc == N, this caller
12+
set, this dead-code status) — not "rc > 0". Revert any of those fixes and a
13+
test here fails immediately, instead of months later in someone's repo.
14+
15+
Each fixture documents which historical issue it guards.
16+
"""
17+
18+
import json
19+
import os
20+
import shutil
21+
import sys
22+
import tempfile
23+
24+
import pytest
25+
26+
SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
27+
sys.path.insert(0, SCRIPT_DIR)
28+
29+
30+
def _tree_sitter_available() -> bool:
31+
try:
32+
import tree_sitter # noqa: F401
33+
return True
34+
except ImportError:
35+
return False
36+
37+
38+
_TS = _tree_sitter_available()
39+
_SKIP = "tree-sitter not installed"
40+
41+
42+
# ─── Scan harness ────────────────────────────────────────────────────
43+
44+
def _run_scan(workspace: str) -> dict:
45+
"""Run `codelens scan <workspace>` in-process; return parsed backend.json."""
46+
import io
47+
import contextlib
48+
from codelens import main as codelens_main
49+
50+
old_argv = sys.argv
51+
old_cwd = os.getcwd()
52+
try:
53+
sys.argv = ["codelens", "scan", workspace]
54+
os.chdir(workspace)
55+
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
56+
try:
57+
codelens_main()
58+
except SystemExit:
59+
pass
60+
finally:
61+
sys.argv = old_argv
62+
os.chdir(old_cwd)
63+
64+
with open(os.path.join(workspace, ".codelens", "backend.json"), encoding="utf-8") as f:
65+
return json.load(f)
66+
67+
68+
def _nodes_named(backend: dict, fn: str) -> list:
69+
return [n for n in backend["nodes"] if n.get("fn") == fn or n.get("name") == fn]
70+
71+
72+
def _rc(backend: dict, fn: str) -> int:
73+
"""Reference count for the single node named `fn` (asserts exactly one)."""
74+
nodes = _nodes_named(backend, fn)
75+
assert len(nodes) == 1, f"expected exactly 1 node named {fn!r}, got {len(nodes)}: {[n['id'] for n in nodes]}"
76+
return nodes[0].get("ref_count", 0)
77+
78+
79+
def _callers_of(backend: dict, fn: str) -> set:
80+
"""Set of source files that call `fn` (via graph edges → target node id)."""
81+
nodes = _nodes_named(backend, fn)
82+
assert len(nodes) == 1, f"expected 1 node named {fn!r}, got {len(nodes)}"
83+
target_id = nodes[0]["id"].replace("\\", "/")
84+
srcs = set()
85+
for e in backend["edges"]:
86+
if (e.get("to") or "").replace("\\", "/") == target_id:
87+
srcs.add((e.get("from") or "").replace("\\", "/"))
88+
return srcs
89+
90+
91+
# ─── Fixtures per historical bug ─────────────────────────────────────
92+
93+
_MODULE_LEVEL_CALL_TS = """\
94+
// Guards #219: a module-top-level call must count toward the callee's rc.
95+
export function moduleLevelHelper(): number { return 1; }
96+
export function wrapper(): number { return moduleLevelHelper(); }
97+
98+
// Called once at module top level (not inside any function):
99+
moduleLevelHelper();
100+
"""
101+
102+
_ASYNC_HANDLER_TS = """\
103+
// Guards #231: a call inside an asyncHandler-wrapped arrow (one extra wrapping
104+
// layer, not a direct argument) must still register as an edge.
105+
import { getGoogleClient } from './svc';
106+
export const router = { post: (_p: string, _h: unknown) => {} };
107+
export const asyncHandler = (fn: (...a: unknown[]) => unknown) => fn;
108+
109+
router.post('/auth', asyncHandler(async (req: unknown, res: unknown) => {
110+
const c = await getGoogleClient();
111+
return c;
112+
}));
113+
"""
114+
115+
_SVC_TS = """\
116+
export async function getGoogleClient(): Promise<number> { return 1; }
117+
"""
118+
119+
_OBJECT_LITERAL_ARROW_TS = """\
120+
// Guards #222: an arrow function assigned as an object-literal value must be
121+
// registered as a node `<var>.<key>` so trace/search can resolve it by name.
122+
export const service = {
123+
listItems: (ctx: unknown) => { return ctx; },
124+
};
125+
"""
126+
127+
_SAME_FILE_USAGE_RS = """\
128+
// Guards #220: a Rust const used >=2 times in the SAME file must not be
129+
// false-flagged dead (Counter threshold, not a broad self-exempting Set).
130+
const RED: &str = "red";
131+
132+
pub fn first() -> &'static str { RED }
133+
pub fn second() -> &'static str { RED }
134+
"""
135+
136+
_GENUINELY_DEAD_TS = """\
137+
// Control: a truly-unreferenced non-exported function IS dead. Guards against a
138+
// fix that "cures" false positives by never flagging anything dead.
139+
function trulyUnusedInternal(): number { return 42; }
140+
export function used(): number { return 1; }
141+
used();
142+
"""
143+
144+
145+
def _build_workspace(tmp_path) -> str:
146+
ws = tmp_path / "golden_ws"
147+
(ws / "src").mkdir(parents=True)
148+
(ws / "src" / "mod_level.ts").write_text(_MODULE_LEVEL_CALL_TS)
149+
(ws / "src" / "handler.ts").write_text(_ASYNC_HANDLER_TS)
150+
(ws / "src" / "svc.ts").write_text(_SVC_TS)
151+
(ws / "src" / "object_arrow.ts").write_text(_OBJECT_LITERAL_ARROW_TS)
152+
(ws / "src" / "same_file.rs").write_text(_SAME_FILE_USAGE_RS)
153+
(ws / "src" / "dead.ts").write_text(_GENUINELY_DEAD_TS)
154+
return str(ws)
155+
156+
157+
@pytest.fixture(scope="module")
158+
def scanned(tmp_path_factory):
159+
"""Yield (workspace_path, backend_json) for a scanned golden workspace."""
160+
if not _TS:
161+
pytest.skip(_SKIP)
162+
tmp = tmp_path_factory.mktemp("golden")
163+
ws = _build_workspace(tmp)
164+
try:
165+
yield ws, _run_scan(ws)
166+
finally:
167+
shutil.rmtree(ws, ignore_errors=True)
168+
169+
170+
@pytest.fixture(scope="module")
171+
def backend(scanned):
172+
return scanned[1]
173+
174+
175+
# ─── Golden assertions ───────────────────────────────────────────────
176+
177+
class TestGraphAccuracyGolden:
178+
"""Concrete rc / caller / dead-code assertions. Revert a fix → this fails."""
179+
180+
def test_module_level_call_counts_toward_rc(self, backend):
181+
"""#219: `moduleLevelHelper` is called from wrapper() AND module-level."""
182+
rc = _rc(backend, "moduleLevelHelper")
183+
assert rc >= 2, f"#219 regression: moduleLevelHelper rc={rc}, expected >=2 (wrapper + module-level)"
184+
callers = _callers_of(backend, "moduleLevelHelper")
185+
assert any("mod_level.ts" in c for c in callers), (
186+
f"#219: expected a caller from mod_level.ts, got {callers}"
187+
)
188+
189+
def test_async_handler_wrapped_call_registers_edge(self, backend):
190+
"""#231: getGoogleClient called inside asyncHandler(async ()=>{...})."""
191+
rc = _rc(backend, "getGoogleClient")
192+
assert rc >= 1, f"#231 regression: getGoogleClient rc={rc}, expected >=1"
193+
callers = _callers_of(backend, "getGoogleClient")
194+
assert any("handler.ts" in c for c in callers), (
195+
f"#231: expected a caller from handler.ts, got {callers}"
196+
)
197+
198+
def test_object_literal_arrow_registered_as_node(self, backend):
199+
"""#222: `service.listItems` arrow value must be a resolvable node."""
200+
candidates = [
201+
n for n in backend["nodes"]
202+
if "listItems" in (n.get("fn") or "") or "listItems" in (n.get("name") or "")
203+
]
204+
assert candidates, (
205+
"#222 regression: object-literal arrow 'service.listItems' not registered as a node"
206+
)
207+
208+
def test_same_file_rust_const_not_dead(self, scanned):
209+
"""#220: Rust const RED used twice same-file must not be reported dead.
210+
211+
Asserts at the layer users consume (`audit --check dead-code` →
212+
`detect_dead_code`), NOT the raw backend.json node status: a const has
213+
rc 0 in the raw graph (it's never a CALLS target), but the dead-code
214+
engine's same-file-usage exemption (#220, Counter threshold >=2) must
215+
keep it out of the dead findings.
216+
"""
217+
ws, _ = scanned
218+
from deadcode_engine import detect_dead_code
219+
res = detect_dead_code(ws)
220+
findings = res.get("findings") or res.get("dead") or res.get("items") or []
221+
red_hits = [f for f in findings if "RED" in str(f) and "same_file" in str(f).replace("\\", "/")]
222+
assert not red_hits, (
223+
f"#220 regression: same-file-used Rust const RED flagged dead by the engine: {red_hits[:1]}"
224+
)
225+
226+
def test_genuinely_dead_still_detected(self, backend):
227+
"""Control: an unreferenced internal function IS still dead (rc 0)."""
228+
nodes = _nodes_named(backend, "trulyUnusedInternal")
229+
assert nodes, "control node trulyUnusedInternal missing"
230+
assert nodes[0].get("ref_count", 0) == 0, (
231+
f"control: trulyUnusedInternal should have rc 0, got {nodes[0].get('ref_count')} — "
232+
"a fix that inflates rc to hide false-positives would break real dead-code detection"
233+
)

0 commit comments

Comments
 (0)