From b8962f0a38eb82f842cc7084df43bf6d7e39887f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 03:45:15 +0000 Subject: [PATCH 1/2] feat(types)!: refuse both content channels on the E3 residual, and stop the shorthand Omit erasing inherited members Part of objectui#9256 (the 15-key E3 residual the ruling on objectui#8284, Q2 A, moved onto that card). Twelve registrations have a published arm yet their renderer reads neither content channel: the nine object-* views, detail-view, email and password. Both body and children are now ?: never on the TypeScript face and a by-name refusal on the zod mirror (neitherContentChannel, a pair of retirementTombstone members), each kept a member so the parity ratchet's key sets stay equal. The message names what the node renders instead. InputShorthandSchema and UiCalendarSchema were spelled as a plain Omit over an interface carrying BaseSchema's index signature, which erased every inherited member on the published face. Both now inherit through OmitDeclared, a key-remapping Omit that keeps the named members; the two Omit COLLAPSE rows leave the MirroredUndeclared ledger and the inherited onChange runtime slot enters KnownDrift. br, hr and img are held: their only arm is the 38-tag HtmlElementSchema family, whose renderer draws children for the other 35. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- .../9256-e3-residual-content-channels.md | 43 +++ .../content-channel-e3-residual-9256.test.ts | 333 ++++++++++++++++++ .../content-channel-family-d-9256.test.ts | 24 +- .../__tests__/form-barrel-mirror-9406.test.ts | 2 +- .../__tests__/object-view-spec-parity.test.ts | 23 +- .../src/__tests__/zod-mirror-parity.test.ts | 149 +++----- packages/types/src/form.ts | 113 ++++-- packages/types/src/index.ts | 4 +- packages/types/src/objectql.ts | 303 ++++++++++++++++ packages/types/src/views.ts | 33 ++ packages/types/src/zod/form.zod.ts | 18 +- packages/types/src/zod/objectql.zod.ts | 65 +++- packages/types/src/zod/tombstone.zod.ts | 34 ++ packages/types/src/zod/views.zod.ts | 9 +- 14 files changed, 1002 insertions(+), 151 deletions(-) create mode 100644 .changeset/9256-e3-residual-content-channels.md create mode 100644 packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts 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..d16be118de --- /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 (`neitherContentChannel`, a pair of + * `retirementTombstone`s), 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 6e47db0299..6f6a748780 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 @@ -219,7 +219,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 }; @@ -559,20 +559,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, @@ -580,9 +571,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 0c9d3a9552..976fe52586 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. - * - **49 entries** in `KnownDrift`, **89 keys** across them — 46 / 84 until + * - **50 entries** in `KnownDrift`, **90 keys** across them — 49 / 89 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 46 / 84 until * objectui#10859 armed the three `@object-ui/plugin-ai` node types in `ai.zod.ts`: * THREE entries are NEW, one per armed node, and together they carry the five `on*` * handler keys — `ai.zod.ts#AIFormAssistSchema` (`onApplySuggestion`, @@ -665,7 +670,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 49 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 50 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 @@ -2212,6 +2217,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. */ @@ -3643,7 +3655,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. @@ -3661,6 +3673,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 @@ -3691,17 +3713,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 @@ -3734,85 +3756,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: @@ -5620,8 +5572,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 9a34496c7c..3669ca30ca 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; } /** @@ -2924,6 +3032,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; } /** @@ -2971,6 +3111,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; } /** @@ -3307,6 +3482,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; } /** @@ -3540,6 +3747,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; } /** @@ -4162,6 +4401,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; } /** @@ -4709,6 +4980,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..cb3207463e 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, neitherContentChannel, 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).', ), @@ -1092,6 +1092,14 @@ 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. + ...neitherContentChannel( + '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`', + ), }); /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 0023c6f884..eb8ce32cf9 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, neitherContentChannel, 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). @@ -375,6 +375,13 @@ 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. + ...neitherContentChannel( + '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`', + ), }); /** @@ -474,6 +481,13 @@ 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. + ...neitherContentChannel( + '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`', + ), }); /** @@ -599,6 +613,13 @@ 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. + ...neitherContentChannel( + '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', + ), }) // ⭐ 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 @@ -1910,6 +1931,13 @@ 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. + ...neitherContentChannel( + '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`', + ), }).superRefine(requireRecordSource('object-map')); /** @@ -1931,6 +1959,13 @@ 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. + ...neitherContentChannel( + '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`', + ), }); /** @@ -2078,6 +2113,13 @@ 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. + ...neitherContentChannel( + '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`', + ), }).superRefine(requireRecordSource('object-gantt')); /** @@ -2191,6 +2233,13 @@ 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. + ...neitherContentChannel( + '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`', + ), }).superRefine(requireRecordSource('object-calendar')); /** @@ -2491,6 +2540,13 @@ 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. + ...neitherContentChannel( + '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', + ), }).superRefine(requireKanbanRecordSource); /** @@ -2788,6 +2844,13 @@ 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. + ...neitherContentChannel( + '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`', + ), }).superRefine(requireObjectChartFamily); /** diff --git a/packages/types/src/zod/tombstone.zod.ts b/packages/types/src/zod/tombstone.zod.ts index 2187b5538f..d808f67de2 100644 --- a/packages/types/src/zod/tombstone.zod.ts +++ b/packages/types/src/zod/tombstone.zod.ts @@ -240,3 +240,37 @@ export function retiredNodeType(type: string, guidance: string) { }) .describe(text); } + +/** + * Declare BOTH content channels refused on a node whose renderer reads NEITHER + * (objectui#9256, family D; the E3 residual moved there by the maintainer's + * ruling on objectui#8284, Q2 A). Spread the result into the node's shape: + * + * ```ts + * BaseSchema.extend({ …, ...neitherContentChannel('object-grid', route, renders) }) + * ``` + * + * Each key stays a MEMBER — a {@link retirementTombstone} — 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`. + * + * 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. ONE string per node feeds both keys and both + * author-facing channels (the issue message and the `.describe()` metadata), and + * it 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 neitherContentChannel(node: string, route: string, renders: string) { + const guidance = + '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 + '.'; + return { body: retirementTombstone(guidance), children: retirementTombstone(guidance) }; +} diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 9b06cc60f1..a68134468c 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, neitherContentChannel, 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'; @@ -306,6 +306,13 @@ 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. + ...neitherContentChannel( + '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`', + ), }); /** From 14fd13c5193022bc067c5f888cf030238779c53f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 04:03:45 +0000 Subject: [PATCH 2/2] fix(types): feed both tombstones one guidance string instead of spreading a call A computed spread in a zod arm is a shape check-handler-key-read-sites cannot follow, and the gate stops judging an arm whose member set it cannot resolve: the spread blinded it on all eleven arms and turned its detail-view onTabChange waiver stale. Each arm now declares body and children explicitly as retirementTombstone members fed one string from neitherContentChannelGuidance, so the gate reads every arm again (0 reads left unjudged). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01DuWo5bdP9SdVebamn99GGk --- .../content-channel-e3-residual-9256.test.ts | 6 +- packages/types/src/zod/form.zod.ts | 16 ++- packages/types/src/zod/objectql.zod.ts | 128 +++++++++++------- packages/types/src/zod/tombstone.zod.ts | 31 +++-- packages/types/src/zod/views.zod.ts | 16 ++- 5 files changed, 123 insertions(+), 74 deletions(-) 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 index d16be118de..4bfd650a07 100644 --- a/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts +++ b/packages/types/src/__tests__/content-channel-e3-residual-9256.test.ts @@ -19,9 +19,9 @@ * * 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 (`neitherContentChannel`, a pair of - * `retirementTombstone`s), each kept a MEMBER so `zod-mirror-parity`'s key sets - * stay equal. `body` was already refused by `BaseSchema` (objectui#6771), but + * 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. * diff --git a/packages/types/src/zod/form.zod.ts b/packages/types/src/zod/form.zod.ts index cb3207463e..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, neitherContentChannel, 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 @@ -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). @@ -1095,11 +1102,8 @@ export const InputShorthandSchema = InputSchema.omit({ type: true, inputType: tr // // 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. - ...neitherContentChannel( - '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`', - ), + 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 eb8ce32cf9..b12a352d51 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, neitherContentChannel, 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 */ @@ -377,11 +384,8 @@ export const ObjectGridSchema = BaseSchema.extend({ 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. - ...neitherContentChannel( - '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`', - ), + body: retirementTombstone(OBJECT_GRID_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_GRID_NEITHER_CHANNEL), }); /** @@ -398,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 */ @@ -483,13 +494,17 @@ export const ObjectFormSchema = BaseSchema.extend({ 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. - ...neitherContentChannel( - '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`', - ), + 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 * @@ -615,11 +630,8 @@ export const ObjectViewSchema = BaseSchema.extend({ 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. - ...neitherContentChannel( - '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', - ), + 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 @@ -1902,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 * @@ -1933,13 +1952,17 @@ export const ObjectMapSchema = BaseSchema.extend({ 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. - ...neitherContentChannel( - '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`', - ), + 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 */ @@ -1961,13 +1984,17 @@ export const ObjectTreeSchema = BaseSchema.extend({ 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. - ...neitherContentChannel( - '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`', - ), + 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 * @@ -2115,13 +2142,17 @@ export const ObjectGanttSchema = BaseSchema.extend({ 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. - ...neitherContentChannel( - '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`', - ), + 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 * @@ -2235,11 +2266,8 @@ export const ObjectCalendarSchema = BaseSchema.extend({ 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. - ...neitherContentChannel( - '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`', - ), + body: retirementTombstone(OBJECT_CALENDAR_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_CALENDAR_NEITHER_CHANNEL), }).superRefine(requireRecordSource('object-calendar')); /** @@ -2402,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 @@ -2542,11 +2577,8 @@ export const ObjectKanbanSchema = BaseSchema.extend({ 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. - ...neitherContentChannel( - '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', - ), + body: retirementTombstone(OBJECT_KANBAN_NEITHER_CHANNEL), + children: retirementTombstone(OBJECT_KANBAN_NEITHER_CHANNEL), }).superRefine(requireKanbanRecordSource); /** @@ -2634,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 */ @@ -2846,11 +2885,8 @@ export const ObjectChartSchema = BaseSchema.extend({ ), // 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. - ...neitherContentChannel( - '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`', - ), + 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 d808f67de2..8ff6af3b93 100644 --- a/packages/types/src/zod/tombstone.zod.ts +++ b/packages/types/src/zod/tombstone.zod.ts @@ -242,35 +242,40 @@ export function retiredNodeType(type: string, guidance: string) { } /** - * Declare BOTH content channels refused on a node whose renderer reads NEITHER + * 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). Spread the result into the node's shape: + * ruling on objectui#8284, Q2 A). Feed the ONE string to both members, each a + * {@link retirementTombstone}: * * ```ts - * BaseSchema.extend({ …, ...neitherContentChannel('object-grid', route, renders) }) + * 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 — a {@link retirementTombstone} — 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`. + * 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. ONE string per node feeds both keys and both - * author-facing channels (the issue message and the `.describe()` metadata), and - * it names what the node renders instead. + * 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 neitherContentChannel(node: string, route: string, renders: string) { - const guidance = - 'REFUSED (objectui#9256, ADR-0049) — `' + node + '` reads NEITHER content channel: measured with the ' +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 + '.'; - return { body: retirementTombstone(guidance), children: retirementTombstone(guidance) }; } diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index a68134468c..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, neitherContentChannel, 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 */ @@ -308,11 +315,8 @@ export const DetailViewSchema = BaseSchema.extend({ ), // 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. - ...neitherContentChannel( - '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`', - ), + body: retirementTombstone(DETAIL_VIEW_NEITHER_CHANNEL), + children: retirementTombstone(DETAIL_VIEW_NEITHER_CHANNEL), }); /**