/** * GitFlow EF Core — Migration detection and validation. * Self-contained: only uses node built-ins. */ import { readFileSync, existsSync } from 'fs'; import type { EfCoreValidation, MigrationInfo } from './types.js'; const MIGRATION_PATTERN = /Migrations?\//i; const DESIGNER_SUFFIX = '.Designer.cs'; const SNAPSHOT_SUFFIX = 'ModelSnapshot.cs'; // GitFlow 4.x list — ONLY genuinely data-destroying operations trigger a // confirmation. DropIndex / DropForeignKey / DropPrimaryKey / DropSchema / // RenameTable / RenameColumn are ROUTINE in legitimate migrations (a rename, a // type change, an FK restructuring all emit them) and were never flagged in // 4.x — they stay OUT to avoid the false-positive storm the v5 9-op list caused. const DESTRUCTIVE_OPS = ['DropTable', 'DropColumn', 'DeleteData']; export function detectMigrations(changedFiles: string[]): MigrationInfo[] { const migrationFiles = changedFiles.filter(f => MIGRATION_PATTERN.test(f) && f.endsWith('.cs')); const migrations = new Map(); for (const file of migrationFiles) { if (file.endsWith(SNAPSHOT_SUFFIX)) continue; let name: string; if (file.endsWith(DESIGNER_SUFFIX)) { name = file.replace(DESIGNER_SUFFIX, ''); } else { name = file.replace('.cs', ''); } const baseName = name.split(/[/\\]/).pop() || name; if (!migrations.has(baseName)) { migrations.set(baseName, { name: baseName, mainFile: '' }); } const info = migrations.get(baseName)!; if (file.endsWith(DESIGNER_SUFFIX)) { info.designerFile = file; } else { info.mainFile = file; } } const snapshotFiles = migrationFiles.filter(f => f.endsWith(SNAPSHOT_SUFFIX)); for (const migration of migrations.values()) { migration.snapshotFile = snapshotFiles[0]; } return Array.from(migrations.values()); } export function validateMigration( migration: MigrationInfo, allChangedFiles: string[], ): { valid: boolean; missing: string[] } { const missing: string[] = []; if (!migration.mainFile) missing.push(`${migration.name}.cs`); if (!migration.designerFile) missing.push(`${migration.name}.Designer.cs`); const hasSnapshot = allChangedFiles.some(f => f.endsWith(SNAPSHOT_SUFFIX)); if (!hasSnapshot) missing.push('ModelSnapshot.cs'); return { valid: missing.length === 0, missing }; } /** * Isolate the FORWARD (`Up`) region of a migration — from the * `Up(MigrationBuilder …)` signature up to (but excluding) the `Down(…)` * rollback. EF Core scaffolds `Down()` as the exact inverse of `Up()`, so a * migration that CREATEs N tables forward mechanically emits N `DropTable` * calls in `Down()`. Those drops run ONLY on an explicit rollback, never on a * normal `database update` — scanning them as "destructive" is a guaranteed * false positive (EVERY InitialCreate would trip it). Fail-open: with no `Up` * signature we scan from the top; with no `Down` we scan to EOF. */ export function forwardMigrationBody(content: string): string { const up = /protected\s+override\s+void\s+Up\s*\(/.exec(content); const down = /protected\s+override\s+void\s+Down\s*\(/.exec(content); const start = up ? up.index : 0; const end = down && down.index > start ? down.index : content.length; return content.slice(start, end); } /** * Destructive ops present in the FORWARD (`Up`) body only. Matches the actual * builder CALL (`.DropTable(`), not a bare substring — so an inlined frozen SQL * object (`migrationBuilder.Sql(@"… DropTable …")`), a comment, or a SQL name * like `usp_DeleteDataArchive` can't trip it. A genuine forward drop (the * developer really removes a table/column/data in `Up()`) is still caught; the * auto-generated `Down()` inverse is excluded. Pure: no I/O. */ export function scanDestructiveOps(content: string): string[] { const forward = forwardMigrationBody(content); return DESTRUCTIVE_OPS.filter(op => new RegExp(`\\.${op}\\s*\\(`).test(forward)); } export function detectDestructiveOps(filePath: string): string[] { if (!existsSync(filePath)) return []; return scanDestructiveOps(readFileSync(filePath, 'utf-8')); } export function validateEfCore(changedFiles: string[]): EfCoreValidation { const migrations = detectMigrations(changedFiles); if (migrations.length === 0) { return { detected: false, valid: true, destructive: false, migrations: [], missing: [], destructiveOps: [] }; } const allMissing: string[] = []; const allDestructive: string[] = []; let allValid = true; for (const migration of migrations) { const { valid, missing } = validateMigration(migration, changedFiles); if (!valid) { allValid = false; allMissing.push(...missing); } if (migration.mainFile && existsSync(migration.mainFile)) { const ops = detectDestructiveOps(migration.mainFile); allDestructive.push(...ops); } } return { detected: true, valid: allValid, destructive: allDestructive.length > 0, migrations: migrations.map(m => m.name), missing: allMissing, destructiveOps: allDestructive, }; } export interface EfcorePolicyConfig { enabled?: boolean; validateOnCommit?: boolean; blockDestructive?: boolean; } export interface StagingPlan { /** false → exclusion is impossible (migration files already staged) — keep the hard block. */ ok: boolean; reason?: string; /** Pathspecs to hand to `git add`, cwd-relative (scope + `:(exclude,icase)` magic). */ pathspecs: string[]; /** Changed files under a Migrations/ folder left out of the commit (for the envelope). */ excludedFiles: string[]; } /** * Plan the staging of a commit whose EF validation hard-blocked on an INCOMPLETE * migration changeset: commit everything else, keep the whole Migrations/ content * out (the shared ModelSnapshot reflects the broken migration — folder-level * exclusion is the only safe granularity). Exclusion is expressed as `:(exclude)` * PATHSPECS resolved by git against the SAME cwd as the `git add` scope — never a * join of porcelain root-relative paths onto the workdir, which would miss in a * sub-directory. `git add` cannot UN-stage an already-indexed file, so when a * migration file is already staged the plan reports `ok: false` and the caller * keeps the original hard block (fail-closed). Pure: no I/O. */ export function planStaging(scope: string[], changedFiles: string[], stagedFiles: string[]): StagingPlan { const underMigrations = (f: string) => MIGRATION_PATTERN.test(f); const excludedFiles = changedFiles.filter(underMigrations); const preStaged = stagedFiles.filter(underMigrations); if (preStaged.length > 0) { return { ok: false, reason: `migration files already staged (${preStaged.join(', ')}) — \`git add :(exclude)\` cannot un-stage them`, pathspecs: scope, excludedFiles, }; } return { ok: true, // `icase` — Windows checkouts routinely mix Migrations/migrations; `*` in a // plain pathspec crosses directory boundaries, covering any nesting depth. pathspecs: [...scope, ':(exclude,icase)*migration/*', ':(exclude,icase)*migrations/*'], excludedFiles, }; } export interface EfcorePolicy { /** Hard block — commit is refused outright (incomplete migration). */ block: boolean; /** Needs explicit user confirmation before proceeding (destructive ops, 4.x AskUserQuestion). */ requiresConfirmation: boolean; error?: string; warnings: string[]; } /** * Decide what a commit should do about detected migrations — config-driven, * restoring the 4.x semantics: * - incomplete migration changeset → hard block IF validateOnCommit (4.x STOP), * else a warning; * - destructive ops → require confirmation IF blockDestructive (4.x * AskUserQuestion), else a warning; * - `enabled === false` disables all blocking/confirmation (warn-only). * Pure: no I/O, fully unit-testable. */ export function decideEfcorePolicy(v: EfCoreValidation, cfg: EfcorePolicyConfig): EfcorePolicy { const warnings: string[] = []; if (!v.detected) { return { block: false, requiresConfirmation: false, warnings }; } const enabled = cfg.enabled !== false; // 4.x default: on; only explicit false disables if (!v.valid) { if (enabled && cfg.validateOnCommit) { return { block: true, requiresConfirmation: false, error: `Incomplete migration changeset — missing: ${v.missing.join(', ')}. Required: Migration.cs + Designer.cs + ModelSnapshot.cs.`, warnings, }; } warnings.push(`EF Core: incomplete migration changeset (missing ${v.missing.join(', ')}).`); } if (v.destructive) { if (enabled && cfg.blockDestructive) { // Surface for confirmation (4.x AskUserQuestion) — NOT a silent hard block. return { block: false, requiresConfirmation: true, error: `Destructive EF Core operations detected: ${v.destructiveOps.join(', ')}.`, warnings, }; } warnings.push(`EF Core: destructive operations (${v.destructiveOps.join(', ')}) — review before merge.`); } return { block: false, requiresConfirmation: false, warnings }; } export interface PrMigrationGate { /** Blocking — a feature with > 1 migration must squash before its PR. */ error?: string; /** Advisory — a release/hotfix bringing > 1 migration to main. */ warning?: string; } /** * Decide what the `pr` CLI does about the migrations a branch brings to its * PR target (diff `origin/...HEAD`), under `efcore.squashBeforePR`: * - feature → HARD BLOCK beyond 1 migration (4.x §4.2 — squash first); * - release/hotfix → WARNING beyond 1: N migrations headed to main is a * LEGITIMATE state (one per squashed feature), but the count is surfaced so * a deliberate consolidation (`/efcore squash` on the branch, reference = * main — migrations already in main are never touched) can happen before * prod. Without this, a release accumulating 8 well-named migrations sailed * through silently while the same 8 on a feature were blocked. * - anything else (develop, unknown) → nothing. * Pure: no I/O, fully unit-testable (sibling of decideEfcorePolicy). */ export function decidePrMigrationGate( branchType: string, migrationBasenames: string[], squashBeforePR: boolean | undefined, ): PrMigrationGate { if (!squashBeforePR || migrationBasenames.length <= 1) return {}; const names = migrationBasenames.join(', '); if (branchType === 'feature') { return { error: `Feature has ${migrationBasenames.length} migrations — squash to a single migration before PR (run /efcore squash). Found: ${names}`, }; } if (branchType === 'release' || branchType === 'hotfix') { return { warning: `This ${branchType} brings ${migrationBasenames.length} migrations to main — consider a deliberate consolidation ` + `with /efcore squash on this branch (reference: main; migrations already in main are never touched). Found: ${names}`, }; } return {}; } /** * 4.x start-efcore-preflight rule: a migration filename must follow * `{context}_v{version}_{seq3}_{Description}` (an optional EF 14-digit timestamp * prefix is allowed). Accepts both dot (`v2.0.0`) and underscore (`v2_0_0`) * version separators. Pure — feed it a basename or full path. */ export function isCompliantMigrationName(fileName: string): boolean { const base = (fileName.split(/[/\\]/).pop() || fileName).replace(/\.cs$/i, ''); const namePart = base.replace(/^[0-9]{14}_/, ''); return /^[a-zA-Z]+_v[0-9]+[._][0-9]+[._][0-9]+_[0-9]{3}_[A-Za-z]/.test(namePart); } export function validateAppsettings( currentContent: string, previousContent?: string, ): { valid: boolean; removedSections: string[]; secrets: string[] } { const secrets: string[] = []; const removedSections: string[] = []; const secretPatterns = [ { pattern: /Server=.*;.*Password=/i, label: 'Connection string with password' }, { pattern: /["']sk-[a-zA-Z0-9]{20,}["']/i, label: 'API key' }, { pattern: /["'][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}["']/i, label: 'GUID (potential secret)' }, ]; for (const { pattern, label } of secretPatterns) { if (pattern.test(currentContent)) secrets.push(label); } if (previousContent) { try { const current = JSON.parse(currentContent); const previous = JSON.parse(previousContent); for (const key of Object.keys(previous)) { if (!(key in current)) removedSections.push(key); } } catch { /* skip parse errors */ } } return { valid: secrets.length === 0 && removedSections.length === 0, removedSections, secrets }; }