Skip to content

Commit 8693110

Browse files
feat(spec)!: retire page-header-subtitle-alias and inline-action-api-params-to-body-extra from the load path at 18 (#23485)
Fixes #23421 Clause-②: yes (narrowing) ## What The two ADR-0087 D2 conversions `page-header-subtitle-alias` (`page:header` `properties.description` to `subtitle`) and `inline-action-api-params-to-body-extra` (an inline `type: 'api'` action's object-form `params` to `bodyExtra`) were declared `toMajor: 17` for protocol 17's one-major window. On the `18.0.0-next` line they were still load-active, so `os validate` and the load path converted silently what the metadata save door (once PR #23418 moves the component-props rule onto it) refuses. This PR retires both from the load path, the way every other retired entry is marked: `retiredFromLoadPath: true` and `retiredAfter: '17.7.0'`, the newest published stable release under the `18.0.0-next.0` label. After this change every door that judges page props answers the same way: `os validate`, `os build`, `os lint`, and the save door after #23418 all refuse the old spelling, and the refusal names the rename. Stored rows keep healing. `applyConversionsToStoredItem` replays the full chain with `includeRetired: true`, so does the artifact-ingestion door inside its window, and `os migrate meta` replays the chain too. ## Premise check (done before any edit) - ADR-0087 D2, "Window": "each conversion is applied by the loader for one major (N accepts N-1 shapes at load), then retired from the load path in N+1". `toMajor: 17` means 18 is N+1. The section "The load-window's second half is now mechanical" names `MetadataConversion.retiredFromLoadPath` as the field that implements that retirement. - `conversions/types.ts`: `RetiredConversionState` is `retiredFromLoadPath: true` plus a required `retiredAfter`. `apply.ts` skips such an entry unless `includeRetired` is set. `stored.ts` pins `includeRetired: true`. - Precedent: at `61b12584f`, 45 `toMajor: 17` entries carry `retiredFromLoadPath` (`retiredAfter` 16.1.0). Among the `toMajor: 18` entries, 69 are retired, 7 of them with `retiredAfter: '17.7.0'` (for example `declared-index-unique-scope` and `email-template-category-is-system-removed`). - The premise holds, so this PR takes the retirement route. It does not touch the other route (the save door applying live-window conversions). ## Changes - `packages/spec/src/conversions/registry.ts`: the two entries get the two fields, and their docblocks now record the retirement in place of "Live window; retires at 18". - `packages/spec/src/ui/component.zod.ts`: `PageHeaderProps` `description` moves from an `aliases` row (a bare "Did you mean `description` to `subtitle`?") to a `guidance` row. It is the upgrade prescription in house form: it names `@objectstack/spec 18` and `subtitle`, followed by the `os migrate meta --from 16` house sentence. - `packages/spec/src/ui/action.zod.ts`: the object-form `params` refusal keeps its `bodyExtra` instruction. It now also says the loader rewrote that form on an inline page button until `@objectstack/spec 18`, and it ends in the MIXED two-clause `os migrate meta --from 16` sentence. The partial form is used because the conversion only covers the inline `element:button` case. - Why these two text edits are here although the claim's file surface does not name `ui/*.zod.ts`: `shared/retired-key-removal-version.pin.test.ts` requires every entry retired after the newest stable release to be joined to the prescriptions an author meets, and requires each of those prescriptions to name the derived release (18). Neither refusal named a release, so the pin went red on exactly these two ids. The landing spot is the producer of the refusal text, in the same package. - Pin flips (load-bearing, not just deletions): - `conversions/conversions.test.ts`: the two entries' rewrite cases run the data-at-rest posture (`includeRetired: true`). New cases assert that the authoring posture rewrites nothing on both `page:header` and the kebab `page-header`, and on the object-form `params`. They also assert the entry fields (`toMajor: 17`, `retiredFromLoadPath: true`, `retiredAfter: '17.7.0'`) and that the `bodyExtra` refusal is what an author meets. The premise pin on `PageHeaderProps` (refuses `description`, names `subtitle`) stays verbatim. - `lint/src/validate-component-props.test.ts`: the "normalized tier" block asserted the normalized tier says nothing about `description`. It now asserts that the normalized tier refuses it as `component-props-unknown-key`, `error`, at the exact path, naming `subtitle`, and that the raw tier gives the same answer. - `shared/retired-key-removal-version.pin.test.ts`: two JOIN rows. - Vehicle swaps: `spec/src/stack-conversions-record.test.ts`, `cli/test/stack-conversion-record-door.test.ts` and `cli/src/commands/validate-json-strict-exit.e2e.test.ts` used `page-header-subtitle-alias` as their live conversion. Their own docblocks said to re-point them at a live entry when it retired. They now ride `page-component-visibility-to-visibleWhen` (`visibility` to `visibleWhen` on the same `page:header` node, `retiresIn: 16`), and every assertion is unchanged in substance. - `.changeset/23421-retire-page-aliases-at-18.md`: `minor`, with the **BREAKING** banner, `Clause-②: yes (narrowing)`, the FROM to TO mapping, and the ADR-0087 disposition. In words, the disposition marker is "adr-0087: not-required (already-registered page-header-subtitle-alias, inline-action-api-params-to-body-extra)". Both conversions are already in the step-17 chain and are the whole mechanical migration, so there is no new D2 conversion and no D3 semantic entry. `check-adr-0087-registration` accepts it. ## For the seat: one sentence in PR #23418 becomes false PR #23418's changeset `.changeset/23388-page-component-rules-runtime-door.md` carries the paragraph that begins "Two live-window ADR-0087 D2 aliases ... are converted at `objectstack validate` and at load but not at this door". Once this PR lands, that paragraph is false: neither alias is converted at `objectstack validate` or at load any more. This PR does not edit #23418's files. The same goes for the comment block in `packages/lint/src/authoring-rules.ts` (the `validateComponentProps` entry, "`normalized` for a reason worth stating ..."), which still cites `page-header-subtitle-alias` as the converted alias that makes the tier matter. That file is in #23418's diff, so it is left for that landing. #23418 is not addressed here. ## Verification (at `6f0fcb21f`, after merging `origin/main` at `3d231ba78`) - `pnpm --filter @objectstack/spec test`: 676 files, 20576 tests passed (after the merge). - `pnpm --filter @objectstack/spec test:repo`: 59 files, 1011 tests passed (before the merge, which touched no tree-scoped pin input of this diff). - `pnpm --filter @objectstack/spec check:generated`: all 14 generated artifacts up to date. - Before the merge: `typecheck`: `@objectstack/spec`, `@objectstack/lint` and `@objectstack/cli` each exit 0. - `@objectstack/lint` full vitest, `@objectstack/cli` `--project unit` (287 files, 4307 tests), and the two touched CLI integration and e2e files: `test/stack-conversion-record-door.test.ts` (21/21) and `src/commands/validate-json-strict-exit.e2e.test.ts` under `OS_TEST_TIERS=nightly` (7/7). - Red before the flip: with only the registry edit applied, `conversions.test.ts` had 5 failing cases and `stack-conversions-record.test.ts` had 30, all asserting the two conversions ran on the authoring path. The lint tier case and the removal-version pin each went red the same way. - The gate families came from `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (93 families, the same set before and after the merge). All 93 were run at `6f0fcb21f` with exit 0, and the list was reconciled with `--ran`. ## Acceptance notes - `page-header` (kebab): the conversion's walk still lists it, for the data-at-rest replays. Retired, a source `page-header` node's `description` is neither converted nor judged, because `ComponentPropsMap` has no `page-header` row. This is harmless: objectui at the pinned `.objectui-sha` no longer registers `page-header` (its components changeset "drop `page-header` from the opt-in protocol placeholders"), so such a node renders the unknown-component panel whatever its props say. - The boot path loading source through `defineStack` does not run the props gate (that gate runs at `os validate` / `os build` / `os lint` and, after #23418, at the save door). This is the same posture every other retired page prop already has (`page:header` `icon` and `breadcrumb`). - In-repo authors of either old spelling: zero (`examples/`, `packages/platform-objects`, `packages/plugins`, `packages/services`). The control grep on `page:header` finds the canonical `subtitle` in use. --- _Generated by [Claude Code](https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05e3b8e commit 8693110

10 files changed

Lines changed: 286 additions & 153 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
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
6+
7+
Clause-②: yes (narrowing)
8+
9+
**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.
10+
11+
Migration:
12+
13+
- `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`.
14+
- 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).
15+
16+
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.
17+
18+
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.
19+
20+
<!-- 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. -->

