ra8-firmware 0.1.0
Bare-metal firmware for the Renesas RA8 family (RA8D2 / RA8P1)
Loading...
Searching...
No Matches
check_gate_bodies.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"""Gate: a gate body shall be structurally capable of failing.
5
6``run_gate_capture`` in ``scripts/ci.sh`` disables ERREXIT around the call so
7that a gate's own ``set -e`` decides its verdict rather than the caller's::
8
9 run_gate_capture() {
10 local name="$1"
11 set +e
12 run_one_gate "$name"
13 RA8_GATE_RC=$?
14 set -e
15 return 0
16 }
17
18That ``set +e`` is live inside any gate whose body is a ``{ }`` BLOCK, because
19a block runs in the calling shell. A block reports only its LAST command's
20status, so every command before the last one can fail with no effect on the
21verdict::
22
23 gate_thing() {
24 checker --selftest # may fail; status discarded
25 checker --strict # only this one decides
26 }
27
28A ``( )`` SUBSHELL body is immune: the subshell re-enables ERREXIT for itself
29(``set -e``) or guards each step (``... || return 1``), and neither is undone
30by the caller.
31
32This is not a hypothetical. ``gate_cite_check`` and ``gate_no_ai_attribution``
33each ran a ``--selftest`` first "so a detector that stopped matching cannot
34pass as a clean tree" -- and then discarded that selftest's status, because
35both were ``{ }`` bodies. The protection the comment described was inert in
36``just ci`` while working under ``just quality::local::gate <name>``, i.e. the
37exact local-green / CI-red divergence ``scripts/ci.sh`` claims is structurally
38impossible.
39
40``suite_errexit_selftest`` (in ``scripts/ci/gates/hygiene.sh``) already proves
41the RUNNER propagates a mid-body failure, but it can only prove it for the
42shape it probes with -- a ``( set -e )`` subshell. It is therefore blind to a
43gate that is not that shape, which is why the defect survived alongside it.
44This checker covers the other half: the runner is honest, and now so is every
45body it dispatches to.
46
47A THIRD RULE: A GATE MEASURES THE TREE UNDER TEST
48-------------------------------------------------
49
50``run_suite_on_snapshot`` cds into a clean snapshot of HEAD and dispatches
51every gate from there, which is what "``just ci`` gates committed HEAD, exactly
52like CI" means. ``$REPO_ROOT`` is something else entirely: the HOST checkout
53the runner was invoked from. A gate body that reaches for it measures a
54different tree than the one the suite claims to be gating.
55
56``tools-build`` did, and both consequences were real (#546). It configured and
57compiled the WORKING TREE's ``tools/`` -- whatever happened to be dirty in it --
58and left its build output there, while the snapshot beside it went unbuilt.
59And on the containerised path it could not run at all: the host repo is
60bind-mounted READ-ONLY at ``/workspace``, so the first ``cmake -B`` under it
61died with ``CMake Error: Unable to (re)create the private pkgRedirects
62directory``. That took win-ci -- the fleet's second verification host, where
63``ci-gate-container`` is the normal path -- out of ever reporting a full green.
64
65There is no legitimate use, so there is no exception list: a gate needing the
66host repository's *history* calls ``ci_history_repo`` (which ``ci.sh`` points
67at the real repo deliberately, because a snapshot cannot carry commit
68messages), and a gate needing a path uses ``$PWD``.
69
70Three rules, all purely structural:
71
721. every ``gate_*()`` body is a ``( ... )`` subshell, never a ``{ ... }`` block;
732. that subshell establishes its own failure discipline -- it enables ERREXIT
74 (``set -e`` in any bundling) or it returns explicitly (``|| return 1``);
753. no body references ``$REPO_ROOT``; a gate reads the tree it is standing in.
76
77Run::
78
79 check_gate_bodies.py # scan scripts/ci/gates/*.sh
80 check_gate_bodies.py --selftest # prove the checker fires on every defect
81
82Exit 0 when every gate body can fail and stays inside the tree under test, 1
83(listing each offender) otherwise, and 2 when no gate body could be found at
84all -- a parser that has stopped seeing its subject must not report a clean
85tree.
86"""
87
88from __future__ import annotations
89
90import argparse
91import re
92import sys
93from pathlib import Path
94
95REPO_ROOT = Path(__file__).resolve().parents[2]
96GATE_DIR = REPO_ROOT / "scripts" / "ci" / "gates"
97
98# `gate_<name>() (` or `gate_<name>() {` on a line of its own -- the only two
99# body forms bash offers, and the shape every gate in this tree is written in.
100GATE_OPEN_RE = re.compile(r"^gate_([a-z0-9_]+)\‍(\‍)\s*([({])\s*$")
101
102# `set -e`, `set -eu`, `set -euo pipefail`, `set -o errexit`, ... Any bundling
103# that turns ERREXIT on counts; the flag letters may arrive in any order.
104ERREXIT_RE = re.compile(r"^\s*set\s+(?:-[a-df-zA-Z]*e[a-zA-Z]*|-o\s+errexit)\b", re.MULTILINE)
105
106# An explicit `return` anywhere in the body -- the discipline used by the
107# commit-range gates, which run `set -uo pipefail` and guard every step with
108# `|| return 1` instead of relying on ERREXIT.
109RETURN_RE = re.compile(r"\breturn\b")
110
111# `$REPO_ROOT` / `${REPO_ROOT}` reached from a gate body -- rule 3. Matched
112# against comment-stripped text, so the fragments may still EXPLAIN the rule.
113REPO_ROOT_RE = re.compile(r"\$\{?REPO_ROOT\b")
114
115# A tree with no gate bodies means the parser stopped matching, not that the
116# gates became compliant. Refuse to report a clean scan against nothing.
117MIN_GATE_BODIES = 1
118
119
120def strip_comments(text: str) -> str:
121 """Drop ``#`` comments so a rule fires on code and not on prose.
122
123 A ``#`` opens a comment at line start or after whitespace, the convention
124 ``check_errexit_masking.py`` already uses. Quoting is not modelled: no
125 gate body in this tree carries a ``#`` inside a string, and a rule that
126 over-fires on a comment is a rule that gets disabled.
127
128 Args:
129 text: raw shell text.
130
131 Returns:
132 The same text with comment tails removed, line count preserved.
133 """
134 out: list[str] = []
135 for line in text.splitlines():
136 if line.lstrip().startswith("#"):
137 out.append("")
138 continue
139 cut = re.search(r"(?:^|\s)#", line)
140 out.append(line[: cut.start()] if cut else line)
141 return "\n".join(out)
142
143
144class GateBody:
145 """One parsed ``gate_*()`` definition and the properties this gate checks.
146
147 Holds the source location, the opening delimiter (which decides whether the
148 caller's ``set +e`` leaks in) and the body text, so both rules can be
149 evaluated without re-reading the file.
150 """
151
152 def __init__(self, name: str, path: Path, line: int, opener: str, body: str) -> None:
153 """Record one gate definition.
154
155 Args:
156 name: gate function suffix, e.g. ``cite_check`` for ``gate_cite_check``.
157 path: the ``scripts/ci/gates/*.sh`` fragment defining it.
158 line: 1-based line number of the ``gate_*()`` opener, for the report.
159 opener: ``(`` for a subshell body, ``{`` for a block body.
160 body: the raw text between the opener and its closing delimiter.
161 """
162 self.name = name
163 self.path = path
164 self.line = line
165 self.opener = opener
166 self.body = body
167
168 @property
169 def is_subshell(self) -> bool:
170 """Return True when the body is a ``( )`` subshell rather than a block."""
171 return self.opener == "("
172
173 @property
174 def has_failure_discipline(self) -> bool:
175 """Return True when the body decides its own verdict.
176
177 Either ERREXIT is enabled (so any failing command aborts the subshell)
178 or the body returns explicitly (the ``|| return 1`` idiom). A body with
179 neither reports only its last command's status even as a subshell.
180 """
181 return bool(ERREXIT_RE.search(self.body)) or bool(RETURN_RE.search(self.body))
182
183 @property
184 def where(self) -> str:
185 """Return a ``path:line`` location string relative to the repo root."""
186 return f"{self.path.relative_to(REPO_ROOT)}:{self.line}"
187
188
189def parse_gate_bodies(text: str, path: Path) -> list[GateBody]:
190 """Extract every ``gate_*()`` definition from one shell fragment.
191
192 The body runs from the opener line to the first line that is exactly the
193 matching closing delimiter at column zero. Every gate in this tree is
194 written that way (the fragments are formatted by shfmt), so no brace
195 counting is needed and a nested ``)`` inside a command cannot end a body
196 early.
197
198 Args:
199 text: full contents of the shell fragment.
200 path: its path, recorded on each returned body for reporting.
201
202 Returns:
203 One ``GateBody`` per definition found, in source order.
204 """
205 lines = text.splitlines()
206 bodies: list[GateBody] = []
207 index = 0
208 while index < len(lines):
209 match = GATE_OPEN_RE.match(lines[index])
210 if match is None:
211 index += 1
212 continue
213 name, opener = match.group(1), match.group(2)
214 closer = ")" if opener == "(" else "}"
215 cursor = index + 1
216 collected: list[str] = []
217 while cursor < len(lines) and lines[cursor].rstrip() != closer:
218 collected.append(lines[cursor])
219 cursor += 1
220 bodies.append(GateBody(name, path, index + 1, opener, "\n".join(collected)))
221 index = cursor + 1
222 return bodies
223
224
225def check_bodies(bodies: list[GateBody]) -> list[str]:
226 """Apply every structural rule and return one message per violation."""
227 errors: list[str] = []
228 for gate in bodies:
229 if not gate.is_subshell:
230 errors.append(
231 f"{gate.where}: gate_{gate.name}() has a `{{ }}` BLOCK body.\n"
232 f" run_gate_capture runs gates under `set +e`, and that suppression\n"
233 f" is live inside a block -- so only the LAST command decides the\n"
234 f" verdict and every command before it can fail unnoticed.\n"
235 f" Write it as a subshell instead:\n"
236 f" gate_{gate.name}() (\n"
237 f" set -e\n"
238 f" ...\n"
239 f" )"
240 )
241 continue
242 if not gate.has_failure_discipline:
243 errors.append(
244 f"{gate.where}: gate_{gate.name}() is a subshell that never enables\n"
245 f" ERREXIT and never returns explicitly, so it reports only its\n"
246 f" last command's status. Add `set -e` as the first line, or guard\n"
247 f" each step with `|| return 1`."
248 )
249 return errors
250
251
252def check_fragment_scope(text: str, path: Path) -> list[str]:
253 """Rule 3: nothing in a gate fragment may reach for ``$REPO_ROOT``.
254
255 Applied to the WHOLE fragment rather than to the ``gate_*()`` bodies alone,
256 because a gate is its helpers too. ``gate_tools_build`` delegated to
257 ``_tb_mdl`` / ``_tb_rabook_viewer`` / ``_tb_other_tools``, and it was
258 those helpers that held most of the ``$REPO_ROOT`` paths -- a body-scoped
259 rule would have passed the file with the defect still in it.
260
261 Every function in ``scripts/ci/gates/`` runs inside the snapshot the suite
262 dispatches from, so the rule needs no exception anywhere in these files.
263
264 Args:
265 text: full contents of the fragment.
266 path: its path, for the reported location.
267
268 Returns:
269 One message per offending line, in source order.
270 """
271 errors: list[str] = []
272 for number, line in enumerate(strip_comments(text).splitlines(), start=1):
273 if not REPO_ROOT_RE.search(line):
274 continue
275 errors.append(
276 f"{path.relative_to(REPO_ROOT)}:{number}: reads $REPO_ROOT.\n"
277 f" {line.strip()}\n"
278 f" That is the HOST checkout. The suite runs every gate inside a\n"
279 f" clean snapshot of HEAD, so this measures a different tree than\n"
280 f" the run reports on -- and on the containerised path it cannot\n"
281 f" write under it at all, because the host repo is mounted\n"
282 f" read-only at /workspace (#546).\n"
283 f" Use $PWD, which is the tree under test on every path."
284 )
285 return errors
286
287
288def scan(gate_dir: Path) -> tuple[list[str], int]:
289 """Scan every shell fragment in ``gate_dir``.
290
291 Args:
292 gate_dir: directory holding the sourced ``gate_*`` body fragments.
293
294 Returns:
295 ``(errors, bodies_seen)``. A caller must treat ``bodies_seen`` below
296 ``MIN_GATE_BODIES`` as a broken scan rather than a clean tree.
297 """
298 errors: list[str] = []
299 seen = 0
300 for fragment in sorted(gate_dir.glob("*.sh")):
301 text = fragment.read_text(encoding="utf-8")
302 bodies = parse_gate_bodies(text, fragment)
303 seen += len(bodies)
304 errors.extend(check_bodies(bodies))
305 errors.extend(check_fragment_scope(text, fragment))
306 return errors, seen
307
308
309def main() -> int:
310 """Verify every gate body can fail, and report each one that cannot.
311
312 A gate whose body swallows a failing command reports PASS for work it did
313 not do, which is the defect class this whole checker family exists to
314 close. The scan is refused outright when it finds no gate bodies, because
315 a parser that has stopped matching would otherwise report the cleanest
316 tree it has ever seen.
317
318 Returns:
319 0 when every body is a failure-capable subshell, 1 when any is not,
320 and 2 when the scan found nothing to check.
321 """
322 parser = argparse.ArgumentParser(description="check that every ci.sh gate body can fail")
323 parser.add_argument(
324 "--selftest",
325 action="store_true",
326 help="prove the checker still fires on both defect shapes, and stays quiet on neither",
327 )
328 args = parser.parse_args()
329
330 if args.selftest:
331 return selftest()
332
333 if not GATE_DIR.is_dir():
334 sys.stderr.write(
335 f"check_gate_bodies.py: {GATE_DIR} does not exist -- the gate bodies "
336 "moved and this checker is scanning nothing.\n"
337 )
338 return 2
339
340 errors, seen = scan(GATE_DIR)
341 if seen < MIN_GATE_BODIES:
342 sys.stderr.write(
343 "check_gate_bodies.py: found NO gate bodies under "
344 f"{GATE_DIR.relative_to(REPO_ROOT)}. Refusing to report a clean scan "
345 "against nothing -- the parser has stopped matching its subject.\n"
346 )
347 return 2
348
349 if errors:
350 sys.stderr.write("check_gate_bodies.py: gate bodies that break the runner contract:\n\n")
351 for error in errors:
352 sys.stderr.write(f" {error}\n\n")
353 sys.stderr.write(
354 f"{len(errors)} gate body/bodies swallow a failing command or leave "
355 "the tree under test.\n"
356 )
357 return 1
358
359 print(
360 f"check_gate_bodies.py: clean -- all {seen} gate bodies can fail and stay "
361 "inside the tree under test."
362 )
363 return 0
364
365
366def _selftest_cases() -> list[tuple[str, str, bool]]:
367 """Return ``(label, fragment_text, must_fire)`` selftest fixtures.
368
369 Both directions are covered deliberately: a checker that only ever sees
370 good input cannot tell "compliant" from "stopped matching".
371 """
372 return [
373 (
374 "block body with two commands (the gate_cite_check shape)",
375 "gate_thing() {\n checker --selftest\n checker --strict\n}\n",
376 True,
377 ),
378 (
379 "block body with one command",
380 "gate_thing() {\n checker --strict\n}\n",
381 True,
382 ),
383 (
384 "subshell with neither errexit nor return",
385 "gate_thing() (\n checker --selftest\n checker --strict\n)\n",
386 True,
387 ),
388 (
389 "subshell with set -e",
390 "gate_thing() (\n set -e\n checker --selftest\n checker --strict\n)\n",
391 False,
392 ),
393 (
394 "subshell with set -euo pipefail",
395 "gate_thing() (\n set -euo pipefail\n checker --strict\n)\n",
396 False,
397 ),
398 (
399 "subshell guarding each step with || return 1",
400 "gate_thing() (\n set -uo pipefail\n probe || return 1\n checker --strict\n)\n",
401 False,
402 ),
403 (
404 "set -uo pipefail alone must NOT count as errexit",
405 "gate_thing() (\n set -uo pipefail\n checker --selftest\n checker --strict\n)\n",
406 True,
407 ),
408 ]
409
410
411def _scope_selftest_cases() -> list[tuple[str, str, bool]]:
412 """Return ``(label, fragment_text, must_fire)`` fixtures for rule 3.
413
414 Separate from the body fixtures because the rule is fragment-scoped: the
415 defect it exists for lived in a gate's HELPER, not in the gate body, so a
416 fixture set that only ever showed it bodies would prove the wrong thing.
417 """
418 return [
419 (
420 "gate body reaching for $REPO_ROOT",
421 "gate_thing() (\n set -e\n"
422 ' cmake -S "$REPO_ROOT/tools/x" -B "$REPO_ROOT/build/x"\n)\n',
423 True,
424 ),
425 (
426 "gate HELPER reaching for ${REPO_ROOT} (the tools-build shape)",
427 '_tb_x() (\n set -e\n bash "${REPO_ROOT}/tools/x/run.sh"\n)\n'
428 "gate_thing() (\n set -e\n _tb_x\n)\n",
429 True,
430 ),
431 (
432 "$PWD -- the tree under test",
433 'gate_thing() (\n set -e\n cmake -S "$PWD/tools/x" -B "$PWD/build/x"\n)\n',
434 False,
435 ),
436 (
437 "REPO_ROOT named only in a COMMENT",
438 "# never reach for $REPO_ROOT from a gate\n"
439 "gate_thing() (\n set -e\n checker --strict # not $REPO_ROOT either\n)\n",
440 False,
441 ),
442 (
443 "a fragment naming no repo root at all",
444 "gate_thing() (\n set -e\n checker --strict\n)\n",
445 False,
446 ),
447 ]
448
449
450def selftest() -> int:
451 """Prove the checker fires on every defect shape and spares the good ones.
452
453 Runs the fixtures through the same ``parse_gate_bodies`` + ``check_bodies``
454 path the live scan uses, then asserts the empty-scan floor separately --
455 the floor is the guard against this checker silently becoming a no-op, so
456 it is the one property that must never be taken on trust.
457
458 Returns:
459 0 when every assertion held in both directions, 1 otherwise.
460 """
461 failures = 0
462 for label, text, must_fire in _selftest_cases():
463 bodies = parse_gate_bodies(text, GATE_DIR / "selftest.sh")
464 fired = bool(check_bodies(bodies))
465 ok = fired == must_fire
466 if not ok:
467 failures += 1
468 expectation = "must fire" if must_fire else "must stay quiet"
469 print(f" [{'ok' if ok else 'FAIL'}] {label} ({expectation})")
470
471 for label, text, must_fire in _scope_selftest_cases():
472 fired = bool(check_fragment_scope(text, GATE_DIR / "selftest.sh"))
473 ok = fired == must_fire
474 if not ok:
475 failures += 1
476 expectation = "must fire" if must_fire else "must stay quiet"
477 print(f" [{'ok' if ok else 'FAIL'}] tree-under-test: {label} ({expectation})")
478
479 # The parser must actually find the definition; a rule that never runs
480 # cannot fire, and would read identically to a compliant tree.
481 parsed = parse_gate_bodies("gate_alpha() (\n set -e\n x\n)\n", GATE_DIR / "selftest.sh")
482 ok = len(parsed) == 1 and parsed[0].name == "alpha"
483 failures += 0 if ok else 1
484 print(f" [{'ok' if ok else 'FAIL'}] parser extracts a gate name and body")
485
486 # The empty-scan floor: a fragment with no gates must not look clean.
487 _, seen = scan(GATE_DIR)
488 ok = seen >= MIN_GATE_BODIES
489 failures += 0 if ok else 1
490 status = "ok" if ok else "FAIL"
491 print(f" [{status}] live scan sees {seen} gate bodies (floor {MIN_GATE_BODIES})")
492
493 if failures:
494 sys.stderr.write(f"check_gate_bodies.py --selftest: {failures} case(s) failed.\n")
495 return 1
496 print("check_gate_bodies.py --selftest: all cases pass (both directions).")
497 return 0
498
499
500if __name__ == "__main__":
501 raise SystemExit(main())
void main(void)
The application entry point Reset_Handler hands control to.
Definition main.c:298