Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/20583-refusal-conversions-json.md
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
lintUnknownAuthoringKeys,
lintUnknownStackKeys,
formatUnknownAuthoringKey,
stackConversionsOf,
type ConversionNotice,
} from '@objectstack/spec';
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
Expand Down Expand Up @@ -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:`
Expand Down Expand Up @@ -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);
}
Expand Down
22 changes: 19 additions & 3 deletions packages/cli/src/commands/lint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
36 changes: 28 additions & 8 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
lintUnknownAuthoringKeys,
lintUnknownStackKeys,
formatUnknownAuthoringKey,
stackConversionsOf,
type ConversionNotice,
} from '@objectstack/spec';
import { loadConfig, namedExportRejectionHints } from '../utils/config.js';
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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(),
});
Expand Down
66 changes: 64 additions & 2 deletions packages/cli/test/stack-conversion-record-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`;
Expand All @@ -160,6 +178,13 @@ const FIXTURES: Record<string, string> = {
`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 = '';
Expand Down Expand Up @@ -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<Door, (label: string) => 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);
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/test/validate-build-gate-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,15 @@ const NOT_A_GATE: Readonly<Record<string, readonly string[]>> = {
// `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
Expand Down
Loading