ghostbuster-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,345 @@
1
+ """Zombie Code Scanner — detects functions and classes that are never called.
2
+
3
+ Uses AST analysis to find definitions and cross-reference them against
4
+ all name references in the codebase.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+
13
+ from ghostbuster.core.models import Ghost, GhostCategory, Severity
14
+
15
+ # Decorators that indicate a function is used externally (not dead)
16
+ FRAMEWORK_DECORATORS: set[str] = {
17
+ # Web frameworks
18
+ "route",
19
+ "get",
20
+ "post",
21
+ "put",
22
+ "delete",
23
+ "patch",
24
+ "head",
25
+ "options",
26
+ "api_view",
27
+ "action",
28
+ "app_route",
29
+ # Testing
30
+ "fixture",
31
+ "pytest_fixture",
32
+ "parametrize",
33
+ "override_settings",
34
+ "mock_patch",
35
+ # General
36
+ "property",
37
+ "staticmethod",
38
+ "classmethod",
39
+ "abstractmethod",
40
+ "cached_property",
41
+ "lru_cache",
42
+ "cache",
43
+ "register",
44
+ "receiver",
45
+ "hook",
46
+ "hookimpl",
47
+ "validator",
48
+ "field_validator",
49
+ "model_validator",
50
+ "event",
51
+ "listener",
52
+ "subscriber",
53
+ "handler",
54
+ "task",
55
+ "shared_task",
56
+ "periodic_task",
57
+ "command",
58
+ "group",
59
+ "callback",
60
+ "click_command",
61
+ "click_group",
62
+ "app_command",
63
+ "app_group",
64
+ "overload",
65
+ }
66
+
67
+ # Method names that are expected to exist by convention (dunder, lifecycle, etc.)
68
+ CONVENTIONAL_NAMES: set[str] = {
69
+ # Dunder methods
70
+ "__init__",
71
+ "__new__",
72
+ "__del__",
73
+ "__repr__",
74
+ "__str__",
75
+ "__bytes__",
76
+ "__format__",
77
+ "__hash__",
78
+ "__bool__",
79
+ "__len__",
80
+ "__length_hint__",
81
+ "__getitem__",
82
+ "__setitem__",
83
+ "__delitem__",
84
+ "__iter__",
85
+ "__next__",
86
+ "__reversed__",
87
+ "__contains__",
88
+ "__add__",
89
+ "__radd__",
90
+ "__iadd__",
91
+ "__sub__",
92
+ "__mul__",
93
+ "__truediv__",
94
+ "__floordiv__",
95
+ "__mod__",
96
+ "__pow__",
97
+ "__lshift__",
98
+ "__rshift__",
99
+ "__and__",
100
+ "__xor__",
101
+ "__or__",
102
+ "__neg__",
103
+ "__pos__",
104
+ "__abs__",
105
+ "__invert__",
106
+ "__complex__",
107
+ "__int__",
108
+ "__float__",
109
+ "__index__",
110
+ "__enter__",
111
+ "__exit__",
112
+ "__await__",
113
+ "__aiter__",
114
+ "__anext__",
115
+ "__aenter__",
116
+ "__aexit__",
117
+ "__call__",
118
+ "__get__",
119
+ "__set__",
120
+ "__delete__",
121
+ "__set_name__",
122
+ "__init_subclass__",
123
+ "__class_getitem__",
124
+ "__eq__",
125
+ "__ne__",
126
+ "__lt__",
127
+ "__le__",
128
+ "__gt__",
129
+ "__ge__",
130
+ "__getattr__",
131
+ "__getattribute__",
132
+ "__setattr__",
133
+ "__delattr__",
134
+ "__dir__",
135
+ "__slots__",
136
+ "__dict__",
137
+ "__weakref__",
138
+ "__reduce__",
139
+ "__reduce_ex__",
140
+ "__getstate__",
141
+ "__setstate__",
142
+ "__copy__",
143
+ "__deepcopy__",
144
+ "__sizeof__",
145
+ "__fspath__",
146
+ "__missing__",
147
+ "__post_init__",
148
+ # Lifecycle / Framework hooks
149
+ "setUp",
150
+ "tearDown",
151
+ "setUpClass",
152
+ "tearDownClass",
153
+ "setUpModule",
154
+ "tearDownModule",
155
+ "setup_method",
156
+ "teardown_method",
157
+ "main",
158
+ }
159
+
160
+
161
+ @dataclass
162
+ class _Definition:
163
+ """A function or class definition found in the codebase."""
164
+
165
+ name: str
166
+ file_path: Path
167
+ line_number: int
168
+ is_method: bool = False
169
+ is_class: bool = False
170
+ has_framework_decorator: bool = False
171
+
172
+
173
+ class ZombieCodeScanner:
174
+ """Detects functions and classes that are never called/referenced."""
175
+
176
+ name = "zombie-code"
177
+
178
+ def scan(self, path: Path) -> list[Ghost]:
179
+ """Scan a project directory for zombie code."""
180
+ definitions: list[_Definition] = []
181
+ references: set[str] = set()
182
+
183
+ for py_file in path.rglob("*.py"):
184
+ # Skip non-source directories
185
+ parts = py_file.relative_to(path).parts
186
+ if any(
187
+ p
188
+ in {
189
+ "venv",
190
+ ".venv",
191
+ "node_modules",
192
+ ".git",
193
+ "__pycache__",
194
+ ".tox",
195
+ ".nox",
196
+ "build",
197
+ "dist",
198
+ "migrations",
199
+ ".eggs",
200
+ }
201
+ for p in parts
202
+ ):
203
+ continue
204
+
205
+ try:
206
+ source = py_file.read_text(encoding="utf-8", errors="ignore")
207
+ tree = ast.parse(source, filename=str(py_file))
208
+ except (SyntaxError, OSError):
209
+ continue
210
+
211
+ # Collect definitions and references from this file
212
+ file_defs, file_refs = self._analyze_file(tree, py_file)
213
+ definitions.extend(file_defs)
214
+ references.update(file_refs)
215
+
216
+ # Find zombies: defined but never referenced
217
+ ghosts: list[Ghost] = []
218
+ for defn in definitions:
219
+ if self._is_zombie(defn, references):
220
+ rel_path = (
221
+ defn.file_path.relative_to(path)
222
+ if path in defn.file_path.parents or path == defn.file_path.parent
223
+ else defn.file_path
224
+ )
225
+ kind = "Class" if defn.is_class else "Function"
226
+ action = "referenced" if defn.is_class else "called"
227
+ ghosts.append(
228
+ Ghost(
229
+ category=GhostCategory.ZOMBIE_CODE,
230
+ name=defn.name,
231
+ message=f"{kind} '{defn.name}' is defined at {rel_path}:{defn.line_number} but never {action}",
232
+ file_path=defn.file_path,
233
+ line_number=defn.line_number,
234
+ severity=Severity.LOW,
235
+ fixable=False,
236
+ suggestion=f"Remove '{defn.name}' if it's truly unused, or add '# noqa: ghostbuster' to suppress",
237
+ )
238
+ )
239
+
240
+ return ghosts
241
+
242
+ def _analyze_file(
243
+ self, tree: ast.Module, file_path: Path
244
+ ) -> tuple[list[_Definition], set[str]]:
245
+ """Extract function/class definitions and name references from a file."""
246
+ definitions: list[_Definition] = []
247
+ references: set[str] = set()
248
+
249
+ # Check if this is a test file (test files are excluded from zombie detection)
250
+ is_test_file = file_path.name.startswith("test_") or file_path.name.endswith("_test.py")
251
+
252
+ for node in ast.walk(tree):
253
+ # Collect function definitions
254
+ if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
255
+ # Skip test functions entirely
256
+ if is_test_file or node.name.startswith("test_"):
257
+ references.add(node.name)
258
+ continue
259
+
260
+ is_method = self._is_method(node, tree)
261
+ has_decorator = self._has_framework_decorator(node)
262
+
263
+ definitions.append(
264
+ _Definition(
265
+ name=node.name,
266
+ file_path=file_path,
267
+ line_number=node.lineno,
268
+ is_method=is_method,
269
+ has_framework_decorator=has_decorator,
270
+ )
271
+ )
272
+
273
+ # Collect class definitions
274
+ elif isinstance(node, ast.ClassDef):
275
+ if not is_test_file:
276
+ definitions.append(
277
+ _Definition(
278
+ name=node.name,
279
+ file_path=file_path,
280
+ line_number=node.lineno,
281
+ is_class=True,
282
+ )
283
+ )
284
+
285
+ # Collect references (names being used)
286
+ elif isinstance(node, ast.Name):
287
+ references.add(node.id)
288
+ elif isinstance(node, ast.Attribute):
289
+ references.add(node.attr)
290
+ elif isinstance(node, ast.Call):
291
+ if isinstance(node.func, ast.Name):
292
+ references.add(node.func.id)
293
+ elif isinstance(node.func, ast.Attribute):
294
+ references.add(node.func.attr)
295
+
296
+ return definitions, references
297
+
298
+ def _is_method(self, node: ast.FunctionDef | ast.AsyncFunctionDef, tree: ast.Module) -> bool:
299
+ """Check if a function is defined inside a class (i.e., is a method)."""
300
+ for parent in ast.walk(tree):
301
+ if isinstance(parent, ast.ClassDef):
302
+ for child in parent.body:
303
+ if child is node:
304
+ return True
305
+ return False
306
+
307
+ def _has_framework_decorator(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
308
+ """Check if a function has a decorator that indicates external usage."""
309
+ for decorator in node.decorator_list:
310
+ dec_name = self._get_decorator_name(decorator)
311
+ if dec_name and dec_name.lower() in FRAMEWORK_DECORATORS:
312
+ return True
313
+ return False
314
+
315
+ def _get_decorator_name(self, node: ast.expr) -> str | None:
316
+ """Extract the base name from a decorator node."""
317
+ if isinstance(node, ast.Name):
318
+ return node.id
319
+ elif isinstance(node, ast.Attribute):
320
+ return node.attr
321
+ elif isinstance(node, ast.Call):
322
+ return self._get_decorator_name(node.func)
323
+ return None
324
+
325
+ def _is_zombie(self, defn: _Definition, references: set[str]) -> bool:
326
+ """Determine if a definition is truly a zombie (unreferenced)."""
327
+ # Skip conventional names (dunders, lifecycle hooks)
328
+ if defn.name in CONVENTIONAL_NAMES:
329
+ return False
330
+
331
+ # Skip names starting with underscore in test patterns
332
+ if defn.name.startswith("test_"):
333
+ return False
334
+
335
+ # Skip if it has a framework decorator
336
+ if defn.has_framework_decorator:
337
+ return False
338
+
339
+ # Skip private methods with a single underscore (often used by framework)
340
+ # But flag double-underscore non-dunder names
341
+ if defn.is_method and defn.name.startswith("_") and not defn.name.startswith("__"):
342
+ return False
343
+
344
+ # The core check: is the name referenced anywhere?
345
+ return defn.name not in references
@@ -0,0 +1 @@
1
+ """Auto-fix modules for resolving ghost findings."""
@@ -0,0 +1,133 @@
1
+ """Import Fixer — removes unused import statements from Python files.
2
+
3
+ Works with the dead-import scanner findings to automatically clean up
4
+ import statements that reference packages no longer needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ast
10
+ from pathlib import Path
11
+
12
+ from ghostbuster.core.models import Ghost, GhostCategory
13
+
14
+
15
+ class ImportFixer:
16
+ """Removes unused import statements from Python files.
17
+
18
+ This fixer works on a per-file basis, removing import lines that
19
+ reference packages identified as dead imports.
20
+ """
21
+
22
+ def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
23
+ """Preview what changes would be made (dry-run mode).
24
+
25
+ Returns a list of human-readable descriptions of changes.
26
+ """
27
+ changes: list[str] = []
28
+ dead_packages = self._get_dead_package_names(ghosts)
29
+
30
+ for py_file in path.rglob("*.py"):
31
+ parts = py_file.relative_to(path).parts
32
+ if any(p in {"venv", ".venv", "node_modules", ".git", "__pycache__"} for p in parts):
33
+ continue
34
+
35
+ file_changes = self._find_removable_imports(py_file, dead_packages)
36
+ changes.extend(file_changes)
37
+
38
+ return changes
39
+
40
+ def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
41
+ """Actually apply fixes: remove unused import statements.
42
+
43
+ Returns a list of applied changes.
44
+ """
45
+ applied: list[str] = []
46
+ dead_packages = self._get_dead_package_names(ghosts)
47
+
48
+ for py_file in path.rglob("*.py"):
49
+ parts = py_file.relative_to(path).parts
50
+ if any(p in {"venv", ".venv", "node_modules", ".git", "__pycache__"} for p in parts):
51
+ continue
52
+
53
+ result = self._remove_imports_from_file(py_file, dead_packages)
54
+ applied.extend(result)
55
+
56
+ return applied
57
+
58
+ def _get_dead_package_names(self, ghosts: list[Ghost]) -> set[str]:
59
+ """Extract package names from dead-import ghost findings."""
60
+ return {
61
+ g.name.lower().replace("-", "_")
62
+ for g in ghosts
63
+ if g.category == GhostCategory.DEAD_IMPORT
64
+ }
65
+
66
+ def _find_removable_imports(self, filepath: Path, dead_packages: set[str]) -> list[str]:
67
+ """Find import statements that can be removed from a file."""
68
+ changes: list[str] = []
69
+ try:
70
+ source = filepath.read_text(encoding="utf-8", errors="ignore")
71
+ tree = ast.parse(source, filename=str(filepath))
72
+ except (SyntaxError, OSError):
73
+ return changes
74
+
75
+ for node in ast.walk(tree):
76
+ if isinstance(node, ast.Import):
77
+ for alias in node.names:
78
+ pkg = alias.name.split(".")[0].lower().replace("-", "_")
79
+ if pkg in dead_packages:
80
+ changes.append(
81
+ f" Would remove: 'import {alias.name}' from {filepath.name}:{node.lineno}"
82
+ )
83
+ elif isinstance(node, ast.ImportFrom) and node.module:
84
+ pkg = node.module.split(".")[0].lower().replace("-", "_")
85
+ if pkg in dead_packages:
86
+ names = ", ".join(a.name for a in node.names)
87
+ changes.append(
88
+ f" Would remove: 'from {node.module} import {names}' from {filepath.name}:{node.lineno}"
89
+ )
90
+
91
+ return changes
92
+
93
+ def _remove_imports_from_file(self, filepath: Path, dead_packages: set[str]) -> list[str]:
94
+ """Remove import statements for dead packages from a single file."""
95
+ applied: list[str] = []
96
+ try:
97
+ source = filepath.read_text(encoding="utf-8", errors="ignore")
98
+ tree = ast.parse(source, filename=str(filepath))
99
+ except (SyntaxError, OSError):
100
+ return applied
101
+
102
+ lines_to_remove: set[int] = set()
103
+
104
+ for node in ast.walk(tree):
105
+ if isinstance(node, ast.Import):
106
+ for alias in node.names:
107
+ pkg = alias.name.split(".")[0].lower().replace("-", "_")
108
+ if pkg in dead_packages:
109
+ lines_to_remove.add(node.lineno)
110
+ applied.append(
111
+ f" Removed: 'import {alias.name}' from {filepath.name}:{node.lineno}"
112
+ )
113
+ elif isinstance(node, ast.ImportFrom) and node.module:
114
+ pkg = node.module.split(".")[0].lower().replace("-", "_")
115
+ if pkg in dead_packages:
116
+ lines_to_remove.add(node.lineno)
117
+ # Handle multi-line imports
118
+ if node.end_lineno and node.end_lineno > node.lineno:
119
+ for ln in range(node.lineno, node.end_lineno + 1):
120
+ lines_to_remove.add(ln)
121
+ names = ", ".join(a.name for a in node.names)
122
+ applied.append(
123
+ f" Removed: 'from {node.module} import {names}' from {filepath.name}:{node.lineno}"
124
+ )
125
+
126
+ if lines_to_remove:
127
+ source_lines = source.splitlines(keepends=True)
128
+ new_lines = [
129
+ line for i, line in enumerate(source_lines, start=1) if i not in lines_to_remove
130
+ ]
131
+ filepath.write_text("".join(new_lines), encoding="utf-8")
132
+
133
+ return applied