Uh oh!
There was an error while loading. Please reload this page.
fix(cli): stop os dev handing its compile child a tsx-source-loader env - #11143
Conversation
…loader `os dev` spawned `os compile` with a hard-coded `NODE_ENV: 'development'`. That activates oclif's tsx-based TypeScript source loader, tsx honours the *cwd* tsconfig's `paths`, and example apps map workspace packages to their TypeScript source there. The redirected packages are CJS, so Node's CJS resolver then walks their sibling relative imports and knows nothing about `.ts` — `Cannot find module './registry'`, and dev dies before the server starts, whenever `dist/objectstack.json` is absent. The same hazard was already documented and fixed on the sibling serve spawn 93 lines below; the compile child was missed. Drop the env override and make the note cover every child this command starts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
…_ENV
The guard the fix needs: a source assertion over every command in
packages/cli that no NODE_ENV write reaching a child process carries a value
that activates oclif's tsx TypeScript source loader ('development' or 'test',
read back out of oclif's own isProd()).
Writing it found a second, identical instance the reproduction never reached:
`os environments bind --build` spawned `os compile` with the same hard-coded
env. Fixed in place — same defect class, same mechanical shape already settled
by the sibling compile spawns in start.ts and dev.ts's watch loop, both of
which pass process.env unmodified.
An end-to-end `os compile` spawn was rejected for this pin: turbo's test task
dependsOn ^build only, so packages/cli/dist is not guaranteed to exist when
the suite runs, and the package's existing subprocess tests reach the CLI
through tsx — which starts the child with the loader already active.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r…v fix Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. 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 0af329ad4ea95ad0123ce2003d76d85ee3861133 && git checkout 0af329ad4ea95ad0123ce2003d76d85ee3861133
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin acb4dbc09703d5c6145efb376c50ea12dfe9f41c d5b688ed627d054f47bb75dc5962c526c7d7e57d && git checkout -B drift-repro acb4dbc09703d5c6145efb376c50ea12dfe9f41c && git merge --no-ff d5b688ed627d054f47bb75dc5962c526c7d7e57d
node scripts/docs-audit/affected-docs.mjs --json acb4dbc09703d5c6145efb376c50ea12dfe9f41c
|
Uh oh!
There was an error while loading. Please reload this page.
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 32599170922 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
Fixes#11020
pnpm devcould not boot an example app.os devauto-compiles whendist/objectstack.jsonis absent, by spawningos compile— and that spawnset a hard-coded
NODE_ENV: 'development', which activates oclif's tsx-basedTypeScript source loader. tsx honours the cwd tsconfig's
paths, andexample apps map workspace packages to their TypeScript source there
(
@objectstack/formula->../../packages/formula/src/index.ts). Thosepackages are CommonJS, so the redirect lands on a
.tsfile and Node's CJSresolver then walks its sibling imports, which it cannot resolve:
A type-resolution directive leaking into runtime resolution. The fix drops
the env override, which is the answer this repo had already chosen for the same
hazard on the sibling serve spawn 93 lines below — that child was hardened
and commented; the compile child was not.
Why the two obvious repairs are both wrong
import spelling was never the variable:
plugin-emailalready ships theexplicit
./email-plugin.jsextension and fails identically toformula'sextensionless
./registry.packages/formulais not defective —extensionless relative specifiers are the correct spelling for a CJS
package under
moduleResolution: NodeNext.pathsblocks fromexamples/app-*/tsconfig.json. They aremandated by
check:type-source-resolution, which is green. That would tradea runtime failure for a silent type failure.
Why no gate caught it
check:type-source-resolutioncovers the types axis andcheck:test-source-aliascovers the vitest axis. Both were green for theentire time
pnpm devwas broken. The axis that broke — a CLI child'sruntime module resolution — is covered by neither. The general guard for
that third axis is separate scope, tracked in #11094, and is deliberately not
folded in here; #11094 stays open.
The symptom also masks itself: dev's auto-compile branch is
needsCompile = !flags.artifact && (flags.compile || !existsSync(artifactPath)),so any tree where
dist/objectstack.jsonalready exists boots fine. Reproducingit requires deleting that artifact first, which is why it survived unreported.
Declared beyond the card's file surface:
environments/bind.tsThe regression pin found a second, previously unknown instance of the identical
defect:
os environments bind --buildspawnedos compilewith the samehard-coded env (
packages/cli/src/commands/environments/bind.ts:76, an exactcharacter-for-character match of the line removed from
dev.ts). It is fixed inthe same commit as the pin. Evidence for treating it as in-bounds rather than as
a separate card:
same cwd class.
os start'scompile spawn (
start.ts:233) andos dev's watch-mode recompile spawn(
dev.ts) both passprocess.envunmodified. Three of the four siblingcompile spawns were already right.
node_modules,distand test files,NODE_ENV: 'development'(or'test') written into a child environment occurred in exactly two places inthe tree, both fixed here.
bind.ts; its last commit isunrelated and months old.
was narrowing the guard to fit the defect.
The regression pin
packages/cli/src/commands/child-env-source-loader.pin.test.tsasserts, overthe command sources themselves, that no CLI command hands a child process an
environment that activates oclif's TypeScript source loader. It scans every
non-test command source recursively, off the TypeScript AST rather than by text
scan (the sibling
artifact-child-env.pin.test.tsrecords why acomment-stripping regex reported one of these files clean while it carried the
write). A write to
process.env.NODE_ENVis exempt only in a file that startsno child process; a value that is not a static string literal counts as a
violation, because it cannot be proven safe.
Three deliberate properties:
start.tsgenuinely writesNODE_ENV = 'production'into its child env; the pin asserts the detectorfinds that write and judges it safe. A detector that silently stopped
finding anything would otherwise pass forever.
@oclif/core's ownisProd()rather than hard-coded here. That functionreturns the negation of
['development', 'test'].includes(process.env.NODE_ENV ?? ''), so those twovalues are exactly the ones that make it false and let the loader register.
If a future version changes the set, the pin reds and the next author
re-derives it.
violation, a non-literal value is too, and a
'production'child env isnot, so the pin is not merely "any
NODE_ENVis red".Why not spawn a real
os compilefrom apaths-carrying example-app cwd —the end-to-end form was considered first and rejected on two measured grounds.
(1) It could not be made to fail: turbo's
testtask declaresdependsOn: ["^build"]— dependencies' builds, not the package's own — sopackages/cli/dist, whichbin/run.jsloads, is not guaranteed to exist whenthe suite runs; a spawn-based pin would have to skip on an unbuilt tree, i.e.
be green exactly when it cannot look. The package's existing subprocess tests
sidestep that by reaching the CLI through tsx, which starts the child with
the loader already active and therefore cannot distinguish the two states.
(2)
packages/cli/vitest.config.ts's header records that the 20 files whichspawn the real CLI are 56.1% of this package's file wall (300.1s) for 177 of
1498 tests. So the property is asserted where it is decidable and cheap, and
the behaviour it stands for was measured by hand (below).
Measured
Reproduction and verification were done on this branch's own head, with the
example apps'
dist/objectstack.jsondeleted first.pathsentriesapp-showcase./registryand./email-plugin.jsapp-crm./registryonlyapp-todoFailures map 1:1 onto each app's
pathsentries —app-todois the control.Real
os devruns after the fix: app-crm reaches✓ Build complete (434ms),✓ Server is ready,Plugins: 31 loaded; app-showcase reaches✓ Build complete (1085ms),✓ Server is ready,Plugins: 47 loaded. Bothstayed up until killed, with zero
Cannot find modulelines.Semantic inertness, re-verified with its control. Compiling
app-todo(which compiles both ways) with and without
NODE_ENV=developmentproduces1466 leaves each, differing in exactly one:
/runtimeModule, a bundle contenthash. A control run — two compiles with identical env — differs in the same
single leaf, because
build-runtime.ts:102embedsbuiltAt: new Date().toISOString().compilereadsNODE_ENVnowhere.Reverse verification of the pin, from the committed state. Predicted:
reverting
dev.tsalone reds it with exactly one offender; revertingbind.tstoo gives two. Observed exactly that —dev.ts:195 (object property, value 'development'), then that plusenvironments/bind.ts:76 (object property, value 'development'). Restored withgit checkout HEAD --; the working tree came back byte-identical (emptygit status --porcelain) and the pin returned to 4 passed. No rebuild wasneeded for either leg: the pin reads the command sources from disk by path, so
distis not on its resolution path.Gates
Derived with
node scripts/pm/dispatch-gates.mjs(no path arguments — it takesits own change set from the merge base), then re-derived on the final head with
an identical family list. All run locally, green, quoting each gate's own
verdict line:
check:changeset-gate-self-tests·check:cross-package-test-inputs("OK: 13 package(s) read outside themselves, all declared") ·
check:objectui-changeset·check:slot-lookup("ratchet holds: 107 unsweptsite(s) ... none new") ·
check:test-source-alias("OK — 72 packages withtests scanned") ·
check:type-source-resolution("OK — 77 packages with atsconfig.json scanned") ·
check-adr-0087-registration·check-changeset-no-major·check-ci-filter-parity·check-empty-changeset·
check-plugin-teardown-shape·check-affected-docs·check:query-options-erasure·check:type-check-coverage("OK — 65/78workspace packages type-checked") ·
check:type-check-debt("OK — 33 ledgerentr(ies) re-measured in 223.4s, 1895 raw tsc error(s) total, none above its
recorded number" — run against a fully built package closure, not skipped) ·
check:engine-double-contract·check:where-matcher·check:nul-bytes.pnpm --filter @objectstack/cli typecheckclean; the package's full suite157 passed (157)files /1721 passed (1721)tests. No ratchet baseline, noledger and no threshold was touched in either direction.
Generated by Claude Code