Skip to content

Commit cdd9f0a

Browse files
fix(spec)!: refuse an object that declares one action name twice; Studio renames to a free name (#26874)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwB9P9QpYQKquVrFuRFUPNpZf)_ Fixes #26725 Clause-②: no (narrowing) Metadata-protocol: narrowed Skills-docs: none — the refusal itself names the action, every index and the fix at every door that parses an object, and no `skills/**` file teaches a shape that repeats an action name (`skills/objectstack-ui/rules/actions.md` says nothing about name uniqueness either way) ## 维护者速读 - 改了什么:对象的 `actions` 数组里同名的两个动作,现在在对象 schema 解析时就被拒绝(保存门返回 422 `INVALID_METADATA`,指出重名和每个下标);Studio 动作面板里把动作改名成已占用的名字时,自动写成第一个空闲的 `NAME_N`。 - 为什么改:动作名是动作在对象上的运行键,之前保存门和发布门都接受同名的两个动作(实测保存 200、发布 200、两个都被服务),到底跑哪个是未定义的;Studio 的名称输入框会直接写出这种重名。 - 风险与回滚:这是一次收窄(BREAKING),已存的重名对象仍能加载,但带 `[metadata_spec_invalid]` 诊断,下次保存被拒;`os migrate meta --stored` 会按对象列出它们。回滚即 revert 本 PR。 - 先看:`packages/spec/src/data/duplicate-action-names.ts`(唯一判定与文案)、`packages/spec/src/data/object.zod.ts` 的 `refuseDuplicateActionNames`、`ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.tsx` 的 `patchSelected` 改名分支。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run src/data/object-duplicate-action-name.test.ts`,预期 7 个用例全绿,其中重名用例断言路径 `actions.1` 与首句 "Object 'probe_item' declares the action 'dup' twice in actions: actions[0] and actions[1]." - 需要你做的:无,席位落地。 ## What changes - **Spec (`@objectstack/spec`, minor, BREAKING).** `ObjectSchema`'s existing `superRefine` gains `refuseDuplicateActionNames`: one `custom` issue per repeated action name, at `actions[j]` of its second declaration, naming the name and every index (`Object 'X' declares the action 'Y' twice in actions: actions[i] and actions[j]. …`). The predicate and message live in one module, `data/duplicate-action-names.ts`, read by the refinement and by the v18 stored-row listing. No new check node, so the JSON Schema projection is unchanged (`dropped-refinements` baseline untouched; spec build green). - **One judge per pair.** `defineStack`'s duplicate action-key check already refused two embedded twins on one object; on the strict path the object parse now refuses them first (`STACK_SCHEMA_INVALID`), so the cross-reference walk keeps the pairs that span two collections (bound standalone + embedded, two standalones). Its docblock says so; row (d) of `stack-duplicate-action-key.test.ts` and the "identical copy beside a differing twin" case now assert the parse envelope (`code`, `status`, first sentence). - **ADR-0087: registered.** New D3 semantic entry `object-duplicate-action-name-refused` (step 18) and a `REFUSED_STORED_SHAPES` detector, so `os migrate meta --stored` lists each stored object that repeats a name, under its row, at `objects[0].actions[j]`. Nothing rewrites it: which declaration keeps the name only the owner knows. The listing's `from` carries only the name and the indexes (an action body can carry request headers). - **Studio (`@object-ui/app-shell`, patch).** The actions panel's create path already derived a free name (`nextActionName`). The duplicate came from the editor's Name field, which wrote whatever was typed, so the QA run's three `call_api` entries were renames. `patchSelected` now writes a rename to a name another listed action holds as the first free `NAME_N`, and selects it. - **Out of scope, by design.** A standalone `action` item bound to the object by `objectName` is a second item: one stack's pair stays `defineStack`'s duplicate-key refusal, and two packages' pair stays the runtime's bound-action collision report, which its own card ruled report-first (warn, not refuse). The object schema sees only its own array. ## Tests - `packages/spec/src/data/object-duplicate-action-name.test.ts` (new): pair, three-way, one issue per name, byte-identical twin, distinct-name control, stored-body reads. - `packages/spec/src/conversions/refused-shapes.test.ts`: the new id in the registered list; a stored row with a pair is listed once at `objects[0].actions[1]`, not rewritten, and still fails the parse at the same site; distinct names are not listed. - `packages/spec/src/stack-duplicate-action-key.test.ts`: rows above move to the parse envelope. - `packages/metadata-protocol/src/protocol.object-duplicate-action-name-refusal.test.ts` (new): `saveMetaItem` answers `INVALID_METADATA` / 422 with one issue at `actions.1`, stores nothing; distinct-name control saves. `scripts/engine-double-contract.pinned.json` learned its doubles (`--write`). - `ui/packages/app-shell/src/views/studio-design/ObjectActionsPanel.renameTaken-26725.test.tsx` (new): rename to a taken name writes `call_api_2`, skips a taken suffix (`call_api_3`), free-name control writes as typed; each written draft parses through `ObjectSchema`. ## Acceptance notes - Fixtures, examples and seeds: a JSON scan of the tracked tree found no object that repeats an action name (1292 files, 0 hits), and every `examples/**` stack already went through `defineStack`'s strict pass, which refused embedded twins before this change. The full spec, metadata-protocol and dogfood suites stayed green, so no TS fixture repeated one either. - The local app-shell run was narrowed to the 169 test files that load the actions panel, the Studio design surface or the Data pillar (the 1,765-file package suite did not finish in 60 minutes on the shared box); CI's `Test UI` runs the rest. ## Verification All readings on head `76f9014ff` (base `main` `c8583f80a`), on a shared container; the lock's VERDICT lines are quoted. <details><summary>Reproduction before the fix (main c8583f8, pnpm dev:crm boot)</summary> - `PUT /api/v1/meta/object/dup_item?mode=draft&package=com.qa.dup` with two actions named `dup` (labels "Dup A", "Dup B") → `200`, `Saved object 'dup_item' … state=draft`. - `POST /api/v1/meta/object/dup_item/publish` → `200`, `Published draft`; the served object lists `dup/Dup A`, `dup/Dup B`. - The standalone action door also saved an `action` named `dup` bound to the object (`200`): a second item, out of scope here (see What changes). </details> <details><summary>Tests</summary> - `pnpm --filter @objectstack/spec test` → Test Files 735 passed (735), Tests 22027 passed | 1 todo; `pnpm --filter @objectstack/spec typecheck` green (tsc, scripts-typecheck, test-typecheck). - `pnpm --filter @objectstack/metadata-protocol test` → Test Files 270 passed | 3 skipped (273), Tests 21618 passed | 19 skipped. VERDICT command-exit 0. - app-shell, narrowed to the 169 files that load `ObjectActionsPanel`, `StudioDesignSurface` or `DataPillar` → Test Files 169 passed (169), Tests 1349 passed; `pnpm --filter @object-ui/app-shell type-check` green. VERDICT command-exit 0. - Dogfood battery, full (`pnpm --filter @objectstack/dogfood test`) → Test Files 314 passed | 3 skipped (317), Tests 2609 passed | 20 skipped. VERDICT command-exit 0. </details> <details><summary>Ablations (scripts/ablation-replace.mjs, restore proven blob == HEAD)</summary> - Spec: the `refuseDuplicateActionNames(...)` call replaced by a no-op → `object-duplicate-action-name.test.ts` + `refused-shapes.test.ts`: Tests 5 failed | 74 passed. Restored. - Studio: the rename guard's condition replaced by `false` → `ObjectActionsPanel.renameTaken-26725.test.tsx`: Tests 2 failed | 1 passed (the free-name control stays green). Restored. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 98 derived families, every one run and exit 0; `--ran` → `98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED`. - 89 of them ran on the tree of `fe0b8e175`; the only later commit, `76f9014ff`, adds the engine-double ledger rows, and the families it brought in plus `check:engine-double-contract` (red before `--write`, exit 0 after) were run on `76f9014ff`. `check:type-check-debt` first exited 3 (lock not granted while the test runs held it) and was re-run to exit 0. - `node scripts/check-adr-0087-registration.mjs --base origin/main` → `registered object-duplicate-action-name-refused (new here: object-duplicate-action-name-refused)`. - `pnpm --filter @objectstack/spec check:generated` → all 14 generated artifacts up to date. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzeqb8AXHib3Hx7Fpeb --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 161e00e commit cdd9f0a

13 files changed

Lines changed: 575 additions & 20 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@object-ui/app-shell': patch
4+
---
5+
6+
fix(spec)!: an object that declares one action name twice is refused at every door that parses it, and Studio never writes the pair
7+
8+
Clause-②: no (narrowing)
9+
Metadata-protocol: narrowed
10+
11+
**BREAKING** for an object whose `actions` array repeats a name.
12+
13+
- **Before.** The object save door accepted two entries of one object's `actions` under one name: `PUT /api/v1/meta/object/X?mode=draft` answered 200, the publish answered 200, and both were served. An action name is the key the action runs under on its object, so which declaration ran was undefined. `defineStack` and `os validate` already refused the pair inside a stack; the save door parses an object alone and did not.
14+
- **Now.** `ObjectSchema` refuses the pair, so every door that parses an object refuses it: `defineStack`, `os validate`, `os build` and the object save door (422 `INVALID_METADATA`). One issue per repeated name, at `actions[j]` of its second declaration, naming the name and every index: `Object 'X' declares the action 'Y' twice in actions: actions[i] and actions[j]. …`. Inside a stack the pair is now refused by the object parse (`STACK_SCHEMA_INVALID`) rather than by the cross-reference pass's duplicate action-key check, which keeps the pairs that span two collections (a bound standalone action beside an embedded one, two standalone actions).
15+
- **Not changed.** Distinct names, the same name on two different objects, and a standalone action bound to an object by `objectName` (judged by `defineStack` for one stack and reported by the runtime's bound-action collision warning across packages).
16+
- **Fix.** Rename all but one of the colliding entries (and every button, flow and caller that runs the renamed one by name), or delete the duplicate.
17+
- **Stored rows.** A stored object that repeats a name still loads, under a `[metadata_spec_invalid]` diagnostic carrying the refusal, and its next save is refused. `os migrate meta --stored` lists each such object under the TODO id `object-duplicate-action-name-refused`; nothing rewrites it, because which declaration keeps the name only the owner knows.
18+
- **Studio** (`@object-ui/app-shell`). New already derived a name no listed action held; renaming an action in the editor's Name field to a name another action of the object holds now writes the first free `<name>_N` instead of the duplicate.
19+
20+
<!-- adr-0087: registered object-duplicate-action-name-refused -->
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26725 — the object save door refuses an object that declares one action
5+
* name twice.
6+
*
7+
* Measured on `main` @ `c8583f80` before the rule, on a `pnpm dev:crm` boot:
8+
* `PUT /api/v1/meta/object/dup_item?mode=draft` with two actions named `dup`
9+
* answered 200 and the publish answered 200. The rule lives in the spec parse
10+
* (`duplicateActionNames`, the object refinement), and `saveMetaItem` parses
11+
* with `ObjectSchema`, so the door answers `422 INVALID_METADATA` at the
12+
* second declaration and stores nothing — the refusal `defineStack` and
13+
* `os validate` meet, from the same rule.
14+
*/
15+
import { describe, expect, it } from 'vitest';
16+
import { assertEngineDeleteDispatch, assertEngineUpdateDispatch, assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/metadata-core';
17+
import { ObjectStackProtocolImplementation } from './protocol.js';
18+
19+
/** The engine surface the repository write path touches (as the container-issue-descent harness). */
20+
function makeProtocol() {
21+
const rows = new Map<string, Record<string, unknown>>();
22+
let nextId = 0;
23+
const engine: any = {
24+
async findOne(object: string, query?: EngineFindOneQueryInput) {
25+
assertEngineFindOnePredicate(object, query); return null; },
26+
async find() { return []; },
27+
async insert(table: string, data: Record<string, unknown>) {
28+
nextId += 1;
29+
if (table === 'sys_metadata') rows.set(`${data.type}|${data.name}`, data);
30+
return { id: `r_${nextId}` };
31+
},
32+
async update(_t: string, data: Record<string, unknown>, opts?: Record<string, unknown>) {
33+
assertEngineUpdateDispatch(data, opts);
34+
return { id: null };
35+
},
36+
async delete(_t: string, opts?: Record<string, unknown>) {
37+
assertEngineDeleteDispatch(opts);
38+
return { deleted: 0 };
39+
},
40+
registry: { registerItem: () => {}, registerObject: () => {} },
41+
};
42+
const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map());
43+
return { protocol, rows };
44+
}
45+
46+
async function rejection(promise: Promise<unknown>): Promise<any> {
47+
try {
48+
await promise;
49+
} catch (err) {
50+
return err;
51+
}
52+
throw new Error('expected the save to be rejected, but it resolved');
53+
}
54+
55+
const act = (name: string, label: string) => ({ name, label, type: 'script', target: 'noop', locations: ['record_header'] });
56+
57+
const itemWith = (actions: unknown[]) => ({
58+
name: 'dup_item',
59+
label: 'Dup Item',
60+
sharingModel: 'private',
61+
fields: { title: { name: 'title', type: 'text', label: 'Title' } },
62+
actions,
63+
});
64+
65+
describe('#26725 the save door refuses an object that declares one action name twice', () => {
66+
it('two actions named `dup` answer 422 INVALID_METADATA at the second one, and nothing is stored', async () => {
67+
const { protocol, rows } = makeProtocol();
68+
69+
const err = await rejection(protocol.saveMetaItem({
70+
type: 'object', name: 'dup_item', item: itemWith([act('dup', 'Dup A'), act('dup', 'Dup B')]),
71+
}));
72+
73+
expect(err.code).toBe('INVALID_METADATA');
74+
expect(err.status).toBe(422);
75+
expect(err.issues).toEqual([expect.objectContaining({ path: 'actions.1', code: 'custom' })]);
76+
expect(err.issues[0].message.startsWith(
77+
"Object 'dup_item' declares the action 'dup' twice in actions: actions[0] and actions[1].",
78+
)).toBe(true);
79+
expect(rows.size).toBe(0);
80+
});
81+
82+
it('control: the same object with distinct action names saves', async () => {
83+
const { protocol, rows } = makeProtocol();
84+
85+
const result = await protocol.saveMetaItem({
86+
type: 'object', name: 'dup_item', item: itemWith([act('dup', 'Dup A'), act('dup_b', 'Dup B')]),
87+
});
88+
89+
expect(result.success).toBe(true);
90+
expect(rows.size).toBe(1);
91+
});
92+
});

‎packages/spec/src/conversions/refused-shapes.test.ts‎

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ describe('the refused-shape list — registered against the migration chain', ()
6868
'flow-value-slot-template-dialect-refused',
6969
'object-external-binding-managed-datasource-refused',
7070
'visibility-predicate-has-argument-refused',
71+
'object-duplicate-action-name-refused',
7172
]);
7273
});
7374

