dead-scanner 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.
- .gitignore +8 -0
- LICENSE +21 -0
- README.md +85 -0
- dead_scanner/__init__.py +10 -0
- dead_scanner/scanner.py +563 -0
- dead_scanner-0.1.0.dist-info/METADATA +110 -0
- dead_scanner-0.1.0.dist-info/RECORD +11 -0
- dead_scanner-0.1.0.dist-info/WHEEL +4 -0
- dead_scanner-0.1.0.dist-info/entry_points.txt +2 -0
- dead_scanner-0.1.0.dist-info/licenses/LICENSE +21 -0
- pyproject.toml +39 -0
.gitignore
ADDED
LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZEUS ARES Engine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# dead-scanner
|
|
2
|
+
|
|
3
|
+
Classify Python modules as **dead** / **fake-alive** / **standalone** / **island** — zero dependencies.
|
|
4
|
+
|
|
5
|
+
Extracted from ZEUS ARES Engine SilenceScanner (v3.0).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install dead-scanner
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
# Scan a project
|
|
17
|
+
dead-scanner /path/to/your/project
|
|
18
|
+
|
|
19
|
+
# JSON output
|
|
20
|
+
dead-scanner /path/to/project --json
|
|
21
|
+
|
|
22
|
+
# Scan a single module
|
|
23
|
+
dead-scanner /path/to/project -m path/to/file.py
|
|
24
|
+
|
|
25
|
+
# Quick summary (one line)
|
|
26
|
+
python -c "from dead_scanner import SilenceScanner; print(SilenceScanner.quick_check('.'))"
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Categories
|
|
30
|
+
|
|
31
|
+
| Category | Meaning |
|
|
32
|
+
|----------|---------|
|
|
33
|
+
| **truly_dead** | Exported symbols with zero references, not standalone |
|
|
34
|
+
| **standalone** | No imports in, but recognized as CLI/script/migration/build tool |
|
|
35
|
+
| **structural** | `__init__.py` or API surface files (keep) |
|
|
36
|
+
| **island** | Referenced but missing `INTERFACE.md` / `MODULE.yaml` contract |
|
|
37
|
+
| **fake_alive** | Imported by something that is itself a dead end |
|
|
38
|
+
|
|
39
|
+
## Python API
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from dead_scanner import SilenceScanner, HealthScorer
|
|
43
|
+
|
|
44
|
+
# Full scan
|
|
45
|
+
scanner = SilenceScanner("/path/to/project")
|
|
46
|
+
result = scanner.scan()
|
|
47
|
+
print(f"{result['truly_dead_count']} dead, {result['standalone_count']} standalone")
|
|
48
|
+
|
|
49
|
+
# Quick one-liner
|
|
50
|
+
print(SilenceScanner.quick_check("/path/to/project"))
|
|
51
|
+
# → "12 dead, 45 standalone, 3 island, 0 fake_alive (534 total modules)"
|
|
52
|
+
|
|
53
|
+
# Single module
|
|
54
|
+
mod = scanner.scan_module("path/to/module.py")
|
|
55
|
+
print(mod["status"], mod["unreferenced"])
|
|
56
|
+
|
|
57
|
+
# Custom standalone patterns
|
|
58
|
+
scanner = SilenceScanner(".",
|
|
59
|
+
standalone_patterns={
|
|
60
|
+
"standalone_script": ["tools/", "scripts/"],
|
|
61
|
+
"cli_entry": ["cli.py"],
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Health scoring
|
|
66
|
+
scorer = HealthScorer(".")
|
|
67
|
+
scores = scorer.score_module("my_module", in_degree=5, contract_exists=True,
|
|
68
|
+
test_exists=True, runtime_calls=100)
|
|
69
|
+
print(scores["score"], scores["tier"]) # → 75, "warning"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## What makes this different from Vulture?
|
|
73
|
+
|
|
74
|
+
| Vulture | dead-scanner |
|
|
75
|
+
|---------|-------------|
|
|
76
|
+
| Finds unused functions/classes | Finds unused **modules** + classifies them |
|
|
77
|
+
| No categorization | 5 categories (dead/standalone/structural/island/fake_alive) |
|
|
78
|
+
| No import-graph analysis | Full directed import graph + reverse lookups |
|
|
79
|
+
| No contract checking | Detects missing INTERFACE.md contracts |
|
|
80
|
+
|
|
81
|
+
They complement each other — use Vulture for unused symbols, dead-scanner for unused modules and architecture gaps.
|
|
82
|
+
|
|
83
|
+
## License
|
|
84
|
+
|
|
85
|
+
MIT — extracted from ZEUS ARES Engine, originally built for the ZEUS Autonomous Trading System.
|
dead_scanner/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""dead_scanner — classify Python modules as dead / fake-alive / standalone / island.
|
|
2
|
+
|
|
3
|
+
pip install dead-scanner
|
|
4
|
+
dead-scanner /path/to/your/project
|
|
5
|
+
python -m dead_scanner /path/to/your/project --json
|
|
6
|
+
|
|
7
|
+
Originally extracted from ZEUS ARES SilenceScanner (v3.0).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .scanner import SilenceScanner, HealthScorer, __version__
|
dead_scanner/scanner.py
ADDED
|
@@ -0,0 +1,563 @@
|
|
|
1
|
+
"""SilenceScanner — classify Python modules into dead / fake-alive / island / standalone.
|
|
2
|
+
|
|
3
|
+
Extracted from ZEUS ARES Engine v3.0.
|
|
4
|
+
Zero external dependencies beyond Python stdlib.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ast
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from collections import defaultdict
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("dead_scanner")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SilenceScanner:
|
|
25
|
+
"""Classifies every Python module in a project into categories:
|
|
26
|
+
|
|
27
|
+
- truly_dead: exported symbols with zero references from any entry point
|
|
28
|
+
AND not classified as standalone
|
|
29
|
+
- standalone: no inbound imports, but recognized as CLI entry / script /
|
|
30
|
+
migration / build tool / test infra / server entry
|
|
31
|
+
- structural: __init__.py or API surface re-export files (keep)
|
|
32
|
+
- island: works and is referenced, but has no INTERFACE.md / MODULE.yaml
|
|
33
|
+
— no cross-module contract
|
|
34
|
+
- fake_alive: imported by something that is itself dead-end
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
EXCLUDE_DIRS = {"tests", "test", "__pycache__", ".git", "venv", ".venv",
|
|
38
|
+
"node_modules", ".claude", "reports", "data", "certs",
|
|
39
|
+
"logs", "backups", "k8s", "helm", "hades", "__pypackages__",
|
|
40
|
+
".tox", ".eggs", "build", "dist", "*.egg-info"}
|
|
41
|
+
|
|
42
|
+
STANDALONE_CATEGORIES = {
|
|
43
|
+
"standalone_script", "cli_entry", "db_migration",
|
|
44
|
+
"build_tooling", "test_infrastructure", "server_entry",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# Default standalone patterns (customizable via constructor)
|
|
48
|
+
DEFAULT_STANDALONE_PATTERNS: dict[str, list[str]] = {
|
|
49
|
+
"standalone_script": ["scripts/"],
|
|
50
|
+
"db_migration": ["alembic"],
|
|
51
|
+
"test_infrastructure": ["conftest.py"],
|
|
52
|
+
"build_tooling": ["setup.py", "setup_cython", "build_cython"],
|
|
53
|
+
"server_entry": ["startup.py", "dashboard/backend/main", "neural_viz_server"],
|
|
54
|
+
"cli_entry": ["_cli", "_start", "cli.py"],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
def __init__(self, project_root: str | Path,
|
|
58
|
+
standalone_patterns: Optional[dict[str, list[str]]] = None,
|
|
59
|
+
exclude_dirs: Optional[set[str]] = None,
|
|
60
|
+
output_dir: Optional[str | Path] = None):
|
|
61
|
+
self._root = Path(project_root).resolve()
|
|
62
|
+
if not self._root.exists():
|
|
63
|
+
raise FileNotFoundError(f"Project root not found: {self._root}")
|
|
64
|
+
|
|
65
|
+
self._all_modules: dict[str, Path] = {}
|
|
66
|
+
self._import_graph: dict[str, set[str]] = defaultdict(set)
|
|
67
|
+
self._reverse_imports: dict[str, set[str]] = defaultdict(set)
|
|
68
|
+
|
|
69
|
+
self._standalone_patterns = standalone_patterns or self.DEFAULT_STANDALONE_PATTERNS
|
|
70
|
+
self._exclude_dirs = exclude_dirs or self.EXCLUDE_DIRS
|
|
71
|
+
self._output_dir = Path(output_dir) if output_dir else self._root / "reports"
|
|
72
|
+
|
|
73
|
+
# -- public API --
|
|
74
|
+
|
|
75
|
+
def scan(self, output_json: bool = True) -> dict:
|
|
76
|
+
"""Run full silence scan and return structured result.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
output_json: If True, write result to {output_dir}/silence_map.json
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
dict with keys: scanned_at, project_root, total_modules,
|
|
83
|
+
truly_dead_count, standalone_count, structural_init_count,
|
|
84
|
+
island_count, dead, island, fake_alive_hints, standalone_breakdown
|
|
85
|
+
"""
|
|
86
|
+
self._discover_modules()
|
|
87
|
+
self._build_import_graph()
|
|
88
|
+
|
|
89
|
+
dead = self._find_dead_modules()
|
|
90
|
+
|
|
91
|
+
structural = [d for d in dead if d["suggestion"] in ("keep_structural", "keep_api_surface")]
|
|
92
|
+
standalone = [d for d in dead if d["suggestion"] in self.STANDALONE_CATEGORIES]
|
|
93
|
+
truly_dead = [d for d in dead
|
|
94
|
+
if d["suggestion"] not in ("keep_structural", "keep_api_surface")
|
|
95
|
+
and d["suggestion"] not in self.STANDALONE_CATEGORIES]
|
|
96
|
+
|
|
97
|
+
standalone_counts: dict[str, int] = {}
|
|
98
|
+
for d in standalone:
|
|
99
|
+
cat = d["suggestion"]
|
|
100
|
+
standalone_counts[cat] = standalone_counts.get(cat, 0) + 1
|
|
101
|
+
|
|
102
|
+
result = {
|
|
103
|
+
"scanned_at": datetime.now().isoformat(),
|
|
104
|
+
"project_root": str(self._root),
|
|
105
|
+
"scanner_version": __version__,
|
|
106
|
+
"total_modules": len(self._all_modules),
|
|
107
|
+
"truly_dead_count": len(truly_dead),
|
|
108
|
+
"standalone_count": len(standalone),
|
|
109
|
+
"standalone_breakdown": standalone_counts,
|
|
110
|
+
"structural_init_count": len(structural),
|
|
111
|
+
"island_count": len(self._find_island_modules()),
|
|
112
|
+
"dead": dead,
|
|
113
|
+
"standalone": [d["module"] for d in standalone],
|
|
114
|
+
"truly_dead": [d["module"] for d in truly_dead],
|
|
115
|
+
"island": self._find_island_modules(),
|
|
116
|
+
"fake_alive_hints": self._find_fake_alive_hints(),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if output_json:
|
|
120
|
+
self._output_dir.mkdir(parents=True, exist_ok=True)
|
|
121
|
+
out = self._output_dir / "silence_map.json"
|
|
122
|
+
out.write_text(json.dumps(result, indent=2, default=str), encoding="utf-8")
|
|
123
|
+
logger.info(
|
|
124
|
+
f"SilenceScanner: {len(truly_dead)} truly dead, "
|
|
125
|
+
f"{len(standalone)} standalone ({standalone_counts}), "
|
|
126
|
+
f"{len(structural)} structural __init__, "
|
|
127
|
+
f"{len(self._find_island_modules())} island → {out}"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
def scan_module(self, module_path: str | Path) -> dict:
|
|
133
|
+
"""Scan a single module for dead symbols.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
dict with module, defined_symbols, unreferenced_symbols, unreferenced, status
|
|
137
|
+
"""
|
|
138
|
+
path = Path(module_path)
|
|
139
|
+
if not path.exists():
|
|
140
|
+
return {"error": f"Module not found: {module_path}"}
|
|
141
|
+
if path.suffix != ".py":
|
|
142
|
+
return {"error": "Only .py files supported"}
|
|
143
|
+
|
|
144
|
+
tree = _parse(path)
|
|
145
|
+
if tree is None:
|
|
146
|
+
return {"error": f"Could not parse: {path}"}
|
|
147
|
+
|
|
148
|
+
defined = _collect_definitions(tree)
|
|
149
|
+
mod_name = _module_name_from_path(self._root, path)
|
|
150
|
+
referenced = {importer for importer, imports in self._import_graph.items()
|
|
151
|
+
if mod_name in imports}
|
|
152
|
+
|
|
153
|
+
unused = [d for d in defined
|
|
154
|
+
if d["name"] not in referenced and not d["name"].startswith("_")]
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
"module": str(path.relative_to(self._root)),
|
|
158
|
+
"defined_symbols": len(defined),
|
|
159
|
+
"unreferenced_symbols": len(unused),
|
|
160
|
+
"unreferenced": [u["name"] for u in unused],
|
|
161
|
+
"status": "dead" if len(unused) == len(defined) else "partial",
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def quick_check(cls, project_root: str | Path) -> str:
|
|
166
|
+
"""One-liner: return a human-readable summary string.
|
|
167
|
+
|
|
168
|
+
>>> SilenceScanner.quick_check("/path/to/project")
|
|
169
|
+
'12 dead, 45 standalone, 3 island, 0 fake_alive (534 total modules)'
|
|
170
|
+
"""
|
|
171
|
+
scanner = cls(project_root, output_dir=None)
|
|
172
|
+
result = scanner.scan(output_json=False)
|
|
173
|
+
return (
|
|
174
|
+
f"{result['truly_dead_count']} dead, "
|
|
175
|
+
f"{result['standalone_count']} standalone, "
|
|
176
|
+
f"{result['island_count']} island, "
|
|
177
|
+
f"{len(result['fake_alive_hints'])} fake_alive "
|
|
178
|
+
f"({result['total_modules']} total modules)"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# -- internal --
|
|
182
|
+
|
|
183
|
+
def _discover_modules(self):
|
|
184
|
+
self._all_modules.clear()
|
|
185
|
+
for dirpath, dirnames, filenames in os.walk(self._root):
|
|
186
|
+
dirnames[:] = [d for d in dirnames
|
|
187
|
+
if d not in self._exclude_dirs and not d.startswith(".")]
|
|
188
|
+
for fn in filenames:
|
|
189
|
+
if fn.endswith(".py"):
|
|
190
|
+
full = Path(dirpath) / fn
|
|
191
|
+
name = _module_name_from_path(self._root, full)
|
|
192
|
+
self._all_modules[name] = full
|
|
193
|
+
|
|
194
|
+
def _build_import_graph(self):
|
|
195
|
+
self._import_graph.clear()
|
|
196
|
+
self._reverse_imports.clear()
|
|
197
|
+
for mod_name, path in self._all_modules.items():
|
|
198
|
+
imported = _extract_imports(path, self._root)
|
|
199
|
+
self._import_graph[mod_name] = imported
|
|
200
|
+
for imp in imported:
|
|
201
|
+
self._reverse_imports[imp].add(mod_name)
|
|
202
|
+
|
|
203
|
+
def _classify_standalone(self, rel_path: str) -> Optional[str]:
|
|
204
|
+
"""Classify a module as a known standalone type, or None.
|
|
205
|
+
|
|
206
|
+
Uses configurable standalone_patterns dict.
|
|
207
|
+
"""
|
|
208
|
+
p = rel_path.replace("\\", "/")
|
|
209
|
+
|
|
210
|
+
for category, patterns in self._standalone_patterns.items():
|
|
211
|
+
for pattern in patterns:
|
|
212
|
+
if pattern.endswith(".py"):
|
|
213
|
+
if p.endswith(pattern) or p.endswith("/" + pattern):
|
|
214
|
+
return category
|
|
215
|
+
else:
|
|
216
|
+
if pattern in p:
|
|
217
|
+
return category
|
|
218
|
+
|
|
219
|
+
return None
|
|
220
|
+
|
|
221
|
+
def _find_dead_modules(self) -> list[dict]:
|
|
222
|
+
dead = []
|
|
223
|
+
for mod_name, path in self._all_modules.items():
|
|
224
|
+
importers = self._reverse_imports.get(mod_name, set())
|
|
225
|
+
if not importers:
|
|
226
|
+
rel = str(path.relative_to(self._root)).replace("\\", "/")
|
|
227
|
+
|
|
228
|
+
standalone = self._classify_standalone(rel)
|
|
229
|
+
if standalone:
|
|
230
|
+
dead.append({
|
|
231
|
+
"module": rel,
|
|
232
|
+
"symbols_defined": 0,
|
|
233
|
+
"def_class_count": 0,
|
|
234
|
+
"reexport_count": 0,
|
|
235
|
+
"suggestion": standalone,
|
|
236
|
+
})
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
tree = _parse(path)
|
|
240
|
+
symbols = _collect_definitions(tree) if tree else []
|
|
241
|
+
public = [s for s in symbols if not s["name"].startswith("_")]
|
|
242
|
+
is_init = path.name == "__init__.py"
|
|
243
|
+
defs = [s for s in public if s["type"] in ("function", "class")]
|
|
244
|
+
reexports = [s for s in public if s["type"] in ("reexport", "import")]
|
|
245
|
+
|
|
246
|
+
if is_init and not defs:
|
|
247
|
+
suggestion = "keep_structural" if not reexports else "keep_api_surface"
|
|
248
|
+
elif not public:
|
|
249
|
+
suggestion = "remove"
|
|
250
|
+
else:
|
|
251
|
+
suggestion = "remove_or_wake"
|
|
252
|
+
|
|
253
|
+
dead.append({
|
|
254
|
+
"module": rel,
|
|
255
|
+
"symbols_defined": len(public),
|
|
256
|
+
"def_class_count": len(defs),
|
|
257
|
+
"reexport_count": len(reexports),
|
|
258
|
+
"suggestion": suggestion,
|
|
259
|
+
})
|
|
260
|
+
return sorted(dead, key=lambda d: d["module"])
|
|
261
|
+
|
|
262
|
+
def _find_island_modules(self) -> list[dict]:
|
|
263
|
+
islands = []
|
|
264
|
+
for mod_name, path in self._all_modules.items():
|
|
265
|
+
importers = self._reverse_imports.get(mod_name, set())
|
|
266
|
+
if importers:
|
|
267
|
+
pkg_dir = path.parent
|
|
268
|
+
has_contract = (pkg_dir / "INTERFACE.md").exists() or \
|
|
269
|
+
(pkg_dir / "MODULE.yaml").exists()
|
|
270
|
+
if not has_contract:
|
|
271
|
+
islands.append({
|
|
272
|
+
"module": str(path.relative_to(self._root)),
|
|
273
|
+
"imported_by": len(importers),
|
|
274
|
+
"importers": sorted(importers)[:5],
|
|
275
|
+
"suggestion": "add_contract",
|
|
276
|
+
})
|
|
277
|
+
return sorted(islands, key=lambda d: d["module"])
|
|
278
|
+
|
|
279
|
+
def _find_fake_alive_hints(self) -> list[dict]:
|
|
280
|
+
hints = []
|
|
281
|
+
for mod_name in self._all_modules:
|
|
282
|
+
importers = self._reverse_imports.get(mod_name, set())
|
|
283
|
+
if not importers:
|
|
284
|
+
continue
|
|
285
|
+
if len(importers) == 1:
|
|
286
|
+
sole = next(iter(importers))
|
|
287
|
+
sole_importers = self._reverse_imports.get(sole, set())
|
|
288
|
+
if not sole_importers:
|
|
289
|
+
hints.append({
|
|
290
|
+
"module": mod_name,
|
|
291
|
+
"sole_importer": sole,
|
|
292
|
+
"risk": "sole_importer_is_dead_end",
|
|
293
|
+
})
|
|
294
|
+
return hints
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class HealthScorer:
|
|
298
|
+
"""0-100 health score per module across four dimensions.
|
|
299
|
+
|
|
300
|
+
- references (30 pts): inbound dependency count
|
|
301
|
+
- contract (20 pts): INTERFACE.md or MODULE.yaml present
|
|
302
|
+
- test (25 pts): corresponding test file exists
|
|
303
|
+
- runtime (25 pts): invocation counter (configurable)
|
|
304
|
+
|
|
305
|
+
Modules below 60 trigger a warning.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
CRITICAL_THRESHOLD = 60
|
|
309
|
+
WARNING_THRESHOLD = 80
|
|
310
|
+
|
|
311
|
+
def __init__(self, project_root: str | Path,
|
|
312
|
+
graph=None,
|
|
313
|
+
runtime_counters: Optional[dict[str, int]] = None):
|
|
314
|
+
self._root = Path(project_root)
|
|
315
|
+
self._graph = graph
|
|
316
|
+
self._runtime_counters = runtime_counters or {}
|
|
317
|
+
self._report_dir = self._root / "reports"
|
|
318
|
+
|
|
319
|
+
def score_module(self, name: str, in_degree: int = 0,
|
|
320
|
+
contract_exists: bool = False, test_exists: bool = False,
|
|
321
|
+
runtime_calls: int = 0) -> dict:
|
|
322
|
+
ref_score = min(30, in_degree * 3)
|
|
323
|
+
contract_score = 20 if contract_exists else 0
|
|
324
|
+
test_score = 25 if test_exists else 0
|
|
325
|
+
runtime_score = min(25, runtime_calls // 10) if runtime_calls else 0
|
|
326
|
+
total = ref_score + contract_score + test_score + runtime_score
|
|
327
|
+
|
|
328
|
+
if total < self.CRITICAL_THRESHOLD:
|
|
329
|
+
tier = "critical"
|
|
330
|
+
elif total < self.WARNING_THRESHOLD:
|
|
331
|
+
tier = "warning"
|
|
332
|
+
else:
|
|
333
|
+
tier = "healthy"
|
|
334
|
+
|
|
335
|
+
return {
|
|
336
|
+
"module": name, "score": total, "tier": tier,
|
|
337
|
+
"breakdown": {"references": ref_score, "contract": contract_score,
|
|
338
|
+
"test": test_score, "runtime": runtime_score},
|
|
339
|
+
"details": {"in_degree": in_degree, "has_contract": contract_exists,
|
|
340
|
+
"has_test": test_exists, "runtime_calls": runtime_calls},
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
def score_all(self, graph=None,
|
|
344
|
+
contract_modules: Optional[set[str]] = None,
|
|
345
|
+
test_files: Optional[set[str]] = None) -> list[dict]:
|
|
346
|
+
g = graph or self._graph
|
|
347
|
+
if g is None:
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
contracts = contract_modules or set()
|
|
351
|
+
tests = test_files or set()
|
|
352
|
+
|
|
353
|
+
results = []
|
|
354
|
+
for name, node in g.nodes.items():
|
|
355
|
+
pkg = name.replace(".", "/").split("/")[0]
|
|
356
|
+
contract_ok = name in contracts or pkg in contracts
|
|
357
|
+
test_ok = name in tests or any(
|
|
358
|
+
t.startswith(name.replace(".", "_")) for t in tests)
|
|
359
|
+
runtime = self._runtime_counters.get(name, 0)
|
|
360
|
+
|
|
361
|
+
results.append(self.score_module(
|
|
362
|
+
name=name, in_degree=node.in_degree,
|
|
363
|
+
contract_exists=contract_ok, test_exists=test_ok,
|
|
364
|
+
runtime_calls=runtime,
|
|
365
|
+
))
|
|
366
|
+
return sorted(results, key=lambda r: r["score"])
|
|
367
|
+
|
|
368
|
+
def generate_report(self, graph=None,
|
|
369
|
+
contract_modules: Optional[set[str]] = None,
|
|
370
|
+
test_files: Optional[set[str]] = None) -> dict:
|
|
371
|
+
scored = self.score_all(graph, contract_modules, test_files)
|
|
372
|
+
previous = self._load_previous_report()
|
|
373
|
+
|
|
374
|
+
prev_scores = {r["module"]: r["score"] for r in previous.get("modules", [])}
|
|
375
|
+
trends = []
|
|
376
|
+
for entry in scored:
|
|
377
|
+
prev = prev_scores.get(entry["module"])
|
|
378
|
+
trends.append({
|
|
379
|
+
"module": entry["module"],
|
|
380
|
+
"delta": entry["score"] - prev if prev is not None else None,
|
|
381
|
+
"previous": prev, "current": entry["score"],
|
|
382
|
+
})
|
|
383
|
+
|
|
384
|
+
improved = [t for t in trends if t["delta"] is not None and t["delta"] > 0]
|
|
385
|
+
declined = [t for t in trends if t["delta"] is not None and t["delta"] < 0]
|
|
386
|
+
critical = [s for s in scored if s["tier"] == "critical"]
|
|
387
|
+
avg_score = (sum(s["score"] for s in scored) / len(scored)) if scored else 0
|
|
388
|
+
|
|
389
|
+
report = {
|
|
390
|
+
"generated_at": datetime.now().isoformat(),
|
|
391
|
+
"total_modules": len(scored), "average_score": round(avg_score, 1),
|
|
392
|
+
"distribution": {
|
|
393
|
+
"healthy": len([s for s in scored if s["tier"] == "healthy"]),
|
|
394
|
+
"warning": len([s for s in scored if s["tier"] == "warning"]),
|
|
395
|
+
"critical": len(critical),
|
|
396
|
+
},
|
|
397
|
+
"modules": scored,
|
|
398
|
+
"trends": {
|
|
399
|
+
"improved": len(improved), "declined": len(declined),
|
|
400
|
+
"stable": len(trends) - len(improved) - len(declined),
|
|
401
|
+
"details": trends,
|
|
402
|
+
},
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
self._report_dir.mkdir(parents=True, exist_ok=True)
|
|
406
|
+
out = self._report_dir / "health-report.json"
|
|
407
|
+
out.write_text(json.dumps(report, indent=2, default=str), encoding="utf-8")
|
|
408
|
+
logger.info(f"HealthScorer: avg={avg_score:.1f}, critical={len(critical)} → {out}")
|
|
409
|
+
|
|
410
|
+
return report
|
|
411
|
+
|
|
412
|
+
def _load_previous_report(self) -> dict:
|
|
413
|
+
prev = self._report_dir / "health-report.json"
|
|
414
|
+
if not prev.exists():
|
|
415
|
+
return {}
|
|
416
|
+
try:
|
|
417
|
+
return json.loads(prev.read_text(encoding="utf-8"))
|
|
418
|
+
except (json.JSONDecodeError, OSError):
|
|
419
|
+
return {}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# -- AST helpers (private, exposed for testing) --
|
|
423
|
+
|
|
424
|
+
def _parse(path: Path) -> Optional[ast.Module]:
|
|
425
|
+
try:
|
|
426
|
+
return ast.parse(path.read_text(encoding="utf-8"))
|
|
427
|
+
except (SyntaxError, UnicodeDecodeError, OSError):
|
|
428
|
+
return None
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _extract_imports(path: Path, root: Optional[Path] = None) -> set[str]:
|
|
432
|
+
tree = _parse(path)
|
|
433
|
+
if tree is None:
|
|
434
|
+
return set()
|
|
435
|
+
imports: set[str] = set()
|
|
436
|
+
|
|
437
|
+
own_prefix = ""
|
|
438
|
+
if root is not None:
|
|
439
|
+
own_prefix = _module_name_from_path(root, path)
|
|
440
|
+
if path.name != "__init__.py":
|
|
441
|
+
own_prefix = ".".join(own_prefix.split(".")[:-1])
|
|
442
|
+
|
|
443
|
+
for node in ast.walk(tree):
|
|
444
|
+
if isinstance(node, ast.Import):
|
|
445
|
+
for alias in node.names:
|
|
446
|
+
imports.add(alias.name)
|
|
447
|
+
parts = alias.name.split(".")
|
|
448
|
+
for i in range(1, len(parts)):
|
|
449
|
+
imports.add(".".join(parts[:i]))
|
|
450
|
+
|
|
451
|
+
elif isinstance(node, ast.ImportFrom):
|
|
452
|
+
if node.module is None:
|
|
453
|
+
continue
|
|
454
|
+
if node.level is not None and node.level > 0:
|
|
455
|
+
parts = own_prefix.split(".") if own_prefix else []
|
|
456
|
+
if len(parts) >= node.level:
|
|
457
|
+
base = ".".join(parts[:-(node.level - 1)] if node.level > 1 else parts)
|
|
458
|
+
else:
|
|
459
|
+
base = ""
|
|
460
|
+
if base:
|
|
461
|
+
imports.add(f"{base}.{node.module}")
|
|
462
|
+
else:
|
|
463
|
+
imports.add(node.module)
|
|
464
|
+
else:
|
|
465
|
+
imports.add(node.module)
|
|
466
|
+
|
|
467
|
+
return imports
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _collect_definitions(tree: ast.Module) -> list[dict]:
|
|
471
|
+
defs = []
|
|
472
|
+
for node in ast.iter_child_nodes(tree):
|
|
473
|
+
if isinstance(node, ast.FunctionDef):
|
|
474
|
+
defs.append({"name": node.name, "type": "function", "lineno": node.lineno})
|
|
475
|
+
elif isinstance(node, ast.ClassDef):
|
|
476
|
+
defs.append({"name": node.name, "type": "class", "lineno": node.lineno})
|
|
477
|
+
elif isinstance(node, ast.Assign):
|
|
478
|
+
for target in node.targets:
|
|
479
|
+
if isinstance(target, ast.Name):
|
|
480
|
+
defs.append({"name": target.id, "type": "variable", "lineno": node.lineno})
|
|
481
|
+
elif isinstance(node, ast.ImportFrom):
|
|
482
|
+
for alias in node.names:
|
|
483
|
+
name = alias.asname or alias.name
|
|
484
|
+
if not name.startswith("_"):
|
|
485
|
+
defs.append({"name": name, "type": "reexport", "lineno": node.lineno})
|
|
486
|
+
elif isinstance(node, ast.Import):
|
|
487
|
+
for alias in node.names:
|
|
488
|
+
name = alias.asname or alias.name
|
|
489
|
+
if not name.startswith("_"):
|
|
490
|
+
defs.append({"name": name, "type": "import", "lineno": node.lineno})
|
|
491
|
+
return defs
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _module_name_from_path(root: Path, path: Path) -> str:
|
|
495
|
+
try:
|
|
496
|
+
rel = path.relative_to(root)
|
|
497
|
+
except ValueError:
|
|
498
|
+
return path.stem
|
|
499
|
+
parts = list(rel.parts)
|
|
500
|
+
parts[-1] = parts[-1].replace(".py", "")
|
|
501
|
+
if parts[-1] == "__init__":
|
|
502
|
+
parts.pop()
|
|
503
|
+
if not parts:
|
|
504
|
+
return "__init__"
|
|
505
|
+
return ".".join(parts) if len(parts) > 1 else parts[0]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
# -- CLI entry point --
|
|
509
|
+
|
|
510
|
+
def main():
|
|
511
|
+
import argparse
|
|
512
|
+
|
|
513
|
+
parser = argparse.ArgumentParser(
|
|
514
|
+
description="dead_scanner — classify Python dead/fake-alive/standalone/island modules"
|
|
515
|
+
)
|
|
516
|
+
parser.add_argument("project", nargs="?", default=".", help="Project root directory")
|
|
517
|
+
parser.add_argument("--json", action="store_true", help="Output JSON to stdout")
|
|
518
|
+
parser.add_argument("--module", "-m", help="Scan a single module")
|
|
519
|
+
parser.add_argument("--exclude", nargs="*", help="Additional directories to exclude")
|
|
520
|
+
parser.add_argument("--version", action="store_true", help="Show version")
|
|
521
|
+
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output")
|
|
522
|
+
args = parser.parse_args()
|
|
523
|
+
|
|
524
|
+
if args.version:
|
|
525
|
+
print(f"dead_scanner v{__version__}")
|
|
526
|
+
return
|
|
527
|
+
|
|
528
|
+
logging.basicConfig(
|
|
529
|
+
level=logging.DEBUG if args.verbose else logging.INFO,
|
|
530
|
+
format="%(levelname)s | %(name)s | %(message)s",
|
|
531
|
+
)
|
|
532
|
+
|
|
533
|
+
exclude = SilenceScanner.EXCLUDE_DIRS.copy()
|
|
534
|
+
if args.exclude:
|
|
535
|
+
exclude.update(args.exclude)
|
|
536
|
+
|
|
537
|
+
scanner = SilenceScanner(args.project, exclude_dirs=exclude)
|
|
538
|
+
|
|
539
|
+
if args.module:
|
|
540
|
+
result = scanner.scan_module(args.module)
|
|
541
|
+
else:
|
|
542
|
+
result = scanner.scan(output_json=not args.json)
|
|
543
|
+
|
|
544
|
+
if args.json:
|
|
545
|
+
print(json.dumps(result, indent=2, default=str, ensure_ascii=False))
|
|
546
|
+
elif not args.module:
|
|
547
|
+
print(f"\n Project: {result['project_root']}")
|
|
548
|
+
print(f" Modules: {result['total_modules']}")
|
|
549
|
+
print(f" Truly Dead: {result['truly_dead_count']}")
|
|
550
|
+
print(f" Standalone: {result['standalone_count']} {result.get('standalone_breakdown', {})}")
|
|
551
|
+
print(f" Structural __init__: {result['structural_init_count']}")
|
|
552
|
+
print(f" Island: {result['island_count']}")
|
|
553
|
+
print(f" Fake-Alive Hints: {len(result.get('fake_alive_hints', []))}")
|
|
554
|
+
if result.get('truly_dead'):
|
|
555
|
+
print(f"\n Truly Dead Modules:")
|
|
556
|
+
for m in result['truly_dead']:
|
|
557
|
+
print(f" - {m}")
|
|
558
|
+
else:
|
|
559
|
+
print(json.dumps(result, indent=2, default=str, ensure_ascii=False))
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
if __name__ == "__main__":
|
|
563
|
+
main()
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dead-scanner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Classify Python modules as dead, fake-alive, standalone, or island — zero dependencies
|
|
5
|
+
Project-URL: Homepage, https://github.com/nickchen/dead-scanner
|
|
6
|
+
Project-URL: Repository, https://github.com/nickchen/dead-scanner
|
|
7
|
+
Project-URL: Issues, https://github.com/nickchen/dead-scanner/issues
|
|
8
|
+
Author-email: ZEUS ARES Engine <nickchen791@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ast,code-quality,dead-code,python,static-analysis
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
22
|
+
Classifier: Topic :: Software Development :: Testing
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# dead-scanner
|
|
27
|
+
|
|
28
|
+
Classify Python modules as **dead** / **fake-alive** / **standalone** / **island** — zero dependencies.
|
|
29
|
+
|
|
30
|
+
Extracted from ZEUS ARES Engine SilenceScanner (v3.0).
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install dead-scanner
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Quick Start
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Scan a project
|
|
42
|
+
dead-scanner /path/to/your/project
|
|
43
|
+
|
|
44
|
+
# JSON output
|
|
45
|
+
dead-scanner /path/to/project --json
|
|
46
|
+
|
|
47
|
+
# Scan a single module
|
|
48
|
+
dead-scanner /path/to/project -m path/to/file.py
|
|
49
|
+
|
|
50
|
+
# Quick summary (one line)
|
|
51
|
+
python -c "from dead_scanner import SilenceScanner; print(SilenceScanner.quick_check('.'))"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Categories
|
|
55
|
+
|
|
56
|
+
| Category | Meaning |
|
|
57
|
+
|----------|---------|
|
|
58
|
+
| **truly_dead** | Exported symbols with zero references, not standalone |
|
|
59
|
+
| **standalone** | No imports in, but recognized as CLI/script/migration/build tool |
|
|
60
|
+
| **structural** | `__init__.py` or API surface files (keep) |
|
|
61
|
+
| **island** | Referenced but missing `INTERFACE.md` / `MODULE.yaml` contract |
|
|
62
|
+
| **fake_alive** | Imported by something that is itself a dead end |
|
|
63
|
+
|
|
64
|
+
## Python API
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from dead_scanner import SilenceScanner, HealthScorer
|
|
68
|
+
|
|
69
|
+
# Full scan
|
|
70
|
+
scanner = SilenceScanner("/path/to/project")
|
|
71
|
+
result = scanner.scan()
|
|
72
|
+
print(f"{result['truly_dead_count']} dead, {result['standalone_count']} standalone")
|
|
73
|
+
|
|
74
|
+
# Quick one-liner
|
|
75
|
+
print(SilenceScanner.quick_check("/path/to/project"))
|
|
76
|
+
# → "12 dead, 45 standalone, 3 island, 0 fake_alive (534 total modules)"
|
|
77
|
+
|
|
78
|
+
# Single module
|
|
79
|
+
mod = scanner.scan_module("path/to/module.py")
|
|
80
|
+
print(mod["status"], mod["unreferenced"])
|
|
81
|
+
|
|
82
|
+
# Custom standalone patterns
|
|
83
|
+
scanner = SilenceScanner(".",
|
|
84
|
+
standalone_patterns={
|
|
85
|
+
"standalone_script": ["tools/", "scripts/"],
|
|
86
|
+
"cli_entry": ["cli.py"],
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Health scoring
|
|
91
|
+
scorer = HealthScorer(".")
|
|
92
|
+
scores = scorer.score_module("my_module", in_degree=5, contract_exists=True,
|
|
93
|
+
test_exists=True, runtime_calls=100)
|
|
94
|
+
print(scores["score"], scores["tier"]) # → 75, "warning"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## What makes this different from Vulture?
|
|
98
|
+
|
|
99
|
+
| Vulture | dead-scanner |
|
|
100
|
+
|---------|-------------|
|
|
101
|
+
| Finds unused functions/classes | Finds unused **modules** + classifies them |
|
|
102
|
+
| No categorization | 5 categories (dead/standalone/structural/island/fake_alive) |
|
|
103
|
+
| No import-graph analysis | Full directed import graph + reverse lookups |
|
|
104
|
+
| No contract checking | Detects missing INTERFACE.md contracts |
|
|
105
|
+
|
|
106
|
+
They complement each other — use Vulture for unused symbols, dead-scanner for unused modules and architecture gaps.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT — extracted from ZEUS ARES Engine, originally built for the ZEUS Autonomous Trading System.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
./.gitignore,sha256=pdCt-gTDblr7Fw-Yj_uWXJCNzm3EMO-3PqIMW2W4J3U,66
|
|
2
|
+
./LICENSE,sha256=f-kvs4qU2smq3K9yBODv2C964b4KdA52qYgrL2QVlHw,1073
|
|
3
|
+
./README.md,sha256=mgVBkzGG6Q02Egq_wASlDxQC0pKV8vf5UEvLHtIvaZ8,2557
|
|
4
|
+
./pyproject.toml,sha256=G2uoeKRS-MBjODn2tfYbIaix7TVlwk2T_5dYX9804Gc,1276
|
|
5
|
+
./dead_scanner/__init__.py,sha256=-VXP-Mlmu8THl7-WtgXQVzHWt1RmgeENh2dQlVpUxQg,341
|
|
6
|
+
./dead_scanner/scanner.py,sha256=y3cySWCfGqx90nvq35uQnBjp9dDAB1dxeteeSdLwOrQ,22356
|
|
7
|
+
dead_scanner-0.1.0.dist-info/METADATA,sha256=9l3pg4f359m94nT_tF8kZ04cTrVUKaUN3E4yEgWsiZU,3680
|
|
8
|
+
dead_scanner-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
9
|
+
dead_scanner-0.1.0.dist-info/entry_points.txt,sha256=h7FS0wbpjaICd74BvCXwvKDT4sHebsRxvleGmeJPLLM,59
|
|
10
|
+
dead_scanner-0.1.0.dist-info/licenses/LICENSE,sha256=f-kvs4qU2smq3K9yBODv2C964b4KdA52qYgrL2QVlHw,1073
|
|
11
|
+
dead_scanner-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ZEUS ARES Engine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyproject.toml
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "dead-scanner"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Classify Python modules as dead, fake-alive, standalone, or island — zero dependencies"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "ZEUS ARES Engine", email = "nickchen791@gmail.com"},
|
|
14
|
+
]
|
|
15
|
+
keywords = ["dead-code", "python", "static-analysis", "code-quality", "ast"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
27
|
+
"Topic :: Software Development :: Testing",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
dead-scanner = "dead_scanner.scanner:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/nickchen/dead-scanner"
|
|
35
|
+
Repository = "https://github.com/nickchen/dead-scanner"
|
|
36
|
+
Issues = "https://github.com/nickchen/dead-scanner/issues"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["."]
|