Skip to content

fix(cli): os init and os compile render each refusal once, not once on stdout and again as oclif's Error block (#21542) - #21560

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21542-one-rendering-per-refusal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-21542-one-rendering-per-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21542

Clause-②: no

os init and os compile printed ten refusals twice: once as the command's own ✗ line on stdout, and again as oclif's Error: block on stderr, because the command handed the same sentence to this.error. Each now prints its ✗ line and the hint under it once, and ends in this.exit(2), the status this.error raised. 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 isReportedError guards, 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 with printError, and ends in this.exit(n). validate, info, diff, lint, verify, i18n check, i18n extract, generate and migrate meta all end that way, and isReportedError is 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, and utils/format.ts is read, not edited.

  • Which stream the one copy is on. The dispatch read the split as "the human text goes on stderr once". That is the helper half of it: printErrorToStderr's docblock says a command that has already decided it renders the text face keeps printError on stdout, and stderr is for shared paths that cannot see --json (resolveConfigPath). os init declares no --json at all, and os compile's --json branch 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.
  • Why init.ts's catch-all has no isReportedError guard. compile.ts's catch-all already carries it, because resolveConfigPath() can reach it. Only ConfigRefusalError (config.ts) and the SDUI manifest error (sdui-manifest.ts) carry the marker, and nothing in init.ts's try reaches either: it imports neither, and validateScaffold has its own loader. A guard there would be a branch no run can take.
  • Why exit 2. this.error(msg) exits 2 and this.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 on os compile's human path as legitimate.

Population: a symbol walk, not a grep

ts-morph over packages/cli/src/commands at fd5a1cd597 with the type checker: calls resolving to oclif's Command.error, and calls resolving to printError or printErrorToStderr in utils/format.ts, grouped by outermost function.

  • 16 this.error call sites in 5 files; 57 command files call a refusal printer. Functions holding both: 2, init.ts run() and compile.ts run().
  • Those two hold 10 this.error sites: the card's 8, plus init's scaffold self-test and dependency install refusals inside its try. The ruling's third bullet makes any pair beyond the 8 ride this landing, and they do.
  • The three datasource commands (introspect, list-tables, validate) hold the other 6 this.error sites and call no refusal printer. Their detail lines go through this.log and the one sentence is oclif's, so they are not pairs.
  • After this PR the same walk finds 0 functions holding both, and 6 this.error sites in 3 files.

Readings at the public door

