From cebc3a64cf6be0f8dde92e5f9ceed21ff983299a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:28:04 +0000 Subject: [PATCH 1/6] feat(cli): `objectui validate` and `objectui check` judge through the strict authoring face (objectui#5250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both commands called `safeValidateSchema` — the tolerant `AnyComponentSchema`, whose `BaseSchemaCore.passthrough()` kept any undeclared key unjudged. They now share one door, `validateAuthoredDocument` in `utils/authoring-face.ts`, which parses through `StrictAnyComponentSchema` (objectui#8345), per the objectui#5250 ruling (option 2): the authoring verdict is strict, the rendering face keeps its passthrough. A refused undeclared key is named with its path and a prescription by `findUndeclaredKeys` (union-arm-diagnostics), which reads keys out of viable union arms, so a nested child's refusal is named even though the issue list carries it only inside an `Invalid input` at the child slot. Part of objectui#5250 (slice A). Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- packages/cli/src/commands/check.ts | 25 ++++- packages/cli/src/commands/validate.ts | 39 ++++++- packages/cli/src/utils/authoring-face.ts | 64 +++++++++++ .../cli/src/utils/union-arm-diagnostics.ts | 103 +++++++++++++++++- 4 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/utils/authoring-face.ts diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 46caf5acef..d8a47ed2fa 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -11,11 +11,12 @@ import { globSync } from 'glob'; import { readFileSync } from 'fs'; import { join } from 'path'; import { parse as parseJsonc, printParseErrorCode, type ParseError } from 'jsonc-parser'; -import { safeValidateSchema } from '@object-ui/types/zod'; +import { describeUndeclaredKey, validateAuthoredDocument } from '../utils/authoring-face.js'; import { formatIssuePath } from '../utils/issue-path.js'; import { isKnownSchemaType } from '../utils/known-schema-types.js'; import { didYouMeanClause } from '../utils/known-type-case-suggestion.js'; +import { findUndeclaredKeys } from '../utils/union-arm-diagnostics.js'; /** * Root keys that positively identify a file as an ObjectUI schema node. @@ -101,8 +102,11 @@ const OBJECTUI_STRUCTURAL_KEYS: readonly string[] = [ /** The part of a Zod issue this command prints. */ interface IssueLike { + code?: string; path?: readonly PropertyKey[]; message: string; + /** Present on `unrecognized_keys`; read by `findUndeclaredKeys`. */ + keys?: readonly string[]; } type Recognition = @@ -133,7 +137,10 @@ type Recognition = * test over the parsed root, and the cheap one. It runs first so the * common case never pays for arm 2. * 2. The validity arm — the document parses as an ObjectUI component schema - * under `@object-ui/types`' own Zod union. This is the recogniser the + * under `@object-ui/types`' STRICT authoring face (objectui#5250), the same + * parse `objectui validate` gives its verdict with — so a document carrying + * a key no schema declares does not validate here either, and is reported + * below with that key named. This is the recogniser the * 2026-08-25 ruling on objectui#5392 selected (Option B, no shipped schema * artifact): measured over this repository it admits schemas the structural * arm cannot see — leaf nodes that carry only their own vocabulary — while @@ -168,7 +175,7 @@ function recogniseObjectUiSchemaFile(content: Record): Recognit if (OBJECTUI_STRUCTURAL_KEYS.some((key) => key in content)) { return { recognised: true, validated: false }; } - const result = safeValidateSchema(content); + const result = validateAuthoredDocument(content); return result.success ? { recognised: true, validated: true } : { recognised: false, issues: result.error.issues }; @@ -324,6 +331,7 @@ export async function check(cwd: string = process.cwd()) { file: string; type: string; issues: readonly IssueLike[]; + document: unknown; }[] = []; // Recognised files, split by the arm that admitted them — the closing line // reports them apart because only one arm validated anything (objectui#11007). @@ -389,6 +397,7 @@ export async function check(cwd: string = process.cwd()) { file, type: content.type, issues: recognition.issues, + document: content, }); } else { skipped++; @@ -436,7 +445,7 @@ export async function check(cwd: string = process.cwd()) { `⚠️ ${n} file${n === 1 ? '' : 's'} carr${n === 1 ? 'ies' : 'y'} a registered ObjectUI component type but did not validate as an ObjectUI schema:` ) ); - for (const { file, type, issues } of unvalidatedCandidates) { + for (const { file, type, issues, document } of unvalidatedCandidates) { console.log(chalk.yellow(` ${file} (type "${type}")`)); // Indented under its file, so it reads as that file's reason and never // as another entry (objectui#11007). @@ -444,6 +453,14 @@ export async function check(cwd: string = process.cwd()) { if (firstIssue !== undefined) { console.log(chalk.yellow(` ${firstIssue}`)); } + // Every undeclared key the strict face refused, named with its path and + // what to do — the same reader and wording `objectui validate` prints + // (objectui#5250). The first issue alone cannot carry them: below a + // child slot it is an `Invalid input` that names no key. Same indent as + // the issue line, so none of these reads as another file. + for (const finding of findUndeclaredKeys(issues)) { + console.log(chalk.yellow(` ${describeUndeclaredKey(finding, document)}`)); + } } console.log( chalk.dim(' Run `objectui validate ` on any of them for the full diagnosis.') diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 6413ccd020..034038eaa1 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -10,9 +10,9 @@ import chalk from 'chalk'; import { readFileSync, existsSync } from 'fs'; import { resolve } from 'path'; import { load as loadYaml } from 'js-yaml'; -import { safeValidateSchema } from '@object-ui/types/zod'; +import { describeUndeclaredKey, validateAuthoredDocument } from '../utils/authoring-face.js'; import { findSpecVocabularyFormFields } from '../utils/spec-vocabulary-hint.js'; -import { explainUnionIssue } from '../utils/union-arm-diagnostics.js'; +import { explainUnionIssue, findUndeclaredKeys } from '../utils/union-arm-diagnostics.js'; import { formatIssuePath } from '../utils/issue-path.js'; /** @@ -55,9 +55,11 @@ export async function validate(schemaPath: string) { } } - // Validate the schema + // Validate the schema — through the STRICT authoring face (objectui#5250): + // a key no schema declares is refused, at any depth. The door is shared + // with `objectui check`; see `../utils/authoring-face.ts`. console.log(chalk.gray('Validating schema...\n')); - const result = safeValidateSchema(schema); + const result = validateAuthoredDocument(schema); if (result.success) { console.log(chalk.green('✓ Schema is valid!\n')); @@ -136,7 +138,9 @@ export async function validate(schemaPath: string) { // in `../utils/union-arm-diagnostics.js`; this file only prints, and it // is the CLI's full zod-issue printer. `objectui check` prints one line // per file that did not validate — that file's first issue, through the - // same path formatter — and sends the reader here for the rest. + // same path formatter — plus the undeclared-key lines printed below (the + // same reader and wording, objectui#5250), and sends the reader here for + // the rest. result.error.issues.forEach((issue, index) => { console.error(chalk.red(`\n${index + 1}. ${issue.message}`)); console.error(chalk.gray(` Path: ${formatIssuePath(issue.path)}`)); @@ -186,6 +190,31 @@ export async function validate(schemaPath: string) { }); }); + // Every undeclared key, named with its path and what to do (objectui#5250). + // The issue list above already carries each one — but below a child slot + // only inside an arm's diagnosis, under an `Invalid input` that names no + // key, and nowhere with a prescription. This block is the loud form, and + // it is read off the same issues rather than a second parse. + const undeclared = findUndeclaredKeys(result.error.issues); + if (undeclared.length > 0) { + console.error(chalk.bold('\nUndeclared keys — the strict authoring face refuses them:')); + for (const finding of undeclared) { + console.error(chalk.yellow(` ${describeUndeclaredKey(finding, schema)}`)); + } + } + + // A mixed-vocabulary entry validated under the tolerant face and got the + // warning in the success branch above (#3090). The strict face refuses its + // `field` key as undeclared, so the same entry now lands HERE — keep the + // explanation with it rather than leave that warning unreachable. + const mixedHere = findSpecVocabularyFormFields(schema).filter((f) => f.mixedName); + for (const m of mixedHere) { + console.error(chalk.yellow( + ` ${m.path}: '${m.mixedName}' also carries { field: '${m.field}' } — mixed form-field ` + + `vocabularies. The renderer ignores \`field\` here; drop one of the two.`, + )); + } + // "name: expected string, received undefined" on a `{ field: … }` entry // reads as an instruction to bolt a `name` on — which converts the // metadata WRONGLY (the spec shape stands for an object-schema merge diff --git a/packages/cli/src/utils/authoring-face.ts b/packages/cli/src/utils/authoring-face.ts new file mode 100644 index 0000000000..d43fa8a26e --- /dev/null +++ b/packages/cli/src/utils/authoring-face.ts @@ -0,0 +1,64 @@ +/** + * 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. + */ + +/** + * The ONE door through which `objectui validate` and `objectui check` judge an + * authored document: the STRICT AUTHORING FACE (objectui#5250, slice A). + * + * The maintainer ruling on objectui#5250 (2026-09-04, decision batch #25, + * option 2) reads: "each node schema gets a derived strict variant; + * `objectui validate` and the doc-snippet gates run **strict**; renderer props + * keep the tolerant face unchanged". `StrictAnyComponentSchema` is that variant + * (objectui#8345): the same declarations as `AnyComponentSchema`, with every + * object closed, at every depth. So a key no schema declares is REFUSED here, + * where the tolerant face kept it unjudged — `BaseSchemaCore` is `.passthrough()`, + * and that is the face the RENDERER keeps. ⛔ Nothing here touches it. + * + * Why one function and not two call sites: both commands called + * `safeValidateSchema` directly, and two call sites are how two commands drift + * into two verdicts on one document. Both now call this, so `check`'s + * validity arm and `validate`'s verdict are the same parse by construction. + * + * ⛔ No flag chooses the face, in either direction. A `--strict` opt-in or a + * `--tolerant` escape would widen the CLI's public surface, and the ruling + * names the strict face as THE authoring verdict, not as a mode of it. + */ + +import { StrictAnyComponentSchema } from '@object-ui/types/zod'; + +import { formatIssuePath } from './issue-path.js'; +import { authoredTypeAt, type UndeclaredKey } from './union-arm-diagnostics.js'; + +/** Judge one authored document through the strict authoring face. */ +export function validateAuthoredDocument(document: unknown) { + return StrictAnyComponentSchema.safeParse(document); +} + +/** + * The line that names one undeclared key, where it sits, and what to do. + * + * A strict refusal read off the issue list alone can be a bare `Invalid input` + * at a child slot, or `Unrecognized key` with no word on what to do about it. + * This is the loud form: the key, the path of the object carrying it (spelled + * by `formatIssuePath`, like every other path the CLI prints), that object's + * own `type` when it has one, and the prescription. + * + * The `type` is quoted as a FACT about the object, never as a claim about + * which schema judged it: a form field's `type` is a field type, not a + * component, so "the keys `text` declares" would send the author to the wrong + * list. The prescription therefore points at the position, not at a type. + */ +export function describeUndeclaredKey(finding: UndeclaredKey, document: unknown): string { + const type = authoredTypeAt(document, finding.path); + const owner = type === undefined ? '' : ` (type "${type}")`; + return ( + `Undeclared key "${finding.key}" at ${formatIssuePath(finding.path)}${owner}: ` + + 'no schema declares it there. Remove it, or check its spelling against the keys ' + + 'declared at that position.' + ); +} diff --git a/packages/cli/src/utils/union-arm-diagnostics.ts b/packages/cli/src/utils/union-arm-diagnostics.ts index c171b9e82a..99dbfcd6ce 100644 --- a/packages/cli/src/utils/union-arm-diagnostics.ts +++ b/packages/cli/src/utils/union-arm-diagnostics.ts @@ -32,7 +32,9 @@ * its lines stays in `validate.ts`, the CLI's full zod-issue printer. * `objectui check` prints one line per file that did not validate — the first * issue only, never an arm selection (objectui#11007) — and the two share - * nothing but `formatIssuePath`, so no second arm-rendering surface exists. + * `formatIssuePath` plus ONE reader from here, {@link findUndeclaredKeys}, so + * both name an undeclared key the same way (objectui#5250). No second + * arm-rendering surface exists: that reader returns keys and paths, never lines. * * ## Measured facts this rests on (Zod 4.4.3, re-measured on this tree) * @@ -113,6 +115,8 @@ export interface UnionIssueLike { /** The key that union dispatches on. ⛔ Not always `type` — see the guard. */ discriminator?: string; note?: string; + /** Present on `unrecognized_keys`: the keys the object does not declare. */ + keys?: readonly string[]; } /** One arm issue, rebased onto an absolute path. */ @@ -247,7 +251,7 @@ export function nearestArmNames( } /** Read `document[...path].type`, when it is a string. */ -function authoredTypeAt(document: unknown, path: readonly PropertyKey[]): string | undefined { +export 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; @@ -433,3 +437,98 @@ export function explainUnionIssue(issue: UnionIssueLike, document: unknown): Uni if (!isUnion(issue)) return []; return explain(issue, issue.path ?? [], document); } + +/** One key an object carries and its schema does not declare, at its absolute path. */ +export interface UndeclaredKey { + /** Path of the OBJECT carrying the key — `[]` for the document root. */ + path: PropertyKey[]; + key: string; +} + +/** + * Did this arm fail because the authored `type` is not the one it declares? + * Shapes (a) and (b) of header fact 2, read at the arm's own node. + */ +function isTypeMismatch(issue: UnionIssueLike): boolean { + return isAtTypeKey(issue) && (issue.code === 'invalid_value' || isUnion(issue)); +} + +/** + * Did this arm fail because the value is not its SHAPE at all — a string arm + * handed an object, the single-node arm of a child slot handed an array? Either + * an `invalid_type` at the arm's own node, or a union there none of whose arms + * is viable. + */ +function isShapeMismatch(issue: UnionIssueLike): boolean { + if ((issue.path ?? []).length !== 0) return false; + if (issue.code === 'invalid_type') return true; + return isUnion(issue) && (issue.errors ?? []).every((arm) => !isViableArm(arm)); +} + +/** + * An arm the document could have been MEANT for: its `type` did not mismatch, + * and it did not fail on shape alone. Only a viable arm's `unrecognized_keys` + * say something about the document — an arm the document was never meant for + * would report "undeclared" keys that are declared on the arm it was meant for. + */ +function isViableArm(armIssues: readonly UnionIssueLike[]): boolean { + if (armIssues.some(isTypeMismatch)) return false; + return !armIssues.every(isShapeMismatch); +} + +/** + * Every key the strict authoring face refused as undeclared, wherever in the + * issue tree it was reported (objectui#5250). + * + * The strict face (`StrictAnyComponentSchema`, objectui#8345) reports an + * undeclared key as `unrecognized_keys` on the object that carries it. At the + * document root that is a top-level issue. Below a child slot it is NOT: a slot + * is `SchemaNode | SchemaNode[]`, an undiscriminated union, so the refusal sits + * inside an arm's `errors` — twice nested for `children[0]`, once more per + * level — under an `invalid_union` · `Invalid input` that names no key at all. + * So this walks union arms, rebasing each path onto its union's node (header + * fact 3), and reads only the VIABLE arms (see {@link isViableArm}). + * + * When more than one arm is viable, a key counts only when EVERY viable arm + * refuses it. A key one viable arm declares is not undeclared — it may belong + * to the arm the document was meant for. The live instance: a dashboard widget + * `{ type: 'metric-card', value, trendValu }` is viable on the `metric-card` + * arm, which declares `value` as a registered input (objectui#11022), and on + * the generic widget arm, which does not; naming `value` would send the author + * to delete a key the component reads. The intersection names `trendValu` only. + * + * Deduplicated by path and key, in the order the issues report them. ⛔ No cap: + * each entry is a refusal the author has to act on. + */ +export function findUndeclaredKeys(issues: readonly UnionIssueLike[]): UndeclaredKey[] { + const idOf = (finding: UndeclaredKey): string => + JSON.stringify([...finding.path.map(String), finding.key]); + const collect = (list: readonly UnionIssueLike[], prefix: readonly PropertyKey[]): UndeclaredKey[] => { + const found: UndeclaredKey[] = []; + for (const issue of list) { + const path = [...prefix, ...(issue.path ?? [])]; + if (issue.code === 'unrecognized_keys') { + for (const key of issue.keys ?? []) found.push({ path, key }); + } + if (isUnion(issue)) { + const perArm = (issue.errors ?? []) + .filter(isViableArm) + .map((arm) => collect(arm, path)); + const [first, ...others] = perArm; + if (first === undefined) continue; + const refusedByAll = first.filter((finding) => + others.every((arm) => arm.some((other) => idOf(other) === idOf(finding))), + ); + found.push(...refusedByAll); + } + } + return found; + }; + const seen = new Set(); + return collect(issues, []).filter((finding) => { + const id = idOf(finding); + if (seen.has(id)) return false; + seen.add(id); + return true; + }); +} From 930f73062782fd9540ed01aa8dd52e12900be07d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:31:10 +0000 Subject: [PATCH 2/6] test(cli): pin the strict authoring face on `objectui validate` / `objectui check` (objectui#5250) The card's probe (`validation` on an `input`) is refused at the root with its key, path and prescription; a nested child's undeclared key is named at the child's path; a key one viable widget arm declares is not named; declared-only documents stay green. `check` lists a leaf with an undeclared key by name and names the key under it. The three CLI pins that stood in for "the parse `check` / `validate` run" now read `validateAuthoredDocument`, the door both commands call. Part of objectui#5250 (slice A). Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .../__tests__/check-first-issue-11007.test.ts | 18 +- .../check-validity-recogniser.test.ts | 8 +- ...tered-types-validate-ratchet-10859.test.ts | 13 +- ...alidate-strict-authoring-face-5250.test.ts | 326 ++++++++++++++++++ 4 files changed, 349 insertions(+), 16 deletions(-) create mode 100644 packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts diff --git a/packages/cli/src/__tests__/check-first-issue-11007.test.ts b/packages/cli/src/__tests__/check-first-issue-11007.test.ts index 53cad58b57..c8c6860e3f 100644 --- a/packages/cli/src/__tests__/check-first-issue-11007.test.ts +++ b/packages/cli/src/__tests__/check-first-issue-11007.test.ts @@ -29,7 +29,8 @@ * the ruling. * * Expected text is derived from the command's own `describeFirstIssue` and - * `closingLine`, fed from `safeValidateSchema`'s real issues, rather than + * `closingLine`, fed from the real issues of the door `check` parses with + * (`validateAuthoredDocument`, the strict authoring face since objectui#5250), rather than * copied here as literals. What the assertions hold by hand is the SUBJECT: * the refused key, the path it sits at, and the counts. * @@ -44,7 +45,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { safeValidateSchema } from '@object-ui/types/zod'; +import { validateAuthoredDocument } from '../utils/authoring-face.js'; import { check, closingLine, describeFirstIssue } from '../commands/check.js'; import { formatIssuePath } from '../utils/issue-path.js'; @@ -90,9 +91,12 @@ function plainLines(): string[] { return lines.map((l) => l.replace(ANSI, '')); } -/** The validator's issues for a document the test asserts does not validate. */ +/** + * The validator's issues for a document the test asserts does not validate — + * read through the same door `check` and `validate` parse with (objectui#5250). + */ function issuesOf(document: unknown) { - const result = safeValidateSchema(document); + const result = validateAuthoredDocument(document); if (result.success) throw new Error('fixture validates; this test needs one that does not'); return result.error.issues; } @@ -214,7 +218,7 @@ describe('objectui check — the closing line is a tally, never a pass', () => { it('does not report the nested typo as passed', async () => { // The precondition: the document IS invalid — `objectui validate` refuses // it. `check` admits it on `children` without parsing it. - expect(safeValidateSchema(NESTED_TYPO).success).toBe(false); + expect(validateAuthoredDocument(NESTED_TYPO).success).toBe(false); writeSchema('page.json', NESTED_TYPO); await check(cwd); @@ -264,8 +268,8 @@ describe('objectui check — no nested walk of its own (triage ruling on objectu // duplicate `objectui validate`, whose verdict this command points to. // The control that makes the pair meaningful: the validator DOES tell them // apart. - expect(safeValidateSchema(NESTED_TYPO).success).toBe(false); - expect(safeValidateSchema(NESTED_CLEAN).success).toBe(true); + expect(validateAuthoredDocument(NESTED_TYPO).success).toBe(false); + expect(validateAuthoredDocument(NESTED_CLEAN).success).toBe(true); writeSchema('broken-page.json', NESTED_TYPO); writeSchema('clean-page.json', NESTED_CLEAN); diff --git a/packages/cli/src/__tests__/check-validity-recogniser.test.ts b/packages/cli/src/__tests__/check-validity-recogniser.test.ts index 5b9744f24d..137e2718dc 100644 --- a/packages/cli/src/__tests__/check-validity-recogniser.test.ts +++ b/packages/cli/src/__tests__/check-validity-recogniser.test.ts @@ -42,7 +42,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { safeValidateSchema } from '@object-ui/types/zod'; +import { validateAuthoredDocument } from '../utils/authoring-face.js'; import { check, closingLine } from '../commands/check.js'; @@ -238,9 +238,11 @@ describe('objectui check — a broken ObjectUI schema is never filed as a foreig // bare `expected +0 to be 1`. If this fires: the objectstack#8593 ruling has // been revisited — re-read this case, then pick another registered type with // no member in `AnyComponentSchema` and update the counts above. + // Read through the door `check` recognises with — the strict authoring + // face since objectui#5250 — so this precondition is about `check` itself. expect( - safeValidateSchema({ type: 'metric-card', title: 'Total Revenue', value: '$123,456' }).success, - '`metric-card` is now modelled by AnyComponentSchema — this fixture needs a type that still is not; see the comment above', + validateAuthoredDocument({ type: 'metric-card', title: 'Total Revenue', value: '$123,456' }).success, + '`metric-card` now validates as a root document — this fixture needs a type that still does not; see the comment above', ).toBe(false); await check(cwd); expect(candidateCount()).toBe(1); diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index e3eef9eddd..e9e4a08e43 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -12,8 +12,9 @@ * * ## The gap this closes * - * `objectui validate` judges a document with `safeValidateSchema`, whose root - * is `AnyComponentSchema` — a union discriminated on `type`. A registered type + * `objectui validate` judges a document through the strict authoring face + * (`validateAuthoredDocument`, objectui#5250), whose root is the strict twin of + * `AnyComponentSchema` — a union discriminated on `type`. A registered type * with no arm in that union is refused with one `invalid_union` issue at * `['type']`, whatever else the document says: `ai-form-assist`, which * `packages/plugin-ai/README.md` teaches, was one of them. The mirror-parity @@ -26,7 +27,7 @@ * The population is the bare (un-namespaced) keys of `KNOWN_SCHEMA_TYPES` * (`../utils/known-schema-types.ts`), the list generated from the registration * calls themselves, which `objectui check` already uses. A key is REFUSED when - * `safeValidateSchema({ type: KEY })` fails with an `invalid_union` issue at + * `validateAuthoredDocument({ type: KEY })` fails with an `invalid_union` issue at * path `['type']` — the "no arm claims this literal" reading. A key whose arm * exists but wants more than the bare `type` (a required member) is not * refused here: its `type` is claimed, and the missing member is the @@ -58,7 +59,7 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { tmpdir } from 'node:os'; import { fileURLToPath } from 'node:url'; -import { safeValidateSchema } from '@object-ui/types/zod'; +import { validateAuthoredDocument } from '../utils/authoring-face.js'; import { KNOWN_SCHEMA_TYPES } from '../utils/known-schema-types.js'; import { validate } from '../commands/validate.js'; @@ -102,7 +103,7 @@ const ARMED_PUBLIC_BLOCKS_10872_BATCH_2 = ['element:number'] as const; /** Is `type` unclaimed by every arm of the validator's root union? */ function refusedAtType(type: string): boolean { - const result = safeValidateSchema({ type }); + const result = validateAuthoredDocument({ type }); if (result.success) return false; return result.error.issues.some( (issue) => issue.code === 'invalid_union' && issue.path.length === 1 && issue.path[0] === 'type', @@ -137,7 +138,7 @@ describe('registered component types refused at `type` — a ratchet (objectui#1 // Lit control: an armed, registered key is not refused at `type`. expect(refusedAtType('timeline')).toBe(false); // A key whose arm wants a required member fails, but NOT at `type`. - expect(safeValidateSchema({ type: 'chart' }).success).toBe(false); + expect(validateAuthoredDocument({ type: 'chart' }).success).toBe(false); expect(refusedAtType('chart')).toBe(false); // And a type no arm claims IS refused there. expect(refusedAtType('no-such-component-10859')).toBe(true); diff --git a/packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts b/packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts new file mode 100644 index 0000000000..70de533a91 --- /dev/null +++ b/packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts @@ -0,0 +1,326 @@ +/** + * 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#5250, slice A — `objectui validate` and `objectui check` judge an + * authored document through the STRICT authoring face. + * + * The ruling (maintainer 2026-09-04, decision batch #25, option 2): "each node + * schema gets a derived strict variant; `objectui validate` and the + * doc-snippet gates run **strict**; renderer props keep the tolerant face + * unchanged". The derived variant is `StrictAnyComponentSchema` + * (objectui#8345); both commands now reach it through one door, + * `validateAuthoredDocument` (`../utils/authoring-face.ts`). + * + * What the pins below hold: + * + * 1. The card's own probe — an `input` node carrying a `validation` object + * `InputSchema` does not declare — was `✓ Schema is valid!`. It is refused + * now, and the refusal NAMES the key, its path and what to do. Each refusal + * pin asserts the tolerant face still accepts the same document, so a red + * here can only mean the strict door moved, never that the fixture broke. + * 2. A nested child's undeclared key is refused too, and named — although the + * issue list carries it only inside the arms of an `Invalid input` at the + * child slot (a slot is `SchemaNode | SchemaNode[]`, an undiscriminated + * union). The per-depth half of the programme (director pointer on + * objectui#5250) is what makes a nested child judged at all. + * 3. A key one viable union arm declares is NOT named, even when another + * viable arm refuses it — the `metric-card` widget case. + * 4. Declared-only documents stay green: the positive control for every + * refusal above, so a door that refused everything fails here. + * 5. `objectui check` reads the same door: a leaf carrying an undeclared key + * leaves the "validated" count, is listed by name, and its undeclared key + * is named under it. Still advisory — the exit code does not move. + * + * Harness (fixtures under `os.tmpdir()`, `process.exit` recorded rather than + * taken) follows `validate-root-path-line.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { safeValidateSchema } from '@object-ui/types/zod'; + +import { check, closingLine, describeFirstIssue } from '../commands/check.js'; +import { validate } from '../commands/validate.js'; +import { describeUndeclaredKey, validateAuthoredDocument } from '../utils/authoring-face.js'; +import { findUndeclaredKeys, type UnionIssueLike } 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 card's probe, verbatim from the objectui#5250 body. */ +const CARD_PROBE = { + type: 'input', + name: 'email', + validation: { pattern: 12345, message: false, nonsense: [1, 2] }, +}; +const CARD_PROBE_DECLARED_ONLY = { type: 'input', name: 'email' }; + +/** The same undeclared key one level down, under a child slot. */ +const NESTED_PROBE = { type: 'div', children: [{ type: 'input', name: 'email', nonsense: 1 }] }; +const NESTED_DECLARED_ONLY = { type: 'div', children: [{ type: 'input', name: 'email' }] }; + +/** Two child slots deep: a `text` node inside a `card` inside a `div`. */ +const DEPTH_TWO = { + type: 'div', + children: [{ type: 'card', title: 'T', children: [{ type: 'text', content: 'x', colour: 'red' }] }], +}; + +/** + * A widget viable on TWO arms of the widget union: the `metric-card` arm, + * which declares `value` as a registered input (objectui#11022), and the + * generic widget arm, which does not. Only the typo is undeclared on both. + */ +const WIDGET_TYPO = { + type: 'dashboard', + widgets: [{ type: 'metric-card', title: 'Total', value: '1', trendValu: '+2%' }], +}; + +/** + * A chart widget whose `options` carries a key its arm does not declare. The + * `metric-card` arm ALSO reports an undeclared key here (`options` itself) — + * but that arm failed on its `type`, so it is not the arm the widget is for. + */ +const WIDGET_OPTIONS = { + type: 'dashboard', + widgets: [{ type: 'bar', title: 'Sales', options: { xField: 'month' } }], +}; + +/** The #3090 mixed-vocabulary entry: a runtime `name` plus the spec `field`. */ +const MIXED_FORM = { type: 'form', fields: [{ name: 'subject', field: 'subject_line', type: 'text' }] }; + +let dir: string; +let out: string[]; +let exitCodes: number[]; +let restore: () => void; + +function plainLines(): string[] { + return out.join('\n').replace(ANSI, '').split('\n'); +} + +/** The undeclared-key lines, in print order, with their indentation removed. */ +function undeclaredLines(): string[] { + return plainLines() + .filter((l) => l.trimStart().startsWith('Undeclared key "')) + .map((l) => l.trim()); +} + +/** The line `describeUndeclaredKey` prints for one finding — the expectation. */ +function expectedLine(document: unknown, path: PropertyKey[], key: string): string { + return describeUndeclaredKey({ path, key }, document); +} + +async function runValidate(name: string, document: unknown): Promise { + const file = join(dir, name); + writeFileSync(file, JSON.stringify(document), 'utf-8'); + await validate(file); +} + +/** Every `unrecognized_keys` the parse reported, at any arm depth, as `path|key`. */ +function everyRefusedKeyInTheTree(document: unknown): string[] { + const result = validateAuthoredDocument(document); + if (result.success) return []; + const all: string[] = []; + const walk = (issues: readonly UnionIssueLike[], prefix: PropertyKey[]): void => { + for (const issue of issues) { + const path = [...prefix, ...(issue.path ?? [])]; + for (const key of issue.keys ?? []) all.push(`${path.join('.')}|${key}`); + for (const arm of issue.errors ?? []) walk(arm, path); + } + }; + walk(result.error.issues as readonly UnionIssueLike[], []); + return all; +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'objectui-strict-face-5250-')); + 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 — an undeclared key is refused by name (objectui#5250)', () => { + it('refuses the card\'s probe at the root, naming `validation`, its path and the prescription', async () => { + // The precondition: the tolerant face accepts this document, so the + // refusal below is the strict face's and nothing else's. + expect(safeValidateSchema(CARD_PROBE).success).toBe(true); + + await runValidate('probe.json', CARD_PROBE); + + expect(exitCodes).toEqual([1]); + const lines = plainLines(); + expect(lines).toContain('1. Unrecognized key: "validation"'); + expect(lines).toContain(' Path: (root)'); + expect(lines).toContain(' Code: unrecognized_keys'); + expect(undeclaredLines()).toEqual([expectedLine(CARD_PROBE, [], 'validation')]); + expect(undeclaredLines()[0]).toBe( + 'Undeclared key "validation" at (root) (type "input"): no schema declares it there. ' + + 'Remove it, or check its spelling against the keys declared at that position.', + ); + expect(lines.some((l) => l.includes('Schema is valid'))).toBe(false); + }); + + it('keeps the declared-only document valid (the positive control)', async () => { + await runValidate('clean.json', CARD_PROBE_DECLARED_ONLY); + expect(exitCodes).toEqual([0]); + expect(plainLines()).toContain('✓ Schema is valid!'); + expect(undeclaredLines()).toEqual([]); + }); + + it('refuses an undeclared key on a nested child and names it at the child\'s path', async () => { + expect(safeValidateSchema(NESTED_PROBE).success).toBe(true); + // The issue list alone does not name it at the top: the child slot is an + // undiscriminated union, so the first issue is an `Invalid input` there. + const result = validateAuthoredDocument(NESTED_PROBE); + expect(result.success).toBe(false); + expect(result.error?.issues[0]).toMatchObject({ code: 'invalid_union', path: ['children'] }); + + await runValidate('nested.json', NESTED_PROBE); + + expect(exitCodes).toEqual([1]); + expect(undeclaredLines()).toEqual([expectedLine(NESTED_PROBE, ['children', 0], 'nonsense')]); + expect(undeclaredLines()[0]).toContain('"nonsense" at children → 0 (type "input")'); + }); + + it('keeps the nested declared-only document valid', async () => { + await runValidate('nested-clean.json', NESTED_DECLARED_ONLY); + expect(exitCodes).toEqual([0]); + expect(undeclaredLines()).toEqual([]); + }); + + it('names a key two child slots deep, at its full path', async () => { + expect(safeValidateSchema(DEPTH_TWO).success).toBe(true); + await runValidate('depth-two.json', DEPTH_TWO); + expect(exitCodes).toEqual([1]); + expect(undeclaredLines()).toEqual([ + expectedLine(DEPTH_TWO, ['children', 0, 'children', 0], 'colour'), + ]); + expect(undeclaredLines()[0]).toContain('at children → 0 → children → 0 (type "text")'); + }); + + it('does not name a key that a viable arm declares — only the one every viable arm refuses', async () => { + // Load-bearing precondition: the generic widget arm DOES refuse `value` + // somewhere in the issue tree, so a reader that took any arm's word would + // name it. The `metric-card` arm declares it (a registered input). + expect(everyRefusedKeyInTheTree(WIDGET_TYPO)).toContain('widgets.0|value'); + + await runValidate('widget-typo.json', WIDGET_TYPO); + + expect(exitCodes).toEqual([1]); + expect(undeclaredLines()).toEqual([expectedLine(WIDGET_TYPO, ['widgets', 0], 'trendValu')]); + }); + + it('does not read an arm whose `type` mismatched', async () => { + // Load-bearing precondition: the `metric-card` arm refuses `options` on + // this `bar` widget — but it is not the arm the widget is for. + expect(everyRefusedKeyInTheTree(WIDGET_OPTIONS)).toContain('widgets.0|options'); + + await runValidate('widget-options.json', WIDGET_OPTIONS); + + expect(exitCodes).toEqual([1]); + expect(undeclaredLines()).toEqual([ + expectedLine(WIDGET_OPTIONS, ['widgets', 0, 'options'], 'xField'), + ]); + }); + + it('refuses the mixed-vocabulary `field` and keeps the #3090 explanation with the refusal', async () => { + expect(safeValidateSchema(MIXED_FORM).success).toBe(true); + await runValidate('mixed.json', MIXED_FORM); + expect(exitCodes).toEqual([1]); + expect(undeclaredLines()).toEqual([expectedLine(MIXED_FORM, ['fields', 0], 'field')]); + expect(plainLines().some((l) => l.includes("'subject' also carries { field: 'subject_line' }"))).toBe(true); + }); +}); + +describe('findUndeclaredKeys — reads the same issues the printer prints', () => { + it('names no key for a refusal that is about a VALUE, not a key', () => { + // Refused by both faces, for `variant` — a declared key with an off-enum + // value. No key is undeclared, so none may be named. + const valueOnly = { type: 'text', content: 'Small text', variant: 'small' }; + const result = validateAuthoredDocument(valueOnly); + expect(result.success).toBe(false); + expect(result.error!.issues.map((i) => i.code)).not.toContain('unrecognized_keys'); + expect(findUndeclaredKeys(result.error!.issues as readonly UnionIssueLike[])).toEqual([]); + }); + + it('reads each refused key once, with the path of the object carrying it', () => { + const result = validateAuthoredDocument(CARD_PROBE); + expect(result.success).toBe(false); + expect(findUndeclaredKeys(result.error!.issues as readonly UnionIssueLike[])).toEqual([ + { path: [], key: 'validation' }, + ]); + }); +}); + +describe('objectui check — the validity arm reads the strict door too (objectui#5250)', () => { + function writeSchema(name: string, body: unknown): void { + writeFileSync(join(dir, name), JSON.stringify(body)); + } + + /** The line printed under a listed file: its first issue. */ + function linesUnder(file: string): string[] { + const lines = plainLines(); + const at = lines.findIndex((l) => /^ {3}\S/.test(l) && l.includes(`${file} (type "`)); + if (at < 0) throw new Error(`${file} is not listed as a file that did not validate`); + const under: string[] = []; + for (let i = at + 1; i < lines.length && /^ {5}\S/.test(lines[i]); i++) under.push(lines[i].trim()); + return under; + } + + it('lists a leaf carrying an undeclared key by name and names the key under it', async () => { + // Before this card the tolerant face validated it, and `check` counted it + // among the validated files. + expect(safeValidateSchema(CARD_PROBE).success).toBe(true); + writeSchema('probe.json', CARD_PROBE); + writeSchema('clean.json', CARD_PROBE_DECLARED_ONLY); + + await check(dir); + + const issues = validateAuthoredDocument(CARD_PROBE).error!.issues; + expect(linesUnder('probe.json')).toEqual([ + describeFirstIssue(issues), + expectedLine(CARD_PROBE, [], 'validation'), + ]); + expect(plainLines()).toContain(closingLine({ validated: 1, notValidated: 0, didNotValidate: 1 })); + // Advisory, as before: listing the file does not fail the run. + expect(exitCodes).toEqual([]); + }); + + it('names a nested undeclared key the first-issue line cannot', async () => { + writeSchema('dashboard.json', WIDGET_OPTIONS); + await check(dir); + const under = linesUnder('dashboard.json'); + // The first issue sits at the widget and names no key… + expect(under[0]).toBe(describeFirstIssue(validateAuthoredDocument(WIDGET_OPTIONS).error!.issues)); + expect(under[0]).not.toContain('xField'); + // …so the undeclared-key line is what names it. + expect(under.slice(1)).toEqual([expectedLine(WIDGET_OPTIONS, ['widgets', 0, 'options'], 'xField')]); + }); +}); From a11f73347ddd9f6e95cede76f3ec321516c0a54d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 08:34:14 +0000 Subject: [PATCH 3/6] docs(cli): `objectui validate` refuses an undeclared key; changeset for the strict wiring (objectui#5250) The CLI README and utilities/cli.mdx say what the strict authoring face does for `validate` and `check`. Four guide/component pages said nothing refuses an undeclared key, or that `objectui validate` runs `safeValidateSchema`; both went false with the wiring, so they now name the render path and the authoring verdict apart. Changeset: `@object-ui/cli` minor, breaking semantics stated in the body (the fixed group never declares major). Part of objectui#5250 (slice A). Claude-Session: https://claude.ai/code/session_01TdiauJaVCHuj45EzZGUxHh Co-authored-by: Claude --- .changeset/5250-strict-validate-wiring.md | 25 +++++++++++++++++++++++ content/docs/components/basic/text.mdx | 9 ++++---- content/docs/guide/architecture.md | 7 ++++--- content/docs/guide/layout.md | 7 ++++--- content/docs/guide/schema-rendering.md | 10 +++++---- content/docs/utilities/cli.mdx | 24 +++++++++++++++++++--- packages/cli/README.md | 5 +++++ 7 files changed, 70 insertions(+), 17 deletions(-) create mode 100644 .changeset/5250-strict-validate-wiring.md diff --git a/.changeset/5250-strict-validate-wiring.md b/.changeset/5250-strict-validate-wiring.md new file mode 100644 index 0000000000..b464b56e9a --- /dev/null +++ b/.changeset/5250-strict-validate-wiring.md @@ -0,0 +1,25 @@ +--- +'@object-ui/cli': minor +--- + +`objectui validate` and `objectui check` judge a document through the strict authoring face (objectui#5250) + +Both commands parsed an authored document with `safeValidateSchema`, whose node schemas are +`.passthrough()`: a key no schema declares was kept and never judged, so a misspelled or invented +key read `✓ Schema is valid!`. They now parse through `StrictAnyComponentSchema`, the strict twin +of the same declarations (objectui#8345), under the objectui#5250 ruling: the authoring verdict is +strict, while the rendering face keeps its passthrough. + +**Breaking for documents that carry an undeclared key.** `objectui validate` now exits 1 on such a +document, on the root node and on every nested node, and names each key, the path of the object +carrying it, and the fix: + + Undeclared key "validation" at (root) (type "input"): no schema declares it there. Remove it, or check its spelling against the keys declared at that position. + +A nested child's key is named at the child's path even though the issue list reports it only as an +`Invalid input` at the child slot. `objectui check` stays advisory — it still exits non-zero only on +unreadable JSON — but such a file is no longer counted as validated: it is listed by name among the +files that did not validate, with the same line under it. There is no flag to opt out. + +Declared `minor` rather than `major` because this release group follows the `@objectstack` major; +the breaking semantics are stated here instead. diff --git a/content/docs/components/basic/text.mdx b/content/docs/components/basic/text.mdx index c1842368ce..f9d467fb8e 100644 --- a/content/docs/components/basic/text.mdx +++ b/content/docs/components/basic/text.mdx @@ -63,10 +63,11 @@ and the renderer builds the element's class list from `variant`, `align` and `"text-muted-foreground"` on the `text` nodes themselves. ⚠️ This page taught a `color` key until objectui#7444. Nothing ever declared or -read it: `BaseSchema` is `.passthrough()`, so an authored `color` is still -accepted today and still does nothing — it does not even reach the element, -because `color` is not on the DOM pass-through whitelist. Rename the key to -`className`; the Tailwind class string is unchanged. +read it: `BaseSchema` is `.passthrough()`, so an authored `color` still renders +without error and still does nothing — it does not even reach the element, +because `color` is not on the DOM pass-through whitelist. `objectui validate` +refuses it by name. Rename the key to `className`; the Tailwind class string is +unchanged. diff --git a/content/docs/guide/architecture.md b/content/docs/guide/architecture.md index d68b127584..dd030c3e41 100644 --- a/content/docs/guide/architecture.md +++ b/content/docs/guide/architecture.md @@ -273,9 +273,10 @@ row a record surface is bound to, and is the only spelling a row field has — t (objectui#5330 phase 2). A head name outside the scope is not refused: the predicate is unevaluable, this surface fails soft, and the node is shown on every row. -A button's text key is `label`, and `text` is not a `ButtonSchema` key at all. Nothing -refuses the misspelling either: `BaseSchema` is `.passthrough()`, so the validator KEEPS -the unknown key, and the renderer — which reads `schema.label` — never looks at it. +A button's text key is `label`, and `text` is not a `ButtonSchema` key at all. Only +`objectui validate` refuses the misspelling, by name: the rendering face is +`.passthrough()` — `BaseSchema` KEEPS the unknown key — and the renderer, which reads +`schema.label`, never looks at it. Measured on the node above with `text`: the button renders with an empty `textContent`, so it appears on screen as a blank rectangle with no text. diff --git a/content/docs/guide/layout.md b/content/docs/guide/layout.md index 570a150066..96750b4e5b 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -179,9 +179,10 @@ A `page` node has no action row of its own. Buttons are NODES, and they go in `c } ``` -`label` is the button's text key — `text` is not a `ButtonSchema` key, and because -`BaseSchema` is `.passthrough()` nothing refuses it: the validator keeps the unknown key -and `button.tsx`, which reads `schema.label`, renders a button with no text. +`label` is the button's text key — `text` is not a `ButtonSchema` key. `objectui validate` +refuses it by name, but nothing on the render path does: `BaseSchema` is `.passthrough()`, +so the rendering face keeps the unknown key and `button.tsx`, which reads `schema.label`, +renders a button with no text. > **⛔ `actions` on a `page` node is refused by name** (objectui#7926). This page used to > teach `"actions": [ … ]` as a sibling of `title`, and it drew **nothing**: `PageRenderer` diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index 65da7d502a..0b3b1d7689 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -440,7 +440,8 @@ Validation happens at three doors, all of them outside the render path: `_diagnostics` envelope, which Studio surfaces. See [Metadata Diagnostics](./metadata-diagnostics.md). 2. **Authoring time — `objectui validate`.** It parses one document, JSON or YAML, against the - published schema, prints the schema's own errors when it fails, and exits non-zero. That exit + published schema's strict authoring face — which refuses, by name, any key no schema declares, + on every node — prints the schema's own errors when it fails, and exits non-zero. That exit code is the CLI's validation verdict. `objectui check` does not give it. `check` sweeps a project's JSON files and checks the `type` of each file it recognises, and a file whose root carries an ObjectUI structural key (`children`, `className`, `body`, …) is recognised by that @@ -449,9 +450,10 @@ Validation happens at three doors, all of them outside the render path: its root `type` names a registered component but the document does not validate, and that list is advisory: `check` exits non-zero on unreadable JSON only. See [`objectui check`](/docs/utilities/cli#objectui-check). -3. **Wherever else you need it — `safeValidateSchema`.** Exported from `@object-ui/types/zod`, - it is the parse `objectui validate` runs, so a build step, a CI job or a save handler can - apply the identical contract. +3. **Wherever else you need it — `StrictAnyComponentSchema.safeParse`.** Exported from + `@object-ui/types/zod`, it is the parse `objectui validate` runs, so a build step, a CI job or + a save handler can apply the identical contract. `safeValidateSchema`, from the same module, + is the tolerant parse: it keeps a key no schema declares, unjudged, the way the renderer does. Put the check where documents are authored, saved or loaded — not in the paint. A document that reaches the browser without passing one of those doors is drawn as best the renderer can, in diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index ba3b91986c..c1b35623a6 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -189,6 +189,21 @@ the report says so and offers the nearest few of the accepted values instead: 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. +#### Undeclared keys + +`objectui validate` parses through the **strict authoring face**: a key that no +schema declares is refused, on the root node and on every nested node, where the +renderer would silently ignore it. Each refused key is named once more after the +numbered issues — including a nested one, which the issue list otherwise reports +only as an `Invalid input` at the child slot: + +```text +Undeclared keys — the strict authoring face refuses them: + Undeclared key "nonsense" at children → 0 (type "input"): no schema declares it there. Remove it, or check its spelling against the keys declared at that position. +``` + +There is no flag to relax this: remove the key, or correct its spelling. + ### `objectui check` Scan the project for ObjectUI schema files and report on the ones it @@ -196,8 +211,10 @@ recognises. No flags. A JSON file with a root `type` is recognised when its root carries an ObjectUI structural key (`children`, `className`, …) **or** when the whole -document validates as an ObjectUI component schema. Recognised files have their -`type` checked against the component types this build registers. +document validates as an ObjectUI component schema — through the same strict +parse `objectui validate` runs, so a document carrying an undeclared key does not +validate here either. Recognised files have their `type` checked against the +component types this build registers. The two ways in are not equal. A document recognised by validating was parsed in full, nested nodes included. A document recognised by a structural key was @@ -212,7 +229,8 @@ distinction matters: That is ObjectUI content the validator could not accept — either the document is off-spec, or its component type is not modelled by `@object-ui/types`. Under each file, `check` prints the validator's first issue: its path, spelled - as `objectui validate` spells it, and its message. Run + as `objectui validate` spells it, and its message — then one line per + undeclared key, worded as `objectui validate` words it. Run `objectui validate ` for the full diagnosis. - **Counted as skipped** otherwise. A root `type` heads several unrelated JSON vocabularies — `package.json`'s `"type": "module"` most of all — and those are diff --git a/packages/cli/README.md b/packages/cli/README.md index 9ca42b757c..8599c4c22a 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -117,6 +117,11 @@ Add a new component renderer to your project. Validate a schema file against the ObjectUI specification. +The document is judged through the strict authoring face: a key no schema +declares is refused, at any depth, and each one is named with the path of the +object carrying it and what to do — remove it, or check its spelling against +the keys declared at that position. + 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 From af8e24552b08f82291d73bd599259298b149068d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:33:11 +0000 Subject: [PATCH 4/6] fix(cli): `objectui check` keeps its objectui#4795 expression refusal on files the strict face refuses (objectui#5250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un-park step 1 of objectui#5250, after merging main: - check: the objectui#4795 judgement of a `${…}` on a closed text key now runs on the registered-type files the strict authoring face refuses, as well as on recognised files. On the merged tree a document carrying one undeclared key was listed and then skipped, so its expression refusal and the exit code it sets disappeared. Both findings are now reported. - New pin: a file with an undeclared key and a refused expression is listed with its key named, refused for the expression, and fails the run. - The objectui#4795 fixture's test name no longer says the validity arm admits it (the strict face refuses its `title`). - registered-types-validate-ratchet-10859: main's report-wrapper case now reads validateAuthoredDocument, the door the PR removed safeValidateSchema from. - validate-strict-authoring-face-5250: the nested probe's issue shape is re-pinned: the strict face reports `unrecognized_keys` at `children → 0`, and findUndeclaredKeys names it there. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- ...ck-unbindable-text-expression-4795.test.ts | 8 ++- ...tered-types-validate-ratchet-10859.test.ts | 2 +- ...alidate-strict-authoring-face-5250.test.ts | 60 ++++++++++++++++--- packages/cli/src/commands/check.ts | 47 +++++++++------ 4 files changed, 89 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts b/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts index 921f4a3638..9e2b86a73f 100644 --- a/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts +++ b/packages/cli/src/__tests__/check-unbindable-text-expression-4795.test.ts @@ -353,8 +353,12 @@ describe('sub-rule (ii): a type no registered component answers to warns, never }); }); -describe('only recognised ObjectUI files are judged', () => { - it('judges a file the validity arm admitted, with no structural key', async () => { +describe('only files that read as ObjectUI content are judged', () => { + it('judges a registered-type file with no structural key, though the strict face refuses it', async () => { + // `statistic` declares no `title`, so the strict authoring face refuses this + // document and `check` lists it as a registered type that did not validate + // (objectui#5250). Its expression is judged all the same: the two refusals + // are separate findings on one file. const document = { type: 'statistic', label: 'Total', value: '1', title: EXPR }; writeFileSync(join(cwd, 'leaf.json'), JSON.stringify(document)); await check(cwd); diff --git a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts index e07118cc56..f9c8eb1b08 100644 --- a/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts +++ b/packages/cli/src/__tests__/registered-types-validate-ratchet-10859.test.ts @@ -491,7 +491,7 @@ describe('registered component types refused at `type` — a ratchet (objectui#1 type: 'report', report: { name: 'opp_by_stage', label: 'Opportunities by Stage', type: 'summary', dataset: 'pipeline', rows: ['stage'], values: ['amount_sum'] }, }; - expect(safeValidateSchema(wrapper).success).toBe(true); + expect(validateAuthoredDocument(wrapper).success).toBe(true); }); it('the refused set is exactly the named remainder — the pin comment cannot drift (objectui#10859 batch 8)', () => { diff --git a/packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts b/packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts index 70de533a91..62b387800b 100644 --- a/packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts +++ b/packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts @@ -24,11 +24,12 @@ * now, and the refusal NAMES the key, its path and what to do. Each refusal * pin asserts the tolerant face still accepts the same document, so a red * here can only mean the strict door moved, never that the fixture broke. - * 2. A nested child's undeclared key is refused too, and named — although the - * issue list carries it only inside the arms of an `Invalid input` at the - * child slot (a slot is `SchemaNode | SchemaNode[]`, an undiscriminated - * union). The per-depth half of the programme (director pointer on - * objectui#5250) is what makes a nested child judged at all. + * 2. A nested child's undeclared key is refused too, and named at the + * child's path. Where a union has more than one arm the document fits (a + * dashboard widget), the refusal sits inside the arms of an `Invalid input` + * that names no key, and it is still named. The per-depth half of the + * programme (director pointer on objectui#5250) is what makes a nested + * child judged at all. * 3. A key one viable union arm declares is NOT named, even when another * viable arm refuses it — the `metric-card` widget case. * 4. Declared-only documents stay green: the positive control for every @@ -36,6 +37,9 @@ * 5. `objectui check` reads the same door: a leaf carrying an undeclared key * leaves the "validated" count, is listed by name, and its undeclared key * is named under it. Still advisory — the exit code does not move. + * 6. The strict refusal does not switch off objectui#4795's: a listed file + * whose `${…}` sits on a text key its node never evaluates is refused for + * that too, and the run fails. * * Harness (fixtures under `os.tmpdir()`, `process.exit` recorded rather than * taken) follows `validate-root-path-line.test.ts`. @@ -50,6 +54,7 @@ import { safeValidateSchema } from '@object-ui/types/zod'; import { check, closingLine, describeFirstIssue } from '../commands/check.js'; import { validate } from '../commands/validate.js'; import { describeUndeclaredKey, validateAuthoredDocument } from '../utils/authoring-face.js'; +import { findUnbindableTextExpressions } from '../utils/unbindable-text-expressions.js'; import { findUndeclaredKeys, type UnionIssueLike } from '../utils/union-arm-diagnostics.js'; /** See `validate-root-path-line.test.ts` — the escape byte is never spelled. */ @@ -93,6 +98,9 @@ const WIDGET_OPTIONS = { widgets: [{ type: 'bar', title: 'Sales', options: { xField: 'month' } }], }; +/** A `${…}` expression, as objectui#4795's pins spell it. */ +const EXPRESSION = '${data.total}'; + /** The #3090 mixed-vocabulary entry: a runtime `name` plus the spec `field`. */ const MIXED_FORM = { type: 'form', fields: [{ name: 'subject', field: 'subject_line', type: 'text' }] }; @@ -196,11 +204,19 @@ describe('objectui validate — an undeclared key is refused by name (objectui#5 it('refuses an undeclared key on a nested child and names it at the child\'s path', async () => { expect(safeValidateSchema(NESTED_PROBE).success).toBe(true); - // The issue list alone does not name it at the top: the child slot is an - // undiscriminated union, so the first issue is an `Invalid input` there. + // The child slot is an undiscriminated union (`SchemaNode | SchemaNode[]`), + // but only one of its arms fits this child, so the parse reports the + // refusal at the child itself, as `unrecognized_keys`, naming the key. + // `findUndeclaredKeys` has to name it there too; the union-nested shape it + // also reads is pinned by the widget cases below. const result = validateAuthoredDocument(NESTED_PROBE); expect(result.success).toBe(false); - expect(result.error?.issues[0]).toMatchObject({ code: 'invalid_union', path: ['children'] }); + expect(result.error?.issues).toMatchObject([ + { code: 'unrecognized_keys', path: ['children', 0], keys: ['nonsense'] }, + ]); + expect(findUndeclaredKeys(result.error!.issues as readonly UnionIssueLike[])).toEqual([ + { path: ['children', 0], key: 'nonsense' }, + ]); await runValidate('nested.json', NESTED_PROBE); @@ -323,4 +339,32 @@ describe('objectui check — the validity arm reads the strict door too (objectu // …so the undeclared-key line is what names it. expect(under.slice(1)).toEqual([expectedLine(WIDGET_OPTIONS, ['widgets', 0, 'options'], 'xField')]); }); + + it('keeps the objectui#4795 expression refusal on a file the strict face refuses: both are reported, and the run fails', async () => { + // `alert` declares `title` and never evaluates it, so the expression is + // objectui#4795's refusal; `nonsense` is the undeclared key, the strict + // face's. Two findings on two different keys of one file. + const document = { type: 'alert', title: EXPRESSION, nonsense: 1 }; + const strict = validateAuthoredDocument(document); + expect(strict.success).toBe(false); + expect(findUndeclaredKeys(strict.error!.issues as readonly UnionIssueLike[])).toEqual([ + { path: [], key: 'nonsense' }, + ]); + expect(findUnbindableTextExpressions(document)).toMatchObject([ + { severity: 'refusal', type: 'alert', key: 'title', path: ['title'] }, + ]); + writeSchema('alert.json', document); + + await check(dir); + + // Listed for the strict refusal, with its undeclared key named under it… + expect(linesUnder('alert.json')).toEqual([ + describeFirstIssue(strict.error!.issues), + expectedLine(document, [], 'nonsense'), + ]); + // …and refused for the expression, which fails the run. + expect(plainLines().filter((l) => l.startsWith('x Unevaluated expression in alert.json '))).toHaveLength(1); + expect(plainLines()).toContain('Found 1 errors'); + expect(exitCodes).toEqual([1]); + }); }); diff --git a/packages/cli/src/commands/check.ts b/packages/cli/src/commands/check.ts index 735d54adb4..da21c3eae3 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -345,6 +345,29 @@ export async function check(cwd: string = process.cwd()) { let validated = 0; let notValidated = 0; + // objectui#4795, ruling item 2: a `${…}` on a closed text key its component + // node never evaluates reaches the user as literal text. Refused on a + // registered type, warned on any other (sub-rule ii). A refusal fails the run. + // + // Judged on every file that reads as ObjectUI content: the files either + // recogniser arm admitted, AND the registered-type files the strict authoring + // face refused (the third bucket below). The second half is objectui#5250's: + // a document carrying one undeclared key is refused by the strict face, and + // ⛔ that refusal must not switch this one off — the two are separate + // findings on the same file, and both are reported. + const judgeTextExpressions = (file: string, document: unknown): void => { + for (const finding of findUnbindableTextExpressions(document)) { + const line = `${file} ${describeUnbindableTextExpression(finding)}`; + if (finding.severity === 'refusal') { + console.log(chalk.red(`x Unevaluated expression in ${line}`)); + console.log(chalk.dim(` ${workingChannels(finding)}`)); + errors++; + } else { + console.log(chalk.yellow(`⚠️ Expression not judged in ${line}`)); + } + } + }; + for (const file of files) { try { // Basic JSON parsing check @@ -406,6 +429,7 @@ export async function check(cwd: string = process.cwd()) { issues: recognition.issues, document: content, }); + judgeTextExpressions(file, content); } else { skipped++; } @@ -431,20 +455,8 @@ export async function check(cwd: string = process.cwd()) { ) ); } - // objectui#4795, ruling item 2: a `${…}` on a closed text key its - // component node never evaluates reaches the user as literal - // text. Refused on a registered type, warned on any other - // (sub-rule ii). Judged on recognised files only, from either arm. - for (const finding of findUnbindableTextExpressions(content)) { - const line = `${file} ${describeUnbindableTextExpression(finding)}`; - if (finding.severity === 'refusal') { - console.log(chalk.red(`x Unevaluated expression in ${line}`)); - console.log(chalk.dim(` ${workingChannels(finding)}`)); - errors++; - } else { - console.log(chalk.yellow(`⚠️ Expression not judged in ${line}`)); - } - } + // objectui#4795 — see `judgeTextExpressions` above. + judgeTextExpressions(file, content); } } } @@ -476,9 +488,10 @@ export async function check(cwd: string = process.cwd()) { } // Every undeclared key the strict face refused, named with its path and // what to do — the same reader and wording `objectui validate` prints - // (objectui#5250). The first issue alone cannot carry them: below a - // child slot it is an `Invalid input` that names no key. Same indent as - // the issue line, so none of these reads as another file. + // (objectui#5250). The first issue alone cannot carry them: it is one + // issue of several, and at a union the object fits more than one arm of + // (a dashboard widget) it is an `Invalid input` that names no key. Same + // indent as the issue line, so none of these reads as another file. for (const finding of findUndeclaredKeys(issues)) { console.log(chalk.yellow(` ${describeUndeclaredKey(finding, document)}`)); } From 101179c624ec073ff78fee7496876b57780784dc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:33:11 +0000 Subject: [PATCH 5/6] docs(cli): correct the strict-wiring text that main made false (objectui#5250) - A nested child's undeclared key is no longer reported only as an `Invalid input` at the child slot: a slot only one union arm fits reports `unrecognized_keys` at the child. The `Invalid input` that names no key is now the shape of a union the node fits more than one arm of, such as a dashboard widget. Corrected in the changeset, the findUndeclaredKeys and describeUndeclaredKey docblocks, the validate and check comments, and the CLI page. - `objectui check` does not exit non-zero only on unreadable JSON: since objectui#4795 a refused expression fails the run, now in a listed file too. Corrected in the changeset, the CLI page and the schema-rendering guide. - The changeset states that it supersedes any entry in the release that calls `safeValidateSchema` what `objectui validate` runs. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .changeset/5250-strict-validate-wiring.md | 14 ++++++++++---- content/docs/guide/schema-rendering.md | 5 +++-- content/docs/utilities/cli.mdx | 9 ++++++--- packages/cli/src/commands/validate.ts | 9 +++++---- packages/cli/src/utils/authoring-face.ts | 3 ++- packages/cli/src/utils/union-arm-diagnostics.ts | 17 +++++++++++------ 6 files changed, 37 insertions(+), 20 deletions(-) diff --git a/.changeset/5250-strict-validate-wiring.md b/.changeset/5250-strict-validate-wiring.md index b464b56e9a..0b3c5bb64e 100644 --- a/.changeset/5250-strict-validate-wiring.md +++ b/.changeset/5250-strict-validate-wiring.md @@ -16,10 +16,16 @@ carrying it, and the fix: Undeclared key "validation" at (root) (type "input"): no schema declares it there. Remove it, or check its spelling against the keys declared at that position. -A nested child's key is named at the child's path even though the issue list reports it only as an -`Invalid input` at the child slot. `objectui check` stays advisory — it still exits non-zero only on -unreadable JSON — but such a file is no longer counted as validated: it is listed by name among the -files that did not validate, with the same line under it. There is no flag to opt out. +A nested child's key is named at the child's path, and so is one inside a union the node fits +more than one arm of (a dashboard widget), where the issue list reports only an `Invalid input` +that names no key. For an undeclared key `objectui check` stays advisory — it does not change the +exit code, which is non-zero on unreadable JSON and on a `${…}` refused on a text key its node never +evaluates (objectui#4795) — but such a file is no longer counted as validated: it is listed by name +among the files that did not validate, with the same line under it. Its `${…}` expressions are still +judged, so a listed file can fail the run for an expression. There is no flag to opt out. + +Where another entry in this release says `objectui validate` or `objectui check` runs +`safeValidateSchema`, the tolerant face, this entry supersedes it: both commands run the strict face. Declared `minor` rather than `major` because this release group follows the `@objectstack` major; the breaking semantics are stated here instead. diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index 27b8b58598..cde1b3975d 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -455,8 +455,9 @@ Validation happens at three doors, all of them outside the render path: key alone, without being parsed against the schema — so an invalid document of that shape is not reported at all. Only a file with none of those keys is parsed; it is listed by name when its root `type` names a registered component but the document does not validate, and that - list is advisory: `check` exits non-zero on unreadable JSON only. See - [`objectui check`](/docs/utilities/cli#objectui-check). + list is advisory: being listed does not fail the run. `check` exits non-zero on unreadable + JSON, and on a `${…}` expression on a text key its node never evaluates, in a listed file as + in a recognised one. See [`objectui check`](/docs/utilities/cli#objectui-check). 3. **Wherever else you need it — `StrictAnyComponentSchema.safeParse`.** Exported from `@object-ui/types/zod`, it is the parse `objectui validate` runs, so a build step, a CI job or a save handler can apply the identical contract. `safeValidateSchema`, from the same module, diff --git a/content/docs/utilities/cli.mdx b/content/docs/utilities/cli.mdx index aec77df263..bf974e1bee 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -179,8 +179,9 @@ list — there is nothing for the suggestions to be near. `objectui validate` parses through the **strict authoring face**: a key that no schema declares is refused, on the root node and on every nested node, where the renderer would silently ignore it. Each refused key is named once more after the -numbered issues — including a nested one, which the issue list otherwise reports -only as an `Invalid input` at the child slot: +numbered issues, with a prescription — including one inside a union the node fits +more than one arm of, such as a dashboard widget, which the issue list reports +only as an `Invalid input` that names no key: ```text Undeclared keys — the strict authoring face refuses them: @@ -233,7 +234,9 @@ distinction matters: `label`, `value` and `description` only where `@objectstack/spec`'s `expressionBindableTextKeysFor(type)` lists that key for the node's type. On any other type, the expression is never resolved: the user sees its literal text, or -nothing. `objectui check` refuses it in every recognised file: +nothing. `objectui check` refuses it in every recognised file, and in every file +it reports by name as a registered type that did not validate — a document +carrying an undeclared key is still judged for its expressions: - **Component nodes only.** The file's root node, every node its `children` hold, and every node under a slot the node's type declares — a dialog's diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index accefa0157..cb1ff520dc 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -194,10 +194,11 @@ export async function validate(schemaPath: string) { }); // Every undeclared key, named with its path and what to do (objectui#5250). - // The issue list above already carries each one — but below a child slot - // only inside an arm's diagnosis, under an `Invalid input` that names no - // key, and nowhere with a prescription. This block is the loud form, and - // it is read off the same issues rather than a second parse. + // The issue list above already carries each one — but at a union the + // object fits more than one arm of (a dashboard widget) only inside an + // arm's diagnosis, under an `Invalid input` that names no key, and + // nowhere with a prescription. This block is the loud form, and it is + // read off the same issues rather than a second parse. const undeclared = findUndeclaredKeys(result.error.issues); if (undeclared.length > 0) { console.error(chalk.bold('\nUndeclared keys — the strict authoring face refuses them:')); diff --git a/packages/cli/src/utils/authoring-face.ts b/packages/cli/src/utils/authoring-face.ts index d43fa8a26e..595578246f 100644 --- a/packages/cli/src/utils/authoring-face.ts +++ b/packages/cli/src/utils/authoring-face.ts @@ -43,7 +43,8 @@ export function validateAuthoredDocument(document: unknown) { * The line that names one undeclared key, where it sits, and what to do. * * A strict refusal read off the issue list alone can be a bare `Invalid input` - * at a child slot, or `Unrecognized key` with no word on what to do about it. + * at a union the object fits more than one arm of (a dashboard widget), or + * `Unrecognized key` with no word on what to do about it. * This is the loud form: the key, the path of the object carrying it (spelled * by `formatIssuePath`, like every other path the CLI prints), that object's * own `type` when it has one, and the prescription. diff --git a/packages/cli/src/utils/union-arm-diagnostics.ts b/packages/cli/src/utils/union-arm-diagnostics.ts index 99dbfcd6ce..6c21e88b6c 100644 --- a/packages/cli/src/utils/union-arm-diagnostics.ts +++ b/packages/cli/src/utils/union-arm-diagnostics.ts @@ -482,12 +482,17 @@ function isViableArm(armIssues: readonly UnionIssueLike[]): boolean { * * The strict face (`StrictAnyComponentSchema`, objectui#8345) reports an * undeclared key as `unrecognized_keys` on the object that carries it. At the - * document root that is a top-level issue. Below a child slot it is NOT: a slot - * is `SchemaNode | SchemaNode[]`, an undiscriminated union, so the refusal sits - * inside an arm's `errors` — twice nested for `children[0]`, once more per - * level — under an `invalid_union` · `Invalid input` that names no key at all. - * So this walks union arms, rebasing each path onto its union's node (header - * fact 3), and reads only the VIABLE arms (see {@link isViableArm}). + * document root that is a top-level issue, and below a child slot that only + * one of its union arms fits it is one too: a `div`'s `children[0]` carrying + * an undeclared key is reported at `children → 0`, naming the key. Where MORE + * than one arm of a union fits the object — a dashboard widget is viable on the + * `metric-card` arm and on the generic widget arm — the union is reported as + * one `invalid_union` · `Invalid input` that names no key at all, and the + * refusal sits inside each arm's `errors`, once more nested per union level. + * Both shapes are pinned by `validate-strict-authoring-face-5250.test.ts`. So + * this reads top-level refusals and walks union arms, rebasing each path onto + * its union's node (header fact 3), reading only the VIABLE arms (see + * {@link isViableArm}). * * When more than one arm is viable, a key counts only when EVERY viable arm * refuses it. A key one viable arm declares is not undeclared — it may belong From 3fa762abec7c128a4127e9b4806de22811f915d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:38:49 +0000 Subject: [PATCH 6/6] docs(types): the strict authoring face has consumers now; re-point the objectui#6318 pin at check's door (objectui#5250) Un-park step 2 of objectui#5250, the statements slice A makes false: - strict-authoring-face.ts and zod/index.zod.ts said nothing in this repository consumes the strict face. `objectui validate` and `objectui check` consume it now. - safe-validate-corpus-6318: the pin is about `objectui check`'s bucket, so it reads the door `check` reads, validateAuthoredDocument. Its seven fixtures are strict-clean, so it stays green. Its counter-probes about the union's declarations stay on safeValidateSchema. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .../test/safe-validate-corpus-6318.test.ts | 20 ++++++++++++++----- packages/types/src/strict-authoring-face.ts | 10 +++++----- packages/types/src/zod/index.zod.ts | 4 ++-- 3 files changed, 22 insertions(+), 12 deletions(-) diff --git a/examples/schema-catalog/test/safe-validate-corpus-6318.test.ts b/examples/schema-catalog/test/safe-validate-corpus-6318.test.ts index 8220be2868..d83e723153 100644 --- a/examples/schema-catalog/test/safe-validate-corpus-6318.test.ts +++ b/examples/schema-catalog/test/safe-validate-corpus-6318.test.ts @@ -15,9 +15,14 @@ * * The bucket is reported by a COUNT and a file list on stdout. Nothing fails * when a file rejoins it: `objectui check` exits 0 whether the list is empty or - * 53 entries long (`packages/cli/src/commands/check.ts` increments `errors` - * only on a parse failure). So a corpus repair that is not pinned is a repair - * that regresses silently — which is how the two shapes below got here. + * 53 entries long (`packages/cli/src/commands/check.ts` does not count a listed + * file as an error). So a corpus repair that is not pinned is a repair that + * regresses silently — which is how the two shapes below got here. + * + * Membership is judged through the door `check` itself reads, + * `validateAuthoredDocument` — the strict authoring face since objectui#5250 — + * so a fixture that gained an undeclared key, and rejoined the bucket for it, + * turns this pin red. * * ## The two shapes are NOT the same repair, and must not be pinned alike * @@ -69,6 +74,8 @@ import { describe, it, expect } from 'vitest'; import { safeValidateSchema } from '@object-ui/types/zod'; +import { validateAuthoredDocument } from '../../../packages/cli/src/utils/authoring-face.js'; + import javascriptEditor from '../src/schemas/plugin-editor/javascript-editor.json' with { type: 'json' }; import pythonEditor from '../src/schemas/plugin-editor/python-editor.json' with { type: 'json' }; import readOnlyJsonViewer from '../src/schemas/plugin-editor/read-only-json-viewer.json' with { type: 'json' }; @@ -77,9 +84,12 @@ import basicSelect from '../src/schemas/components-form-select/basic-select.json import basicTabs from '../src/schemas/components-layout-tabs/basic-tabs.json' with { type: 'json' }; import iconToolbar from '../src/schemas/components-basic-button-group/icon-toolbar.json' with { type: 'json' }; -/** Report the first issue rather than `false`, so a red run says what broke. */ +/** + * Report the issues rather than `false`, so a red run says what broke. Read + * through `check`'s own door, so "out of the bucket" means what `check` means. + */ function reasons(schema: unknown): string[] { - const r = safeValidateSchema(schema); + const r = validateAuthoredDocument(schema); return r.success ? [] : r.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`); } diff --git a/packages/types/src/strict-authoring-face.ts b/packages/types/src/strict-authoring-face.ts index 5b02aeba30..330fc7bba8 100644 --- a/packages/types/src/strict-authoring-face.ts +++ b/packages/types/src/strict-authoring-face.ts @@ -17,11 +17,11 @@ * * It is a SECOND face over the SAME declarations. `BaseSchemaCore` stays * `.passthrough()` and every published mirror keeps the accept set it has: this - * module adds a face, it does not change the existing one. Nothing in this - * repository consumes it yet — wiring `objectui validate`, the JSON-fence gate - * and `objectui check` is the devx half of the ruling and is ruled to come - * after, so the only consumer of these exports today is the pin file that - * measures them. + * module adds a face, it does not change the existing one. `objectui validate` + * and `objectui check` consume it: both judge an authored document through + * `StrictAnyComponentSchema`, by way of `@object-ui/cli`'s + * `validateAuthoredDocument` (objectui#5250, slice A). The JSON-fence gate is + * the ruling's next slice. * * It is DERIVED, never hand-written. A second hand-maintained copy of the 107 * component schemas would be a parity ledger nobody can keep honest — this diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index 885227bf07..e81edf3e09 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -734,8 +734,8 @@ export const SCHEMA_VERSION = '1.0.0'; * run strict; renderer props keep the tolerant face unchanged"). * * ⛔ Nothing here changes the accept set of anything exported above. The - * rendering face keeps its `.passthrough()`; this is a SECOND face, and no - * consumer in this repository is wired to it yet. + * rendering face keeps its `.passthrough()`; this is a SECOND face, and + * `objectui validate` and `objectui check` consume it (objectui#5250). * * ⚠️ The module is `../strict-authoring-face.ts`, OUTSIDE this directory, and * the placement is deliberate. `__tests__/zod-mirror-parity.test.ts` runs a