Skip to content
Merged
15 changes: 15 additions & 0 deletions .changeset/20476-define-stack-conversions-reach-doors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/spec": minor
"@objectstack/cli": minor
---

**`objectstack validate` and `objectstack build` now report the ADR-0087 conversions `defineStack` applied, and `objectstack validate --strict` fails on them.**

`defineStack` rewrites a deprecated metadata spelling to its canonical shape when the config loads, in either mode, and prints one `defineStack: PATH: 'OLD' → 'NEW' (converted at load; conversion 'ID', retires in protocol N)` line on stderr. The two commands received that already-converted stack, so their own conversion pass found nothing to convert: `--json` answered `conversions: []` for every `defineStack` config, and `objectstack validate --strict` exited 0 on a spelling that stops loading in a named protocol major. A CI job gating on either could not see the retirement coming.

- `@objectstack/spec`: `defineStack` (both modes) records every conversion notice it applied on the stack it returns, beside the provenance mark and stamped in the same act. The record is non-enumerable and frozen, so the schema, `Object.keys` and `JSON.stringify` never see it and no compiled artifact changes. `composeStacks` records its inputs' records in input order, counting the same built stack passed twice once. **New export:** `stackConversionsOf(value)` returns the `ConversionNotice[]` a producer recorded, the same element the commands' `conversions` field publishes, and `[]` for a value no producer returned. Like the mark, the record does not survive a spread or JSON copy.
- `@objectstack/cli`: the config loader reads the record off the default export before it merges named exports into it (that merge is a spread, which drops the record as it drops the mark). `objectstack validate` and `objectstack build` add it to their `conversions` list right after the config loads. Their own conversion pass still runs, and still reports what it converts on a key merged in from a named export of the config module, which `defineStack` never saw. The `--json` envelope keeps its shape (`valid` / `success`, `errors`, `warnings`, `conversions`): `conversions` now lists each conversion once.

**What a CI job sees:** `objectstack validate --strict` and `objectstack validate --json --strict` now exit 1 for a config whose only advisory is a live conversion, which is what `--strict` ("treat warnings as errors") documents. Without `--strict` the exit stays 0. The fix is the one the notice names: author the canonical spelling it prints, for example `subtitle` instead of `description` on a `page:header` component. The text face lists the conversion in its warning block. The stderr line from `defineStack` is unchanged and still printed once per conversion.

