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.
@@ -0,0 +1,3 @@
1
+ """Ghostbuster — Find and bust the ghosts haunting your codebase."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running ghostbuster as a module: python -m ghostbuster."""
2
+
3
+ from ghostbuster.cli.app import app
4
+
5
+ app()
@@ -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()
@@ -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()
@@ -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