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
39 changes: 37 additions & 2 deletions scripts/deadcode_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2151,6 +2151,22 @@ def _detect_unused_exports(

return unused[:200]

# Issue #228: Rust trait methods that are routinely invoked implicitly —
# via `#[derive(...)]`, generic trait dispatch, or `..Default::default()`
# spread syntax — so the CALLS graph never records an edge and they surface
# as ref_count==0 false positives at a far higher rate than normal functions.
# Names taken from std's most-implemented traits (Default, Clone, Debug/Display,
# Drop, PartialEq/Eq, Hash, Ord, From/Into, TryFrom/TryInto, AsRef, Deref,
# Iterator, ToString, FromStr, serde, and the common operator traits).
_RUST_TRAIT_DEFAULT_METHODS = {
"default", "clone", "clone_from", "fmt", "drop", "eq", "ne", "hash",
"cmp", "partial_cmp", "from", "into", "try_from", "try_into",
"as_ref", "as_mut", "borrow", "borrow_mut", "deref", "deref_mut",
"next", "to_string", "to_owned", "from_str", "serialize", "deserialize",
"add", "sub", "mul", "div", "rem", "index", "index_mut", "neg", "not",
}


def _detect_dead_from_registry(
workspace: str,
same_file_usages: Dict[str, Set[str]] = None
Expand Down Expand Up @@ -2253,6 +2269,16 @@ def _rust_symbol_is_test(file_path: str, line: int) -> bool:
if node.get("component", False):
continue

# Issue #228: structural nodes are namespaces/containers, not
# callable entities. A module (`mod foo { ... }`, type == "module")
# is referenced by path, never "called". An impl block (synthetic
# `impl X` node, type == "impl", fn like `impl_X`, id like
# `file.rs:13:impl:X`) only groups methods — the methods inside are
# analyzed individually. Neither ever produces a CALLS edge, so both
# always show ref_count==0 and must never be flagged dead.
if node_type in ("module", "impl"):
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
Expand All @@ -2272,10 +2298,19 @@ def _rust_symbol_is_test(file_path: str, line: int) -> bool:
# Skip Rust inline test modules/functions (#[cfg(test)] mod tests,
# #[test] fn ...) — see _rust_symbol_is_test docstring above.
if file_path.endswith('.rs'):
if node_type == "module" and bare_name == "tests":
continue
if _rust_symbol_is_test(file_path, line):
continue
# Issue #228: trait-method implementations (Default::default,
# Clone::clone, Debug::fmt, Drop::drop, PartialEq::eq,
# Hash::hash, etc.) are invoked implicitly through derive macros,
# generic trait dispatch, and `..Default::default()` spread —
# none of which the CALLS extractor can observe, so their
# false-positive rate is far higher than normal fns. Skip a
# targeted whitelist of well-known trait method names. This stays
# narrow: any standalone fn whose name is NOT on the list is
# still eligible for dead-code flagging.
if bare_name in _RUST_TRAIT_DEFAULT_METHODS:
continue

# Skip test fixtures and example files
# v6.4: Expanded to catch examples/, e2e/, __tests__/, stories/
Expand Down
171 changes: 171 additions & 0 deletions tests/test_issue228_rust_impl_dead.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
"""Regression guard for issue #228 — Rust modules / impl-blocks / trait-default
methods must NOT be false-flagged as dead code, while a genuinely-dead
standalone fn IS still flagged.

WHY THIS EXISTS
---------------
`audit --check dead-code` (→ `detect_dead_code` → `_detect_dead_from_registry`)
reads the backend registry and flags nodes with ref_count==0 / status=="dead".
Three Rust structural/implicit patterns produce ref_count==0 in the raw graph
even though they are never dead:

1. Module declarations (`mod foo { ... }`) — a namespace referenced by path,
never "called", so it emits no CALLS edge.
2. Impl blocks (synthetic `impl X` node, id `file.rs:LINE:impl:X`) — only the
methods inside are individually analyzed; the block itself is a container.
3. Trait-default methods (`default`, `clone`, `fmt`, `drop`, `eq`, `hash`, …)
— invoked implicitly via derive macros / `..Default::default()` / generic
trait dispatch, which the CALLS extractor can't see.

The control (`orphan_helper`) is a truly-unreferenced standalone fn: it MUST
still be reported dead, proving the fix narrows false positives without
disabling genuine detection.
"""

import os
import shutil
import sys

import pytest

SCRIPT_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
sys.path.insert(0, SCRIPT_DIR)


def _tree_sitter_available() -> bool:
try:
import tree_sitter # noqa: F401
return True
except ImportError:
return False


_TS = _tree_sitter_available()
_SKIP = "tree-sitter not installed"


# ─── Scan harness (mirrors tests/test_graph_accuracy_golden.py) ──────────

def _run_scan(workspace: str) -> None:
"""Run `codelens scan <workspace>` in-process to populate backend.json."""
import io
import contextlib
from codelens import main as codelens_main

old_argv = sys.argv
old_cwd = os.getcwd()
try:
sys.argv = ["codelens", "scan", workspace]
os.chdir(workspace)
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
try:
codelens_main()
except SystemExit:

Check failure on line 63 in tests/test_issue228_rust_impl_dead.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Reraise this exception to stop the application as the user expects

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ9fdRYFN1_F9MCiCtUW&open=AZ9fdRYFN1_F9MCiCtUW&pullRequest=282
pass
finally:
sys.argv = old_argv
os.chdir(old_cwd)


# ─── Fixture: one Rust file exercising all four patterns ─────────────────

_ISSUE228_RS = """\
// Guards #228: modules, impl-blocks and trait-default methods must NOT be
// flagged dead; a genuinely-unreferenced standalone fn MUST be.

pub struct Widget {
label: String,
}

// (b) an impl block with a method that IS used (see `build` below).
impl Widget {
pub fn describe(&self) -> &str {
&self.label
}
}

// (c) a trait-default method implemented via a trait impl. `default` is called
// implicitly through `Widget::default()` / `..Default::default()` mechanisms
// that the CALLS extractor cannot observe.
impl Default for Widget {
fn default() -> Self {
Widget { label: String::from("widget") }
}
}

pub fn build() -> String {
let w = Widget::default();
w.describe().to_string()
}

// (d) GENUINELY dead: never referenced anywhere, not pub, not a trait method.
fn orphan_helper() -> i32 {
99
}

// (a) a module declaration — a namespace referenced by path, never "called".
mod helpers {
pub fn noop() {}
}
"""


def _norm(s: str) -> str:
return str(s).replace("\\", "/")


@pytest.fixture(scope="module")
def findings(tmp_path_factory):
"""Scan a workspace with the issue #228 Rust fixture; return dead-code findings."""
if not _TS:
pytest.skip(_SKIP)
ws = str(tmp_path_factory.mktemp("issue228") / "ws")
os.makedirs(os.path.join(ws, "src"))
with open(os.path.join(ws, "src", "widget.rs"), "w", encoding="utf-8") as f:
f.write(_ISSUE228_RS)
try:
_run_scan(ws)
from deadcode_engine import detect_dead_code
res = detect_dead_code(ws)
# registry_dead is where _detect_dead_from_registry results land.
reg = res.get("results", {}).get("registry_dead", [])
yield reg
finally:
shutil.rmtree(ws, ignore_errors=True)


def _names(findings):
return {_norm(f.get("name", "")) for f in findings}


class TestIssue228RustImplDead:
"""modules / impl-blocks / trait-defaults NOT dead; orphan fn IS dead."""

def test_module_not_flagged(self, findings):
blob = _norm(findings)

Check warning on line 145 in tests/test_issue228_rust_impl_dead.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the unused local variable "blob".

See more on https://sonarcloud.io/project/issues?id=Wolfvin_CodeLens&issues=AZ9fdRYFN1_F9MCiCtUX&open=AZ9fdRYFN1_F9MCiCtUX&pullRequest=282
assert not any(
(f.get("type") == "module") or "helpers" in _norm(f.get("name", ""))
for f in findings
), f"#228: module declaration false-flagged as dead: {findings}"

def test_impl_block_not_flagged(self, findings):
assert not any(
(f.get("type") == "impl")
or _norm(f.get("name", "")).startswith("impl_")
or ":impl:" in _norm(f.get("name", ""))
for f in findings
), f"#228: impl block false-flagged as dead: {findings}"

def test_trait_default_method_not_flagged(self, findings):
names = _names(findings)
offenders = {n for n in names if n.split("::")[-1] == "default"}
assert not offenders, (
f"#228: trait-default method `default` false-flagged as dead: {offenders}"
)

def test_genuinely_dead_fn_is_flagged(self, findings):
names = _names(findings)
assert any(n.split("::")[-1] == "orphan_helper" for n in names), (
"#228 control: genuinely-dead `orphan_helper` must still be flagged, "
f"got dead names: {names}"
)
Loading