diff --git a/.changeset/9256-e3-residual-content-channels.md b/.changeset/9256-e3-residual-content-channels.md new file mode 100644 index 0000000000..e8e1565d8b --- /dev/null +++ b/.changeset/9256-e3-residual-content-channels.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +--- + +**BREAKING (shipped as `minor` — see below):** twelve node types now refuse both +content channels by name — `object-grid`, `object-form`, `object-kanban`, +`object-map`, `object-tree`, `object-view`, `object-gantt`, `object-calendar`, +`object-chart`, `detail-view`, `email` and `password`. None of their renderers +reads `body` or `children`, so an authored child list on them rendered nothing: +no error, no warning, no element. Both keys are now `?: never` on the TypeScript +face and a by-name refusal on the zod mirror, each kept a member of the mirror +shape. + +What moves for an author: + +- `children` on any of the twelve type-checked and parsed green before; it is now + refused at authoring time and at `safeParse` time, at its own path. +- `body` was already refused on the ten `object-*` / `detail-view` nodes, on both + faces, and by the zod mirror on `email` / `password`. Its refusal message on all + twelve now names what the node renders instead, where it used to point at + `children`, which these nodes do not read either. +- The TypeScript faces of `email` / `password` (`InputShorthandSchema`) and + `ui:calendar` (`UiCalendarSchema`) now carry every member they inherit. Both were + spelled as an `Omit` over an interface with an index signature, which erased + every inherited member, so each face checked only the keys it wrote itself and + accepted every other key at `any`. Now `body`, `children` and a wrongly typed + inherited member — `label: 42` on `email`, a `mode` outside `single` / + `multiple` / `range` on `ui:calendar` — no longer type-check there. An undeclared + key still type-checks on both repaired faces: the index signature is kept. +- The `body` / `children` refusal text on `calendar` and `ui:calendar` changes + wording only, because it described the erased TypeScript face; their zod mirrors + accept exactly what they accepted before. + +No render behaviour changes: nothing read these keys, which is the whole reason +they could be refused. + +Migration: move the content to the channel the component renders, or drop it. On +`detail-view` that is `header`, `footer` or a tab's `content`; for the others, +place the content beside the node in a container that reads `children`. + +`minor` rather than `major` because this repo's version policy forbids `major` +in any changeset — one `fixed` group — and records `minor` plus an explicit +breaking note as the spelling for a breaking change here. diff --git a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts new file mode 100644 index 0000000000..4bfd650a07 --- /dev/null +++ b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts @@ -0,0 +1,333 @@ +/** + * 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. + */ + +/** + * objectui#9256 — the E3 RESIDUAL: registrations that have a published arm and + * whose renderer reads NEITHER content channel, moved onto this card by the + * maintainer's ruling on objectui#8284 (Q2 A: family-D-shaped, narrowed under + * that card's ruling 「1」 — every component schema narrows to the channel its + * renderer actually reads and tombstones the other). Family D's own pins live in + * `content-channel-family-d-9256.test.ts`; this file carries the ones this slice + * adds, and nothing it pins is restated there. + * + * ## What is narrowed here + * + * The nine `object-*` views, `detail-view`, and the `email` / `password` input + * shorthands: both `body` and `children` are `?: never` on the TypeScript face + * and a by-name refusal on the zod mirror (two `retirementTombstone` members fed + * one `neitherContentChannelGuidance` string), each kept a MEMBER so + * `zod-mirror-parity`'s key sets stay equal. `body` was already refused by `BaseSchema` (objectui#6771), but + * that refusal names `children` as the remedy and `children` is dead here too, + * so each node restates it with a message naming what the node renders instead. + * + * READERSHIP was measured, not inherited from the ruling's table: a TypeScript + * compiler-API sweep over one program per workspace package, on a BUILT tree, + * files every `.body` / `.children` read under the declared type of its + * receiver. It answers zero for every declaration narrowed here while its lit + * controls (`ButtonSchema`, `DivSchema`, `CardSchema`, `ContainerSchema`) fire, + * and the same program sees every one of these declarations as the receiver of + * the keys its renderer reads, so the zeros are readings and not blindness. + * Every registration hop here is `any`-typed; each was attributed directly — + * it hands the node to a component typed by the declaration above, or (for + * `email` / `password`) spreads it into the `input` renderer typed + * `InputSchema`. The delegation hosts were checked for pass-through: every node + * `ObjectView` / `ObjectForm` / `ObjectGrid` / `ObjectChart` / `DetailView` + * hands to `SchemaRenderer` is assembled from named keys or from a sub-key, and + * none copies the host's own channel. + * + * ## The `Omit` erasure, repaired (the ruling's execution parameter 3) + * + * `InputShorthandSchema` and `UiCalendarSchema` were spelled `Omit< Base, K >` + * over an interface carrying `BaseSchema`'s index signature, so the published + * face declared nothing but what the interface wrote itself: `body` type-checked + * on `email`, `children` on `ui:calendar`, and `label: 42` on `email`. Both now + * inherit through `OmitDeclared` (`../form.ts`). `ui:calendar` gains NO new + * refusal: its face re-exposes what `CalendarSchema` already declares, and its + * mirror — a `.extend()` of that one — already refused both channels. + * + * ## HELD, and why — a decision, not appetite + * + * `br`, `hr` and `img` read neither channel either — the factory in + * `renderers/basic/html-elements.tsx` passes `undefined` as a void tag's child — + * but their only published arm is `HtmlElementSchema`, ONE object over 38 tags + * whose renderer draws `children` for the other 35. Refusing the key on three + * tags means splitting that family arm, which the option the ruling adopted + * leaves to the parser tier (the registration declares no `children` slot on a + * void tag, so `validateTree` already names it `not-a-container`) and which + * objectui#9067's ruling declined for these families. The CONTROL below pins + * the shared-arm reason, so the hold is visible rather than forgotten. + * + * ## ⚠️ Half of this file is a COMPILE-TIME assertion and vitest CANNOT read it + * + * `?: never` is erased before a test runs. The `@ts-expect-error` lines in the + * last block are read by `tsc -p tsconfig.test.json` (the `type-check` script), + * NOT by this runner: under vitest alone, deleting a tombstone from the + * TypeScript face leaves every case here GREEN. Both readers are the gate. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; +import { + ObjectGridSchema as ObjectGridMirror, + ObjectFormSchema as ObjectFormMirror, + ObjectKanbanSchema as ObjectKanbanMirror, + ObjectMapSchema as ObjectMapMirror, + ObjectTreeSchema as ObjectTreeMirror, + ObjectViewSchema as ObjectViewMirror, + ObjectGanttSchema as ObjectGanttMirror, + ObjectCalendarSchema as ObjectCalendarMirror, + ObjectChartSchema as ObjectChartMirror, +} from '../zod/objectql.zod'; +import { DetailViewSchema as DetailViewMirror } from '../zod/views.zod'; +import { InputShorthandSchema as InputShorthandMirror } from '../zod/form.zod'; +import { HtmlElementSchema as HtmlElementMirror } from '../zod/layout.zod'; +import { AnyComponentSchema } from '../zod/index.zod'; +import type { + ObjectGridSchema, + ObjectFormSchema, + ObjectKanbanSchema, + ObjectMapSchema, + ObjectTreeSchema, + ObjectViewSchema, + ObjectGanttSchema, + ObjectCalendarSchema, + ObjectChartSchema, +} from '../objectql'; +import type { DetailViewSchema } from '../views'; +import type { CalendarSchema, InputSchema, InputShorthandSchema, UiCalendarSchema } from '../form'; + +type Mirror = { + safeParse: (v: unknown) => { success: boolean; error?: z.ZodError }; + shape: Record; +}; + +/** + * One row per narrowed `type`: its mirror and the node's OTHER required + * members — a bare `{ type: 'object-form' }` is refused for reasons that have + * nothing to do with this change, and would read here as a false positive. The + * CONTROL block proves every `required` set parses on its own. + */ +const ROWS: ReadonlyArray]> = [ + ['object-grid', ObjectGridMirror as unknown as Mirror, { objectName: 'account' }], + ['object-form', ObjectFormMirror as unknown as Mirror, { objectName: 'account', mode: 'create' }], + ['object-kanban', ObjectKanbanMirror as unknown as Mirror, { objectName: 'account' }], + ['object-map', ObjectMapMirror as unknown as Mirror, { objectName: 'account' }], + ['object-tree', ObjectTreeMirror as unknown as Mirror, { objectName: 'account' }], + ['object-view', ObjectViewMirror as unknown as Mirror, { objectName: 'account' }], + ['object-gantt', ObjectGanttMirror as unknown as Mirror, { objectName: 'account' }], + ['object-calendar', ObjectCalendarMirror as unknown as Mirror, { objectName: 'account' }], + ['object-chart', ObjectChartMirror as unknown as Mirror, { objectName: 'account', chartType: 'bar' }], + ['detail-view', DetailViewMirror as unknown as Mirror, { objectName: 'account' }], + ['email', InputShorthandMirror as unknown as Mirror, {}], + ['password', InputShorthandMirror as unknown as Mirror, {}], +]; + +const CHANNELS = ['body', 'children'] as const; +const CONTENT = [{ type: 'text', content: 'measured' }]; +const issues = (m: Mirror, doc: unknown) => { + const r = m.safeParse(doc); + return r.success ? null : r.error!.issues.map((i) => ({ code: i.code, path: i.path.join('.'), message: i.message })); +}; +const CASES = ROWS.flatMap(([type, mirror, required]) => + CHANNELS.map((key) => [`${type}.${key}`, type, mirror, key, required] as const)); + +/* ── (a) both channels are REFUSED BY NAME, at the key's own path ─────────── */ + +describe('objectui#9256 E3 residual — both content channels are refused where the renderer reads neither', () => { + it('the population is the narrowed one — a row dropped from the table fails here', () => { + expect(ROWS).toHaveLength(12); + expect(CASES).toHaveLength(24); + }); + + it.each(CASES)('%s is refused at that key\'s own path', (label, type, mirror, key, required) => { + const found = issues(mirror, { ...required, type, [key]: CONTENT }); + expect(found, `${label} parsed green — the tombstone is not installed`).not.toBeNull(); + expect(found!.some((i) => i.path === key && i.code === 'invalid_type')).toBe(true); + }); + + it.each(CASES)('%s — the message names the channel, the card, and what the node renders instead', (_label, type, mirror, key, required) => { + const issue = issues(mirror, { ...required, type, [key]: CONTENT })!.find((i) => i.path === key)!; + expect(issue.message).toContain(`\`${key}\``); + expect(issue.message).toContain('objectui#9256'); + expect(issue.message).toContain('NEITHER content channel'); + expect(issue.message).toContain('What it renders instead: '); + // ⛔ Not `BaseSchema`'s generic `body` refusal, which names `children` as + // the remedy — on these nodes `children` is refused as well. + expect(issue.message).not.toContain('Did you mean'); + }); + + it.each(CASES)('%s — ONE string feeds both author-facing channels: the issue message IS the `.describe()` metadata', (_label, type, mirror, key, required) => { + const issue = issues(mirror, { ...required, type, [key]: CONTENT })!.find((i) => i.path === key)!; + expect(mirror.shape[key]?.description).toBe(issue.message); + }); + + it.each(CASES)('%s — the refusal is about the KEY, not a value domain: every value is refused', (_label, type, mirror, key, required) => { + for (const value of [CONTENT, 'text', 42, null, {}, []]) { + expect(issues(mirror, { ...required, type, [key]: value })?.some((i) => i.path === key)).toBe(true); + } + }); + + it.each(CASES)('%s — the refusal reaches the node through `AnyComponentSchema`, not only its own arm', (_label, type, _mirror, key, required) => { + expect(AnyComponentSchema.safeParse({ ...required, type }).success).toBe(true); + expect(AnyComponentSchema.safeParse({ ...required, type, [key]: CONTENT }).success).toBe(false); + }); +}); + +/* ── (b) CONTROLS — nothing that parsed before stops parsing ──────────────── */ + +describe('objectui#9256 E3 residual — CONTROLS', () => { + it.each(ROWS)('`%s` still parses with its own required members and a `className`', (type, mirror, required) => { + expect(issues(mirror, { ...required, type })).toBeNull(); + expect(issues(mirror, { ...required, type, className: 'p-4' })).toBeNull(); + }); + + it.each(CASES)('%s — the tombstone is a MEMBER of the mirror shape, so the parity ratchet\'s key sets stay equal', (_label, _type, mirror, key) => { + expect(Object.keys(mirror.shape)).toContain(key); + }); + + it('a nested narrowed node is refused inside a container that reads `children`', () => { + const tree = (child: Record) => ({ type: 'div', children: [child] }); + expect(AnyComponentSchema.safeParse(tree({ type: 'object-grid', objectName: 'account' })).success).toBe(true); + expect(AnyComponentSchema.safeParse( + tree({ type: 'object-grid', objectName: 'account', children: CONTENT }), + ).success).toBe(false); + }); + + it('family C still reads a content channel — `div` takes `children`', () => { + // The distinction this card rests on: a reader keeps its channel, a + // non-reader loses both. Measured in the same run as the rows above. + expect(AnyComponentSchema.safeParse({ type: 'div', children: CONTENT }).success).toBe(true); + }); + + it('HELD — `br` / `hr` / `img` share ONE arm with 35 tags that DO render `children`', () => { + // Why the three void tags are not narrowed here (header, "HELD"): refusing + // the key on the arm refuses it on `p` too, and `p` renders it. This pins + // the shared-arm fact the hold rests on; a split of the family arm is the + // decision it waits on, and that change turns this red on purpose. + const html = HtmlElementMirror as unknown as Mirror; + expect(issues(html, { type: 'p', children: CONTENT })).toBeNull(); + expect(issues(html, { type: 'br', children: CONTENT })).toBeNull(); + }); +}); + +/* ── (c) the TypeScript face — ⚠️ READ BY `tsc`, NOT BY VITEST ────────────── */ + +type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + +describe('objectui#9256 E3 residual — the TypeScript face refuses both channels at the AUTHORING site', () => { + // Each refused line spreads a base that the CONTROL test below proves + // compiles, so the ONLY difference an `@ts-expect-error` can be answering is + // the channel written beside it — not a missing required member. + const grid = { type: 'object-grid', objectName: 'account' } satisfies ObjectGridSchema; + const form = { type: 'object-form', objectName: 'account', mode: 'create' } satisfies ObjectFormSchema; + const kanban = { type: 'object-kanban', objectName: 'account' } satisfies ObjectKanbanSchema; + const map = { type: 'object-map', objectName: 'account' } satisfies ObjectMapSchema; + const tree = { type: 'object-tree', objectName: 'account' } satisfies ObjectTreeSchema; + const view = { type: 'object-view', objectName: 'account' } satisfies ObjectViewSchema; + const gantt = { type: 'object-gantt', objectName: 'account' } satisfies ObjectGanttSchema; + const calendar = { type: 'object-calendar', objectName: 'account' } satisfies ObjectCalendarSchema; + const chart = { type: 'object-chart', objectName: 'account', chartType: 'bar' } satisfies ObjectChartSchema; + const detail = { type: 'detail-view', objectName: 'account' } satisfies DetailViewSchema; + const email = { type: 'email' } satisfies InputShorthandSchema; + const password = { type: 'password' } satisfies InputShorthandSchema; + const uiCalendar = { type: 'ui:calendar' } satisfies UiCalendarSchema; + + it('the `@ts-expect-error` lines in this block are the assertion; vitest only proves they are reachable', () => { + // @ts-expect-error objectui#9256 — `object-grid` reads neither channel + const gridBody: ObjectGridSchema = { ...grid, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-grid` reads neither channel + const gridChildren: ObjectGridSchema = { ...grid, children: CONTENT }; + // @ts-expect-error objectui#9256 — `object-form` reads neither channel + const formBody: ObjectFormSchema = { ...form, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-form` reads neither channel + const formChildren: ObjectFormSchema = { ...form, children: CONTENT }; + // @ts-expect-error objectui#9256 — `object-kanban` reads neither channel + const kanbanBody: ObjectKanbanSchema = { ...kanban, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-kanban` reads neither channel + const kanbanChildren: ObjectKanbanSchema = { ...kanban, children: CONTENT }; + // @ts-expect-error objectui#9256 — `object-map` reads neither channel + const mapBody: ObjectMapSchema = { ...map, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-map` reads neither channel + const mapChildren: ObjectMapSchema = { ...map, children: CONTENT }; + // @ts-expect-error objectui#9256 — `object-tree` reads neither channel + const treeBody: ObjectTreeSchema = { ...tree, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-tree` reads neither channel + const treeChildren: ObjectTreeSchema = { ...tree, children: CONTENT }; + // @ts-expect-error objectui#9256 — `object-view` reads neither channel + const viewBody: ObjectViewSchema = { ...view, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-view` reads neither channel + const viewChildren: ObjectViewSchema = { ...view, children: CONTENT }; + // @ts-expect-error objectui#9256 — `object-gantt` reads neither channel + const ganttBody: ObjectGanttSchema = { ...gantt, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-gantt` reads neither channel + const ganttChildren: ObjectGanttSchema = { ...gantt, children: CONTENT }; + // @ts-expect-error objectui#9256 — `object-calendar` reads neither channel + const calendarBody: ObjectCalendarSchema = { ...calendar, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-calendar` reads neither channel + const calendarChildren: ObjectCalendarSchema = { ...calendar, children: CONTENT }; + // @ts-expect-error objectui#9256 — `object-chart` reads neither channel + const chartBody: ObjectChartSchema = { ...chart, body: CONTENT }; + // @ts-expect-error objectui#9256 — `object-chart` reads neither channel + const chartChildren: ObjectChartSchema = { ...chart, children: CONTENT }; + // @ts-expect-error objectui#9256 — `detail-view` reads neither channel + const detailBody: DetailViewSchema = { ...detail, body: CONTENT }; + // @ts-expect-error objectui#9256 — `detail-view` reads neither channel + const detailChildren: DetailViewSchema = { ...detail, children: CONTENT }; + // @ts-expect-error objectui#9256 — `email` reads neither channel (and this face used to erase `body`'s refusal) + const emailBody: InputShorthandSchema = { ...email, body: CONTENT }; + // @ts-expect-error objectui#9256 — `email` reads neither channel + const emailChildren: InputShorthandSchema = { ...email, children: CONTENT }; + // @ts-expect-error objectui#9256 — `password` reads neither channel (and this face used to erase `body`'s refusal) + const passwordBody: InputShorthandSchema = { ...password, body: CONTENT }; + // @ts-expect-error objectui#9256 — `password` reads neither channel + const passwordChildren: InputShorthandSchema = { ...password, children: CONTENT }; + // The pair the `ui:calendar` TRIPWIRE in the family-D file asked for once + // the `Omit` collapse was repaired — `CalendarSchema`'s own tombstones, + // re-exposed rather than newly declared. + // @ts-expect-error objectui#9256 — `ui:calendar` inherits `calendar`'s tombstone + const uiCalendarBody: UiCalendarSchema = { ...uiCalendar, body: CONTENT }; + // @ts-expect-error objectui#9256 — `ui:calendar` inherits `calendar`'s tombstone + const uiCalendarChildren: UiCalendarSchema = { ...uiCalendar, children: CONTENT }; + + expect([ + gridBody, gridChildren, formBody, formChildren, kanbanBody, kanbanChildren, + mapBody, mapChildren, treeBody, treeChildren, viewBody, viewChildren, + ganttBody, ganttChildren, calendarBody, calendarChildren, chartBody, chartChildren, + detailBody, detailChildren, emailBody, emailChildren, passwordBody, passwordChildren, + uiCalendarBody, uiCalendarChildren, + ]).toHaveLength(26); + }); + + it('the repaired `Omit` re-exposes EVERY inherited member, not only the tombstones', () => { + // The erasure was never about the channels: `label: 42` type-checked on + // `email` and `mode: 'default'` on `ui:calendar`, because neither face + // carried a named member at all. `tsc` is the reader of these lines. + // @ts-expect-error objectui#9256 — `label` is `InputSchema`'s `string` again + const emailLabel: InputShorthandSchema = { ...email, label: 42 }; + // @ts-expect-error objectui#9256 — `mode` is `CalendarSchema`'s enum again + const uiCalendarMode: UiCalendarSchema = { ...uiCalendar, mode: 'default' }; + // @ts-expect-error objectui#8762 — the key this face writes itself still refuses + const emailInputType: InputShorthandSchema = { ...email, inputType: 'text' }; + const label: Eq = true; + const mode: Eq = true; + const value: Eq = true; + expect([emailLabel, uiCalendarMode, emailInputType]).toHaveLength(3); + expect([label, mode, value]).toEqual([true, true, true]); + }); + + it('CONTROL — the same nodes WITHOUT a content channel compile (no `@ts-expect-error` here, and `tsc` is the reader)', () => { + const ok = [grid, form, kanban, map, tree, view, gantt, calendar, chart, detail, email, password, uiCalendar]; + // The repair keeps the index signature, so it narrows nothing an author + // could write before beyond the members' own declared types — an + // undeclared key still compiles, exactly as it did. + const undeclared = { type: 'email', label: 'Email', notAMember: 1 } satisfies InputShorthandSchema; + const calendarRange = { type: 'ui:calendar', mode: 'range' } satisfies UiCalendarSchema; + expect(ok).toHaveLength(13); + expect([undeclared.type, calendarRange.mode]).toEqual(['email', 'range']); + }); +}); 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 c1d8692ff0..acc8e31f94 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 @@ -221,7 +221,7 @@ import type { ViewSwitcherSchema } from '../views'; import type { NLQuerySchema } from '../ai'; import type { TextSchema, ImageSchema, IconSchema, TabsSchema } from '../layout'; import type { AccordionSchema } from '../disclosure'; -import type { CalendarSchema, UiCalendarSchema } from '../form'; +import type { CalendarSchema } from '../form'; type Mirror = { safeParse: (v: unknown) => { success: boolean; error?: z.ZodError }; @@ -565,20 +565,11 @@ describe('objectui#9256 — the TypeScript face refuses both channels at the AUT const timelineBody: TimelineSchema = { type: 'timeline', body: CONTENT }; // @ts-expect-error objectui#9256 — `timeline` reads neither channel on either of its two readers const timelineChildren: TimelineSchema = { type: 'timeline', children: CONTENT }; - // ⚠️ TRIPWIRE, and deliberately NOT a `@ts-expect-error`. `UiCalendarSchema` - // is declared as `Omit`, and `BaseSchema` carries an - // index signature — so `Omit` resolves through `Exclude` = - // `string` and collapses every member into that signature. MEASURED with the - // checker: `UiCalendarSchema['body']` answers `any`, and so does - // `UiCalendarSchema['mode']`, which is a key this change never touched. That - // face therefore carries NONE of `CalendarSchema`'s members, tombstones - // included, and the line below compiles today. Its MIRROR does refuse the - // key — the `ui:calendar` row above is the reading — so this is a measured - // asymmetry between the two published faces, not a gap in the mirror. - // When the collapse is repaired this line goes RED, which is the signal to - // restate the two tombstones on `UiCalendarSchema` and turn it back into a - // `@ts-expect-error` pair. - const uiCalendarBody: UiCalendarSchema = { type: 'ui:calendar', body: CONTENT }; + // `ui:calendar`'s TypeScript face stood here as a TRIPWIRE: its heritage was a + // plain `Omit` that collapsed into `BaseSchema`'s index signature, so this line + // compiled. objectui#9256's E3 slice repaired the heritage, the tripwire fired as + // designed, and the pair it asked for lives with that repair's other pins in + // `content-channel-e3-residual-9256.test.ts`. expect([ separatorBody, separatorChildren, kbdBody, skeletonChildren, checkboxBody, paginationChildren, @@ -586,9 +577,8 @@ describe('objectui#9256 — the TypeScript face refuses both channels at the AUT nlQueryChildren, pivotBody, chatbotChildren, textBody, textChildren, imageBody, imageChildren, iconBody, iconChildren, tabsBody, tabsChildren, accordionBody, accordionChildren, calendarBody, calendarChildren, - uiCalendarBody, listBody, listChildren, timelineBody, timelineChildren, - ]).toHaveLength(32); + ]).toHaveLength(31); }); it('CONTROL — the same nodes WITHOUT a content channel compile (no `@ts-expect-error` here, and `tsc` is the reader)', () => { diff --git a/packages/types/src/__tests__/form-barrel-mirror-9406.test.ts b/packages/types/src/__tests__/form-barrel-mirror-9406.test.ts index b017d67c60..04042db078 100644 --- a/packages/types/src/__tests__/form-barrel-mirror-9406.test.ts +++ b/packages/types/src/__tests__/form-barrel-mirror-9406.test.ts @@ -269,7 +269,7 @@ const EXCLUDED_BY_RULING_ELSEWHERE = ['SemanticElementSchema', 'HtmlElementSchem describe('objectui#9406 -- the two names resolve from the root barrel', () => { it('`InputShorthandSchema` resolves from the root, with its narrowed `type`', () => { - // `Omit` re-narrowed: `type` is the two + // `InputSchema` without `type` / `inputType`, re-narrowed: `type` is the two // shorthand literals, and `inputType` is unwritable here (objectui#8762). const type: Eq = true; const inputType: Eq = true; diff --git a/packages/types/src/__tests__/object-view-spec-parity.test.ts b/packages/types/src/__tests__/object-view-spec-parity.test.ts index 7ec38a1718..9e6c2884d6 100644 --- a/packages/types/src/__tests__/object-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/object-view-spec-parity.test.ts @@ -329,6 +329,13 @@ const SANCTIONED_LOCAL = new Set([ 'showViewSwitcher', // Points at objectui's own named-view record, not a spec key. 'defaultListView', + // The two content channels, redeclared ONLY as named refusals (objectui#9256, + // the E3 residual): `ObjectView` reads neither, so both are `?: never` / + // `neitherContentChannel()` twins — declared so an authored child list is + // refused by name instead of rendering nothing. The spec's view models no child + // list on this node, so the refusal is objectui-only surface, as `viewTabBar`'s is. + 'body', + 'children', ]); // ── the suite ──────────────────────────────────────────────────────────────── @@ -385,10 +392,13 @@ describe('ObjectViewSchema declared-surface consistency (#2890 scope B)', () => expect(zodOnly).toEqual([]); }); - it('narrows exactly the two envelope keys the node means to narrow', () => { - // Mechanically derived, so a THIRD redeclaration — the usual way a node + it('narrows exactly the four envelope keys the node means to narrow', () => { + // Mechanically derived, so a FIFTH redeclaration — the usual way a node // quietly re-types `data` or `name` out from under the envelope — fails here. - expect([...redeclaredEnvelopeKeys].sort()).toEqual(['description', 'type']); + // `body` / `children` joined `description` / `type` with objectui#9256 (the E3 + // residual): both are redeclared ONLY as named refusals, because the renderer + // reads neither content channel. + expect([...redeclaredEnvelopeKeys].sort()).toEqual(['body', 'children', 'description', 'type']); }); it('has exactly the known TS-only backlog — it may shrink, never grow', () => { @@ -423,8 +433,13 @@ describe('ObjectViewSchema declared-surface consistency (#2890 scope B)', () => // reference — the last TS-only key closed on the zod side. So 22 // non-envelope keys, 22 + 2 = 24 declared, and the TS figure again does not // move. + // + // objectui#9256 (the E3 residual) redeclared two ENVELOPE keys, `body` and + // `children`, as named refusals: 22 non-envelope keys still, 22 + 4 = 26 + // declared. The TS figure below does not move either — both keys are + // `BaseSchema`'s, so the compiler attributes them to the envelope. expect(ouiZodKeys.filter((k) => !ENVELOPE.has(k))).toHaveLength(22); - expect(ouiDeclaredKeys).toHaveLength(24); + expect(ouiDeclaredKeys).toHaveLength(26); // The interface's own surface beyond the envelope. The audit counted 25 // declared fields including the 3 whose names the envelope also owns // (`type`, `description`, `className`); 22 is that figure with those three diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 6e7a3ece23..19cdaa3a22 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,12 @@ * a delta to this number; count the registry. Nothing asserts it against a written * one, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **48 entries** in `KnownDrift`, **87 keys** across them — 49 / 89 until + * - **49 entries** in `KnownDrift`, **88 keys** across them — 48 / 87 until + * objectui#9256 repaired the `Omit` erasure on `form.zod.ts#InputShorthandSchema`: + * the face had declared nothing it inherited, so no key of it could drift, and it + * now carries `InputSchema`'s members, `onChange` included. ONE entry is new with + * one key, and it is `InputSchema`'s own runtime-slot drift inherited through the + * repaired heritage — not drift the change introduced. It was 49 / 89 until * objectui#10874 settled the TypeScript face of the three `@object-ui/plugin-ai` * declarations per key: `onApplySuggestion` / `onRejectSuggestion` became `?: never` * (both faces now refuse them), so `ai.zod.ts#AIFormAssistSchema` measures clean and @@ -674,7 +679,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 48 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 49 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is * pinned to its EXACT drifted key set, so the entry fails when new drift appears on * that mirror AND when the recorded drift is fixed — a stale entry cannot rot * quietly. Correcting them is not one change: the pairs below split into DISJOINT @@ -2220,6 +2225,13 @@ interface KnownDrift { 'form.zod.ts#InputOTPSchema': 'onChange'; /** RUNTIME SLOT (objectui#6124): the `input` renderer calls `props.onChange(e.target.value)` after `SchemaRenderer`'s spread. */ 'form.zod.ts#InputSchema': 'onChange'; + /** + * Inherited drift: `InputSchema`'s runtime slot above, reaching this face since + * objectui#9256 repaired its `Omit` heritage (it declared nothing it inherited + * before, so nothing could drift). Both registrations spread their props into the + * same `input` renderer, so the slot is the same slot. + */ + 'form.zod.ts#InputShorthandSchema': 'onChange'; /** RUNTIME SLOT (objectui#6124): the `select` renderer calls `props.onChange(matchOptionValue(…))` after `SchemaRenderer`'s spread. (This pair LEFT the ledger with objectui#5927's widenings and re-enters on a different key for a different reason.) */ 'form.zod.ts#SelectSchema': 'onChange'; /** RUNTIME SLOT (objectui#6124): the `textarea` renderer calls `props.onChange(e.target.value)` after `SchemaRenderer`'s spread. */ @@ -3651,7 +3663,7 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * Exact MIRRORED-BUT-UNDECLARED key set per pair — the seed of the direction * objectui#9711 opened, measured on the tree this ledger landed on. * - * `this ledger seeds **4 entries** carrying **60 keys**` — and ⛔ read that off the + * `this ledger seeds **2 entries** carrying **2 keys**` — and ⛔ read that off the * census at the bottom of this file, not off this sentence: both figures are pinned * to this ledger's own AST by 'the fourth direction is enumerated and sized at test * time', so an entry or an arm added here moves them or the file reddens. @@ -3669,6 +3681,16 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * one" remedy the next section refuses: no key was hand-declared, the twin and the * mirror were put on one derivation, and the ruling named the mechanism. * + * ⭐ Then by two entries and 58 keys, when objectui#9256 executed parameter 3 of the + * ruling on objectui#8284 (Q2 A): the `Omit` COLLAPSE rows — the `email` / `password` + * and `ui:calendar` twins — left because their heritage clauses stopped collapsing + * into `BaseSchema`'s index signature. ⚠️ That is NOT the "declare the keys" remedy + * the next section refuses, and the direction is the reason: a collapsed face + * accepted EVERY key at `any`, so restoring the named members NARROWED those faces + * (`label: 42` and `body` stopped type-checking on `email`) rather than enlarging an + * accept set. No key was hand-declared; the members are inherited from the very + * declarations the mirrors `.omit()` / `.extend()`. + * * ## ⛔ What this ledger is NOT * * ⛔ It is NOT a worklist to be drained by DECLARING the keys it names. Declaring a @@ -3699,17 +3721,17 @@ export const assertionWiderLedgerRecordsEveryKey: never = 0 as unknown as WiderL * interface restates only the subset objectui's renderers read. The remedy axis * is the same one objectstack#4115 opened when it made the keys flow in at all, * and it is a spec-alignment decision, not a typo. - * - **`Omit` COLLAPSE.** The declaration is `Omit< Base, K >` over an interface - * that carries `BaseSchema`'s index signature, so `Exclude< keyof Base, K >` - * resolves through `string` and the `Pick` collapses EVERY member into the index - * signature. That face has never declared the keys its docblock describes — - * measured, not assumed, and pinned independently of this file for the calendar - * twin. The mirror propagates because zod `.extend()` carries the shape. ⭐ This - * class is why the operator must not be "fixed" to read fewer keys: it is - * reporting a real property of the published declaration, loudly, which is - * precisely what a degenerate `DeclaredKeys< D >` should do. A vacuous reading - * here over-reports and is caught; the naive `keyof D` spelling under-reports to - * `never` and would never be caught at all. + * - **`Omit` COLLAPSE — EMPTIED by objectui#9256.** The declaration was + * `Omit< Base, K >` over an interface that carries `BaseSchema`'s index + * signature, so `Exclude< keyof Base, K >` resolved through `string` and the + * `Pick` collapsed EVERY member into the index signature; the mirror propagated + * because zod `.extend()` carries the shape. Both instances (the `email` / + * `password` and `ui:calendar` twins) were repaired on the DECLARATION side and + * their rows deleted. ⭐ The class is still why the operator must not be "fixed" + * to read fewer keys: it reported a real property of the published declaration, + * loudly, which is precisely what a degenerate `DeclaredKeys< D >` should do. A + * vacuous reading here over-reports and is caught; the naive `keyof D` spelling + * under-reports to `never` and would never be caught at all. */ interface MirroredUndeclared { // `app.zod.ts#AppComponentSchema` HAD a row here — 20 keys of SPEC-OWNED INFLOW @@ -3742,85 +3764,15 @@ interface MirroredUndeclared { "complex.zod.ts#DashboardConfigSchema": | "aria"; - /** - * `Omit` COLLAPSE. The declaration is `Omit< InputSchema, 'type' | 'inputType' >` - * and `InputSchema` reaches `BaseSchema`'s index signature, so the `Omit` resolves - * through `Exclude< string, … >` = `string` and every member collapses into that - * signature. What survives as a DECLARED member is what this interface writes - * itself. Every other key the mirror states therefore reads as undeclared — which - * is a true statement about the published face, not an artefact: the checker - * answers `any` for each of them. ⛔ Do not "repair" this entry by editing the - * measurement; repairing the FACE is a declaration-side decision and a different - * card. - */ - "form.zod.ts#InputShorthandSchema": - | "label" - | "error" - | "description" - | "id" - | "name" - | "pattern" - | "placeholder" - | "className" - | "style" - | "data" - | "bind" - | "body" - | "children" - | "visible" - | "visibleWhen" - | "visibleOn" - | "hidden" - | "hiddenOn" - | "disabled" - | "disabledOn" - | "testId" - | "defaultValue" - | "ariaLabel" - | "required" - | "value" - | "min" - | "max" - | "step" - | "onChange" - | "wrapperClass" - | "readOnly" - | "maxLength"; + // `form.zod.ts#InputShorthandSchema` and `form.zod.ts#UiCalendarSchema` HAD rows + // here — the whole `Omit` COLLAPSE class (32 and 26 keys): both TypeScript twins + // were spelled `Omit< Base, K >` over an interface carrying `BaseSchema`'s index + // signature, so each face declared only what it wrote itself. objectui#9256 (the E3 + // residual the ruling on objectui#8284 moved there) repaired the FACES, not this + // measurement: both heritage clauses are now `OmitDeclared`, a key-remapping `Omit` + // that keeps the named members, and both pairs measure `never`. Re-adding either row + // without re-adding the collapse reddens the reconciliation below. - /** - * `Omit` COLLAPSE, the second instance and the one measured independently of this - * file: the declaration is `Omit< CalendarSchema, 'type' >`, the collapse is - * described in that declaration's own docblock, and both halves are pinned by the - * content-channel family-D twin test. This entry is what that reading looks like - * when a general operator meets it instead of a per-key tripwire. - */ - "form.zod.ts#UiCalendarSchema": - | "label" - | "description" - | "id" - | "name" - | "placeholder" - | "className" - | "style" - | "data" - | "bind" - | "body" - | "children" - | "visible" - | "visibleWhen" - | "visibleOn" - | "hidden" - | "hiddenOn" - | "disabled" - | "disabledOn" - | "testId" - | "defaultValue" - | "ariaLabel" - | "value" - | "onChange" - | "mode" - | "minDate" - | "maxDate"; // `layout.zod.ts#PageNodeSchema` HAD a row here — 10 keys (the envelope, `source`, // `interfaceConfig`, `requires`) — deleted by objectui#9736 for the same reason: @@ -5628,8 +5580,17 @@ outside the card that built this instrument.`) for (const [entry, keys] of read) { expect(keys.length, `MirroredUndeclared['${entry}'] read as having no keys`).toBeGreaterThan(0); } - expect(keyTotal(), 'MirroredUndeclared: no entry read as a union of more than one literal — the reader is not walking union arms') - .toBeGreaterThan(read.size); + // The union-arm leg needs an entry with more than one arm to be a fact about + // THIS ledger. objectui#9256 emptied the last multi-arm entries here (the two + // `Omit` COLLAPSE rows), leaving single-key entries only — so, while that holds, + // the leg is taken on `KnownDrift`, which this SAME reader sizes above and which + // does hold multi-arm entries. It re-arms on this ledger the moment a multi-arm + // entry lands here; ⛔ it is never dropped, because a reader that stopped walking + // arms would still pass both legs above. + const armWitness = keyTotal() > read.size ? read : ledgerEntryMembers('KnownDrift'); + const armKeys = Array.from(armWitness.values()).reduce((n, keys) => n + keys.length, 0); + expect(armKeys, 'no entry read as a union of more than one literal — the reader is not walking union arms') + .toBeGreaterThan(armWitness.size); }); it('the seed spans BOTH mirror-construction styles — it is not an artefact of one', () => { diff --git a/packages/types/src/form.ts b/packages/types/src/form.ts index 11f602f136..613662d043 100644 --- a/packages/types/src/form.ts +++ b/packages/types/src/form.ts @@ -1277,18 +1277,17 @@ export interface CalendarSchema extends BaseSchema { * at `ui:calendar` only (`packages/components/src/renderers/form/calendar.tsx`), * which reads `defaultValue`, `mode` and `value` and neither content channel. * - * ⚠️ The tombstone does NOT reach {@link UiCalendarSchema} on THIS face, and - * that was measured rather than assumed. `UiCalendarSchema` is declared as an - * `Omit` of this interface, and {@link BaseSchema} carries an index signature, - * so `Omit` resolves through `Exclude` = `string` and collapses - * every member into that signature: the checker answers `any` for - * `UiCalendarSchema['body']` — and for `UiCalendarSchema['mode']` as well, so - * this is not about the tombstone. That face has never carried this key set, - * whatever its own docblock says. The MIRROR does propagate, because - * `zod/form.zod.ts` builds it with `.extend()` and zod carries the shape. - * Both halves are pinned in `__tests__/content-channel-family-d-9256.test.ts` - * — the mirror as a refusal row, this face as a tripwire that reddens the day - * the collapse is repaired. + * The tombstone reaches {@link UiCalendarSchema} on BOTH faces. On this one + * it did not until objectui#9256's E3 slice: `UiCalendarSchema` was declared + * as a plain `Omit` of this interface, and because {@link BaseSchema} carries + * an index signature that `Omit` resolved through `Exclude` = + * `string` and collapsed every member into the signature — the checker + * answered `any` for `UiCalendarSchema['body']` and for + * `UiCalendarSchema['mode']` alike. Its heritage is now the index-signature-safe + * `OmitDeclared`, so it carries this key set; the mirror always did, because + * `zod/form.zod.ts` builds it with `.extend()`. The TypeScript half is pinned + * in `__tests__/content-channel-e3-residual-9256.test.ts`, the mirror half as + * a refusal row in `__tests__/content-channel-family-d-9256.test.ts`. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -1334,18 +1333,17 @@ export interface CalendarSchema extends BaseSchema { * at `ui:calendar` only (`packages/components/src/renderers/form/calendar.tsx`), * which reads `defaultValue`, `mode` and `value` and neither content channel. * - * ⚠️ The tombstone does NOT reach {@link UiCalendarSchema} on THIS face, and - * that was measured rather than assumed. `UiCalendarSchema` is declared as an - * `Omit` of this interface, and {@link BaseSchema} carries an index signature, - * so `Omit` resolves through `Exclude` = `string` and collapses - * every member into that signature: the checker answers `any` for - * `UiCalendarSchema['body']` — and for `UiCalendarSchema['mode']` as well, so - * this is not about the tombstone. That face has never carried this key set, - * whatever its own docblock says. The MIRROR does propagate, because - * `zod/form.zod.ts` builds it with `.extend()` and zod carries the shape. - * Both halves are pinned in `__tests__/content-channel-family-d-9256.test.ts` - * — the mirror as a refusal row, this face as a tripwire that reddens the day - * the collapse is repaired. + * The tombstone reaches {@link UiCalendarSchema} on BOTH faces. On this one + * it did not until objectui#9256's E3 slice: `UiCalendarSchema` was declared + * as a plain `Omit` of this interface, and because {@link BaseSchema} carries + * an index signature that `Omit` resolved through `Exclude` = + * `string` and collapsed every member into the signature — the checker + * answered `any` for `UiCalendarSchema['body']` and for + * `UiCalendarSchema['mode']` alike. Its heritage is now the index-signature-safe + * `OmitDeclared`, so it carries this key set; the mirror always did, because + * `zod/form.zod.ts` builds it with `.extend()`. The TypeScript half is pinned + * in `__tests__/content-channel-e3-residual-9256.test.ts`, the mirror half as + * a refusal row in `__tests__/content-channel-family-d-9256.test.ts`. * * Before objectui#9256 tombstoned them here, `body` and `children` were both * inherited-and-optional from {@link BaseSchema} — so authoring either here @@ -2486,6 +2484,31 @@ export interface CodeEditorSchema extends BaseSchema { onChange?: (value: string | undefined) => void; } +/** + * `Omit` that KEEPS the named members of a type carrying an index signature + * (objectui#9256, the `Omit` erasure the maintainer's ruling on objectui#8284 + * moved onto that card). + * + * The built-in `Omit` is `Pick>`, and `keyof T` on + * a type carrying {@link BaseSchema}'s `[key: string]: any` is `string | number`: + * the literal member names are absorbed, `Exclude` leaves `string`, and the + * `Pick` rebuilds the index signature and NONE of the named members (the same + * mechanism objectui#6151 and objectui#6269 measured at other positions). The + * two shorthand faces below were spelled that way, so every member they meant + * to inherit — `BaseSchema`'s `body` refusal and `CalendarSchema`'s family-D + * tombstones included — answered `any` on the published face. + * + * A key-remapping mapped type iterates the NAMED members and the index + * signature separately, so filtering by name drops exactly `K`: every other + * named member survives with its own type and modifiers, and the index + * signature survives as itself. Not exported — it exists to spell these two + * heritage clauses, and `__tests__/content-channel-e3-residual-9256.test.ts` + * pins what it delivers on both faces. + */ +type OmitDeclared = { + [P in keyof T as P extends K ? never : P]: T[P]; +}; + /** * The `email` / `password` input shorthands * `packages/components/src/renderers/form/input.tsx` registers (objectui#8499). @@ -2508,7 +2531,7 @@ export interface CodeEditorSchema extends BaseSchema { * * Mirror: `zod/form.zod.ts#InputShorthandSchema`. */ -export interface InputShorthandSchema extends Omit { +export interface InputShorthandSchema extends OmitDeclared { type: 'email' | 'password'; /** * ⛔ UNWRITABLE at this position (objectui#8762). The `email` / `password` @@ -2522,6 +2545,37 @@ export interface InputShorthandSchema extends Omit { +export interface UiCalendarSchema extends OmitDeclared { type: 'ui:calendar'; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index b5aad8ff13..050d894616 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -172,8 +172,8 @@ export type { // The two names on `form.ts`'s export list that this barrel did not carry, // added by objectui#9406 (director seat, decision batch #133 item 2, letter // (a), maintainer 2026-09-14). Both narrow a schema already on this list - // — `InputShorthandSchema` is `Omit`, - // `UiCalendarSchema` is `Omit` — and both were + // — `InputShorthandSchema` is `InputSchema` without `type` / `inputType`, + // `UiCalendarSchema` is `CalendarSchema` without `type` — and both were // already published on `@object-ui/types/form` and `@object-ui/types/zod`, // so these two lines ALIGN the third entry point rather than widen the // surface. Purely ADDITIVE, the same route objectui#7697 took for diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 01e49b71a5..d5f415b544 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1166,6 +1166,39 @@ export interface ObjectGridSchema extends BaseSchema { /** Icon name (Lucide icon identifier) */ icon?: string; }; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-grid` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-grid` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-grid` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectGridRenderer` (`packages/plugin-grid`), is + * `any`-typed and hands the node to `ObjectGrid`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * What it renders instead: the records of `objectName` as a data grid, + * shaped by `columns`, `filter`, `sort`, `grouping`, `selection` and the + * rest of this declaration. + * + * @deprecated Not a channel `object-grid` reads — nothing renders it. + */ + children?: never; } /** @@ -1833,6 +1866,43 @@ export interface ObjectFormSchema extends BaseSchema { /** Show a fullscreen-edit affordance for textarea / rich-text fields. */ fullscreenLongText?: boolean; }; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-form` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-form` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-form` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectFormRenderer` (`packages/plugin-form`), is + * `any`-typed and hands the node to `ObjectForm`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * The `form` node `ObjectForm` renders DOES draw `children`, and that is + * exactly why this was checked rather than assumed: every `form` node the + * package builds is assembled key by key, and none copies this node's own + * channels into it. + * + * What it renders instead: a form over `objectName` built from `fields` / + * `sections` / `customFields`, in the `mode` and `formType` declared here. + * + * @deprecated Not a channel `object-form` reads — nothing renders it. + */ + children?: never; } /** @@ -2208,6 +2278,44 @@ export interface ObjectViewSchema extends BaseSchema { type: 'share' | 'settings' | 'duplicate' | 'delete'; icon?: string; }>; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-view` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-view` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-view` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectViewRenderer` (`packages/plugin-view`), is + * `any`-typed and hands the node to `ObjectView`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * It delegates each sub-view to a node it assembles from named keys, never + * by spreading this node, and every type it can generate — `object-kanban`, + * `object-calendar`, `object-gallery`, `object-timeline`, `object-gantt`, + * `object-map`, `object-tree`, `object-chart` — reads neither channel + * either. + * + * What it renders instead: the object's views — `listViews`, + * `defaultViewType`, `table`, `form` — under its own toolbar. + * + * @deprecated Not a channel `object-view` reads — nothing renders it. + */ + children?: never; } /** @@ -2931,6 +3039,38 @@ export interface ObjectMapSchema extends BaseSchema { * avoid colliding with `BaseSchema.style` (inline CSS properties). */ mapStyle?: string; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-map` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-map` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-map` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectMapRenderer` (`packages/plugin-map`), is + * `any`-typed and hands the node to `ObjectMap`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * What it renders instead: the records of `objectName` as markers placed by + * `latitudeField` / `longitudeField` or `locationField`. + * + * @deprecated Not a channel `object-map` reads — nothing renders it. + */ + children?: never; } /** @@ -2978,6 +3118,41 @@ export interface ObjectTreeSchema extends BaseSchema { * Default expansion depth (0 = roots only). When omitted, all nodes expand. */ defaultExpandedDepth?: number; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-tree` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-tree` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-tree` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectTreeRenderer` (`packages/plugin-tree`), is + * `any`-typed and hands the node to `ObjectTree`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * `ObjectTree` does read a `children` — on the `TreeNode` hierarchy it + * builds from the fetched records, never on this node. + * + * What it renders instead: the records of `objectName` as a hierarchy linked + * by `parentField` and labelled by `labelField`. + * + * @deprecated Not a channel `object-tree` reads — nothing renders it. + */ + children?: never; } /** @@ -3316,6 +3491,38 @@ export interface ObjectGanttSchema extends BaseSchema { * it. Same key and meaning as {@link ObjectGridSchema.searchableFields}. */ searchableFields?: string[]; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-gantt` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-gantt` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-gantt` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectGanttRenderer` (`packages/plugin-gantt`), is + * `any`-typed and hands the node to `ObjectGantt`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * What it renders instead: the records of `objectName` as bars from + * `startDateField` to `endDateField`. + * + * @deprecated Not a channel `object-gantt` reads — nothing renders it. + */ + children?: never; } /** @@ -3549,6 +3756,38 @@ export interface ObjectCalendarSchema extends BaseSchema { * vocabularies cannot fork. */ sort?: SortConfig[]; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-calendar` reads + * NEITHER content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-calendar` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-calendar` reads + * NEITHER content channel, so an authored child list here rendered NOTHING: + * no error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectCalendarRenderer` (`packages/plugin-calendar`), + * is `any`-typed and hands the node to `ObjectCalendar`, which reads it as + * this type. `SchemaRenderer` strips both keys out of the props bag it + * spreads, so neither reaches the component by another route, and the + * registration declares no `children` slot (objectui#9910). + * + * What it renders instead: the records of `objectName` as events from + * `startDateField` to `endDateField`. + * + * @deprecated Not a channel `object-calendar` reads — nothing renders it. + */ + children?: never; } /** @@ -4171,6 +4410,38 @@ export interface ObjectKanbanSchema extends BaseSchema { * function type rather than a tombstone. */ onQuickAdd?: (columnId: string, title: string) => void; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-kanban` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-kanban` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-kanban` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectKanbanRenderer` (`packages/plugin-kanban`), is + * `any`-typed and hands the node to `ObjectKanban`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * What it renders instead: the records of `objectName` as lanes grouped by + * `groupBy`, with `cardFields` on each card. + * + * @deprecated Not a channel `object-kanban` reads — nothing renders it. + */ + children?: never; } /** @@ -4718,6 +4989,38 @@ export interface ObjectChartSchema extends BaseSchema { * (objectui#10608): this list is the one author-facing value-axis spelling. */ yAxis?: SpecChartAxis[]; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-chart` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `object-chart` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-chart` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `ObjectChartBlock` (`packages/plugin-charts`), is + * `any`-typed and hands the node to `ObjectChart`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * What it renders instead: a chart of `objectName` (or inline `data`) drawn + * by `chartType` from `aggregate` / `series`. + * + * @deprecated Not a channel `object-chart` reads — nothing renders it. + */ + children?: never; } /** diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 3a0237a738..7392666118 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -983,6 +983,39 @@ export interface DetailViewSchema extends BaseSchema { * Activity history entries for this record */ activities?: ActivityEntry[]; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `detail-view` reads NEITHER + * content channel; see `children` below for the measurement. + * + * {@link BaseSchema} already refuses `body` (objectui#6771), but its refusal + * names `children` as the remedy, which this node does not read either. The + * member is restated here so both faces point at what the node renders + * instead. + * + * @deprecated Not a channel `detail-view` reads — nothing renders it. + */ + body?: never; + /** + * REFUSED BY NAME (objectui#9256, ADR-0049) — `detail-view` reads NEITHER + * content channel, so an authored child list here rendered NOTHING: no + * error, no warning, no element. + * + * Measured with the TypeScript type checker, not grep: no `body` / + * `children` read is filed under this declaration, while the same instrument + * does see it as the receiver of the keys the renderer reads. The + * registration hop, `DetailViewRenderer` (`packages/plugin-detail`), is + * `any`-typed and hands the node to `DetailView`, which reads it as this + * type. `SchemaRenderer` strips both keys out of the props bag it spreads, + * so neither reaches the component by another route, and the registration + * declares no `children` slot (objectui#9910). + * + * What it renders instead: one record of `objectName` through `fields` / + * `sections` / `tabs`; its own node slots are `header`, `footer` and each + * tab's `content`. + * + * @deprecated Not a channel `detail-view` reads — nothing renders it. + */ + children?: never; } /** diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index e152dc47c0..d78c187c47 100644 --- a/packages/types/src/zod/form.zod.ts +++ b/packages/types/src/zod/form.zod.ts @@ -17,7 +17,7 @@ */ import { z } from 'zod'; -import { aliasKeyRefusal, handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; // The predicate wire shape (`string | { dialect?, source }`, #2212) was a @@ -626,8 +626,8 @@ const CalendarObjectSchema = BaseSchema.extend({ + 'What it renders instead: `defaultValue`, `mode`, `value`. ' + 'Both readers answer NEITHER: the bare key resolves to plugin-calendar’s calendar VIEW, and this ' + 'key set describes the `ui:calendar` date-picker primitive. This refusal propagates onto ' - + '`UiCalendarSchema`, which `.extend()`s this mirror — on the MIRROR only; the TypeScript `Omit` ' - + 'collapses into `BaseSchema`’s index signature and carries no member of this declaration at all. ' + + '`UiCalendarSchema` on both faces: the mirror `.extend()`s this one, and the TypeScript face ' + + 'inherits this declaration through an index-signature-safe `Omit` (objectui#9256). ' + '`view:calendar` is the measured SOLE owner of the bare `calendar` key (`ui:calendar` passes `skipFallback: true`); ' + 're-derive with `pnpm check:registry-bare-names --table` (objectui#9264).', ), @@ -640,8 +640,8 @@ const CalendarObjectSchema = BaseSchema.extend({ + 'What it renders instead: `defaultValue`, `mode`, `value`. ' + 'Both readers answer NEITHER: the bare key resolves to plugin-calendar’s calendar VIEW, and this ' + 'key set describes the `ui:calendar` date-picker primitive. This refusal propagates onto ' - + '`UiCalendarSchema`, which `.extend()`s this mirror — on the MIRROR only; the TypeScript `Omit` ' - + 'collapses into `BaseSchema`’s index signature and carries no member of this declaration at all. ' + + '`UiCalendarSchema` on both faces: the mirror `.extend()`s this one, and the TypeScript face ' + + 'inherits this declaration through an index-signature-safe `Omit` (objectui#9256). ' + '`view:calendar` is the measured SOLE owner of the bare `calendar` key (`ui:calendar` passes `skipFallback: true`); ' + 're-derive with `pnpm check:registry-bare-names --table` (objectui#9264).', ), @@ -1024,6 +1024,13 @@ const SHORTHAND_INPUT_TYPE_REFUSAL = '(A form FIELD is a different position: inside `fields: [ … ]` an authored `inputType` still ' + 'wins over the one the field type implies, and this refusal does not reach there.)'; +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `InputShorthandSchema`. */ +const INPUT_SHORTHAND_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'email` / `password', + 'both registrations spread the node into the `input` renderer with `inputType` pinned, and that renderer reads it as `InputSchema`', + 'one input field — `label`, `placeholder`, `value` / `defaultValue`, `description`, `error`, `required`', +); + /** * Input Shorthand Schema — the `email` / `password` aliases * `packages/components/src/renderers/form/input.tsx` registers (objectui#8499). @@ -1092,6 +1099,11 @@ export const InputShorthandSchema = InputSchema.omit({ type: true, inputType: tr // restatement is gone rather than left standing as a duplicate of it. // `__tests__/input-wrapper-class-mirrored-8072.test.ts` pins this arm's // membership and its refusal, so the inheritance is measured, not assumed. + // + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(INPUT_SHORTHAND_NEITHER_CHANNEL), + children: retirementTombstone(INPUT_SHORTHAND_NEITHER_CHANNEL), }); /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index ef5e4c1876..59c2b95496 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -48,7 +48,7 @@ import { checkListViewCalendarVisualization, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { aliasKeyRefusal, handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { aliasKeyRefusal, handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; import { DrillDownConfigSchema } from './data-display.zod.js'; // The kanban CARD vocabulary has one authority (`./complex.zod.ts`); the // `object-kanban` lane below reads it rather than restating it (objectui#8913). @@ -236,6 +236,13 @@ const OBJECT_GRID_EXPORT_OPTIONS_GUIDANCE = '`exportOptions.formats`, so an array is silently dropped for the csv/json ' + 'default. Write `{ "formats": ["csv", "xlsx"] }` instead.'; +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectGridSchema`. */ +const OBJECT_GRID_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-grid', + 'its `any`-typed registration hands the node to `ObjectGrid`, which reads it as `ObjectGridSchema`', + 'the records of `objectName` as a data grid, shaped by `columns`, `filter`, `sort`, `grouping` and `selection`', +); + /** * ObjectGrid Schema */ @@ -375,6 +382,10 @@ export const ObjectGridSchema = BaseSchema.extend({ // rejected `onNavigate` by name (`unrecognized_keys`), while this mirror // accepted and KEPT it. The refusal message points at the node-type spelling. onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_GRID_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_GRID_NEITHER_CHANNEL), }); /** @@ -391,6 +402,13 @@ const RECORD_ID_IS_A_STRING_GUIDANCE = + 'A backend whose primary keys are numeric converts at its OWN adapter boundary, in one typed ' + 'place, so every author, every caller and every adapter sees one shape.'; +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectFormSchema`. */ +const OBJECT_FORM_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-form', + 'its `any`-typed registration hands the node to `ObjectForm`, which reads it as `ObjectFormSchema` and assembles every `form` node it renders key by key, never copying either channel', + 'a form over `objectName` built from `fields` / `sections` / `customFields`, in the declared `mode` and `formType`', +); + /** * ObjectForm Schema */ @@ -474,8 +492,19 @@ export const ObjectFormSchema = BaseSchema.extend({ onOpenChange: handlerKeyRefusal('onOpenChange', 'runtime-slot', 'Modal/drawer open-state handler'), onStepChange: handlerKeyRefusal('onStepChange', 'runtime-slot', 'Wizard step change handler'), onSuccess: handlerKeyRefusal('onSuccess', 'runtime-slot', 'Submit success handler'), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_FORM_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_FORM_NEITHER_CHANNEL), }); +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectViewSchema`. */ +const OBJECT_VIEW_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-view', + 'its `any`-typed registration hands the node to `ObjectView`, which reads it as `ObjectViewSchema` and delegates each sub-view to an `object-*` node it assembles key by key, every one of which reads neither channel', + 'the object’s views — `listViews`, `defaultViewType`, `table`, `form` — under its own toolbar', +); + /** * ObjectView Schema * @@ -599,6 +628,10 @@ export const ObjectViewSchema = BaseSchema.extend({ // signature (`mode: 'view' | 'edit'` rather than the grid's `action?: string`) // and a different supplier. Judged separately for that reason. onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'Record navigation handler'), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_VIEW_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_VIEW_NEITHER_CHANNEL), }) // ⭐ objectui#8355 / objectui#10321 — the by-name pointers for the named alias // refusals on a named view: two sibling checks on this one door (the calendar @@ -1881,6 +1914,13 @@ function requireRecordSource(type: 'object-map' | 'object-gantt' | 'object-calen }; } +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectMapSchema`. */ +const OBJECT_MAP_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-map', + 'its `any`-typed registration hands the node to `ObjectMap`, which reads it as `ObjectMapSchema`', + 'the records of `objectName` as markers placed by `latitudeField` / `longitudeField` or `locationField`', +); + /** * ObjectMap Schema * @@ -1910,8 +1950,19 @@ export const ObjectMapSchema = BaseSchema.extend({ locationField: z.string().optional().describe('Location field (internal flat form; prefer map.locationField)'), titleField: z.string().optional().describe('Title field (internal flat form; prefer map.titleField)'), mapStyle: z.string().optional().describe('MapLibre style URL/spec (overrides the public demo default)'), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_MAP_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_MAP_NEITHER_CHANNEL), }).superRefine(requireRecordSource('object-map')); +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectTreeSchema`. */ +const OBJECT_TREE_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-tree', + 'its `any`-typed registration hands the node to `ObjectTree`, which reads it as `ObjectTreeSchema`; the `children` it reads belong to the record hierarchy it builds, not to this node', + 'the records of `objectName` as a hierarchy linked by `parentField` and labelled by `labelField`', +); + /** * ObjectTree (tree-grid) Schema */ @@ -1931,8 +1982,19 @@ export const ObjectTreeSchema = BaseSchema.extend({ labelField: z.string().optional().describe('Field rendered indented in the first column'), fields: z.array(z.string()).optional().describe('Additional flat columns'), defaultExpandedDepth: z.number().optional().describe('Default expansion depth (0 = roots only)'), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_TREE_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_TREE_NEITHER_CHANNEL), }); +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectGanttSchema`. */ +const OBJECT_GANTT_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-gantt', + 'its `any`-typed registration hands the node to `ObjectGantt`, which reads it as `ObjectGanttSchema`', + 'the records of `objectName` as bars from `startDateField` to `endDateField`', +); + /** * ObjectGantt Schema * @@ -2078,8 +2140,19 @@ export const ObjectGanttSchema = BaseSchema.extend({ // both onto a gantt node, because the chart queries for itself. search: z.string().optional().describe('Full-text search term, forwarded as $search (the server resolves the matched fields, ADR-0061)'), searchableFields: z.array(z.string()).optional().describe('Narrows the fields `search` matches, forwarded as $searchFields alongside a term'), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_GANTT_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_GANTT_NEITHER_CHANNEL), }).superRefine(requireRecordSource('object-gantt')); +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectCalendarSchema`. */ +const OBJECT_CALENDAR_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-calendar', + 'its `any`-typed registration hands the node to `ObjectCalendar`, which reads it as `ObjectCalendarSchema`', + 'the records of `objectName` as events from `startDateField` to `endDateField`', +); + /** * ObjectCalendar Schema * @@ -2191,6 +2264,10 @@ export const ObjectCalendarSchema = BaseSchema.extend({ // time". filter: z.array(z.any()).optional().describe('Query filter, forwarded as $filter with its context tokens ({current_user_id}, {current_org_id}, date macros) resolved first'), sort: z.array(SortConfigSchema).optional().describe('Sort configuration, forwarded as $orderby (array only; the legacy string clause is retired — objectui#8221)'), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_CALENDAR_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_CALENDAR_NEITHER_CHANNEL), }).superRefine(requireRecordSource('object-calendar')); /** @@ -2353,6 +2430,13 @@ function requireKanbanRecordSource( }); } +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectKanbanSchema`. */ +const OBJECT_KANBAN_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-kanban', + 'its `any`-typed registration hands the node to `ObjectKanban`, which reads it as `ObjectKanbanSchema`', + 'the records of `objectName` as lanes grouped by `groupBy`, with `cardFields` on each card', +); + // objectui#7322 — `groupBy` and `limit` are the keys `ObjectKanban.tsx` reads // (thirteen `schema.groupBy` sites; the row cap lowered into the query as // `$top: resolveRowLimit(schema.limit, DEFAULT_KANBAN_LIMIT)`, re-spelled by @@ -2491,6 +2575,10 @@ export const ObjectKanbanSchema = BaseSchema.extend({ onCardClick: handlerKeyRefusal('onCardClick', 'runtime-slot', 'Card click handler'), onCardMove: handlerKeyRefusal('onCardMove', 'retired', 'Card move handler'), onQuickAdd: handlerKeyRefusal('onQuickAdd', 'runtime-slot', 'Quick Add handler'), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_KANBAN_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_KANBAN_NEITHER_CHANNEL), }).superRefine(requireKanbanRecordSource); /** @@ -2578,6 +2666,13 @@ function requireObjectChartFamily( }); } +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `ObjectChartSchema`. */ +const OBJECT_CHART_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'object-chart', + 'its `any`-typed registration hands the node to `ObjectChart`, which reads it as `ObjectChartSchema`', + 'a chart of `objectName` (or inline `data`) drawn by `chartType` from `aggregate` / `series`', +); + /** * ObjectChart Schema */ @@ -2788,6 +2883,10 @@ export const ObjectChartSchema = BaseSchema.extend({ 'AUTHORABLE — value (y) axes: an ARRAY of @objectstack/spec ChartAxis objects, by reference (field required, strict). ' + 'The first entry is the primary axis; a second entry declares the right-hand axis.', ), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(OBJECT_CHART_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_CHART_NEITHER_CHANNEL), }).superRefine(requireObjectChartFamily); /** diff --git a/packages/types/src/zod/tombstone.zod.ts b/packages/types/src/zod/tombstone.zod.ts index 2187b5538f..8ff6af3b93 100644 --- a/packages/types/src/zod/tombstone.zod.ts +++ b/packages/types/src/zod/tombstone.zod.ts @@ -240,3 +240,42 @@ export function retiredNodeType(type: string, guidance: string) { }) .describe(text); } + +/** + * The guidance for a node whose renderer reads NEITHER content channel + * (objectui#9256, family D; the E3 residual moved there by the maintainer's + * ruling on objectui#8284, Q2 A). Feed the ONE string to both members, each a + * {@link retirementTombstone}: + * + * ```ts + * const OBJECT_GRID_NEITHER_CHANNEL = neitherContentChannelGuidance('object-grid', route, renders); + * BaseSchema.extend({ + * …, + * body: retirementTombstone(OBJECT_GRID_NEITHER_CHANNEL), + * children: retirementTombstone(OBJECT_GRID_NEITHER_CHANNEL), + * }) + * ``` + * + * Each key stays a MEMBER, so the parity ratchet's key sets stay equal against + * the TypeScript twin's `?: never` pair, and the refusal is BY NAME at the key's + * own path with code `invalid_type`. It returns a string rather than the two + * members to spread, on purpose: a computed spread is a shape + * `scripts/check-handler-key-read-sites.mjs` cannot follow, and that gate stops + * judging an arm whose member set it cannot resolve. + * + * Why `body` is restated although `BaseSchema` already refuses it + * (objectui#6771): that refusal names `children` as the remedy, and on these + * nodes `children` is dead too. The string names what the node renders instead. + * + * @param node the registered `type`, spelled into the message + * @param route how the node reaches the component that reads it, so the + * reader can re-derive the measurement + * @param renders what the node renders instead of a child list + */ +export function neitherContentChannelGuidance(node: string, route: string, renders: string): string { + return 'REFUSED (objectui#9256, ADR-0049) — `' + node + '` reads NEITHER content channel: measured with the ' + + 'TypeScript type checker over one program per workspace package on a BUILT tree, no renderer read ' + + 'consumes `body` or `children` for this node — ' + route + ' — and `SchemaRenderer` strips both out ' + + 'of the props bag it spreads. An authored value therefore rendered NOTHING — no error, no warning, ' + + 'no element. What it renders instead: ' + renders + '.'; +} diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 9b06cc60f1..c5087a85a6 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -18,7 +18,7 @@ import { z } from 'zod'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; -import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { handlerKeyRefusal, neitherContentChannelGuidance, retirementTombstone } from './tombstone.zod.js'; import { ListViewSchema as SpecListViewSchema } from '@objectstack/spec/ui'; import { SelectOptionSchema as SpecSelectOptionSchema } from '@objectstack/spec/data'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -161,6 +161,13 @@ const RECORD_ID_IS_A_STRING_GUIDANCE = + 'A backend whose primary keys are numeric converts at its OWN adapter boundary, in one typed ' + 'place, so every author, every caller and every adapter sees one shape.'; +/** objectui#9256 (E3 residual): ONE refusal string for both content channels of `DetailViewSchema`. */ +const DETAIL_VIEW_NEITHER_CHANNEL = neitherContentChannelGuidance( + 'detail-view', + 'its `any`-typed registration hands the node to `DetailView`, which reads it as `DetailViewSchema`', + 'one record of `objectName` through `fields` / `sections` / `tabs`; its own node slots are `header`, `footer` and each tab\u2019s `content`', +); + /** * Detail View Schema */ @@ -306,6 +313,10 @@ export const DetailViewSchema = BaseSchema.extend({ + 'strings, and it renders through the same component, so nothing about the ' + 'result is lost — only the second door.', ), + // objectui#9256 (E3 residual, ruling Q2 A on objectui#8284): the renderer reads NEITHER content + // channel, so both are refused by name here as on the TypeScript twin, each kept a MEMBER. + body: retirementTombstone(DETAIL_VIEW_NEITHER_CHANNEL), + children: retirementTombstone(DETAIL_VIEW_NEITHER_CHANNEL), }); /**