4"""Gate: a gate body shall be structurally capable of failing.
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::
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
24 checker --selftest # may fail; status discarded
25 checker --strict # only this one decides
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
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
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
47A THIRD RULE: A GATE MEASURES THE TREE UNDER TEST
48-------------------------------------------------
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.
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.
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``.
70Three rules, all purely structural:
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.
79 check_gate_bodies.py # scan scripts/ci/gates/*.sh
80 check_gate_bodies.py --selftest # prove the checker fires on every defect
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
88from __future__
import annotations
93from pathlib
import Path
95REPO_ROOT = Path(__file__).resolve().parents[2]
96GATE_DIR = REPO_ROOT /
"scripts" /
"ci" /
"gates"
100GATE_OPEN_RE = re.compile(
r"^gate_([a-z0-9_]+)\(\)\s*([({])\s*$")
104ERREXIT_RE = re.compile(
r"^\s*set\s+(?:-[a-df-zA-Z]*e[a-zA-Z]*|-o\s+errexit)\b", re.MULTILINE)
109RETURN_RE = re.compile(
r"\breturn\b")
113REPO_ROOT_RE = re.compile(
r"\$\{?REPO_ROOT\b")
120def strip_comments(text: str) -> str:
121 """Drop ``#`` comments so a rule fires on code and not on prose.
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.
129 text: raw shell text.
132 The same text with comment tails removed, line count preserved.
135 for line
in text.splitlines():
136 if line.lstrip().startswith(
"#"):
139 cut = re.search(
r"(?:^|\s)#", line)
140 out.append(line[: cut.start()]
if cut
else line)
141 return "\n".join(out)
145 """One parsed ``gate_*()`` definition and the properties this gate checks.
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.
152 def __init__(self, name: str, path: Path, line: int, opener: str, body: str) ->
None:
153 """Record one gate definition.
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.
169 def is_subshell(self) -> bool:
170 """Return True when the body is a ``( )`` subshell rather than a block."""
171 return self.opener ==
"("
174 def has_failure_discipline(self) -> bool:
175 """Return True when the body decides its own verdict.
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.
181 return bool(ERREXIT_RE.search(self.body))
or bool(RETURN_RE.search(self.body))
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}"
189def parse_gate_bodies(text: str, path: Path) -> list[GateBody]:
190 """Extract every ``gate_*()`` definition from one shell fragment.
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
199 text: full contents of the shell fragment.
200 path: its path, recorded on each returned body for reporting.
203 One ``GateBody`` per definition found, in source order.
205 lines = text.splitlines()
206 bodies: list[GateBody] = []
208 while index < len(lines):
209 match = GATE_OPEN_RE.match(lines[index])
213 name, opener = match.group(1), match.group(2)
214 closer =
")" if opener ==
"(" else "}"
216 collected: list[str] = []
217 while cursor < len(lines)
and lines[cursor].rstrip() != closer:
218 collected.append(lines[cursor])
220 bodies.append(GateBody(name, path, index + 1, opener,
"\n".join(collected)))
225def check_bodies(bodies: list[GateBody]) -> list[str]:
226 """Apply every structural rule and return one message per violation."""
227 errors: list[str] = []
229 if not gate.is_subshell:
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"
242 if not gate.has_failure_discipline:
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`."
252def check_fragment_scope(text: str, path: Path) -> list[str]:
253 """Rule 3: nothing in a gate fragment may reach for ``$REPO_ROOT``.
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.
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.
265 text: full contents of the fragment.
266 path: its path, for the reported location.
269 One message per offending line, in source order.
271 errors: list[str] = []
272 for number, line
in enumerate(strip_comments(text).splitlines(), start=1):
273 if not REPO_ROOT_RE.search(line):
276 f
"{path.relative_to(REPO_ROOT)}:{number}: reads $REPO_ROOT.\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."
288def scan(gate_dir: Path) -> tuple[list[str], int]:
289 """Scan every shell fragment in ``gate_dir``.
292 gate_dir: directory holding the sourced ``gate_*`` body fragments.
295 ``(errors, bodies_seen)``. A caller must treat ``bodies_seen`` below
296 ``MIN_GATE_BODIES`` as a broken scan rather than a clean tree.
298 errors: list[str] = []
300 for fragment
in sorted(gate_dir.glob(
"*.sh")):
301 text = fragment.read_text(encoding=
"utf-8")
302 bodies = parse_gate_bodies(text, fragment)
304 errors.extend(check_bodies(bodies))
305 errors.extend(check_fragment_scope(text, fragment))
310 """Verify every gate body can fail, and report each one that cannot.
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.
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.
322 parser = argparse.ArgumentParser(description=
"check that every ci.sh gate body can fail")
326 help=
"prove the checker still fires on both defect shapes, and stays quiet on neither",
328 args = parser.parse_args()
333 if not GATE_DIR.is_dir():
335 f
"check_gate_bodies.py: {GATE_DIR} does not exist -- the gate bodies "
336 "moved and this checker is scanning nothing.\n"
340 errors, seen = scan(GATE_DIR)
341 if seen < MIN_GATE_BODIES:
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"
350 sys.stderr.write(
"check_gate_bodies.py: gate bodies that break the runner contract:\n\n")
352 sys.stderr.write(f
" {error}\n\n")
354 f
"{len(errors)} gate body/bodies swallow a failing command or leave "
355 "the tree under test.\n"
360 f
"check_gate_bodies.py: clean -- all {seen} gate bodies can fail and stay "
361 "inside the tree under test."
366def _selftest_cases() -> list[tuple[str, str, bool]]:
367 """Return ``(label, fragment_text, must_fire)`` selftest fixtures.
369 Both directions are covered deliberately: a checker that only ever sees
370 good input cannot tell "compliant" from "stopped matching".
374 "block body with two commands (the gate_cite_check shape)",
375 "gate_thing() {\n checker --selftest\n checker --strict\n}\n",
379 "block body with one command",
380 "gate_thing() {\n checker --strict\n}\n",
384 "subshell with neither errexit nor return",
385 "gate_thing() (\n checker --selftest\n checker --strict\n)\n",
389 "subshell with set -e",
390 "gate_thing() (\n set -e\n checker --selftest\n checker --strict\n)\n",
394 "subshell with set -euo pipefail",
395 "gate_thing() (\n set -euo pipefail\n checker --strict\n)\n",
399 "subshell guarding each step with || return 1",
400 "gate_thing() (\n set -uo pipefail\n probe || return 1\n checker --strict\n)\n",
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",
411def _scope_selftest_cases() -> list[tuple[str, str, bool]]:
412 """Return ``(label, fragment_text, must_fire)`` fixtures for rule 3.
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.
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',
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",
432 "$PWD -- the tree under test",
433 'gate_thing() (\n set -e\n cmake -S "$PWD/tools/x" -B "$PWD/build/x"\n)\n',
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",
443 "a fragment naming no repo root at all",
444 "gate_thing() (\n set -e\n checker --strict\n)\n",
450def selftest() -> int:
451 """Prove the checker fires on every defect shape and spares the good ones.
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.
459 0 when every assertion held in both directions, 1 otherwise.
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
468 expectation =
"must fire" if must_fire
else "must stay quiet"
469 print(f
" [{'ok' if ok else 'FAIL'}] {label} ({expectation})")
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
476 expectation =
"must fire" if must_fire
else "must stay quiet"
477 print(f
" [{'ok' if ok else 'FAIL'}] tree-under-test: {label} ({expectation})")
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")
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})")
494 sys.stderr.write(f
"check_gate_bodies.py --selftest: {failures} case(s) failed.\n")
496 print(
"check_gate_bodies.py --selftest: all cases pass (both directions).")
500if __name__ ==
"__main__":
501 raise SystemExit(
main())
void main(void)
The application entry point Reset_Handler hands control to.