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
27 changes: 27 additions & 0 deletions .changeset/8498-any-component-union-discriminated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@object-ui/types': minor
'@object-ui/cli': minor
---

Discriminate `AnyComponentSchema` on `type` (objectui#8498).

The union was flat, so a refusal carried EVERY arm's issue list, and Zod's
`$ZodError` initializer stringifies that whole tree into `.message` eagerly — in the
constructor, not behind a getter. The cost was paid whether or not anyone read the
message, and it compounded per level of nesting: measured on zod 4.4.3, a root
refusal cost 14,624 chars, growing until `RangeError: Invalid string length`, thrown
out of `safeValidateSchema` — documented as validating "without throwing errors".
Discriminated, the same document costs 164. `ObjectQLComponentSchema` and
`CRUDComponentSchema` follow for the same reason: zod refuses a plain `z.union` as a
discriminated member.

**No document changes verdict.** The 13 arms declare 107 `type` literals with zero
collisions, so the arm a literal selects was already the only arm that could accept
it; across 440 example documents, flat and discriminated agree on every one.

**What moves is diagnostics.** A refused document whose `type` selects an arm now
reports that arm's issues as top-level issues at absolute paths, rather than one
`invalid_union` at the root with them nested inside; a `type` no arm claims is
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.
72 changes: 53 additions & 19 deletions packages/cli/src/__tests__/validate-root-path-line.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@
* in precisely the case a reader most needs oriented.
*
* That case is the common one, not an edge: `safeValidateSchema` runs
* `AnyComponentSchema`, a `z.union` over every component arm, so any document
* matching no arm yields ONE top-level issue — `invalid_union` · `Invalid
* input` · `path: []`. Measured on the parent commit of this file, a menu
* `AnyComponentSchema`. Measured on the parent commit of this file, a menu
* carrying the retired `{ type: 'separator' }` divider spelling printed:
*
* 1. Invalid input
Expand All @@ -30,6 +28,23 @@
* — a bare verdict on a whole document, with nothing saying which node had
* been judged.
*
* ⚠️ WHERE THE ROOT ISSUE COMES FROM MOVED (objectui#8498). This file used to
* say "`AnyComponentSchema` is a `z.union`, so any document matching no arm
* yields ONE top-level issue at `path: []`". That is now false in BOTH halves,
* and the cases below are restated rather than patched:
*
* - the union discriminates on `type`, so a document whose `type` matches
* nothing is judged AT THE DISCRIMINATOR — `invalid_union` at `['type']`,
* printed as `Path: type`, which names the key that actually failed;
* - a document whose `type` DOES select an arm no longer produces a union
* issue at all: the arm's own issues are the top-level ones, already at
* absolute paths.
*
* So the root-path line is now produced by documents that are not component
* objects at all (a bare scalar), and that is the case pinned first below. The
* non-root control is untouched and still load-bearing: a repair that printed
* `(root)` for everything would still be caught by it.
*
* ⚠️ These cases are written against BOTH sides of the guard on purpose. A fix
* that printed `(root)` unconditionally would satisfy a root-only test while
* destroying the real paths authors depend on, so the non-root control below
Expand Down Expand Up @@ -65,9 +80,16 @@ const MENU_WITH_RETIRED_DIVIDER = {
items: [{ label: 'New Tab', type: 'separator' }],
};

/** A document from an entirely foreign vocabulary — the other root producer. */
/** A document from an entirely foreign vocabulary — judged at `type`. */
const FOREIGN_DOCUMENT = { type: 'module', main: './index.js' };

/**
* Not a component object at all. `AnyComponentSchema` cannot even look for a
* discriminator here, so the verdict is about the whole document and its path
* is genuinely `[]` — the shape this file exists to keep visible.
*/
const SCALAR_DOCUMENT = 42;

/**
* The non-root control, lifted from `validate-widget-namespace.test.ts` so both
* files pin the same observed path for the same input.
Expand Down Expand Up @@ -121,8 +143,8 @@ afterEach(() => {
});

describe('objectui validate — a root-level issue says it is at the root', () => {
it('prints a Path line for the union failure that used to print none', async () => {
await validate(writeSchema('menu.json', MENU_WITH_RETIRED_DIVIDER));
it('prints a Path line for the root issue that used to print none', async () => {
await validate(writeSchema('scalar.json', SCALAR_DOCUMENT));

expect(exitCodes).toEqual([1]);
const text = printed();
Expand All @@ -131,14 +153,21 @@ describe('objectui validate — a root-level issue says it is at the root', () =
// adjacent, with nothing between them.
expect(text).toContain('1. Invalid input');
expect(text).toContain('Path: (root)');
expect(text).toContain('Code: invalid_union');
expect(text).toContain('Code: invalid_type');
});

it('does the same for a document from a foreign vocabulary', async () => {
it('names the discriminator, not the root, when no arm claims the type', async () => {
// ⚠️ Was `toContain('Path: (root)')` until objectui#8498. The verdict moved
// to the key it is about, and the `not` half is what keeps this honest: a
// printer that fell back to `(root)` for a union issue would still pass the
// positive half alone.
await validate(writeSchema('package.json', FOREIGN_DOCUMENT));

expect(exitCodes).toEqual([1]);
expect(printed()).toContain('Path: (root)');
const text = printed();
expect(text).toContain('Path: type');
expect(text).toContain('Code: invalid_union');
expect(text).not.toContain('Path: (root)');
});

it('gives EVERY reported issue a Path line, root or not', async () => {
Expand Down Expand Up @@ -214,15 +243,20 @@ describe('objectui validate — the arm-selection half, now that it is ruled', (
// rides the per-arm issues, which is exactly why it never reached an author.
expect(text).toContain('RETIRED (objectui#6523)');
expect(text).toContain('Path: items → 0 → type');
// Unchanged, and load-bearing: the top-level entry still carries the root
// path line this file exists for.
expect(text).toContain('Path: (root)');
// Still exactly one NUMBERED issue. Arm entries are `1.1`-shaped, so a
// reader (and this assertion) can still count the top-level failures — an
// arm walk that emitted them as `2.`, `3.` … would have multiplied this.
const numbered = printed()
.split('\n')
.filter((line) => /^\d+\. /.test(line.trim()));
expect(numbered).toHaveLength(1);
// ⚠️ Was `toContain('Path: (root)')` and `toHaveLength(1)` until
// objectui#8498. With the root discriminated, `dropdown-menu` selects its
// arm outright and THAT ARM'S issues are the top-level ones — this document
// has two independent defects (the retired divider, and a missing required
// `trigger`), so it prints two. What must not happen is the multiplication
// this case was written against: entries contributed by arms the document's
// `type` did NOT select.
const numbered = text.split('\n').filter((line) => /^\d+\. /.test(line.trim()));
expect(numbered).toHaveLength(2);
expect(text).toContain('Path: trigger');
// Every top-level entry belongs to `dropdown-menu`. An arm that merely
// disagreed about `type` would show up as a discriminator complaint.
expect(text).not.toContain('Invalid discriminator value');
expect(text).not.toContain('expected "app"');
expect(text).not.toContain('No arm accepts type');
});
});
95 changes: 92 additions & 3 deletions packages/cli/src/__tests__/validate-union-arm-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ const UNTYPED_DOCUMENT = { items: [] };
*/
const OBJECT_GRID_MISSING_OBJECT_NAME = { type: 'object-grid' };

/**
* `.data` here is `@objectstack/spec`'s `ViewDataSchema`, a
* `z.discriminatedUnion('provider', …)` — the one reachable union in this tree
* keyed on something other than `type`.
*/
const GRID_WITH_PROVIDERLESS_DATA = { type: 'object-grid', objectName: 'x', data: { type: 'rest' } };

/** A failure that is not a union at all — the control for "nothing changed". */
const FORM_WITH_UNRESOLVABLE_WIDGET = {
type: 'form',
Expand Down Expand Up @@ -137,10 +144,13 @@ describe('objectui validate — the arm the document selected', () => {
// at `['type']`, relative to its own node; printing that raw would name the
// document's own `type` key, which is not what failed.
expect(text).toContain('Path: items → 0 → type');
// The top-level entry is still exactly one numbered issue: the arm lines
// are `1.1`-shaped and cannot be read as separate top-level entries.
// ⚠️ `toHaveLength(1)` until objectui#8498: the root now discriminates, so
// `dropdown-menu`'s OWN issues are the top-level entries — two of them for
// this document, one per real defect (the retired divider at `items → 0`,
// and the required `trigger` it never carried). `MenuItemSchema` is still
// an undiscriminated union, so ITS arms are still `1.k` sub-entries.
const numbered = text.split('\n').filter((line) => /^\d+\. /.test(line.trim()));
expect(numbered).toHaveLength(1);
expect(numbered).toHaveLength(2);
expect(armEntries().length).toBeGreaterThan(0);
});

Expand Down Expand Up @@ -204,6 +214,23 @@ describe('objectui validate — when no arm accepts the type', () => {
});
});

describe('objectui validate — a union keyed on something other than `type`', () => {
it('says nothing about arms rather than naming the wrong key', async () => {
// Unguarded this read `data.type` ('rest'), called it unaccepted, and offered
// `ViewDataSchema`'s four PROVIDER names as if they were component types — a
// confident sentence in the ruling's own voice about the wrong key. Silence
// is right: zod's own message already names `provider` and its literals.
await validate(writeSchema('grid.json', GRID_WITH_PROVIDERLESS_DATA));

expect(exitCodes).toEqual([1]);
const text = printed();
expect(text).toContain('Path: data → provider');
expect(text).not.toContain('No arm accepts type');
expect(text).not.toContain('No `type` is declared');
expect(armEntries()).toHaveLength(0);
});
});

describe('objectui validate — the non-union path is untouched', () => {
it('adds no arm entries to an issue that is not a union', async () => {
await validate(writeSchema('form.json', FORM_WITH_UNRESOLVABLE_WIDGET));
Expand Down Expand Up @@ -243,6 +270,68 @@ describe('union-arm-diagnostics — the selection itself', () => {
expect(explainUnionIssue({ code: 'invalid_union', path: [], message: 'x' }, {})).toEqual([]);
});

it('reads the ruling\'s note off a DISCRIMINATED union that matched nothing', () => {
// Header fact 2 shape (c), the shape objectui#8498 made the common one:
// no arms at all, the accepted literals in `options`, and the path ending
// at the discriminator key — so the NODE is that path minus its last
// segment, which is what the note must name.
const lines = explainUnionIssue(
{
code: 'invalid_union',
path: ['items', 0, 'type'],
note: 'No matching discriminator',
errors: [],
options: ['dropdown-menu', 'context-menu', 'menubar', 'card', 'grid', 'div'],
message: 'Invalid input',
},
{ items: [{ type: 'dropdwn-menu' }] },
);
expect(lines).toHaveLength(1);
const [note] = lines;
expect(note.kind).toBe('note');
if (note.kind !== 'note') return;
expect(note.path).toEqual(['items', 0]);
expect(note.authoredType).toBe('dropdwn-menu');
expect(note.candidates[0]).toBe('dropdown-menu');
expect(note.candidates.length).toBeLessThanOrEqual(MAX_UNION_ARMS_REPORTED);
expect(note.totalArmNames).toBe(6);
});

it('declines a discriminator that is not `type`, on either field', () => {
// Both conditions exercised, plus the cases that separate them. Zod fills
// path and `discriminator` from one source, so only a hand-built issue can
// disagree — and one that does must land on SILENT, never on a wrong note.
const doc = { data: { type: 'rest' } };
const note = (path: string[], discriminator?: string) => explainUnionIssue(
{ code: 'invalid_union', note: 'No matching discriminator', errors: [], message: 'x',
options: ['object', 'api', 'value', 'schema'], path, discriminator },
doc,
);
expect(note(['data', 'provider'], 'provider')).toEqual([]);
expect(note(['data', 'provider'])).toEqual([]);
expect(note(['data', 'type'], 'provider')).toEqual([]);
// ...and the `type`-keyed shape still routes, or the three above prove nothing.
expect(note(['data', 'type'], 'type')).toHaveLength(1);
});

it('does NOT read that note off a union that still reports arms', () => {
// The guard that keeps shape (c) off the arm-walk path: an `invalid_union`
// carrying real arms is still selected among, not summarised — otherwise a
// stray `options` would silently replace a whole arm diagnosis with a hint.
const lines = explainUnionIssue(
{
code: 'invalid_union',
path: [],
note: 'No matching discriminator',
options: ['div', 'card'],
errors: [[{ code: 'invalid_type', path: ['type'], message: 'arm one' }]],
message: 'Invalid input',
},
{},
);
expect(lines.every((l) => l.kind === 'issue')).toBe(true);
});

it('reports every arm, capped, when a union has no `type` discriminator', () => {
// `MenuItemSchema`'s two arms both declare `type` as a retirement tombstone,
// so neither names a literal and there is no discriminator to select on.
Expand Down
30 changes: 19 additions & 11 deletions packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,30 @@ export async function validate(schemaPath: string) {
//
// The guard here used to be `issue.path.length > 0`, which dropped the
// line entirely for `path: []` — silent in exactly the case a reader
// most needs oriented. That case is not rare: `safeValidateSchema` runs
// `AnyComponentSchema`, which is a `z.union` of every component arm, so
// ANY document matching no arm reports one top-level issue at the root
// (`invalid_union` · `Invalid input` · `path: []`). Measured before this
// change, a menu carrying the retired `{ type: 'separator' }` divider
// spelling printed `1. Invalid input` and a Code line, and nothing said
// whether the whole document or some node inside it had been judged.
// most needs oriented. Measured before that change, a menu carrying the
// retired `{ type: 'separator' }` divider spelling printed
// `1. Invalid input` and a Code line, and nothing said whether the whole
// document or some node inside it had been judged.
//
// ⚠️ WHERE A ROOT ISSUE COMES FROM MOVED (objectui#8498). This block read
// "`AnyComponentSchema`, a `z.union` of every component arm, so ANY
// document matching no arm reports one top-level issue at the root".
// It discriminates on `type` now and both halves are false: a `type` that
// SELECTS an arm yields that arm's own issues as the top-level entries,
// already absolute, with no union issue at the root; a `type` that matches
// nothing is judged at `['type']`. A root path now means a non-object.
//
// `(root)` is parenthesised so it cannot be read as a real key literally
// named `root` — a genuine path to one would print as `root`.
//
// The ARM-SELECTION half of objectui#7004 landed on the 2026-09-02
// maintainer ruling (option B) and is the block below the three fields:
// when the top-level issue is a failing union, `explainUnionIssue` picks
// the single arm the document's `type` selects and returns ITS issues,
// with their paths rebased to absolute. Everything about WHICH arm lives
// maintainer ruling (option B): print the issues of the arm the authored
// `type` selects, and nothing from the others. Since objectui#8498 ZOD
// does that selecting — a matched discriminator yields the arm's issues
// directly, so there is no union issue here to expand. What
// `explainUnionIssue` still answers is the other half of the ruling: the
// capped candidate note when NO arm accepts, and the undiscriminated
// unions still reached at nested slots. Everything about WHICH arm lives
// in `../utils/union-arm-diagnostics.js`; this file only prints, so it
// stays the repository's only zod-issue printer.
result.error.issues.forEach((issue, index) => {
Expand Down
Loading
Loading