From 5e5ff2c78097da28b7e05aebedd83cebe3f7a0db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 28 Aug 2026 08:34:19 +0000 Subject: [PATCH] fix(sdui-parser): port the unconsumed dashboard-widget-option warning in lockstep with objectui MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw --- .../sdui-parser-unconsumed-widget-option.md | 48 +++ .../dashboard-widget-options.test.ts | 320 ++++++++++++++++++ .../src/dashboard-widget-options.ts | 193 +++++++++++ packages/sdui-parser/src/index.ts | 6 + packages/sdui-parser/src/validate.ts | 15 + 5 files changed, 582 insertions(+) create mode 100644 .changeset/sdui-parser-unconsumed-widget-option.md create mode 100644 packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts create mode 100644 packages/sdui-parser/src/dashboard-widget-options.ts diff --git a/.changeset/sdui-parser-unconsumed-widget-option.md b/.changeset/sdui-parser-unconsumed-widget-option.md new file mode 100644 index 0000000000..2b706844c0 --- /dev/null +++ b/.changeset/sdui-parser-unconsumed-widget-option.md @@ -0,0 +1,48 @@ +--- +'@objectstack/sdui-parser': minor +--- + +html tier: a dashboard widget `options` key that reaches no renderer now draws an `unconsumed-widget-option` warning naming the consumed set + +`@objectstack/spec`'s `DashboardWidgetOptionsSchema` ends in `.passthrough()` +("declared query keys + open renderer extras"), so ANY key parses, validates +and lints cleanly — including one no renderer reads. That is how a dashboard +shipped `options: { invert: true }` on a gauge with a comment saying what it +was believed to do and rendered the un-inverted measure with no diagnostic +anywhere (objectui#5709). The 2026-08-23 maintainer ruling on that card: open +extras stay open — they just stop being **silent**. A key that reaches no +renderer draws a **warning** naming the consumed set. + +objectui's copy of this parser has emitted that warning since the ruling +landed; 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 (objectstack#12719 — both copies agree +on the accepted grammar **and** on diagnostic codes). `validateTree` now ends +its known-component branch with `checkDashboardWidgetOptions(node)`, and the +new module is a byte-equal port of objectui's save for one token (the emitted +`code` is spelled as an inline literal rather than through the exported +constant, so this repo's ADR-0112 vocabulary gate can classify it — called out +at the site, and pinned equal to the constant by test), so the emitted `code`, +`severity`, `message` and census scope are identical. + +The warning is scoped to the only spec-legal render path: a `dashboard` / +`dashboard-grid` host, a widget with a `dataset`, not in the legacy +`component` format, and not carrying the spec's own +`suppressWarnings: ['unconsumed-widget-option']` escape hatch. The consumed set +is the five keys `DashboardWidgetOptionsSchema` declares (`dateGranularity`, +`sortBy`, `sortOrder`, `limit`, `stageOrder`) plus `description`, the metric +sub-caption channel `translateDashboard` writes into `options`. + +New exports for third-party manifest consumers: `checkDashboardWidgetOptions`, +`CONSUMED_WIDGET_OPTION_KEYS`, `DASHBOARD_WIDGET_HOST_TYPES` and +`UNCONSUMED_WIDGET_OPTION` (the diagnostic code, which is also the id +`suppressWarnings` suppresses). + +Unlike the union-arm port that preceded it, this change is **additive**: it +reports an already-inert state and emits `warning` only, so what this copy +accepts and rejects is exactly where it stood — pinned by a dedicated test. +Today it is latent in the production gate anyway: this repo resolves no +`sdui.manifest.json`, so `validateJsxPages` runs parse-only and `validateTree` +is not reached from it. Wiring that manifest (the second gap recorded on +objectstack#12719, still unowned) is what makes this author-visible, and this +port lands ahead of that wiring deliberately. diff --git a/packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts b/packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts new file mode 100644 index 0000000000..32cccca97e --- /dev/null +++ b/packages/sdui-parser/src/__tests__/dashboard-widget-options.test.ts @@ -0,0 +1,320 @@ +/** + * `unconsumed-widget-option` — the objectui#5709 ruling, ported into this copy + * in lockstep (objectstack#12810). + * + * The 2026-08-23 maintainer ruling on objectui#5709: a dashboard widget + * `options` key riding `DashboardWidgetOptionsSchema.passthrough()` that no + * renderer consumes gets an authoring-time WARNING naming the consumed set. + * `invert` — the key that card was filed over — is the first pinned case, and + * it is pinned here AS A CASE of the general mechanism: the same fixture shape + * with a different dead key on a different widget type must draw the same + * diagnostic, or the mechanism is a special case wearing a general name. + * + * WHY THESE PINS EXIST HERE, AND WHY THEY CARRY MORE WEIGHT THAN OBJECTUI'S. + * Two copies of this parser exist — objectui's `packages/sdui-parser` and this + * hoisted one — and the invariant is that both agree on the accepted grammar + * AND on diagnostic codes. If they drift, the save gate and the renderer speak + * different dialects: a page can save clean and render inert, or the reverse — + * surface-dependent, therefore intermittent from the author's point of view + * (objectstack#12719, objectstack#12810). These pins are the objectstack half + * of that lockstep; the emitted diagnostic is byte-equal to objectui's. + * + * They carry more weight here because they are the ONLY witness. This repo + * resolves no `sdui.manifest.json` (there is none in the tree, and + * `@objectstack/console/dist/sdui.manifest.json` is absent), so + * `resolveSduiManifest()` returns undefined and `validateJsxPages` runs + * parse-only — `validateTree` is not reached from the production gate at all + * today. A green CI therefore proves almost nothing about this module; this + * file is what proves it. + * + * SEVERITY IS PINNED AS WARNING deliberately, and `ok` is pinned true beside + * it. This port reports an ALREADY-inert state (the key was legal, silent and + * ignored before it landed), so it must leave this copy's accept/reject set + * exactly where it stood — that is the whole reason objectstack#12810 was split + * from objectstack#12814, which did change what this copy rejects. A test that + * moves `ok` is reporting a contract change, not a port. + * + * The accepted-set expectations are DERIVED from `CONSUMED_WIDGET_OPTION_KEYS` + * — the same array the implementation prints — never restated, so a census + * update cannot desynchronize this file. The derivation is guarded against + * vacuity first (an empty census would warn on everything and make "names the + * consumed set" trivially true of the empty string). + * + * WHAT THIS FILE CANNOT DERIVE, so that nobody reads it as claiming to. + * objectui's copy sits next to a census test that re-measures the accepted set + * against its renderer source (`DatasetWidget.tsx` read sites). This repo has + * no dashboard renderer at all, so that half is not re-derivable here and is + * not faked: the array is pinned literally below, and the pin names the two + * files to re-read when either half moves — this repo's + * `packages/spec/src/ui/dashboard.zod.ts` for the declared keys, objectui's + * `DatasetWidget.tsx` for the read sites. + */ +import { describe, expect, it } from 'vitest'; +import { + compile, + CONSUMED_WIDGET_OPTION_KEYS, + UNCONSUMED_WIDGET_OPTION, + manifestFromConfigs, + validateTree, +} from '../index.js'; +import type { Diagnostic, Manifest, SchemaElement } from '../types.js'; + +/** + * A manifest carrying both widget-host blocks, with the inputs their objectui + * registrations declare, plus a non-host control block. + */ +const manifest: Manifest = manifestFromConfigs([ + { + type: 'dashboard', + namespace: 'view', + inputs: [ + { name: 'columns', type: 'number' }, + { name: 'gap', type: 'number' }, + { name: 'className', type: 'string' }, + ], + }, + { type: 'dashboard-grid', namespace: 'plugin-dashboard', inputs: [] }, + { type: 'card', namespace: 'ui', inputs: [] }, +]); + +/** The objectui#5709 fixture: the hotcrm gauge, verbatim in shape. */ +const slaGauge = { + id: 'sla_compliance_gauge', + title: 'SLA Compliance', + type: 'gauge', + dataset: 'case_metrics', + values: ['avg_sla_violated'], + options: { + format: '0%', + invert: true, + thresholds: [{ value: 0.95, color: 'success' }], + }, +}; + +const diagnose = (node: Record): Diagnostic[] => + validateTree(node as SchemaElement, manifest).diagnostics; + +const unconsumed = (node: Record): Diagnostic[] => + diagnose(node).filter((d) => d.code === UNCONSUMED_WIDGET_OPTION); + +const dash = (...widgets: unknown[]): Record => ({ + type: 'dashboard', + widgets, +}); + +describe('the census the expectations derive from is not vacuous', () => { + it('CONSUMED_WIDGET_OPTION_KEYS is non-empty, duplicate-free and sorted', () => { + // Everything below compares against this array; an empty or degenerate + // census would make those comparisons agree about nothing. + expect(CONSUMED_WIDGET_OPTION_KEYS.length).toBeGreaterThanOrEqual(5); + expect(new Set(CONSUMED_WIDGET_OPTION_KEYS).size).toBe(CONSUMED_WIDGET_OPTION_KEYS.length); + expect([...CONSUMED_WIDGET_OPTION_KEYS].sort()).toEqual([...CONSUMED_WIDGET_OPTION_KEYS]); + }); + + it('the accepted set is exactly objectui\'s — the lockstep pin this copy cannot re-derive', () => { + // This repo has no dashboard renderer, so the read-site half of the census + // is not measurable here (objectui owns it). What IS measurable here is the + // DECLARED half: the five query keys below are exactly the five properties + // `DashboardWidgetOptionsSchema` declares in + // `packages/spec/src/ui/dashboard.zod.ts` — the spec ships from THIS repo, + // so a declared key landing there without landing here would turn this + // module into a false positive on legal metadata. `description` is the + // sixth, undeclared, member: the metric sub-caption channel that + // `translateDashboard` writes into `options` (see `WidgetLike.options` in + // `packages/spec/src/system/i18n-resolver.ts`), also a read site in this + // repo. Re-read both files when this pin fails. + expect([...CONSUMED_WIDGET_OPTION_KEYS]).toEqual([ + 'dateGranularity', + 'description', + 'limit', + 'sortBy', + 'sortOrder', + 'stageOrder', + ]); + }); + + it('the manifest resolves the host blocks — reachability before absence', () => { + expect(manifest.components['dashboard']).toBeTruthy(); + expect(manifest.components['dashboard-grid']).toBeTruthy(); + expect(diagnose(dash()).map((d) => d.code)).not.toContain('unknown-component'); + }); +}); + +describe('the ruled first case: gauge options.invert (objectui#5709)', () => { + it('warns on invert, thresholds AND format — none has a dataset-path read site', () => { + const found = unconsumed(dash(slaGauge)); + const keys = found.map((d) => /options\.(\w+)/.exec(d.message)?.[1]).sort(); + expect(keys).toEqual(['format', 'invert', 'thresholds']); + }); + + it('each warning names the widget, its type, and the FULL consumed set', () => { + const found = unconsumed(dash(slaGauge)); + // Not vacuous: an emitter that stopped emitting would make the loop below + // assert nothing. + expect(found.length).toBeGreaterThan(0); + for (const d of found) { + expect(d.message).toContain('"sla_compliance_gauge"'); + expect(d.message).toContain('(gauge)'); + // "naming the consumed set" is the ruling's own requirement — derived + // from the array the implementation prints, never restated. + for (const key of CONSUMED_WIDGET_OPTION_KEYS) { + expect(d.message).toContain(key); + } + expect(d.tag).toBe('dashboard'); + } + }); + + it('the emitted code IS the exported constant — the one token that diverges from objectui cannot drift', () => { + // objectui stamps `code: UNCONSUMED_WIDGET_OPTION`; this copy stamps the + // literal, because `check:dispatcher-error-vocabulary` cannot reduce a + // SCREAMING_SNAKE constant holding a kebab-case value (reasoning at the + // site). This pin is what makes that divergence safe: the two spellings are + // asserted equal, so a change to either is a red test, not a silent fork. + const found = unconsumed(dash({ ...slaGauge, options: { invert: true } })); + expect(found).toHaveLength(1); + expect(found[0]!.code).toBe(UNCONSUMED_WIDGET_OPTION); + expect(UNCONSUMED_WIDGET_OPTION).toBe('unconsumed-widget-option'); + }); + + it('the emitted diagnostic is byte-equal to objectui\'s, field for field', () => { + // The lockstep claim is about the WHOLE envelope, not just the code: a + // message that drifted by one word is a different author-visible dialect. + const found = unconsumed(dash({ ...slaGauge, options: { invert: true } })); + expect(found).toEqual([ + { + severity: 'warning', + code: 'unconsumed-widget-option', + message: + ' widget "sla_compliance_gauge" (gauge): options.invert reaches no renderer — ' + + 'dashboard widget renderers read only: dateGranularity, description, limit, sortBy, sortOrder, stageOrder', + tag: 'dashboard', + }, + ]); + }); +}); + +describe('the port is ADDITIVE — it must not move this copy\'s accept/reject set', () => { + // objectstack#12810 was split from objectstack#12814 precisely because this + // half only reports an already-inert state. If any assertion here flips, the + // port has become a contract change and must be re-graded, not merged. + it('every diagnostic this code emits is a warning — the ruled ceiling, no new red gates', () => { + const found = unconsumed(dash(slaGauge)); + expect(found.length).toBeGreaterThan(0); + for (const d of found) expect(d.severity).toBe('warning'); + }); + + it('a page whose ONLY defect is a dead option key still passes the save gate', () => { + const before = compile('', manifest); + expect(before.ok).toBe(true); + // The same page, now carrying three unconsumed keys, is still accepted: + // more diagnostics, same verdict. + const after = validateTree(dash(slaGauge) as unknown as SchemaElement, manifest); + expect(after.diagnostics.filter((d) => d.code === UNCONSUMED_WIDGET_OPTION)).toHaveLength(3); + expect(after.diagnostics.some((d) => d.severity === 'error')).toBe(false); + }); + + it('nothing outside the census gained a diagnostic — the pre-port codes are unchanged', () => { + // A tree with one of each pre-existing defect, none of them a widget + // option: the codes and count are exactly what they were before the port. + const r = compile('', manifest); + expect(r.diagnostics.map((d) => d.code).sort()).toEqual([ + 'not-a-container', + 'type-mismatch', + 'unknown-prop', + ]); + expect(r.diagnostics.some((d) => d.code === UNCONSUMED_WIDGET_OPTION)).toBe(false); + }); +}); + +describe('the mechanism is general — invert is a case, not the implementation', () => { + it('a different dead key on a different widget type draws the same code', () => { + const found = unconsumed( + dash({ id: 'k1', type: 'kpi', dataset: 'sales', values: ['total'], options: { sparkline: true } }), + ); + expect(found).toHaveLength(1); + expect(found[0]!.message).toContain('options.sparkline'); + expect(found[0]!.message).toContain('"k1"'); + }); + + it('fires on the dashboard-grid host too — both surfaces share one dispatch', () => { + const found = validateTree( + { type: 'dashboard-grid', widgets: [slaGauge] } as unknown as SchemaElement, + manifest, + ).diagnostics.filter((d) => d.code === UNCONSUMED_WIDGET_OPTION); + expect(found.length).toBe(3); + expect(found[0]!.tag).toBe('dashboard-grid'); + }); + + it('a widget with no usable id is named by index', () => { + const found = unconsumed( + dash({ type: 'bar', dataset: 'd1', values: ['v'], options: { glow: 1 } }), + ); + expect(found).toHaveLength(1); + expect(found[0]!.message).toContain('"#0"'); + }); + + it('every widget in the array is visited, not just the first', () => { + const found = unconsumed( + dash( + { id: 'a', type: 'bar', dataset: 'd1', options: { dead1: 1 } }, + { id: 'b', type: 'bar', dataset: 'd1', options: { dead2: 1 } }, + ), + ); + expect(found.map((d) => d.message.match(/"(\w+)"/)?.[1])).toEqual(['a', 'b']); + }); +}); + +describe('what draws NOTHING — every accepted key, and every out-of-scope shape', () => { + it('the full accepted set on one widget is clean (control: plus one dead key is not)', () => { + const accepted = Object.fromEntries(CONSUMED_WIDGET_OPTION_KEYS.map((k) => [k, 1])); + const widget = { id: 'w', type: 'bar', dataset: 'd1', values: ['v'] }; + expect(unconsumed(dash({ ...widget, options: accepted }))).toEqual([]); + // The control that separates "these keys are accepted" from "the check + // stopped running": the SAME widget with one extra key is reported. + expect(unconsumed(dash({ ...widget, options: { ...accepted, dead: 1 } }))).toHaveLength(1); + }); + + it('a widget without `dataset` is out of census scope — the legacy inline form', () => { + // The legacy (spec-illegal) form consumes a spread-shaped superset this + // census deliberately does not model; a warning here would be a guess. + expect(unconsumed(dash({ id: 'l', type: 'gauge', options: { invert: true } }))).toEqual([]); + // …and the empty-string dataset is the same non-binding, not a binding to ''. + expect(unconsumed(dash({ id: 'l', type: 'gauge', dataset: '', options: { invert: true } }))).toEqual([]); + }); + + it('the legacy component format is out of scope', () => { + expect( + unconsumed( + dash({ id: 'c', dataset: 'd1', component: { type: 'card' }, options: { invert: true } }), + ), + ).toEqual([]); + }); + + it('deferred expressions are opaque, never guessed at', () => { + // Built indirectly: this is the PARSER's `{ $expr }` marker (a whole + // deferred options bag), the same marker `inert-expression` names — this + // module never evaluates it and never reports on what might be inside. + const deferredBag = { $expr: 'ctx.opts' }; + expect(unconsumed(dash({ id: 'e', type: 'gauge', dataset: 'd1', options: deferredBag }))).toEqual([]); + expect(unconsumed({ type: 'dashboard', widgets: { $expr: 'ctx.widgets' } })).toEqual([]); + }); + + it("the spec's own suppressWarnings escape hatch is honoured (control: unsuppressed twin warns)", () => { + const suppressed = { ...slaGauge, id: 'g1', suppressWarnings: [UNCONSUMED_WIDGET_OPTION] }; + const twin = { ...slaGauge, id: 'g2' }; + const found = unconsumed(dash(suppressed, twin)); + expect(found.every((d) => d.message.includes('"g2"'))).toBe(true); + expect(found).toHaveLength(3); + }); + + it('a non-host component with a widgets array is not searched', () => { + expect(unconsumed({ type: 'card', widgets: [slaGauge] })).toEqual([]); + }); + + it('an unknown host draws unknown-component, not deep option warnings', () => { + const bare: Manifest = manifestFromConfigs([{ type: 'card', namespace: 'ui', inputs: [] }]); + const d = validateTree(dash(slaGauge) as unknown as SchemaElement, bare).diagnostics; + expect(d.map((x) => x.code)).toContain('unknown-component'); + expect(d.filter((x) => x.code === UNCONSUMED_WIDGET_OPTION)).toEqual([]); + }); +}); diff --git a/packages/sdui-parser/src/dashboard-widget-options.ts b/packages/sdui-parser/src/dashboard-widget-options.ts new file mode 100644 index 0000000000..d0f95264ff --- /dev/null +++ b/packages/sdui-parser/src/dashboard-widget-options.ts @@ -0,0 +1,193 @@ +/** + * Unconsumed dashboard-widget `options` keys (objectui#5709), ported into this + * copy in lockstep (objectstack#12810). + * + * `@objectstack/spec`'s `DashboardWidgetOptionsSchema` ends in `.passthrough()` + * ("declared query keys + open renderer extras"), so ANY key parses, validates + * and lints cleanly — including one no renderer reads. That is how a showcase + * dashboard shipped `options: { invert: true }` on a gauge with a comment + * saying what it was believed to do, and rendered the un-inverted measure with + * no diagnostic anywhere (objectui#5709). The 2026-08-23 maintainer ruling: + * open extras stay open — they just stop being SILENT. A key that reaches no + * renderer draws a WARNING naming the consumed set. Not an error: no gate + * weakening and no new red gates were ruled. + * + * ## LOCKSTEP — what is byte-equal here and what deliberately is not + * + * Two copies of this parser exist: objectui's `packages/sdui-parser` and this + * hoisted `@objectstack/sdui-parser`. The invariant they owe each other is that + * both agree on the accepted grammar AND on diagnostic codes — if they drift, + * the save gate and the renderer speak different dialects and a page can save + * clean and render inert, or the reverse (objectstack#12719, objectstack#12810). + * + * Everything from the `import` line below to end of file is a byte-equal port of + * objectui's `src/dashboard-widget-options.ts` SAVE FOR ONE TOKEN, called out + * at the site itself: the emitted `code` is spelled as an inline literal here + * and as the constant there, because this repo runs a vocabulary gate objectui + * does not. The emitted `code`, `severity`, `message` and the whole census + * scope are identical, and `__tests__/dashboard-widget-options.test.ts` + * re-derives that rather than trusting it — including an explicit pin that the + * literal equals `UNCONSUMED_WIDGET_OPTION`. Change these functions only + * together with the objectui copy. + * + * THIS HEADER is the one deliberate divergence, and it has to be: objectui's + * header cites the maintenance machinery that derives the census — its + * `DatasetWidget.tsx` / `DashboardRenderer.tsx` read sites, its + * `plugin-dashboard.mdx` claim and its two census tests. NONE of those files + * exists in this repo (measured: no dashboard renderer package here at all), so + * copying those sentences would ship claims this checkout cannot support and + * nothing here would ever notice them going false. What follows instead states + * where each half of the census is derivable, and from what. + * + * ## The accepted set, and where each half is authoritative + * + * The spec REQUIRES `dataset` on every widget (`DashboardWidgetSchema`, this + * repo: `packages/spec/src/ui/dashboard.zod.ts`), and both of objectui's + * dashboard surfaces route a dataset-bound widget to `DatasetWidget`. On that — + * the only spec-legal — path the renderer-consumed `options` keys are exactly + * the five the spec DECLARES: + * + * dateGranularity, sortBy, sortOrder, limit (query-affecting, framework#3588) + * stageOrder (funnel/pyramid stage order) + * + * plus ONE undeclared key with a real read site: + * + * description — the metric-card sub-caption channel. `translateDashboard` + * OVERLAYS the `widgets.{id}.subCaption` translation onto this key, and that + * pipeline lives IN THIS REPO: `packages/spec/src/system/i18n-resolver.ts` + * documents `WidgetLike.options` as "the renderer-extras bag … + * `translateDashboard` writes exactly one key into it — `description`" + * (objectstack#5428 item 4, objectstack#7862). Warning on a key the + * platform's own translation pipeline writes would be a false positive on + * legal metadata, so it is in the accepted set even though the dataset-bound + * render path does not currently display it. + * + * Notably NOT consumed on the path a widget really renders through: + * `thresholds` and `format`. Both were widely believed to work; both draw this + * warning, which is the point. Their closure claims are objectui's to derive — + * `thresholds` by a repo-wide read-site scan there, `format` by the bounded + * claim that the dataset-bound path formats from the MEASURE's own metadata — + * and they are NOT restated here as claims about this repo, which has no + * renderer to make them about. + * + * ## The drift risk that lives on THIS side + * + * The spec whose `.passthrough()` this reasons about ships from this repo. So + * the one way this list can go stale HERE is a new DECLARED key landing in + * `DashboardWidgetOptionsSchema` without landing in the array below: the key + * would be spec-legal, renderer-consumed on the objectui side, and warned about + * here — a false positive on legal metadata. `@objectstack/sdui-parser` takes + * no dependency on `@objectstack/spec` (it is dependency-free and hoistable by + * design), so that cross-check is not mechanized in this copy; the census test + * next door pins the array and names the spec file to re-read when it moves. + * + * ## Scope — where the warning deliberately does NOT fire + * + * - Widgets WITHOUT `dataset`: the legacy inline forms (`options.data` + * arrays, `provider: 'object'` bags) consume a much larger, spread-shaped + * key set, whose true reach is each child component's prop surface. That + * form is spec-illegal today (`dataset` is required) and its census would be + * the unmaintainable one; skipping it keeps every warning this module emits + * a statement about the path the widget actually renders through. + * - Widgets in the legacy COMPONENT format (`widget.component`): `options` + * is not part of that contract. + * - Widgets carrying the spec's own escape hatch + * `suppressWarnings: ['unconsumed-widget-option']` — the spec models + * per-widget diagnostic suppression (`DashboardWidgetSchema.suppressWarnings`, + * "Build diagnostic rule ids suppressed on this widget"), so an author with + * a genuine out-of-band consumer can say so in metadata. + */ +import type { Diagnostic, SchemaElement } from './types.js'; + +/** The diagnostic `code` — also the id `suppressWarnings` suppresses. */ +export const UNCONSUMED_WIDGET_OPTION = 'unconsumed-widget-option'; + +/** + * Component types that host a dashboard `widgets` array. Both resolve to the + * surfaces measured by the census above (`DashboardRenderer`, + * `DashboardGridLayout`), which share one dispatch (`widgetDispatch.ts`). + */ +export const DASHBOARD_WIDGET_HOST_TYPES: ReadonlySet = new Set([ + 'dashboard', + 'dashboard-grid', +]); + +/** + * The accepted set: every `options` key with a renderer read site on the + * dataset-bound path, plus the sub-caption convention key. Alphabetical; the + * warning message prints it verbatim. Derivation and evidence: file header. + */ +export const CONSUMED_WIDGET_OPTION_KEYS: readonly string[] = [ + 'dateGranularity', + 'description', + 'limit', + 'sortBy', + 'sortOrder', + 'stageOrder', +]; + +const CONSUMED = new Set(CONSUMED_WIDGET_OPTION_KEYS); + +const isPlainObject = (v: unknown): v is Record => + typeof v === 'object' && v !== null && !Array.isArray(v); + +/** The parser's deferred-expression marker — opaque here, never evaluated. */ +const isExpr = (v: unknown): boolean => isPlainObject(v) && '$expr' in v; + +/** + * Diagnostics for `options` keys no renderer consumes, over one dashboard-host + * node's `widgets` array. Pure and shallow by design: it never descends into + * `children` (the caller's walk owns that) and answers `[]` for every shape + * outside its census — see the scope notes in the file header. + */ +export function checkDashboardWidgetOptions(node: SchemaElement): Diagnostic[] { + if (!DASHBOARD_WIDGET_HOST_TYPES.has(node.type)) return []; + const widgets = (node as Record).widgets; + if (!Array.isArray(widgets)) return []; + + const diagnostics: Diagnostic[] = []; + widgets.forEach((widget, index) => { + if (!isPlainObject(widget) || isExpr(widget)) return; + // Legacy component format: `options` is not part of that contract. + if (widget.component !== undefined) return; + // Only the dataset-bound (spec-legal) path is censused — see file header. + if (widget.dataset === undefined || widget.dataset === null || widget.dataset === '') return; + const options = widget.options; + if (!isPlainObject(options) || isExpr(options)) return; + if ( + Array.isArray(widget.suppressWarnings) && + widget.suppressWarnings.includes(UNCONSUMED_WIDGET_OPTION) + ) { + return; + } + const label = typeof widget.id === 'string' && widget.id !== '' ? widget.id : `#${index}`; + const widgetType = typeof widget.type === 'string' && widget.type !== '' ? widget.type : 'widget'; + for (const key of Object.keys(options)) { + if (CONSUMED.has(key)) continue; + diagnostics.push({ + severity: 'warning', + // DIVERGENCE FROM OBJECTUI, and the only one below this file's header: + // objectui writes `code: UNCONSUMED_WIDGET_OPTION` here. This repo runs + // `check:dispatcher-error-vocabulary`, whose `objlitconst` shape reads + // the SCREAMING_SNAKE constant NAME at a `code:` position and then must + // reduce it to a literal — and its literal grammar is + // `[A-Za-z][A-Za-z0-9_]*`, which a KEBAB-case value cannot satisfy. So + // the constant form is reported as an unresolvable code constant, and + // that finding cannot be declared away. `unconsumed-widget-option` is a + // parser DIAGNOSTIC code, not an ADR-0112 wire code, and an inline + // quoted literal is the form both vocabulary gates already accept for + // the six sibling diagnostic codes in `validate.ts` + // (`unknown-component`, `unknown-prop`, `not-a-container`, + // `inert-expression`, `type-mismatch`, `invalid-enum`). The emitted + // VALUE is unchanged, and the test next door pins it equal to + // `UNCONSUMED_WIDGET_OPTION` so the two spellings cannot drift apart. + code: 'unconsumed-widget-option', + message: + `<${node.type}> widget "${label}" (${widgetType}): options.${key} reaches no renderer — ` + + `dashboard widget renderers read only: ${CONSUMED_WIDGET_OPTION_KEYS.join(', ')}`, + tag: node.type, + }); + } + }); + return diagnostics; +} diff --git a/packages/sdui-parser/src/index.ts b/packages/sdui-parser/src/index.ts index a0126374a3..686a280efb 100644 --- a/packages/sdui-parser/src/index.ts +++ b/packages/sdui-parser/src/index.ts @@ -9,6 +9,12 @@ export * from './types.js'; export { parseJsx, interpretBrace } from './parse.js'; export { validateTree } from './validate.js'; +export { + checkDashboardWidgetOptions, + CONSUMED_WIDGET_OPTION_KEYS, + DASHBOARD_WIDGET_HOST_TYPES, + UNCONSUMED_WIDGET_OPTION, +} from './dashboard-widget-options.js'; export { generateDts, propsName, generateBlockList } from './codegen.js'; export type { CodegenOptions } from './codegen.js'; export { inputTypeArms, canonicalizeInputType, MANIFEST_INPUT_TYPES } from './input-type.js'; diff --git a/packages/sdui-parser/src/validate.ts b/packages/sdui-parser/src/validate.ts index 8563adb4db..095cde7feb 100644 --- a/packages/sdui-parser/src/validate.ts +++ b/packages/sdui-parser/src/validate.ts @@ -17,6 +17,7 @@ import type { ValidationResult, } from './types.js'; import { inputTypeArms } from './input-type.js'; +import { checkDashboardWidgetOptions } from './dashboard-widget-options.js'; /** Base props every node may carry (mirrors BaseSchema) — never "unknown prop". */ const BASE_PROPS = new Set([ @@ -127,6 +128,20 @@ export function validateTree(tree: SchemaElement | null, manifest: Manifest): Va tag: node.type, }); } + + // Dashboard widgets: an `options` key riding the spec's `.passthrough()` + // that no renderer consumes is legal, silent and inert — warn, naming + // the consumed set (objectui#5709 ruling; census + scope in + // `./dashboard-widget-options.ts`). Like `not-a-container`, this runs + // only for a component the manifest knows: an unresolved tag already + // drew `unknown-component`, and deep diagnostics on it would be noise. + // + // LOCKSTEP: this call site and the module behind it are the byte-equal + // port of objectui's copy (objectstack#12810). The two copies must agree + // on the accepted grammar AND on diagnostic codes — if they drift, the + // save gate and the renderer speak different dialects and a page can save + // clean and render inert. Change this only together with objectui. + diagnostics.push(...checkDashboardWidgetOptions(node)); } if (node.children) node.children.forEach(visit);