fix(cli): os init and os compile render each refusal once, not once on stdout and again as oclif's Error block (#21542) - #21560
Conversation
…n stdout and again as oclif's Error block Ten sites printed their sentence with printError and then handed it to this.error, which has oclif's entry point render it a second time on stderr. Each now ends in this.exit(2): the status this.error raised, with no second rendering. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…and keeps exit 2 A structural half over every command module (no refusal printer paired with this.error) and a driven half that spawns the CLI through all ten sites. The exit-signal pin's this.error anchor follows the sites to this.exit(2). Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
…exit-signal pin's comment Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 27 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 85b81bd9a0477e469707e2124ecd242debc88f4a && git checkout 85b81bd9a0477e469707e2124ecd242debc88f4a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 10454b3afa94d49e6e424cc16fbbff3a898f3ad8 d83eb007b0e95dbdbb2519742a4ee8523e7ea9fc && git checkout -B drift-repro 10454b3afa94d49e6e424cc16fbbff3a898f3ad8 && git merge --no-ff d83eb007b0e95dbdbb2519742a4ee8523e7ea9fc
node scripts/docs-audit/affected-docs.mjs --json 10454b3afa94d49e6e424cc16fbbff3a898f3ad8
|
Fixes #21542
Clause-②: no
os initandos compileprinted ten refusals twice: once as the command's own✗line on stdout, and again as oclif'sError:block on stderr, because the command handed the same sentence tothis.error. Each now prints its✗line and the hint under it once, and ends inthis.exit(2), the statusthis.errorraised. Stdout is unchanged; stderr no longer repeats it; every exit status is still 2.The shape, and why it is this one
Triage's ruling asks for one shape at every site, through the split
isReportedErrorguards, with no second mechanism and no per-site variant. The shape the family's text faces already use is: the command renders its own refusal withprintError, and ends inthis.exit(n).validate,info,diff,lint,verify,i18n check,i18n extract,generateandmigrate metaall end that way, andisReportedErroris the guard a catch-all uses for a refusal a helper already wrote to stderr. This PR applies that shape at all ten sites. No helper is added, andutils/format.tsis read, not edited.printErrorToStderr's docblock says a command that has already decided it renders the text face keepsprintErroron stdout, and stderr is for shared paths that cannot see--json(resolveConfigPath).os initdeclares no--jsonat all, andos compile's--jsonbranch returns above every site here. So the surviving copy stays where the✗line and its hint always were. Stdout already held everything stderr carried, so nothing is lost.init.ts's catch-all has noisReportedErrorguard.compile.ts's catch-all already carries it, becauseresolveConfigPath()can reach it. OnlyConfigRefusalError(config.ts) and the SDUI manifest error (sdui-manifest.ts) carry the marker, and nothing ininit.ts'stryreaches either: it imports neither, andvalidateScaffoldhas its own loader. A guard there would be a branch no run can take.this.error(msg)exits 2 andthis.exit(2)is the same status. The dispatch's A4 and the ruling's second pin both say the statuses stay, and the platform checklist already names oclif's 2 onos compile's human path as legitimate.Population: a symbol walk, not a grep
ts-morph over
packages/cli/src/commandsatfd5a1cd597with the type checker: calls resolving to oclif'sCommand.error, and calls resolving toprintErrororprintErrorToStderrinutils/format.ts, grouped by outermost function.this.errorcall sites in 5 files; 57 command files call a refusal printer. Functions holding both: 2,init.ts run()andcompile.ts run().this.errorsites: the card's 8, plus init's scaffold self-test and dependency install refusals inside itstry. The ruling's third bullet makes any pair beyond the 8 ride this landing, and they do.datasourcecommands (introspect,list-tables,validate) hold the other 6this.errorsites and call no refusal printer. Their detail lines go throughthis.logand the one sentence is oclif's, so they are not pairs.this.errorsites in 3 files.Readings at the public door
Spawned
bin/run-dev.js(the source entry; it shares the published entry's oclifhandle()andflush()) from a scratch directory, before and after. Before, the source entry prints oclif's stack beneath the same sentence; the published entry prints› Error: …, as the card measured it.All ten sites, each reached through a real run (fixtures are in the e2e pin's header). Copies of the sentence across both streams, and the exit status:
init demo -t bogusinit Bad_Nameinit demoover a non-emptydemo/initin a directory namedBad Cwdinitbeside anobjectstack.config.tsinit demo -p npm, stubnpmthat installs nothinginit demo -p npm, stubnpmexiting 1init --no-installwith a file namedsrccompile --runtime-bundle, config importing./helperthat onlyhelper.jsxsatisfiescompile, config throwing at loados buildextendsCompileand has no refusal of its own, so it inherits both compile rows. The publishedbin/run.js, built from this tree, was run for the init unknown-template and compile catch-all rows too: exit 2, stdout once, stderr empty.Pins
packages/cli/test/refusal-renders-once.e2e.test.ts(nightly): spawns the CLI through each of the ten sites and asserts the exit status is 2, the refusal's subject occurs once across both streams, exactly one✗line is printed, and the hint under it survives. Every spawn is paid inbeforeAll; no case is clocked.packages/cli/test/refusal-renders-once.test.ts(queue, unit): the structural half over every command module. No function that calls a refusal printer (an identifier bound by an import fromutils/format.js, aliases followed) also callsthis.error. Nine fixtures pin the scan, and three floors keep it from going vacuous (60 command modules, 50 files calling a printer, 3 files raisingthis.error), set under what the symbol walk counted on this tree: 65, 57 and 3. The population is discovered, so the next command that repeats the shape is in it.packages/cli/test/exit-signal.pin.test.ts: itsthis.errorreal-site anchor followed the sites tothis.exit(2)(init's two in-try refusals, compile's and build's bundling refusal).SITE_FLOORstays 131 (both spellings are seeds), and the pin stays green; thethis.errorseed itself is still covered by its fixtures.Reverse verification
Run on the committed fix, through
scripts/ablation-replace.mjsin wrap mode under the lock, restoring one site's pairing at a time. Both halves read the source (bin/run-dev.jsruns fromsrc/, the structural pin reads text), so nodist/is on the path and no rebuild leg applies.init.tsunknown-template site back tothis.error(...): anchor 1 to 0, blob929642fba1fftoedab7c113e65. Structural pin red (1 failed, naminginit.ts run()); e2e red on exactly that case (1 failed, 9 passed: "expected 2 to be 1"); exit-signal pin green. Restore proven: blob equals HEAD (929642fba1ff) andgit diff HEADis empty.compile.tsruntime-bundle site back tothis.error(err.message): anchor 1 to 0, blob25d3ea870831to42e44c372e8e. Structural pin red (2 failed: the pair, and the exit-signal anchor "each ends inthis.exit(2)"); e2e red on exactly the bundling case (1 failed, 9 passed). Restore proven: blob equals HEAD (25d3ea870831),git diff HEADempty,git statusclean.Verification
At
d83eb007b0unless noted; every heavy run went throughos-verify-lock.sh.pnpm --filter @objectstack/cli typecheck: exit 0 (tsc --noEmit, andcheck:test-typecheckOK with its ledger unchanged: 3 files, 28 errors).refusal-renders-once.test.ts,exit-signal.pin.test.tsandvitest-tiers-partition.test.ts: 3 files, 142 tests passed. Driven pin withOS_TEST_TIERS=nightly: 10 of 10 passed.os init,os compileoros build, selected by import of the command module or by spawn argv (run at228259e373; the later commit changes one comment line in a test file): 31 queue-tier files, 485 passed and 6 failed, all 6 inpublished-subpath-console.pin.test.ts. Those six failed because this worktree'spackages/cli/disthad been built withOS_SKIP_DTS=1and carried no declarations; after a declaration build the file passed 14 of 14. 18 nightly e2e files withOS_TEST_TIERS=nightly: 210 passed.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, atd83eb007b0: 66 derived, all 66 run. Three first answered exit 3 or 124 for environment reasons (check:dual-build-cjs-loadsandcheck:i18n-coveragesaid PREREQUISITE NOT MET because packages outside the cli closure had nodist/;check:type-check-debthit my own 420 s cap). After building the missing packages with declarations and a longer cap, all three exited 0.--ran: 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN.isPathIgnoredandcalculateConfigForFile): 5 files in the JSON results, 0 errors, 0 warnings. The.changesetfile is outside ESLint's population. Type-aware linting is off for each file (noparserOptions.project; the config header says it is never enabled), and the diff touches no ESLint config, so it cannot move a verdict on an untouched file. Whole-repopnpm lintis left to CI.fd5a1cd597andorigin/mainis 7 commits ahead. The derivation tool reports one input changed upstream,scripts/engine-double-contract.pinned.json(thecheck:engine-double-contractfamily, which ran green here; this diff adds no fake engine). The upstream commits touchpackages/clionly undermigrate/and two utils, and add noprintErrororthis.errorline undersrc/commands.Acceptance notes
utils/format.tsaboveCliExitCodereads "The only two exit codes this CLI has: 0 success, 1 failure". Ten refusals exit 2, as they always did, and the platform checklist (docs/qa/platform-checklist/areas/cli.json) blesses oclif's 2 onos compile's human path. This PR keeps 2, as the ruling's pin says. Noted, not filed: the docblock's scope is theemitJsonslot's type.compile.ts'sthis.error()in seven strings. The status it blesses is unchanged; only the named mechanism now reads stale. Not edited here (outside this card's file surface, and the file carries a revision history of its own).os compile's catch-all still answers exit 1 for a refusal a helper already wrote (theisReportedErrorbranch) and exit 2 for any other. That asymmetry predates this PR and is kept.Generated by Claude Code