Skip to content

Commit 0fc8087

Browse files
fix(lint)!: action-name-undefined resolves record:related_list action ids against the child object (#21626)
Fixes #20936 Clause-②: no (narrowing) ## What `action-name-undefined` (`packages/lint/src/validate-action-name-refs.ts`) now walks `record:related_list` → `properties.actions[]`, scoped to that component type, and asks the two questions the console's renderer asks: 1. Is the id an action of the related list's **child** object? The child's actions are the ones written on that object plus every `stack.actions` entry bound to it by `objectName` (the set `defineStack` merges into the object's `actions`, so the set the object's metadata serves). An id defined only on the page's object, or only as a global action, is refused like a typo. The message names where it is defined. The did-you-mean and the hint's action list come from the child object. 2. Does that action declare a location a related list draws? The location set is read from the spec's `ACTION_LOCATIONS` (imported from `@objectstack/spec/ui`), classified per member in a `Record` keyed by the spec's `ActionLocation` type. That gives `list_toolbar`, `list_item` and `record_related`. A location the spec adds fails `@objectstack/lint`'s typecheck until it is classified, so the set cannot go stale the way a hard-coded pair would. Both findings use the same rule id, `action-name-undefined`, at severity `error`. Each is reported at the id's authored index. Only string elements are ids (inline objects are skipped, as on `page:header`). The child object is the component's bound `dataSource.object` when one is set, otherwise `properties.objectName`. The walk says nothing about a child object this stack does not define. ## Measurements against the PM's mechanism assumptions Read at `origin/main` `f97660cdd6` (this branch's base) and at objectui `89cad75d55702cc4f267bead5bf267de575d5842` (`.objectui-sha` on that base). 1. **Holds.** The rule walked `record:quick_actions.actionNames[]`, `record:alert`'s `action.actionName` and `page:header`'s `actions[]`. The docblock at `:27`–`:30` said `actions` "is declared separately on `record:related_list`", and no walk read it. That sentence is replaced by a bullet for the new walk. 2. **Holds.** At the pin, `packages/plugin-detail/src/renderers/record-related-list.tsx:287` reads `schema.actions`. `:290` looks up `useMetadataItem('object', …)` for the related `objectName`. `:301` takes `relatedObjectMeta.actions` as the registry, and `:303` hands both to `placeAuthoredRelatedListActions`. In `relatedListActions.ts`, `:110` resolves the ids with `resolveDeclaredActionIds` against that registry. `:141`–`:143` place by `actionRendersAt` at `list_toolbar` / `list_item` / `record_related`. An id that resolves but is placed at none of the three is refused as `unplaced`. Control: at the previous pin `db11afd4967c`, `git grep -c "schema.actions"` on the same file answers 0 (exit 1), while `schema.relationshipField` answers 3, so the grep reaches the file. `git merge-base --is-ancestor f4ed2387e9 89cad75d5570` exits 0. 3. **No existing walk scopes to an object.** Every walk resolves through `collectActionNames`, the union of `stack.actions` and every object's `actions`. The related list's child is `properties.objectName`, the same key `validate-page-field-bindings.ts`'s `relatedListFieldRefs` already reads. objectui's data-source gate (`packages/react/src/element-data-source/ElementDataSourceGate.tsx:398`) writes a bound `dataSource.object` over it. How the scope note and the direction fit: the note keeps the other walks stack-wide because ownership and location checks there would cost the ADR-0072 D1 zero-false-positive posture for coverage nobody asked for. Neither reason holds for this walk. The renderer asks both questions itself and refuses on either miss, so a finding is the runtime's own verdict moved to authoring time ("resolve at runtime for the surface being authored", ADR-0072 D1). This card's ruling also asks for that coverage. One false-positive source stays: a child object this stack does not define has its actions in another package, so the walk is silent there rather than guessing. The docblock's scope note now says this, and every other walk is unchanged. I see no real conflict, so this is not raised as a question. 4. **Partly disproved.** `ACTION_LOCATIONS` (`packages/spec/src/ui/action.zod.ts:663`) is the full six-location vocabulary and lists `record_related`. It does not declare which locations a related list draws: the list draws three of the six. Using the whole vocabulary as the set would accept a `record_header`-only action that the renderer refuses. So the rule imports the vocabulary and the `ActionLocation` type and classifies every member exhaustively. No location literal is copied outside a compiler-checked key. Whether the spec should export that subset itself is in the report's `open_questions`. This PR does not decide it. 5. **Holds.** The landing site is `packages/lint`. There is no producer-side change: the spec already types the key as `z.array(z.string())`, and the renderer already refuses. ## Pins (beside #20105's) `validate-action-name-refs.test.ts`, `describe('validateActionNameRefs — record:related_list actions')`: - ids that resolve on the child at `record_related`, `list_toolbar`, `list_item`, plus one bound through `stack.actions` `objectName`: **silent**; - an id that resolves nowhere: one finding at its authored index, naming the id and the child object; - **decision pinned:** an id defined only on the page's object, and one defined only as a global action: both are findings, because the list never reads either; - child actions placed at `record_header` only, at `[]`, and with no `locations`: three findings; - a bound `dataSource.object` wins over `objectName`; - a child object this stack does not define (`sys_member`): **silent**. ## Tests (all at `b37812bed9` unless noted) - `pnpm --filter @objectstack/lint test`: `Test Files 119 passed (119)`, `Tests 5621 passed | 5 skipped (5626)`. - `pnpm --filter @objectstack/lint exec vitest run --maxWorkers=2 src/validate-action-name-refs.test.ts`: `Tests 36 passed (36)` (30 before, plus 6). - `pnpm --filter @objectstack/lint typecheck` (at `6a73a0ad67`, same `src` as the head): exit 0. The test layer is clean against its ledger: "2 file(s) / 6 error(s) / 2 pinned signature(s) held", unchanged. - Built-`dist` probe: `require('packages/lint/dist/index.cjs').validateReferenceIntegrity(stack)` on a related list naming one defined and one undefined child id returns exactly one `action-name-undefined` `error` at `…properties.actions[1]`. The ESM entry gives the same count. This is the suite `os validate` / `os lint` / `os build` call. - Corpus: there was no fixture to re-read. The platform's pages (`sys-user`, `sys-organization`, `sys-position`, walked with `walkPageComponents`) hold 13 related lists, and 0 of them author `actions`. `git grep -c related_list -- examples` (CHANGELOGs excluded) answers 0 files, exit 1. Control: `record:quick_actions|page:header` hits 3 files in the same tree. ## Reverse verification (one-off, from the committed head, restore proven by blob equality and an empty `git diff HEAD`) - Deleting `record_related` from the classification (`node scripts/ablation-replace.mjs … --delete -- pnpm --filter @objectstack/lint exec tsc --noEmit`): red with `TS2741: Property 'record_related' is missing in type …`. Restored to blob `e0deed9272da`, which equals HEAD. - Setting `record_related: null` and running the rule's test file: `Tests 4 failed | 32 passed (36)`. All four new tests that use a `record_related` action go red; the stack-wide walks stay green. Restored to blob `e0deed9272da`, which equals HEAD. ## Gates Derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `b37812bed9`, and reconciled with `--ran`: "62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED". Every run family exited 0, including `check-adr-0087-registration` (the changeset carries `not-required (no-migration-prescription)`), `check-changeset-no-major`, `check-empty-changeset`, `check:nul-bytes`, `check:doc-authoring`, `check:engine-double-contract` and `check:type-check-debt` (re-run after a first attempt hit my own 300s timeout under box contention; the re-run reported "none above its recorded number"). - NOT MEASURED: `check:dual-build-cjs-loads` and `check:lean-entry-closure`, reason: PREREQUISITE NOT MET, because they need the whole workspace built (85 packages have no `dist/`). Narrowed instead: the diff adds no new import specifier to `@objectstack/lint` (`@objectstack/spec/ui` was already imported 17 times). After `pnpm --filter @objectstack/lint build`, the CJS `require` of `dist/index.cjs` and the ESM import both load and run the rule. CI runs both gates over the full build. - `check:docs-transcript-drift` first exited 3 (lint unbuilt). It exited 0 after the lint build. - ESLint, narrowed and proven. ① Population, from `eslint.config.mjs`: both files match `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}` and `packages/**/*.{ts,tsx,mts,cts}`, and neither is under `NEVER_LINTED`. ② `npx eslint --no-inline-config --format json` on the two changed source files: 2 files, 0 errors, 0 warnings. ③ The config never enables type-aware linting (`eslint.config.mjs:327`–`:328`: no `parserOptions.project`, no typed rules), and every block is per-file syntactic, so this diff cannot move any untouched file's verdict. ## Acceptance notes - **One rule id for both refusals.** An id that resolves to a child action placed nowhere the list draws is reported as `action-name-undefined`, with a message that says it IS defined and names its `locations`. A new rule id would be a new public export from `@objectstack/lint` (a widening), and the claim declares `Clause-②: no (narrowing)`. - **Docs drift, not fixed here.** `content/docs/ui/actions.mdx:276` still says `record_related` is "Declared, not yet placed: the console does not draw it on those rows yet". At the pin, the related list does draw it (`relatedListActions.ts:143`; objectui#11270 merged as `a8b9889332`, behind the pin by 0). The same page's "Surfaces can also reference actions by name" list (`:279`) does not list `record:related_list.actions`. Neither is in this card's file surface. Carrier: none named. - The rule still does not run at the runtime publish door for `page` writes: its suite member keeps the default `flow` runtime type. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a4f0cb0 commit 0fc8087

3 files changed

Lines changed: 301 additions & 3 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
fix(lint)!: `action-name-undefined` resolves `record:related_list` action ids against the related object, and refuses an id the list cannot draw (#20936)
6+
7+
Clause-②: no (narrowing)
8+
9+
`action-name-undefined` is the authoring gate for "a surface names an action that renders nothing". It already walked list-view row and bulk menus, the `record:quick_actions` bar, the `record:alert` call-to-action, the `page:header` action ids and app navigation. One page surface that binds actions by id was never read: `record:related_list` → `properties.actions`.
10+
11+
The console now reads that key. It resolves each id against the RELATED (child) object's own actions, never the page's object, and places it by that action's own `locations`: `list_toolbar` draws a header button, `list_item` and `record_related` draw a row-menu item. An id that names no action of the child object, or an action placed at none of those three, draws no button; the list shows a refusal notice naming it instead. The spec types the key as plain strings, so a misspelled id passed spec validation and lint and surfaced only at runtime.
12+
13+
The rule now walks the key, scoped to `record:related_list`, and answers the same two questions the renderer asks:
14+
15+
- each string id must name an action of the related object: one written on that object, or a `stack.actions` entry bound to it by `objectName`. An id defined only on the page's object, or only as a global action, is refused like a typo, and the message names where it is defined. The did-you-mean and the hint's action list are the related object's own;
16+
- the action it names must declare at least one location a related list draws. The location set is read from the spec's `ACTION_LOCATIONS` vocabulary, classified per member, so a location added to the vocabulary has to be classified before this package compiles.
17+
18+
The related object is the component's bound `dataSource.object` when one is set, otherwise `properties.objectName`. A related object this stack does not define is skipped: its actions belong to another package, and the rule does not guess. Inline-object elements are skipped, as on `page:header`, and every id is reported at its authored index. Every other walk of the rule is unchanged: it still asks only whether a name is defined anywhere in the stack.
19+
20+
**What moves for consumers.** A stack whose related list names an id the list cannot draw built clean before and now fails `os validate` / `os lint` / `os build` with `action-name-undefined` (severity `error`). That id never rendered a button, so nothing that worked stops working. The rule still does not run at the runtime publish door for `page` writes. No related list in the platform's own pages or in the example apps authors `actions`, so none of them changes.
21+
22+
<!-- adr-0087: not-required (no-migration-prescription) a refusal at authoring of record:related_list action ids that the console already refuses at runtime with a visible notice: an id that names no action of the related object, or an action declaring none of the locations a related list draws. No authorable key, spelling, export or stored shape moves: RecordRelatedListProps keeps parsing every value, no stored row is read or rewritten, and which action an author meant to name is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id covers this key (not already-registered); and the change is a rule verdict, not a declaration (not runtime-interface-only or type-surface-only). -->

‎packages/lint/src/validate-action-name-refs.test.ts‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,98 @@ describe('validateActionNameRefs — page:header actions', () => {
296296
});
297297
});
298298

