Skip to content

Commit 49d1b4e

Browse files
committed
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
1 parent 21bd3dd commit 49d1b4e

2 files changed

Lines changed: 97 additions & 0 deletions

File tree

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

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

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

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

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ import { isIncoherentAggregate } from '@objectstack/spec/data';
4444
* `count_distinct`) of a `percent`/rate field, whose total routinely
4545
* exceeds 100%. Rates must AVG. Checked once per dataset (independent of
4646
* any widget) when the bound object's field types are known.
47+
* - `widget-legacy-analytics-shape` (#1878/#1894) — a widget sets a
48+
* pre-ADR-0021 inline key (`categoryField`/`valueField`/`xAxisField`/
49+
* `yAxisFields`/`aggregate`/`aggregation`/`rowField`/`columnField`) that the
50+
* single-form cutover removed. The dashboard renderer routes dataset-bound
51+
* widgets through `DatasetWidget` and never reads these, so they are a
52+
* silent no-op. Steers the author onto `dataset`+`dimensions`+`values`.
4753
*
4854
* Warnings can be deliberately suppressed per widget via
4955
* `suppressWarnings: ['<rule-id>']`; errors cannot — they describe a
@@ -57,6 +63,21 @@ export const CHART_FIELD_UNKNOWN = 'chart-field-unknown';
5763
export const CHART_CONFIG_MISSING = 'chart-config-missing';
5864
export const TABLE_COUNT_ONLY = 'table-count-only';
5965
export const MEASURE_AGGREGATE_INCOHERENT = 'measure-aggregate-incoherent';
66+
export const WIDGET_LEGACY_ANALYTICS_SHAPE = 'widget-legacy-analytics-shape';
67+
68+
/**
69+
* Pre-ADR-0021 inline-analytics keys. The single-form cutover replaced them
70+
* with the semantic-layer shape (`dataset` + `dimensions` + `values`); the
71+
* dashboard renderer routes dataset-bound widgets through `DatasetWidget` and
72+
* never reads these, so authoring one today is a silent no-op. Warned (not
73+
* errored) because they still parse and a legacy object-bound widget keeps
74+
* rendering — the author is just being steered to the governed shape.
75+
* (liveness audit #1878 / #1894).
76+
*/
77+
const LEGACY_ANALYTICS_KEYS = [
78+
'categoryField', 'valueField', 'xAxisField', 'yAxisFields',
79+
'aggregate', 'aggregation', 'rowField', 'columnField',
80+
] as const;
6081

6182
export type WidgetBindingSeverity = 'error' | 'warning';
6283

@@ -229,6 +250,30 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] {
229250
findings.push({ ...f, where, path });
230251
};
231252

253+
// ── (a0) legacy pre-ADR-0021 analytics shape → advisory ──
254+
// 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.
259+
const legacyUsed = LEGACY_ANALYTICS_KEYS.filter((k) => w[k] !== undefined);
260+
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+
});
275+
}
276+
232277
// ── (a) dataset reference resolves ──
233278
const dsName = typeof w.dataset === 'string' ? w.dataset : undefined;
234279
const dataset = dsName ? datasets.get(dsName) : undefined;

0 commit comments

Comments
 (0)