ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
regen_mcdc_gaps.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"""Regenerate the MC/DC gap tables from the live llvm-cov report.
5
6Rewrites docs/MCDC_GAPS.csv and the summary header of docs/MCDC_GAPS.md from
7build/mcdc-report/mcdc.txt + summary.txt.
8
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()`.
14
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.
19
20A decision is reported when llvm-cov shows it as < 100% MC/DC. The
21columns are:
22
23 source_file,line,condition_count,function_name,decision_excerpt,covered
24
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)
29
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.
33
34Copyright (c) 2026 Brighton Sikarskie
35SPDX-License-Identifier: MIT
36"""
37
38from __future__ import annotations
39
40import re
41import sys
42from collections.abc import Iterator
43from pathlib import Path
44
45sys.path.insert(0, str(Path(__file__).resolve().parent))
46
47# ---------------------------------------------------------------------------
48# Locations
49# ---------------------------------------------------------------------------
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"
56
57# ---------------------------------------------------------------------------
58# Coverage thresholds and table display limits
59# ---------------------------------------------------------------------------
60# MC/DC percentage indicating full decision coverage (llvm-cov scale 0..100).
61MCDC_FULL_PCT = 100.0
62# MC/DC percentage indicating zero coverage for a decision.
63MCDC_ZERO_PCT = 0.0
64# Maximum character width of an excerpt cell in the reachable-gaps table.
65EXCERPT_MAX_REACHABLE = 80
66# Truncated excerpt length (EXCERPT_MAX_REACHABLE - len("...")).
67EXCERPT_TRUNC_REACHABLE = 77
68# Maximum character width of excerpt/rationale cells in the deactivated table.
69EXCERPT_MAX_DEACTIVATED = 60
70# Truncated excerpt/rationale length (EXCERPT_MAX_DEACTIVATED - len("...")).
71EXCERPT_TRUNC_DEACTIVATED = 57
72# Maximum number of rows shown inline in the reachable-gaps markdown table.
73TABLE_ROW_CAP = 60
74
75
76def _truncate_md_cell(text: str, limit: int, trunc: int) -> str:
77 """Truncate `text` for a Markdown table cell, keeping backticks balanced.
78
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.
84 """
85 if len(text) > limit:
86 text = text[:trunc] + "..."
87 if text.count("`") % 2 == 1:
88 text += "`"
89 return text
90
91
92# Lines in mcdc.txt look like:
93# " 385| 0| if ((start == 0U) || (start > end)) {"
94# i.e. <pad><lineno>|<exec_count>|<source>
95LINE_RE = re.compile(r"^\s*(\d+)\|\s*[^|]*\|(.*)$")
96
97# llvm-cov's `show -format=text` prints each file's path followed by ':'.
98# The path is absolute and reflects wherever the tree was built -- `/work/...`
99# in the devcontainer, `/home/<user>/<clone>/...` on a bare Linux checkout, a
100# self-hosted-runner workspace in CI, etc. Capture the whole path here and
101# relativize it against REPO_ROOT in `_repo_relative()`; a naive
102# "first-party-root" regex mis-fires on the nested `src` in `libs/<grp>/src/`.
103FILE_HEADER_RE = re.compile(r"^([^\s:][^:]*\.(?:c|h|cpp|hpp)):\s*$")
104
105
106def _repo_relative(path: str) -> str:
107 """Convert an absolute build-time source path to a repo-relative POSIX one.
108
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.
115 """
116 p = path.replace("\\", "/")
117 root = str(REPO_ROOT).replace("\\", "/").rstrip("/") + "/"
118 if p.startswith(root):
119 return p[len(root) :]
120 if "/work/" in p:
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
124
125
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.]+)%")
129
130
131def parse_mcdc_txt(path: Path) -> Iterator[tuple[str, int, int, str, float]]:
132 """Yield (rel_path, line, cond_count, source_excerpt, pct_float).
133
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.
136 """
137 with path.open("r", encoding="utf-8", errors="replace") as fh:
138 lines = fh.readlines()
139
140 cur_file = None
141 # source_by_line[lineno] -> source text (per current file)
142 source_by_line: dict[int, str] = {}
143 in_decision = False
144 dec_line = None
145 dec_cond_count = None
146
147 for raw in lines:
148 # File transition
149 m = FILE_HEADER_RE.match(raw)
150 if m:
151 cur_file = _repo_relative(m.group(1))
152 source_by_line = {}
153 in_decision = False
154 dec_line = None
155 dec_cond_count = None
156 continue
157
158 # Decision header
159 m = DECISION_HDR_RE.search(raw)
160 if m:
161 in_decision = True
162 dec_line = int(m.group(1))
163 dec_cond_count = None
164 continue
165
166 if in_decision:
167 m = COND_COUNT_RE.search(raw)
168 if m:
169 dec_cond_count = int(m.group(1))
170 continue
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)
176 in_decision = False
177 dec_line = None
178 dec_cond_count = None
179 continue
180 continue
181
182 # Source line in numbered listing
183 m = LINE_RE.match(raw)
184 if m and cur_file is not None:
185 ln = int(m.group(1))
186 # The source after the second '|' may have a leading ' ' the
187 # split swallowed; preserve content as-is.
188 src = m.group(2)
189 # First occurrence wins (some lines repeat in expansion contexts).
190 source_by_line.setdefault(ln, src)
191
192
193# ---------------------------------------------------------------------------
194# Function-name resolver: walk the source file's brace structure to find
195# the innermost function definition enclosing a given line.
196# ---------------------------------------------------------------------------
197FUNC_DEF_RE = re.compile(r"^[A-Za-z_][\w\s\*\‍(\‍),:<>]*?\b([A-Za-z_]\w*)\s*\‍([^;]*?\‍)\s*\{?\s*$")
198
199
200class _CommentStripper:
201 """Blank comments and string literals, one line at a time.
202
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.
207 """
208
209 def __init__(self) -> None:
210 self.in_block = False
211
212 def strip(self, line: str) -> str:
213 """Return ``line`` with comments and string literals removed."""
214 if self.in_block:
215 end = line.find("*/")
216 if end == -1:
217 return ""
218 line = line[end + 2 :]
219 self.in_block = False
220 while True:
221 s = line.find("/*")
222 if s == -1:
223 break
224 e = line.find("*/", s + 2)
225 if e == -1:
226 line = line[:s]
227 self.in_block = True
228 break
229 line = line[:s] + line[e + 2 :]
230 s = line.find("//")
231 if s != -1:
232 line = line[:s]
233 return re.sub(r'"(?:\\.|[^"\\])*"', '""', line)
234
235
236def _function_signature(line: str) -> str | None:
237 """Return the function name if ``line`` opens a definition at file scope.
238
239 Heuristic: an identifier followed by a parameter list, rejecting the
240 control-flow keywords that have the same shape.
241 """
242 stripped = line.strip()
243 if stripped.startswith(("if", "while", "for", "switch", "return", "do", "}")):
244 return None
245 m = FUNC_DEF_RE.match(stripped)
246 return m.group(1) if m else None
247
248
249def _track_braces(line: str, depth: int, pending: str | None, stack: list) -> int:
250 """Advance brace depth over ``line``, pushing/popping the function stack."""
251 for ch in line:
252 if ch == "{":
253 if depth == 0 and pending is not None:
254 stack.append((pending, depth))
255 depth += 1
256 elif ch == "}":
257 depth = max(depth - 1, 0)
258 if stack and depth <= stack[-1][1]:
259 stack.pop()
260 return depth
261
262
263def resolve_function(rel_path: str, target_line: int) -> str:
264 """Best-effort function name lookup for a decision at ``target_line``.
265
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.
268 """
269 abs_path = REPO_ROOT / rel_path
270 if not abs_path.exists():
271 return "(file scope)"
272 try:
273 text = abs_path.read_text(encoding="utf-8", errors="replace")
274 except OSError:
275 return "(file scope)"
276
277 stripper = _CommentStripper()
278 depth = 0
279 pending: str | None = None
280 stack: list[tuple[str, int]] = [] # (name, depth_when_opened)
281 for i, raw in enumerate(text.splitlines(), start=1):
282 line = stripper.strip(raw)
283 if depth == 0:
284 pending = _function_signature(line) or pending
285 depth = _track_braces(line, depth, pending, stack)
286 if i == target_line:
287 return stack[-1][0] if stack else "(file scope)"
288
289 return "(file scope)"
290
291
292# ---------------------------------------------------------------------------
293# Deactivated-condition classifier.
294#
295# Heuristic: a decision condition is "deactivated" (DO-178C 6.4.4.3)
296# when the same function contains an earlier guard (RA8_CHECK_NULL_PTR,
297# `if (p == NULL) return ...`, RA8_CHECK_RANGE, etc.) that makes the
298# condition unreachable on the public-API path. We classify a whole
299# decision as deactivated only when EVERY pointer/null/range token in
300# the decision is shadowed by an earlier guard on the same name in the
301# same function body.
302#
303# Conservative defaults: when in doubt, mark `reachable` (deactivated
304# = False). False negatives only mean a decision stays in the
305# "reachable" bucket and continues to demand a real test vector --
306# never the other way round.
307# ---------------------------------------------------------------------------
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*\‍)"
312)
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"
316)
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*$")
319# Pattern: `x != V || (x == V && y != Z)` -- the second clause's first
320# condition (`x == V`) is structurally `!(x != V)` so it can never be
321# true when the OR's first condition was false. llvm-cov still counts
322# it as a third condition, but no vector can independently flip it.
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*&&"
326)
327# Pattern: `len < N || (uintXX_t)len > buf - cursor` -- segment-length
328# corruption guard inside a bounded-input parser. The buffer is bounded
329# by the public-API contract (`xx_decode(buf, len)` validates `buf`,
330# `len`, and the segment length is parsed from `buf` itself), so the
331# second condition only fires on a deliberately-corrupted input that
332# the upstream API contract documents as undefined.
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+"
335)
336# Pattern: 4-condition OR over enum equality of a single variable
337# `(x == E1 || x == E2 || x == E3 || x == E4)` -- exhaustive enum-set
338# membership. MC/DC requires every condition to independently flip the
339# decision; the only way to make all-false is `x` outside the set,
340# which is structurally rejected by an upstream enum-validation guard.
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*\‍)?"
346)
347
348
349def _function_body_lines(rel_path: str, target_line: int) -> list[str]: # noqa: PLR0911 # multiple early returns for distinct error/sentinel paths
350 """Source lines from the enclosing function's start up to ``target_line``.
351
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.
355 """
356 abs_path = REPO_ROOT / rel_path
357 if not abs_path.exists():
358 return []
359 try:
360 text = abs_path.read_text(encoding="utf-8", errors="replace")
361 except OSError:
362 return []
363 lines = text.splitlines()
364 # Walk forward; remember the last `{` at depth 0->1 transition.
365 depth = 0
366 func_start = None
367 for i, raw in enumerate(lines, start=1):
368 raw.strip()
369 for ch in raw:
370 if ch == "{":
371 if depth == 0:
372 func_start = i
373 depth += 1
374 elif ch == "}":
375 depth -= 1
376 if depth == 0:
377 if i >= target_line:
378 if func_start is not None:
379 return lines[func_start - 1 : target_line - 1]
380 return []
381 func_start = None
382 if i == target_line:
383 if func_start is not None:
384 return lines[func_start - 1 : target_line - 1]
385 return []
386 return []
387
388
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')"
392)
393
394
395def _enclosing_static_priv_name(rel_path: str, target_line: int) -> str | None: # noqa: PLR0912 # parser/gate dispatch, splitting hurts readability
396 """Name of the enclosing function iff it is TU-local, else None.
397
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.
401
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.
405 """
406 abs_path = REPO_ROOT / rel_path
407 if not abs_path.exists():
408 return None
409 try:
410 text = abs_path.read_text(encoding="utf-8", errors="replace")
411 except OSError:
412 return None
413 lines = text.splitlines()
414 depth = 0
415 in_anon_ns = False
416 anon_ns_depth = -1
417 func_name = None
418 func_is_local = False
419 for i, raw in enumerate(lines, start=1):
420 if i == target_line:
421 return func_name if func_is_local else None
422 stripped = raw.strip()
423 # Detect anonymous-namespace open at depth 0 (`namespace {`).
424 if depth == 0 and re.match(r"^namespace\s*\{", stripped):
425 in_anon_ns = True
426 anon_ns_depth = 0
427 # Detect candidate function signature at the appropriate depth.
428 # In C: depth 0. In an anon C++ namespace: depth 1.
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", "}")):
433 cand = m.group(1)
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:
439 func_name = cand
440 func_is_local = True
441 else:
442 func_name = cand
443 func_is_local = False
444 for ch in raw:
445 if ch == "{":
446 depth += 1
447 elif ch == "}":
448 depth -= 1
449 depth = max(depth, 0)
450 # If we close back to (or below) the anon-ns opening
451 # depth, we have left the namespace.
452 if in_anon_ns and depth <= anon_ns_depth:
453 in_anon_ns = False
454 anon_ns_depth = -1
455 if depth <= (1 if in_anon_ns else 0):
456 func_name = None
457 func_is_local = False
458 return None
459
460
461def _line_annotation(rel_path: str, line: int) -> str | None:
462 """Rationale from an ``mcdc-deactivated:`` annotation, or None.
463
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.
466
467 Two recognized syntaxes (case-insensitive):
468 * `... // mcdc-deactivated: <rationale>` on the decision line.
469 * `// mcdc-deactivated: <rationale>` on the line immediately above.
470 """
471 abs_path = REPO_ROOT / rel_path
472 if not abs_path.exists():
473 return None
474 try:
475 text = abs_path.read_text(encoding="utf-8", errors="replace")
476 except OSError:
477 return None
478 src_lines = text.splitlines()
479 if line - 1 >= len(src_lines):
480 return None
481 pat = re.compile(r"mcdc-deactivated\s*:\s*(.+?)\s*(?:\*/|$)", re.IGNORECASE)
482 # Same-line annotation
483 m = pat.search(src_lines[line - 1])
484 if m:
485 return m.group(1).strip()
486 # Previous-line annotation
487 if line - 2 >= 0:
488 m = pat.search(src_lines[line - 2])
489 if m:
490 return m.group(1).strip()
491 return None
492
493
494# Rules that decide from the decision TEXT alone: a regex, and the rationale
495# recorded when it matches. Data, not control flow -- five sequential
496# `if RE.search(excerpt): return (True, "...")` blocks said nothing that this
497# table does not, and hid how many rules there are. Order is preserved: the
498# first match wins, exactly as the if-cascade did.
499#
500# The two rules NOT in this table are the ones that cannot decide from the
501# excerpt: they have to read the surrounding function, so they are functions.
502_TEXTUAL_DEACTIVATION_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
503 # ((p == NULL) && (p_len != 0)) -- a defensive contract check. The public
504 # API documents the contract (caller-side @pre); the only path that
505 # exercises the AND-chain's second condition is a deliberately malformed
506 # call that the public API rejects upstream.
507 (
508 LEN_NULL_PAIR_RE,
509 "Defensive null+len contract: (ptr == NULL) && (len != 0)"
510 " is rejected upstream by the public-API @pre clause.",
511 ),
512 # `for (...; off < sizeof(buf); ...)` defensive bound. The fixed-size
513 # scratch buffer is sized to the documented input caps; the bound
514 # condition can only flip if the caller violates the contract.
515 (
516 DEFENSIVE_OFF_RE,
517 "Defensive scratch-buffer bound: input length is capped"
518 " by the public-API contract; second condition unreachable.",
519 ),
520 # Structurally-redundant `x != V || (x == V && ...)` where the second
521 # clause's leading condition is the negation of the first. llvm-cov counts
522 # the inner equality as a separate condition, but no vector can flip it
523 # independently of the OR's leading inequality. The remaining two
524 # conditions ARE testable (and are tested).
525 (
526 STRUCT_REDUNDANT_RE,
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.",
530 ),
531 # 4-condition exhaustive-enum OR set membership.
532 # `(x==E1)||(x==E2)||(x==E3)||(x==E4)` with upstream enum validation.
533 # MC/DC's all-false vector requires `x` outside the enum range, which is
534 # rejected before this decision.
535 (
536 ENUM_OR_SET_RE,
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.",
540 ),
541 # Segment-length corruption guard inside a bounded parser. The buffer
542 # length is contract-validated by the public API; the second clause only
543 # fires on intentionally-malformed input, documented as undefined.
544 (
545 SEGLEN_BOUND_RE,
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.",
549 ),
550)
551
552
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.
555
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.
560
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.
563 """
564 if not PRIV_NULL_OR_RE.search(excerpt):
565 return None
566 fname = _enclosing_static_priv_name(rel_path, line)
567 if fname is None:
568 return None
569 return (
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"
573 " call path."
574 )
575
576
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.
579
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.
584 """
585 null_tokens = [m.group(1).split("->")[0].split(".")[0] for m in NULL_TOKEN_RE.finditer(excerpt)]
586 if not null_tokens:
587 return None
588 body = _function_body_lines(rel_path, line)
589 if not body:
590 return None
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)
595 if name:
596 guards.add(name)
597 shadowed = [n for n in null_tokens if n in guards]
598 if not shadowed or len(shadowed) != len(null_tokens):
599 return None
600 return (
601 f"Pointer(s) {sorted(set(shadowed))} already null-checked"
602 " upstream in the same function body."
603 )
604
605
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.
608
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.
615
616 Returns ``(deactivated, rationale)``.
617 """
618 # Explicit per-line opt-in annotation outranks every inferred rule.
619 annot = _line_annotation(rel_path, line)
620 if annot is not None:
621 return (True, f"Annotated deactivation: {annot}")
622
623 for pattern, rationale in _TEXTUAL_DEACTIVATION_RULES:
624 if pattern.search(excerpt):
625 return (True, rationale)
626
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)
631
632 return (False, "")
633
634
635# ---------------------------------------------------------------------------
636# Module-name extraction: libs/<group>/src/<module>.c -> <module>
637# ---------------------------------------------------------------------------
638def decision_snippet(excerpt: str, max_chars: int = 40) -> str:
639 """Build a stable text-derived anchor fragment for a decision.
640
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 `-`.
646
647 Example:
648 ' if (a->kind == k_attr_kind_char_value && a->value == NULL)'
649 -> 'a-kind-k_attr_kind_char_value-a-value-NULL'
650
651 Empty input returns 'unknown'. Result is always pure 7-bit ASCII
652 (project policy) and contains no leading/trailing dashes.
653 """
654 if not excerpt:
655 return "unknown"
656 text = excerpt.strip()[:max_chars]
657 slug_chars: list[str] = []
658 prev_dash = False
659 for ch in text:
660 if ch.isalnum() or ch == "_":
661 slug_chars.append(ch)
662 prev_dash = False
663 elif not prev_dash:
664 slug_chars.append("-")
665 prev_dash = True
666 slug = "".join(slug_chars).strip("-")
667 return slug or "unknown"
668
669
670def module_of(rel_path: str) -> str:
671 """Module name for a source path: the basename with its extension dropped.
672
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.
675 """
676 name = Path(rel_path).name
677 if name.endswith(".c"):
678 name = name[:-2]
679 elif name.endswith(".cpp"):
680 name = name[:-4]
681 elif name.endswith((".h", ".hpp")):
682 # Drop extension only.
683 name = re.sub(r"\.(h|hpp)$", "", name)
684 return name
685
686
687# ---------------------------------------------------------------------------
688# Main
689
690
691def main() -> int:
692 """Rebuild the MC/DC gap tables from the live coverage report.
693
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.
697
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.
701
702 Returns 1 when the report is missing, 0 after a successful regeneration.
703 """
704 if not MCDC_TXT.exists():
705 print(
706 f"error: {MCDC_TXT} not found. Run `just quality::local::mcdc` first to generate"
707 " the live llvm-cov report.",
708 file=sys.stderr,
709 )
710 return 1
711
712 # Collect every decision (covered or not) from the live report.
713 all_decisions: list[tuple[str, int, int, str, float]] = list(parse_mcdc_txt(MCDC_TXT))
714 # Skip third_party (the report already strips them, but be defensive).
715 all_decisions = [d for d in all_decisions if "/third_party/" not in d[0]]
716
717 # Gap rows = anything < 100%.
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]))
720
721 # Classify each gap row as deactivated or reachable.
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))
728
729 # Imported here, not at module scope: mcdc_report reads this module's
730 # constants and helpers, so a top-level import either way is a cycle.
731 import mcdc_report # noqa: PLC0415 # avoid import cycle
732
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)
739
740 gap_rows = [d for d in all_decisions if d[4] < MCDC_FULL_PCT]
741 print(
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}%."
749 )
750 return 0
751
752
753if __name__ == "__main__":
754 sys.exit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298