Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/9211-spawners-single-source.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 } });
}
Original file line number Diff line number Diff line change
@@ -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 });
}
44 changes: 44 additions & 0 deletions scripts/__tests__/helpers/spawners.ts
Original file line number Diff line number Diff line change
@@ -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<string> = new Set(['spawnSync', 'spawn', 'execFileSync', 'execFile', 'execSync', 'exec', 'fork']);
92 changes: 83 additions & 9 deletions scripts/__tests__/spawned-build-vitest-env-8598.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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), '../../..');

Expand Down Expand Up @@ -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[] = [];
Expand Down Expand Up @@ -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.
*
Expand All @@ -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,
Expand All @@ -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([]);
});
});
4 changes: 1 addition & 3 deletions scripts/__tests__/spawned-vitest-child-env-8616.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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[] = [];
Expand Down
Loading