Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/dashboard-widget-strict-3251.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions packages/lint/src/validate-widget-bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
46 changes: 46 additions & 0 deletions packages/spec/src/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,13 +277,59 @@ 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<Record<number, MigrationStep>> = {
11: step11,
12: step12,
13: step13,
14: step14,
15: step15,
16: step16,
};

/** The majors that have a step, ascending. */
Expand Down
42 changes: 41 additions & 1 deletion packages/spec/src/ui/dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
62 changes: 61 additions & 1 deletion packages/spec/src/ui/dashboard.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
Loading