ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
mcdc_compound_ratchet.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"""mcdc_compound_ratchet.py -- MC/DC compound-decision ratchet (vs baseline).
5
6WHY THIS EXISTS
7---------------
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.
11
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.
17
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`:
24
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.
31
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.
36
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
44same file still fails.
45
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.
51
52USAGE
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
57
58Copyright (c) 2026 Brighton Sikarskie
59SPDX-License-Identifier: MIT
60"""
61
62from __future__ import annotations
63
64import argparse
65import sys
66import tempfile
67from collections import Counter
68from pathlib import Path
69
70sys.path.insert(0, str(Path(__file__).resolve().parent))
71
72from check_new_compound_has_mcdc import audit_tree, collect_tree_citations, stale_tree_citations
73
74REPO_ROOT = Path(__file__).resolve().parents[2]
75BASELINE_FILE = REPO_ROOT / ".github" / "mcdc-compound-baseline.txt"
76
77MAX_DETAIL_LINES = 10
78"""Cap on offending buckets echoed before the report truncates."""
79
80BASELINE_COLUMNS = 3
81"""Column count of one baseline row: file, function, count."""
82
83MIN_PRODUCTION_FILES = 400
84"""Refuse to ratchet a scan that saw implausibly few production files.
85
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.
94"""
95
96MIN_CITATIONS = 50
97"""...and refuse a scan whose citation index is implausibly small.
98
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
103both directions.
104"""
105
106
107def scan(root: Path) -> tuple[Counter, int, int]:
108 """Scan ``root`` and return ``(counts, production_file_count, citation_count)``.
109
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.
115 """
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))
121
122
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():
127 return counts
128 for raw in baseline_file.read_text(encoding="ascii").splitlines():
129 line = raw.strip()
130 if not line or line.startswith("#"):
131 continue
132 parts = line.split("\t")
133 if len(parts) != BASELINE_COLUMNS:
134 continue
135 counts[(parts[0], parts[1])] = int(parts[2])
136 return counts
137
138
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())
142 lines = [
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).",
146 "#",
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",
151 "# burned down; a",
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.",
157 "#",
158 f"# Total at this baseline: {total} uncovered compound decision(s)",
159 f"# across {len({p for p, _ in counts})} file(s).",
160 "#",
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.",
164 "#",
165 "# Regenerate after burning findings down:",
166 "# python3 scripts/checks/mcdc_compound_ratchet.py --update",
167 "#",
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.",
174 "#",
175 "# Closing this out means this file reaching zero rows and being",
176 "# DELETED -- never regenerated larger.",
177 "#",
178 "# file<TAB>function<TAB>count",
179 ]
180 for (path, function), n in sorted(counts.items()):
181 if n:
182 lines.append(f"{path}\t{function}\t{n}")
183 baseline_file.write_text("\n".join(lines) + "\n", encoding="ascii")
184
185
186def scope_reason(files: int, citations: int) -> str | None:
187 """Return a refusal reason when the scan's scope is not credible, else None.
188
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
192 written as one.
193 """
194 if files < MIN_PRODUCTION_FILES:
195 return (
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."
203 )
204 if citations < MIN_CITATIONS:
205 return (
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."
211 )
212 return None
213
214
215def report_stale_citations(stale: list[tuple[str, int, str, str]]) -> int:
216 """Reject citation tokens whose source path and function no longer resolve."""
217 if not stale:
218 return 0
219 unique = sorted({(source, function) for _test, _line, source, function in stale})
220 print(
221 f"MC/DC citation validation: {len(unique)} stale path@function key(s) "
222 f"across {len(stale)} occurrence(s).",
223 file=sys.stderr,
224 )
225 for source, function in unique[:MAX_DETAIL_LINES]:
226 origins = sorted(
227 f"{test}:{line}"
228 for test, line, cited_source, cited_function in stale
229 if (cited_source, cited_function) == (source, function)
230 )
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)
235 print(
236 "Retarget moved functions to their current source path, or remove a citation "
237 "whose decision no longer exists.",
238 file=sys.stderr,
239 )
240 return 1
241
242
243def report(current: Counter, baseline: Counter) -> int:
244 """Compare and print. Returns the process exit code."""
245 grown = []
246 for key, n in sorted(current.items()):
247 was = baseline.get(key, 0)
248 if n > was:
249 grown.append((key, was, n))
250
251 total_now = sum(current.values())
252 total_was = sum(baseline.values())
253
254 if grown:
255 print(file=sys.stderr)
256 print(
257 "MC/DC ratchet: NEW uncovered compound decisions above the baseline",
258 file=sys.stderr,
259 )
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)
266 print(
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"
272 "\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.",
275 file=sys.stderr,
276 )
277 return 1
278
279 print(
280 f"MC/DC ratchet: {total_now} uncovered compound decision(s), "
281 f"baseline {total_was} -- no growth."
282 )
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")
287 return 0
288
289
290# ---------------------------------------------------------------------------
291# Self-test
292# ---------------------------------------------------------------------------
293
294_ST_COVERED_C = """\
295ra8_err_t ra8_covered_fn(int c, int d)
296{
297 if (c && d) {
298 return k_ra8_ok;
299 }
300 return k_ra8_err;
301}
302"""
303
304_ST_UNCOVERED_C = """\
305ra8_err_t ra8_uncovered_fn(int a, int b)
306{
307 if (a || b) {
308 return k_ra8_ok;
309 }
310 return k_ra8_err;
311}
312"""
313
314_ST_APP_UNCOVERED_C = """\
315ra8_err_t ra8_app_uncovered_fn(int a, int b)
316{
317 if (a && b) {
318 return k_ra8_ok;
319 }
320 return k_ra8_err;
321}
322"""
323
324_ST_SOUP_C = """\
325int soup_fn(int e, int f)
326{
327 if (e && f) {
328 return 1;
329 }
330 return 0;
331}
332"""
333
334_ST_TEST_C = """\
335/**
336 * @test ra8_covered_fn_mcdc
337 *
338 * @par MC/DC:
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)
343 */
344void test_mcdc_ra8_covered_fn(void) {}
345"""
346
347# A second uncovered decision appended to the ALREADY-uncovered function: the
348# "new decision lands in a function the baseline already knows about" case,
349# which a whole-file-count ratchet would catch but only a count-based one
350# catches at all -- a delta scan keyed on new source lines would also fire, and
351# both must.
352_ST_UNCOVERED_C_GROWN = """\
353ra8_err_t ra8_uncovered_fn(int a, int b)
354{
355 if (a || b) {
356 return k_ra8_ok;
357 }
358 if (a && !b) {
359 return k_ra8_err;
360 }
361 return k_ra8_err;
362}
363"""
364
365
366def _st_write(root: Path, rel: str, body: str) -> None:
367 """Write ``body`` to ``rel`` under ``root``, creating parent dirs."""
368 dst = root / rel
369 dst.parent.mkdir(parents=True, exist_ok=True)
370 dst.write_text(body, encoding="ascii")
371
372
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)
381
382
383def _selftest_scan(tmp: Path) -> list[str]:
384 """Assert the whole-tree measurement counts the right decisions and no others."""
385 failures: list[str] = []
386 root = tmp / "tree"
387 _st_build_tree(root)
388 counts, files, cites = scan(root)
389
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: # noqa: PLR2004 -- fixture plants exactly 2 decisions
399 failures.append(f"scan() found {sum(counts.values())} decision(s), expected exactly 2")
400
401 # The measurement must SEE the growth it is meant to gate on.
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: # noqa: PLR2004 -- fixture count
405 failures.append("scan() did not see a second uncovered decision added to a known function")
406
407 # Scope scalars must be real, or the guards below have nothing to guard.
408 if files < 2 or cites < 1: # noqa: PLR2004 -- fixture plants 2 files, 1 citation
409 failures.append(
410 f"scan() reported an implausible scope: {files} file(s), {cites} citation(s)"
411 )
412 return failures
413
414
415def _selftest_ratchet(tmp: Path) -> list[str]:
416 """Assertions about the growth verdict and baseline round-tripping."""
417 failures: list[str] = []
418
419 base = Counter({("libs/f.c", "fn_a"): 1})
420 # FAILS when a known bucket grows ...
421 if report(Counter({("libs/f.c", "fn_a"): 2}), base) == 0:
422 failures.append("report() passed a bucket that GREW above the baseline")
423 # ... when a new function in a known file appears ...
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")
426 # ... and when a file the baseline never saw appears.
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")
429 # PASSES when unchanged or shrinking -- the ratchet must not be a cliff.
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")
434
435 # Round-trip only through a caller-supplied throwaway authority. A selftest
436 # must never rewrite the committed baseline, even briefly: interruption in
437 # the old save/restore window left a fixture baseline in the worktree.
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")
445
446 return failures
447
448
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")
458 return failures
459
460
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")
469 _st_write(
470 root,
471 "tests/test_citation.c",
472 _ST_TEST_C.replace("libs/covered.c@ra8_covered_fn", "libs/old.c@ra8_covered_fn"),
473 )
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")
477 _st_write(
478 root,
479 "tests/test_citation.c",
480 _ST_TEST_C.replace("ra8_covered_fn", "renamed_away"),
481 )
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")
485 return failures
486
487
488def selftest() -> int:
489 """Assert the measurement and the ratchet fire, in BOTH directions.
490
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.
495 """
496 with tempfile.TemporaryDirectory() as td:
497 tmp = Path(td)
498 failures = _selftest_scan(tmp) + _selftest_ratchet(tmp)
499 failures += _selftest_citation_resolution(tmp)
500 failures += _selftest_scope_guard()
501
502 if failures:
503 print("SELFTEST FAILED:", file=sys.stderr)
504 for problem in failures:
505 print(f" - {problem}", file=sys.stderr)
506 return 1
507 print(
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)."
511 )
512 return 0
513
514
515# ---------------------------------------------------------------------------
516# Main
517# ---------------------------------------------------------------------------
518
519
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)")
526 return 0
527
528
529def main() -> int:
530 """Ratchet uncovered compound decisions against the committed baseline.
531
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.
536
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.
539 """
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()
547
548 if args.selftest:
549 return selftest()
550 root = Path(args.root).resolve()
551 if args.list:
552 return _list_backlog(root)
553 if not (args.check or args.update):
554 parser.error("one of --check / --update / --list / --selftest is required")
555
556 current, files, citations = scan(root)
557 stale = stale_tree_citations(root)
558
559 # Refuse to ratchet a scan whose scope never got established, in EITHER
560 # direction, before comparing or writing anything.
561 broken = scope_reason(files, citations)
562 stale_rc = 0
563 if broken:
564 verb = "--update" if args.update else "--check"
565 print(f"refusing to {verb}: {broken}", file=sys.stderr)
566 else:
567 stale_rc = report_stale_citations(stale)
568 if broken or stale_rc != 0:
569 return 1
570
571 if args.update:
572 baseline = load_baseline()
573 # Seeding the very first baseline necessarily "grows" every bucket from
574 # nothing, so the no-growth rule applies only once a baseline exists.
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)]
577 if grew:
578 print(
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.",
581 file=sys.stderr,
582 )
583 for path, function in grew[:MAX_DETAIL_LINES]:
584 print(f" {path} {function}()", file=sys.stderr)
585 return 1
586 write_baseline(current)
587 print(
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)."
591 )
592 return 0
593
594 print(
595 f"MC/DC ratchet: scanned {files} production file(s), "
596 f"{citations} citation(s) in supported indexed test sources."
597 )
598 return report(current, load_baseline())
599
600
601if __name__ == "__main__":
602 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298