|
| 1 | +"""Rank the modules a coverage run left uncovered by what a wrong answer in each one costs. |
| 2 | +
|
| 3 | +A percentage ranking answers the wrong question. Sixty per cent of forty statements in a module |
| 4 | +that prints a summary is a smaller problem than ninety-two per cent of three hundred on the patch |
| 5 | +path, and both of the earlier coverage baselines had to say so in prose beside their tables |
| 6 | +because nothing computed it. This computes it: **exposure is missed statements times the cost of |
| 7 | +being wrong there**, and the cost is declared per package with the argument attached. |
| 8 | +
|
| 9 | +Why a script rather than a paragraph |
| 10 | +------------------------------------ |
| 11 | +The weighting is the contestable part of the measurement, and prose hides it. Here it is one |
| 12 | +table a reader can disagree with by editing a number and re-running, which is the difference |
| 13 | +between a ranking somebody can check and a ranking somebody has to trust. The percentages |
| 14 | +themselves come from `coverage.py` and nothing here recomputes them. |
| 15 | +
|
| 16 | +Nothing here is a gate. `2026-07-27-sync-benchmark-gates.md` binds -- *"do not invent a |
| 17 | +threshold"* -- and an exposure figure is a ranking key, not a floor. It orders work; it does not |
| 18 | +fail a build. |
| 19 | +
|
| 20 | +What it refuses |
| 21 | +--------------- |
| 22 | +Every refusal below exists because the alternative is a table with no rows and exit 0. A coverage |
| 23 | +report that measured nothing, a `--cov` argument naming the wrong package, and a report format |
| 24 | +that changed under a version bump all arrive as *an absence*, and an absence read as "nothing to |
| 25 | +harden" is the same defect as a test that passes without parsing its argument. |
| 26 | +""" |
| 27 | + |
| 28 | +from __future__ import annotations |
| 29 | + |
| 30 | +import argparse |
| 31 | +import json |
| 32 | +import sys |
| 33 | +from dataclasses import dataclass |
| 34 | +from pathlib import Path |
| 35 | +from typing import Any, Iterable, Sequence |
| 36 | + |
| 37 | + |
| 38 | +class CoverageReportUnreadable(RuntimeError): |
| 39 | + """The JSON handed in is not a coverage report, or is one that measured nothing. |
| 40 | +
|
| 41 | + Raised rather than returning an empty list. The two failures this separates -- a report that |
| 42 | + is not JSON, and a report whose `files` is empty -- both produce zero modules, and a caller |
| 43 | + that sees zero modules renders a table saying there is nothing to harden. That is a false |
| 44 | + statement about the tree, arrived at without reading it. |
| 45 | + """ |
| 46 | + |
| 47 | + |
| 48 | +class UnclassifiedModule(RuntimeError): |
| 49 | + """A module under a package with no declared cost tier. |
| 50 | +
|
| 51 | + Refusing rather than defaulting. A package that did not exist when this table was written is |
| 52 | + likelier to be new pipeline than new reporting, so weighting it at the bottom would rank the |
| 53 | + newest and least-exercised code last -- and silently, since a default leaves no trace in the |
| 54 | + output. The fix is one row in `COST_TIERS` and the argument for it. |
| 55 | + """ |
| 56 | + |
| 57 | + |
| 58 | +@dataclass(frozen=True) |
| 59 | +class CostTier: |
| 60 | + """One weight, the packages it covers, and why a wrong answer there costs what it does.""" |
| 61 | + |
| 62 | + name: str |
| 63 | + weight: int |
| 64 | + packages: tuple[str, ...] |
| 65 | + reason: str |
| 66 | + |
| 67 | + |
| 68 | +COST_TIERS: tuple[CostTier, ...] = ( |
| 69 | + CostTier( |
| 70 | + name="patch", |
| 71 | + weight=4, |
| 72 | + packages=("sync/remediate/", "sync/route/", "sync/verify/"), |
| 73 | + reason=( |
| 74 | + "These choose, write and gate an edit to a customer's source. The worst case this " |
| 75 | + "project has is the one `CLAUDE.md` names -- a patch that parses cleanly and means " |
| 76 | + "something else -- and it is produced here or caught here. `verify/` is weighted with " |
| 77 | + "them rather than below them because it is the only thing between a wrong edit and a " |
| 78 | + "pull request: a decline it gets wrong is a patch nobody looked at again." |
| 79 | + ), |
| 80 | + ), |
| 81 | + CostTier( |
| 82 | + name="signal", |
| 83 | + weight=3, |
| 84 | + packages=("sync/index/", "sync/signals/", "sync/detect/", "sync/telemetry/"), |
| 85 | + reason=( |
| 86 | + "A silent decline here costs a binding, and a run missing a binding reports clean -- " |
| 87 | + "the vendor broke, the call site was never found, and nothing distinguishes that from " |
| 88 | + "a healthy repository. A decline that resolves *wrongly* is worse and rarer. Five " |
| 89 | + "committed decline reports over these packages all reach the same finding: the caller " |
| 90 | + "observes nothing at all." |
| 91 | + ), |
| 92 | + ), |
| 93 | + CostTier( |
| 94 | + name="record", |
| 95 | + weight=2, |
| 96 | + packages=("sync/core/", "sync/graph/", "sync/forge/", "sync/benchmark/"), |
| 97 | + reason=( |
| 98 | + "These carry what the earlier stages established and publish it. A defect corrupts " |
| 99 | + "the record rather than the customer's source, which is recoverable -- but not free: " |
| 100 | + "`benchmark/` produces the labels precision and recall are gated on, and " |
| 101 | + "`2026-07-29-sync-verification-regime.md` records recall reading 1.0000 against a " |
| 102 | + "corpus that shared the binder's blind spot." |
| 103 | + ), |
| 104 | + ), |
| 105 | + CostTier( |
| 106 | + name="report", |
| 107 | + weight=1, |
| 108 | + packages=("sync/cli.py", "sync/dashboard/", "sync/mcp/"), |
| 109 | + reason=( |
| 110 | + "A defect here costs a number on a screen or a malformed tool response, with an " |
| 111 | + "operator reading it. This is deliberately the low weight even though `cli.py` is " |
| 112 | + "where the pipeline is wired, because the wiring failure -- a component reachable " |
| 113 | + "from nothing -- is invisible to line coverage in either direction and is gated by " |
| 114 | + "`scripts/lint_dead_links.py` instead." |
| 115 | + ), |
| 116 | + ), |
| 117 | +) |
| 118 | +"""The whole contestable part of this ranking, in one place and with the arguments attached. |
| 119 | +
|
| 120 | +Ordered by weight so the table reads top-down. Membership is by path prefix rather than by module, |
| 121 | +because a per-module table would go stale the day somebody splits a file -- and a package is the |
| 122 | +granularity at which the cost argument is actually true. |
| 123 | +""" |
| 124 | + |
| 125 | + |
| 126 | +@dataclass(frozen=True) |
| 127 | +class ModuleCoverage: |
| 128 | + """One file's row of a coverage report, with the path in the form a reader can grep for.""" |
| 129 | + |
| 130 | + module: str |
| 131 | + statements: int |
| 132 | + missed: int |
| 133 | + missing_lines: tuple[int, ...] |
| 134 | + percent: float |
| 135 | + |
| 136 | + |
| 137 | +@dataclass(frozen=True) |
| 138 | +class RankedModule: |
| 139 | + coverage: ModuleCoverage |
| 140 | + tier: CostTier |
| 141 | + exposure: int |
| 142 | + |
| 143 | + |
| 144 | +def _module_name(path: str) -> str: |
| 145 | + """`src\\sync\\index\\literals.py` as `sync/index/literals.py`. |
| 146 | +
|
| 147 | + Coverage writes the separator the platform uses, so the same run recorded on Windows and in |
| 148 | + CI produces two spellings of one module. Normalising here rather than at every comparison |
| 149 | + keeps the tier prefixes writable in one form. |
| 150 | + """ |
| 151 | + normalised = path.replace("\\", "/") |
| 152 | + prefix = "src/" |
| 153 | + return normalised[len(prefix):] if normalised.startswith(prefix) else normalised |
| 154 | + |
| 155 | + |
| 156 | +def read_report(text: str) -> list[ModuleCoverage]: |
| 157 | + """Every file a `coverage.py` JSON report names, refusing anything that is not one. |
| 158 | +
|
| 159 | + Fully covered modules are kept. They are the denominator, and dropping them here would make |
| 160 | + this disagree with `--cov-report=term-missing` about the same run. |
| 161 | + """ |
| 162 | + try: |
| 163 | + report: Any = json.loads(text) |
| 164 | + except ValueError as exc: |
| 165 | + raise CoverageReportUnreadable( |
| 166 | + f"not a coverage.py JSON report: {exc}. `--cov-report=json:<path>` writes one; " |
| 167 | + f"`term-missing` writes a table this cannot read." |
| 168 | + ) from exc |
| 169 | + |
| 170 | + if not isinstance(report, dict) or "files" not in report: |
| 171 | + raise CoverageReportUnreadable( |
| 172 | + "the JSON names no `files`, so it is not a coverage.py report of format 3" |
| 173 | + ) |
| 174 | + |
| 175 | + files = report["files"] |
| 176 | + if not files: |
| 177 | + raise CoverageReportUnreadable( |
| 178 | + "the coverage report names no files at all. A run that measured nothing and a " |
| 179 | + "`--cov` argument naming a package that was never imported both look like this, and " |
| 180 | + "neither means the tree has nothing left to harden." |
| 181 | + ) |
| 182 | + |
| 183 | + modules = [] |
| 184 | + for path, entry in files.items(): |
| 185 | + summary = entry.get("summary") if isinstance(entry, dict) else None |
| 186 | + if not isinstance(summary, dict) or "num_statements" not in summary: |
| 187 | + raise CoverageReportUnreadable( |
| 188 | + f"{path} carries no `summary`; the report format is not the one this reads" |
| 189 | + ) |
| 190 | + modules.append( |
| 191 | + ModuleCoverage( |
| 192 | + module=_module_name(path), |
| 193 | + statements=int(summary["num_statements"]), |
| 194 | + missed=int(summary["missing_lines"]), |
| 195 | + missing_lines=tuple(entry.get("missing_lines", ())), |
| 196 | + percent=float(summary["percent_covered"]), |
| 197 | + ) |
| 198 | + ) |
| 199 | + return modules |
| 200 | + |
| 201 | + |
| 202 | +def tier_for(module: str) -> CostTier: |
| 203 | + """The cost tier a module sits in, by the package it belongs to.""" |
| 204 | + for tier in COST_TIERS: |
| 205 | + if any(module == package or module.startswith(package) for package in tier.packages): |
| 206 | + return tier |
| 207 | + raise UnclassifiedModule( |
| 208 | + f"{module} is in no package COST_TIERS declares. Add it with the argument for its " |
| 209 | + f"weight rather than letting it default -- a weight nobody chose is not a measurement." |
| 210 | + ) |
| 211 | + |
| 212 | + |
| 213 | +def rank(modules: Iterable[ModuleCoverage]) -> list[RankedModule]: |
| 214 | + """The modules with something missed, heaviest exposure first. |
| 215 | +
|
| 216 | + Ties break on the module name so two runs over one report render the same table -- a ranking |
| 217 | + whose order moves between renderings cannot be diffed, and the diff is how a reader sees what |
| 218 | + the last round of hardening changed. |
| 219 | + """ |
| 220 | + ranked = [ |
| 221 | + RankedModule(coverage=m, tier=tier_for(m.module), exposure=m.missed * tier_for(m.module).weight) |
| 222 | + for m in modules |
| 223 | + if m.missed |
| 224 | + ] |
| 225 | + return sorted(ranked, key=lambda r: (-r.exposure, r.coverage.module)) |
| 226 | + |
| 227 | + |
| 228 | +def render(ranked: Sequence[RankedModule]) -> str: |
| 229 | + """The ranked table, as the markdown a specification carries.""" |
| 230 | + rows = [ |
| 231 | + "| Module | Tier | Weight | Missed | Statements | Covered | Exposure |", |
| 232 | + "|---|---|---:|---:|---:|---:|---:|", |
| 233 | + ] |
| 234 | + for entry in ranked: |
| 235 | + rows.append( |
| 236 | + f"| `{entry.coverage.module}` | {entry.tier.name} | {entry.tier.weight} | " |
| 237 | + f"{entry.coverage.missed} | {entry.coverage.statements} | " |
| 238 | + f"{entry.coverage.percent:.0f}% | {entry.exposure} |" |
| 239 | + ) |
| 240 | + return "\n".join(rows) |
| 241 | + |
| 242 | + |
| 243 | +def render_tiers() -> str: |
| 244 | + """The weighting itself, so a table never travels without the argument behind it.""" |
| 245 | + rows = ["| Tier | Weight | Packages | Why a wrong answer costs this much |", "|---|---:|---|---|"] |
| 246 | + for tier in COST_TIERS: |
| 247 | + packages = ", ".join(f"`{p}`" for p in tier.packages) |
| 248 | + rows.append(f"| {tier.name} | {tier.weight} | {packages} | {tier.reason} |") |
| 249 | + return "\n".join(rows) |
| 250 | + |
| 251 | + |
| 252 | +def main(argv: Sequence[str] | None = None) -> int: |
| 253 | + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) |
| 254 | + parser.add_argument("report", type=Path, help="a coverage.py JSON report") |
| 255 | + parser.add_argument("--tiers", action="store_true", help="print the weighting table too") |
| 256 | + args = parser.parse_args(argv) |
| 257 | + |
| 258 | + ranked = rank(read_report(args.report.read_text(encoding="utf-8"))) |
| 259 | + if args.tiers: |
| 260 | + print(render_tiers()) |
| 261 | + print() |
| 262 | + print(render(ranked)) |
| 263 | + return 0 |
| 264 | + |
| 265 | + |
| 266 | +if __name__ == "__main__": |
| 267 | + raise SystemExit(main()) |
0 commit comments