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
15 changes: 15 additions & 0 deletions .changeset/21131-dashboard-widget-options-door.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/lint': minor
---

`os validate`, `os build` and `os lint` now warn on a dashboard widget `options` key that no renderer reads, by name, as `unconsumed-widget-option` — the check the SDUI page save gate already ran on a `dashboard` node, now run over dashboard metadata (`*.dashboard.ts`, `defineStack({ dashboards })`).

Clause-②: yes

**What is flagged.** Every key in a dataset-bound widget's `options` outside the read set `CONSUMED_WIDGET_OPTION_KEYS` from `@objectstack/sdui-parser`: `dateGranularity`, `description`, `limit`, `sortBy`, `sortOrder`, `stageOrder`. `DashboardWidgetOptionsSchema` stays open (`.passthrough()`), so such a key still parses; it just stops being silent. Typical cases are `icon`, `columns`, `format`, `currency`, `color`, `suffix`, `showLegend` and `horizontal`, none of which styles anything on a widget bound to a dataset, and a misspelled declared key such as `sortDirection` or `granularity`. The finding is reported at `dashboards[N].widgets[M].options`, and its message names the key.

**Where presentation goes instead.** A number's format and currency are the dataset measure's `format` and `currency`; a tile's accent is the widget's `colorVariant`; a chart's look is the widget's `chartConfig`.

**Same check, same level, same exemptions.** The rule is `validateDashboardWidgetOptions`, exported from `@objectstack/lint` and registered for all three commands. It calls `checkDashboardWidgetOptions` from `@objectstack/sdui-parser` and keeps its `warning` level and `code`. Widgets with no `dataset`, legacy `component` widgets and widgets carrying `suppressWarnings: ['unconsumed-widget-option']` are not flagged. It does not run on the Studio/REST/MCP publish path.

**What changes for a project.** Nothing is refused, and nothing changes without `--strict`. `os validate --strict` and `os lint --strict` now exit 1 instead of 0 on a stack that was otherwise warning-clean and writes such a key. Across this repository's example apps (`app-crm`, `app-todo`, `app-showcase`, `app-multi-package`), no dashboard writes one: zero findings, and no example's exit code changes. To clear the warning, delete the key, or move the intent to the home named above.
2 changes: 1 addition & 1 deletion content/docs/deployment/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ os compile --json # JSON output for CI pipelines
→ Normalizing stack definition...
→ Lowering inline handlers...
→ Validating protocol compliance...
→ Running author-time rules (48)...
→ Running author-time rules (49)...
→ Checking that every required capability has a provider installable in this edition...
→ Collecting package docs (ADR-0046)... 0 collected
→ Writing artifact...
Expand Down
2 changes: 1 addition & 1 deletion content/docs/deployment/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -695,7 +695,7 @@ A clean run walks the registry and reports timing:
Config: /path/to/support-desk/objectstack.config.ts
Load time: 21ms
→ Validating against ObjectStack Protocol...
→ Running author-time rules (48)...
→ Running author-time rules (49)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
2 changes: 1 addition & 1 deletion content/docs/getting-started/build-with-claude-code.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -270,7 +270,7 @@ visible: 'status != "resolved"'
◆ Validate
────────────────────────────────────────
→ Validating against ObjectStack Protocol...
→ Running author-time rules (48)...
→ Running author-time rules (49)...

✗ Author-time rules failed (1 issue)
• stack · action 'resolve_ticket' visible: bare reference `status` — a
Expand Down
33 changes: 25 additions & 8 deletions content/docs/ui/dashboards.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ selects `dimensions` (X / group / split) and `values` (the measures to plot):
| `chartConfig` | `object` | optional | Chart **appearance**: `title`, `subtitle`, `description`, `colors`, `height`, `showLegend`, `showDataLabels`, `annotations`, `interaction`. The bound dataset decides which series exist and which column each one reads, so `type`, `xAxis`, `yAxis` and `series` are refused here — see below |
| `colorVariant` | `enum` | optional | KPI/card accent color |
| `compareTo` | `object` | optional | Period-over-period comparison: `{ kind: 'previousPeriod' \| 'previousYear', dimension? }`. Omit `dimension` when the selection dates exactly one time dimension — the runtime resolves it, and errors naming the candidates rather than guessing when it cannot. |
| `options` | `object` | optional | Renderer extras **plus** the query keys below |
| `options` | `object` | optional | The query keys below. Presentation does not go here — see below |

