From 14c8e869a47e4a5fe536eea6eda3dc56e63a82bc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:35:26 +0000 Subject: [PATCH 1/4] feat(spec): defineStack refuses two actions sharing one scope-qualified key Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- ...fine-stack-duplicate-action-key-refusal.md | 13 + .../src/stack-duplicate-action-key.test.ts | 229 ++++++++++++++++++ packages/spec/src/stack.zod.ts | 112 ++++++++- 3 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 .changeset/define-stack-duplicate-action-key-refusal.md create mode 100644 packages/spec/src/stack-duplicate-action-key.test.ts 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..21c6a7e5f6 --- /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. The fix is the one the message names: rename one of the two within that scope, or bind one to a different object. + +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..8eaf44311f --- /dev/null +++ b/packages/spec/src/stack-duplicate-action-key.test.ts @@ -0,0 +1,229 @@ +/** + * `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. + * + * 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, or bind one to a different object.'; + +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 an 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)', () => { + 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('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: [{ type: 'object' as const, 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.zod.ts b/packages/spec/src/stack.zod.ts index 9855490cf1..e45ac12b11 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -365,7 +365,27 @@ 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. 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. 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 +1519,91 @@ 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, 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 mirrors the runtime exactly: 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 (the runtime keys embedded + * declarations by the owning object). + * + * 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, or bind one to a ` + + `different object.`, + ); + } + return errors; +} + /** * Perform strict cross-reference validation on a parsed stack definition. * Returns an array of error messages (empty if valid). @@ -1507,6 +1612,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 From 9cd479c42957e8bf23a6efd130bae6b53f7c85d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 17:37:15 +0000 Subject: [PATCH 2/4] test(spec): nav fixture carries the required id/label Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- packages/spec/src/stack-duplicate-action-key.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/stack-duplicate-action-key.test.ts b/packages/spec/src/stack-duplicate-action-key.test.ts index 8eaf44311f..541e54ab05 100644 --- a/packages/spec/src/stack-duplicate-action-key.test.ts +++ b/packages/spec/src/stack-duplicate-action-key.test.ts @@ -209,7 +209,7 @@ describe('defineStack - the duplicate-key check joins the existing walk', () => apps: [{ name: 'probe_app', label: 'Probe', - navigation: [{ type: 'object' as const, objectName: 'probe_item', runAction: 'ghost_action' }], + 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):'); From d56c22ce22f041985b04a9b230ba8115e226b9a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:07:16 +0000 Subject: [PATCH 3/4] feat(spec): count the merge's echo of a bound action once; pin re-entry and the differing-twin refusal Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- ...fine-stack-duplicate-action-key-refusal.md | 2 +- .../src/stack-duplicate-action-key.test.ts | 53 ++++++++- packages/spec/src/stack.zod.ts | 110 ++++++++++++++---- 3 files changed, 140 insertions(+), 25 deletions(-) diff --git a/.changeset/define-stack-duplicate-action-key-refusal.md b/.changeset/define-stack-duplicate-action-key-refusal.md index 21c6a7e5f6..e13b9bc8af 100644 --- a/.changeset/define-stack-duplicate-action-key-refusal.md +++ b/.changeset/define-stack-duplicate-action-key-refusal.md @@ -6,7 +6,7 @@ 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. The fix is the one the message names: rename one of the two within that scope, or bind one to a different 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. A built stack still re-enters `defineStack` clean: the copy of a bound action that `defineStack` itself writes into the object's `actions` is that same declaration, counted once — only an embedded twin that differs in some field is a second declaration. The fix is the one the message names: rename one of the two within that scope, or bind one to a different object. 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 index 541e54ab05..5d811be1c5 100644 --- a/packages/spec/src/stack-duplicate-action-key.test.ts +++ b/packages/spec/src/stack-duplicate-action-key.test.ts @@ -28,6 +28,15 @@ * first — `resolveRouteActionDeclaration`) is documented on the collection, * not changed. * + * One carve-out inside row (c), measured against the #7397 vacuity guard in + * `stack-inline-action-crossref.test.ts`: `mergeActionsIntoObjects` copies a + * bound standalone action into its object's `actions` on the way OUT of + * `defineStack`, so a built stack fed back in carries that action in both + * positions, byte-identical. That echo is one declaration seen twice — the + * runtime, too, skips a standalone whose object-embedded key is already + * registered — and it is absorbed; an embedded twin that differs in any field + * is the authored collision and stays refused. + * * 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 @@ -95,7 +104,7 @@ describe('defineStack - duplicate scope-qualified action key', () => { ); }); - it('(c) refuses a bound standalone beside an embedded twin on the same object — the merge appends, one key', () => { + 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')] }], @@ -183,6 +192,48 @@ describe('defineStack - the cross-scope pair stays accepted (two keys, documente expect((item?.actions ?? []).map((a) => a.name)).toEqual(['dup_y']); }); + it("absorbs the merge's echo: a built stack (bound action copied into its object) re-enters defineStack clean", () => { + 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']); + expect(refusal(built)).toBeNull(); + }); + + it('absorbs a hand-written embedded copy only when it is identical to the bound standalone', () => { + const bound = act('twin', { objectName: 'probe_item' }); + expect(refusal({ + manifest, + objects: [{ ...probeItem, actions: [{ ...bound }] }], + actions: [bound], + })).toBeNull(); + // One field apart (a different label) and it is two declarations again. + const msg = refusal({ + manifest, + objects: [{ ...probeItem, actions: [{ ...bound, label: 'Twin (embedded)' }] }], + actions: [bound], + }); + expect(msg).toContain(" ✗ Action key 'probe_item:twin' is declared twice: "); + }); + + it('names only the distinct declarations when an echo sits beside a real collision', () => { + 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 twice: " + + "stack.actions[0] (objectName 'probe_item') and " + + "objects['probe_item'].actions[1] (embedded on the object). ", + ); + }); + it('accepts the same name bound to two different objects — two keys', () => { expect(refusal({ manifest, diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index e45ac12b11..4ff2be6088 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -374,16 +374,19 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { * `'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. 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. + * or one in each position (an embedded copy identical to the bound action it + * was merged from is that same declaration, counted once — a built stack + * re-enters `defineStack` clean). 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. One global and one object-bound action may share " + + "object's actions, or one in each position (an embedded copy identical to the bound action it " + + "was merged from is that declaration counted once). 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 @@ -1537,6 +1540,35 @@ function joinDeclarationOrigins(origins: readonly string[]): string { return `${origins.slice(0, -1).join(', ')} and ${origins[origins.length - 1]}`; } +/** + * Structural equality over parsed metadata — primitives, arrays and plain + * objects (key order ignored, `undefined`-valued keys ignored); anything else + * by reference. `ActionSchema` is plain data, so this is exactly "the same + * declaration" for an action. + */ +function structurallyEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; + if (Array.isArray(a) !== Array.isArray(b)) return false; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => structurallyEqual(item, b[i])); + } + const left = a as Record; + const right = b as Record; + const keys = Object.keys(left).filter((k) => left[k] !== undefined); + const otherKeys = Object.keys(right).filter((k) => right[k] !== undefined); + if (keys.length !== otherKeys.length) return false; + return keys.every((k) => k in right && structurallyEqual(left[k], right[k])); +} + +/** One action declaration the duplicate-key walk saw, and where it was written. */ +interface ActionDeclarationSite { + origin: string; + action: unknown; + /** Written in `stack.actions` (true) or on an object's own `actions` (false). */ + standalone: boolean; +} + /** * Same-scope duplicate action keys — one refusal line per colliding key. * @@ -1546,14 +1578,15 @@ function joinDeclarationOrigins(origins: readonly string[]): string { * `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, 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. + * 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 mirrors the runtime exactly: a standalone action is scoped * by its `objectName` (post-parse — the legacy `object` alias is already @@ -1561,6 +1594,16 @@ function joinDeclarationOrigins(origins: readonly string[]): string { * written on, whatever its own `objectName` says (the runtime keys embedded * declarations by the owning object). * + * One declaration, two positions — counted ONCE: `mergeActionsIntoObjects` + * copies every bound standalone action into its object's `actions` on the way + * out of `defineStack`, so a built stack fed back in carries each bound + * action in both positions, byte-identical. That echo is not a second + * declaration (the runtime, too, skips a standalone whose object-embedded key + * is already registered), and `defineStack`'s output must stay valid + * `defineStack` input. So an embedded declaration structurally identical to a + * standalone one bound to the same object is absorbed into it; an embedded + * twin that differs in ANY field is the authored collision and is refused. + * * 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 @@ -1569,30 +1612,51 @@ function joinDeclarationOrigins(origins: readonly string[]): string { * changed here. */ function collectDuplicateActionKeyErrors(config: ObjectStackDefinition): string[] { - const originsByKey = new Map(); - const note = (scope: string, name: string, origin: string): void => { + const sitesByKey = new Map(); + const note = (scope: string, name: string, site: ActionDeclarationSite): void => { const key = `${scope}:${name}`; - const list = originsByKey.get(key) ?? []; - list.push(origin); - originsByKey.set(key, list); + const list = sitesByKey.get(key) ?? []; + list.push(site); + sitesByKey.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}')`); + note(action.objectName, action.name, { + origin: `stack.actions[${i}] (objectName '${action.objectName}')`, + action, + standalone: true, + }); } else { - note(GLOBAL_ACTION_SCOPE, action.name, `stack.actions[${i}] (no objectName, so scope '${GLOBAL_ACTION_SCOPE}')`); + note(GLOBAL_ACTION_SCOPE, action.name, { + origin: `stack.actions[${i}] (no objectName, so scope '${GLOBAL_ACTION_SCOPE}')`, + action, + standalone: true, + }); } } 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)`); + note(obj.name, action.name, { + origin: `objects['${obj.name}'].actions[${j}] (embedded on the object)`, + action, + standalone: false, + }); } } const errors: string[] = []; - for (const [key, origins] of originsByKey) { - if (origins.length < 2) continue; + for (const [key, sites] of sitesByKey) { + if (sites.length < 2) continue; + // Drop the merge's echoes: an embedded site identical to a standalone site + // under the same key is that standalone declaration, seen again. + const distinct = sites.filter( + (site) => + site.standalone || + !sites.some((other) => other.standalone && structurallyEqual(other.action, site.action)), + ); + if (distinct.length < 2) continue; + const origins = distinct.map((site) => site.origin); errors.push( `Action key '${key}' is declared ${origins.length === 2 ? 'twice' : `${origins.length} times`}: ` + `${joinDeclarationOrigins(origins)}. The runtime registers and dispatches every action under ` + From aa937c775121bb92aaa90ebd9a31c5005ba23b3a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 18:55:58 +0000 Subject: [PATCH 4/4] feat(spec): count every same-key action site, identical twins included; the vacuity pin feeds the merged shape authored directly Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_017RbbUMnxkUnWhE4j94v8FE --- ...fine-stack-duplicate-action-key-refusal.md | 2 +- .../src/stack-duplicate-action-key.test.ts | 77 +++++++---- .../src/stack-inline-action-crossref.test.ts | 35 +++-- packages/spec/src/stack.zod.ts | 128 ++++++------------ 4 files changed, 114 insertions(+), 128 deletions(-) diff --git a/.changeset/define-stack-duplicate-action-key-refusal.md b/.changeset/define-stack-duplicate-action-key-refusal.md index e13b9bc8af..15510bbdb3 100644 --- a/.changeset/define-stack-duplicate-action-key-refusal.md +++ b/.changeset/define-stack-duplicate-action-key-refusal.md @@ -6,7 +6,7 @@ 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. A built stack still re-enters `defineStack` clean: the copy of a bound action that `defineStack` itself writes into the object's `actions` is that same declaration, counted once — only an embedded twin that differs in some field is a second declaration. The fix is the one the message names: rename one of the two within that scope, or bind one to a different 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 index 5d811be1c5..090bc18062 100644 --- a/packages/spec/src/stack-duplicate-action-key.test.ts +++ b/packages/spec/src/stack-duplicate-action-key.test.ts @@ -28,14 +28,16 @@ * first — `resolveRouteActionDeclaration`) is documented on the collection, * not changed. * - * One carve-out inside row (c), measured against the #7397 vacuity guard in - * `stack-inline-action-crossref.test.ts`: `mergeActionsIntoObjects` copies a - * bound standalone action into its object's `actions` on the way OUT of - * `defineStack`, so a built stack fed back in carries that action in both - * positions, byte-identical. That echo is one declaration seen twice — the - * runtime, too, skips a standalone whose object-embedded key is already - * registered — and it is absorbed; an embedded twin that differs in any field - * is the authored collision and stays refused. + * 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 — @@ -75,7 +77,7 @@ 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, or bind one to a different object.'; + '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', () => { @@ -165,7 +167,7 @@ describe('defineStack - duplicate scope-qualified action key', () => { }); }); -describe('defineStack - the cross-scope pair stays accepted (two keys, documented precedence)', () => { +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, @@ -192,34 +194,30 @@ describe('defineStack - the cross-scope pair stays accepted (two keys, documente expect((item?.actions ?? []).map((a) => a.name)).toEqual(['dup_y']); }); - it("absorbs the merge's echo: a built stack (bound action copied into its object) re-enters defineStack clean", () => { - 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']); - expect(refusal(built)).toBeNull(); - }); - - it('absorbs a hand-written embedded copy only when it is identical to the bound standalone', () => { + 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' }); - expect(refusal({ + const identical = refusal({ manifest, objects: [{ ...probeItem, actions: [{ ...bound }] }], actions: [bound], - })).toBeNull(); - // One field apart (a different label) and it is two declarations again. - const msg = refusal({ + }); + 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(msg).toContain(" ✗ Action key 'probe_item:twin' is declared twice: "); + expect(differing).toContain(" ✗ Action key 'probe_item:twin' is declared twice: "); }); - it('names only the distinct declarations when an echo sits beside a real collision', () => { + it('counts an identical copy beside a differing twin as three declarations', () => { const bound = act('mixed', { objectName: 'probe_item' }); const msg = refusal({ manifest, @@ -228,12 +226,31 @@ describe('defineStack - the cross-scope pair stays accepted (two keys, documente }); expect(msg).toContain(ENVELOPE_ONE); expect(msg).toContain( - " ✗ Action key 'probe_item:mixed' is declared twice: " + - "stack.actions[0] (objectName 'probe_item') and " + + " ✗ 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, 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 4ff2be6088..493e930707 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -374,19 +374,23 @@ const STACK_DEFINITION_COLLECTIONS_SHAPE = { * `'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 (an embedded copy identical to the bound action it - * was merged from is that same declaration, counted once — a built stack - * re-enters `defineStack` clean). 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. + * 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 (an embedded copy identical to the bound action it " - + "was merged from is that declaration counted once). One global and one object-bound action may share " + + "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 @@ -1540,35 +1544,6 @@ function joinDeclarationOrigins(origins: readonly string[]): string { return `${origins.slice(0, -1).join(', ')} and ${origins[origins.length - 1]}`; } -/** - * Structural equality over parsed metadata — primitives, arrays and plain - * objects (key order ignored, `undefined`-valued keys ignored); anything else - * by reference. `ActionSchema` is plain data, so this is exactly "the same - * declaration" for an action. - */ -function structurallyEqual(a: unknown, b: unknown): boolean { - if (Object.is(a, b)) return true; - if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false; - if (Array.isArray(a) !== Array.isArray(b)) return false; - if (Array.isArray(a) && Array.isArray(b)) { - return a.length === b.length && a.every((item, i) => structurallyEqual(item, b[i])); - } - const left = a as Record; - const right = b as Record; - const keys = Object.keys(left).filter((k) => left[k] !== undefined); - const otherKeys = Object.keys(right).filter((k) => right[k] !== undefined); - if (keys.length !== otherKeys.length) return false; - return keys.every((k) => k in right && structurallyEqual(left[k], right[k])); -} - -/** One action declaration the duplicate-key walk saw, and where it was written. */ -interface ActionDeclarationSite { - origin: string; - action: unknown; - /** Written in `stack.actions` (true) or on an object's own `actions` (false). */ - standalone: boolean; -} - /** * Same-scope duplicate action keys — one refusal line per colliding key. * @@ -1588,21 +1563,25 @@ interface ActionDeclarationSite { * (`mergeActionsIntoObjects` APPENDS, so both survived into `object.actions`) * and two embedded twins all built clean. * - * Scope resolution mirrors the runtime exactly: 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 (the runtime keys embedded - * declarations by the owning object). - * - * One declaration, two positions — counted ONCE: `mergeActionsIntoObjects` - * copies every bound standalone action into its object's `actions` on the way - * out of `defineStack`, so a built stack fed back in carries each bound - * action in both positions, byte-identical. That echo is not a second - * declaration (the runtime, too, skips a standalone whose object-embedded key - * is already registered), and `defineStack`'s output must stay valid - * `defineStack` input. So an embedded declaration structurally identical to a - * standalone one bound to the same object is absorbed into it; an embedded - * twin that differs in ANY field is the authored collision and is refused. + * 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 @@ -1612,57 +1591,36 @@ interface ActionDeclarationSite { * changed here. */ function collectDuplicateActionKeyErrors(config: ObjectStackDefinition): string[] { - const sitesByKey = new Map(); - const note = (scope: string, name: string, site: ActionDeclarationSite): void => { + const originsByKey = new Map(); + const note = (scope: string, name: string, origin: string): void => { const key = `${scope}:${name}`; - const list = sitesByKey.get(key) ?? []; - list.push(site); - sitesByKey.set(key, list); + 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, { - origin: `stack.actions[${i}] (objectName '${action.objectName}')`, - action, - standalone: true, - }); + note(action.objectName, action.name, `stack.actions[${i}] (objectName '${action.objectName}')`); } else { - note(GLOBAL_ACTION_SCOPE, action.name, { - origin: `stack.actions[${i}] (no objectName, so scope '${GLOBAL_ACTION_SCOPE}')`, - action, - standalone: true, - }); + 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, { - origin: `objects['${obj.name}'].actions[${j}] (embedded on the object)`, - action, - standalone: false, - }); + note(obj.name, action.name, `objects['${obj.name}'].actions[${j}] (embedded on the object)`); } } const errors: string[] = []; - for (const [key, sites] of sitesByKey) { - if (sites.length < 2) continue; - // Drop the merge's echoes: an embedded site identical to a standalone site - // under the same key is that standalone declaration, seen again. - const distinct = sites.filter( - (site) => - site.standalone || - !sites.some((other) => other.standalone && structurallyEqual(other.action, site.action)), - ); - if (distinct.length < 2) continue; - const origins = distinct.map((site) => site.origin); + 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, or bind one to a ` + - `different object.`, + `declaration is a dead button. Rename one of them within this scope, bind one to a ` + + `different object, or remove the duplicate.`, ); } return errors;