/** * detect-dbcontexts.ts — Discover EF Core DbContexts in a worktree. * * Strategy: find every *ModelSnapshot.cs, read its `partial class Xxx` * declaration, then associate it with the .csproj that owns the snapshot's * migrations folder. Multiple snapshots for the same context (across * Migrations.Sqlite / Postgres / SqlServer assemblies) are grouped as one * context with multiple "assemblies". * * Return shape is consumed by list/status/create/squash/rebase-snapshot CLIs * and by the Studio's frontend MigrationsTab to render a context picker. */ import path from 'node:path'; import { findFiles, readText, fileExists } from '../../../lib/fs.js'; import { parseCsprojVersion } from './parse-csproj-version.js'; export interface MigrationAssembly { /** Absolute path to the .csproj that owns the migrations folder. */ csprojPath: string; /** Assembly name derived from csproj filename (minus ".csproj"). */ assemblyName: string; /** Absolute path to the directory containing the migrations + snapshot. */ migrationsDir: string; /** Snapshot file inside migrationsDir. */ snapshotPath: string; /** Inferred provider: "sqlite" / "postgres" / "sqlserver" / null. */ provider: string | null; /** Version resolved from the csproj (Version/VersionPrefix/…) or the nearest Directory.Build.props ("0.1.0" fallback). */ version: string; } export interface DbContextInfo { /** Partial-class name from the snapshot (e.g. "CoreDbContext"). */ name: string; /** One entry per migration assembly that targets this context. */ assemblies: MigrationAssembly[]; } export interface DetectionResult { /** Kind heuristic: "generated" app (Core+Extensions) / "studio" / "unknown". */ kind: 'generated' | 'studio' | 'unknown'; contexts: DbContextInfo[]; /** Absolute working directory used for detection. */ cwd: string; } const PROVIDER_HINTS: Array<[RegExp, string]> = [ [/\bSqlite\b/i, 'sqlite'], [/\bPostgres(?:ql)?\b/i, 'postgres'], [/\bNpgsql\b/i, 'postgres'], [/\bSqlServer\b/i, 'sqlserver'], [/\bMssql\b/i, 'sqlserver'], ]; function inferProvider(csprojPath: string, migrationsDir: string): string | null { const haystack = `${csprojPath} ${migrationsDir}`; for (const [re, name] of PROVIDER_HINTS) { if (re.test(haystack)) return name; } return null; } async function extractContextNameFromSnapshot(snapshotPath: string): Promise { try { const text = await readText(snapshotPath); // Matches both: // [DbContext(typeof(CoreDbContext))] // partial class CoreDbContextModelSnapshot : ModelSnapshot const attr = text.match(/DbContext\s*\(\s*typeof\s*\(\s*(\w+)\s*\)/); if (attr) return attr[1]; const cls = text.match(/partial\s+class\s+(\w+?)ModelSnapshot\b/); if (cls) return cls[1]; return null; } catch { return null; } } async function findOwningCsproj(snapshotPath: string, cwd: string): Promise { // Walk up from the snapshot file looking for a .csproj in the same directory // tree. EF Core always places migrations inside the project that references // `Microsoft.EntityFrameworkCore.Design`. let dir = path.dirname(snapshotPath); const cwdResolved = path.resolve(cwd); while (path.resolve(dir).startsWith(cwdResolved)) { const csprojFiles = await findFiles('*.csproj', { cwd: dir }); const direct = csprojFiles.filter((f) => path.dirname(f) === dir); if (direct.length > 0) return direct[0]; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } function classify(contexts: DbContextInfo[]): DetectionResult['kind'] { const names = new Set(contexts.map((c) => c.name.toLowerCase())); if (names.has('studiodbcontext')) return 'studio'; // SmartStack-generated apps expose CoreDbContext, ExtensionsDbContext, or // both. Older / minimal scaffolds may ship only Extensions. if (names.has('coredbcontext') || names.has('extensionsdbcontext')) return 'generated'; if (contexts.length > 0) return 'unknown'; return 'unknown'; } export async function detectDbContexts(cwd: string): Promise { const snapshots = await findFiles('**/Migrations/**/*ModelSnapshot.cs', { cwd }); const grouped = new Map(); for (const snapshot of snapshots) { const name = await extractContextNameFromSnapshot(snapshot); if (!name) continue; const csproj = await findOwningCsproj(snapshot, cwd); if (!csproj) continue; const migrationsDir = path.dirname(snapshot); const assemblyName = path.basename(csproj, '.csproj'); const version = await parseCsprojVersion(csproj); const provider = inferProvider(csproj, migrationsDir); const entry = grouped.get(name) ?? { name, assemblies: [] }; // Skip duplicates (same assembly found twice by the glob) if (!entry.assemblies.some((a) => a.csprojPath === csproj && a.migrationsDir === migrationsDir)) { entry.assemblies.push({ csprojPath: csproj, assemblyName, migrationsDir, snapshotPath: snapshot, provider, version, }); } grouped.set(name, entry); } const contexts = Array.from(grouped.values()).map((c) => ({ ...c, assemblies: c.assemblies.sort((a, b) => a.assemblyName.localeCompare(b.assemblyName)), })); return { kind: classify(contexts), contexts: contexts.sort((a, b) => a.name.localeCompare(b.name)), cwd: path.resolve(cwd), }; } /** * Startup-project heuristic: EF Core design-time commands need a project that * references the DbContext AND can be run (Microsoft.NET.Sdk.Web or an .exe * Sdk). We pick a project whose name ends with `.Host` / `.Api` / `.Web`; * failing that, the first assembly in alphabetical order. */ export async function findStartupProject(cwd: string): Promise { const all = await findFiles('**/*.csproj', { cwd }); const ranked = all .map((f) => ({ f, base: path.basename(f, '.csproj') })) .sort((a, b) => { const score = (name: string) => { if (/\.Host$/i.test(name)) return 0; if (/\.Api$/i.test(name)) return 1; if (/\.Web$/i.test(name)) return 2; return 9; }; const sa = score(a.base); const sb = score(b.base); if (sa !== sb) return sa - sb; return a.base.localeCompare(b.base); }); for (const { f } of ranked) { try { const content = await readText(f); if (/\s*Exe\s*<\/OutputType>/i.test(content)) return f; } catch { continue; } } return ranked[0]?.f ?? null; } export async function hasDotnetProject(cwd: string): Promise { const anyCsproj = (await findFiles('**/*.csproj', { cwd })).length > 0; if (!anyCsproj) return false; const slnOrSlnx = (await findFiles('**/*.sln', { cwd })).length > 0 || (await findFiles('**/*.slnx', { cwd })).length > 0; return slnOrSlnx || anyCsproj; } export async function pathExists(p: string): Promise { return fileExists(p); }