Uh oh!
There was an error while loading. Please reload this page.
fix(cli): name the missing build output instead of oclif's "command not found" - #13064
Conversation
In a checkout where a workspace dependency has no dist/, @oclif/core import()s
every command module while building its manifest, all of them fail, and the run
ends on `Error: command <id> 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.
bin/run-dev.js now collects oclif's module-load warnings around run() and, when
the failure was caused by a package this repo builds, prints the attribution and
the one command that fixes it ahead of oclif's report. Both the classification
and the remedy come from scripts/cli-build-prerequisite.mjs, which already
answers this for the gates that shell out to the CLI.
The collector is attached AFTER run() deliberately: @oclif/core installs its own
warning listener only while process.listenerCount('warning') <= 1, so attaching
first would silently drop its debug blocks from every failing run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd…on mirror
Two facts forced the move, both measured rather than guessed:
- a .ts test importing the untyped .mjs is TS7016 ("Could not find a
declaration file"), and packages/cli's hidden test layer is ledgered in
check-type-check-coverage.mjs with "the first new error in it should go red
rather than be absorbed". The fix is the hand-written .d.mts this repo
already uses three times over -- and check-declaration-mirrors only
discovers scripts/**/*.d.mts, so a declaration under packages/cli/bin would
have been exactly the unwatched drift that gate exists to prevent.
- that gate import()s the module with bare node, so the module may not import
a .ts. The CLI's name therefore arrives as a parameter; the shim already
imports invocation.ts on its failure path and owns that coupling.
Measured after the move: the two new test files add 0 errors to the layer, with
a two-sided control (the same program reports TS7016 for the undeclared sibling
import on the line above and nothing for this one).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCdcheck:cross-package-test-inputs named the import the moment the renderer moved under scripts/: a test whose real inputs are wider than its package is invisible to both the affected-subset filter and the turbo cache. Declared in the roster and mirrored into turbo.json, per package convention: - cli-unbuilt-workspace-lead.mjs -- the import itself; - its .d.mts -- a real input to the typecheck verdict, the same reason the js-comment-mask sibling is declared; - cli-build-prerequisite.mjs -- where both halves of the answer come from, including the remedy string the test pins character for character; - cross-package-test-inputs.mjs -- the mention shape, since the test now says where its inputs are declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2891f5f49c60896cf145e79a9853419c20906752 && git checkout 2891f5f49c60896cf145e79a9853419c20906752
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ef744c49c60a41a69181470cc26fd67bb8a85f84 576a47c792d298330ccb920c85809c84d49ef6e9 && git checkout -B drift-repro ef744c49c60a41a69181470cc26fd67bb8a85f84 && git merge --no-ff 576a47c792d298330ccb920c85809c84d49ef6e9
node scripts/docs-audit/affected-docs.mjs --json ef744c49c60a41a69181470cc26fd67bb8a85f84 |
os-litant
commented
Aug 29, 2026
PM 复核 — ACCEPT
我的派发单要求的"复用 |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#12964
pnpm i18n:extractin an unbuilt worktree reported an oclif "command not found" for a command that is right there on disk. This teachespackages/cli/bin/run-dev.jsto name the real cause — a workspace package with no build output — and the one command that fixes it.1. Reproduced first, on the card's own terms
Fresh worktree at
8cb96ec41,pnpm installdone, nothing built:1518 lines of report; the last line — the only one a reader acts on — names the one cause that is definitely not true.
packages/cli/src/commands/i18n/extract.tsexists. What actually happened is in the noise above it, 58 times over:oclif's
findCommandimport()s every command module while it builds its manifest; a command whose module will not load is indistinguishable, toConfig.runCommand, from one that does not exist.The card's premise held in every particular. All three named paths still exist on
main, and therun-dev.jsshim still lacks the guard.2. Which landing site — measured, not chosen by taste
The card left
run-dev.jsorinvocationFailureLineopen. Four measurements settle it, and they all point the same way.bin/run-dev.jsinvocationFailureLine(src/utils/invocation.ts).catch()CLIErrorin the unbuilt tree: own properties['code','oclif','skipOclifErrorHandling','suggestions'], neitherparsenorshowHelp, soisInvocationErrorisfalseandinvocationFailureLinereturnsundefinedtodayfilesis["dist","README.md","CHANGELOG.md"]and npm packs only thebintarget,bin/run.jsdist/utils/invocation.js. A monorepo remedy (--filter=@objectstack/spec) would ship to an end user who mistyped a commandbin/run.jsreaches it viaawait import('../dist/utils/invocation.js')inside atry/catch— on an unbuilt tree that import itself fails and is swallowed, so a preflight there is dead in exactly the case it exists fornode:builtins", and its docstring pins "a genuine runtime failure keeps oclif's reporting exactly as it was". A not-found preflight needs both widenedSo:
bin/run-dev.js, with the decision factored into a module a test can import.3. No new verdict text, no new classifier
Both facts are decided by
scripts/cli-build-prerequisite.mjs— the module #5217 and #7681 put this knowledge in — and the remedy is that module's ownworkspaceBuildFix:looksLikeMissingCliCommand— is this oclif's "command … not found"? 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, so a third party'sCannot find moduleprints nothing.⛔
scripts/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 callsprocess.exit(report(inspect(ROOT)))at module scope, so importing it would terminate the CLI. Spawning it was prototyped and dropped — it answers about the whole workspace ("67 of 67 packages …pnpm build", measured) where the failure in hand names one package and one build, and two remedies for one precondition is the shape that gate's own #5726 header exists to prevent.Output:
4. The listener order is load-bearing, and the first attempt got it wrong
The cause has to be collected as it happens: oclif warns per failing command module during
Config.load()and then throws a "not found" that carries none of it. The obvious spelling — attachprocess.on('warning', …)at the top of the shim — silently deletes output:@oclif/coreinstalls its ownwarninglistener (displayWarnings()inconfig/config.js, what prints theWarning: ModuleLoadErrorstack plusdetailundersettings.debug) only whenprocess.listenerCount('warning') <= 1. A collector attached first makes that 2 and oclif declines. Measured on the repro: the report went from 1518 lines to 476, with nothing saying why.Attaching after
run()fixes it —run()reachesdisplayWarnings()in its synchronous prefix (main.js:await Config.load(…)is its firstawait;config.js: the call precedesload()'s firstawait), andprocess.emitWarningdefers tonextTick. The e2e asserts oclif'sat Plugin.warnblocks are still present, so a future oclif that moves that call past anawaitfails a test instead of going quiet.5. Before / after — the failure path gains exactly two lines, the success path is byte-identical
Failure path, re-measured at this branch's head in a fresh worktree with
pnpm installand nothing built:Sorted-multiset diff of the two runs (pid and
run-dev.js:LINE:COLnormalised): the only content difference is the twoobjectstack:lines. Control for run-to-run noise, same tree: two runs of identical code differ by 0 lines; the residualat async Promise.all (index N)frames that move are oclif's own settle order over its command-modulePromise.all, same count, no line added or lost.Success path, on a built tree, same command:
That is the elapsed-milliseconds figure the command prints itself. With that one number normalised the two transcripts are byte-identical (sha256
205d0427…both), both exit 0, both leavegit statusclean.6. The test proves it entered the unbuilt branch
The trap this suite is written against: an "unbuilt tree" test that runs in CI's built tree never enters the branch it claims to cover and reads green forever. So the unbuilt condition is manufactured for one child process —
test/fixtures/unbuilt-spec-dist.hook.mjs, a--importresolve hook that touches no disk (this container runs several agents; a test that renamedpackages/spec/distwould break every other run in the box).It re-points the specifier rather than throwing, so the sentence the classifier reads is node's own. Two shapes were measured and rejected first: a short-circuited non-existent URL skips
finalizeResolutionand surfaces asENOENT … open, which the classifier correctly declines (a green run proving nothing); a hand-thrown error would make the test assert a string the fixture authored.Three cases, a positive-control set rather than one assertion:
@objectstack/spec, alongside oclif's unchangedError: command … not found, exit 2;Config file not found), exit 1 — the other branch, on the same probe;Ablation (
bin/run-dev.jsreverted to the merge base; on-disk proof first —announceUnbuiltWorkspace2→0,moduleLoadFailures3→0, blob033f8998…= the base blob):Restored under an
EXIT INT TERMtrap and proved: on-disk hash548f923e…=HEAD:packages/cli/bin/run-dev.js,git diff HEADempty,git statusclean.7. Why the renderer lives in
scripts/, and what that boughtrun-dev.jsends in a top-levelawait run(…), so it cannot be imported by a test. The decision is therefore its own module — and it is underscripts/rather thanpackages/cli/bin/for a measured reason:.tstest importing an untyped.mjsis TS7016 (measured on a minimal probe, then two-sidedly in this tree). That lands in@objectstack/cli's ledgered hidden test layer, whose note says "the first new error in it should go red rather than be absorbed"..d.mts(three existing precedents), andcheck:declaration-mirrorsonly discoversscripts/**/*.d.mts— a declaration underpackages/cli/bin/would be exactly the unwatched drift that gate was built to prevent ([finding]scripts/js-comment-mask.d.mtsis a hand-maintained type mirror with nothing gating it in step — if it drifts, every consumer type-checks clean against a wrong signature #10549). It now reports 4 pairs, this one included.import()s the module with barenode, so the module may not import a.ts. The CLI's name therefore arrives as a parameter; the shim already importsinvocation.tson its failure path and owns that coupling, so there is still exactly one spelling of the name.Two-sided control that the declaration is what keeps the layer clean — one throwaway file, both imports, same program:
⇒ the two new test files add 0 errors to that layer.
8. Gates
Derived after the final commit with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-written path list), re-derived twice as the diff grew. All runs below at576a47c79, the pushed head.Green:
check:nul-bytes·check:cli-command-ids·check:dev-prereqs --self-test·check:declaration-mirrors(self-test + scan) ·check:cross-package-test-inputs(+ theci.ymlinvocation) ·check:cli-test-child-env·check:type-check-coverage·check:turbo-task-graph·check:query-options-erasure·check:engine-double-contract·check:where-matcher·check:published-files·check:test-source-alias·check:type-source-resolution·check:entry-guard·check:parse-guard·check:watch-hint-literal·check:agent-test-spelling·check:bash32-floor·check:pnpm-filter-targets·check:page-declaration-shape·check:slot-lookup·check:objectql-double-limit·check:objectui-changeset·check:changeset-gate-self-tests·check-adr-0087-registration·check-changeset-no-major·check-empty-changeset·check-ci-filter-parity·check-comment-mask-adoption·check-keyed-text-bounds·check-plugin-teardown-shape·check-undeclared-dep-imports·docs-audit/check-affected-docs·docs-audit/check-drift-comment·pm/release-rehearsal-clone --self-test.pnpm lint— the whole repo, not a narrowing.eslint . --no-inline-config, exit 0, 64s under the shared verify lock.check:cross-package-test-inputswent red the moment the renderer moved underscripts/, and that is whyturbo.jsonandscripts/cross-package-test-inputs.mjsare in this diff: the import, its.d.mts(a real input to the typecheck verdict, same reason thejs-comment-masksibling is declared),cli-build-prerequisite.mjs(it renders the remedy the test pins character for character), and the roster module itself (the mention shape).NOT MEASURED, stated as such
check:dev-prereqsscan half — exit 1 here, and it is about this worktree, not this diff. Only@objectstack/cli's dependency closure is built locally, so 13 of 67 packages have nodist/. That gate's own header says the scan is wired intopnpm devonly and never into a workflow ("in a job that has not built it is a hard false red");lint.ymlruns--self-test, which is green.check:type-check-debt --re-measure— not run. It needs the full workspace closure built (turbo run build --filter=./packages/* --filter=./packages/*/*), which did not fit this container. Both halves it would judge were measured directly instead, each with a control: the@objectstack/clihidden test layer gains 0 errors (§7), and the ROOT program is unmoved —tsc -p tsconfig.jsonreports the same 30 diagnostics with and without the new.d.mts(ablated and restored, blob-verified), while--listFilesconfirms the file is in that program, so the zero is a measurement rather than a blind spot. The absolute 30 is a rawgrep -c 'error TS', not that gate's parser's count, and it is unchanged by this branch either way.check:pm-half-states— exit 3, PREREQUISITE NOT MET. This container has no valid GitHub credential, so the gate swept nothing. Not a reading in either direction, and unrelated to this diff.pnpm --filter @objectstack/cli typecheckis green but says nothing about the new files: the packagetsconfig.jsonisinclude: ["src"], and--listFilesfinds 0 of the five changed/added files in that program against a positive control (src/utils/invocation.ts, 1 hit).@objectstack/clisuite was not run; the two suites this change adds were, plus every gate above. CI runs the rest.Generated by Claude Code
Generated by Claude Code