1212from rich .table import Table
1313
1414from . import __version__
15+ from .config import DeadCodeConfig
1516from .scanner import DeadCodeScanner , ScanResult , Finding
1617
1718console = Console ()
1819err_console = Console (stderr = True )
1920
21+ ALL_CATEGORIES = ["unused_export" , "dead_route" , "orphaned_css" , "unreferenced_component" ]
22+
2023
2124@click .group ()
2225@click .option ("--project" , "-p" , default = "." , help = "Project directory to scan" )
@@ -32,19 +35,43 @@ def cli(ctx: click.Context, project: str, ignore: tuple[str, ...]) -> None:
3235 ctx .ensure_object (dict )
3336 ctx .obj ["project" ] = project
3437 ctx .obj ["ignore" ] = list (ignore ) if ignore else None
38+ # Load .deadcode.yml config
39+ ctx .obj ["config" ] = DeadCodeConfig .load (project )
40+
41+
42+ def _merge_config_ignore (ctx : click .Context ) -> list [str ] | None :
43+ """Merge CLI --ignore flags with .deadcode.yml ignore patterns."""
44+ cli_ignore = ctx .obj .get ("ignore" )
45+ config = ctx .obj .get ("config" )
46+ config_ignore = config .ignore if config else []
47+
48+ if cli_ignore and config_ignore :
49+ return config_ignore + cli_ignore
50+ if cli_ignore :
51+ return cli_ignore
52+ if config_ignore :
53+ return config_ignore
54+ return None
55+
56+
57+ def _get_fail_threshold (ctx : click .Context ) -> int :
58+ """Get fail threshold from config."""
59+ config = ctx .obj .get ("config" )
60+ return config .fail_threshold if config else - 1
3561
3662
3763# ── scan ──────────────────────────────────────────────────────────────
3864
3965
4066@cli .command ()
4167@click .option ("--json-output" , "-j" , is_flag = True , help = "Output as JSON" )
42- @click .option ("--category" , "-c" , type = click .Choice (["unused_export" , "dead_route" , "orphaned_css" , "unreferenced_component" ]), default = None , help = "Filter by category" )
68+ @click .option ("--category" , "-c" , type = click .Choice (ALL_CATEGORIES ), default = None , help = "Filter by category" )
69+ @click .option ("--fail" , "fail_threshold" , type = int , default = None , help = "Exit code 1 if findings >= threshold (overrides .deadcode.yml)" )
4370@click .pass_context
44- def scan (ctx : click .Context , json_output : bool , category : str | None ) -> None :
71+ def scan (ctx : click .Context , json_output : bool , category : str | None , fail_threshold : int | None ) -> None :
4572 """Scan project for dead code."""
4673 project = ctx .obj ["project" ]
47- ignore = ctx . obj . get ( "ignore" )
74+ ignore = _merge_config_ignore ( ctx )
4875
4976 if not Path (project ).exists ():
5077 err_console .print (f"[red]Project directory '{ project } ' not found.[/red]" )
@@ -58,6 +85,11 @@ def scan(ctx: click.Context, json_output: bool, category: str | None) -> None:
5885 if category :
5986 findings = [f for f in findings if f .category == category ]
6087
88+ # Also respect config-level category filter if no CLI override
89+ config = ctx .obj .get ("config" )
90+ if not category and config and config .categories :
91+ findings = [f for f in findings if f .category in config .categories ]
92+
6193 if json_output :
6294 output = {
6395 "files_scanned" : result .files_scanned ,
@@ -69,58 +101,63 @@ def scan(ctx: click.Context, json_output: bool, category: str | None) -> None:
69101 "errors" : result .errors ,
70102 }
71103 console .print (json .dumps (output , indent = 2 , default = str ))
72- return
73-
74- # Summary
75- console .print (f"\n [bold]DeadCode Scan[/bold] — { result .files_scanned } files scanned\n " )
76-
77- if not findings :
78- console .print ("[green]✓ No dead code found![/green]" )
79- return
80-
81- # Group by category
82- by_category : dict [str , list [Finding ]] = {}
83- for f in findings :
84- by_category .setdefault (f .category , []).append (f )
104+ else :
105+ # Summary
106+ console .print (f"\n [bold]DeadCode Scan[/bold] — { result .files_scanned } files scanned\n " )
85107
86- category_labels = {
87- "unused_export" : "Unused Exports" ,
88- "dead_route" : "Dead Routes" ,
89- "orphaned_css" : "Orphaned CSS" ,
90- "unreferenced_component" : "Unreferenced Components" ,
91- }
92-
93- for cat , cat_findings in by_category .items ():
94- label = category_labels .get (cat , cat )
95- console .print (f"\n [bold yellow]{ label } [/bold yellow] ({ len (cat_findings )} )" )
96-
97- table = Table (show_header = True )
98- table .add_column ("File" , style = "cyan" )
99- table .add_column ("Line" , style = "magenta" , justify = "right" )
100- table .add_column ("Name" , style = "green" )
101- table .add_column ("Detail" )
102-
103- for f in cat_findings [:50 ]: # Limit display
104- table .add_row (f .file , str (f .line ), f .name , f .detail [:60 ])
105-
106- console .print (table )
107- if len (cat_findings ) > 50 :
108- console .print (f" [dim]... and { len (cat_findings ) - 50 } more[/dim]" )
109-
110- # Total
111- removable = sum (1 for f in findings if f .removable )
112- console .print (f"\n [bold]Total:[/bold] { len (findings )} findings ({ removable } removable)" )
113-
114- if result .errors :
115- console .print (f"\n [yellow]{ len (result .errors )} scan errors (use --json-output to see)[/yellow]" )
108+ if not findings :
109+ console .print ("[green]✓ No dead code found![/green]" )
110+ else :
111+ # Group by category
112+ by_category : dict [str , list [Finding ]] = {}
113+ for f in findings :
114+ by_category .setdefault (f .category , []).append (f )
115+
116+ category_labels = {
117+ "unused_export" : "Unused Exports" ,
118+ "dead_route" : "Dead Routes" ,
119+ "orphaned_css" : "Orphaned CSS" ,
120+ "unreferenced_component" : "Unreferenced Components" ,
121+ }
122+
123+ for cat , cat_findings in by_category .items ():
124+ label = category_labels .get (cat , cat )
125+ console .print (f"\n [bold yellow]{ label } [/bold yellow] ({ len (cat_findings )} )" )
126+
127+ table = Table (show_header = True )
128+ table .add_column ("File" , style = "cyan" )
129+ table .add_column ("Line" , style = "magenta" , justify = "right" )
130+ table .add_column ("Name" , style = "green" )
131+ table .add_column ("Detail" )
132+
133+ for f in cat_findings [:50 ]: # Limit display
134+ table .add_row (f .file , str (f .line ), f .name , f .detail [:60 ])
135+
136+ console .print (table )
137+ if len (cat_findings ) > 50 :
138+ console .print (f" [dim]... and { len (cat_findings ) - 50 } more[/dim]" )
139+
140+ # Total
141+ removable = sum (1 for f in findings if f .removable )
142+ console .print (f"\n [bold]Total:[/bold] { len (findings )} findings ({ removable } removable)" )
143+
144+ if result .errors :
145+ console .print (f"\n [yellow]{ len (result .errors )} scan errors (use --json-output to see)[/yellow]" )
146+
147+ # CI fail threshold
148+ effective_threshold = fail_threshold if fail_threshold is not None else _get_fail_threshold (ctx )
149+ if effective_threshold >= 0 and len (findings ) >= effective_threshold :
150+ if not json_output :
151+ console .print (f"\n [red]FAIL: { len (findings )} findings >= threshold { effective_threshold } [/red]" )
152+ sys .exit (1 )
116153
117154
118155# ── remove ────────────────────────────────────────────────────────────
119156
120157
121158@cli .command ()
122159@click .option ("--dry-run" , is_flag = True , help = "Preview what would be removed without making changes" )
123- @click .option ("--category" , "-c" , type = click .Choice ([ "unused_export" , "dead_route" , "orphaned_css" , "unreferenced_component" ] ), default = None , help = "Only remove findings in this category" )
160+ @click .option ("--category" , "-c" , type = click .Choice (ALL_CATEGORIES ), default = None , help = "Only remove findings in this category" )
124161@click .pass_context
125162def remove (ctx : click .Context , dry_run : bool , category : str | None ) -> None :
126163 """Remove dead code (with --dry-run for preview).
@@ -129,7 +166,7 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
129166 commit your code before running without it.
130167 """
131168 project = ctx .obj ["project" ]
132- ignore = ctx . obj . get ( "ignore" )
169+ ignore = _merge_config_ignore ( ctx )
133170
134171 if not dry_run :
135172 console .print ("[red]WARNING: This will modify files. Use --dry-run first![/red]" )
@@ -144,6 +181,11 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
144181 if category :
145182 findings = [f for f in findings if f .category == category ]
146183
184+ # Also respect config-level category filter if no CLI override
185+ config = ctx .obj .get ("config" )
186+ if not category and config and config .categories :
187+ findings = [f for f in findings if f .category in config .categories ]
188+
147189 # Only remove removable findings
148190 removable = [f for f in findings if f .removable ]
149191
@@ -198,7 +240,8 @@ def remove(ctx: click.Context, dry_run: bool, category: str | None) -> None:
198240def stats (ctx : click .Context ) -> None :
199241 """Show quick stats about the project's dead code."""
200242 project = ctx .obj ["project" ]
201- scanner = DeadCodeScanner (project )
243+ ignore = _merge_config_ignore (ctx )
244+ scanner = DeadCodeScanner (project , ignore_patterns = ignore )
202245 result = scanner .scan ()
203246
204247 console .print (f"Files scanned: [bold]{ result .files_scanned } [/bold]" )
0 commit comments