diff --git a/docs/testing.md b/docs/testing.md index 43c5de48b1..eb1325bfe9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -6,18 +6,23 @@ This guide consolidates the detailed testing guidance moved from `AGENTS.md`. ## The merge gate -CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: CLI `--help` + a real `fn serve` answering `GET /api/health`) and `pnpm test:gate` (static process guards, curated `engine-core`, two PostgreSQL canaries, and the CI-shape test). Everything else — the 4-way shards, the engine slow tier, the dashboard inventory guard — runs NON-BLOCKING in `.github/workflows/full-suite.yml` on push to main. +CI blocks PRs on exactly four checks (`.github/workflows/pr-checks.yml`): **Lint, Typecheck, Build, Gate**. The Gate job runs the boot smoke (`scripts/boot-smoke.mjs`: CLI `--help` + a real `fn serve` answering `GET /api/health`) and `pnpm test:gate`: 11 static policy validators, 22 curated `engine-core` files, three PostgreSQL canaries, four core unit files, then the CI-shape test. Everything else — the 4-way shards, the engine slow tier, the dashboard inventory guard — runs NON-BLOCKING in `.github/workflows/full-suite.yml` on push to main. Gate membership is the explicit allow-list in `packages/engine/vitest.config.ts` (`engine-core` project). Admission requires evidence of value (the test catches real regressions); tests never graduate in by default. A flaky gate test is evicted by deleting its allow-list line — the eviction PR does not need the flaky test to pass. The whole `engine-core` project must stay under ~60s wall-clock. - -**PostgreSQL gate policy:** `packages/core`'s `test:pg-gate` intentionally runs only `task-lifecycle-e2e.pg.test.ts` and `handoff-to-review-atomicity.pg.test.ts`, preserving real-backend lifecycle and atomic-handoff canaries. It runs concurrently with `engine-core` after the static guards; the root script waits for **both** lanes and propagates either failure before running CI-shape. Every other former PG gate member remains enabled and discovered by the non-blocking command `pnpm --filter @fusion/core test` (default config: `src/**/*.test.ts`, no PG quarantine exclusions). `scripts/__tests__/engine-vitest-gate-policy.test.mjs` pins the two canaries and fails if any removed member is deleted, undiscoverable, or hidden by the default lane's script/config. + +**Static-validator and lane ordering:** `test:gate:static` declares the 11 canonical, directly runnable read-only validators. `scripts/run-static-gate-checks.mjs` starts them concurrently and waits for **every** result, so zero, one, or multiple policy failures remain fail-closed and observable before tests start. It then starts `engine-core`, `test:pg-gate`, and `test:unit-gate` concurrently; the shell waits for all **three** and returns nonzero if any fail. CI-shape runs only after that successful wait. + + +**FN-8783 warm result:** The paired W32 protocol recorded in task document `FN-8783/docs` measured the complete-gate median at **15.4s baseline** and **10.2s candidate** across five serialized AB/BA pairs on the same macOS arm64 host (Node 26.3.0, pnpm 10.33.0, identical lockfile). The final engine-core transform-cache profile used one priming run (6.3s), then five warm runs (**5.1, 5.2, 5.1, 5.0, 5.2s; median 5.1s**) versus the pre-cache 6.2s focused engine-core result. The residual full-gate critical path is the unchanged concurrent engine/PG/unit/CI-shape work; task evidence records commands, SHAs, preparation, raw timing order, and coverage counts. + +**PostgreSQL and unit gate policy:** `packages/core`'s `test:pg-gate` intentionally runs `handoff-to-review-atomicity.pg.test.ts`, `task-lifecycle-e2e.pg.test.ts`, and `sync-workflow-ir-is-always-default.pg.test.ts`, preserving atomic-handoff, lifecycle, and default-IR real-backend canaries. `test:unit-gate` runs `task-merge.test.ts`, `legacy-adoption.test.ts`, `no-hardcoded-lifecycle-columns.test.ts`, and `sync-workflow-ir-callsite-allowlist.test.ts`. Every other former PG gate member remains enabled and discovered by the non-blocking command `pnpm --filter @fusion/core test` (default config: `src/**/*.test.ts`, no PG quarantine exclusions). `scripts/__tests__/engine-vitest-gate-policy.test.mjs` pins the exact three PG and four unit files, all waits, CI-shape ordering, and every engine/static member. **Gate-safe `@fusion/core` barrel:** the `engine-core` project resolves `@fusion/core` to `packages/core/src/index.gate.ts` (a project-scoped `resolve.alias`, not the root map), not the full `packages/core/src/index.ts` barrel. `index.gate.ts` is a byte-for-byte copy of the full barrel minus the `export ... from` statements for modules added to the barrel after the last re-audit baseline — i.e. it re-exports everything the full barrel does except genuinely new, gate-irrelevant feature modules (diffed against the prior baseline commit's barrel, not hand-picked from what gate *test* files import — production modules under test pull in far more of the barrel transitively than their own imports suggest). `engine-default`/`engine-reliability`/`engine-slow` are unaffected and keep resolving the full barrel. `@fusion/engine` is untouched (no gate file imports it). When adding a new barrel module that no gate test needs, mirror the exclusion in `index.gate.ts` rather than letting gate wall-time grow — see the FNXC comment at the top of `index.gate.ts` and `packages/engine/vitest.config.ts`'s `engine-core` project for the audit procedure. -**Pre-bundled `@fusion/core` gate bundle:** FN-7668 profiled the gate's dominant wall-time cost as vitest/Vite SSR's **import-phase** — each of the 18 `pool:"forks"` OS processes independently re-resolves+evaluates the barrel closure from scratch with zero cross-fork sharing. `engine-core`'s `@fusion/core` alias now points at a single esbuild-bundled ESM file (`scripts/build-engine-core-gate-bundle.mjs`, entrypoint `packages/core/src/index.gate.ts`, `packages:"external"` so only the first-party closure — 220 files — is inlined) instead of directly at `index.gate.ts`'s source, collapsing 220 per-fork Vite SSR module-loader round-trips into 1 file load per fork. The bundle is **rebuilt fresh on every gate invocation** via the `engine-core` project's `globalSetup` (the builder's own esbuild dependency graph determines what gets bundled — never a hand-maintained file/symbol list, so there is no drift surface), and lives at `packages/core/.gate-bundle/core.mjs` — a gitignored, non-committed artifact placed as a **sibling of `packages/core/node_modules/`, deliberately not nested inside it**: nesting inside `node_modules` triggers Vite's SSR external-dep heuristic (loads the whole bundle via Node's native loader, bypassing Vite's mock-interception pipeline) and silently defeats `vi.mock` for imports nested inside the bundle (see the FNXC comment in the builder script for the full repro/fix). Measured A/B (5 alternating runs each, FN-7669 task docs): median real wall-time −5.5%, import-phase aggregate −14.0%, transform-phase aggregate −25.9%, with full coverage parity (335/335 gate tests, identical per-file counts) — a modest but real, reproducible, zero-downside win. `@fusion/engine` stays on the full (unbundled) barrel: no gate file imports it directly, so bundling it would be zero-benefit churn against the core↔engine circular-import DI. Bundling the `@fusion/engine` relative-import graph (`merger.ts` et al., the untouched remainder of FN-7668's ~430-file closure) is a natural, larger-payoff follow-up, filed separately. +**Pre-bundled `@fusion/core` gate bundle:** FN-7668 profiled the gate's dominant wall-time cost as vitest/Vite SSR's **import-phase** — each fork worker independently re-resolves+evaluates the barrel closure from scratch with zero cross-fork sharing. `engine-core`'s `@fusion/core` alias now points at a single esbuild-bundled ESM file (`scripts/build-engine-core-gate-bundle.mjs`, entrypoint `packages/core/src/index.gate.ts`, `packages:"external"` so only the first-party closure — 220 files — is inlined) instead of directly at `index.gate.ts`'s source, collapsing 220 per-fork Vite SSR module-loader round-trips into 1 file load per fork. The bundle is **rebuilt fresh on every gate invocation** via the `engine-core` project's `globalSetup` (the builder's own esbuild dependency graph determines what gets bundled — never a hand-maintained file/symbol list, so there is no drift surface), and lives at `packages/core/.gate-bundle/core.mjs` — a gitignored, non-committed artifact placed as a **sibling of `packages/core/node_modules/`, deliberately not nested inside it**: nesting inside `node_modules` triggers Vite's SSR external-dep heuristic (loads the whole bundle via Node's native loader, bypassing Vite's mock-interception pipeline) and silently defeats `vi.mock` for imports nested inside the bundle (see the FNXC comment in the builder script for the full repro/fix). Measured A/B (5 alternating runs each, FN-7669 task docs): median real wall-time −5.5%, import-phase aggregate −14.0%, transform-phase aggregate −25.9%, with full coverage parity (335/335 gate tests, identical per-file counts) — a modest but real, reproducible, zero-downside win. `@fusion/engine` stays on the full (unbundled) barrel: no gate file imports it directly, so bundling it would be zero-benefit churn against the core↔engine circular-import DI. Bundling the `@fusion/engine` relative-import graph (`merger.ts` et al., the untouched remainder of FN-7668's ~430-file closure) is a natural, larger-payoff follow-up, filed separately. ## Weekly signal-per-second baseline diff --git a/package.json b/package.json index 0344181e5f..982697b8c2 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "census:lifecycle-columns": "node scripts/lifecycle-column-census.mjs", "check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs --strict", "check:mock-completeness": "node scripts/check-mock-completeness.mjs", - "test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && node scripts/check-inert-sync-lane-conversions.mjs && sh -c 'pnpm --filter @fusion/engine test:core & engine_pid=$!; pnpm --filter @fusion/core test:pg-gate & pg_pid=$!; pnpm --filter @fusion/core test:unit-gate & unit_pid=$!; status=0; wait $engine_pid || status=1; wait $pg_pid || status=1; wait $unit_pid || status=1; exit $status' && pnpm --filter @runfusion/fusion test:ci-shape", + "test:gate:static": "node scripts/check-no-nohup.mjs && node scripts/check-no-cwd-relative-dashboard-test-reads.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-capacity-pool-id.mjs && node scripts/check-no-node-only-core-imports-in-dashboard.mjs && node scripts/check-pi-versions-pinned.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && node scripts/check-inert-sync-lane-conversions.mjs", + "test:gate": "node scripts/run-static-gate-checks.mjs && sh -c 'pnpm --filter @fusion/engine test:core & engine_pid=$!; pnpm --filter @fusion/core test:pg-gate & pg_pid=$!; pnpm --filter @fusion/core test:unit-gate & unit_pid=$!; status=0; wait $engine_pid || status=1; wait $pg_pid || status=1; wait $unit_pid || status=1; exit $status' && pnpm --filter @runfusion/fusion test:ci-shape", "smoke:boot": "node scripts/boot-smoke.mjs", "local": "node scripts/start-local.mjs", "dev": "node scripts/dev-with-memory.mjs", diff --git a/packages/cli/src/__tests__/ci-workflow.test.ts b/packages/cli/src/__tests__/ci-workflow.test.ts index 823539d8f5..105de8c8a1 100644 --- a/packages/cli/src/__tests__/ci-workflow.test.ts +++ b/packages/cli/src/__tests__/ci-workflow.test.ts @@ -230,18 +230,25 @@ describe("Merge gate (.github/workflows/pr-checks.yml)", () => { }); /* - FNXC:CITestGate 2026-06-26-06:40: - The merge gate is the thin trusted CI surface. ci-workflow.test.ts must pin not only that the Gate job invokes `pnpm test:gate`, but also test:gate's internal composition (guards + engine test:core + cli test:ci-shape) and that engine test:core references the engine-core vitest project — otherwise a rename could hollow the gate while this CI-shape test stays green (FN-7059). + FNXC:CITestGate 2026-08-04-15:44: + FN-8783 runs independent read-only static validators concurrently, but they + still must all finish successfully before the curated lanes begin. Pin the + runner and its manifest composition separately: putting the exact inventory + only in test:gate would encourage a future serial regression, while checking + only the runner could hide a removed policy guard. */ - it("pins test:gate to the audited guard scripts and curated suites", () => { + it("pins test:gate to the fail-closed guard runner and curated suites", () => { const testGateScript = rootPackageJson.scripts?.["test:gate"] ?? ""; + const staticGateScript = rootPackageJson.scripts?.["test:gate:static"] ?? ""; - expect(testGateScript).toContain("node scripts/check-no-" + "no" + "hup" + ".mjs"); // process-supervisor-allowlist: asserts the gate wires the checker; not a real spawn - expect(testGateScript).toContain("node scripts/check-no-kill-" + "40" + "40" + ".mjs"); // port-4040-allowlist: asserts the gate wires the checker; not a real port bind - expect(testGateScript).toContain("node scripts/check-no-test-timeout-appeasement.mjs"); - expect(testGateScript).toContain("node scripts/check-changeset-format.mjs"); + expect(testGateScript).toContain("node scripts/run-static-gate-checks.mjs"); + expect(staticGateScript).toContain("node scripts/check-no-" + "no" + "hup" + ".mjs"); // process-supervisor-allowlist: asserts the gate wires the checker; not a real spawn + expect(staticGateScript).toContain("node scripts/check-no-kill-" + "40" + "40" + ".mjs"); // port-4040-allowlist: asserts the gate wires the checker; not a real port bind + expect(staticGateScript).toContain("node scripts/check-no-test-timeout-appeasement.mjs"); + expect(staticGateScript).toContain("node scripts/check-changeset-format.mjs"); expect(testGateScript).toContain("pnpm --filter @fusion/engine test:core"); expect(testGateScript).toContain("pnpm --filter @fusion/core test:pg-gate"); + expect(testGateScript).toContain("pnpm --filter @fusion/core test:unit-gate"); expect(testGateScript).toContain("pnpm --filter @runfusion/fusion test:ci-shape"); }); diff --git a/packages/engine/vitest.config.ts b/packages/engine/vitest.config.ts index 6dac85b8bd..9eff7c4aa0 100644 --- a/packages/engine/vitest.config.ts +++ b/packages/engine/vitest.config.ts @@ -71,6 +71,14 @@ export default defineConfig({ inherited via extends:true, so @fusion/test-utils/@fusion/plugin-sdk/@fusion/dashboard stay on their root aliases and only @fusion/core is overridden here. + FNXC:MergeGatePerformance 2026-08-04-15:44: + FN-8783 confirms W32's engine-core lane has 22 exact policy files. + The current core bundle remains the only evidence-backed import-path + optimization: it is rebuilt every run, retains mock interception, and + avoids the measured-slower engine-graph bundle designs below. Keep pool, + worker budgeting, file parallelism, and this alias intact; membership is + pinned in scripts/__tests__/engine-vitest-gate-policy.test.mjs. + FNXC:EngineTests 2026-07-08-04:50: FN-7669: the @fusion/core alias now points at a PRE-BUNDLED single ESM file (packages/core/.gate-bundle/core.mjs — a SIBLING of @@ -80,8 +88,8 @@ export default defineConfig({ bundle, see scripts/build-engine-core-gate-bundle.mjs for the full repro) instead of directly at index.gate.ts's source. FN-7668 profiled the gate's dominant wall-time cost as vitest/Vite SSR's import-phase — each - of the 18 pool:"forks" processes independently re-resolving+evaluating - the ~430-file barrel closure with zero cross-fork sharing. esbuild- + of the fork workers independently re-resolving+evaluating the ~430-file + barrel closure with zero cross-fork sharing. esbuild- bundling the index.gate.ts closure (220 first-party files, the @fusion/core slice of that ~430) into one file (scripts/build-engine-core-gate-bundle.mjs, wired below via globalSetup @@ -90,8 +98,8 @@ export default defineConfig({ fork. See the task's docs document for the full A/B measurement, coverage-parity proof, and land/no-land rationale. @fusion/engine is deliberately left on the full barrel, unbundled: none - of the 18 curated gate files import "@fusion/engine" at all (verified by - grep across all 18 files), so bundling it would be zero-benefit + of the originally profiled curated files imported "@fusion/engine" at + all, so bundling it would be zero-benefit churn/risk — and it would additionally risk double-registering or dead-locking the core↔engine circular-import DI (`void import("@fusion/core").then(setCreateFnAgent...)` in @@ -100,7 +108,7 @@ export default defineConfig({ FNXC:EngineTests 2026-07-08-06:20: FN-7670 prototyped extending this same lever to the @fusion/engine RELATIVE-import production graph (`../merger.js`, `../hold-release.js`, - `../scheduler.js`, `../workflow-node-handlers.js`, ...) that the 18 gate + `../scheduler.js`, `../workflow-node-handlers.js`, ...) that curated gate files reach directly — NOT the barrel above, which stays untouched per the paragraph above regardless. It built a fully working, coverage- parity-preserving, mock-safe bundle (171 first-party files → 35 output @@ -151,8 +159,8 @@ export default defineConfig({ /* FNXC:EngineTests 2026-07-08-04:50: FN-7669: prepend the gate-bundle builder to this project's globalSetup so - the @fusion/core bundle above is rebuilt before any of the 18 forks spawn - and resolve the alias. REBUILD-EVERY-RUN is the invalidation model — the + the @fusion/core bundle above is rebuilt before fork workers spawn and + resolve the alias. REBUILD-EVERY-RUN is the invalidation model — the builder's own esbuild dependency graph (not a hand list) determines what gets bundled, and because it reruns on every gate invocation there is no drift surface. The original root-level vitest-teardown.ts worker-root @@ -170,6 +178,20 @@ export default defineConfig({ The curated engine-core merge gate hits a Node 24.15.0/macOS libuv kqueue SIGABRT when Vitest thread workers close unmanaged file descriptors. Scope fork workers to this gate so the broad default engine suite keeps its explicit worker-thread behavior. */ pool: "forks", + experimental: { + /* + FNXC:MergeGatePerformance 2026-08-04-16:09: + FN-8783 retains all 22 forked files but enables Vitest's validated + filesystem transform cache only for engine-core. Fork isolation still + evaluates every test and preserves mocks; caching immutable Vite + transforms avoids repeating import/setup compilation on warm gate runs. + Keep this cache project-scoped so broad engine lanes cannot inherit + gate-specific artifacts, and let Vitest invalidate entries from its + transform dependency graph rather than maintaining an unsafe file list. + */ + fsModuleCache: true, + fsModuleCachePath: resolve(__dirname, "node_modules/.engine-core-fs-module-cache"), + }, // The curated merge-gate suite (see docs/testing.md "Merge gate"). // Membership is an explicit allow-list, NOT a glob: tests earn their // way in with evidence of value, and a flaky gate test is evicted by diff --git a/scripts/__tests__/engine-vitest-gate-policy.test.mjs b/scripts/__tests__/engine-vitest-gate-policy.test.mjs index 626461e504..3a25e6df39 100644 --- a/scripts/__tests__/engine-vitest-gate-policy.test.mjs +++ b/scripts/__tests__/engine-vitest-gate-policy.test.mjs @@ -50,6 +50,17 @@ test("engine-core gate keeps a Node 24/macOS-safe Vitest pool without changing b ); assert.match(config, /maxWorkers,/, "worker budgeting must still flow through computeMaxWorkers"); assert.match(config, /fileParallelism:\s*true/, "engine-core should preserve file-level parallelism"); + /* + FNXC:MergeGatePerformance 2026-08-04-16:09: + FN-8783's warm import/setup efficiency is a transform cache, not a result + cache: every engine-core assertion still executes in fork isolation. Pin its + project-local path so a later config edit cannot silently widen this cache to + the broad engine lanes or replace it with stale hand-maintained artifacts. + */ + assert.match(engineCoreBlock, /experimental:\s*\{[\s\S]*?fsModuleCache:\s*true/, + "engine-core must retain Vitest's filesystem transform cache"); + assert.match(engineCoreBlock, /fsModuleCachePath:\s*resolve\(__dirname, "node_modules\/.engine-core-fs-module-cache"\)/, + "engine-core transform cache must stay isolated from broad engine lanes"); }); test("engine-core remains an explicit allow-listed merge gate", () => { @@ -59,35 +70,78 @@ test("engine-core remains an explicit allow-listed merge gate", () => { assert.equal(new Set(includeEntries).size, includeEntries.length, "engine-core allow-list must not contain duplicates"); /* - FNXC:MergeGatePerformance 2026-07-22-15:36: - FN-8497 exercises this policy after profiling the complete gate. The current - curated engine lane has 16 explicit files after the documented SQLite and - obsolete graph-runner retirements; guard its real floor instead of the stale - 18-file count, while still requiring the replacement graph executor seam. + FNXC:MergeGatePerformance 2026-08-04-15:44: + FN-8783 measured the W32 gate after six policy files joined the former + 16-file lane. Exact membership is the coverage contract: an efficiency change + may reduce scheduling overhead, never silently drop an assertion group. */ - assert.ok(includeEntries.length >= 16, "engine-core allow-list must not be gutted to avoid the runtime abort"); - assert.ok( - includeEntries.includes('"src/__tests__/workflow-graph-executor-parity.test.ts"'), - "engine-core must keep workflow graph executor gate coverage", - ); - assert.ok( - includeEntries.includes('"src/__tests__/heartbeat-monitor.test.ts"'), - "engine-core must keep heartbeat monitor gate coverage while avoiding FN-779 scope changes", - ); + const expectedMembers = [ + '"src/__tests__/legacy-column-literal-census.test.ts"', + '"src/__tests__/no-legacy-move-targets.test.ts"', + '"src/__tests__/merger-merge-lifecycle.test.ts"', + '"src/__tests__/merger-conflict-resolution.test.ts"', + '"src/__tests__/merger-diff-scope.test.ts"', + '"src/__tests__/merger-landed-files-capture.test.ts"', + '"src/__tests__/branch-attribution.test.ts"', + '"src/__tests__/project-engine.test.ts"', + '"src/__tests__/merge-single-flight-invariant.test.ts"', + '"src/__tests__/workflow-step-verdict-parsing.test.ts"', + '"src/__tests__/u9-merge-region-node-config-authority.test.ts"', + '"src/__tests__/executor-graph-requeue-gate.test.ts"', + '"src/__tests__/workflow-graph-executor-parity.test.ts"', + '"src/__tests__/task-pipeline-smoke.test.ts"', + '"src/__tests__/scheduler-workflow-cutover.test.ts"', + '"src/__tests__/executor-base-commit-capture.test.ts"', + '"src/__tests__/executor-capture-modified-files-attribution.test.ts"', + '"src/__tests__/triage-preflight.test.ts"', + '"src/__tests__/mission-scheduler.test.ts"', + '"src/__tests__/heartbeat-monitor.test.ts"', + '"src/__tests__/workflow-node-handlers.test.ts"', + '"src/__tests__/workflow-policy-ownership-map.test.ts"', + ]; + assert.deepEqual(includeEntries, expectedMembers, "engine-core must retain its complete ordered 22-file coverage map"); }); test("root and package gate scripts still propagate real Vitest failures", () => { const root = readJson("package.json"); const engine = readJson("packages/engine/package.json"); + const core = readJson("packages/core/package.json"); + const staticChecks = root.scripts?.["test:gate:static"] ?? ""; + const gate = root.scripts?.["test:gate"] ?? ""; assert.equal( engine.scripts?.["test:core"], "vitest run --silent=passed-only --reporter=dot --project=engine-core", ); - assert.match(root.scripts?.["test:gate"] ?? "", /pnpm --filter @fusion\/engine test:core/); - assert.match(root.scripts?.["test:gate"] ?? "", /wait \$engine_pid \|\| status=1/); - assert.match(root.scripts?.["test:gate"] ?? "", /wait \$pg_pid \|\| status=1/); - assert.doesNotMatch(root.scripts?.["test:gate"] ?? "", /NODE_NO_WARNINGS/); + assert.match(gate, /^node scripts\/run-static-gate-checks\.mjs &&/); + const gateValidators = [...staticChecks.matchAll(/node (scripts\/check-[\w-]+\.mjs)/g)].map((match) => match[1]); + const staticCheck = (name) => `scripts/check-${name}.mjs`; + assert.deepEqual(gateValidators, [ + staticCheck(["no-", ["no", "hup"].join("")].join("")), + staticCheck("no-cwd-relative-dashboard-test-reads"), + staticCheck(["no-", "kill-", "40" + "40"].join("")), + staticCheck("no-getdatabase"), + staticCheck("capacity-pool-id"), + staticCheck("no-node-only-core-imports-in-dashboard"), + staticCheck("pi-versions-pinned"), + staticCheck("no-test-timeout-appeasement"), + staticCheck("changeset-format"), + staticCheck("mock-completeness"), + staticCheck("inert-sync-lane-conversions"), + ], "every static policy validator must remain once in the blocking composition"); + assert.equal(new Set(gateValidators).size, gateValidators.length, "the static validator composition must be duplicate-free"); + assert.match(gate, /pnpm --filter @fusion\/engine test:core/); + assert.match(gate, /pnpm --filter @fusion\/core test:pg-gate/); + assert.match(gate, /pnpm --filter @fusion\/core test:unit-gate/); + assert.match(gate, /wait \$engine_pid \|\| status=1/); + assert.match(gate, /wait \$pg_pid \|\| status=1/); + assert.match(gate, /wait \$unit_pid \|\| status=1/); + assert.match(gate, /&& pnpm --filter @runfusion\/fusion test:ci-shape$/); + assert.equal( + core.scripts?.["test:unit-gate"], + "vitest run src/__tests__/task-merge.test.ts src/__tests__/legacy-adoption.test.ts src/__tests__/no-hardcoded-lifecycle-columns.test.ts src/__tests__/sync-workflow-ir-callsite-allowlist.test.ts --silent=passed-only --reporter=dot", + ); + assert.doesNotMatch(gate, /NODE_NO_WARNINGS/); assert.doesNotMatch(root.scripts?.["test"] ?? "", /NODE_NO_WARNINGS/); }); diff --git a/scripts/__tests__/run-static-gate-checks.test.mjs b/scripts/__tests__/run-static-gate-checks.test.mjs new file mode 100644 index 0000000000..fb949b9025 --- /dev/null +++ b/scripts/__tests__/run-static-gate-checks.test.mjs @@ -0,0 +1,100 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { + extractLeadingStaticGateChecks, + readStaticGateChecks, + runStaticGateChecks, +} from "../run-static-gate-checks.mjs"; + +const check = (name) => `scripts/check-${name}.mjs`; +const EXPECTED_GATE_CHECKS = [ + check(["no-", ["no", "hup"].join("")].join("")), + check("no-cwd-relative-dashboard-test-reads"), + check(["no-", "kill-", "40" + "40"].join("")), + check("no-getdatabase"), + check("capacity-pool-id"), + check("no-node-only-core-imports-in-dashboard"), + check("pi-versions-pinned"), + check("no-test-timeout-appeasement"), + check("changeset-format"), + check("mock-completeness"), + check("inert-sync-lane-conversions"), +]; + +function createFixture() { + const root = mkdtempSync(join(tmpdir(), "static-gate-checks-")); + mkdirSync(join(root, "scripts")); + return root; +} + +function writeFixtureCheck(root, name, source) { + writeFileSync(join(root, "scripts", `${name}.mjs`), source); +} + +test("extractLeadingStaticGateChecks keeps only the blocking validator prefix", () => { + assert.deepEqual( + extractLeadingStaticGateChecks("node scripts/check-one.mjs && node scripts/check-two.mjs && sh -c 'test lanes'"), + ["scripts/check-one.mjs", "scripts/check-two.mjs"], + ); + assert.throws( + () => extractLeadingStaticGateChecks("pnpm --filter @fusion/engine test:core"), + /must contain one or more canonical static validators/, + ); +}); + +test("production gate inventory contains each canonical validator exactly once", () => { + const checks = readStaticGateChecks(); + assert.deepEqual(checks, EXPECTED_GATE_CHECKS); + assert.equal(new Set(checks).size, checks.length); +}); + +test("runStaticGateChecks runs clean fixture validators and waits for all", async () => { + const root = createFixture(); + try { + writeFixtureCheck(root, "check-first", 'console.log("first passed");'); + writeFixtureCheck(root, "check-second", 'console.log("second passed");'); + const messages = []; + const results = await runStaticGateChecks( + ["scripts/check-first.mjs", "scripts/check-second.mjs"], + { root, log: (message) => messages.push(message) }, + ); + + assert.deepEqual(results.map((result) => result.code), [0, 0]); + assert.deepEqual(messages, ["[static-gate] 2 validators passed"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("runStaticGateChecks reports every violating fixture validator before failing closed", async () => { + const root = createFixture(); + try { + writeFixtureCheck(root, "check-clean", 'process.exit(0);'); + writeFixtureCheck(root, "check-first-violation", 'console.error("first violation"); process.exit(1);'); + writeFixtureCheck(root, "check-second-violation", 'console.error("second violation"); process.exit(2);'); + const errors = []; + + await assert.rejects( + () => runStaticGateChecks( + [ + "scripts/check-clean.mjs", + "scripts/check-first-violation.mjs", + "scripts/check-second-violation.mjs", + ], + { root, errorLog: (message) => errors.push(message) }, + ), + /2 static merge-gate validators failed/, + ); + + assert.deepEqual(errors, [ + "[static-gate] validator failed: scripts/check-first-violation.mjs (exit 1)", + "[static-gate] validator failed: scripts/check-second-violation.mjs (exit 2)", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/scripts/run-static-gate-checks.mjs b/scripts/run-static-gate-checks.mjs new file mode 100644 index 0000000000..f52eec8bc8 --- /dev/null +++ b/scripts/run-static-gate-checks.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +/* +FNXC:MergeGatePerformance 2026-08-04-15:44: +FN-8783 keeps every static merge-gate validator blocking while removing their +serial startup and repository-scan critical path. Launch each canonical, +read-only validator in manifest order, then wait for every result before any +test lane can begin. Waiting for all children reports multiple failures instead +of hiding a later policy violation behind an earlier one; there is deliberately +no cancellation because validators do not mutate shared state. +*/ + +import { spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +export const repoRoot = resolve(scriptDir, ".."); +export const packageManifestPath = resolve(repoRoot, "package.json"); + +/** + * Return the contiguous canonical static validators from their dedicated gate + * composition. The following concurrent test lanes remain outside this + * function so a failing policy process prevents their launch exactly as the + * old chain did. + * + * @param {string} gateCommand + * @returns {string[]} + */ +export function extractLeadingStaticGateChecks(gateCommand) { + if (typeof gateCommand !== "string" || !gateCommand.trim()) { + throw new Error("package.json must define a non-empty static gate command"); + } + + const commands = gateCommand.split("&&").map((command) => command.trim()); + const checks = []; + for (const command of commands) { + const match = /^node\s+(scripts\/check-[\w-]+\.mjs)$/.exec(command); + if (!match) break; + checks.push(match[1]); + } + if (checks.length === 0) { + throw new Error("static gate command must contain one or more canonical static validators"); + } + return checks; +} + +/** + * Read the validator inventory from the dedicated production gate composition + * so package.json remains the single source of truth for blocking policy membership. + * + * @param {string} [manifestPath] + * @returns {string[]} + */ +export function readStaticGateChecks(manifestPath = packageManifestPath) { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + return extractLeadingStaticGateChecks(manifest.scripts?.["test:gate:static"]); +} + +/** + * Spawn one validator and resolve to its exit status. Spawn failures are a + * failing validator too, preserving the gate's fail-closed behavior. + * + * @param {string} checkScript + * @param {{ root?: string, nodeBin?: string, spawnImpl?: typeof spawn }} [options] + * @returns {Promise<{ checkScript: string, code: number | null, signal: NodeJS.Signals | null, error?: Error }>} + */ +export function runStaticGateCheck(checkScript, { root = repoRoot, nodeBin = process.execPath, spawnImpl = spawn } = {}) { + return new Promise((resolveResult) => { + const child = spawnImpl(nodeBin, [resolve(root, checkScript)], { + cwd: root, + shell: false, + stdio: "inherit", + }); + child.once("error", (error) => resolveResult({ checkScript, code: null, signal: null, error })); + child.once("close", (code, signal) => resolveResult({ checkScript, code, signal })); + }); +} + +/** + * Launch every validator before waiting for results. Promise.all preserves + * manifest order in diagnostics even when the operating system completes the + * processes in a different order. + * + * @param {string[]} checkScripts + * @param {{ root?: string, nodeBin?: string, spawnImpl?: typeof spawn, log?: (message: string) => void, errorLog?: (message: string) => void }} [options] + * @returns {Promise<{ checkScript: string, code: number | null, signal: NodeJS.Signals | null, error?: Error }[]>} + */ +export async function runStaticGateChecks(checkScripts, options = {}) { + const { log = console.log, errorLog = console.error, ...runOptions } = options; + const results = await Promise.all(checkScripts.map((checkScript) => runStaticGateCheck(checkScript, runOptions))); + const failures = results.filter((result) => result.error || result.code !== 0); + for (const failure of failures) { + const detail = failure.error?.message ?? (failure.signal ? `signal ${failure.signal}` : `exit ${failure.code}`); + errorLog(`[static-gate] validator failed: ${failure.checkScript} (${detail})`); + } + if (failures.length > 0) { + throw new Error(`${failures.length} static merge-gate validator${failures.length === 1 ? "" : "s"} failed`); + } + log(`[static-gate] ${results.length} validators passed`); + return results; +} + +if (import.meta.url === `file://${process.argv[1]}`) { + await runStaticGateChecks(readStaticGateChecks()); +}