Skip to content

Commit 2a4ccb4

Browse files
committed
fix(spec): render-projection-diff generates a base's git-ignored migration registry
Since the migration registry left git, a git archive of a base at or after that change carries only registry.ts.template and entries/, so both base projection generators died on a missing ../src/migrations/registry module and the "Render the generated spec-changes and upgrade-guide diff" step went red on every merge-queue entry. realMaterializeBase now runs the base's own build-migration-registry.ts in the base tree when the archive has the generator but not the registry. A base that committed the registry is untouched; a generator that fails, or exits 0 without writing, reds the base side with its output. Claude-Session: https://claude.ai/code/session_01S3aAf11JjbW1mSGL1EhfFj Co-authored-by: Claude <noreply@anthropic.com>
1 parent ed1de8c commit 2a4ccb4

2 files changed

Lines changed: 158 additions & 3 deletions

File tree

‎packages/spec/scripts/render-projection-diff.test.ts‎

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,16 @@ import path from 'node:path';
1616

1717
import {
1818
ARTIFACT_NAME,
19+
BASE_REGISTRY,
1920
SUMMARY_DIFF_CAP,
2021
describeDeltas,
2122
fenceFor,
23+
generateBaseRegistry,
2224
idDeltas,
2325
renderProjectionDiff,
2426
renderSummary,
2527
type Generate,
28+
type RunScript,
2629
} from './render-projection-diff';
2730

2831
let tmp: string;
@@ -149,6 +152,113 @@ describe('render-projection-diff — the generated diff on a pull request', () =
149152
});
150153
});
151154

