Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions .changeset/15110-retired-element-node-refusal.md
Original file line number Diff line number Diff line change
@@ -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 <key>`
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.

<!-- adr-0087: not-required (already-registered element-filter-removed, element-form-removed) both elements' retirement is already in the protocol-18 ledger — these two conversions plus all twelve retired-key tombstones; this change registers no new retirement, it closes the node-level half of those same entries and reuses their prescriptions verbatim -->
4 changes: 2 additions & 2 deletions content/docs/references/ui/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand Down Expand Up @@ -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<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
| **properties** | `Record<string, any>` | optional (default: `{}`) | Component props passed to the widget. See component.zod.ts for schemas. |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.)

Expand Down
56 changes: 56 additions & 0 deletions packages/lint/src/validate-component-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
25 changes: 15 additions & 10 deletions packages/spec/src/conversions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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;
Expand Down Expand Up @@ -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',
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading