diff --git a/.changeset/dashboard-stageorder-gated-to-funnel.md b/.changeset/dashboard-stageorder-gated-to-funnel.md new file mode 100644 index 0000000000..72ba949edb --- /dev/null +++ b/.changeset/dashboard-stageorder-gated-to-funnel.md @@ -0,0 +1,55 @@ +--- +"@objectstack/spec": minor +--- + +fix(spec)!: `dashboard.widgets[].options.stageOrder` is refused on every widget type that does not read it (#17344, finding 1) + + + +**BREAKING** — an accept-set narrowing on a published authoring surface. `options.stageOrder` was an ungated member of the widget `options` bag and parsed on every widget `type`; it is now refused at parse on every type except `funnel`. Shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Stored metadata carrying `stageOrder` on a non-`funnel` widget now fails validation and must be re-authored — the hand-migration prescription is registered under protocol major 18 as `dashboard-widget-stage-order-non-funnel-refused`. + +## What was wrong + +The key never failed. It failed to *order*. + +`options` is the open renderer-extras bag, so nothing closed over `stageOrder`: a `horizontal-bar` widget carrying an authored seven-stage contract lifecycle parsed, booted, and forwarded the array to the renderer — which never looked at it, and rendered alphabetically by display label instead. + +Measured at this repo's `.objectui-sha` pin `53ded82b`: the forwarded `categoryOrder` prop has exactly **one** read in the charts plugin — `buildCategoryRank(categoryOrder)` at `AdvancedChartImpl.tsx:1514` — and it sits inside the `chartType === 'funnel'` guard opened at line 1473. The prop's only other occurrences in that file are its declaration (247) and its destructure (850). The producer has no gate either: `DatasetWidget.tsx:1468` builds the explicit order for **any** widget and forwards it whenever non-empty. + +So the authored order was accepted by the metadata layer, carried all the way to the chart, and dropped there with nothing anywhere to say so. A chart rendered in an order the author did not ask for, and did not ask for it *visibly* — it just looked deliberate. That is ADR-0049's enforce-or-remove shape, and a doc sentence saying "only `funnel` reads this" is not enforcement: it is prose the author has to read first. + +## What it does now + +`DashboardWidgetSchema` carries an object-level check that refuses `stageOrder` unless the widget's `type` is `funnel`. + +It has to be object-level: `stageOrder` lives inside `DashboardWidgetOptionsSchema` while the `type` that decides whether it means anything is that object's **sibling one level up**, so a per-field refinement on `stageOrder` cannot see it. The check is a named function chained on with `.superRefine(…)` — the idiom this file already uses for `GlobalFilterSchema`'s date-default rule, rather than a second shape invented for one key. + +The refusal lands at `options.stageOrder` and names three things, because the defect was silence and a bare "unrecognized key" answers silence with a shrug: the key, the `type` this widget carries, and the one `type` that honours it — plus where ordering lives for everything else. + +## FROM → TO + +| you wrote | write instead | +| --- | --- | +| `{ type: 'horizontal-bar', options: { stageOrder: [...] } }` | `{ type: 'horizontal-bar', options: { sortBy: 'contract_count', sortOrder: 'desc' } }` | +| `{ type: 'funnel', options: { stageOrder: [...] } }` | unchanged — this is the one type that reads it | +| `{ options: { stageOrder: [...] } }` (no `type`) | `{ type: 'funnel', options: { stageOrder: [...] } }` if a funnel was meant | + +⚠️ Deleting the key changes nothing about what renders — the widget was already ignoring it. `sortBy` / `sortOrder` are what change it, and unlike a category order they lower into the dataset query as `order: { : 'asc' | 'desc' }` rather than re-sorting what it returned. + +## What the gate does NOT cover + +Stated so the change is not read as complete: + +- ⚠️ **objectui's client-side authoring door.** This refusal is the **publish** door's, not the editor's. `@object-ui/types` builds its own `DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every object-level check — measured here: `z.strictObject(DashboardWidgetSchema.shape)` accepts a `horizontal-bar` carrying `stageOrder` and reports zero checks, while `.extend({})` keeps the refusal. At the pinned `.objectui-sha` that package re-attaches none of this spec's exported checks, so until it imports and chains `checkDashboardWidgetStageOrder` the dashboard editor keeps accepting the key on a `bar`. That mirror also redeclares `type` as optional with no default, so a typeless widget would reach a re-attached check as `undefined` rather than as `metric`; the exported check defaults it itself for exactly that caller, so re-attaching is sufficient. +- **A widget whose `type` is outside `ChartTypeSchema`.** zod treats that `invalid_value` as aborting and skips object-level checks for the input, so `type: 'ziggurat'` plus a `stageOrder` reports the type refusal alone. The author fixes the type, re-parses, and meets this refusal then; the two are never seen together. Pinned. +- **A widget that declares no `type`.** `type` carries `.default('metric')` and zod applies defaults before object-level checks, so an omitted `type` is indistinguishable here from an authored `metric`. The verdict is right either way — `metric` reads the key no more than `horizontal-bar` does — and that one case carries an extra sentence pointing at the missing `type` rather than a wrong one. +- **The array's contents.** Still unconstrained `string | number | boolean` members, unmatched against the dimension's picklist. A `funnel` carrying a misspelled stage parses and renders that stage in the sentinel position; whether a stored value exists is a fact about the dataset, not about the widget. +- **Consumers that derive this schema with `.omit()` / `.pick()` / `.partial()`.** zod 4 throws on all three once an object carries a refinement, so this change converts those three from working to throwing. Latent rather than live — no consumer in either repo derives the widget schema that way today — and `.extend()` is unaffected. + +## The siblings, measured and deliberately not touched + +`stageOrder` was the only member of that bag with this shape. `dateGranularity`, `sortBy`, `sortOrder` and `limit` are read unconditionally at the top of `DatasetWidget` (lines 443–455, outside every type branch) and lower into the `DatasetSelection` the server compiles, so they act on every widget type. + +## The other arm, deliberately not taken + +The card offered either/or: gate the key, **or** teach the ordered marks (`bar` / `column` / `horizontal-bar` / `line` / `area`) to honour it. The second is a renderer change in `objectstack-ai/objectui` and not this repo's to make. The asymmetry also favours gating: a narrowing that is later relaxed costs an author nothing, while an accepted-and-inert key costs them a chart that silently says something they did not author. diff --git a/content/docs/references/ui/dashboard.mdx b/content/docs/references/ui/dashboard.mdx index 7b0b1dcfd6..36efee95f9 100644 --- a/content/docs/references/ui/dashboard.mdx +++ b/content/docs/references/ui/dashboard.mdx @@ -246,7 +246,7 @@ Dashboard header action | **sortBy** | `string` | optional | Dimension/measure name to order by | | **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy | | **limit** | `integer` | optional | Max rows (applied after ordering) | -| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it: on any other type the key parses and is never consulted, so order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. | +| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it, and the schema refuses it on any other type rather than accepting an order nothing consults — order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. | --- @@ -263,7 +263,7 @@ Widget configuration — declared query keys + open renderer extras | **sortBy** | `string` | optional | Dimension/measure name to order by | | **sortOrder** | `Enum<'asc' \| 'desc'>` | optional | Sort direction for sortBy | | **limit** | `integer` | optional | Max rows (applied after ordering) | -| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it: on any other type the key parses and is never consulted, so order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. | +| **stageOrder** | `(string \| number \| boolean)[]` | optional | Explicit stage order for a funnel widget, as the dimension's stored values. `funnel` is the only widget type that reads it, and the schema refuses it on any other type rather than accepting an order nothing consults — order those with sortBy/sortOrder instead. There is no `pyramid` widget type — write `funnel`. | --- diff --git a/packages/spec/api-surface/ui.json b/packages/spec/api-surface/ui.json index 0507096117..f3c8ac4ff7 100644 --- a/packages/spec/api-surface/ui.json +++ b/packages/spec/api-surface/ui.json @@ -434,6 +434,7 @@ "chartAggregateCategoryKey (function)", "chartAggregateResultKeys (function)", "chartAggregateValueKey (function)", + "checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization (function)", "checkListViewPageMount (function)", diff --git a/packages/spec/export-origins/ui.json b/packages/spec/export-origins/ui.json index 17b87ed2e5..e8c53b8809 100644 --- a/packages/spec/export-origins/ui.json +++ b/packages/spec/export-origins/ui.json @@ -420,6 +420,7 @@ "chartAggregateCategoryKey": "src/ui/chart-aggregate.ts#chartAggregateCategoryKey (function)", "chartAggregateResultKeys": "src/ui/chart-aggregate.ts#chartAggregateResultKeys (function)", "chartAggregateValueKey": "src/ui/chart-aggregate.ts#chartAggregateValueKey (function)", + "checkDashboardWidgetStageOrder": "src/ui/dashboard.zod.ts#checkDashboardWidgetStageOrder (function)", "checkGlobalFilterDateDefaultValue": "src/ui/dashboard.zod.ts#checkGlobalFilterDateDefaultValue (function)", "checkListViewCalendarVisualization": "src/ui/view.zod.ts#checkListViewCalendarVisualization (function)", "checkListViewPageMount": "src/ui/view.zod.ts#checkListViewPageMount (function)", diff --git a/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts new file mode 100644 index 0000000000..eb6ed89a24 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.dashboard-widget-stage-order-non-funnel-refused.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'dashboard-widget-stage-order-non-funnel-refused', + surface: 'dashboard widget stage order — `dashboard.widgets[].options.stageOrder` ' + + '(`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything ' + + 'other than `funnel`, INCLUDING a widget that declares no `type` at all and so ' + + 'resolves to the `metric` default', + replacement: 'either `type: \'funnel\'` on the widget that meant to declare a stage ' + + 'order, or — for every other widget type — DELETE `stageOrder` and order the widget ' + + 'with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as ' + + '`order: { : \'asc\' | \'desc\' }` instead of re-sorting what it returned. ' + + 'There is no third spelling: no other widget type has ever read the key, so nothing ' + + 'is lost by removing it that was not already absent from what rendered. The refusal ' + + 'lands at `options.stageOrder` and names the type the widget carries, the one type ' + + 'that reads the key, and the two keys to reach for instead.', + reason: + '#17344 finding 1, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose ' + + 'whole content was SILENCE. `options` is the open renderer-extras bag, so ' + + '`stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, ' + + '`table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and ' + + 'forwarded the array to the renderer, which never consulted it. Measured at this ' + + 'repo\'s `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded ' + + '`categoryOrder` prop has exactly one read in the charts plugin ' + + '(`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits ' + + 'inside the `chartType === \'funnel\'` guard opened at line 1473; the prop\'s other ' + + 'two occurrences in that file are its declaration and its destructure. The producer ' + + 'side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for ' + + 'ANY widget and forwards it whenever non-empty. So the authored order was accepted ' + + 'by the metadata layer, carried all the way to the chart, and dropped there, with ' + + 'nothing anywhere to say so: the widget rendered in whatever order the analytics ' + + 'query returned and looked deliberate. The reporter measured exactly that in a live ' + + 'app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered ' + + 'alphabetically by display label. The four SIBLING members of the same bag are not ' + + 'in this narrowing and were measured not to share the defect: `dateGranularity`, ' + + '`sortBy`, `sortOrder` and `limit` are read unconditionally at the top of ' + + '`DatasetWidget` (lines 443-455, outside every type branch) and lower into the ' + + '`DatasetSelection` the server compiles, so they act on every widget type. ' + + '`stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT ' + + 'the other arm of the card ("or ordered marks honour it"): teaching `bar` / `line` / ' + + '`area` to sort by a category order is a renderer change in the objectui repo, and ' + + 'widening the set of types that read the key can be done later WITHOUT a second ' + + 'migration — a narrowing that is later relaxed costs an author nothing, while ' + + 'leaving the key accepted-and-inert costs them a chart that silently lies. Ships at ' + + 'once, no deprecation window: there is no window in which an inert key does ' + + 'anything.', + acceptanceCriteria: + '⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored ' + + 'dashboard whose widgets carry `options.stageOrder` on a non-`funnel` type is refused ' + + 'the next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the ' + + 'metadata publish path, and any server-side door that parses the spec schema — with one ' + + '`custom` issue at `widgets[N].options.stageOrder` naming the authored type. It is NOT ' + + 'refused by objectui\'s client-side authoring door: `@object-ui/types` builds its own ' + + '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, ' + + '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every ' + + 'object-level check (measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts the ' + + 'widget and reports zero checks, while `.extend({})` keeps the refusal). At the ' + + '`.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` that package re-attaches NONE ' + + 'of the spec\'s exported checks, so until it imports and chains ' + + '`checkDashboardWidgetStageOrder` the dashboard EDITOR still accepts the key on a `bar` ' + + 'and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a ' + + 'clean dashboard; re-parse through the spec. Fix each by writing ' + + '`type: \'funnel\'` where a funnel was meant, and by deleting the key elsewhere — ' + + 'check the rendered order afterwards, because a widget that was silently ignoring ' + + 'the key renders EXACTLY as it did before once the key is gone, and `sortBy` / ' + + '`sortOrder` is what changes it. A `funnel` widget carrying `stageOrder` parses ' + + 'byte-identically to before, a non-`funnel` widget carrying the other four ' + + '`options` members is untouched, and a widget with no `options` at all is ' + + 'untouched. ⚠️ Three more shapes this does NOT reach, so do not read it as complete ' + + '(the objectui door above is the first): a ' + + 'widget whose `type` is outside `ChartTypeSchema` reports the TYPE refusal alone ' + + '(zod treats that as aborting and skips object-level checks), so the stage-order ' + + 'refusal arrives only on the next parse; and the array\'s CONTENTS are still ' + + 'unconstrained, so a `funnel` carrying a stage value the dimension never declares ' + + 'still parses and still renders that stage in the sentinel position; and a consumer ' + + 'that derives this schema with `.omit()` / `.pick()` / `.partial()` now gets a THROW ' + + 'from zod rather than a schema, because zod 4 refuses all three on an object carrying ' + + 'a refinement — latent rather than live (no consumer in either repo derives the widget ' + + 'schema that way today), and `.extend()` is unaffected. Repo census at ' + + 'the time of the change: zero authored widgets carry the key anywhere in the ' + + 'monorepo — 59 occurrences outside changelogs, all of them schema, tests, generated ' + + 'reference pages, the sdui-parser census and the gate that derives it.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 4727e14966..0fbb525fb3 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6461,6 +6461,87 @@ const step18: MigrationStep = { + '`.` target instead. Clicking each converted button opens the intended ' + 'page or form rather than a refusal dialog.', }, + { + id: 'dashboard-widget-stage-order-non-funnel-refused', + surface: 'dashboard widget stage order — `dashboard.widgets[].options.stageOrder` ' + + '(`DashboardWidgetOptionsSchema.stageOrder`) on a widget whose `type` is anything ' + + 'other than `funnel`, INCLUDING a widget that declares no `type` at all and so ' + + 'resolves to the `metric` default', + replacement: 'either `type: \'funnel\'` on the widget that meant to declare a stage ' + + 'order, or — for every other widget type — DELETE `stageOrder` and order the widget ' + + 'with `options.sortBy` + `options.sortOrder`, which lower into the dataset query as ' + + '`order: { : \'asc\' | \'desc\' }` instead of re-sorting what it returned. ' + + 'There is no third spelling: no other widget type has ever read the key, so nothing ' + + 'is lost by removing it that was not already absent from what rendered. The refusal ' + + 'lands at `options.stageOrder` and names the type the widget carries, the one type ' + + 'that reads the key, and the two keys to reach for instead.', + reason: + '#17344 finding 1, ADR-0049 enforce-or-remove, and the enforce arm of a defect whose ' + + 'whole content was SILENCE. `options` is the open renderer-extras bag, so ' + + '`stageOrder` was an ungated member of it: a `horizontal-bar` (or `line`, `pie`, ' + + '`table`, `metric`) widget carrying an authored lifecycle order PARSED, booted, and ' + + 'forwarded the array to the renderer, which never consulted it. Measured at this ' + + 'repo\'s `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`: the forwarded ' + + '`categoryOrder` prop has exactly one read in the charts plugin ' + + '(`buildCategoryRank(categoryOrder)`, `AdvancedChartImpl.tsx:1514`) and it sits ' + + 'inside the `chartType === \'funnel\'` guard opened at line 1473; the prop\'s other ' + + 'two occurrences in that file are its declaration and its destructure. The producer ' + + 'side has no gate either — `DatasetWidget.tsx:1468` builds the explicit order for ' + + 'ANY widget and forwards it whenever non-empty. So the authored order was accepted ' + + 'by the metadata layer, carried all the way to the chart, and dropped there, with ' + + 'nothing anywhere to say so: the widget rendered in whatever order the analytics ' + + 'query returned and looked deliberate. The reporter measured exactly that in a live ' + + 'app — a `horizontal-bar` carrying a seven-stage contract lifecycle rendered ' + + 'alphabetically by display label. The four SIBLING members of the same bag are not ' + + 'in this narrowing and were measured not to share the defect: `dateGranularity`, ' + + '`sortBy`, `sortOrder` and `limit` are read unconditionally at the top of ' + + '`DatasetWidget` (lines 443-455, outside every type branch) and lower into the ' + + '`DatasetSelection` the server compiles, so they act on every widget type. ' + + '`stageOrder` was the only member whose effect was confined to one branch. ⛔ NOT ' + + 'the other arm of the card ("or ordered marks honour it"): teaching `bar` / `line` / ' + + '`area` to sort by a category order is a renderer change in the objectui repo, and ' + + 'widening the set of types that read the key can be done later WITHOUT a second ' + + 'migration — a narrowing that is later relaxed costs an author nothing, while ' + + 'leaving the key accepted-and-inert costs them a chart that silently lies. Ships at ' + + 'once, no deprecation window: there is no window in which an inert key does ' + + 'anything.', + acceptanceCriteria: + '⚠️ WHICH DOOR: this refusal is the PUBLISH door\'s, not the editor\'s. Every stored ' + + 'dashboard whose widgets carry `options.stageOrder` on a non-`funnel` type is refused ' + + 'the next time it is parsed THROUGH `@objectstack/spec` — `os build` / `os lint`, the ' + + 'metadata publish path, and any server-side door that parses the spec schema — with one ' + + '`custom` issue at `widgets[N].options.stageOrder` naming the authored type. It is NOT ' + + 'refused by objectui\'s client-side authoring door: `@object-ui/types` builds its own ' + + '`DashboardWidgetSchema` from `specFieldsExcept(SpecDashboardWidgetSchema.shape, ' + + '…).extend({…}).strict()`, and a `.shape` spread carries the FIELDS while dropping every ' + + 'object-level check (measured: `z.strictObject(DashboardWidgetSchema.shape)` accepts the ' + + 'widget and reports zero checks, while `.extend({})` keeps the refusal). At the ' + + '`.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694` that package re-attaches NONE ' + + 'of the spec\'s exported checks, so until it imports and chains ' + + '`checkDashboardWidgetStageOrder` the dashboard EDITOR still accepts the key on a `bar` ' + + 'and the author meets the refusal later, at publish. ⇒ Do not read a green editor as a ' + + 'clean dashboard; re-parse through the spec. Fix each by writing ' + + '`type: \'funnel\'` where a funnel was meant, and by deleting the key elsewhere — ' + + 'check the rendered order afterwards, because a widget that was silently ignoring ' + + 'the key renders EXACTLY as it did before once the key is gone, and `sortBy` / ' + + '`sortOrder` is what changes it. A `funnel` widget carrying `stageOrder` parses ' + + 'byte-identically to before, a non-`funnel` widget carrying the other four ' + + '`options` members is untouched, and a widget with no `options` at all is ' + + 'untouched. ⚠️ Three more shapes this does NOT reach, so do not read it as complete ' + + '(the objectui door above is the first): a ' + + 'widget whose `type` is outside `ChartTypeSchema` reports the TYPE refusal alone ' + + '(zod treats that as aborting and skips object-level checks), so the stage-order ' + + 'refusal arrives only on the next parse; and the array\'s CONTENTS are still ' + + 'unconstrained, so a `funnel` carrying a stage value the dimension never declares ' + + 'still parses and still renders that stage in the sentinel position; and a consumer ' + + 'that derives this schema with `.omit()` / `.pick()` / `.partial()` now gets a THROW ' + + 'from zod rather than a schema, because zod 4 refuses all three on an object carrying ' + + 'a refinement — latent rather than live (no consumer in either repo derives the widget ' + + 'schema that way today), and `.extend()` is unaffected. Repo census at ' + + 'the time of the change: zero authored widgets carry the key anywhere in the ' + + 'monorepo — 59 occurrences outside changelogs, all of them schema, tests, generated ' + + 'reference pages, the sdui-parser census and the gate that derives it.', + }, { id: 'data-nosql-query-options-timeout-unit-in-key', surface: 'NoSQLQueryOptions.timeout, the per-query driver deadline whose name carried no ' diff --git a/packages/spec/src/ui/dashboard.test.ts b/packages/spec/src/ui/dashboard.test.ts index 71619cd54a..a3bf9405b6 100644 --- a/packages/spec/src/ui/dashboard.test.ts +++ b/packages/spec/src/ui/dashboard.test.ts @@ -15,7 +15,10 @@ import { DATE_RANGE_PRESETS, DATE_RANGE_DEFAULT_RANGES, DashboardWidgetOptionsSchema, + checkDashboardWidgetStageOrder, } from './dashboard.zod'; +import * as ui from './index'; +import { readFileSync } from 'node:fs'; import { ChartTypeSchema } from './chart.zod'; import { dashboardForm } from './dashboard.form'; @@ -821,6 +824,23 @@ describe('#16458 — DashboardHeaderAction fields carry an item-level `title`', * `.objectui-sha` pin and reported on the issue, not something `packages/spec` * can assert. */ +/** A minimal dataset-bound widget — everything `stageOrder` is not. */ +const WIDGET_BASE = { + id: 'stage_widget', + dataset: 'contracts', + dimensions: ['status'], + values: ['count'], + layout: { x: 0, y: 0, w: 6, h: 4 }, +} as const; + +/** The card's own fixture: an ordered mark that does NOT read the key. */ +const nonFunnelWithStageOrder = { + ...WIDGET_BASE, + id: 'stage_bars', + type: 'horizontal-bar', + options: { stageOrder: ['draft', 'submitted', 'approved'] }, +}; + describe('DashboardWidgetOptions.stageOrder — the shipped doc string', () => { const description = () => { const d = (DashboardWidgetOptionsSchema as unknown as { @@ -864,15 +884,160 @@ describe('DashboardWidgetOptions.stageOrder — the shipped doc string', () => { expect(w.options?.stageOrder).toEqual(['draft', 'submitted', 'approved']); }); - it('CONTROL — the key is still UNGATED: a non-funnel widget carrying it parses too', () => { - // This is finding 1 of the card, recorded as a fact rather than fixed: - // gating the key is a published-surface narrowing and is not this PR. + it('the key is GATED: a non-funnel widget carrying it is refused', () => { + // The behaviour this pin replaces: until the ADR-0049 gate landed, this + // very fixture PARSED and round-tripped the array, which is finding 1 of + // the card — accepted, forwarded, and consulted by no renderer branch. + const r = DashboardWidgetSchema.safeParse(nonFunnelWithStageOrder); + expect(r.success).toBe(false); + }); +}); + + +/** + * `options.stageOrder` is gated to the one widget `type` that reads it + * (ADR-0049 enforce-or-remove). + * + * The key is declared inside `DashboardWidgetOptionsSchema` — an OPEN bag — + * while the `type` that decides whether it means anything is that object's + * sibling one level up on `DashboardWidgetSchema`. So the rule cannot be a + * per-field refinement on `stageOrder`, and these pin it where it has to live: + * an object-level check on the widget, refusing at the key's own path. + * + * Every leg here is BEHAVIOURAL — `safeParse` on an authored widget — never a + * reading of the schema's source or of its `.describe()` prose. The doc-string + * pins above are a separate claim about a separate surface; a gate proven by + * reading the sentence that documents it proves nothing. + * + * The refusal message carries three things because the defect was SILENCE, and + * a bare "unrecognized key" would answer silence with a shrug: the key, the + * type this widget carries, and the one type that honours it — plus where the + * other types' ordering actually lives. + */ +describe('DashboardWidgetOptions.stageOrder — the ADR-0049 type gate', () => { + const refusal = (widget: unknown) => { + const r = DashboardWidgetSchema.safeParse(widget); + expect(r.success).toBe(false); + const issues = r.success ? [] : r.error.issues; + const custom = issues.filter((i) => i.code === 'custom'); + expect(custom).toHaveLength(1); + return custom[0]; + }; + + it('refuses at the key\'s own path, not at the widget or the options bag', () => { + expect(refusal(nonFunnelWithStageOrder).path.join('.')).toBe('options.stageOrder'); + }); + + it('names the key, the type authored, and the one type that reads it', () => { + const message = refusal(nonFunnelWithStageOrder).message; + expect(message).toContain('`options.stageOrder`'); + // the type the author actually wrote, verbatim — not a generic "this type" + expect(message).toContain("`type: 'horizontal-bar'`"); + expect(message).toContain("`type: 'funnel'`"); + // and where ordering lives for every other type + expect(message).toContain('sortBy'); + expect(message).toContain('sortOrder'); + }); + + it('names whichever type was authored — the message is not a fixed string', () => { + // Two different authored types produce two different messages, so the + // assertion above cannot be satisfied by a message that hard-codes one. + const pie = refusal({ ...WIDGET_BASE, type: 'pie', options: { stageOrder: ['a', 'b'] } }).message; + expect(pie).toContain("`type: 'pie'`"); + expect(pie).not.toContain("`type: 'horizontal-bar'`"); + }); + + it('CONTROL — a `funnel` widget carrying `stageOrder` still parses, value intact', () => { const w = DashboardWidgetSchema.parse({ - id: 'stage_bars', type: 'horizontal-bar', dataset: 'contracts', - dimensions: ['status'], values: ['count'], - layout: { x: 0, y: 0, w: 6, h: 4 }, + ...WIDGET_BASE, type: 'funnel', options: { stageOrder: ['draft', 'submitted', 'approved'] }, }); expect(w.options?.stageOrder).toEqual(['draft', 'submitted', 'approved']); }); + + it('CONTROL — a `horizontal-bar` widget WITHOUT `stageOrder` still parses', () => { + // The accept set moved for exactly one shape. A non-funnel widget carrying + // the other `options` members — which every widget type genuinely reads, + // because they lower into the dataset query rather than into a chart + // branch — is untouched. + const w = DashboardWidgetSchema.parse({ + ...WIDGET_BASE, type: 'horizontal-bar', + options: { sortBy: 'count', sortOrder: 'desc', limit: 10 }, + }); + expect(w.options?.sortBy).toBe('count'); + expect('stageOrder' in (w.options ?? {})).toBe(false); + }); + + it('CONTROL — a `horizontal-bar` widget with no `options` at all still parses', () => { + expect(DashboardWidgetSchema.safeParse({ ...WIDGET_BASE, type: 'horizontal-bar' }).success).toBe(true); + }); + + it('a widget that declares NO type is refused, and the message says the type is missing', () => { + // `type` carries `.default('metric')` and zod applies defaults BEFORE + // object-level checks, so an omitted `type` is indistinguishable here from + // an authored `metric`. The verdict is right either way — `metric` reads + // the key no more than `horizontal-bar` does — and the message carries the + // extra sentence for exactly this case. + const message = refusal({ ...WIDGET_BASE, options: { stageOrder: ['a', 'b'] } }).message; + expect(message).toContain("`type: 'metric'`"); + expect(message).toContain('declares no `type` at all'); + }); + + it('an authored `metric` gets the same message — the gate cannot tell them apart', () => { + const message = refusal({ ...WIDGET_BASE, type: 'metric', options: { stageOrder: ['a', 'b'] } }).message; + expect(message).toContain('declares no `type` at all'); + }); + + it('a `type` outside the enum reports the TYPE refusal alone, not both', () => { + // Measured, and pinned so a later zod upgrade cannot change it silently: + // `ChartTypeSchema`'s `invalid_value` aborts, so object-level checks are + // skipped for that input. The author fixes the type first and meets the + // stage-order refusal on the next parse — the two are never seen together. + const r = DashboardWidgetSchema.safeParse({ + ...WIDGET_BASE, type: 'ziggurat', options: { stageOrder: ['a', 'b'] }, + }); + expect(r.success).toBe(false); + const issues = r.success ? [] : r.error.issues; + expect(issues.map((i) => i.code)).toEqual(['invalid_value']); + expect(issues[0]?.path.join('.')).toBe('type'); + }); + + it('the gate does NOT reach the array\'s contents — a misspelled stage still parses', () => { + // Stated as a pin rather than left implied: whether a stored value exists + // is a fact about the dataset's dimension, not about the widget, and is + // not reachable from this schema. A funnel with a stage nobody declared + // renders that stage in the sentinel position and nothing here refuses it. + expect(DashboardWidgetSchema.safeParse({ + ...WIDGET_BASE, type: 'funnel', options: { stageOrder: ['drafft', 42, true] }, + }).success).toBe(true); + }); + + it('the rule the door runs is the EXPORT, attached by identifier — no inline copy', () => { + // The reason this check is exported at all (the lesson of the `.shape` + // mirrors): a consumer that spreads `DashboardWidgetSchema.shape` gets the + // FIELDS and drops every object-level check, so it needs the rule as a + // function it can re-attach. That is only true if the door runs the + // exported function rather than a copy that can drift away from it. + const src = readFileSync(new URL('./dashboard.zod.ts', import.meta.url), 'utf8'); + expect(src).toContain('export function checkDashboardWidgetStageOrder('); + // exactly one declaration, so the count below keys on an unambiguous name + expect(src.match(/^\s*(export )?function checkDashboardWidgetStageOrder\b/gm)).toHaveLength(1); + // …and exactly one attachment, on its own line + expect(src.match(/^[ \t]*\.superRefine\(checkDashboardWidgetStageOrder\)/gm)).toHaveLength(1); + }); + + it('`@objectstack/spec/ui` ships the same function object', () => { + expect((ui as Record).checkDashboardWidgetStageOrder) + .toBe(checkDashboardWidgetStageOrder); + expect(checkDashboardWidgetStageOrder.length).toBe(2); + }); + + it('the gate travels with the widget through `DashboardSchema.widgets[]`', () => { + const r = DashboardSchema.safeParse({ + name: 'legal_dashboard', label: 'Legal', widgets: [nonFunnelWithStageOrder], + }); + expect(r.success).toBe(false); + const paths = (r.success ? [] : r.error.issues).map((i) => i.path.join('.')); + expect(paths).toContain('widgets.0.options.stageOrder'); + }); }); diff --git a/packages/spec/src/ui/dashboard.zod.ts b/packages/spec/src/ui/dashboard.zod.ts index d7ba6af241..008e7a659b 100644 --- a/packages/spec/src/ui/dashboard.zod.ts +++ b/packages/spec/src/ui/dashboard.zod.ts @@ -261,13 +261,15 @@ export const DashboardWidgetOptionsSchema = lazySchema(() => z.object({ * to the dimension field's own picklist option order, which is the pipeline * order an author already declared on the object. * - * `funnel` is the ONLY widget `type` that reads this key. On every other - * type — `bar` / `horizontal-bar` / `column`, `line`, `area`, `pie`, - * `donut`, `treemap`, `sankey`, `radar`, `scatter`, `combo`, the tabular and - * single-value families — the key parses, is forwarded to the renderer, and - * no branch consults it: the rendered order stays whatever the analytics - * query returned. Order those with `sortBy` / `sortOrder`, which lower into - * the dataset query itself. + * `funnel` is the ONLY widget `type` that reads this key, and since the + * ADR-0049 gate the schema enforces that rather than documenting it: on + * every other type — `bar` / `horizontal-bar` / `column`, `line`, `area`, + * `pie`, `donut`, `treemap`, `sankey`, `radar`, `scatter`, `combo`, the + * tabular and single-value families — the key is REFUSED at parse + * ({@link checkDashboardWidgetStageOrder}), because it used to be accepted, + * forwarded to the renderer, consulted by no branch, and the authored order + * simply absent from what rendered. Order those types with `sortBy` / + * `sortOrder`, which lower into the dataset query itself. * * There is no `pyramid` widget type. It was removed from `ChartTypeSchema` * as a variant that only ever rendered as `funnel` (see the taxonomy NOTE at @@ -277,8 +279,9 @@ export const DashboardWidgetOptionsSchema = lazySchema(() => z.object({ stageOrder: z.array(z.union([z.string(), z.number(), z.boolean()])).optional() .describe( 'Explicit stage order for a funnel widget, as the dimension\'s stored values. ' - + '`funnel` is the only widget type that reads it: on any other type the key ' - + 'parses and is never consulted, so order those with sortBy/sortOrder instead. ' + + '`funnel` is the only widget type that reads it, and the schema refuses it on ' + + 'any other type rather than accepting an order nothing consults — order those ' + + 'with sortBy/sortOrder instead. ' + 'There is no `pyramid` widget type — write `funnel`.', ), }).passthrough().describe('Widget configuration — declared query keys + open renderer extras')); @@ -350,6 +353,171 @@ const WIDGET_ACTION_RETIRED = (key: 'actionUrl' | 'actionType' | 'actionIcon') = + 'bound to a dataset: its rows are clickable and drill through the semantic layer. ' + 'Run `os migrate meta --from 16` to list the mechanical edits for existing sources; apply them by hand.'; +/** + * The one widget `type` that reads `options.stageOrder`. + * + * Not a list, and it is the point of {@link checkDashboardWidgetStageOrder} + * that it is not: the renderer consults the forwarded order inside a single + * `chartType === 'funnel'` guard, so every other member of `ChartTypeSchema` + * accepts the key and never looks at it. + */ +const STAGE_ORDER_HONOURING_TYPE = 'funnel'; + +/** + * What `DashboardWidgetSchema.type` resolves to when a widget declares none — + * held beside the check that has to talk about it, so the two cannot drift. + */ +const WIDGET_TYPE_DEFAULT = 'metric'; + +/** + * ADR-0049 enforce-or-remove on `dashboard.widgets[].options.stageOrder` — the + * key is gated to the one widget `type` whose renderer branch reads it. + * + * ## Why this is an OBJECT-level check and cannot be a field-level one + * + * `stageOrder` is declared inside {@link DashboardWidgetOptionsSchema}, and the + * `type` that decides whether it means anything is that object's SIBLING one + * level up on {@link DashboardWidgetSchema}. A refinement attached to + * `stageOrder` sees the array and nothing else, so the rule has to run where + * both keys are in scope. This file already has exactly that idiom — a named, + * exported `(value, ctx)` check chained on with `.superRefine(…)`, the way + * {@link checkGlobalFilterDateDefaultValue} is attached to + * {@link GlobalFilterSchema} — and this follows it rather than inventing a + * second shape. + * + * ⚠️ What the export BUYS, stated precisely, because the looser version of this + * sentence is false. Attaching by identifier does NOT make the rule survive a + * `.shape` mirror — it only makes re-attachment POSSIBLE. Probed on this + * schema: `z.strictObject(DashboardWidgetSchema.shape)` ACCEPTS a + * `horizontal-bar` widget carrying `stageOrder` and reports zero object-level + * checks, while `.extend({})` keeps the refusal; a lit control (`type: + * 'ziggurat'`) is refused by BOTH, so the mirror does carry the fields and it + * is precisely the object-level check that is dropped. A mirror gets this rule + * only by importing the export and chaining it — see non-coverage 4 below. + * + * ## What was wrong + * + * `options` is an open bag and `stageOrder` was an ungated member of it, so a + * `horizontal-bar` (or `line`, or `pie`, or `table`) widget carrying an + * authored lifecycle order parsed, booted, forwarded the array to the renderer + * — and rendered in whatever order the analytics query happened to return. + * Nothing warned and nothing refused; the authored order was simply not there. + * That is the silent shape ADR-0049 exists to end, and a doc sentence saying so + * is not enforcement: the only thing standing between an author and a key that + * does nothing was prose they had to read first. + * + * ## What the refusal says, and why it says that much + * + * A bare "unrecognized key" would be a poor repair for a defect whose whole + * content was silence, so the message names all three things the author needs: + * the key, the widget `type` this widget carries, and the one `type` that + * honours it — plus the keys that DO order every other type (`sortBy` / + * `sortOrder`, which lower into the dataset query itself rather than being + * re-sorted after the fact). + * + * ## What this check deliberately does NOT reach + * + * Five shapes, named so the gate is not read as complete: + * + * 1. **A widget that declares no `type`.** `type` carries + * `.default(WIDGET_TYPE_DEFAULT)` and zod applies defaults BEFORE + * object-level checks, so an omitted `type` arrives here as `metric` and + * is indistinguishable from one an author wrote. The verdict is right + * either way — `metric` does not read the key — but the message cannot + * claim the author wrote it, so that one case carries an extra sentence + * instead. + * 2. **A widget whose `type` is not a declared member at all.** Measured: + * zod treats `ChartTypeSchema`'s `invalid_value` as aborting and skips + * every object-level check for that input, so `type: 'ziggurat'` plus a + * `stageOrder` reports the type refusal alone. That is the useful order — + * fix the type, re-parse, then learn about the key — but it does mean the + * two refusals are never seen together. + * 3. **The array's CONTENTS.** Still unconstrained `string | number | + * boolean` members, unmatched against the dimension's picklist values. A + * `funnel` carrying a misspelled stage still parses and still renders that + * stage in the sentinel position; whether a stored value exists is a fact + * about the dataset, not about the widget, and is not reachable from this + * schema. + * 4. **objectui's CLIENT-SIDE authoring door, which is a `.shape` mirror and + * therefore runs no object-level check of this schema's at all.** At the + * `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`, + * `packages/types/src/zod/complex.zod.ts:627` builds its own + * `DashboardWidgetSchema` from + * `specFieldsExcept(SpecDashboardWidgetSchema.shape, …).extend({…}).strict()`, + * and that package re-attaches NONE of this spec's exported checks — 0 + * occurrences of any of the five names in `packages/types/src`, against a + * lit control of 17 `specFieldsExcept` call sites and the mirror line + * itself present. So until objectui imports and chains + * {@link checkDashboardWidgetStageOrder}, its door keeps accepting + * `stageOrder` on a `bar`: this refusal is the PUBLISH door's, not the + * editor's. ⚠️ Note what that mirror does to `type` — it drops the key + * from the spread and redeclares it `DashboardWidgetTypeSchema.optional()` + * with NO default, so a typeless widget reaches a re-attached check as + * `undefined` rather than as `metric`. This function defaults it itself + * for exactly that caller, so re-attaching IS sufficient; the message's + * "resolves to `metric`" sentence is this schema's vocabulary, and a + * mirror that wants its own wording writes its own. + * 5. **Consumers that DERIVE from this schema with `.omit()` / `.pick()` / + * `.partial()`.** zod 4 throws on all three once an object carries a + * refinement (`.omit() cannot be used on object schemas containing + * refinements`), so this change converts those three from working to + * throwing. Latent rather than live: no consumer in either repo derives + * the widget schema that way today. `.extend()` is unaffected and keeps + * the refusal, which is the spelling the mirrors actually use. + */ +export function checkDashboardWidgetStageOrder( + widget: { type?: unknown; options?: { stageOrder?: unknown } | null }, + ctx: z.RefinementCtx, +): void { + const stageOrder = widget.options?.stageOrder; + if (stageOrder === undefined) return; + + // `?? WIDGET_TYPE_DEFAULT` is UNREACHABLE through this schema's own door — + // zod applies `type`'s default before object-level checks, so `widget.type` + // is always a string by the time this runs, and the accept set is unmoved. + // It is here for the OTHER caller this function has: a mirror that imports + // the export and chains it onto a shape whose `type` carries no default (see + // non-coverage 4). Without it the export would refuse strictly less than the + // door it is exported FROM, which is the one thing an exported check may not + // do — `object-refinement-check-exports.test.ts` compares the two on the RAW + // fixture and pins the equivalence. + const type = widget.type ?? WIDGET_TYPE_DEFAULT; + if (type === STAGE_ORDER_HONOURING_TYPE) return; + // A `type` that is not a declared member never reaches here — measured: zod + // treats `ChartTypeSchema`'s `invalid_value` as aborting, so the object-level + // checks are skipped for that input and the author reads ONE refusal, about + // the key they must fix. This guard covers the remaining non-string shapes + // and keeps the interpolation below honest rather than printing `[object + // Object]` at an author. + if (typeof type !== 'string') return; + + // `type` carries `.default('metric')` and zod applies defaults before + // object-level checks, so a widget that declared NO type arrives here as + // `metric` and cannot be told apart from one that wrote `metric`. The + // refusal is right either way — `metric` does not read the key — and the + // extra sentence is added only in that one ambiguous case rather than on + // every message. + const defaultedTypeNote = type === WIDGET_TYPE_DEFAULT + ? ' (`' + WIDGET_TYPE_DEFAULT + '` is also what a widget that declares no `type` at all ' + + 'resolves to — if you meant a funnel, the `type` key is missing rather than wrong.)' + : ''; + + ctx.addIssue({ + code: 'custom', + path: ['options', 'stageOrder'], + message: + '`options.stageOrder` is authored on a widget of `type: ' + + `'${type}'` + + "`, and `type: '" + STAGE_ORDER_HONOURING_TYPE + "'` is the only widget type that " + + 'reads it — on every other type the key parses, is forwarded to the renderer, and no ' + + 'branch consults it, so the order you wrote is silently absent from what renders. ' + + "Either write `type: '" + STAGE_ORDER_HONOURING_TYPE + "'`, or delete `stageOrder` " + + 'and order this widget with `options.sortBy` + `options.sortOrder`, which lower into ' + + 'the dataset query itself instead of re-sorting what it returned.' + + defaultedTypeNote, + }); +} + /** * Dashboard Widget Schema * A single component on the dashboard grid. @@ -376,7 +544,7 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ description: I18nLabelSchema.optional().describe('Widget description text below the header'), /** Visualization Type */ - type: ChartTypeSchema.default('metric').describe('Visualization type'), + type: ChartTypeSchema.default(WIDGET_TYPE_DEFAULT).describe('Visualization type'), /** Chart Configuration */ chartConfig: ChartConfigSchema.optional().describe('Chart visualization configuration'), @@ -676,8 +844,15 @@ export const DashboardWidgetSchema = lazySchema(() => strictObject({ // rejects undeclared top-level keys instead of silently stripping them. A // hallucinated or legacy key is a deterministic author-time error (CI) rather // than a silent no-op a human reviewer would miss. `options` stays the - // free-form escape hatch for renderer-specific extras. -})); + // free-form escape hatch for renderer-specific extras — which is exactly why + // the one member of it that only ONE widget type reads needs the check below: + // an open bag cannot refuse a key by being strict, so the key is gated + // against its sibling `type` instead. +}) + // ADR-0049 enforce-or-remove on `options.stageOrder`. Attached by identifier + // rather than inlined, the way `GlobalFilterSchema` attaches its own check: + // the exported function IS the rule this door runs. + .superRefine(checkDashboardWidgetStageOrder)); /** * Dashboard date-range presets — the named windows a dashboard date filter may diff --git a/packages/spec/src/ui/object-refinement-check-exports.test.ts b/packages/spec/src/ui/object-refinement-check-exports.test.ts index a9b6e3b151..a1a3632205 100644 --- a/packages/spec/src/ui/object-refinement-check-exports.test.ts +++ b/packages/spec/src/ui/object-refinement-check-exports.test.ts @@ -57,7 +57,12 @@ import { checkListViewCalendarVisualization, } from './view.zod'; import { PageSchema, checkPageSourceCompleteness } from './page.zod'; -import { GlobalFilterSchema, checkGlobalFilterDateDefaultValue } from './dashboard.zod'; +import { + GlobalFilterSchema, + checkGlobalFilterDateDefaultValue, + DashboardWidgetSchema, + checkDashboardWidgetStageOrder, +} from './dashboard.zod'; import * as ui from './index'; const HERE = path.dirname(fileURLToPath(import.meta.url)); @@ -244,6 +249,44 @@ const dateDefaultFixtures: Fixture[] = [ { label: 'a non-date filter with the bad spelling', value: { field: 'period', type: 'select', defaultValue: 'last_7_dayz' }, refusesAt: [] }, ]; +/** + * The widget base every stage-order fixture builds on — shape-valid on purpose + * (`id` two characters or more, a `dataset`, at least one `values` member), for + * the reason the file header gives: zod 4 skips object-level checks when the + * shape itself failed, so a shape-invalid fixture would make "refused" true for + * the wrong reason. + */ +const WIDGET = { id: 'stage_widget', dataset: 'contracts', dimensions: ['status'], values: ['count'] } as const; + +const stageOrderFixtures: Fixture[] = [ + { + label: '`stageOrder` on a widget type that does not read it', + value: { ...WIDGET, type: 'horizontal-bar', options: { stageOrder: ['draft', 'approved'] } }, + refusesAt: ['options.stageOrder'], + }, + { + label: '`stageOrder` on a second non-funnel type — the message interpolates, the check does not', + value: { ...WIDGET, type: 'pie', options: { stageOrder: ['draft'] } }, + refusesAt: ['options.stageOrder'], + }, + { + label: '`stageOrder` on a widget that declares NO type (the `metric` default)', + value: { ...WIDGET, options: { stageOrder: ['draft'] } }, + refusesAt: ['options.stageOrder'], + }, + { + label: '`stageOrder` on the one type that reads it', + value: { ...WIDGET, type: 'funnel', options: { stageOrder: ['draft', 'approved'] } }, + refusesAt: [], + }, + { + label: 'a non-funnel carrying the SIBLING options, which every type reads', + value: { ...WIDGET, type: 'horizontal-bar', options: { sortBy: 'count', sortOrder: 'desc', limit: 10 } }, + refusesAt: [], + }, + { label: 'a non-funnel with no `options` at all', value: { ...WIDGET, type: 'horizontal-bar' }, refusesAt: [] }, +]; + // --------------------------------------------------------------------------- // The population — every mirrored spec object that carries an object-level check // --------------------------------------------------------------------------- @@ -271,6 +314,23 @@ const MIRRORED: MirroredSchema[] = [ exports: [{ name: 'checkGlobalFilterDateDefaultValue', check: checkGlobalFilterDateDefaultValue, fixtures: dateDefaultFixtures }], cleanFixtures: [{ field: 'created_at', type: 'date' }], }, + // `dashboard.widgets[]` is mirrored the same way and for the same reason, so + // the ADR-0049 `stageOrder` type gate belongs in this catalogue: measured at + // the `.objectui-sha` pin `53ded82bf7a494f54e344e19099dbf00854b8694`, + // objectui's `packages/types/src/zod/complex.zod.ts` builds its own + // `DashboardWidgetSchema` from + // `specFieldsExcept(SpecDashboardWidgetSchema.shape, …)` — a `.shape` spread, + // which carries the FIELDS and drops every object-level check. That mirror + // re-attaches none of these exports today, which is a live gap recorded on + // the check's own docblock rather than a reason to leave the export + // uncatalogued: an export nothing pins here can drift away from the rule the + // door runs, and then a mirror that DOES re-attach it re-attaches the drift. + { + name: 'DashboardWidgetSchema', + schema: DashboardWidgetSchema, + exports: [{ name: 'checkDashboardWidgetStageOrder', check: checkDashboardWidgetStageOrder, fixtures: stageOrderFixtures }], + cleanFixtures: [{ ...WIDGET, type: 'horizontal-bar' }], + }, ]; // --------------------------------------------------------------------------- @@ -396,6 +456,11 @@ describe('each schema attaches its export BY IDENTIFIER — no inline copy', () // --------------------------------------------------------------------------- describe('`./index` (the `@objectstack/spec/ui` surface) exports the same function objects', () => { + // NOT the full export list: `checkDashboardWidgetStageOrder` is catalogued in + // `MIRRORED` above (legs 1-2) and carries its own legs 3-4 — barrel identity + // and attached-by-identifier — beside the schema it guards, in + // `dashboard.test.ts`. Read this `it.each` as the rows that live here, not as + // an enumeration of every exported refinement. it.each([ ['checkListViewPageMount', checkListViewPageMount], ['checkListViewCalendarVisualization', checkListViewCalendarVisualization],