155+
describe('render-projection-diff — a base whose migration registry is git-ignored', () => {
156+
/** A base `packages/spec` laid out as its archive would be: the generator and/or the registry, or neither. */
157+
function baseSpecDir(has: { generator: boolean; registry: boolean }): string {
158+
const dir = path.join(tmp, 'base-tree', 'packages', 'spec');
159+
fs.mkdirSync(path.join(dir, 'scripts'), { recursive: true });
160+
if (has.generator) fs.writeFileSync(path.join(dir, 'scripts', BASE_REGISTRY.generator), '');
161+
if (has.registry) {
162+
fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true });
163+
fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// committed\n');
164+
}
165+
return dir;
166+
}
167+
168+
/** A fake runner that records each call and, unless told otherwise, writes the registry the way the generator does. */
169+
function recorder(result: { status: number; write: boolean; output?: string }) {
170+
const calls: [string, string][] = [];
171+
const run: RunScript = (dir, script) => {
172+
calls.push([dir, script]);
173+
if (result.write) fs.writeFileSync(path.join(dir, BASE_REGISTRY.file), '// generated\n');
174+
return { status: result.status, output: result.output ?? '' };
175+
};
176+
return { calls, run };
177+
}
178+
179+
it('an archive with the generator but no registry runs the BASE generator in the base tree', () => {
180+
const dir = baseSpecDir({ generator: true, registry: false });
181+
fs.mkdirSync(path.dirname(path.join(dir, BASE_REGISTRY.file)), { recursive: true });
182+
const { calls, run } = recorder({ status: 0, write: true });
183+
184+
expect(generateBaseRegistry(dir, run)).toBe(true);
185+
expect(calls).toEqual([[dir, 'build-migration-registry.ts']]);
186+
expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// generated\n');
187+
});
188+
189+
it('a base that committed its registry keeps it, and a base without the generator is left as it is', () => {
190+
for (const has of [{ generator: true, registry: true }, { generator: false, registry: true }, { generator: false, registry: false }]) {
191+
fs.rmSync(path.join(tmp, 'base-tree'), { recursive: true, force: true });
192+
const dir = baseSpecDir(has);
193+
const { calls, run } = recorder({ status: 0, write: true });
194+
195+
expect(generateBaseRegistry(dir, run)).toBe(false);
196+
expect(calls).toEqual([]);
197+
if (has.registry) expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe('// committed\n');
198+
}
199+
});
200+
201+
it('a base registry generator that fails, or exits 0 without writing, is thrown with its output', () => {
202+
const dir = baseSpecDir({ generator: true, registry: false });
203+
204+
// First line names the generator and its exit; the generator's output follows it — the
205+
// shape the failure summary splits on.
206+
expect(() => generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run)).toThrow(
207+
/^build-migration-registry\.ts exited 1\b[^\n]*\nentry failed to parse$/,
208+
);
209+
expect(() => generateBaseRegistry(dir, recorder({ status: 0, write: false }).run)).toThrow(
210+
/^build-migration-registry\.ts exited 0\b[^\n]*src\/migrations\/registry\.ts/,
211+
);
212+
});
213+
214+
it('a base whose registry cannot be generated is RED, named as the base', () => {
215+
const dir = baseSpecDir({ generator: true, registry: false });
216+
const generate: Generate = (_dir, script, out) => {
217+
fs.writeFileSync(out, script === 'build-spec-changes.ts' ? BASE.json : BASE.guide);
218+
return { status: 0, output: '' };
219+
};
220+
const head = path.join(tmp, 'head-tree', 'packages', 'spec');
221+
fs.mkdirSync(head, { recursive: true });
222+
const outDir = path.join(tmp, 'out');
223+
const result = renderProjectionDiff({
224+
repoRoot: tmp,
225+
baseSha: 'b'.repeat(40),
226+
baseLabel: 'HEAD^1 = bbbbbbbbbb',
227+
outDir,
228+
headSpecDir: head,
229+
generate,
230+
materializeBase: () => {
231+
generateBaseRegistry(dir, recorder({ status: 1, write: false, output: 'entry failed to parse' }).run);
232+
return dir;
233+
},
234+
});
235+
236+
expect(result.code).toBe(1);
237+
expect(result.headline).toContain('base HEAD^1 = bbbbbbbbbb: build-migration-registry.ts exited 1');
238+
expect(result.summary).toContain('entry failed to parse');
239+
});
240+
241+
it('the default runner executes the base generator by its own path, with the base package as cwd', () => {
242+
const dir = baseSpecDir({ generator: true, registry: false });
243+
// Resolves its package root from its own path, as the real generator does.
244+
fs.writeFileSync(
245+
path.join(dir, 'scripts', BASE_REGISTRY.generator),
246+
[
247+
"import { mkdirSync, writeFileSync } from 'node:fs';",
248+
"import { dirname, join } from 'node:path';",
249+
"import { fileURLToPath } from 'node:url';",
250+
"const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');",
251+
"mkdirSync(join(pkgRoot, 'src', 'migrations'), { recursive: true });",
252+
"writeFileSync(join(pkgRoot, 'src', 'migrations', 'registry.ts'), `// cwd ${process.cwd()}\\n`);",
253+
'',
254+
].join('\n'),
255+
);
256+
257+
expect(generateBaseRegistry(dir)).toBe(true);
258+
expect(fs.readFileSync(path.join(dir, BASE_REGISTRY.file), 'utf8')).toBe(`// cwd ${fs.realpathSync(dir)}\n`);
259+
});
260+
});
261+
152262
describe('render-projection-diff — the pieces', () => {
153263
it('idDeltas names ids gained and lost per record, and skips records that did not move', () => {
154264
const deltas = idDeltas(manifest(['a', 'b'], ['c1']), manifest(['b', 'z'], ['c1', 'c2']));

‎packages/spec/scripts/render-projection-diff.ts‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
* base `<ref>`'s own generators, run in a `git archive` of `<ref>` that
2828
* borrows this checkout's `node_modules` — so the base side is what the
2929
* base's code generated, never this change's generator applied to it.
30+
* A base whose migration registry is git-ignored gets it written first,
31+
* by its own generator (`generateBaseRegistry`).
3032
*
3133
* ⚠️ A base whose generators predate `--out` writes its committed path instead
3234
* (inside the throwaway archive, never this checkout). That is read in its
@@ -86,8 +88,11 @@ export interface GeneratorRun {
8688
/** Runs `<specDir>/scripts/<script> --out <out>` — injected by the tests. */
8789
export type Generate = (specDir: string, script: string, out: string) => GeneratorRun;
8890

89-
function realGenerate(specDir: string, script: string, out: string): GeneratorRun {
90-
const r = spawnSync(TSX, [join(specDir, 'scripts', script), '--out', out], {
91+
/** Runs `<specDir>/scripts/<script>` with no arguments — injected by the tests. */
92+
export type RunScript = (specDir: string, script: string) => GeneratorRun;
93+
94+
function runTsx(specDir: string, args: string[]): GeneratorRun {
95+
const r = spawnSync(TSX, args, {
9196
cwd: specDir,
9297
encoding: 'utf8',
9398
stdio: ['ignore', 'pipe', 'pipe'],
@@ -96,6 +101,44 @@ function realGenerate(specDir: string, script: string, out: string): GeneratorRu
96101
return { status: r.status ?? -1, output: `${r.stdout ?? ''}${r.stderr ?? ''}${r.error ? String(r.error) : ''}` };
97102
}
98103

104+
function realGenerate(specDir: string, script: string, out: string): GeneratorRun {
105+
return runTsx(specDir, [join(specDir, 'scripts', script), '--out', out]);
106+
}
107+
108+
function realRunScript(specDir: string, script: string): GeneratorRun {
109+
return runTsx(specDir, [join(specDir, 'scripts', script)]);
110+
}
111+
112+
/** The base's git-ignored migration registry and the generator that writes it, relative to its `packages/spec`. */
113+
export const BASE_REGISTRY = { file: 'src/migrations/registry.ts', generator: 'build-migration-registry.ts' } as const;
114+
115+
/**
116+
* Write a base tree's migration registry with the BASE's own generator, when the
117+
* archive carries the generator but not the registry. Returns whether it ran.
118+
*
119+
* Both projection generators import `../src/migrations/registry`. Since #22554
120+
* ruling B that file is git-ignored and generated at install and build, so a
121+
* `git archive` of a base at or after that change has only its template and
122+
* entries — the base generators would die on a missing module. The generator
123+
* resolves its package root from its own path, so run from the base tree it
124+
* writes the base's registry from the base's entries, never this checkout's.
125+
* A base that committed the registry keeps it untouched. A generator that fails,
126+
* or exits 0 without writing the file, is thrown: the caller reports the base red.
127+
*/
128+
export function generateBaseRegistry(specDir: string, run: RunScript = realRunScript): boolean {
129+
const registry = join(specDir, BASE_REGISTRY.file);
130+
if (existsSync(registry)) return false;
131+
if (!existsSync(join(specDir, 'scripts', BASE_REGISTRY.generator))) return false;
132+
const r = run(specDir, BASE_REGISTRY.generator);
133+
if (r.status !== 0) {
134+
throw new Error(`${BASE_REGISTRY.generator} exited ${r.status}, so the base has no ${BASE_REGISTRY.file}:\n${r.output.trim()}`);
135+
}
136+
if (!existsSync(registry)) {
137+
throw new Error(`${BASE_REGISTRY.generator} exited 0 but wrote no ${BASE_REGISTRY.file}:\n${r.output.trim()}`);
138+
}
139+
return true;
140+
}
141+
99142
function git(args: string[], cwd: string): { status: number; stdout: string; stderr: string } {
100143
const r = spawnSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
101144
return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: `${r.stderr ?? ''}${r.error ? String(r.error) : ''}` };
@@ -130,7 +173,9 @@ function realMaterializeBase(repoRoot: string, sha: string, dest: string): strin
130173
mkdirSync(join(tree, 'docs'), { recursive: true });
131174
symlinkSync(join(repoRoot, 'node_modules'), join(tree, 'node_modules'));
132175
symlinkSync(join(repoRoot, 'packages', 'spec', 'node_modules'), join(tree, 'packages', 'spec', 'node_modules'));
133-
return join(tree, 'packages', 'spec');
176+
const specDir = join(tree, 'packages', 'spec');
177+
generateBaseRegistry(specDir);
178+
return specDir;
134179
}
135180

136181
/** One side's generated file for one projection, or why there is none. */

0 commit comments

Comments
 (0)