@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.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * ObjectUI — the retired `body` child-list dialect (objectui#6771)
3
+ *
4
+ * `body` was a declared twin of `children` on `BaseSchema`, and for a long
5
+ * while it was the ONLY child-list key a dozen registrations read. This tier
6
+ * never knew it: `BASE_PROPS` lists `children` and not `body`, so the one
7
+ * spelling that resolved drew `unknown-prop` — the same warning a typo draws,
8
+ * on the tier built to accept AI-authored pages, where the diagnostic IS the
9
+ * contract. The maintainer ruled the retirement (2026-09-01, option B): one
10
+ * concept, one spelling, and the spelling is `children`.
11
+ *
12
+ * This module is the tier's half of that ruling. It REPLACES the diagnostic the
13
+ * prop walk would otherwise emit for `body`, rather than adding to it — two
14
+ * diagnostics for one mistake is the shape `checkMemberTypes` already refuses
15
+ * (objectui#8067).
16
+ *
17
+ * WHY A REPLACEMENT AND NOT A BARE `unknown-prop`. After the retirement
18
+ * "`<badge>` has no prop body" is TRUE — but it is the answer that sent authors
19
+ * the wrong way in the first place. Every `body` node in this repository's own
20
+ * docs, examples and READMEs was migrated in the same change, and an author
21
+ * carrying the old spelling in from anywhere else needs the replacement NAMED,
22
+ * not merely the key refused. The two codes below are the ruling's own words:
23
+ * the tier teaches `children` only, and a `body` child list under a
24
+ * non-container draws the same `not-a-container` the `children` spelling draws.
25
+ *
26
+ * SCOPE — asked INSIDE the `!input` branch, because its claim is about a key
27
+ * nobody declares, so a
28
+ * component that publishes its own `body` input keeps its declared type check
29
+ * untouched. Re-derived against the registry rather than recalled: exactly one
30
+ * registration in the tree declares an input named `body`, and it is
31
+ * `record:alert` (`plugin-detail`, `register('alert', …, { namespace: 'record' })`),
32
+ * whose `body` takes an inline translation map and is not this spelling.
33
+ * Control on the same sweep: `name: 'children'` fires seven times.
34
+ */
35
+ import type { Diagnostic } from './types.js';
36
+ /** The child-list spelling objectui#6771 retired. `children` replaced it. */
37
+ export declare const RETIRED_CHILD_LIST_KEY = "body";
38
+ /**
39
+ * The diagnostic for an authored `body`, or `null` when this module has nothing
40
+ * to say about the key.
41
+ *
42
+ * @param tag the node's component type, for the message and the `tag` field
43
+ * @param key the authored prop name
44
+ * @param value its authored value
45
+ * @param acceptsChildren whether the component declares the `children` slot
46
+ * input — `acceptsChildren(comp)` in `./validate.ts`,
47
+ * the ONE containment predicate this tier has. It used
48
+ * to be `comp.isContainer`; objectui#9910 moved both
49
+ * sites together so the retired spelling and the live
50
+ * one can never disagree about who takes a list.
51
+ */
52
+ export declare function checkRetiredBodyDialect(tag: string, key: string, value: unknown, acceptsChildren: boolean): Diagnostic | null;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * ObjectUI — the retired `body` child-list dialect (objectui#6771)
3
+ *
4
+ * `body` was a declared twin of `children` on `BaseSchema`, and for a long
5
+ * while it was the ONLY child-list key a dozen registrations read. This tier
6
+ * never knew it: `BASE_PROPS` lists `children` and not `body`, so the one
7
+ * spelling that resolved drew `unknown-prop` — the same warning a typo draws,
8
+ * on the tier built to accept AI-authored pages, where the diagnostic IS the
9
+ * contract. The maintainer ruled the retirement (2026-09-01, option B): one
10
+ * concept, one spelling, and the spelling is `children`.
11
+ *
12
+ * This module is the tier's half of that ruling. It REPLACES the diagnostic the
13
+ * prop walk would otherwise emit for `body`, rather than adding to it — two
14
+ * diagnostics for one mistake is the shape `checkMemberTypes` already refuses
15
+ * (objectui#8067).
16
+ *
17
+ * WHY A REPLACEMENT AND NOT A BARE `unknown-prop`. After the retirement
18
+ * "`<badge>` has no prop body" is TRUE — but it is the answer that sent authors
19
+ * the wrong way in the first place. Every `body` node in this repository's own
20
+ * docs, examples and READMEs was migrated in the same change, and an author
21
+ * carrying the old spelling in from anywhere else needs the replacement NAMED,
22
+ * not merely the key refused. The two codes below are the ruling's own words:
23
+ * the tier teaches `children` only, and a `body` child list under a
24
+ * non-container draws the same `not-a-container` the `children` spelling draws.
25
+ *
26
+ * SCOPE — asked INSIDE the `!input` branch, because its claim is about a key
27
+ * nobody declares, so a
28
+ * component that publishes its own `body` input keeps its declared type check
29
+ * untouched. Re-derived against the registry rather than recalled: exactly one
30
+ * registration in the tree declares an input named `body`, and it is
31
+ * `record:alert` (`plugin-detail`, `register('alert', …, { namespace: 'record' })`),
32
+ * whose `body` takes an inline translation map and is not this spelling.
33
+ * Control on the same sweep: `name: 'children'` fires seven times.
34
+ */
35
+ /** The child-list spelling objectui#6771 retired. `children` replaced it. */
36
+ export const RETIRED_CHILD_LIST_KEY = 'body';
37
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
38
+ /**
39
+ * Is this value shaped like the child list the dialect used to carry?
40
+ *
41
+ * A list of nodes, or the bare single node `FlatContent` and `renderChildren`
42
+ * have always accepted. A scalar is not — `body="hi"` is a dead key and nothing
43
+ * more, so it does not get the containment sentence, which would name a shape
44
+ * its author did not write.
45
+ */
46
+ const isChildList = (value) => Array.isArray(value) || isPlainObject(value);
47
+ /**
48
+ * The diagnostic for an authored `body`, or `null` when this module has nothing
49
+ * to say about the key.
50
+ *
51
+ * @param tag the node's component type, for the message and the `tag` field
52
+ * @param key the authored prop name
53
+ * @param value its authored value
54
+ * @param acceptsChildren whether the component declares the `children` slot
55
+ * input — `acceptsChildren(comp)` in `./validate.ts`,
56
+ * the ONE containment predicate this tier has. It used
57
+ * to be `comp.isContainer`; objectui#9910 moved both
58
+ * sites together so the retired spelling and the live
59
+ * one can never disagree about who takes a list.
60
+ */
61
+ export function checkRetiredBodyDialect(tag, key, value, acceptsChildren) {
62
+ if (key !== RETIRED_CHILD_LIST_KEY)
63
+ return null;
64
+ if (isChildList(value) && !acceptsChildren) {
65
+ // The ruling, verbatim: "a `body` child list under a non-container draws
66
+ // the same `not-a-container` the `children` spelling draws". Same code, so
67
+ // a consumer keying on the code cannot tell the two spellings apart — which
68
+ // is the single-spelling rule stated in the diagnostic stream. The sentence
69
+ // carries the extra fact the `children` path has no need of.
70
+ return {
71
+ severity: 'warning',
72
+ code: 'not-a-container',
73
+ message: `<${tag}> does not accept children — and "body" is the retired spelling of ` +
74
+ `"children" (objectui#6771), so this child list has no door here at all`,
75
+ tag,
76
+ };
77
+ }
78
+ return {
79
+ severity: 'warning',
80
+ code: 'unknown-prop',
81
+ message: `<${tag}> has no prop "body" — the child-list key is "children" ` +
82
+ `("body" was retired by objectui#6771)`,
83
+ tag,
84
+ };
85
+ }
package/dist/codegen.d.ts CHANGED
@@ -19,5 +19,10 @@ export declare function propsName(type: string): string;
19
19
  /**
20
20
  * Generate the human-facing PUBLIC block list (the curated "清单") from a
21
21
  * manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
22
+ *
23
+ * The html tier's intrinsic elements (`tier: 'html'`, objectui#10735) are
24
+ * listed in a section of their own, under their own count: they are in the
25
+ * manifest because a `kind:'html'` page may author them, not because they are
26
+ * curated blocks, and a single table would read them as the latter.
22
27
  */
23
28
  export declare function generateBlockList(manifest: Manifest): string;
package/dist/codegen.js CHANGED
@@ -7,6 +7,24 @@
7
7
  * fiction — no real React intrinsic named `flex` exists; the interpreter
8
8
  * fulfills it via the registry at render time.
9
9
  */
10
+ import { inputTypeArms } from './input-type.js';
11
+ import { SDUI_BASE_PROPS } from './validate.js';
12
+ /**
13
+ * `SduiBaseProps`, emitted from {@link SDUI_BASE_PROPS} — the list
14
+ * `validateTree` reads — so the generated types and the validator cannot
15
+ * disagree about a base prop (objectui#11044). ⛔ Never a second hand-kept
16
+ * copy of the list here: that is how this interface came to refuse `bind` and
17
+ * `hidden` after the validator accepted them.
18
+ */
19
+ const emitBaseProps = () => `export interface SduiBaseProps {\n${SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null)
20
+ .map((prop) => ` ${prop.name}?: ${prop.tsType};`)
21
+ .join('\n')}\n}`;
22
+ /**
23
+ * The attributes `SduiBaseProps` carries — every entry of the list but `type`,
24
+ * whatever its scope. A declared input of the same name outranks each of them
25
+ * in the generated types (objectui#11044, objectui#11075).
26
+ */
27
+ const BASE_ATTRIBUTES = new Set(SDUI_BASE_PROPS.filter((prop) => prop.tsType !== null).map((prop) => prop.name));
10
28
  export function generateDts(manifest, options = {}) {
11
29
  const { standaloneJsx = true } = options;
12
30
  const comps = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
@@ -26,16 +44,7 @@ export function generateDts(manifest, options = {}) {
26
44
  // Source of truth: ComponentRegistry inputs (ADR-0080 §3). Regenerate via codegen.
27
45
  /* eslint-disable */
28
46
 
29
- export interface SduiBaseProps {
30
- id?: string;
31
- className?: string;
32
- style?: Record<string, unknown>;
33
- visible?: boolean;
34
- visibleOn?: string;
35
- disabled?: boolean;
36
- disabledOn?: string;
37
- children?: unknown;
38
- }
47
+ ${emitBaseProps()}
39
48
 
40
49
  ${interfaces}
41
50
 
@@ -50,40 +59,105 @@ ${intrinsics}
50
59
  export {};
51
60
  `;
52
61
  }
62
+ /**
63
+ * A `'slot'` arm names a child position, not a prop value — `SduiBaseProps`
64
+ * already types `children`, so a slot input contributes no attribute. An input
65
+ * is therefore emitted when it has at least one NON-slot arm, and typed from
66
+ * those arms only (objectui#3832): the old test was `i.type !== 'slot'`, which a
67
+ * union like `['slot', 'string']` would have passed while `tsType` fell through
68
+ * to the default and typed it `string` anyway.
69
+ *
70
+ * A base attribute the component declares is `Omit`ted from the base it
71
+ * extends, whatever its scope: the declared input wins with its type. So a
72
+ * declared type the base one does not admit is no TS2430 conflict — neither
73
+ * `label?: string | Record<string, unknown>` on a `'where-undeclared'` member
74
+ * (objectui#11044) nor `record:alert`'s
75
+ * `visible?: boolean | string | Record<string, unknown>` on an `'every-node'`
76
+ * one (objectui#11075) — and one the base admits (`className?: string`) types
77
+ * the same either way.
78
+ *
79
+ * The scope is the VALIDATOR's distinction — `validateTree` skips an
80
+ * `'every-node'` key before the declared-input lookup — and does not reach this
81
+ * surface. ⛔ Every declared base attribute is `Omit`ted, not only the ones
82
+ * whose types conflict: assignability is TypeScript's judgement, and a
83
+ * conflicts-only rule here would re-derive it from type strings.
84
+ *
85
+ * A slot-only input emits no attribute, so a declared `children: 'slot'` is
86
+ * not `Omit`ted and the base `children` stays.
87
+ */
53
88
  function emitInterface(comp) {
54
- const lines = comp.inputs
55
- .filter((i) => i.type !== 'slot')
56
- .map((i) => ` ${propLine(i)}`)
57
- .join('\n');
58
- return `export interface ${propsName(comp.type)} extends SduiBaseProps {\n${lines}\n}`;
89
+ const emitted = comp.inputs.filter((i) => valueArms(i).length > 0);
90
+ const lines = emitted.map((i) => ` ${propLine(i)}`).join('\n');
91
+ const yielded = emitted.filter((i) => BASE_ATTRIBUTES.has(i.name)).map((i) => JSON.stringify(i.name));
92
+ const base = yielded.length > 0 ? `Omit<SduiBaseProps, ${yielded.join(' | ')}>` : 'SduiBaseProps';
93
+ return `export interface ${propsName(comp.type)} extends ${base} {\n${lines}\n}`;
59
94
  }
95
+ /** The arms that describe a VALUE (every arm except `'slot'`). */
96
+ const valueArms = (input) => inputTypeArms(input.type).filter((arm) => arm !== 'slot');
60
97
  function propLine(input) {
61
98
  const opt = input.required ? '' : '?';
62
99
  return `${quoteKeyIfNeeded(input.name)}${opt}: ${tsType(input)};`;
63
100
  }
64
- function tsType(input) {
65
- switch (input.type) {
101
+ /**
102
+ * The TypeScript type for one arm. Kinds that are a string with a narrower
103
+ * authoring control (`color`, `date`, `code`, `file`) are `string` here, as
104
+ * before — the JSX surface types the VALUE, and the control kind is the
105
+ * designer's business.
106
+ */
107
+ function armTsType(arm, input) {
108
+ switch (arm) {
66
109
  case 'number':
67
110
  return 'number';
68
111
  case 'boolean':
69
112
  return 'boolean';
70
113
  case 'array':
71
- return 'unknown[]';
114
+ return `${memberTsType(input)}[]`;
72
115
  case 'object':
73
- return 'Record<string, unknown>';
116
+ return `Record<string, ${memberTsType(input)}>`;
74
117
  case 'enum': {
75
118
  const vals = (input.enum ?? []).map((e) => (typeof e === 'object' ? e.value : e));
76
119
  return vals.length ? vals.map((v) => JSON.stringify(v)).join(' | ') : 'string';
77
120
  }
78
- case 'string':
79
- case 'color':
80
- case 'date':
81
- case 'code':
82
- case 'file':
83
121
  default:
84
122
  return 'string';
85
123
  }
86
124
  }
125
+ /**
126
+ * The TypeScript type of a container's MEMBERS, from `ManifestInput.of`
127
+ * (objectui#8067).
128
+ *
129
+ * `unknown` when nothing is declared, which is what every input published
130
+ * before `of` existed says — so an undeclared array still emits `unknown[]` and
131
+ * an undeclared object still emits `Record<string, unknown>`, byte for byte.
132
+ * A declared member kind narrows it: `of: 'string'` on `page:header.actions`
133
+ * turns `unknown[]` into `string[]`, and the JSX surface finally types the
134
+ * action IDs the contract has always required.
135
+ *
136
+ * `'slot'` is dropped for the same reason {@link valueArms} drops it one level
137
+ * up: it names a child position, not a value, so it contributes no member type.
138
+ * If that leaves nothing, the result is `unknown` — a declaration that types no
139
+ * member must not silently type them all as strings.
140
+ */
141
+ function memberTsType(input) {
142
+ const arms = inputTypeArms(input.of).filter((arm) => arm !== 'slot');
143
+ if (arms.length === 0)
144
+ return 'unknown';
145
+ const emitted = [...new Set(arms.map((arm) => armTsType(arm, { ...input, of: undefined })))];
146
+ return emitted.length === 1 ? emitted[0] : `(${emitted.join(' | ')})`;
147
+ }
148
+ /**
149
+ * A union declaration emits a TypeScript union, so the `.d.ts` an author
150
+ * type-checks their page against accepts exactly the arms the manifest gate
151
+ * accepts. Arms that collapse to the same TS type (`string` and `color`, say)
152
+ * are de-duplicated rather than emitted as `string | string`.
153
+ */
154
+ function tsType(input) {
155
+ const arms = valueArms(input);
156
+ if (arms.length === 0)
157
+ return 'string';
158
+ const emitted = [...new Set(arms.map((arm) => armTsType(arm, input)))];
159
+ return emitted.join(' | ');
160
+ }
87
161
  const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
88
162
  const quoteKeyIfNeeded = (name) => (IDENT.test(name) ? name : JSON.stringify(name));
89
163
  /** 'object-grid' -> 'ObjectGrid', 'record:details' -> 'RecordDetails' */
@@ -98,23 +172,40 @@ export function propsName(type) {
98
172
  /**
99
173
  * Generate the human-facing PUBLIC block list (the curated "清单") from a
100
174
  * manifest — a Markdown table. Derived, never hand-maintained (ADR-0046).
175
+ *
176
+ * The html tier's intrinsic elements (`tier: 'html'`, objectui#10735) are
177
+ * listed in a section of their own, under their own count: they are in the
178
+ * manifest because a `kind:'html'` page may author them, not because they are
179
+ * curated blocks, and a single table would read them as the latter.
101
180
  */
102
181
  export function generateBlockList(manifest) {
103
- const rows = Object.values(manifest.components)
104
- .sort((a, b) => a.type.localeCompare(b.type))
105
- .map((c) => {
182
+ const sorted = Object.values(manifest.components).sort((a, b) => a.type.localeCompare(b.type));
183
+ const curated = sorted.filter((c) => c.tier !== 'html');
184
+ const htmlTier = sorted.filter((c) => c.tier === 'html');
185
+ const row = (c) => {
106
186
  const req = c.inputs.filter((i) => i.required).map((i) => i.name);
107
187
  const binds = c.inputs.filter((i) => i.binding).map((i) => `${i.name}:${i.binding}`);
108
188
  return `| \`${c.type}\` | ${c.namespace ?? '—'} | ${c.isContainer ? '✓' : ''} | ${req.join(', ') || '—'} | ${binds.join(', ') || '—'} |`;
109
- });
189
+ };
190
+ const header = ['| block | plugin | container | required props | bindings |', '|---|---|---|---|---|'];
110
191
  return [
111
- `# SDUI public blocks (${Object.keys(manifest.components).length})`,
192
+ `# SDUI public blocks (${curated.length})`,
112
193
  '',
113
194
  '> Auto-generated from the registry `tier:\'public\'` set (ADR-0080). Do not edit by hand.',
114
195
  '',
115
- '| block | plugin | container | required props | bindings |',
116
- '|---|---|---|---|---|',
117
- ...rows,
196
+ ...header,
197
+ ...curated.map(row),
198
+ ...(htmlTier.length
199
+ ? [
200
+ '',
201
+ `## html tier intrinsic elements (${htmlTier.length})`,
202
+ '',
203
+ "> The intrinsic HTML tags a `kind:'html'` page may author (ADR-0081 §2), declared by the registry's `HTML_TIER_INTRINSICS` roster and marked `tier: 'html'` in the manifest. Not part of the curated vocabulary above.",
204
+ '',
205
+ ...header,
206
+ ...htmlTier.map(row),
207
+ ]
208
+ : []),
118
209
  '',
119
210
  ].join('\n');
120
211
  }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * ObjectUI — unconsumed dashboard-widget `options` keys (`8d58f46b4`)
3
+ *
4
+ * `@objectstack/spec`'s `DashboardWidgetOptionsSchema` ends in `.passthrough()`
5
+ * ("declared query keys + open renderer extras"), so ANY key parses, validates
6
+ * and lints cleanly — including one no renderer reads. That is how a showcase
7
+ * dashboard shipped `options: { invert: true }` on a gauge with a comment
8
+ * saying what it was believed to do, and rendered the un-inverted measure with
9
+ * no diagnostic anywhere (`8d58f46b4`). The 2026-08-23 maintainer ruling:
10
+ * open extras stay open — they just stop being SILENT. A key that reaches no
11
+ * renderer draws a WARNING naming the consumed set. Not an error: no gate
12
+ * weakening and no new red gates were ruled.
13
+ *
14
+ * ## The census (measured on origin/main @ 8689166f6, spec 17.1.0)
15
+ *
16
+ * The spec REQUIRES `dataset` on every widget (`DashboardWidgetSchema` —
17
+ * re-read for objectui#7293 against the PUBLISHED `@objectstack/spec@17.4.0`,
18
+ * whose required keys are exactly `id` / `dataset` / `values`), and
19
+ * both dashboard surfaces route a dataset-bound widget to `DatasetWidget`
20
+ * (`DashboardRenderer.tsx` renders `<DatasetWidget>` when `widget.dataset` is
21
+ * set; `DashboardGridLayout.tsx` mirrors the fork, objectui#4614). On that —
22
+ * the only spec-legal — path, the renderer-consumed `options` keys are exactly
23
+ * the five the spec DECLARES, all read as direct `options.<key>` accesses in
24
+ * `packages/plugin-dashboard/src/DatasetWidget.tsx`:
25
+ *
26
+ * dateGranularity, sortBy, sortOrder, limit (query-affecting, framework#3588)
27
+ * stageOrder (funnel/pyramid stage order)
28
+ *
29
+ * plus ONE undeclared key with a real read site:
30
+ *
31
+ * description — the metric-card sub-caption channel. Read at
32
+ * `widgetSubCaption.ts` (`(widget.options as …)?.description`, objectui#4032
33
+ * item 4 — that read sat inline in `DashboardRenderer.tsx` until
34
+ * objectui#8889 moved it, verbatim, into the hook BOTH dashboard surfaces
35
+ * now call, so the authored and bundle channels compose at one decision
36
+ * point) and, since objectui#7293, at `DatasetWidget.tsx`'s
37
+ * metric branch, which renders it in the caption row; the server's
38
+ * `translateDashboard` OVERLAYS the `widgets.{id}.subCaption` translation
39
+ * onto this key (objectstack#8056, objectstack#5428 item-4: 「两个作者字段两个
40
+ * key」). It entered the accepted set on the translation-pipeline evidence
41
+ * alone — warning on a key the platform's own pipeline writes would be a
42
+ * false positive on legal metadata — while the dataset-bound render path
43
+ * displayed it nowhere. #7293 closed that gap, so `description` is now
44
+ * accepted for the same reason as the declared five and the accepted set no
45
+ * longer outruns the measured read set.
46
+ *
47
+ * Notably NOT consumed on the path a widget really renders through:
48
+ * `thresholds` and `format`. Both were widely believed to work; both draw this
49
+ * warning, which is the point. They are NOT the same kind of claim, and since
50
+ * the 2026-08-25 maintainer ruling (batch adjudication close-out, decision B1 —
51
+ * a closure claim must be BOUNDED or DERIVABLE, and a claim written in two
52
+ * places is single-sourced) they are written separately (objectui#6186):
53
+ *
54
+ * `thresholds` — zero read sites repo-wide. This paragraph is the CANONICAL
55
+ * statement of that closure claim. `content/docs/plugins/plugin-dashboard.mdx`
56
+ * used to assert it a second time in its own words; two copies of one
57
+ * closure claim drift apart independently and neither knows when the other
58
+ * stopped being true, so the page now POINTS here rather than restating it.
59
+ * Single-sourcing is what makes this copy load-bearing, so it is DERIVED
60
+ * rather than trusted:
61
+ * `scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts` re-scans
62
+ * every JS/TS file git tracks for an access to a key of that name and fails
63
+ * if one appears. Land a renderer that reads it and this module goes red in
64
+ * the same run.
65
+ * `format` — not read ON THE DATASET-BOUND PATH; the value is formatted with
66
+ * the MEASURE's own metadata (`measureField(...).format`, from the dataset
67
+ * definition), not from the widget's bag. Deliberately a BOUNDED claim and
68
+ * not a repo-wide one: `format` is a live key in other vocabularies, so a
69
+ * repo-wide scan would red on a TRUE claim — and an assertion that reds on
70
+ * legitimate code gets deleted by the next person who hits it, which puts
71
+ * the claim back where it started. Leg 2 of
72
+ * `__tests__/dashboard-widget-options-census.test.ts` derives exactly this
73
+ * bound: the DatasetWidget read set is the declared set plus the
74
+ * sub-caption key, and neither `format` nor `thresholds` is in it. Since
75
+ * objectui#7293 that absence is asserted in its OWN right rather than
76
+ * riding on an equality whose right-hand side can grow.
77
+ *
78
+ * ## Scope — where the warning deliberately does NOT fire
79
+ *
80
+ * - Widgets WITHOUT `dataset`: the legacy inline forms (`options.data`
81
+ * arrays, `provider: 'object'` bags) consume a much larger, spread-shaped
82
+ * key set (`{ ...options }` into `metric` / `object-metric` /
83
+ * `data-table` / …), whose true reach is each child component's prop
84
+ * surface. That form is spec-illegal today (`dataset` is required) and its
85
+ * census would be the unmaintainable one; skipping it keeps every warning
86
+ * this module emits a statement about the path the widget actually renders
87
+ * through.
88
+ * - Widgets in the legacy COMPONENT format (`widget.component`): `options`
89
+ * is not part of that contract.
90
+ * - Widgets carrying the spec's own escape hatch
91
+ * `suppressWarnings: ['unconsumed-widget-option']` — the spec models
92
+ * per-widget diagnostic suppression (`DashboardWidgetSchema`), so an
93
+ * author with a genuine out-of-band consumer can say so in metadata.
94
+ *
95
+ * ## Maintenance — how this list stays true
96
+ *
97
+ * `__tests__/dashboard-widget-options-census.test.ts` re-runs the census on
98
+ * every test run: it re-derives the five declared keys from the installed
99
+ * `@objectstack/spec`, re-extracts the `options.<key>` reads from
100
+ * `DatasetWidget.tsx` source text (and fails loudly if that file gains a
101
+ * consumption shape the extractor cannot see — a spread, a destructuring, a
102
+ * computed access), re-checks the sub-caption read site, and trips on any NEW
103
+ * file in `packages/*\/src` or `apps/*\/src` that starts reading
104
+ * `widget.options`. A renderer change that adds or removes a consumed key
105
+ * fails that test until this list is updated — the cost of keeping this
106
+ * warning honest is editing ONE array below plus re-reading the census notes.
107
+ */
108
+ import type { Diagnostic, SchemaElement } from './types.js';
109
+ /** The diagnostic `code` — also the id `suppressWarnings` suppresses. */
110
+ export declare const UNCONSUMED_WIDGET_OPTION = "unconsumed-widget-option";
111
+ /**
112
+ * Component types that host a dashboard `widgets` array. Both resolve to the
113
+ * surfaces measured by the census above (`DashboardRenderer`,
114
+ * `DashboardGridLayout`), which share one dispatch (`widgetDispatch.ts`).
115
+ */
116
+ export declare const DASHBOARD_WIDGET_HOST_TYPES: ReadonlySet<string>;
117
+ /**
118
+ * The accepted set: every `options` key with a renderer read site on the
119
+ * dataset-bound path, plus the sub-caption convention key. Alphabetical; the
120
+ * warning message prints it verbatim. Derivation and evidence: file header.
121
+ */
122
+ export declare const CONSUMED_WIDGET_OPTION_KEYS: readonly string[];
123
+ /**
124
+ * Diagnostics for `options` keys no renderer consumes, over one dashboard-host
125
+ * node's `widgets` array. Pure and shallow by design: it never descends into
126
+ * `children` (the caller's walk owns that) and answers `[]` for every shape
127
+ * outside its census — see the scope notes in the file header.
128
+ */
129
+ export declare function checkDashboardWidgetOptions(node: SchemaElement): Diagnostic[];
@@ -0,0 +1,73 @@
1
+ /** The diagnostic `code` — also the id `suppressWarnings` suppresses. */
2
+ export const UNCONSUMED_WIDGET_OPTION = 'unconsumed-widget-option';
3
+ /**
4
+ * Component types that host a dashboard `widgets` array. Both resolve to the
5
+ * surfaces measured by the census above (`DashboardRenderer`,
6
+ * `DashboardGridLayout`), which share one dispatch (`widgetDispatch.ts`).
7
+ */
8
+ export const DASHBOARD_WIDGET_HOST_TYPES = new Set([
9
+ 'dashboard',
10
+ 'dashboard-grid',
11
+ ]);
12
+ /**
13
+ * The accepted set: every `options` key with a renderer read site on the
14
+ * dataset-bound path, plus the sub-caption convention key. Alphabetical; the
15
+ * warning message prints it verbatim. Derivation and evidence: file header.
16
+ */
17
+ export const CONSUMED_WIDGET_OPTION_KEYS = [
18
+ 'dateGranularity',
19
+ 'description',
20
+ 'limit',
21
+ 'sortBy',
22
+ 'sortOrder',
23
+ 'stageOrder',
24
+ ];
25
+ const CONSUMED = new Set(CONSUMED_WIDGET_OPTION_KEYS);
26
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
27
+ /** The parser's deferred-expression marker — opaque here, never evaluated. */
28
+ const isExpr = (v) => isPlainObject(v) && '$expr' in v;
29
+ /**
30
+ * Diagnostics for `options` keys no renderer consumes, over one dashboard-host
31
+ * node's `widgets` array. Pure and shallow by design: it never descends into
32
+ * `children` (the caller's walk owns that) and answers `[]` for every shape
33
+ * outside its census — see the scope notes in the file header.
34
+ */
35
+ export function checkDashboardWidgetOptions(node) {
36
+ if (!DASHBOARD_WIDGET_HOST_TYPES.has(node.type))
37
+ return [];
38
+ const widgets = node.widgets;
39
+ if (!Array.isArray(widgets))
40
+ return [];
41
+ const diagnostics = [];
42
+ widgets.forEach((widget, index) => {
43
+ if (!isPlainObject(widget) || isExpr(widget))
44
+ return;
45
+ // Legacy component format: `options` is not part of that contract.
46
+ if (widget.component !== undefined)
47
+ return;
48
+ // Only the dataset-bound (spec-legal) path is censused — see file header.
49
+ if (widget.dataset === undefined || widget.dataset === null || widget.dataset === '')
50
+ return;
51
+ const options = widget.options;
52
+ if (!isPlainObject(options) || isExpr(options))
53
+ return;
54
+ if (Array.isArray(widget.suppressWarnings) &&
55
+ widget.suppressWarnings.includes(UNCONSUMED_WIDGET_OPTION)) {
56
+ return;
57
+ }
58
+ const label = typeof widget.id === 'string' && widget.id !== '' ? widget.id : `#${index}`;
59
+ const widgetType = typeof widget.type === 'string' && widget.type !== '' ? widget.type : 'widget';
60
+ for (const key of Object.keys(options)) {
61
+ if (CONSUMED.has(key))
62
+ continue;
63
+ diagnostics.push({
64
+ severity: 'warning',
65
+ code: UNCONSUMED_WIDGET_OPTION,
66
+ message: `<${node.type}> widget "${label}" (${widgetType}): options.${key} reaches no renderer — ` +
67
+ `dashboard widget renderers read only: ${CONSUMED_WIDGET_OPTION_KEYS.join(', ')}`,
68
+ tag: node.type,
69
+ });
70
+ }
71
+ });
72
+ return diagnostics;
73
+ }
package/dist/index.d.ts CHANGED
@@ -7,9 +7,14 @@
7
7
  */
8
8
  export * from './types.js';
9
9
  export { parseJsx, interpretBrace } from './parse.js';
10
- export { validateTree } from './validate.js';
10
+ export { HTML_TIER_NODE, isHtmlTierNode, markHtmlTierNode } from './provenance.js';
11
+ export { validateTree, acceptsChildren, CHILD_LIST_KEY, SDUI_BASE_PROPS } from './validate.js';
12
+ export type { SduiBaseProp, SduiBasePropScope } from './validate.js';
13
+ export { checkDashboardWidgetOptions, CONSUMED_WIDGET_OPTION_KEYS, DASHBOARD_WIDGET_HOST_TYPES, UNCONSUMED_WIDGET_OPTION, } from './dashboard-widget-options.js';
11
14
  export { generateDts, propsName, generateBlockList } from './codegen.js';
12
15
  export type { CodegenOptions } from './codegen.js';
16
+ export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js';
17
+ export { checkRetiredBodyDialect, RETIRED_CHILD_LIST_KEY } from './body-dialect.js';
13
18
  import type { Diagnostic, Manifest, SchemaElement, ManifestValidationResult } from './types.js';
14
19
  export interface CompileResult {
15
20
  tree: SchemaElement | null;
@@ -27,20 +32,72 @@ export declare function compile(source: string, manifest: Manifest): CompileResu
27
32
  export interface RegistryConfigLike {
28
33
  type: string;
29
34
  namespace?: string;
35
+ /**
36
+ * LAYOUT containment — the registration's `ComponentMeta.isContainer`,
37
+ * carried through to the manifest for the consumers that read it there.
38
+ * ⛔ It does not mean "accepts children" and `validateTree` does not read
39
+ * it: whether a component takes an authored child list is declared as an
40
+ * input named `children` (objectui#9910; `acceptsChildren` in `validate.ts`).
41
+ */
30
42
  isContainer?: boolean;
31
- /** ADR-0080 contract tier — only 'public' configs form the AI/contract surface. */
32
- tier?: 'public' | 'internal';
43
+ /**
44
+ * ADR-0080 contract tier — only 'public' configs form the AI/contract
45
+ * surface under `publicOnly`. `'html'` is the projection-only stamp
46
+ * `ComponentRegistry.getPublicConfigs()` puts on the html tier's intrinsic
47
+ * elements (objectui#10735); it is the one value {@link manifestFromConfigs}
48
+ * carries into `ManifestComponent.tier`.
49
+ */
50
+ tier?: 'public' | 'internal' | 'html';
33
51
  label?: string;
34
52
  category?: string;
35
53
  inputs?: Array<{
36
54
  name: string;
37
- type: string;
55
+ /**
56
+ * One coarse kind, or the arms of a union (objectui#3832). Typed loosely
57
+ * (`string`) on purpose — this interface is the STRUCTURAL boundary that
58
+ * keeps this package free of a dependency on the registry, so an
59
+ * off-vocabulary value has to be representable here and is normalized by
60
+ * `canonicalizeInputType` on the way in.
61
+ */
62
+ type: string | string[];
63
+ /**
64
+ * The declared member kind(s) — array elements, or an object map's values
65
+ * (objectui#8067). Typed as loosely as `type` above and for the same
66
+ * reason: this interface is the STRUCTURAL boundary that keeps the package
67
+ * free of a registry dependency, so an off-vocabulary value has to be
68
+ * representable here and is normalized on the way in.
69
+ */
70
+ of?: string | string[];
38
71
  required?: boolean;
39
72
  enum?: Array<string | {
40
73
  value: unknown;
41
74
  label?: string;
42
75
  }>;
43
- binding?: 'object' | 'field';
76
+ /**
77
+ * The binding marker — exactly `'object'` (`9e37d9b39`).
78
+ *
79
+ * `'field'` was declared beside it from the first draft of ADR-0080 §6.3
80
+ * and never written: every `binding:` literal in `packages/`, `apps/`
81
+ * and `examples/` is `'object'` (7 of 7, measured 2026-09-06 and
82
+ * re-measured at the retiring PR's merge-base), and nothing on either
83
+ * side of the manifest resolved a field binding. Retired under ADR-0049
84
+ * enforce-or-remove by the maintainer ruling of 2026-09-07 (decision
85
+ * batch #69): a config that spells the arm is a `tsc` error here rather
86
+ * than an entry the server would never resolve.
87
+ *
88
+ * The retirement then reached the two faces the ruling did not name
89
+ * (objectui#8315): `ManifestInput.binding` and
90
+ * `ManifestValidationResult.bindings[].kind` in `types.ts` read `'object'`
91
+ * too, so the published package no longer narrows on one face and stays
92
+ * wide on the other. The argument for keeping the manifest face wide —
93
+ * producer → reader is a subset relation — is answered at
94
+ * `ManifestInput.binding`, which is where a later reader lands.
95
+ *
96
+ * Only the framework writes the key: `InjectedComponentInput` in
97
+ * `@object-ui/types`, spliced by `Registry.register`. `ComponentInput`
98
+ * itself has no `binding` member, deliberately.
99
+ */
100
+ binding?: 'object';
44
101
  description?: string;
45
102
  }>;
46
103
  /**