From cf1a0811e62a399737e2da1adda66d474f4aeb6b Mon Sep 17 00:00:00 2001 From: dev-engineer Date: Wed, 24 Jun 2026 16:10:03 -0400 Subject: [PATCH 01/13] improve: add missing AGENTS.md for agent workflow conventions --- AGENTS.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5fe1853 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# deadcode + +Repo guide for agents. + +## Workflow +- Use `pytest` for tests. +- Use `ruff` for lint/format. +- Build/publish via GitHub Actions in `.github/workflows/`. + +## Conventions +- Package code under `src/deadcode` per `pyproject.toml` packaging config. +- Keep branches `improve/-` for structural fixes. +- Do not modify dependencies without updating `pyproject.toml`. From 3b6734dbe91c099e7c034772b9c3eb4a081bd471 Mon Sep 17 00:00:00 2001 From: Coding-Dev-Tools Date: Mon, 29 Jun 2026 03:42:28 -0400 Subject: [PATCH 02/13] fix: format code with ruff by reviewer-A --- conftest.py | 1 + src/deadcode/__main__.py | 1 + src/deadcode/cli.py | 25 +- src/deadcode/config.py | 23 +- src/deadcode/scanner.py | 131 +++++----- tests/test_cli_edge_cases.py | 14 +- tests/test_config_and_fixes.py | 174 ++++++------- tests/test_scanner.py | 109 +++------ uv.lock | 432 +++++++++++++++++++++++++++++++++ 9 files changed, 652 insertions(+), 258 deletions(-) create mode 100644 uv.lock diff --git a/conftest.py b/conftest.py index c4eca1c..3d9a13b 100644 --- a/conftest.py +++ b/conftest.py @@ -1,4 +1,5 @@ """pytest configuration — add project src to Python path.""" + import site import sys from pathlib import Path diff --git a/src/deadcode/__main__.py b/src/deadcode/__main__.py index da2a680..f0f0052 100644 --- a/src/deadcode/__main__.py +++ b/src/deadcode/__main__.py @@ -1,4 +1,5 @@ """Allow running deadcode as: python -m deadcode""" + from .cli import cli if __name__ == "__main__": diff --git a/src/deadcode/cli.py b/src/deadcode/cli.py index 82e010a..62fb57a 100644 --- a/src/deadcode/cli.py +++ b/src/deadcode/cli.py @@ -70,8 +70,13 @@ def _get_fail_threshold(ctx: click.Context) -> int: @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", "-c", type=click.Choice(ALL_CATEGORIES), default=None, help="Filter by category") -@click.option("--fail", "fail_threshold", type=int, default=None, - help="Exit code 1 if findings >= threshold (overrides .deadcode.yml)") +@click.option( + "--fail", + "fail_threshold", + type=int, + default=None, + help="Exit code 1 if findings >= threshold (overrides .deadcode.yml)", +) @click.pass_context def scan( ctx: click.Context, @@ -109,8 +114,14 @@ def scan( output = { "files_scanned": result.files_scanned, "findings": [ - {"file": f.file, "line": f.line, "name": f.name, - "category": f.category, "detail": f.detail, "removable": f.removable} + { + "file": f.file, + "line": f.line, + "name": f.name, + "category": f.category, + "detail": f.detail, + "removable": f.removable, + } for f in findings ], "errors": result.errors, @@ -192,8 +203,9 @@ def scan( @cli.command() @click.option("--dry-run", is_flag=True, help="Preview what would be removed without making changes") -@click.option("--category", "-c", type=click.Choice(ALL_CATEGORIES), - default=None, help="Only remove findings in this category") +@click.option( + "--category", "-c", type=click.Choice(ALL_CATEGORIES), default=None, help="Only remove findings in this category" +) @click.pass_context def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None: """Remove dead code (with --dry-run for preview). @@ -212,6 +224,7 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None: 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") diff --git a/src/deadcode/config.py b/src/deadcode/config.py index 6539fcb..d6c3df2 100644 --- a/src/deadcode/config.py +++ b/src/deadcode/config.py @@ -18,9 +18,14 @@ class DeadCodeConfig: """Configuration loaded from .deadcode.yml.""" ignore: list[str] = field(default_factory=list) - categories: list[str] = field(default_factory=lambda: [ - "unused_export", "dead_route", "orphaned_css", "unreferenced_component", - ]) + categories: list[str] = field( + default_factory=lambda: [ + "unused_export", + "dead_route", + "orphaned_css", + "unreferenced_component", + ] + ) fail_threshold: int = -1 # -1 means disabled @classmethod @@ -28,9 +33,15 @@ def from_dict(cls, data: dict[str, Any]) -> DeadCodeConfig: """Create config from a parsed dict.""" return cls( ignore=data.get("ignore", []), - categories=data.get("categories", [ - "unused_export", "dead_route", "orphaned_css", "unreferenced_component", - ]), + categories=data.get( + "categories", + [ + "unused_export", + "dead_route", + "orphaned_css", + "unreferenced_component", + ], + ), fail_threshold=data.get("fail_threshold", -1), ) diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py index 51afc58..0596bf7 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -124,9 +124,7 @@ def __init__( ) self.include_spec = None if include_patterns: - self.include_spec = pathspec.PathSpec.from_lines( - "gitignore", include_patterns - ) + self.include_spec = pathspec.PathSpec.from_lines("gitignore", include_patterns) @staticmethod def _default_ignore_patterns() -> list[str]: @@ -223,15 +221,13 @@ def _collect_files(self) -> list[Path]: # Filter out ignored directories dirs[:] = [ - d for d in dirs - if not self.ignore_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/") + d for d in dirs if not self.ignore_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/") ] # Filter out non-included directories when include_spec is set if self.include_spec: dirs[:] = [ - d for d in dirs - if self.include_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/") + d for d in dirs if self.include_spec.match_file(f"{rel_root}/{d}/" if rel_root != "." else f"{d}/") ] for fname in filenames: @@ -250,18 +246,23 @@ def _collect_files(self) -> list[Path]: @staticmethod def _is_scannable_file(rel_path: str) -> bool: """Check if a file should be scanned.""" - return rel_path.endswith(( - ".ts", ".tsx", ".js", ".jsx", - ".css", ".scss", ".module.css", - )) + return rel_path.endswith( + ( + ".ts", + ".tsx", + ".js", + ".jsx", + ".css", + ".scss", + ".module.css", + ) + ) @staticmethod def _is_css_file(rel_path: str) -> bool: return rel_path.endswith((".css", ".scss", ".module.css")) - def _parse_exports( - self, content: str, rel_path: str, exports: dict[str, list[tuple[str, int]]] - ) -> None: + def _parse_exports(self, content: str, rel_path: str, exports: dict[str, list[tuple[str, int]]]) -> None: """Extract export names from a file. Handles both single-line forms:: @@ -297,9 +298,7 @@ def _parse_exports( if name and re.match(r"^[A-Za-z_$][\w$]*$", name): exports.setdefault(name, []).append((rel_path, line_num)) - def _parse_imports( - self, content: str, rel_path: str, imports: dict[str, set[str]] - ) -> None: + def _parse_imports(self, content: str, rel_path: str, imports: dict[str, set[str]]) -> None: """Extract import names from a file.""" for m in _IMPORT_PATTERN.finditer(content): # Named imports: import { Foo, Bar } from '...' @@ -313,9 +312,7 @@ def _parse_imports( name = m.group(2) imports.setdefault(name, set()).add(rel_path) - def _parse_css_classes( - self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]] - ) -> None: + def _parse_css_classes(self, content: str, rel_path: str, css_classes: dict[str, list[tuple[str, int]]]) -> None: """Extract CSS class names defined in a stylesheet.""" for i, line in enumerate(content.splitlines(), 1): for m in _CSS_CLASS_PATTERN.finditer(line): @@ -330,9 +327,7 @@ def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> No for cls in group.split(): used_css_classes.add(cls) - def _parse_components( - self, content: str, rel_path: str, components: dict[str, str] - ) -> None: + def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None: """Extract React component definitions.""" for m in _COMPONENT_PATTERN.finditer(content): name = m.group(1) @@ -360,9 +355,21 @@ def _find_unused_exports( """Find exports that are never imported elsewhere.""" # Special names that are entry points or conventions skip_names = { - "default", "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", - "middleware", "config", "metadata", "generateMetadata", - "loader", "action", "generateStaticParams", + "default", + "GET", + "POST", + "PUT", + "DELETE", + "PATCH", + "HEAD", + "OPTIONS", + "middleware", + "config", + "metadata", + "generateMetadata", + "loader", + "action", + "generateStaticParams", } for name, locations in exports.items(): @@ -374,14 +381,16 @@ def _find_unused_exports( external_importers = importers - exporter_files if not external_importers: for file, line in locations: - result.findings.append(Finding( - file=file, - line=line, - name=name, - category="unused_export", - detail=f"Export '{name}' is never imported outside its defining file", - removable=True, - )) + result.findings.append( + Finding( + file=file, + line=line, + name=name, + category="unused_export", + detail=f"Export '{name}' is never imported outside its defining file", + removable=True, + ) + ) def _find_dead_routes( self, @@ -420,14 +429,16 @@ def _find_dead_routes( # Dynamic routes are harder to prove dead — skip them continue - result.findings.append(Finding( - file=rel_path, - line=1, - name=route_path, - category="dead_route", - detail=f"Route '{route_path}' has no internal links pointing to it", - removable=False, # Routes may be linked externally - )) + result.findings.append( + Finding( + file=rel_path, + line=1, + name=route_path, + category="dead_route", + detail=f"Route '{route_path}' has no internal links pointing to it", + removable=False, # Routes may be linked externally + ) + ) def _find_orphaned_css( self, @@ -443,14 +454,16 @@ def _find_orphaned_css( if cls in used_css_classes: continue for file, line in locations: - result.findings.append(Finding( - file=file, - line=line, - name=cls, - category="orphaned_css", - detail=f"CSS class '.{cls}' is not used in any JSX className", - removable=True, - )) + result.findings.append( + Finding( + file=file, + line=line, + name=cls, + category="orphaned_css", + detail=f"CSS class '.{cls}' is not used in any JSX className", + removable=True, + ) + ) def _find_unreferenced_components( self, @@ -473,11 +486,13 @@ def _find_unreferenced_components( if "/page." in file or "/route." in file or "/layout." in file: continue - result.findings.append(Finding( - file=file, - line=1, - name=comp_name, - category="unreferenced_component", - detail=f"Component '{comp_name}' is never imported by other files", - removable=True, - )) + result.findings.append( + Finding( + file=file, + line=1, + name=comp_name, + category="unreferenced_component", + detail=f"Component '{comp_name}' is never imported by other files", + removable=True, + ) + ) diff --git a/tests/test_cli_edge_cases.py b/tests/test_cli_edge_cases.py index 666474e..f7ac3c6 100644 --- a/tests/test_cli_edge_cases.py +++ b/tests/test_cli_edge_cases.py @@ -15,6 +15,7 @@ class TestMainModule: @pytest.fixture def runner(self): from click.testing import CliRunner + return CliRunner() def test_main_module_runs_help(self, runner): @@ -30,6 +31,7 @@ class TestCliEdgeCases: @pytest.fixture def runner(self): from click.testing import CliRunner + return CliRunner() def test_non_existent_project_exits_1(self, runner): @@ -62,6 +64,7 @@ class TestCliFormatOutput: @pytest.fixture def runner(self): from click.testing import CliRunner + return CliRunner() @pytest.fixture @@ -69,10 +72,7 @@ def sample(self, tmp_path): """A tiny TS project with at least one dead export.""" mod = tmp_path / "src" / "mod.ts" mod.parent.mkdir(parents=True, exist_ok=True) - mod.write_text( - 'export function usedHelper() { return 1; }\n' - 'export function unusedHelper() { return 2; }\n' - ) + mod.write_text("export function usedHelper() { return 1; }\nexport function unusedHelper() { return 2; }\n") return tmp_path def test_format_compact_output(self, runner, sample): @@ -113,6 +113,7 @@ class TestRemoveCommand: @pytest.fixture def runner(self): from click.testing import CliRunner + return CliRunner() def test_remove_dry_run_nothing_removable(self, runner, tmp_path): @@ -130,14 +131,13 @@ class TestStatsCommand: @pytest.fixture def runner(self): from click.testing import CliRunner + return CliRunner() def test_stats_basic(self, runner, tmp_path): """stats command shows scan summary.""" (tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True) - (tmp_path / "src" / "unused.ts").write_text( - "export function unusedHelper() { return 1; }\n" - ) + (tmp_path / "src" / "unused.ts").write_text("export function unusedHelper() { return 1; }\n") result = runner.invoke(cli, ["-p", str(tmp_path), "stats"]) assert result.exit_code == 0 assert "Files scanned" in result.output diff --git a/tests/test_config_and_fixes.py b/tests/test_config_and_fixes.py index 4d737ea..06ee8ab 100644 --- a/tests/test_config_and_fixes.py +++ b/tests/test_config_and_fixes.py @@ -14,6 +14,7 @@ @pytest.fixture def runner(): from click.testing import CliRunner + return CliRunner() @@ -23,8 +24,8 @@ def sample_project(tmp_path): utils = tmp_path / "src" / "utils.ts" utils.parent.mkdir(parents=True, exist_ok=True) utils.write_text( - 'export function usedHelper() { return 1; }\n' - 'export function unusedHelper() { return 2; }\n' + "export function usedHelper() { return 1; }\n" + "export function unusedHelper() { return 2; }\n" 'export const USED_CONST = "used";\n' 'export const UNUSED_CONST = "unused";\n' ) @@ -33,45 +34,27 @@ def sample_project(tmp_path): button.parent.mkdir(parents=True, exist_ok=True) button.write_text( 'import { usedHelper, USED_CONST } from "../utils";\n' - 'export function Button() {\n' + "export function Button() {\n" ' return ;\n' - '}\n' + "}\n" ) widget = tmp_path / "src" / "components" / "UnusedWidget.tsx" - widget.write_text( - 'export function UnusedWidget() {\n' - ' return
Unused
;\n' - '}\n' - ) + widget.write_text("export function UnusedWidget() {\n return
Unused
;\n}\n") css = tmp_path / "src" / "styles" / "main.css" css.parent.mkdir(parents=True, exist_ok=True) - css.write_text( - '.btn-primary {\n' - ' background: blue;\n' - '}\n' - '.orphaned-class {\n' - ' color: red;\n' - '}\n' - ) + css.write_text(".btn-primary {\n background: blue;\n}\n.orphaned-class {\n color: red;\n}\n") page = tmp_path / "src" / "app" / "page.tsx" page.parent.mkdir(parents=True, exist_ok=True) page.write_text( - 'import { Button } from "../components/Button";\n' - 'export default function Page() {\n' - ' return ;\n' - '}\n' + "}\n" ) # src/components/UnusedWidget.tsx - never imported widget = tmp_path / "src" / "components" / "UnusedWidget.tsx" - widget.write_text( - 'export function UnusedWidget() {\n' - ' return
Unused
;\n' - '}\n' - ) + widget.write_text("export function UnusedWidget() {\n return
Unused
;\n}\n") # src/styles/main.css - with orphaned class css = tmp_path / "src" / "styles" / "main.css" css.parent.mkdir(parents=True, exist_ok=True) - css.write_text( - '.btn-primary {\n' - ' background: blue;\n' - '}\n' - '.orphaned-class {\n' - ' color: red;\n' - '}\n' - ) + css.write_text(".btn-primary {\n background: blue;\n}\n.orphaned-class {\n color: red;\n}\n") # src/app/page.tsx - Next.js page (entry point) page = tmp_path / "src" / "app" / "page.tsx" page.parent.mkdir(parents=True, exist_ok=True) page.write_text( - 'import { Button } from "../components/Button";\n' - 'export default function Page() {\n' - ' return