ghostbuster-cli 0.1.0__tar.gz → 0.2.0__tar.gz
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_cli-0.1.0 → ghostbuster_cli-0.2.0}/.github/workflows/ci.yml +8 -30
- ghostbuster_cli-0.2.0/.github/workflows/publish.yml +35 -0
- ghostbuster_cli-0.2.0/.pre-commit-hooks.yaml +8 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/CHANGELOG.md +10 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/PKG-INFO +22 -4
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/README.md +15 -3
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/pyproject.toml +11 -3
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/__init__.py +1 -1
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/bust.py +19 -7
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/config.py +12 -10
- ghostbuster_cli-0.2.0/src/ghostbuster/fixers/__init__.py +29 -0
- ghostbuster_cli-0.2.0/src/ghostbuster/fixers/env_fixer.py +112 -0
- ghostbuster_cli-0.2.0/src/ghostbuster/fixers/gitignore_fixer.py +101 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_cli.py +1 -1
- ghostbuster_cli-0.2.0/tests/test_fixers.py +171 -0
- ghostbuster_cli-0.1.0/src/ghostbuster/fixers/__init__.py +0 -1
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/.gitignore +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/CONTRIBUTING.md +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/LICENSE +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/assets/npm_package_icon.png +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/assets/readme_header.png +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/assets/terminal_icon.png +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/__main__.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/__init__.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/app.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/display.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/cli/scan.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/__init__.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/dead_imports.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/models.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/orphan_files.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/phantom_env.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/scanner.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/scoring.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/core/zombie_code.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/src/ghostbuster/fixers/import_fixer.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/__init__.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/conftest.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_dead_imports.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_orphan_files.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_phantom_env.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_scoring.py +0 -0
- {ghostbuster_cli-0.1.0 → ghostbuster_cli-0.2.0}/tests/test_zombie_code.py +0 -0
|
@@ -11,7 +11,7 @@ permissions:
|
|
|
11
11
|
|
|
12
12
|
jobs:
|
|
13
13
|
lint:
|
|
14
|
-
name: Lint
|
|
14
|
+
name: Lint & Type Check
|
|
15
15
|
runs-on: ubuntu-latest
|
|
16
16
|
steps:
|
|
17
17
|
- uses: actions/checkout@v4
|
|
@@ -24,6 +24,7 @@ jobs:
|
|
|
24
24
|
- name: Install dependencies
|
|
25
25
|
run: |
|
|
26
26
|
python -m pip install --upgrade pip
|
|
27
|
+
pip install -e .
|
|
27
28
|
pip install ruff mypy
|
|
28
29
|
|
|
29
30
|
- name: Ruff check
|
|
@@ -33,12 +34,10 @@ jobs:
|
|
|
33
34
|
run: ruff format --check src/ tests/
|
|
34
35
|
|
|
35
36
|
- name: Mypy type check
|
|
36
|
-
run:
|
|
37
|
-
pip install -e ".[dev]" 2>/dev/null || pip install -e .
|
|
38
|
-
mypy src/
|
|
37
|
+
run: mypy src/
|
|
39
38
|
|
|
40
39
|
test:
|
|
41
|
-
name: Test (Python ${{ matrix.python-version }})
|
|
40
|
+
name: Test (Python ${{ matrix.python-version }} on ${{ matrix.os }})
|
|
42
41
|
runs-on: ${{ matrix.os }}
|
|
43
42
|
strategy:
|
|
44
43
|
fail-fast: false
|
|
@@ -69,7 +68,7 @@ jobs:
|
|
|
69
68
|
run: pytest tests/ -v --tb=short
|
|
70
69
|
|
|
71
70
|
build:
|
|
72
|
-
name: Build
|
|
71
|
+
name: Build Check
|
|
73
72
|
runs-on: ubuntu-latest
|
|
74
73
|
steps:
|
|
75
74
|
- uses: actions/checkout@v4
|
|
@@ -80,33 +79,12 @@ jobs:
|
|
|
80
79
|
python-version: "3.12"
|
|
81
80
|
|
|
82
81
|
- name: Install build tools
|
|
83
|
-
run:
|
|
82
|
+
run: |
|
|
83
|
+
python -m pip install --upgrade pip
|
|
84
|
+
pip install build twine
|
|
84
85
|
|
|
85
86
|
- name: Build package
|
|
86
87
|
run: python -m build
|
|
87
88
|
|
|
88
89
|
- name: Check package
|
|
89
90
|
run: twine check dist/*
|
|
90
|
-
|
|
91
|
-
- name: Upload artifacts
|
|
92
|
-
uses: actions/upload-artifact@v4
|
|
93
|
-
with:
|
|
94
|
-
name: dist
|
|
95
|
-
path: dist/
|
|
96
|
-
|
|
97
|
-
# Uncomment when ready to publish to PyPI
|
|
98
|
-
# publish:
|
|
99
|
-
# name: Publish to PyPI
|
|
100
|
-
# needs: [lint, test, build]
|
|
101
|
-
# runs-on: ubuntu-latest
|
|
102
|
-
# if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
|
103
|
-
# environment: pypi
|
|
104
|
-
# permissions:
|
|
105
|
-
# id-token: write # Required for trusted publishing
|
|
106
|
-
# steps:
|
|
107
|
-
# - uses: actions/download-artifact@v4
|
|
108
|
-
# with:
|
|
109
|
-
# name: dist
|
|
110
|
-
# path: dist/
|
|
111
|
-
# - name: Publish to PyPI
|
|
112
|
-
# uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
push:
|
|
7
|
+
tags:
|
|
8
|
+
- "v*"
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
pypi-publish:
|
|
12
|
+
name: Upload package to PyPI
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
environment:
|
|
15
|
+
name: pypi
|
|
16
|
+
url: https://pypi.org/p/ghostbuster-cli
|
|
17
|
+
permissions:
|
|
18
|
+
id-token: write # Mandatory for Trusted Publishing (OIDC)
|
|
19
|
+
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v4
|
|
22
|
+
|
|
23
|
+
- name: Set up Python
|
|
24
|
+
uses: actions/setup-python@v5
|
|
25
|
+
with:
|
|
26
|
+
python-version: "3.12"
|
|
27
|
+
|
|
28
|
+
- name: Install build
|
|
29
|
+
run: python -m pip install --upgrade pip build
|
|
30
|
+
|
|
31
|
+
- name: Build package
|
|
32
|
+
run: python -m build
|
|
33
|
+
|
|
34
|
+
- name: Publish package distributions to PyPI
|
|
35
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-08-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Pre-commit Hook Integration**: added `.pre-commit-hooks.yaml` for automated pre-commit scanning (`ghostbuster-scan`).
|
|
15
|
+
- **Auto-Fix for Orphan Files**: `ghostbuster bust --confirm` now automatically appends missing folders and files to `.gitignore`.
|
|
16
|
+
- **Auto-Fix for Phantom Env Vars**: `ghostbuster bust --confirm` now automatically appends missing environment variable stubs to `.env.example`.
|
|
17
|
+
- **Poetry Dependencies Support**: Dead Import Scanner now automatically parses `[tool.poetry.dependencies]` and `[tool.poetry.group.*.dependencies]`.
|
|
18
|
+
- Comprehensive test suite for all auto-fixers (`GitignoreFixer`, `EnvFixer`, `ImportFixer`).
|
|
19
|
+
|
|
10
20
|
## [0.1.0] - 2026-08-29
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostbuster-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Find and bust the ghosts haunting your codebase - unused deps, dead code, orphan files, and phantom env vars.
|
|
5
5
|
Project-URL: Homepage, https://github.com/adewanggar/ghostbuster-cli
|
|
6
6
|
Project-URL: Documentation, https://github.com/adewanggar/ghostbuster-cli#readme
|
|
@@ -28,6 +28,12 @@ Requires-Python: >=3.10
|
|
|
28
28
|
Requires-Dist: rich>=13.0.0
|
|
29
29
|
Requires-Dist: tomli>=2.0.0; python_version < '3.11'
|
|
30
30
|
Requires-Dist: typer>=0.9.0
|
|
31
|
+
Provides-Extra: dev
|
|
32
|
+
Requires-Dist: build>=1.0.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: mypy>=1.10.0; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
35
|
+
Requires-Dist: ruff>=0.4.0; extra == 'dev'
|
|
36
|
+
Requires-Dist: twine>=5.0.0; extra == 'dev'
|
|
31
37
|
Description-Content-Type: text/markdown
|
|
32
38
|
|
|
33
39
|
<div align="center">
|
|
@@ -104,9 +110,9 @@ ghostbuster bust --confirm
|
|
|
104
110
|
```
|
|
105
111
|
|
|
106
112
|
Currently auto-fixes:
|
|
107
|
-
- Removes unused import statements
|
|
108
|
-
-
|
|
109
|
-
-
|
|
113
|
+
- Removes unused import statements from Python files
|
|
114
|
+
- Appends missing orphan files and directories to `.gitignore`
|
|
115
|
+
- Appends missing environment variable stubs (`KEY=`) to `.env.example`
|
|
110
116
|
|
|
111
117
|
## Usage
|
|
112
118
|
|
|
@@ -146,6 +152,18 @@ ghostbuster scan --format json
|
|
|
146
152
|
ghostbuster scan --format markdown
|
|
147
153
|
```
|
|
148
154
|
|
|
155
|
+
### Pre-commit Hook Integration
|
|
156
|
+
|
|
157
|
+
Add Ghostbuster to your `.pre-commit-config.yaml`:
|
|
158
|
+
|
|
159
|
+
```yaml
|
|
160
|
+
repos:
|
|
161
|
+
- repo: https://github.com/adewanggar/ghostbuster-cli
|
|
162
|
+
rev: v0.2.0
|
|
163
|
+
hooks:
|
|
164
|
+
- id: ghostbuster-scan
|
|
165
|
+
```
|
|
166
|
+
|
|
149
167
|
### CI Integration
|
|
150
168
|
|
|
151
169
|
```yaml
|
|
@@ -72,9 +72,9 @@ ghostbuster bust --confirm
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Currently auto-fixes:
|
|
75
|
-
- Removes unused import statements
|
|
76
|
-
-
|
|
77
|
-
-
|
|
75
|
+
- Removes unused import statements from Python files
|
|
76
|
+
- Appends missing orphan files and directories to `.gitignore`
|
|
77
|
+
- Appends missing environment variable stubs (`KEY=`) to `.env.example`
|
|
78
78
|
|
|
79
79
|
## Usage
|
|
80
80
|
|
|
@@ -114,6 +114,18 @@ ghostbuster scan --format json
|
|
|
114
114
|
ghostbuster scan --format markdown
|
|
115
115
|
```
|
|
116
116
|
|
|
117
|
+
### Pre-commit Hook Integration
|
|
118
|
+
|
|
119
|
+
Add Ghostbuster to your `.pre-commit-config.yaml`:
|
|
120
|
+
|
|
121
|
+
```yaml
|
|
122
|
+
repos:
|
|
123
|
+
- repo: https://github.com/adewanggar/ghostbuster-cli
|
|
124
|
+
rev: v0.2.0
|
|
125
|
+
hooks:
|
|
126
|
+
- id: ghostbuster-scan
|
|
127
|
+
```
|
|
128
|
+
|
|
117
129
|
### CI Integration
|
|
118
130
|
|
|
119
131
|
```yaml
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostbuster-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
8
8
|
description = "Find and bust the ghosts haunting your codebase - unused deps, dead code, orphan files, and phantom env vars."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -42,6 +42,15 @@ dependencies = [
|
|
|
42
42
|
"tomli>=2.0.0; python_version < '3.11'",
|
|
43
43
|
]
|
|
44
44
|
|
|
45
|
+
[project.optional-dependencies]
|
|
46
|
+
dev = [
|
|
47
|
+
"pytest>=8.0.0",
|
|
48
|
+
"ruff>=0.4.0",
|
|
49
|
+
"mypy>=1.10.0",
|
|
50
|
+
"build>=1.0.0",
|
|
51
|
+
"twine>=5.0.0",
|
|
52
|
+
]
|
|
53
|
+
|
|
45
54
|
[project.scripts]
|
|
46
55
|
ghostbuster = "ghostbuster.cli.app:app"
|
|
47
56
|
|
|
@@ -97,5 +106,4 @@ warn_return_any = true
|
|
|
97
106
|
warn_unused_configs = true
|
|
98
107
|
disallow_untyped_defs = true
|
|
99
108
|
check_untyped_defs = true
|
|
100
|
-
|
|
101
|
-
packages = ["ghostbuster"]
|
|
109
|
+
ignore_missing_imports = true
|
|
@@ -22,7 +22,7 @@ from ghostbuster.cli.display import (
|
|
|
22
22
|
)
|
|
23
23
|
from ghostbuster.core.models import GhostCategory
|
|
24
24
|
from ghostbuster.core.scanner import create_default_orchestrator
|
|
25
|
-
from ghostbuster.fixers
|
|
25
|
+
from ghostbuster.fixers import EnvFixer, Fixer, GitignoreFixer, ImportFixer
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def bust(
|
|
@@ -101,16 +101,28 @@ def bust(
|
|
|
101
101
|
|
|
102
102
|
console.print(f" Found [bold]{len(fixable_ghosts)}[/bold] fixable ghosts.\n")
|
|
103
103
|
|
|
104
|
-
#
|
|
105
|
-
|
|
104
|
+
# Run fixers across all categories
|
|
105
|
+
fixers: list[Fixer] = [
|
|
106
|
+
ImportFixer(),
|
|
107
|
+
GitignoreFixer(),
|
|
108
|
+
EnvFixer(),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
all_changes: list[str] = []
|
|
106
112
|
|
|
107
113
|
if confirm:
|
|
108
|
-
|
|
109
|
-
|
|
114
|
+
for fixer in fixers:
|
|
115
|
+
changes = fixer.fix(fixable_ghosts, path)
|
|
116
|
+
all_changes.extend(changes)
|
|
117
|
+
|
|
118
|
+
print_bust_applied(all_changes)
|
|
110
119
|
console.print(
|
|
111
120
|
"\n [bold green]Ghostbusting complete![/bold green] "
|
|
112
121
|
"Don't forget to review the changes and run your tests.\n"
|
|
113
122
|
)
|
|
114
123
|
else:
|
|
115
|
-
|
|
116
|
-
|
|
124
|
+
for fixer in fixers:
|
|
125
|
+
changes = fixer.preview(fixable_ghosts, path)
|
|
126
|
+
all_changes.extend(changes)
|
|
127
|
+
|
|
128
|
+
print_bust_preview(all_changes)
|
|
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|
|
11
11
|
import sys
|
|
12
12
|
from dataclasses import dataclass, field
|
|
13
13
|
from pathlib import Path
|
|
14
|
+
from typing import Any, cast
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
@dataclass
|
|
@@ -84,7 +85,7 @@ class GhostbusterConfig:
|
|
|
84
85
|
return config
|
|
85
86
|
|
|
86
87
|
|
|
87
|
-
def _load_toml(filepath: Path) -> dict:
|
|
88
|
+
def _load_toml(filepath: Path) -> dict[str, Any]:
|
|
88
89
|
"""Load a TOML file and return its contents as a dict."""
|
|
89
90
|
try:
|
|
90
91
|
if sys.version_info >= (3, 11):
|
|
@@ -92,25 +93,26 @@ def _load_toml(filepath: Path) -> dict:
|
|
|
92
93
|
else:
|
|
93
94
|
import tomli as tomllib # type: ignore[no-redef]
|
|
94
95
|
|
|
95
|
-
|
|
96
|
+
data = tomllib.loads(filepath.read_text(encoding="utf-8"))
|
|
97
|
+
return cast("dict[str, Any]", data) if isinstance(data, dict) else {}
|
|
96
98
|
except Exception:
|
|
97
99
|
return {}
|
|
98
100
|
|
|
99
101
|
|
|
100
|
-
def _apply_config(config: GhostbusterConfig, data: dict) -> GhostbusterConfig:
|
|
102
|
+
def _apply_config(config: GhostbusterConfig, data: dict[str, Any]) -> GhostbusterConfig:
|
|
101
103
|
"""Apply a dict of config values to a GhostbusterConfig."""
|
|
102
|
-
if "exclude_dirs" in data:
|
|
104
|
+
if "exclude_dirs" in data and isinstance(data["exclude_dirs"], list):
|
|
103
105
|
config.exclude_dirs = data["exclude_dirs"]
|
|
104
|
-
if "exclude_patterns" in data:
|
|
106
|
+
if "exclude_patterns" in data and isinstance(data["exclude_patterns"], list):
|
|
105
107
|
config.exclude_patterns = data["exclude_patterns"]
|
|
106
|
-
if "categories" in data:
|
|
108
|
+
if "categories" in data and isinstance(data["categories"], list):
|
|
107
109
|
config.categories = data["categories"]
|
|
108
|
-
if "ignore_packages" in data:
|
|
110
|
+
if "ignore_packages" in data and isinstance(data["ignore_packages"], list):
|
|
109
111
|
config.ignore_packages = data["ignore_packages"]
|
|
110
|
-
if "ignore_env_vars" in data:
|
|
112
|
+
if "ignore_env_vars" in data and isinstance(data["ignore_env_vars"], list):
|
|
111
113
|
config.ignore_env_vars = data["ignore_env_vars"]
|
|
112
|
-
if "ignore_names" in data:
|
|
114
|
+
if "ignore_names" in data and isinstance(data["ignore_names"], list):
|
|
113
115
|
config.ignore_names = data["ignore_names"]
|
|
114
|
-
if "large_file_threshold" in data:
|
|
116
|
+
if "large_file_threshold" in data and isinstance(data["large_file_threshold"], int):
|
|
115
117
|
config.large_file_threshold = data["large_file_threshold"]
|
|
116
118
|
return config
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Auto-fix modules for resolving ghost findings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol
|
|
6
|
+
|
|
7
|
+
from ghostbuster.fixers.env_fixer import EnvFixer
|
|
8
|
+
from ghostbuster.fixers.gitignore_fixer import GitignoreFixer
|
|
9
|
+
from ghostbuster.fixers.import_fixer import ImportFixer
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ghostbuster.core.models import Ghost
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Fixer(Protocol):
|
|
18
|
+
"""Protocol that all fixers must implement."""
|
|
19
|
+
|
|
20
|
+
def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
21
|
+
"""Return preview of changes without applying them."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
25
|
+
"""Apply fixes and return list of changes made."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
__all__ = ["EnvFixer", "Fixer", "GitignoreFixer", "ImportFixer"]
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Env Fixer - adds phantom environment variables to .env.example or .env."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ghostbuster.core.models import Ghost, GhostCategory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EnvFixer:
|
|
12
|
+
"""Auto-fixer that appends missing environment variable stubs to .env.example."""
|
|
13
|
+
|
|
14
|
+
def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
15
|
+
"""Return a list of changes that would be made without modifying files."""
|
|
16
|
+
env_ghosts = [g for g in ghosts if g.category == GhostCategory.PHANTOM_ENV and g.fixable]
|
|
17
|
+
if not env_ghosts:
|
|
18
|
+
return []
|
|
19
|
+
|
|
20
|
+
target_file = self._get_target_file(path)
|
|
21
|
+
keys_to_add = self._get_keys_to_add(env_ghosts, target_file)
|
|
22
|
+
if not keys_to_add:
|
|
23
|
+
return []
|
|
24
|
+
|
|
25
|
+
target_name = target_file.name if target_file.exists() else f"{target_file.name} (new file)"
|
|
26
|
+
|
|
27
|
+
changes: list[str] = []
|
|
28
|
+
for key in keys_to_add:
|
|
29
|
+
changes.append(f" Would add '{key}=' to {target_name}")
|
|
30
|
+
|
|
31
|
+
return changes
|
|
32
|
+
|
|
33
|
+
def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
34
|
+
"""Append missing environment variable keys to .env.example or .env."""
|
|
35
|
+
env_ghosts = [g for g in ghosts if g.category == GhostCategory.PHANTOM_ENV and g.fixable]
|
|
36
|
+
if not env_ghosts:
|
|
37
|
+
return []
|
|
38
|
+
|
|
39
|
+
target_file = self._get_target_file(path)
|
|
40
|
+
keys_to_add = self._get_keys_to_add(env_ghosts, target_file)
|
|
41
|
+
if not keys_to_add:
|
|
42
|
+
return []
|
|
43
|
+
|
|
44
|
+
existing_content = ""
|
|
45
|
+
if target_file.exists():
|
|
46
|
+
try:
|
|
47
|
+
existing_content = target_file.read_text(encoding="utf-8")
|
|
48
|
+
except OSError:
|
|
49
|
+
existing_content = ""
|
|
50
|
+
|
|
51
|
+
# Prepare new lines
|
|
52
|
+
new_lines: list[str] = []
|
|
53
|
+
if existing_content and not existing_content.endswith("\n"):
|
|
54
|
+
new_lines.append("\n")
|
|
55
|
+
|
|
56
|
+
for key in keys_to_add:
|
|
57
|
+
new_lines.append(f"{key}=\n")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
with target_file.open("a", encoding="utf-8") as f:
|
|
61
|
+
f.write("".join(new_lines))
|
|
62
|
+
except OSError:
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
applied: list[str] = []
|
|
66
|
+
for key in keys_to_add:
|
|
67
|
+
applied.append(f" Added '{key}=' to {target_file.name}")
|
|
68
|
+
|
|
69
|
+
return applied
|
|
70
|
+
|
|
71
|
+
def _get_target_file(self, path: Path) -> Path:
|
|
72
|
+
"""Get the preferred env file to update."""
|
|
73
|
+
env_example = path / ".env.example"
|
|
74
|
+
if env_example.exists():
|
|
75
|
+
return env_example
|
|
76
|
+
|
|
77
|
+
env_sample = path / ".env.sample"
|
|
78
|
+
if env_sample.exists():
|
|
79
|
+
return env_sample
|
|
80
|
+
|
|
81
|
+
env_template = path / ".env.template"
|
|
82
|
+
if env_template.exists():
|
|
83
|
+
return env_template
|
|
84
|
+
|
|
85
|
+
# Default to .env.example to prevent accidental secret commits
|
|
86
|
+
return path / ".env.example"
|
|
87
|
+
|
|
88
|
+
def _get_keys_to_add(self, ghosts: list[Ghost], target_file: Path) -> list[str]:
|
|
89
|
+
"""Determine unique environment variable keys that need to be added."""
|
|
90
|
+
existing_keys: set[str] = set()
|
|
91
|
+
|
|
92
|
+
if target_file.exists():
|
|
93
|
+
try:
|
|
94
|
+
for line in target_file.read_text(encoding="utf-8").splitlines():
|
|
95
|
+
cleaned = line.strip()
|
|
96
|
+
if cleaned and not cleaned.startswith("#"):
|
|
97
|
+
match = re.match(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=", cleaned)
|
|
98
|
+
if match:
|
|
99
|
+
existing_keys.add(match.group(1))
|
|
100
|
+
except OSError:
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
keys: list[str] = []
|
|
104
|
+
seen: set[str] = set()
|
|
105
|
+
|
|
106
|
+
for ghost in ghosts:
|
|
107
|
+
key = ghost.name
|
|
108
|
+
if key not in existing_keys and key not in seen:
|
|
109
|
+
seen.add(key)
|
|
110
|
+
keys.append(key)
|
|
111
|
+
|
|
112
|
+
return keys
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Gitignore Fixer - adds orphan files and directories to .gitignore."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ghostbuster.core.models import Ghost, GhostCategory
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GitignoreFixer:
|
|
11
|
+
"""Auto-fixer that appends orphan files/directories to .gitignore."""
|
|
12
|
+
|
|
13
|
+
def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
14
|
+
"""Return a list of changes that would be made without modifying files."""
|
|
15
|
+
orphan_ghosts = [g for g in ghosts if g.category == GhostCategory.ORPHAN_FILE and g.fixable]
|
|
16
|
+
if not orphan_ghosts:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
entries_to_add = self._get_entries_to_add(orphan_ghosts, path)
|
|
20
|
+
if not entries_to_add:
|
|
21
|
+
return []
|
|
22
|
+
|
|
23
|
+
gitignore_path = path / ".gitignore"
|
|
24
|
+
target_name = ".gitignore" if gitignore_path.exists() else ".gitignore (new file)"
|
|
25
|
+
|
|
26
|
+
changes: list[str] = []
|
|
27
|
+
for entry in entries_to_add:
|
|
28
|
+
changes.append(f" Would add '{entry}' to {target_name}")
|
|
29
|
+
|
|
30
|
+
return changes
|
|
31
|
+
|
|
32
|
+
def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
33
|
+
"""Append missing orphan patterns to .gitignore."""
|
|
34
|
+
orphan_ghosts = [g for g in ghosts if g.category == GhostCategory.ORPHAN_FILE and g.fixable]
|
|
35
|
+
if not orphan_ghosts:
|
|
36
|
+
return []
|
|
37
|
+
|
|
38
|
+
entries_to_add = self._get_entries_to_add(orphan_ghosts, path)
|
|
39
|
+
if not entries_to_add:
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
gitignore_path = path / ".gitignore"
|
|
43
|
+
existing_content = ""
|
|
44
|
+
if gitignore_path.exists():
|
|
45
|
+
try:
|
|
46
|
+
existing_content = gitignore_path.read_text(encoding="utf-8")
|
|
47
|
+
except OSError:
|
|
48
|
+
existing_content = ""
|
|
49
|
+
|
|
50
|
+
# Prepare new content
|
|
51
|
+
new_lines: list[str] = []
|
|
52
|
+
if existing_content and not existing_content.endswith("\n"):
|
|
53
|
+
new_lines.append("\n")
|
|
54
|
+
|
|
55
|
+
for entry in entries_to_add:
|
|
56
|
+
new_lines.append(f"{entry}\n")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
with gitignore_path.open("a", encoding="utf-8") as f:
|
|
60
|
+
f.write("".join(new_lines))
|
|
61
|
+
except OSError:
|
|
62
|
+
return []
|
|
63
|
+
|
|
64
|
+
applied: list[str] = []
|
|
65
|
+
for entry in entries_to_add:
|
|
66
|
+
applied.append(f" Added '{entry}' to .gitignore")
|
|
67
|
+
|
|
68
|
+
return applied
|
|
69
|
+
|
|
70
|
+
def _get_entries_to_add(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
71
|
+
"""Determine unique entries that need to be added to .gitignore."""
|
|
72
|
+
gitignore_path = path / ".gitignore"
|
|
73
|
+
existing_lines: set[str] = set()
|
|
74
|
+
|
|
75
|
+
if gitignore_path.exists():
|
|
76
|
+
try:
|
|
77
|
+
for line in gitignore_path.read_text(encoding="utf-8").splitlines():
|
|
78
|
+
cleaned = line.strip()
|
|
79
|
+
if cleaned and not cleaned.startswith("#"):
|
|
80
|
+
existing_lines.add(cleaned.rstrip("/"))
|
|
81
|
+
except OSError:
|
|
82
|
+
pass
|
|
83
|
+
|
|
84
|
+
entries: list[str] = []
|
|
85
|
+
seen: set[str] = set()
|
|
86
|
+
|
|
87
|
+
for ghost in ghosts:
|
|
88
|
+
# Determine appropriate pattern
|
|
89
|
+
name = ghost.name
|
|
90
|
+
if ghost.file_path and ghost.file_path.is_dir():
|
|
91
|
+
pattern = f"{name}/"
|
|
92
|
+
check_name = name.rstrip("/")
|
|
93
|
+
else:
|
|
94
|
+
pattern = name
|
|
95
|
+
check_name = name
|
|
96
|
+
|
|
97
|
+
if check_name not in existing_lines and pattern not in seen:
|
|
98
|
+
seen.add(pattern)
|
|
99
|
+
entries.append(pattern)
|
|
100
|
+
|
|
101
|
+
return entries
|
|
@@ -17,7 +17,7 @@ class TestCLI:
|
|
|
17
17
|
result = runner.invoke(app, ["--version"])
|
|
18
18
|
assert result.exit_code == 0
|
|
19
19
|
assert "ghostbuster" in result.output
|
|
20
|
-
assert "0.
|
|
20
|
+
assert "0.2.0" in result.output
|
|
21
21
|
|
|
22
22
|
def test_help_flag(self) -> None:
|
|
23
23
|
"""--help should print help text."""
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Tests for Ghostbuster auto-fixers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ghostbuster.core.models import Ghost, GhostCategory, Severity
|
|
8
|
+
from ghostbuster.fixers.env_fixer import EnvFixer
|
|
9
|
+
from ghostbuster.fixers.gitignore_fixer import GitignoreFixer
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TestGitignoreFixer:
|
|
13
|
+
"""Tests for GitignoreFixer."""
|
|
14
|
+
|
|
15
|
+
def test_preview_gitignore_changes(self, tmp_path: Path) -> None:
|
|
16
|
+
"""Should preview adding orphan directories/files to .gitignore."""
|
|
17
|
+
node_modules = tmp_path / "node_modules"
|
|
18
|
+
node_modules.mkdir()
|
|
19
|
+
|
|
20
|
+
ghosts = [
|
|
21
|
+
Ghost(
|
|
22
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
23
|
+
name="node_modules",
|
|
24
|
+
message="Directory node_modules should be in .gitignore",
|
|
25
|
+
file_path=node_modules,
|
|
26
|
+
severity=Severity.HIGH,
|
|
27
|
+
fixable=True,
|
|
28
|
+
),
|
|
29
|
+
Ghost(
|
|
30
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
31
|
+
name=".DS_Store",
|
|
32
|
+
message="File .DS_Store should be in .gitignore",
|
|
33
|
+
file_path=tmp_path / ".DS_Store",
|
|
34
|
+
severity=Severity.LOW,
|
|
35
|
+
fixable=True,
|
|
36
|
+
),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
fixer = GitignoreFixer()
|
|
40
|
+
preview = fixer.preview(ghosts, tmp_path)
|
|
41
|
+
|
|
42
|
+
assert len(preview) == 2
|
|
43
|
+
assert any("node_modules/" in p for p in preview)
|
|
44
|
+
assert any(".DS_Store" in p for p in preview)
|
|
45
|
+
# .gitignore should NOT be created during preview
|
|
46
|
+
assert not (tmp_path / ".gitignore").exists()
|
|
47
|
+
|
|
48
|
+
def test_apply_gitignore_fix(self, tmp_path: Path) -> None:
|
|
49
|
+
"""Should append missing entries to .gitignore."""
|
|
50
|
+
(tmp_path / "venv").mkdir()
|
|
51
|
+
(tmp_path / ".gitignore").write_text("__pycache__/\n", encoding="utf-8")
|
|
52
|
+
|
|
53
|
+
ghosts = [
|
|
54
|
+
Ghost(
|
|
55
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
56
|
+
name="venv",
|
|
57
|
+
message="venv should be in .gitignore",
|
|
58
|
+
file_path=tmp_path / "venv",
|
|
59
|
+
severity=Severity.HIGH,
|
|
60
|
+
fixable=True,
|
|
61
|
+
),
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
fixer = GitignoreFixer()
|
|
65
|
+
applied = fixer.fix(ghosts, tmp_path)
|
|
66
|
+
|
|
67
|
+
assert len(applied) == 1
|
|
68
|
+
content = (tmp_path / ".gitignore").read_text(encoding="utf-8")
|
|
69
|
+
assert "__pycache__/\n" in content
|
|
70
|
+
assert "venv/\n" in content
|
|
71
|
+
|
|
72
|
+
def test_skips_already_ignored_entries(self, tmp_path: Path) -> None:
|
|
73
|
+
"""Should not add duplicate entries."""
|
|
74
|
+
(tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8")
|
|
75
|
+
(tmp_path / "node_modules").mkdir()
|
|
76
|
+
|
|
77
|
+
ghosts = [
|
|
78
|
+
Ghost(
|
|
79
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
80
|
+
name="node_modules",
|
|
81
|
+
message="Directory node_modules",
|
|
82
|
+
file_path=tmp_path / "node_modules",
|
|
83
|
+
severity=Severity.HIGH,
|
|
84
|
+
fixable=True,
|
|
85
|
+
),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
fixer = GitignoreFixer()
|
|
89
|
+
applied = fixer.fix(ghosts, tmp_path)
|
|
90
|
+
assert len(applied) == 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TestEnvFixer:
|
|
94
|
+
"""Tests for EnvFixer."""
|
|
95
|
+
|
|
96
|
+
def test_preview_env_changes(self, tmp_path: Path) -> None:
|
|
97
|
+
"""Should preview adding missing env keys to .env.example."""
|
|
98
|
+
ghosts = [
|
|
99
|
+
Ghost(
|
|
100
|
+
category=GhostCategory.PHANTOM_ENV,
|
|
101
|
+
name="DATABASE_URL",
|
|
102
|
+
message="DATABASE_URL is missing",
|
|
103
|
+
severity=Severity.HIGH,
|
|
104
|
+
fixable=True,
|
|
105
|
+
),
|
|
106
|
+
Ghost(
|
|
107
|
+
category=GhostCategory.PHANTOM_ENV,
|
|
108
|
+
name="STRIPE_SECRET_KEY",
|
|
109
|
+
message="STRIPE_SECRET_KEY is missing",
|
|
110
|
+
severity=Severity.HIGH,
|
|
111
|
+
fixable=True,
|
|
112
|
+
),
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
fixer = EnvFixer()
|
|
116
|
+
preview = fixer.preview(ghosts, tmp_path)
|
|
117
|
+
|
|
118
|
+
assert len(preview) == 2
|
|
119
|
+
assert any("DATABASE_URL=" in p for p in preview)
|
|
120
|
+
assert any("STRIPE_SECRET_KEY=" in p for p in preview)
|
|
121
|
+
assert not (tmp_path / ".env.example").exists()
|
|
122
|
+
|
|
123
|
+
def test_apply_env_fix(self, tmp_path: Path) -> None:
|
|
124
|
+
"""Should create .env.example and append missing keys."""
|
|
125
|
+
ghosts = [
|
|
126
|
+
Ghost(
|
|
127
|
+
category=GhostCategory.PHANTOM_ENV,
|
|
128
|
+
name="API_KEY",
|
|
129
|
+
message="API_KEY is missing",
|
|
130
|
+
severity=Severity.HIGH,
|
|
131
|
+
fixable=True,
|
|
132
|
+
),
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
fixer = EnvFixer()
|
|
136
|
+
applied = fixer.fix(ghosts, tmp_path)
|
|
137
|
+
|
|
138
|
+
assert len(applied) == 1
|
|
139
|
+
env_example = tmp_path / ".env.example"
|
|
140
|
+
assert env_example.exists()
|
|
141
|
+
content = env_example.read_text(encoding="utf-8")
|
|
142
|
+
assert "API_KEY=\n" in content
|
|
143
|
+
|
|
144
|
+
def test_preserves_existing_keys_in_env_example(self, tmp_path: Path) -> None:
|
|
145
|
+
"""Should append new keys without duplicating existing ones."""
|
|
146
|
+
(tmp_path / ".env.example").write_text("EXISTING_KEY=123\n", encoding="utf-8")
|
|
147
|
+
|
|
148
|
+
ghosts = [
|
|
149
|
+
Ghost(
|
|
150
|
+
category=GhostCategory.PHANTOM_ENV,
|
|
151
|
+
name="EXISTING_KEY",
|
|
152
|
+
message="Already in example",
|
|
153
|
+
severity=Severity.HIGH,
|
|
154
|
+
fixable=True,
|
|
155
|
+
),
|
|
156
|
+
Ghost(
|
|
157
|
+
category=GhostCategory.PHANTOM_ENV,
|
|
158
|
+
name="NEW_KEY",
|
|
159
|
+
message="New key",
|
|
160
|
+
severity=Severity.HIGH,
|
|
161
|
+
fixable=True,
|
|
162
|
+
),
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
fixer = EnvFixer()
|
|
166
|
+
applied = fixer.fix(ghosts, tmp_path)
|
|
167
|
+
|
|
168
|
+
assert len(applied) == 1
|
|
169
|
+
content = (tmp_path / ".env.example").read_text(encoding="utf-8")
|
|
170
|
+
assert "EXISTING_KEY=123\n" in content
|
|
171
|
+
assert "NEW_KEY=\n" in content
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Auto-fix modules for resolving ghost findings."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|