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
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Orphan File Scanner — detects files/folders that should be in .gitignore.
|
|
2
|
+
|
|
3
|
+
Identifies large files, common build artifacts, dependency directories,
|
|
4
|
+
and other items that are tracked by git but shouldn't be.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from ghostbuster.core.models import Ghost, GhostCategory, Severity
|
|
13
|
+
|
|
14
|
+
# Directories that should almost always be in .gitignore
|
|
15
|
+
IGNORABLE_DIRS: dict[str, str] = {
|
|
16
|
+
"node_modules": "Node.js dependencies directory",
|
|
17
|
+
"venv": "Python virtual environment",
|
|
18
|
+
".venv": "Python virtual environment",
|
|
19
|
+
"env": "Python virtual environment",
|
|
20
|
+
".env": "Python virtual environment",
|
|
21
|
+
"__pycache__": "Python bytecode cache",
|
|
22
|
+
".pytest_cache": "Pytest cache",
|
|
23
|
+
".mypy_cache": "Mypy cache",
|
|
24
|
+
".ruff_cache": "Ruff cache",
|
|
25
|
+
".tox": "Tox environments",
|
|
26
|
+
".nox": "Nox environments",
|
|
27
|
+
"dist": "Build distribution directory",
|
|
28
|
+
"build": "Build output directory",
|
|
29
|
+
".eggs": "Python eggs directory",
|
|
30
|
+
"*.egg-info": "Python egg-info directory",
|
|
31
|
+
"htmlcov": "Coverage HTML report",
|
|
32
|
+
".coverage": "Coverage data file",
|
|
33
|
+
".hypothesis": "Hypothesis testing cache",
|
|
34
|
+
"bower_components": "Bower dependencies",
|
|
35
|
+
".next": "Next.js build output",
|
|
36
|
+
".nuxt": "Nuxt.js build output",
|
|
37
|
+
".cache": "Generic cache directory",
|
|
38
|
+
".parcel-cache": "Parcel bundler cache",
|
|
39
|
+
".turbo": "Turborepo cache",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# File patterns that are commonly forgotten
|
|
43
|
+
IGNORABLE_PATTERNS: dict[str, str] = {
|
|
44
|
+
".pyc": "Python bytecode",
|
|
45
|
+
".pyo": "Python optimized bytecode",
|
|
46
|
+
".DS_Store": "macOS metadata",
|
|
47
|
+
"Thumbs.db": "Windows thumbnail cache",
|
|
48
|
+
".env": "Environment variables file (may contain secrets)",
|
|
49
|
+
".env.local": "Local environment variables",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Size threshold for flagging large files (10 MB)
|
|
53
|
+
LARGE_FILE_THRESHOLD = 10 * 1024 * 1024
|
|
54
|
+
|
|
55
|
+
# File extensions that are commonly large and shouldn't be in repos
|
|
56
|
+
LARGE_FILE_EXTENSIONS: set[str] = {
|
|
57
|
+
".h5",
|
|
58
|
+
".hdf5", # ML model files
|
|
59
|
+
".pkl",
|
|
60
|
+
".pickle", # Pickled data
|
|
61
|
+
".pt",
|
|
62
|
+
".pth", # PyTorch models
|
|
63
|
+
".onnx", # ONNX models
|
|
64
|
+
".bin", # Generic binary
|
|
65
|
+
".db",
|
|
66
|
+
".sqlite", # Databases
|
|
67
|
+
".sqlite3",
|
|
68
|
+
".csv", # Data files (when large)
|
|
69
|
+
".parquet",
|
|
70
|
+
".zip",
|
|
71
|
+
".tar",
|
|
72
|
+
".gz", # Archives
|
|
73
|
+
".tar.gz",
|
|
74
|
+
".tgz",
|
|
75
|
+
".rar",
|
|
76
|
+
".7z",
|
|
77
|
+
".mp4",
|
|
78
|
+
".avi", # Video files
|
|
79
|
+
".mov",
|
|
80
|
+
".mkv",
|
|
81
|
+
".iso", # Disk images
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class OrphanFileScanner:
|
|
86
|
+
"""Detects files and directories that should be in .gitignore."""
|
|
87
|
+
|
|
88
|
+
name = "orphan-file"
|
|
89
|
+
|
|
90
|
+
def scan(self, path: Path) -> list[Ghost]:
|
|
91
|
+
"""Scan a project directory for orphan files."""
|
|
92
|
+
ghosts: list[Ghost] = []
|
|
93
|
+
gitignore_patterns = self._load_gitignore(path)
|
|
94
|
+
|
|
95
|
+
# Check for ignorable directories
|
|
96
|
+
ghosts.extend(self._check_ignorable_dirs(path, gitignore_patterns))
|
|
97
|
+
|
|
98
|
+
# Check for large files
|
|
99
|
+
ghosts.extend(self._check_large_files(path))
|
|
100
|
+
|
|
101
|
+
# Check for common forgotten files
|
|
102
|
+
ghosts.extend(self._check_forgotten_files(path, gitignore_patterns))
|
|
103
|
+
|
|
104
|
+
return ghosts
|
|
105
|
+
|
|
106
|
+
def _load_gitignore(self, path: Path) -> set[str]:
|
|
107
|
+
"""Load patterns from .gitignore file."""
|
|
108
|
+
gitignore = path / ".gitignore"
|
|
109
|
+
if not gitignore.exists():
|
|
110
|
+
return set()
|
|
111
|
+
|
|
112
|
+
patterns: set[str] = set()
|
|
113
|
+
try:
|
|
114
|
+
for line in gitignore.read_text(encoding="utf-8").splitlines():
|
|
115
|
+
line = line.strip()
|
|
116
|
+
if line and not line.startswith("#"):
|
|
117
|
+
# Normalize: remove trailing slashes for directory matching
|
|
118
|
+
patterns.add(line.rstrip("/"))
|
|
119
|
+
return patterns
|
|
120
|
+
except OSError:
|
|
121
|
+
return set()
|
|
122
|
+
|
|
123
|
+
def _is_in_gitignore(self, name: str, patterns: set[str]) -> bool:
|
|
124
|
+
"""Check if a name matches any gitignore pattern (simplified check)."""
|
|
125
|
+
# Simple check: exact match or glob pattern
|
|
126
|
+
if name in patterns:
|
|
127
|
+
return True
|
|
128
|
+
if name + "/" in patterns:
|
|
129
|
+
return True
|
|
130
|
+
if "/" + name in patterns:
|
|
131
|
+
return True
|
|
132
|
+
# Check wildcard patterns like *.pyc
|
|
133
|
+
for pattern in patterns:
|
|
134
|
+
if pattern.startswith("*") and name.endswith(pattern[1:]):
|
|
135
|
+
return True
|
|
136
|
+
if pattern.endswith("*") and name.startswith(pattern[:-1]):
|
|
137
|
+
return True
|
|
138
|
+
if pattern.endswith("/") and name == pattern[:-1]:
|
|
139
|
+
return True
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
def _check_ignorable_dirs(self, path: Path, gitignore_patterns: set[str]) -> list[Ghost]:
|
|
143
|
+
"""Check for directories that should be in .gitignore."""
|
|
144
|
+
ghosts: list[Ghost] = []
|
|
145
|
+
|
|
146
|
+
for item in path.iterdir():
|
|
147
|
+
if not item.is_dir():
|
|
148
|
+
continue
|
|
149
|
+
|
|
150
|
+
dir_name = item.name
|
|
151
|
+
|
|
152
|
+
# Check known ignorable directories
|
|
153
|
+
if dir_name in IGNORABLE_DIRS and not self._is_in_gitignore(
|
|
154
|
+
dir_name, gitignore_patterns
|
|
155
|
+
):
|
|
156
|
+
# Calculate directory size for impact assessment
|
|
157
|
+
size = self._get_dir_size(item)
|
|
158
|
+
size_str = self._format_size(size)
|
|
159
|
+
ghosts.append(
|
|
160
|
+
Ghost(
|
|
161
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
162
|
+
name=dir_name,
|
|
163
|
+
message=(
|
|
164
|
+
f"Directory '{dir_name}/' ({size_str}) is "
|
|
165
|
+
f"{IGNORABLE_DIRS[dir_name]} and should be in .gitignore"
|
|
166
|
+
),
|
|
167
|
+
file_path=item,
|
|
168
|
+
severity=Severity.HIGH if size > LARGE_FILE_THRESHOLD else Severity.MEDIUM,
|
|
169
|
+
fixable=True,
|
|
170
|
+
suggestion=f"Add '{dir_name}/' to .gitignore",
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# Check for egg-info directories
|
|
175
|
+
if dir_name.endswith(".egg-info") and not self._is_in_gitignore(
|
|
176
|
+
"*.egg-info", gitignore_patterns
|
|
177
|
+
):
|
|
178
|
+
ghosts.append(
|
|
179
|
+
Ghost(
|
|
180
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
181
|
+
name=dir_name,
|
|
182
|
+
message=f"Directory '{dir_name}/' is a build artifact and should be in .gitignore",
|
|
183
|
+
file_path=item,
|
|
184
|
+
severity=Severity.MEDIUM,
|
|
185
|
+
fixable=True,
|
|
186
|
+
suggestion="Add '*.egg-info/' to .gitignore",
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return ghosts
|
|
191
|
+
|
|
192
|
+
def _check_large_files(self, path: Path) -> list[Ghost]:
|
|
193
|
+
"""Check for files that exceed the size threshold."""
|
|
194
|
+
ghosts: list[Ghost] = []
|
|
195
|
+
|
|
196
|
+
for item in path.rglob("*"):
|
|
197
|
+
if not item.is_file():
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Skip .git directory
|
|
201
|
+
try:
|
|
202
|
+
rel = item.relative_to(path)
|
|
203
|
+
except ValueError:
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
parts = rel.parts
|
|
207
|
+
if any(p in {".git", "node_modules", "venv", ".venv", "__pycache__"} for p in parts):
|
|
208
|
+
continue
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
size = item.stat().st_size
|
|
212
|
+
except OSError:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
# Flag files with suspicious extensions regardless of size
|
|
216
|
+
suffix = item.suffix.lower()
|
|
217
|
+
if suffix in LARGE_FILE_EXTENSIONS and size > 1024 * 1024: # > 1MB
|
|
218
|
+
size_str = self._format_size(size)
|
|
219
|
+
ghosts.append(
|
|
220
|
+
Ghost(
|
|
221
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
222
|
+
name=item.name,
|
|
223
|
+
message=f"Large file '{rel}' ({size_str}) should probably not be in the repository",
|
|
224
|
+
file_path=item,
|
|
225
|
+
severity=Severity.HIGH if size > LARGE_FILE_THRESHOLD else Severity.MEDIUM,
|
|
226
|
+
fixable=True,
|
|
227
|
+
suggestion=f"Add '{item.name}' to .gitignore and use Git LFS or external storage",
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
elif size > LARGE_FILE_THRESHOLD:
|
|
231
|
+
size_str = self._format_size(size)
|
|
232
|
+
ghosts.append(
|
|
233
|
+
Ghost(
|
|
234
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
235
|
+
name=item.name,
|
|
236
|
+
message=f"File '{rel}' ({size_str}) exceeds {self._format_size(LARGE_FILE_THRESHOLD)} threshold",
|
|
237
|
+
file_path=item,
|
|
238
|
+
severity=Severity.HIGH,
|
|
239
|
+
fixable=False,
|
|
240
|
+
suggestion=f"Consider adding '{item.name}' to .gitignore or using Git LFS",
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return ghosts
|
|
245
|
+
|
|
246
|
+
def _check_forgotten_files(self, path: Path, gitignore_patterns: set[str]) -> list[Ghost]:
|
|
247
|
+
"""Check for common files that should be gitignored."""
|
|
248
|
+
ghosts: list[Ghost] = []
|
|
249
|
+
|
|
250
|
+
for item in path.iterdir():
|
|
251
|
+
if not item.is_file():
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
name = item.name
|
|
255
|
+
|
|
256
|
+
# Check specific filenames
|
|
257
|
+
if name in IGNORABLE_PATTERNS and not self._is_in_gitignore(name, gitignore_patterns):
|
|
258
|
+
ghosts.append(
|
|
259
|
+
Ghost(
|
|
260
|
+
category=GhostCategory.ORPHAN_FILE,
|
|
261
|
+
name=name,
|
|
262
|
+
message=f"File '{name}' ({IGNORABLE_PATTERNS[name]}) should be in .gitignore",
|
|
263
|
+
file_path=item,
|
|
264
|
+
severity=Severity.LOW
|
|
265
|
+
if name in {".DS_Store", "Thumbs.db"}
|
|
266
|
+
else Severity.MEDIUM,
|
|
267
|
+
fixable=True,
|
|
268
|
+
suggestion=f"Add '{name}' to .gitignore",
|
|
269
|
+
)
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
return ghosts
|
|
273
|
+
|
|
274
|
+
def _get_dir_size(self, path: Path, max_depth: int = 3) -> int:
|
|
275
|
+
"""Calculate approximate directory size (limited depth for speed)."""
|
|
276
|
+
total = 0
|
|
277
|
+
depth = 0
|
|
278
|
+
try:
|
|
279
|
+
for item in path.rglob("*"):
|
|
280
|
+
if item.is_file():
|
|
281
|
+
with contextlib.suppress(OSError):
|
|
282
|
+
total += item.stat().st_size
|
|
283
|
+
# Simple depth tracking
|
|
284
|
+
depth += 1
|
|
285
|
+
if depth > 10000: # Safety limit
|
|
286
|
+
break
|
|
287
|
+
except OSError:
|
|
288
|
+
pass
|
|
289
|
+
return total
|
|
290
|
+
|
|
291
|
+
@staticmethod
|
|
292
|
+
def _format_size(size: int) -> str:
|
|
293
|
+
"""Format a file size in human-readable format."""
|
|
294
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
295
|
+
if size < 1024:
|
|
296
|
+
return f"{size:.1f} {unit}"
|
|
297
|
+
size //= 1024
|
|
298
|
+
return f"{size:.1f} TB"
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Phantom Env Scanner — detects env vars referenced but never set.
|
|
2
|
+
|
|
3
|
+
Scans Python files for os.environ / os.getenv usage and checks if the
|
|
4
|
+
referenced environment variables are defined in .env files or the system.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ghostbuster.core.models import Ghost, GhostCategory, Severity
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PhantomEnvScanner:
|
|
18
|
+
"""Detects environment variables referenced in code but not set anywhere."""
|
|
19
|
+
|
|
20
|
+
name = "phantom-env"
|
|
21
|
+
|
|
22
|
+
def scan(self, path: Path) -> list[Ghost]:
|
|
23
|
+
"""Scan a project directory for phantom environment variables."""
|
|
24
|
+
# Collect all env vars referenced in code
|
|
25
|
+
referenced = self._find_referenced_env_vars(path)
|
|
26
|
+
if not referenced:
|
|
27
|
+
return []
|
|
28
|
+
|
|
29
|
+
# Collect all env vars that are defined
|
|
30
|
+
defined = self._find_defined_env_vars(path)
|
|
31
|
+
|
|
32
|
+
# Find phantoms: referenced but not defined
|
|
33
|
+
ghosts: list[Ghost] = []
|
|
34
|
+
for env_var, locations in referenced.items():
|
|
35
|
+
if env_var not in defined:
|
|
36
|
+
# Use the first location for the ghost
|
|
37
|
+
first_file, first_line = locations[0]
|
|
38
|
+
|
|
39
|
+
locations_str = ", ".join(f"{f.name}:{l}" for f, l in locations[:3])
|
|
40
|
+
if len(locations) > 3:
|
|
41
|
+
locations_str += f" (+{len(locations) - 3} more)"
|
|
42
|
+
|
|
43
|
+
ghosts.append(
|
|
44
|
+
Ghost(
|
|
45
|
+
category=GhostCategory.PHANTOM_ENV,
|
|
46
|
+
name=env_var,
|
|
47
|
+
message=(
|
|
48
|
+
f"Environment variable '{env_var}' is referenced "
|
|
49
|
+
f"at {locations_str} but never defined"
|
|
50
|
+
),
|
|
51
|
+
file_path=first_file,
|
|
52
|
+
line_number=first_line,
|
|
53
|
+
severity=Severity.HIGH,
|
|
54
|
+
fixable=True,
|
|
55
|
+
suggestion=f"Add '{env_var}=' to your .env file or set it in your environment",
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
return ghosts
|
|
60
|
+
|
|
61
|
+
def _find_referenced_env_vars(self, path: Path) -> dict[str, list[tuple[Path, int]]]:
|
|
62
|
+
"""Find all env var references in Python files via AST + regex.
|
|
63
|
+
|
|
64
|
+
Returns mapping of env_var_name -> list of (file_path, line_number).
|
|
65
|
+
"""
|
|
66
|
+
env_vars: dict[str, list[tuple[Path, int]]] = {}
|
|
67
|
+
|
|
68
|
+
for py_file in path.rglob("*.py"):
|
|
69
|
+
parts = py_file.relative_to(path).parts
|
|
70
|
+
if any(
|
|
71
|
+
p
|
|
72
|
+
in {
|
|
73
|
+
"venv",
|
|
74
|
+
".venv",
|
|
75
|
+
"node_modules",
|
|
76
|
+
".git",
|
|
77
|
+
"__pycache__",
|
|
78
|
+
".tox",
|
|
79
|
+
".nox",
|
|
80
|
+
"build",
|
|
81
|
+
"dist",
|
|
82
|
+
}
|
|
83
|
+
for p in parts
|
|
84
|
+
):
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
source = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
89
|
+
tree = ast.parse(source, filename=str(py_file))
|
|
90
|
+
except (SyntaxError, OSError):
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
# AST-based detection
|
|
94
|
+
for node in ast.walk(tree):
|
|
95
|
+
env_name = self._extract_env_var_from_node(node)
|
|
96
|
+
if env_name:
|
|
97
|
+
env_vars.setdefault(env_name, []).append((py_file, getattr(node, "lineno", 0)))
|
|
98
|
+
|
|
99
|
+
return env_vars
|
|
100
|
+
|
|
101
|
+
def _extract_env_var_from_node(self, node: ast.AST) -> str | None:
|
|
102
|
+
"""Extract an env var name from an AST node if it's an env access pattern.
|
|
103
|
+
|
|
104
|
+
Detects:
|
|
105
|
+
- os.environ["KEY"]
|
|
106
|
+
- os.environ.get("KEY")
|
|
107
|
+
- os.getenv("KEY")
|
|
108
|
+
"""
|
|
109
|
+
# Pattern: os.environ["KEY"] or os.environ.get("KEY")
|
|
110
|
+
if (
|
|
111
|
+
isinstance(node, ast.Subscript)
|
|
112
|
+
and self._is_os_environ(node.value)
|
|
113
|
+
and isinstance(node.slice, ast.Constant)
|
|
114
|
+
and isinstance(node.slice.value, str)
|
|
115
|
+
):
|
|
116
|
+
return node.slice.value
|
|
117
|
+
|
|
118
|
+
# Pattern: os.environ.get("KEY", ...) or os.getenv("KEY", ...)
|
|
119
|
+
if isinstance(node, ast.Call):
|
|
120
|
+
func = node.func
|
|
121
|
+
|
|
122
|
+
# os.getenv("KEY")
|
|
123
|
+
if (
|
|
124
|
+
isinstance(func, ast.Attribute)
|
|
125
|
+
and func.attr == "getenv"
|
|
126
|
+
and isinstance(func.value, ast.Name)
|
|
127
|
+
and func.value.id == "os"
|
|
128
|
+
):
|
|
129
|
+
return self._get_first_str_arg(node)
|
|
130
|
+
|
|
131
|
+
# os.environ.get("KEY")
|
|
132
|
+
if (
|
|
133
|
+
isinstance(func, ast.Attribute)
|
|
134
|
+
and func.attr == "get"
|
|
135
|
+
and self._is_os_environ(func.value)
|
|
136
|
+
):
|
|
137
|
+
return self._get_first_str_arg(node)
|
|
138
|
+
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
def _is_os_environ(self, node: ast.AST) -> bool:
|
|
142
|
+
"""Check if a node represents 'os.environ'."""
|
|
143
|
+
return (
|
|
144
|
+
isinstance(node, ast.Attribute)
|
|
145
|
+
and node.attr == "environ"
|
|
146
|
+
and isinstance(node.value, ast.Name)
|
|
147
|
+
and node.value.id == "os"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def _get_first_str_arg(self, node: ast.Call) -> str | None:
|
|
151
|
+
"""Get the first string argument from a call node."""
|
|
152
|
+
if (
|
|
153
|
+
node.args
|
|
154
|
+
and isinstance(node.args[0], ast.Constant)
|
|
155
|
+
and isinstance(node.args[0].value, str)
|
|
156
|
+
):
|
|
157
|
+
return node.args[0].value
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def _find_defined_env_vars(self, path: Path) -> set[str]:
|
|
161
|
+
"""Find env vars that are actually defined.
|
|
162
|
+
|
|
163
|
+
Checks:
|
|
164
|
+
- Current process environment
|
|
165
|
+
- .env file
|
|
166
|
+
- .env.example file
|
|
167
|
+
- .env.sample file
|
|
168
|
+
"""
|
|
169
|
+
defined: set[str] = set()
|
|
170
|
+
|
|
171
|
+
# System environment
|
|
172
|
+
defined.update(os.environ.keys())
|
|
173
|
+
|
|
174
|
+
# .env files
|
|
175
|
+
for env_filename in (".env", ".env.example", ".env.sample", ".env.template", ".env.local"):
|
|
176
|
+
env_file = path / env_filename
|
|
177
|
+
if env_file.exists():
|
|
178
|
+
defined.update(self._parse_env_file(env_file))
|
|
179
|
+
|
|
180
|
+
return defined
|
|
181
|
+
|
|
182
|
+
def _parse_env_file(self, filepath: Path) -> set[str]:
|
|
183
|
+
"""Parse a .env file and return all defined variable names."""
|
|
184
|
+
env_vars: set[str] = set()
|
|
185
|
+
try:
|
|
186
|
+
for line in filepath.read_text(encoding="utf-8").splitlines():
|
|
187
|
+
line = line.strip()
|
|
188
|
+
if not line or line.startswith("#"):
|
|
189
|
+
continue
|
|
190
|
+
# Match KEY=value or export KEY=value
|
|
191
|
+
match = re.match(r"^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=", line)
|
|
192
|
+
if match:
|
|
193
|
+
env_vars.add(match.group(1))
|
|
194
|
+
except OSError:
|
|
195
|
+
pass
|
|
196
|
+
return env_vars
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Scanner protocol and orchestrator for running all ghost detectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Protocol, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from ghostbuster.core.models import Ghost, GhostCategory, ScanResult, Severity
|
|
10
|
+
from ghostbuster.core.scoring import calculate_score
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class Scanner(Protocol):
|
|
15
|
+
"""Protocol that all ghost scanners must implement.
|
|
16
|
+
|
|
17
|
+
Each scanner is responsible for detecting one category of ghosts.
|
|
18
|
+
Scanners must be pure logic — no CLI/UI side effects.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
|
|
23
|
+
def scan(self, path: Path) -> list[Ghost]:
|
|
24
|
+
"""Scan the given path and return a list of found ghosts."""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ScanOrchestrator:
|
|
29
|
+
"""Runs all registered scanners and aggregates results.
|
|
30
|
+
|
|
31
|
+
Usage::
|
|
32
|
+
|
|
33
|
+
orchestrator = ScanOrchestrator()
|
|
34
|
+
orchestrator.register(DeadImportScanner())
|
|
35
|
+
orchestrator.register(OrphanFileScanner())
|
|
36
|
+
result = orchestrator.run(Path("."))
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._scanners: list[Scanner] = []
|
|
41
|
+
|
|
42
|
+
def register(self, scanner: Scanner) -> None:
|
|
43
|
+
"""Register a scanner to be run during scan."""
|
|
44
|
+
self._scanners.append(scanner)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def scanner_names(self) -> list[str]:
|
|
48
|
+
"""Return names of all registered scanners."""
|
|
49
|
+
return [s.name for s in self._scanners]
|
|
50
|
+
|
|
51
|
+
def run(
|
|
52
|
+
self,
|
|
53
|
+
path: Path,
|
|
54
|
+
categories: list[str] | None = None,
|
|
55
|
+
) -> ScanResult:
|
|
56
|
+
"""Run all registered scanners (or filtered subset) on the given path.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
path: Root directory to scan.
|
|
60
|
+
categories: Optional list of category names to filter scanners.
|
|
61
|
+
If None, all scanners are run.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
Aggregated ScanResult with all ghosts found and a score.
|
|
65
|
+
"""
|
|
66
|
+
start = time.perf_counter()
|
|
67
|
+
all_ghosts: list[Ghost] = []
|
|
68
|
+
|
|
69
|
+
scanners_to_run = self._scanners
|
|
70
|
+
if categories:
|
|
71
|
+
scanners_to_run = [s for s in self._scanners if s.name in categories]
|
|
72
|
+
|
|
73
|
+
for scanner in scanners_to_run:
|
|
74
|
+
try:
|
|
75
|
+
ghosts = scanner.scan(path)
|
|
76
|
+
all_ghosts.extend(ghosts)
|
|
77
|
+
except Exception as exc:
|
|
78
|
+
# Don't let one scanner crash the entire run.
|
|
79
|
+
# In debug mode, the CLI layer will re-raise.
|
|
80
|
+
all_ghosts.append(
|
|
81
|
+
Ghost(
|
|
82
|
+
category=GhostCategory.DEAD_IMPORT,
|
|
83
|
+
name=f"scanner-error-{scanner.name}",
|
|
84
|
+
message=f"Scanner '{scanner.name}' failed: {exc}",
|
|
85
|
+
severity=Severity.LOW,
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
90
|
+
score = calculate_score(all_ghosts)
|
|
91
|
+
|
|
92
|
+
return ScanResult(
|
|
93
|
+
ghosts=all_ghosts,
|
|
94
|
+
score=score,
|
|
95
|
+
scanned_path=path,
|
|
96
|
+
duration_ms=elapsed_ms,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def create_default_orchestrator() -> ScanOrchestrator:
|
|
101
|
+
"""Create an orchestrator with all built-in scanners registered."""
|
|
102
|
+
from ghostbuster.core.dead_imports import DeadImportScanner
|
|
103
|
+
from ghostbuster.core.orphan_files import OrphanFileScanner
|
|
104
|
+
from ghostbuster.core.phantom_env import PhantomEnvScanner
|
|
105
|
+
from ghostbuster.core.zombie_code import ZombieCodeScanner
|
|
106
|
+
|
|
107
|
+
orchestrator = ScanOrchestrator()
|
|
108
|
+
orchestrator.register(DeadImportScanner())
|
|
109
|
+
orchestrator.register(OrphanFileScanner())
|
|
110
|
+
orchestrator.register(ZombieCodeScanner())
|
|
111
|
+
orchestrator.register(PhantomEnvScanner())
|
|
112
|
+
return orchestrator
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Ghost Score calculation — the signature viraliy metric.
|
|
2
|
+
|
|
3
|
+
Computes a 0-100 "hauntedness" score based on ghost findings.
|
|
4
|
+
Designed to be provocative enough to screenshot and share.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ghostbuster.core.models import Ghost, GhostCategory, GhostScore, Severity
|
|
10
|
+
|
|
11
|
+
# Weight multipliers per category (reflects real-world severity impact)
|
|
12
|
+
CATEGORY_WEIGHTS: dict[GhostCategory, int] = {
|
|
13
|
+
GhostCategory.DEAD_IMPORT: 5, # Common, moderate waste
|
|
14
|
+
GhostCategory.ORPHAN_FILE: 8, # Disk space, clone time
|
|
15
|
+
GhostCategory.ZOMBIE_CODE: 3, # Confusing but usually harmless
|
|
16
|
+
GhostCategory.PHANTOM_ENV: 10, # Can cause runtime crashes
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
# Bonus weight for high-severity ghosts
|
|
20
|
+
SEVERITY_MULTIPLIER: dict[Severity, float] = {
|
|
21
|
+
Severity.LOW: 0.5,
|
|
22
|
+
Severity.MEDIUM: 1.0,
|
|
23
|
+
Severity.HIGH: 1.5,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Score thresholds and their labels
|
|
27
|
+
SCORE_LABELS: list[tuple[int, str]] = [
|
|
28
|
+
(0, "No ghosts detected. Your codebase is clean!"),
|
|
29
|
+
(10, "Almost clean - just a few minor issues."),
|
|
30
|
+
(20, "Mildly affected. Nothing a quick cleanup cannot fix."),
|
|
31
|
+
(35, "Multiple issues detected. Time for cleanup."),
|
|
32
|
+
(50, "Noticeable technical debt detected."),
|
|
33
|
+
(70, "Seriously affected. Your codebase needs significant cleanup."),
|
|
34
|
+
(85, "Extremely cluttered with unused code and files."),
|
|
35
|
+
(100, "Severe technical debt detected across multiple categories."),
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def calculate_score(ghosts: list[Ghost]) -> GhostScore:
|
|
40
|
+
"""Calculate the Ghost Score from a list of ghost findings.
|
|
41
|
+
|
|
42
|
+
The score is a weighted sum of ghosts, capped at 100.
|
|
43
|
+
Each ghost contributes its category weight × severity multiplier.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
ghosts: List of Ghost findings from all scanners.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
GhostScore with value (0-100), label, and per-category breakdown.
|
|
50
|
+
"""
|
|
51
|
+
if not ghosts:
|
|
52
|
+
return GhostScore(
|
|
53
|
+
value=0,
|
|
54
|
+
label=SCORE_LABELS[0][1],
|
|
55
|
+
breakdown={cat: 0 for cat in GhostCategory},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Calculate raw score with weighted contributions
|
|
59
|
+
raw_score = 0.0
|
|
60
|
+
breakdown: dict[GhostCategory, int] = {cat: 0 for cat in GhostCategory}
|
|
61
|
+
|
|
62
|
+
for ghost in ghosts:
|
|
63
|
+
weight = CATEGORY_WEIGHTS.get(ghost.category, 3)
|
|
64
|
+
multiplier = SEVERITY_MULTIPLIER.get(ghost.severity, 1.0)
|
|
65
|
+
contribution = weight * multiplier
|
|
66
|
+
raw_score += contribution
|
|
67
|
+
breakdown[ghost.category] = breakdown.get(ghost.category, 0) + 1
|
|
68
|
+
|
|
69
|
+
# Cap at 100
|
|
70
|
+
final_score = min(int(raw_score), 100)
|
|
71
|
+
|
|
72
|
+
# Find the appropriate label
|
|
73
|
+
label = SCORE_LABELS[-1][1] # Default to worst
|
|
74
|
+
for threshold, score_label in SCORE_LABELS:
|
|
75
|
+
if final_score <= threshold:
|
|
76
|
+
label = score_label
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
return GhostScore(
|
|
80
|
+
value=final_score,
|
|
81
|
+
label=label,
|
|
82
|
+
breakdown=breakdown,
|
|
83
|
+
)
|