chore(gate): check.sh can list its lanes and run one of them

--list prints the lane names, --only "<lane>" runs that one lane, so a runner can schedule the lanes itself and a red lane can be re-run on its own. An --only run reports that lane, never the gate, and an unknown name exits 2 instead of passing with nothing run; the contract test holds both.
This commit is contained in:
2026-10-16 02:53:30 +02:00
parent 9251918e08
commit 926e5e9899
4 changed files with 88 additions and 14 deletions
+2
View File
@@ -44,6 +44,8 @@ A change is done when this is green, from the repo root:
bash scripts/check.sh # every lane bash scripts/check.sh # every lane
bash scripts/check.sh lint # the static lanes only (no tests, build or audits) — the quick loop bash scripts/check.sh lint # the static lanes only (no tests, build or audits) — the quick loop
bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one bash scripts/check.sh --brief # either mode: one line per lane, output only for a red one
bash scripts/check.sh --list # the lane names, one per line (runs nothing)
bash scripts/check.sh --only "vitest" # one lane, by its exact name — never "the gate"
bash scripts/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only bash scripts/lint-changed.sh # ruff + eslint + prettier on the uncommitted files only
``` ```
+33 -1
View File
@@ -1,6 +1,7 @@
import { execFileSync, spawnSync } from "node:child_process";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { readdirSync, readFileSync, writeFileSync } from "node:fs"; import { readdirSync, readFileSync, writeFileSync } from "node:fs";
import { relative } from "node:path"; import { relative, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { ESLint, type Linter } from "eslint"; import { ESLint, type Linter } from "eslint";
import prettierConfig from "eslint-config-prettier"; import prettierConfig from "eslint-config-prettier";
@@ -375,6 +376,37 @@ describe("the gate snapshot (gates.snapshot.json)", () => {
}, 120_000); }, 120_000);
}); });
// Git for Windows' own bash: on a Windows PATH `bash` is often WSL's, which cannot run the script.
function bash(): string {
if (process.platform !== "win32") return "bash";
const exec = execFileSync("git", ["--exec-path"], { encoding: "utf8" }).trim();
return resolve(exec, "../../../bin/bash.exe");
}
const checkSh = (...args: string[]) =>
spawnSync(bash(), ["scripts/check.sh", ...args], { cwd: REPO, encoding: "utf8" });
// A runner that schedules lanes one by one learns them from `--list` and runs each with `--only`; a
// lane missing from the list would be a lane it never runs, and an `--only` that passed with nothing
// run would be a green about nothing.
describe("the gate script's lane list (check.sh --list / --only)", () => {
it("lists every lane the script runs, in order", () => {
const declared = [...read("scripts/check.sh").matchAll(/^\s*lane "([^"]+)"/gm)].map(
(m) => m[1],
);
const listed = checkSh("--list");
expect(listed.status).toBe(0);
expect(listed.stdout.trim().split("\n")).toEqual(declared);
const lint = checkSh("lint", "--list").stdout.trim().split("\n");
expect(lint.length).toBeGreaterThan(0);
expect(declared.slice(0, lint.length)).toEqual(lint);
});
it("refuses an unknown lane instead of passing with nothing run", () => {
const r = checkSh("--only", "no such lane");
expect(r.status).toBe(2);
expect(r.stdout).not.toContain("green");
});
});
describe("the gate register (docs/gates.md)", () => { describe("the gate register (docs/gates.md)", () => {
it("lists every ESLint rule narrowed, by scope", async () => { it("lists every ESLint rule narrowed, by scope", async () => {
expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]); expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]);
+24 -6
View File
@@ -1299,16 +1299,24 @@
"cd \"$(dirname \"$0\")/..\" || exit 2", "cd \"$(dirname \"$0\")/..\" || exit 2",
"mode=all", "mode=all",
"brief=0", "brief=0",
"for arg in \"$@\"; do", "list=0",
" case \"$arg\" in", "only=\"\"",
" all | lint) mode=\"$arg\" ;;", "usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only \"<lane>\"]' >&2; exit 2; }",
"while [ $# -gt 0 ]; do",
" case \"$1\" in",
" all | lint) mode=\"$1\" ;;",
" --brief) brief=1 ;;", " --brief) brief=1 ;;",
" --list) list=1 ;;",
" --only) [ $# -ge 2 ] || usage; only=\"$2\"; shift ;;",
" esac", " esac",
" shift",
"done", "done",
"[ \"$list\" = 1 ] && [ -n \"$only\" ] && usage",
"export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below", "export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below",
"TESTCOMPOSE=backend/docker-compose.test.yml", "TESTCOMPOSE=backend/docker-compose.test.yml",
"failed=()", "failed=()",
"timings=()", "timings=()",
"ran=0",
"ms() { date +%s%3N; }", "ms() { date +%s%3N; }",
"secs() { awk -v t=\"$1\" 'BEGIN { printf \"%.1f\", t / 1000 }'; }", "secs() { awk -v t=\"$1\" 'BEGIN { printf \"%.1f\", t / 1000 }'; }",
"took=\"\"", "took=\"\"",
@@ -1316,7 +1324,7 @@
" took=\"$(secs $(($(ms) - $3)))\"", " took=\"$(secs $(($(ms) - $3)))\"",
" timings+=(\"$(printf '%8s s %s' \"$took\" \"$1\")$([ \"$2\" = red ] && echo ' (red)')\")", " timings+=(\"$(printf '%8s s %s' \"$took\" \"$1\")$([ \"$2\" = red ] && echo ' (red)')\")",
" if [ -n \"${GATE_TIMES_FILE:-}\" ]; then", " if [ -n \"${GATE_TIMES_FILE:-}\" ]; then",
" printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \"$(date -u +%FT%TZ)\" \"$mode\" \"$1\" \"$took\" \"$2\" \\", " printf '%s\\t%s\\t%s\\t%s\\t%s\\t%s\\n' \"$(date -u +%FT%TZ)\" \"${only:+only}${only:-$mode}\" \"$1\" \"$took\" \"$2\" \\",
" \"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)\" >>\"$GATE_TIMES_FILE\"", " \"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)\" >>\"$GATE_TIMES_FILE\"",
" fi", " fi",
" return 0", " return 0",
@@ -1324,6 +1332,9 @@
"lane() { # lane <name> <command…> — run it, remember a failure, never stop", "lane() { # lane <name> <command…> — run it, remember a failure, never stop",
" local name=\"$1\" t0 result=ok", " local name=\"$1\" t0 result=ok",
" shift", " shift",
" if [ \"$list\" = 1 ]; then echo \"$name\"; return 0; fi",
" [ -n \"$only\" ] && [ \"$name\" != \"$only\" ] && return 0",
" ran=$((ran + 1))",
" t0=\"$(ms)\"", " t0=\"$(ms)\"",
" if [ \"$brief\" = 0 ]; then", " if [ \"$brief\" = 0 ]; then",
" echo \">> $name…\"", " echo \">> $name…\"",
@@ -1407,14 +1418,21 @@
" lane \"pip-audit (backend lock)\" pip_audit", " lane \"pip-audit (backend lock)\" pip_audit",
" lane \"npm audit (production dependencies)\" frontend npm audit --omit=dev --audit-level=high", " lane \"npm audit (production dependencies)\" frontend npm audit --omit=dev --audit-level=high",
"fi", "fi",
"[ \"$list\" = 1 ] && exit 0",
"if [ -n \"$only\" ] && [ \"$ran\" = 0 ]; then",
" echo \"no lane named '$only' in mode $mode — see: scripts/check.sh $mode --list\" >&2",
" exit 2",
"fi",
"scope=\"gate ($mode)\"",
"[ -n \"$only\" ] && scope=\"lane '$only' only (not the gate)\"",
"echo \">> lane times:\"", "echo \">> lane times:\"",
"printf '%s\\n' \"${timings[@]}\"", "printf '%s\\n' \"${timings[@]}\"",
"echo \"$(printf '%8s' \"$(secs $(($(ms) - gate_t0)))\") s total\"", "echo \"$(printf '%8s' \"$(secs $(($(ms) - gate_t0)))\") s total\"",
"if [ ${#failed[@]} -eq 0 ]; then", "if [ ${#failed[@]} -eq 0 ]; then",
" echo \">> gate ($mode): green ✓\"", " echo \">> $scope: green ✓\"",
" exit 0", " exit 0",
"fi", "fi",
"echo \">> gate ($mode): RED — ${failed[*]}\" >&2", "echo \">> $scope: RED — ${failed[*]}\" >&2",
"exit 1" "exit 1"
] ]
} }
+29 -7
View File
@@ -9,6 +9,10 @@
# bash scripts/check.sh lint the static lanes only: no tests, no build, no audits # bash scripts/check.sh lint the static lanes only: no tests, no build, no audits
# … --brief (either mode) a lane's output only when it fails, and only its first # … --brief (either mode) a lane's output only when it fails, and only its first
# error lines — for an agent, whose context the full log would flood # error lines — for an agent, whose context the full log would flood
# … --list the lane names of that mode, one per line — runs nothing
# … --only "<lane>" that one lane (an exact name from --list): a re-run of a red lane, or a
# runner that schedules lanes itself. Its green is that lane's, never "the
# gate"; an unknown name exits 2 rather than passing with nothing run
# GATE_TIMES_FILE=<file> … also append each lane's time as a TSV row (UTC, mode, lane, seconds, # GATE_TIMES_FILE=<file> … also append each lane's time as a TSV row (UTC, mode, lane, seconds,
# ok|red, HEAD's tree) — to compare runs; every run prints the times anyway # ok|red, HEAD's tree) — to compare runs; every run prints the times anyway
# #
@@ -20,18 +24,26 @@ set -u
cd "$(dirname "$0")/.." || exit 2 cd "$(dirname "$0")/.." || exit 2
mode=all mode=all
brief=0 brief=0
for arg in "$@"; do list=0
case "$arg" in only=""
all | lint) mode="$arg" ;; usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only "<lane>"]' >&2; exit 2; }
while [ $# -gt 0 ]; do
case "$1" in
all | lint) mode="$1" ;;
--brief) brief=1 ;; --brief) brief=1 ;;
*) echo "usage: scripts/check.sh [lint] [--brief]" >&2; exit 2 ;; --list) list=1 ;;
--only) [ $# -ge 2 ] || usage; only="$2"; shift ;;
*) usage ;;
esac esac
shift
done done
[ "$list" = 1 ] && [ -n "$only" ] && usage
export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below
TESTCOMPOSE=backend/docker-compose.test.yml TESTCOMPOSE=backend/docker-compose.test.yml
failed=() failed=()
timings=() timings=()
ran=0
ms() { date +%s%3N; } ms() { date +%s%3N; }
secs() { awk -v t="$1" 'BEGIN { printf "%.1f", t / 1000 }'; } secs() { awk -v t="$1" 'BEGIN { printf "%.1f", t / 1000 }'; }
# How long each lane took: a line after the lane, a table at the end, and — when GATE_TIMES_FILE names a # How long each lane took: a line after the lane, a table at the end, and — when GATE_TIMES_FILE names a
@@ -42,7 +54,7 @@ timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it mus
took="$(secs $(($(ms) - $3)))" took="$(secs $(($(ms) - $3)))"
timings+=("$(printf '%8s s %s' "$took" "$1")$([ "$2" = red ] && echo ' (red)')") timings+=("$(printf '%8s s %s' "$took" "$1")$([ "$2" = red ] && echo ' (red)')")
if [ -n "${GATE_TIMES_FILE:-}" ]; then if [ -n "${GATE_TIMES_FILE:-}" ]; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "$mode" "$1" "$took" "$2" \ printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "${only:+only}${only:-$mode}" "$1" "$took" "$2" \
"$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE" "$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE"
fi fi
return 0 return 0
@@ -50,6 +62,9 @@ timed() { # timed <name> <ok|red> <start-ms> — sets $took (no subshell: it mus
lane() { # lane <name> <command…> — run it, remember a failure, never stop lane() { # lane <name> <command…> — run it, remember a failure, never stop
local name="$1" t0 result=ok local name="$1" t0 result=ok
shift shift
if [ "$list" = 1 ]; then echo "$name"; return 0; fi
[ -n "$only" ] && [ "$name" != "$only" ] && return 0
ran=$((ran + 1))
t0="$(ms)" t0="$(ms)"
if [ "$brief" = 0 ]; then if [ "$brief" = 0 ]; then
echo ">> $name…" echo ">> $name…"
@@ -163,12 +178,19 @@ if [ "$mode" = all ]; then
lane "npm audit (production dependencies)" frontend npm audit --omit=dev --audit-level=high lane "npm audit (production dependencies)" frontend npm audit --omit=dev --audit-level=high
fi fi
[ "$list" = 1 ] && exit 0
if [ -n "$only" ] && [ "$ran" = 0 ]; then
echo "no lane named '$only' in mode $mode — see: scripts/check.sh $mode --list" >&2
exit 2
fi
scope="gate ($mode)"
[ -n "$only" ] && scope="lane '$only' only (not the gate)"
echo ">> lane times:" echo ">> lane times:"
printf '%s\n' "${timings[@]}" printf '%s\n' "${timings[@]}"
echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total" echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total"
if [ ${#failed[@]} -eq 0 ]; then if [ ${#failed[@]} -eq 0 ]; then
echo ">> gate ($mode): green ✓" echo ">> $scope: green ✓"
exit 0 exit 0
fi fi
echo ">> gate ($mode): RED — ${failed[*]}" >&2 echo ">> $scope: RED — ${failed[*]}" >&2
exit 1 exit 1