diff --git a/.changeset/7344-handler-string-any-mirrors.md b/.changeset/7344-handler-string-any-mirrors.md new file mode 100644 index 0000000000..9443631a1d --- /dev/null +++ b/.changeset/7344-handler-string-any-mirrors.md @@ -0,0 +1,40 @@ +--- +'@object-ui/types': minor +--- + +The eight `on*` handler keys PR #7339's census could not see now refuse by name +(objectui#7344 — the objectui#6182 ruling of 2026-08-25 that the handler-expression +string dialect is not a supported authoring form, executed in the objectui#6124 shape). + +**The accept set of published validators moves** (`@object-ui/types/zod`): + +- Four mirrors declared the string dialect (`z.string()`) — `AppActionSchema.onClick`, + `ReportBuilderSchema.onSave` / `.onCancel`, `DetailViewSchema.onBack` — so an authored + `onBack: 'goBack'` parsed green and then reached a slot that CALLS it + (`DetailView.handleBack`), throwing `onBack is not a function` at click. +- Three declared `z.any()` — `ActionSchema.onClick`, `DetailSchema.onBack`, + `CRUDDialogSchema.onClose` — wider than the callable the TypeScript face declares, so + any JSON value parsed green (the objectui#7069 direction). +- One, `CalendarViewSchema.onEventClick`, was `z.function()` in a multi-line spelling the + anchored census missed. + +All eight now carry `handlerKeyRefusal(key, disposition, label)`: an authored string, an +authored object and a live function are each refused at the key's own path with +`code: 'custom'` and a message that names the key, says why JSON cannot author it and +points at the node-type spelling. Nothing that used to be refused parses green. + +**The TypeScript face, measured per key** — a function type only where a runtime consumer +reads a function, else `?: never`: + +- Runtime slots (callable kept): `DetailViewSchema.onBack` (now `() => void`, the prop + `DetailView` invokes — it declared `string`), `DetailSchema.onBack`, `ActionSchema.onClick`, + `CalendarViewSchema.onEventClick`. +- Retired (`?: never`): `AppAction.onClick` (nothing reads `AppComponentSchema.actions[]`), + `ReportBuilderSchema.onSave` / `.onCancel` (no `report-builder` renderer is registered), + `CRUDDialogSchema.onClose` (no `crud-dialog` renderer is registered). + +The three `views.zod.ts` `z.string()` keys that are event NAMES (`onViewChange`, the two +`onChange`, PR #6899) are untouched; their describe text says so and the new pin reads it. +`zod-mirror-parity.test.ts` records the three new runtime-slot drift rows; +`content/docs/core/app-schema.mdx` spells its `AppAction.onClick` row `never` and drops the +string example, the two edits the #7340 docs pin and `check:doc-snippets` require. diff --git a/content/docs/core/app-schema.mdx b/content/docs/core/app-schema.mdx index af1ee5c261..0f8f160a7d 100644 --- a/content/docs/core/app-schema.mdx +++ b/content/docs/core/app-schema.mdx @@ -151,7 +151,7 @@ interface AppAction { type: 'button' | 'dropdown' | 'user'; label?: string; icon?: string; - onClick?: string; + onClick?: never; // RETIRED (objectui#7344): the handler-expression string is refused by name — author an action:button node instead avatar?: string; // For type='user' description?: string; // For type='user' items?: AppMenuItem[]; // For type='dropdown' or 'user' @@ -245,8 +245,7 @@ const crm: AppComponentSchema = { type: 'button', label: 'Quick Actions', icon: 'zap', - variant: 'outline', - onClick: 'openQuickActions' + variant: 'outline' }, { type: 'user', diff --git a/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts index 6fcd8dfdf9..71a9a6b362 100644 --- a/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts +++ b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts @@ -53,7 +53,8 @@ * 3. NAME NET — for the retired keys with NO live declaration anywhere * (`onColumnAdd`, `onCardAdd`, `onSlideChange`, `onSendMessage`, * `onSelectChange`, `onExpandChange`, `onCollapsedChange`, `onConfirm` on - * this tree), no row anywhere spells them callable, whatever owner name + * this tree; `onClose` and `onSave` joined with objectui#7344), no row + * anywhere spells them callable, whatever owner name * the page used. This is the net for doc-LOCAL interface names, which rule * 2 cannot resolve by construction (`plugins/*.mdx` document `Overview` / * `Features` / `Properties` blocks with no shipped counterpart). @@ -261,18 +262,25 @@ const CONTROL = [ ] as const; describe('the retired population is measured off the shipped tree (objectui#7340)', () => { - it('counts the `?: never` handler members #6124 left behind', () => { + it('counts the `?: never` handler members #6124 and #7344 left behind', () => { + // 22 from objectui#6124; objectui#7344 (the objectui#6182 string-dialect + // ruling, same shape) added `AppAction.onClick`, `ReportBuilderSchema.onSave` + // / `.onCancel` and `CRUDDialogSchema.onClose` — a ruled move of the + // population, recorded here rather than waved through. const split: Record = {}; for (const m of RETIRED) split[m.file] = (split[m.file] ?? 0) + 1; expect({ total: RETIRED.length, split }).toEqual({ - total: 22, + total: 26, split: { + 'app.ts': 1, 'complex.ts': 4, + 'crud.ts': 1, 'data-display.ts': 4, 'feedback.ts': 1, 'form.ts': 8, 'navigation.ts': 3, 'overlay.ts': 2, + 'reports.ts': 2, }, }); }); @@ -288,10 +296,12 @@ describe('the retired population is measured off the shipped tree (objectui#7340 it('names the keys no shipped interface declares callable', () => { expect(UNAMBIGUOUSLY_RETIRED_NAMES).toEqual([ 'onCardAdd', + 'onClose', 'onCollapsedChange', 'onColumnAdd', 'onConfirm', 'onExpandChange', + 'onSave', 'onSelectChange', 'onSendMessage', 'onSlideChange', diff --git a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts new file mode 100644 index 0000000000..5d9446bd25 --- /dev/null +++ b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -0,0 +1,393 @@ +/** + * 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 8 `on*` handler keys that PR #7339's census could not see — four + * declared the handler-expression STRING dialect (`z.string()`), three declared + * `z.any()`, one declared `z.function()` in a multi-line spelling — now REFUSE + * BY NAME (objectui#7344). + * + * ## The rulings this executes + * + * - objectui#6182 (maintainer, 2026-08-25, batch 4, 「同意」, option A): the + * handler-expression string dialect is NOT a supported authoring form, on + * either face. Its consequence clause sends the `z.string()` handler mirrors + * into objectui#6124's per-key sweep "with the same treatment". + * - objectui#6124 (maintainer, 2026-08-30, 「批次 #8 同意」; PR #7339): the + * treatment's SHAPE. zod face: a named refusal arm (`handlerKeyRefusal`, + * `../zod/tombstone.zod.ts`), no expression arm, no declarative-object arm. + * TypeScript face, measured per key: a function type only where a runtime + * consumer reads the key as a function, else `?: never`. + * + * ## Why #7339 missed these + * + * Its census anchored on `on*: z.function(` — one line, one spelling. The four + * `z.string()` sites and the three `z.any()` sites never matched; the eighth, + * `CalendarViewSchema.onEventClick`, WAS `z.function()` but spelled over three + * lines (`z` ⏎ `.function()` ⏎ `.optional()`). Two of the three families were + * worse than the one #7339 fixed: an authored string parsed GREEN on all seven + * (the mirror was WIDER than the declaration — objectui#7069's direction) and + * then reached a slot that calls it, throwing `onBack is not a function` at + * click (the objectui#4453 shape: an authored string handler that runs nothing + * and is refused nowhere). + * + * ## Per-key measurement (the TypeScript disposition), on `origin/main` @ `d88e20f55` + * + * RUNTIME SLOT — a host-supplied function REACHES a renderer: + * - `views.zod.ts#DetailViewSchema.onBack` — `detail-view`'s registration + * (`plugin-detail/src/index.tsx`) spreads the node's keys onto `DetailView` + * (`{...props}` after `SchemaRenderer`'s `...componentProps`), whose + * `handleBack` CALLS `onBack()` when set (`DetailView.tsx`). The TS twin + * declared `string`; the consumer's own prop is `() => void`, so the twin + * now declares what the renderer invokes. + * - `crud.zod.ts#DetailSchema.onBack` — `ComponentRegistry.register('detail', + * DetailView)` (`plugin-detail/src/index.tsx`), the same `handleBack`. + * - `crud.zod.ts#ActionSchema.onClick` — `ActionRunner.ts` `await + * action.onClick()` (two sites); `action-menu.tsx`, `containers.tsx`, + * `record-quick-actions.tsx` all `typeof action.onClick === 'function'`. + * - `complex.zod.ts#CalendarViewSchema.onEventClick` — `calendar-view`'s + * `pickHostCallbacks` forwards it when it is a function + * (`calendar-view-renderer.tsx`), the sibling of `onViewChange`'s arm. + * + * RETIRED — nothing reads the key (`?: never` on the TypeScript face): + * - `app.zod.ts#AppActionSchema.onClick` — `AppComponentSchema.actions[]` has + * no reader in `@object-ui/layout`, `@object-ui/app-shell` or the console; + * zero references to `AppAction` outside `packages/types`. + * - `reports.zod.ts#ReportBuilderSchema.onSave` / `.onCancel` — no renderer is + * registered for `report-builder` (control on the same tree: + * `register('detail-view'` and `register('report-designer'` both resolve). + * - `crud.zod.ts#CRUDDialogSchema.onClose` — no renderer is registered for + * `crud-dialog`; zero references to `CRUDDialogSchema` outside + * `packages/types` and the docs index. + * + * NONE read the key as a STRING and dispatched it — the third class the card + * reserved for the decision box did not occur on this tree. + * + * ## Excluded by ruling, verified by describe text + * + * `views.zod.ts` still declares three `on*: z.string()` keys — `onViewChange` + * (`ViewSwitcherSchema`), `onChange` (`FilterUISchema`), `onChange` + * (`SortUISchema`). They are event NAMES dispatched on `window` (PR #6899), not + * handler expressions; each `.describe()` says so verbatim, and the census below + * pins that wording as the reason the three survive the anchor. + * + * ## Predictions, written before the first run (red-first) + * + * On the unmodified tree (`origin/main` @ `d88e20f55`): + * - the single-line census finds 10 sites, not the 3 event-name keys; the + * multi-line census finds 1 (`complex.zod.ts` `onEventClick`), not 0; + * - no site carries the objectui#6124 guidance in its description; + * - an authored STRING parses GREEN on the seven `z.string()` / `z.any()` + * sites and is refused with `invalid_type` (not `custom`) on `onEventClick`; + * - a live function parses GREEN on the `z.any()` three and on `onEventClick`, + * and is refused with `invalid_type` on the `z.string()` four; + * - the whole-document counter-probe parses `onBack: 'goBack'` GREEN; + * - `tsc -p tsconfig.test.json` reports TS2344 on every `RetiredIsNever` and + * `StringIsGone` line, and on `KeepsFunction`. + * The `{}`-parses-green probes and the instrument controls are GREEN before and + * after — they pin the instrument, not this change. + */ + +import { describe, it, expect } from 'vitest'; +import { readdirSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { z } from 'zod'; + +import { AppActionSchema as AppActionZod } from '../zod/app.zod'; +import { CalendarViewSchema as CalendarViewZod } from '../zod/complex.zod'; +import { + ActionSchema as ActionZod, + CRUDDialogSchema as CRUDDialogZod, + DetailSchema as DetailZod, +} from '../zod/crud.zod'; +import { ReportBuilderSchema as ReportBuilderZod } from '../zod/reports.zod'; +import { + DetailViewSchema as DetailViewZod, + FilterUISchema as FilterUIZod, + SortUISchema as SortUIZod, + ViewSwitcherSchema as ViewSwitcherZod, +} from '../zod/views.zod'; + +import type { AppAction } from '../app'; +import type { CalendarViewSchema } from '../complex'; +import type { ActionSchema, CRUDDialogSchema, DetailSchema } from '../crud'; +import type { ReportBuilderSchema } from '../reports'; +import type { DetailViewSchema } from '../views'; + +/* ── The census, as data ─────────────────────────────────────────────────── */ + +type Site = readonly [file: string, schema: string, key: string, mirror: z.ZodType]; + +/** The object that DECLARES `key` behind a mirror. `crud.zod.ts#ActionSchema` + * is a `z.lazy` (its `chain` recurses), so the member lives one `unwrap()` + * down; every other mirror here IS the object. Same helper as the #6124 pin. */ +const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => { + const inner = mirror instanceof z.ZodLazy ? mirror.unwrap() : mirror; + const obj = inner as z.ZodObject; + if (!(key in obj.shape)) throw new Error(`mirror does not declare \`${key}\``); + return obj; +}; + +/** The four keys whose function value REACHES a renderer (channels above). */ +const RUNTIME_SLOT: readonly Site[] = [ + ['views.zod.ts', 'DetailViewSchema', 'onBack', DetailViewZod], + ['crud.zod.ts', 'ActionSchema', 'onClick', ActionZod], + ['crud.zod.ts', 'DetailSchema', 'onBack', DetailZod], + ['complex.zod.ts', 'CalendarViewSchema', 'onEventClick', CalendarViewZod], +]; + +/** The four keys NO renderer reads. */ +const RETIRED: readonly Site[] = [ + ['app.zod.ts', 'AppActionSchema', 'onClick', AppActionZod], + ['reports.zod.ts', 'ReportBuilderSchema', 'onSave', ReportBuilderZod], + ['reports.zod.ts', 'ReportBuilderSchema', 'onCancel', ReportBuilderZod], + ['crud.zod.ts', 'CRUDDialogSchema', 'onClose', CRUDDialogZod], +]; + +const ALL_SITES: readonly Site[] = [...RUNTIME_SLOT, ...RETIRED]; + +/** The three `on*: z.string()` keys that SURVIVE the census — event names, not + * handlers — with the mirror that declares each, so the exclusion is pinned to + * its reason (the describe text) and not to a line number. */ +const EVENT_NAME_KEYS: readonly Site[] = [ + ['views.zod.ts', 'ViewSwitcherSchema', 'onViewChange', ViewSwitcherZod], + ['views.zod.ts', 'FilterUISchema', 'onChange', FilterUIZod], + ['views.zod.ts', 'SortUISchema', 'onChange', SortUIZod], +]; +const EVENT_NAME_WORDING = 'an event NAME, not a callback or a handler expression'; + +const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); +/** EVERY mirror module — objectui#6182's close condition runs over the whole + * directory, not the nine files #7339's census listed (that list is what let + * these eight through). */ +const MIRROR_FILES = readdirSync(ZOD_DIR) + .filter((f) => f.endsWith('.zod.ts')) + .sort(); +const readMirror = (file: string) => readFileSync(join(ZOD_DIR, file), 'utf8'); + +/** objectui#6182's anchor, single-line: the key, then `z.` and one of the three + * constructors on the same line. Anchored at line start so the spelling cannot + * match mid-identifier (`buttonLabel`, `actionUrl`). */ +const ON_KEY_SINGLE_LINE = /^\s*(on[A-Z][A-Za-z]*): z\.(function|string|any)\(/gm; +/** The multi-line spelling that hid `onEventClick`: `on*: z` ending the line, + * `.function(` opening the next. */ +const ON_KEY_MULTI_LINE_FUNCTION = /^\s*(on[A-Z][A-Za-z]*): z[ \t]*\r?\n\s*\.function\(/gm; +/** Control for the multi-line instrument: the same two-line shape on a NON-handler + * key with any constructor (`startDateField: z` ⏎ `.string()`, `buttonVariant: z` + * ⏎ `.enum(`). Fires on this tree; a regex that could not see line breaks + * would report 0 here too. */ +const MULTI_LINE_CONTROL = /^\s*[a-z][A-Za-z]*: z[ \t]*\r?\n\s*\.(enum|string|any|union|number|boolean)\(/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, so 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); + +/** The handler-expression string dialect, in the spellings the corpus taught: + * a bare handler name (`content/docs/core/report-schema.mdx` `onSave: + * 'handleSaveReport'`) and an inline call (`examples/schema-catalog` + * `onClick: "toast(\"…\")"`). */ +const AUTHORED_STRINGS = ['handleSaveReport', 'toast("Hello from ObjectUI!")'] as const; +const AUTHORED_ACTION_OBJECT = { action: 'toast', title: 'Saved', variant: 'success' }; +const LIVE_FUNCTION = () => undefined; + +/* ── Census: objectui#6182's close condition, both anchors ───────────────── */ + +describe('census: the only on*: z.(function|string|any) lines left in packages/types/src/zod are the three event-name keys (objectui#6182 close condition)', () => { + it('the single-line anchor returns exactly the three event-name keys', () => { + const hits = MIRROR_FILES.flatMap((file) => + [...readMirror(file).matchAll(ON_KEY_SINGLE_LINE)].map((m) => `${file}#${m[1]}: z.${m[2]}(`), + ); + expect(hits.sort()).toEqual([ + 'views.zod.ts#onChange: z.string(', + 'views.zod.ts#onChange: z.string(', + 'views.zod.ts#onViewChange: z.string(', + ]); + }); + + it('the multi-line anchor returns 0, and its control still fires on the same files', () => { + const hits = MIRROR_FILES.flatMap((file) => + [...readMirror(file).matchAll(ON_KEY_MULTI_LINE_FUNCTION)].map((m) => `${file}#${m[1]}`), + ); + expect(hits).toEqual([]); + const control = MIRROR_FILES.flatMap((file) => + [...readMirror(file).matchAll(MULTI_LINE_CONTROL)].map((m) => `${file}: ${m[0].trim()}`), + ); + expect(control.length).toBeGreaterThan(0); + }); + + it.each(EVENT_NAME_KEYS)('%s %s.%s survives the anchor BECAUSE its describe text says it is an event name (PR #6899)', (_file, _schema, key, mirror) => { + expect(describeOf(mirror, key)).toContain(EVENT_NAME_WORDING); + // Still the string it always was — this card does not touch the three. + expect(pickKey(mirror, key).safeParse({ [key]: 'view-changed' }).success).toBe(true); + }); + + it('the census read the whole mirror directory, not a hand-listed subset', () => { + // Every file this card edits is in the population, and so is the one that + // held the multi-line site. A directory read cannot drift the way #7339's + // nine-file list did. + for (const file of ['app.zod.ts', 'complex.zod.ts', 'crud.zod.ts', 'reports.zod.ts', 'views.zod.ts']) { + expect(MIRROR_FILES).toContain(file); + } + expect(MIRROR_FILES.length).toBeGreaterThanOrEqual(12); + }); + + it('8 sites are ledgered, 4 runtime slots + 4 retired, with no key filed twice', () => { + expect(RUNTIME_SLOT).toHaveLength(4); + expect(RETIRED).toHaveLength(4); + const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); + expect(new Set(ids).size).toBe(8); + }); + + 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) => { + // `.shape`, not `safeParse`: under `.passthrough()` a DELETED key still + // parses green (the value rides through), so a parse-based declaration pin + // stays green through the very deletion it exists to catch. + expect(objectOf(mirror, key).shape[key]).toBeDefined(); + expect(describeOf(mirror, key)).toContain('objectui#6124'); + }); +}); + +/* ── Behaviour: the string dialect is refused BY NAME, not parsed green ──── */ + +describe('the handler-expression string dialect is refused by name (objectui#6182 → the #6124 shape)', () => { + it.each(ALL_SITES)('%s %s.%s refuses an authored STRING at its own path with code `custom` and the guidance', (_file, _schema, key, mirror) => { + for (const authored of AUTHORED_STRINGS) { + const result = pickKey(mirror, key).safeParse({ [key]: authored }); + expect(result.success, `\`${key}: ${JSON.stringify(authored)}\` parsed green`).toBe(false); + if (result.success) return; + // ON THE KEY: an issue addressed to `key`, not merely a failed parse. + const issue = result.error.issues.find((i) => String(i.path[0]) === key); + expect(issue, `no issue addressed to \`${key}\``).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.path).toEqual([key]); + expect(issue!.message).toContain(`\`${key}\``); + // The #6498 remedy, and the one-string invariant: the runtime message + // IS the `.describe()` metadata. + expect(issue!.message).toContain('"type"'); + expect(issue!.message).toContain('action:button'); + expect(issue!.message).toBe(describeOf(mirror, key)); + } + }); + + it.each(ALL_SITES)('%s %s.%s refuses an authored action OBJECT too — no declarative-object arm', (_file, _schema, key, mirror) => { + const result = pickKey(mirror, key).safeParse({ [key]: AUTHORED_ACTION_OBJECT }); + 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 refuses a LIVE FUNCTION — the JSON mirror is not the programmatic channel', (_file, _schema, key, mirror) => { + // The accept-set change the changeset declares, in the `z.any()` / + // `z.function()` direction: 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'); + } + }); +}); + +/* ── Counter-probes: why an arm, and not a deletion, on BOTH base shapes ─── */ + +describe('counter-probe: deleting the key instead is a SILENT accept on either base shape (the ruling\'s ⛔ 不裸删)', () => { + const detailView = { type: 'detail-view', title: 'Account', onBack: 'goBack' }; + + it('with the arm: a whole `detail-view` document is refused at path onBack', () => { + const result = DetailViewZod.safeParse(detailView); + expect(result.success).toBe(false); + expect(result.error?.issues.map((i) => i.path)).toEqual([['onBack']]); + }); + + it('the deletion, simulated on a `.passthrough()` mirror: parses GREEN and KEEPS the string, which then reaches `DetailView.onBack` and throws at click', () => { + const result = DetailViewZod.omit({ onBack: true }).safeParse(detailView); + expect(result.success).toBe(true); + expect((result.data as Record).onBack).toBe('goBack'); + }); + + it('the deletion, simulated on a plain `z.object` mirror: parses GREEN and DROPS the string — the objectui#4453 silence, from the other side', () => { + // `AppActionSchema` is not `.passthrough()`: an undeclared key is stripped, + // so the author is told green and the value vanishes. Two base shapes, two + // different silences; the named refusal is the only outcome that is loud on + // both. + const authored = { type: 'button', label: 'Quick Actions', onClick: 'openQuickActions' }; + const withArm = AppActionZod.safeParse(authored); + expect(withArm.success).toBe(false); + expect(withArm.error?.issues.map((i) => i.path)).toEqual([['onClick']]); + const deleted = AppActionZod.omit({ onClick: true }).safeParse(authored); + expect(deleted.success).toBe(true); + expect('onClick' in (deleted.data as Record)).toBe(false); + }); +}); + +/* ── 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`, because `BaseSchema`'s index signature makes a DELETED member read + * `any`, which a one-way check would accept). */ +type RetiredIsNever = Equal; + +/** A runtime slot keeps a callable member. Over `NonNullable` so `undefined` + * cannot satisfy it. */ +type KeepsFunction = [Extract, (...args: never[]) => unknown>] extends [never] + ? false + : true; + +/** The string dialect is gone from the TypeScript face too (objectui#6182: "not + * a supported authoring form" on BOTH faces): no `string` survives the member. */ +type StringIsGone = [Extract, string>] extends [never] ? true : false; + +export type assertionRetiredKeysAreTombstoned = [ + Expect>, + Expect>, + Expect>, + Expect>, +]; + +export type assertionRuntimeSlotsKeepTheirFunctionType = [ + Expect>, + Expect>, + Expect>, + Expect>, +]; + +/** The four former `string` twins — `app.ts`, `reports.ts` ×2, `views.ts`. */ +export type assertionStringTwinsStopDeclaringString = [ + Expect>, + Expect>, + Expect>, + Expect>, +]; + +// The three helpers must be able to FAIL — synthetic controls, both directions. +export type assertionRetiredIsNeverCanFail = Expect void) | undefined>, false>>; +export type assertionKeepsFunctionCanFail = Expect, false>>; +export type assertionStringIsGoneCanFail = Expect, false>>; diff --git a/packages/types/src/__tests__/phase2-schemas.test.ts b/packages/types/src/__tests__/phase2-schemas.test.ts index 53e78760e2..1621275798 100644 --- a/packages/types/src/__tests__/phase2-schemas.test.ts +++ b/packages/types/src/__tests__/phase2-schemas.test.ts @@ -260,13 +260,31 @@ describe('Phase 2: ReportComponentSchema Zod Validation', () => { const builder = { type: 'report-builder', showPreview: true, - onSave: 'handleSave', - onCancel: 'handleCancel', }; const result = ReportBuilderSchema.safeParse(builder); expect(result.success).toBe(true); }); + + it('refuses the handler-expression strings this fixture used to prove VALID (objectui#7344)', () => { + // This test carried `onSave: 'handleSave', onCancel: 'handleCancel'` and + // asserted green — the string dialect objectui#6182 ruled is not a + // supported authoring form (2026-08-25), on a `report-builder` no renderer + // is registered for. Both keys now refuse BY NAME (the objectui#6124 shape; + // the per-site pin is `handler-keys-string-any-mirrors-7344.test.ts`), so + // the old fixture is kept here as the negative reading, not deleted. + const result = ReportBuilderSchema.safeParse({ + type: 'report-builder', + showPreview: true, + onSave: 'handleSave', + onCancel: 'handleCancel', + }); + expect(result.success).toBe(false); + expect(result.error?.issues.map((i) => [i.code, String(i.path[0])]).sort()).toEqual([ + ['custom', 'onCancel'], + ['custom', 'onSave'], + ]); + }); }); describe('Phase 2: Block component kinds — retirement pins', () => { diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index 37561e7130..73347df9e8 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -57,10 +57,13 @@ * 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. - * - **37 entries** in `KnownDrift`, **53 keys** across them. It was 12 / 17 until + * - **39 entries** in `KnownDrift`, **56 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` — and 36 / 52 until - * objectui#6576 minted `ObjectDataTableSchema` with one such arm (`onRowClick`). + * the class note inside the ledger, above `ButtonSchema` — 36 / 52 until + * objectui#6576 minted `ObjectDataTableSchema` with one such arm (`onRowClick`), + * and 37 / 53 until objectui#7344 swept the string / `z.any()` handler mirrors: + * `DetailSchema` and `DetailViewSchema` entered (one `onBack` each) and + * `CalendarViewSchema` grew by `onEventClick`. * - **17 entries** in `UnmirroredDeclared`, **98 keys** across them (16 / 97 until * objectui#6576 SEEDED the new `ObjectDataTableSchema` pair with its one measured * key, `drillDown` — a pair born ledgered, not growth on an existing one). ⚠️ It was @@ -76,7 +79,7 @@ * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), so the * "no entry in either" population dropped by one to 141 — and stands at 142 * since objectui#6576 added two pairs, one of them ledgered. - * - 160 − 37 = **123**, the "pairs with no entry" `LedgerMismatch` speaks of. + * - 160 − 39 = **121**, the "pairs with no entry" `LedgerMismatch` speaks of. * * ## Two ratchets, because the forward comparison has two halves * @@ -97,7 +100,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 37 of the 160 pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 39 of the 160 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 @@ -676,8 +679,13 @@ 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 { - /** 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'; + /** + * RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads + * `onViewChange` off the spread props (function values only) and hands it to + * `CalendarView`. `onEventClick` joined with objectui#7344 — the same channel; + * its mirror was a multi-line `z.function()` that PR #7339's census missed. + */ + 'complex.zod.ts#CalendarViewSchema': 'onEventClick' | 'onViewChange'; /** * `body` — TS declares `SchemaNode | SchemaNode[]` (a rendered slot); the mirror * declares `Record` ("additional API body params"). Two different @@ -711,6 +719,12 @@ interface KnownDrift { 'complex.zod.ts#FilterFieldSchema': 'operators'; /** 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'; + /** + * RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — `DetailView`'s + * `handleBack` calls `onBack()` when set. The mirror was `z.any()` (wider than + * the declared callable, objectui#7069's direction); it now refuses by name. + */ + 'crud.zod.ts#DetailSchema': 'onBack'; /** * `rowActions` — DISJOINT: TS declares `rowActions?: boolean` (show the column or * not), the mirror declares `any[]` (the actions themselves). One of the two is @@ -827,6 +841,13 @@ interface KnownDrift { 'overlay.zod.ts#PopoverSchema': 'onOpenChange'; /** RUNTIME SLOT (objectui#6124): the `sheet` renderer spreads leftover props onto the Radix `Sheet` (Dialog) root. */ 'overlay.zod.ts#SheetSchema': 'onOpenChange'; + /** + * RUNTIME SLOT (objectui#7344): `detail-view` spreads the node's keys onto + * `DetailView`, whose `handleBack` calls `onBack()`. The TS twin declared the + * handler-expression STRING (objectui#6182: not an authoring form) and now + * declares the callable the renderer invokes; the mirror refuses by name. + */ + 'views.zod.ts#DetailViewSchema': 'onBack'; } /* ── The measured unmirrored-declared ledger (objectui#6058) ────────────────── */ @@ -1282,7 +1303,7 @@ export type assertionLedgerHalvesAreDisjoint = Expect< Equal< DoubleFiledKey, ne /** * Every pair's TYPE drift equals what `KnownDrift` records for it — `never` for the - * 123 pairs with no entry (160 − 37). + * 121 pairs with no entry (160 − 39). * * Routed through `ReconcileAgainstLedger` rather than spelling the conditional * inline. That is a semantics-preserving refactor and nothing else — the type is diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index 61d5f6795a..a50a353e6c 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -713,7 +713,16 @@ export interface AppAction { type: 'button' | 'dropdown' | 'user'; label?: string; icon?: string; - onClick?: string; + /** + * RETIRED (objectui#7344; the objectui#6182 ruling of 2026-08-25 — the + * handler-expression string dialect is not a supported authoring form — in + * the objectui#6124 shape). Nothing reads `AppComponentSchema.actions[]`, so + * no value here could ever run. The zod twin refuses the key by name; author + * behaviour as a node type (an `action:button` node with a declared action) + * instead. + * @deprecated Not part of this contract — the value was inert. + */ + onClick?: never; /** * User Avatar URL (for type='user') */ diff --git a/packages/types/src/complex.ts b/packages/types/src/complex.ts index eb6bfa1c71..909e8dd8b4 100644 --- a/packages/types/src/complex.ts +++ b/packages/types/src/complex.ts @@ -274,6 +274,10 @@ export interface CalendarViewSchema extends BaseSchema { * Event click handler — HOST-ONLY. The renderer forwards it only when the * value is a function, which authored JSON can never produce; supply it from * a React host (``). + * + * RUNTIME SLOT (objectui#7344, the objectui#6124 shape) — the zod twin, which + * declared `z.function()` in a multi-line spelling PR #7339's census missed, + * now refuses this key by name, like `onViewChange` below. */ onEventClick?: (event: CalendarEvent) => void; /** diff --git a/packages/types/src/crud.ts b/packages/types/src/crud.ts index a12b46c381..d43c2d63f7 100644 --- a/packages/types/src/crud.ts +++ b/packages/types/src/crud.ts @@ -205,7 +205,11 @@ export interface ActionSchema extends BaseSchema { */ close?: boolean; /** - * Custom click handler + * Custom click handler — RUNTIME SLOT (objectui#7344, the objectui#6124 + * shape): a host-supplied function, NOT authorable metadata. `ActionRunner` + * awaits `action.onClick()` and the action renderers guard + * `typeof action.onClick === 'function'`, so the callable stays; the zod + * twin, which accepted `z.any()`, now refuses the key by name. */ onClick?: () => void | Promise; /** @@ -298,7 +302,10 @@ export interface DetailSchema extends BaseSchema { */ showBack?: boolean; /** - * Custom back action + * Custom back action — RUNTIME SLOT (objectui#7344, the objectui#6124 shape): + * a host-supplied function, NOT authorable metadata. `detail` is registered to + * `DetailView`, whose `handleBack` calls `onBack()` when set; the zod twin, + * which accepted `z.any()`, now refuses the key by name. */ onBack?: () => void; /** @@ -340,9 +347,13 @@ export interface CRUDDialogSchema extends BaseSchema { */ open?: boolean; /** - * Close handler + * Close handler — RETIRED (objectui#7344, ADR-0049, the objectui#6124 shape): + * no renderer is registered for `crud-dialog`, so nothing reads the key. The + * zod twin, which accepted `z.any()`, now refuses it by name; author + * behaviour as a node type instead. + * @deprecated Not part of this contract — the value was inert. */ - onClose?: () => void; + onClose?: never; /** * Whether clicking outside closes dialog * @default true diff --git a/packages/types/src/reports.ts b/packages/types/src/reports.ts index 06987d5870..e2113e0832 100644 --- a/packages/types/src/reports.ts +++ b/packages/types/src/reports.ts @@ -509,14 +509,19 @@ export interface ReportBuilderSchema extends BaseSchema { showPreview?: boolean; /** - * Save callback + * Save callback — RETIRED (objectui#7344; the objectui#6182 ruling in the + * objectui#6124 shape). No renderer is registered for `report-builder`, so + * the handler-expression string this declared could never run. The zod twin + * refuses the key by name; author behaviour as a node type instead. + * @deprecated Not part of this contract — the value was inert. */ - onSave?: string; + onSave?: never; /** - * Cancel callback + * Cancel callback — RETIRED (objectui#7344), the same measurement as `onSave`. + * @deprecated Not part of this contract — the value was inert. */ - onCancel?: string; + onCancel?: never; } /** diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 9743fc77f7..4b27423503 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -572,9 +572,16 @@ export interface DetailViewSchema extends BaseSchema { */ backUrl?: string; /** - * Custom back action - */ - onBack?: string; + * Custom back action — RUNTIME SLOT (objectui#7344, the objectui#6124 shape): + * a host-supplied function, NOT authorable metadata. `detail-view` spreads the + * node's keys onto `DetailView`, whose `handleBack` CALLS `onBack()` when it is + * set, so this declares the callable the renderer invokes. It used to declare + * the handler-expression STRING, which objectui#6182 ruled is not an authoring + * form and which threw `onBack is not a function` at click. The zod twin + * refuses the key by name; supply it from a React host + * (``). + */ + onBack?: () => void; /** * Show edit button */ diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 51bfedfc54..3c6609b932 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -23,6 +23,7 @@ import { NavigationAreaSchema as SpecNavigationAreaSchema, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; +import { handlerKeyRefusal } from './tombstone.zod.js'; // ============================================================================ // Unified NavigationItem Schema @@ -202,7 +203,10 @@ export const AppActionSchema = z.object({ type: z.enum(['button', 'dropdown', 'user']).describe('Action type'), label: z.string().optional().describe('Action label'), icon: z.string().optional().describe('Icon name'), - onClick: z.string().optional().describe('Click handler expression'), + // RETIRED (objectui#7344 — the objectui#6182 ruling: the handler-expression + // string dialect is not an authoring form; the objectui#6124 shape). Nothing + // reads `AppComponentSchema.actions[]`, so the key refuses by name. + onClick: handlerKeyRefusal('onClick', 'retired', 'Click handler'), avatar: z.string().optional().describe('User avatar URL (for type="user")'), description: z.string().optional().describe('Additional description (e.g., email for user)'), items: z.array(MenuItemSchema).optional().describe('Dropdown menu items (for type="dropdown" or "user")'), diff --git a/packages/types/src/zod/complex.zod.ts b/packages/types/src/zod/complex.zod.ts index 85b951a4ac..ae67ff3c5a 100644 --- a/packages/types/src/zod/complex.zod.ts +++ b/packages/types/src/zod/complex.zod.ts @@ -142,10 +142,10 @@ export const CalendarViewSchema = BaseSchema.extend({ .describe('Initial calendar date (ISO string authored; Date instance from a React host)'), allowCreate: z.boolean().optional().describe('Show the "New event" affordance (default false)'), className: z.string().optional().describe('Tailwind classes for the calendar container'), - onEventClick: z - .function() - .optional() - .describe('Host-only event click handler (authored JSON cannot produce a function)'), + // objectui#7344: the multi-line `z.function()` spelling PR #7339's anchored + // census could not see; `pickHostCallbacks` forwards it exactly like + // `onViewChange` below. + onEventClick: handlerKeyRefusal('onEventClick', 'runtime-slot', 'Host-only event click handler'), onViewChange: handlerKeyRefusal('onViewChange', 'runtime-slot', 'Host-only view change handler'), }); diff --git a/packages/types/src/zod/crud.zod.ts b/packages/types/src/zod/crud.zod.ts index 3819811723..2cfc2363a4 100644 --- a/packages/types/src/zod/crud.zod.ts +++ b/packages/types/src/zod/crud.zod.ts @@ -20,7 +20,7 @@ import { z } from 'zod'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; -import { retirementTombstone } from './tombstone.zod.js'; +import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; /** * Action Execution Mode Schema @@ -102,7 +102,11 @@ export const ActionSchema: z.ZodType = z.lazy(() => BaseSchema.extend({ condition: ActionConditionPredicateSchema.optional().describe('Execution gate — the action runs only while this predicate holds'), reload: z.boolean().optional().default(true).describe('Whether to reload data after action'), close: z.boolean().optional().default(true).describe('Whether to close dialog/modal after action'), - onClick: z.any().optional().describe('Custom click handler'), + // RUNTIME SLOT (objectui#7344): `ActionRunner` awaits `action.onClick()` and + // the action renderers guard `typeof action.onClick === 'function'`. The + // `z.any()` this replaces accepted an authored string or object that then + // reached that call (objectui#7069's mirror-wider-than-declared direction). + onClick: handlerKeyRefusal('onClick', 'runtime-slot', 'Custom click handler'), redirect: z.string().optional().describe('Redirect URL after success'), tracking: z.object({ enabled: z.boolean().optional().describe('Enable tracking'), @@ -142,7 +146,9 @@ export const DetailSchema = BaseSchema.extend({ content: z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)]), })).optional().describe('Tabs for additional content'), showBack: z.boolean().optional().default(true).describe('Show back button'), - onBack: z.any().optional().describe('Custom back action'), + // RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — the same + // `handleBack` that calls `onBack()` for `detail-view`. Was `z.any()`. + onBack: handlerKeyRefusal('onBack', 'runtime-slot', 'Custom back action'), loading: z.boolean().optional().default(true).describe('Whether to show loading state'), }); @@ -157,7 +163,9 @@ export const CRUDDialogSchema = BaseSchema.extend({ size: z.enum(['sm', 'default', 'lg', 'xl', 'full']).optional().default('default').describe('Dialog size'), actions: z.array(ActionSchema).optional().describe('Dialog actions/buttons'), open: z.boolean().optional().describe('Whether dialog is open'), - onClose: z.any().optional().describe('Close handler'), + // RETIRED (objectui#7344): no renderer is registered for `crud-dialog`; + // nothing reads the key. Was `z.any()`. + onClose: handlerKeyRefusal('onClose', 'retired', 'Close handler'), closeOnOutsideClick: z.boolean().optional().default(true).describe('Whether clicking outside closes dialog'), closeOnEscape: z.boolean().optional().default(true).describe('Whether pressing Escape closes dialog'), showClose: z.boolean().optional().default(true).describe('Show close button'), diff --git a/packages/types/src/zod/reports.zod.ts b/packages/types/src/zod/reports.zod.ts index 9c507a8ab9..c31fb670ff 100644 --- a/packages/types/src/zod/reports.zod.ts +++ b/packages/types/src/zod/reports.zod.ts @@ -19,6 +19,7 @@ import { z } from 'zod'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; import { ChartSchema } from './data-display.zod.js'; +import { handlerKeyRefusal } from './tombstone.zod.js'; /** * Report Export Format Schema @@ -167,8 +168,11 @@ export const ReportBuilderSchema = BaseSchema.extend({ dataSources: z.array(z.any()).optional().describe('Available data sources'), availableFields: z.array(ReportFieldSchema).optional().describe('Available fields'), showPreview: z.boolean().optional().describe('Show preview'), - onSave: z.string().optional().describe('Save callback'), - onCancel: z.string().optional().describe('Cancel callback'), + // RETIRED (objectui#7344, the objectui#6182 ruling in the objectui#6124 shape): + // no renderer is registered for `report-builder`, so neither callback could + // ever run; both refuse by name. + onSave: handlerKeyRefusal('onSave', 'retired', 'Save callback'), + onCancel: handlerKeyRefusal('onCancel', 'retired', 'Cancel callback'), }); /** diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index 66066b2443..11cfb443be 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -18,6 +18,7 @@ import { z } from 'zod'; import { BaseSchema, SchemaNodeSchema } from './base.zod.js'; +import { handlerKeyRefusal } from './tombstone.zod.js'; /** * View Type Schema @@ -114,7 +115,11 @@ export const DetailViewSchema = BaseSchema.extend({ tabs: z.array(DetailViewTabSchema).optional().describe('Tabs for additional content'), showBack: z.boolean().optional().default(true).describe('Show back button'), backUrl: z.string().optional().describe('Back button URL'), - onBack: z.string().optional().describe('Custom back action'), + // RUNTIME SLOT (objectui#7344, the objectui#6182 ruling in the objectui#6124 + // shape): `detail-view` spreads the node's keys onto `DetailView`, whose + // `handleBack` CALLS `onBack()` — a host-supplied function, never the string + // this mirror used to accept (which threw `onBack is not a function` at click). + onBack: handlerKeyRefusal('onBack', 'runtime-slot', 'Custom back action'), showEdit: z.boolean().optional().describe('Show edit button'), editUrl: z.string().optional().describe('Edit button URL'), showDelete: z.boolean().optional().describe('Show delete button'),