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/6002-dashboard-widget-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator
in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key
REFUSES the parse with zod's `unrecognized_keys` issue naming every offending
key, instead of being silently deleted.

Before this change the schema was a plain `z.object()`: a widget carrying
`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out,
verdict ACCEPT — the same "dropped without a word" failure the schema's own
docstring records from the pre-derivation hand copy, still live for every key
no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1
two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed
as PR #6945), then this flip makes a stale or mistyped key loud everywhere the
contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog
gate) instead of only inside one catalog test.

**Who is affected — a widget authoring a key outside the declared surface:**
the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` /
`valueField` / `aggregate`) is the canonical case — it used to validate clean
with all four keys deleted; it now refuses with the keys named. The spec's
tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` /
`responsive`) keep their specific removal messages — they are declared
`z.never()` members, so they do not degrade to a generic unknown-key error.

**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot.
Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not
widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a
component node is owned by objectui's own passthrough `BaseSchema`, and
`DashboardComponentSchema`'s widget slot now routes component-enum types there
before the strict widget schema is consulted. The legacy
`{ id, component, layout }` envelope also still parses. The repo-wide corpus
preflight (575 JSON files, every dashboard-bearing doc fence, all designer
emit paths) measured **zero** newly-refused widgets.
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,12 @@
* COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` /
* `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry
* `inputs`, not widget keys), while every other widget is the spec-derived
* `DashboardWidgetSchema`'s. Check 3 is what catches a stale or
* mis-layered key that check 1 would strip in silence.
* `DashboardWidgetSchema`'s. When this gate was written check 3 caught the
* stale or mis-layered keys check 1 stripped in silence; since
* objectui#6002 made the widget schema `.strict()` (and moved this same
* routing into the shared `DashboardComponentSchema` widget slot), those
* keys refuse loudly in checks 1 AND 3, and the dropped-key detector
* remains as the regression floor.
*
* ## Deliberately NOT catalog-wide
*
Expand DownExpand Up@@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] {
// schema that owns its body is objectui's own `BaseSchema` — the ruling's
// "objectui's own component schema" — which is passthrough and therefore
// keeps the component's props. Everything else is a spec-derived widget.
//
// Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared
// key on that arm surfaces as a REFUSAL from the parse below (loud, key
// named) rather than reaching the dropped-key detector. The detector stays:
// it is the regression floor if strictness is ever loosened back toward the
// strip regime — the same defence-in-depth stance check 2 takes for `type`.
const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type);
const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema;
const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema';
Expand DownExpand Up@@ -288,28 +298,35 @@ describe('counter-probe — the gate refuses deliberately malformed entries', ()
'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)',
(w) => { w.type = 'metrci-card'; },
// Both halves must fire: the shared schema's own enum message AND the
// catalog's restatement of it.
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
// catalog's restatement of it. Order-insensitive lookaheads since
// objectui#6002: the doc-level parse (check 1) now reports the widget
// slot's union as a bare `Invalid input`, so the enum message reaches
// the report through check 3's routed parse, AFTER the restatement.
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a chart family the spec retired',
(w) => { w.type = 'heatmap'; },
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a widget naming no component at all',
(w) => { delete w.type; },
/no `type` and no `component` envelope/,
],
[
// Pre-#6002 these two were caught by the dropped-key detector
// (`silently DROPS authored key(s)`); the strict widget schema now
// refuses them upstream of it, naming every key (objectui#6002).
'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about',
(w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; },
/silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/,
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/,
],
[
'check 3 — a single mis-layered renderer setting left at widget top level',
(w) => { w.dateGranularity = 'month'; },
/silently DROPS authored key\(s\) `dateGranularity`/,
// zod prints the singular `Unrecognized key:` for a single offender.
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/,
],
[
'check 1 — a retired key the spec tombstoned, refused rather than dropped',
Expand Down
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget
* keys are REFUSED BY NAME, never silently stripped.
*
* The failure mode being closed is the one the schema's own docstring records
* from the previous incident (the hand copy dropping 12 spec keys "without a
* word"): a plain `z.object()` deletes whatever it does not declare and then
* reports success. Measured on the #4600 branch and re-measured here as the
* red control: a widget carrying `zzcanary` / `categoryField` / `aggregate`
* parsed five-keys-in, three-keys-out, verdict ACCEPT.
*
* Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150
* declares the genuinely consumed keys FIRST, then this schema flips strict.
* Both faces of the flip are pinned here:
*
* 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named);
* 2. the legal surface is untouched — every declared key still parses AND
* survives (strict must not turn into a narrowing of the accepted set
* beyond key membership);
* 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593)
* is unhurt: a `metric-card` node's props are component inputs, not
* widget keys, and the slot routes it to passthrough `BaseSchema`
* before the strict schema is consulted;
* 4. the routing arm is NOT a hatch: a spec-family widget with stray keys
* is refused even at the document level, where the union could
* otherwise have offered the passthrough arm as an escape.
*
* Tombstone precedence (`actionUrl` et al. keep their own removal messages
* under strict) is pinned next door in `report-chart-query-spec-parity.test.ts`
* — the drift guard this schema names; the catalog-side routing gate is
* `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { BaseSchema } from '../zod/base.zod.js';
import {
DashboardComponentSchema,
DashboardWidgetSchema,
} from '../zod/complex.zod.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';

describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => {
it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => {
// The exact probe from the card body. Before #6002: ACCEPT with
// `zzcanary`/`categoryField`/`aggregate` deleted in silence.
const result = DashboardWidgetSchema.safeParse({
type: 'metric',
title: 'x',
zzcanary: 1,
categoryField: 'stage',
aggregate: 'sum',
});
expect(result.success, 'undeclared keys must refuse, not strip').toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined();
// Every offending key is named — the author is told exactly what the
// contract does not know, not just that "something" is wrong.
expect((issue as { keys?: string[] }).keys).toEqual(
expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']),
);
});

it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => {
// `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600
// was opened about. Under the strip regime a widget carrying all four
// validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder
// was the only surface that ever mentioned them, and only at render time.
const result = DashboardWidgetSchema.safeParse({
id: 'w1',
type: 'bar',
object: 'opportunity',
categoryField: 'stage',
valueField: 'amount',
aggregate: 'sum',
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect((issue as { keys?: string[] })?.keys).toEqual(
expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']),
);
});

it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => {
// Same fixture family as the parity suite's "stops stripping" case, plus
// `options` (the single-value form the README documents). Key-for-key
// survival is the other half of strict: refusal of the undeclared set must
// not come with any change to what the declared set keeps.
const widget = {
id: 'w1',
type: 'bar',
title: 'Pipeline',
description: 'Pipeline by stage',
colorVariant: 'blue',
requiresObject: 'opportunity',
requiresService: 'analytics',
suppressWarnings: ['no-data'],
dataset: 'pipeline',
dimensions: ['stage'],
values: ['amount'],
layout: { x: 0, y: 0, w: 6, h: 4 },
filterBindings: { dateRange: 'closed_at' },
options: { value: '1,234' },
};
const result = DashboardWidgetSchema.safeParse(widget);
expect(result.success, 'the declared surface must stay green').toBe(true);
if (!result.success) return;
for (const key of Object.keys(widget)) {
expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key);
}
});

it('the legacy `component` envelope (no `type`) still parses', () => {
const result = DashboardWidgetSchema.safeParse({
id: 'w_legacy',
component: { type: 'metric-card', title: 'Revenue', value: '$1' },
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.success).toBe(true);
});
});

describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => {
// A real component node as the catalog authors it: registry `inputs`
// (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST
// NOT be refused by the strict widget schema.
const metricCard = {
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
icon: 'users',
trend: 'up',
trendValue: '+12%',
description: 'vs last month',
};

it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => {
// Non-vacuity: an emptied component enum would make every case below
// unreachable while staying green.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0);
});

it('a dashboard document holding a component node parses whole, props kept', () => {
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [metricCard],
});
expect(result.success, 'the ruled-legal component node must not refuse').toBe(true);
if (!result.success) return;
const kept = result.data.widgets[0] as Record<string, unknown>;
for (const key of Object.keys(metricCard)) {
expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key);
}
});

it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => {
// The slot's passthrough behaviour is BaseSchema's, not a private third
// schema: whatever BaseSchema keeps, the slot keeps.
const viaBase = BaseSchema.parse(metricCard) as Record<string, unknown>;
for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key);
});

it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => {
// The union offers the passthrough arm ONLY to component-enum types; a
// `metric` widget with an undeclared key must not slip through it.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }],
});
expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false);
if (result.success) return;
// The union nests each arm's issues; the strict arm's refusal must be in
// there, naming the key.
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('zzcanary');
});

it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => {
// A widget that fails the component arm (type outside the component enum)
// and carries keys the strict arm refuses: both arms red, document red.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'bar', value: '$1', trend: 'up' }],
});
expect(result.success).toBe(false);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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/6002-dashboard-widget-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator
in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key
REFUSES the parse with zod's `unrecognized_keys` issue naming every offending
key, instead of being silently deleted.

