diff --git a/.changeset/8498-any-component-union-discriminated.md b/.changeset/8498-any-component-union-discriminated.md new file mode 100644 index 0000000000..b84b746698 --- /dev/null +++ b/.changeset/8498-any-component-union-discriminated.md @@ -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. diff --git a/packages/cli/src/__tests__/validate-root-path-line.test.ts b/packages/cli/src/__tests__/validate-root-path-line.test.ts index d99ed5d54b..5ff604b34b 100644 --- a/packages/cli/src/__tests__/validate-root-path-line.test.ts +++ b/packages/cli/src/__tests__/validate-root-path-line.test.ts @@ -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 @@ -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 @@ -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. @@ -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(); @@ -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 () => { @@ -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'); }); }); diff --git a/packages/cli/src/__tests__/validate-union-arm-selection.test.ts b/packages/cli/src/__tests__/validate-union-arm-selection.test.ts index 0bcd627fe3..d04c527388 100644 --- a/packages/cli/src/__tests__/validate-union-arm-selection.test.ts +++ b/packages/cli/src/__tests__/validate-union-arm-selection.test.ts @@ -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', @@ -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); }); @@ -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)); @@ -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. diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index c487b91258..63dc6cd451 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -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) => { diff --git a/packages/cli/src/utils/union-arm-diagnostics.ts b/packages/cli/src/utils/union-arm-diagnostics.ts index 7129aba6e8..080e4d9175 100644 --- a/packages/cli/src/utils/union-arm-diagnostics.ts +++ b/packages/cli/src/utils/union-arm-diagnostics.ts @@ -11,12 +11,15 @@ * * ## What this exists for * - * `safeValidateSchema` runs `AnyComponentSchema`, a `z.union`. When a document - * matches no arm, Zod 4 reports ONE top-level issue — `invalid_union` · `Invalid - * input` · `path: []` — and hangs every arm's real diagnosis off that issue's + * `safeValidateSchema` runs `AnyComponentSchema`. When it was a `z.union`, a + * document matching no arm got ONE top-level issue — `invalid_union` · `Invalid + * input` · `path: []` — with every arm's real diagnosis hung off that issue's * `errors` array. `validate.ts` used to print only the top level, so the author * got a bare verdict on the whole document even when the schema had diagnosed - * the defect precisely, remediation text and all. + * the defect precisely, remediation text and all. ⚠️ Since objectui#8498 that + * union discriminates on `type`: the root issue sits at `['type']` when nothing + * matches and there is no root union issue at all when something does; the shape + * above still arrives from the undiscriminated unions at nested slots. * * The 2026-09-02 maintainer ruling chose **B — discriminator-selected arm**: * print the issues of the single arm that accepts the authored `type`, and @@ -29,21 +32,33 @@ * `validate.ts`, which remains the repository's only zod-issue printer — so no * second rendering surface is created and no shared renderer is warranted. * - * ## Measured facts this rests on (Zod 4.4.3, measured on this tree) + * ## Measured facts this rests on (Zod 4.4.3, re-measured on this tree) * - * 1. `errors` is positionally aligned with the union's options: one entry per - * member of `AnyComponentSchema`, `errors[i]` being option `i`'s issues. - * Selection here never relies on that alignment — see (2) — but it is why - * the arm lists can be read as arms at all. - * 2. **An arm names the literals it accepts, in its own issues.** Two shapes do - * it, and they are the only two: - * - `invalid_value` at `['type']`, carrying `values: ['app']` — an object - * arm whose `type` is a `z.literal`; - * - `invalid_union` at `['type']` with `note: 'No matching discriminator'`, - * carrying `options: ['div', 'box', …]` — a `z.discriminatedUnion` arm. - * So the accepted-literal set is derivable from the error tree ALONE. This - * module therefore never imports or introspects the schema, and cannot drift - * from it. + * 1. A union that TRIES EVERY ARM reports `errors`, positionally aligned with + * its options: `errors[i]` is option `i`'s issues. Selection here never + * relies on that alignment — see (2) — but it is why the arm lists can be + * read as arms at all. ⚠️ `AnyComponentSchema` stopped being such a union in + * objectui#8498: it discriminates on `type`, so it produces NO `errors` array + * at all. This fact now describes the unions that remain undiscriminated — + * `MenuItemSchema` and its kind, reached at nested slots — which is where the + * fallback at the bottom of `explain` earns its keep. + * 2. **A union names the literals it accepts.** THREE shapes do it, and (c) is + * the one objectui#8498 added — the previous count of two was a totality + * claim, and it is restated rather than patched because it went false: + * - (a) `invalid_value` at `['type']`, carrying `values: ['app']` — an + * object arm whose `type` is a `z.literal`; + * - (b) `invalid_union` at `['type']` with `note: 'No matching + * discriminator'` and `options: ['div', 'box', …]` — a + * `z.discriminatedUnion` sitting as an ARM of a wider union, reporting + * relative to its own node; + * - (c) the same `invalid_union` · `No matching discriminator` · `options` + * issue reported for the union the document was measured against + * DIRECTLY, at `[…node, 'type']` — no arms, nothing to select among, + * because the discriminator already answered. `AnyComponentSchema` is + * this shape now, so it is the shape `objectui validate` meets first. + * So the accepted-literal set is still derivable from the error tree ALONE. + * This module therefore never imports or introspects the schema, and cannot + * drift from it. * 3. **Paths inside `errors` are RELATIVE to their union's node.** The nested * union at `['items', 0]` reports its arm issues at `['type']`, not at * `['items', 0, 'type']`. Printing them raw would name the wrong node, so @@ -93,6 +108,8 @@ export interface UnionIssueLike { values?: readonly unknown[]; /** Present on a discriminated union's `No matching discriminator`. */ options?: readonly unknown[]; + /** The key that union dispatches on. ⛔ Not always `type` — see the guard. */ + discriminator?: string; note?: string; } @@ -243,6 +260,69 @@ function isUnion(issue: UnionIssueLike): boolean { return issue.code === 'invalid_union' && Array.isArray(issue.errors); } +/** Zod's own wording for the shape (b)/(c) issues of header fact 2. */ +const NO_MATCHING_DISCRIMINATOR = 'No matching discriminator'; + +/** + * The ruling's "no arm accepts" line, built for one node. + * + * Shared by both routes to that branch — the arm walk below, and the + * discriminated shape (c) — because it is ONE ruling: a note naming the + * authored `type`, plus a capped list of the nearest accepted literals. Two + * copies would be two places for the cap to drift. + */ +function noArmAccepts( + node: readonly PropertyKey[], + literals: readonly string[], + document: unknown, +): UnionArmNote { + const authoredType = authoredTypeAt(document, node); + const { candidates, total } = nearestArmNames(authoredType, literals); + return { + kind: 'note', + path: [...node], + ...(authoredType === undefined ? {} : { authoredType }), + candidates, + totalArmNames: total, + }; +} + +/** + * Header fact 2 shape (c): the union the document was measured against IS + * discriminated and the authored `type` selected nothing. + * + * Zod reports one issue and NO arms — `errors` is present but empty, the + * literals live in `options`, and the path ends at the discriminator key + * (`[…node, 'type']`), so the node itself is that path minus its last segment. + * Returning `undefined` for every other issue keeps this off the arm-walk path + * entirely: an `invalid_union` that carries real arms still goes there, and one + * carrying neither arms nor options is still not this module's business. + */ +function noMatchingDiscriminator( + issue: UnionIssueLike, + document: unknown, +): UnionArmNote | undefined { + if (issue.code !== 'invalid_union') return undefined; + if (issue.note !== NO_MATCHING_DISCRIMINATOR) return undefined; + if (!Array.isArray(issue.options)) return undefined; + if ((issue.errors ?? []).length > 0) return undefined; + // ⚠️ NOT every discriminated union is keyed on `type`, and this branch is only + // correct for the ones that are: `@objectstack/spec`'s `ViewDataSchema` is + // `discriminatedUnion('provider', …)` and rides `.data` on `object-grid` and + // three siblings. ⛔ Both conditions, establishing different facts: the path + // ending at `type` is what makes `slice(0, -1)` the node, the discriminator + // being `type` is what makes the note's wording and `authoredTypeAt` right. + // Zod 4.4.3 fills both from `def.discriminator` (schemas.js:1187/1190) so they + // cannot disagree today — written this way, a shape that ever separates them + // falls through to SILENT rather than to WRONG. + const path = issue.path ?? []; + if (path[path.length - 1] !== 'type') return undefined; + if (issue.discriminator !== undefined && issue.discriminator !== 'type') return undefined; + const literals = issue.options.filter((v): v is string => typeof v === 'string'); + if (literals.length === 0) return undefined; + return noArmAccepts(path.slice(0, -1), literals, document); +} + function isAtTypeKey(issue: UnionIssueLike): boolean { const path = issue.path ?? []; return path.length === 1 && path[0] === 'type'; @@ -307,6 +387,8 @@ function explain( const expand = (armIssues: readonly UnionIssueLike[], arm?: string): UnionArmLine[] => armIssues.flatMap((issue) => { const absolute = [...prefix, ...(issue.path ?? [])]; + const discriminated = noMatchingDiscriminator({ ...issue, path: absolute }, document); + if (discriminated) return [discriminated]; if (isUnion(issue)) return explain(issue, absolute, document); return [ { @@ -327,17 +409,7 @@ function explain( // B's other half — the union IS discriminated and no arm accepts. if (declaringArms.length > 0 && acceptingIndexes.length === 0) { - const authoredType = authoredTypeAt(document, prefix); - const { candidates, total } = nearestArmNames(authoredType, declaringArms.flat()); - return [ - { - kind: 'note', - path: [...prefix], - ...(authoredType === undefined ? {} : { authoredType }), - candidates, - totalArmNames: total, - }, - ]; + return [noArmAccepts(prefix, declaringArms.flat(), document)]; } // No discriminator to select on (or, defensively, more than one accepting @@ -354,6 +426,8 @@ function explain( * can call it unconditionally. */ export function explainUnionIssue(issue: UnionIssueLike, document: unknown): UnionArmLine[] { + const discriminated = noMatchingDiscriminator(issue, document); + if (discriminated) return [discriminated]; if (!isUnion(issue)) return []; return explain(issue, issue.path ?? [], document); } diff --git a/packages/types/src/__tests__/any-component-union-fanout.test.ts b/packages/types/src/__tests__/any-component-union-fanout.test.ts new file mode 100644 index 0000000000..fa9ef468f2 --- /dev/null +++ b/packages/types/src/__tests__/any-component-union-fanout.test.ts @@ -0,0 +1,183 @@ +/** + * 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. + */ + +/** + * `AnyComponentSchema` discriminates on `type` (objectui#8498). + * + * ## The defect these pin + * + * As a flat `z.union`, a refusal reported EVERY arm's issues under one + * `invalid_union`, and Zod's `$ZodError` initializer `JSON.stringify`s that + * whole tree into `.message` EAGERLY — `zod/v4/core/errors.js:13`, in the + * constructor, not behind a getter. So the cost was paid whether or not anyone + * read the message, and it compounded per level of nesting until `safeParse` + * itself threw `RangeError: Invalid string length` — out of `safeValidateSchema`, + * which `index.zod.ts` documents as validating "without throwing errors". + * + * ⚠️ Every bound below is written to FAIL on the flat union, and each one was + * run against it to check that it does. The readings, same documents, same zod + * 4.4.3, flat -> discriminated: + * + * known-type leaf refusal 14,624 -> 164 chars (13 arm subtrees -> none) + * unknown `type` refusal 14,855 -> 2,178 chars + * refused node 4 deep 19,311 -> 4,330 chars + * + * A bound that also passed on the flat union would assert nothing, which is the + * failure mode this card is most exposed to: `AnyComponentSchema` does not yet + * recurse into child slots (objectui#7869 / objectui#8344), so a nested document + * is simply ACCEPTED and a naive "does not throw at depth 4" test is green for + * the wrong reason. The depth case below is therefore built on `MenuItemSchema`, + * which ALREADY refuses at depth on this tree. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { AnyComponentSchema, safeValidateSchema } from '../zod/index.zod.js'; +import { CRUDComponentSchema } from '../zod/crud.zod.js'; +import { ObjectQLComponentSchema } from '../zod/objectql.zod.js'; + +/** A leaf node whose `type` selects exactly one arm, failing on that arm's key. */ +const OFF_SPEC_ICON = { type: 'icon', icon: 'check', size: 'huge' }; + +/** A `type` no arm claims. */ +const FOREIGN_DOCUMENT = { type: 'module', main: './index.js' }; + +/** + * A menu item nested `depth` levels down whose `type` is an ADR-0049 retirement + * tombstone. `MenuItemSchema` (`overlay.zod.ts`) recurses through `children`, + * and `dropdown-menu` reaches it from the document root — so this refuses AT + * DEPTH on the flat union too, which is what makes the bound below a reading + * rather than a formality. + */ +function menuWithRefusedItemAt(depth: number) { + const item = (n: number): Record => + n === 0 ? { label: 'bad', type: 'separator' } : { label: 'ok', children: [item(n - 1)] }; + return { type: 'dropdown-menu', trigger: 'x', items: [item(depth)] }; +} + +/** Every issue in the tree, counting the per-arm lists a flat union hangs off. */ +function issueNodeCount(issues: readonly { errors?: readonly (readonly unknown[])[] }[]): number { + return issues.reduce((n, issue) => { + const arms = Array.isArray(issue.errors) ? issue.errors : []; + return n + 1 + arms.reduce((m, arm) => m + issueNodeCount(arm as never), 0); + }, 0); +} + +/** The `type` literals a schema declares to Zod's discriminator dispatch. */ +function literalsOf(schema: unknown): string[] { + const values = (schema as { _zod?: { propValues?: { type?: Set } } })._zod?.propValues + ?.type; + return values === undefined ? [] : [...values]; +} + +describe('AnyComponentSchema — the property discriminating rests on', () => { + it('claims every `type` literal exactly once across the arms', () => { + // Stated as a property, not as a count: the number of arms and literals + // moves every time a component lands, the distinctness does not — and it is + // the distinctness that makes "the arm the literal selects" the ONLY arm + // that could have accepted the document. `union-arm-diagnostics.ts` states + // the same invariant as its fact 4. + const literals = literalsOf(AnyComponentSchema); + expect(literals.length).toBeGreaterThan(0); + expect(new Set(literals).size).toBe(literals.length); + }); + + it('detects a collision when there is one', () => { + // The firing control for the assertion above: two arms claiming `div` must + // read as a duplicate, or the check above proves nothing. + const a = z.object({ type: z.literal('div') }); + const b = z.object({ type: z.literal('div'), other: z.string() }); + const literals = [...literalsOf(a), ...literalsOf(b)]; + expect(new Set(literals).size).not.toBe(literals.length); + }); + + it('lets both nested unions declare their literals too', () => { + // Zod 4.4.3 REFUSES a plain `z.union` as a discriminated member — it + // computes no `propValues`, so it declares nothing to dispatch on + // (measured: `Invalid discriminated union option at index "9"`). These two + // were the last flat sites, and `AnyComponentSchema` cannot discriminate + // while either of them is one. + expect(literalsOf(ObjectQLComponentSchema)).toContain('object-grid'); + expect(literalsOf(CRUDComponentSchema)).toContain('action'); + // `ActionSchema` reaches its literal through a `z.lazy` its maintainer-ruled + // `z.ZodType<…>` annotation hides from `tsc` — the fact the cast at that arm + // asserts, pinned here so the cast cannot rot into a lie unnoticed. + expect(literalsOf(CRUDComponentSchema)).toEqual( + expect.arrayContaining(['action', 'detail', 'crud-dialog']), + ); + }); +}); + +describe('AnyComponentSchema — a refusal costs one arm, not every arm', () => { + it('hangs no per-arm subtree off a refusal whose `type` selects an arm', () => { + const result = AnyComponentSchema.safeParse(OFF_SPEC_ICON); + expect(result.success).toBe(false); + if (result.success) return; + // THE mechanism assertion. On the flat union this was 13 arm lists — one + // per member — and it is that array, stringified per level, that grew ~25x + // per level of nesting. Discriminated, the selected arm's issues ARE the + // top-level issues, so there is no array to multiply. + for (const issue of result.error.issues) { + expect((issue as { errors?: unknown[] }).errors ?? []).toHaveLength(0); + } + expect(issueNodeCount(result.error.issues as never)).toBeLessThanOrEqual(4); + expect(result.error.message.length).toBeLessThanOrEqual(1_000); + }); + + it('leaves a non-object root its own diagnosis', () => { + // The message override is scoped to `invalid_union`; an unconditional one is + // scoped to the whole schema and swallowed this, leaving a bare "Invalid + // input" for every non-component document. + const result = AnyComponentSchema.safeParse(42); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0].code).toBe('invalid_type'); + expect(result.error.issues[0].message).toContain('expected object, received number'); + }); + + it('bounds the message when no arm claims the authored `type`', () => { + const result = AnyComponentSchema.safeParse(FOREIGN_DOCUMENT); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message.length).toBeLessThanOrEqual(4_000); + // The literals stay ON the issue, which is where `@object-ui/cli`'s + // `union-arm-diagnostics` reads them to build the CAPPED candidate list the + // 2026-09-02 maintainer ruling requires. Only the default MESSAGE — which + // spelled all of them out inline — was replaced. + const [issue] = result.error.issues as { options?: unknown[]; note?: string }[]; + expect(issue.note).toBe('No matching discriminator'); + expect(issue.options?.length).toBeGreaterThan(50); + }); +}); + +describe('safeValidateSchema — a refused node deep in a document', () => { + it('stays bounded four levels down, where the flat union did not', () => { + // The card's title case. `safeValidateSchema` is documented as validating + // "without throwing errors"; the flat union broke that promise by building + // a message no `String` can hold. A bound is the durable form of the same + // claim — it fires long before `RangeError` does. + const result = safeValidateSchema(menuWithRefusedItemAt(4)); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.message.length).toBeLessThanOrEqual(8_000); + expect(issueNodeCount(result.error.issues as never)).toBeLessThanOrEqual(40); + // The diagnosis still reaches the author — a bound met by reporting + // NOTHING would be the wrong repair. + expect(result.error.message).toContain('RETIRED (objectui#6523)'); + }); + + it('still accepts the same document with a legal item at that depth', () => { + // The control that keeps the bound honest about WHERE the cost was: the + // green path was never expensive, and must still be green. + const legal = (n: number): Record => + n === 0 ? { label: 'leaf' } : { label: 'ok', children: [legal(n - 1)] }; + expect( + safeValidateSchema({ type: 'dropdown-menu', trigger: 'x', items: [legal(4)] }).success, + ).toBe(true); + }); +}); diff --git a/packages/types/src/zod/crud.zod.ts b/packages/types/src/zod/crud.zod.ts index 8860e86ad2..a5e45b4c7b 100644 --- a/packages/types/src/zod/crud.zod.ts +++ b/packages/types/src/zod/crud.zod.ts @@ -199,9 +199,25 @@ export const CRUDDialogSchema = BaseSchema.extend({ /** * Union of all CRUD schemas + * + * `z.discriminatedUnion`, not `z.union` (objectui#8498) — the reasoning lives + * once, on `index.zod.ts#AnyComponentSchema`. This site changed because zod + * 4.4.3 REFUSES a plain `z.union` as a member of a discriminated union (it + * computes no `propValues`), so the root cannot discriminate until this one + * does. ⛔ Not an accept-set change: all three arms already declared `type` as a + * distinct `z.literal` (`action` · `detail` · `crud-dialog`). */ -export const CRUDComponentSchema = z.union([ - ActionSchema, +export const CRUDComponentSchema = z.discriminatedUnion('type', [ + // `ActionSchema`'s `z.ZodType` annotation + // is a maintainer ruling (objectui#7760, decision batch #69) and ⛔ does not + // move here. That type declares `_zod.propValues` as `PropValues | undefined`, + // so `tsc` cannot see through the `z.lazy` to the `type: z.literal('action')` + // the body really declares — the RUNTIME can, measured: + // `ActionSchema._zod.propValues.type` is `Set { 'action' }`. The intersection + // asserts that one fact and nothing else, leaving the output type intact so + // this union infers what it always did; `__tests__/any-component-union-fanout + // .test.ts` pins the fact at runtime so the cast cannot rot into a lie. + ActionSchema as typeof ActionSchema & z.core.$ZodTypeDiscriminable<'type'>, DetailSchema, CRUDDialogSchema, ]); diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index d7db88be29..8f1f0762e5 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -367,8 +367,29 @@ import { ViewComponentSchema } from './views.zod.js'; /** * Union of all component schemas. * Use this for generic component rendering where the type is determined at runtime. + * + * ## Why this is discriminated (objectui#8498) + * + * A flat `z.union` reports EVERY arm's issues under one `invalid_union`, and + * Zod's `$ZodError` initializer `JSON.stringify`s that whole tree into + * `.message` EAGERLY — `zod/v4/core/errors.js:13`, in the constructor, not + * behind a getter. The cost is paid whether or not anyone reads the message, + * and it compounds once a refused node can appear at a child slot: a root-level + * refusal cost 14,624 chars, growing ~25x per level of nesting until + * `RangeError: Invalid string length` — a THROW out of the function this file + * documents below as validating "without throwing". Discriminating selects ONE + * arm from the authored literal: same document, same zod 4.4.3, 164 chars. + * + * ⛔ Not an accept-set change, and the property it rests on is measured rather + * than assumed: the 13 arms declare 107 `type` literals with ZERO collisions, + * so the arm a literal selects is the only arm that could ever have accepted + * it. ⚠️ Every arm must declare its literals to Zod — a plain `z.union` member + * computes no `propValues` and is REFUSED here (`Invalid discriminated union + * option at index "9"`), which is why `objectql.zod.ts` and `crud.zod.ts` are + * discriminated too. `__tests__/any-component-union-fanout.test.ts` pins all of + * it. */ -export const AnyComponentSchema = z.union([ +export const AnyComponentSchema = z.discriminatedUnion('type', [ AppComponentSchema, LayoutSchema, FormComponentSchema, @@ -382,7 +403,19 @@ export const AnyComponentSchema = z.union([ CRUDComponentSchema, ReportUnionSchema, ViewComponentSchema, -]); +], { + // 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 + // arm" output the 2026-09-02 maintainer ruling rejected as noise, arriving by + // the back door on a card about message size. `Invalid input` is what the flat + // union reported here before, so the published root diagnostic is unchanged; + // the ruling's capped list stays the one place arm names are printed, built by + // `@object-ui/cli` from `issue.options`, which this does not touch. + // ⚠️ Narrowed to `invalid_union`: an unconditional map is scoped to the WHOLE + // schema, so it also rewrote this union's `invalid_type` and a non-object root + // lost "expected object, received number". `undefined` declines to the locale. + error: (issue) => (issue.code === 'invalid_union' ? 'Invalid input' : undefined), +}); /** * Validate a schema against the AnyComponentSchema diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index 37f6f962c1..b4a9d01943 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -1268,8 +1268,13 @@ export const ObjectDataTableSchema = BaseSchema.extend({ * Both nodes render (`plugin-list` registers `object-gallery`, `plugin-dashboard` * registers `object-data-table`); this is the validating face catching up with * the rendering one. The behaviour pin is `__tests__/objectql-union-arms-7363.test.ts`. + * + * `z.discriminatedUnion`, not `z.union` (objectui#8498) — see the same note on + * `crud.zod.ts#CRUDComponentSchema` for both reasons. All twelve arms already + * declared a distinct `type` literal, so ⛔ no document changes verdict; what + * changes is that a refusal now carries ONE arm's diagnosis instead of twelve. */ -export const ObjectQLComponentSchema = z.union([ +export const ObjectQLComponentSchema = z.discriminatedUnion('type', [ ObjectGridSchema, ObjectFormSchema, ObjectViewSchema,