Skip to content

Commit 347b777

Browse files
claude[bot]claude
andauthored
fix(lint): chart-config-missing no longer fires on a widget whose binding the renderer derives (#15461)
`chart-config-missing` warned on every chart-family widget with no `chartConfig`, claiming the renderer "cannot determine which measure to plot, so the series renders empty". Read at the `@object-ui` revision this repo pins (`.objectui-sha`), that is false: `DatasetWidget` derives the x-axis key and one series per measure from the widget's own `dimensions`/`values` (`buildChartSeries`) and refuses an authored `ChartAxis.field` / `ChartSeries.name`. `chartConfig` carries presentation only. The false finding landed on this platform's own shipped `system_overview` tiles. One true arm survives, under the same rule id: a `combo` widget with no `chartConfig` takes no per-series mark, so every measure draws with the same default and the chart is not a combination at all. Its message now names that consequence. Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk Co-authored-by: Claude <noreply@anthropic.com>
1 parent 845d767 commit 347b777

3 files changed

Lines changed: 214 additions & 42 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/lint': patch
3+
---
4+
5+
`chart-config-missing` no longer fires on a widget whose binding the renderer derives
6+
7+
The rule warned on every chart-family widget that declared no `chartConfig`, on the
8+
stated grounds that "the renderer cannot determine which measure to plot, so the series
9+
renders empty". Measured against the `@object-ui` revision this repo pins
10+
(`.objectui-sha`), that consequence is false: `DatasetWidget` derives the x-axis key and
11+
one series per measure from the widget's own `dimensions` / `values` via
12+
`buildChartSeries`, and refuses an authored `ChartAxis.field` / `ChartSeries.name`
13+
outright — `chartConfig` carries presentation only. The renderer pins this by name:
14+
"ignores an authored axis `field` and keeps the derived axis binding", "ignores an
15+
authored series and keeps one derived series per measure", "emits none of the
16+
presentation keys when no chartConfig is declared".
17+
18+
The false finding was landing on this platform's own shipped metadata — the
19+
`system_overview` dashboard's pie and bar tiles, on the Setup board every customer opens
20+
first — which is the ADR-0072 D1 cost the rule family exists to avoid.
21+
22+
The rule id is unchanged and keeps one true arm: a `combo` widget with no `chartConfig`,
23+
whose per-series mark is authored as `chartConfig.series[].type` and has no other
24+
channel, so every measure draws with the same default mark and the chart is not a
25+
combination at all. Its message now names that consequence instead of the binding.
26+
An existing `suppressWarnings: ['chart-config-missing']` entry stays valid.

packages/lint/src/validate-widget-bindings.test.ts

Lines changed: 87 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, it, expect } from 'vitest';
2+
import { ChartTypeSchema } from '@objectstack/spec/ui';
23
import { runAuthoringRules, splitBySeverity } from './authoring-rules.js';
34
import {
45
validateWidgetBindings,
6+
MARK_MIXING_CHART_TYPES,
57
TABLE_COUNT_ONLY,
68
WIDGET_DATASET_UNKNOWN,
79
WIDGET_DIMENSION_UNKNOWN,
@@ -192,17 +194,25 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => {
192194
expect(findings[0].hint).toContain('Add "ticket_count" to the widget\'s values');
193195
});
194196

