diff --git a/.changeset/run-dev-unbuilt-workspace-lead.md b/.changeset/run-dev-unbuilt-workspace-lead.md new file mode 100644 index 0000000000..8c818804a6 --- /dev/null +++ b/.changeset/run-dev-unbuilt-workspace-lead.md @@ -0,0 +1,36 @@ +--- +"@objectstack/cli": patch +--- + +fix(cli): name the missing build output instead of reporting "command not found" (#12964) + +In a checkout where a workspace dependency has no `dist/`, `@oclif/core` `import()`s +every command module while it builds its manifest, every one of them fails, and the run +ends on + +``` +Error: command i18n:extract:… not found +``` + +with exit 2 — while the command file is right there in `src/commands/`. A command whose +module will not load is indistinguishable, to `Config.runCommand`, from one that does not +exist, so the only cause the reader is handed is the one cause that is definitely not +true. + +`packages/cli/bin/run-dev.js` — this repo's SOURCE entry point, run through `tsx` by its +own gates and e2e suites, and not part of the published package — now collects oclif's +module-load warnings and, when that failure was caused by a package this repo builds, +prints the attribution and the single command that fixes it ahead of oclif's report: + +``` +objectstack: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to +LOAD as "not found", and one did: Cannot find module '…/@objectstack/spec/dist/index.mjs'. +The unmet precondition is @objectstack/spec's build output, not the invocation. +objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec +``` + +Both the classification and the remedy come from `scripts/cli-build-prerequisite.mjs`, the +module that already answers this question for the gates that shell out to the CLI, so +there is no second verdict to keep in sync. Nothing is added to a run that succeeds, and a +command that really is missing keeps oclif's reporting exactly as it was — the diagnosis +requires BOTH oclif's "not found" and a module-load failure naming a workspace package. diff --git a/packages/cli/bin/run-dev.js b/packages/cli/bin/run-dev.js index 033f8998e4..6f36f9353b 100644 --- a/packages/cli/bin/run-dev.js +++ b/packages/cli/bin/run-dev.js @@ -25,15 +25,77 @@ async function announceInvocationFailure(error) { } } +/** + * Every module-load failure oclif reported while building its command table + * (#12964), in emission order. Filled by the listener attached below. + * + * It HAS to be collected as it happens. `findCommand` `import()`s every command + * module while `Config.load()` runs, warns on each one that will not load, and + * then throws a plain "command … not found" that carries none of it — so by the + * time the `.catch()` below holds the error, the only cause worth naming has + * already gone past. `warning.detail` is where oclif puts the failing specifier. + */ +const moduleLoadFailures = []; + +/** + * The other reading of "command … not found": the command is there and its + * MODULE would not load, because a workspace package this repo builds has no + * usable `dist/`. See `scripts/cli-unbuilt-workspace-lead.mjs` for the whole + * argument, including why the CLI's name is passed IN rather than imported + * there. + * + * Lazy and `catch`-wrapped for the same reason as `announceInvocationFailure`: + * a reporter that throws must never become the report. + */ +async function announceUnbuiltWorkspace(error) { + try { + const [{ unbuiltWorkspaceLines }, { INVOCATION_PREFIX }] = await Promise.all([ + import('../../../scripts/cli-unbuilt-workspace-lead.mjs'), + import('../src/utils/invocation.ts'), + ]); + for (const line of unbuiltWorkspaceLines(error, moduleLoadFailures, INVOCATION_PREFIX) ?? []) { + process.stderr.write(`${line}\n`); + } + } catch { + // Stay quiet rather than replacing oclif's report with an error about the + // reporter itself. + } +} + process.env.NODE_ENV = 'development'; settings.debug = true; -await run(process.argv.slice(2), import.meta.url) +const running = run(process.argv.slice(2), import.meta.url); + +// ⚠️ ATTACHED AFTER `run()`, and that order is load-bearing rather than style. +// @oclif/core installs a `warning` listener of its own — `displayWarnings()` in +// `config/config.js`, which is what prints the `Warning: ModuleLoadError` stack +// plus `detail` under `settings.debug` — but it installs it ONLY when +// `process.listenerCount('warning') <= 1`, i.e. only node's own default is +// attached. A collector attached before `run()` makes that count 2, oclif +// silently declines to install, and every failing run through this shim quietly +// loses those blocks (measured on the #12964 repro: 1518 lines of report became +// 476, with nothing saying why). +// +// `run()` reaches `Config.load()` — and `displayWarnings()` inside it — in its +// SYNCHRONOUS prefix (`main.js`: `await Config.load(...)` is its first `await`; +// `config.js`: `displayWarnings()` precedes `load()`'s first `await`), and +// `process.emitWarning` defers to `nextTick`, so a listener attached here is +// installed second and still sees every warning. `run-dev-unbuilt-workspace.e2e` +// asserts oclif's blocks are still there, so a future oclif that moves that call +// past an `await` fails a test instead of going quiet. +process.on('warning', (warning) => { + const detail = warning?.detail; + if (typeof detail === 'string' && detail) moduleLoadFailures.push(detail); +}); + +await running .then(async (result) => { flush(); return result; }) .catch(async (error) => { await announceInvocationFailure(error); + await announceUnbuiltWorkspace(error); return handle(error); }); diff --git a/packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs b/packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs new file mode 100644 index 0000000000..3572f67423 --- /dev/null +++ b/packages/cli/test/fixtures/unbuilt-spec-dist.hook.mjs @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A BUILT checkout, made to answer like an unbuilt one for `@objectstack/spec` + * and nothing else — the environment `run-dev-unbuilt-workspace.e2e.test.ts` + * needs and CI cannot otherwise have (#12964). + * + * Loaded with `node --import`, so it is in place before `@oclif/core` walks the + * command directory. It is a `resolve` hook and NOT a file operation on purpose: + * this repo is worked by several agents in one container at a time, and a test + * that renamed `packages/spec/dist` for a few seconds would break every other + * run in the box. Nothing here touches the disk. + * + * ## Why it re-points the specifier instead of throwing + * + * The classifier this feeds (`looksLikeStaleWorkspaceDist`) reads node's OWN + * sentence, so the sentence has to be node's. Two shapes were measured before + * this one was kept: + * + * - `{ url, shortCircuit: true }` at a non-existent URL skips + * `finalizeResolution`, so the failure surfaces from the LOAD step as + * `ENOENT: no such file or directory, open '…'`. That is not the corpus and + * the classifier correctly declines it — a green run that proves nothing. + * - throwing a hand-built `ERR_MODULE_NOT_FOUND` would make the test assert + * against a string this file authored, which is the one thing a fixture for + * a text classifier must not do. + * + * Handing `nextResolve` an ABSOLUTE PATH that does not exist runs node's real + * resolution against it, and node produces its real + * `Cannot find module '…' imported from …`. + * + * ## Why the path is spelled through `packages/cli/node_modules` + * + * That is where an unbuilt tree's sentence points, and it is not cosmetic: pnpm + * symlinks the workspace package in, and node only reports the pre-realpath + * spelling when resolution FAILS (a successful resolve reports + * `packages/spec/dist/index.mjs`, with no `@objectstack` in it at all). The + * classifier keys on `node_modules/@objectstack/` — deliberately, so it + * never diagnoses a third party — so a realpath spelling would classify as + * nothing and this fixture would silently stop simulating anything. + */ + +import { registerHooks } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** This file lives in `packages/cli/test/fixtures`, so `packages/cli` is two up. */ +const CLI_PKG = resolve(dirname(fileURLToPath(import.meta.url)), '../..'); + +/** + * Where an unbuilt `@objectstack/spec` is looked for. The last segment is + * deliberately not a real one — `dist/` itself is present in a built checkout, + * and the whole point is a path that is missing. + */ +const UNBUILT_TARGET = resolve(CLI_PKG, 'node_modules/@objectstack/spec/dist/__unbuilt-simulation__/index.mjs'); + +/** Every `@objectstack/spec` subpath, so the simulation is not one entry deep. */ +const DENIED = '@objectstack/spec'; + +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier === DENIED || specifier.startsWith(`${DENIED}/`)) { + return nextResolve(UNBUILT_TARGET, context); + } + return nextResolve(specifier, context); + }, +}); diff --git a/packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts b/packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts new file mode 100644 index 0000000000..6c33c48244 --- /dev/null +++ b/packages/cli/test/run-dev-unbuilt-workspace.e2e.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The WIRING half of #12964 — `bin/run-dev.js` really asks, on a real failing + * run, whether "command … not found" is about a missing command at all. + * + * ``` + * $ pnpm i18n:extract # fresh worktree, pnpm install done, nothing built + * … + * Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found + * $ echo $? + * 2 + * ``` + * + * The command file is right there in `src/commands/i18n/extract.ts`. oclif + * `import()`s every command module while it builds its manifest, all of them + * failed on a `@objectstack/spec` that had no `dist/`, and a command whose + * module will not load is indistinguishable to `Config.runCommand` from one + * that does not exist. + * + * ## Why this suite is spawned, and why it simulates + * + * The lead line is produced from a `process.on('warning')` collector installed + * around `run()` — state that exists only inside a real CLI process, so an + * in-process test cannot see it and `process.exit`-adjacent behaviour cannot be + * asserted from a vitest worker at all. + * + * And CI's checkout is BUILT. ⚠️ That is the trap this file is written against: + * an "unbuilt tree" test that runs in a built tree never enters the branch it + * claims to cover, prints nothing, asserts nothing failed, and reads green + * forever. So the unbuilt condition is MANUFACTURED for one child process + * (`fixtures/unbuilt-spec-dist.hook.mjs`, a `--import` resolve hook that touches + * no disk), and the three cases below are a POSITIVE CONTROL PAIR rather than a + * single assertion: + * + * 1. hook on, real command id → the lead lines appear; + * 2. hook off, THE SAME command id → the command module loads and runs, so + * the run never reaches that branch at all; + * 3. hook off, a command that really is missing → oclif's "not found" stands + * exactly as it did, with nothing added. + * + * (1) without (2) would pass in a tree where every run happens to be diagnosed; + * (2) and (3) without (1) are two zero readings. Together they say the branch is + * reachable, is not always taken, and is taken for the right reason. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +/** The SOURCE entry point — this suite is about it, and it needs no `dist/`. */ +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); +const UNBUILT_HOOK = pathToFileURL(resolve(HERE, 'fixtures/unbuilt-spec-dist.hook.mjs')).href; + +/** + * A REAL command id, so "not found" is a lie rather than the truth. Its + * argument names nothing: case 2 has to fail for its own reason (no config + * file) instead of doing work, and the point there is only WHICH failure. + */ +const REAL_COMMAND = ['i18n', 'extract', 'nope.ts']; + +/** oclif + tsx cold start, plus ~58 failing command imports in case 1. */ +const RUN_TIMEOUT_MS = 180_000; + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +/** + * `NODE_OPTIONS` is stated on every call, in both directions. `childEnv()` + * strips the vitest-worker family and `NODE_PATH`, but not this one, so a + * control leg that said nothing would silently inherit whatever the runner was + * started with — and the control legs' whole job is to be un-simulated. + */ +function runCli(args: string[], cwd: string, nodeOptions: string | undefined): Promise { + return new Promise((resolvePromise) => { + execFile(TSX, [CLI, ...args], { cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1', NODE_OPTIONS: nodeOptions }) }, (err, stdout, stderr) => { + resolvePromise({ + // `err.code` is the real exit status; `null`/undefined means the child + // was signalled — a failure of a different kind, never reported as 0. + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }); + }); +} + +/** The sentence this change exists to contradict. */ +const LEAD = 'objectstack: NOT A MISSING COMMAND'; +const FIX = 'objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec'; + +let dir: string; +let unbuilt: Run; +let built: Run; +let genuinelyMissing: Run; + +beforeAll(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-run-dev-unbuilt-')); + unbuilt = await runCli(REAL_COMMAND, dir, `--import ${UNBUILT_HOOK}`); + built = await runCli(REAL_COMMAND, dir, undefined); + genuinelyMissing = await runCli(['definitely-not-a-command'], dir, undefined); +}, RUN_TIMEOUT_MS * 3); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('run-dev.js on a workspace package with no build output', () => { + it('reproduces the misdiagnosis it is fixing — oclif still says "not found"', () => { + // The upstream line is deliberately NOT suppressed: nothing here changes + // which arguments the CLI accepts or how oclif reports, only what is said + // alongside. Asserting it also proves case 1 really reached that failure + // rather than dying earlier for some unrelated reason. + expect(unbuilt.stderr).toContain('Error: command i18n:extract:nope.ts not found'); + expect(unbuilt.code).toBe(2); + }); + + it('names the real cause and the one command that fixes it', () => { + expect(unbuilt.stderr).toContain(LEAD); + expect(unbuilt.stderr).toContain('@objectstack/spec'); + expect(unbuilt.stderr).toContain(FIX); + }); + + it('keeps the oclif debug warning blocks — the listener order is load-bearing', () => { + // @oclif/core installs its `warning` listener only when + // `process.listenerCount('warning') <= 1`. A collector attached BEFORE + // `run()` makes that 2, oclif silently declines, and every failing run + // through this shim loses these blocks with nothing saying why (measured: + // 1518 lines of report became 476). `at Plugin.warn` is that listener's + // output, so this case reds if the attachment ever moves back. + expect(unbuilt.stderr).toContain('at Plugin.warn'); + }); +}); + +describe('the same probe, un-simulated (positive control)', () => { + it('takes the other branch entirely: the command module loads and runs', () => { + // Not "no lead line" alone — that is a zero reading. The command REACHED + // its own argument handling, which is only possible if its module loaded. + expect(`${built.stdout}${built.stderr}`).toContain('Config file not found'); + expect(built.stderr).not.toContain('Error: command'); + expect(built.stderr).not.toContain(LEAD); + expect(built.code).toBe(1); + }); + + it('leaves a command that really is missing exactly as it was', () => { + expect(genuinelyMissing.stderr).toContain('Error: command definitely-not-a-command not found'); + expect(genuinelyMissing.stderr).not.toContain(LEAD); + expect(genuinelyMissing.stderr).not.toContain('objectstack: Fix:'); + expect(genuinelyMissing.code).toBe(2); + }); +}); diff --git a/packages/cli/test/unbuilt-workspace-lead.test.ts b/packages/cli/test/unbuilt-workspace-lead.test.ts new file mode 100644 index 0000000000..44005cc38a --- /dev/null +++ b/packages/cli/test/unbuilt-workspace-lead.test.ts @@ -0,0 +1,136 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The DECISION half of #12964 — when oclif's "command … not found" is really a + * dependency that has no build output, and when it is genuinely a missing + * command and must be left alone. + * + * ## Where this corpus comes from + * + * Both fixtures below are TRANSCRIPT, not invention. They were read off a real + * run at `8cb96ec41`, in a worktree created with `git worktree add` + `pnpm + * install` and NOTHING built: + * + * $ pnpm i18n:extract # tsx packages/cli/bin/run-dev.js i18n extract … + * …58 ModuleLoadError warnings… + * Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found + * $ echo $? + * 2 + * + * The error object itself was probed in the same tree: `constructor.name` + * `CLIError`, `name` `'Error'`, `oclif.exit` 2 — so `String(error)` is the + * `Error: command … not found` spelling asserted here — and, decisively, own + * properties `['code','oclif','skipOclifErrorHandling','suggestions']`, with + * NEITHER `parse` nor `showHelp`. That is why `invocationFailureLine` (whose + * `isInvocationError` requires both) answers `undefined` for this failure and + * why it could not be the place this lands. + * + * ⚠️ This file pins the decision only. Whether `bin/run-dev.js` actually asks + * the question and prints the answer is a different fact with its own test — + * `run-dev-unbuilt-workspace.e2e.test.ts` drives the real binary, and deleting + * the wiring reds THAT one, not this one. + * + * ## What this suite's verdict is really a function of + * + * Three files outside this package, all declared in + * `scripts/cross-package-test-inputs.mjs` so a change to any of them re-runs + * this suite: + * + * - `scripts/cli-unbuilt-workspace-lead.mjs` — the module imported below. + * - `scripts/cli-build-prerequisite.mjs` — where the module delegates BOTH + * halves of its answer. `looksLikeStaleWorkspaceDist` decides whether there + * is anything to say, and `workspaceBuildFix` renders the remedy this file + * asserts character for character, so that module can move these + * expectations without either file above it changing. + * - `scripts/cli-unbuilt-workspace-lead.d.mts` — the hand-written declaration + * that lets a `.ts` file import an untyped `.mjs`. Without it this import is + * TS7016, and this file sits in `@objectstack/cli`'s ledgered hidden test + * layer, whose entry says the first new error in it goes red rather than + * being absorbed. `check:declaration-mirrors` keeps it in step with the + * module; it asserts name, kind and required arity, never types. + */ + +import { describe, it, expect } from 'vitest'; +import { unbuiltWorkspaceLines } from '../../../scripts/cli-unbuilt-workspace-lead.mjs'; +import { INVOCATION_PREFIX } from '../src/utils/invocation.js'; + +/** + * `warning.detail` of the first of the 58 `ModuleLoadError` warnings the + * measured run emitted, verbatim. + */ +const MEASURED_DETAIL = [ + 'module: @oclif/core@4.13.3', + 'task: findCommand (compile)', + 'plugin: @objectstack/cli', + 'root: /home/user/objectstack-12964/packages/cli', + 'code: MODULE_NOT_FOUND', + "message: [MODULE_NOT_FOUND] import() failed to load /home/user/objectstack-12964/packages/cli/src/commands/compile.ts: Cannot find module '/home/user/objectstack-12964/packages/cli/node_modules/@objectstack/spec/dist/index.mjs' imported from /home/user/objectstack-12964/packages/cli/src/commands/compile.ts", + 'See more details with DEBUG=*', +].join('\n'); + +/** The measured `CLIError`, reproduced through the property the code reads. */ +const notFound = () => new Error('command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found'); + +describe('unbuiltWorkspaceLines', () => { + it('names the package whose build output is missing, and the one command that fixes it', () => { + const lines = unbuiltWorkspaceLines(notFound(), [MEASURED_DETAIL], INVOCATION_PREFIX); + + expect(lines).toBeDefined(); + expect(lines).toHaveLength(2); + // The whole point of the card: the line must contradict "not found" and + // attribute the failure, rather than restate it. + expect(lines?.[0]).toContain('NOT A MISSING COMMAND'); + expect(lines?.[0]).toContain('@objectstack/spec'); + expect(lines?.[0]).toContain("Cannot find module '/home/user/objectstack-12964/packages/cli/node_modules/@objectstack/spec/dist/index.mjs'"); + // The remedy is `workspaceBuildFix`'s, spelled out here because this string + // is what a reader is told to type — a change to it is a change to them. + expect(lines?.[1]).toBe('objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec'); + }); + + it('leaves a genuinely missing command alone — nothing failed to load', () => { + // `os frobnicate` in a tree whose commands all load: oclif emits no + // module-load warning, so there is nothing to classify and nothing to say. + expect(unbuiltWorkspaceLines(new Error('command frobnicate not found'), [], INVOCATION_PREFIX)).toBeUndefined(); + }); + + it('says nothing when the module that failed belongs to somebody else', () => { + // A third party's missing module is not a build this repo can prescribe. + // The two words are chosen so neither is a substring of the other: a + // `lodash` specifier must not be read as an `@objectstack` one. + const thirdParty = "message: [MODULE_NOT_FOUND] import() failed to load /repo/packages/cli/src/commands/x.ts: Cannot find module 'lodash/merge.js' imported from /repo/packages/cli/src/commands/x.ts"; + expect(unbuiltWorkspaceLines(notFound(), [thirdParty], INVOCATION_PREFIX)).toBeUndefined(); + }); + + it('says nothing when the failure was not oclif reporting a missing command', () => { + // A parse error, and a genuine runtime error, both keep oclif's reporting + // exactly as it was even in a tree that really is unbuilt. + expect(unbuiltWorkspaceLines(new Error('Nonexistent flag: --no-ui'), [MEASURED_DETAIL], INVOCATION_PREFIX)).toBeUndefined(); + expect(unbuiltWorkspaceLines(new Error('ENOENT: no such file or directory'), [MEASURED_DETAIL], INVOCATION_PREFIX)).toBeUndefined(); + }); + + it('survives oclif hard-wrapping the sentence it has to recognise', () => { + // oclif wraps that one sentence across ` › `-prefixed lines at a width that + // depends on the argument, sometimes mid-token. A per-line regex matches + // neither shape; `looksLikeMissingCliCommand` flattens first, and this case + // is what keeps this file honest about depending on that. + const wrapped = ' › Error: command \n › i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not \n › found'; + expect(unbuiltWorkspaceLines(wrapped, [MEASURED_DETAIL], INVOCATION_PREFIX)?.[1]).toBe('objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec'); + }); + + it('covers the STALE dist as well as the missing one, with the same fix', () => { + // #7681's other half: the dist exists and predates the export the source + // added. Same environment fact, same one command. + const stale = + "message: import() failed to load /repo/packages/cli/src/commands/lint.ts: The requested module '@objectstack/spec/system' does not provide an export named 'authorisesIrreversibleAction'"; + const lines = unbuiltWorkspaceLines(notFound(), [stale], INVOCATION_PREFIX); + expect(lines?.[0]).toContain('does not provide an export named'); + expect(lines?.[1]).toBe('objectstack: Fix: pnpm exec turbo run build --filter=@objectstack/spec'); + }); + + it('reaches past a leading failure it has no standing to diagnose', () => { + // Emission order is `Promise.all` order over the command modules, so the + // classifiable warning is not reliably first. + const thirdParty = "Cannot find module 'lodash/merge.js' imported from /repo/packages/cli/src/commands/x.ts"; + expect(unbuiltWorkspaceLines(notFound(), [thirdParty, MEASURED_DETAIL], INVOCATION_PREFIX)?.[0]).toContain('@objectstack/spec'); + }); +}); diff --git a/scripts/cli-unbuilt-workspace-lead.d.mts b/scripts/cli-unbuilt-workspace-lead.d.mts new file mode 100644 index 0000000000..dfb8fbfea7 --- /dev/null +++ b/scripts/cli-unbuilt-workspace-lead.d.mts @@ -0,0 +1,32 @@ +// Types for the lead line `cli-unbuilt-workspace-lead.mjs` publishes to the one +// TypeScript consumer that reads it — `packages/cli/test/unbuilt-workspace-lead.test.ts`, +// which sits inside `@objectstack/cli`'s hidden test layer, where an untyped +// `.mjs` import is TS7016 (measured: `error TS7016: Could not find a declaration +// file for module …`). That layer's ledger entry in +// `scripts/check-type-check-coverage.mjs` is recorded EXACTLY — "the first new +// error in it should go red rather than be absorbed" — so the declaration is +// what keeps a new test from spending someone else's budget. +// +// The module itself stays `.mjs` for the reason its two sibling mirrors state: +// the gates invoke these scripts with bare `node`, and `check:declaration-mirrors` +// `import()`s this one to compare it against this file. That is also why the +// module takes the CLI's name as a PARAMETER rather than importing +// `INVOCATION_PREFIX` from a `.ts` — see the module header. +// +// COMPLETE rather than partial, unlike `invoked-as.d.mts`: the module exports +// exactly one thing. Keep this file in step with the module by hand; the mirror +// gate checks name, kind and required arity, never types. + +/** + * The two lines to print when oclif's "command … not found" was really a + * workspace package with no usable build output — or `undefined` when the + * failure is not that one, which is every ordinary invocation error and every + * command that genuinely does not exist. + * + * @param error the error `run()` rejected with; only its string form is read. + * @param moduleLoadFailures `detail` of each warning oclif emitted while + * loading its command table, in emission order. + * @param prefix the CLI's own name, which every line it prints starts with + * (`INVOCATION_PREFIX` in `packages/cli/src/utils/invocation.ts`). + */ +export function unbuiltWorkspaceLines(error: unknown, moduleLoadFailures: readonly string[], prefix: string): [string, string] | undefined; diff --git a/scripts/cli-unbuilt-workspace-lead.mjs b/scripts/cli-unbuilt-workspace-lead.mjs new file mode 100644 index 0000000000..1fcfce0e43 --- /dev/null +++ b/scripts/cli-unbuilt-workspace-lead.mjs @@ -0,0 +1,115 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// cli-unbuilt-workspace-lead -- what `packages/cli/bin/run-dev.js` says when +// oclif's "command … not found" is NOT about a missing command (#12964). +// +// ── The defect, measured ──────────────────────────────────────────────────── +// +// In a fresh worktree with `pnpm install` done and nothing built, the root +// script `pnpm i18n:extract` -- `tsx packages/cli/bin/run-dev.js i18n extract …` +// -- ends on +// +// Error: command i18n:extract:packages/platform-objects/scripts/i18n-extract.config.ts not found +// +// and exit 2. The command file is right there at `src/commands/i18n/extract.ts`. +// What actually happened is that oclif's `findCommand` `import()`s every command +// module while it builds its manifest, all 58 of them failed on +// +// Cannot find module '…/packages/cli/node_modules/@objectstack/spec/dist/index.mjs' +// +// and a command whose module will not load is, to `Config.runCommand`, +// indistinguishable from one that does not exist. The reader is handed the one +// cause that is definitely not true. +// +// This is the class this repo already treats as a defect rather than a shrug: +// `check-dev-prereqs.mjs` exists to refuse a missing/stale spec dist with a +// NAMED remedy (its header carries the #5726 chase), and #5217 fixed the same +// misdiagnosis for `check-i18n-bundles`, where "the CLI is not built" arrived as +// nine per-package bundle problems. `bin/run-dev.js` exists so gates need not +// depend on `packages/cli/dist` -- but it still hard-depends on its +// DEPENDENCIES' dists, and said nothing about them when that was what was +// missing. +// +// ── Nothing here is a new verdict or a new classifier ─────────────────────── +// +// Both facts are decided by `cli-build-prerequisite.mjs` next door, the module +// #5217 and #7681 put this knowledge in, and the remedy is that module's own +// `workspaceBuildFix`. This file contributes WORDING and nothing else, which is +// the split that module's header asks for in as many words ("What is +// deliberately NOT shared is the WORDING. Only the gate knows what it did not +// check"). +// +// * `looksLikeMissingCliCommand` -- is this oclif's "command … not found"? It +// is written to survive oclif's mid-token hard wrapping, which a per-line +// regex does not. +// * `looksLikeStaleWorkspaceDist` -- did a package THIS REPO BUILDS cause the +// load failure? Deliberately narrow: a third party's `Cannot find module` +// returns null and this module then says nothing, because the mirror-image +// defect of a misdiagnosis is a confident diagnosis pointing somewhere +// innocent. +// +// ⛔ `check-dev-prereqs.mjs` -- the gate that owns the fuller verdict -- is NOT +// reachable from here and was not made reachable. It has no exports and calls +// `process.exit(report(inspect(ROOT)))` at module scope, so importing it would +// terminate the CLI; and spawning it would answer about the WHOLE workspace +// ("67 of 67 packages … `pnpm build`") when the failure in hand names one +// package and one build. Two remedies for one precondition is the shape that +// gate's own header (#5726) exists to prevent, so this stays with the narrower +// one its sibling already spells. +// +// ── Why it lives here, and why the prefix is a parameter ──────────────────── +// +// `run-dev.js` ends in a top-level `await run(...)`, so it cannot be imported by +// a test without running the CLI. The decision lives here, as a pure function +// over the two strings the shim collected. +// +// This directory rather than `packages/cli/bin/` for one concrete reason: a +// hand-written `.d.mts` is what lets a `.ts` test import an untyped `.mjs` +// without TS7016, and `check:declaration-mirrors` only discovers +// `scripts/**/*.d.mts`. A declaration outside its corpus is exactly the +// unwatched drift that gate was built to make impossible (#10549), so the pair +// goes where the gate can see it. +// +// ⚠️ That gate `import()`s this module with bare `node`, so this file must stay +// loadable without tsx. It is why the CLI's name arrives as a PARAMETER instead +// of an `import { INVOCATION_PREFIX } from '…/invocation.ts'`: the shim already +// imports that module on its failure path and owns that coupling, and pulling a +// `.ts` in here would make the mirror check unrunnable. + +import { looksLikeMissingCliCommand, looksLikeStaleWorkspaceDist, workspaceBuildFix } from './cli-build-prerequisite.mjs'; + +/** + * The two lines, or `undefined` when this failure is not that one. + * + * BOTH conditions are required, and the second is what keeps a plain typo + * (`os frobnicate`) silent even in a half-built tree: oclif emits no + * module-load warning for a command that genuinely does not exist, so there is + * nothing to classify and nothing is printed. A run that really is missing a + * command keeps oclif's reporting exactly as it was. + * + * @param {unknown} error the error `run()` rejected with + * @param {readonly string[]} moduleLoadFailures `detail` of every warning the + * shim collected, in emission order -- oclif attaches the failing specifier to + * its `ModuleLoadError` warnings there + * @param {string} prefix the CLI's own name, as every line it prints starts + * with (`INVOCATION_PREFIX` in `packages/cli/src/utils/invocation.ts`) + * @returns {[string, string] | undefined} `[lead, fix]`, or `undefined` + */ +export function unbuiltWorkspaceLines(error, moduleLoadFailures, prefix) { + if (!looksLikeMissingCliCommand(String(error))) return undefined; + + for (const detail of moduleLoadFailures) { + // First classified failure wins: ONE unmet precondition, ONE fix. The 58 + // warnings the measured run emitted all name the same missing dist, and a + // list of them would be the "9 bundle problems" shape #5217 removed. + const cause = looksLikeStaleWorkspaceDist(String(detail)); + if (!cause) continue; + return [ + `${prefix}: NOT A MISSING COMMAND — @oclif/core reports a command module that failed to LOAD as "not found", and one did: ${cause.sentence}. The unmet precondition is ${cause.pkg}'s build output, not the invocation.`, + `${prefix}: Fix: ${workspaceBuildFix(cause.pkg)}`, + ]; + } + + return undefined; +} diff --git a/scripts/cross-package-test-inputs.mjs b/scripts/cross-package-test-inputs.mjs index b005cc1b07..e5d5da0476 100644 --- a/scripts/cross-package-test-inputs.mjs +++ b/scripts/cross-package-test-inputs.mjs @@ -292,6 +292,36 @@ export const CROSS_PACKAGE_TEST_INPUTS = { 'scripts/check-cross-package-test-inputs.mjs', 'scripts/js-comment-mask.mjs', 'scripts/js-comment-mask.d.mts', + // The #12964 trio, the second import-shaped coupling on this package. + // test/unbuilt-workspace-lead.test.ts imports `unbuiltWorkspaceLines` + // from `cli-unbuilt-workspace-lead.mjs` -- the decision `bin/run-dev.js` + // makes when oclif's "command not found" is really a workspace package + // with no build output -- and asserts the exact two lines it renders, so + // that module's behaviour IS this suite's verdict. + // + // All three entries, for three distinct reasons this roster records: + // - the `.mjs` is the import, and the only one this gate demanded; + // - the `.d.mts` is a real input to the typecheck verdict, exactly as + // the js-comment-mask sibling above is. Measured on this card rather + // than assumed: without it that test is TS7016 ("Could not find a + // declaration file"), which lands in @objectstack/cli's ledgered + // hidden test layer, whose note says the first new error in it must + // go red rather than be absorbed; + // - `cli-build-prerequisite.mjs` is what the `.mjs` delegates BOTH + // halves of its answer to -- `looksLikeStaleWorkspaceDist` decides + // whether to speak at all, and `workspaceBuildFix` renders the remedy + // that test pins CHARACTER FOR CHARACTER. A change there moves this + // suite's verdict without touching either file above it. + 'scripts/cli-unbuilt-workspace-lead.mjs', + 'scripts/cli-unbuilt-workspace-lead.d.mts', + 'scripts/cli-build-prerequisite.mjs', + // And THIS file, for the mention shape a fourth time on this package: the + // test above names it while saying where its three cross-package inputs + // are declared. Settled the way `check-nul-bytes.mjs` is — the literal + // collector takes quoted paths without parsing, so a mention forces a + // declaration, and declaring one rarely-touched file is cheaper than + // rewording prose to dodge a scanner. + 'scripts/cross-package-test-inputs.mjs', // `translation.zod.ts` is the second entry no test READS -- named in a // comment in test/i18n-section-coverage.test.ts, which describes it as the // DECLARATION face of the schema that test asserts against. It appears diff --git a/turbo.json b/turbo.json index a995a3adae..ad53c31b53 100644 --- a/turbo.json +++ b/turbo.json @@ -90,6 +90,10 @@ "$TURBO_ROOT$/scripts/check-nul-bytes.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.mjs", "$TURBO_ROOT$/scripts/js-comment-mask.d.mts", + "$TURBO_ROOT$/scripts/cli-unbuilt-workspace-lead.mjs", + "$TURBO_ROOT$/scripts/cli-unbuilt-workspace-lead.d.mts", + "$TURBO_ROOT$/scripts/cli-build-prerequisite.mjs", + "$TURBO_ROOT$/scripts/cross-package-test-inputs.mjs", "$TURBO_ROOT$/packages/spec/src/system/translation.zod.ts", "$TURBO_ROOT$/scripts/check-cross-package-test-inputs.mjs", "$TURBO_ROOT$/packages/create-objectstack/src/templates/blank/pnpm-workspace.yaml"