/** * The ordered-migration-chain guard (#227). * * A project that declares `routing.repos..migrations.dir` gets its * merges checked against one invariant: after the merge, the base branch's * `revision`/`down_revision` graph has exactly one head. The check runs on the * base tip at merge time, against the PR's diff — a stale parent, a reused * revision id, or a deleted migration is refused before the merge, because a * wrong merge here is silent production schema drift. * * Alembic-convention only (`revision = ""` / `down_revision = ` in * `*.py`); a repo without the config key is never touched. Pure by * construction: every fact arrives as strings, and the module reads no files. */ import type { PrDiffFile } from "./types.ts"; /** One migration file's identity, as the chain check reasons about it. */ export interface ChainEntry { /** Repo-relative path of the file that declares the revision. */ path: string; /** The revision id this file declares. */ id: string; /** Parent revision ids; empty for a root. */ parents: string[]; } const REVISION_LINE = /^\s*revision(?::[^=]*)?\s*=\s*["']([^"']+)["']/m; const DOWN_REVISION_LINE = /^\s*down_revision(?::[^=]*)?\s*=\s*(.*)$/m; const QUOTED = /["']([^"']+)["']/g; /** * Parse one migration file's source into its chain identity, or `undefined` * when it declares no `revision` (not a chain file; skip it). * * `down_revision` may be `"abc"`, `None`, or a tuple `('a', 'b')` (a merge * migration) — the parents are every quoted string on the right-hand side, so * `None`/empty reads as a root without special-casing syntax. */ export function parseChainSource(path: string, source: string): ChainEntry | undefined { const id = REVISION_LINE.exec(source)?.[1]; if (id === undefined) return undefined; const parents: string[] = []; const rhs = DOWN_REVISION_LINE.exec(source)?.[1] ?? ""; for (const m of rhs.matchAll(QUOTED)) { const parent = m[1]; if (parent !== undefined && !parents.includes(parent)) parents.push(parent); } return { path, id, parents }; } /** Post-image source of one diff file: the added lines, rejoined as a file. */ function postImageSource(file: PrDiffFile): string { const hunks = file.hunks ?? ""; const lines: string[] = []; for (const line of hunks.split("\n")) { if (line.startsWith("+")) lines.push(line.slice(1)); } return lines.join("\n"); } /** Whether one diff entry lives inside the chain directory. */ function inChainDir(dir: string, path: string): boolean { return path.startsWith(`${dir}/`) && path.endsWith(".py"); } /** * What the PR does to the chain: entries added, entries changed (post-image * used — a modified file's base entry stands when its added lines declare no * revision), and chain files deleted. */ export function chainEntriesFromDiff( files: readonly PrDiffFile[], dir: string, ): { added: ChainEntry[]; changed: ChainEntry[]; deleted: string[] } { const added: ChainEntry[] = []; const changed: ChainEntry[] = []; const deleted: string[] = []; for (const file of files) { if (!inChainDir(dir, file.path)) continue; if (file.status === "removed") { deleted.push(file.path); continue; } const entry = parseChainSource(file.path, postImageSource(file)); if (entry === undefined) continue; if (file.status === "added") added.push(entry); else changed.push(entry); // modified, or renamed into the chain directory } return { added, changed, deleted }; } export interface ChainViolationInput { base: ChainEntry[]; added: ChainEntry[]; changed: ChainEntry[]; deleted: string[]; } /** * Refuse a merge that would corrupt the ordered chain, or return [] to pass. * * A combined graph that no PR touches is never judged: a broken base is not * this PR's problem. */ export function chainViolations(input: ChainViolationInput): string[] { const touched = input.added.length + input.changed.length + input.deleted.length; if (touched === 0) return []; const violations: string[] = []; // 1. The one deletion nothing can justify: a published migration may not // disappear from the base branch. for (const path of input.deleted) { if (input.base.some((entry) => entry.path === path)) { violations.push(`deletes ${path}, which exists on the base branch — a published migration may never be deleted`); } } // 2. Reusing an id that already exists on the base at a DIFFERENT path makes // two files claim one revision; the graph stops being an order. const baseByPath = new Map(input.base.map((entry) => [entry.path, entry])); for (const entry of [...input.added, ...input.changed]) { const owner = input.base.find((b) => b.id === entry.id); if (owner !== undefined && owner.path !== entry.path) { violations.push(`reuses revision id ${entry.id}, already declared by ${owner.path} on the base branch`); } } // 3. Head coherence: the combined graph — base, minus deleted files, minus // changed files' base entries (replaced by their post-image), plus added // entries — must have exactly one head. Two heads means a stale parent or // a fork nobody repaired; one head after a repair merge is the goal. A // duplicate id inside the PR's own entries is incoherent too. const combined = new Map(baseByPath); for (const path of input.deleted) combined.delete(path); for (const entry of input.changed) combined.delete(entry.path); for (const entry of [...input.changed, ...input.added]) combined.set(entry.path, entry); const ids = [...combined.values()].map((entry) => entry.id); const duplicateIds = ids.filter((id, index) => ids.indexOf(id) !== index); if (duplicateIds.length > 0) { violations.push( `the migration graph would declare duplicate revision id${duplicateIds.length > 1 ? "s" : ""} ${[...new Set(duplicateIds)].join(", ")} after merge`, ); } const referenced = new Set([...combined.values()].flatMap((entry) => entry.parents)); const heads = ids.filter((id, index) => ids.indexOf(id) === index && !referenced.has(id)); if (heads.length !== 1) { violations.push( `the migration graph would have ${heads.length} heads after merge (${heads.join(", ") || "none"}); parent(s) must be the current tip`, ); } return violations; }