From 1db8d1508cf0cd3aec244358db32d8bc9f06a7f2 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Mon, 13 Jul 2026 22:38:42 +0800 Subject: [PATCH] feat(dx): env-name parity check and branch-ledger sweeper (report-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wave 3b — two repo-hygiene tools. - scripts/check-env-parity.mjs: reconciles env-var NAMES (never values) across the canonical env.ts Zod schema, check-ci-env.mjs, and — opt-in, names-only — GitHub secrets (--gh) and Railway vars (--railway). Reports expected secrets missing from a live source and live names unknown to env.ts. Directly targets the RAG_QUERY_HASH_SECRET-missing class. Offline by default. - scripts/sweep-branch-ledger.mjs: builds the branch inventory the cleanup guide mandates (ahead/behind + cherry-pick-aware unique-content check, since squash merges defeat ancestry --merged), flags deletion candidates, and notes which branches are already in the review ledger. REPORT ONLY — never deletes/edits. - tests/repo-hygiene.test.ts: 4 tests for the pure name/ledger parsers + parity diff. Verified: 4/4 vitest, offline env-parity run, sweep smoke, tsc, eslint, prettier — all green. No provider calls (live sources are opt-in flags). Co-Authored-By: Claude Fable 5 --- package.json | 2 + scripts/check-env-parity.mjs | 130 ++++++++++++++++++++++++++++++++ scripts/sweep-branch-ledger.mjs | 108 ++++++++++++++++++++++++++ tests/repo-hygiene.test.ts | 49 ++++++++++++ 4 files changed, 289 insertions(+) create mode 100644 scripts/check-env-parity.mjs create mode 100644 scripts/sweep-branch-ledger.mjs create mode 100644 tests/repo-hygiene.test.ts diff --git a/package.json b/package.json index a215e063c..ab23ad1d9 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,8 @@ "check:github-actions": "node scripts/check-github-action-pins.mjs", "check:client-bundle-secrets": "node scripts/check-client-bundle-secrets.mjs", "check:ci-scope": "node scripts/ci-change-scope.mjs --self-test", + "check:env-parity": "node scripts/check-env-parity.mjs", + "sweep:branch-ledger": "node scripts/sweep-branch-ledger.mjs", "docs:check-links": "node scripts/check-docs-links.mjs", "sitemap:update": "node scripts/run-tsx.mjs scripts/generate-site-map.ts", "sitemap:check": "node scripts/run-tsx.mjs scripts/generate-site-map.ts --check", diff --git a/scripts/check-env-parity.mjs b/scripts/check-env-parity.mjs new file mode 100644 index 000000000..f5e8a28b3 --- /dev/null +++ b/scripts/check-env-parity.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +/** + * check-env-parity — reconcile environment-variable NAMES across the places this + * repo declares them, without ever reading or printing a single value. + * + * Config truth is scattered: the canonical Zod schema in src/lib/env.ts, the CI + * browser-env checker (scripts/check-ci-env.mjs), GitHub repo secrets, and Railway + * runtime vars. A name present in one place but missing in another has broken main + * CI before (e.g. RAG_QUERY_HASH_SECRET). This diffs the name sets and reports gaps. + * + * Offline by default (parses env.ts + check-ci-env.mjs only). Live sources are + * opt-in and names-only: + * --gh run `gh secret list` (names only; values are write-only anyway) + * --railway run `railway variables` (names only) if the CLI is available + * + * Never prints a value. Exit 1 only when a hard parity problem is found (an + * expected secret is absent from a queried live source), else 0. + */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); + +// Vars that MUST be supplied as deployment/CI secrets (never committed). Each is +// asserted to exist in the canonical name set below, so this list cannot silently +// drift from the schema. +const EXPECTED_SECRETS = [ + "SUPABASE_SERVICE_ROLE_KEY", + "OPENAI_API_KEY", + "RAG_QUERY_HASH_SECRET", + "HEALTH_DEEP_PROBE_SECRET", + "E2E_USER_EMAIL", + "E2E_USER_PASSWORD", +]; + +/** Zod schema keys from env.ts: lines shaped like ` NAME: z.…`. */ +export function parseEnvSchemaNames(envTsText) { + return [...envTsText.matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*:\s*z\./gm)].map((m) => m[1]); +} + +/** UPPER_SNAKE names referenced in check-ci-env.mjs (quoted literals + process.env.X). */ +export function parseCiEnvNames(ciEnvText) { + const names = new Set(); + for (const m of ciEnvText.matchAll(/"([A-Z][A-Z0-9_]*)"/g)) names.add(m[1]); + for (const m of ciEnvText.matchAll(/process\.env\.([A-Z][A-Z0-9_]*)/g)) names.add(m[1]); + return [...names]; +} + +/** Pure diff of live secret names against expectations + the known-name universe. */ +export function computeParity({ canonical, liveNames, expectedSecrets }) { + const canon = new Set(canonical); + const live = new Set(liveNames); + return { + missingSecrets: expectedSecrets.filter((name) => !live.has(name)), + unknownLive: [...live].filter((name) => !canon.has(name)), + }; +} + +function ghSecretNames() { + const raw = execFileSync("gh", ["secret", "list", "--json", "name"], { encoding: "utf8" }); + return JSON.parse(raw).map((s) => s.name); +} + +function railwayVarNames() { + // `railway variables` prints a table; --json/-k vary by CLI version, so parse + // UPPER_SNAKE tokens defensively. Names only. + const raw = execFileSync("railway", ["variables"], { encoding: "utf8" }); + return [...new Set([...raw.matchAll(/\b([A-Z][A-Z0-9_]{2,})\b/g)].map((m) => m[1]))]; +} + +function main() { + const useGh = process.argv.includes("--gh"); + const useRailway = process.argv.includes("--railway"); + + const envTs = readFileSync(path.join(root, "src/lib/env.ts"), "utf8"); + const ciEnv = readFileSync(path.join(root, "scripts/check-ci-env.mjs"), "utf8"); + const canonical = new Set([...parseEnvSchemaNames(envTs), ...parseCiEnvNames(ciEnv)]); + + const problems = []; + + // Self-consistency: every expected secret must be a name the app/CI actually knows. + const unknownExpected = EXPECTED_SECRETS.filter((name) => !canonical.has(name)); + if (unknownExpected.length > 0) { + problems.push( + `Expected-secret names not found in env.ts/check-ci-env (typo or drift): ${unknownExpected.join(", ")}`, + ); + } + + console.log(`Known env names: ${canonical.size} (env.ts schema + check-ci-env).`); + console.log(`Expected secrets: ${EXPECTED_SECRETS.join(", ")}`); + + for (const [flag, enabled, label, getter] of [ + ["--gh", useGh, "GitHub secrets", ghSecretNames], + ["--railway", useRailway, "Railway variables", railwayVarNames], + ]) { + if (!enabled) { + console.log(`(${label}: skipped — pass ${flag} to check; names only, no values)`); + continue; + } + let liveNames; + try { + liveNames = getter(); + } catch (error) { + problems.push(`${label}: could not query (${error.message.split("\n")[0]})`); + continue; + } + const { missingSecrets, unknownLive } = computeParity({ + canonical: [...canonical], + liveNames, + expectedSecrets: EXPECTED_SECRETS, + }); + console.log(`\n${label}: ${liveNames.length} names.`); + if (missingSecrets.length > 0) problems.push(`${label}: missing expected secret(s): ${missingSecrets.join(", ")}`); + if (unknownLive.length > 0) { + console.log(` ⚠ present but not in env.ts (possible stale/typo): ${unknownLive.join(", ")}`); + } + } + + if (problems.length > 0) { + console.error("\nEnv parity problems:"); + for (const p of problems) console.error(`- ${p}`); + process.exit(1); + } + console.log("\nEnv parity OK (names only; no values were read)."); +} + +const invokedDirectly = process.argv[1]?.endsWith("check-env-parity.mjs"); +if (invokedDirectly) main(); diff --git a/scripts/sweep-branch-ledger.mjs b/scripts/sweep-branch-ledger.mjs new file mode 100644 index 000000000..74b203e34 --- /dev/null +++ b/scripts/sweep-branch-ledger.mjs @@ -0,0 +1,108 @@ +#!/usr/bin/env node +/** + * sweep-branch-ledger — build the branch inventory that docs/branch-cleanup-guide.md + * mandates, and flag deletion candidates. REPORT ONLY: it never deletes, renames, or + * pushes anything, and it edits no files. + * + * With ~40 worktrees and a squash-merge flow, ancestry-based `--merged` misses + * squash-absorbed branches, so this uses the cherry-pick-aware check from the guide + * (`git log --right-only --cherry-pick`) to decide whether a branch still has unique + * patch content. Branches already recorded in docs/branch-review-ledger.md are noted + * so a follow-up review can skip them. + * + * Flags: --no-fetch (skip the network fetch), --json (machine-readable output). + */ +import { execFileSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), ".."); +const LEDGER = path.join(root, "docs/branch-review-ledger.md"); + +function git(args) { + return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); +} + +function tryGit(args) { + try { + return git(args); + } catch { + return ""; + } +} + +/** Branch names referenced in the review ledger (2nd table column may prefix "PR #N / "). */ +export function parseLedgerBranches(markdown) { + const names = new Set(); + for (const m of markdown.matchAll(/\b((?:claude|codex)\/[A-Za-z0-9._-]+)/g)) names.add(m[1]); + return names; +} + +function main() { + const asJson = process.argv.includes("--json"); + if (!process.argv.includes("--no-fetch")) { + try { + execFileSync("git", ["fetch", "--prune", "--quiet", "origin"], { cwd: root, stdio: "ignore" }); + } catch { + /* offline — use last-known refs */ + } + } + + const ledgerBranches = (() => { + try { + return parseLedgerBranches(readFileSync(LEDGER, "utf8")); + } catch { + return new Set(); + } + })(); + + const remoteBranches = tryGit(["for-each-ref", "--format=%(refname:short)", "refs/remotes/origin"]) + .split("\n") + .map((b) => b.trim()) + // `refname:short` renders refs/remotes/origin/HEAD as bare "origin"; exclude it. + .filter((b) => b && b !== "origin" && b !== "origin/HEAD" && b !== "origin/main"); + + const rows = []; + for (const ref of remoteBranches) { + const short = ref.replace(/^origin\//, ""); + const counts = tryGit(["rev-list", "--left-right", "--count", `origin/main...${ref}`]); + const [behind, ahead] = counts ? counts.split(/\s+/).map((n) => Number.parseInt(n, 10) || 0) : [0, 0]; + // Cherry-pick-aware: commits on the branch NOT already in main (by patch id). + const uniqueLog = tryGit(["log", "--oneline", "--right-only", "--cherry-pick", `origin/main...${ref}`]); + const uniqueCommits = uniqueLog ? uniqueLog.split("\n").filter(Boolean).length : 0; + rows.push({ + branch: short, + ahead, + behind, + uniqueCommits, + inLedger: ledgerBranches.has(short), + deletionCandidate: uniqueCommits === 0, + }); + } + + rows.sort((a, b) => a.uniqueCommits - b.uniqueCommits || a.branch.localeCompare(b.branch)); + + if (asJson) { + console.log(JSON.stringify({ generatedAt: new Date().toISOString(), branches: rows }, null, 2)); + return; + } + + const candidates = rows.filter((r) => r.deletionCandidate); + console.log(`Branch inventory vs origin/main (${rows.length} remote branches):\n`); + console.log("unique ahead behind ledger branch"); + for (const r of rows) { + console.log( + `${String(r.uniqueCommits).padStart(6)} ${String(r.ahead).padStart(5)} ${String(r.behind).padStart(6)} ` + + `${r.inLedger ? " yes " : " no "} ${r.branch}`, + ); + } + console.log(`\n${candidates.length} deletion candidate(s) (no unique patch content — squash-merged or empty):`); + for (const r of candidates) console.log(` - ${r.branch}`); + console.log( + "\nREPORT ONLY — nothing deleted. Verify each candidate per docs/branch-cleanup-guide.md before removing.", + ); +} + +const invokedDirectly = process.argv[1]?.endsWith("sweep-branch-ledger.mjs"); +if (invokedDirectly) main(); diff --git a/tests/repo-hygiene.test.ts b/tests/repo-hygiene.test.ts new file mode 100644 index 000000000..60d56924b --- /dev/null +++ b/tests/repo-hygiene.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { computeParity, parseCiEnvNames, parseEnvSchemaNames } from "../scripts/check-env-parity.mjs"; +import { parseLedgerBranches } from "../scripts/sweep-branch-ledger.mjs"; + +describe("check-env-parity name parsing", () => { + it("extracts UPPER_SNAKE schema keys from env.ts-style text", () => { + const text = [ + "const envSchema = z.object({", + " NEXT_PUBLIC_SUPABASE_URL: z.string().url().optional(),", + " SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),", + " OPENAI_MAX_OUTPUT_TOKENS: z.coerce.number().default(16000),", + " notAKey: 3,", + "});", + ].join("\n"); + const names = parseEnvSchemaNames(text); + expect(names).toContain("NEXT_PUBLIC_SUPABASE_URL"); + expect(names).toContain("SUPABASE_SERVICE_ROLE_KEY"); + expect(names).toContain("OPENAI_MAX_OUTPUT_TOKENS"); + expect(names).not.toContain("notAKey"); + }); + + it("extracts names from check-ci-env quoted literals and process.env access", () => { + const text = `const required = ["E2E_USER_EMAIL", "E2E_USER_PASSWORD"]; if (process.env.E2E_AUTH_ENABLED) {}`; + const names = parseCiEnvNames(text); + expect(names).toEqual(expect.arrayContaining(["E2E_USER_EMAIL", "E2E_USER_PASSWORD", "E2E_AUTH_ENABLED"])); + }); + + it("reports missing expected secrets and unknown live names", () => { + const parity = computeParity({ + canonical: ["OPENAI_API_KEY", "SUPABASE_SERVICE_ROLE_KEY"], + liveNames: ["OPENAI_API_KEY", "LEFTOVER_OLD_KEY"], + expectedSecrets: ["OPENAI_API_KEY", "SUPABASE_SERVICE_ROLE_KEY"], + }); + expect(parity.missingSecrets).toEqual(["SUPABASE_SERVICE_ROLE_KEY"]); + expect(parity.unknownLive).toEqual(["LEFTOVER_OLD_KEY"]); + }); +}); + +describe("sweep-branch-ledger parsing", () => { + it("extracts claude/ and codex/ branch names from ledger markdown", () => { + const md = [ + "| 2026-07-10 | codex/design-ux-review-fixes | abc | scope | out | checks |", + "| 2026-07-11 | PR #487 / claude/answer-page-design-polish-ffd5a6 | def | s | o | c |", + ].join("\n"); + const names = parseLedgerBranches(md); + expect(names.has("codex/design-ux-review-fixes")).toBe(true); + expect(names.has("claude/answer-page-design-polish-ffd5a6")).toBe(true); + }); +});