22
33from __future__ import annotations
44
5- import click
65import json
76import sys
87from pathlib import Path
8+
9+ import click
910from rich .console import Console
1011from rich .table import Table
1112
12- try :
13- from revenueholdings_license import require_license
14- except ImportError :
15- require_license = None
16-
1713from . import __version__
1814from .config import DeadCodeConfig
1915from .scanner import DeadCodeScanner , Finding
2016
2117console = Console ()
2218err_console = Console (stderr = True )
2319
20+ FORMAT_HELP = "Output format: pretty (default), compact, github, or json"
2421ALL_CATEGORIES = ["unused_export" , "dead_route" , "orphaned_css" , "unreferenced_component" ]
22+ FORMAT_CHOICES = click .Choice (["pretty" , "compact" , "github" , "json" ])
2523
2624
2725@click .group ()
2826@click .option ("--project" , "-p" , default = "." , help = "Project directory to scan" )
2927@click .option ("--ignore" , "-i" , multiple = True , help = "Additional ignore patterns (gitignore-style)" )
28+ @click .option ("--include" , multiple = True , help = "Include only matching files (gitignore-style whitelist)" )
3029@click .version_option (__version__ , prog_name = "deadcode" )
3130@click .pass_context
32- def cli (ctx : click .Context , project : str , ignore : tuple [str , ...]) -> None :
31+ def cli (ctx : click .Context , project : str , ignore : tuple [str , ...], include : tuple [ str , ...] ) -> None :
3332 """DeadCode — Find and remove dead code in TS/React/Next.js projects.
3433
3534 Scans for unused exports, dead routes, orphaned CSS classes,
@@ -38,6 +37,7 @@ def cli(ctx: click.Context, project: str, ignore: tuple[str, ...]) -> None:
3837 ctx .ensure_object (dict )
3938 ctx .obj ["project" ] = project
4039 ctx .obj ["ignore" ] = list (ignore ) if ignore else None
40+ ctx .obj ["include" ] = list (include ) if include else None
4141 # Load .deadcode.yml config
4242 ctx .obj ["config" ] = DeadCodeConfig .load (project )
4343
@@ -67,23 +67,29 @@ def _get_fail_threshold(ctx: click.Context) -> int:
6767
6868
6969@cli .command ()
70- @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 )
7172@click .option ("--category" , "-c" , type = click .Choice (ALL_CATEGORIES ), default = None , help = "Filter by category" )
7273@click .option ("--fail" , "fail_threshold" , type = int , default = None ,
7374 help = "Exit code 1 if findings >= threshold (overrides .deadcode.yml)" )
7475@click .pass_context
75- def scan (ctx : click .Context , json_output : bool , category : str | None , fail_threshold : int | None ) -> None :
76+ def scan (
77+ ctx : click .Context ,
78+ json_output : bool ,
79+ format : str | None ,
80+ category : str | None ,
81+ fail_threshold : int | None ,
82+ ) -> None :
7683 """Scan project for dead code."""
77- if require_license :
78- require_license ("deadcode" )
7984 project = ctx .obj ["project" ]
8085 ignore = _merge_config_ignore (ctx )
8186
8287 if not Path (project ).exists ():
8388 err_console .print (f"[red]Project directory '{ project } ' not found.[/red]" )
8489 sys .exit (1 )
8590
86- scanner = DeadCodeScanner (project , ignore_patterns = ignore )
91+ include_patterns = ctx .obj .get ("include" )
92+ scanner = DeadCodeScanner (project , ignore_patterns = ignore , include_patterns = include_patterns )
8793 result = scanner .scan ()
8894
8995 # Filter by category
@@ -96,7 +102,10 @@ def scan(ctx: click.Context, json_output: bool, category: str | None, fail_thres
96102 if not category and config and config .categories :
97103 findings = [f for f in findings if f .category in config .categories ]
98104
99- if json_output :
105+ # Determine effective format (legacy --json-output maps to json)
106+ effective_format = "json" if json_output else (format or "pretty" )
107+
108+ if effective_format == "json" :
100109 output = {
101110 "files_scanned" : result .files_scanned ,
102111 "findings" : [
@@ -107,6 +116,26 @@ def scan(ctx: click.Context, json_output: bool, category: str | None, fail_thres
107116 "errors" : result .errors ,
108117 }
109118 console .print (json .dumps (output , indent = 2 , default = str ))
119+ elif effective_format == "compact" :
120+ if not findings :
121+ console .print ("OK — 0 findings" )
122+ else :
123+ for f in findings :
124+ console .print (f"{ f .file } :{ f .line } \u2014 { f .category } : { f .name } " )
125+ console .print (f"\n { len (findings )} findings" )
126+ elif effective_format == "github" :
127+ # GitHub Actions annotation syntax
128+ # ::warning file={name},line={line},endLine={line}::{message}
129+ if not findings :
130+ console .print ("deadcode: 0 findings" )
131+ else :
132+ for f in findings :
133+ level = "error" if f .removable else "warning"
134+ msg = f"{ f .category } : { f .name } "
135+ if f .detail :
136+ msg += f" ({ f .detail [:120 ]} )"
137+ console .print (f"::{ level } file={ f .file } ,line={ f .line } ::{ msg } " )
138+ console .print (f"\n ::notice::deadcode: { len (findings )} findings" )
110139 else :
111140 # Summary
112141 console .print (f"\n [bold]DeadCode Scan[/bold] — { result .files_scanned } files scanned\n " )
@@ -153,7 +182,7 @@ def scan(ctx: click.Context, json_output: bool, category: str | None, fail_thres
153182 # CI fail threshold
154183 effective_threshold = fail_threshold if fail_threshold is not None else _get_fail_threshold (ctx )
155184 if effective_threshold >= 0 and len (findings ) >= effective_threshold :
156- if not json_output :
185+ if effective_format not in ( "json" , "github" ) :
157186 console .print (f"\n [red]FAIL: { len (findings )} findings >= threshold { effective_threshold } [/red]" )
158187 sys .exit (1 )
159188
@@ -175,13 +204,18 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
175204 project = ctx .obj ["project" ]
176205 ignore = _merge_config_ignore (ctx )
177206
207+ if not Path (project ).exists ():
208+ err_console .print (f"[red]Project directory '{ project } ' not found.[/red]" )
209+ sys .exit (1 )
210+
178211 if not dry_run :
179212 console .print ("[red]WARNING: This will modify files. Use --dry-run first![/red]" )
180213 console .print ("[dim]Press Ctrl+C to abort. Running in 3 seconds...[/dim]" )
181214 import time
182215 time .sleep (3 )
183216
184- scanner = DeadCodeScanner (project , ignore_patterns = ignore )
217+ include_patterns = ctx .obj .get ("include" )
218+ scanner = DeadCodeScanner (project , ignore_patterns = ignore , include_patterns = include_patterns )
185219 result = scanner .scan ()
186220
187221 findings = result .findings
@@ -246,11 +280,10 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
246280@click .pass_context
247281def stats (ctx : click .Context ) -> None :
248282 """Show quick stats about the project's dead code."""
249- if require_license :
250- require_license ("deadcode" )
251283 project = ctx .obj ["project" ]
252284 ignore = _merge_config_ignore (ctx )
253- scanner = DeadCodeScanner (project , ignore_patterns = ignore )
285+ include_patterns = ctx .obj .get ("include" )
286+ scanner = DeadCodeScanner (project , ignore_patterns = ignore , include_patterns = include_patterns )
254287 result = scanner .scan ()
255288
256289 console .print (f"Files scanned: [bold]{ result .files_scanned } [/bold]" )
0 commit comments