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