Skip to content

Commit db160dd

Browse files
authored
feat(lint): flag dead action/route references in dashboard header actions (#3383)
Closes #3367. New validateDashboardActionRefs rule in @objectstack/lint, wired into os validate and os build: script/modal action targets that resolve to no defined action (or, for modal, the <verb>_<object> convention / a real object) fail validation; url targets that name an unregistered in-app route warn. External, interpolated, and opaque routes are skipped. Docs + changeset included.
1 parent 61ac02e commit db160dd

8 files changed

Lines changed: 675 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/cli.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -379,6 +379,7 @@ os compile --json # JSON output for CI pipelines
379379
→ Validating protocol compliance...
380380
→ Validating expressions (ADR-0032)...
381381
→ Checking dashboard widget bindings (ADR-0021)...
382+
→ Checking dashboard action references (ADR-0049)...
382383
→ Checking SDUI styling (ADR-0065)...
383384
→ Checking security posture (ADR-0090 D7)...
384385
→ Collecting package docs (ADR-0046)...

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@ dashboard, widget, filter, field, and object. Opt a widget out with
5858
`filterBindings: { <name>: false }`, or re-target the filter to one of that
5959
object's own fields.
6060

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

6388
`os validate` and `os build` (alias of `os compile`) run the **same** validator:
@@ -67,6 +92,7 @@ object's own fields.
6792
| Protocol schema (Zod) |||
6893
| CEL / predicate validation |||
6994
| Widget-binding integrity |||
95+
| Dashboard action/route references (ADR-0049) |||
7096
| Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) |||
7197
| Emits `dist/objectstack.json` |||
7298

@@ -91,6 +117,7 @@ A clean run walks each gate and reports timing:
91117
→ Checking list-view navigation modes (ADR-0053)...
92118
→ Checking view container shape...
93119
→ Checking dashboard widget bindings (ADR-0021)...
120+
→ Checking dashboard action references (ADR-0049)...
94121
→ Checking SDUI styling (ADR-0065)...
95122
→ Checking JSX-source pages (ADR-0080)...
96123
→ 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';
@@ -258,6 +259,39 @@ export default class Compile extends Command {
258259
}
259260
}
260261

262+
// 3c-bis. Dashboard action/route reference integrity (ADR-0049 for
263+
// references, #3367). A header/widget action naming a `script`/`modal`
264+
// target that resolves to no defined action, or a `url` target that
265+
// matches no in-app route, ships a button that renders and silently
266+
// does nothing on click. Dead script/modal targets fail the build
267+
// (they fail open at runtime); unresolved url routes are advisory.
268+
if (!flags.json) printStep('Checking dashboard action references (ADR-0049)...');
269+
const actionRefFindings = validateDashboardActionRefs(result.data as Record<string, unknown>);
270+
const actionRefErrors = actionRefFindings.filter((f) => f.severity === 'error');
271+
const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning');
272+
if (actionRefErrors.length > 0) {
273+
if (flags.json) {
274+
console.log(JSON.stringify({ success: false, error: 'dashboard action reference validation failed', issues: actionRefErrors }));
275+
this.exit(1);
276+
}
277+
console.log('');
278+
printError(`Dashboard action reference check failed (${actionRefErrors.length} issue${actionRefErrors.length > 1 ? 's' : ''})`);
279+
for (const f of actionRefErrors.slice(0, 50)) {
280+
console.log(` • ${f.where}: ${f.message}`);
281+
console.log(chalk.dim(` ${f.hint}`));
282+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
283+
}
284+
this.exit(1);
285+
}
286+
if (actionRefWarnings.length > 0 && !flags.json) {
287+
console.log('');
288+
for (const w of actionRefWarnings) {
289+
printWarning(`${w.where}: ${w.message}`);
290+
console.log(chalk.dim(` ${w.hint}`));
291+
console.log(chalk.dim(` rule: ${w.rule} at ${w.path}`));
292+
}
293+
}
294+
261295
// 3c. SDUI scoped-styling correctness (ADR-0065) — a styled node without
262296
// an `id` drops its CSS silently; Tailwind-in-className does nothing
263297
// 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';
@@ -210,6 +211,43 @@ export default class Validate extends Command {
210211
this.exit(1);
211212
}
212213

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

packages/lint/src/index.ts

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

128+
export {
129+
validateDashboardActionRefs,
130+
DASHBOARD_ACTION_TARGET_UNDEFINED,
131+
DASHBOARD_ACTION_ROUTE_UNRESOLVED,
132+
} from './validate-dashboard-action-refs.js';
133+
export type {
134+
DashboardActionRefFinding,
135+
DashboardActionRefSeverity,
136+
} from './validate-dashboard-action-refs.js';
137+
128138
export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js';

0 commit comments

Comments
 (0)