diff --git a/.changeset/dashboard-widget-strict-3251.md b/.changeset/dashboard-widget-strict-3251.md new file mode 100644 index 0000000000..b0f0858f76 --- /dev/null +++ b/.changeset/dashboard-widget-strict-3251.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": patch +--- + +feat(spec)!: `DashboardWidgetSchema.strict()` — reject undeclared widget keys (framework#3251) + +The ADR-0021 analytics endpoint. `DashboardWidgetSchema` now rejects any +undeclared top-level key instead of silently stripping it, moving a whole class +of author error (a hallucinated or legacy key that renders as a silent no-op) +from fallible human review to deterministic CI. `options: z.unknown()` remains +the escape hatch for renderer-specific extras. + +A custom error map names the offending key(s) and, when a key is a removed +pre-ADR-0021 inline-analytics key (`object` / `categoryField` / `valueField` / +`aggregate`, pivot `rowField` / `columnField`) or an objectui-internal prop +(`component`, inline `data`), points the author at the dataset shape +(`dataset` + `dimensions` + `values`). + +Recorded as protocol-16 migration `step16` +(`dashboard-widget-strict-unknown-keys`), mirroring protocol-15's `step15` +strict flip on the form/page schemas (ADR-0089 D3a). The inline-analytics shape +itself was already removed at protocol 9 (single-form cutover), so there is no +mechanical rewrite — the residue is the strictness, delegated to the author. + +**Breaking:** shipped as `minor` per the launch-window policy (a breaking change +does not burn a major while the stack is in lockstep), riding the already-pending +16.0.0 train. The release train's Version-Packages PR must set +`PROTOCOL_VERSION = '16.0.0'`; until then `step16` is inert +(`composeMigrationChain` caps at `PROTOCOL_MAJOR`). + +`@objectstack/lint` — the `widget-legacy-analytics-shape` / +`widget-legacy-analytics-unrenderable` rules are retained as the friendly, +suppressible bridge on the raw-config lint/doctor paths (strict preempts them on +the schema-parsed compile/validate paths); doc comment updated to explain the +interplay. diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index 992ea09c36..8f9f1401da 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -81,6 +81,14 @@ export const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = 'widget-legacy-analytics-unr * errored) because they still parse and a legacy object-bound widget keeps * rendering — the author is just being steered to the governed shape. * (liveness audit #1878 / #1894). + * + * Interplay with `DashboardWidgetSchema.strict()` (framework#3251, protocol 16): + * on the schema-parsed CLI paths (`compile`, `validate`) strict rejects these + * keys as a hard parse error *before* binding validation runs, so these rules + * are effectively preempted there. They remain the friendly, suppressible + * bridge on the raw-config paths (`lint`, `doctor`) that hand + * `validateWidgetBindings` un-parsed config — keeping the actionable + * "steer to the dataset shape" message rather than a bare unknown-key error. */ const LEGACY_ANALYTICS_KEYS = [ 'categoryField', 'valueField', 'xAxisField', 'yAxisFields', diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index f6d4f9c680..7beb2f525d 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -277,6 +277,51 @@ const step15: MigrationStep = { ], }; +/** + * Protocol 16 step. + * + * Mechanical: none — the pre-ADR-0021 inline analytics shape + * (`object`+`categoryField`+`valueField`+`aggregate`, pivot + * `rowField`/`columnField`) was already removed at protocol 9 (the single-form + * cutover), below the chain floor, so there is no key to rewrite. Semantic: the + * `.strict()` flip on `DashboardWidgetSchema` (framework#3251) turns a + * previously silently-stripped undeclared widget key into a parse error — a + * class of error that must move from fallible human review to deterministic CI, + * with no lossless auto-target for an arbitrary unknown key. + */ +const step16: MigrationStep = { + toMajor: 16, + rationale: + 'Protocol 16 flipped `DashboardWidgetSchema` to `.strict()` (framework#3251, ' + + 'ADR-0021 endpoint): an undeclared top-level widget key is now a loud parse ' + + 'error instead of a silent strip (ADR-0049 enforce-or-remove, ADR-0078 ' + + 'no-silently-inert). The inline analytics shape it most often catches ' + + '(`object`+`categoryField`+`valueField`+`aggregate`, pivot ' + + '`rowField`/`columnField`) was already removed at protocol 9, so no mechanical ' + + 'rewrite applies; the residue is the strictness itself, delegated to the author ' + + 'because an arbitrary unknown key has no lossless canonical target.', + conversionIds: [], + semantic: [ + { + id: 'dashboard-widget-strict-unknown-keys', + surface: 'dashboard widgets (undeclared top-level keys — legacy inline ' + + 'analytics, objectui-internal `component`/`data`, or typos)', + replacement: 'declared keys only (`dataset` + `dimensions` + `values` for ' + + 'analytics; `options` for renderer-specific extras)', + reason: + 'The `.strict()` flip turns a previously silently-stripped unknown key into a ' + + 'parse error. There is no mapping target for an arbitrary unknown key — ' + + 'auto-deleting it would be exactly the silent data loss ADR-0078 bans — so ' + + 'each occurrence needs the author to decide: bind a `dataset` and select ' + + '`dimensions`/`values`, move a renderer setting under `options`, or delete ' + + 'the dead key.', + acceptanceCriteria: + '`objectstack validate` passes with no unknown-key parse errors on dashboard ' + + 'widgets.', + }, + ], +}; + /** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { 11: step11, @@ -284,6 +329,7 @@ export const MIGRATIONS_BY_MAJOR: Readonly> = { 13: step13, 14: step14, 15: step15, + 16: step16, }; /** The majors that have a step, ascending. */ diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index b9d497432b..ac2233f3ad 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -67,10 +67,50 @@ describe('DashboardWidgetSchema (dataset-bound)', () => { expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', dataset: 'sales', values: [], layout: { x: 0, y: 0, w: 3, h: 2 } })).toThrow(); }); - it('a widget supplying only the removed inline fields is invalid (no dataset)', () => { + it('a widget supplying only the removed inline fields is invalid (missing dataset AND unknown keys)', () => { + // Fails twice over now: no `dataset`/`values`, and under `.strict()` the + // legacy `object`/`aggregate` keys are unrecognized. expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', object: 'opportunity', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } } as any)).toThrow(); }); + // ── .strict() endpoint (framework#3251, protocol 16 step16) ────────────── + it('rejects an otherwise-valid widget carrying a legacy analytics key, and points at the dataset shape', () => { + const legacy = { id: 'w_legacy', type: 'bar', dataset: 'sales', values: ['revenue'], categoryField: 'stage' } as any; + const res = DashboardWidgetSchema.safeParse(legacy); + expect(res.success).toBe(false); + if (!res.success) { + const unknown = res.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown).toBeDefined(); + const msg = unknown!.message; + expect(msg).toContain('categoryField'); + expect(msg).toContain('dataset'); + } + }); + + it('rejects the objectui-internal `component` / inline `data` keys', () => { + expect(() => DashboardWidgetSchema.parse({ id: 'w_comp', type: 'metric', dataset: 'sales', values: ['revenue'], component: {} } as any)).toThrow(); + const res = DashboardWidgetSchema.safeParse({ id: 'w_data', type: 'metric', dataset: 'sales', values: ['revenue'], data: [] } as any); + expect(res.success).toBe(false); + if (!res.success) { + const unknown = res.error.issues.find((i) => i.code === 'unrecognized_keys'); + expect(unknown!.message).toContain('objectui-internal'); + } + }); + + it('rejects an unknown/typo top-level key and names it in the error', () => { + const res = DashboardWidgetSchema.safeParse({ id: 'w_typo', type: 'metric', dataset: 'sales', values: ['revenue'], colourVariant: 'blue' } as any); + expect(res.success).toBe(false); + if (!res.success) expect(JSON.stringify(res.error.issues)).toContain('colourVariant'); + }); + + it('keeps `options` as the free-form renderer-extras escape hatch', () => { + const w = DashboardWidgetSchema.parse({ + id: 'w_opts', type: 'bar', dataset: 'sales', values: ['revenue'], + options: { stacked: true, palette: ['#111', '#222'], drillDown: { enabled: true } }, + }); + expect((w.options as any).stacked).toBe(true); + }); + it('keeps the runtime capability gates', () => { const w = DashboardWidgetSchema.parse({ id: 'gated', type: 'metric', dataset: 'sys', values: ['cnt'], diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index 8dfaa60629..3cae90aab5 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -68,6 +68,60 @@ export const DashboardHeaderSchema = lazySchema(() => z.object({ actions: z.array(DashboardHeaderActionSchema).optional().describe('Header action buttons'), }).describe('Dashboard header configuration')); +/** + * Legacy / quarantined widget keys that `.strict()` now rejects. Naming them + * lets the error map hand the author a fixable message instead of a bare + * "unrecognized key". Two families: + * + * - **Pre-ADR-0021 inline analytics** (`object`/`categoryField`/`valueField`/ + * `aggregate`/`rowField`/`columnField`/…): removed from the authorable spec at + * `@objectstack/spec` 9.0.0 (the single-form cutover). Bind a `dataset` and + * select `dimensions`/`values` instead. + * - **objectui-internal props** (`component`, inline `data`): renderer-only + * capabilities that are intentionally not modeled server-side (framework#3251 + * decision tree) — they must not appear on AI-authored dashboard metadata. + */ +const LEGACY_WIDGET_ANALYTICS_KEYS = new Set([ + 'object', 'categoryField', 'categoryGranularity', 'valueField', 'aggregate', + 'aggregation', 'rowField', 'columnField', 'xAxisField', 'yAxisFields', 'measures', +]); +const QUARANTINED_WIDGET_KEYS = new Set(['component', 'data']); + +/** + * Error map for the strict `DashboardWidgetSchema`. Turns an + * `unrecognized_keys` rejection into a *fixable* message: it always names the + * offending key(s), and when a key is a removed inline-analytics key or an + * objectui-internal prop it points the author at the ADR-0021 dataset shape + * (and `options` for renderer-specific extras). Mirrors `strictVisibilityError` + * (ADR-0089 D3a); every other issue code defers to zod's default. + */ +const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => { + if (issue.code !== 'unrecognized_keys') return undefined; + const keys = (issue as { keys?: readonly string[] }).keys ?? []; + const list = keys.map((k) => `\`${k}\``).join(', '); + const base = + `Unrecognized key(s) on this dashboard widget: ${list}. ` + + `Undeclared top-level keys were dropped silently before strict validation, ` + + `shipping inert metadata; a stale or mis-layered key is now a loud parse error.`; + if (keys.some((k) => LEGACY_WIDGET_ANALYTICS_KEYS.has(k))) { + return ( + base + + ' The pre-ADR-0021 inline analytics shape (`object` + `categoryField` + ' + + '`valueField` + `aggregate`, pivot `rowField`/`columnField`) was removed — ' + + 'bind a `dataset` and select `dimensions` + `values` by name. Renderer-only ' + + 'settings belong under `options`.' + ); + } + if (keys.some((k) => QUARANTINED_WIDGET_KEYS.has(k))) { + return ( + base + + ' `component` and inline `data` are objectui-internal renderer capabilities, ' + + 'not part of the author-facing dashboard spec (framework#3251).' + ); + } + return base; +}; + /** * Dashboard Widget Schema * A single component on the dashboard grid. @@ -210,7 +264,13 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({ aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'), // ADR-0021 single-form: every widget binds a `dataset` and selects `values` // (both required above) — there is no inline-query shape to disambiguate. -})); +}, { error: strictWidgetAnalyticsError }) + // ADR-0021 endpoint (framework#3251, protocol 16 `step16`): reject undeclared + // top-level keys instead of silently stripping them. A hallucinated or legacy + // key is now a deterministic author-time error (CI) rather than a silent + // no-op a human reviewer would miss. `options` stays the free-form escape + // hatch for renderer-specific extras. + .strict()); /** * Dynamic options binding for global filters.