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
45 changes: 45 additions & 0 deletions .changeset/action-no-placement-lint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
"@objectstack/lint": minor
"@objectstack/cli": minor
"@objectstack/metadata-protocol": minor
---

Lint an action nobody placed (ADR-0078 Phase 3, Tier-A `action-locations`).

New advisory rule `action-no-placement`: an action that declares no
`locations` and that no list view places by name renders on **no** surface —
it parses, publishes, and appears in Setup, while no user can ever click it.
ADR-0078 names this shape in its opening paragraph and Phase 3 asks for
exactly this rule; the shared completeness predicate it envisioned was never
built, so this lands standalone, one verified shape at a time.

What made it verifiable now: objectui#3142 collapsed four disagreeing
renderers onto one placement predicate. Before that, `action:bar` and the
record header rendered an *undeclared* action anyway, so the shape only looked
inert on paper. As of objectui 17.1 it is measurably inert.

Two things are deliberately **not** flagged:

- **`locations: []`** — the documented headless action (callable over REST /
MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so a
headless declaration is the only legal way to expose one. The rule therefore
distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key
absent) and only reports the latter.
- **Actions a view places by name** — `bulkActions`, `bulkActionDefs`
(including `execution: 'aggregate'` defs, whose whole point is an action with
no single-record home) and `rowActions`, across all three list-view tiers:
`views[i].list`, `views[i].listViews.<key>` and the object-embedded
`objects[i].listViews.<key>`.

Advisory, never fatal — a view in another installed package may be the one
placing the action, the same reason `validateSemanticRoles` and
`lintLivenessProperties` warn rather than gate.