195-
it('(d) warns when a chart-type widget has no chartConfig at all', () => {
196-
const findings = validateWidgetBindings(chartStack({ chartConfig: undefined }));
197+
it('(d) warns when a `combo` widget has no chartConfig at all', () => {
198+
const findings = validateWidgetBindings(chartStack({ type: 'combo', chartConfig: undefined }));
197199
expect(findings).toHaveLength(1);
198200
expect(findings[0].severity).toBe('warning');
199201
expect(findings[0].rule).toBe(CHART_CONFIG_MISSING);
200-
expect(findings[0].message).toContain("'bar'");
202+
expect(findings[0].message).toContain("'combo'");
203+
// The message names the REAL consequence — the mark, not the binding. The
204+
// old wording ("cannot determine which measure to plot, so the series
205+
// renders empty") was false against the pinned renderer (#14436), so it is
206+
// pinned here as absent rather than merely replaced.
207+
expect(findings[0].message).toContain('per-series mark');
208+
expect(findings[0].message).not.toContain('renders empty');
209+
expect(findings[0].hint).toContain('chartConfig: { series: [{ name:');
201210
expect(findings[0].hint).toContain(`suppressWarnings: ['${CHART_CONFIG_MISSING}']`);
202211
});
203212

204213
it('(d) missing chartConfig is suppressible per widget', () => {
205214
expect(validateWidgetBindings(chartStack({
215+
type: 'combo',
206216
chartConfig: undefined,
207217
suppressWarnings: [CHART_CONFIG_MISSING],
208218
}))).toHaveLength(0);
@@ -214,20 +224,85 @@ describe('validateWidgetBindings (reference integrity, issue #1721)', () => {
214224
}
215225
});
216226

217-
it('(d) every multi-series family in the taxonomy warns — including a newly added one', () => {
218-
// The set of chart families that need a measure mapping is derived from
219-
// `ChartTypeSchema`, so a family added to the taxonomy is covered without
220-
// editing a list here. `combo` is the case that proved it: as a hand-written
221-
// list, an unlisted family read as "not a chart" and the missing mapping
222-
// went unreported. objectui#2945.
227+
it('(d) [#14436] a chart family whose binding the renderer DERIVES is silent', () => {
228+
// The over-reach this rule shipped with. `DatasetWidget` derives the axis
229+
// key from `dimensions[0]` and one series per `values` entry, and refuses an
230+
// authored `ChartAxis.field` / `ChartSeries.name` — pinned in objectui's
231+
// `DatasetWidget.chartConfig.test.tsx` by the names quoted in the rule's
232+
// docblock. So a bar/line/pie with a resolvable selection and no
233+
// `chartConfig` renders correctly, and a warning on it is a false finding.
223234
for (const type of ['bar', 'horizontal-bar', 'column', 'line', 'area', 'pie',
224-
'donut', 'funnel', 'scatter', 'treemap', 'sankey', 'radar', 'combo']) {
235+
'donut', 'funnel', 'scatter', 'treemap', 'sankey', 'radar']) {
225236
const findings = validateWidgetBindings(chartStack({ type, chartConfig: undefined }));
226-
expect(findings, `expected a warning for chart type '${type}'`).toHaveLength(1);
227-
expect(findings[0].rule).toBe(CHART_CONFIG_MISSING);
237+
expect(findings, `expected NO finding for chart type '${type}'`).toHaveLength(0);
228238
}
229239
});
230240

241+
it('(d) [#14436] every mark-mixing family is a declared chart type', () => {
242+
// objectui#2945's lesson, kept as a check rather than as a derivation:
243+
// membership is a fact about the RENDERER (which families carry their shape
244+
// in `chartConfig`), which the taxonomy cannot know — but a member that is
245+
// not a chart type at all is a typo or a retired family, and reds here.
246+
for (const type of MARK_MIXING_CHART_TYPES) {
247+
expect(ChartTypeSchema.options, `'${type}' is not a declared chart type`)
248+
.toContain(type);
249+
}
250+
});
251+
252+
it('(d) [#14436] the SHIPPED `system_overview` chart tiles report nothing', () => {
253+
// The card's repro, as a fixture rather than a cross-package import: the two
254+
// Row 3 widgets of `packages/platform-objects/src/apps/dashboards/
255+
// system_overview.dashboard.ts`, bound to the `sys_audit_log_metrics`
256+
// dataset from `system.datasets.ts` in the same directory. Copied verbatim
257+
// (one dimension, one measure, no `chartConfig`) because that is the exact
258+
// shape the rule used to warn on — the platform's OWN metadata, on the
259+
// Setup dashboard every customer opens first.
260+
//
261+
// Kept as a shape rather than a snapshot: if either widget later gains a
262+
// `chartConfig` this test still asserts the thing that matters, which is
263+
// that not declaring one is not a finding.
264+
const findings = validateWidgetBindings({
265+
datasets: [{
266+
name: 'sys_audit_log_metrics',
267+
label: 'Audit Log Metrics',
268+
object: 'sys_audit_log',
269+
dimensions: [
270+
{ name: 'action', label: 'Action', field: 'action', type: 'string' },
271+
{ name: 'user_id', label: 'User', field: 'user_id', type: 'lookup' },
272+
],
273+
measures: [{ name: 'event_count', label: 'Events', aggregate: 'count' }],
274+
}],
275+
dashboards: [{
276+
name: 'system_overview',
277+
label: 'System Overview',
278+
widgets: [
279+
{
280+
id: 'widget_events_by_type',
281+
dataset: 'sys_audit_log_metrics', dimensions: ['action'], values: ['event_count'],
282+
title: 'Audit Events by Action',
283+
type: 'pie',
284+
layout: { x: 0, y: 4, w: 6, h: 4 },
285+
},
286+
{
287+
id: 'widget_events_by_user',
288+
dataset: 'sys_audit_log_metrics', dimensions: ['user_id'], values: ['event_count'],
289+
title: 'Events by User',
290+
type: 'bar',
291+
layout: { x: 6, y: 4, w: 6, h: 4 },
292+
},
293+
],
294+
}],
295+
});
296+
expect(findings).toEqual([]);
297+
});
298+
299+
it('(d) [#14436] a combo that DOES declare its per-series marks is clean', () => {
300+
expect(validateWidgetBindings(chartStack({
301+
type: 'combo',
302+
chartConfig: { series: [{ name: 'sum_amount', type: 'line' }] },
303+
}))).toHaveLength(0);
304+
});
305+
231306
it('errors are NOT suppressible via suppressWarnings', () => {
232307
const findings = validateWidgetBindings(chartStack({
233308
dataset: 'no_such_dataset',

packages/lint/src/validate-widget-bindings.ts

Lines changed: 101 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { isIncoherentAggregate } from '@objectstack/spec/data';
4-
import { ChartTypeSchema } from '@objectstack/spec/ui';
54

65
import { walkFilterFieldKeys } from './filter-walk.js';
76
import {
@@ -77,8 +76,13 @@ import {
7776
*
7877
* Advisory rules — severity `warning`, build stays green:
7978
*
80-
* - `chart-config-missing` — a chart-type widget (bar/line/pie/…) has no
81-
* `chartConfig`, so the renderer cannot tell which measure to plot.
79+
* - `chart-config-missing` — a `combo` widget has no `chartConfig`, so no
80+
* series carries a mark and the combination chart draws as one uniform
81+
* family. Narrowed to `combo` because `chartConfig` carries NO binding:
82+
* `DatasetWidget` derives the axis and one series per measure from the
83+
* widget's own `dimensions` / `values`, and actively REFUSES an authored
84+
* `ChartAxis.field` / `ChartSeries.name`. See "What the renderer actually
85+
* derives" below for the pinned contract this now mirrors.
8286
* - `table-count-only` (#1719) — a `table`/`pivot` widget whose selected
8387
* measures are ALL `aggregate: 'count'` and which declares no
8488
* `dimensions` asks the analytics service for a single summary row. That
@@ -191,6 +195,65 @@ import {
191195
* through the flat `SYSTEM_FIELDS` union, which is what lets a reference to
192196
* `owner_id` on an `ownership: 'none'` object stay a real finding.
193197
*
198+
* ### What the renderer actually derives (#14436)
199+
*
200+
* `chart-config-missing` used to fire on EVERY chart family that was not a
201+
* single-value or tabular type, on the stated grounds that without
202+
* `chartConfig` "the renderer cannot determine which measure to plot, so the
203+
* series renders empty". That consequence was false, and it fired on this
204+
* platform's OWN shipped metadata: the `system_overview` dashboard draws a pie
205+
* and a bar, each with one dimension and one measure and no `chartConfig`, and
206+
* both render correctly. A warning-tier rule that mis-fires on first-party
207+
* metadata is the ADR-0072 D1 cost the whole family exists to avoid — it
208+
* teaches every reader, human and agent, that this family is noise.
209+
*
210+
* The renderer's contract, read at the `@object-ui` revision this repo PINS
211+
* (`.objectui-sha`), not at objectui's `origin/main` — the Console ships the
212+
* pin, so the exemption is written against the behaviour the pin has:
213+
*
214+
* - `DatasetWidget` calls `buildChartSeries(rows, dimensions, values, …)`,
215+
* which returns `{ data, xAxisKey, series }` — the x-axis key is
216+
* `dimensions[0]` and there is exactly one series per entry of `values`.
217+
* `chartConfig` is not an argument to it.
218+
* - The authored `chartConfig` reaches the chart only through
219+
* `mergeAuthoredPresentation` (per-series/axis PRESENTATION merged ONTO
220+
* those derived bindings) and `chartConfigPresentation` (chrome: titles,
221+
* height, colours, data labels, annotations, legend).
222+
* - The BINDING half of an authored `chartConfig` is refused outright. The
223+
* renderer pins this by name in
224+
* `packages/plugin-dashboard/src/__tests__/DatasetWidget.chartConfig.test.tsx`:
225+
* *"ignores an authored axis `field` and keeps the derived axis binding"*,
226+
* *"ignores an authored series and keeps one derived series per measure"*,
227+
* *"ignores `chartConfig.type` — the widget type owns the chart family"*,
228+
* and *"emits none of the presentation keys when no chartConfig is
229+
* declared"*.
230+
*
231+
* So for every chart family except one, a missing `chartConfig` costs the
232+
* widget nothing at all, and ADDING one could not have repaired a widget whose
233+
* selection is empty either — `chart-field-unknown` above refuses a
234+
* `yAxis[].field` that names anything the widget did not select, so
235+
* `chartConfig` can never supply a measure the `values` array is missing.
236+
*
237+
* **The one surviving arm is `combo`**, and it is a real loss rather than an
238+
* inferred one. A combination chart's whole identity is a per-series MARK, and
239+
* that mark is authored as `chartConfig.series[].type` — presentation, so it
240+
* does merge forward. `mergeAuthoredSeries` states the default it falls back
241+
* to: *"a derived series with no authored entry keeps the family default"*,
242+
* and `DatasetWidget.comboPresentation.test.tsx` records what that cost when
243+
* the merge was missing — *"Without it the line measure drew as the second
244+
* bar."* A `combo` with no `chartConfig` therefore draws every measure with
245+
* one mark: the author asked for a combination and got a plain one. Warning
246+
* rather than error, because the numbers are right and the chart renders — it
247+
* is the shape that is wrong.
248+
*
249+
* ⚠️ Two genuinely un-renderable shapes are NOT this rule's, and are
250+
* deliberately left unreported here rather than folded in under an id that
251+
* would then misname its own condition: a chart-type widget selecting NO
252+
* measures (the pin renders an explicit "Pick measures (values)" placeholder)
253+
* and one selecting no dimensions (the pin's `isMetric` covers
254+
* `dimensions.length === 0`, so it renders as a KPI number instead of the
255+
* family asked for). Neither is caused by, nor repairable with, `chartConfig`.
256+
*
194257
* Warnings can be deliberately suppressed per widget via
195258
* `suppressWarnings: ['<rule-id>']`; errors cannot — they describe a
196259
* binding the analytics service cannot satisfy.
@@ -274,28 +337,26 @@ function asStrings(v: unknown): string[] {
274337
}
275338

276339
/**
277-
* Chart families that plot a single value or every column, and so need no
278-
* `chartConfig` measure mapping: single-value types plot their lone value,
279-
* tabular types render each column as-is.
280-
*/
281-
const MEASURE_EXEMPT_CHART_TYPES = new Set([
282-
'gauge', 'solid-gauge', 'metric', 'kpi', 'bullet',
283-
'table', 'pivot',
284-
]);
285-
286-
/**
287-
* Chart families whose renderer needs a `chartConfig` measure mapping — the
288-
* taxonomy minus the exemptions above.
340+
* Chart families whose rendered SHAPE depends on `chartConfig` — today exactly
341+
* one, `combo`, whose per-series mark is authored as `chartConfig.series[].type`
342+
* and has no other channel.
343+
*
344+
* ⛔ This is NOT "the families that need a measure mapping". No family needs one:
345+
* `DatasetWidget` derives the axis key and one series per measure from the
346+
* widget's `dimensions` / `values`, and refuses an authored `ChartAxis.field` /
347+
* `ChartSeries.name` outright — the rule docblock's "What the renderer actually
348+
* derives" carries the pinned test names that establish it (#14436). The
349+
* previous spelling of this set was the taxonomy minus a hand-written exemption
350+
* list, which reported every bar, line and pie that never wrote a `chartConfig`
351+
* — including this platform's own `system_overview` tiles.
289352
*
290-
* Derived from `ChartTypeSchema` rather than restated. As a hand-written list it
291-
* had no way to know when the taxonomy grew, and the omission is silent in
292-
* exactly the wrong direction: an unlisted family is treated as "not a chart",
293-
* so a widget missing its measure mapping passes validation instead of being
294-
* reported. objectui#2945.
353+
* Membership is checked against `ChartTypeSchema` in this rule's tests rather
354+
* than derived from it: a family belongs here because of what its RENDERER does
355+
* with `chartConfig`, which a taxonomy cannot know. objectui#2945's lesson —
356+
* that a hand-written list cannot notice the taxonomy growing — survives as
357+
* that check: a member that stops being a declared chart type reds.
295358
*/
296-
const CHART_TYPES = new Set<string>(
297-
ChartTypeSchema.options.filter(t => !MEASURE_EXEMPT_CHART_TYPES.has(t)),
298-
);
359+
export const MARK_MIXING_CHART_TYPES = new Set<string>(['combo']);
299360

300361
function list(names: Iterable<string>): string {
301362
const arr = [...names];
@@ -863,7 +924,11 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
863924
const chartConfig = (w.chartConfig && typeof w.chartConfig === 'object')
864925
? (w.chartConfig as AnyRec)
865926
: undefined;
866-
const isChartType = typeof w.type === 'string' && CHART_TYPES.has(w.type);
927+
// [#14436] Not "is this a chart" — the renderer derives every chart's
928+
// binding from `dimensions`/`values`. This asks the only question a
929+
// MISSING `chartConfig` can still answer badly: does this family carry
930+
// its shape in `chartConfig`?
931+
const isMarkMixing = typeof w.type === 'string' && MARK_MIXING_CHART_TYPES.has(w.type);
867932

868933
if (chartConfig) {
869934
// The query result carries the widget's selected dimensions and
@@ -916,17 +981,23 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
916981
const name = series[k]?.name;
917982
if (typeof name === 'string') measureField(`series[${k}].name`, name);
918983
}
919-
} else if (isChartType) {
984+
} else if (isMarkMixing) {
920985
push({
921986
severity: 'warning',
922987
rule: CHART_CONFIG_MISSING,
923988
message:
924-
`chart-type widget ('${w.type}') has no chartConfig — the renderer ` +
925-
`cannot determine which measure to plot, so the series renders empty.`,
989+
`'${w.type}' widget has no chartConfig — a combination chart takes its ` +
990+
`per-series mark from \`chartConfig.series[].type\`, and with none declared ` +
991+
`every measure (${list(values)}) draws with the same default mark, so the ` +
992+
`chart is not a combination at all. The data and the axis are unaffected: ` +
993+
`the renderer derives those from this widget's dimensions and values.`,
926994
hint:
927-
`Add chartConfig with xAxis.field set to a dimension (${list(dims)}) and ` +
928-
`yAxis[].field set to a measure name (${list(values)}). If the default ` +
929-
`rendering is intentional, suppress with: suppressWarnings: ['${CHART_CONFIG_MISSING}']`,
995+
`Give each measure its mark — chartConfig: { series: [{ name: '<measure>', ` +
996+
`type: 'bar' | 'line' | 'area' }] } — naming measures this widget selects ` +
997+
`(${list(values)}). \`series[].name\` selects WHICH derived series the mark ` +
998+
`lands on; it cannot add, remove or re-point one. If one uniform mark is ` +
999+
`intentional, prefer that family's own widget type, or suppress with: ` +
1000+
`suppressWarnings: ['${CHART_CONFIG_MISSING}']`,
9301001
});
9311002
}
9321003

0 commit comments

Comments
 (0)