// Guards scripts/release/merge-train.sh (merge-gates.md §7 — batch validation of N // queued PRs as one merged result, replacing O(N²) per-PR CI re-runs). Only the // side-effect-free surface is testable in unit scope: --plan mode (no worktree, no // network) and argument validation. import { test } from "node:test"; import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { access, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { promisify } from "node:util"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; const pExecFile = promisify(execFile); const SCRIPT = join( dirname(fileURLToPath(import.meta.url)), "../../scripts/release/merge-train.sh" ); async function run(args: string[]) { try { const { stdout, stderr } = await pExecFile("bash", [SCRIPT, ...args]); return { code: 0, stdout, stderr }; } catch (err) { const e = err as { code?: number; stdout?: string; stderr?: string }; return { code: e.code ?? -1, stdout: e.stdout ?? "", stderr: e.stderr ?? "" }; } } test("--plan prints the full step plan without touching anything and exits 0", async () => { const { code, stdout } = await run(["--plan", "release/v9.9.9", "111", "222"]); assert.equal(code, 0); assert.match(stdout, /PLAN \(full\) — base=origin\/release\/v9\.9\.9 prs=111 222/); assert.match(stdout, /worktree add \.claude\/worktrees\/merge-train-/); assert.match(stdout, /pull\/111\/head/); assert.match(stdout, /pull\/222\/head/); // the parity suite is fully enumerated in the plan for (const gate of [ "typecheck:core", "check-file-size.mjs", "check-complexity.mjs", "check-cognitive-complexity.mjs", "check-changelog-integrity.mjs", "npm run test:unit", "test:vitest", ]) { assert.ok(stdout.includes(gate), `plan must include ${gate}`); } // Speed guard (2026-07-18): full mode must use the box-tuned `test:unit` runner // (--test-concurrency=20), never the two sequential 4-core CI shards that ran the // dominant phase at ~25% of the box. assert.ok(!stdout.includes("TEST_SHARD="), "full mode must not use the sequential CI shards"); assert.match(stdout, /--admin evidence/); assert.match(stdout, /teardown: git worktree remove/); }); test("--plan --fast swaps the full unit suite for changed-tests, keeps static gates + vitest", async () => { const { code, stdout } = await run(["--plan", "--fast", "release/v9.9.9", "111"]); assert.equal(code, 0); assert.match(stdout, /PLAN \(fast\) — base=origin\/release\/v9\.9\.9 prs=111/); for (const gate of [ "typecheck:core", "check-file-size.mjs", "check-complexity.mjs", "check-cognitive-complexity.mjs", "check-changelog-integrity.mjs", "test:vitest", ]) { assert.ok(stdout.includes(gate), `fast plan must still include ${gate}`); } assert.match(stdout, /\(fast\) run node:test files changed by the boarded PRs/); assert.ok(!stdout.includes("npm run test:unit"), "fast mode must not run the full unit suite"); }); test("--plan binds the changelog gate to the requested base inside the detached worktree", async () => { const { code, stdout } = await run(["--plan", "release/v3.8.50", "11326"]); assert.equal(code, 0); assert.match( stdout, /worktree add .* --detach origin\/release\/v3\.8\.50/, "the train worktree must remain detached from the requested base" ); assert.match( stdout, /env CHANGELOG_BASE_REF=origin\/release\/v3\.8\.50 node scripts\/check\/check-changelog-integrity\.mjs/, "the gate must not fall back to a different numerically highest release branch" ); }); test("--plan shell-quotes a hostile base before the gate command is evaluated", async () => { const tempDir = await mkdtemp(join(tmpdir(), "merge-train-plan-")); const dollarMarker = join(tempDir, "dollar-marker"); const backtickMarker = join(tempDir, "backtick-marker"); const semicolonMarker = join(tempDir, "semicolon-marker"); const base = `release/v9.9.9 $(touch ${dollarMarker}) ` + `\`touch ${backtickMarker}\` whitespace gap ; touch ${semicolonMarker}`; try { const { code, stdout } = await run(["--plan", base, "11326"]); assert.equal(code, 0); const gateLine = stdout.split("\n").find((line) => line.includes("env CHANGELOG_BASE_REF=")); assert.ok(gateLine, "the plan must include the changelog gate command"); const plannedGate = gateLine.replace(/^\[merge-train\] \d+\. /, ""); assert.ok( !plannedGate.includes(`CHANGELOG_BASE_REF=origin/${base}`), "hostile shell syntax must not appear unescaped in the eval-backed gate command" ); // Exercise the exact plan command through the same eval boundary as the real // train, replacing only the gate executable with a side-effect-free env probe. const probe = plannedGate.replace( "node scripts/check/check-changelog-integrity.mjs", "printenv CHANGELOG_BASE_REF" ); const { stdout: evaluatedBase } = await pExecFile("bash", ["-c", 'eval "$1"', "bash", probe]); assert.equal(evaluatedBase, `origin/${base}\n`); for (const marker of [dollarMarker, backtickMarker, semicolonMarker]) { await assert.rejects(access(marker), { code: "ENOENT" }); } } finally { await rm(tempDir, { recursive: true, force: true }); } }); test("fast mode's UNIT_SUBDIRS allowlist mirrors package.json test:unit exactly", async () => { // Regression for the 2026-07-18 train red: tests/unit/autoCombo/ (a vitest-only // subdir) was fed to the node:test bucket because the fast filter had no subdir // allowlist. The script must classify changed tests with the SAME subdir set // test:unit runs, so files owned by another runner are skipped, not misrun. const script = await readFile(SCRIPT, "utf8"); const scriptList = script.match(/UNIT_SUBDIRS=",([^"]+),"/)?.[1]; assert.ok(scriptList, "merge-train.sh must declare the UNIT_SUBDIRS allowlist"); const pkg = JSON.parse(await readFile(new URL("../../package.json", import.meta.url), "utf8")); const pkgList = pkg.scripts["test:unit"].match(/tests\/unit\/\{([^}]+)\}/)?.[1]; assert.ok(pkgList, "package.json test:unit must carry the {subdir} allowlist glob"); assert.equal( scriptList, pkgList, "merge-train.sh UNIT_SUBDIRS must equal test:unit's subdir set" ); assert.ok( !scriptList.split(",").includes("autoCombo"), "autoCombo belongs to vitest, not node:test" ); }); test("rejects an unknown flag", async () => { const { code, stderr } = await run(["--nope", "release/v9.9.9", "111"]); assert.equal(code, 1); assert.match(stderr, /unknown flag/); }); test("usage error without enough args", async () => { const { code, stderr } = await run(["--plan", "release/v9.9.9"]); assert.equal(code, 1); assert.match(stderr, /usage:/); }); test("rejects a non-numeric PR ref", async () => { const { code, stderr } = await run(["--plan", "release/v9.9.9", "12a"]); assert.equal(code, 1); assert.match(stderr, /not numeric/); });