Skip to content

fix(cli): os init prints its in-try refusals once; the exit-signal pin is seeded with this.error - #21541

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21523-this-error-exit-signal
Oct 3, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-21523-this-error-exit-signal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #21523

Clause-②: no

This is the this.error face of the exit-signal family. Its this.exit text face (#21496) landed as PR #21522 (5895119c35), which renamed and widened the pin this PR extends.

What was wrong

this.error(msg) does not end the process. It throws oclif's CLIError, which carries oclif.exit (2 by default), so isExitSignal (packages/cli/src/utils/format.ts) already recognises it. In os init, two this.error calls sit inside run()'s outer try: the scaffold self-test refusal and the dependency-install refusal. That try's catch printed the message again with printError, then raised a second this.error with the same message.

Measured at the public door: the published entry packages/cli/bin/run.js over a freshly built dist/, run from a scratch directory, with npm pointed at a closed local port (npm_config_registry=http://127.0.0.1:9/, npm_config_fetch_retries=0).

os init demo -p npm before (bee8d1c62c) after (549f3704e4)
stdout ✗ Project scaffolded, but dependency installation failed., then ✗ Dependency installation failed ✗ Project scaffolded, but dependency installation failed. only
stderr (besides npm's own output) › Error: Dependency installation failed › Error: Dependency installation failed
exit status 2 2

The scaffold self-test refusal had the same shape, measured in-process (below): ✗ Scaffold validation failed: …, then a second ✗ Scaffold validation failed.

The fix

The outer catch of packages/cli/src/commands/init.ts now opens with if (isExitSignal(error)) throw error;, imported from utils/format.js. This is the ruled idiom: no second helper, and format.ts is not edited. The catch-all still prints and refuses for every other error it catches.

Closing the class: the analyzer is seeded with this.error

The analyzer in packages/cli/test/exit-signal.pin.test.ts was seeded with exit only. It is now seeded with both members (SIGNAL_SEEDS = ['exit', 'error']), over the same every-command population discovered from oclif's command table. A later command that throws either signal inside a try enters by existing.

Red list of the reseeded analyzer, measured BEFORE the fix on bee8d1c62c: 1 member, 2 sites.

  • os init: src/commands/init.ts:1359 this.error('Scaffold validation failed') and src/commands/init.ts:1388 this.error('Dependency installation failed'), both swallowed by the catch at line 1391 ("its first statement is not the isExitSignal rethrow").

Both are in scope and both are repaired by the one catch above. The reseed finds 4 this.error sites inside a try over the population, 3 distinct in source:

  • init.ts's two, above;
  • compile.ts:1046 (this.error(err.message)), judged once for os compile and once for os build, which inherits it. It shares its enclosing catch with a this.exit(1) the pin already judged. That catch opens with the rethrow, so it stays green.

No flagged catch handles the signal on purpose. The only red catch, init.ts's, re-reports what it caught. No catch converts a this.error into something else, so the two seeds have the same shape.

One more census, taken on bee8d1c62c over every command source: the only other oclif Command member that throws the signal, this.parse, is called inside a try by no command. The header records this, along with the rule that a command which does so adds a seed.

Header and floors. The header now names what seeds the analyzer, and says a new command enters by either call. The population floor (65) and the JSON-face floor (46) are unchanged; both were re-measured on bee8d1c62c. The site floor rises from 127 to 131: the 127 this.exit sites plus the 4 this.error sites.

Fixtures. Five new fixtures pin the new seed:

  • the os init defect shape (red);
  • the same shape under the idiom (green);
  • a this.error outside every try (not a site);
  • a this.error reached through a same-class helper (red);
  • os compile's shape: a this.error sharing a guarded catch with a this.exit (green).

this.error(msg, { exit: false }) throws nothing, and no command writes it. The header says the analyzer judges it like any other this.error. That can give a false red (the guard is harmless there), never a false green.

Text-face pins for os init

Two cases join the driven text-face describe. os init runs in-process through oclif. Three things are replaced:

  • its package-manager install (execSync, through vi.mock('child_process')) by a seam;
  • its scaffold self-test (validateScaffold) by a seam;
  • its working directory, by a scratch directory (a process.cwd spy, restored after each case).

The cases:

  • A failed dependency install. ONE ✗ line and exit 2. The install ran once, in the target directory. The self-test never ran.
  • A scaffold its own self-test rejects. ONE ✗ line, naming the rejection the case chose, and exit 2.

expectOneRefusal now takes the expected status: 1 for the this.exit(1) refusals, as before, and 2 for os init's this.error ones. The tier stays unit: nothing is spawned and nothing is bundled.

Verification

Everything below ran on the final commit 549f3704e4 unless it names another commit.

  • The pin. pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/exit-signal.pin.test.ts: 109 passed. Baseline on bee8d1c62c, before any edit: 101 passed.
  • Before the fix, with the pin edited and init.ts untouched: 3 failed, 106 passed. The failures are os init (text), with the two leaks above, and both driven os init cases, each printing 2 ✗ lines.
  • Reverse verification, after the fix was committed.
    • Command: node scripts/ablation-replace.mjs --file packages/cli/src/commands/init.ts --anchor 'if (isExitSignal(error)) throw error;' --delete -- pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 test/exit-signal.pin.test.ts.
    • The mutation landed: anchor count 1 → 0, blob 4930c989f594 → d811c1c322b6.
    • Result: 3 failed, 106 passed, exactly the three predicted. os init (text) names init.ts:1360 and :1389 swallowed by the catch at :1392. Each driven case reports "expected [ …(2) ] to have a length of 1 but got 2".
    • The restore was proven: blob == HEAD (4930c989f594) and git diff HEAD is empty.
    • The pin imports init.ts from src/ by a relative import, so this leg needed no dist/ rebuild.
  • packages/cli unit tier. pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2: 251 files, 3672 tests passed. The integration tier is declared to CI.
  • The six nightly-tier e2e files that drive os init. These are init-created-files-summary, starter-field-consumers, scaffold-emission-policy, generate-object-namespace-prefix, generate-scaffolds-reach-stack and create-refuses-invalid-project-name. Command: OS_TEST_TIERS=nightly pnpm --filter @objectstack/cli exec vitest run --maxWorkers=2 over those files. Result: 6 files, 53 tests passed. init-created-files-summary drives the failed-install path through a fake package manager on PATH.
  • Typecheck. pnpm --filter @objectstack/cli typecheck exits 0. check:test-typecheck reports OK: the debt ledger holds 28 errors, and none is in the pin file. --listFiles confirms the pin is in tsconfig.test.json's program.
  • Gates.
    • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 66 commands for this diff, and all 66 exit 0.
    • Two of them, check:dual-build-cjs-loads and check:i18n-coverage, first answered exit 3 (PREREQUISITE NOT MET: no dist/ for packages outside the cli closure). They were re-run after a full turbo run build and then exited 0.
    • --ran reconciliation: "66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED".
  • Lint: a proven narrowing, not a pnpm lint run.
    • ① The population, read from eslint's own config: both touched TypeScript files are linted. eslint --print-config shows 5 rules in effect on each. The changeset is outside every files glob.
    • ② The file count, from --format json: node --stack-size=4000 node_modules/eslint/bin/eslint.js --no-inline-config --format json packages/cli/src/commands/init.ts packages/cli/test/exit-signal.pin.test.ts reports 2 files, 0 errors, 0 warnings, exit 0.
    • ③ Invariance: on both files --print-config shows parserOptions.project and projectService null. The config states it never enables type-aware linting. Every rule in effect is single-file (no-restricted-syntax, no-restricted-imports, slot-lookup/no-any-assignment, query-options/no-any-erasure, verify-stand-in/no-asserted-driver-argument, comment-swallow/no-code-inside-block-comment). This diff touches neither eslint.config.mjs nor the baselines it reads, so no untouched file's verdict can move.

Acceptance notes

  • oclif's own error block remains. After the fix, os init's refusal is still followed by oclif's › Error: … block on stderr. The entry point renders that block from the thrown CLIError after run() exits, and it printed exactly once before the fix too. What the fix removed is the catch's second ✗ line. Every other os init refusal has the same ✗ + Error: pair: os init demo -t bogus prints ✗ Unknown template: bogus and then › Error: Unknown template: bogus, exit 2, measured on 549f3704e4. That pairing comes from printError followed by this.error, not from this mechanism. The pin counts the command's ✗ lines, as the family's other text-face pins do. Whether the pairing is itself a second report of one refusal is outside this card, and is reported to the seat rather than changed here.
  • No changes outside the claimed surface. Nothing under dev.ts, start.ts, serve.ts, packages/runtime/ or packages/metadata-protocol/ was touched. serve.ts changed on main since this branch's base (550f4cc2fd), adding a try with no signal call inside it, and git merge-tree of this head with main is clean.

Generated by Claude Code

…n is seeded with this.error

os init's outer catch re-reported the CLIError its own this.error calls
raise inside the try (the dependency-install and scaffold-validation
refusals): a second ✗ line under each refusal. The catch now opens with
the isExitSignal rethrow. The exit-signal pin's analyzer is seeded with
this.error as well as this.exit over the same every-command population,
and drives both os init refusals in-process.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz
@github-actions github-actions Bot added size/m 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 1 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/deployment/cli.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/getting-started/examples.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/getting-started/your-first-project.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/plugins/index.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/protocol/kernel/index.mdx (via os init (command, read off packages/cli/src/commands/init.ts))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/releases/v17/17-4.mdx (via os init (command, read off packages/cli/src/commands/init.ts))
  • content/docs/releases/v17/17-5.mdx (via os init (command, read off packages/cli/src/commands/init.ts))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

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 49161683fbc9ab0ef15993d9f201f490ca71a2db → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e736f3bd700847ed4b1a705a92a02a89fcb876cb — the merge of head 549f3704e4e96c860f28b5f8dd302aca4dc79ca0 into base 49161683fbc9ab0ef15993d9f201f490ca71a2db, 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 e736f3bd700847ed4b1a705a92a02a89fcb876cb && git checkout e736f3bd700847ed4b1a705a92a02a89fcb876cb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 49161683fbc9ab0ef15993d9f201f490ca71a2db 549f3704e4e96c860f28b5f8dd302aca4dc79ca0 && git checkout -B drift-repro 49161683fbc9ab0ef15993d9f201f490ca71a2db && git merge --no-ff 549f3704e4e96c860f28b5f8dd302aca4dc79ca0

node scripts/docs-audit/affected-docs.mjs --json 49161683fbc9ab0ef15993d9f201f490ca71a2db

⚠️ 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 49161683fbc9ab0ef15993d9f201f490ca71a2db → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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/m tests tooling

Projects

None yet

2 participants