Skip to content

Commit 29a62af

Browse files
Harden remove against multi-line corruption; fix docs + CI
- remove: only blank self-contained single-line findings; skip multi-line constructs with a warning so it never leaves syntactically broken code - README: 'AST analysis' -> regex-based; clarify safe-removal behavior - package.json: correct description (TS/React/Next.js, not Python) - CI: drop pytest -x so all failures surface; add coverage
1 parent 90afaf1 commit 29a62af

4 files changed

Lines changed: 54 additions & 57 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ jobs:
3636
run: ruff check .
3737

3838
- name: Run tests
39-
run: python -m pytest tests/ -x -q
39+
run: python -m pytest tests/ -q --cov=deadcode --cov-report=term-missing
4040

4141
publish:
4242
needs: test

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,8 @@ deadcode stats
7676
- **Unused export detection** — finds functions, types, classes, interfaces, enums, and consts that are exported but never imported within your project
7777
- **Dead route detection** — detects unreachable page components in Next.js App Router projects
7878
- **Orphaned CSS detection** — finds CSS module classes that are defined but never referenced in TSX/JSX files
79-
- **Safe auto-removal**`--dry-run` preview mode shows exactly what will be deleted before making changes
80-
- **Full-project AST analysis** — regex-based scanning covers export/import patterns, route detection, CSS class usage, and component references across your entire codebase
79+
- **Safe auto-removal**`--dry-run` preview shows exactly what will be deleted first; `remove` only blanks self-contained single-line findings and skips (with a warning) anything spanning multiple lines, so it never leaves half-deleted, broken code
80+
- **Full-project scanning**fast regex-based scanning covers export/import patterns, route detection, CSS class usage, and component references across your entire codebase (no AST/tree-sitter — deliberately dependency-free and quick)
8181
- **Monorepo support** — handles large projects efficiently with ignore patterns
8282
- **CI integration** — JSON output for automated pipelines and gating
8383

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "deadcode-cli",
33
"version": "0.1.1",
4-
"description": "Find unused/dead code in Python projects. Static analysis tool to identify orphaned functions, classes, and imports.",
4+
"description": "Find unused/dead code in TypeScript, React, and Next.js projects. Scans for orphaned exports, dead routes, unreferenced components, and unused CSS module classes.",
55
"author": "Revenue Holdings <engineering@revenueholdings.dev>",
66
"license": "MIT",
77
"repository": {

src/deadcode/cli.py

Lines changed: 50 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,35 @@
2727
FORMAT_CHOICES = click.Choice(["pretty", "compact", "github", "json"])
2828

2929

30+
def _line_self_contained(text: str) -> bool:
31+
"""Return True if a line's brackets/braces/parens are balanced on that line.
32+
33+
Used by ``remove`` to decide whether a single reported line can be safely
34+
blanked. A balanced line is a complete one-liner (``export const X = 1;`` or
35+
``.foo { color: red; }``); a line that opens a brace/bracket/paren it never
36+
closes is the start of a multi-line construct and must not be blanked in
37+
isolation. String and template-literal contents are ignored so brackets
38+
inside quotes don't skew the count.
39+
"""
40+
depth = 0
41+
in_str: str | None = None
42+
prev = ""
43+
for ch in text:
44+
if in_str is not None:
45+
if ch == in_str and prev != "\\":
46+
in_str = None
47+
elif ch in ("'", '"', "`"):
48+
in_str = ch
49+
elif ch in "([{":
50+
depth += 1
51+
elif ch in ")]}":
52+
depth -= 1
53+
if depth < 0: # closes something opened on an earlier line
54+
return False
55+
prev = ch
56+
return depth == 0
57+
58+
3059
@click.group()
3160
@click.option("--project", "-p", default=".", help="Project directory to scan")
3261
@click.option(
@@ -315,61 +344,29 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
315344
console.print(f"[red]Error reading {rel_file}: {e}[/red]")
316345
continue
317346

318-
# Remove lines in reverse order to preserve line numbers
319-
lines_to_remove = sorted(set(f.line for f in file_findings), reverse=True)
347+
# Findings carry only a start line, no span. Blanking a single line of a
348+
# multi-line construct (a multi-line `export { ... }`, a CSS rule, or a
349+
# component body) leaves dangling, syntactically-broken code — worse than
350+
# doing nothing. DeadCode is regex-based with no AST, so guard
351+
# conservatively: only blank a line whose brackets/braces/parens are
352+
# balanced on that line (it's a self-contained one-liner). Anything that
353+
# opens an unclosed block is skipped and reported for manual removal.
354+
candidate_lines = sorted(set(f.line for f in file_findings), reverse=True)
355+
safe_lines = [
356+
n
357+
for n in candidate_lines
358+
if 0 < n <= len(lines) and _line_self_contained(lines[n - 1])
359+
]
360+
skipped_lines = [n for n in candidate_lines if n not in safe_lines]
320361

321362
if dry_run:
322-
for line_num in sorted(lines_to_remove):
323-
content = lines[line_num - 1].rstrip() if line_num <= len(lines) else ""
363+
for line_num in sorted(safe_lines):
364+
content = lines[line_num - 1].strip()
324365
console.print(
325-
f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num}{content.strip()[:80]}"
366+
f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num}{content[:80]}"
326367
)
327-
removed_count += len(lines_to_remove)
368+
removed_count += len(safe_lines)
328369
else:
329-
for line_num in lines_to_remove:
330-
if 0 < line_num <= len(lines):
331-
lines[line_num - 1] = "" # Blank the line (safer than deleting)
332-
filepath.write_text("".join(lines), encoding="utf-8")
333-
removed_count += len(lines_to_remove)
334-
console.print(
335-
f"[green]✓[/green] Cleaned {rel_file} ({len(lines_to_remove)} lines)"
336-
)
337-
338-
action = "Would remove" if dry_run else "Removed"
339-
console.print(f"\n[bold]{action}: {removed_count} dead code entries[/bold]")
340-
341-
342-
# ── stats ─────────────────────────────────────────────────────────────
343-
344-
345-
@cli.command()
346-
@click.pass_context
347-
def stats(ctx: click.Context) -> None:
348-
"""Show quick stats about the project's dead code."""
349-
project = ctx.obj["project"]
350-
ignore = _merge_config_ignore(ctx)
351-
include_patterns = ctx.obj.get("include")
352-
scanner = DeadCodeScanner(
353-
project, ignore_patterns=ignore, include_patterns=include_patterns
354-
)
355-
result = scanner.scan()
356-
357-
console.print(f"Files scanned: [bold]{result.files_scanned}[/bold]")
358-
console.print(
359-
f"Unused exports: [bold yellow]{len(result.unused_exports)}[/bold yellow]"
360-
)
361-
console.print(f"Dead routes: [bold red]{len(result.dead_routes)}[/bold red]")
362-
console.print(
363-
f"Orphaned CSS: [bold magenta]{len(result.orphaned_css)}[/bold magenta]"
364-
)
365-
console.print(
366-
f"Unreferenced components: [bold cyan]{len(result.unreferenced_components)}[/bold cyan]"
367-
)
368-
console.print(f"Total findings: [bold]{len(result.findings)}[/bold]")
369-
370-
if result.errors:
371-
console.print(f"[yellow]Errors: {len(result.errors)}[/yellow]")
372-
373-
374-
if __name__ == "__main__":
375-
cli()
370+
for line_num in safe_lines:
371+
lines[line_num - 1] = "" # Blank the line (safer than deleting)
372+

0 commit comments

Comments
 (0)