diff --git a/.changeset/7804-objectql-handler-key-arms.md b/.changeset/7804-objectql-handler-key-arms.md new file mode 100644 index 0000000000..21f699444c --- /dev/null +++ b/.changeset/7804-objectql-handler-key-arms.md @@ -0,0 +1,60 @@ +--- +'@object-ui/types': minor +--- + +The four plain `objectql.ts` node faces declare the nine handler keys their +registered renderers read (objectui#7804, the `objectql.ts` slice): +`ObjectFormSchema.onCancel` / `.onError` / `.onOpenChange` / `.onStepChange` / +`.onSuccess`, `ObjectGallerySchema.onCardClick` / `.onRowClick`, +`ObjectGridSchema.onNavigate` and `ObjectViewSchema.onNavigate`. + +`BaseSchema` is `.passthrough()`, so a key no arm declares is not refused — it +stops being judged and the value is KEPT. All nine were in that state while a +registered renderer read and INVOKED each one, so an authored +`{ "type": "object-form", "objectName": "a", "mode": "create", "onSuccess": { "action": "toast" } }` +parsed GREEN and that action object was handed to a call site expecting a +function. Each key is now a named refusal on the mirror (`handlerKeyRefusal`, +the objectui#6124 shape), whose message says why JSON cannot author it and what +to write instead. + +Accept-set change on the published validator, stated plainly — this NARROWS: + +- REFUSED where it was accepted: any value at all on these keys of the node they + belong to, including the `{ "action": … }` object shape a declarative author + would reach for. Previously accepted and kept; now refused by name with + remediation text, at `code: 'custom'` on the key's own path. +- REFUSED one level deeper too, and this is a consequence rather than a separate + decision: `ObjectViewSchema`'s nested `form` and `table` config slots are the + `object-form` / `object-grid` mirrors BY REFERENCE, and the declaration types + them off `ObjectFormSlotKey` / `ObjectGridSlotKey` — two unions that list + exactly these handler keys. So an authored `form: { onSuccess: … }` on an + `object-view` node is refused as well. Working around that by omitting the + keys from the nested reference would keep accepting an un-authorable function + value one level down, which is the defect and not the fix. +- Measured before choosing this level: NOTHING in this repository authors any of + the nine as metadata — not in `examples/`, not in `apps/`, not in the schema + catalog, not in a doc fence. Across 2603 tracked `.json` / `.md` / `.mdx` / + `.yml` / `.yaml` files the JSON key spelling reads zero for every one (the + three textual hits are changeset PROSE from this card's earlier slices, + quoting the defect); across 274 `apps/` + `examples/` TypeScript sources every + hit is a React prop on a JSX element or a local component's own prop; across + 471 `examples/schema-catalog` files the one hit is the substring inside + `GPUInitializationError`. Lit controls fired in the same pass on every corpus + (`"objectName"`, `"gallery"`, `"titleField"`, `"layout"`, `"object-form"`), so + each zero is a reading and not a silence. +- TS face: the seven keys already declared keep their function types, because + the value genuinely reaches the renderer — the programmatic channel is the + TypeScript interface and React props, never `safeParse`. The two + `ObjectGallerySchema` keys are DECLARED here for the first time, which narrows + that face too: they were reaching the renderer through `SchemaRenderer`'s + props spread while `BaseSchema`'s index signature admitted them as `any`. +- The disposition was measured per key rather than applied as a pattern, and + three of the nine do not share the group's supplier: `ObjectFormSchema.onStepChange`, + `ObjectGallerySchema.onCardClick` and `ObjectGridSchema.onNavigate` have no + in-repo host filling them, though each channel is wired end to end and each + key is still read and still run. `'retired'` would have published "no renderer + reads this key" for nine keys renderers demonstrably read. + +No renderer behaviour changes; a host that supplies these functions in +TypeScript is unaffected, and `check:handler-key-reads` drops the nine ledger +rows that waived them, leaving 20. diff --git a/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts b/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts index b6fafc72c4..414094b219 100644 --- a/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts +++ b/packages/react/src/hooks/__tests__/navigationOverlayConsumers.onRowClickArity-9357.test.ts @@ -154,13 +154,35 @@ function splitParams(src: string, open: number): string[] | null { return null; } -/** Parameters of the inline function TYPE declared for `member`, or null. */ -function declParams(src: string, member: string): string[] | null { +/** + * Parameters of the inline function TYPE declared for `member`, or null. + * + * `after` scopes the search to the text following a literal anchor — the + * declaring interface's own `export interface X` line. ⚠️ It is not a + * convenience: without it this reader answers for the FIRST declaration of + * `member` in the file and silently reattributes the site the moment a second + * one appears above it. objectui#7804's `objectql.ts` slice made that concrete — + * that file now carries TWO `onRowClick` declarations with DIFFERENT contracts + * (`ObjectGallerySchema`'s two-parameter modifier-forwarding one, and + * `ObjectDataTableSchema`'s one-parameter `row: any`), and they sit in opposite + * halves of this file's ledger. A file-scoped reader cannot express that. + * + * A missing anchor returns `null` rather than falling back to the whole file, + * so a renamed or deleted interface REDS here instead of quietly answering + * about some other declaration. + */ +function declParams(src: string, member: string, after?: string): string[] | null { + let text = src; + if (after !== undefined) { + const at = src.indexOf(after); + if (at < 0) return null; + text = src.slice(at); + } const re = new RegExp(`(^|[^\\w$])${member}\\??\\s*:\\s*\\(`, 'm'); - const m = re.exec(src); + const m = re.exec(text); if (!m) return null; - const open = src.indexOf('(', m.index + m[0].length - 1); - const params = splitParams(src, open); + const open = text.indexOf('(', m.index + m[0].length - 1); + const params = splitParams(text, open); if (!params) return null; return params; } @@ -175,7 +197,7 @@ function readSource(rel: string): string { * the criterion. */ const HOOK_CALL = /useNavigationOverlay\s*\(/; -const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp }> = [ +const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp; after?: string }> = [ { rel: 'packages/plugin-grid/src/ObjectGrid.tsx', member: 'onRowClick', why: 'fed to useNavigationOverlay as its onRowClick', hop: HOOK_CALL }, { rel: 'packages/plugin-kanban/src/ObjectKanban.tsx', member: 'onRowClick', @@ -201,6 +223,21 @@ const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp }> { rel: 'packages/plugin-detail/src/RelatedList.tsx', member: 'onRowClick', why: 'placed on the object-gallery schema it renders, reaching ObjectGallery props', hop: /type: 'object-gallery'/ }, + // ⭐ objectui#7804's `objectql.ts` slice closed the remaining hole on this + // exact path. `RelatedList` above is IN *because* it writes `onRowClick` onto + // the `object-gallery` NODE — and until that slice the node type declared + // neither key, so `BaseSchema`'s index signature typed both `any` and a host + // writing the node learned nothing about the second parameter. That is this + // card's own defect ("Declaring one parameter hid the second on the ONE line a + // host reads") one hop further out. Both are anchored to their interface: the + // same file's `ObjectDataTableSchema.onRowClick` is a CONTROL below, with a + // different arity and a different contract. + { rel: 'packages/types/src/objectql.ts', member: 'onRowClick', + why: 'the object-gallery node face ListView and RelatedList write onto; SchemaRenderer spreads it into the props fed to useNavigationOverlay', + hop: /type: 'object-gallery'/, after: 'export interface ObjectGallerySchema' }, + { rel: 'packages/types/src/objectql.ts', member: 'onCardClick', + why: 'the onCardClick arm of the same `??` inside ObjectGallery, on the same node face', + hop: /type: 'object-gallery'/, after: 'export interface ObjectGallerySchema' }, { rel: 'packages/plugin-kanban/src/index.tsx', member: 'onCardClick', why: 'handed to KanbanImpl, whose SortableCard invokes it with the DOM event', hop: /onCardClick=\{schema\.onCardClick\}/ }, @@ -212,7 +249,7 @@ const IN_SITES: Array<{ rel: string; member: string; why: string; hop: RegExp }> * If a later change widens one of these, this file reds and the reasoning below * gets revisited instead of the edit going through unremarked. */ -const OUT_SITES: Array<{ rel: string; member: string; arity: number; param: RegExp; why: string }> = [ +const OUT_SITES: Array<{ rel: string; member: string; arity: number; param: RegExp; why: string; after?: string }> = [ { rel: 'packages/plugin-grid/src/VirtualGrid.tsx', member: 'onRowClick', arity: 2, param: /^index: number$/, why: 'its second parameter is `index: number` — a DIFFERENT contract, not this one' }, { rel: 'packages/plugin-view/src/ManageViewsDialog.tsx', member: 'onRowClick', arity: 1, @@ -220,7 +257,12 @@ const OUT_SITES: Array<{ rel: string; member: string; arity: number; param: RegE { rel: 'packages/types/src/data-display.ts', member: 'onRowClick', arity: 1, param: /^row: any$/, why: '`data-table` invokes `schema.onRowClick(row)` with ONE argument; the declaration is accurate, and widening it would promise a payload that renderer never hands over' }, { rel: 'packages/types/src/objectql.ts', member: 'onRowClick', arity: 1, - param: /^row: any$/, why: 'ObjectDataTable forwards it into the same `data-table` channel above' }, + param: /^row: any$/, why: 'ObjectDataTable forwards it into the same `data-table` channel above', + // Anchored since objectui#7804: `ObjectGallerySchema.onRowClick` now stands + // EARLIER in this same file with the opposite contract, so a file-scoped + // read would answer about the IN site and score this control green for the + // wrong declaration. + after: 'export interface ObjectDataTableSchema' }, { rel: 'packages/plugin-view/src/ObjectView.tsx', member: 'onRowClick', arity: 1, param: /^record: Record$/, why: 'its own `handleRowClick` truncates to `onRowClick(record)`; that hop DROPS the payload, which is a separate defect from an understated declaration and is reported rather than fixed here' }, ]; @@ -246,6 +288,22 @@ describe('objectui#9357 — the arity counter, before it is pointed at the tree' } }); + it('the `after` anchor scopes the read, and a missing anchor reads NULL', () => { + // objectui#7804: `objectql.ts` gained a SECOND `onRowClick` with the opposite + // contract, above the one the CONTROLS block pins. Unanchored, the reader + // answers for whichever comes first — so these three legs are what keep the + // IN site and the OUT site in that one file from swapping places unnoticed. + const two = 'export interface A { onRowClick?: (record: Record, event?: any) => void; }\n' + + 'export interface B { onRowClick?: (row: any) => void; }'; + expect(declParams(two, 'onRowClick'), 'unanchored reads the FIRST declaration').toHaveLength(2); + expect(declParams(two, 'onRowClick', 'export interface B'), 'anchored reads B').toHaveLength(1); + expect(declParams(two, 'onRowClick', 'export interface B')![0]).toBe('row: any'); + expect( + declParams(two, 'onRowClick', 'export interface Nope'), + 'a missing anchor must read NULL, never fall back to the whole file', + ).toBeNull(); + }); + it('pins the naive counter\'s WRONG answer, so the two are never confused', () => { const oneParam = 'onRowClick?: (record: Record) => void;'; const naive = /\(([^)]*)\)/.exec(oneParam)![1].split(',').length; @@ -257,8 +315,8 @@ describe('objectui#9357 — the arity counter, before it is pointed at the tree' }); describe('objectui#9357 — consumers on the modifier-forwarding path declare the payload', () => { - it.each(IN_SITES)('$rel declares two parameters for $member ($why)', ({ rel, member }) => { - const params = declParams(readSource(rel), member); + it.each(IN_SITES)('$rel declares two parameters for $member ($why)', ({ rel, member, after }) => { + const params = declParams(readSource(rel), member, after); expect(params, `${rel} :: ${member} — no inline function-type declaration found`).not.toBeNull(); expect(params, `${rel} :: ${member}`).toHaveLength(2); // The second parameter is optional, so no existing caller is forced to pass it. @@ -277,8 +335,8 @@ describe('objectui#9357 — consumers on the modifier-forwarding path declare th }); describe('objectui#9357 — CONTROLS: sites that share the shape and are deliberately OUT', () => { - it.each(OUT_SITES)('$rel keeps $member at arity $arity ($why)', ({ rel, member, arity, param }) => { - const params = declParams(readSource(rel), member); + it.each(OUT_SITES)('$rel keeps $member at arity $arity ($why)', ({ rel, member, arity, param, after }) => { + const params = declParams(readSource(rel), member, after); expect(params, `${rel} :: ${member}`).toHaveLength(arity); // The LAST parameter is what says which contract this is. `VirtualGrid` // has arity 2 and is still out of scope because its second parameter is an @@ -289,8 +347,8 @@ describe('objectui#9357 — CONTROLS: sites that share the shape and are deliber it('the instrument is live on the control tree too (a silent zero would fake every control)', () => { // Same counter, same files, a member that IS declared there — so a control // reading "arity 1" cannot be the counter failing to find anything. - for (const { rel, member } of OUT_SITES) { - expect(declParams(readSource(rel), member), `${rel} :: ${member}`).not.toBeNull(); + for (const { rel, member, after } of OUT_SITES) { + expect(declParams(readSource(rel), member, after), `${rel} :: ${member}`).not.toBeNull(); } }); }); 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 index 0f3cd02812..8e55fa8b55 100644 --- a/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts +++ b/packages/types/src/__tests__/handler-keys-json-refusal-6124.test.ts @@ -56,7 +56,8 @@ * sibling `kanban` arm; ⛔ this running tally is PROSE and has never carried * objectui#7655's six or objectui#8802's removals, so it is not today's * figure — read `RUNTIME_SLOT`'s own docblock and the length pin beside it, - * which objectui#7804's `DataTableSchema` slice moved by seven). A key nothing reads + * which objectui#7804's `DataTableSchema` slice moved by seven and its + * `objectql.ts` slice by a further nine). A key nothing reads * gets the `?: never` tombstone (22 sites, `RETIRED` below; the `crud.ts` * `confirm` / `base.ts` convention). * @@ -89,9 +90,20 @@ import { retirementTombstone } from '../zod/tombstone.zod'; // `kanban` arm. import { ObjectDataTableSchema as ObjectDataTableZod, + ObjectFormSchema as ObjectFormZod, + ObjectGallerySchema as ObjectGalleryZod, + ObjectGridSchema as ObjectGridZod, ObjectKanbanSchema as ObjectKanbanZod, + ObjectViewSchema as ObjectViewZod, } from '../zod/objectql.zod'; -import type { ObjectDataTableSchema, ObjectKanbanSchema } from '../objectql'; +import type { + ObjectDataTableSchema, + ObjectFormSchema, + ObjectGallerySchema, + ObjectGridSchema, + ObjectKanbanSchema, + ObjectViewSchema, +} from '../objectql'; import { CalendarViewSchema as CalendarViewZod, CarouselSchema as CarouselZod, @@ -219,13 +231,15 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => }; /** - * 51 keys whose function value REACHES a renderer at runtime — the TypeScript + * 60 keys whose function value REACHES a renderer at runtime — the TypeScript * interface keeps the function type (36 at the objectui#6124 census; 38 since * `ObjectDataTableSchema.onRowClick`, objectui#6576, and `AlertDialogSchema.onAction`, * objectui#7104, joined; 44 since objectui#7655 gave `chatbot-enhanced` and * `chatbot-floating` their own faces, each carrying the three slots its * registration forwards; 51 since objectui#7804's `DataTableSchema` slice - * declared the seven keys that arm's renderer had been reading undeclared). + * declared the seven keys that arm's renderer had been reading undeclared; + * 60 since the same card's `objectql.ts` slice declared nine more across four + * arms in that one mirror file). * ⛔ Do not add a delta to this figure — the pin that cannot rot is * `expect(RUNTIME_SLOT).toHaveLength(…)` below; count the constant. * Channel measured per key on this tree: @@ -346,6 +360,51 @@ const RUNTIME_SLOT: readonly Site[] = [ // while the renderer still read the key. ['objectql.zod.ts', 'ObjectKanbanSchema', 'onCardClick', ObjectKanbanZod], ['objectql.zod.ts', 'ObjectKanbanSchema', 'onQuickAdd', ObjectKanbanZod], + // ⭐ objectui#7804 — the `objectql.ts` slice: nine keys across FOUR plain + // `export interface X extends BaseSchema` faces in one file, every one read + // off the authored document by a registered renderer while its arm declared + // nothing, so `BaseSchema.passthrough()` ACCEPTED and KEPT an authored + // `{ "action": "toast" }` and handed it to a call site that CALLS it. + // + // Each channel measured on its own, and they do NOT all share one — which is + // why the group is not evidence for any member of it: + // - the five `ObjectFormSchema` keys are forwarded off `schema.*` by + // `ObjectForm` onto the variant node it renders; + // `ObjectFormComponentProps` declares only `schema` / `dataSource` / + // `className`, so the `object-form` NODE a host builds in TypeScript is + // the channel. `onSuccess` / `onCancel` have ten in-repo suppliers + // between them, `onOpenChange` three, `onError` one; + // - ⚠️ `onStepChange` has NONE. `ObjectForm` forwards it onto the wizard + // node and `WizardForm` calls `schema.onStepChange(step)`, so the channel + // is wired end to end and only the supplier is missing; + // - ⚠️ the two `ObjectGallerySchema` keys are this ledger's PROPS-half + // shape: `SchemaRenderer` spreads an authored node's leftover keys into + // the props bag, and `ObjectGallery` reads BOTH on one line + // (`props.onRowClick ?? props.onCardClick`). `onRowClick` is supplied by + // `ListView`'s `baseProps` and `RelatedList`'s mobile branch; + // `onCardClick` is the `??` fallback spelling with no in-repo supplier; + // - ⚠️ `ObjectGridSchema.onNavigate` has no in-repo supplier either. Its + // read is deliberate (maintainer ruling 2026-08-19 on objectui#5234, + // option C: declared for programmatic callers, off the authoring + // surface), and `gridNonAuthorKeys.test.tsx` supplies it from a schema + // and asserts the call still fires; + // - `ObjectViewSchema.onNavigate` is read at four sites in `plugin-view`'s + // `ObjectView` and supplied by `@object-ui/app-shell`'s `ObjectView`. + // ⚠️ Same key NAME as the grid's, different signature, different + // supplier, judged separately. + // + // ⛔ `'retired'` was refused for all nine: it publishes "no renderer reads + // this key" against reads the renderers demonstrably make. A missing SUPPLIER + // is not a missing read. + ['objectql.zod.ts', 'ObjectFormSchema', 'onCancel', ObjectFormZod], + ['objectql.zod.ts', 'ObjectFormSchema', 'onError', ObjectFormZod], + ['objectql.zod.ts', 'ObjectFormSchema', 'onOpenChange', ObjectFormZod], + ['objectql.zod.ts', 'ObjectFormSchema', 'onStepChange', ObjectFormZod], + ['objectql.zod.ts', 'ObjectFormSchema', 'onSuccess', ObjectFormZod], + ['objectql.zod.ts', 'ObjectGallerySchema', 'onCardClick', ObjectGalleryZod], + ['objectql.zod.ts', 'ObjectGallerySchema', 'onRowClick', ObjectGalleryZod], + ['objectql.zod.ts', 'ObjectGridSchema', 'onNavigate', ObjectGridZod], + ['objectql.zod.ts', 'ObjectViewSchema', 'onNavigate', ObjectViewZod], ['overlay.zod.ts', 'DialogSchema', 'onOpenChange', DialogZod], ['overlay.zod.ts', 'AlertDialogSchema', 'onOpenChange', AlertDialogZod], // objectui#7104 — the action button's `onClick`; the renderer read `schema.onAction` UNDECLARED until then. @@ -482,7 +541,7 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje ]); }); - it('72 sites are ledgered, 51 runtime slots + 21 retired, with no key filed twice', () => { + it('81 sites are ledgered, 60 runtime slots + 21 retired, with no key filed twice', () => { // 58 from objectui#6124; the 59th is `ObjectDataTableSchema.onRowClick`, // minted with its arm by objectui#6576 / #6914; the 60th is // `AlertDialogSchema.onAction`, declared by objectui#7104 for a key the @@ -512,16 +571,25 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje // measurement always asked for. A ledger GROWTH on the retired half, and // the disposition is the one objectui#7804 measured — not a new reading. // + // ⭐ 72 → 81: objectui#7804's `objectql.ts` slice — nine keys across FOUR + // arms in one mirror file, all nine on the RUNTIME SLOT half. Larger than + // the `DataTableSchema` slice below, and unlike it spread over four faces, + // so the per-key measurement had to be taken four times over: three of the + // nine (`ObjectFormSchema.onStepChange`, `ObjectGallerySchema.onCardClick`, + // `ObjectGridSchema.onNavigate`) have NO in-repo supplier, and the site + // comment beside the rows records why that is a missing supplier and not a + // missing read. + // // ⭐ 65 → 72: objectui#7804's `DataTableSchema` slice, the largest single // growth this ledger has taken — seven keys the registered `data-table` // renderer read off the authored document with the arm declaring none of // them. All seven land on the RUNTIME SLOT half, each measured at its own // channel; `onColumnResize` is the one that does not share the group's, and // the site comment beside the rows records why that mattered. - expect(RUNTIME_SLOT).toHaveLength(51); + expect(RUNTIME_SLOT).toHaveLength(60); expect(RETIRED).toHaveLength(21); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(72); + expect(new Set(ids).size).toBe(81); }); 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) => { @@ -690,6 +758,21 @@ export type assertionRuntimeSlotsKeepTheirFunctionType = [ Expect>, Expect>, Expect>, + // objectui#7804 — the `objectql.ts` slice. Each TS twin stays callable + // because the function value REACHES the renderer; the mirror refuses by + // name. The two `ObjectGallerySchema` keys are DECLARED here by this slice: + // they reached the renderer through `SchemaRenderer`'s props spread while + // `BaseSchema`'s index signature admitted them untyped, so declaring them is + // a narrowing on this face as well as on the mirror. + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, Expect>, Expect>, Expect>, 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 893689b3fd..070e823ec3 100644 --- a/packages/types/src/__tests__/object-view-spec-parity.test.ts +++ b/packages/types/src/__tests__/object-view-spec-parity.test.ts @@ -252,9 +252,16 @@ const TS_ONLY_BACKLOG = new Set([ // measurement (`zod-mirror-parity.test.ts`, `UnmirroredDeclared`) until the // maintainer decides. ⛔ Not closed with `z.any()`. 'listViews', - // Not a zod gap: a function, so it CANNOT be declared in a JSON protocol - // schema. Recorded here rather than exempted silently. - 'onNavigate', + // ⭐ `onNavigate` LEFT this backlog with objectui#7804's `objectql.ts` slice, + // and the sentence it used to carry here was the thing that needed correcting + // — not the entry. It read "a function, so it CANNOT be declared in a JSON + // protocol schema", and the second half does not follow from the first: a + // function value cannot be AUTHORED, but the key can absolutely be DECLARED — + // as a named refusal (`handlerKeyRefusal`, the objectui#6124 shape), which is + // what the mirror now carries. Leaving it undeclared did not keep the key out + // of an authored document; `BaseSchema` is `.passthrough()`, so an authored + // `onNavigate` was ACCEPTED, KEPT, and handed to the four call sites in + // `plugin-view`'s `ObjectView` that invoke it. ]); /** @@ -406,8 +413,14 @@ describe('ObjectViewSchema declared-surface consistency (#2890 scope B)', () => // `viewTabBar` tombstone, which is a shape member so it can refuse by name): // 11 + 9 = 20 non-envelope keys, 20 + 2 = 22 declared. The TS figure below // did not move — every one of the nine was already declared there. - expect(ouiZodKeys.filter((k) => !ENVELOPE.has(k))).toHaveLength(20); - expect(ouiDeclaredKeys).toHaveLength(22); + // + // objectui#7804's `objectql.ts` slice added the TWENTY-FIRST: `onNavigate`, + // a shape member that refuses by name, the same way `viewTabBar` is one. So + // 21 non-envelope keys, 21 + 2 = 23 declared — and the TS figure below again + // does not move, because the key was already declared there. That is the + // whole shape of this slice: it closes gaps on the ZOD side only. + expect(ouiZodKeys.filter((k) => !ENVELOPE.has(k))).toHaveLength(21); + expect(ouiDeclaredKeys).toHaveLength(23); // 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 569983e261..76843ae0fa 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -129,7 +129,22 @@ * 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. - * - **41 entries** in `KnownDrift`, **72 keys** across them — 41 / 65 until + * - **45 entries** in `KnownDrift`, **83 keys** across them — 41 / 72 until + * objectui#7804's `objectql.ts` slice DECLARED nine handler keys across FOUR + * arms in that one mirror file, three of them NEW entries here + * (`ObjectFormSchema`, `ObjectGridSchema`, `ObjectViewSchema`) plus a fourth + * (`ObjectGallerySchema`) — the entry count moved by four because every one + * of the four was absent from this ledger. ⚠️ The key total moved by + * ELEVEN, not nine, and the two extra are NOT keys of the slice: declaring + * the form and grid arms propagated into `ObjectViewSchema`'s nested `form` + * and `table` slots, which are those sibling mirrors BY REFERENCE while the + * declaration types them off `ObjectFormSlotKey` / `ObjectGridSlotKey` — + * two unions that list exactly these handler keys. ⚠️ And only SEVEN of the + * nine came out of `RuntimeOnlyDeclared` below; the two + * `ObjectGallerySchema` keys were in NEITHER ledger, because they were + * declared on NEITHER face. ⛔ Read those three sentences together, or the + * arithmetic (+4 entries, +11 keys, −3 entries and −7 keys next door) reads + * as growth from nowhere. It was 41 / 65 until * objectui#7804's `DataTableSchema` slice DECLARED seven handler keys on * `data-display.zod.ts#DataTableSchema`, an existing entry (so the entry * count did not move). ⭐ The largest single move this ledger has taken, and @@ -269,7 +284,17 @@ * seeded long after the 121). It is ⛔ not replaced with a fresh digit, for the * reason above. The full statement is on that ledger, which owns it — read it * there, and ⛔ do not copy it back. - * - **7 entries** in `RuntimeOnlyDeclared`, **17 keys** across them — 7 / 24 + * - **4 entries** in `RuntimeOnlyDeclared`, **10 keys** across them — 7 / 17 + * until objectui#7804's `objectql.ts` slice took three WHOLE entries out — + * `objectql.zod.ts#ObjectFormSchema` (five keys), + * `objectql.zod.ts#ObjectGridSchema` and `objectql.zod.ts#ObjectViewSchema` + * (one each) — the mirror having declared every one as a named refusal. + * ⭐ The first time this ledger lost ENTRIES rather than keys: each pair's + * whole runtime-only debt went at once, so there was nothing left to + * shorten. ⚠️ Read it beside the `KnownDrift` bullet above, which gained + * eleven keys where this lost seven — the difference is the two + * `ObjectGallerySchema` keys that were in neither ledger and the two nested + * `ObjectViewSchema` slots the declaration propagated into. It was 7 / 24 * until objectui#7804's `DataTableSchema` slice took seven keys out of * `data-display.zod.ts#DataTableSchema` and into `KnownDrift` above, the * mirror having declared each as a named refusal. The entry SURVIVES with @@ -278,7 +303,7 @@ * two. ⭐ The direction is the one this ledger is meant to drain in: a * runtime-only key leaves by being declared on the mirror, never by being * quietly refiled. - * **6 of the 7** are a subset of the **14** pairs above; `TreeViewSchema` is + * **3 of the 4** are a subset of the **14** pairs above; `TreeViewSchema` is * NOT — it is the first pair whose ONLY ledger entry is a runtime-only one * (objectui#6150 declared `onNodeClick` on an otherwise clean pair), which is why * the union of the two unmirrored ledgers is **15** pairs and not **14**. @@ -409,7 +434,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 41 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 45 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 @@ -1702,6 +1727,88 @@ interface KnownDrift { * pre-#6124 state of that file, not a rule for new mirrors. */ 'objectql.zod.ts#ObjectDataTableSchema': 'onRowClick'; + /** + * RUNTIME SLOT (objectui#6124 shape, declared by objectui#7804) ×5 — the + * `objectql.ts` slice, which drained this pair's whole `RuntimeOnlyDeclared` + * entry rather than shortening it. `plugin-form`'s `ObjectForm` reads every + * one off `schema.*` and forwards it onto the variant node it renders, and + * `ObjectFormComponentProps` declares only `schema` / `dataSource` / + * `className` — so the `object-form` NODE a host builds in TypeScript is the + * channel, and the TS side keeps all five callable while the mirror refuses + * them by name. + * + * ⚠️ The disposition was measured per key, and one of the five does NOT share + * the group's supplier: `onStepChange` has NO in-repo host filling it, while + * `onSuccess` / `onCancel` have ten between them, `onOpenChange` three and + * `onError` one. Its channel is wired end to end all the same — forwarded + * onto the wizard node, then CALLED as `schema.onStepChange(step)` — so what + * is missing is a supplier, not a read, and `'retired'` would have published + * "no renderer reads this key" against a read that runs. + */ + 'objectql.zod.ts#ObjectFormSchema': + | 'onCancel' | 'onError' | 'onOpenChange' | 'onStepChange' | 'onSuccess'; + /** + * RUNTIME SLOT (objectui#6124 shape, declared by objectui#7804) ×2 — the + * `objectql.ts` slice again, and the only pair in it whose keys were declared + * on NEITHER face before. They are this ledger's PROPS-half shape: + * `SchemaRenderer` spreads an authored node's leftover keys into the props + * bag, and `ObjectGallery` reads both on ONE line + * (`props.onRowClick ?? props.onCardClick`) before handing the winner to + * `useNavigationOverlay` as the function it calls. So an authored value + * reached a renderer through a face that declared nothing — with + * `BaseSchema`'s index signature admitting it untyped — which is why + * declaring them is a NARROWING on both faces at once. + * + * ⚠️ Measured per key: `onRowClick` has two in-repo suppliers (`ListView`'s + * `baseProps`, `RelatedList`'s mobile branch), `onCardClick` has none and is + * the `??` fallback spelling `ObjectGalleryProps` declares for a host that + * uses that name. + */ + 'objectql.zod.ts#ObjectGallerySchema': 'onCardClick' | 'onRowClick'; + /** + * RUNTIME SLOT (objectui#6124 shape, declared by objectui#7804) — the key the + * maintainer's 2026-08-19 ruling on objectui#5234 (option C) kept DECLARED + * for programmatic callers and kept OFF the authoring surface. "Not offered" + * was true of the manifest and the designer panel; the validator still said + * yes, because `BaseSchema` is `.passthrough()`. The named refusal closes + * that half, and it makes this mirror agree with `@objectstack/spec`, whose + * `ComponentPropsMap['object-grid']` `strictObject` already refused the key. + * + * ⚠️ No in-repo host builds an `object-grid` node carrying it — measured. The + * read is live and deliberate (`onNavigate: schema.onNavigate` into + * `useNavigationOverlay`), and `plugin-grid`'s `gridNonAuthorKeys.test.tsx` + * supplies it from a schema and asserts the call fires. + */ + 'objectql.zod.ts#ObjectGridSchema': 'onNavigate'; + /** + * RUNTIME SLOT (objectui#6124 shape, declared by objectui#7804) — the same + * key NAME as the entry above and nothing else in common: a different second + * parameter (`mode: 'view' | 'edit'` rather than `action?: string`), a + * different renderer, a different supplier. Judged separately for exactly + * that reason. + * + * `plugin-view`'s `ObjectView` invokes it at four sites — + * `schema.onNavigate('new', 'edit')` on create, and the record id with + * `'edit'` / `'view'` on the other three. Supplied by `@object-ui/app-shell`'s + * `ObjectView`, which builds the `object-view` node in TypeScript and puts + * `onNavigate: (recordId, mode) => …` on it. + * + * ⭐ `form` and `table` are the SAME slice reaching one level down, and they + * are here by measurement rather than by intent: this pair's two nested + * config slots are the sibling mirrors BY REFERENCE + * (`ObjectFormSchema.omit({ type, objectName, mode }).partial()` and + * `ObjectGridSchema.omit({ type, objectName }).partial()`), while the + * declaration types them `Partial>` / + * `Partial>` — and those two slot-key unions list + * exactly the handler keys this slice declared (`onCancel`, `onError`, + * `onOpenChange`, `onStepChange`, `onSuccess` on the form union; + * `onNavigate` on the grid union). So the named refusals propagate into the + * nested config and an authored `form: { onSuccess: … }` is refused there + * too. ⛔ NOT worked around by omitting the keys from the nested lazy: that + * would keep accepting an un-authorable function value one level down, which + * is the defect, not the fix. + */ + 'objectql.zod.ts#ObjectViewSchema': 'onNavigate' | 'form' | 'table'; /** * RUNTIME SLOT (objectui#6124 shape, declared by objectui#7804) ×2 — the * SECOND handler entry on this mirror, and the first anywhere in this ledger @@ -2353,23 +2460,28 @@ interface RuntimeOnlyDeclared { */ 'form.zod.ts#FormSchema': 'onDirtyChange'; /** - * 5 of `ObjectFormSchema`'s former 26. POLICY group — `objectql.zod.ts` mirrors no - * callback at all. All five are read in `plugin-form/src/ObjectForm.tsx`. - */ - 'objectql.zod.ts#ObjectFormSchema': - | 'onCancel' | 'onError' | 'onOpenChange' | 'onStepChange' | 'onSuccess'; - /** 1 of `ObjectGridSchema`'s former 17. POLICY group. Read at `ObjectGrid.tsx:1334`. */ - 'objectql.zod.ts#ObjectGridSchema': 'onNavigate'; - /** - * 1 of `ObjectViewSchema`'s former 11 — the one key that sits in both stories. Of - * its other ten keys, nine closed with objectui#7779 (eight mirrored, `viewTabBar` - * retired) and `listViews` stays in `UnmirroredDeclared`; reclassifying its callback - * did not re-route the pair, and neither did objectui#7279's move of that entry from - * the split's SPEC-DERIVED half to its LOCAL one (the pair had never been - * spec-derived until #7779 gave the mirror real spec references — see the entry - * above). POLICY group. + * ⭐ THREE `objectql.zod.ts` entries LEFT this ledger with objectui#7804's + * `objectql.ts` slice, and they left the way this ledger is meant to drain — + * by the mirror DECLARING each key as a named refusal, never by refiling: + * + * - `ObjectFormSchema` — all five (`onCancel`, `onError`, `onOpenChange`, + * `onStepChange`, `onSuccess`), so the entry is gone, not shortened; + * - `ObjectGridSchema` — `onNavigate`, the key the 2026-08-19 ruling on + * objectui#5234 kept declared for programmatic callers and off the + * authoring surface; the mirror now says on this face what + * `@objectstack/spec`'s `strictObject` already said on the other; + * - `ObjectViewSchema` — `onNavigate`, a DIFFERENT key of the same name: + * different signature, different supplier, judged separately. + * + * ⚠️ The pairs are gone from this ledger and present in `KnownDrift` above, + * which is ONE move seen from both sides. And `KnownDrift` gained NINE keys + * where this one lost SEVEN: the other two are `ObjectGallerySchema`'s + * `onCardClick` / `onRowClick`, which were in NEITHER ledger because they + * were declared on NEITHER face — they reached the renderer through + * `SchemaRenderer`'s props spread while `BaseSchema`'s index signature + * admitted them untyped. ⛔ Read the two bullets together or the arithmetic + * looks like growth from nowhere. */ - 'objectql.zod.ts#ObjectViewSchema': 'onNavigate'; /** * `TreeViewSchema`'s ONLY entry in either ledger — the pair was clean before * objectui#6150 and this key is the whole of its debt. diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 9c9a69491c..07bdc65918 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -1063,6 +1063,20 @@ export interface ObjectGridSchema extends BaseSchema { * cycle, for zero measured harm. The exemption comment at the read site * (`plugin-grid/src/ObjectGrid.tsx`) carries the same statement, and both are * pinned by `plugin-grid/src/__tests__/gridNonAuthorKeys.test.tsx`. + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — the zod twin now + * refuses this key BY NAME instead of letting `BaseSchema.passthrough()` + * accept and KEEP an authored value that reaches `useNavigationOverlay` and + * is CALLED. That closes the gap the ruling above left open on this face: + * "not offered" was true of the manifest and the designer panel, but the + * validator still said yes. The two faces now agree, and they agree with + * `@objectstack/spec`, whose `strictObject` already refused the key. + * + * ⚠️ No host in this repository builds an `object-grid` node carrying it — + * measured, and reported rather than smoothed over. The slot is nonetheless + * real: the renderer reads and runs it, and the pin named above supplies it + * from a schema and asserts the call. What is absent is a supplier, not the + * channel, so `'retired'` ("no renderer reads this key") would be false. */ onNavigate?: (recordId: string | number, action?: string) => void; @@ -1457,6 +1471,20 @@ export interface ObjectFormSchema extends BaseSchema { /** * Called when wizard step changes. Only used when formType is 'wizard'. + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — a host-supplied + * function, NOT authorable metadata: JSON has no function value, so the zod + * twin refuses this key by name and points at the node-type spelling. Kept + * callable here because it is READ and RUN by the registered renderer. + * The read: `ObjectForm` forwards it onto the wizard node it builds + * (`onStepChange: schema.onStepChange`) and `WizardForm` calls + * `schema.onStepChange(step)`. + * + * ⚠️ Its supplier is NOT the one its four siblings have, and the difference + * was measured rather than inferred: NO host in this repository fills this + * key. The channel is nonetheless wired end to end — forwarded, then called — + * so a host that fills it is run, and `'retired'` ("no renderer reads this + * key") would be false. What is absent is a supplier, not the channel. */ onStepChange?: (step: number) => void; @@ -1558,6 +1586,19 @@ export interface ObjectFormSchema extends BaseSchema { /** * Callback on successful submission + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — a host-supplied + * function, NOT authorable metadata: JSON has no function value, so the zod + * twin refuses this key by name and points at the node-type spelling. Kept + * callable here because it is READ and RUN by the registered renderer. + * The read: `ObjectForm` forwards it onto every variant node it builds + * (`onSuccess: schema.onSuccess`). Supplied by hosts that build the + * `object-form` node in TypeScript — `AppContent`, `RecordFormPage`, + * `ScreenView`, `FlowRunner` and `useActionModal` in `@object-ui/app-shell`, + * `ObjectManager` / `FieldDesigner` in `@object-ui/plugin-designer`, + * `MasterDetailForm` / `EmbeddableForm` in `@object-ui/plugin-form`, and + * `plugin-view`'s `ObjectView`. `ObjectFormComponentProps` declares only + * `schema`, `dataSource` and `className`, so the NODE is the channel. */ onSuccess?: (data: any) => void | Promise; @@ -1593,11 +1634,27 @@ export interface ObjectFormSchema extends BaseSchema { /** * Callback on error + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — a host-supplied + * function, NOT authorable metadata: JSON has no function value, so the zod + * twin refuses this key by name and points at the node-type spelling. Kept + * callable here because it is READ and RUN by the registered renderer. + * The read: `ObjectForm` forwards it onto the master-detail node + * (`onError: schema.onError`). Supplied by `MasterDetailForm` + * (`@object-ui/plugin-form`), beside its {@link onSuccess}. */ onError?: (error: Error) => void; - + /** * Callback on cancel + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — a host-supplied + * function, NOT authorable metadata: JSON has no function value, so the zod + * twin refuses this key by name and points at the node-type spelling. Kept + * callable here because it is READ and RUN by the registered renderer. + * The read: `ObjectForm` forwards it onto every variant node it builds + * (`onCancel: schema.onCancel`). Supplied by the same hosts as + * {@link onSuccess}, one line below their `onSuccess`. */ onCancel?: () => void; @@ -1643,6 +1700,16 @@ export interface ObjectFormSchema extends BaseSchema { /** * Callback when open state changes. Only used when formType is 'drawer'. + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — a host-supplied + * function, NOT authorable metadata: JSON has no function value, so the zod + * twin refuses this key by name and points at the node-type spelling. Kept + * callable here because it is READ and RUN by the registered renderer. + * The read: `ObjectForm` forwards it onto the drawer / modal node + * (`onOpenChange: schema.onOpenChange`). Supplied by `AppContent` + * (`if (!open) closeRecordForm()`) in `@object-ui/app-shell` and by + * `ObjectManager` / `FieldDesigner` (`handleFormClose`) in + * `@object-ui/plugin-designer`. */ onOpenChange?: (open: boolean) => void; @@ -2007,6 +2074,19 @@ export interface ObjectViewSchema extends BaseSchema { /** * Callback when navigating to detail page (page layout mode) + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — a host-supplied + * function, NOT authorable metadata: JSON has no function value, so the zod + * twin refuses this key by name and points at the node-type spelling. Kept + * callable here because it is READ and RUN by the registered renderer. + * The read: `plugin-view`'s `ObjectView` invokes it at four sites — + * `schema.onNavigate('new', 'edit')` on create, and the record id with + * `'edit'` / `'view'` on the other three. Supplied by + * `@object-ui/app-shell`'s `ObjectView`, which builds the `object-view` node + * in TypeScript and puts `onNavigate: (recordId, mode) => …` on it. + * + * ⚠️ Shares a key NAME with `ObjectGridSchema.onNavigate` and nothing else: + * different second parameter, different supplier, judged separately. */ onNavigate?: (recordId: string | number, mode: 'view' | 'edit') => void; @@ -4073,6 +4153,50 @@ export interface ObjectGallerySchema extends BaseSchema { imageField?: string; /** @deprecated Use `gallery.titleField` instead */ titleField?: string; + /** + * Card click handler. + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — a host-supplied + * function, NOT authorable metadata: JSON has no function value, so the zod + * twin refuses this key by name and points at the node-type spelling. Kept + * callable here because it is READ and RUN by the registered renderer. + * + * ⭐ Its channel is the PROPS half of the slot, not the `schema.*` half: this + * key was declared on `ObjectGalleryProps` (`@object-ui/plugin-list`) and + * nowhere on this node, while `SchemaRenderer` spreads an authored node's + * leftover keys into the very props bag `ObjectGallery` reads + * (`props.onRowClick ?? props.onCardClick`, handed to `useNavigationOverlay` + * as the function it calls on a card click). So an authored value DID reach + * the renderer through a face that never declared it. Declaring it here + * NARROWS: the key was already admitted, untyped, by `BaseSchema`'s index + * signature. + * + * ⚠️ No host in this repository supplies this spelling — it is the second arm + * of that `??`, the name a host may use instead of {@link onRowClick}. + * Measured and reported rather than smoothed: what is absent is a supplier, + * not the channel. + */ + onCardClick?: (record: Record, event?: any) => void; + /** + * Row/item click handler — overrides {@link navigation}. + * + * RUNTIME SLOT (objectui#6124, declared by objectui#7804) — a host-supplied + * function, NOT authorable metadata: JSON has no function value, so the zod + * twin refuses this key by name and points at the node-type spelling. Kept + * callable here because it is READ and RUN by the registered renderer, on the + * same `props.onRowClick ?? props.onCardClick` line as {@link onCardClick}. + * + * Supplied by two in-repo hosts, each putting it on the `object-gallery` node + * it builds: `ListView` (`onRowClick: navigation.handleClick`, from the + * `baseProps` every child view receives) and `RelatedList`'s mobile branch + * (its own React prop of this name). + * + * TWO parameters, matching `ObjectGalleryProps.onRowClick`: the second is the + * modifier payload a host needs for Cmd/Ctrl/middle-click, and it is spelled + * `any` for the reason objectui#9341 measured — `HandleClickModifiers` lives + * in `@object-ui/react`, which the published twins here may not name. + */ + onRowClick?: (record: Record, event?: any) => void; /** * REFUSED BY NAME (objectui#9256, ADR-0049) — `object-gallery` reads NEITHER * content channel: no renderer read consumes `body` or `children` for this diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 8521112d29..da00feab43 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -286,6 +286,36 @@ export const ObjectGridSchema = BaseSchema.extend({ editable: z.boolean().optional(), keyboardNavigation: z.boolean().optional(), frozenColumns: z.number().optional(), + // ⭐ objectui#7804 — one key the REGISTERED `object-grid` renderer reads off + // the authored document while this arm declared none. `BaseSchema` is + // `.passthrough()`, so an undeclared key is NOT refused: it stops being + // judged and the value is KEPT. `{ "type": "object-grid", "objectName": "a", + // "onNavigate": { "action": "toast" } }` therefore parsed GREEN and that + // action object was handed to `useNavigationOverlay`, which CALLS it as + // `onNavigate(recordId, view)`. + // + // ⛔ Disposition MEASURED, not patterned. `'retired'` publishes "no renderer + // reads this key" — FALSE here: `ObjectGrid` reads it (`onNavigate: + // schema.onNavigate` into its `useNavigationOverlay` call) and + // `gridNonAuthorKeys.test.tsx` pins the read firing on a row click from a + // SCHEMA-supplied function. So `'runtime-slot'`, and the channel is the one + // the maintainer's 2026-08-19 ruling on objectui#5234 (option C) preserved on + // purpose: the key stays DECLARED on `ObjectGridSchema` for programmatic + // callers and stays OFF the authoring surface (`GRID_QUERY_INPUTS`). + // + // ⚠️ Measured and reported rather than smoothed: NO in-repo host builds an + // `object-grid` node carrying this key today — the nine siblings the read + // site's own comment names (`onRowClick`, `onRowSelect`, …) travel + // `ObjectGridComponentProps` instead. The slot is nonetheless real and + // exercised: the TypeScript face declares it, the renderer reads and runs it, + // and the pin supplies it. What is absent is an in-repo SUPPLIER, not the + // channel. + // + // ⭐ This arm and `@objectstack/spec` now say the same thing in two places: + // `ComponentPropsMap['object-grid']` is a `strictObject` that already + // 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'), }); /** @@ -326,6 +356,48 @@ export const ObjectFormSchema = BaseSchema.extend({ showReset: z.boolean().optional().describe('Show reset button'), initialValues: z.record(z.string(), z.any()).optional().describe('Initial values'), readOnly: z.boolean().optional().describe('Read-only mode'), + // ⭐ objectui#7804 — five keys the REGISTERED `object-form` renderer reads off + // the authored document while this arm declared none of them. `BaseSchema` is + // `.passthrough()`, so an undeclared key is NOT refused: it stops being + // judged and the value is KEPT. `{ "type": "object-form", "objectName": "a", + // "mode": "create", "onSuccess": { "action": "toast" } }` therefore parsed + // GREEN and that action object was forwarded onto the child node whose + // renderer CALLS it. + // + // ⛔ The disposition is MEASURED PER KEY, never applied as a pattern. + // `'retired'` publishes "no renderer reads this key, so nothing could ever + // run it" — FALSE for all five: `plugin-form`'s `ObjectForm` reads every one + // off `schema.*` and forwards it into the variant it renders. So each is a + // `'runtime-slot'`, and each was carried by finding the path a host actually + // supplies it through — the `object-form` NODE a host builds in TypeScript, + // `ObjectFormComponentProps` declaring only `schema` / `dataSource` / + // `className`: + // + // - `onSuccess` forwarded as `onSuccess: schema.onSuccess`; supplied by + // `AppContent`, `RecordFormPage`, `ScreenView`, + // `FlowRunner` and `useActionModal` (`@object-ui/app-shell`), + // `ObjectManager` / `FieldDesigner` (`@object-ui/plugin-designer`), + // `MasterDetailForm` and `EmbeddableForm` (`@object-ui/plugin-form`), + // and `plugin-view`'s `ObjectView`. + // - `onCancel` the same builders, one line below their `onSuccess`. + // - `onOpenChange` the MODAL/DRAWER arm's open-state slot; supplied by + // `AppContent` (`if (!open) closeRecordForm()`) and by + // `ObjectManager` / `FieldDesigner` (`handleFormClose`). + // - `onError` supplied by `MasterDetailForm`, beside its `onSuccess`. + // - `onStepChange` ⚠️ a DIFFERENT reading from its four siblings, and the + // reason four of them are not evidence for the fifth: NO + // in-repo host supplies this key. `ObjectForm` forwards it + // onto the wizard node (`onStepChange: schema.onStepChange`) + // and `WizardForm` CALLS it (`schema.onStepChange(step)`), + // so the channel is wired end to end and a host that fills + // it is run — what is absent is an in-repo supplier, not + // the channel. `'retired'` would still be false: the read + // and the call are both there. + onCancel: handlerKeyRefusal('onCancel', 'runtime-slot', 'Cancel handler'), + onError: handlerKeyRefusal('onError', 'runtime-slot', 'Submit error handler'), + 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'), }); /** @@ -426,6 +498,25 @@ export const ObjectViewSchema = BaseSchema.extend({ // verbatim into the `view-switcher` node it composes. allowCreateView: ViewSwitcherSchema.shape.allowCreateView, viewActions: ViewSwitcherSchema.shape.viewActions, + // ⭐ objectui#7804 — one key the REGISTERED `object-view` renderer reads off + // the authored document while this arm declared none. `BaseSchema` is + // `.passthrough()`, so an undeclared key is NOT refused: it stops being + // judged and the value is KEPT, then reaches four call sites in + // `plugin-view`'s `ObjectView` that INVOKE it — + // `schema.onNavigate('new', 'edit')` on create, and the record id with + // `'edit'` / `'view'` on the other three. + // + // ⛔ Disposition MEASURED. `'retired'` publishes "no renderer reads this key" + // — FALSE against four live reads. `'runtime-slot'`, and the supplier is + // named: `@object-ui/app-shell`'s `ObjectView` builds the `object-view` node + // in TypeScript and puts `onNavigate: (recordId, mode) => …` on it, the same + // two-parameter shape the declaration carries. The value travels the node, + // which is the TypeScript face, never `safeParse`. + // + // ⚠️ Same key NAME as `ObjectGridSchema.onNavigate` above, a DIFFERENT + // 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'), }); /** @@ -1801,6 +1892,37 @@ export const ObjectGallerySchema = BaseSchema.extend({ grouping: stripImportedDefaults(SpecGroupingConfigSchema).optional().describe('Grouping configuration for sectioned display'), imageField: z.string().optional().describe('DEPRECATED — use gallery.coverField'), titleField: z.string().optional().describe('DEPRECATED — use gallery.titleField'), + // ⭐ objectui#7804 — two keys the REGISTERED `object-gallery` renderer reads + // off the authored document while this arm declared neither. `BaseSchema` is + // `.passthrough()`, so an undeclared key is NOT refused: it stops being + // judged and the value is KEPT, and `SchemaRenderer` then spreads it into the + // component's props bag (`createElement` spreading `...componentProps`), where + // `ObjectGallery` reads `props.onRowClick ?? props.onCardClick` and hands the + // winner to `useNavigationOverlay` as the function it CALLS on a card click. + // `{ "type": "object-gallery", "onCardClick": { "action": "toast" } }` + // therefore parsed GREEN and put that action object where a function is run. + // + // ⚠️ The channel is the PROPS half of the runtime slot, not the `schema.*` + // half, which is why the read census finds both at one line. It is still the + // same defect: the key travels the authored NODE to get there. + // + // ⛔ Disposition MEASURED PER KEY. `'retired'` publishes "no renderer reads + // this key" — FALSE for both. + // + // - `onRowClick` two in-repo suppliers, each putting it on the + // `object-gallery` node it builds: `ListView` + // (`onRowClick: navigation.handleClick`, from the + // `baseProps` every child view receives) and + // `RelatedList`'s mobile branch (its own React prop). + // - `onCardClick` ⚠️ a DIFFERENT reading from its sibling: NO in-repo host + // supplies it. It is the second arm of the `??` above, the + // spelling a host may use instead, declared on + // `ObjectGalleryProps` with its own doc. The slot is real — + // declared on the props interface, read at two sites, run + // through the hook — but what is absent is an in-repo + // SUPPLIER, not the channel. + onCardClick: handlerKeyRefusal('onCardClick', 'runtime-slot', 'Card click handler'), + onRowClick: handlerKeyRefusal('onRowClick', 'runtime-slot', 'Row/item click handler'), body: retirementTombstone( 'REFUSED (objectui#9256, ADR-0049) — `object-gallery` reads NEITHER content channel: measured with the ' + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index c763c23057..5b30a0808c 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -156,16 +156,30 @@ export const KNOWN_UNDECLARED_READS = new Map([ // outlived its read reddens `staleExemptions()` below — which is exactly the // intermediate reading that proved the arm edit had reached these keys. ['tree-view::TreeViewSchema.onNodeClick', 'objectui#7804'], - ['object-form::ObjectFormSchema.onCancel', 'objectui#7804'], - ['object-form::ObjectFormSchema.onError', 'objectui#7804'], - ['object-form::ObjectFormSchema.onOpenChange', 'objectui#7804'], - ['object-form::ObjectFormSchema.onStepChange', 'objectui#7804'], - ['object-form::ObjectFormSchema.onSuccess', 'objectui#7804'], + // ⭐ ALL FIVE `object-form::ObjectFormSchema` rows LANDED and are gone — + // objectui#7804's `objectql.ts` slice, which drained nine rows across four + // plain `export interface X extends BaseSchema` faces in one file. + // `onCancel`, `onError`, `onOpenChange`, `onStepChange` and `onSuccess` are + // objectui#6124 RUNTIME SLOTS on the arm, each measured at its OWN channel + // rather than assumed from its siblings: four are supplied by hosts that + // build the `object-form` NODE in TypeScript (`AppContent`, `RecordFormPage`, + // `ScreenView`, `FlowRunner`, `useActionModal`, `ObjectManager`, + // `FieldDesigner`, `MasterDetailForm`, `EmbeddableForm`, `plugin-view`'s + // `ObjectView`), while `onStepChange` has NO in-repo supplier at all — the + // channel is wired end to end (`ObjectForm` forwards it onto the wizard node, + // `WizardForm` calls it) and only the supplier is missing. ['form::FormSchema.onError', 'objectui#7804'], ['form::FormSchema.onOpenChange', 'objectui#7804'], ['form::FormSchema.onStepChange', 'objectui#7804'], ['form::FormSchema.onSuccess', 'objectui#7804'], - ['object-grid::ObjectGridSchema.onNavigate', 'objectui#7804'], + // ⭐ `object-grid::ObjectGridSchema.onNavigate` LANDED and is gone with the + // same slice. A RUNTIME SLOT whose channel the maintainer's 2026-08-19 ruling + // on objectui#5234 (option C) preserved on purpose — declared on the + // TypeScript face for programmatic callers, kept OFF the authoring surface — + // so the mirror's named refusal now says on this face what + // `@objectstack/spec`'s `strictObject` already said on the other. ⚠️ No + // in-repo host builds an `object-grid` node carrying it; the pin + // `gridNonAuthorKeys.test.tsx` supplies it and asserts the read still fires. ['grid::GridSchema.onNavigate', 'objectui#7804'], // ⭐ ALL THREE `object-kanban::ObjectKanbanSchema` rows LANDED and are gone. // `onCardClick` and `onQuickAdd` went with objectui#7804's `plugin-kanban` @@ -192,9 +206,25 @@ export const KNOWN_UNDECLARED_READS = new Map([ ['list::ListSchema.onDensityChange', 'objectui#7804'], ['list::ListSchema.onNavigate', 'objectui#7804'], ['list::ListSchema.onPageSizeChange', 'objectui#7804'], - ['object-gallery::ObjectGallerySchema.onCardClick', 'objectui#7804'], - ['object-gallery::ObjectGallerySchema.onRowClick', 'objectui#7804'], - ['object-view::ObjectViewSchema.onNavigate', 'objectui#7804'], + // ⭐ BOTH `object-gallery::ObjectGallerySchema` rows and + // `object-view::ObjectViewSchema.onNavigate` LANDED and are gone with the + // same slice. The gallery pair is this ledger's PROPS-half shape: the two + // reads sit on one line (`props.onRowClick ?? props.onCardClick`) because + // `SchemaRenderer` spreads the authored node's leftover keys into the props + // bag — `onRowClick` has two in-repo suppliers (`ListView`'s `baseProps`, + // `RelatedList`'s mobile branch), `onCardClick` has none and is the `??` + // fallback spelling declared on `ObjectGalleryProps`. `ObjectViewSchema`'s + // key is read at four call sites and supplied by `@object-ui/app-shell`'s + // `ObjectView`, which builds the node in TypeScript. + // + // ⚠️ `onNavigate` is filed TWICE in this ledger's history, on two different + // faces with two different signatures and two different suppliers. They were + // judged separately, not co-disposed. + // + // Draining a row is part of the landing, not cleanup after it: a row that + // outlived its read reddens `staleExemptions()` below — which is exactly the + // intermediate reading that proved the arm edit had reached these nine keys + // and no others. // ⭐ objectui#9344 — the two rows this ledger could not have held before, and // the reason its population was never a total. Both reads are spelled // `(schema as any).onTabChange`, and a cast receiver was invisible to the