Before this change the schema was a plain `z.object()`: a widget carrying
`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out,
verdict ACCEPT — the same "dropped without a word" failure the schema's own
docstring records from the pre-derivation hand copy, still live for every key
no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1
two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed
as PR #6945), then this flip makes a stale or mistyped key loud everywhere the
contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog
gate) instead of only inside one catalog test.

**Who is affected — a widget authoring a key outside the declared surface:**
the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` /
`valueField` / `aggregate`) is the canonical case — it used to validate clean
with all four keys deleted; it now refuses with the keys named. The spec's
tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` /
`responsive`) keep their specific removal messages — they are declared
`z.never()` members, so they do not degrade to a generic unknown-key error.

**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot.
Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not
widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a
component node is owned by objectui's own passthrough `BaseSchema`, and
`DashboardComponentSchema`'s widget slot now routes component-enum types there
before the strict widget schema is consulted. The legacy
`{ id, component, layout }` envelope also still parses. The repo-wide corpus
preflight (575 JSON files, every dashboard-bearing doc fence, all designer
emit paths) measured **zero** newly-refused widgets.
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,12 @@
* COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` /
* `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry
* `inputs`, not widget keys), while every other widget is the spec-derived
* `DashboardWidgetSchema`'s. Check 3 is what catches a stale or
* mis-layered key that check 1 would strip in silence.
* `DashboardWidgetSchema`'s. When this gate was written check 3 caught the
* stale or mis-layered keys check 1 stripped in silence; since
* objectui#6002 made the widget schema `.strict()` (and moved this same
* routing into the shared `DashboardComponentSchema` widget slot), those
* keys refuse loudly in checks 1 AND 3, and the dropped-key detector
* remains as the regression floor.
*
* ## Deliberately NOT catalog-wide
*
Expand DownExpand Up@@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] {
// schema that owns its body is objectui's own `BaseSchema` — the ruling's
// "objectui's own component schema" — which is passthrough and therefore
// keeps the component's props. Everything else is a spec-derived widget.
//
// Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared
// key on that arm surfaces as a REFUSAL from the parse below (loud, key
// named) rather than reaching the dropped-key detector. The detector stays:
// it is the regression floor if strictness is ever loosened back toward the
// strip regime — the same defence-in-depth stance check 2 takes for `type`.
const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type);
const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema;
const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema';
Expand DownExpand Up@@ -288,28 +298,35 @@ describe('counter-probe — the gate refuses deliberately malformed entries', ()
'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)',
(w) => { w.type = 'metrci-card'; },
// Both halves must fire: the shared schema's own enum message AND the
// catalog's restatement of it.
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
// catalog's restatement of it. Order-insensitive lookaheads since
// objectui#6002: the doc-level parse (check 1) now reports the widget
// slot's union as a bare `Invalid input`, so the enum message reaches
// the report through check 3's routed parse, AFTER the restatement.
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a chart family the spec retired',
(w) => { w.type = 'heatmap'; },
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a widget naming no component at all',
(w) => { delete w.type; },
/no `type` and no `component` envelope/,
],
[
// Pre-#6002 these two were caught by the dropped-key detector
// (`silently DROPS authored key(s)`); the strict widget schema now
// refuses them upstream of it, naming every key (objectui#6002).
'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about',
(w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; },
/silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/,
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/,
],
[
'check 3 — a single mis-layered renderer setting left at widget top level',
(w) => { w.dateGranularity = 'month'; },
/silently DROPS authored key\(s\) `dateGranularity`/,
// zod prints the singular `Unrecognized key:` for a single offender.
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/,
],
[
'check 1 — a retired key the spec tombstoned, refused rather than dropped',
Expand Down
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget
* keys are REFUSED BY NAME, never silently stripped.
*
* The failure mode being closed is the one the schema's own docstring records
* from the previous incident (the hand copy dropping 12 spec keys "without a
* word"): a plain `z.object()` deletes whatever it does not declare and then
* reports success. Measured on the #4600 branch and re-measured here as the
* red control: a widget carrying `zzcanary` / `categoryField` / `aggregate`
* parsed five-keys-in, three-keys-out, verdict ACCEPT.
*
* Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150
* declares the genuinely consumed keys FIRST, then this schema flips strict.
* Both faces of the flip are pinned here:
*
* 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named);
* 2. the legal surface is untouched — every declared key still parses AND
* survives (strict must not turn into a narrowing of the accepted set
* beyond key membership);
* 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593)
* is unhurt: a `metric-card` node's props are component inputs, not
* widget keys, and the slot routes it to passthrough `BaseSchema`
* before the strict schema is consulted;
* 4. the routing arm is NOT a hatch: a spec-family widget with stray keys
* is refused even at the document level, where the union could
* otherwise have offered the passthrough arm as an escape.
*
* Tombstone precedence (`actionUrl` et al. keep their own removal messages
* under strict) is pinned next door in `report-chart-query-spec-parity.test.ts`
* — the drift guard this schema names; the catalog-side routing gate is
* `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { BaseSchema } from '../zod/base.zod.js';
import {
DashboardComponentSchema,
DashboardWidgetSchema,
} from '../zod/complex.zod.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';

describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => {
it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => {
// The exact probe from the card body. Before #6002: ACCEPT with
// `zzcanary`/`categoryField`/`aggregate` deleted in silence.
const result = DashboardWidgetSchema.safeParse({
type: 'metric',
title: 'x',
zzcanary: 1,
categoryField: 'stage',
aggregate: 'sum',
});
expect(result.success, 'undeclared keys must refuse, not strip').toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined();
// Every offending key is named — the author is told exactly what the
// contract does not know, not just that "something" is wrong.
expect((issue as { keys?: string[] }).keys).toEqual(
expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']),
);
});

it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => {
// `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600
// was opened about. Under the strip regime a widget carrying all four
// validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder
// was the only surface that ever mentioned them, and only at render time.
const result = DashboardWidgetSchema.safeParse({
id: 'w1',
type: 'bar',
object: 'opportunity',
categoryField: 'stage',
valueField: 'amount',
aggregate: 'sum',
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect((issue as { keys?: string[] })?.keys).toEqual(
expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']),
);
});

it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => {
// Same fixture family as the parity suite's "stops stripping" case, plus
// `options` (the single-value form the README documents). Key-for-key
// survival is the other half of strict: refusal of the undeclared set must
// not come with any change to what the declared set keeps.
const widget = {
id: 'w1',
type: 'bar',
title: 'Pipeline',
description: 'Pipeline by stage',
colorVariant: 'blue',
requiresObject: 'opportunity',
requiresService: 'analytics',
suppressWarnings: ['no-data'],
dataset: 'pipeline',
dimensions: ['stage'],
values: ['amount'],
layout: { x: 0, y: 0, w: 6, h: 4 },
filterBindings: { dateRange: 'closed_at' },
options: { value: '1,234' },
};
const result = DashboardWidgetSchema.safeParse(widget);
expect(result.success, 'the declared surface must stay green').toBe(true);
if (!result.success) return;
for (const key of Object.keys(widget)) {
expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key);
}
});

it('the legacy `component` envelope (no `type`) still parses', () => {
const result = DashboardWidgetSchema.safeParse({
id: 'w_legacy',
component: { type: 'metric-card', title: 'Revenue', value: '$1' },
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.success).toBe(true);
});
});

describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => {
// A real component node as the catalog authors it: registry `inputs`
// (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST
// NOT be refused by the strict widget schema.
const metricCard = {
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
icon: 'users',
trend: 'up',
trendValue: '+12%',
description: 'vs last month',
};

it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => {
// Non-vacuity: an emptied component enum would make every case below
// unreachable while staying green.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0);
});

it('a dashboard document holding a component node parses whole, props kept', () => {
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [metricCard],
});
expect(result.success, 'the ruled-legal component node must not refuse').toBe(true);
if (!result.success) return;
const kept = result.data.widgets[0] as Record<string, unknown>;
for (const key of Object.keys(metricCard)) {
expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key);
}
});

it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => {
// The slot's passthrough behaviour is BaseSchema's, not a private third
// schema: whatever BaseSchema keeps, the slot keeps.
const viaBase = BaseSchema.parse(metricCard) as Record<string, unknown>;
for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key);
});

it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => {
// The union offers the passthrough arm ONLY to component-enum types; a
// `metric` widget with an undeclared key must not slip through it.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }],
});
expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false);
if (result.success) return;
// The union nests each arm's issues; the strict arm's refusal must be in
// there, naming the key.
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('zzcanary');
});

it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => {
// A widget that fails the component arm (type outside the component enum)
// and carries keys the strict arm refuses: both arms red, document red.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'bar', value: '$1', trend: 'up' }],
});
expect(result.success).toBe(false);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/6002-dashboard-widget-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator
in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key
REFUSES the parse with zod's `unrecognized_keys` issue naming every offending
key, instead of being silently deleted.

