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..eeb8248 100644 --- a/src/deadcode/scanner.py +++ b/src/deadcode/scanner.py @@ -11,6 +11,7 @@ import os import re +from bisect import bisect_right from dataclasses import dataclass, field from pathlib import Path @@ -31,6 +32,14 @@ class Finding: removable: bool = False # Whether safe auto-removal is possible +@dataclass(frozen=True) +class _CssDefinition: + file: str + line: int + global_scope: bool + rule: int | None = None + + @dataclass class ScanResult: """Aggregated scan results.""" @@ -89,11 +98,117 @@ def unreferenced_components(self) -> list[Finding]: r"(?:app|src/app|pages|src/pages)/(.*?)/(?:page|route)\.(?:tsx|ts|jsx|js)$", ) -# CSS class selectors (supports Tailwind utility classes with colon-separated segments like hover:bg-red) +# CSS escapes include up to six hexadecimal digits and their optional single +# whitespace terminator. Consume them inside the identifier before interpreting +# punctuation as selector syntax (for example, .foo\\:bar and .foo\\.bar). +_CSS_ESCAPE = r"\\(?:[0-9a-fA-F]{1,6}(?:\r\n|[ \t\r\n\f])?|[^\r\n\f0-9a-fA-F])" +_CSS_NAME_START = rf"(?:[a-zA-Z_]|[^\x00-\x7f]|{_CSS_ESCAPE})" +_CSS_NAME_CHAR = rf"(?:[a-zA-Z0-9_-]|[^\x00-\x7f]|{_CSS_ESCAPE})" +_CSS_IDENTIFIER = rf"(?:--|-?{_CSS_NAME_START}){_CSS_NAME_CHAR}*" +_CSS_IDENTIFIER_PATTERN = re.compile(_CSS_IDENTIFIER) +_CSS_ESCAPE_PATTERN = re.compile(_CSS_ESCAPE) + +# Preserve the existing global utility-prefix handling as well as escaped names. _CSS_CLASS_PATTERN = re.compile( - r"\.([a-zA-Z_][\w-]*(?::[\w-]+)*)\s*(?:\{|,|\[)", + rf"\.({_CSS_IDENTIFIER}(?::{_CSS_IDENTIFIER})*)\s*(?:\{{|,|\[)", ) +_CSS_NONCODE_PATTERN = re.compile(_CSS_ESCAPE + r"|/\*[\s\S]*?\*/|'(?:\\[\s\S]|[^'\\])*'|\"(?:\\[\s\S]|[^\"\\])*\"") +_CSS_SELECTOR_TOKEN_PATTERN = re.compile( + rf"{_CSS_ESCAPE}|:(global|local)(?![\w-]|[^\x00-\x7f]|\\)|\.({_CSS_IDENTIFIER})|[(),\[\]]" +) +_CSS_BLOCK_TOKEN_PATTERN = re.compile(_CSS_ESCAPE + r"|[{};()\[\]]") + + +def _decode_css_identifier(identifier: str) -> str: + """Decode CSS Syntax escapes, replacing invalid escaped Unicode scalars.""" + + def unescape(match: re.Match) -> str: + value = match.group()[1:] + if value[0] in "0123456789abcdefABCDEF": + codepoint = int(value.rstrip(" \t\r\n\f"), 16) + return chr(codepoint) if 0 < codepoint <= 0x10FFFF and not 0xD800 <= codepoint <= 0xDFFF else "�" + return value + + return _CSS_ESCAPE_PATTERN.sub(unescape, identifier) + + +def _module_selector_classes(selector: str, inherited_global: bool): + """Yield each class's own scope, respecting functional and bare switches.""" + global_scope = inherited_global + parentheses: list[tuple[bool, bool]] = [] + pending_scope: bool | None = None + brackets = 0 + for match in _CSS_SELECTOR_TOKEN_PATTERN.finditer(selector): + token = match.group() + if token == "[": + brackets += 1 + elif token == "]": + brackets = max(0, brackets - 1) + elif brackets: + continue + elif match.group(1): + scope = match.group(1) == "global" + if selector[match.end() :].lstrip().startswith("("): + pending_scope = scope + else: + global_scope = scope + elif token == "(": + inner_scope = global_scope if pending_scope is None else pending_scope + parentheses.append((global_scope, inner_scope)) + global_scope = inner_scope + pending_scope = None + elif token == ")": + if parentheses: + global_scope = parentheses.pop()[0] + elif token == ",": + global_scope = parentheses[-1][1] if parentheses else inherited_global + elif match.group(2): + yield _decode_css_identifier(match.group(2)), match.start(), global_scope + + +def _module_css_definitions(content: str, rel_path: str): + """Read selector headers without mistaking declaration strings for classes.""" + masked = _CSS_NONCODE_PATTERN.sub( + lambda match: match.group() if match.group().startswith("\\") else re.sub(r"[^\n]", " ", match.group()), content + ) + lines = [0, *(match.end() for match in re.finditer("\n", content))] + scopes = [False] + start = 0 + parentheses = brackets = 0 + for match in _CSS_BLOCK_TOKEN_PATTERN.finditer(masked): + token = match.group() + if token.startswith("\\"): + continue + if token == "(": + parentheses += 1 + elif token == ")": + parentheses = max(0, parentheses - 1) + elif token == "[": + brackets += 1 + elif token == "]": + brackets = max(0, brackets - 1) + elif parentheses or brackets: + continue + elif token == "{": + selector = masked[start : match.start()] + if not selector.lstrip().startswith("@"): + for name, offset, global_scope in _module_selector_classes(selector, scopes[-1]): + yield ( + name, + _CssDefinition(rel_path, bisect_right(lines, start + offset), global_scope, match.start()), + ) + block_scope = {":global": True, ":local": False}.get(selector.strip(), scopes[-1]) + scopes.append(block_scope) + start = match.end() + elif token == "}": + if len(scopes) > 1: + scopes.pop() + start = match.end() + else: + start = match.end() + + # import statements # Handles: import {Foo} from ..., import Foo from ..., import type {Foo} from ..., # import Default, {Named} from ..., import {type Foo} from ... @@ -126,15 +241,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 +268,65 @@ 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. +# Computed keys are full quoted JavaScript literals, not identifier prefixes. _CSS_MODULE_USAGE_PATTERN = re.compile( - r"""\b\w+\.(?:_?[\w$]+)|(\w+)\[['"]([\w$-]+)['"]\]""", + r"""(? str | None: + """Decode a computed literal; retain uncertainty for interpolation or invalid escapes.""" + body = literal[1:-1] + decoded = [] + index = 0 + while index < len(body): + char = body[index] + index += 1 + if char != "\\": + if literal[0] == "`" and char == "$" and body[index : index + 1] == "{": + return None + decoded.append(char) + continue + if index == len(body): + return None + char = body[index] + index += 1 + if char in "\r\n\u2028\u2029": + if char == "\r" and body[index : index + 1] == "\n": + index += 1 + continue + if char in "xu": + width = 2 if char == "x" else 4 + if char == "u" and body[index : index + 1] == "{": + end = body.find("}", index + 1) + digits = body[index + 1 : end] if end >= 0 else "" + index = end + 1 + else: + digits = body[index : index + width] + index += width + if len(digits) != width: + return None + if not re.fullmatch(r"[0-9a-fA-F]{1,6}", digits) or int(digits, 16) > 0x10FFFF: + return None + decoded.append(chr(int(digits, 16))) + elif char.isdecimal() and (char != "0" or body[index : index + 1].isdigit()): + return None # Legacy octal escapes are invalid in ECMAScript modules. + else: + decoded.append( + {"n": "\n", "r": "\r", "t": "\t", "b": "\b", "f": "\f", "v": "\v", "0": "\0"}.get(char, char) + ) + try: + # JavaScript Unicode escapes use UTF-16; pairs must match astral CSS names. + return "".join(decoded).encode("utf-16-le", "surrogatepass").decode("utf-16-le") + except UnicodeDecodeError: + return None + + # Detect a CSS-module import so we know which source-file class accessors # correspond to module classes (``import styles from './x.module.css'``). _CSS_MODULE_IMPORT_PATTERN = re.compile( - r"""import\s+(?:type\s+)?(\w+)\s+from\s+['"]([^'"]*\.module\.css)['"]""", + r"""import\s+(?:type\s+)?([\w$]+)\s+from\s+['"]([^'"]*\.module\.css)['"]""", ) @@ -226,11 +383,14 @@ def scan(self) -> ScanResult: # Phase 1: Build export map and import map exports: dict[str, list[tuple[str, int]]] = {} # name -> [(file, line)] imports: dict[str, set[str]] = {} # name -> set of files that import it - css_classes: dict[str, list[tuple[str, int]]] = {} # class -> [(file, line)] + css_classes: dict[str, list[_CssDefinition]] = {} used_css_classes: set[str] = set() + used_css_module_classes: dict[Path, set[str | None]] = {} + unresolved_css_module_classes: set[str | None] = 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 +418,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 +440,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 +453,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 +475,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 +486,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 @@ -493,12 +662,21 @@ def _resolve_relative_module(importer_rel: str, spec: str, file_set: set[str]) - return candidate return None - 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[_CssDefinition]]) -> None: """Extract CSS class names defined in a stylesheet.""" + if rel_path.endswith(".module.css"): + for cls, definition in _module_css_definitions(content, rel_path): + css_classes.setdefault(cls, []).append(definition) + return for i, line in enumerate(content.splitlines(), 1): for m in _CSS_CLASS_PATTERN.finditer(line): - cls = m.group(1) - css_classes.setdefault(cls, []).append((rel_path, i)) + identifier = m.group(1) + if "\\" in identifier: + # An unescaped colon following an escaped name begins a + # pseudo-selector, not part of that class's decoded key. + identifier = _CSS_IDENTIFIER_PATTERN.match(identifier).group() + cls = _decode_css_identifier(identifier) + css_classes.setdefault(cls, []).append(_CssDefinition(rel_path, i, True)) def _parse_classname_usage(self, content: str, used_css_classes: set[str]) -> None: """Extract CSS class names used in JSX className attributes.""" @@ -508,7 +686,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 | None]], + unresolved_css_module_classes: set[str | None], + ) -> None: """Extract CSS-module class names consumed via object accessors. The canonical Next.js/React pattern is:: @@ -516,33 +701,35 @@ 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_literal = m.groups() + if binding in module_bindings: + name = dot_name if dot_name is not None else _decode_js_string(bracket_literal) + stylesheet_path = module_bindings[binding] + if stylesheet_path is None: + unresolved_css_module_classes.add(name) + else: + used_css_module_classes.setdefault(stylesheet_path, set()).add(name) def _parse_components(self, content: str, rel_path: str, components: dict[str, str]) -> None: """Extract React component definitions.""" @@ -667,11 +854,17 @@ def _find_dead_routes( def _find_orphaned_css( self, - css_classes: dict[str, list[tuple[str, int]]], + css_classes: dict[str, list[_CssDefinition]], used_css_classes: set[str], + used_css_module_classes: dict[Path, set[str | None]], + unresolved_css_module_classes: set[str | None], result: ScanResult, ) -> None: """Find CSS classes defined but never used in JSX.""" + module_usage_by_file: dict[str, set[str | None]] = {} + protected_lines: set[tuple[str, int]] = set() + protected_rules: set[tuple[str, int]] = set() + unused: list[tuple[str, _CssDefinition, bool]] = [] for cls, locations in css_classes.items(): # Skip common utility classes and pseudo-selectors if cls.startswith( @@ -687,20 +880,54 @@ def _find_orphaned_css( "xl:", ) ): + # Skipped utility classes still protect a shared rule/line from + # whole-line removal through a separate unused selector. + protected_lines.update((definition.file, definition.line) for definition in locations) + protected_rules.update( + (definition.file, definition.rule) for definition in locations if definition.rule is not None + ) continue - 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, - ) + for definition in locations: + file, line = definition.file, definition.line + if definition.global_scope: + used = cls in used_css_classes + else: + if file not in module_usage_by_file: + module_usage_by_file[file] = used_css_module_classes.get( + (self.project_dir / file).resolve(), set() + ) + used = cls in module_usage_by_file[file] + uncertain = not definition.global_scope and ( + cls in unresolved_css_module_classes + or None in unresolved_css_module_classes + or None in module_usage_by_file[file] ) + if used or uncertain: + protected_lines.add((file, line)) + if definition.rule is not None: + protected_rules.add((file, definition.rule)) + if not used: + unused.append((cls, definition, uncertain)) + for cls, definition, uncertain in unused: + shared = (definition.file, definition.line) in protected_lines or ( + definition.rule is not None and (definition.file, definition.rule) in protected_rules + ) + result.findings.append( + Finding( + file=definition.file, + line=definition.line, + name=cls, + category="orphaned_css", + detail=( + f"CSS module class '.{cls}' may be used through an unresolved stylesheet import or computed key" + if uncertain + else f"CSS class '.{cls}' shares a rule or line with a used selector; remove manually" + if shared + else f"CSS class '.{cls}' is not used in any JSX className" + ), + removable=not (uncertain or shared), + ) + ) def _find_unreferenced_components( self, 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