Skip to content

Commit eaed2ee

Browse files
os-zhuangzhuangjianguoclaude
authored
fix(spec): defineStack cross-reference validation reaches inline page-element actions (#6889) (#7392)
`validateCrossReferences` iterated `config.actions` only, so an action authored inline on a page element (`element:button` -> `properties.action`) was never cross-referenced. Measured on main: a dangling inline `type: 'modal'` target built clean and failed only when a user clicked, while the identical target on a registered action was a build error. Page `regions[].components[]`, `slots.*` and nested container children are now walked, and every inline action found gets the SAME two target checks as a registered one -- same rule, same message tail, same size gates. The `flow` arm rides the same traversal. The modal arm mirrors the registered rule rather than also accepting an object name, per the maintainer ruling on #6739 (2026-08-09): a `type: 'modal'` target names a PAGE, only. Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2 Co-authored-by: os-zhuang <zhuangjianguo@steedos.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 934774d commit eaed2ee

3 files changed

Lines changed: 463 additions & 12 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): `defineStack`'s action cross-reference walk now reaches INLINE page-element actions (#6889)
6+
7+
`validateCrossReferences` iterated `config.actions` — the registered action list — only.
8+
An action authored **inline** on a page element (`element:button``properties.action`,
9+
an `InlineActionSchema`) never enters that list, so no cross-reference check ever visited
10+
one. The card's five-stack probe, re-measured on `main` before this change:
11+
12+
```
13+
A registered modal -> object : REJECTED
14+
B registered modal -> page : ACCEPTED
15+
C registered modal -> nothing : REJECTED
16+
D inline modal -> object : ACCEPTED <- same target, opposite verdict
17+
E inline modal -> nothing : ACCEPTED <- dangling, builds clean
18+
```
19+
20+
Row E is the defect on its own terms: a `target` naming neither a page nor an object nor
21+
anything else built clean, shipped, and failed only when a user clicked — a
22+
silent-until-clicked dead button, exactly the class row C exists to prevent. Inline is
23+
also the shape AI authoring emits most readily (a button with its behaviour written right
24+
there, no registry entry), so it was the one surface that most needed authoring-time
25+
rejection and the one surface the walk did not visit. "Declared = enforced" held for
26+
registered actions and not for inline ones.
27+
28+
**Now**: page `regions[].components[]`, `slots.*`, and nested container children are
29+
walked, and every inline action found is subjected to the **same** two target checks as a
30+
registered one — same rule, same message tail, same size gates. The `flow` arm rides the
31+
same traversal, so an inline flow action naming no declared flow is rejected too.
32+
33+
Messages keep the registered wording and change only the subject, because an inline action
34+
is located by page + path rather than by a registry entry and its `name` is optional:
35+
36+
```
37+
Inline action 'new_task' on page 'home' (regions.0.components.2) references page
38+
'nowhere' (via modal target) which is not defined in pages.
39+
```
40+
41+
Scope of the modal arm is the maintainer's ruling on #6739 (2026-08-09): **a
42+
`type: 'modal'` target names a PAGE, only** — so the inline arm mirrors the registered one
43+
rather than also accepting an object name. `objectName` has no inline counterpart to
44+
check: `InlineActionSchema` does not pick that key.
45+
46+
**Acceptance-face narrowing.** A stack carrying a dangling inline `modal`/`flow` target
47+
now fails to build where it previously built clean. Census of the shipped corpus found
48+
**zero** stacks affected: the one inline action in the reference corpus
49+
(`examples/app-showcase`'s home CTA) is `type: 'form'` since #6739, and cloud's five
50+
tenant-page buttons are all `type: 'url'`. Neither type is cross-referenced.
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
/**
2+
* `defineStack` cross-reference validation reaches INLINE (page-element)
3+
* actions — #6889.
4+
*
5+
* An action authored inline on a page element (`element:button` →
6+
* `properties.action`, an `InlineActionSchema`) never enters `config.actions`,
7+
* which is the only list the cross-reference walk used to iterate. The measured
8+
* consequence, from the card's own five-stack probe on `main`:
9+
*
10+
* ```
11+
* A registered modal -> object : REJECTED
12+
* B registered modal -> page : ACCEPTED
13+
* C registered modal -> nothing : REJECTED
14+
* D inline modal -> object : ACCEPTED ← same target, opposite verdict
15+
* E inline modal -> nothing : ACCEPTED ← dangling, builds clean
16+
* ```
17+
*
18+
* Row E is the defect on its own terms: a target naming nothing at all shipped
19+
* as a dead button that failed only when a user clicked it. Row D is the
20+
* A/D split, and its verdict is fixed by the maintainer's ruling on #6739
21+
* (2026-08-09): "A — a `type: 'modal'` target names a PAGE, only." So inline
22+
* mirrors registered exactly — same rule, same message tail, one more
23+
* traversal.
24+
*
25+
* Message shape is contract here (one condition ⇒ one wording), so these pin
26+
* full message text rather than `toThrow()` alone: a bare throw assertion
27+
* cannot tell "refused for the right reason" from "refused because the fixture
28+
* is broken", and every rejection fixture below differs from an ACCEPTED twin
29+
* by exactly one string.
30+
*/
31+
import { describe, it, expect } from 'vitest';
32+
import { defineStack } from './stack.zod';
33+
34+
const baseManifest = {
35+
id: 'com.example.inline',
36+
name: 'inline-crossref-test',
37+
version: '1.0.0',
38+
type: 'app' as const,
39+
};
40+
41+
const objects = [
42+
{ name: 'probe_task', label: 'Probe Task', fields: { title: { type: 'text' as const } } },
43+
];
44+
45+
const flows = [
46+
{ name: 'probe_flow', label: 'Probe Flow', type: 'autolaunched' as const, nodes: [], edges: [] },
47+
];
48+
49+
/** A page whose single region holds the given components. */
50+
const pageWith = (components: unknown[], extra: Record<string, unknown> = {}) => ({
51+
name: 'probe_home',
52+
label: 'Probe Home',
53+
type: 'home' as const,
54+
regions: [{ name: 'main', components }],
55+
...extra,
56+
});
57+
58+
const button = (action: unknown) => ({
59+
type: 'element:button',
60+
properties: { label: 'Go', action },
61+
});
62+
63+
/** A stack whose ONLY action is the inline one — no `config.actions` at all. */
64+
const inlineStack = (action: unknown, extra: Record<string, unknown> = {}) => ({
65+
manifest: baseManifest,
66+
objects,
67+
pages: [pageWith([button(action)])],
68+
...extra,
69+
});
70+
71+
const build = (config: unknown) => defineStack(config as Parameters<typeof defineStack>[0]);
72+
73+
/** The `✗` lines of a cross-reference rejection, or `[]` when it was accepted. */
74+
function refusals(config: unknown): string[] {
75+
try {
76+
build(config);
77+
return [];
78+
} catch (error) {
79+
return String((error as Error).message)
80+
.split('\n')
81+
.map((line) => line.trim())
82+
.filter((line) => line.startsWith('✗'))
83+
.map((line) => line.slice(1).trim());
84+
}
85+
}
86+
87+
describe('defineStack — inline action cross-references: modal targets (#6889)', () => {
88+
it('rejects a dangling inline modal target (probe row E) with the registered rule\'s wording', () => {
89+
expect(refusals(inlineStack({ name: 'probe_new_task', type: 'modal', target: 'probe_nowhere' }))).toEqual([
90+
"Inline action 'probe_new_task' on page 'probe_home' (regions.0.components.0) "
91+
+ "references page 'probe_nowhere' (via modal target) which is not defined in pages.",
92+
]);
93+
});
94+
95+
it('rejects an inline modal target naming an OBJECT — #6739 ruling A, a modal target names a page (probe row D)', () => {
96+
expect(refusals(inlineStack({ name: 'probe_new_task', type: 'modal', target: 'probe_task' }))).toEqual([
97+
"Inline action 'probe_new_task' on page 'probe_home' (regions.0.components.0) "
98+
+ "references page 'probe_task' (via modal target) which is not defined in pages.",
99+
]);
100+
});
101+
102+
it('accepts an inline modal target naming a declared page — the legitimate shape survives', () => {
103+
expect(refusals(inlineStack({ name: 'probe_new_task', type: 'modal', target: 'probe_home' }))).toEqual([]);
104+
});
105+
106+
it('closes the A/D split: the same target gets the same verdict registered or inline', () => {
107+
const registered = (target: string) => ({
108+
manifest: baseManifest,
109+
objects,
110+
pages: [pageWith([])],
111+
actions: [{ name: 'probe_new_task', label: 'New', type: 'modal' as const, target }],
112+
});
113+
114+
for (const target of ['probe_task', 'probe_nowhere']) {
115+
expect(refusals(registered(target)).length, `registered → ${target}`).toBe(1);
116+
expect(refusals(inlineStack({ name: 'probe_new_task', type: 'modal', target })).length, `inline → ${target}`).toBe(1);
117+
}
118+
// …and both accept the page.
119+
expect(refusals(registered('probe_home'))).toEqual([]);
120+
expect(refusals(inlineStack({ name: 'probe_new_task', type: 'modal', target: 'probe_home' }))).toEqual([]);
121+
});
122+
});
123+
124+
describe('defineStack — inline action cross-references: flow targets (#6889)', () => {
125+
it('rejects an inline flow target that names no declared flow', () => {
126+
expect(refusals(inlineStack({ name: 'probe_run', type: 'flow', target: 'probe_nowhere' }, { flows }))).toEqual([
127+
"Inline action 'probe_run' on page 'probe_home' (regions.0.components.0) "
128+
+ "references flow 'probe_nowhere' which is not defined in flows.",
129+
]);
130+
});
131+
132+
it('accepts an inline flow target that names a declared flow', () => {
133+
expect(refusals(inlineStack({ name: 'probe_run', type: 'flow', target: 'probe_flow' }, { flows }))).toEqual([]);
134+
});
135+
136+
it('skips inline flow targets when the stack declares NO flows — same size gate as the registered rule', () => {
137+
// The referenced flow may be provided by a plugin; the registered walk has
138+
// made this concession since it was written, and inline must not be
139+
// stricter than registered.
140+
expect(refusals(inlineStack({ name: 'probe_run', type: 'flow', target: 'probe_nowhere' }))).toEqual([]);
141+
});
142+
});
143+
144+
describe('defineStack — inline action cross-references: the traversal itself (#6889)', () => {
145+
it('reaches a button nested inside a container\'s children and reports its path', () => {
146+
const config = {
147+
manifest: baseManifest,
148+
objects,
149+
pages: [pageWith([
150+
{
151+
type: 'layout:container',
152+
properties: { children: [button({ name: 'probe_deep', type: 'modal', target: 'probe_nowhere' })] },
153+
},
154+
])],
155+
};
156+
157+
expect(refusals(config)).toEqual([
158+
"Inline action 'probe_deep' on page 'probe_home' (regions.0.components.0.properties.children.0) "
159+
+ "references page 'probe_nowhere' (via modal target) which is not defined in pages.",
160+
]);
161+
});
162+
163+
it('reaches a button authored under `slots` rather than `regions`', () => {
164+
const config = {
165+
manifest: baseManifest,
166+
objects,
167+
pages: [pageWith([], {
168+
kind: 'slotted',
169+
slots: { actions: [button({ name: 'probe_slot', type: 'modal', target: 'probe_nowhere' })] },
170+
})],
171+
};
172+
173+
expect(refusals(config)).toEqual([
174+
"Inline action 'probe_slot' on page 'probe_home' (slots.actions.0) "
175+
+ "references page 'probe_nowhere' (via modal target) which is not defined in pages.",
176+
]);
177+
});
178+
179+
it('identifies an ANONYMOUS inline action by its path — `name` is optional on this surface', () => {
180+
expect(refusals(inlineStack({ type: 'modal', target: 'probe_nowhere' }))).toEqual([
181+
"Inline action on page 'probe_home' (regions.0.components.0) "
182+
+ "references page 'probe_nowhere' (via modal target) which is not defined in pages.",
183+
]);
184+
});
185+
186+
it('reads the legacy `to` spelling through InlineActionSchema, not by hand', () => {
187+
// `to` → `target` is the schema's preprocess. Page-component `properties`
188+
// are a loose record, so the raw node has NOT been through it; the walk
189+
// parses rather than reading `target ?? to` itself (PD #12).
190+
expect(refusals(inlineStack({ name: 'probe_legacy', type: 'modal', to: 'probe_nowhere' }))).toEqual([
191+
"Inline action 'probe_legacy' on page 'probe_home' (regions.0.components.0) "
192+
+ "references page 'probe_nowhere' (via modal target) which is not defined in pages.",
193+
]);
194+
});
195+
196+
it('still catches a dangling target on a node InlineActionSchema cannot parse', () => {
197+
// `objectName` is not a key `InlineActionSchema` picks, so this node fails
198+
// to parse (`unrecognized_keys`). The dangling target must not get to hide
199+
// behind that unrelated defect.
200+
expect(refusals(inlineStack({ name: 'probe_unparsed', type: 'modal', target: 'probe_nowhere', objectName: 'probe_task' }))).toEqual([
201+
"Inline action 'probe_unparsed' on page 'probe_home' (regions.0.components.0) "
202+
+ "references page 'probe_nowhere' (via modal target) which is not defined in pages.",
203+
]);
204+
});
205+
206+
it('reports every offending inline action on a page, not just the first', () => {
207+
const config = {
208+
manifest: baseManifest,
209+
objects,
210+
flows,
211+
pages: [pageWith([
212+
button({ name: 'probe_one', type: 'modal', target: 'probe_nowhere' }),
213+
button({ name: 'probe_two', type: 'flow', target: 'probe_elsewhere' }),
214+
])],
215+
};
216+
217+
expect(refusals(config)).toEqual([
218+
"Inline action 'probe_one' on page 'probe_home' (regions.0.components.0) "
219+
+ "references page 'probe_nowhere' (via modal target) which is not defined in pages.",
220+
"Inline action 'probe_two' on page 'probe_home' (regions.0.components.1) "
221+
+ "references flow 'probe_elsewhere' which is not defined in flows.",
222+
]);
223+
});
224+
});
225+
226+
describe('defineStack — inline action cross-references: what the walk must NOT refuse (#6889)', () => {
227+
it.each([
228+
['form', { name: 'probe_form', type: 'form', target: 'probe_task.edit' }],
229+
['url', { name: 'probe_url', type: 'url', target: '/environments' }],
230+
['api', { name: 'probe_api', type: 'api', target: '/api/v1/x', method: 'POST' }],
231+
['script', { name: 'probe_script', type: 'script', target: 'doThing' }],
232+
['navigation', { type: 'navigation', to: '/environments' }],
233+
])('leaves an inline `%s` action alone — only modal and flow targets are cross-referenced', (_type, action) => {
234+
expect(refusals(inlineStack(action, { flows }))).toEqual([]);
235+
});
236+
237+
it('leaves a component with no inline action alone', () => {
238+
const config = {
239+
manifest: baseManifest,
240+
objects,
241+
pages: [pageWith([{ type: 'element:text', properties: { content: 'hello' } }])],
242+
};
243+
expect(refusals(config)).toEqual([]);
244+
});
245+
246+
it('is vacuity-guarded: the shipped showcase home CTA shape still builds', () => {
247+
// The exact inline shape `examples/app-showcase/src/ui/pages/index.ts`
248+
// carries after #6739 — `type: 'form'` at the object's edit view. If this
249+
// ever refuses, the corpus census in PR #6889 has gone stale.
250+
expect(refusals(inlineStack({
251+
name: 'showcase_new_task', type: 'form', target: 'probe_task.edit', refreshAfter: true,
252+
}))).toEqual([]);
253+
});
254+
});

0 commit comments

Comments
 (0)