Skip to content

Commit 9547063

Browse files
fix(types): a bind-only list is accepted; ListSchema.items is optional with an at-least-one-of bind / items refinement (objectui#11405) (#11407)
Fixes #11405 Clause-②: yes — the `list` arm of `@object-ui/types` only widens. A bind-only `list` goes from refused to accepted; a `list` with neither key was refused before (at `items`) and is refused now (at the node). Nothing went from accepted to refused. Dispatched run for the `domain:spec` seat (objectui#10217), session `https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT`, claim comment 5942476028, triage ruling 5941529511. Draft, left for the seat; the at-tier contract-review record is owed on the landing head before enqueue. ## What changes - **zod face** (`packages/types/src/zod/data-display.zod.ts`): `ListSchema.items` is `.optional()`, and the arm ends in `.superRefine(listHasAnEntrySource, …)` with a `when` that always answers true, so the rule is reported beside any other issue on the node. A node with neither `bind` nor `items` is refused once, at the node: `code: custom`, `params.code: LIST_ENTRIES_REQUIRED`, a message naming both keys. The rule is at least one, not exactly one, because `items` is the renderer's fallback beside `bind`. Presence is `!== undefined`, as in `requireRecordSource`. The strict face carries the check, because its clone keeps `def.checks`. - **TS face** (`packages/types/src/data-display.ts`): `items?: ListItem[]`. The docblock names the read site and the mirror refinement, in the shape `ObjectKanbanSchema.objectName` uses (objectui#7780). `bind` stays declared once, on `BaseSchema`. - **Pin**: `packages/types/src/__tests__/list-bind-only-11405.test.ts`. - **Docs** (AGENTS.md #2): `content/docs/components/data-display/list.mdx` documents `bind` / optional `items` and the at-least-one rule. - **Changeset**: `.changeset/11405-list-bind-only.md`, `@object-ui/types` minor. - **Ledger row**: `scripts/markdown-test-inputs.mjs` gains one ADJUDICATED row for the new pin. The pin reads the two guides, and the ledger audit (`scripts/__tests__/markdown-test-inputs.test.ts`) fails on an unregistered reader. This row is outside the claim's declared file surface; see Deviations. ⛔ Not edited: `skills/**` (the four fences become valid as they stand), `list.tsx`, and every other arm. ## Zone 2 measurements | | hypothesis | reading | |---|---|---| | H1 | the faces, and the refusal | Confirmed on base `7728c67c8` (rebuilt dist). `{ type: list, bind: customerNames }` is refused with `invalid_type` at `items` by the arm, `safeValidateSchema` and `StrictAnyComponentSchema`. A `list` with neither key was refused the same way, at `items`. | | H2 | the refinement in the union | `superRefine` on the arm keeps it a valid `DataDisplaySchema` / `AnyComponentSchema` member: the module loads, `DataDisplaySchema._zod.propValues.type` still holds `list`, and a wrong literal is still `invalid_union` at `type`. The issue lands at the node: `[]` at the root, `children.0` when nested. Precedent: `requireRecordSource` already sits on discriminated arms. **TS union spelling, measured with a probe**: it refuses a literal with neither key, but the TS2322 names only `bind` as missing. The reader's type for `schema.items` is identical to the optional member's (`ListItem[]` or undefined). An interface can no longer extend it (TS2312), and a type alias drops declaration merging. So it was not taken: no reader gains, and three precedent arms (`object-kanban`, `object-map`, `object-tree`) keep the optional member plus a mirror refinement. | | H3 | readers of `ListSchema['items']` | Type-checker census over one program from the repo-root tsconfig (5787 files): one read site, `list.tsx`'s `Array.isArray(schema.items) ? schema.items : []`, guarded. `@object-ui/components` type-check is green against the rebuilt types. Nothing to retype. | | H4 | the registration | `list.tsx`'s `items` input carries no `required`. The console parity guard has no `list` row, and `check:component-surface-parity` compares key sets only. The registration is left alone. | | H5 | the fences | Read off disk with `scanSkillFences` (the marker gate's own reader) and judged through `safeValidateSchema` and the strict face. Four marked fences author a bind-only `list`. The three taught as valid pass. The fence the guide marks as wrong is refused only at `children`, never at `items` and never on the new rule. | | H6 | parity | Both faces move together, so no `zod-mirror-parity` row opens; that file is untouched. Ablation M3 below shows the TS face moving alone reddens the parity ratchet. | ## Verification (landing head `8b317ec61`) - `pnpm --filter @object-ui/types build`: exit 0. `pnpm --filter @object-ui/types type-check` (three legs, including `tsc -p tsconfig.test.json`, whose `--listFilesOnly` includes the new pin): exit 0. - `pnpm exec vitest run packages/types/`: `Test Files 321 passed (321)`, `Tests 8560 passed (8560)`. - `pnpm --filter '@object-ui/components^...' build`, then `pnpm --filter @object-ui/components type-check`: exit 0. - Consumer and ledger suites, 15 files: the components list and bind tests (`list-title-declared-11347`, `list-item-content-channel-9590`, `shadowed-renderer-behaviour`, `bind-base-prop-parser-tier-11008`, `skill-guide-data-table-binding`), `markdown-test-inputs`, `check-skill-examples`, and the changeset-script suites that name the ledger. Result: `Test Files 15 passed (15)`, `Tests 494 passed (494)`. - Gates, each exit 0: `check:control-bytes`, `check:new-line-citations`, `check:test-path-roots`, `check:vi-mock-specifiers`, `check:phantom-deps`, `check:self-import`, `check:unreferenced-sources`, `check:spec-symbols`, `check:changeset-claims`, `check:pending-changeset-literals`, `check-changeset-no-major`, `check-changeset-presence`, `check:doc-types`, `check:doc-fences`, `check:doc-example-ids`, `check-doc-links`, `check-doc-expression-carriage`, `check:doc-example-readers`, `check:component-surface-parity --type list` (report-only), `check:prompt-keys`, `check:pre-install-import-graph`, `check-lint-coverage`, `check-type-check-coverage`, `markdown-test-inputs --audit`, and `check-governed-queue-guard --test` over the six changed paths (NOT GOVERNED). - **Narrowed, declared**: `check:doc-snippets` and `check:doc-examples` need the whole-workspace build (`check:doc-examples` exited 2, could-not-run). In their place, the `list.mdx` ts fence was compiled `--strict` against the rebuilt `@object-ui/types` dist (exit 0), with a failing control in the same harness (TS2322, exit 2). CI builds their closure. - **Lint, narrowed and proven**: the population is read from eslint's own config. Four changed files are linted: `data-display.zod.ts`, `data-display.ts`, the pin, and `markdown-test-inputs.mjs`. The `.mdx` and the changeset are reported as "no matching configuration". The count of 4 comes from `--format json`. Result: 0 errors. `data-display.ts` has 30 `no-explicit-any` warnings, the same 30 as its base blob. `eslint.config.js` enables no type-aware linting (no `parserOptions.project` / `projectService`), so this diff cannot move any untouched file's verdict. ## Ablation (fix committed first, each leg through `ablation-replace`, restored from HEAD and proven blob-equal) - **M1**, zod `items` required again: 11 failed, 1 passed of 12. Only the counter-probe stays green, which is correct. - **M2**, refinement removed: 5 failed, 7 passed. The five are the "neither" rows and the nested and beside-another-fault rows; bind-only stays green. ⚠️ The first M2 attempt was a no-op: the replacement count did not rise, so the tool refused it before the command ran. It was re-run with a delete-mode anchor. - **M3**, TS `items` required again: `tsc -p tsconfig.test.json` exit 2. TS2741 at the pin's bind-only literal, plus the `zod-mirror-parity` ratchet (TS2322 on `data-display.zod.ts#ListSchema` and `items`). - **Control**, unmutated HEAD: 12 of 12 passed, tsc exit 0. ## Acceptance notes - The TypeScript face cannot spell "at least one": a literal with neither key still compiles. The validator enforces the rule, as for `object-kanban`. - `content/docs/guide/expressions.md` ("Scoped Data", "Index in Loops") teaches a `list` with `items` as a string, an `itemTemplate` and per-item `item` / `index` expressions. The published `skills/objectui` expressions guide refutes that shape. `safeValidateSchema` refuses it at `items`, and the strict face adds `unrecognized_keys` for `itemTemplate`. `check-doc-expression-carriage` already lists both sites, and its header points to that family's open cards. Noted, not filed. Untouched here. - `tree-view` is the other `useDataScope` reader in `@object-ui/components`. Measured: it already accepts a bind-only node, so the same defect is not present there. ## Deviations - `scripts/markdown-test-inputs.mjs`: one ledger row, outside the claim's file surface. It is required because the claimed pin reads the guides off disk, and `markdown-test-inputs.test.ts` fails on an unregistered reader. The script's own suite and its importers ran green (above). --- _Generated by [Claude Code](https://claude.ai/code/session_01VhxTqosz7wn54ahqyxgERT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 138ad45 commit 9547063

6 files changed

Lines changed: 291 additions & 6 deletions

File tree

‎.changeset/11405-list-bind-only.md‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
A bind-only `list` is accepted: `ListSchema.items` is optional on both faces, and the zod face requires at least one of `bind` / `items` (objectui#11405).
6+
7+
The `list` renderer reads `useDataScope(schema.bind)` first and falls back to `schema.items` when the bound value is not an array. So a `list` that carries only `bind` draws one entry per element of the bound array. Both published faces nevertheless required `items`: `safeValidateSchema`, the function `objectui validate` calls, refused `{ "type": "list", "bind": "customerNames" }` with `invalid_type` at `items`, the strict face agreed, and the TypeScript face refused the same literal. The published `skills/objectui` guides author that shape in four marked examples.
8+
9+
The accept set moves in one direction only, measured through the arm, `safeValidateSchema` and the strict face against a rebuilt `dist`:
10+
11+
| document | before | after |
12+
| --- | --- | --- |
13+
| `bind` only | refused at `items` (`invalid_type`) | **accepted** |
14+
| `items` only | accepted | accepted |
15+
| both | accepted | accepted |
16+
| neither | refused at `items` (`invalid_type`) | refused at the node (`custom`, `params.code` = `LIST_ENTRIES_REQUIRED`) |
17+
18+
Nothing went from accepted to refused. The rule is at least one and not exactly one, because `items` is the renderer's fallback beside `bind`. Presence is `!== undefined`, as in the record-source refinement the `object-*` arms carry, so `bind: ''` and `items: []` each count. The node-level issue is reported beside any other issue on the node, so a `list` with neither key and a bad `ordered` reports both.
19+
20+
On the TypeScript face `items?: ListItem[]` is optional, so a reader of `ListSchema['items']` now sees `ListItem[] | undefined`. The only reader in this repository, the `list` renderer, already guards the read with `Array.isArray`. The TypeScript face cannot express the at-least-one rule, so a literal with neither key still compiles; the validator enforces it.
21+
22+
Marked `minor` per this repo's version-alignment rule.

‎content/docs/components/data-display/list.mdx‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,30 @@ import type { SchemaNode } from '@object-ui/types';
1515
interface ListSchema {
1616
type: 'list';
1717
title?: string; // heading drawn above the list
18-
items: SchemaNode[];
18+
bind?: string; // path to an array in the host scope, read first
19+
items?: SchemaNode[]; // drawn when `bind` resolves no array
1920
ordered?: boolean;
2021
className?: string;
2122
wrapperClass?: string;
2223
}
2324
```
2425

26+
A `list` needs at least one of `bind` and `items`. The renderer reads `bind` through
27+
`useDataScope` first and falls back to `items` when the bound value is not an array, so
28+
a node may carry both. A bind-only list draws one entry per element of the bound array:
29+
30+
```json
31+
{
32+
"type": "list",
33+
"bind": "customerNames"
34+
}
35+
```
36+
37+
A `list` with neither key is refused at the node by `safeValidateSchema` and the strict
38+
face, with `params.code` set to `LIST_ENTRIES_REQUIRED`. Until objectui#11405 `items` was
39+
required, so the bind-only list above was refused at `items`. The TypeScript face declares
40+
`items` optional and cannot express the at-least-one rule; the validator enforces it.
41+
2542
<Callout type="warn">
2643
`wrapperClass` is now typed. The renderer has read it all along — it lands on the
2744
wrapper element around the list — but it went undeclared until objectui#7722,
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#11405 — a bind-only `list` is accepted: `ListSchema.items` is
11+
* optional on both published faces, and the zod face requires AT LEAST ONE of
12+
* `bind` / `items`.
13+
*
14+
* ## The defect
15+
*
16+
* `list.tsx` reads `useDataScope(schema.bind)` first and falls back to
17+
* `schema.items` when the bound value is not an array, so a `list` carrying only
18+
* `bind` draws one entry per element of the bound array. Both published faces
19+
* nevertheless REQUIRED `items`: `safeValidateSchema({ type: 'list', bind:
20+
* 'customerNames' })` was refused with `invalid_type` at `items`, the strict face
21+
* agreed, and the TypeScript face refused the same literal (`TS2741`). Four
22+
* marked fences in two published guides author exactly that shape, and their
23+
* marker only checks that they parse, so nothing noticed.
24+
*
25+
* Triage's ruling (comment 5941529511): the arm follows the renderer, and the
26+
* guides are not edited. The rule is at least one and ⛔ not exactly one,
27+
* because `items` is the renderer's real fallback beside `bind`.
28+
*
29+
* ## What this file pins
30+
*
31+
* - the zod face, through the arm itself, through `safeValidateSchema` (the
32+
* function `objectui validate` calls) and through the strict face: a bind-only
33+
* `list`, an items-only one and one carrying both are accepted, and one with
34+
* neither is refused ONCE, at the node, keyed `LIST_ENTRIES_REQUIRED`;
35+
* - the TypeScript face: the bind-only literal compiles. That half is judged by
36+
* `tsc -p tsconfig.test.json` (`pnpm --filter @object-ui/types type-check`),
37+
* not by vitest, which strips types. The TypeScript face cannot spell "at
38+
* least one" with an optional member, so a literal with neither key still
39+
* compiles; the rule lives on the mirror, as it does for `object-kanban`
40+
* (objectui#7780);
41+
* - the guides: every marked fence that authors a bind-only `list` is READ OFF
42+
* DISK with `scanSkillFences`, the reader the marker's own gate uses, and fed
43+
* to `safeValidateSchema`. A copy here would not notice a guide change. A fence
44+
* the guide itself marks ❌ is wrong for another reason (its `children` and
45+
* its `${item.name}`), so for it this file pins only that `items` is no longer
46+
* what refuses it.
47+
*/
48+
49+
import { describe, it, expect } from 'vitest';
50+
import { readFileSync } from 'node:fs';
51+
import { dirname, resolve } from 'node:path';
52+
import { fileURLToPath } from 'node:url';
53+
// @ts-expect-error -- plain-JS shared helper, intentionally untyped (`allowJs: false`)
54+
import { scanSkillFences as scanUntyped, stripJsonComments as stripUntyped } from '../../../../scripts/check-skill-examples.mjs';
55+
import { ListSchema, safeValidateSchema, StrictAnyComponentSchema } from '../zod/index.zod';
56+
import type { ListSchema as TsListSchema } from '../data-display';
57+
58+
/** Local annotations, since the import above is untyped — the call sites stay checked. */
59+
type ScannedFence = { kind: 'ts' | 'json'; language: string; fenceLine: number; body: string; marked: boolean };
60+
const scanSkillFences: (source: string) => { fences: ScannedFence[]; orphans: number[] } = scanUntyped;
61+
const stripJsonComments: (source: string) => string = stripUntyped;
62+
63+
/** Rooted at THIS file, never at `process.cwd()`. */
64+
const HERE = dirname(fileURLToPath(import.meta.url));
65+
const REPO_ROOT = resolve(HERE, '..', '..', '..', '..');
66+
67+
const GUIDES = [
68+
'skills/objectui/guides/data-integration.md',
69+
'skills/objectui/guides/schema-expressions.md',
70+
] as const;
71+
72+
/* ── The TypeScript face (judged by `tsc -p tsconfig.test.json`) ── */
73+
74+
// Refused with TS2741 (`items` is missing) before objectui#11405.
75+
const BIND_ONLY: TsListSchema = { type: 'list', bind: 'customerNames' };
76+
const ITEMS_ONLY: TsListSchema = { type: 'list', items: [{ content: 'Ada' }] };
77+
const BOTH: TsListSchema = { type: 'list', bind: 'rows', items: [{ content: 'Ada' }] };
78+
79+
type Issue = { code: string; path: PropertyKey[]; params?: Record<string, unknown> };
80+
81+
/** The issues a parse raised, or `[]` when it passed. */
82+
function issuesOf(result: { success: boolean; error?: { issues: readonly unknown[] } }): Issue[] {
83+
return result.success ? [] : ((result.error?.issues ?? []) as Issue[]);
84+
}
85+
86+
/** Every face a node goes through: the arm, the published validator, the strict face. */
87+
const FACES = {
88+
arm: (node: unknown) => ListSchema.safeParse(node),
89+
safeValidateSchema: (node: unknown) => safeValidateSchema(node),
90+
strict: (node: unknown) => StrictAnyComponentSchema.safeParse(node),
91+
} as const;
92+
93+
describe('a `list` needs at least one of `bind` / `items` (objectui#11405)', () => {
94+
for (const [face, parse] of Object.entries(FACES)) {
95+
it(`${face}: bind-only, items-only and both are accepted`, () => {
96+
for (const node of [BIND_ONLY, ITEMS_ONLY, BOTH]) {
97+
expect(issuesOf(parse(node)), JSON.stringify(node)).toEqual([]);
98+
}
99+
});
100+
101+
it(`${face}: a list with neither is refused once, at the node, keyed LIST_ENTRIES_REQUIRED`, () => {
102+
const issues = issuesOf(parse({ type: 'list', ordered: true }));
103+
expect(issues).toHaveLength(1);
104+
expect(issues[0]).toMatchObject({ code: 'custom', path: [], params: { code: 'LIST_ENTRIES_REQUIRED' } });
105+
});
106+
}
107+
108+
it('the rule is reported BESIDE an unrelated fault on the same node, not hidden by it', () => {
109+
const issues = issuesOf(safeValidateSchema({ type: 'list', ordered: 'yes' }));
110+
expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([
111+
['invalid_type', 'ordered'],
112+
['custom', ''],
113+
]);
114+
});
115+
116+
it('a `bind` that fails its own type is refused at `bind`, and the rule does not fire twice', () => {
117+
const issues = issuesOf(safeValidateSchema({ type: 'list', bind: 42 }));
118+
expect(issues.map((i) => [i.code, i.path.join('.')])).toEqual([['invalid_type', 'bind']]);
119+
});
120+
121+
it('a nested list with neither is refused at its own path', () => {
122+
const issues = issuesOf(safeValidateSchema({ type: 'div', children: [{ type: 'list' }] }));
123+
expect(issues).toHaveLength(1);
124+
expect(issues[0]).toMatchObject({ code: 'custom', path: ['children', 0], params: { code: 'LIST_ENTRIES_REQUIRED' } });
125+
});
126+
});
127+
128+
/* ── The guides' marked fences, read off disk ── */
129+
130+
type Fence = { guide: string; fenceLine: number; teachesWrong: boolean; node: Record<string, unknown> };
131+
132+
/** Every marked json/jsonc fence in the two guides whose root is a `list` with `bind` and no `items`. */
133+
function bindOnlyListFences(): Fence[] {
134+
const out: Fence[] = [];
135+
for (const guide of GUIDES) {
136+
const { fences } = scanSkillFences(readFileSync(resolve(REPO_ROOT, guide), 'utf8'));
137+
for (const fence of fences) {
138+
if (!fence.marked || fence.kind !== 'json') continue;
139+
// The marker's own gate already requires every marked fence to parse.
140+
const node = JSON.parse(fence.language === 'jsonc' ? stripJsonComments(fence.body) : fence.body);
141+
if (!node || typeof node !== 'object' || node.type !== 'list') continue;
142+
if (node.bind === undefined || node.items !== undefined) continue;
143+
out.push({ guide, fenceLine: fence.fenceLine, teachesWrong: fence.body.includes('❌'), node });
144+
}
145+
}
146+
return out;
147+
}
148+
149+
describe('the guides\' bind-only `list` fences (objectui#11405)', () => {
150+
const fences = bindOnlyListFences();
151+
152+
it('COUNTER-PROBE: the reader reaches a bind-only list in each guide, and the ❌ example among them', () => {
153+
for (const guide of GUIDES) {
154+
expect(fences.filter((f) => f.guide === guide).length, guide).toBeGreaterThan(0);
155+
}
156+
expect(fences.some((f) => f.teachesWrong)).toBe(true);
157+
});
158+
159+
it('every fence taught as valid passes safeValidateSchema and the strict face', () => {
160+
const taught = fences.filter((f) => !f.teachesWrong);
161+
expect(taught.length).toBeGreaterThan(0);
162+
for (const { guide, fenceLine, node } of taught) {
163+
const where = `${guide}, fence opened on its line ${fenceLine}`;
164+
expect(issuesOf(safeValidateSchema(node)), where).toEqual([]);
165+
expect(issuesOf(StrictAnyComponentSchema.safeParse(node)), where).toEqual([]);
166+
}
167+
});
168+
169+
it('the ❌ example is still refused, and never for a missing `items`', () => {
170+
for (const { guide, fenceLine, node, teachesWrong } of fences) {
171+
if (!teachesWrong) continue;
172+
const where = `${guide}, fence opened on its line ${fenceLine}`;
173+
const issues = issuesOf(safeValidateSchema(node));
174+
expect(issues.length, where).toBeGreaterThan(0);
175+
expect(issues.filter((i) => i.path[0] === 'items'), where).toEqual([]);
176+
expect(issues.filter((i) => i.params?.code === 'LIST_ENTRIES_REQUIRED'), where).toEqual([]);
177+
}
178+
});
179+
});

‎packages/types/src/data-display.ts‎

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,9 +299,24 @@ export interface ListSchema extends BaseSchema {
299299
*/
300300
title?: string;
301301
/**
302-
* List items
303-
*/
304-
items: ListItem[];
302+
* List items — the entries drawn when {@link BaseSchema.bind} resolves no
303+
* array.
304+
*
305+
* READ SITE: `packages/components/src/renderers/data-display/list.tsx`, which
306+
* reads `useDataScope(schema.bind)` FIRST and falls back to this member only
307+
* when the bound value is not an array (`Array.isArray(schema.items)`, so the
308+
* read is guarded).
309+
*
310+
* Optional since objectui#11405. It was REQUIRED, so a bind-only `list` —
311+
* which draws one entry per element of the bound array — was refused by both
312+
* published faces and could not be annotated with its own type. The
313+
* requirement the renderer really has, at least one of `bind` / `items`
314+
* present (⛔ not exactly one: `items` is the fallback beside `bind`), lives
315+
* on the mirror as a refinement (`listHasAnEntrySource` in
316+
* `zod/data-display.zod.ts`, keyed `LIST_ENTRIES_REQUIRED`), so the published
317+
* declaration and the published validator say the same thing.
318+
*/
319+
items?: ListItem[];
305320
/**
306321
* Whether list is ordered
307322
* @default false

‎packages/types/src/zod/data-display.zod.ts‎

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,14 +180,53 @@ export const ListItemSchema = z.object({
180180
),
181181
});
182182

183+
/**
184+
* A `list` draws its entries from `bind` or from `items`, so it needs at least
185+
* one of the two (objectui#11405).
186+
*
187+
* `list.tsx` reads `useDataScope(schema.bind)` FIRST and falls back to
188+
* `schema.items` when the bound value is not an array, so both keys are live
189+
* inputs and `items` is the real fallback beside `bind`. That is why the rule is
190+
* AT LEAST one and ⛔ not exactly one: a node carrying both is the fallback
191+
* shape, and refusing it would refuse a document the renderer draws. Until
192+
* objectui#11405 `items` was REQUIRED here, so a bind-only `list` — the shape the
193+
* renderer and this arm's own tombstone text name, and the one the published
194+
* `skills/objectui` guides author — was refused with `invalid_type` at `items`.
195+
*
196+
* Presence is `!== undefined`, the wording `requireRecordSource` in
197+
* `./objectql.zod.ts` uses for its own at-least-one rule, so `bind: ''` and
198+
* `items: []` each count. The issue sits at the ROOT, because no single key is
199+
* at fault when both are missing, and it carries `params.code` so a consumer
200+
* can tell this refusal from the others without parsing the message.
201+
*
202+
* Installed with `when: () => true`, for the reason `requireRecordSource`
203+
* gives: zod skips a refinement once an earlier issue aborts the parse, and the
204+
* required member this replaces was reported BESIDE every other issue on the
205+
* node. So the body reads the raw input defensively.
206+
*/
207+
function listHasAnEntrySource(node: unknown, ctx: z.core.$RefinementCtx): void {
208+
if (!node || typeof node !== 'object' || Array.isArray(node)) return;
209+
// Read as a plain record: `bind` stays declared once, on `BaseSchema` (objectui#6357).
210+
const keys = node as Record<string, unknown>;
211+
if (keys.bind !== undefined || keys.items !== undefined) return;
212+
ctx.addIssue({
213+
code: 'custom',
214+
path: [],
215+
params: { code: 'LIST_ENTRIES_REQUIRED' },
216+
message: '`list` has no entries to draw: declare `items`, or `bind` a path to an array in the host scope',
217+
});
218+
}
219+
183220
/**
184221
* List Schema - List component
185222
*/
186223
export const ListSchema = BaseSchema.extend({
187224
type: z.literal('list'),
188225
title: z.string().optional()
189226
.describe('Heading drawn above the list (objectui#11347)'),
190-
items: z.array(ListItemSchema).describe('List items'),
227+
// Optional since objectui#11405: a bind-only `list` draws its entries from the
228+
// host scope. The at-least-one rule is `listHasAnEntrySource` above.
229+
items: z.array(ListItemSchema).optional().describe('List items'),
191230
ordered: z.boolean().optional().describe('Whether list is ordered'),
192231
dividers: z.boolean().optional().describe('Show dividers between items'),
193232
dense: z.boolean().optional().describe('Dense spacing'),
@@ -217,7 +256,7 @@ export const ListSchema = BaseSchema.extend({
217256
+ '`ui:list` is the measured SOLE owner of the bare `list` key (`view:list` passes `skipFallback: true`); '
218257
+ 're-derive with `pnpm check:registry-bare-names --table` (objectui#9264).',
219258
),
220-
});
259+
}).superRefine(listHasAnEntrySource, { when: () => true });
221260

222261
/**
223262
* Table Column Schema

‎scripts/markdown-test-inputs.mjs‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,19 @@ export const ADJUDICATED = new Map([
492492
'packages/types/src/__tests__/held-public-block-arms-10872.test.ts',
493493
{ reads: ['content/docs/guide/quick-start.md'] },
494494
],
495+
// objectui#11405. Reads every marked fence in the two guides that authors a
496+
// bind-only `list`, with the marker gate's own `scanSkillFences`, and judges it
497+
// through `safeValidateSchema` and the strict face -- so an edit to either guide
498+
// is an edit to this test's input.
499+
[
500+
'packages/types/src/__tests__/list-bind-only-11405.test.ts',
501+
{
502+
reads: [
503+
'skills/objectui/guides/data-integration.md',
504+
'skills/objectui/guides/schema-expressions.md',
505+
],
506+
},
507+
],
495508
[
496509
'packages/types/src/__tests__/object-calendar-record-source-7313.test.ts',
497510
{ reads: ['content/docs/plugins/plugin-calendar.mdx'] },

0 commit comments

Comments
 (0)