|
| 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