Also: the action form schema in `@objectstack/metadata-protocol` no longer
declares `shortcut` / `bulkEnabled`. Both were retired as `retiredKey()`
tombstones in spec 17, and this schema is what the Studio designer renders its
fallback form from — so advertising them handed authors two inputs that could
only ever produce an unsaveable draft (objectui#3145 removed the matching
dedicated controls). And `content/docs/ui/actions.mdx` now says which surface
is the exception to location filtering, instead of a blanket claim its own
showcase contradicted.
10 changes: 9 additions & 1 deletion content/docs/ui/actions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,17 @@ defineView({
```

<Callout type="info">
Naming an action in a widget does **not** bypass location filtering — the
Naming an action in a **widget** does not bypass location filtering — the
engine still requires the action to declare the matching location (that's why
`MarkDoneAction` above includes `record_section`).

The **selection bar is the exception**, and the only one: an action named in a
list view's `bulkActions` or `bulkActionDefs` is placed by that declaration,
not by `locations`. That is what the retired `action.bulkEnabled` tombstone
prescribes ("the multi-select toolbar is driven by the LIST VIEW's
`bulkActions` / `bulkActionDefs`"), and it is what lets an aggregate bulk
action — one that acts on a whole selection and has no single-record home by
construction — exist at all.
</Callout>

## Collect input and shape the UX
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/lint/authoring-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
validateVisibilityPredicates,
validateSecurityPosture,
validateOrgAxisRedLines,
validateActionLocations,
} from '@objectstack/lint';
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
Expand Down Expand Up @@ -385,6 +386,20 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
source: 'packages/lint/src/validate-semantic-roles.ts',
run: (stack) => validateSemanticRoles(stack),
},
// ADR-0078 Phase 3 (Tier-A `action-locations`) — an action that declares no
// `locations` and that no view places by name renders on no surface at all.
// objectui#3142 made that measurable: four renderers used to show an
// undeclared action anyway, and now none does. Advisory: a view in another
// installed package may be the one placing it, and `locations: []` (the
// documented headless shape) is deliberately never flagged.
{
name: 'validateActionLocations',
tier: 'advisory',
input: 'parsed',
commands: ALL,
source: 'packages/lint/src/validate-action-locations.ts',
run: (stack) => validateActionLocations(stack),
},
// framework#3434 — seeds replay on every boot, so a `mode: 'insert'` dataset
// duplicates its table on every restart.
{
Expand Down
3 changes: 3 additions & 0 deletions packages/lint/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ export type {
export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';

export { validateActionLocations, ACTION_NO_PLACEMENT } from './validate-action-locations.js';
export type { ActionLocationsFinding, ActionLocationsSeverity } from './validate-action-locations.js';

export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js';
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';

Expand Down
165 changes: 165 additions & 0 deletions packages/lint/src/validate-action-locations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';
import { validateActionLocations, ACTION_NO_PLACEMENT } from './validate-action-locations.js';

/** A stack whose single action declares a real placement. */
const placed = () => ({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [
{
name: 'crm_convert_lead',
label: 'Convert',
type: 'script',
locations: ['record_header'],
},
],
});

/** The same action with the placement key absent. */
const unplaced = () => ({
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
actions: [{ name: 'crm_convert_lead', label: 'Convert', type: 'script' }],
});

describe('validateActionLocations', () => {
it('flags an action that declares no locations and that no view places', () => {
const findings = validateActionLocations(unplaced());

expect(findings).toHaveLength(1);
expect(findings[0].severity).toBe('warning');
expect(findings[0].rule).toBe(ACTION_NO_PLACEMENT);
expect(findings[0].path).toBe('actions[0]');
expect(findings[0].where).toBe('action "crm_convert_lead"');
expect(findings[0].message).toContain('renders on no surface');
expect(findings[0].hint).toContain('locations: []');
});

it('accepts a declared placement', () => {
expect(validateActionLocations(placed())).toEqual([]);
});

it('walks object-embedded actions too', () => {
const findings = validateActionLocations({
objects: [
{
name: 'crm_lead',
actions: [{ name: 'crm_score', label: 'Score', type: 'script' }],
},
],
});

expect(findings).toHaveLength(1);
expect(findings[0].path).toBe('objects[0].actions[0]');
});

it('ignores a nameless action — that is action-name-*’s problem, not this rule’s', () => {
expect(validateActionLocations({ actions: [{ label: 'Nameless', type: 'script' }] })).toEqual([]);
});

describe('— headless actions (`locations: []`) are never flagged', () => {
it('accepts an explicitly empty placement', () => {
// `content/docs/ui/actions.mdx` documents the empty array as the way to
// declare a REST/MCP/AI-callable action with no UI surface. ADR-0110 D3
// refuses an UNdeclared handler, so this is the only legal shape for one
// — flagging it would fight that ADR.
const findings = validateActionLocations({
actions: [{ name: 'crm_sync_remote', label: 'Sync', type: 'script', locations: [] }],
});
expect(findings).toEqual([]);
});

it('distinguishes "nowhere, deliberately" from an unstated placement', () => {
const findings = validateActionLocations({
actions: [
{ name: 'said_nowhere', type: 'script', locations: [] },
{ name: 'said_nothing', type: 'script' },
],
});
expect(findings.map((f) => f.where)).toEqual(['action "said_nothing"']);
});
});

describe('— a view that places the action by NAME exempts it', () => {
it('exempts an action named in a list view’s bulkActions', () => {
const findings = validateActionLocations({
...unplaced(),
views: [{ name: 'crm_lead', list: { bulkActions: ['crm_convert_lead'] } }],
});
expect(findings).toEqual([]);
});

it('exempts an action named in a bulkActionDefs entry (incl. aggregate defs)', () => {
// objectui#3139: an aggregate bulk action has no single-record location
// by construction — the view naming it IS the placement.
const findings = validateActionLocations({
...unplaced(),
views: [
{
name: 'crm_lead',
list: {
bulkActionDefs: [
{ name: 'crm_convert_lead', operation: 'custom', execution: 'aggregate' },
],
},
},
],
});
expect(findings).toEqual([]);
});

it('exempts an action named in rowActions', () => {
const findings = validateActionLocations({
...unplaced(),
views: [{ name: 'crm_lead', list: { rowActions: ['crm_convert_lead'] } }],
});
expect(findings).toEqual([]);
});

it('exempts via a named listViews entry, not just the default list', () => {
const findings = validateActionLocations({
...unplaced(),
views: [{ name: 'crm_lead', listViews: { hot: { bulkActions: ['crm_convert_lead'] } } }],
});
expect(findings).toEqual([]);
});

it('exempts via an OBJECT-embedded list view — an object has no top-level `list`', () => {
const findings = validateActionLocations({
objects: [
{
name: 'crm_lead',
listViews: { all: { bulkActions: ['crm_convert_lead'] } },
},
],
actions: [{ name: 'crm_convert_lead', label: 'Convert', type: 'script' }],
});
expect(findings).toEqual([]);
});

it('still flags an action no view names, alongside one that is named', () => {
const findings = validateActionLocations({
actions: [
{ name: 'named_one', type: 'script' },
{ name: 'orphan_one', type: 'script' },
],
views: [{ name: 'crm_lead', list: { bulkActions: ['named_one'] } }],
});
expect(findings.map((f) => f.where)).toEqual(['action "orphan_one"']);
});
});

describe('— floor', () => {
it('returns nothing for a clean stack', () => {
expect(validateActionLocations(placed())).toEqual([]);
});

it('returns nothing for an empty stack', () => {
expect(validateActionLocations({})).toEqual([]);
});

it('returns nothing for a null stack', () => {
expect(validateActionLocations(null as unknown as Record<string, unknown>)).toEqual([]);
});
});
});
Loading
Loading