4"""check_mcdc_floor.py -- per-file MC/DC FLOOR gate (no allowlist).
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.
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:
20 reachable_total = total_decisions - deactivated_decisions
21 reachable_covered = decisions at 100% MC/DC
22 reachable_pct = 100 * reachable_covered / reachable_total
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.
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.
42Exit 0 if every in-scope file with at least one reachable decision is
43>= FLOOR_PCT, else exit 1 with the offenders.
45Copyright (c) 2026 Brighton Sikarskie
46SPDX-License-Identifier: MIT
49from __future__
import annotations
53from pathlib
import Path
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."""
63REPO_ROOT = Path(__file__).resolve().parents[2]
64MCDC_JSON = REPO_ROOT /
"build" /
"mcdc-report" /
"mcdc_per_file.json"
66IN_SCOPE_PREFIXES = (
"libs/",
"apps/shared_libs/",
"examples/",
"port/",
"tools/")
67"""First-party production roots represented in the live MC/DC report."""
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."""
73OUT_OF_SCOPE_PARTS = frozenset({
"tests",
"test",
"_deps"})
74"""Nested test suites and dependency-fetch output directory names."""
77def is_generated_part(part: str) -> bool:
78 """True for a CMake/build output directory component."""
79 return part ==
"build" or part.startswith(
"build-")
82def normalize(path: str) -> str:
83 """Normalise a coverage-JSON ``file`` field to a repo-relative POSIX path.
85 The field may arrive absolute or already relative, so both are handled.
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.
92 p = path.replace(
"\\",
"/")
93 marker =
"/" + REPO_ROOT.name +
"/"
95 p = p.split(marker, 1)[1]
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):
103 if rel.startswith(OUT_OF_SCOPE_PREFIXES):
105 parts = Path(rel).parts
106 if OUT_OF_SCOPE_PARTS.intersection(parts):
108 return not any(is_generated_part(part)
for part
in parts)
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):
115 return next(prefix
for prefix
in IN_SCOPE_PREFIXES
if rel.startswith(prefix))
118def file_reachable(entry: dict) -> tuple[int, int]:
119 """Return (reachable_covered, reachable_total) decisions for one file.
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.
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
132def _collect_offenders(
134) -> tuple[list[tuple[float, str, int, int]], dict[str, int]]:
135 """Return offenders and per-root counts for in-scope decision-bearing files.
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.
140 offenders: list[tuple[float, str, int, int]] = []
141 checked_by_scope = dict.fromkeys(IN_SCOPE_PREFIXES, 0)
143 rel = normalize(entry.get(
"file",
""))
144 prefix = scope_prefix(rel)
147 covered, reachable_total = file_reachable(entry)
148 if reachable_total <= 0:
150 checked_by_scope[prefix] += 1
151 pct = 100.0 * covered / reachable_total
153 offenders.append((pct, rel, covered, reachable_total))
154 return offenders, checked_by_scope
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]
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}
167def selftest() -> int:
168 """Prove every production root is required and every exemption stays out."""
169 failures: list[str] = []
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,
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}")
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"),
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")
202 _entry(entry[
"file"], 0, 1)
if entry[
"file"].startswith(
"port/")
else entry
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")
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")
215 _entry(
"libs/third_party/soup.c", 0),
216 _entry(
"apps/shared_libs/third_party/soup.c", 0),
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")
223 for failure
in failures:
224 print(f
"check_mcdc_floor.py selftest: FAIL -- {failure}")
226 print(
"check_mcdc_floor.py selftest: PASS -- scope and non-vacuity checks hold.")
230def _report_offenders(offenders: list[tuple[float, str, int, int]]) ->
None:
231 """Print the below-floor table, worst first, with the remedy."""
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):"
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}")
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 "
250 """Fail when any in-scope file sits below the reachable MC/DC floor.
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.
256 A missing JSON report exits 1 rather than passing vacuously.
258 if not MCDC_JSON.is_file():
260 f
"check_mcdc_floor.py: ERROR -- {MCDC_JSON} not found; "
261 f
"run `bash scripts/report/mcdc_report.sh` first."
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}")
271 files = data.get(
"files", [])
273 print(
"check_mcdc_floor.py: ERROR -- MC/DC JSON has no files.")
276 offenders, checked_by_scope = _collect_offenders(files)
278 missing_scopes = _missing_scopes(checked_by_scope)
281 "check_mcdc_floor.py: ERROR -- no reachable decisions matched required "
282 f
"scope(s): {', '.join(missing_scopes)}; check the JSON path / scope."
287 _report_offenders(offenders)
290 checked = sum(checked_by_scope.values())
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."
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.