Before this change the schema was a plain `z.object()`: a widget carrying
`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out,
verdict ACCEPT — the same "dropped without a word" failure the schema's own
docstring records from the pre-derivation hand copy, still live for every key
no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1
two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed
as PR #6945), then this flip makes a stale or mistyped key loud everywhere the
contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog
gate) instead of only inside one catalog test.

**Who is affected — a widget authoring a key outside the declared surface:**
the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` /
`valueField` / `aggregate`) is the canonical case — it used to validate clean
with all four keys deleted; it now refuses with the keys named. The spec's
tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` /
`responsive`) keep their specific removal messages — they are declared
`z.never()` members, so they do not degrade to a generic unknown-key error.

**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot.
Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not
widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a
component node is owned by objectui's own passthrough `BaseSchema`, and
`DashboardComponentSchema`'s widget slot now routes component-enum types there
before the strict widget schema is consulted. The legacy
`{ id, component, layout }` envelope also still parses. The repo-wide corpus
preflight (575 JSON files, every dashboard-bearing doc fence, all designer
emit paths) measured **zero** newly-refused widgets.
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,12 @@
* COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` /
* `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry
* `inputs`, not widget keys), while every other widget is the spec-derived
* `DashboardWidgetSchema`'s. Check 3 is what catches a stale or
* mis-layered key that check 1 would strip in silence.
* `DashboardWidgetSchema`'s. When this gate was written check 3 caught the
* stale or mis-layered keys check 1 stripped in silence; since
* objectui#6002 made the widget schema `.strict()` (and moved this same
* routing into the shared `DashboardComponentSchema` widget slot), those
* keys refuse loudly in checks 1 AND 3, and the dropped-key detector
* remains as the regression floor.
*
* ## Deliberately NOT catalog-wide
*
Expand DownExpand Up@@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] {
// schema that owns its body is objectui's own `BaseSchema` — the ruling's
// "objectui's own component schema" — which is passthrough and therefore
// keeps the component's props. Everything else is a spec-derived widget.
//
// Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared
// key on that arm surfaces as a REFUSAL from the parse below (loud, key
// named) rather than reaching the dropped-key detector. The detector stays:
// it is the regression floor if strictness is ever loosened back toward the
// strip regime — the same defence-in-depth stance check 2 takes for `type`.
const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type);
const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema;
const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema';
Expand DownExpand Up@@ -288,28 +298,35 @@ describe('counter-probe — the gate refuses deliberately malformed entries', ()
'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)',
(w) => { w.type = 'metrci-card'; },
// Both halves must fire: the shared schema's own enum message AND the
// catalog's restatement of it.
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
// catalog's restatement of it. Order-insensitive lookaheads since
// objectui#6002: the doc-level parse (check 1) now reports the widget
// slot's union as a bare `Invalid input`, so the enum message reaches
// the report through check 3's routed parse, AFTER the restatement.
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a chart family the spec retired',
(w) => { w.type = 'heatmap'; },
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a widget naming no component at all',
(w) => { delete w.type; },
/no `type` and no `component` envelope/,
],
[
// Pre-#6002 these two were caught by the dropped-key detector
// (`silently DROPS authored key(s)`); the strict widget schema now
// refuses them upstream of it, naming every key (objectui#6002).
'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about',
(w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; },
/silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/,
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/,
],
[
'check 3 — a single mis-layered renderer setting left at widget top level',
(w) => { w.dateGranularity = 'month'; },
/silently DROPS authored key\(s\) `dateGranularity`/,
// zod prints the singular `Unrecognized key:` for a single offender.
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/,
],
[
'check 1 — a retired key the spec tombstoned, refused rather than dropped',
Expand Down
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget
* keys are REFUSED BY NAME, never silently stripped.
*
* The failure mode being closed is the one the schema's own docstring records
* from the previous incident (the hand copy dropping 12 spec keys "without a
* word"): a plain `z.object()` deletes whatever it does not declare and then
* reports success. Measured on the #4600 branch and re-measured here as the
* red control: a widget carrying `zzcanary` / `categoryField` / `aggregate`
* parsed five-keys-in, three-keys-out, verdict ACCEPT.
*
* Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150
* declares the genuinely consumed keys FIRST, then this schema flips strict.
* Both faces of the flip are pinned here:
*
* 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named);
* 2. the legal surface is untouched — every declared key still parses AND
* survives (strict must not turn into a narrowing of the accepted set
* beyond key membership);
* 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593)
* is unhurt: a `metric-card` node's props are component inputs, not
* widget keys, and the slot routes it to passthrough `BaseSchema`
* before the strict schema is consulted;
* 4. the routing arm is NOT a hatch: a spec-family widget with stray keys
* is refused even at the document level, where the union could
* otherwise have offered the passthrough arm as an escape.
*
* Tombstone precedence (`actionUrl` et al. keep their own removal messages
* under strict) is pinned next door in `report-chart-query-spec-parity.test.ts`
* — the drift guard this schema names; the catalog-side routing gate is
* `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { BaseSchema } from '../zod/base.zod.js';
import {
DashboardComponentSchema,
DashboardWidgetSchema,
} from '../zod/complex.zod.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';

describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => {
it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => {
// The exact probe from the card body. Before #6002: ACCEPT with
// `zzcanary`/`categoryField`/`aggregate` deleted in silence.
const result = DashboardWidgetSchema.safeParse({
type: 'metric',
title: 'x',
zzcanary: 1,
categoryField: 'stage',
aggregate: 'sum',
});
expect(result.success, 'undeclared keys must refuse, not strip').toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined();
// Every offending key is named — the author is told exactly what the
// contract does not know, not just that "something" is wrong.
expect((issue as { keys?: string[] }).keys).toEqual(
expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']),
);
});

it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => {
// `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600
// was opened about. Under the strip regime a widget carrying all four
// validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder
// was the only surface that ever mentioned them, and only at render time.
const result = DashboardWidgetSchema.safeParse({
id: 'w1',
type: 'bar',
object: 'opportunity',
categoryField: 'stage',
valueField: 'amount',
aggregate: 'sum',
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect((issue as { keys?: string[] })?.keys).toEqual(
expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']),
);
});

it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => {
// Same fixture family as the parity suite's "stops stripping" case, plus
// `options` (the single-value form the README documents). Key-for-key
// survival is the other half of strict: refusal of the undeclared set must
// not come with any change to what the declared set keeps.
const widget = {
id: 'w1',
type: 'bar',
title: 'Pipeline',
description: 'Pipeline by stage',
colorVariant: 'blue',
requiresObject: 'opportunity',
requiresService: 'analytics',
suppressWarnings: ['no-data'],
dataset: 'pipeline',
dimensions: ['stage'],
values: ['amount'],
layout: { x: 0, y: 0, w: 6, h: 4 },
filterBindings: { dateRange: 'closed_at' },
options: { value: '1,234' },
};
const result = DashboardWidgetSchema.safeParse(widget);
expect(result.success, 'the declared surface must stay green').toBe(true);
if (!result.success) return;
for (const key of Object.keys(widget)) {
expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key);
}
});

it('the legacy `component` envelope (no `type`) still parses', () => {
const result = DashboardWidgetSchema.safeParse({
id: 'w_legacy',
component: { type: 'metric-card', title: 'Revenue', value: '$1' },
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.success).toBe(true);
});
});

describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => {
// A real component node as the catalog authors it: registry `inputs`
// (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST
// NOT be refused by the strict widget schema.
const metricCard = {
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
icon: 'users',
trend: 'up',
trendValue: '+12%',
description: 'vs last month',
};

it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => {
// Non-vacuity: an emptied component enum would make every case below
// unreachable while staying green.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0);
});

it('a dashboard document holding a component node parses whole, props kept', () => {
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [metricCard],
});
expect(result.success, 'the ruled-legal component node must not refuse').toBe(true);
if (!result.success) return;
const kept = result.data.widgets[0] as Record<string, unknown>;
for (const key of Object.keys(metricCard)) {
expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key);
}
});

it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => {
// The slot's passthrough behaviour is BaseSchema's, not a private third
// schema: whatever BaseSchema keeps, the slot keeps.
const viaBase = BaseSchema.parse(metricCard) as Record<string, unknown>;
for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key);
});

it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => {
// The union offers the passthrough arm ONLY to component-enum types; a
// `metric` widget with an undeclared key must not slip through it.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }],
});
expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false);
if (result.success) return;
// The union nests each arm's issues; the strict arm's refusal must be in
// there, naming the key.
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('zzcanary');
});

it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => {
// A widget that fails the component arm (type outside the component enum)
// and carries keys the strict arm refuses: both arms red, document red.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'bar', value: '$1', trend: 'up' }],
});
expect(result.success).toBe(false);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/6002-dashboard-widget-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator
in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key
REFUSES the parse with zod's `unrecognized_keys` issue naming every offending
key, instead of being silently deleted.

