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/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
+ }