coverage.py
python
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