@object-ui/sdui-parser 17.5.0 → 17.7.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.
package/dist/validate.js CHANGED
@@ -2,22 +2,129 @@
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).
8
11
  */
9
- /** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */
10
- const BASE_PROPS = new Set([
11
- 'type',
12
- 'id',
13
- 'className',
14
- 'style',
15
- 'visible',
16
- 'visibleOn',
17
- 'disabled',
18
- 'disabledOn',
19
- 'children',
12
+ import { inputTypeArms } from './input-type.js';
13
+ import { checkDashboardWidgetOptions } from './dashboard-widget-options.js';
14
+ import { checkRetiredBodyDialect } from './body-dialect.js';
15
+ /**
16
+ * The protocol's ONE child-list key — `BaseSchema.children` — and therefore the
17
+ * name of the `inputs` entry a registration declares when its renderer puts
18
+ * that list on the page: `{ name: 'children', type: 'slot' }` (objectui#9910).
19
+ */
20
+ export const CHILD_LIST_KEY = 'children';
21
+ /**
22
+ * Does this component ACCEPT an authored child list? (objectui#9910)
23
+ *
24
+ * Read from exactly ONE declaration: an input named {@link CHILD_LIST_KEY} in
25
+ * the component's `inputs`. That is the shape ten registrations already
26
+ * carried when the maintainer ruled it the containment contract
27
+ * (2026-09-24, objectui#9910 Q1-A "declare-and-pin"), and it is held in both
28
+ * directions by the runtime census in
29
+ * `packages/components/src/renderers/__tests__/container-declaration-ratchet.test.tsx`:
30
+ * a renderer that puts `schema.children` on the page without declaring the
31
+ * input is red, and a declaration whose renderer never renders the list is
32
+ * red too.
33
+ *
34
+ * ⛔ `isContainer` is NOT consulted and is NOT a fallback. It used to decide
35
+ * this branch and it lied both ways: a hand-kept flag drifted from the code
36
+ * (objectui#3900 / `7c9b044f4` / #6764 / #6779 found the same drift four times),
37
+ * and after objectui#6771 converged a dozen registrations onto `children`
38
+ * the flag put a FALSE `not-a-container` on the one key they read
39
+ * (objectui#9910). The flag now means LAYOUT containment only — the
40
+ * react-page JSX scope and the public layout ledger read it; this tier does
41
+ * not (objectui#6804, objectui#9910 Q2-A).
42
+ */
43
+ export function acceptsChildren(comp) {
44
+ return comp.inputs.some((input) => input.name === CHILD_LIST_KEY);
45
+ }
46
+ /**
47
+ * The base props: the `BaseSchema` members this tier accepts on a node without
48
+ * a registration declaring them. ONE list, read by BOTH of its consumers —
49
+ * {@link validateTree}'s `unknown-prop` branch and `generateDts`'s
50
+ * `SduiBaseProps` (objectui#11044). Before this list the two were separate
51
+ * hand-kept copies and had drifted: the validator accepted `bind` and `hidden`
52
+ * (objectui#11008) while the generated types still refused both.
53
+ *
54
+ * `bind` and `hidden` are `'every-node'` (objectui#11008). `BaseSchema` declares
55
+ * both for every node and no registration declares either as an input, so
56
+ * before they joined, the undeclared-key branch below answered every authored
57
+ * one with `unknown-prop` — "has no prop" about a key the protocol declares,
58
+ * on the nodes that honour it: `hidden` is read for every node by
59
+ * `SchemaRenderer`'s hide chain, and `bind` by every renderer that calls
60
+ * `useDataScope` (`list`, `tree-view`, the `object-*` widgets). The
61
+ * declaration outranks the implementation, so the parser's view is the
62
+ * declared type's, not a per-registration subset. The cost is accepted and
63
+ * named: a `bind` on a node that does not read it — `data-table`
64
+ * (objectui#6575) — draws nothing here either, and its render-time console
65
+ * warning is the one signal left.
66
+ *
67
+ * `visibleWhen`, `hiddenOn` and `testId` are `'every-node'` for the same reason
68
+ * (objectui#11044): no registration declares any of them, `SchemaRenderer`'s
69
+ * hide chain reads the first two for every node, and it strips `testId` and
70
+ * re-emits it as `data-testid`. `visibleWhen` is the canonical ADR-0089
71
+ * predicate; before it joined, the deprecated `visibleOn` was silent while it
72
+ * drew `unknown-prop`.
73
+ *
74
+ * The `'where-undeclared'` members (objectui#11044, triage ruling) are the
75
+ * `BaseSchema` members some registrations DECLARE as typed inputs — the input
76
+ * family's `placeholder`, `label`, `name`, … . Skipping them the way the
77
+ * `'every-node'` members are skipped would silence those registrations'
78
+ * `type-mismatch`, so they are base props only where the type declares no
79
+ * input of that name. ⛔ Never move one to `'every-node'` to accept a key: that
80
+ * silences a declared type check.
81
+ *
82
+ * Held over the live registry by `base-props-one-list-11044.test.tsx` in
83
+ * `@object-ui/components` — every member a `BaseSchema` member, and `body` the
84
+ * one member left out.
85
+ *
86
+ * ⛔ `body` is NOT here and must not be added. It was `BaseSchema`'s second
87
+ * child-list spelling until objectui#6771 retired it; teaching this list the
88
+ * key was the option that ruling refused, because it would have blessed a
89
+ * second permanent spelling of one concept. `./body-dialect.ts` answers it by
90
+ * name instead.
91
+ *
92
+ * `children` IS here: the key is legal on every node, so it never draws
93
+ * `unknown-prop` and its declared `slot` input is never type-checked. Whether
94
+ * a given component RENDERS it is the containment question below, answered by
95
+ * {@link acceptsChildren} from the declared input.
96
+ */
97
+ export const SDUI_BASE_PROPS = Object.freeze([
98
+ { name: 'type', scope: 'every-node', tsType: null },
99
+ { name: 'id', scope: 'every-node', tsType: 'string' },
100
+ { name: 'className', scope: 'every-node', tsType: 'string' },
101
+ { name: 'style', scope: 'every-node', tsType: 'Record<string, unknown>' },
102
+ { name: 'visible', scope: 'every-node', tsType: 'boolean' },
103
+ { name: 'visibleWhen', scope: 'every-node', tsType: 'string' },
104
+ { name: 'visibleOn', scope: 'every-node', tsType: 'string' },
105
+ { name: 'hidden', scope: 'every-node', tsType: 'boolean' },
106
+ { name: 'hiddenOn', scope: 'every-node', tsType: 'string' },
107
+ { name: 'disabled', scope: 'every-node', tsType: 'boolean' },
108
+ { name: 'disabledOn', scope: 'every-node', tsType: 'string' },
109
+ { name: 'bind', scope: 'every-node', tsType: 'string' },
110
+ { name: 'testId', scope: 'every-node', tsType: 'string' },
111
+ { name: CHILD_LIST_KEY, scope: 'every-node', tsType: 'unknown' },
112
+ { name: 'name', scope: 'where-undeclared', tsType: 'string' },
113
+ { name: 'label', scope: 'where-undeclared', tsType: 'string | Record<string, string>' },
114
+ { name: 'description', scope: 'where-undeclared', tsType: 'string | Record<string, string>' },
115
+ { name: 'placeholder', scope: 'where-undeclared', tsType: 'string' },
116
+ { name: 'data', scope: 'where-undeclared', tsType: 'unknown' },
117
+ {
118
+ name: 'ariaLabel',
119
+ scope: 'where-undeclared',
120
+ tsType: 'string | { key: string; defaultValue?: string; params?: Record<string, unknown> }',
121
+ },
20
122
  ]);
123
+ const basePropNames = (scope) => new Set(SDUI_BASE_PROPS.filter((prop) => prop.scope === scope).map((prop) => prop.name));
124
+ /** The `'every-node'` members of {@link SDUI_BASE_PROPS}: never "unknown prop". */
125
+ const BASE_PROPS = basePropNames('every-node');
126
+ /** The `'where-undeclared'` members of {@link SDUI_BASE_PROPS}. */
127
+ const WHERE_UNDECLARED_BASE_PROPS = basePropNames('where-undeclared');
21
128
  const isExpr = (v) => typeof v === 'object' && v !== null && '$expr' in v;
22
129
  export function validateTree(tree, manifest) {
23
130
  const diagnostics = [];
@@ -54,9 +161,23 @@ export function validateTree(tree, manifest) {
54
161
  for (const [key, value] of Object.entries(node)) {
55
162
  if (BASE_PROPS.has(key))
56
163
  continue;
164
+ // A declaration outranks a `'where-undeclared'` base prop
165
+ // (objectui#11044): skipped only when this type declares no input of
166
+ // that name, so a declared one keeps its type check below.
167
+ if (WHERE_UNDECLARED_BASE_PROPS.has(key) && !byName.has(key))
168
+ continue;
57
169
  const input = byName.get(key);
58
170
  if (!input) {
59
- diagnostics.push({
171
+ // The retired `body` child-list dialect gets its replacement named
172
+ // rather than the bare "has no prop" every typo gets
173
+ // (objectui#6771). Asked INSIDE this branch, so a component that
174
+ // declares its own `body` input keeps its declared type check —
175
+ // mechanism in `./body-dialect.ts`. Its containment verdict is the
176
+ // SAME predicate the `children` branch below reads — the declared
177
+ // `children` input, never `isContainer` (objectui#9910) — so the two
178
+ // spellings cannot disagree about which components take a list.
179
+ const retiredBody = checkRetiredBodyDialect(node.type, key, value, acceptsChildren(comp));
180
+ diagnostics.push(retiredBody ?? {
60
181
  severity: 'warning',
61
182
  code: 'unknown-prop',
62
183
  message: `<${node.type}> has no prop "${key}"`,
@@ -67,14 +188,64 @@ export function validateTree(tree, manifest) {
67
188
  if (input.binding) {
68
189
  bindings.push({ tag: node.type, input: key, kind: input.binding, value });
69
190
  }
70
- if (!isExpr(value)) {
191
+ if (isExpr(value)) {
192
+ // A braced value that failed JSON materialization compiled to the
193
+ // parser's deferred `{ $expr }` marker — and NOTHING downstream
194
+ // evaluates that marker: this tier parses, never executes
195
+ // (ADR-0080), and no renderer consumes `$expr`. The value therefore
196
+ // reaches the renderer as an opaque object, every defensive
197
+ // non-array/non-object read degrades it to "not declared", and the
198
+ // author's binding silently vanishes (objectui#6598: eight `columns`
199
+ // spellings on a data block, all eaten without a single diagnostic —
200
+ // rows rendered, zero data columns). ADR-0078 prohibits exactly this
201
+ // parsed-but-silently-inert state, so name it at compile time, with
202
+ // the fix in the message.
203
+ //
204
+ // The message must name the CURRENT accepted grammar, and objectui#6614
205
+ // (Q1-A, ruled 2026-08-28) moved it: `interpretBrace` now materializes
206
+ // the JS literal subset, so single-quoted strings and unquoted
207
+ // identifier keys REACH the renderer and can no longer draw this
208
+ // warning. The old wording ("write it as JSON, double-quoted") named a
209
+ // now-legal spelling as the illegal one — advice that would have sent
210
+ // an author to edit working source. What is left on this side of the
211
+ // boundary is a genuine expression, so that is what the message names.
212
+ //
213
+ // Warning, not error, per the `8d58f46b4` precedent for inert
214
+ // authored keys. ⛔ Escalation to error is objectui#6614 Q2 and is
215
+ // deliberately NOT part of this change: it belongs at the SAVE GATE,
216
+ // once the framework wires the registry manifest into
217
+ // `validate-jsx-pages` (objectstack#12719 records that gap).
218
+ diagnostics.push({
219
+ severity: 'warning',
220
+ code: 'inert-expression',
221
+ message: `<${node.type}> prop "${key}" is a braced expression this tier never evaluates — ` +
222
+ `the value will be silently ignored at render. This tier materializes LITERALS only ` +
223
+ `(strings, numbers, booleans, null, arrays, objects; quotes may be single or double, ` +
224
+ `object keys may be unquoted), e.g. columns={['name','amount']} works — ` +
225
+ `columns={rows.map((r) => r.name)} cannot`,
226
+ tag: node.type,
227
+ });
228
+ }
229
+ else {
71
230
  const typeDiag = checkType(node.type, input, value);
72
- if (typeDiag)
231
+ if (typeDiag) {
73
232
  diagnostics.push(typeDiag);
233
+ }
234
+ else {
235
+ // Members only once the CONTAINER kind was accepted. Reporting a
236
+ // member of a value that is not even the declared container is two
237
+ // diagnostics for one mistake, and the second one names positions
238
+ // of a shape the author did not write (objectui#8067).
239
+ const memberDiag = checkMemberTypes(node.type, input, value);
240
+ if (memberDiag)
241
+ diagnostics.push(memberDiag);
242
+ }
74
243
  }
75
244
  }
76
- // containment
77
- if (node.children?.length && !comp.isContainer) {
245
+ // containment — decided by the declared `children` input and by NOTHING
246
+ // else (objectui#9910 Q1-A). ⛔ No `isContainer` fallback: see
247
+ // `acceptsChildren` for why the flag stopped deciding this.
248
+ if (node.children?.length && !acceptsChildren(comp)) {
78
249
  diagnostics.push({
79
250
  severity: 'warning',
80
251
  code: 'not-a-container',
@@ -82,6 +253,13 @@ export function validateTree(tree, manifest) {
82
253
  tag: node.type,
83
254
  });
84
255
  }
256
+ // Dashboard widgets: an `options` key riding the spec's `.passthrough()`
257
+ // that no renderer consumes is legal, silent and inert — warn, naming
258
+ // the consumed set (the 2026-08-23 ruling; census + scope in
259
+ // `./dashboard-widget-options.ts`). Like `not-a-container`, this runs
260
+ // only for a component the manifest knows: an unresolved tag already
261
+ // drew `unknown-component`, and deep diagnostics on it would be noise.
262
+ diagnostics.push(...checkDashboardWidgetOptions(node));
85
263
  }
86
264
  if (node.children)
87
265
  node.children.forEach(visit);
@@ -90,42 +268,155 @@ export function validateTree(tree, manifest) {
90
268
  visit(tree);
91
269
  return { diagnostics, requires: [...requires], bindings };
92
270
  }
93
- function checkType(tag, input, value) {
94
- const mismatch = (expected) => ({
95
- severity: 'warning',
96
- code: 'type-mismatch',
97
- message: `<${tag}> prop "${input.name}" expected ${expected}`,
98
- tag,
99
- });
100
- switch (input.type) {
271
+ /** The values an `enum` arm admits, flattened from either declaration form. */
272
+ const enumValues = (input) => (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
273
+ /**
274
+ * Does ONE coarse arm accept this value?
275
+ *
276
+ * An arm outside the vocabulary — and `'slot'`, which describes a child
277
+ * position rather than a value — accepts everything, preserving the old
278
+ * `default: return null` branch: those inputs never drew a diagnostic and must
279
+ * not start now.
280
+ */
281
+ function armAccepts(arm, input, value) {
282
+ switch (arm) {
101
283
  case 'number':
102
- return typeof value === 'number' ? null : mismatch('a number');
284
+ return typeof value === 'number';
103
285
  case 'boolean':
104
- return typeof value === 'boolean' ? null : mismatch('a boolean');
286
+ return typeof value === 'boolean';
105
287
  case 'string':
106
288
  case 'color':
107
289
  case 'date':
108
290
  case 'code':
109
291
  case 'file':
110
- return typeof value === 'string' ? null : mismatch('a string');
292
+ return typeof value === 'string';
111
293
  case 'array':
112
- return Array.isArray(value) ? null : mismatch('an array');
294
+ return Array.isArray(value);
113
295
  case 'object':
114
- return typeof value === 'object' && value !== null && !Array.isArray(value)
115
- ? null
116
- : mismatch('an object');
117
- case 'enum': {
118
- const allowed = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
119
- return allowed.includes(value)
120
- ? null
121
- : {
122
- severity: 'error',
123
- code: 'invalid-enum',
124
- message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(allowed)}`,
125
- tag,
126
- };
127
- }
296
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
297
+ case 'enum':
298
+ return enumValues(input).includes(value);
128
299
  default:
129
- return null;
300
+ return true;
130
301
  }
131
302
  }
303
+ /** How one arm is named in a diagnostic message. */
304
+ function armExpectation(arm, input) {
305
+ switch (arm) {
306
+ case 'number':
307
+ return 'a number';
308
+ case 'boolean':
309
+ return 'a boolean';
310
+ case 'array':
311
+ return 'an array';
312
+ case 'object':
313
+ return 'an object';
314
+ case 'enum':
315
+ return `one of ${JSON.stringify(enumValues(input))}`;
316
+ default:
317
+ return 'a string';
318
+ }
319
+ }
320
+ /**
321
+ * The member positions of a container value, as `[position, member]` pairs, or
322
+ * `null` when the value has no member position to speak of.
323
+ *
324
+ * Arrays index by position and objects by key, which is exactly the pair
325
+ * `ComponentInput.of` describes: array ELEMENTS, and the VALUES of an object
326
+ * used as a map. A scalar returns `null` rather than an empty list, so a value
327
+ * that only satisfied a non-container arm of a union declaration
328
+ * (`type: ['string', 'array'], of: 'string'`) is not reported as an empty
329
+ * container that trivially conforms — it is simply not the arm `of` describes.
330
+ */
331
+ function memberEntries(value) {
332
+ if (Array.isArray(value))
333
+ return value.map((member, index) => [String(index), member]);
334
+ if (typeof value === 'object' && value !== null)
335
+ return Object.entries(value);
336
+ return null;
337
+ }
338
+ /**
339
+ * Coarse MEMBER check, over the arms `of` declares (objectui#8067).
340
+ *
341
+ * The same question `checkType` asks, one level down and with the same answer
342
+ * shape: ANY declared arm accepting a member clears it, a member no arm accepts
343
+ * is reported, and an input that declares no `of` is checked exactly as it was
344
+ * before the key existed — this function returns immediately on an empty arm
345
+ * list, so nothing published today changes severity or gains a diagnostic.
346
+ *
347
+ * ONE diagnostic per prop, naming every offending position, rather than one per
348
+ * member: a page that passes an array of the wrong member kind is one mistake
349
+ * made once, and N copies of it is the noise this repo treats as the thing that
350
+ * trains authors to dismiss real reports.
351
+ *
352
+ * Severity mirrors `checkType`'s rule for the same reason — `error` when an
353
+ * `enum` arm is present, because a closed list is the one fact this layer can
354
+ * be certain about; `warning` otherwise, since the coarse kind is a KIND claim
355
+ * and `os validate` / `os build` remain the judge of values.
356
+ */
357
+ function checkMemberTypes(tag, input, value) {
358
+ const arms = inputTypeArms(input.of);
359
+ if (arms.length === 0)
360
+ return null;
361
+ const entries = memberEntries(value);
362
+ if (entries === null)
363
+ return null;
364
+ const offenders = entries.filter(([, member]) => !arms.some((arm) => armAccepts(arm, input, member)));
365
+ if (offenders.length === 0)
366
+ return null;
367
+ const expectation = arms.map((arm) => armExpectation(arm, input)).join(' or ');
368
+ return {
369
+ severity: arms.includes('enum') ? 'error' : 'warning',
370
+ code: 'member-type-mismatch',
371
+ message: `<${tag}> prop "${input.name}" expected every member to be ${expectation}` +
372
+ ` — ${offenders.map(([position]) => `[${position}]`).join(', ')} ` +
373
+ `${offenders.length === 1 ? 'is' : 'are'} not`,
374
+ tag,
375
+ };
376
+ }
377
+ /**
378
+ * Coarse type check, over the arms an input declares (objectui#3832).
379
+ *
380
+ * ANY arm accepting the value clears the prop — that is what lets a key whose
381
+ * contract is a union (`string | number`, or a string plus an inline
382
+ * translation map) be declared honestly instead of picking one arm and having
383
+ * this function report the other arm's legal values.
384
+ *
385
+ * When NO arm accepts it the prop is still reported; a union widens what counts
386
+ * as legal, it does not turn the check off. Two properties of the reporting are
387
+ * deliberate:
388
+ *
389
+ * - A single-arm input produces the byte-identical diagnostic it always did,
390
+ * `invalid-enum` included. This change adds a form; it does not restate the
391
+ * old one.
392
+ * - A multi-arm input produces ONE diagnostic naming every arm, at the
393
+ * STRICTEST arm's severity — `error` when an `enum` arm is present, because
394
+ * an enum's closed list is the one fact this layer can be certain about, and
395
+ * a value outside it should not become dismissible merely because a second
396
+ * arm was added next to it. Its code is `type-mismatch` (not `invalid-enum`)
397
+ * since the reported fact is "fits none of the declared arms", and the
398
+ * message carries the allowed values so the author still sees the list.
399
+ */
400
+ function checkType(tag, input, value) {
401
+ const arms = inputTypeArms(input.type);
402
+ if (arms.length === 0)
403
+ return null;
404
+ if (arms.some((arm) => armAccepts(arm, input, value)))
405
+ return null;
406
+ if (arms.length === 1 && arms[0] === 'enum') {
407
+ return {
408
+ severity: 'error',
409
+ code: 'invalid-enum',
410
+ message: `<${tag}> prop "${input.name}"=${JSON.stringify(value)} is not one of ${JSON.stringify(enumValues(input))}`,
411
+ tag,
412
+ };
413
+ }
414
+ return {
415
+ severity: arms.includes('enum') ? 'error' : 'warning',
416
+ code: 'type-mismatch',
417
+ message: `<${tag}> prop "${input.name}" expected ${arms
418
+ .map((arm) => armExpectation(arm, input))
419
+ .join(' or ')}`,
420
+ tag,
421
+ };
422
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/sdui-parser",
3
- "version": "17.5.0",
3
+ "version": "17.7.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -25,13 +25,13 @@
25
25
  "dist"
26
26
  ],
27
27
  "devDependencies": {
28
- "@objectstack/spec": "^17.0.0-rc.6",
29
- "@object-ui/core": "17.5.0",
30
- "@object-ui/react": "17.5.0"
28
+ "@objectstack/spec": "^17.0.0",
29
+ "@object-ui/core": "17.7.0",
30
+ "@object-ui/react": "17.7.0"
31
31
  },
32
32
  "scripts": {
33
- "build": "tsc",
34
- "test": "vitest run",
33
+ "build": "tsc && node ../../scripts/check-dist-completeness.mjs",
34
+ "test": "vitest run --root ../.. packages/sdui-parser/",
35
35
  "type-check": "tsc --noEmit && tsc -p tsconfig.test.json",
36
36
  "lint": "eslint ."
37
37
  }