Skip to content

Commit 78b74b4

Browse files
os-zhuangclaude
andauthored
feat(lint): catch legacy pre-ADR-0021 dashboard analytics keys at author time (#1878/#1894) (#3238)
* feat(lint): warn on legacy pre-ADR-0021 dashboard analytics keys (#1878/#1894) An author — very often an AI — can still write the removed inline-analytics shape (categoryField/valueField/xAxisField/yAxisFields/aggregate/aggregation/ rowField/columnField) on a dashboard widget. Post-ADR-0021 the renderer routes dataset-bound widgets through DatasetWidget and never reads these keys, so authoring one is a silent no-op — exactly the "AI writes a dead property" failure the liveness audit targets, but on the authoring side. Add a `widget-legacy-analytics-shape` advisory rule to validateWidgetBindings (already wired into `objectstack compile`): when a widget carries any legacy key, emit a warning steering the author to `dataset` + `dimensions` + `values`, with the fix spelled out. Fires whether or not a dataset is also present (the keys are dead either way) and closes the gap where a dataset-less legacy widget was skipped silently. Warning-only, per-widget suppressible via `suppressWarnings`; never fails the build. Tests: 4 new cases (dataset-bound legacy key, dataset-less legacy pivot, clean dataset widget, suppression); full validate-widget-bindings suite green (37 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LddW4NaQBdf5FTEnBPpnUJ * 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1e145eb commit 78b74b4

2 files changed

Lines changed: 181 additions & 0 deletions

File tree

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

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import {
88
CHART_FIELD_UNKNOWN,
99
CHART_CONFIG_MISSING,
1010
MEASURE_AGGREGATE_INCOHERENT,
11+
WIDGET_LEGACY_ANALYTICS_SHAPE,
12+
WIDGET_LEGACY_ANALYTICS_UNRENDERABLE,
1113
} from './validate-widget-bindings.js';
1214

1315
/** The downstream repro from issue #1719 — dataset with a count AND a sum
@@ -380,3 +382,104 @@ describe('validateWidgetBindings (measure-aggregate-incoherent — rate aggregat
380382
expect(validateWidgetBindings(stack)).toHaveLength(0);
381383
});
382384
});
385+
386+
describe('validateWidgetBindings — legacy analytics shape (#1878/#1894)', () => {
387+
const only = (findings: ReturnType<typeof validateWidgetBindings>) =>
388+
findings.filter((f) => f.rule === WIDGET_LEGACY_ANALYTICS_SHAPE);
389+
390+
it('warns (not errors) when a dataset-bound widget also carries a legacy key', () => {
391+
// valueField is dead once the widget is dataset-bound; steer the author off it.
392+
const findings = only(validateWidgetBindings(reproStack({ valueField: 'total_amount' })));
393+
expect(findings).toHaveLength(1);
394+
expect(findings[0].severity).toBe('warning');
395+
expect(findings[0].message).toContain('`valueField`');
396+
expect(findings[0].hint).toMatch(/dataset.*dimensions.*values/is);
397+
});
398+
399+
it('warns on a legacy pivot widget that has NO dataset (previously skipped silently)', () => {
400+
const stack = {
401+
dashboards: [{
402+
name: 'legacy_dash',
403+
label: 'Legacy',
404+
widgets: [{
405+
id: 'legacy_pivot',
406+
type: 'pivot',
407+
object: 'task',
408+
rowField: 'status',
409+
columnField: 'priority',
410+
valueField: 'id',
411+
aggregation: 'count',
412+
layout: { x: 0, y: 0, w: 6, h: 4 },
413+
}],
414+
}],
415+
};
416+
const findings = only(validateWidgetBindings(stack));
417+
expect(findings).toHaveLength(1);
418+
// all legacy keys reported in one finding
419+
expect(findings[0].message).toContain('`rowField`');
420+
expect(findings[0].message).toContain('`columnField`');
421+
expect(findings[0].message).toContain('`aggregation`');
422+
});
423+
424+
it('does NOT warn on a clean dataset-shaped widget', () => {
425+
expect(only(validateWidgetBindings(reproStack()))).toHaveLength(0);
426+
});
427+
428+
it('is suppressible per widget via suppressWarnings', () => {
429+
const findings = only(validateWidgetBindings(reproStack({
430+
categoryField: 'cost_center',
431+
suppressWarnings: [WIDGET_LEGACY_ANALYTICS_SHAPE],
432+
})));
433+
expect(findings).toHaveLength(0);
434+
});
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+
});
485+
});

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

Lines changed: 78 additions & 0 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
*
@@ -44,6 +51,12 @@ import { isIncoherentAggregate } from '@objectstack/spec/data';
4451
* `count_distinct`) of a `percent`/rate field, whose total routinely
4552
* exceeds 100%. Rates must AVG. Checked once per dataset (independent of
4653
* any widget) when the bound object's field types are known.
54+
* - `widget-legacy-analytics-shape` (#1878/#1894) — a widget sets a
55+
* pre-ADR-0021 inline key (`categoryField`/`valueField`/`xAxisField`/
56+
* `yAxisFields`/`aggregate`/`aggregation`/`rowField`/`columnField`) that the
57+
* single-form cutover removed. The dashboard renderer routes dataset-bound
58+
* widgets through `DatasetWidget` and never reads these, so they are a
59+
* silent no-op. Steers the author onto `dataset`+`dimensions`+`values`.
4760
*
4861
* Warnings can be deliberately suppressed per widget via
4962
* `suppressWarnings: ['<rule-id>']`; errors cannot — they describe a
@@ -57,6 +70,22 @@ export const CHART_FIELD_UNKNOWN = 'chart-field-unknown';
5770
export const CHART_CONFIG_MISSING = 'chart-config-missing';
5871
export const TABLE_COUNT_ONLY = 'table-count-only';
5972
export const MEASURE_AGGREGATE_INCOHERENT = 'measure-aggregate-incoherent';
73+
export const WIDGET_LEGACY_ANALYTICS_SHAPE = 'widget-legacy-analytics-shape';
74+
export const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = 'widget-legacy-analytics-unrenderable';
75+
76+
/**
77+
* Pre-ADR-0021 inline-analytics keys. The single-form cutover replaced them
78+
* with the semantic-layer shape (`dataset` + `dimensions` + `values`); the
79+
* dashboard renderer routes dataset-bound widgets through `DatasetWidget` and
80+
* never reads these, so authoring one today is a silent no-op. Warned (not
81+
* errored) because they still parse and a legacy object-bound widget keeps
82+
* rendering — the author is just being steered to the governed shape.
83+
* (liveness audit #1878 / #1894).
84+
*/
85+
const LEGACY_ANALYTICS_KEYS = [
86+
'categoryField', 'valueField', 'xAxisField', 'yAxisFields',
87+
'aggregate', 'aggregation', 'rowField', 'columnField',
88+
] as const;
6089

6190
export type WidgetBindingSeverity = 'error' | 'warning';
6291

@@ -229,6 +258,55 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
229258
findings.push({ ...f, where, path });
230259
};
231260

261+
// ── (a0) legacy pre-ADR-0021 analytics shape ──
262+
// Steer authors (very often an AI) off the removed inline shape and onto
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).
269+
const legacyUsed = LEGACY_ANALYTICS_KEYS.filter((k) => w[k] !== undefined);
270+
if (legacyUsed.length > 0) {
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+
}
308+
}
309+
232310
// ── (a) dataset reference resolves ──
233311
const dsName = typeof w.dataset === 'string' ? w.dataset : undefined;
234312
const dataset = dsName ? datasets.get(dsName) : undefined;

0 commit comments

Comments
 (0)