#!/usr/bin/env bash # ──────────────────────────────────────────────────────────────────────────── # maple-brief commit gate # Approved order (hard, fail-closed): 1) secret → 2) build → 3) test # # commit mode (pre-commit, fast): # 1. gitleaks on STAGED changes 2. tsc --noEmit 3. LLM parse unit tests # push mode (pre-push, heavy): # 1. gitleaks on full HEAD history 2. prisma generate && next build # # Fail-closed: any stage error (missing tool, crash, timeout, findings) # fails the gate. Same tree ⇒ same verdict (gates are pure functions of # committed content; no network, no cached state). Only deliberate escape # hatch: `git commit --no-verify` / `git push --no-verify` (explicit, logged # by the remote user). # # Usage: gate.sh commit | push # ──────────────────────────────────────────────────────────────────────────── set -u MODE="${1:-commit}" ROOT="$(cd "$(dirname "$0")/../.." && pwd)" cd "$ROOT" || { echo "gate: cannot cd to repo root" >&2; exit 1; } TS0=$(date +%s) FAILURES=0 say() { printf '\n█ gate[%s] %s\n' "$MODE" "$1"; } ok() { printf '\x1b[32m PASS\x1b[0m %s\n' "$1"; } bad() { printf '\x1b[31m FAIL\x1b[0m %s\n' "$1"; FAILURES=$((FAILURES + 1)); } # --- setup (fail-closed: never proceed without the gate components) --------- command -v gitleaks >/dev/null 2>&1 || { say "setup" bad "gitleaks not found — run: bash scripts/install/install-hooks.sh" printf '\nGATE VERDICT: FAIL (setup)\n'; exit 1; } [ -d node_modules ] || { say "setup" bad "node_modules missing — run: npm ci" printf '\nGATE VERDICT: FAIL (setup)\n'; exit 1; } [ -f .gitleaks.toml ] || { say "setup"; bad ".gitleaks.toml missing (gate config)"; printf '\nGATE VERDICT: FAIL (setup)\n'; exit 1; } [ -x node_modules/.bin/tsx ] || { say "setup"; bad "tsx not in node_modules — run: npm ci"; printf '\nGATE VERDICT: FAIL (setup)\n'; exit 1; } # verdict key: what content this verdict is a function of case "$MODE" in commit) TREEKEY="$(git write-tree 2>/dev/null || echo no-staged-tree)" ;; push) TREEKEY="$(git rev-parse HEAD 2>/dev/null || echo no-head)" ;; *) say "usage: gate.sh commit|push"; exit 2 ;; esac # --- stage 1: secret -------------------------------------------------------- # $1 = "staged" | "history"; gitleaks 8.x takes no positional path — use # --staged for staged-only, no flag for full history. Exit 0 = clean, # 1 = findings, anything else = scan error (all fail-closed). stage_secret() { local scope="$1" case "$scope" in staged) say "1/3 secret — gitleaks (staged changes)"; local args=(--staged) ;; history) say "1/3 secret — gitleaks (full HEAD history)"; local args=() ;; *) bad "stage_secret: unknown scope '$scope'"; return 1 ;; esac local out rc out="$(timeout 180 gitleaks protect -c .gitleaks.toml --no-banner --no-color "${args[@]+"${args[@]}"}" 2>&1)"; rc=$? if [ "$rc" -ne 0 ]; then bad "secret scan failed (exit ${rc:-ERR})" printf '%s\n' "$out" | grep -vE '^\s*$' | tail -12 | sed 's/^/ /' else ok "no secret matches in $scope" fi } # --- stage 2: build --------------------------------------------------------- stage_build_commit() { say "2/3 build — tsc --noEmit (strict TS)" local out rc out="$(timeout 240 ./node_modules/.bin/tsc --noEmit 2>&1)"; rc=$? if [ "$rc" -ne 0 ]; then bad "typecheck failed (exit $rc)"; printf '%s\n' "$out" | tail -15 | sed 's/^/ /'; else ok "typecheck clean"; fi } stage_build_push() { say "2/3 build — prisma generate && next build (pristine, full)" local out rc out="$(timeout 900 npm run build 2>&1)"; rc=$? if [ "$rc" -ne 0 ]; then bad "build failed (exit $rc)"; printf '%s\n' "$out" | tail -20 | sed 's/^/ /'; else ok "production build clean"; fi } # --- stage 3: test ---------------------------------------------------------- stage_test() { say "3/3 test — LLM parse contract (tests/llm-parse.test.ts)" local out rc out="$(timeout 120 ./node_modules/.bin/tsx tests/llm-parse.test.ts 2>&1)"; rc=$? if [ "$rc" -ne 0 ]; then bad "unit test failed (exit $rc)"; printf '%s\n' "$out" | grep -E '^(not ok|ok|fail)' | tail -10 | sed 's/^/ /'; else ok "LLM parse contract holds ($(printf '%s\n' "$out" | grep -c '^ok ') checks)"; fi } # --- run (all stages always execute; verdict = OR of failures) -------------- case "$MODE" in commit) stage_secret staged stage_build_commit stage_test ;; push) stage_secret history stage_build_push stage_test ;; esac ELAPSED=$(( $(date +%s) - TS0 )) case "$MODE" in commit) OBJECT="staged-tree $TREEKEY" ;; push) OBJECT="HEAD $TREEKEY" ;; esac if [ "$FAILURES" -eq 0 ]; then printf '\n════════════════════════════════════════\n GATE PASS [%s] %s in %ss\n════════════════════════════════════════\n' "$MODE" "$OBJECT" "$ELAPSED" exit 0 else printf '\n════════════════════════════════════════\n GATE FAIL [%s] %s — %s stage(s) failed, in %ss\n bypass (auditable): git %s --no-verify\n════════════════════════════════════════\n' "$MODE" "$OBJECT" "$FAILURES" "$ELAPSED" "$MODE" exit 1 fi