Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/flag-dead-dashboard-action-refs.md
Original file line number Diff line number Diff line change
@@ -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 `<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.
- `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.
1 change: 1 addition & 0 deletions content/docs/getting-started/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)...
Expand Down
27 changes: 27 additions & 0 deletions content/docs/getting-started/validating-metadata.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,31 @@ dashboard, widget, filter, field, and object. Opt a widget out with
`filterBindings: { <name>: false }`, or re-target the filter to one of that
object's own fields.

### 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 <verb>_<object> 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:
Expand All @@ -67,6 +92,7 @@ object's own fields.
| 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` | — | ✓ |

Expand All @@ -91,6 +117,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)...
Expand Down
34 changes: 34 additions & 0 deletions packages/cli/src/commands/compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -258,6 +259,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<string, unknown>);
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
Expand Down
40 changes: 39 additions & 1 deletion packages/cli/src/commands/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -210,6 +211,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<string, unknown>);
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;
Expand Down Expand Up @@ -471,7 +509,7 @@ export default class Validate extends Command {
valid: true,
manifest: config.manifest,
stats,
warnings: [...exprWarnings, ...widgetWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories, ...capProviderWarnings],
warnings: [...exprWarnings, ...widgetWarnings, ...actionRefWarnings, ...styleWarnings, ...jsxWarnings, ...capWarnings, ...flowReadinessWarnings, ...securityAdvisories, ...capProviderWarnings],
conversions: conversionNotices,
specVersionGap: specGap,
duration: timer.elapsed(),
Expand Down
10 changes: 10 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,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';
Loading