@@ -26327,6 +26327,11 @@ export class ObjectStackProtocolImplementation implements
2632726327 * A copied permission set's field rules follow too: in `fields`, keyed
2632826328 * `<object>.<field>`, a key's object part takes the renamed object's name
2632926329 * and its field part is left as stored.
26330+ *
26331+ * A rename at a reference site that meets a name the item already holds
26332+ * there (a permission set's or a translation's `objects` key, a hook's
26333+ * `object` entry) is never merged: the item is refused in `failed[]`,
26334+ * naming both, before anything is written for it.
2633026335 */
2633126336 async duplicatePackage(request: {
2633226337 sourcePackageId: string;
@@ -26550,26 +26555,75 @@ export class ObjectStackProtocolImplementation implements
2655026555 // KEY (`permission.objects` is keyed by object name) or a `.name`. The
2655126556 // record key holds for prefixed names too: a copied permission set
2655226557 // otherwise kept `objects.<source object>`, a grant on the SOURCE.
26558+ //
26559+ // ⛔ REFUSED, NEVER MERGED: at a reference site, a renamed name can
26560+ // meet a name the item already holds there — the source also names
26561+ // the object the copy is about to be called (`dupdst_customer`, an
26562+ // object outside the package or not yet created) beside its own
26563+ // (`dupsrc_customer`). Two record KEYS that land on one key keep only
26564+ // the last entry: a permission set's `objects` dropped a grant (and
26565+ // the copied object could gain edit and delete its source never gave
26566+ // it), a translation's `objects` dropped a label. Two array ELEMENTS
26567+ // that land on one name bind it twice: a hook's handler ran twice per
26568+ // event on the copied object. So every such meeting is recorded in
26569+ // `collisions` — the path, the name that moved, the name it met — and
26570+ // the call site refuses the item in `failed[]` before anything is
26571+ // written for it. `renameMap` is one-to-one, so a meeting is always a
26572+ // renamed name and a name this copy does not rename; an element the
26573+ // source itself repeats is copied as stored.
2655326574 const objectReferenceProperties = new Set(
2655426575 (REFERENCE_SITES.byTarget.get('object') ?? []).map((site) => site.property),
2655526576 );
26556- const deepRewrite = (v: any, atReference = false): any => {
26577+ type ReferenceCollision = { at: string; shape: 'key' | 'entry'; moved: string; met: string };
26578+ const deepRewrite = (v: any, collisions: ReferenceCollision[], atReference = false, at = ''): any => {
2655726579 if (typeof v === 'string') {
2655826580 const whole = atReference ? renameMap.get(v) : undefined;
2655926581 if (whole !== undefined) return whole;
2656026582 return re ? v.replace(re, (m) => renameMap.get(m) ?? m) : v;
2656126583 }
26562- if (Array.isArray(v)) return v.map((element) => deepRewrite(element, atReference));
26584+ if (Array.isArray(v)) {
26585+ const out = v.map((element, i) => deepRewrite(element, collisions, atReference, `${at}[${i}]`));
26586+ if (atReference) {
26587+ const firstAt = new Map<string, number>();
26588+ out.forEach((element, i) => {
26589+ if (typeof element !== 'string') return;
26590+ const j = firstAt.get(element);
26591+ if (j === undefined) {
26592+ firstAt.set(element, i);
26593+ } else if (v[j] !== v[i]) {
26594+ collisions.push({ at, shape: 'entry', moved: v[i] !== element ? v[i] : v[j], met: element });
26595+ }
26596+ });
26597+ }
26598+ return out;
26599+ }
2656326600 if (v && typeof v === 'object') {
2656426601 const o: any = {};
26602+ const from = new Map<string, string>();
2656526603 for (const [k, val] of Object.entries(v)) {
2656626604 const key = atReference ? (renameMap.get(k) ?? k) : k;
26567- o[key] = deepRewrite(val, objectReferenceProperties.has(k) || (atReference && k === 'name'));
26605+ const other = from.get(key);
26606+ if (other !== undefined) {
26607+ collisions.push({ at, shape: 'key', moved: key === k ? other : k, met: key });
26608+ }
26609+ from.set(key, k);
26610+ o[key] = deepRewrite(
26611+ val, collisions,
26612+ objectReferenceProperties.has(k) || (atReference && k === 'name'),
26613+ at ? `${at}.${k}` : k,
26614+ );
2656826615 }
2656926616 return o;
2657026617 }
2657126618 return v;
2657226619 };
26620+ const describeCollision = (c: ReferenceCollision): string =>
26621+ `its \`${c.at}\` ${c.shape} '${c.moved}' follows the copied object to '${c.met}', `
26622+ + `${c.shape === 'key' ? 'a key' : 'an entry'} it already holds there, which this copy does not rename. `
26623+ + (c.shape === 'key'
26624+ ? 'A record keeps one entry per key, so the copy would drop one of the two. '
26625+ : `The copy would name '${c.met}' twice. `)
26626+ + `Remove '${c.moved}' or '${c.met}' on the source, then duplicate again.`;
2657326627 // ⭐ A COPIED PERMISSION SET'S FIELD RULES FOLLOW THE RENAMED OBJECTS.
2657426628 // `PermissionSetSchema.fields` is a record keyed `<entity_name>.<field_name>`
2657526629 // (`packages/spec/src/security/permission.zod.ts`), and the security
@@ -26738,7 +26792,21 @@ export class ObjectStackProtocolImplementation implements
2673826792 continue;
2673926793 }
2674026794 }
26741- const rewritten = deepRewrite(item);
26795+ const collisions: ReferenceCollision[] = [];
26796+ const rewritten = deepRewrite(item, collisions);
26797+ if (collisions.length > 0) {
26798+ const noun = singular === 'permission'
26799+ ? 'Permission set'
26800+ : `${singular.charAt(0).toUpperCase()}${singular.slice(1)}`;
26801+ const more = collisions.length - 1;
26802+ failed.push({
26803+ type: row.type,
26804+ name: row.name,
26805+ error: `${noun} '${row.name}' is not copied: ${describeCollision(collisions[0]!)}`
26806+ + (more > 0 ? ` It has ${more} more such collision${more === 1 ? '' : 's'}.` : ''),
26807+ });
26808+ continue;
26809+ }
2674226810 if (rewritten && typeof rewritten === 'object' && !Array.isArray(rewritten)) {
2674326811 rewritten.name = newName;
2674426812 // [ADR-0086 D3] A permission set that declares its SOURCE
0 commit comments