4"""ONE per-file coverage policy for every first-party translation unit.
6There is one quality bar for this codebase and no tier gets a softer one. Every
7enrolled unit -- ``libs/``, ``src/``, ``port/``, ``tools/``, ``apps/``,
8``examples/`` alike -- carries exactly one row in
9``.github/tree-coverage-baseline.txt``, and this checker is the only thing that
10judges those rows. ``tree_coverage_model.py`` says what is enrolled and what
11measures it; ``scripts/report/tree_coverage.sh`` produces the measurement.
15Three overlapping regimes used to answer the same question differently: an
16aggregate ``gcovr --fail-under-line 90 --fail-under-branch 80`` plus a
17``libs/``+``src/``-only per-file line floor; a SECOND full coverage build
18ratcheted against a two-number project-wide baseline; and a product-named
19mdl per-file ratchet with its own baseline and its own scope list. Three
20policies meant three answers to "is this file covered enough", two coverage
21builds of the same translation units, and -- because each had its own scope --
22a large majority of the tree that no policy mentioned at all.
24They are one policy now:
26* **MEASURED** rows freeze the unit's measured line and branch counts.
27 Uncovered debt may not grow and the ratio may not fall, so a unit standing at
28 100% keeps it and a unit standing at 41% burns down toward the 90/80 floor
29 instead of sliding. An improvement is silently welcome; ``--update`` is how
30 it gets frozen in. A unit that only SHRANK is exempt from the ratio half --
31 deleting covered code lowers a ratio without making anything worse, and the
32 debt half still applies (see ``_only_lost_code``).
33* A unit with **no row** is new. It must enter at the full 90% line / 80%
34 branch floor -- historical debt is not something a new file can inherit.
35* A unit that **moved** is not new. An unambiguous move -- one baseline path
36 gone, one path of the same basename arrived -- carries its frozen row to the
37 new path and answers to the same ratchet there, so reorganising the tree
38 costs no coverage work and launders no regression.
39* **UNMEASURED(<reason>)** rows are explicit. Nothing is silently absent: a
40 unit no host build executes still has a row naming which of the four classes
41 in ``tree_coverage_model`` it falls into, and the class is RE-DERIVED from
42 the tree on every run, so it cannot be hand-written into something friendlier.
43 Gaining measurement is one-way: a unit that starts producing execution data
44 must move to MEASURED and can never move back.
48Every project in ``tree_coverage_model.PROJECTS`` is built and run separately
49and reported into its own gcovr trace; the traces are then merged. The merge is
50the point. The mdl core is compiled by BOTH the host suite and the mdl host
51form, and a single gcovr sweep over one build tree therefore reports
52whichever half it happened to see -- ``mdl_config.c`` measures 77.3% from the
53host suite alone and 90.1% from the union. One row per unit means one number
54per unit, and that number is what every project together executed.
58 check_tree_coverage.py # the gate
59 check_tree_coverage.py --update # re-freeze the baseline (tighten only)
60 check_tree_coverage.py --selftest # prove both directions
61 check_tree_coverage.py --projects # the measurement projects, for the producer
63Exit 0 when the tree matches the baseline, 1 on a violation, 2 when the
64measurement or the enumeration itself did not happen.
67from __future__
import annotations
72from dataclasses
import dataclass
73from pathlib
import Path
75sys.path.insert(0, str(Path(__file__).resolve().parent))
77from lint_targets
import firmware_app_dirs, first_party_paths
78from tree_coverage_model
import (
87 census_floor_failures,
89 coverage_capable_dirs,
92 unclaimed_coverage_projects,
95REPO_ROOT = Path(__file__).resolve().parents[2]
96REPORT_DIR = REPO_ROOT /
"build" /
"tree-coverage"
97MERGED_SUMMARY = REPORT_DIR /
"summary.json"
98BASELINE_FILE = REPO_ROOT /
".github" /
"tree-coverage-baseline.txt"
103KIND_MEASURED =
"MEASURED"
104KIND_UNMEASURED =
"UNMEASURED"
107UNMEASURED_COLUMNS = 3
120Counts = tuple[int, int, int, int]
123@dataclass(frozen=True)
125 """One baseline row: a frozen measurement, or a named reason there is none."""
128 """``KIND_MEASURED`` or ``KIND_UNMEASURED``."""
131 """The frozen counts. ``NO_COUNTS`` on an UNMEASURED row."""
134 """One of ``tree_coverage_model.REASONS``. Empty on a MEASURED row."""
137@dataclass(frozen=True)
139 """One reason the tree and the baseline disagree."""
142 """``HARD`` or ``DRIFT``."""
145 """Path-first, human-readable, and specific enough to act on."""
150NO_COUNTS: Counts = (0, 0, 0, 0)
153def measured(counts: Counts) -> Row:
154 """Build a MEASURED row from four gcovr counts."""
155 return Row(KIND_MEASURED, counts,
"")
158def unmeasured(reason: str) -> Row:
159 """Build an UNMEASURED row from a reason class."""
160 return Row(KIND_UNMEASURED, NO_COUNTS, reason)
168def load_summary(path: Path) -> dict[str, Counts]:
169 """Load a gcovr ``--json-summary`` report into per-file integer counts.
172 path: The summary written by ``scripts/report/tree_coverage.sh``.
175 Repo-relative path -> counts, for census units only. Anything else in
176 the report (headers, vendored SOUP, test sources) is dropped here so
177 the policy never has to know they existed.
180 SystemExit: When the file is missing or unreadable -- an absent
181 measurement is not an empty one.
183 data = _read_json(path)
184 rows: dict[str, Counts] = {}
185 for entry
in data.get(
"files", []):
186 rel = str(entry.get(
"filename",
"")).replace(
"\\",
"/")
187 if not rel.endswith(CENSUS_SUFFIXES):
190 int(entry[
"line_covered"]),
191 int(entry[
"line_total"]),
192 int(entry[
"branch_covered"]),
193 int(entry[
"branch_total"]),
198def _read_json(path: Path) -> dict:
199 """Read one JSON document, exiting 2 when it is absent or malformed."""
200 if not path.is_file():
202 f
"check_tree_coverage.py: missing measurement: {path}\n"
203 f
" Run `bash scripts/report/tree_coverage.sh` first.",
208 return json.loads(path.read_text(encoding=
"utf-8"))
209 except (OSError, json.JSONDecodeError)
as exc:
210 print(f
"check_tree_coverage.py: cannot read {path}: {exc}", file=sys.stderr)
211 raise SystemExit(2)
from exc
214def compiled_sources() -> set[str]:
215 """Every census-shaped source a measurement project's build compiled.
217 Read from each project's ``compile_commands.json``. This is what separates
218 "no host build touches this unit" from "a host build compiled it and no
219 test ever pulled the archive member in" -- the second is invisible in a
220 coverage report, because gcov writes a .gcno and never a .gcda.
223 SystemExit: When a declared project left no compile database. Skipping
224 it would silently collapse that distinction and reclassify every
225 never-executed unit as one no host build reaches, which is the
226 friendlier answer and the wrong one.
228 root = str(REPO_ROOT).replace(
"\\",
"/")
229 out: set[str] = set()
230 for project
in PROJECTS:
231 db = REPORT_DIR / project.name /
"compile_commands.json"
234 f
"check_tree_coverage.py: missing compile database: {db}\n"
235 f
" Run `bash scripts/report/tree_coverage.sh` first.",
239 for entry
in _read_json_list(db):
240 rel = _repo_relative(entry, root)
241 if rel
is not None and rel.endswith(CENSUS_SUFFIXES):
246def _read_json_list(path: Path) -> list[dict]:
247 """Read a JSON array document, tolerating nothing but a real array."""
248 data = json.loads(path.read_text(encoding=
"utf-8"))
249 return data
if isinstance(data, list)
else []
252def _repo_relative(entry: dict, root: str) -> str |
None:
253 """Repo-relative source path from one compile-database entry, or None."""
254 source = str(entry.get(
"file",
"")).replace(
"\\",
"/")
255 if not source.startswith(
"/"):
256 directory = str(entry.get(
"directory",
"")).replace(
"\\",
"/")
257 source = f
"{directory}/{source}"
258 source = str(Path(source).resolve()).replace(
"\\",
"/")
259 if not source.startswith(root +
"/"):
261 return source[len(root) + 1 :]
264def project_report_failures() -> list[str]:
265 """Return one message per measurement project whose own report is vacuous.
267 Every declared project must have produced a report, and that report must
268 carry at least the project's ``min_files`` census units. A project that
269 silently stopped instrumenting still merges cleanly into a healthy-looking
270 total, so the floor has to be asserted per project.
272 failures: list[str] = []
273 for project
in PROJECTS:
274 path = REPORT_DIR /
"summaries" / f
"{project.name}.json"
275 seen = len(load_summary(path))
276 if seen < project.min_files:
278 f
"measurement project {project.name} reported {seen} census unit(s), "
279 f
"floor is {project.min_files}"
289def derive_rows(paths: list[str], report: dict[str, Counts], compiled: set[str]) -> dict[str, Row]:
290 """Build the row every census unit is entitled to from this measurement.
293 paths: The census, from ``tree_coverage_model.census_paths``.
294 report: Merged per-file counts from ``load_summary``.
295 compiled: Sources a measurement build compiled, from
296 ``compiled_sources``.
299 Repo-relative path -> the row the tree currently supports.
301 firmware = firmware_app_dirs()
302 rows: dict[str, Row] = {}
304 counts = report.get(rel)
305 if counts
is not None and counts[1] > 0:
306 rows[rel] = measured(counts)
308 reason = structural_reason(
310 compiled=rel
in compiled
or counts
is not None,
311 firmware_dirs=firmware,
313 rows[rel] = unmeasured(reason)
322def _below_floor(covered: int, total: int, floor: int) -> bool:
323 """True when an integer covered/total ratio is under ``floor`` percent."""
324 return total > 0
and (covered * 100) < (floor * total)
327def _new_file_findings(rel: str, counts: Counts) -> list[Finding]:
328 """A unit with no baseline row must enter at the full floor."""
329 line_cov, line_total, branch_cov, branch_total = counts
330 out: list[Finding] = []
331 if _below_floor(line_cov, line_total, LINE_FLOOR_PCT):
332 out.append(Finding(HARD, f
"{rel}: new unit enters below {LINE_FLOOR_PCT}% line coverage"))
333 if _below_floor(branch_cov, branch_total, BRANCH_FLOOR_PCT):
335 Finding(HARD, f
"{rel}: new unit enters below {BRANCH_FLOOR_PCT}% branch coverage")
338 out.append(Finding(DRIFT, f
"{rel}: new unit has no baseline row"))
342def _only_lost_code(now: tuple[int, int], base: tuple[int, int]) -> bool:
343 """True when the unit shrank and every covered item it lost simply left it.
345 The ratio rule below stops a unit's quality sliding, and the debt rule
346 already covers every way it can slide while STAYING THE SAME SIZE. Write
347 the base counts C/T and the current ones c/t. Non-increasing debt is
348 ``t - c <= T - C``. If ``t >= T`` that gives ``c - C >= t - T >= 0``, and
349 then ``cT - Ct >= (t - T)(T - C) >= 0`` -- so a ratio violation is
350 arithmetically impossible unless ``t < T``. The ratio rule's only distinct
351 effect on a debt-clean unit is therefore a tax on DELETING COVERED CODE.
353 That tax is not hypothetical and not small: moving a covered function to a
354 better home lowers the ratio of the file it left while making nothing worse
355 anywhere, and #725 hit it five times in one refactor -- in four of the five
356 the file's uncovered debt actually went DOWN while this rule called it a
357 regression. A ratchet that can only be satisfied by leaving code where it
358 is stops being a coverage policy and becomes a layout policy.
360 So a shrink is exempt, under the narrowest condition that cannot hide
361 growth: the unit is smaller, and it lost no more covered items than it lost
362 items. The debt rule still applies unconditionally, so a unit that shrinks
363 while gaining uncovered code is still caught -- and the ratio rule still
364 fires, as a second and more descriptive message, on any same-size or
365 growing unit whose ratio falls.
368 base_covered, base_total = base
369 return (total < base_total)
and ((base_covered - covered) <= (base_total - total))
373 rel: str, label: str, now: tuple[int, int], base: tuple[int, int]
375 """Ratchet one metric: uncovered debt may not grow, the ratio may not fall."""
377 base_covered, base_total = base
378 out: list[Finding] = []
379 if (total - covered) > (base_total - base_covered):
383 f
"{rel}: uncovered {label} debt grew "
384 f
"{base_total - base_covered} -> {total - covered}",
389 and covered * base_total < base_covered * total
390 and not _only_lost_code(now, base)
395 f
"{rel}: {label} ratio regressed {base_covered}/{base_total} -> {covered}/{total}",
398 floor = LINE_FLOOR_PCT
if label ==
"line" else BRANCH_FLOOR_PCT
399 if base_total == 0
and _below_floor(covered, total, floor):
400 out.append(Finding(HARD, f
"{rel}: new {label} metric entered below the {floor}% floor"))
404def _measured_findings(rel: str, now: Row, base: Row) -> list[Finding]:
405 """Compare a MEASURED baseline row against what the tree now reports."""
406 if now.kind != KIND_MEASURED:
410 f
"{rel}: lost its measurement (now {now.reason}); gaining measurement is one-way",
413 line_cov, line_total, branch_cov, branch_total = now.counts
414 base_line_cov, base_line_total, base_branch_cov, base_branch_total = base.counts
416 *_metric_findings(rel,
"line", (line_cov, line_total), (base_line_cov, base_line_total)),
418 rel,
"branch", (branch_cov, branch_total), (base_branch_cov, base_branch_total)
423def _unmeasured_findings(rel: str, now: Row, base: Row) -> list[Finding]:
424 """Compare an UNMEASURED baseline row against what the tree now reports."""
425 if base.reason
not in REASONS:
426 return [Finding(HARD, f
"{rel}: baseline reason {base.reason!r} is not a known class")]
427 if now.kind == KIND_MEASURED:
428 return [Finding(DRIFT, f
"{rel}: gained measurement; re-freeze it as MEASURED")]
429 if now.reason != base.reason:
431 Finding(DRIFT, f
"{rel}: reason class is now {now.reason}, baseline says {base.reason}")
436def detect_moves(fresh: dict[str, Row], baseline: dict[str, Row]) -> dict[str, str]:
437 """Pair each vanished baseline path with the arrived unit that IS that unit.
439 A unit that moved is not a new unit. It carries the debt it was already
440 carrying and answers to the same ratchet at its new path. Without this
441 pairing every file move is a "new unit", which must enter at the full
442 90%/80% floor -- so a tree could only ever be reorganised by first getting
443 the moved file to the floor, or by hand-editing a baseline whose own header
444 says never to hand-edit it. Neither is a coverage decision, and a policy
445 that makes layout changes cost coverage work is a policy that stops layout
448 The pairing is deliberately narrow, because a wrong pairing would launder a
449 real regression: same BASENAME, and that basename must have vanished
450 exactly once and arrived exactly once. Anything ambiguous -- two files of
451 one name moving, a rename that also changes the basename, a split -- stays
452 unpaired and is reported as a deletion plus a new unit, which is the
453 conservative answer and the one that still fires.
456 fresh: What this measurement supports, from ``derive_rows``.
457 baseline: What is committed, from ``load_baseline``.
460 New path -> the baseline path it moved from, for every unambiguous move.
462 vanished = _by_basename(set(baseline) - set(fresh))
463 arrived = _by_basename(set(fresh) - set(baseline))
465 arrived[name][0]: paths[0]
466 for name, paths
in vanished.items()
467 if len(paths) == 1
and len(arrived.get(name, [])) == 1
471def _by_basename(paths: set[str]) -> dict[str, list[str]]:
472 """Group repo-relative paths by their final component."""
473 groups: dict[str, list[str]] = {}
474 for rel
in sorted(paths):
475 groups.setdefault(rel.rsplit(
"/", 1)[-1], []).append(rel)
479def evaluate(fresh: dict[str, Row], baseline: dict[str, Row]) -> list[Finding]:
480 """Return every disagreement between the tree and the committed baseline.
483 fresh: What this measurement supports, from ``derive_rows``.
484 baseline: What is committed, from ``load_baseline``.
487 Findings sorted by severity then message, so a HARD violation is always
488 read before the drift it may have caused.
490 moves = detect_moves(fresh, baseline)
491 out: list[Finding] = [
492 Finding(DRIFT, f
"{rel}: baseline row is stale -- the unit is gone from the census")
493 for rel
in sorted(set(baseline) - set(fresh) - set(moves.values()))
495 for rel
in sorted(fresh):
497 origin = moves.get(rel)
498 base = baseline[origin]
if origin
is not None else baseline.get(rel)
500 if now.kind == KIND_MEASURED:
501 out.extend(_new_file_findings(rel, now.counts))
503 out.append(Finding(DRIFT, f
"{rel}: new unit has no baseline row ({now.reason})"))
505 if base.kind == KIND_MEASURED:
506 out.extend(_measured_findings(rel, now, base))
508 out.extend(_unmeasured_findings(rel, now, base))
509 if origin
is not None:
510 out.append(Finding(DRIFT, f
"{rel}: moved from {origin}; re-key its row"))
511 return sorted(out, key=
lambda f: (f.severity != HARD, f.message))
519 "# ONE coverage baseline for every first-party translation unit.",
521 "# Emitted by `python3 scripts/checks/check_tree_coverage.py --update`.",
522 "# Never hand-edit: every field is re-derived from the tree and the merged",
523 "# gcovr measurement, so an edit is either a no-op or a lie the gate finds.",
525 "# MEASURED <file> MEASURED <line-covered> <line-total> <branch-covered> <branch-total>",
526 "# Frozen debt. Uncovered lines/branches may not grow and the",
527 "# ratio may not fall; a NEW unit must enter at >=90% line and",
529 "# UNMEASURED <file> UNMEASURED <reason-class>",
530 "# No host execution path reaches it. The class is re-derived",
531 "# every run; gaining measurement is one-way.",
533 "# Columns are TAB-separated. Rows are sorted by path.",
537def format_baseline(rows: dict[str, Row]) -> str:
538 """Render the baseline deterministically: sorted, counted, no timestamps."""
539 kinds = [row.kind
for row
in rows.values()]
542 f
"# rows: {len(rows)}"
543 f
" measured: {kinds.count(KIND_MEASURED)}"
544 f
" unmeasured: {kinds.count(KIND_UNMEASURED)}",
547 for rel
in sorted(rows):
549 if row.kind == KIND_MEASURED:
550 body =
"\t".join(str(value)
for value
in row.counts)
551 lines.append(f
"{rel}\t{KIND_MEASURED}\t{body}")
553 lines.append(f
"{rel}\t{KIND_UNMEASURED}\t{row.reason}")
554 return "\n".join([*lines,
""])
557def parse_baseline(text: str) -> dict[str, Row]:
558 """Parse baseline text into rows.
561 ValueError: On a malformed row. A baseline that cannot be read is not
564 rows: dict[str, Row] = {}
565 for raw
in text.splitlines():
566 if not raw
or raw.startswith(
"#"):
568 fields = raw.split(
"\t")
569 if len(fields) == MEASURED_COLUMNS
and fields[1] == KIND_MEASURED:
570 values = tuple(int(field)
for field
in fields[2:])
571 rows[fields[0]] = measured((values[0], values[1], values[2], values[3]))
572 elif len(fields) == UNMEASURED_COLUMNS
and fields[1] == KIND_UNMEASURED:
573 rows[fields[0]] = unmeasured(fields[2])
575 message = f
"malformed baseline row: {raw!r}"
576 raise ValueError(message)
580def load_baseline(path: Path = BASELINE_FILE) -> dict[str, Row]:
581 """Read the committed baseline, or an empty mapping when it does not exist."""
582 if not path.is_file():
584 return parse_baseline(path.read_text(encoding=
"ascii"))
592def scope_failures() -> list[str]:
593 """Return one message per coverage-capable listfile no project claims.
595 A CMake project that declares ``option(RA8_COVERAGE ...)`` can produce
596 execution data. If no measurement project builds it, that data is never
597 collected and every unit in it sits at UNMEASURED forever while the gate
598 reports a clean tree -- the same shape as a scope list that quietly stopped
599 describing the repository.
602 rel: (REPO_ROOT / rel).read_text(encoding=
"utf-8", errors=
"replace")
603 for rel
in first_party_paths((
"CMakeLists.txt",
".cmake"))
605 unclaimed = unclaimed_coverage_projects(coverage_capable_dirs(listfiles))
607 f
"{directory}/ declares option(RA8_COVERAGE ...) but no measurement "
608 f
"project in tree_coverage_model.PROJECTS builds it"
609 for directory
in unclaimed
618def _print_findings(findings: list[Finding]) ->
None:
619 """Print every finding, hard violations first, with the remedy."""
620 hard = [f
for f
in findings
if f.severity == HARD]
621 drift = [f
for f
in findings
if f.severity == DRIFT]
622 print(
"check_tree_coverage.py: FAIL", file=sys.stderr)
624 print(f
" [regression] {finding.message}", file=sys.stderr)
625 for finding
in drift:
626 print(f
" [stale row] {finding.message}", file=sys.stderr)
629 "\n A regression is fixed with a test, never by editing the baseline.",
632 if drift
and not hard:
634 "\n Re-freeze with `python3 scripts/checks/check_tree_coverage.py --update`.",
639def _fail_setup(failures: list[str]) -> int:
640 """Report a collapsed enumeration or an uncollected project and exit 2."""
641 print(
"check_tree_coverage.py: the measurement did not happen", file=sys.stderr)
642 for failure
in failures:
643 print(f
" {failure}", file=sys.stderr)
647def _measure() -> tuple[list[str], dict[str, Row]] | int:
648 """Return (census, fresh rows), or an exit code when the setup is broken."""
649 paths = census_paths()
650 setup = census_floor_failures(paths) + scope_failures() + project_report_failures()
652 return _fail_setup(setup)
653 fresh = derive_rows(paths, load_summary(MERGED_SUMMARY), compiled_sources())
654 seen = sum(1
for row
in fresh.values()
if row.kind == KIND_MEASURED)
655 if seen < MEASURED_FLOOR:
657 [f
"only {seen} census unit(s) carry execution data, floor is {MEASURED_FLOOR}"]
662def run_gate(*, update: bool) -> int:
663 """Judge the tree against the committed baseline, optionally re-freezing it."""
665 if isinstance(outcome, int):
668 baseline = load_baseline()
669 findings = evaluate(fresh, baseline)
if baseline
else []
671 hard = [f
for f
in findings
if f.severity == HARD]
673 _print_findings(hard)
675 BASELINE_FILE.write_text(format_baseline(fresh), encoding=
"ascii")
676 print(f
"check_tree_coverage.py: wrote {BASELINE_FILE} ({len(fresh)} rows)")
679 print(
"check_tree_coverage.py: no baseline; run --update once", file=sys.stderr)
682 _print_findings(findings)
684 kinds = [row.kind
for row
in fresh.values()]
686 f
"check_tree_coverage.py: PASS -- {len(fresh)} first-party unit(s): "
687 f
"{kinds.count(KIND_MEASURED)} measured (no regression), "
688 f
"{kinds.count(KIND_UNMEASURED)} unmeasured (all declared)."
708SELFTEST_BASELINE: dict[str, Row] = {
709 "libs/ra8_demo/src/frozen.c": measured((90, 100, 80, 100)),
710 "apps/shared_libs/mdl/src/debt.c": measured((41, 100, 30, 100)),
711 "examples/ek_ra8d2/demo/main.c": unmeasured(REASON_FIRMWARE),
712 "tools/demo/src/host.c": unmeasured(REASON_HOSTED),
715Case = tuple[str, dict[str, Row], bool]
718def _swap(rel: str, row: Row) -> dict[str, Row]:
719 """The baseline with one row replaced -- the shape every case needs."""
720 return {**SELFTEST_BASELINE, rel: row}
723def _ratchet_cases() -> list[Case]:
724 """Cases for the MEASURED ratchet: debt, ratio, floors, improvement."""
725 frozen =
"libs/ra8_demo/src/frozen.c"
726 debt =
"apps/shared_libs/mdl/src/debt.c"
728 (
"an unchanged tree stays quiet", dict(SELFTEST_BASELINE),
False),
729 (
"uncovered line debt growth fires", _swap(frozen, measured((90, 101, 80, 100))),
True),
730 (
"a line ratio drop fires", _swap(frozen, measured((89, 100, 80, 100))),
True),
731 (
"a branch ratio drop fires", _swap(frozen, measured((90, 100, 79, 100))),
True),
732 (
"an improvement stays quiet", _swap(frozen, measured((97, 100, 88, 100))),
False),
733 (
"burning debt down stays quiet", _swap(debt, measured((60, 100, 45, 100))),
False),
734 (
"a debt unit sliding further fires", _swap(debt, measured((40, 100, 30, 100))),
True),
737 (
"moving covered code out stays quiet", _swap(debt, measured((31, 90, 25, 95))),
False),
738 (
"a shrink that grew debt still fires", _swap(debt, measured((30, 90, 30, 100))),
True),
742def _kind_cases() -> list[Case]:
743 """Cases for the row kinds: new units, one-way moves, reason classes."""
744 frozen =
"libs/ra8_demo/src/frozen.c"
745 tool =
"tools/demo/src/host.c"
746 new =
"libs/ra8_demo/src/new.c"
749 "a well-covered new unit still needs a row",
750 {**SELFTEST_BASELINE, new: measured((9, 10, 8, 10))},
754 "a poorly-covered new unit fires",
755 {**SELFTEST_BASELINE, new: measured((8, 10, 7, 10))},
759 "a new unmeasured unit fires",
760 {**SELFTEST_BASELINE, new: unmeasured(REASON_PLATFORM)},
763 (
"losing measurement fires", _swap(frozen, unmeasured(REASON_PLATFORM)),
True),
764 (
"gaining measurement fires", _swap(tool, measured((10, 10, 10, 10))),
True),
765 (
"a changed reason class fires", _swap(tool, unmeasured(REASON_COMPILED)),
True),
767 "a deleted unit's stale row fires",
768 {k: v
for k, v
in SELFTEST_BASELINE.items()
if k != tool},
775MOVE_FROM =
"apps/shared_libs/mdl/src/debt.c"
776MOVE_TO =
"apps/host/mdl/src/debt.c"
779def _moved(destination: str, row: Row) -> dict[str, Row]:
780 """The baseline with ``MOVE_FROM`` relocated to ``destination``."""
781 out = {k: v
for k, v
in SELFTEST_BASELINE.items()
if k != MOVE_FROM}
782 out[destination] = row
786def _move_cases() -> list[Case]:
787 """Cases for move detection: a real move, and every ambiguity it refuses."""
788 carried = SELFTEST_BASELINE[MOVE_FROM]
790 (
"an unchanged tree pairs nothing", dict(SELFTEST_BASELINE),
False),
791 (
"a move still reports its stale row", _moved(MOVE_TO, carried),
True),
792 (
"a move that regressed fires", _moved(MOVE_TO, measured((40, 100, 30, 100))),
True),
794 "a move that also renames the file is not paired",
795 _moved(
"apps/host/mdl/src/renamed.c", carried),
799 "a split into two same-named units is not paired",
800 {**_moved(MOVE_TO, carried),
"tools/demo/src/debt.c": carried},
806def _move_failures() -> list[str]:
807 """Prove a move carries its debt, and that no ambiguous pairing does."""
808 carried = SELFTEST_BASELINE[MOVE_FROM]
810 if detect_moves(SELFTEST_BASELINE, SELFTEST_BASELINE):
811 out.append(
"an unchanged tree must pair no move")
812 if detect_moves(_moved(MOVE_TO, carried), SELFTEST_BASELINE) != {MOVE_TO: MOVE_FROM}:
813 out.append(
"one vanished and one arrived unit of the same name must pair")
815 {**_moved(MOVE_TO, carried),
"tools/demo/src/debt.c": carried}, SELFTEST_BASELINE
817 out.append(
"an ambiguous same-basename arrival must not pair")
820 as_new = evaluate({**SELFTEST_BASELINE, MOVE_TO: carried}, SELFTEST_BASELINE)
821 as_move = evaluate(_moved(MOVE_TO, carried), SELFTEST_BASELINE)
822 regressed = evaluate(_moved(MOVE_TO, measured((40, 100, 30, 100))), SELFTEST_BASELINE)
823 if not any(f.severity == HARD
for f
in as_new):
824 out.append(
"a genuinely new below-floor unit must stay HARD")
825 if any(f.severity == HARD
for f
in as_move):
826 out.append(
"a moved unit must carry its debt rather than re-enter at the floor")
827 if not any(f.severity == HARD
for f
in regressed):
828 out.append(
"a move must not launder a coverage regression")
832def _evaluate_failures() -> list[str]:
833 """Run every evaluate() case and name the ones that answered wrongly."""
836 for name, fresh, should_fire
in _ratchet_cases() + _kind_cases() + _move_cases()
837 if bool(evaluate(fresh, SELFTEST_BASELINE)) != should_fire
841def _severity_failures() -> list[str]:
842 """Prove --update refuses a regression and accepts a pure staleness."""
843 frozen =
"libs/ra8_demo/src/frozen.c"
844 tool =
"tools/demo/src/host.c"
845 debt =
"apps/shared_libs/mdl/src/debt.c"
846 regression = evaluate(_swap(frozen, measured((80, 100, 80, 100))), SELFTEST_BASELINE)
847 staleness = evaluate(_swap(tool, measured((10, 10, 10, 10))), SELFTEST_BASELINE)
848 shrink = evaluate(_swap(debt, measured((31, 90, 25, 95))), SELFTEST_BASELINE)
850 if not any(f.severity == HARD
for f
in regression):
851 out.append(
"a coverage regression must be HARD so --update refuses it")
852 if any(f.severity == HARD
for f
in staleness):
853 out.append(
"a unit that merely gained measurement must not be HARD")
855 out.append(
"deleting covered code must not be reported as a regression")
859 "ratio regressed" in f.message
860 for f
in evaluate(_swap(frozen, measured((85, 100, 80, 100))), SELFTEST_BASELINE)
862 out.append(
"a same-size unit whose ratio fell must still report the ratio")
866def _scope_failures() -> list[str]:
867 """Prove the census, the floors and the project-claim guard all still bite."""
868 live = census_paths()
870 if census_floor_failures(live):
871 out.append(
"the live census must clear every root floor")
872 if not census_floor_failures([rel
for rel
in live
if not rel.startswith(
"tools/")]):
873 out.append(
"a census with tools/ removed must fail its root floor")
874 if unclaimed_coverage_projects([
"tests",
"apps/shared_libs/mdl"]):
875 out.append(
"a claimed coverage project must not be reported unclaimed")
876 if not unclaimed_coverage_projects([
"tools/unwired"]):
877 out.append(
"an unclaimed coverage project must be reported")
878 if not in_census(
"libs/ra8_demo/src/a.c")
or in_census(
"libs/ra8_demo/tests/a.c"):
879 out.append(
"the census must take production units and reject test sources")
882 "apps/shared_libs/reflow/v2/src/reflow_v2.cpp",
888 out.append(
"the mutually exclusive reflow v2 adapter must remain platform-cross-only")
890 structural_reason(
"apps/shared_libs/demo/src/host.c", compiled=
False, firmware_dirs=())
893 out.append(
"ordinary unmeasured app code must remain hosted debt")
897def _format_failures() -> list[str]:
898 """Prove the baseline round-trips and renders byte-identically twice."""
899 text = format_baseline(SELFTEST_BASELINE)
901 if parse_baseline(text) != SELFTEST_BASELINE:
902 out.append(
"the baseline must round-trip through format/parse unchanged")
903 if format_baseline(SELFTEST_BASELINE) != text:
904 out.append(
"the baseline must render identically on every call")
906 parse_baseline(
"libs/a.c\tMEASURED\t1\t2\n")
910 out.append(
"a malformed baseline row must raise, not be skipped")
914def selftest() -> int:
915 """Prove every rule fires and stays quiet, and that no scope collapsed."""
916 cases = len(_ratchet_cases()) + len(_kind_cases()) + len(_move_cases())
919 + _severity_failures()
925 for name
in failures:
926 print(f
"check_tree_coverage.py --selftest: FAIL: {name}", file=sys.stderr)
929 f
"check_tree_coverage.py --selftest: PASS "
930 f
"({cases} both-direction cases, 4 non-vacuity floors)"
936 """Dispatch the selftest, the project list, or the gate."""
937 parser = argparse.ArgumentParser(description=
"One coverage policy for the whole tree.")
938 parser.add_argument(
"--selftest", action=
"store_true", help=
"prove both directions")
939 parser.add_argument(
"--update", action=
"store_true", help=
"re-freeze the baseline")
941 "--projects", action=
"store_true", help=
"print '<name> <cmake-dir>' per project"
943 args = parser.parse_args()
947 for project
in PROJECTS:
948 print(f
"{project.name} {project.cmake_dir}")
950 return run_gate(update=args.update)
953if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.