From f2e349de661abc59c87f4dd2f9d97407bf5eff74 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 28 Aug 2026 00:48:02 -0400 Subject: [PATCH 1/2] ci(gate): add sea-ref-gate to forbid regrown SEA-NNN issue refs (RIG-2804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fail-closed CI check that the compass tree carries no retired `SEA-NNN` issue reference, keeping the RIG-2804 P1 flip from silently re-rotting — the same pattern as orion-ref-gate and design-ledger-gate. A sibling to orion-ref-gate, not an extension of it: that gate is deliberately single-token ("SCOPE: orion ONLY... does not double as a general brand-token gate"), so the SEA boundary gets its own gate rather than bloating it. The gate matches the UPPERCASE-NUMERIC `SEA-` whole-word token — exactly what the P1 codemod flipped, so the two are symmetric: the gate goes green precisely when the flip is complete. It does NOT match lowercase `sea-` branch slugs or `SEA-nnn` placeholders, mirroring the codemod. Carve-outs mirror orion-ref-gate: forks/** vendored subtrees (but not first-party forks/README.md), the gate's own source, generated eng-docs copies, and bun.lock. Wired into `moon run :ci` via the `ci` aggregate; never cached (the subject is the live tracked tree). Verified: 22 unit tests over the pure core (token match, carve-outs, grep-hit parsing, runner exit codes); the gate runs clean on the flipped tree and trips red (exit 1) on an injected `SEA-9999`. Refs RIG-2804. Co-authored-by: Matt Wilkinson --- .moon/workspace.yml | 6 ++ bun.lock | 12 +++ tools/sea-ref-gate/biome.json | 3 + tools/sea-ref-gate/index.test.ts | 169 ++++++++++++++++++++++++++++++ tools/sea-ref-gate/index.ts | 171 +++++++++++++++++++++++++++++++ tools/sea-ref-gate/moon.yml | 43 ++++++++ tools/sea-ref-gate/package.json | 14 +++ tools/sea-ref-gate/tsconfig.json | 19 ++++ 8 files changed, 437 insertions(+) create mode 100644 tools/sea-ref-gate/biome.json create mode 100644 tools/sea-ref-gate/index.test.ts create mode 100644 tools/sea-ref-gate/index.ts create mode 100644 tools/sea-ref-gate/moon.yml create mode 100644 tools/sea-ref-gate/package.json create mode 100644 tools/sea-ref-gate/tsconfig.json diff --git a/.moon/workspace.yml b/.moon/workspace.yml index be70056d1..bee129677 100644 --- a/.moon/workspace.yml +++ b/.moon/workspace.yml @@ -79,6 +79,12 @@ projects: # in the moon-driven CI sweep — unregistered, moon never discovers it and the # boundary re-rots uncaught. orion-ref-gate: 'tools/orion-ref-gate' + # The sea-ref-gate boundary gate (RIG-2804): fail-closed scan that the compass + # tree carries no retired SEA-NNN issue ref (the Linear team key is RIG, + # numbers preserved 1:1). Sibling to orion-ref-gate; registered here so its + # check + typecheck + unit test run in the moon-driven CI sweep — unregistered, + # moon never discovers it and the flipped boundary re-rots uncaught. + sea-ref-gate: 'tools/sea-ref-gate' # The cx-token consumption gate: scans apps/ui/src CSS for raw hex, --rigel-* # primitives, and literal duration/easing values banned outside tokens.css # (D2/D9 consumption rule; RIG-1812). Mirrors design-ledger-gate; the mark diff --git a/bun.lock b/bun.lock index 6bab1a22b..7f4310773 100644 --- a/bun.lock +++ b/bun.lock @@ -196,6 +196,16 @@ "typescript": "catalog:", }, }, + "tools/sea-ref-gate": { + "name": "@compass/sea-ref-gate", + "bin": { + "sea-ref-gate": "./index.ts", + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:", + }, + }, "tools/stamp-gate": { "name": "@compass/stamp-gate", "devDependencies": { @@ -431,6 +441,8 @@ "@compass/renovate-preflight": ["@compass/renovate-preflight@workspace:tools/renovate-preflight"], + "@compass/sea-ref-gate": ["@compass/sea-ref-gate@workspace:tools/sea-ref-gate"], + "@compass/stamp-gate": ["@compass/stamp-gate@workspace:tools/stamp-gate"], "@compass/toolchain-parity": ["@compass/toolchain-parity@workspace:tools/toolchain"], diff --git a/tools/sea-ref-gate/biome.json b/tools/sea-ref-gate/biome.json new file mode 100644 index 000000000..99b4ab8f0 --- /dev/null +++ b/tools/sea-ref-gate/biome.json @@ -0,0 +1,3 @@ +{ + "extends": "//" +} diff --git a/tools/sea-ref-gate/index.test.ts b/tools/sea-ref-gate/index.test.ts new file mode 100644 index 000000000..9363f4758 --- /dev/null +++ b/tools/sea-ref-gate/index.test.ts @@ -0,0 +1,169 @@ +// Unit tests for the sea-ref-gate pure core (index.ts). +// +// These defend the gate's contract: it flags an uppercase-numeric `SEA-` +// issue ref in a tracked, non-carved-out file, and does NOT flag a carve-out, a +// lowercase branch slug, a `SEA-NNN` placeholder, or an allowlisted path. The +// grep-hit parser is exercised on real `git grep -n` line shapes, including +// text that itself contains colons. + +import { describe, expect, test } from "bun:test"; +import { + type Deps, + findViolations, + isCarveOut, + lineHasToken, + runOnce, +} from "./index.ts"; + +describe("lineHasToken — uppercase-numeric SEA-NNN only", () => { + test("matches a bare issue ref", () => { + expect(lineHasToken("Refs SEA-1512 in this record")).toBe(true); + }); + test("matches inside parens / punctuation", () => { + expect(lineHasToken("proven (SEA-965).")).toBe(true); + }); + test("does NOT match a lowercase branch-slug artifact", () => { + expect(lineHasToken("on `compass-sea-1243-t0-harness-drop`")).toBe(false); + }); + test("does NOT match a SEA-nnn / SEA-NNN / SEA-N placeholder", () => { + expect(lineHasToken("file a SEA-NNN follow-up")).toBe(false); + expect(lineHasToken("issue refs (Linear SEA-nnn)")).toBe(false); + expect(lineHasToken("Split from SEA-N")).toBe(false); + }); + test("does NOT match an embedded (non-word-boundary) token", () => { + expect(lineHasToken("NOSEA-1 and xSEA-2")).toBe(false); + }); + test("no token, no match", () => { + expect(lineHasToken("the retired team key")).toBe(false); + }); +}); + +describe("isCarveOut", () => { + test("forks/ subtree is carved out", () => { + expect(isCarveOut("forks/devenv/src/x.nix")).toBe(true); + }); + test("first-party forks/README.md is NOT carved out", () => { + expect(isCarveOut("forks/README.md")).toBe(false); + }); + test("the gate's own source is carved out", () => { + expect(isCarveOut("tools/sea-ref-gate/index.ts")).toBe(true); + }); + test("generated eng-docs copies are carved out", () => { + expect(isCarveOut("apps/eng-docs/src/content/docs/x.md")).toBe(true); + expect(isCarveOut("apps/eng-docs/dist/index.html")).toBe(true); + }); + test("the generated bun.lock is carved out", () => { + expect(isCarveOut("bun.lock")).toBe(true); + }); + test("a canonical docs/ record is NOT carved out", () => { + expect( + isCarveOut("docs/designs/product/compass-board-view/design.md"), + ).toBe(false); + }); +}); + +describe("findViolations", () => { + test("flags a real reference in a scanned file", () => { + const v = findViolations([ + "docs/designs/product/compass-board-view/design.md:12:tracked as SEA-1512 here", + ]); + expect(v).toHaveLength(1); + expect(v[0]).toEqual({ + file: "docs/designs/product/compass-board-view/design.md", + line: 12, + text: "tracked as SEA-1512 here", + }); + }); + test("does NOT flag a carved-out file even when it carries the token", () => { + expect( + findViolations([ + "forks/devenv/README.md:3:upstream mentions SEA-1 here", + "tools/sea-ref-gate/index.ts:10:the token SEA-1512", + ]), + ).toHaveLength(0); + }); + test("does NOT flag a lowercase slug or a placeholder", () => { + expect( + findViolations([ + "docs/x.md:5:on compass-sea-1243-branch", + "go/.golangci.yml:65:issue refs (Linear SEA-nnn)", + ]), + ).toHaveLength(0); + }); + test("parses text containing colons (line/col-like suffixes)", () => { + const v = findViolations([ + "docs/x.md:42:see `SEA-1787 fixture.go:67` for the backend", + ]); + expect(v).toHaveLength(1); + const [first] = v; + expect(first).toEqual({ + file: "docs/x.md", + line: 42, + text: "see `SEA-1787 fixture.go:67` for the backend", + }); + }); + test("skips blank and malformed hits", () => { + expect( + findViolations(["", "no-colons-here", "path-only:notanumber"]), + ).toHaveLength(0); + }); + test("collects multiple references", () => { + const v = findViolations([ + "a.md:1:SEA-1 one", + "b.md:2:SEA-2 two", + "forks/x:3:SEA-3 carved", + ]); + expect(v).toHaveLength(2); + }); +}); + +describe("runOnce", () => { + function deps(hits: string[] | Error, out: string[], errs: string[]): Deps { + return { + grep: async () => { + if (hits instanceof Error) throw hits; + return hits; + }, + log: (m) => out.push(m), + err: (m) => errs.push(m), + }; + } + + test("returns 0 and logs clean when there are no references", async () => { + const out: string[] = []; + const errs: string[] = []; + const code = await runOnce(deps([], out, errs)); + expect(code).toBe(0); + expect(out.join("\n")).toContain("clean"); + expect(errs).toHaveLength(0); + }); + + test("returns 1 and prints each reference when the boundary is crossed", async () => { + const out: string[] = []; + const errs: string[] = []; + const code = await runOnce( + deps(["docs/x.md:7:tracked as SEA-1512"], out, errs), + ); + expect(code).toBe(1); + const e = errs.join("\n"); + expect(e).toContain("docs/x.md:7"); + expect(e).toContain("RIG-"); + }); + + test("returns 2 on a scan error (fail closed)", async () => { + const out: string[] = []; + const errs: string[] = []; + const code = await runOnce(deps(new Error("not a git tree"), out, errs)); + expect(code).toBe(2); + expect(errs.join("\n")).toContain("cannot scan"); + }); + + test("a carved-out hit does not trip the gate", async () => { + const out: string[] = []; + const errs: string[] = []; + const code = await runOnce( + deps(["forks/devenv/x.nix:1:SEA-1 upstream"], out, errs), + ); + expect(code).toBe(0); + }); +}); diff --git a/tools/sea-ref-gate/index.ts b/tools/sea-ref-gate/index.ts new file mode 100644 index 000000000..53ed1318c --- /dev/null +++ b/tools/sea-ref-gate/index.ts @@ -0,0 +1,171 @@ +// sea-ref-gate (RIG-2804) — fail-closed CI check that the compass tree carries +// no `SEA-NNN` issue reference. The Linear team key was renamed SEA→RIG with +// issue numbers preserved 1:1 (SEA-1512 == RIG-1512); a one-time codemod flipped +// every tracked ref (RIG-2804 P1). This gate keeps the boundary from silently +// re-rotting — the same fail-closed pattern as orion-ref-gate and +// design-ledger-gate. +// +// SCOPE: the `SEA-` issue-ref token ONLY. A sibling to orion-ref-gate, not an +// extension of it: orion-ref-gate is deliberately scoped to its single token +// and states that it does not double as a general brand-token gate, so the SEA +// boundary gets its own gate rather than bloating that one. +// +// What counts as a reference: the UPPERCASE-NUMERIC whole-word token +// `SEA-` (e.g. `SEA-1512`, `Refs SEA-965`). This is exactly what the P1 +// codemod flipped, so the gate is symmetric with it. Deliberately NOT matched +// (mirrors the codemod, which left these untouched): +// - lowercase `sea-1243` branch-slug artifacts — historical git branch names +// quoted in design records; the team rename does not rewrite the past. +// - `SEA-nnn` / `SEA-NNN` / `SEA-N` prose placeholders — the digit class +// rejects letters, so these stay as generic "some issue number" prose. +// +// CARVE-OUTS (never scanned): +// - forks/** vendored upstream subtrees; any SEA-like token there +// is upstream text, not a compass-authored issue ref. +// (First-party forks/README.md is NOT carved out.) +// - tools/sea-ref-gate/** this gate's own source + fixtures name the token to +// describe what it forbids. +// - apps/eng-docs/src/content/docs/**, apps/eng-docs/dist/** generated copies +// of docs/ (gitignored; regenerated by gather.ts). The +// canonical source under docs/ is what the gate governs. +// - an explicit ALLOWLIST of exact repo-relative paths, each with a reason, +// for a genuine non-issue-ref use. Empty today. +// +// The scan reads tracked files via `git grep`, so untracked build output and +// node_modules are never considered. +// +// Exit codes: +// 0 - no references (clean) +// 1 - one or more references (printed one per line as `:: `) +// 2 - usage / internal error (e.g. not a git tree) +// +// Usage: bun run tools/sea-ref-gate/index.ts + +import { $ } from "bun"; + +/** The retired Linear team key, as an uppercase-numeric issue ref. */ +export const SEA_REF_RE = /\bSEA-\d+\b/; + +/** + * Repo-relative path prefixes never scanned. A reference under one of these is + * either upstream text (forks/), a generated copy (eng-docs), or this gate's + * own description of what it forbids. + */ +export const CARVEOUT_PREFIXES: readonly string[] = [ + "forks/", + "tools/sea-ref-gate/", + "apps/eng-docs/src/content/docs/", + "apps/eng-docs/dist/", +]; + +/** + * Exact repo-relative paths never scanned — generated files that mechanically + * carry a token. + */ +export const CARVEOUT_PATHS: readonly string[] = ["bun.lock"]; + +/** + * Exact repo-relative paths exempted with a reason — for a genuine non-issue-ref + * use of the token. Empty today: every tracked `SEA-` in compass was a live + * issue ref and was flipped. Add a path here ONLY with a one-line reason, and + * prefer rewording the text. + */ +export const ALLOWLIST: Readonly> = {}; + +/** One scanned line carrying the token. */ +export interface Reference { + readonly file: string; + readonly line: number; + readonly text: string; +} + +/** True when a repo-relative path is carved out of the scan. */ +export function isCarveOut(path: string): boolean { + if (path in ALLOWLIST) return true; + // forks/README.md is first-party compass prose, not a vendored subtree — + // it is scanned like any other authored file. + if (path === "forks/README.md") return false; + if (CARVEOUT_PATHS.includes(path)) return true; + return CARVEOUT_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + +/** + * Whole-word, uppercase-numeric match for the retired issue-ref token in one + * line. Lowercase `sea-` slugs and `SEA-` placeholders do not match. + */ +export function lineHasToken(text: string): boolean { + return SEA_REF_RE.test(text); +} + +/** + * The pure gate decision: given `git grep -n` style hits (each `file:line:text`) + * return the references that are NOT carved out. Pure — the caller supplies the + * raw hits, so tests drive it over fixtures with no git. + */ +export function findViolations(grepHits: readonly string[]): Reference[] { + const out: Reference[] = []; + for (const hit of grepHits) { + const trimmed = hit.trimEnd(); + if (trimmed.length === 0) continue; + // `git grep -n` emits `path:line:text`; text may itself contain colons. + const firstColon = trimmed.indexOf(":"); + const secondColon = trimmed.indexOf(":", firstColon + 1); + if (firstColon < 0 || secondColon < 0) continue; + const file = trimmed.slice(0, firstColon); + const line = Number(trimmed.slice(firstColon + 1, secondColon)); + const text = trimmed.slice(secondColon + 1); + if (!Number.isInteger(line)) continue; + if (isCarveOut(file)) continue; + if (!lineHasToken(text)) continue; + out.push({ file, line, text }); + } + return out; +} + +/** Injected I/O, so the runner is testable without a real git tree. */ +export interface Deps { + /** Return `git grep -n` hits for the token across tracked files. */ + grep: () => Promise; + log: (msg: string) => void; + err: (msg: string) => void; +} + +export async function runOnce(deps: Deps): Promise { + let hits: string[]; + try { + hits = await deps.grep(); + } catch (e) { + deps.err(`sea-ref-gate: cannot scan the tree: ${String(e)}`); + return 2; + } + const violations = findViolations(hits); + if (violations.length > 0) { + deps.err( + `sea-ref-gate: ${violations.length} retired SEA-NNN issue reference(s) in the tree:`, + ); + for (const v of violations) + deps.err(` ${v.file}:${v.line}: ${v.text.trim()}`); + deps.err( + "The Linear team key is RIG (issue numbers preserved 1:1). " + + "Write RIG-, not SEA-.", + ); + return 1; + } + deps.log("sea-ref-gate: clean — no retired SEA-NNN issue references."); + return 0; +} + +/** `git grep -nIE 'SEA-[0-9]+'` over tracked files; empty on no match (exit 1). */ +async function gitGrep(): Promise { + const raw = await $`git grep -nIE ${"SEA-[0-9]+"}`.nothrow().text(); + return raw.split("\n").filter((l) => l.length > 0); +} + +if (import.meta.main) { + const code = await runOnce({ + grep: gitGrep, + log: (m) => console.log(m), + err: (m) => console.error(m), + }); + process.exit(code); +} diff --git a/tools/sea-ref-gate/moon.yml b/tools/sea-ref-gate/moon.yml new file mode 100644 index 000000000..8361ee200 --- /dev/null +++ b/tools/sea-ref-gate/moon.yml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=https://moonrepo.dev/schemas/project.json +# +# sea-ref-gate (RIG-2804) — fail-closed check that the compass tree carries no +# retired SEA-NNN issue reference (the Linear team key is RIG, numbers preserved +# 1:1). A bun/TypeScript CLI; a hoisted root-workspace member (`bun` tag): +# install is inherited via .moon/tasks/tag-bun.yml (the shared root install) and +# lint/format are whole-repo tasks on the root project (/moon.yml), so this leaf +# has no own bun.lock and never runs its own install. +# +# Compass CI is a single moon-driven job (.github/workflows/ci.yml runs +# `moon run :ci`), so the `ci` aggregate below is swept automatically. +layer: 'tool' +language: 'typescript' +tags: ['bun', 'ci-group.bun'] + +tasks: + typecheck: + command: 'bunx tsc --noEmit' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock'] + test: + command: 'bun test' + deps: ['install'] + inputs: ['*.ts', 'tsconfig.json', 'package.json', '/bun.lock'] + check: + # The boundary gate: scan the whole tracked tree (via git grep) for any + # retired SEA-NNN issue ref. Run from the workspace root so the scan and + # carve-out prefixes resolve repo-relative. + command: 'bun run tools/sea-ref-gate/index.ts' + deps: ['install'] + options: + runFromWorkspaceRoot: true + # Never cache: the gate's subject is the live tracked tree, and a cached + # green from another checkout is the false-green a fail-closed gate exists + # to stop. + cache: false + inputs: + - '/**/*' + - 'index.ts' + ci: + deps: ['typecheck', 'test', 'check'] + options: + cache: false diff --git a/tools/sea-ref-gate/package.json b/tools/sea-ref-gate/package.json new file mode 100644 index 000000000..e52b553db --- /dev/null +++ b/tools/sea-ref-gate/package.json @@ -0,0 +1,14 @@ +{ + "name": "@compass/sea-ref-gate", + "private": true, + "type": "module", + "description": "CI gate (RIG-2804): fail-closed check that the compass tree carries no retired SEA-NNN issue reference (the Linear team key is RIG, numbers preserved 1:1).", + "module": "index.ts", + "bin": { + "sea-ref-gate": "./index.ts" + }, + "devDependencies": { + "@types/bun": "catalog:", + "typescript": "catalog:" + } +} diff --git a/tools/sea-ref-gate/tsconfig.json b/tools/sea-ref-gate/tsconfig.json new file mode 100644 index 000000000..47d3248bb --- /dev/null +++ b/tools/sea-ref-gate/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "types": ["bun"] + } +} From 0616f77564748f1fd90414e678024819ce693695 Mon Sep 17 00:00:00 2001 From: mintaka Date: Fri, 28 Aug 2026 13:00:34 -0400 Subject: [PATCH 2/2] ci(gate): fail closed on a real git-grep scan error in both ref gates (RIG-2804) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both sea-ref-gate and orion-ref-gate scanned via `git grep ….nothrow()`, which swallowed every non-zero git exit and returned empty output — so a genuine scan malfunction (e.g. not a git work tree, git exit 128) was reported as clean/exit 0. That is fail-OPEN: the exact false-green a fail-closed gate exists to stop, and it contradicted each gate's own docstring/package.json claim of "exit 2 on scan error" (whose exit-2 branch was previously reachable only from the injected-throw unit test, never from the real scanner). Fix: inspect the git exit code. `git grep` exits 0 with matches, 1 on no match (a legitimately clean empty result), and >=2 on a real error; exit >=2 now throws, so runOnce's existing catch returns exit 2. Exit 1 stays clean. Applied identically to both sibling gates so they do not diverge (the review of the sea-ref-gate PR surfaced this as an inherited pattern from the merged orion-ref-gate; Matt ruled fix both together). Verified: both gates stay clean/exit 0 on the valid tree; run outside a git tree now exits 2 with the "cannot scan the tree" diagnosis (was exit 0 before). Both test suites green (sea 22, orion 23). Refs RIG-2804. Co-authored-by: Matt Wilkinson --- tools/orion-ref-gate/index.ts | 20 +++++++++++++++++--- tools/sea-ref-gate/index.ts | 20 +++++++++++++++++--- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/tools/orion-ref-gate/index.ts b/tools/orion-ref-gate/index.ts index bbd6a8bd1..2d7d93505 100644 --- a/tools/orion-ref-gate/index.ts +++ b/tools/orion-ref-gate/index.ts @@ -162,10 +162,24 @@ export async function runOnce(deps: Deps): Promise { return 0; } -/** `git grep -nwI -i ` over tracked files; empty on no match (exit 1). */ +/** + * `git grep -nwI -i ` over tracked files. `git grep` exits 0 with + * matches, 1 on no match (a legitimately clean, empty result), and >=2 on a + * real error (e.g. not a git work tree). We must distinguish the last from the + * clean case: swallowing it would make the gate report clean on a broken scan — + * fail-OPEN, the exact false-green a fail-closed gate exists to stop. Exit >=2 + * throws, so runOnce's catch returns exit 2. + */ async function gitGrep(): Promise { - const raw = await $`git grep -nwiI ${PRIVATE_TOKEN}`.nothrow().text(); - return raw.split("\n").filter((l) => l.length > 0); + const res = await $`git grep -nwiI ${PRIVATE_TOKEN}`.nothrow().quiet(); + if (res.exitCode >= 2) + throw new Error( + `git grep exited ${res.exitCode}: ${res.stderr.toString().trim()}`, + ); + return res.stdout + .toString() + .split("\n") + .filter((l) => l.length > 0); } if (import.meta.main) { diff --git a/tools/sea-ref-gate/index.ts b/tools/sea-ref-gate/index.ts index 53ed1318c..860e878bd 100644 --- a/tools/sea-ref-gate/index.ts +++ b/tools/sea-ref-gate/index.ts @@ -155,10 +155,24 @@ export async function runOnce(deps: Deps): Promise { return 0; } -/** `git grep -nIE 'SEA-[0-9]+'` over tracked files; empty on no match (exit 1). */ +/** + * `git grep -nIE 'SEA-[0-9]+'` over tracked files. `git grep` exits 0 with + * matches, 1 on no match (a legitimately clean, empty result), and >=2 on a + * real error (e.g. not a git work tree). We must distinguish the last from the + * clean case: swallowing it would make the gate report clean on a broken scan — + * fail-OPEN, the exact false-green a fail-closed gate exists to stop. Exit >=2 + * throws, so runOnce's catch returns exit 2. + */ async function gitGrep(): Promise { - const raw = await $`git grep -nIE ${"SEA-[0-9]+"}`.nothrow().text(); - return raw.split("\n").filter((l) => l.length > 0); + const res = await $`git grep -nIE ${"SEA-[0-9]+"}`.nothrow().quiet(); + if (res.exitCode >= 2) + throw new Error( + `git grep exited ${res.exitCode}: ${res.stderr.toString().trim()}`, + ); + return res.stdout + .toString() + .split("\n") + .filter((l) => l.length > 0); } if (import.meta.main) {