From 08a83b87d612fa82157f962b4de7886f15199447 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:19:30 +0800 Subject: [PATCH] =?UTF-8?q?feat(dx):=20Wave=201=20session=20guards=20?= =?UTF-8?q?=E2=80=94=20retire=20repeated=20push-time=20traps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a committed pre-push safety net plus session/operator tooling that encodes this repo's most-repeated traps into machinery: - guard-push.mjs: auto-merge race sentinel (claude/* only; blocks when a PR's auto-merge is armed, ALLOW_AUTOMERGE_PUSH override, fails open when gh absent), format-before-push (closes verify:cheap vs CI gap, SKIP_FORMAT_GUARD override), drift-manifest freshness (SKIP_DRIFT_GUARD override). Includes --self-test. - .githooks/pre-push + install-git-hooks.mjs, auto-installed via a bulletproof postinstall (session-start.sh is remote-gated, so postinstall is the reliable local/CI/remote install point). - check-base-freshness.mjs: advisory stale-base tripwire wired into a second SessionStart hook (never blocks). - docs/operator-backlog.md: consolidates operator/provider-gated actions out of chat memory (statuses seeded from runbooks โ€” confirm before trust). - tests/guard-push.test.ts: 13 tests incl. sha-parity lock vs check-drift. Verified: self-test, 13/13 vitest, eslint, tsc --noEmit, prettier, and a live pre-push smoke against real git all green. Co-Authored-By: Claude Fable 5 --- .claude/settings.json | 4 + .githooks/pre-push | 26 +++ docs/operator-backlog.md | 58 ++++++ package.json | 5 + scripts/check-base-freshness.mjs | 78 +++++++ scripts/guard-push.mjs | 337 +++++++++++++++++++++++++++++++ scripts/install-git-hooks.mjs | 60 ++++++ tests/guard-push.test.ts | 75 +++++++ 8 files changed, 643 insertions(+) create mode 100644 .githooks/pre-push create mode 100644 docs/operator-backlog.md create mode 100644 scripts/check-base-freshness.mjs create mode 100644 scripts/guard-push.mjs create mode 100644 scripts/install-git-hooks.mjs create mode 100644 tests/guard-push.test.ts diff --git a/.claude/settings.json b/.claude/settings.json index e06b0338e..cf4d48112 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,6 +6,10 @@ { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + }, + { + "type": "command", + "command": "node \"$CLAUDE_PROJECT_DIR/scripts/check-base-freshness.mjs\"" } ] } diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100644 index 000000000..b3d174f7a --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,26 @@ +#!/bin/sh +# Pre-push safety net. Delegates to scripts/guard-push.mjs, which runs the +# auto-merge race sentinel, format-before-push, and drift-manifest freshness +# guards. Each guard has an explicit override env var (see the script header). +# +# Installed by scripts/install-git-hooks.mjs via `git config core.hooksPath +# .githooks` (run automatically on npm install/ci). Git passes the pushed refs +# on stdin as " " lines, which the +# script reads to scope the format and drift checks to what is actually pushed. +# +# Set GUARD_PUSH_DISABLE=1 to bypass every guard at once (individual guards also +# have their own override flags). +set -eu + +if [ "${GUARD_PUSH_DISABLE:-}" = "1" ]; then + exit 0 +fi + +# Resolve node; if unavailable, do not block the push (fail open). +if ! command -v node >/dev/null 2>&1; then + echo "[pre-push] node not found on PATH โ€” skipping push guards" >&2 + exit 0 +fi + +repo_root=$(git rev-parse --show-toplevel) +exec node "$repo_root/scripts/guard-push.mjs" "$@" diff --git a/docs/operator-backlog.md b/docs/operator-backlog.md new file mode 100644 index 000000000..9f3e1848b --- /dev/null +++ b/docs/operator-backlog.md @@ -0,0 +1,58 @@ +# Operator backlog + +Single source of truth for **human-only / provider-gated actions** that cannot be done from a coding +session (they touch Supabase, Railway, OpenAI, or GitHub settings, per the AGENTS.md provider boundary). +This exists so that launch-blocking state lives in the repo instead of chat memory. + +**How to use:** work top to bottom; each row links to the detailed runbook. `Status` values are +`โณ pending`, `๐Ÿ”Ž verify` (may already be done โ€” confirm before repeating), `โœ… done`, `โ€”` (n/a). +Update the row (and its runbook) when an action lands. The sequenced flow with exact commands and +approval gates is [launch-operator-runbook.md](launch-operator-runbook.md); this table is the index. + +> Status column is seeded from repo runbooks + session memory and **must be confirmed against live +> state** before acting โ€” do not treat a `๐Ÿ”Ž verify` row as authoritative. + +## Launch-gating actions + +| Action | Status | Blocked by | Verify command | Runbook | +| -------------------------------------------------------------------------- | ---------- | --------------------------------------------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | +| Apply July-8 migration batch (aโ€“g) to live | ๐Ÿ”Ž verify | queue must be quiet | `SUPABASE_ENVIRONMENT=production npm run check:july8-live-batch` | [operator-apply-july8-batch.md](operator-apply-july8-batch.md) | +| Apply drift-codify forward migration (step 1h) | โณ pending | migration artifact committed + live fingerprint recapture | `npm run check:drift` then `npm run eval:retrieval:quality` (36/36) | [database-drift-detection.md](database-drift-detection.md) | +| Full release gate (bounded OpenAI spend) | โณ pending | migrations 1 applied | `npm run verify:release`; `npm run eval:quality -- --rag-only` | [launch-operator-runbook.md ยง2](launch-operator-runbook.md) | +| Provision staging Supabase project (`Clinical KB Staging`, ap-southeast-2) | โณ pending | โ€” | `npm run check:indexing` after `db push` | [staging-setup.md](staging-setup.md) | +| Staging soak + rollback rehearsal on Railway | โณ pending | staging provisioned | `scripts/soak-test.ts --confirm-staging` (answer p95 โ‰ค 25 s) | [launch-operator-runbook.md ยง4](launch-operator-runbook.md) ยท [capacity-review.md](capacity-review.md) | +| Production deploy to Railway | โณ pending | release gate + soak pass | `GET /api/health` โ†’ `{"status":"ok"}`; `npm run check:deployment-readiness` | [deployment-architecture.md](deployment-architecture.md) | + +## Post-deploy actions + +| Action | Status | Blocked by | Verify command | Runbook | +| --------------------------------------------------------------------- | ---------- | ------------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Redeploy worker (one always-on instance) | โณ pending | migration `20260708130000` live | `npm run reindex:health` | [worker-deploy-runbook.md](worker-deploy-runbook.md) | +| Seed registry / differentials / medications (prod) | โณ pending | prod deploy | Services/Forms surfaces non-empty | [launch-operator-runbook.md ยง6](launch-operator-runbook.md) | +| Switch auth connection cap 10-absolute โ†’ percentage-based (dashboard) | โณ pending | before first vertical scale-up | dashboard โ€” not SQL/MCP settable | [auth-connection-cap-runbook.md](auth-connection-cap-runbook.md) ยท [capacity-review.md](capacity-review.md) | +| Wire SLO warn/page thresholds into a real alert channel | โณ pending | host metrics exist | nightly eval canary green from `main` (one `workflow_dispatch`) | [observability-slos.md](observability-slos.md) | + +## Standing secret / config placement (per environment) + +Each environment gets **separate** service-role + OpenAI keys (per-env blast radius). Placement is a +dashboard/CLI action, never committed. + +| Secret / config | Status | Where | Notes | +| ------------------------------------- | ---------- | ---------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `RAG_QUERY_HASH_SECRET` (prod) | ๐Ÿ”Ž verify | Railway runtime secret | PIA-2 fail-closed guard requires it at boot (min 16 chars); its absence has broken main CI before | +| `SUPABASE_SERVICE_ROLE_KEY` (per env) | โณ pending | Railway runtime secret | accepts the `sb_secret_โ€ฆ` key | +| `OPENAI_API_KEY` (per env) | โณ pending | Railway runtime secret | `RAG_PROVIDER_MODE=auto` | +| OpenAI DPA / ZDR execution | โณ pending | OpenAI account + legal | app endpoints are ZDR-eligible; execution is operator + legal โ€” see [openai-cross-border-basis.md](openai-cross-border-basis.md) | + +## Disaster-recovery re-creation (does NOT survive a schema restore) + +Per [disaster-recovery-runbook.md](disaster-recovery-runbook.md) โ€” config & secrets are the layer a schema +restore does not bring back: + +| Action | Status | Notes | +| ------------------------------------------------------------------------------------------------------------------ | ---------- | -------------------------------- | +| Re-create pg_cron schedules | โณ pending | e.g. ingestion / retention crons | +| Re-add Vault secrets (`cron_ingestion_jwt`) | โณ pending | โ€” | +| Re-set custom GUCs | โณ pending | โ€” | +| Redeploy edge functions | โณ pending | needs Deno v2.x | +| Re-enter dashboard config (auth providers/SSO redirect URLs, connection-pool caps, per-project keys, `E2E_USER_*`) | โณ pending | โ€” | diff --git a/package.json b/package.json index da84b2f3d..b34baaebf 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,11 @@ "scripts": { "dev": "node scripts/dev-free-port.mjs", "preinstall": "node scripts/check-node-engine.cjs", + "postinstall": "node scripts/install-git-hooks.mjs", + "hooks:install": "node scripts/install-git-hooks.mjs", + "guard:push": "node scripts/guard-push.mjs", + "guard:push:self-test": "node scripts/guard-push.mjs --self-test", + "check:base-freshness": "node scripts/check-base-freshness.mjs", "ensure": "node scripts/ensure-local-server.mjs", "build": "node scripts/guard-next-build.mjs && node --max-old-space-size=8192 ./node_modules/next/dist/bin/next build --webpack && node scripts/check-client-bundle-secrets.mjs", "build:analyze": "node scripts/build-analyze.mjs", diff --git a/scripts/check-base-freshness.mjs b/scripts/check-base-freshness.mjs new file mode 100644 index 000000000..5cf55a6e4 --- /dev/null +++ b/scripts/check-base-freshness.mjs @@ -0,0 +1,78 @@ +#!/usr/bin/env node +/** + * check-base-freshness โ€” advisory stale-base tripwire. + * + * main moves fast (claude/* branches auto-merge on green), so a worktree branched + * a while ago can be many commits behind origin/main โ€” which is how duplicate work + * gets built against a stale base. This fetches origin/main and reports how far + * behind the current branch is, warning loudly past a threshold. + * + * ADVISORY ONLY โ€” never exits non-zero on staleness, so it is safe to wire into a + * SessionStart hook or statusline without ever blocking work. Exit is non-zero + * only on a genuine tooling error under `--strict` (off by default). + * + * Env: + * STALE_BASE_THRESHOLD commits-behind that triggers the loud warning (default 10) + * BASE_FRESHNESS_NO_FETCH=1 skip the network fetch (use last-known origin/main) + * Flags: + * --json machine-readable output + * --strict exit 1 when the base ref cannot be resolved (default: exit 0) + */ +import { execFileSync } from "node:child_process"; + +const threshold = Number.parseInt(process.env.STALE_BASE_THRESHOLD ?? "10", 10) || 10; +const asJson = process.argv.includes("--json"); +const strict = process.argv.includes("--strict"); + +function git(args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +function tryGit(args) { + try { + return git(args); + } catch { + return undefined; + } +} + +function finish(result) { + if (asJson) { + console.log(JSON.stringify(result)); + } else if (result.error) { + console.error(`[base-freshness] ${result.error}`); + } else if (result.behind > threshold) { + console.error( + `\nโš  [base-freshness] ${result.branch} is ${result.behind} commits BEHIND origin/main ` + + `(ahead ${result.ahead}).\n` + + ` You may be building on a stale base โ€” rebase/merge origin/main before starting new work.\n`, + ); + } else { + console.error( + `[base-freshness] ${result.branch}: behind ${result.behind}, ahead ${result.ahead} vs origin/main โ€” ok`, + ); + } + process.exit(result.error && strict ? 1 : 0); +} + +const branch = tryGit(["rev-parse", "--abbrev-ref", "HEAD"]) ?? "(unknown)"; + +if (process.env.BASE_FRESHNESS_NO_FETCH !== "1") { + try { + execFileSync("git", ["fetch", "--quiet", "origin", "main"], { stdio: "ignore" }); + } catch { + // Offline or no remote โ€” fall back to whatever origin/main we already have. + } +} + +if (!tryGit(["rev-parse", "--verify", "--quiet", "origin/main"])) { + finish({ branch, error: "origin/main not resolvable (no remote or never fetched)", behind: 0, ahead: 0 }); +} + +const counts = tryGit(["rev-list", "--left-right", "--count", "origin/main...HEAD"]); +if (!counts) { + finish({ branch, error: "could not compute ahead/behind vs origin/main", behind: 0, ahead: 0 }); +} + +const [behind, ahead] = counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0); +finish({ branch, behind, ahead, threshold }); diff --git a/scripts/guard-push.mjs b/scripts/guard-push.mjs new file mode 100644 index 000000000..432f4925b --- /dev/null +++ b/scripts/guard-push.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node +/** + * guard-push โ€” pre-push safety net for this repo's known, repeated traps. + * + * Runs three independent guards; any one can BLOCK the push (non-zero exit) and + * each honours an explicit override env var so you are never truly stuck: + * + * 1. Auto-merge race sentinel (claude/* branches only) + * This repo auto-merges claude/* PRs on green. Pushing a late follow-up + * commit to a PR whose auto-merge is already armed races the merge and has + * orphaned commits before. If `gh` reports an armed autoMergeRequest for the + * current branch's open PR, block. Override: ALLOW_AUTOMERGE_PUSH=1. + * Fails OPEN (never blocks) when gh is missing/unauthenticated, so + * contributors without gh can still push. + * + * 2. Format-before-push + * verify:cheap does NOT run format:check but CI requires it, so unformatted + * files reach CI and fail there. Runs `prettier --check` on the files in the + * push range. Override: SKIP_FORMAT_GUARD=1. + * + * 3. Drift-manifest freshness + * Editing supabase/schema.sql without regenerating supabase/drift-manifest.json + * fails check:drift in CI. Caught here at push time instead. Override: + * SKIP_DRIFT_GUARD=1. + * + * The .githooks/pre-push hook invokes this with the raw `git push` stdin (lines of + * " "). Run `--self-test` for the + * offline unit checks used by tests/guard-push.test.ts. + */ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const ZERO_SHA = "0000000000000000000000000000000000000000"; +const SCHEMA_PATH = "supabase/schema.sql"; +const MANIFEST_PATH = "supabase/drift-manifest.json"; + +/** + * sha256 over CRLF-normalized schema text. MUST stay byte-identical to + * normalizedSchemaSha256() in scripts/check-drift.ts โ€” tests/guard-push.test.ts + * asserts parity so the two cannot silently diverge. + */ +export function normalizedSchemaSha256(schemaSqlText) { + return createHash("sha256").update(schemaSqlText.replace(/\r\n/g, "\n")).digest("hex"); +} + +function runGit(args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +function tryGit(args) { + try { + return runGit(args); + } catch { + return undefined; + } +} + +function currentBranch() { + return tryGit(["rev-parse", "--abbrev-ref", "HEAD"]) ?? ""; +} + +/** + * Resolve the set of files being pushed from the pre-push stdin payload. Each + * line is " ". For a brand-new remote + * branch (remoteSha all-zero) we diff against origin/main so we still see the new + * work rather than the whole history. Deletion pushes (local sha all-zero) are + * dropped, so an empty array means "nothing to check". + */ +export function parsePushRanges(stdinText) { + const ranges = []; + for (const raw of stdinText.split("\n")) { + const line = raw.trim(); + if (!line) continue; + const [localRef, localSha, , remoteSha] = line.split(/\s+/); + if (!localSha || localSha === ZERO_SHA) continue; // branch deletion โ€” nothing to push + ranges.push({ localRef, localSha, remoteSha: remoteSha ?? ZERO_SHA }); + } + return ranges; +} + +function changedFilesForRange(range) { + const base = + range.remoteSha && range.remoteSha !== ZERO_SHA + ? range.remoteSha + : tryGit(["rev-parse", "--verify", "--quiet", "origin/main"]) + ? "origin/main" + : undefined; + const spec = base ? `${base}..${range.localSha}` : range.localSha; + const out = base + ? tryGit(["diff", "--name-only", spec]) + : tryGit(["show", "--name-only", "--pretty=format:", range.localSha]); + if (!out) return []; + return out + .split("\n") + .map((f) => f.trim()) + .filter(Boolean); +} + +function collectChangedFiles(ranges) { + const files = new Set(); + for (const range of ranges) { + for (const file of changedFilesForRange(range)) files.add(file); + } + return [...files]; +} + +// --------------------------------------------------------------------------- +// Guard 1: auto-merge race sentinel +// --------------------------------------------------------------------------- +function ghIsAvailable() { + try { + execFileSync("gh", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +/** Exported for tests: decide from a parsed `gh pr view` payload. */ +export function autoMergeVerdict(branch, prPayload) { + if (!branch.startsWith("claude/")) return { block: false, reason: "not-a-claude-branch" }; + if (!prPayload) return { block: false, reason: "no-open-pr" }; + if (prPayload.state && prPayload.state !== "OPEN") return { block: false, reason: "pr-not-open" }; + if (prPayload.autoMergeRequest) { + return { block: true, reason: "auto-merge-armed", number: prPayload.number }; + } + return { block: false, reason: "auto-merge-not-armed" }; +} + +function autoMergeGuard(branch) { + if (process.env.ALLOW_AUTOMERGE_PUSH === "1") { + return { name: "auto-merge", ok: true, skipped: "ALLOW_AUTOMERGE_PUSH=1" }; + } + if (!branch.startsWith("claude/")) return { name: "auto-merge", ok: true }; + if (!ghIsAvailable()) { + return { name: "auto-merge", ok: true, note: "gh not available โ€” auto-merge check skipped (fail-open)" }; + } + let payload; + try { + const raw = execFileSync("gh", ["pr", "view", "--json", "autoMergeRequest,state,number"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + payload = JSON.parse(raw); + } catch { + // No PR for this branch, or gh unauthenticated: fail open. + return { name: "auto-merge", ok: true, note: "no open PR resolvable โ€” auto-merge check skipped" }; + } + const verdict = autoMergeVerdict(branch, payload); + if (verdict.block) { + return { + name: "auto-merge", + ok: false, + message: + `PR #${verdict.number} on ${branch} has auto-merge ARMED.\n` + + ` Pushing now races the squash-merge and can orphan this commit (it has happened before).\n` + + ` Let the armed merge land first, or disable auto-merge on the PR, then push.\n` + + ` To push anyway: ALLOW_AUTOMERGE_PUSH=1 git push`, + }; + } + return { name: "auto-merge", ok: true }; +} + +// --------------------------------------------------------------------------- +// Guard 2: format-before-push +// --------------------------------------------------------------------------- +function resolvePrettierBin() { + const require = createRequire(import.meta.url); + const pkgJson = require.resolve("prettier/package.json"); + return path.join(path.dirname(pkgJson), "bin", "prettier.cjs"); +} + +function formatGuard(changedFiles) { + if (process.env.SKIP_FORMAT_GUARD === "1") { + return { name: "format", ok: true, skipped: "SKIP_FORMAT_GUARD=1" }; + } + const existing = changedFiles.filter((f) => existsSync(f)); + if (existing.length === 0) return { name: "format", ok: true }; + let prettierBin; + try { + prettierBin = resolvePrettierBin(); + } catch { + return { name: "format", ok: true, note: "prettier not resolvable โ€” format check skipped" }; + } + try { + // prettier respects .prettierignore for listed paths; --ignore-unknown skips + // files it has no parser for (e.g. images) without failing. + execFileSync(process.execPath, [prettierBin, "--check", "--ignore-unknown", ...existing], { + stdio: ["ignore", "pipe", "pipe"], + }); + return { name: "format", ok: true }; + } catch (error) { + const detail = [error.stdout, error.stderr] + .map((b) => (b ? b.toString() : "")) + .join("") + .trim(); + return { + name: "format", + ok: false, + message: + `Prettier found unformatted files in this push (CI format:check would fail):\n` + + (detail ? `${detail}\n` : "") + + ` Fix with: npm run format\n` + + ` To push anyway: SKIP_FORMAT_GUARD=1 git push`, + }; + } +} + +// --------------------------------------------------------------------------- +// Guard 3: drift-manifest freshness +// --------------------------------------------------------------------------- +/** Exported for tests: pure comparison of schema text vs the manifest's sha. */ +export function driftVerdict(schemaText, manifestJson) { + const expected = manifestJson?.schema_sha256; + const actual = normalizedSchemaSha256(schemaText); + return { stale: Boolean(expected) && expected !== actual, expected, actual }; +} + +function driftGuard(changedFiles) { + if (process.env.SKIP_DRIFT_GUARD === "1") { + return { name: "drift", ok: true, skipped: "SKIP_DRIFT_GUARD=1" }; + } + const schemaTouched = changedFiles.some((f) => f.replaceAll("\\", "/") === SCHEMA_PATH); + if (!schemaTouched) return { name: "drift", ok: true }; + if (!existsSync(SCHEMA_PATH) || !existsSync(MANIFEST_PATH)) return { name: "drift", ok: true }; + let manifest; + try { + manifest = JSON.parse(readFileSync(MANIFEST_PATH, "utf8")); + } catch { + return { name: "drift", ok: true, note: "drift-manifest.json unreadable โ€” drift check skipped" }; + } + const verdict = driftVerdict(readFileSync(SCHEMA_PATH, "utf8"), manifest); + if (verdict.stale) { + return { + name: "drift", + ok: false, + message: + `${SCHEMA_PATH} changed but ${MANIFEST_PATH} is stale (check:drift would fail).\n` + + ` schema sha: ${verdict.actual}\n` + + ` manifest sha: ${verdict.expected}\n` + + ` Regenerate with: npm run drift:manifest (needs Docker)\n` + + ` To push anyway: SKIP_DRIFT_GUARD=1 git push`, + }; + } + return { name: "drift", ok: true }; +} + +// --------------------------------------------------------------------------- +// Orchestration +// --------------------------------------------------------------------------- +function report(results) { + const blocked = results.filter((r) => !r.ok); + for (const r of results) { + if (r.skipped) console.error(`[guard-push] ${r.name}: skipped (${r.skipped})`); + else if (r.note) console.error(`[guard-push] ${r.name}: ${r.note}`); + } + if (blocked.length === 0) return 0; + console.error("\n[guard-push] Push blocked:\n"); + for (const r of blocked) console.error(`โœ– ${r.name}\n ${r.message}\n`); + return 1; +} + +function main() { + if (process.argv.includes("--self-test")) { + return selfTest(); + } + const stdin = readStdinSync(); + const ranges = parsePushRanges(stdin); + if (ranges.length === 0) process.exit(0); // deletion-only push or nothing to do + const branch = currentBranch(); + const changedFiles = collectChangedFiles(ranges); + const results = [autoMergeGuard(branch), formatGuard(changedFiles), driftGuard(changedFiles)]; + process.exit(report(results)); +} + +function readStdinSync() { + try { + return readFileSync(0, "utf8"); + } catch { + return ""; + } +} + +// --------------------------------------------------------------------------- +// Self-test: offline assertions on the pure decision functions. +// --------------------------------------------------------------------------- +function assert(condition, label) { + if (!condition) { + console.error(`โœ– self-test failed: ${label}`); + process.exitCode = 1; + throw new Error(label); + } +} + +function selfTest() { + // auto-merge verdicts + assert(autoMergeVerdict("main", { autoMergeRequest: {} }).block === false, "non-claude branch never blocks"); + assert( + autoMergeVerdict("claude/x", { autoMergeRequest: { enabledAt: "t" }, state: "OPEN", number: 7 }).block === true, + "armed auto-merge on claude/* blocks", + ); + assert( + autoMergeVerdict("claude/x", { autoMergeRequest: null, state: "OPEN" }).block === false, + "unarmed does not block", + ); + assert(autoMergeVerdict("claude/x", null).block === false, "no PR does not block"); + assert( + autoMergeVerdict("claude/x", { autoMergeRequest: {}, state: "MERGED" }).block === false, + "non-open PR does not block", + ); + + // drift verdicts + const text = "create table t();\n"; + const sha = normalizedSchemaSha256(text); + assert(driftVerdict(text, { schema_sha256: sha }).stale === false, "matching sha is fresh"); + assert(driftVerdict(text, { schema_sha256: "deadbeef" }).stale === true, "mismatched sha is stale"); + assert(driftVerdict(text, {}).stale === false, "no manifest sha is treated as fresh (no false block)"); + assert(normalizedSchemaSha256("a\r\nb") === normalizedSchemaSha256("a\nb"), "CRLF normalization matches LF"); + + // push-range parsing + const ranges = parsePushRanges(`refs/heads/x abc123 refs/heads/x ${ZERO_SHA}\n`); + assert(ranges.length === 1 && ranges[0].remoteSha === ZERO_SHA, "new-branch range parsed"); + assert(parsePushRanges(`refs/heads/x ${ZERO_SHA} refs/heads/x abc\n`).length === 0, "deletion range skipped"); + + if (process.exitCode !== 1) console.error("[guard-push] self-test passed"); + return process.exitCode ?? 0; +} + +// Only run as a CLI when invoked directly โ€” importing (tests) must not exit. +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (invokedDirectly) { + main(); +} diff --git a/scripts/install-git-hooks.mjs b/scripts/install-git-hooks.mjs new file mode 100644 index 000000000..a08919203 --- /dev/null +++ b/scripts/install-git-hooks.mjs @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * install-git-hooks โ€” point git at the committed .githooks/ directory. + * + * Runs automatically from the package.json `postinstall` script, so every + * `npm install` / `npm ci` (local, CI, and Claude web containers) self-installs + * the pre-push guards. The repo's SessionStart hook only runs on remote web + * containers, so postinstall is the reliable cross-surface install point. + * + * Contract: this must NEVER fail an install. Every failure path swallows the + * error and exits 0. It is idempotent โ€” a no-op when core.hooksPath is already + * `.githooks`. + */ +import { execFileSync } from "node:child_process"; +import { chmodSync, existsSync } from "node:fs"; +import path from "node:path"; + +const HOOKS_DIR = ".githooks"; + +function git(args) { + return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +try { + // Only act inside a git work tree. + let insideRepo = false; + try { + insideRepo = git(["rev-parse", "--is-inside-work-tree"]) === "true"; + } catch { + insideRepo = false; + } + if (!insideRepo) process.exit(0); + + let current = ""; + try { + current = git(["config", "--get", "core.hooksPath"]); + } catch { + current = ""; + } + + if (current !== HOOKS_DIR) { + git(["config", "core.hooksPath", HOOKS_DIR]); + console.log(`[install-git-hooks] core.hooksPath set to ${HOOKS_DIR}`); + } + + // Best-effort: ensure the hook is executable on POSIX (harmless on Windows). + const prePush = path.join(process.cwd(), HOOKS_DIR, "pre-push"); + if (existsSync(prePush)) { + try { + chmodSync(prePush, 0o755); + } catch { + /* non-fatal */ + } + } +} catch (error) { + // Never break `npm install` over hook setup. + console.warn(`[install-git-hooks] skipped: ${error?.message ?? error}`); +} + +process.exit(0); diff --git a/tests/guard-push.test.ts b/tests/guard-push.test.ts new file mode 100644 index 000000000..2fa4e3394 --- /dev/null +++ b/tests/guard-push.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { normalizedSchemaSha256 as driftSha } from "../scripts/check-drift"; +import { + autoMergeVerdict, + driftVerdict, + normalizedSchemaSha256 as guardSha, + parsePushRanges, +} from "../scripts/guard-push.mjs"; + +const ZERO = "0".repeat(40); + +describe("guard-push sha parity", () => { + it("guard-push's sha is byte-identical to check-drift's (they must never diverge)", () => { + for (const sample of ["create table t();\n", "a\r\nb\r\n", "", "SELECT 1;"]) { + expect(guardSha(sample)).toBe(driftSha(sample)); + } + }); + + it("normalizes CRLF to LF", () => { + expect(guardSha("a\r\nb")).toBe(guardSha("a\nb")); + }); +}); + +describe("auto-merge verdict", () => { + it("never blocks a non-claude branch", () => { + expect(autoMergeVerdict("main", { autoMergeRequest: {}, state: "OPEN" }).block).toBe(false); + }); + + it("blocks a claude/* branch with an armed auto-merge on an open PR", () => { + const v = autoMergeVerdict("claude/x", { autoMergeRequest: { enabledAt: "t" }, state: "OPEN", number: 7 }); + expect(v.block).toBe(true); + expect(v.number).toBe(7); + }); + + it("does not block when auto-merge is not armed", () => { + expect(autoMergeVerdict("claude/x", { autoMergeRequest: null, state: "OPEN" }).block).toBe(false); + }); + + it("does not block when there is no open PR", () => { + expect(autoMergeVerdict("claude/x", null).block).toBe(false); + }); + + it("does not block when the PR is not OPEN", () => { + expect(autoMergeVerdict("claude/x", { autoMergeRequest: {}, state: "MERGED" }).block).toBe(false); + }); +}); + +describe("drift verdict", () => { + const text = "create table t();\n"; + it("is fresh when the manifest sha matches", () => { + expect(driftVerdict(text, { schema_sha256: guardSha(text) }).stale).toBe(false); + }); + it("is stale when the manifest sha differs", () => { + expect(driftVerdict(text, { schema_sha256: "deadbeef" }).stale).toBe(true); + }); + it("never false-blocks when the manifest has no sha", () => { + expect(driftVerdict(text, {}).stale).toBe(false); + }); +}); + +describe("push-range parsing", () => { + it("parses a new-branch push (zero remote sha)", () => { + const ranges = parsePushRanges(`refs/heads/x abc123 refs/heads/x ${ZERO}\n`); + expect(ranges).toHaveLength(1); + expect(ranges[0].remoteSha).toBe(ZERO); + }); + + it("skips a branch-deletion push (zero local sha)", () => { + expect(parsePushRanges(`refs/heads/x ${ZERO} refs/heads/x abc\n`)).toHaveLength(0); + }); + + it("ignores blank lines", () => { + expect(parsePushRanges("\n \n")).toHaveLength(0); + }); +});