Skip to content

Commit 0800433

Browse files
os-zhuangclaude
andauthored
feat(lint): flag an action nobody placed (ADR-0078 Phase 3) (#4501)
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, shows up in Setup, and 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 deliberate non-findings: - `locations: []` — the documented headless action (callable over REST / MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so this is the only legal way to expose one; flagging it would fight that ADR. The rule distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key absent) and reports only 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. Verified zero findings against app-showcase, app-crm and app-todo. Also in this change: - metadata-protocol's action form schema stops declaring `shortcut` / `bulkEnabled`. Both are spec-17 retiredKey() tombstones, and this schema is what the Studio designer renders its fallback form from — advertising them handed authors two inputs that could only ever produce an unsaveable draft (objectui#3145 removed the matching dedicated controls). - content/docs/ui/actions.mdx said naming an action in a widget never bypasses location filtering. The selection bar IS the exception, which the showcase's own task.view.ts comment already stated — the two now agree. Claude-Session: https://claude.ai/code/session_01S9aiswZBzoVYsyLKRuGByE Co-authored-by: Claude <noreply@anthropic.com>
1 parent b4487aa commit 0800433

7 files changed

Lines changed: 427 additions & 3 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
"@objectstack/lint": minor
3+
"@objectstack/cli": minor
4+
"@objectstack/metadata-protocol": minor
5+
---
6+
7+
Lint an action nobody placed (ADR-0078 Phase 3, Tier-A `action-locations`).
8+
9+
New advisory rule `action-no-placement`: an action that declares no
10+
`locations` and that no list view places by name renders on **no** surface —
11+
it parses, publishes, and appears in Setup, while no user can ever click it.
12+
ADR-0078 names this shape in its opening paragraph and Phase 3 asks for
13+
exactly this rule; the shared completeness predicate it envisioned was never
14+
built, so this lands standalone, one verified shape at a time.
15+
16+
What made it verifiable now: objectui#3142 collapsed four disagreeing
17+
renderers onto one placement predicate. Before that, `action:bar` and the
18+
record header rendered an *undeclared* action anyway, so the shape only looked
19+
inert on paper. As of objectui 17.1 it is measurably inert.
20+
21+
Two things are deliberately **not** flagged:
22+
23+
- **`locations: []`** — the documented headless action (callable over REST /
24+
MCP / AI, no UI surface). ADR-0110 D3 refuses an undeclared handler, so a
25+
headless declaration is the only legal way to expose one. The rule therefore
26+
distinguishes "nowhere, deliberately" (`[]`) from an unstated placement (key
27+
absent) and only reports the latter.
28+
- **Actions a view places by name**`bulkActions`, `bulkActionDefs`
29+
(including `execution: 'aggregate'` defs, whose whole point is an action with
30+
no single-record home) and `rowActions`, across all three list-view tiers:
31+
`views[i].list`, `views[i].listViews.<key>` and the object-embedded
32+
`objects[i].listViews.<key>`.
33+
34+
Advisory, never fatal — a view in another installed package may be the one
35+
placing the action, the same reason `validateSemanticRoles` and
36+
`lintLivenessProperties` warn rather than gate.
37+
38+
Also: the action form schema in `@objectstack/metadata-protocol` no longer
39+
declares `shortcut` / `bulkEnabled`. Both were retired as `retiredKey()`
40+
tombstones in spec 17, and this schema is what the Studio designer renders its
41+
fallback form from — so advertising them handed authors two inputs that could
42+
only ever produce an unsaveable draft (objectui#3145 removed the matching
43+
dedicated controls). And `content/docs/ui/actions.mdx` now says which surface
44+
is the exception to location filtering, instead of a blanket claim its own
45+
showcase contradicted.

content/docs/ui/actions.mdx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,17 @@ defineView({
210210
```
211211

212212
<Callout type="info">
213-
Naming an action in a widget does **not** bypass location filtering — the
213+
Naming an action in a **widget** does not bypass location filtering — the
214214
engine still requires the action to declare the matching location (that's why
215215
`MarkDoneAction` above includes `record_section`).
216+
217+
The **selection bar is the exception**, and the only one: an action named in a
218+
list view's `bulkActions` or `bulkActionDefs` is placed by that declaration,
219+
not by `locations`. That is what the retired `action.bulkEnabled` tombstone
220+
prescribes ("the multi-select toolbar is driven by the LIST VIEW's
221+
`bulkActions` / `bulkActionDefs`"), and it is what lets an aggregate bulk
222+
action — one that acts on a whole selection and has no single-record home by
223+
construction — exist at all.
216224
</Callout>
217225

218226
## Collect input and shape the UX

packages/cli/src/lint/authoring-rules.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import {
9494
validateVisibilityPredicates,
9595
validateSecurityPosture,
9696
validateOrgAxisRedLines,
97+
validateActionLocations,
9798
} from '@objectstack/lint';
9899
import { lintFlowPatterns } from '../utils/lint-flow-patterns.js';
99100
import { lintLivenessProperties } from '../utils/lint-liveness-properties.js';
@@ -385,6 +386,20 @@ export const AUTHORING_RULES: readonly AuthoringRule[] = [
385386
source: 'packages/lint/src/validate-semantic-roles.ts',
386387
run: (stack) => validateSemanticRoles(stack),
387388
},
389+
// ADR-0078 Phase 3 (Tier-A `action-locations`) — an action that declares no
390+
// `locations` and that no view places by name renders on no surface at all.
391+
// objectui#3142 made that measurable: four renderers used to show an
392+
// undeclared action anyway, and now none does. Advisory: a view in another
393+
// installed package may be the one placing it, and `locations: []` (the
394+
// documented headless shape) is deliberately never flagged.
395+
{
396+
name: 'validateActionLocations',
397+
tier: 'advisory',
398+
input: 'parsed',
399+
commands: ALL,
400+
source: 'packages/lint/src/validate-action-locations.ts',
401+
run: (stack) => validateActionLocations(stack),
402+
},
388403
// framework#3434 — seeds replay on every boot, so a `mode: 'insert'` dataset
389404
// duplicates its table on every restart.
390405
{

packages/lint/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ export type {
207207
export { validateActionNameRefs, ACTION_NAME_UNDEFINED } from './validate-action-name-refs.js';
208208
export type { ActionNameRefFinding, ActionNameRefSeverity } from './validate-action-name-refs.js';
209209

210+
export { validateActionLocations, ACTION_NO_PLACEMENT } from './validate-action-locations.js';
211+
export type { ActionLocationsFinding, ActionLocationsSeverity } from './validate-action-locations.js';
212+
210213
export { validatePageFieldBindings, PAGE_FIELD_UNKNOWN } from './validate-page-field-bindings.js';
211214
export type { PageFieldFinding, PageFieldSeverity } from './validate-page-field-bindings.js';
212215

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { validateActionLocations, ACTION_NO_PLACEMENT } from './validate-action-locations.js';
5+
6+
/** A stack whose single action declares a real placement. */
7+
const placed = () => ({
8+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
9+
actions: [
10+
{
11+
name: 'crm_convert_lead',
12+
label: 'Convert',
13+
type: 'script',
14+
locations: ['record_header'],
15+
},
16+
],
17+
});
18+
19+
/** The same action with the placement key absent. */
20+
const unplaced = () => ({
21+
objects: [{ name: 'crm_lead', fields: { name: { type: 'text' } } }],
22+
actions: [{ name: 'crm_convert_lead', label: 'Convert', type: 'script' }],
23+
});
24+
25+
describe('validateActionLocations', () => {
26+
it('flags an action that declares no locations and that no view places', () => {
27+
const findings = validateActionLocations(unplaced());
28+
29+
expect(findings).toHaveLength(1);
30+
expect(findings[0].severity).toBe('warning');
31+
expect(findings[0].rule).toBe(ACTION_NO_PLACEMENT);
32+
expect(findings[0].path).toBe('actions[0]');
33+
expect(findings[0].where).toBe('action "crm_convert_lead"');
34+
expect(findings[0].message).toContain('renders on no surface');
35+
expect(findings[0].hint).toContain('locations: []');
36+
});
37+
38+
it('accepts a declared placement', () => {
39+
expect(validateActionLocations(placed())).toEqual([]);
40+
});
41+
42+
it('walks object-embedded actions too', () => {
43+
const findings = validateActionLocations({
44+
objects: [
45+
{
46+
name: 'crm_lead',
47+
actions: [{ name: 'crm_score', label: 'Score', type: 'script' }],
48+
},
49+
],
50+
});
51+
52+
expect(findings).toHaveLength(1);
53+
expect(findings[0].path).toBe('objects[0].actions[0]');
54+
});
55+
56+
it('ignores a nameless action — that is action-name-*’s problem, not this rule’s', () => {
57+
expect(validateActionLocations({ actions: [{ label: 'Nameless', type: 'script' }] })).toEqual([]);
58+
});
59+
60+
describe('— headless actions (`locations: []`) are never flagged', () => {
61+
it('accepts an explicitly empty placement', () => {
62+
// `content/docs/ui/actions.mdx` documents the empty array as the way to
63+
// declare a REST/MCP/AI-callable action with no UI surface. ADR-0110 D3
64+
// refuses an UNdeclared handler, so this is the only legal shape for one
65+
// — flagging it would fight that ADR.
66+
const findings = validateActionLocations({
67+
actions: [{ name: 'crm_sync_remote', label: 'Sync', type: 'script', locations: [] }],
68+
});
69+
expect(findings).toEqual([]);
70+
});
71+
72+
it('distinguishes "nowhere, deliberately" from an unstated placement', () => {
73+
const findings = validateActionLocations({
74+
actions: [
75+
{ name: 'said_nowhere', type: 'script', locations: [] },
76+
{ name: 'said_nothing', type: 'script' },
77+
],
78+
});
79+
expect(findings.map((f) => f.where)).toEqual(['action "said_nothing"']);
80+
});
81+
});
82+
83+
describe('— a view that places the action by NAME exempts it', () => {
84+
it('exempts an action named in a list view’s bulkActions', () => {
85+
const findings = validateActionLocations({
86+
...unplaced(),
87+
views: [{ name: 'crm_lead', list: { bulkActions: ['crm_convert_lead'] } }],
88+
});
89+
expect(findings).toEqual([]);
90+
});
91+
92+
it('exempts an action named in a bulkActionDefs entry (incl. aggregate defs)', () => {
93+
// objectui#3139: an aggregate bulk action has no single-record location
94+
// by construction — the view naming it IS the placement.
95+
const findings = validateActionLocations({
96+
...unplaced(),
97+
views: [
98+
{
99+
name: 'crm_lead',
100+
list: {
101+
bulkActionDefs: [
102+
{ name: 'crm_convert_lead', operation: 'custom', execution: 'aggregate' },
103+
],
104+
},
105+
},
106+
],
107+
});
108+
expect(findings).toEqual([]);
109+
});
110+
111+
it('exempts an action named in rowActions', () => {
112+
const findings = validateActionLocations({
113+
...unplaced(),
114+
views: [{ name: 'crm_lead', list: { rowActions: ['crm_convert_lead'] } }],
115+
});
116+
expect(findings).toEqual([]);
117+
});
118+
119+
it('exempts via a named listViews entry, not just the default list', () => {
120+
const findings = validateActionLocations({
121+
...unplaced(),
122+
views: [{ name: 'crm_lead', listViews: { hot: { bulkActions: ['crm_convert_lead'] } } }],
123+
});
124+
expect(findings).toEqual([]);
125+
});
126+
127+
it('exempts via an OBJECT-embedded list view — an object has no top-level `list`', () => {
128+
const findings = validateActionLocations({
129+
objects: [
130+
{
131+
name: 'crm_lead',
132+
listViews: { all: { bulkActions: ['crm_convert_lead'] } },
133+
},
134+
],
135+
actions: [{ name: 'crm_convert_lead', label: 'Convert', type: 'script' }],
136+
});
137+
expect(findings).toEqual([]);
138+
});
139+
140+
it('still flags an action no view names, alongside one that is named', () => {
141+
const findings = validateActionLocations({
142+
actions: [
143+
{ name: 'named_one', type: 'script' },
144+
{ name: 'orphan_one', type: 'script' },
145+
],
146+
views: [{ name: 'crm_lead', list: { bulkActions: ['named_one'] } }],
147+
});
148+
expect(findings.map((f) => f.where)).toEqual(['action "orphan_one"']);
149+
});
150+
});
151+
152+
describe('— floor', () => {
153+
it('returns nothing for a clean stack', () => {
154+
expect(validateActionLocations(placed())).toEqual([]);
155+
});
156+
157+
it('returns nothing for an empty stack', () => {
158+
expect(validateActionLocations({})).toEqual([]);
159+
});
160+
161+
it('returns nothing for a null stack', () => {
162+
expect(validateActionLocations(null as unknown as Record<string, unknown>)).toEqual([]);
163+
});
164+
});
165+
});

0 commit comments

Comments
 (0)