Skip to content

Commit ce8a6d2

Browse files
feat(lint): os validate, os build and os lint flag unread dashboard widget options keys by name (#21204)
Fixes #21131 Clause-②: yes ## What this does `os validate`, `os build` and `os lint` now run `checkDashboardWidgetOptions` from `@objectstack/sdui-parser` over dashboard metadata (`*.dashboard.ts`, `defineStack({ dashboards })`). An `options` key that no renderer reads is flagged by name as `unconsumed-widget-option`, at the check's one level, `warning`. Until now the check had one caller, the SDUI tree validator (`packages/sdui-parser/src/validate.ts`), so a dashboard declared as metadata never met it, and `DashboardWidgetOptionsSchema` (`.passthrough()`) parsed any key in silence. - **`packages/lint/src/validate-dashboard-widget-options.ts`** (new): an adapter, not a second check. It calls the check once per widget, with an array that holds only that widget at its own index, and turns each diagnostic into a finding at `dashboards[N].widgets[M].options`. Code, severity and message come from the check unchanged. ⛔ No key list: the read set is `CONSUMED_WIDGET_OPTION_KEYS`, which `check:widget-option-census` derives from the spec. The check's own exemptions (no `dataset`, legacy `component` widgets, expression-valued `options`, `suppressWarnings: ['unconsumed-widget-option']`) apply because the check is what runs. - **`packages/lint/src/authoring-rules.ts`**: one registry entry, `tier: 'advisory'`, `commands: ALL`, `input: 'parsed'`. The surface is CLI-only, and the `surfaceReason` says why: the per-write `dashboard` snapshot would carry its input, but crossing an advisory rule onto the Studio/REST/MCP door is a rollout decision over stored tenant rows. - **`packages/lint/src/index.ts`**: exports `validateDashboardWidgetOptions` and `DashboardWidgetOptionFinding`. - **`content/docs/ui/dashboards.mdx`**: the page stops teaching `icon`, `columns`, `striped` and `density` as working extras, and the `options` row stops saying "Renderer extras". A new table names the declared homes from `DashboardWidgetOptionsSchema`'s doc comment: the dataset measure's `format` and `currency`, the widget's `colorVariant`, and the widget's `chartConfig`. - **`.changeset/21131-dashboard-widget-options-door.md`**: `@objectstack/lint` minor, with the `Clause-②: yes` line. No CLI wiring was needed. All three commands call `runAuthoringRules` (`packages/cli/src/commands/validate.ts`, `compile.ts`, `lint.ts`), and a registry entry reaches all three. That is measured below. ## The level: one check, one level The check emits `warning`. The spec declares the bag open, and the ruling recorded in the check's header is that open extras stay open and stop being silent. This PR uses that same level at the new door. The triage pin says "refused by name"; here it is read as "flagged by name at that level", as the dispatch decided. `os validate --strict` does refuse it, because `--strict` treats warnings as errors. The measurement did not argue for a different level: the in-repo corpus writes zero such keys today (below), and PR #21130 removed the keys from the only producers the card names. ## Measured Tree: `objectstack-ai/objectstack` at `5fb667f2` (code unchanged since `6b19d334`). **Doors reached.** A throwaway probe built the CRM example (`examples/app-crm`) with `options: { icon, sortOrder }` written onto widget `total_pipeline` before `defineStack` ran: | door | exit | `unconsumed-widget-option` findings | | :--- | :--- | :--- | | `os validate` | 0 | 1, `severity: warning`, path `dashboards[0].widgets[0].options`, key `icon` | | `os validate --json` | 0 | the same, in `warnings[]` | | `os build --json` | 0 | the same, in `warnings[]` | | `os lint` | 0 | the same | | control: `sortOrder` (a read key) on the same widget | | 0 at every door | The probe files were deleted after the run. The CLI prints "Running author-time rules (49)". **Example corpus (assumption 4).** `os validate --json` over every example with a dashboard: `app-crm` 0, `app-todo` 0, `app-showcase` 0 (3 dashboards), `app-multi-package` 0 (no dashboards). So no example producer needed a fix. The other dashboard sources in the tree (`packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts` and the `create-objectstack` blank template) write no widget `options`. **Tests.** - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2`: 119 files, 5497 tests passed. - `pnpm --filter @objectstack/lint typecheck`: `tsc --noEmit` clean, and `check:test-typecheck` OK (no new debt). - `pnpm --filter @objectstack/sdui-parser test`: 13 files, 217 tests passed. - New file `validate-dashboard-widget-options.test.ts` (8 tests). It pins the following: - An unread key is flagged with code `unconsumed-widget-option` and severity `warning`, and the finding names the key. - The control: all six consumed keys pass on a parsed widget. - Parity: the findings' code, severity and message equal the check's own diagnostics on the equivalent SDUI node. - The check's exemptions are honoured. - An id-less widget keeps its index. - `os validate`, `os build` and `os lint` each report it through `runAuthoringRules`. **Ablation (one-time proof, committed state `6b19d334`).** `node scripts/ablation-replace.mjs` replaced the check call with an empty diagnostic list. The anchor count went 1 to 0, and the blob went `e6339a78` to `4f3895a6`. Result: 6 of 8 tests red (every flagging pin plus the three per-command pins). The 2 green ones are the control and the exemptions, which assert an empty result. The restore was proven: blob equals HEAD `e6339a78`, and `git diff HEAD` is empty. **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` at `5fb667f2` derived 89 families. All 89 were run at that head, every one exited 0, and `--ran` reconciled 89 derived, 89 run, 0 NOT-MEASURED. Notes: - Three families first answered exit 3 (prerequisite not met). Each went to 0 once its prerequisite was met: - `check-plugin-teardown-shape --self-test`: a shallow clone; fixed by fetching the pinned fixture commit. - `check:skill-examples`: needed `client-react` built. - `check:dual-build-cjs-loads`: needed the rest of the workspace built. - `check:docs-transcript-drift` was red at first, a real finding: four CLI transcripts printed "author-time rules (48)". They now print 49 (see below). ## In-place corrections (declared) - **`content/docs/deployment/cli.mdx`, `deployment/validating-metadata.mdx`, `getting-started/build-with-claude-code.mdx`, `ui/react-pages.mdx`**: the transcript count went from 48 to 49. The new registry entry is what changed the derived count, and `check:docs-transcript-drift` holds these lines equal to it. - **`content/docs/ui/dashboards.mdx`, the `stageOrder` row**: it said the key "parses and is never consulted" on a non-funnel type. The schema refuses it there (`checkDashboardWidgetStageOrder`, `packages/spec/src/ui/dashboard.zod.ts`). This is the same section and the same defect class (the page misstating what happens to an `options` key). - **`content/docs/ui/dashboards.mdx`, the misspelling sentence**: it said a misspelled key (`sortDirection`, `granularity`) "is an author-time error". Measured: a `tsc --noEmit` probe in `packages/lint` against the built spec accepts `options: { sortDirection: 'desc', granularity: 'month' }` with no error. A wrong value for a declared key, `sortOrder: 'sideways'`, is TS2322 in the same run. The sentence now says the value is checked, while the misspelled key parses and draws this warning. ## Acceptance notes - The fourth door (Studio, REST `/meta`, MCP publish) still does not run this check over dashboard writes. The entry records why in `surfaceReason`. Crossing it is a rollout decision. - `DashboardWidgetOptionsSchema`'s doc comment (`packages/spec/src/ui/dashboard.zod.ts`) still says a typo such as `sortDirection` "is now an author-time type error". The `tsc` probe above measures that as false. Fixing it is a `packages/spec` TSDoc edit (`check:docs` regeneration), which this PR leaves to whoever next edits that file. The docs page here no longer repeats the claim. - The finding's message is the check's own text, which opens with the host type in angle brackets (the SDUI node spelling). The `where` field carries the metadata location next to it. - The skill sentence (`skills/objectstack-ui/rules/dashboards.md`) is a governed surface. It goes in its own docs-only PR, part of the same card. --- _Generated by [Claude Code](https://claude.ai/code/session_01UtnxvdiN376GF3sgXwAw4d)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e18fea6 commit ce8a6d2

10 files changed

Lines changed: 313 additions & 12 deletions
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
`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 })`).
6+
7+
Clause-②: yes
8+
9+
**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.
10+
11+
**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`.
12+
13+
**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.
14+
15+
**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.

‎content/docs/deployment/cli.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -604,7 +604,7 @@ os compile --json # JSON output for CI pipelines
604604
→ Normalizing stack definition...
605605
→ Lowering inline handlers...
606606
→ Validating protocol compliance...
607-
→ Running author-time rules (48)...
607+
→ Running author-time rules (49)...
608608
→ Checking that every required capability has a provider installable in this edition...
609609
→ Collecting package docs (ADR-0046)... 0 collected
610610
→ Writing artifact...

‎content/docs/deployment/validating-metadata.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -695,7 +695,7 @@ A clean run walks the registry and reports timing:
695695
Config: /path/to/support-desk/objectstack.config.ts
696696
Load time: 21ms
697697
→ Validating against ObjectStack Protocol...
698-
→ Running author-time rules (48)...
698+
→ Running author-time rules (49)...
699699
→ Checking that every required capability has a provider installable in this edition...
700700
→ Checking package docs (ADR-0046)...
701701

‎content/docs/getting-started/build-with-claude-code.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ visible: 'status != "resolved"'
270270
◆ Validate
271271
────────────────────────────────────────
272272
→ Validating against ObjectStack Protocol...
273-
→ Running author-time rules (48)...
273+
→ Running author-time rules (49)...
274274
275275
✗ Author-time rules failed (1 issue)
276276
• stack · action 'resolve_ticket' visible: bare reference `status` — a

‎content/docs/ui/dashboards.mdx‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ selects `dimensions` (X / group / split) and `values` (the measures to plot):
104104
| `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 |
105105
| `colorVariant` | `enum` | optional | KPI/card accent color |
106106
| `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. |
107-
| `options` | `object` | optional | Renderer extras **plus** the query keys below |
107+
| `options` | `object` | optional | The query keys below. Presentation does not go here — see below |
108108

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

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

136136
### Widget `options`
137137

138-
`options` is an open bag: keys the renderer understands (`icon`, `columns`,
139-
`striped`, `density`, …) pass through untouched. Five keys are **declared**,
140-
because they are not presentation-only — four of them change the query the
141-
dataset compiles to:
138+
`options` carries five **declared** keys, and four of them change the query the
139+
dataset compiles to. The bag is open, so any other key parses, but a dashboard
140+
widget is always bound to a dataset and nothing on that render path reads
141+
another key: an `icon`, `columns`, `format` or `color` written here styles
142+
nothing. `os validate`, `os build` and `os lint` warn on each such key by name
143+
(`unconsumed-widget-option`) and list the keys that are read.
144+
145+
Presentation has declared homes instead:
146+
147+
| You want | Write it here |
148+
| :--- | :--- |
149+
| A number's format or currency | The dataset **measure**'s `format` and `currency` |
150+
| A KPI tile's accent color | The widget's `colorVariant` |
151+
| A chart's look (colors, legend, data labels, height) | The widget's `chartConfig` |
152+
153+
A key that something outside the renderer genuinely reads can say so with
154+
`suppressWarnings: ['unconsumed-widget-option']` on the widget.
155+
156+
The declared keys:
142157

143158
| Key | Type | Effect |
144159
| :--- | :--- | :--- |
145160
| `dateGranularity` | `day \| week \| month \| quarter \| year` | Buckets this widget's selected **date** dimensions. Overrides the dataset dimension's own default for this widget only. |
146161
| `sortBy` | `string` | Orders rows by a dimension or measure **this widget selects**. |
147162
| `sortOrder` | `asc \| desc` | Direction for `sortBy` (default `asc`). |
148163
| `limit` | `number` | Max rows, applied **after** ordering. |
149-
| `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. |
164+
| `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. |
150165

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

154171
```ts
155172
{

‎content/docs/ui/react-pages.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -389,7 +389,7 @@ objectstack validate
389389
────────────────────────────────────────
390390
→ Loading configuration...
391391
→ Validating against ObjectStack Protocol...
392-
→ Running author-time rules (48)...
392+
→ Running author-time rules (49)...
393393
→ Checking that every required capability has a provider installable in this edition...
394394
→ Checking package docs (ADR-0046)...
395395

‎packages/lint/src/authoring-rules.ts‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ import { validateViewContainers } from './validate-view-containers.js';
105105
import { validateWidgetBindings } from './validate-widget-bindings.js';
106106
import { validateDatasetMeasureAggregates } from './validate-dataset-measure-aggregates.js';
107107
import { validateDashboardActionRefs } from './validate-dashboard-action-refs.js';
108+
import { validateDashboardWidgetOptions } from './validate-dashboard-widget-options.js';
108109
import { validateFilterTokens } from './validate-filter-tokens.js';
109110
import { validateFlowFilterTokens } from './validate-flow-filter-tokens.js';
110111
import { validatePresetComparands } from './validate-preset-comparands.js';
@@ -736,6 +737,32 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
736737
surfaceReason: RUNTIME_NEEDS_FULL_SNAPSHOT,
737738
run: (stack) => validateDashboardActionRefs(stack),
738739
},
740+
// A dashboard widget `options` key no renderer reads parses clean (the bag
741+
// is `.passthrough()`) and styles nothing. `@objectstack/sdui-parser`'s
742+
// `checkDashboardWidgetOptions` already says so for a `dashboard` node in an
743+
// SDUI page; this entry runs that SAME check over dashboard metadata, at its
744+
// same level (`warning`) and code (`unconsumed-widget-option`), reading the
745+
// same `CONSUMED_WIDGET_OPTION_KEYS`. The rule file is an adapter that
746+
// localizes the check's diagnostics; it holds no key list and no verdict.
747+
{
748+
name: 'validateDashboardWidgetOptions',
749+
tier: 'advisory',
750+
input: 'parsed',
751+
commands: ALL,
752+
source: 'packages/lint/src/validate-dashboard-widget-options.ts',
753+
surfaces: CLI_ONLY,
754+
// The judgement is dashboard-local, so the per-write `dashboard` snapshot
755+
// `validateWidgetBindings` already runs on would carry everything it
756+
// reads. What holds it at the CLI is scope, not input: crossing an
757+
// advisory rule onto the Studio/REST/MCP door puts a warning on every
758+
// dashboard save that writes such a key, which is a rollout decision over
759+
// stored tenant rows the in-repo corpus does not measure.
760+
surfaceReason:
761+
'Advisory, dashboard-local: the per-write dashboard snapshot would carry its input. Held at the CLI ' +
762+
'doors by scope: crossing an advisory rule onto the Studio/REST/MCP door adds a warning to dashboard ' +
763+
'saves, a rollout decision over stored tenant rows the in-repo corpus does not measure.',
764+
run: (stack) => validateDashboardWidgetOptions(stack),
765+
},
739766
// #3574 — a filter value like `{current_user}` resolves in no vocabulary,
740767
// reaches the data engine as a literal and matches nothing. The surface
741768
// renders empty with no error, and a silent zero is indistinguishable from a

