Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -148,3 +148,7 @@ examples/app-crm/storage/
# committed (#22449 B′, #22485); `files[]` ships whatever the lane wrote.
packages/spec/spec-changes.json
packages/spec/protocol-upgrade-guide.md

# #23181 slice 14: written by packages/cli/test/helpers/oclif-manifest.global-setup.ts for a test run and removed at its teardown; never committed, never published
packages/cli/oclif.manifest.json
packages/cli/oclif.manifest.json.*.tmp
156 changes: 156 additions & 0 deletions packages/cli/test/helpers/oclif-manifest.global-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The vitest `globalSetup` that writes `packages/cli/oclif.manifest.json` once
* per run, from source, and removes it at teardown. Test infrastructure only:
* the file is gitignored and `files[]` (`dist`, `README.md`, `CHANGELOG.md`)
* never names it, so nothing published changes.
*
* ## Why (#23181, seat ruling A on report 6418998910)
*
* This package ships no oclif manifest, so every CLI process builds its command
* table by importing all 72 command modules. Through `tsx bin/run-dev.js` that
* is a floor of 8.2-8.5 s per spawn, `--version` included, and the files that
* spawn the CLI held 89.4% of this package's file wall. With a manifest beside
* `package.json` oclif reads the table from it and imports only the command it
* runs: the same spawn takes 2.0-2.3 s.
*
* ## What is written, and why it is transparent
*
* The manifest is the object oclif's own root plugin builds in memory when no
* manifest exists — `new Plugin({ isRoot: true, root })`, loaded in a `tsx`
* child under `NODE_ENV=development`, which is what `bin/run-dev.js` runs — with
* two keys removed from every command: `isESM` and `relativePath`.
*
* ⛔ Those two keys are not optional to remove. When a cached command carries
* both, `Plugin.findCommand` imports `root + relativePath` VERBATIM
* (`cachedCommandCanBeUsed` in @oclif/core 5.1.2), and a manifest generated from
* source records `src/commands/<id>.ts`. The built entry (`bin/run.js`, which
* the published-entry suites spawn with `NODE_ENV` unset) would then run the
* SOURCE, and those suites would test something other than the build. Without
* the two keys oclif falls back to the path it takes with no manifest at all —
* the commands directory `tsPath` resolves, `src/` under `run-dev.js` and
* `dist/` under `run.js` — so the manifest supplies the command TABLE and
* nothing about where a command's code comes from.
*
* What still differs from a run without the file: `Config.load()` no longer
* imports every command module, so a module that fails to load is reported when
* it is run rather than as a load-time warning on every spawn. The suites that
* pin that load-time behaviour are `*.e2e.test.ts` files, and the e2e tier runs
* only in the nightly population, which `vitest.config.ts` leaves without this
* setup. A command module that fails to load here fails the GENERATION instead
* (`errorOnManifestCreate`), naming the module, before any test runs.
*
* ## Ownership, nesting and cleanup
*
* - Written to a temporary sibling and renamed over the target, so a spawn
* reads either a whole manifest or none (oclif then builds its table as
* before). Every run regenerates, so a manifest left by a killed run is never
* what a later integration run uses.
* - `writtenBy.pid` records the vitest process that owns the file. Teardown, and
* an `exit` listener for a run that ends without teardown, remove it only
* while that pid is still this process's: two runs on one checkout each
* remove their own file, and the run that is left continues without a
* manifest, at the old floor and with the same output.
* - A vitest started INSIDE a run (`test/vitest-tiers-partition.test.ts` spawns
* `vitest list`) inherits {@link OWNER_ENV} through `childEnv()` and does
* nothing, so it neither pays a second generation nor removes the file under
* the run that wrote it.
* - A `unit`-only run generates nothing (no unit file spawns the CLI) and only
* removes a manifest whose writer is no longer alive, so an in-process
* `Config.load()` there never trusts a file a killed run left behind.
*/

import { execFile } from 'node:child_process';
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { promisify } from 'node:util';
import type { TestProject } from 'vitest/node';
import { childEnv, TSX } from './serve-process.js';

const HERE = resolve(fileURLToPath(import.meta.url), '..');

/** `packages/cli` — the root oclif reads `oclif.manifest.json` from. */
const CLI_ROOT = resolve(HERE, '../..');

export const MANIFEST = resolve(CLI_ROOT, 'oclif.manifest.json');

