From e055d3d26d47860e4cefc8f4b3fae6984782e796 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:17:57 +0000 Subject: [PATCH 1/3] feat(lint): action-name-undefined resolves record:related_list actions against the child object Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../src/validate-action-name-refs.test.ts | 92 +++++++++ .../lint/src/validate-action-name-refs.ts | 190 +++++++++++++++++- 2 files changed, 279 insertions(+), 3 deletions(-) diff --git a/packages/lint/src/validate-action-name-refs.test.ts b/packages/lint/src/validate-action-name-refs.test.ts index 12da02368b7..88369929934 100644 --- a/packages/lint/src/validate-action-name-refs.test.ts +++ b/packages/lint/src/validate-action-name-refs.test.ts @@ -296,6 +296,98 @@ describe('validateActionNameRefs — page:header actions', () => { }); }); +// The related list resolves its `actions` ids against the RELATED (child) +// object's own actions — never the page's object — and places each by that +// action's own `locations`; an id that misses either draws no button, only a +// refusal notice. So this walk, unlike the stack-wide ones above, answers from +// the child object, and only when this stack defines it. +describe('validateActionNameRefs — record:related_list actions', () => { + const stackWith = (component: Record) => ({ + objects: [ + { + name: 'crm_account', + fields: { name: { type: 'text' } }, + actions: [{ name: 'crm_merge_accounts', type: 'script', locations: ['record_header'] }], + }, + { + name: 'crm_contact', + fields: { name: { type: 'text' } }, + actions: [ + { name: 'crm_log_call', type: 'script', locations: ['record_related'] }, + { name: 'crm_new_contact', type: 'script', locations: ['list_toolbar'] }, + { name: 'crm_email_contact', type: 'script', locations: ['list_item'] }, + { name: 'crm_pin_contact', type: 'script', locations: ['record_header'] }, + { name: 'crm_sync_contact', type: 'script', locations: [] }, + { name: 'crm_score_contact', type: 'script' }, + ], + }, + ], + actions: [ + { name: 'crm_tag_contact', objectName: 'crm_contact', type: 'script', locations: ['list_item'] }, + { name: 'crm_export_all', type: 'script', locations: ['list_toolbar'] }, + ], + pages: [ + { + name: 'account_record', + object: 'crm_account', + regions: [{ name: 'main', components: [{ type: 'record:related_list', ...component }] }], + }, + ], + }); + const relatedList = (actions: unknown[], objectName = 'crm_contact') => + stackWith({ properties: { objectName, relationshipField: 'account_id', actions } }); + const at = (i: number) => `pages[0].regions[0].components[0].properties.actions[${i}]`; + + it('accepts ids that resolve on the child object at every location a related list draws', () => { + expect( + validateActionNameRefs( + relatedList(['crm_log_call', 'crm_new_contact', 'crm_email_contact', 'crm_tag_contact']), + ), + ).toEqual([]); + }); + + it('errors on an id that resolves nowhere, naming the child object', () => { + const findings = validateActionNameRefs(relatedList(['crm_log_call', 'crm_lgo_call'])); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(ACTION_NAME_UNDEFINED); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe(at(1)); + expect(findings[0].message).toContain('"crm_lgo_call"'); + expect(findings[0].hint).toContain('"crm_contact"'); + }); + + // The decision the direction fixes: the list never reads the page's object + // (or a global action), so an id defined only there is as dead as a typo. + it('errors on an id defined only on the page object or as a global action', () => { + const findings = validateActionNameRefs(relatedList(['crm_merge_accounts', 'crm_export_all'])); + expect(findings.map((f) => f.path)).toEqual([at(0), at(1)]); + expect(findings.every((f) => f.rule === ACTION_NAME_UNDEFINED && f.severity === 'error')).toBe(true); + expect(findings[0].message).toContain('"crm_contact"'); + }); + + it('errors on a child action placed at no location a related list draws', () => { + const findings = validateActionNameRefs( + relatedList(['crm_pin_contact', 'crm_log_call', 'crm_sync_contact', 'crm_score_contact']), + ); + expect(findings.map((f) => f.path)).toEqual([at(0), at(2), at(3)]); + expect(findings.every((f) => f.rule === ACTION_NAME_UNDEFINED && f.severity === 'error')).toBe(true); + }); + + it('resolves against a bound dataSource object, which the renderer writes over objectName', () => { + const findings = validateActionNameRefs( + stackWith({ + dataSource: { object: 'crm_contact' }, + properties: { objectName: 'crm_account', relationshipField: 'account_id', actions: ['crm_log_call', 'crm_merge_accounts'] }, + }), + ); + expect(findings.map((f) => f.path)).toEqual([at(1)]); + }); + + it('says nothing about a child object this stack does not define', () => { + expect(validateActionNameRefs(relatedList(['invite_user', 'nope'], 'sys_member'))).toEqual([]); + }); +}); + describe('validateActionNameRefs — navigation action items', () => { it('errors on an undefined nav actionName', () => { const findings = validateActionNameRefs({ diff --git a/packages/lint/src/validate-action-name-refs.ts b/packages/lint/src/validate-action-name-refs.ts index cc02f0601a9..3cacaac0fae 100644 --- a/packages/lint/src/validate-action-name-refs.ts +++ b/packages/lint/src/validate-action-name-refs.ts @@ -26,8 +26,14 @@ * author reads — so authoring time is where the refusal belongs (#20105). * Each walk is scoped to its component type, because the same key means * something else elsewhere: `element:button`'s `action` is an inline - * definition, not a reference, and `actions` is declared separately on - * `record:related_list`. + * definition, not a reference. + * - page components — `record:related_list` → `properties.actions[]` (the + * list's action ids, #20936). objectui resolves each id against the + * RELATED (child) object's own actions — not the page's object — and + * places it by that action's own `locations`; an id that misses either + * test draws no button, only a refusal notice above the list. This walk is + * therefore the one that resolves against an OBJECT rather than the whole + * stack, and the one that checks placement — see the scope note. * - app navigation — `{ type: 'action', actionDef: { actionName } }` * - app navigation deep-link auto-run — `{ type: 'object', runAction }` * (#4848 — the declared form of the `?runAction=` URL contract) @@ -51,8 +57,20 @@ * zero-false-positive posture (ADR-0072 D1) for coverage this issue did not ask * for. An action defined by another installed package is the one legitimate * miss; it is called out in the hint rather than guessed at. + * + * The `record:related_list` walk is the exception, and it keeps the posture + * rather than trading it. Its renderer asks both questions itself — is the id + * an action of the CHILD object, and does that action declare a location the + * list draws — and refuses the id when either answer is no. A finding that + * repeats that refusal is not a false positive; it is the runtime's own verdict + * moved to authoring time, which is what ADR-0072 D1 asks for ("resolve at + * runtime for the surface being authored"). So the walk answers from the child + * object's actions, and only for a child object this stack DEFINES: one it + * does not define has its actions in another package, and the walk says + * nothing about it rather than guess. Every other walk is unchanged. */ +import { ACTION_LOCATIONS, type ActionLocation } from '@objectstack/spec/ui'; import { recordsOf, suggestName } from './object-graph.js'; import { walkPageComponents } from './page-walk.js'; @@ -102,6 +120,82 @@ function collectActionNames(stack: AnyRec): Set { return names; } +/** + * What a `record:related_list` draws for an authored action placed at each + * location of the spec's vocabulary (`ACTION_LOCATIONS`), or `null` where it + * draws nothing. Read at objectui's `relatedListActions.ts` + * (`placeAuthoredRelatedListActions`): `list_toolbar` is a header button; + * `list_item` and `record_related` are a row-menu item. The list renders only + * inside a parent record, which is the one scope `record_related` names. + * + * Keyed by `ActionLocation`, so the vocabulary is read from the spec and never + * copied: a location the spec adds fails this package's typecheck until it is + * classified here, instead of leaving a stale pair that silently refuses it. + */ +const RELATED_LIST_DRAWS: Readonly> = { + list_toolbar: 'a header button', + list_item: 'a row-menu item', + record_header: null, + record_more: null, + record_related: 'a row-menu item', + record_section: null, +}; + +/** The locations a related list draws, in the spec's own order. */ +const RELATED_LIST_LOCATIONS: readonly ActionLocation[] = ACTION_LOCATIONS.filter( + (location) => RELATED_LIST_DRAWS[location] !== null, +); + +/** `` `list_toolbar` (a header button), … `` — for a hint. */ +const RELATED_LIST_PLACEMENTS = RELATED_LIST_LOCATIONS.map( + (location) => `\`${location}\` (${RELATED_LIST_DRAWS[location]})`, +).join(', '); + +/** + * The actions a related list resolves an id against, for each object this + * stack DEFINES: the actions written on the object (keyed by the object they + * are written on), then 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 to the renderer. An object this stack + * does not define is absent from the map: its actions live in another package. + */ +function indexObjectActions(stack: AnyRec): Map> { + const index = new Map>(); + for (const obj of recordsOf(stack.objects)) { + const objectName = strName(obj.name); + if (!objectName) continue; + const byName = index.get(objectName) ?? new Map(); + for (const action of recordsOf(obj.actions)) { + const n = strName(action.name); + if (n && !byName.has(n)) byName.set(n, action); + } + index.set(objectName, byName); + } + for (const action of recordsOf(stack.actions)) { + const owner = strName(action.objectName); + const byName = owner ? index.get(owner) : undefined; + const n = strName(action.name); + if (byName && n && !byName.has(n)) byName.set(n, action); + } + return index; +} + +/** Where an action name IS defined in the stack: `object "x"` per owner, or `a global action`. */ +function actionOwners(stack: AnyRec, name: string): string[] { + const owners = new Set(); + for (const obj of recordsOf(stack.objects)) { + if (recordsOf(obj.actions).some((a) => a.name === name)) { + owners.add(`object "${strName(obj.name) ?? '?'}"`); + } + } + for (const action of recordsOf(stack.actions)) { + if (action.name !== name) continue; + const owner = strName(action.objectName); + owners.add(owner ? `object "${owner}"` : 'a global action'); + } + return [...owners].sort(); +} + /** * Validate every name-bound action reference in a stack. Returns findings * (empty = clean). @@ -111,6 +205,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { if (!stack || typeof stack !== 'object') return findings; const known = collectActionNames(stack); + let objectActions: Map> | undefined; const check = ( name: string, @@ -248,9 +343,85 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { } } + /** + * `record:related_list` → `properties.actions[]`. The renderer resolves each + * id against the RELATED object's own actions and places it by that + * action's own `locations` — naming it here is not a placement — and an id + * that misses either test draws no button, only a refusal notice above the + * list. Only the string elements are ids, each reported at its AUTHORED + * index, as for `page:header`. Silent when `child` is not an object this + * stack defines: its actions live in another package. + */ + const checkRelatedListActions = ( + child: string | undefined, + ids: readonly unknown[], + where: string, + path: string, + ) => { + if (!child) return; + objectActions ??= indexObjectActions(stack); + const childActions = objectActions.get(child); + if (!childActions) return; + const childNames = [...childActions.keys()].sort(); + for (let ri = 0; ri < ids.length; ri++) { + const id = strName(ids[ri]); + if (!id) continue; + const idPath = `${path}.properties.actions[${ri}]`; + const action = childActions.get(id); + if (!action) { + const owners = known.has(id) ? actionOwners(stack, id) : []; + findings.push({ + severity: 'error', + rule: ACTION_NAME_UNDEFINED, + where, + path: idPath, + message: + `Related-list actions names action "${id}", which is not an action of the related object ` + + `"${child}"` + + (owners.length > 0 + ? ` (it is defined in this stack on ${owners.join(', ')}, which this list never reads)` + : ' (no action in this stack defines it)') + + ". The list resolves each id against its related object's own actions only — not the " + + "page's object, not a global action — so it draws no button for this one, only a refusal " + + 'notice naming it above the list.' + + suggestName(id, childNames), + hint: + `Define "${id}" on "${child}" — in that object's \`actions\`, or in \`stack.actions\` with ` + + `\`objectName: '${child}'\` — with one of ${RELATED_LIST_PLACEMENTS} in its \`locations\`; ` + + `or name one of "${child}"'s own actions; or remove the reference. Ignore this only if ` + + `another installed package binds the action to "${child}".` + + ` Actions of "${child}": ${childNames.length > 0 ? childNames.join(', ') : '(none)'}.`, + }); + continue; + } + const declared = Array.isArray(action.locations) + ? action.locations.filter((l): l is string => typeof l === 'string') + : undefined; + if (declared?.some((l) => (RELATED_LIST_LOCATIONS as readonly string[]).includes(l))) continue; + findings.push({ + severity: 'error', + rule: ACTION_NAME_UNDEFINED, + where, + path: idPath, + message: + `Related-list actions names action "${id}", an action of the related object "${child}" ` + + (declared === undefined + ? 'that declares no `locations`, so it is placed at none' + : `whose \`locations\` (${declared.length > 0 ? declared.join(', ') : 'empty'}) include none`) + + ` of the locations a related list draws (${RELATED_LIST_LOCATIONS.join(', ')}). The list ` + + 'places an authored action by its own `locations` — naming it here is not a placement — so ' + + 'it draws no button for it, only a refusal notice naming it above the list.', + hint: + `Add one of ${RELATED_LIST_PLACEMENTS} to the \`locations\` of "${id}" on "${child}", ` + + 'or remove the reference.', + }); + } + }; + // ── Page components: record:quick_actions → properties.actionNames, // record:alert → properties.action.actionName, - // page:header → properties.actions[] ── + // page:header → properties.actions[], + // record:related_list → properties.actions[] (against the child object) ── const pages = recordsOf(stack.pages); for (let pi = 0; pi < pages.length; pi++) { const page = pages[pi]; @@ -320,6 +491,19 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { ); } } + + // `record:related_list`'s action ids, resolved against the RELATED + // object (see `checkRelatedListActions`). The related object is the + // per-element `dataSource.object` when one is bound (objectui's + // data-source gate writes it over `objectName`), else `objectName`. + if (type === 'record:related_list' && Array.isArray(props.actions)) { + const binding = component.dataSource; + const child = + (binding && typeof binding === 'object' && !Array.isArray(binding) + ? strName((binding as AnyRec).object) + : undefined) ?? strName(props.objectName); + checkRelatedListActions(child, props.actions as unknown[], where, path); + } } } From 6a73a0ad674f06cb29da07cea383ed8e3e0ce50a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:26:13 +0000 Subject: [PATCH 2/3] fix(lint): related-list finding names where an off-child action is defined Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/lint/src/validate-action-name-refs.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/lint/src/validate-action-name-refs.ts b/packages/lint/src/validate-action-name-refs.ts index 3cacaac0fae..e0deed9272d 100644 --- a/packages/lint/src/validate-action-name-refs.ts +++ b/packages/lint/src/validate-action-name-refs.ts @@ -180,18 +180,18 @@ function indexObjectActions(stack: AnyRec): Map> { return index; } -/** Where an action name IS defined in the stack: `object "x"` per owner, or `a global action`. */ +/** Where an action name IS defined in the stack: `on object "x"` per owner, or `as a global action`. */ function actionOwners(stack: AnyRec, name: string): string[] { const owners = new Set(); for (const obj of recordsOf(stack.objects)) { if (recordsOf(obj.actions).some((a) => a.name === name)) { - owners.add(`object "${strName(obj.name) ?? '?'}"`); + owners.add(`on object "${strName(obj.name) ?? '?'}"`); } } for (const action of recordsOf(stack.actions)) { if (action.name !== name) continue; const owner = strName(action.objectName); - owners.add(owner ? `object "${owner}"` : 'a global action'); + owners.add(owner ? `on object "${owner}"` : 'as a global action'); } return [...owners].sort(); } @@ -379,7 +379,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] { `Related-list actions names action "${id}", which is not an action of the related object ` + `"${child}"` + (owners.length > 0 - ? ` (it is defined in this stack on ${owners.join(', ')}, which this list never reads)` + ? ` (it is defined in this stack ${owners.join(' and ')}, which this list never reads)` : ' (no action in this stack defines it)') + ". The list resolves each id against its related object's own actions only — not the " + "page's object, not a global action — so it draws no button for this one, only a refusal " + From b37812bed9f9a02ca545c7a236e3d72173991894 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 19:28:01 +0000 Subject: [PATCH 3/3] chore(changeset): lint related-list action refs Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../20936-action-name-refs-related-list.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .changeset/20936-action-name-refs-related-list.md diff --git a/.changeset/20936-action-name-refs-related-list.md b/.changeset/20936-action-name-refs-related-list.md new file mode 100644 index 00000000000..9eaf990a503 --- /dev/null +++ b/.changeset/20936-action-name-refs-related-list.md @@ -0,0 +1,22 @@ +--- +"@objectstack/lint": minor +--- + +fix(lint)!: `action-name-undefined` resolves `record:related_list` action ids against the related object, and refuses an id the list cannot draw (#20936) + +Clause-②: no (narrowing) + +`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`. + +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. + +The rule now walks the key, scoped to `record:related_list`, and answers the same two questions the renderer asks: + +- 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; +- 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. + +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. + +**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. + +