@object-ui/sdui-parser 17.6.0 → 18.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,9 +2,123 @@
2
2
  * ObjectUI — SDUI tree validation against the registry manifest (ADR-0080 §3/§6)
3
3
  *
4
4
  * Shallow, author-time validation: unknown component, unknown/missing prop,
5
- * wrong coarse type, illegal enum value. Collects `requires` (plugin provenance)
6
- * and binding sites the SERVER must resolve against object schema (we cannot
7
- * resolve objects/fields here — that check is framework-side by design).
5
+ * wrong coarse type, illegal enum value, and containment — a child list under
6
+ * a component whose registration declares no `children` input draws
7
+ * `not-a-container` (objectui#9910; see `acceptsChildren`). Collects
8
+ * `requires` (plugin provenance) and binding sites the SERVER must resolve
9
+ * against object schema (we cannot resolve objects/fields here — that check is
10
+ * framework-side by design).
11
+ *
12
+ * The walk descends `children` on every node and, for a component the
13
+ * manifest knows, the NODE SLOTS its entry carries (`ManifestComponent.slots`,
14
+ * objectui#11170) — the positions besides `children` through which that
15
+ * renderer hands nodes back to `SchemaRenderer`, projected from the one
16
+ * declaration in `@object-ui/types` by `manifestFromConfigs`. ⛔ No slot list
17
+ * lives here.
18
+ */
19
+ import type { Manifest, ManifestComponent, SchemaElement, ManifestValidationResult } from './types.js';
20
+ /**
21
+ * The protocol's ONE child-list key — `BaseSchema.children` — and therefore the
22
+ * name of the `inputs` entry a registration declares when its renderer puts
23
+ * that list on the page: `{ name: 'children', type: 'slot' }` (objectui#9910).
24
+ */
25
+ export declare const CHILD_LIST_KEY = "children";
26
+ /**
27
+ * Does this component ACCEPT an authored child list? (objectui#9910)
28
+ *
29
+ * Read from exactly ONE declaration: an input named {@link CHILD_LIST_KEY} in
30
+ * the component's `inputs`. That is the shape ten registrations already
31
+ * carried when the maintainer ruled it the containment contract
32
+ * (2026-09-24, objectui#9910 Q1-A "declare-and-pin"), and it is held in both
33
+ * directions by the runtime census in
34
+ * `packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx`:
35
+ * a renderer that puts `schema.children` on the page without declaring the
36
+ * input is red, and a declaration whose renderer never renders the list is
37
+ * red too.
38
+ *
39
+ * ⛔ `isContainer` is NOT consulted and is NOT a fallback. It used to decide
40
+ * this branch and it lied both ways: a hand-kept flag drifted from the code
41
+ * (objectui#3900 / `7c9b044f4` / #6764 / #6779 found the same drift four times),
42
+ * and after objectui#6771 converged a dozen registrations onto `children`
43
+ * the flag put a FALSE `not-a-container` on the one key they read
44
+ * (objectui#9910). The flag now means LAYOUT containment only — the
45
+ * react-page JSX scope and the public layout ledger read it; this tier does
46
+ * not (objectui#6804, objectui#9910 Q2-A).
47
+ */
48
+ export declare function acceptsChildren(comp: Pick<ManifestComponent, 'inputs'>): boolean;
49
+ /**
50
+ * Where a base prop is legal without a declaration (objectui#11044).
51
+ *
52
+ * - `'every-node'` — on every node, and a registration's own input of the
53
+ * same name is not consulted: {@link validateTree} skips the key before the
54
+ * declared-input lookup.
55
+ * - `'where-undeclared'` — on a type whose registration declares NO input of
56
+ * that name. Where one does, the declared input wins, its `type-mismatch`
57
+ * check included, and the generated JSX types take the declared type too.
58
+ */
59
+ export type SduiBasePropScope = 'every-node' | 'where-undeclared';
60
+ /** One entry of {@link SDUI_BASE_PROPS}. */
61
+ export interface SduiBaseProp {
62
+ /** The `BaseSchema` member (`@object-ui/types`). */
63
+ readonly name: string;
64
+ readonly scope: SduiBasePropScope;
65
+ /**
66
+ * The attribute's type in the generated JSX surface (`SduiBaseProps` in
67
+ * `sdui-intrinsics.d.ts`), or `null` for the one key that is no attribute:
68
+ * `type`, which the tag name carries (`parse.ts` refuses a `type` attribute).
69
+ */
70
+ readonly tsType: string | null;
71
+ }
72
+ /**
73
+ * The base props: the `BaseSchema` members this tier accepts on a node without
74
+ * a registration declaring them. ONE list, read by BOTH of its consumers —
75
+ * {@link validateTree}'s `unknown-prop` branch and `generateDts`'s
76
+ * `SduiBaseProps` (objectui#11044). Before this list the two were separate
77
+ * hand-kept copies and had drifted: the validator accepted `bind` and `hidden`
78
+ * (objectui#11008) while the generated types still refused both.
79
+ *
80
+ * `bind` and `hidden` are `'every-node'` (objectui#11008). `BaseSchema` declares
81
+ * both for every node and no registration declares either as an input, so
82
+ * before they joined, the undeclared-key branch below answered every authored
83
+ * one with `unknown-prop` — "has no prop" about a key the protocol declares,
84
+ * on the nodes that honour it: `hidden` is read for every node by
85
+ * `SchemaRenderer`'s hide chain, and `bind` by every renderer that calls
86
+ * `useDataScope` (`list`, `tree-view`, the `object-*` widgets). The
87
+ * declaration outranks the implementation, so the parser's view is the
88
+ * declared type's, not a per-registration subset. The cost is accepted and
89
+ * named: a `bind` on a node that does not read it — `data-table`
90
+ * (objectui#6575) — draws nothing here either, and its render-time console
91
+ * warning is the one signal left.
92
+ *
93
+ * `visibleWhen`, `hiddenOn` and `testId` are `'every-node'` for the same reason
94
+ * (objectui#11044): no registration declares any of them, `SchemaRenderer`'s
95
+ * hide chain reads the first two for every node, and it strips `testId` and
96
+ * re-emits it as `data-testid`. `visibleWhen` is the canonical ADR-0089
97
+ * predicate; before it joined, the deprecated `visibleOn` was silent while it
98
+ * drew `unknown-prop`.
99
+ *
100
+ * The `'where-undeclared'` members (objectui#11044, triage ruling) are the
101
+ * `BaseSchema` members some registrations DECLARE as typed inputs — the input
102
+ * family's `placeholder`, `label`, `name`, … . Skipping them the way the
103
+ * `'every-node'` members are skipped would silence those registrations'
104
+ * `type-mismatch`, so they are base props only where the type declares no
105
+ * input of that name. ⛔ Never move one to `'every-node'` to accept a key: that
106
+ * silences a declared type check.
107
+ *
108
+ * Held over the live registry by `base-props-one-list-11044.test.tsx` in
109
+ * `@object-ui/components` — every member a `BaseSchema` member, and `body` the
110
+ * one member left out.
111
+ *
112
+ * ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second
113
+ * child-list spelling until objectui#6771 retired it; teaching this list the
114
+ * key was the option that ruling refused, because it would have blessed a
115
+ * second permanent spelling of one concept. `./body-dialect.ts` answers it by
116
+ * name instead.
117
+ *
118
+ * `children` IS here: the key is legal on every node, so it never draws
119
+ * `unknown-prop` and its declared `slot` input is never type-checked. Whether
120
+ * a given component RENDERS it is the containment question below, answered by
121
+ * {@link acceptsChildren} from the declared input.
8
122
  */
9
- import type { Manifest, SchemaElement, ManifestValidationResult } from './types.js';
123
+ export declare const SDUI_BASE_PROPS: readonly SduiBaseProp[];
10
124
  export declare function validateTree(tree: SchemaElement | null, manifest: Manifest): ManifestValidationResult;
package/dist/validate.js CHANGED
@@ -2,25 +2,177 @@
2
2
  * ObjectUI — SDUI tree validation against the registry manifest (ADR-0080 §3/§6)
3
3
  *
4
4
  * Shallow, author-time validation: unknown component, unknown/missing prop,
5
- * wrong coarse type, illegal enum value. Collects `requires` (plugin provenance)
6
- * and binding sites the SERVER must resolve against object schema (we cannot
7
- * resolve objects/fields here — that check is framework-side by design).
5
+ * wrong coarse type, illegal enum value, and containment — a child list under
6
+ * a component whose registration declares no `children` input draws
7
+ * `not-a-container` (objectui#9910; see `acceptsChildren`). Collects
8
+ * `requires` (plugin provenance) and binding sites the SERVER must resolve
9
+ * against object schema (we cannot resolve objects/fields here — that check is
10
+ * framework-side by design).
11
+ *
12
+ * The walk descends `children` on every node and, for a component the
13
+ * manifest knows, the NODE SLOTS its entry carries (`ManifestComponent.slots`,
14
+ * objectui#11170) — the positions besides `children` through which that
15
+ * renderer hands nodes back to `SchemaRenderer`, projected from the one
16
+ * declaration in `@object-ui/types` by `manifestFromConfigs`. ⛔ No slot list
17
+ * lives here.
8
18
  */
9
19
  import { inputTypeArms } from './input-type.js';
10
20
  import { checkDashboardWidgetOptions } from './dashboard-widget-options.js';
11
- /** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */
12
- const BASE_PROPS = new Set([
13
- 'type',
14
- 'id',
15
- 'className',
16
- 'style',
17
- 'visible',
18
- 'visibleOn',
19
- 'disabled',
20
- 'disabledOn',
21
- 'children',
21
+ import { checkRetiredBodyDialect } from './body-dialect.js';
22
+ /**
23
+ * The protocol's ONE child-list key — `BaseSchema.children` — and therefore the
24
+ * name of the `inputs` entry a registration declares when its renderer puts
25
+ * that list on the page: `{ name: 'children', type: 'slot' }` (objectui#9910).
26
+ */
27
+ export const CHILD_LIST_KEY = 'children';
28
+ /**
29
+ * Does this component ACCEPT an authored child list? (objectui#9910)
30
+ *
31
+ * Read from exactly ONE declaration: an input named {@link CHILD_LIST_KEY} in
32
+ * the component's `inputs`. That is the shape ten registrations already
33
+ * carried when the maintainer ruled it the containment contract
34
+ * (2026-09-24, objectui#9910 Q1-A "declare-and-pin"), and it is held in both
35
+ * directions by the runtime census in
36
+ * `packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx`:
37
+ * a renderer that puts `schema.children` on the page without declaring the
38
+ * input is red, and a declaration whose renderer never renders the list is
39
+ * red too.
40
+ *
41
+ * ⛔ `isContainer` is NOT consulted and is NOT a fallback. It used to decide
42
+ * this branch and it lied both ways: a hand-kept flag drifted from the code
43
+ * (objectui#3900 / `7c9b044f4` / #6764 / #6779 found the same drift four times),
44
+ * and after objectui#6771 converged a dozen registrations onto `children`
45
+ * the flag put a FALSE `not-a-container` on the one key they read
46
+ * (objectui#9910). The flag now means LAYOUT containment only — the
47
+ * react-page JSX scope and the public layout ledger read it; this tier does
48
+ * not (objectui#6804, objectui#9910 Q2-A).
49
+ */
50
+ export function acceptsChildren(comp) {
51
+ return comp.inputs.some((input) => input.name === CHILD_LIST_KEY);
52
+ }
53
+ /**
54
+ * The base props: the `BaseSchema` members this tier accepts on a node without
55
+ * a registration declaring them. ONE list, read by BOTH of its consumers —
56
+ * {@link validateTree}'s `unknown-prop` branch and `generateDts`'s
57
+ * `SduiBaseProps` (objectui#11044). Before this list the two were separate
58
+ * hand-kept copies and had drifted: the validator accepted `bind` and `hidden`
59
+ * (objectui#11008) while the generated types still refused both.
60
+ *
61
+ * `bind` and `hidden` are `'every-node'` (objectui#11008). `BaseSchema` declares
62
+ * both for every node and no registration declares either as an input, so
63
+ * before they joined, the undeclared-key branch below answered every authored
64
+ * one with `unknown-prop` — "has no prop" about a key the protocol declares,
65
+ * on the nodes that honour it: `hidden` is read for every node by
66
+ * `SchemaRenderer`'s hide chain, and `bind` by every renderer that calls
67
+ * `useDataScope` (`list`, `tree-view`, the `object-*` widgets). The
68
+ * declaration outranks the implementation, so the parser's view is the
69
+ * declared type's, not a per-registration subset. The cost is accepted and
70
+ * named: a `bind` on a node that does not read it — `data-table`
71
+ * (objectui#6575) — draws nothing here either, and its render-time console
72
+ * warning is the one signal left.
73
+ *
74
+ * `visibleWhen`, `hiddenOn` and `testId` are `'every-node'` for the same reason
75
+ * (objectui#11044): no registration declares any of them, `SchemaRenderer`'s
76
+ * hide chain reads the first two for every node, and it strips `testId` and
77
+ * re-emits it as `data-testid`. `visibleWhen` is the canonical ADR-0089
78
+ * predicate; before it joined, the deprecated `visibleOn` was silent while it
79
+ * drew `unknown-prop`.
80
+ *
81
+ * The `'where-undeclared'` members (objectui#11044, triage ruling) are the
82
+ * `BaseSchema` members some registrations DECLARE as typed inputs — the input
83
+ * family's `placeholder`, `label`, `name`, … . Skipping them the way the
84
+ * `'every-node'` members are skipped would silence those registrations'
85
+ * `type-mismatch`, so they are base props only where the type declares no
86
+ * input of that name. ⛔ Never move one to `'every-node'` to accept a key: that
87
+ * silences a declared type check.
88
+ *
89
+ * Held over the live registry by `base-props-one-list-11044.test.tsx` in
90
+ * `@object-ui/components` — every member a `BaseSchema` member, and `body` the
91
+ * one member left out.
92
+ *
93
+ * ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second
94
+ * child-list spelling until objectui#6771 retired it; teaching this list the
95
+ * key was the option that ruling refused, because it would have blessed a
96
+ * second permanent spelling of one concept. `./body-dialect.ts` answers it by
97
+ * name instead.
98
+ *
99
+ * `children` IS here: the key is legal on every node, so it never draws
100
+ * `unknown-prop` and its declared `slot` input is never type-checked. Whether
101
+ * a given component RENDERS it is the containment question below, answered by
102
+ * {@link acceptsChildren} from the declared input.
103
+ */
104
+ export const SDUI_BASE_PROPS = Object.freeze([
105
+ { name: 'type', scope: 'every-node', tsType: null },
106
+ { name: 'id', scope: 'every-node', tsType: 'string' },
107
+ { name: 'className', scope: 'every-node', tsType: 'string' },
108
+ { name: 'style', scope: 'every-node', tsType: 'Record<string, unknown>' },
109
+ { name: 'visible', scope: 'every-node', tsType: 'boolean' },
110
+ { name: 'visibleWhen', scope: 'every-node', tsType: 'string' },
111
+ { name: 'visibleOn', scope: 'every-node', tsType: 'string' },
112
+ { name: 'hidden', scope: 'every-node', tsType: 'boolean' },
113
+ { name: 'hiddenOn', scope: 'every-node', tsType: 'string' },
114
+ { name: 'disabled', scope: 'every-node', tsType: 'boolean' },
115
+ { name: 'disabledOn', scope: 'every-node', tsType: 'string' },
116
+ { name: 'bind', scope: 'every-node', tsType: 'string' },
117
+ { name: 'testId', scope: 'every-node', tsType: 'string' },
118
+ { name: CHILD_LIST_KEY, scope: 'every-node', tsType: 'unknown' },
119
+ { name: 'name', scope: 'where-undeclared', tsType: 'string' },
120
+ { name: 'label', scope: 'where-undeclared', tsType: 'string | Record<string, string>' },
121
+ { name: 'description', scope: 'where-undeclared', tsType: 'string | Record<string, string>' },
122
+ { name: 'placeholder', scope: 'where-undeclared', tsType: 'string' },
123
+ { name: 'data', scope: 'where-undeclared', tsType: 'unknown' },
124
+ {
125
+ name: 'ariaLabel',
126
+ scope: 'where-undeclared',
127
+ tsType: 'string | { key: string; defaultValue?: string; params?: Record<string, unknown> }',
128
+ },
22
129
  ]);
130
+ const basePropNames = (scope) => new Set(SDUI_BASE_PROPS.filter((prop) => prop.scope === scope).map((prop) => prop.name));
131
+ /** The `'every-node'` members of {@link SDUI_BASE_PROPS}: never "unknown prop". */
132
+ const BASE_PROPS = basePropNames('every-node');
133
+ /** The `'where-undeclared'` members of {@link SDUI_BASE_PROPS}. */
134
+ const WHERE_UNDECLARED_BASE_PROPS = basePropNames('where-undeclared');
23
135
  const isExpr = (v) => typeof v === 'object' && v !== null && '$expr' in v;
136
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
137
+ const isElement = (v) => isPlainObject(v) && typeof v.type === 'string';
138
+ /**
139
+ * The element nodes a slot position holds on `node`, in document order.
140
+ *
141
+ * The position is spelled in `@object-ui/types`' key-path grammar, which that
142
+ * package's `nodeSlotValues` implements for the other two readers and whose
143
+ * header states it: keys separated by `.`, a key followed by `[]` meaning each
144
+ * element of the array under it, and the value at the end being one node or a
145
+ * list of nodes. Restated here because this package imports nothing at
146
+ * runtime; the components-side census for objectui#11170 holds the two walks
147
+ * to the same answer over the same fixtures. Only element-shaped values are
148
+ * returned — a string child is legal in a slot and has nothing to validate.
149
+ */
150
+ function slotElements(node, path) {
151
+ let holders = [node];
152
+ for (const segment of path.split('.')) {
153
+ const each = segment.endsWith('[]');
154
+ const key = each ? segment.slice(0, -2) : segment;
155
+ const next = [];
156
+ for (const holder of holders) {
157
+ if (!isPlainObject(holder))
158
+ continue;
159
+ const value = holder[key];
160
+ if (!each)
161
+ next.push(value);
162
+ else if (Array.isArray(value))
163
+ next.push(...value);
164
+ }
165
+ holders = next;
166
+ }
167
+ const found = [];
168
+ for (const holder of holders) {
169
+ for (const value of Array.isArray(holder) ? holder : [holder]) {
170
+ if (isElement(value))
171
+ found.push(value);
172
+ }
173
+ }
174
+ return found;
175
+ }
24
176
  export function validateTree(tree, manifest) {
25
177
  const diagnostics = [];
26
178
  const requires = new Set();
@@ -56,9 +208,23 @@ export function validateTree(tree, manifest) {
56
208
  for (const [key, value] of Object.entries(node)) {
57
209
  if (BASE_PROPS.has(key))
58
210
  continue;
211
+ // A declaration outranks a `'where-undeclared'` base prop
212
+ // (objectui#11044): skipped only when this type declares no input of
213
+ // that name, so a declared one keeps its type check below.
214
+ if (WHERE_UNDECLARED_BASE_PROPS.has(key) && !byName.has(key))
215
+ continue;
59
216
  const input = byName.get(key);
60
217
  if (!input) {
61
- diagnostics.push({
218
+ // The retired `body` child-list dialect gets its replacement named
219
+ // rather than the bare "has no prop" every typo gets
220
+ // (objectui#6771). Asked INSIDE this branch, so a component that
221
+ // declares its own `body` input keeps its declared type check —
222
+ // mechanism in `./body-dialect.ts`. Its containment verdict is the
223
+ // SAME predicate the `children` branch below reads — the declared
224
+ // `children` input, never `isContainer` (objectui#9910) — so the two
225
+ // spellings cannot disagree about which components take a list.
226
+ const retiredBody = checkRetiredBodyDialect(node.type, key, value, acceptsChildren(comp));
227
+ diagnostics.push(retiredBody ?? {
62
228
  severity: 'warning',
63
229
  code: 'unknown-prop',
64
230
  message: `<${node.type}> has no prop "${key}"`,
@@ -69,14 +235,64 @@ export function validateTree(tree, manifest) {
69
235
  if (input.binding) {
70
236
  bindings.push({ tag: node.type, input: key, kind: input.binding, value });
71
237
  }
72
- if (!isExpr(value)) {
238
+ if (isExpr(value)) {
239
+ // A braced value that failed JSON materialization compiled to the
240
+ // parser's deferred `{ $expr }` marker — and NOTHING downstream
241
+ // evaluates that marker: this tier parses, never executes
242
+ // (ADR-0080), and no renderer consumes `$expr`. The value therefore
243
+ // reaches the renderer as an opaque object, every defensive
244
+ // non-array/non-object read degrades it to "not declared", and the
245
+ // author's binding silently vanishes (objectui#6598: eight `columns`
246
+ // spellings on a data block, all eaten without a single diagnostic —
247
+ // rows rendered, zero data columns). ADR-0078 prohibits exactly this
248
+ // parsed-but-silently-inert state, so name it at compile time, with
249
+ // the fix in the message.
250
+ //
251
+ // The message must name the CURRENT accepted grammar, and objectui#6614
252
+ // (Q1-A, ruled 2026-08-28) moved it: `interpretBrace` now materializes
253
+ // the JS literal subset, so single-quoted strings and unquoted
254
+ // identifier keys REACH the renderer and can no longer draw this
255
+ // warning. The old wording ("write it as JSON, double-quoted") named a
256
+ // now-legal spelling as the illegal one — advice that would have sent
257
+ // an author to edit working source. What is left on this side of the
258
+ // boundary is a genuine expression, so that is what the message names.
259
+ //
260
+ // Warning, not error, per the `8d58f46b4` precedent for inert
261
+ // authored keys. ⛔ Escalation to error is objectui#6614 Q2 and is
262
+ // deliberately NOT part of this change: it belongs at the SAVE GATE,
263
+ // once the framework wires the registry manifest into
264
+ // `validate-jsx-pages` (objectstack#12719 records that gap).
265
+ diagnostics.push({
266
+ severity: 'warning',
267
+ code: 'inert-expression',
268
+ message: `<${node.type}> prop "${key}" is a braced expression this tier never evaluates — ` +
269
+ `the value will be silently ignored at render. This tier materializes LITERALS only ` +
270
+ `(strings, numbers, booleans, null, arrays, objects; quotes may be single or double, ` +
271
+ `object keys may be unquoted), e.g. columns={['name','amount']} works — ` +
272
+ `columns={rows.map((r) => r.name)} cannot`,
273
+ tag: node.type,
274
+ });
275
+ }
276
+ else {
73
277
  const typeDiag = checkType(node.type, input, value);
74
- if (typeDiag)
278
+ if (typeDiag) {
75
279
  diagnostics.push(typeDiag);
280
+ }
281
+ else {
282
+ // Members only once the CONTAINER kind was accepted. Reporting a
283
+ // member of a value that is not even the declared container is two
284
+ // diagnostics for one mistake, and the second one names positions
285
+ // of a shape the author did not write (objectui#8067).
286
+ const memberDiag = checkMemberTypes(node.type, input, value);
287
+ if (memberDiag)
288
+ diagnostics.push(memberDiag);
289
+ }
76
290
  }
77
291
  }
78
- // containment
79
- if (node.children?.length && !comp.isContainer) {
292
+ // containment — decided by the declared `children` input and by NOTHING
293
+ // else (objectui#9910 Q1-A). ⛔ No `isContainer` fallback: see
294
+ // `acceptsChildren` for why the flag stopped deciding this.
295
+ if (node.children?.length && !acceptsChildren(comp)) {
80
296
  diagnostics.push({
81
297
  severity: 'warning',
82
298
  code: 'not-a-container',
@@ -86,7 +302,7 @@ export function validateTree(tree, manifest) {
86
302
  }
87
303
  // Dashboard widgets: an `options` key riding the spec's `.passthrough()`
88
304
  // that no renderer consumes is legal, silent and inert — warn, naming
89
- // the consumed set (objectui#5709 ruling; census + scope in
305
+ // the consumed set (the 2026-08-23 ruling; census + scope in
90
306
  // `./dashboard-widget-options.ts`). Like `not-a-container`, this runs
91
307
  // only for a component the manifest knows: an unresolved tag already
92
308
  // drew `unknown-component`, and deep diagnostics on it would be noise.
@@ -94,6 +310,14 @@ export function validateTree(tree, manifest) {
94
310
  }
95
311
  if (node.children)
96
312
  node.children.forEach(visit);
313
+ // The node slots this component's renderer reads besides `children`
314
+ // (objectui#11170), as its manifest entry declares them. An unknown
315
+ // component has no entry and so no slots: its `unknown-component` above is
316
+ // the answer, and nothing under it is judged.
317
+ for (const path of comp?.slots ?? []) {
318
+ for (const element of slotElements(node, path))
319
+ visit(element);
320
+ }
97
321
  };
98
322
  if (tree)
99
323
  visit(tree);
@@ -148,6 +372,63 @@ function armExpectation(arm, input) {
148
372
  return 'a string';
149
373
  }
150
374
  }
375
+ /**
376
+ * The member positions of a container value, as `[position, member]` pairs, or
377
+ * `null` when the value has no member position to speak of.
378
+ *
379
+ * Arrays index by position and objects by key, which is exactly the pair
380
+ * `ComponentInput.of` describes: array ELEMENTS, and the VALUES of an object
381
+ * used as a map. A scalar returns `null` rather than an empty list, so a value
382
+ * that only satisfied a non-container arm of a union declaration
383
+ * (`type: ['string', 'array'], of: 'string'`) is not reported as an empty
384
+ * container that trivially conforms — it is simply not the arm `of` describes.
385
+ */
386
+ function memberEntries(value) {
387
+ if (Array.isArray(value))
388
+ return value.map((member, index) => [String(index), member]);
389
+ if (typeof value === 'object' && value !== null)
390
+ return Object.entries(value);
391
+ return null;
392
+ }
393
+ /**
394
+ * Coarse MEMBER check, over the arms `of` declares (objectui#8067).
395
+ *
396
+ * The same question `checkType` asks, one level down and with the same answer
397
+ * shape: ANY declared arm accepting a member clears it, a member no arm accepts
398
+ * is reported, and an input that declares no `of` is checked exactly as it was
399
+ * before the key existed — this function returns immediately on an empty arm
400
+ * list, so nothing published today changes severity or gains a diagnostic.
401
+ *
402
+ * ONE diagnostic per prop, naming every offending position, rather than one per
403
+ * member: a page that passes an array of the wrong member kind is one mistake
404
+ * made once, and N copies of it is the noise this repo treats as the thing that
405
+ * trains authors to dismiss real reports.
406
+ *
407
+ * Severity mirrors `checkType`'s rule for the same reason — `error` when an
408
+ * `enum` arm is present, because a closed list is the one fact this layer can
409
+ * be certain about; `warning` otherwise, since the coarse kind is a KIND claim
410
+ * and `os validate` / `os build` remain the judge of values.
411
+ */
412
+ function checkMemberTypes(tag, input, value) {
413
+ const arms = inputTypeArms(input.of);
414
+ if (arms.length === 0)
415
+ return null;
416
+ const entries = memberEntries(value);
417
+ if (entries === null)
418
+ return null;
419
+ const offenders = entries.filter(([, member]) => !arms.some((arm) => armAccepts(arm, input, member)));
420
+ if (offenders.length === 0)
421
+ return null;
422
+ const expectation = arms.map((arm) => armExpectation(arm, input)).join(' or ');
423
+ return {
424
+ severity: arms.includes('enum') ? 'error' : 'warning',
425
+ code: 'member-type-mismatch',
426
+ message: `<${tag}> prop "${input.name}" expected every member to be ${expectation}` +
427
+ ` — ${offenders.map(([position]) => `[${position}]`).join(', ')} ` +
428
+ `${offenders.length === 1 ? 'is' : 'are'} not`,
429
+ tag,
430
+ };
431
+ }
151
432
  /**
152
433
  * Coarse type check, over the arms an input declares (objectui#3832).
153
434
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/sdui-parser",
3
- "version": "17.6.0",
3
+ "version": "18.0.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -25,13 +25,14 @@
25
25
  "dist"
26
26
  ],
27
27
  "devDependencies": {
28
- "@objectstack/spec": "^17.0.0",
29
- "@object-ui/core": "17.6.0",
30
- "@object-ui/react": "17.6.0"
28
+ "@objectstack/spec": "^18.0.0",
29
+ "@object-ui/core": "18.0.0",
30
+ "@object-ui/react": "18.0.0",
31
+ "@object-ui/types": "18.0.0"
31
32
  },
32
33
  "scripts": {
33
- "build": "tsc",
34
- "test": "vitest run",
34
+ "build": "tsc && node ../../scripts/check-dist-completeness.mjs",
35
+ "test": "vitest run --root ../.. packages/sdui-parser/",
35
36
  "type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
36
37
  "lint": "eslint ."
37
38
  }