diff --git a/.changeset/7804-object-kanban-handler-keys-judged.md b/.changeset/7804-object-kanban-handler-keys-judged.md new file mode 100644 index 0000000000..14f1b2e869 --- /dev/null +++ b/.changeset/7804-object-kanban-handler-keys-judged.md @@ -0,0 +1,43 @@ +--- +'@object-ui/types': minor +--- + +`ObjectKanbanSchema` now judges two of the three handler keys the kanban board reads off the +authored document (objectui#7804, the `plugin-kanban` slice; director seat ruling of +2026-09-07, decision batch #69): `onCardClick` and `onQuickAdd` are declared as objectui#6124 +RUNTIME SLOTS — callable on the TypeScript face, refused BY NAME in the zod mirror because +JSON has no function value — and the message points at the node-type spelling an author can +write instead. + +Until now neither was declared anywhere. `BaseSchema` is `.passthrough()`, so a key no arm +declares is not refused: it stops being judged and the value is KEPT, then reaches the +renderer that reads it. Measured on this branch, `{ "type": "object-kanban", "objectName": +"task", "groupBy": "status", "onCardClick": { "action": "toast" } }` parsed GREEN with +`{"action":"toast"}` surviving into the parsed output, and `KanbanRenderer` forwarded it to a +call site expecting a function. That is objectui#7664's measured transition, inherited by +this face when objectui#8802 retired the sibling `kanban` arm that used to carry all three as +runtime slots. + +**BREAKING (scored `minor` per this repo's version-alignment convention, majors track +`@objectstack`)** — the accept set on the published zod mirror moves. A declared key is validated +even under `.passthrough()`, so two documents that parsed green yesterday are refused today, +each at its own path: `onCardClick` and `onQuickAdd` carried at all. Neither is authorable +in JSON by construction (a function has no JSON value), and `@objectstack/spec`'s +`ObjectKanbanPropsSchema` — fourteen keys on the installed 17.4.0 pin — already refuses both +by `unrecognized_keys`, so this narrows toward the protocol rather than away from it. Every +other key parses exactly as before, and an undeclared key still passes through unchanged. + +**No renderer change; no runtime behaviour changes.** The registration, its `inputs` and +`ObjectKanban` are untouched. On the TypeScript face both keys become typed members where +`BaseSchema`'s index signature used to absorb them, so a wrong-typed value is now a compile +error at the key. + +**The third key, `onCardMove`, is deliberately NOT declared, and that is a measurement rather +than an omission.** Its authored value reaches nothing on this face — `ObjectKanban` +substitutes its own mover on the schema it hands down and declares no `onCardMove` React +prop — which is the objectui#6124 `'retired'` disposition; `check:handler-key-reads` refuses +that spelling while `KanbanRenderer` still reads the key, printing `declares it RETIRED, but +a renderer still reads it`. The two spellings that would make the gate green are both worse: +`'runtime-slot'` would publish a callable key the object-bound board drops, and deleting the +read would narrow `KanbanRenderer`'s published props — a ruling, not a repair. The key keeps +its `KNOWN_UNDECLARED_READS` row naming objectui#7804, which stays open and stays the parent. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 2aa244c12d..82b5308e94 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -950,7 +950,9 @@ A drag-and-drop Kanban board. The `object-kanban` type key validates the shape t > > ⚠️ The **bare-string array applies only to a board with no `groupBy`.** It is declared so this package does not refuse an authoring the protocol allows. The renderer reads a bare-string lane list only when a board has no `groupBy` — so on a board that *does* declare one the strings are ignored and the lanes come from the group field's picklist options or from the data. Since objectui#8990 made `groupBy` optional, a lane-less board is a valid authoring and this arm is live on it: the lanes are drawn, titled by the **raw strings** (a grouped board titles its lanes with the picklist *labels* instead). ⚠️ Such a board holds **no cards** — with no lane key the records are never distributed — and dragging a card writes nothing back. It is lane headings, not a populated board; to control the lanes of a working board, declare `groupBy` and write the `{ id, title }` array. > -> The other keys the retired `kanban` arm alone declared — `cardTitle`, `swimlaneField`, `grouping` and `navigation` — are still undeclared on this face. The renderer reads them, so a board may carry them; they are simply not judged. The board's React host supplies `onCardMove` / `onCardClick` / `onQuickAdd` as props; none of the three is authorable in JSON. +> The other keys the retired `kanban` arm alone declared — `cardTitle`, `swimlaneField`, `grouping` and `navigation` — are still undeclared on this face. The renderer reads them, so a board may carry them; they are simply not judged. + +> **Handler keys are not authorable in JSON, and two of the three now say so by name.** Since objectui#7804 this face declares `onCardClick` and `onQuickAdd` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and this validator **refuses the key by name** with a message pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onCardClick: { "action": "toast" }` parsed **green** — `BaseSchema` is `.passthrough()`, so a key no arm declares is not refused, it stops being judged and the value is kept, then reaches a call site expecting a function. ⚠️ `onCardMove` is the third key the board component reads and it is **still undeclared**: an authored one is accepted and dropped, because on an object-bound board the renderer substitutes its own mover. That gap is open on objectui#7804. > `data` and `bind` are [`BaseSchema`](#baseschema) members, not narrowed here, but this face requires **one of** `bind`, `data`, `objectName` — the renderer's own record-source ladder (an external `data` prop → `bind` via `useDataScope` → this schema's own `data` → a fetch keyed by `objectName`). A purely static board (lanes carrying their own cards, no record source) authors `"groupBy"` and `"data": []`. ⚠️ The record-source rule is **separate** from the lane key and is unaffected by objectui#8990: omitting `groupBy` is fine, omitting all of `bind` / `data` / `objectName` is still refused, at the refinement rather than at `groupBy`. diff --git a/content/docs/plugins/plugin-kanban.mdx b/content/docs/plugins/plugin-kanban.mdx index 627577a19a..c37c484261 100644 --- a/content/docs/plugins/plugin-kanban.mdx +++ b/content/docs/plugins/plugin-kanban.mdx @@ -82,6 +82,17 @@ const onCardMove = (cardId: string, fromCol: string, toCol: string, index: numbe has no function value), never a member of the schema object below — `packages/plugin-kanban/README.md` documents this the same way. +Neither are `onCardClick` and `onQuickAdd`, and since objectui#7804 the +`object-kanban` validator **says so by name**: both are declared as +objectui#6124 **runtime slots**, so an authored `onCardClick: { "action": +"toast" }` is now refused with a message pointing at the node-type spelling, +where before it parsed green and was handed to a call site expecting a function. +A React host still supplies either function through the TypeScript interface or +as a React prop; only the JSON face refuses them. ⚠️ `onCardMove` itself is +still *undeclared* on that validator — an authored one is accepted and silently +dropped, because an object-bound board substitutes its own mover — and that gap +is open on objectui#7804. + `columns` are the board's **swimlanes**, not a field projection — the fields drawn on a card are `cardFields`. It is **one of two array shapes**, the pair `@objectstack/spec` declares: an array of `{ id, title }` lanes (one per `groupBy` diff --git a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx new file mode 100644 index 0000000000..3ccca1db02 --- /dev/null +++ b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx @@ -0,0 +1,408 @@ +/** + * 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. + */ + +/** + * Two of the three handler keys `KanbanRenderer` reads off the authored + * document are now JUDGED by the `object-kanban` arm, and each one's + * objectui#6124 disposition is MEASURED here rather than shared across the + * prefix (objectui#7804, the `plugin-kanban` slice of the 39-row finding; + * director seat ruling of 2026-09-07, decision batch #69). + * + * ## The exposure this closes + * + * `BaseSchema` is `.passthrough()`. A key an arm does not declare is not + * refused — it stops being judged and the value is KEPT, then reaches the + * renderer that reads it. `ObjectKanbanSchema` declared none of these three + * while `KanbanRenderer` forwarded all three off `schema.*`, so an authored + * `onCardClick: { action: 'toast' }` parsed GREEN and was handed to a call site + * expecting a function. That is objectui#7664's measured transition, one arm + * over, and `AlertDialogSchema.onAction` was this same shape until + * objectui#7104 declared it. + * + * ## ⭐ The three do NOT agree, and that is the deliverable + * + * The ruling requires the disposition per key. Measured on the ONE surviving + * registration, `'object-kanban'` (`ObjectKanbanRenderer` → `ObjectKanban` → + * `KanbanRenderer`): + * + * - `onQuickAdd` — RUNTIME SLOT. It rides `ObjectKanban`'s `...schema` spread + * untouched and arrives at the board implementation BY IDENTITY. + * - `onCardClick` — RUNTIME SLOT. `ObjectKanban` replaces the schema key with + * its own wrapper, but `SchemaRenderer` also spreads the authored key as a + * React PROP, `ObjectKanbanComponentProps` declares that prop, and the + * wrapper CALLS it. The authored function runs. + * - `onCardMove` — the reading is `'retired'` and it does NOT land here. + * `ObjectKanban` replaces the schema key with `handleCardMove` and declares + * NO `onCardMove` prop (its rest parameter is discarded), so neither + * channel delivers and an authored value reaches nothing on this arm. + * + * ⛔ "`ObjectKanban` overrides it" does not separate `onCardClick` from + * `onCardMove` — it is true of both. What separates them is the PROP channel, + * which only `onCardClick` has, so that is what suite 2 drives. + * + * ## ⚠️ Why the third key is recorded here instead of declared + * + * `check:handler-key-reads` — the gate of record for this class — refuses the + * `'retired'` spelling while a renderer still reads the key, and prints + * `declares it RETIRED, but a renderer still reads it`. Its own contract says + * why: a tombstone "exists precisely because nothing reads the key — it has no + * read site BY CONSTRUCTION". `KanbanRenderer` does read `schema.onCardMove`, + * off the document `ObjectKanban` hands it; what the gate cannot see is that + * the value at that read was substituted one hop earlier. + * + * ⛔ The two spellings that would make it green are both worse. Declaring + * `'runtime-slot'` keeps the TypeScript twin callable and so publishes a key + * the object-bound board DROPS — the one resolution this package's `quickAdd` + * carve-out records as forbidden. Deleting the read narrows `KanbanRenderer`'s + * published props, which is the objectui#7742 remedy (`objectFields`, one file + * over, maintainer decision batch #70) and a ruling rather than a repair. + * + * ⇒ `onCardMove` keeps its `KNOWN_UNDECLARED_READS` row naming objectui#7804, + * which stays open and stays the parent. Suite 1 pins it as STILL ACCEPTED AND + * KEPT, so the exposure cannot drift silently and the day it is closed this + * file goes red pointing at the reading that closed it. + * + * ## Every control here can fire + * + * A `'retired'` ruling is a claim about absence, and an absent read and an + * unreachable one look identical to a grep. So the dead-read leg does not stop + * at "the authored function is not the one the board got": it DRIVES the + * handler the board was handed and asserts the authored spy never runs, with + * the same drive on `onCardClick` — which does run it — as the lit control on + * the same instrument, the same document and the same render. + * + * ## Predictions, written before the code (red-first) + * + * On the unmodified base tree (`origin/main` @ `0f3d15314`), with this file in + * place and no `packages/types` change yet: + * + * - suite 1 (the accept set) FAILS on the two declared keys: both parse + * GREEN and the authored value survives into the parsed output, which is + * the exposure restated as a reading. Its `onCardMove` leg PASSES before + * and after — that key is the one this slice does not close; + * - suite 3 (both faces) FAILS on the two: nothing is declared on either + * face, so there is no disposition to read; + * - suite 4 (the drained ledger) FAILS listing the two + * `object-kanban::ObjectKanbanSchema.*` rows this slice drains, while its + * `onCardMove` row is expected to survive; + * - suite 2 (the channels) PASSES UNCHANGED, before and after. It measures + * the renderer, and this card changes no renderer — which is exactly why it + * is the evidence the dispositions are derived from rather than a + * restatement of them. + * + * ⭐ The base run FALSIFIED the last of those, and the correction is recorded + * rather than smoothed over: suite 1 and suites 3–4 failed exactly as written + * (all three keys ACCEPTED, with the authored value surviving into the parsed + * output), but the dead-read leg of suite 2 also failed — on its lit CONTROL, + * not on its subject. `onCardMove` was already measured dead; `onCardClick` ran + * TWICE rather than once. That count is a defect in `ObjectKanban`'s click + * wiring, not in this card's subject, so the control now asserts that the + * authored handler RAN and the count is reported on its own card instead of + * being pinned here. + */ + +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +import { ObjectKanbanSchema as ObjectKanbanZod } from '@object-ui/types/zod'; +// The gate of record's own ledger, read rather than restated: suite 4 asks +// whether the rows this slice landed are gone and the one it did not is still +// there, and a copy of the list here could answer that about itself. +// @ts-expect-error — plain-JS shared gate, intentionally untyped (`allowJs: false`) +import { KNOWN_UNDECLARED_READS } from '../../../../scripts/check-handler-key-read-sites.mjs'; +// Prose is not source. Without the mask a docblock naming a member spelling +// would be read as a declaration, and this file's TS-face leg would report a +// disposition nobody wrote (`scripts/js-comment-mask.mjs`, kept honest by +// `check:comment-mask-corpus`). +// @ts-expect-error — plain-JS shared helper, intentionally untyped (`allowJs: false`) +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; +import '../index'; + +/** Local annotations, since both imports above are untyped — the call sites stay checked. */ +const mask: (source: string) => string = maskComments; +const ledger: Map = KNOWN_UNDECLARED_READS; + +/** The three keys this slice owns, in the ledger's own spelling. */ +const KEYS = ['onCardClick', 'onCardMove', 'onQuickAdd'] as const; + +/** The two whose disposition LANDED — the third is recorded, not declared. */ +const DECLARED = ['onCardClick', 'onQuickAdd'] as const; + +/** Every props object the board implementation was rendered with, in order. */ +const recorded = vi.hoisted(() => ({ impl: [] as Array> })); + +// The lazy board chunk is a prop recorder: the question is what reaches the +// board's props, not what the board draws with them. +vi.mock('../KanbanImpl', () => ({ + default: (props: Record) => { + recorded.impl.push(props); + return null; + }, +})); + +const STATIC_COLUMNS = [{ id: 'todo', title: 'To Do', cards: [{ id: '1', title: 'One' }] }]; + +/** + * Author a document on the PRODUCTION path — `SchemaRenderer` resolves the + * registry key and spreads every non-metadata schema key as a React prop — and + * return the props the board implementation was handed last. + */ +async function boardPropsFor(schemaKeys: Record) { + const before = recorded.impl.length; + const { unmount } = render( + + + , + ); + await waitFor(() => expect(recorded.impl.length).toBeGreaterThan(before)); + const received = recorded.impl[recorded.impl.length - 1]; + unmount(); + return received; +} + +describe('suite 1 — the accept set: an authored handler key on `object-kanban` is REFUSED BY NAME (objectui#7804)', () => { + /** The document shape objectui#7664 measured, re-pointed at the surviving key. */ + const board = (extra: Record) => ({ + type: 'object-kanban', + objectName: 'task', + groupBy: 'status', + ...extra, + }); + + it.each(DECLARED)('`%s: { action: "toast" }` draws the objectui#6124 named refusal', (key) => { + const parsed = ObjectKanbanZod.safeParse(board({ [key]: { action: 'toast' } })); + expect( + { + accepted: parsed.success, + code: parsed.success ? null : parsed.error.issues[0]?.code, + path: parsed.success ? null : parsed.error.issues[0]?.path.join('.'), + }, + `an authored \`${key}\` still parses GREEN — the passthrough is keeping it and handing it to a call site that expects a function`, + ).toEqual({ accepted: false, code: 'custom', path: key }); + }); + + it('a live FUNCTION is refused there too — the arm is a refusal, not a type check', () => { + // The #6124 predicate refuses everything, a function included: the JSON + // face has no function value, and the programmatic face reaches the + // renderer through React props and the TypeScript interface, never through + // `safeParse`. Suite 2 is where the function channel is measured. + const parsed = ObjectKanbanZod.safeParse(board({ onCardClick: () => {} })); + expect(parsed.success).toBe(false); + }); + + it('⚠️ `onCardMove` is STILL ACCEPTED AND KEPT — the row this slice did not close', () => { + // ⛔ Not an oversight and not a passing grade: this is the objectui#7664 + // exposure, still open on this one key, pinned so it cannot drift in + // silence. The header says why declaring it is refused by the gate of + // record and why the two greener spellings are worse. The day the read + // moves to an explicit React prop and the key is tombstoned, THIS leg goes + // red — which is the point of writing it down as a reading. + const parsed = ObjectKanbanZod.safeParse(board({ onCardMove: { action: 'toast' } })); + expect({ + accepted: parsed.success, + kept: parsed.success ? (parsed.data as Record).onCardMove : null, + }).toEqual({ accepted: true, kept: { action: 'toast' } }); + }); + + it('CONTROLS — the scope did not move: a declared key still parses, and an undeclared one is still KEPT', () => { + // Lit control: the board itself is still acceptable, so the three `false`s + // above are about those keys and not about a schema that now refuses + // everything. + expect(ObjectKanbanZod.safeParse(board({})).success).toBe(true); + + // Scope control: `BaseSchema` is still `.passthrough()`. This card judges + // three keys; it does not turn the arm strict, and a reader must be able to + // tell those apart. + const probe = ObjectKanbanZod.safeParse(board({ zzzNotAHandlerKey: { action: 'toast' } })); + expect({ + accepted: probe.success, + kept: probe.success ? 'zzzNotAHandlerKey' in probe.data : null, + }).toEqual({ accepted: true, kept: true }); + }); +}); + +describe('suite 2 — the channels, per key, on the one surviving registration (objectui#7804)', () => { + it('`onQuickAdd` reaches the board BY IDENTITY', async () => { + const onQuickAdd = vi.fn(); + const props = await boardPropsFor({ onQuickAdd }); + expect(props.onQuickAdd).toBe(onQuickAdd); + }); + + it('`onCardClick` is REPLACED by ObjectKanban, and the replacement CALLS the authored one', async () => { + const onCardClick = vi.fn(); + const card = { id: '1', title: 'One' }; + const props = await boardPropsFor({ onCardClick }); + + // Control: the wrapper is genuinely interposed, so a call that reaches the + // spy can only have arrived through it. + expect(props.onCardClick).not.toBe(onCardClick); + (props.onCardClick as (c: unknown, e?: unknown) => void)(card); + expect(onCardClick).toHaveBeenCalledWith(card); + }); + + it('⭐ `onCardMove` reaches NOTHING — driven, not inferred, with `onCardClick` as the lit control', async () => { + const onCardMove = vi.fn(); + const onCardClick = vi.fn(); + const card = { id: '1', title: 'One' }; + // ONE document, ONE render: the two keys travel the same passthrough, the + // same `...schema` spread and the same prop spread, so the contrast below + // is about the keys and not about how each was authored. + const props = await boardPropsFor({ onCardMove, onCardClick }); + + expect(props.onCardMove).not.toBe(onCardMove); + // DRIVE the handler the board was actually handed. An authored value that + // reached a live channel would run here; nothing does. A board with no + // `groupBy` short-circuits `handleCardMove` before any write, so this drive + // measures the forwarding and touches no data source. + await (props.onCardMove as (a: string, b: string, c: string, d: number) => Promise | void)( + '1', + 'todo', + 'done', + 0, + ); + (props.onCardClick as (c: unknown, e?: unknown) => void)(card); + + // ⚠️ The control asserts that `onCardClick` RAN, deliberately not how many + // times. Measured on the base tree it runs TWICE per click on this path: + // `ObjectKanban` passes the same function to `useNavigationOverlay` as + // `onRowClick` — whose `handleClick` forwards to it and returns — and then + // calls it again itself. That is a separate defect on a separate card; a + // count pinned here would make fixing it red on a file that is not about + // it, and the control needs only to be able to fire. + expect( + { cardMove: onCardMove.mock.calls.length, cardClickRan: onCardClick.mock.calls.length > 0 }, + 'the lit control `onCardClick` must run — a run where NEITHER fires measures nothing', + ).toEqual({ cardMove: 0, cardClickRan: true }); + }); + + it("the prop channel is the difference: `ObjectKanbanComponentProps` declares `onCardClick` and no `onCardMove`", () => { + const here = dirname(fileURLToPath(import.meta.url)); + const src = mask(readFileSync(join(here, '..', 'ObjectKanban.tsx'), 'utf8')); + const declares = (key: string) => new RegExp(`^\\s{2}${key}\\?:`, 'm').test(src); + expect({ onCardClick: declares('onCardClick'), onCardMove: declares('onCardMove') }).toEqual({ + onCardClick: true, + onCardMove: false, + }); + }); +}); + +describe('suite 3 — the disposition is legible on BOTH faces, and they agree (objectui#7804)', () => { + const TS_FACE = join( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + 'types', + 'src', + 'objectql.ts', + ); + + /** The `ObjectKanbanSchema` interface body, comments masked. */ + function objectKanbanInterface(): string { + const src = mask(readFileSync(TS_FACE, 'utf8')); + const start = src.indexOf('export interface ObjectKanbanSchema'); + expect(start, '`ObjectKanbanSchema` not found on the TypeScript face').toBeGreaterThan(-1); + const end = src.indexOf('\n}', start); + expect(end, '`ObjectKanbanSchema` has no closing brace').toBeGreaterThan(start); + return src.slice(start, end); + } + + it('the zod mirror refuses the two by name — and still declares nothing for the third', () => { + const shape = ObjectKanbanZod.shape as Record; + expect(KEYS.map((key) => ({ key, declared: key in shape }))).toEqual([ + { key: 'onCardClick', declared: true }, + { key: 'onCardMove', declared: false }, + { key: 'onQuickAdd', declared: true }, + ]); + for (const key of DECLARED) { + expect(shape[key]?.description, `\`${key}\` carries no author-facing guidance`).toContain( + 'objectui#6124', + ); + expect(shape[key]?.description).toContain(key); + } + }); + + it('the mirror spells RUNTIME SLOT on both landed keys, and nothing at all on the third', () => { + const shape = ObjectKanbanZod.shape as Record; + const said = (key: string) => + shape[key]?.description?.includes('RUNTIME SLOT') + ? 'runtime-slot' + : shape[key]?.description?.includes('RETIRED') + ? 'retired' + : 'undeclared'; + expect({ + onCardClick: said('onCardClick'), + onCardMove: said('onCardMove'), + onQuickAdd: said('onQuickAdd'), + }).toEqual({ + onCardClick: 'runtime-slot', + onCardMove: 'undeclared', + onQuickAdd: 'runtime-slot', + }); + }); + + it('the TypeScript twin keeps both live slots callable, and declares no third member', () => { + const body = objectKanbanInterface(); + const member = (key: string) => new RegExp(`^\\s{2}${key}\\?:\\s*([^;]+);`, 'm').exec(body)?.[1]?.trim(); + expect({ + onCardClick: member('onCardClick'), + onCardMove: member('onCardMove'), + onQuickAdd: member('onQuickAdd'), + }).toEqual({ + onCardClick: '(card: any) => void', + // ⚠️ `undefined` is the reading, not a gap in the regex — the firing + // control below reads a member this interface has always had. A + // `?: never` tombstone here is what the measurement asks for and what + // the gate of record refuses; see the header. + onCardMove: undefined, + onQuickAdd: '(columnId: string, title: string) => void', + }); + + // Firing control on the same reader: a member this interface has always + // declared still reads, so the three readings above are not a regex that + // matches nothing. + expect(member('groupBy')).toBe('string'); + }); +}); + +describe('suite 4 — the ledger drained with the fix (objectui#7804)', () => { + it('the two landed rows are gone, and the one this slice did not close is still named', () => { + const rows = [...ledger.keys()].filter((row) => + row.startsWith('object-kanban::'), + ); + expect( + rows, + 'a ledger row naming a read that is now declared is a live waiver for a defect that is gone; a ' + + 'read that is still undeclared must keep its row or the gate loses it', + ).toEqual(['object-kanban::ObjectKanbanSchema.onCardMove']); + expect( + ledger.get('object-kanban::ObjectKanbanSchema.onCardMove'), + ).toBe('objectui#7804'); + }); + + it('CONTROL — the ledger still carries the rows this slice did NOT take', () => { + // objectui#7804 stays the parent and lands per package. A drained ledger + // would mean this leg is reading an empty map rather than a shrinking one. + const remaining = [...ledger.keys()]; + expect(remaining.length).toBeGreaterThan(0); + expect(remaining).toContain('detail::DetailSchema.onNavigate'); + }); +}); diff --git a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx index 29a00ffa9f..4f19a40ca8 100644 --- a/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx +++ b/packages/plugin-kanban/src/__tests__/kanban-handler-slots-7664.test.tsx @@ -46,6 +46,19 @@ * `ObjectKanbanSchema` would WIDEN a published accept set, which is a * ruling and not a repair. * + * ## ⭐ What objectui#7804 then did to suite 3 + * + * The ruling arrived (director seat, decision batch #69, 2026-09-07) and the + * dispositions were measured per key on this face. TWO landed — + * `onCardClick` and `onQuickAdd`, both objectui#6124 RUNTIME SLOTS — so suite + * 3's verdict flips a second time, and it is now spelled PER KEY: `onCardMove` + * is still declared by nothing, because its authored value reaches neither + * channel and the gate of record refuses the `'retired'` spelling while this + * file's own forward block still reads the key. The separate per-key evidence + * lives in `./handlerKeyDispositionsMeasured-7804.test.tsx`; what stays here is + * the DERIVATION from the read site, which is the thing a re-key cannot hold + * constant. + * * ## Suite 1 — runtime reachability, per registration * * The two lazy board chunks are replaced by prop recorders; three spies are @@ -287,22 +300,30 @@ describe('the handler keys KanbanRenderer forwards, and where they are declared expect(forwardedByKanbanRenderer()).toEqual(['onCardClick', 'onCardMove', 'onQuickAdd']); }); - it('⚠️ none of the three is declared on the surviving `object-kanban` face — the retirement moved this reading, and it is recorded rather than repaired', () => { + it('⭐ two of the three are declared on the surviving `object-kanban` face — measured per key, not per prefix', () => { // ⭐ This leg USED to assert `declared: true, guidance: true` against the // zod `'kanban'` arm, which carried all three as objectui#6124 RUNTIME - // SLOTS. objectui#8802 retired that arm with the bare node key, and the - // surviving `ObjectKanbanSchema` never declared them — so the honest - // reading today is the opposite one, and it is pinned so it cannot drift - // back in silence. - // - // ⛔ NOT repaired here: adding the three to `ObjectKanbanSchema` WIDENS a - // published accept set, which is a ruling, not a repair. Reported on the - // retirement PR for the maintainer. + // SLOTS; objectui#8802 retired that arm and the reading flipped to three + // `false`s on the surviving `ObjectKanbanSchema`. objectui#7804 then ruled + // the class (director seat, decision batch #69) and measured this face key + // by key — and the answers DIFFER, which is why the expectation below is + // spelled per key rather than mapped over the list: + // - `onCardClick` and `onQuickAdd` are objectui#6124 RUNTIME SLOTS. Suite + // 1 above is where the two channels that make them so are measured — + // identity through the schema spread for the second, and the React prop + // the wrapper calls (suite 2) for the first. + // - `onCardMove` is still declared by NOTHING. Its authored value reaches + // neither channel, which is the `'retired'` disposition, and + // `check:handler-key-reads` refuses that spelling while this very + // forward block still reads the key. It keeps its + // `KNOWN_UNDECLARED_READS` row on objectui#7804, which stays open. const shape = ObjectKanbanZod.shape as Record; const forwarded = forwardedByKanbanRenderer(); - expect(forwarded.map((key) => ({ key, declared: key in shape }))).toEqual( - ['onCardClick', 'onCardMove', 'onQuickAdd'].map((key) => ({ key, declared: false })), - ); + expect(forwarded.map((key) => ({ key, declared: key in shape }))).toEqual([ + { key: 'onCardClick', declared: true }, + { key: 'onCardMove', declared: false }, + { key: 'onQuickAdd', declared: true }, + ]); // Firing control on the SAME instrument: a key this face really does // declare reads `true`, so the three `false`s above are readings and not a // shape lookup that answers `false` to everything (an unwrapped 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 deeee2e653..027e2c0f19 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 @@ -50,7 +50,10 @@ * below — 37 since objectui#6576 minted `ObjectDataTableSchema` with an * `onRowClick` arm, the first on an `objectql` mirror; 38 since objectui#7104 * declared `AlertDialogSchema.onAction`, a key the renderer had been reading - * UNDECLARED). A key nothing reads + * UNDECLARED; 40 since objectui#7804 declared + * `ObjectKanbanSchema.onCardClick` / `.onQuickAdd`, that same shape again on + * the face that INHERITED those reads when objectui#8802 retired the + * sibling `kanban` arm). A key nothing reads * gets the `?: never` tombstone (22 sites, `RETIRED` below; the `crud.ts` * `confirm` / `base.ts` convention). * @@ -78,8 +81,14 @@ import { z } from 'zod'; import { retirementTombstone } from '../zod/tombstone.zod'; // objectui#6576 — the first handler arm on an `objectql` mirror, ledgered here // like the 58 it joins. -import { ObjectDataTableSchema as ObjectDataTableZod } from '../zod/objectql.zod'; -import type { ObjectDataTableSchema } from '../objectql'; +// objectui#7804 added the second pair on this mirror: the `object-kanban` face +// INHERITED reads it never declared when objectui#8802 retired the sibling +// `kanban` arm. +import { + ObjectDataTableSchema as ObjectDataTableZod, + ObjectKanbanSchema as ObjectKanbanZod, +} from '../zod/objectql.zod'; +import type { ObjectDataTableSchema, ObjectKanbanSchema } from '../objectql'; import { CalendarViewSchema as CalendarViewZod, CarouselSchema as CarouselZod, @@ -281,6 +290,23 @@ const RUNTIME_SLOT: readonly Site[] = [ ['navigation.zod.ts', 'PaginationSchema', 'onPageChange', PaginationZod], // objectui#6576 / #6914 — `ObjectDataTable.tsx` forwards `schema.onRowClick` into the `data-table` it renders. ['objectql.zod.ts', 'ObjectDataTableSchema', 'onRowClick', ObjectDataTableZod], + // ⭐ objectui#7804 — the `plugin-kanban` slice of the 39-row finding, and a + // ledger GROWTH on the face that INHERITED the reads objectui#8802's arm + // retirement left declared by nothing. Each channel measured on its own, and + // the two do not share one: + // - `onQuickAdd` rides `ObjectKanban`'s `...schema` spread untouched and + // arrives at `KanbanImpl` BY IDENTITY; + // - `onCardClick` is SUBSTITUTED on the schema `ObjectKanban` hands down, + // and is live anyway through the other channel — `SchemaRenderer` spreads + // the authored key as a React prop, `ObjectKanbanComponentProps` declares + // it, and the substituted wrapper CALLS it. + // ⚠️ The third key `KanbanRenderer` forwards, `onCardMove`, is NOT here. Its + // authored value reaches nothing on this entry, which is the `'retired'` + // disposition, and `check:handler-key-reads` refuses that spelling while the + // renderer still reads the key — so it keeps its `KNOWN_UNDECLARED_READS` row + // on objectui#7804 instead of being guessed into either half of this ledger. + ['objectql.zod.ts', 'ObjectKanbanSchema', 'onCardClick', ObjectKanbanZod], + ['objectql.zod.ts', 'ObjectKanbanSchema', 'onQuickAdd', ObjectKanbanZod], ['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. @@ -359,11 +385,22 @@ const NON_ON_FUNCTION = /^\s*(cell|custom|renderCellEditor|validate): z\.functio const describeOf = (mirror: z.ZodType, key: string): string | undefined => (objectOf(mirror, key).shape[key] as { description?: string } | undefined)?.description; -/** One key, isolated: `.pick()` keeps the member's own declaration and drops - * the rest of the object, so the probe needs no per-schema fixture and a - * refusal can only be about the key under test. */ +/** One key, isolated: the member's OWN declaration, lifted out of its object, + * so the probe needs no per-schema fixture and a refusal can only be about the + * key under test. + * + * ⚠️ Rebuilt rather than `.pick()`ed since objectui#7804. `ObjectKanbanSchema` + * closes with `.superRefine(requireKanbanRecordSource)` — the one-of + * `bind`/`data`/`objectName` rule — and zod refuses `.pick()` on an object + * carrying refinements (`.pick() cannot be used on object schemas containing + * refinements`), which is a THROW rather than a red assertion and so would + * have read as the instrument breaking rather than as a missing fixture. The + * member declaration handed to the new object is the same one `.pick()` would + * have carried, and every probe below writes only that key, so no probe's + * reading moves; what is dropped with the wrapper is the record-source + * refinement, which is exactly the noise this isolation exists to remove. */ const pickKey = (mirror: z.ZodType, key: string) => - objectOf(mirror, key).pick({ [key]: true } as Record); + z.object({ [key]: objectOf(mirror, key).shape[key] }); const AUTHORED_ACTION_OBJECT = { action: 'toast', title: 'Saved', variant: 'success' }; const LIVE_FUNCTION = () => undefined; @@ -392,7 +429,7 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje ]); }); - it('62 sites are ledgered, 42 runtime slots + 20 retired, with no key filed twice', () => { + it('64 sites are ledgered, 44 runtime slots + 20 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 @@ -408,10 +445,18 @@ describe('census: no on* key in the eight mirrors is declared z.function() (obje // own failure mode; the two comment blocks above state why an ARM // retirement is not that failure, and `./bare-kanban-node-key-retired-8802.test.ts` // measures the refusal that replaced them. - expect(RUNTIME_SLOT).toHaveLength(42); + // + // ⭐ 62 → 64: objectui#7804 declared `ObjectKanbanSchema.onCardClick` and + // `.onQuickAdd`, two of the three reads the retirement above left on the + // SURVIVING face with nothing declaring them. ⚠️ TWO, not three — the + // third (`onCardMove`) is measured `'retired'` and the gate of record + // refuses that spelling while the renderer still reads the key, so it stays + // in `KNOWN_UNDECLARED_READS` rather than being filed here under a + // disposition nothing measured. + expect(RUNTIME_SLOT).toHaveLength(44); expect(RETIRED).toHaveLength(20); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(62); + expect(new Set(ids).size).toBe(64); }); 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) => { @@ -590,6 +635,8 @@ export type assertionRuntimeSlotsKeepTheirFunctionType = [ Expect>, Expect>, Expect>, + Expect>, + Expect>, Expect>, Expect>, Expect>, diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index a10f6f80e1..877d0ee2d2 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -129,7 +129,18 @@ * 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. - * - **40 entries** in `KnownDrift`, **61 keys** across them — 41 / 64 until + * - **41 entries** in `KnownDrift`, **63 keys** across them — 40 / 61 until + * objectui#7804 DECLARED `objectql.zod.ts#ObjectKanbanSchema`'s + * `onCardClick` and `onQuickAdd` (director seat, decision batch #69), a new + * entry carrying TWO of the three keys the retirement below stranded on the + * surviving face. ⭐ The first entry this ledger has gained from an arm + * RETIREMENT leaving reads behind on a sibling: the keys did not move and + * no face drifted — the face that used to declare them stopped existing, + * and a different one picked up the obligation. ⚠️ TWO of the three, not + * three: `onCardMove` is measured `'retired'` on this face and + * `check:handler-key-reads` refuses that spelling while the renderer still + * reads the key, so neither face declares it and it is not drift at all. + * It was 40 / 61 until then, because * objectui#8802 RETIRED the bare `kanban` node type key (maintainer ruling * 2026-09-09) and the `complex.zod.ts#KanbanSchema` pair with it, taking that * entry's three RUNTIME SLOT keys (`onCardMove` / `onCardClick` / @@ -365,7 +376,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 40 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 41 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 @@ -1627,6 +1638,31 @@ 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) ×2 — the + * SECOND handler entry on this mirror, and the first anywhere in this ledger + * that was born from an ARM RETIREMENT rather than from a mirror or a + * declaration moving. The three keys `KanbanRenderer` forwards off `schema.*` + * sat on the `complex.zod.ts#KanbanSchema` entry until objectui#8802 retired + * the bare `kanban` node type key; the reads stayed, on the surviving + * `object-kanban` face, declared by neither side. + * + * The two here are declared because their function value REACHES the board on + * that face, measured one channel at a time and NOT shared across the prefix + * (`plugin-kanban`'s `__tests__/handlerKeyDispositionsMeasured-7804.test.tsx`): + * `onQuickAdd` arrives at the board implementation BY IDENTITY through + * `ObjectKanban`'s schema spread, and `onCardClick` — substituted on that + * spread — arrives instead as the React PROP `ObjectKanbanComponentProps` + * declares, which `ObjectKanban`'s own wrapper CALLS. + * + * ⚠️ The THIRD key, `onCardMove`, is deliberately NOT here and is not drift: + * neither face declares it, so `undefined` meets `undefined`. Its authored + * value reaches nothing on this entry — the `'retired'` disposition — and + * `check:handler-key-reads` refuses that spelling while the renderer still + * reads the key, so it keeps its `KNOWN_UNDECLARED_READS` row on + * objectui#7804 rather than joining either face. + */ + 'objectql.zod.ts#ObjectKanbanSchema': 'onCardClick' | 'onQuickAdd'; /** * RUNTIME SLOT (objectui#6124): the `alert-dialog` renderer spreads leftover props * onto the Radix `AlertDialog` root (`onOpenChange`). `onAction` joined with diff --git a/packages/types/src/objectql.ts b/packages/types/src/objectql.ts index 08cd9286cd..1cc1fbd6a6 100644 --- a/packages/types/src/objectql.ts +++ b/packages/types/src/objectql.ts @@ -3281,6 +3281,48 @@ export interface ObjectKanbanSchema extends BaseSchema { * Cards are colored based on field values matching conditions. */ conditionalFormatting?: KanbanConditionalFormattingRule[]; + + /** + * Card click handler. + * + * RUNTIME SLOT (objectui#6124 shape; 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 the function REACHES the board and + * RUNS: `SchemaRenderer` spreads every non-metadata schema key as a React + * prop, `ObjectKanbanComponentProps` declares an `onCardClick` prop, and + * `ObjectKanban`'s own click wrapper calls it. + * + * ⚠️ It is NOT the function the board implementation receives — `ObjectKanban` + * substitutes its own wrapper on the schema it hands down, because that + * wrapper also owns the record-detail overlay. Reachability here is the PROP + * channel, and that is the whole difference between this key and the sibling + * `onCardMove`, which this face still does NOT declare: "the wrapper + * overrides it" is true of both and separates neither. `onCardMove` has no + * prop channel — `ObjectKanban` declares no such prop and discards its rest + * parameter — so an authored one reaches nothing, which is a `'retired'` + * reading that `check:handler-key-reads` refuses while `KanbanRenderer` still + * reads the key. It keeps its `KNOWN_UNDECLARED_READS` row on objectui#7804. + */ + onCardClick?: (card: any) => void; + + /** + * Quick Add handler. + * + * RUNTIME SLOT (objectui#6124 shape; 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. Kept callable here because + * it rides `ObjectKanban`'s schema spread untouched and arrives at the board + * implementation BY IDENTITY, where it is half of the pair the Quick Add + * control is gated on. + * + * ⚠️ The other half, `quickAdd`, is deliberately undeclared on this face + * pending objectui#8285, and an object-bound board supplies no handler of its + * own — so a JSON author gets no control. That is a statement about the + * document, not about this slot: the slot is live, which is why it keeps its + * function type rather than a tombstone. + */ + onQuickAdd?: (columnId: string, title: string) => void; } /** diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 58d4837f8f..799afd5487 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1452,6 +1452,40 @@ export const ObjectKanbanSchema = BaseSchema.extend({ coverImageField: z.string().optional().describe('Field name for cover image on cards'), allowCollapse: z.boolean().optional().describe('Allow columns to collapse/expand'), conditionalFormatting: z.array(KanbanConditionalFormattingRuleSchema).optional().describe('Card conditional formatting rules'), + // ── objectui#7804 — the three handler keys `KanbanRenderer` reads off the + // document this arm judges, MEASURED one at a time (director seat ruling of + // 2026-09-07, decision batch #69: the arm a `type` selects is the contract + // for what renders under it, and a registered renderer may not read a key the + // arm does not declare). + // + // Until here they were declared by NOTHING. `BaseSchema` is `.passthrough()`, + // so an authored `onCardClick: { action: 'toast' }` was not refused — it + // stopped being judged, the value was KEPT, and it was handed to a call site + // expecting a function. That is objectui#7664's measured transition; the + // three sat on the bare `kanban` arm as objectui#6124 RUNTIME SLOTS until + // objectui#8802 retired that arm, and the surviving `object-kanban` face + // inherited the reads without the declarations. + // + // ⛔ The three do NOT share a disposition, and sharing one because they share + // a prefix is the error this ruling forbids. The per-key channel readings and + // the drive that separates them are in `@object-ui/plugin-kanban`'s + // `__tests__/handlerKeyDispositionsMeasured-7804.test.tsx`; the twin + // docblocks in `../objectql.ts` carry the reasons member by member. + // + // ⚠️ TWO of the three land here. `onCardMove` is the third and it is NOT + // declared, deliberately: its authored value is measured to reach NOTHING on + // this entry (`ObjectKanban` substitutes its own mover and declares no + // `onCardMove` React prop), which is the `'retired'` disposition — and + // `check:handler-key-reads` REFUSES that spelling while `KanbanRenderer` + // still reads the key off the document it is handed, printing + // `declares it RETIRED, but a renderer still reads it`. Closing that needs + // the READ to move to an explicit React prop — the objectui#7742 remedy this + // same file already applied to `objectFields` — which narrows a published + // component's props and is a ruling, not a repair. So the key keeps its + // `KNOWN_UNDECLARED_READS` row naming objectui#7804, which stays open and + // stays the parent, and this arm does not pretend to judge it. + onCardClick: handlerKeyRefusal('onCardClick', 'runtime-slot', 'Card click handler'), + onQuickAdd: handlerKeyRefusal('onQuickAdd', 'runtime-slot', 'Quick Add handler'), }).superRefine(requireKanbanRecordSource); /** diff --git a/scripts/__tests__/check-handler-key-read-sites.test.ts b/scripts/__tests__/check-handler-key-read-sites.test.ts index 3c4056859a..5b2d669ff1 100644 --- a/scripts/__tests__/check-handler-key-read-sites.test.ts +++ b/scripts/__tests__/check-handler-key-read-sites.test.ts @@ -445,27 +445,41 @@ describe('check-handler-key-read-sites — this repository', () => { // stopped following the document would leave the green above intact while // losing exactly the instance the card was filed for. // - // ⚠️ Re-keyed by objectui#8802, and the re-key CHANGES ONE READING rather + // ⚠️ Re-keyed by objectui#8802, and the re-key CHANGED ONE READING rather // than merely renaming a string. The rows used to be `kanban.*`, DECLARED, // carrying the RUNTIME SLOT disposition off the `'kanban'` Zod arm. That - // arm retired with the bare node type key, and the surviving - // `object-kanban` face declares none of the three — so the walk still finds - // all three reads (which is what this leg is for) and now reports them - // UNDECLARED, waived by the `object-kanban::…` rows objectui#7804 already - // owns in `KNOWN_UNDECLARED_READS`. ⛔ Not repaired here: declaring them on - // `ObjectKanbanSchema` widens a published accept set, which is a ruling. + // arm retired with the bare node type key and the surviving `object-kanban` + // face declared none of the three, so the walk went on finding all three + // reads (which is what this leg is for) and reported them UNDECLARED. + // + // ⭐ objectui#7804 closed TWO of them on the surviving face, each measured + // at its own channel, and the split is asserted rather than averaged: a + // reading that put all three in one bucket would be the error that ruling + // forbids. `onCardMove` is the third — its authored value reaches nothing + // on this entry, which is the `'retired'` disposition, and THIS GATE + // refuses that spelling while `KanbanRenderer` still reads the key — so it + // keeps its `KNOWN_UNDECLARED_READS` row on objectui#7804. const judged = result.census.map((c) => `${c.type}.${c.key}`); expect(judged).toContain('object-kanban.onCardClick'); expect(judged).toContain('object-kanban.onCardMove'); expect(judged).toContain('object-kanban.onQuickAdd'); - for (const key of ['onCardClick', 'onCardMove', 'onQuickAdd']) { - const row = result.census.find((c) => c.type === 'object-kanban' && c.key === key); - expect(row?.declared, `'object-kanban'.${key} is undeclared since objectui#8802`).toBe(false); - } + const kanbanRow = (key: string) => + result.census.find((c) => c.type === 'object-kanban' && c.key === key); + expect( + ['onCardClick', 'onCardMove', 'onQuickAdd'].map((key) => ({ + key, + declared: kanbanRow(key)?.declared, + disposition: kanbanRow(key)?.disposition, + })), + ).toEqual([ + { key: 'onCardClick', declared: true, disposition: 'runtime-slot' }, + { key: 'onCardMove', declared: false, disposition: undefined }, + { key: 'onQuickAdd', declared: true, disposition: 'runtime-slot' }, + ]); - // FIRING CONTROL for the `false`s above: the census still reports DECLARED - // runtime slots elsewhere, so `declared: false` is a reading about this face - // and not a census that lost its dispositions. + // FIRING CONTROL for the `false` above: the census still reports DECLARED + // runtime slots elsewhere, so `declared: false` is a reading about that one + // key and not a census that lost its dispositions. const chatbotSend = result.census.find((c) => c.type === 'chatbot' && c.key === 'onSend'); expect(chatbotSend?.declared).toBe(true); expect(chatbotSend?.disposition).toBe('runtime-slot'); @@ -522,9 +536,21 @@ describe('check-handler-key-read-sites — this repository', () => { // It declares real members — the anti-vacuity half, so "resolves completely" // is not satisfied by an empty arm. expect(objectKanban?.members.has('groupBy')).toBe(true); - // ⛔ And it declares NONE of the three handler keys the plugin reads. That is - // the reading objectui#8802 moved; it is recorded, not repaired. - expect(objectKanban?.members.get('onCardClick')).toBeUndefined(); + // ⭐ And the resolver reads its handler dispositions PER KEY, which is what + // objectui#7804 measured this face on: two of the three keys the plugin + // reads are objectui#6124 RUNTIME SLOTS, and `onCardMove` is declared by + // nothing — its authored value reaches neither channel, which is the + // `'retired'` disposition, and this gate refuses that spelling while the + // renderer still reads the key. + expect({ + onCardClick: objectKanban?.members.get('onCardClick'), + onCardMove: objectKanban?.members.get('onCardMove'), + onQuickAdd: objectKanban?.members.get('onQuickAdd'), + }).toEqual({ + onCardClick: 'runtime-slot', + onCardMove: undefined, + onQuickAdd: 'runtime-slot', + }); // A live arm that still carries both dispositions, so this leg keeps // proving the resolver can read them at all. diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index 8bd5e448c8..cef801d6a6 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -160,9 +160,26 @@ export const KNOWN_UNDECLARED_READS = new Map([ ['form::FormSchema.onSuccess', 'objectui#7804'], ['object-grid::ObjectGridSchema.onNavigate', 'objectui#7804'], ['grid::GridSchema.onNavigate', 'objectui#7804'], - ['object-kanban::ObjectKanbanSchema.onCardClick', 'objectui#7804'], + // ⭐ TWO of the three `object-kanban::ObjectKanbanSchema` rows LANDED and are + // gone (objectui#7804's `plugin-kanban` slice): `onCardClick` and + // `onQuickAdd` are now objectui#6124 RUNTIME SLOTS on the arm, each measured + // at its own channel — the first by the React PROP `ObjectKanban` declares + // and its click wrapper CALLS, the second by identity through the schema + // spread. Draining them is part of the landing, not cleanup after it: a row + // that outlived its read reddens `staleExemptions()` below. + // + // ⚠️ `onCardMove` STAYS, and the row is not a leftover. Measured on the same + // instrument and driven rather than inferred, an authored `onCardMove` on an + // `object-kanban` document reaches NOTHING — `ObjectKanban` substitutes its + // own mover on the schema it hands down and declares no `onCardMove` React + // prop — so the objectui#6124 disposition is `'retired'`, and THIS GATE + // refuses that spelling while `KanbanRenderer` still reads the key + // (`retired-but-read`). The exit is to move the READ to an explicit React + // prop, the objectui#7742 remedy already applied to `objectFields` one file + // over, which narrows a published component's props and is a ruling rather + // than a repair. So the key stays undeclared, accepted and KEPT, and the row + // keeps naming objectui#7804 — which stays open and stays the parent. ['object-kanban::ObjectKanbanSchema.onCardMove', 'objectui#7804'], - ['object-kanban::ObjectKanbanSchema.onQuickAdd', 'objectui#7804'], ['list-view::ListViewSchema.onAddRecord', 'objectui#7804'], ['list-view::ListViewSchema.onBulkAction', 'objectui#7804'], ['list-view::ListViewSchema.onDensityChange', 'objectui#7804'],