Spawned bin/run-dev.js (the source entry; it shares the published entry's oclif handle() and flush()) 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.

os init demo -t bogus      before: stdout "  ✗ Unknown template: bogus" + "  Available: app, plugin, empty"
                                   stderr "Error: Unknown template: bogus" + stack      exit 2
                           after:  stdout the same two lines                            exit 2, stderr empty
os compile  (config throws at load)
                           before: stdout "  ✗ probe: the config module threw at load"
                                   stderr "Error: probe: the config module threw at load" + stack   exit 2
                           after:  stdout the same line                                 exit 2, stderr empty

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:

Site Reached by Copies Exit
init: unknown template init demo -t bogus 2 to 1 2 to 2
init: invalid project name init Bad_Name 2 to 1 2 to 2
init: target not empty init demo over a non-empty demo/ 2 to 1 2 to 2
init: current directory name invalid init in a directory named Bad Cwd 2 to 1 2 to 2
init: config already exists init beside an objectstack.config.ts 2 to 1 2 to 2
init: scaffold self-test rejects (in the try) init demo -p npm, stub npm that installs nothing 2 to 1 2 to 2
init: dependency install fails (in the try) init demo -p npm, stub npm exiting 1 2 to 1 2 to 2
init: catch-all init --no-install with a file named src 2 to 1 2 to 2
compile: runtime bundle refused compile --runtime-bundle, config importing ./helper that only helper.jsx satisfies 2 to 1 2 to 2
compile: catch-all compile, config throwing at load 2 to 1 2 to 2

os build extends Compile and has no refusal of its own, so it inherits both compile rows. The published bin/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 in beforeAll; 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 from utils/format.js, aliases followed) also calls this.error. Nine fixtures pin the scan, and three floors keep it from going vacuous (60 command modules, 50 files calling a printer, 3 files raising this.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: its this.error real-site anchor followed the sites to this.exit(2) (init's two in-try refusals, compile's and build's bundling refusal). SITE_FLOOR stays 131 (both spellings are seeds), and the pin stays green; the this.error seed itself is still covered by its fixtures.

Reverse verification

Run on the committed fix, through scripts/ablation-replace.mjs in wrap mode under the lock, restoring one site's pairing at a time. Both halves read the source (bin/run-dev.js runs from src/, the structural pin reads text), so no dist/ is on the path and no rebuild leg applies.

  • Leg A, init.ts unknown-template site back to this.error(...): anchor 1 to 0, blob 929642fba1ff to edab7c113e65. Structural pin red (1 failed, naming init.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) and git diff HEAD is empty.
  • Leg B, compile.ts runtime-bundle site back to this.error(err.message): anchor 1 to 0, blob 25d3ea870831 to 42e44c372e8e. Structural pin red (2 failed: the pair, and the exit-signal anchor "each ends in this.exit(2)"); e2e red on exactly the bundling case (1 failed, 9 passed). Restore proven: blob equals HEAD (25d3ea870831), git diff HEAD empty, git status clean.
  • Direction observed: red in both legs, the normal one.

Verification

At d83eb007b0 unless noted; every heavy run went through os-verify-lock.sh.

  • pnpm --filter @objectstack/cli typecheck: exit 0 (tsc --noEmit, and check:test-typecheck OK with its ledger unchanged: 3 files, 28 errors).
  • Unit: refusal-renders-once.test.ts, exit-signal.pin.test.ts and vitest-tiers-partition.test.ts: 3 files, 142 tests passed. Driven pin with OS_TEST_TIERS=nightly: 10 of 10 passed.
  • Every test file that drives os init, os compile or os build, selected by import of the command module or by spawn argv (run at 228259e373; the later commit changes one comment line in a test file): 31 queue-tier files, 485 passed and 6 failed, all 6 in published-subpath-console.pin.test.ts. Those six failed because this worktree's packages/cli/dist had been built with OS_SKIP_DTS=1 and carried no declarations; after a declaration build the file passed 14 of 14. 18 nightly e2e files with OS_TEST_TIERS=nightly: 210 passed.
  • Gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, at d83eb007b0: 66 derived, all 66 run. Three first answered exit 3 or 124 for environment reasons (check:dual-build-cjs-loads and check:i18n-coverage said PREREQUISITE NOT MET because packages outside the cli closure had no dist/; check:type-check-debt hit 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.
  • Lint, as a proven narrowing: ESLint with inline config disabled over the 5 touched TypeScript files, population read from ESLint's own config (isPathIgnored and calculateConfigForFile): 5 files in the JSON results, 0 errors, 0 warnings. The .changeset file is outside ESLint's population. Type-aware linting is off for each file (no parserOptions.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-repo pnpm lint is left to CI.
  • Not re-derived on a fresh tree: the branch is based on fd5a1cd597 and origin/main is 7 commits ahead. The derivation tool reports one input changed upstream, scripts/engine-double-contract.pinned.json (the check:engine-double-contract family, which ran green here; this diff adds no fake engine). The upstream commits touch packages/cli only under migrate/ and two utils, and add no printError or this.error line under src/commands.

Acceptance notes

  • The declaration in utils/format.ts above CliExitCode reads "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 on os compile's human path. This PR keeps 2, as the ruling's pin says. Noted, not filed: the docblock's scope is the emitJson slot's type.
  • The same checklist file describes that exit 2 as coming from compile.ts's this.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 (the isReportedError branch) and exit 2 for any other. That asymmetry predates this PR and is kept.

Generated by Claude Code

claude added 3 commits October 3, 2026 06:23
…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
@github-actions github-actions Bot added the size/l label Oct 3, 2026
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Oct 3, 2026
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 2 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 10454b3afa94d49e6e424cc16fbbff3a898f3ad8.

⛔ 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): node scripts/docs-audit/affected-docs.mjs --json 10454b3afa94d49e6e424cc16fbbff3a898f3ad8 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 85b81bd9a0477e469707e2124ecd242debc88f4a — the merge of head d83eb007b0e95dbdbb2519742a4ee8523e7ea9fc into base 10454b3afa94d49e6e424cc16fbbff3a898f3ad8, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 10454b3afa94d49e6e424cc16fbbff3a898f3ad8 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 3, 2026 07:59
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 3, 2026 07:59
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 3, 2026
Merged via the queue into main with commit bf36edd Oct 3, 2026
37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-21542-one-rendering-per-refusal branch October 3, 2026 08:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants