@object-ui/sdui-parser 17.6.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,24 +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
12
  import { inputTypeArms } from './input-type.js';
10
13
  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',
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
+ },
22
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');
23
128
  const isExpr = (v) => typeof v === 'object' && v !== null && '$expr' in v;
24
129
  export function validateTree(tree, manifest) {
25
130
  const diagnostics = [];
@@ -56,9 +161,23 @@ export function validateTree(tree, manifest) {
56
161
  for (const [key, value] of Object.entries(node)) {
57
162
  if (BASE_PROPS.has(key))
58
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;
59
169
  const input = byName.get(key);
60
170
  if (!input) {
61
- 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 ?? {
62
181
  severity: 'warning',
63
182
  code: 'unknown-prop',
64
183
  message: `<${node.type}> has no prop "${key}"`,
@@ -69,14 +188,64 @@ export function validateTree(tree, manifest) {
69
188
  if (input.binding) {
70
189
  bindings.push({ tag: node.type, input: key, kind: input.binding, value });
71
190
  }
72
- 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 {
73
230
  const typeDiag = checkType(node.type, input, value);
74
- if (typeDiag)
231
+ if (typeDiag) {
75
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
+ }
76
243
  }
77
244
  }
78
- // containment
79
- 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)) {
80
249
  diagnostics.push({
81
250
  severity: 'warning',
82
251
  code: 'not-a-container',
@@ -86,7 +255,7 @@ export function validateTree(tree, manifest) {
86
255
  }
87
256
  // Dashboard widgets: an `options` key riding the spec's `.passthrough()`
88
257
  // that no renderer consumes is legal, silent and inert — warn, naming
89
- // the consumed set (objectui#5709 ruling; census + scope in
258
+ // the consumed set (the 2026-08-23 ruling; census + scope in
90
259
  // `./dashboard-widget-options.ts`). Like `not-a-container`, this runs
91
260
  // only for a component the manifest knows: an unresolved tag already
92
261
  // drew `unknown-component`, and deep diagnostics on it would be noise.
@@ -148,6 +317,63 @@ function armExpectation(arm, input) {
148
317
  return 'a string';
149
318
  }
150
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
+ }
151
377
  /**
152
378
  * Coarse type check, over the arms an input declares (objectui#3832).
153
379
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/sdui-parser",
3
- "version": "17.6.0",
3
+ "version": "17.7.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "license": "MIT",
@@ -26,12 +26,12 @@
26
26
  ],
27
27
  "devDependencies": {
28
28
  "@objectstack/spec": "^17.0.0",
29
- "@object-ui/core": "17.6.0",
30
- "@object-ui/react": "17.6.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
  }