From bd66ec37aaef3fe3a1ae84ca8f5474eab3237b36 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 13:48:26 +0000 Subject: [PATCH 1/2] feat(lint): flag dead action/route references in dashboard header actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `_` 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 Claude-Session: https://claude.ai/code/session_018KSiJuXDAi78CQD3Lv2TNJ --- .changeset/flag-dead-dashboard-action-refs.md | 11 + .../getting-started/validating-metadata.mdx | 27 ++ packages/cli/src/commands/compile.ts | 34 ++ packages/cli/src/commands/validate.ts | 40 ++- packages/lint/src/index.ts | 10 + .../validate-dashboard-action-refs.test.ts | 214 +++++++++++ .../src/validate-dashboard-action-refs.ts | 339 ++++++++++++++++++ 7 files changed, 674 insertions(+), 1 deletion(-) create mode 100644 .changeset/flag-dead-dashboard-action-refs.md create mode 100644 packages/lint/src/validate-dashboard-action-refs.test.ts create mode 100644 packages/lint/src/validate-dashboard-action-refs.ts diff --git a/.changeset/flag-dead-dashboard-action-refs.md b/.changeset/flag-dead-dashboard-action-refs.md new file mode 100644 index 0000000000..ed9502c53d --- /dev/null +++ b/.changeset/flag-dead-dashboard-action-refs.md @@ -0,0 +1,11 @@ +--- +"@objectstack/lint": minor +"@objectstack/cli": minor +--- + +Flag dead action/route references in dashboard header & widget actions (ADR-0049 for references, #3367). + +`os validate` / `os build` now run a new `validateDashboardActionRefs` gate over every dashboard `header.actions[]` and widget `actionUrl`: + +- `actionType: 'script' | 'modal'` — **error** unless `actionUrl` resolves to a defined action (`stack.actions` or an object's `actions`). `modal` also resolves via the runtime `_` 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. +- `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. diff --git a/content/docs/getting-started/validating-metadata.mdx b/content/docs/getting-started/validating-metadata.mdx index aa7b8e26de..bbd945f259 100644 --- a/content/docs/getting-started/validating-metadata.mdx +++ b/content/docs/getting-started/validating-metadata.mdx @@ -48,6 +48,31 @@ If a name doesn't resolve, the chart renders **empty** — no error (ADR-0021). `os validate` resolves every binding against the declared datasets and fails on a dangling one. +### 3. Dead action/route references + +A dashboard `header.actions[]` button (and a widget's `actionUrl`) names a target: +a `script`/`modal` action, or a `url` route. Nothing in the schema checks that the +target exists, so a button can ship pointing at an action defined nowhere — it +renders and then **silently does nothing** when clicked. This is ADR-0049's +"declared ≠ enforced" gate applied to *references*. + +```ts +header: { + actions: [ + // ✗ script/modal target resolves to no defined action → error + { label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' }, + // ✓ modal _ against a real `opportunity` object + { label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }, + // ⚠ url path matching no in-app route → warning + { label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' }, + ], +} +``` + +`script`/`modal` targets that resolve nowhere **fail** validation; a `url` path +whose `objects/reports/dashboards/pages/views` route is unregistered is **warned** +(external, interpolated, and opaque routes are skipped). + ## The one gate, two entry points `os validate` and `os build` (alias of `os compile`) run the **same** validator: @@ -57,6 +82,7 @@ dangling one. | Protocol schema (Zod) | ✓ | ✓ | | CEL / predicate validation | ✓ | ✓ | | Widget-binding integrity | ✓ | ✓ | +| Dashboard action/route references (ADR-0049) | ✓ | ✓ | | Security posture (ADR-0090 — e.g. every custom object declares `sharingModel`) | ✓ | ✓ | | Emits `dist/objectstack.json` | — | ✓ | @@ -81,6 +107,7 @@ A clean run walks each gate and reports timing: → Checking list-view navigation modes (ADR-0053)... → Checking view container shape... → Checking dashboard widget bindings (ADR-0021)... + → Checking dashboard action references (ADR-0049)... → Checking SDUI styling (ADR-0065)... → Checking JSX-source pages (ADR-0080)... → Checking React-source pages (ADR-0081)... diff --git a/packages/cli/src/commands/compile.ts b/packages/cli/src/commands/compile.ts index 2888043c84..099773529e 100644 --- a/packages/cli/src/commands/compile.ts +++ b/packages/cli/src/commands/compile.ts @@ -11,6 +11,7 @@ import { lowerCallables } from '../utils/lower-callables.js'; import { validateStackExpressions } from '@objectstack/lint'; import { validateVisibilityPredicates } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; +import { validateDashboardActionRefs } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateSecurityPosture, buildAccessMatrix, diffAccessMatrix } from '@objectstack/lint'; import { lintFlowPatterns } from '../utils/lint-flow-patterns.js'; @@ -218,6 +219,39 @@ export default class Compile extends Command { } } + // 3c-bis. Dashboard action/route reference integrity (ADR-0049 for + // references, #3367). A header/widget action naming a `script`/`modal` + // target that resolves to no defined action, or a `url` target that + // matches no in-app route, ships a button that renders and silently + // does nothing on click. Dead script/modal targets fail the build + // (they fail open at runtime); unresolved url routes are advisory. + if (!flags.json) printStep('Checking dashboard action references (ADR-0049)...'); + const actionRefFindings = validateDashboardActionRefs(result.data as Record); + const actionRefErrors = actionRefFindings.filter((f) => f.severity === 'error'); + const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning'); + if (actionRefErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ success: false, error: 'dashboard action reference validation failed', issues: actionRefErrors })); + this.exit(1); + } + console.log(''); + printError(`Dashboard action reference check failed (${actionRefErrors.length} issue${actionRefErrors.length > 1 ? 's' : ''})`); + for (const f of actionRefErrors.slice(0, 50)) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); + } + this.exit(1); + } + if (actionRefWarnings.length > 0 && !flags.json) { + console.log(''); + for (const w of actionRefWarnings) { + printWarning(`${w.where}: ${w.message}`); + console.log(chalk.dim(` ${w.hint}`)); + console.log(chalk.dim(` rule: ${w.rule} at ${w.path}`)); + } + } + // 3c. SDUI scoped-styling correctness (ADR-0065) — a styled node without // an `id` drops its CSS silently; Tailwind-in-className does nothing // from metadata. Same bar for hand-authored and AI-generated pages diff --git a/packages/cli/src/commands/validate.ts b/packages/cli/src/commands/validate.ts index 18516ec443..3fb0a342dc 100644 --- a/packages/cli/src/commands/validate.ts +++ b/packages/cli/src/commands/validate.ts @@ -12,6 +12,7 @@ import { validateStackExpressions } from '@objectstack/lint'; import { validateListViewMode } from '@objectstack/lint'; import { validateViewContainers } from '@objectstack/lint'; import { validateWidgetBindings } from '@objectstack/lint'; +import { validateDashboardActionRefs } from '@objectstack/lint'; import { validateResponsiveStyles } from '@objectstack/lint'; import { validateJsxPages, validateReactPages, validateReactPageProps, validatePageSourceStyling } from '@objectstack/lint'; import { validateCapabilityReferences } from '@objectstack/lint'; @@ -209,6 +210,43 @@ export default class Validate extends Command { this.exit(1); } + // 3a-bis. Dashboard action/route reference integrity (ADR-0049 for + // references, #3367) — a header/widget action names a `script`/`modal` + // target that resolves to no defined action, or a `url` target that + // matches no in-app route. Nothing else flags it, so it ships as a + // button that renders and silently does nothing on click (a false + // affordance). Dead script/modal targets are errors (fail open at + // runtime); unresolved url routes are advisory warnings. + if (!flags.json) printStep('Checking dashboard action references (ADR-0049)...'); + const actionRefFindings = validateDashboardActionRefs(result.data as Record); + const actionRefErrors = actionRefFindings.filter((f) => f.severity === 'error'); + const actionRefWarnings = actionRefFindings.filter((f) => f.severity === 'warning'); + if (actionRefErrors.length > 0) { + if (flags.json) { + console.log(JSON.stringify({ + valid: false, + errors: actionRefErrors, + warnings: [...widgetWarnings, ...actionRefWarnings], + duration: timer.elapsed(), + }, null, 2)); + this.exit(1); + } + console.log(''); + printError(`Dashboard action reference check failed (${actionRefErrors.length} issue${actionRefErrors.length > 1 ? 's' : ''})`); + for (const f of actionRefErrors.slice(0, 50)) { + console.log(` • ${f.where}: ${f.message}`); + console.log(chalk.dim(` ${f.hint}`)); + console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`)); + } + this.exit(1); + } + if (!flags.json) { + for (const w of actionRefWarnings.slice(0, 50)) { + console.log(chalk.yellow(` ⚠ ${w.where}: ${w.message}`)); + console.log(chalk.dim(` ${w.hint}`)); + } + } + // 3b. SDUI scoped-styling correctness (ADR-0065) — a styled node's // responsiveStyles must be scopable (needs an `id`), reference real // CSS properties + design tokens, and carry a `large` base; @@ -434,7 +472,7 @@ export default class Validate extends Command { valid: true, manifest: config.manifest, stats, - warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories], + warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories], conversions: conversionNotices, specVersionGap: specGap, duration: timer.elapsed(), diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 336cd13684..bf9c681525 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -124,4 +124,14 @@ export { } from './validate-security-posture.js'; export type { SecurityFinding, SecuritySeverity } from './validate-security-posture.js'; +export { + validateDashboardActionRefs, + DASHBOARD_ACTION_TARGET_UNDEFINED, + DASHBOARD_ACTION_ROUTE_UNRESOLVED, +} from './validate-dashboard-action-refs.js'; +export type { + DashboardActionRefFinding, + DashboardActionRefSeverity, +} from './validate-dashboard-action-refs.js'; + export { buildAccessMatrix, diffAccessMatrix } from './build-access-matrix.js'; diff --git a/packages/lint/src/validate-dashboard-action-refs.test.ts b/packages/lint/src/validate-dashboard-action-refs.test.ts new file mode 100644 index 0000000000..9beb3efe20 --- /dev/null +++ b/packages/lint/src/validate-dashboard-action-refs.test.ts @@ -0,0 +1,214 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect } from 'vitest'; +import { + validateDashboardActionRefs, + DASHBOARD_ACTION_TARGET_UNDEFINED, + DASHBOARD_ACTION_ROUTE_UNRESOLVED, +} from './validate-dashboard-action-refs'; + +/** Build a stack with a single dashboard whose header carries `actions`. */ +function dashWithHeaderActions(actions: unknown[], extra: Record = {}) { + return { + ...extra, + dashboards: [ + { name: 'exec', label: 'Executive', header: { actions }, widgets: [] }, + ], + }; +} + +describe('validateDashboardActionRefs (ADR-0049 references / #3367)', () => { + it('passes a script action that names a defined global action', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions( + [{ label: 'Recalc', actionType: 'script', actionUrl: 'recalc_totals' }], + { actions: [{ name: 'recalc_totals', type: 'script' }] }, + ), + ); + expect(findings).toEqual([]); + }); + + it('passes a script action that names a defined object-embedded action', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions( + [{ label: 'Close', actionType: 'script', actionUrl: 'close_deal' }], + { objects: [{ name: 'opportunity', actions: [{ name: 'close_deal', type: 'script' }] }] }, + ), + ); + expect(findings).toEqual([]); + }); + + it('ERRORS on a script action whose target is defined nowhere', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions([ + { label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: DASHBOARD_ACTION_TARGET_UNDEFINED, + where: 'dashboard "exec" · header action "Export PDF"', + path: 'dashboards[0].header.actions[0].actionUrl', + }); + expect(findings[0].message).toContain('export_dashboard_pdf'); + }); + + it('passes a modal action that names a defined action', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions( + [{ label: 'New Deal', actionType: 'modal', actionUrl: 'quick_create_deal' }], + { actions: [{ name: 'quick_create_deal', type: 'modal' }] }, + ), + ); + expect(findings).toEqual([]); + }); + + it('passes a modal action using the _ convention against a real object', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions( + [{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }], + { objects: [{ name: 'opportunity' }] }, + ), + ); + expect(findings).toEqual([]); + }); + + it('passes a modal action that is a bare object name (create-form fallback)', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions( + [{ label: 'Add Lead', actionType: 'modal', actionUrl: 'lead' }], + { objects: [{ name: 'lead' }] }, + ), + ); + expect(findings).toEqual([]); + }); + + it('ERRORS on a modal action whose target is neither a defined action nor a real object', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions( + [{ label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }], + { objects: [{ name: 'account' }] }, // no `opportunity` + ), + ); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: DASHBOARD_ACTION_TARGET_UNDEFINED, + }); + expect(findings[0].message).toContain('create_opportunity'); + expect(findings[0].message).toContain('or object'); + }); + + it('passes a url action pointing at a registered report route', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions( + [{ label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' }], + { reports: [{ name: 'forecast' }] }, + ), + ); + expect(findings).toEqual([]); + }); + + it('WARNS on a url action pointing at a non-existent in-app route', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions([ + { label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' }, + ]), + ); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'warning', + rule: DASHBOARD_ACTION_ROUTE_UNRESOLVED, + where: 'dashboard "exec" · header action "Forecast"', + path: 'dashboards[0].header.actions[0].actionUrl', + }); + expect(findings[0].message).toContain('/reports/forecast'); + expect(findings[0].message).toContain('report named "forecast"'); + }); + + it('resolves an object route embedded mid-path (app-scoped route)', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions( + [{ label: 'Deals', actionType: 'url', actionUrl: '/apps/crm/objects/deal' }], + { objects: [{ name: 'deal' }] }, + ), + ); + expect(findings).toEqual([]); + }); + + it('skips external URLs, interpolated targets, and opaque routes (no false positives)', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions([ + { label: 'Docs', actionType: 'url', actionUrl: 'https://example.com/x' }, + { label: 'Proto', actionType: 'url', actionUrl: '//cdn.example.com/y' }, + { label: 'Dyn', actionType: 'url', actionUrl: '/reports/${ctx.reportId}' }, + { label: 'Home', actionType: 'url', actionUrl: '/home' }, + { label: 'Settings', actionType: 'url', actionUrl: '/settings/profile' }, + { label: 'Bare', actionType: 'url', actionUrl: 'some-handler' }, + ]), + ); + expect(findings).toEqual([]); + }); + + it('defaults a missing actionType to url (never errors on an unqualified target)', () => { + const findings = validateDashboardActionRefs( + dashWithHeaderActions([{ label: 'Mystery', actionUrl: 'do_something' }]), + ); + // `do_something` has no leading slash → treated as opaque url → skipped. + expect(findings).toEqual([]); + }); + + it('checks per-widget actionUrl buttons (script)', () => { + const findings = validateDashboardActionRefs({ + dashboards: [ + { + name: 'ops', + label: 'Ops', + widgets: [ + { id: 'kpi', dataset: 'd', values: ['x'], actionType: 'script', actionUrl: 'ghost_action' }, + { id: 'noaction', dataset: 'd', values: ['x'] }, + ], + }, + ], + }); + expect(findings).toHaveLength(1); + expect(findings[0]).toMatchObject({ + severity: 'error', + rule: DASHBOARD_ACTION_TARGET_UNDEFINED, + where: 'dashboard "ops" · widget "kpi" action', + path: 'dashboards[0].widgets[0].actionUrl', + }); + }); + + it('covers the issue #3367 repro: one script + one modal error, one url warning', () => { + // Faithful to the runtime: `create_opportunity` resolves via the modal + // _ convention ONLY when an `opportunity` object exists. Here + // it does not, so all three targets are dead. + const findings = validateDashboardActionRefs( + dashWithHeaderActions([ + { label: 'Export PDF', actionType: 'script', actionUrl: 'export_dashboard_pdf' }, + { label: 'New Deal', actionType: 'modal', actionUrl: 'create_opportunity' }, + { label: 'Forecast', actionType: 'url', actionUrl: '/reports/forecast' }, + ]), + ); + const errors = findings.filter((f) => f.severity === 'error'); + const warnings = findings.filter((f) => f.severity === 'warning'); + expect(errors).toHaveLength(2); + expect(warnings).toHaveLength(1); + expect(errors.map((e) => e.path)).toEqual([ + 'dashboards[0].header.actions[0].actionUrl', + 'dashboards[0].header.actions[1].actionUrl', + ]); + }); + + it('tolerates junk / empty input and dashboards without actions', () => { + expect(validateDashboardActionRefs({})).toEqual([]); + expect(validateDashboardActionRefs(undefined as unknown as Record)).toEqual([]); + expect(validateDashboardActionRefs({ dashboards: [] })).toEqual([]); + expect(validateDashboardActionRefs({ dashboards: [null, 42] as unknown })).toEqual([]); + expect( + validateDashboardActionRefs({ dashboards: [{ name: 'd', widgets: [], header: {} }] }), + ).toEqual([]); + }); +}); diff --git a/packages/lint/src/validate-dashboard-action-refs.ts b/packages/lint/src/validate-dashboard-action-refs.ts new file mode 100644 index 0000000000..c2d9692eca --- /dev/null +++ b/packages/lint/src/validate-dashboard-action-refs.ts @@ -0,0 +1,339 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0049 — references] Reference-integrity for dashboard header & widget + * action targets (issue #3367). + * + * ADR-0049 established the "enforce-or-remove" gate for spec *properties*: a + * declared property the runtime does not honour is a false promise and must be + * enforced, marked experimental, or removed. This rule applies the SAME honesty + * principle to *references*. A dashboard header action (or a widget's header + * action button) names a target — a `script`/`modal` action, or a `url` route — + * that must actually resolve. A dangling target ships a button that renders and, + * on click, silently does nothing: a false affordance, exactly the failure + * ADR-0049 exists to prevent, just for a reference rather than a property. + * + * Nothing in the protocol schema can express this: `actionUrl` is a free string, + * so `{ actionType: 'script', actionUrl: 'export_dashboard_pdf' }` parses and + * ships even when no such action is defined anywhere in the stack. + * + * Surfaces checked: + * - dashboard `header.actions[]` — each `{ actionType, actionUrl }` + * - dashboard `widgets[].actionUrl` (+ `actionType`) — the per-widget button + * + * Resolution mirrors the objectui runtime dispatch (`DashboardRenderer` + + * `DashboardView`) so the lint flags exactly what would fail to resolve at + * runtime: + * + * actionType 'script' → `actionUrl` must name a DEFINED action (`stack.actions` + * or any `object.actions`, by `name`). A script target that names no + * defined action fails open at runtime ("action not found"). → ERROR. + * + * actionType 'modal' → `actionUrl` resolves if it names a defined action, OR + * matches the runtime `_` convention the modalHandler + * implements (create_/new_/add_/edit_/update_ + a defined object), OR is a + * bare defined object name (the handler falls back to that object's create + * form). Otherwise → ERROR. + * + * actionType 'url' → a relative in-app path. WARN when a recognizable + * `/` segment (objects/reports/dashboards/pages/views) + * names an entity that does not exist in this stack. External URLs + * (`http(s)://`, `//`), interpolated targets (`${…}`), and opaque routes + * (no recognized collection segment) are skipped — they cannot be resolved + * statically and may be host/app/plugin routes. → WARNING. + * + * actionType 'flow' | 'api' — not checked: flow targets resolve against the + * automation engine / other packages, and api targets are opaque endpoints. + * Out of scope for #3367. + * + * Severity split follows the issue's acceptance criteria: an undefined + * `script`/`modal` target FAILS validation (a genuine dead reference that fails + * open at runtime as a dead button); an unresolved `url` route is advisory + * (route resolution is app-context-dependent, and a path may be served by + * another installed package or a host/console route). External, interpolated, + * convention, and opaque targets are exempted to keep false positives near zero + * — the same conservative posture as the sibling `lint-view-refs` and + * `validate-capability-references` rules. + */ + +export const DASHBOARD_ACTION_TARGET_UNDEFINED = 'dashboard-action-target-undefined'; +export const DASHBOARD_ACTION_ROUTE_UNRESOLVED = 'dashboard-action-route-unresolved'; + +export type DashboardActionRefSeverity = 'error' | 'warning'; + +export interface DashboardActionRefFinding { + /** `error` for a dangling script/modal action; `warning` for an unresolved url route. */ + severity: DashboardActionRefSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `dashboard "sales_overview" · header action "Export PDF"`. */ + where: string; + /** Config path, e.g. `dashboards[2].header.actions[0].actionUrl`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +type AnyRec = Record; + +/** Coerce a collection (array or name-keyed map) to an array of records, injecting + * `name` from the map key — mirrors the helper in the sibling authoring lints so + * the rule works on both the parsed (array) and normalized (map) stack shapes. */ +function asArray(v: unknown): AnyRec[] { + if (Array.isArray(v)) return v as AnyRec[]; + if (v && typeof v === 'object') { + return Object.entries(v as AnyRec).map(([name, def]) => ({ name, ...(def as AnyRec) })); + } + return []; +} + +function strName(v: unknown): string | undefined { + return typeof v === 'string' && v.length > 0 ? v : undefined; +} + +/** The runtime modal `_` convention (objectui `DashboardView` + * modalHandler): `create_/new_/add_/edit_/update_` + an object name opens that + * object's create/edit form. */ +const MODAL_VERB_RE = /^(?:create|new|add|edit|update)_(.+)$/; + +/** URL path segments that name a metadata collection, mapped to the stack key + * whose members can appear after them in an in-app route + * (`/…/objects/crm_lead`, `/reports/forecast`, `/dashboards/exec`, …). Both the + * singular and plural spellings are accepted. */ +const URL_COLLECTION_TO_STACK_KEY: Record = { + object: 'objects', + objects: 'objects', + report: 'reports', + reports: 'reports', + dashboard: 'dashboards', + dashboards: 'dashboards', + page: 'pages', + pages: 'pages', + view: 'views', + views: 'views', +}; + +/** Derive the name a top-level `views` container registers under (mirrors the + * runtime loader's `resolveMetadataItemName('views', …)` fallbacks). */ +function viewContainerName(item: AnyRec): string | undefined { + return ( + strName(item.name) ?? + strName(item.id) ?? + strName(item.object) ?? + strName((item.list as AnyRec | undefined)?.data && ((item.list as AnyRec).data as AnyRec).object) ?? + strName((item.form as AnyRec | undefined)?.data && ((item.form as AnyRec).data as AnyRec).object) + ); +} + +interface KnownTargets { + /** Every action name defined in the stack (global + object-embedded). */ + actions: Set; + /** Object names (also valid as bare modal targets and `objects/` routes). */ + objects: Set; + reports: Set; + dashboards: Set; + pages: Set; + /** View names routable as `views/` — container names plus object names. */ + views: Set; +} + +/** Build the author-time "known target" sets from a stack. */ +function collectKnownTargets(stack: AnyRec): KnownTargets { + const actions = new Set(); + const objects = new Set(); + const reports = new Set(); + const dashboards = new Set(); + const pages = new Set(); + const views = new Set(); + + const collectNames = (v: unknown, into: Set, name: (rec: AnyRec) => string | undefined) => { + for (const item of asArray(v)) { + if (!item || typeof item !== 'object') continue; + const n = name(item); + if (n) into.add(n); + } + }; + + collectNames(stack.actions, actions, (a) => strName(a.name)); + for (const obj of asArray(stack.objects)) { + if (!obj || typeof obj !== 'object') continue; + const n = strName(obj.name); + if (n) objects.add(n); + collectNames(obj.actions, actions, (a) => strName(a.name)); + } + collectNames(stack.reports, reports, (r) => strName(r.name)); + collectNames(stack.dashboards, dashboards, (d) => strName(d.name)); + collectNames(stack.pages, pages, (p) => strName(p.name)); + collectNames(stack.views, views, viewContainerName); + // An object's default view is routable by the object's own name too. + for (const o of objects) views.add(o); + + return { actions, objects, reports, dashboards, pages, views }; +} + +/** Does a `script`/`modal` `actionUrl` resolve? */ +function resolveActionTarget( + actionType: 'script' | 'modal', + target: string, + known: KnownTargets, +): boolean { + if (known.actions.has(target)) return true; + if (actionType === 'modal') { + // Runtime modalHandler convention: `_` or a bare object name + // opens that object's create/edit form. + if (known.objects.has(target)) return true; + const m = MODAL_VERB_RE.exec(target); + if (m && known.objects.has(m[1])) return true; + } + return false; +} + +/** + * Resolve a relative `url` in-app route. Returns: + * - `null` when the target is not statically resolvable (external, interpolated, + * or carries no recognized `/` segment) — SKIP, no finding. + * - `{ collection, name }` for a recognized `/` pair that does + * NOT exist in the stack — WARN. + * - `undefined` when a recognized pair DID resolve — OK, no finding. + */ +function resolveUrlRoute( + target: string, + known: KnownTargets, +): { collection: string; name: string } | null | undefined { + // External / protocol-relative — leaves the app; not an in-app route. + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(target) || target.startsWith('//')) return null; + // Interpolated — resolved by the renderer at click time, not statically known. + if (target.includes('${')) return null; + // Only relative in-app paths are considered. + if (!target.startsWith('/')) return null; + + // Strip query + hash, then split into non-empty segments. + const pathPart = target.split(/[?#]/, 1)[0]; + const segments = pathPart.split('/').filter(Boolean); + + for (let i = 0; i < segments.length - 1; i++) { + const stackKey = URL_COLLECTION_TO_STACK_KEY[segments[i]]; + if (!stackKey) continue; + const name = segments[i + 1]; + if (known[stackKey].has(name)) return undefined; // resolved + return { collection: segments[i], name }; // recognized shape, unknown name + } + return null; // no recognized collection segment — opaque route, skip +} + +interface HeaderAction { + actionType?: string; + actionUrl?: string; + label?: string; +} + +/** + * Validate every dashboard header / widget action reference in a stack. Returns + * findings (empty = clean). `script`/`modal` dead targets are errors; `url` + * unresolved routes are warnings. + */ +export function validateDashboardActionRefs(stack: AnyRec): DashboardActionRefFinding[] { + const findings: DashboardActionRefFinding[] = []; + if (!stack || typeof stack !== 'object') return findings; + + const dashboards = asArray(stack.dashboards); + if (dashboards.length === 0) return findings; + + const known = collectKnownTargets(stack); + + const checkOne = ( + action: HeaderAction, + where: string, + path: string, + ) => { + const target = strName(action.actionUrl); + if (!target) return; // nothing referenced (widget with no action button) + if (target.includes('${')) return; // dynamic target — not statically resolvable + + // Renderer default: a missing actionType is treated as a 'url' navigation + // (DashboardRenderer builds header ActionDefs with `type: actionType || 'url'`). + const actionType = strName(action.actionType) ?? 'url'; + + if (actionType === 'script' || actionType === 'modal') { + if (resolveActionTarget(actionType, target, known)) return; + const kindWord = actionType === 'script' ? 'script' : 'modal'; + findings.push({ + severity: 'error', + rule: DASHBOARD_ACTION_TARGET_UNDEFINED, + where, + path, + message: + `${kindWord} action target "${target}" resolves to no defined action` + + (actionType === 'modal' ? ' or object' : '') + + `. The button renders but does nothing when clicked — a dangling reference ` + + `the runtime cannot dispatch (ADR-0049: a declared reference must resolve).`, + hint: + actionType === 'modal' + ? `Define an action named "${target}" (stack.actions or the object's actions), ` + + `use the "_" convention against a real object ` + + `(e.g. "create_"), point actionUrl at an existing object, or remove the button.` + : `Define a script action named "${target}" (stack.actions or the object's actions) ` + + `with an inline body or a registered handler, or remove the button.`, + }); + return; + } + + if (actionType === 'url') { + const route = resolveUrlRoute(target, known); + if (!route) return; // skip (external/interpolated/opaque) or resolved + findings.push({ + severity: 'warning', + rule: DASHBOARD_ACTION_ROUTE_UNRESOLVED, + where, + path, + message: + `url action target "${target}" points at ${route.collection}/${route.name}, ` + + `but no ${route.collection.replace(/s$/, '')} named "${route.name}" is registered ` + + `in this stack — the button likely navigates to a dead route.`, + hint: + `Check the path for a typo, define the referenced ${route.collection.replace(/s$/, '')}, ` + + `or ignore this if the route is served by another installed package or a host/console route.`, + }); + return; + } + // 'flow' | 'api' | custom types are out of scope (see module header). + }; + + for (let di = 0; di < dashboards.length; di++) { + const dash = dashboards[di]; + if (!dash || typeof dash !== 'object') continue; + const dashName = strName(dash.name) ?? `(dashboard ${di})`; + const dashPath = `dashboards[${di}]`; + + // Header actions. + const headerActions = asArray((dash.header as AnyRec | undefined)?.actions); + for (let ai = 0; ai < headerActions.length; ai++) { + const action = headerActions[ai] as HeaderAction | null; + if (!action || typeof action !== 'object') continue; + const label = strName(action.label) ?? strName(action.actionUrl) ?? `#${ai}`; + checkOne( + action, + `dashboard "${dashName}" · header action "${label}"`, + `${dashPath}.header.actions[${ai}].actionUrl`, + ); + } + + // Per-widget action buttons. + const widgets = asArray(dash.widgets); + for (let wi = 0; wi < widgets.length; wi++) { + const widget = widgets[wi]; + if (!widget || typeof widget !== 'object') continue; + if (!strName(widget.actionUrl)) continue; + const widgetId = strName(widget.id) ?? `#${wi}`; + checkOne( + { actionType: widget.actionType as string | undefined, actionUrl: widget.actionUrl as string | undefined }, + `dashboard "${dashName}" · widget "${widgetId}" action`, + `${dashPath}.widgets[${wi}].actionUrl`, + ); + } + } + + return findings; +} From 89ad192e6f2bdf89c81b61f54f85940703091288 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 21 Jul 2026 13:52:47 +0000 Subject: [PATCH 2/2] docs(cli): add dashboard action-reference check to os build step output The `os build` sample output in cli.mdx enumerates each compile step; the new ADR-0049 dashboard action-reference check landed between the widget-binding and SDUI-styling steps, so list it there too. Addresses the docs-drift advisory on #3383. Docs-only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018KSiJuXDAi78CQD3Lv2TNJ --- content/docs/getting-started/cli.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/content/docs/getting-started/cli.mdx b/content/docs/getting-started/cli.mdx index da12e6dfa2..a6ca466bfb 100644 --- a/content/docs/getting-started/cli.mdx +++ b/content/docs/getting-started/cli.mdx @@ -379,6 +379,7 @@ os compile --json # JSON output for CI pipelines → Validating protocol compliance... → Validating expressions (ADR-0032)... → Checking dashboard widget bindings (ADR-0021)... + → Checking dashboard action references (ADR-0049)... → Checking SDUI styling (ADR-0065)... → Checking security posture (ADR-0090 D7)... → Collecting package docs (ADR-0046)...