@register_rule("lint")
class DeadCodeRule(ProjectRule):
"""Detect dead (unreferenced) code using axm-ast.
Gracefully skips if axm-ast is not available in the environment.
Scoring: 100 - (dead_symbols_count * 5), min 0.
"""
@property
def rule_id(self) -> str:
"""Unique identifier for this rule."""
return "QUALITY_DEAD_CODE"
def _skip(self, reason: str) -> CheckResult:
"""Return graceful skip result."""
return CheckResult(
rule_id=self.rule_id,
passed=True, # Passing so it doesn't fail the build
message=f"Skipped: {reason}",
severity=Severity.INFO,
score=100,
details={"skipped": True, "reason": reason},
)
def check(self, project_path: Path) -> CheckResult:
"""Check for dead code using axm-ast dead-code via subprocess."""
availability = self._check_availability(project_path)
if availability is not None:
return availability
result = self._run_analysis(project_path)
if isinstance(result, CheckResult):
return result
dead_symbols = self._parse_dead_symbols(result)
if dead_symbols is None:
return CheckResult(
rule_id=self.rule_id,
passed=False,
message="Failed to parse axm-ast output",
severity=Severity.ERROR,
score=0,
details={
"stdout": result.stdout,
"stderr": result.stderr,
},
)
return self._build_result(dead_symbols)
def _check_availability(self, project_path: Path) -> CheckResult | None:
"""Return a skip result if axm-ast is not on the PATH, else None."""
if shutil.which("axm-ast") is None:
return self._skip("axm-ast is not available in the environment")
return None
def _run_analysis(
self,
project_path: Path,
) -> subprocess.CompletedProcess[str] | CheckResult:
"""Run axm-ast dead-code directly (resolved from PATH, not target venv)."""
try:
return subprocess.run( # noqa: S603
["axm-ast", "dead-code", str(project_path), "--json"], # noqa: S607
capture_output=True,
text=True,
check=False,
timeout=300,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return CheckResult(
rule_id=self.rule_id,
passed=False,
message="Failed to execute axm-ast",
severity=Severity.ERROR,
score=0,
fix_hint="Ensure axm-ast is installed in the audit environment",
)
def _parse_dead_symbols(
self,
result: subprocess.CompletedProcess[str],
) -> list[dict[str, str]] | None:
"""Parse JSON output from axm-ast, returning the dead symbols list.
Returns ``None`` when the output is not valid JSON.
"""
try:
out = result.stdout or "[]"
data = json.loads(out)
except json.JSONDecodeError:
return None
if isinstance(data, dict):
symbols: list[dict[str, str]] = data.get("dead_symbols", [])
return symbols
return data if isinstance(data, list) else []
def _build_result(self, dead_symbols: list[dict[str, str]]) -> CheckResult:
"""Build a CheckResult from the dead symbols list."""
dead_count = len(dead_symbols)
score = max(0.0, 100.0 - (dead_count * 5.0))
passed = dead_count == 0
message = (
"No dead code detected."
if passed
else f"Found {dead_count} dead (unreferenced) symbol(s)."
)
details: dict[str, object] = {
"dead_count": dead_count,
"symbols": dead_symbols,
}
if dead_symbols:
details["top_offenders"] = dead_symbols[:_MAX_TOP_OFFENDERS]
text_lines: list[str] = []
for sym in dead_symbols[:_MAX_TOP_OFFENDERS]:
path = sym.get("file", sym.get("module_path", ""))
if path.startswith("src/"):
path = path[4:]
text_lines.append(f"\u2022 {sym['name']} {path}:{sym.get('line', '')}")
if dead_count > _MAX_TOP_OFFENDERS:
text_lines.append(f"\u2022 +{dead_count - _MAX_TOP_OFFENDERS} more")
return CheckResult(
rule_id=self.rule_id,
passed=passed,
message=message,
severity=Severity.WARNING if dead_count > 0 else Severity.INFO,
score=int(score),
details=details,
fix_hint="Remove dead code or mark exported in __all__ if public API",
text="\n".join(text_lines) if text_lines else None,
)