Before this change the schema was a plain `z.object()`: a widget carrying
`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out,
verdict ACCEPT — the same "dropped without a word" failure the schema's own
docstring records from the pre-derivation hand copy, still live for every key
no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1
two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed
as PR #6945), then this flip makes a stale or mistyped key loud everywhere the
contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog
gate) instead of only inside one catalog test.

**Who is affected — a widget authoring a key outside the declared surface:**
the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` /
`valueField` / `aggregate`) is the canonical case — it used to validate clean
with all four keys deleted; it now refuses with the keys named. The spec's
tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` /
`responsive`) keep their specific removal messages — they are declared
`z.never()` members, so they do not degrade to a generic unknown-key error.

**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot.
Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not
widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a
component node is owned by objectui's own passthrough `BaseSchema`, and
`DashboardComponentSchema`'s widget slot now routes component-enum types there
before the strict widget schema is consulted. The legacy
`{ id, component, layout }` envelope also still parses. The repo-wide corpus
preflight (575 JSON files, every dashboard-bearing doc fence, all designer
emit paths) measured **zero** newly-refused widgets.
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,12 @@
* COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` /
* `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry
* `inputs`, not widget keys), while every other widget is the spec-derived
* `DashboardWidgetSchema`'s. Check 3 is what catches a stale or
* mis-layered key that check 1 would strip in silence.
* `DashboardWidgetSchema`'s. When this gate was written check 3 caught the
* stale or mis-layered keys check 1 stripped in silence; since
* objectui#6002 made the widget schema `.strict()` (and moved this same
* routing into the shared `DashboardComponentSchema` widget slot), those
* keys refuse loudly in checks 1 AND 3, and the dropped-key detector
* remains as the regression floor.
*
* ## Deliberately NOT catalog-wide
*
Expand DownExpand Up@@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] {
// schema that owns its body is objectui's own `BaseSchema` — the ruling's
// "objectui's own component schema" — which is passthrough and therefore
// keeps the component's props. Everything else is a spec-derived widget.
//
// Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared
// key on that arm surfaces as a REFUSAL from the parse below (loud, key
// named) rather than reaching the dropped-key detector. The detector stays:
// it is the regression floor if strictness is ever loosened back toward the
// strip regime — the same defence-in-depth stance check 2 takes for `type`.
const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type);
const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema;
const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema';
Expand DownExpand Up@@ -288,28 +298,35 @@ describe('counter-probe — the gate refuses deliberately malformed entries', ()
'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)',
(w) => { w.type = 'metrci-card'; },
// Both halves must fire: the shared schema's own enum message AND the
// catalog's restatement of it.
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
// catalog's restatement of it. Order-insensitive lookaheads since
// objectui#6002: the doc-level parse (check 1) now reports the widget
// slot's union as a bare `Invalid input`, so the enum message reaches
// the report through check 3's routed parse, AFTER the restatement.
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a chart family the spec retired',
(w) => { w.type = 'heatmap'; },
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a widget naming no component at all',
(w) => { delete w.type; },
/no `type` and no `component` envelope/,
],
[
// Pre-#6002 these two were caught by the dropped-key detector
// (`silently DROPS authored key(s)`); the strict widget schema now
// refuses them upstream of it, naming every key (objectui#6002).
'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about',
(w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; },
/silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/,
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/,
],
[
'check 3 — a single mis-layered renderer setting left at widget top level',
(w) => { w.dateGranularity = 'month'; },
/silently DROPS authored key\(s\) `dateGranularity`/,
// zod prints the singular `Unrecognized key:` for a single offender.
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/,
],
[
'check 1 — a retired key the spec tombstoned, refused rather than dropped',
Expand Down
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget
* keys are REFUSED BY NAME, never silently stripped.
*
* The failure mode being closed is the one the schema's own docstring records
* from the previous incident (the hand copy dropping 12 spec keys "without a
* word"): a plain `z.object()` deletes whatever it does not declare and then
* reports success. Measured on the #4600 branch and re-measured here as the
* red control: a widget carrying `zzcanary` / `categoryField` / `aggregate`
* parsed five-keys-in, three-keys-out, verdict ACCEPT.
*
* Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150
* declares the genuinely consumed keys FIRST, then this schema flips strict.
* Both faces of the flip are pinned here:
*
* 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named);
* 2. the legal surface is untouched — every declared key still parses AND
* survives (strict must not turn into a narrowing of the accepted set
* beyond key membership);
* 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593)
* is unhurt: a `metric-card` node's props are component inputs, not
* widget keys, and the slot routes it to passthrough `BaseSchema`
* before the strict schema is consulted;
* 4. the routing arm is NOT a hatch: a spec-family widget with stray keys
* is refused even at the document level, where the union could
* otherwise have offered the passthrough arm as an escape.
*
* Tombstone precedence (`actionUrl` et al. keep their own removal messages
* under strict) is pinned next door in `report-chart-query-spec-parity.test.ts`
* — the drift guard this schema names; the catalog-side routing gate is
* `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { BaseSchema } from '../zod/base.zod.js';
import {
DashboardComponentSchema,
DashboardWidgetSchema,
} from '../zod/complex.zod.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';

describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => {
it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => {
// The exact probe from the card body. Before #6002: ACCEPT with
// `zzcanary`/`categoryField`/`aggregate` deleted in silence.
const result = DashboardWidgetSchema.safeParse({
type: 'metric',
title: 'x',
zzcanary: 1,
categoryField: 'stage',
aggregate: 'sum',
});
expect(result.success, 'undeclared keys must refuse, not strip').toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined();
// Every offending key is named — the author is told exactly what the
// contract does not know, not just that "something" is wrong.
expect((issue as { keys?: string[] }).keys).toEqual(
expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']),
);
});

it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => {
// `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600
// was opened about. Under the strip regime a widget carrying all four
// validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder
// was the only surface that ever mentioned them, and only at render time.
const result = DashboardWidgetSchema.safeParse({
id: 'w1',
type: 'bar',
object: 'opportunity',
categoryField: 'stage',
valueField: 'amount',
aggregate: 'sum',
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect((issue as { keys?: string[] })?.keys).toEqual(
expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']),
);
});

it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => {
// Same fixture family as the parity suite's "stops stripping" case, plus
// `options` (the single-value form the README documents). Key-for-key
// survival is the other half of strict: refusal of the undeclared set must
// not come with any change to what the declared set keeps.
const widget = {
id: 'w1',
type: 'bar',
title: 'Pipeline',
description: 'Pipeline by stage',
colorVariant: 'blue',
requiresObject: 'opportunity',
requiresService: 'analytics',
suppressWarnings: ['no-data'],
dataset: 'pipeline',
dimensions: ['stage'],
values: ['amount'],
layout: { x: 0, y: 0, w: 6, h: 4 },
filterBindings: { dateRange: 'closed_at' },
options: { value: '1,234' },
};
const result = DashboardWidgetSchema.safeParse(widget);
expect(result.success, 'the declared surface must stay green').toBe(true);
if (!result.success) return;
for (const key of Object.keys(widget)) {
expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key);
}
});

it('the legacy `component` envelope (no `type`) still parses', () => {
const result = DashboardWidgetSchema.safeParse({
id: 'w_legacy',
component: { type: 'metric-card', title: 'Revenue', value: '$1' },
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.success).toBe(true);
});
});

describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => {
// A real component node as the catalog authors it: registry `inputs`
// (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST
// NOT be refused by the strict widget schema.
const metricCard = {
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
icon: 'users',
trend: 'up',
trendValue: '+12%',
description: 'vs last month',
};

it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => {
// Non-vacuity: an emptied component enum would make every case below
// unreachable while staying green.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0);
});

it('a dashboard document holding a component node parses whole, props kept', () => {
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [metricCard],
});
expect(result.success, 'the ruled-legal component node must not refuse').toBe(true);
if (!result.success) return;
const kept = result.data.widgets[0] as Record<string, unknown>;
for (const key of Object.keys(metricCard)) {
expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key);
}
});

it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => {
// The slot's passthrough behaviour is BaseSchema's, not a private third
// schema: whatever BaseSchema keeps, the slot keeps.
const viaBase = BaseSchema.parse(metricCard) as Record<string, unknown>;
for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key);
});

it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => {
// The union offers the passthrough arm ONLY to component-enum types; a
// `metric` widget with an undeclared key must not slip through it.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }],
});
expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false);
if (result.success) return;
// The union nests each arm's issues; the strict arm's refusal must be in
// there, naming the key.
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('zzcanary');
});

it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => {
// A widget that fails the component arm (type outside the component enum)
// and carries keys the strict arm refuses: both arms red, document red.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'bar', value: '$1', trend: 'up' }],
});
expect(result.success).toBe(false);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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/6002-dashboard-widget-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator
in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key
REFUSES the parse with zod's `unrecognized_keys` issue naming every offending
key, instead of being silently deleted.

