diff --git a/.changeset/3965-mint-box-neutral-container.md b/.changeset/3965-mint-box-neutral-container.md index 816615d608..0b75745d96 100644 --- a/.changeset/3965-mint-box-neutral-container.md +++ b/.changeset/3965-mint-box-neutral-container.md @@ -19,11 +19,12 @@ gap with a three-clause contract, pinned in 2. authored `className` passes through **verbatim**; 3. **zero** injected classes. -Deliberately unlike `div`, `box` reads `children` only — never `schema.body`. -The `div` renderer's `children || body` fallback is what made a mechanical -`div`→X swap silently drop content on `body`-authoring nodes while the element -count stayed unchanged; content moves into `children` at migration time (the -objectui#6771 B-ruling direction). +Deliberately unlike `div` AT THE TIME, `box` reads `children` only — never +`schema.body`. The `div` renderer's `children || body` fallback is what made a +mechanical `div`→X swap silently drop content on `body`-authoring nodes while the +element count stayed unchanged. ⚠️ objectui#6771 has since executed that B-ruling and +retired `body` across the protocol, so `div` reads `children` only too and `box` is no +longer the exception this paragraph mints it as. Landed on both contract faces per the zod-mirror-parity pairing (objectui#6424 family form): `BoxSchema` interface in `@object-ui/types`, its zod mirror in diff --git a/.changeset/5632-sidebar-trigger-dom-passthrough.md b/.changeset/5632-sidebar-trigger-dom-passthrough.md index 62eaee2504..8873b496b3 100644 --- a/.changeset/5632-sidebar-trigger-dom-passthrough.md +++ b/.changeset/5632-sidebar-trigger-dom-passthrough.md @@ -9,8 +9,9 @@ The renderer forwarded its whole prop bag to `SidebarTrigger`, which spreads its own rest onto the ` ); diff --git a/packages/components/src/renderers/layout/aspect-ratio.tsx b/packages/components/src/renderers/layout/aspect-ratio.tsx index 9eab52c38c..b2656b2ef3 100644 --- a/packages/components/src/renderers/layout/aspect-ratio.tsx +++ b/packages/components/src/renderers/layout/aspect-ratio.tsx @@ -30,7 +30,7 @@ ComponentRegistry.register('aspect-ratio', {schema.image ? ( {schema.alt ) : ( - renderChildren(schema.children || schema.body) + renderChildren(schema.children) )} ); @@ -39,7 +39,7 @@ ComponentRegistry.register('aspect-ratio', namespace: 'ui', label: 'Aspect Ratio', // Same declaration, same reason as `semantic.tsx` (objectui#6764): the - // renderer falls back to `renderChildren(schema.children || schema.body)` + // renderer falls back to `renderChildren(schema.children)` // whenever no `image` is set, so a ratio box wrapping authored content is // the documented shape -- and it drew `not-a-container` for it. Not in // `PUBLIC_BLOCKS`, so the react-page scope builder never saw this tag and diff --git a/packages/components/src/renderers/layout/box.tsx b/packages/components/src/renderers/layout/box.tsx index 219b9b6359..f65dd5a937 100644 --- a/packages/components/src/renderers/layout/box.tsx +++ b/packages/components/src/renderers/layout/box.tsx @@ -30,12 +30,18 @@ import { forwardRef } from 'react'; * 2. the authored `className` passes through VERBATIM; * 3. zero injected classes — no `cn()` merge, no default class, nothing. * - * Deliberately unlike `div`, this renderer reads `children` ONLY — never - * `schema.body`. The `div` renderer's `children || body` fallback is exactly - * what made a mechanical `div`→X swap unsafe (four catalog fixtures authored - * `body` and would have lost their content silently, with an unchanged element - * count); `box` keeps the one spelling so that hazard cannot re-grow. Content - * on a migrated node moves into `children` — the #6771 B-ruling direction. + * This renderer reads `children` ONLY, and since objectui#6771 retired the + * `body` dialect every renderer outside the `page:*` namespace does too. The + * exceptions are FOUR, all in `layout/containers.tsx` — `page:card` and the + * three thin `page:section` / `page:footer` / `page:sidebar` containers — and + * all four are back-compat reads for STORED documents, which `@objectstack/spec` + * says in as many words on `PageContainerProps`. So `box` is no longer the + * exception it was written as, and the surviving reads are not an authoring + * face at all (escalation: objectui#9916). The hazard it was minted against was `div`'s + * `children || body` fallback, which made a mechanical `div`→X swap unsafe + * (four catalog fixtures authored `body` and would have lost their content + * silently, with an unchanged element count). That fallback is retired; the + * one spelling is now the protocol's, not just this renderer's. */ // Index signature on the parameter annotation, not on the `forwardRef` type // argument — mechanism note on `action:bar` (objectui#4422), pinned by diff --git a/packages/components/src/renderers/layout/card.tsx b/packages/components/src/renderers/layout/card.tsx index 598c600b03..6f9696e041 100644 --- a/packages/components/src/renderers/layout/card.tsx +++ b/packages/components/src/renderers/layout/card.tsx @@ -69,7 +69,7 @@ const CardRenderer = forwardRef ( + {renderNodeSlot(schema.children, (body) => ( {renderChildren(body)} ))} {renderNodeSlot(schema.footer, (footer) => ( diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index c17512562c..04e33fc292 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -438,6 +438,17 @@ const collectRelatedLists = (nodes: any, acc: any[] = []): any[] => { acc.push(n); continue; // Don't descend into a related_list's own subtree. } + // ⚠️ `body` STAYS, and an earlier pass of this change wrongly removed it. + // This walker does not RENDER anything — it descends a tab's subtree to + // count `record:related_list` nodes for a badge. `page:card` still reads + // `body` for stored documents, so a stored subtree under that key is still + // drawn; dropping it here made the renderer draw content this count could + // not see, which is a wrong badge rather than a retirement. The rule for + // every NON-RENDERING reader in this tree: while any renderer still reaches + // stored `body` content, the readers that must see the same content keep + // their arm (objectui#6771, escalation objectui#9916). + // `items` stays for its own reason — the `list` registration's item channel, + // objectui#9590's card, not this spelling. const candidates = [ n.children, n.properties?.children, @@ -479,6 +490,7 @@ const containsAttachmentsNode = (nodes: any): boolean => { for (const n of list) { if (!n || typeof n !== 'object') continue; if (n.type === 'record:attachments') return true; + // `body` stays — same ground as `collectRelatedLists` above. const candidates = [n.children, n.properties?.children, n.properties?.items, n.body, n.items]; for (const c of candidates) { if (c && containsAttachmentsNode(c)) return true; @@ -932,6 +944,19 @@ const PageCardRenderer: React.FC = ({ schema, className, ...props }) => { // carrying both, which the conversion is what resolves; deleting the read // before the conversion is live would blank an existing card's content // silently — the `page-header-subtitle-alias` sequencing precedent, verbatim. + // ⚠️ ONE OF FOUR SURVIVING FALLBACKS, all in this file: this one and the + // three thin `page:section` / `page:footer` / `page:sidebar` containers + // below. ⛔ Do not delete any of them on the authority of this comment — + // objectui#6771 retired the spelling on every RENDERER outside the `page:*` + // namespace, and what holds these four is stored documents, not the + // authoring face. + // + // What separates THIS one from the three below is a CONVERSION, not a + // ground: `@objectstack/spec`'s conversions registry carries + // `page-card-body-to-children` (`toMajor: 17`, surface + // `page.component.page:card.body`) and carries nothing for the other three. + // ⇒ a stored row under this key has a migration path; a stored row under + // theirs has none. Escalated as objectui#9916. const body = schema?.body ?? schema?.children; const footer = schema?.footer; @@ -1105,6 +1130,40 @@ const PageSectionRenderer: React.FC = ({ schema, className, ...props }) => className={cn('space-y-4', className)} {...designer} > + {/* + ⚠️ `body` KEPT on all three thin containers, and the ground is the + SPEC'S OWN — ⛔ not the "they never published it" argument an earlier + pass of this comment made, which was false in both directions. + + `@objectstack/spec`'s `PageContainerProps` names these three and says + it outright: `children` is the canonical spelling and `body` is + deliberately not declared, but 「The renderers keep reading `body` as a + back-compat fallback for stored documents; that fallback is objectui's + to retire on its own schedule, and it is not a second authorable + spelling.」 ⇒ they share `page:card`'s ground exactly. What they lack is + a CONVERSION: the spec's registry carries `page-card-body-to-children` + and NOTHING for these three, so dropping this arm leaves a stored row + with no migration path at all. + + The authoring corpus IS answerable, and was answered by the committed + instrument rather than an ad-hoc scan: + pnpm census:body-dialect --keys page:section,page:footer,page:sidebar,page:card,card + CONTROL (fires): `card` + `body` 40 on the pre-retirement tree -> 2 on + this one. SUBJECT: `page:section` resolves 6 nodes on both trees, 0 + with `body`, 4 with `children` — a LIT zero. ⚠️ `page:footer` and + `page:sidebar` resolve 0 nodes at all, so their zero is a key-population + zero and says nothing. + + ⇒ nothing in this repository authors it; whether a stored ROW does is a + database question this tree cannot ask. Escalated as objectui#9916, and + the two facts that belong to it are POSTED there (comment 5733844778), + not merely asserted to be: the designer canvas already honours `children` + ONLY for `page:section` (`PageBlockCanvas.tsx`), so runtime and canvas + already disagree about a stored `body` here; and `page:card`'s "until the + conversion lands" precondition is STALE — `pageCardBodyToChildren` carries + `toMajor: 17` and `retiredFromLoadPath: true` and this repo installs spec + 17.4.0, so the live ground there is unreplayed stored rows. + */} {renderChildren(schema?.children || schema?.body)} ); @@ -2211,6 +2270,7 @@ const PageFooterRenderer: React.FC = ({ schema, className, ...props }) => { className={cn('flex items-center justify-between text-sm text-muted-foreground', className)} {...designer} > + {/* `body` kept — escalation recorded at `page:section` above (objectui#6771). */} {renderChildren(schema?.children || schema?.body)} @@ -2237,6 +2297,7 @@ const PageSidebarRenderer: React.FC = ({ schema, className, ...props }) => className={cn('flex flex-col gap-4 w-full md:w-80 shrink-0', className)} {...designer} > + {/* `body` kept — escalation recorded at `page:section` above (objectui#6771). */} {renderChildren(schema?.children || schema?.body)} ); diff --git a/packages/components/src/renderers/layout/page.tsx b/packages/components/src/renderers/layout/page.tsx index 48fb71622b..d19c242d95 100644 --- a/packages/components/src/renderers/layout/page.tsx +++ b/packages/components/src/renderers/layout/page.tsx @@ -8,8 +8,8 @@ * The Page renderer interprets PageSchema into structured layouts. * It supports four page types (record, home, app, utility) and * renders named regions (header, sidebar, main, footer, aside) with - * configurable widths. When no regions are defined, it falls back to - * body/children for backward compatibility. + * configurable widths. When no regions are defined, it falls back to the + * node's `children` list (objectui#6771 retired the `body` spelling). */ import React, { useMemo } from 'react'; @@ -123,9 +123,9 @@ function isTitledPageHeader(node: any): boolean { /** * Depth-bounded walk over the component shapes a page can nest. * - * Takes ONE node or a LIST of them, and judges both by the same rule. `body` - * and `children` are declared `SchemaNode | SchemaNode[]` on `PageNodeSchema` - * and on `BaseSchema`, and `FlatContent` below has always rendered the bare-node + * Takes ONE node or a LIST of them, and judges both by the same rule. + * `children` is declared `SchemaNode | SchemaNode[]` on `PageNodeSchema` and on + * `BaseSchema`, and `FlatContent` below has always rendered the bare-node * form, so a single node is a first-class authored shape — at the top level and * at every nested level the recursion re-enters (objectui#8923). This used to * open with `if (!Array.isArray(nodes)) return false`, so every bare-node @@ -148,8 +148,7 @@ function containsTitledPageHeader(nodes: unknown, depth = 0): boolean { typeof n === 'object' && (isTitledPageHeader(n) || containsTitledPageHeader(n.components, depth + 1) || - containsTitledPageHeader(n.children, depth + 1) || - containsTitledPageHeader(n.body, depth + 1)), + containsTitledPageHeader(n.children, depth + 1)), ); } @@ -176,7 +175,6 @@ function pageHeaderOwnsTitle(schema: PageNodeSchema): boolean { const regionNodes = (schema.regions ?? []).flatMap((r: any) => r?.components ?? []); return ( containsTitledPageHeader(regionNodes) || - containsTitledPageHeader(schema.body) || containsTitledPageHeader(schema.children) ); } @@ -278,11 +276,11 @@ const RegionLayout: React.FC<{ }; // --------------------------------------------------------------------------- -// FlatContent — legacy body/children fallback +// FlatContent — the `children` fallback when a page declares no regions // --------------------------------------------------------------------------- const FlatContent: React.FC<{ schema: PageNodeSchema }> = ({ schema }) => { - const content = schema.body || schema.children; + const content = schema.children; const nodes: SchemaNode[] = Array.isArray(content) ? content : content @@ -701,7 +699,11 @@ const pageMeta: any = { itemType: 'object', }, { - name: 'body', + // The flat content list `FlatContent` renders when a page declares no + // regions. Published as `body` until objectui#6771 retired that + // spelling; `pageMeta` backs five registrations, so this one line is + // the authoring face of `page` / `app` / `utility` / `home` / `record`. + name: 'children', type: 'array', itemType: 'component', }, diff --git a/packages/components/src/renderers/layout/semantic.tsx b/packages/components/src/renderers/layout/semantic.tsx index 61d3abfe0e..266b78053a 100644 --- a/packages/components/src/renderers/layout/semantic.tsx +++ b/packages/components/src/renderers/layout/semantic.tsx @@ -37,7 +37,7 @@ tags.forEach(tag => { {...restProps} {...{ 'data-obj-id': dataObjId, 'data-obj-type': dataObjType, style }} > - {renderChildren(schema.children || schema.body)} + {renderChildren(schema.children)} ); }); @@ -48,7 +48,7 @@ tags.forEach(tag => { label: tag.charAt(0).toUpperCase() + tag.slice(1), category: 'layout', // Declared because the factory above RENDERS a child list - // (`renderChildren(schema.children || schema.body)`), which is the whole + // (`renderChildren(schema.children)`), which is the whole // question this flag answers -- `children` is a BASE property of every // node in the JSON protocol (`BASE_PROPS` in `sdui-parser/src/validate.ts`), // not a per-component authoring key, so declaring it widens no spec diff --git a/packages/components/src/renderers/navigation/sidebar.tsx b/packages/components/src/renderers/navigation/sidebar.tsx index bb0b018506..da22a7573f 100644 --- a/packages/components/src/renderers/navigation/sidebar.tsx +++ b/packages/components/src/renderers/navigation/sidebar.tsx @@ -40,7 +40,7 @@ import { ComponentRegistry.register('sidebar-provider', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -56,7 +56,7 @@ ComponentRegistry.register('sidebar-provider', ComponentRegistry.register('sidebar', ({ schema, ...props }: { schema: SidebarSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -76,7 +76,7 @@ ComponentRegistry.register('sidebar', ComponentRegistry.register('sidebar-header', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -86,7 +86,7 @@ ComponentRegistry.register('sidebar-header', ComponentRegistry.register('sidebar-content', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -109,7 +109,7 @@ ComponentRegistry.register('sidebar-group', {resolveInlineI18nLabel(schema.label, locale)} )} - {renderChildren(schema.body)} + {renderChildren(schema.children)} ); @@ -128,7 +128,7 @@ ComponentRegistry.register('sidebar-group', ComponentRegistry.register('sidebar-menu', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -138,7 +138,7 @@ ComponentRegistry.register('sidebar-menu', ComponentRegistry.register('sidebar-menu-item', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -161,7 +161,7 @@ ComponentRegistry.register('sidebar-menu-button', {...toFormControlDomProps(buttonProps)} style={style} > - {renderChildren(schema.body)} + {renderChildren(schema.children)} ); }, @@ -181,7 +181,7 @@ ComponentRegistry.register('sidebar-menu-button', ComponentRegistry.register('sidebar-footer', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', @@ -191,7 +191,7 @@ ComponentRegistry.register('sidebar-footer', ComponentRegistry.register('sidebar-inset', ({ schema, ...props }: { schema: BaseSchema; [key: string]: any }) => ( - {renderChildren(schema.body)} + {renderChildren(schema.children)} ), { namespace: 'ui', diff --git a/packages/components/src/renderers/overlay/tooltip.tsx b/packages/components/src/renderers/overlay/tooltip.tsx index 769c550284..79283a2b9b 100644 --- a/packages/components/src/renderers/overlay/tooltip.tsx +++ b/packages/components/src/renderers/overlay/tooltip.tsx @@ -26,7 +26,7 @@ ComponentRegistry.register('tooltip', {renderTriggerSlot(TooltipTrigger, schema.trigger)} - {(schema.content || renderChildren(schema.body)) as any} + {(schema.content || renderChildren(schema.children)) as any} @@ -46,9 +46,13 @@ ComponentRegistry.register('tooltip', name: 'content', type: 'string', }, - { - name: 'body', - type: 'slot' }, + // The rich-content slot. This registration published `body` and was + // the one place on the whole authoring surface that ADVERTISED the + // dialect in its `inputs`; objectui#6771 retired it, so the slot is + // declared under the one spelling the protocol keeps. Same two-sided + // move as the `page:card` retirement pinned by + // `components/src/__tests__/page-container-authorable-keys.test.tsx`. + { name: 'children', type: 'slot' }, { name: 'className', type: 'string' } ], defaultProps: { diff --git a/packages/core/README.md b/packages/core/README.md index c16b161b07..245641e967 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -37,7 +37,7 @@ import type { const mySchema: PageNodeSchema = { type: 'page', title: 'My Page', - body: [] + children: [] } ``` diff --git a/packages/core/src/validation/schema-validator.ts b/packages/core/src/validation/schema-validator.ts index 380217743f..cf11d741b8 100644 --- a/packages/core/src/validation/schema-validator.ts +++ b/packages/core/src/validation/schema-validator.ts @@ -464,7 +464,11 @@ function validateChildren( ): SchemaNodeValidationError[] { const errors: SchemaNodeValidationError[] = []; - const children = schema.children || schema.body; + // One spelling. This walker resolved `children || body` for ANY node type, + // so it outlived every per-registration read of the dialect — objectui#6771 + // retired it, and a recursive validator that still descended `body` would + // keep validating a child list no renderer puts on the page. + const children = schema.children; if (children) { if (Array.isArray(children)) { children.forEach((child: unknown, index: number) => { diff --git a/packages/runner/README.md b/packages/runner/README.md index 2d24c15390..6767268c65 100644 --- a/packages/runner/README.md +++ b/packages/runner/README.md @@ -128,7 +128,7 @@ that do configure it are: { "type": "page", "title": "Dashboard", - "body": { + "children": { "type": "grid", "columns": 3, "gap": 4, @@ -136,7 +136,7 @@ that do configure it are: { "type": "card", "title": "Total Users", - "body": { + "children": { "type": "statistic", "value": 1234, "trend": "up" @@ -145,7 +145,7 @@ that do configure it are: { "type": "card", "title": "Revenue", - "body": { + "children": { "type": "statistic", "value": "$56,789", "trend": "up" @@ -154,7 +154,7 @@ that do configure it are: { "type": "card", "title": "Orders", - "body": { + "children": { "type": "statistic", "value": 432, "trend": "down" diff --git a/packages/sdui-parser/src/__tests__/body-dialect-6771.test.ts b/packages/sdui-parser/src/__tests__/body-dialect-6771.test.ts new file mode 100644 index 0000000000..570537ddd1 --- /dev/null +++ b/packages/sdui-parser/src/__tests__/body-dialect-6771.test.ts @@ -0,0 +1,132 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The tier's half of objectui#6771 — the retired `body` child-list dialect. + * + * ## Why this file exists at all + * + * The retirement's acceptance evidence was measured on the BUILT CLI, and that + * reading is about the ZOD tier: `objectui validate` parses through + * `@object-ui/types`' mirrors, where `body` is an `aliasKeyRefusal`. ⛔ It says + * NOTHING about `validateTree`, which is a different tier with a different + * vocabulary — it parses a manifest, never a zod schema, and it is the tier the + * card was FILED about: the original defect was that `validateTree` answered + * `unknown-prop` on `body`, the one spelling a dozen registrations resolved. + * + * ⇒ a change that moves what this tier says about `body` needs its own pin, or + * the card's own subject is the one surface with no coverage. + * + * ## The two verdicts, and why they are two + * + * The ruling's step 4 has two clauses: the tier teaches `children` only, and a + * `body` child list under a non-container draws the same `not-a-container` the + * `children` spelling draws. `./body-dialect.ts` answers both by REPLACING the + * prop walk's diagnostic rather than adding to it — two diagnostics for one + * mistake is the shape `checkMemberTypes` already refuses (objectui#8067), and + * the shape `checkKanbanQuickAdd` next door is written against. + * + * ## ⚠️ What each row must not be allowed to pass on + * + * Every assertion below names a CODE and asserts the message names `children`. + * The code alone is not enough: the pre-retirement tier already answered + * `unknown-prop` for `body`, so a row asserting only that code is green in BOTH + * worlds and measures nothing. What moved is that the answer now carries the + * remedy, which is the whole reason the replacement exists rather than a bare + * refusal. And the `children` control beside each row is what keeps "the tier + * refuses `body`" a reading rather than "this tier refuses everything". + */ +import { describe, expect, it } from 'vitest'; +import { RETIRED_CHILD_LIST_KEY, manifestFromConfigs, validateTree } from '../index.js'; +import type { Diagnostic, Manifest, SchemaElement } from '../types.js'; + +/** + * Two blocks and nothing else: one that accepts children and one that does not. + * The containment verdict is keyed on `isContainer`, so those two are the whole + * population this rule can distinguish. + */ +const manifest: Manifest = manifestFromConfigs([ + { type: 'box', namespace: 'ui', isContainer: true, inputs: [{ name: 'className', type: 'string' }] }, + { type: 'badge', namespace: 'ui', isContainer: false, inputs: [{ name: 'label', type: 'string' }] }, + // Declared so the CHILD below resolves: `validateTree` descends `children`, + // and an undeclared child draws `unknown-component`, which would make the + // silence controls below assert the wrong absence. + { type: 'text', namespace: 'ui', isContainer: false, inputs: [{ name: 'content', type: 'string' }] }, + // Declares `body` as its OWN input — the carve-out this rule must respect. + { type: 'detail', namespace: 'plugin-detail', isContainer: false, inputs: [{ name: 'body', type: 'string' }] }, +] as unknown as Parameters[0]); + +const CHILD = { type: 'text', content: 'measured' }; +const diagnose = (node: unknown): Diagnostic[] => + validateTree(node as SchemaElement, manifest).diagnostics; + +describe('objectui#6771 — the tier answers the retired `body` with its replacement', () => { + it('the exported key IS the retired spelling, so the rule and its pin cannot drift', () => { + expect(RETIRED_CHILD_LIST_KEY).toBe('body'); + }); + + it('a `body` child list under a NON-container draws `not-a-container` — the ruling clause, verbatim', () => { + const found = diagnose({ type: 'badge', body: [CHILD] }); + expect(found.map((d) => d.code)).toContain('not-a-container'); + // The SAME code the live spelling draws — that is what "the same + // `not-a-container` the `children` spelling draws" means, and a consumer + // keying on the code cannot tell the two spellings apart. + expect(diagnose({ type: 'badge', children: [CHILD] }).map((d) => d.code)).toContain('not-a-container'); + // …and only the retired one also names the remedy. + expect(found.find((d) => d.code === 'not-a-container')!.message).toContain('"children"'); + }); + + it('ONE diagnostic, not two — the replacement replaces', () => { + // ⛔ The failure this guards is the obvious implementation: leave the prop + // walk's `unknown-prop` in place and ADD a containment branch that reads + // `body`. That author gets two reports for one mistake. + const found = diagnose({ type: 'badge', body: [CHILD] }); + expect(found.filter((d) => d.message.includes('"body"'))).toHaveLength(1); + }); + + it('a `body` child list under a CONTAINER draws `unknown-prop` naming `children`', () => { + const found = diagnose({ type: 'box', body: [CHILD] }); + const own = found.filter((d) => d.message.includes('"body"')); + expect(own).toHaveLength(1); + expect(own[0]!.code).toBe('unknown-prop'); + expect(own[0]!.message).toContain('"children"'); + // CONTROL — the live spelling under the same block is silent, so the row + // above is about the key and not about the block. + expect(diagnose({ type: 'box', children: [CHILD] })).toHaveLength(0); + }); + + it('a SCALAR `body` is a dead key and nothing more — no containment sentence', () => { + // The containment wording names a shape; naming it for `body: "hi"` would + // describe a child list the author did not write. + const found = diagnose({ type: 'badge', body: 'hi' }); + const own = found.filter((d) => d.message.includes('"body"')); + expect(own).toHaveLength(1); + expect(own[0]!.code).toBe('unknown-prop'); + expect(own[0]!.message).toContain('"children"'); + }); + + it('⛔ a component that DECLARES its own `body` input is untouched', () => { + // ⚠️ The fixture below is SYNTHETIC — a manifest is an argument to + // `validateTree`, so this file can ask what the rule says about any shape. + // The real component this carve-out exists for is `record:alert`, the one + // registration in the tree that declares an input named `body` (its value is + // an inline translation map, not a child list); the extension's own gate + // names it, and `sdui-parser` needs no list because it asks inside the + // `!input` branch. That scoping is the opposite of `checkKanbanQuickAdd`'s, + // deliberately: there the claim is about the render path, so declaring the + // key must not disarm it; here the claim is about a key nobody declares. + expect(diagnose({ type: 'detail', body: 'Updated the deal stage.' })).toHaveLength(0); + }); + + it('the tier still teaches `children` ONLY — `body` never became a base prop', () => { + // The option the ruling REFUSED was adding `body` to `BASE_PROPS`, which + // would have made it a blessed second spelling forever. If it were there, + // every row above would be silent. + expect(diagnose({ type: 'box', body: [CHILD] }).length).toBeGreaterThan(0); + }); +}); diff --git a/packages/sdui-parser/src/body-dialect.ts b/packages/sdui-parser/src/body-dialect.ts new file mode 100644 index 0000000000..83745ca052 --- /dev/null +++ b/packages/sdui-parser/src/body-dialect.ts @@ -0,0 +1,99 @@ +/** + * ObjectUI — the retired `body` child-list dialect (objectui#6771) + * + * `body` was a declared twin of `children` on `BaseSchema`, and for a long + * while it was the ONLY child-list key a dozen registrations read. This tier + * never knew it: `BASE_PROPS` lists `children` and not `body`, so the one + * spelling that resolved drew `unknown-prop` — the same warning a typo draws, + * on the tier built to accept AI-authored pages, where the diagnostic IS the + * contract. The maintainer ruled the retirement (2026-09-01, option B): one + * concept, one spelling, and the spelling is `children`. + * + * This module is the tier's half of that ruling. It REPLACES the diagnostic the + * prop walk would otherwise emit for `body`, rather than adding to it — two + * diagnostics for one mistake is the shape `checkMemberTypes` already refuses + * (objectui#8067), and it is the shape `checkKanbanQuickAdd` is written against + * next door. + * + * WHY A REPLACEMENT AND NOT A BARE `unknown-prop`. After the retirement + * "`` has no prop body" is TRUE — but it is the answer that sent authors + * the wrong way in the first place. Every `body` node in this repository's own + * docs, examples and READMEs was migrated in the same change, and an author + * carrying the old spelling in from anywhere else needs the replacement NAMED, + * not merely the key refused. The two codes below are the ruling's own words: + * the tier teaches `children` only, and a `body` child list under a + * non-container draws the same `not-a-container` the `children` spelling draws. + * + * SCOPE — deliberately the opposite of `checkKanbanQuickAdd`'s. That one is + * asked AHEAD of the declaration lookup, because its claim is about the render + * path and declaring the key must not disarm it. This one is asked INSIDE the + * `!input` branch, because its claim is about a key nobody declares: a + * component that publishes its own `body` input keeps its declared type check + * untouched. Re-derived against the registry rather than recalled: exactly one + * registration in the tree declares an input named `body`, and it is + * `record:alert` (`plugin-detail`, `register('alert', …, { namespace: 'record' })`), + * whose `body` takes an inline translation map and is not this spelling. + * Control on the same sweep: `name: 'children'` fires seven times. + */ + +import type { Diagnostic, SchemaNode } from './types.js'; + +/** The child-list spelling objectui#6771 retired. `children` replaced it. */ +export const RETIRED_CHILD_LIST_KEY = 'body'; + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** + * Is this value shaped like the child list the dialect used to carry? + * + * A list of nodes, or the bare single node `FlatContent` and `renderChildren` + * have always accepted. A scalar is not — `body="hi"` is a dead key and nothing + * more, so it does not get the containment sentence, which would name a shape + * its author did not write. + */ +const isChildList = (value: unknown): value is SchemaNode | SchemaNode[] => + Array.isArray(value) || isPlainObject(value); + +/** + * The diagnostic for an authored `body`, or `null` when this module has nothing + * to say about the key. + * + * @param tag the node's component type, for the message and the `tag` field + * @param key the authored prop name + * @param value its authored value + * @param isContainer whether the manifest says this component accepts children + */ +export function checkRetiredBodyDialect( + tag: string, + key: string, + value: unknown, + isContainer: boolean | undefined, +): Diagnostic | null { + if (key !== RETIRED_CHILD_LIST_KEY) return null; + + if (isChildList(value) && !isContainer) { + // The ruling, verbatim: "a `body` child list under a non-container draws + // the same `not-a-container` the `children` spelling draws". Same code, so + // a consumer keying on the code cannot tell the two spellings apart — which + // is the single-spelling rule stated in the diagnostic stream. The sentence + // carries the extra fact the `children` path has no need of. + return { + severity: 'warning', + code: 'not-a-container', + message: + `<${tag}> does not accept children — and "body" is the retired spelling of ` + + `"children" (objectui#6771), so this child list has no door here at all`, + tag, + }; + } + + return { + severity: 'warning', + code: 'unknown-prop', + message: + `<${tag}> has no prop "body" — the child-list key is "children" ` + + `("body" was retired by objectui#6771)`, + tag, + }; +} diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index 91b19dab8d..4a53bd618a 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -25,6 +25,7 @@ export { export { generateDts, propsName, generateBlockList } from './codegen.js'; export type { CodegenOptions } from './codegen.js'; export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js'; +export { checkRetiredBodyDialect, RETIRED_CHILD_LIST_KEY } from './body-dialect.js'; import { parseJsx } from './parse.js'; import { validateTree } from './validate.js'; diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index c95691430c..c649d27315 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -19,8 +19,17 @@ import type { import { inputTypeArms } from './input-type.js'; import { checkDashboardWidgetOptions } from './dashboard-widget-options.js'; import { checkKanbanQuickAdd } from './kanban-quick-add.js'; +import { checkRetiredBodyDialect } from './body-dialect.js'; -/** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */ +/** + * Base props every node may carry (mirrors BaseSchema) — never "unknown prop". + * + * ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second + * child-list spelling until objectui#6771 retired it; teaching this set the key + * was the option that ruling refused, because it would have blessed a second + * permanent spelling of one concept. `./body-dialect.ts` answers it by name + * instead. + */ const BASE_PROPS = new Set([ 'type', 'id', @@ -88,12 +97,20 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Ma } const input = byName.get(key); if (!input) { - diagnostics.push({ - severity: 'warning', - code: 'unknown-prop', - message: `<${node.type}> has no prop "${key}"`, - tag: node.type, - }); + // The retired `body` child-list dialect gets its replacement named + // rather than the bare "has no prop" every typo gets + // (objectui#6771). Asked INSIDE this branch, so a component that + // declares its own `body` input keeps its declared type check — + // mechanism in `./body-dialect.ts`. + const retiredBody = checkRetiredBodyDialect(node.type, key, value, comp.isContainer); + diagnostics.push( + retiredBody ?? { + severity: 'warning', + code: 'unknown-prop', + message: `<${node.type}> has no prop "${key}"`, + tag: node.type, + }, + ); continue; } if (input.binding) { diff --git a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts index c7d627cba6..6e47db0299 100644 --- a/packages/types/src/__tests__/content-channel-family-d-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-family-d-9256.test.ts @@ -394,27 +394,39 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel, // objectui#8572. This control was aimed at `ChatbotSchema`, whose own `body` // the parity ledger recorded as "two different meanings of one key — a naming // collision to rule on"; that ruling has since been made and belongs to its - // own card, ⛔ which is why this line does not restate its verdict here. The - // hold-out this card recorded still stands on the two TWIN faces: they - // inherit `body` as the content channel and only their `children` is - // narrowed above — so the control keeps doing its job, which is to keep the - // chatbot rows a reading about `children` rather than about the whole mirror. + // own card, ⛔ which is why this line does not restate its verdict here. + // + // ⚠️ THE HOLD-OUT ENDED, and not by a decision on this card. It read: the + // two TWIN faces inherit `body` as the content channel, only their + // `children` is narrowed here. objectui#6771 retired `body` on `BaseSchema` + // itself, so there is no content channel left to inherit — and an inherited + // refusal would have named `children` as the remedy, which is refused on + // these two as well. Each twin therefore declares the same NEITHER-channel + // tombstone its `children` already carried, pointing at `requestBody`. + // ⛔ This is not a narrowing this card chose: the sentence was already + // asserted here, only the declaration was missing. const r = (ChatbotEnhancedMirror as unknown as Mirror) .safeParse({ type: 'chatbot-enhanced', messages: [], body: CONTENT }); - expect(r.success).toBe(true); + expect(r.success).toBe(false); + const bodyIssue = (r.success ? [] : r.error!.issues).find((i) => i.path.join('.') === 'body'); + expect(bodyIssue?.message).toContain('requestBody'); + // CONTROL — the key an author should write instead still parses. + expect((ChatbotEnhancedMirror as unknown as Mirror) + .safeParse({ type: 'chatbot-enhanced', messages: [], requestBody: { model: 'gpt-4' } }).success).toBe(true); }); - it('LIVE CONTROL — `button` still accepts BOTH channels: it is family C, not family D', () => { - // Re-derived at this branch point, and it contradicts the table this slice - // was dispatched from: `ui:button` owns the bare `button` key, and that - // renderer reads `schema.label || renderChildren(schema.body || schema.children)` - // — a LIVE fallback. Narrowing it would REMOVE A LIVE READ, which is a - // behaviour change and the maintainer's call (objectui#8284), so `button` - // is untouched and this line is what keeps the rows above a reading about - // the six that moved rather than about the whole mirror. + it('`button` is family C with ONE channel left — the `body` arm was retired, not narrowed away', () => { + // This line used to read `button` accepts BOTH channels, on the ground that + // `ui:button` renders `schema.label || renderChildren(schema.body || schema.children)` + // — a LIVE fallback whose removal was a behaviour change and the + // maintainer's call. That call was made: objectui#6771's ruling puts the + // `body` arm of every fallback reader in scope ("single-spelling applies to + // fallback readers too") and names this expression explicitly. `button` is + // still family C — it reads a content channel, unlike family D — and the + // channel it reads is `children`. const mirror = ButtonFamilyCControl as unknown as Mirror; - expect(issues(mirror, { type: 'button', body: CONTENT })).toBeNull(); expect(issues(mirror, { type: 'button', children: CONTENT })).toBeNull(); + expect(issues(mirror, { type: 'button', body: CONTENT })).not.toBeNull(); }); it('LIVE CONTROL — the ITEM-level channel is untouched: a `tabs` item still parses `content`', () => { @@ -441,13 +453,15 @@ describe('objectui#9256 — CONTROLS: the node itself, and the held-out channel, })).toBeNull(); }); - it('LIVE CONTROL — family C is NOT narrowed: a `div` still accepts both channels', () => { - // `div` reads `children || body` (a live fallback). Removing a live read is - // a behaviour change and is with the maintainer, so this line must stay - // green for this card to be a declaration repair rather than a behaviour - // change. + it('family C still reads a content channel — `div` takes `children`, and only `children`', () => { + // `div` read `children || body`, a live fallback, and this line used to pin + // BOTH as accepted so that this card stayed a declaration repair rather than + // a behaviour change. objectui#6771 made the behaviour change deliberately, + // under a maintainer ruling, and the distinction this control exists to + // guard survives it: family C reads a content channel and family D reads + // none. What changed is how many spellings family C answers to. expect(AnyComponentSchema.safeParse({ type: 'div', children: CONTENT }).success).toBe(true); - expect(AnyComponentSchema.safeParse({ type: 'div', body: CONTENT }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ type: 'div', body: CONTENT }).success).toBe(false); }); }); @@ -587,15 +601,27 @@ describe('objectui#9256 — the TypeScript face refuses both channels at the AUT expect(ok).toHaveLength(7); }); - it('CONTROL — the chatbot family still TYPE-CHECKS with `body`, the held-out channel', () => { + it('the chatbot family no longer TYPE-CHECKS with `body` — the held-out channel was retired under it', () => { // RE-POINTED by objectui#8572, for the reason spelled out at the mirror // control above, and ⛔ deliberately not restating that card's verdict here. - // What survives unchanged is this card's own claim: it narrowed `children` - // on the chatbot faces and left their `body` channel alone. On a TWIN that - // is still visible on both faces — `body` arrives from `BaseSchema` as the - // content channel and `tsc` admits it — and `tsc`, not vitest, is the reader - // of this line. - const held = { type: 'chatbot-enhanced', messages: [], body: CONTENT } satisfies ChatbotEnhancedSchema; - expect(held.type).toBe('chatbot-enhanced'); + // + // ⚠️ INVERTED, and not by a decision on this card. Its claim was: objectui#9256 + // narrowed `children` on the chatbot faces and left their `body` channel + // alone, `body` arriving from `BaseSchema` as the content channel. objectui#6771 + // retired `body` on `BaseSchema` itself, so there is no content channel left + // to inherit and each twin now declares the same NEITHER-channel tombstone + // its `children` sentence already asserted. ⇒ the hold-out ended by the base + // moving, not by this card re-deciding anything. `tsc`, not vitest, is the + // reader of both lines below. + // @ts-expect-error objectui#6771 — `body` is retired; this family authors `requestBody` + const retired = { type: 'chatbot-enhanced', messages: [], body: CONTENT } satisfies ChatbotEnhancedSchema; + // CONTROL — the key an author should write instead still compiles, so the + // line above is a reading about `body` and not about the whole declaration. + const live = { + type: 'chatbot-enhanced', + messages: [], + requestBody: { model: 'gpt-4' }, + } satisfies ChatbotEnhancedSchema; + expect([retired.type, live.type]).toEqual(['chatbot-enhanced', 'chatbot-enhanced']); }); }); diff --git a/packages/types/src/__tests__/content-channel-per-component-8284.test.ts b/packages/types/src/__tests__/content-channel-per-component-8284.test.ts index a38bfed22b..da736bba6c 100644 --- a/packages/types/src/__tests__/content-channel-per-component-8284.test.ts +++ b/packages/types/src/__tests__/content-channel-per-component-8284.test.ts @@ -31,10 +31,25 @@ * `packages/components/src/renderers/**` (114 registrations) and the read side * is measured with the TypeScript TYPE CHECKER — each `body` / `children` * property access is filed under the TYPE of the object it is read from, so a - * docblock mention cannot score (the control: the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and `grep -l` counts - * it, the checker does not; the card's own `17 / 17` figure came from that - * query). + * docblock mention cannot score. + * + * ⚠️ THE CONTROL MOVED, because objectui#6771 SPENT the one that stood here. + * It read: the `BoxSchema` docblock in `renderers/layout/box.tsx` SAYS + * `schema.body` in prose and `grep -l` counts it, the checker does not — and + * the card's own `17 / 17` figure came from that query. This card rewrote that + * docblock, so `box.tsx` now answers 0 to BOTH halves and a reader replaying it + * gets no divergence at all. ⛔ A control that cannot fire is worse than none: + * it reads as evidence and is not. + * + * ⭐ The live control is `renderers/basic/span.tsx`, whose JSX comment says + * "This read used to be `schema.body`" and carries no such property access. + * Measured with the repo's own masker (`scripts/js-comment-mask.mjs`, the + * instrument the producer scan projects): the raw text answers 1 — what + * `grep -l` counts — and the comment-stripped text answers 0 — what the checker + * can see. ⇒ the divergence is live and replayable. The masker is controlled on + * the same run rather than trusted: `schema.bodyExtra` in + * `renderers/action/action-icon.tsx` is a REAL property access and survives + * stripping at 1, so the 0 above is a reading and not a blanked file. * * The twelve rows below are the ones where the renderer reads EXACTLY ONE * channel, the component owns a dedicated declaration, and exactly one @@ -106,9 +121,17 @@ const ROWS: ReadonlyArray ({ type: 'card', title: 'Parent', body: [child] }); +const nested = (child: unknown) => ({ type: 'card', title: 'Parent', children: [child] }); describe('objectui#7869 — the off-spec node gets the same verdict at both depths', () => { it('is refused STANDING ALONE (unchanged — this half was never the defect)', () => { @@ -142,8 +142,11 @@ describe('the late-binding wiring, read by IDENTITY on the exported wrapper', () }); it('that identity survives through a declared child slot', () => { - const body = (CardSchema.shape.body as unknown as { _zod: { def: { innerType: { _zod: { def: { options: unknown[] } } } } } }); - expect(body._zod.def.innerType._zod.def.options).toContain(SchemaNodeSchema); + // Read off `children`: objectui#6771 retired `card`'s `body` spelling, so + // the declared child slot whose union must hold the recursion point is + // this one. `body` on this mirror is now an alias refusal with no union. + const slot = (CardSchema.shape.children as unknown as { _zod: { def: { innerType: { _zod: { def: { options: unknown[] } } } } } }); + expect(slot._zod.def.innerType._zod.def.options).toContain(SchemaNodeSchema); }); it('the holder is FILLED by importing the barrel — the module-cycle break works', () => { @@ -282,17 +285,22 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a it('names `body` AT ITS OWN PATH one slot down, and carries the remedy there too', () => { // ⭐ RE-POINTED by objectui#9659, carrying a contract-review residual on objectui#9639. - // This leg used to read `JSON.stringify(issues)` for the substring `"body"` and was - // VACUOUS: the parent card slot puts `"body"` in the issue path for ANY refused child, so - // the old assertion held whether or not the `chatbot` arm named anything. Measured on this - // head — three documents with nothing wrong at `body`, all three satisfying the old - // assertion, none of them carrying an issue at the child's own `body` path: + // This leg used to read `JSON.stringify(issues)` for the substring naming the + // parent's child slot and was VACUOUS: that slot is in the issue path for ANY + // refused child, so the old assertion held whether or not the `chatbot` arm + // named anything. Measured on this head — three documents with nothing wrong + // at the child's own `body`, all three satisfying the old assertion, none of + // them carrying an issue at that path: // - // nested off-spec `icon` (size: 'huge') → blob contains `"body"`: true, issues at - // `body.0.body`: 0 + // nested off-spec `icon` (size: 'huge') → blob contains the slot name: true, + // issues at `.0.body`: 0 // nested unmirrored `metric-card` → true / 0 // nested `chatbot` missing `messages` → true / 0 - // nested `chatbot` with a record `body` → true / 1 ← the only one that is about `body` + // nested `chatbot` with a record `body` → true / 1 ← the only one about `body` + // + // ⚠️ The parent slot is spelled `children` since objectui#6771 retired `body`; + // the child's own refused key is still `body`, so the two halves of the path + // no longer read alike and the reading is easier to see, not different. // // ⇒ the reading that discriminates is the child's OWN path, not the serialized blob. The // controls above are kept as assertions below so the discrimination is pinned rather than @@ -312,7 +320,7 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a walk(issues, []); return out; }; - const named = at(result.error.issues, 'body.0.body'); + const named = at(result.error.issues, 'children.0.body'); expect(named.length).toBeGreaterThan(0); // the refusal one slot down is the ARM's tombstone, and the REMEDY reaches the author at // depth and not only at the root — the root leg above reads the same string at depth 0. @@ -326,7 +334,7 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a ['`chatbot` missing `messages`', { type: 'chatbot' }], ])('CONTROL — a nested %s is refused with NOTHING at the child\'s `body` path', (_label, child) => { // These are the documents that made the old leg vacuous. Each is refused for a reason that - // has nothing to do with `body`, so the leg above must find nothing at `body.0.body` here. + // has nothing to do with `body`, so the leg above must find nothing at `children.0.body`. // ⛔ Do not "repair" a future failure by widening the path: a refusal that starts naming // `body` for an off-spec `icon` is a defect in the recursion point, not in this control. const result = AnyComponentSchema.safeParse(nested(child)); @@ -341,9 +349,11 @@ describe('objectui#8572 — the `chatbot` record `body` is refused at the ROOT a for (const bucket of issue.errors ?? []) walk(bucket, here); }; walk(result.error.issues, []); - expect(paths).not.toContain('body.0.body'); + expect(paths).not.toContain('children.0.body'); // and the old assertion holds anyway — which is the whole reason it was replaced. - expect(JSON.stringify(result.error.issues)).toContain('"body"'); + // (It reads the PARENT's slot name, which is what made it vacuous; that name is + // `children` since objectui#6771, and the vacuity is unchanged.) + expect(JSON.stringify(result.error.issues)).toContain('"children"'); }); }); @@ -365,7 +375,7 @@ describe('objectui#8344 + objectui#8498 — a refusal at depth 4 stays bounded a const deep = (levels: number): unknown => levels === 0 ? { type: 'badge', variant: 'not-a-variant' } - : { type: 'card', title: 'p', body: [deep(levels - 1)] }; + : { type: 'card', title: 'p', children: [deep(levels - 1)] }; it('refuses at every depth 0 through 4 without throwing', () => { for (const depth of [0, 1, 2, 3, 4]) { @@ -387,7 +397,7 @@ describe('objectui#8344 + objectui#8498 — a refusal at depth 4 stays bounded a const legal = (levels: number): unknown => levels === 0 ? { type: 'badge', variant: 'default' } - : { type: 'card', title: 'p', body: [legal(levels - 1)] }; + : { type: 'card', title: 'p', children: [legal(levels - 1)] }; expect(safeValidateSchema(legal(4)).success).toBe(true); }); }); diff --git a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts index 10179f5bfd..b8d4eb96c3 100644 --- a/packages/types/src/__tests__/page-actions-refusal-7926.test.ts +++ b/packages/types/src/__tests__/page-actions-refusal-7926.test.ts @@ -94,7 +94,8 @@ describe('objectui#7926 — the `page` node refuses `actions` (contract half)', // sentence: pinning prose byte-for-byte turns every wording fix red for no // gain (AGENTS.md — assert the named subject, not the copy). expect(message).toContain('actions'); - expect(message).toContain('body'); + // The remedy key, which objectui#6771 moved from `body` to `children`. + expect(message).toContain('children'); expect(message).toContain('page:header'); // Zod's own default for a `never` arm says none of this. expect(message).not.toBe('Invalid input: expected never, received array'); @@ -108,11 +109,11 @@ describe('objectui#7926 — the `page` node refuses `actions` (contract half)', expect(PageNodeSchema.safeParse(withoutActions).success).toBe(true); }); - it('the remedy the message names actually parses — buttons as nodes in `body`', () => { + it('the remedy the message names actually parses — buttons as nodes in `children`', () => { const r = PageNodeSchema.safeParse({ type: 'page', title: 'Products', - body: [ + children: [ { type: 'flex', justify: 'end', @@ -233,13 +234,13 @@ describe('objectui#7926 — the guide no longer authors `actions` on a `page` no } }); - it('every `page` node the guide teaches declares its content under `body`', () => { + it('every `page` node the guide teaches declares its content under `children`', () => { // The positive form of the same fact: the passages were not merely stripped // of `actions`, they were REWRITTEN onto the key that renders. const pages = fences.filter((f) => isPageNode(f.doc)); for (const f of pages) { const doc = f.doc as Record; - expect({ line: f.line, hasBody: 'body' in doc }).toEqual({ line: f.line, hasBody: true }); + expect({ line: f.line, hasContent: 'children' in doc }).toEqual({ line: f.line, hasContent: true }); } }); }); diff --git a/packages/types/src/__tests__/page-body-arity-8310.test.ts b/packages/types/src/__tests__/page-body-arity-8310.test.ts index 4851e5c904..1b9e13a445 100644 --- a/packages/types/src/__tests__/page-body-arity-8310.test.ts +++ b/packages/types/src/__tests__/page-body-arity-8310.test.ts @@ -7,14 +7,25 @@ */ /** - * `PageNodeSchema.body` admits ONE node or a list of them — on both faces - * (objectui#8310, maintainer ruling 2026-09-07, director decision batch #2). + * `PageNodeSchema`'s flat content list admits ONE node or a list of them — on + * both faces (objectui#8310, maintainer ruling 2026-09-07, director decision + * batch #2). + * + * ⚠️ THE KEY MOVED, THE RULING DID NOT. objectui#8310 was decided about the + * `body` spelling, which was this node's flat content list at the time. + * objectui#6771 retired that spelling across the protocol; `FlatContent` now + * reads `schema.children` and nothing else, so the arity ruling governs + * `children` and every assertion below was moved onto it. The last block in + * this file is the other half: `body` is refused by name on both faces, so a + * page still written in the retired spelling is answered rather than silently + * rendered empty. Read the two together — the arity survives, the spelling did + * not. * * ## The defect this pins closed * - * `BaseSchema.body` is `SchemaNode | SchemaNode[]`. `PageNodeSchema` inherited - * that channel and NARROWED it to `SchemaNode[]`, while the only reader of the - * channel — `FlatContent`, the legacy body/children fallback inside + * The base declares this channel `SchemaNode | SchemaNode[]`. `PageNodeSchema` + * inherited it and NARROWED it to `SchemaNode[]`, while the only reader of the + * channel — `FlatContent`, the flat fallback inside * `PageRenderer` (`packages/components/src/renderers/layout/page.tsx`) — went on * accepting a bare node, normalizing it into a one-element list behind a * `content as SchemaNode` cast. The cast was the marker: the branch was @@ -89,7 +100,7 @@ const README = readFileSync(join(REPO_ROOT, 'README.md'), 'utf8'); /* -------------------------------------------------------------------------- */ /** - * The `grid` node the README's flagship example hands to `body` — child list + * The `grid` node the README's flagship example hands to `children` — child list * spelled `children`, the key `GridSchema` declares and `grid` reads * (objectui#8912). */ @@ -106,7 +117,7 @@ const flagshipGrid: SchemaNode = { /** * The root `README.md` "Basic Usage" example, as a `PageNodeSchema`. * - * ⭐ THE pin of this card. Narrowing `body` back to `SchemaNode[]` makes this + * ⭐ THE pin of this card. Narrowing `children` back to `SchemaNode[]` makes this * declaration stop compiling; that is exactly the state the repository was in * before objectui#8310, and it is why the landing page's own example could not * be annotated with the type of the node it is. @@ -114,7 +125,7 @@ const flagshipGrid: SchemaNode = { const readmeFlagshipPage: PageNodeSchema = { type: 'page', title: 'Dashboard', - body: flagshipGrid, + children: flagshipGrid, }; /** @@ -126,15 +137,15 @@ const readmeFlagshipPage: PageNodeSchema = { const listArityPage: PageNodeSchema = { type: 'page', title: 'Dashboard', - body: [flagshipGrid], + children: [flagshipGrid], }; /* -------------------------------------------------------------------------- */ /* Leg 2 + 3 — the zod mirror, at runtime */ /* -------------------------------------------------------------------------- */ -describe('PageNodeSchema.body arity — the declaration matches its reader (objectui#8310)', () => { - it('parses the README flagship example, whose `body` is ONE node', () => { +describe('PageNodeSchema flat-content arity — the declaration matches its reader (objectui#8310)', () => { + it('parses the README flagship example, whose `children` is ONE node', () => { const result = ZodPageNodeSchema.safeParse(readmeFlagshipPage); expect(result.success).toBe(true); }); @@ -145,9 +156,9 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje }); it('keeps the single node intact through the parse rather than wrapping it', () => { - const parsed = ZodPageNodeSchema.parse(readmeFlagshipPage) as { body?: unknown }; - expect(Array.isArray(parsed.body)).toBe(false); - expect((parsed.body as { type?: string } | undefined)?.type).toBe('grid'); + const parsed = ZodPageNodeSchema.parse(readmeFlagshipPage) as { children?: unknown }; + expect(Array.isArray(parsed.children)).toBe(false); + expect((parsed.children as { type?: string } | undefined)?.type).toBe('grid'); }); }); @@ -156,19 +167,19 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje /* -------------------------------------------------------------------------- */ /** - * REFUSAL CONTROL — what the widened `body` must still turn away. + * REFUSAL CONTROL — what the widened `children` must still turn away. * - * Legs 1-3 all assert ACCEPTANCE. A `body` arm that had degraded into + * Legs 1-3 all assert ACCEPTANCE. A `children` arm that had degraded into * accept-anything (`z.any()`, or a union whose floor fell out) passes every one * of them green, so on their own they measure the widening's DIRECTION and * never its BOUND. This block is the missing half. It pins the refusal - * ENVELOPE — an `invalid_union` issue at path `['body']` — rather than the bare - * falsity of `success`, so a page refused for some unrelated reason cannot - * stand in for a `body` that was judged and rejected. + * ENVELOPE — an `invalid_union` issue at path `['children']` — rather than the + * bare falsity of `success`, so a page refused for some unrelated reason cannot + * stand in for a `children` that was judged and rejected. * * ## ⚠️ Measured, and it is not what the obvious guess says * - * `body: 42` and `body: 'x'` are **LEGAL**, on both faces, and an assertion + * `children: 42` and `children: 'x'` are **LEGAL**, on both faces, and an assertion * that they are refused pins a contract this repo does not have: * * - `../base.ts`: `type SchemaNode = BaseSchema | string | number | boolean | null | undefined` @@ -177,8 +188,8 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje * * A primitive IS a `SchemaNode` here — the text-node convention — so * `SchemaNode | SchemaNode[]` admits `42` by construction, exactly as - * `BaseSchema.body`, `CardSchema.body` and `AspectRatioSchema.body` already - * did before this card. ⛔ Do not "harden" this block by asserting a primitive + * `BaseSchema.children`, `CardSchema.children` and `AspectRatioSchema.children` + * already did before this card. ⛔ Do not "harden" this block by asserting a primitive * is refused: that assertion is RED today, and if it ever went green it would * mean the node union had lost arms it is declared to have. * @@ -194,30 +205,75 @@ describe('PageNodeSchema.body arity — the declaration matches its reader (obje * requires `type`, and a discriminated union cannot dispatch without it — so * that is the value this control is built on, in either arity. */ -const REFUSED_BODIES: ReadonlyArray<[label: string, body: unknown]> = [ +const REFUSED_CONTENT: ReadonlyArray<[label: string, children: unknown]> = [ ['an object that is not a node — it carries no `type`', {}], ['an object with node-ish keys but still no `type`', { columns: 3 }], ['the LIST arity carrying that same non-node object', [{}]], ]; -describe('PageNodeSchema.body still refuses a non-node — the widening kept its floor (objectui#8310)', () => { - for (const [label, body] of REFUSED_BODIES) { +describe('PageNodeSchema flat content still refuses a non-node — the widening kept its floor (objectui#8310)', () => { + for (const [label, children] of REFUSED_CONTENT) { it(`refuses ${label}`, () => { - const result = ZodPageNodeSchema.safeParse({ type: 'page', title: 'Dashboard', body }); + const result = ZodPageNodeSchema.safeParse({ type: 'page', title: 'Dashboard', children }); expect(result.success).toBe(false); if (result.success) return; - const bodyIssues = result.error.issues.filter((issue) => issue.path[0] === 'body'); - expect(bodyIssues.length).toBeGreaterThan(0); - expect(bodyIssues.map((issue) => issue.code)).toContain('invalid_union'); + const contentIssues = result.error.issues.filter((issue) => issue.path[0] === 'children'); + expect(contentIssues.length).toBeGreaterThan(0); + expect(contentIssues.map((issue) => issue.code)).toContain('invalid_union'); }); } - it('BOUNDARY — a primitive body stays legal, because `SchemaNode` declares it', () => { + it('BOUNDARY — a primitive stays legal, because `SchemaNode` declares it', () => { // ⛔ Not a defect and ⛔ not a candidate for "hardening" — see the docblock. - expect(ZodPageNodeSchema.safeParse({ type: 'page', body: 42 }).success).toBe(true); - expect(ZodPageNodeSchema.safeParse({ type: 'page', body: 'Hello' }).success).toBe(true); + expect(ZodPageNodeSchema.safeParse({ type: 'page', children: 42 }).success).toBe(true); + expect(ZodPageNodeSchema.safeParse({ type: 'page', children: 'Hello' }).success).toBe(true); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* The other half of the move — the retired spelling is ANSWERED, not ignored */ +/* -------------------------------------------------------------------------- */ + +/** + * objectui#6771 retired `body`. The arity above moved onto `children`; this + * block pins what happened to the key it moved off. + * + * ⛔ The assertion is NOT "the old pin is gone". A deleted member would be + * ACCEPTED, silently: `BaseSchemaCore` ends `.passthrough()` and the TS face + * carries `[key: string]: any`, so dropping the declaration would leave a page + * written in the retired spelling parsing green and rendering nothing — the + * exact silence the retirement exists to end. What is pinned is the REFUSAL, + * at the key's own path, carrying the remedy. + */ +describe('`body` is refused by name on the page node (objectui#6771)', () => { + it('refuses the single-node form the README used to author under `body`', () => { + const result = ZodPageNodeSchema.safeParse({ + type: 'page', + title: 'Dashboard', + body: flagshipGrid, + }); + expect(result.success).toBe(false); + if (result.success) return; + const bodyIssues = result.error.issues.filter((issue) => issue.path[0] === 'body'); + expect(bodyIssues.length).toBeGreaterThan(0); + expect(bodyIssues[0]!.message).toContain('`children`'); + }); + + it('refuses the LIST arity under the retired spelling too — this is about the key', () => { + const result = ZodPageNodeSchema.safeParse({ type: 'page', body: [flagshipGrid] }); + expect(result.success).toBe(false); + }); + + it('CONTROL — the identical document under `children` parses green', () => { + // Without this leg, a mirror that refused every page would satisfy the two + // assertions above. + expect(ZodPageNodeSchema.safeParse({ + type: 'page', + title: 'Dashboard', + children: flagshipGrid, + }).success).toBe(true); }); }); @@ -248,16 +304,16 @@ function basicUsageSchemaLiteral(): string { throw new Error('unbalanced braces in the README "Basic Usage" schema literal'); } -/** The first non-whitespace character of the literal's `body:` value. */ -function bodyValueOpener(literal: string): string { - const at = literal.indexOf('body:'); +/** The first non-whitespace character of the literal's `children:` value. */ +function contentValueOpener(literal: string): string { + const at = literal.indexOf('children:'); expect(at).toBeGreaterThan(-1); - return literal.slice(at + 'body:'.length).trimStart()[0]; + return literal.slice(at + 'children:'.length).trimStart()[0]; } describe('the root README keeps authoring the single-node form (objectui#8310)', () => { - it('gives `body` ONE node, not a list — the shape this card made legal', () => { - expect(bodyValueOpener(basicUsageSchemaLiteral())).toBe('{'); + it('gives `children` ONE node, not a list — the shape this card made legal', () => { + expect(contentValueOpener(basicUsageSchemaLiteral())).toBe('{'); }); it('still authors the page/grid/statistic shape transcribed above', () => { diff --git a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts index e74b87ec07..b2c327c331 100644 --- a/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts +++ b/packages/types/src/__tests__/page-breadcrumbs-refusal-8871.test.ts @@ -128,7 +128,8 @@ describe('objectui#8871 — the `page` node refuses `breadcrumbs` (contract half // copy). expect(message).toContain('breadcrumbs'); expect(message).toContain('breadcrumb'); - expect(message).toContain('body'); + // The remedy key, which objectui#6771 moved from `body` to `children`. + expect(message).toContain('children'); expect(message).toContain('page:header'); // Zod's own default for a `never` arm says none of this. expect(message).not.toBe('Invalid input: expected never, received array'); @@ -142,11 +143,11 @@ describe('objectui#8871 — the `page` node refuses `breadcrumbs` (contract half expect(PageNodeSchema.safeParse(withoutBreadcrumbs).success).toBe(true); }); - it('the remedy the message names actually parses — a `breadcrumb` node in `body`', () => { + it('the remedy the message names actually parses — a `breadcrumb` node in `children`', () => { const r = PageNodeSchema.safeParse({ type: 'page', title: 'Acme Corporation', - body: [ + children: [ { type: 'breadcrumb', items: [ @@ -235,7 +236,7 @@ describe('objectui#8871 — the guide teaches the node, not the key', () => { // A deletion that leaves no trace teaches the next author nothing — they // rewrite the key from memory. The guide must name the card and the remedy. expect(guide).toContain('objectui#8871'); - expect(guide).toMatch(/breadcrumb.*node.*`body`|`body`.*breadcrumb.*node/s); + expect(guide).toMatch(/breadcrumb.*node.*`children`|`children`.*breadcrumb.*node/s); }); }); diff --git a/packages/types/src/base.ts b/packages/types/src/base.ts index bcebba9b90..548244c121 100644 --- a/packages/types/src/base.ts +++ b/packages/types/src/base.ts @@ -299,14 +299,46 @@ export interface BaseSchema { bind?: string; /** - * Child components or content. - * Can be a single component, array of components, or primitive values. + * RETIRED (objectui#6771, maintainer ruling 2026-09-01, ADR-0049) — the + * second spelling of {@link BaseSchema.children}. Author `children`. + * + * ## Why it is refused by name rather than merely deleted + * + * `BaseSchema` carries an index signature on the TS side and + * `BaseSchemaCore` ends `.passthrough()` on the zod side, so DELETING a + * member does not refuse it — it makes it silently acceptable and + * silently inert, which is the state this retirement exists to leave. The + * `?: never` twin of the mirror's `aliasKeyRefusal` is what turns the + * removal into an answer: `tsc` refuses the key at the authoring site and + * the parse names `children` as the remedy. + * + * ## What it used to be + * + * A declared twin of `children`, and for a dozen registrations — `badge`, + * `alert`, the `sidebar-*` family, `tooltip` — the ONLY child-list key + * their renderer read, while `div` / `card` / `page` / `button` / + * `aspect-ratio` / the sectioning tags read `children || body` and took + * either. One concept with two spellings is the lenient-fallback shape + * AGENTS.md #0.1 names, and the parser tier only ever knew `children`, so + * the one spelling that resolved drew the warning a typo draws. The + * ruling retired the spelling rather than blessing it: the registrations + * converged on `children` and this repository's own corpus and teaching + * moved in the same change. + * + * @deprecated Retired spelling of `children` — author `children`. */ - body?: SchemaNode | SchemaNode[]; + body?: never; /** - * Alternative name for children (React-style). - * Some components use 'children' instead of 'body'. + * Child components or content — THE child-list key, and since + * objectui#6771 the only one. Can be a single component, an array of + * components, or primitive values. + * + * This docblock used to read "alternative name for children" and admit + * that "some components use 'children' instead of 'body'" without saying + * which — a sentence that was itself load-bearing evidence on three + * separate cards, because it told an author both spellings were live and + * left them to guess per component. */ children?: SchemaNode | SchemaNode[]; diff --git a/packages/types/src/data-display.ts b/packages/types/src/data-display.ts index 591f90256d..b1d08e37ea 100644 --- a/packages/types/src/data-display.ts +++ b/packages/types/src/data-display.ts @@ -55,26 +55,26 @@ export interface AlertSchema extends BaseSchema { */ onDismiss?: never; /** - * REFUSED BY NAME (objectui#8284, ADR-0049) — `alert` reads `body`, and no - * renderer read consumes `children`. + * REFUSED BY NAME (objectui#8284, ADR-0049; INVERTED by objectui#6771) — + * `alert` reads `children`, and no renderer read consumes `body`. * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a - * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.body` read in - * `packages/components/src/renderers/data-display/alert.tsx`. The same sweep finds zero `children` reads - * for this node type. + * docblock mention is not a read): the `schema.children` read in + * `packages/components/src/renderers/data-display/alert.tsx`. The same sweep + * finds zero `body` reads for this node type. * - * `children` is inherited-and-optional from {@link BaseSchema}, whose own - * docblock admits "some components use `children` instead of `body`" without - * saying which — so authoring it here type-checked, parsed green through - * `.passthrough()`, and rendered an EMPTY element with no error and no - * warning. Per component, the channel a renderer does not read is now - * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07). + * ⚠️ THIS MEMBER SWAPPED SIDES, and the principle did not. objectui#8284 + * tombstoned `children` here, because `body` was then the only key this + * renderer read — `alert` was one of the dozen registrations for which + * `body` was the ONLY door. objectui#6771 retired that spelling across the + * protocol, the renderer converged on `children`, and the tombstone follows + * the read: the channel a renderer does not read is refused by name, on both + * published faces (maintainer ruling, summon #17 decision batch #2, + * 2026-09-07). Which key that is changed; the rule did not. * - * @deprecated Not a channel `alert` reads — author `body`. + * @deprecated Not a channel `alert` reads — author `children`. */ - children?: never; + body?: never; } /** @@ -171,26 +171,26 @@ export interface BadgeSchema extends BaseSchema { */ icon?: string; /** - * REFUSED BY NAME (objectui#8284, ADR-0049) — `badge` reads `body`, and no - * renderer read consumes `children`. + * REFUSED BY NAME (objectui#8284, ADR-0049; INVERTED by objectui#6771) — + * `badge` reads `children`, and no renderer read consumes `body`. * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a - * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.body` read in - * `packages/components/src/renderers/data-display/badge.tsx`. The same sweep finds zero `children` reads - * for this node type. + * docblock mention is not a read): the `schema.children` read in + * `packages/components/src/renderers/data-display/badge.tsx`. The same sweep + * finds zero `body` reads for this node type. * - * `children` is inherited-and-optional from {@link BaseSchema}, whose own - * docblock admits "some components use `children` instead of `body`" without - * saying which — so authoring it here type-checked, parsed green through - * `.passthrough()`, and rendered an EMPTY element with no error and no - * warning. Per component, the channel a renderer does not read is now - * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07). + * ⚠️ THIS MEMBER SWAPPED SIDES, and the principle did not. objectui#8284 + * tombstoned `children` here, because `body` was then the only key this + * renderer read — `badge` was one of the dozen registrations for which + * `body` was the ONLY door. objectui#6771 retired that spelling across the + * protocol, the renderer converged on `children`, and the tombstone follows + * the read: the channel a renderer does not read is refused by name, on both + * published faces (maintainer ruling, summon #17 decision batch #2, + * 2026-09-07). Which key that is changed; the rule did not. * - * @deprecated Not a channel `badge` reads — author `body`. + * @deprecated Not a channel `badge` reads — author `children`. */ - children?: never; + body?: never; } /** diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 110478cc31..a2b17b6fd6 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -788,8 +788,10 @@ export interface ToggleSchema extends BaseSchema { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/form/toggle.tsx`. The same sweep finds zero `body` reads * for this node type. * @@ -1945,8 +1947,10 @@ export interface FormSchema extends BaseSchema { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/form/form.tsx`. The same sweep finds zero `body` reads * for this node type. * diff --git a/packages/types/src/layout.ts b/packages/types/src/layout.ts index 01496bc198..6acdee8dac 100644 --- a/packages/types/src/layout.ts +++ b/packages/types/src/layout.ts @@ -60,8 +60,10 @@ export interface BoxSchema extends BaseSchema { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/layout/box.tsx`. The same sweep finds zero `body` reads * for this node type. * @@ -96,8 +98,10 @@ export interface TextSpanSchema extends BaseSchema { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/basic/span.tsx`. The same sweep finds zero `body` reads * for this node type. * @@ -543,8 +547,10 @@ export interface ContainerSchema extends BaseSchema { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/layout/container.tsx`. The same sweep finds zero `body` reads * for this node type. * @@ -669,8 +675,10 @@ export interface FlexSchema extends BaseSchema, FlexLayoutProps { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/layout/flex.tsx`. The same sweep finds zero `body` reads * for this node type. * @@ -701,8 +709,10 @@ export interface StackSchema extends BaseSchema, FlexLayoutProps { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/layout/stack.tsx`. The same sweep finds zero `body` reads * for this node type. * @@ -750,8 +760,10 @@ export interface GridSchema extends BaseSchema { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/layout/grid.tsx`. The same sweep finds zero `body` reads * for this node type. * @@ -785,11 +797,27 @@ export interface CardSchema extends BaseSchema { */ header?: SchemaNode | SchemaNode[]; /** - * Card body/content (Legacy, use children) + * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body` + * child-list spelling. Author {@link CardSchema.children}. + * + * This key was already labelled "Legacy, use children" and the renderer + * read `renderNodeSlot(schema.children || schema.body, …)`, so `card` was + * one of the fallback readers whose `body` arm the ruling drops in the + * same change as the `body`-only registrations converge. + * + * Refused by name rather than deleted: `BaseSchema` carries + * `[key: string]: any` and the zod twin ends `.passthrough()`, so a + * deleted member is ACCEPTED silently and rendered by nothing. The + * `?: never` is what makes `tsc` answer at the authoring site, and the + * mirror's `aliasKeyRefusal` names `children` at parse time. + * + * @deprecated Retired spelling of `children` — author `children`. */ - body?: SchemaNode | SchemaNode[]; + body?: never; /** - * Child components + * Card content — the card's child list, and since objectui#6771 its only + * spelling. READ SITE: `renderNodeSlot(schema.children, …)` in + * `packages/components/src/renderers/layout/card.tsx`. */ children?: SchemaNode | SchemaNode[]; /** @@ -994,8 +1022,10 @@ export interface ScrollAreaSchema extends BaseSchema { * * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.children` read in + * `renderers/layout/box.tsx` SAID `schema.body` in prose and grep counted it, + * until objectui#6771 rewrote that docblock — the example is spent, the + * hazard is not, which is why the measurement is a type-checker sweep and + * never a grep): the `schema.children` read in * `packages/components/src/renderers/complex/scroll-area.tsx`. The same sweep finds zero `body` reads * for this node type. * @@ -1135,11 +1165,25 @@ export interface AspectRatioSchema extends BaseSchema { */ alt?: string; /** - * Child components (alternative to image) + * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body` + * child-list spelling. Author {@link AspectRatioSchema.children}. + * + * `aspect-ratio` read `renderChildren(schema.children || schema.body)` + * whenever no `image` was set, so it is one of the fallback readers whose + * `body` arm drops in the same change. + * + * Refused by name rather than deleted: `BaseSchema` carries + * `[key: string]: any` and the zod twin ends `.passthrough()`, so a + * deleted member is ACCEPTED silently and rendered by nothing. The + * `?: never` is what makes `tsc` answer at the authoring site, and the + * mirror's `aliasKeyRefusal` names `children` at parse time. + * + * @deprecated Retired spelling of `children` — author `children`. */ - body?: SchemaNode | SchemaNode[]; + body?: never; /** - * Child components (alternative syntax) + * Child components rendered inside the ratio box when no `image` is set — + * the one child-list spelling (objectui#6771). */ children?: SchemaNode | SchemaNode[]; } @@ -1257,13 +1301,13 @@ export interface PageNodeSchema extends BaseSchema { * * `PageRenderer` has no read point for it: a `page` node carrying * `actions: [{type:'button',label:'Add Product'}, …]` drew 0 buttons through - * the real `SchemaRenderer`, while the SAME two buttons in {@link body} drew + * the real `SchemaRenderer`, while the SAME two buttons in {@link PageNodeSchema.children} drew * 2. `BaseSchema` is `.passthrough()`, so the array was not dropped — it was * kept, and before objectui#7933 it reached the DOM as * `actions="[object Object],[object Object]"`. * * The remedy is a NODE, not a key: put a `button` (or an `action:button` with - * a declared `actionType`) in {@link body}; on a record page declare them on a + * a declared `actionType`) in {@link PageNodeSchema.children}; on a record page declare them on a * `page:header` block, whose own `actions` are ACTION IDS resolved from the * object's metadata (objectui#7182) rather than nodes. * @@ -1300,7 +1344,7 @@ export interface PageNodeSchema extends BaseSchema { * * The remedy is a NODE that already ships: put * `{ "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] }` in - * {@link body}. `breadcrumb` is a registered renderer taking that exact item + * {@link PageNodeSchema.children}. `breadcrumb` is a registered renderer taking that exact item * shape, plus `separator`, `maxItems` and a per-item `icon`. * ⛔ Not the `page:header` block's `breadcrumb`: that one is SINGULAR and a * BOOLEAN display toggle, not a list of links. @@ -1358,21 +1402,41 @@ export interface PageNodeSchema extends BaseSchema { // blankLayout removed — the `blank` page type has no renderer and was dropped // from @objectstack/spec PageTypeSchema (framework#2265, enforce-or-remove). /** - * Main content (Legacy/Simple mode) — ONE node, or a list of them. + * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body` + * child-list spelling. Author {@link PageNodeSchema.children}. + * + * `PageRenderer`'s `FlatContent` read `schema.body || schema.children`, and + * the registration published `body` as the flat content list on all five + * page kinds. Both moved to `children` in the retirement; the ARITY ruling + * this key carried (objectui#8310 — one node OR a list) moved with it and + * is restated on `children` below, because that is now the key it governs. + * + * Refused by name rather than deleted: `BaseSchema` carries + * `[key: string]: any` and the zod twin ends `.passthrough()`, so a deleted + * member is ACCEPTED silently and rendered by nothing. + * + * @deprecated Retired spelling of `children` — author `children`. + */ + body?: never; + /** + * Main content when a page declares no regions — ONE node, or a list of + * them. * * The union is the declaration catching up to its reader, not a widening for - * convenience (objectui#8310, maintainer ruling 2026-09-07). This key read - * `SchemaNode[]` and was the OUTLIER in this file: `CardSchema.body` and - * `AspectRatioSchema.body` already spell the union, and so does - * `BaseSchema.body` — the channel this interface inherits and then narrowed. + * convenience (objectui#8310, maintainer ruling 2026-09-07). The ruling was + * made about the `body` spelling this interface used to declare; objectui#6771 + * retired that spelling, so the union lives here, on the key the same reader + * now takes. `CardSchema.children` and `AspectRatioSchema.children` spell the + * same union, and so does `BaseSchema.children`. * `PageRenderer`'s `FlatContent` fallback * (`packages/components/src/renderers/layout/page.tsx`) has always accepted a * bare node, normalizing it into a one-element list; under the narrowing that * branch was unreachable through the renderer's own declared props and stood * only behind a `content as SchemaNode` cast, which the same ruling deletes. * - * The refused value is authored on this project's own landing page: the root - * `README.md` "Basic Usage" example gives `body` a single `grid` node. It + * The refused value was authored on this project's own landing page: the root + * `README.md` "Basic Usage" example gives this key a single `grid` node (it + * spelled the key `body` until objectui#6771 moved the corpus). It * type-checked only because that snippet is annotated `BaseSchema` — the * WIDER parent — so nothing on the authoring path ever asked this key about * its arity. Pinned by `__tests__/page-body-arity-8310.test.ts`. @@ -1383,10 +1447,6 @@ export interface PageNodeSchema extends BaseSchema { * absorbed by the index signature, zero diagnostics). This union repairs the * first case only. */ - body?: SchemaNode | SchemaNode[]; - /** - * Alternative content prop - */ children?: SchemaNode | SchemaNode[]; /** * Whether this is the default page for the object/app. @@ -1537,8 +1597,10 @@ export interface PageSlotMap { * that rendered correctly in the browser was refused by `objectui check`. * * The renderer is one factory over all seven tags: it renders - * `renderChildren(schema.children || schema.body)` inside the tag and declares - * exactly one authoring input, `className` (a {@link BaseSchema} member). The + * `renderChildren(schema.children)` inside the tag and declares exactly one + * authoring input, `className` (a {@link BaseSchema} member). It read + * `schema.children || schema.body` until objectui#6771 retired the second + * spelling. The * mirror is `zod/layout.zod.ts#SemanticElementSchema`, and * `__tests__/node-slot-registered-arms-8499.test.ts` compares the tag list below * against `semantic.tsx`'s own `tags` array. @@ -1546,7 +1608,8 @@ export interface PageSlotMap { export interface SemanticElementSchema extends BaseSchema { type: 'aside' | 'main' | 'header' | 'nav' | 'footer' | 'section' | 'article'; /** - * Child components — read as `schema.children || schema.body`. + * Child components. The factory read `schema.children || schema.body` until + * objectui#6771 retired the second spelling; it reads `schema.children`. */ children?: SchemaNode | SchemaNode[]; } @@ -1577,8 +1640,9 @@ export interface HtmlElementSchema extends BaseSchema { | 'ul' | 'ol' | 'li' | 'dl' | 'dt' | 'dd' | 'figure' | 'figcaption' | 'img' | 'hr' | 'br' | 'time' | 'address' | 'cite' | 'q'; /** - * Child components — read as `schema.children ?? schema.body`; ignored for the - * void tags `img` / `hr` / `br`. + * Child components; ignored for the void tags `img` / `hr` / `br`. The factory + * read `schema.children ?? schema.body` until objectui#6771 retired the second + * spelling. */ children?: SchemaNode | SchemaNode[]; /** `a` link target; scheme-sanitised (`javascript:` / `data:` / `vbscript:` are dropped). */ diff --git a/packages/types/src/overlay.ts b/packages/types/src/overlay.ts index 7fbafd07d4..ed725e1e94 100644 --- a/packages/types/src/overlay.ts +++ b/packages/types/src/overlay.ts @@ -688,12 +688,16 @@ export interface PopoverSchema extends BaseSchema { * Tooltip component * * ⚠️ This declaration used to REQUIRE `children` and declare neither `trigger` - * nor `body` (objectui#6939). Nothing reads `children` here — the renderer - * reads `schema.trigger` and `schema.content || renderChildren(schema.body)` + * nor a rich-content slot (objectui#6939). The renderer reads `schema.trigger` + * and `schema.content || renderChildren(…)` * (`packages/components/src/renderers/overlay/tooltip.tsx`), and the - * registration's own `inputs` list `trigger` / `content` / `body` and never - * `children`. `children` stays legal through {@link BaseSchema}, where it is - * optional; it is no longer demanded, so nothing that type-checked before + * registration's own `inputs` list `trigger` / `content` / that slot. ⚠️ The + * slot was spelled `body` from objectui#6939 until objectui#6771 retired the + * spelling; it is `children` on both the read and the published `inputs` now, + * which is why the tombstones below face the other way round from the ones + * objectui#8284 first wrote. `children` was legal through {@link BaseSchema} + * throughout, where it is optional; it is no longer demanded, so nothing that + * type-checked before * stops type-checking. * * ⛔ Do not move a tooltip's trigger back under `children`: `basic-tooltip` @@ -717,38 +721,41 @@ export interface TooltipSchema extends BaseSchema { * Tooltip content/text — the FIRST half of the content read. * * READ SITE: `packages/components/src/renderers/overlay/tooltip.tsx` — - * `schema.content || renderChildren(schema.body)`. Optional because - * {@link TooltipSchema.body} is the other half of that same read. + * `schema.content || renderChildren(schema.children)`. Optional because + * {@link TooltipSchema.children} is the other half of that same read. */ content?: string | SchemaNode; /** - * Rich tooltip content — the FALLBACK half of the same read at - * `packages/components/src/renderers/overlay/tooltip.tsx`, listed by the - * registration as the "Rich Content" slot. - */ - body?: SchemaNode | SchemaNode[]; - /** - * REFUSED BY NAME (objectui#8284, ADR-0049) — `tooltip` reads `content` and, - * as the fallback for that same slot, `body`. No renderer read consumes - * `children`. - * - * READ SITE, measured with the TypeScript TYPE CHECKER and not with grep (a - * docblock mention is not a read; the `BoxSchema` docblock in - * `renderers/layout/box.tsx` says `schema.body` in prose and grep counts - * it): the `schema.body` read in - * `packages/components/src/renderers/overlay/tooltip.tsx`. The same sweep finds zero `children` reads - * for this node type. + * RETIRED (objectui#6771, maintainer ruling 2026-09-01) — the `body` + * child-list spelling. Author {@link TooltipSchema.children}. * - * `children` is inherited-and-optional from {@link BaseSchema}, whose own - * docblock admits "some components use `children` instead of `body`" without - * saying which — so authoring it here type-checked, parsed green through - * `.passthrough()`, and rendered an EMPTY element with no error and no - * warning. Per component, the channel a renderer does not read is now - * tombstoned on both published faces (maintainer ruling, summon #17 decision batch #2, 2026-09-07). + * ⭐ `tooltip` was the ONE registration in the whole tree that ADVERTISED + * this spelling in its `inputs` (a `slot`, labelled "Rich Content"), which + * made it the most discoverable instance of the dialect on the authoring + * surface — and the ruled step-2 population never listed it. Leaving it out + * would have kept the dialect alive precisely where an author was most + * likely to meet it, so it converged with the rest. * - * @deprecated Not a channel `tooltip` reads — author `body`. + * @deprecated Retired spelling of `children` — author `children`. */ - children?: never; + body?: never; + /** + * Rich tooltip content — the FALLBACK half of the same read at + * `packages/components/src/renderers/overlay/tooltip.tsx`, and the slot the + * registration publishes as "Rich Content". + * + * ⚠️ THIS DECLARATION IS AN INVERSION, not a widening. objectui#8284 (ADR-0049, + * maintainer ruling summon #17 decision batch #2, 2026-09-07) tombstoned + * `children` on this node with the measured ground that the renderer read + * `schema.content || renderChildren(schema.body)` and no read consumed + * `children`. objectui#6771 changed the READ, not the principle: the same + * renderer now reads `schema.content || renderChildren(schema.children)`, so + * the channel this node does not read is `body`, and that is where the + * tombstone moved. The per-component rule — tombstone the channel the + * renderer does not read, on both published faces — is unchanged and is + * still what both of these members express. + */ + children?: SchemaNode | SchemaNode[]; /** * Tooltip side * @default 'top' diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index bf2529370f..ba3da6c9f7 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -18,7 +18,7 @@ import { z } from 'zod'; import { I18nLabelSchema } from '@objectstack/spec/ui'; -import { retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, retirementTombstone } from './tombstone.zod.js'; import { ExpressionWireSchema } from './expression.zod.js'; import type { SchemaNode } from '../base.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -369,12 +369,24 @@ const BaseSchemaCore = z.object({ bind: z.string().optional().describe('Data-scope binding path (resolved by useDataScope)'), /** - * Child components or content + * RETIRED (objectui#6771) — mirrors `BaseSchema.body: never` (`../base.ts`). + * + * A bare deletion would be a silent accept, not a refusal: this object ends + * `.passthrough()`, so an undeclared `body` parses green and reaches a + * renderer that no longer reads it. The alias refusal keeps the key + * declared and unwritable, and names `children` in the issue message — + * the remedy, not just the rejection. */ - body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'), + body: aliasKeyRefusal( + 'body', + 'children', + 'this node', + 'The `body` child-list spelling was retired by objectui#6771 — one concept, ' + + 'one spelling. Every registration that read it now reads `children`.', + ), /** - * Alternative children property + * Child components or content — the one child-list spelling (objectui#6771) */ children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components (React-style)'), diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 56f931cdb5..430ab45b47 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -793,6 +793,25 @@ export const ChatbotEnhancedSchema = BaseSchema.extend({ surface: z.enum(['card', 'plain']).optional() .describe("Visual chrome for the chat surface: 'card' bordered panel (default) or 'plain' frameless full-page workspace (objectui#6687)"), onClear: chatbotOnClearArm(), + // Declared HERE rather than inherited, because objectui#6771's retirement + // of `BaseSchema.body` refuses the key with a message naming `children` — + // which is the right remedy on every node except this family, where + // `children` is refused too and the key an author actually wants is + // `requestBody`. The sentence below is the one this node's `children` + // tombstone already asserts about BOTH channels; only the declaration was + // missing, and inheriting a wrong remedy is worse than inheriting none. + body: retirementTombstone( + 'REFUSED (objectui#9256, ADR-0049) — `chatbot-enhanced` reads NEITHER content channel: measured with the ' + + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + + 'authored value therefore rendered NOTHING — no error, no warning, no element. ' + + 'The chat API body params go on `requestBody`, which the registration forwards to the chat runtime. ' + + 'What it renders instead: `api`, `assistantAvatarFallback`, `assistantAvatarUrl`, `autoResponse`, ' + + '`autoResponseDelay`, `autoResponseText`, `conversationId`, `enableFileUpload`, `enableMarkdown`, ' + + '`headers`, `maxHeight`, `maxToolRoundtrips`, `messages`, `model`, `onClear`, `onError`, ' + + '`onSend`, `placeholder`, `processVisibility`, `requestBody`, `showTimestamp`, ' + + '`streamingEnabled`, `surface`, `systemPrompt`, `userAvatarFallback`, `userAvatarUrl`.', + ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `chatbot-enhanced` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' @@ -838,6 +857,25 @@ export const ChatbotFloatingSchema = BaseSchema.extend({ enableMarkdown: chatbotEnableMarkdownArm(), enableFileUpload: chatbotEnableFileUploadArm(), onClear: chatbotOnClearArm(), + // Declared HERE rather than inherited, because objectui#6771's retirement + // of `BaseSchema.body` refuses the key with a message naming `children` — + // which is the right remedy on every node except this family, where + // `children` is refused too and the key an author actually wants is + // `requestBody`. The sentence below is the one this node's `children` + // tombstone already asserts about BOTH channels; only the declaration was + // missing, and inheriting a wrong remedy is worse than inheriting none. + body: retirementTombstone( + 'REFUSED (objectui#9256, ADR-0049) — `chatbot-floating` reads NEITHER content channel: measured with the ' + + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + + 'authored value therefore rendered NOTHING — no error, no warning, no element. ' + + 'The chat API body params go on `requestBody`, which the registration forwards to the chat runtime. ' + + 'What it renders instead: `api`, `assistantAvatarFallback`, `assistantAvatarUrl`, `autoResponse`, ' + + '`autoResponseDelay`, `autoResponseText`, `conversationId`, `enableFileUpload`, `enableMarkdown`, ' + + '`floatingConfig`, `headers`, `maxToolRoundtrips`, `messages`, `model`, `onClear`, `onError`, ' + + '`onSend`, `placeholder`, `requestBody`, `showTimestamp`, `streamingEnabled`, `systemPrompt`, ' + + '`userAvatarFallback`, `userAvatarUrl`.', + ), children: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `chatbot-floating` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' diff --git a/packages/types/src/zod/data-display.zod.ts b/packages/types/src/zod/data-display.zod.ts index 9996fc9aa8..3a157b0bed 100644 --- a/packages/types/src/zod/data-display.zod.ts +++ b/packages/types/src/zod/data-display.zod.ts @@ -65,13 +65,16 @@ export const AlertSchema = BaseSchema.extend({ icon: z.string().optional().describe('Alert icon'), dismissible: z.boolean().optional().describe('Whether alert can be dismissed'), onDismiss: handlerKeyRefusal('onDismiss', 'retired', 'Dismiss handler'), - children: aliasKeyRefusal( - 'children', + // INVERTED by objectui#6771, which retired `body`: the refusal follows the + // read, and this renderer now reads `children`. objectui#8284's rule — the + // channel a renderer does not read is refused by name — is unchanged. + body: aliasKeyRefusal( 'body', + 'children', 'this alert node', - '`alert` reads `body`, never `children` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/alert.tsx`). ' - + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered ' - + 'an EMPTY element — no error, no warning. objectui#8284.', + '`alert` reads `children`, never `body` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/alert.tsx`). ' + + '`body` was this node\'s only child-list key until objectui#6771 retired the spelling; an authored `body` now parses green through ' + + '`.passthrough()` and renders an EMPTY element — no error, no warning. objectui#8284.', ), }); @@ -109,13 +112,16 @@ export const BadgeSchema = BaseSchema.extend({ label: z.string().optional().describe('Badge label'), variant: z.enum(['default', 'secondary', 'destructive', 'outline']).optional().describe('Badge variant'), icon: z.string().optional().describe('Badge icon'), - children: aliasKeyRefusal( - 'children', + // INVERTED by objectui#6771, which retired `body`: the refusal follows the + // read, and this renderer now reads `children`. objectui#8284's rule — the + // channel a renderer does not read is refused by name — is unchanged. + body: aliasKeyRefusal( 'body', + 'children', 'this badge node', - '`badge` reads `body`, never `children` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/badge.tsx`). ' - + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered ' - + 'an EMPTY element — no error, no warning. objectui#8284.', + '`badge` reads `children`, never `body` (READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/data-display/badge.tsx`). ' + + '`body` was this node\'s only child-list key until objectui#6771 retired the spelling; an authored `body` now parses green through ' + + '`.passthrough()` and renders an EMPTY element — no error, no warning. objectui#8284.', ), }); diff --git a/packages/types/src/zod/layout.zod.ts b/packages/types/src/zod/layout.zod.ts index 13efd8312a..47b0ec6578 100644 --- a/packages/types/src/zod/layout.zod.ts +++ b/packages/types/src/zod/layout.zod.ts @@ -405,8 +405,15 @@ export const CardSchema = BaseSchema.extend({ title: z.string().optional().describe('Card title'), description: z.string().optional().describe('Card description'), header: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card header content'), - body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card body content'), - children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'), + body: aliasKeyRefusal( + 'body', + 'children', + 'this card node', + '`card` read `renderNodeSlot(schema.children || schema.body, …)`, one of the fallback readers whose ' + + '`body` arm objectui#6771 dropped in the same change as the `body`-only registrations converged. ' + + 'The TS face declares this member `never` and the corpus moved with it.', + ), + children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card content — the one child-list spelling (objectui#6771)'), footer: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Card footer content'), variant: z.enum(['default', 'outline', 'ghost']).optional().describe('Card variant style'), hoverable: z.boolean().optional().describe('Whether the card is hoverable'), @@ -520,8 +527,14 @@ export const AspectRatioSchema = BaseSchema.extend({ ratio: z.number().optional().describe('Aspect ratio (width / height)'), image: z.string().optional().describe('Image URL to display'), alt: z.string().optional().describe('Image alt text'), - body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components (alternative to image)'), - children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components'), + body: aliasKeyRefusal( + 'body', + 'children', + 'this aspect-ratio node', + '`aspect-ratio` read `renderChildren(schema.children || schema.body)` whenever no `image` was set; ' + + 'objectui#6771 dropped the `body` arm with the rest of the dialect.', + ), + children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Child components rendered when no image is set'), }); /** @@ -685,7 +698,7 @@ const SpecPageFields = specFieldsExcept(stripImportedDefaults(SpecPageSchema).sh const PAGE_ACTIONS_REFUSAL = '`actions` is not a key of the `page` node and never was (objectui#7926): no renderer ' + 'reads it, so an authored array drew nothing and rode `.passthrough()` onto the wrapper ' + - 'element. Author the buttons as NODES in `body` (a `button` node, or an `action:button` ' + + 'element. Author the buttons as NODES in `children` (a `button` node, or an `action:button` ' + 'node with a declared `actionType`); on a record page declare them on a `page:header` ' + 'block instead, whose own `actions` are ACTION IDS resolved from the object metadata ' + '(objectui#7182), not nodes.'; @@ -775,7 +788,7 @@ const PAGE_BREADCRUMBS_REFUSAL = '`breadcrumbs` is not a key of the `page` node and never was (objectui#8871, ADR-0049 ' + 'enforce-or-remove): no renderer reads it, so an authored trail drew nothing and rode ' + '`.passthrough()` through the validator as a silent accept. Author the trail as a NODE ' + - 'in `body` instead — { "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] } ' + + 'in `children` instead — { "type": "breadcrumb", "items": [{ "label": "Home", "href": "/" }] } ' + '— which is a registered renderer and takes the same item shape, plus `separator` and ' + '`maxItems`. ⛔ Not the `page:header` block\'s `breadcrumb` either: that one is SINGULAR ' + 'and a BOOLEAN display toggle, not a list of links.'; @@ -797,16 +810,24 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ template: z.string().optional().describe('Layout template name'), variables: z.array(PageVariableSchema).optional().describe('Local page state variables'), regions: z.array(PageNodeRegionSchema).optional().describe('Page layout regions'), + body: aliasKeyRefusal( + 'body', + 'children', + 'this page node', + '`PageRenderer`\'s `FlatContent` read `schema.body || schema.children` and the registration published ' + + '`body` as the flat content list on all five page kinds; objectui#6771 retired the spelling and ' + + 'moved both to `children`.', + ), // objectui#8310: ONE node or a list, mirroring the TS face and the runtime. // `FlatContent` in `page.tsx` normalizes a bare node into a one-element list, - // and the root README's flagship example authors exactly that; array-only here - // made this the only `body` in this file that is not the union (`CardSchema` - // and `AspectRatioSchema` already spell it, as does `BaseSchema`). - body: z + // and the root README's flagship example authors exactly that. The ruling was + // made about the `body` spelling; objectui#6771 retired it, so the union lives + // on the key the same reader now takes (`CardSchema` and `AspectRatioSchema` + // spell the same union on `children`, as does `BaseSchema`). + children: z .union([SchemaNodeSchema, z.array(SchemaNodeSchema)]) .optional() .describe('Main content — one node or a list of nodes'), - children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional().describe('Alternative content prop'), isDefault: z.boolean().optional().describe('Whether this is the default page'), assignedProfiles: z.array(z.string()).optional().describe('Profiles that can access this page'), }); @@ -835,8 +856,9 @@ export const PageNodeSchema = BaseSchema.extend(SpecPageFields.shape).extend({ * ## Why one arm and not seven * * The factory in `semantic.tsx` builds ONE component over all seven tags: it - * renders `renderChildren(schema.children || schema.body)` inside the tag and - * declares exactly one authoring input, `className`. Seven arms would restate + * renders `renderChildren(schema.children)` inside the tag and declares exactly + * one authoring input, `className`. It read `schema.children || schema.body` + * until objectui#6771 retired the second spelling. Seven arms would restate * the same shape seven times with no key to tell them apart. * * ## What is NOT declared here, and why @@ -852,7 +874,7 @@ export const SemanticElementSchema = BaseSchema.extend({ type: z.enum(['aside', 'main', 'header', 'nav', 'footer', 'section', 'article']) .describe('HTML sectioning tag — the seven `renderers/layout/semantic.tsx` registers'), children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() - .describe('Child components — read as `schema.children || schema.body` by the factory'), + .describe('Child components — read by the factory as `schema.children`; the `body` arm was retired by objectui#6771'), }); /** @@ -902,7 +924,7 @@ export const HtmlElementSchema = BaseSchema.extend({ 'figure', 'figcaption', 'img', 'hr', 'br', 'time', 'address', 'cite', 'q', ]).describe('Safe HTML tag — the set `renderers/basic/html-elements.tsx` registers'), children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() - .describe('Child components — read as `schema.children ?? schema.body`; ignored for the void tags `img` / `hr` / `br`'), + .describe('Child components — read as `schema.children`, ignored for the void tags `img` / `hr` / `br`; the `body` arm was retired by objectui#6771'), href: z.string().optional() .describe('`a` link target; scheme-sanitised (`javascript:` / `data:` / `vbscript:` are dropped)'), target: z.string().optional().describe('`a` browsing context — an internal link navigates through the SPA router unless this names another target'), diff --git a/packages/types/src/zod/overlay.zod.ts b/packages/types/src/zod/overlay.zod.ts index e70bde566c..9232caaec0 100644 --- a/packages/types/src/zod/overlay.zod.ts +++ b/packages/types/src/zod/overlay.zod.ts @@ -307,15 +307,19 @@ export const PopoverSchema = BaseSchema.extend({ /** * Tooltip Schema - Tooltip component * - * ⚠️ This member used to REQUIRE `children` and declare neither `trigger` nor - * `body` (objectui#6939). No read site has ever consumed `children` here: the - * renderer reads `schema.trigger` (`renderers/overlay/tooltip.tsx:28`) and - * `schema.content || renderChildren(schema.body)` (:31), and the registration's - * own `inputs` list `trigger` / `content` / `body` and never `children`. So the + * ⚠️ This member used to REQUIRE `children` and declare neither `trigger` nor a + * rich-content slot (objectui#6939). The renderer reads `schema.trigger` and + * `schema.content || renderChildren(…)` (`renderers/overlay/tooltip.tsx`), and + * the registration's own `inputs` list `trigger` / `content` / that slot. So the * validator refused documents the renderer draws and blessed a spelling that * paints an empty trigger — `declared !== enforced`, with the corpus on the * right side of it. * + * ⚠️ The slot was spelled `body` from objectui#6939 until objectui#6771 retired + * the spelling. It is `children` now, so the refusal below faces the other way + * round from the one objectui#8284 first wrote — the RULE (tombstone the channel + * this renderer does not read) is what is preserved, not the key it named. + * * `HoverCardSchema` two entries below is the settled in-repo shape for this * pair of slots and is what `trigger` follows here. * @@ -334,20 +338,25 @@ export const TooltipSchema = BaseSchema.extend({ trigger: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() .describe('Element the tooltip attaches to (objectui#6939)'), content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() - .describe('Tooltip content, checked before `body` — optional because `body` is the fallback for the same slot (objectui#6939)'), - body: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() - .describe('Rich tooltip content — the fallback for `content`, listed by the registration as the "Rich Content" slot (objectui#6939)'), + .describe('Tooltip content, checked before `children` — optional because `children` is the fallback for the same slot (objectui#6939)'), + children: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]).optional() + .describe('Rich tooltip content — the fallback for `content`, published by the registration as the "Rich Content" slot (objectui#6939, objectui#6771)'), side: z.enum(['top', 'right', 'bottom', 'left']).optional().describe('Tooltip side'), align: z.enum(['start', 'center', 'end']).optional().describe('Tooltip alignment'), delayDuration: z.number().optional().describe('Delay before showing (ms)'), - children: aliasKeyRefusal( - 'children', + // AN INVERSION, not a widening. objectui#8284 tombstoned `children` here on + // the measured ground that the renderer read `content || body` and never + // `children`. objectui#6771 changed the READ, not the principle: the renderer + // now reads `content || children`, so the channel this node does not read is + // `body`, and that is where the tombstone moved. + body: aliasKeyRefusal( 'body', + 'children', 'this tooltip node', - '`tooltip` reads `content` first and `body` as the fallback for that same slot, and never `children` ' + '`tooltip` reads `content` first and `children` as the fallback for that same slot, and never `body` ' + '(READ SITE, measured with the TypeScript type checker: `packages/components/src/renderers/overlay/tooltip.tsx`). ' - + '`children` is inherited from `BaseSchema`, so an authored `children` parsed green here and rendered ' - + 'an EMPTY element — no error, no warning. objectui#8284.', + + 'This registration was the one place in the tree that ADVERTISED `body` in its `inputs`, which is why ' + + 'objectui#6771 brought it into the retirement rather than leaving it as the dialect\'s last discoverable home. objectui#8284.', ), }); diff --git a/packages/vscode-extension/DESIGN.md b/packages/vscode-extension/DESIGN.md index e8297f0856..6c2109ca9f 100644 --- a/packages/vscode-extension/DESIGN.md +++ b/packages/vscode-extension/DESIGN.md @@ -70,7 +70,7 @@ The Object UI VSCode extension is a comprehensive development tool designed to e - **Component Types**: `div`, `card`, `button`, `input`, etc. - **Properties**: 根据component type提供相关属性 -- **Common Properties**: `type`, `className`, `body`, etc. +- **Common Properties**: `type`, `className`, `children`, etc. - **Trigger Characters**: `"`, `:`, ` ` **实现特点**: diff --git a/packages/vscode-extension/README.md b/packages/vscode-extension/README.md index 855a2a0606..0731d1ef77 100644 --- a/packages/vscode-extension/README.md +++ b/packages/vscode-extension/README.md @@ -75,10 +75,10 @@ Create a file `app.objectui.json`: { "type": "div", "className": "p-6", - "body": { + "children": { "type": "card", "title": "Hello Object UI", - "body": { + "children": { "type": "text", "content": "This is a schema-driven UI!" } diff --git a/packages/vscode-extension/schemas/objectui-schema.json b/packages/vscode-extension/schemas/objectui-schema.json index 602526cc2e..ad1c504611 100644 --- a/packages/vscode-extension/schemas/objectui-schema.json +++ b/packages/vscode-extension/schemas/objectui-schema.json @@ -34,7 +34,7 @@ "type": "string", "description": "Unique identifier for the component" }, - "body": { + "children": { "description": "Child components or content", "oneOf": [ { diff --git a/packages/vscode-extension/snippets/objectui.json b/packages/vscode-extension/snippets/objectui.json index 7300e06131..34b745f1f2 100644 --- a/packages/vscode-extension/snippets/objectui.json +++ b/packages/vscode-extension/snippets/objectui.json @@ -5,7 +5,7 @@ "{", " \"type\": \"${1:div}\",", " \"className\": \"${2:p-4}\",", - " \"body\": {", + " \"children\": {", " $0", " }", "}" @@ -18,13 +18,13 @@ "{", " \"type\": \"div\",", " \"className\": \"max-w-2xl mx-auto p-6\",", - " \"body\": {", + " \"children\": {", " \"type\": \"card\",", " \"title\": \"${1:Form Title}\",", - " \"body\": {", + " \"children\": {", " \"type\": \"div\",", " \"className\": \"space-y-4\",", - " \"body\": [", + " \"children\": [", " {", " \"type\": \"input\",", " \"label\": \"${2:Name}\",", @@ -50,7 +50,7 @@ " \"title\": \"${1:Card Title}\",", " \"description\": \"${2:Card description}\",", " \"className\": \"${3:shadow-lg}\",", - " \"body\": {", + " \"children\": {", " $0", " }", "}" @@ -110,7 +110,7 @@ "{", " \"type\": \"div\",", " \"className\": \"grid gap-${1:4} md:grid-cols-${2:2} lg:grid-cols-${3:3}\",", - " \"body\": [", + " \"children\": [", " $0", " ]", "}" @@ -123,11 +123,11 @@ "{", " \"type\": \"div\",", " \"className\": \"min-h-screen p-6\",", - " \"body\": [", + " \"children\": [", " {", " \"type\": \"div\",", " \"className\": \"mb-6\",", - " \"body\": {", + " \"children\": {", " \"type\": \"text\",", " \"content\": \"${1:Dashboard}\",", " \"className\": \"text-3xl font-bold\"", @@ -136,11 +136,11 @@ " {", " \"type\": \"div\",", " \"className\": \"grid gap-4 md:grid-cols-3\",", - " \"body\": [", + " \"children\": [", " {", " \"type\": \"card\",", " \"title\": \"${2:Metric 1}\",", - " \"body\": {", + " \"children\": {", " \"type\": \"text\",", " \"content\": \"${3:1,234}\",", " \"className\": \"text-2xl font-bold\"", @@ -169,7 +169,7 @@ "{", " \"type\": \"div\",", " \"className\": \"${1|container mx-auto,max-w-2xl mx-auto,max-w-4xl mx-auto,max-w-6xl mx-auto|} ${2:p-6}\",", - " \"body\": [", + " \"children\": [", " $0", " ]", "}" @@ -182,7 +182,7 @@ "{", " \"type\": \"div\",", " \"className\": \"flex ${1|flex-row,flex-col|} ${2|items-center,items-start,items-end|} ${3|justify-between,justify-center,justify-start,justify-end|} gap-${4:4}\",", - " \"body\": [", + " \"children\": [", " $0", " ]", "}" diff --git a/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts b/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts index 10bf22d5af..8a3f662cdd 100644 --- a/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts +++ b/packages/vscode-extension/src/__tests__/body-dialect-children-arm-7181.test.ts @@ -10,11 +10,55 @@ * objectui#7181 — the two extension-host readers honour `children`, the * spelling the rest of the platform declares. * + * ⚠️ INVERTED BY objectui#6771, AND THE INVERSION IS THE POINT — read this before + * reading a red in this file as a regression. + * + * objectui#7181 added the `children` arm and deliberately KEPT `body`, so this + * file is TWO-SIDED: every case below pins the live spelling AND the retired one. + * objectui#6771's ruling then ordered the second arm dropped — verbatim, "the + * wider `children || body` readers drop the `body` arm in the same change" — and + * the two sources this file pins deferred that drop TO THAT CARD BY NUMBER, in + * their own words: `SchemaValidator.ts` "The `body` arm is deliberately KEPT: + * dropping it is objectui#6771 step 2, not this change", and `PreviewProvider.ts` + * "The body arm stays; removing it is objectui#6771 step 2, not this change." + * ⇒ the `body` legs were recording a PRE-RETIREMENT state, not guarding a live + * contract, so they are inverted rather than deleted. + * + * ⭐ AND THE REFUSAL MUST NOT GO QUIET — which took two goes to get right, so the + * correction is recorded rather than smoothed over. + * + * The first answer to that objection cited the BUILT CLI: same document both + * spellings, `body` exits 1 with `Unrecognized key(s) on this node: body. Did you + * mean body → children?` at `Path: body`, `children` exits 0. That reading is + * TRUE and it is about the WRONG TIER. It measures `@object-ui/types`' zod mirror + * through `@object-ui/cli`. ⛔ This file pins neither: the extension host imports + * no zod, builds no manifest, and its own JSON schema sets + * `additionalProperties: true` (`schemas/objectui-schema.json`). ⇒ dropping the + * `body` arm here, on its own, took a document that drew one diagnostic down to + * ZERO — a refusal genuinely going quiet, in the one tool whose job is teaching + * the format. + * + * So the arm did not just drop. `SchemaValidator` answers the retired key BY + * NAME, and the leg below is a POSITIVE assertion about that answer rather than + * an assertion that nothing happens. ⚠️ A measurement taken on one tier is not + * evidence about another, however true it is — that is the whole of the lesson + * here, and it is why the leg names the message it expects. + * + * ⛔ Every inverted leg keeps its ABSOLUTE numbers rather than comparing the two + * spellings to each other. ⚠️ That is NOT the same as "a reader that broke + * ENTIRELY satisfies neither half", which this header claimed until ablation + * falsified it. What the legs do and do not catch is set out under "What these + * pins actually catch" below, measured in both directions — and that reading, + * not the symmetry, is why the numbers are spelled out. + * * ## The defect these pins were built for * * `children` is declared on four faces: the TypeScript declaration and the zod * mirror in `@object-ui/types`, `validateSchema` in `@object-ui/core` (which - * reads `schema.children || schema.body`, children FIRST), and the manifest + * READ `schema.children || schema.body`, children FIRST, until objectui#6771 + * dropped that second arm — the past tense is load-bearing, because a reader + * replaying this sentence against `@object-ui/core` today finds one spelling), + * and the manifest * tier's `BASE_PROPS` in `@object-ui/sdui-parser` (which carries `children` and * does NOT carry `body`). Both readers in this package guarded their child-list * recursion on `schema.body` ALONE. @@ -52,11 +96,77 @@ * that the string `schema.children` appears would pass against a `children` arm * that is present but unreachable, which is precisely the failure being pinned. * - * ## The `body` arm is expected to keep working + * ## The `body` arm is GONE — and each pin's second half now says so + * + * ⚠️ INVERTED, and the old text is quoted rather than dropped so the reversal is + * legible: this section read "the `body` arm is expected to keep working … + * Removing the `body` arm is objectui#6771 step 2, not this change". This IS + * that change. The sentence is therefore reversed, not deleted — a + * `body`-spelled document that renders or validates as it did before is now the + * regression, and these pins are what catch it. + * + * ## ⭐ What these pins actually catch — MEASURED, in both directions + * + * ⚠️ This section replaces three sentences that were WRONG. They said that each + * pin still asserts BOTH spellings, that the `body` leg carries zero beside its + * twin, and that a reader which broke entirely satisfies neither half. The first + * two are contradicted by this file's own contents; the third by ablation. ⛔ The + * pins were NOT widened to rescue the prose — the prose was re-derived from what + * the pins already do. + * + * ⭐ Not every pin is two-sided. Of the EIGHT behavioural pins, THREE assert both + * spellings — `card`, the generic container (`div`) and the bare-node arity. + * THREE assert `children` alone: nested descent, the validator's `children` + * recursion, and the well-formed-tree pin, whose `body` half objectui#6771 + * DELETED as non-discriminating (its own comment says so). TWO assert `body` + * alone: the diagnostic-by-name pin and the `record:alert` carve-out. + * + * ⭐ Nor do the `body` legs carry the same number. `card`'s asserts ONE, not + * zero, because the title element survives when the child list does not. `div`'s + * and the bare node's assert zero. That difference turns out to be the whole of + * what follows. + * + * Two ablations on `PreviewProvider.ts`, each mutated ON DISK and restored to the + * HEAD blob: * - * Each pin asserts BOTH spellings. Removing the `body` arm is objectui#6771 - * step 2, not this change, so a `body`-spelled document rendering or validating - * differently than before is a regression these pins must also catch. + * COLLAPSE — an early `return element;` after `document.createElement`, so + * nothing is appended under EITHER spelling. Every `children` leg reds, and so + * does `card`'s `body` leg, because 0 is not the 1 it asserts. ⚠️ But `div`'s + * and the bare node's `body` legs are SATISFIED: a reader that renders nothing + * produces exactly the zero they demand. + * + * RESURRECTION — `schema.children || schema.body` restored in both branches. + * Every `children` leg holds, and EXACTLY the three `body` legs red. + * + * ⇒ THE READING: a zero-valued `body` leg catches the retired arm COMING BACK, + * ⛔ not the reader falling over. What catches a collapse is the `children` leg + * beside it — which is why the pair is kept together even though the two halves + * answer different questions. ⭐ And `card`'s `body` leg, alone among the three, + * reds in BOTH directions, because it asserts a NON-ZERO absolute. Where the + * shape affords a non-zero number, that is the leg worth having. + * + * ⭐ For the validator the `body` leg is POSITIVE rather than silent — it asserts + * the diagnostic that NAMES `children` — because dropping the arm without that + * assertion took this host from one diagnostic to zero, the correction recorded + * at the top of this header. That too is measured, on `SchemaValidator.ts`: + * + * SILENCE — the retirement push gated off. The diagnostic-by-name pin reds, + * AND so does the carve-out pin's twin leg, which exists to prove the carve-out + * is a carve-out rather than a validator that stopped working. + * + * RESURRECTION — the recursion given its `body` arm back. The "does not descend + * it" leg reds, on the skipped child's own diagnostic surfacing. + * + * ⇒ the validator's two `body` legs DO catch the refusal going quiet, which the + * preview's zero-valued legs cannot do for the preview. ⛔ Do not carry either + * reading over to the other reader: they were measured separately and they + * differ. + * + * ⛔ One exception, and it has its own leg: a node type that declares its own + * `body` input is not writing the retired spelling. `record:alert` is the single + * such registration in the tree, it is carved out of the diagnostic, and the leg + * that pins the carve-out asserts both points — `record:alert` silent, `card` + * still warned. */ import { readFileSync } from 'node:fs'; @@ -149,11 +259,11 @@ function loadShippedPreviewRenderer(): RendererFn { )(document) as RendererFn; } -describe('objectui#7181 — the VS Code preview renders `children`, not only `body`', () => { +describe('the VS Code preview renders `children` — and, since objectui#6771, only `children`', () => { const render = loadShippedPreviewRenderer(); const child = { type: 'text', content: 'Hello' }; - it('renders a `card` node the same whichever child-list spelling it uses', () => { + it('renders a `card` node\'s `children`, and draws nothing for the retired `body`', () => { const viaBody = countDescendants( render({ type: 'card', title: 'T', body: [child] }) ); @@ -161,20 +271,35 @@ describe('objectui#7181 — the VS Code preview renders `children`, not only `bo render({ type: 'card', title: 'T', children: [child] }) ); - // The title element plus the one rendered child. Asserted absolutely, so a - // renderer that dropped BOTH spellings could not satisfy this by symmetry. - expect(viaBody).toBe(2); + // Both halves ABSOLUTE, exactly as before the inversion: the title element + // plus the one rendered child under the live spelling, and the title ALONE + // under the retired one. A renderer that dropped both could satisfy neither, + // which is why the numbers are spelled out rather than compared. + // + // ⭐ AND THIS PIN IS THE EXCEPTION — measured, see "What these pins actually + // catch". Because the title survives, this `body` leg asserts ONE rather than + // zero, so it reds under BOTH ablations: the collapse (0, not 1) and the + // resurrection (2, not 1). ⛔ The sibling `div` and bare-node `body` legs do + // NOT share that reach — their zero is satisfied by a reader that renders + // nothing — so this sentence is about THIS pin and ⛔ does not generalise. expect(viaChildren).toBe(2); + expect(viaBody).toBe(1); }); - it('renders a generic container node the same whichever spelling it uses', () => { + it('renders a generic container node\'s `children`, and nothing for the retired `body`', () => { const viaBody = countDescendants(render({ type: 'div', body: [child] })); const viaChildren = countDescendants( render({ type: 'div', children: [child] }) ); - expect(viaBody).toBe(1); + // ⚠️ The reach of these two legs is NOT the same, and the difference is + // measured. `viaChildren` is what catches a renderer that stopped appending: + // under the collapse ablation it reds. `viaBody` does NOT — a renderer that + // renders nothing produces the 0 asserted here and the leg passes. What it + // catches is the retired arm being RESURRECTED, which reds it at 1. ⛔ Read + // it as a collapse guard and you are reading a guard that is not there. expect(viaChildren).toBe(1); + expect(viaBody).toBe(0); }); it('renders nested `children` all the way down', () => { @@ -186,9 +311,15 @@ describe('objectui#7181 — the VS Code preview renders `children`, not only `bo expect(countDescendants(render(tree))).toBe(3); }); - it('still honours a single non-array child node under either spelling', () => { - expect(countDescendants(render({ type: 'div', body: child }))).toBe(1); + it('still honours a single non-array child node — under the one spelling left', () => { + // The bare-node ARITY is what this case is about and it is untouched by the + // retirement; only which key carries it moved. + // + // ⚠️ Same asymmetry as the `div` pin above, same measurement: the `children` + // leg catches a collapse, the `body` leg catches a resurrection and ⛔ not a + // collapse — its 0 is satisfied by a reader that renders nothing at all. expect(countDescendants(render({ type: 'div', children: child }))).toBe(1); + expect(countDescendants(render({ type: 'div', body: child }))).toBe(0); }); }); @@ -280,7 +411,7 @@ function validateWithShippedValidator(schema: unknown): CapturedDiagnostic[] { return published; } -describe('objectui#7181 — the VS Code validator recurses into `children`, not only `body`', () => { +describe('the VS Code validator recurses into `children` — and, since objectui#6771, only `children`', () => { // A child missing its required `type` is the observable: the validator warns // about it ONLY if the recursion actually reached that child. const untypedChild = { className: 'p-4' }; @@ -296,25 +427,120 @@ describe('objectui#7181 — the VS Code validator recurses into `children`, not ); }); - it('still reaches a child spelled `body` and reports its missing `type`', () => { + it('ANSWERS a child list spelled `body` by name, and does not descend it', () => { + // ⛔ INVERTED, NOT DELETED, and ⛔ not weakened into a non-assertion. Both + // halves are positive: the retired key draws a diagnostic that NAMES the + // replacement, and the recursion does not walk into it (so the child's own + // problem is not reported at an address the document does not contain). + // + // ⚠️ The first half is the one that matters, and it is here because dropping + // the arm WITHOUT it took this host from one diagnostic to zero — see the + // header. A `not.toContain` alone would have been satisfied by exactly that + // silence. const diagnostics = validateWithShippedValidator({ type: 'card', body: [untypedChild], }); - expect(diagnostics.map((d) => d.message)).toContain( + expect( + diagnostics.some( + (d) => d.message.includes('"body"') && d.message.includes('"children"') + ), + 'the retired spelling drew no diagnostic naming its replacement' + ).toBe(true); + expect(diagnostics.map((d) => d.message)).not.toContain( 'Missing required property "type"' ); }); - it('reports nothing for a well-formed tree under either spelling', () => { + it('does NOT warn on a node type that declares its own `body` input', () => { + // ⭐ THE PIN THE CARVE-OUT DID NOT HAVE. `TYPES_DECLARING_OWN_BODY` was added + // to close a false positive, published in the changeset as behaviour of a + // released package, and then nothing could fail on it: emptying the set to + // `new Set([])` left this file passing every leg. A behaviour a test cannot + // break is a behaviour nobody is holding. + // + // `record:alert` is the real subject — the one registration in the tree that + // declares an input named `body`, whose value is an inline translation map + // rather than a child list. Warning about it would be the false diagnostic + // objectui#6771 exists to remove, reintroduced one host over. + const carvedOut = validateWithShippedValidator({ + type: 'record:alert', + body: 'Updated the deal stage to Negotiation.', + }); + expect( + carvedOut.some((d) => d.message.includes('"body"')), + '`record:alert` drew a retirement warning for its own declared `body`' + ).toBe(false); + + // THE TWIN, and it is what makes the line above a reading rather than a + // validator that stopped working: the same key on a type that does NOT + // declare it still draws the warning, naming the replacement. + const notCarvedOut = validateWithShippedValidator({ + type: 'card', + body: 'Updated the deal stage to Negotiation.', + }); + expect( + notCarvedOut.some( + (d) => d.message.includes('"body"') && d.message.includes('"children"') + ), + 'the retirement warning stopped firing for types outside the carve-out' + ).toBe(true); + }); + + it('reports nothing for a well-formed tree under the live spelling', () => { + // The `body` half of this pair was never discriminating — a well-formed tree + // draws no diagnostic whether the recursion walks it or skips it — so it is + // dropped rather than inverted into an assertion that cannot fail. const typedChild = { type: 'text', content: 'Hello' }; expect( validateWithShippedValidator({ type: 'card', children: [typedChild] }) ).toHaveLength(0); - expect( - validateWithShippedValidator({ type: 'card', body: [typedChild] }) - ).toHaveLength(0); + }); +}); + +/* ------------------------------------------------------------------ * + * The two spellings of one constant * + * ------------------------------------------------------------------ */ + +/** + * `SchemaValidator.ts` says of its local `RETIRED_CHILD_LIST_KEY` that this file + * "pins the two together" with `sdui-parser`'s export. That sentence was written + * before the pin existed — a claim about a check that was not there, which is the + * class this card keeps meeting. This block is the check. + * + * ⛔ Read off SOURCE TEXT, not by importing: this package ships to the extension + * host with no `@object-ui/*` runtime dependency, and importing `sdui-parser` + * here to satisfy a pin would put a dependency in the tree that the product does + * not have. Both literals are read from disk instead, rooted at THIS FILE the way + * `check-test-path-roots.mjs` requires. + */ +describe('the two spellings of the retired key cannot drift (objectui#6771)', () => { + const literalOf = (source: string, constName: string): string => { + const m = new RegExp(`${constName}\\s*(?::[^=]*)?=\\s*'([^']+)'`).exec(source); + expect(m, `\`${constName}\` is not declared as a single-quoted literal any more`).toBeTruthy(); + return m![1]; + }; + + it('the extension and `sdui-parser` name the same key', () => { + const parserSource = readFileSync( + resolve(HERE, '../../../sdui-parser/src/body-dialect.ts'), + 'utf8' + ); + + const extension = literalOf(VALIDATOR_SOURCE, 'const RETIRED_CHILD_LIST_KEY'); + const parser = literalOf(parserSource, 'export const RETIRED_CHILD_LIST_KEY'); + + // Asserted ABSOLUTELY on both sides, not just as equal to each other: two + // constants that drifted to the same WRONG value would satisfy equality. + expect(extension).toBe('body'); + expect(parser).toBe('body'); + expect(extension).toBe(parser); + }); + + it('the diagnostic the extension pushes names the replacement', () => { + // The constant is only half of it — the message is what an author reads. + expect(VALIDATOR_SOURCE).toContain('"children"'); }); }); diff --git a/packages/vscode-extension/src/providers/CompletionProvider.ts b/packages/vscode-extension/src/providers/CompletionProvider.ts index 9052ee0cbc..8055749626 100644 --- a/packages/vscode-extension/src/providers/CompletionProvider.ts +++ b/packages/vscode-extension/src/providers/CompletionProvider.ts @@ -105,7 +105,7 @@ export class CompletionProvider card: [ { name: 'title', desc: 'Card title' }, { name: 'description', desc: 'Card description' }, - { name: 'body', desc: 'Card content' }, + { name: 'children', desc: 'Card content' }, ], text: [{ name: 'content', desc: 'Text content' }], }; @@ -130,7 +130,7 @@ export class CompletionProvider const common = [ { name: 'type', desc: 'Component type' }, { name: 'className', desc: 'Tailwind CSS classes' }, - { name: 'body', desc: 'Child components or content' }, + { name: 'children', desc: 'Child components or content' }, { name: 'id', desc: 'Component ID' }, { name: 'visible', desc: 'Visibility condition' }, ]; @@ -142,8 +142,8 @@ export class CompletionProvider ); item.detail = prop.desc; - if (prop.name === 'body') { - item.insertText = new vscode.SnippetString('"body": {\n $0\n}'); + if (prop.name === 'children') { + item.insertText = new vscode.SnippetString('"children": {\n $0\n}'); } else if (prop.name === 'className') { item.insertText = new vscode.SnippetString('"className": "$1"'); } else { diff --git a/packages/vscode-extension/src/providers/HoverProvider.ts b/packages/vscode-extension/src/providers/HoverProvider.ts index 96d98ead3d..c095c9e5d2 100644 --- a/packages/vscode-extension/src/providers/HoverProvider.ts +++ b/packages/vscode-extension/src/providers/HoverProvider.ts @@ -69,7 +69,7 @@ Example: [Tailwind CSS Documentation](https://tailwindcss.com)`, - body: `**body** (object | array) + children: `**children** (object | array) Child components or content. @@ -80,7 +80,7 @@ Can be: Example: \`\`\`json -"body": [ +"children": [ { "type": "text", "content": "Hello" }, { "type": "button", "label": "Click me" } ] @@ -182,7 +182,7 @@ Example: { "type": "div", "className": "flex gap-4 p-6", - "body": [...] + "children": [...] } \`\`\``, @@ -196,7 +196,7 @@ Example: "type": "card", "title": "Card Title", "description": "Card description", - "body": {...} + "children": {...} } \`\`\``, diff --git a/packages/vscode-extension/src/providers/PreviewProvider.ts b/packages/vscode-extension/src/providers/PreviewProvider.ts index befde17787..1de2da0fc1 100644 --- a/packages/vscode-extension/src/providers/PreviewProvider.ts +++ b/packages/vscode-extension/src/providers/PreviewProvider.ts @@ -223,8 +223,9 @@ export class PreviewProvider { desc.textContent = schema.description; element.appendChild(desc); } - // children first, body kept -- see the container branch below. - const cardChildren = schema.children || schema.body; + // children is the one child-list spelling -- see the container + // branch below. + const cardChildren = schema.children; if (cardChildren) { const body = Array.isArray(cardChildren) ? cardChildren : [cardChildren]; body.forEach(child => { @@ -240,18 +241,18 @@ export class PreviewProvider { default: // Handle generic div/container. // - // children is read FIRST, body is kept as a second arm. This guard - // used to name schema.body alone, so a node spelling its child list - // children -- the spelling the TypeScript declaration, the zod - // mirror, core's validator and the manifest tier all bless -- - // rendered as an EMPTY element: no error, no diagnostic, just a - // blank preview (objectui#7181). The body arm stays; removing it is - // objectui#6771 step 2, not this change. + // children is the one child-list spelling. This guard used to name + // schema.body alone, so a node spelling its child list children -- + // the spelling the TypeScript declaration, the zod mirror, core's + // validator and the manifest tier all bless -- rendered as an EMPTY + // element: no error, no diagnostic, just a blank preview + // (objectui#7181). objectui#6771 retired body, so the second arm is + // gone and the preview shows what the runtime will show. // // NOTE: this comment carries no backticks on purpose. It lives // INSIDE the webview's template literal, where an unescaped backtick // ends the string and the file stops parsing. - const containerChildren = schema.children || schema.body; + const containerChildren = schema.children; if (containerChildren) { const body = Array.isArray(containerChildren) ? containerChildren : [containerChildren]; body.forEach(child => { diff --git a/packages/vscode-extension/src/providers/SchemaValidator.ts b/packages/vscode-extension/src/providers/SchemaValidator.ts index 9fc641b8c4..73e2be4763 100644 --- a/packages/vscode-extension/src/providers/SchemaValidator.ts +++ b/packages/vscode-extension/src/providers/SchemaValidator.ts @@ -8,6 +8,41 @@ import * as vscode from 'vscode'; +/** + * The child-list spelling objectui#6771 retired. Spelled here rather than + * imported: this package ships to the extension host with no `@object-ui/*` + * runtime dependency, which is the same reason it has no zod tier to lean on. + * `sdui-parser` exports the same constant as `RETIRED_CHILD_LIST_KEY`, and + * `__tests__/body-dialect-children-arm-7181.test.ts` pins the two together by + * reading both literals off disk — see its `the two spellings cannot drift` + * case. ⛔ Do not change this string without that test going red. + */ +const RETIRED_CHILD_LIST_KEY = 'body'; + +/** + * Node types that declare `body` as their OWN input, where the key is not the + * retired child-list spelling and a retirement warning would be a FALSE + * POSITIVE. + * + * Measured, not guessed — grep the registrations under every package `src` + * directory for a declared input named `body`, excluding tests. ⛔ The glob is + * spelled in words on purpose: written literally it contains the two characters + * that END a block comment, which silently truncates this docblock and leaves the + * rest of the file as stray tokens — caught here by the pin that evaluates this + * very source. The only survivor after objectui#6771 moved + * `tooltip` and `page` to `children` is `record:alert`, whose `body` is a text + * field taking an inline translation map (`plugin-detail`, registered under the + * `record` namespace). This is the same carve-out the parser tier gets for free + * by asking inside its `!input` branch; this host has no manifest, so the set is + * spelled out. + * + * ⚠️ A SNAPSHOT WITH NO GATE BEHIND IT. Nothing re-derives this set, so a + * registration that starts declaring `body` will draw a false warning here until + * someone adds it. That is stated rather than left to be discovered, and it is + * the reason the set is kept to what was measured rather than widened on a guess. + */ +const TYPES_DECLARING_OWN_BODY: ReadonlySet = new Set(['record:alert']); + /** * Validates Object UI schemas */ @@ -98,34 +133,59 @@ export class SchemaValidator { this.validateTypeSpecificProps(schema, diagnostics, document, path); } - // Recursively validate children. - // - // `children` is read FIRST and `body` is kept as a second arm, matching - // `schema.children || schema.body` in `@object-ui/core`'s `validateSchema` - // and in the `div` / `card` renderers (objectui#7181). + // The retired `body` child-list spelling, answered BY NAME. // - // This guard used to name `schema.body` alone, which made the extension a - // `body`-ONLY reader while the TypeScript declaration, the zod mirror, - // core's validator and the manifest tier all declare `children`. An author - // who wrote the declared spelling therefore had every child silently - // skipped by this recursion — no diagnostic, no refusal, just an unvisited - // subtree — while the preview rendered the same document blank. + // ⚠️ THIS DIAGNOSTIC EXISTS BECAUSE THIS HOST HAS NO OTHER TIER. Everywhere + // else, dropping the `body` arm moves the answer UP a level and makes it + // louder: `@object-ui/types`' zod mirror refuses the key by name, so + // `objectui validate` exits 1 naming `children`. The extension host imports + // no zod, builds no manifest, and its JSON schema sets + // `additionalProperties: true` — so without the push below, retiring the arm + // would take a document that USED to draw a diagnostic here down to ZERO, + // which is a refusal going quiet in the one tool whose job is teaching the + // format. ⛔ That is the opposite of what objectui#6771 is for. + const retired = schema[RETIRED_CHILD_LIST_KEY]; + if (retired !== undefined && !TYPES_DECLARING_OWN_BODY.has(schema.type)) { + // The message does not over-describe the value, the same discipline + // `sdui-parser/src/body-dialect.ts` keeps: calling a scalar `body` a + // "child-list spelling" names a shape the author did not write. + const isChildList = typeof retired === 'object' && retired !== null; + diagnostics.push( + new vscode.Diagnostic( + this.findPropertyRange(document, path, RETIRED_CHILD_LIST_KEY), + isChildList + ? `"${RETIRED_CHILD_LIST_KEY}" is a retired child-list spelling — author "children" instead (objectui#6771)` + : `"${RETIRED_CHILD_LIST_KEY}" is a retired key — the child-list key is "children" (objectui#6771)`, + vscode.DiagnosticSeverity.Warning + ) + ); + } + + // The push above is GATED on `TYPES_DECLARING_OWN_BODY` rather than firing + // on every node: a `record:alert` authored with its declared translation-map + // `body` is not writing the retired spelling, and warning about it would be + // the false diagnostic objectui#6771 exists to remove, reintroduced one host + // over. ⚠️ The set is a measured snapshot with nothing re-deriving it — see + // its own docblock. + + // Recursively validate children. // - // The `body` arm is deliberately KEPT: dropping it is objectui#6771 step 2, - // not this change, and every `body`-spelled document must keep working. - const childList = schema.children || schema.body; + // `children` is the one child-list spelling. This guard once named + // `schema.body` ALONE, so an author writing the declared spelling had + // every child silently skipped by this recursion — no diagnostic, no + // refusal, just an unvisited subtree (objectui#7181 added the `children` + // arm). objectui#6771 then retired `body` itself, so the second arm went + // with it: a `body`-spelled document has no child list for this recursion + // to walk, and the push above is what keeps that from being silent. + const childList = schema.children; if (childList) { - // The diagnostic path has to name the key this document actually used, - // or a `children`-spelled node reports its problems at a `.body[...]` - // address that does not occur anywhere in the file being validated. - const childKey = schema.children ? 'children' : 'body'; const children = Array.isArray(childList) ? childList : [childList]; children.forEach((child: any, index: number) => { this.validateSchema( child, diagnostics, document, - `${path}.${childKey}[${index}]` + `${path}.children[${index}]` ); }); } diff --git a/packages/vscode-extension/syntaxes/objectui.tmLanguage.json b/packages/vscode-extension/syntaxes/objectui.tmLanguage.json index 022e1cd8bd..000bb5fccf 100644 --- a/packages/vscode-extension/syntaxes/objectui.tmLanguage.json +++ b/packages/vscode-extension/syntaxes/objectui.tmLanguage.json @@ -5,7 +5,7 @@ "include": "source.json" }, { - "match": "\"(type|className|body|label|placeholder|content|title|description)\"", + "match": "\"(type|className|children|label|placeholder|content|title|description)\"", "name": "keyword.other.objectui" } ] diff --git a/scripts/__tests__/body-dialect-census.test.ts b/scripts/__tests__/body-dialect-census.test.ts index 13bff3ef3c..86d3c9dd7f 100644 --- a/scripts/__tests__/body-dialect-census.test.ts +++ b/scripts/__tests__/body-dialect-census.test.ts @@ -44,7 +44,7 @@ import { RULED_BUT_NOT_A_READER, BODY_ONLY_UNRULED, } from '../body-dialect-census.mjs'; -import { scan, producersOf } from '../body-dialect-producer-scan.mjs'; +import { scan, producersOf, unchannelledOf } from '../body-dialect-producer-scan.mjs'; const REPO_ROOT = join(__dirname, '..', '..'); const read = (p: string) => readFileSync(join(REPO_ROOT, p), 'utf8'); @@ -131,9 +131,18 @@ describe('the scanner sees the shapes authored metadata actually uses', () => { it('CONTROL — a corpus-shaped input returns hits, so a zero elsewhere is readable', () => { // Without this, every "0" in the census is indistinguishable from a // scanner that resolved nothing at all. + // + // ⚠️ RE-POINTED at `children` by objectui#6771. This file was the densest + // `body` corpus in the tree — 14 nodes — and the retirement migrated it, so + // a `body` control here now measures the migration instead of the scanner. + // The control's job is unchanged: prove the scanner resolves NODES and + // their child key on a real corpus file, so a zero read somewhere else is a + // reading rather than a blind walk. const nodes = scanNodes(read('examples/schema-catalog/src/schemas/components-basic-sidebar/basic-sidebar.json')); expect(nodes.length).toBeGreaterThan(0); - expect(nodes.filter((n: { keys: Set }) => n.keys.has('body')).length).toBeGreaterThan(0); + expect(nodes.filter((n: { keys: Set }) => n.keys.has('children')).length).toBeGreaterThan(0); + // And the retired spelling is gone from it, which is the migration half. + expect(nodes.filter((n: { keys: Set }) => n.keys.has('body')).length).toBe(0); }); }); @@ -155,14 +164,21 @@ describe('the measured population is read off the renderers, not off the card bo expect(BODY_ONLY.filter((k) => k.startsWith('sidebar')).length).toBe(10); }); - it('`badge` and `alert` read `body` and never `children`', () => { + it('`badge` and `alert` read `children` and never `body` — CONVERGED by objectui#6771', () => { + // ⚠️ INVERTED. This pin held the ground the whole census rested on: these + // two rendered `renderChildren(schema.body)` and never touched + // `schema.children`, which is what made `body` their ONLY door and made + // retiring it a reject-direction change on published contract. The ruling + // was executed; the door is `children` now, and the reading the census + // produced is what cleared it (`badge` and `alert` carried ZERO authored + // `body` in both populations, so the published cost measured zero). for (const [file, type] of [ ['packages/components/src/renderers/data-display/badge.tsx', 'badge'], ['packages/components/src/renderers/data-display/alert.tsx', 'alert'], ] as const) { const src = read(file); - expect(src, `${type} stopped reading schema.body`).toContain('renderChildren(schema.body)'); - expect(src, `${type} now reads schema.children — it is no longer body-only`).not.toContain('schema.children'); + expect(src, `${type} does not read schema.children`).toContain('renderChildren(schema.children)'); + expect(src, `${type} still reads the retired schema.body`).not.toContain('schema.body'); } }); @@ -175,24 +191,32 @@ describe('the measured population is read off the renderers, not off the card bo // 11 `sidebar-*` registrations. The one without is `sidebar-trigger`, whose // renderer never receives `schema` at all. expect(src.match(/ComponentRegistry\.register\('sidebar/g)?.length).toBe(11); - expect(src.match(/renderChildren\(schema\.body\)/g)?.length).toBe(10); + // Spelled `children` since objectui#6771; the COUNT is the load-bearing + // half and it did not move — ten reads across eleven registrations. + expect(src.match(/renderChildren\(schema\.children\)/g)?.length).toBe(10); + expect(src.match(/renderChildren\(schema\.body\)/g)?.length ?? 0).toBe(0); const trigger = src.slice(src.indexOf("register('sidebar-trigger'")); expect(trigger).not.toContain('schema.body'); expect(trigger).not.toContain('schema.children'); }); - it('⚠️ `tooltip` is a `body`-only reader that the ruled 13 does NOT include', () => { - // Measured, and it matters: `tooltip` renders `renderChildren(schema.body)` - // and never `schema.children`, so direction B removes its only rich-content - // key too — yet it is absent from the ruling's step 2 list. It is also the - // ONE registration in the tree that DECLARES `body` as an authorable input, - // which makes it the most discoverable spelling of `body` on the whole - // authoring surface. + it('⚠️ `tooltip` was the `body`-only reader the ruled 13 omitted — and it converged with the rest', () => { + // ⚠️ INVERTED, and this one was a SCOPE call before it was an edit. The + // census measured `tooltip` rendering `renderChildren(schema.body)` and + // never `schema.children` — a `body`-only reader absent from the ruling's + // step 2 list — and, decisively, the ONE registration in the tree that + // DECLARED `body` as an authorable input, which made it the most + // discoverable spelling of the dialect on the whole authoring surface. + // Seat 1 read the ruling rather than filing for it (2026-09-17) and triage + // recorded the answer as already given: step 2's two clauses leave no third + // class, so `tooltip` is IN. It is, and its declared input moved too — the + // half that would otherwise have left the dialect advertised. expect(BODY_ONLY_UNRULED).toEqual(['tooltip']); const src = read('packages/components/src/renderers/overlay/tooltip.tsx'); - expect(src).toContain('renderChildren(schema.body)'); - expect(src).not.toContain('schema.children'); - expect(src).toMatch(/name:\s*'body'/); + expect(src).toContain('renderChildren(schema.children)'); + expect(src).not.toContain('schema.body'); + expect(src).toMatch(/name:\s*'children'/); + expect(src).not.toMatch(/name:\s*'body'/); }); it('the two published keys are exactly `badge` and `alert`', () => { @@ -223,7 +247,7 @@ describe('the census stays re-runnable', () => { }); describe('the `body` consumers the ruling does not enumerate', () => { - it('three generic readers outside the renderer tree still resolve `body`', () => { + it('three generic readers outside the renderer tree read `children` ALONE — step 2 reached them', () => { // Recorded because ruling steps 2-5 name renderers, the published type and // the tier — and these three are none of those. They read `body` for ANY // node type, so they outlive the per-registration convergence and would @@ -238,15 +262,26 @@ describe('the `body` consumers the ruling does not enumerate', () => { // objectui#7181 converged both onto the core spelling, adding the // `children` arm and KEEPING `body`. Nothing was retired; that is step 2. // - // ⇒ all three now read the same way, which is what these three assertions - // pin. The `body` arm surviving in each is the half that says step 2 is - // still outstanding. + // ⇒ all three then read the same way, and the `body` arm surviving in each + // was the half that said step 2 was still outstanding. + // + // ⚠️ INVERTED — step 2 landed (objectui#6771). These three are the reason + // the block exists: a per-registration convergence would have left them + // resolving `body` for ANY node type and the dialect alive underneath it, + // so the retirement is only real if all three dropped the arm. Both vscode + // readers even deferred it to that card BY NUMBER in their own comments. + // The lit control stays FIRST, because "does not contain `body`" holds just + // as well over a file that was renamed, emptied or moved. for (const reader of [ 'packages/core/src/validation/schema-validator.ts', 'packages/vscode-extension/src/providers/SchemaValidator.ts', 'packages/vscode-extension/src/providers/PreviewProvider.ts', ]) { - expect(readCode(reader), reader).toContain('schema.children || schema.body'); + const text = readCode(reader); + expect(text, `${reader} — lit control: this reader still resolves a child list`) + .toMatch(/=\s*schema\.children;/); + expect(text, `${reader} still resolves the retired \`body\``) + .not.toContain('schema.children || schema.body'); } }); @@ -311,11 +346,17 @@ describe('the `body` consumers the ruling does not enumerate', () => { // ⛔ That zero is NOT the answer to "is step 4 landable", and reading it as // one is the trap this half exists to close. The census states its own - // limits and TWO of them hide a producer: it scores a child list only on an - // object that also carries a string-LITERAL `type` (a `tabs` ITEM carries + // limits and TWO of them CAN hide a producer: it scores a child list only on + // an object that also carries a string-LITERAL `type` (a `tabs` ITEM carries // `label`/`value`/`body` and no `type`), and it never reads inside a string // or a template literal (a VS Code completion snippet is a string). // + // ⚠️ PAST-TENSED where objectui#9871 wrote that both still DO. Only the + // first still hides a live producer. The second's only subjects were the + // `CompletionProvider` snippets, and objectui#6771 step 5 migrated them — + // which is the B2 inversion below, measured rather than assumed. The limit + // itself is unchanged and still structural; what emptied is its occupancy. + // // ⭐ objectui#9871 REPLACED the enumeration that used to stand here. Two // named sites were an enumeration with the census's blind spots written // into it by hand — the next producer in a third blind direction would have @@ -324,8 +365,17 @@ describe('the `body` consumers the ruling does not enumerate', () => { // · C2 resolution · C3 carrier) and this block reads its table. // // ⭐ When the table empties, THAT is the handoff, and the message on the - // assertion says so in these terms — it is the day step 4 becomes landable. - // ⛔ Do not delete the claim to make the block green. + // assertion says so in these terms. ⚠️ CORRECTED where objectui#9871 wrote + // "the day step 4 becomes landable": step 4 lands in objectui#6771 with this + // table NON-empty, and measurement is why. The tier refuses `body` inside + // `Object.entries(node)` on a node whose `type` resolves to a registration; + // every site left in the table is carried on a `tabs` ITEM, which is the + // value of a declared `items` input and never a node in that walk. Probed + // rather than reasoned, both legs, against the built parser: a `card` node + // carrying `body` draws `unknown-prop: has no prop "body" — the + // child-list key is "children"`, and a `tabs` item carrying the same key + // draws ZERO diagnostics. ⇒ an empty table is objectui#9590's finish line, + // ⛔ not step 4's gate. ⛔ Do not delete the claim to make the block green. const producerScan = scan(REPO_ROOT); // Two lit controls, because an empty table is the assertion's own shape: a @@ -342,7 +392,9 @@ describe('the `body` consumers the ruling does not enumerate', () => { expect( producers.length, 'the producer table is EMPTY — under objectui#9871\'s criterion nothing in shipped source ' + - 'emits the dialect any more, which is the day objectui#6771 step 4 becomes landable. ' + + 'emits the dialect any more. ⚠️ That is objectui#9590\'s finish line, ⛔ NOT step 4\'s gate: ' + + 'step 4 landed in objectui#6771 with this table non-empty, because everything left in it ' + + 'is item-carried and outside the ruled family. ' + 'Re-point this block and say so in those terms; ⛔ do not delete the claim.' ).toBeGreaterThan(0); @@ -353,15 +405,85 @@ describe('the `body` consumers the ruling does not enumerate', () => { producers.filter((hit: { carrier: string }) => hit.carrier === 'item').length, 'no item-carried producer — the B1 shape (a `body` on an object with no `type`)' ).toBeGreaterThan(0); + // ⚠️ B2 IS INVERTED BY THIS CARD, and the inversion is what the merge with + // `main` actually found rather than a tidy-up. objectui#9871 asserted a + // string-carried producer EXISTED, and its only subjects were the three VS + // Code completion snippets in `CompletionProvider.ts` — which objectui#6771 + // step 5 migrated. The two changes were written without knowledge of each + // other and landed on DISJOINT files, so the text merged clean and this + // assertion is the one place they actually meet. ⛔ Not deleted, per + // objectui#9871's own instruction: re-pointed, with the reason named. + // + // ⭐ THE ZERO NEEDS A LIT CONTROL OR IT IS A BLIND WALK, and the control is + // in TWO parts, because `source !== 'code-key'` is a conjunction of two + // independent capabilities and a single control would leave one untested: + // - B2 reads inside literals AT ALL — `scanSource`'s literal projection + // still resolves the key in strings and templates somewhere in the tree; + // - a literal-carried hit still PASSES C2 — some derived reader resolves + // it into a child list, so the zero below is about C1 EMISSION alone. + // Without both, `toBe(0)` would hold just as well over a scan that had gone + // blind to literals — the exact failure objectui#9871 built B2 to end. + const literalCarried = producerScan.hits.filter( + (hit: { source: string }) => hit.source !== 'code-key' + ); expect( - producers.filter((hit: { source: string }) => hit.source !== 'code-key').length, - 'no string-carried producer — the B2 shape (the key spelled inside a literal)' + literalCarried.length, + 'lit control — the scan resolved NO literal-carried `body` anywhere, so B2 has gone blind ' + + 'and the zero below would be measuring the instrument rather than the tree' ).toBeGreaterThan(0); + expect( + unchannelledOf(producerScan.hits, producerScan.readers).filter( + (hit: { source: string }) => hit.source !== 'code-key' + ).length, + 'lit control — no literal-carried hit passes C2 any more, so the zero below would be ' + + 'measuring C2 rather than the emission channel it claims to measure' + ).toBeGreaterThan(0); + + // ⇒ AND THE READING ITSELF, with both controls lit: no literal-carried site + // reaches an emission channel. The B2 shape is REACHABLE and UNOCCUPIED — + // which is a different sentence from the one objectui#9871 could write, and + // only this merge could have produced it. + expect( + producers.filter((hit: { source: string }) => hit.source !== 'code-key').length, + 'a string-carried producer is BACK — objectui#6771 step 5 migrated the last of them ' + + '(the `CompletionProvider` snippets). A new one means a tool started writing the retired ' + + 'spelling into an author document again; ⛔ do not relax this to a range.' + ).toBe(0); + + // ⭐ THE SUBJECT THAT ZERO IS ABOUT, pinned BY NAME — because a shape + // assertion cannot say WHICH site left, and naming it is the handoff + // objectui#9871's prose asks for. A completion snippet is the most direct + // form a producer takes: it is inserted into the author's own document the + // moment the completion is accepted, so the author never types the spelling. + // The lit control stays and stays FIRST: "does not contain" is satisfied by + // a file that was renamed, emptied or moved. + const completion = readCode('packages/vscode-extension/src/providers/CompletionProvider.ts'); + expect(completion, 'lit control — the snippet table is being read at all').toContain( + '"className": "$1"' + ); + expect(completion).not.toContain('"body": {'); + expect(completion).toContain('"children": {'); // ⚠️ And the family question stays OPEN in the instrument rather than being // settled by it: an item-carried `body` is filed `unruled` and is never // folded into objectui#6771's ruled total. objectui#9871 handed that // question back rather than extending a ruled family from a dev seat. + // + // ⭐ AND THE CLAIM IN THIS TEST'S NAME, restated in the terms BOTH + // instruments now agree on — which is the other half of what this merge + // found. What still ships the spelling is the `tabs` ITEM, and that is a + // DIFFERENT KEY from the one step 4 refuses (measured above the scan call, + // both legs). objectui#9871 reaches the same disposition from its own side + // and files those sites `unruled:item-carrier` — ⛔ not objectui#6771's ruled + // family. ⇒ the consumer-side retirement and the producer-side scan do not + // contradict each other on the substance; they disagreed on ONE liveness + // assertion, B2's, and that is re-pointed above rather than deleted. + // ⇒ step 4 refuses nothing the platform still ships, which is exactly what + // step 5's ordering rule asks, and the item-level dialect is objectui#9590's + // card, ⛔ neither refused nor migrated here. ⚠️ That card's BODY names only + // `list`'s `items[].body`; `tabs` items and the `dashboard` widget key are + // recorded on it by comment 5733850974, so this pointer resolves to a record + // that actually carries the two shapes named here. for (const hit of producers.filter((h: { carrier: string }) => h.carrier === 'item')) { expect(hit.disposition).toBe('unruled:item-carrier'); } diff --git a/scripts/__tests__/body-dialect-producer-scan.test.ts b/scripts/__tests__/body-dialect-producer-scan.test.ts index bc4ebea62c..00ed54e894 100644 --- a/scripts/__tests__/body-dialect-producer-scan.test.ts +++ b/scripts/__tests__/body-dialect-producer-scan.test.ts @@ -346,8 +346,10 @@ export const ListSchema = z.object({}).describe( expect(verdict.refusal?.join(' ')).toContain('Zero readers derived'); // Lit control, and the point of the whole card: a real ZERO over a real - // corpus with a real reader is a LEGITIMATE reading and is NOT refused — - // it is the day objectui#6771 step 4 becomes landable. + // corpus with a real reader is a LEGITIMATE reading and is NOT refused. + // ⚠️ It is NOT "the day objectui#6771 step 4 becomes landable", as first + // written — step 4 landed with the live table non-empty and item-carried. + // A zero is objectui#9590's finish line; see the LIVE-tree block below. const clean = scan(plant({})); expect(producersOf(clean.hits, clean.readers)).toEqual([]); expect(finalVerdict({ filesScanned: clean.filesScanned, readers: clean.readers }).exit).toBe(0); @@ -552,17 +554,61 @@ describe('the LIVE tree, read through the criterion', () => { // producer arriving in a file nobody named appears in `producers` without // anyone extending anything, which is the whole point of this card. expect(producers.filter((p: { carrier: string }) => p.carrier === 'item').length).toBeGreaterThan(0); - expect(producers.filter((p: { source: string }) => p.source !== 'code-key').length).toBeGreaterThan(0); expect(files.size).toBeGreaterThan(0); - // ⭐ And the claim that outlives every one of them: while the table is - // non-empty, objectui#6771 step 4 is not landable. ⛔ When this reds - // because the table emptied, that is the HANDOFF — say so here in those - // terms rather than deleting the assertion. + // ⚠️ B2 INVERTED BY objectui#6771, and this is the SECOND copy of the claim + // — the census block carries the first. ⭐ Neither file was in the merge's + // text intersection with this branch, which is the lesson: a one-file + // overlap said the text merged, and the two scans still met HERE. + // objectui#9871 asserted a string-carried producer EXISTED; its only + // subjects were the three `CompletionProvider` snippets, which step 5 + // migrated. ⛔ Not deleted, per this file's own instruction: re-pointed. + // + // ⭐ The zero carries the same two lit controls as the census copy, because + // `source !== 'code-key'` is a conjunction and one control would leave half + // of it untested: B2 still reads inside literals AT ALL, and a + // literal-carried hit still PASSES C2. Without both, `toBe(0)` would hold + // over a scan gone blind to literals — the failure B2 exists to end. + expect( + run.hits.filter((h: { source: string }) => h.source !== 'code-key').length, + 'lit control — no literal-carried `body` resolved anywhere, so B2 has gone blind', + ).toBeGreaterThan(0); + expect( + unchannelledOf(run.hits, run.readers).filter( + (h: { source: string }) => h.source !== 'code-key', + ).length, + 'lit control — no literal-carried hit passes C2, so the zero below would measure C2 ' + + 'rather than the emission channel it claims to measure', + ).toBeGreaterThan(0); + expect( + producers.filter((p: { source: string }) => p.source !== 'code-key').length, + 'a string-carried producer is BACK — objectui#6771 step 5 migrated the last of them. ' + + 'A new one means a tool started writing the retired spelling into an author document ' + + 'again; ⛔ do not relax this to a range.', + ).toBe(0); + + // ⭐ And the claim that outlives every one of them — ⚠️ CORRECTED, because + // objectui#9871 wrote it as "while the table is non-empty, objectui#6771 + // step 4 is not landable" and that blanket does not survive measurement. + // This file files every surviving producer `unruled:item-carrier` and says + // in as many words that whether an item-carried `body` belongs to + // objectui#6771 is UNRULED — so using the same rows to block that card's + // step 4 reads the question back as an answer. + // + // Probed rather than reasoned, both legs, against the built parser: the + // tier refuses `body` inside `Object.entries(node)` on a node whose `type` + // resolves to a registration, so a `card` NODE carrying `body` draws + // `unknown-prop: has no prop "body" — the child-list key is + // "children"`, and a `tabs` ITEM carrying the same key draws ZERO + // diagnostics. ⇒ step 4 refuses nothing the platform still ships. + // An empty table is objectui#9590's finish line, ⛔ not step 4's gate. + // ⛔ When this reds because the table emptied, that is still the HANDOFF — + // say so here in those terms rather than deleting the assertion. expect( producers.length, - 'the producer table is EMPTY — under this criterion nothing ships the dialect, which is ' + - 'the day objectui#6771 step 4 becomes landable. Re-point this block, do not delete it.', + 'the producer table is EMPTY — under this criterion nothing ships the dialect. That is ' + + 'objectui#9590\'s finish line, ⛔ NOT objectui#6771 step 4\'s gate: step 4 landed with ' + + 'this table non-empty and item-carried. Re-point this block, do not delete it.', ).toBeGreaterThan(0); // ⏱ Explicit — one tree-wide scan over 5,136 files, measured 4.0s here; the // CI shard is at least 1.9x slower (objectui#9871's timeout reading), and diff --git a/scripts/body-dialect-census.mjs b/scripts/body-dialect-census.mjs index 4a4468557d..32392eaa4b 100644 --- a/scripts/body-dialect-census.mjs +++ b/scripts/body-dialect-census.mjs @@ -82,26 +82,45 @@ import { isEntrypoint } from './invoked-as.mjs'; // ── The measured population ──────────────────────────────────────────────── // -// BODY_ONLY: registrations whose renderer reads `renderChildren(schema.body)` -// and never `schema.children`. For these, `body` is the ONLY door — retiring it -// removes the sole child-list key. +// ⚠️ THIS BLOCK IS IN THE PAST TENSE ON PURPOSE. It records the population as +// it was when step 1 of objectui#6771 built this instrument; step 2 then +// converged every registration below onto `children`. The keys are what the +// census LOOKS AT, not what it expects to find — its job now is to report that +// the reading has gone to zero, so a present-tense sentence here would be a +// claim the instrument's own output contradicts. For what is true TODAY read +// `__tests__/body-dialect-census.test.ts`, which pins the converged state. +// +// BODY_ONLY: registrations whose renderer read `renderChildren(schema.body)` +// and never `schema.children`. For those, `body` was the ONLY door — retiring +// it removed the sole child-list key, which is why they are the population. // // ⚠️ Derived from the renderer sources, NOT from objectui#6771's card body -// (which lists 10) and NOT verbatim from the ratchet pin's `bodyReaders` array -// (which is `['badge','alert',...sidebar*]` = 13). The pin's array is a -// convenience construction for a containment assertion — it is correct that all -// 13 are non-containers, but two of its members are not `body` readers at all: +// (which lists 10) and NOT verbatim from the ratchet pin's `bodyReaders` array. +// That array — `['badge','alert',...sidebar*]` = 13 — lived in +// `container-declaration-ratchet.test.tsx` until objectui#6771 removed it along +// with the reads it named, so do not go looking for it. It was a convenience +// construction for a containment assertion: correct that all 13 were +// non-containers, but two of its members were not `body` readers at all: +// +// - `sidebar-trigger` (navigation/sidebar.tsx) renders a `SidebarTrigger` and +// takes no `schema` at all — it read NEITHER `body` NOR `children`. +// - `tooltip` (overlay/tooltip.tsx) DID read `schema.body` and never +// `schema.children`, and was the one registration in the tree that DECLARED +// `body` as an input (`type: 'slot'`, label "Rich Content") — yet it was +// absent from the ruled 13. objectui#6771 moved both halves: it renders +// `renderChildren(schema.children)` and declares `{ name: 'children' }`. // -// - `sidebar-trigger` (navigation/sidebar.tsx:202) renders `` -// and takes no `schema` at all — it reads NEITHER `body` NOR `children`. -// - `tooltip` (overlay/tooltip.tsx:31) DOES read `schema.body` and never -// `schema.children`, and is the one registration in the tree that DECLARES -// `body` as an input (`type: 'slot'`, label "Rich Content") — yet it is -// absent from the ruled 13. +// ⚠️ That last title is NOT vacant, and leaving this note in the present tense +// is what let two files in one tree answer the same question differently. The +// one registration declaring an input named `body` is now `record:alert` +// (`plugin-detail/src/index.tsx`), whose `body` is a declared translation map +// and never a child list. `packages/sdui-parser/src/body-dialect.ts` carries +// that reading and the carve-outs that depend on it; it is the file to keep in +// step, not this one. // -// So the body-reading population is 12 of the ruled 13, plus `tooltip` = 13 -// readers, which is the same NUMBER by coincidence and a different SET. -// Both facts are pinned in `__tests__/body-dialect-census.test.mjs`. +// So the body-reading population WAS 12 of the ruled 13, plus `tooltip` = 13 +// readers, the same NUMBER by coincidence and a different SET. +// Both facts are pinned in `__tests__/body-dialect-census.test.ts`. export const BODY_ONLY = [ 'alert', 'badge', @@ -117,10 +136,14 @@ export const BODY_ONLY = [ 'sidebar-provider', ]; -/** Ruled by objectui#6771 but reads no child list at all — retiring `body` costs it nothing. */ +/** Ruled by objectui#6771 but read no child list at all — retiring `body` cost it nothing. */ export const RULED_BUT_NOT_A_READER = ['sidebar-trigger']; -/** Reads `schema.body` only, and is NOT in the ruled 13. See the note above. */ +/** + * Read `schema.body` only, and was NOT in the ruled 13. Past tense: objectui#6771 + * converged it, and `__tests__/body-dialect-census.test.ts` pins that. See the + * note above for why the name stays in the population regardless. + */ export const BODY_ONLY_UNRULED = ['tooltip']; // FALLBACK_READERS: `schema.children || schema.body` (or `body || children`). @@ -695,9 +718,9 @@ function main(argv = process.argv.slice(2)) { const inPopulation = (list) => list.filter((key) => population.keys.includes(key)); const tables = [ - ['Group 1 — `body`-only registrations (retiring `body` removes their ONLY child-list key)', inPopulation(BODY_ONLY)], - ['Group 1b — ruled by #6771 but reads NO child list (retirement costs it nothing)', inPopulation(RULED_BUT_NOT_A_READER)], - ['Group 1c — `body`-only reader NOT in the ruled 13 (⚠️ declares `body` as an input)', inPopulation(BODY_ONLY_UNRULED)], + ['Group 1 — former `body`-only registrations (`body` was their ONLY child-list key)', inPopulation(BODY_ONLY)], + ['Group 1b — ruled by #6771 but read NO child list (retirement cost it nothing)', inPopulation(RULED_BUT_NOT_A_READER)], + ['Group 1c — former `body`-only reader outside the ruled 13 (it also DECLARED `body`; that title now belongs to `record:alert`)', inPopulation(BODY_ONLY_UNRULED)], ]; for (const [title, keys] of tables) { if (keys.length > 0) printTable(title, group(keys, hits)); diff --git a/scripts/body-dialect-producer-scan.mjs b/scripts/body-dialect-producer-scan.mjs index 035c746541..b51f13aaa9 100644 --- a/scripts/body-dialect-producer-scan.mjs +++ b/scripts/body-dialect-producer-scan.mjs @@ -814,9 +814,14 @@ export const EXIT_UNREADABLE = 2; /** * Whether a run may report a table at all. * - * ⭐ A zero PRODUCER count is a legitimate reading and is NOT refused — it is - * the day objectui#6771 step 4 becomes landable, and refusing it would delete - * the one answer this scan exists to be able to give. What IS refused is a run + * ⭐ A zero PRODUCER count is a legitimate reading and is NOT refused — + * refusing it would delete the one answer this scan exists to be able to give. + * ⚠️ It is NOT, as first written here, "the day objectui#6771 step 4 becomes + * landable": step 4 landed with this table non-empty, because every row left in + * it is `unruled:item-carrier` and the tier refuses `body` only on a node whose + * `type` resolves to a registration — a `tabs` item is the value of a declared + * `items` input and is never walked as one. A zero here is objectui#9590's + * finish line instead. What IS refused is a run * that could not have found anything: a blind walk, or a C2 instrument that * derived no readers, in which case every `body` fails C2 for a reason that has * nothing to do with the corpus. diff --git a/scripts/container-declaration-baseline.json b/scripts/container-declaration-baseline.json index 562c9780b9..9773108609 100644 --- a/scripts/container-declaration-baseline.json +++ b/scripts/container-declaration-baseline.json @@ -74,52 +74,234 @@ "reason": "PERMANENT, RULED EXCEPTION -- not an oversight, not pending anything. Admitted 2026-08-29 as provisional by objectui#6779, which deferred the question to a separate card; that card is objectui#6804 and the maintainer ruled it 2026-08-30: `button` does NOT declare `isContainer`. Three grounds, all re-derived on this tree. (1) SEMANTICS -- `isContainer` means LAYOUT CONTAINMENT, not `this tag renders children`. `button` reads `schema.children` only as a fallback for `schema.label` (packages/components/src/renderers/form/button.tsx, `schema.label || renderChildren(...)`), so it is not a container; declaring it would make one predicate mean two different things. (2) COST -- `button` is the only public-tier member of the 45, and `renderers/layout/react-page.tsx` skips containers when it builds the JSX scope of every `kind:'react'` page, so declaring the flag DELETES the `Button` identifier from all of them. That is an immediate, user-visible capability loss. (3) PULL -- zero measured: nothing records anyone wanting to nest layout content inside a button, so the trade is a certain cost against a hypothetical gain. ⛔ Do NOT `fix` this entry by declaring the flag, and do NOT move `button` into `undeclared` -- both override the ruling. What keeps the exception honest is the pin, which asserts all three facts it rests on (still renders children, still undeclared, still public); if any one of them changes this goes RED and the exception must be RE-RULED, not quietly re-based. Scope note for the next hand: this card deliberately did not edit that `button.tsx` line -- the `schema.body || schema.children` expression on it belongs to objectui#6771 (retirement of the `body` dialect).", "issue": "objectui#6804", "since": "2026-08-29" + }, + "alert": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a content fallback behind `description`, not a layout slot, so declaring it would make one predicate mean two things. ⚠️ PUBLIC TIER: this tag is in `PUBLIC_BLOCKS`, so declaring the flag would also delete its identifier from the JSX scope of every `kind:'react'` page — the same immediate, user-visible capability loss that made `button` an exclusion rather than a ratchet line. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "badge": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a label fallback behind `label`, the same shape `button` was excluded for, so declaring it would make one predicate mean two things. ⚠️ PUBLIC TIER: this tag is in `PUBLIC_BLOCKS`, so declaring the flag would also delete its identifier from the JSX scope of every `kind:'react'` page — the same immediate, user-visible capability loss that made `button` an exclusion rather than a ratchet line. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-content": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-footer": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-group": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-header": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-inset": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-menu": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-menu-item": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" + }, + "sidebar-provider": { + "reason": "RULED EXCEPTION, admitted by objectui#6771's retirement of the `body` child-list dialect. This registration rendered `renderChildren(schema.body)` and never touched `schema.children`, so it was one of the dozen for which `body` was the ONLY door; the maintainer ruling of 2026-09-01 retired that spelling and it now reads `children`, which is why the runtime predicate started calling it a container. ⛔ The answer is NOT `isContainer`: objectui#6804's ruling (2026-08-30) anticipated exactly this moment and wrote the cross-card instruction verbatim — when objectui#6771 gives these registrations a `children` read and objectui#6808's predicate marks them containers, the answer is a pin exception WITH ITS GROUND, not a declaration. The ground is the same one `button` rests on: `isContainer` means LAYOUT CONTAINMENT, and this read is a chrome slot on a shadcn sidebar part rather than an authored layout region, so declaring it would make one predicate mean two things. This tag is not in `PUBLIC_BLOCKS` (the public set carries the namespaced `page:sidebar`, a different type), so no injected react-page identifier is at stake either way. ⚠️ AND THERE IS A MEASURED COST TO THIS EXCEPTION, recorded here rather than left for rediscovery: `validateTree`'s containment branch reads `node.children?.length`, so an author writing `children` — now the ONLY key this registration reads — draws `not-a-container` on a page that renders correctly. That is the defect objectui#6771 was filed about (a false diagnostic on the one spelling that works), moved from `body` to `children` rather than removed. It is pinned as a refusal in `container-declaration-ratchet.test.tsx`'s `schema.body` readers block and reported on objectui#6771; ⛔ do not resolve it by quietly declaring the flag, which overrides objectui#6804.", + "issue": "objectui#6804", + "since": "2026-09-18" } }, "undeclared": { - "a": { "since": "2026-08-29", "issue": "objectui#6779" }, - "abbr": { "since": "2026-08-29", "issue": "objectui#6779" }, - "address": { "since": "2026-08-29", "issue": "objectui#6779" }, - "app": { "since": "2026-08-29", "issue": "objectui#6779" }, - "b": { "since": "2026-08-29", "issue": "objectui#6779" }, - "blockquote": { "since": "2026-08-29", "issue": "objectui#6779" }, - "cite": { "since": "2026-08-29", "issue": "objectui#6779" }, - "dd": { "since": "2026-08-29", "issue": "objectui#6779" }, - "del": { "since": "2026-08-29", "issue": "objectui#6779" }, - "div": { "since": "2026-08-29", "issue": "objectui#6779" }, - "dl": { "since": "2026-08-29", "issue": "objectui#6779" }, - "dt": { "since": "2026-08-29", "issue": "objectui#6779" }, - "em": { "since": "2026-08-29", "issue": "objectui#6779" }, - "figcaption": { "since": "2026-08-29", "issue": "objectui#6779" }, - "figure": { "since": "2026-08-29", "issue": "objectui#6779" }, - "form": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h1": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h2": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h3": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h4": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h5": { "since": "2026-08-29", "issue": "objectui#6779" }, - "h6": { "since": "2026-08-29", "issue": "objectui#6779" }, - "home": { "since": "2026-08-29", "issue": "objectui#6779" }, - "i": { "since": "2026-08-29", "issue": "objectui#6779" }, - "ins": { "since": "2026-08-29", "issue": "objectui#6779" }, - "li": { "since": "2026-08-29", "issue": "objectui#6779" }, - "mark": { "since": "2026-08-29", "issue": "objectui#6779" }, - "ol": { "since": "2026-08-29", "issue": "objectui#6779" }, - "p": { "since": "2026-08-29", "issue": "objectui#6779" }, - "page": { "since": "2026-08-29", "issue": "objectui#6779" }, - "pre": { "since": "2026-08-29", "issue": "objectui#6779" }, - "q": { "since": "2026-08-29", "issue": "objectui#6779" }, - "record": { "since": "2026-08-29", "issue": "objectui#6779" }, - "scroll-area": { "since": "2026-08-29", "issue": "objectui#6779" }, - "small": { "since": "2026-08-29", "issue": "objectui#6779" }, - "span": { "since": "2026-08-29", "issue": "objectui#6779" }, - "strong": { "since": "2026-08-29", "issue": "objectui#6779" }, - "sub": { "since": "2026-08-29", "issue": "objectui#6779" }, - "sup": { "since": "2026-08-29", "issue": "objectui#6779" }, - "time": { "since": "2026-08-29", "issue": "objectui#6779" }, - "toggle": { "since": "2026-08-29", "issue": "objectui#6779" }, - "u": { "since": "2026-08-29", "issue": "objectui#6779" }, - "ul": { "since": "2026-08-29", "issue": "objectui#6779" }, - "utility": { "since": "2026-08-29", "issue": "objectui#6779" } + "a": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "abbr": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "address": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "app": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "b": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "blockquote": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "cite": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "dd": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "del": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "div": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "dl": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "dt": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "em": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "figcaption": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "figure": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "form": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h1": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h2": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h3": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h4": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h5": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "h6": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "home": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "i": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "ins": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "li": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "mark": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "ol": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "p": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "page": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "pre": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "q": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "record": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "scroll-area": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "small": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "span": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "strong": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "sub": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "sup": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "time": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "toggle": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "u": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "ul": { + "since": "2026-08-29", + "issue": "objectui#6779" + }, + "utility": { + "since": "2026-08-29", + "issue": "objectui#6779" + } } }