Skip to content

Commit 555a8e4

Browse files
committed
test(spec): pin step 18's merge shape — two retirements merge clean with no driver
A repo-project test beside count-shards-merge: the real registry keeps STEP18_RATIONALE sorted by key and renders it by `order`, conversionIds is derived, and two retirement-shaped insertions into the REAL file (one existing fragment apart, same next `order`) merge clean under `git merge-tree` and equal both insertions applied together. Lit controls: a same-gap pair, the same pair appended at the list's end, and the old `+`-chain tail rewrite each conflict. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EJ1ED8X4MMrT18BhVx4tx
1 parent 2e6251a commit 555a8e4

2 files changed

Lines changed: 280 additions & 0 deletions

File tree

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+
});

‎packages/spec/vitest.repo-tests.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"scripts/schema-tree-freshness.test.ts",
2020
"scripts/sharded-artifacts.test.ts",
2121
"scripts/solution-blueprint-header-row.test.ts",
22+
"scripts/step18-rationale-merge.test.ts",
2223
"scripts/strictness-ledger-doc.test.ts",
2324
"scripts/strictness-ledger.test.ts",
2425
"src/ai/tool-confirmation-prescription-tense.pin.test.ts",

0 commit comments

Comments
 (0)