### What `chartConfig` does **not** carry

Expand Down Expand Up @@ -135,21 +135,38 @@ Existing sources: `os migrate meta --from 17` lists the mechanical edits.

### Widget `options`

`options` is an open bag: keys the renderer understands (`icon`, `columns`,
`striped`, `density`, …) pass through untouched. Five keys are **declared**,
because they are not presentation-only — four of them change the query the
dataset compiles to:
`options` carries five **declared** keys, and four of them change the query the
dataset compiles to. The bag is open, so any other key parses, but a dashboard
widget is always bound to a dataset and nothing on that render path reads
another key: an `icon`, `columns`, `format` or `color` written here styles
nothing. `os validate`, `os build` and `os lint` warn on each such key by name
(`unconsumed-widget-option`) and list the keys that are read.

Presentation has declared homes instead:

| You want | Write it here |
| :--- | :--- |
| A number's format or currency | The dataset **measure**'s `format` and `currency` |
| A KPI tile's accent color | The widget's `colorVariant` |
| A chart's look (colors, legend, data labels, height) | The widget's `chartConfig` |

A key that something outside the renderer genuinely reads can say so with
`suppressWarnings: ['unconsumed-widget-option']` on the widget.

The declared keys:

| Key | Type | Effect |
| :--- | :--- | :--- |
| `dateGranularity` | `day \| week \| month \| quarter \| year` | Buckets this widget's selected **date** dimensions. Overrides the dataset dimension's own default for this widget only. |
| `sortBy` | `string` | Orders rows by a dimension or measure **this widget selects**. |
| `sortOrder` | `asc \| desc` | Direction for `sortBy` (default `asc`). |
| `limit` | `number` | Max rows, applied **after** ordering. |
| `stageOrder` | `(string \| number \| boolean)[]` | 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; order those with `sortBy` / `sortOrder`. Omit to use the dimension field's picklist option order. |
| `stageOrder` | `(string \| number \| boolean)[]` | 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; order those with `sortBy` / `sortOrder`. Omit to use the dimension field's picklist option order. |

Because they are declared, a misspelling (`sortDirection`, `granularity`) is an
author-time error rather than an option that reads as if it works.
Because they are declared, a wrong **value** (`sortOrder: 'sideways'`) is an
author-time error. A misspelled **key** (`sortDirection`, `granularity`) is
not declared, so it parses like any other extra and changes nothing; the
`unconsumed-widget-option` warning is what names it.

