Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1d7cb22
fix: move revenueholdings-license to optional [license] extra with gr…
Coding-Dev-Tools May 17, 2026
1d25cb8
Merge branch 'fix/optional-license-dep'
Coding-Dev-Tools Jun 15, 2026
56e8d90
docs: add AGENTS.md for agent discoverability
Coding-Dev-Tools Jun 23, 2026
34e889e
Merge docs: add AGENTS.md
Coding-Dev-Tools Jun 25, 2026
4a906cc
chore: add CODEOWNERS file
Coding-Dev-Tools Jun 29, 2026
61997cb
merge: origin/main into main (take revenueholdings-license import wit…
Jul 8, 2026
a3305d9
cowork-bot: standardize copyright holder to 2025 Coding-Dev-Tools (wa…
Jul 8, 2026
f8809cd
cowork-bot: fix false-positive orphaned CSS for CSS-module class acce…
cowork-bot Jul 17, 2026
05313a1
fix(ci): correct checkout SHA pin to actual v4 tag (11d5960)
Coding-Dev-Tools Jul 21, 2026
8f25880
fix(ci): correct checkout SHA pin to actual v4 tag (11d5960)
Coding-Dev-Tools Jul 21, 2026
55776d1
fix(ci): correct checkout SHA pin to actual v4 tag (11d5960)
Coding-Dev-Tools Jul 21, 2026
af155e1
fix: restore missing license value in package.json (JSON syntax error)
Jul 21, 2026
9824153
fix: remove unused _CLASSLIST_PATTERN dead code (no references in cod…
Coding-Dev-Tools Jul 21, 2026
2d99bde
chore: generate package-lock.json for dependency auditability
Coding-Dev-Tools Jul 22, 2026
7f43fb4
fix(ci): correct checkout SHA pin to actual v4 tag (11d5960)
Jul 21, 2026
d1439c1
fix(ci): revert invalid actions/checkout SHA pin to @v4 (11d5960 is n…
Jul 22, 2026
14762db
fix(ci): add missing checkout step to cowork-auto-pr.yml (gh pr creat…
Jul 22, 2026
01463a3
fix: address verified release audit findings
Coding-Dev-Tools Oct 16, 2026
34857c1
fix: address verified release audit findings
Coding-Dev-Tools Oct 16, 2026
d99cebb
merge: reconcile preserved scanner updates with current master
Jaixii Oct 17, 2026
900c5c7
merge: reconcile historical main without reverting current CLI contract
Jaixii Oct 17, 2026
dc6197d
merge: preserve metadata history with current copyright and package i…
Jaixii Oct 17, 2026
779fa79
fix(cli): preserve machine output and nested include patterns
Jaixii Oct 17, 2026
08a9f3c
style: satisfy existing formatter check without changing Python ASTs
Jaixii Oct 17, 2026
f17f2e7
fix(ci): deploy Pages only from the repository primary branch
Coding-Dev-Tools Oct 17, 2026
feed852
fix: scope CSS module class uses to their imported stylesheet
Jaixii Oct 17, 2026
9dbded5
fix: keep unresolved CSS module imports safe during removal
Jaixii Oct 17, 2026
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,4 @@ jobs:
run: python -m build

- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
9 changes: 9 additions & 0 deletions .github/workflows/cowork-auto-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ jobs:
ensure-pr:
runs-on: ubuntu-latest
steps:
# gh pr create requires a local git checkout to diff head against base;
# without this step every run failed with "not a git repository" and no
# PR was ever opened (fleet-wide defect: 11/11 seeded copies lacked it).
- name: Check out the pushed branch
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
with:
ref: ${{ github.ref_name }}
persist-credentials: false
fetch-depth: 0
- name: Open PR for this branch if none exists
env:
GH_TOKEN: ${{ github.token }}
Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ name: Deploy GitHub Pages

on:
push:
branches: [master, main]
branches: [master]
workflow_dispatch:

permissions:
Expand All @@ -16,6 +16,7 @@ concurrency:

jobs:
build:
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
Expand All @@ -32,6 +33,7 @@ jobs:
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa

deploy:
if: github.ref == format('refs/heads/{0}', github.event.repository.default_branch)
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
Expand Down
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"version": "0.1.1",
"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.",
"author": "Coding-Dev-Tools",
"license"
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Coding-Dev-Tools/deadcode.git"
Expand Down
1 change: 1 addition & 0 deletions src/deadcode/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Allow running deadcode as: python -m deadcode"""

from .cli import cli

if __name__ == "__main__":
Expand Down
104 changes: 36 additions & 68 deletions src/deadcode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
FORMAT_CHOICES = click.Choice(["pretty", "compact", "github", "json"])


def _escape_annotation(value: str, *, property_value: bool = False) -> str:
"""Escape data for GitHub's workflow-command protocol."""
value = value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
if property_value:
value = value.replace(":", "%3A").replace(",", "%2C")
return value


def _line_self_contained(text: str) -> bool:
"""Return True if a line's brackets/braces/parens are balanced on that line.

Expand Down Expand Up @@ -63,19 +71,15 @@ def _line_self_contained(text: str) -> bool:

@click.group()
@click.option("--project", "-p", default=".", help="Project directory to scan")
@click.option(
"--ignore", "-i", multiple=True, help="Additional ignore patterns (gitignore-style)"
)
@click.option("--ignore", "-i", multiple=True, help="Additional ignore patterns (gitignore-style)")
@click.option(
"--include",
multiple=True,
help="Include only matching files (gitignore-style whitelist)",
)
@click.version_option(__version__, prog_name="deadcode")
@click.pass_context
def cli(
ctx: click.Context, project: str, ignore: tuple[str, ...], include: tuple[str, ...]
) -> None:
def cli(ctx: click.Context, project: str, ignore: tuple[str, ...], include: tuple[str, ...]) -> None:
"""DeadCode — Find and remove dead code in TS/React/Next.js projects.

Scans for unused exports, dead routes, orphaned CSS classes,
Expand Down Expand Up @@ -114,9 +118,7 @@ def _get_fail_threshold(ctx: click.Context) -> int:


@cli.command()
@click.option(
"--json-output", "-j", is_flag=True, help="Alias for --format=json (deprecated)"
)
@click.option("--json-output", "-j", is_flag=True, help="Alias for --format=json (deprecated)")
@click.option("--format", type=FORMAT_CHOICES, default="pretty", help=FORMAT_HELP)
@click.option(
"--category",
Expand Down Expand Up @@ -149,9 +151,7 @@ def scan(
sys.exit(1)

include_patterns = ctx.obj.get("include")
scanner = DeadCodeScanner(
project, ignore_patterns=ignore, include_patterns=include_patterns
)
scanner = DeadCodeScanner(project, ignore_patterns=ignore, include_patterns=include_patterns)
result = scanner.scan()

# Filter by category
Expand Down Expand Up @@ -183,7 +183,9 @@ def scan(
],
"errors": result.errors,
}
console.print(json.dumps(output, indent=2, default=str))
# Machine output must preserve literal paths and remain valid at any
# terminal width; Rich interprets markup and wraps long JSON strings.
click.echo(json.dumps(output, indent=2, default=str))
elif effective_format == "compact":
if not findings:
console.print("OK — 0 findings")
Expand All @@ -195,20 +197,19 @@ def scan(
# GitHub Actions annotation syntax
# ::warning file={name},line={line},endLine={line}::{message}
if not findings:
console.print("deadcode: 0 findings")
click.echo("deadcode: 0 findings")
else:
for f in findings:
level = "error" if f.removable else "warning"
msg = f"{f.category}: {f.name}"
if f.detail:
msg += f" ({f.detail[:120]})"
console.print(f"::{level} file={f.file},line={f.line}::{msg}")
console.print(f"\n::notice::deadcode: {len(findings)} findings")
file = _escape_annotation(f.file, property_value=True)
click.echo(f"::{level} file={file},line={f.line}::{_escape_annotation(msg)}")
click.echo(f"\n::notice::deadcode: {len(findings)} findings")
else:
# Summary
console.print(
f"\n[bold]DeadCode Scan[/bold] — {result.files_scanned} files scanned\n"
)
console.print(f"\n[bold]DeadCode Scan[/bold] — {result.files_scanned} files scanned\n")

if not findings:
console.print("[green]✓ No dead code found![/green]")
Expand All @@ -227,9 +228,7 @@ def scan(

for cat, cat_findings in by_category.items():
label = category_labels.get(cat, cat)
console.print(
f"\n[bold yellow]{label}[/bold yellow] ({len(cat_findings)})"
)
console.print(f"\n[bold yellow]{label}[/bold yellow] ({len(cat_findings)})")

table = Table(show_header=True)
table.add_column("File", style="cyan")
Expand All @@ -246,24 +245,16 @@ def scan(

# Total
removable = sum(1 for f in findings if f.removable)
console.print(
f"\n[bold]Total:[/bold] {len(findings)} findings ({removable} removable)"
)
console.print(f"\n[bold]Total:[/bold] {len(findings)} findings ({removable} removable)")

if result.errors:
console.print(
f"\n[yellow]{len(result.errors)} scan errors (use --json-output to see)[/yellow]"
)
console.print(f"\n[yellow]{len(result.errors)} scan errors (use --json-output to see)[/yellow]")

# CI fail threshold
effective_threshold = (
fail_threshold if fail_threshold is not None else _get_fail_threshold(ctx)
)
effective_threshold = fail_threshold if fail_threshold is not None else _get_fail_threshold(ctx)
if effective_threshold >= 0 and len(findings) >= effective_threshold:
if effective_format not in ("json", "github"):
console.print(
f"\n[red]FAIL: {len(findings)} findings >= threshold {effective_threshold}[/red]"
)
console.print(f"\n[red]FAIL: {len(findings)} findings >= threshold {effective_threshold}[/red]")
sys.exit(1)


Expand Down Expand Up @@ -298,18 +289,14 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
sys.exit(1)

if not dry_run:
console.print(
"[red]WARNING: This will modify files. Use --dry-run first![/red]"
)
console.print("[red]WARNING: This will modify files. Use --dry-run first![/red]")
console.print("[dim]Press Ctrl+C to abort. Running in 3 seconds...[/dim]")
import time

time.sleep(3)

include_patterns = ctx.obj.get("include")
scanner = DeadCodeScanner(
project, ignore_patterns=ignore, include_patterns=include_patterns
)
scanner = DeadCodeScanner(project, ignore_patterns=ignore, include_patterns=include_patterns)
result = scanner.scan()

findings = result.findings
Expand Down Expand Up @@ -342,9 +329,7 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
continue

try:
lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines(
keepends=True
)
lines = filepath.read_text(encoding="utf-8", errors="replace").splitlines(keepends=True)
except Exception as e:
console.print(f"[red]Error reading {rel_file}: {e}[/red]")
continue
Expand All @@ -357,35 +342,26 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
# balanced on that line (it's a self-contained one-liner). Anything that
# opens an unclosed block is skipped and reported for manual removal.
candidate_lines = sorted(set(f.line for f in file_findings), reverse=True)
safe_lines = [
n
for n in candidate_lines
if 0 < n <= len(lines) and _line_self_contained(lines[n - 1])
]
safe_lines = [n for n in candidate_lines if 0 < n <= len(lines) and _line_self_contained(lines[n - 1])]
skipped_lines = [n for n in candidate_lines if n not in safe_lines]

if dry_run:
for line_num in sorted(safe_lines):
content = lines[line_num - 1].strip()
console.print(
f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num} — {content[:80]}"
)
console.print(f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num} — {content[:80]}")
removed_count += len(safe_lines)
else:
for line_num in safe_lines:
lines[line_num - 1] = "" # Blank the line (safer than deleting)
if safe_lines:
filepath.write_text("".join(lines), encoding="utf-8")
removed_count += len(safe_lines)
console.print(
f"[green]✓[/green] Cleaned {rel_file} ({len(safe_lines)} lines)"
)
console.print(f"[green]✓[/green] Cleaned {rel_file} ({len(safe_lines)} lines)")

for line_num in sorted(skipped_lines):
content = lines[line_num - 1].strip() if 0 < line_num <= len(lines) else ""
console.print(
f"[yellow]⚠ SKIPPED (multi-line — remove manually)[/yellow] "
f"{rel_file}:{line_num} — {content[:80]}"
f"[yellow]⚠ SKIPPED (multi-line — remove manually)[/yellow] {rel_file}:{line_num} — {content[:80]}"
)

action = "Would remove" if dry_run else "Removed"
Expand All @@ -402,22 +378,14 @@ def stats(ctx: click.Context) -> None:
project = ctx.obj["project"]
ignore = _merge_config_ignore(ctx)
include_patterns = ctx.obj.get("include")
scanner = DeadCodeScanner(
project, ignore_patterns=ignore, include_patterns=include_patterns
)
scanner = DeadCodeScanner(project, ignore_patterns=ignore, include_patterns=include_patterns)
result = scanner.scan()

console.print(f"Files scanned: [bold]{result.files_scanned}[/bold]")
console.print(
f"Unused exports: [bold yellow]{len(result.unused_exports)}[/bold yellow]"
)
console.print(f"Unused exports: [bold yellow]{len(result.unused_exports)}[/bold yellow]")
console.print(f"Dead routes: [bold red]{len(result.dead_routes)}[/bold red]")
console.print(
f"Orphaned CSS: [bold magenta]{len(result.orphaned_css)}[/bold magenta]"
)
console.print(
f"Unreferenced components: [bold cyan]{len(result.unreferenced_components)}[/bold cyan]"
)
console.print(f"Orphaned CSS: [bold magenta]{len(result.orphaned_css)}[/bold magenta]")
console.print(f"Unreferenced components: [bold cyan]{len(result.unreferenced_components)}[/bold cyan]")
console.print(f"Total findings: [bold]{len(result.findings)}[/bold]")

if result.errors:
Expand Down
Loading
Loading