Skip to content

Commit bd66ec3

Browse files
committed
feat(lint): flag dead action/route references in dashboard header actions
Dashboard header `actions[]` and widget `actionUrl` can name a script/modal action or a url route that does not exist. Nothing in `os validate` / `os build` flags them, so they ship as buttons that render and silently do nothing when clicked — a false affordance. This applies ADR-0049's "declared ≠ enforced" gate to references rather than properties (#3367). New `validateDashboardActionRefs` rule in @objectstack/lint, wired into `os validate` and `os build`: - actionType 'script' | 'modal' → ERROR unless `actionUrl` resolves to a defined action (stack.actions or an object's actions). 'modal' also resolves via the runtime `<verb>_<object>` convention (create_/new_/add_/edit_/update_ + a real object) and bare object names, mirroring the objectui modalHandler. - actionType 'url' → WARNING when a relative in-app path names an objects/reports/dashboards/pages/views route absent from the stack. External, interpolated (${…}), and opaque routes are skipped to keep false positives near zero — the same conservative posture as lint-view-refs / capability-references. Covers dashboard `header.actions[]` and widget `actionUrl`. Fixture coverage for defined vs dangling script/modal targets, the modal convention, and resolved vs unresolved url routes, plus the #3367 repro. Docs + changeset included. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018KSiJuXDAi78CQD3Lv2TNJ
1 parent 9a43e04 commit bd66ec3

7 files changed

