ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_mcdc_block.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4r"""check_mcdc_block.py -- Require @par MC/DC: blocks on unit tests.
5
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
15ambiguous.
16
17This checker enforces the convention: every ``TEST(...)`` / ``TEST_F(...)``
18and every ``test_*(void)`` function must have a preceding Doxygen block
19containing ``@par MC/DC:``.
20
21Three selection modes, and NO fourth silent one:
22
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.
30
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.
34
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.
38
39Invoked with NONE of these modes, the check FAILS LOUDLY (exit 2) rather
40than reporting a clean scan of zero files.
41
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.
51"""
52
53from __future__ import annotations
54
55import argparse
56import re
57import subprocess
58import sys
59import tempfile
60from pathlib import Path
61
62MAX_DISPLAYED_FINDINGS = 50 # Max number of findings to print before summarizing the rest.
63
64# A test function: a Google-Test-style ``TEST(suite, name)`` / ``TEST_F`` or a
65# bare ``(static) void|int|UINT test_name(void)`` definition. Matches the
66# forms the repo's hand-rolled and vendored (ThreadX ``UINT``) test harnesses
67# actually use.
68TEST_FUNC_PATTERN = re.compile(
69 r"""
70 (?:
71 ^\s*TEST(?:_F)?\s*\‍(\s*([A-Za-z_]\w*)\s*,\s*([A-Za-z_]\w*)\s*\‍)
72 |
73 ^\s*(?:static\s+)?(?:void|int|UINT)\s+(test_[A-Za-z_]\w*)\s*\‍(\s*void\s*\‍)
74 )
75 """,
76 re.VERBOSE | re.MULTILINE,
77)
78
79DOXYGEN_BLOCK_END_RE = re.compile(r"\*/\s*$")
80MCDC_TAG_RE = re.compile(r"@par\s+MC/DC\s*:", re.IGNORECASE)
81
82
83# ---------------------------------------------------------------------------
84# Git helpers
85# ---------------------------------------------------------------------------
86
87
88def _git(*args: str, repo: str = ".") -> str:
89 """Run ``git -C repo <args...>`` and return stdout (text)."""
90 return subprocess.run( # noqa: S603 # trusted: fixed git argv
91 ["git", "-C", repo, *args], # noqa: S607 # trusted: fixed git argv
92 check=True,
93 capture_output=True,
94 text=True,
95 ).stdout
96
97
98def _git_ok(*args: str, repo: str = ".") -> tuple[bool, str]:
99 """Run ``git -C repo <args...>``; return (success, stdout)."""
100 proc = subprocess.run( # noqa: S603 # trusted: fixed git argv
101 ["git", "-C", repo, *args], # noqa: S607 # trusted: fixed git argv
102 check=False,
103 capture_output=True,
104 text=True,
105 )
106 return proc.returncode == 0, proc.stdout
107
108
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")
112
113
114def all_test_files(repo: str = ".") -> list[Path]:
115 """Every tracked ``tests/**/*.c`` file, index-independent.
116
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.
121 """
122 ok, out = _git_ok("ls-files", "--", "tests", repo=repo)
123 root = Path(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():
128 return []
129 return sorted(tests_dir.rglob("*.c"))
130
131
132def staged_test_files(repo: str = ".") -> list[Path]:
133 """Staged (index) ``tests/**/*.c`` files, excluding deletions.
134
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.
137 """
138 out = _git("diff", "--cached", "--name-only", "--diff-filter=ACMR", repo=repo)
139 root = Path(repo)
140 return [root / p for p in out.splitlines() if _is_test_c(p) and (root / p).is_file()]
141
142
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.
145
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.
148 """
149 spec = spec.strip()
150 if "..." in spec:
151 base_spec, head_spec = spec.split("...", 1)
152 elif ".." in spec:
153 base_spec, head_spec = spec.split("..", 1)
154 else:
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()):
161 return None
162 return base.strip(), head.strip()
163
164
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)
168 root = Path(repo)
169 return [root / p for p in out.splitlines() if _is_test_c(p) and (root / p).is_file()]
170
171
172# ---------------------------------------------------------------------------
173# Detection
174# ---------------------------------------------------------------------------
175
176
177def preceding_doxygen_block(lines: list[str], func_lineno: int) -> str:
178 """Text of the Doxygen block immediately above a 1-based function line.
179
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.
183 """
184 end = func_lineno - 2 # zero-based index of the line just above the function
185 while end >= 0 and not lines[end].strip():
186 end -= 1
187 if end < 0:
188 return ""
189 if not DOXYGEN_BLOCK_END_RE.search(lines[end]):
190 return ""
191 start = end
192 while start >= 0 and "/**" not in lines[start]:
193 start -= 1
194 if start < 0:
195 return ""
196 return "\n".join(lines[start : end + 1])
197
198
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.
201
202 Returns ``(path, 1-based-line, func_name)`` tuples.
203 """
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))
212 return findings
213
214
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``."""
217 root = Path(rel_to)
218 findings: list[tuple[str, int, str]] = []
219 for path in paths:
220 try:
221 display = str(path.relative_to(root))
222 except ValueError:
223 display = str(path)
224 text = path.read_text(encoding="utf-8", errors="ignore")
225 findings.extend(scan_text(display, text))
226 return findings
227
228
229# ---------------------------------------------------------------------------
230# Reporting
231# ---------------------------------------------------------------------------
232
233
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.
236
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).
239 """
240 if findings:
241 print("[FAIL] check_mcdc_block.py: unit tests missing the required")
242 print(" @par MC/DC: block.")
243 print()
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.")
250 print()
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")
256 return 1
257
258 print(f"check_mcdc_block.py: 0 findings ({scope}; {len(files)} files scanned).")
259 return 0
260
261
262# ---------------------------------------------------------------------------
263# Selftest
264# ---------------------------------------------------------------------------
265
266_ST_WITH_BLOCK = """\
267/**
268 * @test present
269 * @par MC/DC:
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)
274 */
275static void test_has_block(void)
276{
277 TEST_ASSERT(guard(0, 1) || guard(1, 0));
278}
279"""
280
281_ST_NO_BLOCK = """\
282/** @test absent -- this test documents no vector pattern. */
283static void test_missing_block(void)
284{
285 TEST_ASSERT(guard(0, 1) || guard(1, 0));
286}
287"""
288
289
290def selftest() -> int:
291 """Assert the detector fires on a test with no block and stays quiet with one.
292
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``.
297 """
298 failures: list[str] = []
299 with tempfile.TemporaryDirectory() as td:
300 root = Path(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")
306
307 good_findings = scan_paths([good], rel_to=str(root))
308 if good_findings:
309 failures.append(f" fired on a test that HAS a @par MC/DC: block: {good_findings}")
310
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)}")
315
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}")
320
321 if failures:
322 print("check_mcdc_block.py: --selftest FAILED", file=sys.stderr)
323 print("\n".join(failures), file=sys.stderr)
324 return 1
325 print(
326 "check_mcdc_block.py: --selftest OK "
327 "(fires on a test with no @par MC/DC: block; silent on one that has it)."
328 )
329 return 0
330
331
332# ---------------------------------------------------------------------------
333# Main
334# ---------------------------------------------------------------------------
335
336
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)
340 if rng is None:
341 print(
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.",
346 file=sys.stderr,
347 )
348 return 2
349 base, head = rng
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]}")
353
354
355def main(argv: list[str]) -> int:
356 """Dispatch to the selected mode, or fail loudly when none was given.
357
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.
362 """
363 ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
364 ap.add_argument(
365 "--all",
366 action="store_true",
367 help="audit every tests/**/*.c in the tree (the CI mode; index-independent)",
368 )
369 ap.add_argument(
370 "--staged",
371 action="store_true",
372 help="audit the staged tests/**/*.c files (the pre-commit-hook mode)",
373 )
374 ap.add_argument(
375 "--range",
376 dest="commit_range",
377 metavar="BASE..HEAD",
378 help="audit test files changed in this commit range (a PR-delta mode)",
379 )
380 ap.add_argument(
381 "--repo",
382 default=".",
383 metavar="DIR",
384 help="repository the scope is resolved and read against (default '.')",
385 )
386 ap.add_argument(
387 "--selftest",
388 action="store_true",
389 help="prove the detector fires on a test with no block and not otherwise",
390 )
391 args = ap.parse_args(argv[1:])
392
393 if args.selftest:
394 return selftest()
395 if args.all:
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)
401 if args.staged:
402 files = staged_test_files(args.repo)
403 findings = scan_paths(files, rel_to=args.repo)
404 return report(files, findings, "the staged index")
405
406 print(
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.",
414 file=sys.stderr,
415 )
416 return 2
417
418
419if __name__ == "__main__":
420 sys.exit(main(sys.argv))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298