/** Set in the owning run's process, and so in every worker and child below it. */
export const OWNER_ENV = 'OS_TEST_OCLIF_MANIFEST_OWNER';

/** The argv flag that makes this module the generator, inside the `tsx` child. */
const WRITE_FLAG = '--write-oclif-manifest';

const THIS_FILE = fileURLToPath(import.meta.url);

/** The pid recorded in the manifest at `path`, or `undefined` for none or not ours. */
function writerOf(path: string): number | undefined {
try {
const pid = JSON.parse(readFileSync(path, 'utf8'))?.writtenBy?.pid;
return Number.isInteger(pid) ? pid : undefined;
} catch {
return undefined;
}
}

function isAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM';
}
}

function removeIfOurs(): void {
if (writerOf(MANIFEST) === process.pid) rmSync(MANIFEST, { force: true });
}

/** Runs in the `tsx` child: build the table the way an unmanifested run does. */
async function writeManifest(out: string, ownerPid: number): Promise<void> {
const { Plugin } = await import('@oclif/core');
const plugin = new Plugin({ isRoot: true, root: CLI_ROOT, ignoreManifest: true, errorOnManifestCreate: true });
await plugin.load();
const commands = Object.fromEntries(
Object.entries(plugin.manifest.commands).map(([id, { isESM: _isESM, relativePath: _relativePath, ...cached }]) => [
id,
cached,
]),
);
writeFileSync(out, JSON.stringify({ ...plugin.manifest, commands, writtenBy: { pid: ownerPid, by: 'packages/cli/test/helpers/oclif-manifest.global-setup.ts' } }));
}

export default async function setup(project: TestProject): Promise<(() => void) | void> {
if (process.env[OWNER_ENV]) return;

if (project.name !== 'integration') {
const writer = writerOf(MANIFEST);
if (writer !== undefined && !isAlive(writer)) rmSync(MANIFEST, { force: true });
return;
}

const tmp = `${MANIFEST}.${process.pid}.tmp`;
try {
await promisify(execFile)(TSX, [THIS_FILE, WRITE_FLAG, tmp, String(process.pid)], {
cwd: CLI_ROOT,
env: childEnv({ NODE_ENV: 'development' }),
maxBuffer: 64 * 1024 * 1024,
});
} catch (error) {
rmSync(tmp, { force: true });
const { stderr } = error as { stderr?: string };
throw new Error(`could not write ${MANIFEST} from source:\n${stderr || String(error)}`);
}
renameSync(tmp, MANIFEST);
process.env[OWNER_ENV] = String(process.pid);
process.once('exit', removeIfOurs);
return () => {
process.off('exit', removeIfOurs);
removeIfOurs();
};
}

