From 926e5e9899f677d911d74f7c8a2a281d6571d303 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 16 Oct 2026 02:53:30 +0200 Subject: [PATCH] chore(gate): check.sh can list its lanes and run one of them --list prints the lane names, --only "" 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. --- docs/development.md | 2 ++ frontend/src/lib/gates.contract.test.ts | 34 ++++++++++++++++++++++- frontend/src/lib/gates.snapshot.json | 30 ++++++++++++++++----- scripts/check.sh | 36 ++++++++++++++++++++----- 4 files changed, 88 insertions(+), 14 deletions(-) diff --git a/docs/development.md b/docs/development.md index 5a8d8114..13dd22f4 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 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 --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 ``` diff --git a/frontend/src/lib/gates.contract.test.ts b/frontend/src/lib/gates.contract.test.ts index 220a4303..9a487c82 100644 --- a/frontend/src/lib/gates.contract.test.ts +++ b/frontend/src/lib/gates.contract.test.ts @@ -1,6 +1,7 @@ +import { execFileSync, spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { readdirSync, readFileSync, writeFileSync } from "node:fs"; -import { relative } from "node:path"; +import { relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { ESLint, type Linter } from "eslint"; import prettierConfig from "eslint-config-prettier"; @@ -375,6 +376,37 @@ describe("the gate snapshot (gates.snapshot.json)", () => { }, 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)", () => { it("lists every ESLint rule narrowed, by scope", async () => { expect(missingFrom(eslintNarrowings(await linted()))).toEqual([]); diff --git a/frontend/src/lib/gates.snapshot.json b/frontend/src/lib/gates.snapshot.json index ce60c8c7..d6230d64 100644 --- a/frontend/src/lib/gates.snapshot.json +++ b/frontend/src/lib/gates.snapshot.json @@ -1299,16 +1299,24 @@ "cd \"$(dirname \"$0\")/..\" || exit 2", "mode=all", "brief=0", - "for arg in \"$@\"; do", - " case \"$arg\" in", - " all | lint) mode=\"$arg\" ;;", + "list=0", + "only=\"\"", + "usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only \"\"]' >&2; exit 2; }", + "while [ $# -gt 0 ]; do", + " case \"$1\" in", + " all | lint) mode=\"$1\" ;;", " --brief) brief=1 ;;", + " --list) list=1 ;;", + " --only) [ $# -ge 2 ] || usage; only=\"$2\"; shift ;;", " esac", + " shift", "done", + "[ \"$list\" = 1 ] && [ -n \"$only\" ] && usage", "export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below", "TESTCOMPOSE=backend/docker-compose.test.yml", "failed=()", "timings=()", + "ran=0", "ms() { date +%s%3N; }", "secs() { awk -v t=\"$1\" 'BEGIN { printf \"%.1f\", t / 1000 }'; }", "took=\"\"", @@ -1316,7 +1324,7 @@ " took=\"$(secs $(($(ms) - $3)))\"", " timings+=(\"$(printf '%8s s %s' \"$took\" \"$1\")$([ \"$2\" = red ] && echo ' (red)')\")", " 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\"", " fi", " return 0", @@ -1324,6 +1332,9 @@ "lane() { # lane — run it, remember a failure, never stop", " local name=\"$1\" t0 result=ok", " shift", + " if [ \"$list\" = 1 ]; then echo \"$name\"; return 0; fi", + " [ -n \"$only\" ] && [ \"$name\" != \"$only\" ] && return 0", + " ran=$((ran + 1))", " t0=\"$(ms)\"", " if [ \"$brief\" = 0 ]; then", " echo \">> $name…\"", @@ -1407,14 +1418,21 @@ " lane \"pip-audit (backend lock)\" pip_audit", " lane \"npm audit (production dependencies)\" frontend npm audit --omit=dev --audit-level=high", "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:\"", "printf '%s\\n' \"${timings[@]}\"", "echo \"$(printf '%8s' \"$(secs $(($(ms) - gate_t0)))\") s total\"", "if [ ${#failed[@]} -eq 0 ]; then", - " echo \">> gate ($mode): green ✓\"", + " echo \">> $scope: green ✓\"", " exit 0", "fi", - "echo \">> gate ($mode): RED — ${failed[*]}\" >&2", + "echo \">> $scope: RED — ${failed[*]}\" >&2", "exit 1" ] } diff --git a/scripts/check.sh b/scripts/check.sh index 41e1217e..626255be 100644 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -9,6 +9,10 @@ # 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 # 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 "" 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= … 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 # @@ -20,18 +24,26 @@ set -u cd "$(dirname "$0")/.." || exit 2 mode=all brief=0 -for arg in "$@"; do - case "$arg" in - all | lint) mode="$arg" ;; +list=0 +only="" +usage() { echo 'usage: scripts/check.sh [lint] [--brief] [--list | --only ""]' >&2; exit 2; } +while [ $# -gt 0 ]; do + case "$1" in + all | lint) mode="$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 + shift done +[ "$list" = 1 ] && [ -n "$only" ] && usage export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below TESTCOMPOSE=backend/docker-compose.test.yml failed=() timings=() +ran=0 ms() { date +%s%3N; } 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 @@ -42,7 +54,7 @@ timed() { # timed — sets $took (no subshell: it mus took="$(secs $(($(ms) - $3)))" timings+=("$(printf '%8s s %s' "$took" "$1")$([ "$2" = red ] && echo ' (red)')") 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" fi return 0 @@ -50,6 +62,9 @@ timed() { # timed — sets $took (no subshell: it mus lane() { # lane — run it, remember a failure, never stop local name="$1" t0 result=ok shift + if [ "$list" = 1 ]; then echo "$name"; return 0; fi + [ -n "$only" ] && [ "$name" != "$only" ] && return 0 + ran=$((ran + 1)) t0="$(ms)" if [ "$brief" = 0 ]; then echo ">> $name…" @@ -163,12 +178,19 @@ if [ "$mode" = all ]; then lane "npm audit (production dependencies)" frontend npm audit --omit=dev --audit-level=high 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:" printf '%s\n' "${timings[@]}" echo "$(printf '%8s' "$(secs $(($(ms) - gate_t0)))") s total" if [ ${#failed[@]} -eq 0 ]; then - echo ">> gate ($mode): green ✓" + echo ">> $scope: green ✓" exit 0 fi -echo ">> gate ($mode): RED — ${failed[*]}" >&2 +echo ">> $scope: RED — ${failed[*]}" >&2 exit 1