Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 27 additions & 0 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 19 additions & 0 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 28 additions & 0 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
## Description

<!-- Briefly describe the changes in this PR -->

## 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

<!-- Link to any related issues: Fixes #123, Closes #456 -->
6 changes: 3 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ name: CI

on:
push:
branches: [main]
branches: [master]
tags: ["v*"]
pull_request:
branches: [main]
branches: [master]

permissions:
contents: read
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
By contributing, you agree your work will be licensed under the same license as this project.
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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 <engineering@revenueholdings.dev>",
"license": "MIT",
"repository": {
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
77 changes: 65 additions & 12 deletions src/deadcode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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"
Expand Down
10 changes: 8 additions & 2 deletions tests/test_config_and_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
Loading