ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
tree_coverage_model.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The coverage census: what is enrolled, what measures it, why a row is unmeasured.
4
5``check_tree_coverage.py`` is the ENFORCER -- it reads the measured traces and
6judges them against the committed baseline. This module is the MODEL it judges
7against: which translation units are enrolled, which host projects measure
8them, what an unmeasured row is allowed to say, and how small any one root may
9legitimately get before the enumeration itself is the defect.
10
11Keeping the two apart is the same split ``lint_coverage_rules.py`` /
12``check_lint_coverage.py`` and ``tier_layers.py`` / ``check_tier_imports.py``
13already use here: the tables below are the part a human edits when a new
14measurement project or a new source root lands, and that edit is reviewable
15without reading the trace plumbing.
16
17ONE CENSUS
18----------
19Every first-party ``.c`` / ``.cc`` / ``.cpp`` under ``libs/``, ``src/``,
20``port/``, ``tools/``, ``apps/`` and ``examples/`` is enrolled -- firmware,
21platform, host tool and product alike. There is one quality bar for the tree
22and no tier gets a softer one, so there is no per-tier scope list to fall out
23of date. The enumeration itself comes from ``lint_targets.first_party_paths``,
24i.e. from ``git ls-files``, so a directory added tomorrow is enrolled the day
25it lands.
26
27Only three things are subtracted, and each is subtracted somewhere else first:
28
29* vendored SOUP and generated tables -- ``lint_targets`` already drops
30 ``libs/third_party/``, ``libs/ra8_fonts/``, ``tools/vela/generated/`` and
31 ``port/threadx/``;
32* the individually registered generated sources in
33 ``lint_coverage_rules.PATH_CLASS`` -- a protobuf-c codec is its generator's
34 output, not hand-authored code;
35* test sources. A file under a ``tests/`` directory is the INSTRUMENT, not the
36 thing measured, and the ``tests/`` root is already outside the census by the
37 same reasoning. Applying it at any depth is what keeps the rule uniform
38 instead of a per-product carve-out: ``apps/shared_libs/mdl/tests/`` and
39 ``tools/ra8_emulator/tests/`` are test code exactly as ``tests/`` is.
40
41Headers carry no row. Inline code in a header is measured through the TUs that
42include it, and a header row would double-count it against whichever TU
43happened to be compiled first.
44"""
45
46from __future__ import annotations
47
48import sys
49from dataclasses import dataclass
50from pathlib import Path
51
52sys.path.insert(0, str(Path(__file__).resolve().parent))
53
54from lint_coverage_rules import PATH_CLASS
55from lint_targets import first_party_paths
56
57REPO_ROOT = Path(__file__).resolve().parents[2]
58
59#: The five first-party source roots. ``tests/`` is deliberately absent: it is
60#: the instrument. Trailing slashes so a root can never prefix-match a sibling.
61CENSUS_ROOTS: tuple[str, ...] = (
62 "libs/",
63 "port/",
64 "tools/",
65 "apps/",
66 "examples/",
67)
68
69#: Translation-unit suffixes. Headers are excluded by construction.
70CENSUS_SUFFIXES: tuple[str, ...] = (".c", ".cc", ".cpp", ".cxx")
71
72#: A path COMPONENT that marks test code wherever it appears.
73TEST_DIR_COMPONENT = "tests"
74
75#: The ``lint_coverage_rules`` class whose members are a generator's output.
76GENERATED_CLASS = "generated-source"
77
78
79def root_of(rel: str) -> str:
80 """Return the census root a repo-relative path belongs to, without its slash."""
81 return rel.split("/", 1)[0]
82
83
84def is_test_source(rel: str) -> bool:
85 """True when `rel` sits under a ``tests/`` directory at any depth."""
86 return TEST_DIR_COMPONENT in rel.split("/")[:-1]
87
88
89def in_census(rel: str) -> bool:
90 """True when `rel` is an enrolled first-party translation unit.
91
92 The caller is expected to have filtered SOUP, generated tables and build
93 output already (``lint_targets`` does), so this adds only the three
94 subtractions this module owns: root, generated registry, test source.
95 """
96 if not rel.startswith(CENSUS_ROOTS) or not rel.endswith(CENSUS_SUFFIXES):
97 return False
98 if PATH_CLASS.get(rel) == GENERATED_CLASS:
99 return False
100 return not is_test_source(rel)
101
102
103def census_paths(paths: list[str] | None = None) -> list[str]:
104 """Every enrolled translation unit, sorted.
105
106 Args:
107 paths: Repo-relative candidates to filter. Defaults to the tracked
108 first-party tree; the parameter exists so a selftest can drive the
109 rule with a fixture instead of the live checkout.
110
111 Returns:
112 The enrolled repo-relative paths, sorted.
113 """
114 if paths is None:
115 paths = first_party_paths(CENSUS_SUFFIXES)
116 return sorted(rel for rel in paths if in_census(rel))
117
118
119# ---------------------------------------------------------------------------
120# MEASUREMENT PROJECTS -- the host builds that produce execution data.
121#
122# Each one is configured with ``RA8_COVERAGE=ON``, built, run under ctest, and
123# reported by ``scripts/report/tree_coverage.sh`` into one gcovr trace. The
124# traces are then merged, so a translation unit compiled by more than one
125# project (the mdl core is built by BOTH the host suite and the mdl host
126# form) carries the union of what every project executed rather than whichever
127# number the last sweep happened to produce.
128#
129# ``subsumes`` names a source root whose own coverage-capable listfile is
130# configured as a SUBDIRECTORY of this project rather than on its own. It is
131# not decoration: ``unclaimed_coverage_projects`` below fails when a listfile
132# declares ``option(RA8_COVERAGE ...)`` and no project claims it, which is what
133# stops a new measurable project from being added and silently never measured.
134# ---------------------------------------------------------------------------
135
136
137@dataclass(frozen=True)
138class MeasurementProject:
139 """One host build that produces coverage data for the census."""
140
141 name: str
142 """Trace file stem and build subdirectory name."""
143
144 cmake_dir: str
145 """Repo-relative directory handed to ``cmake -S``."""
146
147 subsumes: tuple[str, ...]
148 """Coverage-capable source roots this project configures as subdirectories."""
149
150 min_files: int
151 """Non-vacuity floor: census units this project's own report must carry. A
152 project whose build silently stopped instrumenting reports a handful of
153 files and the merged total still looks healthy, so the floor is per
154 project rather than on the merge."""
155
156 @property
157 def claimed_dirs(self) -> tuple[str, ...]:
158 """Every source root whose coverage option this project is responsible for."""
159 return (self.cmake_dir, *self.subsumes)
160
161
162PROJECTS: tuple[MeasurementProject, ...] = (
163 # Measured 480 census units when the gate landed.
164 MeasurementProject("host-tests", "tests", (), 400),
165 # Measured 57 census units when the gate landed.
166 MeasurementProject("mdl", "apps/host/mdl", ("apps/shared_libs/mdl",), 50),
167)
168
169#: The declaration a listfile makes when it can emit coverage data.
170COVERAGE_OPTION_DECLARATION = "option(RA8_COVERAGE"
171
172
173def coverage_capable_dirs(listfiles: dict[str, str]) -> list[str]:
174 """Return the directories whose listfiles declare the coverage option.
175
176 Args:
177 listfiles: Repo-relative listfile path -> its text.
178
179 Returns:
180 The owning directories, sorted and de-duplicated.
181 """
182 found = {
183 rel.rsplit("/", 1)[0]
184 for rel, text in listfiles.items()
185 if COVERAGE_OPTION_DECLARATION in text
186 }
187 return sorted(found)
188
189
190def unclaimed_coverage_projects(dirs: list[str]) -> list[str]:
191 """Return coverage-capable directories no measurement project claims.
192
193 A directory is claimed when it IS a project's claimed root or sits under
194 one. Anything left over can produce coverage data that nothing collects,
195 which is how a whole product stays invisible while the gate reports a
196 clean tree.
197 """
198 claimed = {d for project in PROJECTS for d in project.claimed_dirs}
199 prefixes = tuple(f"{d}/" for d in sorted(claimed))
200 return [d for d in dirs if d not in claimed and not d.startswith(prefixes)]
201
202
203# ---------------------------------------------------------------------------
204# WHY A UNIT IS UNMEASURED -- four classes, each derived from the tree.
205#
206# An unmeasured unit gets an EXPLICIT row rather than being absent, so nothing
207# is silently missing, and the reason is a class the checker can re-derive
208# instead of prose a human can write anything into. A row whose reason does not
209# match what the tree says is a stale baseline, not a waiver.
210# ---------------------------------------------------------------------------
211
212REASON_FIRMWARE = "firmware-composition"
213"""Only ever cross-compiled into an image: ``examples/`` and the firmware
214products under ``apps/``. There is no host process to run and no exit status to
215read, so no host coverage build can reach it."""
216
217REASON_PLATFORM = "platform-cross-only"
218"""Platform code (``libs/``, ``src/``, ``port/``) that no host coverage build
219compiles at all -- board boot code, RTOS/USB stack ports, and drivers with no
220host double. It is compiled only by the ARM toolchain."""
221
222REASON_HOSTED = "hosted-no-coverage-build"
223"""Host-side tool or product code whose CMake project is not wired into any
224measurement project. This is the one class that is pure debt: the code IS host
225executable, so the fix is to add the project to ``PROJECTS``, not to keep the
226row."""
227
228REASON_COMPILED = "compiled-not-executed"
229"""A measurement project COMPILED the unit and no test ever executed it, so
230gcov wrote a .gcno and never a .gcda. Usually a static-archive member no test
231binary pulls in. Named separately because it is invisible to a report-driven
232gate -- the unit simply does not appear -- which is how three of these sat
233outside a floor advertised as having no allowlist."""
234
235REASONS: tuple[str, ...] = (
236 REASON_FIRMWARE,
237 REASON_PLATFORM,
238 REASON_HOSTED,
239 REASON_COMPILED,
240)
241
242PLATFORM_ROOTS: tuple[str, ...] = ("libs/", "src/", "port/")
243HOSTED_ROOTS: tuple[str, ...] = ("tools/", "apps/")
244FIRMWARE_ROOTS: tuple[str, ...] = ("examples/",)
245
246# Exact production adapters whose host build cannot coexist with the default
247# implementation in one coverage image. Reflow v2 implements the same public
248# symbols as v1 and is selected only by the firmware composition option; moving
249# it from libs/ into apps/shared_libs must not change that platform constraint.
250PLATFORM_CROSS_ONLY_UNITS: frozenset[str] = frozenset(
251 {"apps/shared_libs/reflow/v2/src/reflow_v2.cpp"}
252)
253
254
255def is_firmware_composition(rel: str, firmware_dirs: tuple[str, ...]) -> bool:
256 """True when `rel` is only ever linked into a cross-compiled image.
257
258 ``examples/`` is firmware by root. Under ``apps/`` the root answers
259 nothing -- mdl is a host program and the e-reader is a TrustZone
260 image -- so the discriminator is ``lint_targets.firmware_app_dirs()``: an
261 app directory carrying BOTH a linker script and a vector table.
262 """
263 if rel.startswith(FIRMWARE_ROOTS):
264 return True
265 return any(rel.startswith(f"{d}/") for d in firmware_dirs)
266
267
268def structural_reason(rel: str, *, compiled: bool, firmware_dirs: tuple[str, ...]) -> str:
269 """Return the one reason class the tree says an unmeasured `rel` may carry.
270
271 Args:
272 rel: Repo-relative census path with no execution data.
273 compiled: Whether a measurement project's build compiled it anyway.
274 firmware_dirs: ``lint_targets.firmware_app_dirs()`` for this tree.
275
276 Returns:
277 One member of ``REASONS``.
278 """
279 if compiled:
280 return REASON_COMPILED
281 if is_firmware_composition(rel, firmware_dirs):
282 return REASON_FIRMWARE
283 if rel in PLATFORM_CROSS_ONLY_UNITS:
284 return REASON_PLATFORM
285 if rel.startswith(PLATFORM_ROOTS):
286 return REASON_PLATFORM
287 return REASON_HOSTED
288
289
290# ---------------------------------------------------------------------------
291# NON-VACUITY FLOORS
292#
293# A checker that enumerates nothing reports a clean tree because it looked at
294# nothing -- the dominant defect class in this repository. One floor per root,
295# so a collapse confined to a SINGLE root still fails: a tree-wide total would
296# stay comfortably above its floor while ``tools/`` silently dropped to zero.
297#
298# Each floor is set well under the population measured when the gate landed, so
299# ordinary deletion never trips it and a broken enumeration always does.
300# ---------------------------------------------------------------------------
301
302ROOT_CENSUS_FLOORS: dict[str, int] = {
303 # The apps/shared_libs migration moved 82 production TUs out of libs/ and
304 # into apps/ without changing the tree-wide census. Rebalance both root
305 # floors together so the move cannot turn either root's guard vacuous.
306 "libs": 315, # measured 362
307 "examples": 300, # measured 370
308 "tools": 110, # measured 134
309 "apps": 120, # measured 150
310 "port": 28, # measured 35
311}
312
313MEASURED_FLOOR = 440
314"""Census units carrying execution data. Measured 507 when the gate landed; a
315drop past this means the measurement, not the tests, came apart."""
316
317
318def census_floor_failures(paths: list[str]) -> list[str]:
319 """Return one message per root whose census fell below its floor."""
320 counts = dict.fromkeys(ROOT_CENSUS_FLOORS, 0)
321 for rel in paths:
322 root = root_of(rel)
323 if root in counts:
324 counts[root] += 1
325 return [
326 f"census for root {root}/ collapsed to {counts[root]} unit(s), floor is {floor}"
327 for root, floor in sorted(ROOT_CENSUS_FLOORS.items())
328 if counts[root] < floor
329 ]