#!/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 # # 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 for arg in "$@"; do case "$arg" in all | lint) mode="$arg" ;; --brief) brief=1 ;; *) echo "usage: scripts/check.sh [lint] [--brief]" >&2; exit 2 ;; esac done export MSYS_NO_PATHCONV=1 # Git Bash: do not rewrite the in-container paths below TESTCOMPOSE=backend/docker-compose.test.yml failed=() lane() { # lane — run it, remember a failure, never stop local name="$1" shift if [ "$brief" = 0 ]; then echo ">> $name…" "$@" || failed+=("$name") return 0 fi local out out="$(mktemp)" if "$@" >"$out" 2>&1; then echo ">> $name: ok" else failed+=("$name") echo ">> $name: FAILED" 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 && "$@"); } 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 if [ ${#failed[@]} -eq 0 ]; then echo ">> gate ($mode): green ✓" exit 0 fi echo ">> gate ($mode): RED — ${failed[*]}" >&2 exit 1