From dd4a6d24fa6d247ec8521c73f75dad5cafe4b42f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 09:36:02 +0000 Subject: [PATCH] test(scripts): give the two spawn-env gates one SPAWNERS definition, with a fork fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `spawned-build-vitest-env-8598.test.ts` and `spawned-vitest-child-env-8616.test.ts` each derive a population of child-process call sites by AST and then judge each one's `env:`. Each carried its own hand-maintained copy of the spawner set, and the copies had diverged: 8616's listed `fork`, 8598's did not, and neither file's prose gave a reason for the difference. Dropping a member from a POPULATION set is a false GREEN. A build started as `fork('…', ['build'], { env })` never entered 8598's census, so the gate that exists to stop `VITEST` reaching a spawned build would have reported clean about a call site it never looked at — and a gate cannot notice that it is judging fewer things than it should. Repair the construction rather than the instance: one exported constant in `scripts/__tests__/helpers/spawners.ts`, imported by both gates, so there is no second literal to keep honest. Converged upward, onto the set including `fork`; narrowing 8616's census instead would be a weakening, not a repair. `fork` is latent, not live — the test tree holds zero `fork(` call sites, so against the real population a run with `fork` in the set and one without produce identical results and a green suite measures nothing. A fixture pair supplies the missing population: the same `fork()` build spawn twice, differing only in what its `env:` does to `VITEST`. The gate is driven over both through the same `leakingIn()` renderer the live assertion uses, and each leg asserts the population COUNT as well as the verdict, so removing `fork` from the set fails the case on an empty census instead of clearing it vacuously. Fixtures carry `.fixture.ts`, which `testFiles()` does not match, so the deliberate leak in one of them stays invisible to the live census. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01FhBNJcLRZLe8M87VcUgpKr --- .changeset/9211-spawners-single-source.md | 27 ++++++ .../fork-build-inherits.fixture.ts | 22 +++++ .../fork-build-scrubbed.fixture.ts | 19 ++++ scripts/__tests__/helpers/spawners.ts | 44 +++++++++ .../spawned-build-vitest-env-8598.test.ts | 92 +++++++++++++++++-- .../spawned-vitest-child-env-8616.test.ts | 4 +- 6 files changed, 196 insertions(+), 12 deletions(-) create mode 100644 .changeset/9211-spawners-single-source.md create mode 100644 scripts/__tests__/fixtures/spawned-build-vitest-env-8598/fork-build-inherits.fixture.ts create mode 100644 scripts/__tests__/fixtures/spawned-build-vitest-env-8598/fork-build-scrubbed.fixture.ts create mode 100644 scripts/__tests__/helpers/spawners.ts diff --git a/.changeset/9211-spawners-single-source.md b/.changeset/9211-spawners-single-source.md new file mode 100644 index 0000000000..58d8956f19 --- /dev/null +++ b/.changeset/9211-spawners-single-source.md @@ -0,0 +1,27 @@ +--- +--- + +Give the two spawned-child-environment gates ONE definition of what counts as a +child-process spawner (objectui#9211). Test tooling only; no package is released +by this change. + +`spawned-build-vitest-env-8598.test.ts` and `spawned-vitest-child-env-8616.test.ts` +each carried a hand-maintained copy of the same `SPAWNERS` set, and the copies had +diverged: objectui#8616's listed `fork`, objectui#8598's did not, and neither +file's prose gave a reason. Dropping a member from a POPULATION set is a false +GREEN — a build started as `fork('…', ['build'], { env })` never entered +objectui#8598's census, so the gate that exists to stop `VITEST` reaching a +spawned build would have reported clean about a call site it never looked at. + +The repair is the construction, not the instance: both gates now import +`scripts/__tests__/helpers/spawners.ts`, so there is no second literal to keep +honest. Converged upward, onto the set that includes `fork` — the other +direction narrows a live gate's census, which is not a repair. + +`fork` is latent rather than live: the test tree holds zero `fork(` call sites, +so a green suite would carry no information about whether the new member works. +A fixture pair under `scripts/__tests__/fixtures/spawned-build-vitest-env-8598/` +supplies the population the tree does not have — the same `fork()` build spawn +twice, differing only in whether the child's `env:` still carries `VITEST` — and +the gate is driven over both, asserting the population count as well as the +verdict so an empty census fails loudly instead of clearing everything. diff --git a/scripts/__tests__/fixtures/spawned-build-vitest-env-8598/fork-build-inherits.fixture.ts b/scripts/__tests__/fixtures/spawned-build-vitest-env-8598/fork-build-inherits.fixture.ts new file mode 100644 index 0000000000..0036395659 --- /dev/null +++ b/scripts/__tests__/fixtures/spawned-build-vitest-env-8598/fork-build-inherits.fixture.ts @@ -0,0 +1,22 @@ +/** + * A build started with `child_process.fork`, handed the worker's environment + * unchanged — the LEAK objectui#8598's gate exists to refuse, spelled with the + * spawner that gate could not see before objectui#9211. + * + * ⚠️ Not a test and never executed: read only as TEXT by + * `spawned-build-vitest-env-8598.test.ts`, which parses this file and asks its + * own `buildSpawns()` what it finds. The `.fixture.ts` extension keeps it out + * of that gate's LIVE census (`/\.(test|spec)\.tsx?$/`), so the deliberate leak + * below cannot turn the real gate red. `tsconfig.scripts.json` compiles every + * `.ts` under `scripts/`, so it does have to type-check. + * + * Its pair, `fork-build-scrubbed.fixture.ts`, differs ONLY in what the `env:` + * does to `VITEST`. Everything else — the spawner, the arguments, the shape of + * the call — is byte-identical, so the verdict the gate returns is attributable + * to that one difference and to nothing else. + */ +import { fork } from 'node:child_process'; + +export function spawnTheBuild(): void { + fork('scripts/run-package-build.js', ['build'], { env: { ...process.env } }); +} diff --git a/scripts/__tests__/fixtures/spawned-build-vitest-env-8598/fork-build-scrubbed.fixture.ts b/scripts/__tests__/fixtures/spawned-build-vitest-env-8598/fork-build-scrubbed.fixture.ts new file mode 100644 index 0000000000..d1c951158d --- /dev/null +++ b/scripts/__tests__/fixtures/spawned-build-vitest-env-8598/fork-build-scrubbed.fixture.ts @@ -0,0 +1,19 @@ +/** + * The repaired half of the objectui#9211 pair: the same `child_process.fork` + * build spawn as `fork-build-inherits.fixture.ts`, with `VITEST` removed from + * the child's environment by the rest-pattern spelling the gate accepts. + * + * ⚠️ Not a test and never executed — see the header of its pair for why the + * `.fixture.ts` extension matters and what the two files are read by. + * + * The two fixtures differ only in the `env:`. This one must be judged + * `scrubbed`; if both came back the same, the gate would be reporting the + * spawner rather than the environment. + */ +import { fork } from 'node:child_process'; + +const { VITEST: _vitest, ...BUILD_ENV } = process.env; + +export function spawnTheBuild(): void { + fork('scripts/run-package-build.js', ['build'], { env: BUILD_ENV }); +} diff --git a/scripts/__tests__/helpers/spawners.ts b/scripts/__tests__/helpers/spawners.ts new file mode 100644 index 0000000000..a353d34f16 --- /dev/null +++ b/scripts/__tests__/helpers/spawners.ts @@ -0,0 +1,44 @@ +/** + * The Node child-process entry points that start a program — ONE definition + * (objectui#9211). + * + * ## The defect this closes + * + * Two gates derive a population of child-process call sites by AST and then + * judge each one's `env:` — `spawned-build-vitest-env-8598.test.ts` (does the + * child of a spawned BUILD still carry `VITEST`?) and + * `spawned-vitest-child-env-8616.test.ts` (does a spawned VITEST still carry + * this container's agent markers?). Each one carried its own hand-maintained + * copy of this set, and the copies had already diverged: objectui#8616's + * listed `fork`, objectui#8598's did not, and NEITHER file's prose gave a + * reason. Measured on `1f4e02995a` and re-measured on `b775500`, `fork` occurs + * in neither file outside the set literal itself — so it was drift, not an + * unrecorded decision. + * + * ⚠️ The direction of that drift is what makes it worth a module rather than a + * one-line edit. Dropping a member from a POPULATION set is a false GREEN, not + * a false red: a build started as `fork('…', ['build'], { env })` simply never + * entered objectui#8598's population, so the gate that exists to stop `VITEST` + * reaching a spawned build reported clean about a call site it never looked at. + * A gate cannot notice that it is judging fewer things than it should. + * + * ## Why the module, and not just adding `fork` back + * + * The failure is not that the two sets disagreed on a Tuesday — it is the + * construction that let them: one set, maintained in two places, with nothing + * comparing them. Repairing only the instance leaves the construction, and the + * next divergence is written by the same mechanism and noticed by nobody. So + * the correct shape is made the only spelling available: one exported constant, + * both gates importing it, no second literal to keep honest. + * + * ⭐ Converged UPWARD, to the set that includes `fork`. The other direction — + * dropping `fork` from objectui#8616's set — narrows a live gate's census, and + * weakening a gate is not a repair. + * + * ⚠️ `fork` is LATENT here, not live: the test tree holds zero `fork(` call + * sites today, so nothing is leaking through the hole this closes. That is also + * why `spawned-build-vitest-env-8598.test.ts` carries a fixture-driven case — + * against a live population of 0, adding `fork` to this set changes no observed + * result, and a green suite would carry no information about whether it works. + */ +export const SPAWNERS: ReadonlySet = new Set(['spawnSync', 'spawn', 'execFileSync', 'execFile', 'execSync', 'exec', 'fork']); diff --git a/scripts/__tests__/spawned-build-vitest-env-8598.test.ts b/scripts/__tests__/spawned-build-vitest-env-8598.test.ts index b7aa811b6a..1714dfa1f5 100644 --- a/scripts/__tests__/spawned-build-vitest-env-8598.test.ts +++ b/scripts/__tests__/spawned-build-vitest-env-8598.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { SPAWNERS } from './helpers/spawners'; const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); @@ -62,9 +63,6 @@ const ROOT = path.resolve(fileURLToPath(import.meta.url), '../../..'); * directions at once. Removal is the property, so removal is what is read. */ -/** Node child-process entry points that start a program. */ -const SPAWNERS = new Set(['spawnSync', 'spawn', 'execFileSync', 'execFile', 'execSync', 'exec']); - /** Test files anywhere in the workspace — the only files this gate judges. */ function testFiles(): string[] { const found: string[] = []; @@ -283,6 +281,23 @@ function buildSpawns(file: string): BuildSpawn[] { const SPAWNS = testFiles().flatMap(buildSpawns); +/** + * The gate's verdict on a set of spawns, rendered — one line per spawn that + * does NOT remove `VITEST`. This is the list the assertion below requires to be + * empty, so the fixture case at the bottom of this file can drive the REAL + * judgement rather than a paraphrase of it. + */ +function leakingIn(spawns: readonly BuildSpawn[]): string[] { + return spawns + .filter((s) => s.verdict !== 'scrubbed') + .map( + (s) => + `${s.file}:${s.line} — this environment ${ + s.verdict === 'sets' ? 'SETS `VITEST`' : 'hands the child `VITEST` unchanged' + }`, + ); +} + /** * The floor, plus a named member. * @@ -301,12 +316,7 @@ describe(`objectui#8598 — ${SPAWNS.length} build spawn(s) in the test tree`, ( }); it('every one of them scrubs VITEST from the child environment', () => { - const leaking = SPAWNS.filter((s) => s.verdict !== 'scrubbed').map( - (s) => - `${s.file}:${s.line} — this environment ${ - s.verdict === 'sets' ? 'SETS `VITEST`' : 'hands the child `VITEST` unchanged' - }`, - ); + const leaking = leakingIn(SPAWNS); expect( leaking, @@ -320,3 +330,67 @@ describe(`objectui#8598 — ${SPAWNS.length} build spawn(s) in the test tree`, ( ).toEqual([]); }); }); + +/** + * ⭐ That this gate can see a `fork()` build at all (objectui#9211). + * + * `fork` was missing from this gate's spawner set while its objectui#8616 + * sibling had it, and both sets were hand-maintained copies. objectui#9211 + * replaced the two copies with one — `helpers/spawners.ts` — converging UPWARD, + * onto the set that includes `fork`. + * + * ⚠️ This case is not decoration, it is the only thing that measures the + * change. The LIVE population of `fork(` call sites across `packages`, `apps`, + * `scripts` and `examples` is ZERO — re-measured on this branch's base, with + * `spawnSync(` at 35 in the same run as the control that proves the zero is a + * reading and not a broken command. So against the real tree, a run with `fork` + * in the set and a run without it produce byte-identical results, and the whole + * suite going green says nothing whatsoever about whether `fork` is covered. + * The fixtures below supply the population the tree does not have. + * + * They are a PAIR differing only in the `env:`, so what is demonstrated is the + * gate judging the environment of a `fork` — not merely noticing the call: + * + * - `fork-build-inherits.fixture.ts` hands the child `{ ...process.env }`. + * The gate must report it — RED. + * - `fork-build-scrubbed.fixture.ts` binds `VITEST` away with a rest pattern. + * The gate must clear it — GREEN. + * + * Both legs read the population count as well as the verdict: drop `fork` from + * `SPAWNERS` and neither file resolves to a spawn at all, so the leaking list + * for the first fixture goes EMPTY and this case fails on a count of 0 rather + * than passing vacuously. ⛔ That is the failure mode a verdict-only assertion + * would have: "nothing leaks" and "nothing was looked at" are the same string. + * + * ⚠️ The fixtures carry `.fixture.ts`, which `testFiles()` does not match, so + * the deliberate leak in the first one is invisible to the live census above + * and cannot turn the real gate red. + */ +const FIXTURES = 'scripts/__tests__/fixtures/spawned-build-vitest-env-8598'; + +describe('objectui#9211 — the population includes fork()', () => { + it('a fork() build handed the environment unchanged is SEEN, and is reported', () => { + const spawns = buildSpawns(`${FIXTURES}/fork-build-inherits.fixture.ts`); + + expect( + spawns.length, + 'The fixture holds exactly one `fork(…, [\'build\'], { env })`. A count of 0 means this ' + + 'gate no longer treats `fork` as a spawner — check `SPAWNERS` in `helpers/spawners.ts` ' + + '(objectui#9211). Every assertion below would pass vacuously on an empty population.', + ).toBe(1); + expect(spawns.map((s) => s.verdict)).toEqual(['inherits']); + expect(leakingIn(spawns)).toEqual([ + `${FIXTURES}/fork-build-inherits.fixture.ts:${spawns[0].line} — this environment hands the child \`VITEST\` unchanged`, + ]); + }); + + it('control — the same fork() with VITEST scrubbed is SEEN, and is cleared', () => { + const spawns = buildSpawns(`${FIXTURES}/fork-build-scrubbed.fixture.ts`); + + // Same count assertion, same reason: an empty population would clear this + // fixture too, and for the wrong reason. + expect(spawns.length, 'see the sibling case — a count of 0 measures nothing').toBe(1); + expect(spawns.map((s) => s.verdict)).toEqual(['scrubbed']); + expect(leakingIn(spawns)).toEqual([]); + }); +}); diff --git a/scripts/__tests__/spawned-vitest-child-env-8616.test.ts b/scripts/__tests__/spawned-vitest-child-env-8616.test.ts index 72aac35ee1..fd5c13acf4 100644 --- a/scripts/__tests__/spawned-vitest-child-env-8616.test.ts +++ b/scripts/__tests__/spawned-vitest-child-env-8616.test.ts @@ -6,6 +6,7 @@ import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; import { childVitestEnv } from './helpers/child-vitest-env'; +import { SPAWNERS } from './helpers/spawners'; /** * A vitest a TEST spawns must not inherit this container's AGENT markers @@ -75,9 +76,6 @@ import { childVitestEnv } from './helpers/child-vitest-env'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); -/** Node child-process entry points that start a program. */ -const SPAWNERS = new Set(['spawnSync', 'spawn', 'execFileSync', 'execFile', 'execSync', 'exec', 'fork']); - /** Test files anywhere in the workspace — the only files this gate judges. */ function testFiles(): string[] { const found: string[] = [];