```ts
{
Expand Down
2 changes: 1 addition & 1 deletion content/docs/ui/react-pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,7 @@ objectstack validate
────────────────────────────────────────
→ Loading configuration...
→ Validating against ObjectStack Protocol...
→ Running author-time rules (48)...
→ Running author-time rules (49)...
→ Checking that every required capability has a provider installable in this edition...
→ Checking package docs (ADR-0046)...

Expand Down
27 changes: 27 additions & 0 deletions packages/lint/src/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ import { validateViewContainers } from './validate-view-containers.js';
import { validateWidgetBindings } from './validate-widget-bindings.js';
import { validateDatasetMeasureAggregates } from './validate-dataset-measure-aggregates.js';
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
import { validateDashboardWidgetOptions } from './validate-dashboard-widget-options.js';
import { validateFilterTokens } from './validate-filter-tokens.js';
import { validateFlowFilterTokens } from './validate-flow-filter-tokens.js';
import { validatePresetComparands } from './validate-preset-comparands.js';
Expand Down Expand Up @@ -736,6 +737,32 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
run: (stack) => validateDashboardActionRefs(stack),
},
// A dashboard widget `options` key no renderer reads parses clean (the bag
// is `.passthrough()`) and styles nothing. `@objectstack/sdui-parser`'s
// `checkDashboardWidgetOptions` already says so for a `dashboard` node in an
// SDUI page; this entry runs that SAME check over dashboard metadata, at its
// same level (`warning`) and code (`unconsumed-widget-option`), reading the
// same `CONSUMED_WIDGET_OPTION_KEYS`. The rule file is an adapter that
// localizes the check's diagnostics; it holds no key list and no verdict.
{
name: 'validateDashboardWidgetOptions',
tier: 'advisory',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-dashboard-widget-options.ts',
surfaces: CLI_ONLY,
// The judgement is dashboard-local, so the per-write `dashboard` snapshot
// `validateWidgetBindings` already runs on would carry everything it
// reads. What holds it at the CLI is scope, not input: crossing an
// advisory rule onto the Studio/REST/MCP door puts a warning on every
// dashboard save that writes such a key, which is a rollout decision over
// stored tenant rows the in-repo corpus does not measure.
surfaceReason:
'Advisory, dashboard-local: the per-write dashboard snapshot would carry its input. Held at the CLI ' +
'doors by scope: crossing an advisory rule onto the Studio/REST/MCP door adds a warning to dashboard ' +
'saves, a rollout decision over stored tenant rows the in-repo corpus does not measure.',
run: (stack) => validateDashboardWidgetOptions(stack),
},
// #3574 — a filter value like `{current_user}` resolves in no vocabulary,
// reaches the data engine as a literal and matches nothing. The surface
// renders empty with no error, and a silent zero is indistinguishable from a
Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,9 @@ export type {
DashboardActionRefSeverity,
} from './validate-dashboard-action-refs.js';

export { validateDashboardWidgetOptions } from './validate-dashboard-widget-options.js';
export type { DashboardWidgetOptionFinding } from './validate-dashboard-widget-options.js';

export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
export { validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN } from './validate-flow-filter-tokens.js';
export type { FlowFilterTokenFinding } from './validate-flow-filter-tokens.js';
Expand Down
136 changes: 136 additions & 0 deletions packages/lint/src/validate-dashboard-widget-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { DashboardSchema } from '@objectstack/spec/ui';
import {
checkDashboardWidgetOptions,
CONSUMED_WIDGET_OPTION_KEYS,
UNCONSUMED_WIDGET_OPTION,
} from '@objectstack/sdui-parser';
import { validateDashboardWidgetOptions } from './validate-dashboard-widget-options.js';
import { AUTHORING_COMMANDS, authoringRulesFor, runAuthoringRules } from './authoring-rules.js';

type AnyRec = Record<string, unknown>;

/** A dataset-bound widget: the only spec-legal shape, and the one the check censuses. */
function widget(id: string, options?: AnyRec, extra: AnyRec = {}): AnyRec {
return {
id,
type: 'metric',
title: id,
dataset: 'opportunity_metrics',
values: ['total_amount'],
layout: { x: 0, y: 0, w: 4, h: 2 },
...(options === undefined ? {} : { options }),
...extra,
};
}

/** The dashboard as the parsed tier hands it over: through the real schema. */
function parsedDashboard(widgets: AnyRec[]): AnyRec {
return DashboardSchema.parse({ name: 'pipeline', label: 'Pipeline', widgets }) as AnyRec;
}

describe('validateDashboardWidgetOptions — the SDUI widget-option check at the metadata door', () => {
it('flags an unread options key by name, at the check\'s own code and level', () => {
const stack = { dashboards: [parsedDashboard([widget('won_revenue', { format: 'currency' })])] };
const findings = validateDashboardWidgetOptions(stack);

expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
rule: UNCONSUMED_WIDGET_OPTION,
severity: 'warning',
where: 'dashboard "pipeline" › widget "won_revenue"',
path: 'dashboards[0].widgets[0].options',
});
// The named subject: the finding says WHICH key.
expect(findings[0].message).toContain('options.format');
});

it('passes every consumed key (the control) on the same widget shape', () => {
// Every key the read set names, with a value the schema accepts, survives
// the parse and is not flagged. `stageOrder` is the funnel's own key: the
// schema refuses it on any other type, so the control widget is a funnel.
const values: AnyRec = {
dateGranularity: 'month',
description: 'sub-caption',
limit: 10,
sortBy: 'total_amount',
sortOrder: 'desc',
stageOrder: ['prospecting', 'closed_won'],
};
expect(Object.keys(values).sort()).toEqual([...CONSUMED_WIDGET_OPTION_KEYS].sort());
const control = widget('stages', values, { type: 'funnel', dimensions: ['stage'] });
const stack = { dashboards: [parsedDashboard([control])] };

expect(validateDashboardWidgetOptions(stack)).toEqual([]);
});

it('flags exactly what the check flags — one finding per diagnostic, message unchanged', () => {
// The adapter decides nothing: over the same widgets, the lint findings'
// codes, levels and messages ARE the check's diagnostics on the SDUI node.
const widgets = [
widget('tile_a', { color: 'green', sortBy: 'total_amount' }),
widget('tile_b'),
widget('tile_c', { showLegend: true, horizontal: true, limit: 5 }),
];
const dash = parsedDashboard(widgets);
const findings = validateDashboardWidgetOptions({ dashboards: [dash] });
const diagnostics = checkDashboardWidgetOptions({ type: 'dashboard', widgets: dash.widgets });

expect(diagnostics).toHaveLength(3);
expect(findings.map((f) => [f.rule, f.severity, f.message])).toEqual(
diagnostics.map((d) => [d.code, d.severity, d.message]),
);
expect(findings.map((f) => f.path)).toEqual([
'dashboards[0].widgets[0].options',
'dashboards[0].widgets[2].options',
'dashboards[0].widgets[2].options',
]);
});

it('honours the check\'s own exemptions rather than restating them', () => {
const suppressed = widget('quiet', { format: 'currency' }, {
suppressWarnings: [UNCONSUMED_WIDGET_OPTION],
});
const stack = { dashboards: [parsedDashboard([suppressed])] };
expect(validateDashboardWidgetOptions(stack)).toEqual([]);

// A widget with no `dataset` is outside the check's census (unparsed input:
// the schema requires `dataset`, which is the point of the exemption).
const inline = { dashboards: [{ name: 'legacy', widgets: [{ id: 'x', type: 'metric', options: { icon: 'x' } }] }] };
expect(validateDashboardWidgetOptions(inline)).toEqual([]);
});

it('keeps each widget at its own index, so an id-less widget is still named correctly', () => {
// `os lint` never parses, so a widget can arrive with no `id`.
const stack = {
dashboards: {
ops: { widgets: [widget('first'), { type: 'metric', dataset: 'd', options: { suffix: '%' } }] },
},
};
const findings = validateDashboardWidgetOptions(stack);

expect(findings).toHaveLength(1);
expect(findings[0]).toMatchObject({
rule: UNCONSUMED_WIDGET_OPTION,
severity: 'warning',
where: 'dashboard "ops" › widget "(widget 1)"',
path: 'dashboards[0].widgets[1].options',
});
expect(findings[0].message).toContain('"#1"');
});
});

describe('the registry runs it on all three authoring commands', () => {
const stack = { dashboards: [parsedDashboard([widget('won_revenue', { currency: 'USD' })])] };

it.each(AUTHORING_COMMANDS)('os %s reports the unread key as an advisory', (command) => {
expect(authoringRulesFor(command).map((r) => r.name)).toContain('validateDashboardWidgetOptions');
const hits = runAuthoringRules(command, { normalized: stack, parsed: stack }).filter(
(f) => f.rule === UNCONSUMED_WIDGET_OPTION,
);
expect(hits).toHaveLength(1);
expect(hits[0]).toMatchObject({ severity: 'warning', path: 'dashboards[0].widgets[0].options' });
});
});
Loading
Loading