cgcardona / muse public
dead.py python
271 lines 9.4 KB
25a0c523 feat(code): add call-graph tier — impact, dead, coverage commands (#60) Gabriel Cardona <cgcardona@gmail.com> 1d ago
1 """muse dead — dead code detection.
2
3 Finds symbols that are **never called** and whose containing module is
4 **never imported** by anything else in the committed snapshot.
5
6 A symbol is a dead-code candidate when two independent conditions hold:
7
8 1. **No call-site**: its bare name does not appear in any ``ast.Call``
9 node in any Python file in the snapshot.
10
11 2. **No import**: its containing file's module name does not appear in
12 any ``import``-kind symbol in any other file.
13
14 Both conditions must hold simultaneously. A function that is never
15 called but lives in a module that *is* imported is still reachable —
16 it may be part of an exported API even if it's not called internally.
17
18 Known limitations (documented, not bugs)
19 -----------------------------------------
20 - Dynamic dispatch: ``getattr(obj, name)()``, ``functools.partial``,
21 decorator-wrapped calls, and ``eval`` are not detected.
22 - Exported APIs: symbols accessed from outside the repo (library code)
23 appear dead because the callers are not in the snapshot.
24 - Entry points: ``main()``, CLI callbacks, and test functions appear dead
25 by design. Use ``--exclude-tests`` to hide test file symbols.
26 - tree-sitter languages: call-site extraction is Python-only. Symbols in
27 Go/Rust/TypeScript files are checked for import-graph reachability only.
28
29 Usage::
30
31 muse dead
32 muse dead --kind function
33 muse dead --exclude-tests
34 muse dead --commit HEAD~10
35 muse dead --json
36
37 Output::
38
39 Dead code candidates — commit cb4afaed
40 ──────────────────────────────────────────────────────────────
41 src/billing.py::_internal_helper function (not called, module not imported)
42 src/utils.py::deprecated_format function (not called, module imported)
43
44 ⚠️ 2 potentially dead symbol(s)
45 Note: dynamic dispatch, exported APIs, and entry points are not detected.
46
47 Flags:
48
49 ``--kind KIND``
50 Restrict to symbols of a specific kind.
51
52 ``--exclude-tests``
53 Exclude symbols in files whose path contains ``test`` or ``spec``.
54
55 ``--commit, -c REF``
56 Analyse a historical snapshot instead of HEAD.
57
58 ``--json``
59 Emit results as JSON.
60 """
61 from __future__ import annotations
62
63 import json
64 import logging
65 import pathlib
66
67 import typer
68
69 from muse.core.errors import ExitCode
70 from muse.core.repo import require_repo
71 from muse.core.store import get_commit_snapshot_manifest, resolve_commit_ref
72 from muse.plugins.code._callgraph import build_reverse_graph
73 from muse.plugins.code._query import symbols_for_snapshot
74 from muse.plugins.code.ast_parser import SEMANTIC_EXTENSIONS, parse_symbols
75 from muse.core.object_store import read_object
76
77 logger = logging.getLogger(__name__)
78
79 app = typer.Typer()
80
81
82 def _read_repo_id(root: pathlib.Path) -> str:
83 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
84
85
86 def _read_branch(root: pathlib.Path) -> str:
87 head_ref = (root / ".muse" / "HEAD").read_text().strip()
88 return head_ref.removeprefix("refs/heads/").strip()
89
90
91 def _is_test_file(file_path: str) -> bool:
92 lower = file_path.lower()
93 return "test" in lower or "spec" in lower
94
95
96 def _all_imported_modules(
97 root: pathlib.Path,
98 manifest: dict[str, str],
99 ) -> set[str]:
100 """Return the set of all module/symbol names imported across the snapshot."""
101 imported: set[str] = set()
102 for file_path, obj_id in manifest.items():
103 suffix = pathlib.PurePosixPath(file_path).suffix.lower()
104 if suffix not in SEMANTIC_EXTENSIONS:
105 continue
106 raw = read_object(root, obj_id)
107 if raw is None:
108 continue
109 tree = parse_symbols(raw, file_path)
110 for rec in tree.values():
111 if rec["kind"] == "import":
112 imported.add(rec["qualified_name"])
113 return imported
114
115
116 def _module_is_imported(file_path: str, imported_modules: set[str]) -> bool:
117 """Return True if *file_path*'s module name appears in *imported_modules*."""
118 stem = pathlib.PurePosixPath(file_path).stem
119 module = pathlib.PurePosixPath(file_path).with_suffix("").as_posix().replace("/", ".")
120 for imp in imported_modules:
121 if (
122 imp == stem
123 or imp == module
124 or imp.endswith(f".{stem}")
125 or imp.endswith(f".{module}")
126 or stem in imp.split(".")
127 ):
128 return True
129 return False
130
131
132 class _DeadCandidate:
133 def __init__(
134 self,
135 address: str,
136 kind: str,
137 called: bool,
138 module_imported: bool,
139 ) -> None:
140 self.address = address
141 self.kind = kind
142 self.called = called
143 self.module_imported = module_imported
144
145 @property
146 def reason(self) -> str:
147 if not self.called and not self.module_imported:
148 return "not called, module not imported"
149 if not self.called:
150 return "not called (module is imported — may be exported API)"
151 return "module not imported"
152
153 def to_dict(self) -> dict[str, str | bool]:
154 return {
155 "address": self.address,
156 "kind": self.kind,
157 "called": self.called,
158 "module_imported": self.module_imported,
159 "reason": self.reason,
160 }
161
162
163 @app.callback(invoke_without_command=True)
164 def dead(
165 ctx: typer.Context,
166 kind_filter: str | None = typer.Option(
167 None, "--kind", "-k", metavar="KIND",
168 help="Restrict to symbols of this kind (function, class, method, …).",
169 ),
170 exclude_tests: bool = typer.Option(
171 False, "--exclude-tests",
172 help="Exclude symbols in files whose path contains 'test' or 'spec'.",
173 ),
174 ref: str | None = typer.Option(
175 None, "--commit", "-c", metavar="REF",
176 help="Analyse a historical snapshot instead of HEAD.",
177 ),
178 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
179 ) -> None:
180 """Find symbols with no callers and no importers — dead code candidates.
181
182 A symbol is flagged when its bare name appears in no ``ast.Call`` node
183 in any Python file, *and* its containing module is not imported by any
184 other file in the snapshot.
185
186 Symbols whose module *is* imported are also reported (with a softer
187 warning) — they may be exported API surface that is reachable from
188 outside the snapshot.
189
190 Use ``--exclude-tests`` to suppress test-file symbols (which are
191 intentionally uncalled within the production codebase).
192
193 Limitations: dynamic dispatch, exported APIs, and entry points are not
194 detected. Treat results as *candidates*, not confirmed dead code.
195 """
196 root = require_repo()
197 repo_id = _read_repo_id(root)
198 branch = _read_branch(root)
199
200 commit = resolve_commit_ref(root, repo_id, branch, ref)
201 if commit is None:
202 typer.echo(f"❌ Commit '{ref or 'HEAD'}' not found.", err=True)
203 raise typer.Exit(code=ExitCode.USER_ERROR)
204
205 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
206
207 # Build the set of all called bare names across the snapshot.
208 reverse = build_reverse_graph(root, manifest)
209 called_names: set[str] = set(reverse.keys())
210
211 # Build the set of all imported module names.
212 imported_modules = _all_imported_modules(root, manifest)
213
214 # Collect all symbols from the snapshot.
215 symbol_map = symbols_for_snapshot(root, manifest, kind_filter=kind_filter)
216
217 candidates: list[_DeadCandidate] = []
218 for file_path, tree in sorted(symbol_map.items()):
219 if exclude_tests and _is_test_file(file_path):
220 continue
221 mod_imported = _module_is_imported(file_path, imported_modules)
222 for address, rec in sorted(tree.items()):
223 if rec["kind"] == "import":
224 continue # import symbols are infrastructure, not callables
225 bare_name = rec["name"].split(".")[-1]
226 is_called = bare_name in called_names
227 # Only flag as dead if not called; module-import status is
228 # additional context in the reason string.
229 if not is_called:
230 candidates.append(_DeadCandidate(
231 address=address,
232 kind=rec["kind"],
233 called=is_called,
234 module_imported=mod_imported,
235 ))
236
237 # Sort: definite dead (module not imported) first, then softer warnings.
238 candidates.sort(key=lambda c: (c.module_imported, c.address))
239
240 if as_json:
241 typer.echo(json.dumps(
242 {
243 "commit": commit.commit_id[:8],
244 "total_symbols_scanned": sum(len(t) for t in symbol_map.values()),
245 "dead_candidates": [c.to_dict() for c in candidates],
246 },
247 indent=2,
248 ))
249 return
250
251 typer.echo(f"\nDead code candidates — commit {commit.commit_id[:8]}")
252 typer.echo("─" * 62)
253
254 if not candidates:
255 typer.echo(" ✅ No dead code candidates found.")
256 typer.echo(
257 "\n Note: dynamic dispatch, exported APIs, and entry points are not detected."
258 )
259 return
260
261 max_addr = max(len(c.address) for c in candidates)
262 for c in candidates:
263 typer.echo(f" {c.address:<{max_addr}} {c.kind:<14} ({c.reason})")
264
265 typer.echo(f"\n⚠️ {len(candidates)} potentially dead symbol(s)")
266 typer.echo(
267 "Note: dynamic dispatch, exported APIs, and entry points are not detected."
268 "\nTreat these as candidates — verify before deleting."
269 )
270 if exclude_tests:
271 typer.echo("(test files excluded)")