From 3a7b82ec1d8bc04b9719c5aff159169165dd0c5f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:24:28 +0000 Subject: [PATCH 1/2] ci: enforce the local verify:cheap gate set in CI + gate-manifest self-test (maturity L3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gates ran in the local `verify:cheap` chain but in NO workflow — `sitemap:check`, `check:therapy-data-index`, `check:design-system-contract` — so a site-map, therapy-index, or design-system-contract regression merged green (the CI aggregate never ran them). This is the same class of miss the static-pr job already documents for the type/icon/brand guards. - Add the three drift detectors to the static-pr job (all pass on main). - Add scripts/check-gate-manifest.mjs: a self-test asserting every `verify:cheap:internal` gate is enforced in CI (in static-pr, or via an explicit mapping for `test` -> `test:coverage` in the coverage job), so a gate added to the local chain can no longer be silently missed in CI. Wired into both `verify:cheap:internal` and the static-pr job. Verified: `check:gate-manifest` passes; before the fix it failed on exactly the three drifted gates (proving it catches the regression); ci-scope / ci-triage / pr-policy / github-actions self-tests, eslint, and prettier all green. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Jc1ZYHFjXjn6mE6U6riVU --- .github/workflows/ci.yml | 17 +++++++ package.json | 3 +- scripts/check-gate-manifest.mjs | 87 +++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 scripts/check-gate-manifest.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a4266e89..acea5eda1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,9 @@ jobs: - name: PR policy self-test run: npm run check:pr-policy + - name: Gate-manifest self-test + run: npm run check:gate-manifest + - name: Codebase index coverage run: npm run docs:check-index @@ -177,6 +180,20 @@ jobs: - name: Brand asset check run: npm run brand:check + # More generated-output drift detectors that were local-only in + # verify:cheap — same rationale as the design-system guards above. Without + # them a stale site map, therapy index, or design-system contract merged + # green because no workflow ran them. The gate-manifest self-test now fails + # CI if this list drifts from verify:cheap again. + - name: Site map drift + run: npm run sitemap:check + + - name: Therapy data index drift + run: npm run check:therapy-data-index + + - name: Design-system contract + run: npm run check:design-system-contract + # Fails if a SECURITY DEFINER public function is left executable by # PUBLIC/anon (privilege-escalation / cross-tenant read surface). - name: Function-grant guard diff --git a/package.json b/package.json index be3434be1..c36e7f3c0 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "test:e2e:visual": "node scripts/run-playwright.mjs --config=playwright.visual.config.ts", "test:cross-tenant:staging": "node scripts/run-tsx.mjs scripts/test-cross-tenant-staging.ts", "verify:cheap": "node scripts/run-heavy.mjs --npm-script verify:cheap:internal", - "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run sitemap:check && npm run docs:check-index && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", + "verify:cheap:internal": "npm run check:runtime && npm run check:github-actions && npm run check:ci-scope && npm run check:ci-triage && npm run check:pr-policy && npm run check:gate-manifest && npm run sitemap:check && npm run docs:check-index && npm run check:knip && npm run check:maintainability-budgets && npm run brand:check && npm run check:therapy-data-index && npm run check:type-scale && npm run check:icon-scale && npm run check:design-system-contract && npm run check:function-grants && npm run check:owner-scope && npm run lint && npm run typecheck && npm run test", "verify:pr-local": "node scripts/verify-pr-local.mjs", "verify:ui": "npm run check:runtime && npm run test:e2e:pr", "verify:release": "npm run check:runtime && npm run lint && npm run typecheck && npm run test && npm run build && npm run test:e2e && npm run check:production-readiness && npm run governance:release && npm run eval:quality:release", @@ -57,6 +57,7 @@ "check:maintainability-budgets": "node scripts/check-maintainability-budgets.mjs", "check:ci-scope": "node scripts/ci-change-scope.mjs --self-test", "check:ci-triage": "node scripts/ci-triage.mjs --self-test", + "check:gate-manifest": "node scripts/check-gate-manifest.mjs", "check:pr-policy": "node scripts/pr-policy.mjs --self-test && node scripts/check-pr-policy-workflow.mjs", "check:env-parity": "node scripts/check-env-parity.mjs", "sweep:branch-ledger": "node scripts/sweep-branch-ledger.mjs", diff --git a/scripts/check-gate-manifest.mjs b/scripts/check-gate-manifest.mjs new file mode 100644 index 000000000..22cf1f99d --- /dev/null +++ b/scripts/check-gate-manifest.mjs @@ -0,0 +1,87 @@ +#!/usr/bin/env node +// Gate-manifest self-test (maturity L3). +// +// Invariant: every static gate in the local `verify:cheap:internal` chain must +// also run in CI. Without this, a gate added to the local chain can be silently +// missed in `.github/workflows/ci.yml`, so a regression it would catch merges +// green because no workflow runs it. This has already happened twice — the +// `static-pr` job comment records type/icon/brand being promoted after that +// exact miss, and sitemap/therapy-data-index/design-system-contract were a +// second instance. This check makes the drift a hard CI failure instead. +// +// Direction is one-way on purpose: CI may run MORE than the local chain (e.g. +// `format:check`, or heavier build/e2e gates in other jobs). It must never run +// LESS of the local static set. +import { readFileSync } from "node:fs"; + +const pkg = JSON.parse(readFileSync("package.json", "utf8")); +const ci = readFileSync(".github/workflows/ci.yml", "utf8"); + +const localChain = pkg.scripts?.["verify:cheap:internal"] ?? ""; +const localGates = [...localChain.matchAll(/npm run ([\w:.-]+)/g)].map((m) => m[1]); +if (localGates.length === 0) { + console.error("gate-manifest: could not parse verify:cheap:internal from package.json."); + process.exit(1); +} + +// Extract the `run: npm run X` scripts inside a named top-level job (2-space key). +function jobScripts(name) { + const lines = ci.split("\n"); + const start = lines.findIndex((line) => line === ` ${name}:`); + if (start === -1) return null; + const scripts = []; + for (let i = start + 1; i < lines.length; i++) { + if (/^ \S/.test(lines[i])) break; // reached the next top-level job + const match = lines[i].match(/run: npm run ([\w:.-]+)/); + if (match) scripts.push(match[1]); + } + return scripts; +} + +const staticPr = jobScripts("static-pr"); +if (!staticPr) { + console.error("gate-manifest: could not find the `static-pr` job in .github/workflows/ci.yml."); + process.exit(1); +} + +// Every `npm run X` anywhere in ci.yml — used to satisfy gates that run in CI +// under a different job/name than in the local chain. +const allCiScripts = new Set([...ci.matchAll(/run: npm run ([\w:.-]+)/g)].map((m) => m[1])); + +// A local gate whose CI counterpart has a different script name / job. +const CI_EQUIVALENT = new Map([ + // `npm run test` locally is the full vitest run; CI enforces it as coverage in + // the dedicated `coverage` job, not in static-pr. + ["test", "test:coverage"], +]); + +// Local gates that deliberately do NOT run in CI. Empty today; kept as the +// explicit, reviewed escape hatch so any future exemption is a conscious edit. +const LOCAL_ONLY = new Set(); + +const failures = []; +for (const gate of localGates) { + if (LOCAL_ONLY.has(gate)) continue; + const equivalent = CI_EQUIVALENT.get(gate); + if (equivalent) { + if (!allCiScripts.has(equivalent)) { + failures.push(`verify:cheap runs "${gate}" (CI counterpart "${equivalent}") but no CI job runs "${equivalent}".`); + } + continue; + } + if (!staticPr.includes(gate)) { + failures.push( + `verify:cheap runs "${gate}" but the static-pr CI job does not — add "- run: npm run ${gate}" to the static-pr job in .github/workflows/ci.yml, or record a mapping/exemption in scripts/check-gate-manifest.mjs.`, + ); + } +} + +if (failures.length > 0) { + console.error("Gate-manifest drift — a local verify:cheap gate is not enforced in CI:"); + for (const failure of failures) console.error(`- ${failure}`); + process.exit(1); +} + +console.log( + `Gate-manifest OK: all ${localGates.length} verify:cheap gates are enforced in CI (static-pr + mapped jobs).`, +); From 898e61d0d2e137c806e3b0ff4d10125b5886978b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 20 Jul 2026 18:49:49 +0000 Subject: [PATCH 2/2] fix(ci): anchor gate-manifest parsing to real run: fields (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The npm-run extraction regexes were unanchored, so a YAML comment that merely mentioned `run: npm run X` counted as an executed CI gate — which would let the drift check pass after the real step had been deleted, defeating the guard. Parse line-anchored `run:` fields only, via a shared npmRunScript() helper (a trailing `# comment` on a real step is still allowed). Verified: the self-test still reports all 20 gates enforced; the negative drift test still fires when a step is removed; and a comment line mentioning `run: npm run test:coverage` now correctly yields no gate. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019Jc1ZYHFjXjn6mE6U6riVU --- scripts/check-gate-manifest.mjs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/scripts/check-gate-manifest.mjs b/scripts/check-gate-manifest.mjs index 22cf1f99d..abbfb2a40 100644 --- a/scripts/check-gate-manifest.mjs +++ b/scripts/check-gate-manifest.mjs @@ -24,16 +24,23 @@ if (localGates.length === 0) { process.exit(1); } +// The `npm run