Skip to content

test(cli): route every env-less spawner through childEnv(), and make the gate refuse an undeclared child env - #12530

Merged
os-litant merged 2 commits into
mainfrom
claude/issue-11595-envless-spawners
Aug 26, 2026
Merged

test(cli): route every env-less spawner through childEnv(), and make the gate refuse an undeclared child env#12530
os-litant merged 2 commits into
mainfrom
claude/issue-11595-envless-spawners

Conversation

@os-litant

@os-litantos-litant commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11595

Two halves, repairs first, in one PR — as the claim ruling on the card directs.

  1. The repairs. Eight spawn call sites under packages/cli/test/** passed no env option at all, so each child got process.env verbatim — under vitest that is the worker's: TEST=true, VITEST=true, the VITEST_* family. Each now passes env: childEnv().
  2. The gate.scripts/check-cli-test-child-env.mjs gains a second, call-anchored rule: a spawn call in the population must declare an env. No baseline and no carve-out — the eight predating sites were repaired in the commit before the one that lands the rule, precisely so no baseline had to exist.

packages/cli/test/helpers/serve-process.ts is not touched (hard serial, held by #12441). The repaired files import childEnv from it; four gained the import, two already had it.

The population, re-derived — not taken from the card

The card's eight were measured at 2dc0a770b. Re-derived AST-first against origin/main @ e57744536, by an independent walk (resolve every node:child_process / node:worker_threads binding, including aliases, namespace imports and promisify() wrappers; then classify every call's options object):

files=95 spawners=35 spawn calls=40
NO-ENV 8 · NO-OPTIONS 0 · SPREAD-NO-ENV 0 · OPAQUE 0 · HAS-ENV 32

Same eight files, no ninth site, none vanished. Two carry line drift from the rewrites the card was warned about (cloud-login-json-ndjson.e2e.test.ts 310 → 311, login-json-ndjson.e2e.test.ts 298 → 299); the other six are unmoved. The extended gate independently reports the same census (35 spawner source(s) among 95 … all 40 spawn call(s) declare their child's env), derived by different code from the same tree.

The ruling's falsifiable claim, tested

applying childEnv() to each of the eight changes no test's behaviour.

It holds. All six affected files, run together:

Test Files 6 passed (6)
Tests 61 passed (61)
Duration 54.85s

Nothing needed anything childEnv() strips — which is the interesting half, because that strip family moved today: #12294 added NODE_PATH to it. Three of the eight spawn node bin/run-dev.js from a temp cwd, and those resolve @oclif/core and the CLI's own sources through the ordinary node_modules walk from the entry file, never through the hoisted store. So there is no finding about childEnv()'s strip family to report.

Overrides were deliberately kept empty (childEnv(), not childEnv({ NO_COLOR: '1' })): three of these sites assert on captured CLI output, and adding an override would have made the change a behaviour change instead of a legibility one.

Proving the extended gate can say no

The card's whole subject is a gate that is silent on a shape, so a second silent gate would be the same defect with a new number. Two ablation legs, both with a restore trap and both restored by blob hash, never by an exit code:

Leg A — the pre-repair tree. The six files reverted to e57744536 (mutation confirmed on disk: env: childEnv() occurrences 0). The gate exits 1 and names all eight:

❌ check:cli-test-child-env -- 8 spawn call(s) leave the child's environment UNDECLARED:
packages/cli/test/authoring-rule-command-parity.test.ts:192 [(top-level)] execFileSync …
packages/cli/test/cloud-login-json-ndjson.e2e.test.ts:311 [(top-level)] execFileSync …
packages/cli/test/emit-json-pipe.test.ts:62 [runPiped] execFile …
packages/cli/test/emit-json-pipe.test.ts:144 [(top-level)] spawn …
packages/cli/test/emit-json-pipe.test.ts:194 [(top-level)] execFile …
packages/cli/test/format-zod-union.test.ts:176 [runCli] execFileSync …
packages/cli/test/login-json-ndjson.e2e.test.ts:299 [(top-level)] execFileSync …
packages/cli/test/metadata-type-schema-gate.test.ts:185 [runCli] execFileSync …

Leg B — a brand-new env-less spawn planted in a file that had none (blob 46eaefc87cbdf82a, so the mutation is proven on disk rather than assumed). Gate exits 1, naming serve-stdio-stdout-purity.e2e.test.ts:430.

Restore, proven: every one of the seven files back to its HEAD blob hash — git hash-object on the working file compared against git rev-parse of the same path at HEAD, non-empty on both sides — plus git diff HEAD --stat empty, planted text grep -c back to 0, and the gate green again on the restored tree.

The durable version of both legs is in the self-test, out of process, where an exit status can actually be observed: OUT OF PROCESS: a spawn with NO env option exits NON-ZERO and names the site.

What the new rule refuses, and what it deliberately does not

The rule asks only that env be declared — it does not name childEnv(). That keeps it negative, like rule 1, so it does not go stale when the choke point is renamed and does not force a call that genuinely needs something else through a helper. What may not be declared is the whole of process.env; that is rule 1. The pair reaches the same place from two sides, and the self-test pins that they red independently of each other.

Four refused shapes, each with its own reason string and its own case:

shapeverdict
spawn(cmd, args, { cwd })no env key
spawn(cmd, args)no options object at all
spawn(cmd, args, { env: undefined })Node omits undefined entries — the omission wearing the repair's clothes. Zero instances today; flagged so the class closes for the next spelling
spawn(cmd, args, opts) / { ...opts }an options object the scan cannot read — a finding, never a quiet pass

The options object is located as the last object literal among the arguments rather than by position (every roster API takes a string first, an argv array second, a callback last), so the roster can grow without a positional table rotting behind it. promisify() wrappers resolve one level, which is every spelling in the population; that limit is named in the header rather than left to be discovered.

The header's own FALSE NEGATIVE roster is updated: item 1 was this gap, and it is now closed rather than merely re-worded.

Verification — all at 4109b81f7, the final commit, on a clean tree

Run through the shared heavy-verify lock (TREE_AT=4109b81f7 dirty=[]):

checkresult
pnpm --filter @objectstack/cli exec vitest run (the 6 affected files)Test Files 6 passed (6) · Tests 61 passed (61)
pnpm --filter @objectstack/cli typechecktsc --noEmit, exit 0 — ⚠️ see the caveat below
pnpm lint (eslint . --no-inline-config, whole repo, not narrowed)exit 0
pnpm check:cli-test-child-envself-test 78 cases pass; live tree green
pnpm check:type-check-coverage · check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:cross-package-test-inputs · check:test-source-alias · check:parse-guard · check:entry-guard · check:agent-test-spelling · check:bash32-floor · check:pnpm-filter-targets · check:pm-dispatch-gates · scripts/pm/bare-root-worklist.mjs --self-test · check:comment-mask-adoption · check:ci-filter-parity · check:nul-bytesall exit 0

Gate list derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack against the real change set (7 paths), not from a hand-written list.

⚠️pnpm --filter @objectstack/cli typecheck says nothing about the six edited files, and is reported here as NOT MEASURED rather than as a green.packages/cli/tsconfig.json is include: ["src"] with no exclude at all, so the sibling test/ tree is read by nothing — that is precisely the TEST_DEBT['@objectstack/cli'] entry (65 hidden files, 146 errors). The gate that does read it, check:type-check-debt --re-measure, rebuilds the entire workspace and runs the whole ledger; that run is CI's.

Declared narrowing, in its place: a tsc --noEmit program over exactly the six edited files, run on this tree and on the pre-repair tree, with --listFiles proving membership rather than assuming it:

program size 414 file(s); the six edited files present in the program: 6/6
total errors: 1 (before) total errors: 1 (after)
per-file tsc error counts over the six edited files: IDENTICAL before and after

The single error is pre-existing and untouched by this change: format-zod-union.test.ts(38,33) TS2835'../src/utils/format' without the .js extension, one of the 57 extension-less relative imports the ledger's own note already inventories. Repairing it would lower a shrink-only ledger, which that note says is a deliberate PR of its own; it is not folded in here.

No changeset

Test files plus one repo gate script. Nothing published changes, so this PR releases nothing — skip-changeset.

⚠️ That label was applied through the whole-set write, declared here because the additive endpoint is not reachable from this seat (POST /issues/12530/labels answered "GitHub access is not enabled for this session"). The set written was the read-back union of what the PR already carried — size/l, tests — plus skip-changeset; nothing was dropped.

And per the card's last ruling: all eight were latent, not live. Every one spawned tsx on a probe or a non-serve command, so nothing in those children read TEST or VITEST. This closes a structural gap; it does not repair a measured wrong answer, and nothing here should be read as claiming it does.


Generated by Claude Code

…childEnv()
Eight spawn call sites passed no `env` option at all, so each child inherited
the vitest worker's environment verbatim. Latent rather than live -- every one
spawns tsx on a probe or a non-`serve` command -- but the convention's product
is that a child's environment is legible at the call site, not that a particular
variable happens to be stripped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
… no env
Rule 2, call-anchored: a spawn call under packages/cli/test/** must pass an
`env` option. An omitted one hands the child process.env verbatim -- the same
leak rule 1 refuses, in a purer form. No baseline and no carve-out: the eight
sites that predated the rule were repaired in the commit before this one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 14b114553c3e7c61672e3d25a1d15036f7561e27packageMentionDocs.

@os-litantos-litant added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 26, 2026 — with Claude
@os-litant
os-litant marked this pull request as ready for review August 26, 2026 08:58
@os-litant
os-litant added this pull request to the merge queueAug 26, 2026
Merged via the queue into main with commit 5d4d93aAug 26, 2026
39 checks passed
@os-litant
os-litant deleted the claude/issue-11595-envless-spawners branch August 26, 2026 09:20
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/lskip-changesetPR has no user-facing published change; bypasses the changeset gatetests

Projects

None yet

2 participants

@os-litant@claude