From 8641b8cd32c068cf30a593302bfee885643fa095 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 14:30:32 +0000 Subject: [PATCH] fix(cli): give a root-level validation issue a Path line (objectui#7004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `objectui validate` guarded its Path line with `issue.path.length > 0`, so an issue at `path: []` printed no Path line at all — silent in exactly 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 reports one top-level issue at the root: `invalid_union` · `Invalid input` · `path: []`. Measured on the parent commit, a menu carrying the divider spelling retired in objectui#6523 printed a bare verdict on a whole document, with nothing saying which node had been judged. Every reported issue now carries a Path line; a root-level one reads `Path: (root)`, parenthesised so it cannot be read as a real key named `root`. Non-root issues print their authored path unchanged — pinned by a control case, since a fix printing `(root)` unconditionally would satisfy a root-only test while destroying the paths authors depend on. Scope: only top-level issues are read, as before. Whether a failing union should also surface its per-arm diagnoses — and if so which arm's — is an author-facing diagnostic contract left open on objectui#7004 for a maintainer ruling; `issue.errors` is deliberately not walked, and a case pins that boundary so the ruling lands as a deliberate edit rather than a silent widening. --- .changeset/7004-cli-root-path-line.md | 28 +++ .../__tests__/validate-root-path-line.test.ts | 210 ++++++++++++++++++ packages/cli/src/commands/validate.ts | 27 ++- 3 files changed, 262 insertions(+), 3 deletions(-) create mode 100644 .changeset/7004-cli-root-path-line.md create mode 100644 packages/cli/src/__tests__/validate-root-path-line.test.ts diff --git a/.changeset/7004-cli-root-path-line.md b/.changeset/7004-cli-root-path-line.md new file mode 100644 index 0000000000..1b9708c16d --- /dev/null +++ b/.changeset/7004-cli-root-path-line.md @@ -0,0 +1,28 @@ +--- +'@object-ui/cli': patch +--- + +`objectui validate` now says when a validation issue sits at the document root +(objectui#7004, mechanical half). + +The printer guarded its Path line with `issue.path.length > 0`, so an issue at +`path: []` printed no Path line at all — silent in exactly the case a reader +most needs oriented. That case is the common one, not an edge: the CLI validates +against `AnyComponentSchema`, a union over every component arm, so any document +matching no arm reports a single top-level issue (`invalid_union` · `Invalid +input` · root path). Authors saw a bare verdict on a whole document with nothing +saying which node had been judged: + +``` +1. Invalid input + Code: invalid_union +``` + +Every reported issue now carries a Path line; a root-level one reads +`Path: (root)`, parenthesised so it cannot be mistaken for a real key named +`root`. Non-root issues print their authored path exactly as before. + +Scope: the printer still reads only top-level issues. Whether a failing union +should also surface its per-arm diagnoses — and if so which arm's — is an +author-facing diagnostic contract left open on objectui#7004 for a maintainer +ruling, and is deliberately not decided here. diff --git a/packages/cli/src/__tests__/validate-root-path-line.test.ts b/packages/cli/src/__tests__/validate-root-path-line.test.ts new file mode 100644 index 0000000000..ac917fb20f --- /dev/null +++ b/packages/cli/src/__tests__/validate-root-path-line.test.ts @@ -0,0 +1,210 @@ +/** + * 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 ROOT-LEVEL issue (objectui#7004, mechanical half). + * + * 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. + * + * 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 + * carrying the retired `{ type: 'separator' }` divider spelling printed: + * + * 1. Invalid input + * Code: invalid_union + * + * — a bare verdict on a whole document, with nothing saying which node had + * been judged. + * + * ⚠️ 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 + * is load-bearing, not decoration. + * + * Harness (fixtures under `os.tmpdir()`, `process.exit` recorded rather than + * taken) follows `validate-widget-namespace.test.ts`; see its header for why + * fixtures never live in the repo tree. + */ + +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'; + +/** + * The CSI sequences chalk may add. The escape byte is built with + * `String.fromCharCode` rather than spelled into the source, so this file holds + * no raw control character and no escape a tooling pass could materialise into + * one (objectui AGENTS.md byte discipline). + */ +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); + +/** + * A menu whose item uses the divider spelling retired in objectui#6523. This + * is the document the finding was measured on: `MenuItemSchema` is a union, so + * its diagnosis rides the per-arm issues while the top-level issue stays at the + * root. + */ +const MENU_WITH_RETIRED_DIVIDER = { + type: 'dropdown-menu', + items: [{ label: 'New Tab', type: 'separator' }], +}; + +/** A document from an entirely foreign vocabulary — the other root producer. */ +const FOREIGN_DOCUMENT = { type: 'module', main: './index.js' }; + +/** + * The non-root control, lifted from `validate-widget-namespace.test.ts` so both + * files pin the same observed path for the same input. + */ +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; + +/** Everything the command printed, chalk colour codes stripped. */ +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; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'objectui-validate-7004-')); + 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 — 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)); + + expect(exitCodes).toEqual([1]); + const text = printed(); + // The three fields, in the order the printer emits them. The middle one is + // the whole card: before this change the message and Code lines were + // adjacent, with nothing between them. + expect(text).toContain('1. Invalid input'); + expect(text).toContain('Path: (root)'); + expect(text).toContain('Code: invalid_union'); + }); + + 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('Path: (root)'); + }); + + it('gives EVERY reported issue a Path line, root or not', async () => { + // Structural rather than by-example: the defect was an absence, and the + // repair is "no numbered issue is ever printed without a Path". Asserting + // one example line would not catch a future guard reintroducing the hole + // on some other issue shape. + await validate(writeSchema('menu.json', MENU_WITH_RETIRED_DIVIDER)); + + const lines = printed().split('\n'); + const numbered = lines + .map((line, i) => ({ line, i })) + .filter(({ line }) => /^\d+\. /.test(line.trim())); + expect(numbered.length).toBeGreaterThan(0); + for (const { line, i } of numbered) { + expect( + lines[i + 1]?.trim().startsWith('Path: '), + `issue "${line.trim()}" printed no Path line`, + ).toBe(true); + } + }); +}); + +describe('objectui validate — a real path is still a real path', () => { + it('prints the authored path unchanged, and does not call it the root', async () => { + // The guard against the fix that "passes" by printing (root) for + // everything. + 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)'); + }); + + it('still exits 0 and prints no Path line on a document that validates', async () => { + await validate( + writeSchema('ok.json', { type: 'form', fields: [{ name: 'pw', type: 'password' }] }), + ); + + expect(exitCodes).toEqual([0]); + const text = printed(); + expect(text).toContain('Schema is valid!'); + expect(text).not.toContain('Path:'); + }); +}); + +describe('objectui validate — the arm-selection half is deliberately NOT done here', () => { + /** + * ⚠️ 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. + * + * 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. + */ + it('prints one top-level entry for a union, not the per-arm remediation text', 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. + const numbered = printed() + .split('\n') + .filter((line) => /^\d+\. /.test(line.trim())); + expect(numbered).toHaveLength(1); + }); +}); diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 52b4d553f4..0bf7b1493e 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -103,11 +103,32 @@ export async function validate(schemaPath: string) { // undefined and this loop threw a TypeError that the catch below // reported as "Error reading or parsing schema file", hiding the very // errors this command exists to print. + // Every issue gets a Path line, INCLUDING a root-level one (objectui#7004). + // + // 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. + // + // `(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. result.error.issues.forEach((issue, index) => { console.error(chalk.red(`\n${index + 1}. ${issue.message}`)); - if (issue.path && issue.path.length > 0) { - console.error(chalk.gray(` Path: ${issue.path.join(' → ')}`)); - } + const path = issue.path ?? []; + console.error( + chalk.gray(` Path: ${path.length > 0 ? path.join(' → ') : '(root)'}`) + ); if (issue.code) { console.error(chalk.gray(` Code: ${issue.code}`)); }