Skip to content

fix(cli): the published entry resolves its commands from dist/, whatever an ambient NODE_ENV says - #17927

Merged
claude[bot] merged 2 commits into
mainfrom
claude/issue-12271-compile-child-env-scrub
Sep 13, 2026
Merged

fix(cli): the published entry resolves its commands from dist/, whatever an ambient NODE_ENV says#17927
claude[bot] merged 2 commits into
mainfrom
claude/issue-12271-compile-child-env-scrub

Conversation

@claude

@claude claude Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

Fixes #12271

Clause-②: no

Dispatched by the domain:cli execution seat (#6024) under triage's re-grade 5650901283 (p3 → p2, TaskBug, file face unlocked to start.ts). Branch base 5741ff10c, the parked worktree merged forward and fully rebuilt before any reading was taken — freshness proven by content, not mtime: a symbol introduced by merged commit eadcde6d8 (KNOWN_UNSUPPORTED_JSON_SCHEMA_PATTERNS) occurs twice in packages/cli/dist/commands/generate.js and twice in its source.


1. What was actually broken — and it is not what the card's title says

The card is titled around the os dev compile child inheriting an ambient NODE_ENV=development. That inheritance is real, but it is a symptom. Reading the failure output rather than the card:

[MODULE_NOT_FOUND] Warning: ModuleLoadError
task: findCommand (doctor)
plugin: @objectstack/cli
message: [MODULE_NOT_FOUND] import() failed to load
         …/packages/cli/src/commands/doctor.ts: Cannot find module './registry'
Require stack:
- …/packages/formula/src/index.ts

⭐ The module that failed to load is packages/cli/src/commands/doctor.ts — one of the CLI's own command modules. The casualty is the command table, not the user's config.

@oclif/core@4.13.3's lib/config/ts-path.js skips its TypeScript path lookup only when isProd(), defined in lib/util/util.js as a negated ['development', 'test'].includes(process.env.NODE_ENV ?? ''). Under either value it (a) rewrites the command target from the declared ./dist/commands to src/commands and (b) calls registerTsx(). tsx honours the tsconfig of the current working directory, so an application that maps a CommonJS workspace package to its TypeScript source for type resolution steers this CLI's runtime module graph into .ts files — and Node's CJS resolver then walks their extensionless siblings and knows nothing about .ts.

Three consequences the card did not have:

  • It is not specific to compile, or to any command. os --version reproduces it.
  • It reaches invocations with no parent at allos serve --dev, os start — so ⛔ no child-environment scrub can fix it. This is the reason triage measured shape B as not fixing the card.
  • NODE_ENV=test is the second value oclif treats as non-production, and it had never been measured on this card. vitest exports it on its own worker.

2. The spawn census (acceptance item 3) — measured here, with controls

⛔ Not taken from the card, the previous dev's report, or triage's comment. Read through TypeScript's own parser (ts.createSourceFile), resolving child_process bindings through named / aliased / namespace / default imports, require() destructuring and await import() destructuring, then matching call expressions against those bindings. Script: census.mjs, run at 149135743.

Controls, both required to pass before the census prints:

control expectation result
positive fixture — 7 spawn calls across every import spelling, plus one look-alike identifier that must NOT match 7 7 — PASS (spawn, spawnSync, execFileSync, exec, execFile, execSync, fork)
negative fixture — a local binding named spawn, and the word inside a string literal 0 0 — PASS

Census — 6 child-process spawn sites, not 5:

site API env handed to the child
dev.ts:349 spawnSync ⛔ no env property — inherits process.env whole
dev.ts:586 spawn env: localEnv (a spread of process.env)
dev.ts:724 execSync no env property — inherits process.env whole
dev.ts:821 spawnSync env: process.env
start.ts:238 spawnSync env: process.env
start.ts:435 spawn env: localEnv

Dark-instrument control: the API names occur as bare words 13 times in dev.ts and 6 times in start.ts; the site counts are 4 and 2, and the gap is imports and prose.

⚠️ dev.ts:724 is the disagreement, and it is a real site all four previous numbers missed. The card, the previous dev's report, triage's own count and the seat's re-count all say 5. They agree because they all counted spawn / spawnSync. dev.ts:724 is an execSync reached through const { execSync } = await import('child_process') — a dynamic import inside a function body, invisible to a scan looking for the two static names. It is the workspace-root branch of os dev, which delegates to pnpm [--filter X] dev.

It is deliberately not touched, and that is a classification rather than an omission: it spawns a user's own workspace script, not the os CLI. NODE_ENV=development is meaningful and expected there, and scrubbing it would change the behaviour of arbitrary user dev scripts. It is in the census because the census was asked for; it is outside the defect class because the defect class is this CLI resolving its own commands.

3. Which sites were scrubbed, and why that set is empty

None. The fix is one declaration in packages/cli/bin/run.js:

settings.enableAutoTranspile = false;

oclif checks settings.enableAutoTranspile ?? settings.tsnodeEnabled ahead of isProd(), so false skips both the source redirect and the tsx registration under every value of NODE_ENV.

The argument for the entry over the six sites, point by point:

  • A child-env scrub cannot satisfy acceptance item 1. os serve --dev and os start are top-level processes. There is no parent.
  • It is the contract this repo already wrote down. bin/run.js is the BUILT entry (bin.objectstack / bin.os; package.json declares its command table over ./dist/commands); bin/run-dev.js is the SOURCE entry. scripts/check-cli-test-child-env.mjs rule 3 enforces exactly that division on every test that spawns the CLI, with no baseline. The entry had simply never asserted it about itself.
  • Every spawn site hands the child process.argv[1], i.e. this same entry — so fixing the entry fixes all of them, plus every future one, with no per-site convention to keep true.
  • A per-site scrub changes what the child DOES. NODE_ENV is read by product code (start.ts's production default, the crypto posture, plugin-auth's origin gate). Rewriting it at six spawns to work around a module-resolution bug is the lenient-consumer shape Prime Directive Add comprehensive test suite for Zod schema validation #12 refuses. The entry-point declaration changes only which of two already-declared code paths oclif loads.
  • Not a TSX_TSCONFIG_PATH pin either (what bin/run-dev.js carries). That shim genuinely executes TypeScript, so all it can do is aim the transpiler; it cannot even do that in-process and pays a full re-exec. This entry executes no TypeScript, and a published install has no packages/cli/tsconfig.json to aim at — files names dist only.

dev.ts and start.ts carry comment-only changes, both required by Prime Directive #10 because this change moved what they claim. See §6.

4. Two-leg ablation with the production control (acceptance items 1 and 2)

Run from the committed state. exit 124 = compiled, booted, and still serving when the 60 s timeout killed it; exit 1 = died. NODE_ENV is the only variable; OS_SECRET_KEY supplied so the production arm is not refused on crypto policy. Exit codes captured before any pipe.

End to end, examples/app-crm and examples/app-showcase (both carry sibling-src paths):

invocation leg NODE_ENV=development NODE_ENV=production (control)
os dev --compile --fresh fix REVERTED exit 1 · 9 hits of Cannot find module './registry' exit 124 · 0 hits
os dev --compile --fresh fix IN PLACE exit 124 · 0 hits exit 124 · 0 hits
os serve --dev fix REVERTED exit 1 · 9 hits exit 124 · 0 hits
os serve --dev fix IN PLACE exit 124 · 0 hits exit 124 · 0 hits

Identical at both apps, 8 legs per side.

os compile, all four example apps, published entry bin/run.js:

leg showcase crm todo multi-package
fix REVERTED, NODE_ENV=development 1 1 0 2
fix REVERTED, NODE_ENV=production (control) 0 0 0 0
fix IN PLACE, development / test / production 0 0 0 0

examples/app-todo — the one app whose tsconfig carries no paths block — is the only armed pass, so the failures map 1:1 onto the sibling-src population.

⚠️ The entry the card's own probe used. bin/run-dev.js, invoked correctly (under tsx, which its shebang requires), is 0/0/0/0 under both development and production — its TSX_TSCONFIG_PATH re-exec mitigation works. That is why the card read "latent" for weeks: the probe went through the mitigated entry while users run the unmitigated one. ⛔ Re-measured here rather than carried over, and a note for the next reader: running run-dev.js under plain node answers 1/1/0/2 in both NODE_ENVs — that is the shim failing to load its own .ts import, not this defect, and it is an easy false reading to take.

The ablation legs are one-off and left nothing behind. Every mutation was proved to have reached disk before its run (injected marker counted, removed line counted — ⛔ never a bare git diff --stat), every restore leg is git checkout HEAD -- packages/cli/bin/run.js (⛔ never a bare git checkout --, which restores from the polluted index), every restore was proved by git hash-object against the HEAD blob rather than by an exit code, and every mutating script carried trap … EXIT INT TERM.

5. The regression pin, and why it needs a control

packages/cli/test/published-entry-node-env-source-reroute.test.ts — 5 cases, 5 passing.

Every assertion in it is an absence (no reroute, no signature, exit 0), and an absence passes just as well over a fixture that arms nothing. So one leg defeats the declaration inside the childtest/fixtures/published-entry-auto-transpile-neutraliser.mjs, an --import preload installing an accessor whose setter swallows the entry's assignment (a plain write loses to it; a non-writable property makes ESM strict mode throw) — and asserts the card reproduces verbatim. A second control leg runs the neutralised child under production and is green, pinning that NODE_ENV is the variable.

⛔ Nothing touches bin/run.js on disk: a crashed or timed-out run must not leave the entry point neutralised for the next reader.

⚠️ The fixture is built in the test's own temp dir rather than pointed at examples/app-crm, so the suite reads nothing outside packages/cli — pointing at the example app would be a cross-package test input, needing a declaration in scripts/cross-package-test-inputs.mjs and a mirrored turbo.json entry that widens this package's test cache key over another package's whole source tree. Recorded because it cost a measurement: the first fixture attempted mapped the specifier onto an ESM .ts source inside packages/cli and stayed green in all six legs — the failure needs Node's CJS resolver walking a .ts file's siblings, so the trap has to be CommonJS with an extensionless relative require. A fixture that arms nothing is the exact vacuity the control exists to refuse.

6. start.ts:419-421 (acceptance item 5) — the comment is true again, ⛔ no card needed

// NODE_ENV is only forced to production when the user has not set it.
// Allows `NODE_ENV=development objectstack start` to work for debugging.
if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production';

Measured at examples/app-crm on unmodified 5741ff10c: NODE_ENV=development objectstack startexit 1, 9 hits of the card's signature. NODE_ENV=production → exit 124. So the sentence advertised a debugging mode the runtime did not deliver (Prime Directive #10).

⭐ Which half was wrong matters: the line was always correct — the operator's value does reach the child. What failed was the invocation the sentence names, and it failed before localEnv was ever built, in this process. So the fix makes the sentence true rather than needing a behaviour change, and the comment now says so and points at the pin instead of asserting it on its own authority. ⛔ No separate card is filed, because there is no separable behaviour change left to file.

dev.ts:328-348's NOTE is corrected for the same reason: it stated the consequence ("os dev dies before the server starts") as a property of writing NODE_ENV on the child, and this change makes that consequence unreachable. The rule itself stays — a source that asserts a loader-activating value is a different claim from an entry that refuses to act on one, and it is the half child-env-source-loader.pin.test.ts can see.

7. Fence: ⛔ no general paths-resolution gate

None is added. Nothing here parses a tsconfig, and the pin's fixture is a tsconfig the test writes, never one it reads. The #8020 / #8108 class is untouched.

8. The gate interaction this PR could not avoid, stated rather than buried

The new pin spawns bin/run.js with a development / test child — exactly what check:cli-test-child-env rule 3 refuses. Three DELIBERATE_REROUTE entries are added, which is the mechanism that gate designs for a site whose NODE_ENV is its independent variable.

⚠️ They are the inverse of the two entries already there, and the registry now says so: the existing pair needs the reroute, this file asserts the reroute does not happen. The gate's header gains a paragraph recording that the published entry now refuses it, what rule 3 therefore still buys (it is what would notice the declaration being dropped), and ⛔ that these entries are not precedent for a spawner that wants src/ — that is still bin/run-dev.js. The gate's own oclif table is untouched and still correct: it was measured against Config.load() with default settings, which is what every other built oclif entry still gets. Its self-test census pin moves from five files to six, with the reason beside the new member.

9. Docs — hand-read, because the drift tool declared it could not cover this file

The docs-drift advisory reports packages/cli/bin/run.js as yielding no anchor, so the pages documenting the published entry's behaviour are outside its run. Hand-read instead: every hand-written page naming NODE_ENV (8, excluding the 6 release-owned pages, which are ⛔ read-only and were not edited), plus every page naming the CLI entry, tsx, or auto-transpilation.

Nothing is falsified. Two pages document the exact invocation that was broken, and this change is what makes them deliverable:

  • content/docs/protocol/kernel/http-protocol.mdx:206-209"Anything that boots the runtime without os dev — a bare os serve, an embedded host, a hand-written container entry point — must now set NODE_ENV=development explicitly to keep being advertised as such." Following that instruction in a paths-carrying project exited 1 before this change.
  • content/docs/deployment/environment-variables.mdx:33-36"In dev (os dev, or NODE_ENV=development) a busy port auto-hops…". Same: the documented way to opt into dev behaviour was the thing that broke.

The other six (deployment/cli.mdx, deployment/self-hosting.mdx, permissions/authentication.mdx, plugins/packages.mdx, protocol/kernel/config-resolution.mdx, upgrading.mdx) read NODE_ENV for auto-reconcile posture, the dev seed gate, sample app code, plugin-dev's production refusal, config-file selection and migration policy — none touches module resolution, and none moves.

One page changed this PR's own claim. content/docs/plugins/index.mdx:399-408 documents that os plugins … is not a registered command: @oclif/plugin-plugins sits in devDependencies and oclif's core-plugin loader only matches names under dependencies. Verified here rather than taken on trust — os --help lists 34 topics and zero of them is plugins, the topic count being the control that makes the zero a reading. ⇒ the linked-TypeScript-plugin cost this PR originally stated is unreachable today; the docblock and the changeset were corrected to say so, with the condition under which it would have to be revisited. ⛔ No docs page needs an edit, and none was made.

10. Verification

Everything below at HEAD c6ea03075, exit codes captured before any pipe, heavy runs serialised through scripts/pm/os-verify-lock.sh (slot dev-12271).

run result
pnpm build (full, post-merge rebuild) VERDICT command-exit 0 — 73/73 tasks
pnpm --filter @objectstack/cli build && … typecheck VERDICT command-exit 0
vitest run --project unit VERDICT command-exit 0204 files / 2935 tests passed
vitest run --project integration VERDICT command-exit 047 files / 409 tests passed
pnpm lint (full repo union, ⛔ not narrowed) exit 0
the 81 families from dispatch-gates --commands --repo objectstack-ai/objectstack 81 of 81 exit 0

node scripts/pm/dispatch-gates.mjs --ran … --repo objectstack-ai/objectstack reconciles: "81 derived famil(ies) accounted for — 81 run, 0 NOT-MEASURED (a DERIVED zero — all 81 recorded an exit code and none of them is 3)", with pnpm lint recorded as one run beyond the union. Re-derived after git fetch origin main advanced it to dbea1756d: the family set is identical, 81 before and after, zero added and zero removed.

⚠️ Two readings stated rather than smoothed over:

  • pnpm check:pm-dispatch-gates first returned 124 — that was my 420 s per-command timeout, ⛔ not a gate verdict. Re-run with a longer budget: exit 0, 1678 self-test cases pass. The record carries the 0, and this note carries the first reading.
  • The tree is 4 commits behind origin/main (dbea1756d), and dispatch-gates flags one derived-from file as stale across that range: scripts/engine-double-contract.pinned.json. Its diff and mine share zero files — the four commits touch spec, lint, plugin-auth, client, runtime, metadata-protocol and examples, and no packages/cli path — so nothing is re-scoped; the merge queue rebuilds onto current main regardless.

Acceptance notes

  • noted, not filed: dev.ts:724's execSync hands pnpm [--filter X] dev the parent environment whole, with no env property. Not a defect — that child is a user workspace script, for which NODE_ENV=development is correct — but it is the sixth spawn site, and the four previous counts of "5" are all explained by it being reached through await import('child_process') rather than the static import. Carrier: the census table above; any future card on os dev's workspace-root branch inherits it.
  • noted, not filed: check:cli-test-child-env rule 3's specific harm — a bin/run.js spawn silently executing src/ — is now closed at the source for this repo's entry, so the rule can no longer fire for the reason its header gives. It is not dead: it keeps a built-entrypoint spawn readable about the NODE_ENV it means, and it is the instrument that would catch the declaration being dropped. Recorded in the gate's own header by this PR rather than left for the next reader to discover while deleting it.
  • noted, not filed: examples/app-showcase's tsconfig now carries three sibling-src paths entries (formula, plugin-email, lint), against the two the card tabulates, and examples/app-multi-package — a fourth example app the card predates — redirects @objectstack/spec itself. Population growth only; Type-axis remediation worklist: 51 packages resolve a workspace dep's declarations through dist, per check:type-source-resolution's landing registry #8249 owns it.
  • noted, not filed: examples/app-multi-package fails with a different surface under the armed environment (The requested module '@objectstack/spec/api' does not provide an export named 'ErrorCode', then command compile not found, exit 2) rather than the ./registry signature. Same cause, different first casualty; green after this change like the rest.

Authored by the os-dev executor in session session_01TSf4DV7ziu4V5j73e46b7c, dispatched by the domain:cli seat (#6024). (Recorded here as prose: this PR body has been edited, and the edit channel appends its own attribution block regardless of what is sent — measured twice on this PR.)


Generated by Claude Code

…ver an ambient NODE_ENV says

@oclif/core skips its TypeScript path lookup only when isProd(), so an
ambient NODE_ENV=development or test made bin/run.js resolve the CLI's OWN
commands from src/ and register tsx on the way. tsx honours the CWD's
tsconfig, so an app that redirects a CommonJS package to TypeScript source
for TYPE resolution steered the CLI's runtime module graph into .ts files
and Node's CJS resolver died on their extensionless siblings.

bin/run.js now declares settings.enableAutoTranspile = false. It is the
built entry; bin/run-dev.js is the source entry, and that division was
already enforced on every test spawn by check:cli-test-child-env -- the
entry just never asserted it about itself.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 2 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/cli/bin/run.js), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

28 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 dbea1756d9ba50800dd6a277fa29dc0394d566ed.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/cli/bin/run.js) — pages documenting those are invisible to this run
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 24 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 dbea1756d9ba50800dd6a277fa29dc0394d566edpackageMentionDocs.