Lines changed: 674 additions & 1 deletion

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
Flag dead action/route references in dashboard header & widget actions (ADR-0049 for references, #3367).
7+
8+
`os validate` / `os build` now run a new `validateDashboardActionRefs` gate over every dashboard `header.actions[]` and widget `actionUrl`:
9+
10+
- `actionType: 'script' | 'modal'`**error** unless `actionUrl` resolves to a defined action (`stack.actions` or an object's `actions`). `modal` also resolves via the runtime `<verb>_<object>` convention (`create_/new_/add_/edit_/update_` + a real object) and bare object names. A dangling target ships a button that renders and silently does nothing on click — a false affordance, exactly the "declared ≠ enforced" gap ADR-0049 closes, applied to references.
11+
- `actionType: 'url'`**warning** when a relative in-app path names a `objects/reports/dashboards/pages/views` route whose target does not exist in the stack. External URLs, interpolated (`${…}`) targets, and opaque routes are skipped to keep false positives near zero.

content/docs/getting-started/validating-metadata.mdx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,31 @@ If a name doesn't resolve, the chart renders **empty** — no error (ADR-0021).
4848
`os validate` resolves every binding against the declared datasets and fails on a
4949
dangling one.
5050

51+
### 3. Dead action/route references
52+
53+
A dashboard `header.actions[]` button (and a widget's `actionUrl`) names a target:
54+
a `script`/`modal` action, or a `url` route. Nothing in the schema checks that the
55+
target exists, so a button can ship pointing at an action defined nowhere — it
56+
renders and then **silently does nothing** when clicked. This is ADR-0049's
57+
"declared ≠ enforced" gate applied to *references*.
58+
59+
```ts
60+
header: {
61+
actions: [
62+
// ✗ script/modal target resolves to no defined action → error
63+
{ label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' },
64+
// ✓ modal <verb>_<object> against a real `opportunity` object
65+
{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' },
66+
// ⚠ url path matching no in-app route → warning
67+
{ label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' },
68+
],
69+
}
70+
```
71+
72+
`script`/`modal` targets that resolve nowhere **fail** validation; a `url` path
73+
whose `objects/reports/dashboards/pages/views` route is unregistered is **warned**
74+
(external, interpolated, and opaque routes are skipped).
75+
5176
## The one gate, two entry points
5277

5378
`os validate` and `os build` (alias of `os compile`) run the **same** validator:
@@ -57,6 +82,7 @@ dangling one.
5782
| Protocol schema (Zod) |||
5883
| CEL / predicate validation |||
5984
| Widget-binding integrity |||
85+
| Dashboard action/route references (ADR-0049) |||
6086
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) |||
6187
| Emits `dist/objectstack.json` |||
6288

@@ -81,6 +107,7 @@ A clean run walks each gate and reports timing:
81107
→ Checking list-view navigation modes (ADR-0053)...
82108
→ Checking view container shape...
83109
→ Checking dashboard widget bindings (ADR-0021)...
110+
→ Checking dashboard action references (ADR-0049)...
84111
→ Checking SDUI styling (ADR-0065)...
85112
→ Checking JSX-source pages (ADR-0080)...
86113
→ Checking React-source pages (ADR-0081)...

packages/cli/src/commands/compile.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js';
1111
import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateVisibilityPredicates } from '@objectstack/lint';
1313
import { validateWidgetBindings } from '@objectstack/lint';
14+
import { validateDashboardActionRefs } from '@objectstack/lint';
1415
import { validateResponsiveStyles } from '@objectstack/lint';
1516
import { validateSecurityPosture, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint';
1617
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
@@ -218,6 +219,39 @@ export default class Compile extends Command {
218219
}
219220
}
220221

222+
// 3c-bis. Dashboard action/route reference integrity (ADR-0049 for
223+
// references, #3367). A header/widget action naming a `script`/`modal`
224+
// target that resolves to no defined action, or a `url` target that
225+
// matches no in-app route, ships a button that renders and silently
226+
// does nothing on click. Dead script/modal targets fail the build
227+
// (they fail open at runtime); unresolved url routes are advisory.
228+
if (!flags.json) printStep('Checking dashboard action references (ADR-0049)...');
229+
const actionRefFindings = validateDashboardActionRefs(result.data as Record<string, unknown>);
230+
const actionRefErrors = actionRefFindings.filter((f) => f.severity === 'error');
231+
const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning');
232+
if (actionRefErrors.length > 0) {
233+
if (flags.json) {
234+
console.log(JSON.stringify({ success: false, error: 'dashboard action reference validation failed', issues: actionRefErrors }));
235+
this.exit(1);
236+
}
237+
console.log('');
238+
printError(`Dashboard action reference check failed (${actionRefErrors.length} issue${actionRefErrors.length > 1 ? 's' : ''})`);
239+
for (const f of actionRefErrors.slice(0, 50)) {
240+
console.log(` • ${f.where}: ${f.message}`);
241+
console.log(chalk.dim(` ${f.hint}`));
242+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
243+
}
244+
this.exit(1);
245+
}
246+
if (actionRefWarnings.length > 0 && !flags.json) {
247+
console.log('');
248+
for (const w of actionRefWarnings) {
249+
printWarning(`${w.where}: ${w.message}`);
250+
console.log(chalk.dim(` ${w.hint}`));
251+
console.log(chalk.dim(` rule: ${w.rule} at ${w.path}`));
252+
}
253+
}
254+
221255
// 3c. SDUI scoped-styling correctness (ADR-0065) — a styled node without
222256
// an `id` drops its CSS silently; Tailwind-in-className does nothing
223257
// from metadata. Same bar for hand-authored and AI-generated pages

packages/cli/src/commands/validate.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { validateStackExpressions } from '@objectstack/lint';
1212
import { validateListViewMode } from '@objectstack/lint';
1313
import { validateViewContainers } from '@objectstack/lint';
1414
import { validateWidgetBindings } from '@objectstack/lint';
15+
import { validateDashboardActionRefs } from '@objectstack/lint';
1516
import { validateResponsiveStyles } from '@objectstack/lint';
1617
import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint';
1718
import { validateCapabilityReferences } from '@objectstack/lint';
@@ -209,6 +210,43 @@ export default class Validate extends Command {
209210
this.exit(1);
210211
}
211212

213+
// 3a-bis. Dashboard action/route reference integrity (ADR-0049 for
214+
// references, #3367) — a header/widget action names a `script`/`modal`
215+
// target that resolves to no defined action, or a `url` target that
216+
// matches no in-app route. Nothing else flags it, so it ships as a
217+
// button that renders and silently does nothing on click (a false
218+
// affordance). Dead script/modal targets are errors (fail open at
219+
// runtime); unresolved url routes are advisory warnings.
220+
if (!flags.json) printStep('Checking dashboard action references (ADR-0049)...');
221+
const actionRefFindings = validateDashboardActionRefs(result.data as Record<string, unknown>);
222+
const actionRefErrors = actionRefFindings.filter((f) => f.severity === 'error');
223+
const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning');
224+
if (actionRefErrors.length > 0) {
225+
if (flags.json) {
226+
console.log(JSON.stringify({
227+
valid: false,
228+
errors: actionRefErrors,
229+
warnings: [...widgetWarnings, ...actionRefWarnings],
230+
duration: timer.elapsed(),
231+
}, null, 2));
232+
this.exit(1);
233+
}
234+
console.log('');
235+
printError(`Dashboard action reference check failed (${actionRefErrors.length} issue${actionRefErrors.length > 1 ? 's' : ''})`);
236+
for (const f of actionRefErrors.slice(0, 50)) {
237+
console.log(` • ${f.where}: ${f.message}`);
238+
console.log(chalk.dim(` ${f.hint}`));
239+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
240+
}
241+
this.exit(1);
242+
}
243+
if (!flags.json) {
244+
for (const w of actionRefWarnings.slice(0, 50)) {
245+
console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`));
246+
console.log(chalk.dim(` ${w.hint}`));
247+
}
248+
}
249+
212250
// 3b. SDUI scoped-styling correctness (ADR-0065) — a styled node's
213251
// responsiveStyles must be scopable (needs an `id`), reference real
214252
// CSS properties + design tokens, and carry a `large` base;
@@ -434,7 +472,7 @@ export default class Validate extends Command {
434472
valid: true,
435473
manifest: config.manifest,
436474
stats,
437-
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories],
475+
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories],
438476
conversions: conversionNotices,
439477
specVersionGap: specGap,
440478
duration: timer.elapsed(),

packages/lint/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,14 @@ export {
124124
} from './validate-security-posture.js';
125125
export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js';
126126

127+
export {
128+
validateDashboardActionRefs,
129+
DASHBOARD_ACTION_TARGET_UNDEFINED,
130+
DASHBOARD_ACTION_ROUTE_UNRESOLVED,
131+
} from './validate-dashboard-action-refs.js';
132+
export type {
133+
DashboardActionRefFinding,
134+
DashboardActionRefSeverity,
135+
} from './validate-dashboard-action-refs.js';
136+
127137
export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
validateDashboardActionRefs,
6+
DASHBOARD_ACTION_TARGET_UNDEFINED,
7+
DASHBOARD_ACTION_ROUTE_UNRESOLVED,
8+
} from './validate-dashboard-action-refs';
9+
10+
/** Build a stack with a single dashboard whose header carries `actions`. */
11+
function dashWithHeaderActions(actions: unknown[], extra: Record<string, unknown> = {}) {
12+
return {
13+
...extra,
14+
dashboards: [
15+
{ name: 'exec', label: 'Executive', header: { actions }, widgets: [] },
16+
],
17+
};
18+
}
19+
20+
describe('validateDashboardActionRefs (ADR-0049 references / #3367)', () => {
21+
it('passes a script action that names a defined global action', () => {
22+
const findings = validateDashboardActionRefs(
23+
dashWithHeaderActions(
24+
[{ label: 'Recalc', actionType: 'script', actionUrl: 'recalc_totals' }],
25+
{ actions: [{ name: 'recalc_totals', type: 'script' }] },
26+
),
27+
);
28+
expect(findings).toEqual([]);
29+
});
30+
31+
it('passes a script action that names a defined object-embedded action', () => {
32+
const findings = validateDashboardActionRefs(
33+
dashWithHeaderActions(
34+
[{ label: 'Close', actionType: 'script', actionUrl: 'close_deal' }],
35+
{ objects: [{ name: 'opportunity', actions: [{ name: 'close_deal', type: 'script' }] }] },
36+
),
37+
);
38+
expect(findings).toEqual([]);
39+
});
40+
41+
it('ERRORS on a script action whose target is defined nowhere', () => {
42+
const findings = validateDashboardActionRefs(
43+
dashWithHeaderActions([
44+
{ label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' },
45+
]),
46+
);
47+
expect(findings).toHaveLength(1);
48+
expect(findings[0]).toMatchObject({
49+
severity: 'error',
50+
rule: DASHBOARD_ACTION_TARGET_UNDEFINED,
51+
where: 'dashboard "exec" · header action "Export PDF"',
52+
path: 'dashboards[0].header.actions[0].actionUrl',
53+
});
54+
expect(findings[0].message).toContain('export_dashboard_pdf');
55+
});
56+
57+
it('passes a modal action that names a defined action', () => {
58+
const findings = validateDashboardActionRefs(
59+
dashWithHeaderActions(
60+
[{ label: 'New Deal', actionType: 'modal', actionUrl: 'quick_create_deal' }],
61+
{ actions: [{ name: 'quick_create_deal', type: 'modal' }] },
62+
),
63+
);
64+
expect(findings).toEqual([]);
65+
});
66+
67+
it('passes a modal action using the <verb>_<object> convention against a real object', () => {
68+
const findings = validateDashboardActionRefs(
69+
dashWithHeaderActions(
70+
[{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }],
71+
{ objects: [{ name: 'opportunity' }] },
72+
),
73+
);
74+
expect(findings).toEqual([]);
75+
});
76+
77+
it('passes a modal action that is a bare object name (create-form fallback)', () => {
78+
const findings = validateDashboardActionRefs(
79+
dashWithHeaderActions(
80+
[{ label: 'Add Lead', actionType: 'modal', actionUrl: 'lead' }],
81+
{ objects: [{ name: 'lead' }] },
82+
),
83+
);
84+
expect(findings).toEqual([]);
85+
});
86+
87+
it('ERRORS on a modal action whose target is neither a defined action nor a real object', () => {
88+
const findings = validateDashboardActionRefs(
89+
dashWithHeaderActions(
90+
[{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }],
91+
{ objects: [{ name: 'account' }] }, // no `opportunity`
92+
),
93+
);
94+
expect(findings).toHaveLength(1);
95+
expect(findings[0]).toMatchObject({
96+
severity: 'error',
97+
rule: DASHBOARD_ACTION_TARGET_UNDEFINED,
98+
});
99+
expect(findings[0].message).toContain('create_opportunity');
100+
expect(findings[0].message).toContain('or object');
101+
});
102+
103+
it('passes a url action pointing at a registered report route', () => {
104+
const findings = validateDashboardActionRefs(
105+
dashWithHeaderActions(
106+
[{ label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' }],
107+
{ reports: [{ name: 'forecast' }] },
108+
),
109+
);
110+
expect(findings).toEqual([]);
111+
});
112+
113+
it('WARNS on a url action pointing at a non-existent in-app route', () => {
114+
const findings = validateDashboardActionRefs(
115+
dashWithHeaderActions([
116+
{ label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' },
117+
]),
118+
);
119+
expect(findings).toHaveLength(1);
120+
expect(findings[0]).toMatchObject({
121+
severity: 'warning',
122+
rule: DASHBOARD_ACTION_ROUTE_UNRESOLVED,
123+
where: 'dashboard "exec" · header action "Forecast"',
124+
path: 'dashboards[0].header.actions[0].actionUrl',
125+
});
126+
expect(findings[0].message).toContain('/reports/forecast');
127+
expect(findings[0].message).toContain('report named "forecast"');
128+
});
129+
130+
it('resolves an object route embedded mid-path (app-scoped route)', () => {
131+
const findings = validateDashboardActionRefs(
132+
dashWithHeaderActions(
133+
[{ label: 'Deals', actionType: 'url', actionUrl: '/apps/crm/objects/deal' }],
134+
{ objects: [{ name: 'deal' }] },
135+
),
136+
);
137+
expect(findings).toEqual([]);
138+
});
139+
140+
it('skips external URLs, interpolated targets, and opaque routes (no false positives)', () => {
141+
const findings = validateDashboardActionRefs(
142+
dashWithHeaderActions([
143+
{ label: 'Docs', actionType: 'url', actionUrl: 'https://example.com/x' },
144+
{ label: 'Proto', actionType: 'url', actionUrl: '//cdn.example.com/y' },
145+
{ label: 'Dyn', actionType: 'url', actionUrl: '/reports/${ctx.reportId}' },
146+
{ label: 'Home', actionType: 'url', actionUrl: '/home' },
147+
{ label: 'Settings', actionType: 'url', actionUrl: '/settings/profile' },
148+
{ label: 'Bare', actionType: 'url', actionUrl: 'some-handler' },
149+
]),
150+
);
151+
expect(findings).toEqual([]);
152+
});
153+
154+
it('defaults a missing actionType to url (never errors on an unqualified target)', () => {
155+
const findings = validateDashboardActionRefs(
156+
dashWithHeaderActions([{ label: 'Mystery', actionUrl: 'do_something' }]),
157+
);
158+
// `do_something` has no leading slash → treated as opaque url → skipped.
159+
expect(findings).toEqual([]);
160+
});
161+
162+
it('checks per-widget actionUrl buttons (script)', () => {
163+
const findings = validateDashboardActionRefs({
164+
dashboards: [
165+
{
166+
name: 'ops',
167+
label: 'Ops',
168+
widgets: [
169+
{ id: 'kpi', dataset: 'd', values: ['x'], actionType: 'script', actionUrl: 'ghost_action' },
170+
{ id: 'noaction', dataset: 'd', values: ['x'] },
171+
],
172+
},
173+
],
174+
});
175+
expect(findings).toHaveLength(1);
176+
expect(findings[0]).toMatchObject({
177+
severity: 'error',
178+
rule: DASHBOARD_ACTION_TARGET_UNDEFINED,
179+
where: 'dashboard "ops" · widget "kpi" action',
180+
path: 'dashboards[0].widgets[0].actionUrl',
181+
});
182+
});
183+
184+
it('covers the issue #3367 repro: one script + one modal error, one url warning', () => {
185+
// Faithful to the runtime: `create_opportunity` resolves via the modal
186+
// <verb>_<object> convention ONLY when an `opportunity` object exists. Here
187+
// it does not, so all three targets are dead.
188+
const findings = validateDashboardActionRefs(
189+
dashWithHeaderActions([
190+
{ label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' },
191+
{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' },
192+
{ label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' },
193+
]),
194+
);
195+
const errors = findings.filter((f) => f.severity === 'error');
196+
const warnings = findings.filter((f) => f.severity === 'warning');
197+
expect(errors).toHaveLength(2);
198+
expect(warnings).toHaveLength(1);
199+
expect(errors.map((e) => e.path)).toEqual([
200+
'dashboards[0].header.actions[0].actionUrl',
201+
'dashboards[0].header.actions[1].actionUrl',
202+
]);
203+
});
204+
205+
it('tolerates junk / empty input and dashboards without actions', () => {
206+
expect(validateDashboardActionRefs({})).toEqual([]);
207+
expect(validateDashboardActionRefs(undefined as unknown as Record<string, unknown>)).toEqual([]);
208+
expect(validateDashboardActionRefs({ dashboards: [] })).toEqual([]);
209+
expect(validateDashboardActionRefs({ dashboards: [null, 42] as unknown })).toEqual([]);
210+
expect(
211+
validateDashboardActionRefs({ dashboards: [{ name: 'd', widgets: [], header: {} }] }),
212+
).toEqual([]);
213+
});
214+
});

0 commit comments

Comments
 (0)