From 1eddd8974612e5e0289f2bf355aa39a66f8eac4d Mon Sep 17 00:00:00 2001 From: os-bill Date: Fri, 11 Sep 2026 00:36:30 +0000 Subject: [PATCH 1/4] wip(spec): refuse bare element:filter / element:form nodes by name Source half of #15110: the two elements join RETIRED_PAGE_COMPONENT_TYPES with the element-grain tail of their own retiredKey tombstones, the typo suggester stops offering retired keys, and the prose the node refusal falsifies is corrected. Tests follow in the next commit. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- packages/spec/src/conversions/registry.ts | 25 ++++--- .../18.ui__ElementFilterProps__aria.ts | 4 +- .../18.ui__ElementFilterProps__fields.ts | 4 +- .../18.ui__ElementFilterProps__layout.ts | 4 +- .../18.ui__ElementFilterProps__object.ts | 4 +- .../18.ui__ElementFilterProps__showSearch.ts | 4 +- ....ui__ElementFilterProps__targetVariable.ts | 4 +- .../18.ui__ElementFormProps__aria.ts | 4 +- .../18.ui__ElementFormProps__fields.ts | 4 +- .../18.ui__ElementFormProps__mode.ts | 4 +- .../18.ui__ElementFormProps__object.ts | 4 +- .../18.ui__ElementFormProps__onSubmit.ts | 4 +- .../18.ui__ElementFormProps__submitLabel.ts | 4 +- packages/spec/src/migrations/registry.ts | 54 +++++++++++---- .../spec/src/ui/component-type-vocabulary.ts | 26 +++++-- packages/spec/src/ui/component.zod.ts | 37 ++++++---- packages/spec/src/ui/page.zod.ts | 67 +++++++++++++++---- 17 files changed, 189 insertions(+), 68 deletions(-) diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index df7ab777f7a..ecaeb5c9a54 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -6801,11 +6801,13 @@ const elementInputTargetVariableRemoved: MetadataConversion = { * them together. * * Pure lossless deletes — no key ever had an effect to lose. The component - * node itself is NOT removed: the open `type` union tolerates a bare inert - * node (nothing rendered it before either), and deleting authored page nodes - * is a layout decision a mechanical conversion must not make. The + * node itself is NOT removed: deleting authored page nodes is a layout + * decision a mechanical conversion must not make. The bare node it leaves is + * not the end state — `element:filter` is a member of + * `RETIRED_PAGE_COMPONENT_TYPES`, so the parse refuses it by name and the * prescription tells the author to delete the component and use the list - * surface's own filtering instead. + * surface's own filtering instead. Mechanical where it can be, located where + * it cannot. */ const elementFilterRemoved: MetadataConversion = { id: 'element-filter-removed', @@ -6819,7 +6821,8 @@ const elementFilterRemoved: MetadataConversion = { "the whole 'element:filter' element retired (#9220 — no renderer for it ever shipped in " + 'any repo, so every key was a capability claim nothing kept; list surfaces own their ' + "filtering via a view's userFilters / the list filter builder). All six props are " - + 'stripped; the bare node stays, inert as it always was', + + 'stripped; the bare node the conversion leaves is refused by name at the parse, with ' + + 'the prescription to delete the component', apply(stack, emit) { return mapPageComponents(stack, (component, path) => { if (component.type !== 'element:filter') return component; @@ -6949,13 +6952,14 @@ const elementFilterRemoved: MetadataConversion = { * together and this conversion strips them together. * * Pure lossless deletes — no key ever had an effect to lose. The component - * node itself is NOT removed: the open `type` union tolerates a bare inert - * node (nothing rendered it before either), and deleting authored page nodes - * is a layout decision a mechanical conversion must not make. The + * node itself is NOT removed: deleting authored page nodes is a layout + * decision a mechanical conversion must not make. The bare node it leaves is + * not the end state — `element:form` is a member of + * `RETIRED_PAGE_COMPONENT_TYPES`, so the parse refuses it by name and the * prescription tells the author to delete the component and use the * object-bound `object-form` block (#7751) instead — rendered, * designer-publishable, and carrying the same intent (`objectName`, `fields`, - * `mode`, `submitText`). + * `mode`, `submitText`). Mechanical where it can be, located where it cannot. */ const elementFormRemoved: MetadataConversion = { id: 'element-form-removed', @@ -6969,7 +6973,8 @@ const elementFormRemoved: MetadataConversion = { "the whole 'element:form' element retired (#9249 — no renderer for it ever shipped in " + 'any repo, so every key was a capability claim nothing kept; use the object-bound ' + "'object-form' block instead — rendered and designer-publishable). All six props are " - + 'stripped; the bare node stays, inert as it always was', + + 'stripped; the bare node the conversion leaves is refused by name at the parse, with ' + + 'the prescription to delete the component', apply(stack, emit) { return mapPageComponents(stack, (component, path) => { if (component.type !== 'element:form') return component; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__aria.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__aria.ts index 94eea29abaf..277ff13d76b 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__aria.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__aria.ts @@ -18,5 +18,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFilterProps:aria'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__fields.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__fields.ts index 879f841fe57..4fe43cda25c 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__fields.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__fields.ts @@ -18,5 +18,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFilterProps:fields'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__layout.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__layout.ts index 95b2f1f8f02..ea31605553d 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__layout.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__layout.ts @@ -18,5 +18,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFilterProps:layout'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__object.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__object.ts index 486c9b9d779..51de0662a61 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__object.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__object.ts @@ -18,5 +18,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFilterProps:object'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__showSearch.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__showSearch.ts index ecb7150c60e..d20d46b4119 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__showSearch.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__showSearch.ts @@ -18,5 +18,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFilterProps:showSearch'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__targetVariable.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__targetVariable.ts index 73bad6d58b4..da55fbdc5b0 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__targetVariable.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFilterProps__targetVariable.ts @@ -18,5 +18,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFilterProps:targetVariable'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__aria.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__aria.ts index 1fc79fb9470..6cfe9c503de 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__aria.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__aria.ts @@ -20,5 +20,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFormProps:aria'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__fields.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__fields.ts index dab0289613b..c6b6f58cfdd 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__fields.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__fields.ts @@ -20,5 +20,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFormProps:fields'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__mode.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__mode.ts index 3767b70bd36..4bae07fd827 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__mode.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__mode.ts @@ -20,5 +20,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFormProps:mode'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__object.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__object.ts index 1576c5e758c..d664a8f00fb 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__object.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__object.ts @@ -20,5 +20,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFormProps:object'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__onSubmit.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__onSubmit.ts index b79de289e0e..74629d5dd05 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__onSubmit.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__onSubmit.ts @@ -20,5 +20,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFormProps:onSubmit'; diff --git a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__submitLabel.ts b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__submitLabel.ts index b5ca8df8524..e4020feb163 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__submitLabel.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.ui__ElementFormProps__submitLabel.ts @@ -20,5 +20,7 @@ // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which -// strips all six keys and leaves the bare node — inert as it always was. +// strips all six keys and leaves the bare node — which the parse then refuses +// by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the +// component. export const entry = 'ui/ElementFormProps:submitLabel'; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 3ca473a7368..4727e149667 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5149,7 +5149,8 @@ const step18: MigrationStep = { 'the 2026-06 page-liveness audit recorded it rendering "Unknown component type" — so ' + 'every one of its six authorable keys was a capability claim nothing kept. All six ' + 'are retiredKey tombstones; the mechanical conversion strips them from old sources ' + - '(pure lossless deletes) and leaves the bare node, inert as it always was. List ' + + '(pure lossless deletes) and leaves the bare node, which the parse then refuses by ' + + 'name — delete the component. List ' + 'surfaces own their filtering: a view\'s `userFilters` quick-filter bar / the list ' + 'toolbar\'s filter builder. ' + 'It also retires the whole `element:form` element (#9249, ADR-0049 enforce-or-remove ' + @@ -5160,7 +5161,8 @@ const step18: MigrationStep = { 'rendering "Unknown component type" — so every one of its six authorable keys was a ' + 'capability claim nothing kept. All six are retiredKey tombstones; the mechanical ' + 'conversion strips them from old sources (pure lossless deletes) and leaves the bare ' + - 'node, inert as it always was. Use the object-bound `object-form` block instead ' + + 'node, which the parse then refuses by name — delete the component. ' + + 'Use the object-bound `object-form` block instead ' + '(#7751) — rendered, designer-publishable, and carrying the same intent ' + '(`objectName`, `fields`, `mode`, `submitText`). ' + 'It also closes the two explicit column lists on relationship fields (#9227): ' + @@ -12965,7 +12967,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFilterProps:aria', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its @@ -12985,7 +12989,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFilterProps:fields', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its @@ -13005,7 +13011,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFilterProps:layout', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its @@ -13025,7 +13033,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFilterProps:object', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its @@ -13045,7 +13055,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFilterProps:showSearch', // #9220 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:filter` never // had a renderer or reader anywhere: objectui registers none (its @@ -13065,7 +13077,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-filter-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFilterProps:targetVariable', // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never // had a renderer or reader anywhere — the #9220 shape one element over, @@ -13087,7 +13101,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFormProps:aria', // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never // had a renderer or reader anywhere — the #9220 shape one element over, @@ -13109,7 +13125,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFormProps:fields', // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never // had a renderer or reader anywhere — the #9220 shape one element over, @@ -13131,7 +13149,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFormProps:mode', // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never // had a renderer or reader anywhere — the #9220 shape one element over, @@ -13153,7 +13173,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFormProps:object', // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never // had a renderer or reader anywhere — the #9220 shape one element over, @@ -13175,7 +13197,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFormProps:onSubmit', // #9249 — ADR-0049 enforce-or-remove at ELEMENT grain. `element:form` never // had a renderer or reader anywhere — the #9220 shape one element over, @@ -13197,7 +13221,9 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // narrowings ride minor releases) and the prescription lives at the major // boundary where `migrate meta` users look (the #8495 / PR #8666 precedent). // Sources are rewritten by the D2 conversion `element-form-removed`, which - // strips all six keys and leaves the bare node — inert as it always was. + // strips all six keys and leaves the bare node — which the parse then refuses + // by name (`RETIRED_PAGE_COMPONENT_TYPES`), with the prescription to delete the + // component. 'ui/ElementFormProps:submitLabel', // #9198 — ADR-0049 enforce-or-remove. `targetVariable` on // `element:record_picker` was a declarative hint with zero readers: the picker diff --git a/packages/spec/src/ui/component-type-vocabulary.ts b/packages/spec/src/ui/component-type-vocabulary.ts index 9b1a95bc1a3..1c1a40be3d4 100644 --- a/packages/spec/src/ui/component-type-vocabulary.ts +++ b/packages/spec/src/ui/component-type-vocabulary.ts @@ -39,7 +39,7 @@ * where the author who typed the string is still present to fix it. */ -import { PageComponentType } from './page.zod'; +import { PageComponentType, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; import { ComponentPropsMap } from './component.zod'; /** @@ -81,8 +81,17 @@ export const RESERVED_COMPONENT_TYPE_NAMESPACES: ReadonlySet = new Set( * Every type string the spec answers for: the enum vocabulary, every * `ComponentPropsMap` row (which is a superset of the enum by exactly the * measured string-arm registrations that DID get a row — `element:metadata_viewer`, - * the retired-with-tombstones `element:filter` / `element:form`, the plugin - * console widgets, the `object-*` blocks), and the string-arm ledger above. + * the plugin console widgets, the `object-*` blocks — plus every type the + * vocabulary RETIRED by name, whose row is kept on purpose so the readers that + * dispatch on it keep recognising the name: `user:profile`, and the + * retired-with-tombstones `element:filter` / `element:form`), and the + * string-arm ledger above. + * + * KNOWN is not the same as WRITABLE. A retired type stays known here — that is + * what makes its refusal a located prescription instead of an + * unregistered-custom-string skip — and is refused at the parse by + * `PageComponentSchema.type`. The candidate list below is where the difference + * is spent. */ export const KNOWN_COMPONENT_TYPES: ReadonlySet = new Set([ ...PageComponentType.options, @@ -93,9 +102,18 @@ export const KNOWN_COMPONENT_TYPES: ReadonlySet = new Set([ /** * Stable candidate list for typo suggestions — only the types an author may * actually write inside a reserved namespace, sorted for deterministic output. + * + * "May actually write" is why the RETIRED types come OUT. They are KNOWN (their + * `ComponentPropsMap` rows are kept deliberately), but `PageComponentSchema` + * refuses them by name, so proposing one answers a typo with a rename the + * parser will reject — `element:fitler` was answered "Rename `element:fitler` + * → `element:filter`", renaming an author INTO a retired element. A suggester + * that can only ever be right is a suggester that draws from the writable set, + * so this list is derived from the retirement map rather than restated beside + * it: a type retired tomorrow leaves the candidates the day it lands. */ export const KNOWN_COMPONENT_TYPE_CANDIDATES: readonly string[] = - [...KNOWN_COMPONENT_TYPES].sort(); + [...KNOWN_COMPONENT_TYPES].filter((t) => !RETIRED_PAGE_COMPONENT_TYPES.has(t)).sort(); /** * Is this type inside a namespace the spec's enum claims? (`record:detials` → diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index f5e6bf1d720..e44bfabbd2a 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -8,9 +8,9 @@ import { FeedItemType, FeedFilterMode } from '../data/feed.zod'; import { lazySchema } from '../shared/lazy-schema'; import { ExpressionInputSchema } from '../shared/expression.zod'; import { retiredKey } from '../shared/retired-key'; -// `user:profile`'s retirement prescription — one string, three doors (#14159): -// the enum's error map and the `PageComponentSchema.type` check in page.zod.ts, -// and the kept `ComponentPropsMap` row below (`retiredComponentProps`). +// The retired page-component TYPES' prescriptions — one string per type, three +// doors (#14159): the enum's error map and the `PageComponentSchema.type` check +// in page.zod.ts, and the kept `ComponentPropsMap` rows below. import { RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; // `element:record_picker`'s flat `sort` shorthand is the SAME contract as // `ElementDataSourceSchema.sort` (page.zod.ts) — one shape, imported from the @@ -2051,10 +2051,13 @@ const elementFilterRetired = (key: string): string => * props gate keeps DISPATCHING on `type: 'element:filter'` and refusing every * authored key with the prescription — deleting the row would demote the type * to an unregistered custom string the gate deliberately skips, turning a loud - * retirement back into a silent no-op. A bare node with empty `properties` - * parses clean (the open `type` union accepts any string, so a node-level - * refusal is not expressible here); the migration strips the keys and leaves - * exactly that bare, inert node. + * retirement back into a silent no-op. The bare node the migration leaves + * behind — it strips the keys and nothing else — used to parse clean, because + * the open `type` union accepts any string and a node-level refusal was not + * expressible here. It is expressible one level up: `element:filter` is a + * member of `RETIRED_PAGE_COMPONENT_TYPES` (page.zod.ts), so + * `PageComponentSchema` now refuses the node by name and hands the author the + * element-grain tail of these very tombstones. */ export const ElementFilterPropsSchema = lazySchema(() => strictObject({ surface: 'this `element:filter`', @@ -2101,10 +2104,13 @@ const elementFormRetired = (key: string): string => * props gate keeps DISPATCHING on `type: 'element:form'` and refusing every * authored key with the prescription — deleting the row would demote the type * to an unregistered custom string the gate deliberately skips, turning a loud - * retirement back into a silent no-op. A bare node with empty `properties` - * parses clean (the open `type` union accepts any string, so a node-level - * refusal is not expressible here); the migration strips the keys and leaves - * exactly that bare, inert node. + * retirement back into a silent no-op. The bare node the migration leaves + * behind — it strips the keys and nothing else — used to parse clean, because + * the open `type` union accepts any string and a node-level refusal was not + * expressible here. It is expressible one level up: `element:form` is a + * member of `RETIRED_PAGE_COMPONENT_TYPES` (page.zod.ts), so + * `PageComponentSchema` now refuses the node by name and hands the author the + * element-grain tail of these very tombstones. */ export const ElementFormPropsSchema = lazySchema(() => strictObject({ surface: 'this `element:form`', @@ -3134,9 +3140,12 @@ export const ComponentPropsMap = { // that dispatches on the row (the #5068 props gate, `check-yaml-examples`, // the vocabulary's known set) keeps recognising the name and refuses it with // the prescription instead of skipping it as an unregistered custom string. - // Unlike those two, the WHOLE bag is refused — `{}` included — because the - // node itself is refused by name at `PageComponentSchema.type`; a row that - // accepted the empty bag would contradict the door one level up. + // All three names are refused at the node by `PageComponentSchema.type`; the + // rows differ only in what they have to say about a bag that door no longer + // lets through. This type never had an authorable key, so the WHOLE bag is + // refused — `{}` included. The two elements below carry six tombstoned keys + // each, where a per-key prescription says more than one whole-bag refusal + // could. 'user:profile': retiredComponentProps('user:profile'), // Plugin console widgets — #11575, the #8691/#8744 mechanism two instances diff --git a/packages/spec/src/ui/page.zod.ts b/packages/spec/src/ui/page.zod.ts index d8f39d0ece0..680ad6cc2a9 100644 --- a/packages/spec/src/ui/page.zod.ts +++ b/packages/spec/src/ui/page.zod.ts @@ -67,14 +67,19 @@ export const PageRegionSchema = lazySchema(() => strictObject({ // retirements recorded as "a node-level refusal is not expressible here". It is // expressible one level up, and this map is what makes it so: the union carries // a check against it (see `PageComponentSchema.type`), so the name is refused -// with this prescription at the element's own path (`code: 'custom'`), and -// `ComponentPropsMap['user:profile']` (component.zod.ts) refuses the props bag -// with the same string for every reader that dispatches on the row. One -// prescription, three doors, no drift. +// with this prescription at the element's own path (`code: 'custom'`), and the +// kept `ComponentPropsMap` row (component.zod.ts) refuses the props bag with +// the same text for every reader that dispatches on the row — as `z.never` for +// `user:profile`, which never had an authorable key, and as the per-key +// `retiredKey` tombstones whose element-grain tail this map reuses for the two +// elements. One prescription, three doors, no drift. // // Adding a member here is an accept-set narrowing (Clause ②) — a contract // decision, never a convenience: an entry needs the ruling that retired the // type, the measured zero-renderer finding, and the row + enum edits beside it. +// For a member retired BEFORE this map existed, one more thing: that member's +// own docblock must already record the surviving bare node as unintended — +// family resemblance to a member already here is NOT a reason to add one. // // The prescription names no issue id on purpose — `check:doc-authoring` refuses // citation-shaped tokens in text printed AT the customer (maintainer ruling @@ -93,6 +98,34 @@ export const RETIRED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map + 'additively). Removed from `PageComponentType` in @objectstack/spec 17 (ADR-0049 ' + 'enforce-or-remove); the name stays refused here so the failure lands in front of the ' + 'author, not the user.'], + // #9220 / #9249, ADR-0049 enforce-or-remove at ELEMENT grain. The node-level + // half of two retirements that could only reach their KEYS when they landed: + // each element's own docblock (component.zod.ts) recorded the remainder as + // structural — "A bare node with empty `properties` parses clean (the open + // `type` union accepts any string, so a node-level refusal is not expressible + // here)". It is expressible HERE, and this map is what makes it so. + // + // The prescription is the element-grain TAIL of that element's own + // `retiredKey` tombstones, reused verbatim: the node message is the key + // message with its `property \`\`` clause dropped, so the two doors + // carry one text and cannot drift (pinned in `component.test.ts`). No new + // prose is authored here. + ['element:filter', '`element:filter` was removed in @objectstack/spec 17 ' + + '(ADR-0049) — the whole `element:filter` element is retired: no renderer for it ' + + 'ever shipped in objectui, framework or cloud (Studio\'s designer palette lists it as a ' + + 'no-renderer exclusion), so every key on this element was a capability claim nothing ' + + 'kept. Delete the `element:filter` component; list surfaces own their filtering — use a ' + + "view's `userFilters` quick-filter bar or the list toolbar's filter builder. " + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'], + ['element:form', '`element:form` was removed in @objectstack/spec 17 ' + + '(ADR-0049) — the whole `element:form` element is retired: no renderer for it ' + + 'ever shipped in objectui, framework or cloud (Studio\'s designer palette lists it as a ' + + 'no-renderer exclusion — "use the object-bound `object-form` block"), so every key on ' + + 'this element was a capability claim nothing kept. Delete the `element:form` component ' + + 'and use the object-bound `object-form` block instead — it is rendered, ' + + 'designer-publishable, and carries the same intent (`objectName`, `fields`, `mode`, ' + + '`submitText`). ' + + 'Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand.'], ]); /** @@ -102,12 +135,14 @@ export const RETIRED_PAGE_COMPONENT_TYPES: ReadonlyMap = new Map * objectstack#12183 ask, ruled 2026-09-01): a user profile is shell chrome (the * avatar menu), no mainstream product makes it a page-placeable component, and * no renderer for it ever existed anywhere (objectui#7135 measured the zero - * with a positive control in the same query shape). Unlike the `element:filter` + * with a positive control in the same query shape). Like the `element:filter` * / `element:form` removals below, dropping the value is NOT de-advertisement * only: the name is refused through {@link RETIRED_PAGE_COMPONENT_TYPES} — by * this enum's error map, by the check on `PageComponentSchema.type` that the * open string arm would otherwise defeat, and by the kept `ComponentPropsMap` - * row. + * row. (`user:profile` was refused by name from the day it left the enum; the + * two elements left the enum first and were refused by name later, once this + * map existed to express it.) */ export const PageComponentType = z.enum([ // Structure @@ -130,13 +165,16 @@ export const PageComponentType = z.enum([ 'element:text', 'element:number', 'element:image', 'element:divider', // Interactive Elements (Phase B — Element Library) // `element:filter` REMOVED (#9220, ADR-0049): retired at element grain — no - // renderer ever shipped anywhere. Dropping the enum value is de-advertisement - // only (the `type` union's open string arm still accepts any string); the - // LOUD half of the retirement is `ElementFilterPropsSchema`'s retiredKey - // tombstones, dispatched through the kept `ComponentPropsMap` row. + // renderer ever shipped anywhere. Dropping the enum value was de-advertisement + // only while the `type` union's open string arm still accepted the name; the + // LOUD half of the retirement was `ElementFilterPropsSchema`'s retiredKey + // tombstones, dispatched through the kept `ComponentPropsMap` row. The bare + // node that survived both is refused by name through + // `RETIRED_PAGE_COMPONENT_TYPES` above, with the tombstones' own tail. // `element:form` REMOVED (#9249, ADR-0049): the same shape one element over - // — retired at element grain, same mechanism; the tombstones' prescription - // names the live replacement, the object-bound `object-form` block (#7751). + // — retired at element grain, same mechanism, same node-level refusal; the + // tombstones' prescription names the live replacement, the object-bound + // `object-form` block (#7751). 'element:button', 'element:record_picker', 'element:text_input' ], { // Only a value that USED to be legal gets a retirement prescription; every @@ -247,7 +285,8 @@ export const PageComponentSchema = lazySchema(() => strictObject({ * here, with its prescription at this node's path. The enum's own error map * cannot deliver it through this door (the string arm admits whatever the * enum refuses), which is the gap the `element:filter` / `element:form` - * retirements recorded as "a node-level refusal is not expressible here". + * retirements recorded as "a node-level refusal is not expressible here" — + * and which this check now closes for those two members as well. */ type: z.union([ PageComponentType, @@ -263,7 +302,7 @@ export const PageComponentSchema = lazySchema(() => strictObject({ if (guidance) { ctx.addIssue({ code: 'custom', message: guidance, params: { retiredComponentType: type } }); } - }).describe('Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec\'s own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable) is refused at the parse itself, with the retirement prescription.'), + }).describe('Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec\'s own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription.'), id: z.string().optional().describe('Unique instance ID'), /** Configuration */ From cb74176fc5a80d357fadb2db9c3328234cc66104 Mon Sep 17 00:00:00 2001 From: os-bill Date: Fri, 11 Sep 2026 00:54:54 +0000 Subject: [PATCH 2/4] wip(spec): pins for the node refusal and the suggester Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- content/docs/references/ui/page.mdx | 4 +- content/docs/ui/pages.mdx | 2 +- .../lint/src/validate-component-types.test.ts | 56 ++++++ .../src/ui/component-type-vocabulary.test.ts | 26 ++- packages/spec/src/ui/component.test.ts | 166 +++++++++++++++++- 5 files changed, 239 insertions(+), 15 deletions(-) diff --git a/content/docs/references/ui/page.mdx b/content/docs/references/ui/page.mdx index 512a0a6c3cf..a1ce95db0b0 100644 --- a/content/docs/references/ui/page.mdx +++ b/content/docs/references/ui/page.mdx @@ -245,7 +245,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| 'page:card' \| 'page:section' \| 'record:details' \| 'record:highlights' \| 'record:related_list' \| 'record:activity' \| 'record:chatter' \| 'record:discussion' \| 'record:path' \| 'record:alert' \| 'record:quick_actions' \| 'record:reference_rail' \| 'record:history' \| 'app:launcher' \| 'nav:menu' \| 'nav:breadcrumb' \| 'global:search' \| 'global:notifications' \| 'ai:chat_window' \| 'ai:suggestion' \| 'element:text' \| 'element:number' \| 'element:image' \| 'element:divider' \| 'element:button' \| 'element:record_picker' \| 'element:text_input'> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | @@ -343,7 +343,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable) is refused at the parse itself, with the retirement prescription. | +| **type** | `Enum<'page:header' \| 'page:footer' \| 'page:sidebar' \| 'page:tabs' \| 'page:accordion' \| …> \| string` | ✅ | Component Type — a standard vocabulary member, or a custom/registered component type in its own namespace (e.g. `object-grid`, `mcp:connect-agent`). The spec's own type namespaces are a closed vocabulary at author time: inside them, a type the vocabulary does not declare is refused by `os validate` / `os build` / `os lint` (rule `component-type-unknown`); a type the vocabulary RETIRED by name (`user:profile` — shell chrome, not author-placeable; `element:filter` and `element:form` — retired whole, no renderer for either ever shipped) is refused at the parse itself, with the retirement prescription. | | **id** | `string` | optional | Unique instance ID | | **label** | `string \| Record` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **properties** | `Record` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. | diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index 047cc263923..6dbbb163602 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -184,7 +184,7 @@ The `type` field is a union of the standard `PageComponentType` enum and any cus - **Navigation:** `app:launcher`, `nav:menu`, `nav:breadcrumb` - **Utility:** `global:search`, `global:notifications` — `user:profile` is **not** author-placeable: it is shell chrome (the signed-in user's avatar menu, which the app shell renders itself on every page), no renderer exists for it by ruling (objectstack#14159 / objectui#7135), and an authored `user:profile` node is refused at the schema door — `definePage()`, `os validate`, `os build` — with that prescription at the node's path instead of drawing an unknown-type panel in front of a user - **AI:** `ai:chat_window`, `ai:suggestion` -- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:record_picker`, `element:text_input` (`element:filter` and `element:form` were retired in v17.x — no renderer ever shipped for either. List surfaces own their filtering via a view's `userFilters` quick-filter bar or the list toolbar's filter builder; for forms use the object-bound `object-form` block, which is rendered and designer-publishable) +- **Elements:** `element:text`, `element:number`, `element:image`, `element:divider`, `element:button`, `element:record_picker`, `element:text_input` (`element:filter` and `element:form` were retired in v17.x — no renderer ever shipped for either, and an authored node of either type is refused at the schema door — `definePage()`, `os validate`, `os build` — with that prescription at the node's path, bare node included. List surfaces own their filtering via a view's `userFilters` quick-filter bar or the list toolbar's filter builder; for forms use the object-bound `object-form` block, which is rendered and designer-publishable) Components may also carry `dataSource` (per-element object binding for multi-object pages), `responsiveStyles` (per-breakpoint scoped CSS, ADR-0065), and `aria` configuration. Custom string types are also accepted for project-specific widgets. (The former `responsive` layout block was retired in v17.x — no renderer ever applied it; see the upgrade guide.) diff --git a/packages/lint/src/validate-component-types.test.ts b/packages/lint/src/validate-component-types.test.ts index 4a6748fbf4b..b21971c702f 100644 --- a/packages/lint/src/validate-component-types.test.ts +++ b/packages/lint/src/validate-component-types.test.ts @@ -133,3 +133,59 @@ describe('leaves the declared vocabulary and the open arm alone', () => { expect(findings[0].message).toContain("'nav:menu'"); }); }); + +/** + * #15110 — the suggester must never rename an author INTO a retired type. + * + * Measured before the fix, through this same rule: `element:fitler` was + * answered `Rename \`element:fitler\` → \`element:filter\``, and + * `element:frm` → `element:form`. Both targets are types + * `PageComponentSchema` refuses by name, so the tool was emitting guidance the + * parser rejects — wrong guidance, not a missing refusal. + * + * Pinned through the RULE, never by reading `KNOWN_COMPONENT_TYPE_CANDIDATES`: + * the array is the mechanism, the hint is the contract. + */ +describe('retired types are never proposed as typo suggestions (#15110)', () => { + it.each([ + ['element:fitler', 'element:filter'], + ['element:frm', 'element:form'], + ])('a near-miss of %s no longer proposes the retired %s', (typo, retired) => { + const findings = validateComponentTypes(page([{ type: typo }])); + // The typo is still refused — the rule's own job is untouched. + expect(findings).toHaveLength(1); + const f = findings[0]; + expect(f.rule).toBe(COMPONENT_TYPE_UNKNOWN); + // ...but nothing about the finding points the author at the retired name. + expect(f.hint).not.toContain(retired); + expect(f.message).not.toContain(retired); + }); + + it('the reverse direction: what it proposes instead is never worse', () => { + // A retired-name near-miss either proposes a type that is actually + // writable, or proposes nothing and falls back to the own-namespace + // prescription. Both are acceptable; a proposal the parser would refuse is + // not, which is what the per-case assertion above forbids. + for (const typo of ['element:fitler', 'element:frm']) { + const f = validateComponentTypes(page([{ type: typo }]))[0]; + const proposed = /Rename `[^`]+` → `([^`]+)`/.exec(f.hint)?.[1]; + if (proposed === undefined) { + expect(f.hint).toContain('give it its own namespace'); + continue; + } + // Whatever it proposes must itself pass the vocabulary the rule guards. + expect(validateComponentTypes(page([{ type: proposed }]))).toEqual([]); + } + }); + + it('LIVE types are still proposed — the lit control', () => { + // Same rule, same call shape, same reserved-namespace typo: if the + // subtraction had emptied the candidate list, these would go quiet too. + expect(validateComponentTypes(page([{ type: 'global:serch' }]))[0].hint) + .toContain('global:search'); + expect(validateComponentTypes(page([{ type: 'element:butotn' }]))[0].hint) + .toContain('element:button'); + expect(validateComponentTypes(page([{ type: 'record:detials' }]))[0].hint) + .toContain('record:details'); + }); +}); diff --git a/packages/spec/src/ui/component-type-vocabulary.test.ts b/packages/spec/src/ui/component-type-vocabulary.test.ts index fffc3eb96d1..e6222cbefe8 100644 --- a/packages/spec/src/ui/component-type-vocabulary.test.ts +++ b/packages/spec/src/ui/component-type-vocabulary.test.ts @@ -17,7 +17,7 @@ import { hasReservedComponentNamespace, isKnownComponentType, } from './component-type-vocabulary'; -import { PageComponentType } from './page.zod'; +import { PageComponentType, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; import { ComponentPropsMap } from './component.zod'; describe('RESERVED_COMPONENT_TYPE_NAMESPACES is derived from the enum', () => { @@ -51,8 +51,28 @@ describe('KNOWN_COMPONENT_TYPES covers every declared face', () => { } }); - it('the candidate list is the known set, sorted and stable', () => { - expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toEqual([...KNOWN_COMPONENT_TYPES].sort()); + /** + * #15110 — the candidate list is the known set MINUS what the vocabulary + * retired by name. Known and writable are different questions: a retired + * type stays known (its `ComponentPropsMap` row is kept on purpose), and + * `PageComponentSchema` refuses it, so proposing it answers a typo with a + * rename the parser rejects. Derived from the retirement map, never restated + * — a type retired tomorrow leaves the candidates the day it lands. + */ + it('the candidate list is the known set MINUS the retired types, sorted and stable', () => { + const writable = [...KNOWN_COMPONENT_TYPES].filter((t) => !RETIRED_PAGE_COMPONENT_TYPES.has(t)); + expect(KNOWN_COMPONENT_TYPE_CANDIDATES).toEqual(writable.sort()); + // The subtraction is not empty — an assertion that held vacuously would + // green on a candidate list that had stopped subtracting anything. + expect(RETIRED_PAGE_COMPONENT_TYPES.size).toBeGreaterThan(0); + for (const retired of RETIRED_PAGE_COMPONENT_TYPES.keys()) { + expect(isKnownComponentType(retired), retired).toBe(true); + expect(KNOWN_COMPONENT_TYPE_CANDIDATES, retired).not.toContain(retired); + } + // Lit control: every live enum member IS a candidate. + for (const member of PageComponentType.options) { + expect(KNOWN_COMPONENT_TYPE_CANDIDATES, member).toContain(member); + } }); /** diff --git a/packages/spec/src/ui/component.test.ts b/packages/spec/src/ui/component.test.ts index c72a8d34c60..04ae33e91d9 100644 --- a/packages/spec/src/ui/component.test.ts +++ b/packages/spec/src/ui/component.test.ts @@ -23,7 +23,7 @@ import { ObjectMetricPropsSchema, ObjectKanbanPropsSchema, } from './component.zod'; -import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema } from './page.zod'; +import { PageComponentSchema, PageSchema, PageComponentType, ElementDataSourceSchema, RETIRED_PAGE_COMPONENT_TYPES } from './page.zod'; describe('PageHeaderProps', () => { it('should accept minimal header', () => { @@ -1552,14 +1552,27 @@ describe('Interactive Elements — element:button', () => { // Interactive Elements — element:filter (RETIRED at element grain, #9220) // --------------------------------------------------------------------------- describe('Interactive Elements — element:filter (retired, #9220)', () => { - // The node-level parse never judged `properties` (that is the #5068 props - // gate's job), and `type` is an open union — so a stored, not-yet-migrated - // node still parses at THIS level. Pinned so the element retirement is not - // misread as a node-level refusal. - it('still parses at the node level — the refusal lives at the props dispatch', () => { + // FLIPPED (#15110). This pin used to read "still parses at the node level — + // the refusal lives at the props dispatch", and the docblock above + // `ElementFilterPropsSchema` recorded why: "A bare node with empty + // `properties` parses clean (the open `type` union accepts any string, so a + // node-level refusal is not expressible here)". #14159 built the door that + // expresses it; `element:filter` is a member of + // `RETIRED_PAGE_COMPONENT_TYPES`, so the node is refused BY NAME wherever it + // is written, populated or bare. The located refusal is pinned in the + // describe below; this one holds the flip itself. + it('no longer parses at the node level — the name is refused, populated or bare', () => { expect(() => PageComponentSchema.parse({ type: 'element:filter', properties: { object: 'order', fields: ['status'] }, + })).toThrow(/`element:filter` element is retired/); + expect(() => PageComponentSchema.parse({ + type: 'element:filter', + properties: {}, + })).toThrow(/`element:filter` element is retired/); + // Lit control: a LIVE element in the same namespace is untouched. + expect(() => PageComponentSchema.parse({ + type: 'element:text', properties: { text: 'hi' }, })).not.toThrow(); }); @@ -1603,12 +1616,21 @@ describe('Interactive Elements — element:filter (retired, #9220)', () => { // Interactive Elements — element:form // --------------------------------------------------------------------------- describe('Interactive Elements — element:form (retired, #9249)', () => { - // The node itself stays parseable: the open `type` union accepts any string, - // and the migration leaves a bare inert node behind. - it('accepts a bare element:form node (the migrated shape)', () => { + // FLIPPED (#15110). This pin used to read "accepts a bare element:form node + // (the migrated shape)", on the reading the docblock recorded as structural: + // "the open `type` union accepts any string, so a node-level refusal is not + // expressible here". `element:form` is now a member of + // `RETIRED_PAGE_COMPONENT_TYPES`, so the node is refused by name — the bare + // migrated shape included, which is the whole point: that is the shape an + // author is left holding. + it('no longer accepts a bare element:form node — the migrated shape is refused by name', () => { expect(() => PageComponentSchema.parse({ type: 'element:form', properties: {}, + })).toThrow(/`element:form` element is retired/); + // Lit control: a LIVE element in the same namespace is untouched. + expect(() => PageComponentSchema.parse({ + type: 'element:button', properties: { label: 'Save' }, })).not.toThrow(); }); @@ -1644,6 +1666,132 @@ describe('Interactive Elements — element:form (retired, #9249)', () => { }); }); +// --------------------------------------------------------------------------- +// The two element-grain retirements are refused BY NAME at the node (#15110) +// --------------------------------------------------------------------------- + +/** + * #15110 — the node-level half of #9220 / #9249, expressed through the door + * #14159 built for `user:profile`. Each element's own docblock recorded the + * surviving bare node as structural, not intended: "A bare node with empty + * `properties` parses clean (the open `type` union accepts any string, so a + * node-level refusal is not expressible here)". It is expressible one level up. + * + * The shape mirrors the `user:profile` describe above deliberately: `code` + + * `path` + `params` + the prescription's text are the pin, never a bare + * `toThrow()`, which greens on any error. + */ +describe('element:filter / element:form are refused by name at the node (#15110)', () => { + // `check:doc-authoring` (maintainer ruling 2026-08-12): a prescription + // printed at the customer carries no citation-shaped issue id. + const ISSUE_ID = /#\d{3,}/; + const cases = [ + { type: 'element:filter', props: ElementFilterPropsSchema, key: 'object', + marker: 'list surfaces own their filtering' }, + { type: 'element:form', props: ElementFormPropsSchema, key: 'object', + marker: 'use the object-bound `object-form` block instead' }, + ] as const; + + it.each(cases)('$type is a member with a prescription that names no issue id', ({ type, marker }) => { + const guidance = RETIRED_PAGE_COMPONENT_TYPES.get(type); + expect(guidance).toBeTypeOf('string'); + expect(guidance!).toMatch(new RegExp('^`' + type + '` was removed in @objectstack/spec 17 ')); + expect(guidance!).toContain('ADR-0049'); + expect(guidance!).toContain(marker); + expect(guidance!).not.toMatch(ISSUE_ID); + }); + + /** + * The anti-drift pin. The node prescription is not new prose: it is the + * element-grain TAIL of this element's own `retiredKey` tombstones, with the + * per-key head dropped. Holding the two equal BY BYTES is what keeps the + * node door and the props door telling one story — the `user:profile` shape + * ("one prescription, three doors") reached at a type whose row could not be + * `z.never`, because it has six tombstoned keys with more to say. + */ + it.each(cases)('$type: the node prescription is the tombstones\' own tail, byte for byte', ({ type, props, key }) => { + const node = RETIRED_PAGE_COMPONENT_TYPES.get(type)!; + const tail = node.slice(node.indexOf('\u2014 ') + 2); + expect(tail.length).toBeGreaterThan(200); + const r = props.safeParse({ [key]: 'x' }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues[0]!.message).toContain(tail); + // ...and the head is the only difference: the key message names the key. + expect(r.error.issues[0]!.message).toContain('property `' + key + '`'); + expect(node).not.toContain('property `' + key + '`'); + }); + + it.each(cases)('$type: PageComponentSchema refuses the node at `type`, bare or populated', ({ type }) => { + for (const properties of [undefined, {}, { object: 'order' }]) { + const r = PageComponentSchema.safeParse( + properties === undefined ? { type } : { type, properties }, + ); + expect(r.success, `properties=${JSON.stringify(properties)}`).toBe(false); + if (r.success) continue; + const located = r.error.issues.filter((i) => i.code === 'custom'); + expect(located).toHaveLength(1); + expect(located[0]!.path).toEqual(['type']); + expect(located[0]!.message).toBe(RETIRED_PAGE_COMPONENT_TYPES.get(type)); + expect((located[0]! as { params?: Record }).params) + .toEqual({ retiredComponentType: type }); + } + }); + + it.each(cases)('$type: PageSchema locates it at the element path — the door `os validate` parses', ({ type }) => { + const r = PageSchema.safeParse({ + name: 'board', + label: 'Board', + regions: [{ + name: 'main', + components: [ + { type: 'page:header', properties: { title: 'Board' } }, + { type }, + ], + }], + }); + expect(r.success).toBe(false); + if (r.success) return; + const located = r.error.issues.filter((i) => i.code === 'custom'); + expect(located).toHaveLength(1); + expect(located[0]!.path).toEqual(['regions', 0, 'components', 1, 'type']); + expect(located[0]!.message).toBe(RETIRED_PAGE_COMPONENT_TYPES.get(type)); + }); + + it.each(cases)('$type: the enum error map carries the same prescription', ({ type }) => { + expect(PageComponentType.options).not.toContain(type); + const r = PageComponentType.safeParse(type); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues[0]!.code).toBe('invalid_value'); + expect(r.error.issues[0]!.message).toBe(RETIRED_PAGE_COMPONENT_TYPES.get(type)); + }); + + /** + * The kept row is the reason these two are NOT `retiredComponentProps`: six + * tombstoned keys each, and a per-key prescription says more than one + * whole-bag refusal could. The empty bag still parses AT THE ROW — that door + * is simply no longer reachable through `PageComponentSchema`, which is + * pinned above. Both halves are load-bearing, so both are pinned. + */ + it.each(cases)('$type: the row keeps dispatching per key, and still accepts the empty bag', ({ type, props, key }) => { + expect(Object.keys(ComponentPropsMap)).toContain(type); + expect(props.safeParse({}).success).toBe(true); + expect(props.safeParse({ [key]: 'x' }).success).toBe(false); + }); + + it('a LIVE element in the same namespace is untouched — the lit control', () => { + for (const type of ['element:text', 'element:number', 'element:image', 'element:divider', + 'element:button', 'element:record_picker', 'element:text_input']) { + expect(RETIRED_PAGE_COMPONENT_TYPES.has(type), type).toBe(false); + expect(PageComponentSchema.safeParse({ type }).success, type).toBe(true); + } + // ...as is the open arm outside the reserved namespaces. + expect(PageComponentSchema.safeParse({ type: 'object-grid' }).success).toBe(true); + expect(PageComponentSchema.safeParse({ type: 'mcp:connect-agent' }).success).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // Interactive Elements — element:record_picker // --------------------------------------------------------------------------- From 4f49cf5d9deef78c059c2baebcf09c26a9648fdf Mon Sep 17 00:00:00 2001 From: os-bill Date: Fri, 11 Sep 2026 00:57:19 +0000 Subject: [PATCH 3/4] chore(spec): changeset for the element node refusal Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .../15110-retired-element-node-refusal.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 .changeset/15110-retired-element-node-refusal.md diff --git a/.changeset/15110-retired-element-node-refusal.md b/.changeset/15110-retired-element-node-refusal.md new file mode 100644 index 00000000000..b2c0dedb5bb --- /dev/null +++ b/.changeset/15110-retired-element-node-refusal.md @@ -0,0 +1,76 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `element:filter` and `element:form` are refused BY NAME at the node, and the typo suggester stops renaming authors into retired types (#15110) + +Two halves of one vocabulary defect, and only one of them is a narrowing. + +**BREAKING** — a bare `element:filter` / `element:form` component node no longer +parses. Both elements were retired whole at element grain (ADR-0049 +enforce-or-remove): no renderer for either ever shipped in objectui, framework +or cloud. Every authorable key became a `retiredKey` tombstone at the time, but +the node itself kept parsing, and each schema's own docblock recorded that as a +limitation rather than an intention: + +> A bare node with empty `properties` parses clean (the open `type` union +> accepts any string, so a node-level refusal is not expressible here) + +It is expressible one level up. Both names join +`RETIRED_PAGE_COMPONENT_TYPES`, so `PageComponentSchema.type` refuses them with +a located prescription — the same door already built for `user:profile`. + +``` +FROM PageComponentSchema.safeParse({ type: 'element:filter' }) + -> { success: true } // nothing renders it; the console + // drew the unknown-type panel + +TO PageComponentSchema.safeParse({ type: 'element:filter' }) + -> { success: false, + issues: [{ code: 'custom', path: ['type'], + params: { retiredComponentType: 'element:filter' }, + message: '`element:filter` was removed in @objectstack/spec 17 …' }] } +``` + +**The prescription is not new prose.** Each node message is the element-grain +TAIL of that element's own `retiredKey` tombstones with the `property ` +clause dropped, so the node door and the props door carry one text — pinned +byte-for-byte in `component.test.ts`. An author who writes `element:filter` is +told to delete the component and use a view's `userFilters` quick-filter bar or +the list toolbar's filter builder; an author who writes `element:form` is sent +to the object-bound `object-form` block. + +**What does NOT change.** The rows stay in `ComponentPropsMap` — deleting one +would demote a loud retirement to a silent skip on every reader that dispatches +on it — so both rows keep refusing each retired key with its own per-key +prescription, and `isKnownComponentType` still answers `true` for both. The open +string arm is untouched: `object-grid`, `mcp:connect-agent`, `custom.widget` and +every live `element:*` member parse exactly as before. The two D2 conversions +still strip the keys and still leave the node; what changes is that the node +they leave is now refused by name instead of sitting inert, and their prose says +so. + +**The other half is a plain bug fix, no accept set involved.** +`KNOWN_COMPONENT_TYPE_CANDIDATES` — the typo-suggestion pool behind the +`component-type-unknown` authoring rule — was derived from every known type, +retired ones included. Measured through the rule: + +``` +FROM type: 'element:fitler' -> hint: "Rename `element:fitler` → `element:filter`." +TO type: 'element:fitler' -> hint: "Use a declared component type from the standard + vocabulary, or … give it its own namespace …" +``` + +The tool was renaming an author INTO a retired element — a rename the parser +refuses. The pool is now the known set minus whatever the vocabulary retired, +derived from the retirement map rather than restated beside it, so a type +retired tomorrow leaves the pool the day it lands. Live spellings are +unaffected: `global:serch` still proposes `global:search`, `record:detials` +still proposes `record:details`, `element:butotn` still proposes +`element:button`. + +Also corrected: the vocabulary docblock described the `ComponentPropsMap` row +set as a superset of the enum by "exactly" the string-arm registrations plus the +two tombstoned elements — one member short since `user:profile` joined it. + + From c712af03f8803d717a3d30a4f6363d9c7f0cd565 Mon Sep 17 00:00:00 2001 From: os-bill Date: Fri, 11 Sep 2026 00:57:46 +0000 Subject: [PATCH 4/4] chore(spec): name the pre-existing ADR-0087 conversion ids in the disposition Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH --- .changeset/15110-retired-element-node-refusal.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/15110-retired-element-node-refusal.md b/.changeset/15110-retired-element-node-refusal.md index b2c0dedb5bb..0148f5ef593 100644 --- a/.changeset/15110-retired-element-node-refusal.md +++ b/.changeset/15110-retired-element-node-refusal.md @@ -73,4 +73,4 @@ Also corrected: the vocabulary docblock described the `ComponentPropsMap` row set as a superset of the enum by "exactly" the string-arm registrations plus the two tombstoned elements — one member short since `user:profile` joined it. - +