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
20 changes: 20 additions & 0 deletions .changeset/23421-retire-page-aliases-at-18.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@objectstack/spec': minor
---

feat(spec)!: two page-property conversions leave the load path at 18 — a `page:header`'s `description` and an inline `api` action's object-form `params` are refused with their rename at every door that judges page props

Clause-②: yes (narrowing)

**BREAKING (authoring):** the ADR-0087 D2 conversions `page-header-subtitle-alias` and `inline-action-api-params-to-body-extra` were declared for protocol 17's one-major window and are now retired from the load path. The authoring funnel (`defineStack`, `os validate`, `os build`, `os lint`) no longer rewrites either spelling, so an author who still writes one meets the page-props refusal, which names the rename. That refusal is the same answer the metadata save door gives, so `os validate` no longer passes a page that the save door then refuses. Both refusals now also name the release that refuses the old spelling and the `os migrate meta` command: the `page:header` `description` refusal is an upgrade prescription naming `subtitle` (it was a bare "did you mean"), and the object-form `params` refusal adds the release and the command after its `bodyExtra` instruction.

Migration:

- `page:header` (and the legacy `page-header` node) `properties.description` → `properties.subtitle`. Rename the key; the value is unchanged. If both keys are present with different text, keep the one you mean under `subtitle` and delete `description`.
- An inline `type: 'api'` action's object-form `params` (a request payload map such as `params: { name: '{{page.inquiryName}}' }`) → `bodyExtra`. Rename the key; the value is unchanged. `params` stays the `ActionParam[]` definition array (the fields a dialog collects).

Run `os migrate meta --from 16` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand.

Stored metadata needs no edit. Stored-row rehydration still replays the whole conversion chain, these two entries included, so `sys_metadata` page rows written under either old spelling keep loading in canonical form. A built artifact whose declared protocol floor is at or below `17.7.0` is also still converted at the artifact-ingestion door.

