4"""Render the per-file MC/DC delta comment posted on a pull request.
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.
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::
14 if (!fs.existsSync(path)) return new Map();
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.
23Two changes make that impossible here:
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
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
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
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.
47from __future__
import annotations
53from pathlib
import Path
56COMMENT_MARKER =
"<!-- mcdc-delta-comment -->"
63PCT_RE = re.compile(
r"^[0-9]+(\.[0-9]+)?%$")
69SKIP_PREFIXES = (
"---",
"Filename",
"TOTAL")
75def load_summary(path: Path) -> dict[str, float]:
76 """Parse an llvm-cov summary into ``file -> MC/DC percent``.
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.
83 path: Summary file to read.
86 One entry per data row; empty when the file does not exist.
88 out: dict[str, float] = {}
89 if not path.is_file():
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):
95 cols = trimmed.split()
96 if len(cols) < MIN_ROW_COLUMNS:
98 pcts = [col
for col
in cols
if PCT_RE.match(col)]
101 out[cols[0]] = float(pcts[-1].rstrip(
"%"))
105def base_is_available(base: dict[str, float], status: str |
None) -> bool:
106 """Report whether a real base measurement exists to compare against.
109 base: Parsed base summary.
110 status: Contents of the base status file, or None when absent.
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.
117 if status
is not None and status.strip() !=
"ok":
122def _unavailable_body(reason: str, pr_files: int) -> str:
123 """Build the body used when no comparison could be performed.
126 reason: Human-readable cause.
127 pr_files: Number of files measured on the PR head.
130 The markdown body, with no delta table.
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"
144def _table_body(pr: dict[str, float], base: dict[str, float]) -> str:
145 """Build the body containing the per-file delta table.
148 pr: Parsed PR-head summary.
149 base: Parsed base-branch summary.
155 for name
in set(pr) | set(base):
156 before = base.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])
162 def fmt(value: float |
None) -> str:
163 return "n/a" if value
is None else f
"{value:.2f}%"
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"
173 body +=
"| _no rows_ | | | |\n"
177def render(pr: dict[str, float], base: dict[str, float], status: str |
None) -> str:
178 """Render the full comment body for the given reports.
181 pr: Parsed PR-head summary.
182 base: Parsed base-branch summary.
183 status: Contents of the base status file, or None when absent.
186 The markdown body, prefixed with `COMMENT_MARKER`.
188 if base_is_available(base, status):
189 body = _table_body(pr, base)
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"
196 body = _unavailable_body(reason, len(pr))
197 return COMMENT_MARKER +
"\n" + body
200def _read_status(path: Path |
None) -> str |
None:
201 """Return the base status text, or None when no status file was given.
204 path: Status file path, or None.
207 The file's text, or None when absent.
209 if path
is None or not path.is_file():
211 return path.read_text(encoding=
"utf-8")
214def _selftest_cases(root: Path) -> list[tuple[str, bool]]:
215 """Build every selftest assertion against fixtures written under `root`.
218 root: Temporary directory to materialise the two summaries in.
221 ``(label, passed)`` pairs, both directions covered.
223 pr_file = root /
"pr.txt"
225 "Filename Regions Miss Cover MC/DC\nlibs/a.c 10 1 90.00% 75.00%\n",
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",
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"
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),
249 "MUST FIRE: a MISSING base report also states no comparison",
250 absent
in empty
and "| delta |" not in empty,
253 "every body carries the update marker",
254 all(b.startswith(COMMENT_MARKER)
for b
in (good, failed, empty)),
257 "a missing summary file parses to nothing rather than raising",
258 load_summary(root /
"absent.txt") == {},
263def selftest() -> int:
264 """Prove an unavailable base suppresses the table, and a real one keeps it.
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.
271 ``EXIT_OK`` when every case holds, ``EXIT_VACUOUS`` otherwise.
273 with tempfile.TemporaryDirectory()
as tmp:
274 cases = _selftest_cases(Path(tmp))
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)
281 print(f
"mcdc_delta_comment.py: selftest passed ({len(cases)} cases, both directions).")
285def main(argv: list[str]) -> int:
286 """Parse arguments and write the rendered comment body.
289 0 on success, 2 on a failed selftest or an unreadable PR report.
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)
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():
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.",
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})")
321if __name__ ==
"__main__":
322 raise SystemExit(
main(sys.argv[1:]))
void main(void)
The application entry point Reset_Handler hands control to.