From 8c1c4019d37af7fffe1552028483dd708308932b Mon Sep 17 00:00:00 2001 From: os-justin Date: Sun, 6 Sep 2026 20:19:05 +0000 Subject: [PATCH] fix(types): SchemaRegistry names all three chatbot registrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `packages/plugin-chatbot/src/renderer.tsx` registers `chatbot`, `chatbot-enhanced` and `chatbot-floating`; `SchemaRegistry` mapped one of them. `ComponentType = keyof SchemaRegistry` is the published union, so a consumer discriminating on it was told two registered keys do not exist — while `packages/cli/src/utils/known-schema-types.ts` kept its own parallel list containing both, precisely because this map did not. The two entries can be honest and reachable only since objectui#7655 published `ChatbotEnhancedSchema` / `ChatbotFloatingSchema` from this package: each pins `type` to its own key, and each registration already takes that exact type as its `schema` parameter, so the map's value and the renderer's prop type are one declaration. Unlike the `kanban` case objectui#7645 measured, naming them costs this zero-workspace-dependency layer no plugin import. Pinned in two channels: compile-time (`tsc -p tsconfig.test.json` — the keys survive in `keyof`, each value IS its face, each value's `type` literal IS its key) and runtime (an AST census of the interface source, plus each key selecting its own arm through `safeValidateSchema`). Scope is these two keys, not a sweep of the map's other entries. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S --- .../7704-schema-registry-chatbot-keys.md | 42 ++++ .../schema-registry-chatbot-keys-7704.test.ts | 209 ++++++++++++++++++ packages/types/src/registry.ts | 37 ++++ 3 files changed, 288 insertions(+) create mode 100644 .changeset/7704-schema-registry-chatbot-keys.md create mode 100644 packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts diff --git a/.changeset/7704-schema-registry-chatbot-keys.md b/.changeset/7704-schema-registry-chatbot-keys.md new file mode 100644 index 0000000000..aaf1ecec86 --- /dev/null +++ b/.changeset/7704-schema-registry-chatbot-keys.md @@ -0,0 +1,42 @@ +--- +'@object-ui/types': minor +--- + +`SchemaRegistry` names all three `plugin-chatbot` registrations, so the published +`ComponentType` union does too (objectui#7704). + +`packages/plugin-chatbot/src/renderer.tsx` registers three components — `chatbot`, +`chatbot-enhanced` and `chatbot-floating` — and `SchemaRegistry` mapped one of them. +Since `ComponentType = keyof SchemaRegistry` is the published union, a consumer +discriminating on it was told two registered keys do not exist: an author narrowing a +node by `ComponentType`, or writing a `Record` table, had no arm for +either. The asymmetry that showed which half was wrong is that +`packages/cli/src/utils/known-schema-types.ts` keeps its own parallel list containing +both keys, precisely because this map did not. + +**Additive.** Two keys join the map under its `// Complex` group; no existing entry +changes, `'chatbot'` still maps to `ChatbotSchema`, and nothing is removed. The union +widens, which cannot break a consumer that produces `ComponentType` values and can only +help one that consumes them — except an exhaustive `Record` or +`switch`, which now needs the two new arms. Nothing in this repo has one: the only +consumer of `ComponentType` outside its own declaration is a pin test. + +**Why the entries can be honest now.** This map's value has to be the type the +registered renderer honours, and until objectui#7655 there was none to point at — +`ChatbotSchema` pins `type` to `'chatbot'`, and each registration's real key set lived +in an anonymous `ChatbotSchema & { … }` intersection local to the renderer file. +objectui#7655 published `ChatbotEnhancedSchema` and `ChatbotFloatingSchema` from this +package, and both registrations already take them as their `schema` parameter, so the +map's value and the renderer's prop type are one declaration — the same property the +`'kanban'` arm gained in objectui#7664. Being declared here also makes them reachable: +`@object-ui/types` has zero workspace dependencies, which is exactly what blocked the +`kanban` case objectui#7645 measured, where the honoured type lived in a plugin. + +Pinned in `src/__tests__/schema-registry-chatbot-keys-7704.test.ts` in two channels — +compile-time (`tsc -p tsconfig.test.json`: the keys survive in `keyof`, each value is +the face its renderer honours, and each value's own `type` literal is its key) and +runtime (a TypeScript-AST census of the interface source, plus each key selecting its +own arm through `safeValidateSchema`). + +Scope: these two keys, whose authoring faces now exist — not a sweep of the map's other +entries, which objectui#7665 holds. diff --git a/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts b/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts new file mode 100644 index 0000000000..ff2209d167 --- /dev/null +++ b/packages/types/src/__tests__/schema-registry-chatbot-keys-7704.test.ts @@ -0,0 +1,209 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `SchemaRegistry` names all three `plugin-chatbot` registrations, so the + * published `ComponentType` union does too (objectui#7704). + * + * ## The defect this pins shut + * + * `packages/plugin-chatbot/src/renderer.tsx` registers three components — + * `chatbot` (`:62`), `chatbot-enhanced` (`:241`), `chatbot-floating` (`:379`). + * `SchemaRegistry` mapped one of them. `ComponentType = keyof SchemaRegistry` + * is the published union, so a consumer discriminating on it was told two + * registered keys DO NOT EXIST. The asymmetry that proves which half was wrong: + * `packages/cli/src/utils/known-schema-types.ts:83-84` lists both keys — the + * CLI keeps its own parallel list precisely because this map did not have them. + * + * The map could not have carried them earlier and stayed honest. This map's + * value has to be the type the registered renderer honours, and until + * objectui#7655 there was none to point at: `ChatbotSchema` pins `type` to + * `'chatbot'`, and each registration's real key set lived in an anonymous + * `ChatbotSchema & { ... }` intersection local to the renderer file. + * objectui#7655 gave each registration one named authoring face, declared in + * THIS package — which is what makes the two entries simultaneously + * + * - HONEST: each value pins `type` to its own key, and the registration for + * that key takes that exact type as its `schema` parameter + * (`renderer.tsx:256`, `:394`), so the map's value and the renderer's prop + * type are one declaration; and + * - REACHABLE: `@object-ui/types` has zero workspace dependencies, and both + * faces are its own declarations. This is the difference from the `kanban` + * case objectui#7645 measured, where the honoured type lived in + * `@object-ui/plugin-kanban` — naming it from here would have been a + * phantom dependency and a cycle, and it was resolved the other way, by + * moving the dialect down into this package (objectui#7664). + * + * ## Scope — two keys, not a sweep + * + * objectui#7704 is these two keys, whose faces now exist. Whether EVERY + * `SchemaRegistry` value must be the type its renderer honours is + * objectui#7665's question and is deliberately not asked or answered here. + * + * ## Two channels, stated so nobody reads the wrong one + * + * The `export type assertion…` block below is COMPILE-TIME: it is checked by + * `tsc -p packages/types/tsconfig.test.json` (this package's `type-check` + * script chains it) and erased before vitest runs. A green vitest run is NOT + * evidence about it — the same split `chatbot-registration-authoring-faces-7655 + * .test.ts` and `kanban-plugin-dialect-authoritative-7664.test.ts` document. + * The `it` blocks are the RUNTIME channel: a source census of the interface + * (so deleting an entry fails vitest too, not only `tsc`) and the accept sets + * of the validator the CLI applies. + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import ts from 'typescript'; +import type { + SchemaRegistry, + ComponentType, + ComplexSchema, + ChatbotSchema, + ChatbotEnhancedSchema, + ChatbotFloatingSchema, +} from '../index'; +import { + ChatbotEnhancedSchema as ChatbotEnhancedZod, + ChatbotFloatingSchema as ChatbotFloatingZod, + safeValidateSchema, +} from '../zod/index.zod'; + +/* -------------------------------------------------------------------------- */ +/* Compile-time pins — read by tsc via tsconfig.test.json, not by this run. */ +/* -------------------------------------------------------------------------- */ + +type Assert = T; +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type IsAny = 0 extends 1 & T ? true : false; + +// Non-vacuity controls: `any` on either side satisfies every `extends` below +// while checking nothing, and `Equal` is `false`. +type _RegistryIsReal = Assert, false>>; +type _EnhancedFaceIsReal = Assert, false>>; +type _FloatingFaceIsReal = Assert, false>>; + +// 1. THE CARD. The published union yields both keys. `Extract` collapses to +// `never` the moment an entry is dropped, which fails this loudly — the +// `_KeyKept` shape objectui#7645/#7664 carry for `'kanban'`. +type _EnhancedKeyPublished = Assert, 'chatbot-enhanced'>>; +type _FloatingKeyPublished = Assert, 'chatbot-floating'>>; + +// 2. Each value IS the face its registration honours — not `ChatbotSchema` +// (which `Equal` rejects: it pins `type` to `'chatbot'`), and not a +// `BaseSchema & { type }` weakest-true-claim placeholder. +type _EnhancedValueIsItsFace = Assert>; +type _FloatingValueIsItsFace = Assert>; + +// 3. Honesty, stated independently of #2: each value's own `type` literal IS +// the key it is filed under. This is the property objectui#7645 found +// violated for `'kanban'`, and the reason these two keys could not be added +// before objectui#7655 minted the faces. +type _EnhancedValueIsHonest = Assert>; +type _FloatingValueIsHonest = Assert>; +// …and the honesty check can fail — the pre-#7655 value would have been +// `ChatbotSchema`, whose `type` is `'chatbot'`, not either new key. +type _HonestyCanFail = Assert, false>>; + +// 4. The `'chatbot'` entry is untouched: this card ADDS two keys, it does not +// re-point the existing one. +type _ChatbotEntryUnchanged = Assert>; + +// 5. Registry value and validator arm are the same declaration, so the key the +// map publishes and the arm `safeValidateSchema` selects cannot drift. +type _EnhancedArmIsTheValue = Assert< + Equal, SchemaRegistry['chatbot-enhanced']> +>; +type _FloatingArmIsTheValue = Assert< + Equal, SchemaRegistry['chatbot-floating']> +>; + +/* -------------------------------------------------------------------------- */ +/* Runtime pin 1 — source census of the interface */ +/* -------------------------------------------------------------------------- */ + +const REGISTRY_TS = join(dirname(fileURLToPath(import.meta.url)), '..', 'registry.ts'); + +/** The `SchemaRegistry` members as declared, in source order: key + value type text. */ +function registryEntries(interfaceName = 'SchemaRegistry'): Array<{ key: string; value: string }> { + const sf = ts.createSourceFile(REGISTRY_TS, readFileSync(REGISTRY_TS, 'utf8'), ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS); + const decl = sf.statements.find( + (s): s is ts.InterfaceDeclaration => ts.isInterfaceDeclaration(s) && s.name.text === interfaceName, + ); + if (!decl) throw new Error(`no top-level interface ${interfaceName} in ${REGISTRY_TS}`); + return decl.members.filter(ts.isPropertySignature).map((m) => ({ + key: ts.isStringLiteral(m.name) || ts.isIdentifier(m.name) ? m.name.text : m.name.getText(sf), + value: m.type ? m.type.getText(sf) : '', + })); +} + +describe('SchemaRegistry declares all three chatbot registrations (objectui#7704)', () => { + const entries = registryEntries(); + const keys = entries.map((e) => e.key); + const valueOf = (key: string) => entries.find((e) => e.key === key)?.value; + + it('the census reader read a real map — lit control before any assertion about the two keys', () => { + // If the parse collapsed, or the interface were renamed, every assertion + // below would pass vacuously on an empty list. These are the controls. + expect(keys.length).toBeGreaterThan(60); + expect(keys).toContain('kanban'); + expect(keys).toContain('chatbot'); + expect(valueOf('chatbot')).toBe('ChatbotSchema'); + }); + + it('both registered keys are declared, each pointing at its own authoring face', () => { + expect(keys).toContain('chatbot-enhanced'); + expect(keys).toContain('chatbot-floating'); + expect(valueOf('chatbot-enhanced')).toBe('ChatbotEnhancedSchema'); + expect(valueOf('chatbot-floating')).toBe('ChatbotFloatingSchema'); + }); + + it('they sit with the family, under the `// Complex` group', () => { + const at = keys.indexOf('chatbot'); + expect(at).toBeGreaterThan(keys.indexOf('kanban')); + expect(keys[at + 1]).toBe('chatbot-enhanced'); + expect(keys[at + 2]).toBe('chatbot-floating'); + }); + + it('the census reader can fail — a name that is not there throws rather than reading empty', () => { + expect(() => registryEntries('NotARealRegistryInterface')).toThrow(/no top-level interface NotARealRegistryInterface/); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* Runtime pin 2 — the key is reachable through the validator the CLI applies */ +/* -------------------------------------------------------------------------- */ + +describe('each newly named key selects its own arm end to end (objectui#7704)', () => { + // `messages` is a required arm on both twins (measured: a `{ type }`-only + // document is refused at `messages`, not at `type`), so the minimal node + // carries it. Everything else on both faces is optional. + const ENHANCED_NODE = { type: 'chatbot-enhanced', messages: [] }; + const FLOATING_NODE = { type: 'chatbot-floating', messages: [] }; + + it('a minimal node of either key passes safeValidateSchema — the union the CLI applies', () => { + expect(safeValidateSchema(ENHANCED_NODE).success).toBe(true); + expect(safeValidateSchema(FLOATING_NODE).success).toBe(true); + }); + + it('the discriminant is real — each twin refuses the sibling key, and refuses it AT `type`', () => { + // Same documents as the control below with only `type` swapped, so the + // one refusal path proves the key is the discriminant and nothing else. + const enhancedOnFloating = ChatbotEnhancedZod.safeParse(FLOATING_NODE); + const floatingOnEnhanced = ChatbotFloatingZod.safeParse(ENHANCED_NODE); + expect(enhancedOnFloating.success).toBe(false); + expect(floatingOnEnhanced.success).toBe(false); + if (!enhancedOnFloating.success) { + expect(enhancedOnFloating.error.issues.map((i) => i.path.join('.'))).toEqual(['type']); + } + if (!floatingOnEnhanced.success) { + expect(floatingOnEnhanced.error.issues.map((i) => i.path.join('.'))).toEqual(['type']); + } + }); + + it('control: each twin accepts its own key, so the refusal above is about the discriminant', () => { + expect(ChatbotEnhancedZod.safeParse(ENHANCED_NODE).success).toBe(true); + expect(ChatbotFloatingZod.safeParse(FLOATING_NODE).success).toBe(true); + }); +}); diff --git a/packages/types/src/registry.ts b/packages/types/src/registry.ts index 3b5ce94b28..eb39756db6 100644 --- a/packages/types/src/registry.ts +++ b/packages/types/src/registry.ts @@ -97,6 +97,8 @@ import type { FilterBuilderSchema, CarouselSchema, ChatbotSchema, + ChatbotEnhancedSchema, + ChatbotFloatingSchema, } from './complex.js'; /** @@ -201,6 +203,41 @@ export interface SchemaRegistry { 'filter-builder': FilterBuilderSchema; 'carousel': CarouselSchema; 'chatbot': ChatbotSchema; + // `'chatbot-enhanced'` and `'chatbot-floating'` are the other two keys + // `packages/plugin-chatbot/src/renderer.tsx` registers (`:241`, `:379`). + // They were absent from this map until objectui#7704, so `ComponentType` + // — the published `keyof SchemaRegistry` union — told a consumer + // discriminating on it that two registered keys do not exist, and + // `packages/cli/src/utils/known-schema-types.ts` had to keep its own + // parallel list (`:83-84`) to know they do. + // + // Why they can be added now, and could not before: this map's value has to + // be the type the registered renderer honours, and until objectui#7655 + // there was no honest one to point at. `ChatbotSchema` pins `type` to + // `'chatbot'`, and the two registrations' real key sets lived in anonymous + // `ChatbotSchema & { ... }` intersections local to the renderer file, + // referenceable by nothing outside it. objectui#7655 gave each registration + // one named authoring face, declared HERE — so both entries are honest (each + // value pins `type` to its own key) AND reachable (this is a + // zero-workspace-dependency layer; both faces are its own declarations, not + // a plugin's). That is what separated this from the `kanban` case + // objectui#7645 measured, where the honoured type lived in + // `@object-ui/plugin-kanban` and naming it from here would have been a + // phantom dependency and a cycle — resolved the other way, by moving the + // dialect down here (objectui#7664, the `'kanban'` note above). + // + // The two registrations already take these exact types as their `schema` + // parameter (`renderer.tsx:256`, `:394`), so the map's value and the + // renderer's prop type are one declaration, the same property the `'kanban'` + // arm above has. + // + // Pinned in `src/__tests__/schema-registry-chatbot-keys-7704.test.ts`: the + // keys survive in `keyof`, each value IS the face its renderer honours, and + // each value's `type` literal IS its own key. Scope note — objectui#7704 is + // these two keys, whose faces now exist; it is NOT a sweep of the map's + // other entries, which objectui#7665 holds. + 'chatbot-enhanced': ChatbotEnhancedSchema; + 'chatbot-floating': ChatbotFloatingSchema; } /**