diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 042ebc6..1dc1e6e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -63,4 +63,4 @@ jobs:
run: python -m build
- name: Publish to PyPI
- uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
\ No newline at end of file
+ uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b
diff --git a/.github/workflows/cowork-auto-pr.yml b/.github/workflows/cowork-auto-pr.yml
index 91690e6..98c0ece 100644
--- a/.github/workflows/cowork-auto-pr.yml
+++ b/.github/workflows/cowork-auto-pr.yml
@@ -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 }}
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index e64318a..02b6b46 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -2,7 +2,7 @@ name: Deploy GitHub Pages
on:
push:
- branches: [master, main]
+ branches: [master]
workflow_dispatch:
permissions:
@@ -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
@@ -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 }}
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..6ac8caf
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,19 @@
+{
+ "name": "deadcode-cli",
+ "version": "0.1.1",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "deadcode-cli",
+ "version": "0.1.1",
+ "license": "MIT",
+ "bin": {
+ "deadcode": "cli.js"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
index 560949a..4492ddd 100644
--- a/package.json
+++ b/package.json
@@ -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"
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 a70f78a..61cc663 100644
--- a/src/deadcode/cli.py
+++ b/src/deadcode/cli.py
@@ -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.
@@ -63,9 +71,7 @@ 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,
@@ -73,9 +79,7 @@ def _line_self_contained(text: str) -> bool:
)
@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,
@@ -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",
@@ -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
@@ -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")
@@ -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]")
@@ -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")
@@ -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)
@@ -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
@@ -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
@@ -357,19 +342,13 @@ 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:
@@ -377,15 +356,12 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
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"
@@ -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:
diff --git a/src/deadcode/scanner.py b/src/deadcode/scanner.py
index 9ec2726..bc00bea 100644
--- a/src/deadcode/scanner.py
+++ b/src/deadcode/scanner.py
@@ -126,15 +126,11 @@ def unreferenced_components(self) -> list[Finding]:
# the whole export surface of the target module counts as consumed; otherwise
# exports used only via a namespace are falsely reported as unused with
# removable=True — live code queued for deletion.
-_NAMESPACE_IMPORT_PATTERN = re.compile(
- r"import\s+\*\s+as\s+\w+\s+from\s*['\"]([^'\"]+)['\"]"
-)
+_NAMESPACE_IMPORT_PATTERN = re.compile(r"import\s+\*\s+as\s+\w+\s+from\s*['\"]([^'\"]+)['\"]")
# Bare side-effect import: `import './polyfill';` — executes the module without
# binding any names, consuming its entire export surface.
-_SIDE_EFFECT_IMPORT_PATTERN = re.compile(
- r"^\s*import\s*['\"]([^'\"]+)['\"]", re.MULTILINE
-)
+_SIDE_EFFECT_IMPORT_PATTERN = re.compile(r"^\s*import\s*['\"]([^'\"]+)['\"]", re.MULTILINE)
# Dynamic import: `const m = await import('./heavy');` — lazily loads the whole
# module at runtime; individual consumed names are invisible statically, so the
@@ -157,19 +153,16 @@ def unreferenced_components(self) -> list[Finding]:
r'"([^"]+)"\s*:\s*\["([^"]+)"\]',
)
-# CSS-module accessor usage, two forms::
-# - dot: `styles.card` -> capture group 1 empty, full = "styles.card"
-# - bracket: `styles['card-hover']` / `styles["card"]` -> capture group 1 = name
-# We deliberately match either form after a binding identifier so both
-# `styles.card` and `styles['card-hover']` register the class as used.
+# CSS-module dot and literal bracket access, including valid JavaScript
+# identifiers containing '$', and CSS class names beginning with '_'.
_CSS_MODULE_USAGE_PATTERN = re.compile(
- r"""\b\w+\.(?:_?[\w$]+)|(\w+)\[['"]([\w$-]+)['"]\]""",
+ r"""(? ScanResult:
imports: dict[str, set[str]] = {} # name -> set of files that import it
css_classes: dict[str, list[tuple[str, int]]] = {} # class -> [(file, line)]
used_css_classes: set[str] = set()
+ used_css_module_classes: dict[Path, set[str]] = {}
+ unresolved_css_module_classes: set[str] = set()
components: dict[str, str] = {} # ComponentName -> file
routes: list[tuple[str, str]] = [] # (route_path, file)
star_reexports: list[tuple[str, str]] = [] # (barrel_file, module_spec)
+ file_set = {str(f.relative_to(self.project_dir)).replace("\\", "/") for f in all_files}
for filepath in all_files:
try:
@@ -258,11 +254,13 @@ def scan(self) -> ScanResult:
# Parse className usage in TSX/JSX files
if rel_path.endswith((".tsx", ".jsx")):
self._parse_classname_usage(content, used_css_classes)
- # CSS-module accessor usage (styles.card / styles['card']).
- # Only register accessors imported as a CSS module so we don't
- # treat every object property access (e.g. `user.name`) as a
- # CSS class.
- self._parse_css_module_usage(content, used_css_classes)
+
+ # CSS modules can also be consumed by plain TypeScript/JavaScript
+ # files, for example when exporting class-name maps to components.
+ if not self._is_css_file(rel_path):
+ self._parse_css_module_usage(
+ content, rel_path, file_set, used_css_module_classes, unresolved_css_module_classes
+ )
# Parse components
if rel_path.endswith((".tsx", ".jsx")):
@@ -278,7 +276,6 @@ def scan(self) -> ScanResult:
# Resolve `export * from './mod'` specifiers to scanned files so that
# every export forwarded by a barrel is treated as part of the public
# API surface (never reported as removable).
- file_set = {str(f.relative_to(self.project_dir)).replace("\\", "/") for f in all_files}
star_reexported_files: set[str] = set()
for barrel_file, module_spec in star_reexports:
resolved = self._resolve_relative_module(barrel_file, module_spec, file_set)
@@ -292,7 +289,9 @@ def scan(self) -> ScanResult:
self._find_dead_routes(routes, all_files, result)
# 2c. Orphaned CSS
- self._find_orphaned_css(css_classes, used_css_classes, result)
+ self._find_orphaned_css(
+ css_classes, used_css_classes, used_css_module_classes, unresolved_css_module_classes, result
+ )
# 2d. Unreferenced components
# Collect all names that are imported somewhere (i.e., actually used)
@@ -312,11 +311,9 @@ def _collect_files(self) -> list[Path]:
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}/")
- ]
+ # A file whitelist need not match its parent directories (for
+ # example, "src/components/*.tsx"). Only ignored directories can
+ # be pruned; apply include patterns to the individual files below.
for fname in filenames:
rel_path = f"{rel_root}/{fname}" if rel_root != "." else fname
@@ -325,9 +322,17 @@ def _collect_files(self) -> list[Path]:
if self.include_spec and not self.include_spec.match_file(rel_path):
continue
+ if not self._is_scannable_file(rel_path):
+ continue
+
filepath = Path(root) / fname
- if self._is_scannable_file(rel_path):
- files.append(filepath)
+ # A source-file symlink may resolve outside the project. Such
+ # findings could otherwise make `remove` modify external files.
+ try:
+ filepath.resolve().relative_to(self.project_dir)
+ except (ValueError, OSError, RuntimeError):
+ continue
+ files.append(filepath)
return files
@@ -508,7 +513,14 @@ 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_css_module_usage(self, content: str, used_css_classes: set[str]) -> None:
+ def _parse_css_module_usage(
+ self,
+ content: str,
+ rel_path: str,
+ file_set: set[str],
+ used_css_module_classes: dict[Path, set[str]],
+ unresolved_css_module_classes: set[str],
+ ) -> None:
"""Extract CSS-module class names consumed via object accessors.
The canonical Next.js/React pattern is::
@@ -516,33 +528,34 @@ def _parse_css_module_usage(self, content: str, used_css_classes: set[str]) -> N
import styles from './Card.module.css';
...
- We first find any ``*.module.css`` imports (binding name ``styles``)
- so that accessor use ``styles.card`` registers ``card`` as a used
- class. Bracket access ``styles['card-hover']`` is also captured.
+ Resolve each relative ``*.module.css`` import from its importing file
+ so that ``styles.card`` protects ``card`` only in that stylesheet.
+ Bracket access ``styles['card-hover']`` is also captured. Canonical
+ paths keep internal stylesheet symlinks and their targets consistent.
+ Classes used through unresolved imports remain uncertain so alias or
+ bundler-specific resolution cannot make live module classes removable.
Without this, every CSS-module class is falsely reported as orphaned
and marked removable=True, risking deletion of live styles.
"""
# Map the local binding (e.g. ``styles``) to a CSS-module import so
# only module accessors are treated as class names.
- module_bindings = {m.group(1) for m in _CSS_MODULE_IMPORT_PATTERN.finditer(content)}
+ module_bindings: dict[str, Path | None] = {}
+ for match in _CSS_MODULE_IMPORT_PATTERN.finditer(content):
+ stylesheet = self._resolve_relative_module(rel_path, match.group(2), file_set)
+ if stylesheet is not None and stylesheet.endswith(".module.css"):
+ module_bindings[match.group(1)] = (self.project_dir / stylesheet).resolve()
+ else:
+ module_bindings[match.group(1)] = None
if not module_bindings:
return
for m in _CSS_MODULE_USAGE_PATTERN.finditer(content):
- groups = m.groups()
- # Bracket form `styles['card-hover']` -> groups = (binding, name).
- # Dot form `styles.card` -> groups = (None, None); the whole match
- # is "binding.attr". Distinguish by whether a bracket name exists.
- bracket_binding, bracket_name = groups
- if bracket_name is not None:
- if bracket_binding in module_bindings:
- used_css_classes.add(bracket_name)
- continue
- # Dot form `styles.card` -> the whole match is "binding.attr".
- full = m.group(0)
- binding, _, attr = full.partition(".")
- if binding in module_bindings and attr and attr[0] != "_":
- # Skip internal/private-ish accessors (e.g. styles.toString).
- used_css_classes.add(attr)
+ binding, dot_name, _, bracket_name = m.groups()
+ if binding in module_bindings:
+ stylesheet_path = module_bindings[binding]
+ if stylesheet_path is None:
+ unresolved_css_module_classes.add(dot_name or bracket_name)
+ else:
+ used_css_module_classes.setdefault(stylesheet_path, set()).add(dot_name or bracket_name)
def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None:
"""Extract React component definitions."""
@@ -669,9 +682,12 @@ def _find_orphaned_css(
self,
css_classes: dict[str, list[tuple[str, int]]],
used_css_classes: set[str],
+ used_css_module_classes: dict[Path, set[str]],
+ unresolved_css_module_classes: set[str],
result: ScanResult,
) -> None:
"""Find CSS classes defined but never used in JSX."""
+ module_usage_by_file: dict[str, set[str]] = {}
for cls, locations in css_classes.items():
# Skip common utility classes and pseudo-selectors
if cls.startswith(
@@ -688,17 +704,29 @@ def _find_orphaned_css(
)
):
continue
- if cls in used_css_classes:
- continue
for file, line in locations:
+ if file.endswith(".module.css"):
+ if file not in module_usage_by_file:
+ module_usage_by_file[file] = used_css_module_classes.get(
+ (self.project_dir / file).resolve(), set()
+ )
+ if cls in module_usage_by_file[file]:
+ continue
+ elif cls in used_css_classes:
+ continue
+ uncertain = file.endswith(".module.css") and cls in unresolved_css_module_classes
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,
+ detail=(
+ f"CSS module class '.{cls}' may be used through an unresolved stylesheet import"
+ if uncertain
+ else f"CSS class '.{cls}' is not used in any JSX className"
+ ),
+ removable=not uncertain,
)
)
diff --git a/tests/test_cli_edge_cases.py b/tests/test_cli_edge_cases.py
index 0d71e22..f7ac3c6 100644
--- a/tests/test_cli_edge_cases.py
+++ b/tests/test_cli_edge_cases.py
@@ -42,9 +42,7 @@ def test_non_existent_project_exits_1(self, runner):
def test_fail_threshold_exits_high(self, runner, tmp_path):
"""--fail=0 exits 1 when findings exist (covers fail threshold path)."""
(tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True)
- (tmp_path / "src" / "unused.ts").write_text(
- "export function unused() { return 1; }\n"
- )
+ (tmp_path / "src" / "unused.ts").write_text("export function unused() { return 1; }\n")
result = runner.invoke(cli, ["-p", str(tmp_path), "scan", "--fail", "0"])
assert result.exit_code == 1
assert "FAIL" in result.output
@@ -52,16 +50,10 @@ def test_fail_threshold_exits_high(self, runner, tmp_path):
def test_ignore_flag_before_subcommand(self, runner, tmp_path):
"""--ignore group option rejects submodule patterns (covers _merge_config_ignore)."""
(tmp_path / "src" / "used.ts").parent.mkdir(parents=True, exist_ok=True)
- (tmp_path / "src" / "used.ts").write_text(
- "export function used() { return 1; }\n"
- )
+ (tmp_path / "src" / "used.ts").write_text("export function used() { return 1; }\n")
(tmp_path / "src" / "unused.ts").parent.mkdir(parents=True, exist_ok=True)
- (tmp_path / "src" / "unused.ts").write_text(
- "export function unused() { return 2; }\n"
- )
- result = runner.invoke(
- cli, ["-p", str(tmp_path), "--ignore", "**/unused.ts", "scan"]
- )
+ (tmp_path / "src" / "unused.ts").write_text("export function unused() { return 2; }\n")
+ result = runner.invoke(cli, ["-p", str(tmp_path), "--ignore", "**/unused.ts", "scan"])
assert result.exit_code == 0
assert "unused" not in result.output
@@ -80,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):
@@ -148,9 +137,7 @@ def runner(self):
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_cli_machine_output.py b/tests/test_cli_machine_output.py
new file mode 100644
index 0000000..bb96f01
--- /dev/null
+++ b/tests/test_cli_machine_output.py
@@ -0,0 +1,51 @@
+"""Machine output preserves source paths without terminal presentation rules."""
+
+import json
+
+import pytest
+from click.testing import CliRunner
+
+from deadcode.cli import cli
+
+
+@pytest.mark.parametrize("option", ["--format=json", "--json-output"])
+def test_json_preserves_long_paths_and_markup(tmp_path, option):
+ name = "[green]" + "x" * 100 + "[red].ts"
+ (tmp_path / name).write_text("export const unused = 1;\n")
+
+ result = CliRunner().invoke(cli, ["-p", str(tmp_path), "scan", option])
+
+ assert result.exit_code == 0
+ data = json.loads(result.output)
+ assert data["files_scanned"] == 1
+ assert data["findings"][0]["file"] == name
+ assert "never imported outside its defining file" in data["findings"][0]["detail"]
+
+
+def test_github_annotations_preserve_long_paths_and_escape_properties(tmp_path):
+ name = "[green]" + "x" * 100 + "%value,other.ts"
+ (tmp_path / name).write_text("export const unused = 1;\n")
+
+ result = CliRunner().invoke(cli, ["-p", str(tmp_path), "scan", "--format=github"])
+
+ assert result.exit_code == 0
+ annotations = [line for line in result.output.splitlines() if line.startswith("::error")]
+ assert len(annotations) == 1
+ expected_path = name.replace("%", "%25").replace(",", "%2C")
+ assert annotations[0].startswith(f"::error file={expected_path},line=1::unused_export: unused")
+
+
+def test_github_annotation_path_cannot_inject_workflow_commands(tmp_path):
+ import os
+
+ if os.name == "nt":
+ pytest.skip("Windows does not permit newline characters in file names")
+ name = "first.ts\n::error file=other.ts::injected.ts"
+ (tmp_path / name).write_text("export const unused = 1;\n")
+
+ result = CliRunner().invoke(cli, ["-p", str(tmp_path), "scan", "--format=github"])
+
+ assert result.exit_code == 0
+ annotations = [line for line in result.output.splitlines() if line.startswith("::error")]
+ assert len(annotations) == 1
+ assert "first.ts%0A%3A%3Aerror file=other.ts%3A%3Ainjected.ts" in annotations[0]
diff --git a/tests/test_config_and_fixes.py b/tests/test_config_and_fixes.py
index 035346a..d50150f 100644
--- a/tests/test_config_and_fixes.py
+++ b/tests/test_config_and_fixes.py
@@ -40,30 +40,21 @@ def sample_project(tmp_path):
)
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"
+ 'import { Button } from "../components/Button";\nexport default function Page() {\n return ;\n}\n'
)
deadpage = tmp_path / "src" / "app" / "deadpage" / "page.tsx"
deadpage.parent.mkdir(parents=True, exist_ok=True)
- deadpage.write_text(
- "export default function DeadPage() {\n return Dead
;\n}\n"
- )
+ deadpage.write_text("export default function DeadPage() {\n return Dead
;\n}\n")
return tmp_path
@@ -105,12 +96,7 @@ def test_from_dict_partial(self):
def test_load_from_yml(self, tmp_path):
config_file = tmp_path / ".deadcode.yml"
config_file.write_text(
- "ignore:\n"
- ' - "generated/"\n'
- ' - "legacy/"\n'
- "categories:\n"
- " - unused_export\n"
- "fail_threshold: 3\n"
+ 'ignore:\n - "generated/"\n - "legacy/"\ncategories:\n - unused_export\nfail_threshold: 3\n'
)
config = DeadCodeConfig.load(tmp_path)
assert config.ignore == ["generated/", "legacy/"]
@@ -151,9 +137,7 @@ def test_fail_exits_1_when_threshold_met(self, runner, sample_project):
def test_fail_exits_0_when_below_threshold(self, runner, sample_project):
# Set a very high threshold
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--fail", "999"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--fail", "999"])
assert result.exit_code == 0
def test_fail_zero_exits_1_on_any_finding(self, runner, sample_project):
@@ -161,9 +145,7 @@ def test_fail_zero_exits_1_on_any_finding(self, runner, sample_project):
assert result.exit_code == 1
def test_fail_with_json_output(self, runner, sample_project):
- result = runner.invoke(
- cli, ["-p", str(sample_project), "scan", "--json-output", "--fail", "1"]
- )
+ result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--json-output", "--fail", "1"])
assert result.exit_code == 1
# JSON should still be valid
data = json.loads(result.output, strict=False)
@@ -206,9 +188,7 @@ def test_cli_ignore_overrides_config(self, runner, tmp_path):
mod.write_text("export function unused() { return 1; }\n")
# -i is a group-level option, must come before subcommand
- result = runner.invoke(
- cli, ["-p", str(tmp_path), "-i", "src/", "scan", "--json-output"]
- )
+ result = runner.invoke(cli, ["-p", str(tmp_path), "-i", "src/", "scan", "--json-output"])
assert result.exit_code == 0
data = json.loads(result.output, strict=False)
assert len(data["findings"]) == 0
@@ -223,15 +203,11 @@ def test_merge_config_and_cli_ignore(self, runner, tmp_path):
config.write_text('ignore:\n - "nonexistent/"\n')
# CLI --ignore with a matching pattern
- result = runner.invoke(
- cli, ["-p", str(tmp_path), "-i", "*.ts", "scan", "--json-output"]
- )
+ result = runner.invoke(cli, ["-p", str(tmp_path), "-i", "*.ts", "scan", "--json-output"])
assert result.exit_code == 0
data = json.loads(result.output, strict=False)
# Both ignores combined should cover the .ts file
- assert len(data["findings"]) == 0, (
- f"Expected 0 findings with merged ignores, got {len(data['findings'])}"
- )
+ assert len(data["findings"]) == 0, f"Expected 0 findings with merged ignores, got {len(data['findings'])}"
class TestBugFixUnreferencedComponents:
@@ -241,18 +217,13 @@ def test_component_imported_not_reported(self, tmp_path):
comp.parent.mkdir(parents=True, exist_ok=True)
comp.write_text("export function Button() { return Hi
; }\n")
app = tmp_path / "src" / "App.tsx"
- app.write_text(
- 'import { Button } from "./Button";\n'
- "export function App() { return ; }\n"
- )
+ app.write_text('import { Button } from "./Button";\nexport function App() { return ; }\n')
scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()
comp_names = {f.name for f in result.unreferenced_components}
- assert "Button" not in comp_names, (
- "Button should not be unreferenced — it's imported by App.tsx"
- )
+ assert "Button" not in comp_names, "Button should not be unreferenced — it's imported by App.tsx"
def test_component_not_imported_is_reported(self, tmp_path):
"""Component with zero imports should still be reported."""
@@ -315,9 +286,7 @@ def test_include_filters_scan(self, runner, tmp_path):
lib.parent.mkdir(parents=True, exist_ok=True)
lib.write_text("export function unusedInLib() { return 2; }\n")
- result = runner.invoke(
- cli, ["-p", str(tmp_path), "--include", "src/", "scan", "--json-output"]
- )
+ result = runner.invoke(cli, ["-p", str(tmp_path), "--include", "src/", "scan", "--json-output"])
assert result.exit_code == 0
data = json.loads(result.output, strict=False)
finding_names = {f["name"] for f in data["findings"]}
@@ -375,9 +344,7 @@ def test_include_in_remove_dry_run(self, runner, tmp_path):
lib.parent.mkdir(parents=True, exist_ok=True)
lib.write_text("export function bar() { return 2; }\n")
- result = runner.invoke(
- cli, ["-p", str(tmp_path), "--include", "src/", "remove", "--dry-run"]
- )
+ result = runner.invoke(cli, ["-p", str(tmp_path), "--include", "src/", "remove", "--dry-run"])
assert result.exit_code == 0
@@ -406,9 +373,7 @@ def test_include_with_ignore_more_restrictive(self, runner, tmp_path):
)
assert result.exit_code == 0
data = json.loads(result.output, strict=False)
- assert data["files_scanned"] == 1, (
- "only mod.ts should be scanned, internal/ ignored"
- )
+ assert data["files_scanned"] == 1, "only mod.ts should be scanned, internal/ ignored"
finding_names = {f["name"] for f in data["findings"]}
assert "foo" in finding_names
assert "bar" not in finding_names
@@ -433,12 +398,8 @@ def test_include_invalid_pattern_graceful(self, runner, tmp_path):
mod.parent.mkdir(parents=True, exist_ok=True)
mod.write_text("export function foo() { return 1; }\n")
- result = runner.invoke(
- cli, ["-p", str(tmp_path), "--include", "src/[invalid", "scan"]
- )
- assert result.exit_code in (0, 2), (
- "should not crash, may produce error or proceed"
- )
+ result = runner.invoke(cli, ["-p", str(tmp_path), "--include", "src/[invalid", "scan"])
+ assert result.exit_code in (0, 2), "should not crash, may produce error or proceed"
class TestRemoveNonDryRun:
@@ -454,17 +415,12 @@ def test_remove_actually_blanks_lines(self, tmp_path):
"""remove without --dry-run should blank the flagged lines in the file."""
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
- mod.write_text(
- "export function unusedA() { return 1; }\n"
- "export function unusedB() { return 2; }\n"
- )
+ mod.write_text("export function unusedA() { return 1; }\nexport function unusedB() { return 2; }\n")
import unittest.mock
with unittest.mock.patch("time.sleep"):
- result = self._r.invoke(
- cli, ["-p", str(tmp_path), "remove", "-c", "unused_export"]
- )
+ result = self._r.invoke(cli, ["-p", str(tmp_path), "remove", "-c", "unused_export"])
assert result.exit_code == 0
content = mod.read_text()
@@ -474,19 +430,14 @@ def test_remove_preserves_used_exports(self, tmp_path):
"""remove should not blank lines for exports that are imported elsewhere."""
mod = tmp_path / "src" / "mod.ts"
mod.parent.mkdir(parents=True, exist_ok=True)
- mod.write_text(
- "export function usedFunc() { return 1; }\n"
- "export function unusedFunc() { return 2; }\n"
- )
+ mod.write_text("export function usedFunc() { return 1; }\nexport function unusedFunc() { return 2; }\n")
app = tmp_path / "src" / "app.ts"
app.write_text('import { usedFunc } from "./mod";\nusedFunc();\n')
import unittest.mock
with unittest.mock.patch("time.sleep"):
- result = self._r.invoke(
- cli, ["-p", str(tmp_path), "remove", "-c", "unused_export"]
- )
+ result = self._r.invoke(cli, ["-p", str(tmp_path), "remove", "-c", "unused_export"])
assert result.exit_code == 0
content = mod.read_text()
@@ -502,14 +453,10 @@ def test_remove_no_removable_findings(self, tmp_path):
# component in a page file (excluded by /page. check).
page = tmp_path / "app" / "page.tsx"
page.parent.mkdir(parents=True, exist_ok=True)
- page.write_text(
- 'export function Home() { return About; }\n'
- )
+ page.write_text('export function Home() { return About; }\n')
about = tmp_path / "app" / "about" / "page.tsx"
about.parent.mkdir(parents=True, exist_ok=True)
- about.write_text(
- "export default function About() { return About
; }\n"
- )
+ about.write_text("export default function About() { return About
; }\n")
result = self._r.invoke(cli, ["-p", str(tmp_path), "remove", "--dry-run"])
assert result.exit_code == 0
@@ -541,9 +488,7 @@ def test_remove_missing_file_skips_gracefully(self, tmp_path):
if fpath.exists():
fpath.unlink()
- result = runner.invoke(
- cli, ["-p", str(tmp_path), "remove", "-c", "unused_export"]
- )
+ result = runner.invoke(cli, ["-p", str(tmp_path), "remove", "-c", "unused_export"])
assert result.exit_code == 0
@@ -556,15 +501,9 @@ def test_console_scripts_entry_point(self):
eps = entry_points()
# Python 3.12+ returns a SelectableGroups; 3.10 returns dict
- scripts = (
- eps.select(group="console_scripts")
- if hasattr(eps, "select")
- else eps.get("console_scripts", [])
- )
+ scripts = eps.select(group="console_scripts") if hasattr(eps, "select") else eps.get("console_scripts", [])
deadcode_eps = [e for e in scripts if e.name == "deadcode"]
- assert len(deadcode_eps) >= 1, (
- "Expected at least one 'deadcode' console_scripts entry point"
- )
+ assert len(deadcode_eps) >= 1, "Expected at least one 'deadcode' console_scripts entry point"
ep = deadcode_eps[0]
assert "deadcode.cli:cli" in str(ep.value) or "deadcode.cli:cli" in str(ep)
@@ -575,9 +514,7 @@ def test_py_typed_marker_exists(self):
import deadcode
pkg_dir = Path(deadcode.__file__).parent
- assert (pkg_dir / "py.typed").exists(), (
- "py.typed marker missing — needed for PEP 561 type-checking support"
- )
+ assert (pkg_dir / "py.typed").exists(), "py.typed marker missing — needed for PEP 561 type-checking support"
def test_ruff_known_first_party(self):
"""ruff known-first-party should be ['deadcode'], not ['*']."""
@@ -591,13 +528,9 @@ def test_ruff_known_first_party(self):
pyproject = Path(__file__).parent.parent / "pyproject.toml"
with open(pyproject, "rb") as f:
data = tomllib.load(f)
- isort_cfg = (
- data.get("tool", {}).get("ruff", {}).get("lint", {}).get("isort", {})
- )
+ isort_cfg = data.get("tool", {}).get("ruff", {}).get("lint", {}).get("isort", {})
kfp = isort_cfg.get("known-first-party", [])
- assert kfp == ["deadcode"], (
- f"known-first-party should be ['deadcode'], got {kfp}"
- )
+ assert kfp == ["deadcode"], f"known-first-party should be ['deadcode'], got {kfp}"
def test_package_data_includes_py_typed(self):
"""pyproject.toml should have package-data config for py.typed."""
@@ -612,9 +545,7 @@ def test_package_data_includes_py_typed(self):
with open(pyproject, "rb") as f:
data = tomllib.load(f)
pkg_data = data.get("tool", {}).get("setuptools", {}).get("package-data", {})
- assert "deadcode" in pkg_data, (
- "Expected [tool.setuptools.package-data] section for 'deadcode'"
- )
+ assert "deadcode" in pkg_data, "Expected [tool.setuptools.package-data] section for 'deadcode'"
assert "py.typed" in pkg_data["deadcode"], (
f"Expected 'py.typed' in package-data for deadcode, got {pkg_data['deadcode']}"
)
@@ -627,9 +558,7 @@ def test_dynamic_routes_not_reported_dead(self, tmp_path):
"""Dynamic routes (with [param]) should not be flagged as dead."""
page = tmp_path / "app" / "users" / "[id]" / "page.tsx"
page.parent.mkdir(parents=True, exist_ok=True)
- page.write_text(
- "export default function UserProfile() { return Profile
; }\n"
- )
+ page.write_text("export default function UserProfile() { return Profile
; }\n")
scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()
@@ -654,9 +583,7 @@ def test_component_in_layout_file_not_reported(self, tmp_path):
"""Components in layout.tsx files should not be reported as unreferenced."""
layout = tmp_path / "app" / "layout.tsx"
layout.parent.mkdir(parents=True, exist_ok=True)
- layout.write_text(
- "export function RootLayout() { return Layout
; }\n"
- )
+ layout.write_text("export function RootLayout() { return Layout
; }\n")
scanner = DeadCodeScanner(tmp_path)
result = scanner.scan()
@@ -686,9 +613,7 @@ def test_css_utility_prefixes_skipped(self, tmp_path):
"""CSS classes with utility prefixes (hover:, focus:, etc.) should be skipped."""
css = tmp_path / "styles.css"
css.write_text(
- ".hover:bg-red { color: red; }\n"
- ".focus:ring { outline: none; }\n"
- ".normal-class { color: blue; }\n"
+ ".hover:bg-red { color: red; }\n.focus:ring { outline: none; }\n.normal-class { color: blue; }\n"
)
# No JSX using any of these
@@ -697,12 +622,8 @@ def test_css_utility_prefixes_skipped(self, tmp_path):
orphaned = {f.name for f in result.orphaned_css}
# Utility-prefixed classes should be skipped (starts with hover:, focus:)
- assert "hover:bg-red" not in orphaned, (
- "hover:bg-red should be skipped by utility prefix check"
- )
- assert "focus:ring" not in orphaned, (
- "focus:ring should be skipped by utility prefix check"
- )
+ assert "hover:bg-red" not in orphaned, "hover:bg-red should be skipped by utility prefix check"
+ assert "focus:ring" not in orphaned, "focus:ring should be skipped by utility prefix check"
# Normal class without a utility prefix IS orphaned
assert "normal-class" in orphaned
# Verify the full utility class names are captured (not truncated at colon)
@@ -712,12 +633,8 @@ def test_css_utility_prefixes_skipped(self, tmp_path):
css_classes.add(finding.name)
# If hover:bg-red or focus:ring appear as orphaned (before skip), that's expected
# Names like 'hover' or 'focus' alone should NOT appear (regression check)
- assert "hover" not in orphaned, (
- "hover should not appear alone — it's part of hover:bg-red"
- )
- assert "focus" not in orphaned, (
- "focus should not appear alone — it's part of focus:ring"
- )
+ assert "hover" not in orphaned, "hover should not appear alone — it's part of hover:bg-red"
+ assert "focus" not in orphaned, "focus should not appear alone — it's part of focus:ring"
def test_scan_read_error_recorded(self, tmp_path):
"""Scanner should record errors for files it can't read."""
@@ -755,9 +672,7 @@ def test_default_import_parsed_correctly(self, tmp_path):
result = scanner.scan()
unused_names = {f.name for f in result.unused_exports}
- assert "helper" not in unused_names, (
- "helper should be recognized as imported via default import"
- )
+ assert "helper" not in unused_names, "helper should be recognized as imported via default import"
def test_scan_collect_files_with_ignore(self, tmp_path):
"""Scanner._collect_files handles ignore_spec correctly (covers scanner.py:238-241)."""
diff --git a/tests/test_css_module_boundaries.py b/tests/test_css_module_boundaries.py
new file mode 100644
index 0000000..7a874df
--- /dev/null
+++ b/tests/test_css_module_boundaries.py
@@ -0,0 +1,50 @@
+"""Protect live styles and the project boundary during a dead-code scan."""
+
+import pytest
+
+from deadcode.scanner import DeadCodeScanner
+
+
+@pytest.mark.parametrize("extension", ["ts", "js", "tsx", "jsx"])
+@pytest.mark.parametrize(
+ ("binding", "accessor", "class_name"),
+ [
+ ("styles", "._active", "_active"),
+ ("styles", "[ 'card-hover' ]", "card-hover"),
+ ("$styles", ".card", "card"),
+ ],
+)
+def test_css_module_accessors_preserve_live_classes(tmp_path, extension, binding, accessor, class_name):
+ (tmp_path / "Card.module.css").write_text(f".{class_name} {{ color: red; }}\n.unused {{}}\n")
+ (tmp_path / f"consumer.{extension}").write_text(
+ f"import {binding} from './Card.module.css';\nconst selected = {binding}{accessor};\n"
+ )
+
+ result = DeadCodeScanner(tmp_path).scan()
+
+ assert class_name not in {finding.name for finding in result.orphaned_css}
+ assert "unused" in {finding.name for finding in result.orphaned_css}
+
+
+def test_scanner_does_not_read_source_symlink_outside_project(tmp_path):
+ project = tmp_path / "project"
+ project.mkdir()
+ external = tmp_path / "external.ts"
+ external.write_text("export const externalLiveValue = 1;\n")
+ (project / "linked.ts").symlink_to(external)
+
+ result = DeadCodeScanner(project).scan()
+
+ assert result.files_scanned == 0
+ assert result.findings == []
+ assert external.read_text() == "export const externalLiveValue = 1;\n"
+
+
+def test_scanner_still_reads_internal_source_symlink(tmp_path):
+ target = tmp_path / "target.ts"
+ target.write_text("export const internalValue = 1;\n")
+ (tmp_path / "linked.ts").symlink_to(target)
+
+ result = DeadCodeScanner(tmp_path).scan()
+
+ assert result.files_scanned == 2
diff --git a/tests/test_css_module_scopes.py b/tests/test_css_module_scopes.py
new file mode 100644
index 0000000..1b370f9
--- /dev/null
+++ b/tests/test_css_module_scopes.py
@@ -0,0 +1,136 @@
+"""CSS-module uses belong to one stylesheet, while literal classes stay global."""
+
+import pytest
+from click.testing import CliRunner
+
+from deadcode.cli import cli
+from deadcode.scanner import DeadCodeScanner
+
+
+def orphaned(project):
+ return {(finding.file, finding.name) for finding in DeadCodeScanner(project).scan().orphaned_css}
+
+
+@pytest.mark.parametrize("extension", ["ts", "js", "tsx", "jsx"])
+@pytest.mark.parametrize(
+ ("binding", "accessor", "class_name"),
+ [
+ ("styles", ".shared", "shared"),
+ ("styles", "[ 'card-hover' ]", "card-hover"),
+ ("$styles", "._active", "_active"),
+ ],
+)
+def test_module_accessor_only_protects_its_imported_stylesheet(tmp_path, extension, binding, accessor, class_name):
+ for name in ["A.module.css", "B.module.css", "global.css"]:
+ (tmp_path / name).write_text(f".{class_name} {{ color: red; }}\n")
+ (tmp_path / f"consumer.{extension}").write_text(
+ f"import {binding} from './B.module.css';\nconst selected = {binding}{accessor};\n"
+ )
+
+ assert orphaned(tmp_path) == {("A.module.css", class_name), ("global.css", class_name)}
+
+
+def test_nested_imports_distinguish_stylesheets_with_the_same_filename(tmp_path):
+ for directory in ["components", "first", "second"]:
+ (tmp_path / directory).mkdir()
+ for directory in ["first", "second"]:
+ (tmp_path / directory / "Card.module.css").write_text(".shared {}\n")
+ (tmp_path / "components" / "consumer.ts").write_text(
+ "import styles from '../second/./Card.module.css';\nconst selected = styles.shared;\n"
+ )
+
+ assert orphaned(tmp_path) == {("first/Card.module.css", "shared")}
+
+
+def test_bindings_are_local_to_each_importing_file(tmp_path):
+ for name in ["A.module.css", "B.module.css"]:
+ (tmp_path / name).write_text(".shared {}\n.other {}\n")
+ (tmp_path / "first.ts").write_text("import styles from './A.module.css';\nconst selected = styles.shared;\n")
+ (tmp_path / "second.ts").write_text("import styles from './B.module.css';\nconst selected = styles.other;\n")
+
+ assert orphaned(tmp_path) == {("A.module.css", "other"), ("B.module.css", "shared")}
+
+
+def test_literal_classes_protect_global_stylesheets_without_protecting_module_classes(tmp_path):
+ for name in ["first.css", "second.scss", "Card.module.css"]:
+ (tmp_path / name).write_text(".shared {}\n")
+ (tmp_path / "consumer.tsx").write_text('export const View = () => ;\n')
+
+ assert orphaned(tmp_path) == {("Card.module.css", "shared")}
+
+
+def test_global_literal_and_module_accessor_uses_coexist(tmp_path):
+ for name in ["A.module.css", "B.module.css", "global.css"]:
+ (tmp_path / name).write_text(".shared {}\n.literal {}\n")
+ (tmp_path / "consumer.tsx").write_text(
+ "import styles from './B.module.css';\n"
+ 'export const View = () =>
;\n'
+ )
+
+ assert orphaned(tmp_path) == {
+ ("A.module.css", "shared"),
+ ("A.module.css", "literal"),
+ ("B.module.css", "literal"),
+ ("global.css", "shared"),
+ }
+
+
+@pytest.mark.parametrize("specifier", ["./missing.module.css", "package/Other.module.css"])
+def test_unresolved_imports_do_not_hide_unrelated_classes(tmp_path, specifier):
+ (tmp_path / "A.module.css").write_text(".shared {}\n")
+ (tmp_path / "global.css").write_text(".shared {}\n")
+ (tmp_path / "consumer.ts").write_text(f"import styles from '{specifier}';\nconst selected = styles.shared;\n")
+
+ assert orphaned(tmp_path) == {("A.module.css", "shared"), ("global.css", "shared")}
+
+
+def test_internal_stylesheet_symlink_keeps_live_target_and_alias_classes(tmp_path):
+ (tmp_path / "Actual.module.css").write_text(".shared {}\n.unused {}\n")
+ (tmp_path / "Linked.module.css").symlink_to(tmp_path / "Actual.module.css")
+ (tmp_path / "consumer.ts").write_text(
+ "import styles from './Linked.module.css';\nconst selected = styles.shared;\n"
+ )
+
+ assert orphaned(tmp_path) == {("Actual.module.css", "unused"), ("Linked.module.css", "unused")}
+
+
+def test_remove_preserves_live_module_and_only_removes_unused_definitions(tmp_path):
+ for name in ["A.module.css", "B.module.css", "global.css"]:
+ (tmp_path / name).write_text(".shared { color: red; }\n")
+ (tmp_path / "consumer.ts").write_text("import styles from './B.module.css';\nconst selected = styles.shared;\n")
+
+ result = CliRunner().invoke(cli, ["-p", str(tmp_path), "remove", "-c", "orphaned_css"], input="y\n")
+
+ assert result.exit_code == 0, result.output
+ assert (tmp_path / "B.module.css").read_text() == ".shared { color: red; }\n"
+ assert ".shared" not in (tmp_path / "A.module.css").read_text()
+ assert ".shared" not in (tmp_path / "global.css").read_text()
+
+
+def test_unresolved_alias_keeps_module_findings_visible_and_nonremovable(tmp_path):
+ for name in ["A.module.css", "B.module.css", "global.css"]:
+ (tmp_path / name).write_text(".shared {}\n")
+ (tmp_path / "consumer.ts").write_text("import styles from '@/Card.module.css';\nconst selected = styles.shared;\n")
+
+ findings = DeadCodeScanner(tmp_path).scan().orphaned_css
+
+ assert {(finding.file, finding.name) for finding in findings} == {
+ ("A.module.css", "shared"),
+ ("B.module.css", "shared"),
+ ("global.css", "shared"),
+ }
+ assert {finding.file for finding in findings if finding.removable} == {"global.css"}
+ assert all("unresolved stylesheet import" in finding.detail for finding in findings if not finding.removable)
+
+
+def test_remove_retains_uncertain_module_classes_and_removes_unreferenced_global_class(tmp_path):
+ for name in ["A.module.css", "B.module.css", "global.css"]:
+ (tmp_path / name).write_text(".shared { color: red; }\n")
+ (tmp_path / "consumer.ts").write_text("import styles from '@/Card.module.css';\nconst selected = styles.shared;\n")
+
+ result = CliRunner().invoke(cli, ["-p", str(tmp_path), "remove", "-c", "orphaned_css"], input="y\n")
+
+ assert result.exit_code == 0, result.output
+ for name in ["A.module.css", "B.module.css"]:
+ assert (tmp_path / name).read_text() == ".shared { color: red; }\n"
+ assert ".shared" not in (tmp_path / "global.css").read_text()
diff --git a/tests/test_namespace_sideeffect_imports.py b/tests/test_namespace_sideeffect_imports.py
index eb9cd6a..a812040 100644
--- a/tests/test_namespace_sideeffect_imports.py
+++ b/tests/test_namespace_sideeffect_imports.py
@@ -19,9 +19,7 @@ def _make_project(tmp_path: Path, consumer_source: str) -> Path:
"""utils.ts defines two exports; main.ts consumes it per consumer_source."""
utils = tmp_path / "src" / "utils.ts"
utils.parent.mkdir(parents=True, exist_ok=True)
- utils.write_text(
- "export function helper() { return 1; }\nexport const RATE = 2;\n"
- )
+ utils.write_text("export function helper() { return 1; }\nexport const RATE = 2;\n")
main = tmp_path / "src" / "main.ts"
main.write_text(consumer_source)
return tmp_path
@@ -40,8 +38,7 @@ class TestNamespaceImports:
def test_namespace_import_marks_all_exports_used(self, tmp_path):
project = _make_project(
tmp_path,
- "import * as Utils from './utils';\n\n"
- "const total = Utils.helper() + Utils.RATE;\nexport default total;\n",
+ "import * as Utils from './utils';\n\nconst total = Utils.helper() + Utils.RATE;\nexport default total;\n",
)
# Neither utils.ts export may be flagged: both are consumed via the
# namespace binding.
@@ -87,10 +84,7 @@ class TestDynamicAndRequireImports:
def test_dynamic_import_marks_all_exports_used(self, tmp_path):
project = _make_project(
tmp_path,
- "export async function load() {\n"
- " const u = await import('./utils');\n"
- " return u.helper() + u.RATE;\n"
- "}\n",
+ "export async function load() {\n const u = await import('./utils');\n return u.helper() + u.RATE;\n}\n",
)
assert not [f for _n, f in _unused_names(project) if f.endswith("utils.ts")]
diff --git a/tests/test_nested_include.py b/tests/test_nested_include.py
new file mode 100644
index 0000000..d83f23f
--- /dev/null
+++ b/tests/test_nested_include.py
@@ -0,0 +1,48 @@
+"""File include patterns must not prune unmatched ancestor directories."""
+
+import json
+
+import pytest
+from click.testing import CliRunner
+
+from deadcode.cli import cli
+from deadcode.scanner import DeadCodeScanner
+
+
+@pytest.fixture
+def nested_project(tmp_path):
+ nested = tmp_path / "src" / "components"
+ nested.mkdir(parents=True)
+ (nested / "Unused.tsx").write_text("export const unused = 1;\n")
+ (nested / "ignored.tsx").write_text("export const ignored = 2;\n")
+ (tmp_path / "outside.ts").write_text("export const outside = 3;\n")
+ return tmp_path
+
+
+@pytest.mark.parametrize("pattern", ["src/components/*.tsx", "**/*.tsx", "src/components/Unused.tsx"])
+def test_nested_file_whitelist_reaches_matching_files(nested_project, pattern):
+ result = DeadCodeScanner(nested_project, include_patterns=[pattern], ignore_patterns=["**/ignored.tsx"]).scan()
+
+ assert result.files_scanned == 1
+ assert [(finding.file, finding.name) for finding in result.findings] == [("src/components/Unused.tsx", "unused")]
+
+
+@pytest.mark.parametrize("command", [["scan", "--format=json"], ["stats"], ["remove", "--dry-run"]])
+def test_nested_whitelist_applies_to_all_cli_commands(nested_project, command):
+ source = nested_project / "src" / "components" / "Unused.tsx"
+ original = source.read_bytes()
+
+ result = CliRunner().invoke(
+ cli,
+ ["-p", str(nested_project), "--include", "src/components/Unused.tsx", *command],
+ )
+
+ assert result.exit_code == 0
+ if command[0] == "scan":
+ assert json.loads(result.output)["files_scanned"] == 1
+ elif command[0] == "stats":
+ assert "Files scanned: 1" in result.output
+ else:
+ assert "WOULD REMOVE" in result.output
+ assert "outside.ts" not in result.output
+ assert source.read_bytes() == original