semantic_test_coverage.py python
846 lines 30.8 KB
Raw
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago
1 """muse code semantic-test-coverage — static symbol-level test coverage.
2
3 Traditional coverage tools measure *which lines execute* during a test run.
4 They require an instrumented test suite, a working interpreter, and real I/O.
5
6 This command answers a different question:
7
8 **Which symbols are exercised by which test functions — without running any
9 tests?**
10
11 Because Muse tracks the complete symbol graph of every snapshot, we can
12 perform static call-graph analysis to determine — at the symbol level, across
13 every production file — which test functions exercise which symbols. No test
14 runner. No instrumentation. No I/O.
15
16 Coverage tiers
17 --------------
18 ``--depth 1`` (default, "direct")
19 A production symbol is covered if any test function body directly calls it
20 by bare name (e.g. ``compute_total(...)`` or ``obj.compute_total(...)``).
21 This is a conservative but high-precision signal.
22
23 ``--depth N / --transitive`` (N > 1)
24 Extends direct coverage by following the production call graph up to N−1
25 additional hops. If ``test_process_order`` calls ``process_order`` and
26 ``process_order`` calls ``Invoice.compute_total``, then
27 ``Invoice.compute_total`` is covered at depth 2.
28
29 Scope
30 -----
31 Production symbols
32 Every non-import symbol in a non-test file. Classes, functions, methods,
33 async functions, and async methods are all included.
34
35 Test functions
36 Every ``test_``-prefixed function in a test file, including methods inside
37 ``Test*`` classes. All top-level functions in ``conftest.py`` (fixtures)
38 are included because they often call production code directly.
39
40 Usage::
41
42 muse code semantic-test-coverage
43 muse code semantic-test-coverage --file billing.py
44 muse code semantic-test-coverage --kind function
45 muse code semantic-test-coverage --uncovered-only
46 muse code semantic-test-coverage --show-tests
47 muse code semantic-test-coverage --transitive --depth 2
48 muse code semantic-test-coverage --min-coverage 80
49 muse code semantic-test-coverage --json
50
51 Output::
52
53 Semantic test coverage — HEAD (378 symbols · 47 test functions)
54
55 billing.py
56 [████████████████░░░░] 80.0% (4/5)
57 ✅ compute_total meth
58 ✅ apply_discount meth
59 ✅ process_order func
60 ✅ Invoice clas
61 ❌ generate_pdf meth
62
63 ────────────────────────────────────────────────────────────────
64 TOTAL: 285/378 symbols covered (75.4%)
65
66 JSON output (``--json``)::
67
68 {
69 "ref": "HEAD",
70 "snapshot_id": "a3f2c9e1ab2c",
71 "depth": 1,
72 "transitive": false,
73 "filters": { "file": null, "kind": null, "min_coverage": null,
74 "uncovered_only": false },
75 "summary": {
76 "total_symbols": 378,
77 "covered_symbols": 285,
78 "uncovered_symbols": 93,
79 "coverage_pct": 75.4,
80 "total_test_functions": 47,
81 "total_production_files": 28
82 },
83 "files": [
84 {
85 "file": "billing.py",
86 "total_symbols": 5,
87 "covered_symbols": 4,
88 "uncovered_symbols": 1,
89 "coverage_pct": 80.0,
90 "symbols": [
91 {
92 "address": "billing.py::Invoice.compute_total",
93 "name": "compute_total",
94 "kind": "method",
95 "covered": true,
96 "test_functions": ["tests/test_billing.py::test_compute_total_basic"]
97 }
98 ]
99 }
100 ]
101 }
102
103 ``--min-coverage PCT`` — CI integration::
104
105 # Exit 1 if any file falls below 80% semantic coverage
106 muse code semantic-test-coverage --min-coverage 80
107
108 Performance
109 -----------
110 A single pass loads all Python blobs from the committed object store into
111 memory. AST parsing and walking are done once per file. For transitive
112 coverage, the production call graph is built from the already-loaded blobs
113 without a second store read. Typical runtimes:
114
115 200 files / 1 000 symbols / 100 test functions → < 2 s
116 1 000 files / 10 000 symbols / 500 test functions → < 10 s
117
118 Accuracy note
119 -------------
120 This is a *static* analysis. Dynamic dispatch, conditional imports, and
121 runtime code generation cannot be captured without execution. The analysis
122 may report:
123
124 * False negatives: dynamically-constructed call sites (``getattr(obj, name)()``)
125 are not detected.
126 * False positives: names like ``save`` match any production symbol named
127 ``save``, regardless of the actual class/instance.
128
129 Use the results as a coverage *signal*, not a proof.
130 """
131
132 from __future__ import annotations
133
134 import argparse
135 import ast
136 import json
137 import logging
138 import pathlib
139 import re
140 import sys
141 from collections import defaultdict
142 from typing import TypedDict
143
144 from muse.core.errors import ExitCode
145 from muse.core.repo import read_repo_id, require_repo
146 from muse.core.store import (
147 Manifest,
148 get_commit_snapshot_manifest,
149 read_current_branch,
150 resolve_commit_ref,
151 )
152 from muse.plugins.code._callgraph import ForwardGraph, call_name, find_func_node
153 from muse.plugins.code._query import symbols_for_snapshot
154 from muse.plugins.code.ast_parser import SymbolRecord, parse_symbols
155 from muse.core.validation import clamp_int, MAX_AST_BYTES, sanitize_display
156
157 logger = logging.getLogger(__name__)
158
159 type BlobMap = dict[str, bytes] # file_path → raw blob bytes
160 type TestRefs = dict[str, set[str]] # test_addr → set of callee names
161 type CoverageMap = dict[str, set[str]] # prod_addr → set of test_addrs
162 type NameToAddrs = dict[str, list[str]] # bare_name → list of prod_addrs
163 type SymbolTreeMap = dict[str, dict[str, SymbolRecord]] # file_path → symbol tree
164 type FlatSymbolMap = dict[str, SymbolRecord] # address → symbol record
165
166
167
168 # ── Constants ──────────────────────────────────────────────────────────────────
169
170 _DEFAULT_TOP = 0 # 0 = unlimited
171 _DEFAULT_DEPTH = 1
172 _MAX_DEPTH = 10
173
174 _PY_SUFFIXES: frozenset[str] = frozenset({".py", ".pyi"})
175
176 # Test file heuristics — same patterns used across the code-domain commands.
177 _TEST_PATTERNS: tuple[re.Pattern[str], ...] = (
178 re.compile(r"(^|/)test_"),
179 re.compile(r"_test\.py$"),
180 re.compile(r"(^|/)tests/"),
181 re.compile(r"(^|/)spec/"),
182 re.compile(r"(^|/)conftest\.py$"),
183 )
184
185 _IMPORT_KIND = "import"
186
187 # Symbol kinds surfaced by this command.
188 _TRACKED_KINDS: frozenset[str] = frozenset(
189 {"function", "async_function", "method", "async_method", "class"}
190 )
191
192
193 # ── TypedDicts ─────────────────────────────────────────────────────────────────
194
195
196 class _SymbolCov(TypedDict):
197 """Coverage record for a single production symbol."""
198
199 address: str
200 name: str
201 kind: str
202 covered: bool
203 test_functions: list[str]
204
205
206 class _FileCov(TypedDict):
207 """Aggregated coverage record for one production file."""
208
209 file: str
210 total_symbols: int
211 covered_symbols: int
212 uncovered_symbols: int
213 coverage_pct: float
214 symbols: list[_SymbolCov]
215
216
217 class _FilterSpec(TypedDict):
218 """Filters applied to this analysis run."""
219
220 file: str | None
221 kind: str | None
222 min_coverage: int | None
223 uncovered_only: bool
224
225
226 class _SummarySpec(TypedDict):
227 """Aggregate statistics across all production files."""
228
229 total_symbols: int
230 covered_symbols: int
231 uncovered_symbols: int
232 coverage_pct: float
233 total_test_functions: int
234 total_production_files: int
235
236
237 class _JsonOut(TypedDict):
238 """Top-level JSON output structure."""
239
240 ref: str
241 snapshot_id: str
242 depth: int
243 transitive: bool
244 filters: _FilterSpec
245 summary: _SummarySpec
246 files: list[_FileCov]
247
248
249 # ── Test-file detection ────────────────────────────────────────────────────────
250
251
252 def _is_test_file(file_path: str) -> bool:
253 """Return True if *file_path* looks like a test or conftest file."""
254 return any(p.search(file_path) for p in _TEST_PATTERNS)
255
256
257 def _is_conftest(file_path: str) -> bool:
258 """Return True if *file_path* is a conftest module."""
259 return pathlib.PurePosixPath(file_path).name == "conftest.py"
260
261
262 # ── AST helpers ────────────────────────────────────────────────────────────────
263
264
265 def _collect_test_funcs(
266 stmts: list[ast.stmt],
267 prefix: str,
268 is_conftest: bool,
269 ) -> list[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]]:
270 """Recursively collect test function nodes from *stmts*.
271
272 Handles top-level test functions and methods inside ``Test*`` classes.
273 ``conftest.py`` includes all functions (fixture bodies also call production
274 code).
275
276 Args:
277 stmts: Statement list from a module or class body.
278 prefix: Dotted qualification prefix accumulated from enclosing classes.
279 is_conftest: True when the file is ``conftest.py``; includes all functions.
280
281 Returns:
282 List of ``(qualified_name, node)`` pairs for each test entry point found.
283 """
284 results: list[tuple[str, ast.FunctionDef | ast.AsyncFunctionDef]] = []
285 for stmt in stmts:
286 if isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
287 qname = f"{prefix}.{stmt.name}" if prefix else stmt.name
288 if is_conftest or stmt.name.startswith("test_"):
289 results.append((qname, stmt))
290 elif isinstance(stmt, ast.ClassDef):
291 new_prefix = f"{prefix}.{stmt.name}" if prefix else stmt.name
292 results.extend(_collect_test_funcs(stmt.body, new_prefix, is_conftest))
293 return results
294
295
296 def _scan_calls(func_node: ast.FunctionDef | ast.AsyncFunctionDef) -> set[str]:
297 """Return the set of bare callee names called inside *func_node*.
298
299 Only ``ast.Call`` nodes are examined. Bare name references without a call
300 are excluded (they do not indicate the symbol is exercised).
301
302 Args:
303 func_node: The function or async-function AST node to scan.
304
305 Returns:
306 Set of bare callee names (e.g. ``{"compute_total", "Invoice"}``)
307 """
308 names: set[str] = set()
309 for node in ast.walk(func_node):
310 if isinstance(node, ast.Call):
311 name = call_name(node.func)
312 if name:
313 names.add(name)
314 return names
315
316
317 # ── Data collection ────────────────────────────────────────────────────────────
318
319
320 def _load_py_blobs(
321 root: pathlib.Path,
322 manifest: Manifest,
323 ) -> BlobMap:
324 """Load every Python/stub blob from *manifest* into memory.
325
326 A single bulk read avoids redundant object-store I/O for subsequent AST
327 parsing and call-graph construction phases.
328
329 Args:
330 root: Repository root (object store location).
331 manifest: Snapshot manifest from ``get_commit_snapshot_manifest``.
332
333 Returns:
334 Mapping ``{file_path: raw_bytes}`` for every Python file in the snapshot.
335 """
336 from muse.core.object_store import read_object as _read_obj
337
338 blobs: BlobMap = {}
339 for file_path, obj_id in manifest.items():
340 if pathlib.PurePosixPath(file_path).suffix.lower() not in _PY_SUFFIXES:
341 continue
342 raw = _read_obj(root, obj_id)
343 if raw is not None:
344 blobs[file_path] = raw
345 return blobs
346
347
348 def _build_test_refs(blobs: BlobMap) -> TestRefs:
349 """Scan test files and return ``{test_addr: set[bare_callee_name]}``.
350
351 Args:
352 blobs: Pre-loaded mapping from ``_load_py_blobs``.
353
354 Returns:
355 A mapping from each test-function address to the set of bare callee
356 names called anywhere inside that function body.
357 """
358 refs: TestRefs = {}
359 for file_path, raw in blobs.items():
360 if not _is_test_file(file_path):
361 continue
362 try:
363 if len(raw) > MAX_AST_BYTES:
364 return {}
365 tree = ast.parse(raw)
366 except SyntaxError:
367 logger.warning("SyntaxError in %s — test file skipped", file_path)
368 continue
369 is_conf = _is_conftest(file_path)
370 for qname, func_node in _collect_test_funcs(tree.body, "", is_conf):
371 addr = f"{file_path}::{qname}"
372 refs[addr] = _scan_calls(func_node)
373 return refs
374
375
376 def _build_prod_forward_graph(
377 blobs: BlobMap,
378 prod_files: set[str],
379 ) -> ForwardGraph:
380 """Build a forward call graph for production files using pre-loaded blobs.
381
382 Reuses ``blobs`` to avoid a second round-trip through the object store.
383 Only callable symbols (functions, methods) are included in the graph.
384
385 Args:
386 blobs: Pre-loaded Python blobs from ``_load_py_blobs``.
387 prod_files: Set of production file paths to include in the graph.
388
389 Returns:
390 ``{caller_address: frozenset[callee_bare_name]}``
391 """
392 graph: ForwardGraph = {}
393 for file_path, raw in blobs.items():
394 if file_path not in prod_files:
395 continue
396 try:
397 if len(raw) > MAX_AST_BYTES:
398 return {}
399 tree = ast.parse(raw)
400 except SyntaxError:
401 continue
402 sym_tree = parse_symbols(raw, file_path)
403 for addr, rec in sym_tree.items():
404 if rec["kind"] not in {"function", "async_function", "method", "async_method"}:
405 continue
406 func_node = find_func_node(tree.body, rec["qualified_name"].split("."))
407 if func_node is None:
408 continue
409 callees: set[str] = set()
410 for node in ast.walk(func_node):
411 if isinstance(node, ast.Call):
412 n = call_name(node.func)
413 if n:
414 callees.add(n)
415 graph[addr] = frozenset(callees)
416 return graph
417
418
419 def _compute_coverage(
420 test_refs: TestRefs,
421 name_to_prod_addrs: NameToAddrs,
422 forward_graph: ForwardGraph | None,
423 depth: int,
424 ) -> CoverageMap:
425 """Compute ``{prod_addr: {test_addr, ...}}`` coverage mapping.
426
427 Phase 1 — Direct coverage:
428 For each test function, find every bare callee name that matches a
429 production symbol address via *name_to_prod_addrs*.
430
431 Phase 2 — Transitive expansion (only when ``forward_graph`` is provided
432 and ``depth > 1``):
433 BFS from each directly-covered production symbol, following calls in
434 *forward_graph*. Each newly-reached production symbol is added to the
435 test function's coverage set. The BFS is capped at ``depth - 1``
436 additional hops.
437
438 Args:
439 test_refs: ``{test_addr: set[bare_callee_name]}``.
440 name_to_prod_addrs: Reverse index: ``{bare_name: [prod_addr, ...]}``.
441 forward_graph: Production call graph; ``None`` for direct-only mode.
442 depth: Total coverage depth (1 = direct only).
443
444 Returns:
445 ``{prod_addr: {test_addr, ...}}``
446 """
447 # Phase 1: direct coverage
448 coverage: CoverageMap = defaultdict(set)
449 test_to_direct: CoverageMap = defaultdict(set)
450
451 for test_addr, bare_names in test_refs.items():
452 for bare in bare_names:
453 for prod_addr in name_to_prod_addrs.get(bare, []):
454 coverage[prod_addr].add(test_addr)
455 test_to_direct[test_addr].add(prod_addr)
456
457 # Phase 2: transitive expansion
458 if forward_graph is not None and depth > 1:
459 for test_addr, direct_prods in test_to_direct.items():
460 frontier: set[str] = set(direct_prods)
461 visited: set[str] = set(direct_prods)
462 for _ in range(depth - 1):
463 next_frontier: set[str] = set()
464 for prod_addr in frontier:
465 for callee_bare in forward_graph.get(prod_addr, frozenset()):
466 for callee_addr in name_to_prod_addrs.get(callee_bare, []):
467 if callee_addr not in visited:
468 visited.add(callee_addr)
469 next_frontier.add(callee_addr)
470 coverage[callee_addr].add(test_addr)
471 if not next_frontier:
472 break
473 frontier = next_frontier
474
475 return dict(coverage)
476
477
478 # ── Output builders ────────────────────────────────────────────────────────────
479
480
481 def _build_file_coverage(
482 prod_trees: SymbolTreeMap,
483 coverage: CoverageMap,
484 file_filter: str | None,
485 kind_filter: str | None,
486 ) -> list[_FileCov]:
487 """Build structured ``_FileCov`` records for every production file.
488
489 Returns all symbols per file (uncovered-only filtering is applied at
490 display/JSON time so summary statistics always reflect the full picture).
491
492 Args:
493 prod_trees: ``{file_path: {addr: SymbolRecord}}`` for production files.
494 coverage: ``{prod_addr: {test_addr, ...}}`` from ``_compute_coverage``.
495 file_filter: Optional path suffix; only files containing this string are included.
496 kind_filter: Optional symbol kind; only symbols of this kind are included.
497
498 Returns:
499 List of ``_FileCov`` records sorted by file path.
500 """
501 result: list[_FileCov] = []
502 for file_path in sorted(prod_trees):
503 if file_filter and file_filter not in file_path:
504 continue
505 sym_tree = prod_trees[file_path]
506 syms: list[_SymbolCov] = []
507 for addr in sorted(sym_tree):
508 rec = sym_tree[addr]
509 if rec["kind"] == _IMPORT_KIND:
510 continue
511 if rec["kind"] not in _TRACKED_KINDS:
512 continue
513 if kind_filter and rec["kind"] != kind_filter:
514 continue
515 test_fns = sorted(coverage.get(addr, set()))
516 syms.append(
517 _SymbolCov(
518 address=addr,
519 name=rec["name"],
520 kind=rec["kind"],
521 covered=bool(test_fns),
522 test_functions=test_fns,
523 )
524 )
525 if not syms:
526 continue
527 covered_count = sum(1 for s in syms if s["covered"])
528 result.append(
529 _FileCov(
530 file=file_path,
531 total_symbols=len(syms),
532 covered_symbols=covered_count,
533 uncovered_symbols=len(syms) - covered_count,
534 coverage_pct=round(100.0 * covered_count / len(syms), 1),
535 symbols=syms,
536 )
537 )
538 return result
539
540
541 # ── Formatting ─────────────────────────────────────────────────────────────────
542
543 _BAR_WIDTH = 20
544 _KindAbbrevMap = dict[str, str]
545 _KIND_ABBREV: _KindAbbrevMap = {
546 "function": "func",
547 "async_function": "afn ",
548 "method": "meth",
549 "async_method": "amth",
550 "class": "cls ",
551 }
552
553
554 def _bar(pct: float) -> str:
555 """Return a 20-char Unicode block bar representing *pct* (0–100)."""
556 filled = round(pct / (100 / _BAR_WIDTH))
557 return "█" * filled + "░" * (_BAR_WIDTH - filled)
558
559
560 def _print_table(
561 files: list[_FileCov],
562 uncovered_only: bool,
563 show_tests: bool,
564 min_coverage: int,
565 ref: str,
566 total_test_fns: int,
567 ) -> bool:
568 """Print human-readable coverage table to stdout.
569
570 Args:
571 files: Structured file coverage from ``_build_file_coverage``.
572 uncovered_only: When True, only uncovered symbols are printed per file.
573 show_tests: When True, list covering test functions under each symbol.
574 min_coverage: Coverage threshold for ⚠️ flag and exit-1 signalling.
575 ref: Display string for the analysed ref (e.g. ``"HEAD"``).
576 total_test_fns: Total number of test functions found in the snapshot.
577
578 Returns:
579 True if any file violates *min_coverage*; False otherwise.
580 """
581 total_syms = sum(f["total_symbols"] for f in files)
582 total_covered = sum(f["covered_symbols"] for f in files)
583 total_pct = round(100.0 * total_covered / total_syms, 1) if total_syms else 0.0
584
585 print(
586 f"Semantic test coverage — {ref}"
587 f" ({total_syms} symbols · {total_test_fns} test functions)\n"
588 )
589
590 violation = False
591 for fc in files:
592 pct = fc["coverage_pct"]
593 bar = _bar(pct)
594 below = pct < min_coverage and min_coverage > 0
595 if below:
596 violation = True
597 flag = " ⚠️" if below else ""
598 print(f" {fc['file']}")
599 print(
600 f" [{bar}] {pct:5.1f}%"
601 f" ({fc['covered_symbols']}/{fc['total_symbols']}){flag}"
602 )
603
604 for sym in fc["symbols"]:
605 if uncovered_only and sym["covered"]:
606 continue
607 icon = "✅" if sym["covered"] else "❌"
608 kind_short = _KIND_ABBREV.get(sym["kind"], sym["kind"][:4])
609 print(f" {icon} {sanitize_display(sym['name']):<42} {kind_short}")
610 if show_tests and sym["test_functions"]:
611 for tf in sym["test_functions"]:
612 print(f" ← {tf}")
613 print()
614
615 sep = "─" * 64
616 print(f" {sep}")
617 print(f" TOTAL: {total_covered}/{total_syms} symbols covered ({total_pct}%)\n")
618 if violation:
619 print(
620 f" ⚠️ One or more files are below the {min_coverage}%"
621 " minimum coverage threshold."
622 )
623 return violation
624
625
626 # ── Entry point ────────────────────────────────────────────────────────────────
627
628
629 def run(args: argparse.Namespace) -> None:
630 """Entry point for ``muse code semantic-test-coverage``.
631
632 Validates arguments, loads the HEAD snapshot, runs the coverage analysis,
633 and prints results (or emits JSON). Exits 1 when ``--min-coverage`` is
634 set and any file falls below the threshold.
635 """
636 root = require_repo()
637
638 # ── Argument validation ────────────────────────────────────────────────────
639 depth: int = clamp_int(args.depth, 1, 50, 'depth')
640 if depth < 1 or depth > _MAX_DEPTH:
641 print(f"❌ --depth must be between 1 and {_MAX_DEPTH}.", file=sys.stderr)
642 raise SystemExit(ExitCode.USER_ERROR)
643
644 min_coverage: int = clamp_int(args.min_coverage, 0, 100, 'min_coverage')
645 if not (0 <= min_coverage <= 100):
646 print("❌ --min-coverage must be between 0 and 100.", file=sys.stderr)
647 raise SystemExit(ExitCode.USER_ERROR)
648
649 transitive: bool = args.transitive or depth > 1
650 effective_depth: int = depth if transitive else 1
651
652 kind_filter: str | None = args.kind or None
653 file_filter: str | None = args.file or None
654
655 # ── Resolve HEAD snapshot ──────────────────────────────────────────────────
656 repo_id = read_repo_id(root)
657 branch = read_current_branch(root)
658
659 head = resolve_commit_ref(root, repo_id, branch, None)
660 if head is None:
661 print("❌ HEAD commit not found — is this an empty repository?", file=sys.stderr)
662 raise SystemExit(ExitCode.USER_ERROR)
663
664 manifest: Manifest = get_commit_snapshot_manifest(root, head.commit_id) or {}
665
666 # ── Single bulk read of all Python blobs ───────────────────────────────────
667 blobs = _load_py_blobs(root, manifest)
668
669 # ── Symbol extraction ──────────────────────────────────────────────────────
670 all_trees = symbols_for_snapshot(root, manifest)
671
672 # Partition: test files vs. production files.
673 prod_trees: SymbolTreeMap = {}
674 for file_path, sym_tree in all_trees.items():
675 if not _is_test_file(file_path):
676 prod_trees[file_path] = sym_tree
677
678 # Flat index of all production symbols, excluding imports.
679 all_prod: FlatSymbolMap = {}
680 for sym_tree in prod_trees.values():
681 for addr, rec in sym_tree.items():
682 if rec["kind"] not in (_IMPORT_KIND,) and rec["kind"] in _TRACKED_KINDS:
683 all_prod[addr] = rec
684
685 # Reverse index: bare_name → [prod_addr, ...]
686 name_to_addrs: NameToAddrs = defaultdict(list)
687 for addr, rec in all_prod.items():
688 name_to_addrs[rec["name"]].append(addr)
689
690 # ── Test-function scanning ─────────────────────────────────────────────────
691 test_refs = _build_test_refs(blobs)
692 total_test_fns = len(test_refs)
693
694 # ── Optional: build production call graph for transitive coverage ──────────
695 forward_graph: ForwardGraph | None = None
696 if transitive:
697 forward_graph = _build_prod_forward_graph(blobs, set(prod_trees))
698
699 # ── Coverage computation ───────────────────────────────────────────────────
700 coverage = _compute_coverage(
701 test_refs, dict(name_to_addrs), forward_graph, effective_depth
702 )
703
704 # ── Structured output ──────────────────────────────────────────────────────
705 file_coverage = _build_file_coverage(prod_trees, coverage, file_filter, kind_filter)
706
707 # ── JSON output ────────────────────────────────────────────────────────────
708 if args.json:
709 total_syms = sum(f["total_symbols"] for f in file_coverage)
710 total_covered = sum(f["covered_symbols"] for f in file_coverage)
711 pct = round(100.0 * total_covered / total_syms, 1) if total_syms else 0.0
712
713 # Apply uncovered_only filter to symbol lists in JSON.
714 if args.uncovered_only:
715 filtered_files: list[_FileCov] = []
716 for fc in file_coverage:
717 uncov = [s for s in fc["symbols"] if not s["covered"]]
718 if uncov:
719 filtered_files.append(
720 _FileCov(
721 file=fc["file"],
722 total_symbols=fc["total_symbols"],
723 covered_symbols=fc["covered_symbols"],
724 uncovered_symbols=fc["uncovered_symbols"],
725 coverage_pct=fc["coverage_pct"],
726 symbols=uncov,
727 )
728 )
729 file_coverage = filtered_files
730
731 out: _JsonOut = _JsonOut(
732 ref="HEAD",
733 snapshot_id=head.commit_id[:12],
734 depth=effective_depth,
735 transitive=transitive,
736 filters=_FilterSpec(
737 file=file_filter,
738 kind=kind_filter,
739 min_coverage=min_coverage if min_coverage > 0 else None,
740 uncovered_only=args.uncovered_only,
741 ),
742 summary=_SummarySpec(
743 total_symbols=total_syms,
744 covered_symbols=total_covered,
745 uncovered_symbols=total_syms - total_covered,
746 coverage_pct=pct,
747 total_test_functions=total_test_fns,
748 total_production_files=len(file_coverage),
749 ),
750 files=file_coverage,
751 )
752 print(json.dumps(out, indent=2))
753 return
754
755 # ── Human-readable output ──────────────────────────────────────────────────
756 violation = _print_table(
757 file_coverage,
758 uncovered_only=args.uncovered_only,
759 show_tests=args.show_tests,
760 min_coverage=min_coverage,
761 ref="HEAD",
762 total_test_fns=total_test_fns,
763 )
764 if violation:
765 sys.exit(1)
766
767
768 # ── CLI registration ───────────────────────────────────────────────────────────
769
770
771 def register(
772 sub: argparse._SubParsersAction[argparse.ArgumentParser],
773 ) -> None:
774 """Register ``semantic-test-coverage`` under the ``code`` subcommand group.
775
776 Args:
777 sub: The subparser action from the ``code`` command group.
778 """
779 p = sub.add_parser(
780 "semantic-test-coverage",
781 help=(
782 "Static symbol-level test coverage — which symbols are exercised"
783 " by which tests, without running the test suite."
784 ),
785 description=__doc__,
786 formatter_class=argparse.RawDescriptionHelpFormatter,
787 )
788 p.add_argument(
789 "--file",
790 metavar="SUFFIX",
791 help=(
792 "Scope analysis to production files whose path contains SUFFIX"
793 " (e.g. --file billing.py)."
794 ),
795 )
796 p.add_argument(
797 "--kind",
798 metavar="KIND",
799 choices=["function", "async_function", "method", "async_method", "class"],
800 help="Filter symbols by kind.",
801 )
802 p.add_argument(
803 "--transitive",
804 action="store_true",
805 help=(
806 "Expand coverage through the production call graph."
807 " Combines with --depth (default: 2 when this flag is set)."
808 ),
809 )
810 p.add_argument(
811 "--depth",
812 type=int,
813 default=_DEFAULT_DEPTH,
814 metavar="D",
815 help=(
816 f"Transitive call-graph depth (default: {_DEFAULT_DEPTH})."
817 " Values > 1 imply --transitive."
818 f" Maximum: {_MAX_DEPTH}."
819 ),
820 )
821 p.add_argument(
822 "--uncovered-only",
823 action="store_true",
824 help="Show (or emit in JSON) only symbols with no test coverage.",
825 )
826 p.add_argument(
827 "--show-tests",
828 action="store_true",
829 help="Under each covered symbol, list the test functions that exercise it.",
830 )
831 p.add_argument(
832 "--min-coverage",
833 type=int,
834 default=0,
835 metavar="PCT",
836 help=(
837 "Exit 1 if any production file is below PCT%% semantic coverage."
838 " Useful for CI gates."
839 ),
840 )
841 p.add_argument(
842 "--json",
843 action="store_true",
844 help="Emit JSON instead of human-readable text.",
845 )
846 p.set_defaults(func=run)
File History 1 commit
sha256:cf6265cea8c21d9228d90dec13ef6ec2dab5103d466db9cc4590681832de4bf8 docs(KD-STAGING): sync governance after KD-6b DONE Human 12 days ago