4"""Regenerate the MC/DC gap tables from the live llvm-cov report.
6Rewrites docs/MCDC_GAPS.csv and the summary header of docs/MCDC_GAPS.md from
7build/mcdc-report/mcdc.txt + summary.txt.
9Each row carries a `deactivated` boolean classifying whether the
10remaining uncovered MC/DC condition is reachable through the public
11API or whether it is a defensive guard already enforced by an upstream
12check (per DO-178C 6.4.4.3, "deactivated code"). Detection is
13heuristic but conservative -- see `is_deactivated_decision()`.
15Source of truth: actual llvm-cov per-decision output. NOT a static parse
16of the source tree, NOT a heuristic match against test_mcdc_* function
17names. The previous CSV regenerator used heuristics and went stale; this
18one parses the same report `just quality::local::mcdc` emits.
20A decision is reported when llvm-cov shows it as < 100% MC/DC. The
23 source_file,line,condition_count,function_name,decision_excerpt,covered
25where `covered` is one of:
26 - "no" -- 0.00% MC/DC for that decision
27 - "partial" -- 0 < pct < 100
28 - "yes" -- 100% (omitted from CSV; CSV is gap-only)
30Verification: for any module, the number of CSV rows equals the count
31of `MC/DC Coverage for Decision: <pct>%` blocks in mcdc.txt where the
32percentage is < 100, restricted to that module's source files.
34Copyright (c) 2026 Brighton Sikarskie
35SPDX-License-Identifier: MIT
38from __future__
import annotations
42from collections.abc
import Iterator
43from pathlib
import Path
45sys.path.insert(0, str(Path(__file__).resolve().parent))
50SCRIPT_DIR = Path(__file__).resolve().parent
51REPO_ROOT = SCRIPT_DIR.parent.parent
52MCDC_TXT = REPO_ROOT /
"build" /
"mcdc-report" /
"mcdc.txt"
53CSV_OUT = REPO_ROOT /
"docs" /
"MCDC_GAPS.csv"
54MD_OUT = REPO_ROOT /
"docs" /
"MCDC_GAPS.md"
55DEACT_MD_OUT = REPO_ROOT /
"docs" /
"MCDC_DEACTIVATIONS.md"
65EXCERPT_MAX_REACHABLE = 80
67EXCERPT_TRUNC_REACHABLE = 77
69EXCERPT_MAX_DEACTIVATED = 60
71EXCERPT_TRUNC_DEACTIVATED = 57
76def _truncate_md_cell(text: str, limit: int, trunc: int) -> str:
77 """Truncate `text` for a Markdown table cell, keeping backticks balanced.
79 Rationale/excerpt strings carry backtick code spans; a truncation that
80 cuts between a span's opening and closing backtick leaves an odd count,
81 which opens a verbatim block that swallows the rest of the page (and
82 trips doxygen's "still searching closing backtick" warning). When the
83 truncated cell holds an odd number of backticks, close the span.
86 text = text[:trunc] +
"..."
87 if text.count(
"`") % 2 == 1:
95LINE_RE = re.compile(
r"^\s*(\d+)\|\s*[^|]*\|(.*)$")
103FILE_HEADER_RE = re.compile(
r"^([^\s:][^:]*\.(?:c|h|cpp|hpp)):\s*$")
106def _repo_relative(path: str) -> str:
107 """Convert an absolute build-time source path to a repo-relative POSIX one.
109 Stripping REPO_ROOT is deterministic and location-independent: CMake
110 compiles with absolute source paths rooted at the tree the script itself
111 lives in, so REPO_ROOT is exactly that prefix in every environment
112 (`/work` in the devcontainer, the clone dir on a bare checkout, the runner
113 workspace in CI). The `/work` and first-party-root fallbacks only guard the
114 unlikely case of a symlinked or relocated object path.
116 p = path.replace(
"\\",
"/")
117 root = str(REPO_ROOT).replace(
"\\",
"/").rstrip(
"/") +
"/"
118 if p.startswith(root):
119 return p[len(root) :]
121 return p.split(
"/work/", 1)[1]
122 m = re.search(
r"(?:^|/)((?:libs|port|examples|tests)/.+)$", p)
123 return m.group(1)
if m
else p
126DECISION_HDR_RE = re.compile(
r"\|---> MC/DC Decision Region \((\d+):\d+\) to \(\d+:\d+\)")
127COND_COUNT_RE = re.compile(
r"\|\s+Number of Conditions:\s+(\d+)")
128PCT_RE = re.compile(
r"\|\s+MC/DC Coverage for Decision:\s+([0-9.]+)%")
131def parse_mcdc_txt(path: Path) -> Iterator[tuple[str, int, int, str, float]]:
132 """Yield (rel_path, line, cond_count, source_excerpt, pct_float).
134 Only emits one record per decision. The source excerpt is taken from
135 the numbered listing at `line` in the same per-file section.
137 with path.open(
"r", encoding=
"utf-8", errors=
"replace")
as fh:
138 lines = fh.readlines()
142 source_by_line: dict[int, str] = {}
145 dec_cond_count =
None
149 m = FILE_HEADER_RE.match(raw)
151 cur_file = _repo_relative(m.group(1))
155 dec_cond_count =
None
159 m = DECISION_HDR_RE.search(raw)
162 dec_line = int(m.group(1))
163 dec_cond_count =
None
167 m = COND_COUNT_RE.search(raw)
169 dec_cond_count = int(m.group(1))
171 m = PCT_RE.search(raw)
172 if m
and cur_file
is not None and dec_line
is not None:
173 pct = float(m.group(1))
174 src = source_by_line.get(dec_line,
"").strip()
175 yield (cur_file, dec_line, dec_cond_count
or 0, src, pct)
178 dec_cond_count =
None
183 m = LINE_RE.match(raw)
184 if m
and cur_file
is not None:
190 source_by_line.setdefault(ln, src)
197FUNC_DEF_RE = re.compile(
r"^[A-Za-z_][\w\s\*\(\),:<>]*?\b([A-Za-z_]\w*)\s*\([^;]*?\)\s*\{?\s*$")
200class _CommentStripper:
201 """Blank comments and string literals, one line at a time.
203 Crude by design and stateful across lines, because the caller is walking
204 the file to track brace depth and needs every line in order. A real parse
205 would be better, but this runs over llvm-cov output for files that may not
206 even compile in isolation.
209 def __init__(self) -> None:
210 self.in_block =
False
212 def strip(self, line: str) -> str:
213 """Return ``line`` with comments and string literals removed."""
215 end = line.find(
"*/")
218 line = line[end + 2 :]
219 self.in_block =
False
224 e = line.find(
"*/", s + 2)
229 line = line[:s] + line[e + 2 :]
233 return re.sub(
r'"(?:\\.|[^"\\])*"',
'""', line)
236def _function_signature(line: str) -> str |
None:
237 """Return the function name if ``line`` opens a definition at file scope.
239 Heuristic: an identifier followed by a parameter list, rejecting the
240 control-flow keywords that have the same shape.
242 stripped = line.strip()
243 if stripped.startswith((
"if",
"while",
"for",
"switch",
"return",
"do",
"}")):
245 m = FUNC_DEF_RE.match(stripped)
246 return m.group(1)
if m
else None
249def _track_braces(line: str, depth: int, pending: str |
None, stack: list) -> int:
250 """Advance brace depth over ``line``, pushing/popping the function stack."""
253 if depth == 0
and pending
is not None:
254 stack.append((pending, depth))
257 depth = max(depth - 1, 0)
258 if stack
and depth <= stack[-1][1]:
263def resolve_function(rel_path: str, target_line: int) -> str:
264 """Best-effort function name lookup for a decision at ``target_line``.
266 Returns "(file scope)" when the decision is not inside a function (e.g. a
267 file-scope initializer), or when the file cannot be read.
269 abs_path = REPO_ROOT / rel_path
270 if not abs_path.exists():
271 return "(file scope)"
273 text = abs_path.read_text(encoding=
"utf-8", errors=
"replace")
275 return "(file scope)"
277 stripper = _CommentStripper()
279 pending: str |
None =
None
280 stack: list[tuple[str, int]] = []
281 for i, raw
in enumerate(text.splitlines(), start=1):
282 line = stripper.strip(raw)
284 pending = _function_signature(line)
or pending
285 depth = _track_braces(line, depth, pending, stack)
287 return stack[-1][0]
if stack
else "(file scope)"
289 return "(file scope)"
308NULL_TOKEN_RE = re.compile(
r"\(\s*([A-Za-z_]\w*(?:->\w+|\.\w+)?)\s*==\s*(?:NULL|nullptr|0)\s*\)")
309GUARD_NULL_RE = re.compile(
310 r"RA8_CHECK_NULL_PTR\s*\(\s*([A-Za-z_]\w*)|"
311 r"if\s*\(\s*([A-Za-z_]\w*)\s*==\s*(?:NULL|nullptr)\s*\)"
313LEN_NULL_PAIR_RE = re.compile(
314 r"\(\s*([A-Za-z_]\w*)\s*==\s*(?:NULL|nullptr)\s*\)\s*&&\s*"
315 r"\(\s*([A-Za-z_]\w*_len)\s*!=\s*0"
317DEFENSIVE_OFF_RE = re.compile(
r"\boff\s*<\s*sizeof\s*\(")
318FUNC_BODY_BRACE_RE = re.compile(
r"\b([A-Za-z_]\w*)\s*\([^;]*?\)\s*\{?\s*$")
323STRUCT_REDUNDANT_RE = re.compile(
324 r"([A-Za-z_]\w*(?:\[\d+\]|->\w+|\.\w+)?)\s*!=\s*('[^']+'|\"[^\"]+\"|[A-Za-z0-9_]+)\s*"
325 r"\|\|\s*\(\s*\1\s*==\s*\2\s*&&"
333SEGLEN_BOUND_RE = re.compile(
334 r"\b(seg_?len|len|sec_?len)\s*<\s*\d+U?\s*\|\|\s*\(?\s*\(?[A-Za-z_]\w*\s*\)?\s*\1?\s*>\s*\w+->\w+\s*-\s*\w+->\w+"
341ENUM_OR_SET_RE = re.compile(
342 r"\(?\s*([A-Za-z_]\w*)\s*==\s*[A-Za-z_][\w]*\s*\)?\s*\|\|"
343 r"\s*\(?\s*\1\s*==\s*[A-Za-z_][\w]*\s*\)?\s*\|\|"
344 r"\s*\(?\s*\1\s*==\s*[A-Za-z_][\w]*\s*\)?\s*\|\|"
345 r"\s*\(?\s*\1\s*==\s*[A-Za-z_][\w]*\s*\)?"
349def _function_body_lines(rel_path: str, target_line: int) -> list[str]:
350 """Source lines from the enclosing function's start up to ``target_line``.
352 Excludes the target line itself, since the caller is looking for what
353 guards the decision, not the decision. Returns an empty list when no
354 enclosing function can be identified.
356 abs_path = REPO_ROOT / rel_path
357 if not abs_path.exists():
360 text = abs_path.read_text(encoding=
"utf-8", errors=
"replace")
363 lines = text.splitlines()
367 for i, raw
in enumerate(lines, start=1):
378 if func_start
is not None:
379 return lines[func_start - 1 : target_line - 1]
383 if func_start
is not None:
384 return lines[func_start - 1 : target_line - 1]
389PRIV_NULL_OR_RE = re.compile(
390 r"[A-Za-z_]\w*\s*==\s*(?:NULL|nullptr)\s*\|\|\s*"
391 r"[A-Za-z_]\w*(?:\.\w+|->\w+)?\s*==\s*(?:NULL|nullptr|0U?|'\\0')"
395def _enclosing_static_priv_name(rel_path: str, target_line: int) -> str |
None:
396 """Name of the enclosing function iff it is TU-local, else None.
398 TU-local means declared ``static`` and named by the project's private
399 convention (``priv_*`` or ``internal_*``), or inside a C++ anonymous
400 namespace, which is the C++ equivalent scope.
402 These helpers are called only from inside the TU; their NULL
403 guards are defensive contract-checks duplicating the public-API
404 guard at the entry point.
406 abs_path = REPO_ROOT / rel_path
407 if not abs_path.exists():
410 text = abs_path.read_text(encoding=
"utf-8", errors=
"replace")
413 lines = text.splitlines()
418 func_is_local =
False
419 for i, raw
in enumerate(lines, start=1):
421 return func_name
if func_is_local
else None
422 stripped = raw.strip()
424 if depth == 0
and re.match(
r"^namespace\s*\{", stripped):
429 check_depth = 1
if in_anon_ns
else 0
430 if depth == check_depth:
431 m = FUNC_DEF_RE.match(stripped)
432 if m
and not stripped.startswith((
"if",
"while",
"for",
"switch",
"return",
"do",
"}")):
434 start = max(0, i - 5)
435 window =
" ".join(lines[start:i])
436 is_static_priv =
"static" in window
and (cand.startswith((
"priv_",
"internal_")))
437 is_anon_ns = in_anon_ns
and depth == 1
438 if is_static_priv
or is_anon_ns:
443 func_is_local =
False
449 depth = max(depth, 0)
452 if in_anon_ns
and depth <= anon_ns_depth:
455 if depth <= (1
if in_anon_ns
else 0):
457 func_is_local =
False
461def _line_annotation(rel_path: str, line: int) -> str |
None:
462 """Rationale from an ``mcdc-deactivated:`` annotation, or None.
464 Accepts the annotation on the decision's own line or the one directly
465 above it, so it can be written wherever it reads best.
467 Two recognized syntaxes (case-insensitive):
468 * `... // mcdc-deactivated: <rationale>` on the decision line.
469 * `// mcdc-deactivated: <rationale>` on the line immediately above.
471 abs_path = REPO_ROOT / rel_path
472 if not abs_path.exists():
475 text = abs_path.read_text(encoding=
"utf-8", errors=
"replace")
478 src_lines = text.splitlines()
479 if line - 1 >= len(src_lines):
481 pat = re.compile(
r"mcdc-deactivated\s*:\s*(.+?)\s*(?:\*/|$)", re.IGNORECASE)
483 m = pat.search(src_lines[line - 1])
485 return m.group(1).strip()
488 m = pat.search(src_lines[line - 2])
490 return m.group(1).strip()
502_TEXTUAL_DEACTIVATION_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
509 "Defensive null+len contract: (ptr == NULL) && (len != 0)"
510 " is rejected upstream by the public-API @pre clause.",
517 "Defensive scratch-buffer bound: input length is capped"
518 " by the public-API contract; second condition unreachable.",
527 "Structurally-redundant condition: `x == V` inside the"
528 " second clause is the negation of the first OR-clause's"
529 " `x != V` and cannot be flipped independently.",
537 "Exhaustive enum-set OR: 4-way mode equality. The"
538 " all-false MC/DC vector requires an out-of-range enum"
539 " value, which is rejected by an upstream enum guard.",
546 "Defensive segment-length bound in a bounded parser:"
547 " buffer length is contract-validated upstream; the"
548 " malformed-input branch is exempted under DO-178C 6.4.4.3.",
553def _deactivated_by_priv_null(rel_path: str, line: int, excerpt: str) -> str |
None:
554 """Rationale when this is a NULL guard inside a TU-local static helper.
556 Project convention: such helpers are only called from inside the same TU,
557 where the public-API entry point has already validated every pointer via
558 RA8_CHECK_NULL_PTR. The null guard is defensive duplication, so the
559 all-NULL MC/DC vector is rejected upstream.
561 Needs the enclosing function's name, which the excerpt does not carry --
562 which is why this is a function and not a row in the table above.
564 if not PRIV_NULL_OR_RE.search(excerpt):
566 fname = _enclosing_static_priv_name(rel_path, line)
570 f
"TU-local static helper `{fname}` -- defensive NULL"
571 " guard duplicates the public-API entry-point check,"
572 " which has already rejected NULL on every reachable"
577def _deactivated_by_upstream_guard(rel_path: str, line: int, excerpt: str) -> str |
None:
578 """Rationale when every pointer here was already null-checked in this function.
580 Reads the enclosing function body looking for an earlier
581 RA8_CHECK_NULL_PTR or `if (p == NULL) return ...` covering EVERY pointer
582 the decision tests. Partial coverage is not enough: one unguarded pointer
583 means the vector is still reachable.
585 null_tokens = [m.group(1).split(
"->")[0].split(
".")[0]
for m
in NULL_TOKEN_RE.finditer(excerpt)]
588 body = _function_body_lines(rel_path, line)
591 text =
"\n".join(body)
592 guards: set[str] = set()
593 for m
in GUARD_NULL_RE.finditer(text):
594 name = m.group(1)
or m.group(2)
597 shadowed = [n
for n
in null_tokens
if n
in guards]
598 if not shadowed
or len(shadowed) != len(null_tokens):
601 f
"Pointer(s) {sorted(set(shadowed))} already null-checked"
602 " upstream in the same function body."
606def is_deactivated_decision(rel_path: str, line: int, excerpt: str) -> tuple[bool, str]:
607 """Classify one decision as deactivated code, with the rationale why.
609 Deliberately conservative: it reports deactivated only on positive
610 evidence (an explicit annotation, or a guard in a TU-local function that
611 an upstream check already enforces). An undecidable decision stays
612 classified as reachable, so the gap count errs toward overstating the
613 work rather than quietly excusing it -- which is the only safe direction
614 under DO-178C 6.4.4.3.
616 Returns ``(deactivated, rationale)``.
619 annot = _line_annotation(rel_path, line)
620 if annot
is not None:
621 return (
True, f
"Annotated deactivation: {annot}")
623 for pattern, rationale
in _TEXTUAL_DEACTIVATION_RULES:
624 if pattern.search(excerpt):
625 return (
True, rationale)
627 for rule
in (_deactivated_by_priv_null, _deactivated_by_upstream_guard):
628 rationale = rule(rel_path, line, excerpt)
629 if rationale
is not None:
630 return (
True, rationale)
638def decision_snippet(excerpt: str, max_chars: int = 40) -> str:
639 """Build a stable text-derived anchor fragment for a decision.
641 Citation policy forbids `file:line` references because line numbers
642 drift on every reformat. Instead we hash the decision into a short,
643 grep-able slug derived from the source text itself: take the first
644 `max_chars` characters of the (whitespace-collapsed) line and
645 replace every run of non-alphanumeric bytes with a single `-`.
648 ' if (a->kind == k_attr_kind_char_value && a->value == NULL)'
649 -> 'a-kind-k_attr_kind_char_value-a-value-NULL'
651 Empty input returns 'unknown'. Result is always pure 7-bit ASCII
652 (project policy) and contains no leading/trailing dashes.
656 text = excerpt.strip()[:max_chars]
657 slug_chars: list[str] = []
660 if ch.isalnum()
or ch ==
"_":
661 slug_chars.append(ch)
664 slug_chars.append(
"-")
666 slug =
"".join(slug_chars).strip(
"-")
667 return slug
or "unknown"
670def module_of(rel_path: str) -> str:
671 """Module name for a source path: the basename with its extension dropped.
673 Groups a header and its implementation under one module, which is what
674 makes the per-module gap counts add up the way a reader expects.
676 name = Path(rel_path).name
677 if name.endswith(
".c"):
679 elif name.endswith(
".cpp"):
681 elif name.endswith((
".h",
".hpp")):
683 name = re.sub(
r"\.(h|hpp)$",
"", name)
692 """Rebuild the MC/DC gap tables from the live coverage report.
694 Refuses to run when build/mcdc-report/mcdc.txt is absent rather than
695 emitting empty tables: a zero-gap report generated from no data would
696 read exactly like full MC/DC coverage.
698 Reads the LIVE report every time and never merges with the existing CSV,
699 so a decision that has since been covered disappears from the tables
700 instead of lingering as a stale row.
702 Returns 1 when the report is missing, 0 after a successful regeneration.
704 if not MCDC_TXT.exists():
706 f
"error: {MCDC_TXT} not found. Run `just quality::local::mcdc` first to generate"
707 " the live llvm-cov report.",
713 all_decisions: list[tuple[str, int, int, str, float]] = list(parse_mcdc_txt(MCDC_TXT))
715 all_decisions = [d
for d
in all_decisions
if "/third_party/" not in d[0]]
718 gap_rows = [d
for d
in all_decisions
if d[4] < MCDC_FULL_PCT]
719 gap_rows.sort(key=
lambda r: (r[0], r[1]))
722 classified: list[tuple[str, int, int, str, str, str, bool, str]] = []
723 for src, ln, n, excerpt, pct
in gap_rows:
724 covered =
"no" if pct == MCDC_ZERO_PCT
else "partial"
725 func = resolve_function(src, ln)
726 deact, rationale = is_deactivated_decision(src, ln, excerpt)
727 classified.append((src, ln, n, func, excerpt, covered, deact, rationale))
733 mcdc_report.write_gap_csv(classified)
734 h = mcdc_report.headline(all_decisions, classified)
735 mcdc_report.write_markdown(mcdc_report.module_rows(all_decisions), h)
736 mcdc_report.write_deactivations(h)
737 mcdc_report.write_gate_json(h)
738 mcdc_report.write_per_file_json(all_decisions, classified)
740 gap_rows = [d
for d
in all_decisions
if d[4] < MCDC_FULL_PCT]
742 f
"Wrote {CSV_OUT.relative_to(REPO_ROOT)} ({len(gap_rows)} gap decision rows;"
743 f
" {h['deact_count']} deactivated,"
744 f
" {len(h['reachable_rows'])} reachable),"
745 f
" {MD_OUT.relative_to(REPO_ROOT)},"
746 f
" {DEACT_MD_OUT.relative_to(REPO_ROOT)}."
747 f
" Decision-complete rate: {h['decision_complete_rate']:.2f}%;"
748 f
" reachable decision-complete rate: {h['reachable_rate']:.2f}%."
753if __name__ ==
"__main__":
void main(void)
The application entry point Reset_Handler hands control to.