diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..79935f8 --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,34 @@ +# Migration safety + +CodeDecay analyzes repo-local PostgreSQL migration SQL as a **plan-only** safety +check. It does not connect to a database or apply migrations. + +## What it can establish + +- Operation classification (add/drop/rename/alter/index/backfill) +- Rolling-deploy blockers for destructive or rename operations +- NOT NULL without default/backfill as a static blocker +- Five-state deployment matrix statuses +- Connection-target classification (`localhost` vs production-looking hosts) +- Cleanup obligations for disposable targets (plan recorded, not executed) +- Verdicts: `plan-ready`, `plan-blocked`, `needs-execution-proof`, `not-fully-verified` + +## What it cannot establish + +- Existing-data compatibility +- Lock duration / live rollback success +- Mixed-version application behavior +- A `fullyVerified: true` result (always false in this slice) + +## CLI / MCP + +```bash +codedecay migration --file migration.sql --target-kind disposable-local --cleanup-plan "drop volume codedecay-mig" +codedecay migration --file migration.sql --connection-host db.rds.amazonaws.com +``` + +MCP tool: `migration_safety`. + +Prisma schema-diff planning remains available through +`createPrismaMigrationAdapterPlan` (read-only `prisma migrate diff`); applying +migrations is still blocked by CodeDecay execution safety. diff --git a/packages/cli/src/commands/migration.ts b/packages/cli/src/commands/migration.ts index 1baf0db..551d270 100644 --- a/packages/cli/src/commands/migration.ts +++ b/packages/cli/src/commands/migration.ts @@ -12,7 +12,17 @@ export function runMigrationCommand(context: CliCommandContext, dependencies: Ru const options = parseMigrationArgs(context.args); const cwd = resolve(context.runtimeCwd, options.cwd ?? "."); const rootDir = dependencies.resolveRepoRoot(cwd, options); - const report = analyzeMigrationSafety({ rootDir, files: options.files, rollbackFiles: options.rollbackFiles, targetKind: options.targetKind }); + const report = analyzeMigrationSafety({ + rootDir, + files: options.files, + rollbackFiles: options.rollbackFiles, + targetKind: options.targetKind, + connectionUrl: options.connectionUrl, + connectionHost: options.connectionHost, + databaseUrlEnv: options.databaseUrlEnv, + cleanupPlan: options.cleanupPlan, + rollbackFailed: options.rollbackFailed + }); const rendered = options.format === "json" ? `${JSON.stringify(report, null, 2)}\n` : renderMigrationSafetyMarkdown(report); dependencies.writeOutput({ cwd: rootDir, output: options.output, rendered, runtime: context.runtime }); } diff --git a/packages/cli/src/docs/command-docs/analysis.ts b/packages/cli/src/docs/command-docs/analysis.ts index a8a7f30..1402a44 100644 --- a/packages/cli/src/docs/command-docs/analysis.ts +++ b/packages/cli/src/docs/command-docs/analysis.ts @@ -10,12 +10,23 @@ export const ANALYSIS_COMMAND_DOCS: Record = { { flag: "--file ", description: "Repo-local migration SQL file; repeat for multiple files" }, { flag: "--rollback-file ", description: "Repo-local rollback SQL file; repeat for multiple files" }, { flag: "--target-kind ", description: "unspecified, disposable-local, remote-unapproved, or production-like" }, + { flag: "--connection-url ", description: "Optional DB URL used only for host classification; secret values are redacted" }, + { flag: "--connection-host ", description: "Optional DB host used for target classification" }, + { flag: "--database-url-env ", description: "Env var name holding credentials; values are never read" }, + { flag: "--cleanup-plan ", description: "Disposable database cleanup plan recorded in the report" }, + { flag: "--rollback-failed", description: "Mark rollback as failed so the verdict stays not fully verified" }, { flag: "--cwd ", description: "Repository working directory (default: current directory)" }, { flag: "--format ", description: "json or markdown (default: markdown)" }, { flag: "--output ", description: "Write the plan to a file instead of stdout" } ], - examples: ["codedecay migration --file prisma/migrations/20260802_change/migration.sql --target-kind disposable-local", "codedecay migration --file migration.sql --target-kind production-like --format json"], - notes: ["This command is plan-only: it reads no database secret, contacts no database, and applies no migration."] + examples: [ + "codedecay migration --file prisma/migrations/20260802_change/migration.sql --target-kind disposable-local", + "codedecay migration --file migration.sql --connection-host localhost --cleanup-plan \"drop docker volume codedecay-mig\" --format json" + ], + notes: [ + "This command is plan-only: it reads no database secret, contacts no database, and applies no migration.", + "See docs/migration.md for what plan-ready vs fully-verified means." + ] }, runtime: { name: "runtime", diff --git a/packages/cli/src/parsers/migration.ts b/packages/cli/src/parsers/migration.ts index 019c299..cc39d46 100644 --- a/packages/cli/src/parsers/migration.ts +++ b/packages/cli/src/parsers/migration.ts @@ -8,6 +8,10 @@ export function parseMigrationArgs(args: string[]): MigrationOptions { const arg = args[index]; if (!arg) continue; if (arg === "--help" || arg === "-h") throw new HelpRequested(); + if (arg === "--rollback-failed") { + options.rollbackFailed = true; + continue; + } const [flag, inline] = splitArg(arg); const value = () => inline ?? requireValue(args, index, flag); if (flag === "--file") options.files.push(value()); @@ -16,7 +20,14 @@ export function parseMigrationArgs(args: string[]): MigrationOptions { else if (flag === "--output") options.output = value(); else if (flag === "--format") options.format = parseFormat(value()); else if (flag === "--target-kind") options.targetKind = parseTarget(value()); - else { throwUnknownOption(arg, "migration"); continue; } + else if (flag === "--connection-url") options.connectionUrl = value(); + else if (flag === "--connection-host") options.connectionHost = value(); + else if (flag === "--database-url-env") options.databaseUrlEnv = value(); + else if (flag === "--cleanup-plan") options.cleanupPlan = value(); + else { + throwUnknownOption(arg, "migration"); + continue; + } if (inline === undefined) index += 1; } return options; diff --git a/packages/cli/src/types/migration.ts b/packages/cli/src/types/migration.ts index 9a19e14..6844fe9 100644 --- a/packages/cli/src/types/migration.ts +++ b/packages/cli/src/types/migration.ts @@ -8,4 +8,9 @@ export interface MigrationOptions { targetKind: MigrationTargetKind; format: ConfigFormat; output?: string | undefined; + connectionUrl?: string | undefined; + connectionHost?: string | undefined; + databaseUrlEnv?: string | undefined; + cleanupPlan?: string | undefined; + rollbackFailed?: boolean | undefined; } diff --git a/packages/knowledge/src/index.ts b/packages/knowledge/src/index.ts index 86f839f..6e0ead5 100644 --- a/packages/knowledge/src/index.ts +++ b/packages/knowledge/src/index.ts @@ -70,15 +70,20 @@ export { export { ingestRuntimeEvidence, persistRuntimeEvidenceArtifact, RUNTIME_EVIDENCE_ARTIFACT_PATH } from "./runtime/ingest"; export { analyzeMigrationSafety } from "./migration/analyze"; export type { AnalyzeMigrationSafetyOptions } from "./migration/analyze"; +export { classifyMigrationConnectionTarget } from "./migration/target-safety"; export { renderMigrationSafetyMarkdown } from "./migration/render"; export { MIGRATION_EVIDENCE_SCHEMA_VERSION } from "./migration/types"; export type { + MigrationCleanupEvidence, + MigrationConnectionTarget, MigrationMatrixState, MigrationOperationEvidence, MigrationOperationKind, MigrationRisk, + MigrationRollbackStatus, MigrationSafetyReport, - MigrationTargetKind + MigrationTargetKind, + MigrationVerdict } from "./migration/types"; export type { IngestRuntimeEvidenceOptions } from "./runtime/ingest"; export { renderRuntimeEvidenceMarkdown } from "./runtime/render"; diff --git a/packages/knowledge/src/migration/analyze.ts b/packages/knowledge/src/migration/analyze.ts index 3a15715..bc55de6 100644 --- a/packages/knowledge/src/migration/analyze.ts +++ b/packages/knowledge/src/migration/analyze.ts @@ -2,14 +2,18 @@ import { createHash } from "node:crypto"; import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; import { resolve } from "node:path"; import { parse, type Statement } from "pgsql-ast-parser"; +import { classifyMigrationConnectionTarget } from "./target-safety"; import { MIGRATION_EVIDENCE_SCHEMA_VERSION, + type MigrationCleanupEvidence, type MigrationMatrixState, type MigrationOperationEvidence, type MigrationOperationKind, type MigrationRisk, + type MigrationRollbackStatus, type MigrationSafetyReport, - type MigrationTargetKind + type MigrationTargetKind, + type MigrationVerdict } from "./types"; const MAX_FILES = 100; @@ -20,44 +24,120 @@ export interface AnalyzeMigrationSafetyOptions { files: string[]; targetKind?: MigrationTargetKind | undefined; rollbackFiles?: string[] | undefined; + connectionUrl?: string | undefined; + connectionHost?: string | undefined; + databaseUrlEnv?: string | undefined; + cleanupPlan?: string | undefined; + /** Synthetic/fixture signal that rollback execution failed. */ + rollbackFailed?: boolean | undefined; generatedAt?: string | undefined; } export function analyzeMigrationSafety(options: AnalyzeMigrationSafetyOptions): MigrationSafetyReport { const rootDir = realpathSync(options.rootDir); - const targetKind = options.targetKind ?? "unspecified"; + const connectionTarget = classifyMigrationConnectionTarget({ + connectionUrl: options.connectionUrl, + connectionHost: options.connectionHost, + databaseUrlEnv: options.databaseUrlEnv, + declaredTargetKind: options.targetKind + }); + const targetKind = options.connectionUrl || options.connectionHost + ? connectionTarget.kind + : (options.targetKind ?? connectionTarget.kind); const files = boundedFiles(options.files, "migration"); const rollbackFiles = boundedFiles(options.rollbackFiles ?? [], "rollback"); const operations = files.flatMap((path) => parseMigrationFile(rootDir, path, rollbackFiles.length > 0)); for (const path of rollbackFiles) parseMigrationFile(rootDir, path, true); + const blockers = operations.filter((item) => item.risk === "blocker").map((item) => `${item.sourceRef}: ${item.detail}`); if (targetKind === "production-like") blockers.unshift("Production-like database targets are blocked; use a disposable local database."); if (targetKind === "remote-unapproved") blockers.unshift("Remote database target is not explicitly approved as disposable."); if (targetKind === "unspecified") blockers.unshift("Database target classification is required before migration execution."); + for (const reason of connectionTarget.reasons) { + if (!blockers.includes(reason) && connectionTarget.blocked) blockers.push(reason); + } + + const cleanup = createCleanupEvidence(options.cleanupPlan, targetKind); + + const rollbackStatus = resolveRollbackStatus(rollbackFiles.length > 0, options.rollbackFailed === true); + if (rollbackStatus === "failed") { + blockers.push("Rollback execution failed; the migration cannot be fully verified."); + } + const limitations = [ "Static migration plans do not prove existing-data compatibility, lock duration, application compatibility, or rollback execution.", - "No database was contacted and no migration command was executed." + "No database was contacted and no migration command was executed.", + "A plan-ready additive migration still requires disposable mixed-version execution before a fully verified verdict." ]; if (files.length === 0) limitations.unshift("No migration SQL file was supplied."); if (targetKind === "unspecified") limitations.unshift("Database target classification is unspecified; execution must remain blocked."); - const matrix = createMatrix(operations, rollbackFiles.length > 0, targetKind); + if (options.databaseUrlEnv) limitations.push(`Database credentials are referenced only as environment variable ${options.databaseUrlEnv}; secret values were not read.`); + + const matrix = createMatrix(operations, rollbackFiles.length > 0, targetKind, rollbackStatus); + const verdict = resolveVerdict(blockers, targetKind, operations, rollbackStatus); + return { tool: "CodeDecay", schemaVersion: MIGRATION_EVIDENCE_SCHEMA_VERSION, generatedAt: options.generatedAt ?? new Date().toISOString(), dialect: "postgresql", targetKind, + verdict, + fullyVerified: false, + rollbackStatus, + connectionTarget, + cleanup, sourceFiles: files, rollbackFiles, operations, matrix, blockers, - investigationTasks: matrix.flatMap((item) => item.verificationTask ? [item.verificationTask] : []), + investigationTasks: matrix.flatMap((item) => (item.verificationTask ? [item.verificationTask] : [])), limitations, - safety: { commandsExecuted: false, databaseConnected: false, migrationApplied: false, secretsRead: false, productionTargetAllowed: false } + safety: { + commandsExecuted: false, + databaseConnected: false, + migrationApplied: false, + secretsRead: false, + productionTargetAllowed: false + } + }; +} + +function createCleanupEvidence(plan: string | undefined, targetKind: MigrationTargetKind): MigrationCleanupEvidence { + const trimmed = plan?.trim(); + return { + plan: trimmed || undefined, + required: targetKind === "disposable-local", + proven: false, + requiredOnFailure: true, + limitations: [ + "Cleanup was not executed by this plan-only analyzer.", + "Failed mixed-version or rollback paths still require disposable database cleanup." + ] }; } +function resolveRollbackStatus(rollbackProvided: boolean, rollbackFailed: boolean): MigrationRollbackStatus { + if (rollbackFailed) return "failed"; + if (!rollbackProvided) return "missing"; + return "unproven"; +} + +function resolveVerdict( + blockers: string[], + targetKind: MigrationTargetKind, + operations: MigrationOperationEvidence[], + rollbackStatus: MigrationRollbackStatus +): MigrationVerdict { + if (rollbackStatus === "failed" || blockers.length > 0 || targetKind !== "disposable-local") { + return blockers.length || rollbackStatus === "failed" ? "plan-blocked" : "not-fully-verified"; + } + const onlyAdditiveSafe = operations.length > 0 && operations.every((item) => item.risk === "info" && !item.destructive); + if (onlyAdditiveSafe) return "plan-ready"; + return "needs-execution-proof"; +} + function parseMigrationFile(rootDir: string, path: string, rollbackProvided: boolean): MigrationOperationEvidence[] { const resolved = resolveInside(rootDir, path); const size = statSync(resolved).size; @@ -79,50 +159,177 @@ function normalizeStatement(statement: Statement, path: string, index: number, r if (type === "alter table" && Array.isArray(value.changes)) { return value.changes.map((change, changeIndex) => normalizeAlter(change, table, path, index, changeIndex + 1, rollbackProvided)); } - if (type === "create index") return [operation("create-index", table, `Create index ${objectName(value.indexName) ?? "unknown-index"} on ${table}.`, path, index, "needs-proof", false, "high", false, rollbackProvided)]; - if (type === "drop table" || type === "drop type" || type === "drop sequence") return [operation("drop-object", table, `Drop ${type.slice(5)} ${table}.`, path, index, "blocker", true, "high", false, rollbackProvided)]; - if (type === "create table" || type === "create type" || type === "create sequence") return [operation("create-object", table, `Create ${type.slice(7)} ${table}.`, path, index, "info", false, "low", false, rollbackProvided)]; - if (type === "update") return [operation("backfill", table, `Backfill data in ${table}.`, path, index, "needs-proof", false, "high", false, rollbackProvided)]; + if (type === "create index") { + return [operation("create-index", table, `Create index ${objectName(value.indexName) ?? "unknown-index"} on ${table}.`, path, index, "needs-proof", false, "high", false, rollbackProvided)]; + } + if (type === "drop table" || type === "drop type" || type === "drop sequence") { + return [operation("drop-object", table, `Drop ${type.slice(5)} ${table}.`, path, index, "blocker", true, "high", false, rollbackProvided)]; + } + if (type === "create table" || type === "create type" || type === "create sequence") { + return [operation("create-object", table, `Create ${type.slice(7)} ${table}.`, path, index, "info", false, "low", false, rollbackProvided)]; + } + if (type === "update") { + return [operation("backfill", table, `Backfill data in ${table}.`, path, index, "needs-proof", false, "high", false, rollbackProvided)]; + } return [operation("other", table, `Review PostgreSQL statement type ${type}.`, path, index, "needs-proof", false, "unknown", false, rollbackProvided, ["Statement has no specialized migration classifier."])]; } -function normalizeAlter(change: unknown, table: string, path: string, statementIndex: number, changeIndex: number, rollbackProvided: boolean): MigrationOperationEvidence { +function normalizeAlter( + change: unknown, + table: string, + path: string, + statementIndex: number, + changeIndex: number, + rollbackProvided: boolean +): MigrationOperationEvidence { const value = change as Record; const type = String(value.type ?? "alter column"); const column = objectName(value.column) ?? objectName((value.column as Record | undefined)?.name) ?? "unknown-column"; const sourceIndex = `${statementIndex}.${changeIndex}`; - if (type === "drop column") return operation("drop-column", `${table}.${column}`, `Drop column ${table}.${column}.`, path, sourceIndex, "blocker", true, "high", false, rollbackProvided); + if (type === "drop column") { + return operation("drop-column", `${table}.${column}`, `Drop column ${table}.${column}.`, path, sourceIndex, "blocker", true, "high", false, rollbackProvided); + } + if (type === "rename column") { + const to = objectName(value.to) ?? "unknown-column"; + return operation( + "rename-column", + `${table}.${column}->${to}`, + `Rename column ${table}.${column} to ${to}; old application versions may break during rolling deployment.`, + path, + sourceIndex, + "blocker", + true, + "high", + false, + rollbackProvided + ); + } + if (type === "rename") { + const to = objectName(value.to) ?? "unknown-object"; + return operation( + "rename-object", + `${table}->${to}`, + `Rename ${table} to ${to}; old application versions may break during rolling deployment.`, + path, + sourceIndex, + "blocker", + true, + "high", + false, + rollbackProvided + ); + } if (type === "add column") { const definition = value.column as Record | undefined; const constraints = Array.isArray(definition?.constraints) ? definition.constraints as Array> : []; const notNull = constraints.some((item) => item.type === "not null"); const hasDefault = constraints.some((item) => item.type === "default"); const unsafe = notNull && !hasDefault; - return operation("add-column", `${table}.${column}`, `Add column ${table}.${column}${unsafe ? " as NOT NULL without a default or proven backfill" : ""}.`, path, sourceIndex, unsafe ? "blocker" : "info", false, unsafe ? "high" : "low", unsafe, rollbackProvided); + return operation( + "add-column", + `${table}.${column}`, + `Add column ${table}.${column}${unsafe ? " as NOT NULL without a default or proven backfill" : ""}.`, + path, + sourceIndex, + unsafe ? "blocker" : "info", + false, + unsafe ? "high" : "low", + unsafe, + rollbackProvided + ); } const risky = /set not null|alter type|set data type/i.test(type); return operation("alter-column", `${table}.${column}`, `Apply ${type} to ${table}.${column}.`, path, sourceIndex, risky ? "blocker" : "needs-proof", risky, "high", risky, rollbackProvided); } -function operation(kind: MigrationOperationKind, object: string, detail: string, path: string, index: number | string, risk: MigrationRisk, destructive: boolean, lockRisk: "low" | "unknown" | "high", requiresBackfill: boolean, rollbackProvided: boolean, limitations: string[] = []): MigrationOperationEvidence { +function operation( + kind: MigrationOperationKind, + object: string, + detail: string, + path: string, + index: number | string, + risk: MigrationRisk, + destructive: boolean, + lockRisk: "low" | "unknown" | "high", + requiresBackfill: boolean, + rollbackProvided: boolean, + limitations: string[] = [] +): MigrationOperationEvidence { const sourceRef = `${path}#statement:${index}`; return { evidenceId: `migration:${createHash("sha256").update(`${sourceRef}\0${kind}\0${object}`).digest("hex").slice(0, 20)}`, - kind, object, detail, sourceRef, risk, destructive, lockRisk, requiresBackfill, + kind, + object, + detail, + sourceRef, + risk, + destructive, + lockRisk, + requiresBackfill, rollbackSupported: rollbackProvided ? "unknown" : false, - limitations: [...limitations, ...(rollbackProvided ? ["Rollback SQL was supplied but not executed."] : ["No rollback SQL was supplied."])] + limitations: [ + ...limitations, + ...(rollbackProvided ? ["Rollback SQL was supplied but not executed."] : ["No rollback SQL was supplied."]) + ] }; } -function createMatrix(operations: MigrationOperationEvidence[], rollbackProvided: boolean, targetKind: MigrationTargetKind): MigrationMatrixState[] { +function createMatrix( + operations: MigrationOperationEvidence[], + rollbackProvided: boolean, + targetKind: MigrationTargetKind, + rollbackStatus: MigrationRollbackStatus +): MigrationMatrixState[] { const risky = operations.filter((item) => item.risk === "blocker"); const targetBlocked = targetKind !== "disposable-local"; return [ - { state: "old-app-old-schema", status: "baseline", evidenceIds: [], reason: "Baseline state is unchanged by this plan; runtime behavior was not executed." }, - { state: "old-app-new-schema", status: risky.length || targetBlocked ? "blocked" : "needs-proof", evidenceIds: risky.map((item) => item.evidenceId), reason: risky.length ? "Destructive or incompatible schema operations may break the old application." : "Old application compatibility with the new schema requires execution proof.", verificationTask: "Run the old application against the migrated disposable database and verify representative reads, writes, and jobs." }, - { state: "new-app-old-schema", status: targetBlocked ? "blocked" : "needs-proof", evidenceIds: [], reason: "The new application may access schema objects that do not exist before migration.", verificationTask: "Run the new application against the old disposable schema and verify startup plus changed persistence flows." }, - { state: "new-app-new-schema", status: targetBlocked ? "blocked" : "needs-proof", evidenceIds: operations.map((item) => item.evidenceId), reason: "Static SQL analysis cannot prove existing-data or application behavior.", verificationTask: "Apply the migration to representative disposable data and run changed API, job, and persistence checks." }, - { state: "rollback", status: !rollbackProvided || targetBlocked ? "blocked" : "needs-proof", evidenceIds: operations.filter((item) => item.destructive).map((item) => item.evidenceId), reason: rollbackProvided ? "Rollback SQL exists but has not been executed or checked for data restoration." : "No rollback SQL was supplied.", verificationTask: "Execute the reviewed rollback on a disposable database and prove schema, data, and cleanup outcomes." } + { + state: "old-app-old-schema", + status: "baseline", + evidenceIds: [], + reason: "Baseline state is unchanged by this plan; runtime behavior was not executed." + }, + { + state: "old-app-new-schema", + status: risky.length || targetBlocked ? "blocked" : "needs-proof", + evidenceIds: risky.map((item) => item.evidenceId), + reason: risky.length + ? "Destructive or incompatible schema operations may break the old application during rolling deployment." + : "Old application compatibility with the new schema requires execution proof.", + verificationTask: + "Run the old application against the migrated disposable database and verify representative reads, writes, and jobs." + }, + { + state: "new-app-old-schema", + status: targetBlocked ? "blocked" : "needs-proof", + evidenceIds: [], + reason: "The new application may access schema objects that do not exist before migration.", + verificationTask: "Run the new application against the old disposable schema and verify startup plus changed persistence flows." + }, + { + state: "new-app-new-schema", + status: targetBlocked ? "blocked" : "needs-proof", + evidenceIds: operations.map((item) => item.evidenceId), + reason: "Static SQL analysis cannot prove existing-data or application behavior.", + verificationTask: "Apply the migration to representative disposable data and run changed API, job, and persistence checks." + }, + { + state: "rollback", + status: + rollbackStatus === "failed" + ? "failed" + : !rollbackProvided || targetBlocked + ? "blocked" + : "needs-proof", + evidenceIds: operations.filter((item) => item.destructive).map((item) => item.evidenceId), + reason: + rollbackStatus === "failed" + ? "Rollback execution failed; full verification is blocked until rollback succeeds on a disposable database." + : rollbackProvided + ? "Rollback SQL exists but has not been executed or checked for data restoration." + : "No rollback SQL was supplied.", + verificationTask: "Execute the reviewed rollback on a disposable database and prove schema, data, and cleanup outcomes." + } ]; } @@ -133,10 +340,14 @@ function boundedFiles(files: string[], label: string): string[] { function resolveInside(rootDir: string, path: string): string { const lexical = resolve(rootDir, path); - if (lexical !== rootDir && !lexical.startsWith(`${rootDir}/`)) throw new Error(`Migration path must stay inside repository: ${path}`); + if (lexical !== rootDir && !lexical.startsWith(`${rootDir}/`)) { + throw new Error(`Migration path must stay inside repository: ${path}`); + } if (!existsSync(lexical)) throw new Error(`Migration file not found: ${path}`); const real = realpathSync(lexical); - if (real !== rootDir && !real.startsWith(`${rootDir}/`)) throw new Error(`Migration path must stay inside repository: ${path}`); + if (real !== rootDir && !real.startsWith(`${rootDir}/`)) { + throw new Error(`Migration path must stay inside repository: ${path}`); + } return real; } diff --git a/packages/knowledge/src/migration/render.ts b/packages/knowledge/src/migration/render.ts index 8c9469c..4e47985 100644 --- a/packages/knowledge/src/migration/render.ts +++ b/packages/knowledge/src/migration/render.ts @@ -2,17 +2,29 @@ import type { MigrationSafetyReport } from "./types"; export function renderMigrationSafetyMarkdown(report: MigrationSafetyReport): string { const lines = [ - "## CodeDecay Migration Safety Plan", "", - `Target: \`${report.targetKind}\`; dialect: \`${report.dialect}\`; commands executed: no.`, "", - "### Operations", "" + "## CodeDecay Migration Safety Plan", + "", + `Target: \`${report.targetKind}\`; dialect: \`${report.dialect}\`; verdict: \`${report.verdict}\`; fullyVerified: \`${report.fullyVerified}\`.`, + `Rollback status: \`${report.rollbackStatus}\`; cleanup proven: \`${report.cleanup.proven}\`.`, + report.connectionTarget ? `Connection target: \`${report.connectionTarget.redacted}\` (${report.connectionTarget.kind}).` : "Connection target: unspecified.", + "Commands executed: no.", + "", + "### Operations", + "" ]; if (!report.operations.length) lines.push("No migration operation was analyzed."); - for (const item of report.operations) lines.push(`- **${item.risk}** \`${item.kind}\` ${item.detail} Source: \`${item.sourceRef}\``); + for (const item of report.operations) { + lines.push(`- **${item.risk}** \`${item.kind}\` ${item.detail} Source: \`${item.sourceRef}\``); + } lines.push("", "### Deployment Matrix", "", "| State | Status | Reason |", "| --- | --- | --- |"); for (const item of report.matrix) lines.push(`| ${item.state} | ${item.status} | ${item.reason} |`); lines.push("", "### Blockers", ""); if (!report.blockers.length) lines.push("No static blocker was found; execution proof is still required."); for (const blocker of report.blockers) lines.push(`- ${blocker}`); + lines.push("", "### Cleanup", ""); + lines.push(`- Required: \`${report.cleanup.required}\``); + lines.push(`- Plan: ${report.cleanup.plan ? `\`${report.cleanup.plan}\`` : "missing"}`); + lines.push(`- Proven: \`${report.cleanup.proven}\` (required on failure: \`${report.cleanup.requiredOnFailure}\`)`); lines.push("", "### Verification Tasks", ""); for (const task of report.investigationTasks) lines.push(`- ${task}`); lines.push("", "### Limitations", ""); diff --git a/packages/knowledge/src/migration/target-safety.ts b/packages/knowledge/src/migration/target-safety.ts new file mode 100644 index 0000000..fbafb7d --- /dev/null +++ b/packages/knowledge/src/migration/target-safety.ts @@ -0,0 +1,89 @@ +import type { MigrationConnectionTarget, MigrationTargetKind } from "./types"; + +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "0.0.0.0", "host.docker.internal"]); +const PROD_HINT = + /\b(prod|production|live|aurora|rds\.amazonaws\.com|neon\.tech|supabase\.co|azure\.com|cloudsql|planetscale|cockroachlabs\.cloud|aiven\.io)\b/i; + +/** + * Classify a database connection target without persisting secrets. + * Accepts a URL, host, or env-var *name*. Secret values are never returned. + */ +export function classifyMigrationConnectionTarget(input: { + connectionUrl?: string | undefined; + connectionHost?: string | undefined; + databaseUrlEnv?: string | undefined; + declaredTargetKind?: MigrationTargetKind | undefined; +}): MigrationConnectionTarget { + const reasons: string[] = []; + const envName = input.databaseUrlEnv?.trim(); + if (envName) { + if (!/^[A-Z][A-Z0-9_]*$/.test(envName)) { + reasons.push("database URL environment variable names must match /^[A-Z][A-Z0-9_]*$/."); + } + } + + const raw = input.connectionUrl?.trim() || input.connectionHost?.trim() || ""; + const host = + extractHost(raw) ?? + (looksLikeHost(raw) || LOCAL_HOSTS.has(raw.toLowerCase()) ? raw.toLowerCase() : undefined); + const redacted = envName + ? `env:${envName}` + : host + ? `host:${host}` + : input.declaredTargetKind + ? `kind:${input.declaredTargetKind}` + : "unspecified"; + + let kind: MigrationTargetKind = input.declaredTargetKind ?? "unspecified"; + if (host) { + if (isLocalHost(host)) { + kind = "disposable-local"; + } else if (PROD_HINT.test(host) || PROD_HINT.test(raw)) { + kind = "production-like"; + reasons.push(`Connection target looks production-like (${host}).`); + } else { + kind = "remote-unapproved"; + reasons.push(`Remote host ${host} is not an approved disposable local target.`); + } + } else if (!input.declaredTargetKind) { + kind = "unspecified"; + reasons.push("No connection host/URL or disposable-local target classification was supplied."); + } + + if (kind === "production-like") { + reasons.push("Production-like database targets are blocked."); + } + if (kind === "remote-unapproved") { + reasons.push("Remote database target is not explicitly approved as disposable."); + } + + const blocked = kind !== "disposable-local"; + return { + kind, + host, + redacted, + blocked, + reasons: [...new Set(reasons)] + }; +} + +function extractHost(value: string): string | undefined { + if (!value) return undefined; + try { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value)) { + const url = new URL(value); + return url.hostname.toLowerCase() || undefined; + } + } catch { + return undefined; + } + return undefined; +} + +function looksLikeHost(value: string): boolean { + return /^[a-z0-9.-]+$/i.test(value) && value.includes("."); +} + +function isLocalHost(host: string): boolean { + return LOCAL_HOSTS.has(host) || host.endsWith(".local") || host.endsWith(".localhost"); +} diff --git a/packages/knowledge/src/migration/types.ts b/packages/knowledge/src/migration/types.ts index f8548a7..bdfdb73 100644 --- a/packages/knowledge/src/migration/types.ts +++ b/packages/knowledge/src/migration/types.ts @@ -2,11 +2,15 @@ export const MIGRATION_EVIDENCE_SCHEMA_VERSION = 1 as const; export type MigrationRisk = "info" | "needs-proof" | "blocker"; export type MigrationTargetKind = "unspecified" | "disposable-local" | "remote-unapproved" | "production-like"; +export type MigrationVerdict = "plan-ready" | "plan-blocked" | "needs-execution-proof" | "not-fully-verified"; +export type MigrationRollbackStatus = "missing" | "planned" | "failed" | "unproven"; export type MigrationOperationKind = | "create-object" | "drop-object" | "add-column" | "drop-column" + | "rename-column" + | "rename-object" | "alter-column" | "create-index" | "backfill" @@ -28,18 +32,39 @@ export interface MigrationOperationEvidence { export interface MigrationMatrixState { state: "old-app-old-schema" | "old-app-new-schema" | "new-app-old-schema" | "new-app-new-schema" | "rollback"; - status: "baseline" | "needs-proof" | "blocked"; + status: "baseline" | "needs-proof" | "blocked" | "failed"; evidenceIds: string[]; reason: string; verificationTask?: string | undefined; } +export interface MigrationConnectionTarget { + kind: MigrationTargetKind; + host?: string | undefined; + redacted: string; + blocked: boolean; + reasons: string[]; +} + +export interface MigrationCleanupEvidence { + plan?: string | undefined; + required: boolean; + proven: false; + requiredOnFailure: true; + limitations: string[]; +} + export interface MigrationSafetyReport { tool: "CodeDecay"; schemaVersion: typeof MIGRATION_EVIDENCE_SCHEMA_VERSION; generatedAt: string; dialect: "postgresql"; targetKind: MigrationTargetKind; + verdict: MigrationVerdict; + fullyVerified: false; + rollbackStatus: MigrationRollbackStatus; + connectionTarget?: MigrationConnectionTarget | undefined; + cleanup: MigrationCleanupEvidence; sourceFiles: string[]; rollbackFiles: string[]; operations: MigrationOperationEvidence[]; diff --git a/packages/knowledge/test/migration-safety.test.ts b/packages/knowledge/test/migration-safety.test.ts index ac58e21..0a017d4 100644 --- a/packages/knowledge/test/migration-safety.test.ts +++ b/packages/knowledge/test/migration-safety.test.ts @@ -1,33 +1,171 @@ -import { mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { analyzeMigrationSafety } from "../src/index"; +import { analyzeMigrationSafety, classifyMigrationConnectionTarget } from "../src/index"; const roots: string[] = []; -afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("UAT migration data safety (#686)", () => { + it("UAT-DATA-1: additive nullable migration is plan-ready for mixed-version checks", () => { + const root = tempRoot(); + write(root, "migration.sql", 'ALTER TABLE "User" ADD COLUMN "nickname" text;'); + const report = analyzeMigrationSafety({ + rootDir: root, + files: ["migration.sql"], + targetKind: "disposable-local", + cleanupPlan: "drop disposable volume codedecay-mig", + generatedAt: "2026-08-06T00:00:00.000Z" + }); + expect(report.operations[0]).toMatchObject({ kind: "add-column", risk: "info", requiresBackfill: false }); + expect(report.blockers).toEqual([]); + expect(report.verdict).toBe("plan-ready"); + expect(report.fullyVerified).toBe(false); + expect(report.matrix.find((item) => item.state === "old-app-new-schema")?.status).toBe("needs-proof"); + }); + + it("UAT-DATA-2: non-null column without safe default/backfill is blocked", () => { + const root = tempRoot(); + write(root, "migration.sql", 'ALTER TABLE "User" ADD COLUMN "email" text NOT NULL;'); + const report = analyzeMigrationSafety({ + rootDir: root, + files: ["migration.sql"], + targetKind: "disposable-local", + cleanupPlan: "drop disposable volume codedecay-mig" + }); + expect(report.operations[0]).toMatchObject({ kind: "add-column", risk: "blocker", requiresBackfill: true }); + expect(report.verdict).toBe("plan-blocked"); + expect(report.matrix.find((item) => item.state === "old-app-new-schema")?.status).toBe("blocked"); + }); + + it("UAT-DATA-3: renamed or removed field breaks old app during rolling deploy", () => { + const root = tempRoot(); + write( + root, + "migration.sql", + 'ALTER TABLE "User" RENAME COLUMN "name" TO "full_name"; ALTER TABLE "User" DROP COLUMN "legacy";' + ); + const report = analyzeMigrationSafety({ + rootDir: root, + files: ["migration.sql"], + targetKind: "disposable-local", + cleanupPlan: "drop disposable volume codedecay-mig" + }); + expect(report.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "rename-column", risk: "blocker" }), + expect.objectContaining({ kind: "drop-column", risk: "blocker", destructive: true }) + ]) + ); + expect(report.matrix.find((item) => item.state === "old-app-new-schema")).toMatchObject({ status: "blocked" }); + expect(report.verdict).toBe("plan-blocked"); + }); + + it("UAT-DATA-4: rollback failure remains visible and blocks full verification", () => { + const root = tempRoot(); + write(root, "up.sql", 'ALTER TABLE "User" ADD COLUMN "nickname" text;'); + write(root, "down.sql", 'ALTER TABLE "User" DROP COLUMN "nickname";'); + const report = analyzeMigrationSafety({ + rootDir: root, + files: ["up.sql"], + rollbackFiles: ["down.sql"], + targetKind: "disposable-local", + cleanupPlan: "drop disposable volume codedecay-mig", + rollbackFailed: true + }); + expect(report.rollbackStatus).toBe("failed"); + expect(report.matrix.find((item) => item.state === "rollback")?.status).toBe("failed"); + expect(report.fullyVerified).toBe(false); + expect(report.verdict).toBe("plan-blocked"); + expect(report.blockers.join(" ")).toMatch(/Rollback execution failed/i); + }); + + it("UAT-DATA-5: production-looking connection string is blocked without leaking secrets", () => { + const root = tempRoot(); + write(root, "migration.sql", 'CREATE TABLE "Audit" ("id" text PRIMARY KEY);'); + const report = analyzeMigrationSafety({ + rootDir: root, + files: ["migration.sql"], + connectionUrl: "postgres://app:s3cret-pass@db.abc123.us-east-1.rds.amazonaws.com:5432/app", + databaseUrlEnv: "DATABASE_URL", + cleanupPlan: "n/a" + }); + expect(report.targetKind).toBe("production-like"); + expect(report.connectionTarget).toMatchObject({ blocked: true, kind: "production-like" }); + expect(report.verdict).toBe("plan-blocked"); + const serialized = JSON.stringify(report); + expect(serialized).not.toContain("s3cret-pass"); + expect(serialized).toContain("env:DATABASE_URL"); + expect(classifyMigrationConnectionTarget({ connectionHost: "localhost" }).kind).toBe("disposable-local"); + }); + + it("UAT-DATA-6: disposable cleanup obligation remains proven-false including failed paths", () => { + const root = tempRoot(); + write(root, "migration.sql", 'ALTER TABLE "User" ADD COLUMN "nickname" text;'); + const failed = analyzeMigrationSafety({ + rootDir: root, + files: ["migration.sql"], + targetKind: "disposable-local", + cleanupPlan: "drop volume codedecay-mig", + rollbackFailed: true + }); + expect(failed.cleanup).toMatchObject({ + required: true, + proven: false, + requiredOnFailure: true, + plan: "drop volume codedecay-mig" + }); + expect(failed.cleanup.limitations.join(" ")).toMatch(/Failed mixed-version or rollback paths still require/i); + expect(failed.fullyVerified).toBe(false); + }); +}); describe("migration safety analysis", () => { it("keeps an additive nullable migration as execution proof rather than a blocker", () => { const root = tempRoot(); write(root, "migration.sql", 'ALTER TABLE "User" ADD COLUMN "nickname" text;'); - const report = analyzeMigrationSafety({ rootDir: root, files: ["migration.sql"], targetKind: "disposable-local", generatedAt: "2026-08-02T00:00:00.000Z" }); - expect(report.operations[0]).toMatchObject({ kind: "add-column", object: "User.nickname", risk: "info", destructive: false, requiresBackfill: false }); + const report = analyzeMigrationSafety({ + rootDir: root, + files: ["migration.sql"], + targetKind: "disposable-local", + generatedAt: "2026-08-02T00:00:00.000Z" + }); + expect(report.operations[0]).toMatchObject({ + kind: "add-column", + object: "User.nickname", + risk: "info", + destructive: false, + requiresBackfill: false + }); expect(report.blockers).toEqual([]); expect(report.matrix.find((item) => item.state === "old-app-new-schema")?.status).toBe("needs-proof"); - expect(report.safety).toEqual({ commandsExecuted: false, databaseConnected: false, migrationApplied: false, secretsRead: false, productionTargetAllowed: false }); + expect(report.safety).toEqual({ + commandsExecuted: false, + databaseConnected: false, + migrationApplied: false, + secretsRead: false, + productionTargetAllowed: false + }); }); it("blocks a non-null column without a default and destructive rolling deployment", () => { const root = tempRoot(); write(root, "migration.sql", 'ALTER TABLE "User" ADD COLUMN "email" text NOT NULL; ALTER TABLE "User" DROP COLUMN "name";'); const report = analyzeMigrationSafety({ rootDir: root, files: ["migration.sql"], targetKind: "disposable-local" }); - expect(report.operations).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: "add-column", risk: "blocker", requiresBackfill: true }), - expect.objectContaining({ kind: "drop-column", risk: "blocker", destructive: true }) - ])); + expect(report.operations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "add-column", risk: "blocker", requiresBackfill: true }), + expect.objectContaining({ kind: "drop-column", risk: "blocker", destructive: true }) + ]) + ); expect(report.matrix.find((item) => item.state === "old-app-new-schema")).toMatchObject({ status: "blocked" }); - expect(report.matrix.find((item) => item.state === "rollback")).toMatchObject({ status: "blocked", reason: "No rollback SQL was supplied." }); + expect(report.matrix.find((item) => item.state === "rollback")).toMatchObject({ + status: "blocked", + reason: "No rollback SQL was supplied." + }); }); it("blocks production-like targets without reading a connection string", () => { @@ -39,7 +177,8 @@ describe("migration safety analysis", () => { expect(JSON.stringify(report)).not.toContain("DATABASE_URL"); }); - it("rejects malformed SQL and paths that escape through symlinks", () => { + it("rejects malformed SQL and paths that escape through symlinks", async () => { + const { symlinkSync } = await import("node:fs"); const root = tempRoot(); const outside = tempRoot(); write(root, "bad.sql", "ALTER TABLE ???"); @@ -53,7 +192,9 @@ describe("migration safety analysis", () => { const root = tempRoot(); write(root, "up.sql", 'ALTER TABLE "User" ADD COLUMN "nickname" text;'); write(root, "down.sql", "ALTER TABLE ???"); - expect(() => analyzeMigrationSafety({ rootDir: root, files: ["up.sql"], rollbackFiles: ["down.sql"], targetKind: "disposable-local" })).toThrow("Unable to parse PostgreSQL migration down.sql"); + expect(() => + analyzeMigrationSafety({ rootDir: root, files: ["up.sql"], rollbackFiles: ["down.sql"], targetKind: "disposable-local" }) + ).toThrow("Unable to parse PostgreSQL migration down.sql"); write(root, "down.sql", 'ALTER TABLE "User" DROP COLUMN "nickname";'); const report = analyzeMigrationSafety({ rootDir: root, files: ["up.sql"], rollbackFiles: ["down.sql"] }); expect(report.rollbackFiles).toEqual(["down.sql"]); @@ -64,6 +205,11 @@ describe("migration safety analysis", () => { function tempRoot(): string { const root = join(tmpdir(), `codedecay-migration-${Date.now()}-${Math.random().toString(16).slice(2)}`); - mkdirSync(root, { recursive: true }); roots.push(root); return root; + mkdirSync(root, { recursive: true }); + roots.push(root); + return root; +} + +function write(root: string, path: string, content: string): void { + writeFileSync(join(root, path), content, "utf8"); } -function write(root: string, path: string, content: string): void { writeFileSync(join(root, path), content, "utf8"); } diff --git a/packages/mcp/src/handlers/migration-safety.ts b/packages/mcp/src/handlers/migration-safety.ts new file mode 100644 index 0000000..38c1448 --- /dev/null +++ b/packages/mcp/src/handlers/migration-safety.ts @@ -0,0 +1,41 @@ +import { resolve } from "node:path"; +import { + analyzeMigrationSafety, + renderMigrationSafetyMarkdown +} from "@submuxhq/codedecay-knowledge"; +import type { StartMcpServerOptions } from "../server/types"; + +export interface MigrationSafetyToolInput { + cwd?: string | undefined; + format?: "markdown" | "json" | undefined; + files?: string[] | undefined; + rollbackFiles?: string[] | undefined; + targetKind?: "unspecified" | "disposable-local" | "remote-unapproved" | "production-like" | undefined; + connectionUrl?: string | undefined; + connectionHost?: string | undefined; + databaseUrlEnv?: string | undefined; + cleanupPlan?: string | undefined; + rollbackFailed?: boolean | undefined; +} + +export async function runMigrationSafetyTool( + options: StartMcpServerOptions, + input: MigrationSafetyToolInput +): Promise { + const rootDir = resolve(options.cwd ?? process.cwd(), input.cwd ?? "."); + const report = analyzeMigrationSafety({ + rootDir, + files: input.files ?? [], + rollbackFiles: input.rollbackFiles, + targetKind: input.targetKind, + connectionUrl: input.connectionUrl, + connectionHost: input.connectionHost, + databaseUrlEnv: input.databaseUrlEnv, + cleanupPlan: input.cleanupPlan, + rollbackFailed: input.rollbackFailed + }); + if ((input.format ?? "markdown") === "json") { + return JSON.stringify(report, null, 2); + } + return renderMigrationSafetyMarkdown(report); +} diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 8d9abb1..446dd8b 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -30,6 +30,7 @@ import { import { runContextServiceTool } from "./handlers/context-service"; import { runServiceTopologyTool } from "./handlers/service-topology"; import { runRuntimeEvidenceTool } from "./handlers/runtime-evidence"; +import { runMigrationSafetyTool } from "./handlers/migration-safety"; import type { StartMcpServerOptions } from "./server/types"; import { registerCodeDecayMcpTools } from "./tools/registry"; @@ -57,6 +58,7 @@ export { runExecuteConfiguredChecksTool } from "./handlers/execution"; export { runContextServiceTool } from "./handlers/context-service"; export { runServiceTopologyTool } from "./handlers/service-topology"; export { runRuntimeEvidenceTool } from "./handlers/runtime-evidence"; +export { runMigrationSafetyTool } from "./handlers/migration-safety"; export { runProductFailuresTool, runProductPlanTool, @@ -91,6 +93,7 @@ export function createCodeDecayMcpServer(options: StartMcpServerOptions): McpSer contextService: (input) => runContextServiceTool(options, input), serviceTopology: (input) => runServiceTopologyTool(options, input), runtimeEvidence: (input) => runRuntimeEvidenceTool(options, input), + migrationSafety: (input) => runMigrationSafetyTool(options, input), agentInvestigation: (input) => runAgentInvestigationTool(options, input), scopeCheck: (input) => runScopeCheckTool(options, input), designContractCheck: (input) => runDesignContractCheckTool(options, input), diff --git a/packages/mcp/src/tools/register-analysis.ts b/packages/mcp/src/tools/register-analysis.ts index 3d8de40..174dd5a 100644 --- a/packages/mcp/src/tools/register-analysis.ts +++ b/packages/mcp/src/tools/register-analysis.ts @@ -14,7 +14,8 @@ import { taskContextToolSchema, contextServiceToolSchema, serviceTopologyToolSchema, - runtimeEvidenceToolSchema + runtimeEvidenceToolSchema, + migrationSafetyToolSchema } from "./schemas"; import type { AgentPreflightToolInput, @@ -31,7 +32,8 @@ import type { WhatDidIMissToolInput, ContextServiceToolInput, ServiceTopologyToolInput, - RuntimeEvidenceToolInput + RuntimeEvidenceToolInput, + MigrationSafetyToolInput } from "./types"; export function registerAnalysisMcpTools(server: McpServer, handlers: CodeDecayMcpToolHandlers): void { @@ -140,6 +142,13 @@ export function registerAnalysisMcpTools(server: McpServer, handlers: CodeDecayM async (input) => textResult(handlers.runtimeEvidence(input as RuntimeEvidenceToolInput)) ); + server.tool( + "migration_safety", + "Plan-only PostgreSQL migration safety analysis with mixed-version matrix, target gates, and cleanup obligations. Does not connect to a database.", + migrationSafetyToolSchema, + async (input) => textResult(handlers.migrationSafety(input as MigrationSafetyToolInput)) + ); + server.tool( "scope_check", "Return a deterministic in-scope/out-of-scope verdict for the current PR or working tree.", diff --git a/packages/mcp/src/tools/registry.ts b/packages/mcp/src/tools/registry.ts index e6266bf..2674627 100644 --- a/packages/mcp/src/tools/registry.ts +++ b/packages/mcp/src/tools/registry.ts @@ -21,7 +21,8 @@ import type { WhatDidIMissToolInput, ContextServiceToolInput, ServiceTopologyToolInput, - RuntimeEvidenceToolInput + RuntimeEvidenceToolInput, + MigrationSafetyToolInput } from "./types"; export interface CodeDecayMcpToolHandlers { @@ -39,6 +40,7 @@ export interface CodeDecayMcpToolHandlers { contextService(input: ContextServiceToolInput): string | Promise; serviceTopology(input: ServiceTopologyToolInput): string | Promise; runtimeEvidence(input: RuntimeEvidenceToolInput): string | Promise; + migrationSafety(input: MigrationSafetyToolInput): string | Promise; agentInvestigation(input: AgentInvestigationToolInput): string | Promise; scopeCheck(input: ScopeCheckToolInput): string | Promise; designContractCheck(input: DesignContractCheckToolInput): string | Promise; diff --git a/packages/mcp/src/tools/schemas.ts b/packages/mcp/src/tools/schemas.ts index 317b1e8..254a9a8 100644 --- a/packages/mcp/src/tools/schemas.ts +++ b/packages/mcp/src/tools/schemas.ts @@ -141,6 +141,22 @@ export const runtimeEvidenceToolSchema = { environment: z.string().optional().describe("Environment label when an export omits one.") }; +export const migrationSafetyToolSchema = { + cwd: cwdSchema, + format: formatSchema, + files: z.array(z.string()).optional().describe("Repo-local migration SQL files."), + rollbackFiles: z.array(z.string()).optional().describe("Repo-local rollback SQL files."), + targetKind: z + .enum(["unspecified", "disposable-local", "remote-unapproved", "production-like"]) + .optional() + .describe("Database target classification."), + connectionUrl: z.string().optional().describe("Optional URL used only for host classification; secrets are redacted."), + connectionHost: z.string().optional().describe("Optional host used for target classification."), + databaseUrlEnv: z.string().optional().describe("Credential env var name; values are never read."), + cleanupPlan: z.string().optional().describe("Disposable database cleanup plan."), + rollbackFailed: z.boolean().optional().describe("Mark rollback as failed.") +}; + export const agentSessionToolSchema = { cwd: cwdSchema, operation: z.enum(["start", "context", "checkpoint", "finish"]).describe("Session lifecycle operation."), diff --git a/packages/mcp/src/tools/types.ts b/packages/mcp/src/tools/types.ts index 6f17ec7..f18bb98 100644 --- a/packages/mcp/src/tools/types.ts +++ b/packages/mcp/src/tools/types.ts @@ -73,6 +73,19 @@ export interface RuntimeEvidenceToolInput { environment?: string | undefined; } +export interface MigrationSafetyToolInput { + cwd?: string | undefined; + format?: "markdown" | "json" | undefined; + files?: string[] | undefined; + rollbackFiles?: string[] | undefined; + targetKind?: "unspecified" | "disposable-local" | "remote-unapproved" | "production-like" | undefined; + connectionUrl?: string | undefined; + connectionHost?: string | undefined; + databaseUrlEnv?: string | undefined; + cleanupPlan?: string | undefined; + rollbackFailed?: boolean | undefined; +} + export interface AgentSessionToolInput { cwd?: string | undefined; operation: "start" | "context" | "checkpoint" | "finish"; diff --git a/packages/mcp/test/mcp-migration-safety.test.ts b/packages/mcp/test/mcp-migration-safety.test.ts new file mode 100644 index 0000000..03a8f1d --- /dev/null +++ b/packages/mcp/test/mcp-migration-safety.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { runMigrationSafetyTool } from "../src/index"; +import { createRepo } from "./helpers/mcp"; + +describe("MCP migration_safety tool", () => { + it("returns a plan-ready additive migration without secrets", async () => { + const repo = createRepo({ + "migration.sql": 'ALTER TABLE "User" ADD COLUMN "nickname" text;\n' + }); + const output = await runMigrationSafetyTool( + { cwd: repo }, + { + format: "json", + files: ["migration.sql"], + targetKind: "disposable-local", + cleanupPlan: "drop volume codedecay-mig", + databaseUrlEnv: "DATABASE_URL" + } + ); + const report = JSON.parse(output) as { + verdict: string; + fullyVerified: boolean; + safety: { databaseConnected: boolean }; + }; + expect(report.verdict).toBe("plan-ready"); + expect(report.fullyVerified).toBe(false); + expect(report.safety.databaseConnected).toBe(false); + expect(output).not.toContain("postgres://"); + }); + + it("blocks a production-looking host", async () => { + const repo = createRepo({ "migration.sql": 'CREATE TABLE "T" ("id" text);\n' }); + writeFileSync(join(repo, "migration.sql"), 'CREATE TABLE "T" ("id" text);\n'); + const output = await runMigrationSafetyTool( + { cwd: repo }, + { format: "json", files: ["migration.sql"], connectionHost: "db.rds.amazonaws.com" } + ); + const report = JSON.parse(output) as { verdict: string; targetKind: string }; + expect(report.targetKind).toBe("production-like"); + expect(report.verdict).toBe("plan-blocked"); + }); +});