From 252157173f8cbae866aa22c1a0ff65c0128a6107 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:58:18 +0000 Subject: [PATCH 1/8] docs: repair the JSON fences the strict authoring face refuses (objectui#5250) Every json/jsonc node document under content/docs that the strict authoring face (StrictAnyComponentSchema, the face `objectui validate` judges with) refused is either repaired or declared: - a key nothing reads is removed, or replaced by the declared key its renderer reads (button `action` -> an `action:button` node with its `properties` bag; `empty.message` -> `title`; `card.dataSource` -> a data-bound block; stat-card `value`/`change` -> `children`/`description`); - an off-contract value is corrected (detail field type, button size, grid gap), and teaching shapes no renderer implements are rewritten to the declared ones (form `fields`, select option objects, form `defaultValues`, `data-table` instead of the layout `grid`); - sections that taught capabilities that do not exist are rewritten from the producer (the VS Code extension's shipped `oui-*` snippets and its real validation) or removed (the list item template, an analytics action); - deliberate fragments carry a `doc-json: fragment` declaration. Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- content/docs/api/schema-reference.md | 2 +- content/docs/blocks/authentication.mdx | 14 ++- content/docs/blocks/dashboard.mdx | 26 ++-- content/docs/blocks/ecommerce.mdx | 25 ++-- content/docs/blocks/forms.mdx | 14 ++- content/docs/blocks/marketing.mdx | 13 -- content/docs/components/basic/icon.mdx | 1 + content/docs/components/index.md | 2 +- content/docs/guide/dashboard-filters.md | 1 + content/docs/guide/expressions.md | 61 +++++----- content/docs/guide/schema-playground.md | 44 +++---- content/docs/guide/schema-rendering.md | 2 +- content/docs/utilities/runner.mdx | 15 ++- content/docs/utilities/vscode-extension.mdx | 126 ++++++++------------ 14 files changed, 160 insertions(+), 186 deletions(-) diff --git a/content/docs/api/schema-reference.md b/content/docs/api/schema-reference.md index 45acfedf1f..2e5287dfef 100644 --- a/content/docs/api/schema-reference.md +++ b/content/docs/api/schema-reference.md @@ -710,7 +710,7 @@ A single-record detail view with grouped fields, actions, and tabs. "title": "Customer Info", "fields": [ { "name": "customer", "label": "Customer", "type": "text" }, - { "name": "email", "label": "Email", "type": "email" }, + { "name": "email", "label": "Email", "type": "text" }, { "name": "created", "label": "Created", "type": "date", "format": "MMM d, yyyy" } ] }, diff --git a/content/docs/blocks/authentication.mdx b/content/docs/blocks/authentication.mdx index 6aa5e59b74..cd30b8d923 100644 --- a/content/docs/blocks/authentication.mdx +++ b/content/docs/blocks/authentication.mdx @@ -54,13 +54,17 @@ behaviour. Common customisations: ### Wire Up Submission +A control that runs something is its own node type, `action:button`, and its +props go in the `properties` bag; `actionType: "api"` sends the request to +`target`: + ```json { - "type": "button", - "label": "Sign In", - "action": { - "type": "submit", - "endpoint": "/api/auth/login", + "type": "action:button", + "properties": { + "label": "Sign In", + "actionType": "api", + "target": "/api/auth/login", "method": "POST" } } diff --git a/content/docs/blocks/dashboard.mdx b/content/docs/blocks/dashboard.mdx index 83f90f8936..791c6845c1 100644 --- a/content/docs/blocks/dashboard.mdx +++ b/content/docs/blocks/dashboard.mdx @@ -33,13 +33,16 @@ Customize dashboard blocks for your needs: ### Add Real Data +A `card` fetches nothing. A live figure comes from a data-bound block: an +`element:number` takes its object from the per-element `dataSource` binding and +its aggregate from its `properties` bag (see the +[Data Source guide](/docs/guide/data-source)): + ```json { - "type": "card", - "dataSource": { - "api": "/api/stats/revenue", - "method": "GET" - } + "type": "element:number", + "dataSource": { "object": "order" }, + "properties": { "aggregate": "count" } } ``` @@ -55,13 +58,16 @@ Customize dashboard blocks for your needs: ### Add Actions +A control that runs something is its own node type, `action:button`, and its +props go in the `properties` bag; `actionType: "url"` navigates to `target`: + ```json { - "type": "button", - "label": "View Details", - "action": { - "type": "navigate", - "path": "/dashboard/details" + "type": "action:button", + "properties": { + "label": "View Details", + "actionType": "url", + "target": "/dashboard/details" } } ``` diff --git a/content/docs/blocks/ecommerce.mdx b/content/docs/blocks/ecommerce.mdx index cd0307ef02..b65f059956 100644 --- a/content/docs/blocks/ecommerce.mdx +++ b/content/docs/blocks/ecommerce.mdx @@ -39,25 +39,30 @@ Customize e-commerce blocks for your store: ### Add Product Data +A `card` fetches nothing. Records come from a data-bound block that names its +object in the per-element `dataSource` binding (see the +[Data Source guide](/docs/guide/data-source)): + ```json { - "type": "card", - "dataSource": { - "api": "/api/products", - "method": "GET" - } + "type": "object-grid", + "dataSource": { "object": "product", "limit": 20 } } ``` ### Add to Cart Action +A control that runs something is its own node type, `action:button`, and its +props go in the `properties` bag; `actionType: "api"` sends the request to +`target`: + ```json { - "type": "button", - "label": "Add to Cart", - "action": { - "type": "submit", - "endpoint": "/api/cart", + "type": "action:button", + "properties": { + "label": "Add to Cart", + "actionType": "api", + "target": "/api/cart", "method": "POST" } } diff --git a/content/docs/blocks/forms.mdx b/content/docs/blocks/forms.mdx index da9be706f2..d82a1323d0 100644 --- a/content/docs/blocks/forms.mdx +++ b/content/docs/blocks/forms.mdx @@ -93,13 +93,17 @@ or in a TypeScript-authored schema pass the real thing — ### Add Submit Action +A control that runs something is its own node type, `action:button`, and its +props go in the `properties` bag; `actionType: "api"` sends the request to +`target`: + ```json { - "type": "button", - "label": "Submit", - "action": { - "type": "submit", - "endpoint": "/api/contact", + "type": "action:button", + "properties": { + "label": "Submit", + "actionType": "api", + "target": "/api/contact", "method": "POST" } } diff --git a/content/docs/blocks/marketing.mdx b/content/docs/blocks/marketing.mdx index 903614f386..cf7344e55a 100644 --- a/content/docs/blocks/marketing.mdx +++ b/content/docs/blocks/marketing.mdx @@ -46,19 +46,6 @@ Customize marketing blocks for your campaigns: } ``` -### Add Analytics - -```json -{ - "type": "button", - "label": "Get Started", - "action": { - "type": "analytics", - "event": "cta_clicked" - } -} -``` - ### Customize Content ```json diff --git a/content/docs/components/basic/icon.mdx b/content/docs/components/basic/icon.mdx index 74564fa180..918956ea65 100644 --- a/content/docs/components/basic/icon.mdx +++ b/content/docs/components/basic/icon.mdx @@ -34,6 +34,7 @@ The glyph is named by `icon`: `name` is the SDUI identity key every node carries, alongside `id`. It is **not** read as a glyph name on `ui:icon`, and writing one there renders no icon: +{/* doc-json: fragment — a deliberately refused node: it shows the retired name-as-glyph shape the paragraph below describes */} ```json { "type": "icon", "id": "save_icon", "name": "save_icon" } ``` diff --git a/content/docs/components/index.md b/content/docs/components/index.md index c3e1633a2f..650ffad5fc 100644 --- a/content/docs/components/index.md +++ b/content/docs/components/index.md @@ -128,7 +128,7 @@ All ObjectUI components follow the same schema-based pattern: "type": "button", "label": "Click Me", "variant": "default", - "size": "md", + "size": "default", "className": "mt-4" } ``` diff --git a/content/docs/guide/dashboard-filters.md b/content/docs/guide/dashboard-filters.md index 047492bcea..fa75cd8b38 100644 --- a/content/docs/guide/dashboard-filters.md +++ b/content/docs/guide/dashboard-filters.md @@ -202,6 +202,7 @@ it refuses the same documents before they reach the platform. Options can also be fetched from an object at runtime: + ```json { "name": "industry", diff --git a/content/docs/guide/expressions.md b/content/docs/guide/expressions.md index c967171bb1..7b4752cb1f 100644 --- a/content/docs/guide/expressions.md +++ b/content/docs/guide/expressions.md @@ -216,34 +216,16 @@ The scope the evaluator builds is what you published, plus two names the rendere > is `record` (ADR-0089 D3) — rewrite such a gate to `record.*` rather than re-publishing a > `data` key. -### Scoped Data +### Lists -Some components provide scoped data: - -```jsonc -{ - "type": "list", - "items": "${users}", - "itemTemplate": { - "type": "card", - "title": "${item.name}", // 'item' is scoped data - "description": "${item.email}" - } -} -``` - -### Index in Loops - -Access the current index in loops: +`list` has no item template, and it provides no `item` or `index` scope for an expression to +read. It draws its `items` array — or the array its `bind` path resolves to — one row per entry: +a string, or an object whose `content` is a node. ```json { "type": "list", - "items": "${users}", - "itemTemplate": { - "type": "text", - "content": "#${index + 1}: ${item.name}" - } + "bind": "users" } ``` @@ -406,7 +388,7 @@ Two neighbouring traps: `text` is not a `badge` key at all — the badge's text { "type": "empty", "visibleOn": "${items.length === 0}", - "message": "No items to display", + "title": "No items to display", "description": "Start by adding your first item" } ``` @@ -453,28 +435,42 @@ and a fail-soft surface answers it by showing the field on every row. ### Dependent Fields +A form's fields are its `fields` array, and each field's `visibleOn` is evaluated against that +row: + ```json { "type": "form", - "children": [ + "fields": [ { - "type": "select", "name": "country", + "type": "select", "label": "Country", - "options": ["USA", "Canada", "Mexico"] + "options": [ + { "label": "USA", "value": "USA" }, + { "label": "Canada", "value": "Canada" }, + { "label": "Mexico", "value": "Mexico" } + ] }, { - "type": "select", "name": "state", + "type": "select", "label": "State/Province", "visibleOn": "${record.country === 'USA'}", - "options": ["CA", "NY", "TX"] + "options": [ + { "label": "CA", "value": "CA" }, + { "label": "NY", "value": "NY" }, + { "label": "TX", "value": "TX" } + ] } ] } ``` -### Dynamic Validation +### Validation + +An `input` declares no `validations` bag, and nothing reads one. `inputType: "email"` renders an +email input, which the browser validates, and `required` marks it required: ```json { @@ -482,10 +478,7 @@ and a fail-soft surface answers it by showing the field on every row. "name": "email", "label": "Email", "required": true, - "validations": { - "isEmail": true, - "errorMessage": "Please enter a valid email" - } + "inputType": "email" } ``` diff --git a/content/docs/guide/schema-playground.md b/content/docs/guide/schema-playground.md index 905d22923e..9e2490938c 100644 --- a/content/docs/guide/schema-playground.md +++ b/content/docs/guide/schema-playground.md @@ -100,7 +100,6 @@ A content card with a header, a description, and a `children` content list: "type": "card", "title": "Monthly Revenue", "description": "Revenue summary for the current month", - "icon": "dollar-sign", "children": { "type": "stack", "direction": "col", @@ -125,10 +124,12 @@ A content card with a header, a description, and a `children` content list: A complete form with validation, driven entirely by schema: +A form draws no title of its own (put it in a `card` around the form), a select's `options` are +`{ "label", "value" }` objects, and a field's starting value goes in the form's `defaultValues`: + ```json { "type": "form", - "title": "Create Account", "fields": [ { "name": "fullName", @@ -149,8 +150,11 @@ A complete form with validation, driven entirely by schema: "name": "role", "type": "select", "label": "Role", - "options": ["Admin", "Editor", "Viewer"], - "defaultValue": "Viewer" + "options": [ + { "label": "Admin", "value": "Admin" }, + { "label": "Editor", "value": "Editor" }, + { "label": "Viewer", "value": "Viewer" } + ] }, { "name": "bio", @@ -159,6 +163,7 @@ A complete form with validation, driven entirely by schema: "placeholder": "Tell us about yourself..." } ], + "defaultValues": { "role": "Viewer" }, "actions": [ { "type": "submit", "label": "Create Account", "variant": "default" }, { "type": "reset", "label": "Reset", "variant": "outline" } @@ -166,33 +171,28 @@ A complete form with validation, driven entirely by schema: } ``` -### Grid +### Data Table -A data grid with sortable columns and row actions: +A table over inline rows, with sortable columns and pagination. `grid` is the layout grid (a +column count and a gap), not a data table: ```json { - "type": "grid", - "title": "Team Members", + "type": "data-table", + "caption": "Team Members", "columns": [ - { "field": "name", "label": "Name", "sortable": true }, - { "field": "role", "label": "Role", "sortable": true }, - { "field": "status", "label": "Status", "sortable": true }, - { "field": "joined", "label": "Joined", "type": "date" } + { "header": "Name", "accessorKey": "name", "sortable": true }, + { "header": "Role", "accessorKey": "role", "sortable": true }, + { "header": "Status", "accessorKey": "status", "sortable": true }, + { "header": "Joined", "accessorKey": "joined" } ], - "rows": [ + "data": [ { "name": "Alice Chen", "role": "Engineer", "status": "Active", "joined": "2024-01-15" }, { "name": "Bob Smith", "role": "Designer", "status": "Active", "joined": "2024-03-22" }, { "name": "Carol Jones", "role": "PM", "status": "Away", "joined": "2023-11-08" } ], - "actions": [ - { "label": "Edit", "icon": "pencil", "action": "edit" }, - { "label": "Delete", "icon": "trash", "action": "delete", "variant": "destructive" } - ], - "pagination": { - "pageSize": 10, - "showPageSizeOptions": true - } + "pagination": true, + "pageSize": 10 } ``` @@ -208,7 +208,7 @@ Schemas can be nested to build complex layouts. Here is a dashboard that combine "children": { "type": "grid", "columns": 3, - "gap": "md", + "gap": 4, "children": [ { "type": "card", diff --git a/content/docs/guide/schema-rendering.md b/content/docs/guide/schema-rendering.md index cde1b3975d..fefaf83080 100644 --- a/content/docs/guide/schema-rendering.md +++ b/content/docs/guide/schema-rendering.md @@ -608,7 +608,7 @@ Always type your schemas for better IDE support and fewer runtime errors. { "type": "empty", "visibleOn": "${items.length === 0}", - "message": "No items found", + "title": "No items found", "action": { "type": "button", "label": "Create New" diff --git a/content/docs/utilities/runner.mdx b/content/docs/utilities/runner.mdx index bd8c682a14..347f0811aa 100644 --- a/content/docs/utilities/runner.mdx +++ b/content/docs/utilities/runner.mdx @@ -267,6 +267,7 @@ import '@object-ui/plugin-myplugin' …then give it a page to render, as `src/app-data/pages/index.json` — the page served at `/`: +{/* doc-json: fragment — its child is `my-component`, the type the reader's own plugin registers, so no node schema here declares it */} ```json { "type": "page", @@ -291,6 +292,7 @@ Experiment with complex schemas: Test how multiple plugins work together: +{/* doc-json: fragment — a type-only skeleton: each child names a plugin node type, with the data props that type requires left for the reader to fill in */} ```json { "type": "div", @@ -338,20 +340,20 @@ serve it from your own backend and load it with `?api=`. { "type": "card", "title": "Revenue", - "value": "$125,000", - "change": "+12.5%" + "description": "+12.5%", + "children": { "type": "text", "content": "$125,000", "className": "text-2xl font-bold" } }, { "type": "card", "title": "Orders", - "value": "1,234", - "change": "+8.2%" + "description": "+8.2%", + "children": { "type": "text", "content": "1,234", "className": "text-2xl font-bold" } }, { "type": "card", "title": "Customers", - "value": "567", - "change": "+5.1%" + "description": "+5.1%", + "children": { "type": "text", "content": "567", "className": "text-2xl font-bold" } } ] }, @@ -495,6 +497,7 @@ ComponentRegistry.register('my-component', MyComponent) Add a page document to `src/app-data/pages/`. The file name is the route: this one is served at `/my-page`. +{/* doc-json: fragment — its innermost child is `my-component`, the type the reader registers in the step above, so no node schema here declares it */} ```jsonc // src/app-data/pages/my-page.json { diff --git a/content/docs/utilities/vscode-extension.mdx b/content/docs/utilities/vscode-extension.mdx index 543ca458e0..e5f7a53ef2 100644 --- a/content/docs/utilities/vscode-extension.mdx +++ b/content/docs/utilities/vscode-extension.mdx @@ -188,101 +188,71 @@ Hover over properties to see: ### Validation -Real-time validation with error messages: +Real-time validation flags a value the extension knows to be out of range — an `input`'s +`inputType` or a `button`'s `variant` outside its set — and suggests a `label` on `input`, +`textarea` and `select` nodes: +{/* doc-json: fragment — a deliberately refused node: it shows the out-of-range `variant` the extension warns about */} ```jsonc { "type": "button", - "invalidProp": true // ❌ Error: Unknown property + "label": "Save", + "variant": "primary" // ⚠️ Invalid variant "primary". Must be one of: default, destructive, outline, secondary, ghost, link } ``` -## Snippets - -Type these prefixes and press Tab to insert templates: - -### `objectui-button` +It does not flag a key no schema declares. `objectui validate` does: it judges a document through +the strict authoring face and refuses an undeclared key by name (see the [CLI](/docs/utilities/cli)). -```json -{ - "type": "button", - "variant": "default", - "children": "Click me" -} -``` +## Snippets -### `objectui-form` +Type `oui-` and press Tab to insert one of the templates the extension ships, defined in +`packages/vscode-extension/snippets/objectui.json`: + +| Prefix | Inserts | +|--------|---------| +| `oui-empty` | a node with a `className` and an empty `children` slot | +| `oui-form` | a card holding a labelled `input` and a `button` | +| `oui-card` | a `card` with a `title`, a `description` and a `children` slot | +| `oui-input` | an `input` with a `label`, a `placeholder`, an `inputType` and `required` | +| `oui-textarea` | a `textarea` with a `label`, a `placeholder` and `rows` | +| `oui-button` | a `button` with a `label` and a `variant` | +| `oui-text` | a `text` node with `content` | +| `oui-grid` | a responsive grid `div` | +| `oui-dashboard` | a dashboard `div` with a heading and a metric card | +| `oui-separator` | a `separator` | +| `oui-container` | a centred container `div` | +| `oui-flex` | a flex `div` | + +`oui-form`, with its placeholders left at their defaults, inserts: ```json { - "type": "form", - "fields": [ - { - "name": "fieldName", - "type": "text", - "label": "Label", - "required": true + "type": "div", + "className": "max-w-2xl mx-auto p-6", + "children": { + "type": "card", + "title": "Form Title", + "children": { + "type": "div", + "className": "space-y-4", + "children": [ + { + "type": "input", + "label": "Name", + "placeholder": "Enter your name", + "required": true + }, + { + "type": "button", + "label": "Submit" + } + ] } - ], - "onSubmit": { - "type": "ajax", - "url": "/api/endpoint", - "method": "POST" } } ``` -### `objectui-grid` - -```json -{ - "type": "data-table", - "dataSource": { - "type": "api", - "url": "/api/data" - }, - "columns": [ - { - "key": "id", - "title": "ID" - } - ] -} -``` - -### `objectui-app` - -```json -{ - "type": "app", - "title": "My App", - "pages": [ - { - "path": "/", - "title": "Home", - "component": { - "type": "div", - "children": [] - } - } - ] -} -``` - -### `objectui-chart` - -```json -{ - "type": "bar-chart", - "data": [ - { "name": "A", "value": 100 } - ], - "dataKey": "value", - "xAxisKey": "name", - "height": 300 -} -``` - ## Configuration Configure the extension via Settings (Ctrl+, / Cmd+,): From c6bebe5a0d6222b495b41a5afdaf828f9d09b611 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 06:58:18 +0000 Subject: [PATCH 2/8] feat(scripts): judge JSON doc fences through the strict authoring face (objectui#5250) The JSON-fence gate the objectui#5250 ruling orders "on top of" the strict face. `scripts/check-doc-json-fences.mjs` judges every json/jsonc node document under content/docs with StrictAnyComponentSchema, read from the built @object-ui/types the CLI imports, so a document it accepts is one `objectui validate` accepts. Non-documents are counted per bucket, deliberate fragments are declared with a `doc-json:` marker the gate re-judges, and RESOLUTION / REFUSES / ACCEPTS / anti-idle controls exit 2 before any verdict when the harness cannot be trusted. Wired as the last step of doc-snippet-types.yml, after the build that produces @object-ui/types; `pnpm check:doc-json`; documented in the CI pipeline guide. The ts/tsx gate exports its blockquote helpers and reason floor so both gates read quoted fences by one rule. Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- .github/workflows/doc-snippet-types.yml | 12 + content/docs/guide/ci-cd-pipeline.md | 42 +- package.json | 1 + .../__tests__/check-doc-json-fences.test.ts | 335 +++++++++ scripts/check-doc-json-fences.mjs | 634 ++++++++++++++++++ scripts/check-doc-snippet-types.mjs | 15 +- 6 files changed, 1034 insertions(+), 5 deletions(-) create mode 100644 scripts/__tests__/check-doc-json-fences.test.ts create mode 100644 scripts/check-doc-json-fences.mjs diff --git a/.github/workflows/doc-snippet-types.yml b/.github/workflows/doc-snippet-types.yml index 4725cc4744..57e9a00710 100644 --- a/.github/workflows/doc-snippet-types.yml +++ b/.github/workflows/doc-snippet-types.yml @@ -185,3 +185,15 @@ jobs: # narrowing what the gate scans. - name: Compile JSDoc `@example` blocks against the built types run: node scripts/check-doc-example-types.mjs + + # objectui#5250 (the maintainer ruling's JSON-fence slice). Every `json` / + # `jsonc` node document in `content/docs` is judged through the STRICT + # authoring face — `StrictAnyComponentSchema`, read from the + # `@object-ui/types` the build step above produced (the snippet gate's + # closure carries it) — the face `objectui validate` judges with. A fence + # that is deliberately not a complete node document is declared on the + # line above it, and the declaration is re-judged on every run. Last of + # the three, for the reason the step above gives: a newly wired blocking + # gate must not mask an established one. + - name: Judge JSON doc fences through the strict authoring face + run: node scripts/check-doc-json-fences.mjs diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md index 3db7e70d7b..c96a055c12 100644 --- a/content/docs/guide/ci-cd-pipeline.md +++ b/content/docs/guide/ci-cd-pipeline.md @@ -34,7 +34,7 @@ one has its own section below. | `skill-examples.yml` | Skill Example Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a MARKED fenced example in a `skills/` or `.claude/skills/` guide no longer compiles against the packages' built types, no longer parses as JSON, uses a bare `any`, or carries a marker that opts nothing in | | `skill-eval-tokens.yml` | Skill Eval Token Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when an eval assertion's `must_contain` token is not taught as a whole token anywhere in its own `skills/` bundle | | `doc-component-types.yml` | Doc Component Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `content/docs/**.mdx` snippet teaches a `type` nothing registers | -| `doc-snippet-types.yml` | Doc Snippet Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a covered documentation snippet no longer compiles against the packages' built types, **or** when a covered JSDoc `@example` block does (two gates, one job — see below) | +| `doc-snippet-types.yml` | Doc Snippet Type Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a covered documentation snippet no longer compiles against the packages' built types, **or** when a covered JSDoc `@example` block does, **or** when a `json` / `jsonc` node document in `content/docs` is refused by the strict authoring face (three gates, one job — see below) | | `doc-fence-languages.yml` | Doc Fence Language Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a TypeScript block sits under a fence the snippet gate does not read | | `doc-example-ids.yml` | Doc Example Id Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a `content/docs/**` page references a schema-catalog example id the registry does not carry, or when the gate's own population collapses | | `pre-install-import-graph.yml` | Pre-Install Import Graph Check | Push / PR to `main`, `develop` — **no path filter**; merge-queue builds; manual | **Yes** — when a gate a workflow runs *before* `pnpm install` reaches a package anywhere in its import graph | @@ -1159,7 +1159,7 @@ names: `pnpm exec turbo run build $(node scripts/check-doc-snippet-types.mjs --b ### JSDoc `@example` Types — the second gate in this job (`doc-snippet-types.yml`) -The same job's **last** step runs `scripts/check-doc-example-types.mjs` +The same job's second blocking step runs `scripts/check-doc-example-types.mjs` (`pnpm check:doc-examples`). It asks the snippet gate's question of a different corpus: every fenced `ts` / `tsx` block inside a JSDoc `@example` on an **exported** declaration under `packages/NAME/src/**` must compile `--strict` against the packages' built `dist/*.d.ts`, or be @@ -1174,6 +1174,7 @@ copying it out of an IDE hover got TS2322, and nothing in this repository had ev | `pnpm check:doc-snippets` (`scripts/check-doc-snippet-types.mjs`) | fenced `ts` / `tsx` blocks in the **documents** the `UNGATED_DOCS` ledger does not exempt | **Yes** | | `pnpm check:doc-snippets --emit-census` | code this repository's generators emit from **template literals** | No — report-only | | `pnpm check:doc-examples` (`scripts/check-doc-example-types.mjs`) | fenced `ts` / `tsx` blocks in JSDoc **`@example`** tags on exported declarations under `packages/NAME/src/**` | **Yes** | +| `pnpm check:doc-json` (`scripts/check-doc-json-fences.mjs`) | fenced `json` / `jsonc` **node documents** in `content/docs`, judged through the strict authoring face | **Yes** | **Why it shares this job rather than getting a workflow.** It is a *sibling* of the gate above, not a fork: it imports that script's compiler host, its built-`.d.ts` resolution and every one of its @@ -1211,6 +1212,43 @@ example, or declare it. Run it locally exactly as this job does: build first (`pnpm exec turbo run build $(node scripts/check-doc-snippet-types.mjs --build-filter)`), then `pnpm check:doc-examples`; the gate prints that same recipe when it exits `2`. +### JSON Node Documents — the third gate in this job (`doc-snippet-types.yml`) + +The job's **last** step runs `scripts/check-doc-json-fences.mjs` (`pnpm check:doc-json`). The two +gates above read `ts` / `tsx` only, so a `json` fence was checked by nothing: +[#5229](https://github.com/objectstack-ai/objectui/issues/5229) taught a `validation` bag on an +`input` node in `content/docs/blocks/forms.mdx` while every shipped tool was green. Every fenced +`json` / `jsonc` block under `content/docs` that parses into an object whose `type` names a node +schema is judged through the **strict authoring face** — `StrictAnyComponentSchema`, read from the +`@object-ui/types` the build step above produced, the face `objectui validate` and `objectui check` +judge an authored document with ([#5250](https://github.com/objectstack-ai/objectui/issues/5250)). +So a key no schema declares fails here exactly as it fails `objectui validate`; the tolerant face +keeps it, because `BaseSchemaCore` is `.passthrough()` for the renderer. + +**What it does not judge, it counts.** A fence that is not JSON, not an object, carries no string +`type`, or names a `type` no node schema declares is not judged; the summary prints each bucket, and +`--list` prints every fence with its bucket. Pages outside `content/docs` are not read, and the +summary says so. + +**Fragments are declared, and the declaration is re-judged.** A node document that is deliberately +not complete or not valid — a refused example that shows the error, an excerpt that elides required +keys, an object whose `type` only collides with a node type — carries +`{/* doc-json: fragment - why */}` (the HTML-comment form in `.md`) on the line above its fence. The +declared fence must be one the strict face refuses: a declaration on a fence the face accepts, or on +one that is not a node document at all, is reported as stale, so the declarations can only shrink. + +**It proves itself before it judges the docs.** The face must resolve inside `packages/types/dist/`; +a document carrying an undeclared key must be refused by the strict face and accepted by the +tolerant one; a declared-only document must be accepted; and the walk must judge at least one node +document, including one from its anchor page. Its exit codes are the sibling gates' three: `0` +clean, `1` the gate ran and found errors, `2` it **could not run** — `@object-ui/types` unbuilt, or +a control or the enumeration failed — which is neither green nor red. + +**If it fails:** each refusal names the page, the line of its fence, and each reason — an undeclared +key by name and path, anything else by the face's own message. Remove the key, or replace it with +the declared key its renderer reads; declare a deliberate fragment. Run it locally with +`pnpm --filter @object-ui/types build`, then `pnpm check:doc-json`. + ## Fence Languages (`doc-fence-languages.yml`) **Triggers:** Push and PR to `main`/`develop`, merge-queue builds, plus manual dispatch — **no path diff --git a/package.json b/package.json index 08120eb45a..407dbca74a 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "check:prompt-keys": "node scripts/check-prompt-component-keys.mjs", "check:doc-snippets": "node scripts/check-doc-snippet-types.mjs", "check:doc-examples": "node scripts/check-doc-example-types.mjs", + "check:doc-json": "node scripts/check-doc-json-fences.mjs", "check:doc-fences": "node scripts/check-doc-fence-languages.mjs", "check:doc-example-ids": "node scripts/check-doc-example-ids.mjs", "check:eager-closure": "node scripts/check-eager-closure-budget.mjs", diff --git a/scripts/__tests__/check-doc-json-fences.test.ts b/scripts/__tests__/check-doc-json-fences.test.ts new file mode 100644 index 0000000000..27f61cda92 --- /dev/null +++ b/scripts/__tests__/check-doc-json-fences.test.ts @@ -0,0 +1,335 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS CI helpers; their types are inferred from the .mjs source by +// `tsconfig.scripts.json` (`allowJs`). +import { + ACCEPTED_CONTROL, + ANCHOR_DOCUMENT, + EXIT_CODES, + FRAGMENT_MARKER, + FRAGMENT_MARKER_EXAMPLES, + REFUSED_CONTROL, + analyze, + classifyFence, + controlFailures, + describeIssue, + listDocuments, + main, + nodeTypesOf, + readableIssues, + scanJsonFences, +} from '../check-doc-json-fences.mjs'; +import { + FRAGMENT_MARKER as SNIPPET_FRAGMENT_MARKER, + FRAGMENT_MARKER_EXAMPLES as SNIPPET_FRAGMENT_MARKER_EXAMPLES, +} from '../check-doc-snippet-types.mjs'; + +/** + * objectui#5250 — the JSON-fence gate the maintainer ruling orders "on top of" + * the strict authoring face. + * + * The face and the CLI's own door are imported DYNAMICALLY, through specifiers + * held in variables, on purpose. `tsconfig.scripts.json`'s program imports no + * `@object-ui/*` package — `scripts-type-check.test.ts` pins that premise, and it + * is why `pnpm type-check:scripts` can run before the workspace build — and a + * static import of either would pull a package's built declarations into that + * program. Vitest resolves both at run time through the root config's aliases, + * so here the face is the SOURCE face; the gate itself reads the BUILT one, and + * the agreement test below is what ties the two verdicts to one door. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +interface Verdict { + success: boolean; + error?: { issues: unknown[] }; +} +interface FaceModule { + StrictAnyComponentSchema: { safeParse(doc: unknown): Verdict }; + AnyComponentSchema: { safeParse(doc: unknown): Verdict }; +} +interface DoorModule { + validateAuthoredDocument(doc: unknown): Verdict; +} + +const FACE_SPECIFIER = '@object-ui/types/zod'; +const DOOR_SPECIFIER = '../../packages/cli/src/utils/authoring-face.ts'; + +let face: FaceModule; +let door: DoorModule; +let judge: (doc: unknown) => Verdict; +let tolerant: (doc: unknown) => Verdict; +let nodeTypes: Set; +const parseJsonc = createRequire(path.join(repoRoot, 'packages/cli/package.json'))('jsonc-parser').parse; + +beforeAll(async () => { + face = (await import(/* @vite-ignore */ FACE_SPECIFIER)) as FaceModule; + door = (await import(/* @vite-ignore */ DOOR_SPECIFIER)) as DoorModule; + judge = (doc) => face.StrictAnyComponentSchema.safeParse(doc); + tolerant = (doc) => face.AnyComponentSchema.safeParse(doc); + nodeTypes = nodeTypesOf(face.AnyComponentSchema); +}, 60_000); + +const tempRoots: string[] = []; +afterAll(() => { + for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); +}); + +function tempTree(files: Record): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'doc-json-gate-')); + tempRoots.push(root); + for (const [rel, content] of Object.entries(files)) { + const full = path.join(root, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return root; +} + +const FENCE = '```'; +const fence = (lang: string, value: unknown): string => [`${FENCE}${lang}`, JSON.stringify(value, null, 2), FENCE].join('\n'); +const run = (files: Record, anchor: string | null = null) => + analyze({ root: tempTree(files), judge, tolerant, nodeTypes, parseJsonc, anchor }); +const reasons = (r: { findings: { reason: string }[] }) => r.findings.map((f) => f.reason); + +describe('scanJsonFences — what the gate reads', () => { + it('collects json and jsonc fences, lower-cased, and nothing else', () => { + const { fences } = scanJsonFences( + [`${FENCE}JSON`, '{}', FENCE, `${FENCE}ts`, 'const x = 1;', FENCE, `${FENCE}jsonc title="a"`, '{}', FENCE, `${FENCE}bash`, 'ls', FENCE].join('\n'), + ); + expect(fences.map((f) => f.language)).toEqual(['json', 'jsonc']); + expect(fences.map((f) => f.fenceLine)).toEqual([1, 7]); + }); + + it('reads a fence inside a blockquote, with the quote prefix stripped from its body', () => { + const { fences } = scanJsonFences(['> Callout prose.', '>', `> ${FENCE}json`, '> { "type": "text" }', `> ${FENCE}`].join('\n')); + expect(fences).toHaveLength(1); + expect(fences[0].quoteDepth).toBe(1); + expect(JSON.parse(fences[0].body)).toEqual({ type: 'text' }); + }); + + it('treats a shorter run inside a longer fence as body, through the shared scanner', () => { + const { fences } = scanJsonFences(['````markdown', `${FENCE}json`, '{ "type": "text" }', FENCE, '````'].join('\n')); + expect(fences).toEqual([]); + }); + + it('attaches a marker across blank lines, at the same quote depth only', () => { + const marker = FRAGMENT_MARKER_EXAMPLES[0]; + const same = scanJsonFences([marker, '', '', `${FENCE}json`, '{}', FENCE].join('\n')); + expect(same.fences[0].fragmentReason).toMatch(/not a complete, valid node document/); + expect(same.markers.map((m) => m.consumed)).toEqual([true]); + + const otherDepth = scanJsonFences([marker, '', `> ${FENCE}json`, '> {}', `> ${FENCE}`].join('\n')); + expect(otherDepth.fences[0].fragmentReason).toBeNull(); + expect(otherDepth.markers.map((m) => m.consumed)).toEqual([false]); + + const aboveTs = scanJsonFences([marker, `${FENCE}ts`, 'x', FENCE].join('\n')); + expect(aboveTs.markers.map((m) => m.consumed)).toEqual([false]); + }); +}); + +describe('the marker spelling is its own namespace', () => { + it('matches both of its own example spellings', () => { + for (const example of FRAGMENT_MARKER_EXAMPLES) expect(FRAGMENT_MARKER.test(example)).toBe(true); + }); + + it('is not the ts/tsx gate\'s marker, in either direction — that gate reports its marker above a json fence as stale', () => { + for (const example of FRAGMENT_MARKER_EXAMPLES) expect(SNIPPET_FRAGMENT_MARKER.test(example)).toBe(false); + for (const example of SNIPPET_FRAGMENT_MARKER_EXAMPLES) expect(FRAGMENT_MARKER.test(example)).toBe(false); + }); +}); + +describe('classifyFence — the node-document predicate', () => { + const classify = (language: string, body: string) => + classifyFence({ language, body, fenceLine: 1, quoteDepth: 0, fragmentReason: null }, { nodeTypes, parseJsonc }); + + it('sorts every non-document into its own bucket', () => { + expect(classify('json', '{ "type": "text", }').kind).toBe('notJson'); + expect(classify('json', '[{ "type": "text" }]').kind).toBe('notAnObject'); + expect(classify('json', '{ "name": "amount" }').kind).toBe('noTypeKey'); + expect(classify('json', '{ "type": "my-component" }')).toEqual({ kind: 'unresolvedType', type: 'my-component' }); + }); + + it('reads jsonc with the options `objectui check` reads a .json file with — comments and trailing commas', () => { + const c = classify('jsonc', '{\n "type": "text", // a comment\n "content": "hi",\n}'); + expect(c.kind).toBe('document'); + expect(c.value).toEqual({ type: 'text', content: 'hi' }); + }); + + it('reads json with JSON.parse — a comment in a json fence is not JSON', () => { + expect(classify('json', '{ "type": "text" // a comment\n}').kind).toBe('notJson'); + }); +}); + +describe('analyze — the verdicts, against the real face', () => { + it('RED: refuses a node document carrying an undeclared key, and names the key', () => { + const r = run({ 'content/docs/a.mdx': fence('json', { type: 'button', label: 'Go', action: { type: 'submit' } }) }); + expect(reasons(r)).toEqual(['refused']); + expect(r.findings[0].site).toBe('content/docs/a.mdx:1'); + expect(r.findings[0].detail).toMatch(/tolerant face accepts it/); + expect(r.findings[0].issues).toEqual(['at (root): undeclared key "action"']); + }); + + it('accepts a declared-only node document', () => { + const r = run({ 'content/docs/a.md': fence('json', { type: 'button', label: 'Go' }) }); + expect(r.findings).toEqual([]); + expect(r.accepted).toHaveLength(1); + expect(r.judged).toHaveLength(1); + }); + + it('takes a declaration only where the face refuses the fence', () => { + const declared = run({ 'content/docs/a.mdx': [FRAGMENT_MARKER_EXAMPLES[0], fence('json', { type: 'button', nonsense: 1 })].join('\n') }); + expect(declared.findings).toEqual([]); + expect(declared.declared).toHaveLength(1); + + const onAccepted = run({ 'content/docs/a.md': [FRAGMENT_MARKER_EXAMPLES[1], fence('json', { type: 'button', label: 'Go' })].join('\n') }); + expect(reasons(onAccepted)).toEqual(['stale-fragment-marker']); + + const onNonDocument = run({ 'content/docs/a.md': [FRAGMENT_MARKER_EXAMPLES[1], fence('json', { name: 'amount' })].join('\n') }); + expect(reasons(onNonDocument)).toEqual(['stale-fragment-marker']); + }); + + it('reports a marker that declares nothing, a reason too short to be one, and a verification claim', () => { + expect(reasons(run({ 'content/docs/a.mdx': [FRAGMENT_MARKER_EXAMPLES[0], '', 'Just prose.'].join('\n') }))).toEqual([ + 'dangling-fragment-marker', + ]); + expect( + reasons(run({ 'content/docs/a.mdx': ['{/* doc-json: fragment — short */}', fence('json', { type: 'button', nonsense: 1 })].join('\n') })), + ).toEqual(['unexplained-fragment']); + expect( + reasons( + run({ + 'content/docs/a.mdx': [ + '{/* doc-json: fragment — an excerpt, verified against the renderer by hand */}', + fence('json', { type: 'button', nonsense: 1 }), + ].join('\n'), + }), + ), + ).toEqual(['verification-claim-on-fragment']); + }); + + it('reads past the first issue of a union to the arm the value nearly fits', () => { + const r = run({ 'content/docs/a.mdx': fence('json', { type: 'card', title: 'T', children: [{ type: 'button', label: 'Go', nonsense: 1 }] }) }); + expect(reasons(r)).toEqual(['refused']); + expect((r.findings[0].issues ?? []).join('\n')).toMatch(/undeclared key "nonsense"/); + }); + + it('ANTI-IDLE: an enumeration that judged nothing, or lost its anchor, is a precondition, never a green', () => { + expect(run({ 'content/docs/a.mdx': fence('json', { name: 'not a node' }) }).preconditions.map((p) => p.reason)).toEqual(['nothing-judged']); + expect(run({ 'content/docs/a.mdx': fence('json', { type: 'button', label: 'Go' }) }, 'content/docs/anchor.mdx').preconditions.map((p) => p.reason)).toEqual([ + 'anchor-unread', + ]); + const empty = analyze({ root: tempTree({ 'README.md': '# not on the surface' }), judge, tolerant, nodeTypes, parseJsonc, anchor: null }); + expect(empty.preconditions.map((p) => p.reason)).toEqual(['empty-enumeration']); + }); +}); + +describe('controls — a probe that cannot fail is not a probe', () => { + it('hold against the real faces', () => { + expect(controlFailures({ judge, tolerant, nodeTypes })).toEqual([]); + }); + + it('REFUSES fails when the "strict" face handed in is the passthrough one', () => { + expect(controlFailures({ judge: tolerant, tolerant, nodeTypes }).join('\n')).toMatch(/REFUSES/); + }); + + it('ACCEPTS fails when the judge refuses everything', () => { + expect(controlFailures({ judge: () => ({ success: false }), tolerant, nodeTypes }).join('\n')).toMatch(/ACCEPTS/); + }); + + it('the control documents isolate exactly one undeclared key', () => { + expect(judge(REFUSED_CONTROL).success).toBe(false); + expect(tolerant(REFUSED_CONTROL).success).toBe(true); + expect(judge(ACCEPTED_CONTROL).success).toBe(true); + const issues = readableIssues(judge(REFUSED_CONTROL).error?.issues ?? []).map(describeIssue); + expect(issues).toEqual(['at (root): undeclared key "thisKeyIsDeclaredNowhere"']); + }); +}); + +describe('one authority — the CLI door and the gate agree on every judged document', () => { + it('validateAuthoredDocument returns the gate\'s verdict for every node document in the corpus', () => { + const r = analyze({ root: repoRoot, judge, tolerant, nodeTypes, parseJsonc }); + const judged = r.judged as { site: string; value: unknown }[]; + expect(judged.length, 'the corpus walk judged nothing — the agreement below would be vacuous').toBeGreaterThan(0); + const gate = judged.map((j) => `${j.site} ${judge(j.value).success}`); + const cli = judged.map((j) => `${j.site} ${door.validateAuthoredDocument(j.value).success}`); + expect(cli).toEqual(gate); + // Both verdicts occur, so the agreement is not over one constant answer. + expect(new Set(judged.map((j) => judge(j.value).success))).toEqual(new Set([true, false])); + }); + + it(`reads its anchor page, ${ANCHOR_DOCUMENT}, from the real corpus`, () => { + expect(listDocuments(repoRoot)).toContain(ANCHOR_DOCUMENT); + }); +}); + +describe('main — exit codes', () => { + it('exits 2, could not run, when the face is not built', async () => { + const root = tempTree({ + 'packages/types/package.json': JSON.stringify({ exports: { './zod': { import: './dist/zod/index.zod.js' } } }), + 'content/docs/a.mdx': fence('json', { type: 'button', label: 'Go' }), + }); + expect(await main([], root)).toBe(EXIT_CODES.couldNotRun); + }); + + it('states all three codes in its own header', () => { + const source = fs.readFileSync(path.join(repoRoot, 'scripts/check-doc-json-fences.mjs'), 'utf8'); + const header = source.slice(0, source.indexOf('## Why this exists')); + expect(header).toContain('0 = every judged node document'); + expect(header).toContain('1 = THE GATE RAN AND FOUND ERRORS'); + expect(header).toContain('2 = THE GATE COULD NOT RUN'); + }); +}); + +/** + * Wired like its two siblings, and pinned the way they pin theirs: a check no + * workflow runs is not a gate (objectui#8757). + */ +describe('wiring — the gate runs in CI, after the build and after its siblings', () => { + const GATE = 'scripts/check-doc-json-fences.mjs'; + const SIBLINGS = ['scripts/check-doc-snippet-types.mjs', 'scripts/check-doc-example-types.mjs']; + const HOME = 'doc-snippet-types.yml'; + const workflowDir = path.join(repoRoot, '.github/workflows'); + const workflowFiles = fs.readdirSync(workflowDir).filter((f) => f.endsWith('.yml')); + const yamlOf = (file: string): string => + fs + .readFileSync(path.join(workflowDir, file), 'utf8') + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); + const invocation = (yaml: string, script: string) => yaml.search(new RegExp(`run: node ${script.replace(/[.\\/]/g, '\\$&')}\\s*$`, 'm')); + + it('lives in exactly one workflow, which gates pull requests and filters no path', () => { + expect(workflowFiles.length, 'the workflow directory scan returned implausibly few files').toBeGreaterThan(5); + expect(workflowFiles.filter((f) => yamlOf(f).includes(GATE))).toEqual([HOME]); + const yaml = yamlOf(HOME); + expect(yaml).toContain('pull_request:'); + expect(yaml).not.toMatch(/^\s*paths(-ignore)?:/m); + }); + + it('is invoked after the build that produces @object-ui/types, and after both sibling gates', () => { + const yaml = yamlOf(HOME); + const build = yaml.indexOf('turbo run build'); + const invoke = invocation(yaml, GATE); + expect(build).toBeGreaterThan(-1); + expect(invoke, 'the workflow must invoke the gate itself').toBeGreaterThan(build); + for (const sibling of SIBLINGS) { + const at = invocation(yaml, sibling); + expect(at, `${sibling} must still be invoked`).toBeGreaterThan(-1); + expect(invoke, `a newly wired gate must not mask ${sibling}`).toBeGreaterThan(at); + } + }); + + it('has a root script and is documented on the page that inventories the gates', () => { + const manifest = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + expect(manifest.scripts['check:doc-json']).toBe(`node ${GATE}`); + const page = fs.readFileSync(path.join(repoRoot, 'content/docs/guide/ci-cd-pipeline.md'), 'utf8'); + expect(page).toContain('pnpm check:doc-json'); + expect(page).toContain(GATE); + }); +}); diff --git a/scripts/check-doc-json-fences.mjs b/scripts/check-doc-json-fences.mjs new file mode 100644 index 0000000000..012532924c --- /dev/null +++ b/scripts/check-doc-json-fences.mjs @@ -0,0 +1,634 @@ +#!/usr/bin/env node +/** + * Every fenced `json` / `jsonc` NODE DOCUMENT in `content/docs` must pass the + * STRICT AUTHORING FACE — `StrictAnyComponentSchema`, the face `objectui + * validate` and `objectui check` judge an authored document with. + * + * Run: node scripts/check-doc-json-fences.mjs (also `pnpm check:doc-json`) + * node scripts/check-doc-json-fences.mjs --list (also print every fence and + * the bucket it fell in) + * Exit: 0 = every judged node document is accepted or carries a declaration the + * face confirms, every declaration is attached and needed, and the + * harness proved itself on its own controls. + * 1 = THE GATE RAN AND FOUND ERRORS. A node document is refused, or a + * declaration is stale, dangling or unexplained. Everything printed above + * the summary is a verdict about a document. + * 2 = THE GATE COULD NOT RUN, so nothing printed above is a verdict about + * any document: `@object-ui/types` is not built, the JSONC reader cannot + * be resolved, a control failed, or the enumeration came back empty or + * without its anchor page. Fix the tree and re-run. Never read this as a + * documentation defect, and never as a pass. + * + * The three codes are the sibling gate's (`scripts/check-doc-snippet-types.mjs`, + * its "Why could not run is its own exit code" section), for the same reason. + * + * ## Why this exists (objectui#5250) + * + * The ts/tsx snippet gate reads `ts` / `tsx` / `typescript` fences and nothing + * else, so a `json` fence was checked by nothing: objectui#5229 taught + * `"validation": { … }` on an `input` node in `content/docs/blocks/forms.mdx` + * while every shipped tool was green. And a gate that parsed those fences + * against the TOLERANT face would have caught nothing either, because + * `BaseSchemaCore` is `.passthrough()`. The maintainer ruling on objectui#5250 + * (2026-09-04, decision batch #25, option 2) ordered both halves, in this order: + * "each node schema gets a derived strict variant; `objectui validate` and the + * doc-snippet gates run **strict**", and "a JSON-fence gate (option 1) is then + * built **on top of** the strict face — ⛔ not before it". The strict face is + * objectui#8345; `objectui validate` / `objectui check` moved onto it in slice A + * (`validateAuthoredDocument`, `packages/cli/src/utils/authoring-face.ts`). + * This gate is that ruling's JSON-fence slice. + * + * ## One authority: the face the CLI imports, built + * + * The verdict here is `StrictAnyComponentSchema.safeParse(document).success`, + * read from `@object-ui/types`' BUILT `./zod` entry — the module + * `@object-ui/cli` resolves at runtime for `validateAuthoredDocument`, whose + * whole body is that same call. So a document this gate accepts is a document + * `objectui validate` accepts, and the reverse, by construction rather than by + * a second copy of the rule. `scripts/__tests__/check-doc-json-fences.test.ts` + * pins the agreement by running the CLI's own door over the same corpus. + * + * The tolerant face (`AnyComponentSchema`) is read too, for the WORDS only: a + * refusal it does not share is reported as "strict-only — every reason is an + * undeclared key". It decides nothing. + * + * ⛔ Not from `src/`. The repository's root `tsconfig.json` maps + * `@object-ui/types` to source, which is one inherited config away from judging + * the docs against a face nobody installs; the RESOLUTION control below prints + * the resolved file and refuses one outside the package's `dist/`. The + * workflow (`doc-snippet-types.yml`) runs this gate after it has built the + * snippet gate's `--build-filter` closure, which carries `@object-ui/types`. + * + * ## What a "node document" is — the population, stated + * + * The predicate `scripts/measure-strict-authoring-face.mjs` measured the + * programme with (objectui#7581), with two corrections, both toward reading + * MORE: + * + * 1. a fence whose language (the first token of its info string, + * case-insensitive) is `json` or `jsonc`; + * 2. whose body parses — `json` with `JSON.parse`, the reader `objectui + * validate` uses; `jsonc` with `jsonc-parser` and the options `objectui + * check` reads a `.json` file with (comments, trailing commas). ⚠️ + * Correction one: the measurement read `jsonc` with `JSON.parse` too, so + * every `jsonc` fence carrying a comment fell out of its population while + * teaching keys like any other; + * 3. into a plain object with a string `type`; + * 4. whose `type` names an arm of `AnyComponentSchema` (walked through its + * unions and lazies, by the `type` literals each object arm declares). + * + * ⚠️ Correction two: a fence inside a blockquote is read, its quote prefix + * stripped the way the sibling gate strips it. The measurement's scanner did + * not see quoted fences at all. + * + * Everything else is NOT JUDGED, and is counted rather than skipped in silence: + * the summary prints each bucket (not JSON · not an object · no string `type` · + * a `type` no node schema declares, with the types), and `--list` prints every + * fence with its bucket. A `type` no arm declares is refused by the tolerant + * face as well; whether a `type` literal names a registered component is + * `scripts/check-doc-component-types.mjs`'s question, not this gate's. + * + * ## The rule for fragments: a declaration the face confirms + * + * A node document that is deliberately not a complete, valid one — a + * deliberately refused example that shows the error, an excerpt whose required + * keys the page elides, an object whose `type` happens to collide with a node + * type but is not a node — must be DECLARED by a marker line immediately above + * its fence, at the fence's own quote depth, carrying a written reason. The two + * spellings are quoted in `FRAGMENT_MARKER_EXAMPLES` (a block comment cannot + * quote them). The keyword is `fragment` for every kind, as in the sibling + * gate; the reason says which kind. ⛔ The namespace is `doc-json:`, not + * `doc-snippet:`, on purpose: the sibling gate reports a `doc-snippet:` marker + * that sits above anything but a ts/tsx fence as stale, so one spelling cannot + * serve both gates without coupling them. + * + * A declaration is never taken on trust. It is re-judged on every run: + * + * - the fence below it must be a node document the strict face REFUSES. A + * declaration on a fence the face accepts, or on a fence that is not a node + * document at all, exempts nothing and is reported as stale — so the list + * of declarations can only shrink, never accrete; + * - a marker with no fence directly below it is reported as dangling; + * - a reason shorter than the sibling gate's minimum is not a declaration; + * - a reason that claims the fence was checked is refused, by the sibling + * gate's own `VERIFICATION_CLAIM` (objectui#7505): a declared fence is not + * judged for its verdict, so a claim about it cannot be re-verified. + * + * ## Controls — a probe that cannot fail is not a probe + * + * Run on every invocation, before any verdict about a document: + * + * RESOLUTION the face module resolves inside `packages/types/dist/`, and the + * resolved file is printed. + * REFUSES a document carrying one undeclared key MUST be refused by the + * strict face and accepted by the tolerant one. A face that + * accepts it is the passthrough face, and every green below + * would be the objectui#5250 green this gate exists to end. + * ACCEPTS a declared-only document MUST be accepted. A harness broken the + * other way reds every page at once and reads as "the docs are + * full of defects". + * ANTI-IDLE the walk must judge at least one node document, and + * `ANCHOR_DOCUMENT` — the page objectui#5229 was found on — must + * contribute one. An enumeration that collapsed (a moved root, a + * broken language filter, a scanner that stopped seeing fences) + * exits 2 instead of printing a green over nothing. + * + * ## What this gate does NOT answer + * + * 1. Whether a ts/tsx snippet compiles — `scripts/check-doc-snippet-types.mjs`. + * 2. Whether a `type` literal names a registered component — + * `scripts/check-doc-component-types.mjs`. + * 3. Whether a `${…}` sits on a key `SchemaRenderer` evaluates — + * `scripts/check-doc-expression-carriage.mjs` (report-only). + * 4. ⚠️ Any page outside `content/docs`. The package READMEs, the root + * `README.md`, the `apps//docs` trees and the root `docs/` tree are + * NOT READ here, and nothing about them is claimed by a green run. That is + * the population objectui#5250's measurement priced; widening it is a + * change of this constant and of the pages it would then read. + * 5. Whether a key the face DECLARES is read by a renderer. A declared key + * nothing reads passes here; that is the declaration side's question. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + MIN_REASON_LENGTH, + VERIFICATION_CLAIM, + isBlankAtDepth, + quoteDepthOf, + stripQuotePrefix, +} from './check-doc-snippet-types.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; +import { closesFence, openFence } from './markdown-fence-scan.mjs'; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// ── Configuration ──────────────────────────────────────────────────────────── + +/** The scan surface. See the header's "does NOT answer" item 4. */ +export const DOCS_ROOT = 'content/docs'; + +/** Page extensions collected under `DOCS_ROOT` — both, as the sibling gate collects. */ +export const DOC_EXTENSIONS = ['.mdx', '.md']; + +/** The fence languages read. Matched against the first info token, lower-cased. */ +export const JSON_FENCE_LANGUAGES = new Set(['json', 'jsonc']); + +/** + * A page that must contribute at least one judged node document on every run — + * the page objectui#5229 was found on. If it stops holding one (moved, renamed, + * or its fences rewritten away), name another; ⛔ never delete the control. + */ +export const ANCHOR_DOCUMENT = 'content/docs/blocks/forms.mdx'; + +/** The package and entry the face is read from — the one `@object-ui/cli` imports. */ +export const FACE_PACKAGE_DIR = 'packages/types'; +export const FACE_ENTRY = './zod'; + +/** The package whose dependency `jsonc-parser` is — the reader `objectui check` uses. */ +export const JSONC_READER_OWNER = 'packages/cli'; + +export const EXIT_CODES = { + /** Every judged node document passed, every declaration held, the controls held. */ + verified: 0, + /** The gate RAN. A document or a declaration is at fault — a verdict was read. */ + documentsFailed: 1, + /** The gate COULD NOT RUN. Nothing it printed is a verdict about a document. */ + couldNotRun: 2, +}; + +/** + * The declaration a deliberate fragment carries; see `FRAGMENT_MARKER_EXAMPLES`. + * Matched against the line with its blockquote markers stripped. + */ +export const FRAGMENT_MARKER = + /^[ \t]*(?:\{\/\*|)[ \t]*$/; + +/** The marker, spelled out: the MDX expression comment, then the HTML comment. */ +export const FRAGMENT_MARKER_EXAMPLES = [ + '{/* doc-json: fragment — why this fence is not a complete, valid node document */}', + '', +]; + +/** The REFUSES control: one undeclared key on a declared-only node. */ +export const REFUSED_CONTROL = { type: 'button', label: 'Save', thisKeyIsDeclaredNowhere: true }; +/** The ACCEPTS control: the same node, declared keys only. */ +export const ACCEPTED_CONTROL = { type: 'button', label: 'Save' }; + +// ── Scanning ───────────────────────────────────────────────────────────────── + +/** Every page in the scan set, repo-relative and POSIX-separated, in a stable order. */ +export function listDocuments(root = repoRoot) { + const out = []; + const walk = (dir) => { + for (const entry of readdirSync(dir).sort()) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) walk(p); + else if (DOC_EXTENSIONS.some((ext) => entry.endsWith(ext))) out.push(relative(root, p).split(sep).join('/')); + } + }; + const docsRoot = join(root, DOCS_ROOT); + if (existsSync(docsRoot)) walk(docsRoot); + return out; +} + +/** + * Every json/jsonc fence in one page, with the marker that declares it, and + * every marker in the page. Fences are opened and closed by + * `markdown-fence-scan.mjs` — the one answer to "is this a fence" — read + * through the quote prefix of the line that opened them, so a fence inside a + * callout is collected with its `>` stripped from every body line. + * + * A marker declares the fence whose opener is the nearest non-blank line below + * it AT THE SAME QUOTE DEPTH (a bare `>` spacer counts as blank), the sibling + * gate's attachment rule. + */ +export function scanJsonFences(source) { + const lines = source.split('\n'); + const fences = []; + const markers = []; + for (let i = 0; i < lines.length; i++) { + const markerDepth = quoteDepthOf(lines[i]); + const marker = FRAGMENT_MARKER.exec(stripQuotePrefix(lines[i], markerDepth)); + if (marker) markers.push({ line: i + 1, depth: markerDepth, reason: marker[1].trim(), consumed: false }); + + const depth = quoteDepthOf(lines[i]); + const open = openFence(stripQuotePrefix(lines[i], depth)); + if (!open) continue; + let close = lines.length; + for (let j = i + 1; j < lines.length; j++) { + if (closesFence(stripQuotePrefix(lines[j], depth), open)) { + close = j; + break; + } + } + const language = open.lang.toLowerCase(); + if (JSON_FENCE_LANGUAGES.has(language)) { + let k = i - 1; + while (k >= 0 && isBlankAtDepth(lines[k], depth)) k--; + const above = k >= 0 ? markers.find((m) => m.line === k + 1 && m.depth === depth) : undefined; + if (above) above.consumed = true; + fences.push({ + fenceLine: i + 1, + language, + quoteDepth: depth, + body: lines + .slice(i + 1, close) + .map((line) => stripQuotePrefix(line, depth)) + .join('\n'), + fragmentReason: above ? above.reason : null, + }); + } + i = close; + } + return { fences, markers }; +} + +// ── Classification ─────────────────────────────────────────────────────────── + +const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); + +/** The `type` literals one schema declares, or `null` when it declares none. */ +function typeLiterals(schema) { + const def = schema?._zod?.def; + if (!def) return null; + if (def.type === 'literal') return def.values.filter((v) => typeof v === 'string'); + if (def.type === 'enum') return Object.values(def.entries).filter((v) => typeof v === 'string'); + if (def.type === 'union') { + const out = []; + for (const option of def.options) { + const literals = typeLiterals(option); + if (!literals) return null; + out.push(...literals); + } + return out; + } + return null; +} + +/** + * The node types the face declares: every `type` literal of every object arm + * of `AnyComponentSchema`, walked through nested unions and lazies. + */ +export function nodeTypesOf(anyComponentSchema) { + const types = new Set(); + const visit = (schema, depth) => { + const def = schema?._zod?.def; + if (!def || depth > 8) return; + if (def.type === 'union') { + for (const option of def.options) visit(option, depth + 1); + return; + } + if (def.type === 'lazy') { + let inner; + try { + inner = def.getter(); + } catch { + return; + } + visit(inner, depth + 1); + return; + } + if (def.type !== 'object') return; + for (const t of typeLiterals(def.shape?.type) ?? []) types.add(t); + }; + visit(anyComponentSchema, 0); + return types; +} + +/** + * Which bucket one fence falls in. `parseJsonc` is `jsonc-parser`'s `parse`; + * the options are the ones `objectui check` reads a `.json` file with. + * + * @returns {{ kind: 'notJson' | 'notAnObject' | 'noTypeKey' | 'unresolvedType' | 'document', type?: string, value?: unknown }} + */ +export function classifyFence(fence, { nodeTypes, parseJsonc }) { + let value; + if (fence.language === 'jsonc') { + const errors = []; + value = parseJsonc(fence.body, errors, { allowTrailingComma: true }); + if (errors.length > 0 || value === undefined) return { kind: 'notJson' }; + } else { + try { + value = JSON.parse(fence.body); + } catch { + return { kind: 'notJson' }; + } + } + if (!isPlainObject(value)) return { kind: 'notAnObject' }; + if (typeof value.type !== 'string') return { kind: 'noTypeKey' }; + if (!nodeTypes.has(value.type)) return { kind: 'unresolvedType', type: value.type }; + return { kind: 'document', type: value.type, value }; +} + +// ── Reading a refusal ──────────────────────────────────────────────────────── + +const formatPath = (path) => (path.length > 0 ? path.join(' → ') : '(root)'); + +/** An arm whose only complaint is the base type of the value — the primitives a slot admits. */ +const isShapeMismatchOnly = (leaves) => + leaves.every((l) => l.code === 'invalid_type' && /received (array|object|string|number|boolean)/.test(l.message)); + +/** + * The issues worth printing, flattened. A union issue (`Invalid input`) says + * nothing on its own, so it is replaced by the issues of the arm the value + * comes closest to fitting: arms that only refuse the value's base type are + * set aside, and the arm with the fewest remaining issues is shown. Words + * only — the verdict is `success`, never this list. + */ +export function readableIssues(issues, prefix = []) { + const out = []; + for (const issue of issues) { + const path = [...prefix, ...(issue.path ?? [])]; + if (issue.code === 'invalid_union' && Array.isArray(issue.errors) && issue.errors.length > 0) { + const arms = issue.errors.map((arm) => readableIssues(arm, path)); + const informative = arms.filter((leaves) => !isShapeMismatchOnly(leaves)); + const best = (informative.length > 0 ? informative : arms).sort((a, b) => a.length - b.length)[0]; + out.push(...best); + continue; + } + out.push({ code: issue.code, path, message: String(issue.message ?? ''), keys: issue.keys }); + } + return out; +} + +/** One printed line per issue: an undeclared key by name, anything else by its message. */ +export function describeIssue(issue) { + if (issue.code === 'unrecognized_keys' && Array.isArray(issue.keys)) { + const keys = issue.keys.map((k) => `"${k}"`).join(', '); + return `at ${formatPath(issue.path)}: undeclared key${issue.keys.length === 1 ? '' : 's'} ${keys}`; + } + const message = issue.message.length > 220 ? `${issue.message.slice(0, 217)}...` : issue.message; + return `at ${formatPath(issue.path)}: ${message}`; +} + +// ── The analysis ───────────────────────────────────────────────────────────── + +/** + * Judge every json/jsonc fence on the scan surface. + * + * `judge(document)` is the strict verdict (`{ success, error? }`); `tolerant` + * is the tolerant face's, read for words only. Both are injected so the test + * suite can hand in the CLI's own door and the source face. `anchor: null` + * switches the anchor control off, for a fixture tree that has no anchor page. + * + * @param {{ + * root?: string, + * judge: (doc: unknown) => { success: boolean, error?: { issues: any[] } }, + * tolerant?: (doc: unknown) => { success: boolean }, + * nodeTypes: Set, + * parseJsonc: (text: string, errors: unknown[], options: object) => unknown, + * anchor?: string | null, + * }} options + */ +export function analyze({ root = repoRoot, judge, tolerant, nodeTypes, parseJsonc, anchor = ANCHOR_DOCUMENT }) { + const findings = []; + const preconditions = []; + const documents = listDocuments(root); + const buckets = { notJson: [], notAnObject: [], noTypeKey: [], unresolvedType: [] }; + const judged = []; + const accepted = []; + const declared = []; + const refused = []; + let fenceCount = 0; + + for (const doc of documents) { + const { fences, markers } = scanJsonFences(readFileSync(join(root, doc), 'utf8')); + fenceCount += fences.length; + for (const marker of markers) { + if (!marker.consumed) { + findings.push({ + reason: 'dangling-fragment-marker', + site: `${doc}:${marker.line}`, + detail: 'a `doc-json: fragment` marker must sit immediately above the json/jsonc fence it declares', + }); + } + } + for (const fence of fences) { + const site = `${doc}:${fence.fenceLine}`; + const c = classifyFence(fence, { nodeTypes, parseJsonc }); + if (fence.fragmentReason !== null) { + if (fence.fragmentReason.length < MIN_REASON_LENGTH) { + findings.push({ reason: 'unexplained-fragment', site, detail: 'a fragment declaration must say why the fence is not a complete, valid node document' }); + } + const claim = VERIFICATION_CLAIM.exec(fence.fragmentReason); + if (claim) { + findings.push({ + reason: 'verification-claim-on-fragment', + site, + detail: `a declared fence is not judged for its verdict, so this gate cannot re-verify "${claim[0]}" on any later commit. Say why the fence is not a complete document without asserting it was checked.`, + }); + } + } + if (c.kind !== 'document') { + buckets[c.kind].push({ site, type: c.type }); + if (fence.fragmentReason !== null) { + findings.push({ reason: 'stale-fragment-marker', site, detail: 'this fence is not a node document, so nothing judges it and the declaration exempts nothing — delete the marker' }); + } + continue; + } + const verdict = judge(c.value); + const entry = { site, doc, type: c.type, value: c.value }; + judged.push(entry); + if (verdict.success) { + if (fence.fragmentReason !== null) { + findings.push({ reason: 'stale-fragment-marker', site, detail: `the strict face ACCEPTS this \`${c.type}\` node, so it is a complete document and the declaration exempts nothing — delete the marker` }); + } else accepted.push(entry); + continue; + } + if (fence.fragmentReason !== null) { + declared.push({ ...entry, reason: fence.fragmentReason }); + continue; + } + const strictOnly = tolerant ? tolerant(c.value).success === true : null; + refused.push(entry); + findings.push({ + reason: 'refused', + site, + detail: + `the strict authoring face refuses this \`${c.type}\` node` + + (strictOnly === true + ? ' (the tolerant face accepts it, so every reason is a key no schema declares)' + : strictOnly === false + ? ' (the tolerant face refuses it too)' + : ''), + issues: readableIssues(verdict.error?.issues ?? []).map(describeIssue), + }); + } + } + + if (documents.length === 0) { + preconditions.push({ reason: 'empty-enumeration', site: DOCS_ROOT, detail: 'no page was found on the scan surface' }); + } else if (judged.length === 0) { + preconditions.push({ reason: 'nothing-judged', site: DOCS_ROOT, detail: 'the walk judged no node document at all — the enumeration collapsed, so a green here would be over nothing' }); + } + if (anchor && !judged.some((j) => j.doc === anchor)) { + preconditions.push({ reason: 'anchor-unread', site: anchor, detail: 'the anchor page contributed no judged node document — the scanner, the language filter or the page has moved; name another anchor, never delete the control' }); + } + + return { documents, fenceCount, buckets, judged, accepted, declared, refused, findings, preconditions }; +} + +// ── Loading the face and the reader ────────────────────────────────────────── + +/** The built face's file, from the face package's own `exports` — `null` when unbuilt. */ +export function resolveFaceFile(root = repoRoot) { + const manifest = JSON.parse(readFileSync(join(root, FACE_PACKAGE_DIR, 'package.json'), 'utf8')); + const entry = manifest.exports?.[FACE_ENTRY]; + const target = typeof entry === 'string' ? entry : entry?.import ?? entry?.default; + if (typeof target !== 'string') return { file: null, detail: `${FACE_PACKAGE_DIR}/package.json declares no importable \`${FACE_ENTRY}\` entry` }; + const file = join(root, FACE_PACKAGE_DIR, target); + return { file, detail: null }; +} + +/** The controls, against the loaded faces. Each failure is a reason the run could not be read. */ +export function controlFailures({ judge, tolerant, nodeTypes }) { + const failures = []; + if (!nodeTypes.has(REFUSED_CONTROL.type)) failures.push(`the node-type walk does not reach \`${REFUSED_CONTROL.type}\` — it read ${nodeTypes.size} type(s)`); + if (judge(REFUSED_CONTROL).success) failures.push('REFUSES: the strict face accepted a document carrying an undeclared key — it is the passthrough face'); + if (tolerant && !tolerant(REFUSED_CONTROL).success) failures.push('REFUSES: the tolerant face refused the control document — the control no longer isolates the undeclared key; pick another'); + if (!judge(ACCEPTED_CONTROL).success) failures.push('ACCEPTS: the strict face refused a declared-only document — the harness is broken in the other direction'); + return failures; +} + +function printFindingList(items) { + for (const f of items) { + console.error(` ❌ ${f.site} — ${f.detail}`); + for (const line of f.issues ?? []) console.error(` ${line}`); + } +} + +export async function main(argv = process.argv.slice(2), root = repoRoot) { + const list = argv.includes('--list'); + + const { file: faceFile, detail: faceDetail } = resolveFaceFile(root); + if (!faceFile || !existsSync(faceFile)) { + console.error( + `⛔ check-doc-json-fences could not run: the strict authoring face is not built (${faceDetail ?? faceFile}).\n` + + ' Build it, then re-run: pnpm --filter @object-ui/types build\n' + + ' Nothing above is a verdict about any document.', + ); + return EXIT_CODES.couldNotRun; + } + const faceRelative = relative(root, faceFile).split(sep).join('/'); + if (!faceRelative.startsWith(`${FACE_PACKAGE_DIR}/dist/`)) { + console.error(`⛔ RESOLUTION: the face resolved to ${faceRelative}, outside ${FACE_PACKAGE_DIR}/dist/ — refusing to judge the docs against it.`); + return EXIT_CODES.couldNotRun; + } + + let face; + let parseJsonc; + try { + face = await import(pathToFileURL(faceFile).href); + parseJsonc = createRequire(join(root, JSONC_READER_OWNER, 'package.json'))('jsonc-parser').parse; + } catch (error) { + console.error(`⛔ check-doc-json-fences could not run: ${String(error?.message ?? error).split('\n')[0]}`); + return EXIT_CODES.couldNotRun; + } + if (!face.StrictAnyComponentSchema || !face.AnyComponentSchema || typeof parseJsonc !== 'function') { + console.error('⛔ check-doc-json-fences could not run: the face module exports no StrictAnyComponentSchema / AnyComponentSchema, or jsonc-parser has no parse.'); + return EXIT_CODES.couldNotRun; + } + + const judge = (doc) => face.StrictAnyComponentSchema.safeParse(doc); + const tolerant = (doc) => face.AnyComponentSchema.safeParse(doc); + const nodeTypes = nodeTypesOf(face.AnyComponentSchema); + + const failures = controlFailures({ judge, tolerant, nodeTypes }); + if (failures.length > 0) { + console.error('⛔ check-doc-json-fences could not run — a control failed, so no verdict below would mean anything:'); + for (const f of failures) console.error(` ${f}`); + return EXIT_CODES.couldNotRun; + } + + const r = analyze({ root, judge, tolerant, nodeTypes, parseJsonc }); + + if (list) { + for (const j of r.accepted) console.log(` accepted ${j.site} ${j.type}`); + for (const j of r.declared) console.log(` declared ${j.site} ${j.type} — ${j.reason}`); + for (const j of r.refused) console.log(` refused ${j.site} ${j.type}`); + for (const [bucket, items] of Object.entries(r.buckets)) { + for (const item of items) console.log(` ${bucket.padEnd(15)} ${item.site}${item.type ? ` ${item.type}` : ''}`); + } + } + + if (r.findings.length > 0) { + console.error(`\n❌ JSON doc fences: ${r.findings.length} finding(s) — the strict authoring face is the face \`objectui validate\` judges with.`); + printFindingList(r.findings); + } + + const unresolvedTypes = [...new Set(r.buckets.unresolvedType.map((b) => b.type))].sort(); + console.log( + [ + '', + `JSON doc fences — strict authoring face: ${faceRelative} (StrictAnyComponentSchema, the face \`objectui validate\` and \`objectui check\` judge with)`, + ` scan surface: every ${DOC_EXTENSIONS.join(' / ')} page under ${DOCS_ROOT}/ — ${r.documents.length} page(s), ${r.fenceCount} json/jsonc fence(s)`, + ` judged node documents: ${r.judged.length} — ${r.accepted.length} accepted, ${r.declared.length} declared fragment(s) the face refuses as declared, ${r.refused.length} refused`, + ` not judged: ${r.buckets.notJson.length} not JSON · ${r.buckets.notAnObject.length} not an object · ${r.buckets.noTypeKey.length} without a string \`type\` · ${r.buckets.unresolvedType.length} with a \`type\` no node schema declares${unresolvedTypes.length ? ` (${unresolvedTypes.join(', ')})` : ''}`, + ` controls: RESOLUTION, REFUSES, ACCEPTS hold over ${nodeTypes.size} node type(s); anchor ${ANCHOR_DOCUMENT} read`, + ` NOT read: pages outside ${DOCS_ROOT}/ (see this script's header) — a green run claims nothing about them`, + ].join('\n'), + ); + + if (r.preconditions.length > 0) { + console.error('\n⛔ check-doc-json-fences could not produce a verdict — the enumeration failed its own controls:'); + printFindingList(r.preconditions); + return EXIT_CODES.couldNotRun; + } + if (r.findings.length > 0) { + console.error( + `\n Fix the document: remove a key no schema declares, or replace it with the declared key its renderer reads.` + + `\n A fence that is deliberately not a complete, valid node document is declared on the line above it:` + + `\n ${FRAGMENT_MARKER_EXAMPLES[0]}\n ${FRAGMENT_MARKER_EXAMPLES[1]}`, + ); + return EXIT_CODES.documentsFailed; + } + console.log('✅ Every judged JSON node document in the docs passes the strict authoring face.'); + return EXIT_CODES.verified; +} + +if (isEntrypoint(import.meta.url)) { + process.exit(await main()); +} diff --git a/scripts/check-doc-snippet-types.mjs b/scripts/check-doc-snippet-types.mjs index e15e9da4df..24f3fe8b07 100644 --- a/scripts/check-doc-snippet-types.mjs +++ b/scripts/check-doc-snippet-types.mjs @@ -54,9 +54,11 @@ * answer: `@objectstack/spec`'s schemas are strict, so they reject keys a * TypeScript annotation never sees (an object literal assigned through a * widened type, or written with no annotation at all). That is objectui#5138 - * shape 1, left unruled on purpose — it needs a way to mark which blocks are - * complete documents rather than prose fragments, and guessing that boundary - * is what produces a gate people learn to ignore. + * shape 1. For a ts/tsx block it stays unanswered here. For a `json` / + * `jsonc` fence it is answered by the sibling gate + * `scripts/check-doc-json-fences.mjs` (objectui#5250), which judges each + * node document in `content/docs` through the strict authoring face and + * marks the deliberate fragments with a declaration it re-judges. * 2. **Whether a `type` literal names a registered component.** That is * `scripts/check-doc-component-types.mjs`, the first dimension, and it stays * its own gate: it needs no install and no build, and it must keep running @@ -3533,6 +3535,13 @@ export { TS_FENCE_LANGUAGES, FRAGMENT_MARKER, VERIFICATION_CLAIM, + // The blockquote reading and the declaration floor, shared with the JSON-fence + // sibling (`check-doc-json-fences.mjs`, objectui#5250) so a quoted fence and a + // fragment reason are read by ONE rule in both gates. + MIN_REASON_LENGTH, + quoteDepthOf, + stripQuotePrefix, + isBlankAtDepth, UNDECLARED_CONTROL_PACKAGE, ROOT_DECLARED_CONTROL_PACKAGE, main, From e090a2012243f5388c4f602b75f58ccbc4ba45d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:08:06 +0000 Subject: [PATCH 3/8] fix(scripts): retire the doc-type exemptions the fence repairs emptied (objectui#5250) The JSON-fence repairs removed every snippet eight DOC_TYPE_EXEMPTIONS entries pointed at (the `action` bag discriminants on five blocks pages, the layout-grid column type, and two fictional VS Code snippets), so check-doc-component-types reported them stale. Deleted with their sites. Also: the gate's test imports the face at module scope (the no-dynamic-import-in-test-hook rule), declares its one fixture address, and analyze() carries a JSDoc parameter type; the playground's form note says a form draws no heading rather than no title. Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- content/docs/guide/schema-playground.md | 2 +- .../__tests__/check-doc-json-fences.test.ts | 23 ++++------ scripts/check-doc-component-types.mjs | 45 +++++++------------ 3 files changed, 27 insertions(+), 43 deletions(-) diff --git a/content/docs/guide/schema-playground.md b/content/docs/guide/schema-playground.md index 9e2490938c..7f423f9235 100644 --- a/content/docs/guide/schema-playground.md +++ b/content/docs/guide/schema-playground.md @@ -124,7 +124,7 @@ A content card with a header, a description, and a `children` content list: A complete form with validation, driven entirely by schema: -A form draws no title of its own (put it in a `card` around the form), a select's `options` are +A form draws no heading of its own (put the title on a `card` around it), a select's `options` are `{ "label", "value" }` objects, and a field's starting value goes in the form's `defaultValues`: ```json diff --git a/scripts/__tests__/check-doc-json-fences.test.ts b/scripts/__tests__/check-doc-json-fences.test.ts index 27f61cda92..fa37f66bd8 100644 --- a/scripts/__tests__/check-doc-json-fences.test.ts +++ b/scripts/__tests__/check-doc-json-fences.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { afterAll, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -60,21 +60,15 @@ interface DoorModule { const FACE_SPECIFIER = '@object-ui/types/zod'; const DOOR_SPECIFIER = '../../packages/cli/src/utils/authoring-face.ts'; -let face: FaceModule; -let door: DoorModule; -let judge: (doc: unknown) => Verdict; -let tolerant: (doc: unknown) => Verdict; -let nodeTypes: Set; +// At module scope, not in a hook: the cold transform of the face belongs to the +// import phase, where no test or hook timeout applies (AGENTS.md section 9). +const face = (await import(/* @vite-ignore */ FACE_SPECIFIER)) as FaceModule; +const door = (await import(/* @vite-ignore */ DOOR_SPECIFIER)) as DoorModule; +const judge = (doc: unknown): Verdict => face.StrictAnyComponentSchema.safeParse(doc); +const tolerant = (doc: unknown): Verdict => face.AnyComponentSchema.safeParse(doc); +const nodeTypes: Set = nodeTypesOf(face.AnyComponentSchema); const parseJsonc = createRequire(path.join(repoRoot, 'packages/cli/package.json'))('jsonc-parser').parse; -beforeAll(async () => { - face = (await import(/* @vite-ignore */ FACE_SPECIFIER)) as FaceModule; - door = (await import(/* @vite-ignore */ DOOR_SPECIFIER)) as DoorModule; - judge = (doc) => face.StrictAnyComponentSchema.safeParse(doc); - tolerant = (doc) => face.AnyComponentSchema.safeParse(doc); - nodeTypes = nodeTypesOf(face.AnyComponentSchema); -}, 60_000); - const tempRoots: string[] = []; afterAll(() => { for (const root of tempRoots) fs.rmSync(root, { recursive: true, force: true }); @@ -170,6 +164,7 @@ describe('analyze — the verdicts, against the real face', () => { it('RED: refuses a node document carrying an undeclared key, and names the key', () => { const r = run({ 'content/docs/a.mdx': fence('json', { type: 'button', label: 'Go', action: { type: 'submit' } }) }); expect(reasons(r)).toEqual(['refused']); + // fixture-address: a page in the temp fixture tree this test writes, as the gate under test reports it expect(r.findings[0].site).toBe('content/docs/a.mdx:1'); expect(r.findings[0].detail).toMatch(/tolerant face accepts it/); expect(r.findings[0].issues).toEqual(['at (root): undeclared key "action"']); diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index caf2657f6d..edbf40e00e 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -761,11 +761,13 @@ const DOC_TYPE_EXEMPTIONS = { 'formatted by. Not a node type. objectui#10859 batch 8 retired the bare `date` field-widget ' + 'fallback, which is the only reason this value used to pass here as a registered key.', }, - 'content/docs/blocks/authentication.mdx': { - submit: - 'ActionSchema discriminant under a button\'s `action` key, not a node type. ' + - '`@object-ui/types` ActionSchema.', - }, + // `content/docs/blocks/{authentication,dashboard,ecommerce,forms,marketing}.mdx` + // each had one entry here — `submit` / `navigate` / `analytics`, the + // discriminant inside a `button` node's `action` bag. objectui#5250 slice B + // removed every one of those bags: `button` declares no `action` and nothing + // reads one, so the strict authoring face refused all five snippets, which now + // teach an `action:button` node instead (or, for `analytics`, nothing — no + // executor of that name exists). Deleted with their sites. // `content/docs/blocks/block-schema.mdx` had five entries here — `block`, // `block-instance`, `block-library`, `block-editor` (the four block-family // discriminants) and `string` (a `BlockVariable.type` data type) — plus a note @@ -774,18 +776,6 @@ const DOC_TYPE_EXEMPTIONS = { // (ADR-0049 enforce-or-remove, maintainer ruling 2026-09-02, option C1), so // every one of those entries would now fail as `stale-exemption`. Deleted with // their site rather than re-pointed — there is no page left to point at. - 'content/docs/blocks/dashboard.mdx': { - navigate: 'ActionSchema discriminant under a node\'s `action` key.', - }, - 'content/docs/blocks/ecommerce.mdx': { - submit: 'ActionSchema discriminant under a node\'s `action` key.', - }, - 'content/docs/blocks/forms.mdx': { - submit: 'ActionSchema discriminant under a node\'s `action` key.', - }, - 'content/docs/blocks/marketing.mdx': { - analytics: 'ActionSchema discriminant under a node\'s `action` key.', - }, 'content/docs/components/complex/filter-ui.mdx': { 'date-range': 'Filter control type — `packages/types/src/crud.ts:329` and `views.ts:804` declare it in the ' + @@ -1205,15 +1195,13 @@ const DOC_TYPE_EXEMPTIONS = { 'content/docs/guide/schema-playground.md': { reset: 'ActionSchema discriminant under a form\'s `actions[]`, alongside `submit` — an action ' + - 'definition in a list, not a rendered child. Same vocabulary as blocks/authentication.mdx.', + 'definition in a list, not a rendered child.', submit: 'ActionSchema discriminant under a form\'s `actions[]`, alongside `reset` — an action ' + - 'definition in a list, not a rendered child. Same vocabulary as blocks/authentication.mdx.', - date: - 'Column type under a `grid` node\'s `columns[]` — the field type a column renders its cells ' + - 'by (`ListColumnSchema.type`, @objectstack/spec/ui). Not a node type. objectui#10859 batch 8 ' + - 'retired the bare `date` field-widget fallback, which is the only reason this value used to ' + - 'pass here as a registered key.', + 'definition in a list, not a rendered child.', + // `date` stood here too — a column type under the layout `grid` that page + // taught as a data table. objectui#5250 slice B rewrote that snippet as the + // `data-table` it described, whose columns carry no `type`; deleted with its site. }, 'content/docs/guide/schema-rendering.md': { 'admin-panel': @@ -1308,10 +1296,11 @@ const DOC_TYPE_EXEMPTIONS = { 'your-component': 'Deliberate placeholder in the "load your own plugin" walkthrough — the reader registers it.', }, - 'content/docs/utilities/vscode-extension.mdx': { - ajax: 'ActionSchema discriminant under a form\'s `onSubmit`, not a node type.', - api: 'Data source kind under a node\'s `dataSource`, not a node type.', - }, + // `content/docs/utilities/vscode-extension.mdx` had two entries here — `ajax` + // (under a form's `onSubmit`) and `api` (under a `data-table`'s `dataSource`). + // Both sat in snippets the extension never shipped; objectui#5250 slice B + // rewrote that section from `packages/vscode-extension/snippets/objectui.json`. + // Deleted with their sites. // ── `packages/NAME/README.md` (objectui#8115) ──────────────────────────────── // From 3c0d3123bfb13e703ec13c8a459a1679473dc3eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:08:44 +0000 Subject: [PATCH 4/8] fix(scripts): the JSON-fence gate's summary says when its anchor went unread (objectui#5250) An ablation of the language filter exited 2 as designed, but the summary line above the precondition still printed "anchor ... read". It now reads NOT read whenever the anchor control is what failed. Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- scripts/check-doc-json-fences.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-doc-json-fences.mjs b/scripts/check-doc-json-fences.mjs index 012532924c..fe3a4cf7cb 100644 --- a/scripts/check-doc-json-fences.mjs +++ b/scripts/check-doc-json-fences.mjs @@ -607,7 +607,7 @@ export async function main(argv = process.argv.slice(2), root = repoRoot) { ` scan surface: every ${DOC_EXTENSIONS.join(' / ')} page under ${DOCS_ROOT}/ — ${r.documents.length} page(s), ${r.fenceCount} json/jsonc fence(s)`, ` judged node documents: ${r.judged.length} — ${r.accepted.length} accepted, ${r.declared.length} declared fragment(s) the face refuses as declared, ${r.refused.length} refused`, ` not judged: ${r.buckets.notJson.length} not JSON · ${r.buckets.notAnObject.length} not an object · ${r.buckets.noTypeKey.length} without a string \`type\` · ${r.buckets.unresolvedType.length} with a \`type\` no node schema declares${unresolvedTypes.length ? ` (${unresolvedTypes.join(', ')})` : ''}`, - ` controls: RESOLUTION, REFUSES, ACCEPTS hold over ${nodeTypes.size} node type(s); anchor ${ANCHOR_DOCUMENT} read`, + ` controls: RESOLUTION, REFUSES, ACCEPTS hold over ${nodeTypes.size} node type(s); anchor ${ANCHOR_DOCUMENT} ${r.preconditions.some((p) => p.reason === 'anchor-unread') ? 'NOT read' : 'read'}`, ` NOT read: pages outside ${DOCS_ROOT}/ (see this script's header) — a green run claims nothing about them`, ].join('\n'), ); From b21a21ee9b2ee54b40984005d502f38faa97ae27 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 07:36:34 +0000 Subject: [PATCH 5/8] test(scripts): adjudicate the JSON-fence gate's test in the markdown-input ledger (objectui#5250) `scripts/__tests__/markdown-test-inputs.test.ts` requires every test that reads markdown to be adjudicated, so a markdown-only pull request still starts the shard that runs it. The JSON-fence gate's agreement test walks `content/docs/**`; its `README.md` is a fixture-tree file, recorded as not read. Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- scripts/markdown-test-inputs.mjs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/scripts/markdown-test-inputs.mjs b/scripts/markdown-test-inputs.mjs index 99fed64fb9..251845d535 100644 --- a/scripts/markdown-test-inputs.mjs +++ b/scripts/markdown-test-inputs.mjs @@ -816,6 +816,18 @@ export const ADJUDICATED = new Map([ walker: 'markdown-tree', }, ], + [ + 'scripts/__tests__/check-doc-json-fences.test.ts', + { + // objectui#5250: the agreement test walks the JSON-fence gate's own scan + // surface, `content/docs/**` (the CI pipeline guide it also reads by name + // is inside it). Its `README.md` is a file the test writes into a + // temporary fixture tree, never the root document. + reads: ['content/docs/**'], + notRead: ['README.md'], + walker: 'markdown-tree', + }, + ], // `./*` rather than the eight root documents it used to list, and the // distinction is the whole of objectui#9142. This test does not read a list // of root documents -- it runs `git ls-files -- '*.md'`, keeps the paths with From 29d6bfab7b2bcf097797d80a7e7ec1ce036d4715 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:22:57 +0000 Subject: [PATCH 6/8] fix(types): mint the record-source refusal's path per issue, never share it (objectui#5250) `requireRecordSource` built its issue `path` once, when the arm was defined, and passed that same array to every `addIssue`. zod 4 copies an added issue shallowly and prefixes its `path` in place (`unshift`) as the issue climbs out of an array slot or object key, so the first refusal reported from inside a container rewrote the path of every later refusal of that arm, for the life of the module. `objectui check` judges many files in one process and read the drift; so did the `unit` vitest project (`isolate: false`): after the JSON-fence gate's agreement test ran the docs corpus through the strict face, a bare `object-kanban` member parse reported its root refusal at `0 -> 0 -> 0 -> 0`, which dequeued PR #12127 from the merge queue. The issue now carries a fresh copy. A pin refuses each gate-wrapped arm from inside a parent's child list and then bare, and asserts the bare path did not move (red on four arms before this change). Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- ...ata-source-objectname-waiver-11117.test.ts | 31 +++++++++++++++++++ packages/types/src/zod/objectql.zod.ts | 7 ++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/types/src/__tests__/element-data-source-objectname-waiver-11117.test.ts b/packages/types/src/__tests__/element-data-source-objectname-waiver-11117.test.ts index 0282f5785b..b60d792550 100644 --- a/packages/types/src/__tests__/element-data-source-objectname-waiver-11117.test.ts +++ b/packages/types/src/__tests__/element-data-source-objectname-waiver-11117.test.ts @@ -221,3 +221,34 @@ describe('objectui#11117 — every documented per-element binding parses on the expect(refused).toEqual([]); }); }); + +/** + * objectui#5250 — a refusal's `path` is minted per issue, never shared. + * + * zod 4 copies an added issue SHALLOWLY (`util.issue` spreads it) and then + * prefixes its `path` IN PLACE (`util.prefixIssues`: `iss.path.unshift(segment)`) + * as the issue climbs out of an array slot or an object key. So an `addIssue` + * that hands over an array it keeps — built once, when the arm was defined — + * has that array rewritten by the first refusal reported from inside a + * container, and every later refusal of the arm reports the rewritten path, for + * the life of the module. `objectui check` judges many files in one process, + * and the `unit` project shares one module graph per worker (`isolate: false`), + * so both read the drift: measured, after the docs corpus went through the + * strict face, a bare `object-kanban` member parse reported its ROOT refusal at + * `0 → 0 → 0 → 0`. + * + * The probe refuses each arm from inside a parent's child list, then refuses it + * bare again: the bare path must not have moved. + */ +describe('objectui#5250 — a refusal reported from inside a container leaves the next one where it was', () => { + it.each(ARMS)('%s: the bare refusal path is unchanged by a nested refusal', (type, member) => { + const before = recordSourceIssues(member.safeParse({ type }) as Parsed).map((i) => i.path); + const nested = safeValidateSchema({ type: 'card', title: 'Records', children: [{ type }] }) as Parsed; + const nestedPaths = recordSourceIssues(nested).map((i) => i.path); + expect(nestedPaths.length, `the nested node must be refused for this probe to mean anything: ${explain(nested)}`).toBeGreaterThan(0); + for (const p of nestedPaths) expect(p.slice(0, 2), explain(nested)).toEqual(['children', 0]); + const after = recordSourceIssues(member.safeParse({ type }) as Parsed).map((i) => i.path); + expect(before).toEqual([REFUSAL_PATH[type]]); + expect(after).toEqual([REFUSAL_PATH[type]]); + }); +}); diff --git a/packages/types/src/zod/objectql.zod.ts b/packages/types/src/zod/objectql.zod.ts index d8177abb34..7d817a225a 100644 --- a/packages/types/src/zod/objectql.zod.ts +++ b/packages/types/src/zod/objectql.zod.ts @@ -2690,7 +2690,12 @@ function requireRecordSource( if (bindingRung === 'counts' && dataSourceSuppliesObject(node)) return; ctx.addIssue({ code: 'custom', - path, + // A FRESH array per issue (objectui#5250). zod copies an added issue + // shallowly and then prefixes its `path` in place as the issue climbs out + // of an array slot or an object key, so handing over `path` itself let the + // first refusal reported from inside a container rewrite this arm's path + // for every refusal after it, for the life of the module. + path: [...path], params: { code: 'RECORD_SOURCE_REQUIRED' }, message, }); From 4ff99894f470f2533adc5a1c9ae828b650b2908d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:23:35 +0000 Subject: [PATCH 7/8] test(scripts): the gate/CLI agreement test also runs the strict-only control through the door (objectui#5250) Every fence the docs corpus refuses is refused by the tolerant face too, so the corpus alone could not tell the strict door from a tolerant one: an ablation that re-pointed `validateAuthoredDocument` at `AnyComponentSchema` stayed green. The REFUSES and ACCEPTS controls now ride with the corpus in the agreement set. Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- scripts/__tests__/check-doc-json-fences.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/scripts/__tests__/check-doc-json-fences.test.ts b/scripts/__tests__/check-doc-json-fences.test.ts index fa37f66bd8..ebb4d808fb 100644 --- a/scripts/__tests__/check-doc-json-fences.test.ts +++ b/scripts/__tests__/check-doc-json-fences.test.ts @@ -251,8 +251,18 @@ describe('one authority — the CLI door and the gate agree on every judged docu const r = analyze({ root: repoRoot, judge, tolerant, nodeTypes, parseJsonc }); const judged = r.judged as { site: string; value: unknown }[]; expect(judged.length, 'the corpus walk judged nothing — the agreement below would be vacuous').toBeGreaterThan(0); - const gate = judged.map((j) => `${j.site} ${judge(j.value).success}`); - const cli = judged.map((j) => `${j.site} ${door.validateAuthoredDocument(j.value).success}`); + // The two controls ride with the corpus. Every fence the corpus refuses is + // refused by the TOLERANT face as well (declared fragments, each red on both + // faces), so the corpus alone cannot tell the strict door from a tolerant + // one — measured: a door re-pointed at `AnyComponentSchema` stayed green + // over the corpus. The REFUSES control is strict-only by construction. + const population = [ + ...judged, + { site: 'REFUSED_CONTROL', value: REFUSED_CONTROL }, + { site: 'ACCEPTED_CONTROL', value: ACCEPTED_CONTROL }, + ]; + const gate = population.map((j) => `${j.site} ${judge(j.value).success}`); + const cli = population.map((j) => `${j.site} ${door.validateAuthoredDocument(j.value).success}`); expect(cli).toEqual(gate); // Both verdicts occur, so the agreement is not over one constant answer. expect(new Set(judged.map((j) => judge(j.value).success))).toEqual(new Set([true, false])); From cd5e0d0c25d52bb2127db80e71cce2c594443ac5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 08:24:16 +0000 Subject: [PATCH 8/8] chore(changeset): @object-ui/types patch for the record-source refusal path (objectui#5250) Claude-Session: https://claude.ai/code/session_01DwLS3LzXyNmyTunMGbpc8W Co-authored-by: Claude --- .changeset/5250-record-source-issue-path.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/5250-record-source-issue-path.md diff --git a/.changeset/5250-record-source-issue-path.md b/.changeset/5250-record-source-issue-path.md new file mode 100644 index 0000000000..c936e10624 --- /dev/null +++ b/.changeset/5250-record-source-issue-path.md @@ -0,0 +1,7 @@ +--- +'@object-ui/types': patch +--- + +A record-source refusal (`RECORD_SOURCE_REQUIRED`, raised by every arm that requires a record source) now reports the same path every time (objectui#5250). + +The refusal's `path` was one array, built when the arm was defined and handed to every issue. zod copies an added issue shallowly and prefixes its `path` in place as the issue climbs out of an array slot or an object key, so the first refusal reported from inside a container rewrote the path of every later refusal of that arm for as long as the module stayed loaded. A process that judges many documents saw the drift: `objectui check` over a project, or `objectui validate` called repeatedly in one process, could report a root refusal at a path like `0 → 0 → 0 → 0`. Each issue now carries its own copy. The refusal, its message and its code are unchanged.