diff --git a/.changeset/define-stack-duplicate-action-key-refusal.md b/.changeset/define-stack-duplicate-action-key-refusal.md new file mode 100644 index 0000000000..15510bbdb3 --- /dev/null +++ b/.changeset/define-stack-duplicate-action-key-refusal.md @@ -0,0 +1,13 @@ +--- +'@objectstack/spec': minor +--- + +`defineStack` now refuses two actions that resolve to the same scope-qualified runtime key — **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes. + +The runtime registers and dispatches every action under one exact-string key: the owning object's name (or `global` for an object-less action), a colon, then the action name — with no wildcard semantics. Two declarations under one key collapse to one handler registration: whichever registers second wins, and the other action stays a live, declared, permission-gated button whose handler is unreachable. Nothing at author, build or boot time said so, and the loser failed only when a user clicked it. Every same-scope shape built clean before: two standalone globals sharing a name, two standalone actions bound to the same object, a bound standalone beside an embedded twin on the same object (the merge into the object's `actions` appends, so both survived), and two embedded twins on one object. + +The refusal joins `defineStack`'s cross-reference walk and its existing envelope (`defineStack cross-reference validation failed (N issue(s)):`), one line per colliding key, naming the key and where each declaration was written (`stack.actions[i]`, or `objects['OBJECT'].actions[j]` for an embedded one). It runs in an object-less stack too, and every site counts — a byte-identical copy in both positions is refused as well, because the merge into the object's `actions` appends and the shipped artifact then carries two entries under one key (the runtime lists both, and a bare-name lookup refuses the ambiguity). Consequently a stack BUILT by `defineStack` (each bound action already copied into its object) is refused if fed back in; author the source shape, not the artifact. An embedded action is keyed by the object it is written on, not by its own `objectName`. The fix is the one the message names: rename one of the two within that scope, bind one to a different object, or remove the duplicate. + +Deliberately unchanged: one global and one object-bound action MAY still share a `name`. They occupy two distinct keys, and the precedence the runtime already implements for by-name readers on the object's route (the object's own `actions` first) is now documented on the `actions` collection rather than altered. + + diff --git a/packages/spec/src/stack-duplicate-action-key.test.ts b/packages/spec/src/stack-duplicate-action-key.test.ts new file mode 100644 index 0000000000..090bc18062 --- /dev/null +++ b/packages/spec/src/stack-duplicate-action-key.test.ts @@ -0,0 +1,297 @@ +/** + * `defineStack` refuses two actions that resolve to the same scope-qualified + * runtime key — and ONLY those. + * + * The runtime registers and dispatches every action under one exact-string + * key, `:` (`executeAction` is a `Map` lookup with no wildcard + * semantics; the scope is the owning object's name, or `'global'` for an + * object-less action — objectql's `GLOBAL_ACTION_OBJECT_KEY`). Two + * declarations under one key collapse to one handler registration: the second + * to register wins, and the other stays a live, declared, permission-gated + * button whose handler is unreachable — failing only when a user clicks it. + * + * Measured on `main` @ `2aa8456cf` before the check existed, one probe per row: + * + * ``` + * (a) two standalone globals, one name : ACCEPTED stack.actions=["global:dup_a","global:dup_a"] + * (b) two standalone bound to the same object : ACCEPTED object.actions=["dup_b/BOUND","dup_b/BOUND"] + * (c) standalone bound to X + embedded on X : ACCEPTED object.actions=["dup_c/EMB","dup_c/BOUND"] ← merge APPENDS + * (d) two embedded on one object : ACCEPTED object.actions=["dup_d/EMB","dup_d/EMB"] + * (z) object-less stack, two globals : ACCEPTED stack.actions=["global:dup_z","global:dup_z"] + * (x) cross-scope: global + bound to X : ACCEPTED stack.actions=["global:dup_x","probe_item:dup_x"] + * (y) cross-scope: global + embedded on X : ACCEPTED stack.actions=["global:dup_y"], object.actions=["dup_y/EMB"] + * ``` + * + * Rows (a)–(d) and (z) each yield ONE runtime key and are refused here. Rows + * (x) and (y) yield TWO keys and stay accepted by ruling: the precedence the + * runtime already implements for by-name readers (the object's own `actions` + * first — `resolveRouteActionDeclaration`) is documented on the collection, + * not changed. + * + * Every site counts — byte-identical twins included. `mergeActionsIntoObjects` + * APPENDS a bound standalone action into its object's `actions` on the way OUT + * of `defineStack`, so an identical pair in both positions becomes TWO embedded + * entries under one key in the shipped artifact: the runtime's + * `collectActionDeclarations` pushes every embedded entry (it dedupes only a + * standalone against an embedded one), MCP `listActions` lists both, and + * bare-name `resolveActionByName` refuses the ambiguity. A built stack fed + * back into `defineStack` therefore carries every bound action twice and is + * refused — the #7397 vacuity guard in `stack-inline-action-crossref.test.ts` + * feeds the merged SHAPE authored directly instead of a re-fed build. + * + * Message shape is contract (one condition ⇒ one wording), so the refusal + * cases pin the full line — the key, and where each declaration was written — + * rather than `toThrow()` alone: a bare throw cannot tell "refused for the + * right reason" from "refused because the fixture is broken", and every + * refusal fixture below differs from an accepted twin by exactly one name. + */ +import { describe, it, expect } from 'vitest'; +import { defineStack } from './stack.zod'; + +const manifest = { + id: 'com.example.dupkey', + name: 'duplicate-action-key-test', + version: '1.0.0', + type: 'app' as const, +}; + +// `as const` on the field type is load-bearing (see stack.test.ts): hoisted +// without it the literal widens to `string`, which the input type refuses. +const probeItem = { name: 'probe_item', label: 'Probe Item', fields: { title: { type: 'text' as const } } }; +const probeOther = { name: 'probe_other', label: 'Probe Other', fields: { title: { type: 'text' as const } } }; + +const act = (name: string, extra: Record = {}) => + ({ name, label: name, type: 'script' as const, target: 'noop', ...extra }); + +/** The thrown message, or `null` when the stack is accepted. */ +function refusal(config: Parameters[0]): string | null { + try { + defineStack(config); + return null; + } catch (e) { + return (e as Error).message; + } +} + +const ENVELOPE_ONE = 'defineStack cross-reference validation failed (1 issue):'; +const TAIL = + 'The runtime registers and dispatches every action under this one exact-string key, ' + + 'so only one of these handlers is reachable and the other declaration is a dead button. ' + + 'Rename one of them within this scope, bind one to a different object, or remove the duplicate.'; + +describe('defineStack - duplicate scope-qualified action key', () => { + it('(a) refuses two standalone globals sharing a name, naming the global key and both origins', () => { + const msg = refusal({ manifest, objects: [probeItem], actions: [act('dup_a'), act('dup_a')] }); + expect(msg).not.toBeNull(); + expect(msg).toContain(ENVELOPE_ONE); + expect(msg).toContain( + " ✗ Action key 'global:dup_a' is declared twice: " + + "stack.actions[0] (no objectName, so scope 'global') and " + + "stack.actions[1] (no objectName, so scope 'global'). " + + TAIL, + ); + }); + + it('(b) refuses two standalone actions bound to the same object', () => { + const msg = refusal({ + manifest, + objects: [probeItem], + actions: [act('dup_b', { objectName: 'probe_item' }), act('dup_b', { objectName: 'probe_item' })], + }); + expect(msg).toContain(ENVELOPE_ONE); + expect(msg).toContain( + " ✗ Action key 'probe_item:dup_b' is declared twice: " + + "stack.actions[0] (objectName 'probe_item') and stack.actions[1] (objectName 'probe_item'). " + + TAIL, + ); + }); + + it('(c) refuses a bound standalone beside a DIFFERING embedded twin on the same object — the merge appends, one key', () => { + const msg = refusal({ + manifest, + objects: [{ ...probeItem, actions: [act('dup_c')] }], + actions: [act('dup_c', { objectName: 'probe_item' })], + }); + expect(msg).toContain(ENVELOPE_ONE); + expect(msg).toContain( + " ✗ Action key 'probe_item:dup_c' is declared twice: " + + "stack.actions[0] (objectName 'probe_item') and " + + "objects['probe_item'].actions[0] (embedded on the object). " + + TAIL, + ); + }); + + it('(d) refuses two embedded twins on one object', () => { + const msg = refusal({ + manifest, + objects: [{ ...probeItem, actions: [act('dup_d'), act('dup_d')] }], + }); + expect(msg).toContain(ENVELOPE_ONE); + expect(msg).toContain( + " ✗ Action key 'probe_item:dup_d' is declared twice: " + + "objects['probe_item'].actions[0] (embedded on the object) and " + + "objects['probe_item'].actions[1] (embedded on the object). " + + TAIL, + ); + }); + + it('(z) refuses two globals in an object-less stack — the check does not need an object to resolve against', () => { + const msg = refusal({ manifest, actions: [act('dup_z'), act('dup_z')] }); + expect(msg).toContain(ENVELOPE_ONE); + expect(msg).toContain(" ✗ Action key 'global:dup_z' is declared twice: "); + }); + + it('counts three declarations under one key as one issue, listing every origin', () => { + const msg = refusal({ + manifest, + objects: [{ ...probeItem, actions: [act('dup_t')] }], + actions: [act('dup_t', { objectName: 'probe_item' }), act('dup_t', { objectName: 'probe_item' })], + }); + expect(msg).toContain(ENVELOPE_ONE); + expect(msg).toContain( + " ✗ Action key 'probe_item:dup_t' is declared 3 times: " + + "stack.actions[0] (objectName 'probe_item'), stack.actions[1] (objectName 'probe_item') and " + + "objects['probe_item'].actions[0] (embedded on the object). ", + ); + }); + + it('scopes an embedded action by the object it is written on, not by its own objectName', () => { + // An embedded action may name a DIFFERENT declared object (existence is + // all the walk checks there); the runtime still keys it by the owner. + const msg = refusal({ + manifest, + objects: [{ ...probeItem, actions: [act('dup_e', { objectName: 'probe_other' })] }, probeOther], + actions: [act('dup_e', { objectName: 'probe_item' })], + }); + expect(msg).toContain(" ✗ Action key 'probe_item:dup_e' is declared twice: "); + }); +}); + +describe('defineStack - the cross-scope pair stays accepted (two keys, documented precedence); every same-key site counts', () => { + it('(x) accepts one global and one object-bound action sharing a name, and emits both keys', () => { + const out = defineStack({ + manifest, + objects: [probeItem], + actions: [act('dup_x'), act('dup_x', { objectName: 'probe_item' })], + }); + expect((out.actions ?? []).map((a) => `${a.objectName ?? 'global'}:${a.name}`)).toEqual([ + 'global:dup_x', + 'probe_item:dup_x', + ]); + // The bound twin is what the object carries; the global one never merges in. + const item = (out.objects ?? []).find((o) => o.name === 'probe_item'); + expect((item?.actions ?? []).map((a) => `${a.name}/${a.objectName ? 'BOUND' : 'EMB'}`)).toEqual(['dup_x/BOUND']); + }); + + it('(y) accepts one global standalone beside an embedded twin on an object', () => { + const out = defineStack({ + manifest, + objects: [{ ...probeItem, actions: [act('dup_y')] }], + actions: [act('dup_y')], + }); + expect((out.actions ?? []).map((a) => `${a.objectName ?? 'global'}:${a.name}`)).toEqual(['global:dup_y']); + const item = (out.objects ?? []).find((o) => o.name === 'probe_item'); + expect((item?.actions ?? []).map((a) => a.name)).toEqual(['dup_y']); + }); + + it('refuses an identical hand-written embedded copy of a bound standalone — the identical case is a delete, not a rename', () => { + const bound = act('twin', { objectName: 'probe_item' }); + const identical = refusal({ + manifest, + objects: [{ ...probeItem, actions: [{ ...bound }] }], + actions: [bound], + }); + expect(identical).toContain(ENVELOPE_ONE); + expect(identical).toContain( + " ✗ Action key 'probe_item:twin' is declared twice: " + + "stack.actions[0] (objectName 'probe_item') and " + + "objects['probe_item'].actions[0] (embedded on the object). " + + TAIL, + ); + // One field apart (a different label) is refused the same way. + const differing = refusal({ + manifest, + objects: [{ ...probeItem, actions: [{ ...bound, label: 'Twin (embedded)' }] }], + actions: [bound], + }); + expect(differing).toContain(" ✗ Action key 'probe_item:twin' is declared twice: "); + }); + + it('counts an identical copy beside a differing twin as three declarations', () => { + const bound = act('mixed', { objectName: 'probe_item' }); + const msg = refusal({ + manifest, + objects: [{ ...probeItem, actions: [{ ...bound }, act('mixed')] }], + actions: [bound], + }); + expect(msg).toContain(ENVELOPE_ONE); + expect(msg).toContain( + " ✗ Action key 'probe_item:mixed' is declared 3 times: " + + "stack.actions[0] (objectName 'probe_item'), " + + "objects['probe_item'].actions[0] (embedded on the object) and " + + "objects['probe_item'].actions[1] (embedded on the object). ", + ); + }); + + it("refuses a built stack fed back in: the merge's echo of each bound action is two entries under one key", () => { + const built = defineStack({ + manifest, + objects: [probeItem], + actions: [act('echo_one', { objectName: 'probe_item' }), act('echo_two', { objectName: 'probe_item' })], + }); + const item = (built.objects ?? []).find((o) => o.name === 'probe_item'); + expect((item?.actions ?? []).map((a) => a.name)).toEqual(['echo_one', 'echo_two']); + const msg = refusal(built); + expect(msg).toContain('defineStack cross-reference validation failed (2 issues):'); + expect(msg).toContain( + " ✗ Action key 'probe_item:echo_one' is declared twice: " + + "stack.actions[0] (objectName 'probe_item') and " + + "objects['probe_item'].actions[0] (embedded on the object). ", + ); + expect(msg).toContain(" ✗ Action key 'probe_item:echo_two' is declared twice: "); + }); + + it('accepts the same name bound to two different objects — two keys', () => { + expect(refusal({ + manifest, + objects: [probeItem, probeOther], + actions: [act('dup_o', { objectName: 'probe_item' }), act('dup_o', { objectName: 'probe_other' })], + })).toBeNull(); + }); + + it('accepts distinct names in every position', () => { + expect(refusal({ + manifest, + objects: [{ ...probeItem, actions: [act('embedded_one')] }], + actions: [act('global_one'), act('bound_one', { objectName: 'probe_item' })], + })).toBeNull(); + }); +}); + +describe('defineStack - the duplicate-key check joins the existing walk', () => { + it('aggregates with the runAction-to-missing-action refusal in one envelope, and that refusal still fires', () => { + const msg = refusal({ + manifest, + objects: [probeItem], + actions: [act('dup_r'), act('dup_r')], + apps: [{ + name: 'probe_app', + label: 'Probe', + navigation: [{ id: 'nav_probe', type: 'object' as const, label: 'Probe', objectName: 'probe_item', runAction: 'ghost_action' }], + }], + }); + expect(msg).toContain('defineStack cross-reference validation failed (2 issues):'); + expect(msg).toContain(" ✗ Action key 'global:dup_r' is declared twice: "); + expect(msg).toContain( + " ✗ App 'probe_app' navigation deep-link references action 'ghost_action' (via runAction) " + + "which is not defined in actions (neither stack.actions nor any object's actions).", + ); + }); + + it('is skipped under `strict: false`, like every other cross-reference check', () => { + expect(() => defineStack( + { manifest, objects: [probeItem], actions: [act('dup_n'), act('dup_n')] }, + { strict: false }, + )).not.toThrow(); + }); +}); diff --git a/packages/spec/src/stack-inline-action-crossref.test.ts b/packages/spec/src/stack-inline-action-crossref.test.ts index 42a1ab2798..ed92e56676 100644 --- a/packages/spec/src/stack-inline-action-crossref.test.ts +++ b/packages/spec/src/stack-inline-action-crossref.test.ts @@ -507,23 +507,34 @@ describe('defineStack — object-embedded action cross-references: what the walk expect(refusals(embeddedStack(action, { flows }))).toEqual([]); }); - it('is vacuity-guarded: the ordinary merged shape a shipped stack produces still builds', () => { + it('is vacuity-guarded: the ordinary merged shape, authored directly on the object, still builds', () => { // `objects[].actions[]` is overwhelmingly WRITTEN by the merge rather than // by hand — a top-level action with `objectName` lands there on the way - // out of `defineStack`. Feeding that output back in must stay clean, or the - // corpus census in PR #7397 has gone stale. - const built = build({ + // out of `defineStack`, carrying its `objectName` with it. This feeds that + // exact shape AUTHORED DIRECTLY: embedded actions with valid targets and an + // `objectName` naming their owner, and no top-level twin. It must stay + // clean, or the corpus census in PR #7397 has gone stale. + // + // It deliberately no longer re-feeds `defineStack`'s own OUTPUT: the merge + // APPENDS, so a built stack carries each bound action in BOTH positions + // under one runtime key, and the duplicate-action-key refusal reads that + // as two declarations (stack-duplicate-action-key.test.ts pins the + // refusal). What #7397 guards is that the embedded walk's target checks + // refuse nothing on the merged shape — and that shape is what is fed here. + const authored = { manifest: baseManifest, - objects, + objects: [{ + ...objects[0], + actions: [ + { ...modalAction('probe_home'), objectName: 'probe_task' }, + { ...flowAction('probe_flow'), objectName: 'probe_task' }, + ], + }], pages, flows, - actions: [ - { ...modalAction('probe_home'), objectName: 'probe_task' }, - { ...flowAction('probe_flow'), objectName: 'probe_task' }, - ], - }); + }; - expect(built.objects?.[0]?.actions?.map((a) => a.name)).toEqual(['probe_new_task', 'probe_run']); - expect(refusals(built)).toEqual([]); + expect(refusals(authored)).toEqual([]); + expect(build(authored).objects?.[0]?.actions?.map((a) => a.name)).toEqual(['probe_new_task', 'probe_run']); }); }); diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 9855490cf1..493e930707 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -365,7 +365,34 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { dashboards: z.array(DashboardSchema).optional().describe('Dashboards'), reports: z.array(ReportSchema).optional().describe('Analytics Reports'), datasets: z.array(DatasetSchema).optional().describe('Analytics semantic-layer datasets (ADR-0021)'), - actions: z.array(ActionSchema).optional().describe('Global and Object Actions'), + /** + * Registered actions — global (no `objectName`) and object-bound (`objectName` + * set; `defineStack` merges those into the named object's `actions`). + * + * Uniqueness is per SCOPE, not per stack. The runtime registers and dispatches + * every action under one exact-string key — the owning object's name (or + * `'global'` for an object-less action), a colon, then the action name — with + * no wildcard semantics, so `defineStack` refuses two declarations that + * resolve to the same key: both written here, both on one object's `actions`, + * or one in each position, byte-identical twins included (that case is a + * delete, not a rename). An embedded action is keyed by the object it is + * written ON — the declaration-resolution key `collectActionDeclarations` / + * `resolveRouteActionDeclaration` use — not by its own `objectName` (the + * registration key `collectBundleActions` / `actionObjectKey` read). One + * global and one object-bound action MAY share a `name`: they occupy two + * keys, and a by-name reader on the object's route resolves the object's own + * `actions` (embedded, or merged in from here) before a standalone global + * declaration. + */ + actions: z.array(ActionSchema).optional().describe( + 'Global and Object Actions. Unique per scope, not per stack: the runtime keys every action by ' + + "its owning object's name (or 'global' when object-less), a colon, then the action name, and " + + 'defineStack refuses two declarations that resolve to one key — both here, both on one ' + + "object's actions, or one in each position, identical twins included (an embedded action is keyed " + + 'by the object it is written on, not by its own objectName). ' + + "One global and one object-bound action may share " + + "a name; on that object's route the object's own actions take precedence for by-name readers.", + ), // `themes` was REMOVED in 17.1 (#10485, ADR-0049 enforce-or-remove — ruled // 退役授权面, 2026-08-21). The pipeline was live from authoring gate through // artifact ingest and stopped there: no framework package ever read the @@ -1499,6 +1526,106 @@ function collectInlinePageActions(page: unknown): InlineActionSite[] { return sites; } +/** + * Scope segment of the runtime's action key for an object-less action. + * + * Must stay in lockstep with `GLOBAL_ACTION_OBJECT_KEY` in + * `@objectstack/objectql` (`action-governance.ts`) — the literal `executeAction` + * registers and looks up an object-less action under. Spelled here rather than + * imported because the spec sits below every runtime package. + */ +const GLOBAL_ACTION_SCOPE = 'global'; + +/** + * Join origins for a refusal line: `a`, `a and b`, `a, b and c`. + */ +function joinDeclarationOrigins(origins: readonly string[]): string { + if (origins.length <= 1) return origins.join(''); + return `${origins.slice(0, -1).join(', ')} and ${origins[origins.length - 1]}`; +} + +/** + * Same-scope duplicate action keys — one refusal line per colliding key. + * + * The runtime registers and dispatches every action under one exact-string + * key, `:`, where the scope is the owning object's name or + * {@link GLOBAL_ACTION_SCOPE} for an object-less action (`packages/objectql`'s + * `executeAction`; the runtime's `collectActionDeclarations` and + * `standaloneActionObjectName` build the same key), with no wildcard + * semantics. Two declarations that resolve to one key collapse to one handler + * registration: whichever registers second wins (`registerAction` is a plain + * `Map.set`), and the other stays a live, declared, permission-gated button + * whose handler is unreachable — nothing at author, build or boot time says + * so, and the loser fails only when a user clicks it (the ADR-0078 shape + * arriving at collection level). Measured on main before this check: two + * standalone globals sharing a name, two standalone actions bound to the same + * object, a bound standalone beside an embedded twin on the same object + * (`mergeActionsIntoObjects` APPENDS, so both survived into `object.actions`) + * and two embedded twins all built clean. + * + * Scope resolution follows the runtime's DECLARATION-RESOLUTION key: a + * standalone action is scoped by its `objectName` (post-parse — the legacy + * `object` alias is already canonicalized) or global; an embedded action is + * scoped by the object it is written on, whatever its own `objectName` says — + * that is how `collectActionDeclarations` and `resolveRouteActionDeclaration` + * key it. (The runtime's REGISTRATION key — `collectBundleActions` / + * `actionObjectKey` — reads the action's own `objectName` instead; the walk + * deliberately follows the resolution side, where the by-name collision the + * card describes happens.) + * + * Every site counts, byte-identical twins included: the merge appends, so a + * pair that is one declaration to the eye is two entries under one key in the + * shipped artifact — `collectActionDeclarations` pushes every embedded entry, + * MCP `listActions` lists both, and bare-name `resolveActionByName` refuses + * the ambiguity. The identical case is therefore a delete, not a rename, and + * the message says so. A consequence worth knowing: a stack BUILT by + * `defineStack` carries each bound action in both positions, so feeding a + * built stack back in is refused too — author the source shape, not the + * artifact. + * + * Deliberately NOT refused: the cross-scope pair — one global and one + * object-bound declaration sharing a `name`. They occupy two distinct keys, and + * the precedence the runtime already implements for by-name readers + * (`resolveRouteActionDeclaration` reads the object's own `actions` before a + * standalone declaration) is documented on the `actions` collection, not + * changed here. + */ +function collectDuplicateActionKeyErrors(config: ObjectStackDefinition): string[] { + const originsByKey = new Map(); + const note = (scope: string, name: string, origin: string): void => { + const key = `${scope}:${name}`; + const list = originsByKey.get(key) ?? []; + list.push(origin); + originsByKey.set(key, list); + }; + + for (const [i, action] of (config.actions ?? []).entries()) { + if (action.objectName) { + note(action.objectName, action.name, `stack.actions[${i}] (objectName '${action.objectName}')`); + } else { + note(GLOBAL_ACTION_SCOPE, action.name, `stack.actions[${i}] (no objectName, so scope '${GLOBAL_ACTION_SCOPE}')`); + } + } + for (const obj of config.objects ?? []) { + for (const [j, action] of (obj.actions ?? []).entries()) { + note(obj.name, action.name, `objects['${obj.name}'].actions[${j}] (embedded on the object)`); + } + } + + const errors: string[] = []; + for (const [key, origins] of originsByKey) { + if (origins.length < 2) continue; + errors.push( + `Action key '${key}' is declared ${origins.length === 2 ? 'twice' : `${origins.length} times`}: ` + + `${joinDeclarationOrigins(origins)}. The runtime registers and dispatches every action under ` + + `this one exact-string key, so only one of these handlers is reachable and the other ` + + `declaration is a dead button. Rename one of them within this scope, bind one to a ` + + `different object, or remove the duplicate.`, + ); + } + return errors; +} + /** * Perform strict cross-reference validation on a parsed stack definition. * Returns an array of error messages (empty if valid). @@ -1507,6 +1634,11 @@ function validateCrossReferences(config: ObjectStackDefinition): string[] { const errors: string[] = []; const objectNames = collectObjectNames(config); + // Runs BEFORE the object-count early return on purpose: an object-less + // stack that declares two globals under one name collides on exactly the + // same runtime key, and this check needs no object to resolve against. + errors.push(...collectDuplicateActionKeyErrors(config)); + if (objectNames.size === 0) return errors; // Validate hook → object references