Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
130 changes: 130 additions & 0 deletions scripts/check-env-parity.mjs
Original file line number Diff line number Diff line change
@@ -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",
Comment on lines +34 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Separate CI-only E2E secrets from Railway requirements

When --railway is used, these CI-only E2E credentials are passed through the same EXPECTED_SECRETS list as runtime secrets, so a correctly configured Railway environment will be reported as missing E2E_USER_EMAIL/E2E_USER_PASSWORD and the command exits 1. The repo's deployment matrix documents those two as “CI only” and “GitHub repo secrets” (docs/deployment-architecture.md:294-299), so this makes the new Railway parity check fail in the intended production-runtime context or encourages putting test-account credentials into Railway unnecessarily.

Useful? React with 👍 / 👎.

];

/** 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();
108 changes: 108 additions & 0 deletions scripts/sweep-branch-ledger.mjs
Original file line number Diff line number Diff line change
@@ -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),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match ledger entries by HEAD and cleanup scope

This marks a branch as present in the review ledger based only on the branch name, but the cleanup guide requires matching branch/ref, reviewed HEAD, and branch-cleanup scope before a cleanup pass may skip review. If a branch has an older non-cleanup ledger row or has advanced since review, the inventory will still print ledger yes, which can cause operators to skip the required unique-content/diff check before acting on deletion candidates.

Useful? React with 👍 / 👎.

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();
49 changes: 49 additions & 0 deletions tests/repo-hygiene.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});