Skip to content

Commit a243cfb

Browse files
fix(lint): action-name-undefined walks the record:alert CTA and page:header action ids (#20171)
Fixes #20105 Clause-②: no ## What `action-name-undefined` (`packages/lint/src/validate-action-name-refs.ts`) is the authoring gate for "a surface names an action no action in the stack defines". It walked list-view row/bulk menus, the `record:quick_actions` bar and app navigation, but never read the two other page surfaces that bind an action by id: - `record:alert` → `properties.action.actionName` (the banner's call-to-action); - `page:header` → `properties.actions` (the header's action ids). Both objectui renderers resolve the id against the object's declared actions and draw nothing on a miss (the alert keeps its banner and silently loses its button; the header renders one button fewer and only warns in the browser console). The spec types both as plain strings, so a typo passed spec and lint and vanished at runtime. This PR adds two walks to the existing rule (no new rule, no new gate), each scoped to its component type and resolved through the same `check` closure as `actionNames`: against every action defined in the stack, global or object-embedded, with the same did-you-mean. Details: - Type-scoped because the keys mean something else elsewhere: `element:button`'s `action` is an inline definition, and `record:related_list` declares its own `actions`. - `page:header.actions`: only string elements are resolved, each reported at its AUTHORED index. An inline object element is a definition, not a reference: the spec schema rejects it, and `os validate` already reports it as `component-props-invalid` (warning). - `check` gained an optional consequence sentence, because "the button renders and does nothing when clicked" is false for these two surfaces: no button is drawn at all. The hint names each surface's placement: none for the alert (it runs its CTA by name, with no `locations` filter), `record_header` or `record_more` for the header (its `placedOnHeader` filter). - The rule header lists the two new covered surfaces. ## Mechanism readings (objectui, read-only) - H3, `page:header.actions` shape: spec `PageHeaderProps.actions = z.array(z.string()).describe('Action IDs to show in header')`. objectui `PageHeaderRenderer` (`packages/components/src/renderers/layout/containers.tsx`, same code at the pin `f8a9d0fb05` and at `origin/main` `41ae65b2`) resolves ids ONLY against the bound object's `actions` metadata via `resolveDeclaredActionIds`. Host system actions (Edit / Share / Delete) are injected separately through `RecordContext.headerSystemActions` and never resolved from an authored id, so no built-in id can legitimately appear here. The rule's stack-wide resolution is a superset of the runtime's, so it refuses nothing the runtime would draw. - `record:alert` CTA: objectui `record-alert.tsx` resolves `action.actionName` against the object's `actions[]` (a `find` at the pin; `resolveDeclaredActionIds` on main) and executes it through `ActionEngine.executeAction(name)`, which applies no location filter. ## Evidence (all at `a92b55d86` unless stated) - **Blind spot proven with failing rows first.** The test rows were committed before the fix (`90b98e842`). Pre-fix leg: the rule file was restored from `90b98e842` (tree only; the rule then counts `record:alert` 0, `page:header` 0, `record:quick_actions` 2). Result: `Tests 3 failed | 27 passed (30)`, and the three failures are exactly the new negative rows (alert CTA; header id at index 1; header mixed array). The restore was trapped and proven by content: the hash `cc02f0601a96` equals the HEAD blob, and `git diff HEAD` is empty. - **Rule file at HEAD:** `vitest run src/validate-action-name-refs.test.ts` gives `Tests 30 passed (30)`. - **`@objectstack/lint` full suite:** `Test Files 108 passed (108)`, `Tests 4144 passed | 5 skipped (4149)`. `pnpm --filter @objectstack/lint typecheck` exits 0, and its test layer holds its pinned debt. The dependency closure was built first (`--filter '@objectstack/lint^...'`, VERDICT command-exit 0). - **Real metadata (H2 positive control), a one-off script not committed:** the rule over all 45 platform-objects objects (54 embedded actions; `sys-api-key.object.ts` was skipped, since a smaller universe can only add findings) plus the 3 shipped pages (`sys_organization_detail`, `sys_position_detail`, `sys_user_detail`). - **REAL stack:** 0 findings, both pre-fix and at HEAD. `sys_user_detail`'s `resend_verification_email` CTA resolves clean. - **Mutation A, the real `sys_user` page with the CTA misspelled:** pre-fix 0, HEAD 1 (`action-name-undefined` error at `pages[0].slots.alerts[0].properties.action.actionName`). - **Mutation B, a `page:header` with `['resend_verification_email','resend_verification']`:** pre-fix 0, HEAD 1 (at `.properties.actions[1]`). - **`@objectstack/platform-objects` suite** (the named live producer; lint is aliased to source there): `Test Files 55 passed (55)`, `Tests 911 passed (911)`. None of its tests calls this rule, so this reading shows only that nothing regressed. - **`@objectstack/cli`:** declared to CI, not run locally. Census of its test inputs: 1 fixture carries `page:header` (`validate-json-strict-exit.e2e.test.ts`, with no `actions` key) and 0 carry `record:alert`, so neither new walk can yield a finding over cli's inputs. - **Gates:** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` (derived after the changeset landed) gave 59 commands. `--ran` reconciliation: 59 derived, 56 run (all exit 0), 3 NOT MEASURED (exit 3, PREREQUISITE NOT MET, since each needs a whole-workspace build that CI owns): `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt`. `check:docs-transcript-drift` first reported PREREQUISITE NOT MET, then passed once lint was built. The derivation flagged its tree as 4 commits behind `origin/main`: the moved files were `dispatch-gates.mjs` (over-threshold prose only), `check-governed-merges.mjs` and `check-governed-queue-guard.mjs` (governed-surface landing), and none of them changes the families for these three paths. - **ESLint, as a proven narrowing** (the repo-wide `pnpm lint` is CI's). Population from `eslint.config.mjs`: `**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`, so the changeset `.md` is outside it. `--format json` over the 2 changed `.ts` files: 2 files, 0 errors, 0 warnings, exit 0. Invariance: the config never enables type-aware linting (`--print-config` gives `parserOptions.project: null` for both files), so this diff cannot move any untouched file's verdict. ## Changeset `@objectstack/lint` gets a `patch`: a blind-spot repair to a released rule. A stack whose alert CTA or header id names an undefined action built clean before and now fails `os validate` / `os lint` / `os build`, but that id never rendered a button. The rule keeps its frozen `flow` runtime crossing, so the `page` write door is unchanged. ## Acceptance notes (noted, not filed) - **Kebab `page-header`.** The legacy spelling (objectui `@object-ui/layout` registration, open namespace) also reads `actions` ids, through `record:quick_actions`. It is not walked here: the card names `page:header`, and the census found zero in-repo producers outside spec conversion fixtures. Carrier: none. - **`record:related_list.actions`.** Declared as "Action IDs available for related records", this is another by-name id list the rule does not walk. Its runtime liveness was not measured. Carrier: none. - **Resolution scope.** The triage note says "resolved against the page object's declared actions", while the card and the rule's documented posture say "exactly as `record:quick_actions` is", which is stack-wide (zero false positives). The runtime resolves only the bound object's `actions`, so an id defined on a different object still passes lint and draws nothing. The same limit already applies to the quick-actions walk, and this PR does not widen it. - **`actionNames` index.** The existing walk reports the index into the string-filtered list, which is off by one or more only when a non-string element precedes. The spec refuses those elements anyway. The new header walk reports authored indices. The existing walk is left untouched. ## Out of scope (reported to the seat for filing) - **Class b: the published skill teaches the refused header shape.** `skills/objectstack-ui/rules/pages.md` (the catalogue row `inline actions: Action[]`, the example `actions: [ConvertLeadAction]`, and the note "pass full `Action` objects into `page:header.properties.actions`") and its eval `skills/objectstack-ui/evals/views-apps-actions-pages.json` teach inline Action OBJECTS. The contract is ids: `PageHeaderProps.actions: z.array(z.string()).describe('Action IDs to show in header')`. Measured: `validateComponentProps` on that exact shape reports `component-props-invalid` (warning) `actions.0: Invalid input: expected string, received object`. Seam: `spec:PageHeaderProps.actions → renderer:PageHeaderRenderer` (the undeclared all-object transition arm). --- _Generated by [Claude Code](https://claude.ai/code/session_01QcAS3qiYYZNezaxZxaUdMV)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9347c1f commit a243cfb

3 files changed

Lines changed: 179 additions & 4 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): `action-name-undefined` now resolves the `record:alert` call-to-action and the `page:header` action ids (#20105)
6+
7+
`action-name-undefined` is the authoring gate for "a surface names an action that no action in the stack defines". It already walked list-view row/bulk menus, the `record:quick_actions` bar (`properties.actionNames`) and app navigation, but two page surfaces that bind an action by id were never read:
8+
9+
- `record:alert` → `properties.action.actionName` (the banner's call-to-action button);
10+
- `page:header` → `properties.actions` (the header's action ids).
11+
12+
Both renderers resolve the id against the object's declared actions and draw nothing when it resolves nowhere: the alert keeps its banner and silently loses its button, and the header renders one button fewer with only a browser-console warning. The spec types both as plain strings, so a misspelled id passed spec validation and lint and vanished at runtime.
13+
14+
The rule now walks both keys, each scoped to its component type (`element:button`'s `action` is an inline definition and `record:related_list` declares its own `actions`, so neither is read), and resolves them exactly as it resolves `actionNames`: against every action defined in the stack, global or object-embedded, with the same did-you-mean. A `page:header` array's inline-object elements are skipped (the spec refuses them on its own; they are definitions, not references), and every id is reported at its authored index. Each finding says what the author will actually see — a banner with no button, a header with no button — and the hint names the placement each surface needs: none for the alert, which runs its call-to-action by name, and `record_header` or `record_more` in `locations` for the header.
15+
16+
**What moves for consumers.** A stack whose `record:alert` or `page:header` names an undefined action 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. A stack whose ids all resolve is unaffected: measured on the platform's own `sys_user` page, whose `resend_verification_email` call-to-action resolves clean.

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

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,99 @@ describe('validateActionNameRefs — page quick actions', () => {
203203
});
204204
});
205205

206+
// The two page surfaces that name an action by id OUTSIDE `actionNames`. Both
207+
// renderers resolve the id against the object's declared actions and draw
208+
// nothing for an id that resolves nowhere — the alert keeps its banner and
209+
// loses its button, the header renders one button fewer — so a typo here is
210+
// the same dead reference as on the quick-actions bar, one key over.
211+
describe('validateActionNameRefs — record:alert call-to-action', () => {
212+
const alertPage = (actionName: string) => ({
213+
name: 'user_record',
214+
kind: 'slotted',
215+
object: 'sys_user',
216+
slots: {
217+
alerts: [
218+
{
219+
type: 'record:alert',
220+
properties: { severity: 'warning', title: 'Email not verified', action: { actionName } },
221+
},
222+
],
223+
},
224+
});
225+
const withUserAction = () => ({
226+
objects: [
227+
{
228+
name: 'sys_user',
229+
fields: { email: { type: 'text' } },
230+
actions: [{ name: 'resend_verification_email', label: 'Resend', type: 'script' }],
231+
},
232+
],
233+
});
234+
235+
it('errors on an action.actionName naming no defined action', () => {
236+
const findings = validateActionNameRefs({
237+
...withUserAction(),
238+
pages: [alertPage('resend_verifcation_email')],
239+
});
240+
expect(findings).toHaveLength(1);
241+
expect(findings[0].rule).toBe(ACTION_NAME_UNDEFINED);
242+
expect(findings[0].severity).toBe('error');
243+
expect(findings[0].path).toBe('pages[0].slots.alerts[0].properties.action.actionName');
244+
expect(findings[0].message).toContain('"resend_verifcation_email"');
245+
});
246+
247+
it('accepts an action.actionName that resolves to a declared action', () => {
248+
expect(
249+
validateActionNameRefs({ ...withUserAction(), pages: [alertPage('resend_verification_email')] }),
250+
).toEqual([]);
251+
});
252+
});
253+
254+
describe('validateActionNameRefs — page:header actions', () => {
255+
const headerPage = (actions: unknown[]) => ({
256+
name: 'lead_record',
257+
object: 'crm_lead',
258+
regions: [
259+
{
260+
name: 'header',
261+
components: [{ type: 'page:header', properties: { title: 'Lead', actions } }],
262+
},
263+
],
264+
});
265+
266+
it('errors on an actions id naming no defined action, at its own index', () => {
267+
const findings = validateActionNameRefs({
268+
...withActions(),
269+
pages: [headerPage(['crm_convert_lead', 'covert_lead'])],
270+
});
271+
expect(findings).toHaveLength(1);
272+
expect(findings[0].rule).toBe(ACTION_NAME_UNDEFINED);
273+
expect(findings[0].severity).toBe('error');
274+
expect(findings[0].path).toBe('pages[0].regions[0].components[0].properties.actions[1]');
275+
expect(findings[0].message).toContain('"covert_lead"');
276+
});
277+
278+
it('accepts actions ids that all resolve to declared actions', () => {
279+
expect(
280+
validateActionNameRefs({ ...withActions(), pages: [headerPage(['crm_convert_lead'])] }),
281+
).toEqual([]);
282+
});
283+
284+
// The spec's contract is ids (`z.array(z.string())`), and it refuses an
285+
// inline object element on its own; an object here is a definition, not a
286+
// reference, so there is nothing for THIS rule to resolve — but the ids
287+
// around it are still references, reported at their real index.
288+
it('resolves only the id elements, reporting each at its authored index', () => {
289+
const findings = validateActionNameRefs({
290+
...withActions(),
291+
pages: [headerPage([{ name: 'inline_def', type: 'script' }, 'nope'])],
292+
});
293+
expect(findings.map((f) => f.path)).toEqual([
294+
'pages[0].regions[0].components[0].properties.actions[1]',
295+
]);
296+
});
297+
});
298+
206299
describe('validateActionNameRefs — navigation action items', () => {
207300
it('errors on an undefined nav actionName', () => {
208301
const findings = validateActionNameRefs({

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

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,17 @@
1717
* own `listViews.<key>` (added in #4457; an object has no top-level `list`,
1818
* so that tier had simply never been walked)
1919
* - page components — `record:quick_actions` → `properties.actionNames[]`
20+
* - page components — `record:alert` → `properties.action.actionName` (the
21+
* banner's call-to-action) and `page:header` → `properties.actions[]` (the
22+
* header's action ids). Both renderers resolve the id against the object's
23+
* declared actions and draw NOTHING for one that resolves nowhere — the
24+
* alert keeps its banner and loses its button without a word, the header
25+
* renders one button fewer and says so only in a browser console no
26+
* author reads — so authoring time is where the refusal belongs (#20105).
27+
* Each walk is scoped to its component type, because the same key means
28+
* 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`.
2031
* - app navigation — `{ type: 'action', actionDef: { actionName } }`
2132
* - app navigation deep-link auto-run — `{ type: 'object', runAction }`
2233
* (#4848 — the declared form of the `?runAction=<name>` URL contract)
@@ -115,6 +126,13 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
115126
* hint for both would have to be wrong for one of them.
116127
*/
117128
placement = 'with the location this surface needs',
129+
/**
130+
* What the author will SEE. Most surfaces draw the button and dispatch
131+
* nothing; a surface that resolves the id before drawing (the alert's
132+
* call-to-action, the page header) draws no button at all — one sentence
133+
* for both would describe a failure the author cannot find.
134+
*/
135+
consequence = 'The button renders and does nothing when clicked — a dead affordance the runtime cannot dispatch.',
118136
) => {
119137
if (known.has(name)) return;
120138
findings.push({
@@ -124,8 +142,7 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
124142
path,
125143
message:
126144
`${surface} names action "${name}", which is defined by no action in this stack ` +
127-
`(neither \`stack.actions\` nor any object's \`actions\`). The button renders and ` +
128-
`does nothing when clicked — a dead affordance the runtime cannot dispatch.` +
145+
`(neither \`stack.actions\` nor any object's \`actions\`). ${consequence}` +
129146
suggestName(name, known),
130147
hint:
131148
`Define an action named "${name}" (in \`stack.actions\` or the object's \`actions\`) ` +
@@ -231,7 +248,9 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
231248
}
232249
}
233250

234-
// ── Page components: record:quick_actions → properties.actionNames ──
251+
// ── Page components: record:quick_actions → properties.actionNames,
252+
// record:alert → properties.action.actionName,
253+
// page:header → properties.actions[] ──
235254
const pages = recordsOf(stack.pages);
236255
for (let pi = 0; pi < pages.length; pi++) {
237256
const page = pages[pi];
@@ -245,15 +264,62 @@ export function validateActionNameRefs(stack: AnyRec): ActionNameRefFinding[] {
245264
for (const { component, path } of walkPageComponents(page, `pages[${pi}]`)) {
246265
const props = component.properties as AnyRec | undefined;
247266
if (!props || typeof props !== 'object') continue;
267+
const type = strName(component.type);
268+
const where = `page "${pageName}" · component "${type ?? '?'}"`;
248269
const names = strList(props.actionNames);
249270
for (let ai = 0; ai < names.length; ai++) {
250271
check(
251272
names[ai],
252-
`page "${pageName}" · component "${strName(component.type) ?? '?'}"`,
273+
where,
253274
`${path}.properties.actionNames[${ai}]`,
254275
'Quick-actions bar',
255276
);
256277
}
278+
279+
// `record:alert`'s call-to-action. The renderer resolves `actionName`
280+
// against the object's declared actions and, on a miss, renders the
281+
// banner WITHOUT its button — nothing is logged. It runs the resolved
282+
// action by name, with no `locations` filter, so the name is the whole
283+
// placement.
284+
if (type === 'record:alert') {
285+
const cta = props.action;
286+
const ctaName =
287+
cta && typeof cta === 'object' && !Array.isArray(cta)
288+
? strName((cta as AnyRec).actionName)
289+
: undefined;
290+
if (ctaName) {
291+
check(
292+
ctaName,
293+
where,
294+
`${path}.properties.action.actionName`,
295+
'Alert call-to-action',
296+
'(no `locations` entry needed — the alert renders its call-to-action by name)',
297+
'The banner renders with no call-to-action button: the id resolves to nothing and the renderer drops it without a word.',
298+
);
299+
}
300+
}
301+
302+
// `page:header`'s action ids. The spec's contract is ids
303+
// (`z.array(z.string())`); an inline object element is a definition,
304+
// refused by the spec on its own, not a reference for this rule to
305+
// resolve — so only the string elements are checked, each at its
306+
// AUTHORED index. The header draws an authored action only when it is
307+
// placed at `record_header` or `record_more`.
308+
if (type === 'page:header' && Array.isArray(props.actions)) {
309+
const ids = props.actions as unknown[];
310+
for (let hi = 0; hi < ids.length; hi++) {
311+
const id = strName(ids[hi]);
312+
if (!id) continue;
313+
check(
314+
id,
315+
where,
316+
`${path}.properties.actions[${hi}]`,
317+
'Page-header actions',
318+
'with `record_header` or `record_more` in its `locations` (the header draws only actions placed at one of the two)',
319+
'The header draws no button for it: the id resolves to nothing and is dropped, with only a browser-console warning no author reads.',
320+
);
321+
}
322+
}
257323
}
258324
}
259325

0 commit comments

Comments
 (0)