4"""Reject a NEW compound decision that lands without an MC/DC test.
6A new compound boolean decision (``&&`` / ``||``) in production code must
7arrive with an accompanying MC/DC test vector set. Per CLAUDE.md
8"IEC 61508 SIL 3 / DO-178C Level B Qualification" and docs/MCDC.md, every
9compound boolean decision in production code under ``libs/``,
10``apps/shared_libs/``, ``port/``, and firmware applications must have a
11matching MC/DC test vector set in an indexed test translation unit. The test
12declares its vector pattern in a Doxygen ``@par MC/DC:`` block that cites the
13decision as ``path@function`` -- the source path and the *enclosing function*
14of the decision. Citing by function (not line number) means unrelated edits
15that shift lines never invalidate a citation.
17This is a *static* check: it never builds or runs the test suite. It compares
18structural fingerprints of logical ``&&`` / ``||`` expressions in each
19changed production component. Formatting, file splits, and stable-symbol
20function moves therefore do not turn existing decisions into "new" ones;
21renamed decision owners need a citation at their new anchor. Adding an operator
22or changing predicate structure also creates a new fingerprint. For each new
23fingerprint, it searches supported indexed test sources under ``tests/`` and
24``apps/`` for a ``@par MC/DC:`` block citing ``path@that_function``.
26Identifiers are alpha-normalized so a systematic local rename is cosmetic.
27Consequently this is not a predicate-equivalence proof: a replacement with
28the same operator/comparison topology can compare equal. The whole-tree debt
29ratchet and executed MC/DC gate remain responsible for detecting coverage loss
30after such substitutions. That boundary is explicit and self-tested.
32Two selection modes, and NO third silent one:
34 * ``--range BASE..HEAD [--repo DIR]`` -- audit the files changed in that
35 commit range, run against DIR (default ``.``). This is the mode CI uses;
36 ``scripts/ci.sh``'s ``ci_commit_range`` / ``ci_history_repo`` resolve the
37 range and the history repository the same way every other range-aware
38 gate does. A range that does not resolve in the repository is FATAL, not
39 a clean scan of nothing.
41 * ``--staged`` -- audit the git index against HEAD. This is the mode the
42 local ``scripts/git/pre-commit`` hook uses: it gates exactly what is
43 about to be committed.
45Invoked with NEITHER mode, the check FAILS LOUDLY (exit 2) rather than
46reporting a clean scan of zero files. That is the #355 defect this rewrite
47closes: the check used ``git diff --cached`` unconditionally, so in any CI
48checkout -- where nothing is staged -- it saw 0 files and exited 0, having
49audited nothing in any CI run, ever. A scan that examined zero files can
50never exit 0 silently: the audited file count is always reported, and a
51scope that could not be established is a non-PASS.
53Besides the two CLI modes there is a git-free WHOLE-TREE scan, ``audit_tree()``,
54which walks the checked-out production sources and reports every uncovered
55compound decision with its enclosing function. It is the measurement
56``scripts/checks/mcdc_compound_ratchet.py`` ratchets against the committed
57``.github/mcdc-compound-baseline.txt``, and it is what makes CI enforcement
58possible while a large backlog is still outstanding: the delta modes above fail
59the moment an existing uncovered decision line is merely *reformatted*, which
60with a backlog this size is a cliff rather than a ratchet. ``audit_tree()``
61counts, so the debt is frozen and can only shrink. The detection primitives are
62shared, so there is exactly one definition of "this decision lacks MC/DC
65The check intentionally does NOT cover:
66 * Either canonical ``third_party`` root -- SOUP exempted per docs/MCDC.md.
67 * ``tests/`` -- only production code.
68 * ``examples/`` and host tools -- outside this structural citation ratchet;
69 the executed per-file MC/DC floor covers represented files from both.
70 * Single-condition ``if (x)`` -- MC/DC only applies to compound decisions.
73 0 the audited (non-empty or legitimately empty) scope adds no uncovered
75 1 one or more NEW decisions lack a matching MC/DC test.
76 2 no usable scan scope (no mode given, or an unresolvable range) -- the
77 scope could not be established, so no verdict is trustworthy.
80from __future__
import annotations
86from pathlib
import Path
88sys.path.insert(0, str(Path(__file__).resolve().parent))
90from lint_targets
import firmware_app_dirs, is_build_output_path
91from mcdc_compound_delta
import (
93 NO_ENCLOSING_FUNCTION,
96 new_decision_occurrences,
117PROD_PREFIXES: tuple[str, ...] = (
121 *(f
"{d}/" for d
in firmware_app_dirs()),
125TEST_SOURCE_SUFFIXES: tuple[str, ...] = (
".c",
".cpp")
128MAX_DISPLAYED_FINDINGS = 50
130SNIPPET_TRUNCATE_LEN = 77
133RENAME_ROW_FIELD_COUNT = 3
134CHANGE_ROW_FIELD_COUNT = 2
139EMPTY_TREE =
"4b825dc642cb6eb9a060e54bf8d69288fbee4904"
142EXCLUDED_SUBSTRINGS: tuple[str, ...] = (
"/third_party/",
"/tests/",
"/test/")
152SYMBOL_CITATION_RE = re.compile(
154 +
"|".join(re.escape(prefix.rstrip(
"/"))
for prefix
in PROD_PREFIXES)
155 +
r")/[A-Za-z0-9_./-]+\.c)@(?P<sym>[A-Za-z_]\w*)"
161MCDC_BLOCK_RE = re.compile(
162 r"@par\s+MC/DC\s*:.*?(?=(?:\*/|@par\s+\w|\n\s*\*\s*\n))",
163 re.IGNORECASE | re.DOTALL,
172def _git(*args: str) -> str:
173 """Run ``git <args...>`` and return stdout, raising on a non-zero exit."""
174 return subprocess.run(
182def _git_ok(*args: str) -> bool:
183 """Whether ``git <args...>`` exits 0 (used for object-existence probes)."""
195def _blob_at(repo: str, rev: str, path: str) -> str:
196 """Content of ``path`` at ``rev`` in ``repo``, or "" when it is absent.
198 The empty string is the meaningful case for the base revision: it makes
199 every decision in a file that did not exist there count as new.
202 return _git(
"-C", repo,
"show", f
"{rev}:{path}")
203 except subprocess.CalledProcessError:
207def _path_included(path: str, *, prefixes: tuple[str, ...]) -> bool:
208 """Whether ``path`` is a production ``.c`` file the gate should audit."""
209 if not path.endswith(
".c"):
211 if not any(path.startswith(pre)
for pre
in prefixes):
213 return not (is_build_output_path(path)
or any(sub
in path
for sub
in EXCLUDED_SUBSTRINGS))
216def _is_test_source_name(name: str) -> bool:
217 """Whether ``name`` is a supported MC/DC test translation unit.
219 Both orderings of the convention count. ``test_<module>.c`` is the common
220 one, but the tree also carries ``<module>_test.c`` and companion units
221 under ``tests/support/``, and a citation written in one of those used to be
222 invisible: the glob was ``tests/test_*.{c,cpp}`` only, so every decision
223 those suites cover read as UNCOVERED. That is the same scope collapse that
224 once hid 81 decisions in the two ``.cpp`` EPUB suites -- a checker whose
225 scope quietly stops matching reports FEWER findings, which reads as an
228 return (name.startswith(
"test_")
or name.endswith(_TEST_NAME_SUFFIXES))
and name.endswith(
234_TEST_NAME_SUFFIXES = tuple(f
"_test{suffix}" for suffix
in TEST_SOURCE_SUFFIXES)
237def _working_test_sources(root_or_dir: Path) -> list[Path]:
238 """Return every supported test translation unit under ``root_or_dir``."""
239 sources: list[Path] = []
240 if root_or_dir.name
in (
"tests",
"test"):
241 dirs_to_check = [root_or_dir]
244 d
for dir_name
in (
"tests",
"apps")
if (d := root_or_dir / dir_name).is_dir()
246 if not dirs_to_check
and root_or_dir.is_dir():
247 dirs_to_check = [root_or_dir]
248 for d
in dirs_to_check:
249 for path
in d.rglob(
"*"):
250 if path.is_file()
and _is_test_source_name(path.name):
252 rel = path.relative_to(root_or_dir).as_posix()
253 if not is_build_output_path(rel):
257 return sorted(sources)
265def staged_files() -> list[str]:
266 """Production ``.c`` paths staged for commit (added/copied/modified/renamed).
268 Deletions are excluded: a removed file has no decision left to cover.
270 out = _git(
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR")
271 return [p
for p
in out.splitlines()
if _path_included(p, prefixes=PROD_PREFIXES)]
274def _parse_change_rows(out: str) -> list[tuple[str |
None, str |
None]]:
275 """Parse name-status rows into ``(old_path, new_path)`` pairs."""
276 pairs: list[tuple[str |
None, str |
None]] = []
277 for row
in out.splitlines():
278 parts = row.split(
"\t")
279 status = parts[0][:1]
if parts
else ""
280 if status ==
"R" and len(parts) == RENAME_ROW_FIELD_COUNT:
281 pairs.append((parts[1], parts[2]))
282 elif status ==
"C" and len(parts) == RENAME_ROW_FIELD_COUNT:
283 pairs.append((
None, parts[2]))
284 elif len(parts) == CHANGE_ROW_FIELD_COUNT
and status ==
"A":
285 pairs.append((
None, parts[1]))
286 elif len(parts) == CHANGE_ROW_FIELD_COUNT
and status ==
"D":
287 pairs.append((parts[1],
None))
288 elif len(parts) == CHANGE_ROW_FIELD_COUNT
and status ==
"M":
289 pairs.append((parts[1], parts[1]))
293def _production_change_pairs(out: str) -> list[tuple[str |
None, str |
None]]:
294 """Changed path pairs with at least one production endpoint."""
295 pairs: list[tuple[str |
None, str |
None]] = []
296 for old_path, new_path
in _parse_change_rows(out):
297 old_prod = old_path
is not None and _path_included(old_path, prefixes=PROD_PREFIXES)
298 new_prod = new_path
is not None and _path_included(new_path, prefixes=PROD_PREFIXES)
299 if old_prod
or new_prod:
300 pairs.append((old_path
if old_prod
else None, new_path
if new_prod
else None))
304def staged_change_pairs() -> list[tuple[str | None, str | None]]:
305 """All staged production changes, including deletions used as move ancestry."""
306 out = _git(
"diff",
"--cached",
"--name-status",
"-M40%",
"--diff-filter=ACMRD")
307 return _production_change_pairs(out)
310def staged_blob(path: str) -> str:
311 """Staged (index) content of ``path``, or "" when it is not staged.
313 Reads the INDEX rather than the working tree, so unstaged edits sitting
314 alongside a staged change cannot make the gate judge content not about to
318 return _git(
"show", f
":0:{path}")
319 except subprocess.CalledProcessError:
323def head_blob(path: str) -> str:
324 """HEAD content of ``path``, or "" when the file is newly added."""
326 return _git(
"show", f
"HEAD:{path}")
327 except subprocess.CalledProcessError:
331def staged_rename_map() -> dict[str, str]:
332 """Map each staged rename's new path to its pre-rename old path.
334 A ``git mv`` plus interior edits would otherwise make every decision in the
335 moved file look brand new. A 40% similarity bar still pairs a rename that
336 also renamed many interior symbols; mispairing only ever suppresses a "new"
337 finding, so the generous threshold is safe.
339 out = _git(
"diff",
"--cached",
"--name-status",
"-M40%",
"--diff-filter=R")
340 return _parse_rename_rows(out)
343def collect_staged_citations() -> list[tuple[str, str]]:
344 """Every citation in test sources present in the git index.
346 Staged mode judges exactly the prospective commit. Untracked tests and
347 unstaged citation edits must not change its verdict, while a staged test
348 added with the decision must count immediately.
350 cites: list[tuple[str, str]] = []
351 listing = _git(
"ls-files",
"--cached",
"--",
"tests",
"apps")
352 for path
in listing.splitlines():
353 name = path.rsplit(
"/", 1)[-1]
354 if _is_test_source_name(name)
and not is_build_output_path(path):
355 cites.extend(_extract_citations(staged_blob(path)))
364def _parse_rename_rows(out: str) -> dict[str, str]:
365 """Parse ``git diff --name-status`` rename rows into new -> old paths."""
366 mapping: dict[str, str] = {}
367 for row
in out.splitlines():
368 parts = row.split(
"\t")
369 if len(parts) == RENAME_ROW_FIELD_COUNT
and parts[0].startswith(
"R"):
370 _status, old, new = parts
375def resolve_range(repo: str, spec: str) -> tuple[str, str] |
None:
376 """Resolve a range spec to a ``(base, head)`` pair, or None when unusable.
378 Accepts the shapes ``ci_commit_range`` emits: ``BASE..HEAD``, ``A...B``
379 (symmetric, resolved via merge-base), and a bare ``HEAD`` (base becomes its
380 first parent, or the empty tree at a root commit). Returns None -- the
381 caller's cue to fail loudly -- when the spec is empty or names an endpoint
382 the repository does not contain, which is the failure mode of pointing the
383 gate at a snapshot whose object store lacks those commits.
389 left, _, right = spec.partition(
"...")
390 head = right
or "HEAD"
391 left = left
or "HEAD"
393 base = _git(
"-C", repo,
"merge-base", left, head).strip()
394 except subprocess.CalledProcessError:
397 left, _, right = spec.partition(
"..")
399 head = right
or "HEAD"
403 _git(
"-C", repo,
"rev-parse",
"--verify",
"--quiet", f
"{head}~1").strip()
404 if _git_ok(
"-C", repo,
"rev-parse",
"--verify",
"--quiet", f
"{head}~1")
407 if not _git_ok(
"-C", repo,
"rev-parse",
"--verify",
"--quiet", f
"{head}^{{commit}}"):
411 if base != EMPTY_TREE
and not _git_ok(
"-C", repo,
"cat-file",
"-e", f
"{base}^{{commit}}"):
416def changed_prod_files(repo: str, base: str, head: str) -> list[str]:
417 """Production ``.c`` files changed between ``base`` and ``head`` in ``repo``."""
418 out = _git(
"-C", repo,
"diff",
"--name-only",
"--diff-filter=ACMR", base, head)
419 return [p
for p
in out.splitlines()
if _path_included(p, prefixes=PROD_PREFIXES)]
422def range_change_pairs(repo: str, base: str, head: str) -> list[tuple[str |
None, str |
None]]:
423 """All production changes in a range, including move-source deletions."""
424 out = _git(
"-C", repo,
"diff",
"--name-status",
"-M40%",
"--diff-filter=ACMRD", base, head)
425 return _production_change_pairs(out)
428def range_rename_map(repo: str, base: str, head: str) -> dict[str, str]:
429 """Map each rename between ``base`` and ``head`` to its pre-rename path."""
430 out = _git(
"-C", repo,
"diff",
"--name-status",
"-M40%",
"--diff-filter=R", base, head)
431 return _parse_rename_rows(out)
434def collect_range_citations(repo: str, head: str) -> list[tuple[str, str]]:
435 """Every citation in a supported indexed test source at ``head``.
437 Reads the tests as committed at the audited revision (not the working
438 tree), so the citation set matches the code under audit even when ``repo``
439 is not the current checkout -- exactly the case under the CI snapshot,
440 where the gate runs from a clean snapshot but resolves the range against
441 the real history repository.
443 cites: list[tuple[str, str]] = []
445 listing = _git(
"-C", repo,
"ls-tree",
"-r",
"--name-only", head,
"--",
"tests",
"apps")
446 except subprocess.CalledProcessError:
448 for path
in listing.splitlines():
449 name = path.rsplit(
"/", 1)[-1]
450 if _is_test_source_name(name):
451 cites.extend(_extract_citations(_blob_at(repo, head, path)))
460def compound_decision_lines(text: str) -> set[tuple[int, str]]:
461 """Every line holding a compound operator outside comments and strings.
463 Returns a set of ``(line_no, normalized_line)`` with 1-based line numbers.
464 The normalized text -- whitespace-collapsed with ``NULL`` folded to
465 ``nullptr`` -- is carried so the same decision compares equal across a
466 cosmetic reformat or the C23 ``nullptr`` migration.
468 Comment, literal, and preprocessor text is removed by
469 ``lexical_code_view()`` -- the same whole-source view the delta modes
470 read, so the ratchet measurement and the delta gate cannot disagree about
471 what a decision is. The line-local scrub this replaced could not see that
472 an operator sat on an interior line of a multi-line Doxygen block, nor
473 that a `#define` continued onto the next line, so it counted prose and
474 conditional-compilation logic as MC/DC debt (issue #790).
476 found: set[tuple[int, str]] = set()
477 for idx, raw
in enumerate(lexical_code_view(text).splitlines(), start=1):
478 if COMPOUND_OP_RE.search(raw):
479 normalized = re.sub(
r"\s+",
" ", raw.strip())
480 normalized = re.sub(
r"\bNULL\b",
"nullptr", normalized)
481 found.add((idx, normalized))
485def new_decisions(new_text: str, base_text: str) -> list[tuple[int, str]]:
486 """Compound decisions present in ``new_text`` but not in ``base_text``.
488 A decision counts as "not new" when the SAME normalized scrubbed line
489 appears anywhere in ``base_text`` (regardless of line number), so pure
490 insertions above an existing decision do not trip the gate.
492 base_norms = {norm
for _, norm
in compound_decision_lines(base_text)}
493 new = compound_decision_lines(new_text)
495 [(ln, norm)
for (ln, norm)
in new
if norm
not in base_norms],
505def _extract_citations(text: str) -> list[tuple[str, str]]:
506 """Every ``(path, function)`` citation inside a ``@par MC/DC:`` block."""
507 cites: list[tuple[str, str]] = []
508 for block
in MCDC_BLOCK_RE.findall(text):
509 cites.extend((m.group(
"path"), m.group(
"sym"))
for m
in SYMBOL_CITATION_RE.finditer(block))
513def has_matching_citation(
517 symbol_cites: list[tuple[str, str]],
519 """Whether some test cites the enclosing function of this decision.
521 Matches at FUNCTION granularity: a citation names ``path@function``, so
522 adding a second decision to an already-cited function satisfies the gate.
523 That is deliberate -- line-exact citations would churn on every edit above
524 the decision -- but it proves a vector set exists for the function, not
525 that the new decision itself is individually covered.
527 fn = enclosing_function(src_text, src_line)
530 return any(path == src_path
and sym == fn
for path, sym
in symbol_cites)
540 new_occurrences: list[tuple[str, int, str, str]],
541 symbol_cites: list[tuple[str, str]],
542) -> list[tuple[str, int, str]]:
543 """One finding per function that owns a new uncited structural decision."""
544 findings: list[tuple[str, int, str]] = []
545 file_set = set(files)
546 cite_set = set(symbol_cites)
547 reported: set[tuple[str, str]] = set()
548 for path, line_no, snippet, symbol
in new_occurrences:
549 owner = (path, symbol)
550 if path
not in file_set
or owner
in reported:
553 if owner
not in cite_set:
554 findings.append((path, line_no, snippet))
558def audit_range(repo: str, base: str, head: str) -> tuple[list[str], list[tuple[str, int, str]]]:
559 """Audit files changed between ``base`` and ``head`` in ``repo``.
561 Returns ``(changed_files, findings)`` so callers can both report the file
562 count (a scan of zero files must never be silent) and act on the findings.
564 files = changed_prod_files(repo, base, head)
565 symbol_cites = collect_range_citations(repo, head)
566 new_occurrences = new_decision_occurrences(
567 range_change_pairs(repo, base, head),
568 lambda p: _blob_at(repo, head, p),
569 lambda p: _blob_at(repo, base, p),
571 findings = audit_files(files, new_occurrences, symbol_cites)
572 return files, findings
575def audit_staged() -> tuple[list[str], list[tuple[str, int, str]]]:
576 """Audit the staged index against HEAD (the local pre-commit-hook mode)."""
577 files = staged_files()
578 symbol_cites = collect_staged_citations()
579 new_occurrences = new_decision_occurrences(staged_change_pairs(), staged_blob, head_blob)
580 findings = audit_files(files, new_occurrences, symbol_cites)
581 return files, findings
589def production_files(root: Path) -> list[str]:
590 """Every production ``.c`` file under ``root``, as sorted repo-relative paths.
592 Walks the checked-out tree rather than git, so the scan works identically in
593 a developer checkout, a CI ``git archive`` snapshot, and a throwaway
594 fixture. Selection is delegated to the same predicate the git-based modes
595 use, so all three modes agree on what "production code" means.
597 found: list[str] = []
598 for prefix
in PROD_PREFIXES:
599 base = root / prefix.rstrip(
"/")
600 if not base.is_dir():
602 for path
in base.rglob(
"*.c"):
603 rel = path.relative_to(root).as_posix()
604 if _path_included(rel, prefixes=PROD_PREFIXES):
609def _read_text(path: Path) -> str:
610 """Contents of ``path``, or "" when it cannot be read.
612 An unreadable file yields no decisions and no citations rather than
613 aborting the scan; the scope guards in the ratchet are what notice when
614 that has happened at a scale that matters.
617 return path.read_text(encoding=
"utf-8", errors=
"ignore")
622def collect_tree_citations(root: Path) -> list[tuple[str, str]]:
623 """Every citation in ``root``'s test sources."""
624 cites: list[tuple[str, str]] = []
625 for tf
in _working_test_sources(root):
626 cites.extend(_extract_citations(_read_text(tf)))
630def collect_tree_citation_occurrences(root: Path) -> list[tuple[str, int, str, str]]:
631 """Return ``(test path, line, source path, function)`` for every citation."""
632 occurrences: list[tuple[str, int, str, str]] = []
633 for test_file
in _working_test_sources(root):
634 text = _read_text(test_file)
636 test_rel = test_file.relative_to(root).as_posix()
638 test_rel = test_file.as_posix()
639 for block_match
in MCDC_BLOCK_RE.finditer(text):
640 block = block_match.group(0)
641 for cite_match
in SYMBOL_CITATION_RE.finditer(block):
642 offset = block_match.start() + cite_match.start()
643 line = text.count(
"\n", 0, offset) + 1
648 cite_match.group(
"path"),
649 cite_match.group(
"sym"),
655def _defined_functions(text: str) -> set[str]:
656 """Return function definitions in one clang-formatted C translation unit."""
657 functions: set[str] = set()
658 for line, source_line
in enumerate(text.splitlines(), start=1):
659 if source_line !=
"{":
661 function = enclosing_function(text, line)
662 if function
is not None:
663 functions.add(function)
667def stale_tree_citations(root: Path) -> list[tuple[str, int, str, str]]:
668 """Return citations whose ``path@function`` resolves to no live definition."""
669 symbol_index: dict[str, set[str]] = {}
670 for source_path
in production_files(root):
671 symbol_index[source_path] = _defined_functions(_read_text(root / source_path))
674 for occurrence
in collect_tree_citation_occurrences(root)
675 if occurrence[3]
not in symbol_index.get(occurrence[2], set())
679def audit_tree(root: Path) -> tuple[list[str], list[tuple[str, str, int, str]]]:
680 """Every uncovered compound decision in the tree at ``root``.
682 Returns ``(production_files, findings)`` where each finding is
683 ``(path, enclosing_function, line, snippet)``. Unlike the delta modes this
684 treats every decision in the tree as in scope, which is what a ratchet needs
685 to measure: a count that is invariant under reformatting and that rises the
686 moment the tree gains an uncovered decision.
688 The file list is returned alongside the findings so a caller can refuse to
689 trust a scan that examined implausibly little -- an empty or partial scan
690 reports FEWER findings, which reads as an improvement.
692 files = production_files(root)
693 cites = set(collect_tree_citations(root))
694 findings: list[tuple[str, str, int, str]] = []
696 text = _read_text(root / rel)
697 for line_no, normalized
in sorted(compound_decision_lines(text)):
698 fn = enclosing_function(text, line_no)
699 if fn
is not None and (rel, fn)
in cites:
701 bucket = fn
if fn
is not None else NO_ENCLOSING_FUNCTION
702 findings.append((rel, bucket, line_no, normalized))
703 return files, findings
711def _report(files: list[str], findings: list[tuple[str, int, str]], scope: str) -> int:
712 """Print the audited file count then the verdict; return the exit code.
714 The count is printed unconditionally: a scan that examined zero files can
715 never pass silently, so even a legitimately empty diff says so out loud.
717 print(f
"check_new_compound_has_mcdc.py: audited {len(files)} production file(s) in {scope}.")
719 print(
"check_new_compound_has_mcdc.py: no production file changed -- nothing to audit.")
722 print(
"check_new_compound_has_mcdc.py: 0 findings.")
726 print(
"[FAIL] check_new_compound_has_mcdc.py: new compound boolean")
727 print(
" decisions landed without an accompanying MC/DC test")
728 print(
" vector set in an indexed test translation unit.")
730 print(
" Per docs/MCDC.md, every `&&` / `||` decision under")
731 print(
" libs/, apps/shared_libs/, port/, and the discovered")
732 print(
" firmware product directories must")
733 print(
" have a co-located or repository test function whose")
734 print(
" `@par MC/DC:` block cites the decision as")
735 print(
" `path@function` (the enclosing function of the")
736 print(
" decision -- a drift-proof anchor, no line numbers).")
738 print(
" Offending decisions (path:line is informational):")
739 for path, line_no, normalized
in findings[:MAX_DISPLAYED_FINDINGS]:
742 if len(normalized) <= SNIPPET_MAX_LEN
743 else normalized[:SNIPPET_TRUNCATE_LEN] +
"..."
745 print(f
" {path}:{line_no}: {snippet}")
746 if len(findings) > MAX_DISPLAYED_FINDINGS:
747 print(f
" ... and {len(findings) - MAX_DISPLAYED_FINDINGS} more")
749 print(
" Fix: add a `test_mcdc_<decision>` function in the")
750 print(
" matching indexed test translation unit with N+1 vectors and")
751 print(
" a `@par MC/DC:` block citing `path@function`, then")
752 print(
" re-run. See docs/MCDC.md for the worked example.")
761def _run_range(spec: str, repo: str) -> int:
762 """Resolve and audit a commit range, failing loudly on an unusable scope."""
763 rng = resolve_range(repo, spec)
766 f
"check_new_compound_has_mcdc.py: FATAL -- range '{spec}' does not\n"
767 f
" resolve in repository '{repo}'. Refusing to report a clean\n"
768 " scan of zero files: an unresolvable range means the gate is\n"
769 " looking at nothing (the #355 defect), not that the tree is\n"
770 " clean. Under the CI suite the range is resolved against the\n"
771 " history repository (RA8_CI_HISTORY_REPO); pass --repo to it.",
776 files, findings = audit_range(repo, base, head)
777 return _report(files, findings, f
"range {base[:12]}..{head[:12]}")
780def main(argv: list[str]) -> int:
781 """Dispatch to the selected mode, or fail loudly when none was given.
783 Exactly one of ``--selftest`` / ``--range`` / ``--staged`` selects the
784 scope. With none of them the check exits 2 rather than silently auditing
785 the empty staged set -- the #355 defect that left it toothless in every CI
788 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
792 metavar=
"BASE..HEAD",
793 help=
"audit files changed in this commit range (the CI mode)",
799 help=
"repository the range is resolved and read against (default '.')",
804 help=
"audit the git index against HEAD (the pre-commit-hook mode)",
809 help=
"prove the detector fires on a new uncovered decision and not otherwise",
811 args = ap.parse_args(argv[1:])
816 from check_new_compound_has_mcdc_selftest
import (
820 return run_selftest()
821 if args.commit_range
is not None:
822 return _run_range(args.commit_range, args.repo)
824 files, findings = audit_staged()
825 return _report(files, findings,
"the staged index")
828 "check_new_compound_has_mcdc.py: FATAL -- no scan scope selected.\n"
829 " Pass --range <base..head> [--repo DIR] (CI) or --staged (the\n"
830 " pre-commit hook). This check used to default to `git diff\n"
831 " --cached`, so in any CI checkout -- where nothing is staged --\n"
832 " it saw 0 files and exited 0, auditing nothing in any CI run\n"
833 " (issue #355). A scope that cannot be established is now a\n"
834 " non-PASS, never a clean scan of zero files.",
840if __name__ ==
"__main__":
841 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.