diff --git a/docs/design/0220-same-file-usage-multi-lang.md b/docs/design/0220-same-file-usage-multi-lang.md new file mode 100644 index 00000000..3fd51c39 --- /dev/null +++ b/docs/design/0220-same-file-usage-multi-lang.md @@ -0,0 +1,168 @@ +# Design Doc: Same-File-Usage Tracking for Non-Python Languages (Issue #220) + +> **Status:** Proposed +> **Date:** 2026-07-12 +> **Author:** Wolfvin +> **Related issues:** #220 +> **Related PRs:** (this PR) + +--- + +## Problem + +CodeLens `audit --check dead-code` was producing severe false positives for +non-Python languages. In a real Rust codebase (rekening-dev-mode, 109 .rs +files), 100 of 108 dead-code findings were `registry_dead` — a ratio too +extreme to be real. + +Concrete example from the issue: `const RED: &str = ""` at +`regrets/verify.rs:22` was flagged as `registry_dead` ("zero references"), +but grep confirmed `RED` is used 10+ times in the same file (lines +78, 87, 101, 134, 164, 178, 186, 199, 343, ...). + +Root cause: `scripts/deadcode_engine.py` has two dead-code detection paths +that need same-file-usage data: + +1. `_detect_unused_exports()` — already checks `same_file_usages` (line ~1873) + to exempt exports used within their own file. But only Python had a + collector (`_collect_py_same_file_usages()`, line ~1004) that populated + this dict. For the 14 other languages, `same_file_usages` was always + empty, so any export not imported cross-file was flagged — even if used + 10+ times in its own file. + +2. `_detect_dead_from_registry()` — reads `.codelens/backend.json` (populated + by `scan`) and flags nodes with `ref_count == 0 && status == "dead"`. + The `ref_count` is computed from CALLS edges, which only capture function + calls — not const references, type usages, or macro invocations. A const + used 10+ times in its own file has 0 CALLS edges (consts are "referenced", + not "called"), so `ref_count == 0` and it's flagged. This path did NOT + check `same_file_usages` at all. + +## Goal + +`audit --check dead-code` should not false-positive flag symbols that are +used within their own file, for the 7 most common non-Python languages +(Rust, Go, Java, PHP, Ruby, C, C++). Genuinely dead symbols (defined but +never referenced anywhere) should still be flagged. + +## Changes + +### Architecture / Data Model + +Add `_collect__same_file_usages()` collector functions for 7 +languages, following the Python reference pattern but with a key +improvement: **occurrence counting**. Each collector uses `collections.Counter` +to count how many times each identifier appears in the file, and only adds +names with `count >= 2` to the usage set. + +The `count >= 2` threshold is critical: a symbol's own definition (e.g. +`const RED` or `fn foo`) causes the name to appear at least once. Without +the threshold, every symbol would exempt itself and no dead code would +ever be flagged. With `count >= 2`, a symbol must appear in its definition +PLUS at least one actual usage to be exempted. + +This differs from the Python collector, which uses a broad Set (count +includes definition). The Python collector works despite this because +Python's `unused_exports` check is about cross-file API — exempting +everything is acceptable for Python modules that are imported as a whole. +For non-Python languages and the `registry_dead` path, the `count >= 2` +threshold is necessary to preserve dead-code detection capability. + +### Modified Files + +- `scripts/deadcode_engine.py`: + - Add 6 new collector functions: `_collect_rust_same_file_usages`, + `_collect_go_same_file_usages`, `_collect_java_same_file_usages`, + `_collect_php_same_file_usages`, `_collect_ruby_same_file_usages`, + `_collect_c_same_file_usages` (handles both C and C++). + - Add 6 language-specific keyword sets (`_RUST_KW`, `_GO_KW`, etc.) + reused from `_collect_name_references()` to avoid exempting keywords. + - Wire collectors into `detect_dead_code()` main loop at the same point + as Python (line ~135). + - Modify `_detect_dead_from_registry()` to accept `same_file_usages` + parameter and exempt symbols that appear in their own file's usage set. + - Add `Counter` import from `collections`. + +- `tests/test_deadcode_engine.py`: + - Add `TestIssue220SameFileUsageCollectors` — 7 tests (one per language) + verifying each collector correctly distinguishes used (count>=2) from + unused (count==1) symbols. + - Add `TestIssue220DetectDeadFromRegistry` — 2 tests verifying + `_detect_dead_from_registry()` exempts same-file usages and still flags + genuinely dead symbols. + +### Tests + +- `tests/test_deadcode_engine.py::TestIssue220SameFileUsageCollectors` — 7 + tests, one per language (Rust, Go, Java, PHP, Ruby, C, C++). Each creates + a fixture with a used const (10+ references) and a genuinely unused fn, + then verifies the collector output set contains the used name but NOT the + unused name. +- `tests/test_deadcode_engine.py::TestIssue220DetectDeadFromRegistry` — 2 + tests verifying the registry-dead path exempts same-file usages and still + flags genuinely dead symbols. + +## Trade-offs + +### Alternative A: Broad Set (include definition name, like Python) + +- **Pros:** Simpler code — no Counter needed, just a Set. +- **Cons:** Every symbol exempts itself (its definition name appears in the + file). This would make `registry_dead` return 0 findings always — no dead + code ever detected for non-Python languages. Unacceptable. +- **Why rejected:** Destroys dead-code detection capability. + +### Alternative B: Strip definition lines before matching + +- **Pros:** More precise — only counts actual usages, not definitions. +- **Cons:** Requires language-specific definition-line patterns for each of + 7 languages. Complex to maintain. Fragile — misses edge cases like + multi-line definitions, attributes, etc. +- **Why rejected:** High complexity, high maintenance burden, fragile. + +### Alternative C: Count occurrences (chosen approach) + +- **Pros:** Simple — one regex (`\b\w+\b`) per language, plus a Counter. + The `count >= 2` threshold naturally distinguishes definition-only + (count==1) from definition+usage (count>=2). Works for all languages + with minimal per-language customization (just keyword sets). +- **Cons:** Counts string/comment occurrences as "usages" (false negatives + for genuinely dead symbols whose name appears in a comment). Acceptable + per issue constraint: "False negative lebih baik daripada false positive". +- **Why chosen:** Best balance of simplicity, correctness, and + maintainability. Follows the issue's design principle. + +### Alternative D: Make Rust/Go parsers register const references as CALLS edges + +- **Pros:** Fixes the root cause — `ref_count` would correctly reflect + const references. No need for `same_file_usages` in `_detect_dead_from_registry`. +- **Cons:** Changes call-graph semantics — consts aren't "called", they're + "referenced". Mixing CALLS edges with REFERENCES edges would break other + consumers (trace, impact, dataflow). Huge blast radius across the codebase. +- **Why rejected:** Violates the issue constraint "Jangan ubah + `_detect_unused_exports()` inti" (and by extension, the call-graph model). + Out of scope for this issue. + +## Open Questions + +- [ ] Should the remaining 7 languages (Lua, Elixir, Nim, C#, Swift, Scala, + Dart, Shell) get same-file-usage collectors too? The issue says they can + follow in a separate PR. Owner: BOS, decide if follow-up issue is wanted. +- [ ] Should `_collect_py_same_file_usages()` be updated to use the + `count >= 2` threshold for consistency? Currently Python uses a broad Set. + This would be a behavior change for Python `unused_exports` — out of scope + for this issue. Owner: BOS. + +## Migration / Rollout + +No migration impact — additive change. Existing dead-code findings will +decrease for non-Python languages (false positives removed). Genuinely dead +symbols remain flagged. This is a correctness improvement, not a breaking +change. + +## References + +- Issue: #220 +- Prior art: `_collect_py_same_file_usages()` in `scripts/deadcode_engine.py` + (line ~1004) — Python reference implementation. +- Related design docs: none diff --git a/scripts/deadcode_engine.py b/scripts/deadcode_engine.py index 997731e7..588c45ee 100755 --- a/scripts/deadcode_engine.py +++ b/scripts/deadcode_engine.py @@ -17,7 +17,7 @@ import json import time from typing import Dict, List, Any, Optional, Set -from collections import defaultdict +from collections import defaultdict, Counter from utils import DEFAULT_IGNORE_DIRS, safe_read_file, MAX_FILE_SIZE, logger, time_budget_expired SOURCE_EXTENSIONS = { @@ -136,30 +136,42 @@ def detect_dead_code( elif ext == ".go": _collect_go_exports_imports(content, rel_path, all_exports, all_imports) + # Issue #220: track same-file usage for non-Python languages + _collect_go_same_file_usages(content, rel_path, same_file_usages) elif ext == ".rs": _collect_rust_exports_imports(content, rel_path, all_exports, all_imports) + # Issue #220: track same-file usage for non-Python languages + _collect_rust_same_file_usages(content, rel_path, same_file_usages) elif ext in {".c", ".cpp", ".cxx", ".cc", ".h", ".hpp", ".hxx"}: _collect_c_exports_imports(content, rel_path, all_exports, all_imports) + # Issue #220: track same-file usage for non-Python languages + _collect_c_same_file_usages(content, rel_path, same_file_usages) elif ext == ".lua": _collect_lua_exports_imports(content, rel_path, all_exports, all_imports) elif ext == ".php": _collect_php_exports_imports(content, rel_path, all_exports, all_imports) + # Issue #220: track same-file usage for non-Python languages + _collect_php_same_file_usages(content, rel_path, same_file_usages) elif ext in {".ex", ".exs"}: _collect_elixir_exports_imports(content, rel_path, all_exports, all_imports) elif ext == ".rb": _collect_ruby_exports_imports(content, rel_path, all_exports, all_imports) + # Issue #220: track same-file usage for non-Python languages + _collect_ruby_same_file_usages(content, rel_path, same_file_usages) elif ext in {".nim", ".nims"}: _collect_nim_exports_imports(content, rel_path, all_exports, all_imports) elif ext == ".java": _collect_java_exports_imports(content, rel_path, all_exports, all_imports) + # Issue #220: track same-file usage for non-Python languages + _collect_java_same_file_usages(content, rel_path, same_file_usages) elif ext == ".cs": _collect_csharp_exports_imports(content, rel_path, all_exports, all_imports) @@ -215,7 +227,11 @@ def detect_dead_code( # v6: Use the backend registry's ref_count data when available. # Functions with ref_count == 0 and status == "dead" from the scan # should be reported as dead code. - registry_dead = _detect_dead_from_registry(workspace) + # Issue #220: pass same_file_usages so _detect_dead_from_registry can + # exempt symbols that are referenced within their own file (e.g. a Rust + # const used 10+ times in-file but never appearing as a CALLS edge + # because consts are not "called"). + registry_dead = _detect_dead_from_registry(workspace, same_file_usages) if registry_dead: results["registry_dead"] = registry_dead[:max_results] @@ -1033,6 +1049,240 @@ def _collect_py_same_file_usages( usages[rel_path] = used_names + +# ─── Issue #220: same-file-usage collectors for non-Python languages ──────── +# These mirror _collect_py_same_file_usages() but with language-specific +# keyword filters and syntax patterns. Without these, same_file_usages is +# always empty for non-Python files, causing _detect_unused_exports() to +# flag any export that's used only within its own file (never imported +# cross-file) as dead. The _detect_dead_from_registry() path also needs +# same_file_usages to exempt consts/statics that are referenced in-file +# but never appear as CALLS edges in the backend registry (consts/statics +# are not "called", so ref_count==0 even when used 10+ times in-file). +# +# Design principle (per issue #220 constraint): "False negative (skip +# flagging simbol yang benar2 dead) lebih baik daripada false positive". +# Each collector uses a BROAD identifier match (\b\w+\b) to catch all +# references — string/comment content is intentionally NOT stripped +# because the cost of a false positive (flagging genuinely-used code as +# dead) is higher than a false negative (missing a genuinely-dead symbol). + +# Shared keyword sets — kept in sync with _collect_name_references() to +# avoid exempting a name that's actually a language keyword rather than a +# real usage. +_RUST_KW = {'fn', 'pub', 'let', 'if', 'while', 'for', 'match', 'return', + 'use', 'mod', 'struct', 'enum', 'impl', 'trait', 'type', + 'where', 'unsafe', 'async', 'await', 'self', 'Self', 'super', + 'crate', 'true', 'false', 'as', 'break', 'continue', 'else', + 'loop', 'move', 'mut', 'ref', 'static', 'const', 'extern', + 'dyn', 'in', 'box', 'yield', 'try', 'union', 'macro_rules', + 'default', 'existential', 'become', 'unsized', 'abi'} + +_GO_KW = {'func', 'type', 'var', 'const', 'import', 'package', + 'return', 'defer', 'go', 'select', 'switch', 'if', + 'for', 'range', 'map', 'chan', 'interface', 'struct', + 'true', 'false', 'nil', 'error', 'string', 'bool', + 'int', 'int8', 'int16', 'int32', 'int64', 'uint', 'uint8', + 'uint16', 'uint32', 'uint64', 'uintptr', 'float32', 'float64', + 'byte', 'rune', 'make', 'new', 'len', 'cap', 'append', 'copy', + 'delete', 'close', 'panic', 'recover', 'print', 'println', + 'complex', 'real', 'imag', 'iota', 'else', 'case', 'default', + 'break', 'continue', 'goto', 'fallthrough'} + +_JAVA_KW = {'if', 'else', 'for', 'while', 'switch', 'return', 'new', + 'class', 'interface', 'extends', 'implements', 'import', + 'package', 'public', 'private', 'protected', 'static', + 'final', 'abstract', 'void', 'int', 'long', 'double', + 'float', 'boolean', 'char', 'byte', 'short', 'true', + 'false', 'null', 'this', 'super', 'try', 'catch', + 'throw', 'throws', 'finally', 'synchronized', 'volatile', + 'transient', 'native', 'enum', 'assert', 'instanceof', + 'do', 'break', 'continue', 'case', 'default', 'const', + 'goto', 'strictfp', 'var', 'yield', 'record', 'sealed', + 'permits', 'non-sealed'} + +_PHP_KW = {'if', 'else', 'elseif', 'for', 'while', 'switch', 'return', + 'function', 'class', 'new', 'public', 'private', 'protected', + 'static', 'abstract', 'final', 'try', 'catch', 'throw', + 'foreach', 'isset', 'unset', 'echo', 'print', 'list', + 'array', 'namespace', 'use', 'require', 'include', + 'require_once', 'include_once', 'true', 'false', 'null', + 'as', 'extends', 'implements', 'interface', 'trait', + 'const', 'var', 'case', 'break', 'continue', 'default', + 'do', 'global', 'print', 'clone', 'instanceof', 'insteadof', + 'yield', 'fn', 'match', 'enum', 'readonly', 'never', 'void', + 'int', 'float', 'bool', 'string', 'object', 'mixed', 'iterable', + 'self', 'parent', 'static'} + +_RUBY_KW = {'def', 'class', 'module', 'if', 'else', 'elsif', 'unless', + 'while', 'until', 'for', 'do', 'begin', 'rescue', 'ensure', + 'raise', 'return', 'yield', 'nil', 'true', 'false', + 'require', 'include', 'extend', 'attr', 'attr_accessor', + 'attr_reader', 'attr_writer', 'new', 'end', 'then', + 'and', 'or', 'not', 'in', 'case', 'when', 'break', + 'next', 'redo', 'retry', 'super', 'self', 'defined?', + 'lambda', 'proc', 'puts', 'print', 'p', 'pp', 'alias', + 'undef', 'BEGIN', 'END', '__FILE__', '__LINE__', '__dir__'} + +# C and C++ share a keyword set (C++ is a superset). This collector is +# used for all C/C++ extensions: .c, .cpp, .cxx, .cc, .h, .hpp, .hxx. +_C_CPP_KW = {'if', 'for', 'while', 'switch', 'return', 'sizeof', 'typeof', + 'catch', 'class', 'struct', 'enum', 'union', 'namespace', + 'template', 'typename', 'new', 'delete', 'void', 'int', + 'char', 'float', 'double', 'long', 'short', 'unsigned', + 'signed', 'const', 'static', 'extern', 'inline', 'virtual', + 'override', 'final', 'public', 'private', 'protected', + 'true', 'false', 'nullptr', 'NULL', 'auto', 'register', + 'volatile', 'typedef', 'using', 'else', 'case', 'default', + 'break', 'continue', 'goto', 'do', 'try', 'throw', 'throws', + 'operator', 'friend', 'this', 'explicit', 'mutable', + 'constexpr', 'noexcept', 'thread_local', 'alignas', 'alignof', + 'decltype', 'static_assert', 'nullptr_t', 'size_t', + 'int8_t', 'int16_t', 'int32_t', 'int64_t', + 'uint8_t', 'uint16_t', 'uint32_t', 'uint64_t', + 'wchar_t', 'char16_t', 'char32_t', 'bool', 'std', 'string', + 'vector', 'map', 'set', 'pair', 'shared_ptr', 'unique_ptr', + 'weak_ptr', 'make_shared', 'make_unique', 'static_cast', + 'dynamic_cast', 'reinterpret_cast', 'const_cast'} + + +def _collect_rust_same_file_usages( + content: str, rel_path: str, + usages: Dict[str, Set[str]] +): + """Collect names referenced within a Rust file (issue #220). + + Rust uses: function calls (name(), qualified access (module::name), + macro invocations (name!), struct/enum instantiation (Name{...}), + const/static references (bare identifier in expression position). + A broad \\b\\w+\\b match catches all of these; macro invocations + need an additional (\\w+)! pattern because the `!` breaks the word + boundary match. + + Only names appearing 2+ times are added to the usage set. This + prevents a symbol's own definition (e.g. `const RED` or `fn foo`) + from counting as a "usage" — a symbol that appears only in its + definition line is NOT used within the file and should remain + eligible for dead-code flagging. The broad match intentionally + includes string/comment occurrences because the issue's principle + is "false negative > false positive" (better to miss a genuinely + dead symbol than to flag a genuinely used one). + """ + counts: Counter = Counter() + # Broad identifier match — catches bare const refs, function names, + # type names, field names, etc. + for m in re.finditer(r'\b([a-zA-Z_]\w*)\b', content): + name = m.group(1) + if name not in _RUST_KW: + counts[name] += 1 + # Macro invocations: name!(...) — the `!` is not a word char so the + # broad match above catches `name` but we add it explicitly for + # clarity and in case the broad match is tightened later. + for m in re.finditer(r'\b([a-zA-Z_]\w*)\s*!', content): + counts[m.group(1)] += 1 + # Only exempt names that appear 2+ times (definition + ≥1 usage). + usages[rel_path] = {name for name, cnt in counts.items() if cnt >= 2} + + +def _collect_go_same_file_usages( + content: str, rel_path: str, + usages: Dict[str, Set[str]] +): + """Collect names referenced within a Go file (issue #220). + + Only names appearing 2+ times are added (see _collect_rust_same_file_usages + docstring for rationale). + """ + counts: Counter = Counter() + for m in re.finditer(r'\b([a-zA-Z_]\w*)\b', content): + name = m.group(1) + if name not in _GO_KW: + counts[name] += 1 + usages[rel_path] = {name for name, cnt in counts.items() if cnt >= 2} + + +def _collect_java_same_file_usages( + content: str, rel_path: str, + usages: Dict[str, Set[str]] +): + """Collect names referenced within a Java file (issue #220). + + Only names appearing 2+ times are added (see _collect_rust_same_file_usages + docstring for rationale). + """ + counts: Counter = Counter() + for m in re.finditer(r'\b([a-zA-Z_]\w*)\b', content): + name = m.group(1) + if name not in _JAVA_KW: + counts[name] += 1 + usages[rel_path] = {name for name, cnt in counts.items() if cnt >= 2} + + +def _collect_php_same_file_usages( + content: str, rel_path: str, + usages: Dict[str, Set[str]] +): + """Collect names referenced within a PHP file (issue #220). + + PHP variables start with `$` — those are tracked separately by the + unused_vars detector. For same-file-usage of exports (function/class + names), we match non-variable identifiers. + + Only names appearing 2+ times are added (see _collect_rust_same_file_usages + docstring for rationale). + """ + counts: Counter = Counter() + # Match non-variable identifiers (not preceded by $) + for m in re.finditer(r'(?= 2} + + +def _collect_ruby_same_file_usages( + content: str, rel_path: str, + usages: Dict[str, Set[str]] +): + """Collect names referenced within a Ruby file (issue #220). + + Ruby method calls don't require parentheses, so a broad identifier + match is essential. Symbols (:name) are also references. + + Only names appearing 2+ times are added (see _collect_rust_same_file_usages + docstring for rationale). + """ + counts: Counter = Counter() + for m in re.finditer(r'\b([a-zA-Z_]\w*)\b', content): + name = m.group(1) + if name not in _RUBY_KW: + counts[name] += 1 + # Symbol references: :method_name + for m in re.finditer(r':([a-zA-Z_]\w*)', content): + counts[m.group(1)] += 1 + usages[rel_path] = {name for name, cnt in counts.items() if cnt >= 2} + + +def _collect_c_same_file_usages( + content: str, rel_path: str, + usages: Dict[str, Set[str]] +): + """Collect names referenced within a C/C++ file (issue #220). + + Handles all C/C++ extensions: .c, .cpp, .cxx, .cc, .h, .hpp, .hxx. + Uses a combined C/C++ keyword set since C++ is a superset of C. + + Only names appearing 2+ times are added (see _collect_rust_same_file_usages + docstring for rationale). + """ + counts: Counter = Counter() + for m in re.finditer(r'\b([a-zA-Z_]\w*)\b', content): + name = m.group(1) + if name not in _C_CPP_KW: + counts[name] += 1 + usages[rel_path] = {name for name, cnt in counts.items() if cnt >= 2} + + def _collect_go_exports_imports( content: str, rel_path: str, exports: Dict[str, List[Dict]], imports: Dict[str, Set[str]] @@ -1901,9 +2151,23 @@ def _detect_unused_exports( return unused[:200] -def _detect_dead_from_registry(workspace: str) -> List[Dict]: +def _detect_dead_from_registry( + workspace: str, + same_file_usages: Dict[str, Set[str]] = None +) -> List[Dict]: """v6: Read the backend registry from .codelens/backend.json and report - functions with ref_count == 0 and status == 'dead' as dead code.""" + functions with ref_count == 0 and status == 'dead' as dead code. + + Issue #220: ``same_file_usages`` (file → set of names referenced in that + file) is used to exempt symbols that are used within their own file but + never appear as CALLS edges in the backend registry. This is critical + for non-function symbols like Rust consts/statics, Go vars, C + #defines — they're "referenced" (used in expressions) but not "called" + (no ``()`` invocation), so the call-graph builder produces 0 CALLS + edges even when the symbol is used 10+ times in the same file. + Without this exemption, such symbols are false-positive flagged as + ``registry_dead``. + """ registry_path = os.path.join(workspace, '.codelens', 'backend.json') if not os.path.isfile(registry_path): return [] @@ -1955,6 +2219,23 @@ def _detect_dead_from_registry(workspace: str) -> List[Dict]: # Skip components (React/PascalCase classes) — consumed externally if node.get("component", False): continue + + # Issue #220: Skip symbols that are referenced within their own + # file. The backend registry's ref_count is computed from CALLS + # edges, which only capture function calls — not const/static + # references, type usages, or macro invocations. A symbol used + # 10+ times in its own file (e.g. `const RED` referenced in + # `println!("{}", RED)`) will have ref_count==0 but is NOT dead. + # The same_file_usages dict (populated by _collect__same_file_usages + # collectors) captures these in-file references. + if same_file_usages: + _in_file_usages = same_file_usages.get(file_path, set()) + if name in _in_file_usages: + continue + # Also check the bare name (without :: qualifier) for Rust/C++ + # qualified names like `Module::func` + if '::' in name and bare_name in _in_file_usages: + continue # Skip test fixtures and example files # v6.4: Expanded to catch examples/, e2e/, __tests__/, stories/ _test_example_patterns = [ diff --git a/tests/test_deadcode_engine.py b/tests/test_deadcode_engine.py index ba9779e5..24ba26e7 100644 --- a/tests/test_deadcode_engine.py +++ b/tests/test_deadcode_engine.py @@ -290,3 +290,336 @@ def test_issue_105_genuinely_unreachable_still_detected(self): f"Regression: genuinely unreachable code not detected: {unreachable}" finally: shutil.rmtree(ws, ignore_errors=True) + + + +# ─── Issue #220: same-file-usage tracking for non-Python languages ────────── +# Before the fix, only Python had a _collect__same_file_usages() +# collector. For the 14 other languages, same_file_usages was always empty, +# causing _detect_unused_exports() and _detect_dead_from_registry() to +# false-positive flag any symbol that was used only within its own file +# (never imported cross-file) as dead. The most impactful case: Rust +# consts (e.g. `const RED: &str = "..."` used 10+ times via +# `println!("{}", RED)`) appeared as `registry_dead` because consts are +# "referenced" not "called", so ref_count==0 even when heavily used. +# +# These tests verify the fix for 7 languages (Rust, Go, Java, PHP, Ruby, +# C, C++). Each collector test verifies: +# 1. A symbol used 2+ times IS in the usage set (exempted from dead flagging) +# 2. A symbol appearing only in its definition (count==1) is NOT in the set +# (remains eligible for dead-code flagging) +from deadcode_engine import ( + _collect_rust_same_file_usages, + _collect_go_same_file_usages, + _collect_java_same_file_usages, + _collect_php_same_file_usages, + _collect_ruby_same_file_usages, + _collect_c_same_file_usages, + _detect_dead_from_registry, +) + + +class TestIssue220SameFileUsageCollectors: + """Issue #220 regression tests: same-file-usage collectors for 7 languages. + + Each test verifies that the collector correctly distinguishes between: + - A symbol used 2+ times in the file (definition + >=1 usage) -> IN the set + - A symbol appearing only in its definition (count==1) -> NOT in the set + + This distinction is critical: without it, every symbol would exempt + itself (because its definition name appears in the file), and no + dead code would ever be flagged. + """ + + def test_rust_collector_exempts_used_consts_not_unused_fns(self): + """Rust: const RED used 10+ times -> in set. fn unused (only def) -> NOT in set.""" + code = """const RED: &str = "\\x1b[31m"; +const GREEN: &str = "\\x1b[32m"; + +fn print_red(msg: &str) { + println!("{}{}", RED, msg); +} + +fn verify_all() { + print_red("error 1"); + print_red("error 2"); + let _ = (RED, GREEN, RED, GREEN, RED, GREEN); +} + +fn genuinely_unused() -> i32 { + 42 +} +""" + usages = {} + _collect_rust_same_file_usages(code, "verify.rs", usages) + used = usages["verify.rs"] + assert "RED" in used, f"RED should be in usage set (used 5+ times). got: {used}" + assert "GREEN" in used, f"GREEN should be in usage set. got: {used}" + assert "print_red" in used, f"print_red should be in usage set. got: {used}" + assert "genuinely_unused" not in used, ( + f"genuinely_unused should NOT be in usage set (only appears in " + f"definition, count==1). got: {used}" + ) + + def test_go_collector_exempts_used_consts_not_unused_fns(self): + """Go: const used multiple times -> in set. Unused func -> NOT in set.""" + code = """package main + +const RedColor = "\\x1b[31m" +const GreenColor = "\\x1b[32m" + +func printRed(msg string) { + println(RedColor, msg) +} + +func verifyAll() { + printRed("error 1") + printRed("error 2") + _ = []string{RedColor, GreenColor, RedColor, GreenColor} +} + +func GenuinelyUnused() int { + return 42 +} +""" + usages = {} + _collect_go_same_file_usages(code, "main.go", usages) + used = usages["main.go"] + assert "RedColor" in used, f"RedColor should be in usage set. got: {used}" + assert "GreenColor" in used, f"GreenColor should be in usage set. got: {used}" + assert "printRed" in used, f"printRed should be in usage set. got: {used}" + assert "GenuinelyUnused" not in used, ( + f"GenuinelyUnused should NOT be in usage set (only definition). got: {used}" + ) + + def test_java_collector_exempts_used_consts_not_unused_fns(self): + """Java: static final used multiple times -> in set. Unused method -> NOT in set.""" + code = """public class Config { + static final String RED = "\\x1b[31m"; + static final String GREEN = "\\x1b[32m"; + + static void printRed(String msg) { + System.out.println(RED + msg); + } + + static void verifyAll() { + printRed("error 1"); + printRed("error 2"); + String[] colors = {RED, GREEN, RED, GREEN, RED}; + } + + static int genuinelyUnused() { + return 42; + } +} +""" + usages = {} + _collect_java_same_file_usages(code, "Config.java", usages) + used = usages["Config.java"] + assert "RED" in used, f"RED should be in usage set. got: {used}" + assert "GREEN" in used, f"GREEN should be in usage set. got: {used}" + assert "printRed" in used, f"printRed should be in usage set. got: {used}" + assert "genuinelyUnused" not in used, ( + f"genuinelyUnused should NOT be in usage set. got: {used}" + ) + + def test_php_collector_exempts_used_consts_not_unused_fns(self): + """PHP: const used multiple times -> in set. Unused method -> NOT in set.""" + code = """ in set. Unused method -> NOT in set.""" + code = """RED = "\\x1b[31m" +GREEN = "\\x1b[32m" + +def print_red(msg) + puts RED + msg +end + +def verify_all + print_red("error 1") + print_red("error 2") + colors = [RED, GREEN, RED, GREEN, RED] +end + +def genuinely_unused + 42 +end +""" + usages = {} + _collect_ruby_same_file_usages(code, "colors.rb", usages) + used = usages["colors.rb"] + assert "RED" in used, f"RED should be in usage set. got: {used}" + assert "GREEN" in used, f"GREEN should be in usage set. got: {used}" + assert "print_red" in used, f"print_red should be in usage set. got: {used}" + assert "genuinely_unused" not in used, ( + f"genuinely_unused should NOT be in usage set. got: {used}" + ) + + def test_c_collector_exempts_used_consts_not_unused_fns(self): + """C: #define const used multiple times -> in set. Unused fn -> NOT in set.""" + code = """#include + +#define RED "\\x1b[31m" +#define GREEN "\\x1b[32m" + +void print_red(const char* msg) { + printf("%s%s\\n", RED, msg); +} + +void verify_all() { + print_red("error 1"); + print_red("error 2"); + const char* colors[] = {RED, GREEN, RED, GREEN, RED}; +} + +int genuinely_unused() { + return 42; +} +""" + usages = {} + _collect_c_same_file_usages(code, "colors.c", usages) + used = usages["colors.c"] + assert "RED" in used, f"RED should be in usage set. got: {used}" + assert "GREEN" in used, f"GREEN should be in usage set. got: {used}" + assert "print_red" in used, f"print_red should be in usage set. got: {used}" + assert "genuinely_unused" not in used, ( + f"genuinely_unused should NOT be in usage set. got: {used}" + ) + + def test_cpp_collector_exempts_used_consts_not_unused_fns(self): + """C++: constexpr const used multiple times -> in set. Unused fn -> NOT in set.""" + code = """#include + +constexpr const char* RED = "\\x1b[31m"; +constexpr const char* GREEN = "\\x1b[32m"; + +void print_red(const char* msg) { + std::cout << RED << msg << std::endl; +} + +void verify_all() { + print_red("error 1"); + print_red("error 2"); + const char* colors[] = {RED, GREEN, RED, GREEN, RED}; +} + +int genuinely_unused() { + return 42; +} +""" + usages = {} + _collect_c_same_file_usages(code, "colors.cpp", usages) + used = usages["colors.cpp"] + assert "RED" in used, f"RED should be in usage set. got: {used}" + assert "GREEN" in used, f"GREEN should be in usage set. got: {used}" + assert "print_red" in used, f"print_red should be in usage set. got: {used}" + assert "genuinely_unused" not in used, ( + f"genuinely_unused should NOT be in usage set. got: {used}" + ) + + +class TestIssue220DetectDeadFromRegistry: + """Issue #220: verify _detect_dead_from_registry exempts same-file usages.""" + + def test_registry_dead_exempts_same_file_usage(self): + """A symbol with ref_count==0 and status==dead BUT used in its own + file should NOT be flagged. A genuinely unused symbol (not in + same_file_usages) SHOULD still be flagged. + """ + import json + ws = tempfile.mkdtemp() + try: + registry = { + "nodes": [ + { + "fn": "RED", "file": "verify.rs", "line": 4, + "ref_count": 0, "status": "dead", "type": "const", + "pub": False + }, + { + "fn": "genuinely_unused", "file": "verify.rs", "line": 27, + "ref_count": 0, "status": "dead", "type": "function", + "pub": False + }, + ], + "edges": [] + } + codelens_dir = os.path.join(ws, ".codelens") + os.makedirs(codelens_dir, exist_ok=True) + with open(os.path.join(codelens_dir, "backend.json"), 'w') as f: + json.dump(registry, f) + + same_file_usages = { + "verify.rs": {"RED", "GREEN", "print_red"} + } + + result = _detect_dead_from_registry(ws, same_file_usages) + names = {item["name"] for item in result} + + assert "RED" not in names, ( + f"RED should be exempted (in same_file_usages) but was flagged. " + f"Findings: {result}" + ) + assert "genuinely_unused" in names, ( + f"genuinely_unused should be flagged (not in same_file_usages) " + f"but was not. Findings: {result}" + ) + finally: + shutil.rmtree(ws, ignore_errors=True) + + def test_registry_dead_without_same_file_usages_flags_everything(self): + """Without same_file_usages (pre-fix behavior), all dead nodes are flagged.""" + import json + ws = tempfile.mkdtemp() + try: + registry = { + "nodes": [ + { + "fn": "RED", "file": "verify.rs", "line": 4, + "ref_count": 0, "status": "dead", "type": "const", + "pub": False + }, + ], + "edges": [] + } + codelens_dir = os.path.join(ws, ".codelens") + os.makedirs(codelens_dir, exist_ok=True) + with open(os.path.join(codelens_dir, "backend.json"), 'w') as f: + json.dump(registry, f) + + result = _detect_dead_from_registry(ws, None) + names = {item["name"] for item in result} + assert "RED" in names, ( + f"Without same_file_usages, RED should be flagged. Findings: {result}" + ) + finally: + shutil.rmtree(ws, ignore_errors=True)