|
| 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