4r"""check_mcdc_block.py -- Require @par MC/DC: blocks on unit tests.
6Per CLAUDE.md "IEC 61508 SIL 3 / DO-178C Level B Qualification" and
7docs/MCDC.md, a test that exercises a compound boolean decision must declare
8its MC/DC vector pattern in a Doxygen ``@par MC/DC:`` block. Without that
9block a test can drive a compound decision and still prove nothing about
10MC/DC -- it looks like coverage, and the distinction is exactly what DO-178C
11Level B turns on. The repo convention is that EVERY unit test carries the
12block: a real vector pattern when the code under test has a compound
13decision, or an explicit "(no compound decisions in this test ...)"
14statement when it does not, so the absence of a block is never left
17This checker enforces the convention: every ``TEST(...)`` / ``TEST_F(...)``
18and every ``test_*(void)`` function must have a preceding Doxygen block
19containing ``@par MC/DC:``.
21Three selection modes, and NO fourth silent one:
23 * ``--all`` -- audit every ``tests/**/*.c`` in the tree. This is the mode
24 CI uses. It reads the working tree (``git ls-files`` when inside a repo,
25 a filesystem walk otherwise), so it is INDEPENDENT of the git index:
26 it reports the same finding count in a fresh ``actions/checkout`` (where
27 nothing is staged), under ``scripts/ci.sh`` (which stages ``git add
28 -A``), and on a developer's checkout. That index-independence is the
29 #325 fix -- see below.
31 * ``--staged`` -- audit the staged ``tests/**/*.c`` files. This is the mode
32 the local ``scripts/git/pre-commit`` hook uses: it gates exactly the test
33 files about to be committed.
35 * ``--range BASE..HEAD [--repo DIR]`` -- audit the test files changed in a
36 commit range. Available for a PR-delta gate; a range that does not
37 resolve is FATAL, not a clean scan of nothing.
39Invoked with NONE of these modes, the check FAILS LOUDLY (exit 2) rather
40than reporting a clean scan of zero files.
42That is the #325 defect this rewrite closes: the check used ``git diff
43--cached --name-only`` unconditionally, so in any CI checkout -- where
44nothing is staged -- it saw 0 files and exited 0, having audited nothing in
45any CI run, ever. Meanwhile ``scripts/ci.sh`` stages the whole snapshot
46(``git add -A``), so the SAME checker examined every test file locally and
47reported a real backlog. The two environments disagreed, silently, and the
48one that read as green was the one that checked nothing. A scan that
49examined zero files can never exit 0 silently now: the audited file count is
50always reported, and a scope that could not be established is a non-PASS.
53from __future__
import annotations
60from pathlib
import Path
62MAX_DISPLAYED_FINDINGS = 50
68TEST_FUNC_PATTERN = re.compile(
71 ^\s*TEST(?:_F)?\s*\(\s*([A-Za-z_]\w*)\s*,\s*([A-Za-z_]\w*)\s*\)
73 ^\s*(?:static\s+)?(?:void|int|UINT)\s+(test_[A-Za-z_]\w*)\s*\(\s*void\s*\)
76 re.VERBOSE | re.MULTILINE,
79DOXYGEN_BLOCK_END_RE = re.compile(
r"\*/\s*$")
80MCDC_TAG_RE = re.compile(
r"@par\s+MC/DC\s*:", re.IGNORECASE)
88def _git(*args: str, repo: str =
".") -> str:
89 """Run ``git -C repo <args...>`` and return stdout (text)."""
90 return subprocess.run(
91 [
"git",
"-C", repo, *args],
98def _git_ok(*args: str, repo: str =
".") -> tuple[bool, str]:
99 """Run ``git -C repo <args...>``; return (success, stdout)."""
100 proc = subprocess.run(
101 [
"git",
"-C", repo, *args],
106 return proc.returncode == 0, proc.stdout
109def _is_test_c(path: str) -> bool:
110 """Whether ``path`` is a first-party unit-test C source file."""
111 return path.startswith(
"tests/")
and path.endswith(
".c")
114def all_test_files(repo: str =
".") -> list[Path]:
115 """Every tracked ``tests/**/*.c`` file, index-independent.
117 Prefers ``git ls-files`` (the tracked set) when ``repo`` is a git
118 checkout, and falls back to a filesystem walk otherwise. Neither path
119 consults the index, so the finding count is the same whether or not
120 anything is staged -- the whole point of the #325 fix.
122 ok, out = _git_ok(
"ls-files",
"--",
"tests", repo=repo)
124 if ok
and out.strip():
125 return [root / p
for p
in out.splitlines()
if _is_test_c(p)
and (root / p).is_file()]
126 tests_dir = root /
"tests"
127 if not tests_dir.is_dir():
129 return sorted(tests_dir.rglob(
"*.c"))
132def staged_test_files(repo: str =
".") -> list[Path]:
133 """Staged (index) ``tests/**/*.c`` files, excluding deletions.
135 Scoped to the index because this backs a pre-commit hook: it polices what
136 is about to be committed, not the whole tree.
138 out = _git(
"diff",
"--cached",
"--name-only",
"--diff-filter=ACMR", repo=repo)
140 return [root / p
for p
in out.splitlines()
if _is_test_c(p)
and (root / p).is_file()]
143def resolve_range(repo: str, spec: str) -> tuple[str, str] |
None:
144 """Resolve ``BASE..HEAD`` / ``A...B`` / a single rev into a ``(base, head)`` pair.
146 Returns ``None`` when either endpoint does not resolve in ``repo`` -- the
147 caller turns that into a FATAL exit, never a clean scan of nothing.
151 base_spec, head_spec = spec.split(
"...", 1)
153 base_spec, head_spec = spec.split(
"..", 1)
155 base_spec, head_spec = f
"{spec}~1", spec
156 base_spec = base_spec.strip()
or "HEAD~1"
157 head_spec = head_spec.strip()
or "HEAD"
158 ok_b, base = _git_ok(
"rev-parse",
"--verify",
"--quiet", f
"{base_spec}^{{commit}}", repo=repo)
159 ok_h, head = _git_ok(
"rev-parse",
"--verify",
"--quiet", f
"{head_spec}^{{commit}}", repo=repo)
160 if not (ok_b
and ok_h
and base.strip()
and head.strip()):
162 return base.strip(), head.strip()
165def range_test_files(repo: str, base: str, head: str) -> list[Path]:
166 """The ``tests/**/*.c`` files added/modified between ``base`` and ``head``."""
167 out = _git(
"diff",
"--name-only",
"--diff-filter=ACMR", base, head, repo=repo)
169 return [root / p
for p
in out.splitlines()
if _is_test_c(p)
and (root / p).is_file()]
177def preceding_doxygen_block(lines: list[str], func_lineno: int) -> str:
178 """Text of the Doxygen block immediately above a 1-based function line.
180 Blank lines between the block and the function are tolerated, so ordinary
181 spacing does not detach a block from what it documents. Returns "" when no
182 block precedes the function.
184 end = func_lineno - 2
185 while end >= 0
and not lines[end].strip():
189 if not DOXYGEN_BLOCK_END_RE.search(lines[end]):
192 while start >= 0
and "/**" not in lines[start]:
196 return "\n".join(lines[start : end + 1])
199def scan_text(path: str, text: str) -> list[tuple[str, int, str]]:
200 """Every test function in ``text`` lacking a preceding ``@par MC/DC:`` block.
202 Returns ``(path, 1-based-line, func_name)`` tuples.
204 lines = text.splitlines()
205 findings: list[tuple[str, int, str]] = []
206 for match
in TEST_FUNC_PATTERN.finditer(text):
207 func_name = next((g
for g
in match.groups()
if g),
"<unknown>")
208 func_line = text[: match.start()].count(
"\n") + 1
209 block = preceding_doxygen_block(lines, func_line)
210 if not block
or not MCDC_TAG_RE.search(block):
211 findings.append((path, func_line, func_name))
215def scan_paths(paths: list[Path], *, rel_to: str =
".") -> list[tuple[str, int, str]]:
216 """Scan every path, reporting finding paths relative to ``rel_to``."""
218 findings: list[tuple[str, int, str]] = []
221 display = str(path.relative_to(root))
224 text = path.read_text(encoding=
"utf-8", errors=
"ignore")
225 findings.extend(scan_text(display, text))
234def report(files: list[Path], findings: list[tuple[str, int, str]], scope: str) -> int:
235 """Print the verdict for ``findings`` over ``files`` and return the exit code.
237 The audited file count is ALWAYS printed, so a scan of zero files can
238 never read as a silent clean PASS (the #325 defect).
241 print(
"[FAIL] check_mcdc_block.py: unit tests missing the required")
242 print(
" @par MC/DC: block.")
244 print(
" Per CLAUDE.md and docs/MCDC.md, every unit test must")
245 print(
" declare its MC/DC vector pattern in a Doxygen")
246 print(
" `@par MC/DC:` block -- the real N+1 vectors when the")
247 print(
" code under test has a compound `&&` / `||` decision,")
248 print(
' or an explicit "(no compound decisions in this test)"')
249 print(
" statement when it does not.")
251 print(f
" Missing block ({len(findings)} findings; {len(files)} files scanned):")
252 for name, lineno, func
in findings[:MAX_DISPLAYED_FINDINGS]:
253 print(f
" {name}:{lineno}: {func}")
254 if len(findings) > MAX_DISPLAYED_FINDINGS:
255 print(f
" ... and {len(findings) - MAX_DISPLAYED_FINDINGS} more")
258 print(f
"check_mcdc_block.py: 0 findings ({scope}; {len(files)} files scanned).")
270 * Decision: `if (a == 0 || b == 0)` (2 conditions, OR; N+1 = 3 vectors).
271 * - V1: a=1,b=1 -> false (control)
272 * - V2: a=0,b=1 -> true (varies a)
273 * - V3: a=1,b=0 -> true (varies b)
275static void test_has_block(void)
277 TEST_ASSERT(guard(0, 1) || guard(1, 0));
282/** @test absent -- this test documents no vector pattern. */
283static void test_missing_block(void)
285 TEST_ASSERT(guard(0, 1) || guard(1, 0));
290def selftest() -> int:
291 """Assert the detector fires on a test with no block and stays quiet with one.
293 Exercises the REAL ``scan_paths`` code path against throwaway files, so a
294 detector that quietly stopped matching cannot pass as clean. Both
295 directions are asserted: it must flag ``test_missing_block`` and stay
296 silent on ``test_has_block``.
298 failures: list[str] = []
299 with tempfile.TemporaryDirectory()
as td:
301 (root /
"tests").mkdir()
302 good = root /
"tests" /
"test_good.c"
303 bad = root /
"tests" /
"test_bad.c"
304 good.write_text(_ST_WITH_BLOCK, encoding=
"utf-8")
305 bad.write_text(_ST_NO_BLOCK, encoding=
"utf-8")
307 good_findings = scan_paths([good], rel_to=str(root))
309 failures.append(f
" fired on a test that HAS a @par MC/DC: block: {good_findings}")
311 bad_findings = scan_paths([bad], rel_to=str(root))
312 bad_names = {f[2]
for f
in bad_findings}
313 if bad_names != {
"test_missing_block"}:
314 failures.append(f
" expected exactly the block-less test, got {sorted(bad_names)}")
316 both = scan_paths([good, bad], rel_to=str(root))
317 if {f[2]
for f
in both} != {
"test_missing_block"}:
318 got = sorted(f[2]
for f
in both)
319 failures.append(f
" mixed scan should flag only test_missing_block, got {got}")
322 print(
"check_mcdc_block.py: --selftest FAILED", file=sys.stderr)
323 print(
"\n".join(failures), file=sys.stderr)
326 "check_mcdc_block.py: --selftest OK "
327 "(fires on a test with no @par MC/DC: block; silent on one that has it)."
337def _run_range(spec: str, repo: str) -> int:
338 """Resolve and audit a commit range, failing loudly on an unusable scope."""
339 rng = resolve_range(repo, spec)
342 f
"check_mcdc_block.py: FATAL -- range '{spec}' does not resolve in\n"
343 f
" repository '{repo}'. Refusing to report a clean scan of zero\n"
344 " files: an unresolvable range means the gate is looking at\n"
345 " nothing (the #325 defect), not that the tree is clean.",
350 files = range_test_files(repo, base, head)
351 findings = scan_paths(files, rel_to=repo)
352 return report(files, findings, f
"range {base[:12]}..{head[:12]}")
355def main(argv: list[str]) -> int:
356 """Dispatch to the selected mode, or fail loudly when none was given.
358 Exactly one of ``--selftest`` / ``--all`` / ``--staged`` / ``--range``
359 selects the scope. With none of them the check exits 2 rather than
360 silently auditing the empty staged set -- the #325 defect that left it
361 toothless in every CI run.
363 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
367 help=
"audit every tests/**/*.c in the tree (the CI mode; index-independent)",
372 help=
"audit the staged tests/**/*.c files (the pre-commit-hook mode)",
377 metavar=
"BASE..HEAD",
378 help=
"audit test files changed in this commit range (a PR-delta mode)",
384 help=
"repository the scope is resolved and read against (default '.')",
389 help=
"prove the detector fires on a test with no block and not otherwise",
391 args = ap.parse_args(argv[1:])
396 files = all_test_files(args.repo)
397 findings = scan_paths(files, rel_to=args.repo)
398 return report(files, findings,
"whole tree")
399 if args.commit_range
is not None:
400 return _run_range(args.commit_range, args.repo)
402 files = staged_test_files(args.repo)
403 findings = scan_paths(files, rel_to=args.repo)
404 return report(files, findings,
"the staged index")
407 "check_mcdc_block.py: FATAL -- no scan scope selected.\n"
408 " Pass --all (CI, whole tree), --staged (the pre-commit hook),\n"
409 " or --range <base..head> [--repo DIR]. This check used to\n"
410 " default to `git diff --cached`, so in any CI checkout -- where\n"
411 " nothing is staged -- it saw 0 files and exited 0, auditing\n"
412 " nothing in any CI run (issue #325). A scope that cannot be\n"
413 " established is now a non-PASS, never a clean scan of zero files.",
419if __name__ ==
"__main__":
420 sys.exit(
main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.