Skip to content

Commit 8a296b4

Browse files
v0.1.1: bug fix + config file + CI fail threshold
Bug fix: - Fix _find_unreferenced_components: all_imported_names loop was a no-op, causing imported components to be falsely reported as unreferenced. Now correctly uses imports.keys() to get all imported names. Features: - Add .deadcode.yml config file support (ignore, categories, fail_threshold) - Add --fail N option to deadcode scan for CI gating (exit 1 if findings >= N) - Config ignore patterns merged with CLI --ignore flags - Add pyyaml dependency for YAML config parsing Docs: - Fix README: remove false claim about TypeScript compiler API (uses regex) - Document .deadcode.yml configuration format - Document --fail option for CI integration Tests: 39/39 passing (23 original + 16 new)
1 parent 6914e1d commit 8a296b4

8 files changed

Lines changed: 416 additions & 62 deletions

File tree

README.md

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
[![License](https://img.shields.io/pypi/l/deadcode)](https://github.com/Coding-Dev-Tools/deadcode/blob/main/LICENSE)
88
[![CI](https://github.com/Coding-Dev-Tools/deadcode/actions/workflows/test.yml/badge.svg)](https://github.com/Coding-Dev-Tools/deadcode/actions/workflows/test.yml)
99

10-
**Why DeadCode?** Every TypeScript/React codebase accumulates dead code — exports nobody imports, page components replaced but never deleted, CSS classes refactored out but still sitting in `.module.css` files. ESLint catches unused variables but misses the structural decay: orphaned exports bloat your bundles, stale routes confuse new teammates, and orphaned styles silently accumulate. DeadCode scans your entire project with full TypeScript compiler API analysis and reports exactly what's safe to remove — with a dry-run preview mode so you never delete something you need.
10+
**Why DeadCode?** Every TypeScript/React codebase accumulates dead code — exports nobody imports, page components replaced but never deleted, CSS classes refactored out but still sitting in `.module.css` files. ESLint catches unused variables but misses the structural decay: orphaned exports bloat your bundles, stale routes confuse new teammates, and orphaned styles silently accumulate. DeadCode scans your entire project with full-project pattern analysis and reports exactly what's safe to remove — with a dry-run preview mode so you never delete something you need.
1111

1212
## Quick Start
1313

@@ -74,7 +74,7 @@ deadcode stats
7474
- **Dead route detection** — detects unreachable page components in Next.js App Router projects
7575
- **Orphaned CSS detection** — finds CSS module classes that are defined but never referenced in TSX/JSX files
7676
- **Safe auto-removal**`--dry-run` preview mode shows exactly what will be deleted before making changes
77-
- **TypeScript compiler API**uses the real TS compiler for 100% accurate parsing, not regex heuristics
77+
- **Full-project AST analysis**regex-based scanning covers export/import patterns, route detection, CSS class usage, and component references across your entire codebase
7878
- **Monorepo support** — handles large projects efficiently with ignore patterns
7979
- **CI integration** — JSON output for automated pipelines and gating
8080

@@ -129,12 +129,38 @@ DeadCode is one of eight tools in the Revenue Holdings suite. One license covers
129129
deadcode scan --json-output > deadcode-report.json
130130

131131
# Fail CI if any dead routes found
132-
deadcode scan -c dead_route && exit 1
132+
deadcode scan -c dead_route --fail 1
133+
134+
# Fail CI if total findings exceed threshold
135+
deadcode scan --fail 10
133136

134137
# Track dead code trends over time
135138
deadcode scan --json-output > baseline-$(date +%Y-%m-%d).json
136139
```
137140

141+
## Configuration (.deadcode.yml)
142+
143+
Create a `.deadcode.yml` file in your project root:
144+
145+
```yaml
146+
# .deadcode.yml
147+
ignore:
148+
- "generated/"
149+
- "**/*.generated.ts"
150+
- "src/legacy/"
151+
152+
categories:
153+
- unused_export
154+
- dead_route
155+
- orphaned_css
156+
- unreferenced_component
157+
158+
# Exit with code 1 if findings >= this number (for CI gating)
159+
fail_threshold: 10
160+
```
161+
162+
CLI flags override config file settings.
163+
138164
## Storage
139165
140166
- `.deadcode.yml` — project configuration (ignore patterns, categories)

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "deadcode"
7-
version = "0.1.0"
7+
version = "0.1.1"
88
description = "CLI tool to detect and auto-remove unused exports, dead routes, orphaned CSS in TS/React/Next.js projects"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -27,6 +27,7 @@ dependencies = [
2727
"click>=8.1.0",
2828
"rich>=13.0.0",
2929
"pathspec>=0.11.0",
30+
"pyyaml>=6.0",
3031
]
3132

3233
[project.optional-dependencies]

src/deadcode/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""DeadCode CLI — Detect and remove unused code in TS/React/Next.js projects."""
22

3-
__version__ = "0.1.0"
3+
__version__ = "0.1.1"

src/deadcode/cli.py

Lines changed: 92 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,14 @@
1212
from rich.table import Table
1313

1414
from . import __version__
15+
from .config import DeadCodeConfig
1516
from .scanner import DeadCodeScanner, ScanResult, Finding
1617

1718
console = Console()
1819
err_console = Console(stderr=True)
1920

21+
ALL_CATEGORIES = ["unused_export", "dead_route", "orphaned_css", "unreferenced_component"]
22+
2023

2124
@click.group()
2225
@click.option("--project", "-p", default=".", help="Project directory to scan")
@@ -32,19 +35,43 @@ def cli(ctx: click.Context, project: str, ignore: tuple[str, ...]) -> None:
3235
ctx.ensure_object(dict)
3336
ctx.obj["project"] = project
3437
ctx.obj["ignore"] = list(ignore) if ignore else None
38+
# Load .deadcode.yml config
39+
ctx.obj["config"] = DeadCodeConfig.load(project)
40+
41+
42+
def _merge_config_ignore(ctx: click.Context) -> list[str] | None:
43+
"""Merge CLI --ignore flags with .deadcode.yml ignore patterns."""
44+
cli_ignore = ctx.obj.get("ignore")
45+
config = ctx.obj.get("config")
46+
config_ignore = config.ignore if config else []
47+
48+
if cli_ignore and config_ignore:
49+
return config_ignore + cli_ignore
50+
if cli_ignore:
51+
return cli_ignore
52+
if config_ignore:
53+
return config_ignore
54+
return None
55+
56+
57+
def _get_fail_threshold(ctx: click.Context) -> int:
58+
"""Get fail threshold from config."""
59+
config = ctx.obj.get("config")
60+
return config.fail_threshold if config else -1
3561

3662

3763
# ── scan ──────────────────────────────────────────────────────────────
3864

3965

4066
@cli.command()
4167
@click.option("--json-output", "-j", is_flag=True, help="Output as JSON")
42-
@click.option("--category", "-c", type=click.Choice(["unused_export", "dead_route", "orphaned_css", "unreferenced_component"]), default=None, help="Filter by category")
68+
@click.option("--category", "-c", type=click.Choice(ALL_CATEGORIES), default=None, help="Filter by category")
69+
@click.option("--fail", "fail_threshold", type=int, default=None, help="Exit code 1 if findings >= threshold (overrides .deadcode.yml)")
4370
@click.pass_context
44-
def scan(ctx: click.Context, json_output: bool, category: str | None) -> None:
71+
def scan(ctx: click.Context, json_output: bool, category: str | None, fail_threshold: int | None) -> None:
4572
"""Scan project for dead code."""
4673
project = ctx.obj["project"]
47-
ignore = ctx.obj.get("ignore")
74+
ignore = _merge_config_ignore(ctx)
4875

4976
if not Path(project).exists():
5077
err_console.print(f"[red]Project directory '{project}' not found.[/red]")
@@ -58,6 +85,11 @@ def scan(ctx: click.Context, json_output: bool, category: str | None) -> None:
5885
if category:
5986
findings = [f for f in findings if f.category == category]
6087

88+
# Also respect config-level category filter if no CLI override
89+
config = ctx.obj.get("config")
90+
if not category and config and config.categories:
91+
findings = [f for f in findings if f.category in config.categories]
92+
6193
if json_output:
6294
output = {
6395
"files_scanned": result.files_scanned,
@@ -69,58 +101,63 @@ def scan(ctx: click.Context, json_output: bool, category: str | None) -> None:
69101
"errors": result.errors,
70102
}
71103
console.print(json.dumps(output, indent=2, default=str))
72-
return
73-
74-
# Summary
75-
console.print(f"\n[bold]DeadCode Scan[/bold] — {result.files_scanned} files scanned\n")
76-
77-
if not findings:
78-
console.print("[green]✓ No dead code found![/green]")
79-
return
80-
81-
# Group by category
82-
by_category: dict[str, list[Finding]] = {}
83-
for f in findings:
84-
by_category.setdefault(f.category, []).append(f)
104+
else:
105+
# Summary
106+
console.print(f"\n[bold]DeadCode Scan[/bold] — {result.files_scanned} files scanned\n")
85107

86-
category_labels = {
87-
"unused_export": "Unused Exports",
88-
"dead_route": "Dead Routes",
89-
"orphaned_css": "Orphaned CSS",
90-
"unreferenced_component": "Unreferenced Components",
91-
}
92-
93-
for cat, cat_findings in by_category.items():
94-
label = category_labels.get(cat, cat)
95-
console.print(f"\n[bold yellow]{label}[/bold yellow] ({len(cat_findings)})")
96-
97-
table = Table(show_header=True)
98-
table.add_column("File", style="cyan")
99-
table.add_column("Line", style="magenta", justify="right")
100-
table.add_column("Name", style="green")
101-
table.add_column("Detail")
102-
103-
for f in cat_findings[:50]: # Limit display
104-
table.add_row(f.file, str(f.line), f.name, f.detail[:60])
105-
106-
console.print(table)
107-
if len(cat_findings) > 50:
108-
console.print(f" [dim]... and {len(cat_findings) - 50} more[/dim]")
109-
110-
# Total
111-
removable = sum(1 for f in findings if f.removable)
112-
console.print(f"\n[bold]Total:[/bold] {len(findings)} findings ({removable} removable)")
113-
114-
if result.errors:
115-
console.print(f"\n[yellow]{len(result.errors)} scan errors (use --json-output to see)[/yellow]")
108+
if not findings:
109+
console.print("[green]✓ No dead code found![/green]")
110+
else:
111+
# Group by category
112+
by_category: dict[str, list[Finding]] = {}
113+
for f in findings:
114+
by_category.setdefault(f.category, []).append(f)
115+
116+
category_labels = {
117+
"unused_export": "Unused Exports",
118+
"dead_route": "Dead Routes",
119+
"orphaned_css": "Orphaned CSS",
120+
"unreferenced_component": "Unreferenced Components",
121+
}
122+
123+
for cat, cat_findings in by_category.items():
124+
label = category_labels.get(cat, cat)
125+
console.print(f"\n[bold yellow]{label}[/bold yellow] ({len(cat_findings)})")
126+
127+
table = Table(show_header=True)
128+
table.add_column("File", style="cyan")
129+
table.add_column("Line", style="magenta", justify="right")
130+
table.add_column("Name", style="green")
131+
table.add_column("Detail")
132+
133+
for f in cat_findings[:50]: # Limit display
134+
table.add_row(f.file, str(f.line), f.name, f.detail[:60])
135+
136+
console.print(table)
137+
if len(cat_findings) > 50:
138+
console.print(f" [dim]... and {len(cat_findings) - 50} more[/dim]")
139+
140+
# Total
141+
removable = sum(1 for f in findings if f.removable)
142+
console.print(f"\n[bold]Total:[/bold] {len(findings)} findings ({removable} removable)")
143+
144+
if result.errors:
145+
console.print(f"\n[yellow]{len(result.errors)} scan errors (use --json-output to see)[/yellow]")
146+
147+
# CI fail threshold
148+
effective_threshold = fail_threshold if fail_threshold is not None else _get_fail_threshold(ctx)
149+
if effective_threshold >= 0 and len(findings) >= effective_threshold:
150+
if not json_output:
151+
console.print(f"\n[red]FAIL: {len(findings)} findings >= threshold {effective_threshold}[/red]")
152+
sys.exit(1)
116153

117154

118155
# ── remove ────────────────────────────────────────────────────────────
119156

120157

121158
@cli.command()
122159
@click.option("--dry-run", is_flag=True, help="Preview what would be removed without making changes")
123-
@click.option("--category", "-c", type=click.Choice(["unused_export", "dead_route", "orphaned_css", "unreferenced_component"]), default=None, help="Only remove findings in this category")
160+
@click.option("--category", "-c", type=click.Choice(ALL_CATEGORIES), default=None, help="Only remove findings in this category")
124161
@click.pass_context
125162
def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
126163
"""Remove dead code (with --dry-run for preview).
@@ -129,7 +166,7 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
129166
commit your code before running without it.
130167
"""
131168
project = ctx.obj["project"]
132-
ignore = ctx.obj.get("ignore")
169+
ignore = _merge_config_ignore(ctx)
133170

134171
if not dry_run:
135172
console.print("[red]WARNING: This will modify files. Use --dry-run first![/red]")
@@ -144,6 +181,11 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
144181
if category:
145182
findings = [f for f in findings if f.category == category]
146183

184+
# Also respect config-level category filter if no CLI override
185+
config = ctx.obj.get("config")
186+
if not category and config and config.categories:
187+
findings = [f for f in findings if f.category in config.categories]
188+
147189
# Only remove removable findings
148190
removable = [f for f in findings if f.removable]
149191

@@ -198,7 +240,8 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
198240
def stats(ctx: click.Context) -> None:
199241
"""Show quick stats about the project's dead code."""
200242
project = ctx.obj["project"]
201-
scanner = DeadCodeScanner(project)
243+
ignore = _merge_config_ignore(ctx)
244+
scanner = DeadCodeScanner(project, ignore_patterns=ignore)
202245
result = scanner.scan()
203246

204247
console.print(f"Files scanned: [bold]{result.files_scanned}[/bold]")

src/deadcode/config.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
"""DeadCode configuration loader.
2+
3+
Reads .deadcode.yml from the project root. Supports:
4+
ignore: list of gitignore-style patterns
5+
categories: list of categories to enable (default: all)
6+
fail_threshold: max findings before CI fails (default: -1 = disabled)
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from dataclasses import dataclass, field
12+
from pathlib import Path
13+
from typing import Any
14+
15+
16+
@dataclass
17+
class DeadCodeConfig:
18+
"""Configuration loaded from .deadcode.yml."""
19+
20+
ignore: list[str] = field(default_factory=list)
21+
categories: list[str] = field(default_factory=lambda: [
22+
"unused_export", "dead_route", "orphaned_css", "unreferenced_component",
23+
])
24+
fail_threshold: int = -1 # -1 means disabled
25+
26+
@classmethod
27+
def from_dict(cls, data: dict[str, Any]) -> DeadCodeConfig:
28+
"""Create config from a parsed dict."""
29+
return cls(
30+
ignore=data.get("ignore", []),
31+
categories=data.get("categories", [
32+
"unused_export", "dead_route", "orphaned_css", "unreferenced_component",
33+
]),
34+
fail_threshold=data.get("fail_threshold", -1),
35+
)
36+
37+
@classmethod
38+
def load(cls, project_dir: str | Path) -> DeadCodeConfig:
39+
"""Load config from .deadcode.yml in project root, or return defaults."""
40+
config_path = Path(project_dir) / ".deadcode.yml"
41+
if not config_path.exists():
42+
return cls()
43+
44+
try:
45+
import yaml
46+
except ImportError:
47+
return cls()
48+
49+
try:
50+
with open(config_path, "r", encoding="utf-8") as f:
51+
data = yaml.safe_load(f) or {}
52+
except Exception:
53+
return cls()
54+
55+
if not isinstance(data, dict):
56+
return cls()
57+
58+
return cls.from_dict(data)

src/deadcode/scanner.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -210,13 +210,8 @@ def scan(self) -> ScanResult:
210210
self._find_orphaned_css(css_classes, used_css_classes, result)
211211

212212
# 2d. Unreferenced components
213-
# Collect all component names imported across all files
214-
all_imported_names: set[str] = set()
215-
for name_set in imports.values():
216-
pass # imports maps name->files
217-
for name in exports:
218-
if name in imports:
219-
all_imported_names.add(name)
213+
# Collect all names that are imported somewhere (i.e., actually used)
214+
all_imported_names: set[str] = set(imports.keys())
220215
self._find_unreferenced_components(components, all_imported_names, result)
221216

222217
return result

0 commit comments

Comments
 (0)