Skip to content

fix(cli): stop os dev handing its compile child a tsx-source-loader env - #11143

Merged
os-elon merged 3 commits into
mainfrom
claude/issue-11020-dev-compile-node-env
Aug 22, 2026
Merged

fix(cli): stop os dev handing its compile child a tsx-source-loader env#11143
os-elon merged 3 commits into
mainfrom
claude/issue-11020-dev-compile-node-env

Conversation

@os-elon

@os-elonos-elon commented Aug 22, 2026

Copy link
Copy Markdown
Collaborator

Fixes#11020

pnpm dev could not boot an example app. os dev auto-compiles when
dist/objectstack.json is absent, by spawning os compile — and that spawn
set a hard-coded NODE_ENV: 'development', which 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
(@objectstack/formula -> ../../packages/formula/src/index.ts). Those
packages are CommonJS, so the redirect lands on a .ts file and Node's CJS
resolver then walks its sibling imports, which it cannot resolve:

Cannot find module './registry'
Require stack:
- packages/formula/src/index.ts
at w._resolveFilename (tsx/dist/register-Ciecs-Zx.cjs)
at packages/formula/src/index.ts:12:67
✗ Compile failed — fix errors above before starting dev server

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

  • Adding extensions to the redirected packages' relative imports. The
    import spelling was never the variable: plugin-email already ships the
    explicit ./email-plugin.js extension and fails identically to formula's
    extensionless ./registry. packages/formula is not defective —
    extensionless relative specifiers are the correct spelling for a CJS
    package under moduleResolution: NodeNext.
  • Removing the paths blocks from examples/app-*/tsconfig.json. They are
    mandated by check:type-source-resolution, which is green. That would trade
    a runtime failure for a silent type failure.

Why no gate caught it

check:type-source-resolution covers the types axis and
check:test-source-alias covers the vitest axis. Both were green for the
entire time pnpm dev was broken. The axis that broke — a CLI child's
runtime 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.json already exists boots fine. Reproducing
it requires deleting that artifact first, which is why it survived unreported.

Declared beyond the card's file surface: environments/bind.ts

The regression pin found a second, previously unknown instance of the identical
defect: os environments bind --build spawned os compile with the same
hard-coded env (packages/cli/src/commands/environments/bind.ts:76, an exact
character-for-character match of the line removed from dev.ts). It is fixed in
the same commit as the pin. Evidence for treating it as in-bounds rather than as
a separate card:

  • Same defect class, same mechanism, same symptom — same argv, same env,
    same cwd class.
  • The correct shape was already settled, not designed here: os start's
    compile spawn (start.ts:233) and os dev's watch-mode recompile spawn
    (dev.ts) both pass process.env unmodified. Three of the four sibling
    compile spawns were already right.
  • A repo-wide scan bounds it at exactly these two. Excluding
    node_modules, dist and test files, NODE_ENV: 'development' (or
    'test') written into a child environment occurred in exactly two places in
    the tree, both fixed here.
  • No competing claim — no open PR touches bind.ts; its last commit is
    unrelated and months old.
  • Leaving it would have meant shipping a red pin, and the only alternative
    was narrowing the guard to fit the defect.

The regression pin

packages/cli/src/commands/child-env-source-loader.pin.test.ts asserts, over
the 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.ts records why a
comment-stripping regex reported one of these files clean while it carried the
write). A write to process.env.NODE_ENV is exempt only in a file that starts
no child process; a value that is not a static string literal counts as a
violation, because it cannot be proven safe.

Three deliberate properties:

  • It grades real code, not nothing.start.ts genuinely writes
    NODE_ENV = 'production' into its child env; the pin asserts the detector
    finds that write and judges it safe. A detector that silently stopped
    finding anything would otherwise pass forever.
  • The activating vocabulary is oclif's, read back out of the installed
    @oclif/core's own isProd() rather than hard-coded here. That function
    returns the negation of
    ['development', 'test'].includes(process.env.NODE_ENV ?? ''), so those two
    values 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.
  • Specimens both ways — the pre-fix spawn options are classified as a
    violation, a non-literal value is too, and a 'production' child env is
    not, so the pin is not merely "any NODE_ENV is red".

Why not spawn a real os compile from a paths-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 test task declares
dependsOn: ["^build"] — dependencies' builds, not the package's own — so
packages/cli/dist, which bin/run.js loads, is not guaranteed to exist when
the 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 which
spawn 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.json deleted first.

apptsconfig paths entriesbeforeafter
app-showcaseformula + plugin-emailexit 1 — both ./registry and ./email-plugin.jsexit 0
app-crmformula onlyexit 1 — ./registry onlyexit 0
app-todononeexit 0exit 0

Failures map 1:1 onto each app's paths entries — app-todo is the control.
Real os dev runs 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. Both
stayed up until killed, with zero Cannot find module lines.

Semantic inertness, re-verified with its control. Compiling app-todo
(which compiles both ways) with and without NODE_ENV=development produces
1466 leaves each, differing in exactly one: /runtimeModule, a bundle content
hash. A control run — two compiles with identical env — differs in the same
single leaf, because build-runtime.ts:102 embeds
builtAt: new Date().toISOString(). compile reads NODE_ENV nowhere.

Reverse verification of the pin, from the committed state. Predicted:
reverting dev.ts alone reds it with exactly one offender; reverting
bind.ts too gives two. Observed exactly that —
dev.ts:195 (object property, value 'development'), then that plus
environments/bind.ts:76 (object property, value 'development'). Restored with
git checkout HEAD --; the working tree came back byte-identical (empty
git status --porcelain) and the pin returned to 4 passed. No rebuild was
needed for either leg: the pin reads the command sources from disk by path, so
dist is not on its resolution path.

Gates

Derived with node scripts/pm/dispatch-gates.mjs (no path arguments — it takes
its 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 unswept
site(s) ... none new"
) · check:test-source-alias ("OK — 72 packages with
tests scanned"
) · check:type-source-resolution ("OK — 77 packages with a
tsconfig.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/78
workspace packages type-checked"
) · check:type-check-debt ("OK — 33 ledger
entr(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 typecheck clean; the package's full suite
157 passed (157) files / 1721 passed (1721) tests. No ratchet baseline, no
ledger and no threshold was touched in either direction.


Generated by Claude Code

…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
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 3 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json acb4dbc09703d5c6145efb376c50ea12dfe9f41c.

3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 23 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 acb4dbc09703d5c6145efb376c50ea12dfe9f41cpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 0af329ad4ea95ad0123ce2003d76d85ee3861133 — the merge of head d5b688ed627d054f47bb75dc5962c526c7d7e57d into base acb4dbc09703d5c6145efb376c50ea12dfe9f41c, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs acb4dbc09703d5c6145efb376c50ea12dfe9f41c → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 22, 2026
@os-elon
os-elon marked this pull request as ready for review August 22, 2026 21:17
@os-elon
os-elon added this pull request to the merge queueAug 22, 2026
Merged via the queue into main with commit 78019bbAug 22, 2026
35 checks passed
@os-elon
os-elon deleted the claude/issue-11020-dev-compile-node-env branch August 22, 2026 21:29
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32599170922 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 4.15s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 99 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] pnpm dev fails to boot an example app — tsx cannot resolve ./registry from packages/formula's extensionless relative imports

2 participants

@os-elon@claude