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/config.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Configuration loading for ghostbuster.
|
|
2
|
+
|
|
3
|
+
Supports configuration from:
|
|
4
|
+
- .ghostbuster.toml (project root)
|
|
5
|
+
- pyproject.toml [tool.ghostbuster] section
|
|
6
|
+
- CLI flags (highest priority)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class GhostbusterConfig:
|
|
18
|
+
"""Configuration for a ghostbuster scan.
|
|
19
|
+
|
|
20
|
+
All fields have sensible defaults so ghostbuster works with zero config.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
# Directories to exclude from scanning
|
|
24
|
+
exclude_dirs: list[str] = field(
|
|
25
|
+
default_factory=lambda: [
|
|
26
|
+
"venv",
|
|
27
|
+
".venv",
|
|
28
|
+
"node_modules",
|
|
29
|
+
".git",
|
|
30
|
+
"__pycache__",
|
|
31
|
+
".tox",
|
|
32
|
+
".nox",
|
|
33
|
+
"build",
|
|
34
|
+
"dist",
|
|
35
|
+
".eggs",
|
|
36
|
+
"migrations",
|
|
37
|
+
]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# File patterns to exclude from scanning
|
|
41
|
+
exclude_patterns: list[str] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
# Categories to scan (empty = all)
|
|
44
|
+
categories: list[str] = field(default_factory=list)
|
|
45
|
+
|
|
46
|
+
# Packages to ignore in dead-import scanning
|
|
47
|
+
ignore_packages: list[str] = field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
# Env vars to ignore in phantom-env scanning
|
|
50
|
+
ignore_env_vars: list[str] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
# Functions/classes to ignore in zombie-code scanning
|
|
53
|
+
ignore_names: list[str] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
# Large file threshold in bytes (default: 10MB)
|
|
56
|
+
large_file_threshold: int = 10 * 1024 * 1024
|
|
57
|
+
|
|
58
|
+
@staticmethod
|
|
59
|
+
def load(path: Path) -> GhostbusterConfig:
|
|
60
|
+
"""Load configuration from project files.
|
|
61
|
+
|
|
62
|
+
Priority:
|
|
63
|
+
1. .ghostbuster.toml
|
|
64
|
+
2. pyproject.toml [tool.ghostbuster]
|
|
65
|
+
3. Defaults
|
|
66
|
+
"""
|
|
67
|
+
config = GhostbusterConfig()
|
|
68
|
+
|
|
69
|
+
# Try .ghostbuster.toml first
|
|
70
|
+
ghostbuster_toml = path / ".ghostbuster.toml"
|
|
71
|
+
if ghostbuster_toml.exists():
|
|
72
|
+
data = _load_toml(ghostbuster_toml)
|
|
73
|
+
config = _apply_config(config, data)
|
|
74
|
+
return config
|
|
75
|
+
|
|
76
|
+
# Try pyproject.toml [tool.ghostbuster]
|
|
77
|
+
pyproject = path / "pyproject.toml"
|
|
78
|
+
if pyproject.exists():
|
|
79
|
+
data = _load_toml(pyproject)
|
|
80
|
+
tool_config = data.get("tool", {}).get("ghostbuster", {})
|
|
81
|
+
if tool_config:
|
|
82
|
+
config = _apply_config(config, tool_config)
|
|
83
|
+
|
|
84
|
+
return config
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _load_toml(filepath: Path) -> dict:
|
|
88
|
+
"""Load a TOML file and return its contents as a dict."""
|
|
89
|
+
try:
|
|
90
|
+
if sys.version_info >= (3, 11):
|
|
91
|
+
import tomllib
|
|
92
|
+
else:
|
|
93
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
94
|
+
|
|
95
|
+
return tomllib.loads(filepath.read_text(encoding="utf-8"))
|
|
96
|
+
except Exception:
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _apply_config(config: GhostbusterConfig, data: dict) -> GhostbusterConfig:
|
|
101
|
+
"""Apply a dict of config values to a GhostbusterConfig."""
|
|
102
|
+
if "exclude_dirs" in data:
|
|
103
|
+
config.exclude_dirs = data["exclude_dirs"]
|
|
104
|
+
if "exclude_patterns" in data:
|
|
105
|
+
config.exclude_patterns = data["exclude_patterns"]
|
|
106
|
+
if "categories" in data:
|
|
107
|
+
config.categories = data["categories"]
|
|
108
|
+
if "ignore_packages" in data:
|
|
109
|
+
config.ignore_packages = data["ignore_packages"]
|
|
110
|
+
if "ignore_env_vars" in data:
|
|
111
|
+
config.ignore_env_vars = data["ignore_env_vars"]
|
|
112
|
+
if "ignore_names" in data:
|
|
113
|
+
config.ignore_names = data["ignore_names"]
|
|
114
|
+
if "large_file_threshold" in data:
|
|
115
|
+
config.large_file_threshold = data["large_file_threshold"]
|
|
116
|
+
return config
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core scanning and analysis engine — no CLI/UI dependencies."""
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Dead Import Scanner — detects dependencies declared but never imported.
|
|
2
|
+
|
|
3
|
+
Checks requirements.txt, pyproject.toml, and setup.cfg for declared
|
|
4
|
+
dependencies, then walks all Python files to see which are actually imported.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import re
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from ghostbuster.core.models import Ghost, GhostCategory, Severity
|
|
15
|
+
|
|
16
|
+
# Mapping from PyPI package names to their actual import names.
|
|
17
|
+
# Many packages have different install names vs import names.
|
|
18
|
+
PACKAGE_TO_IMPORT: dict[str, str | list[str]] = {
|
|
19
|
+
"pillow": "PIL",
|
|
20
|
+
"scikit-learn": "sklearn",
|
|
21
|
+
"scikit-image": "skimage",
|
|
22
|
+
"opencv-python": "cv2",
|
|
23
|
+
"opencv-python-headless": "cv2",
|
|
24
|
+
"python-dateutil": "dateutil",
|
|
25
|
+
"pyyaml": "yaml",
|
|
26
|
+
"beautifulsoup4": "bs4",
|
|
27
|
+
"python-dotenv": "dotenv",
|
|
28
|
+
"pymysql": "pymysql",
|
|
29
|
+
"attrs": "attr",
|
|
30
|
+
"google-cloud-storage": "google.cloud.storage",
|
|
31
|
+
"google-auth": "google.auth",
|
|
32
|
+
"python-jose": "jose",
|
|
33
|
+
"python-multipart": "multipart",
|
|
34
|
+
"ujson": "ujson",
|
|
35
|
+
"msgpack": "msgpack",
|
|
36
|
+
"setuptools": ["setuptools", "pkg_resources"],
|
|
37
|
+
"typing-extensions": "typing_extensions",
|
|
38
|
+
"importlib-metadata": "importlib_metadata",
|
|
39
|
+
"importlib-resources": "importlib_resources",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# Packages that are commonly used as CLI tools or plugins, not imported directly.
|
|
43
|
+
# These should not be flagged as dead imports.
|
|
44
|
+
TOOL_PACKAGES: set[str] = {
|
|
45
|
+
"pip",
|
|
46
|
+
"setuptools",
|
|
47
|
+
"wheel",
|
|
48
|
+
"build",
|
|
49
|
+
"twine",
|
|
50
|
+
"ruff",
|
|
51
|
+
"black",
|
|
52
|
+
"isort",
|
|
53
|
+
"flake8",
|
|
54
|
+
"mypy",
|
|
55
|
+
"pytest",
|
|
56
|
+
"pytest-cov",
|
|
57
|
+
"pytest-xdist",
|
|
58
|
+
"pytest-asyncio",
|
|
59
|
+
"pre-commit",
|
|
60
|
+
"tox",
|
|
61
|
+
"nox",
|
|
62
|
+
"sphinx",
|
|
63
|
+
"ipython",
|
|
64
|
+
"ipdb",
|
|
65
|
+
"debugpy",
|
|
66
|
+
"hatchling",
|
|
67
|
+
"hatch",
|
|
68
|
+
"poetry",
|
|
69
|
+
"flit",
|
|
70
|
+
"pdm",
|
|
71
|
+
"autopep8",
|
|
72
|
+
"pyright",
|
|
73
|
+
"pylint",
|
|
74
|
+
"bandit",
|
|
75
|
+
"safety",
|
|
76
|
+
"coverage",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class DeadImportScanner:
|
|
81
|
+
"""Detects dependencies declared in config but never imported in code."""
|
|
82
|
+
|
|
83
|
+
name = "dead-import"
|
|
84
|
+
|
|
85
|
+
def scan(self, path: Path) -> list[Ghost]:
|
|
86
|
+
"""Scan a project directory for dead imports."""
|
|
87
|
+
declared = self._find_declared_dependencies(path)
|
|
88
|
+
if not declared:
|
|
89
|
+
return []
|
|
90
|
+
|
|
91
|
+
imported = self._find_all_imports(path)
|
|
92
|
+
ghosts: list[Ghost] = []
|
|
93
|
+
|
|
94
|
+
for dep_name, source_file in declared:
|
|
95
|
+
normalized = self._normalize_package_name(dep_name)
|
|
96
|
+
|
|
97
|
+
# Skip known tool-only packages
|
|
98
|
+
if normalized in TOOL_PACKAGES:
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
# Get possible import names for this package
|
|
102
|
+
import_names = self._get_import_names(normalized)
|
|
103
|
+
|
|
104
|
+
# Check if any of the possible import names are actually used
|
|
105
|
+
is_used = any(
|
|
106
|
+
self._import_matches(imp, name) for imp in imported for name in import_names
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
if not is_used:
|
|
110
|
+
ghosts.append(
|
|
111
|
+
Ghost(
|
|
112
|
+
category=GhostCategory.DEAD_IMPORT,
|
|
113
|
+
name=dep_name,
|
|
114
|
+
message=f"Package '{dep_name}' is declared in {source_file} but never imported",
|
|
115
|
+
file_path=path / source_file,
|
|
116
|
+
severity=Severity.MEDIUM,
|
|
117
|
+
fixable=True,
|
|
118
|
+
suggestion=f"Remove '{dep_name}' from {source_file}",
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return ghosts
|
|
123
|
+
|
|
124
|
+
def _normalize_package_name(self, name: str) -> str:
|
|
125
|
+
"""Normalize a package name: lowercase, hyphens to underscores, strip extras."""
|
|
126
|
+
# Remove version specifiers and extras
|
|
127
|
+
name = re.split(r"[>=<!\[;]", name)[0].strip()
|
|
128
|
+
return name.lower().replace("-", "_")
|
|
129
|
+
|
|
130
|
+
def _get_import_names(self, normalized: str) -> list[str]:
|
|
131
|
+
"""Get all possible import names for a normalized package name."""
|
|
132
|
+
# Check our known mapping
|
|
133
|
+
original = normalized.replace("_", "-")
|
|
134
|
+
if original in PACKAGE_TO_IMPORT:
|
|
135
|
+
mapping = PACKAGE_TO_IMPORT[original]
|
|
136
|
+
if isinstance(mapping, list):
|
|
137
|
+
return [m.lower().replace("-", "_") for m in mapping]
|
|
138
|
+
return [mapping.lower().replace("-", "_")]
|
|
139
|
+
|
|
140
|
+
if normalized in PACKAGE_TO_IMPORT:
|
|
141
|
+
mapping = PACKAGE_TO_IMPORT[normalized]
|
|
142
|
+
if isinstance(mapping, list):
|
|
143
|
+
return [m.lower().replace("-", "_") for m in mapping]
|
|
144
|
+
return [mapping.lower().replace("-", "_")]
|
|
145
|
+
|
|
146
|
+
# Default: the normalized name itself is the import name
|
|
147
|
+
return [normalized]
|
|
148
|
+
|
|
149
|
+
def _import_matches(self, imported: str, expected: str) -> bool:
|
|
150
|
+
"""Check if an imported module matches an expected package name."""
|
|
151
|
+
imported_lower = imported.lower().replace("-", "_")
|
|
152
|
+
expected_lower = expected.lower().replace("-", "_")
|
|
153
|
+
# Exact match or sub-module import (e.g., "google.cloud.storage" matches "google")
|
|
154
|
+
return imported_lower == expected_lower or imported_lower.startswith(expected_lower + ".")
|
|
155
|
+
|
|
156
|
+
def _find_declared_dependencies(self, path: Path) -> list[tuple[str, str]]:
|
|
157
|
+
"""Find all declared dependencies from project config files.
|
|
158
|
+
|
|
159
|
+
Returns list of (package_name, source_file) tuples.
|
|
160
|
+
"""
|
|
161
|
+
deps: list[tuple[str, str]] = []
|
|
162
|
+
|
|
163
|
+
# Check requirements.txt
|
|
164
|
+
req_file = path / "requirements.txt"
|
|
165
|
+
if req_file.exists():
|
|
166
|
+
deps.extend(self._parse_requirements_txt(req_file))
|
|
167
|
+
|
|
168
|
+
# Check pyproject.toml
|
|
169
|
+
pyproject = path / "pyproject.toml"
|
|
170
|
+
if pyproject.exists():
|
|
171
|
+
deps.extend(self._parse_pyproject_toml(pyproject))
|
|
172
|
+
|
|
173
|
+
return deps
|
|
174
|
+
|
|
175
|
+
def _parse_requirements_txt(self, filepath: Path) -> list[tuple[str, str]]:
|
|
176
|
+
"""Parse a requirements.txt file for package names."""
|
|
177
|
+
deps: list[tuple[str, str]] = []
|
|
178
|
+
try:
|
|
179
|
+
for line in filepath.read_text(encoding="utf-8").splitlines():
|
|
180
|
+
line = line.strip()
|
|
181
|
+
# Skip comments, empty lines, and options
|
|
182
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
183
|
+
continue
|
|
184
|
+
# Extract package name (before any version specifier)
|
|
185
|
+
name = re.split(r"[>=<!\[;@]", line)[0].strip()
|
|
186
|
+
if name:
|
|
187
|
+
deps.append((name, "requirements.txt"))
|
|
188
|
+
except OSError:
|
|
189
|
+
pass
|
|
190
|
+
return deps
|
|
191
|
+
|
|
192
|
+
def _parse_pyproject_toml(self, filepath: Path) -> list[tuple[str, str]]:
|
|
193
|
+
"""Parse pyproject.toml for declared dependencies."""
|
|
194
|
+
deps: list[tuple[str, str]] = []
|
|
195
|
+
try:
|
|
196
|
+
if sys.version_info >= (3, 11):
|
|
197
|
+
import tomllib
|
|
198
|
+
else:
|
|
199
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
200
|
+
|
|
201
|
+
data = tomllib.loads(filepath.read_text(encoding="utf-8"))
|
|
202
|
+
project_deps = data.get("project", {}).get("dependencies", [])
|
|
203
|
+
for dep in project_deps:
|
|
204
|
+
name = re.split(r"[>=<!\[;@]", dep)[0].strip()
|
|
205
|
+
if name:
|
|
206
|
+
deps.append((name, "pyproject.toml"))
|
|
207
|
+
|
|
208
|
+
# Also check optional dependencies
|
|
209
|
+
optional = data.get("project", {}).get("optional-dependencies", {})
|
|
210
|
+
for group_name, group_deps in optional.items():
|
|
211
|
+
for dep in group_deps:
|
|
212
|
+
name = re.split(r"[>=<!\[;@]", dep)[0].strip()
|
|
213
|
+
if name:
|
|
214
|
+
deps.append((name, f"pyproject.toml [optional: {group_name}]"))
|
|
215
|
+
|
|
216
|
+
# Support Poetry: [tool.poetry.dependencies]
|
|
217
|
+
poetry_deps = data.get("tool", {}).get("poetry", {}).get("dependencies", {})
|
|
218
|
+
if isinstance(poetry_deps, dict):
|
|
219
|
+
for dep_name in poetry_deps:
|
|
220
|
+
if dep_name.lower() != "python":
|
|
221
|
+
deps.append((dep_name, "pyproject.toml [poetry.dependencies]"))
|
|
222
|
+
|
|
223
|
+
# Support Poetry groups: [tool.poetry.group.<group>.dependencies]
|
|
224
|
+
poetry_groups = data.get("tool", {}).get("poetry", {}).get("group", {})
|
|
225
|
+
if isinstance(poetry_groups, dict):
|
|
226
|
+
for group_name, group_data in poetry_groups.items():
|
|
227
|
+
if isinstance(group_data, dict):
|
|
228
|
+
g_deps = group_data.get("dependencies", {})
|
|
229
|
+
if isinstance(g_deps, dict):
|
|
230
|
+
for dep_name in g_deps:
|
|
231
|
+
if dep_name.lower() != "python":
|
|
232
|
+
deps.append(
|
|
233
|
+
(dep_name, f"pyproject.toml [poetry.group.{group_name}]")
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
except (OSError, Exception):
|
|
237
|
+
pass
|
|
238
|
+
return deps
|
|
239
|
+
|
|
240
|
+
def _find_all_imports(self, path: Path) -> set[str]:
|
|
241
|
+
"""Walk all .py files and collect all imported module names via AST."""
|
|
242
|
+
imports: set[str] = set()
|
|
243
|
+
for py_file in path.rglob("*.py"):
|
|
244
|
+
# Skip common non-source directories
|
|
245
|
+
parts = py_file.relative_to(path).parts
|
|
246
|
+
if any(
|
|
247
|
+
p
|
|
248
|
+
in {
|
|
249
|
+
"venv",
|
|
250
|
+
".venv",
|
|
251
|
+
"node_modules",
|
|
252
|
+
".git",
|
|
253
|
+
"__pycache__",
|
|
254
|
+
".tox",
|
|
255
|
+
".nox",
|
|
256
|
+
"build",
|
|
257
|
+
"dist",
|
|
258
|
+
}
|
|
259
|
+
for p in parts
|
|
260
|
+
):
|
|
261
|
+
continue
|
|
262
|
+
try:
|
|
263
|
+
source = py_file.read_text(encoding="utf-8", errors="ignore")
|
|
264
|
+
tree = ast.parse(source, filename=str(py_file))
|
|
265
|
+
for node in ast.walk(tree):
|
|
266
|
+
if isinstance(node, ast.Import):
|
|
267
|
+
for alias in node.names:
|
|
268
|
+
imports.add(alias.name)
|
|
269
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
270
|
+
imports.add(node.module)
|
|
271
|
+
except (SyntaxError, OSError):
|
|
272
|
+
continue
|
|
273
|
+
return imports
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Data models for ghost findings, scan results, and scoring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import enum
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class GhostCategory(str, enum.Enum):
|
|
11
|
+
"""Categories of ghosts that can haunt a codebase."""
|
|
12
|
+
|
|
13
|
+
DEAD_IMPORT = "dead-import"
|
|
14
|
+
ORPHAN_FILE = "orphan-file"
|
|
15
|
+
ZOMBIE_CODE = "zombie-code"
|
|
16
|
+
PHANTOM_ENV = "phantom-env"
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def emoji(self) -> str:
|
|
20
|
+
"""Return category tag for display."""
|
|
21
|
+
return ""
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def label(self) -> str:
|
|
25
|
+
"""Return a human-readable label for each category."""
|
|
26
|
+
return {
|
|
27
|
+
GhostCategory.DEAD_IMPORT: "Dead Import",
|
|
28
|
+
GhostCategory.ORPHAN_FILE: "Orphan File",
|
|
29
|
+
GhostCategory.ZOMBIE_CODE: "Zombie Code",
|
|
30
|
+
GhostCategory.PHANTOM_ENV: "Phantom Env",
|
|
31
|
+
}[self]
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def description(self) -> str:
|
|
35
|
+
"""Return a short description for each category."""
|
|
36
|
+
return {
|
|
37
|
+
GhostCategory.DEAD_IMPORT: "Dependency declared but never imported",
|
|
38
|
+
GhostCategory.ORPHAN_FILE: "File/folder that should be in .gitignore",
|
|
39
|
+
GhostCategory.ZOMBIE_CODE: "Function or class that is never called",
|
|
40
|
+
GhostCategory.PHANTOM_ENV: "Env var referenced but never set",
|
|
41
|
+
}[self]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Severity(str, enum.Enum):
|
|
45
|
+
"""How severe this finding is."""
|
|
46
|
+
|
|
47
|
+
LOW = "low"
|
|
48
|
+
MEDIUM = "medium"
|
|
49
|
+
HIGH = "high"
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def emoji(self) -> str:
|
|
53
|
+
return ""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class Ghost:
|
|
58
|
+
"""A single ghost finding in the codebase.
|
|
59
|
+
|
|
60
|
+
This is the atomic unit of a scan result. Each ghost represents one
|
|
61
|
+
specific issue found (e.g., one unused import, one orphan file).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
category: GhostCategory
|
|
65
|
+
name: str
|
|
66
|
+
message: str
|
|
67
|
+
file_path: Path | None = None
|
|
68
|
+
line_number: int | None = None
|
|
69
|
+
severity: Severity = Severity.MEDIUM
|
|
70
|
+
fixable: bool = False
|
|
71
|
+
suggestion: str = ""
|
|
72
|
+
|
|
73
|
+
def to_dict(self) -> dict[str, object]:
|
|
74
|
+
"""Serialize to a JSON-compatible dictionary."""
|
|
75
|
+
return {
|
|
76
|
+
"category": self.category.value,
|
|
77
|
+
"name": self.name,
|
|
78
|
+
"message": self.message,
|
|
79
|
+
"file_path": str(self.file_path) if self.file_path else None,
|
|
80
|
+
"line_number": self.line_number,
|
|
81
|
+
"severity": self.severity.value,
|
|
82
|
+
"fixable": self.fixable,
|
|
83
|
+
"suggestion": self.suggestion,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class GhostScore:
|
|
89
|
+
"""The overall 'hauntedness' score for a codebase.
|
|
90
|
+
|
|
91
|
+
Score ranges from 0 (clean) to 100 (extremely haunted).
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
value: int
|
|
95
|
+
label: str
|
|
96
|
+
breakdown: dict[GhostCategory, int] = field(default_factory=dict)
|
|
97
|
+
|
|
98
|
+
def to_dict(self) -> dict[str, object]:
|
|
99
|
+
"""Serialize to a JSON-compatible dictionary."""
|
|
100
|
+
return {
|
|
101
|
+
"score": self.value,
|
|
102
|
+
"label": self.label,
|
|
103
|
+
"breakdown": {k.value: v for k, v in self.breakdown.items()},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class ScanResult:
|
|
109
|
+
"""Complete result of a ghostbuster scan."""
|
|
110
|
+
|
|
111
|
+
ghosts: list[Ghost] = field(default_factory=list)
|
|
112
|
+
score: GhostScore | None = None
|
|
113
|
+
scanned_path: Path | None = None
|
|
114
|
+
duration_ms: float = 0.0
|
|
115
|
+
|
|
116
|
+
@property
|
|
117
|
+
def ghost_count(self) -> int:
|
|
118
|
+
return len(self.ghosts)
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def fixable_count(self) -> int:
|
|
122
|
+
return sum(1 for g in self.ghosts if g.fixable)
|
|
123
|
+
|
|
124
|
+
def ghosts_by_category(self) -> dict[GhostCategory, list[Ghost]]:
|
|
125
|
+
"""Group ghosts by their category."""
|
|
126
|
+
result: dict[GhostCategory, list[Ghost]] = {}
|
|
127
|
+
for ghost in self.ghosts:
|
|
128
|
+
result.setdefault(ghost.category, []).append(ghost)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
def to_dict(self) -> dict[str, object]:
|
|
132
|
+
"""Serialize to a JSON-compatible dictionary."""
|
|
133
|
+
return {
|
|
134
|
+
"ghosts": [g.to_dict() for g in self.ghosts],
|
|
135
|
+
"ghost_count": self.ghost_count,
|
|
136
|
+
"fixable_count": self.fixable_count,
|
|
137
|
+
"score": self.score.to_dict() if self.score else None,
|
|
138
|
+
"scanned_path": str(self.scanned_path) if self.scanned_path else None,
|
|
139
|
+
"duration_ms": round(self.duration_ms, 2),
|
|
140
|
+
}
|