Before this change the schema was a plain `z.object()`: a widget carrying
`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out,
verdict ACCEPT — the same "dropped without a word" failure the schema's own
docstring records from the pre-derivation hand copy, still live for every key
no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1
two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed
as PR #6945), then this flip makes a stale or mistyped key loud everywhere the
contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog
gate) instead of only inside one catalog test.

**Who is affected — a widget authoring a key outside the declared surface:**
the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` /
`valueField` / `aggregate`) is the canonical case — it used to validate clean
with all four keys deleted; it now refuses with the keys named. The spec's
tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` /
`responsive`) keep their specific removal messages — they are declared
`z.never()` members, so they do not degrade to a generic unknown-key error.

**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot.
Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not
widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a
component node is owned by objectui's own passthrough `BaseSchema`, and
`DashboardComponentSchema`'s widget slot now routes component-enum types there
before the strict widget schema is consulted. The legacy
`{ id, component, layout }` envelope also still parses. The repo-wide corpus
preflight (575 JSON files, every dashboard-bearing doc fence, all designer
emit paths) measured **zero** newly-refused widgets.
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,12 @@
* COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` /
* `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry
* `inputs`, not widget keys), while every other widget is the spec-derived
* `DashboardWidgetSchema`'s. Check 3 is what catches a stale or
* mis-layered key that check 1 would strip in silence.
* `DashboardWidgetSchema`'s. When this gate was written check 3 caught the
* stale or mis-layered keys check 1 stripped in silence; since
* objectui#6002 made the widget schema `.strict()` (and moved this same
* routing into the shared `DashboardComponentSchema` widget slot), those
* keys refuse loudly in checks 1 AND 3, and the dropped-key detector
* remains as the regression floor.
*
* ## Deliberately NOT catalog-wide
*
Expand DownExpand Up@@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] {
// schema that owns its body is objectui's own `BaseSchema` — the ruling's
// "objectui's own component schema" — which is passthrough and therefore
// keeps the component's props. Everything else is a spec-derived widget.
//
// Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared
// key on that arm surfaces as a REFUSAL from the parse below (loud, key
// named) rather than reaching the dropped-key detector. The detector stays:
// it is the regression floor if strictness is ever loosened back toward the
// strip regime — the same defence-in-depth stance check 2 takes for `type`.
const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type);
const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema;
const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema';
Expand DownExpand Up@@ -288,28 +298,35 @@ describe('counter-probe — the gate refuses deliberately malformed entries', ()
'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)',
(w) => { w.type = 'metrci-card'; },
// Both halves must fire: the shared schema's own enum message AND the
// catalog's restatement of it.
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
// catalog's restatement of it. Order-insensitive lookaheads since
// objectui#6002: the doc-level parse (check 1) now reports the widget
// slot's union as a bare `Invalid input`, so the enum message reaches
// the report through check 3's routed parse, AFTER the restatement.
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a chart family the spec retired',
(w) => { w.type = 'heatmap'; },
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a widget naming no component at all',
(w) => { delete w.type; },
/no `type` and no `component` envelope/,
],
[
// Pre-#6002 these two were caught by the dropped-key detector
// (`silently DROPS authored key(s)`); the strict widget schema now
// refuses them upstream of it, naming every key (objectui#6002).
'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about',
(w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; },
/silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/,
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/,
],
[
'check 3 — a single mis-layered renderer setting left at widget top level',
(w) => { w.dateGranularity = 'month'; },
/silently DROPS authored key\(s\) `dateGranularity`/,
// zod prints the singular `Unrecognized key:` for a single offender.
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/,
],
[
'check 1 — a retired key the spec tombstoned, refused rather than dropped',
Expand Down
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget
* keys are REFUSED BY NAME, never silently stripped.
*
* The failure mode being closed is the one the schema's own docstring records
* from the previous incident (the hand copy dropping 12 spec keys "without a
* word"): a plain `z.object()` deletes whatever it does not declare and then
* reports success. Measured on the #4600 branch and re-measured here as the
* red control: a widget carrying `zzcanary` / `categoryField` / `aggregate`
* parsed five-keys-in, three-keys-out, verdict ACCEPT.
*
* Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150
* declares the genuinely consumed keys FIRST, then this schema flips strict.
* Both faces of the flip are pinned here:
*
* 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named);
* 2. the legal surface is untouched — every declared key still parses AND
* survives (strict must not turn into a narrowing of the accepted set
* beyond key membership);
* 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593)
* is unhurt: a `metric-card` node's props are component inputs, not
* widget keys, and the slot routes it to passthrough `BaseSchema`
* before the strict schema is consulted;
* 4. the routing arm is NOT a hatch: a spec-family widget with stray keys
* is refused even at the document level, where the union could
* otherwise have offered the passthrough arm as an escape.
*
* Tombstone precedence (`actionUrl` et al. keep their own removal messages
* under strict) is pinned next door in `report-chart-query-spec-parity.test.ts`
* — the drift guard this schema names; the catalog-side routing gate is
* `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { BaseSchema } from '../zod/base.zod.js';
import {
DashboardComponentSchema,
DashboardWidgetSchema,
} from '../zod/complex.zod.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';

describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => {
it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => {
// The exact probe from the card body. Before #6002: ACCEPT with
// `zzcanary`/`categoryField`/`aggregate` deleted in silence.
const result = DashboardWidgetSchema.safeParse({
type: 'metric',
title: 'x',
zzcanary: 1,
categoryField: 'stage',
aggregate: 'sum',
});
expect(result.success, 'undeclared keys must refuse, not strip').toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined();
// Every offending key is named — the author is told exactly what the
// contract does not know, not just that "something" is wrong.
expect((issue as { keys?: string[] }).keys).toEqual(
expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']),
);
});

it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => {
// `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600
// was opened about. Under the strip regime a widget carrying all four
// validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder
// was the only surface that ever mentioned them, and only at render time.
const result = DashboardWidgetSchema.safeParse({
id: 'w1',
type: 'bar',
object: 'opportunity',
categoryField: 'stage',
valueField: 'amount',
aggregate: 'sum',
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect((issue as { keys?: string[] })?.keys).toEqual(
expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']),
);
});

it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => {
// Same fixture family as the parity suite's "stops stripping" case, plus
// `options` (the single-value form the README documents). Key-for-key
// survival is the other half of strict: refusal of the undeclared set must
// not come with any change to what the declared set keeps.
const widget = {
id: 'w1',
type: 'bar',
title: 'Pipeline',
description: 'Pipeline by stage',
colorVariant: 'blue',
requiresObject: 'opportunity',
requiresService: 'analytics',
suppressWarnings: ['no-data'],
dataset: 'pipeline',
dimensions: ['stage'],
values: ['amount'],
layout: { x: 0, y: 0, w: 6, h: 4 },
filterBindings: { dateRange: 'closed_at' },
options: { value: '1,234' },
};
const result = DashboardWidgetSchema.safeParse(widget);
expect(result.success, 'the declared surface must stay green').toBe(true);
if (!result.success) return;
for (const key of Object.keys(widget)) {
expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key);
}
});

it('the legacy `component` envelope (no `type`) still parses', () => {
const result = DashboardWidgetSchema.safeParse({
id: 'w_legacy',
component: { type: 'metric-card', title: 'Revenue', value: '$1' },
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.success).toBe(true);
});
});

describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => {
// A real component node as the catalog authors it: registry `inputs`
// (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST
// NOT be refused by the strict widget schema.
const metricCard = {
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
icon: 'users',
trend: 'up',
trendValue: '+12%',
description: 'vs last month',
};

it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => {
// Non-vacuity: an emptied component enum would make every case below
// unreachable while staying green.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0);
});

it('a dashboard document holding a component node parses whole, props kept', () => {
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [metricCard],
});
expect(result.success, 'the ruled-legal component node must not refuse').toBe(true);
if (!result.success) return;
const kept = result.data.widgets[0] as Record<string, unknown>;
for (const key of Object.keys(metricCard)) {
expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key);
}
});

it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => {
// The slot's passthrough behaviour is BaseSchema's, not a private third
// schema: whatever BaseSchema keeps, the slot keeps.
const viaBase = BaseSchema.parse(metricCard) as Record<string, unknown>;
for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key);
});

it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => {
// The union offers the passthrough arm ONLY to component-enum types; a
// `metric` widget with an undeclared key must not slip through it.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }],
});
expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false);
if (result.success) return;
// The union nests each arm's issues; the strict arm's refusal must be in
// there, naming the key.
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('zzcanary');
});

it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => {
// A widget that fails the component arm (type outside the component enum)
// and carries keys the strict arm refuses: both arms red, document red.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'bar', value: '$1', trend: 'up' }],
});
expect(result.success).toBe(false);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/6002-dashboard-widget-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator
in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key
REFUSES the parse with zod's `unrecognized_keys` issue naming every offending
key, instead of being silently deleted.

Before this change the schema was a plain `z.object()`: a widget carrying
`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out,
verdict ACCEPT — the same "dropped without a word" failure the schema's own
docstring records from the pre-derivation hand copy, still live for every key
no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1
two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed
as PR #6945), then this flip makes a stale or mistyped key loud everywhere the
contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog
gate) instead of only inside one catalog test.

**Who is affected — a widget authoring a key outside the declared surface:**
the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` /
`valueField` / `aggregate`) is the canonical case — it used to validate clean
with all four keys deleted; it now refuses with the keys named. The spec's
tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` /
`responsive`) keep their specific removal messages — they are declared
`z.never()` members, so they do not degrade to a generic unknown-key error.

**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot.
Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not
widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a
component node is owned by objectui's own passthrough `BaseSchema`, and
`DashboardComponentSchema`'s widget slot now routes component-enum types there
before the strict widget schema is consulted. The legacy
`{ id, component, layout }` envelope also still parses. The repo-wide corpus
preflight (575 JSON files, every dashboard-bearing doc fence, all designer
emit paths) measured **zero** newly-refused widgets.
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,12 @@
* COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` /
* `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry
* `inputs`, not widget keys), while every other widget is the spec-derived
* `DashboardWidgetSchema`'s. Check 3 is what catches a stale or
* mis-layered key that check 1 would strip in silence.
* `DashboardWidgetSchema`'s. When this gate was written check 3 caught the
* stale or mis-layered keys check 1 stripped in silence; since
* objectui#6002 made the widget schema `.strict()` (and moved this same
* routing into the shared `DashboardComponentSchema` widget slot), those
* keys refuse loudly in checks 1 AND 3, and the dropped-key detector
* remains as the regression floor.
*
* ## Deliberately NOT catalog-wide
*
Expand DownExpand Up@@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] {
// schema that owns its body is objectui's own `BaseSchema` — the ruling's
// "objectui's own component schema" — which is passthrough and therefore
// keeps the component's props. Everything else is a spec-derived widget.
//
// Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared
// key on that arm surfaces as a REFUSAL from the parse below (loud, key
// named) rather than reaching the dropped-key detector. The detector stays:
// it is the regression floor if strictness is ever loosened back toward the
// strip regime — the same defence-in-depth stance check 2 takes for `type`.
const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type);
const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema;
const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema';
Expand DownExpand Up@@ -288,28 +298,35 @@ describe('counter-probe — the gate refuses deliberately malformed entries', ()
'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)',
(w) => { w.type = 'metrci-card'; },
// Both halves must fire: the shared schema's own enum message AND the
// catalog's restatement of it.
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
// catalog's restatement of it. Order-insensitive lookaheads since
// objectui#6002: the doc-level parse (check 1) now reports the widget
// slot's union as a bare `Invalid input`, so the enum message reaches
// the report through check 3's routed parse, AFTER the restatement.
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a chart family the spec retired',
(w) => { w.type = 'heatmap'; },
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a widget naming no component at all',
(w) => { delete w.type; },
/no `type` and no `component` envelope/,
],
[
// Pre-#6002 these two were caught by the dropped-key detector
// (`silently DROPS authored key(s)`); the strict widget schema now
// refuses them upstream of it, naming every key (objectui#6002).
'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about',
(w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; },
/silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/,
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/,
],
[
'check 3 — a single mis-layered renderer setting left at widget top level',
(w) => { w.dateGranularity = 'month'; },
/silently DROPS authored key\(s\) `dateGranularity`/,
// zod prints the singular `Unrecognized key:` for a single offender.
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/,
],
[
'check 1 — a retired key the spec tombstoned, refused rather than dropped',
Expand Down
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget
* keys are REFUSED BY NAME, never silently stripped.
*
* The failure mode being closed is the one the schema's own docstring records
* from the previous incident (the hand copy dropping 12 spec keys "without a
* word"): a plain `z.object()` deletes whatever it does not declare and then
* reports success. Measured on the #4600 branch and re-measured here as the
* red control: a widget carrying `zzcanary` / `categoryField` / `aggregate`
* parsed five-keys-in, three-keys-out, verdict ACCEPT.
*
* Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150
* declares the genuinely consumed keys FIRST, then this schema flips strict.
* Both faces of the flip are pinned here:
*
* 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named);
* 2. the legal surface is untouched — every declared key still parses AND
* survives (strict must not turn into a narrowing of the accepted set
* beyond key membership);
* 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593)
* is unhurt: a `metric-card` node's props are component inputs, not
* widget keys, and the slot routes it to passthrough `BaseSchema`
* before the strict schema is consulted;
* 4. the routing arm is NOT a hatch: a spec-family widget with stray keys
* is refused even at the document level, where the union could
* otherwise have offered the passthrough arm as an escape.
*
* Tombstone precedence (`actionUrl` et al. keep their own removal messages
* under strict) is pinned next door in `report-chart-query-spec-parity.test.ts`
* — the drift guard this schema names; the catalog-side routing gate is
* `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { BaseSchema } from '../zod/base.zod.js';
import {
DashboardComponentSchema,
DashboardWidgetSchema,
} from '../zod/complex.zod.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';

describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => {
it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => {
// The exact probe from the card body. Before #6002: ACCEPT with
// `zzcanary`/`categoryField`/`aggregate` deleted in silence.
const result = DashboardWidgetSchema.safeParse({
type: 'metric',
title: 'x',
zzcanary: 1,
categoryField: 'stage',
aggregate: 'sum',
});
expect(result.success, 'undeclared keys must refuse, not strip').toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined();
// Every offending key is named — the author is told exactly what the
// contract does not know, not just that "something" is wrong.
expect((issue as { keys?: string[] }).keys).toEqual(
expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']),
);
});

it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => {
// `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600
// was opened about. Under the strip regime a widget carrying all four
// validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder
// was the only surface that ever mentioned them, and only at render time.
const result = DashboardWidgetSchema.safeParse({
id: 'w1',
type: 'bar',
object: 'opportunity',
categoryField: 'stage',
valueField: 'amount',
aggregate: 'sum',
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect((issue as { keys?: string[] })?.keys).toEqual(
expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']),
);
});

it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => {
// Same fixture family as the parity suite's "stops stripping" case, plus
// `options` (the single-value form the README documents). Key-for-key
// survival is the other half of strict: refusal of the undeclared set must
// not come with any change to what the declared set keeps.
const widget = {
id: 'w1',
type: 'bar',
title: 'Pipeline',
description: 'Pipeline by stage',
colorVariant: 'blue',
requiresObject: 'opportunity',
requiresService: 'analytics',
suppressWarnings: ['no-data'],
dataset: 'pipeline',
dimensions: ['stage'],
values: ['amount'],
layout: { x: 0, y: 0, w: 6, h: 4 },
filterBindings: { dateRange: 'closed_at' },
options: { value: '1,234' },
};
const result = DashboardWidgetSchema.safeParse(widget);
expect(result.success, 'the declared surface must stay green').toBe(true);
if (!result.success) return;
for (const key of Object.keys(widget)) {
expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key);
}
});

it('the legacy `component` envelope (no `type`) still parses', () => {
const result = DashboardWidgetSchema.safeParse({
id: 'w_legacy',
component: { type: 'metric-card', title: 'Revenue', value: '$1' },
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.success).toBe(true);
});
});

describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => {
// A real component node as the catalog authors it: registry `inputs`
// (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST
// NOT be refused by the strict widget schema.
const metricCard = {
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
icon: 'users',
trend: 'up',
trendValue: '+12%',
description: 'vs last month',
};

it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => {
// Non-vacuity: an emptied component enum would make every case below
// unreachable while staying green.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0);
});

it('a dashboard document holding a component node parses whole, props kept', () => {
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [metricCard],
});
expect(result.success, 'the ruled-legal component node must not refuse').toBe(true);
if (!result.success) return;
const kept = result.data.widgets[0] as Record<string, unknown>;
for (const key of Object.keys(metricCard)) {
expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key);
}
});

it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => {
// The slot's passthrough behaviour is BaseSchema's, not a private third
// schema: whatever BaseSchema keeps, the slot keeps.
const viaBase = BaseSchema.parse(metricCard) as Record<string, unknown>;
for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key);
});

it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => {
// The union offers the passthrough arm ONLY to component-enum types; a
// `metric` widget with an undeclared key must not slip through it.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }],
});
expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false);
if (result.success) return;
// The union nests each arm's issues; the strict arm's refusal must be in
// there, naming the key.
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('zzcanary');
});

it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => {
// A widget that fails the component arm (type outside the component enum)
// and carries keys the strict arm refuses: both arms red, document red.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'bar', value: '$1', trend: 'up' }],
});
expect(result.success).toBe(false);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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/6002-dashboard-widget-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator
in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key
REFUSES the parse with zod's `unrecognized_keys` issue naming every offending
key, instead of being silently deleted.

Before this change the schema was a plain `z.object()`: a widget carrying
`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out,
verdict ACCEPT — the same "dropped without a word" failure the schema's own
docstring records from the pre-derivation hand copy, still live for every key
no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1
two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed
as PR #6945), then this flip makes a stale or mistyped key loud everywhere the
contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog
gate) instead of only inside one catalog test.

**Who is affected — a widget authoring a key outside the declared surface:**
the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` /
`valueField` / `aggregate`) is the canonical case — it used to validate clean
with all four keys deleted; it now refuses with the keys named. The spec's
tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` /
`responsive`) keep their specific removal messages — they are declared
`z.never()` members, so they do not degrade to a generic unknown-key error.

**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot.
Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not
widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a
component node is owned by objectui's own passthrough `BaseSchema`, and
`DashboardComponentSchema`'s widget slot now routes component-enum types there
before the strict widget schema is consulted. The legacy
`{ id, component, layout }` envelope also still parses. The repo-wide corpus
preflight (575 JSON files, every dashboard-bearing doc fence, all designer
emit paths) measured **zero** newly-refused widgets.
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,12 @@
* COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` /
* `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry
* `inputs`, not widget keys), while every other widget is the spec-derived
* `DashboardWidgetSchema`'s. Check 3 is what catches a stale or
* mis-layered key that check 1 would strip in silence.
* `DashboardWidgetSchema`'s. When this gate was written check 3 caught the
* stale or mis-layered keys check 1 stripped in silence; since
* objectui#6002 made the widget schema `.strict()` (and moved this same
* routing into the shared `DashboardComponentSchema` widget slot), those
* keys refuse loudly in checks 1 AND 3, and the dropped-key detector
* remains as the regression floor.
*
* ## Deliberately NOT catalog-wide
*
Expand DownExpand Up@@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] {
// schema that owns its body is objectui's own `BaseSchema` — the ruling's
// "objectui's own component schema" — which is passthrough and therefore
// keeps the component's props. Everything else is a spec-derived widget.
//
// Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared
// key on that arm surfaces as a REFUSAL from the parse below (loud, key
// named) rather than reaching the dropped-key detector. The detector stays:
// it is the regression floor if strictness is ever loosened back toward the
// strip regime — the same defence-in-depth stance check 2 takes for `type`.
const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type);
const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema;
const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema';
Expand DownExpand Up@@ -288,28 +298,35 @@ describe('counter-probe — the gate refuses deliberately malformed entries', ()
'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)',
(w) => { w.type = 'metrci-card'; },
// Both halves must fire: the shared schema's own enum message AND the
// catalog's restatement of it.
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
// catalog's restatement of it. Order-insensitive lookaheads since
// objectui#6002: the doc-level parse (check 1) now reports the widget
// slot's union as a bare `Invalid input`, so the enum message reaches
// the report through check 3's routed parse, AFTER the restatement.
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a chart family the spec retired',
(w) => { w.type = 'heatmap'; },
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a widget naming no component at all',
(w) => { delete w.type; },
/no `type` and no `component` envelope/,
],
[
// Pre-#6002 these two were caught by the dropped-key detector
// (`silently DROPS authored key(s)`); the strict widget schema now
// refuses them upstream of it, naming every key (objectui#6002).
'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about',
(w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; },
/silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/,
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/,
],
[
'check 3 — a single mis-layered renderer setting left at widget top level',
(w) => { w.dateGranularity = 'month'; },
/silently DROPS authored key\(s\) `dateGranularity`/,
// zod prints the singular `Unrecognized key:` for a single offender.
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/,
],
[
'check 1 — a retired key the spec tombstoned, refused rather than dropped',
Expand Down
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget
* keys are REFUSED BY NAME, never silently stripped.
*
* The failure mode being closed is the one the schema's own docstring records
* from the previous incident (the hand copy dropping 12 spec keys "without a
* word"): a plain `z.object()` deletes whatever it does not declare and then
* reports success. Measured on the #4600 branch and re-measured here as the
* red control: a widget carrying `zzcanary` / `categoryField` / `aggregate`
* parsed five-keys-in, three-keys-out, verdict ACCEPT.
*
* Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150
* declares the genuinely consumed keys FIRST, then this schema flips strict.
* Both faces of the flip are pinned here:
*
* 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named);
* 2. the legal surface is untouched — every declared key still parses AND
* survives (strict must not turn into a narrowing of the accepted set
* beyond key membership);
* 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593)
* is unhurt: a `metric-card` node's props are component inputs, not
* widget keys, and the slot routes it to passthrough `BaseSchema`
* before the strict schema is consulted;
* 4. the routing arm is NOT a hatch: a spec-family widget with stray keys
* is refused even at the document level, where the union could
* otherwise have offered the passthrough arm as an escape.
*
* Tombstone precedence (`actionUrl` et al. keep their own removal messages
* under strict) is pinned next door in `report-chart-query-spec-parity.test.ts`
* — the drift guard this schema names; the catalog-side routing gate is
* `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { BaseSchema } from '../zod/base.zod.js';
import {
DashboardComponentSchema,
DashboardWidgetSchema,
} from '../zod/complex.zod.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';

describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => {
it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => {
// The exact probe from the card body. Before #6002: ACCEPT with
// `zzcanary`/`categoryField`/`aggregate` deleted in silence.
const result = DashboardWidgetSchema.safeParse({
type: 'metric',
title: 'x',
zzcanary: 1,
categoryField: 'stage',
aggregate: 'sum',
});
expect(result.success, 'undeclared keys must refuse, not strip').toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined();
// Every offending key is named — the author is told exactly what the
// contract does not know, not just that "something" is wrong.
expect((issue as { keys?: string[] }).keys).toEqual(
expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']),
);
});

it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => {
// `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600
// was opened about. Under the strip regime a widget carrying all four
// validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder
// was the only surface that ever mentioned them, and only at render time.
const result = DashboardWidgetSchema.safeParse({
id: 'w1',
type: 'bar',
object: 'opportunity',
categoryField: 'stage',
valueField: 'amount',
aggregate: 'sum',
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect((issue as { keys?: string[] })?.keys).toEqual(
expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']),
);
});

it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => {
// Same fixture family as the parity suite's "stops stripping" case, plus
// `options` (the single-value form the README documents). Key-for-key
// survival is the other half of strict: refusal of the undeclared set must
// not come with any change to what the declared set keeps.
const widget = {
id: 'w1',
type: 'bar',
title: 'Pipeline',
description: 'Pipeline by stage',
colorVariant: 'blue',
requiresObject: 'opportunity',
requiresService: 'analytics',
suppressWarnings: ['no-data'],
dataset: 'pipeline',
dimensions: ['stage'],
values: ['amount'],
layout: { x: 0, y: 0, w: 6, h: 4 },
filterBindings: { dateRange: 'closed_at' },
options: { value: '1,234' },
};
const result = DashboardWidgetSchema.safeParse(widget);
expect(result.success, 'the declared surface must stay green').toBe(true);
if (!result.success) return;
for (const key of Object.keys(widget)) {
expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key);
}
});

it('the legacy `component` envelope (no `type`) still parses', () => {
const result = DashboardWidgetSchema.safeParse({
id: 'w_legacy',
component: { type: 'metric-card', title: 'Revenue', value: '$1' },
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.success).toBe(true);
});
});

describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => {
// A real component node as the catalog authors it: registry `inputs`
// (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST
// NOT be refused by the strict widget schema.
const metricCard = {
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
icon: 'users',
trend: 'up',
trendValue: '+12%',
description: 'vs last month',
};

it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => {
// Non-vacuity: an emptied component enum would make every case below
// unreachable while staying green.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0);
});

it('a dashboard document holding a component node parses whole, props kept', () => {
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [metricCard],
});
expect(result.success, 'the ruled-legal component node must not refuse').toBe(true);
if (!result.success) return;
const kept = result.data.widgets[0] as Record<string, unknown>;
for (const key of Object.keys(metricCard)) {
expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key);
}
});

it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => {
// The slot's passthrough behaviour is BaseSchema's, not a private third
// schema: whatever BaseSchema keeps, the slot keeps.
const viaBase = BaseSchema.parse(metricCard) as Record<string, unknown>;
for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key);
});

it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => {
// The union offers the passthrough arm ONLY to component-enum types; a
// `metric` widget with an undeclared key must not slip through it.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }],
});
expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false);
if (result.success) return;
// The union nests each arm's issues; the strict arm's refusal must be in
// there, naming the key.
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('zzcanary');
});

it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => {
// A widget that fails the component arm (type outside the component enum)
// and carries keys the strict arm refuses: both arms red, document red.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'bar', value: '$1', trend: 'up' }],
});
expect(result.success).toBe(false);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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/6002-dashboard-widget-strict.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
---
'@object-ui/types': minor
---

**Breaking for authored metadata:** `DashboardWidgetSchema` (the zod validator
in `@object-ui/types/zod`) is now `.strict()` — an undeclared widget key
REFUSES the parse with zod's `unrecognized_keys` issue naming every offending
key, instead of being silently deleted.