<!-- adr-0087: not-required (already-registered page-header-subtitle-alias, inline-action-api-params-to-body-extra) Both D2 conversions are registered in the step-17 chain before this change, and they are the whole mechanical migration: this diff marks them retiredFromLoadPath with retiredAfter 17.7.0 and makes the two refusals an author now meets name that release, so the authoring funnel stops replaying them while os migrate meta, stored-row rehydration and the artifact door keep replaying them. No key, value or shape is added or removed, so there is no new D2 conversion and no D3 semantic judgment to register: both rewrites are lossless renames. -->
50 changes: 27 additions & 23 deletions packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,15 @@
* the original divergence for exactly these configs with all of them green.
*
* The two fixtures below are a MINIMAL PAIR from one template, differing in a
* single key on one page-header component: `description`, the alias
* `page-header-subtitle-alias` rewrites at load, against `subtitle`, the
* canonical spelling that converts nothing. So the pin discriminates on the
* single key on one page-header component: `visibility`, the alias
* `page-component-visibility-to-visibleWhen` rewrites at load, against
* `visibleWhen`, the canonical spelling that converts nothing. (The pair used
* to differ in the header's `description` against `subtitle`; that conversion,
* `page-component-visibility-to-visibleWhen`, retired from the load path at protocol 18 and
* the fixture was re-pointed here, as the paragraph below prescribes.) So the pin discriminates on the
* CONVERSION and not on "a page is present" — measured on the pair before it
* was written: `description` → text `--strict` 1, `--json --strict` 1, `--json`
* 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices.
* was written: `visibility` → text `--strict` 1, `--json --strict` 1, `--json`
* 0, `warnings: []`, one notice; `visibleWhen` → 0 on every face, no notices.
*
* Under the one-authoring-shape ruling (#20367) every accepted config is
* `defineStack` output, and `defineStack` applies the conversion itself at
Expand All @@ -71,13 +74,13 @@
* 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`
* cell below is back to the measurement above: `visibility` → both `--strict`
* faces 1, `--json` alone 0, `warnings: []`, the one notice.
*
* 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,
* decorative. `page-component-visibility-to-visibleWhen` is a LIVE
* window; the day it retires from the load path,
* this fixture raises nothing and, without that assertion, the file would keep
* passing while pinning an empty cell — precisely the failure this test exists
* to remove. It goes red instead, and whoever retires the entry re-points the
Expand Down Expand Up @@ -172,14 +175,15 @@ export default defineStack({

/**
* The conversions-only minimal pair (#11301) — `CLEAN_SOURCE`'s shape plus one
* `page:header` component, whose second line is authored under the key named.
* `page:header` component, whose visibility predicate is authored under the
* key named.
*
* Nothing else here raises an advisory: the unknown-key lints run on the
* POST-conversion `normalized`, so what they see is the canonical `subtitle`
* POST-conversion `normalized`, so what they see is the canonical `visibleWhen`
* either way, and both members are pinned to `warnings: []` below rather than
* assumed to be.
*/
const headerPageSource = (headerTextKey: 'description' | 'subtitle'): string => `
const headerPageSource = (headerVisibilityKey: 'visibility' | 'visibleWhen'): string => `
import { defineStack } from '@objectstack/spec';

export default defineStack({
Expand All @@ -195,7 +199,7 @@ export default defineStack({
name: 'strictexit_home',
label: 'Home',
regions: [{ name: 'main', components: [
{ type: 'page:header', properties: { title: 'Tickets', ${headerTextKey}: 'All open tickets' } },
{ type: 'page:header', properties: { title: 'Tickets' }, ${headerVisibilityKey}: "page.mode != 'edit'" },
] }],
}],
}, { strict: false });
Expand Down Expand Up @@ -237,10 +241,10 @@ beforeAll(() => {
writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE);
linkSpec(cleanDir);
conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-'));
writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('description'));
writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('visibility'));
linkSpec(conversionsDir);
conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-'));
writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('subtitle'));
writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('visibleWhen'));
linkSpec(conversionsCanonDir);
});

Expand Down Expand Up @@ -326,25 +330,25 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
'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',
conversionId: 'page-component-visibility-to-visibleWhen',
path: 'pages[0].regions[0].components[0].visibleWhen',
from: 'visibility',
to: 'visibleWhen',
},
]);
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'");
expect(text.stdout).toContain("conversion 'page-component-visibility-to-visibleWhen'");

// 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;
// on stderr. The day `page-component-visibility-to-visibleWhen` 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.split("conversion 'page-header-subtitle-alias'").length - 1,
run.stderr.split("conversion 'page-component-visibility-to-visibleWhen'").length - 1,
'defineStack reported the conversion at load, on exactly one stderr line',
).toBe(1);
}
Expand All @@ -356,7 +360,7 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
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']);
expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['page-component-visibility-to-visibleWhen']);
}, 120_000);

it('control: the same page under the CANONICAL key converts nothing and exits 0 on both faces', async () => {
Expand All @@ -372,7 +376,7 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
expect(payload.warnings).toEqual([]);
expect(payload.conversions).toEqual([]);
// The discriminator's other half: the canonical key raises no notice anywhere.
expect(json.stderr).not.toContain("conversion 'page-header-subtitle-alias'");
expect(json.stderr).not.toContain("conversion 'page-component-visibility-to-visibleWhen'");
}, 120_000);

it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => {
Expand Down
40 changes: 21 additions & 19 deletions packages/cli/test/stack-conversion-record-door.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,10 @@
* 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
* `page-component-visibility-to-visibleWhen` is the live conversion driven here
* (`visibility` on a `page:header` component, canonical `visibleWhen`; it was
* the header's `description` → `subtitle` until that entry retired from the
* load path at protocol 18). 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`.
*
Expand Down Expand Up @@ -122,10 +124,10 @@ function payloadOf(run: Run, label: string): Payload {
/** 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',
conversionId: 'page-component-visibility-to-visibleWhen',
path: 'pages[0].regions[0].components[0].visibleWhen',
from: 'visibility',
to: 'visibleWhen',
};

function expectExactly(payload: Payload, expected: Array<typeof THE_NOTICE>, label: string): void {
Expand All @@ -141,15 +143,15 @@ function expectExactly(payload: Payload, expected: Array<typeof THE_NOTICE>, lab
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') => `{
const page = (ns: string, headerKey: 'visibility' | 'visibleWhen') => `{
name: '${ns}_home',
label: 'Home',
regions: [{ name: 'main', components: [
{ type: 'page:header', properties: { title: 'Things', ${headerKey}: 'All things' } },
{ type: 'page:header', properties: { title: 'Things' }, ${headerKey}: "page.mode != 'edit'" },
] }],
}`;

const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null, requires?: string) => `{
const stackBody = (ns: string, headerKey: 'visibility' | 'visibleWhen' | 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)}],` : ''}${requires ? `\n requires: ['${requires}'],` : ''}
Expand All @@ -159,32 +161,32 @@ const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\

const FIXTURES: Record<string, string> = {
// The plain case: one `defineStack` default export, no named export.
plain: IMPORT + `export default defineStack(${stackBody('pln', 'description')});\n`,
plain: IMPORT + `export default defineStack(${stackBody('pln', 'visibility')});\n`,
// The record, across `loadConfig`'s named-export spread.
recordAcrossSpread:
IMPORT +
`export const onEnable = async () => {};\n\n` +
`export default defineStack(${stackBody('rec', 'description')});\n`,
`export default defineStack(${stackBody('rec', 'visibility')});\n`,
// The record of a composed stack: its inputs' records.
composed:
IMPORT +
`const withIt = defineStack(${stackBody('cmpa', 'description')});\n` +
`const withIt = defineStack(${stackBody('cmpa', 'visibility')});\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 const pages = [${page('nep', 'visibility')}];\n\n` +
`export default defineStack(${stackBody('nep', null)});\n`,
// The control: the canonical spelling.
canonical: IMPORT + `export default defineStack(${stackBody('can', 'subtitle')});\n`,
canonical: IMPORT + `export default defineStack(${stackBody('can', 'visibleWhen')});\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`,
IMPORT + `export default defineStack(${stackBody('ctr', 'visibility', '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`,
IMPORT + `export default defineStack(${stackBody('ctc', 'visibleWhen', 'no-such-capability')});\n`,
};

let root = '';
Expand Down Expand Up @@ -232,7 +234,7 @@ for (const command of ['validate', 'build'] as const) {
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'");
expect(run.stderr).not.toContain("conversion 'page-component-visibility-to-visibleWhen'");
}, 180_000);

it('control: the canonical spelling converts nothing — `[]`', async () => {
Expand All @@ -251,7 +253,7 @@ describe("os lint --json — the producer's conversion record reaches `conversio
// and still converts through the pass (`lint-conversion-notices.e2e.test.ts`
// pins that half on a plain object-literal export).
const lint = (label: string) => runCli(['lint', '--json'], dirs[label]);
const producerLine = "conversion 'page-header-subtitle-alias'";
const producerLine = "conversion 'page-component-visibility-to-visibleWhen'";

it('the plain case: one `defineStack` default export carrying the retiring spelling', async () => {
const run = await lint('plain');
Expand Down Expand Up @@ -333,7 +335,7 @@ describe('os validate --strict — a conversion the producer applied fails it, o
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(text.stdout).toContain("conversion 'page-component-visibility-to-visibleWhen'");
expect(json.code, json.stdout + json.stderr).toBe(1);
const p = payloadOf(json, 'validate --json --strict');
expect(p.valid).toBe(true);
Expand Down
34 changes: 19 additions & 15 deletions packages/lint/src/validate-component-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -713,31 +713,35 @@ describe('validateComponentProps — object-* blocks are dispatched (#7751)', ()
});

/**
* Why the registry entry reads the NORMALIZED tier.
* The normalized tier, after `page-header-subtitle-alias` retired from the
* load path at 18 (#23421).
*
* The props bag survives the Zod parse unchanged (`z.record(z.string(),
* z.unknown())`), so the two tiers carry identical props and the choice looks
* free. It is not: the ADR-0087 D2 conversion layer runs INSIDE
* `normalizeStackInput`, and `page-header-subtitle-alias` rewrites
* `properties.description` → `subtitle` on header nodes. Reading the raw
* authored input would report a key the conversion layer has already fixed —
* the rule contradicting a declared conversion, which is exactly the
* second-de-facto-contract shape Prime Directive #12 forbids.
* The registry entry reads the NORMALIZED tier, and while that conversion was
* live it rewrote `properties.description` → `subtitle` on header nodes inside
* `normalizeStackInput`, so `os validate` passed a page the metadata save door
* (which judges the raw page) refused. Retired, the conversion no longer runs
* in the authoring funnel: both tiers now carry the authored `description`,
* and both refuse it with the rename `PageHeaderProps` declares — the same
* answer every door gives. The conversion still canonicalizes data at rest.
*/
describe('validateComponentProps — reads the post-conversion (normalized) tier', () => {
const authored = stackWith([
{ type: 'page:header', properties: { title: 'Lead', description: 'All open leads' } },
]);
const DESCRIPTION_PATH = 'pages[0].regions[0].components[0].properties.description';

it('says nothing about a key the ADR-0087 conversion layer canonicalizes', () => {
expect(validateComponentProps(normalizeStackInput(authored))).toEqual([]);
it('refuses a retired conversion\'s old key at the normalized tier, naming the rename', () => {
const findings = validateComponentProps(normalizeStackInput(authored));
expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([
[COMPONENT_PROPS_UNKNOWN_KEY, 'error', DESCRIPTION_PATH],
]);
expect(findings[0].message).toContain('subtitle');
});

it('WOULD report it pre-conversion — which is why the tier is not free', () => {
it('gives the raw tier the same answer — the two doors no longer disagree on this page', () => {
const raw = validateComponentProps(authored);
expect(unknownKeys(raw).map((f) => f.path)).toEqual([
'pages[0].regions[0].components[0].properties.description',
]);
expect(unknownKeys(raw).map((f) => f.path)).toEqual([DESCRIPTION_PATH]);
expect(unknownKeys(raw)[0].message).toContain('subtitle');
});
});

Expand Down
Loading
Loading