@@ -661,3 +662,43 @@ describe('#25281 — a stored page or view whose visibleWhen hands `has()` an ar
661662
expect(PageSchema.safeParse(hasPage('has(status)')).success).toBe(true);
662663
});
663664
});
665+
666+
// ── #26725 ────────────────────────────────────────────────────────────────
667+
668+
const dupAct = (name: string, label: string) => ({ name, label, type: 'script', target: 'noop' });
669+
const dupRow = (actions: unknown[]) => ({
670+
name: 'crm_deal',
671+
label: 'Deal',
672+
sharingModel: 'private',
673+
fields: { title: { type: 'text', label: 'Title' } },
674+
actions,
675+
});
676+
const dupActionIssue = (row: unknown) => {
677+
const parsed = ObjectSchema.safeParse(row);
678+
return parsed.success
679+
? undefined
680+
: parsed.error.issues.find((i) => i.path[0] === 'actions' && /declares the action/.test(i.message));
681+
};
682+
683+
describe('#26725 — a stored object that declares one action name twice is listed by object', () => {
684+
it('one TODO at the second declaration, naming the name and both indexes; the row is not rewritten', () => {
685+
const row = dupRow([dupAct('approve', 'Approve A'), dupAct('approve', 'Approve B')]);
686+
const { item, todos } = storedTodos('object', row);
687+
expect(item).toEqual(row);
688+
expect(todos).toHaveLength(1);
689+
const todo = todos[0]!;
690+
expect(todo.conversionId).toBe('object-duplicate-action-name-refused');
691+
expect(todo.path).toBe('objects[0].actions[1]');
692+
expect(todo.reason).toMatch(/^No lossless rewrite: which declaration keeps the name/);
693+
expect(todo.reason).toContain("Object 'crm_deal' declares the action 'approve' twice in actions: actions[0] and actions[1].");
694+
expect(JSON.parse(todo.from)).toEqual({ name: 'approve', indexes: [0, 1] });
695+
// The listing and the parse refuse ONE set: the stored row still fails the parse, at the same site.
696+
expect(dupActionIssue(item)?.path).toEqual(['actions', 1]);
697+
});
698+
699+
it('distinct names are not listed — and the parse accepts them', () => {
700+
const row = dupRow([dupAct('approve', 'Approve'), dupAct('reject', 'Reject')]);
701+
expect(storedTodos('object', row).todos).toEqual([]);
702+
expect(dupActionIssue(row)).toBeUndefined();
703+
});
704+
});

