4"""mcdc_compound_ratchet.py -- MC/DC compound-decision ratchet (vs baseline).
8This project targets DO-178C Level B, where MC/DC coverage of every compound
9boolean decision is the core evidence. `check_new_compound_has_mcdc.py` is the
10detector for that rule and it works -- but it audited nothing in CI.
12`scripts/ci/gates/checks.sh` ran only its `--selftest`. The blocking `--range`
13invocation existed solely as a commented-out line (added commented, never
14enabled), while issue #426 carried a comment asserting it was blocking. So a
15newly-added uncovered compound decision passed CI, and the gate meant to stop
16exactly that was decorative -- this tree's signature defect.
18Turning the `--range` delta scan on was not the fix. A delta scan flags any
19decision whose normalized source line is new, so with a backlog this size it
20fails on a *reformat* of a pre-existing uncovered decision. That is a cliff,
21and a cliff gets bypassed. This ratchet is the mechanism the tree already uses
22for a measured debt it intends to burn down rather than grandfather -- it is
23the same shape as `tidy_ratchet.py` and `misra_ratchet.py`:
25* NEW findings (any per-file-per-function count above the baseline, or any
26 file/function bucket absent from the baseline) FAIL.
27* Shrinkage PASSES with a notice to re-baseline, which locks the progress in so
28 the debt can never quietly grow back.
29* Reformatting, renaming a variable inside, or moving an existing uncovered
30 decision changes no count, so it passes -- a ratchet, not a cliff.
32Closing the debt means the baseline reaching zero rows and being deleted -- not
33being regenerated larger. `--update` refuses to grow a bucket for exactly that
34reason; a genuine increase has to be justified by a human editing the file,
35which leaves a reviewable diff.
37BASELINE NORMALISATION -- per-file-per-FUNCTION counts, not raw finding lines.
38Raw findings carry line numbers, which churn on every unrelated edit above
39them. A `(file, function) -> count` map is invariant under that, still trips
40the moment a function gains another uncovered decision, and is keyed on exactly
41the granularity an MC/DC citation uses (`path@function`) -- so a baseline row
42names the function whose vectors are missing. It is strictly tighter than a
43per-file count: covering one decision while adding another elsewhere in the
46The measurement itself is `check_new_compound_has_mcdc.py::audit_tree`, the
47same detection primitives the delta modes use, imported rather than re-parsed
48from console output. There is therefore exactly one definition of "this
49decision lacks MC/DC vectors", and no text seam between detector and gate that
50could silently stop matching.
53 python3 scripts/checks/mcdc_compound_ratchet.py --selftest # assert it fires
54 python3 scripts/checks/mcdc_compound_ratchet.py --check # the CI gate
55 python3 scripts/checks/mcdc_compound_ratchet.py --update # re-baseline
56 python3 scripts/checks/mcdc_compound_ratchet.py --list # burn-down list
58Copyright (c) 2026 Brighton Sikarskie
59SPDX-License-Identifier: MIT
62from __future__
import annotations
67from collections
import Counter
68from pathlib
import Path
70sys.path.insert(0, str(Path(__file__).resolve().parent))
72from check_new_compound_has_mcdc
import audit_tree, collect_tree_citations, stale_tree_citations
74REPO_ROOT = Path(__file__).resolve().parents[2]
75BASELINE_FILE = REPO_ROOT /
".github" /
"mcdc-compound-baseline.txt"
78"""Cap on offending buckets echoed before the report truncates."""
81"""Column count of one baseline row: file, function, count."""
83MIN_PRODUCTION_FILES = 400
84"""Refuse to ratchet a scan that saw implausibly few production files.
86The tree holds about 540 production `.c` files under `libs/`, `port/`,
87`apps/shared_libs/`, and the firmware product directories derived by
88`lint_targets.firmware_app_dirs()`. A scan that finds a fraction of that is not
89looking at this repository -- a wrong `--root`, a partial checkout, a build
90snapshot missing a subtree. It would report FEWER findings, which reads as a
91burn-down, and `--update` would freeze that as the accepted state. Half the
92tree is the failure signature; a genuine deletion of half the firmware is not
93a thing that happens quietly.
97"""...and refuse a scan whose citation index is implausibly small.
99Every finding is "a decision whose enclosing function is not cited", so an
100empty or truncated `tests/` makes the ENTIRE tree look uncovered. The tree
101carries ~240 `path@function` citations today. Below this floor the citation
102index, not the code, is what changed, and the resulting count is meaningless in
107def scan(root: Path) -> tuple[Counter, int, int]:
108 """Scan ``root`` and return ``(counts, production_file_count, citation_count)``.
110 ``counts`` maps ``(repo-relative file, enclosing function)`` to the number
111 of compound decisions in that function with no matching MC/DC vector set.
112 The two scalars accompany it so the caller can refuse a scan whose scope
113 never got established -- a count is only trustworthy once the thing that
114 produced it is known to have looked at the tree.
116 files, findings = audit_tree(root)
117 counts: Counter = Counter()
118 for path, function, _line, _snippet
in findings:
119 counts[(path, function)] += 1
120 return counts, len(files), len(collect_tree_citations(root))
123def load_baseline(baseline_file: Path = BASELINE_FILE) -> Counter:
124 """Read the committed baseline into a Counter. Missing file means empty."""
125 counts: Counter = Counter()
126 if not baseline_file.is_file():
128 for raw
in baseline_file.read_text(encoding=
"ascii").splitlines():
130 if not line
or line.startswith(
"#"):
132 parts = line.split(
"\t")
133 if len(parts) != BASELINE_COLUMNS:
135 counts[(parts[0], parts[1])] = int(parts[2])
139def write_baseline(counts: Counter, baseline_file: Path = BASELINE_FILE) ->
None:
140 """Write `counts` out in the committed, sorted, diffable form."""
141 total = sum(counts.values())
143 "# MC/DC compound-decision ratchet baseline -- per-file-per-function counts.",
144 "# Consumed by scripts/checks/mcdc_compound_ratchet.py --check",
145 "# (CI gate: pre-commit-checks).",
147 "# Each row is a function holding N compound boolean decisions (`&&` / `||`)",
148 "# with NO matching `@par MC/DC:` `path@function` citation in any",
149 "# supported indexed test source. These predate enforcement (issue #426).",
150 "# The gate fails on any INCREASE, so the debt is frozen and can only be",
152 "# newly-added uncovered decision raises a count and fails.",
153 "# Reusable production code under apps/shared_libs/ is included. Adding that",
154 "# previously omitted scope exposed 1,195 pre-existing decisions in 586 buckets,",
155 "# with zero growth outside apps/shared_libs/. This is a visibility correction,",
156 "# not a coverage regression or waiver; all rows share the same no-growth rule.",
158 f
"# Total at this baseline: {total} uncovered compound decision(s)",
159 f
"# across {len({p for p, _ in counts})} file(s).",
161 "# Burn one down: add a `test_mcdc_<decision>` function with N+1 vectors",
162 "# to a supported indexed test source whose MC/DC block cites",
163 "# `path@function`. See docs/MCDC.md for the worked example.",
165 "# Regenerate after burning findings down:",
166 "# python3 scripts/checks/mcdc_compound_ratchet.py --update",
168 "# RENAMING a function (or moving a file) retires its row and creates a",
169 "# new one, which reads as growth -- so the gate fails and --update",
170 "# refuses, by design. Rename the row(s) here BY HAND, keeping the counts",
171 "# identical. That is a one-line reviewable diff, and it is deliberately",
172 "# not automated: rename detection that guessed wrong would silently",
173 "# absorb a genuinely new uncovered decision.",
175 "# Closing this out means this file reaching zero rows and being",
176 "# DELETED -- never regenerated larger.",
178 "# file<TAB>function<TAB>count",
180 for (path, function), n
in sorted(counts.items()):
182 lines.append(f
"{path}\t{function}\t{n}")
183 baseline_file.write_text(
"\n".join(lines) +
"\n", encoding=
"ascii")
186def scope_reason(files: int, citations: int) -> str |
None:
187 """Return a refusal reason when the scan's scope is not credible, else None.
189 Guards BOTH directions of the ratchet: a scan that examined a fragment of
190 the tree, or read a fragment of the citation index, produces a number that
191 must not be compared against the baseline and must certainly never be
194 if files < MIN_PRODUCTION_FILES:
196 f
"only {files} production .c file(s) found under libs/, port/, "
197 "apps/shared_libs/, and discovered firmware products, "
198 f
"below the {MIN_PRODUCTION_FILES} floor.\n"
199 " The scan is not looking at this repository (wrong --root, or a\n"
200 " partial checkout). A partial scan reports FEWER uncovered\n"
201 " decisions, which reads as a burn-down; refusing it is the only\n"
202 " way that cannot be mistaken for progress."
204 if citations < MIN_CITATIONS:
206 f
"only {citations} MC/DC citation(s) found in supported test sources, "
207 f
"below the {MIN_CITATIONS} floor.\n"
208 " Every finding is 'this function is not cited', so a truncated\n"
209 " citation index makes the whole tree look uncovered. What changed\n"
210 " is the index, not the code, so the count is meaningless."
215def report_stale_citations(stale: list[tuple[str, int, str, str]]) -> int:
216 """Reject citation tokens whose source path and function no longer resolve."""
219 unique = sorted({(source, function)
for _test, _line, source, function
in stale})
221 f
"MC/DC citation validation: {len(unique)} stale path@function key(s) "
222 f
"across {len(stale)} occurrence(s).",
225 for source, function
in unique[:MAX_DETAIL_LINES]:
228 for test, line, cited_source, cited_function
in stale
229 if (cited_source, cited_function) == (source, function)
231 print(f
" {source}@{function}", file=sys.stderr)
232 print(f
" cited by {', '.join(origins)}", file=sys.stderr)
233 if len(unique) > MAX_DETAIL_LINES:
234 print(f
" ... and {len(unique) - MAX_DETAIL_LINES} more stale key(s)", file=sys.stderr)
236 "Retarget moved functions to their current source path, or remove a citation "
237 "whose decision no longer exists.",
243def report(current: Counter, baseline: Counter) -> int:
244 """Compare and print. Returns the process exit code."""
246 for key, n
in sorted(current.items()):
247 was = baseline.get(key, 0)
249 grown.append((key, was, n))
251 total_now = sum(current.values())
252 total_was = sum(baseline.values())
255 print(file=sys.stderr)
257 "MC/DC ratchet: NEW uncovered compound decisions above the baseline",
260 print(file=sys.stderr)
261 for (path, function), was, now
in grown[:MAX_DETAIL_LINES]:
262 print(f
" {path}\n {function}(): {was} -> {now}", file=sys.stderr)
263 if len(grown) > MAX_DETAIL_LINES:
264 print(f
" ... and {len(grown) - MAX_DETAIL_LINES} more bucket(s)", file=sys.stderr)
265 print(file=sys.stderr)
267 "Every compound boolean decision under libs/, port/,\n"
268 "apps/shared_libs/, and discovered firmware products needs MC/DC\n"
269 "vectors: add a `test_mcdc_<decision>` function with N+1 vectors to\n"
270 "a supported indexed test source and cite the decision as\n"
271 "`path@function` in its `@par MC/DC:` block (see docs/MCDC.md).\n"
273 "The baseline is a burn-down of debt that predates enforcement, not\n"
274 "a place to record new debt. Do not --update to make this pass.",
280 f
"MC/DC ratchet: {total_now} uncovered compound decision(s), "
281 f
"baseline {total_was} -- no growth."
283 if total_now < total_was:
284 burned = total_was - total_now
285 print(f
" {burned} decision(s) burned down. Re-baseline to lock it in:")
286 print(
" python3 scripts/checks/mcdc_compound_ratchet.py --update")
295ra8_err_t ra8_covered_fn(int c, int d)
304_ST_UNCOVERED_C =
"""\
305ra8_err_t ra8_uncovered_fn(int a, int b)
314_ST_APP_UNCOVERED_C =
"""\
315ra8_err_t ra8_app_uncovered_fn(int a, int b)
325int soup_fn(int e, int f)
336 * @test ra8_covered_fn_mcdc
339 * Decision: `if (c && d)` cites libs/covered.c@ra8_covered_fn
340 * - Vector 1: c=1, d=1 -> true
341 * - Vector 2: c=0, d=1 -> false (varies c)
342 * - Vector 3: c=1, d=0 -> false (varies d)
344void test_mcdc_ra8_covered_fn(void) {}
352_ST_UNCOVERED_C_GROWN =
"""\
353ra8_err_t ra8_uncovered_fn(int a, int b)
366def _st_write(root: Path, rel: str, body: str) ->
None:
367 """Write ``body`` to ``rel`` under ``root``, creating parent dirs."""
369 dst.parent.mkdir(parents=
True, exist_ok=
True)
370 dst.write_text(body, encoding=
"ascii")
373def _st_build_tree(root: Path) ->
None:
374 """Lay down platform/app production, covered, and exempt SOUP decisions."""
375 _st_write(root,
"libs/covered.c", _ST_COVERED_C)
376 _st_write(root,
"libs/uncovered.c", _ST_UNCOVERED_C)
377 _st_write(root,
"apps/shared_libs/demo/src/app.c", _ST_APP_UNCOVERED_C)
378 _st_write(root,
"libs/third_party/soup.c", _ST_SOUP_C)
379 _st_write(root,
"apps/shared_libs/third_party/soup/source.c", _ST_SOUP_C)
380 _st_write(root,
"tests/test_covered.c", _ST_TEST_C)
383def _selftest_scan(tmp: Path) -> list[str]:
384 """Assert the whole-tree measurement counts the right decisions and no others."""
385 failures: list[str] = []
388 counts, files, cites = scan(root)
390 if counts.get((
"libs/uncovered.c",
"ra8_uncovered_fn")) != 1:
391 failures.append(
"scan() did not count the uncovered decision in libs/uncovered.c")
392 if counts.get((
"apps/shared_libs/demo/src/app.c",
"ra8_app_uncovered_fn")) != 1:
393 failures.append(
"scan() did not count the app-owned production decision")
394 if any(path ==
"libs/covered.c" for path, _fn
in counts):
395 failures.append(
"scan() counted libs/covered.c, which carries MC/DC vectors")
396 if any(
"third_party" in path
for path, _fn
in counts):
397 failures.append(
"scan() counted a decision under a canonical SOUP root")
398 if sum(counts.values()) != 2:
399 failures.append(f
"scan() found {sum(counts.values())} decision(s), expected exactly 2")
402 _st_write(root,
"libs/uncovered.c", _ST_UNCOVERED_C_GROWN)
403 grown, _files, _cites = scan(root)
404 if grown.get((
"libs/uncovered.c",
"ra8_uncovered_fn")) != 2:
405 failures.append(
"scan() did not see a second uncovered decision added to a known function")
408 if files < 2
or cites < 1:
410 f
"scan() reported an implausible scope: {files} file(s), {cites} citation(s)"
415def _selftest_ratchet(tmp: Path) -> list[str]:
416 """Assertions about the growth verdict and baseline round-tripping."""
417 failures: list[str] = []
419 base = Counter({(
"libs/f.c",
"fn_a"): 1})
421 if report(Counter({(
"libs/f.c",
"fn_a"): 2}), base) == 0:
422 failures.append(
"report() passed a bucket that GREW above the baseline")
424 if report(Counter({(
"libs/f.c",
"fn_a"): 1, (
"libs/f.c",
"fn_b"): 1}), base) == 0:
425 failures.append(
"report() passed a NEW function bucket in a baselined file")
427 if report(Counter({(
"libs/new.c",
"fn_a"): 1}), base) == 0:
428 failures.append(
"report() passed a finding in a file absent from the baseline")
430 if report(Counter({(
"libs/f.c",
"fn_a"): 1}), base) != 0:
431 failures.append(
"report() failed an unchanged bucket")
432 if report(Counter(), base) != 0:
433 failures.append(
"report() failed a fully burned-down baseline")
438 fixture_baseline = tmp /
"mcdc-compound-baseline.txt"
439 if fixture_baseline.resolve() == BASELINE_FILE.resolve():
440 failures.append(
"selftest fixture resolved to the committed baseline")
441 fixture = Counter({(
"libs/a.c",
"fn_x"): 3, (
"src/b.c",
"fn_y"): 1})
442 write_baseline(fixture, fixture_baseline)
443 if load_baseline(fixture_baseline) != fixture:
444 failures.append(
"write_baseline()/load_baseline() did not round-trip")
449def _selftest_scope_guard() -> list[str]:
450 """Assertions about the scope guards, in both directions."""
451 failures: list[str] = []
452 if scope_reason(MIN_PRODUCTION_FILES - 1, MIN_CITATIONS)
is None:
453 failures.append(
"scope_reason() accepted a scan that saw too few production files")
454 if scope_reason(MIN_PRODUCTION_FILES, MIN_CITATIONS - 1)
is None:
455 failures.append(
"scope_reason() accepted a scan with a truncated citation index")
456 if scope_reason(MIN_PRODUCTION_FILES, MIN_CITATIONS)
is not None:
457 failures.append(
"scope_reason() rejected a scan that is exactly at both floors")
461def _selftest_citation_resolution(tmp: Path) -> list[str]:
462 """Prove moved/missing symbols fire and an exact live citation stays quiet."""
463 failures: list[str] = []
464 root = tmp /
"citation-tree"
465 _st_write(root,
"libs/covered.c", _ST_COVERED_C)
466 _st_write(root,
"tests/test_citation.c", _ST_TEST_C)
467 if stale_tree_citations(root):
468 failures.append(
"citation validator rejected an exact live path@function")
471 "tests/test_citation.c",
472 _ST_TEST_C.replace(
"libs/covered.c@ra8_covered_fn",
"libs/old.c@ra8_covered_fn"),
474 stale = stale_tree_citations(root)
475 if len({(source, function)
for _test, _line, source, function
in stale}) != 1:
476 failures.append(
"citation validator did not reject exactly one moved source key")
479 "tests/test_citation.c",
480 _ST_TEST_C.replace(
"ra8_covered_fn",
"renamed_away"),
482 stale = stale_tree_citations(root)
483 if len({(source, function)
for _test, _line, source, function
in stale}) != 1:
484 failures.append(
"citation validator did not reject exactly one missing symbol key")
488def selftest() -> int:
489 """Assert the measurement and the ratchet fire, in BOTH directions.
491 Runs the REAL scan against a throwaway fixture tree, so a detector that
492 quietly stopped matching cannot pass as clean: it must COUNT the uncovered
493 decision, must NOT count the vector-covered or SOUP ones, and must see a
494 second uncovered decision appear in a function it already knew about.
496 with tempfile.TemporaryDirectory()
as td:
498 failures = _selftest_scan(tmp) + _selftest_ratchet(tmp)
499 failures += _selftest_citation_resolution(tmp)
500 failures += _selftest_scope_guard()
503 print(
"SELFTEST FAILED:", file=sys.stderr)
504 for problem
in failures:
505 print(f
" - {problem}", file=sys.stderr)
508 "selftest: MC/DC compound-decision ratchet OK "
509 "(counts an uncovered decision; ignores vector-covered and SOUP ones; "
510 "fails on growth, passes on shrinkage; refuses an implausible scope)."
520def _list_backlog(root: Path) -> int:
521 """Print every uncovered decision, grouped for burn-down work."""
522 _files, findings = audit_tree(root)
523 for path, function, line, snippet
in findings:
524 print(f
"{path}:{line}: {function}(): {snippet}")
525 print(f
"total: {len(findings)} uncovered compound decision(s)")
530 """Ratchet uncovered compound decisions against the committed baseline.
532 A ratchet, not a floor: ``--check`` fails when a count RISES, and
533 ``--update`` lowers the baseline once vectors are written. The baseline can
534 therefore only move downward, which is what stops a large legacy count from
535 being permanently accepted while still letting CI block a new one today.
537 Returns 0 when every count is at or below the baseline, 1 when one grew or
538 the scan's scope was not credible.
540 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
541 parser.add_argument(
"--root", default=str(REPO_ROOT), help=
"tree to scan (default: this repo)")
542 parser.add_argument(
"--check", action=
"store_true", help=
"gate against the baseline")
543 parser.add_argument(
"--update", action=
"store_true", help=
"rewrite the baseline")
544 parser.add_argument(
"--list", action=
"store_true", help=
"print the whole backlog")
545 parser.add_argument(
"--selftest", action=
"store_true", help=
"assert this gate still fires")
546 args = parser.parse_args()
550 root = Path(args.root).resolve()
552 return _list_backlog(root)
553 if not (args.check
or args.update):
554 parser.error(
"one of --check / --update / --list / --selftest is required")
556 current, files, citations = scan(root)
557 stale = stale_tree_citations(root)
561 broken = scope_reason(files, citations)
564 verb =
"--update" if args.update
else "--check"
565 print(f
"refusing to {verb}: {broken}", file=sys.stderr)
567 stale_rc = report_stale_citations(stale)
568 if broken
or stale_rc != 0:
572 baseline = load_baseline()
575 seeding =
not BASELINE_FILE.is_file()
576 grew = []
if seeding
else [k
for k, n
in current.items()
if n > baseline.get(k, 0)]
579 f
"refusing to --update: {len(grew)} bucket(s) would GROW. "
580 "The baseline is a burn-down; write the missing MC/DC vectors instead.",
583 for path, function
in grew[:MAX_DETAIL_LINES]:
584 print(f
" {path} {function}()", file=sys.stderr)
586 write_baseline(current)
588 f
"baseline updated: {sum(current.values())} uncovered compound decision(s) "
589 f
"recorded across {len({p for p, _ in current})} file(s) "
590 f
"({files} production file(s) scanned)."
595 f
"MC/DC ratchet: scanned {files} production file(s), "
596 f
"{citations} citation(s) in supported indexed test sources."
598 return report(current, load_baseline())
601if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.