FN-8783: parallelize static merge-gate validators
Run independent static merge-gate policy validators concurrently without weakening gate ordering. - Add a fail-closed concurrent static-validator runner with coverage for inventory and failures. - Preserve curated engine, PostgreSQL, unit, and CI-shape gate contracts. - Document the gate composition and warm-cache performance policy. Files changed: docs/testing.md | 13 ++- package.json | 3 +- packages/cli/src/__tests__/ci-workflow.test.ts | 21 ++-- packages/engine/vitest.config.ts | 36 +++++-- .../__tests__/engine-vitest-gate-policy.test.mjs | 90 +++++++++++++---- scripts/__tests__/run-static-gate-checks.test.mjs | 100 +++++++++++++++++++ scripts/run-static-gate-checks.mjs | 106 +++++++++++++++++++++ 7 files changed, 332 insertions(+), 37 deletions(-) Fusion-Task-Id: FN-8783 Fusion-Task-Lineage: d5d3c9e1-b3c4-45ff-a3e7-f9555585cd70 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
@@ -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.
|
||||
|
||||
<!-- FNXC:MergeGatePerformance 2026-07-22-15:35: FN-8497 restores the 8–10s gate band after a 23-file PG integration lane added 17s of serialized database-template work. Keep only the lifecycle and transactional-handoff canaries blocking; the other former members must remain enabled in the non-blocking core test lane and the structural policy test enforces that containment. -->
|
||||
**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.
|
||||
<!-- FNXC:MergeGatePerformance 2026-08-04-15:44: FN-8783 removes only independent static-validator scheduling overhead. The exact policy inventory, all 22 engine files, every PG/unit canary, their nonzero propagation, and CI-shape-after-success remain blocking; a timing win that weakens any of those contracts is not accepted. -->
|
||||
**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.
|
||||
|
||||
<!-- FNXC:MergeGatePerformance 2026-08-04-16:09: FN-8783 keeps engine-core's forks, worker budget, parallelism, bundle rebuild, and all 22 files while caching only Vitest transform artifacts between warm runs. This is never a test-result cache: every assertion and mock boundary remains evaluated for each invocation. -->
|
||||
**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.
|
||||
|
||||
<!-- FNXC:EngineTests 2026-07-08-03:00: FN-7667 decouples the engine-core gate's module graph from full-barrel growth so new feature modules don't silently inflate every gate fork's transform/import cost. -->
|
||||
**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.
|
||||
|
||||
<!-- FNXC:EngineTests 2026-07-08-05:30: FN-7669 pre-bundles the gate-safe @fusion/core barrel to attack the FN-7668-identified import-phase-dominated wall-time cost. -->
|
||||
**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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
|
||||
100
scripts/__tests__/run-static-gate-checks.test.mjs
Normal file
100
scripts/__tests__/run-static-gate-checks.test.mjs
Normal file
@@ -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 });
|
||||
}
|
||||
});
|
||||
106
scripts/run-static-gate-checks.mjs
Normal file
106
scripts/run-static-gate-checks.mjs
Normal file
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user