diff --git a/.changeset/12271-published-entry-no-auto-transpile.md b/.changeset/12271-published-entry-no-auto-transpile.md new file mode 100644 index 0000000000..2834af4375 --- /dev/null +++ b/.changeset/12271-published-entry-no-auto-transpile.md @@ -0,0 +1,19 @@ +--- +"@objectstack/cli": patch +--- + +`bin/run.js` — the entry `os` / `objectstack` names — resolves its commands from `dist/` whatever an ambient `NODE_ENV` says, so an exported `NODE_ENV=development` no longer kills the CLI in a project whose tsconfig maps a package to TypeScript source (#12271). + +`@oclif/core` skips its TypeScript path lookup only when `isProd()` — a negated `['development', 'test'].includes(NODE_ENV)`. Under either value it resolved the CLI's **own** command modules from `src/` and registered tsx on the way, and tsx honours the tsconfig of the **current working directory**. An application that maps a CommonJS workspace package to its TypeScript source for *type* resolution — `"@objectstack/formula": ["../../packages/formula/src/index.ts"]` — therefore steered this CLI's *runtime* module graph into `.ts` files, after which Node's CommonJS resolver walked their extensionless siblings and found nothing: + +``` +[MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts: +Cannot find module './registry' +``` + +Measured at two example apps with `NODE_ENV` as the only variable: `os compile`, `os dev --compile --fresh`, `os serve --dev` and `os start` each exited 1 on that signature under `development`, and each compiled or booted cleanly under `production`. The app with no `paths` block was the only one unaffected. + +- **The fix is one declaration**: `settings.enableAutoTranspile = false`, checked by oclif ahead of `isProd()`. `bin/run.js` is the built entry and `bin/run-dev.js` is the source entry — a division `check:cli-test-child-env` already enforced on every test that spawns the CLI; the entry simply never asserted it about itself. +- ⛔ **Not a child-environment scrub.** `os serve --dev` and `os start` are top-level processes with no parent to scrub, and the casualty was the CLI's own command table rather than the user's config, so no per-spawn `NODE_ENV` handling could reach it. +- **`NODE_ENV=development objectstack start` works again** — the debugging mode `os start` has advertised in a comment all along, and did not deliver. +- ⚠️ **What it costs, measured**: the only thing oclif keeps its TypeScript lookup alive for in production is a **linked** plugin, so a `plugins link`ed TypeScript plugin would no longer be auto-transpiled through the published entry. That path is not reachable today — `@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`), which is what `content/docs/plugins/index.mdx` already documents. On an unbuilt checkout the entry now answers oclif's `command not found` under `development`/`test` exactly as it already did with `NODE_ENV` unset. diff --git a/packages/cli/bin/run.js b/packages/cli/bin/run.js index e522731ad6..43f98d36c0 100755 --- a/packages/cli/bin/run.js +++ b/packages/cli/bin/run.js @@ -14,7 +14,95 @@ // // ⛔ Nothing here changes which arguments the CLI accepts. `os dev --no-ui` is // still rejected — it is only rejected legibly. -import { flush, handle, run } from '@oclif/core'; +import { flush, handle, run, settings } from '@oclif/core'; + +/** + * ⭐ THIS ENTRY POINT RUNS `dist/`. It says so here so that an ambient + * environment variable cannot decide otherwise. + * + * `bin/run.js` is the BUILT entry — `bin.objectstack` / `bin.os`, the file an + * `npm i -g @objectstack/cli` install executes — and `packages/cli/package.json` + * declares its command table over the emitted tree + * (`"target": "./dist/commands"`). `bin/run-dev.js` is the SOURCE entry, run + * under tsx, and it is the one that is SUPPOSED to reach `src/`. That division + * is not new prose: `scripts/check-cli-test-child-env.mjs` already enforces it + * on every test that spawns this file, and its rule 3 states the property in + * the same words — a child of the built entrypoint must be readably outside + * `development`/`test`, with no baseline and only two declared exceptions. + * + * What was missing is that **this file never asserted it about itself.** + * `@oclif/core@4.13.3`'s `lib/config/ts-path.js` skips its TypeScript path + * lookup only when `isProd()`, which `lib/util/util.js` defines as + * `['development', 'test'].includes(process.env.NODE_ENV ?? '')` negated. So an + * ambient `NODE_ENV` — exported by a developer, or inherited by any child this + * CLI spawns — rewrote the command target from `dist/commands` to + * `src/commands` and registered tsx on the way. Measured against `Config.load()` + * on this package with `dist` present (the table #11317 recorded, unchanged): + * + * child NODE_ENV resolved commandsDir + * -------------- ------------------------- + * unset packages/cli/dist/commands + * production packages/cli/dist/commands + * development packages/cli/src/commands ⛔ + * test packages/cli/src/commands ⛔ + * + * ⚠️ The registration is the damaging half, not the redirect. `registerTsx()` + * runs BEFORE `determinePath()` decides anything, and tsx honours the tsconfig + * of the **current working directory**. An application whose tsconfig maps a + * workspace package to its TypeScript source for TYPE resolution — + * `"@objectstack/formula": ["../../packages/formula/src/index.ts"]`, which is + * what `examples/app-crm`, `app-showcase` and `app-multi-package` all do — then + * steers this CLI's own module graph into `.ts` files, after which Node's CJS + * resolver walks their extensionless siblings and knows nothing about `.ts`: + * + * [MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts: + * Cannot find module './registry' + * Require stack: + * - …/packages/formula/src/index.ts + * + * ⭐ Note WHICH file failed to load: `src/commands/doctor.ts`. The casualty is + * this CLI's own command table, not the user's config — so the failure is not + * specific to any one command, and no amount of scrubbing a CHILD's environment + * reaches it. Measured at `examples/app-crm` and `examples/app-showcase` with + * `NODE_ENV=development` exported, before this line existed: `os compile`, + * `os dev --compile --fresh`, `os serve --dev` and `os start` each exit 1 on + * that signature, against exit 0 / still-serving for every one of them with + * `NODE_ENV=production`. `examples/app-todo`, the one example app whose + * tsconfig carries no `paths` block, is the only one that survived — the + * failures map 1:1 onto that population, and #8249 is actively growing it. + * + * ⛔ This is deliberately NOT a `TSX_TSCONFIG_PATH` pin like the one + * `bin/run-dev.js` carries. That shim genuinely executes TypeScript, so all it + * can do is aim the transpiler at the right tsconfig; and it cannot even do + * that in-process (tsx parses its tsconfig in the loader's `initialize`, which + * has already run by then), so it pays a whole re-exec. This file executes no + * TypeScript at all, so the correct statement is not "transpile against a + * different config" but "do not transpile" — and the published install has no + * `packages/cli/tsconfig.json` to aim at in any case (`files` names `dist` + * only). + * + * ⚠️ What it costs, measured rather than assumed. The one thing oclif keeps the + * TypeScript lookup alive for even in production is a LINKED plugin + * (`plugin?.type !== 'link'` guards the `isProduction` early return), and this + * setting is checked ahead of that — so a `plugins link`ed TypeScript plugin + * would no longer be auto-transpiled through this entry. ⭐ That path is not + * reachable today: `@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 at all (measured on this entry — + * `os --help` lists 34 topics and none of them is `plugins`; the count is the + * control, so the zero is a reading). `content/docs/plugins/index.mdx` says the + * same in its own words and tells an extension author to build an `os` + * distribution listing the package in both places. ⛔ If that is ever fixed, + * this line is what has to be revisited — the remedy is `bin/run-dev.js`, or + * building the plugin. + * + * The other change in behaviour is a convergence, not a loss: on an UNBUILT + * tree this file now answers oclif's "command not found" under + * `development`/`test` exactly as it already did when `NODE_ENV` was unset — + * the signature `scripts/cli-build-prerequisite.mjs` classifies for every gate + * that shells out to this CLI, so the three legs stop disagreeing. + */ +settings.enableAutoTranspile = false; /** * Print the one-line invocation verdict, if this failure is one. diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index d1c781285f..e9944c3dc3 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -346,6 +346,21 @@ export default class Dev extends Command { // from the `/runtimeModule` hash, which differs run-to-run regardless // (the bundle embeds `builtAt`). Pinned by // child-env-source-loader.pin.test.ts. + // + // ⚠️ WHAT THIS RULE NO LONGER CARRIES, so the next reader does not + // re-derive it: the CONSEQUENCE above was never conditional on a child + // being handed the variable. Writing no `NODE_ENV` here leaves the + // child inheriting whatever the parent has, so an operator who merely + // EXPORTED `NODE_ENV=development` reproduced every word of it — and on + // a direct `os serve --dev` or `os start`, which has no parent to scrub + // at all. That class is closed one level down, where it is actually + // decided: `bin/run.js` declares `settings.enableAutoTranspile = false`, + // so the built entry resolves its commands from `dist/` whatever + // `NODE_ENV` says (#12271 — its docblock carries the measurement). + // ⛔ This rule stays anyway and is not redundant: it keeps the CLI's own + // sources from ASSERTING a loader-activating value, which is a + // different claim from the entry refusing to act on one, and it is the + // half `child-env-source-loader.pin.test.ts` can see. const compileResult = spawnSync( process.execPath, [binPath, 'compile', '--output', artifactPath], diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 10224fa03b..af0f754aba 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -418,6 +418,19 @@ export default class Start extends Command { if (flags.artifact) delete localEnv.OS_ARTIFACT_URL; // NODE_ENV is only forced to production when the user has not set it. // Allows `NODE_ENV=development objectstack start` to work for debugging. + // + // ⚠️ That second sentence was a FALSE ADVERTISEMENT for as long as #12271 + // was open, and it is worth recording which half was wrong. This line was + // always correct: the operator's value does reach the child. What did not + // work was the invocation the sentence names — measured at + // `examples/app-crm`, `NODE_ENV=development objectstack start` exited 1 on + // `Cannot find module './registry'`, because the ambient value made + // @oclif/core resolve the CLI's OWN commands from `src/` in this process, + // before `localEnv` was ever built. It is honoured again because + // `bin/run.js` now declares `settings.enableAutoTranspile = false`; the + // sentence is held true by + // `test/published-entry-node-env-source-reroute.test.ts`, ⛔ not by this + // comment. if (!localEnv.NODE_ENV) localEnv.NODE_ENV = 'production'; // Single-node self-host quickstart: forcing production above would make diff --git a/packages/cli/test/fixtures/published-entry-auto-transpile-neutraliser.mjs b/packages/cli/test/fixtures/published-entry-auto-transpile-neutraliser.mjs new file mode 100644 index 0000000000..b47a0a5af7 --- /dev/null +++ b/packages/cli/test/fixtures/published-entry-auto-transpile-neutraliser.mjs @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The POSITIVE CONTROL for `published-entry-node-env-source-reroute.test.ts` + * (#12271): it defeats `bin/run.js`'s `settings.enableAutoTranspile = false` + * from inside the child, so the suite can watch the defect reproduce. + * + * ## Why a control is not optional here + * + * Every assertion in that file is an ABSENCE — the CLI did not reroute to + * `src/`, the card's signature did not appear, the exit code was 0. An absence + * passes just as well over a fixture that arms nothing: a `tsconfig.json` whose + * `paths` entry never matched, a trap module that resolves fine, a probe + * command that loads no command modules. Every one of those would be green, and + * green for the wrong reason, forever. So one leg has to make the SAME fixture, + * on the SAME entry, under the SAME environment, fail — and the only difference + * between the legs is this file. + * + * ## Why it is a preload and not an edit + * + * The alternative is mutating `bin/run.js` on disk and putting it back, which + * is a real edit to a shared worktree from inside a test run — the failure mode + * being a crashed or timed-out run that leaves the entry point neutralised for + * every later reader. Nothing here touches disk: the process exits and the + * neutralisation is gone with it. + * + * ## ⚠️ Why a getter/setter pair and not a plain write + * + * `--import` runs BEFORE the entry, and the entry then assigns `false` — so a + * preload that simply wrote `true` would be overwritten a moment later and the + * control would silently not fire. Making the property non-writable instead + * would make that assignment THROW, because `bin/run.js` is ESM and therefore + * strict mode. An accessor whose setter ignores writes is the one shape that + * absorbs the entry's assignment without either losing to it or crashing it, + * and leaves `@oclif/core` reading `undefined` — exactly the value it saw + * before the declaration existed, so the leg reproduces the ORIGINAL defect + * rather than some third state. + */ +const { settings } = await import('@oclif/core'); + +Object.defineProperty(settings, 'enableAutoTranspile', { + get: () => undefined, + set: () => {}, + configurable: true, +}); diff --git a/packages/cli/test/published-entry-node-env-source-reroute.test.ts b/packages/cli/test/published-entry-node-env-source-reroute.test.ts new file mode 100644 index 0000000000..3b9f52cdb6 --- /dev/null +++ b/packages/cli/test/published-entry-node-env-source-reroute.test.ts @@ -0,0 +1,201 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #12271 — the PUBLISHED entry resolves its commands from `dist/`, whatever an + * ambient `NODE_ENV` says. + * + * ## The defect + * + * `@oclif/core@4.13.3`'s `lib/config/ts-path.js` skips its TypeScript path + * lookup only when `isProd()`, which `lib/util/util.js` defines as a negated + * `['development', 'test'].includes(process.env.NODE_ENV ?? '')`. So an ambient + * `NODE_ENV=development` — exported by a developer, or inherited by any child + * `os dev` / `os start` spawns — made `bin/run.js` resolve the CLI's OWN + * commands from `src/commands` and register tsx on the way. tsx honours the + * tsconfig of the CURRENT WORKING DIRECTORY, so an application whose tsconfig + * redirects a CommonJS package to its TypeScript source for TYPE resolution + * then steered this CLI's runtime module graph into `.ts` files, after which + * Node's CJS resolver walked their extensionless siblings and knew nothing + * about `.ts`: + * + * [MODULE_NOT_FOUND] import() failed to load …/packages/cli/src/commands/doctor.ts: + * Cannot find module './registry' + * + * ⭐ Note which file failed to LOAD — one of this CLI's own command modules. + * The casualty is the command table, not the user's config, which is why the + * failure was not specific to any command and why ⛔ no amount of scrubbing a + * CHILD's environment could reach it: `os serve --dev` and `os start` are + * top-level processes with no parent to scrub. Measured at `examples/app-crm` + * before the fix, `NODE_ENV` the only variable: `os compile`, + * `os dev --compile --fresh`, `os serve --dev` and `os start` each exit 1 on + * that signature under `development`, and each compile/boot cleanly under + * `production`. + * + * The fix is one line in `bin/run.js` — `settings.enableAutoTranspile = false` + * — and its docblock carries the argument for why that, and not a + * `TSX_TSCONFIG_PATH` pin or a child-env scrub. This file holds the behaviour. + * + * ## ⛔ Why the fixture is built here and not pointed at an example app + * + * `examples/app-crm` is where the defect was measured, and pointing at it would + * make this suite read a second package — a cross-package test input, which has + * to be declared in `scripts/cross-package-test-inputs.mjs` AND mirrored into + * `turbo.json`, widening this package's test cache key over another package's + * whole source tree. It would also make the pin depend on that app keeping a + * `paths` block it maintains for its own reasons. + * + * So the trap is built here instead, and it is the SAME trap rather than an + * analogue: a CommonJS module whose relative `require` has no extension, mapped + * over a specifier every command module imports. It reproduces the card's + * verbatim signature (asserted below), and it reads nothing outside this + * package and its own temp directory. + * + * ⚠️ The trap has to be CommonJS with an EXTENSIONLESS relative require, and + * that is a measured constraint rather than a stylistic choice — the first + * fixture attempted here mapped the specifier onto an ESM `.ts` source inside + * this package and stayed green in all six legs, because the failure needs + * Node's CJS resolver walking a `.ts` file's siblings. A fixture that arms + * nothing is the exact vacuity the control below exists to refuse. + * + * ## The legs + * + * Two independent variables, and both are needed: + * + * • `NODE_ENV` — `development` and `test` are the two values oclif treats as + * non-production; `production` is the CONTROL that was green before the fix + * too, so on its own it proves nothing. It is here to show that the fixture + * is not simply inert. + * • the DECLARATION — present (the tree as shipped) or neutralised in the + * child by `fixtures/published-entry-auto-transpile-neutraliser.mjs`. That + * is the control that must FIRE: without it every assertion in this file is + * an absence, and an absence passes over a fixture that arms nothing. + */ + +import { describe, expect, it, beforeAll, afterAll } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { childEnv } from './helpers/serve-process.js'; + +const HERE = dirname(fileURLToPath(import.meta.url)); + +/** The BUILT entry — the file `bin.objectstack` / `bin.os` names and npm packs. */ +const PUBLISHED_ENTRY = resolve(HERE, '../bin/run.js'); + +/** The control preload. See its own header for why it is an accessor pair. */ +const NEUTRALISER = resolve(HERE, 'fixtures/published-entry-auto-transpile-neutraliser.mjs'); + +/** + * The card's verbatim signature. Asserted as text rather than as an exit code + * alone because an exit code says only THAT the run failed — this says the run + * failed for the reason the card names. + */ +const CARD_SIGNATURE = "Cannot find module './registry'"; + +/** + * The reroute itself, visible in oclif's own warning block: it names the module + * it failed to load, and under the reroute that path is under `src/commands`. + */ +const SOURCE_COMMANDS = join('packages', 'cli', 'src', 'commands'); + +let fixtureCwd: string; + +beforeAll(() => { + fixtureCwd = mkdtempSync(join(tmpdir(), 'os-12271-')); + mkdirSync(join(fixtureCwd, 'trap')); + // The CJS half: an extensionless relative require whose target the CJS + // resolver cannot see, because the sibling on disk is a `.ts` file. + writeFileSync(join(fixtureCwd, 'trap', 'index.ts'), "module.exports = require('./registry');\n"); + writeFileSync(join(fixtureCwd, 'trap', 'registry.ts'), 'module.exports = {};\n'); + // The `paths` half: a TYPE-resolution directive over a specifier every + // command module imports. `chalk` is the carrier rather than a workspace + // package for the cross-package reason in the header — what matters is that + // the redirect is reachable from this CLI's own command modules. + writeFileSync( + join(fixtureCwd, 'tsconfig.json'), + `${JSON.stringify({ compilerOptions: { baseUrl: '.', paths: { chalk: ['./trap/index.ts'] } } }, null, 2)}\n`, + ); +}); + +afterAll(() => { + if (fixtureCwd) rmSync(fixtureCwd, { recursive: true, force: true }); +}); + +/** + * Run the published entry from the armed fixture directory. + * + * `--version` is the probe on purpose: it is the cheapest invocation that still + * makes oclif build its command table, which is where the reroute happens. + * Measured — every command tried (`--version`, `doctor --help`, `compile + * --help`) reproduces identically, so the cheapest one is the honest one. + * + * ⚠️ `TSX_TSCONFIG_PATH` is cleared explicitly. It is what `bin/run-dev.js` + * sets to pin tsx away from the CWD's tsconfig, and a developer running the + * suite under that shim would otherwise inherit the very mitigation this file + * is measuring the absence of. + */ +function runPublishedEntry( + env: Record, + options: { neutralise?: boolean } = {}, +) { + const result = spawnSync( + process.execPath, + [...(options.neutralise ? [`--import=${NEUTRALISER}`] : []), PUBLISHED_ENTRY, '--version'], + { + cwd: fixtureCwd, + encoding: 'utf8', + timeout: 120_000, + env: childEnv({ TSX_TSCONFIG_PATH: undefined, ...env }), + }, + ); + return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}` }; +} + +describe('#12271 - the published entry does not reroute to src/ on an ambient NODE_ENV', () => { + it('resolves commands from dist/ under an ambient NODE_ENV=development', () => { + const { status, output } = runPublishedEntry({ NODE_ENV: 'development' }); + expect(output).not.toContain(CARD_SIGNATURE); + expect(output).not.toContain(SOURCE_COMMANDS); + expect(status).toBe(0); + }); + + it('resolves commands from dist/ under an ambient NODE_ENV=test', () => { + const { status, output } = runPublishedEntry({ NODE_ENV: 'test' }); + expect(output).not.toContain(CARD_SIGNATURE); + expect(output).not.toContain(SOURCE_COMMANDS); + expect(status).toBe(0); + }); + + it('NODE_ENV=production stays green, the leg that was never broken', () => { + const { status, output } = runPublishedEntry({ NODE_ENV: 'production' }); + expect(output).not.toContain(CARD_SIGNATURE); + expect(status).toBe(0); + }); + + /** + * THE CONTROL. If this ever goes green the three assertions above have + * stopped measuring anything, and the correct response is to repair the + * fixture - never to delete this case because "the bug is fixed". + */ + it('CONTROL: neutralising the declaration in the child reproduces the card verbatim', () => { + const { status, output } = runPublishedEntry({ NODE_ENV: 'development' }, { neutralise: true }); + expect(output).toContain(CARD_SIGNATURE); + expect(output).toContain(SOURCE_COMMANDS); + expect(status).not.toBe(0); + }); + + /** + * The second half of the control: with the declaration neutralised, the only + * thing still standing between the fixture and the failure is `NODE_ENV`. So + * this pins that the variable really is the variable - `isProd()` and nothing + * else in the fixture is what makes the difference. + */ + it('CONTROL: neutralised under NODE_ENV=production, the same fixture is green', () => { + const { status, output } = runPublishedEntry({ NODE_ENV: 'production' }, { neutralise: true }); + expect(output).not.toContain(CARD_SIGNATURE); + expect(status).toBe(0); + }); +}); diff --git a/scripts/check-cli-test-child-env.mjs b/scripts/check-cli-test-child-env.mjs index a019ed2dbc..f146650964 100644 --- a/scripts/check-cli-test-child-env.mjs +++ b/scripts/check-cli-test-child-env.mjs @@ -522,6 +522,31 @@ export const DELIBERATE = { * Pinned in BOTH directions by {@link judge}: an entry that stops matching * FAILS, so if those legs are ever rewritten the gate says so rather than * carrying a carve-out nobody re-reads. + * + * ## ⚠️ #12271 changed what the FIRST bullet above is true OF, and the three + * entries it adds are a different KIND from the two above + * + * "keeping `bin/run.js` means oclif reroutes to `src/`" was a statement about + * the ENTRY, and it is no longer one: `packages/cli/bin/run.js` now declares + * `settings.enableAutoTranspile = false`, which `@oclif/core`'s `tsPath()` + * checks AHEAD of `isProd()`, so the published entry resolves `dist/commands` + * under every value of `NODE_ENV`. ⛔ The oclif table quoted in this file's + * header and failure text is untouched and still correct -- it was measured + * against `Config.load()` with DEFAULT settings, which is what any other + * spawner of a built oclif entry still gets. + * + * ⇒ What this rule now buys is narrower and worth stating rather than leaving + * for someone to discover while deleting it: it keeps a built-entrypoint spawn + * READABLE about the `NODE_ENV` it means, and it is the thing that would notice + * if that declaration were ever dropped from `bin/run.js` -- every spawner in + * the population would start rerouting again, silently, exactly as before. + * + * ⭐ The `published-entry-node-env-source-reroute.test.ts` entries are + * therefore NOT "the reroute is the point" like the two above. They are the + * opposite: that file hands the built entry a `development` / `test` child + * precisely to assert the reroute does NOT happen, which is an input this rule + * cannot distinguish from the hazard by reading the call. ⛔ Do not read them as + * precedent for a spawner that wants `src/`; that is still `bin/run-dev.js`. */ export const DELIBERATE_REROUTE = { 'packages/cli/test/serve-node-env-production-default.e2e.test.ts::it("NODE_ENV=development (explicit): the gate stays OPEN \u2014 unaffected by the production default")': { @@ -530,6 +555,15 @@ export const DELIBERATE_REROUTE = { 'packages/cli/test/serve-node-env-production-default.e2e.test.ts::it("NODE_ENV=test (explicit): the gate stays OPEN \u2014 unaffected by the production default")': { why: 'The sibling leg, same argument, one value over. Keyed separately on purpose -- one entry may silence one site, which is what #12531 made possible.', }, + 'packages/cli/test/published-entry-node-env-source-reroute.test.ts::it("resolves commands from dist/ under an ambient NODE_ENV=development")': { + why: 'The INVERSE of the two entries above, and #12271 is why it can exist: this leg hands the built entry a development child in order to assert it resolves dist/ ANYWAY, which is what bin/run.js\'s settings.enableAutoTranspile = false now guarantees. The ambient NODE_ENV is the defect\'s input, so it cannot be scrubbed without deleting the regression; and bin/run-dev.js is the wrong entry by construction -- it is the one that is supposed to reach src/.', + }, + 'packages/cli/test/published-entry-node-env-source-reroute.test.ts::it("resolves commands from dist/ under an ambient NODE_ENV=test")': { + why: 'The sibling leg, one value over. Keyed separately for the #12531 reason the pair above records. test is oclif\'s second non-production value and was never measured before #12271; vitest exporting it on its own worker is exactly the silent inheritance this gate\'s rule-3 header warns about.', + }, + 'packages/cli/test/published-entry-node-env-source-reroute.test.ts::it("CONTROL: neutralising the declaration in the child reproduces the card verbatim")': { + why: 'The control that must FIRE. Every other assertion in that file is an ABSENCE, so one leg has to defeat the declaration in the child -- via an --import preload, never an edit to bin/run.js on disk -- and watch the card reproduce verbatim. Deleting this entry means deleting the only leg that proves the sibling legs are measuring anything.', + }, }; // --------------------------------------------------------------------------- @@ -2601,8 +2635,15 @@ export function selfTest() { .filter((abs) => builtEntrypointSpawns(abs, readFileSync(abs, 'utf8')).spawns > 0) .map((abs) => relative(REPO_ROOT, abs).split(sep).join('/')) .sort(); - t('the built-entrypoint population is exactly the five files that spawn bin/run.js', + t('the built-entrypoint population is exactly the six files that spawn bin/run.js', JSON.stringify(builtFiles) === JSON.stringify([ + // [#12271] Joined the population deliberately, and it is the one member + // whose subject is the ENTRY rather than `serve`: it spawns `bin/run.js` + // with an ambient `development` / `test` child to assert that the + // published entry resolves `dist/commands` anyway. See its three + // DELIBERATE_REROUTE entries for why that input cannot be scrubbed and + // why `bin/run-dev.js` is the wrong entry for it. + 'packages/cli/test/published-entry-node-env-source-reroute.test.ts', 'packages/cli/test/serve-mcp-capability-collision.e2e.test.ts', 'packages/cli/test/serve-mcp-stdio-answers.e2e.test.ts', 'packages/cli/test/serve-node-env-production-default.e2e.test.ts',