3"""The five artefacts the MC/DC audit emits, and the counts they share.
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.
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.
16from __future__
import annotations
21from collections
import defaultdict
23from regen_mcdc_gaps
import (
26 EXCERPT_MAX_DEACTIVATED,
27 EXCERPT_MAX_REACHABLE,
28 EXCERPT_TRUNC_DEACTIVATED,
29 EXCERPT_TRUNC_REACHABLE,
42def write_gap_csv(classified: list) ->
None:
43 """Write the gap-only CSV.
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
49 with CSV_OUT.open(
"w", encoding=
"ascii", newline=
"")
as fh:
50 w = csv.writer(fh, lineterminator=
"\n")
54 "decision_text_snippet",
60 "deactivation_rationale",
63 for src, _ln, n, func, excerpt, covered, deact, rationale
in classified:
67 decision_snippet(excerpt),
72 "true" if deact
else "false",
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)
85 for mod, pcts
in per_module.items():
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))
93 rows.sort(key=
lambda r: (-(r[3] + r[4]), -r[1], r[0]))
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
106 deactivated_rows = [r
for r
in classified
if r[6]]
107 reachable_rows = [r
for r
in classified
if not r[6]]
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
118 "total_dec": total_dec,
120 "partial_dec": partial_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,
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")
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."
146 md_lines.append(
"## Methodology")
149 "- Source of truth: `build/mcdc-report/mcdc.txt` (output of `just quality::local::mcdc`)."
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."
156 md_lines.append(
"- Coverage status (`covered` column):")
158 " - `yes` -- llvm-cov reports 100.00% MC/DC for the decision."
159 " Excluded from the CSV (CSV is gap-only)."
162 " - `partial` -- 0 < MC/DC % < 100. The decision was exercised"
163 " but at least one independence pair is missing."
166 " - `no` -- MC/DC % == 0. The decision was never evaluated under instrumentation."
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"]
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")
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}**")
192 "- Decision-complete rate (fully covered decisions / total decisions):"
193 f
" **{decision_complete_rate:.2f}%**"
195 md_lines.append(f
"- Deactivated gap decision regions (DO-178C 6.4.4.3): **{deact_count}**")
197 f
"- Reachable decision-region denominator (total - deactivated): **{reachable_total}**"
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`."
206 "See `docs/MCDC_DEACTIVATIONS.md` for the per-decision deactivation rationale catalog."
212def _md_summary(h: dict) -> list[str]:
213 """Report heading, methodology, and the top-line numbers."""
214 return [*_md_preamble(), *_md_topline(h)]
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)")
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
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)* | |")
235 md_lines.append(
"## Deactivated gaps (DO-178C 6.4.4.3 exempted)")
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`."
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
250 rt = _truncate_md_cell(
251 rationale.replace(
"|",
"\\|"), EXCERPT_MAX_DEACTIVATED, EXCERPT_TRUNC_DEACTIVATED
253 md_lines.append(f
"| {src} | {n} | {func} | `{ex}` | {_escape_md_tags(rt)} |")
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)")
263 md_lines.append(
"Sorted by (uncovered + partial) descending, then total descending.")
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} |")
270 md_lines.append(
"## Top 30 modules with at least one uncovered decision")
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} |")
279 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.*"
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")
297def _deact_preamble() -> list[str]:
298 """The fixed header of the catalog: what it is, the standard, and the policy."""
300 "# MC/DC Deactivated-Decision Catalog",
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.",
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.",
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).",
325 "## Auto-generated entries",
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).",
337def _escape_md_tags(text: str) -> str:
338 """Escape angle brackets outside backtick spans.
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.
345 parts = text.split(
"`")
346 for i
in range(0, len(parts), 2):
347 parts[i] = parts[i].replace(
"<",
"<").replace(
">",
">")
348 return "`".join(parts)
351def _deact_entries(deactivated_rows: list[tuple]) -> list[str]:
352 """One catalog section per deactivated decision, keyed by a stable anchor.
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.
358 if not deactivated_rows:
359 return [
"(no deactivated decision regions detected)",
""]
361 for src, _ln, n, func, excerpt, covered, _deact, rationale
in deactivated_rows:
362 anchor = _escape_md_tags(f
"{src}::{func}::{decision_snippet(excerpt)}")
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"
381def _deact_manual_footer() -> list[str]:
382 """The marker below which hand-written narrative may be added."""
386 "## Manual narrative (per anchor)",
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._",
399def write_deactivations(h: dict) ->
None:
400 """Write the per-decision deactivation catalog (DO-178C 6.4.4.3)."""
403 *_deact_entries(h[
"deactivated_rows"]),
404 *_deact_manual_footer(),
406 DEACT_MD_OUT.write_text(
"\n".join(deact_lines), encoding=
"ascii")
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(
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'
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}
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:
445 per_file[row[0]][
"deactivated"] += 1
447 per_file_json = REPO_ROOT /
"build" /
"mcdc-report" /
"mcdc_per_file.json"
451 "total_decisions": rec[
"total"],
452 "covered_decisions": rec[
"covered"],
453 "deactivated_decisions": rec[
"deactivated"],
455 for src, rec
in sorted(per_file.items())
457 with contextlib.suppress(OSError):
458 per_file_json.write_text(
459 json.dumps({
"files": per_file_entries}, indent=2) +
"\n",