Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/10859-ai-node-zod-arms.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions .changeset/8178-ai-schemas-retire-zero-read-members.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions .changeset/8344-node-recursion-point-redirect.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions .changeset/8498-any-component-union-discriminated.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
6 changes: 6 additions & 0 deletions .changeset/8784-arm-named-export-pin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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]);
});
});
43 changes: 27 additions & 16 deletions packages/types/src/__tests__/ai-insights-retired-8800.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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));
Expand Down Expand Up @@ -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 `<name>.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);
});
});
Loading
Loading