Skip to content

Commit 048e28a

Browse files
committed
merge: M3-W118 -- the instrument that ranks uncovered modules by cost
2 parents e092715 + 7752d5d commit 048e28a

2 files changed

Lines changed: 476 additions & 0 deletions

File tree

scripts/rank_coverage.py

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
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

Comments
 (0)