ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_tree_coverage.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""ONE per-file coverage policy for every first-party translation unit.
5
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.
12
13WHAT REPLACED WHAT
14------------------
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.
23
24They are one policy now:
25
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.
45
46MEASUREMENT
47-----------
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.
55
56Run::
57
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
62
63Exit 0 when the tree matches the baseline, 1 on a violation, 2 when the
64measurement or the enumeration itself did not happen.
65"""
66
67from __future__ import annotations
68
69import argparse
70import json
71import sys
72from dataclasses import dataclass
73from pathlib import Path
74
75sys.path.insert(0, str(Path(__file__).resolve().parent))
76
77from lint_targets import firmware_app_dirs, first_party_paths
78from tree_coverage_model import (
79 CENSUS_SUFFIXES,
80 MEASURED_FLOOR,
81 PROJECTS,
82 REASON_COMPILED,
83 REASON_FIRMWARE,
84 REASON_HOSTED,
85 REASON_PLATFORM,
86 REASONS,
87 census_floor_failures,
88 census_paths,
89 coverage_capable_dirs,
90 in_census,
91 structural_reason,
92 unclaimed_coverage_projects,
93)
94
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"
99
100LINE_FLOOR_PCT = 90
101BRANCH_FLOOR_PCT = 80
102
103KIND_MEASURED = "MEASURED"
104KIND_UNMEASURED = "UNMEASURED"
105
106MEASURED_COLUMNS = 6
107UNMEASURED_COLUMNS = 3
108
109#: A finding that ``--update`` must refuse to write over: a real regression, a
110#: new file below the floor, or a unit that lost its measurement.
111HARD = "HARD"
112
113#: A finding that says the baseline no longer describes the tree in a direction
114#: ``--update`` may record: a new unit, a deleted unit, a unit that GAINED
115#: measurement, a reason class the tree no longer supports. The gate still
116#: fails on these -- a baseline that is out of date is not a true baseline.
117DRIFT = "DRIFT"
118
119#: line_covered, line_total, branch_covered, branch_total.
120Counts = tuple[int, int, int, int]
121
122
123@dataclass(frozen=True)
124class Row:
125 """One baseline row: a frozen measurement, or a named reason there is none."""
126
127 kind: str
128 """``KIND_MEASURED`` or ``KIND_UNMEASURED``."""
129
130 counts: Counts
131 """The frozen counts. ``NO_COUNTS`` on an UNMEASURED row."""
132
133 reason: str
134 """One of ``tree_coverage_model.REASONS``. Empty on a MEASURED row."""
135
136
137@dataclass(frozen=True)
138class Finding:
139 """One reason the tree and the baseline disagree."""
140
141 severity: str
142 """``HARD`` or ``DRIFT``."""
143
144 message: str
145 """Path-first, human-readable, and specific enough to act on."""
146
147
148#: The counts an UNMEASURED row carries. A sentinel rather than ``None`` so the
149#: two row kinds have one shape and no rule needs a null check to read them.
150NO_COUNTS: Counts = (0, 0, 0, 0)
151
152
153def measured(counts: Counts) -> Row:
154 """Build a MEASURED row from four gcovr counts."""
155 return Row(KIND_MEASURED, counts, "")
156
157
158def unmeasured(reason: str) -> Row:
159 """Build an UNMEASURED row from a reason class."""
160 return Row(KIND_UNMEASURED, NO_COUNTS, reason)
161
162
163# ---------------------------------------------------------------------------
164# Reading the measurement
165# ---------------------------------------------------------------------------
166
167
168def load_summary(path: Path) -> dict[str, Counts]:
169 """Load a gcovr ``--json-summary`` report into per-file integer counts.
170
171 Args:
172 path: The summary written by ``scripts/report/tree_coverage.sh``.
173
174 Returns:
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.
178
179 Raises:
180 SystemExit: When the file is missing or unreadable -- an absent
181 measurement is not an empty one.
182 """
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):
188 continue
189 rows[rel] = (
190 int(entry["line_covered"]),
191 int(entry["line_total"]),
192 int(entry["branch_covered"]),
193 int(entry["branch_total"]),
194 )
195 return rows
196
197
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():
201 print(
202 f"check_tree_coverage.py: missing measurement: {path}\n"
203 f" Run `bash scripts/report/tree_coverage.sh` first.",
204 file=sys.stderr,
205 )
206 raise SystemExit(2)
207 try:
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
212
213
214def compiled_sources() -> set[str]:
215 """Every census-shaped source a measurement project's build compiled.
216
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.
221
222 Raises:
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.
227 """
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"
232 if not db.is_file():
233 print(
234 f"check_tree_coverage.py: missing compile database: {db}\n"
235 f" Run `bash scripts/report/tree_coverage.sh` first.",
236 file=sys.stderr,
237 )
238 raise SystemExit(2)
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):
242 out.add(rel)
243 return out
244
245
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 []
250
251
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 + "/"):
260 return None
261 return source[len(root) + 1 :]
262
263
264def project_report_failures() -> list[str]:
265 """Return one message per measurement project whose own report is vacuous.
266
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.
271 """
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:
277 failures.append(
278 f"measurement project {project.name} reported {seen} census unit(s), "
279 f"floor is {project.min_files}"
280 )
281 return failures
282
283
284# ---------------------------------------------------------------------------
285# Deriving what the tree says right now
286# ---------------------------------------------------------------------------
287
288
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.
291
292 Args:
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``.
297
298 Returns:
299 Repo-relative path -> the row the tree currently supports.
300 """
301 firmware = firmware_app_dirs()
302 rows: dict[str, Row] = {}
303 for rel in paths:
304 counts = report.get(rel)
305 if counts is not None and counts[1] > 0:
306 rows[rel] = measured(counts)
307 continue
308 reason = structural_reason(
309 rel,
310 compiled=rel in compiled or counts is not None,
311 firmware_dirs=firmware,
312 )
313 rows[rel] = unmeasured(reason)
314 return rows
315
316
317# ---------------------------------------------------------------------------
318# The policy
319# ---------------------------------------------------------------------------
320
321
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)
325
326
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):
334 out.append(
335 Finding(HARD, f"{rel}: new unit enters below {BRANCH_FLOOR_PCT}% branch coverage")
336 )
337 if not out:
338 out.append(Finding(DRIFT, f"{rel}: new unit has no baseline row"))
339 return out
340
341
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.
344
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.
352
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.
359
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.
366 """
367 covered, total = now
368 base_covered, base_total = base
369 return (total < base_total) and ((base_covered - covered) <= (base_total - total))
370
371
372def _metric_findings(
373 rel: str, label: str, now: tuple[int, int], base: tuple[int, int]
374) -> list[Finding]:
375 """Ratchet one metric: uncovered debt may not grow, the ratio may not fall."""
376 covered, total = now
377 base_covered, base_total = base
378 out: list[Finding] = []
379 if (total - covered) > (base_total - base_covered):
380 out.append(
381 Finding(
382 HARD,
383 f"{rel}: uncovered {label} debt grew "
384 f"{base_total - base_covered} -> {total - covered}",
385 )
386 )
387 if (
388 base_total > 0
389 and covered * base_total < base_covered * total
390 and not _only_lost_code(now, base)
391 ):
392 out.append(
393 Finding(
394 HARD,
395 f"{rel}: {label} ratio regressed {base_covered}/{base_total} -> {covered}/{total}",
396 )
397 )
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"))
401 return out
402
403
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:
407 return [
408 Finding(
409 HARD,
410 f"{rel}: lost its measurement (now {now.reason}); gaining measurement is one-way",
411 )
412 ]
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
415 return [
416 *_metric_findings(rel, "line", (line_cov, line_total), (base_line_cov, base_line_total)),
417 *_metric_findings(
418 rel, "branch", (branch_cov, branch_total), (base_branch_cov, base_branch_total)
419 ),
420 ]
421
422
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:
430 return [
431 Finding(DRIFT, f"{rel}: reason class is now {now.reason}, baseline says {base.reason}")
432 ]
433 return []
434
435
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.
438
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
446 changes.
447
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.
454
455 Args:
456 fresh: What this measurement supports, from ``derive_rows``.
457 baseline: What is committed, from ``load_baseline``.
458
459 Returns:
460 New path -> the baseline path it moved from, for every unambiguous move.
461 """
462 vanished = _by_basename(set(baseline) - set(fresh))
463 arrived = _by_basename(set(fresh) - set(baseline))
464 return {
465 arrived[name][0]: paths[0]
466 for name, paths in vanished.items()
467 if len(paths) == 1 and len(arrived.get(name, [])) == 1
468 }
469
470
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)
476 return groups
477
478
479def evaluate(fresh: dict[str, Row], baseline: dict[str, Row]) -> list[Finding]:
480 """Return every disagreement between the tree and the committed baseline.
481
482 Args:
483 fresh: What this measurement supports, from ``derive_rows``.
484 baseline: What is committed, from ``load_baseline``.
485
486 Returns:
487 Findings sorted by severity then message, so a HARD violation is always
488 read before the drift it may have caused.
489 """
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()))
494 ]
495 for rel in sorted(fresh):
496 now = fresh[rel]
497 origin = moves.get(rel)
498 base = baseline[origin] if origin is not None else baseline.get(rel)
499 if base is None:
500 if now.kind == KIND_MEASURED:
501 out.extend(_new_file_findings(rel, now.counts))
502 else:
503 out.append(Finding(DRIFT, f"{rel}: new unit has no baseline row ({now.reason})"))
504 continue
505 if base.kind == KIND_MEASURED:
506 out.extend(_measured_findings(rel, now, base))
507 else:
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))
512
513
514# ---------------------------------------------------------------------------
515# The baseline file
516# ---------------------------------------------------------------------------
517
518BASELINE_HEADER = (
519 "# ONE coverage baseline for every first-party translation unit.",
520 "#",
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.",
524 "#",
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",
528 "# >=80% branch.",
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.",
532 "#",
533 "# Columns are TAB-separated. Rows are sorted by path.",
534)
535
536
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()]
540 lines = [
541 *BASELINE_HEADER,
542 f"# rows: {len(rows)}"
543 f" measured: {kinds.count(KIND_MEASURED)}"
544 f" unmeasured: {kinds.count(KIND_UNMEASURED)}",
545 "",
546 ]
547 for rel in sorted(rows):
548 row = rows[rel]
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}")
552 else:
553 lines.append(f"{rel}\t{KIND_UNMEASURED}\t{row.reason}")
554 return "\n".join([*lines, ""])
555
556
557def parse_baseline(text: str) -> dict[str, Row]:
558 """Parse baseline text into rows.
559
560 Raises:
561 ValueError: On a malformed row. A baseline that cannot be read is not
562 an empty baseline.
563 """
564 rows: dict[str, Row] = {}
565 for raw in text.splitlines():
566 if not raw or raw.startswith("#"):
567 continue
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])
574 else:
575 message = f"malformed baseline row: {raw!r}"
576 raise ValueError(message)
577 return rows
578
579
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():
583 return {}
584 return parse_baseline(path.read_text(encoding="ascii"))
585
586
587# ---------------------------------------------------------------------------
588# Scope guard: a measurable project that nothing measures
589# ---------------------------------------------------------------------------
590
591
592def scope_failures() -> list[str]:
593 """Return one message per coverage-capable listfile no project claims.
594
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.
600 """
601 listfiles = {
602 rel: (REPO_ROOT / rel).read_text(encoding="utf-8", errors="replace")
603 for rel in first_party_paths(("CMakeLists.txt", ".cmake"))
604 }
605 unclaimed = unclaimed_coverage_projects(coverage_capable_dirs(listfiles))
606 return [
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
610 ]
611
612
613# ---------------------------------------------------------------------------
614# The gate
615# ---------------------------------------------------------------------------
616
617
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)
623 for finding in hard:
624 print(f" [regression] {finding.message}", file=sys.stderr)
625 for finding in drift:
626 print(f" [stale row] {finding.message}", file=sys.stderr)
627 if hard:
628 print(
629 "\n A regression is fixed with a test, never by editing the baseline.",
630 file=sys.stderr,
631 )
632 if drift and not hard:
633 print(
634 "\n Re-freeze with `python3 scripts/checks/check_tree_coverage.py --update`.",
635 file=sys.stderr,
636 )
637
638
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)
644 return 2
645
646
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()
651 if setup:
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:
656 return _fail_setup(
657 [f"only {seen} census unit(s) carry execution data, floor is {MEASURED_FLOOR}"]
658 )
659 return paths, fresh
660
661
662def run_gate(*, update: bool) -> int:
663 """Judge the tree against the committed baseline, optionally re-freezing it."""
664 outcome = _measure()
665 if isinstance(outcome, int):
666 return outcome
667 _, fresh = outcome
668 baseline = load_baseline()
669 findings = evaluate(fresh, baseline) if baseline else []
670 if update:
671 hard = [f for f in findings if f.severity == HARD]
672 if hard:
673 _print_findings(hard)
674 return 1
675 BASELINE_FILE.write_text(format_baseline(fresh), encoding="ascii")
676 print(f"check_tree_coverage.py: wrote {BASELINE_FILE} ({len(fresh)} rows)")
677 return 0
678 if not baseline:
679 print("check_tree_coverage.py: no baseline; run --update once", file=sys.stderr)
680 return 2
681 if findings:
682 _print_findings(findings)
683 return 1
684 kinds = [row.kind for row in fresh.values()]
685 print(
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)."
689 )
690 return 0
691
692
693# ---------------------------------------------------------------------------
694# Selftest -- both directions, and a non-vacuity case for every floor.
695#
696# The cases drive `evaluate()`, `census_floor_failures()`,
697# `unclaimed_coverage_projects()` and the baseline round trip: the four things
698# that can silently stop working. One direction proves nothing, so every rule
699# below has a must-fire case AND a must-stay-quiet one -- a checker whose scope
700# collapsed to zero rows is also perfectly quiet.
701# ---------------------------------------------------------------------------
702
703#: The committed state the ratchet cases are measured against: one unit at the
704#: floor, one deep in debt, one firmware composition, one host tool with no
705#: coverage build. Every reason class and both row kinds are represented, so a
706#: rule that stopped covering either kind is caught by a case rather than by
707#: nobody.
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),
713}
714
715Case = tuple[str, dict[str, Row], bool]
716
717
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}
721
722
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"
727 return [
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),
735 # Deleting covered code lowers the ratio without making anything worse:
736 # exempt. Shrinking while uncovered debt grows is still caught.
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),
739 ]
740
741
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"
747 return [
748 (
749 "a well-covered new unit still needs a row",
750 {**SELFTEST_BASELINE, new: measured((9, 10, 8, 10))},
751 True,
752 ),
753 (
754 "a poorly-covered new unit fires",
755 {**SELFTEST_BASELINE, new: measured((8, 10, 7, 10))},
756 True,
757 ),
758 (
759 "a new unmeasured unit fires",
760 {**SELFTEST_BASELINE, new: unmeasured(REASON_PLATFORM)},
761 True,
762 ),
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),
766 (
767 "a deleted unit's stale row fires",
768 {k: v for k, v in SELFTEST_BASELINE.items() if k != tool},
769 True,
770 ),
771 ]
772
773
774#: The unit the move cases relocate, and where they relocate it to.
775MOVE_FROM = "apps/shared_libs/mdl/src/debt.c"
776MOVE_TO = "apps/host/mdl/src/debt.c"
777
778
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
783 return out
784
785
786def _move_cases() -> list[Case]:
787 """Cases for move detection: a real move, and every ambiguity it refuses."""
788 carried = SELFTEST_BASELINE[MOVE_FROM]
789 return [
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),
793 (
794 "a move that also renames the file is not paired",
795 _moved("apps/host/mdl/src/renamed.c", carried),
796 True,
797 ),
798 (
799 "a split into two same-named units is not paired",
800 {**_moved(MOVE_TO, carried), "tools/demo/src/debt.c": carried},
801 True,
802 ),
803 ]
804
805
806def _move_failures() -> list[str]:
807 """Prove a move carries its debt, and that no ambiguous pairing does."""
808 carried = SELFTEST_BASELINE[MOVE_FROM]
809 out: list[str] = []
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")
814 if detect_moves(
815 {**_moved(MOVE_TO, carried), "tools/demo/src/debt.c": carried}, SELFTEST_BASELINE
816 ):
817 out.append("an ambiguous same-basename arrival must not pair")
818 # The load-bearing pair: identical below-floor counts are HARD when the
819 # unit is new and quiet when the same unit merely moved.
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")
829 return out
830
831
832def _evaluate_failures() -> list[str]:
833 """Run every evaluate() case and name the ones that answered wrongly."""
834 return [
835 name
836 for name, fresh, should_fire in _ratchet_cases() + _kind_cases() + _move_cases()
837 if bool(evaluate(fresh, SELFTEST_BASELINE)) != should_fire
838 ]
839
840
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)
849 out: list[str] = []
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")
854 if shrink:
855 out.append("deleting covered code must not be reported as a regression")
856 # The ratio rule must still bite where it adds signal: a unit that did NOT
857 # shrink. Without this the shrink exemption could widen into a no-op.
858 if not any(
859 "ratio regressed" in f.message
860 for f in evaluate(_swap(frozen, measured((85, 100, 80, 100))), SELFTEST_BASELINE)
861 ):
862 out.append("a same-size unit whose ratio fell must still report the ratio")
863 return out
864
865
866def _scope_failures() -> list[str]:
867 """Prove the census, the floors and the project-claim guard all still bite."""
868 live = census_paths()
869 out: list[str] = []
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")
880 if (
881 structural_reason(
882 "apps/shared_libs/reflow/v2/src/reflow_v2.cpp",
883 compiled=False,
884 firmware_dirs=(),
885 )
886 != REASON_PLATFORM
887 ):
888 out.append("the mutually exclusive reflow v2 adapter must remain platform-cross-only")
889 if (
890 structural_reason("apps/shared_libs/demo/src/host.c", compiled=False, firmware_dirs=())
891 != REASON_HOSTED
892 ):
893 out.append("ordinary unmeasured app code must remain hosted debt")
894 return out
895
896
897def _format_failures() -> list[str]:
898 """Prove the baseline round-trips and renders byte-identically twice."""
899 text = format_baseline(SELFTEST_BASELINE)
900 out: list[str] = []
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")
905 try:
906 parse_baseline("libs/a.c\tMEASURED\t1\t2\n")
907 except ValueError:
908 pass
909 else:
910 out.append("a malformed baseline row must raise, not be skipped")
911 return out
912
913
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())
917 failures = (
918 _evaluate_failures()
919 + _severity_failures()
920 + _move_failures()
921 + _scope_failures()
922 + _format_failures()
923 )
924 if failures:
925 for name in failures:
926 print(f"check_tree_coverage.py --selftest: FAIL: {name}", file=sys.stderr)
927 return 1
928 print(
929 f"check_tree_coverage.py --selftest: PASS "
930 f"({cases} both-direction cases, 4 non-vacuity floors)"
931 )
932 return 0
933
934
935def main() -> int:
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")
940 parser.add_argument(
941 "--projects", action="store_true", help="print '<name> <cmake-dir>' per project"
942 )
943 args = parser.parse_args()
944 if args.selftest:
945 return selftest()
946 if args.projects:
947 for project in PROJECTS:
948 print(f"{project.name} {project.cmake_dir}")
949 return 0
950 return run_gate(update=args.update)
951
952
953if __name__ == "__main__":
954 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298