Skip to content
31 changes: 31 additions & 0 deletions .changeset/5250-strict-validate-wiring.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 5 additions & 4 deletions content/docs/components/basic/text.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<SchemaExample id="components-basic-text/text-with-colors" />

Expand Down
7 changes: 4 additions & 3 deletions content/docs/guide/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
7 changes: 4 additions & 3 deletions content/docs/guide/layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
15 changes: 9 additions & 6 deletions content/docs/guide/schema-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 25 additions & 4 deletions content/docs/utilities/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,33 @@ 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
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
Expand All @@ -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 <file>` 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
Expand All @@ -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
Expand Down
20 changes: 15 additions & 5 deletions examples/schema-catalog/test/safe-validate-corpus-6318.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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' };
Expand All @@ -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}`);
}

Expand Down
5 changes: 5 additions & 0 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 11 additions & 7 deletions packages/cli/src/__tests__/check-first-issue-11007.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand All @@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
8 changes: 5 additions & 3 deletions packages/cli/src/__tests__/check-validity-recogniser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading