ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
misra_ratchet.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# SPDX-License-Identifier: MIT
3# Copyright (c) 2026 Brighton Sikarskie
4"""misra_ratchet.py -- MISRA-C 2012 ratchet gate (compare vs committed baseline).
5
6`scripts/checks/misra_check_inner.sh` runs cppcheck + the bundled
7misra.py addon and writes one finding per line to `build/misra/results.txt`.
8Until now that audit was advisory-only: no CI job consumed it, so the
9finding count could only grow (issue #240). This script turns the audit
10into a one-way ratchet against a committed baseline:
11
12* NEW findings (any per-file-per-rule count above the baseline, or any
13 file/rule pair absent from the baseline) FAIL the gate.
14* Shrinkage (findings burned down) PASSES with a notice to re-baseline via
15 `just quality::local::misra_baseline`, which locks the progress in so the
16 debt can never quietly grow back.
17
18Baseline normalization -- per-file-per-rule COUNTS, not raw finding lines:
19raw misra.py findings carry line numbers, which churn on every unrelated
20edit above them, and message text, which embeds identifiers. Fingerprinting
21the violating source line churns whenever that line is touched for any
22reason. A `(file, rule) -> count` map is invariant under both kinds of
23drift, still trips on any new violation of a rule in a file (the bucket
24count rises), keeps the committed baseline small and diffable, and is fully
25deterministic. The accepted coarseness: within one bucket, adding one
26violation while simultaneously fixing another nets zero and passes -- fine
27for a burn-down ratchet whose end state is an empty baseline.
28
29The baseline records the cppcheck version that generated it. cppcheck
30majors disagree on rule coverage (2.13 has no C23 parse; newer versions
31change false-positive sets), so the gate WARNS loudly on a version mismatch
32-- regenerate the baseline on the CI-pinned toolchain (the self-hosted
33runner / dev box cppcheck), never on a drifted local install.
34
35Usage:
36 python3 scripts/checks/misra_ratchet.py --check # gate (default)
37 python3 scripts/checks/misra_ratchet.py --update # rewrite the baseline
38 python3 scripts/checks/misra_ratchet.py --selftest # both-direction regression test
39
40Both modes read `build/misra/results.txt`; run the audit first:
41 bash scripts/checks/misra_check_inner.sh
42
43Copyright (c) 2026 Brighton Sikarskie
44SPDX-License-Identifier: MIT
45"""
46
47from __future__ import annotations
48
49import argparse
50import subprocess
51import sys
52import tempfile
53from collections import Counter
54from pathlib import Path
55
56REPO_ROOT = Path(__file__).resolve().parents[2]
57RESULTS_TSV = REPO_ROOT / "build" / "misra" / "results.txt"
58BASELINE_FILE = REPO_ROOT / ".github" / "misra-baseline.txt"
59
60MAX_DETAIL_LINES = 10
61"""Cap on raw finding lines echoed per offending (file, rule) bucket."""
62
63RESULTS_COLUMNS = 5
64"""Column count of one results.txt row: rule, severity, file, line, message."""
65
66BASELINE_COLUMNS = 3
67"""Column count of one baseline row: file, rule, count."""
68
69
70MIGRATION_PROVENANCE_HEADER = (
71 "# 2026-08-23 migration-scope audit (#786/#790):",
72 "# The prior baseline was last refreshed at 6917906371. Exact Cppcheck 2.13.0",
73 "# producer proof: 6fec41c7142 had 23,912 findings; 6bb5779471b had 22,097.",
74 "# Normalizing 1,978 renames back to 6917906371 attributes the +1,121 net change:",
75 "# +1,500 pre-existing findings in 115 repo-root tests moved under apps/; +36",
76 "# findings in new alphabet_soup code already reviewed under active deviations",
77 "# D-001 (Rule 15.5: 35) and D-010 (Rule 11.5: 1); and -415 findings burned down",
78 "# in comparable pre-existing (file, rule) buckets. Zero comparable old",
79 "# (file, rule) buckets grew. Full ownership and compensating controls:",
80 "# docs/qualification/MISRA_DEVIATIONS.md.",
81)
82"""Audited migration provenance that every regenerated baseline must retain."""
83
84
85def cppcheck_version() -> str:
86 """Return the `cppcheck --version` string, or a placeholder when absent."""
87 try:
88 out = subprocess.run(
89 ["cppcheck", "--version"], # noqa: S607 # trusted: fixed cppcheck argv
90 capture_output=True,
91 text=True,
92 check=True,
93 timeout=30,
94 )
95 except (OSError, subprocess.SubprocessError):
96 return "unknown"
97 return out.stdout.strip().splitlines()[0] if out.stdout.strip() else "unknown"
98
99
100def load_results(path: Path) -> tuple[Counter[tuple[str, str]], dict[tuple[str, str], list[str]]]:
101 r"""Parse the misra_check.sh TSV into per-(file, rule) counts + raw lines.
102
103 Each row is `rule \\t severity \\t file \\t line \\t message`. Rows that
104 do not split into five columns are ignored (defensive: the awk parser in
105 misra_check.sh already guarantees the shape).
106 """
107 counts: Counter[tuple[str, str]] = Counter()
108 details: dict[tuple[str, str], list[str]] = {}
109 for raw in path.read_text(encoding="utf-8").splitlines():
110 cols = raw.split("\t")
111 if len(cols) != RESULTS_COLUMNS:
112 continue
113 rule, _severity, fname, line, message = cols
114 key = (fname, rule)
115 counts[key] += 1
116 details.setdefault(key, []).append(f"{fname}:{line}: {message} [{rule}]")
117 return counts, details
118
119
120def load_baseline(path: Path) -> tuple[Counter[tuple[str, str]], str]:
121 """Parse the committed baseline into counts + the recorded cppcheck version."""
122 counts: Counter[tuple[str, str]] = Counter()
123 version = "unknown"
124 for raw in path.read_text(encoding="utf-8").splitlines():
125 if raw.startswith("# cppcheck:"):
126 version = raw.split(":", 1)[1].strip()
127 continue
128 if not raw or raw.startswith("#"):
129 continue
130 cols = raw.split("\t")
131 if len(cols) != BASELINE_COLUMNS:
132 print(f"misra_ratchet.py: ERROR -- malformed baseline row: {raw!r}")
133 sys.exit(1)
134 fname, rule, count = cols
135 counts[(fname, rule)] = int(count)
136 return counts, version
137
138
139def write_baseline(path: Path, counts: Counter[tuple[str, str]]) -> None:
140 """Serialize `counts` (sorted by file, then rule) with a provenance header."""
141 total = sum(counts.values())
142 lines = [
143 "# MISRA-C 2012 ratchet baseline -- per-file-per-rule finding counts.",
144 "# Consumed by scripts/checks/misra_ratchet.py --check (CI job: misra).",
145 "# Regenerate ON THE CI-PINNED CPPCHECK (self-hosted runner / dev box):",
146 "# just quality::local::misra_baseline",
147 "# and commit the result. Never regenerate to absorb NEW findings --",
148 "# fix those at the root or record a deviation in",
149 "# docs/qualification/MISRA_DEVIATIONS.md first.",
150 *MIGRATION_PROVENANCE_HEADER,
151 f"# cppcheck: {cppcheck_version()}",
152 f"# total findings: {total}",
153 "# columns: file<TAB>rule<TAB>count",
154 ]
155 lines += [f"{fname}\t{rule}\t{count}" for (fname, rule), count in sorted(counts.items())]
156 path.write_text("\n".join(lines) + "\n", encoding="utf-8")
157
158
159def report_regressions(
160 regressions: list[tuple[str, str, int, int]],
161 details: dict[tuple[str, str], list[str]],
162) -> None:
163 """Print each offending (file, rule) bucket with its raw finding lines."""
164 print(f"misra_ratchet.py: FAIL -- {len(regressions)} (file, rule) bucket(s) grew:")
165 for fname, rule, base, cur in regressions:
166 print(f" {fname} {rule} baseline={base} -> current={cur}")
167 for detail in details.get((fname, rule), [])[:MAX_DETAIL_LINES]:
168 print(f" {detail}")
169 print(
170 "Fix the new violation(s) at the root. If a finding is a cppcheck\n"
171 "false positive, suppress it in .cppcheck-suppressions with a\n"
172 "justification comment; if it is a formally accepted deviation,\n"
173 "record it in docs/qualification/MISRA_DEVIATIONS.md. Only after\n"
174 "one of those dispositions may the baseline be regenerated\n"
175 "(`just quality::local::misra_baseline`, on the CI-pinned cppcheck)."
176 )
177
178
179def find_regressions(
180 counts: Counter[tuple[str, str]], baseline: Counter[tuple[str, str]]
181) -> list[tuple[str, str, int, int]]:
182 """Return every current (file, rule) bucket above its frozen count."""
183 return [
184 (fname, rule, baseline.get((fname, rule), 0), count)
185 for (fname, rule), count in sorted(counts.items())
186 if count > baseline.get((fname, rule), 0)
187 ]
188
189
190def find_missing_baseline_files(
191 baseline: Counter[tuple[str, str]], existing_files: set[str]
192) -> list[str]:
193 """Return baseline paths that no longer name a real repository file.
194
195 A deleted or moved bucket is otherwise indistinguishable from burn-down:
196 no current finding can exceed it, so stale debt silently stays available
197 forever. Requiring an exact live path makes moves update their provenance
198 without changing the frozen per-rule counts.
199 """
200 return sorted({fname for fname, _rule in baseline if fname not in existing_files})
201
202
203def selftest() -> int:
204 """Prove the count ratchet fires on growth and stays quiet on burn-down."""
205 key = ("apps/shared_libs/mdl/src/mdl_fetch.c", "misra-c2012-15.5")
206 new_key = ("tools/new_tool.c", "misra-c2012-17.3")
207 baseline = Counter({key: 2})
208 cases = [
209 ("exact frozen debt stays quiet", Counter({key: 2}), False),
210 ("burn-down stays quiet", Counter({key: 1}), False),
211 ("bucket growth fires", Counter({key: 3}), True),
212 ("a new file/rule bucket fires", Counter({key: 2, new_key: 1}), True),
213 ]
214 failures = [
215 name
216 for name, current, should_fire in cases
217 if bool(find_regressions(current, baseline)) != should_fire
218 ]
219 stale = Counter({("moved.c", "misra-c2012-1.1"): 1})
220 if find_missing_baseline_files(stale, {"live.c"}) != ["moved.c"]:
221 failures.append("a stale baseline path fires")
222 if find_missing_baseline_files(stale, {"moved.c"}):
223 failures.append("a live baseline path stays quiet")
224 with tempfile.TemporaryDirectory() as tmp_dir:
225 generated = Path(tmp_dir) / "misra-baseline.txt"
226 write_baseline(generated, baseline)
227 expected = "\n".join(MIGRATION_PROVENANCE_HEADER) + "\n"
228 if expected not in generated.read_text(encoding="utf-8"):
229 failures.append("baseline regeneration preserves migration provenance")
230 if failures:
231 for name in failures:
232 print(f"misra_ratchet.py --selftest: FAIL: {name}", file=sys.stderr)
233 return 1
234 print(
235 f"misra_ratchet.py --selftest: PASS ({len(cases) + 2} both-direction cases; "
236 "1 provenance assertion)"
237 )
238 return 0
239
240
241def check(counts: Counter[tuple[str, str]], details: dict[tuple[str, str], list[str]]) -> int:
242 """Ratchet `counts` against the committed baseline; return the exit code."""
243 if not BASELINE_FILE.is_file():
244 print(
245 f"misra_ratchet.py: ERROR -- baseline {BASELINE_FILE} missing; "
246 f"generate it with `just quality::local::misra_baseline` and commit it."
247 )
248 return 1
249 baseline, base_version = load_baseline(BASELINE_FILE)
250
251 baseline_files = {fname for fname, _rule in baseline}
252 existing_files = {fname for fname in baseline_files if (REPO_ROOT / fname).is_file()}
253 missing_files = find_missing_baseline_files(baseline, existing_files)
254 if missing_files:
255 print(
256 f"misra_ratchet.py: FAIL -- {len(missing_files)} baseline file(s) "
257 "no longer exist; migrate or remove their frozen buckets:"
258 )
259 for fname in missing_files:
260 print(f" {fname}")
261 return 1
262
263 current_version = cppcheck_version()
264 if current_version != base_version:
265 print(
266 f"misra_ratchet.py: WARNING -- cppcheck version skew: baseline was "
267 f"generated by {base_version!r} but this run used {current_version!r}. "
268 f"Finding sets are not comparable across cppcheck versions; run the "
269 f"gate on the CI-pinned toolchain before trusting a red or a green."
270 )
271
272 regressions = find_regressions(counts, baseline)
273 if regressions:
274 report_regressions(regressions, details)
275 return 1
276
277 # No regressions past this point, so every current bucket is <= its
278 # baseline bucket and the totals difference is exactly the burn-down.
279 cur_total = sum(counts.values())
280 base_total = sum(baseline.values())
281 shrunk = base_total - cur_total
282 if shrunk > 0:
283 print(
284 f"misra_ratchet.py: PASS -- no new findings, and {shrunk} baseline "
285 f"finding(s) were burned down ({base_total} -> {cur_total}). Lock the "
286 f"progress in: run `just quality::local::misra_baseline` on the "
287 f"CI-pinned cppcheck "
288 f"and commit the shrunken .github/misra-baseline.txt."
289 )
290 else:
291 print(f"misra_ratchet.py: PASS -- {cur_total} finding(s), all within the baseline.")
292 return 0
293
294
295def main() -> int:
296 """Entry point: parse the mode flag and run the ratchet or the update."""
297 parser = argparse.ArgumentParser(description=__doc__.splitlines()[1])
298 mode = parser.add_mutually_exclusive_group()
299 mode.add_argument(
300 "--check",
301 action="store_true",
302 help="fail on any finding above the committed baseline (default)",
303 )
304 mode.add_argument(
305 "--update",
306 action="store_true",
307 help="rewrite the committed baseline from build/misra/results.txt",
308 )
309 mode.add_argument(
310 "--selftest",
311 action="store_true",
312 help="prove the ratchet fires and stays quiet, then exit",
313 )
314 args = parser.parse_args()
315
316 if args.selftest:
317 return selftest()
318
319 if not RESULTS_TSV.is_file():
320 print(
321 f"misra_ratchet.py: ERROR -- {RESULTS_TSV} not found; "
322 f"run `bash scripts/checks/misra_check_inner.sh` first."
323 )
324 return 1
325 counts, details = load_results(RESULTS_TSV)
326 if not counts:
327 print(
328 "misra_ratchet.py: ERROR -- results.txt parsed to zero findings; "
329 "a clean tree writes an empty baseline via --update, but an empty "
330 "parse in --check mode almost always means the audit itself broke "
331 "(cppcheck missing, addon missing, or dump generation failed)."
332 )
333 return 1
334
335 if args.update:
336 write_baseline(BASELINE_FILE, counts)
337 print(
338 f"misra_ratchet.py: baseline updated -- {sum(counts.values())} finding(s) "
339 f"across {len(counts)} (file, rule) bucket(s) -> {BASELINE_FILE}"
340 )
341 return 0
342 return check(counts, details)
343
344
345if __name__ == "__main__":
346 sys.exit(main())
-copyright
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298