Skip to content

Commit 8beb3de

Browse files
os-elonclaude
andauthored
fix(sdui-parser): port the unconsumed dashboard-widget-option warning in lockstep with objectui (#12927)
`DashboardWidgetOptionsSchema` ends in `.passthrough()`, so a dashboard widget `options` key no renderer reads parses, validates and lints cleanly — legal, silent and inert. objectui's copy of this parser has warned on that since the objectui#5709 ruling (2026-08-23); this repo's hoisted copy emitted nothing, so the same authored page produced a diagnostic on one surface and silence on the other — the dialect split the two copies' invariant forbids. Ports `checkDashboardWidgetOptions` and its module, and calls it at the end of `validateTree`'s known-component branch. The module's executable region is a byte-equal port of objectui's save for ONE token, stated at the site: the emitted `code` is an inline literal here rather than the exported constant, because `check:dispatcher-error-vocabulary` reads a SCREAMING_SNAKE constant at a `code:` position and cannot reduce a kebab-case value, and that finding cannot be declared away. A test pins the literal equal to `UNCONSUMED_WIDGET_OPTION` so the two spellings cannot drift. The file header is the other deliberate divergence: objectui's cites renderer files and census tests this repo has none of. Additive by construction — `warning` severity only, reporting an already-inert state — so what this copy accepts and rejects is unchanged; a dedicated test pins that. Unit tests are the only witness: this repo resolves no `sdui.manifest.json`, so `validateJsxPages` runs parse-only and `validateTree` is not reached from the production gate today. Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce83058 commit 8beb3de

5 files changed

Lines changed: 582 additions & 0 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/sdui-parser': minor
3+
---
4+
5+
html tier: a dashboard widget `options` key that reaches no renderer now draws an `unconsumed-widget-option` warning naming the consumed set
6+
7+
`@objectstack/spec`'s `DashboardWidgetOptionsSchema` ends in `.passthrough()`
8+
("declared query keys + open renderer extras"), so ANY key parses, validates
9+
and lints cleanly — including one no renderer reads. That is how a dashboard
10+
shipped `options: { invert: true }` on a gauge with a comment saying what it
11+
was believed to do and rendered the un-inverted measure with no diagnostic
12+
anywhere (objectui#5709). The 2026-08-23 maintainer ruling on that card: open
13+
extras stay open — they just stop being **silent**. A key that reaches no
14+
renderer draws a **warning** naming the consumed set.
15+
16+
objectui's copy of this parser has emitted that warning since the ruling
17+
landed; this repo's hoisted copy emitted nothing, so the same authored page
18+
produced a diagnostic on one surface and silence on the other — the dialect
19+
split the two copies' invariant forbids (objectstack#12719 — both copies agree
20+
on the accepted grammar **and** on diagnostic codes). `validateTree` now ends
21+
its known-component branch with `checkDashboardWidgetOptions(node)`, and the
22+
new module is a byte-equal port of objectui's save for one token (the emitted
23+
`code` is spelled as an inline literal rather than through the exported
24+
constant, so this repo's ADR-0112 vocabulary gate can classify it — called out
25+
at the site, and pinned equal to the constant by test), so the emitted `code`,
26+
`severity`, `message` and census scope are identical.
27+
28+
The warning is scoped to the only spec-legal render path: a `dashboard` /
29+
`dashboard-grid` host, a widget with a `dataset`, not in the legacy
30+
`component` format, and not carrying the spec's own
31+
`suppressWarnings: ['unconsumed-widget-option']` escape hatch. The consumed set
32+
is the five keys `DashboardWidgetOptionsSchema` declares (`dateGranularity`,
33+
`sortBy`, `sortOrder`, `limit`, `stageOrder`) plus `description`, the metric
34+
sub-caption channel `translateDashboard` writes into `options`.
35+
36+
New exports for third-party manifest consumers: `checkDashboardWidgetOptions`,
37+
`CONSUMED_WIDGET_OPTION_KEYS`, `DASHBOARD_WIDGET_HOST_TYPES` and
38+
`UNCONSUMED_WIDGET_OPTION` (the diagnostic code, which is also the id
39+
`suppressWarnings` suppresses).
40+
41+
Unlike the union-arm port that preceded it, this change is **additive**: it
42+
reports an already-inert state and emits `warning` only, so what this copy
43+
accepts and rejects is exactly where it stood — pinned by a dedicated test.
44+
Today it is latent in the production gate anyway: this repo resolves no
45+
`sdui.manifest.json`, so `validateJsxPages` runs parse-only and `validateTree`
46+
is not reached from it. Wiring that manifest (the second gap recorded on
47+
objectstack#12719, still unowned) is what makes this author-visible, and this
48+
port lands ahead of that wiring deliberately.
Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
/**
2+
* `unconsumed-widget-option` — the objectui#5709 ruling, ported into this copy
3+
* in lockstep (objectstack#12810).
4+
*
5+
* The 2026-08-23 maintainer ruling on objectui#5709: a dashboard widget
6+
* `options` key riding `DashboardWidgetOptionsSchema.passthrough()` that no
7+
* renderer consumes gets an authoring-time WARNING naming the consumed set.
8+
* `invert` — the key that card was filed over — is the first pinned case, and
9+
* it is pinned here AS A CASE of the general mechanism: the same fixture shape
10+
* with a different dead key on a different widget type must draw the same
11+
* diagnostic, or the mechanism is a special case wearing a general name.
12+
*
13+
* WHY THESE PINS EXIST HERE, AND WHY THEY CARRY MORE WEIGHT THAN OBJECTUI'S.
14+
* Two copies of this parser exist — objectui's `packages/sdui-parser` and this
15+
* hoisted one — and the invariant is that both agree on the accepted grammar
16+
* AND on diagnostic codes. If they drift, the save gate and the renderer speak
17+
* different dialects: a page can save clean and render inert, or the reverse —
18+
* surface-dependent, therefore intermittent from the author's point of view
19+
* (objectstack#12719, objectstack#12810). These pins are the objectstack half
20+
* of that lockstep; the emitted diagnostic is byte-equal to objectui's.
21+
*
22+
* They carry more weight here because they are the ONLY witness. This repo
23+
* resolves no `sdui.manifest.json` (there is none in the tree, and
24+
* `@objectstack/console/dist/sdui.manifest.json` is absent), so
25+
* `resolveSduiManifest()` returns undefined and `validateJsxPages` runs
26+
* parse-only — `validateTree` is not reached from the production gate at all
27+
* today. A green CI therefore proves almost nothing about this module; this
28+
* file is what proves it.
29+
*
30+
* SEVERITY IS PINNED AS WARNING deliberately, and `ok` is pinned true beside
31+
* it. This port reports an ALREADY-inert state (the key was legal, silent and
32+
* ignored before it landed), so it must leave this copy's accept/reject set
33+
* exactly where it stood — that is the whole reason objectstack#12810 was split
34+
* from objectstack#12814, which did change what this copy rejects. A test that
35+
* moves `ok` is reporting a contract change, not a port.
36+
*
37+
* The accepted-set expectations are DERIVED from `CONSUMED_WIDGET_OPTION_KEYS`
38+
* — the same array the implementation prints — never restated, so a census
39+
* update cannot desynchronize this file. The derivation is guarded against
40+
* vacuity first (an empty census would warn on everything and make "names the
41+
* consumed set" trivially true of the empty string).
42+
*
43+
* WHAT THIS FILE CANNOT DERIVE, so that nobody reads it as claiming to.
44+
* objectui's copy sits next to a census test that re-measures the accepted set
45+
* against its renderer source (`DatasetWidget.tsx` read sites). This repo has
46+
* no dashboard renderer at all, so that half is not re-derivable here and is
47+
* not faked: the array is pinned literally below, and the pin names the two
48+
* files to re-read when either half moves — this repo's
49+
* `packages/spec/src/ui/dashboard.zod.ts` for the declared keys, objectui's
50+
* `DatasetWidget.tsx` for the read sites.
51+
*/
52+
import { describe, expect, it } from 'vitest';
53+
import {
54+
compile,
55+
CONSUMED_WIDGET_OPTION_KEYS,
56+
UNCONSUMED_WIDGET_OPTION,
57+
manifestFromConfigs,
58+
validateTree,
59+
} from '../index.js';
60+
import type { Diagnostic, Manifest, SchemaElement } from '../types.js';
61+
62+
/**
63+
* A manifest carrying both widget-host blocks, with the inputs their objectui
64+
* registrations declare, plus a non-host control block.
65+
*/
66+
const manifest: Manifest = manifestFromConfigs([
67+
{
68+
type: 'dashboard',
69+
namespace: 'view',
70+
inputs: [
71+
{ name: 'columns', type: 'number' },
72+
{ name: 'gap', type: 'number' },
73+
{ name: 'className', type: 'string' },
74+
],
75+
},
76+
{ type: 'dashboard-grid', namespace: 'plugin-dashboard', inputs: [] },
77+
{ type: 'card', namespace: 'ui', inputs: [] },
78+
]);
79+
80+
/** The objectui#5709 fixture: the hotcrm gauge, verbatim in shape. */
81+
const slaGauge = {
82+
id: 'sla_compliance_gauge',
83+
title: 'SLA Compliance',
84+
type: 'gauge',
85+
dataset: 'case_metrics',
86+
values: ['avg_sla_violated'],
87+
options: {
88+
format: '0%',
89+
invert: true,
90+
thresholds: [{ value: 0.95, color: 'success' }],
91+
},
92+
};
93+
94+
const diagnose = (node: Record<string, unknown>): Diagnostic[] =>
95+
validateTree(node as SchemaElement, manifest).diagnostics;
96+
97+
const unconsumed = (node: Record<string, unknown>): Diagnostic[] =>
98+
diagnose(node).filter((d) => d.code === UNCONSUMED_WIDGET_OPTION);
99+
100+
const dash = (...widgets: unknown[]): Record<string, unknown> => ({
101+
type: 'dashboard',
102+
widgets,
103+
});
104+
105+
describe('the census the expectations derive from is not vacuous', () => {
106+
it('CONSUMED_WIDGET_OPTION_KEYS is non-empty, duplicate-free and sorted', () => {
107+
// Everything below compares against this array; an empty or degenerate
108+
// census would make those comparisons agree about nothing.
109+
expect(CONSUMED_WIDGET_OPTION_KEYS.length).toBeGreaterThanOrEqual(5);
110+
expect(new Set(CONSUMED_WIDGET_OPTION_KEYS).size).toBe(CONSUMED_WIDGET_OPTION_KEYS.length);
111+
expect([...CONSUMED_WIDGET_OPTION_KEYS].sort()).toEqual([...CONSUMED_WIDGET_OPTION_KEYS]);
112+
});
113+
114+
it('the accepted set is exactly objectui\'s — the lockstep pin this copy cannot re-derive', () => {
115+
// This repo has no dashboard renderer, so the read-site half of the census
116+
// is not measurable here (objectui owns it). What IS measurable here is the
117+
// DECLARED half: the five query keys below are exactly the five properties
118+
// `DashboardWidgetOptionsSchema` declares in
119+
// `packages/spec/src/ui/dashboard.zod.ts` — the spec ships from THIS repo,
120+
// so a declared key landing there without landing here would turn this
121+
// module into a false positive on legal metadata. `description` is the
122+
// sixth, undeclared, member: the metric sub-caption channel that
123+
// `translateDashboard` writes into `options` (see `WidgetLike.options` in
124+
// `packages/spec/src/system/i18n-resolver.ts`), also a read site in this
125+
// repo. Re-read both files when this pin fails.
126+
expect([...CONSUMED_WIDGET_OPTION_KEYS]).toEqual([
127+
'dateGranularity',
128+
'description',
129+
'limit',
130+
'sortBy',
131+
'sortOrder',
132+
'stageOrder',
133+
]);
134+
});
135+
136+
it('the manifest resolves the host blocks — reachability before absence', () => {
137+
expect(manifest.components['dashboard']).toBeTruthy();
138+
expect(manifest.components['dashboard-grid']).toBeTruthy();
139+
expect(diagnose(dash()).map((d) => d.code)).not.toContain('unknown-component');
140+
});
141+
});
142+
143+
describe('the ruled first case: gauge options.invert (objectui#5709)', () => {
144+
it('warns on invert, thresholds AND format — none has a dataset-path read site', () => {
145+
const found = unconsumed(dash(slaGauge));
146+
const keys = found.map((d) => /options\.(\w+)/.exec(d.message)?.[1]).sort();
147+
expect(keys).toEqual(['format', 'invert', 'thresholds']);
148+
});
149+
150+
it('each warning names the widget, its type, and the FULL consumed set', () => {
151+
const found = unconsumed(dash(slaGauge));
152+
// Not vacuous: an emitter that stopped emitting would make the loop below
153+
// assert nothing.
154+
expect(found.length).toBeGreaterThan(0);
155+
for (const d of found) {
156+
expect(d.message).toContain('"sla_compliance_gauge"');
157+
expect(d.message).toContain('(gauge)');
158+
// "naming the consumed set" is the ruling's own requirement — derived
159+
// from the array the implementation prints, never restated.
160+
for (const key of CONSUMED_WIDGET_OPTION_KEYS) {
161+
expect(d.message).toContain(key);
162+
}
163+
expect(d.tag).toBe('dashboard');
164+
}
165+
});
166+
167+
it('the emitted code IS the exported constant — the one token that diverges from objectui cannot drift', () => {
168+
// objectui stamps `code: UNCONSUMED_WIDGET_OPTION`; this copy stamps the
169+
// literal, because `check:dispatcher-error-vocabulary` cannot reduce a
170+
// SCREAMING_SNAKE constant holding a kebab-case value (reasoning at the
171+
// site). This pin is what makes that divergence safe: the two spellings are
172+
// asserted equal, so a change to either is a red test, not a silent fork.
173+
const found = unconsumed(dash({ ...slaGauge, options: { invert: true } }));
174+
expect(found).toHaveLength(1);
175+
expect(found[0]!.code).toBe(UNCONSUMED_WIDGET_OPTION);
176+
expect(UNCONSUMED_WIDGET_OPTION).toBe('unconsumed-widget-option');
177+
});
178+
179+
it('the emitted diagnostic is byte-equal to objectui\'s, field for field', () => {
180+
// The lockstep claim is about the WHOLE envelope, not just the code: a
181+
// message that drifted by one word is a different author-visible dialect.
182+
const found = unconsumed(dash({ ...slaGauge, options: { invert: true } }));
183+
expect(found).toEqual([
184+
{
185+
severity: 'warning',
186+
code: 'unconsumed-widget-option',
187+
message:
188+
'<dashboard> widget "sla_compliance_gauge" (gauge): options.invert reaches no renderer — ' +
189+
'dashboard widget renderers read only: dateGranularity, description, limit, sortBy, sortOrder, stageOrder',
190+
tag: 'dashboard',
191+
},
192+
]);
193+
});
194+
});
195+
196+
describe('the port is ADDITIVE — it must not move this copy\'s accept/reject set', () => {
197+
// objectstack#12810 was split from objectstack#12814 precisely because this
198+
// half only reports an already-inert state. If any assertion here flips, the
199+
// port has become a contract change and must be re-graded, not merged.
200+
it('every diagnostic this code emits is a warning — the ruled ceiling, no new red gates', () => {
201+
const found = unconsumed(dash(slaGauge));
202+
expect(found.length).toBeGreaterThan(0);
203+
for (const d of found) expect(d.severity).toBe('warning');
204+
});
205+
206+
it('a page whose ONLY defect is a dead option key still passes the save gate', () => {
207+
const before = compile('<dashboard columns={2} />', manifest);
208+
expect(before.ok).toBe(true);
209+
// The same page, now carrying three unconsumed keys, is still accepted:
210+
// more diagnostics, same verdict.
211+
const after = validateTree(dash(slaGauge) as unknown as SchemaElement, manifest);
212+
expect(after.diagnostics.filter((d) => d.code === UNCONSUMED_WIDGET_OPTION)).toHaveLength(3);
213+
expect(after.diagnostics.some((d) => d.severity === 'error')).toBe(false);
214+
});
215+
216+
it('nothing outside the census gained a diagnostic — the pre-port codes are unchanged', () => {
217+
// A tree with one of each pre-existing defect, none of them a widget
218+
// option: the codes and count are exactly what they were before the port.
219+
const r = compile('<dashboard columns="two" nope={1}><card /></dashboard>', manifest);
220+
expect(r.diagnostics.map((d) => d.code).sort()).toEqual([
221+
'not-a-container',
222+
'type-mismatch',
223+
'unknown-prop',
224+
]);
225+
expect(r.diagnostics.some((d) => d.code === UNCONSUMED_WIDGET_OPTION)).toBe(false);
226+
});
227+
});
228+
229+
describe('the mechanism is general — invert is a case, not the implementation', () => {
230+
it('a different dead key on a different widget type draws the same code', () => {
231+
const found = unconsumed(
232+
dash({ id: 'k1', type: 'kpi', dataset: 'sales', values: ['total'], options: { sparkline: true } }),
233+
);
234+
expect(found).toHaveLength(1);
235+
expect(found[0]!.message).toContain('options.sparkline');
236+
expect(found[0]!.message).toContain('"k1"');
237+
});
238+
239+
it('fires on the dashboard-grid host too — both surfaces share one dispatch', () => {
240+
const found = validateTree(
241+
{ type: 'dashboard-grid', widgets: [slaGauge] } as unknown as SchemaElement,
242+
manifest,
243+
).diagnostics.filter((d) => d.code === UNCONSUMED_WIDGET_OPTION);
244+
expect(found.length).toBe(3);
245+
expect(found[0]!.tag).toBe('dashboard-grid');
246+
});
247+
248+
it('a widget with no usable id is named by index', () => {
249+
const found = unconsumed(
250+
dash({ type: 'bar', dataset: 'd1', values: ['v'], options: { glow: 1 } }),
251+
);
252+
expect(found).toHaveLength(1);
253+
expect(found[0]!.message).toContain('"#0"');
254+
});
255+
256+
it('every widget in the array is visited, not just the first', () => {
257+
const found = unconsumed(
258+
dash(
259+
{ id: 'a', type: 'bar', dataset: 'd1', options: { dead1: 1 } },
260+
{ id: 'b', type: 'bar', dataset: 'd1', options: { dead2: 1 } },
261+
),
262+
);
263+
expect(found.map((d) => d.message.match(/"(\w+)"/)?.[1])).toEqual(['a', 'b']);
264+
});
265+
});
266+
267+
describe('what draws NOTHING — every accepted key, and every out-of-scope shape', () => {
268+
it('the full accepted set on one widget is clean (control: plus one dead key is not)', () => {
269+
const accepted = Object.fromEntries(CONSUMED_WIDGET_OPTION_KEYS.map((k) => [k, 1]));
270+
const widget = { id: 'w', type: 'bar', dataset: 'd1', values: ['v'] };
271+
expect(unconsumed(dash({ ...widget, options: accepted }))).toEqual([]);
272+
// The control that separates "these keys are accepted" from "the check
273+
// stopped running": the SAME widget with one extra key is reported.
274+
expect(unconsumed(dash({ ...widget, options: { ...accepted, dead: 1 } }))).toHaveLength(1);
275+
});
276+
277+
it('a widget without `dataset` is out of census scope — the legacy inline form', () => {
278+
// The legacy (spec-illegal) form consumes a spread-shaped superset this
279+
// census deliberately does not model; a warning here would be a guess.
280+
expect(unconsumed(dash({ id: 'l', type: 'gauge', options: { invert: true } }))).toEqual([]);
281+
// …and the empty-string dataset is the same non-binding, not a binding to ''.
282+
expect(unconsumed(dash({ id: 'l', type: 'gauge', dataset: '', options: { invert: true } }))).toEqual([]);
283+
});
284+
285+
it('the legacy component format is out of scope', () => {
286+
expect(
287+
unconsumed(
288+
dash({ id: 'c', dataset: 'd1', component: { type: 'card' }, options: { invert: true } }),
289+
),
290+
).toEqual([]);
291+
});
292+
293+
it('deferred expressions are opaque, never guessed at', () => {
294+
// Built indirectly: this is the PARSER's `{ $expr }` marker (a whole
295+
// deferred options bag), the same marker `inert-expression` names — this
296+
// module never evaluates it and never reports on what might be inside.
297+
const deferredBag = { $expr: 'ctx.opts' };
298+
expect(unconsumed(dash({ id: 'e', type: 'gauge', dataset: 'd1', options: deferredBag }))).toEqual([]);
299+
expect(unconsumed({ type: 'dashboard', widgets: { $expr: 'ctx.widgets' } })).toEqual([]);
300+
});
301+
302+
it("the spec's own suppressWarnings escape hatch is honoured (control: unsuppressed twin warns)", () => {
303+
const suppressed = { ...slaGauge, id: 'g1', suppressWarnings: [UNCONSUMED_WIDGET_OPTION] };
304+
const twin = { ...slaGauge, id: 'g2' };
305+
const found = unconsumed(dash(suppressed, twin));
306+
expect(found.every((d) => d.message.includes('"g2"'))).toBe(true);
307+
expect(found).toHaveLength(3);
308+
});
309+
310+
it('a non-host component with a widgets array is not searched', () => {
311+
expect(unconsumed({ type: 'card', widgets: [slaGauge] })).toEqual([]);
312+
});
313+
314+
it('an unknown host draws unknown-component, not deep option warnings', () => {
315+
const bare: Manifest = manifestFromConfigs([{ type: 'card', namespace: 'ui', inputs: [] }]);
316+
const d = validateTree(dash(slaGauge) as unknown as SchemaElement, bare).diagnostics;
317+
expect(d.map((x) => x.code)).toContain('unknown-component');
318+
expect(d.filter((x) => x.code === UNCONSUMED_WIDGET_OPTION)).toEqual([]);
319+
});
320+
});

0 commit comments

Comments
 (0)