if (process.argv[1] === THIS_FILE && process.argv[2] === WRITE_FLAG) {
await writeManifest(process.argv[3], Number(process.argv[4]));
}
34 changes: 25 additions & 9 deletions packages/cli/test/published-entry-node-env-source-reroute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,8 @@ const CHILD_BUDGET_MS = 120_000;
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`.
* The reroute itself, visible in oclif's own report: 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');

Expand Down Expand Up @@ -170,10 +170,26 @@ afterAll(() => {
/**
* 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.
* `explain object` is the probe because it RUNS a command: oclif has to resolve
* that command's module and import it, which is where the reroute happens, and
* `explain.ts` imports the trapped `chalk` at its top. It prints a static
* reference page and exits 0 with nothing to read from the fixture directory.
*
* ⛔ Not `--version`, which this file used until #23181. `--version` loads no
* command; it reached the reroute only because, with no `oclif.manifest.json`
* beside `package.json`, `Config.load()` builds the command table by importing
* every command module. The `integration` project now writes that manifest for
* the run (`helpers/oclif-manifest.global-setup.ts`), and with it present
* `--version` imports nothing: this leg's CONTROL stopped reproducing the card
* and the three legs above it passed over a fixture that armed nothing. A real
* command reaches the reroute with the manifest and without it, measured both
* ways at the slice that made this change.
*
* `OCLIF_COLUMNS` is set wide so oclif renders the load failure on one line.
* With a manifest the failure arrives as the error `handle()` prints, which is
* word-wrapped at 80 columns on a pipe, and a wrap can fall inside the card's
* signature or a path. A split signature fails the CONTROL; worse, it would
* let every `not.toContain` above pass over text it can no longer match.
*
* ⚠️ `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
Expand Down Expand Up @@ -202,12 +218,12 @@ function runPublishedEntry(
const startedAt = Date.now();
const result = spawnSync(
process.execPath,
[...(options.neutralise ? [`--import=${NEUTRALISER}`] : []), PUBLISHED_ENTRY, '--version'],
[...(options.neutralise ? [`--import=${NEUTRALISER}`] : []), PUBLISHED_ENTRY, 'explain', 'object'],
{
cwd: fixtureCwd,
encoding: 'utf8',
timeout: CHILD_BUDGET_MS,
env: childEnv({ TSX_TSCONFIG_PATH: undefined, ...env }),
env: childEnv({ TSX_TSCONFIG_PATH: undefined, OCLIF_COLUMNS: '100000', ...env }),
},
);
const elapsedMs = Date.now() - startedAt;
Expand Down Expand Up @@ -257,7 +273,7 @@ describe('#12271 - the published entry does not reroute to src/ on an ambient NO
* ⚠️ It arms only if oclif can REGISTER tsx (`registerTsx` in `ts-path`).
* When that fails, oclif logs `Could not find tsx` under
* `DEBUG=oclif:config:ts-path` and stays on `dist/`. This leg then prints
* the version line and nothing else, and the legs above pass with the
* the reference page and nothing else, and the legs above pass with the
* declaration deleted. tsx 4.23.15's CommonJS ESM API fails that way on any
* Node without tsx's `registerHooks` path (below 22.22.3 on the 22 line).
* That is why the workspace patches it: see `tsx@4.23.15` under
Expand Down
22 changes: 22 additions & 0 deletions packages/cli/vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,7 @@ import {
runProjectCliOverridePreflight,
} from '../qa/vitest-filter-preflight/src/index.js';
import { integrationTestFiles, unitTestFiles } from './vitest-tiers.js';
import { readTierMode } from '../../scripts/nightly-tiers.mjs';

// The two tiers, DERIVED from what the files DO — never written down — over
// the population `OS_TEST_TIERS` selects. `vitest-tiers.ts` holds the
Expand Down Expand Up @@ -739,6 +740,25 @@ runProjectCliOverridePreflight({
// lives at its use site in the `test` block below, not in a helper binding
// that could outlive the spread.

// #23181 slice 14 — THE OCLIF MANIFEST SETUP. One `globalSetup` module writes
// `oclif.manifest.json` from source when the `integration` project runs (the
// tier every CLI-spawning file is derived into) and removes it at teardown, so
// a `tsx bin/run-dev.js` spawn reads its command table instead of importing all
// 72 command modules: 8.2-8.5 s -> 2.0-2.3 s per spawn, same bytes out, same
// exit code. The `unit` project carries the same module and generates nothing;
// it only clears a manifest a killed run left behind. The module's header is
// the authority on what is written, why it is transparent, and who removes it.
//
// ⛔ The QUEUE population only. Under `OS_TEST_TIERS=nightly` both projects run
// without it, because the e2e tier holds the suites that pin what a manifest
// would change: oclif importing every command module inside `Config.load()`
// (`run-dev-unbuilt-workspace.e2e`, the `*-stderr-nonblocking.e2e` pair). The
// product ships no manifest, so that is the shape those pins must keep seeing.
// Set per project rather than at the root, where `extends: true` would hand it
// to both projects AND to the root project, running it three times.
const OCLIF_MANIFEST_SETUP =
readTierMode() === 'queue' ? [path.resolve(__dirname, 'test/helpers/oclif-manifest.global-setup.ts')] : [];

export default defineConfig({
resolve: {
// Array form with an ANCHORED pattern, per the trap the gate documents:
Expand Down Expand Up @@ -873,6 +893,7 @@ export default defineConfig({
test: {
name: 'unit',
disableConsoleIntercept: true,
globalSetup: OCLIF_MANIFEST_SETUP,
// #13517: quiet the registry's per-item registration chatter — the
// engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped
// default. Enforced by scripts/check-registry-log-declared.mjs.
Expand All @@ -885,6 +906,7 @@ export default defineConfig({
test: {
name: 'integration',
disableConsoleIntercept: true,
globalSetup: OCLIF_MANIFEST_SETUP,
// #13517: quiet the registry's per-item registration chatter — the
// engine's own `OS_REGISTRY_LOG` seam, not a change to its shipped
// default. Enforced by scripts/check-registry-log-declared.mjs.
Expand Down
Loading