/** * Core-seed delta gate — blocks a PR to MAIN when the committed core-seed * desired state (`.smartstack/core-seed/{app}.state.json`) changed vs the * target without a matching SQL delta script. * * Why: in generated SmartStack apps the nav/RBAC boot seed is strictly * additive; prod data only follows renames/updates/removals through the * delta scripts generated by derive-seed-delta (core-seed skill), committed * on the release branch, reviewed in the PR and applied once at boot by * CoreSeedScriptRunner. A release whose seed changed WITHOUT its script would * silently desynchronise every prod database — same philosophy as * `efcore.squashBeforePR`, enforced at the same spot. * * Contract with derive-seed-delta (duplicated here on purpose — gitflow must * not import another skill's modules at runtime; a repo-side test asserts the * two stay in lockstep): * - state files live under `.smartstack/core-seed/*.state.json` and carry a * top-level `"specHash"`; * - scripts live under `**\/Persistence/Seeding/Scripts/*.sql` and carry * `-- baseHash: ` / `-- newHash: ` header lines. * * Inert by construction for repos without state files (the diff filter finds * nothing) — including SmartStack.cli itself. */ import * as git from './git.js' export const CORE_SEED_STATE_DIR = '.smartstack/core-seed' const STATE_FILE_RE = /\.state\.json$/ const SCRIPT_PATH_RE = /Persistence\/Seeding\/Scripts\/[^/]+\.sql$/i /** Extract the specHash of a state file's content (null = not a state file). */ export function parseStateHash(json: string): string | null { try { const parsed = JSON.parse(json) as { specHash?: unknown } return typeof parsed.specHash === 'string' && parsed.specHash.length > 0 ? parsed.specHash : null } catch { return null } } /** Parse the `-- baseHash:` / `-- newHash:` header of a delta script. */ export function parseScriptHeader(sql: string): { baseHash: string | null; newHash: string | null } { const baseMatch = sql.match(/^--\s*baseHash:\s*(\S+)\s*$/m) const newMatch = sql.match(/^--\s*newHash:\s*(\S+)\s*$/m) return { baseHash: baseMatch?.[1] ?? null, newHash: newMatch?.[1] ?? null } } export interface SeedStateChange { app: string /** specHash at the PR base (null = no state there → baseline, exempt). */ baseHash: string | null /** specHash at HEAD (null = state file removed at HEAD). */ headHash: string | null } export interface SeedScriptCandidate { path: string baseHash: string | null newHash: string | null } export interface SeedDeltaGateDecision { ok: boolean error?: string warnings: string[] /** Apps whose state change is covered by a committed script. */ covered: string[] } /** * Pure decision: which changed states require a script, and is one committed * whose header hashes bridge exactly base → head? */ export function decideSeedDeltaGate( changes: SeedStateChange[], scripts: SeedScriptCandidate[], ): SeedDeltaGateDecision { const warnings: string[] = [] const covered: string[] = [] const missing: SeedStateChange[] = [] for (const change of changes) { if (change.headHash === null) { warnings.push( `core-seed state for app '${change.app}' was removed — a whole-app removal is NOT reconciled automatically; prod keeps its data.`, ) continue } if (change.baseHash === null) { warnings.push( `core-seed app '${change.app}': no state at the PR target — baseline release, no delta script required.`, ) continue } if (change.baseHash === change.headHash) continue const match = scripts.find((s) => s.baseHash === change.baseHash && s.newHash === change.headHash) if (match) { covered.push(change.app) } else { missing.push(change) } } if (missing.length > 0) { const detail = missing .map((m) => `'${m.app}' (${m.baseHash} -> ${m.headHash})`) .join(', ') return { ok: false, warnings, covered, error: `core-seed state changed for ${detail} without a matching committed delta script. ` + `Prod nav/RBAC data would silently desynchronise. Run the core-seed delta skill ` + `(derive-seed-delta) on this branch with the release version, review the generated ` + `Persistence/Seeding/Scripts/*.sql, commit it, then retry the PR.`, } } return { ok: true, warnings, covered } } /** * I/O wrapper: gathers the changed state files + committed script headers from * git (read-only: diff + show) and applies the pure decision. `targetBranch` * is the PR target (main); the comparison base prefers `origin/`. */ export async function checkSeedDeltaGate( targetBranch: string, cwd?: string, ): Promise { const baseRef = await git.resolveBaseRef(targetBranch, cwd) const diffFiles = await git.getDiffFiles(`${baseRef}...HEAD`, cwd) const stateFiles = diffFiles.filter( (f) => f.startsWith(`${CORE_SEED_STATE_DIR}/`) && STATE_FILE_RE.test(f), ) if (stateFiles.length === 0) return { ok: true, warnings: [], covered: [] } const scripts: SeedScriptCandidate[] = [] for (const path of diffFiles.filter((f) => SCRIPT_PATH_RE.test(f))) { const show = await git.execGit(['show', `HEAD:${path}`], cwd) if (show.exitCode !== 0) continue // deleted script — not a candidate scripts.push({ path, ...parseScriptHeader(show.stdout) }) } const changes: SeedStateChange[] = [] for (const path of stateFiles) { const app = (path.split('/').pop() ?? '').replace(STATE_FILE_RE, '') const head = await git.execGit(['show', `HEAD:${path}`], cwd) const base = await git.execGit(['show', `${baseRef}:${path}`], cwd) changes.push({ app, headHash: head.exitCode === 0 ? parseStateHash(head.stdout) : null, baseHash: base.exitCode === 0 ? parseStateHash(base.stdout) : null, }) } return decideSeedDeltaGate(changes, scripts) }