diff --git a/.changeset/20476-define-stack-conversions-reach-doors.md b/.changeset/20476-define-stack-conversions-reach-doors.md new file mode 100644 index 00000000000..8b392c8a865 --- /dev/null +++ b/.changeset/20476-define-stack-conversions-reach-doors.md @@ -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. diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index acfb1d60301..b1ae356ae83 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -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)); @@ -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. diff --git a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts index f59849cc38b..4f7ea829f89 100644 --- a/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts +++ b/packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts @@ -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 @@ -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>; }; + // `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. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index d5d28f0ea74..0d4bbdf6fcc 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -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 { @@ -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); @@ -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. diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts index 29d291a14a2..ec9f5b86c38 100644 --- a/packages/cli/src/utils/config.ts +++ b/packages/cli/src/utils/config.ts @@ -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 { @@ -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[]; } /** @@ -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 @@ -509,6 +528,7 @@ export async function loadConfig(source?: string, options?: LoadConfigOptions): namedExports, shadowedNamedExports, stackProvenance, + stackConversions, }; } diff --git a/packages/cli/test/build-json-failure-conversions.e2e.test.ts b/packages/cli/test/build-json-failure-conversions.e2e.test.ts index ac7dd2eae6a..3b3b1b2b3ab 100644 --- a/packages/cli/test/build-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/build-json-failure-conversions.e2e.test.ts @@ -1,15 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * ⚠️ RE-JUDGED under the one-authoring-shape ruling (#20367). The command now - * refuses a default export `defineStack` did not build, and `defineStack` - * applies every ADR-0087 D2 conversion itself at load (either mode), reporting - * it on stderr. So the door's step-2 sink converts nothing for any accepted - * config and every exit below carries `conversions: []` — which is still - * exactly "what the run computed". The pins now assert that, plus the live - * notice on the producer's stderr line (`expectTheOneNotice`). Whether the - * producer's notices should reach this envelope is an open question the PR - * reports; it was not this change's to decide. + * ⭐ The producer's record reaches every exit (#20476). Under the + * one-authoring-shape ruling (#20367) the command accepts only a default export + * `defineStack` built, and `defineStack` applies every ADR-0087 D2 conversion + * itself at load (either mode) — so the door's own step-2 pass has nothing of + * the default export left to convert, and these pins spent one release + * recording `conversions: []` beside the producer's stderr line. The producer + * now RECORDS what it applied on the stack it returns (`stackConversionsOf`, + * beside the provenance mark); `loadConfig` reads it off the default export and + * the command folds it into its one `conversions` sink at step 1b, right after + * load. So every exit below carries the notice again, asserted whole by + * identity (`expectTheOneNotice`), and the producer's stderr line is asserted + * to appear exactly ONCE — the payload now carries the notice, and the terminal + * gains no second stderr line for it. * * #12125 — `os build --json`'s FAILURE payloads dropped the `conversions` field * the run had ALREADY COMPUTED, on all nine of its failure exits. @@ -60,12 +64,15 @@ * the same exit with the canonical `kind: 'html'` and requires `[]`, which is * the negative whose positive is every other test here. * - * ## Why no `dist/` sits on the measured path + * ## Which half of the measured path is `dist/` * * These run the CLI through `bin/run-dev.js`, the SOURCE entry point (src/ via * tsx), so `compile.ts` is loaded from source and an ablation of it is measured * without a rebuild. Its DEPENDENCY `@objectstack/spec` — which owns the - * conversion — resolves through `exports` to `dist/`, and is untouched here. + * conversion AND the record (`stackConversionsOf`) — resolves through `exports` + * to `dist/`, in the child and in the fixture's own `defineStack` alike: an + * edit to the spec half is measured here only after + * `pnpm --filter @objectstack/spec build`. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -187,17 +194,28 @@ const THE_NOTICE = { /** Asserts EXACTLY the one computed notice. `toEqual` is the "and NO MORE" half. */ function expectTheOneNotice(payload: Record, label: string, run: Run): void { - // [#20367 ruling B] Re-judged. The door accepts only `defineStack` output, - // and `defineStack` applies the D2 conversion at load (either mode) and - // reports it on stderr — so the notice is computed by the PRODUCER, and the - // door's own step-2 sink has nothing left to convert. What this exit carries - // is therefore exactly what the door computed: `[]`, asserted whole. The - // notice itself is asserted where it now lives — the producer's stderr line, - // by conversion id AND path, so a different conversion cannot satisfy it. - expect(conversionsOf(payload), `${label}: the door computes no conversion for a defineStack export`).toEqual([]); - expect(run.stderr, `${label}: the producer reported the conversion at load`).toContain( - `defineStack: ${THE_NOTICE.path}: '${THE_NOTICE.from}' → '${THE_NOTICE.to}' (converted at load; conversion '${THE_NOTICE.conversionId}'`, - ); + // [#20476] The notice is computed by the PRODUCER — `defineStack` converts at + // load — and reaches this exit through the record it left on the default + // export, folded at step 1b. Asserted whole: exactly one entry, carrying the + // identity, the site, the direction and the expiry, so neither a different + // conversion nor a second copy of this one can satisfy it. + const entries = conversionsOf(payload) as Array>; + expect( + entries.map((n) => ({ + conversionId: n.conversionId, + surface: n.surface, + from: n.from, + to: n.to, + path: n.path, + })), + `${label}: the producer's conversion reaches the payload, once`, + ).toEqual([THE_NOTICE]); + expect(entries[0].code, `${label}: the entry is the conversion layer's own notice`).toBe('OS_METADATA_CONVERTED'); + expect(typeof entries[0].retiresIn, `${label}: the expiry rides the entry`).toBe('number'); + // The producer's stderr line stays, and stays ONE line: the envelope now + // carries the notice, and nothing on the door's side repeats it on stderr. + const producerLine = `defineStack: ${THE_NOTICE.path}: '${THE_NOTICE.from}' → '${THE_NOTICE.to}' (converted at load; conversion '${THE_NOTICE.conversionId}'`; + expect(run.stderr.split(producerLine).length - 1, `${label}: one stderr line for the one conversion`).toBe(1); } const dirs: Record = {}; @@ -283,7 +301,7 @@ describe('#12125 — every `os build --json` failure exit carries the conversion const payload = payloadOf(run, 'control'); expect(payload.success).toBe(true); expectTheOneNotice(payload, 'control', run); - // The expiry still reaches the author — on the producer's line. + // The expiry rides the payload entry (`expectTheOneNotice`) and the producer's line. expect(run.stderr).toMatch(/conversion 'page-kind-jsx-to-html', retires in protocol \d+\)/); }, 180_000); diff --git a/packages/cli/test/stack-conversion-record-door.test.ts b/packages/cli/test/stack-conversion-record-door.test.ts new file mode 100644 index 00000000000..decfd6fa134 --- /dev/null +++ b/packages/cli/test/stack-conversion-record-door.test.ts @@ -0,0 +1,230 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ADR-0087 conversions a stack PRODUCER applied reach `os validate` and + * `os build` — `--json` `conversions` and `os validate --strict` (#20476). + * + * ## What was wrong + * + * `defineStack` applies every ADR-0087 D2 conversion at load (either mode) and + * said so only on stderr. Both doors accept nothing but `defineStack` / + * `composeStacks` output (#20367 ruling B), so the stack they received was + * already canonical and their own conversion pass found nothing to convert: + * `--json` answered `conversions: []` for every such config and + * `os validate --strict` exited 0 on a retiring spelling — the one advisory + * class that carries an expiry, invisible to the CI job gating on it. + * + * ## What is pinned — each `--json` row at BOTH doors + * + * | default export | `conversions` | + * |:------------------------------------------------------------|:---------------------------| + * | `defineStack` with a retiring spelling + a named export | the one notice — the record | + * | (`onEnable`): `loadConfig` merges with a spread | is read off the default | + * | | BEFORE the spread drops it | + * | `composeStacks([built-with-it, built-without])` | the one notice, from the | + * | | input that applied it | + * | canonical `defineStack` + a NAMED export `pages` carrying | the one notice — the door's | + * | the retiring spelling (merged after the producer ran) | own pass, which stays | + * | the canonical spelling (control) | `[]` | + * + * Every non-empty row asserts EXACTLY one entry, so the fold and the door's own + * pass cannot both report one conversion. And `os validate --strict` exits 1 on + * both faces for the retiring spelling, 0 for the control. + * + * `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 + * in `packages/spec/src/conversions/registry.ts`. + * + * The CLI runs from source (`bin/run-dev.js`), but `@objectstack/spec` — the + * producer and the record's reader — resolves through `exports` to its + * `dist/`, in the child and in the fixture's own `defineStack` alike. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { execFile } from 'node:child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { childEnv } from './helpers/serve-process.js'; +import { linkSpec } from './helpers/define-stack-fixture.js'; + +const HERE = resolve(fileURLToPath(import.meta.url), '..'); +const CLI = resolve(HERE, '../bin/run-dev.js'); +const TSX = resolve(HERE, '../../../node_modules/.bin/tsx'); + +interface Run { + code: number; + stdout: string; + stderr: string; +} + +function runCli(args: string[], cwd: string): Promise { + return new Promise((resolvePromise) => { + execFile( + TSX, + [CLI, ...args], + { cwd, maxBuffer: 16 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) }, + (err, stdout, stderr) => { + resolvePromise({ + code: err ? (typeof (err as { code?: unknown }).code === 'number' ? (err as unknown as { code: number }).code : 1) : 0, + stdout: String(stdout), + stderr: String(stderr), + }); + }, + ); + }); +} + +interface Payload { + valid?: boolean; + success?: boolean; + warnings?: unknown[]; + conversions?: Array>; +} + +function payloadOf(run: Run, label: string): Payload { + try { + return JSON.parse(run.stdout) as Payload; + } catch { + throw new Error(`${label}: stdout was not one JSON document (exit ${run.code})\n${run.stdout}\n${run.stderr}`); + } +} + +/** The one entry every non-empty row must carry, by identity, site and direction. */ +const THE_NOTICE = { + code: 'OS_METADATA_CONVERTED', + conversionId: 'page-header-subtitle-alias', + path: 'pages[0].regions[0].components[0].properties.subtitle', + from: 'description', + to: 'subtitle', +}; + +function expectExactly(payload: Payload, expected: Array, label: string): void { + const entries = payload.conversions; + expect(Array.isArray(entries), `${label}: \`conversions\` is present`).toBe(true); + expect( + entries!.map((n) => ({ code: n.code, conversionId: n.conversionId, path: n.path, from: n.from, to: n.to })), + label, + ).toEqual(expected); + for (const n of entries!) expect(typeof n.retiresIn, `${label}: the expiry rides the entry`).toBe('number'); +} + +const manifest = (ns: string) => + `{ id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }`; + +const page = (ns: string, headerKey: 'description' | 'subtitle') => `{ + name: '${ns}_home', + label: 'Home', + regions: [{ name: 'main', components: [ + { type: 'page:header', properties: { title: 'Things', ${headerKey}: 'All things' } }, + ] }], + }`; + +const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null) => `{ + 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)}],` : ''} +}`; + +const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\n\n`; + +const FIXTURES: Record = { + // The record, across `loadConfig`'s named-export spread. + recordAcrossSpread: + IMPORT + + `export const onEnable = async () => {};\n\n` + + `export default defineStack(${stackBody('rec', 'description')});\n`, + // The record of a composed stack: its inputs' records. + composed: + IMPORT + + `const withIt = defineStack(${stackBody('cmpa', 'description')});\n` + + `const without = defineStack({ manifest: ${manifest('cmpb')}, objects: [{ name: 'cmpb_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }] });\n\n` + + `export default composeStacks([withIt, without]);\n`, + // A key merged after the producer ran: only the door's own pass sees it. + namedExportPass: + IMPORT + + `export const pages = [${page('nep', 'description')}];\n\n` + + `export default defineStack(${stackBody('nep', null)});\n`, + // The control: the canonical spelling. + canonical: IMPORT + `export default defineStack(${stackBody('can', 'subtitle')});\n`, +}; + +let root = ''; +const dirs: Record = {}; + +beforeAll(() => { + root = mkdtempSync(join(tmpdir(), 'os-stack-conversion-record-')); + for (const [label, source] of Object.entries(FIXTURES)) { + const dir = join(root, label); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'objectstack.config.ts'), source); + linkSpec(dir); + dirs[label] = dir; + } +}); + +afterAll(() => { + if (root) rmSync(root, { recursive: true, force: true }); +}); + +for (const command of ['validate', 'build'] as const) { + const ok = (p: Payload) => (command === 'validate' ? p.valid : p.success); + const args = (label: string) => + command === 'build' ? ['build', '--json', '-o', join(dirs[label], 'out', 'objectstack.json')] : ['validate', '--json']; + + describe(`os ${command} --json — the producer's conversion record reaches \`conversions\``, () => { + it('defineStack + a named export: the record is read off the default before the named-export spread', async () => { + const run = await runCli(args('recordAcrossSpread'), dirs.recordAcrossSpread); + const p = payloadOf(run, command); + expect(run.code, run.stdout + run.stderr).toBe(0); + expect(ok(p)).toBe(true); + expectExactly(p, [THE_NOTICE], `${command} recordAcrossSpread`); + }, 180_000); + + it('composeStacks: the composed stack carries the record of the input that applied the conversion', async () => { + const run = await runCli(args('composed'), dirs.composed); + const p = payloadOf(run, command); + expect(run.code, run.stdout + run.stderr).toBe(0); + expectExactly(p, [THE_NOTICE], `${command} composed`); + }, 180_000); + + it("a key merged from a named export: the door's own pass still converts it — once", async () => { + const run = await runCli(args('namedExportPass'), dirs.namedExportPass); + const p = payloadOf(run, command); + expect(run.code, run.stdout + run.stderr).toBe(0); + expectExactly(p, [THE_NOTICE], `${command} namedExportPass`); + // The producer never saw that key, so it printed nothing for it. + expect(run.stderr).not.toContain("conversion 'page-header-subtitle-alias'"); + }, 180_000); + + it('control: the canonical spelling converts nothing — `[]`', async () => { + const run = await runCli(args('canonical'), dirs.canonical); + const p = payloadOf(run, command); + expect(run.code, run.stdout + run.stderr).toBe(0); + expectExactly(p, [], `${command} canonical`); + }, 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); + const json = await runCli(['validate', '--json', '--strict'], dirs.recordAcrossSpread); + expect(text.code, text.stdout + text.stderr).toBe(1); + expect(text.stdout).toContain('Strict mode: warnings treated as errors'); + expect(text.stdout).toContain("conversion 'page-header-subtitle-alias'"); + expect(json.code, json.stdout + json.stderr).toBe(1); + const p = payloadOf(json, 'validate --json --strict'); + expect(p.valid).toBe(true); + expect(p.warnings).toEqual([]); + expectExactly(p, [THE_NOTICE], 'validate --json --strict'); + }, 180_000); + + it('control: the canonical spelling exits 0 on both faces', async () => { + const text = await runCli(['validate', '--strict'], dirs.canonical); + const json = await runCli(['validate', '--json', '--strict'], dirs.canonical); + expect(text.code, text.stdout + text.stderr).toBe(0); + expect(json.code, json.stdout + json.stderr).toBe(0); + }, 180_000); +}); diff --git a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts index 524e3f1ffa4..5d7157ac535 100644 --- a/packages/cli/test/validate-json-failure-conversions.e2e.test.ts +++ b/packages/cli/test/validate-json-failure-conversions.e2e.test.ts @@ -1,15 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * ⚠️ RE-JUDGED under the one-authoring-shape ruling (#20367). The command now - * refuses a default export `defineStack` did not build, and `defineStack` - * applies every ADR-0087 D2 conversion itself at load (either mode), reporting - * it on stderr. So the door's step-2 sink converts nothing for any accepted - * config and every exit below carries `conversions: []` — which is still - * exactly "what the run computed". The pins now assert that, plus the live - * notice on the producer's stderr line (`expectTheOneNotice`). Whether the - * producer's notices should reach this envelope is an open question the PR - * reports; it was not this change's to decide. + * ⭐ The producer's record reaches every exit (#20476). Under the + * one-authoring-shape ruling (#20367) the command accepts only a default export + * `defineStack` built, and `defineStack` applies every ADR-0087 D2 conversion + * itself at load (either mode) — so the door's own step-2 pass has nothing of + * the default export left to convert, and these pins spent one release + * recording `conversions: []` beside the producer's stderr line. The producer + * now RECORDS what it applied on the stack it returns (`stackConversionsOf`, + * beside the provenance mark); `loadConfig` reads it off the default export and + * the command folds it into its one `conversions` sink at step 1b, right after + * load. So every exit below carries the notice again, asserted whole by + * identity (`expectTheOneNotice`), and the producer's stderr line is asserted + * to appear exactly ONCE — the payload now carries the notice, and the terminal + * gains no second stderr line for it. * * #12125 — `os validate --json`'s FAILURE payloads dropped the `conversions` * field the run had ALREADY COMPUTED, on all five of its failure exits. @@ -87,13 +91,15 @@ * field is shown to track what the run actually computed. That is the negative * whose positive is every other test in this file. * - * ## Why no `dist/` sits on the measured path + * ## Which half of the measured path is `dist/` * * These run the CLI through `bin/run-dev.js`, "the SOURCE entry point — same * CLI, run from `src/` through tsx". `validate.ts` is loaded from source by the * child, so an ablation of that file is measured without a rebuild. Its - * DEPENDENCY `@objectstack/spec` — which owns the conversion itself — does - * resolve through `exports` to `dist/`, and this change does not touch it. + * DEPENDENCY `@objectstack/spec` — which owns the conversion AND the record + * (`stackConversionsOf`) — resolves through `exports` to `dist/`, in the child + * and in the fixture's own `defineStack` alike: an edit to the spec half is + * measured here only after `pnpm --filter @objectstack/spec build`. */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -206,17 +212,28 @@ const THE_NOTICE = { * the whole array is the "and NO MORE" half. */ function expectTheOneNotice(payload: Record, label: string, run: Run): void { - // [#20367 ruling B] Re-judged. The door accepts only `defineStack` output, - // and `defineStack` applies the D2 conversion at load (either mode) and - // reports it on stderr — so the notice is computed by the PRODUCER, and the - // door's own step-2 sink has nothing left to convert. What this exit carries - // is therefore exactly what the door computed: `[]`, asserted whole. The - // notice itself is asserted where it now lives — the producer's stderr line, - // by conversion id AND path, so a different conversion cannot satisfy it. - expect(conversionsOf(payload), `${label}: the door computes no conversion for a defineStack export`).toEqual([]); - expect(run.stderr, `${label}: the producer reported the conversion at load`).toContain( - `defineStack: ${THE_NOTICE.path}: '${THE_NOTICE.from}' → '${THE_NOTICE.to}' (converted at load; conversion '${THE_NOTICE.conversionId}'`, - ); + // [#20476] The notice is computed by the PRODUCER — `defineStack` converts at + // load — and reaches this exit through the record it left on the default + // export, folded at step 1b. Asserted whole: exactly one entry, carrying the + // identity, the site, the direction and the expiry, so neither a different + // conversion nor a second copy of this one can satisfy it. + const entries = conversionsOf(payload) as Array>; + expect( + entries.map((n) => ({ + conversionId: n.conversionId, + surface: n.surface, + from: n.from, + to: n.to, + path: n.path, + })), + `${label}: the producer's conversion reaches the payload, once`, + ).toEqual([THE_NOTICE]); + expect(entries[0].code, `${label}: the entry is the conversion layer's own notice`).toBe('OS_METADATA_CONVERTED'); + expect(typeof entries[0].retiresIn, `${label}: the expiry rides the entry`).toBe('number'); + // The producer's stderr line stays, and stays ONE line: the envelope now + // carries the notice, and nothing on the door's side repeats it on stderr. + const producerLine = `defineStack: ${THE_NOTICE.path}: '${THE_NOTICE.from}' → '${THE_NOTICE.to}' (converted at load; conversion '${THE_NOTICE.conversionId}'`; + expect(run.stderr.split(producerLine).length - 1, `${label}: one stderr line for the one conversion`).toBe(1); } const dirs: Record = {}; @@ -287,8 +304,8 @@ describe('#12125 — every `os validate --json` failure exit carries the convers const payload = payloadOf(run, 'control'); expect(payload.valid).toBe(true); expectTheOneNotice(payload, 'control', run); - // The expiry is the reason this field cannot just be dropped into prose. - // The expiry still reaches the author — on the producer's line. + // The expiry is the reason this field cannot just be dropped into prose: + // it rides the payload entry (`expectTheOneNotice`) and the producer's line. expect(run.stderr).toMatch(/conversion 'page-kind-jsx-to-html', retires in protocol \d+\)/); }, 120_000); diff --git a/packages/spec/api-surface/root.json b/packages/spec/api-surface/root.json index 959e44f43ff..8c02d15cf7b 100644 --- a/packages/spec/api-surface/root.json +++ b/packages/spec/api-surface/root.json @@ -230,6 +230,7 @@ "objectStackErrorMap (const)", "partitionAssembledViewArtifacts (function)", "safeParsePretty (function)", + "stackConversionsOf (function)", "suggestFieldType (function)", "tmpl (function)" ] diff --git a/packages/spec/export-origins/root.json b/packages/spec/export-origins/root.json index 1b16fb90886..3beef4f743f 100644 --- a/packages/spec/export-origins/root.json +++ b/packages/spec/export-origins/root.json @@ -229,6 +229,7 @@ "objectStackErrorMap": "src/shared/error-map.zod.ts#objectStackErrorMap (const)", "partitionAssembledViewArtifacts": "src/ui/assembled-views.zod.ts#partitionAssembledViewArtifacts (function)", "safeParsePretty": "src/shared/error-map.zod.ts#safeParsePretty (function)", + "stackConversionsOf": "src/stack-provenance.ts#stackConversionsOf (function)", "suggestFieldType": "src/shared/field-type-suggestion.ts#suggestFieldType (function)", "tmpl": "src/shared/expression.zod.ts#tmpl (function)" } diff --git a/packages/spec/src/stack-conversions-record.test.ts b/packages/spec/src/stack-conversions-record.test.ts new file mode 100644 index 00000000000..952e78e74d4 --- /dev/null +++ b/packages/spec/src/stack-conversions-record.test.ts @@ -0,0 +1,278 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The conversion record beside the stack provenance mark — `stackConversionsOf` + * (`stack-provenance.ts`). + * + * `defineStack` applies every ADR-0087 D2 conversion at load, in both modes, + * so the stack it returns is already canonical and a door re-running the pass + * on it converts nothing. What the doors report under `conversions` (and what + * `os validate --strict` gates on) therefore has to come from the producer, as + * a record on the value it returns. What is pinned here: + * + * - `defineStack` (strict and `strict: false`) records each conversion it + * applied, whole, as the `ConversionNotice` the conversion layer emitted — + * and records nothing for the canonical spelling (the control, on a stack + * that IS marked, so `[]` is not the unmarked answer); + * - the record is not subject to the stderr warn-once: a second build of the + * same source carries it too; + * - it is invisible to every data reader (keys, JSON, the strict schema) and + * frozen; + * - it is dropped exactly where the mark is dropped, and a forged record on + * an unmarked value is not read; + * - `composeStacks`: the inputs' records concatenated in input order, one + * application once, at every arity and through nesting. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + composeStacks, + defineStack, + hasStackProvenance, + stackConversionsOf, + ObjectStackDefinitionSchema, + type ObjectStackDefinition, +} from './stack.zod'; +import { CONVERSION_NOTICE_CODE, type ConversionNotice } from './conversions/types'; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** + * A stack with one `page:header` component whose second line is authored + * under `headerKey` — `description` is the alias the live conversion + * `page-header-subtitle-alias` rewrites to `subtitle`; `subtitle` is canonical. + * No standalone actions, so the same built stack can be composed twice. + */ +const source = (ns: string, headerKey: 'description' | 'subtitle', pageKind: 'jsx' | 'html' = 'html') => ({ + manifest: { id: `com.example.${ns}`, name: ns, version: '1.0.0', type: 'app' as const, namespace: ns }, + objects: [{ name: `${ns}_thing`, label: 'Thing', fields: { title: { type: 'text' as const, label: 'Title' } } }], + pages: [ + { + name: `${ns}_home`, + label: 'Home', + regions: [ + { + name: 'main', + components: [{ type: 'page:header', properties: { title: 'Things', [headerKey]: 'All things' } }], + }, + ], + }, + ...(pageKind === 'jsx' ? [{ name: `${ns}_landing`, label: 'Landing', kind: 'jsx', source: '
hi
' }] : []), + ], +}); + +const HEADER_PATH = 'pages[0].regions[0].components[0].properties.subtitle'; + +/** The substance a door publishes per entry: identity, site, direction, expiry. */ +const substance = (n: ConversionNotice) => ({ + code: n.code, + conversionId: n.conversionId, + path: n.path, + from: n.from, + to: n.to, + retiresIn: n.retiresIn, +}); + +const HEADER_NOTICE = { + code: CONVERSION_NOTICE_CODE, + conversionId: 'page-header-subtitle-alias', + path: HEADER_PATH, + from: 'description', + to: 'subtitle', + retiresIn: 18, +}; + +const KIND_NOTICE = { + code: CONVERSION_NOTICE_CODE, + conversionId: 'page-kind-jsx-to-html', + path: 'pages[1].kind', + from: 'jsx', + to: 'html', +}; + +const quiet = () => vi.spyOn(console, 'warn').mockImplementation(() => {}); + +describe('defineStack records the conversions it applied', () => { + it('strict: the one conversion, whole, as the conversion layer emitted it', () => { + quiet(); + const built = defineStack(source('aa', 'description') as never); + const record = stackConversionsOf(built); + expect(record.map(substance)).toEqual([HEADER_NOTICE]); + // The element IS the `ConversionNotice` the doors' `conversions` field + // declares — every field, not a second shape. + expect(Object.keys(record[0]).sort()).toEqual( + ['code', 'conversionId', 'from', 'message', 'path', 'retiresIn', 'surface', 'to', 'toMajor'].sort(), + ); + // …and the stack itself IS canonical: the record is of what was done to it. + const header = (built.pages as Array<{ regions: Array<{ components: Array<{ properties: Record }> }> }>)[0] + .regions[0].components[0].properties; + expect(header.subtitle).toBe('All things'); + expect('description' in header).toBe(false); + }); + + it('strict: false records it too — the conversion runs in both modes', () => { + quiet(); + const built = defineStack(source('aa', 'description') as never, { strict: false }); + expect(stackConversionsOf(built).map(substance)).toEqual([HEADER_NOTICE]); + }); + + it('two conversions in one source are recorded in the order they were applied', () => { + quiet(); + const built = defineStack(source('aa', 'description', 'jsx') as never, { strict: false }); + const record = stackConversionsOf(built).map(substance); + expect(record).toHaveLength(2); + expect(record).toContainEqual(HEADER_NOTICE); + expect(record).toContainEqual(expect.objectContaining(KIND_NOTICE)); + }); + + it('⭐ control: the canonical spelling records nothing, on a stack that IS marked', () => { + const warn = quiet(); + for (const built of [ + defineStack(source('aa', 'subtitle') as never), + defineStack(source('aa', 'subtitle') as never, { strict: false }), + ]) { + expect(hasStackProvenance(built), '`[]` here must not be the unmarked answer').toBe(true); + expect(stackConversionsOf(built)).toEqual([]); + } + expect(warn).not.toHaveBeenCalledWith(expect.stringContaining("conversion 'page-header-subtitle-alias'")); + }); + + it('is not subject to the stderr warn-once: a second build of the same source carries the record too', () => { + const warn = quiet(); + const ns = 'warnonce'; + const first = defineStack(source(ns, 'description') as never); + const second = defineStack(source(ns, 'description') as never); + const lines = warn.mock.calls + .map((c) => String(c[0])) + .filter((l) => l.includes("conversion 'page-header-subtitle-alias'") && l.includes(HEADER_PATH)); + // stderr says it at most once per process (an earlier test may have said it already)… + expect(lines.length).toBeLessThanOrEqual(1); + // …while each built stack keeps its own record. + expect(stackConversionsOf(first).map(substance)).toEqual([HEADER_NOTICE]); + expect(stackConversionsOf(second).map(substance)).toEqual([HEADER_NOTICE]); + }); + + it('a built stack handed straight back to defineStack keeps the record it was built with', () => { + quiet(); + const inner = defineStack(source('aa', 'description') as never, { strict: false }); + const outer = defineStack(inner as never, { strict: false }); + expect(outer).not.toBe(inner); + expect(stackConversionsOf(outer).map(substance)).toEqual([HEADER_NOTICE]); + }); +}); + +describe('the record is invisible to every data reader, and frozen', () => { + const built = () => { + quiet(); + return defineStack(source('aa', 'description') as never); + }; + + it('is not an own string key and is non-enumerable, non-writable, non-configurable', () => { + const stack = built(); + const sym = Symbol.for('objectstack.stack.conversions'); + expect(Object.keys(stack).some((k) => k.includes('conversion'))).toBe(false); + const desc = Object.getOwnPropertyDescriptor(stack, sym); + expect(desc?.enumerable).toBe(false); + expect(desc?.writable).toBe(false); + expect(desc?.configurable).toBe(false); + }); + + it('is frozen, entries included', () => { + const record = stackConversionsOf(built()); + expect(record, 'anti-vacuity: an empty record is frozen by construction').toHaveLength(1); + expect(Object.isFrozen(record)).toBe(true); + expect(record.every((n) => Object.isFrozen(n))).toBe(true); + expect(() => (record as ConversionNotice[]).push(record[0])).toThrow(TypeError); + }); + + it('never reaches JSON, and the strict stack schema neither sees nor refuses it', () => { + const stack = built(); + expect(stackConversionsOf(stack), 'anti-vacuity: there IS a record to leak').toHaveLength(1); + expect(JSON.stringify(stack)).not.toContain('page-header-subtitle-alias'); + expect(ObjectStackDefinitionSchema.safeParse(stack).success).toBe(true); + }); +}); + +describe('stackConversionsOf answers [] wherever the mark is absent', () => { + const built = () => { + quiet(); + return defineStack(source('aa', 'description') as never); + }; + + it('a plain literal, a spread copy and a JSON copy of a built stack', () => { + const stack = built(); + expect(stackConversionsOf(source('aa', 'description'))).toEqual([]); + expect(stackConversionsOf({ ...stack })).toEqual([]); + expect(stackConversionsOf(JSON.parse(JSON.stringify(stack)))).toEqual([]); + // positive control for the three: the built stack itself + expect(stackConversionsOf(stack)).toHaveLength(1); + }); + + it('a forged record on an unmarked value is not read', () => { + const stack = built(); + const forged: Record = {}; + const genuine = stackConversionsOf(stack); + expect(genuine, 'anti-vacuity: the forged record is a non-empty one').toHaveLength(1); + Object.defineProperty(forged, Symbol.for('objectstack.stack.conversions'), { + value: genuine, + enumerable: false, + }); + expect(hasStackProvenance(forged)).toBe(false); + expect(stackConversionsOf(forged)).toEqual([]); + }); + + it('non-objects', () => { + for (const v of [null, undefined, 0, 'x', true]) expect(stackConversionsOf(v)).toEqual([]); + }); +}); + +describe('composeStacks records its inputs’ records, in input order, one application once', () => { + const build = (ns: string, headerKey: 'description' | 'subtitle') => { + quiet(); + return defineStack(source(ns, headerKey) as never, { strict: false }); + }; + + it('two inputs: concatenated in input order, each path relative to its own defineStack call', () => { + const a = build('aa', 'description'); + const b = build('bb', 'subtitle'); + const c = build('cc', 'description'); + const composed = composeStacks([a, b, c] as ObjectStackDefinition[]); + const record = stackConversionsOf(composed); + expect(record).toEqual([...stackConversionsOf(a), ...stackConversionsOf(c)]); + expect(record.map(substance)).toEqual([HEADER_NOTICE, HEADER_NOTICE]); + // Reversing the inputs reverses the record: order is the INPUT order. + const reversed = stackConversionsOf(composeStacks([c, b, a] as ObjectStackDefinition[])); + expect(reversed[0]).toBe(stackConversionsOf(c)[0]); + expect(reversed[1]).toBe(stackConversionsOf(a)[0]); + }); + + it('the same built stack passed twice is one application, counted once', () => { + const a = build('aa', 'description'); + const composed = composeStacks([a, a] as ObjectStackDefinition[], { objectConflict: 'override' }); + expect(stackConversionsOf(composed).map(substance)).toEqual([HEADER_NOTICE]); + }); + + it('every arity, the preserve manifest and nesting', () => { + const a = build('aa', 'description'); + const b = build('bb', 'subtitle'); + const c = build('cc', 'description'); + expect(stackConversionsOf(a), 'anti-vacuity: the inputs carry records').toHaveLength(1); + expect(stackConversionsOf(c)).toHaveLength(1); + expect(stackConversionsOf(composeStacks([]))).toEqual([]); + // a single input is returned as-is, record included + expect(composeStacks([a] as ObjectStackDefinition[])).toBe(a); + expect(stackConversionsOf(composeStacks([a] as ObjectStackDefinition[]))).toBe(stackConversionsOf(a)); + expect( + stackConversionsOf(composeStacks([a, b] as ObjectStackDefinition[], { manifest: 'preserve' })), + ).toEqual(stackConversionsOf(a)); + const nested = composeStacks([composeStacks([a, b] as ObjectStackDefinition[]), c] as ObjectStackDefinition[]); + expect(stackConversionsOf(nested)).toEqual([...stackConversionsOf(a), ...stackConversionsOf(c)]); + }); + + it('⭐ control: inputs that converted nothing compose to an empty record on a marked artifact', () => { + const composed = composeStacks([build('aa', 'subtitle'), build('bb', 'subtitle')] as ObjectStackDefinition[]); + expect(hasStackProvenance(composed)).toBe(true); + expect(stackConversionsOf(composed)).toEqual([]); + }); +}); diff --git a/packages/spec/src/stack-provenance.ts b/packages/spec/src/stack-provenance.ts index 3b2367f528b..b42824619de 100644 --- a/packages/spec/src/stack-provenance.ts +++ b/packages/spec/src/stack-provenance.ts @@ -1,5 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import type { ConversionNotice } from './conversions/types.js'; + /** * STACK provenance — the spec-declared mark saying *this stack definition was * built by a stack producer*: `defineStack` (both modes) or `composeStacks`. @@ -51,9 +53,32 @@ * loads its own ESM build while the config it bundles resolves the CJS one — * reads the same key. Precedent: `data/filter-subtree-provenance.ts`. * - * Only {@link hasStackProvenance} is published; stamping belongs to the two + * Only the readers are published — {@link hasStackProvenance} and, for the + * record below, {@link stackConversionsOf}; stamping belongs to the two * producers in `stack.zod.ts` alone, so no third party can attest a judgement * it did not run. + * + * ## The record beside the mark: the ADR-0087 conversions the producer applied + * + * `defineStack` runs the ADR-0087 D2 conversion layer at load, in both modes, + * so an old spelling is already canonical in what it returns. The doors that + * report conversions (`os validate` / `os build`, the `--json` envelope's + * `conversions` and `os validate --strict`) receive that returned value — and + * a door re-running the conversion pass on it finds nothing left to convert. + * The one party that knows what was converted is the producer, so it RECORDS + * the notices it applied on the stack it returns, under a second symbol + * stamped in the same act as the mark, and {@link stackConversionsOf} is the + * one reader. A door folds that record into its own list; it never runs a + * second conversion pass to reconstruct it (which would disagree with what + * was loaded, or double-apply). + * + * The record shares the mark's properties and its failure direction: a + * `ConversionNotice[]` exactly as the conversion layer emitted it (the + * element the doors' `conversions` field already declares), frozen, + * non-enumerable, non-writable and non-configurable, so neither the schema + * nor the compiled artifact carries it; every copy that drops the mark drops + * the record with it, and an unmarked value has no record ({@link + * stackConversionsOf} answers `[]`). */ /** The producers whose output is a judged stack. */ @@ -65,18 +90,42 @@ const STACK_PRODUCERS: ReadonlySet = new Set(['defineSta const STACK_PROVENANCE: symbol = Symbol.for('objectstack.stack.provenance'); /** - * Stamp the producer's mark on the stack it is about to return, and return it. + * The symbol key the producer's conversion record lives under, beside + * {@link STACK_PROVENANCE}. `Symbol.for` for the same reason: a CLI and the + * config it loads may resolve two copies of this package. + */ +const STACK_CONVERSIONS: symbol = Symbol.for('objectstack.stack.conversions'); + +/** The record an unmarked value — or a marked one that applied nothing — answers. */ +const NO_CONVERSIONS: readonly ConversionNotice[] = Object.freeze([]); + +/** + * Stamp the producer's mark on the stack it is about to return, together with + * the record of the ADR-0087 conversions that producer applied, and return it. * * Non-enumerable, non-writable and non-configurable: invisible to every data * reader (see the module header) and not flippable by a later actor. A stack - * already carrying a valid mark is returned unchanged. A non-extensible - * object (frozen or sealed) cannot take the property in place, so it is - * shallow-copied first — the producer's own return value, never the author's - * input. + * already carrying a valid mark is returned unchanged, record included — the + * producers never hand this an already-marked value of their own making + * (`normalizeStackInput` and the composition both build a fresh object), so + * that early return only ever keeps a record that is already complete. A + * non-extensible object (frozen or sealed) cannot take the properties in + * place, so it is shallow-copied first — the producer's own return value, + * never the author's input. + * + * `conversions` is frozen together with each notice in it, so every reader of + * one built stack reads the same entries and none of them can edit the record + * for the next. Freezing a notice in place (rather than copying it) keeps its + * identity, which is what lets `composeStacks` count one application once + * when the same built stack reaches it twice. * * Internal to the two producers: NOT re-exported from the package entry. */ -export function markStackProvenance(stack: T, producer: StackProducer): T { +export function markStackProvenance( + stack: T, + producer: StackProducer, + conversions: readonly ConversionNotice[] = NO_CONVERSIONS, +): T { if (stack === null || typeof stack !== 'object') return stack; if (hasStackProvenance(stack)) return stack; const target = Object.isExtensible(stack) ? stack : ({ ...(stack as object) } as T); @@ -86,6 +135,12 @@ export function markStackProvenance(stack: T, producer: StackProducer): T { writable: false, configurable: false, }); + Object.defineProperty(target, STACK_CONVERSIONS, { + value: conversions.length === 0 ? NO_CONVERSIONS : Object.freeze(conversions.map((notice) => Object.freeze(notice))), + enumerable: false, + writable: false, + configurable: false, + }); return target; } @@ -106,3 +161,39 @@ export function hasStackProvenance(value: unknown): boolean { if (value === null || typeof value !== 'object') return false; return STACK_PRODUCERS.has((value as Record)[STACK_PROVENANCE]); } + +/** + * The ADR-0087 D2 conversions the stack producers applied while building this + * value, in the order they were applied — each a `ConversionNotice`, the same + * element `os validate --json` / `os build --json` publish under + * `conversions`. + * + * - **`defineStack`** (either mode) records every conversion its load-time + * pass applied to the source it was handed. Each is also printed once per + * process on stderr (`defineStack: : …`); the record is not subject + * to that warn-once, so two stacks built from the same source each carry + * their own. A built stack handed straight back to `defineStack` keeps the + * record it arrived with. + * - **`composeStacks`** records its inputs' records, concatenated in input + * order, one application once (the same built stack passed twice is counted + * once). A notice's `path` is relative to the `defineStack` call that + * applied it — the source the author wrote — not to the composed artifact. + * A single input is returned as-is, record included. + * + * `[]` for a value no producer returned (the doors refuse it anyway, with + * `STACK_PROVENANCE_MISSING`), and for a stack whose source needed no + * conversion. Like the mark, the record does not survive a spread or JSON + * copy — read it off the value the producer returned, BEFORE any merge. + * + * ⚠️ What it cannot hold: a `defineStack` call that REFUSES returns no stack, + * so the conversions it applied before refusing reach stderr only; and a key + * merged onto the stack after the producer ran (a config module's named + * export, say) was never seen by it, which is why a door still runs its own + * pass over the merged stack and folds this record in beside that pass's + * findings. + */ +export function stackConversionsOf(value: unknown): readonly ConversionNotice[] { + if (!hasStackProvenance(value)) return NO_CONVERSIONS; + const record = (value as Record)[STACK_CONVERSIONS]; + return Array.isArray(record) ? (record as readonly ConversionNotice[]) : NO_CONVERSIONS; +} diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 3dded8337c2..ebbad2e7340 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -13,7 +13,7 @@ import { hasPlatformObjectPrefix } from './system/constants/platform-object-name import { objectStackErrorMap, formatZodError } from './shared/error-map.zod'; import { strictObject } from './shared/strict-object'; import { deepEqualAuthored } from './shared/deep-equal'; -import { markStackProvenance, hasStackProvenance } from './stack-provenance'; +import { markStackProvenance, hasStackProvenance, stackConversionsOf } from './stack-provenance'; import { normalizeStackInput, MAP_SUPPORTED_FIELDS, @@ -3596,8 +3596,21 @@ export function defineStack( // warning below this runs in BOTH modes: a conversion happens whether or not // we go on to parse, so `strict: false` does not make the old shape any less // retiring. + // + // Each notice is also RECORDED on the stack returned below, beside the + // provenance mark (`stackConversionsOf`, `stack-provenance.ts`): the stack + // leaves here already canonical, so a door that reports conversions — the + // `--json` `conversions` field, `os validate --strict` — can learn what was + // converted only from this producer. The record starts from the input's own + // record, so a built stack handed straight back here keeps what its first + // build applied (the pass below finds nothing left to convert on it); it + // is not subject to the stderr warn-once. + const appliedConversions: ConversionNotice[] = [...stackConversionsOf(config)]; const normalized = normalizeStackInput(config as Record, { - onConversionNotice: warnConversionNotice, + onConversionNotice: (notice) => { + appliedConversions.push(notice); + warnConversionNotice(notice); + }, }); // Pre-parse: the parse below is what strips an undeclared key, so this is the @@ -3609,7 +3622,11 @@ export function defineStack( // still this producer's, so it carries the provenance mark — `strict: false` // is an explicit authoring choice made INSIDE the producer, which is what // the doors check for (`stack-provenance.ts`). - return markStackProvenance(mergeActionsIntoObjects(normalized as ObjectStackDefinition), 'defineStack'); + return markStackProvenance( + mergeActionsIntoObjects(normalized as ObjectStackDefinition), + 'defineStack', + appliedConversions, + ); } @@ -3699,16 +3716,19 @@ export function defineStack( // [#20367 ruling B] The mark the author-time doors and `composeStacks` check: // this value went through the judgement above. Non-enumerable, so it reaches - // neither the schema nor the compiled artifact (`stack-provenance.ts`). - return markStackProvenance(mergeActionsIntoObjects(data), 'defineStack'); + // neither the schema nor the compiled artifact (`stack-provenance.ts`). The + // conversion record rides beside it, stamped in the same act. + return markStackProvenance(mergeActionsIntoObjects(data), 'defineStack', appliedConversions); } /** - * [#20367 ruling B] The one published half of stack provenance — see - * `stack-provenance.ts`. `os validate` / `os build` read it off the config's - * default export and refuse an unmarked one (`STACK_PROVENANCE_MISSING`). + * [#20367 ruling B] The published halves of stack provenance — see + * `stack-provenance.ts`. `os validate` / `os build` read the mark off the + * config's default export and refuse an unmarked one + * (`STACK_PROVENANCE_MISSING`), and fold the conversion record into their + * `conversions` field and the `--strict` gate. */ -export { hasStackProvenance }; +export { hasStackProvenance, stackConversionsOf }; // ─── composeStacks ────────────────────────────────────────────────── @@ -5312,5 +5332,13 @@ export function composeStacks( // [#20367 ruling B] Built from built inputs only (step 0), so the artifact is // a producer's output too: a nested `composeStacks` or an author-time door // accepts it. - return markStackProvenance(artifact, 'composeStacks') as ObjectStackDefinition; + // + // Its conversion record is its inputs' records, concatenated in input order. + // Composition converts nothing itself — every input arrived canonical from + // its own `defineStack` — so this is the whole of what was applied to build + // the artifact. A `Set` over the (frozen, identity-kept) notices counts one + // application once when the same built stack is passed twice; each notice's + // `path` stays relative to the `defineStack` call that applied it. + const conversions = [...new Set(stacks.flatMap((stack) => stackConversionsOf(stack)))]; + return markStackProvenance(artifact, 'composeStacks', conversions) as ObjectStackDefinition; }