gabriel / muse public
coverage.py python
261 lines 9.2 KB
e6786943 feat: upgrade to Python 3.14, drop from __future__ import annotations Gabriel Cardona <cgcardona@gmail.com> 5d ago
1 """muse coverage — class interface call-coverage.
2
3 Reports which methods of a class are actually called somewhere in the
4 committed snapshot and which are never reached.
5
6 This command answers the question: *"Is my API actually used?"*
7
8 Every ``class`` symbol with method children is a candidate interface.
9 ``muse coverage`` builds the reverse call graph for the snapshot, then
10 checks each method's bare name against the set of called names.
11
12 Why this matters
13 ----------------
14 Traditional coverage tools measure *test* coverage — how many lines are
15 executed during a test run. That requires a running test suite.
16
17 Muse's *interface coverage* measures *call-site* coverage — how many of
18 a class's methods are invoked anywhere in the production codebase. It
19 runs in O(snapshot_size) without executing any code. It is ideal for:
20
21 * Auditing API surface before a deprecation.
22 * Finding method pairs where one is always called and the other never is.
23 * Verifying that a new interface is actually adopted after landing.
24
25 Usage::
26
27 muse coverage "src/models.py::User"
28 muse coverage "src/auth.py::TokenValidator" --commit HEAD~5
29 muse coverage "src/billing.py::Invoice" --json
30
31 Output::
32
33 Interface coverage: src/models.py::User
34 ──────────────────────────────────────────────────────────────
35
36 ✅ User.__init__ called by: src/api.py::create_user, src/api.py::update_user
37 ✅ User.save called by: src/api.py::create_user
38 ❌ User.delete (no callers detected)
39 ❌ User.to_dict (no callers detected)
40
41 ──────────────────────────────────────────────────────────────
42 Coverage: 2/4 methods called (50%)
43 🟡 Partial coverage — 2 uncovered method(s) may be dead API surface.
44
45 Flags:
46
47 ``--commit, -c REF``
48 Analyse a historical snapshot instead of HEAD.
49
50 ``--json``
51 Emit results as JSON.
52
53 ``--show-callers``
54 Include the list of caller addresses next to each covered method
55 (shown by default; use ``--no-show-callers`` to suppress).
56 """
57
58 import json
59 import logging
60 import pathlib
61
62 import typer
63
64 from muse.core.errors import ExitCode
65 from muse.core.repo import require_repo
66 from muse.core.store import get_commit_snapshot_manifest, resolve_commit_ref
67 from muse.plugins.code._callgraph import build_reverse_graph
68 from muse.plugins.code._query import symbols_for_snapshot
69 from muse.plugins.code.ast_parser import SymbolRecord
70
71 logger = logging.getLogger(__name__)
72
73 app = typer.Typer()
74
75 _METHOD_KINDS: frozenset[str] = frozenset({"method", "async_method"})
76
77
78 def _read_repo_id(root: pathlib.Path) -> str:
79 return str(json.loads((root / ".muse" / "repo.json").read_text())["repo_id"])
80
81
82 def _read_branch(root: pathlib.Path) -> str:
83 head_ref = (root / ".muse" / "HEAD").read_text().strip()
84 return head_ref.removeprefix("refs/heads/").strip()
85
86
87 def _class_methods(
88 file_path: str,
89 class_name: str,
90 symbol_map: dict[str, dict[str, SymbolRecord]],
91 ) -> list[tuple[str, str]]:
92 """Return ``(address, bare_name)`` pairs for all methods under *class_name* in *file_path*.
93
94 Addresses look like ``"src/models.py::User.__init__"``.
95 Bare names look like ``"__init__"``.
96 """
97 methods: list[tuple[str, str]] = []
98 prefix = f"{file_path}::{class_name}."
99 for file, tree in symbol_map.items():
100 if file != file_path:
101 continue
102 for address, rec in sorted(tree.items()):
103 if rec["kind"] not in _METHOD_KINDS:
104 continue
105 if address.startswith(prefix):
106 bare = rec["name"].split(".")[-1]
107 methods.append((address, bare))
108 return sorted(methods, key=lambda t: t[1])
109
110
111 @app.callback(invoke_without_command=True)
112 def coverage(
113 ctx: typer.Context,
114 address: str = typer.Argument(
115 ..., metavar="CLASS_ADDRESS",
116 help='Class symbol address, e.g. "src/models.py::User".',
117 ),
118 ref: str | None = typer.Option(
119 None, "--commit", "-c", metavar="REF",
120 help="Analyse a historical snapshot instead of HEAD.",
121 ),
122 show_callers: bool = typer.Option(
123 True, "--show-callers/--no-show-callers",
124 help="Include caller addresses next to each covered method.",
125 ),
126 as_json: bool = typer.Option(False, "--json", help="Emit results as JSON."),
127 ) -> None:
128 """Show which methods of a class are called anywhere in the snapshot.
129
130 Builds the reverse call graph, then checks each method's bare name
131 against the set of called names. Reports covered and uncovered methods,
132 and a percentage coverage score.
133
134 Useful for auditing API adoption, finding dead interface surface, and
135 planning safe deprecations — without running a single test.
136
137 Python only (call-graph analysis uses stdlib ``ast``).
138 """
139 root = require_repo()
140 repo_id = _read_repo_id(root)
141 branch = _read_branch(root)
142
143 if "::" not in address:
144 typer.echo(
145 f"❌ ADDRESS must be a symbol address like 'src/models.py::User'.", err=True
146 )
147 raise typer.Exit(code=ExitCode.USER_ERROR)
148
149 file_path, class_name = address.split("::", 1)
150
151 commit = resolve_commit_ref(root, repo_id, branch, ref)
152 if commit is None:
153 typer.echo(f"❌ Commit '{ref or 'HEAD'}' not found.", err=True)
154 raise typer.Exit(code=ExitCode.USER_ERROR)
155
156 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
157 symbol_map = symbols_for_snapshot(root, manifest, kind_filter=None)
158
159 # Verify the class exists in the snapshot.
160 class_addr = f"{file_path}::{class_name}"
161 all_syms: dict[str, str] = {}
162 for tree in symbol_map.values():
163 for addr, rec in tree.items():
164 all_syms[addr] = rec["kind"]
165 if class_addr not in all_syms:
166 typer.echo(
167 f"❌ Class '{class_addr}' not found in snapshot {commit.commit_id[:8]}.",
168 err=True,
169 )
170 raise typer.Exit(code=ExitCode.USER_ERROR)
171
172 # Collect all methods.
173 methods = _class_methods(file_path, class_name, symbol_map)
174 if not methods:
175 typer.echo(f"⚠️ No methods found for '{class_addr}'.", err=True)
176 raise typer.Exit(code=ExitCode.USER_ERROR)
177
178 # Build reverse call graph.
179 reverse = build_reverse_graph(root, manifest)
180
181 # Classify each method.
182 covered: list[tuple[str, str, list[str]]] = [] # (address, bare_name, callers)
183 uncovered: list[tuple[str, str]] = [] # (address, bare_name)
184 for method_addr, bare_name in methods:
185 callers = sorted(reverse.get(bare_name, []))
186 if callers:
187 covered.append((method_addr, bare_name, callers))
188 else:
189 uncovered.append((method_addr, bare_name))
190
191 total = len(methods)
192 n_covered = len(covered)
193 pct = round(n_covered / total * 100) if total else 0
194
195 if as_json:
196 typer.echo(json.dumps(
197 {
198 "address": class_addr,
199 "commit": commit.commit_id[:8],
200 "total_methods": total,
201 "covered": n_covered,
202 "percent": pct,
203 "methods": [
204 {
205 "address": addr,
206 "name": name,
207 "called": True,
208 "callers": callers,
209 }
210 for addr, name, callers in covered
211 ] + [
212 {
213 "address": addr,
214 "name": name,
215 "called": False,
216 "callers": [],
217 }
218 for addr, name in uncovered
219 ],
220 },
221 indent=2,
222 ))
223 return
224
225 typer.echo(f"\nInterface coverage: {class_addr}")
226 typer.echo("─" * 62)
227
228 max_name = max(
229 (len(f"{class_name}.{name}") for _, name in methods),
230 default=0,
231 )
232
233 for addr, bare_name, callers in covered:
234 display = f"{class_name}.{bare_name}"
235 line = f" ✅ {display:<{max_name}}"
236 if show_callers:
237 caller_str = ", ".join(callers[:3])
238 if len(callers) > 3:
239 caller_str += f" (+{len(callers) - 3} more)"
240 line += f" ← {caller_str}"
241 typer.echo(line)
242
243 for addr, bare_name in uncovered:
244 display = f"{class_name}.{bare_name}"
245 typer.echo(f" ❌ {display:<{max_name}} (no callers detected)")
246
247 typer.echo("\n" + "─" * 62)
248 typer.echo(f"Coverage: {n_covered}/{total} methods called ({pct}%)")
249
250 if pct == 100:
251 typer.echo("✅ Full coverage — all methods are called at least once.")
252 elif pct >= 75:
253 typer.echo(f"🟢 Good coverage — {total - n_covered} uncovered method(s).")
254 elif pct >= 50:
255 typer.echo(f"🟡 Partial coverage — {total - n_covered} uncovered method(s) may be dead API surface.")
256 else:
257 typer.echo(f"🔴 Low coverage — {total - n_covered} of {total} methods have no detected callers.")
258
259 typer.echo(
260 "\nNote: dynamic dispatch, subclass overrides, and external callers are not detected."
261 )