Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/define-stack-duplicate-action-key-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message names the fix (rename one of the colliding declarations within its scope, or rebind it), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
297 changes: 297 additions & 0 deletions packages/spec/src/stack-duplicate-action-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -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, `<scope>:<name>` (`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<string, unknown> = {}) =>
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });

/** The thrown message, or `null` when the stack is accepted. */
function refusal(config: Parameters<typeof defineStack>[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();
});
});
35 changes: 23 additions & 12 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/define-stack-duplicate-action-key-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message names the fix (rename one of the colliding declarations within its scope, or rebind it), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
297 changes: 297 additions & 0 deletions packages/spec/src/stack-duplicate-action-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -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, `<scope>:<name>` (`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<string, unknown> = {}) =>
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });

/** The thrown message, or `null` when the stack is accepted. */
function refusal(config: Parameters<typeof defineStack>[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();
});
});
35 changes: 23 additions & 12 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/define-stack-duplicate-action-key-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message names the fix (rename one of the colliding declarations within its scope, or rebind it), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
297 changes: 297 additions & 0 deletions packages/spec/src/stack-duplicate-action-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -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, `<scope>:<name>` (`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<string, unknown> = {}) =>
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });

/** The thrown message, or `null` when the stack is accepted. */
function refusal(config: Parameters<typeof defineStack>[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();
});
});
35 changes: 23 additions & 12 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/define-stack-duplicate-action-key-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message names the fix (rename one of the colliding declarations within its scope, or rebind it), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
297 changes: 297 additions & 0 deletions packages/spec/src/stack-duplicate-action-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -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, `<scope>:<name>` (`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<string, unknown> = {}) =>
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });

/** The thrown message, or `null` when the stack is accepted. */
function refusal(config: Parameters<typeof defineStack>[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();
});
});
35 changes: 23 additions & 12 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/define-stack-duplicate-action-key-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message names the fix (rename one of the colliding declarations within its scope, or rebind it), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
297 changes: 297 additions & 0 deletions packages/spec/src/stack-duplicate-action-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -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, `<scope>:<name>` (`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<string, unknown> = {}) =>
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });

/** The thrown message, or `null` when the stack is accepted. */
function refusal(config: Parameters<typeof defineStack>[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();
});
});
35 changes: 23 additions & 12 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/define-stack-duplicate-action-key-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message names the fix (rename one of the colliding declarations within its scope, or rebind it), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
297 changes: 297 additions & 0 deletions packages/spec/src/stack-duplicate-action-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -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, `<scope>:<name>` (`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<string, unknown> = {}) =>
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });

/** The thrown message, or `null` when the stack is accepted. */
function refusal(config: Parameters<typeof defineStack>[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();
});
});
35 changes: 23 additions & 12 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/define-stack-duplicate-action-key-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message names the fix (rename one of the colliding declarations within its scope, or rebind it), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
297 changes: 297 additions & 0 deletions packages/spec/src/stack-duplicate-action-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -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, `<scope>:<name>` (`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<string, unknown> = {}) =>
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });

/** The thrown message, or `null` when the stack is accepted. */
function refusal(config: Parameters<typeof defineStack>[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();
});
});
35 changes: 23 additions & 12 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/define-stack-duplicate-action-key-refusal.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: not-required (no-migration-prescription) the refusal message names the fix (rename one of the colliding declarations within its scope, or rebind it), and nothing is renamed or removed — no authorable key changes spelling and no export moves, so the ledger has no rewrite to carry. -->
297 changes: 297 additions & 0 deletions packages/spec/src/stack-duplicate-action-key.test.ts
Original file line numberDiff line numberDiff line change
@@ -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, `<scope>:<name>` (`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<string, unknown> = {}) =>
({ name, label: name, type: 'script' as const, target: 'noop', ...extra });

/** The thrown message, or `null` when the stack is accepted. */
function refusal(config: Parameters<typeof defineStack>[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();
});
});
35 changes: 23 additions & 12 deletions packages/spec/src/stack-inline-action-crossref.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);
});
});
Loading
Loading