Skip to content

Commit ad7046a

Browse files
os-zhuangclaude
andcommitted
feat(cli): dashboard widget reference integrity (dataset → dimensions/measures + chartConfig)
Closes #1721. The ADR-0021 single-form cutover makes every DashboardWidget select its dataset's dimensions/measures BY NAME, but nothing validated those references — a widget could name a dataset that doesn't exist, a measure the dataset never declares, or point chartConfig.yAxis[].field at a stale base column, and validate/lint/doctor/build all stayed green while the page rendered an empty series. Downstream this broke 36 widgets across 11 template dashboards (templates#27, templates#30) with a green build. `validateWidgetBindings()` (the #1719 semantic pass) now returns severity-carrying findings: errors — the binding cannot be satisfied; validate and build FAIL: - widget-dataset-unknown `dataset` resolves to no declared Dataset - widget-dimension-unknown a `dimensions[]` entry isn't a dataset dimension - widget-measure-unknown a `values[]` entry isn't a dataset measure - chart-field-unknown chartConfig xAxis.field doesn't resolve to a dataset dimension, or yAxis[].field / series[].name doesn't resolve to a measure selected in `values` (post-cutover rows are keyed by measure NAME, e.g. sum_amount — not the base column amount) warnings — advisory, suppressible per widget via suppressWarnings: - chart-config-missing chart-type widget with no chartConfig (renders an empty series on chartConfig-driven renderers; templates#30 verified in-browser that adding the mapping fixes it) - table-count-only unchanged from #1719 Errors are not suppressible, and messages carry the declared-name list plus a did-you-mean suggestion (containment-first, so amount → sum_amount). Surfaces: validate + compile fail on errors (human + --json), lint passes severity through, doctor gains a "Dashboard integrity" section mirroring the orphan-view pass. A chartConfig field naming an already-invalid selection entry is not double-reported — rules (b)/(c) own that error. The example apps' chart widgets now carry explicit chartConfig (the authoring shape templates#30 standardized on; objectui HEAD derives series from `values` and ignores it, so this is compatible both ways). app-todo's two count-only record-listing tables still warn — that re-modeling to ListViews (ADR-0017) is #1719 territory, left as a follow-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent cf6e117 commit ad7046a

9 files changed

Lines changed: 610 additions & 71 deletions

File tree

examples/app-crm/src/dashboards/pipeline.dashboard.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,15 @@ export const PipelineDashboard: Dashboard = {
9090
dataset: 'opportunity_metrics',
9191
dimensions: ['close_date'],
9292
values: ['opp_count'],
93+
// Axis fields name the dataset's dimension/measure (NOT base columns) —
94+
// post-cutover query rows are keyed by measure name (issue #1721).
95+
chartConfig: {
96+
type: 'line',
97+
xAxis: { field: 'close_date', showGridLines: true, logarithmic: false },
98+
yAxis: [{ field: 'opp_count', showGridLines: true, logarithmic: false }],
99+
showLegend: true,
100+
showDataLabels: false,
101+
},
93102
layout: { x: 0, y: 2, w: 8, h: 4 },
94103
},
95104
{
@@ -107,6 +116,13 @@ export const PipelineDashboard: Dashboard = {
107116
dataset: 'opportunity_metrics',
108117
dimensions: ['stage'],
109118
values: ['opp_count'],
119+
chartConfig: {
120+
type: 'bar',
121+
xAxis: { field: 'stage', showGridLines: true, logarithmic: false },
122+
yAxis: [{ field: 'opp_count', showGridLines: true, logarithmic: false }],
123+
showLegend: true,
124+
showDataLabels: false,
125+
},
110126
layout: { x: 8, y: 2, w: 4, h: 4 },
111127
},
112128

@@ -120,6 +136,13 @@ export const PipelineDashboard: Dashboard = {
120136
dataset: 'opportunity_metrics',
121137
dimensions: ['stage'],
122138
values: ['total_amount'],
139+
chartConfig: {
140+
type: 'pie',
141+
xAxis: { field: 'stage', showGridLines: true, logarithmic: false },
142+
yAxis: [{ field: 'total_amount', showGridLines: true, logarithmic: false }],
143+
showLegend: true,
144+
showDataLabels: false,
145+
},
123146
layout: { x: 0, y: 6, w: 6, h: 4 },
124147
},
125148
],

examples/app-showcase/src/dashboards/chart-gallery.dashboard.ts

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { Dashboard } from '@objectstack/spec/ui';
3+
import type { ChartConfig, ChartType, Dashboard } from '@objectstack/spec/ui';
44

