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 ADDED
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .eggs/
7
+ *.egg
8
+ reports/
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.
@@ -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__
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dead-scanner = dead_scanner.scanner:main
@@ -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 = ["."]