4"""misra_ratchet.py -- MISRA-C 2012 ratchet gate (compare vs committed baseline).
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:
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.
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.
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.
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
40Both modes read `build/misra/results.txt`; run the audit first:
41 bash scripts/checks/misra_check_inner.sh
43Copyright (c) 2026 Brighton Sikarskie
44SPDX-License-Identifier: MIT
47from __future__
import annotations
53from collections
import Counter
54from pathlib
import Path
56REPO_ROOT = Path(__file__).resolve().parents[2]
57RESULTS_TSV = REPO_ROOT /
"build" /
"misra" /
"results.txt"
58BASELINE_FILE = REPO_ROOT /
".github" /
"misra-baseline.txt"
61"""Cap on raw finding lines echoed per offending (file, rule) bucket."""
64"""Column count of one results.txt row: rule, severity, file, line, message."""
67"""Column count of one baseline row: file, rule, count."""
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.",
82"""Audited migration provenance that every regenerated baseline must retain."""
85def cppcheck_version() -> str:
86 """Return the `cppcheck --version` string, or a placeholder when absent."""
89 [
"cppcheck",
"--version"],
95 except (OSError, subprocess.SubprocessError):
97 return out.stdout.strip().splitlines()[0]
if out.stdout.strip()
else "unknown"
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.
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).
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:
113 rule, _severity, fname, line, message = cols
116 details.setdefault(key, []).append(f
"{fname}:{line}: {message} [{rule}]")
117 return counts, details
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()
124 for raw
in path.read_text(encoding=
"utf-8").splitlines():
125 if raw.startswith(
"# cppcheck:"):
126 version = raw.split(
":", 1)[1].strip()
128 if not raw
or raw.startswith(
"#"):
130 cols = raw.split(
"\t")
131 if len(cols) != BASELINE_COLUMNS:
132 print(f
"misra_ratchet.py: ERROR -- malformed baseline row: {raw!r}")
134 fname, rule, count = cols
135 counts[(fname, rule)] = int(count)
136 return counts, version
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())
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",
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")
159def report_regressions(
160 regressions: list[tuple[str, str, int, int]],
161 details: dict[tuple[str, str], list[str]],
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]:
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)."
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."""
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)
190def find_missing_baseline_files(
191 baseline: Counter[tuple[str, str]], existing_files: set[str]
193 """Return baseline paths that no longer name a real repository file.
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.
200 return sorted({fname
for fname, _rule
in baseline
if fname
not in existing_files})
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})
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),
216 for name, current, should_fire
in cases
217 if bool(find_regressions(current, baseline)) != should_fire
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")
231 for name
in failures:
232 print(f
"misra_ratchet.py --selftest: FAIL: {name}", file=sys.stderr)
235 f
"misra_ratchet.py --selftest: PASS ({len(cases) + 2} both-direction cases; "
236 "1 provenance assertion)"
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():
245 f
"misra_ratchet.py: ERROR -- baseline {BASELINE_FILE} missing; "
246 f
"generate it with `just quality::local::misra_baseline` and commit it."
249 baseline, base_version = load_baseline(BASELINE_FILE)
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)
256 f
"misra_ratchet.py: FAIL -- {len(missing_files)} baseline file(s) "
257 "no longer exist; migrate or remove their frozen buckets:"
259 for fname
in missing_files:
263 current_version = cppcheck_version()
264 if current_version != base_version:
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."
272 regressions = find_regressions(counts, baseline)
274 report_regressions(regressions, details)
279 cur_total = sum(counts.values())
280 base_total = sum(baseline.values())
281 shrunk = base_total - cur_total
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."
291 print(f
"misra_ratchet.py: PASS -- {cur_total} finding(s), all within the baseline.")
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()
302 help=
"fail on any finding above the committed baseline (default)",
307 help=
"rewrite the committed baseline from build/misra/results.txt",
312 help=
"prove the ratchet fires and stays quiet, then exit",
314 args = parser.parse_args()
319 if not RESULTS_TSV.is_file():
321 f
"misra_ratchet.py: ERROR -- {RESULTS_TSV} not found; "
322 f
"run `bash scripts/checks/misra_check_inner.sh` first."
325 counts, details = load_results(RESULTS_TSV)
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)."
336 write_baseline(BASELINE_FILE, counts)
338 f
"misra_ratchet.py: baseline updated -- {sum(counts.values())} finding(s) "
339 f
"across {len(counts)} (file, rule) bucket(s) -> {BASELINE_FILE}"
342 return check(counts, details)
345if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.