Skip to content

Commit 078d250

Browse files
committed
feat(lint): error (not warn) when a legacy dashboard widget binds no data source (#1878/#1894)
Escalation of the legacy-analytics-shape rule for the case that is an outright bug rather than deprecated-but-working: a widget that uses the removed pre-ADR-0021 inline keys (categoryField/rowField/valueField/…) as its ONLY data wiring — no `dataset`, no `object`, no inline `data`. The renderer reads only the dataset path, so such a widget has no data at all and renders nothing. New rule `widget-legacy-analytics-unrenderable` (severity error) fires for exactly that case; the existing `widget-legacy-analytics-shape` stays a suppressible warning when a data source IS present (the widget still renders, the legacy keys are just ignored noise). Rationale (AI-writes / human-reviews): a silently-blank widget is precisely what a human reviewer misses. Because validateWidgetBindings runs only in the CLI author path (`objectstack compile`/`validate`/`doctor`/`lint`, never in Studio's runtime authoring), this fails the AI/code build in CI without touching Studio. It only fails builds that are already broken (the widget renders nothing regardless), so it surfaces an existing defect rather than introducing a new gate. Errors are not suppressible. Framework examples/fixtures carry no legacy-key dashboards, so nothing regresses. Tests: 3 new cases (unrenderable→error, object-bound→warning, error-not-suppressible); full suite 40 passed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LddW4NaQBdf5FTEnBPpnUJ
1 parent 49d1b4e commit 078d250

2 files changed

Lines changed: 103 additions & 19 deletions

File tree

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
CHART_CONFIG_MISSING,
1010
MEASURE_AGGREGATE_INCOHERENT,
1111
WIDGET_LEGACY_ANALYTICS_SHAPE,
12+
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
1213
} from './validate-widget-bindings.js';
1314

1415
/** The downstream repro from issue #1719 — dataset with a count AND a sum
@@ -431,4 +432,54 @@ describe('validateWidgetBindings — legacy analytics shape (#1878/#1894)', () =
431432
})));
432433
expect(findings).toHaveLength(0);
433434
});
435+
436+
// ── error escalation (②): legacy keys as the ONLY data wiring ──
437+
438+
const legacyOnly = (findings: ReturnType<typeof validateWidgetBindings>) =>
439+
findings.filter((f) => f.rule === WIDGET_LEGACY_ANALYTICS_UNRENDERABLE);
440+
441+
it('ERRORS on a legacy chart with no dataset/object/data — it renders nothing', () => {
442+
const stack = {
443+
dashboards: [{
444+
name: 'broken_dash',
445+
label: 'Broken',
446+
widgets: [{
447+
id: 'orphan_chart',
448+
type: 'bar',
449+
categoryField: 'status',
450+
valueField: 'amount',
451+
aggregate: 'sum',
452+
layout: { x: 0, y: 0, w: 6, h: 4 },
453+
}],
454+
}],
455+
};
456+
const findings = legacyOnly(validateWidgetBindings(stack));
457+
expect(findings).toHaveLength(1);
458+
expect(findings[0].severity).toBe('error');
459+
expect(findings[0].message).toMatch(/renders nothing/i);
460+
});
461+
462+
it('does NOT error when an object binding is present (legacy path still renders) — warns instead', () => {
463+
const stack = {
464+
dashboards: [{
465+
name: 'legacy_dash', label: 'Legacy',
466+
widgets: [{ id: 'obj_pivot', type: 'pivot', object: 'task', rowField: 'status', columnField: 'priority', valueField: 'id', aggregation: 'count', layout: { x: 0, y: 0, w: 6, h: 4 } }],
467+
}],
468+
};
469+
const findings = validateWidgetBindings(stack);
470+
expect(findings.filter((f) => f.rule === WIDGET_LEGACY_ANALYTICS_UNRENDERABLE)).toHaveLength(0);
471+
expect(findings.filter((f) => f.rule === WIDGET_LEGACY_ANALYTICS_SHAPE)).toHaveLength(1);
472+
expect(findings.find((f) => f.rule === WIDGET_LEGACY_ANALYTICS_SHAPE)!.severity).toBe('warning');
473+
});
474+
475+
it('the unrenderable error is NOT suppressible', () => {
476+
const stack = {
477+
dashboards: [{
478+
name: 'broken_dash', label: 'Broken',
479+
widgets: [{ id: 'orphan', type: 'pie', categoryField: 'status', suppressWarnings: [WIDGET_LEGACY_ANALYTICS_UNRENDERABLE], layout: { x: 0, y: 0, w: 6, h: 4 } }],
480+
}],
481+
};
482+
// errors ignore suppressWarnings — a blank widget must not be silenceable
483+
expect(legacyOnly(validateWidgetBindings(stack))).toHaveLength(1);
484+
});
434485
});

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

Lines changed: 52 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,13 @@ import { isIncoherentAggregate } from '@objectstack/spec/data';
2727
* (`values`). Post-cutover (ADR-0021) the result rows are keyed by
2828
* measure NAME (e.g. `sum_amount`), not the base column (`amount`) — a
2929
* stale base-column reference renders the axis but an empty series.
30+
* - `widget-legacy-analytics-unrenderable` (#1878/#1894) — a widget uses the
31+
* removed pre-ADR-0021 inline-analytics shape (`categoryField`/`rowField`/…)
32+
* as its ONLY data wiring: no `dataset`, no `object`, no inline `data`. The
33+
* renderer reads only the dataset path, so the widget has no data at all and
34+
* renders nothing. Errored (not warned) so this class of authoring mistake —
35+
* very often an AI emitting a removed shape — fails the build instead of
36+
* shipping a blank widget past human review.
3037
*
3138
* Advisory rules — severity `warning`, build stays green:
3239
*
@@ -64,6 +71,7 @@ export const CHART_CONFIG_MISSING = 'chart-config-missing';
6471
export const TABLE_COUNT_ONLY = 'table-count-only';
6572
export const MEASURE_AGGREGATE_INCOHERENT = 'measure-aggregate-incoherent';
6673
export const WIDGET_LEGACY_ANALYTICS_SHAPE = 'widget-legacy-analytics-shape';
74+
export const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = 'widget-legacy-analytics-unrenderable';
6775

6876
/**
6977
* Pre-ADR-0021 inline-analytics keys. The single-form cutover replaced them
@@ -250,28 +258,53 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
250258
findings.push({ ...f, where, path });
251259
};
252260

253-
// ── (a0) legacy pre-ADR-0021 analytics shape → advisory ──
261+
// ── (a0) legacy pre-ADR-0021 analytics shape ──
254262
// Steer authors (very often an AI) off the removed inline shape and onto
255-
// the semantic-layer `dataset`+`dimensions`+`values`. Fires whether or
256-
// not a dataset is also present, because the renderer ignores these keys
257-
// either way — a dataset-bound widget carrying `categoryField` silently
258-
// drops it. Suppressible per widget; never fails the build.
263+
// the semantic-layer `dataset`+`dimensions`+`values`. The renderer reads
264+
// ONLY the dataset path, so these keys are dead. Two severities:
265+
// • ERROR — the legacy keys are the widget's only (dead) data wiring
266+
// (no dataset / object / inline data): it renders nothing.
267+
// • warning — a data source is present, so the widget still renders and
268+
// the legacy keys are merely ignored noise (suppressible).
259269
const legacyUsed = LEGACY_ANALYTICS_KEYS.filter((k) => w[k] !== undefined);
260270
if (legacyUsed.length > 0) {
261-
push({
262-
severity: 'warning',
263-
rule: WIDGET_LEGACY_ANALYTICS_SHAPE,
264-
message:
265-
`sets legacy analytics key${legacyUsed.length > 1 ? 's' : ''} ` +
266-
`${legacyUsed.map((k) => `\`${k}\``).join(', ')} that the ADR-0021 ` +
267-
`single-form cutover removed — the dashboard renderer ignores ${legacyUsed.length > 1 ? 'them' : 'it'}.`,
268-
hint:
269-
`Bind a semantic dataset and select fields BY NAME instead: ` +
270-
`\`dataset: '<name>', dimensions: [...], values: [...]\`. ` +
271-
`Dataset-bound widgets render through DatasetWidget (pivot rows/cols come from ` +
272-
`\`dimensions\`, cell values from \`values\`); these inline keys are a no-op. ` +
273-
`Suppress with suppressWarnings: ['${WIDGET_LEGACY_ANALYTICS_SHAPE}'] if intentional.`,
274-
});
271+
const optionsData =
272+
typeof w.options === 'object' && w.options !== null &&
273+
(w.options as AnyRec).data !== undefined;
274+
const hasDataSource =
275+
w.dataset !== undefined || w.object !== undefined ||
276+
w.data !== undefined || optionsData;
277+
const keyList = legacyUsed.map((k) => `\`${k}\``).join(', ');
278+
const plural = legacyUsed.length > 1;
279+
const datasetHint =
280+
`Bind a semantic dataset and select fields BY NAME: ` +
281+
`\`dataset: '<name>', dimensions: [...], values: [...]\`. ` +
282+
`Dataset-bound widgets render through DatasetWidget (pivot rows/cols come from ` +
283+
`\`dimensions\`, cell values from \`values\`).`;
284+
if (!hasDataSource) {
285+
push({
286+
severity: 'error',
287+
rule: WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
288+
message:
289+
`sets legacy analytics key${plural ? 's' : ''} ${keyList} ` +
290+
`(removed by the ADR-0021 single-form cutover) and binds no data source ` +
291+
`(no \`dataset\`, \`object\`, or inline \`data\`) — it renders nothing.`,
292+
hint:
293+
`${datasetHint} The renderer ignores the legacy keys, so without a data ` +
294+
`source this widget has no data at all.`,
295+
});
296+
} else {
297+
push({
298+
severity: 'warning',
299+
rule: WIDGET_LEGACY_ANALYTICS_SHAPE,
300+
message:
301+
`sets legacy analytics key${plural ? 's' : ''} ${keyList} that the ADR-0021 ` +
302+
`single-form cutover removed — the dashboard renderer ignores ${plural ? 'them' : 'it'}.`,
303+
hint:
304+
`${datasetHint} These inline keys are a no-op. ` +
305+
`Suppress with suppressWarnings: ['${WIDGET_LEGACY_ANALYTICS_SHAPE}'] if intentional.`,
306+
});
307+
}
275308
}
276309

277310
// ── (a) dataset reference resolves ──

0 commit comments

Comments
 (0)