diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..edf3d41 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,27 @@ +--- +name: Bug Report +about: Report a bug to help us improve +title: "[BUG] " +labels: bug +assignees: "" +--- + +**Describe the Bug** +A clear and concise description of what the bug is. + +**To Reproduce** +Steps to reproduce the behavior: +1. Run command '...' +2. With input '...' +3. See error + +**Expected Behavior** +A clear and concise description of what you expected to happen. + +**Environment (please complete):** +- OS: [e.g. Ubuntu 22.04, macOS 14, Windows 11] +- Python version: [e.g. 3.10, 3.12] +- deadcode version: [e.g. 0.1.1] + +**Additional Context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bed82a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: Feature Request +about: Suggest an idea for this project +title: "[FEATURE] " +labels: enhancement +assignees: "" +--- + +**Is your feature request related to a problem?** +A clear and concise description of what the problem is. + +**Describe the Solution** +A clear and concise description of what you want to happen. + +**Describe Alternatives** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional Context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..30bced7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,28 @@ +## Description + + + +## Type of Change + +- [ ] Bug fix +- [ ] New feature +- [ ] Documentation update +- [ ] CI/CD improvement +- [ ] Refactoring +- [ ] Dependency update + +## How Has This Been Tested? + +- [ ] `pytest tests/ -v` passes +- [ ] `ruff check .` passes + +## Checklist + +- [ ] My code follows the project's style guidelines +- [ ] I have added tests that prove my fix/feature works +- [ ] All existing tests pass +- [ ] I have updated documentation as needed + +## Related Issues + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5a06057..042ebc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,10 @@ name: CI on: push: - branches: [main] + branches: [master] tags: ["v*"] pull_request: - branches: [main] + branches: [master] permissions: contents: read @@ -36,7 +36,7 @@ jobs: run: ruff check . - name: Run tests - run: python -m pytest tests/ -x -q + run: python -m pytest tests/ -q --cov=deadcode --cov-report=term-missing publish: needs: test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e93a00c..253ba34 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,14 +8,15 @@ Thanks for your interest in contributing! 2. Create a virtual environment: python -m venv .venv && source .venv/bin/activate 3. Install dev dependencies: pip install -e ".[dev]" 4. Run tests: pytest tests/ -v -5. Lint: uff check src/ +5. Lint: ruff check src/ +6. Run ruff format src/ --check before committing ## Pull Requests - Fork the repo and create a feature branch - Add tests for any new functionality - Ensure all existing tests pass -- Run uff check src/ --fix before committing +- Run ruff check src/ --fix before committing - Keep PRs focused on a single change ## Reporting Issues @@ -32,4 +33,4 @@ Thanks for your interest in contributing! ## License -By contributing, you agree your work will be licensed under the same license as this project. \ No newline at end of file +By contributing, you agree your work will be licensed under the same license as this project. diff --git a/README.md b/README.md index bd62ad0..dd02c97 100644 --- a/README.md +++ b/README.md @@ -76,8 +76,8 @@ deadcode stats - **Unused export detection** — finds functions, types, classes, interfaces, enums, and consts that are exported but never imported within your project - **Dead route detection** — detects unreachable page components in Next.js App Router projects - **Orphaned CSS detection** — finds CSS module classes that are defined but never referenced in TSX/JSX files -- **Safe auto-removal** — `--dry-run` preview mode shows exactly what will be deleted before making changes -- **Full-project AST analysis** — regex-based scanning covers export/import patterns, route detection, CSS class usage, and component references across your entire codebase +- **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 +- **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) - **Monorepo support** — handles large projects efficiently with ignore patterns - **CI integration** — JSON output for automated pipelines and gating diff --git a/package.json b/package.json index 8200b04..d6a7e2b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "deadcode-cli", "version": "0.1.1", - "description": "Find unused/dead code in Python projects. Static analysis tool to identify orphaned functions, classes, and imports.", + "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": "Revenue Holdings ", "license": "MIT", "repository": { diff --git a/pyproject.toml b/pyproject.toml index 2b9b4b5..82c3726 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "ruff>=0.4.0", + "tomli>=1.1.0; python_version < '3.11'", ] [project.urls] diff --git a/src/deadcode/cli.py b/src/deadcode/cli.py index 6d979da..a70f78a 100644 --- a/src/deadcode/cli.py +++ b/src/deadcode/cli.py @@ -27,6 +27,40 @@ FORMAT_CHOICES = click.Choice(["pretty", "compact", "github", "json"]) +def _line_self_contained(text: str) -> bool: + """Return True if a line's brackets/braces/parens are balanced on that line. + + Used by ``remove`` to decide whether a single reported line can be safely + blanked. A balanced line is a complete one-liner (``export const X = 1;`` or + ``.foo { color: red; }``); a line that opens a brace/bracket/paren it never + closes is the start of a multi-line construct and must not be blanked in + isolation. String and template-literal contents are ignored so brackets + inside quotes don't skew the count. + """ + depth = 0 + in_str: str | None = None + escaped = False + for ch in text: + if escaped: + escaped = False + continue + if ch == "\\": + escaped = True + continue + if in_str is not None: + if ch == in_str: + in_str = None + elif ch in ("'", '"', "`"): + in_str = ch + elif ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + if depth < 0: # closes something opened on an earlier line + return False + return depth == 0 and in_str is None + + @click.group() @click.option("--project", "-p", default=".", help="Project directory to scan") @click.option( @@ -315,24 +349,43 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None: console.print(f"[red]Error reading {rel_file}: {e}[/red]") continue - # Remove lines in reverse order to preserve line numbers - lines_to_remove = sorted(set(f.line for f in file_findings), reverse=True) + # Findings carry only a start line, no span. Blanking a single line of a + # multi-line construct (a multi-line `export { ... }`, a CSS rule, or a + # component body) leaves dangling, syntactically-broken code — worse than + # doing nothing. DeadCode is regex-based with no AST, so guard + # conservatively: only blank a line whose brackets/braces/parens are + # 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]) + ] + skipped_lines = [n for n in candidate_lines if n not in safe_lines] if dry_run: - for line_num in sorted(lines_to_remove): - content = lines[line_num - 1].rstrip() if line_num <= len(lines) else "" + 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.strip()[:80]}" + f"[yellow]WOULD REMOVE[/yellow] {rel_file}:{line_num} — {content[:80]}" ) - removed_count += len(lines_to_remove) + removed_count += len(safe_lines) else: - for line_num in lines_to_remove: - if 0 < line_num <= len(lines): - lines[line_num - 1] = "" # Blank the line (safer than deleting) - filepath.write_text("".join(lines), encoding="utf-8") - removed_count += len(lines_to_remove) + 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)" + ) + + for line_num in sorted(skipped_lines): + content = lines[line_num - 1].strip() if 0 < line_num <= len(lines) else "" console.print( - f"[green]✓[/green] Cleaned {rel_file} ({len(lines_to_remove)} lines)" + f"[yellow]⚠ SKIPPED (multi-line — remove manually)[/yellow] " + f"{rel_file}:{line_num} — {content[:80]}" ) action = "Would remove" if dry_run else "Removed" diff --git a/tests/test_config_and_fixes.py b/tests/test_config_and_fixes.py index 9572267..23c442e 100644 --- a/tests/test_config_and_fixes.py +++ b/tests/test_config_and_fixes.py @@ -583,7 +583,10 @@ def test_ruff_known_first_party(self): """ruff known-first-party should be ['deadcode'], not ['*'].""" from pathlib import Path - import tomllib + try: + import tomllib # Python >=3.11 + except ModuleNotFoundError: + import tomli as tomllib # Python 3.10 backport pyproject = Path(__file__).parent.parent / "pyproject.toml" with open(pyproject, "rb") as f: @@ -600,7 +603,10 @@ def test_package_data_includes_py_typed(self): """pyproject.toml should have package-data config for py.typed.""" from pathlib import Path - import tomllib + try: + import tomllib # Python >=3.11 + except ModuleNotFoundError: + import tomli as tomllib # Python 3.10 backport pyproject = Path(__file__).parent.parent / "pyproject.toml" with open(pyproject, "rb") as f: