From 808b63bbf7c35675bd6ebdf0324141074959a562 Mon Sep 17 00:00:00 2001 From: objectstack-ai Date: Tue, 8 Sep 2026 08:10:05 +0000 Subject: [PATCH 1/3] fix(types,cli): discriminate `AnyComponentSchema` on `type` so a refusal costs one arm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AnyComponentSchema` was a flat `z.union`, 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. Measured on zod 4.4.3, a root refusal cost 14,624 chars and grew ~25x per level of nesting until `safeParse` threw `RangeError: Invalid string length` out of `safeValidateSchema`, which is documented as validating "without throwing errors". Discriminating on `type` selects one arm from the authored literal: same document, 164 chars. `ObjectQLComponentSchema` and `CRUDComponentSchema` are discriminated too because zod 4.4.3 refuses a plain `z.union` as a discriminated member. The accept set does not move: the 13 arms declare 107 `type` literals with zero collisions, and flat vs discriminated agree on all 440 example documents. `@object-ui/cli`'s `union-arm-diagnostics` learns the discriminated issue shape, so `objectui validate` still prints the 2026-09-02 maintainer ruling's output — the selected arm alone, or a note plus a capped candidate list. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- .../8498-any-component-union-discriminated.md | 27 +++ .../__tests__/validate-root-path-line.test.ts | 72 ++++++-- .../validate-union-arm-selection.test.ts | 54 +++++- .../cli/src/utils/union-arm-diagnostics.ts | 108 ++++++++--- .../any-component-union-fanout.test.ts | 172 ++++++++++++++++++ packages/types/src/zod/crud.zod.ts | 20 +- packages/types/src/zod/index.zod.ts | 34 +++- packages/types/src/zod/objectql.zod.ts | 7 +- 8 files changed, 442 insertions(+), 52 deletions(-) create mode 100644 .changeset/8498-any-component-union-discriminated.md create mode 100644 packages/types/src/__tests__/any-component-union-fanout.test.ts 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..ededadfdb5 100644 --- a/packages/cli/src/__tests__/validate-union-arm-selection.test.ts +++ b/packages/cli/src/__tests__/validate-union-arm-selection.test.ts @@ -137,10 +137,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); }); @@ -243,6 +246,51 @@ 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('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/utils/union-arm-diagnostics.ts b/packages/cli/src/utils/union-arm-diagnostics.ts index 7129aba6e8..e1e25ea941 100644 --- a/packages/cli/src/utils/union-arm-diagnostics.ts +++ b/packages/cli/src/utils/union-arm-diagnostics.ts @@ -29,21 +29,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 @@ -243,6 +255,58 @@ 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; + const literals = issue.options.filter((v): v is string => typeof v === 'string'); + if (literals.length === 0) return undefined; + const path = issue.path ?? []; + 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 +371,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 +393,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 +410,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..5096109317 --- /dev/null +++ b/packages/types/src/__tests__/any-component-union-fanout.test.ts @@ -0,0 +1,172 @@ +/** + * 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,624 -> 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('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..cf1bcbb58f 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,16 @@ 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. + error: () => 'Invalid input', +}); /** * 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, From 42344e912c52d6e1b2292c68b48744abc11556e7 Mon Sep 17 00:00:00 2001 From: objectstack-ai Date: Tue, 8 Sep 2026 09:21:26 +0000 Subject: [PATCH 2/3] fix(cli,types): the no-arm note is for `type`-keyed unions only; keep a non-object root its own message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects from the in-seat contract review of PR #8544. 1. `noMatchingDiscriminator` routed on `note`/`options`/empty-`errors` alone, so a discriminated union keyed on anything else was summarised as if it were keyed on `type`. `@objectstack/spec`'s `ViewDataSchema` is `discriminatedUnion('provider', ...)` and rides `.data` on `object-grid` and three siblings, so an authored `{ type: 'object-grid', data: { ... } }` printed a confident, ruling-shaped sentence naming the wrong key and offering provider names as component types. The merge base printed nothing there — silent, not wrong — so this was a silent-to-wrong regression. Guarded on both the path's last segment and the `discriminator` field: they establish different facts, and a shape that ever separates them now falls through to silent rather than to wrong. 2. The union's message override was unconditional, so it was scoped to the whole schema and also replaced its `invalid_type`: a non-object root lost "expected object, received number". Narrowed to `invalid_union`. Also corrects stale prose the discriminated root falsified, in the reader's header and in one figure in the new test's header. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- .../validate-union-arm-selection.test.ts | 41 +++++++++++++++++++ .../cli/src/utils/union-arm-diagnostics.ts | 26 +++++++++--- .../any-component-union-fanout.test.ts | 13 +++++- packages/types/src/zod/index.zod.ts | 5 ++- 4 files changed, 78 insertions(+), 7 deletions(-) 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 ededadfdb5..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', @@ -207,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)); @@ -273,6 +297,23 @@ describe('union-arm-diagnostics — the selection itself', () => { 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 diff --git a/packages/cli/src/utils/union-arm-diagnostics.ts b/packages/cli/src/utils/union-arm-diagnostics.ts index e1e25ea941..8e6c47ff7b 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 @@ -105,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; } @@ -301,9 +306,20 @@ function noMatchingDiscriminator( 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:1189/1191) 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; - const path = issue.path ?? []; return noArmAccepts(path.slice(0, -1), literals, 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 index 5096109317..fa9ef468f2 100644 --- a/packages/types/src/__tests__/any-component-union-fanout.test.ts +++ b/packages/types/src/__tests__/any-component-union-fanout.test.ts @@ -24,7 +24,7 @@ * 4.4.3, flat -> discriminated: * * known-type leaf refusal 14,624 -> 164 chars (13 arm subtrees -> none) - * unknown `type` refusal 14,624 -> 2,178 chars + * 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 @@ -129,6 +129,17 @@ describe('AnyComponentSchema — a refusal costs one arm, not every arm', () => 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); diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index cf1bcbb58f..8f1f0762e5 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -411,7 +411,10 @@ export const AnyComponentSchema = z.discriminatedUnion('type', [ // 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. - error: () => 'Invalid input', + // ⚠️ 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), }); /** From ae6886dde328f1b8a32da96e09cacbbf6970a8ca Mon Sep 17 00:00:00 2001 From: objectstack-ai Date: Tue, 8 Sep 2026 09:54:30 +0000 Subject: [PATCH 3/3] docs(cli): restate the two `validate.ts` sentences the discriminated root falsified Comment-only, authorised as the single follow-up to the contract re-review PASS on 42344e912. No code changes. `validate.ts` still described `AnyComponentSchema` as a `z.union` whose every no-arm document yields one top-level issue at `path: []`, and still credited `explainUnionIssue` with selecting the arm. Both went false when the union began discriminating on `type`: zod does the selecting, a matched discriminator yields the arm's own issues as top-level entries at absolute paths, a missed one is judged at `['type']`, and a root path now means the document is not a component object at all. Also corrects a line citation in `union-arm-diagnostics.ts`: zod fills `discriminator` and `path` from the same `def.discriminator` at `schemas.js:1187` and `:1190`, not `:1189`/`:1191`. The cited fact was right, the line numbers were not. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CZY49skxUBYyJcdnTcYPrE --- packages/cli/src/commands/validate.ts | 30 ++++++++++++------- .../cli/src/utils/union-arm-diagnostics.ts | 2 +- 2 files changed, 20 insertions(+), 12 deletions(-) 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 8e6c47ff7b..080e4d9175 100644 --- a/packages/cli/src/utils/union-arm-diagnostics.ts +++ b/packages/cli/src/utils/union-arm-diagnostics.ts @@ -312,7 +312,7 @@ function noMatchingDiscriminator( // 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:1189/1191) so they + // 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 ?? [];