dead.py
python
| 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)") |