ghostbuster-cli 0.2.0__tar.gz → 0.2.2__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.2.0 → ghostbuster_cli-0.2.2}/CHANGELOG.md +12 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/PKG-INFO +6 -7
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/README.md +5 -6
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/pyproject.toml +1 -1
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/__init__.py +1 -1
- ghostbuster_cli-0.2.2/src/ghostbuster/fixers/import_fixer.py +217 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_cli.py +1 -1
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_fixers.py +33 -0
- ghostbuster_cli-0.2.0/src/ghostbuster/fixers/import_fixer.py +0 -133
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/.github/workflows/ci.yml +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/.github/workflows/publish.yml +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/.gitignore +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/.pre-commit-hooks.yaml +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/CONTRIBUTING.md +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/LICENSE +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/assets/npm_package_icon.png +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/assets/readme_header.png +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/assets/terminal_icon.png +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/__main__.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/__init__.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/app.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/bust.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/display.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/scan.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/config.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/__init__.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/dead_imports.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/models.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/orphan_files.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/phantom_env.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/scanner.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/scoring.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/zombie_code.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/fixers/__init__.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/fixers/env_fixer.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/fixers/gitignore_fixer.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/__init__.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/conftest.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_dead_imports.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_orphan_files.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_phantom_env.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_scoring.py +0 -0
- {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_zombie_code.py +0 -0
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.2] - 2026-08-29
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Use absolute GitHub raw asset URLs in `README.md` so headers and icons render properly on PyPI and third-party package viewers.
|
|
15
|
+
|
|
16
|
+
## [0.2.1] - 2026-08-29
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
- `ghostbuster bust --confirm` now properly removes unused dependencies from `requirements.txt` and `pyproject.toml`.
|
|
21
|
+
|
|
10
22
|
## [0.2.0] - 2026-08-29
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostbuster-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
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
|
|
@@ -38,17 +38,16 @@ Description-Content-Type: text/markdown
|
|
|
38
38
|
|
|
39
39
|
<div align="center">
|
|
40
40
|
|
|
41
|
-
<img src="assets/readme_header.png" alt="Ghostbuster Header" width="100%" />
|
|
41
|
+
<img src="https://raw.githubusercontent.com/adewanggar/ghostbuster-cli/main/assets/readme_header.png" alt="Ghostbuster Header" width="100%" />
|
|
42
42
|
|
|
43
43
|
# Ghostbuster
|
|
44
44
|
|
|
45
45
|
**Find and bust the ghosts haunting your codebase.**
|
|
46
46
|
|
|
47
|
-
[](https://pypi.org/project/ghostbuster-cli/)
|
|
48
|
-
[](https://pypi.org/project/ghostbuster-cli/)
|
|
47
|
+
[](https://pypi.org/project/ghostbuster-cli/)
|
|
48
|
+
[](https://pypi.org/project/ghostbuster-cli/)
|
|
49
49
|
[](https://github.com/adewanggar/ghostbuster-cli/actions)
|
|
50
50
|
[](https://opensource.org/licenses/MIT)
|
|
51
|
-
[](https://pypi.org/project/ghostbuster-cli/)
|
|
52
51
|
|
|
53
52
|
Unused dependencies, dead functions, orphan files, and phantom environment variables lurk in codebases - slowing down repos, confusing new contributors, and wasting CI minutes.
|
|
54
53
|
|
|
@@ -58,7 +57,7 @@ Unused dependencies, dead functions, orphan files, and phantom environment varia
|
|
|
58
57
|
|
|
59
58
|
---
|
|
60
59
|
|
|
61
|
-
## <img src="assets/terminal_icon.png" width="24" height="24" valign="middle" /> Quick Start
|
|
60
|
+
## <img src="https://raw.githubusercontent.com/adewanggar/ghostbuster-cli/main/assets/terminal_icon.png" width="24" height="24" valign="middle" /> Quick Start
|
|
62
61
|
|
|
63
62
|
```bash
|
|
64
63
|
# Install
|
|
@@ -226,7 +225,7 @@ Ghostbuster uses **pure AST analysis** - no runtime imports, no code execution,
|
|
|
226
225
|
|
|
227
226
|
## Roadmap
|
|
228
227
|
|
|
229
|
-
- <img src="assets/npm_package_icon.png" width="18" height="18" valign="middle" /> Node.js / TypeScript support (scan `package.json`, dead exports)
|
|
228
|
+
- <img src="https://raw.githubusercontent.com/adewanggar/ghostbuster-cli/main/assets/npm_package_icon.png" width="18" height="18" valign="middle" /> Node.js / TypeScript support (scan `package.json`, dead exports)
|
|
230
229
|
- Pre-commit hook integration
|
|
231
230
|
- GitHub Actions reporter (comment Ghost Score on PRs)
|
|
232
231
|
- Config inheritance for monorepos
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
<img src="assets/readme_header.png" alt="Ghostbuster Header" width="100%" />
|
|
3
|
+
<img src="https://raw.githubusercontent.com/adewanggar/ghostbuster-cli/main/assets/readme_header.png" alt="Ghostbuster Header" width="100%" />
|
|
4
4
|
|
|
5
5
|
# Ghostbuster
|
|
6
6
|
|
|
7
7
|
**Find and bust the ghosts haunting your codebase.**
|
|
8
8
|
|
|
9
|
-
[](https://pypi.org/project/ghostbuster-cli/)
|
|
10
|
-
[](https://pypi.org/project/ghostbuster-cli/)
|
|
9
|
+
[](https://pypi.org/project/ghostbuster-cli/)
|
|
10
|
+
[](https://pypi.org/project/ghostbuster-cli/)
|
|
11
11
|
[](https://github.com/adewanggar/ghostbuster-cli/actions)
|
|
12
12
|
[](https://opensource.org/licenses/MIT)
|
|
13
|
-
[](https://pypi.org/project/ghostbuster-cli/)
|
|
14
13
|
|
|
15
14
|
Unused dependencies, dead functions, orphan files, and phantom environment variables lurk in codebases - slowing down repos, confusing new contributors, and wasting CI minutes.
|
|
16
15
|
|
|
@@ -20,7 +19,7 @@ Unused dependencies, dead functions, orphan files, and phantom environment varia
|
|
|
20
19
|
|
|
21
20
|
---
|
|
22
21
|
|
|
23
|
-
## <img src="assets/terminal_icon.png" width="24" height="24" valign="middle" /> Quick Start
|
|
22
|
+
## <img src="https://raw.githubusercontent.com/adewanggar/ghostbuster-cli/main/assets/terminal_icon.png" width="24" height="24" valign="middle" /> Quick Start
|
|
24
23
|
|
|
25
24
|
```bash
|
|
26
25
|
# Install
|
|
@@ -188,7 +187,7 @@ Ghostbuster uses **pure AST analysis** - no runtime imports, no code execution,
|
|
|
188
187
|
|
|
189
188
|
## Roadmap
|
|
190
189
|
|
|
191
|
-
- <img src="assets/npm_package_icon.png" width="18" height="18" valign="middle" /> Node.js / TypeScript support (scan `package.json`, dead exports)
|
|
190
|
+
- <img src="https://raw.githubusercontent.com/adewanggar/ghostbuster-cli/main/assets/npm_package_icon.png" width="18" height="18" valign="middle" /> Node.js / TypeScript support (scan `package.json`, dead exports)
|
|
192
191
|
- Pre-commit hook integration
|
|
193
192
|
- GitHub Actions reporter (comment Ghost Score on PRs)
|
|
194
193
|
- Config inheritance for monorepos
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostbuster-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
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"
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Import Fixer - removes unused packages from requirements/pyproject and unused import statements."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ghostbuster.core.models import Ghost, GhostCategory
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ImportFixer:
|
|
13
|
+
"""Removes unused dependencies from config files and unused import statements."""
|
|
14
|
+
|
|
15
|
+
def preview(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
16
|
+
"""Preview what changes would be made (dry-run mode)."""
|
|
17
|
+
changes: list[str] = []
|
|
18
|
+
dead_ghosts = [g for g in ghosts if g.category == GhostCategory.DEAD_IMPORT and g.fixable]
|
|
19
|
+
if not dead_ghosts:
|
|
20
|
+
return changes
|
|
21
|
+
|
|
22
|
+
# 1. Preview removing from requirements.txt and pyproject.toml
|
|
23
|
+
changes.extend(self._preview_config_removals(dead_ghosts))
|
|
24
|
+
|
|
25
|
+
# 2. Preview removing unused import statements from Python files
|
|
26
|
+
dead_packages = self._get_dead_package_names(dead_ghosts)
|
|
27
|
+
for py_file in path.rglob("*.py"):
|
|
28
|
+
parts = py_file.relative_to(path).parts
|
|
29
|
+
if any(p in {"venv", ".venv", "node_modules", ".git", "__pycache__"} for p in parts):
|
|
30
|
+
continue
|
|
31
|
+
|
|
32
|
+
file_changes = self._find_removable_imports(py_file, dead_packages)
|
|
33
|
+
changes.extend(file_changes)
|
|
34
|
+
|
|
35
|
+
return changes
|
|
36
|
+
|
|
37
|
+
def fix(self, ghosts: list[Ghost], path: Path) -> list[str]:
|
|
38
|
+
"""Apply fixes: remove unused dependencies from config and import statements."""
|
|
39
|
+
applied: list[str] = []
|
|
40
|
+
dead_ghosts = [g for g in ghosts if g.category == GhostCategory.DEAD_IMPORT and g.fixable]
|
|
41
|
+
if not dead_ghosts:
|
|
42
|
+
return applied
|
|
43
|
+
|
|
44
|
+
# 1. Remove from requirements.txt and pyproject.toml
|
|
45
|
+
applied.extend(self._apply_config_removals(dead_ghosts))
|
|
46
|
+
|
|
47
|
+
# 2. Remove unused import statements from Python files
|
|
48
|
+
dead_packages = self._get_dead_package_names(dead_ghosts)
|
|
49
|
+
for py_file in path.rglob("*.py"):
|
|
50
|
+
parts = py_file.relative_to(path).parts
|
|
51
|
+
if any(p in {"venv", ".venv", "node_modules", ".git", "__pycache__"} for p in parts):
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
result = self._remove_imports_from_file(py_file, dead_packages)
|
|
55
|
+
applied.extend(result)
|
|
56
|
+
|
|
57
|
+
return applied
|
|
58
|
+
|
|
59
|
+
def _get_dead_package_names(self, ghosts: list[Ghost]) -> set[str]:
|
|
60
|
+
"""Extract package names from dead-import ghost findings."""
|
|
61
|
+
return {
|
|
62
|
+
g.name.lower().replace("-", "_")
|
|
63
|
+
for g in ghosts
|
|
64
|
+
if g.category == GhostCategory.DEAD_IMPORT
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
def _preview_config_removals(self, ghosts: list[Ghost]) -> list[str]:
|
|
68
|
+
"""Preview removal of dependencies from requirements.txt and pyproject.toml."""
|
|
69
|
+
changes: list[str] = []
|
|
70
|
+
for ghost in ghosts:
|
|
71
|
+
if ghost.file_path and ghost.file_path.exists():
|
|
72
|
+
changes.append(f" Would remove '{ghost.name}' from {ghost.file_path.name}")
|
|
73
|
+
return changes
|
|
74
|
+
|
|
75
|
+
def _apply_config_removals(self, ghosts: list[Ghost]) -> list[str]:
|
|
76
|
+
"""Remove declared dependencies from requirements.txt and pyproject.toml."""
|
|
77
|
+
applied: list[str] = []
|
|
78
|
+
|
|
79
|
+
# Group by config file
|
|
80
|
+
by_file: dict[Path, list[str]] = {}
|
|
81
|
+
for ghost in ghosts:
|
|
82
|
+
if ghost.file_path and ghost.file_path.exists():
|
|
83
|
+
by_file.setdefault(ghost.file_path, []).append(ghost.name)
|
|
84
|
+
|
|
85
|
+
for file_path, package_names in by_file.items():
|
|
86
|
+
if file_path.name == "requirements.txt" or file_path.name.endswith(".txt"):
|
|
87
|
+
removed = self._remove_from_requirements_txt(file_path, package_names)
|
|
88
|
+
for pkg in removed:
|
|
89
|
+
applied.append(f" Removed '{pkg}' from {file_path.name}")
|
|
90
|
+
elif file_path.name == "pyproject.toml":
|
|
91
|
+
removed = self._remove_from_pyproject_toml(file_path, package_names)
|
|
92
|
+
for pkg in removed:
|
|
93
|
+
applied.append(f" Removed '{pkg}' from {file_path.name}")
|
|
94
|
+
|
|
95
|
+
return applied
|
|
96
|
+
|
|
97
|
+
def _remove_from_requirements_txt(self, filepath: Path, package_names: list[str]) -> list[str]:
|
|
98
|
+
"""Remove package entries from a requirements.txt file."""
|
|
99
|
+
removed: list[str] = []
|
|
100
|
+
try:
|
|
101
|
+
lines = filepath.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
102
|
+
norm_targets = {p.lower().replace("-", "_") for p in package_names}
|
|
103
|
+
|
|
104
|
+
new_lines: list[str] = []
|
|
105
|
+
for line in lines:
|
|
106
|
+
stripped = line.strip()
|
|
107
|
+
if not stripped or stripped.startswith("#") or stripped.startswith("-"):
|
|
108
|
+
new_lines.append(line)
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
pkg_name = re.split(r"[>=<!\[;@]", stripped)[0].strip()
|
|
112
|
+
norm_pkg = pkg_name.lower().replace("-", "_")
|
|
113
|
+
|
|
114
|
+
if norm_pkg in norm_targets:
|
|
115
|
+
removed.append(pkg_name)
|
|
116
|
+
else:
|
|
117
|
+
new_lines.append(line)
|
|
118
|
+
|
|
119
|
+
if removed:
|
|
120
|
+
filepath.write_text("".join(new_lines), encoding="utf-8")
|
|
121
|
+
except OSError:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
return removed
|
|
125
|
+
|
|
126
|
+
def _remove_from_pyproject_toml(self, filepath: Path, package_names: list[str]) -> list[str]:
|
|
127
|
+
"""Remove package entries from dependencies list in pyproject.toml."""
|
|
128
|
+
removed: list[str] = []
|
|
129
|
+
try:
|
|
130
|
+
lines = filepath.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
131
|
+
norm_targets = {p.lower().replace("-", "_") for p in package_names}
|
|
132
|
+
|
|
133
|
+
new_lines: list[str] = []
|
|
134
|
+
for line in lines:
|
|
135
|
+
stripped = line.strip().strip(",").strip('"').strip("'")
|
|
136
|
+
pkg_name = re.split(r"[>=<!\[;@]", stripped)[0].strip()
|
|
137
|
+
norm_pkg = pkg_name.lower().replace("-", "_")
|
|
138
|
+
|
|
139
|
+
if norm_pkg in norm_targets and ("=" not in stripped or "[" not in stripped):
|
|
140
|
+
removed.append(pkg_name)
|
|
141
|
+
else:
|
|
142
|
+
new_lines.append(line)
|
|
143
|
+
|
|
144
|
+
if removed:
|
|
145
|
+
filepath.write_text("".join(new_lines), encoding="utf-8")
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
return removed
|
|
150
|
+
|
|
151
|
+
def _find_removable_imports(self, filepath: Path, dead_packages: set[str]) -> list[str]:
|
|
152
|
+
"""Find import statements that can be removed from a file."""
|
|
153
|
+
changes: list[str] = []
|
|
154
|
+
try:
|
|
155
|
+
source = filepath.read_text(encoding="utf-8", errors="ignore")
|
|
156
|
+
tree = ast.parse(source, filename=str(filepath))
|
|
157
|
+
except (SyntaxError, OSError):
|
|
158
|
+
return changes
|
|
159
|
+
|
|
160
|
+
for node in ast.walk(tree):
|
|
161
|
+
if isinstance(node, ast.Import):
|
|
162
|
+
for alias in node.names:
|
|
163
|
+
pkg = alias.name.split(".")[0].lower().replace("-", "_")
|
|
164
|
+
if pkg in dead_packages:
|
|
165
|
+
changes.append(
|
|
166
|
+
f" Would remove: 'import {alias.name}' from {filepath.name}:{node.lineno}"
|
|
167
|
+
)
|
|
168
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
169
|
+
pkg = node.module.split(".")[0].lower().replace("-", "_")
|
|
170
|
+
if pkg in dead_packages:
|
|
171
|
+
names = ", ".join(a.name for a in node.names)
|
|
172
|
+
changes.append(
|
|
173
|
+
f" Would remove: 'from {node.module} import {names}' from {filepath.name}:{node.lineno}"
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return changes
|
|
177
|
+
|
|
178
|
+
def _remove_imports_from_file(self, filepath: Path, dead_packages: set[str]) -> list[str]:
|
|
179
|
+
"""Remove import statements for dead packages from a single file."""
|
|
180
|
+
applied: list[str] = []
|
|
181
|
+
try:
|
|
182
|
+
source = filepath.read_text(encoding="utf-8", errors="ignore")
|
|
183
|
+
tree = ast.parse(source, filename=str(filepath))
|
|
184
|
+
except (SyntaxError, OSError):
|
|
185
|
+
return applied
|
|
186
|
+
|
|
187
|
+
lines_to_remove: set[int] = set()
|
|
188
|
+
|
|
189
|
+
for node in ast.walk(tree):
|
|
190
|
+
if isinstance(node, ast.Import):
|
|
191
|
+
for alias in node.names:
|
|
192
|
+
pkg = alias.name.split(".")[0].lower().replace("-", "_")
|
|
193
|
+
if pkg in dead_packages:
|
|
194
|
+
lines_to_remove.add(node.lineno)
|
|
195
|
+
applied.append(
|
|
196
|
+
f" Removed: 'import {alias.name}' from {filepath.name}:{node.lineno}"
|
|
197
|
+
)
|
|
198
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
199
|
+
pkg = node.module.split(".")[0].lower().replace("-", "_")
|
|
200
|
+
if pkg in dead_packages:
|
|
201
|
+
lines_to_remove.add(node.lineno)
|
|
202
|
+
if node.end_lineno and node.end_lineno > node.lineno:
|
|
203
|
+
for ln in range(node.lineno, node.end_lineno + 1):
|
|
204
|
+
lines_to_remove.add(ln)
|
|
205
|
+
names = ", ".join(a.name for a in node.names)
|
|
206
|
+
applied.append(
|
|
207
|
+
f" Removed: 'from {node.module} import {names}' from {filepath.name}:{node.lineno}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
if lines_to_remove:
|
|
211
|
+
source_lines = source.splitlines(keepends=True)
|
|
212
|
+
new_lines = [
|
|
213
|
+
line for i, line in enumerate(source_lines, start=1) if i not in lines_to_remove
|
|
214
|
+
]
|
|
215
|
+
filepath.write_text("".join(new_lines), encoding="utf-8")
|
|
216
|
+
|
|
217
|
+
return applied
|
|
@@ -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.2.
|
|
20
|
+
assert "0.2.2" in result.output
|
|
21
21
|
|
|
22
22
|
def test_help_flag(self) -> None:
|
|
23
23
|
"""--help should print help text."""
|
|
@@ -7,6 +7,39 @@ from pathlib import Path
|
|
|
7
7
|
from ghostbuster.core.models import Ghost, GhostCategory, Severity
|
|
8
8
|
from ghostbuster.fixers.env_fixer import EnvFixer
|
|
9
9
|
from ghostbuster.fixers.gitignore_fixer import GitignoreFixer
|
|
10
|
+
from ghostbuster.fixers.import_fixer import ImportFixer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestImportFixer:
|
|
14
|
+
"""Tests for ImportFixer."""
|
|
15
|
+
|
|
16
|
+
def test_remove_dead_import_from_requirements_txt(self, tmp_path: Path) -> None:
|
|
17
|
+
"""Should remove dead package from requirements.txt."""
|
|
18
|
+
req_file = tmp_path / "requirements.txt"
|
|
19
|
+
req_file.write_text("requests==2.28.0\nlxml>=4.9.0\npyyaml\n", encoding="utf-8")
|
|
20
|
+
|
|
21
|
+
ghosts = [
|
|
22
|
+
Ghost(
|
|
23
|
+
category=GhostCategory.DEAD_IMPORT,
|
|
24
|
+
name="lxml",
|
|
25
|
+
message="lxml is unused",
|
|
26
|
+
file_path=req_file,
|
|
27
|
+
severity=Severity.MEDIUM,
|
|
28
|
+
fixable=True,
|
|
29
|
+
),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
fixer = ImportFixer()
|
|
33
|
+
preview = fixer.preview(ghosts, tmp_path)
|
|
34
|
+
assert any("lxml" in p for p in preview)
|
|
35
|
+
|
|
36
|
+
applied = fixer.fix(ghosts, tmp_path)
|
|
37
|
+
assert len(applied) == 1
|
|
38
|
+
|
|
39
|
+
content = req_file.read_text(encoding="utf-8")
|
|
40
|
+
assert "lxml" not in content
|
|
41
|
+
assert "requests==2.28.0" in content
|
|
42
|
+
assert "pyyaml" in content
|
|
10
43
|
|
|
11
44
|
|
|
12
45
|
class TestGitignoreFixer:
|
|
@@ -1,133 +0,0 @@
|
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|