diff --git a/.changeset/7804-detail-handler-slots-declared.md b/.changeset/7804-detail-handler-slots-declared.md new file mode 100644 index 0000000000..0ec5346411 --- /dev/null +++ b/.changeset/7804-detail-handler-slots-declared.md @@ -0,0 +1,45 @@ +--- +'@object-ui/types': minor +--- + +Declare the two handler keys the `'detail'` renderer reads (objectui#7804, the +`plugin-detail` slice; director seat, decision batch #69, 2026-09-07). + +`DetailSchema` — the zod arm `type: 'detail'` selects — now declares +`onNavigate` and `onAddComment` as objectui#6124 RUNTIME SLOTS: a named refusal +on the JSON face, a callable twin on the TypeScript face. + +**Breaking, and measured.** `BaseSchemaCore` ends `.passthrough()`, so a key an +arm does not declare is not refused — it stops being judged and the value is +KEPT. Both keys were declared on NEITHER face while `DetailView` read and RAN +them. Measured on the unmodified arm: + +- `{ "type": "detail", "onNavigate": { "action": "toast" } }` parsed GREEN, with + `{"action":"toast"}` surviving into the parsed output; clicking Back then + reported `TypeError: schema.onNavigate is not a function`. +- the same document spelling `onAddComment` parsed GREEN the same way, and the + value was forwarded into the comment composer that awaits it. +- `onBack` — already a named refusal on the same arm — was refused on the same + document, which is the control proving the probe could see a refusal. + +After this change both keys are refused BY NAME with the objectui#6124 guidance +(issue `code: 'custom'` at the key's own path) and the message points at the +node-type spelling. A version shipped as `minor` because this package ships +inside the `fixed` group `.changeset/config.json` enumerates, where any +`major` would carry every member with it, so `major` is unavailable +(`scripts/check-changeset-no-major.mjs`); the accept-set move is the breaking +part. + +**Migration.** Nothing in the corpus has to change: no authored `'detail'` +document in this repository, its examples or its docs writes either key — they +were only ever reachable as host-supplied functions. A React host keeps +supplying them exactly as before, through the TypeScript interface, which now +declares the signature the call site builds (`onNavigate(url, { replace })`, +`onAddComment(text)`) instead of leaving it to `BaseSchema`'s `any`-valued index +signature. A document that *did* author either key was never running anything: +it was being handed an object where a function was expected. + +Per key, not per prefix: the two reach the renderer on different channels — +`onNavigate` is called in `DetailView`'s own body, `onAddComment` is forwarded +as a prop into the comment composer — and both were driven through the real +`SchemaRenderer` before the disposition was assigned. diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 94a65a7e1d..de0e6c95bf 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -712,6 +712,8 @@ A single-record detail view with grouped fields, actions, and tabs. | `showBack` | `boolean` | Show a back navigation button. | | `loading` | `boolean` | Show loading state. | +> **Handler keys are not authorable in JSON, and this face refuses three of them by name.** `onBack` has been a named refusal since objectui#7344; since objectui#7804 this face also declares `onNavigate` and `onAddComment` as objectui#6124 **runtime slots**: a React host supplies the function through the TypeScript interface or as a React prop, and the validator **refuses the key by name** — the message leads with the slot's label (`SPA navigation callback`, `New comment callback`), states that the key "is a RUNTIME SLOT for a host-supplied function, not authorable metadata (objectui#6124): JSON has no function value, and no handler key consumes a declarative action object", and closes by pointing at the node-type spelling (`{ "type": "toast", … }`, an `action:button` node). Until then an authored `onNavigate: { "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 — and because every read site only tests the key for truthiness, the kept object then reached a call site expecting a function: `handleBack`, `handleEdit` and the post-delete redirect call `schema.onNavigate(url, { replace })` in `DetailView`'s own body, while `onAddComment` is forwarded as a prop into the comment composer, which renders *because* the key is truthy and then awaits it on send. ⚠️ `onTabChange` is a fourth handler key this view reads and it is **still undeclared** — and where the `object-kanban` board's third key, `onCardMove`, has been a **tombstone** refused by name since objectui#9342, an authored `onTabChange` is not refused at all: it is **kept**, read through a cast and handed to the tab strip's `onValueChange`, so it still reaches a call site expecting a function at the first tab switch. That gap is open on objectui#7804. + **Related:** [DetailViewSchema](#detailviewschema), [ObjectGridSchema](#objectgridschema) --- diff --git a/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx new file mode 100644 index 0000000000..97eea6c345 --- /dev/null +++ b/packages/plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx @@ -0,0 +1,287 @@ +/** + * 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. + */ + +/** + * Which authored `on*` keys reach the registered `'detail'` renderer — measured + * per key, driven through the real `SchemaRenderer` — and the guard that a bare + * deletion of either declaration goes red (objectui#7804, the `plugin-detail` + * slice of the `KNOWN_UNDECLARED_READS` ledger — ⛔ that ledger's size is NOT + * written down here: it is whatever `node scripts/check-handler-key-read-sites.mjs` + * prints, and the hard-coded figure this line used to carry went stale inside + * the life of this branch. AGENTS.md #9). + * + * ## The exposure + * + * `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. `ComponentRegistry.register('detail', DetailView)` + * registers `DetailView` RAW (no wrapper, `../index.tsx`), so nothing is + * interposed: the authored value arrives at `schema.onNavigate` / + * `schema.onAddComment` BY IDENTITY and is CALLED. An authored + * `onNavigate: { action: 'toast' }` therefore parses GREEN and is handed to a + * call site expecting a function — objectui#7664's measured mechanism, and the + * shape `AlertDialogSchema.onAction` had until objectui#7104 declared it. + * + * ## Per-key disposition, MEASURED not assumed (the batch #69 ruling) + * + * Both keys measure `'runtime-slot'`, and they do NOT share one channel: + * + * - `onNavigate` — read in `DetailView`'s OWN body (`handleBack`, + * `handleEdit`, the post-delete redirect) and CALLED there. Suite 1 drives + * the back button and the authored function runs with the arguments the + * call site builds. + * - `onAddComment` — never called by `DetailView`; it is FORWARDED as a React + * prop into ``, whose `handleSubmit` awaits it. Suite 1 + * drives the composer, so the leg measures the forward AND the call at the + * other end. It is also gated by `schema.comments`, itself an undeclared + * key kept alive by the same passthrough. + * + * ⇒ two keys, two channels, one disposition — which is a reading, not an + * assumption: the sibling slice on `object-kanban` took three keys sharing one + * prefix and measured them into three DIFFERENT channels carrying only TWO + * dispositions — two of them are RUNTIME SLOTS, and the third, `onCardMove`, + * is a TOMBSTONE. That is what the kanban slice's own `objectql.zod.ts` + * docblock says in as many words, and this sentence agrees with it. + * + * ⭐ That slice is precisely where the two counts COME APART, which is a + * SHARPER case for measuring per key than three-out-of-three would have been. + * Neither count can be read off the other: `HandlerKeyDisposition` offers + * exactly TWO values, so three keys can never carry three distinct + * dispositions — while the routes by which a key reaches a renderer are + * bounded by nothing, and there were three. ⛔ A per-prefix reading collapses + * both counts to one and cannot say which of the two it lost; only the per-key + * channel reading recovers them. + * + * ## Every control can fire + * + * `onNeverReadByAnyKnownReader` is authored on the SAME document, in the SAME + * render, and must stay at zero calls. Without it a harness that fired every + * authored function would read exactly like a live channel. + * + * ## Predictions, written before the code (red-first, base `a686403b3`) + * + * - suite 1 (reachability) PASSES on the base tree — the channels are what + * this change declares, not what it builds; + * - suite 2 (the hazard) PASSES on the base tree for the same reason; + * - suite 3 (the zod face) FAILS on both keys: `DetailSchema.shape.onNavigate` + * and `.onAddComment` are `undefined`, and an authored action object parses + * GREEN and survives into the parsed output. `onBack` — already a #6124 + * runtime-slot refusal on this very arm — is the lit control and is GREEN + * before and after; + * - suite 4 (the derivation) FAILS on both keys for the same reason. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, fireEvent, screen, waitFor, cleanup } from '@testing-library/react'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { DetailSchema as DetailZod } from '@object-ui/types/zod'; +import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react'; +// Module-scope side-effect imports: the registry must hold `'detail'` and the +// widgets it renders into before the first render (AGENTS.md, test discipline). +import '@object-ui/components'; +import '@object-ui/fields'; +import '../index'; + +afterEach(() => cleanup()); + +/** The corpus spelling an author learns — a declarative action object where a + * function is expected. objectui#7664 measured this exact value surviving. */ +const AUTHORED_ACTION_OBJECT = { action: 'toast', title: 'Saved', variant: 'success' }; + +/** Render an authored `'detail'` document the way production does. */ +function renderDetail(schema: Record) { + return render( + + + , + ); +} + +/* -- Suite 1: reachability, per key, driven ------------------------------- */ + +describe('an authored handler key reaches the registered `detail` renderer (objectui#7804)', () => { + it('onNavigate: the authored function is CALLED by DetailView, with the arguments the call site builds', () => { + const onNavigate = vi.fn(); + const onNeverReadByAnyKnownReader = vi.fn(); + renderDetail({ onNavigate, onNeverReadByAnyKnownReader }); + + // No `data` and no fetch inputs -> the not-found panel, whose "Go back" + // button is wired to the same `handleBack` as the header's. + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + + expect(onNavigate).toHaveBeenCalledTimes(1); + expect(onNavigate).toHaveBeenCalledWith('/', { replace: true }); + // The control: same document, same render, a key no reader names. + expect(onNeverReadByAnyKnownReader).toHaveBeenCalledTimes(0); + }); + + it('onAddComment: forwarded into RecordComments as a prop and awaited there', async () => { + const onAddComment = vi.fn(); + const onNeverReadByAnyKnownReader = vi.fn(); + renderDetail({ + data: { id: '1', name: 'Acme' }, + // `comments` is itself undeclared on the arm and kept by the same + // passthrough — the gate this key sits behind. + comments: [{ id: 'c1', author: 'Ada', content: 'first', createdAt: '2026-01-01T00:00:00Z' }], + onAddComment, + onNeverReadByAnyKnownReader, + }); + + const box = await screen.findByPlaceholderText(/Add a comment/i); + fireEvent.change(box, { target: { value: 'measured' } }); + const send = box.parentElement!.querySelector('button')!; + fireEvent.click(send); + + await waitFor(() => expect(onAddComment).toHaveBeenCalledTimes(1)); + expect(onAddComment).toHaveBeenCalledWith('measured'); + expect(onNeverReadByAnyKnownReader).toHaveBeenCalledTimes(0); + }); +}); + +/* -- Suite 2: the hazard the disposition names ---------------------------- */ + +describe('the value the passthrough keeps is handed to a call site expecting a function', () => { + /** + * Every string React or the runtime reported while `body()` ran. + * + * ⚠️ Shaped by a MISSED prediction, recorded rather than smoothed: this leg + * was first written as `expect(() => fireEvent.click(...)).toThrow()`, on the + * assumption that a handler error propagates out of the dispatch. It does + * not — React 19 REPORTS it (the `TypeError: schema.onNavigate is not a + * function` this leg now reads) and the click returns normally. An + * `expect(...).toThrow()` here would have been a green assertion about a + * hazard that never fired. + */ + function reportedWhile(body: () => void): string[] { + const seen: string[] = []; + const onError = (e: Event) => seen.push(String((e as ErrorEvent).error ?? e)); + const spy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + seen.push(args.map((a) => String(a)).join(' ')); + }); + window.addEventListener('error', onError); + try { + body(); + } catch (err) { + seen.push(String(err)); + } finally { + window.removeEventListener('error', onError); + spy.mockRestore(); + } + return seen; + } + + it('an authored onNavigate action OBJECT reaches the call site and is invoked as a function', () => { + renderDetail({ onNavigate: AUTHORED_ACTION_OBJECT }); + const reported = reportedWhile(() => { + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + }); + expect( + reported.some((m) => /onNavigate is not a function/.test(m)), + `the kept object was never invoked; reported: ${JSON.stringify(reported)}`, + ).toBe(true); + }); + + it('CONTROL: the same click on a document authoring NO onNavigate reports nothing', () => { + renderDetail({ title: 'Acme' }); + const reported = reportedWhile(() => { + fireEvent.click(screen.getByRole('button', { name: 'Go back' })); + }); + expect(reported.filter((m) => /is not a function/.test(m))).toEqual([]); + }); +}); + +/* -- Suite 3: the zod face ------------------------------------------------ */ + +describe('the `detail` arm declares every handler key its renderer reads (objectui#7804)', () => { + const DECLARED = [['onNavigate'], ['onAddComment']] as const; + /** The same two plus `onBack` — a named refusal on this arm since + * objectui#7344, so it is the LIT CONTROL: it was refused on the base tree + * too, and a probe that could not see a refusal would have failed on it + * first rather than reporting a clean pair of new ones. */ + const WITH_CONTROL = [...DECLARED, ['onBack']] as const; + + it.each(DECLARED)('DetailSchema.%s is a DECLARED member carrying the objectui#6124 runtime-slot guidance', (key) => { + // Deliberately `.shape`, not `safeParse`: under `.passthrough()` a DELETED + // key still parses green, so a parse-only pin stays green through the very + // deletion it exists to catch. + const member = DetailZod.shape[key] as { description?: string } | undefined; + expect(member).toBeDefined(); + expect(member!.description).toContain('objectui#6124'); + expect(member!.description).toContain('RUNTIME SLOT'); + expect(member!.description).not.toContain('RETIRED'); + }); + + it.each(WITH_CONTROL)('an authored action object on DetailSchema.%s is refused BY NAME', (key) => { + const result = DetailZod.safeParse({ type: 'detail', [key]: AUTHORED_ACTION_OBJECT }); + expect(result.success).toBe(false); + if (result.success) return; + const issue = result.error.issues.find((i) => String(i.path[0]) === key); + expect(issue, `no issue addressed to \`${key}\``).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain(`\`${key}\``); + }); + + it('the arm still parses GREEN without the keys — the refusal is about the key, not the document', () => { + expect(DetailZod.safeParse({ type: 'detail', title: 'Acme' }).success).toBe(true); + }); +}); + +/* -- Suite 4: derived from the read site, so a deletion cannot hide -------- */ + +describe('the declaration is derived from the read site, not from a list (objectui#7804)', () => { + const SOURCE = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '..', 'DetailView.tsx'), + 'utf8', + ); + + /** `schema.onX` — the reads the repo gate `check:handler-key-reads` judges. */ + const plainReads = [...new Set([...SOURCE.matchAll(/\bschema\.(on[A-Z][A-Za-z]*)/g)].map((m) => m[1]))].sort(); + /** `(schema as any).onX` — the same read, behind a cast. */ + const castReads = [...new Set([...SOURCE.matchAll(/\(schema as any\)\.(on[A-Z][A-Za-z]*)/g)].map((m) => m[1]))].sort(); + + it('every plain `schema.on*` read in DetailView is a declared member of the `detail` arm', () => { + expect(plainReads, 'the census instrument found no read at all').not.toEqual([]); + const undeclared = plainReads.filter((k) => !(k in DetailZod.shape)); + expect(undeclared).toEqual([]); + }); + + it('the two keys this card owns are exactly the plain reads DetailView names', () => { + expect(plainReads).toEqual(['onAddComment', 'onNavigate']); + }); + + it('records the ONE cast-spelled read DetailView still has — ledgered here, dispositioned elsewhere', () => { + // `DetailView.tsx` reads `(schema as any).onTabChange` in its `autoTabs` + // branch and forwards it into the `` it renders. + // + // ⚠️ This leg USED TO say that the repo gate could not see a read behind + // that cast, and that the key was therefore absent from the ledger. Both + // halves stopped being true inside the life of this branch: the gate now + // unwraps `as` / `!` / `satisfies` / parens before it names the receiver, + // so this read IS judged and the key IS carried as a + // `KNOWN_UNDECLARED_READS` row attributed to objectui#7804. Kept as a + // record rather than smoothed away, because the assertion below never + // moved — it was a GREEN assertion narrating a fact that had died, which is + // the shape AGENTS.md #9 exists to name. ⛔ Neither the row's position nor + // the ledger's size is written down here; the gate's own + // `check-handler-key-read-sites.mjs --list` enumerates both. + // + // So what this leg measures is the SOURCE spelling, not the gate's reach: + // that `(schema as any)` is still how `DetailView` reads the key, and that + // there is exactly ONE such read. The key's objectui#6124 disposition is + // OPEN — ⛔ not decided here, and ⛔ not decided anywhere else yet. It + // lands in the zod arms, and the ledger row above carries it on + // objectui#7804, which is the card `check-handler-key-read-sites.mjs` + // itself names for this row. `DetailSchema` today declares neither + // `onTabChange` nor the `onValueChange` spelling `TabsSchema` carries for + // the same event. + expect(castReads).toEqual(['onTabChange']); + }); +}); diff --git a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx index 2dc5b7380a..bfa88fb71c 100644 --- a/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx +++ b/packages/plugin-kanban/src/__tests__/handlerKeyDispositionsMeasured-7804.test.tsx @@ -10,9 +10,14 @@ * All three handler keys the kanban board consumes 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 — with the third key landing on objectui#9342, which moved the READ - * that had blocked its tombstone). + * slice of the `KNOWN_UNDECLARED_READS` finding — ⛔ its row count is NOT + * written down here. This file reads that ledger live, through the import + * below, and `node scripts/check-handler-key-read-sites.mjs` prints the count + * while its `--list` enumerates the rows. AGENTS.md #9: the figure this line + * used to hard-code has since been overtaken by the population it described; + * director seat ruling of 2026-09-07, decision batch #69 — with the third key + * landing on objectui#9342, which moved the READ that had blocked its + * tombstone). * * ## The exposure this closes * @@ -451,8 +456,18 @@ describe('suite 4 — the ledger drained with the fix (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. + // + // ⚠️ The WITNESS is re-derived, not decorative. This leg named + // `detail::DetailSchema.onNavigate` until the `plugin-detail` slice of the + // same card declared it and drained the row — a row this slice did not + // take, taken by a sibling slice that landed after it. That is the shape + // to expect here: the witness is only ever a row no LANDED slice has + // closed yet, so when its own slice lands, re-derive it against + // `KNOWN_UNDECLARED_READS` rather than dropping the name and leaving the + // length check alone — the length alone passes on a map holding one stale + // row, which is the reading this control exists to refuse. const remaining = [...ledger.keys()]; expect(remaining.length).toBeGreaterThan(0); - expect(remaining).toContain('detail::DetailSchema.onNavigate'); + expect(remaining).toContain('button::ButtonSchema.onSuccess'); }); }); diff --git a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts index cbeb46e314..813d273bd4 100644 --- a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts +++ b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -47,6 +47,27 @@ * now declares what the renderer invokes. * - `crud.zod.ts#DetailSchema.onBack` — `ComponentRegistry.register('detail', * DetailView)` (`plugin-detail/src/index.tsx`), the same `handleBack`. + * - `crud.zod.ts#DetailSchema.onNavigate` and `.onAddComment` — objectui#7804, + * added on the batch #69 ruling. ⚠️ They do NOT belong to this file's + * origin story: neither was ever `z.string()` or `z.any()`, so #7339's + * anchor could not have missed them — they were declared on NEITHER face, + * which is why `.passthrough()` KEPT them and the repo gate + * `check:handler-key-reads` carried them as `KNOWN_UNDECLARED_READS` rows. + * They are ledgered here because this file owns the `crud.zod.ts#DetailSchema` + * pair, and its per-key assertions are exactly what they need. + * Measured per key, and the two channels are NOT the same one: + * - `onNavigate` — `DetailView` reads it in its OWN body and CALLS it + * (`handleBack`, `handleEdit`, the post-delete redirect). The `'detail'` + * registration is the RAW component, so the authored value arrives by + * identity with nothing interposed. + * - `onAddComment` — `DetailView` never calls it; it FORWARDS it as a + * React prop into ``, whose submit handler awaits it, + * behind a `schema.comments` gate that the same passthrough keeps alive. + * Both driven through the real `SchemaRenderer` in + * `plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx`, where + * the base reading was: an authored `{ action: 'toast' }` parsed GREEN on + * both keys and survived into the parsed output, while `onBack` — the lit + * control on the same arm — was refused. * - `crud.zod.ts#ActionSchema.onClick` — `ActionRunner.ts` `await * action.onClick()` (two sites); `action-menu.tsx`, `containers.tsx`, * `record-quick-actions.tsx` all `typeof action.onClick === 'function'`. @@ -174,11 +195,17 @@ const objectOf = (mirror: z.ZodType, key: string): z.ZodObject => return obj; }; -/** The four keys whose function value REACHES a renderer (channels above). */ +/** The six keys whose function value REACHES a renderer (channels above). */ const RUNTIME_SLOT: readonly Site[] = [ ['views.zod.ts', 'DetailViewSchema', 'onBack', DetailViewZod], ['crud.zod.ts', 'ActionSchema', 'onClick', ActionZod], ['crud.zod.ts', 'DetailSchema', 'onBack', DetailZod], + // objectui#7804 — the two keys `DetailView` reads off a `'detail'` document + // that its arm never declared. They arrive by a DIFFERENT route from the four + // above (never `z.string()` / `z.any()`, simply absent), and on two different + // channels from each other; see the docblock's objectui#7804 section. + ['crud.zod.ts', 'DetailSchema', 'onNavigate', DetailZod], + ['crud.zod.ts', 'DetailSchema', 'onAddComment', DetailZod], ['complex.zod.ts', 'CalendarViewSchema', 'onEventClick', CalendarViewZod], ]; @@ -284,11 +311,15 @@ describe('census: the only on*: z.(function|string|any) lines left in packages/t expect(MIRROR_FILES.length).toBeGreaterThanOrEqual(12); }); - it('8 sites are ledgered, 4 runtime slots + 4 retired, with no key filed twice', () => { - expect(RUNTIME_SLOT).toHaveLength(4); + it('10 sites are ledgered, 6 runtime slots + 4 retired, with no key filed twice', () => { + // 8 at objectui#7344; 10 since objectui#7804 declared the two keys + // `DetailView` reads off a `'detail'` document undeclared. ⛔ A ledger + // GROWS here by a declaration landing, never by a key being reclassified + // in place — the shrink direction is the failure this family guards. + expect(RUNTIME_SLOT).toHaveLength(6); expect(RETIRED).toHaveLength(4); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(8); + expect(new Set(ids).size).toBe(10); }); 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) => { @@ -752,6 +783,15 @@ export type assertionRetiredKeysAreTombstoned = [ export type assertionRuntimeSlotsKeepTheirFunctionType = [ Expect>, Expect>, + // objectui#7804, listed here for uniformity but ⛔ NOT the assertion that + // holds them: these two were UNDECLARED, not `string` / `any`, so their base + // state was `BaseSchema`'s index signature — and `KeepsFunction` is + // `true` (`[any] extends [never]` is false). On this pair the helper CANNOT + // FAIL, which is exactly the reason `RetiredIsNever` above is spelled with + // `Equal`. `assertionDetailSlotsAreDECLARED` below is the one with a control + // that fires. + Expect>, + Expect>, Expect>, Expect>, ]; @@ -764,7 +804,33 @@ export type assertionStringTwinsStopDeclaringString = [ Expect>, ]; -// The three helpers must be able to FAIL — synthetic controls, both directions. +/** + * The member is DECLARED on the interface, not inherited from `BaseSchema`'s + * `[key: string]: any` index signature. + * + * ⚠️ This exists because objectui#7804's two keys enter this ledger from a base + * state the other four never had: ABSENT. `Extract`-based helpers read an + * absent member as `any` and answer `true` for it, so a one-way check would + * have passed on the unmodified tree and asserted nothing. `Equal` separates + * `any` from a real declaration, which is the same reason `RetiredIsNever` is + * spelled with it. + */ +type DeclaresExactly = Equal; + +/** objectui#7804 — the two `'detail'` slots declare the signature their call + * site builds, and the control proves the check can fail on an ABSENT member. */ +export type assertionDetailSlotsAreDECLARED = [ + Expect void>>, + Expect void | Promise>>, +]; + +// The four helpers must be able to FAIL — synthetic controls, both directions. +export type assertionDeclaresExactlyCanFail = [ + // an ABSENT member, as `BaseSchema`'s index signature types it + Expect void>, false>>, + // a DECLARED member with the wrong signature + Expect void) | undefined, () => void>, false>>, +]; export type assertionRetiredIsNeverCanFail = Expect void) | undefined>, false>>; export type assertionKeepsFunctionCanFail = Expect, false>>; export type assertionStringIsGoneCanFail = Expect, false>>; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index d2647911e1..9a316eef6a 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -129,8 +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`, **63 keys** across them — 40 / 61 until - * objectui#7804 DECLARED `objectql.zod.ts#ObjectKanbanSchema`'s + * - **41 entries** in `KnownDrift`, **65 keys** across them — 41 / 63 until + * objectui#7804's `plugin-detail` slice (batch #69) DECLARED `onNavigate` + * and `onAddComment` on `crud.zod.ts#DetailSchema`, an existing entry (so + * the entry count did not move). ⭐ The first keys this ledger has gained + * from a pair that declared them on NEITHER face: the TypeScript side typed + * them `any` through `BaseSchema`'s index signature and the mirror kept + * them through `.passthrough()`, while the registered renderer read and RAN + * them — so the pair was not "in parity", it was mutually silent, and + * declaring the callable twin against the mirror's named refusal is what + * makes the drift visible. Growth by REPAIR, in a ledger whose entries + * usually shrink by it. ⚠️ Two slices of objectui#7804 land in this bullet + * back to back and both are real, and neither is a copy-paste of the other: + * the `plugin-detail` one here, the `plugin-kanban` one directly below. + * It was 40 / 61 until + * objectui#7804's `plugin-kanban` slice 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 @@ -1527,8 +1541,19 @@ interface KnownDrift { * RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — `DetailView`'s * `handleBack` calls `onBack()` when set. The mirror was `z.any()` (wider than * the declared callable, objectui#7069's direction); it now refuses by name. + * + * `onNavigate` and `onAddComment` joined with objectui#7804 (batch #69), and + * they drift for the SAME reason `onBack` does — a callable twin against a + * named refusal — but they reach that state from the opposite side. `onBack` + * was declared on both faces and the mirror was too WIDE. These two were + * declared on NEITHER: the TypeScript face typed them `any` through + * `BaseSchema`'s index signature and the mirror kept them through + * `.passthrough()`, while `DetailView` read and ran them. ⇒ this entry + * GREW by a repair, which is the direction this ledger's drift entries + * normally shrink in; the growth is the declaration arriving, not a + * regression. */ - 'crud.zod.ts#DetailSchema': 'onBack'; + 'crud.zod.ts#DetailSchema': 'onBack' | 'onNavigate' | 'onAddComment'; /** * `rowActions` was the FIFTH key here until objectui#6940 settled the ruling * this entry was explicitly waiting on. It read: DISJOINT — TS declares diff --git a/packages/types/src/crud.ts b/packages/types/src/crud.ts index e8a9665fa1..31aefc3eeb 100644 --- a/packages/types/src/crud.ts +++ b/packages/types/src/crud.ts @@ -319,6 +319,31 @@ export interface DetailSchema extends BaseSchema { * which accepted `z.any()`, now refuses the key by name. */ onBack?: () => void; + /** + * SPA navigation callback — RUNTIME SLOT (objectui#7804, the objectui#6124 + * shape): a host-supplied function, NOT authorable metadata. `'detail'` is + * registered to `DetailView` RAW, so an authored value reaches + * `schema.onNavigate` by identity and `handleBack` / `handleEdit` / the + * post-delete redirect CALL it. Until this card the key was declared on + * NEITHER face: `BaseSchema`'s index signature typed it `any` here and the + * zod mirror's `.passthrough()` kept it there, so an authored + * `{ "action": "toast" }` parsed green and threw `schema.onNavigate is not a + * function` at click. The zod twin now refuses the key by name; supply it + * from a React host. + * + * Signature taken from the call site (`(url, { replace })`), which is the + * same one `views.ts#DetailViewSchema.onNavigate` declares — one component, + * two registrations, one contract. + */ + onNavigate?: (url: string, options?: { replace?: boolean; newTab?: boolean }) => void; + /** + * New comment callback — RUNTIME SLOT (objectui#7804), reaching the renderer + * on a DIFFERENT channel from {@link DetailSchema.onNavigate}: `DetailView` + * does not call it, it forwards it as a prop into ``, whose + * submit handler awaits it. Measured per key rather than per prefix, as the + * batch #69 ruling requires. + */ + onAddComment?: (text: string) => void | Promise; /** * Force the loading skeleton. * diff --git a/packages/types/src/zod/crud.zod.ts b/packages/types/src/zod/crud.zod.ts index a5e45b4c7b..557c86b6ba 100644 --- a/packages/types/src/zod/crud.zod.ts +++ b/packages/types/src/zod/crud.zod.ts @@ -175,6 +175,51 @@ export const DetailSchema = BaseSchema.extend({ // RUNTIME SLOT (objectui#7344): `register('detail', DetailView)` — the same // `handleBack` that calls `onBack()` for `detail-view`. Was `z.any()`. onBack: handlerKeyRefusal('onBack', 'runtime-slot', 'Custom back action'), + /** + * RUNTIME SLOT (objectui#7804, the objectui#6124 shape, batch #69 ruling) — + * DECLARED here for the first time; it was never on this arm at all, so + * `BaseSchemaCore`'s `.passthrough()` KEPT an authored value instead of + * refusing it and handed it to a call site expecting a function. + * + * ## The channel, measured — not the same one `onAddComment` uses + * + * `ComponentRegistry.register('detail', DetailView)` registers `DetailView` + * RAW (no wrapper, unlike `'detail-view'`), so nothing is interposed: an + * authored value arrives at `schema.onNavigate` BY IDENTITY, and `DetailView` + * CALLS it in its own body — `handleBack`, `handleEdit`, and the post-delete + * redirect. Driven through the real `SchemaRenderer` in + * `plugin-detail/src/__tests__/detail-handler-slots-7804.test.tsx`: the + * authored function runs, with the arguments the call site builds. + * + * The base reading that made this a defect, measured on the unmodified arm: + * `{ type: 'detail', onNavigate: { action: 'toast' } }` parsed GREEN with + * `{"action":"toast"}` surviving into the parsed output, and clicking Back + * then reported `TypeError: schema.onNavigate is not a function`. The sibling + * `onBack` above — already a named refusal — was the lit control and was + * refused on the same document. + * + * The signature the TypeScript twin declares is the one the call site builds: + * `(url, { replace })`. Same spelling as `views.ts#DetailViewSchema.onNavigate`, + * because it is the same component reading it under the other registration. + */ + onNavigate: handlerKeyRefusal('onNavigate', 'runtime-slot', 'SPA navigation callback'), + /** + * RUNTIME SLOT (objectui#7804), and a DIFFERENT channel from `onNavigate` + * above — which is why the ruling demands a measurement per key rather than + * per prefix. + * + * `DetailView` never calls this one. It FORWARDS it as a React prop into the + * `` it renders, whose submit handler awaits it; the forward + * is itself gated by `schema.comments`, an undeclared key the same + * passthrough keeps alive. Driven end to end in the same probe: the composer + * is typed into, the send button clicked, and the authored function runs with + * the text. + * + * ⚠️ `comments` is deliberately NOT declared here. It is not a handler key, + * declaring it is an accept-set decision of its own, and objectui#7804's rows + * are the handler keys — noted on that card instead of ridden in on this one. + */ + onAddComment: handlerKeyRefusal('onAddComment', 'runtime-slot', 'New comment callback'), loading: z.boolean().optional().describe('Whether to show loading state'), }); diff --git a/scripts/check-handler-key-read-sites.mjs b/scripts/check-handler-key-read-sites.mjs index fec1ae8eef..92b403285f 100644 --- a/scripts/check-handler-key-read-sites.mjs +++ b/scripts/check-handler-key-read-sites.mjs @@ -147,8 +147,6 @@ export const KNOWN_UNDECLARED_READS = new Map([ ['data-table::DataTableSchema.onRowClick', 'objectui#7804'], ['data-table::DataTableSchema.onRowSave', 'objectui#7804'], ['tree-view::TreeViewSchema.onNodeClick', 'objectui#7804'], - ['detail::DetailSchema.onAddComment', 'objectui#7804'], - ['detail::DetailSchema.onNavigate', 'objectui#7804'], ['object-form::ObjectFormSchema.onCancel', 'objectui#7804'], ['object-form::ObjectFormSchema.onError', 'objectui#7804'], ['object-form::ObjectFormSchema.onOpenChange', 'objectui#7804'],