Skip to content

Commit 87847a2

Browse files
fix(cli): os validate / build / lint --json report the conversions a refusing defineStack applied, beside the refusal (#20926)
Fixes #20583 Clause-②: no ## What this lands: location 2's CLI half A `defineStack` (or `composeStacks`) call that converts an ADR-0087 D2 spelling and then refuses now reports both on `os validate --json`, `os build --json` and `os lint --json`: the refusal's `error` and `code`, and the conversions the producer applied before it refused, in `conversions`. The spec half landed in PR #20651 (#20618): a refusing producer stamps the notices it applied on the ADR-0112 refusal it throws, and `stackConversionsOf(error)` reads them. This PR is the fold the seat answer `5886671384` kept on this card: one line in each of the three catch-alls. - `validate.ts`, `compile.ts` (`os build` inherits it) and `lint.ts`: inside the catch-all's `--json` branch, `conversionNotices.push(...stackConversionsOf(error))` runs before `emitJson`. The payload still reads the one shared sink (`conversions: conversionNotices`), which the nightly source-scan pins require of every exit. - **Folded, never recomputed.** There is no second conversion pass over the authored source and no reading of the producer's stderr line. Both were declined as consumer-side reconstructions (`5886671384`). - **Exactly once, by construction.** None of the three commands calls a stack producer itself (`git grep` over `packages/cli/src`: every `defineStack(` / `composeStacks(` hit is prose or scaffold text; the door dependencies `packages/lint/src` and `packages/objectql/src` hit only comments). So only the config module's load can throw a stamped refusal, and a throwing load comes before both other fillers of the list (step 1b's `loaded.stackConversions` fold and step 2's own pass). `stackConversionsOf` answers `[]` for every other throw: a plain `Error`, this CLI's own refusals (`refuseUnbuiltStack`, `ConfigRefusalError`) and non-refusal throws. - **`loadConfig` passes the refusal through untouched,** so `packages/cli/src/utils/config.ts` is not edited. Measured at `165c1d49e3` by calling `loadConfig` from source on the fixture below. The caught error is a `StackCapabilityUnknownError` (`code` `STACK_CAPABILITY_UNKNOWN`, `instanceof Error`) that carries the own symbol `objectstack.stack.conversions`, and `stackConversionsOf(error)` answers the one `page-header-subtitle-alias` notice. The canonical control answers `[]`. - The text face does not change. The fold sits inside the `--json` branch, and the producer's own stderr line is unchanged. - The comments that said "a throw at load reports `[]`" now name the one exception. `validate.ts`'s hoist note counts three fillers. - `test/validate-build-gate-parity.test.ts`: `stackConversionsOf` is now a bare call site in both `compile.ts` and `validate.ts`, so the closed call-site ledger needed a row for it. It gets its own `NOT_A_GATE` reason ("carries the conversion record a stack producer stamped on the refusal it threw ... refuses nothing"), not the nearest bucket. `os lint` still does not take the one-authoring-shape rule. An unbuilt default export is neither built nor refused by a producer, so nothing moves for it (#20367's OQ3 stays unopened). ## Measurements: the three doors through `bin/run-dev.js` (CLI from source) The fixture is a strict `defineStack` whose `page:header` authors `description` (the live conversion `page-header-subtitle-alias`) and which then declares `requires: ['no-such-capability']`. The control is the same config with `subtitle`. | run | before (`165c1d49e3`) | after (fold applied) | |:--|:--|:--| | `os validate --json`, convert then refuse | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []`, 1 producer stderr line | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions` = the one notice, 1 stderr line | | `os build --json`, the same config | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []`, 1 stderr line | exit 1, `STACK_CAPABILITY_UNKNOWN`, the one notice, 1 stderr line | | `os lint --json`, the same config | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []`, 1 stderr line | exit 1, `STACK_CAPABILITY_UNKNOWN`, the one notice, 1 stderr line | | all three, the canonical control | exit 1, `STACK_CAPABILITY_UNKNOWN`, `conversions: []`, 0 stderr lines | unchanged | The notice is `page-header-subtitle-alias` at `pages[0].regions[0].components[0].properties.subtitle`, `description` to `subtitle`, `retiresIn` 18. The "after" column was also read at the final head: at `dd6fa55374` all six rows answer exactly as in the table. ## Tests - **The pins,** in `packages/cli/test/stack-conversion-record-door.test.ts` (the per-PR `integration` tier; `*.e2e.*` files run nightly only, which is why the #20617 lint rows live here too). A new block drives triage's convert-then-refuse fixture through each door. Each row asserts exit 1, `code` `STACK_CAPABILITY_UNKNOWN`, the catch-all's `error` string plus the door's own verdict key (`valid: false` / `success: false`), and `conversions` equal to exactly the one notice. The control (canonical then refuse) asserts the same envelope with `conversions: []`. That is 6 new rows, and the file now holds 21. - At the final head `dd6fa55374`, through `scripts/pm/os-verify-lock.sh`: - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2`: `Test Files 238 passed (238)` / `Tests 3382 passed (3382)`. - `vitest run --project integration --maxWorkers=2 test/stack-conversion-record-door.test.ts`: `Test Files 1 passed (1)` / `Tests 21 passed (21)`. That is the 15 existing rows plus the 6 new ones. - `pnpm --filter @objectstack/cli typecheck`: exit 0, `check:test-typecheck: OK`. The door file is in the test-layer program (`tsc -p tsconfig.test.json --listFilesOnly`, 1 hit). - The nightly pins that read these catch-alls were run too, because the diff edits exactly what they assert. At `dd6fa55374`, `OS_TEST_TIERS=nightly vitest run` over `validate-json-failure-conversions.e2e.test.ts`, `build-json-failure-conversions.e2e.test.ts` and `lint-conversion-notices.e2e.test.ts` gave `Test Files 3 passed (3)` / `Tests 35 passed (35)`. That covers every exit still reading the one sink, the load-throw rows (a plain `Error`) still answering `[]`, and the normalize call still below `loadConfig`. - The first unit run, at `649236e024`, failed one test: `validate-build-gate-parity.test.ts` found the new call site unclassified. `dd6fa55374` adds the ledger row, and the run above is green. ## Ablation (reverse verification) Run from the committed state `a34e868fa4`. The fold line was deleted in all three doors at once: three nested WRAP legs of `node scripts/ablation-replace.mjs`, plus a shell `trap` restoring the three absolute paths with `git checkout HEAD --`. The CLI loads these files from `src/` through tsx, so no `dist/` sits on the measured path and no rebuild was needed. - **Landed on disk:** the anchor `conversionNotices.push(...stackConversionsOf(error));` went from 1 to 0 in each file. The blobs moved: `validate.ts` `11b7ea3e79e2` to `f744edd59909`, `compile.ts` `1e716a6e53fa` to `3f653b1a82ba`, `lint.ts` `4837e2246a13` to `5df926e26a3e`. - **Direction observed: red, and exactly where expected.** The result was `Tests 3 failed | 18 passed (21)`. The three red rows are the convert-then-refuse rows on `validate`, `build` and `lint`, each failing on `conversions`: `expected [] to deeply equal [ { …(5) } ]`. Their exit and `code` assertions held. The three controls and all 15 earlier rows stayed green. - **Restore proven:** each file's blob equals HEAD again (`11b7ea3e79e2`, `1e716a6e53fa`, `4837e2246a13`), the anchor count is 1 in each, and `git diff HEAD` over the three paths is 0 bytes. ## Gates At the final head `dd6fa55374`, in this worktree: - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 63 commands. I ran each one and captured its exit code before any pipe. - 61 exited 0 on the first run. - `check:dual-build-cjs-loads` and `check:i18n-coverage` first answered PREREQUISITE NOT MET (exit 3: no `dist/` for packages outside the CLI closure). After `pnpm turbo run build --filter=!@objectstack/docs`, both exited 0. `check:i18n-coverage` printed `OK (13 config(s), 621 baselined untranslated string(s), none new)`. - `--ran` reconciliation: `Run reconciliation — 63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero (all 63 carry an exit code). - `pnpm lint` (`eslint . --no-inline-config`, the whole repo): exit 0 at `dd6fa55374`. - Remote CI: not waited on. ## Acceptance notes - No hand-written docs page is touched. A grep of `content/docs` finds no page describing the `conversions` field of a failure payload, so no doc sentence was made false. - The macOS `TMPDIR` note on PR #20617 does not apply here: this run is on Linux. --- _Generated by [Claude Code](https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent aaad682 commit 87847a2

6 files changed

Lines changed: 148 additions & 14 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
**When a `defineStack` or `composeStacks` call converts a deprecated spelling and then refuses the config, `objectstack validate --json`, `objectstack build --json` and `objectstack lint --json` now report both the refusal and the ADR-0087 conversions it applied.**
6+
7+
`defineStack` rewrites a deprecated metadata spelling to its canonical shape when the config loads, such as `description` on a `page:header` component (canonical `subtitle`). When the same call then refused the config, for example on an unknown `requires` token (`STACK_CAPABILITY_UNKNOWN`), each of the three commands exited 1 with the refusal's `error` and `code` and with `conversions: []`. The conversion reached stderr only, as a warn-once line.
8+
9+
The refusal now carries the conversions the producer applied before it refused (`stackConversionsOf(error)` in `@objectstack/spec`), and each command adds them to the `conversions` list of its failure payload, beside the refusal. Each conversion is listed once. A refusal whose source needed no conversion, and any other failure at load, still answers `conversions: []`.
10+
11+
Nothing is accepted or refused differently: the exit code, `error`, `code` and every other key of each payload are unchanged, and no key is added. The text face is unchanged.
12+
13+
Clause-②: no

‎packages/cli/src/commands/compile.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
lintUnknownAuthoringKeys,
1212
lintUnknownStackKeys,
1313
formatUnknownAuthoringKey,
14+
stackConversionsOf,
1415
type ConversionNotice,
1516
} from '@objectstack/spec';
1617
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
@@ -239,7 +240,10 @@ export default class Compile extends Command {
239240
// the same `const` array the `onConversionNotice` sink pushes into, moved
240241
// above the `try` only so the catch-all exit can read it. `normalizeStackInput`
241242
// still runs at exactly step 2, so a run that throws in `loadConfig` — above
242-
// it — reports `[]` honestly, exactly as `warningsSoFar()` does there.
243+
// it — reports `[]` honestly, exactly as `warningsSoFar()` does there,
244+
// unless what it threw is a stack producer's refusal: that carries the
245+
// conversions the producer applied before refusing, and the catch-all
246+
// folds them (#20583) — step 1b's fold for the run whose load threw.
243247
//
244248
// ⛔ NOT FOLDED INTO `warningsSoFar()`, in either direction. The success
245249
// payload keeps these separate deliberately (see its note at `conversions:`
@@ -1135,6 +1139,16 @@ export default class Compile extends Command {
11351139
} catch (error: any) {
11361140
if (isExitSignal(error)) throw error;
11371141
if (flags.json) {
1142+
// [#20583] The ADR-0087 D2 conversions a stack PRODUCER applied before
1143+
// it REFUSED — `os validate`'s catch-all fold, one door over, for the
1144+
// same reason: a refusing `defineStack` / `composeStacks` returns no
1145+
// stack, so step 1b never ran, and the producer stamps what it had
1146+
// applied on the ADR-0112 refusal it throws. `stackConversionsOf`
1147+
// answers `[]` for any other throw. ⛔ Folded, never recomputed, never
1148+
// read off stderr. Cannot double-count: this command calls no producer
1149+
// itself, so only the config module's load can raise a stamped
1150+
// refusal, and a throwing load precedes both other fillers of this list.
1151+
conversionNotices.push(...stackConversionsOf(error));
11381152
await emitJson({ success: false, error: error.message, ...errorCodeFields(error), warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true });
11391153
this.exit(1);
11401154
}

‎packages/cli/src/commands/lint.ts‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { dirname } from 'node:path';
44
import { Args, Command, Flags } from '@oclif/core';
55
import chalk from 'chalk';
66
import { bundleRequire } from 'bundle-require';
7-
import { normalizeStackInput, type ConversionNotice } from '@objectstack/spec';
7+
import { normalizeStackInput, stackConversionsOf, type ConversionNotice } from '@objectstack/spec';
88
import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel';
99
import { GLOBAL_ACTION_OBJECT_KEY } from '@objectstack/objectql';
1010
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
@@ -931,7 +931,9 @@ export default class Lint extends Command {
931931
// commit 79cf692b0): every failure exit carries the lists the run has ALREADY
932932
// COMPUTED, so the field means the same thing on every exit. The CALL that
933933
// fills it stays below, at the step that owns it — a throw in `loadConfig`,
934-
// above it, reports `[]` honestly.
934+
// above it, reports `[]` honestly, unless what it threw is a stack
935+
// producer's refusal: that carries the conversions the producer applied
936+
// before refusing, and the catch-all folds them (#20583).
935937
//
936938
// ⛔ NOT FOLDED INTO `issues`. Whether an auto-converted key should become
937939
// a `LintIssue` — or, on the sibling commands, whether `warnings` and
@@ -1170,9 +1172,23 @@ export default class Lint extends Command {
11701172
} catch (error: any) {
11711173
if (isExitSignal(error)) throw error;
11721174
if (flags.json) {
1175+
// [#20583] The ADR-0087 D2 conversions a stack PRODUCER applied before
1176+
// it REFUSED — the fold right after `loadConfig` above, for the run
1177+
// whose load threw, and the same catch-all fold `os validate` /
1178+
// `os build` make. A refusing `defineStack` / `composeStacks` returns
1179+
// no stack, so that fold never ran; the producer stamps what it had
1180+
// applied on the ADR-0112 refusal it throws. `stackConversionsOf`
1181+
// answers `[]` for any other throw, so nothing moves for an unbuilt
1182+
// default export, which no producer built and none refused (the
1183+
// one-authoring-shape rule stays off this command). ⛔ Folded, never
1184+
// recomputed, never read off stderr. Cannot double-count: this command
1185+
// calls no producer itself, so only the config module's load can raise
1186+
// a stamped refusal, and a throwing load precedes both other fillers.
1187+
conversionNotices.push(...stackConversionsOf(error));
11731188
// [commit 9fd45a952] Whatever the run had reached before the throw, under the
11741189
// same 2026-08-25 ruling: `[]` for a throw in `loadConfig` — the
1175-
// normalize step never ran — and the notices in hand for any later one.
1190+
// normalize step never ran — except a producer's refusal, folded just
1191+
// above, and the notices in hand for any later one.
11761192
// Wiring the producer without this exit would ship a fresh instance of
11771193
// the defect commit 79cf692b0 fixed, one command over, on the day it was closed.
11781194
await emitJson(

‎packages/cli/src/commands/validate.ts‎

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
lintUnknownAuthoringKeys,
1111
lintUnknownStackKeys,
1212
formatUnknownAuthoringKey,
13+
stackConversionsOf,
1314
type ConversionNotice,
1415
} from '@objectstack/spec';
1516
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
@@ -193,7 +194,10 @@ export default class Validate extends Command {
193194
// the same `const` array the `onConversionNotice` sink pushes into, moved
194195
// above the `try` only so the catch-all exit can read it. `normalizeStackInput`
195196
// still runs at exactly step 2, so a run that throws in `loadConfig` — above
196-
// it — reports `[]` honestly, exactly as `warningsSoFar()` does there.
197+
// it — reports `[]` honestly, exactly as `warningsSoFar()` does there,
198+
// unless what it threw is a stack producer's refusal: that carries the
199+
// conversions the producer applied before refusing, which the run HAS
200+
// already computed, and the catch-all folds them (#20583).
197201
//
198202
// ⛔ NOT FOLDED INTO `warningsSoFar()`, in either direction. The two fields
199203
// are separate on the success payload by an explicit decision recorded at
@@ -205,13 +209,14 @@ export default class Validate extends Command {
205209
// authority to settle, so the shape is mirrored, not merged.
206210
//
207211
// No `conversionsSoFar()` wrapper: `warningsSoFar()` exists because five
208-
// producers had to be concatenated in ONE stated order. This list has two
209-
// fillers, and both push into this ONE array in the order the run reaches
210-
// them — step 1b folds the record the stack producer left on the default
212+
// producers had to be concatenated in ONE stated order. This list has three
213+
// fillers, and each pushes into this ONE array in the order the run reaches
214+
// it — step 1b folds the record the stack producer left on the default
211215
// export (`loaded.stackConversions`), step 2's own pass appends what it
212-
// converts on the merged stack — so reading the binding directly already
213-
// is the "a list cannot drift from itself" idiom the wrapper was built to
214-
// buy.
216+
// converts on the merged stack, and the catch-all folds the record a
217+
// producer's REFUSAL carries when the load threw one (so on that run the
218+
// other two never ran) — so reading the binding directly already is the
219+
// "a list cannot drift from itself" idiom the wrapper was built to buy.
215220
const conversionNotices: ConversionNotice[] = [];
216221

217222
try {
@@ -905,6 +910,19 @@ export default class Validate extends Command {
905910
} catch (error: any) {
906911
if (isExitSignal(error)) throw error;
907912
if (flags.json) {
913+
// [#20583] The ADR-0087 D2 conversions a stack PRODUCER applied before
914+
// it REFUSED — step 1b's fold, for the run whose load threw. A refusing
915+
// `defineStack` / `composeStacks` returns no stack, so step 1b never
916+
// ran; the producer stamps what it had applied on the ADR-0112 refusal
917+
// it throws instead, and `stackConversionsOf` reads it off the caught
918+
// error — `[]` for any other throw (a plain `Error`, this command's own
919+
// refusals). ⛔ Folded, never recomputed: no second conversion pass
920+
// over the authored source, no reading of the producer's stderr line
921+
// (warn-once per process, so it can be missing). Cannot double-count:
922+
// this command calls no producer itself, so only the config module's
923+
// load can raise a stamped refusal, and a throwing load precedes both
924+
// other fillers of this list.
925+
conversionNotices.push(...stackConversionsOf(error));
908926
await emitJson({
909927
valid: false,
910928
error: error.message,
@@ -916,7 +934,9 @@ export default class Validate extends Command {
916934
// the three lists already in hand.
917935
warnings: warningsSoFar(),
918936
// [commit 79cf692b0] Same reading, one field over: `[]` for a throw at load —
919-
// step 2 had not run — and the notices in hand for any later throw.
937+
// step 2 had not run — except a producer's refusal, which carries the
938+
// conversions it applied (folded just above); the notices in hand for
939+
// any later throw.
920940
conversions: conversionNotices,
921941
duration: timer.elapsed(),
922942
});

‎packages/cli/test/stack-conversion-record-door.test.ts‎

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,24 @@
4141
* unbuilt default export still lints and still converts through the command's
4242
* own pass (`lint-conversion-notices.e2e.test.ts` pins that half).
4343
*
44+
* ## Convert, then REFUSE — the refusal carries the record, on all three doors (#20583)
45+
*
46+
* A strict `defineStack` that converts the retiring spelling and then refuses
47+
* (`requires: ['no-such-capability']`, `STACK_CAPABILITY_UNKNOWN`) returns no
48+
* stack, so there is no record on a default export to fold. The producer stamps
49+
* the notices it applied on the refusal it throws instead, and each door's
50+
* catch-all folds `stackConversionsOf(error)` into `conversions` beside the
51+
* refusal. Before that fold all three answered `conversions: []` and the notice
52+
* reached stderr alone.
53+
*
54+
* | door (`--json`) | converts, then refuses | canonical, then refuses (control) |
55+
* |:-------------------------|:-----------------------------|:----------------------------------|
56+
* | `os validate` / `build` / `lint` | exit 1, the refusal's `code`, the one notice | exit 1, the refusal's `code`, `[]` |
57+
*
58+
* The control holds the other direction: the same refusal from a source that
59+
* needed no conversion answers `[]`, so an exit that reports the notice without
60+
* reading it off the refusal it caught is red there.
61+
*
4462
* `page-header-subtitle-alias` is the live conversion driven here (`description`
4563
* on a `page:header` component, canonical `subtitle`). The day it retires from
4664
* the load path the non-empty rows go red; re-point the fixture at a live entry
@@ -131,10 +149,10 @@ const page = (ns: string, headerKey: 'description' | 'subtitle') => `{
131149
] }],
132150
}`;
133151

134-
const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null) => `{
152+
const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null, requires?: string) => `{
135153
manifest: ${manifest(ns)},
136154
objects: [{ name: '${ns}_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }],
137-
apps: [{ name: '${ns}_app', label: 'App' }],${headerKey ? `\n pages: [${page(ns, headerKey)}],` : ''}
155+
apps: [{ name: '${ns}_app', label: 'App' }],${headerKey ? `\n pages: [${page(ns, headerKey)}],` : ''}${requires ? `\n requires: ['${requires}'],` : ''}
138156
}`;
139157

140158
const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\n\n`;
@@ -160,6 +178,13 @@ const FIXTURES: Record<string, string> = {
160178
`export default defineStack(${stackBody('nep', null)});\n`,
161179
// The control: the canonical spelling.
162180
canonical: IMPORT + `export default defineStack(${stackBody('can', 'subtitle')});\n`,
181+
// Convert, then refuse: the strict (default) `defineStack` converts the
182+
// retiring spelling, then refuses an unknown capability token.
183+
convertThenRefuse:
184+
IMPORT + `export default defineStack(${stackBody('ctr', 'description', 'no-such-capability')});\n`,
185+
// Its control: the same refusal from a source that needed no conversion.
186+
canonicalThenRefuse:
187+
IMPORT + `export default defineStack(${stackBody('ctc', 'subtitle', 'no-such-capability')});\n`,
163188
};
164189

165190
let root = '';
@@ -265,6 +290,43 @@ describe("os lint --json — the producer's conversion record reaches `conversio
265290
}, 180_000);
266291
});
267292

293+
describe("convert, then REFUSE — the refusal carries the conversions into each door's `--json` (#20583)", () => {
294+
// The catch-all exit on every door: the load threw the producer's refusal, so
295+
// the record rides on the error, not on a returned stack.
296+
type Door = 'validate' | 'build' | 'lint';
297+
const DOORS: Record<Door, (label: string) => string[]> = {
298+
validate: () => ['validate', '--json'],
299+
build: (label) => ['build', '--json', '-o', join(dirs[label], 'out', 'objectstack.json')],
300+
lint: () => ['lint', '--json'],
301+
};
302+
const REFUSAL_CODE = 'STACK_CAPABILITY_UNKNOWN';
303+
304+
/** The catch-all payload's own verdict, per door (`os lint`'s is the `error` string alone). */
305+
function expectCatchAll(command: Door, run: Run, p: Payload & { error?: unknown; code?: unknown }, label: string): void {
306+
expect(run.code, `${label}: the refusal fails the run\n${run.stdout}${run.stderr}`).toBe(1);
307+
expect(p.code, `${label}: the producer's refusal, unwrapped`).toBe(REFUSAL_CODE);
308+
expect(typeof p.error, `${label}: the catch-all exit`).toBe('string');
309+
if (command === 'validate') expect(p.valid).toBe(false);
310+
if (command === 'build') expect(p.success).toBe(false);
311+
}
312+
313+
for (const command of Object.keys(DOORS) as Door[]) {
314+
it(`os ${command} --json: exit 1, the refusal, and the one notice the producer applied before refusing`, async () => {
315+
const run = await runCli(DOORS[command]('convertThenRefuse'), dirs.convertThenRefuse);
316+
const p = payloadOf(run, `${command} convertThenRefuse`);
317+
expectCatchAll(command, run, p, `${command} convertThenRefuse`);
318+
expectExactly(p, [THE_NOTICE], `${command} convertThenRefuse`);
319+
}, 180_000);
320+
321+
it(`os ${command} --json control: the same refusal from a source that needed no conversion — \`[]\``, async () => {
322+
const run = await runCli(DOORS[command]('canonicalThenRefuse'), dirs.canonicalThenRefuse);
323+
const p = payloadOf(run, `${command} canonicalThenRefuse`);
324+
expectCatchAll(command, run, p, `${command} canonicalThenRefuse`);
325+
expectExactly(p, [], `${command} canonicalThenRefuse`);
326+
}, 180_000);
327+
}
328+
});
329+
268330
describe('os validate --strict — a conversion the producer applied fails it, on both faces', () => {
269331
it('the retiring spelling: exit 1 on the text face and under --json', async () => {
270332
const text = await runCli(['validate', '--strict'], dirs.recordAcrossSpread);

‎packages/cli/test/validate-build-gate-parity.test.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,15 @@ const NOT_A_GATE: Readonly<Record<string, readonly string[]>> = {
242242
// `test/rls-policy-authoring-admission.test.ts`.
243243
'The engine judge handed to the authoring-rule registry as an input — the rule that reads it is the gate':
244244
['stackFilterJudge'],
245+
// [#20583] The catch-all's fold: it reads the ADR-0087 conversion record a
246+
// stack PRODUCER stamped on the refusal it threw at load — the refusing half
247+
// of step 1b's `loaded.stackConversions`, which is a property read and so
248+
// never reached this scan. It raises no finding and refuses nothing: the
249+
// refusal was already thrown, the conversions were already applied by the
250+
// producer, and the exit is 1 whether it answers the record or `[]`. Both
251+
// commands call it (and `lint.ts`), so no parity gap sits behind it either.
252+
'Carries the conversion record a stack producer stamped on the refusal it threw — the producer judged; this reads, and refuses nothing':
253+
['stackConversionsOf'],
245254
// [#18431] Artifact ASSEMBLY, and deliberately not a `BUILD_ONLY_GATES` row.
246255
// That ledger's entries are gates that cannot run read-only (they rewrite a
247256
// committed snapshot, or emit a sibling module); filing this one there would

0 commit comments

Comments
 (0)