Skip to content

Commit 9b485f5

Browse files
committed
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 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() stays 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 step15's strict flip on the form/page schemas (ADR-0089 D3a). PROTOCOL_VERSION is NOT bumped here — the release train's Version-Packages PR sets it to 16.0.0; until then step16 is inert (composeMigrationChain caps at PROTOCOL_MAJOR). - lint: the widget-legacy-analytics-* rules are kept as the friendly bridge on the raw-config lint/doctor paths (strict preempts them on parsed paths); doc comment updated. Shipped as minor per the launch-window policy, riding the pending 16.0.0 train. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4qmiXd4wjnJMLt18Cir1Y
1 parent a140ff0 commit 9b485f5

5 files changed

Lines changed: 192 additions & 2 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": patch
4+
---
5+
6+
feat(spec)!: `DashboardWidgetSchema.strict()` — reject undeclared widget keys (framework#3251)
7+
8+
The ADR-0021 analytics endpoint. `DashboardWidgetSchema` now rejects any
9+
undeclared top-level key instead of silently stripping it, moving a whole class
10+
of author error (a hallucinated or legacy key that renders as a silent no-op)
11+
from fallible human review to deterministic CI. `options: z.unknown()` remains
12+
the escape hatch for renderer-specific extras.
13+
14+
A custom error map names the offending key(s) and, when a key is a removed
15+
pre-ADR-0021 inline-analytics key (`object` / `categoryField` / `valueField` /
16+
`aggregate`, pivot `rowField` / `columnField`) or an objectui-internal prop
17+
(`component`, inline `data`), points the author at the dataset shape
18+
(`dataset` + `dimensions` + `values`).
19+
20+
Recorded as protocol-16 migration `step16`
21+
(`dashboard-widget-strict-unknown-keys`), mirroring protocol-15's `step15`
22+
strict flip on the form/page schemas (ADR-0089 D3a). The inline-analytics shape
23+
itself was already removed at protocol 9 (single-form cutover), so there is no
24+
mechanical rewrite — the residue is the strictness, delegated to the author.
25+
26+
**Breaking:** shipped as `minor` per the launch-window policy (a breaking change
27+
does not burn a major while the stack is in lockstep), riding the already-pending
28+
16.0.0 train. The release train's Version-Packages PR must set
29+
`PROTOCOL_VERSION = '16.0.0'`; until then `step16` is inert
30+
(`composeMigrationChain` caps at `PROTOCOL_MAJOR`).
31+
32+
`@objectstack/lint` — the `widget-legacy-analytics-shape` /
33+
`widget-legacy-analytics-unrenderable` rules are retained as the friendly,
34+
suppressible bridge on the raw-config lint/doctor paths (strict preempts them on
35+
the schema-parsed compile/validate paths); doc comment updated to explain the
36+
interplay.

packages/lint/src/validate-widget-bindings.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ export const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = 'widget-legacy-analytics-unr
8181
* errored) because they still parse and a legacy object-bound widget keeps
8282
* rendering — the author is just being steered to the governed shape.
8383
* (liveness audit #1878 / #1894).
84+
*
85+
* Interplay with `DashboardWidgetSchema.strict()` (framework#3251, protocol 16):
86+
* on the schema-parsed CLI paths (`compile`, `validate`) strict rejects these
87+
* keys as a hard parse error *before* binding validation runs, so these rules
88+
* are effectively preempted there. They remain the friendly, suppressible
89+
* bridge on the raw-config paths (`lint`, `doctor`) that hand
90+
* `validateWidgetBindings` un-parsed config — keeping the actionable
91+
* "steer to the dataset shape" message rather than a bare unknown-key error.
8492
*/
8593
const LEGACY_ANALYTICS_KEYS = [
8694
'categoryField', 'valueField', 'xAxisField', 'yAxisFields',

packages/spec/src/migrations/registry.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,13 +277,59 @@ const step15: MigrationStep = {
277277
],
278278
};
279279

280+
/**
281+
* Protocol 16 step.
282+
*
283+
* Mechanical: none — the pre-ADR-0021 inline analytics shape
284+
* (`object`+`categoryField`+`valueField`+`aggregate`, pivot
285+
* `rowField`/`columnField`) was already removed at protocol 9 (the single-form
286+
* cutover), below the chain floor, so there is no key to rewrite. Semantic: the
287+
* `.strict()` flip on `DashboardWidgetSchema` (framework#3251) turns a
288+
* previously silently-stripped undeclared widget key into a parse error — a
289+
* class of error that must move from fallible human review to deterministic CI,
290+
* with no lossless auto-target for an arbitrary unknown key.
291+
*/
292+
const step16: MigrationStep = {
293+
toMajor: 16,
294+
rationale:
295+
'Protocol 16 flipped `DashboardWidgetSchema` to `.strict()` (framework#3251, ' +
296+
'ADR-0021 endpoint): an undeclared top-level widget key is now a loud parse ' +
297+
'error instead of a silent strip (ADR-0049 enforce-or-remove, ADR-0078 ' +
298+
'no-silently-inert). The inline analytics shape it most often catches ' +
299+
'(`object`+`categoryField`+`valueField`+`aggregate`, pivot ' +
300+
'`rowField`/`columnField`) was already removed at protocol 9, so no mechanical ' +
301+
'rewrite applies; the residue is the strictness itself, delegated to the author ' +
302+
'because an arbitrary unknown key has no lossless canonical target.',
303+
conversionIds: [],
304+
semantic: [
305+
{
306+
id: 'dashboard-widget-strict-unknown-keys',
307+
surface: 'dashboard widgets (undeclared top-level keys — legacy inline ' +
308+
'analytics, objectui-internal `component`/`data`, or typos)',
309+
replacement: 'declared keys only (`dataset` + `dimensions` + `values` for ' +
310+
'analytics; `options` for renderer-specific extras)',
311+
reason:
312+
'The `.strict()` flip turns a previously silently-stripped unknown key into a ' +
313+
'parse error. There is no mapping target for an arbitrary unknown key — ' +
314+
'auto-deleting it would be exactly the silent data loss ADR-0078 bans — so ' +
315+
'each occurrence needs the author to decide: bind a `dataset` and select ' +
316+
'`dimensions`/`values`, move a renderer setting under `options`, or delete ' +
317+
'the dead key.',
318+
acceptanceCriteria:
319+
'`objectstack validate` passes with no unknown-key parse errors on dashboard ' +
320+
'widgets.',
321+
},
322+
],
323+
};
324+
280325
/** All migration steps, keyed by the major they migrate into. */
281326
export const MIGRATIONS_BY_MAJOR: Readonly<Record<number, MigrationStep>> = {
282327
11: step11,
283328
12: step12,
284329
13: step13,
285330
14: step14,
286331
15: step15,
332+
16: step16,
287333
};
288334

289335
/** The majors that have a step, ascending. */

packages/spec/src/ui/dashboard.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,50 @@ describe('DashboardWidgetSchema (dataset-bound)', () => {
6767
expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', dataset: 'sales', values: [], layout: { x: 0, y: 0, w: 3, h: 2 } })).toThrow();
6868
});
6969

70-
it('a widget supplying only the removed inline fields is invalid (no dataset)', () => {
70+
it('a widget supplying only the removed inline fields is invalid (missing dataset AND unknown keys)', () => {
71+
// Fails twice over now: no `dataset`/`values`, and under `.strict()` the
72+
// legacy `object`/`aggregate` keys are unrecognized.
7173
expect(() => DashboardWidgetSchema.parse({ id: 'x', type: 'metric', object: 'opportunity', aggregate: 'count', layout: { x: 0, y: 0, w: 3, h: 2 } } as any)).toThrow();
7274
});
7375

76+
// ── .strict() endpoint (framework#3251, protocol 16 step16) ──────────────
77+
it('rejects an otherwise-valid widget carrying a legacy analytics key, and points at the dataset shape', () => {
78+
const legacy = { id: 'w_legacy', type: 'bar', dataset: 'sales', values: ['revenue'], categoryField: 'stage' } as any;
79+
const res = DashboardWidgetSchema.safeParse(legacy);
80+
expect(res.success).toBe(false);
81+
if (!res.success) {
82+
const unknown = res.error.issues.find((i) => i.code === 'unrecognized_keys');
83+
expect(unknown).toBeDefined();
84+
const msg = unknown!.message;
85+
expect(msg).toContain('categoryField');
86+
expect(msg).toContain('dataset');
87+
}
88+
});
89+
90+
it('rejects the objectui-internal `component` / inline `data` keys', () => {
91+
expect(() => DashboardWidgetSchema.parse({ id: 'w_comp', type: 'metric', dataset: 'sales', values: ['revenue'], component: {} } as any)).toThrow();
92+
const res = DashboardWidgetSchema.safeParse({ id: 'w_data', type: 'metric', dataset: 'sales', values: ['revenue'], data: [] } as any);
93+
expect(res.success).toBe(false);
94+
if (!res.success) {
95+
const unknown = res.error.issues.find((i) => i.code === 'unrecognized_keys');
96+
expect(unknown!.message).toContain('objectui-internal');
97+
}
98+
});
99+
100+
it('rejects an unknown/typo top-level key and names it in the error', () => {
101+
const res = DashboardWidgetSchema.safeParse({ id: 'w_typo', type: 'metric', dataset: 'sales', values: ['revenue'], colourVariant: 'blue' } as any);
102+
expect(res.success).toBe(false);
103+
if (!res.success) expect(JSON.stringify(res.error.issues)).toContain('colourVariant');
104+
});
105+
106+
it('keeps `options` as the free-form renderer-extras escape hatch', () => {
107+
const w = DashboardWidgetSchema.parse({
108+
id: 'w_opts', type: 'bar', dataset: 'sales', values: ['revenue'],
109+
options: { stacked: true, palette: ['#111', '#222'], drillDown: { enabled: true } },
110+
});
111+
expect((w.options as any).stacked).toBe(true);
112+
});
113+
74114
it('keeps the runtime capability gates', () => {
75115
const w = DashboardWidgetSchema.parse({
76116
id: 'gated', type: 'metric', dataset: 'sys', values: ['cnt'],

packages/spec/src/ui/dashboard.zod.ts

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,60 @@ export const DashboardHeaderSchema = lazySchema(() => z.object({
6868
actions: z.array(DashboardHeaderActionSchema).optional().describe('Header action buttons'),
6969
}).describe('Dashboard header configuration'));
7070

71+
/**
72+
* Legacy / quarantined widget keys that `.strict()` now rejects. Naming them
73+
* lets the error map hand the author a fixable message instead of a bare
74+
* "unrecognized key". Two families:
75+
*
76+
* - **Pre-ADR-0021 inline analytics** (`object`/`categoryField`/`valueField`/
77+
* `aggregate`/`rowField`/`columnField`/…): removed from the authorable spec at
78+
* `@objectstack/spec` 9.0.0 (the single-form cutover). Bind a `dataset` and
79+
* select `dimensions`/`values` instead.
80+
* - **objectui-internal props** (`component`, inline `data`): renderer-only
81+
* capabilities that are intentionally not modeled server-side (framework#3251
82+
* decision tree) — they must not appear on AI-authored dashboard metadata.
83+
*/
84+
const LEGACY_WIDGET_ANALYTICS_KEYS = new Set([
85+
'object', 'categoryField', 'categoryGranularity', 'valueField', 'aggregate',
86+
'aggregation', 'rowField', 'columnField', 'xAxisField', 'yAxisFields', 'measures',
87+
]);
88+
const QUARANTINED_WIDGET_KEYS = new Set(['component', 'data']);
89+
90+
/**
91+
* Error map for the strict `DashboardWidgetSchema`. Turns an
92+
* `unrecognized_keys` rejection into a *fixable* message: it always names the
93+
* offending key(s), and when a key is a removed inline-analytics key or an
94+
* objectui-internal prop it points the author at the ADR-0021 dataset shape
95+
* (and `options` for renderer-specific extras). Mirrors `strictVisibilityError`
96+
* (ADR-0089 D3a); every other issue code defers to zod's default.
97+
*/
98+
const strictWidgetAnalyticsError: z.core.$ZodErrorMap = (issue) => {
99+
if (issue.code !== 'unrecognized_keys') return undefined;
100+
const keys = (issue as { keys?: readonly string[] }).keys ?? [];
101+
const list = keys.map((k) => `\`${k}\``).join(', ');
102+
const base =
103+
`Unrecognized key(s) on this dashboard widget: ${list}. ` +
104+
`Undeclared top-level keys were dropped silently before strict validation, ` +
105+
`shipping inert metadata; a stale or mis-layered key is now a loud parse error.`;
106+
if (keys.some((k) => LEGACY_WIDGET_ANALYTICS_KEYS.has(k))) {
107+
return (
108+
base +
109+
' The pre-ADR-0021 inline analytics shape (`object` + `categoryField` + ' +
110+
'`valueField` + `aggregate`, pivot `rowField`/`columnField`) was removed — ' +
111+
'bind a `dataset` and select `dimensions` + `values` by name. Renderer-only ' +
112+
'settings belong under `options`.'
113+
);
114+
}
115+
if (keys.some((k) => QUARANTINED_WIDGET_KEYS.has(k))) {
116+
return (
117+
base +
118+
' `component` and inline `data` are objectui-internal renderer capabilities, ' +
119+
'not part of the author-facing dashboard spec (framework#3251).'
120+
);
121+
}
122+
return base;
123+
};
124+
71125
/**
72126
* Dashboard Widget Schema
73127
* A single component on the dashboard grid.
@@ -210,7 +264,13 @@ export const DashboardWidgetSchema = lazySchema(() => z.object({
210264
aria: AriaPropsSchema.optional().describe('ARIA accessibility attributes'),
211265
// ADR-0021 single-form: every widget binds a `dataset` and selects `values`
212266
// (both required above) — there is no inline-query shape to disambiguate.
213-
}));
267+
}, { error: strictWidgetAnalyticsError })
268+
// ADR-0021 endpoint (framework#3251, protocol 16 `step16`): reject undeclared
269+
// top-level keys instead of silently stripping them. A hallucinated or legacy
270+
// key is now a deterministic author-time error (CI) rather than a silent
271+
// no-op a human reviewer would miss. `options` stays the free-form escape
272+
// hatch for renderer-specific extras.
273+
.strict());
214274

215275
/**
216276
* Dynamic options binding for global filters.

0 commit comments

Comments
 (0)