ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_new_compound_has_mcdc.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"""Reject a NEW compound decision that lands without an MC/DC test.
5
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.
16
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``.
25
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.
31
32Two selection modes, and NO third silent one:
33
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.
40
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.
44
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.
52
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
63vectors".
64
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.
71
72Exit codes:
73 0 the audited (non-empty or legitimately empty) scope adds no uncovered
74 compound decision.
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.
78"""
79
80from __future__ import annotations
81
82import argparse
83import re
84import subprocess
85import sys
86from pathlib import Path
87
88sys.path.insert(0, str(Path(__file__).resolve().parent))
89
90from lint_targets import firmware_app_dirs, is_build_output_path
91from mcdc_compound_delta import (
92 COMPOUND_OP_RE,
93 NO_ENCLOSING_FUNCTION,
94 enclosing_function,
95 lexical_code_view,
96 new_decision_occurrences,
97)
98
99# ---------------------------------------------------------------------------
100# Configuration
101# ---------------------------------------------------------------------------
102
103# Production directories that are subject to the MC/DC gate.
104#
105# "Production" here means code that runs on the target: the platform libraries
106# (the Ring 5 secure substrate among them, as libs/ra8_secure_app), the RTOS
107# ports, reusable application-domain production modules, and the FIRMWARE
108# products. Shared modules are explicit because they compile into product
109# images without owning a linker script. The remaining apps/ product tier
110# mixes host programs (the mdl CLI) and firmware, so that half appears through
111# a derivation. Deriving the firmware products from
112# ``lint_targets.firmware_app_dirs()`` keeps the scope's MEANING fixed while
113# the tree moves under it: when the e-reader composition moved into the
114# products tier, a literal tuple would have dropped six firmware translation
115# units out of this gate and reported the resulting smaller count as a
116# burn-down.
117PROD_PREFIXES: tuple[str, ...] = (
118 "libs/",
119 "port/",
120 "apps/shared_libs/",
121 *(f"{d}/" for d in firmware_app_dirs()),
122)
123
124# Test translation units allowed to carry executable MC/DC vector sets.
125TEST_SOURCE_SUFFIXES: tuple[str, ...] = (".c", ".cpp")
126
127# Display limits.
128MAX_DISPLAYED_FINDINGS = 50 # Max findings to print before summarizing the rest.
129SNIPPET_MAX_LEN = 80 # Max characters of a decision snippet before truncation.
130SNIPPET_TRUNCATE_LEN = 77 # Length of truncated snippet body (leaves room for "...").
131
132# Number of tab-separated fields in a `git diff --name-status -M` rename row.
133RENAME_ROW_FIELD_COUNT = 3 # <status>\t<old>\t<new>
134CHANGE_ROW_FIELD_COUNT = 2 # <status>\t<path>
135
136# The canonical empty-tree object. Used as the "base" when a range names a
137# root/new-branch head with no parent, so every decision in every changed file
138# is treated as new. `git` always resolves it, in every repository.
139EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
140
141# Excluded subtrees (SOUP, tests, generated, etc.).
142EXCLUDED_SUBSTRINGS: tuple[str, ...] = ("/third_party/", "/tests/", "/test/")
143
144# Regex matching one citation token inside a `@par MC/DC:` block. The only
145# accepted form is `path@function_name`: it pins the decision to its enclosing
146# function, so unrelated edits that shift lines never invalidate it, and --
147# having no `:line` -- it is not flagged by check_line_citations.py.
148# The path alternation is built from PROD_PREFIXES rather than spelled out: a
149# citation naming a file this gate scans must parse, and the two drifting apart
150# is silent -- the citation simply stops matching and its decision reads as
151# uncovered.
152SYMBOL_CITATION_RE = re.compile(
153 r"(?P<path>(?:"
154 + "|".join(re.escape(prefix.rstrip("/")) for prefix in PROD_PREFIXES)
155 + r")/[A-Za-z0-9_./-]+\.c)@(?P<sym>[A-Za-z_]\w*)"
156)
157
158# Regex isolating each `@par MC/DC:` block in a test file. The block starts at
159# `@par MC/DC:` and runs until the next `@par`, the next `*/`, or the next
160# blank Doxygen line (` *` followed by EOL).
161MCDC_BLOCK_RE = re.compile(
162 r"@par\s+MC/DC\s*:.*?(?=(?:\*/|@par\s+\w|\n\s*\*\s*\n))",
163 re.IGNORECASE | re.DOTALL,
164)
165
166
167# ---------------------------------------------------------------------------
168# Git helpers
169# ---------------------------------------------------------------------------
170
171
172def _git(*args: str) -> str:
173 """Run ``git <args...>`` and return stdout, raising on a non-zero exit."""
174 return subprocess.run( # noqa: S603 # trusted: fixed git argv
175 ["git", *args], # noqa: S607 # trusted: fixed git argv
176 check=True,
177 capture_output=True,
178 text=True,
179 ).stdout
180
181
182def _git_ok(*args: str) -> bool:
183 """Whether ``git <args...>`` exits 0 (used for object-existence probes)."""
184 return (
185 subprocess.run( # noqa: S603 # trusted: fixed git argv
186 ["git", *args], # noqa: S607 # trusted: fixed git argv
187 check=False,
188 capture_output=True,
189 text=True,
190 ).returncode
191 == 0
192 )
193
194
195def _blob_at(repo: str, rev: str, path: str) -> str:
196 """Content of ``path`` at ``rev`` in ``repo``, or "" when it is absent.
197
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.
200 """
201 try:
202 return _git("-C", repo, "show", f"{rev}:{path}")
203 except subprocess.CalledProcessError:
204 return ""
205
206
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"):
210 return False
211 if not any(path.startswith(pre) for pre in prefixes):
212 return False
213 return not (is_build_output_path(path) or any(sub in path for sub in EXCLUDED_SUBSTRINGS))
214
215
216def _is_test_source_name(name: str) -> bool:
217 """Whether ``name`` is a supported MC/DC test translation unit.
218
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
226 improvement.
227 """
228 return (name.startswith("test_") or name.endswith(_TEST_NAME_SUFFIXES)) and name.endswith(
229 TEST_SOURCE_SUFFIXES
230 )
231
232
233#: Trailing forms of the same convention, checked before the extension.
234_TEST_NAME_SUFFIXES = tuple(f"_test{suffix}" for suffix in TEST_SOURCE_SUFFIXES)
235
236
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]
242 else:
243 dirs_to_check = [
244 d for dir_name in ("tests", "apps") if (d := root_or_dir / dir_name).is_dir()
245 ]
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):
251 try:
252 rel = path.relative_to(root_or_dir).as_posix()
253 if not is_build_output_path(rel):
254 sources.append(path)
255 except ValueError:
256 sources.append(path)
257 return sorted(sources)
258
259
260# ---------------------------------------------------------------------------
261# Staged-mode selection (the local pre-commit hook)
262# ---------------------------------------------------------------------------
263
264
265def staged_files() -> list[str]:
266 """Production ``.c`` paths staged for commit (added/copied/modified/renamed).
267
268 Deletions are excluded: a removed file has no decision left to cover.
269 """
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)]
272
273
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]))
290 return pairs
291
292
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))
301 return pairs
302
303
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)
308
309
310def staged_blob(path: str) -> str:
311 """Staged (index) content of ``path``, or "" when it is not staged.
312
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
315 be committed.
316 """
317 try:
318 return _git("show", f":0:{path}")
319 except subprocess.CalledProcessError:
320 return ""
321
322
323def head_blob(path: str) -> str:
324 """HEAD content of ``path``, or "" when the file is newly added."""
325 try:
326 return _git("show", f"HEAD:{path}")
327 except subprocess.CalledProcessError:
328 return ""
329
330
331def staged_rename_map() -> dict[str, str]:
332 """Map each staged rename's new path to its pre-rename old path.
333
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.
338 """
339 out = _git("diff", "--cached", "--name-status", "-M40%", "--diff-filter=R")
340 return _parse_rename_rows(out)
341
342
343def collect_staged_citations() -> list[tuple[str, str]]:
344 """Every citation in test sources present in the git index.
345
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.
349 """
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)))
356 return cites
357
358
359# ---------------------------------------------------------------------------
360# Range-mode selection (CI)
361# ---------------------------------------------------------------------------
362
363
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
371 mapping[new] = old
372 return mapping
373
374
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.
377
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.
384 """
385 spec = spec.strip()
386 if not spec:
387 return None
388 if "..." in spec:
389 left, _, right = spec.partition("...")
390 head = right or "HEAD"
391 left = left or "HEAD"
392 try:
393 base = _git("-C", repo, "merge-base", left, head).strip()
394 except subprocess.CalledProcessError:
395 return None
396 elif ".." in spec:
397 left, _, right = spec.partition("..")
398 base = left
399 head = right or "HEAD"
400 else:
401 head = spec
402 base = (
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")
405 else EMPTY_TREE
406 )
407 if not _git_ok("-C", repo, "rev-parse", "--verify", "--quiet", f"{head}^{{commit}}"):
408 return None
409 if not base:
410 base = EMPTY_TREE
411 if base != EMPTY_TREE and not _git_ok("-C", repo, "cat-file", "-e", f"{base}^{{commit}}"):
412 return None
413 return (base, head)
414
415
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)]
420
421
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)
426
427
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)
432
433
434def collect_range_citations(repo: str, head: str) -> list[tuple[str, str]]:
435 """Every citation in a supported indexed test source at ``head``.
436
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.
442 """
443 cites: list[tuple[str, str]] = []
444 try:
445 listing = _git("-C", repo, "ls-tree", "-r", "--name-only", head, "--", "tests", "apps")
446 except subprocess.CalledProcessError:
447 return cites
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)))
452 return cites
453
454
455# ---------------------------------------------------------------------------
456# Decision detection
457# ---------------------------------------------------------------------------
458
459
460def compound_decision_lines(text: str) -> set[tuple[int, str]]:
461 """Every line holding a compound operator outside comments and strings.
462
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.
467
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).
475 """
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))
482 return found
483
484
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``.
487
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.
491 """
492 base_norms = {norm for _, norm in compound_decision_lines(base_text)}
493 new = compound_decision_lines(new_text)
494 return sorted(
495 [(ln, norm) for (ln, norm) in new if norm not in base_norms],
496 key=lambda t: t[0],
497 )
498
499
500# ---------------------------------------------------------------------------
501# Test-side citation index
502# ---------------------------------------------------------------------------
503
504
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))
510 return cites
511
512
513def has_matching_citation(
514 src_path: str,
515 src_line: int,
516 src_text: str,
517 symbol_cites: list[tuple[str, str]],
518) -> bool:
519 """Whether some test cites the enclosing function of this decision.
520
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.
526 """
527 fn = enclosing_function(src_text, src_line)
528 if fn is None:
529 return False
530 return any(path == src_path and sym == fn for path, sym in symbol_cites)
531
532
533# ---------------------------------------------------------------------------
534# Core audit
535# ---------------------------------------------------------------------------
536
537
538def audit_files(
539 files: list[str],
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:
551 continue
552 reported.add(owner)
553 if owner not in cite_set:
554 findings.append((path, line_no, snippet))
555 return findings
556
557
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``.
560
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.
563 """
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),
570 )
571 findings = audit_files(files, new_occurrences, symbol_cites)
572 return files, findings
573
574
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
582
583
584# ---------------------------------------------------------------------------
585# Whole-tree scan (the ratchet's measurement)
586# ---------------------------------------------------------------------------
587
588
589def production_files(root: Path) -> list[str]:
590 """Every production ``.c`` file under ``root``, as sorted repo-relative paths.
591
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.
596 """
597 found: list[str] = []
598 for prefix in PROD_PREFIXES:
599 base = root / prefix.rstrip("/")
600 if not base.is_dir():
601 continue
602 for path in base.rglob("*.c"):
603 rel = path.relative_to(root).as_posix()
604 if _path_included(rel, prefixes=PROD_PREFIXES):
605 found.append(rel)
606 return sorted(found)
607
608
609def _read_text(path: Path) -> str:
610 """Contents of ``path``, or "" when it cannot be read.
611
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.
615 """
616 try:
617 return path.read_text(encoding="utf-8", errors="ignore")
618 except OSError:
619 return ""
620
621
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)))
627 return cites
628
629
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)
635 try:
636 test_rel = test_file.relative_to(root).as_posix()
637 except ValueError:
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
644 occurrences.append(
645 (
646 test_rel,
647 line,
648 cite_match.group("path"),
649 cite_match.group("sym"),
650 )
651 )
652 return occurrences
653
654
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 != "{":
660 continue
661 function = enclosing_function(text, line)
662 if function is not None:
663 functions.add(function)
664 return functions
665
666
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))
672 return [
673 occurrence
674 for occurrence in collect_tree_citation_occurrences(root)
675 if occurrence[3] not in symbol_index.get(occurrence[2], set())
676 ]
677
678
679def audit_tree(root: Path) -> tuple[list[str], list[tuple[str, str, int, str]]]:
680 """Every uncovered compound decision in the tree at ``root``.
681
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.
687
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.
691 """
692 files = production_files(root)
693 cites = set(collect_tree_citations(root))
694 findings: list[tuple[str, str, int, str]] = []
695 for rel in files:
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:
700 continue
701 bucket = fn if fn is not None else NO_ENCLOSING_FUNCTION
702 findings.append((rel, bucket, line_no, normalized))
703 return files, findings
704
705
706# ---------------------------------------------------------------------------
707# Reporting
708# ---------------------------------------------------------------------------
709
710
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.
713
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.
716 """
717 print(f"check_new_compound_has_mcdc.py: audited {len(files)} production file(s) in {scope}.")
718 if not files:
719 print("check_new_compound_has_mcdc.py: no production file changed -- nothing to audit.")
720 return 0
721 if not findings:
722 print("check_new_compound_has_mcdc.py: 0 findings.")
723 return 0
724
725 print()
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.")
729 print()
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).")
737 print()
738 print(" Offending decisions (path:line is informational):")
739 for path, line_no, normalized in findings[:MAX_DISPLAYED_FINDINGS]:
740 snippet = (
741 normalized
742 if len(normalized) <= SNIPPET_MAX_LEN
743 else normalized[:SNIPPET_TRUNCATE_LEN] + "..."
744 )
745 print(f" {path}:{line_no}: {snippet}")
746 if len(findings) > MAX_DISPLAYED_FINDINGS:
747 print(f" ... and {len(findings) - MAX_DISPLAYED_FINDINGS} more")
748 print()
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.")
753 return 1
754
755
756# ---------------------------------------------------------------------------
757# Main
758# ---------------------------------------------------------------------------
759
760
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)
764 if rng is None:
765 print(
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.",
772 file=sys.stderr,
773 )
774 return 2
775 base, head = rng
776 files, findings = audit_range(repo, base, head)
777 return _report(files, findings, f"range {base[:12]}..{head[:12]}")
778
779
780def main(argv: list[str]) -> int:
781 """Dispatch to the selected mode, or fail loudly when none was given.
782
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
786 run.
787 """
788 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
789 ap.add_argument(
790 "--range",
791 dest="commit_range",
792 metavar="BASE..HEAD",
793 help="audit files changed in this commit range (the CI mode)",
794 )
795 ap.add_argument(
796 "--repo",
797 default=".",
798 metavar="DIR",
799 help="repository the range is resolved and read against (default '.')",
800 )
801 ap.add_argument(
802 "--staged",
803 action="store_true",
804 help="audit the git index against HEAD (the pre-commit-hook mode)",
805 )
806 ap.add_argument(
807 "--selftest",
808 action="store_true",
809 help="prove the detector fires on a new uncovered decision and not otherwise",
810 )
811 args = ap.parse_args(argv[1:])
812
813 if args.selftest:
814 # Deferred import: check_new_compound_has_mcdc_selftest imports FROM
815 # this module, so importing it at module load time would cycle.
816 from check_new_compound_has_mcdc_selftest import ( # noqa: PLC0415 -- avoids import cycle
817 run_selftest,
818 )
819
820 return run_selftest()
821 if args.commit_range is not None:
822 return _run_range(args.commit_range, args.repo)
823 if args.staged:
824 files, findings = audit_staged()
825 return _report(files, findings, "the staged index")
826
827 print(
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.",
835 file=sys.stderr,
836 )
837 return 2
838
839
840if __name__ == "__main__":
841 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298