Skip to content

Commit 24d521e

Browse files
refactor(spec): step 18 rationale as key-sorted fragments, conversionIds derived, so two retirements merge clean (#20572)
Fixes #20535 Clause-②: no Every major-18 retirement appended to two tails of `step18` in `packages/spec/src/migrations/registry.ts`: the `+`-chained `rationale` (by rewriting its closing line) and the `conversionIds` list. So any two retirement PRs in flight conflicted in GitHub's driver-free merge. This PR reshapes both tails so that two retirements no longer touch the same line. Nothing a consumer reads changes: the values are byte-identical. ## What changed - **`rationale` is now `STEP18_RATIONALE`.** It holds 46 fragments of the form `{ id, order, text }`, one per retirement. The list is kept **sorted by `id`**, and the rationale renders by `order` (ties broken by `id`), joined with one space (`joinRationale`). Of the 46 keys, 40 are the retirement's own D3 semantic entry id. The other 6 are kebab-case names for retirements with no entry of their own: `compliance-deadline-keys-retired`, `cron-positions-deleted`, `duration-keys-unit-in-key`, `element-filter-retired`, `element-form-retired`, `page-component-filter-record-to-rule-array`. The fragments keep the original literal source bytes; only the 45 boundary spaces moved into the join. - **`conversionIds` is derived:** the ids of `CONVERSIONS_BY_MAJOR[18]`, in its order. It was a value-identical copy of that list (same 45 ids, same order), so a retirement now adds its conversion in one place only. This adds a second value import to `registry.ts`. It creates no cycle: `conversions/registry.ts` imports nothing from `migrations/`, and it was already in the migrations barrel's graph through `chain.ts`. - The header note and the import comment now describe step 18's shape. `MigrationStep`'s type is unchanged, and no reader of `rationale` changed. ## Why sorted, and not the plain array the triage sketched (mechanism measured, then the route changed) Git reports a conflict whenever two branches insert into the **same gap** between unchanged lines, whatever they insert. A list appended at its end is a single gap, so a plain array conflicts exactly as the old tail did. I measured this on a toy file and again on the real file (the pin's end-append control below): exit 1. With the list kept sorted by key, two retirements insert into different gaps and merge clean. One existing fragment between them is enough, the same property `.gitattributes` records for the sorted generated tables. Two keys that land in the same gap still conflict. That residue is pinned as a lit control. ## Rendered-text proof (byte-identical) | value | parent `6154165484` | head | |---|---|---| | `MIGRATIONS_BY_MAJOR[18].rationale` | 48,953 chars, sha256 `797afbe924eef185…75828e10` | identical | | `MIGRATIONS_BY_MAJOR[18].conversionIds` | 45 ids, sha256 `55d56175bf7c109c…` | identical | | chain hop 17 → 18 `rationale` (what `migrate meta --step` prints) | `797afbe924eef185…` | identical | | the whole `MIGRATIONS_BY_MAJOR` value as JSON | `d989a2b827fd7f93…` | identical | | built `dist/index.js` + `dist/browser/index.js` (CJS) and `dist/index.mjs` (ESM) | n/a | load; `797afbe9…` / 45 ids `55d56175…` | No committed artifact embeds step 18's rationale: the upgrade guide prints majors up to `PROTOCOL_MAJOR` (17). `check:upgrade-guide`, `check:spec-changes` and `check:migration-registry` are green. A closure check on the built bundles: all four bundles that carry step 18 (`dist/index.{js,mjs}` and `dist/browser/index.{js,mjs}`) already carried the conversions registry. The marker was `page-kind-jsx-to-html`, which no other non-test `src` module contains. So the new import widens no entry's closure. ## Merge measurement: the card's instrument A one-shot run on the **parent** `6154165484`, with git 2.43.0, in a scratch repo holding the real file with no attributes and no driver. Each side makes the edit a retirement PR makes: | pair | `git merge-tree --write-tree` | |---|---| | two rationale-tail rewrites (closing line rewritten, sentence appended) | **exit 1**, CONFLICT (content) | | two `conversionIds` tail appends | **exit 1**, CONFLICT (content) | | both edits on each side | **exit 1**, CONFLICT (content) | The **permanent pin** is `packages/spec/scripts/step18-rationale-merge.test.ts`, in the repo project beside `count-shards-merge.test.ts` (PR #20532), and it works against the REAL file. It asserts: - The premise: fragments are strictly sorted and kebab-case; the step renders them by `order`, joined with one space (so this compares the join, not the list); and `conversionIds` is the derived expression. - The card's reproduction, now clean: two retirement-shaped insertions one existing fragment apart, both taking the same next `order` → **exit 0**. The merged bytes equal both insertions applied together, and the two render last, in key order. - Lit controls, all **exit 1** with conflicted path `registry.ts`: a same-gap pair; the same two fragments appended at the list's END; and the old `+`-chain tail rewrite (a synthetic model of the parent shape). The one open PR on this file, PR #20504 (a step-18 semantic entry in a generated region), merges clean with this head: bare shared-clone probe with no driver, `merge-tree` exit 0. ## How a retirement adds its sentence once this lands Add ONE element to `STEP18_RATIONALE`: - `id` is the retirement's D3 semantic entry id. - Insert it where that `id` sorts, **never at the end**. - `order` is one more than the highest present. Two PRs in flight may take the same number; they then render in `id` order. - `text` has no leading or trailing space. Add the D2 conversion to `CONVERSIONS_BY_MAJOR[18]` only. A branch cut before this lands meets the change once, on its next base merge: its appended sentence becomes one new fragment, and its `conversionIds` line is dropped. ## Tests and gates (final commit `bcb255881a`; `registry.ts` blob `2f010628be9a` unchanged since `2e6251af0c`) - `@objectstack/spec` `local` project: 574 files, 16,879 passed, 1 todo (exit 0). `repo` project: 41 files, 725 passed (exit 0). Both ran through `os-verify-lock`, `--maxWorkers=2`, on a shared box. - `pnpm --filter @objectstack/spec typecheck`: exit 0 (includes `check:scripts-typecheck` and `check:test-typecheck`). `check:generated`: 15 of 15 artifacts up to date, measured against the `dist` built at this head. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`, reconciled with `--ran` (exit codes recorded): 88 derived, **84 exit 0**, 4 NOT MEASURED, 0 unrun. - NOT MEASURED (exit 3, `PREREQUISITE NOT MET`: they need the whole-repo build closure, which CI builds): - `check:doc-formula-expressions`: needs `@objectstack/formula` and `@objectstack/lint` built. - `check:dual-build-cjs-loads`: needs all packages built. Narrowed reading: spec's own built CJS entries load (table above). - `check:lean-entry-closure`: needs `@objectstack/objectql` built. - `check:type-check-debt`: needs the whole-repo build closure. Spec's own typecheck is green. - Declared to CI: `packages/cli/test/migrate-meta-default-range.test.ts`. It spawns the CLI (integration tier, and this diff touches no CLI file), it passes no `--step`, and it reads the spec values proven identical above. Repo-level `pnpm lint` is CI-owned. - **Ablations** (one-shot; each through `scripts/ablation-replace.mjs` with the anchor proven to hit, and restored to the HEAD blob with `git diff HEAD` empty): 1. Deleting the `order` sort in `joinRationale` (render by position): the pin went **red**, 1 failed / 8 passed, on the render-order assertion. 2. Renaming the first key `action-aria-retired` to `zz-action-aria-retired`: **red**, 3 failed / 6 passed. The sortedness assertion failed, plus the two that depend on a sorted list. The first attempt was a no-op the tool refused, because the anchor also matched the D3 entry of the same id and nothing was written. It was re-run with a longer anchor. ## Acceptance notes - **Governed wording to route to the skills lane (not edited here):** `.claude/skills/spec-property-retirement/SKILL.md:215-216` reads 「把 id 加进 `MIGRATIONS_BY_MAJOR[N].conversionIds`,扩写该步的 `rationale`。」. For N = 18 that becomes: add a `STEP18_RATIONALE` fragment at its sorted position, and add the conversion only to `CONVERSIONS_BY_MAJOR[18]`. Lines 217-219 (a misspelled step id is silently skipped at replay) no longer apply to step 18, whose ids are derived. - **Same-family residue outside this card's file surface:** `packages/spec/src/conversions/registry.ts` has the same tail. Every retirement with a D2 conversion appends to `CONVERSIONS_BY_MAJOR[18]` (and usually defines its conversion just above the previous last one). Two synthetic appends to that tail, on parent `6154165484`: `merge-tree` **exit 1**, CONFLICT (content). So after this lands, such PRs still conflict in that file. Only the migrations-registry half is removed here. The order of that list is application order, so the shape there is its own decision. Reported to the seat, not filed. - **Choice surfaced for review:** I derived `conversionIds` instead of giving it the keyed fragment treatment. A keyed copy would keep a second hand-kept order, which can drift from the loader's: step 17's copy names the same 57 ids in a different order from index 21 on. It would also let two concurrent conversions tie-break by key instead of by the author's chosen application order. - The sortedness check is an assertion in the new repo-project test, not a `check:*` gate. It is what makes an end-append fail loudly instead of quietly bringing the conflict back. - A side effect, not claimed as a goal: step 18's rationale was one `+` chain of 614 literals, and its longest chain is now 28. Step 17's 970-literal chain (the `eslint.config.mjs` stack-size note) is untouched. --- _Generated by [Claude Code](https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f11b5f2 commit 24d521e

4 files changed

Lines changed: 1212 additions & 664 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
refactor(spec): protocol 18's migration step keeps its `rationale` as key-sorted fragments and derives its `conversionIds` — no value changes (#20535)
6+
7+
Nothing a consumer reads changes. `MIGRATIONS_BY_MAJOR[18].rationale` (48,953
8+
characters), `MIGRATIONS_BY_MAJOR[18].conversionIds` (45 ids, same order) and the
9+
whole `MIGRATIONS_BY_MAJOR` value are byte-identical to the previous release, and so
10+
is the rationale `migrate meta` prints for the 17 → 18 hop.
11+
12+
What changed is how the step is written, so two major-18 retirements can be in
13+
flight at once without conflicting in `packages/spec/src/migrations/registry.ts`:
14+
15+
- The rationale is `STEP18_RATIONALE`, one `{ id, order, text }` fragment per
16+
retirement, kept sorted by `id` and rendered by `order`, joined with one space.
17+
A retirement adds ONE fragment where its `id` (its D3 semantic entry id) sorts —
18+
never at the end — with `order` one more than the highest present.
19+
- `conversionIds` is read off `CONVERSIONS_BY_MAJOR[18]`, which it copied value
20+
for value. A retirement adds its conversion there only.
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Step 18's rationale, asked of git the way GitHub asks it (#20535).
4+
//
5+
// WHY THIS TEST SPAWNS GIT. The defect was never in a value: it was in a MERGE.
6+
// Every major-18 retirement PR appended its sentences to `step18.rationale` in
7+
// `src/migrations/registry.ts` — one `+` chain whose closing line each PR
8+
// rewrote — and its id to the end of `step18.conversionIds`, so any two in
9+
// flight conflicted in GitHub's server-side merge, which runs no driver and
10+
// decides `mergeable`. The file is hand-written, so nothing regenerates it.
11+
//
12+
// The cure is a SHAPE, and a shape is only proven by merging it. Git conflicts
13+
// on any two insertions into the same gap between unchanged lines, so a plain
14+
// array appended at its end conflicts exactly as the old tail did; the
15+
// fragments are kept sorted by `id` instead, so two retirements land in
16+
// different gaps, and `order` says where each renders. This file holds both
17+
// halves: the real registry is in that shape, and two retirement-shaped
18+
// insertions into the REAL file merge clean and right, with no driver. The
19+
// same-gap pair, the end-appended pair and the old `+`-chain tail are the lit
20+
// controls — each MUST conflict, or a clean result would be equally explained
21+
// by a harness that cannot see a conflict at all.
22+
23+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
24+
import { spawnSync } from 'node:child_process';
25+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
26+
import { tmpdir } from 'node:os';
27+
import path from 'node:path';
28+
import { fileURLToPath } from 'node:url';
29+
import { runInNewContext } from 'node:vm';
30+
31+
import { gitFreeEnv } from '../../../scripts/git-env.mjs';
32+
33+
import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry';
34+
import { MIGRATIONS_BY_MAJOR } from '../src/migrations/registry';
35+
36+
const HERE = path.dirname(fileURLToPath(import.meta.url));
37+
const REGISTRY_PATH = path.resolve(HERE, '../src/migrations/registry.ts');
38+
/** Where the file sits in the fixture repo — the real relative path, so a failure names it. */
39+
const REL = 'packages/spec/src/migrations/registry.ts';
40+
41+
const OPEN = 'const STEP18_RATIONALE: readonly RationaleFragment[] = [\n';
42+
const CLOSE = '];\n';
43+
/** One element, exactly as the registry spells it: the key, the render order, then the literal chain. */
44+
const ELEMENT = / \{\n id: '([^'\n]*)',\n order: (\d+),\n text:\n((?: [^\n]*\n)+?) \},\n/y;
45+
const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
46+
47+
interface Fragment {
48+
id: string;
49+
order: number;
50+
text: string;
51+
/** Offset of the element's first line in the file. */
52+
at: number;
53+
}
54+
55+
/** The fragments of `STEP18_RATIONALE`, parsed from source text — every byte of the list accounted for. */
56+
function fragmentsOf(source: string): { fragments: Fragment[]; end: number } {
57+
const start = source.indexOf(OPEN);
58+
expect(start, 'the STEP18_RATIONALE declaration').toBeGreaterThan(-1);
59+
const fragments: Fragment[] = [];
60+
let at = start + OPEN.length;
61+
for (;;) {
62+
ELEMENT.lastIndex = at;
63+
const m = ELEMENT.exec(source);
64+
if (!m) break;
65+
const expr = m[3]!.trim().replace(/,$/, '');
66+
fragments.push({ id: m[1]!, order: Number(m[2]), text: runInNewContext(`(${expr})`) as string, at });
67+
at = ELEMENT.lastIndex;
68+
}
69+
// No residue: the list ends where the last element does, so a fragment
70+
// spelled any other way cannot hide from the checks below.
71+
expect(source.slice(at, at + CLOSE.length), `unparsed text in STEP18_RATIONALE at offset ${at}`).toBe(CLOSE);
72+
return { fragments, end: at };
73+
}
74+
75+
/** A retirement-shaped edit: one new element, inserted where its key sorts — what the list's doc comment asks for. */
76+
function insertSorted(source: string, id: string, order: number, sentence: string): string {
77+
const { fragments, end } = fragmentsOf(source);
78+
const next = fragments.find((f) => f.id > id);
79+
const at = next ? next.at : end;
80+
const element = ` {\n id: '${id}',\n order: ${order},\n text:\n '${sentence}',\n },\n`;
81+
return source.slice(0, at) + element + source.slice(at);
82+
}
83+
84+
/** The shape the triage's first sketch named: the same element appended at the list's END. */
85+
function appendAtEnd(source: string, id: string, order: number, sentence: string): string {
86+
const { end } = fragmentsOf(source);
87+
const element = ` {\n id: '${id}',\n order: ${order},\n text:\n '${sentence}',\n },\n`;
88+
return source.slice(0, end) + element + source.slice(end);
89+
}
90+
91+
/** Every fixture git is LOCAL-ONLY and hermetic: no inherited `GIT_*`, no global or system config. */
92+
const HERMETIC_ENV: NodeJS.ProcessEnv = (() => {
93+
const env = gitFreeEnv();
94+
env.GIT_CONFIG_GLOBAL = '/dev/null';
95+
env.GIT_CONFIG_SYSTEM = '/dev/null';
96+
env.GIT_CONFIG_NOSYSTEM = '1';
97+
return env;
98+
})();
99+
100+
const GIT_ARGS = ['-c', 'user.name=t', '-c', 'user.email=t@example.invalid', '-c', 'gc.auto=0', '-c', 'maintenance.auto=false'];
101+
102+
/** A throwaway repository holding one file at `REL` — no attributes and no driver, which is what GitHub's merge sees. */
103+
class FileRepo {
104+
readonly root = mkdtempSync(path.join(tmpdir(), 'os-step18-rationale-merge-'));
105+
106+
constructor(readonly base: string) {
107+
this.must('init', '-q', '-b', 'base');
108+
this.write(base);
109+
this.must('add', '-A');
110+
this.must('commit', '-q', '-m', 'base');
111+
}
112+
113+
private write(text: string): void {
114+
mkdirSync(path.dirname(path.join(this.root, REL)), { recursive: true });
115+
writeFileSync(path.join(this.root, REL), text);
116+
}
117+
118+
git(...args: string[]): { status: number | null; stdout: string; stderr: string } {
119+
const r = spawnSync('git', [...GIT_ARGS, ...args], { cwd: this.root, encoding: 'utf8', env: HERMETIC_ENV, maxBuffer: 1 << 28 });
120+
if (r.error) throw r.error;
121+
return { status: r.status, stdout: r.stdout, stderr: r.stderr };
122+
}
123+
124+
must(...args: string[]): string {
125+
const r = this.git(...args);
126+
expect(r.status, `git ${args.join(' ')}\n${r.stderr}`).toBe(0);
127+
return r.stdout;
128+
}
129+
130+
/** Commit `text` on a new branch cut from `base`. */
131+
branch(name: string, text: string): void {
132+
expect(text, `branch ${name} must change the file`).not.toBe(this.base);
133+
this.must('checkout', '-q', '-b', name, 'base');
134+
this.write(text);
135+
this.must('commit', '-q', '-am', name);
136+
}
137+
138+
/** `git merge-tree --write-tree` of two branches: exit code, tree, and the paths it names on a conflict. */
139+
merge(a: string, b: string): { status: number | null; tree: string; conflicted: string[] } {
140+
const r = this.git('merge-tree', '--write-tree', '--name-only', '--no-messages', a, b);
141+
const [tree = '', ...rest] = r.stdout.trim().split('\n');
142+
return { status: r.status, tree, conflicted: rest.filter(Boolean) };
143+
}
144+
145+
/** The merged file's bytes. */
146+
merged(tree: string): string {
147+
return this.must('show', `${tree}:${REL}`);
148+
}
149+
150+
dispose(): void {
151+
rmSync(this.root, { recursive: true, force: true });
152+
}
153+
}
154+
155+
const SOURCE = readFileSync(REGISTRY_PATH, 'utf8');
156+
const { fragments: REAL } = fragmentsOf(SOURCE);
157+
const IDS = REAL.map((f) => f.id);
158+
const NEXT_ORDER = Math.max(...REAL.map((f) => f.order)) + 1;
159+
160+
/** How many existing keys sort before `id` — the gap an insertion lands in. */
161+
const gapOf = (id: string): number => IDS.filter((x) => x < id).length;
162+
163+
describe('step 18 in the registry — the shape the merge needs (#20535)', () => {
164+
it('the rationale is a list of fragments, not one string', () => {
165+
expect(REAL.length).toBeGreaterThan(2);
166+
});
167+
168+
it('the fragments are kept SORTED by key, one key each, all kebab-case — so insertions spread over the list', () => {
169+
const outOfPlace = IDS.flatMap((id, i) => (i > 0 && !(IDS[i - 1]! < id) ? [`${IDS[i - 1]} → ${id}`] : []));
170+
expect(
171+
outOfPlace,
172+
'STEP18_RATIONALE must stay sorted by `id` (strictly: keys are unique). A fragment added at the END puts '
173+
+ 'every retirement in one gap, and two in flight conflict again. Move it to where its id sorts.',
174+
).toEqual([]);
175+
expect(IDS.filter((id) => !KEBAB.test(id))).toEqual([]);
176+
});
177+
178+
it('`order` places each fragment, and the step renders them in that order, joined with one space', () => {
179+
for (const f of REAL) {
180+
expect(Number.isInteger(f.order) && f.order > 0, `${f.id}: order ${f.order}`).toBe(true);
181+
expect(f.text.length, `${f.id}: empty text`).toBeGreaterThan(0);
182+
expect(f.text, `${f.id}: a leading or trailing space doubles at the join`).toBe(f.text.trim());
183+
}
184+
const byOrder = [...REAL].sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : 1));
185+
// Anti-vacuity: render order differs from key order, so this compares the join, not the list.
186+
expect(byOrder.map((f) => f.id)).not.toEqual(IDS);
187+
expect(MIGRATIONS_BY_MAJOR[18]!.rationale).toBe(byOrder.map((f) => f.text).join(' '));
188+
});
189+
190+
it('`conversionIds` is read off the conversions registry — no second list for a retirement to append to', () => {
191+
const step18 = SOURCE.slice(SOURCE.indexOf('const step18: MigrationStep = {'));
192+
const head = step18.slice(0, step18.indexOf(' semantic: ['));
193+
expect(head).toContain(' conversionIds: CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id),\n');
194+
expect(head).not.toMatch(/conversionIds: \[/);
195+
expect(MIGRATIONS_BY_MAJOR[18]!.conversionIds).toEqual(CONVERSIONS_BY_MAJOR[18]!.map((c) => c.id));
196+
});
197+
});
198+
199+
describe('step 18 in the registry — two retirements, no merge driver (#20535)', () => {
200+
// Two keys one existing fragment apart — the closest two DIFFERENT gaps get —
201+
// and a third key in the first one's gap. Derived from the real list (the
202+
// first `k` from the middle where the suffixed keys land where intended), so
203+
// the pair keeps meaning what it says as retirements land.
204+
const keysAt = (i: number) => [`${IDS[i - 1]}-synthetic-a`, `${IDS[i]}-synthetic-b`, `${IDS[i - 1]}-synthetic-c`] as const;
205+
const k = [...IDS.keys()]
206+
.filter((i) => i > 0)
207+
.sort((x, y) => Math.abs(x - IDS.length / 2) - Math.abs(y - IDS.length / 2))
208+
.find((i) => {
209+
const [a, b, c] = keysAt(i);
210+
return gapOf(a) === i && gapOf(b) === i + 1 && gapOf(c) === i;
211+
}) ?? -1;
212+
const [ID_A, ID_B, ID_SAME_GAP] = keysAt(Math.max(k, 1));
213+
const SENTENCE_A = 'It also retires the synthetic key a (ADR-0049 enforce-or-remove).';
214+
const SENTENCE_B = 'It also retires the synthetic key b (ADR-0049 enforce-or-remove).';
215+
const SENTENCE_C = 'It also retires the synthetic key c (ADR-0049 enforce-or-remove).';
216+
let repo: FileRepo;
217+
218+
beforeAll(() => {
219+
repo = new FileRepo(SOURCE);
220+
// Both in flight take the same next `order`, as two PRs cut from one base do.
221+
repo.branch('sorted-a', insertSorted(SOURCE, ID_A, NEXT_ORDER, SENTENCE_A));
222+
repo.branch('sorted-b', insertSorted(SOURCE, ID_B, NEXT_ORDER, SENTENCE_B));
223+
repo.branch('sorted-same-gap', insertSorted(SOURCE, ID_SAME_GAP, NEXT_ORDER, SENTENCE_C));
224+
repo.branch('end-a', appendAtEnd(SOURCE, ID_A, NEXT_ORDER, SENTENCE_A));
225+
repo.branch('end-b', appendAtEnd(SOURCE, ID_B, NEXT_ORDER, SENTENCE_B));
226+
});
227+
afterAll(() => repo.dispose());
228+
229+
it('the pair is what it claims: adjacent gaps, one existing fragment between them, a third key sharing the first gap', () => {
230+
expect(k, 'no position in the list yields such a pair').toBeGreaterThan(0);
231+
expect(gapOf(ID_A)).toBe(k);
232+
expect(gapOf(ID_B)).toBe(k + 1);
233+
expect(gapOf(ID_SAME_GAP)).toBe(k);
234+
expect(repo.git('config', '--get', 'merge.os-regen.driver').status).toBe(1);
235+
});
236+
237+
// THE CARD'S REPRODUCTION, now clean.
238+
it('two retirements inserted where their keys sort: merge clean, and the result is both insertions', () => {
239+
const m = repo.merge('sorted-a', 'sorted-b');
240+
expect(m.status, m.conflicted.join('\n')).toBe(0);
241+
const expected = insertSorted(insertSorted(SOURCE, ID_A, NEXT_ORDER, SENTENCE_A), ID_B, NEXT_ORDER, SENTENCE_B);
242+
expect(repo.merged(m.tree) === expected, 'the merged file is not the two insertions applied together').toBe(true);
243+
// Equal `order` from a shared base renders in key order, after everything already there.
244+
const merged = fragmentsOf(repo.merged(m.tree)).fragments;
245+
const rendered = [...merged].sort((a, b) => a.order - b.order || (a.id < b.id ? -1 : 1)).map((f) => f.id);
246+
expect(rendered.slice(-2)).toEqual([ID_A, ID_B]);
247+
});
248+
249+
// THE LIT CONTROLS: the same harness, three pairs that must still conflict.
250+
it('two retirements in the SAME gap still conflict — the residue a key sort cannot remove', () => {
251+
const m = repo.merge('sorted-a', 'sorted-same-gap');
252+
expect(m.status).toBe(1);
253+
expect(m.conflicted).toEqual([REL]);
254+
});
255+
256+
it('the same two fragments appended at the list\'s END conflict — an array alone is not the cure', () => {
257+
const m = repo.merge('end-a', 'end-b');
258+
expect(m.status).toBe(1);
259+
expect(m.conflicted).toEqual([REL]);
260+
});
261+
262+
it('the old shape — a `+` chain whose closing line each retirement rewrote — conflicts', () => {
263+
const tail = " + 'refused at parse rather than rewritten.',\n conversionIds: [\n";
264+
const old = `const step18 = {\n rationale:\n 'It also retires the list view\\'s own \`tabs\`. '\n${tail} 'view-list-tabs-removed',\n ],\n};\n`;
265+
const append = (sentence: string, id: string) => old
266+
.replace(tail, ` + 'refused at parse rather than rewritten. '\n + '${sentence}',\n conversionIds: [\n`)
267+
.replace(" 'view-list-tabs-removed',\n", ` 'view-list-tabs-removed',\n '${id}',\n`);
268+
const legacy = new FileRepo(old);
269+
try {
270+
legacy.branch('old-a', append(SENTENCE_A, 'synthetic-a-removed'));
271+
legacy.branch('old-b', append(SENTENCE_B, 'synthetic-b-removed'));
272+
const m = legacy.merge('old-a', 'old-b');
273+
expect(m.status).toBe(1);
274+
expect(m.conflicted).toEqual([REL]);
275+
} finally {
276+
legacy.dispose();
277+
}
278+
});
279+
});

0 commit comments

Comments
 (0)