diff --git a/.changeset/7004-cli-union-arm-selection.md b/.changeset/7004-cli-union-arm-selection.md new file mode 100644 index 0000000000..690dede100 --- /dev/null +++ b/.changeset/7004-cli-union-arm-selection.md @@ -0,0 +1,39 @@ +--- +'@object-ui/cli': minor +--- + +`objectui validate` now prints the failing union arm the document selected, instead of a +bare "Invalid input" (objectui#7004, maintainer ruling 2026-09-02 — option B). + +`safeValidateSchema` checks a document against `AnyComponentSchema`, a `z.union`. When a +document matches no arm, Zod reports ONE top-level issue — `invalid_union` · `Invalid +input` · path `(root)` — and hangs every arm's real diagnosis off that issue's `errors` +array, which nothing read. So a menu whose item used the divider spelling retired in +objectui#6523 printed a bare verdict on the whole document, while the remediation text +objectui#6931 wrote into that arm sat one level down, unreachable. + +**What is printed now.** When the top-level issue is a failing union: + +- the document's `type` selects exactly one arm ⇒ that arm's issues are printed beneath + the entry as `1.1`, `1.2` … with their real paths (`Path: items → 0 → type`) and codes, + and **nothing** from the other arms; +- no arm accepts the `type` ⇒ `No arm accepts type "dropdwn-menu".` plus the nearest few + of the accepted values, ranked by edit distance and **capped** at five + (`MAX_UNION_ARMS_REPORTED`); +- the document declares no `type` at all ⇒ the note says so and offers no candidates — + "nearest" needs something to be near, and an alphabetical slice of 108 arm names + presented as guidance would be a bogus suggestion; +- a union with no `type` discriminator to select on — `MenuItemSchema`, whose two arms + both declare `type` as an ADR-0049 retirement tombstone — reports every arm, labelled + and capped by the same constant. This is the path that finally delivers the + objectui#6523 text to the author. + +Printing EVERY arm was rejected in the ruling: `AnyComponentSchema` resolves to 108 leaf +arms, so one mistyped `type` would have produced hundreds of lines. + +`objectui check` is unchanged and deliberately so: it has no zod-issue printer, using +`safeValidateSchema(...).success` as a boolean recogniser. Printing issues behind a +*negative* recognition would flood its report with diagnoses of non-ObjectUI files, the +failure objectui#5127 and objectui#6075 exist to prevent. + +Nothing about which documents are ACCEPTED changes — this is diagnostic output only. diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index 07e73d96da..faa1c999ca 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -158,6 +158,37 @@ objectui validate app.json objectui validate ./schemas/dashboard.yaml ``` +#### Reading a failure + +A document is checked against a union of every component type, so a failure is +reported first at the document root and then narrowed to the arm your `type` +selected. Each numbered issue carries a path and a code; the `1.1`-style entries +beneath it are that arm's own diagnosis, with the real path to the node that +failed: + +```text +1. Invalid input + Path: (root) + Code: invalid_union + 1.1 RETIRED (objectui#6523) — dividers are `{ separator: true }`; … + Path: items → 0 → type + Code: invalid_type +``` + +Only the selected arm is shown. When your `type` matches no component at all, +the report says so and offers the nearest few of the accepted values instead: + +```text +1. Invalid input + Path: (root) + Code: invalid_union + No arm accepts type "dropdwn-menu". + Nearest of the 108 accepted types: dropdown-menu, context-menu, component, drawer, breadcrumb +``` + +A document with no `type` at all is reported the same way, without a candidate +list — there is nothing for the suggestions to be near. + ### `objectui check` Scan the project for ObjectUI schema files and report on the ones it diff --git a/packages/cli/README.md b/packages/cli/README.md index 1d689374ef..9ca42b757c 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -117,6 +117,13 @@ Add a new component renderer to your project. Validate a schema file against the ObjectUI specification. +A failure is reported at the document root and then narrowed to the union arm +your `type` selected — the `1.1`-style entries under a numbered issue are that +arm's own diagnosis, at the real path of the node that failed. When no component +type matches, the report names the nearest few of the accepted values instead of +listing all of them. See the +[docs](https://www.objectui.org/docs/utilities/cli) for worked output. + ### `objectui check`, `objectui doctor`, `objectui studio`, `objectui analyze`, `objectui create plugin ` Utility commands — see `objectui --help`. 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 ac917fb20f..d99ed5d54b 100644 --- a/packages/cli/src/__tests__/validate-root-path-line.test.ts +++ b/packages/cli/src/__tests__/validate-root-path-line.test.ts @@ -9,6 +9,11 @@ /** * `objectui validate` and the ROOT-LEVEL issue (objectui#7004, mechanical half). * + * The arm-selection half of the same card landed later, on the 2026-09-02 + * ruling; its contract lives in `validate-union-arm-selection.test.ts`. The last + * describe block here was the boundary pin that made that landing an explicit + * edit, and now restates the new semantics from this file's point of view. + * * The printer used to guard its Path line with `issue.path.length > 0`, so an * issue at the document root (`path: []`) printed no Path line at all — silent * in precisely the case a reader most needs oriented. @@ -181,27 +186,40 @@ describe('objectui validate — a real path is still a real path', () => { }); }); -describe('objectui validate — the arm-selection half is deliberately NOT done here', () => { +describe('objectui validate — the arm-selection half, now that it is ruled', () => { /** - * ⚠️ This case pins a BOUNDARY, not a desired end state. objectui#7004 splits - * into the root-path line (done, above) and the question of whether a failing - * union should also surface its per-arm diagnoses — and if so, which arm's. - * The second is an author-facing diagnostic contract and is awaiting a - * maintainer ruling, so this file records that the printer walks only the - * top level today. + * ⚠️ This case USED to pin the opposite. It was written as a deliberate + * boundary: while the arm-selection question was with the maintainer, it + * asserted that the printer walked only the top level — `not.toContain + * ('RETIRED (objectui#6523)')`, `not.toContain('Path: items')` — so that when + * the ruling landed it would land as an explicit edit against a RED test + * rather than as a silent widening. + * + * The 2026-09-02 ruling landed (option B: the arm the document's `type` + * selects, nothing from the others), so the boundary moved and these + * assertions are inverted. That is the mechanism working, not a test being + * loosened: the pin forced this file to be opened and the semantics restated + * by hand. * - * When that ruling lands, this case is EXPECTED to change with it. It exists - * so the change is a deliberate edit rather than a silent widening. + * The full contract lives in `validate-union-arm-selection.test.ts`. What + * stays HERE is the part this file has always been about — that widening the + * printer did not cost the root-path line, and did not turn one top-level + * issue into many. */ - it('prints one top-level entry for a union, not the per-arm remediation text', async () => { + it('surfaces the selected arm\'s diagnosis without multiplying top-level entries', async () => { await validate(writeSchema('menu.json', MENU_WITH_RETIRED_DIVIDER)); const text = printed(); - // The arm issues carry the objectui#6523 tombstone guidance. Nothing here - // reads `issue.errors`, so none of it is printed. - expect(text).not.toContain('RETIRED (objectui#6523)'); - expect(text).not.toContain('Path: items'); - // Exactly one numbered issue — an arm walk would multiply this. + // Was `not.toContain` until the ruling. The objectui#6523 tombstone text + // 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())); diff --git a/packages/cli/src/__tests__/validate-union-arm-selection.test.ts b/packages/cli/src/__tests__/validate-union-arm-selection.test.ts new file mode 100644 index 0000000000..9ea95490c4 --- /dev/null +++ b/packages/cli/src/__tests__/validate-union-arm-selection.test.ts @@ -0,0 +1,270 @@ +/** + * 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. + */ + +/** + * `objectui validate` and the SELECTED UNION ARM (objectui#7004, arm-selection + * half — the 2026-09-02 maintainer ruling, option B). + * + * The root-path half (PR #7038) gave a failing union a `Path: (root)` line and + * stopped there: `issue.errors` was deliberately left unwalked while the + * question of WHICH arm to surface was with the maintainer. The ruling settled + * it — print the issues of the single arm the document's `type` selects, and + * nothing from the others; when no arm accepts the `type`, print a note plus a + * capped list of the nearest candidate arm names. + * + * ⚠️ Two halves of that ruling are load-bearing in opposite directions, and + * both are asserted here. "Print the selected arm" is worthless if the other + * 107 arms come with it (that is option A, rejected for noise), so the + * exclusion is tested as hard as the inclusion. + * + * Harness (fixtures under `os.tmpdir()`, `process.exit` recorded rather than + * taken) follows `validate-root-path-line.test.ts`. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { validate } from '../commands/validate.js'; +import { + MAX_UNION_ARMS_REPORTED, + nearestArmNames, + explainUnionIssue, +} from '../utils/union-arm-diagnostics.js'; + +/** See `validate-root-path-line.test.ts` — the escape byte is never spelled. */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +/** + * The document the finding was measured on, in the shape that actually reaches + * the CLI. The card's body puts `{ label: 'New Tab', type: 'separator' }` at the + * DOCUMENT ROOT, where it validates and exits 0 — the CLI checks the root + * against `AnyComponentSchema`, not `MenuItemSchema`, so the item has to be + * nested in a menu to reproduce anything at all. + */ +const MENU_WITH_RETIRED_DIVIDER = { + type: 'dropdown-menu', + items: [{ label: 'New Tab', type: 'separator' }], +}; + +/** A document from an entirely foreign vocabulary — no arm accepts its type. */ +const FOREIGN_DOCUMENT = { type: 'module', main: './index.js' }; + +/** One dropped character in a real arm name. */ +const TYPO_DOCUMENT = { type: 'dropdwn-menu', items: [] }; + +/** No `type` key at all — the ruling's other no-arm case. */ +const UNTYPED_DOCUMENT = { items: [] }; + +/** + * A document whose `type` selects exactly one arm, which then fails on its OWN + * required key. The cleanest possible statement of "print that arm and nothing + * else": `objectName` belongs to no other arm. + */ +const OBJECT_GRID_MISSING_OBJECT_NAME = { type: 'object-grid' }; + +/** A failure that is not a union at all — the control for "nothing changed". */ +const FORM_WITH_UNRESOLVABLE_WIDGET = { + type: 'form', + fields: [{ name: 'pw', widget: 'ui:password' }], +}; + +let dir: string; +let out: string[]; +let exitCodes: number[]; +let restore: () => void; + +function printed(): string { + return out.join('\n').replace(ANSI, ''); +} + +function writeSchema(name: string, schema: unknown): string { + const file = join(dir, name); + writeFileSync(file, JSON.stringify(schema, null, 2), 'utf-8'); + return file; +} + +/** The `.` sub-entries — one per issue of the arm(s) that were shown. */ +function armEntries(): string[] { + return printed() + .split('\n') + .map((line) => line.trim()) + .filter((line) => /^\d+\.\d+ /.test(line)); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'objectui-validate-7004-arm-')); + out = []; + exitCodes = []; + const originalLog = console.log; + const originalError = console.error; + const capture = (...args: unknown[]) => { + out.push(args.map(String).join(' ')); + }; + console.log = capture; + console.error = capture; + const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + exitCodes.push(code ?? 0); + return undefined as never; + }) as never); + restore = () => { + console.log = originalLog; + console.error = originalError; + exitSpy.mockRestore(); + }; +}); + +afterEach(() => { + restore(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('objectui validate — the arm the document selected', () => { + it('surfaces the nested item\'s own remediation text, at its real path', async () => { + await validate(writeSchema('menu.json', MENU_WITH_RETIRED_DIVIDER)); + + expect(exitCodes).toEqual([1]); + const text = printed(); + // The objectui#6523 tombstone guidance — written in #6931, and until now + // unreachable from this command. This assertion IS the card. + expect(text).toContain('RETIRED (objectui#6523)'); + // Rebased onto an absolute path. Zod reports the nested union's arm issues + // 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. + const numbered = text.split('\n').filter((line) => /^\d+\. /.test(line.trim())); + expect(numbered).toHaveLength(1); + expect(armEntries().length).toBeGreaterThan(0); + }); + + it('prints the selected arm ONLY — no other arm\'s complaints ride along', async () => { + await validate(writeSchema('grid.json', OBJECT_GRID_MISSING_OBJECT_NAME)); + + expect(exitCodes).toEqual([1]); + const text = printed(); + // The arm `object-grid` selects, failing on its own required key. + expect(text).toContain('Path: objectName'); + // ...and nothing from the 107 arms that merely disagree about `type`. + // Option A would have printed one of these per arm; this is the assertion + // that the ruling's rejection of it is real rather than nominal. + expect(text).not.toContain('Invalid discriminator value'); + expect(text).not.toContain('expected "app"'); + expect(text).not.toContain('expected "object-form"'); + // Cheap structural double-check on the same claim: an option-A printer + // would emit dozens of sub-entries here, not a handful. + expect(armEntries().length).toBeLessThanOrEqual(MAX_UNION_ARMS_REPORTED); + }); +}); + +describe('objectui validate — when no arm accepts the type', () => { + it('says so, and offers the nearest few of the accepted values', async () => { + await validate(writeSchema('typo.json', TYPO_DOCUMENT)); + + expect(exitCodes).toEqual([1]); + const text = printed(); + expect(text).toContain('No arm accepts type "dropdwn-menu"'); + const line = text.split('\n').find((l) => l.includes('Nearest of the')); + expect(line, 'no candidate line was printed').toBeDefined(); + // The one-character miss ranks first — the whole point of ranking by edit + // distance rather than by case alone. + const names = (line as string).split(':')[1].split(',').map((n) => n.trim()); + expect(names[0]).toBe('dropdown-menu'); + // ...and the list is CAPPED. Uncapped, this is 108 names. + expect(names.length).toBeLessThanOrEqual(MAX_UNION_ARMS_REPORTED); + expect(/Nearest of the \d+ accepted types/.test(line as string)).toBe(true); + }); + + it('does the same for a document from a foreign vocabulary', async () => { + await validate(writeSchema('package.json', FOREIGN_DOCUMENT)); + + expect(exitCodes).toEqual([1]); + expect(printed()).toContain('No arm accepts type "module"'); + }); + + it('offers NO candidates when the document declares no type at all', async () => { + // "Nearest" needs something to be near. Ranking 108 arm names against a + // `type` the author never wrote would present an alphabetical slice as + // guidance — the bogus suggestion `known-type-case-suggestion.ts` refuses + // to make on the sibling surface. + await validate(writeSchema('untyped.json', UNTYPED_DOCUMENT)); + + expect(exitCodes).toEqual([1]); + const text = printed(); + expect(text).toContain('No `type` is declared'); + expect(text).not.toContain('Nearest of the'); + expect(text).not.toContain('No arm accepts type'); + }); +}); + +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)); + + expect(exitCodes).toEqual([1]); + const text = printed(); + expect(text).toContain('Path: fields → 0 → widget'); + expect(text).not.toContain('(root)'); + expect(armEntries()).toHaveLength(0); + }); +}); + +describe('union-arm-diagnostics — the selection itself', () => { + it('pins the cap the ruling requires as a named constant', () => { + // The ruling: "a capped list of the nearest candidate arm names (the cap is + // a named constant, chosen by the implementer and pinned)". This is the pin. + expect(MAX_UNION_ARMS_REPORTED).toBe(5); + }); + + it('ranks by edit distance, caps, and is deterministic under ties', () => { + const arms = ['dropdown-menu', 'context-menu', 'navigation-menu', 'menubar', 'card', 'grid']; + const first = nearestArmNames('dropdwn-menu', arms); + expect(first.candidates[0]).toBe('dropdown-menu'); + expect(first.candidates.length).toBeLessThanOrEqual(MAX_UNION_ARMS_REPORTED); + expect(first.total).toBe(arms.length); + // Same input, same order — no dependence on the arms' incoming order. + expect(nearestArmNames('dropdwn-menu', [...arms].reverse()).candidates).toEqual( + first.candidates, + ); + // A transposition still lands the intended arm first even though unit-cost + // distance scores it 2 rather than 1. + expect(nearestArmNames('dropdwon-menu', arms).candidates[0]).toBe('dropdown-menu'); + }); + + it('returns nothing for an issue that is not a union', () => { + expect(explainUnionIssue({ code: 'custom', path: ['a'], message: 'x' }, {})).toEqual([]); + expect(explainUnionIssue({ code: 'invalid_union', path: [], message: 'x' }, {})).toEqual([]); + }); + + 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. + // The ruling's named fallback applies: every arm, capped — which is how the + // objectui#6523 text reaches the author at all. + const issue = { + code: 'invalid_union', + path: ['items', 0], + message: 'Invalid input', + errors: [ + [{ code: 'invalid_type', path: ['type'], message: 'RETIRED — arm one' }], + [{ code: 'invalid_type', path: ['type'], message: 'RETIRED — arm two' }], + ], + }; + const lines = explainUnionIssue(issue, { items: [{ type: 'separator' }] }); + expect(lines).toHaveLength(2); + expect(lines.every((l) => l.kind === 'issue')).toBe(true); + // Labelled, because more than one arm is on screen at once. + expect(lines.map((l) => (l.kind === 'issue' ? l.arm : undefined))).toEqual(['1/2', '2/2']); + // ...and rebased onto the union's own node. + expect(lines.map((l) => l.path)).toEqual([ + ['items', 0, 'type'], + ['items', 0, 'type'], + ]); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 0bf7b1493e..c487b91258 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -12,6 +12,7 @@ import { resolve } from 'path'; import { load as loadYaml } from 'js-yaml'; import { safeValidateSchema } from '@object-ui/types/zod'; import { findSpecVocabularyFormFields } from '../utils/spec-vocabulary-hint.js'; +import { explainUnionIssue } from '../utils/union-arm-diagnostics.js'; /** * Validate a schema file @@ -118,11 +119,13 @@ export async function validate(schemaPath: string) { // `(root)` is parenthesised so it cannot be read as a real key literally // named `root` — a genuine path to one would print as `root`. // - // ⛔ Scope: this prints the top-level issue's own path and NOTHING more. - // The other half of objectui#7004 — whether a failing union should also - // surface its per-arm diagnoses, and if so which arm's — is an - // author-facing diagnostic contract awaiting a maintainer ruling, so - // `issue.errors` is deliberately NOT walked here. + // 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 + // 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) => { console.error(chalk.red(`\n${index + 1}. ${issue.message}`)); const path = issue.path ?? []; @@ -132,6 +135,47 @@ export async function validate(schemaPath: string) { if (issue.code) { console.error(chalk.gray(` Code: ${issue.code}`)); } + + // The selected arm's own diagnosis, indented under the entry it + // explains and numbered `.` so it can never be mistaken for a + // separate top-level issue (`1.1` does not match the `^\d+\. ` shape a + // numbered entry has). + explainUnionIssue(issue, schema).forEach((line, sub) => { + const where = line.path.length > 0 ? line.path.join(' → ') : '(root)'; + if (line.kind === 'note') { + // No arm accepts the authored `type`. Name that, then the nearest + // few of the accepted values — never all of them, which is the + // noise the ruling rejected option A for. + const at = line.path.length > 0 ? ` at ${where}` : ''; + if (line.authoredType === undefined) { + console.error( + chalk.yellow( + ` No \`type\` is declared${at}, so none of the ${line.totalArmNames} ` + + `accepted component types can be selected.`, + ), + ); + return; + } + console.error( + chalk.yellow(` No arm accepts type "${line.authoredType}"${at}.`), + ); + if (line.candidates.length > 0) { + console.error( + chalk.gray( + ` Nearest of the ${line.totalArmNames} accepted types: ` + + line.candidates.join(', '), + ), + ); + } + return; + } + const arm = line.arm === undefined ? '' : `[arm ${line.arm}] `; + console.error(chalk.red(` ${index + 1}.${sub + 1} ${arm}${line.message}`)); + console.error(chalk.gray(` Path: ${where}`)); + if (line.code) { + console.error(chalk.gray(` Code: ${line.code}`)); + } + }); }); // "name: expected string, received undefined" on a `{ field: … }` entry diff --git a/packages/cli/src/utils/union-arm-diagnostics.ts b/packages/cli/src/utils/union-arm-diagnostics.ts new file mode 100644 index 0000000000..dc406e1ae7 --- /dev/null +++ b/packages/cli/src/utils/union-arm-diagnostics.ts @@ -0,0 +1,354 @@ +/** + * 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. + */ + +/** + * Arm selection for a failing union (objectui#7004, arm-selection half). + * + * ## 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 + * `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 2026-09-02 maintainer ruling chose **B — discriminator-selected arm**: + * print the issues of the single arm that accepts the authored `type`, and + * nothing from the others; when no arm accepts it, print a note plus a CAPPED + * list of the nearest candidate arm names. Printing every arm (option A), + * picking the arm with the fewest issues (option C) and the status quo (option + * D) were all rejected. + * + * This module performs the SELECTION only. Every `console.*` call stays in + * `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) + * + * 1. `errors` is positionally aligned with the union's options: 14 entries for + * `AnyComponentSchema`'s 14 members, `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. + * 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 + * every path here is rebased onto its parent's prefix. + * 4. `AnyComponentSchema` resolves to 108 leaf arms and 108 DISTINCT `type` + * literals — no literal is claimed by two arms — so "exactly one arm accepts + * that literal" is total and unambiguous at the document root. + * + * ## The third branch, and why it is not option A + * + * The ruling partitions on "exactly one arm accepts" vs "no arm accepts". Both + * presuppose arms that DECLARE a `type` contract. Not every union has them: + * `MenuItemSchema` — the union this card was filed about — is a two-arm + * `z.union` whose arms both declare `type` as an ADR-0049 retirement tombstone + * (`z.never()`), so neither names a literal and no discriminator exists to + * select on. Measured: its arms report `invalid_type` (`expected: 'never'`) + * with no `values`. + * + * Routing that to the "no arm accepts" branch would be literally true and + * exactly wrong: the candidate list would be empty and the objectui#6523 + * remediation text — the very text this card exists to deliver — would be + * dropped. So a union with NO declaring arm falls back to the ruling's own + * named fallback, "A with a cap": every arm reported, capped by + * {@link MAX_UNION_ARMS_REPORTED}. This is not option A at the root, which the + * ruling rejected on the 108-arm noise argument — the root is always + * discriminated (fact 4), and undiscriminated unions in this mirror are small + * (`MenuItemSchema` has two arms). + */ + +/** + * The shape this module reads out of a Zod issue. + * + * Structural rather than imported from `zod`: the CLI receives issues across a + * package boundary and only ever reads them, so a narrow local shape keeps this + * module honest about exactly which fields the selection depends on. + */ +export interface UnionIssueLike { + code?: string; + path?: readonly PropertyKey[]; + message?: string; + /** Present on `invalid_union`: one entry per arm, positionally aligned. */ + errors?: readonly (readonly UnionIssueLike[])[]; + /** Present on `invalid_value`: the literal values the arm accepts. */ + values?: readonly unknown[]; + /** Present on a discriminated union's `No matching discriminator`. */ + options?: readonly unknown[]; + note?: string; +} + +/** One arm issue, rebased onto an absolute path. */ +export interface UnionArmIssue { + kind: 'issue'; + path: PropertyKey[]; + message: string; + code?: string; + /** + * Set only when several arms are reported at once (the undiscriminated + * fallback), so the reader can tell whose diagnosis is whose. A selected arm + * needs no label — there is only one. + */ + arm?: string; +} + +/** The "no arm accepts type X" note, with its capped candidate list. */ +export interface UnionArmNote { + kind: 'note'; + path: PropertyKey[]; + /** The authored `type`, or `undefined` when the document carries none. */ + authoredType?: string; + /** + * Nearest arm names, already ranked and capped. + * + * EMPTY when the document declares no `type` at all. "Nearest" needs + * something to be near, and an alphabetical slice of 108 arm names presented + * under a `type` the author never wrote is a bogus suggestion — the failure + * `known-type-case-suggestion.ts` refuses by returning `undefined` rather + * than guessing. The note still fires; it just names the missing `type` key + * instead of pretending to rank against it. + */ + candidates: string[]; + /** + * How many `type` values the union accepts in total (108 at the document + * root). Printed with the candidates so the list reads as "the nearest few of + * a closed set" rather than as a confident "did you mean" — which matters + * because a foreign `type` has no near miss at all: measured, `module`'s + * nearest arm is `toggle` at distance 3. + */ + totalArmNames: number; +} + +export type UnionArmLine = UnionArmIssue | UnionArmNote; + +/** + * The cap the 2026-09-02 ruling requires ("a **capped** list of the nearest + * candidate arm names — the cap is a named constant, chosen by the implementer + * and pinned"). + * + * Five, from a measurement rather than taste. The candidate set is the union's + * arm names — 108 of them at the document root — so the cap is the whole + * distance between a hint and the option A the ruling rejected. + * + * Ranking those 108 by edit distance against four authored typos gave the same + * shape every time: the intended arm is rank 1 and ALONE in its distance band + * (`dropdwn-menu` -> `dropdown-menu` at 1, one name at that distance; + * `dropdwon-menu` -> `dropdown-menu` at 2, one name; `Page` -> `page` at 1, one + * name; `obect-grid` -> `object-grid` at 1, one name), and the next band opens + * 1-6 edits further out holding 1-5 names. So a cap of 5 shows the winner plus + * the following band entire, and stops well short of the 108. A cap of 1 would + * print the winner with nothing around it and read as a confident answer rather + * than as a ranked list — the same over-claim `known-type-case-suggestion.ts` + * refuses on the sibling surface. Five is also the conventional shell and + * compiler suggestion size and holds one terminal line at these name lengths. + * + * The same number caps the arms reported when a union has no discriminator to + * select on (see this module's header). Both lists answer the same question — + * how many arm-shaped things may be printed before the output becomes the noise + * option A was rejected for — so they share one constant rather than inviting a + * second magic number to drift from it. + */ +export const MAX_UNION_ARMS_REPORTED = 5; + +/** Unit-cost Levenshtein distance (insert / delete / substitute). */ +function editDistance(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + let previous = Array.from({ length: b.length + 1 }, (_, i) => i); + for (let i = 1; i <= a.length; i += 1) { + const current = [i]; + for (let j = 1; j <= b.length; j += 1) { + current[j] = Math.min( + previous[j] + 1, + current[j - 1] + 1, + previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1), + ); + } + previous = current; + } + return previous[b.length]; +} + +/** + * The nearest arm names to the authored `type`, capped. + * + * Unit-cost edit distance, ascending, ties broken lexicographically so the + * output is deterministic. Chosen because the authored `type` and the arm names + * are short kebab-case identifiers whose realistic authoring errors are a + * dropped, added, doubled or mistyped character (`dropdwn-menu`), which is + * precisely what unit-cost distance ranks nearest; a prefix or case-only match + * would miss any typo in the first token, and a transposition-aware metric + * (Damerau) buys a case this tree's arm names do not need — a transposition + * scores 2 here and still lands at the top of the list. + * + * ⚠️ Not to be confused with `known-type-case-suggestion.ts`, which is + * deliberately case-only under a DIFFERENT ruling (objectui#5247) for a + * DIFFERENT surface (`objectui check`) over a DIFFERENT candidate set + * (`KNOWN_SCHEMA_TYPES`, the registry's keys). This surface's candidates are the + * schema union's arms, and the 2026-09-02 ruling on objectui#7004 asks for the + * "nearest" ones by name — so proximity is granted here and is not a widening + * of that one. + */ +export function nearestArmNames( + authoredType: string | undefined, + armNames: readonly string[], +): { candidates: string[]; total: number } { + const unique = [...new Set(armNames)].sort(); + // Nothing to be near: say nothing rather than rank against a `type` the + // author never wrote. See `UnionArmNote.candidates`. + if (typeof authoredType !== 'string' || authoredType === '') { + return { candidates: [], total: unique.length }; + } + const ordered = [...unique].sort((x, y) => { + const d = editDistance(authoredType, x) - editDistance(authoredType, y); + return d !== 0 ? d : x.localeCompare(y); + }); + return { candidates: ordered.slice(0, MAX_UNION_ARMS_REPORTED), total: unique.length }; +} + +/** Read `document[...path].type`, when it is a string. */ +function authoredTypeAt(document: unknown, path: readonly PropertyKey[]): string | undefined { + let node: unknown = document; + for (const key of path) { + if (node === null || typeof node !== 'object') return undefined; + node = (node as Record)[key]; + } + if (node === null || typeof node !== 'object') return undefined; + const type = (node as Record).type; + return typeof type === 'string' ? type : undefined; +} + +function isUnion(issue: UnionIssueLike): boolean { + return issue.code === 'invalid_union' && Array.isArray(issue.errors); +} + +function isAtTypeKey(issue: UnionIssueLike): boolean { + const path = issue.path ?? []; + return path.length === 1 && path[0] === 'type'; +} + +/** + * The `type` literals an arm declares it accepts, or `undefined` when the arm + * declares no `type` contract at all. + * + * `undefined` and `[]` are different answers and the caller depends on the + * difference: `undefined` means "this arm has no discriminator" (an ADR-0049 + * tombstone arm, or one that simply never mentions `type`), which is what sends + * a union to the undiscriminated fallback instead of to "no arm accepts". + */ +function declaredTypeLiterals(armIssues: readonly UnionIssueLike[]): string[] | undefined { + const literals: string[] = []; + let declares = false; + for (const issue of armIssues) { + if (isAtTypeKey(issue) && issue.code === 'invalid_value' && Array.isArray(issue.values)) { + declares = true; + literals.push(...issue.values.filter((v): v is string => typeof v === 'string')); + continue; + } + if (isAtTypeKey(issue) && isUnion(issue) && Array.isArray(issue.options)) { + declares = true; + literals.push(...issue.options.filter((v): v is string => typeof v === 'string')); + continue; + } + // A nested union at the arm's own node (a `z.union` member that is itself a + // union, e.g. `ObjectQLComponentSchema`): it declares a contract only if + // every one of ITS arms does, and its literals are their union. + if ((issue.path ?? []).length === 0 && isUnion(issue)) { + const nested = (issue.errors ?? []).map((sub) => declaredTypeLiterals(sub)); + if (nested.length > 0 && nested.every((n) => n !== undefined)) { + declares = true; + for (const n of nested) literals.push(...(n as string[])); + } + } + } + return declares ? literals : undefined; +} + +/** + * Recursion terminates by construction: every call descends one level into + * `issue.errors`, which Zod materialises eagerly as plain nested arrays at parse + * time — a finite tree with no lazy getters and therefore no cycle, even for a + * `z.lazy` schema like `MenuItemSchema` (the laziness is in the SCHEMA, not in + * the issue tree it produces). Measured max depth on this tree: 3. + */ +function explain( + unionIssue: UnionIssueLike, + prefix: readonly PropertyKey[], + document: unknown, +): UnionArmLine[] { + const arms = unionIssue.errors ?? []; + const declared = arms.map((arm) => declaredTypeLiterals(arm)); + const declaringArms = declared.filter((d) => d !== undefined) as string[][]; + const acceptingIndexes = declared + .map((d, i) => (d === undefined ? i : -1)) + .filter((i) => i >= 0); + + const expand = (armIssues: readonly UnionIssueLike[], arm?: string): UnionArmLine[] => + armIssues.flatMap((issue) => { + const absolute = [...prefix, ...(issue.path ?? [])]; + if (isUnion(issue)) return explain(issue, absolute, document); + return [ + { + kind: 'issue' as const, + path: absolute, + message: issue.message ?? '', + code: issue.code, + ...(arm === undefined ? {} : { arm }), + }, + ]; + }); + + // B — exactly one arm accepts the authored literal. Print its issues, and + // nothing from the others. + if (declaringArms.length > 0 && acceptingIndexes.length === 1) { + return expand(arms[acceptingIndexes[0]]); + } + + // 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, + }, + ]; + } + + // No discriminator to select on (or, defensively, more than one accepting + // arm): the ruling's named fallback, every arm capped. + return arms + .slice(0, MAX_UNION_ARMS_REPORTED) + .flatMap((arm, i) => expand(arm, `${i + 1}/${arms.length}`)); +} + +/** + * The lines to print beneath a top-level `invalid_union` entry. + * + * Returns `[]` for any issue that is not a union with arm errors, so the caller + * can call it unconditionally. + */ +export function explainUnionIssue(issue: UnionIssueLike, document: unknown): UnionArmLine[] { + if (!isUnion(issue)) return []; + return explain(issue, issue.path ?? [], document); +}