diff --git a/.changeset/5250-strict-validate-wiring.md b/.changeset/5250-strict-validate-wiring.md new file mode 100644 index 0000000000..0b3c5bb64e --- /dev/null +++ b/.changeset/5250-strict-validate-wiring.md @@ -0,0 +1,31 @@ +--- +'@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, 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/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 19c80b43cc..470a391a35 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 d409f99565..82ffdd9912 100644 --- a/content/docs/guide/layout.md +++ b/content/docs/guide/layout.md @@ -223,9 +223,10 @@ This is a list of products, so the page is a `list` page, which draws its `title } ``` -`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 4fa66442f9..cde1b3975d 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -447,18 +447,21 @@ 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 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). -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. + 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, + 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 9303900e4d..bf974e1bee 100644 --- a/content/docs/utilities/cli.mdx +++ b/content/docs/utilities/cli.mdx @@ -174,6 +174,22 @@ 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, 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: + 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 @@ -181,8 +197,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 @@ -197,7 +215,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 @@ -215,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/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/cli/README.md b/packages/cli/README.md index 932c5d65b1..3d281de6da 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -95,6 +95,11 @@ Generate new resources (`resource`/`object`, `page`, `plugin`). 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 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 4a3e28afa8..8e6f2de75e 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'; @@ -96,9 +97,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; } @@ -218,7 +222,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); @@ -268,8 +272,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-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__/check-validity-recogniser.test.ts b/packages/cli/src/__tests__/check-validity-recogniser.test.ts index a93053c577..3581475e1f 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'; @@ -249,9 +249,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: 'plugin-dashboard:metric-card', title: 'Total Revenue', value: '$123,456' }).success, - '`plugin-dashboard:metric-card` is now modelled by AnyComponentSchema — this fixture needs a type that still is not; see the comment above', + validateAuthoredDocument({ type: 'plugin-dashboard:metric-card', title: 'Total Revenue', value: '$123,456' }).success, + '`plugin-dashboard: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 e597833d5f..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 @@ -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'; @@ -298,7 +299,7 @@ const STILL_REFUSED_10859: readonly string[] = []; /** 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', @@ -334,7 +335,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); @@ -490,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 new file mode 100644 index 0000000000..62b387800b --- /dev/null +++ b/packages/cli/src/__tests__/validate-strict-authoring-face-5250.test.ts @@ -0,0 +1,370 @@ +/** + * 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 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 + * 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. + * 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`. + */ + +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 { 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. */ +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' } }], +}; + +/** 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' }] }; + +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 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).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); + + 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')]); + }); + + 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 0073f66428..da21c3eae3 100644 --- a/packages/cli/src/commands/check.ts +++ b/packages/cli/src/commands/check.ts @@ -11,8 +11,8 @@ 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'; @@ -21,6 +21,7 @@ import { findUnbindableTextExpressions, workingChannels, } from '../utils/unbindable-text-expressions.js'; +import { findUndeclaredKeys } from '../utils/union-arm-diagnostics.js'; /** * Root keys that positively identify a file as an ObjectUI schema node. @@ -106,8 +107,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 = @@ -138,7 +142,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 @@ -173,7 +180,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 }; @@ -331,12 +338,36 @@ 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). 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 @@ -396,7 +427,9 @@ export async function check(cwd: string = process.cwd()) { file, type: content.type, issues: recognition.issues, + document: content, }); + judgeTextExpressions(file, content); } else { skipped++; } @@ -422,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); } } } @@ -457,7 +478,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). @@ -465,6 +486,15 @@ 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: 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)}`)); + } } 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 001c84f048..cb1ff520dc 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')); @@ -139,7 +141,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)}`)); @@ -189,6 +193,32 @@ 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 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:')); + 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..595578246f --- /dev/null +++ b/packages/cli/src/utils/authoring-face.ts @@ -0,0 +1,65 @@ +/** + * 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 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. + * + * 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..6c21e88b6c 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,103 @@ 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, 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 + * 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; + }); +} 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