ghostbuster-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ghostbuster/__init__.py +3 -0
- ghostbuster/__main__.py +5 -0
- ghostbuster/cli/__init__.py +1 -0
- ghostbuster/cli/app.py +76 -0
- ghostbuster/cli/bust.py +116 -0
- ghostbuster/cli/display.py +276 -0
- ghostbuster/cli/scan.py +141 -0
- ghostbuster/config.py +116 -0
- ghostbuster/core/__init__.py +1 -0
- ghostbuster/core/dead_imports.py +273 -0
- ghostbuster/core/models.py +140 -0
- ghostbuster/core/orphan_files.py +298 -0
- ghostbuster/core/phantom_env.py +196 -0
- ghostbuster/core/scanner.py +112 -0
- ghostbuster/core/scoring.py +83 -0
- ghostbuster/core/zombie_code.py +345 -0
- ghostbuster/fixers/__init__.py +1 -0
- ghostbuster/fixers/import_fixer.py +133 -0
- ghostbuster_cli-0.1.0.dist-info/METADATA +239 -0
- ghostbuster_cli-0.1.0.dist-info/RECORD +23 -0
- ghostbuster_cli-0.1.0.dist-info/WHEEL +4 -0
- ghostbuster_cli-0.1.0.dist-info/entry_points.txt +2 -0
- ghostbuster_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
ghostbuster/__init__.py
ADDED
ghostbuster/__main__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI sub-package for ghostbuster terminal interface."""
|
ghostbuster/cli/app.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Main Typer application — entry point for the ghostbuster CLI.
|
|
2
|
+
|
|
3
|
+
This module ties together all sub-commands and global options.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
import ghostbuster
|
|
11
|
+
from ghostbuster.cli.bust import bust
|
|
12
|
+
from ghostbuster.cli.scan import scan
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(
|
|
15
|
+
name="ghostbuster",
|
|
16
|
+
help="Find and bust the ghosts haunting your codebase.",
|
|
17
|
+
no_args_is_help=False,
|
|
18
|
+
add_completion=True,
|
|
19
|
+
rich_markup_mode="rich",
|
|
20
|
+
pretty_exceptions_enable=True,
|
|
21
|
+
pretty_exceptions_show_locals=False,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _version_callback(value: bool) -> None:
|
|
26
|
+
"""Print version and exit."""
|
|
27
|
+
if value:
|
|
28
|
+
typer.echo(f"ghostbuster {ghostbuster.__version__}")
|
|
29
|
+
raise typer.Exit()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@app.callback(invoke_without_command=True)
|
|
33
|
+
def main(
|
|
34
|
+
ctx: typer.Context,
|
|
35
|
+
version: bool | None = typer.Option(
|
|
36
|
+
None,
|
|
37
|
+
"--version",
|
|
38
|
+
"-V",
|
|
39
|
+
help="Show version and exit.",
|
|
40
|
+
callback=_version_callback,
|
|
41
|
+
is_eager=True,
|
|
42
|
+
),
|
|
43
|
+
debug: bool = typer.Option(
|
|
44
|
+
False,
|
|
45
|
+
"--debug",
|
|
46
|
+
help="Show full stack traces on error.",
|
|
47
|
+
envvar="GHOSTBUSTER_DEBUG",
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Ghostbuster - Find and bust the ghosts haunting your codebase.
|
|
51
|
+
|
|
52
|
+
Run [bold cyan]ghostbuster scan[/bold cyan] to detect issues, or
|
|
53
|
+
[bold cyan]ghostbuster bust[/bold cyan] to auto-fix them.
|
|
54
|
+
|
|
55
|
+
[dim]Examples:[/dim]
|
|
56
|
+
ghostbuster scan # Scan current directory
|
|
57
|
+
ghostbuster scan ./my-project -v # Verbose scan
|
|
58
|
+
ghostbuster scan --format json # JSON output for CI
|
|
59
|
+
ghostbuster bust --confirm # Auto-fix issues
|
|
60
|
+
"""
|
|
61
|
+
# Store debug flag in context for sub-commands
|
|
62
|
+
ctx.ensure_object(dict)
|
|
63
|
+
ctx.obj["debug"] = debug
|
|
64
|
+
|
|
65
|
+
# If no sub-command is given, run scan with defaults
|
|
66
|
+
if ctx.invoked_subcommand is None:
|
|
67
|
+
ctx.invoke(scan)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# Register sub-commands
|
|
71
|
+
app.command(name="scan")(scan)
|
|
72
|
+
app.command(name="bust")(bust)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
app()
|
ghostbuster/cli/bust.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Bust command — auto-fix ghost findings.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
ghostbuster bust [PATH] [OPTIONS]
|
|
5
|
+
ghostbuster bust . # dry-run by default (safe)
|
|
6
|
+
ghostbuster bust . --confirm # actually apply fixes
|
|
7
|
+
ghostbuster bust --category dead-import --confirm
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from ghostbuster.cli.display import (
|
|
17
|
+
console,
|
|
18
|
+
print_banner,
|
|
19
|
+
print_bust_applied,
|
|
20
|
+
print_bust_preview,
|
|
21
|
+
print_error,
|
|
22
|
+
)
|
|
23
|
+
from ghostbuster.core.models import GhostCategory
|
|
24
|
+
from ghostbuster.core.scanner import create_default_orchestrator
|
|
25
|
+
from ghostbuster.fixers.import_fixer import ImportFixer
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bust(
|
|
29
|
+
path: Path = typer.Argument(
|
|
30
|
+
Path("."),
|
|
31
|
+
help="Path to the project directory to fix.",
|
|
32
|
+
exists=True,
|
|
33
|
+
file_okay=False,
|
|
34
|
+
resolve_path=True,
|
|
35
|
+
),
|
|
36
|
+
confirm: bool = typer.Option(
|
|
37
|
+
False,
|
|
38
|
+
"--confirm",
|
|
39
|
+
help="Actually apply fixes. Without this flag, only shows what would be changed (dry-run).",
|
|
40
|
+
),
|
|
41
|
+
category: list[str] | None = typer.Option(
|
|
42
|
+
None,
|
|
43
|
+
"--category",
|
|
44
|
+
"-c",
|
|
45
|
+
help="Only fix specific categories.",
|
|
46
|
+
),
|
|
47
|
+
no_color: bool = typer.Option(
|
|
48
|
+
False,
|
|
49
|
+
"--no-color",
|
|
50
|
+
help="Disable colored output.",
|
|
51
|
+
),
|
|
52
|
+
) -> None:
|
|
53
|
+
"""Bust the ghosts - auto-fix findings in your codebase.
|
|
54
|
+
|
|
55
|
+
By default, runs in dry-run mode (shows what would be fixed).
|
|
56
|
+
Use --confirm to actually apply the fixes.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
ghostbuster bust # preview changes (safe)
|
|
60
|
+
ghostbuster bust --confirm # apply fixes
|
|
61
|
+
ghostbuster bust -c dead-import --confirm
|
|
62
|
+
"""
|
|
63
|
+
if no_color:
|
|
64
|
+
console.no_color = True
|
|
65
|
+
|
|
66
|
+
# Validate categories
|
|
67
|
+
valid_categories = {c.value for c in GhostCategory}
|
|
68
|
+
if category:
|
|
69
|
+
for cat in category:
|
|
70
|
+
if cat not in valid_categories:
|
|
71
|
+
print_error(
|
|
72
|
+
f"Unknown category: '{cat}'",
|
|
73
|
+
f"Valid categories: {', '.join(sorted(valid_categories))}",
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
|
|
77
|
+
print_banner()
|
|
78
|
+
|
|
79
|
+
if confirm:
|
|
80
|
+
console.print(" [bold yellow]LIVE MODE[/bold yellow] - changes will be applied!\n")
|
|
81
|
+
else:
|
|
82
|
+
console.print(" [bold cyan]DRY-RUN MODE[/bold cyan] - no changes will be made.\n")
|
|
83
|
+
|
|
84
|
+
# First, scan to find ghosts
|
|
85
|
+
try:
|
|
86
|
+
orchestrator = create_default_orchestrator()
|
|
87
|
+
|
|
88
|
+
with console.status("[cyan]Scanning for fixable ghosts...[/cyan]", spinner="dots"):
|
|
89
|
+
result = orchestrator.run(path, categories=category)
|
|
90
|
+
|
|
91
|
+
except Exception as exc:
|
|
92
|
+
print_error(f"Scan failed: {exc}")
|
|
93
|
+
raise typer.Exit(1) from exc
|
|
94
|
+
|
|
95
|
+
# Filter to only fixable ghosts
|
|
96
|
+
fixable_ghosts = [g for g in result.ghosts if g.fixable]
|
|
97
|
+
|
|
98
|
+
if not fixable_ghosts:
|
|
99
|
+
console.print(" [green]No fixable ghosts found. Your codebase is clean![/green]\n")
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
console.print(f" Found [bold]{len(fixable_ghosts)}[/bold] fixable ghosts.\n")
|
|
103
|
+
|
|
104
|
+
# Apply fixes per category
|
|
105
|
+
fixer = ImportFixer()
|
|
106
|
+
|
|
107
|
+
if confirm:
|
|
108
|
+
changes = fixer.fix(fixable_ghosts, path)
|
|
109
|
+
print_bust_applied(changes)
|
|
110
|
+
console.print(
|
|
111
|
+
"\n [bold green]Ghostbusting complete![/bold green] "
|
|
112
|
+
"Don't forget to review the changes and run your tests.\n"
|
|
113
|
+
)
|
|
114
|
+
else:
|
|
115
|
+
changes = fixer.preview(fixable_ghosts, path)
|
|
116
|
+
print_bust_preview(changes)
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Rich console display helpers for ghostbuster output.
|
|
2
|
+
|
|
3
|
+
All terminal formatting lives here — the core logic never imports Rich.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.panel import Panel
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from ghostbuster.core.models import ScanResult
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
error_console = Console(stderr=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def print_banner() -> None:
|
|
24
|
+
"""Print the ghostbuster ASCII banner."""
|
|
25
|
+
banner = Text()
|
|
26
|
+
banner.append("G H O S T B U S T E R", style="bold cyan")
|
|
27
|
+
banner.append("\n")
|
|
28
|
+
banner.append("Find and bust the ghosts haunting your codebase", style="dim")
|
|
29
|
+
|
|
30
|
+
console.print(
|
|
31
|
+
Panel(
|
|
32
|
+
banner,
|
|
33
|
+
border_style="cyan",
|
|
34
|
+
padding=(1, 2),
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
console.print()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def print_scanning_start(path: str, scanner_names: list[str]) -> None:
|
|
41
|
+
"""Print the scan start message."""
|
|
42
|
+
console.print(f" Scanning [bold cyan]{path}[/bold cyan]")
|
|
43
|
+
console.print(f" Scanners: [dim]{', '.join(scanner_names)}[/dim]")
|
|
44
|
+
console.print()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_scan_result(result: ScanResult, verbose: bool = False) -> None:
|
|
48
|
+
"""Print the full scan result with ghost table and score card."""
|
|
49
|
+
if not result.ghosts:
|
|
50
|
+
_print_clean_result(result)
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
_print_ghost_table(result, verbose)
|
|
54
|
+
console.print()
|
|
55
|
+
_print_category_summary(result)
|
|
56
|
+
console.print()
|
|
57
|
+
_print_score_card(result)
|
|
58
|
+
_print_footer(result)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def print_scan_result_json(result: ScanResult) -> None:
|
|
62
|
+
"""Print scan result as JSON (for scripting/CI)."""
|
|
63
|
+
console.print_json(json.dumps(result.to_dict(), indent=2))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def print_scan_result_markdown(result: ScanResult) -> None:
|
|
67
|
+
"""Print scan result as markdown (for pasting into issues/PRs)."""
|
|
68
|
+
if result.score:
|
|
69
|
+
console.print(f"# Ghost Score: {result.score.value}/100")
|
|
70
|
+
console.print(f"_{result.score.label}_")
|
|
71
|
+
console.print()
|
|
72
|
+
|
|
73
|
+
if not result.ghosts:
|
|
74
|
+
console.print("No ghosts found!")
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
console.print(f"**{result.ghost_count} ghosts found** ({result.fixable_count} auto-fixable)")
|
|
78
|
+
console.print()
|
|
79
|
+
|
|
80
|
+
by_category = result.ghosts_by_category()
|
|
81
|
+
for category, ghosts in by_category.items():
|
|
82
|
+
console.print(f"## {category.label} ({len(ghosts)})")
|
|
83
|
+
console.print()
|
|
84
|
+
for ghost in ghosts:
|
|
85
|
+
location = ""
|
|
86
|
+
if ghost.file_path:
|
|
87
|
+
location = f" - `{ghost.file_path.name}"
|
|
88
|
+
if ghost.line_number:
|
|
89
|
+
location += f":{ghost.line_number}"
|
|
90
|
+
location += "`"
|
|
91
|
+
console.print(f"- **{ghost.name}**: {ghost.message}{location}")
|
|
92
|
+
console.print()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def print_bust_preview(changes: list[str]) -> None:
|
|
96
|
+
"""Print a preview of changes that would be made in bust mode."""
|
|
97
|
+
if not changes:
|
|
98
|
+
console.print(" [green]Nothing to fix - your codebase is clean![/green]")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
console.print(f" [bold yellow]{len(changes)} fixes available (dry-run):[/bold yellow]")
|
|
102
|
+
console.print()
|
|
103
|
+
for change in changes:
|
|
104
|
+
console.print(f" [dim]{change}[/dim]")
|
|
105
|
+
console.print()
|
|
106
|
+
console.print(" [dim]Run with [bold]--confirm[/bold] to apply these fixes.[/dim]")
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def print_bust_applied(changes: list[str]) -> None:
|
|
110
|
+
"""Print the results of applied fixes."""
|
|
111
|
+
if not changes:
|
|
112
|
+
console.print(" [green]Nothing to fix![/green]")
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
console.print(f" [bold green]Applied {len(changes)} fixes:[/bold green]")
|
|
116
|
+
console.print()
|
|
117
|
+
for change in changes:
|
|
118
|
+
console.print(f" [green]{change}[/green]")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def print_error(message: str, hint: str = "") -> None:
|
|
122
|
+
"""Print a user-friendly error message."""
|
|
123
|
+
error_console.print(f" [bold red]Error:[/bold red] {message}")
|
|
124
|
+
if hint:
|
|
125
|
+
error_console.print(f" [dim]Hint: {hint}[/dim]")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _print_clean_result(result: ScanResult) -> None:
|
|
129
|
+
"""Print the all-clear message when no ghosts are found."""
|
|
130
|
+
msg = Text()
|
|
131
|
+
msg.append("No ghosts detected!\n", style="bold green")
|
|
132
|
+
msg.append("Your codebase is squeaky clean.", style="green")
|
|
133
|
+
|
|
134
|
+
console.print(
|
|
135
|
+
Panel(
|
|
136
|
+
msg,
|
|
137
|
+
border_style="green",
|
|
138
|
+
title="[bold green]Ghost Report[/bold green]",
|
|
139
|
+
padding=(1, 2),
|
|
140
|
+
)
|
|
141
|
+
)
|
|
142
|
+
if result.duration_ms > 0:
|
|
143
|
+
console.print(f" [dim]Completed in {result.duration_ms:.0f}ms[/dim]")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _print_ghost_table(result: ScanResult, verbose: bool) -> None:
|
|
147
|
+
"""Print the table of found ghosts."""
|
|
148
|
+
table = Table(
|
|
149
|
+
title="Ghosts Found",
|
|
150
|
+
title_style="bold red",
|
|
151
|
+
border_style="red",
|
|
152
|
+
show_lines=verbose,
|
|
153
|
+
padding=(0, 1),
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
table.add_column("#", style="dim", width=4, justify="right")
|
|
157
|
+
table.add_column("Category", style="cyan", width=14)
|
|
158
|
+
table.add_column("Severity", width=8, justify="center")
|
|
159
|
+
table.add_column("Name", style="bold", max_width=30)
|
|
160
|
+
table.add_column("Message", max_width=60)
|
|
161
|
+
if verbose:
|
|
162
|
+
table.add_column("Location", style="dim", max_width=30)
|
|
163
|
+
table.add_column("Fix", style="green dim", max_width=30)
|
|
164
|
+
|
|
165
|
+
for i, ghost in enumerate(result.ghosts, 1):
|
|
166
|
+
row = [
|
|
167
|
+
str(i),
|
|
168
|
+
ghost.category.label,
|
|
169
|
+
ghost.severity.value.upper(),
|
|
170
|
+
ghost.name,
|
|
171
|
+
ghost.message,
|
|
172
|
+
]
|
|
173
|
+
if verbose:
|
|
174
|
+
location = ""
|
|
175
|
+
if ghost.file_path:
|
|
176
|
+
location = ghost.file_path.name
|
|
177
|
+
if ghost.line_number:
|
|
178
|
+
location += f":{ghost.line_number}"
|
|
179
|
+
row.append(location)
|
|
180
|
+
row.append(ghost.suggestion if ghost.fixable else "-")
|
|
181
|
+
|
|
182
|
+
table.add_row(*row)
|
|
183
|
+
|
|
184
|
+
console.print(table)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _print_category_summary(result: ScanResult) -> None:
|
|
188
|
+
"""Print a summary breakdown by category."""
|
|
189
|
+
by_category = result.ghosts_by_category()
|
|
190
|
+
|
|
191
|
+
table = Table(
|
|
192
|
+
title="Breakdown by Category",
|
|
193
|
+
title_style="bold",
|
|
194
|
+
border_style="dim",
|
|
195
|
+
show_header=True,
|
|
196
|
+
padding=(0, 1),
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
table.add_column("Category", style="cyan")
|
|
200
|
+
table.add_column("Count", justify="right", style="bold")
|
|
201
|
+
table.add_column("Fixable", justify="right", style="green")
|
|
202
|
+
table.add_column("Description", style="dim")
|
|
203
|
+
|
|
204
|
+
for category in sorted(by_category.keys(), key=lambda c: len(by_category[c]), reverse=True):
|
|
205
|
+
ghosts = by_category[category]
|
|
206
|
+
fixable = sum(1 for g in ghosts if g.fixable)
|
|
207
|
+
table.add_row(
|
|
208
|
+
category.label,
|
|
209
|
+
str(len(ghosts)),
|
|
210
|
+
str(fixable) if fixable > 0 else "-",
|
|
211
|
+
category.description,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
console.print(table)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _print_score_card(result: ScanResult) -> None:
|
|
218
|
+
"""Print the Ghost Score card."""
|
|
219
|
+
if not result.score:
|
|
220
|
+
return
|
|
221
|
+
|
|
222
|
+
score = result.score
|
|
223
|
+
score_val = score.value
|
|
224
|
+
|
|
225
|
+
# Color based on score severity
|
|
226
|
+
if score_val == 0 or score_val <= 20:
|
|
227
|
+
color = "green"
|
|
228
|
+
bar_char = "#"
|
|
229
|
+
elif score_val <= 50:
|
|
230
|
+
color = "yellow"
|
|
231
|
+
bar_char = "#"
|
|
232
|
+
elif score_val <= 80:
|
|
233
|
+
color = "red"
|
|
234
|
+
bar_char = "#"
|
|
235
|
+
else:
|
|
236
|
+
color = "bold red"
|
|
237
|
+
bar_char = "#"
|
|
238
|
+
|
|
239
|
+
# Build the score bar (50 chars wide)
|
|
240
|
+
bar_width = 50
|
|
241
|
+
filled = int(score_val / 100 * bar_width)
|
|
242
|
+
empty = bar_width - filled
|
|
243
|
+
bar = f"[{color}]{bar_char * filled}[/{color}][dim]{'-' * empty}[/dim]"
|
|
244
|
+
|
|
245
|
+
content = f" [bold {color}]{score_val}[/bold {color}] / 100\n\n {bar}\n\n {score.label}"
|
|
246
|
+
|
|
247
|
+
console.print(
|
|
248
|
+
Panel(
|
|
249
|
+
content,
|
|
250
|
+
title=f"[bold {color}]Ghost Score[/bold {color}]",
|
|
251
|
+
border_style=color,
|
|
252
|
+
padding=(1, 2),
|
|
253
|
+
width=62,
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _print_footer(result: ScanResult) -> None:
|
|
259
|
+
"""Print the scan footer with stats."""
|
|
260
|
+
console.print()
|
|
261
|
+
parts = []
|
|
262
|
+
if result.ghost_count > 0:
|
|
263
|
+
parts.append(f"[bold]{result.ghost_count}[/bold] ghosts found")
|
|
264
|
+
if result.fixable_count > 0:
|
|
265
|
+
parts.append(f"[green]{result.fixable_count}[/green] auto-fixable")
|
|
266
|
+
if result.duration_ms > 0:
|
|
267
|
+
parts.append(f"Completed in {result.duration_ms:.0f}ms")
|
|
268
|
+
|
|
269
|
+
if parts:
|
|
270
|
+
console.print(" " + " | ".join(parts))
|
|
271
|
+
|
|
272
|
+
if result.fixable_count > 0:
|
|
273
|
+
console.print(
|
|
274
|
+
"\n [dim]Run [bold]ghostbuster bust --confirm[/bold] to auto-fix what we can.[/dim]"
|
|
275
|
+
)
|
|
276
|
+
console.print()
|
ghostbuster/cli/scan.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Scan command — the primary ghostbuster command.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
ghostbuster scan [PATH] [OPTIONS]
|
|
5
|
+
ghostbuster scan .
|
|
6
|
+
ghostbuster scan --format json
|
|
7
|
+
ghostbuster scan --category dead-import --verbose
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from enum import Enum
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from ghostbuster.cli.display import (
|
|
18
|
+
console,
|
|
19
|
+
print_banner,
|
|
20
|
+
print_error,
|
|
21
|
+
print_scan_result,
|
|
22
|
+
print_scan_result_json,
|
|
23
|
+
print_scan_result_markdown,
|
|
24
|
+
print_scanning_start,
|
|
25
|
+
)
|
|
26
|
+
from ghostbuster.core.models import GhostCategory
|
|
27
|
+
from ghostbuster.core.scanner import create_default_orchestrator
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OutputFormat(str, Enum):
|
|
31
|
+
"""Output format for scan results."""
|
|
32
|
+
|
|
33
|
+
rich = "rich"
|
|
34
|
+
json = "json"
|
|
35
|
+
markdown = "markdown"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def scan(
|
|
39
|
+
path: Path = typer.Argument(
|
|
40
|
+
Path("."),
|
|
41
|
+
help="Path to the project directory to scan.",
|
|
42
|
+
exists=True,
|
|
43
|
+
file_okay=False,
|
|
44
|
+
resolve_path=True,
|
|
45
|
+
),
|
|
46
|
+
format: OutputFormat = typer.Option(
|
|
47
|
+
OutputFormat.rich,
|
|
48
|
+
"--format",
|
|
49
|
+
"-f",
|
|
50
|
+
help="Output format: rich (colorful), json (machine-readable), markdown (for issues/PRs).",
|
|
51
|
+
),
|
|
52
|
+
category: list[str] | None = typer.Option(
|
|
53
|
+
None,
|
|
54
|
+
"--category",
|
|
55
|
+
"-c",
|
|
56
|
+
help="Only scan specific categories (dead-import, orphan-file, zombie-code, phantom-env).",
|
|
57
|
+
),
|
|
58
|
+
verbose: bool = typer.Option(
|
|
59
|
+
False,
|
|
60
|
+
"--verbose",
|
|
61
|
+
"-v",
|
|
62
|
+
help="Show detailed findings with locations and fix suggestions.",
|
|
63
|
+
),
|
|
64
|
+
no_color: bool = typer.Option(
|
|
65
|
+
False,
|
|
66
|
+
"--no-color",
|
|
67
|
+
help="Disable colored output.",
|
|
68
|
+
),
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Scan your codebase for ghosts.
|
|
71
|
+
|
|
72
|
+
Searches for dead imports, orphan files, zombie code, and phantom
|
|
73
|
+
environment variables in your project.
|
|
74
|
+
|
|
75
|
+
Examples:
|
|
76
|
+
ghostbuster scan
|
|
77
|
+
ghostbuster scan ./my-project --verbose
|
|
78
|
+
ghostbuster scan --format json --category dead-import
|
|
79
|
+
"""
|
|
80
|
+
if no_color:
|
|
81
|
+
console.no_color = True
|
|
82
|
+
|
|
83
|
+
# Validate categories if provided
|
|
84
|
+
valid_categories = {c.value for c in GhostCategory}
|
|
85
|
+
if category:
|
|
86
|
+
for cat in category:
|
|
87
|
+
if cat not in valid_categories:
|
|
88
|
+
print_error(
|
|
89
|
+
f"Unknown category: '{cat}'",
|
|
90
|
+
f"Valid categories: {', '.join(sorted(valid_categories))}",
|
|
91
|
+
)
|
|
92
|
+
raise typer.Exit(1)
|
|
93
|
+
|
|
94
|
+
# Show banner for rich format
|
|
95
|
+
if format == OutputFormat.rich:
|
|
96
|
+
print_banner()
|
|
97
|
+
print_scanning_start(str(path), _get_scanner_names(category))
|
|
98
|
+
|
|
99
|
+
# Run the scan
|
|
100
|
+
try:
|
|
101
|
+
orchestrator = create_default_orchestrator()
|
|
102
|
+
|
|
103
|
+
with (
|
|
104
|
+
console.status("[cyan]Hunting for ghosts...[/cyan]", spinner="dots")
|
|
105
|
+
if format == OutputFormat.rich
|
|
106
|
+
else _nullcontext()
|
|
107
|
+
):
|
|
108
|
+
result = orchestrator.run(path, categories=category)
|
|
109
|
+
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
print_error(f"Scan failed: {exc}", "Run with --debug flag for full traceback.")
|
|
112
|
+
raise typer.Exit(1) from exc
|
|
113
|
+
|
|
114
|
+
# Display results
|
|
115
|
+
if format == OutputFormat.json:
|
|
116
|
+
print_scan_result_json(result)
|
|
117
|
+
elif format == OutputFormat.markdown:
|
|
118
|
+
print_scan_result_markdown(result)
|
|
119
|
+
else:
|
|
120
|
+
print_scan_result(result, verbose=verbose)
|
|
121
|
+
|
|
122
|
+
# Exit with non-zero code if ghosts were found (useful for CI)
|
|
123
|
+
if result.ghost_count > 0:
|
|
124
|
+
raise typer.Exit(1)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _get_scanner_names(categories: list[str] | None) -> list[str]:
|
|
128
|
+
"""Get human-readable scanner names."""
|
|
129
|
+
if categories:
|
|
130
|
+
return categories
|
|
131
|
+
return ["dead-import", "orphan-file", "zombie-code", "phantom-env"]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _nullcontext:
|
|
135
|
+
"""Simple null context manager for Python 3.10 compatibility."""
|
|
136
|
+
|
|
137
|
+
def __enter__(self) -> None:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
def __exit__(self, *args: object) -> None:
|
|
141
|
+
pass
|