55
const taskDs = 'showcase_task_metrics';
66
const projectDs = 'showcase_project_metrics';
77

8+
/** Axis fields name the dataset's dimension/measure — query rows are keyed by
9+
* measure NAME post-cutover, never the base column (issue #1721). */
10+
const cfg = (type: ChartType, dimension: string, measure: string): ChartConfig => ({
11+
type,
12+
xAxis: { field: dimension, showGridLines: true, logarithmic: false },
13+
yAxis: [{ field: measure, showGridLines: true, logarithmic: false }],
14+
showLegend: true,
15+
showDataLabels: false,
16+
});
17+
818
/**
919
* Chart Gallery — one widget per chart family the dashboard renderer can draw
1020
* DISTINCTLY, so the showcase honestly reflects what the platform implements.
@@ -34,26 +44,26 @@ export const ChartGalleryDashboard: Dashboard = {
3444
{ id: 'kpi_total_spent', type: 'metric', title: 'Total Spent', dataset: projectDs, values: ['spent_sum'], layout: { x: 8, y: 0, w: 4, h: 2 } },
3545

3646
// ── Comparison ─────────────────────────────────────────────────────────
37-
{ id: 'bar_by_status', type: 'bar', title: 'Tasks by Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 2, w: 4, h: 4 } },
38-
{ id: 'column_by_priority', type: 'column', title: 'Tasks by Priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 2, w: 4, h: 4 } },
39-
{ id: 'hbar_hours', type: 'horizontal-bar', title: 'Hours by Status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 8, y: 2, w: 4, h: 4 } },
47+
{ id: 'bar_by_status', type: 'bar', title: 'Tasks by Status', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('bar', 'status', 'task_count'), layout: { x: 0, y: 2, w: 4, h: 4 } },
48+
{ id: 'column_by_priority', type: 'column', title: 'Tasks by Priority', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], chartConfig: cfg('column', 'priority', 'task_count'), layout: { x: 4, y: 2, w: 4, h: 4 } },
49+
{ id: 'hbar_hours', type: 'horizontal-bar', title: 'Hours by Status', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], chartConfig: cfg('horizontal-bar', 'status', 'est_hours'), layout: { x: 8, y: 2, w: 4, h: 4 } },
4050

4151
// ── Trend (month-bucketed via the dataset's created_at granularity) ──────
42-
{ id: 'line_created', type: 'line', title: 'Tasks Created (monthly)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 0, y: 6, w: 6, h: 4 } },
43-
{ id: 'area_created', type: 'area', title: 'Tasks Created (area)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], layout: { x: 6, y: 6, w: 6, h: 4 } },
52+
{ id: 'line_created', type: 'line', title: 'Tasks Created (monthly)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], chartConfig: cfg('line', 'created_at', 'task_count'), layout: { x: 0, y: 6, w: 6, h: 4 } },
53+
{ id: 'area_created', type: 'area', title: 'Tasks Created (area)', dataset: taskDs, dimensions: ['created_at'], values: ['task_count'], chartConfig: cfg('area', 'created_at', 'task_count'), layout: { x: 6, y: 6, w: 6, h: 4 } },
4454

4555
// ── Distribution ─────────────────────────────────────────────────────────
46-
{ id: 'pie_status', type: 'pie', title: 'Status Split', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 0, y: 10, w: 4, h: 4 } },
47-
{ id: 'donut_priority', type: 'donut', title: 'Priority Split', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 4, y: 10, w: 4, h: 4 } },
48-
{ id: 'funnel_status', type: 'funnel', title: 'Status Funnel', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 8, y: 10, w: 4, h: 4 } },
56+
{ id: 'pie_status', type: 'pie', title: 'Status Split', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('pie', 'status', 'task_count'), layout: { x: 0, y: 10, w: 4, h: 4 } },
57+
{ id: 'donut_priority', type: 'donut', title: 'Priority Split', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], chartConfig: cfg('donut', 'priority', 'task_count'), layout: { x: 4, y: 10, w: 4, h: 4 } },
58+
{ id: 'funnel_status', type: 'funnel', title: 'Status Funnel', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('funnel', 'status', 'task_count'), layout: { x: 8, y: 10, w: 4, h: 4 } },
4959

5060
// ── Relationship + Advanced ──────────────────────────────────────────────
51-
{ id: 'scatter_estimate', type: 'scatter', title: 'Estimate vs Progress', dataset: taskDs, dimensions: ['progress'], values: ['avg_estimate'], layout: { x: 0, y: 14, w: 6, h: 4 } },
52-
{ id: 'radar_priority', type: 'radar', title: 'Priority Radar', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], layout: { x: 6, y: 14, w: 6, h: 4 } },
61+
{ id: 'scatter_estimate', type: 'scatter', title: 'Estimate vs Progress', dataset: taskDs, dimensions: ['progress'], values: ['avg_estimate'], chartConfig: cfg('scatter', 'progress', 'avg_estimate'), layout: { x: 0, y: 14, w: 6, h: 4 } },
62+
{ id: 'radar_priority', type: 'radar', title: 'Priority Radar', dataset: taskDs, dimensions: ['priority'], values: ['task_count'], chartConfig: cfg('radar', 'priority', 'task_count'), layout: { x: 6, y: 14, w: 6, h: 4 } },
5363

5464
// ── Composition ──────────────────────────────────────────────────────────
55-
{ id: 'treemap_hours', type: 'treemap', title: 'Hours Treemap', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], layout: { x: 0, y: 18, w: 6, h: 4 } },
56-
{ id: 'sankey_flow', type: 'sankey', title: 'Status Flow (Sankey)', dataset: taskDs, dimensions: ['status'], values: ['task_count'], layout: { x: 6, y: 18, w: 6, h: 4 } },
65+
{ id: 'treemap_hours', type: 'treemap', title: 'Hours Treemap', dataset: taskDs, dimensions: ['status'], values: ['est_hours'], chartConfig: cfg('treemap', 'status', 'est_hours'), layout: { x: 0, y: 18, w: 6, h: 4 } },
66+
{ id: 'sankey_flow', type: 'sankey', title: 'Status Flow (Sankey)', dataset: taskDs, dimensions: ['status'], values: ['task_count'], chartConfig: cfg('sankey', 'status', 'task_count'), layout: { x: 6, y: 18, w: 6, h: 4 } },
5767

5868
// ── Tabular (real grouped tables, multiple measures) ─────────────────────
5969
{ id: 'table_projects', type: 'table', title: 'Projects by Account', dataset: projectDs, dimensions: ['account'], values: ['project_count', 'budget_sum', 'spent_sum'], layout: { x: 0, y: 22, w: 6, h: 4 } },

examples/app-todo/src/dashboards/task.dashboard.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ export const TaskDashboard: Dashboard = {
6262
},
6363

6464
// Row 2: Task Distribution
65+
// chartConfig axis fields name the dataset's dimension/measure — query
66+
// rows are keyed by measure NAME post-cutover (issue #1721).
6567
{
6668
id: 'tasks_by_status',
6769
title: 'Tasks by Status',
@@ -70,6 +72,7 @@ export const TaskDashboard: Dashboard = {
7072
dataset: 'task_metrics',
7173
dimensions: ['status'],
7274
values: ['task_count'],
75+
chartConfig: { type: 'pie', xAxis: { field: 'status', showGridLines: true, logarithmic: false }, yAxis: [{ field: 'task_count', showGridLines: true, logarithmic: false }], showLegend: true, showDataLabels: false },
7376
layout: { x: 0, y: 2, w: 6, h: 4 },
7477
options: { showLegend: true }
7578
},
@@ -81,6 +84,7 @@ export const TaskDashboard: Dashboard = {
8184
dataset: 'task_metrics',
8285
dimensions: ['priority'],
8386
values: ['task_count'],
87+
chartConfig: { type: 'bar', xAxis: { field: 'priority', showGridLines: true, logarithmic: false }, yAxis: [{ field: 'task_count', showGridLines: true, logarithmic: false }], showLegend: true, showDataLabels: false },
8488
layout: { x: 6, y: 2, w: 6, h: 4 },
8589
options: { horizontal: true }
8690
},
@@ -94,6 +98,7 @@ export const TaskDashboard: Dashboard = {
9498
dataset: 'task_metrics',
9599
dimensions: ['completed_date'],
96100
values: ['task_count'],
101+
chartConfig: { type: 'line', xAxis: { field: 'completed_date', showGridLines: true, logarithmic: false }, yAxis: [{ field: 'task_count', showGridLines: true, logarithmic: false }], showLegend: true, showDataLabels: false },
97102
layout: { x: 0, y: 6, w: 8, h: 4 },
98103
options: { showDataLabels: true }
99104
},
@@ -105,6 +110,7 @@ export const TaskDashboard: Dashboard = {
105110
dataset: 'task_metrics',
106111
dimensions: ['category'],
107112
values: ['task_count'],
113+
chartConfig: { type: 'donut', xAxis: { field: 'category', showGridLines: true, logarithmic: false }, yAxis: [{ field: 'task_count', showGridLines: true, logarithmic: false }], showLegend: true, showDataLabels: false },
108114
layout: { x: 8, y: 6, w: 4, h: 4 },
109115
options: { showLegend: true }
110116
},

packages/cli/src/commands/compile.ts

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -147,13 +147,31 @@ export default class Compile extends Command {
147147
this.exit(1);
148148
}
149149

150-
// 3c. Widget-binding diagnostics (issue #1719) — semantic checks that
151-
// need the widget's `dataset` reference resolved to its dataset and
152-
// `values` resolved to measures with known aggregates. Advisory
153-
// only: warnings are printed (and included in --json output) but
154-
// never fail the build. Suppress per widget via
150+
// 3c. Widget-binding diagnostics (issues #1719/#1721) — semantic checks
151+
// that need the widget's `dataset` reference resolved to its dataset
152+
// and `dimensions`/`values` resolved to declared names. Errors are
153+
// unresolvable bindings (dangling dataset/dimension/measure or a
154+
// chartConfig field the query result won't contain) and fail the
155+
// build; warnings are advisory and suppressible per widget via
155156
// `suppressWarnings: ['<rule-id>']`.
156-
const widgetWarnings = validateWidgetBindings(result.data as Record<string, unknown>);
157+
if (!flags.json) printStep('Checking dashboard widget bindings (ADR-0021)...');
158+
const widgetFindings = validateWidgetBindings(result.data as Record<string, unknown>);
159+
const widgetErrors = widgetFindings.filter((f) => f.severity === 'error');
160+
const widgetWarnings = widgetFindings.filter((f) => f.severity === 'warning');
161+
if (widgetErrors.length > 0) {
162+
if (flags.json) {
163+
console.log(JSON.stringify({ success: false, error: 'widget binding validation failed', issues: widgetErrors }));
164+
this.exit(1);
165+
}
166+
console.log('');
167+
printError(`Dashboard widget integrity failed (${widgetErrors.length} issue${widgetErrors.length > 1 ? 's' : ''})`);
168+
for (const f of widgetErrors.slice(0, 50)) {
169+
console.log(` • ${f.where}: ${f.message}`);
170+
console.log(chalk.dim(` ${f.hint}`));
171+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
172+
}
173+
this.exit(1);
174+
}
157175
if (widgetWarnings.length > 0 && !flags.json) {
158176
console.log('');
159177
for (const w of widgetWarnings) {

packages/cli/src/commands/doctor.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import path from 'path';
88
import { normalizeStackInput } from '@objectstack/spec';
99
import { printHeader, printSuccess, printWarning, printError, printStep, printInfo } from '../utils/format.js';
1010
import { loadConfig, configExists } from '../utils/config.js';
11+
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
1112

1213
interface HealthCheckResult {
1314
name: string;
@@ -525,6 +526,31 @@ export default class Doctor extends Command {
525526
printSuccess('View integrity All views reference valid objects');
526527
}
527528
}
529+
530+
// Dashboard widget integrity (issue #1721) — the widget-side analogue
531+
// of the orphan-view pass: every widget's `dataset`, `dimensions`,
532+
// `values`, and chartConfig axis/series fields must resolve against
533+
// the declared datasets (ADR-0021).
534+
if (Array.isArray(config.dashboards) && config.dashboards.length > 0) {
535+
printStep('Checking dashboard widget integrity...');
536+
const widgetFindings = validateWidgetBindings(config);
537+
if (widgetFindings.length > 0) {
538+
for (const f of widgetFindings) {
539+
if (f.severity === 'error') {
540+
hasErrors = true;
541+
printError(`${f.where}: ${f.message}`);
542+
} else {
543+
hasWarnings = true;
544+
printWarning(`${f.where}: ${f.message}`);
545+
}
546+
if (flags.verbose) {
547+
console.log(chalk.dim(` → ${f.hint}`));
548+
}
549+
}
550+
} else {
551+
printSuccess('Dashboard integrity All widgets resolve datasets, dimensions, and measures');
552+
}
553+
}
528554
} catch {
529555
printWarning('Could not load config for analysis (config checks skipped)');
530556
hasWarnings = true;

packages/cli/src/commands/lint.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -220,13 +220,16 @@ export function lintConfig(config: any): LintIssue[] {
220220
// objectstack-data/-ui skills. These double as the eval rubric (see score.ts).
221221
issues.push(...lintDataModel(objects));
222222

223-
// ── Dashboard widget bindings (ADR-0021, issue #1719) ──
224-
// e.g. a table/pivot widget whose binding resolves to count-only measures
225-
// with no dimensions — almost always a record listing that belongs in an
226-
// object-bound ListView (ADR-0017), not an analytics dataset.
223+
// ── Dashboard widget bindings (ADR-0021, issues #1719/#1721) ──
224+
// Reference integrity (errors): widget `dataset`/`dimensions`/`values` and
225+
// chartConfig axis/series fields must resolve against the declared
226+
// datasets. Advisory shapes (warnings): e.g. a table/pivot widget whose
227+
// binding resolves to count-only measures with no dimensions — almost
228+
// always a record listing that belongs in an object-bound ListView
229+
// (ADR-0017), not an analytics dataset.
227230
for (const w of validateWidgetBindings(config)) {
228231
issues.push({
229-
severity: 'warning',
232+
severity: w.severity,
230233
rule: w.rule,
231234
message: `${w.where}: ${w.message}`,
232235
path: w.path,

packages/cli/src/commands/validate.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import chalk from 'chalk';
55
import { ZodError } from 'zod';
66
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
77
import { loadConfig } from '../utils/config.js';
8+
import { validateWidgetBindings } from '../utils/validate-widget-bindings.js';
89
import {
910
printHeader,
1011
printKV,
@@ -69,22 +70,56 @@ export default class Validate extends Command {
6970
this.exit(1);
7071
}
7172

72-
// 3. Collect and display stats
73+
// 3. Dashboard widget reference integrity (issue #1721) — a semantic
74+
// cross-reference pass the protocol schema cannot express: every
75+
// widget's `dataset`/`dimensions`/`values` and chartConfig
76+
// axis/series fields must resolve against the declared datasets
77+
// (ADR-0021). Errors fail validation; warnings are advisory.
78+
if (!flags.json) printStep('Checking dashboard widget bindings (ADR-0021)...');
79+
const widgetFindings = validateWidgetBindings(result.data as Record<string, unknown>);
80+
const widgetErrors = widgetFindings.filter((f) => f.severity === 'error');
81+
const widgetWarnings = widgetFindings.filter((f) => f.severity === 'warning');
82+
83+
if (widgetErrors.length > 0) {
84+
if (flags.json) {
85+
console.log(JSON.stringify({
86+
valid: false,
87+
errors: widgetErrors,
88+
warnings: widgetWarnings,
89+
duration: timer.elapsed(),
90+
}, null, 2));
91+
this.exit(1);
92+
}
93+
console.log('');
94+
printError(`Dashboard widget integrity failed (${widgetErrors.length} issue${widgetErrors.length > 1 ? 's' : ''})`);
95+
for (const f of widgetErrors.slice(0, 50)) {
96+
console.log(` • ${f.where}: ${f.message}`);
97+
console.log(chalk.dim(` ${f.hint}`));
98+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
99+
}
100+
this.exit(1);
101+
}
102+
103+
// 4. Collect and display stats
73104
const stats = collectMetadataStats(config);
74105

75106
if (flags.json) {
76107
console.log(JSON.stringify({
77108
valid: true,
78109
manifest: config.manifest,
79110
stats,
111+
warnings: widgetWarnings,
80112
duration: timer.elapsed(),
81113
}, null, 2));
82114
return;
83115
}
84116

85-
// 4. Warnings (non-blocking)
117+
// 5. Warnings (non-blocking)
86118
const warnings: string[] = [];
87-
119+
120+
for (const f of widgetWarnings) {
121+
warnings.push(`${f.where}: ${f.message}`);
122+
}
88123
if (stats.objects === 0) {
89124
warnings.push('No objects defined — this stack has no data model');
90125
}
@@ -98,7 +133,7 @@ export default class Validate extends Command {
98133
warnings.push('Missing manifest.namespace — required for multi-app hosting');
99134
}
100135

101-
// 5. Display results
136+
// 6. Display results
102137
console.log('');
103138
printSuccess(`Validation passed ${chalk.dim(`(${timer.display()})`)}`);
104139
console.log('');

0 commit comments

Comments
 (0)