diff --git a/.changeset/chart-empty-selection-rules.md b/.changeset/chart-empty-selection-rules.md new file mode 100644 index 0000000000..0a290341c1 --- /dev/null +++ b/.changeset/chart-empty-selection-rules.md @@ -0,0 +1,31 @@ +--- +'@objectstack/lint': minor +--- + +Two new widget-binding rule ids for a chart widget with an empty selection + +`validateWidgetBindings` reported nothing about two dataset-bound chart shapes that the +`@object-ui` revision this repo pins (`.objectui-sha`) visibly degrades. Both are now +warnings, suppressible per widget with `suppressWarnings: ['']`: + +- `chart-measures-missing` — a chart-family widget selects no measures (`values` empty or + absent). `DatasetWidget.tsx:683` returns the authoring placeholder "Pick measures + (values) for this dataset widget." before any query runs, above every family branch, so + no chart is drawn at all. +- `chart-dimensions-missing` — a chart-family widget selects at least one measure but no + dimensions. `DatasetWidget.tsx:423` reads + `const isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0;`, so the + widget renders as a single KPI number and the declared chart family is silently ignored. + The hint steers the author to a dimension, or to the `metric`/`kpi` family that matches + what actually renders. + +Warning tier rather than error for both: an empty selection is a work-in-progress state a +build must tolerate, and erroring would gate the `sys_metadata` publish path on a +half-authored widget. Neither shape is folded into `chart-config-missing` — neither is +caused by, nor repairable with, `chartConfig`, which carries presentation only. + +"Chart family" is derived, not hand-listed: every declared `ChartTypeSchema` option that +the pinned renderer routes to its chart branch — the taxonomy minus the renderer's own +`METRIC_TYPES` (`metric`, `kpi`, `gauge`, `solid-gauge`, `bullet`) and its `table`/`pivot` +tabular test. A `metric` tile with no dimensions, such as the shipped `system_overview` +board's own KPI tiles, is therefore not a finding. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 273467ff0f..f460efd85a 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -18,6 +18,12 @@ export { WIDGET_MEASURE_UNKNOWN, CHART_FIELD_UNKNOWN, CHART_CONFIG_MISSING, + // [#15462] The two empty-selection shapes the pinned renderer degrades on + // before `chartConfig` is ever consulted: no measures (an authoring + // placeholder replaces the chart) and no dimensions (`isMetric` draws a KPI + // number instead of the declared family). + CHART_MEASURES_MISSING, + CHART_DIMENSIONS_MISSING, TABLE_COUNT_ONLY, MEASURE_AGGREGATE_INCOHERENT, WIDGET_LEGACY_ANALYTICS_SHAPE, diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 290e501673..b4c59292c0 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -4,6 +4,11 @@ import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; import { validateWidgetBindings, MARK_MIXING_CHART_TYPES, + METRIC_WIDGET_TYPES, + TABULAR_WIDGET_TYPES, + CHART_FAMILY_WIDGET_TYPES, + CHART_MEASURES_MISSING, + CHART_DIMENSIONS_MISSING, TABLE_COUNT_ONLY, WIDGET_DATASET_UNKNOWN, WIDGET_DIMENSION_UNKNOWN, @@ -1447,3 +1452,224 @@ describe('#14148 acceptance — both limbs gate `validate` AND `build`', () => { }); } }); + +/** + * [#15462] The two empty-selection shapes. Both are read at the `@object-ui` + * revision this repo PINS (`.objectui-sha`), in + * `packages/plugin-dashboard/src/DatasetWidget.tsx`: + * + * - `:683` — `if (values.length === 0)` returns the authoring placeholder + * "Pick measures (values) for this dataset widget.", above every family + * branch, so no chart is drawn at all; + * - `:423` — `const isMetric = METRIC_TYPES.has(widgetType) || + * dimensions.length === 0;`, so a dimensionless `bar` renders as a KPI + * number instead of the family the author declared. + * + * Warning tier for both: an empty selection is a state an author passes + * through, and this family's errors are reserved for bindings the analytics + * service cannot satisfy. + */ +describe('chart-measures-missing / chart-dimensions-missing (#15462)', () => { + const rules = (findings: { rule: string }[]) => findings.map((f) => f.rule); + + it('(1) warns when a chart-family widget selects no measures', () => { + const findings = validateWidgetBindings(chartStack({ values: [], chartConfig: undefined })); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(CHART_MEASURES_MISSING); + expect(findings[0].where).toContain('spend_by_category'); + expect(findings[0].path).toBe('dashboards[0].widgets[0]'); + // The message names the PINNED consequence — the placeholder string the + // renderer returns — not an inferred one. + expect(findings[0].message).toContain('Pick measures (values) for this dataset widget.'); + expect(findings[0].message).toContain('no chart is drawn at all'); + expect(findings[0].hint).toContain('declared measures: sum_amount, ticket_count'); + expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_MEASURES_MISSING}']`); + }); + + it('(1) an absent `values` key reports the same shape as an empty array', () => { + const stack = chartStack({ chartConfig: undefined }); + delete (stack as { dashboards: { widgets: Record[] }[] }) + .dashboards[0].widgets[0].values; + const findings = validateWidgetBindings(stack); + expect(rules(findings)).toEqual([CHART_MEASURES_MISSING]); + }); + + it('(2) warns when a chart-family widget selects no dimensions', () => { + const findings = validateWidgetBindings(chartStack({ dimensions: [], chartConfig: undefined })); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].rule).toBe(CHART_DIMENSIONS_MISSING); + expect(findings[0].path).toBe('dashboards[0].widgets[0]'); + // The pinned expression, quoted, and the consequence it produces. + expect(findings[0].message).toContain('METRIC_TYPES.has(widgetType) || dimensions.length === 0'); + expect(findings[0].message).toContain('draws a single KPI number'); + expect(findings[0].hint).toContain('declared dimensions: category'); + // The honest alternative repair: declare the family that actually renders. + expect(findings[0].hint).toContain("'metric' or 'kpi'"); + expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_DIMENSIONS_MISSING}']`); + }); + + it('each shape is suppressible per widget', () => { + expect(validateWidgetBindings(chartStack({ + values: [], chartConfig: undefined, suppressWarnings: [CHART_MEASURES_MISSING], + }))).toHaveLength(0); + expect(validateWidgetBindings(chartStack({ + dimensions: [], chartConfig: undefined, suppressWarnings: [CHART_DIMENSIONS_MISSING], + }))).toHaveLength(0); + }); + + it('an unrelated suppressWarnings entry suppresses neither', () => { + expect(rules(validateWidgetBindings(chartStack({ + values: [], chartConfig: undefined, suppressWarnings: [CHART_DIMENSIONS_MISSING], + })))).toEqual([CHART_MEASURES_MISSING]); + expect(rules(validateWidgetBindings(chartStack({ + dimensions: [], chartConfig: undefined, suppressWarnings: [CHART_MEASURES_MISSING], + })))).toEqual([CHART_DIMENSIONS_MISSING]); + }); + + it('a widget missing BOTH reports only the measures id — the pin returns first', () => { + // `values.length === 0` short-circuits at `:683`, ABOVE the `isMetric` + // branch, so this widget never renders the KPI number the other id + // describes. Reporting both would attribute two consequences to one widget. + const findings = validateWidgetBindings(chartStack({ + values: [], dimensions: [], chartConfig: undefined, + })); + expect(rules(findings)).toEqual([CHART_MEASURES_MISSING]); + // The dimension is still named, so one fix round closes both. + expect(findings[0].hint).toContain(CHART_DIMENSIONS_MISSING); + }); + + it('a single-value or tabular family with no dimensions is NOT the dimensions finding', () => { + // The renderer routes these away from the chart branch on their TYPE + // (`METRIC_TYPES` / `isTable`), so a dimensionless one renders exactly what + // the author declared. `table`/`pivot` keep `table-count-only` as their own + // dimensionless rule. + for (const type of ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet', 'table', 'pivot']) { + const findings = validateWidgetBindings(chartStack({ type, dimensions: [], chartConfig: undefined })); + expect(rules(findings), `'${type}' must not report a chart-family finding`) + .not.toContain(CHART_DIMENSIONS_MISSING); + } + }); + + it('a single-value or tabular family with no measures is NOT the measures finding', () => { + for (const type of ['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet', 'table', 'pivot']) { + const findings = validateWidgetBindings(chartStack({ type, values: [], chartConfig: undefined })); + expect(rules(findings), `'${type}' must not report a chart-family finding`) + .not.toContain(CHART_MEASURES_MISSING); + } + }); + + it('a chart-family widget that selects both is clean', () => { + for (const type of CHART_FAMILY_WIDGET_TYPES) { + const findings = validateWidgetBindings(chartStack({ type, chartConfig: undefined })); + const mine = findings.filter((f) => f.rule === CHART_MEASURES_MISSING + || f.rule === CHART_DIMENSIONS_MISSING); + expect(mine, `'${type}' with one dimension and one measure must be clean`).toEqual([]); + } + }); + + it('every chart family reports, and no other family does', () => { + for (const type of ChartTypeSchema.options as readonly string[]) { + const noMeasures = rules(validateWidgetBindings(chartStack({ type, values: [], chartConfig: undefined }))); + const noDims = rules(validateWidgetBindings(chartStack({ type, dimensions: [], chartConfig: undefined }))); + if (CHART_FAMILY_WIDGET_TYPES.has(type)) { + expect(noMeasures, `'${type}' selects no measures`).toContain(CHART_MEASURES_MISSING); + expect(noDims, `'${type}' selects no dimensions`).toContain(CHART_DIMENSIONS_MISSING); + } else { + expect(noMeasures, `'${type}' is not a chart family`).not.toContain(CHART_MEASURES_MISSING); + expect(noDims, `'${type}' is not a chart family`).not.toContain(CHART_DIMENSIONS_MISSING); + } + } + }); + + it('a widget type outside the taxonomy is judged by neither id', () => { + expect(validateWidgetBindings(chartStack({ type: 'barr', values: [], dimensions: [], chartConfig: undefined }))) + .toEqual([]); + }); + + it('the exception sets mirror the PINNED renderer verbatim', () => { + // `DatasetWidget.tsx:343` `METRIC_TYPES` and `:424` `isTable`. Our copies + // drifting from the quoted lines reds here; the renderer's own set moving + // is caught by re-reading the pin when `.objectui-sha` bumps, which is the + // same contract `DATE_RANGE_DEFAULT_FIELD` carries in this file. + expect([...METRIC_WIDGET_TYPES]).toEqual(['metric', 'kpi', 'gauge', 'solid-gauge', 'bullet']); + expect([...TABULAR_WIDGET_TYPES]).toEqual(['table', 'pivot']); + }); + + it('every member of both exception sets is a declared chart type', () => { + // #14436's check for `MARK_MIXING_CHART_TYPES`, applied to the two sets the + // chart family is derived from: a member that is not a chart type at all is + // a typo or a retired family, and would silently WIDEN the family. + for (const type of [...METRIC_WIDGET_TYPES, ...TABULAR_WIDGET_TYPES]) { + expect(ChartTypeSchema.options, `'${type}' is not a declared chart type`).toContain(type); + } + }); + + it('the chart family is the taxonomy minus those two sets', () => { + for (const type of ['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie', 'donut', + 'funnel', 'scatter', 'treemap', 'sankey', 'combo', 'radar']) { + expect(CHART_FAMILY_WIDGET_TYPES.has(type), `'${type}' should be a chart family`).toBe(true); + } + for (const type of [...METRIC_WIDGET_TYPES, ...TABULAR_WIDGET_TYPES]) { + expect(CHART_FAMILY_WIDGET_TYPES.has(type), `'${type}' should NOT be a chart family`).toBe(false); + } + }); + + it('the SHIPPED `system_overview` single-value tiles report nothing', () => { + // The other half of #15461's fixture: the Row 1/2 tiles of + // `packages/platform-objects/src/apps/dashboards/system_overview.dashboard.ts` + // select a measure and NO dimensions — which is exactly shape 2's input, + // and is correct here because they are `metric` widgets. Copied verbatim, + // for the same reason that fixture was: first-party metadata is where a + // mis-scoped warning-tier rule does its damage (ADR-0072 D1). + const findings = validateWidgetBindings({ + datasets: [{ + name: 'sys_user_metrics', + label: 'User Metrics', + object: 'sys_user', + dimensions: [{ name: 'is_active', label: 'Active', field: 'is_active', type: 'boolean' }], + measures: [{ name: 'user_count', label: 'Users', aggregate: 'count' }], + }], + dashboards: [{ + name: 'system_overview', + label: 'System Overview', + widgets: [{ + id: 'widget_total_users', + dataset: 'sys_user_metrics', values: ['user_count'], + title: 'Total Users', + type: 'metric', + layout: { x: 0, y: 0, w: 3, h: 2 }, + }], + }], + }); + expect(findings).toEqual([]); + }); +}); + +/** + * [#15462] Tier, pinned end-to-end rather than inferred from the constant: both + * ids ride the advisory channel on `validate` AND `build`. Shape 1 was the one + * the card left open (`error` "looks right — the renderer draws nothing"), and + * it was ruled warning because an empty selection is a work-in-progress state a + * build must tolerate — erroring would gate the `sys_metadata` publish path on + * a half-authored widget. Nothing else in this file would notice a later flip. + */ +describe('#15462 acceptance — both ids are advisory on `validate` and `build`', () => { + const noMeasures = chartStack({ values: [], chartConfig: undefined }); + const noDims = chartStack({ dimensions: [], chartConfig: undefined }); + + for (const command of ['validate', 'build'] as const) { + it(`chart-measures-missing advises (never gates) \`${command}\``, () => { + const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: noMeasures })); + expect(errors.map((f) => f.rule)).not.toContain(CHART_MEASURES_MISSING); + expect(advisories.map((f) => f.rule)).toContain(CHART_MEASURES_MISSING); + }); + + it(`chart-dimensions-missing advises (never gates) \`${command}\``, () => { + const { errors, advisories } = splitBySeverity(runAuthoringRules(command, { normalized: noDims })); + expect(errors.map((f) => f.rule)).not.toContain(CHART_DIMENSIONS_MISSING); + expect(advisories.map((f) => f.rule)).toContain(CHART_DIMENSIONS_MISSING); + }); + } +}); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index 48ababf342..8987e498f7 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { isIncoherentAggregate } from '@objectstack/spec/data'; +import { ChartTypeSchema } from '@objectstack/spec/ui'; import { walkFilterFieldKeys } from './filter-walk.js'; import { @@ -83,6 +84,19 @@ import { * widget's own `dimensions` / `values`, and actively REFUSES an authored * `ChartAxis.field` / `ChartSeries.name`. See "What the renderer actually * derives" below for the pinned contract this now mirrors. + * - `chart-measures-missing` — a chart-family widget selects NO measures + * (`values` empty or absent). The pinned renderer short-circuits to an + * authoring placeholder — *"Pick measures (values) for this dataset + * widget."* — before any query runs, so no chart is drawn at all. Warning + * rather than error because an empty selection is a legitimate + * work-in-progress state a build must tolerate; erroring would gate the + * `sys_metadata` publish path on a half-authored widget. + * - `chart-dimensions-missing` — a chart-family widget selects at least one + * measure but NO dimensions. The pinned renderer's + * `isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0` + * routes it to the single-value branch, so it renders a KPI number and the + * family the author declared is silently ignored. Warning because the + * number is real and correct — it is the chart that is gone. * - `table-count-only` (#1719) — a `table`/`pivot` widget whose selected * measures are ALL `aggregate: 'count'` and which declares no * `dimensions` asks the analytics service for a single summary row. That @@ -246,13 +260,48 @@ import { * rather than error, because the numbers are right and the chart renders — it * is the shape that is wrong. * - * ⚠️ Two genuinely un-renderable shapes are NOT this rule's, and are - * deliberately left unreported here rather than folded in under an id that - * would then misname its own condition: a chart-type widget selecting NO - * measures (the pin renders an explicit "Pick measures (values)" placeholder) - * and one selecting no dimensions (the pin's `isMetric` covers - * `dimensions.length === 0`, so it renders as a KPI number instead of the - * family asked for). Neither is caused by, nor repairable with, `chartConfig`. + * ⚠️ Two genuinely un-renderable shapes are NOT this id's, because neither is + * caused by nor repairable with `chartConfig` and folding them in would leave + * this id misnaming its own condition: a chart-family widget selecting NO + * measures, and one selecting NO dimensions. They have their own ids — + * `chart-measures-missing` and `chart-dimensions-missing` — described next. + * + * ### The two empty-selection shapes (#15462) + * + * Read at the same PINNED `@object-ui` revision (`.objectui-sha`), in + * `packages/plugin-dashboard/src/DatasetWidget.tsx`: + * + * - `:683` — `if (values.length === 0)` returns the authoring placeholder + * `tt('dashboard.pickMeasures', 'Pick measures (values) for this dataset + * widget.')`. It stands ABOVE every family branch, so a widget selecting no + * measure never reaches a chart, a table or a KPI number: nothing is drawn. + * - `:423` — `const isMetric = METRIC_TYPES.has(widgetType) || + * dimensions.length === 0;`, with `METRIC_TYPES` (`:343`) = `metric`, `kpi`, + * `gauge`, `solid-gauge`, `bullet`; `:424` is the tabular test + * (`table`/`pivot`); and the render branches (`:702`, `:798`, `:854`) route + * `isMetric ? KPI : isTable ? table : chart`. A dimensionless `bar` is + * therefore drawn as a single KPI number — the failure direction the family's + * own docblock names, except worse: the number is REAL, so the missing chart + * reads as a design choice rather than as a defect. + * + * So "chart family" here is not a hand list — it is what that routing leaves + * over: a declared `ChartTypeSchema` option that is neither a `METRIC_TYPES` + * member nor tabular ({@link CHART_FAMILY_WIDGET_TYPES}). The taxonomy supplies + * the universe and the renderer decides the exceptions, which is the same + * division #14436 settled for `MARK_MIXING_CHART_TYPES`: this file's copies of + * the renderer's two sets are held to `ChartTypeSchema` by the rule's tests, so + * a member that stops being a declared chart type reds instead of going quiet. + * + * The two ids never both fire on one widget, and the reason is the pin's own + * order: with no measures the placeholder returns at `:683` and the `isMetric` + * branch is never reached, so `chart-dimensions-missing`'s consequence (a KPI + * number in place of the chart) is not what that widget does. A widget missing + * both reports `chart-measures-missing` alone, whose hint names the missing + * dimension too. + * + * Both are warnings, per the tier decision on #15462: an empty selection is a + * state an author passes THROUGH, and the family's errors are reserved for + * bindings the analytics service cannot satisfy. * * Warnings can be deliberately suppressed per widget via * `suppressWarnings: ['']`; errors cannot — they describe a @@ -264,6 +313,17 @@ export const WIDGET_DIMENSION_UNKNOWN = 'widget-dimension-unknown'; export const WIDGET_MEASURE_UNKNOWN = 'widget-measure-unknown'; export const CHART_FIELD_UNKNOWN = 'chart-field-unknown'; export const CHART_CONFIG_MISSING = 'chart-config-missing'; +/** + * [#15462] A chart-family widget selects no measures, so the pinned renderer + * draws the "Pick measures (values)" placeholder instead of a chart. + */ +export const CHART_MEASURES_MISSING = 'chart-measures-missing'; +/** + * [#15462] A chart-family widget selects no dimensions, so the pinned + * renderer's `isMetric` branch draws a KPI number instead of the declared + * chart family. + */ +export const CHART_DIMENSIONS_MISSING = 'chart-dimensions-missing'; export const TABLE_COUNT_ONLY = 'table-count-only'; export const MEASURE_AGGREGATE_INCOHERENT = 'measure-aggregate-incoherent'; export const WIDGET_LEGACY_ANALYTICS_SHAPE = 'widget-legacy-analytics-shape'; @@ -358,6 +418,53 @@ function asStrings(v: unknown): string[] { */ export const MARK_MIXING_CHART_TYPES = new Set(['combo']); +/** + * [#15462] Widget types the PINNED renderer draws as a single value rather than + * as a chart. MUST track objectui `DatasetWidget.tsx`'s own `METRIC_TYPES` + * (`:343` at `.objectui-sha`), the left half of + * `isMetric = METRIC_TYPES.has(widgetType) || dimensions.length === 0` (`:423`) + * — the same "mirror the runtime this check shadows" relationship + * `DATE_RANGE_DEFAULT_FIELD` carries one position up in this file. + */ +export const METRIC_WIDGET_TYPES = new Set([ + 'metric', 'kpi', 'gauge', 'solid-gauge', 'bullet', +]); + +/** + * [#15462] Widget types the pinned renderer draws as a table — `isTable` + * (`DatasetWidget.tsx:424`), which reads `widgetType === 'table' || widgetType + * === 'pivot'`. Their dimensionless shape is `table-count-only`'s subject, not + * a chart-family one. + */ +export const TABULAR_WIDGET_TYPES = new Set(['table', 'pivot']); + +/** + * [#15462] The chart family, derived rather than hand-listed: every declared + * `ChartTypeSchema` option the pinned renderer routes to its CHART branch — + * i.e. the taxonomy minus the two sets above, which are the renderer's own + * (`isMetric ? KPI : isTable ? table : chart`, `DatasetWidget.tsx:702`). + * + * The direction matters. #14436 retired a `CHART_TYPES` that was "the taxonomy + * minus a hand-written exemption list" because its PREDICATE was wrong (no + * family carries its binding in `chartConfig`), not because deriving a + * population from the taxonomy is. Here the exemptions are not invented: each + * one is a set the renderer itself branches on, and a chart type that appears + * in neither is one the renderer really does draw as a chart — including a + * family added to the taxonomy after this line was written, which is the case a + * hand list gets wrong (objectui#2945). Membership of both exception sets is + * held to `ChartTypeSchema` by this rule's tests. + */ +export const CHART_FAMILY_WIDGET_TYPES: ReadonlySet = new Set( + (ChartTypeSchema.options as readonly string[]).filter( + (t) => !METRIC_WIDGET_TYPES.has(t) && !TABULAR_WIDGET_TYPES.has(t), + ), +); + +/** [#15462] Does the pinned renderer draw this widget `type` as a chart? */ +export function isChartFamilyWidgetType(type: unknown): boolean { + return typeof type === 'string' && CHART_FAMILY_WIDGET_TYPES.has(type); +} + function list(names: Iterable): string { const arr = [...names]; return arr.length > 0 ? arr.join(', ') : '(none)'; @@ -1001,6 +1108,59 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { }); } + // ── (d1) a chart-family widget with an empty selection (#15462) ── + // Neither shape is about `chartConfig` — the renderer degrades before it + // is ever consulted — so both are their own ids rather than arms of + // `chart-config-missing` (which would then misname its own condition). + // Evaluated on the AUTHORED arrays, exactly as (c1) is: an entry that + // does not resolve is rules (b)/(c)'s finding, and emptiness is a + // different question from resolvability. + // + // Mutually exclusive, in the pin's own order: `values.length === 0` + // returns the placeholder at `DatasetWidget.tsx:683`, above every family + // branch, so a measureless widget never reaches the `isMetric` test the + // second id describes. Reporting both would attribute to one widget two + // consequences it cannot have at once. + if (isChartFamilyWidgetType(w.type)) { + if (values.length === 0) { + push({ + severity: 'warning', + rule: CHART_MEASURES_MISSING, + message: + `'${w.type}' widget selects no measures (\`values\` is empty), so the ` + + `renderer short-circuits to the authoring placeholder "Pick measures ` + + `(values) for this dataset widget." before any query runs — no chart is ` + + `drawn at all.`, + hint: + `Select at least one measure of dataset "${dsName}" BY NAME — ` + + `values: [''] (declared measures: ${list(measures.keys())}).` + + (dims.length === 0 + ? ` This widget selects no \`dimensions\` either; a chart family needs one ` + + `to plot against (see ${CHART_DIMENSIONS_MISSING}).` + : '') + + ` If the widget is deliberately still being authored, suppress with: ` + + `suppressWarnings: ['${CHART_MEASURES_MISSING}']`, + }); + } else if (dims.length === 0) { + push({ + severity: 'warning', + rule: CHART_DIMENSIONS_MISSING, + message: + `'${w.type}' widget selects no dimensions, so the renderer's ` + + `\`isMetric\` test (\`METRIC_TYPES.has(widgetType) || dimensions.length ` + + `=== 0\`) is true and it draws a single KPI number instead of a ` + + `'${w.type}' chart. The number is real, so nothing looks broken — the ` + + `declared chart family is simply gone.`, + hint: + `Plot the chart against a dataset dimension — dimensions: [''] ` + + `(declared dimensions: ${list(dimensionNames)}) — or, if a single value ` + + `IS what this tile should show, declare it as a 'metric' or 'kpi' widget ` + + `so the type matches what renders. Suppress with: ` + + `suppressWarnings: ['${CHART_DIMENSIONS_MISSING}']`, + }); + } + } + // ── (e) table/pivot bound to a count-only, dimensionless selection ── if (w.type !== 'table' && w.type !== 'pivot') continue; // Grouped by at least one dimension → genuinely aggregated rows.