diff --git a/.changeset/10859-ai-node-zod-arms.md b/.changeset/10859-ai-node-zod-arms.md new file mode 100644 index 0000000000..599353218a --- /dev/null +++ b/.changeset/10859-ai-node-zod-arms.md @@ -0,0 +1,15 @@ +--- +'@object-ui/types': minor +--- + +`safeValidateSchema` — and so `objectui validate` — accepts the three `@object-ui/plugin-ai` node types, `ai-form-assist`, `ai-recommendations` and `nl-query` (objectui#10859, batch 1). + +**Clause-②: yes** — the accept set of `AnyComponentSchema` widens by three `type` literals, and `@object-ui/types/zod` exports eight new schemas. Nothing that parsed before is refused now. + +**What it was.** All three types are registered by `@object-ui/plugin-ai`, declared on the published TypeScript face (`AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema`) and taught by that package's README. `AnyComponentSchema` carried no arm for any of them, so every document naming one was refused with `invalid_union` at `type` — the README's own `{ "type": "ai-form-assist", "showConfidence": true, "showReasoning": true }` included, which `objectui validate` reported as "Schema validation failed". + +**What changed, in observable terms.** + +- `@object-ui/types/zod` exports `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema`, their sub-schemas `AIConfigSchema`, `AIFieldSuggestionSchema`, `AIRecommendationItemSchema` and `NLQueryResultSchema`, and the category union `AIComponentSchema`, which `AnyComponentSchema` now lists. The strict authoring face (`StrictAnyComponentSchema`) derives from `AnyComponentSchema` and accepts the same three types, closed to undeclared keys like every other arm. +- Each arm restates its TypeScript declaration member for member. The objectui#8178 retired members (`formId`, `objectName`, `fields`, `autoFill`, `maxResults`) and both content channels (objectui#9256) are refused by name, as they already were on the TypeScript face. +- ⚠️ The `on*` members are the one place the two faces differ. The TypeScript face still types `onApplySuggestion`, `onRejectSuggestion`, `onSelect`, `onDismiss` and `onSubmit` as `string`; the zod face refuses an authored value on each, by name, following objectui#6182 (the handler-expression string dialect is not an authoring form) and objectui#6124 (`onSelect` / `onDismiss` / `onSubmit` are runtime slots a React host fills; the other two have no reader). An authored string was never a working handler: `onSelect`, `onDismiss` and `onSubmit` are called only as function props a React host passes, and `onApplySuggestion` / `onRejectSuggestion` are read by nothing — delete the key from the document. The TypeScript face's `string` typing is objectui#10874's to correct. diff --git a/.changeset/8178-ai-schemas-retire-zero-read-members.md b/.changeset/8178-ai-schemas-retire-zero-read-members.md index 366229de8d..1d0a9007b2 100644 --- a/.changeset/8178-ai-schemas-retire-zero-read-members.md +++ b/.changeset/8178-ai-schemas-retire-zero-read-members.md @@ -71,3 +71,10 @@ node type WHOLE from the published type face: there is no member left to pin, an that ruling rewrote the exclusion pin — the only thing that was ever allowed to rewrite it. Its entry publishes in this same release, and the successor pin is `packages/types/src/__tests__/ai-insights-retired-8800.test.ts`. + +⚠️ **Dated note, 2026-09-28 — the validator now reaches these tombstones — objectui#10859.** Later in +this same release `AnyComponentSchema` gains an arm for each of the three AI node types, so +`safeValidateSchema` — and `objectui validate` — refuses a stored JSON document that carries one of +the seven retired members BY NAME, at that member, where before it refused every document of these +three types at `type`. The renderer still ignores the value. The rest of this entry is kept as the +reading of this change; the objectui#10859 entry states what the validator now does. diff --git a/.changeset/8344-node-recursion-point-redirect.md b/.changeset/8344-node-recursion-point-redirect.md index ffbc2d7718..decd16bd80 100644 --- a/.changeset/8344-node-recursion-point-redirect.md +++ b/.changeset/8344-node-recursion-point-redirect.md @@ -127,3 +127,8 @@ module, so a consumer bundler has no internal graph to link past and every entry the barrel, or a deep link — gets the same accept set. Until it lands, a consumer that bundles `@object-ui/types/zod` should keep `AnyComponentSchema` in its import graph, which is enough to make the redirect apply. + +⚠️ **Dated note, 2026-09-28 — one more category module — objectui#10859.** Later in this same +release `zod/ai.zod.ts` joins the category modules `AnyComponentSchema` is built from and imports +`zod/base.zod.ts` too, so both counts in the mechanical note above are one higher; the reason the arm +cannot be an import is unchanged. The rest of this entry is kept as the reading of this change. diff --git a/.changeset/8498-any-component-union-discriminated.md b/.changeset/8498-any-component-union-discriminated.md index b84b746698..1e2e1d4eb9 100644 --- a/.changeset/8498-any-component-union-discriminated.md +++ b/.changeset/8498-any-component-union-discriminated.md @@ -25,3 +25,9 @@ reports that arm's issues as top-level issues at absolute paths, rather than one reported at `type` rather than at the root. `objectui validate` prints the same 2026-09-02 ruling output — the selected arm alone, or a note plus a capped candidate list — read off the new issue shape. + +⚠️ **Dated note, 2026-09-28 — a fourteenth arm — objectui#10859.** Later in this same release +`AnyComponentSchema` gains a fourteenth top-level arm, `AIComponentSchema` (the three AI node types), +and still no `type` literal is claimed by two arms — the property this entry's "no document changes +verdict" rests on. The counts above are this change's reading; the objectui#10859 entry states what +that arm accepts. diff --git a/.changeset/8784-arm-named-export-pin.md b/.changeset/8784-arm-named-export-pin.md index 01ddd8e827..28d6df332e 100644 --- a/.changeset/8784-arm-named-export-pin.md +++ b/.changeset/8784-arm-named-export-pin.md @@ -35,3 +35,9 @@ carry ledger rows against objectui#9067, which holds the ruling, because exporti new names on a published surface. The fifth, `RetiredKanbanNodeSchema`, is a refusal arm the barrel already declares deliberately internal (objectui#8802). A stale-row leg deletes any ledger row the moment its arm is exported or stops existing. + +⚠️ **Dated note, 2026-09-28 — a fourteenth top-level member — objectui#10859.** Later, +objectui#10859 adds `AIComponentSchema` as a fourteenth top-level member of `AnyComponentSchema` +(13 sub-unions plus one object arm), and its three leaf arms are named exports of the barrel, so the +figures above are this change's reading, not today's. The pin derives the arm list on every run and +needs no edit for it. diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts new file mode 100644 index 0000000000..eacb0591ee --- /dev/null +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -0,0 +1,176 @@ +/** + * 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. + */ + +/** + * How many REGISTERED component types does `objectui validate` refuse at + * `type`? A ratchet (objectui#10859). + * + * ## The gap this closes + * + * `objectui validate` judges a document with `safeValidateSchema`, whose root + * is `AnyComponentSchema` — a union discriminated on `type`. A registered type + * with no arm in that union is refused with one `invalid_union` issue at + * `['type']`, whatever else the document says: `ai-form-assist`, which + * `packages/plugin-ai/README.md` teaches, was one of them. The mirror-parity + * ratchet in `@object-ui/types` counts declaration↔mirror PAIRS and the keys + * inside them; a registered type with no mirror at all is in neither, so + * nothing counted this family until this file. + * + * ## What is counted, and against what + * + * The population is the bare (un-namespaced) keys of `KNOWN_SCHEMA_TYPES` + * (`../utils/known-schema-types.ts`), the list generated from the registration + * calls themselves, which `objectui check` already uses. A key is REFUSED when + * `safeValidateSchema({ type: KEY })` fails with an `invalid_union` issue at + * path `['type']` — the "no arm claims this literal" reading. A key whose arm + * exists but wants more than the bare `type` (a required member) is not + * refused here: its `type` is claimed, and the missing member is the + * document's problem, not the union's. + * + * ## The pin moves ONE way + * + * `REFUSED_AT_TYPE` is the head's count. objectui#10859 lowers it batch by + * batch as each registered, declared type gets its arm (or, for a type that is + * not meant to be authored, as the seat rules on it). ⛔ It never rises: a + * registration added without an arm, or an arm removed, turns this file red + * with the refused keys listed, which is where the next author finds out. + * ⛔ Do not raise the constant to make it pass — arm the type, or take the + * registration's authorability to the card. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { safeValidateSchema } from '@object-ui/types/zod'; + +import { KNOWN_SCHEMA_TYPES } from '../utils/known-schema-types.js'; +import { validate } from '../commands/validate.js'; + +/** + * The head's refused count (objectui#10859 batch 1: 79 on `main` before it, + * minus the three `@object-ui/plugin-ai` arms). LOWER it when a batch arms more + * keys; never raise it. + */ +const REFUSED_AT_TYPE = 76; + +/** The bare registry keys — the population the card measured. */ +const BARE_KEYS = KNOWN_SCHEMA_TYPES.filter((key) => !key.includes(':')); + +/** Is `type` unclaimed by every arm of the validator's root union? */ +function refusedAtType(type: string): boolean { + const result = safeValidateSchema({ type }); + if (result.success) return false; + return result.error.issues.some( + (issue) => issue.code === 'invalid_union' && issue.path.length === 1 && issue.path[0] === 'type', + ); +} + +describe('registered component types refused at `type` — a ratchet (objectui#10859)', () => { + it('the refused count equals the pin, and only ever falls', () => { + const refused = BARE_KEYS.filter(refusedAtType); + expect( + refused.length, + [ + `\`objectui validate\` refuses ${refused.length} registered bare key(s) at \`type\`; the pin is ${REFUSED_AT_TYPE}.`, + refused.length < REFUSED_AT_TYPE + ? `Fewer than the pin — an arm landed. LOWER \`REFUSED_AT_TYPE\` to ${refused.length} in this same change (objectui#10859).` + : 'MORE than the pin — a registered key lost its arm or a registration landed without one. ' + + 'Arm it in `@object-ui/types/zod` (or take its authorability to objectui#10859); ⛔ never raise the pin.', + `Refused: ${refused.join(', ')}`, + ].join('\n'), + ).toBe(REFUSED_AT_TYPE); + }); + + it('reads the whole generated population, not a fragment of it (non-vacuity)', () => { + // The card's measurement was over this same population; a filter that + // matched nothing would make the count above trivially small. + expect(BARE_KEYS.length).toBeGreaterThan(200); + expect(BARE_KEYS).toContain('timeline'); + expect(BARE_KEYS).toContain('ai-form-assist'); + }); + + it('tells a claimed type from an unclaimed one — both instrument controls fire', () => { + // Lit control: an armed, registered key is not refused at `type`. + expect(refusedAtType('timeline')).toBe(false); + // A key whose arm wants a required member fails, but NOT at `type`. + expect(safeValidateSchema({ type: 'chart' }).success).toBe(false); + expect(refusedAtType('chart')).toBe(false); + // And a type no arm claims IS refused there. + expect(refusedAtType('no-such-component-10859')).toBe(true); + }); + + it('counts the three plugin-ai keys as armed (objectui#10859 batch 1)', () => { + for (const key of ['ai-form-assist', 'ai-recommendations', 'nl-query']) { + expect(BARE_KEYS, key).toContain(key); + expect(refusedAtType(key), key).toBe(false); + } + }); +}); + +/* ── End to end: the README document through `objectui validate` ─────────── */ + +/** Rooted on this file, never on `process.cwd()`. */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_AI_README = join(HERE, '..', '..', '..', 'plugin-ai', 'README.md'); + +/** See `validate-root-path-line.test.ts` — the escape byte is never spelled. */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +let dir: string; +let out: string[]; +let exitCodes: number[]; +let restore: () => void; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'objectui-validate-10859-')); + out = []; + exitCodes = []; + const originalLog = console.log; + const originalError = console.error; + const capture = (...args: unknown[]) => { + out.push(args.map(String).join(' ')); + }; + console.log = capture; + console.error = capture; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCodes.push(code ?? 0); + return undefined as never; + }) as never); + restore = () => { + console.log = originalLog; + console.error = originalError; + exitSpy.mockRestore(); + }; +}); + +afterEach(() => { + restore(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('objectui validate — the plugin-ai README document (objectui#10859)', () => { + it('validates the "Schema-Driven Usage" `ai-form-assist` document', async () => { + const readme = readFileSync(PLUGIN_AI_README, 'utf8'); + const section = readme.slice(readme.indexOf('## Schema-Driven Usage')); + const fence = /```json\n([\s\S]*?)\n```/.exec(section); + expect(fence, 'no ```json fence under "## Schema-Driven Usage"').not.toBeNull(); + const file = join(dir, 'ai-form-assist.json'); + writeFileSync(file, (fence as RegExpExecArray)[1], 'utf-8'); + // Lit control on the extraction: it is the taught node. + expect(JSON.parse((fence as RegExpExecArray)[1]).type).toBe('ai-form-assist'); + + await validate(file); + + const text = out.join('\n').replace(ANSI, ''); + expect(text).not.toContain('Schema validation failed'); + expect(text).toContain('Schema is valid'); + expect(exitCodes).toEqual([0]); + }); +}); diff --git a/packages/types/src/__tests__/ai-insights-retired-8800.test.ts b/packages/types/src/__tests__/ai-insights-retired-8800.test.ts index 4a94aa2f36..7d41e085c0 100644 --- a/packages/types/src/__tests__/ai-insights-retired-8800.test.ts +++ b/packages/types/src/__tests__/ai-insights-retired-8800.test.ts @@ -58,6 +58,17 @@ * ⚠️ `./blocks.ts` earned that leg because the block family had zod mirrors * shipping as runtime values; this family has no zod mirror at all, which is * asserted below rather than assumed. + * ⚠️ AMENDED 2026-09-28 (objectui#10859): the AI family HAS a zod module now + * — `../zod/ai.zod.ts` mirrors the three siblings that render, so the + * premise leg below re-derives what the retirement actually rests on (that + * face declares no `ai-insights` arm and exports no `AIInsightsSchema`) + * instead of the module's absence. Its `'AIInsightsSchema' in ZodFace` probe + * is NOT the fourth instrument ruled out above: that one would read the + * type-only `../ai.ts` namespace, where the name never existed at runtime; + * this one reads the zod barrel, where a mirror of the retired declaration + * WOULD be a runtime value, and its `AIRecommendationsSchema` lit control + * shows the probe can answer yes. The sentence above records the tree this + * pin was written against and is left as it was. */ import { describe, it, expect } from 'vitest'; @@ -66,6 +77,7 @@ import { execFileSync } from 'node:child_process'; import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { AnyComponentSchema } from '../zod/index.zod.js'; +import * as ZodFace from '../zod/index.zod.js'; /** Rooted at THIS file, never at `process.cwd()` — the two differ per invocation. */ const HERE = dirname(fileURLToPath(import.meta.url)); @@ -217,21 +229,20 @@ describe('objectui#8800 — PREMISE: the zod face never accepted the spelling', expect(AnyComponentSchema.safeParse({ type: 'button', label: 'Save' }).success).toBe(true); }); - it('the AI family has no zod mirror at all — the retirement has ONE face, not two', () => { - // The card's sweep found no zod mirror for `ai.ts` and this re-derives it, - // because a mirror would give the retirement a second face and change its - // shape. Measured structurally: `zod/` mirrors a module as `.zod.ts`. - const mirrored = (name: string): boolean => { - try { - readFileSync(resolve(HERE, '..', 'zod', `${name}.zod.ts`), 'utf8'); - return true; - } catch { - return false; - } - }; - expect(mirrored('ai')).toBe(false); - // ⭐ LIT CONTROL — the same probe finds a module that IS mirrored, so the - // false above is a reading and not a broken path join. - expect(mirrored('views')).toBe(true); + it('the AI zod mirror declares no `ai-insights` arm — the retirement still has ONE face, not two', () => { + // The card's sweep found no zod mirror for `ai.ts`, and a mirror of the + // RETIRED declaration would give the retirement a second face and change its + // shape. ⚠️ AMENDED (objectui#10859): `ai.zod.ts` now exists and mirrors the + // three siblings that render, so this leg no longer reads the module's + // absence — it reads that the mirror carries no arm for the retired literal + // and no schema under the retired name. + const source = readFileSync(resolve(HERE, '..', 'zod', 'ai.zod.ts'), 'utf8'); + expect(source).not.toMatch(/ai-insights/); + expect(source).not.toMatch(/AIInsights/); + expect('AIInsightsSchema' in ZodFace).toBe(false); + // ⭐ LIT CONTROLS — the same probes find a sibling the mirror DOES carry, so + // the absences above are readings and not a broken path join or namespace. + expect(source).toMatch(/z\.literal\('ai-recommendations'\)/); + expect('AIRecommendationsSchema' in ZodFace).toBe(true); }); }); diff --git a/packages/types/src/__tests__/ai-zod-arms-10859.test.ts b/packages/types/src/__tests__/ai-zod-arms-10859.test.ts new file mode 100644 index 0000000000..55a0d0f229 --- /dev/null +++ b/packages/types/src/__tests__/ai-zod-arms-10859.test.ts @@ -0,0 +1,299 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The three `@object-ui/plugin-ai` node types have a zod arm in + * `AnyComponentSchema` (objectui#10859, batch 1). + * + * ## The defect these pin + * + * `ai-form-assist`, `ai-recommendations` and `nl-query` are registered by + * `@object-ui/plugin-ai`, declared on the published TypeScript face + * (`../ai.ts`) and taught by that package's README — and `AnyComponentSchema` + * carried no arm for any of them, so `safeValidateSchema` refused every + * document naming one with `invalid_union` at `type`. The README's own + * "Schema-Driven Usage" document was refused, and `objectui validate` printed + * "Schema validation failed" for it. + * + * ## Two instruments, and which half each one reads + * + * The `Expect` / `Equal` block below is TYPE-level: `tsc -p tsconfig.test.json` + * (the third leg of this package's `type-check` script) reads it, and vitest — + * which strips types — does not. The `describe` blocks are RUNTIME and vitest + * reads them. A green run of either one alone says nothing about the other. + * + * ## What the parity half asserts + * + * For every member the TypeScript declaration declares, the arm's INPUT type + * for that member is invariantly equal to the declaration's — with exactly one + * stated exception per arm: the `on*` handler members. The TypeScript face + * declares them `string`, the handler-expression string dialect objectui#6182 + * withdrew on EITHER face, and the zod face follows that ruling with + * `handlerKeyRefusal` (see `../zod/ai.zod.ts`). That divergence is pinned BOTH + * ways — the TypeScript member still reads `string`, the zod member reads + * `undefined` — so a later repair of the TypeScript face reddens this file + * instead of leaving a stale exception standing. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { + AIConfigSchema, + AIFieldSuggestionSchema, + AIFormAssistSchema, + AIRecommendationItemSchema, + AIRecommendationsSchema, + NLQueryResultSchema, + NLQuerySchema, + StrictAnyComponentSchema, + safeValidateSchema, +} from '../zod/index.zod.js'; +import type { + AIConfig as Ts_AIConfig, + AIFieldSuggestion as Ts_AIFieldSuggestion, + AIFormAssistSchema as Ts_AIFormAssistSchema, + AIRecommendationItem as Ts_AIRecommendationItem, + AIRecommendationsSchema as Ts_AIRecommendationsSchema, + NLQueryResult as Ts_NLQueryResult, + NLQuerySchema as Ts_NLQuerySchema, +} from '../ai'; + +/* ── Type-level parity: the `tsc` channel ────────────────────────────────── */ + +/** Invariant equality — `extends` both ways would accept a narrowing. */ +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** The mirror's own shape. */ +type ShapeOf = M extends { shape: infer S } ? S : never; +/** What a shape entry ACCEPTS (input side, so `.optional()` shows). */ +type InputOf = T extends z.ZodType ? z.input : never; +/** The declaration's DECLARED keys — the `BaseSchema` index signature dropped. */ +type DeclaredKeys = Extract< + keyof { [K in keyof D as string extends K ? never : number extends K ? never : K]: D[K] }, + string +>; +/** The mirror's declared keys, read from its own shape. */ +type MirroredKeys = Extract, string>; + +/** Declared members whose accepted type differs from the declaration's. */ +type MismatchedKeys = { + [K in DeclaredKeys & MirroredKeys]: Equal[K]>, D[K]> extends true ? never : K; +}[DeclaredKeys & MirroredKeys]; + +/** Every arm and sub-schema declares exactly the declaration's key set — none added, none dropped. */ +export type assertionKeySetsAgree = [ + Expect, DeclaredKeys>>, + Expect, DeclaredKeys>>, + Expect, DeclaredKeys>>, + Expect, DeclaredKeys>>, + Expect, DeclaredKeys>>, + Expect, DeclaredKeys>>, + Expect, DeclaredKeys>>, +]; + +/** + * Every member's accepted type equals the declaration's — the four sub-schemas + * with no exception, each arm with exactly its `on*` handler members excepted. + */ +export type assertionMemberTypesAgree = [ + Expect, never>>, + Expect, never>>, + Expect, never>>, + Expect, never>>, + Expect, 'onApplySuggestion' | 'onRejectSuggestion'>>, + Expect, 'onSelect' | 'onDismiss'>>, + Expect, 'onSubmit'>>, +]; + +/** + * The handler-member divergence, both halves. The TypeScript face still types + * each `string`; the zod face accepts nothing but absence. Repairing the + * TypeScript face (a function type for a runtime slot, `?: never` for a retired + * key, per objectui#6124) turns the first half red, which is the point. + */ +export type assertionHandlerDivergence = [ + Expect>, + Expect>, + Expect>, + Expect>, + Expect>, + Expect['onApplySuggestion']>, undefined>>, + Expect['onRejectSuggestion']>, undefined>>, + Expect['onSelect']>, undefined>>, + Expect['onDismiss']>, undefined>>, + Expect['onSubmit']>, undefined>>, +]; + +/** + * Non-vacuity: the comparison can fail. A member typed differently on the two + * sides is reported by name, and an unmirrored key is caught by the key-set row. + */ +export type assertionInstrumentFires = [ + Expect, { a: number }>, 'a'>>, + Expect>, DeclaredKeys<{ a: string; b: string }>>, false>>, +]; + +/* ── Runtime: the documents that were refused ────────────────────────────── */ + +/** One minimal document per armed key — the `type` alone. */ +const MINIMAL = [ + { type: 'ai-form-assist' }, + { type: 'ai-recommendations' }, + { type: 'nl-query' }, +] as const; + +/** Rooted on this file, never on `process.cwd()` (AGENTS.md, test path roots). */ +const HERE = dirname(fileURLToPath(import.meta.url)); +const PLUGIN_AI_README = join(HERE, '..', '..', '..', 'plugin-ai', 'README.md'); + +/** + * The document `packages/plugin-ai/README.md` teaches under "Schema-Driven + * Usage", read off the README itself so the pin follows the page rather than a + * copy of it. + */ +function readmeFormAssistDocument(): Record { + const readme = readFileSync(PLUGIN_AI_README, 'utf8'); + const section = readme.slice(readme.indexOf('## Schema-Driven Usage')); + const fence = /```json\n([\s\S]*?)\n```/.exec(section); + if (!fence) throw new Error('no ```json fence under "## Schema-Driven Usage" in plugin-ai/README.md'); + return JSON.parse(fence[1]) as Record; +} + +describe('the registered AI node types validate (objectui#10859)', () => { + it.each(MINIMAL)('$type is accepted by safeValidateSchema', (doc) => { + const result = safeValidateSchema(doc); + expect(result.success, JSON.stringify(result.success ? null : result.error.issues)).toBe(true); + }); + + it.each(MINIMAL)('$type is accepted by the strict authoring face', (doc) => { + const result = StrictAnyComponentSchema.safeParse(doc); + expect(result.success, JSON.stringify(result.success ? null : result.error.issues)).toBe(true); + }); + + it("accepts the plugin-ai README's `ai-form-assist` document on both faces", () => { + const doc = readmeFormAssistDocument(); + // Lit control on the extraction: the fence really is the taught document. + expect(doc.type).toBe('ai-form-assist'); + expect(safeValidateSchema(doc).success).toBe(true); + expect(StrictAnyComponentSchema.safeParse(doc).success).toBe(true); + }); + + it('accepts a fully populated document of each type', () => { + const config = { provider: 'openai', model: 'gpt-4', temperature: 0.2, maxTokens: 512, systemPrompt: 'x' }; + const docs = [ + { + type: 'ai-form-assist', + context: { record: 1 }, + config, + suggestions: [{ fieldName: 'company', value: 'ObjectStack', confidence: 0.9, reasoning: 'domain' }], + showConfidence: true, + showReasoning: false, + }, + { + type: 'ai-recommendations', + config, + recommendations: [ + { id: 'r1', title: 'Renew', score: 0.8, category: 'sales', metadata: { a: 1 }, action: { type: 'open', target: 'r1' } }, + ], + showScores: true, + layout: 'grid', + loading: false, + emptyMessage: 'Nothing yet', + }, + { + type: 'nl-query', + placeholder: 'Ask anything', + config, + result: { query: 'q', data: [{ a: 1 }], columns: [{ name: 'a', label: 'A', type: 'number' }], summary: 's', confidence: 0.5 }, + suggestions: ['top accounts'], + showHistory: true, + history: [{ query: 'q', timestamp: '2026-09-27T00:00:00Z' }], + loading: false, + }, + ]; + for (const doc of docs) { + expect(safeValidateSchema(doc).success, doc.type).toBe(true); + expect(StrictAnyComponentSchema.safeParse(doc).success, doc.type).toBe(true); + } + }); +}); + +describe('the arms are closed where the declaration is (objectui#10859)', () => { + it('refuses an undeclared member on the strict face, naming it — the strictness control', () => { + const doc = { type: 'nl-query', placeholder: 'Ask', inventedKey: true }; + const strict = StrictAnyComponentSchema.safeParse(doc); + expect(strict.success).toBe(false); + if (strict.success) return; + const issue = strict.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(issue, JSON.stringify(strict.error.issues)).toBeDefined(); + expect((issue as { keys?: string[] }).keys).toEqual(['inventedKey']); + // The rendering face keeps its `.passthrough()` — the second face is the + // one that closes, exactly as for every other arm. + expect(safeValidateSchema(doc).success).toBe(true); + }); + + it.each([ + ['ai-form-assist', 'formId', 'new-contact'], + ['ai-form-assist', 'objectName', 'Contact'], + ['ai-form-assist', 'fields', ['name']], + ['ai-form-assist', 'autoFill', true], + ['ai-recommendations', 'objectName', 'Product'], + ['ai-recommendations', 'maxResults', 5], + ['nl-query', 'objectName', 'Order'], + ] as const)('%s refuses the objectui#8178 tombstone `%s` by name', (type, key, value) => { + const result = safeValidateSchema({ type, [key]: value }); + expect(result.success).toBe(false); + if (result.success) return; + const [issue] = result.error.issues; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual([key]); + expect(issue.message).toContain('objectui#8178'); + expect(issue.message).toContain(`\`${key}\``); + }); + + it.each([ + ['ai-form-assist', 'onApplySuggestion', 'RETIRED'], + ['ai-form-assist', 'onRejectSuggestion', 'RETIRED'], + ['ai-recommendations', 'onSelect', 'RUNTIME SLOT'], + ['ai-recommendations', 'onDismiss', 'RUNTIME SLOT'], + ['nl-query', 'onSubmit', 'RUNTIME SLOT'], + ] as const)('%s refuses an authored handler string on `%s` (objectui#6182 / #6124, %s)', (type, key, disposition) => { + const result = safeValidateSchema({ type, [key]: 'handleIt' }); + expect(result.success).toBe(false); + if (result.success) return; + const [issue] = result.error.issues; + expect(issue.code).toBe('custom'); + expect(issue.path).toEqual([key]); + expect(issue.message).toContain(`\`${key}\``); + expect(issue.message).toContain(disposition); + }); + + it.each(['ai-form-assist', 'ai-recommendations', 'nl-query'])('%s refuses both content channels (objectui#9256 family D)', (type) => { + for (const key of ['body', 'children'] as const) { + const result = safeValidateSchema({ type, [key]: [{ type: 'text', content: 'x' }] }); + expect(result.success, `${type}.${key}`).toBe(false); + if (result.success) continue; + expect(result.error.issues[0].path).toEqual([key]); + expect(result.error.issues[0].message).toContain('objectui#9256'); + } + }); + + it('refuses the objectui#10330 retired `carousel` layout value', () => { + const result = safeValidateSchema({ type: 'ai-recommendations', layout: 'carousel' }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0].path).toEqual(['layout']); + }); +}); diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index d92ce8256d..0c9d3a9552 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -154,7 +154,16 @@ * 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. - * - **46 entries** in `KnownDrift`, **84 keys** across them — 46 / 86 until + * - **49 entries** in `KnownDrift`, **89 keys** across them — 46 / 84 until + * objectui#10859 armed the three `@object-ui/plugin-ai` node types in `ai.zod.ts`: + * THREE entries are NEW, one per armed node, and together they carry the five `on*` + * handler keys — `ai.zod.ts#AIFormAssistSchema` (`onApplySuggestion`, + * `onRejectSuggestion`: DEBT, a refusal of keys nothing reads while `../ai.ts` still + * types them `string`), `ai.zod.ts#AIRecommendationsSchema` (`onDismiss`, + * `onSelect`) and `ai.zod.ts#NLQuerySchema` (`onSubmit`), the last two RUNTIME + * SLOTS whose TS twin is `string` rather than a callable. +3 entries, +5 keys, each + * a pair born ledgered, not growth on an existing entry; all five are + * objectui#10874's to settle on the TS face. It was 46 / 86 until * objectui#7759 group A settled `complex.zod.ts#DashboardComponentSchema`'s * `header` and `globalFilters` by the spec (ruling 5617465269, principle 1): the * declaration inherits the spec's `header` and the mirror's `GlobalFilterSchema` @@ -656,7 +665,7 @@ * * ## KNOWN_DRIFT is a ratchet, not a waiver * - * 46 of the registered pairs carry TYPE drift TODAY (measured, not assumed). Each is + * 49 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 @@ -680,6 +689,12 @@ * their TypeScript twins stay callable. That drift is the ruling's intended shape, * ledgered so the ratchet holds it exactly — a pair leaving this class means either * the mirror accepts a function again or a renderer lost its callback. + * + * It was 46 until objectui#10859 registered the three `@object-ui/plugin-ai` arms: three + * pairs born ledgered, each for its `on*` handler keys. ⚠️ They are NOT the #6124 shape + * this paragraph describes — the TypeScript twin of every one is `string`, not a + * callable (objectui#10874 owns that face), and the `AIFormAssistSchema` pair is DEBT + * rather than a runtime slot, because nothing reads its two keys at all. */ import { describe, it, expect } from 'vitest'; @@ -689,6 +704,7 @@ import { dirname, join } from 'node:path'; import ts from 'typescript'; import type { z } from 'zod'; +import { AIConfigSchema, AIFieldSuggestionSchema, AIFormAssistSchema, AIRecommendationItemSchema, AIRecommendationsSchema, NLQueryResultSchema, NLQuerySchema } from '../zod/ai.zod.js'; import { AppComponentSchema, MenuItemSchema as AppMenuItemSchema, NavigationAreaSchema, NavigationItemSchema } from '../zod/app.zod.js'; import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema, SchemaNodeSchema } from '../zod/base.zod.js'; import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, CardTemplateSchema, FilterGroupSchema } from '../zod/complex.zod.js'; @@ -704,6 +720,7 @@ import { AlertDialogSchema, ContextMenuSchema, DialogSchema, DrawerSchema, Dropd import { ReportBuilderSchema, ReportComponentSchema, ReportExportConfigSchema, ReportFieldSchema, ReportFilterSchema, ReportGroupBySchema, ReportSectionSchema, ReportViewerSchema } from '../zod/reports.zod.js'; import { DetailViewFieldSchema, DetailViewSchema, DetailViewSectionSchema, DetailViewTabSchema, FilterUISchema, SortUISchema, ViewSwitcherSchema } from '../zod/views.zod.js'; +import type { AIConfig as Ts_AIConfig, AIFieldSuggestion as Ts_AIFieldSuggestion, AIFormAssistSchema as Ts_AIFormAssistSchema, AIRecommendationItem as Ts_AIRecommendationItem, AIRecommendationsSchema as Ts_AIRecommendationsSchema, NLQueryResult as Ts_NLQueryResult, NLQuerySchema as Ts_NLQuerySchema } from '../ai'; import type { AppComponentSchema as Ts_AppComponentSchema, NavigationArea as Ts_NavigationArea } from '../app'; import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig, ComponentInput as Ts_ComponentInput, ComponentMeta as Ts_ComponentMeta, KeyedI18nLabel as Ts_KeyedI18nLabel } from '../base'; import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, CardTemplate as Ts_CardTemplate } from '../complex'; @@ -1521,6 +1538,13 @@ export type assertionNarrowerOperatorIsBlindToAMirroredOnlyKey = /** Mirror VALUES, keyed `#`. Runtime, so the census below can read the keys. */ const MIRRORS = { + 'ai.zod.ts#AIConfigSchema': AIConfigSchema, + 'ai.zod.ts#AIFieldSuggestionSchema': AIFieldSuggestionSchema, + 'ai.zod.ts#AIFormAssistSchema': AIFormAssistSchema, + 'ai.zod.ts#AIRecommendationItemSchema': AIRecommendationItemSchema, + 'ai.zod.ts#AIRecommendationsSchema': AIRecommendationsSchema, + 'ai.zod.ts#NLQueryResultSchema': NLQueryResultSchema, + 'ai.zod.ts#NLQuerySchema': NLQuerySchema, 'app.zod.ts#AppComponentSchema': AppComponentSchema, 'app.zod.ts#NavigationAreaSchema': NavigationAreaSchema, 'base.zod.ts#BaseSchema': BaseSchema, @@ -1687,6 +1711,13 @@ const MIRRORS = { /** The declaration each mirror restates. Same keys as `MIRRORS` — pinned below. */ interface Declared { + 'ai.zod.ts#AIConfigSchema': Ts_AIConfig; + 'ai.zod.ts#AIFieldSuggestionSchema': Ts_AIFieldSuggestion; + 'ai.zod.ts#AIFormAssistSchema': Ts_AIFormAssistSchema; + 'ai.zod.ts#AIRecommendationItemSchema': Ts_AIRecommendationItem; + 'ai.zod.ts#AIRecommendationsSchema': Ts_AIRecommendationsSchema; + 'ai.zod.ts#NLQueryResultSchema': Ts_NLQueryResult; + 'ai.zod.ts#NLQuerySchema': Ts_NLQuerySchema; 'app.zod.ts#AppComponentSchema': Ts_AppComponentSchema; 'app.zod.ts#NavigationAreaSchema': Ts_NavigationArea; 'base.zod.ts#BaseSchema': Ts_BaseSchema; @@ -1883,6 +1914,32 @@ export type DeclaredTypeOf< K extends MirrorKey, P extends keyof Declared[K] > = * new drift on a listed mirror fails, and so does a listed key that has been fixed. */ interface KnownDrift { + /** + * DEBT, ⛔ not the objectui#6124 shape (objectui#10859). Both mirror arms are + * `handlerKeyRefusal(…, 'retired')`: nothing in `@object-ui/plugin-ai` reads either + * key — `AIFormAssist` takes `onApply` / `onRefresh`, not these — so the zod face + * refuses an authored value by name. `../ai.ts` still types both `string`, the + * handler-expression dialect objectui#6182 withdrew on EITHER face, and that TS face + * is objectui#10874's to correct. When it lands them as `?: never`, both faces agree + * and this entry goes STALE — delete it then; ⛔ never keep it by re-widening a face. + */ + 'ai.zod.ts#AIFormAssistSchema': 'onApplySuggestion' | 'onRejectSuggestion'; + /** + * RUNTIME SLOT (objectui#6124, objectui#10859): `ai-recommendations` registers the + * raw `AIRecommendations` component, `SchemaRenderer` spreads the node's keys onto + * it, and it CALLS both as function props (`onSelect?.(item)`, `onDismiss?.(item)`). + * ⚠️ Unlike every other RUNTIME SLOT row in this ledger, the TS twin is `string`, + * not a callable: `../ai.ts` still carries the objectui#6182 string dialect, so this + * drift is a named refusal against a `string`, and objectui#10874 owns the TS face. + */ + 'ai.zod.ts#AIRecommendationsSchema': 'onDismiss' | 'onSelect'; + /** + * RUNTIME SLOT (objectui#6124, objectui#10859): `nl-query` registers the raw + * `NLQueryInput` component, which calls `onSubmit` as a function prop + * (`onSubmitProp?.(queryText)`). ⚠️ As with `AIRecommendationsSchema` above, the TS + * twin is `string`, not a callable (objectui#10874). + */ + 'ai.zod.ts#NLQuerySchema': 'onSubmit'; /** * RUNTIME SLOT (objectui#6124): `calendar-view`'s `pickHostCallbacks` reads * `onViewChange` off the spread props (function values only) and hands it to @@ -3966,6 +4023,8 @@ const EXCLUSIONS: Readonly> = { // `./bare-kanban-node-key-retired-8802.test.ts`. 'complex.zod.ts#RetiredKanbanNodeSchema': 'a RETIRED node type refusal arm (objectui#8802), not a mirror — its only member is the `type` literal it refuses on, and the TS half of that retirement is the ABSENCE of an arm in `ComplexSchema`', + 'ai.zod.ts#AIComponentSchema': + "a union OVER the mirrors, not an object of its own — its members are checked individually above", // Renamed from `StylePropsSchema` by objectui#5928. Under the old name the // like-named `StyleProps` (../base.ts) — the Tailwind-scale vocabulary, sharing // ZERO keys with this `{ className, style }` object — read as its declaration, and @@ -4205,7 +4264,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 162; +const EXPECTED_MIRROR_PAIRS = 169; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/zod/README.md b/packages/types/src/zod/README.md index 3936147128..88c0577046 100644 --- a/packages/types/src/zod/README.md +++ b/packages/types/src/zod/README.md @@ -231,6 +231,9 @@ function validateComponent(config: unknown) { - `KanbanSchema`, `CalendarViewSchema` - `FilterBuilderSchema`, `CarouselSchema`, `ChatbotSchema` +### AI Components (3) +- `AIFormAssistSchema`, `AIRecommendationsSchema`, `NLQuerySchema` + ## Schema Structure All component schemas follow the @objectstack/spec UI specification format: diff --git a/packages/types/src/zod/ai.zod.ts b/packages/types/src/zod/ai.zod.ts new file mode 100644 index 0000000000..274c801804 --- /dev/null +++ b/packages/types/src/zod/ai.zod.ts @@ -0,0 +1,258 @@ +/** + * 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. + */ + +/** + * @object-ui/types/zod - AI Component Zod Validators + * + * Zod mirrors of the three AI node declarations in `../ai.ts` — + * `AIFormAssistSchema`, `AIRecommendationsSchema` and `NLQuerySchema` — which + * `@object-ui/plugin-ai` registers as `ai-form-assist`, `ai-recommendations` + * and `nl-query`. + * + * ## Why this module exists (objectui#10859) + * + * The three keys were REGISTERED, DECLARED on the published TypeScript face and + * TAUGHT by `packages/plugin-ai/README.md` ("Schema-Driven Usage"), while + * `AnyComponentSchema` carried no arm for them — so `safeValidateSchema` and + * `objectui validate` refused every document naming them with `invalid_union` + * at `type`, the README's own example included. The arms below restate the + * declarations member for member; `AIComponentSchema` is the category union + * `AnyComponentSchema` lists. + * + * ## Where the mirror does NOT restate the TypeScript face, and why + * + * The five `on*` members are declared `string` on the TypeScript face — the + * handler-expression string dialect that objectui#6182 (maintainer ruling, + * option A) withdrew as an authoring form on EITHER face. The zod face follows + * that ruling and objectui#6124's shape, `handlerKeyRefusal`, with the + * disposition measured per key on `@object-ui/plugin-ai`: + * + * - `onSelect` / `onDismiss` (`AIRecommendations`) and `onSubmit` + * (`NLQueryInput`) are RUNTIME SLOTS — the registration is the raw + * component, `SchemaRenderer` spreads the node's keys onto it as props, and + * each component CALLS the prop (`onSelect?.(item)`, + * `onSubmitProp?.(queryText)`), so an authored string reaches a call site; + * - `onApplySuggestion` / `onRejectSuggestion` have NO reader in the package — + * `AIFormAssist` takes `onApply` / `onRefresh`, not these — so they are + * RETIRED. + * + * The TypeScript twins are left as they are here, deliberately: this module + * mirrors the contract and does not edit it. The divergence is pinned — both + * halves of it — in `../__tests__/ai-zod-arms-10859.test.ts`, so a later + * change to either face turns that pin red instead of drifting silently. + * + * ⛔ No `.default()` anywhere in this module — see the "authors no default" + * note in `index.zod.ts`. + * + * @module zod/ai + * @packageDocumentation + */ + +import { z } from 'zod'; +import { BaseSchema } from './base.zod.js'; +import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; + +/** + * The objectui#8178 retirement guidance, one string per retired member so the + * refusal an author reads names the key and the reason (the TypeScript face + * carries the same members as `?: never` tombstones). + */ +const retiredAiMember = (node: string, key: string, detail: string) => + retirementTombstone( + `RETIRED (objectui#8178, ADR-0049) — \`${key}\` on \`${node}\` had no reader: ${detail} ` + + 'The value was inert, so it is refused by name rather than silently ignored.', + ); + +/** + * The objectui#9256 family-D guidance: the renderer reads NEITHER content + * channel, so both are refused by name on this face as on the TypeScript one. + */ +const neitherChannel = (node: string, renders: string) => + 'REFUSED (objectui#9256, ADR-0049) — `' + node + '` reads NEITHER content channel: measured with the ' + + 'TypeScript type checker across all 24 registering packages, no renderer read consumes `body` or ' + + '`children` for this node, and `SchemaRenderer` strips both out of the props bag it spreads. An ' + + 'authored value therefore rendered NOTHING — no error, no warning, no element. ' + + `What it renders instead: ${renders}.`; + +/** + * AI Configuration — mirrors `AIConfig` (`../ai.ts`). + */ +export const AIConfigSchema = z.object({ + provider: z.enum(['openai', 'anthropic', 'google', 'azure', 'custom']).optional().describe('AI provider to use'), + // `AIModelType | string` on the TypeScript face — the named models are + // examples, not a closed set, so the accepted value is any string. + model: z.string().optional().describe('Model identifier'), + apiEndpoint: z.string().optional().describe('Custom API endpoint URL'), + temperature: z.number().optional().describe('Sampling temperature (0-1)'), + maxTokens: z.number().optional().describe('Maximum tokens for response'), + systemPrompt: z.string().optional().describe('System prompt for the AI model'), +}); + +/** + * AI Field Suggestion — mirrors `AIFieldSuggestion` (`../ai.ts`). + */ +export const AIFieldSuggestionSchema = z.object({ + fieldName: z.string().describe('Name of the field being suggested'), + value: z.any().describe('Suggested value for the field'), + confidence: z.number().describe('Confidence score (0-1)'), + reasoning: z.string().optional().describe('Explanation for the suggestion'), +}); + +/** + * AI Form Assist Schema — mirrors `AIFormAssistSchema` (`../ai.ts`). + */ +export const AIFormAssistSchema = BaseSchema.extend({ + type: z.literal('ai-form-assist'), + formId: retiredAiMember( + 'ai-form-assist', + 'formId', + '`AIFormAssist` takes `({ schema, onApply, onRefresh })` and never reads it.', + ), + objectName: retiredAiMember( + 'ai-form-assist', + 'objectName', + '`AIFormAssist` reaches no data source at all.', + ), + fields: retiredAiMember( + 'ai-form-assist', + 'fields', + 'each suggestion already names its own `fieldName`.', + ), + context: z.record(z.string(), z.any()).optional().describe('Additional context for generating suggestions'), + config: AIConfigSchema.optional().describe('AI configuration'), + suggestions: z.array(AIFieldSuggestionSchema).optional().describe('Current suggestions'), + autoFill: retiredAiMember( + 'ai-form-assist', + 'autoFill', + 'the component destructured it and never used the binding; apply suggestions through `onApply`.', + ), + showConfidence: z.boolean().optional().describe('Show confidence scores for suggestions'), + showReasoning: z.boolean().optional().describe('Show reasoning for suggestions'), + onApplySuggestion: handlerKeyRefusal('onApplySuggestion', 'retired', 'Callback when a suggestion is applied'), + onRejectSuggestion: handlerKeyRefusal('onRejectSuggestion', 'retired', 'Callback when a suggestion is rejected'), + body: retirementTombstone(neitherChannel('ai-form-assist', '`showConfidence`, `showReasoning`, `suggestions`')), + children: retirementTombstone(neitherChannel('ai-form-assist', '`showConfidence`, `showReasoning`, `suggestions`')), +}); + +/** + * AI Recommendation Item — mirrors `AIRecommendationItem` (`../ai.ts`). + */ +export const AIRecommendationItemSchema = z.object({ + id: z.string().describe('Unique identifier'), + title: z.string().describe('Recommendation title'), + description: z.string().optional().describe('Recommendation description'), + score: z.number().describe('Relevance score (0-1)'), + category: z.string().optional().describe('Recommendation category'), + metadata: z.record(z.string(), z.any()).optional().describe('Additional metadata'), + action: z + .object({ + type: z.string().describe('Action type'), + target: z.string().optional().describe('Action target'), + }) + .optional() + .describe('Action to perform when selected'), +}); + +/** + * AI Recommendations Schema — mirrors `AIRecommendationsSchema` (`../ai.ts`). + */ +export const AIRecommendationsSchema = BaseSchema.extend({ + type: z.literal('ai-recommendations'), + objectName: retiredAiMember( + 'ai-recommendations', + 'objectName', + '`AIRecommendations` renders the `recommendations` it is handed and fetches nothing.', + ), + context: z.record(z.string(), z.any()).optional().describe('Additional context for generating recommendations'), + config: AIConfigSchema.optional().describe('AI configuration'), + recommendations: z.array(AIRecommendationItemSchema).optional().describe('Current recommendations'), + maxResults: retiredAiMember( + 'ai-recommendations', + 'maxResults', + '`AIRecommendations` renders EVERY item in `recommendations`; cap the array before handing it over.', + ), + showScores: z.boolean().optional().describe('Show relevance scores'), + // `'carousel'` left this union with objectui#10330 (never implemented), so + // the two layouts the renderer draws are the whole vocabulary. + layout: z.enum(['list', 'grid']).optional().describe('Display layout'), + onSelect: handlerKeyRefusal('onSelect', 'runtime-slot', 'Callback when a recommendation is selected'), + onDismiss: handlerKeyRefusal('onDismiss', 'runtime-slot', 'Callback when a recommendation is dismissed'), + loading: z.boolean().optional().describe('Loading state'), + emptyMessage: z.string().optional().describe('Message to display when no recommendations are available'), + body: retirementTombstone( + neitherChannel('ai-recommendations', '`emptyMessage`, `layout`, `loading`, `recommendations`, `showScores`'), + ), + children: retirementTombstone( + neitherChannel('ai-recommendations', '`emptyMessage`, `layout`, `loading`, `recommendations`, `showScores`'), + ), +}); + +/** + * Natural Language Query Result — mirrors `NLQueryResult` (`../ai.ts`). + */ +export const NLQueryResultSchema = z.object({ + query: z.string().describe('Original query string'), + parsedQuery: z.record(z.string(), z.any()).optional().describe('Parsed query representation'), + data: z.array(z.any()).optional().describe('Result data'), + columns: z + .array( + z.object({ + name: z.string().describe('Column name'), + label: z.string().optional().describe('Display label'), + type: z.string().optional().describe('Column data type'), + }), + ) + .optional() + .describe('Column definitions for the result data'), + summary: z.string().optional().describe('AI-generated summary of the results'), + confidence: z.number().optional().describe('Confidence score for the query interpretation (0-1)'), +}); + +/** + * Natural Language Query Schema — mirrors `NLQuerySchema` (`../ai.ts`). + */ +export const NLQuerySchema = BaseSchema.extend({ + type: z.literal('nl-query'), + objectName: retiredAiMember( + 'nl-query', + 'objectName', + '`NLQueryInput` collects a query string and hands it to `onSubmit`; scope the query in the host that answers it.', + ), + placeholder: z.string().optional().describe('Input placeholder text'), + config: AIConfigSchema.optional().describe('AI configuration'), + result: NLQueryResultSchema.optional().describe('Current query result'), + suggestions: z.array(z.string()).optional().describe('Example queries to suggest'), + showHistory: z.boolean().optional().describe('Show query history'), + history: z + .array( + z.object({ + query: z.string().describe('Query string'), + timestamp: z.string().describe('Timestamp of the query'), + }), + ) + .optional() + .describe('Query history entries'), + loading: z.boolean().optional().describe('Loading state'), + onSubmit: handlerKeyRefusal('onSubmit', 'runtime-slot', 'Callback when a query is submitted'), + body: retirementTombstone( + neitherChannel('nl-query', '`history`, `loading`, `placeholder`, `result`, `showHistory`, `suggestions`'), + ), + children: retirementTombstone( + neitherChannel('nl-query', '`history`, `loading`, `placeholder`, `result`, `showHistory`, `suggestions`'), + ), +}); + +/** + * Union of the AI component arms — the category member `AnyComponentSchema` + * lists (objectui#10859). + */ +export const AIComponentSchema = z.discriminatedUnion('type', [ + AIFormAssistSchema, + AIRecommendationsSchema, + NLQuerySchema, +]); diff --git a/packages/types/src/zod/base.zod.ts b/packages/types/src/zod/base.zod.ts index c77e70b866..2d9d6d034f 100644 --- a/packages/types/src/zod/base.zod.ts +++ b/packages/types/src/zod/base.zod.ts @@ -213,8 +213,8 @@ export function defineNodeComponentUnion(union: T): T { * * ## ⚠️ Why the arm is late-bound and not imported * - * `AnyComponentSchema` is built in `index.zod.ts` out of all 13 category modules, - * and 14 modules import THIS one — so naming it here is a module cycle, and + * `AnyComponentSchema` is built in `index.zod.ts` out of every category module, and + * each of those modules imports THIS one — so naming it here is a module cycle, and * `z.lazy` defers the EVALUATION, not the module graph. With that import in place, * entering the graph at `base.zod.js` evaluates `app.zod.ts`'s body while * `BaseSchema` is still in its temporal dead zone and the package throws on import. diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 0ec969c991..981b81c455 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -393,6 +393,20 @@ export { ViewComponentSchema, } from './views.zod.js'; +// ============================================================================ +// AI Components - `@object-ui/plugin-ai` (objectui#10859) +// ============================================================================ +export { + AIConfigSchema, + AIFieldSuggestionSchema, + AIFormAssistSchema, + AIRecommendationItemSchema, + AIRecommendationsSchema, + NLQueryResultSchema, + NLQuerySchema, + AIComponentSchema, +} from './ai.zod.js'; + // ============================================================================ // Union Types - All Component Schemas // ============================================================================ @@ -412,6 +426,7 @@ import { ObjectQLComponentSchema } from './objectql.zod.js'; import { CRUDComponentSchema } from './crud.zod.js'; import { ReportUnionSchema } from './reports.zod.js'; import { ViewComponentSchema } from './views.zod.js'; +import { AIComponentSchema } from './ai.zod.js'; /** * Union of all component schemas. @@ -421,8 +436,8 @@ import { ViewComponentSchema } from './views.zod.js'; * `z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, and `SchemaNodeSchema` * resolves its component arm to THIS union, so a nested node is judged by its own * component schema at every depth instead of by the ~21 base keys. The wiring is a - * late-binding holder rather than an import because 14 modules import `base.zod.js` - * and this module is built from all 13 category modules — the full reasoning, and + * late-binding holder rather than an import because the category modules import + * `base.zod.js` and this module is built from all of them — the full reasoning, and * what the UNFILLED holder answers, live on `SchemaNodeSchema` in `base.zod.ts`. * * ⚠️ The fill is written as this const's own initializer, not as a statement beside @@ -477,6 +492,7 @@ export const AnyComponentSchema = defineNodeComponentUnion(z.discriminatedUnion( CRUDComponentSchema, ReportUnionSchema, ViewComponentSchema, + AIComponentSchema, ], { // Zod's default message for a missed discriminator spells out EVERY accepted // literal — measured, 1,462 chars naming all 107. That is the "print every diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 60148e6277..2812b0f535 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -217,6 +217,18 @@ export const ADJUDICATED = new Map([ walker: 'not-markdown: sweeps package `src` directories for `.ts` sources', }, ], + // objectui#10859. Extracts `@object-ui/plugin-ai`'s README "Schema-Driven + // Usage" block and runs it through `objectui validate` end to end -- so an edit + // to that README IS an edit to this test's input. The scanner also resolves the + // bare `'README.md'` segment of that path against this package and the root; + // an `fs` trace of the file's run opened the plugin-ai README and nothing else. + [ + 'packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts', + { + reads: ['packages/plugin-ai/README.md'], + notRead: ['README.md', 'packages/cli/README.md'], + }, + ], [ 'packages/components/src/__tests__/div-guidance-names-box.test.tsx', { @@ -405,6 +417,19 @@ export const ADJUDICATED = new Map([ walker: 'markdown-tree: walks `content/docs` and refuses the retired callback shape in every `.md`/`.mdx` under it', }, ], + // objectui#10859. Extracts `@object-ui/plugin-ai`'s README "Schema-Driven + // Usage" block and runs it through `safeValidateSchema` and the strict + // authoring face -- the `timeline-readme-schema-example-10824.test.ts` shape. + // The scanner also resolves the bare `'README.md'` segment of that path against + // this package and the root; an `fs` trace of the file's run opened the + // plugin-ai README and nothing else. + [ + 'packages/types/src/__tests__/ai-zod-arms-10859.test.ts', + { + reads: ['packages/plugin-ai/README.md'], + notRead: ['README.md', 'packages/types/README.md'], + }, + ], [ 'packages/types/src/__tests__/alert-dialog-read-dialect-7104.test.ts', { reads: ['content/docs/components/overlay/alert-dialog.mdx'] },