diff --git a/packages/cli/src/__tests__/root-test-command.test.ts b/packages/cli/src/__tests__/root-test-command.test.ts index d91c649a48..4a8972b0a7 100644 --- a/packages/cli/src/__tests__/root-test-command.test.ts +++ b/packages/cli/src/__tests__/root-test-command.test.ts @@ -24,7 +24,7 @@ describe("root test command changed-only planning", () => { expect(plan).toEqual({ mode: "changed", packages: ["@fusion/core", "@fusion/engine"] }); }); - it("falls back to full suite when shared test infra changes", () => { + it("routes to gate mode when shared test infra changes (no implicit full suite)", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: "abc123", @@ -32,10 +32,10 @@ describe("root test command changed-only planning", () => { packageNameByDir: new Map([["packages/core", "@fusion/core"]]), }); - expect(plan).toEqual({ mode: "full", reason: "shared-infra-changed" }); + expect(plan).toEqual({ mode: "gate", reason: "shared-infra-changed" }); }); - it("falls back to full suite when comparison base cannot be resolved", () => { + it("routes to gate mode when comparison base cannot be resolved", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: null, @@ -43,16 +43,16 @@ describe("root test command changed-only planning", () => { packageNameByDir: new Map(), }); - expect(plan).toEqual({ mode: "full", reason: "missing-comparison-base" }); + expect(plan).toEqual({ mode: "gate", reason: "missing-comparison-base" }); }); - it("treats unknown package directories as full-suite fallback", () => { + it("treats unknown package directories as gate-mode fallback (resolver returns null)", () => { const resolved = resolveAffectedPackages(["packages/unknown/src/index.ts"], new Map()); expect(resolved).toBeNull(); }); - it("marks root workflow/config changes as full-suite triggers", () => { - expect(shouldForceFullSuite([".github/workflows/ci.yml"])).toBe(true); + it("marks root workflow/config changes as shared-infra (gate-mode) triggers", () => { + expect(shouldForceFullSuite([".github/workflows/pr-checks.yml"])).toBe(true); expect(shouldForceFullSuite(["package.json"])).toBe(true); expect(shouldForceFullSuite(["packages/core/src/store.ts"])).toBe(false); }); diff --git a/scripts/__tests__/test-changed.test.mjs b/scripts/__tests__/test-changed.test.mjs index 0ca4160a8e..0f4fa23b6b 100644 --- a/scripts/__tests__/test-changed.test.mjs +++ b/scripts/__tests__/test-changed.test.mjs @@ -248,47 +248,47 @@ test("decideExecutionPlan: forced full suite", () => { assert.equal(plan.reason, "forced"); }); -test("decideExecutionPlan: missing comparison base → full", () => { +test("decideExecutionPlan: missing comparison base → gate", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: null, changedFiles: null, packageNameByDir: basePackageMap, }); - assert.equal(plan.mode, "full"); + assert.equal(plan.mode, "gate"); assert.equal(plan.reason, "missing-comparison-base"); }); -test("decideExecutionPlan: diff failed → full", () => { +test("decideExecutionPlan: diff failed → gate", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: "abc123", changedFiles: null, packageNameByDir: basePackageMap, }); - assert.equal(plan.mode, "full"); + assert.equal(plan.mode, "gate"); assert.equal(plan.reason, "diff-failed"); }); -test("decideExecutionPlan: no changes → full", () => { +test("decideExecutionPlan: no changes → gate", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: "abc123", changedFiles: [], packageNameByDir: basePackageMap, }); - assert.equal(plan.mode, "full"); + assert.equal(plan.mode, "gate"); assert.equal(plan.reason, "no-changes"); }); -test("decideExecutionPlan: shared infra changed → full", () => { +test("decideExecutionPlan: shared infra changed → gate", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: "abc123", changedFiles: ["pnpm-lock.yaml"], packageNameByDir: basePackageMap, }); - assert.equal(plan.mode, "full"); + assert.equal(plan.mode, "gate"); assert.equal(plan.reason, "shared-infra-changed"); }); @@ -344,14 +344,14 @@ test("decideExecutionPlan: expands changed packages with reverse dependents", () assert.deepEqual(plan.packages, ["@fusion/core", "@fusion/engine", "@fusion/dashboard"]); }); -test("decideExecutionPlan: no affected package resolved → full", () => { +test("decideExecutionPlan: no affected package resolved → gate", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: "abc123", changedFiles: ["packages/nonexistent/src/foo.ts"], packageNameByDir: basePackageMap, }); - assert.equal(plan.mode, "full"); + assert.equal(plan.mode, "gate"); assert.equal(plan.reason, "no-affected-package"); }); @@ -370,7 +370,7 @@ test("decideExecutionPlan: plugin-only workspace changes stay in changed mode", assert.deepEqual(plan.packages, ["@fusion-plugin-examples/openclaw-runtime"]); }); -test("decideExecutionPlan: plugin changes without mapping fail safe to full", () => { +test("decideExecutionPlan: plugin changes without mapping fail safe to gate", () => { const plan = decideExecutionPlan({ forceFullSuite: false, comparisonBase: "abc123", @@ -378,7 +378,7 @@ test("decideExecutionPlan: plugin changes without mapping fail safe to full", () packageNameByDir: basePackageMap, }); - assert.equal(plan.mode, "full"); + assert.equal(plan.mode, "gate"); assert.equal(plan.reason, "no-affected-package"); }); @@ -857,25 +857,41 @@ test("emitModeDecision: changed plan reports changed-packages reason + package c assert.deepEqual(lines, [line]); }); -test("emitModeDecision: full plan surfaces the decideExecutionPlan reason, packages=0", () => { +test("emitModeDecision: gate plan surfaces the decideExecutionPlan reason, packages=0", () => { assert.equal( - emitModeDecision({ mode: "full", reason: "missing-comparison-base" }, () => {}), - "[test-changed] mode=full reason=missing-comparison-base packages=0", + emitModeDecision({ mode: "gate", reason: "missing-comparison-base" }, () => {}), + "[test-changed] mode=gate reason=missing-comparison-base packages=0", ); assert.equal( - emitModeDecision({ mode: "full", reason: "shared-infra-changed" }, () => {}), - "[test-changed] mode=full reason=shared-infra-changed packages=0", + emitModeDecision({ mode: "gate", reason: "shared-infra-changed" }, () => {}), + "[test-changed] mode=gate reason=shared-infra-changed packages=0", ); }); -test("emitModeDecision: distinct full reasons round-trip from decideExecutionPlan", () => { - const full = decideExecutionPlan({ forceFullSuite: false, comparisonBase: null }); - assert.equal(emitModeDecision(full, () => {}), "[test-changed] mode=full reason=missing-comparison-base packages=0"); +test("emitModeDecision: gate and forced-full reasons round-trip from decideExecutionPlan", () => { + const gate = decideExecutionPlan({ forceFullSuite: false, comparisonBase: null }); + assert.equal(emitModeDecision(gate, () => {}), "[test-changed] mode=gate reason=missing-comparison-base packages=0"); const forced = decideExecutionPlan({ forceFullSuite: true }); assert.equal(emitModeDecision(forced, () => {}), "[test-changed] mode=full reason=forced packages=0"); }); +// The implicit full-suite escalation was the local OOM path (FN: merge-gate +// redesign). The full suite must be reachable ONLY via explicit opt-in. +test("decideExecutionPlan: full mode is reachable only via forceFullSuite", () => { + const implicitInputs = [ + { forceFullSuite: false, comparisonBase: null }, + { forceFullSuite: false, comparisonBase: "origin/main", changedFiles: null }, + { forceFullSuite: false, comparisonBase: "origin/main", changedFiles: [] }, + { forceFullSuite: false, comparisonBase: "origin/main", changedFiles: [".github/workflows/pr-checks.yml"] }, + { forceFullSuite: false, comparisonBase: "origin/main", changedFiles: ["unmapped/path.ts"], packageNameByDir: new Map() }, + ]; + for (const input of implicitInputs) { + const plan = decideExecutionPlan(input); + assert.equal(plan.mode, "gate", `expected gate mode for ${JSON.stringify(input)}`); + } +}); + // --------------------------------------------------------------------------- // U3: cache-fresh fast path — when every changed package is cache-fresh, // applyCacheToPlan yields zero active packages, which is the signal that lets diff --git a/scripts/boot-smoke.mjs b/scripts/boot-smoke.mjs index 0ffd16d583..05505b51af 100644 --- a/scripts/boot-smoke.mjs +++ b/scripts/boot-smoke.mjs @@ -9,6 +9,9 @@ * 3. The server shuts down cleanly on SIGTERM. * * Safety properties (see scripts/check-no-kill-4040.mjs and AGENTS.md): + * (port-4040-allowlist: this file only ever AVOIDS the reserved ports — it + * requests an ephemeral port and rejects reserved ones; it never binds, + * probes, or kills them.) * - Never binds or touches port 4040 / FUSION_RESERVED_PORTS — an ephemeral * port is requested from the OS (listen on 0) and double-checked against * the reserved list. diff --git a/scripts/test-changed.mjs b/scripts/test-changed.mjs index 192d192961..461de987bb 100644 --- a/scripts/test-changed.mjs +++ b/scripts/test-changed.mjs @@ -430,8 +430,16 @@ function isTestIrrelevantRootPath(file) { export function shouldForceFullSuite(changedFiles) { // NOTE: overlaps SHARED_HASH_INPUT_PATHS by intent (different axis: this list - // forces full-suite mode; that one busts every package's cache hash). When - // adding a new shared root config input, consider both lists. + // signals shared-infra changes; that one busts every package's cache hash). + // When adding a new shared root config input, consider both lists. + // + // HISTORY: this signal used to escalate `pnpm test` to an implicit full + // recursive run — which was the local OOM path (two concurrent heavy + // packages, 6GB dashboard heaps). Since the merge-gate redesign + // (docs/plans/2026-06-04-001-refactor-fast-trusted-test-gate-plan.md) it + // routes to GATE mode instead: run the merge-gate suite and point at + // `pnpm test:full` for the explicit full sweep. The full suite only ever + // runs on explicit opt-in (--full / FUSION_TEST_FULL=1). const fullSuitePaths = [ "package.json", "pnpm-lock.yaml", @@ -1012,13 +1020,16 @@ export function decideExecutionPlan({ reverseDependencyMap, }) { if (forceFullSuite) return { mode: "full", reason: "forced" }; - if (!comparisonBase) return { mode: "full", reason: "missing-comparison-base" }; - if (!changedFiles) return { mode: "full", reason: "diff-failed" }; - if (changedFiles.length === 0) return { mode: "full", reason: "no-changes" }; - if (shouldForceFullSuite(changedFiles)) return { mode: "full", reason: "shared-infra-changed" }; + // Every implicit wide-blast condition below routes to GATE mode (merge-gate + // suite only), never to an implicit full-suite run — the old escalation was + // the local OOM path. `pnpm test:full` is the explicit opt-in full sweep. + if (!comparisonBase) return { mode: "gate", reason: "missing-comparison-base" }; + if (!changedFiles) return { mode: "gate", reason: "diff-failed" }; + if (changedFiles.length === 0) return { mode: "gate", reason: "no-changes" }; + if (shouldForceFullSuite(changedFiles)) return { mode: "gate", reason: "shared-infra-changed" }; const affectedPackages = resolveAffectedPackages(changedFiles, packageNameByDir); - if (!affectedPackages || affectedPackages.length === 0) return { mode: "full", reason: "no-affected-package" }; + if (!affectedPackages || affectedPackages.length === 0) return { mode: "gate", reason: "no-affected-package" }; return { mode: "changed", @@ -1060,8 +1071,11 @@ export function normalizeForwardedArgs(argv) { } export function main(argv = process.argv.slice(2)) { + // The full suite is explicit opt-in ONLY (--full / FUSION_TEST_FULL=1). + // CI no longer routes through this script (the gate job runs `pnpm + // test:gate`; the demoted tier runs `test:ci:shard` in full-suite.yml), so + // the old `CI === "true"` force-full branch is gone. const forceFullSuite = - process.env.CI === "true" || process.env.FUSION_TEST_FULL === "1" || argv.includes("--full"); @@ -1136,7 +1150,10 @@ export function main(argv = process.argv.slice(2)) { })); } - const hasWork = plan.mode === "full" || activePackages.length > 0; + // Gate mode always has work: the merge-gate suite is not covered by the + // per-package cache (it spans engine + cli with its own selection), so it + // must never short-circuit through the cache-fresh fast path. + const hasWork = plan.mode === "full" || plan.mode === "gate" || activePackages.length > 0; // Cache-fresh fast path: nothing to run. Emit a fast-path mode line, run only // the (now cheap) isolation guard, and skip skill-sync, artifact-ensure, @@ -1177,18 +1194,7 @@ export function main(argv = process.argv.slice(2)) { try { if (plan.mode === "full") { - if (plan.reason === "missing-comparison-base") { - console.log(`[test-changed] could not resolve merge-base with ${baseBranch}; running full suite.`); - } else if (plan.reason === "diff-failed") { - console.log("[test-changed] failed to read git diff; running full suite."); - } else if (plan.reason === "no-changes") { - console.log("[test-changed] no changes detected against base; running full suite."); - } else if (plan.reason === "shared-infra-changed") { - console.log("[test-changed] shared/root test infrastructure changed; running full suite."); - } else if (plan.reason === "no-affected-package") { - console.log("[test-changed] no affected workspace package resolved; running full suite."); - } - + // Explicit opt-in only ("forced": --full / FUSION_TEST_FULL=1). runMaybeIsolated("pnpm", [`-r`, `--workspace-concurrency=${workspaceConcurrency}`, "test", ...forwardedArgs], { env: isolatedHomeEnv, onBeforeAfterCheck: cleanupIsolatedHome, @@ -1196,6 +1202,34 @@ export function main(argv = process.argv.slice(2)) { return; } + if (plan.mode === "gate") { + if (plan.reason === "missing-comparison-base") { + console.log(`[test-changed] could not resolve merge-base with ${baseBranch}; running merge-gate suite.`); + } else if (plan.reason === "diff-failed") { + console.log("[test-changed] failed to read git diff; running merge-gate suite."); + } else if (plan.reason === "no-changes") { + console.log("[test-changed] no changes detected against base; running merge-gate suite."); + } else if (plan.reason === "shared-infra-changed") { + console.log("[test-changed] shared/root test infrastructure changed; running merge-gate suite."); + } else if (plan.reason === "no-affected-package") { + console.log("[test-changed] no affected workspace package resolved; running merge-gate suite."); + } + console.log("[test-changed] need the full sweep instead? run `pnpm test:full` (explicit opt-in)."); + + runMaybeIsolated("pnpm", ["test:gate"], { + env: isolatedHomeEnv, + onBeforeAfterCheck: cleanupIsolatedHome, + }); + return; + } + + // Changed mode: merge-gate suite first, then the affected set. The gate is + // cheap (~10s) and keeps `pnpm test` green ⇒ mergeable-signal honest; the + // affected expansion preserves changed-code coverage. Overlap (engine in the + // affected set re-runs the engine-core files) is accepted by design. + console.log("[test-changed] running merge-gate suite (pnpm test:gate) before affected packages."); + run("pnpm", ["test:gate"], { env: isolatedHomeEnv }); + const filterArgs = activePackages.flatMap((pkg) => ["--filter", pkg]); console.log(`[test-changed] running tests for changed packages: ${activePackages.join(", ")}`); if (cachedPackages.length > 0) {