ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_mcdc_floor.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"""check_mcdc_floor.py -- per-file MC/DC FLOOR gate (no allowlist).
5
6Per CLAUDE.md "IEC 61508 SIL 3 / DO-178C Level B" every compound boolean
7decision in first-party code must be covered to full Modified
8Condition/Decision Coverage. The historical MC/DC gate only looked at the
9project-wide TOTAL row (`mcdc_report.sh` + `.github/mcdc-baseline.txt`),
10which a well-covered majority can hold above a baseline while an individual
11file rots: an aggregate is an average, and averages hide per-file holes.
12This gate closes that hole the same way `check_tree_coverage.py` closes it
13for line and branch coverage -- it fails CI if ANY first-party file drops below the
14floor, and it deliberately has NO allowlist / no per-file exemption table.
15
16Unit of measure: the llvm-cov "MC/DC Decision Region" (one compound `&&` /
17`||` decision), the same unit `regen_mcdc_gaps.py` classifies. A file's
18score is its *reachable* MC/DC rate:
19
20 reachable_total = total_decisions - deactivated_decisions
21 reachable_covered = decisions at 100% MC/DC
22 reachable_pct = 100 * reachable_covered / reachable_total
23
24Deactivated decisions (DO-178C 6.4.4.3 -- documented as unreachable on any
25public-API path, catalogued in docs/MCDC_DEACTIVATIONS.md and driven by an
26explicit `// mcdc-deactivated:` annotation or a conservative structural
27classifier) are dropped from BOTH numerator and denominator, exactly as the
28line-coverage floor drops `gcovr/excluded` lines. This is the deactivation
29mechanism, not an allowlist: a decision cannot escape the floor without a
30catalogued rationale that the whole tree can audit. A file whose only gaps
31are deactivated therefore stays at 100% reachable and passes.
32
33Input: `build/mcdc-report/mcdc_per_file.json`, written by
34`regen_mcdc_gaps.py` from the live `just quality::local::mcdc` report
35(`build/mcdc-report/mcdc.txt`). Run the MC/DC build first (which runs the
36regenerator), then this. Scope covers every first-party production root the
37report contains: `libs/`, `apps/shared_libs/`, `examples/`, `port/`, and
38`tools/`. Vendored SOUP, generated font tables, nested test suites, and build
39outputs are excluded. Each production root must contribute at least one
40reachable decision so a missing report subtree cannot pass vacuously.
41
42Exit 0 if every in-scope file with at least one reachable decision is
43>= FLOOR_PCT, else exit 1 with the offenders.
44
45Copyright (c) 2026 Brighton Sikarskie
46SPDX-License-Identifier: MIT
47"""
48
49from __future__ import annotations
50
51import json
52import sys
53from pathlib import Path
54
55FLOOR_PCT = 100.0
56"""Per-file reachable-MC/DC floor, in percent. DO-178C Level B mandates full
57MC/DC of every reachable compound decision; a reachable gap in any single
58file is a hard CI failure. Deactivated (documented-unreachable) decisions are
59excluded per DO-178C 6.4.4.3, so 100% is the honest bar the tree meets today.
60No allowlist: a file below this is fixed at the root (a real MC/DC vector, or
61a catalogued `// mcdc-deactivated:` rationale), never grandfathered."""
62
63REPO_ROOT = Path(__file__).resolve().parents[2]
64MCDC_JSON = REPO_ROOT / "build" / "mcdc-report" / "mcdc_per_file.json"
65
66IN_SCOPE_PREFIXES = ("libs/", "apps/shared_libs/", "examples/", "port/", "tools/")
67"""First-party production roots represented in the live MC/DC report."""
68
69OUT_OF_SCOPE_PREFIXES = ("libs/third_party/", "apps/shared_libs/third_party/", "libs/ra8_fonts/")
70"""Vendored SOUP and generated font tables -- exempt from first-party rules
71per the CLAUDE.md coding-standards scope, so exempt from the floor too."""
72
73OUT_OF_SCOPE_PARTS = frozenset({"tests", "test", "_deps"})
74"""Nested test suites and dependency-fetch output directory names."""
75
76
77def is_generated_part(part: str) -> bool:
78 """True for a CMake/build output directory component."""
79 return part == "build" or part.startswith("build-")
80
81
82def normalize(path: str) -> str:
83 """Normalise a coverage-JSON ``file`` field to a repo-relative POSIX path.
84
85 The field may arrive absolute or already relative, so both are handled.
86
87 The absolute-path split marker is derived from the checkout directory
88 basename (`REPO_ROOT.name`, itself resolved from this file's location)
89 rather than a hardcoded project name, so the gate strips the prefix
90 correctly from any clone regardless of what the repo directory is named.
91 """
92 p = path.replace("\\", "/")
93 marker = "/" + REPO_ROOT.name + "/"
94 if marker in p:
95 p = p.split(marker, 1)[1]
96 return p.lstrip("./")
97
98
99def in_scope(rel: str) -> bool:
100 """True if `rel` is a first-party file subject to the MC/DC floor."""
101 if not rel.startswith(IN_SCOPE_PREFIXES):
102 return False
103 if rel.startswith(OUT_OF_SCOPE_PREFIXES):
104 return False
105 parts = Path(rel).parts
106 if OUT_OF_SCOPE_PARTS.intersection(parts):
107 return False
108 return not any(is_generated_part(part) for part in parts)
109
110
111def scope_prefix(rel: str) -> str | None:
112 """Return the first-party scope root for ``rel``, or ``None`` if exempt."""
113 if not in_scope(rel):
114 return None
115 return next(prefix for prefix in IN_SCOPE_PREFIXES if rel.startswith(prefix))
116
117
118def file_reachable(entry: dict) -> tuple[int, int]:
119 """Return (reachable_covered, reachable_total) decisions for one file.
120
121 Deactivated decisions (DO-178C 6.4.4.3) are removed from the denominator;
122 covered decisions are never deactivated, so the numerator is just the
123 count of decisions at 100% MC/DC.
124 """
125 total = int(entry.get("total_decisions", 0))
126 covered = int(entry.get("covered_decisions", 0))
127 deactivated = int(entry.get("deactivated_decisions", 0))
128 reachable_total = total - deactivated
129 return covered, reachable_total
130
131
132def _collect_offenders(
133 files: list[dict],
134) -> tuple[list[tuple[float, str, int, int]], dict[str, int]]:
135 """Return offenders and per-root counts for in-scope decision-bearing files.
136
137 A file with no reachable decision is skipped rather than counted as a pass:
138 it has nothing to measure, and scoring it 100% would dilute the floor.
139 """
140 offenders: list[tuple[float, str, int, int]] = []
141 checked_by_scope = dict.fromkeys(IN_SCOPE_PREFIXES, 0)
142 for entry in files:
143 rel = normalize(entry.get("file", ""))
144 prefix = scope_prefix(rel)
145 if prefix is None:
146 continue
147 covered, reachable_total = file_reachable(entry)
148 if reachable_total <= 0:
149 continue
150 checked_by_scope[prefix] += 1
151 pct = 100.0 * covered / reachable_total
152 if pct < FLOOR_PCT:
153 offenders.append((pct, rel, covered, reachable_total))
154 return offenders, checked_by_scope
155
156
157def _missing_scopes(checked_by_scope: dict[str, int]) -> list[str]:
158 """Return required first-party roots absent from a coverage report."""
159 return [prefix for prefix in IN_SCOPE_PREFIXES if checked_by_scope.get(prefix, 0) == 0]
160
161
162def _entry(path: str, covered: int = 1, total: int = 1) -> dict:
163 """Build a minimal per-file fixture for the embedded scope self-test."""
164 return {"file": path, "covered_decisions": covered, "total_decisions": total}
165
166
167def selftest() -> int:
168 """Prove every production root is required and every exemption stays out."""
169 failures: list[str] = []
170 scope_cases = {
171 "libs/ra8_core/src/core.c": True,
172 "apps/shared_libs/book/src/book.c": True,
173 "examples/ek_ra8d2/demo/src/main.c": True,
174 "port/posix/src/io.c": True,
175 "tools/ra8_emulator/src/main.c": True,
176 "libs/third_party/soup.c": False,
177 "apps/shared_libs/third_party/soup/source.c": False,
178 "libs/ra8_fonts/src/generated.c": False,
179 "apps/shared_libs/book/tests/src/test_book.c": False,
180 "examples/ek_ra8d2/demo/build/generated.c": False,
181 "examples/ek_ra8d2/demo/build-reflow-v2/generated.c": False,
182 "port/esp-hosted/build-mcdc/shim.c": False,
183 "tools/demo/_deps/vendor.c": False,
184 "src/legacy.c": False,
185 }
186 for path, expected in scope_cases.items():
187 if in_scope(path) is not expected:
188 failures.append(f"scope mismatch for {path}: expected {expected}")
189
190 covered = [
191 _entry("libs/ra8_core/src/core.c"),
192 _entry("apps/shared_libs/book/src/book.c"),
193 _entry("examples/ek_ra8d2/demo/src/main.c"),
194 _entry("port/posix/src/io.c"),
195 _entry("tools/ra8_emulator/src/main.c"),
196 ]
197 offenders, counts = _collect_offenders(covered)
198 if offenders or counts != dict.fromkeys(IN_SCOPE_PREFIXES, 1):
199 failures.append("covered fixtures did not populate every required production root")
200
201 below_floor = [
202 _entry(entry["file"], 0, 1) if entry["file"].startswith("port/") else entry
203 for entry in covered
204 ]
205 offenders, _counts = _collect_offenders(below_floor)
206 if len(offenders) != 1 or offenders[0][1] != "port/posix/src/io.c":
207 failures.append("below-floor production fixture did not become an offender")
208
209 missing_tools = [entry for entry in covered if not entry["file"].startswith("tools/")]
210 offenders, counts = _collect_offenders(missing_tools)
211 if offenders or _missing_scopes(counts) != ["tools/"]:
212 failures.append("a missing production root did not fail its non-vacuity check")
213
214 vendor_only = [
215 _entry("libs/third_party/soup.c", 0),
216 _entry("apps/shared_libs/third_party/soup.c", 0),
217 ]
218 offenders, counts = _collect_offenders(vendor_only)
219 if offenders or _missing_scopes(counts) != list(IN_SCOPE_PREFIXES):
220 failures.append("exempt-only input did not fail every production non-vacuity check")
221
222 if failures:
223 for failure in failures:
224 print(f"check_mcdc_floor.py selftest: FAIL -- {failure}")
225 return 1
226 print("check_mcdc_floor.py selftest: PASS -- scope and non-vacuity checks hold.")
227 return 0
228
229
230def _report_offenders(offenders: list[tuple[float, str, int, int]]) -> None:
231 """Print the below-floor table, worst first, with the remedy."""
232 offenders.sort()
233 print(
234 f"check_mcdc_floor.py: {len(offenders)} first-party file(s) below "
235 f"the {FLOOR_PCT:.0f}% reachable-MC/DC floor (NO allowlist):"
236 )
237 print(" mc/dc covered/reachable file")
238 for pct, rel, covered, reachable_total in offenders:
239 print(f" {pct:5.1f}% {covered:5d}/{reachable_total:<5d} {rel}")
240 print(
241 "Fix each at the root -- add the missing MC/DC vector (N+1 vectors "
242 "for N conditions; see docs/MCDC.md), or, if the gap is genuinely "
243 "unreachable on any public-API path, catalogue it with a "
244 "`// mcdc-deactivated:` rationale per DO-178C 6.4.4.3. Do NOT add "
245 "an allowlist."
246 )
247
248
249def main() -> int:
250 """Fail when any in-scope file sits below the reachable MC/DC floor.
251
252 The floor is REACHABLE coverage, not absolute: decisions classified as
253 deactivated are excluded, so the number this gates is what tests could
254 actually cover rather than a figure no test can ever reach.
255
256 A missing JSON report exits 1 rather than passing vacuously.
257 """
258 if not MCDC_JSON.is_file():
259 print(
260 f"check_mcdc_floor.py: ERROR -- {MCDC_JSON} not found; "
261 f"run `bash scripts/report/mcdc_report.sh` first."
262 )
263 return 1
264
265 try:
266 data = json.loads(MCDC_JSON.read_text(encoding="utf-8"))
267 except (OSError, json.JSONDecodeError) as exc:
268 print(f"check_mcdc_floor.py: ERROR -- cannot read MC/DC JSON: {exc}")
269 return 1
270
271 files = data.get("files", [])
272 if not files:
273 print("check_mcdc_floor.py: ERROR -- MC/DC JSON has no files.")
274 return 1
275
276 offenders, checked_by_scope = _collect_offenders(files)
277
278 missing_scopes = _missing_scopes(checked_by_scope)
279 if missing_scopes:
280 print(
281 "check_mcdc_floor.py: ERROR -- no reachable decisions matched required "
282 f"scope(s): {', '.join(missing_scopes)}; check the JSON path / scope."
283 )
284 return 1
285
286 if offenders:
287 _report_offenders(offenders)
288 return 1
289
290 checked = sum(checked_by_scope.values())
291 print(
292 f"check_mcdc_floor.py: PASS -- all {checked} first-party file(s) "
293 f"with a reachable decision are >= {FLOOR_PCT:.0f}% MC/DC."
294 )
295 return 0
296
297
298if __name__ == "__main__":
299 sys.exit(selftest() if sys.argv[1:] == ["--selftest"] else main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298