‎packages/spec/src/conversions/refused-shapes.ts‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,8 @@
4040
* `AutomationEngine.registerFlow` and `objectstack validate` run
4141
* ({@link flowNodeValueTemplateRefusals}), and the `external` binding judge
4242
* the object refinement and `defineStack` share
43-
* ({@link externalBindingDatasourceRefusal}). A site is listed here exactly
43+
* ({@link externalBindingDatasourceRefusal}), and the one-name-per-action
44+
* judge of the object refinement ({@link duplicateActionNames}). A site is listed here exactly
4445
* when that door refuses it. One door lives outside this package: the
4546
* page/view `visibleWhen` rule in `@objectstack/lint` judges a `has()`
4647
* argument on the engine's AST, which this package cannot parse, so its
@@ -59,6 +60,7 @@ import {
5960
isControlledByParentMasterField,
6061
type ControlledByParentMasterRefusal,
6162
} from '../data/controlled-by-parent-master.js';
63+
import { duplicateActionNames } from '../data/duplicate-action-names.js';
6264
import { externalBindingDatasourceRefusal } from '../data/external-binding-datasource.js';
6365
import { ComponentPropsMap } from '../ui/component.zod.js';
6466
import { ViewDataSchema } from '../ui/view.zod.js';
@@ -478,6 +480,31 @@ const visibilityHasArgument: RefusedStoredShape = {
478480
},
479481
};
480482