Clause-②: yes (widening) — one new export, `stackConversionsOf`, on the spec package root. Nothing `objectstack build`, or `objectstack validate` without `--strict`, accepted before is refused. `--strict` now applies its documented meaning to the conversions a `defineStack` config carries. It does not add a new rule.
14 changes: 13 additions & 1 deletion packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,15 @@ export default class Compile extends Command {
// catch-all below (`--json`: `error` + `code`, exit 1), the same
// envelope a `defineStack` refusal raised at load reaches.
refuseUnbuiltStack(loaded);
// 1b. The ADR-0087 D2 conversions the PRODUCER applied — the same fold
// `os validate` makes at its step 1b, for the same reason: `defineStack`
// converts at load, so step 2's pass below finds nothing of the
// default export left to convert, and `conversions` read `[]` on every
// `defineStack` config. Read by `loadConfig` off the default export
// before its named-export merge (`stackConversionsOf`). ⛔ Folded,
// never recomputed. Rendered on the text face at step 2 with the
// pass's own findings, in this one list.
conversionNotices.push(...loaded.stackConversions);

if (!flags.json) {
printKV('Config', path.relative(process.cwd(), absolutePath));
Expand All @@ -277,7 +286,10 @@ export default class Compile extends Command {
// bites harder than it reads, because the notice is the ONLY warning an
// old-shape author gets before the conversion retires and their metadata
// stops loading. Five conversions are live today (protocol 11 and 15),
// so the gap is real, not hypothetical.
// so the gap is real, not hypothetical. After step 1b the pass can still
// find what the producer never saw — a key `loadConfig` merged onto the
// stack from a NAMED export of the config module — and appends it to
// the same list.
if (!flags.json) printStep('Normalizing stack definition...');
// The sink is declared above the `try` (see its note there); the CALL that
// fills it stays right here, at the step that owns it.
Expand Down
87 changes: 59 additions & 28 deletions packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,19 @@
* was written: `description` → text `--strict` 1, `--json --strict` 1, `--json`
* 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices.
*
* ⚠️ Re-judged under the one-authoring-shape ruling (#20367): `os validate`
* refuses a default export `defineStack` did not build, and `defineStack`
* applies the conversion itself at load (stderr notice), so the door's
* `conversions` is empty and the cell below is unreachable by an accepted
* config. The pin now holds what IS true — both faces agree at exit 0, the
* notice fires in the producer — and the anti-vacuity guard reads stderr.
* Under the one-authoring-shape ruling (#20367) every accepted config is
* `defineStack` output, and `defineStack` applies the conversion itself at
* load — so the door's own pass converts nothing, and for one release this
* cell was unreachable: both faces exited 0 with `conversions: []`. It is
* reachable again (#20476) because the producer RECORDS the conversions it
* applied on the stack it returns (`stackConversionsOf`) and the door folds
* that record into the list `--strict` gates on and into `conversions`. The
* cell below is back to the measurement above: `description` → both `--strict`
* faces 1, `--json` alone 0, `warnings: []`, the one notice.
*
* The live-notice assertion (on stderr since the re-judgement) is the anti-vacuity guard, and it is
* load-bearing rather than decorative. `page-header-subtitle-alias` is a LIVE
* The live-notice assertion (the payload entry, and the producer's one
* stderr line) is the anti-vacuity guard, and it is load-bearing rather than
* decorative. `page-header-subtitle-alias` is a LIVE
* window that retires from the load path at protocol 18; the day it retires,
* this fixture raises nothing and, without that assertion, the file would keep
* passing while pinning an empty cell — precisely the failure this test exists
Expand Down Expand Up @@ -292,42 +296,69 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(0);
}, 120_000);

it('conversions-only (ruling B): the PRODUCER consumes the conversion at load — both faces agree at exit 0', async () => {
// Re-judged under the one-authoring-shape ruling (#20367). A config is now
// always `defineStack(…)` output, and `defineStack` runs the D2 conversion
// itself (either mode) and reports it on stderr, so the door's own
// `normalizeStackInput` has nothing left to convert: the #11301 cell —
// `{ valid: true, warnings: [], conversions: [...] }` at exit 1 — is no
// longer reachable by a config the door accepts. ⚠️ Recorded, not endorsed:
// `--strict` therefore does not gate on a retiring conversion for ANY
// accepted config (it never did for a `defineStack` one); the PR reports
// that as an open finding rather than widening this change to fix it.
it('conversions-only: a retiring conversion the PRODUCER applied fails --strict on BOTH faces', async () => {
// [#20476] `defineStack` converts at load and records what it applied on
// the stack it returns; the door folds that record into the list `--strict`
// gates on and into `conversions`. So the #11301 cell holds for the one
// authoring shape the door accepts: `{ valid: true, warnings: [],
// conversions: [the notice] }` at exit 1.
const text = await runCli(['validate', '--strict'], conversionsDir);
const json = await runCli(['validate', '--json', '--strict'], conversionsDir);

// Parity, the #11174 contract this file exists for, still holds.
expect(text.code, `text --strict:\n${text.stdout}\n${text.stderr}`).toBe(0);
// Floor, then parity — the #11174 contract this file exists for.
expect(text.code, `text --strict must fail on a retiring conversion:\n${text.stdout}\n${text.stderr}`).toBe(1);
expect(json.code, `json --strict:\n${json.stdout}\n${json.stderr}`).toBe(text.code);
expect(text.stdout).toContain('Strict mode: warnings treated as errors');

const payload = JSON.parse(json.stdout) as {
valid?: unknown;
warnings?: unknown;
conversions?: unknown;
conversions?: Array<Record<string, unknown>>;
};
// `valid: true` beside exit 1: the stack is schema-valid; `--strict` is what
// promotes the conversion to a failure.
expect(payload.valid).toBe(true);
// The cell's defining property: the exit is decided by `conversions`, a
// collection absent from `warnings`.
expect(payload.warnings).toEqual([]);
expect(payload.conversions, 'the door computed no conversion: the producer already applied it').toEqual([]);
expect(
(payload.conversions ?? []).map((n) => ({ conversionId: n.conversionId, path: n.path, from: n.from, to: n.to })),
'the producer-applied conversion reaches the payload, once',
).toEqual([
{
conversionId: 'page-header-subtitle-alias',
path: 'pages[0].regions[0].components[0].properties.subtitle',
from: 'description',
to: 'subtitle',
},
]);
expect(typeof payload.conversions?.[0]?.retiresIn, 'the expiry rides the entry').toBe('number');

// The text face names what failed it, in its `⚠` block.
expect(text.stdout).toContain("conversion 'page-header-subtitle-alias'");

// Anti-vacuity: the conversion is LIVE — it fired, in the producer, on both
// faces. The day `page-header-subtitle-alias` retires this goes red; re-point
// `headerPageSource` at a live entry in `packages/spec/src/conversions/registry.ts`.
// Anti-vacuity AND the one-stderr-line property: the conversion is LIVE —
// it fired, in the producer, once per run — and no door-side line repeats it
// on stderr. The day `page-header-subtitle-alias` retires this goes red;
// re-point `headerPageSource` at a live entry in
// `packages/spec/src/conversions/registry.ts`.
for (const run of [text, json]) {
expect(run.stderr, 'defineStack reported the conversion at load').toContain(
"conversion 'page-header-subtitle-alias'",
);
expect(
run.stderr.split("conversion 'page-header-subtitle-alias'").length - 1,
'defineStack reported the conversion at load, on exactly one stderr line',
).toBe(1);
}
}, 120_000);

it('conversions-only, without --strict: the same config exits 0 and still lists the conversion', async () => {
// Separates "gates on --strict" from "fails whenever a conversion is
// listed": the notice is advisory until `--strict` promotes it.
const json = await runCli(['validate', '--json'], conversionsDir);
expect(json.code, `--json without --strict must stay 0:\n${json.stdout}\n${json.stderr}`).toBe(0);
const payload = JSON.parse(json.stdout) as { conversions?: Array<{ conversionId?: unknown }> };
expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['page-header-subtitle-alias']);
}, 120_000);

it('control: the same page under the CANONICAL key converts nothing and exits 0 on both faces', async () => {
// The discriminator. Byte-identical to the fixture above but for one key,
// so a pin that passed here too would be pinning the presence of a page.
Expand Down
24 changes: 20 additions & 4 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,13 @@ 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, and this list has
// exactly one producer. Reading the binding directly already is the "a list
// cannot drift from itself" idiom the wrapper was built to buy.
// 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
// 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.
const conversionNotices: ConversionNotice[] = [];

try {
Expand All @@ -222,6 +226,16 @@ export default class Validate extends Command {
// catch-all below (`--json`: `error` + `code`, exit 1), the same
// envelope a `defineStack` refusal raised at load reaches.
refuseUnbuiltStack(loaded);
// 1b. The ADR-0087 D2 conversions the PRODUCER applied. `defineStack`
// converts at load (either mode), so the stack this door received is
// already canonical and step 2's pass below has nothing of it left to
// convert: without this fold `conversions` read `[]` and `--strict`
// passed on every `defineStack` config carrying a retiring spelling.
// Read by `loadConfig` off the default export before its named-export
// merge (`stackConversionsOf`, beside the provenance mark). ⛔ Folded,
// never recomputed: a second conversion pass here would disagree with
// what was loaded. After 1a, so a refused export reports none.
conversionNotices.push(...loaded.stackConversions);

if (!flags.json) {
printKV('Config', absolutePath);
Expand All @@ -232,7 +246,9 @@ export default class Validate extends Command {
// The ADR-0087 D2 conversion layer runs here (inside normalizeStackInput);
// surface each applied conversion as a non-blocking deprecation notice so
// the author knows the source still carries an old-shape key that will
// retire from the load path in a future major.
// retire from the load path in a future major. What it can still find
// after step 1b is what the producer never saw: a key `loadConfig`
// merged onto the stack from a NAMED export of the config module.
if (!flags.json) printStep('Validating against ObjectStack Protocol...');
// The sink is declared above the `try` (see its note there); the CALL that
// fills it stays right here, at the step that owns it.
Expand Down
22 changes: 21 additions & 1 deletion packages/cli/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { pathToFileURL } from 'node:url';
import chalk from 'chalk';
import { bundleRequire } from 'bundle-require';
import type { Plugin } from 'esbuild';
import { hasStackProvenance } from '@objectstack/spec';
import { hasStackProvenance, stackConversionsOf, type ConversionNotice } from '@objectstack/spec';
import { printErrorToStderr, printWarningToStderr } from './format.js';

export interface LoadedConfig {
Expand Down Expand Up @@ -57,6 +57,22 @@ export interface LoadedConfig {
* `refuseUnbuiltStack`); every other command reads the config as before.
*/
stackProvenance: boolean;

/**
* The ADR-0087 D2 conversions the stack producer applied while building the
* DEFAULT export — `stackConversionsOf` (`@objectstack/spec`) read off
* `mod.default` itself, beside {@link stackProvenance} and for the same
* reason: the record rides beside the mark, non-enumerable, so the
* named-export merge below drops it just as it drops the mark.
*
* `defineStack` converts at load, so `config` is already canonical and a
* command re-running the conversion pass over it finds nothing the producer
* converted. This is the only place those conversions can be read from:
* `os validate` / `os build` fold it into their `conversions` field and the
* `--strict` gate. `[]` for an unbuilt export and for a source that needed
* no conversion.
*/
stackConversions: readonly ConversionNotice[];
}

/**
Expand Down Expand Up @@ -456,6 +472,9 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions):
// is non-enumerable by design). `mod` stands in for a missing default, and a
// module namespace never carries the mark.
const stackProvenance = hasStackProvenance(baseConfig);
// The producer's conversion record rides beside the mark and is dropped by
// the same spread, so it is read here too, off the same value.
const stackConversions = stackConversionsOf(baseConfig);

// Preserve named exports (e.g. the `onEnable` runtime hook and `functions`)
// alongside the default-exported stack. Module-namespace named exports are
Expand Down Expand Up @@ -509,6 +528,7 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions):
namedExports,
shadowedNamedExports,
stackProvenance,
stackConversions,
};
}

Expand Down
Loading
Loading