299+
// The related list resolves its `actions` ids against the RELATED (child)
300+
// object's own actions — never the page's object — and places each by that
301+
// action's own `locations`; an id that misses either draws no button, only a
302+
// refusal notice. So this walk, unlike the stack-wide ones above, answers from
303+
// the child object, and only when this stack defines it.
304+
describe('validateActionNameRefs — record:related_list actions', () => {
305+
const stackWith = (component: Record<string, unknown>) => ({
306+
objects: [
307+
{
308+
name: 'crm_account',
309+
fields: { name: { type: 'text' } },
310+
actions: [{ name: 'crm_merge_accounts', type: 'script', locations: ['record_header'] }],
311+
},
312+
{
313+
name: 'crm_contact',
314+
fields: { name: { type: 'text' } },
315+
actions: [
316+
{ name: 'crm_log_call', type: 'script', locations: ['record_related'] },
317+
{ name: 'crm_new_contact', type: 'script', locations: ['list_toolbar'] },
318+
{ name: 'crm_email_contact', type: 'script', locations: ['list_item'] },
319+
{ name: 'crm_pin_contact', type: 'script', locations: ['record_header'] },
320+
{ name: 'crm_sync_contact', type: 'script', locations: [] },
321+
{ name: 'crm_score_contact', type: 'script' },
322+
],
323+
},
324+
],
325+
actions: [
326+
{ name: 'crm_tag_contact', objectName: 'crm_contact', type: 'script', locations: ['list_item'] },
327+
{ name: 'crm_export_all', type: 'script', locations: ['list_toolbar'] },
328+
],
329+
pages: [
330+
{
331+
name: 'account_record',
332+
object: 'crm_account',
333+
regions: [{ name: 'main', components: [{ type: 'record:related_list', ...component }] }],
334+
},
335+
],
336+
});
337+
const relatedList = (actions: unknown[], objectName = 'crm_contact') =>
338+
stackWith({ properties: { objectName, relationshipField: 'account_id', actions } });
339+
const at = (i: number) => `pages[0].regions[0].components[0].properties.actions[${i}]`;
340+
341+
it('accepts ids that resolve on the child object at every location a related list draws', () => {
342+
expect(
343+
validateActionNameRefs(
344+
relatedList(['crm_log_call', 'crm_new_contact', 'crm_email_contact', 'crm_tag_contact']),
345+
),
346+
).toEqual([]);
347+
});
348+
349+
it('errors on an id that resolves nowhere, naming the child object', () => {
350+
const findings = validateActionNameRefs(relatedList(['crm_log_call', 'crm_lgo_call']));
351+
expect(findings).toHaveLength(1);
352+
expect(findings[0].rule).toBe(ACTION_NAME_UNDEFINED);
353+
expect(findings[0].severity).toBe('error');
354+
expect(findings[0].path).toBe(at(1));
355+
expect(findings[0].message).toContain('"crm_lgo_call"');
356+
expect(findings[0].hint).toContain('"crm_contact"');
357+
});
358+
359+
// The decision the direction fixes: the list never reads the page's object
360+
// (or a global action), so an id defined only there is as dead as a typo.
361+
it('errors on an id defined only on the page object or as a global action', () => {
362+
const findings = validateActionNameRefs(relatedList(['crm_merge_accounts', 'crm_export_all']));
363+
expect(findings.map((f) => f.path)).toEqual([at(0), at(1)]);
364+
expect(findings.every((f) => f.rule === ACTION_NAME_UNDEFINED && f.severity === 'error')).toBe(true);
365+
expect(findings[0].message).toContain('"crm_contact"');
366+
});
367+
368+
it('errors on a child action placed at no location a related list draws', () => {
369+
const findings = validateActionNameRefs(
370+
relatedList(['crm_pin_contact', 'crm_log_call', 'crm_sync_contact', 'crm_score_contact']),
371+
);
372+
expect(findings.map((f) => f.path)).toEqual([at(0), at(2), at(3)]);
373+
expect(findings.every((f) => f.rule === ACTION_NAME_UNDEFINED && f.severity === 'error')).toBe(true);
374+
});
375+
376+
it('resolves against a bound dataSource object, which the renderer writes over objectName', () => {
377+
const findings = validateActionNameRefs(
378+
stackWith({
379+
dataSource: { object: 'crm_contact' },
380+
properties: { objectName: 'crm_account', relationshipField: 'account_id', actions: ['crm_log_call', 'crm_merge_accounts'] },
381+
}),
382+
);
383+
expect(findings.map((f) => f.path)).toEqual([at(1)]);
384+
});
385+
386+
it('says nothing about a child object this stack does not define', () => {
387+
expect(validateActionNameRefs(relatedList(['invite_user', 'nope'], 'sys_member'))).toEqual([]);
388+
});
389+
});
390+
299391
describe('validateActionNameRefs — navigation action items', () => {
300392
it('errors on an undefined nav actionName', () => {
301393
const findings = validateActionNameRefs({

‎packages/lint/src/validate-action-name-refs.ts‎

Lines changed: 187 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,14 @@
2626
* author reads — so authoring time is where the refusal belongs (#20105).
2727
* Each walk is scoped to its component type, because the same key means
2828
* something else elsewhere: `element:button`'s `action` is an inline
29-
* definition, not a reference, and `actions` is declared separately on
30-
* `record:related_list`.
29+
* definition, not a reference.
30+
* - page components — `record:related_list` → `properties.actions[]` (the
31+
* list's action ids, #20936). objectui resolves each id against the
32+
* RELATED (child) object's own actions — not the page's object — and
33+
* places it by that action's own `locations`; an id that misses either
34+
* test draws no button, only a refusal notice above the list. This walk is
35+
* therefore the one that resolves against an OBJECT rather than the whole
36+
* stack, and the one that checks placement — see the scope note.
3137
* - app navigation — `{ type: 'action', actionDef: { actionName } }`
3238
* - app navigation deep-link auto-run — `{ type: 'object', runAction }`
3339
* (#4848 — the declared form of the `?runAction=<name>` URL contract)
@@ -51,8 +57,20 @@
5157
* zero-false-positive posture (ADR-0072 D1) for coverage this issue did not ask
5258
* for. An action defined by another installed package is the one legitimate
5359
* miss; it is called out in the hint rather than guessed at.
60+
*
61+
* The `record:related_list` walk is the exception, and it keeps the posture
62+
* rather than trading it. Its renderer asks both questions itself — is the id
63+
* an action of the CHILD object, and does that action declare a location the
64+
* list draws — and refuses the id when either answer is no. A finding that
65+
* repeats that refusal is not a false positive; it is the runtime's own verdict
66+
* moved to authoring time, which is what ADR-0072 D1 asks for ("resolve at
67+
* runtime for the surface being authored"). So the walk answers from the child
68+
* object's actions, and only for a child object this stack DEFINES: one it
69+
* does not define has its actions in another package, and the walk says
70+
* nothing about it rather than guess. Every other walk is unchanged.
5471
*/
5572

73+
import { ACTION_LOCATIONS, type ActionLocation } from '@objectstack/spec/ui';
5674
import { recordsOf, suggestName } from './object-graph.js';
5775
import { walkPageComponents } from './page-walk.js';
5876

@@ -102,6 +120,82 @@ function collectActionNames(stack: AnyRec): Set<string> {
102120
return names;
103121
}
104122

123+
/**
124+
* What a `record:related_list` draws for an authored action placed at each
125+
* location of the spec's vocabulary (`ACTION_LOCATIONS`), or `null` where it
126+
* draws nothing. Read at objectui's `relatedListActions.ts`
127+
* (`placeAuthoredRelatedListActions`): `list_toolbar` is a header button;
128+
* `list_item` and `record_related` are a row-menu item. The list renders only
129+
* inside a parent record, which is the one scope `record_related` names.
130+
*
131+
* Keyed by `ActionLocation`, so the vocabulary is read from the spec and never
132+
* copied: a location the spec adds fails this package's typecheck until it is
133+
* classified here, instead of leaving a stale pair that silently refuses it.
134+
*/
135+
const RELATED_LIST_DRAWS: Readonly<Record<ActionLocation, string | null>> = {
136+
list_toolbar: 'a header button',
137+
list_item: 'a row-menu item',
138+
record_header: null,
139+
record_more: null,
140+
record_related: 'a row-menu item',
141+
record_section: null,
142+
};
143+
144+
/** The locations a related list draws, in the spec's own order. */
145+
const RELATED_LIST_LOCATIONS: readonly ActionLocation[] = ACTION_LOCATIONS.filter(
146+
(location) => RELATED_LIST_DRAWS[location] !== null,
147+
);
148+
149+
/** `` `list_toolbar` (a header button), … `` — for a hint. */
150+
const RELATED_LIST_PLACEMENTS = RELATED_LIST_LOCATIONS.map(
151+
(location) => `\`${location}\` (${RELATED_LIST_DRAWS[location]})`,
152+
).join(', ');
153+
154+
/**
155+
* The actions a related list resolves an id against, for each object this
156+
* stack DEFINES: the actions written on the object (keyed by the object they
157+
* are written on), then every `stack.actions` entry bound to it by
158+
* `objectName` — the set `defineStack` merges into the object's `actions`, so
159+
* the set the object's metadata serves to the renderer. An object this stack
160+
* does not define is absent from the map: its actions live in another package.
161+
*/
162+
function indexObjectActions(stack: AnyRec): Map<string, Map<string, AnyRec>> {
163+
const index = new Map<string, Map<string, AnyRec>>();
164+
for (const obj of recordsOf(stack.objects)) {
165+
const objectName = strName(obj.name);
166+
if (!objectName) continue;
167+
const byName = index.get(objectName) ?? new Map<string, AnyRec>();
168+
for (const action of recordsOf(obj.actions)) {
169+
const n = strName(action.name);
170+
if (n && !byName.has(n)) byName.set(n, action);
171+
}
172+
index.set(objectName, byName);
173+
}
174+
for (const action of recordsOf(stack.actions)) {
175+
const owner = strName(action.objectName);
176+
const byName = owner ? index.get(owner) : undefined;
177+
const n = strName(action.name);
178+
if (byName && n && !byName.has(n)) byName.set(n, action);
179+
}
180+
return index;
181+
}
182+
183+
/** Where an action name IS defined in the stack: `on object "x"` per owner, or `as a global action`. */
184+
function actionOwners(stack: AnyRec, name: string): string[] {
185+
const owners = new Set<string>();
186+
for (const obj of recordsOf(stack.objects)) {
187+
if (recordsOf(obj.actions).some((a) => a.name === name)) {
188+
owners.add(`on object "${strName(obj.name) ?? '?'}"`);
189+
}
190+
}
191+
for (const action of recordsOf(stack.actions)) {
192+
if (action.name !== name) continue;
193+
const owner = strName(action.objectName);
194+
owners.add(owner ? `on object "${owner}"` : 'as a global action');
195+
}
196+
return [...owners].sort();
197+
}
198+
105199
/**
106200
* Validate every name-bound action reference in a stack. Returns findings
107201
* (empty = clean).
@@ -111,6 +205,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
111205
if (!stack || typeof stack !== 'object') return findings;
112206

113207
const known = collectActionNames(stack);
208+
let objectActions: Map<string, Map<string, AnyRec>> | undefined;
114209

115210
const check = (
116211
name: string,
@@ -248,9 +343,85 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
248343
}
249344
}
250345

346+
/**
347+
* `record:related_list` → `properties.actions[]`. The renderer resolves each
348+
* id against the RELATED object's own actions and places it by that
349+
* action's own `locations` — naming it here is not a placement — and an id
350+
* that misses either test draws no button, only a refusal notice above the
351+
* list. Only the string elements are ids, each reported at its AUTHORED
352+
* index, as for `page:header`. Silent when `child` is not an object this
353+
* stack defines: its actions live in another package.
354+
*/
355+
const checkRelatedListActions = (
356+
child: string | undefined,
357+
ids: readonly unknown[],
358+
where: string,
359+
path: string,
360+
) => {
361+
if (!child) return;
362+
objectActions ??= indexObjectActions(stack);
363+
const childActions = objectActions.get(child);
364+
if (!childActions) return;
365+
const childNames = [...childActions.keys()].sort();
366+
for (let ri = 0; ri < ids.length; ri++) {
367+
const id = strName(ids[ri]);
368+
if (!id) continue;
369+
const idPath = `${path}.properties.actions[${ri}]`;
370+
const action = childActions.get(id);
371+
if (!action) {
372+
const owners = known.has(id) ? actionOwners(stack, id) : [];
373+
findings.push({
374+
severity: 'error',
375+
rule: ACTION_NAME_UNDEFINED,
376+
where,
377+
path: idPath,
378+
message:
379+
`Related-list actions names action "${id}", which is not an action of the related object ` +
380+
`"${child}"` +
381+
(owners.length > 0
382+
? ` (it is defined in this stack ${owners.join(' and ')}, which this list never reads)`
383+
: ' (no action in this stack defines it)') +
384+
". The list resolves each id against its related object's own actions only — not the " +
385+
"page's object, not a global action — so it draws no button for this one, only a refusal " +
386+
'notice naming it above the list.' +
387+
suggestName(id, childNames),
388+
hint:
389+
`Define "${id}" on "${child}" — in that object's \`actions\`, or in \`stack.actions\` with ` +
390+
`\`objectName: '${child}'\` — with one of ${RELATED_LIST_PLACEMENTS} in its \`locations\`; ` +
391+
`or name one of "${child}"'s own actions; or remove the reference. Ignore this only if ` +
392+
`another installed package binds the action to "${child}".` +
393+
` Actions of "${child}": ${childNames.length > 0 ? childNames.join(', ') : '(none)'}.`,
394+
});
395+
continue;
396+
}
397+
const declared = Array.isArray(action.locations)
398+
? action.locations.filter((l): l is string => typeof l === 'string')
399+
: undefined;
400+
if (declared?.some((l) => (RELATED_LIST_LOCATIONS as readonly string[]).includes(l))) continue;
401+
findings.push({
402+
severity: 'error',
403+
rule: ACTION_NAME_UNDEFINED,
404+
where,
405+
path: idPath,
406+
message:
407+
`Related-list actions names action "${id}", an action of the related object "${child}" ` +
408+
(declared === undefined
409+
? 'that declares no `locations`, so it is placed at none'
410+
: `whose \`locations\` (${declared.length > 0 ? declared.join(', ') : 'empty'}) include none`) +
411+
` of the locations a related list draws (${RELATED_LIST_LOCATIONS.join(', ')}). The list ` +
412+
'places an authored action by its own `locations` — naming it here is not a placement — so ' +
413+
'it draws no button for it, only a refusal notice naming it above the list.',
414+
hint:
415+
`Add one of ${RELATED_LIST_PLACEMENTS} to the \`locations\` of "${id}" on "${child}", ` +
416+
'or remove the reference.',
417+
});
418+
}
419+
};
420+
251421
// ── Page components: record:quick_actions → properties.actionNames,
252422
// record:alert → properties.action.actionName,
253-
// page:header → properties.actions[] ──
423+
// page:header → properties.actions[],
424+
// record:related_list → properties.actions[] (against the child object) ──
254425
const pages = recordsOf(stack.pages);
255426
for (let pi = 0; pi < pages.length; pi++) {
256427
const page = pages[pi];
@@ -320,6 +491,19 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
320491
);
321492
}
322493
}
494+
495+
// `record:related_list`'s action ids, resolved against the RELATED
496+
// object (see `checkRelatedListActions`). The related object is the
497+
// per-element `dataSource.object` when one is bound (objectui's
498+
// data-source gate writes it over `objectName`), else `objectName`.
499+
if (type === 'record:related_list' && Array.isArray(props.actions)) {
500+
const binding = component.dataSource;
501+
const child =
502+
(binding && typeof binding === 'object' && !Array.isArray(binding)
503+
? strName((binding as AnyRec).object)
504+
: undefined) ?? strName(props.objectName);
505+
checkRelatedListActions(child, props.actions as unknown[], where, path);
506+
}
323507
}
324508
}
325509

0 commit comments

Comments
 (0)