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