ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
mcdc_report.py
Go to the documentation of this file.
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2026 Brighton Sikarskie
3"""The five artefacts the MC/DC audit emits, and the counts they share.
4
5Split out of ``regen_mcdc_gaps.py`` (#359): parsing llvm-cov's report and
6classifying each decision is one job; rendering that into a CSV, two Markdown
7documents and two JSON roll-ups is another, and it is by far the larger half.
8
9Every artefact quotes the same headline counts, so :func:`headline` computes
10them once and each writer takes the result. That is the reason these live
11together rather than one file per artefact -- the numbers in the CSV, the
12report and the gate JSON must agree, and the only way to guarantee that is for
13them to be the same numbers.
14"""
15
16from __future__ import annotations
17
18import contextlib
19import csv
20import json
21from collections import defaultdict
22
23from regen_mcdc_gaps import (
24 CSV_OUT,
25 DEACT_MD_OUT,
26 EXCERPT_MAX_DEACTIVATED,
27 EXCERPT_MAX_REACHABLE,
28 EXCERPT_TRUNC_DEACTIVATED,
29 EXCERPT_TRUNC_REACHABLE,
30 MCDC_FULL_PCT,
31 MCDC_ZERO_PCT,
32 MD_OUT,
33 REPO_ROOT,
34 TABLE_ROW_CAP,
35 _truncate_md_cell,
36 decision_snippet,
37 module_of,
38)
39
40
41# ---------------------------------------------------------------------------
42def write_gap_csv(classified: list) -> None:
43 """Write the gap-only CSV.
44
45 The `line` column is a text-derived snippet, not a line number: line
46 numbers drift on every reformat and produce stale anchors, which project
47 citation policy bans.
48 """
49 with CSV_OUT.open("w", encoding="ascii", newline="") as fh:
50 w = csv.writer(fh, lineterminator="\n")
51 w.writerow(
52 [
53 "source_file",
54 "decision_text_snippet",
55 "condition_count",
56 "function_name",
57 "decision_excerpt",
58 "covered",
59 "deactivated",
60 "deactivation_rationale",
61 ]
62 )
63 for src, _ln, n, func, excerpt, covered, deact, rationale in classified:
64 w.writerow(
65 [
66 src,
67 decision_snippet(excerpt),
68 n,
69 func,
70 excerpt,
71 covered,
72 "true" if deact else "false",
73 rationale,
74 ]
75 )
76
77
78def module_rows(all_decisions: list) -> list:
79 """Per-module (total, covered, partial, uncovered), worst first."""
80 per_module: dict[str, list[float]] = defaultdict(list)
81 for src, _ln, _n, _e, pct in all_decisions:
82 per_module[module_of(src)].append(pct)
83
84 rows = []
85 for mod, pcts in per_module.items():
86 total = len(pcts)
87 covered = sum(1 for p in pcts if p >= MCDC_FULL_PCT)
88 partial = sum(1 for p in pcts if MCDC_ZERO_PCT < p < MCDC_FULL_PCT)
89 uncov = sum(1 for p in pcts if p == MCDC_ZERO_PCT)
90 rows.append((mod, total, covered, partial, uncov))
91
92 # Sort by uncovered+partial desc, then total desc, then name.
93 rows.sort(key=lambda r: (-(r[3] + r[4]), -r[1], r[0]))
94 return rows
95
96
97def headline(all_decisions: list, classified: list) -> dict:
98 """The counts every generated artefact quotes, computed once."""
99 total_dec = len(all_decisions)
100 yes_dec = sum(1 for d in all_decisions if d[4] >= MCDC_FULL_PCT)
101 partial_dec = sum(1 for d in all_decisions if MCDC_ZERO_PCT < d[4] < MCDC_FULL_PCT)
102 no_dec = sum(1 for d in all_decisions if d[4] == MCDC_ZERO_PCT)
103 files_seen = len({d[0] for d in all_decisions})
104 decision_complete_rate = (100.0 * yes_dec / total_dec) if total_dec else 0.0
105
106 deactivated_rows = [r for r in classified if r[6]]
107 reachable_rows = [r for r in classified if not r[6]]
108 # Reachable-only MC/DC: treat deactivated decisions as if they were
109 # at 100% (they are exempted under DO-178C 6.4.4.3 with documented
110 # rationale in docs/MCDC_DEACTIVATIONS.md). The denominator is
111 # unchanged; the numerator counts every covered decision plus every
112 # documented-deactivated decision.
113 deact_count = len(deactivated_rows)
114 reachable_total = total_dec - deact_count
115 reachable_covered = yes_dec
116 reachable_rate = (100.0 * reachable_covered / reachable_total) if reachable_total else 100.0
117 return {
118 "total_dec": total_dec,
119 "yes_dec": yes_dec,
120 "partial_dec": partial_dec,
121 "no_dec": no_dec,
122 "files_seen": files_seen,
123 "decision_complete_rate": decision_complete_rate,
124 "deactivated_rows": deactivated_rows,
125 "reachable_rows": reachable_rows,
126 "deact_count": deact_count,
127 "reachable_total": reachable_total,
128 "reachable_covered": reachable_covered,
129 "reachable_rate": reachable_rate,
130 }
131
132
133def _md_preamble() -> list[str]:
134 """Report heading and the methodology note. No data, so no arguments."""
135 md_lines: list[str] = []
136 md_lines.append("# MC/DC Coverage Gap Audit")
137 md_lines.append("")
138 md_lines.append(
139 "Live audit of compound boolean decisions reported by"
140 " `llvm-cov show --show-mcdc` for first-party sources"
141 " (`libs/`, `port/`, excluding `libs/third_party/`)."
142 " Regenerated from `build/mcdc-report/mcdc.txt` by"
143 " `scripts/fix/regen_mcdc_gaps.py`; do not edit by hand."
144 )
145 md_lines.append("")
146 md_lines.append("## Methodology")
147 md_lines.append("")
148 md_lines.append(
149 "- Source of truth: `build/mcdc-report/mcdc.txt` (output of `just quality::local::mcdc`)."
150 )
151 md_lines.append(
152 '- A decision is one llvm-cov "MC/DC Decision Region".'
153 " Condition count is taken from the `Number of Conditions:`"
154 " field that llvm-cov emits for that region."
155 )
156 md_lines.append("- Coverage status (`covered` column):")
157 md_lines.append(
158 " - `yes` -- llvm-cov reports 100.00% MC/DC for the decision."
159 " Excluded from the CSV (CSV is gap-only)."
160 )
161 md_lines.append(
162 " - `partial` -- 0 < MC/DC % < 100. The decision was exercised"
163 " but at least one independence pair is missing."
164 )
165 md_lines.append(
166 " - `no` -- MC/DC % == 0. The decision was never evaluated under instrumentation."
167 )
168 md_lines.append("")
169 return md_lines
170
171
172def _md_topline(h: dict) -> list[str]:
173 """The headline decision counts and coverage rates."""
174 total_dec = h["total_dec"]
175 yes_dec = h["yes_dec"]
176 partial_dec = h["partial_dec"]
177 no_dec = h["no_dec"]
178 files_seen = h["files_seen"]
179 decision_complete_rate = h["decision_complete_rate"]
180 deact_count = h["deact_count"]
181 reachable_total = h["reachable_total"]
182 reachable_rate = h["reachable_rate"]
183 md_lines: list[str] = []
184 md_lines.append("## Top-line Numbers")
185 md_lines.append("")
186 md_lines.append(f"- Source files with at least one decision: **{files_seen}**")
187 md_lines.append(f"- Total compound decisions in scope: **{total_dec}**")
188 md_lines.append(f"- Decisions at 100% MC/DC (`yes`): **{yes_dec}**")
189 md_lines.append(f"- Decisions partially covered (`partial`): **{partial_dec}**")
190 md_lines.append(f"- Decisions fully uncovered (`no`): **{no_dec}**")
191 md_lines.append(
192 "- Decision-complete rate (fully covered decisions / total decisions):"
193 f" **{decision_complete_rate:.2f}%**"
194 )
195 md_lines.append(f"- Deactivated gap decision regions (DO-178C 6.4.4.3): **{deact_count}**")
196 md_lines.append(
197 f"- Reachable decision-region denominator (total - deactivated): **{reachable_total}**"
198 )
199 md_lines.append(
200 f"- **Reachable decision-complete MC/DC rate**: **{reachable_rate:.2f}%**"
201 " -- every counted decision region has complete MC/DC; the enforced"
202 " ratchet threshold is recorded in `.github/mcdc-baseline.txt`."
203 )
204 md_lines.append("")
205 md_lines.append(
206 "See `docs/MCDC_DEACTIVATIONS.md` for the per-decision deactivation rationale catalog."
207 )
208 md_lines.append("")
209 return md_lines
210
211
212def _md_summary(h: dict) -> list[str]:
213 """Report heading, methodology, and the top-line numbers."""
214 return [*_md_preamble(), *_md_topline(h)]
215
216
217def _md_gap_tables(h: dict) -> list[str]:
218 """The reachable-gap and deactivated-gap tables."""
219 deactivated_rows = h["deactivated_rows"]
220 reachable_rows = h["reachable_rows"]
221 md_lines: list[str] = []
222 md_lines.append("## Reachable gaps (require new MC/DC test vectors)")
223 md_lines.append("")
224 md_lines.append("| File | Conds | Function | Excerpt | Status |")
225 md_lines.append("|------|------:|----------|---------|--------|")
226 for src, _ln, n, func, excerpt, covered, _deact, _rat in reachable_rows[:TABLE_ROW_CAP]:
227 ex = _truncate_md_cell(
228 excerpt.replace("|", "\\|"), EXCERPT_MAX_REACHABLE, EXCERPT_TRUNC_REACHABLE
229 )
230 md_lines.append(f"| {src} | {n} | {func} | `{ex}` | {covered} |")
231 if len(reachable_rows) > TABLE_ROW_CAP:
232 overflow = len(reachable_rows) - TABLE_ROW_CAP
233 md_lines.append(f"| ... | | | | *({overflow} more rows in CSV)* | |")
234 md_lines.append("")
235 md_lines.append("## Deactivated gaps (DO-178C 6.4.4.3 exempted)")
236 md_lines.append("")
237 md_lines.append(
238 "These decision regions are unreachable on any public-API path and"
239 " are therefore exempted from the reachable decision-complete gate."
240 " Each row carries the rationale used by the auto-classifier; humans"
241 " may extend the per-decision narrative in `docs/MCDC_DEACTIVATIONS.md`."
242 )
243 md_lines.append("")
244 md_lines.append("| File | Conds | Function | Excerpt | Rationale |")
245 md_lines.append("|------|------:|----------|---------|-----------|")
246 for src, _ln, n, func, excerpt, _covered, _deact, rationale in deactivated_rows:
247 ex = _truncate_md_cell(
248 excerpt.replace("|", "\\|"), EXCERPT_MAX_DEACTIVATED, EXCERPT_TRUNC_DEACTIVATED
249 )
250 rt = _truncate_md_cell(
251 rationale.replace("|", "\\|"), EXCERPT_MAX_DEACTIVATED, EXCERPT_TRUNC_DEACTIVATED
252 )
253 md_lines.append(f"| {src} | {n} | {func} | `{ex}` | {_escape_md_tags(rt)} |")
254 md_lines.append("")
255 return md_lines
256
257
258def _md_module_tables(rows: list) -> list[str]:
259 """The per-module roll-up tables and the report footer."""
260 md_lines: list[str] = []
261 md_lines.append("## Per-module gap counts (full table)")
262 md_lines.append("")
263 md_lines.append("Sorted by (uncovered + partial) descending, then total descending.")
264 md_lines.append("")
265 md_lines.append("| Module | Total | Covered | Partial | Uncovered |")
266 md_lines.append("|--------|------:|--------:|--------:|----------:|")
267 for mod, total, covered, partial, uncov in rows:
268 md_lines.append(f"| {mod} | {total} | {covered} | {partial} | {uncov} |")
269 md_lines.append("")
270 md_lines.append("## Top 30 modules with at least one uncovered decision")
271 md_lines.append("")
272 md_lines.append("| Module | Uncovered | Partial | Covered | Total |")
273 md_lines.append("|--------|----------:|--------:|--------:|------:|")
274 uncov_only = [r for r in rows if r[4] > 0]
275 uncov_only.sort(key=lambda r: (-r[4], -r[3], r[0]))
276 for mod, total, covered, partial, uncov in uncov_only[:30]:
277 md_lines.append(f"| {mod} | {uncov} | {partial} | {covered} | {total} |")
278 md_lines.append("")
279 md_lines.append("---")
280 md_lines.append("")
281 md_lines.append(
282 "*Regenerated from the live `just quality::local::mcdc` report. See"
283 " `docs/MCDC_GAPS.csv` for the full per-decision table"
284 " including decision-text snippets and excerpts.*"
285 )
286 md_lines.append("")
287
288 return md_lines
289
290
291def write_markdown(rows: list, h: dict) -> None:
292 """Write the gap-audit report, one function per section."""
293 md_lines = [*_md_summary(h), *_md_gap_tables(h), *_md_module_tables(rows)]
294 MD_OUT.write_text("\n".join(md_lines), encoding="ascii")
295
296
297def _deact_preamble() -> list[str]:
298 """The fixed header of the catalog: what it is, the standard, and the policy."""
299 return [
300 "# MC/DC Deactivated-Decision Catalog",
301 "",
302 "This file documents every MC/DC decision region that has been"
303 " classified as **deactivated** under **DO-178C 6.4.4.3"
304 ' ("deactivated code")**. Deactivated decision regions are exempted'
305 " from the reachable decision-complete MC/DC gate because the public-API contract"
306 " makes them unreachable; they remain in the source for"
307 " defense-in-depth, fault-injection robustness, and to give"
308 " static analyzers a clear local invariant to anchor on.",
309 "",
310 "## Policy",
311 "",
312 "DO-178C 6.4.4.3 permits structural-coverage exemption for code"
313 " that is intentionally not reachable in the operational"
314 " configuration, provided each instance is (a) identified, (b)"
315 " justified by a documented rationale, and (c) accompanied by"
316 " upstream evidence that the exemption holds. The"
317 " auto-classifier in `scripts/fix/regen_mcdc_gaps.py`"
318 " enumerates every such decision region; this catalog records"
319 " the upstream guard or contract that makes each one unreachable.",
320 "",
321 "Equivalent industry references: IEC 61508-3:2010 7.4.7"
322 " (defensive-programming code), ISO 26262-6:2018 9.4.5"
323 " (deactivated branches).",
324 "",
325 "## Auto-generated entries",
326 "",
327 "Generated by `scripts/fix/regen_mcdc_gaps.py` from"
328 " `build/mcdc-report/mcdc.txt`. Do not edit by hand above the"
329 " `<!-- MANUAL -->` marker; manual narrative may be added below"
330 " the marker for a specific decision region by appending its"
331 " `file::function::snippet` anchor (line numbers are not used:"
332 " they drift on every reformat).",
333 "",
334 ]
335
336
337def _escape_md_tags(text: str) -> str:
338 """Escape angle brackets outside backtick spans.
339
340 Deactivation rationales quote XML/HTML tag names (<nav>, <spine>, ...);
341 emitted raw into markdown they read as real markup to Doxygen's HTML
342 pass, which warns and fails the docs gate. Segments inside backtick code
343 spans are already literal to Doxygen and must stay byte-identical.
344 """
345 parts = text.split("`")
346 for i in range(0, len(parts), 2):
347 parts[i] = parts[i].replace("<", "&lt;").replace(">", "&gt;")
348 return "`".join(parts)
349
350
351def _deact_entries(deactivated_rows: list[tuple]) -> list[str]:
352 """One catalog section per deactivated decision, keyed by a stable anchor.
353
354 The anchor is ``file::function::snippet`` rather than a line number
355 precisely because line numbers drift on every reformat, which would
356 silently detach a hand-written justification from its decision region.
357 """
358 if not deactivated_rows:
359 return ["(no deactivated decision regions detected)", ""]
360 out: list[str] = []
361 for src, _ln, n, func, excerpt, covered, _deact, rationale in deactivated_rows:
362 anchor = _escape_md_tags(f"{src}::{func}::{decision_snippet(excerpt)}")
363 out.extend(
364 [
365 f"### {anchor}",
366 "",
367 f"- **Function**: `{func}`",
368 f"- **Conditions in decision**: {n}",
369 f"- **Current llvm-cov status**: {covered}",
370 f"- **Source line**: `{excerpt.strip()}`",
371 f"- **Rationale**: {_escape_md_tags(rationale)}",
372 "- **DO-178C 6.4.4.3 basis**: defensive guard whose"
373 " upstream contract is enforced on every public-API"
374 " entry.",
375 "",
376 ]
377 )
378 return out
379
380
381def _deact_manual_footer() -> list[str]:
382 """The marker below which hand-written narrative may be added."""
383 return [
384 "<!-- MANUAL -->",
385 "",
386 "## Manual narrative (per anchor)",
387 "",
388 "_Add expanded justification here keyed by the"
389 " `file::function::snippet` anchor above when the"
390 " auto-generated rationale is"
391 " insufficient. Anything below this marker is preserved across"
392 " regenerations only if you commit it -- the regenerator"
393 " currently overwrites the entire file; future revisions may"
394 " split the manual section into a sibling file._",
395 "",
396 ]
397
398
399def write_deactivations(h: dict) -> None:
400 """Write the per-decision deactivation catalog (DO-178C 6.4.4.3)."""
401 deact_lines = [
402 *_deact_preamble(),
403 *_deact_entries(h["deactivated_rows"]),
404 *_deact_manual_footer(),
405 ]
406 DEACT_MD_OUT.write_text("\n".join(deact_lines), encoding="ascii")
407
408
409def write_gate_json(h: dict) -> None:
410 """Stash key counts so the gate script need not re-parse the report."""
411 total_dec = h["total_dec"]
412 yes_dec = h["yes_dec"]
413 decision_complete_rate = h["decision_complete_rate"]
414 deact_count = h["deact_count"]
415 reachable_total = h["reachable_total"]
416 reachable_covered = h["reachable_covered"]
417 reachable_rate = h["reachable_rate"]
418 gate_json = REPO_ROOT / "build" / "mcdc-report" / "gate.json"
419 with contextlib.suppress(OSError):
420 gate_json.write_text(
421 "{\n"
422 f' "total_decisions": {total_dec},\n'
423 f' "covered_decisions": {yes_dec},\n'
424 f' "deactivated_decisions": {deact_count},\n'
425 f' "reachable_total": {reachable_total},\n'
426 f' "reachable_covered": {reachable_covered},\n'
427 f' "reachable_decision_complete_rate": {reachable_rate:.4f},\n'
428 f' "decision_complete_rate": {decision_complete_rate:.4f}\n'
429 "}\n",
430 encoding="ascii",
431 )
432
433
434def write_per_file_json(all_decisions: list, classified: list) -> None:
435 """Per-file decision roll-up for check_mcdc_floor.py."""
436 per_file: dict[str, dict[str, int]] = defaultdict(
437 lambda: {"total": 0, "covered": 0, "deactivated": 0}
438 )
439 for src, _ln, _n, _e, pct in all_decisions:
440 per_file[src]["total"] += 1
441 if pct >= MCDC_FULL_PCT:
442 per_file[src]["covered"] += 1
443 for row in classified:
444 if row[6]: # deactivated
445 per_file[row[0]]["deactivated"] += 1
446
447 per_file_json = REPO_ROOT / "build" / "mcdc-report" / "mcdc_per_file.json"
448 per_file_entries = [
449 {
450 "file": src,
451 "total_decisions": rec["total"],
452 "covered_decisions": rec["covered"],
453 "deactivated_decisions": rec["deactivated"],
454 }
455 for src, rec in sorted(per_file.items())
456 ]
457 with contextlib.suppress(OSError):
458 per_file_json.write_text(
459 json.dumps({"files": per_file_entries}, indent=2) + "\n",
460 encoding="ascii",
461 )