ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_misra_deviations.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"""Gate the MISRA deviation register's derived claims against committed evidence.
5
6``docs/qualification/MISRA_DEVIATIONS.md`` is the certification artefact that
7says which MISRA-C 2012 findings the project has formally dispositioned. Its
8"current tree" inventories used to be live prose that nothing re-derived, and
9they rotted spectacularly: D-005 claimed 166 Rule 8.4 findings while the
10committed baseline held 1873 across 313 files (#632). This checker makes that
11class of rot a gate failure by re-deriving every machine-checkable claim in
12the register from the two committed evidence files:
13
14* ``.github/misra-baseline.txt`` -- the per-file-per-rule ratchet baseline
15 (parsed by the one authoritative parser, ``misra_ratchet.load_baseline``).
16* ``.cppcheck-suppressions`` -- the ``misra-c2012-*`` suppression rows the
17 audit converts to ``--suppress=`` flags.
18
19Machine-checked structures in the register (each is a SINGLE physical line,
20so prose re-wrapping cannot silently detach a number from its pattern):
21
221. Provenance: ``Baseline: <N> findings across <M> file/rule rows
23 (Cppcheck <V>).`` -- N must equal the baseline's ``# total findings:``
24 header AND the recomputed sum (a three-way check, so a truncated baseline
25 is a refusal to compare, never a burn-down); M is the data-row count and V
26 the ``# cppcheck:`` header value.
272. Register rows: ``| D-NNN | misra-c2012-R | <category> | <class> |
28 <status> | <MAR> | <findings> | <files> |`` -- the deviation-index
29 table, exactly one row per deviation, whose last two columns are
30 re-derived per rule from the baseline. Any ``| D-...`` line that
31 does not parse as this 8-column shape -- including one with a
32 non-three-digit ID like ``D-11`` -- is malformed, and so is any
33 ``## D-`` heading that does not parse canonically, so the register
34 cannot silently change shape out from under the checker.
353. Residual: ``Residual (no deviation record): <R> rules, <N> findings,
36 <M> rows.`` -- everything in the baseline outside the register.
374. Ownership: ``- `misra-c2012-R` (<N> rows, <M> paths): <owner>`` -- one
38 bullet per rule family present in ``.cppcheck-suppressions``, both counts
39 re-derived; an unlisted family or a ghost bullet fails.
405. Excerpts: ``Highest-count files for misra-c2012-R (top <N>, derived):``
41 followed by ``| `path` | <count> |`` rows that must equal the true top-N
42 (count descending, path ascending). An excerpt-shaped row outside a
43 parsed excerpt table -- the footprint of a wrapped or deleted intro
44 line -- is malformed.
456. Populations: any ``<N> findings across|in <M> files`` phrase is
46 re-derived against the rule of the ``## D-NNN`` section it sits in, and
47 is MALFORMED outside such a section, where no rule could derive it. An
48 unanchored population sentence in the top matter is how the #632 rot was
49 worded, so the shape is refused rather than ignored.
50
51Structural cross-checks: the ``## D-NNN: Rule R`` section headings and the
52register rows must describe the same (ID, rule) set; that set must be exactly
53the contiguous range the document header declares as active, so a deviation
54cannot be retired, added or renumbered without amending the header; and every
55row's rule must exist in the baseline, so an invented or typo'd rule id
56cannot pass by claiming zero findings.
57
58Non-vacuity floors (documented to SHRINK with the debt, never grow): the
59baseline must parse to at least ``MIN_BASELINE_RULES`` distinct rules, and the
60register to at least ``MIN_REGISTER_ROWS`` deviations,
61``MIN_OWNERSHIP_FAMILIES`` ownership bullets and ``MIN_EXCERPTS`` excerpt
62tables, else the scan is treated as collapsed (exit 2), not clean -- deleting
63a claim class outright must never read as verifying it. There is no
64suppressive baseline of its own: every discrepancy is a failure the register
65must absorb by being corrected.
66
67What this checker does NOT verify, so the register's own wording must not
68claim it does: the index's Category / Class / Status / MAR columns, prose that
69does not use one of the shapes above, and the identity (as opposed to the
70count) of the suppression rows behind each ownership bullet.
71
72Exit codes: 0 clean, 1 drift (a claim disagrees with the evidence), 2
73malformed input or collapsed/vacuous scan.
74
75Usage::
76
77 python3 scripts/checks/check_misra_deviations.py --check
78 python3 scripts/checks/check_misra_deviations.py --selftest
79
80``--selftest`` builds a fixture register + baseline + suppressions in a
81temporary tree and drives ``run_check`` -- the identical entry point
82``--check`` uses -- through every failure class: stale count, missing /
83extra / misshapen register row, short deviation ID in a row or heading, a
84row naming a rule absent from the baseline, a retirement that leaves the
85header's ID range stale, a missing ID range, malformed and missing
86documents, tampered / vacuous / two-column / below-floor baselines,
87suppression drift, unowned family, ghost bullet, heading mismatch,
88misordered excerpt, wrapped excerpt intro, a deleted excerpt block, a
89deleted ownership list, and a population claim that is stale, reworded
90``in``, or stranded outside any deviation section -- plus the
91must-stay-quiet direction, and finally asserts the live tree clears the
92floors.
93"""
94
95from __future__ import annotations
96
97import argparse
98import re
99import sys
100import tempfile
101from collections import Counter
102from dataclasses import dataclass, field
103from pathlib import Path
104
105sys.path.insert(0, str(Path(__file__).resolve().parent))
106
107from misra_deviations_fixtures import fixture_files, selftest_cases
108from misra_ratchet import load_baseline
109
110REPO_ROOT = Path(__file__).resolve().parents[2]
111DOC_PATH = REPO_ROOT / "docs" / "qualification" / "MISRA_DEVIATIONS.md"
112BASELINE_PATH = REPO_ROOT / ".github" / "misra-baseline.txt"
113SUPPRESSIONS_PATH = REPO_ROOT / ".cppcheck-suppressions"
114
115EXIT_OK = 0
116EXIT_DRIFT = 1
117EXIT_MALFORMED = 2
118
119MIN_BASELINE_RULES = 30
120"""Floor on distinct rules parsed from the baseline (63 at introduction).
121
122A collapsed parse reports FEWER rules, which would otherwise read as a clean
123register. Shrink this constant with the debt as burn-down retires whole
124rules; never raise it to paper over a parse regression.
125"""
126
127MIN_REGISTER_ROWS = 5
128"""Floor on derived-population rows parsed from the register (10 today).
129
130The register only ever loses rows when a deviation is formally retired, so a
131parse that suddenly sees fewer than this is a collapsed scan, not progress.
132The header's declared ID range is the tighter guard: a retirement that does
133not also amend the range fails, so this floor only has to catch a collapse.
134"""
135
136MIN_OWNERSHIP_FAMILIES = 10
137"""Floor on suppression-ownership bullets parsed from the register (15 today).
138
139Without it, commenting out every ``misra-c2012-*`` suppression row and
140deleting the bullet list verifies nothing and still announces PASS. Shrink
141it as families are genuinely retired; never raise it to hide a parse break.
142"""
143
144MIN_EXCERPTS = 1
145"""Floor on highest-count-file excerpt tables parsed from the register.
146
147An excerpt is the one claim class a maintainer could delete outright rather
148than let it drift, so a floor is what keeps "0 excerpt(s) verified" from
149reading as a clean run.
150"""
151
152RULE_PATTERN = r"misra-c2012-\d+\.\d+"
153
154HEADER_RANGE_RE = re.compile(r"\‍(D-(\d{3})\.\.D-(\d{3}) active\‍)")
155HEADING_RE = re.compile(r"^## (D-\d{3}): Rule (\d+\.\d+) ")
156ANY_HEADING_RE = re.compile(r"^## D-")
157ANY_REGISTER_ROW_RE = re.compile(r"^\|\s*D-\d+")
158REGISTER_ROW_RE = re.compile(
159 r"^\|\s*(D-\d{3})\s*\|\s*("
160 + RULE_PATTERN
161 + r")\s*\|(?:[^|]*\|){4}\s*(\d+)\s*\|\s*(\d+)\s*\|\s*$"
162)
163PROVENANCE_RE = re.compile(
164 r"^Baseline: (\d+) findings across (\d+) file/rule rows \‍((Cppcheck [0-9.]+)\‍)\.$"
165)
166RESIDUAL_RE = re.compile(
167 r"^Residual \‍(no deviation record\‍): (\d+) rules, (\d+) findings, (\d+) rows\.$"
168)
169OWNERSHIP_RE = re.compile(r"^- `(" + RULE_PATTERN + r")` \‍((\d+) rows?, (\d+) paths?\‍): \S")
170EXCERPT_INTRO_RE = re.compile(
171 r"^Highest-count files for (" + RULE_PATTERN + r") \‍(top (\d+), derived\‍):$"
172)
173EXCERPT_ROW_RE = re.compile(r"^\|\s*`([^`|]+)`\s*\|\s*(\d+)\s*\|$")
174TABLE_DECOR_RE = re.compile(r"^\|[\s:|-]+\|$")
175TOTAL_HEADER_RE = re.compile(r"^# total findings: (\d+)$")
176POPULATION_RE = re.compile(r"\b(\d+)(?: spurious)? findings (?:across|in) (\d+) files?(?![\w/])")
177"""A per-rule population restated in prose.
178
179Matched ANYWHERE in the register: inside a ``## D-NNN`` section it is
180re-derived against that section's rule, and outside one it is malformed --
181there is no rule to derive it against, and an unanchored population sentence
182in the top matter is exactly how the #632 rot was worded. A deliberately
183HISTORICAL figure must therefore not use this phrasing; the register says
184"751 violations in the 2026-05-02 baseline" for those.
185"""
186
187
188@dataclass
189class DocClaims:
190 """Every machine-checkable claim parsed out of the deviation register."""
191
192 provenance: list[tuple[int, int, str]] = field(default_factory=list)
193 derived: list[tuple[str, str, int, int]] = field(default_factory=list)
194 residual: list[tuple[int, int, int]] = field(default_factory=list)
195 headings: dict[str, str] = field(default_factory=dict)
196 declared_range: tuple[str, str] | None = None
197 populations: list[tuple[str, int, int]] = field(default_factory=list)
198 ownership: dict[str, tuple[int, int]] = field(default_factory=dict)
199 excerpts: list[tuple[str, int, list[tuple[str, int]]]] = field(default_factory=list)
200 malformed: list[str] = field(default_factory=list)
201
202
203def _parse_excerpt_table(lines: list[str], start: int) -> tuple[list[tuple[str, int]], int]:
204 """Consume one excerpt table after its intro line.
205
206 Args:
207 lines: The whole document as physical lines.
208 start: Index of the first line after the intro sentence.
209
210 Returns:
211 The parsed ``(path, count)`` rows and the index of the first line
212 after the table.
213 """
214 rows: list[tuple[str, int]] = []
215 i = start
216 while i < len(lines):
217 line = lines[i]
218 if not line.strip() or TABLE_DECOR_RE.match(line) or line.startswith("| File"):
219 i += 1
220 continue
221 m = EXCERPT_ROW_RE.match(line)
222 if m is None:
223 break
224 rows.append((m.group(1), int(m.group(2))))
225 i += 1
226 return rows, i
227
228
229def _classify_line(claims: DocClaims, line: str) -> None:
230 """Match one document line against every single-line claim pattern."""
231 if m := PROVENANCE_RE.match(line):
232 claims.provenance.append((int(m.group(1)), int(m.group(2)), m.group(3)))
233 elif m := RESIDUAL_RE.match(line):
234 claims.residual.append((int(m.group(1)), int(m.group(2)), int(m.group(3))))
235 elif m := REGISTER_ROW_RE.match(line):
236 claims.derived.append((m.group(1), m.group(2), int(m.group(3)), int(m.group(4))))
237 elif ANY_REGISTER_ROW_RE.match(line):
238 claims.malformed.append(f"register row does not match the 8-column shape: {line!r}")
239 elif m := HEADING_RE.match(line):
240 dev_id, rule = m.group(1), "misra-c2012-" + m.group(2)
241 if dev_id in claims.headings:
242 claims.malformed.append(f"duplicate section heading for {dev_id}")
243 claims.headings[dev_id] = rule
244 elif ANY_HEADING_RE.match(line):
245 claims.malformed.append(f"deviation heading does not match '## D-NNN: Rule R': {line!r}")
246 elif m := OWNERSHIP_RE.match(line):
247 rule = m.group(1)
248 if rule in claims.ownership:
249 claims.malformed.append(f"duplicate suppression-ownership bullet for {rule}")
250 claims.ownership[rule] = (int(m.group(2)), int(m.group(3)))
251 elif EXCERPT_ROW_RE.match(line):
252 claims.malformed.append(
253 f"excerpt-shaped row outside an excerpt table (wrapped or missing intro?): {line!r}"
254 )
255
256
257def parse_doc(path: Path) -> DocClaims:
258 """Parse the deviation register into its machine-checkable claims.
259
260 Args:
261 path: The register document (``MISRA_DEVIATIONS.md``).
262
263 Returns:
264 The parsed claims; structural defects land in ``claims.malformed``.
265 """
266 claims = DocClaims()
267 lines = path.read_text(encoding="utf-8").splitlines()
268 i = 0
269 current_rule: str | None = None
270 while i < len(lines):
271 line = lines[i]
272 if m := EXCERPT_INTRO_RE.match(line):
273 rows, i = _parse_excerpt_table(lines, i + 1)
274 if not rows:
275 claims.malformed.append(f"excerpt for {m.group(1)} has an intro but no table rows")
276 claims.excerpts.append((m.group(1), int(m.group(2)), rows))
277 continue
278 if line.startswith("## "):
279 heading = HEADING_RE.match(line)
280 current_rule = "misra-c2012-" + heading.group(2) if heading else None
281 if claims.declared_range is None and (rm := HEADER_RANGE_RE.search(line)):
282 claims.declared_range = ("D-" + rm.group(1), "D-" + rm.group(2))
283 _classify_line(claims, line)
284 for pm in POPULATION_RE.finditer(line):
285 if current_rule is None:
286 claims.malformed.append(
287 f"population claim outside any deviation section (no rule to derive "
288 f"it against): {line.strip()!r}"
289 )
290 else:
291 claims.populations.append((current_rule, int(pm.group(1)), int(pm.group(2))))
292 i += 1
293 if len(claims.provenance) != 1:
294 claims.malformed.append(
295 f"expected exactly one 'Baseline: ...' provenance line, found {len(claims.provenance)}"
296 )
297 if len(claims.residual) != 1:
298 claims.malformed.append(
299 f"expected exactly one 'Residual ...' line, found {len(claims.residual)}"
300 )
301 seen: set[str] = set()
302 for dev_id, _rule, _f, _n in claims.derived:
303 if dev_id in seen:
304 claims.malformed.append(f"duplicate derived-population row for {dev_id}")
305 seen.add(dev_id)
306 return claims
307
308
309def read_baseline(path: Path) -> tuple[Counter[tuple[str, str]], str, int | None, list[str]]:
310 """Load the committed baseline via the ratchet's own parser.
311
312 Args:
313 path: The committed ``misra-baseline.txt``.
314
315 Returns:
316 The per-(file, rule) counts, the recorded cppcheck version, the
317 ``# total findings:`` header value (None when absent) and any
318 malformed-input problems.
319 """
320 problems: list[str] = []
321 header_total: int | None = None
322 try:
323 counts, version = load_baseline(path)
324 except (SystemExit, ValueError):
325 return Counter(), "unknown", None, [f"{path.name}: malformed baseline row"]
326 except OSError as err:
327 return Counter(), "unknown", None, [f"{path.name}: unreadable -- {err}"]
328 for raw in path.read_text(encoding="utf-8").splitlines():
329 if m := TOTAL_HEADER_RE.match(raw):
330 header_total = int(m.group(1))
331 if header_total is None:
332 problems.append(f"{path.name}: missing '# total findings:' header")
333 elif header_total != sum(counts.values()):
334 problems.append(
335 f"{path.name}: header claims {header_total} findings but the rows sum to "
336 f"{sum(counts.values())} -- truncated or tampered; refusing to compare"
337 )
338 return counts, version, header_total, problems
339
340
341def parse_suppressions(path: Path) -> tuple[dict[str, tuple[int, int]], list[str]]:
342 """Derive per-rule row and path counts from the suppression list.
343
344 Args:
345 path: The committed ``.cppcheck-suppressions``.
346
347 Returns:
348 A map of rule id to ``(row count, distinct path-pattern count)`` and
349 any malformed-input problems.
350 """
351 try:
352 text = path.read_text(encoding="utf-8")
353 except OSError as err:
354 return {}, [f"{path.name}: unreadable -- {err}"]
355 rows: dict[str, int] = {}
356 paths: dict[str, set[str]] = {}
357 for raw in text.splitlines():
358 line = raw.strip()
359 if not line or line.startswith("#") or not line.startswith("misra-c2012-"):
360 continue
361 parts = line.split(":")
362 rule = parts[0]
363 rows[rule] = rows.get(rule, 0) + 1
364 if len(parts) > 1:
365 paths.setdefault(rule, set()).add(parts[1])
366 return {rule: (rows[rule], len(paths.get(rule, set()))) for rule in rows}, []
367
368
369def _per_rule(counts: Counter[tuple[str, str]]) -> dict[str, tuple[int, int]]:
370 """Aggregate the per-(file, rule) baseline into per-rule (findings, files)."""
371 agg: dict[str, list[int]] = {}
372 for (_fname, rule), count in counts.items():
373 bucket = agg.setdefault(rule, [0, 0])
374 bucket[0] += count
375 bucket[1] += 1
376 return {rule: (f, n) for rule, (f, n) in agg.items()}
377
378
379def _range_problems(claims: DocClaims) -> list[str]:
380 """Check the header's declared ID range against the rows actually present.
381
382 Args:
383 claims: The parsed register claims.
384
385 Returns:
386 Problems describing a silently retired, added or renumbered deviation.
387 """
388 if claims.declared_range is None:
389 return ["header declares no '(D-NNN..D-NNN active)' range -- cannot bound the register"]
390 first, last = claims.declared_range
391 want = [f"D-{n:03d}" for n in range(int(first[2:]), int(last[2:]) + 1)]
392 got = sorted({dev_id for dev_id, _r, _f, _n in claims.derived})
393 if got != want:
394 missing = [d for d in want if d not in got]
395 extra = [d for d in got if d not in want]
396 return [
397 f"header declares {first}..{last} active but the index holds "
398 f"{len(got)} row(s)"
399 + (f"; missing {missing}" if missing else "")
400 + (f"; unexpected {extra}" if extra else "")
401 ]
402 return []
403
404
405def _register_problems(claims: DocClaims, per_rule: dict[str, tuple[int, int]]) -> list[str]:
406 """Cross-check section headings and register rows against the baseline."""
407 problems: list[str] = []
408 derived = {dev_id: (rule, f, n) for dev_id, rule, f, n in claims.derived}
409 for dev_id in sorted(set(claims.headings) | set(derived)):
410 if dev_id not in claims.headings:
411 problems.append(f"{dev_id}: register row without a section heading (extra row)")
412 if dev_id not in derived:
413 problems.append(f"{dev_id}: section heading without a register row (missing row)")
414 continue
415 rule, doc_f, doc_n = derived[dev_id]
416 if claims.headings.get(dev_id, rule) != rule:
417 problems.append(f"{dev_id}: rule mismatch between section heading and register row")
418 if rule not in per_rule:
419 problems.append(
420 f"{dev_id}: rule {rule} appears nowhere in the baseline -- a typo, an "
421 f"invented id, or a deviation whose debt is gone and which must be retired"
422 )
423 continue
424 real_f, real_n = per_rule[rule]
425 if (doc_f, doc_n) != (real_f, real_n):
426 problems.append(
427 f"{dev_id} ({rule}): register claims {doc_f} findings / {doc_n} files, "
428 f"baseline derives {real_f} / {real_n} (stale count)"
429 )
430 return problems
431
432
433def _population_problems(claims: DocClaims, per_rule: dict[str, tuple[int, int]]) -> list[str]:
434 """Check in-section 'N findings across M files' restatements against the baseline."""
435 problems = []
436 for rule, doc_f, doc_n in claims.populations:
437 real_f, real_n = per_rule.get(rule, (0, 0))
438 if (doc_f, doc_n) != (real_f, real_n):
439 problems.append(
440 f"in-section population claim under {rule}: register says {doc_f} findings / "
441 f"{doc_n} files, baseline derives {real_f} / {real_n} (stale count)"
442 )
443 return problems
444
445
446def _residual_problems(claims: DocClaims, per_rule: dict[str, tuple[int, int]]) -> list[str]:
447 """Check the residual line covers exactly the rules outside the register."""
448 if not claims.residual or not claims.provenance:
449 return []
450 register_rules = {rule for _id, rule, _f, _n in claims.derived}
451 resid = [rule for rule in per_rule if rule not in register_rules]
452 want = (
453 len(resid),
454 sum(per_rule[r][0] for r in resid),
455 sum(per_rule[r][1] for r in resid),
456 )
457 got = claims.residual[0]
458 if got != want:
459 return [
460 f"residual line claims {got[0]} rules / {got[1]} findings / {got[2]} rows, "
461 f"baseline derives {want[0]} / {want[1]} / {want[2]} (stale count)"
462 ]
463 return []
464
465
466def _ownership_problems(claims: DocClaims, families: dict[str, tuple[int, int]]) -> list[str]:
467 """Check the suppression-ownership bullets against the suppression list."""
468 problems = []
469 for rule in sorted(set(families) | set(claims.ownership)):
470 if rule not in claims.ownership:
471 problems.append(
472 f"suppression family {rule} ({families[rule][0]} row(s)) has no ownership "
473 f"bullet in the register (unowned waiver)"
474 )
475 elif rule not in families:
476 problems.append(f"ownership bullet for {rule} matches no suppression row (ghost row)")
477 elif claims.ownership[rule] != families[rule]:
478 problems.append(
479 f"ownership bullet for {rule} claims {claims.ownership[rule]}, suppression "
480 f"list derives {families[rule]} (rows, paths)"
481 )
482 return problems
483
484
485def _excerpt_problems(claims: DocClaims, counts: Counter[tuple[str, str]]) -> list[str]:
486 """Check every highest-count-files excerpt equals the true top-N."""
487 problems = []
488 for rule, top_n, rows in claims.excerpts:
489 per_file = [(fname, c) for (fname, r), c in counts.items() if r == rule]
490 want = sorted(per_file, key=lambda fc: (-fc[1], fc[0]))[:top_n]
491 if rows != want:
492 problems.append(f"excerpt for {rule}: register lists {rows}, baseline derives {want}")
493 return problems
494
495
496def _provenance_problems(
497 claims: DocClaims, counts: Counter[tuple[str, str]], version: str
498) -> list[str]:
499 """Check the provenance line's totals and tool version."""
500 if not claims.provenance:
501 return []
502 doc_f, doc_rows, doc_version = claims.provenance[0]
503 problems = []
504 if (doc_f, doc_rows) != (sum(counts.values()), len(counts)):
505 problems.append(
506 f"provenance claims {doc_f} findings / {doc_rows} rows, baseline derives "
507 f"{sum(counts.values())} / {len(counts)} (stale count)"
508 )
509 if doc_version != version:
510 problems.append(
511 f"provenance names {doc_version!r} but the baseline header records {version!r}"
512 )
513 return problems
514
515
516def evaluate(
517 doc_path: Path, baseline_path: Path, suppressions_path: Path
518) -> tuple[list[str], list[str]]:
519 """Re-derive every register claim from the committed evidence.
520
521 Args:
522 doc_path: The deviation register.
523 baseline_path: The committed MISRA ratchet baseline.
524 suppressions_path: The committed cppcheck suppression list.
525
526 Returns:
527 ``(drift, malformed)`` problem lists; both empty means clean.
528 """
529 try:
530 claims = parse_doc(doc_path)
531 except OSError as err:
532 return [], [f"{doc_path.name}: unreadable -- {err}"]
533 counts, version, _header_total, base_problems = read_baseline(baseline_path)
534 families, supp_problems = parse_suppressions(suppressions_path)
535 malformed = claims.malformed + base_problems + supp_problems
536 per_rule = _per_rule(counts)
537 if len(per_rule) < MIN_BASELINE_RULES:
538 malformed.append(
539 f"baseline parsed to {len(per_rule)} distinct rules, below the "
540 f"non-vacuity floor of {MIN_BASELINE_RULES} -- collapsed scan, refusing to pass"
541 )
542 for parsed, floor, what in (
543 (len(claims.derived), MIN_REGISTER_ROWS, "derived-population rows"),
544 (len(claims.ownership), MIN_OWNERSHIP_FAMILIES, "suppression-ownership bullets"),
545 (len(claims.excerpts), MIN_EXCERPTS, "excerpt tables"),
546 ):
547 if parsed < floor:
548 malformed.append(
549 f"register parsed to {parsed} {what}, below the non-vacuity floor of "
550 f"{floor} -- collapsed scan, refusing to pass"
551 )
552 if malformed:
553 return [], malformed
554 drift = (
555 _provenance_problems(claims, counts, version)
556 + _range_problems(claims)
557 + _register_problems(claims, per_rule)
558 + _population_problems(claims, per_rule)
559 + _residual_problems(claims, per_rule)
560 + _ownership_problems(claims, families)
561 + _excerpt_problems(claims, counts)
562 )
563 return drift, []
564
565
566def run_check(doc_path: Path, baseline_path: Path, suppressions_path: Path) -> int:
567 """Evaluate the register and report; the single entry point both modes use.
568
569 Args:
570 doc_path: The deviation register.
571 baseline_path: The committed MISRA ratchet baseline.
572 suppressions_path: The committed cppcheck suppression list.
573
574 Returns:
575 ``EXIT_OK``, ``EXIT_DRIFT`` or ``EXIT_MALFORMED``.
576 """
577 tag = "check_misra_deviations.py"
578 drift, malformed = evaluate(doc_path, baseline_path, suppressions_path)
579 for problem in malformed:
580 print(f"{tag}: MALFORMED -- {problem}", file=sys.stderr)
581 if malformed:
582 return EXIT_MALFORMED
583 for problem in drift:
584 print(f"{tag}: DRIFT -- {problem}", file=sys.stderr)
585 if drift:
586 print(
587 f"{tag}: {len(drift)} claim(s) in {doc_path.name} disagree with the committed "
588 f"evidence. Re-derive the numbers from .github/misra-baseline.txt and "
589 f".cppcheck-suppressions and correct the register -- never the evidence.",
590 file=sys.stderr,
591 )
592 return EXIT_DRIFT
593 claims = parse_doc(doc_path)
594 print(
595 f"{tag}: PASS -- {len(claims.derived)} deviations, {len(claims.ownership)} "
596 f"suppression families and {len(claims.excerpts)} excerpt(s) verified against "
597 f"the committed baseline."
598 )
599 return EXIT_OK
600
601
602def _fixture_files() -> dict[str, str]:
603 """Build the mutually consistent self-test fixture."""
604 return fixture_files()
605
606
607def _selftest_cases() -> list[tuple[str, str, str, str, int]]:
608 """Enumerate every self-test mutation and the quiet control."""
609 return selftest_cases(EXIT_OK, EXIT_DRIFT, EXIT_MALFORMED)
610
611
612def _run_fixture_case(tmp: Path, files: dict[str, str], name: str, expected: int) -> str | None:
613 """Materialise one fixture variant and drive ``run_check`` over it.
614
615 Args:
616 tmp: Scratch directory to write the variant into.
617 files: The three fixture file bodies, already mutated for this case.
618 name: Case label for the failure report.
619 expected: The exit code the case must produce.
620
621 Returns:
622 A failure description, or None when the case behaves as expected.
623 """
624 doc = tmp / "MISRA_DEVIATIONS.md"
625 baseline = tmp / "misra-baseline.txt"
626 suppressions = tmp / "cppcheck-suppressions"
627 doc.write_text(files["doc"], encoding="utf-8")
628 baseline.write_text(files["baseline"], encoding="utf-8")
629 suppressions.write_text(files["suppressions"], encoding="utf-8")
630 got = run_check(doc, baseline, suppressions)
631 if got != expected:
632 return f"{name}: expected exit {expected}, got {got}"
633 return None
634
635
636def selftest() -> int:
637 """Drive every failure class and the quiet direction through ``run_check``."""
638 tag = "check_misra_deviations.py --selftest"
639 failures: list[str] = []
640 with tempfile.TemporaryDirectory() as tmpdir:
641 tmp = Path(tmpdir)
642 for name, key, old, new, expected in _selftest_cases():
643 files = dict(_fixture_files())
644 if old and old not in files[key]:
645 failures.append(f"{name}: fixture mutation target {old!r} not found")
646 continue
647 if old:
648 files[key] = files[key].replace(old, new)
649 problem = _run_fixture_case(tmp, files, name, expected)
650 if problem:
651 failures.append(problem)
652 floor_files = dict(_fixture_files())
653 floor_files["baseline"] = (
654 "# fixture baseline\n"
655 "# cppcheck: Cppcheck 9.9.9\n"
656 "# total findings: 3\n"
657 "a.c\tmisra-c2012-1.1\t3\n"
658 )
659 problem = _run_fixture_case(tmp, floor_files, "below-floor baseline", EXIT_MALFORMED)
660 if problem:
661 failures.append(problem)
662 stripped = dict(_fixture_files())
663 stripped["doc"] = "\n".join(
664 line for line in stripped["doc"].splitlines() if not line.startswith("- `misra-c2012-")
665 )
666 problem = _run_fixture_case(tmp, stripped, "no ownership bullets at all", EXIT_MALFORMED)
667 if problem:
668 failures.append(problem)
669 good = _fixture_files()
670 (tmp / "misra-baseline.txt").write_text(good["baseline"], encoding="utf-8")
671 (tmp / "cppcheck-suppressions").write_text(good["suppressions"], encoding="utf-8")
672 got = run_check(
673 tmp / "absent.md", tmp / "misra-baseline.txt", tmp / "cppcheck-suppressions"
674 )
675 if got != EXIT_MALFORMED:
676 failures.append(f"missing register document: expected exit {EXIT_MALFORMED}, got {got}")
677 _live_drift, live_malformed = evaluate(DOC_PATH, BASELINE_PATH, SUPPRESSIONS_PATH)
678 if live_malformed:
679 failures.append(f"live tree trips the floors or fails to parse: {live_malformed[:3]}")
680 for failure in failures:
681 print(f"{tag}: FAIL: {failure}", file=sys.stderr)
682 if failures:
683 return 1
684 print(f"{tag}: PASS ({len(_selftest_cases()) + 3} both-direction cases + live floors)")
685 return 0
686
687
688def main(argv: list[str] | None = None) -> int:
689 """Entry point: parse the mode flag and run the register check or selftest.
690
691 Args:
692 argv: Argument vector override for tests; None uses ``sys.argv``.
693
694 Returns:
695 The process exit code.
696 """
697 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
698 mode = parser.add_mutually_exclusive_group()
699 mode.add_argument(
700 "--check",
701 action="store_true",
702 help="re-derive every register claim from committed evidence (default)",
703 )
704 mode.add_argument(
705 "--selftest",
706 action="store_true",
707 help="prove every failure class fires and the clean fixture stays quiet",
708 )
709 args = parser.parse_args(argv)
710 if args.selftest:
711 return selftest()
712 return run_check(DOC_PATH, BASELINE_PATH, SUPPRESSIONS_PATH)
713
714
715if __name__ == "__main__":
716 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298