diff --git a/.changeset/6124-handler-keys-json-refusal.md b/.changeset/6124-handler-keys-json-refusal.md new file mode 100644 index 000000000..641c7a769 --- /dev/null +++ b/.changeset/6124-handler-keys-json-refusal.md @@ -0,0 +1,15 @@ +--- +"@object-ui/types": minor +--- + +`@object-ui/types/zod`: the 58 `on*` handler keys declared `z.function()` now refuse BY NAME (objectui#6124) + +The zod mirrors declared 58 `on*` keys (26 distinct — `onClick`, `onChange`, `onOpenChange`, `onValueChange`, `onCardMove`, …) across `complex`, `data-display`, `disclosure`, `feedback`, `form`, `layout`, `navigation` and `overlay` as `z.function()`, a declaration no JSON document can satisfy on a JSON-authored vocabulary. A JSON author who wrote `onClick: { "action": "toast" }` was already refused, with zod's bare `invalid_type … expected function, received object` naming the key and nothing else. + +Every one of the 58 sites is now a named refusal arm in the shape #5099 landed for `FieldConstraintsSchema.pattern.value` (`z.custom` + guidance, via `handlerKeyRefusal()` in `zod/tombstone.zod.ts`): the message names the key, says why JSON cannot author it, and points at the node-type spelling PR #6498 established (`{ "type": "toast" }`, an `action:button` node with a declared action). The same text is the key's `.describe()` metadata — one string, two channels. Deleting the keys was measured and refused: under `BaseSchema.passthrough()` an undeclared key is not refused, it is KEPT, and `onClick` rides `SDUI_DOM_PASS_THROUGH_KEYS` into the DOM listener slot where React throws at click. + +**Accept-set change (Clause ②).** A live function value — which parsed green before — is now refused on the JSON mirror too. The programmatic face reaches renderers through the TypeScript interface and React props, never through `safeParse`; on this tree the only runtime `safeParse` doors into these mirrors are the CLI validators and the exported `validateSchema` / `safeValidateSchema` helpers, none of which is fed a function-bearing object. Code that ran a host-supplied function through one of these mirrors must stop doing so. + +**TypeScript face, measured per key.** 36 keys whose function value reaches a renderer at runtime (read off `schema.*`, called as a React prop after `SchemaRenderer`'s spread, or spread onto a Radix root / DOM listener slot) keep their function type. 22 keys nothing reads carry the `?: never` tombstone (ADR-0049): `KanbanSchema.onColumnAdd` / `onCardAdd`, `CarouselSchema.onSlideChange`, `ChatbotSchema.onSendMessage`, `AlertSchema.onDismiss`, `ListItem.onClick`, `TreeViewSchema.onSelectChange` / `onExpandChange`, `ToastSchema.onDismiss`, `RadioGroupSchema` / `SwitchSchema` / `ToggleSchema` / `SliderSchema` / `CalendarSchema` / `ComboboxSchema` / `CommandSchema` `.onChange`, `InputOTPSchema.onComplete`, `BreadcrumbItem.onClick`, `SidebarSchema.onCollapsedChange`, `ButtonGroupButton.onClick`, `AlertDialogSchema.onConfirm` / `onCancel`. Assigning one of those is now a `tsc` error naming the key. + +Out of scope, per the ruling: the four non-`on*` `z.function()` keys (`cell`, `custom`, `validate`, `renderCellEditor`) stay as they are; `EventHandlersSchema` is objectui#6910's card. diff --git a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts index 0ef454459..fc8ebd79d 100644 --- a/examples/schema-catalog/test/component-fixture-declared-keys.test.ts +++ b/examples/schema-catalog/test/component-fixture-declared-keys.test.ts @@ -136,7 +136,14 @@ describe('toast fixtures: the registered spelling, not an action object off `onC const result = ButtonSchema.safeParse(retired); expect(result.success).toBe(false); expect(result.error?.issues[0]?.path).toEqual(['onClick']); - expect(result.error?.issues[0]?.message).toContain('expected function, received object'); + // Still RED, and now BY NAME: objectui#6124 replaced `ButtonSchema.onClick`'s + // bare `z.function()` (zod's "expected function, received object") with a + // named refusal arm that points at the node-type spelling this block's + // fixtures already use. The verdict this block leans on did not move; the + // message an author reads did. + expect(result.error?.issues[0]?.code).toBe('custom'); + expect(result.error?.issues[0]?.message).toContain('`onClick` is a RUNTIME SLOT'); + expect(result.error?.issues[0]?.message).toContain('{ "type": "toast"'); }); }); diff --git a/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts b/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts index 50d0f43a6..a6cb18cb3 100644 --- a/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts +++ b/packages/types/src/__tests__/chatbot-authoring-face-keys.test.ts @@ -125,12 +125,28 @@ describe('ChatbotSchema: the ten local-display/legacy keys are declared, not ano autoResponse: true, autoResponseText: 'Thanks!', autoResponseDelay: 1000, - onSend: () => {}, }); expect(result.success).toBe(true); }); + it('`onSend` is a RUNTIME SLOT the Zod mirror refuses by name; the TypeScript face above is its channel (objectui#6124)', () => { + // `onSend: () => {}` used to sit in the green fixture above. objectui#6124 + // replaced every `on*: z.function()` arm with a named refusal: a JSON face + // has no function value, and `plugin-chatbot` reads `schema.onSend` through + // the TypeScript interface (which keeps the callable member — see the first + // `it` in this file), never through `safeParse`. + const result = ChatbotZodSchema.safeParse({ + type: 'chatbot', + messages: [{ id: '1', role: 'user', content: 'hi' }], + onSend: () => {}, + }); + expect(result.success).toBe(false); + const issue = result.error?.issues.find((i) => String(i.path[0]) === 'onSend'); + expect(issue?.code).toBe('custom'); + expect(issue?.message).toContain('`onSend` is a RUNTIME SLOT'); + }); + it('refuses a wrong-typed value on a declared key through the Zod mirror (was silently passed through before)', () => { const result = ChatbotZodSchema.safeParse({ type: 'chatbot', diff --git a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts new file mode 100644 index 000000000..5fe002ada --- /dev/null +++ b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts @@ -0,0 +1,535 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The 58 `on*` handler keys that the zod mirrors declared as `z.function()` + * now REFUSE BY NAME (objectui#6124, maintainer ruling 2026-08-30, batch #8: + * Q1 → A, Q2 → A with C, Q3 → A, Q4 → B). + * + * ## The defect, and why deletion was not the fix + * + * `z.function()` is a declaration NO JSON document can satisfy — `null`, `{}`, + * `1`, `[]`, `"x"` and `true` are all refused; only a live function parses — + * on the protocol layer of a JSON-authored vocabulary. Authors who learned + * `onClick: { action: 'toast' }` from the corpus got a bare + * `invalid_type … expected function` that named the key and nothing else. + * + * The obvious remedy, "the key leaves the mirror", was measured and refused: + * `BaseSchema` is `.passthrough()`, so an UNDECLARED key is not refused — it + * stops being judged and the value is KEPT. `onClick` is a member of + * `SDUI_DOM_PASS_THROUGH_KEYS`, so the kept object reached the DOM listener + * slot and React threw at click. A deletion converts a clear parse error into + * silence plus a runtime throw. The counter-probe below pins that hazard so + * nobody "simplifies" a refusal arm into a deletion. + * + * ## The ruled shape — two faces, one measurement per key + * + * - zod face (all 58): the #5099 `z.custom` + guidance shape + * (`form.zod.ts` `FieldConstraintsSchema.pattern.value`), via + * `handlerKeyRefusal()` in `../zod/tombstone.zod.ts`. The predicate refuses + * EVERYTHING — an authored object AND a live function — because a JSON + * face has no function value and the programmatic face reaches renderers + * through the TypeScript interface / React props, never through + * `safeParse`. Measured on this tree: the only runtime `safeParse` doors + * into these mirrors are the CLI file validators and the exported + * `validateSchema` / `safeValidateSchema` helpers; `SchemaRenderer` + * validates through `@object-ui/core`'s structural validator, which never + * reads a handler key. The message names the key, says why JSON cannot + * author it, and points at the node-type spelling PR #6498 established + * (Q1 → A, option C). + * - TypeScript face (per key, measured): a key whose function value reaches + * a renderer at runtime keeps its function type — `SchemaRenderer` spreads + * every non-metadata schema key as a React prop, so a renderer that reads + * `schema.onX`, calls `props.onX`, or spreads leftover props onto a Radix + * root / DOM listener slot is a live channel (36 sites, `RUNTIME_SLOT` + * below). A key nothing reads gets the `?: never` tombstone (22 sites, + * `RETIRED` below; the `crud.ts` `confirm` / `base.ts` convention). + * + * ## Predictions, written before the first run (red-first) + * + * On the unmodified tree (`origin/main` @ `c93b4d5f3`): + * - the source census finds 58 `on*: z.function(` sites, not 0; + * - an authored object is refused with `invalid_type` (zod's bare message), + * not `custom` with the named guidance; + * - a live function parses GREEN on every one of the 58 (the accept-set + * change this card declares in its changeset); + * - `tsc -p tsconfig.test.json` reports TS2344 on every `RetiredIsNever` + * line (the member is still a function type). + * The non-`on*` census (`cell` / `custom` / `renderCellEditor` / `validate`) + * and the passthrough counter-probe are GREEN before and after — they pin the + * ruling's scope (Q4 → B) and the reason for the arm, not this change. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; + +import { retirementTombstone } from '../zod/tombstone.zod'; +import { + CalendarViewSchema as CalendarViewZod, + CarouselSchema as CarouselZod, + ChatbotSchema as ChatbotZod, + FilterBuilderSchema as FilterBuilderZod, + KanbanSchema as KanbanZod, +} from '../zod/complex.zod'; +import { + AlertSchema as AlertZod, + DataTableSchema as DataTableZod, + ListItemSchema as ListItemZod, + TreeViewSchema as TreeViewZod, +} from '../zod/data-display.zod'; +import { + AccordionSchema as AccordionZod, + CollapsibleSchema as CollapsibleZod, + ToggleGroupSchema as ToggleGroupZod, +} from '../zod/disclosure.zod'; +import { ToastSchema as ToastZod } from '../zod/feedback.zod'; +import { + ButtonSchema as ButtonZod, + CalendarSchema as CalendarZod, + CheckboxSchema as CheckboxZod, + CodeEditorSchema as CodeEditorZod, + ComboboxSchema as ComboboxZod, + CommandSchema as CommandZod, + DatePickerSchema as DatePickerZod, + FileUploadSchema as FileUploadZod, + FormSchema as FormZod, + InputOTPSchema as InputOTPZod, + InputSchema as InputZod, + RadioGroupSchema as RadioGroupZod, + SelectSchema as SelectZod, + SliderSchema as SliderZod, + SwitchSchema as SwitchZod, + TextareaSchema as TextareaZod, + ToggleSchema as ToggleZod, +} from '../zod/form.zod'; +import { CardSchema as CardZod, TabsSchema as TabsZod } from '../zod/layout.zod'; +import { + BreadcrumbItemSchema as BreadcrumbItemZod, + ButtonGroupButtonSchema as ButtonGroupButtonZod, + PaginationSchema as PaginationZod, + SidebarSchema as SidebarZod, +} from '../zod/navigation.zod'; +import { + AlertDialogSchema as AlertDialogZod, + DialogSchema as DialogZod, + DrawerSchema as DrawerZod, + DropdownMenuSchema as DropdownMenuZod, + HoverCardSchema as HoverCardZod, + MenuItemSchema as MenuItemZod, + PopoverSchema as PopoverZod, + SheetSchema as SheetZod, +} from '../zod/overlay.zod'; + +import type { + CalendarViewSchema, + CarouselSchema, + ChatbotSchema, + FilterBuilderSchema, + KanbanSchema, +} from '../complex'; +import type { AlertSchema, DataTableSchema, ListItem, TreeViewSchema } from '../data-display'; +import type { AccordionSchema, CollapsibleSchema, ToggleGroupSchema } from '../disclosure'; +import type { ToastSchema } from '../feedback'; +import type { + ButtonSchema, + CalendarSchema, + CheckboxSchema, + CodeEditorSchema, + ComboboxSchema, + CommandSchema, + DatePickerSchema, + FileUploadSchema, + FormSchema, + InputOTPSchema, + InputSchema, + RadioGroupSchema, + SelectSchema, + SliderSchema, + SwitchSchema, + TextareaSchema, + ToggleSchema, +} from '../form'; +import type { CardSchema, TabsSchema } from '../layout'; +import type { + BreadcrumbItem, + ButtonGroupButton, + PaginationSchema, + SidebarSchema, +} from '../navigation'; +import type { + AlertDialogSchema, + DialogSchema, + DrawerSchema, + DropdownMenuSchema, + HoverCardSchema, + MenuCommandItem, + PopoverSchema, + SheetSchema, +} from '../overlay'; + +/* ── The census, as data ─────────────────────────────────────────────────── */ + +type Site = readonly [file: string, schema: string, key: string, mirror: z.ZodType]; + +/** The object that DECLARES `key` behind a mirror. Every mirror here IS the + * object except `overlay.zod.ts#MenuItemSchema`: a `z.lazy` (its `children` + * recurse) over a discriminated UNION (command item | divider, objectui#6523), + * so the member lives on the command arm one `unwrap()` down. Typed loosely + * on purpose: the census is about members, not shapes. */ +const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => { + const inner = mirror instanceof z.ZodLazy ? mirror.unwrap() : mirror; + if (inner instanceof z.ZodUnion) { + const arm = (inner.options as z.ZodType[]).find( + (o) => o instanceof z.ZodObject && key in (o as z.ZodObject).shape, + ); + if (!arm) throw new Error(`no union arm declares \`${key}\``); + return arm as z.ZodObject; + } + return inner as z.ZodObject; +}; + +/** + * 36 keys whose function value REACHES a renderer at runtime — the TypeScript + * interface keeps the function type. Channel measured per key on this tree: + * `schema.onX` read/forwarded (kanban, chatbot, data-table, form, code-editor, + * menu items), `props.onX` called after `SchemaRenderer`'s spread (input, + * textarea, select, checkbox, file-upload, date-picker, input-otp, pagination, + * filter-builder, calendar-view's `pickHostCallbacks`), or leftover props + * spread onto a Radix root / DOM listener slot (accordion, collapsible, + * toggle-group, tabs, the seven `onOpenChange` overlays, button's + * `toFormControlDomProps` whitelist, card's ``). + */ +const RUNTIME_SLOT: readonly Site[] = [ + ['complex.zod.ts', 'KanbanSchema', 'onCardMove', KanbanZod], + ['complex.zod.ts', 'KanbanSchema', 'onCardClick', KanbanZod], + ['complex.zod.ts', 'CalendarViewSchema', 'onViewChange', CalendarViewZod], + ['complex.zod.ts', 'FilterBuilderSchema', 'onChange', FilterBuilderZod], + ['complex.zod.ts', 'ChatbotSchema', 'onError', ChatbotZod], + ['complex.zod.ts', 'ChatbotSchema', 'onSend', ChatbotZod], + ['data-display.zod.ts', 'DataTableSchema', 'onRowEdit', DataTableZod], + ['data-display.zod.ts', 'DataTableSchema', 'onRowDelete', DataTableZod], + ['data-display.zod.ts', 'DataTableSchema', 'onSelectionChange', DataTableZod], + ['data-display.zod.ts', 'DataTableSchema', 'onColumnsReorder', DataTableZod], + ['disclosure.zod.ts', 'AccordionSchema', 'onValueChange', AccordionZod], + ['disclosure.zod.ts', 'CollapsibleSchema', 'onOpenChange', CollapsibleZod], + ['disclosure.zod.ts', 'ToggleGroupSchema', 'onValueChange', ToggleGroupZod], + ['form.zod.ts', 'ButtonSchema', 'onClick', ButtonZod], + ['form.zod.ts', 'InputSchema', 'onChange', InputZod], + ['form.zod.ts', 'TextareaSchema', 'onChange', TextareaZod], + ['form.zod.ts', 'SelectSchema', 'onChange', SelectZod], + ['form.zod.ts', 'CheckboxSchema', 'onChange', CheckboxZod], + ['form.zod.ts', 'FileUploadSchema', 'onChange', FileUploadZod], + ['form.zod.ts', 'DatePickerSchema', 'onChange', DatePickerZod], + ['form.zod.ts', 'InputOTPSchema', 'onChange', InputOTPZod], + ['form.zod.ts', 'FormSchema', 'onSubmit', FormZod], + ['form.zod.ts', 'FormSchema', 'onChange', FormZod], + ['form.zod.ts', 'FormSchema', 'onCancel', FormZod], + ['form.zod.ts', 'CodeEditorSchema', 'onChange', CodeEditorZod], + ['layout.zod.ts', 'CardSchema', 'onClick', CardZod], + ['layout.zod.ts', 'TabsSchema', 'onValueChange', TabsZod], + ['navigation.zod.ts', 'PaginationSchema', 'onPageChange', PaginationZod], + ['overlay.zod.ts', 'DialogSchema', 'onOpenChange', DialogZod], + ['overlay.zod.ts', 'AlertDialogSchema', 'onOpenChange', AlertDialogZod], + ['overlay.zod.ts', 'SheetSchema', 'onOpenChange', SheetZod], + ['overlay.zod.ts', 'DrawerSchema', 'onOpenChange', DrawerZod], + ['overlay.zod.ts', 'PopoverSchema', 'onOpenChange', PopoverZod], + ['overlay.zod.ts', 'HoverCardSchema', 'onOpenChange', HoverCardZod], + ['overlay.zod.ts', 'MenuItemSchema', 'onClick', MenuItemZod], + ['overlay.zod.ts', 'DropdownMenuSchema', 'onOpenChange', DropdownMenuZod], +]; + +/** + * 22 keys NO renderer reads — the TypeScript interface carries the `?: never` + * tombstone. Measured per key: the renderer takes `({ schema })` only, or + * strips the key through a `toFormControlDomProps` whitelist, or spreads it + * onto a DOM element / primitive that has no such prop (React warns about an + * unknown event handler and attaches nothing). `CommandSchema.onChange` is the + * one that lands somewhere at all — cmdk's root `div`, where React fires it + * with a SyntheticEvent on every keystroke — which is a DIFFERENT contract + * from the declared `(value: string) => void`, not a consumer of it. + */ +const RETIRED: readonly Site[] = [ + ['complex.zod.ts', 'KanbanSchema', 'onColumnAdd', KanbanZod], + ['complex.zod.ts', 'KanbanSchema', 'onCardAdd', KanbanZod], + ['complex.zod.ts', 'CarouselSchema', 'onSlideChange', CarouselZod], + ['complex.zod.ts', 'ChatbotSchema', 'onSendMessage', ChatbotZod], + ['data-display.zod.ts', 'AlertSchema', 'onDismiss', AlertZod], + ['data-display.zod.ts', 'ListItemSchema', 'onClick', ListItemZod], + ['data-display.zod.ts', 'TreeViewSchema', 'onSelectChange', TreeViewZod], + ['data-display.zod.ts', 'TreeViewSchema', 'onExpandChange', TreeViewZod], + ['feedback.zod.ts', 'ToastSchema', 'onDismiss', ToastZod], + ['form.zod.ts', 'RadioGroupSchema', 'onChange', RadioGroupZod], + ['form.zod.ts', 'SwitchSchema', 'onChange', SwitchZod], + ['form.zod.ts', 'ToggleSchema', 'onChange', ToggleZod], + ['form.zod.ts', 'SliderSchema', 'onChange', SliderZod], + ['form.zod.ts', 'CalendarSchema', 'onChange', CalendarZod], + ['form.zod.ts', 'InputOTPSchema', 'onComplete', InputOTPZod], + ['form.zod.ts', 'ComboboxSchema', 'onChange', ComboboxZod], + ['form.zod.ts', 'CommandSchema', 'onChange', CommandZod], + ['navigation.zod.ts', 'BreadcrumbItemSchema', 'onClick', BreadcrumbItemZod], + ['navigation.zod.ts', 'SidebarSchema', 'onCollapsedChange', SidebarZod], + ['navigation.zod.ts', 'ButtonGroupButtonSchema', 'onClick', ButtonGroupButtonZod], + ['overlay.zod.ts', 'AlertDialogSchema', 'onConfirm', AlertDialogZod], + ['overlay.zod.ts', 'AlertDialogSchema', 'onCancel', AlertDialogZod], +]; + +const ALL_SITES: readonly Site[] = [...RUNTIME_SLOT, ...RETIRED]; + +/** The eight mirror files the census covers; `base.zod.ts` holds only + * `EventHandlersSchema` (a record, objectui#6910's card) and no named key. */ +const MIRROR_FILES = [ + 'complex.zod.ts', + 'data-display.zod.ts', + 'disclosure.zod.ts', + 'feedback.zod.ts', + 'form.zod.ts', + 'layout.zod.ts', + 'navigation.zod.ts', + 'overlay.zod.ts', +] as const; + +const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); +const readMirror = (file: string) => readFileSync(join(ZOD_DIR, file), 'utf8'); + +/** The card's ANCHORED census — the unanchored `on[A-Z]…` spelling matches + * mid-identifier (`buttonLabel`, `actionUrl`, …) and over-reports. */ +const ON_KEY_FUNCTION = /^\s*on[A-Z][A-Za-z]*: z\.function\(/gm; +const NON_ON_FUNCTION = /^\s*(cell|custom|renderCellEditor|validate): z\.function\(/gm; + +const describeOf = (mirror: z.ZodType, key: string): string | undefined => + (objectOf(mirror, key).shape[key] as { description?: string } | undefined)?.description; + +/** One key, isolated: `.pick()` keeps the member's own declaration and drops + * the rest of the object, so the probe needs no per-schema fixture and a + * refusal can only be about the key under test. */ +const pickKey = (mirror: z.ZodType, key: string) => + objectOf(mirror, key).pick({ [key]: true } as Record); + +const AUTHORED_ACTION_OBJECT = { action: 'toast', title: 'Saved', variant: 'success' }; +const LIVE_FUNCTION = () => undefined; + +/* ── Census ──────────────────────────────────────────────────────────────── */ + +describe('census: no on* key in the eight mirrors is declared z.function() (objectui#6124)', () => { + it('the anchored source census finds 0 `on*: z.function(` sites', () => { + const hits = MIRROR_FILES.flatMap((file) => + [...readMirror(file).matchAll(ON_KEY_FUNCTION)].map((m) => `${file}: ${m[0].trim()}`), + ); + expect(hits).toEqual([]); + }); + + it('the four non-on* z.function() sites stay exactly as they are (Q4 → B — cell/custom/validate/renderCellEditor are out of scope)', () => { + // Positive control for the census instrument as well: the same regex + // family, anchored the same way, still finds the sites it should. + const hits = MIRROR_FILES.flatMap((file) => + [...readMirror(file).matchAll(NON_ON_FUNCTION)].map((m) => `${file}#${m[1]}`), + ); + expect(hits.sort()).toEqual([ + 'data-display.zod.ts#cell', + 'data-display.zod.ts#renderCellEditor', + 'form.zod.ts#custom', + 'form.zod.ts#validate', + ]); + }); + + it('58 sites are ledgered, 36 runtime slots + 22 retired, with no key filed twice', () => { + expect(RUNTIME_SLOT).toHaveLength(36); + expect(RETIRED).toHaveLength(22); + const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); + expect(new Set(ids).size).toBe(58); + }); + + it.each(ALL_SITES)('%s %s.%s is DECLARED on the mirror shape, with the objectui#6124 guidance as its description', (_file, _schema, key, mirror) => { + // Deliberately `.shape`, not `safeParse`: under `.passthrough()` a DELETED + // key still parses green and the value rides through, so a parse-based + // declaration pin stays green through the very deletion it exists to + // catch (the same reading PR #6899's pin recorded for the event-name keys). + expect(objectOf(mirror, key).shape[key]).toBeDefined(); + expect(describeOf(mirror, key)).toContain('objectui#6124'); + }); +}); + +/* ── Behaviour: the refusal, by name, with the remedy ─────────────────────── */ + +describe('a JSON author is refused BY NAME and pointed at the node-type spelling (objectui#6124, Q2 → A)', () => { + it.each(ALL_SITES)('%s %s.%s refuses an authored action object with its own guidance, not zod\'s bare invalid_type', (_file, _schema, key, mirror) => { + const result = pickKey(mirror, key).safeParse({ [key]: AUTHORED_ACTION_OBJECT }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => String(i.path[0]) === key); + expect(issue, `no issue addressed to \`${key}\``).toBeDefined(); + // The #5099 shape: a `z.custom` predicate, so the code is `custom` — the + // bare `z.function()` reported `invalid_type` with "expected function". + expect(issue!.code).toBe('custom'); + expect(issue!.path).toEqual([key]); + expect(issue!.message).toContain(`\`${key}\``); + expect(issue!.message).not.toContain('expected function'); + // The remedy: the node-type spelling PR #6498 established (Q1, option C). + expect(issue!.message).toContain('"type"'); + expect(issue!.message).toContain('action:button'); + // BOTH channels, one string — the runtime message and the `.describe()` + // metadata cannot drift apart (the `retirementTombstone()` invariant). + expect(issue!.message).toBe(describeOf(mirror, key)); + }); + + it.each(ALL_SITES)('%s %s.%s refuses a LIVE FUNCTION too — the JSON mirror is not the programmatic channel', (_file, _schema, key, mirror) => { + // This is the accept-set change the changeset declares. The ruling: the + // programmatic face goes through the TypeScript interface / React props + // and never through `safeParse`; a function that parsed green here was + // the instrument's positive control, never an authoring form. + const result = pickKey(mirror, key).safeParse({ [key]: LIVE_FUNCTION }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.map((i) => [i.code, String(i.path[0])])).toEqual([['custom', key]]); + }); + + it.each(ALL_SITES)('%s %s.%s — the same isolated shape parses GREEN without the key (the arm is optional; the refusal is about the key)', (_file, _schema, key, mirror) => { + expect(pickKey(mirror, key).safeParse({}).success).toBe(true); + }); + + it('the guidance wording distinguishes a runtime slot from a retired key', () => { + for (const [, , key, mirror] of RUNTIME_SLOT) { + expect(describeOf(mirror, key), key).toContain('RUNTIME SLOT'); + expect(describeOf(mirror, key), key).not.toContain('RETIRED'); + } + for (const [, , key, mirror] of RETIRED) { + expect(describeOf(mirror, key), key).toContain('RETIRED (objectui#6124'); + expect(describeOf(mirror, key), key).not.toContain('RUNTIME SLOT'); + } + }); + + it('a whole document still parses green once the handler key is spelled as a node type', () => { + // The corpus flip PR #6498 landed: `{ "type": "toast" }` is the authorable + // spelling; a button without a handler key is a plain green button. + expect(ButtonZod.safeParse({ type: 'button', label: 'Save' }).success).toBe(true); + expect(ToastZod.safeParse({ type: 'toast', title: 'Saved' }).success).toBe(true); + expect(DialogZod.safeParse({ type: 'dialog', title: 'Confirm' }).success).toBe(true); + }); +}); + +/* ── Counter-probe: why the arm, and not a deletion ───────────────────────── */ + +describe('counter-probe: deleting the key instead would be a SILENT accept that KEEPS the value (the ruling\'s ⛔ 不裸删)', () => { + const retiredEnvelope = { + type: 'button', + label: 'Destructive Toast', + variant: 'destructive', + onClick: AUTHORED_ACTION_OBJECT, + }; + + it('with the arm: refused at path onClick', () => { + const result = ButtonZod.safeParse(retiredEnvelope); + expect(result.success).toBe(false); + expect(result.error?.issues[0]?.path).toEqual(['onClick']); + }); + + it('the deletion, simulated: `BaseSchema.passthrough()` parses it GREEN and the object rides through to the DOM listener slot', () => { + const result = ButtonZod.omit({ onClick: true }).safeParse(retiredEnvelope); + expect(result.success).toBe(true); + expect((result.data as Record).onClick).toEqual(AUTHORED_ACTION_OBJECT); + }); + + it('the refusal arm is not a retirement tombstone: it reports `custom`, the tombstone reports `invalid_type`', () => { + // Two helpers, two meanings — `retirementTombstone()` retires a key from + // the contract on BOTH faces; the handler refusal keeps 36 of these keys + // live on the TypeScript face. Pinned so the two are not merged into one. + const tombstone = retirementTombstone('RETIRED (fixture)').safeParse('x'); + expect(tombstone.success).toBe(false); + expect(tombstone.error?.issues[0]?.code).toBe('invalid_type'); + const arm = pickKey(ButtonZod, 'onClick').safeParse({ onClick: 'x' }); + expect(arm.error?.issues[0]?.code).toBe('custom'); + }); +}); + +/* ── The TypeScript face, judged by `tsc -p tsconfig.test.json` ──────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** `?: never` reads as exactly `undefined` off the interface. `Equal`, not + * `extends`: `BaseSchema`'s `[key: string]: any` means a DELETED member reads + * `any`, which a one-way check would accept (the disabled-twin lesson). */ +type RetiredIsNever = Equal; + +/** A runtime slot keeps a callable member: some function type survives the + * `Extract`. Written over `NonNullable` so `undefined` cannot satisfy it. */ +type KeepsFunction = [Extract, (...args: never[]) => unknown>] extends [never] + ? false + : true; + +export type assertionRetiredKeysAreTombstoned = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +export type assertionRuntimeSlotsKeepTheirFunctionType = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, +]; + +// The two helpers must be able to FAIL — synthetic controls, both directions. +export type assertionRetiredIsNeverCanFail = Expect void) | undefined>, false>>; +export type assertionKeepsFunctionCanFail = Expect, false>>; diff --git a/packages/types/src/__tests__/menu-item-union.test.ts b/packages/types/src/__tests__/menu-item-union.test.ts index c141bb062..3cc71766d 100644 --- a/packages/types/src/__tests__/menu-item-union.test.ts +++ b/packages/types/src/__tests__/menu-item-union.test.ts @@ -215,13 +215,45 @@ describe('MenuItemSchema — the zod mirror agrees with the TS union (objectui#6 expect(MenuItemSchema.safeParse({ separator: true }).success).toBe(true); }); - it('a live command item — label, icon, shortcut, onClick — still parses green', () => { + it('a live command item — label, icon, shortcut — still parses green', () => { const result = MenuItemSchema.safeParse({ label: 'New Tab', icon: 'plus', shortcut: 'Ctrl+T', - onClick: () => {}, }); expect(result.success).toBe(true); }); + + it('`onClick` is a RUNTIME SLOT the mirror refuses by name — the function still parses on the TS face only (objectui#6124)', () => { + // This case used to parse a live function GREEN. objectui#6124 replaced + // every `on*: z.function()` arm with a named refusal: the JSON face has no + // function value, and the renderers reach `item.onClick?.()` through the + // TypeScript `MenuCommandItem` — which keeps the callable member, pinned + // in `handler-keys-json-refusal-6124.test.ts` — never through `safeParse`. + const result = MenuItemSchema.safeParse({ + label: 'New Tab', + icon: 'plus', + shortcut: 'Ctrl+T', + onClick: () => {}, + }); + expect(result.success).toBe(false); + if (result.success) return; + // A UNION reports `invalid_union` at the top; the named refusal lives in + // the command arm's errors (the same reading the `type` tombstone case + // above records for objectui#6931). + const top = result.error.issues[0]!; + expect(top.code).toBe('invalid_union'); + const armIssues = ( + (top as unknown as { errors?: { path: PropertyKey[]; code: string; message: string }[][] }).errors ?? [] + ) + .flat() + .filter((i) => String(i.path[0]) === 'onClick'); + expect(armIssues.length, 'no arm reported an issue addressed to `onClick`').toBeGreaterThan(0); + for (const issue of armIssues) { + expect(issue.code).toBe('custom'); + expect(issue.message).toContain('`onClick` is a RUNTIME SLOT'); + } + const onClick: MenuCommandItem['onClick'] = () => {}; + expect(typeof onClick).toBe('function'); + }); }); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 1a45bced0..6cf03654f 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -57,7 +57,9 @@ * already pins equal to `keyof Declared`. Nothing asserts it against a written * number, so this line is prose and can rot; the pin that cannot is the one * comparing the two halves to each other. - * - **12 entries** in `KnownDrift`, **17 keys** across them. + * - **36 entries** in `KnownDrift`, **52 keys** across them. It was 12 / 17 until + * objectui#6124 added the RUNTIME-SLOT class (28 pairs touched, 35 keys) — see + * the class note inside the ledger, above `ButtonSchema`. * - **16 entries** in `UnmirroredDeclared`, **97 keys** across them. ⚠️ It was * **121** when objectui#6058 seeded it; objectui#6152 moved 23 callback-shaped * keys to the ledger below by RECLASSIFICATION, not by fixing them, and @@ -70,7 +72,7 @@ * the first pair whose ONLY ledger entry is a runtime-only one * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), so the * "no entry in either" population dropped by one to 141. - * - 158 − 12 = **146**, the "pairs with no entry" `LedgerMismatch` speaks of. + * - 158 − 36 = **122**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves * @@ -91,7 +93,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 12 of the 158 pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 36 of the 158 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 @@ -107,6 +109,13 @@ * `ViewSwitcherSchema`) and two shrank to the keys that are NOT widenings * (`DataTableSchema` kept `rowActions`, `FormSchema` kept `fields`/`mode`). The * remaining classes are rulings, not edits, and are deliberately still here. + * + * Then 12 became 36 with objectui#6124, which is the opposite movement from #5927's + * and must not be read as regression: 24 pairs ENTERED (and 4 grew) because the + * ruling put a NAMED REFUSAL on the mirror side of 35 runtime-slot handler keys while + * their TypeScript twins stay callable. That drift is the ruling's intended shape, + * ledgered so the ratchet holds it exactly — a pair leaving this class means either + * the mirror accepts a function again or a renderer lost its callback. */ import { describe, it, expect } from 'vitest'; @@ -679,8 +688,18 @@ export type UnmirroredOf< K extends MirrorKey > = UnmirroredDeclaredKeys< (typeo * new drift on a listed mirror fails, and so does a listed key that has been fixed. */ interface KnownDrift { - /** TS declares `SchemaNode | SchemaNode[]` (a rendered slot); the mirror declares `Record` ("additional API body params"). Two different meanings of one key — a naming collision to rule on, not a widening. */ - 'complex.zod.ts#ChatbotSchema': 'body'; + /** RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads `onViewChange` off the spread props (function values only) and hands it to `CalendarView`. */ + 'complex.zod.ts#CalendarViewSchema': 'onViewChange'; + /** + * `body` — TS declares `SchemaNode | SchemaNode[]` (a rendered slot); the mirror + * declares `Record` ("additional API body params"). Two different + * meanings of one key — a naming collision to rule on, not a widening. + * + * `onError` / `onSend` — RUNTIME SLOT (objectui#6124): `plugin-chatbot` forwards both off + * `schema.*` into `useObjectChat`, so the TS side keeps the callables; the mirror + * refuses them by name (`handlerKeyRefusal`). See the class note above `ButtonSchema`. + */ + 'complex.zod.ts#ChatbotSchema': 'body' | 'onError' | 'onSend'; /** * spec-derived shape (`SpecDashboardFields`) measured against a hand-written * local declaration. Needs the spec-unification triage of #2231 rather than a @@ -694,24 +713,122 @@ interface KnownDrift { 'complex.zod.ts#DashboardComponentSchema': 'header' | 'widgets' | 'globalFilters'; /** TS declares `unknown`; the mirror declares a structured options object. The mirror is the STRICTER side here — narrowing the check would be wrong, widening the TS declaration is the ADR-0049 question. */ 'complex.zod.ts#DashboardWidgetSchema': 'options'; - /** inherited from `FilterFieldSchema.operators` below — the element type is the drifted one. */ - 'complex.zod.ts#FilterBuilderSchema': 'fields'; + /** + * `fields` — inherited from `FilterFieldSchema.operators` below; the element type is + * the drifted one. `onChange` — RUNTIME SLOT (objectui#6124): the `filter-builder` renderer + * calls it as `props.onChange` after `SchemaRenderer`'s spread. + */ + 'complex.zod.ts#FilterBuilderSchema': 'fields' | 'onChange'; /** DISJOINT vocabularies: TS declares `is_empty`/`is_not_empty`, the mirror declares `is_null`/`is_not_null`. One of the two is dead; which one is a ruling. */ 'complex.zod.ts#FilterFieldSchema': 'operators'; - /** DISJOINT: TS declares `rowActions?: boolean` (show the column or not), the mirror declares `any[]` (the actions themselves). One of the two is dead; which is a ruling. (`selectable` was a second drifted key here until objectui#5927 widened the mirror to `boolean | 'single' | 'multiple'` — `resolveSelectionMode` in `renderers/complex/data-table.tsx` implements `'single'` as a real mode.) */ - 'data-display.zod.ts#DataTableSchema': 'rowActions'; - /** DISJOINT: TS `Date | Date[]`, mirror `string | Date`. The mirror refuses `Date[]`; the TS side refuses the ISO string the mirror accepts. */ + /** RUNTIME SLOT (objectui#6124) ×2: `plugin-kanban` forwards `onCardMove` / `onCardClick` off `schema.*` into the board. (`onColumnAdd` / `onCardAdd` are NOT here: nothing reads them, so both faces retire them — `?: never` meets the refusal arm and the pair does not drift on those keys.) */ + 'complex.zod.ts#KanbanSchema': 'onCardMove' | 'onCardClick'; + /** + * `rowActions` — DISJOINT: TS declares `rowActions?: boolean` (show the column or + * not), the mirror declares `any[]` (the actions themselves). One of the two is + * dead; which is a ruling. (`selectable` was a second drifted key here until + * objectui#5927 widened the mirror to `boolean | 'single' | 'multiple'` — + * `resolveSelectionMode` in `renderers/complex/data-table.tsx` implements + * `'single'` as a real mode.) + * + * The four callbacks — RUNTIME SLOT (objectui#6124) ×4: `renderers/complex/data-table.tsx` + * CALLS every one of them off `schema.*` (`schema.onRowEdit?.(r)`, + * `schema.onSelectionChange(selectedData)`, …), so the TS side keeps them callable + * and the mirror refuses them by name. + */ + 'data-display.zod.ts#DataTableSchema': 'rowActions' | 'onRowEdit' | 'onRowDelete' | 'onSelectionChange' | 'onColumnsReorder'; + /** RUNTIME SLOT (objectui#6124): the `accordion` renderer spreads leftover props onto the Radix `Accordion` root, where `onValueChange` is a real prop. */ + 'disclosure.zod.ts#AccordionSchema': 'onValueChange'; + /** RUNTIME SLOT (objectui#6124): the `collapsible` renderer spreads leftover props onto the Radix `Collapsible` root. */ + 'disclosure.zod.ts#CollapsibleSchema': 'onOpenChange'; + /** RUNTIME SLOT (objectui#6124): the `toggle-group` renderer spreads `toggleGroupProps` onto the Radix `ToggleGroup` root. */ + 'disclosure.zod.ts#ToggleGroupSchema': 'onValueChange'; + /** + * ## The objectui#6124 class — a RUNTIME SLOT on the TS face, a NAMED REFUSAL on the mirror + * + * Maintainer ruling 2026-08-30 (batch #8, Q2 → A with C): the 58 `on*` keys the + * mirrors declared as `z.function()` — a type NO JSON document can satisfy — keep + * their declaration and REFUSE BY NAME (`handlerKeyRefusal()` in + * `../zod/tombstone.zod.ts`, the #5099 `z.custom` + guidance shape), because under + * `BaseSchema.passthrough()` deleting a key is a SILENT accept that keeps the value + * and forwards it to the DOM. The mirror's `z.input` for such a key is therefore + * `undefined` — a JSON author cannot write it — while the TypeScript twin of a key + * whose function value REACHES a renderer stays callable, because that is the + * programmatic channel (`SchemaRenderer` spreads every non-metadata schema key as a + * React prop; renderers read `schema.onX`, call `props.onX`, or spread leftovers onto + * a Radix root / DOM listener slot). Two faces of one key, both true, measured per + * key — a DELIBERATE divergence like `PageNodeSchema.pageType`, not debt to widen + * away. ⛔ Do not "fix" one of these by making the mirror accept a function again + * (that re-opens the JSON lie) or by deleting the TS member (that breaks the shipped + * renderer that reads it). + * + * The 22 keys NOTHING reads are not in this ledger at all: their TS twin is a + * `?: never` tombstone, so `undefined` meets `undefined` and the pair does not drift + * on them. Every one of the 58 is pinned member-by-member, both faces, in + * `handler-keys-json-refusal-6124.test.ts`; this ledger records the 35 that drift. + * + * `ButtonSchema.onClick` — RUNTIME SLOT (objectui#6124): `toFormControlDomProps` forwards it + * to the DOM `