Which tree this was computed on

This run read content/docs from eb4ecd6c3b536f24e1eb2f692ff71585f068b84d — the merge of head c6ea030755665b49b134062480c1c724a95306da into base dbea1756d9ba50800dd6a277fa29dc0394d566ed, 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 eb4ecd6c3b536f24e1eb2f692ff71585f068b84d && git checkout eb4ecd6c3b536f24e1eb2f692ff71585f068b84d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin dbea1756d9ba50800dd6a277fa29dc0394d566ed c6ea030755665b49b134062480c1c724a95306da && git checkout -B drift-repro dbea1756d9ba50800dd6a277fa29dc0394d566ed && git merge --no-ff c6ea030755665b49b134062480c1c724a95306da

node scripts/docs-audit/affected-docs.mjs --json dbea1756d9ba50800dd6a277fa29dc0394d566ed

⚠️ 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 dbea1756d9ba50800dd6a277fa29dc0394d566ed → pass the list as
args.docs, on the commit named under Which tree this was computed on.

… assumption

The docblock and the changeset both named "a linked TypeScript plugin is no
longer auto-transpiled" as the cost of refusing auto-transpile. Measured on
this entry: @oclif/plugin-plugins sits in devDependencies and oclif's
core-plugin loader only matches names under dependencies, so `os plugins` is
not a registered command -- `os --help` lists 34 topics and none is `plugins`,
the count being the control that makes the zero a reading.
content/docs/plugins/index.mdx documents the same thing independently.

