Skip to content

finding: the os dev compile child still inherits an ambient NODE_ENV=development — the source-loader pin asserts "no write", which cannot see inheritance #12271

Description

@yinlianghui

Filed unassigned by the domain:devx seat while measuring #11094 (session session_01UjM2ia8Av1v5NqfqQEQmC6). Recording only — not graded here, and deliberately not fixed under #11094, whose dispatch rules packages/cli/src/commands/dev.ts out of scope as another lane's surface.

⛔ This does not re-open #11020. That card's fix is present and correct on main; this is an observation about the shape of the guard around it, not a claim that the fix is wrong.

The mechanism

PR #11143 removed the explicit NODE_ENV: 'development' write from dev.ts's compile spawn. The spawn that replaced it is:

const compileResult = spawnSync(
  process.execPath,
  [binPath, 'compile', '--output', artifactPath],
  { stdio: 'inherit' },
);

No env option, so the child inherits process.env whole. The fix removed the write; it did not scrub the variable. So the child is under oclif's tsx source loader whenever the parent already is — for example under tsx packages/cli/bin/run-dev.js dev, since bin/run-dev.js:28 does process.env.NODE_ENV = 'development' (deliberately — it inlines oclif's development: true), or for any developer with NODE_ENV=development exported.

Why the existing pin cannot see it

packages/cli/src/commands/child-env-source-loader.pin.test.ts is green (4 tests) and its property is exactly right for what it asserts: no command source writes a loader-activating NODE_ENV into a child environment. Two structural consequences:

  1. It reads writes off the TypeScript AST. Inheritance is the absence of a write, so it is invisible to the detector by construction — not a bug in the pin, a boundary of its property.
  2. Its walk is COMMANDS_DIR (packages/cli/src/commands/, .ts only, recursive). packages/cli/bin/run-dev.js — the one file in the repo that still deliberately arms the loader — is outside it on both counts (different directory, .js).

Note the pin's own exemption rule would exempt run-dev.js anyway even if it were in the walk: a process.env write in a file that starts no child process is treated as a command setting its own mode. That is correct in isolation; it just means nothing connects "this process arms the loader" to "this process later spawns children that inherit it".

Measured, on origin/main @ 2c4c59ed20

The hazard is currently latent, not live — the pair can be fully armed and does not detonate:

cwd sibling-src paths entries NODE_ENV=development … run-dev.js compile
examples/app-showcase 2 exit 0
examples/app-crm 1 exit 0
examples/app-todo 0 exit 0

The redirect itself still fires — simulating oclif's exact registration (require('tsx/cjs/api').register(), per @oclif/core@4.13.3/lib/config/ts-path.js) from an app-showcase cwd resolves @objectstack/formula to /packages/formula/src/index.ts and requires it successfully. tsx's CJS hook resolves the extensionless sibling ./registry that the original report recorded as failing.

⚠️ But it is conditioned on build state. Before the dependency closure was built, the same redirect failed through the same path:

Error: Cannot find module '.../packages/formula/node_modules/@objectstack/spec/dist/index.js'
    at nextResolveSimple (.../tsx@4.23.12/dist/register-C557imBs.cjs:10:1006)

So redirecting a package to its source makes runtime resolution depend on that package's own dependency dist/s existing — which a static gate reading tsconfigs cannot observe, and which varies with what the developer has built.

Why it is worth recording rather than fixing now

Possible shapes (not chosen here)

  • A — pin that no spawn of bin/run-dev.js uses a cwd inside a package whose tsconfig carries a sibling-src paths entry. Measures the real composition; needs no resolver simulation.
  • B — scrub NODE_ENV explicitly on the compile spawn (env: { ...process.env, NODE_ENV: undefined }) so the property holds regardless of the ambient environment, and widen the pin to assert the scrub rather than the absence of a write.
  • C — leave it recorded. The comment block at dev.ts:192 already explains the hazard at length; this issue is the note that the enforcement is narrower than the comment.

⚠️ #8020 / #8108 are the standing precedent against anything that simulates resolution: both were defects in a gate that read a correct config as wrong by failing to parse one spelling of it. A and B avoid that class; a general paths-resolution gate would inherit it.

Searched before filing

os dev compile child inherits ambient NODE_ENV development oclif source loader → 3 hits, none duplicate: #11773 (open — childEnv() forwarding NODE_PATH, a different variable and consequence), #11317 (closed — e2e tests spawning bin/run.js reaching src/ instead of dist/ under the same loader), #5673 (closed — unrelated /discovery broadcast). ⚠️ Channel note: this search used the MCP search_issues endpoint rather than the REST list endpoint, because direct REST from this session returns "GitHub access is not enabled for this session".

Related: #11094 (the card this was measured under) · #11020 / PR #11143 (the fix) · #8249 (the growing paths population) · #8180 · #7849.


Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions