ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
mcdc_delta_comment.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"""Render the per-file MC/DC delta comment posted on a pull request.
5
6The PR job builds an MC/DC report for the head commit and, best-effort, one
7for the base branch; this renders the markdown table comparing them.
8
9It exists as a script rather than as inline workflow JavaScript because of the
10failure it is written to prevent. The delta used to be computed by ~70 lines
11of JS inside an ``actions/github-script`` step whose loader treated a missing
12base report as an empty result::
13
14 if (!fs.existsSync(path)) return new Map();
15
16The base build is deliberately best-effort (``continue-on-error``), so when it
17failed every row's delta rendered as ``n/a`` -- and a reviewer saw a posted,
18complete-looking MC/DC delta table and concluded the change was clean, when no
19comparison had been performed at all (#536). That is the gate-honesty defect
20class in its most consequential form: not a gate that silently passes, but one
21that silently produces EVIDENCE nobody checked.
22
23Two changes make that impossible here:
24
25 * **An unavailable base is stated, not implied.** When the base report is
26 missing, empty or explicitly flagged unavailable, the body says so in a
27 banner and emits NO delta table. A reader cannot mistake absence of
28 comparison for absence of regression, because there is no table to
29 misread.
30 * **The logic is in scope for the parity guard.** ``check_ci_parity.py``
31 only inspects workflow steps with a ``run:`` key, so the JS in a ``uses:``
32 step was outside the very guard that exists to stop check logic growing a
33 second home in YAML. Under ``scripts/`` it is back in scope, and it has a
34 ``--selftest``.
35
36Run::
37
38 mcdc_delta_comment.py --pr pr-mcdc/summary.txt --base base-summary.txt \
39 --base-status base-summary.status --out mcdc-delta-comment.md
40 mcdc_delta_comment.py --selftest
41
42Exit 0 on success, 2 on a failed selftest or an unreadable PR report. The PR
43report is required: if THAT is missing the job has nothing to say and must
44fail rather than post an empty comment.
45"""
46
47from __future__ import annotations
48
49import argparse
50import re
51import sys
52import tempfile
53from pathlib import Path
54
55# Marker the workflow uses to find and update its own prior comment.
56COMMENT_MARKER = "<!-- mcdc-delta-comment -->"
57
58# Rows shown, sorted by delta with regressions first, so the comment stays
59# readable on a large change.
60MAX_ROWS = 50
61
62# A percentage column in an llvm-cov summary row.
63PCT_RE = re.compile(r"^[0-9]+(\.[0-9]+)?%$")
64
65# A data row needs at least a path and one measurement column.
66MIN_ROW_COLUMNS = 2
67
68# Header / rule / total lines that are not per-file data.
69SKIP_PREFIXES = ("---", "Filename", "TOTAL")
70
71EXIT_OK = 0
72EXIT_VACUOUS = 2
73
74
75def load_summary(path: Path) -> dict[str, float]:
76 """Parse an llvm-cov summary into ``file -> MC/DC percent``.
77
78 A typical row is ``path/to/file.c 1 2 50.00% 3 4 75.00% ... 12.34%``: the
79 first whitespace-delimited token is the path and the last percent-suffixed
80 token is the MC/DC figure.
81
82 Args:
83 path: Summary file to read.
84
85 Returns:
86 One entry per data row; empty when the file does not exist.
87 """
88 out: dict[str, float] = {}
89 if not path.is_file():
90 return out
91 for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
92 trimmed = line.strip()
93 if not trimmed or trimmed.startswith(SKIP_PREFIXES):
94 continue
95 cols = trimmed.split()
96 if len(cols) < MIN_ROW_COLUMNS:
97 continue
98 pcts = [col for col in cols if PCT_RE.match(col)]
99 if not pcts:
100 continue
101 out[cols[0]] = float(pcts[-1].rstrip("%"))
102 return out
103
104
105def base_is_available(base: dict[str, float], status: str | None) -> bool:
106 """Report whether a real base measurement exists to compare against.
107
108 Args:
109 base: Parsed base summary.
110 status: Contents of the base status file, or None when absent.
111
112 Returns:
113 True only when the base build reported success AND produced rows.
114 Either half alone is insufficient: an empty summary and a failed build
115 are the same thing to a reader, and both must suppress the table.
116 """
117 if status is not None and status.strip() != "ok":
118 return False
119 return bool(base)
120
121
122def _unavailable_body(reason: str, pr_files: int) -> str:
123 """Build the body used when no comparison could be performed.
124
125 Args:
126 reason: Human-readable cause.
127 pr_files: Number of files measured on the PR head.
128
129 Returns:
130 The markdown body, with no delta table.
131 """
132 return (
133 "## MC/DC delta (PR vs base)\n\n"
134 "**No comparison was performed.** The base-branch MC/DC report is "
135 f"unavailable ({reason}).\n\n"
136 f"The PR head measured {pr_files} file(s), but there is nothing to "
137 "diff it against, so this comment states no verdict about "
138 "regressions. It is NOT evidence that MC/DC held.\n\n"
139 "The base build is best-effort; re-run the `MC/DC delta comment` job "
140 "if the delta is needed.\n"
141 )
142
143
144def _table_body(pr: dict[str, float], base: dict[str, float]) -> str:
145 """Build the body containing the per-file delta table.
146
147 Args:
148 pr: Parsed PR-head summary.
149 base: Parsed base-branch summary.
150
151 Returns:
152 The markdown body.
153 """
154 rows = []
155 for name in set(pr) | set(base):
156 before = base.get(name)
157 after = pr.get(name)
158 delta = (after - before) if (before is not None and after is not None) else None
159 rows.append((name, before, after, delta))
160 rows.sort(key=lambda row: float("-inf") if row[3] is None else row[3])
161
162 def fmt(value: float | None) -> str:
163 return "n/a" if value is None else f"{value:.2f}%"
164
165 body = "## MC/DC delta (PR vs base)\n\n"
166 body += f"Showing up to {MAX_ROWS} files sorted by delta (regressions first).\n\n"
167 body += "| file | base | PR | delta |\n|---|---|---|---|\n"
168 shown = rows[:MAX_ROWS]
169 for name, before, after, delta in shown:
170 sign = "" if delta is None or delta <= 0 else "+"
171 body += f"| `{name}` | {fmt(before)} | {fmt(after)} | {sign}{fmt(delta)} |\n"
172 if not shown:
173 body += "| _no rows_ | | | |\n"
174 return body
175
176
177def render(pr: dict[str, float], base: dict[str, float], status: str | None) -> str:
178 """Render the full comment body for the given reports.
179
180 Args:
181 pr: Parsed PR-head summary.
182 base: Parsed base-branch summary.
183 status: Contents of the base status file, or None when absent.
184
185 Returns:
186 The markdown body, prefixed with `COMMENT_MARKER`.
187 """
188 if base_is_available(base, status):
189 body = _table_body(pr, base)
190 else:
191 reason = (
192 "the base build failed"
193 if status is not None and status.strip() != "ok"
194 else "the base report is missing or measured nothing"
195 )
196 body = _unavailable_body(reason, len(pr))
197 return COMMENT_MARKER + "\n" + body
198
199
200def _read_status(path: Path | None) -> str | None:
201 """Return the base status text, or None when no status file was given.
202
203 Args:
204 path: Status file path, or None.
205
206 Returns:
207 The file's text, or None when absent.
208 """
209 if path is None or not path.is_file():
210 return None
211 return path.read_text(encoding="utf-8")
212
213
214def _selftest_cases(root: Path) -> list[tuple[str, bool]]:
215 """Build every selftest assertion against fixtures written under `root`.
216
217 Args:
218 root: Temporary directory to materialise the two summaries in.
219
220 Returns:
221 ``(label, passed)`` pairs, both directions covered.
222 """
223 pr_file = root / "pr.txt"
224 pr_file.write_text(
225 "Filename Regions Miss Cover MC/DC\nlibs/a.c 10 1 90.00% 75.00%\n",
226 encoding="utf-8",
227 )
228 base_file = root / "base.txt"
229 base_file.write_text(
230 "Filename Regions Miss Cover MC/DC\nlibs/a.c 10 1 90.00% 50.00%\n",
231 encoding="utf-8",
232 )
233 pr = load_summary(pr_file)
234 base = load_summary(base_file)
235 good = render(pr, base, "ok\n")
236 failed = render(pr, base, "unavailable\n")
237 empty = render(pr, {}, None)
238 absent = "No comparison was performed"
239 return [
240 ("the PR summary parses to one row", pr == {"libs/a.c": 75.0}),
241 ("MUST NOT FIRE: a real base renders a delta table", "| delta |" in good),
242 ("a real base computes the delta", "+25.00%" in good),
243 ("a real base does not claim unavailability", absent not in good),
244 ("MUST FIRE: a FAILED base build states no comparison happened", absent in failed),
245 ("MUST FIRE: a failed base emits NO delta table", "| delta |" not in failed),
246 ("MUST FIRE: a failed base emits no n/a rows to be misread", "n/a" not in failed),
247 ("a failed base says it is not evidence MC/DC held", "NOT evidence" in failed),
248 (
249 "MUST FIRE: a MISSING base report also states no comparison",
250 absent in empty and "| delta |" not in empty,
251 ),
252 (
253 "every body carries the update marker",
254 all(b.startswith(COMMENT_MARKER) for b in (good, failed, empty)),
255 ),
256 (
257 "a missing summary file parses to nothing rather than raising",
258 load_summary(root / "absent.txt") == {},
259 ),
260 ]
261
262
263def selftest() -> int:
264 """Prove an unavailable base suppresses the table, and a real one keeps it.
265
266 The must-fire direction here is unusual and is the whole point: the defect
267 was a body that looked complete. So the assertions are about what the body
268 must NOT contain when no comparison happened.
269
270 Returns:
271 ``EXIT_OK`` when every case holds, ``EXIT_VACUOUS`` otherwise.
272 """
273 with tempfile.TemporaryDirectory() as tmp:
274 cases = _selftest_cases(Path(tmp))
275
276 for label, ok in cases:
277 print(f" {'ok ' if ok else 'FAIL'} {label}")
278 if not all(ok for _, ok in cases):
279 print("mcdc_delta_comment.py: selftest FAILED", file=sys.stderr)
280 return EXIT_VACUOUS
281 print(f"mcdc_delta_comment.py: selftest passed ({len(cases)} cases, both directions).")
282 return EXIT_OK
283
284
285def main(argv: list[str]) -> int:
286 """Parse arguments and write the rendered comment body.
287
288 Returns:
289 0 on success, 2 on a failed selftest or an unreadable PR report.
290 """
291 parser = argparse.ArgumentParser(description="render the PR MC/DC delta comment body")
292 parser.add_argument("--pr", type=Path, help="PR-head llvm-cov summary")
293 parser.add_argument("--base", type=Path, help="base-branch llvm-cov summary")
294 parser.add_argument("--base-status", type=Path, help="base build status file")
295 parser.add_argument("--out", type=Path, help="markdown body to write")
296 parser.add_argument("--selftest", action="store_true", help="prove both directions, then exit")
297 args = parser.parse_args(argv)
298
299 if args.selftest:
300 return selftest()
301 if args.pr is None or args.out is None:
302 parser.error("--pr and --out are required unless --selftest is given")
303 if not args.pr.is_file():
304 print(
305 f"mcdc_delta_comment.py: FATAL -- the PR report '{args.pr}' is missing. "
306 "There is nothing to report; refusing to post an empty comment.",
307 file=sys.stderr,
308 )
309 return EXIT_VACUOUS
310
311 pr = load_summary(args.pr)
312 base = load_summary(args.base) if args.base is not None else {}
313 body = render(pr, base, _read_status(args.base_status))
314 args.out.write_text(body, encoding="utf-8")
315 available = base_is_available(base, _read_status(args.base_status))
316 verdict = "delta table" if available else "UNAVAILABLE banner"
317 print(f"mcdc_delta_comment.py: wrote {args.out} ({len(pr)} PR file(s), {verdict})")
318 return EXIT_OK
319
320
321if __name__ == "__main__":
322 raise SystemExit(main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298