483+
// ── #26725: an object that declares one action name twice ──
484+
485+
const objectDuplicateActionName: RefusedStoredShape = {
486+
id: 'object-duplicate-action-name-refused',
487+
toMajor: 18,
488+
surface: 'object.actions — two or more entries of one object under the same action name',
489+
detect(stack, report) {
490+
mapCollection(stack, 'objects', (obj, path) => {
491+
for (const duplicate of duplicateActionNames(obj.name, obj.actions)) {
492+
report({
493+
path: `${path}.actions[${duplicate.indexes[1]}]`,
494+
// The name and the sites only: an action body can carry request headers.
495+
from: JSON.stringify({ name: duplicate.name, indexes: duplicate.indexes }),
496+
reason: 'No lossless rewrite: which declaration keeps the name, and what the others are renamed to '
497+
+ '(with every button, flow and caller that runs them by name), only the owner knows. Left as stored, '
498+
+ 'the object registers under `[metadata_spec_invalid]` and its next save is refused with 422 '
499+
+ 'INVALID_METADATA. '
500+
+ duplicate.message,
501+
});
502+
}
503+
return obj;
504+
});
505+
},
506+
};
507+
481508
/**
482509
* Every refused stored shape, in report order. A new entry is a reviewed edit:
483510
* `refused-shapes.test.ts` pins each one.
@@ -488,6 +515,7 @@ export const REFUSED_STORED_SHAPES: readonly RefusedStoredShape[] = [
488515
flowValueSlotTemplateToken,
489516
externalBindingOnManagedDatasource,
490517
visibilityHasArgument,
518+
objectDuplicateActionName,
491519
];
492520

493521
/**
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#26725] The ONE predicate behind "an object declares each action name once".
5+
*
6+
* An action's `name` is its identifier on its object: the runtime registers and
7+
* dispatches it under `<object>:<name>`, and the action endpoint addresses it
8+
* as `/api/v1/actions/<object>/<name>`. Two entries of one object's `actions`
9+
* under one name are two declarations under one key, so which one runs is
10+
* undefined. `defineStack`'s cross-reference pass already refused that pair
11+
* for a stack, but the object save door parses an object alone, so it saved
12+
* and published the pair.
13+
*
14+
* Read by two doors, one judgement:
15+
*
16+
* - `ObjectSchema`'s refinement (`object.zod.ts`), so every door that parses an
17+
* object refuses the pair: `defineStack`, `os validate`, the object save door
18+
* (422 `INVALID_METADATA`) and the stored-row diagnostics;
19+
* - the v18 stored-row listing (`conversions/refused-shapes.ts`), so
20+
* `os migrate meta --stored` names each stored object this refuses.
21+
*
22+
* Scope is ONE object's own `actions` array. A standalone action bound to the
23+
* object by `objectName` is a second item, judged where both are visible:
24+
* `defineStack`'s duplicate action-key check for one stack, and the runtime's
25+
* bound-action collision report for two packages.
26+
*/
27+
28+
/** One action name that an object's `actions` array declares more than once. */
29+
export interface DuplicateActionName {
30+
/** The repeated action name. */
31+
readonly name: string;
32+
/** Every index of `actions` that declares it, ascending. */
33+
readonly indexes: readonly number[];
34+
/** The refusal's message, naming the action, every index and the fix. */
35+
readonly message: string;
36+
}
37+
38+
/** `actions[0] and actions[1]`, `actions[0], actions[2] and actions[5]`. */
39+
function joinIndexes(indexes: readonly number[]): string {
40+
const sites = indexes.map((i) => `actions[${i}]`);
41+
if (sites.length <= 1) return sites.join('');
42+
return `${sites.slice(0, -1).join(', ')} and ${sites[sites.length - 1]}`;
43+
}
44+
45+
/**
46+
* Every action name that `actions` declares more than once, in the order each
47+
* name first appears. An empty list means nothing to refuse. Read on a parsed
48+
* object or a stored body alike: a non-array, a non-object entry and an entry
49+
* with no string `name` are left to the schema's own issues.
50+
*/
51+
export function duplicateActionNames(objectName: unknown, actions: unknown): DuplicateActionName[] {
52+
if (!Array.isArray(actions)) return [];
53+
const indexesByName = new Map<string, number[]>();
54+
actions.forEach((action, index) => {
55+
const name = action !== null && typeof action === 'object' ? (action as { name?: unknown }).name : undefined;
56+
if (typeof name !== 'string' || name === '') return;
57+
const list = indexesByName.get(name);
58+
if (list) list.push(index);
59+
else indexesByName.set(name, [index]);
60+
});
61+
const owner = typeof objectName === 'string' && objectName.length > 0 ? `Object '${objectName}'` : 'This object';
62+
const out: DuplicateActionName[] = [];
63+
for (const [name, indexes] of indexesByName) {
64+
if (indexes.length < 2) continue;
65+
out.push({
66+
name,
67+
indexes,
68+
message:
69+
`${owner} declares the action '${name}' ${indexes.length === 2 ? 'twice' : `${indexes.length} times`} `
70+
+ `in actions: ${joinIndexes(indexes)}. An action name identifies the action on its object (it is the `
71+
+ 'key the action runs under), so only one of these declarations could run. Rename all but one of them, '
72+
+ 'or remove the duplicate.',
73+
});
74+
}
75+
return out;
76+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #26725 — an object's `actions` array declares each action name once.
5+
*
6+
* Measured on `main` @ `c8583f80` before this rule, on a `pnpm dev:crm` boot:
7+
* `PUT /api/v1/meta/object/dup_item?mode=draft` with two actions named `dup`
8+
* answered 200, the publish answered 200, and the served object listed both
9+
* (`dup/Dup A`, `dup/Dup B`). `defineStack` already refused the pair for a
10+
* stack; the object parse now refuses it for every door that parses an object.
11+
*
12+
* Each refusal fixture differs from an accepted twin by one name.
13+
*/
14+
import { describe, expect, it } from 'vitest';
15+
import { ObjectSchema } from './object.zod';
16+
import { duplicateActionNames } from './duplicate-action-names';
17+
18+
const act = (name: string, label = name) => ({ name, label, type: 'script' as const, target: 'noop' });
19+
20+
const objectWith = (actions: unknown[]) => ({
21+
name: 'probe_item',
22+
label: 'Probe Item',
23+
fields: { title: { type: 'text' as const, label: 'Title' } },
24+
actions,
25+
});
26+
27+
const issuesAtActions = (row: unknown) => {
28+
const parsed = ObjectSchema.safeParse(row);
29+
return parsed.success ? [] : parsed.error.issues.filter((i) => i.path[0] === 'actions');
30+
};
31+
32+
describe('#26725 the object parse refuses two actions under one name', () => {
33+
it('refuses a pair, at the second declaration, naming the name and both indexes', () => {
34+
const issues = issuesAtActions(objectWith([act('dup', 'Dup A'), act('dup', 'Dup B')]));
35+
expect(issues).toHaveLength(1);
36+
expect(issues[0]!.code).toBe('custom');
37+
expect(issues[0]!.path).toEqual(['actions', 1]);
38+
expect(issues[0]!.message.startsWith(
39+
"Object 'probe_item' declares the action 'dup' twice in actions: actions[0] and actions[1].",
40+
)).toBe(true);
41+
});
42+
43+
it('control: the same object with distinct names parses', () => {
44+
expect(ObjectSchema.safeParse(objectWith([act('dup', 'Dup A'), act('dup_b', 'Dup B')])).success).toBe(true);
45+
});
46+
47+
it('counts three declarations as one issue listing every index, at the second one', () => {
48+
const issues = issuesAtActions(objectWith([act('first_one'), act('dup'), act('second_one'), act('dup'), act('dup')]));
49+
expect(issues).toHaveLength(1);
50+
expect(issues[0]!.path).toEqual(['actions', 3]);
51+
expect(issues[0]!.message.startsWith(
52+
"Object 'probe_item' declares the action 'dup' 3 times in actions: actions[1], actions[3] and actions[4].",
53+
)).toBe(true);
54+
});
55+
56+
it('reports one issue per repeated name', () => {
57+
const issues = issuesAtActions(objectWith([act('dup_x'), act('dup_y'), act('dup_x'), act('dup_y')]));
58+
expect(issues.map((i) => i.path)).toEqual([['actions', 2], ['actions', 3]]);
59+
});
60+
61+
it('refuses a byte-identical twin too: two entries are two declarations under one key', () => {
62+
const twin = act('same');
63+
expect(issuesAtActions(objectWith([twin, { ...twin }]))).toHaveLength(1);
64+
});
65+
});
66+
67+
describe('#26725 duplicateActionNames reads a stored body as the parse does', () => {
68+
it('leaves a non-array, a non-object entry and a nameless entry to the schema', () => {
69+
expect(duplicateActionNames('o', undefined)).toEqual([]);
70+
expect(duplicateActionNames('o', { dup: {} })).toEqual([]);
71+
expect(duplicateActionNames('o', [null, 'dup', { label: 'x' }, { label: 'y' }, { name: '' }, { name: '' }])).toEqual([]);
72+
});
73+
74+
it('names the object when it has a name, and says "This object" when it has none', () => {
75+
expect(duplicateActionNames(undefined, [act('d'), act('d')])[0]!.message.startsWith(
76+
"This object declares the action 'd' twice",
77+
)).toBe(true);
78+
});
79+
});

0 commit comments

Comments
 (0)