ghostbuster-cli 0.2.2__tar.gz → 0.4.0__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.4.0/.github/FUNDING.yml +4 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/.github/workflows/publish.yml +2 -3
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/CHANGELOG.md +22 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/PKG-INFO +25 -6
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/README.md +24 -5
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/pyproject.toml +1 -1
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/__init__.py +1 -1
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/scan.py +28 -1
- ghostbuster_cli-0.4.0/src/ghostbuster/core/dead_imports.py +416 -0
- ghostbuster_cli-0.4.0/src/ghostbuster/core/git_diff.py +80 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/orphan_files.py +3 -0
- ghostbuster_cli-0.4.0/src/ghostbuster/core/phantom_env.py +246 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/scanner.py +15 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/fixers/import_fixer.py +26 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/test_cli.py +1 -1
- ghostbuster_cli-0.4.0/tests/test_git_diff.py +114 -0
- ghostbuster_cli-0.4.0/tests/test_node_scanner.py +191 -0
- ghostbuster_cli-0.2.2/src/ghostbuster/core/dead_imports.py +0 -273
- ghostbuster_cli-0.2.2/src/ghostbuster/core/phantom_env.py +0 -196
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/.github/workflows/ci.yml +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/.gitignore +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/.pre-commit-hooks.yaml +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/CONTRIBUTING.md +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/LICENSE +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/assets/npm_package_icon.png +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/assets/readme_header.png +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/assets/terminal_icon.png +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/__main__.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/__init__.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/app.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/bust.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/cli/display.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/config.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/__init__.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/models.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/scoring.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/core/zombie_code.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/fixers/__init__.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/fixers/env_fixer.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/src/ghostbuster/fixers/gitignore_fixer.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/__init__.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/conftest.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/test_dead_imports.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/test_fixers.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/test_orphan_files.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/test_phantom_env.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/test_scoring.py +0 -0
- {ghostbuster_cli-0.2.2 → ghostbuster_cli-0.4.0}/tests/test_zombie_code.py +0 -0
|
@@ -3,9 +3,6 @@ name: Publish to PyPI
|
|
|
3
3
|
on:
|
|
4
4
|
release:
|
|
5
5
|
types: [published]
|
|
6
|
-
push:
|
|
7
|
-
tags:
|
|
8
|
-
- "v*"
|
|
9
6
|
|
|
10
7
|
jobs:
|
|
11
8
|
pypi-publish:
|
|
@@ -33,3 +30,5 @@ jobs:
|
|
|
33
30
|
|
|
34
31
|
- name: Publish package distributions to PyPI
|
|
35
32
|
uses: pypa/gh-action-pypi-publish@release/v1
|
|
33
|
+
with:
|
|
34
|
+
skip-existing: true
|
|
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-08-29
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Git Diff / Incremental Fast Scan Mode (`ghostbuster scan --diff` / `-d`)**:
|
|
15
|
+
- Scan only modified, staged, and untracked files instead of the entire project tree.
|
|
16
|
+
- Added `--diff-base` flag to compare working branch against a target ref (e.g. `ghostbuster scan --diff-base origin/main`).
|
|
17
|
+
- Ideal for monorepos and GitHub Actions PR validation.
|
|
18
|
+
- Zero false positives: global reference verification remains intact for zombie code.
|
|
19
|
+
|
|
20
|
+
## [0.3.0] - 2026-08-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **Node.js, JavaScript, and TypeScript Support**:
|
|
25
|
+
- **Dead Imports**: Scans `package.json` (`dependencies`, `devDependencies`, `peerDependencies`) and verifies against imports in `.js`, `.mjs`, `.cjs`, `.jsx`, `.ts`, `.mts`, `.cts`, `.tsx`, `.vue`, and `.svelte` files.
|
|
26
|
+
- **Scoped Packages & Subpaths**: Supports `@scope/pkg` and `pkg/subpath` imports.
|
|
27
|
+
- **Phantom Env**: Scans `process.env.VAR`, `process.env['VAR']`, `import.meta.env.VITE_VAR`, and `NEXT_PUBLIC_VAR`.
|
|
28
|
+
- **Auto-Fix for `package.json`**: `ghostbuster bust --confirm` automatically removes dead dependencies from `package.json` with preserved JSON formatting.
|
|
29
|
+
- **Orphan Files**: Added detection for Node log files (`npm-debug.log`, `yarn-error.log`, `pnpm-debug.log`).
|
|
30
|
+
- **Full-Stack Scanning**: Simultaneously scans Python backends and JS/TS frontends in monorepos with a unified Ghost Score.
|
|
31
|
+
|
|
10
32
|
## [0.2.2] - 2026-08-29
|
|
11
33
|
|
|
12
34
|
### Fixed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ghostbuster-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
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
|
|
@@ -128,6 +128,18 @@ ghostbuster scan ./my-project
|
|
|
128
128
|
ghostbuster scan -v
|
|
129
129
|
```
|
|
130
130
|
|
|
131
|
+
### Incremental / Git Diff Scan
|
|
132
|
+
|
|
133
|
+
Scan only files that were modified, staged, or untracked in your working tree:
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
# Scan modified files only (fast mode)
|
|
137
|
+
ghostbuster scan --diff
|
|
138
|
+
|
|
139
|
+
# Compare working branch against main/master
|
|
140
|
+
ghostbuster scan --diff-base origin/main
|
|
141
|
+
```
|
|
142
|
+
|
|
131
143
|
### Filtered Scan
|
|
132
144
|
|
|
133
145
|
```bash
|
|
@@ -225,11 +237,11 @@ Ghostbuster uses **pure AST analysis** - no runtime imports, no code execution,
|
|
|
225
237
|
|
|
226
238
|
## Roadmap
|
|
227
239
|
|
|
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 (
|
|
229
|
-
- Pre-commit hook integration
|
|
230
|
-
- GitHub Actions reporter (comment Ghost Score on PRs)
|
|
231
|
-
- Config inheritance for monorepos
|
|
232
|
-
- Ghost Score history tracking & trend chart
|
|
240
|
+
- [x] <img src="https://raw.githubusercontent.com/adewanggar/ghostbuster-cli/main/assets/npm_package_icon.png" width="18" height="18" valign="middle" /> Node.js / JavaScript / TypeScript support (`package.json`, `process.env`, `import.meta.env`)
|
|
241
|
+
- [x] Pre-commit hook integration (`.pre-commit-hooks.yaml`)
|
|
242
|
+
- [ ] GitHub Actions reporter (comment Ghost Score on PRs)
|
|
243
|
+
- [ ] Config inheritance for monorepos
|
|
244
|
+
- [ ] Ghost Score history tracking & trend chart
|
|
233
245
|
|
|
234
246
|
## Contributing
|
|
235
247
|
|
|
@@ -243,6 +255,13 @@ pip install pytest ruff mypy
|
|
|
243
255
|
pytest tests/ -v
|
|
244
256
|
```
|
|
245
257
|
|
|
258
|
+
## Support & Sponsor
|
|
259
|
+
|
|
260
|
+
If Ghostbuster helped clean up your codebase or saved CI minutes, consider supporting the project:
|
|
261
|
+
|
|
262
|
+
[](https://ko-fi.com/adewanggar)
|
|
263
|
+
[](https://saweria.co/adewanggar)
|
|
264
|
+
|
|
246
265
|
## License
|
|
247
266
|
|
|
248
267
|
MIT (c) Ghostbuster Contributors
|
|
@@ -90,6 +90,18 @@ ghostbuster scan ./my-project
|
|
|
90
90
|
ghostbuster scan -v
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
### Incremental / Git Diff Scan
|
|
94
|
+
|
|
95
|
+
Scan only files that were modified, staged, or untracked in your working tree:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# Scan modified files only (fast mode)
|
|
99
|
+
ghostbuster scan --diff
|
|
100
|
+
|
|
101
|
+
# Compare working branch against main/master
|
|
102
|
+
ghostbuster scan --diff-base origin/main
|
|
103
|
+
```
|
|
104
|
+
|
|
93
105
|
### Filtered Scan
|
|
94
106
|
|
|
95
107
|
```bash
|
|
@@ -187,11 +199,11 @@ Ghostbuster uses **pure AST analysis** - no runtime imports, no code execution,
|
|
|
187
199
|
|
|
188
200
|
## Roadmap
|
|
189
201
|
|
|
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 (
|
|
191
|
-
- Pre-commit hook integration
|
|
192
|
-
- GitHub Actions reporter (comment Ghost Score on PRs)
|
|
193
|
-
- Config inheritance for monorepos
|
|
194
|
-
- Ghost Score history tracking & trend chart
|
|
202
|
+
- [x] <img src="https://raw.githubusercontent.com/adewanggar/ghostbuster-cli/main/assets/npm_package_icon.png" width="18" height="18" valign="middle" /> Node.js / JavaScript / TypeScript support (`package.json`, `process.env`, `import.meta.env`)
|
|
203
|
+
- [x] Pre-commit hook integration (`.pre-commit-hooks.yaml`)
|
|
204
|
+
- [ ] GitHub Actions reporter (comment Ghost Score on PRs)
|
|
205
|
+
- [ ] Config inheritance for monorepos
|
|
206
|
+
- [ ] Ghost Score history tracking & trend chart
|
|
195
207
|
|
|
196
208
|
## Contributing
|
|
197
209
|
|
|
@@ -205,6 +217,13 @@ pip install pytest ruff mypy
|
|
|
205
217
|
pytest tests/ -v
|
|
206
218
|
```
|
|
207
219
|
|
|
220
|
+
## Support & Sponsor
|
|
221
|
+
|
|
222
|
+
If Ghostbuster helped clean up your codebase or saved CI minutes, consider supporting the project:
|
|
223
|
+
|
|
224
|
+
[](https://ko-fi.com/adewanggar)
|
|
225
|
+
[](https://saweria.co/adewanggar)
|
|
226
|
+
|
|
208
227
|
## License
|
|
209
228
|
|
|
210
229
|
MIT (c) Ghostbuster Contributors
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ghostbuster-cli"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
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"
|
|
@@ -23,6 +23,7 @@ from ghostbuster.cli.display import (
|
|
|
23
23
|
print_scan_result_markdown,
|
|
24
24
|
print_scanning_start,
|
|
25
25
|
)
|
|
26
|
+
from ghostbuster.core.git_diff import get_changed_files
|
|
26
27
|
from ghostbuster.core.models import GhostCategory
|
|
27
28
|
from ghostbuster.core.scanner import create_default_orchestrator
|
|
28
29
|
|
|
@@ -55,6 +56,17 @@ def scan(
|
|
|
55
56
|
"-c",
|
|
56
57
|
help="Only scan specific categories (dead-import, orphan-file, zombie-code, phantom-env).",
|
|
57
58
|
),
|
|
59
|
+
diff: bool = typer.Option(
|
|
60
|
+
False,
|
|
61
|
+
"--diff",
|
|
62
|
+
"-d",
|
|
63
|
+
help="Incremental fast scan: only scan modified, staged, and untracked files.",
|
|
64
|
+
),
|
|
65
|
+
diff_base: str | None = typer.Option(
|
|
66
|
+
None,
|
|
67
|
+
"--diff-base",
|
|
68
|
+
help="Compare changed files against a specific git ref/branch (e.g. main, origin/main, HEAD~1).",
|
|
69
|
+
),
|
|
58
70
|
verbose: bool = typer.Option(
|
|
59
71
|
False,
|
|
60
72
|
"--verbose",
|
|
@@ -74,6 +86,8 @@ def scan(
|
|
|
74
86
|
|
|
75
87
|
Examples:
|
|
76
88
|
ghostbuster scan
|
|
89
|
+
ghostbuster scan --diff
|
|
90
|
+
ghostbuster scan --diff-base origin/main
|
|
77
91
|
ghostbuster scan ./my-project --verbose
|
|
78
92
|
ghostbuster scan --format json --category dead-import
|
|
79
93
|
"""
|
|
@@ -91,10 +105,19 @@ def scan(
|
|
|
91
105
|
)
|
|
92
106
|
raise typer.Exit(1)
|
|
93
107
|
|
|
108
|
+
# Handle diff mode
|
|
109
|
+
changed_files: set[Path] | None = None
|
|
110
|
+
if diff or diff_base:
|
|
111
|
+
changed_files = get_changed_files(path, base_ref=diff_base)
|
|
112
|
+
|
|
94
113
|
# Show banner for rich format
|
|
95
114
|
if format == OutputFormat.rich:
|
|
96
115
|
print_banner()
|
|
97
116
|
print_scanning_start(str(path), _get_scanner_names(category))
|
|
117
|
+
if diff or diff_base:
|
|
118
|
+
console.print(
|
|
119
|
+
f" [cyan]Diff mode active ({len(changed_files or set())} changed files analyzed)[/cyan]\n"
|
|
120
|
+
)
|
|
98
121
|
|
|
99
122
|
# Run the scan
|
|
100
123
|
try:
|
|
@@ -105,7 +128,11 @@ def scan(
|
|
|
105
128
|
if format == OutputFormat.rich
|
|
106
129
|
else _nullcontext()
|
|
107
130
|
):
|
|
108
|
-
result = orchestrator.run(
|
|
131
|
+
result = orchestrator.run(
|
|
132
|
+
path,
|
|
133
|
+
categories=category,
|
|
134
|
+
changed_files=changed_files if (diff or diff_base) else None,
|
|
135
|
+
)
|
|
109
136
|
|
|
110
137
|
except Exception as exc:
|
|
111
138
|
print_error(f"Scan failed: {exc}", "Run with --debug flag for full traceback.")
|
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
"""Dead Import Scanner - detects dependencies declared but never imported.
|
|
2
|
+
|
|
3
|
+
Checks requirements.txt, pyproject.toml, and package.json for declared
|
|
4
|
+
dependencies, then walks Python, JavaScript, and TypeScript files to see which
|
|
5
|
+
are actually imported.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from ghostbuster.core.models import Ghost, GhostCategory, Severity
|
|
17
|
+
|
|
18
|
+
# Mapping from PyPI package names to their actual import names.
|
|
19
|
+
PACKAGE_TO_IMPORT: dict[str, str | list[str]] = {
|
|
20
|
+
"pillow": "PIL",
|
|
21
|
+
"scikit-learn": "sklearn",
|
|
22
|
+
"scikit-image": "skimage",
|
|
23
|
+
"opencv-python": "cv2",
|
|
24
|
+
"opencv-python-headless": "cv2",
|
|
25
|
+
"python-dateutil": "dateutil",
|
|
26
|
+
"pyyaml": "yaml",
|
|
27
|
+
"beautifulsoup4": "bs4",
|
|
28
|
+
"python-dotenv": "dotenv",
|
|
29
|
+
"pymysql": "pymysql",
|
|
30
|
+
"attrs": "attr",
|
|
31
|
+
"google-cloud-storage": "google.cloud.storage",
|
|
32
|
+
"google-auth": "google.auth",
|
|
33
|
+
"python-jose": "jose",
|
|
34
|
+
"python-multipart": "multipart",
|
|
35
|
+
"ujson": "ujson",
|
|
36
|
+
"msgpack": "msgpack",
|
|
37
|
+
"setuptools": ["setuptools", "pkg_resources"],
|
|
38
|
+
"typing-extensions": "typing_extensions",
|
|
39
|
+
"importlib-metadata": "importlib_metadata",
|
|
40
|
+
"importlib-resources": "importlib_resources",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Python tool packages that are run via CLI or plugins
|
|
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
|
+
# Node.js tool/build packages that are run via CLI or config
|
|
80
|
+
NODE_TOOL_PACKAGES: set[str] = {
|
|
81
|
+
"typescript",
|
|
82
|
+
"eslint",
|
|
83
|
+
"prettier",
|
|
84
|
+
"vite",
|
|
85
|
+
"webpack",
|
|
86
|
+
"rollup",
|
|
87
|
+
"tsup",
|
|
88
|
+
"esbuild",
|
|
89
|
+
"jest",
|
|
90
|
+
"vitest",
|
|
91
|
+
"mocha",
|
|
92
|
+
"chai",
|
|
93
|
+
"husky",
|
|
94
|
+
"lint-staged",
|
|
95
|
+
"rimraf",
|
|
96
|
+
"nodemon",
|
|
97
|
+
"concurrently",
|
|
98
|
+
"cross-env",
|
|
99
|
+
"tailwindcss",
|
|
100
|
+
"postcss",
|
|
101
|
+
"autoprefixer",
|
|
102
|
+
"dotenv-cli",
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Directories to skip when scanning source files
|
|
106
|
+
SKIP_DIRS: set[str] = {
|
|
107
|
+
"venv",
|
|
108
|
+
".venv",
|
|
109
|
+
"node_modules",
|
|
110
|
+
".git",
|
|
111
|
+
"__pycache__",
|
|
112
|
+
".tox",
|
|
113
|
+
".nox",
|
|
114
|
+
"build",
|
|
115
|
+
"dist",
|
|
116
|
+
".next",
|
|
117
|
+
".nuxt",
|
|
118
|
+
".turbo",
|
|
119
|
+
"coverage",
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
JS_TS_EXTENSIONS: set[str] = {
|
|
123
|
+
".js",
|
|
124
|
+
".mjs",
|
|
125
|
+
".cjs",
|
|
126
|
+
".jsx",
|
|
127
|
+
".ts",
|
|
128
|
+
".mts",
|
|
129
|
+
".cts",
|
|
130
|
+
".tsx",
|
|
131
|
+
".vue",
|
|
132
|
+
".svelte",
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DeadImportScanner:
|
|
137
|
+
"""Detects dependencies declared in config but never imported in code."""
|
|
138
|
+
|
|
139
|
+
name = "dead-import"
|
|
140
|
+
|
|
141
|
+
def scan(self, path: Path) -> list[Ghost]:
|
|
142
|
+
"""Scan a project directory for dead imports across Python and Node.js."""
|
|
143
|
+
declared = self._find_declared_dependencies(path)
|
|
144
|
+
if not declared:
|
|
145
|
+
return []
|
|
146
|
+
|
|
147
|
+
imported = self._find_all_imports(path)
|
|
148
|
+
ghosts: list[Ghost] = []
|
|
149
|
+
|
|
150
|
+
for dep_name, source_file in declared:
|
|
151
|
+
is_node = source_file.startswith("package.json")
|
|
152
|
+
|
|
153
|
+
if is_node:
|
|
154
|
+
# Node.js package handling
|
|
155
|
+
if self._is_node_tool_package(dep_name):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
if not self._is_node_dep_used(dep_name, imported):
|
|
159
|
+
ghosts.append(
|
|
160
|
+
Ghost(
|
|
161
|
+
category=GhostCategory.DEAD_IMPORT,
|
|
162
|
+
name=dep_name,
|
|
163
|
+
message=f"Package '{dep_name}' is declared in {source_file} but never imported",
|
|
164
|
+
file_path=path / "package.json",
|
|
165
|
+
severity=Severity.MEDIUM,
|
|
166
|
+
fixable=True,
|
|
167
|
+
suggestion=f"Remove '{dep_name}' from package.json",
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
else:
|
|
171
|
+
# Python package handling
|
|
172
|
+
normalized = self._normalize_package_name(dep_name)
|
|
173
|
+
if normalized in TOOL_PACKAGES:
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
import_names = self._get_import_names(normalized)
|
|
177
|
+
is_used = any(
|
|
178
|
+
self._import_matches(imp, name) for imp in imported for name in import_names
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if not is_used:
|
|
182
|
+
ghosts.append(
|
|
183
|
+
Ghost(
|
|
184
|
+
category=GhostCategory.DEAD_IMPORT,
|
|
185
|
+
name=dep_name,
|
|
186
|
+
message=f"Package '{dep_name}' is declared in {source_file} but never imported",
|
|
187
|
+
file_path=path / (source_file.split(" [")[0]),
|
|
188
|
+
severity=Severity.MEDIUM,
|
|
189
|
+
fixable=True,
|
|
190
|
+
suggestion=f"Remove '{dep_name}' from {source_file.split(' [')[0]}",
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
return ghosts
|
|
195
|
+
|
|
196
|
+
def _is_node_tool_package(self, pkg_name: str) -> bool:
|
|
197
|
+
"""Check if a Node package is a known CLI/type/build tool."""
|
|
198
|
+
if pkg_name.startswith("@types/"):
|
|
199
|
+
return True
|
|
200
|
+
if pkg_name.startswith("@vitejs/") or pkg_name.startswith("@babel/"):
|
|
201
|
+
return True
|
|
202
|
+
return pkg_name.lower() in NODE_TOOL_PACKAGES
|
|
203
|
+
|
|
204
|
+
def _is_node_dep_used(self, dep_name: str, imported: set[str]) -> bool:
|
|
205
|
+
"""Check if a declared Node dependency is used in imports."""
|
|
206
|
+
dep_lower = dep_name.lower()
|
|
207
|
+
for imp in imported:
|
|
208
|
+
imp_lower = imp.lower()
|
|
209
|
+
if imp_lower == dep_lower or imp_lower.startswith(dep_lower + "/"):
|
|
210
|
+
return True
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
def _normalize_package_name(self, name: str) -> str:
|
|
214
|
+
"""Normalize a Python package name: lowercase, hyphens to underscores."""
|
|
215
|
+
name = re.split(r"[>=<!\[;@]", name)[0].strip()
|
|
216
|
+
return name.lower().replace("-", "_")
|
|
217
|
+
|
|
218
|
+
def _get_import_names(self, normalized: str) -> list[str]:
|
|
219
|
+
"""Get all possible import names for a normalized package name."""
|
|
220
|
+
original = normalized.replace("_", "-")
|
|
221
|
+
if original in PACKAGE_TO_IMPORT:
|
|
222
|
+
mapping = PACKAGE_TO_IMPORT[original]
|
|
223
|
+
if isinstance(mapping, list):
|
|
224
|
+
return [m.lower().replace("-", "_") for m in mapping]
|
|
225
|
+
return [mapping.lower().replace("-", "_")]
|
|
226
|
+
|
|
227
|
+
if normalized in PACKAGE_TO_IMPORT:
|
|
228
|
+
mapping = PACKAGE_TO_IMPORT[normalized]
|
|
229
|
+
if isinstance(mapping, list):
|
|
230
|
+
return [m.lower().replace("-", "_") for m in mapping]
|
|
231
|
+
return [mapping.lower().replace("-", "_")]
|
|
232
|
+
|
|
233
|
+
return [normalized]
|
|
234
|
+
|
|
235
|
+
def _import_matches(self, imported: str, expected: str) -> bool:
|
|
236
|
+
"""Check if an imported module matches an expected package name."""
|
|
237
|
+
imported_lower = imported.lower().replace("-", "_")
|
|
238
|
+
expected_lower = expected.lower().replace("-", "_")
|
|
239
|
+
return imported_lower == expected_lower or imported_lower.startswith(expected_lower + ".")
|
|
240
|
+
|
|
241
|
+
def _find_declared_dependencies(self, path: Path) -> list[tuple[str, str]]:
|
|
242
|
+
"""Find all declared dependencies from Python and Node.js config files."""
|
|
243
|
+
deps: list[tuple[str, str]] = []
|
|
244
|
+
|
|
245
|
+
# Check requirements.txt
|
|
246
|
+
req_file = path / "requirements.txt"
|
|
247
|
+
if req_file.exists():
|
|
248
|
+
deps.extend(self._parse_requirements_txt(req_file))
|
|
249
|
+
|
|
250
|
+
# Check pyproject.toml
|
|
251
|
+
pyproject = path / "pyproject.toml"
|
|
252
|
+
if pyproject.exists():
|
|
253
|
+
deps.extend(self._parse_pyproject_toml(pyproject))
|
|
254
|
+
|
|
255
|
+
# Check package.json (Node.js)
|
|
256
|
+
package_json = path / "package.json"
|
|
257
|
+
if package_json.exists():
|
|
258
|
+
deps.extend(self._parse_package_json(package_json))
|
|
259
|
+
|
|
260
|
+
return deps
|
|
261
|
+
|
|
262
|
+
def _parse_requirements_txt(self, filepath: Path) -> list[tuple[str, str]]:
|
|
263
|
+
"""Parse a requirements.txt file for package names."""
|
|
264
|
+
deps: list[tuple[str, str]] = []
|
|
265
|
+
try:
|
|
266
|
+
for line in filepath.read_text(encoding="utf-8").splitlines():
|
|
267
|
+
line = line.strip()
|
|
268
|
+
if not line or line.startswith("#") or line.startswith("-"):
|
|
269
|
+
continue
|
|
270
|
+
name = re.split(r"[>=<!\[;@]", line)[0].strip()
|
|
271
|
+
if name:
|
|
272
|
+
deps.append((name, "requirements.txt"))
|
|
273
|
+
except OSError:
|
|
274
|
+
pass
|
|
275
|
+
return deps
|
|
276
|
+
|
|
277
|
+
def _parse_pyproject_toml(self, filepath: Path) -> list[tuple[str, str]]:
|
|
278
|
+
"""Parse pyproject.toml for declared dependencies."""
|
|
279
|
+
deps: list[tuple[str, str]] = []
|
|
280
|
+
try:
|
|
281
|
+
if sys.version_info >= (3, 11):
|
|
282
|
+
import tomllib
|
|
283
|
+
else:
|
|
284
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
285
|
+
|
|
286
|
+
data = tomllib.loads(filepath.read_text(encoding="utf-8"))
|
|
287
|
+
project_deps = data.get("project", {}).get("dependencies", [])
|
|
288
|
+
for dep in project_deps:
|
|
289
|
+
name = re.split(r"[>=<!\[;@]", dep)[0].strip()
|
|
290
|
+
if name:
|
|
291
|
+
deps.append((name, "pyproject.toml"))
|
|
292
|
+
|
|
293
|
+
# Also check optional dependencies
|
|
294
|
+
optional = data.get("project", {}).get("optional-dependencies", {})
|
|
295
|
+
for group_name, group_deps in optional.items():
|
|
296
|
+
for dep in group_deps:
|
|
297
|
+
name = re.split(r"[>=<!\[;@]", dep)[0].strip()
|
|
298
|
+
if name:
|
|
299
|
+
deps.append((name, f"pyproject.toml [optional: {group_name}]"))
|
|
300
|
+
|
|
301
|
+
# Support Poetry: [tool.poetry.dependencies]
|
|
302
|
+
poetry_deps = data.get("tool", {}).get("poetry", {}).get("dependencies", {})
|
|
303
|
+
if isinstance(poetry_deps, dict):
|
|
304
|
+
for dep_name in poetry_deps:
|
|
305
|
+
if dep_name.lower() != "python":
|
|
306
|
+
deps.append((dep_name, "pyproject.toml [poetry.dependencies]"))
|
|
307
|
+
|
|
308
|
+
# Support Poetry groups: [tool.poetry.group.<group>.dependencies]
|
|
309
|
+
poetry_groups = data.get("tool", {}).get("poetry", {}).get("group", {})
|
|
310
|
+
if isinstance(poetry_groups, dict):
|
|
311
|
+
for group_name, group_data in poetry_groups.items():
|
|
312
|
+
if isinstance(group_data, dict):
|
|
313
|
+
g_deps = group_data.get("dependencies", {})
|
|
314
|
+
if isinstance(g_deps, dict):
|
|
315
|
+
for dep_name in g_deps:
|
|
316
|
+
if dep_name.lower() != "python":
|
|
317
|
+
deps.append(
|
|
318
|
+
(dep_name, f"pyproject.toml [poetry.group.{group_name}]")
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
except (OSError, Exception):
|
|
322
|
+
pass
|
|
323
|
+
return deps
|
|
324
|
+
|
|
325
|
+
def _parse_package_json(self, filepath: Path) -> list[tuple[str, str]]:
|
|
326
|
+
"""Parse package.json for declared dependencies."""
|
|
327
|
+
deps: list[tuple[str, str]] = []
|
|
328
|
+
try:
|
|
329
|
+
data = json.loads(filepath.read_text(encoding="utf-8"))
|
|
330
|
+
for section in ["dependencies", "devDependencies", "peerDependencies"]:
|
|
331
|
+
sec_deps = data.get(section, {})
|
|
332
|
+
if isinstance(sec_deps, dict):
|
|
333
|
+
for dep_name in sec_deps:
|
|
334
|
+
deps.append((dep_name, f"package.json [{section}]"))
|
|
335
|
+
except (OSError, ValueError):
|
|
336
|
+
pass
|
|
337
|
+
return deps
|
|
338
|
+
|
|
339
|
+
def _find_all_imports(self, path: Path) -> set[str]:
|
|
340
|
+
"""Walk Python and JS/TS files and collect all imported module/package names."""
|
|
341
|
+
imports: set[str] = set()
|
|
342
|
+
|
|
343
|
+
for file_path in path.rglob("*"):
|
|
344
|
+
if not file_path.is_file():
|
|
345
|
+
continue
|
|
346
|
+
|
|
347
|
+
parts = file_path.relative_to(path).parts
|
|
348
|
+
if any(p in SKIP_DIRS for p in parts):
|
|
349
|
+
continue
|
|
350
|
+
|
|
351
|
+
suffix = file_path.suffix.lower()
|
|
352
|
+
|
|
353
|
+
# Python files
|
|
354
|
+
if suffix == ".py":
|
|
355
|
+
self._extract_python_imports(file_path, imports)
|
|
356
|
+
# JS/TS/Vue/Svelte files
|
|
357
|
+
elif suffix in JS_TS_EXTENSIONS:
|
|
358
|
+
self._extract_js_ts_imports(file_path, imports)
|
|
359
|
+
|
|
360
|
+
return imports
|
|
361
|
+
|
|
362
|
+
def _extract_python_imports(self, filepath: Path, imports: set[str]) -> None:
|
|
363
|
+
"""Extract imports from a Python file."""
|
|
364
|
+
try:
|
|
365
|
+
source = filepath.read_text(encoding="utf-8", errors="ignore")
|
|
366
|
+
tree = ast.parse(source, filename=str(filepath))
|
|
367
|
+
for node in ast.walk(tree):
|
|
368
|
+
if isinstance(node, ast.Import):
|
|
369
|
+
for alias in node.names:
|
|
370
|
+
imports.add(alias.name)
|
|
371
|
+
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
372
|
+
imports.add(node.module)
|
|
373
|
+
except (SyntaxError, OSError):
|
|
374
|
+
pass
|
|
375
|
+
|
|
376
|
+
def _extract_js_ts_imports(self, filepath: Path, imports: set[str]) -> None:
|
|
377
|
+
"""Extract imports/requires from a JavaScript or TypeScript file."""
|
|
378
|
+
try:
|
|
379
|
+
source = filepath.read_text(encoding="utf-8", errors="ignore")
|
|
380
|
+
|
|
381
|
+
# Patterns:
|
|
382
|
+
# 1. import ... from 'pkg'
|
|
383
|
+
# 2. import 'pkg'
|
|
384
|
+
# 3. export ... from 'pkg'
|
|
385
|
+
# 4. require('pkg')
|
|
386
|
+
# 5. import('pkg')
|
|
387
|
+
patterns = [
|
|
388
|
+
r"""(?:import|export)\s+(?:[\w\s{},*]+from\s+)?['"]([^'"]+)['"]""",
|
|
389
|
+
r"""(?:require|import)\s*\(\s*['"]([^'"]+)['"]\s*\)""",
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
for pattern in patterns:
|
|
393
|
+
for match in re.finditer(pattern, source):
|
|
394
|
+
raw_specifier = match.group(1).strip()
|
|
395
|
+
# Skip relative or absolute paths
|
|
396
|
+
if (
|
|
397
|
+
raw_specifier.startswith(".")
|
|
398
|
+
or raw_specifier.startswith("/")
|
|
399
|
+
or raw_specifier.startswith("#")
|
|
400
|
+
):
|
|
401
|
+
continue
|
|
402
|
+
|
|
403
|
+
# Handle scoped package: @org/pkg/sub -> @org/pkg
|
|
404
|
+
if raw_specifier.startswith("@"):
|
|
405
|
+
parts = raw_specifier.split("/")
|
|
406
|
+
if len(parts) >= 2:
|
|
407
|
+
imports.add(f"{parts[0]}/{parts[1]}")
|
|
408
|
+
else:
|
|
409
|
+
imports.add(raw_specifier)
|
|
410
|
+
else:
|
|
411
|
+
# Unscoped package: pkg/sub -> pkg
|
|
412
|
+
pkg_root = raw_specifier.split("/")[0]
|
|
413
|
+
imports.add(pkg_root)
|
|
414
|
+
|
|
415
|
+
except OSError:
|
|
416
|
+
pass
|