Repository navigation
Commit 24dc7c1
fix(cli): os init prints its in-try refusals once; the exit-signal pin is seeded with this.error (#21541)
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](https://claude.ai/code/session_016GiHYRmLSNWTfbX9gVQkpz)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 44defd4 commit 24dc7c1
3 files changed
Lines changed: 168 additions & 40 deletions
File tree
- .changeset
- packages/cli
- src/commands
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| 20 | + | |
20 | 21 | | |
21 | 22 | | |
22 | 23 | | |
| |||
1389 | 1390 | | |
1390 | 1391 | | |
1391 | 1392 | | |
| 1393 | + | |
| 1394 | + | |
| 1395 | + | |
| 1396 | + | |
1392 | 1397 | | |
1393 | 1398 | | |
1394 | 1399 | | |
| |||
0 commit comments