#!/usr/bin/env bash # The gate — every lane, in one place, run from the repo root (since CI went: the lab runner # duplicated what this already ran before every release, and one CI run starved the git host of # memory until it stopped answering). The release dispatcher calls this; so can anyone with Docker # and the pinned toolchain. `frontend/src/lib/gates.contract.test.ts` holds its lines, so a lane # dropped or narrowed here fails a test like any other gate change. # # bash scripts/check.sh every lane (what a release requires, before e2e and the CVE scan) # 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 # # Each lane runs even when an earlier one failed, and the summary names every red one: a gate that # stops at the first failure hides the second until the first is fixed. The backend lanes run in the # test image (backend/Dockerfile.test): requirements.lock will not install on Windows (uvloop), and a # type checker or auditor without the real packages reports nonsense. set -u cd "$(dirname "$0")/.." || exit 2 mode=all brief=0 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 ;; *) 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 # file — one tab-separated row per lane appended to it (UTC time, mode, lane, seconds, ok|red, tree), so # runs can be compared. The script writes no file unless asked to. took="" timed() { # timed — sets $took (no subshell: it must add to $timings) 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)" "${only:+only}${only:-$mode}" "$1" "$took" "$2" \ "$(git rev-parse --short 'HEAD^{tree}' 2>/dev/null)" >>"$GATE_TIMES_FILE" fi return 0 } 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…" "$@" || { failed+=("$name"); result=red; } timed "$name" "$result" "$t0" echo "<< $name: $took s" return 0 fi local out out="$(mktemp)" if "$@" >"$out" 2>&1; then timed "$name" ok "$t0" echo ">> $name: ok ($took s)" else failed+=("$name") timed "$name" red "$t0" echo ">> $name: FAILED ($took s)" brief_excerpt "$out" fi rm -f "$out" } # What broke, in a log's own words. A short log is shown whole: a keyword filter keeps ruff's # summary line and drops the rule and file above it, and eslint's file path sits on a line of its # own above its errors. A long one shows its first 20 error-looking lines, each with the line before # it and the two after (knip names the file on the line after "Unused files"); a long log with none # of them shows its tail, which is where a summary sits. The test database's own log is left out: # the DB lane provokes constraint errors on purpose, and its `ERROR:` lines filled the excerpt ahead # of the failing test. brief_excerpt() { local log hits log="$(grep -v -E '^testdb-[0-9]+ ' "$1")" if [ "$(printf '%s\n' "$log" | wc -l)" -le 60 ]; then printf '%s\n' "$log" return fi hits="$(printf '%s\n' "$log" | grep -E -i -B 1 -A 2 -m 20 'error|fail|✗|×|warn|would reformat|unused|vulnerab|found [0-9]')" if [ -n "$hits" ]; then printf '%s\n' "$hits"; else printf '%s\n' "$log" | tail -n 20; fi } # --- static lanes ------------------------------------------------------------------------------------ # Host ruff, held to the pin in requirements-dev.txt: a newer ruff silently changes what "formatted" # means. `--config backend/ruff.toml` on both: it makes that file the WHOLE configuration (ruff then # ignores every other config it would discover) and gives scripts/ the backend's rule set. ruff_pinned() { local pin host pin="$(sed -n 's/^ruff==\([0-9.]*\).*/\1/p' backend/requirements-dev.txt)" host="$(python -m ruff --version 2>/dev/null | awk '{print $2}')" [ -n "$pin" ] && [ "$host" = "$pin" ] && return 0 echo "ruff ${host:-missing} on the host, ${pin:-nothing} pinned — python -m pip install ruff==$pin" >&2 return 1 } ruff_check() { ruff_pinned && python -m ruff check --config backend/ruff.toml backend scripts; } ruff_format() { ruff_pinned && python -m ruff format --check --config backend/ruff.toml backend scripts; } # New errors only: the pre-existing ones are in backend/.basedpyright/baseline.json. basedpyright # TIGHTENS that file by itself when errors go, and the tree is bind-mounted read-write, so a green run # can leave it rewritten: that fails here until it is committed, or the next run's stack reads stale. basedpyright() { local before before="$(git hash-object backend/.basedpyright/baseline.json)" docker compose -f "$TESTCOMPOSE" run --rm --no-deps --build test \ python -m basedpyright --project pyrightconfig.json || return 1 if [ "$(git hash-object backend/.basedpyright/baseline.json)" != "$before" ]; then echo "this run rewrote backend/.basedpyright/baseline.json (errors were fixed) — commit it" >&2 return 1 fi git diff HEAD --quiet -- backend/.basedpyright/baseline.json && return 0 echo "backend/.basedpyright/baseline.json is uncommitted — commit it" >&2 return 1 } frontend() { (cd frontend && "$@"); } gate_t0="$(ms)" lane "ruff" ruff_check lane "ruff format --check" ruff_format lane "basedpyright (new errors only)" basedpyright # every frontend lane judges the HOST node_modules: this proves it is the tree the lock pins lane "lock check (frontend/node_modules == package-lock.json)" node scripts/check-node-modules.mjs lane "tsc (src + configs + e2e)" frontend npm run --silent typecheck lane "eslint (warnings fail too)" frontend npm run --silent lint lane "prettier --check" frontend npm run --silent format:check lane "knip (unused files, exports, dependencies)" frontend npm run --silent knip # --- tests, build, audits -------------------------------------------------------------------------- pytest_lane() { # the full suite against a throwaway Postgres; --pull keeps the base image current docker compose -f "$TESTCOMPOSE" build --pull -q test || return 1 local rc=0 docker compose -f "$TESTCOMPOSE" up --build --abort-on-container-exit --exit-code-from test || rc=1 docker compose -f "$TESTCOMPOSE" down -v >/dev/null 2>&1 return $rc } # Every pinned backend package against the advisory databases, at any severity — the lock is a full # `pip freeze`, so it is audited as written, without a resolver. An advisory with no fix is answered # with `--ignore-vuln ` here and a row in docs/gates.md (the register test reads this line). pip_audit() { docker compose -f "$TESTCOMPOSE" run --rm --no-deps test \ pip-audit -r requirements.lock --no-deps --disable-pip } if [ "$mode" = all ]; then lane "vitest" frontend npm run --silent test # the production bundle, and devOnlyGuard, which fails it when dev-only code reaches it lane "vite build" frontend npx vite build --logLevel warn lane "pytest (pure-logic + DB lane)" pytest_lane lane "pip-audit (backend lock)" pip_audit # what ships to the browser, at high or above; the dev toolchain is burned down by hand 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 ">> $scope: green ✓" exit 0 fi echo ">> $scope: RED — ${failed[*]}" >&2 exit 1