From dd7f3c886a1a8ec6276cd57d93392843cfbd5af5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:00:37 +0000 Subject: [PATCH 1/4] fix(cli): the three doors' catch-alls fold the conversions a refusing stack producer applied A defineStack / composeStacks that converts an ADR-0087 D2 spelling and then refuses stamps the notices it applied on its ADR-0112 refusal (stackConversionsOf(error)). os validate, os build and os lint now fold that record into the --json `conversions` of their catch-all exit, beside the refusal. Folded, never recomputed; `[]` for any other throw. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/src/commands/compile.ts | 16 +++++++++++- packages/cli/src/commands/lint.ts | 22 +++++++++++++--- packages/cli/src/commands/validate.ts | 36 +++++++++++++++++++++------ 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 0dd6cd86d22..1e716a6e53f 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -11,6 +11,7 @@ import { lintUnknownAuthoringKeys, lintUnknownStackKeys, formatUnknownAuthoringKey, + stackConversionsOf, type ConversionNotice, } from '@objectstack/spec'; import { loadConfig, namedExportRejectionHints } from '../utils/config.js'; @@ -239,7 +240,10 @@ export default class Compile extends Command { // the same `const` array the `onConversionNotice` sink pushes into, moved // above the `try` only so the catch-all exit can read it. `normalizeStackInput` // still runs at exactly step 2, so a run that throws in `loadConfig` — above - // it — reports `[]` honestly, exactly as `warningsSoFar()` does there. + // it — reports `[]` honestly, exactly as `warningsSoFar()` does there, + // unless what it threw is a stack producer's refusal: that carries the + // conversions the producer applied before refusing, and the catch-all + // folds them (#20583) — step 1b's fold for the run whose load threw. // // ⛔ NOT FOLDED INTO `warningsSoFar()`, in either direction. The success // payload keeps these separate deliberately (see its note at `conversions:` @@ -1135,6 +1139,16 @@ export default class Compile extends Command { } catch (error: any) { if (isExitSignal(error)) throw error; if (flags.json) { + // [#20583] The ADR-0087 D2 conversions a stack PRODUCER applied before + // it REFUSED — `os validate`'s catch-all fold, one door over, for the + // same reason: a refusing `defineStack` / `composeStacks` returns no + // stack, so step 1b never ran, and the producer stamps what it had + // applied on the ADR-0112 refusal it throws. `stackConversionsOf` + // answers `[]` for any other throw. ⛔ Folded, never recomputed, never + // read off stderr. Cannot double-count: this command calls no producer + // itself, so only the config module's load can raise a stamped + // refusal, and a throwing load precedes both other fillers of this list. + conversionNotices.push(...stackConversionsOf(error)); await emitJson({ success: false, error: error.message, ...errorCodeFields(error), warnings: warningsSoFar(), conversions: conversionNotices }, 0, { compact: true }); this.exit(1); } diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 56422eeeaeb..4837e2246a1 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -4,7 +4,7 @@ import { dirname } from 'node:path'; import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; -import { normalizeStackInput, type ConversionNotice } from '@objectstack/spec'; +import { normalizeStackInput, stackConversionsOf, type ConversionNotice } from '@objectstack/spec'; import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { GLOBAL_ACTION_OBJECT_KEY } from '@objectstack/objectql'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; @@ -931,7 +931,9 @@ export default class Lint extends Command { // commit 79cf692b0): every failure exit carries the lists the run has ALREADY // COMPUTED, so the field means the same thing on every exit. The CALL that // fills it stays below, at the step that owns it — a throw in `loadConfig`, - // above it, reports `[]` honestly. + // above it, reports `[]` honestly, unless what it threw is a stack + // producer's refusal: that carries the conversions the producer applied + // before refusing, and the catch-all folds them (#20583). // // ⛔ NOT FOLDED INTO `issues`. Whether an auto-converted key should become // a `LintIssue` — or, on the sibling commands, whether `warnings` and @@ -1170,9 +1172,23 @@ export default class Lint extends Command { } catch (error: any) { if (isExitSignal(error)) throw error; if (flags.json) { + // [#20583] The ADR-0087 D2 conversions a stack PRODUCER applied before + // it REFUSED — the fold right after `loadConfig` above, for the run + // whose load threw, and the same catch-all fold `os validate` / + // `os build` make. A refusing `defineStack` / `composeStacks` returns + // no stack, so that fold never ran; the producer stamps what it had + // applied on the ADR-0112 refusal it throws. `stackConversionsOf` + // answers `[]` for any other throw, so nothing moves for an unbuilt + // default export, which no producer built and none refused (the + // one-authoring-shape rule stays off this command). ⛔ Folded, never + // recomputed, never read off stderr. Cannot double-count: this command + // calls no producer itself, so only the config module's load can raise + // a stamped refusal, and a throwing load precedes both other fillers. + conversionNotices.push(...stackConversionsOf(error)); // [commit 9fd45a952] Whatever the run had reached before the throw, under the // same 2026-08-25 ruling: `[]` for a throw in `loadConfig` — the - // normalize step never ran — and the notices in hand for any later one. + // normalize step never ran — except a producer's refusal, folded just + // above, and the notices in hand for any later one. // Wiring the producer without this exit would ship a fresh instance of // the defect commit 79cf692b0 fixed, one command over, on the day it was closed. await emitJson( diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 5c311e8171d..11b7ea3e79e 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -10,6 +10,7 @@ import { lintUnknownAuthoringKeys, lintUnknownStackKeys, formatUnknownAuthoringKey, + stackConversionsOf, type ConversionNotice, } from '@objectstack/spec'; import { loadConfig, namedExportRejectionHints } from '../utils/config.js'; @@ -193,7 +194,10 @@ export default class Validate extends Command { // the same `const` array the `onConversionNotice` sink pushes into, moved // above the `try` only so the catch-all exit can read it. `normalizeStackInput` // still runs at exactly step 2, so a run that throws in `loadConfig` — above - // it — reports `[]` honestly, exactly as `warningsSoFar()` does there. + // it — reports `[]` honestly, exactly as `warningsSoFar()` does there, + // unless what it threw is a stack producer's refusal: that carries the + // conversions the producer applied before refusing, which the run HAS + // already computed, and the catch-all folds them (#20583). // // ⛔ NOT FOLDED INTO `warningsSoFar()`, in either direction. The two fields // are separate on the success payload by an explicit decision recorded at @@ -205,13 +209,14 @@ export default class Validate extends Command { // authority to settle, so the shape is mirrored, not merged. // // No `conversionsSoFar()` wrapper: `warningsSoFar()` exists because five - // producers had to be concatenated in ONE stated order. This list has two - // fillers, and both push into this ONE array in the order the run reaches - // them — step 1b folds the record the stack producer left on the default + // producers had to be concatenated in ONE stated order. This list has three + // fillers, and each pushes into this ONE array in the order the run reaches + // it — step 1b folds the record the stack producer left on the default // export (`loaded.stackConversions`), step 2's own pass appends what it - // converts on the merged stack — so reading the binding directly already - // is the "a list cannot drift from itself" idiom the wrapper was built to - // buy. + // converts on the merged stack, and the catch-all folds the record a + // producer's REFUSAL carries when the load threw one (so on that run the + // other two never ran) — so reading the binding directly already is the + // "a list cannot drift from itself" idiom the wrapper was built to buy. const conversionNotices: ConversionNotice[] = []; try { @@ -905,6 +910,19 @@ export default class Validate extends Command { } catch (error: any) { if (isExitSignal(error)) throw error; if (flags.json) { + // [#20583] The ADR-0087 D2 conversions a stack PRODUCER applied before + // it REFUSED — step 1b's fold, for the run whose load threw. A refusing + // `defineStack` / `composeStacks` returns no stack, so step 1b never + // ran; the producer stamps what it had applied on the ADR-0112 refusal + // it throws instead, and `stackConversionsOf` reads it off the caught + // error — `[]` for any other throw (a plain `Error`, this command's own + // refusals). ⛔ Folded, never recomputed: no second conversion pass + // over the authored source, no reading of the producer's stderr line + // (warn-once per process, so it can be missing). Cannot double-count: + // this command calls no producer itself, so only the config module's + // load can raise a stamped refusal, and a throwing load precedes both + // other fillers of this list. + conversionNotices.push(...stackConversionsOf(error)); await emitJson({ valid: false, error: error.message, @@ -916,7 +934,9 @@ export default class Validate extends Command { // the three lists already in hand. warnings: warningsSoFar(), // [commit 79cf692b0] Same reading, one field over: `[]` for a throw at load — - // step 2 had not run — and the notices in hand for any later throw. + // step 2 had not run — except a producer's refusal, which carries the + // conversions it applied (folded just above); the notices in hand for + // any later throw. conversions: conversionNotices, duration: timer.elapsed(), }); From a34e868fa474b48a46cce98a060593b53e9b65fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:01:50 +0000 Subject: [PATCH 2/4] test(cli): pin convert-then-refuse on os validate / build / lint --json A strict defineStack that converts page:header `description` and then refuses `requires: ['no-such-capability']` answers exit 1, STACK_CAPABILITY_UNKNOWN and exactly the one page-header-subtitle-alias notice on each door; the canonical-then-refuse control answers `[]`. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .../test/stack-conversion-record-door.test.ts | 66 ++++++++++++++++++- 1 file changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/cli/test/stack-conversion-record-door.test.ts b/packages/cli/test/stack-conversion-record-door.test.ts index 7ffaf14dcf4..ba8c850fbf1 100644 --- a/packages/cli/test/stack-conversion-record-door.test.ts +++ b/packages/cli/test/stack-conversion-record-door.test.ts @@ -41,6 +41,24 @@ * unbuilt default export still lints and still converts through the command's * own pass (`lint-conversion-notices.e2e.test.ts` pins that half). * + * ## Convert, then REFUSE — the refusal carries the record, on all three doors (#20583) + * + * A strict `defineStack` that converts the retiring spelling and then refuses + * (`requires: ['no-such-capability']`, `STACK_CAPABILITY_UNKNOWN`) returns no + * stack, so there is no record on a default export to fold. The producer stamps + * the notices it applied on the refusal it throws instead, and each door's + * catch-all folds `stackConversionsOf(error)` into `conversions` beside the + * refusal. Before that fold all three answered `conversions: []` and the notice + * reached stderr alone. + * + * | door (`--json`) | converts, then refuses | canonical, then refuses (control) | + * |:-------------------------|:-----------------------------|:----------------------------------| + * | `os validate` / `build` / `lint` | exit 1, the refusal's `code`, the one notice | exit 1, the refusal's `code`, `[]` | + * + * The control holds the other direction: the same refusal from a source that + * needed no conversion answers `[]`, so an exit that reports the notice without + * reading it off the refusal it caught is red there. + * * `page-header-subtitle-alias` is the live conversion driven here (`description` * on a `page:header` component, canonical `subtitle`). The day it retires from * 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') => `{ ] }], }`; -const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null) => `{ +const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null, requires?: string) => `{ manifest: ${manifest(ns)}, objects: [{ name: '${ns}_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }], - apps: [{ name: '${ns}_app', label: 'App' }],${headerKey ? `\n pages: [${page(ns, headerKey)}],` : ''} + apps: [{ name: '${ns}_app', label: 'App' }],${headerKey ? `\n pages: [${page(ns, headerKey)}],` : ''}${requires ? `\n requires: ['${requires}'],` : ''} }`; const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\n\n`; @@ -160,6 +178,13 @@ const FIXTURES: Record = { `export default defineStack(${stackBody('nep', null)});\n`, // The control: the canonical spelling. canonical: IMPORT + `export default defineStack(${stackBody('can', 'subtitle')});\n`, + // Convert, then refuse: the strict (default) `defineStack` converts the + // retiring spelling, then refuses an unknown capability token. + convertThenRefuse: + IMPORT + `export default defineStack(${stackBody('ctr', 'description', 'no-such-capability')});\n`, + // Its control: the same refusal from a source that needed no conversion. + canonicalThenRefuse: + IMPORT + `export default defineStack(${stackBody('ctc', 'subtitle', 'no-such-capability')});\n`, }; let root = ''; @@ -265,6 +290,43 @@ describe("os lint --json — the producer's conversion record reaches `conversio }, 180_000); }); +describe("convert, then REFUSE — the refusal carries the conversions into each door's `--json` (#20583)", () => { + // The catch-all exit on every door: the load threw the producer's refusal, so + // the record rides on the error, not on a returned stack. + type Door = 'validate' | 'build' | 'lint'; + const DOORS: Record string[]> = { + validate: () => ['validate', '--json'], + build: (label) => ['build', '--json', '-o', join(dirs[label], 'out', 'objectstack.json')], + lint: () => ['lint', '--json'], + }; + const REFUSAL_CODE = 'STACK_CAPABILITY_UNKNOWN'; + + /** The catch-all payload's own verdict, per door (`os lint`'s is the `error` string alone). */ + function expectCatchAll(command: Door, run: Run, p: Payload & { error?: unknown; code?: unknown }, label: string): void { + expect(run.code, `${label}: the refusal fails the run\n${run.stdout}${run.stderr}`).toBe(1); + expect(p.code, `${label}: the producer's refusal, unwrapped`).toBe(REFUSAL_CODE); + expect(typeof p.error, `${label}: the catch-all exit`).toBe('string'); + if (command === 'validate') expect(p.valid).toBe(false); + if (command === 'build') expect(p.success).toBe(false); + } + + for (const command of Object.keys(DOORS) as Door[]) { + it(`os ${command} --json: exit 1, the refusal, and the one notice the producer applied before refusing`, async () => { + const run = await runCli(DOORS[command]('convertThenRefuse'), dirs.convertThenRefuse); + const p = payloadOf(run, `${command} convertThenRefuse`); + expectCatchAll(command, run, p, `${command} convertThenRefuse`); + expectExactly(p, [THE_NOTICE], `${command} convertThenRefuse`); + }, 180_000); + + it(`os ${command} --json control: the same refusal from a source that needed no conversion — \`[]\``, async () => { + const run = await runCli(DOORS[command]('canonicalThenRefuse'), dirs.canonicalThenRefuse); + const p = payloadOf(run, `${command} canonicalThenRefuse`); + expectCatchAll(command, run, p, `${command} canonicalThenRefuse`); + expectExactly(p, [], `${command} canonicalThenRefuse`); + }, 180_000); + } +}); + describe('os validate --strict — a conversion the producer applied fails it, on both faces', () => { it('the retiring spelling: exit 1 on the text face and under --json', async () => { const text = await runCli(['validate', '--strict'], dirs.recordAcrossSpread); From 64bf48ddf121e7f0992db87eb32207b3d0005b68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:17:25 +0000 Subject: [PATCH 3/4] chore(changeset): @objectstack/cli patch for the refusal's conversions on --json Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- .changeset/20583-refusal-conversions-json.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .changeset/20583-refusal-conversions-json.md diff --git a/.changeset/20583-refusal-conversions-json.md b/.changeset/20583-refusal-conversions-json.md new file mode 100644 index 00000000000..10c382e9a35 --- /dev/null +++ b/.changeset/20583-refusal-conversions-json.md @@ -0,0 +1,13 @@ +--- +"@objectstack/cli": patch +--- + +**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.** + +`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. + +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: []`. + +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. + +Clause-②: no From dd6fa5537450a9b13d982abead4b9e543a39725b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 18:43:27 +0000 Subject: [PATCH 4/4] test(cli): classify stackConversionsOf in the validate/build call-site ledger The catch-all fold is a call site both doors now make; it reads the record a producer stamped on its refusal and judges nothing. Claude-Session: https://claude.ai/code/session_01VvcEokUG1tvVxkceYfR5XB Co-authored-by: Claude --- packages/cli/test/validate-build-gate-parity.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/cli/test/validate-build-gate-parity.test.ts b/packages/cli/test/validate-build-gate-parity.test.ts index 278a2758c7c..ebd134d64d8 100644 --- a/packages/cli/test/validate-build-gate-parity.test.ts +++ b/packages/cli/test/validate-build-gate-parity.test.ts @@ -242,6 +242,15 @@ const NOT_A_GATE: Readonly> = { // `test/rls-policy-authoring-admission.test.ts`. 'The engine judge handed to the authoring-rule registry as an input — the rule that reads it is the gate': ['stackFilterJudge'], + // [#20583] The catch-all's fold: it reads the ADR-0087 conversion record a + // stack PRODUCER stamped on the refusal it threw at load — the refusing half + // of step 1b's `loaded.stackConversions`, which is a property read and so + // never reached this scan. It raises no finding and refuses nothing: the + // refusal was already thrown, the conversions were already applied by the + // producer, and the exit is 1 whether it answers the record or `[]`. Both + // commands call it (and `lint.ts`), so no parity gap sits behind it either. + 'Carries the conversion record a stack producer stamped on the refusal it threw — the producer judged; this reads, and refuses nothing': + ['stackConversionsOf'], // [#18431] Artifact ASSEMBLY, and deliberately not a `BUILD_ONLY_GATES` row. // That ledger's entries are gates that cannot run read-only (they rewrite a // committed snapshot, or emit a sibling module); filing this one there would