‎packages/cli/src/commands/validate-json-strict-exit.e2e.test.ts‎

Lines changed: 27 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,15 @@
5757
* the original divergence for exactly these configs with all of them green.
5858
*
5959
* The two fixtures below are a MINIMAL PAIR from one template, differing in a
60-
* single key on one page-header component: `description`, the alias
61-
* `page-header-subtitle-alias` rewrites at load, against `subtitle`, the
62-
* canonical spelling that converts nothing. So the pin discriminates on the
60+
* single key on one page-header component: `visibility`, the alias
61+
* `page-component-visibility-to-visibleWhen` rewrites at load, against
62+
* `visibleWhen`, the canonical spelling that converts nothing. (The pair used
63+
* to differ in the header's `description` against `subtitle`; that conversion,
64+
* `page-component-visibility-to-visibleWhen`, retired from the load path at protocol 18 and
65+
* the fixture was re-pointed here, as the paragraph below prescribes.) So the pin discriminates on the
6366
* CONVERSION and not on "a page is present" — measured on the pair before it
64-
* was written: `description` → text `--strict` 1, `--json --strict` 1, `--json`
65-
* 0, `warnings: []`, one notice; `subtitle` → 0 on every face, no notices.
67+
* was written: `visibility` → text `--strict` 1, `--json --strict` 1, `--json`
68+
* 0, `warnings: []`, one notice; `visibleWhen` → 0 on every face, no notices.
6669
*
6770
* Under the one-authoring-shape ruling (#20367) every accepted config is
6871
* `defineStack` output, and `defineStack` applies the conversion itself at
@@ -71,13 +74,13 @@
7174
* reachable again (#20476) because the producer RECORDS the conversions it
7275
* applied on the stack it returns (`stackConversionsOf`) and the door folds
7376
* that record into the list `--strict` gates on and into `conversions`. The
74-
* cell below is back to the measurement above: `description` → both `--strict`
77+
* cell below is back to the measurement above: `visibility` → both `--strict`
7578
* faces 1, `--json` alone 0, `warnings: []`, the one notice.
7679
*
7780
* The live-notice assertion (the payload entry, and the producer's one
7881
* stderr line) is the anti-vacuity guard, and it is load-bearing rather than
79-
* decorative. `page-header-subtitle-alias` is a LIVE
80-
* window that retires from the load path at protocol 18; the day it retires,
82+
* decorative. `page-component-visibility-to-visibleWhen` is a LIVE
83+
* window; the day it retires from the load path,
8184
* this fixture raises nothing and, without that assertion, the file would keep
8285
* passing while pinning an empty cell — precisely the failure this test exists
8386
* to remove. It goes red instead, and whoever retires the entry re-points the
@@ -172,14 +175,15 @@ export default defineStack({
172175

173176
/**
174177
* The conversions-only minimal pair (#11301) — `CLEAN_SOURCE`'s shape plus one
175-
* `page:header` component, whose second line is authored under the key named.
178+
* `page:header` component, whose visibility predicate is authored under the
179+
* key named.
176180
*
177181
* Nothing else here raises an advisory: the unknown-key lints run on the
178-
* POST-conversion `normalized`, so what they see is the canonical `subtitle`
182+
* POST-conversion `normalized`, so what they see is the canonical `visibleWhen`
179183
* either way, and both members are pinned to `warnings: []` below rather than
180184
* assumed to be.
181185
*/
182-
const headerPageSource = (headerTextKey: 'description' | 'subtitle'): string => `
186+
const headerPageSource = (headerVisibilityKey: 'visibility' | 'visibleWhen'): string => `
183187
import { defineStack } from '@objectstack/spec';
184188
185189
export default defineStack({
@@ -195,7 +199,7 @@ export default defineStack({
195199
name: 'strictexit_home',
196200
label: 'Home',
197201
regions: [{ name: 'main', components: [
198-
{ type: 'page:header', properties: { title: 'Tickets', ${headerTextKey}: 'All open tickets' } },
202+
{ type: 'page:header', properties: { title: 'Tickets' }, ${headerVisibilityKey}: "page.mode != 'edit'" },
199203
] }],
200204
}],
201205
}, { strict: false });
@@ -237,10 +241,10 @@ beforeAll(() => {
237241
writeFileSync(join(cleanDir, 'objectstack.config.ts'), CLEAN_SOURCE);
238242
linkSpec(cleanDir);
239243
conversionsDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-'));
240-
writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('description'));
244+
writeFileSync(join(conversionsDir, 'objectstack.config.ts'), headerPageSource('visibility'));
241245
linkSpec(conversionsDir);
242246
conversionsCanonDir = mkdtempSync(join(tmpdir(), 'os-validate-strict-exit-conversions-canon-'));
243-
writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('subtitle'));
247+
writeFileSync(join(conversionsCanonDir, 'objectstack.config.ts'), headerPageSource('visibleWhen'));
244248
linkSpec(conversionsCanonDir);
245249
});
246250

@@ -326,25 +330,25 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
326330
'the producer-applied conversion reaches the payload, once',
327331
).toEqual([
328332
{
329-
conversionId: 'page-header-subtitle-alias',
330-
path: 'pages[0].regions[0].components[0].properties.subtitle',
331-
from: 'description',
332-
to: 'subtitle',
333+
conversionId: 'page-component-visibility-to-visibleWhen',
334+
path: 'pages[0].regions[0].components[0].visibleWhen',
335+
from: 'visibility',
336+
to: 'visibleWhen',
333337
},
334338
]);
335339
expect(typeof payload.conversions?.[0]?.retiresIn, 'the expiry rides the entry').toBe('number');
336340

337341
// The text face names what failed it, in its `⚠` block.
338-
expect(text.stdout).toContain("conversion 'page-header-subtitle-alias'");
342+
expect(text.stdout).toContain("conversion 'page-component-visibility-to-visibleWhen'");
339343

340344
// Anti-vacuity AND the one-stderr-line property: the conversion is LIVE —
341345
// it fired, in the producer, once per run — and no door-side line repeats it
342-
// on stderr. The day `page-header-subtitle-alias` retires this goes red;
346+
// on stderr. The day `page-component-visibility-to-visibleWhen` retires this goes red;
343347
// re-point `headerPageSource` at a live entry in
344348
// `packages/spec/src/conversions/registry.ts`.
345349
for (const run of [text, json]) {
346350
expect(
347-
run.stderr.split("conversion 'page-header-subtitle-alias'").length - 1,
351+
run.stderr.split("conversion 'page-component-visibility-to-visibleWhen'").length - 1,
348352
'defineStack reported the conversion at load, on exactly one stderr line',
349353
).toBe(1);
350354
}
@@ -356,7 +360,7 @@ describe('#11174 — --strict reaches the same exit status on both faces', () =>
356360
const json = await runCli(['validate', '--json'], conversionsDir);
357361
expect(json.code, `--json without --strict must stay 0:\n${json.stdout}\n${json.stderr}`).toBe(0);
358362
const payload = JSON.parse(json.stdout) as { conversions?: Array<{ conversionId?: unknown }> };
359-
expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['page-header-subtitle-alias']);
363+
expect((payload.conversions ?? []).map((n) => n.conversionId)).toEqual(['page-component-visibility-to-visibleWhen']);
360364
}, 120_000);
361365

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

