Skip to content

Commit 7135cf0

Browse files
os-billclaude
andauthored
fix(spec): refuse a declared def rename that would collapse two baseline keys onto one (#17920)
Fixes #17383 Clause-②: no `checkRenameTable` validates `RENAMED_DEFS` against the defs a build **emits** — that is all its call site can see. A single rename `A -> B` where the recorded **baseline** already holds a property name under both `A` and `B` is invisible to it: it is not two sources onto one target, the source is unemitted and the target is emitted, so all of the existing rules pass. Every carry in `build-schemas.ts` is a plain `Map.set` keyed by the carried key, so the two entries collapse and the later write wins. ## The premise this PR did not inherit The card places `checkRenameTable` in `packages/spec/scripts/lib/authorable-defaults.ts`. It is not there. Re-derived by symbol on `origin/main`: - the guard is `packages/spec/scripts/lib/renamed-defs.ts`, consumed once in `build-schemas.ts`; - there are **five** clobbering carry sites, not two — four in `build-schemas.ts` (the surface gate's `prev`, the deletion gate's `baseSnapshot`, and both `baselineKeys` builders of the defaults gate) plus `carryDefaultsThroughRenames`; - a sixth carry site, `registeredRetiredKeys()`, is **not** affected: it merges deliberately (earliest major wins) instead of clobbering. A second correction: the card and the dispatch both say `checkRenameTable` refuses "exactly four shapes". It refuses **five** — the chained-rename rule (`A -> B -> C`) is a separate pass at the end of the function. ## The gap, reproduced with a lit control Against the real carry code, one rename `integration/Old -> integration/New`, with the baseline holding `mode` under both defs: | leg | reading | | --- | --- | | the guard today | `checkRenameTable` returns `[]` — no diagnostic | | **lit control** | the *same* collapse reached by two sources returns 1 problem — `the TARGET def is already claimed by …` | | key side | `prev` collapses 2 baseline entries to 1; the surviving retired flag is whichever sorted last | | defaults side | `carryDefaultsThroughRenames` collapses 2 fingerprints to 1 | | **acceptance bar** | target default `"b"`, this build emits `"a"` — a genuine change — and `diffAuthorableDefaults` reports `[]`. **Swallowed.** | | mirror | target default `"b"`, this build emits `"b"` — no change at all — and the differ reports `changed: "a" -> "b"`. **Manufactured.** | The corruption is bidirectional, which is what decides the disposition below. ## The choice: REFUSE — and refuse the intersection, not the populated target The card offers two shapes and specifies neither. This PR refuses, for three reasons, and narrows *what* is refused for a fourth. 1. **The damage is identical to the two-sources rule's**, not merely similar: the same maps, the same `Map.set`, the same lost retired state and lost default. Where the damage is identical, the disposition should be too. 2. **Report-and-allow cannot work here**, because the clobber happens *inside the carry, before any comparison runs*. The rows a reviewer would read are themselves computed against corrupted input — and per the table above the gate both swallows real changes and manufactures false ones, so a reviewer holding an advisory "collision" line beside a `changed` row cannot tell which of the two is real. Refusal is the only disposition that keeps the reported diff honest. 3. The remedy is available and is the one the two-sources rule already prescribes: keep the rename, and retire the losing side explicitly with `retiredKey()` plus its registered ADR-0087 conversion. 4. ⚠️ **But the card's literal predicate is falsified by measurement.** "Refuse a rename whose target already carries keys in the baseline" would redden `main` immediately: - **24 of the 39** committed entries have a target that already holds baseline keys in the committed `authorable-surface/` (that snapshot is the *post*-rename one, so the keys sit under the new name and the source has none); - the in-tree `cloud/Sha256Digest -> system/Sha256Digest` entry is documented as "a rename onto a def that already existed", so rename-into-an-existing-target is an already-sanctioned shape. `Map.set` can only collapse entries that are the **same key**, so a merge whose two defs share no property **name** writes every key exactly once and loses nothing. The refusal is therefore the **intersection** — the property names the baseline records under both defs — which is exactly the damage and nothing more. Measured on `origin/main`: **0 collisions** in the committed `authorable-surface/` and **0** in `authorable-surface.base.json`. Both baselines are guarded, because they are different documents: the upstream anchor is the *pre*-rename snapshot (source populated, target empty) and the in-tree snapshot is the *post*-rename one. A real merge-into-populated-target shows up in the anchor first, so guarding only the in-tree snapshot would have missed the card's own case. ## Ablation — both directions, on-disk proof, hash restore Each leg: unique-anchor check, occurrence counts before and after, `git hash-object` against the `HEAD` blob, `trap … EXIT INT TERM`, and restore verified byte-identical plus an empty `git diff HEAD`. **Leg A — disable the guard.** Blob `f32305e7` -> `addc4481`. - 4 unit pins RED: *refuses a rename whose target already holds the same property name*; *is silent where checkRenameTable is loud*; *reports EVERY colliding property*; *splits on the FIRST separator* - 1 wiring pin RED: *refuses a rename whose baseline records the same property under BOTH defs, and writes nothing* - 25 unit pins stayed green — the mutation is targeted, not a blanket break - restored: `f32305e7`, byte-identical, `git diff HEAD` empty **Leg B — make the guard over-fire** (refuse when BOTH sides are populated, ignoring the intersection). Blob `f32305e7` -> `4f87b2b5`. This is the cost this change can impose. - the over-refusal pins RED, exactly as designed: *ACCEPTS a merge into a populated target whose property names are disjoint*, and the wiring pin *does NOT refuse a rename into a populated target when no property name is shared* - one extra, honestly reported: *reports EVERY colliding property, sorted* also went RED, because the over-firing rule reports 3 properties where the real rule reports 2 - 27 unit pins stayed green; restored `f32305e7`, byte-identical, `git diff HEAD` empty ⚠️ Note what Leg B means: under that mutation the **real build stays green**, because every committed entry has an empty source side in the in-tree snapshot. The over-refusal is caught *only* by these pins. That is why they exist. ⭐ **Correction, from the at-tier contract review of this PR.** An earlier revision of this section said Leg B "is the leg the card's literal predicate would have shipped" while also saying the real build stays green under it. **Those cannot both be true**, and the review separated them by measurement: - **Leg B (above, blob `4f87b2b5`)** — refuse when both sides are populated. Real `check:authorable-surface` **exit 0**; caught by the pins alone. - **The card's literal predicate** — refuse ANY populated target — is a *different* mutation (the review's B2, blob `4a3848b9`). Wired into the real build it exits **1** with **24 problem lines**: `❌ 24 declared def rename(s) would COLLAPSE keys of the committed authorable-surface/`. ⇒ The card's predicate is caught by the real build, loudly. The narrower over-fire is the one only the pins catch. Both readings stand; only the sentence conflating them was wrong. ## One fixture corrected, and why it is in scope `build-schemas-check-mode.test.ts` seeded its carry fixture by **injecting** the old key into the base while leaving the carried key in place — so the anchor recorded `source` under both defs at once. That is the collision, and the new guard refuses it: measured, the run now exits 1 *before* the check the fixture was written for is reached, so it would have been asserting about a build that never got there. No real landing produces that anchor (a pre-rename anchor holds the property under the old def only), so the fixture now removes the carried key as well. It is a faithfulness fix, not an accommodation — and it doubles as the over-refusal pin, since the target def still holds six other keys in that base. ## Verification | run | exit | result | | --- | ---: | --- | | `vitest run --project local scripts/renamed-defs.test.ts` | 0 | 29 passed | | `vitest run --project repo build-schemas-check-mode.test.ts -t 'deleted baseline lines must prove themselves'` | 0 | 11 passed, 63 skipped | | `pnpm --filter @objectstack/spec typecheck` | 0 | `tsc` + `check:scripts-typecheck` + `check:test-typecheck` all green | | `pnpm --filter @objectstack/spec run check:authorable-surface` | 0 | the real `build-schemas.ts --check` accepts the committed table | | derived gate families (`scripts/pm/dispatch-gates.mjs --commands`) | 0 | see the report; 3 of them read `dist/` and were re-run after a build | ## No changeset — `skip-changeset` Measured rather than assumed. `packages/spec`'s `files[]` ships `dist`, `json-schema`, `liveness`, `prompts`, `llms.txt`, `README.md`, `src/**/*.zod.ts`, `CHANGELOG.md`, `api-surface`, `spec-changes.json`. This diff touches only `packages/spec/scripts/**`. - positive control — `ConnectorSchema`, a published symbol: **4** published paths hit - `checkRenameBaselineCollisions` (new): **0** - `checkRenameTable` and `carryAuthorableKey`, siblings in the same build-script module that have shipped through many releases: **0** The sibling control is the load-bearing half: build-script symbols have never reached a published path, so this is a measurement across releases rather than an argument from construction. Nothing published moves. ⚠️ The dispatch's declared file surface listed "a changeset"; this deviates from it deliberately and is flagged in the round report so the seat can reverse it in one step. ## 验收备注 范围外发现,均未立卡: - 卡片与派发词都称 `checkRenameTable` "refuses exactly four shapes",实为**五条** —— 链式改名(`A -> B -> C`)是函数末尾独立的一遍。属于卡片表述,不是代码缺陷。 - 两来源规则(guard four)即使在两个来源的属性名**完全不相交**时也拒绝,比它自己 陈述的损害更宽;本次新规则只拒交集。这是一处不对称,但方向偏严、不违反任何契约, 因此记录不立卡。承接者:下一个向 `RENAMED_DEFS` 添加合并型条目的 PR。 - `registeredRetiredKeys()`(`build-schemas.ts`)是第六个 carry 站点,它**刻意合并** (取最早的 major)而非覆盖,不受本次影响。记录以免下次读成遗漏。 ## 维护者速读(草稿) **改了什么** —— `packages/spec` 的构建期闸门新增一条规则:声明的 def 改名,如果基线 快照在**源 def 和目标 def 下记录了同一个属性名**,构建直接失败。新增 `checkRenameBaselineCollisions`,在 `build-schemas.ts` 的两处基线(in-tree 快照与 upstream 锚点)各调用一次。只动构建脚本,不动任何已发布内容。 **为什么改** —— 现有的 `checkRenameTable` 只能看到"这次构建发出了哪些 def",看不到 基线。所以单条改名 `A -> B` 撞上一个基线里已有 keys 的 `B` 时,五条现有规则全部放行, 而所有 carry 都是 `Map.set`,两条记录塌成一条、后写的赢。丢掉的是属性的 retired 状态 与 default 指纹,而且塌陷发生在**任何比较之前**,因此下游每个 ratchet 都在已被污染的 输入上判案 —— 双向出错:真实的 default 变更会被吞掉,没变的会被报成 `changed`。 ⚠️ 今天树里没有这种改名,这是**潜在**而非现行缺陷:修它的理由是闸门的保证比它读起来 更窄,不是现在坏了。 **风险与代价(含回滚)** —— 代价是闸门可能**误拒**一次合法改名。卡片建议的字面判据 ("目标在基线里已有 keys 就拒绝")实测会当场让 `main` 变红:39 条已提交条目里有 24 条 的目标在 in-tree 快照下已有 keys,而且 `cloud/Sha256Digest -> system/Sha256Digest` 这条在树里就是"改名到一个已存在的 def"。所以本次只拒**交集**(两边同名的属性), 不相交的合并逐字节无损、照常放行,并由 Leg B 消融钉死。实测 `main` 两份基线的交集 均为 0,`check:authorable-surface` 绿。回滚成本极低:整个改动是构建脚本加一条规则, `git revert` 即可,不涉及任何已发布载荷、导出符号或生成产物。 **席位意见** —— (留空,待席位定稿) **你要做的** —— 两件。① 裁一下方向:卡片把"拒绝"与"带碰撞报告放行"并列且不指定, 本 PR 选了**拒绝**并收窄到交集,理由与证据在正文;若你要"报告放行",需要先回答 guard four 为什么对同一损害判了拒绝。② 确认 `skip-changeset` 这一路:实测没有任何 已发布内容移动(带正控),但派发词的文件面写了"a changeset",两者冲突,由你定。 --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5741ff1 commit 7135cf0

4 files changed

Lines changed: 301 additions & 2 deletions

File tree

packages/spec/scripts/build-schemas-check-mode.test.ts

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -975,6 +975,11 @@ const DELETED_AGED = `data/Object:${DELETED_AGED_LEAF} [RETIRED]`;
975975
* fixture vacuous. */
976976
const DELETED_BY_RENAME_SOURCE_DEF = 'integration/FieldMapping';
977977
const DELETED_BY_RENAME = `${DELETED_BY_RENAME_SOURCE_DEF}:source`;
978+
/** The SAME property under the rename's TARGET def. The committed surface is the
979+
* post-rename snapshot, so it records this one and not `DELETED_BY_RENAME`; an
980+
* upstream anchor from before the rename is the mirror image, and holding both
981+
* at once is the #17383 collision. */
982+
const CARRIED_BY_RENAME = `${RENAMED_DEFS[DELETED_BY_RENAME_SOURCE_DEF]}:source`;
978983

979984
describe('build-schemas.ts — deleted baseline lines must prove themselves (#4650)', () => {
980985
beforeAll(() => {
@@ -1266,7 +1271,14 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46
12661271
expect(pristineSurface).toContain(
12671272
`${RENAMED_DEFS[DELETED_BY_RENAME_SOURCE_DEF]}:source`,
12681273
);
1269-
seedBase((s) => [...s, DELETED_BY_RENAME].sort());
1274+
// ⚠️ The old key is INJECTED and the carried one REMOVED, which is what an
1275+
// upstream anchor from before the rename really looks like: the property
1276+
// is recorded under the OLD def and not yet under the new one. Injecting
1277+
// alone left the base recording `source` under BOTH defs — a shape no
1278+
// real landing produces, and one #17383's collision guard now refuses
1279+
// outright (measured: the run exits 1 before this check is reached), so
1280+
// the fixture would have been asserting about a build that never got here.
1281+
seedBase((s) => [...s.filter((k) => k !== CARRIED_BY_RENAME), DELETED_BY_RENAME].sort());
12701282
seedSurface((s) => s);
12711283

12721284
const { status, output } = run(['--check']);
@@ -1276,6 +1288,66 @@ describe('build-schemas.ts — deleted baseline lines must prove themselves (#46
12761288
expect(status).toBe(0);
12771289
},
12781290
);
1291+
1292+
// ─── #17383 — a rename may MOVE keys, it may never MERGE two onto one ─────
1293+
// `checkRenameTable` validates the table against the defs the build EMITS, so
1294+
// this shape is invisible to it: one well-formed rename, source unemitted,
1295+
// target emitted. The damage is in the BASELINE, where the carry's plain
1296+
// `Map.set` collapses the two entries and drops one side's recorded retired
1297+
// state and default — before any ratchet below runs.
1298+
1299+
it(
1300+
'refuses a rename whose baseline records the same property under BOTH defs, and writes nothing',
1301+
{ timeout: SPAWN_TIMEOUT_MS },
1302+
() => {
1303+
// The target keeps `CARRIED_BY_RENAME`, so the base records `source` under
1304+
// the source def AND the target def. That is the collision.
1305+
seedBase((s) => [...s, DELETED_BY_RENAME].sort());
1306+
const surfaceBytes = seedSurface((s) => s);
1307+
1308+
const { status, output } = run(['--check']);
1309+
1310+
expect(status).toBe(1);
1311+
expect(output).toContain('would COLLAPSE keys of the upstream baseline');
1312+
expect(output).toContain(
1313+
`${DELETED_BY_RENAME_SOURCE_DEF}${RENAMED_DEFS[DELETED_BY_RENAME_SOURCE_DEF]}`,
1314+
);
1315+
expect(output).toContain('under the TARGET def — source');
1316+
// The remedy is the one the two-sources rule already prescribes.
1317+
expect(output).toContain('retiredKey()');
1318+
// A check reports; it does not write (#4711).
1319+
expect(readSurface()).toBe(surfaceBytes);
1320+
},
1321+
);
1322+
1323+
it(
1324+
'does NOT refuse a rename into a populated target when no property name is shared',
1325+
{ timeout: SPAWN_TIMEOUT_MS },
1326+
() => {
1327+
// The cost this guard can impose, pinned: the target def is populated in
1328+
// the base (six other keys survive the filter below), and the carried
1329+
// property name is not one of them. `Map.set` collapses only entries that
1330+
// are the SAME key, so this merge writes every key exactly once and loses
1331+
// nothing. Refusing a populated target as such would redden most of the
1332+
// committed table.
1333+
const baseKeys = [
1334+
...pristineSurface.filter((k) => k !== CARRIED_BY_RENAME),
1335+
DELETED_BY_RENAME,
1336+
].sort();
1337+
const targetDef = RENAMED_DEFS[DELETED_BY_RENAME_SOURCE_DEF];
1338+
expect(
1339+
baseKeys.filter((k) => k.startsWith(`${targetDef}:`)).length,
1340+
'the target def must still hold keys in the base, or this pins nothing',
1341+
).toBeGreaterThan(0);
1342+
seedBase(() => baseKeys);
1343+
seedSurface((s) => s);
1344+
1345+
const { status, output } = run(['--check']);
1346+
1347+
expect(output).not.toContain('would COLLAPSE keys');
1348+
expect(status).toBe(0);
1349+
},
1350+
);
12791351
});
12801352

12811353
// ─────────────────────────────────────────────────────────────────────────────

packages/spec/scripts/build-schemas.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ import {
1717
formatDefKeyCollisions,
1818
type EmittedDef,
1919
} from './lib/def-key-collisions';
20-
import { RENAMED_DEFS, carryAuthorableKey, checkRenameTable } from './lib/renamed-defs';
20+
import {
21+
RENAMED_DEFS,
22+
carryAuthorableKey,
23+
checkRenameBaselineCollisions,
24+
checkRenameTable,
25+
} from './lib/renamed-defs';
2126
// The Zod-graph walkers the authorable-surface reachability BFS runs on. Extracted
2227
// at #5317 so the pipe-direction rule (#4488) is assertable without running the
2328
// whole generator — see scripts/zod-graph.test.ts.
@@ -871,11 +876,41 @@ try {
871876
process.exit(1);
872877
}
873878

879+
/**
880+
* Refuse a declared rename that would COLLAPSE two of a baseline's own keys
881+
* (#17383). Every carry below is a plain `Map.set` keyed by the carried key, so
882+
* a rename whose source and target both hold the same property name in this
883+
* baseline loses one of the two recorded facts — its retired state and its
884+
* default — before any comparison runs. `checkRenameTable` cannot see this: it
885+
* validates the table against the defs this build EMITS, and the damage lives
886+
* in the baseline. Called once per baseline this script carries, because the
887+
* in-tree snapshot and the upstream anchor are different documents and a
888+
* collision can exist in either alone.
889+
*/
890+
function assertNoRenameBaselineCollisions(label: string, baselineKeys: Iterable<string>): void {
891+
const problems = checkRenameBaselineCollisions(baselineKeys);
892+
if (problems.length === 0) return;
893+
console.error(
894+
`\n❌ ${problems.length} declared def rename(s) would COLLAPSE keys of the ${label}:`,
895+
);
896+
for (const p of problems) console.error(` - ${p}`);
897+
console.error(
898+
`\n A rename may MOVE keys; it may never merge two of them onto one name. The carry\n` +
899+
` runs before every ratchet below, so a collapsed key makes the diff they report a\n` +
900+
` diff against input this script already corrupted — in both directions: a real\n` +
901+
` default change on the merged key can read as no change, and a key whose default\n` +
902+
` never moved can read as changed. See scripts/lib/renamed-defs.ts (#4684, #17383).`,
903+
);
904+
process.exit(1);
905+
}
906+
874907
if (surfaceDoc) {
875908
const snapshot = new Map<string, boolean>(
876909
surfaceDoc.keys.map((e) => [e.replace(RETIRED_MARK, ''), e.endsWith(RETIRED_MARK)]),
877910
);
878911

912+
assertNoRenameBaselineCollisions(`committed ${SURFACE_FILE_NAME}`, snapshot.keys());
913+
879914
// Carry the snapshot through any declared def rename FIRST, so every check
880915
// below compares like with like. A rename moves keys between defs; it must
881916
// never be able to drop one, and it must never launder a retirement past
@@ -2137,6 +2172,10 @@ let gitResolvedAnchor: { rev: string; keys: string[] } | null = null;
21372172
if (base) {
21382173
// Carry base keys through declared def renames first — same discipline as
21392174
// the snapshot carry above — so a rename is never misread as a deletion.
2175+
assertNoRenameBaselineCollisions(
2176+
`upstream baseline ${base.rev.slice(0, 12)}`,
2177+
(base.doc.keys ?? []).map((entry) => entry.replace(RETIRED_MARK, '')),
2178+
);
21402179
const baseSnapshot = new Map<string, boolean>();
21412180
for (const entry of base.doc.keys ?? []) {
21422181
const key = entry.replace(RETIRED_MARK, '');

packages/spec/scripts/lib/renamed-defs.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,87 @@ export function carryAuthorableKey(
245245
* The last two rules are about entries *interacting*, and only bind once the
246246
* table holds more than one entry — which #4703 is the first change to do.
247247
*/
248+
/**
249+
* Property names a recorded baseline holds under BOTH a rename's source def and
250+
* its target def — the keys the carry would silently collapse (#17383).
251+
*
252+
* ## Why this is a separate rule from the four in {@link checkRenameTable}
253+
*
254+
* That function validates the table against the defs a build EMITS, which is
255+
* all it can see at its call site. The damage here is not visible there at all:
256+
* it lives in the BASELINE, and it is reached by a single, perfectly well-formed
257+
* rename. `A → B` where the baseline already records `B:mode` is not two sources
258+
* onto one target, so the merge rule never sees it; the source is gone and the
259+
* target is emitted, so the decay rules pass; and every carry in
260+
* `build-schemas.ts` is a plain `Map.set` keyed by the CARRIED key, so
261+
* `A:mode` and `B:mode` land on the same entry and the later write wins.
262+
*
263+
* The damage is the same one the two-sources rule already refuses, reached by
264+
* one rename instead of two: the surviving entry keeps only one of the two
265+
* recorded RETIRED states and only one of the two recorded DEFAULTS, and the
266+
* loss happens INSIDE the carry, before any comparison runs. So every gate
267+
* downstream — check (b)'s live → retired transition, the deletion gate, the
268+
* authorable-defaults differ — adjudicates against already-clobbered input, in
269+
* both directions: a genuine default change on the merged key can read as no
270+
* change at all, and a key whose default never moved can read as `changed`.
271+
*
272+
* ## What it deliberately does NOT refuse
273+
*
274+
* A rename onto a def that already exists is a legitimate, in-tree shape —
275+
* `cloud/Sha256Digest → system/Sha256Digest` is one, and 24 of the committed
276+
* entries have a target that already holds baseline keys once the surface
277+
* snapshot has been regenerated under the new name. None of that loses
278+
* anything: `Map.set` can only collapse two entries that are the SAME key, so a
279+
* merge whose source and target share no property NAME writes every key exactly
280+
* once. Refusing a populated target as such would redden the committed table;
281+
* the collision — the intersection — is the whole of the damage and the whole
282+
* of what is refused.
283+
*
284+
* Returns one problem line per colliding entry; empty means this baseline
285+
* survives the carry intact.
286+
*/
287+
export function checkRenameBaselineCollisions(
288+
baselineKeys: Iterable<string>,
289+
renames: Readonly<Record<string, string>> = RENAMED_DEFS,
290+
): string[] {
291+
const propsByDef = new Map<string, Set<string>>();
292+
for (const key of baselineKeys) {
293+
const sep = key.indexOf(':');
294+
// A bare def key names no property, so it cannot collide with one.
295+
if (sep < 0) continue;
296+
const def = key.slice(0, sep);
297+
let props = propsByDef.get(def);
298+
if (props === undefined) propsByDef.set(def, (props = new Set<string>()));
299+
props.add(key.slice(sep + 1));
300+
}
301+
const problems: string[] = [];
302+
for (const [from, to] of Object.entries(renames)) {
303+
// A self-rename collides with itself on every key; rule 1 of
304+
// `checkRenameTable` already names it, and a second line would bury it.
305+
if (from === to) continue;
306+
const source = propsByDef.get(from);
307+
const target = propsByDef.get(to);
308+
if (source === undefined || target === undefined) continue;
309+
const collisions = [...source].filter((prop) => target.has(prop)).sort();
310+
if (collisions.length === 0) continue;
311+
problems.push(
312+
`${from}${to}: the baseline already records ${collisions.length} of this ` +
313+
`rename's property name(s) under the TARGET def — ${collisions.join(', ')}. ` +
314+
`Carrying the rename collapses each pair onto one key (last write wins), and ` +
315+
`takes the losing side's recorded retired state and recorded default with it. ` +
316+
`That is the same damage the two-sources-onto-one-target rule refuses, reached ` +
317+
`by one rename instead of two, and it happens INSIDE the carry — before any ` +
318+
`comparison runs — so the diff this build reports is computed against clobbered ` +
319+
`input. Converging two defs on a shared property name is a real change: keep the ` +
320+
`rename, and retire the losing side explicitly with \`retiredKey()\` plus its ` +
321+
`registered ADR-0087 conversion, exactly as a retirement without a rename would ` +
322+
`require. A merge whose two defs share no property name is lossless and is not ` +
323+
`refused here.`,
324+
);
325+
}
326+
return problems;
327+
}
328+
248329
export function checkRenameTable(
249330
emittedDefs: ReadonlySet<string>,
250331
renames: Readonly<Record<string, string>> = RENAMED_DEFS,

packages/spec/scripts/renamed-defs.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { describe, it, expect } from 'vitest';
1818
import {
1919
RENAMED_DEFS,
2020
carryAuthorableKey,
21+
checkRenameBaselineCollisions,
2122
checkRenameTable,
2223
} from './lib/renamed-defs';
2324

@@ -137,6 +138,112 @@ describe('checkRenameTable', () => {
137138
});
138139
});
139140

141+
describe('checkRenameBaselineCollisions — a rename may MOVE keys, never MERGE them (#17383)', () => {
142+
const renames = { 'integration/Old': 'integration/New' } as const;
143+
144+
// The shape the four rules of `checkRenameTable` cannot see. It is ONE rename,
145+
// so the two-sources rule never fires; the source is unemitted and the target
146+
// is emitted, so both decay rules pass. The damage is in the BASELINE, and
147+
// every carry in build-schemas.ts is a plain `Map.set` on the carried key.
148+
it('refuses a rename whose target already holds the same property name, and names it', () => {
149+
const problems = checkRenameBaselineCollisions(
150+
['integration/New:mode', 'integration/Old:mode'],
151+
renames,
152+
);
153+
expect(problems).toHaveLength(1);
154+
expect(problems[0]).toContain('integration/Old → integration/New');
155+
expect(problems[0]).toContain('under the TARGET def — mode');
156+
// The remedy must be the one the two-sources rule already prescribes.
157+
expect(problems[0]).toContain('retiredKey()');
158+
});
159+
160+
it('is silent where checkRenameTable is loud, and loud where it is silent', () => {
161+
// The discriminator, stated as one assertion: the SAME baseline damage is
162+
// invisible to the emitted-def rules, which is why this rule exists.
163+
const baseline = ['integration/New:mode', 'integration/Old:mode'];
164+
expect(checkRenameTable(new Set(['integration/New']), renames)).toEqual([]);
165+
expect(checkRenameBaselineCollisions(baseline, renames)).toHaveLength(1);
166+
});
167+
168+
it('reports EVERY colliding property, sorted, not just the first', () => {
169+
const problems = checkRenameBaselineCollisions(
170+
[
171+
'integration/New:alpha', 'integration/New:beta', 'integration/New:gamma',
172+
'integration/Old:beta', 'integration/Old:alpha', 'integration/Old:delta',
173+
],
174+
renames,
175+
);
176+
expect(problems).toHaveLength(1);
177+
expect(problems[0]).toContain('2 of this');
178+
expect(problems[0]).toContain('alpha, beta');
179+
});
180+
181+
// ─── What it must NOT refuse — the cost this rule can impose ─────────────
182+
// A `Map.set` can only collapse two entries that are the SAME key, so a merge
183+
// whose two defs share no property NAME writes every key exactly once and
184+
// loses nothing. Refusing a populated target as such would redden 24 of the
185+
// committed entries, whose targets all hold keys once the surface snapshot has
186+
// been regenerated under the new name — and it would forbid the in-tree
187+
// `cloud/Sha256Digest → system/Sha256Digest` shape outright.
188+
189+
it('ACCEPTS a merge into a populated target whose property names are disjoint', () => {
190+
expect(
191+
checkRenameBaselineCollisions(
192+
['integration/New:kept', 'integration/Old:moved'],
193+
renames,
194+
),
195+
).toEqual([]);
196+
});
197+
198+
it('ACCEPTS the landing shape: the baseline holds the keys under the SOURCE only', () => {
199+
// The real `authorable-surface.base.json` shape while a rename lands — the
200+
// upstream anchor predates it, so the target has no keys there at all.
201+
expect(
202+
checkRenameBaselineCollisions(['integration/Old:mode', 'integration/Old:other'], renames),
203+
).toEqual([]);
204+
});
205+
206+
it('ACCEPTS the settled shape: the baseline holds the keys under the TARGET only', () => {
207+
// The committed `authorable-surface/` shape after regeneration — the entry is
208+
// inert against the snapshot but still enforces the hygiene invariants.
209+
expect(
210+
checkRenameBaselineCollisions(['integration/New:mode', 'integration/New:other'], renames),
211+
).toEqual([]);
212+
});
213+
214+
it('matches the def exactly — a def that merely shares a prefix is not the target', () => {
215+
expect(
216+
checkRenameBaselineCollisions(['integration/NewThing:mode', 'integration/Old:mode'], renames),
217+
).toEqual([]);
218+
});
219+
220+
it('ignores bare def keys, which name no property and so can collide with none', () => {
221+
expect(checkRenameBaselineCollisions(['integration/New', 'integration/Old'], renames)).toEqual([]);
222+
});
223+
224+
it('splits on the FIRST separator, so a property containing ":" still collides', () => {
225+
const problems = checkRenameBaselineCollisions(
226+
['integration/New:a:b', 'integration/Old:a:b'],
227+
renames,
228+
);
229+
expect(problems).toHaveLength(1);
230+
expect(problems[0]).toContain('a:b');
231+
});
232+
233+
it('leaves a self-rename to checkRenameTable rather than reporting it twice', () => {
234+
// Every key of a self-rename collides with itself; rule 1 already names the
235+
// entry, and a second line would bury the real diagnosis.
236+
expect(
237+
checkRenameBaselineCollisions(['integration/Old:mode'], {
238+
'integration/Old': 'integration/Old',
239+
}),
240+
).toEqual([]);
241+
expect(
242+
checkRenameTable(new Set(['integration/Old']), { 'integration/Old': 'integration/Old' }),
243+
).toHaveLength(1);
244+
});
245+
});
246+
140247
describe('the committed RENAMED_DEFS table', () => {
141248
it('no longer carries the #4684 connector rate-limit rename — #4911 absorbed it', () => {
142249
// The #4684 rename (`integration/RateLimitConfig` →

0 commit comments

Comments
 (0)