gabriel / muse public
coverage.py python
617 lines 22.3 KB
Raw
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
1 """muse code 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 code 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 * Tracking coverage drift across commits with ``--compare``.
25
26 Usage::
27
28 muse code coverage "src/models.py::User"
29 muse code coverage "src/auth.py::TokenValidator" --commit HEAD~5
30 muse code coverage "src/billing.py::Invoice" --json
31 muse code coverage "src/billing.py::Invoice" --exclude-dunder
32 muse code coverage "src/billing.py::Invoice" --exclude-self
33 muse code coverage "src/billing.py::Invoice" --min-callers 2
34 muse code coverage "src/billing.py::Invoice" --compare HEAD~10
35 muse code coverage "src/billing.py::Invoice" --count
36
37 Output::
38
39 Interface coverage: src/models.py::User
40 ──────────────────────────────────────────────────────────────
41
42 ✅ User.__init__ called by: src/api.py::create_user, src/api.py::update_user
43 ✅ User.save called by: src/api.py::create_user
44 ❌ User.delete (no callers detected)
45 ❌ User.to_dict (no callers detected)
46
47 ──────────────────────────────────────────────────────────────
48 Coverage: 2/4 methods called (50%)
49 🟡 Partial coverage — 2 uncovered method(s) may be dead API surface.
50
51 Flags:
52
53 ``--commit, -c REF``
54 Analyse a historical snapshot instead of HEAD.
55
56 ``--compare REF``
57 Diff coverage against this commit. Shows which methods gained or lost
58 callers between the two snapshots.
59
60 ``--exclude-dunder``
61 Exclude dunder methods (``__init__``, ``__repr__``, ``__eq__``, …) from
62 the coverage count. These are called implicitly by Python internals and
63 never appear in the call graph, producing unavoidable false negatives.
64
65 ``--exclude-private``
66 Exclude methods whose name starts with a single underscore.
67
68 ``--min-callers N``
69 A method only counts as "covered" when called from at least N distinct
70 call-sites. Separates "used in one test" from "widely adopted".
71
72 ``--exclude-self``
73 Only count callers outside the class's own file. Answers: "does anyone
74 *external* actually use this method?"
75
76 ``--no-show-callers``
77 Suppress caller addresses next to each covered method.
78
79 ``--count``
80 Print only ``n_covered/total`` (scriptable).
81
82 ``--json``
83 Emit results as JSON.
84 """
85
86 from __future__ import annotations
87
88 import argparse
89 import difflib
90 import json
91 import logging
92 import pathlib
93 import sys
94
95 from muse.core._types import short_id
96 from muse.core.errors import ExitCode
97 from muse.core.repo import read_repo_id, require_repo
98 from collections.abc import Callable
99 from typing import TypedDict
100
101 from muse.core.store import (
102 CommitRecord,
103 get_commit_snapshot_manifest,
104 read_current_branch,
105 resolve_commit_ref,
106 )
107 from muse.core.symbol_cache import load_symbol_cache
108 from muse.core.envelope import EnvelopeJson, make_envelope
109 from muse.core.timing import start_timer
110 from muse.plugins.code._callgraph import ReverseGraph, build_reverse_graph
111 from muse.plugins.code._query import symbols_for_snapshot
112 from muse.plugins.code.ast_parser import SymbolRecord
113 from muse.core.validation import sanitize_display
114
115 logger = logging.getLogger(__name__)
116
117 type _SymbolMap = dict[str, dict[str, SymbolRecord]]
118 type _AddrKindMap = dict[str, str]
119
120
121 class _CoverageFilters(TypedDict):
122 exclude_dunder: bool
123 exclude_private: bool
124 min_callers: int
125 exclude_self: bool
126
127
128 class _MethodJson(TypedDict):
129 address: str
130 name: str
131 called: bool
132 callers: list[str]
133
134
135 class _CoveragePayload(EnvelopeJson, total=False):
136 """JSON output for ``muse code coverage``.
137
138 Fields
139 ------
140 address Class symbol address analysed.
141 commit_id Commit that was analysed.
142 total_methods Total methods after filters applied.
143 covered Methods with at least --min-callers callers.
144 percent Coverage percentage (0–100).
145 filters Echo of the filter arguments used.
146 methods Per-method detail: address, name, called, callers.
147 compare_commit_id Present when --compare is used.
148 newly_covered Methods that gained callers vs --compare snapshot.
149 newly_uncovered Methods that lost callers vs --compare snapshot.
150 percent_change Coverage delta vs --compare snapshot.
151 """
152
153 address: str
154 commit_id: str
155 total_methods: int
156 covered: int
157 percent: float
158 filters: _CoverageFilters
159 methods: list[_MethodJson]
160 compare_commit_id: str
161 newly_covered: list[str]
162 newly_uncovered: list[str]
163 percent_change: float
164
165
166 _METHOD_KINDS: frozenset[str] = frozenset({"method", "async_method"})
167 _CLASS_KINDS: frozenset[str] = frozenset({"class", "async_class"})
168
169
170
171 # ---------------------------------------------------------------------------
172 # Analysis helpers
173 # ---------------------------------------------------------------------------
174
175
176 def _class_methods(
177 file_path: str,
178 class_name: str,
179 symbol_map: _SymbolMap,
180 ) -> list[tuple[str, str]]:
181 """Return ``(address, bare_name)`` pairs for all methods of *class_name*.
182
183 Uses a direct dict lookup on *file_path* — O(1) instead of iterating all
184 files in the symbol map.
185 """
186 methods: list[tuple[str, str]] = []
187 prefix = f"{file_path}::{class_name}."
188 tree = symbol_map.get(file_path, {})
189 for address, rec in sorted(tree.items()):
190 if rec["kind"] not in _METHOD_KINDS:
191 continue
192 if address.startswith(prefix):
193 bare = rec["name"].split(".")[-1]
194 methods.append((address, bare))
195 return sorted(methods, key=lambda t: t[1])
196
197
198 def _filter_methods(
199 methods: list[tuple[str, str]],
200 exclude_dunder: bool,
201 exclude_private: bool,
202 ) -> list[tuple[str, str]]:
203 """Apply name-based filters to the raw method list."""
204 result: list[tuple[str, str]] = []
205 for addr, bare in methods:
206 if exclude_dunder and bare.startswith("__") and bare.endswith("__"):
207 continue
208 if exclude_private and bare.startswith("_") and not (bare.startswith("__") and bare.endswith("__")):
209 continue
210 result.append((addr, bare))
211 return result
212
213
214 def _classify_methods(
215 methods: list[tuple[str, str]],
216 reverse: ReverseGraph,
217 min_callers: int,
218 exclude_self_file: str | None,
219 ) -> tuple[list[tuple[str, str, list[str]]], list[tuple[str, str]]]:
220 """Separate methods into covered and uncovered lists.
221
222 Returns ``(covered, uncovered)`` where covered entries are
223 ``(address, bare_name, callers)`` and uncovered are ``(address, bare_name)``.
224 """
225 covered: list[tuple[str, str, list[str]]] = []
226 uncovered: list[tuple[str, str]] = []
227
228 for method_addr, bare_name in methods:
229 all_callers = sorted(reverse.get(bare_name, []))
230 if exclude_self_file:
231 all_callers = [c for c in all_callers if c.split("::")[0] != exclude_self_file]
232 if len(all_callers) >= min_callers:
233 covered.append((method_addr, bare_name, all_callers))
234 else:
235 uncovered.append((method_addr, bare_name))
236
237 return covered, uncovered
238
239
240 def _analyse_snapshot(
241 root: pathlib.Path,
242 commit: CommitRecord,
243 file_path: str,
244 class_name: str,
245 exclude_dunder: bool,
246 exclude_private: bool,
247 min_callers: int,
248 exclude_self_file: str | None,
249 ) -> tuple[
250 list[tuple[str, str, list[str]]],
251 list[tuple[str, str]],
252 list[tuple[str, str]],
253 ]:
254 """Full analysis pipeline for one commit snapshot.
255
256 Returns ``(covered, uncovered, all_methods)`` after applying all filters.
257 Uses a single shared ``SymbolCache`` for both ``symbols_for_snapshot``
258 and ``build_reverse_graph``.
259 """
260 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
261 cache = load_symbol_cache(root)
262 symbol_map = symbols_for_snapshot(root, manifest, cache=cache)
263 reverse = build_reverse_graph(root, manifest, cache=cache)
264 cache.save()
265
266 all_methods = _class_methods(file_path, class_name, symbol_map)
267 filtered = _filter_methods(all_methods, exclude_dunder, exclude_private)
268 covered, uncovered = _classify_methods(filtered, reverse, min_callers, exclude_self_file)
269 return covered, uncovered, filtered
270
271
272 def _find_class_suggestions(
273 class_addr: str,
274 file_path: str,
275 class_name: str,
276 symbol_map: _SymbolMap,
277 ) -> tuple[list[str], list[str], list[str]]:
278 """Return (same_file_classes, same_name_classes, fuzzy_matches)."""
279 all_syms: _AddrKindMap = {
280 addr: rec["kind"]
281 for tree in symbol_map.values()
282 for addr, rec in tree.items()
283 }
284 same_file = sorted(
285 a for a, k in all_syms.items()
286 if k in _CLASS_KINDS and a.startswith(f"{file_path}::")
287 )
288 same_name = sorted(
289 a for a, k in all_syms.items()
290 if k in _CLASS_KINDS and a.endswith(f"::{class_name}")
291 )
292 all_class_addrs = [a for a, k in all_syms.items() if k in _CLASS_KINDS]
293 fuzzy = difflib.get_close_matches(class_addr, all_class_addrs, n=5, cutoff=0.4)
294 fuzzy = [a for a in fuzzy if a not in same_file and a not in same_name]
295 return same_file, same_name, fuzzy
296
297
298 # ---------------------------------------------------------------------------
299 # CLI registration
300 # ---------------------------------------------------------------------------
301
302
303 def register(subparsers: "argparse._SubParsersAction[argparse.ArgumentParser]") -> None:
304 """Register the coverage subcommand."""
305 parser = subparsers.add_parser(
306 "coverage",
307 help="Show which methods of a class are called anywhere in the snapshot.",
308 description=__doc__,
309 formatter_class=argparse.RawDescriptionHelpFormatter,
310 )
311 parser.add_argument(
312 "address", metavar="CLASS_ADDRESS",
313 help='Class symbol address, e.g. "src/models.py::User".',
314 )
315 parser.add_argument(
316 "--commit", "-c", default=None, metavar="REF", dest="ref",
317 help="Analyse a historical snapshot instead of HEAD.",
318 )
319 parser.add_argument(
320 "--compare", default=None, metavar="REF", dest="compare_ref",
321 help="Diff coverage against this commit (shows methods gained/lost).",
322 )
323 parser.add_argument(
324 "--exclude-dunder", action="store_true", dest="exclude_dunder",
325 help="Exclude dunder methods (__init__, __repr__, …) from the count.",
326 )
327 parser.add_argument(
328 "--exclude-private", action="store_true", dest="exclude_private",
329 help="Exclude single-underscore private methods from the count.",
330 )
331 parser.add_argument(
332 "--min-callers", type=int, default=1, metavar="N", dest="min_callers",
333 help="Minimum distinct call-sites to count a method as covered (default: 1).",
334 )
335 parser.add_argument(
336 "--exclude-self", action="store_true", dest="exclude_self",
337 help="Only count callers outside the class's own file.",
338 )
339 parser.add_argument(
340 "--no-show-callers", action="store_false", dest="show_callers",
341 help="Suppress caller addresses next to each covered method.",
342 )
343 parser.add_argument(
344 "--count", action="store_true", dest="count_only",
345 help="Print only 'n_covered/total' (scriptable).",
346 )
347 parser.add_argument(
348 "--json", "-j", action="store_true", dest="as_json",
349 help="Emit results as JSON.",
350 )
351 parser.set_defaults(func=run, show_callers=True)
352
353
354 # ---------------------------------------------------------------------------
355 # Command entry point
356 # ---------------------------------------------------------------------------
357
358
359 def run(args: argparse.Namespace) -> None:
360 """Show which methods of a class are called anywhere in the snapshot.
361
362 Builds the reverse call graph and checks each method's name against the set
363 of called names. Reports covered / uncovered methods and a coverage
364 percentage. Python only. Use ``--compare`` to see coverage delta between
365 two commits.
366
367 Agent quickstart
368 ----------------
369 ::
370
371 muse code coverage "billing.py::BillingService" --json
372 muse code coverage "billing.py::BillingService" --compare HEAD~5 --json
373 muse code coverage "billing.py::BillingService" --show-callers --json
374
375 JSON fields
376 -----------
377 address Class symbol address analysed.
378 commit_id Commit analysed.
379 total_methods Total methods after filters applied.
380 covered Methods with at least ``--min-callers`` callers.
381 percent Coverage percentage (0–100).
382 filters Echo of filter arguments.
383 methods List of method objects: ``address``, ``name``,
384 ``called`` (bool), ``callers`` (list with ``--show-callers``).
385 compare_commit_id Present with ``--compare``.
386 newly_covered Methods that gained callers vs the compare snapshot.
387 newly_uncovered Methods that lost callers vs the compare snapshot.
388 percent_change Coverage delta vs the compare snapshot.
389
390 Exit codes
391 ----------
392 0 Analysis complete.
393 1 Invalid address or ref not found.
394 2 Not inside a Muse repository.
395 """
396 elapsed = start_timer()
397 address: str = args.address
398 ref: str | None = args.ref
399 compare_ref: str | None = args.compare_ref
400 show_callers: bool = args.show_callers
401 exclude_dunder: bool = args.exclude_dunder
402 exclude_private: bool = args.exclude_private
403 min_callers: int = max(1, args.min_callers)
404 exclude_self: bool = args.exclude_self
405 count_only: bool = args.count_only
406 as_json: bool = args.as_json
407
408 root = require_repo()
409 repo_id = read_repo_id(root)
410 branch = read_current_branch(root)
411
412 if "::" not in address:
413 print("❌ ADDRESS must be a symbol address like 'src/models.py::User'.", file=sys.stderr)
414 raise SystemExit(ExitCode.USER_ERROR)
415
416 file_path, class_name = address.split("::", 1)
417 class_addr = f"{file_path}::{class_name}"
418 exclude_self_file = file_path if exclude_self else None
419
420 commit = resolve_commit_ref(root, repo_id, branch, ref)
421 if commit is None:
422 print(f"❌ Commit '{ref or 'HEAD'}' not found.", file=sys.stderr)
423 raise SystemExit(ExitCode.USER_ERROR)
424
425 # Verify the class exists — load symbol map for validation and suggestions.
426 manifest = get_commit_snapshot_manifest(root, commit.commit_id) or {}
427 cache = load_symbol_cache(root)
428 symbol_map = symbols_for_snapshot(root, manifest, cache=cache)
429
430 tree_for_file = symbol_map.get(file_path, {})
431 if class_addr not in tree_for_file:
432 cache.save()
433 _emit_not_found(class_addr, file_path, class_name, symbol_map, as_json, commit, elapsed)
434 raise SystemExit(ExitCode.USER_ERROR)
435
436 all_methods = _class_methods(file_path, class_name, symbol_map)
437 if not all_methods:
438 cache.save()
439 print(f"⚠️ No methods found for '{class_addr}'.", file=sys.stderr)
440 raise SystemExit(ExitCode.USER_ERROR)
441
442 # Build reverse graph sharing the already-warm cache.
443 reverse = build_reverse_graph(root, manifest, cache=cache)
444 cache.save()
445
446 filtered = _filter_methods(all_methods, exclude_dunder, exclude_private)
447 covered, uncovered = _classify_methods(filtered, reverse, min_callers, exclude_self_file)
448
449 total = len(filtered)
450 n_covered = len(covered)
451 pct = round(n_covered / total * 100) if total else 0
452
453 # --compare diff
454 compare_commit: CommitRecord | None = None
455 newly_covered: list[str] = []
456 newly_uncovered: list[str] = []
457 pct_change: int = 0
458
459 if compare_ref:
460 compare_commit = resolve_commit_ref(root, repo_id, branch, compare_ref)
461 if compare_commit is None:
462 print(f"❌ --compare commit '{compare_ref}' not found.", file=sys.stderr)
463 raise SystemExit(ExitCode.USER_ERROR)
464 cmp_covered, cmp_uncovered, _ = _analyse_snapshot(
465 root, compare_commit, file_path, class_name,
466 exclude_dunder, exclude_private, min_callers, exclude_self_file,
467 )
468 cmp_covered_names = {name for _, name, _ in cmp_covered}
469 cur_covered_names = {name for _, name, _ in covered}
470 newly_covered = sorted(cur_covered_names - cmp_covered_names)
471 newly_uncovered = sorted(cmp_covered_names - cur_covered_names)
472 cmp_total = len(cmp_covered) + len(cmp_uncovered)
473 cmp_pct = round(len(cmp_covered) / cmp_total * 100) if cmp_total else 0
474 pct_change = pct - cmp_pct
475
476 # --count
477 if count_only and not as_json:
478 print(f"{n_covered}/{total}")
479 return
480
481 if as_json:
482 methods: list[_MethodJson] = [
483 _MethodJson(address=addr, name=name, called=True, callers=callers)
484 for addr, name, callers in covered
485 ] + [
486 _MethodJson(address=addr, name=name, called=False, callers=[])
487 for addr, name in uncovered
488 ]
489 payload: _CoveragePayload = {
490 **make_envelope(elapsed),
491 "address": class_addr,
492 "commit_id": commit.commit_id,
493 "total_methods": total,
494 "covered": n_covered,
495 "percent": pct,
496 "filters": _CoverageFilters(
497 exclude_dunder=exclude_dunder,
498 exclude_private=exclude_private,
499 min_callers=min_callers,
500 exclude_self=exclude_self,
501 ),
502 "methods": methods,
503 }
504 if compare_commit is not None:
505 payload["compare_commit_id"] = compare_commit.commit_id
506 payload["newly_covered"] = newly_covered
507 payload["newly_uncovered"] = newly_uncovered
508 payload["percent_change"] = pct_change
509 print(json.dumps(payload))
510 return
511
512 print(f"\nInterface coverage: {class_addr}")
513 active_filters: list[str] = []
514 if exclude_dunder:
515 active_filters.append("no dunders")
516 if exclude_private:
517 active_filters.append("no private")
518 if min_callers > 1:
519 active_filters.append(f"min {min_callers} callers")
520 if exclude_self:
521 active_filters.append("external callers only")
522 if active_filters:
523 print(f"Filters: {', '.join(active_filters)}")
524 print("─" * 62)
525
526 max_name = max(
527 (len(f"{class_name}.{name}") for _, name in filtered),
528 default=0,
529 )
530
531 for addr, bare_name, callers in covered:
532 display = f"{class_name}.{bare_name}"
533 line = f" ✅ {display:<{max_name}}"
534 if show_callers:
535 caller_str = ", ".join(callers[:3])
536 if len(callers) > 3:
537 caller_str += f" (+{len(callers) - 3} more)"
538 line += f" ← {caller_str}"
539 print(line)
540
541 for addr, bare_name in uncovered:
542 display = f"{class_name}.{bare_name}"
543 print(f" ❌ {display:<{max_name}} (no callers detected)")
544
545 print("\n" + "─" * 62)
546 print(f"Coverage: {n_covered}/{total} methods called ({pct}%)")
547
548 if pct == 100:
549 print("✅ Full coverage — all methods are called at least once.")
550 elif pct >= 75:
551 print(f"🟢 Good coverage — {total - n_covered} uncovered method(s).")
552 elif pct >= 50:
553 print(f"🟡 Partial coverage — {total - n_covered} uncovered method(s) may be dead API surface.")
554 else:
555 print(f"🔴 Low coverage — {total - n_covered} of {total} methods have no detected callers.")
556
557 if compare_commit is not None:
558 sign = "+" if pct_change >= 0 else ""
559 print(f"\nCoverage diff vs {short_id(compare_commit.commit_id)}: {sign}{pct_change}%")
560 if newly_covered:
561 print(f" Newly covered ({len(newly_covered)}): {', '.join(newly_covered)}")
562 if newly_uncovered:
563 print(f" Lost coverage ({len(newly_uncovered)}): {', '.join(newly_uncovered)}")
564
565 print(
566 "\nNote: dynamic dispatch, subclass overrides, and external callers are not detected."
567 )
568
569
570 # ---------------------------------------------------------------------------
571 # Error rendering
572 # ---------------------------------------------------------------------------
573
574
575 def _emit_not_found(
576 class_addr: str,
577 file_path: str,
578 class_name: str,
579 symbol_map: _SymbolMap,
580 as_json: bool,
581 commit: CommitRecord,
582 elapsed: Callable[[], float],
583 ) -> None:
584 """Print a helpful not-found error with suggestions."""
585 same_file, same_name, fuzzy = _find_class_suggestions(
586 class_addr, file_path, class_name, symbol_map
587 )
588
589 if as_json:
590 print(json.dumps({**make_envelope(elapsed, exit_code=1), **{
591 "error": "symbol_not_found",
592 "address": class_addr,
593 "commit_id": commit.commit_id,
594 "suggestions": same_file[:8] or same_name[:5] or fuzzy[:5],
595 }}))
596 return
597
598 print(f"❌ '{class_addr}' not found in snapshot {short_id(commit.commit_id)}.", file=sys.stderr)
599
600 if same_file:
601 print(f"\n Classes in {sanitize_display(file_path)}:", file=sys.stderr)
602 for c in same_file[:8]:
603 print(f" {c}", file=sys.stderr)
604 if same_name and same_name != same_file:
605 print(f"\n '{class_name}' found at:", file=sys.stderr)
606 for c in same_name[:5]:
607 print(f" {c}", file=sys.stderr)
608 if fuzzy and not same_file and not same_name:
609 print("\n Did you mean:", file=sys.stderr)
610 for c in fuzzy[:5]:
611 print(f" {c}", file=sys.stderr)
612 if not same_file and not same_name and not fuzzy:
613 print(
614 f"\n No classes found in {file_path}. "
615 "Check 'muse code symbols --json | jq' for valid addresses.",
616 file=sys.stderr,
617 )
File History 2 commits
sha256:88ac91129873e6a496e9189515aa690eb893ae25d69c8f72af141a2be5068eb3 docs: docstring sprint contract→find-symbol — idiomatic run… Sonnet 4.6 patch 139 days ago
sha256:a09b1b4f6838754495547f200aa0ce88e2f56ffc5b20b900f6f0cff2c3cdede9 fix(cursorignore): remove git-ism (.git/worktrees) Human minor 142 days ago