Skip to content

Commit 0ccab80

Browse files
Coding-Dev-ToolsDevForge Engineer
andauthored
feat: add scan format option (#32)
* fix: remove duplicate include_spec check in _collect_files; test: add 9 edge case tests covering remove non-dry-run, dynamic routes, utility CSS prefixes, read errors, skip names, page/layout component exclusion * fix: update CSS class regex to capture Tailwind utility classes with colon prefixes * fix: correct ruff known-first-party, add py.typed marker, relative import in __main__, packaging tests * feat: add --format option to scan with pretty/compact/github/json output --------- Co-authored-by: DevForge Engineer <engineer@devforge.dev>
1 parent 739898a commit 0ccab80

2 files changed

Lines changed: 80 additions & 5 deletions

File tree

src/deadcode/cli.py

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22

33
from __future__ import annotations
44

5-
import click
65
import json
76
import sys
87
from pathlib import Path
8+
9+
import click
910
from rich.console import Console
1011
from rich.table import Table
1112

@@ -16,7 +17,9 @@
1617
console = Console()
1718
err_console = Console(stderr=True)
1819

20+
FORMAT_HELP = "Output format: pretty (default), compact, github, or json"
1921
ALL_CATEGORIES = ["unused_export", "dead_route", "orphaned_css", "unreferenced_component"]
22+
FORMAT_CHOICES = click.Choice(["pretty", "compact", "github", "json"])
2023

2124

2225
@click.group()
@@ -64,12 +67,13 @@ def _get_fail_threshold(ctx: click.Context) -> int:
6467

6568

6669
@cli.command()
67-
@click.option("--json-output", "-j", is_flag=True, help="Output as JSON")
70+
@click.option("--json-output", "-j", is_flag=True, help="Alias for --format=json (deprecated)")
71+
@click.option("--format", type=FORMAT_CHOICES, default="pretty", help=FORMAT_HELP)
6872
@click.option("--category", "-c", type=click.Choice(ALL_CATEGORIES), default=None, help="Filter by category")
6973
@click.option("--fail", "fail_threshold", type=int, default=None,
7074
help="Exit code 1 if findings >= threshold (overrides .deadcode.yml)")
7175
@click.pass_context
72-
def scan(ctx: click.Context, json_output: bool, category: str | None, fail_threshold: int | None) -> None:
76+
def scan(ctx: click.Context, json_output: bool, format: str | None, category: str | None, fail_threshold: int | None) -> None:
7377
"""Scan project for dead code."""
7478
project = ctx.obj["project"]
7579
ignore = _merge_config_ignore(ctx)
@@ -92,7 +96,10 @@ def scan(ctx: click.Context, json_output: bool, category: str | None, fail_thres
9296
if not category and config and config.categories:
9397
findings = [f for f in findings if f.category in config.categories]
9498

95-
if json_output:
99+
# Determine effective format (legacy --json-output maps to json)
100+
effective_format = "json" if json_output else (format or "pretty")
101+
102+
if effective_format == "json":
96103
output = {
97104
"files_scanned": result.files_scanned,
98105
"findings": [
@@ -103,6 +110,26 @@ def scan(ctx: click.Context, json_output: bool, category: str | None, fail_thres
103110
"errors": result.errors,
104111
}
105112
console.print(json.dumps(output, indent=2, default=str))
113+
elif effective_format == "compact":
114+
if not findings:
115+
console.print("OK — 0 findings")
116+
else:
117+
for f in findings:
118+
console.print(f"{f.file}:{f.line} \u2014 {f.category}: {f.name}")
119+
console.print(f"\n{len(findings)} findings")
120+
elif effective_format == "github":
121+
# GitHub Actions annotation syntax
122+
# ::warning file={name},line={line},endLine={line}::{message}
123+
if not findings:
124+
console.print("deadcode: 0 findings")
125+
else:
126+
for f in findings:
127+
level = "error" if f.removable else "warning"
128+
msg = f"{f.category}: {f.name}"
129+
if f.detail:
130+
msg += f" ({f.detail[:120]})"
131+
console.print(f"::{level} file={f.file},line={f.line}::{msg}")
132+
console.print(f"\n::notice::deadcode: {len(findings)} findings")
106133
else:
107134
# Summary
108135
console.print(f"\n[bold]DeadCode Scan[/bold] — {result.files_scanned} files scanned\n")
@@ -149,7 +176,7 @@ def scan(ctx: click.Context, json_output: bool, category: str | None, fail_thres
149176
# CI fail threshold
150177
effective_threshold = fail_threshold if fail_threshold is not None else _get_fail_threshold(ctx)
151178
if effective_threshold >= 0 and len(findings) >= effective_threshold:
152-
if not json_output:
179+
if effective_format not in ("json", "github"):
153180
console.print(f"\n[red]FAIL: {len(findings)} findings >= threshold {effective_threshold}[/red]")
154181
sys.exit(1)
155182

tests/test_scanner.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,3 +388,51 @@ def test_include_patterns_none_scans_all(self, tmp_path):
388388
scanner = DeadCodeScanner(tmp_path)
389389
result = scanner.scan()
390390
assert result.files_scanned == 2
391+
392+
class TestScanFormat:
393+
"""Tests for the new --format scan option."""
394+
395+
def test_format_compact(self, runner, sample_project):
396+
result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=compact"])
397+
assert result.exit_code == 0
398+
399+
def test_format_github(self, runner, sample_project):
400+
result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=github"])
401+
assert result.exit_code == 0
402+
403+
def test_format_compact_with_findings(self, runner, sample_project):
404+
result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=compact"])
405+
assert result.exit_code == 0
406+
assert "unusedHelper" in result.output or "No dead code" in result.output
407+
408+
def test_format_github_with_findings(self, runner, sample_project):
409+
result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=github"])
410+
assert result.exit_code == 0
411+
assert "::" in result.output or "No dead code" in result.output
412+
413+
def test_format_json_legacy_alias(self, runner, sample_project):
414+
result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--json-output"])
415+
assert result.exit_code == 0
416+
data = json.loads(result.output, strict=False)
417+
assert "findings" in data
418+
419+
def test_format_json_explicit(self, runner, sample_project):
420+
result = runner.invoke(cli, ["-p", str(sample_project), "scan", "--format=json"])
421+
assert result.exit_code == 0
422+
data = json.loads(result.output, strict=False)
423+
assert "findings" in data
424+
425+
def test_format_compact_empty(self, runner, tmp_path):
426+
result = runner.invoke(cli, ["-p", str(tmp_path), "scan", "--format=compact"])
427+
assert result.exit_code == 0
428+
assert "OK \u2014 0 findings" in result.output
429+
430+
def test_format_github_empty(self, runner, tmp_path):
431+
result = runner.invoke(cli, ["-p", str(tmp_path), "scan", "--format=github"])
432+
assert result.exit_code == 0
433+
assert "deadcode: 0 findings" in result.output
434+
435+
def test_format_pretty_default(self, runner, sample_project):
436+
result = runner.invoke(cli, ["-p", str(sample_project), "scan"])
437+
assert result.exit_code == 0
438+
assert "DeadCode Scan" in result.output

0 commit comments

Comments
 (0)