From 49d1b4e1f3437d09eb502faa7639a3517a7d9f85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 16:12:15 +0000 Subject: [PATCH 1/2] feat(lint): warn on legacy pre-ADR-0021 dashboard analytics keys (#1878/#1894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LddW4NaQBdf5FTEnBPpnUJ --- .../lint/src/validate-widget-bindings.test.ts | 52 +++++++++++++++++++ packages/lint/src/validate-widget-bindings.ts | 45 ++++++++++++++++ 2 files changed, 97 insertions(+) diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index 3bbaa2d935..a93e92f87c 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -8,6 +8,7 @@ import { CHART_FIELD_UNKNOWN, CHART_CONFIG_MISSING, MEASURE_AGGREGATE_INCOHERENT, + WIDGET_LEGACY_ANALYTICS_SHAPE, } from './validate-widget-bindings.js'; /** The downstream repro from issue #1719 — dataset with a count AND a sum @@ -380,3 +381,54 @@ 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); + }); +}); diff --git a/packages/lint/src/validate-widget-bindings.ts b/packages/lint/src/validate-widget-bindings.ts index 9cda00ff3e..c92b44ad9a 100644 --- a/packages/lint/src/validate-widget-bindings.ts +++ b/packages/lint/src/validate-widget-bindings.ts @@ -44,6 +44,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 +63,21 @@ 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'; + +/** + * 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 +250,30 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { findings.push({ ...f, where, path }); }; + // ── (a0) legacy pre-ADR-0021 analytics shape → advisory ── + // Steer authors (very often an AI) off the removed inline shape and onto + // the semantic-layer `dataset`+`dimensions`+`values`. Fires whether or + // not a dataset is also present, because the renderer ignores these keys + // either way — a dataset-bound widget carrying `categoryField` silently + // drops it. Suppressible per widget; never fails the build. + const legacyUsed = LEGACY_ANALYTICS_KEYS.filter((k) => w[k] !== undefined); + if (legacyUsed.length > 0) { + push({ + severity: 'warning', + rule: WIDGET_LEGACY_ANALYTICS_SHAPE, + message: + `sets legacy analytics key${legacyUsed.length > 1 ? 's' : ''} ` + + `${legacyUsed.map((k) => `\`${k}\``).join(', ')} that the ADR-0021 ` + + `single-form cutover removed — the dashboard renderer ignores ${legacyUsed.length > 1 ? 'them' : 'it'}.`, + hint: + `Bind a semantic dataset and select fields BY NAME instead: ` + + `\`dataset: '', dimensions: [...], values: [...]\`. ` + + `Dataset-bound widgets render through DatasetWidget (pivot rows/cols come from ` + + `\`dimensions\`, cell values from \`values\`); 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; From 078d250947c13a86d0a6f9c2d41745a160b5b6d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 16:25:57 +0000 Subject: [PATCH 2/2] feat(lint): error (not warn) when a legacy dashboard widget binds no data source (#1878/#1894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LddW4NaQBdf5FTEnBPpnUJ --- .../lint/src/validate-widget-bindings.test.ts | 51 +++++++++++++ packages/lint/src/validate-widget-bindings.ts | 71 ++++++++++++++----- 2 files changed, 103 insertions(+), 19 deletions(-) diff --git a/packages/lint/src/validate-widget-bindings.test.ts b/packages/lint/src/validate-widget-bindings.test.ts index a93e92f87c..8226098bea 100644 --- a/packages/lint/src/validate-widget-bindings.test.ts +++ b/packages/lint/src/validate-widget-bindings.test.ts @@ -9,6 +9,7 @@ import { 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 @@ -431,4 +432,54 @@ describe('validateWidgetBindings — legacy analytics shape (#1878/#1894)', () = }))); 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 c92b44ad9a..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: * @@ -64,6 +71,7 @@ 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 @@ -250,28 +258,53 @@ export function validateWidgetBindings(stack: AnyRec): WidgetBindingFinding[] { findings.push({ ...f, where, path }); }; - // ── (a0) legacy pre-ADR-0021 analytics shape → advisory ── + // ── (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`. Fires whether or - // not a dataset is also present, because the renderer ignores these keys - // either way — a dataset-bound widget carrying `categoryField` silently - // drops it. Suppressible per widget; never fails the build. + // 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) { - push({ - severity: 'warning', - rule: WIDGET_LEGACY_ANALYTICS_SHAPE, - message: - `sets legacy analytics key${legacyUsed.length > 1 ? 's' : ''} ` + - `${legacyUsed.map((k) => `\`${k}\``).join(', ')} that the ADR-0021 ` + - `single-form cutover removed — the dashboard renderer ignores ${legacyUsed.length > 1 ? 'them' : 'it'}.`, - hint: - `Bind a semantic dataset and select fields BY NAME instead: ` + - `\`dataset: '', dimensions: [...], values: [...]\`. ` + - `Dataset-bound widgets render through DatasetWidget (pivot rows/cols come from ` + - `\`dimensions\`, cell values from \`values\`); these inline keys are a no-op. ` + - `Suppress with suppressWarnings: ['${WIDGET_LEGACY_ANALYTICS_SHAPE}'] if intentional.`, - }); + 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 ──