diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 3bbaa2d935..8226098bea 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -8,6 +8,8 @@ import { CHART_FIELD_UNKNOWN, CHART_CONFIG_MISSING, MEASURE_AGGREGATE_INCOHERENT, + WIDGET_LEGACY_ANALYTICS_SHAPE, + WIDGET_LEGACY_ANALYTICS_UNRENDERABLE, } from './validate-widget-bindings.js'; /** The downstream repro from issue #1719 — dataset with a count AND a sum @@ -380,3 +382,104 @@ describe('validateWidgetBindings (measure-aggregate-incoherent — rate aggregat expect(validateWidgetBindings(stack)).toHaveLength(0); }); }); + +describe('validateWidgetBindings — legacy analytics shape (#1878/#1894)', () => { + const only = (findings: ReturnType) => + findings.filter((f) => f.rule === WIDGET_LEGACY_ANALYTICS_SHAPE); + + it('warns (not errors) when a dataset-bound widget also carries a legacy key', () => { + // valueField is dead once the widget is dataset-bound; steer the author off it. + const findings = only(validateWidgetBindings(reproStack({ valueField: 'total_amount' }))); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('warning'); + expect(findings[0].message).toContain('`valueField`'); + expect(findings[0].hint).toMatch(/dataset.*dimensions.*values/is); + }); + + it('warns on a legacy pivot widget that has NO dataset (previously skipped silently)', () => { + const stack = { + dashboards: [{ + name: 'legacy_dash', + label: 'Legacy', + widgets: [{ + id: 'legacy_pivot', + type: 'pivot', + object: 'task', + rowField: 'status', + columnField: 'priority', + valueField: 'id', + aggregation: 'count', + layout: { x: 0, y: 0, w: 6, h: 4 }, + }], + }], + }; + const findings = only(validateWidgetBindings(stack)); + expect(findings).toHaveLength(1); + // all legacy keys reported in one finding + expect(findings[0].message).toContain('`rowField`'); + expect(findings[0].message).toContain('`columnField`'); + expect(findings[0].message).toContain('`aggregation`'); + }); + + it('does NOT warn on a clean dataset-shaped widget', () => { + expect(only(validateWidgetBindings(reproStack()))).toHaveLength(0); + }); + + it('is suppressible per widget via suppressWarnings', () => { + const findings = only(validateWidgetBindings(reproStack({ + categoryField: 'cost_center', + suppressWarnings: [WIDGET_LEGACY_ANALYTICS_SHAPE], + }))); + expect(findings).toHaveLength(0); + }); + + // ── error escalation (②): legacy keys as the ONLY data wiring ── + + const legacyOnly = (findings: ReturnType) => + findings.filter((f) => f.rule === WIDGET_LEGACY_ANALYTICS_UNRENDERABLE); + + it('ERRORS on a legacy chart with no dataset/object/data — it renders nothing', () => { + const stack = { + dashboards: [{ + name: 'broken_dash', + label: 'Broken', + widgets: [{ + id: 'orphan_chart', + type: 'bar', + categoryField: 'status', + valueField: 'amount', + aggregate: 'sum', + layout: { x: 0, y: 0, w: 6, h: 4 }, + }], + }], + }; + const findings = legacyOnly(validateWidgetBindings(stack)); + expect(findings).toHaveLength(1); + expect(findings[0].severity).toBe('error'); + expect(findings[0].message).toMatch(/renders nothing/i); + }); + + it('does NOT error when an object binding is present (legacy path still renders) — warns instead', () => { + const stack = { + dashboards: [{ + name: 'legacy_dash', label: 'Legacy', + 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 } }], + }], + }; + const findings = validateWidgetBindings(stack); + expect(findings.filter((f) => f.rule === WIDGET_LEGACY_ANALYTICS_UNRENDERABLE)).toHaveLength(0); + expect(findings.filter((f) => f.rule === WIDGET_LEGACY_ANALYTICS_SHAPE)).toHaveLength(1); + expect(findings.find((f) => f.rule === WIDGET_LEGACY_ANALYTICS_SHAPE)!.severity).toBe('warning'); + }); + + it('the unrenderable error is NOT suppressible', () => { + const stack = { + dashboards: [{ + name: 'broken_dash', label: 'Broken', + widgets: [{ id: 'orphan', type: 'pie', categoryField: 'status', suppressWarnings: [WIDGET_LEGACY_ANALYTICS_UNRENDERABLE], layout: { x: 0, y: 0, w: 6, h: 4 } }], + }], + }; + // errors ignore suppressWarnings — a blank widget must not be silenceable + expect(legacyOnly(validateWidgetBindings(stack))).toHaveLength(1); + }); +}); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index 9cda00ff3e..992ea09c36 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -27,6 +27,13 @@ import { isIncoherentAggregate } from '@objectstack/spec/data'; * (`values`). Post-cutover (ADR-0021) the result rows are keyed by * measure NAME (e.g. `sum_amount`), not the base column (`amount`) — a * stale base-column reference renders the axis but an empty series. + * - `widget-legacy-analytics-unrenderable` (#1878/#1894) — a widget uses the + * removed pre-ADR-0021 inline-analytics shape (`categoryField`/`rowField`/…) + * as its ONLY data wiring: no `dataset`, no `object`, no inline `data`. The + * renderer reads only the dataset path, so the widget has no data at all and + * renders nothing. Errored (not warned) so this class of authoring mistake — + * very often an AI emitting a removed shape — fails the build instead of + * shipping a blank widget past human review. * * Advisory rules — severity `warning`, build stays green: * @@ -44,6 +51,12 @@ import { isIncoherentAggregate } from '@objectstack/spec/data'; * `count_distinct`) of a `percent`/rate field, whose total routinely * exceeds 100%. Rates must AVG. Checked once per dataset (independent of * any widget) when the bound object's field types are known. + * - `widget-legacy-analytics-shape` (#1878/#1894) — a widget sets a + * pre-ADR-0021 inline key (`categoryField`/`valueField`/`xAxisField`/ + * `yAxisFields`/`aggregate`/`aggregation`/`rowField`/`columnField`) that the + * single-form cutover removed. The dashboard renderer routes dataset-bound + * widgets through `DatasetWidget` and never reads these, so they are a + * silent no-op. Steers the author onto `dataset`+`dimensions`+`values`. * * Warnings can be deliberately suppressed per widget via * `suppressWarnings: ['']`; errors cannot — they describe a @@ -57,6 +70,22 @@ export const CHART_FIELD_UNKNOWN = 'chart-field-unknown'; export const CHART_CONFIG_MISSING = 'chart-config-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'; +export const WIDGET_LEGACY_ANALYTICS_UNRENDERABLE = 'widget-legacy-analytics-unrenderable'; + +/** + * Pre-ADR-0021 inline-analytics keys. The single-form cutover replaced them + * with the semantic-layer shape (`dataset` + `dimensions` + `values`); the + * dashboard renderer routes dataset-bound widgets through `DatasetWidget` and + * never reads these, so authoring one today is a silent no-op. Warned (not + * errored) because they still parse and a legacy object-bound widget keeps + * rendering — the author is just being steered to the governed shape. + * (liveness audit #1878 / #1894). + */ +const LEGACY_ANALYTICS_KEYS = [ + 'categoryField', 'valueField', 'xAxisField', 'yAxisFields', + 'aggregate', 'aggregation', 'rowField', 'columnField', +] as const; export type WidgetBindingSeverity = 'error' | 'warning'; @@ -229,6 +258,55 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { findings.push({ ...f, where, path }); }; + // ── (a0) legacy pre-ADR-0021 analytics shape ── + // Steer authors (very often an AI) off the removed inline shape and onto + // the semantic-layer `dataset`+`dimensions`+`values`. The renderer reads + // ONLY the dataset path, so these keys are dead. Two severities: + // • ERROR — the legacy keys are the widget's only (dead) data wiring + // (no dataset / object / inline data): it renders nothing. + // • warning — a data source is present, so the widget still renders and + // the legacy keys are merely ignored noise (suppressible). + const legacyUsed = LEGACY_ANALYTICS_KEYS.filter((k) => w[k] !== undefined); + if (legacyUsed.length > 0) { + const optionsData = + typeof w.options === 'object' && w.options !== null && + (w.options as AnyRec).data !== undefined; + const hasDataSource = + w.dataset !== undefined || w.object !== undefined || + w.data !== undefined || optionsData; + const keyList = legacyUsed.map((k) => `\`${k}\``).join(', '); + const plural = legacyUsed.length > 1; + const datasetHint = + `Bind a semantic dataset and select fields BY NAME: ` + + `\`dataset: '', dimensions: [...], values: [...]\`. ` + + `Dataset-bound widgets render through DatasetWidget (pivot rows/cols come from ` + + `\`dimensions\`, cell values from \`values\`).`; + if (!hasDataSource) { + push({ + severity: 'error', + rule: WIDGET_LEGACY_ANALYTICS_UNRENDERABLE, + message: + `sets legacy analytics key${plural ? 's' : ''} ${keyList} ` + + `(removed by the ADR-0021 single-form cutover) and binds no data source ` + + `(no \`dataset\`, \`object\`, or inline \`data\`) — it renders nothing.`, + hint: + `${datasetHint} The renderer ignores the legacy keys, so without a data ` + + `source this widget has no data at all.`, + }); + } else { + push({ + severity: 'warning', + rule: WIDGET_LEGACY_ANALYTICS_SHAPE, + message: + `sets legacy analytics key${plural ? 's' : ''} ${keyList} that the ADR-0021 ` + + `single-form cutover removed — the dashboard renderer ignores ${plural ? 'them' : 'it'}.`, + hint: + `${datasetHint} These inline keys are a no-op. ` + + `Suppress with suppressWarnings: ['${WIDGET_LEGACY_ANALYTICS_SHAPE}'] if intentional.`, + }); + } + } + // ── (a) dataset reference resolves ── const dsName = typeof w.dataset === 'string' ? w.dataset : undefined; const dataset = dsName ? datasets.get(dsName) : undefined;