Uh oh!
There was an error while loading. Please reload this page.
test(cli): strip NODE_PATH in childEnv() so a spawned CJS resolution measures its real base - #12294
Conversation
…measures its real base
A vitest worker runs with NODE_PATH pointing at pnpm's hoisted store, which
holds everything transitively reachable anywhere in the workspace. childEnv()
stripped only the vitest worker family, so NODE_PATH rode into every spawned
child. CJS createRequire() honours it (ESM import() does not), and it is a
FALLBACK rather than an override -- so the store can only turn a MISS into a
HIT. That makes an ACCEPTANCE claim ("this base CAN reach X") green because the
store supplied X, not because the base did: spawning a real Node child escapes
Vite's rewrite but not this, so a spawned resolution pin routed through CJS was
as vacuous as the in-process one it replaced.
childEnv() now strips NODE_PATH too, under its own named family
(RESOLUTION_BASE_ENV_KEYS) rather than folded into VITEST_WORKER_ENV_KEYS:
NODE_PATH is not something vitest sets, every pnpm bin shim exports one, and a
real serve/dev child in production carries it. Overrides still apply after the
strip, so a test reproducing the shim shape asks for it explicitly -- which the
#4719 pin already does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HbG3rGVLjZStHQxHDtzJdJ📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f2d95b6654c7c9170973898d5ced5c2220e13232 && git checkout f2d95b6654c7c9170973898d5ced5c2220e13232
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0acadda3dd414288514a20cba128ab5d9d381bd2 52a8b9fd1438f43d8f79be2bae7ff19117021001 && git checkout -B drift-repro 0acadda3dd414288514a20cba128ab5d9d381bd2 && git merge --no-ff 52a8b9fd1438f43d8f79be2bae7ff19117021001
node scripts/docs-audit/affected-docs.mjs --json 0acadda3dd414288514a20cba128ab5d9d381bd2 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#11773
The defect
A vitest worker runs with
NODE_PATHpointing at pnpm's hoisted store(
node_modules/.pnpm/node_modules), which holds everything transitivelyreachable anywhere in the workspace.
childEnv()stripped exactly the vitestworker family (
TEST,VITEST,VITEST_*) and forwarded everything else, soNODE_PATHrode into every spawned child.CJS
createRequire().resolve()honoursNODE_PATH; ESMimport()/import.meta.resolveignore it. And it is a fallback, not an override — thenode_moduleswalk wins whenever it hits — so the store can only turn a MISSinto a HIT. The dangerous direction is therefore an ACCEPTANCE claim ("this base
CAN reach X"): green because the store supplied X, not because the base did.
Spawning a real Node child is this directory's remedy for the resolution-base
collapse an in-process test cannot measure at all (#11412). It escapes Vite's
rewrite — but not this. So a spawned resolution pin routed through CJS was as
vacuous as the in-process one it replaced.
The fix
childEnv()now stripsNODE_PATHas well.VITEST_WORKER_ENV_KEYS, and that is deliberate. Thedispatch framed this as "one more entry in the vitest-family strip"; measuring
it says otherwise, and the report records the divergence. That list is what
vitest sets on its own worker and
serve-process-child-env.e2e.test.tsassertsover it by name;
NODE_PATHis not vitest's — every pnpm bin shim exports one,so a real
serve/devchild in production carries it (that is #4719's wholehistory). Folding it in would have made both the constant's name and that pin's
meaning silently wrong. It gets its own named family instead,
RESOLUTION_BASE_ENV_KEYS, with the opposite shape: exact names only, noprefix class, because
NODE_PATHis a variable real children legitimatelycarry and widening it by namespace would strip things nobody measured.
The strip is a default, not a prohibition.
overridesare still appliedafter it, so a test that reproduces the pnpm bin shim on purpose asks for it
explicitly — which the #4719 pin in
serve-organizations-host-resolution.e2e.test.tsalready does(
env: { …, NODE_PATH: hoistedStore }). What changes is that the fidelity isnow declared by the test that wants it rather than inherited by every child.
The pin — and why it is not another vacuous one
The defect being fixed was itself a vacuous pin, so a new pin asserting
NODE_PATHis merely absent from the child environment would repeat thatfailure one level up: it would pass against a child that resolves nothing at
all. Every assertion in the new block is the outcome of a real resolution in
a real spawned Node child anchored at
packages/types:resolve.paths()[0]ispackages/types/node_modulesesmReferrercontrol proves this for ESM only — two different resolvers, neither one's anchor is evidence for the other's)@objectstack/spec, the base's own declared depchalk(declared bypackages/cli, not bypackages/types)withStore— the same child withNODE_PATHput back explicitly — is retainedprecisely so the NEGATIVE stays a measurement: it is the same probe answering
the other way, and its HIT names the
.pnpmstore path it came from, so theacceptance is provably the fallback supplying
chalkrather than the basereaching it.
Ablation — the instrument produces a positive before its negative is trusted
Mutation: drop
RESOLUTION_BASE_ENV_SET.has(key)fromchildEnv()'s loop, i.e.restore the defect. Confirmed on disk with anchored greps in both directions
(
removed-text-count 0/injected-text-count 1), restored undertrap … EXIT INT TERM, and the restore leg re-confirmed both ways plusgit status --porcelainempty.imports the mutated helper by relative path inside the same package
(
import { childEnv } from './helpers/serve-process.js', line 102). Nothingresolves through a dependency's
exportsto adist/, so there is no staleartifact that could keep a mutated tree green.
The three that went red, with what they actually saw:
#11773 … NEGATIVE: cannot reach a package its base does not declare→RESOLVED:…/node_modules/.pnpm/chalk@6.0.0/node_modules/chalk/source/index.js.The store supplied
chalk. This is the card's defect, reproduced.M2 … CJS honours NODE_PATH→ same.pnpmpath.M2 … the worker HAS a NODE_PATH, and childEnv() no longer hands it over→received the worker's real value, three entries ending in
node_modules/.pnpm/node_modules— the hoisted store, exactly as filed.#11773 ANCHORand#11773 POSITIVEwere green in both states. They arenamed here as regression guards / instrument-validity controls, never as
red-before evidence — being green in both states is their design.
Fallout measurement
This was the load-bearing half of the dispatch: a test legitimately relying on
inherited
NODE_PATHwould surface immediately.Result: nothing relies on it.
packages/cli— 180 files, 2048 tests, 2048passed, exit 0.
sweep
serve-node-env-production-default.e2e.test.tsfailed withError: command serve not found. That is not fallout:packages/cli/distdid not exist in the worktree (building
--filter '@objectstack/cli^...'buildsthe dependency closure, not
packages/cliitself), and that file spawns theshipped
bin/run.js, which its own header says needs "a genuinely builtdist/". Afterpnpm --filter @objectstack/cli build, the wholebin/run.jsclass — all 8 files, 45 tests — passes, and so does the full suite.Notably green throughout:
serve-organizations-host-resolution.e2e.test.ts, the#4719 pin that does want the pnpm shim shape. It passes
NODE_PATHexplicitly,so the strip cannot reach it — which is the design, not luck.
Verification
Gate family re-derived with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstackagainst the actual change set, at52a8b9fd— the final commit, which is also the tree every run below wasmeasured on.
check:cli-test-child-env— exit 0, its own line: "✓ check:cli-test-child-env: 33 spawner source(s) among 92 underpackages/cli/test/**; no new bulk process.env copy reaches a spawned child (0 baselined in 0 file(s), ⛔ SHRINK-ONLY; 2 deliberate site(s) still pinned)."check:test-source-alias— exit 0: "check-test-source-alias OK — 72 packages with tests scanned; 61 registered as still resolving a workspace dep throughdist/; 45 published subpath(s) resolved through every alias table."check:published-files,check:slot-lookup,check:type-source-resolution,check:type-check-coverage,check:engine-double-contract,check:cross-package-test-inputs,check:where-matcher,check:query-options-erasure,check:i18n,check-plugin-teardown-shape.mjs,check-nul-bytes.mjspnpm lint— full repo, not narrowed — exit 0pnpm --filter @objectstack/cli exec tsc --noEmit— exit 0⛔ Not measured, declared rather than counted:
check:i18n-coverageREFUSED — its own words: "COULD NOT MEASURE — 1 of12 config(s) failed to lint", because
@objectstack/connector-mcphas nobuild output in this worktree; it adds "this result says NOTHING about whether
any declared label went untranslated". A refusal, not a pass and not a
failure. It needs a full-workspace build, and this diff touches no i18n
surface.
check:type-check-debt --re-measure— not run locally; it requires the fullworkspace build for the same reason. The half that this diff could move —
whether the edited test files typecheck — is the green
tsc --noEmitabove.Why no changeset
skip-changeset.packages/clipublishes["dist", "README.md", "CHANGELOG.md"];this diff is
packages/cli/test/**plus one comment in ascripts/gate header.Nothing published changes.
Footprint note
Beyond
childEnv()and the pin, this touches two things, both consequences ofthe strip rather than drive-by edits:
vitest-resolution-base-collapse.e2e.test.ts— its M2 block asserted theold behaviour (
childEnv() hands NODE_PATH to the child), so the stripnecessarily moves it. Its
inheritedleg becamewithStore, built with anexplicit override, which keeps the platform measurement alive.
scripts/check-test-source-alias.mjsheader — it instructed authors that"
childEnv()forwards it … a spawned pin whose claim routes through CJS muststrip it (
childEnv({ NODE_PATH: undefined }))". This PR is what makes thatsentence false, so correcting it is part of the change, not a rider. No gate
logic touched. ⛔
scripts/check-cli-test-child-env.mjsdeliberately untouched— that is finding: 8
packages/cli/testspawners pass noenvat all, so the child inherits the vitest worker environment verbatim — the purer form of #11341's leak, and the new gate is silent on it #11595's, and adomain:devxsurface.#11595 remains queued behind this and is not addressed here.