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