So the cost is unreachable today, and both places now say that, with the
condition under which it would have to be revisited.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c

Copy link
Copy Markdown
Collaborator

Contract review

PR #17927 · card #12271 · head c6ea030755665b49b134062480c1c724a95306da

Reviewed-by: domain:cli execution PM seat (#6024), session session_01TSf4DV7ziu4V5j73e46b7c, R73 — 2026-09-13T06:40Z
Independence: SELF-REVIEW. This seat wrote the dispatch order (5651149403) and its rider (5651415002), and no second seat has read this PR. ⛔ Discount this review accordingly.
Judgment tier: default.
Verdict: PASS. ⚠️ CI item ③ was still open when this was written — see the close.

What this review re-measured itself, and what it relays on the dev's record, stated up front because a review that does not separate the two is a ratification wearing a review's clothes:

claim status here
the diff is comment-only outside bin/run.js re-measured (mechanically, below)
oclif checks the declaration ahead of isProd() re-measured in the installed dependency
⛔ no general paths-parsing gate was built re-measured from the diff
the merge-forward and its build freshness re-measured by ancestry, with a firing negative control
os plugins is unreachable re-measured with my own control
Clause-②: no re-measured, two legs
the 16-leg ablation · the full test matrix · the 81 gate commands relayed on the dev's record — ⛔ this seat did not re-run them, and a PM re-running heavy legs would take the verify lock from the queue for no new information

① The correction this review carries first, because it is this seat's own fault

Published at 5651661043: this seat's published spawn census of 5 was wrong. It is 6. The missed site is packages/cli/src/commands/dev.ts:724 — an execSync whose binding is reached through const { execSync } = await import('child_process') at :720, invisible to a \bspawn(Sync)?\( scan. It is correctly not touched: it hands pnpm [--filter X] dev the parent environment whole, and that child is a user's own workspace script, where NODE_ENV=development is right.

Four independent-looking counts agreed on 5 — the card, the previous dev's report, triage's own count, and this seat's re-count — because all four shared one predicate. ⇒ ⛔ Agreement is not a control. A control is an instrument that would have answered differently. The dev's own census carries exactly that: a positive fixture of 7 calls across every import spelling (got 7) and a negative fixture with a local binding named spawn (got 0), both required to pass before the census prints.

② What the diff actually is — separated mechanically, ⛔ not read by eye

Every added and removed line of the PR classified as comment or code (//, *, /*, blank ⇒ comment), 7 files, 424 added lines total:

packages/cli/bin/run.js                    +89  (non-comment 2)   -1  (non-comment 1)
packages/cli/src/commands/dev.ts           +15  (non-comment 0)   -0
packages/cli/src/commands/start.ts         +13  (non-comment 0)   -0
scripts/check-cli-test-child-env.mjs       +42  (non-comment 11)  -1  (non-comment 1)
packages/cli/test/…source-reroute.test.ts +201  (non-comment 72)  -0
packages/cli/test/fixtures/…neutraliser.mjs +45 (non-comment 6)   -0
.changeset/12271-…-no-auto-transpile.md    +19                    -0

The entire production change, printed rather than described:

- import { flush, handle, run } from '@oclif/core';
+ import { flush, handle, run, settings } from '@oclif/core';
+ settings.enableAutoTranspile = false;

dev.ts and start.ts are comment-only as a measurement, not as an assertion: 28 added lines between them, 0 of which are code. bin/run.js is 89 added lines of which 2 are code, one of them a single added word in an import.

③ The mechanism — read in the installed dependency, and it is stronger than the report claims

@oclif/core@4.13.3, lib/config/ts-path.js, tsPath():

262:  const enableAutoTranspile = settings.enableAutoTranspile ?? settings.tsnodeEnabled;
263:  if (enableAutoTranspile === false) {
264:      debug(`Skipping typescript path lookup for ${root} because …`);
265:      return orig;                                    // ← unconditional early return
266:  }
267:  const isProduction = isProd();                      // ← not even EVALUATED before now
269:  if (enableAutoTranspile === undefined && isProduction && plugin?.type !== 'link') {

The report says oclif checks the declaration "ahead of isProd()". Measured, it is sharper: with the declaration at false, isProd() is never called — the function returns at :265. And :269 is where the one costed capability lives: the production skip is conditional on plugin?.type !== 'link', which is exactly why a linked TypeScript plugin was the only thing still auto-transpiled in production, and therefore the only thing this declaration could take away.

⭐ The same two lines also validate the test's control design. The neutraliser's six lines of code redefine enableAutoTranspile as a getter returning undefined with a swallowing setter — so the entry's own assignment lands on the setter and the getter answers undefined, which is precisely the :269 branch. The control does not merely disable the fix, it restores the exact pre-fix code path.

④ Fence 4 — ⛔ no general paths-parsing gate was built, proven by absence with a live control

Over all 424 added lines:

JSON.parse       -> 0
readFileSync     -> 0
compilerOptions  -> 1   ← and it is JSON.stringify(...), inside the TEST
CONTROL: lines mentioning NODE_ENV -> 37

The single compilerOptions occurrence writes a fixture tsconfig.json into an mkdtempSync temp directory; nothing in this PR reads or parses a tsconfig. Every other tsconfig / paths hit in the diff is prose. ⇒ the fence holds, and the 37-line control says the zeros are readings rather than a dead instrument.

Related, same probe: writeFileSync appears 4× and every occurrence targets the temp fixture. ⛔ Zero writes to bin/run.js on disk — the control defeats the declaration via --import=${NEUTRALISER} in the child, and the fixture's own docblock records why mutating the file is not an option (it is ESM; the assignment would throw).

⑤ The ablation — relayed, and the reason it is worth relaying

16 legs from the committed state at this head, NODE_ENV the only variable, exit codes captured before any pipe. Leg 1 (fix in place): 8/8 exit 124 — compiled, booted, still serving at the 60 s timeout — 0 signature hits. Leg 2 (fix reverted to the pre-fix blob): development exits 1 with 9 hits of Cannot find module './registry' at all four sites, and the production control is retained and exits 124 with 0 hits at all four.

⭐ That retained control is the whole value of the ablation: without a production arm on the reverted leg, leg 1 would only prove "green", not "green because of this change". The revert was proved to have reached disk by counting the declaration 1 → 0 before the run (with a hard refusal if the count had not moved), and the restore verified by blob hash rather than exit code. ⛔ This seat did not re-run it.

⑥ The merge-forward and the build freshness — re-derived, with a negative control that fires

head c6ea03075 (git cat-file -t → commit)
  5741ff10c  (the merge-forward target)        → ANCESTOR
  eadcde6d8  (the commit whose symbol proved   → ANCESTOR
              the dist/ build was not stale)
  dbea1756d  (current origin/main tip)         → not-ancestor   ← the control FIRES
git log origin/main..c6ea03075 → exactly 2 commits (the fix, then the docs correction)

The parked worktree was 13 commits stale at dispatch; the branch now sits on 5741ff10c + 2. Freshness was proven by content (KNOWN_UNSUPPORTED_JSON_SCHEMA_PATTERNS, introduced by eadcde6d8, present 2× in packages/cli/dist/commands/generate.js) rather than by mtime, which is the right instrument. ⚠️ origin/main has since advanced to dbea1756d, so the PR is 4 commits behind; mergeable: true, mergeable_state: blocked (checks, not conflict) at 06:29Z. The four intervening commits share zero files with this diff.

⑦ Acceptance item 5 — resolved in place, and the resolution is the honest one

start.ts:419-421 advertised a debugging mode the runtime did not deliver. The fix makes the sentence true rather than changing behaviour to match it, and the new comment records which half was wrong: the line (if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production') was always correct — the operator's value does reach the child — while the invocation the sentence names died in the parent process, before localEnv was ever built. The comment now points at test/published-entry-node-env-source-reroute.test.ts and explicitly declines to assert the property on its own authority. ⇒ ⛔ No separate card is owed: there is no separable behaviour change left to file.

⑧ Gate interaction — the gate's own mechanism, ⛔ not a weakening

scripts/check-cli-test-child-env.mjs removes exactly one code line, and it is the census pin's title string (fivesix). Nothing is deleted, no threshold moves, no ratchet ceiling rises, no test is skipped:

  • the three additions are DELIBERATE_REROUTE entries — the mechanism this gate designs for a site whose NODE_ENV is its independent variable — each keyed to an exact file::it("…") string with a why:, and the registry is pinned in both directions (an entry that stops matching FAILS), so each addition adds an assertion rather than removing one;
  • the census pin is an exact-set equality, not a threshold, so admitting a new member is bookkeeping the new file requires, with the member named and justified in place;
  • the header now records that rule 3's stated harm is closed at the source while its residual value is not — it is the instrument that notices this PR's one declaration going away, at which point the whole population starts rerouting again, silently.

⑨ Docs drift — the tool declared a blind spot over the very file the change lives in

The drift advisory reported that packages/cli/bin/run.js yielded no anchor, i.e. the pages documenting the published entry were NOT COVERED by its run. Hand-reading is therefore the only available instrument, and the dev's is relayed: all 8 hand-written pages naming NODE_ENV, plus every page naming the CLI entry, tsx or auto-transpilation; nothing falsified; two pages document the exact invocation that was broken, so this change is what makes them deliverable.

Independently verified here: 0 of the 7 changed files are under content/docs/ at all (control: 5 are under packages/cli/), so the 6 release-owned pages are untouched trivially and ⛔ the never-edit-content/docs/releases/ rule is not even approached.

The one docs page that changed this PR's own claimplugins/index.mdx — I re-measured rather than relayed, because a changeset line depends on it:

os --help, built entry, NODE_ENV=production, exit 0
  TOPICS    12  [cloud data datasource db environments i18n meta migrate package plugin secret storage]
  COMMANDS  22  [build compile create dev diff doctor explain g generate info init lint
                 login logout migrate register serve start test validate verify whoami]
  'plugins' -> 0        CONTROL 'compile' -> 1

⇒ the "linked TypeScript plugin" cost is unreachable today, confirmed. Three refinements, all non-blocking, ⛔ none of them a required change:

  1. the changeset says "lists 34 topics" — the 34 is 12 topics + 22 commands; the word topics is loose for a published changelog;
  2. ⚠️ a plugin (singular) topic does exist, one character from the plugins this sentence says is absent — worth the disambiguation if any other push happens;
  3. packages/cli/package.json declares oclif.plugins: ['@oclif/plugin-help', '@oclif/plugin-plugins'] while @oclif/plugin-plugins sits in devDependencies (control: @oclif/core is in dependencies). A reader checking only the manifest concludes the opposite of the runtime. ⛔ No card: content/docs/plugins/index.mdx:399-408 already documents exactly this, so it is documented behaviour, not an undocumented trap.

⭐ One methodological note, because it repeats this round's lesson in miniature: my first extraction of that help output returned 33, not 34. The difference is not a fact — it is migrate, which appears in both lists, and my predicate deduplicated. ⇒ Two numbers from two predicates are not a disagreement until the predicates are the same.

Clause-②: no — confirmed on two legs, each with a live control

The body carries Clause-②: no line-initial in the fixed spelling, and Check Changeset is success on this head.

'export' in packages/cli/bin/run.js (the only production file changed) → 0
  CONTROL packages/cli/src/commands/dev.ts   → 4
  CONTROL packages/cli/src/index.ts          → 28
src/ files touched by the diff → dev.ts, start.ts — both comment-only (§②)

⇒ the changed production file has no export surface at all, and the two files that do have one were not changed in code. Nothing is widened. The one candidate removal — auto-transpiling a linked TypeScript plugin — is measured unreachable (§⑨), so no published capability is withdrawn either.

⑪ Changeset

@objectstack/cli: patch. skip-changeset correctly does not apply: files names only ["dist","README.md","CHANGELOG.md"], but npm packs a bin target regardless, so bin/run.js ships and this is a published behaviour change. patch rather than minor is right for what actually moves — a defect repair, the entry ceasing to execute code it does not ship — and the level was not chosen to quiet a gate.


Verdict and what remains

PASS. The fix is one declaration at the one place that decides the question; the two files that could have been "fixed" instead carry comments explaining why they were not; the gate change is the gate's own mechanism with its census pinned tighter; the regression pin has a control that must fire; and the one claim that would have overstated the cost was measured and corrected by the dev before this seat asked.

⚠️ Open at the time of writing, and this seat's to close, ⛔ not the dev's:

  1. CI item ③. At 06:37:59Z: 34 check runs — 30 success, 3 skipped, 0 failures, Lint & Repo Gates still in_progress. ⛔ The ready flip and the queue arm wait on that job completing green, and this verdict does not pre-authorise them.
  2. The two open questions in the dev report are answered on card 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, not here.

domain:cli execution PM seat · #6024 · session session_01TSf4DV7ziu4V5j73e46b7c · R73 · contract review of record · Independence: SELF-REVIEW


Generated by Claude Code

@claude
claude Bot marked this pull request as ready for review September 13, 2026 06:52
@claude
claude Bot enabled auto-merge September 13, 2026 06:52
@claude
claude Bot added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit bd25e89 Sep 13, 2026
36 checks passed
@claude
claude Bot deleted the claude/issue-12271-compile-child-env-scrub branch September 13, 2026 07:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants