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.
Files changed (43) hide show
  1. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/CHANGELOG.md +12 -0
  2. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/PKG-INFO +6 -7
  3. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/README.md +5 -6
  4. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/pyproject.toml +1 -1
  5. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/__init__.py +1 -1
  6. ghostbuster_cli-0.2.2/src/ghostbuster/fixers/import_fixer.py +217 -0
  7. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_cli.py +1 -1
  8. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_fixers.py +33 -0
  9. ghostbuster_cli-0.2.0/src/ghostbuster/fixers/import_fixer.py +0 -133
  10. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/.github/workflows/ci.yml +0 -0
  11. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/.github/workflows/publish.yml +0 -0
  12. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/.gitignore +0 -0
  13. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/.pre-commit-hooks.yaml +0 -0
  14. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/CONTRIBUTING.md +0 -0
  15. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/LICENSE +0 -0
  16. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/assets/npm_package_icon.png +0 -0
  17. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/assets/readme_header.png +0 -0
  18. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/assets/terminal_icon.png +0 -0
  19. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/__main__.py +0 -0
  20. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/__init__.py +0 -0
  21. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/app.py +0 -0
  22. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/bust.py +0 -0
  23. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/display.py +0 -0
  24. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/cli/scan.py +0 -0
  25. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/config.py +0 -0
  26. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/__init__.py +0 -0
  27. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/dead_imports.py +0 -0
  28. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/models.py +0 -0
  29. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/orphan_files.py +0 -0
  30. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/phantom_env.py +0 -0
  31. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/scanner.py +0 -0
  32. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/scoring.py +0 -0
  33. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/core/zombie_code.py +0 -0
  34. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/fixers/__init__.py +0 -0
  35. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/fixers/env_fixer.py +0 -0
  36. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/src/ghostbuster/fixers/gitignore_fixer.py +0 -0
  37. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/__init__.py +0 -0
  38. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/conftest.py +0 -0
  39. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_dead_imports.py +0 -0
  40. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_orphan_files.py +0 -0
  41. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_phantom_env.py +0 -0
  42. {ghostbuster_cli-0.2.0 → ghostbuster_cli-0.2.2}/tests/test_scoring.py +0 -0
  43. {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.0
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
- [![PyPI version](https://img.shields.io/pypi/v/ghostbuster-cli?color=blue&label=PyPI)](https://pypi.org/project/ghostbuster-cli/)
48
- [![Python](https://img.shields.io/pypi/pyversions/ghostbuster-cli)](https://pypi.org/project/ghostbuster-cli/)
47
+ [![PyPI version](https://img.shields.io/pypi/v/ghostbuster-cli?color=blue&label=PyPI&logo=pypi)](https://pypi.org/project/ghostbuster-cli/)
48
+ [![Python](https://img.shields.io/pypi/pyversions/ghostbuster-cli?logo=python)](https://pypi.org/project/ghostbuster-cli/)
49
49
  [![CI](https://github.com/adewanggar/ghostbuster-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/adewanggar/ghostbuster-cli/actions)
50
50
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
51
- [![Downloads](https://img.shields.io/pypi/dm/ghostbuster-cli)](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
- [![PyPI version](https://img.shields.io/pypi/v/ghostbuster-cli?color=blue&label=PyPI)](https://pypi.org/project/ghostbuster-cli/)
10
- [![Python](https://img.shields.io/pypi/pyversions/ghostbuster-cli)](https://pypi.org/project/ghostbuster-cli/)
9
+ [![PyPI version](https://img.shields.io/pypi/v/ghostbuster-cli?color=blue&label=PyPI&logo=pypi)](https://pypi.org/project/ghostbuster-cli/)
10
+ [![Python](https://img.shields.io/pypi/pyversions/ghostbuster-cli?logo=python)](https://pypi.org/project/ghostbuster-cli/)
11
11
  [![CI](https://github.com/adewanggar/ghostbuster-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/adewanggar/ghostbuster-cli/actions)
12
12
  [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
13
- [![Downloads](https://img.shields.io/pypi/dm/ghostbuster-cli)](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.0"
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"
@@ -1,3 +1,3 @@
1
1
  """Ghostbuster — Find and bust the ghosts haunting your codebase."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
@@ -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.0" in result.output
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