‎packages/lint/src/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -395,6 +395,9 @@ export type {
395395
DashboardActionRefSeverity,
396396
} from './validate-dashboard-action-refs.js';
397397

398+
export { validateDashboardWidgetOptions } from './validate-dashboard-widget-options.js';
399+
export type { DashboardWidgetOptionFinding } from './validate-dashboard-widget-options.js';
400+
398401
export { validateFilterTokens, FILTER_TOKEN_UNKNOWN } from './validate-filter-tokens.js';
399402
export { validateFlowFilterTokens, FLOW_FILTER_TOKEN_UNKNOWN } from './validate-flow-filter-tokens.js';
400403
export type { FlowFilterTokenFinding } from './validate-flow-filter-tokens.js';
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { DashboardSchema } from '@objectstack/spec/ui';
5+
import {
6+
checkDashboardWidgetOptions,
7+
CONSUMED_WIDGET_OPTION_KEYS,
8+
UNCONSUMED_WIDGET_OPTION,
9+
} from '@objectstack/sdui-parser';
10+
import { validateDashboardWidgetOptions } from './validate-dashboard-widget-options.js';
11+
import { AUTHORING_COMMANDS, authoringRulesFor, runAuthoringRules } from './authoring-rules.js';
12+
13+
type AnyRec = Record<string, unknown>;
14+
15+
/** A dataset-bound widget: the only spec-legal shape, and the one the check censuses. */
16+
function widget(id: string, options?: AnyRec, extra: AnyRec = {}): AnyRec {
17+
return {
18+
id,
19+
type: 'metric',
20+
title: id,
21+
dataset: 'opportunity_metrics',
22+
values: ['total_amount'],
23+
layout: { x: 0, y: 0, w: 4, h: 2 },
24+
...(options === undefined ? {} : { options }),
25+
...extra,
26+
};
27+
}
28+
29+
/** The dashboard as the parsed tier hands it over: through the real schema. */
30+
function parsedDashboard(widgets: AnyRec[]): AnyRec {
31+
return DashboardSchema.parse({ name: 'pipeline', label: 'Pipeline', widgets }) as AnyRec;
32+
}
33+
34+
describe('validateDashboardWidgetOptions — the SDUI widget-option check at the metadata door', () => {
35+
it('flags an unread options key by name, at the check\'s own code and level', () => {
36+
const stack = { dashboards: [parsedDashboard([widget('won_revenue', { format: 'currency' })])] };
37+
const findings = validateDashboardWidgetOptions(stack);
38+
39+
expect(findings).toHaveLength(1);
40+
expect(findings[0]).toMatchObject({
41+
rule: UNCONSUMED_WIDGET_OPTION,
42+
severity: 'warning',
43+
where: 'dashboard "pipeline" › widget "won_revenue"',
44+
path: 'dashboards[0].widgets[0].options',
45+
});
46+
// The named subject: the finding says WHICH key.
47+
expect(findings[0].message).toContain('options.format');
48+
});
49+
50+
it('passes every consumed key (the control) on the same widget shape', () => {
51+
// Every key the read set names, with a value the schema accepts, survives
52+
// the parse and is not flagged. `stageOrder` is the funnel's own key: the
53+
// schema refuses it on any other type, so the control widget is a funnel.
54+
const values: AnyRec = {
55+
dateGranularity: 'month',
56+
description: 'sub-caption',
57+
limit: 10,
58+
sortBy: 'total_amount',
59+
sortOrder: 'desc',
60+
stageOrder: ['prospecting', 'closed_won'],
61+
};
62+
expect(Object.keys(values).sort()).toEqual([...CONSUMED_WIDGET_OPTION_KEYS].sort());
63+
const control = widget('stages', values, { type: 'funnel', dimensions: ['stage'] });
64+
const stack = { dashboards: [parsedDashboard([control])] };
65+
66+
expect(validateDashboardWidgetOptions(stack)).toEqual([]);
67+
});
68+
69+
it('flags exactly what the check flags — one finding per diagnostic, message unchanged', () => {
70+
// The adapter decides nothing: over the same widgets, the lint findings'
71+
// codes, levels and messages ARE the check's diagnostics on the SDUI node.
72+
const widgets = [
73+
widget('tile_a', { color: 'green', sortBy: 'total_amount' }),
74+
widget('tile_b'),
75+
widget('tile_c', { showLegend: true, horizontal: true, limit: 5 }),
76+
];
77+
const dash = parsedDashboard(widgets);
78+
const findings = validateDashboardWidgetOptions({ dashboards: [dash] });
79+
const diagnostics = checkDashboardWidgetOptions({ type: 'dashboard', widgets: dash.widgets });
80+
81+
expect(diagnostics).toHaveLength(3);
82+
expect(findings.map((f) => [f.rule, f.severity, f.message])).toEqual(
83+
diagnostics.map((d) => [d.code, d.severity, d.message]),
84+
);
85+
expect(findings.map((f) => f.path)).toEqual([
86+
'dashboards[0].widgets[0].options',
87+
'dashboards[0].widgets[2].options',
88+
'dashboards[0].widgets[2].options',
89+
]);
90+
});
91+
92+
it('honours the check\'s own exemptions rather than restating them', () => {
93+
const suppressed = widget('quiet', { format: 'currency' }, {
94+
suppressWarnings: [UNCONSUMED_WIDGET_OPTION],
95+
});
96+
const stack = { dashboards: [parsedDashboard([suppressed])] };
97+
expect(validateDashboardWidgetOptions(stack)).toEqual([]);
98+
99+
// A widget with no `dataset` is outside the check's census (unparsed input:
100+
// the schema requires `dataset`, which is the point of the exemption).
101+
const inline = { dashboards: [{ name: 'legacy', widgets: [{ id: 'x', type: 'metric', options: { icon: 'x' } }] }] };
102+
expect(validateDashboardWidgetOptions(inline)).toEqual([]);
103+
});
104+
105+
it('keeps each widget at its own index, so an id-less widget is still named correctly', () => {
106+
// `os lint` never parses, so a widget can arrive with no `id`.
107+
const stack = {
108+
dashboards: {
109+
ops: { widgets: [widget('first'), { type: 'metric', dataset: 'd', options: { suffix: '%' } }] },
110+
},
111+
};
112+
const findings = validateDashboardWidgetOptions(stack);
113+
114+
expect(findings).toHaveLength(1);
115+
expect(findings[0]).toMatchObject({
116+
rule: UNCONSUMED_WIDGET_OPTION,
117+
severity: 'warning',
118+
where: 'dashboard "ops" › widget "(widget 1)"',
119+
path: 'dashboards[0].widgets[1].options',
120+
});
121+
expect(findings[0].message).toContain('"#1"');
122+
});
123+
});
124+
125+
describe('the registry runs it on all three authoring commands', () => {
126+
const stack = { dashboards: [parsedDashboard([widget('won_revenue', { currency: 'USD' })])] };
127+
128+
it.each(AUTHORING_COMMANDS)('os %s reports the unread key as an advisory', (command) => {
129+
expect(authoringRulesFor(command).map((r) => r.name)).toContain('validateDashboardWidgetOptions');
130+
const hits = runAuthoringRules(command, { normalized: stack, parsed: stack }).filter(
131+
(f) => f.rule === UNCONSUMED_WIDGET_OPTION,
132+
);
133+
expect(hits).toHaveLength(1);
134+
expect(hits[0]).toMatchObject({ severity: 'warning', path: 'dashboards[0].widgets[0].options' });
135+
});
136+
});

0 commit comments

Comments
 (0)