378382
it('control: without --strict, the same advisory-raising config still exits 0 under --json', async () => {

‎packages/cli/test/stack-conversion-record-door.test.ts‎

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,10 @@
5959
* needed no conversion answers `[]`, so an exit that reports the notice without
6060
* reading it off the refusal it caught is red there.
6161
*
62-
* `page-header-subtitle-alias` is the live conversion driven here (`description`
63-
* on a `page:header` component, canonical `subtitle`). The day it retires from
62+
* `page-component-visibility-to-visibleWhen` is the live conversion driven here
63+
* (`visibility` on a `page:header` component, canonical `visibleWhen`; it was
64+
* the header's `description` → `subtitle` until that entry retired from the
65+
* load path at protocol 18). The day it retires from
6466
* the load path the non-empty rows go red; re-point the fixture at a live entry
6567
* in `packages/spec/src/conversions/registry.ts`.
6668
*
@@ -122,10 +124,10 @@ function payloadOf(run: Run, label: string): Payload {
122124
/** The one entry every non-empty row must carry, by identity, site and direction. */
123125
const THE_NOTICE = {
124126
code: 'OS_METADATA_CONVERTED',
125-
conversionId: 'page-header-subtitle-alias',
126-
path: 'pages[0].regions[0].components[0].properties.subtitle',
127-
from: 'description',
128-
to: 'subtitle',
127+
conversionId: 'page-component-visibility-to-visibleWhen',
128+
path: 'pages[0].regions[0].components[0].visibleWhen',
129+
from: 'visibility',
130+
to: 'visibleWhen',
129131
};
130132

131133
function expectExactly(payload: Payload, expected: Array<typeof THE_NOTICE>, label: string): void {
@@ -141,15 +143,15 @@ function expectExactly(payload: Payload, expected: Array<typeof THE_NOTICE>, lab
141143
const manifest = (ns: string) =>
142144
`{ id: 'com.example.${ns}', name: '${ns}', version: '1.0.0', type: 'app', namespace: '${ns}' }`;
143145

144-
const page = (ns: string, headerKey: 'description' | 'subtitle') => `{
146+
const page = (ns: string, headerKey: 'visibility' | 'visibleWhen') => `{
145147
name: '${ns}_home',
146148
label: 'Home',
147149
regions: [{ name: 'main', components: [
148-
{ type: 'page:header', properties: { title: 'Things', ${headerKey}: 'All things' } },
150+
{ type: 'page:header', properties: { title: 'Things' }, ${headerKey}: "page.mode != 'edit'" },
149151
] }],
150152
}`;
151153

152-
const stackBody = (ns: string, headerKey: 'description' | 'subtitle' | null, requires?: string) => `{
154+
const stackBody = (ns: string, headerKey: 'visibility' | 'visibleWhen' | null, requires?: string) => `{
153155
manifest: ${manifest(ns)},
154156
objects: [{ name: '${ns}_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }],
155157
apps: [{ name: '${ns}_app', label: 'App' }],${headerKey ? `\n pages: [${page(ns, headerKey)}],` : ''}${requires ? `\n requires: ['${requires}'],` : ''}
@@ -159,32 +161,32 @@ const IMPORT = `import { composeStacks, defineStack } from '@objectstack/spec';\
159161

160162
const FIXTURES: Record<string, string> = {
161163
// The plain case: one `defineStack` default export, no named export.
162-
plain: IMPORT + `export default defineStack(${stackBody('pln', 'description')});\n`,
164+
plain: IMPORT + `export default defineStack(${stackBody('pln', 'visibility')});\n`,
163165
// The record, across `loadConfig`'s named-export spread.
164166
recordAcrossSpread:
165167
IMPORT +
166168
`export const onEnable = async () => {};\n\n` +
167-
`export default defineStack(${stackBody('rec', 'description')});\n`,
169+
`export default defineStack(${stackBody('rec', 'visibility')});\n`,
168170
// The record of a composed stack: its inputs' records.
169171
composed:
170172
IMPORT +
171-
`const withIt = defineStack(${stackBody('cmpa', 'description')});\n` +
173+
`const withIt = defineStack(${stackBody('cmpa', 'visibility')});\n` +
172174
`const without = defineStack({ manifest: ${manifest('cmpb')}, objects: [{ name: 'cmpb_thing', label: 'Thing', sharingModel: 'private', fields: { title: { type: 'text', label: 'Title' } } }] });\n\n` +
173175
`export default composeStacks([withIt, without]);\n`,
174176
// A key merged after the producer ran: only the door's own pass sees it.
175177
namedExportPass:
176178
IMPORT +
177-
`export const pages = [${page('nep', 'description')}];\n\n` +
179+
`export const pages = [${page('nep', 'visibility')}];\n\n` +
178180
`export default defineStack(${stackBody('nep', null)});\n`,
179181
// The control: the canonical spelling.
180-
canonical: IMPORT + `export default defineStack(${stackBody('can', 'subtitle')});\n`,
182+
canonical: IMPORT + `export default defineStack(${stackBody('can', 'visibleWhen')});\n`,
181183
// Convert, then refuse: the strict (default) `defineStack` converts the
182184
// retiring spelling, then refuses an unknown capability token.
183185
convertThenRefuse:
184-
IMPORT + `export default defineStack(${stackBody('ctr', 'description', 'no-such-capability')});\n`,
186+
IMPORT + `export default defineStack(${stackBody('ctr', 'visibility', 'no-such-capability')});\n`,
185187
// Its control: the same refusal from a source that needed no conversion.
186188
canonicalThenRefuse:
187-
IMPORT + `export default defineStack(${stackBody('ctc', 'subtitle', 'no-such-capability')});\n`,
189+
IMPORT + `export default defineStack(${stackBody('ctc', 'visibleWhen', 'no-such-capability')});\n`,
188190
};
189191

190192
let root = '';
@@ -232,7 +234,7 @@ for (const command of ['validate', 'build'] as const) {
232234
expect(run.code, run.stdout + run.stderr).toBe(0);
233235
expectExactly(p, [THE_NOTICE], `${command} namedExportPass`);
234236
// The producer never saw that key, so it printed nothing for it.
235-
expect(run.stderr).not.toContain("conversion 'page-header-subtitle-alias'");
237+
expect(run.stderr).not.toContain("conversion 'page-component-visibility-to-visibleWhen'");
236238
}, 180_000);
237239

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

256258
it('the plain case: one `defineStack` default export carrying the retiring spelling', async () => {
257259
const run = await lint('plain');
@@ -333,7 +335,7 @@ describe('os validate --strict — a conversion the producer applied fails it, o
333335
const json = await runCli(['validate', '--json', '--strict'], dirs.recordAcrossSpread);
334336
expect(text.code, text.stdout + text.stderr).toBe(1);
335337
expect(text.stdout).toContain('Strict mode: warnings treated as errors');
336-
expect(text.stdout).toContain("conversion 'page-header-subtitle-alias'");
338+
expect(text.stdout).toContain("conversion 'page-component-visibility-to-visibleWhen'");
337339
expect(json.code, json.stdout + json.stderr).toBe(1);
338340
const p = payloadOf(json, 'validate --json --strict');
339341
expect(p.valid).toBe(true);

‎packages/lint/src/validate-component-props.test.ts‎

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -713,31 +713,35 @@ describe('validateComponentProps — object-* blocks are dispatched (#7751)', ()
713713
});
714714

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

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

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

0 commit comments

Comments
 (0)