Before this change the schema was a plain `z.object()`: a widget carrying
`zzcanary` / `categoryField` / `aggregate` parsed five-keys-in, three-keys-out,
verdict ACCEPT — the same "dropped without a word" failure the schema's own
docstring records from the pre-derivation hand copy, still live for every key
no contract declares (objectui#6002). Maintainer ruling 2026-08-25, Route 1
two-step: objectui#6150 declared the 13 genuinely-consumed keys first (landed
as PR #6945), then this flip makes a stale or mistyped key loud everywhere the
contract is consulted (`objectui validate`, `safeValidateSchema`, the catalog
gate) instead of only inside one catalog test.

**Who is affected — a widget authoring a key outside the declared surface:**
the retired pre-ADR-0021 inline analytics shape (`object` / `categoryField` /
`valueField` / `aggregate`) is the canonical case — it used to validate clean
with all four keys deleted; it now refuses with the keys named. The spec's
tombstoned keys (`actionUrl` / `actionType` / `actionIcon` / `aria` /
`responsive`) keep their specific removal messages — they are declared
`z.never()` members, so they do not degrade to a generic unknown-key error.

**Not affected:** a `metric-card` COMPONENT node in a dashboard's widget slot.
Its props (`value` / `icon` / `trend` / `trendValue` — registry inputs, not
widget keys) stay legal: per the 2026-08-14 ruling (objectstack#8593) a
component node is owned by objectui's own passthrough `BaseSchema`, and
`DashboardComponentSchema`'s widget slot now routes component-enum types there
before the strict widget schema is consulted. The legacy
`{ id, component, layout }` envelope also still parses. The repo-wide corpus
preflight (575 JSON files, every dashboard-bearing doc fence, all designer
emit paths) measured **zero** newly-refused widgets.
Original file line numberDiff line numberDiff line change
Expand Up@@ -62,8 +62,12 @@
* COMPONENT node, so its body is `BaseSchema`'s (passthrough: `value` /
* `icon` / `trend` / `trendValue` are `plugin-dashboard`'s registry
* `inputs`, not widget keys), while every other widget is the spec-derived
* `DashboardWidgetSchema`'s. Check 3 is what catches a stale or
* mis-layered key that check 1 would strip in silence.
* `DashboardWidgetSchema`'s. When this gate was written check 3 caught the
* stale or mis-layered keys check 1 stripped in silence; since
* objectui#6002 made the widget schema `.strict()` (and moved this same
* routing into the shared `DashboardComponentSchema` widget slot), those
* keys refuse loudly in checks 1 AND 3, and the dropped-key detector
* remains as the regression floor.
*
* ## Deliberately NOT catalog-wide
*
Expand DownExpand Up@@ -141,6 +145,12 @@ function auditWidget(widget: Widget, where: string): string[] {
// schema that owns its body is objectui's own `BaseSchema` — the ruling's
// "objectui's own component schema" — which is passthrough and therefore
// keeps the component's props. Everything else is a spec-derived widget.
//
// Since objectui#6002 the spec-derived arm is `.strict()`, so an undeclared
// key on that arm surfaces as a REFUSAL from the parse below (loud, key
// named) rather than reaching the dropped-key detector. The detector stays:
// it is the regression floor if strictness is ever loosened back toward the
// strip regime — the same defence-in-depth stance check 2 takes for `type`.
const isComponentNode = typeof type === 'string' && COMPONENT_WIDGET_TYPES.has(type);
const owner = isComponentNode ? BaseSchema : DashboardWidgetSchema;
const ownerName = isComponentNode ? 'BaseSchema (objectui component node)' : 'DashboardWidgetSchema';
Expand DownExpand Up@@ -288,28 +298,35 @@ describe('counter-probe — the gate refuses deliberately malformed entries', ()
'check 2 — a widget type nothing registers (the OBJUI-001 red panel at runtime)',
(w) => { w.type = 'metrci-card'; },
// Both halves must fire: the shared schema's own enum message AND the
// catalog's restatement of it.
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
// catalog's restatement of it. Order-insensitive lookaheads since
// objectui#6002: the doc-level parse (check 1) now reports the widget
// slot's union as a bare `Invalid input`, so the enum message reaches
// the report through check 3's routed parse, AFTER the restatement.
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a chart family the spec retired',
(w) => { w.type = 'heatmap'; },
/Invalid option: expected one of[\s\S]*outside the closed widget vocabulary/,
/^(?=[\s\S]*Invalid option: expected one of)(?=[\s\S]*outside the closed widget vocabulary)/,
],
[
'check 2 — a widget naming no component at all',
(w) => { delete w.type; },
/no `type` and no `component` envelope/,
],
[
// Pre-#6002 these two were caught by the dropped-key detector
// (`silently DROPS authored key(s)`); the strict widget schema now
// refuses them upstream of it, naming every key (objectui#6002).
'check 3 — the pre-ADR-0021 inline analytics keys this card was opened about',
(w) => { w.object = 'opportunity'; w.categoryField = 'stage'; w.aggregate = 'sum'; },
/silently DROPS authored key\(s\).*`object`.*`categoryField`.*`aggregate`/,
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys[\s\S]*"object"[\s\S]*"categoryField"[\s\S]*"aggregate"/,
],
[
'check 3 — a single mis-layered renderer setting left at widget top level',
(w) => { w.dateGranularity = 'month'; },
/silently DROPS authored key\(s\) `dateGranularity`/,
// zod prints the singular `Unrecognized key:` for a single offender.
/refused by DashboardWidgetSchema[\s\S]*Unrecognized keys?[\s\S]*"dateGranularity"/,
],
[
'check 1 — a retired key the spec tombstoned, refused rather than dropped',
Expand Down
197 changes: 197 additions & 0 deletions packages/types/src/__tests__/dashboard-widget-strict-6002.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#6002 — `DashboardWidgetSchema` is `.strict()`: undeclared widget
* keys are REFUSED BY NAME, never silently stripped.
*
* The failure mode being closed is the one the schema's own docstring records
* from the previous incident (the hand copy dropping 12 spec keys "without a
* word"): a plain `z.object()` deletes whatever it does not declare and then
* reports success. Measured on the #4600 branch and re-measured here as the
* red control: a widget carrying `zzcanary` / `categoryField` / `aggregate`
* parsed five-keys-in, three-keys-out, verdict ACCEPT.
*
* Maintainer ruling 2026-08-25 (objectui#6002, Route 1 two-step): #6150
* declares the genuinely consumed keys FIRST, then this schema flips strict.
* Both faces of the flip are pinned here:
*
* 1. undeclared keys refuse loudly (`unrecognized_keys`, every key named);
* 2. the legal surface is untouched — every declared key still parses AND
* survives (strict must not turn into a narrowing of the accepted set
* beyond key membership);
* 3. the widget-slot COMPONENT route (2026-08-14 ruling, objectstack#8593)
* is unhurt: a `metric-card` node's props are component inputs, not
* widget keys, and the slot routes it to passthrough `BaseSchema`
* before the strict schema is consulted;
* 4. the routing arm is NOT a hatch: a spec-family widget with stray keys
* is refused even at the document level, where the union could
* otherwise have offered the passthrough arm as an escape.
*
* Tombstone precedence (`actionUrl` et al. keep their own removal messages
* under strict) is pinned next door in `report-chart-query-spec-parity.test.ts`
* — the drift guard this schema names; the catalog-side routing gate is
* `examples/schema-catalog/test/plugin-dashboard-component-schema.test.ts`.
*/

import { describe, it, expect } from 'vitest';
import { BaseSchema } from '../zod/base.zod.js';
import {
DashboardComponentSchema,
DashboardWidgetSchema,
} from '../zod/complex.zod.js';
import { DASHBOARD_COMPONENT_WIDGET_TYPES } from '../complex.js';

describe('DashboardWidgetSchema.strict() — undeclared keys refuse by name (objectui#6002)', () => {
it('the measured five-in/three-out ACCEPT is now a refusal naming all three keys', () => {
// The exact probe from the card body. Before #6002: ACCEPT with
// `zzcanary`/`categoryField`/`aggregate` deleted in silence.
const result = DashboardWidgetSchema.safeParse({
type: 'metric',
title: 'x',
zzcanary: 1,
categoryField: 'stage',
aggregate: 'sum',
});
expect(result.success, 'undeclared keys must refuse, not strip').toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect(issue, 'the refusal must be zod `unrecognized_keys`').toBeDefined();
// Every offending key is named — the author is told exactly what the
// contract does not know, not just that "something" is wrong.
expect((issue as { keys?: string[] }).keys).toEqual(
expect.arrayContaining(['zzcanary', 'categoryField', 'aggregate']),
);
});

it('the retired pre-ADR-0021 inline analytics shape refuses as a set', () => {
// `object`/`categoryField`/`valueField`/`aggregate` — the very keys #4600
// was opened about. Under the strip regime a widget carrying all four
// validated clean; the renderer's LEGACY_RETIRED_WIDGET_SCHEMA placeholder
// was the only surface that ever mentioned them, and only at render time.
const result = DashboardWidgetSchema.safeParse({
id: 'w1',
type: 'bar',
object: 'opportunity',
categoryField: 'stage',
valueField: 'amount',
aggregate: 'sum',
});
expect(result.success).toBe(false);
if (result.success) return;
const issue = result.error.issues.find((i) => i.code === 'unrecognized_keys');
expect((issue as { keys?: string[] })?.keys).toEqual(
expect.arrayContaining(['object', 'categoryField', 'valueField', 'aggregate']),
);
});

it('POSITIVE — a widget authoring the declared surface parses green and loses nothing', () => {
// Same fixture family as the parity suite's "stops stripping" case, plus
// `options` (the single-value form the README documents). Key-for-key
// survival is the other half of strict: refusal of the undeclared set must
// not come with any change to what the declared set keeps.
const widget = {
id: 'w1',
type: 'bar',
title: 'Pipeline',
description: 'Pipeline by stage',
colorVariant: 'blue',
requiresObject: 'opportunity',
requiresService: 'analytics',
suppressWarnings: ['no-data'],
dataset: 'pipeline',
dimensions: ['stage'],
values: ['amount'],
layout: { x: 0, y: 0, w: 6, h: 4 },
filterBindings: { dateRange: 'closed_at' },
options: { value: '1,234' },
};
const result = DashboardWidgetSchema.safeParse(widget);
expect(result.success, 'the declared surface must stay green').toBe(true);
if (!result.success) return;
for (const key of Object.keys(widget)) {
expect(result.data, `declared key \`${key}\` must survive the parse`).toHaveProperty(key);
}
});

it('the legacy `component` envelope (no `type`) still parses', () => {
const result = DashboardWidgetSchema.safeParse({
id: 'w_legacy',
component: { type: 'metric-card', title: 'Revenue', value: '$1' },
layout: { x: 0, y: 0, w: 3, h: 2 },
});
expect(result.success).toBe(true);
});
});

describe('the widget-slot component route is unhurt (objectstack#8593 ruling)', () => {
// A real component node as the catalog authors it: registry `inputs`
// (`value`/`icon`/`trend`/`trendValue`) that are NOT widget keys and MUST
// NOT be refused by the strict widget schema.
const metricCard = {
type: 'metric-card',
title: 'Total Sales',
value: '$123,456',
icon: 'users',
trend: 'up',
trendValue: '+12%',
description: 'vs last month',
};

it('DASHBOARD_COMPONENT_WIDGET_TYPES is the closed set the route keys on', () => {
// Non-vacuity: an emptied component enum would make every case below
// unreachable while staying green.
expect(DASHBOARD_COMPONENT_WIDGET_TYPES.length).toBeGreaterThan(0);
});

it('a dashboard document holding a component node parses whole, props kept', () => {
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [metricCard],
});
expect(result.success, 'the ruled-legal component node must not refuse').toBe(true);
if (!result.success) return;
const kept = result.data.widgets[0] as Record<string, unknown>;
for (const key of Object.keys(metricCard)) {
expect(kept, `component prop \`${key}\` must survive — it is a component input, not a widget key`).toHaveProperty(key);
}
});

it('the component arm agrees with BaseSchema — the ruling\'s named owner', () => {
// The slot's passthrough behaviour is BaseSchema's, not a private third
// schema: whatever BaseSchema keeps, the slot keeps.
const viaBase = BaseSchema.parse(metricCard) as Record<string, unknown>;
for (const key of Object.keys(metricCard)) expect(viaBase).toHaveProperty(key);
});

it('NOT A HATCH — a spec-family widget with stray keys refuses at document level too', () => {
// The union offers the passthrough arm ONLY to component-enum types; a
// `metric` widget with an undeclared key must not slip through it.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'metric', title: 'x', zzcanary: 1 }],
});
expect(result.success, 'the passthrough arm must not admit spec-family widgets').toBe(false);
if (result.success) return;
// The union nests each arm's issues; the strict arm's refusal must be in
// there, naming the key.
const flat = JSON.stringify(result.error.issues);
expect(flat).toContain('unrecognized_keys');
expect(flat).toContain('zzcanary');
});

it('NOT A HATCH — undeclared keys refuse even when the widget also carries component-ish props', () => {
// A widget that fails the component arm (type outside the component enum)
// and carries keys the strict arm refuses: both arms red, document red.
const result = DashboardComponentSchema.safeParse({
type: 'dashboard',
widgets: [{ type: 'bar', value: '$1', trend: 'up' }],
});
expect(result.success).toBe(false);
});
});
Loading
Loading