From 6c10c98398d4cee1492254a015487b4642daa6e5 Mon Sep 17 00:00:00 2001 From: kunaldhongade Date: Sun, 2 Aug 2026 19:32:48 +0530 Subject: [PATCH] feat(migration): add schema safety planning --- packages/cli/src/commands/migration.ts | 18 +++ packages/cli/src/commands/registry.ts | 5 + .../cli/src/docs/command-docs/analysis.ts | 16 ++ packages/cli/src/docs/command-docs/order.ts | 2 +- packages/cli/src/parsers/args.ts | 1 + packages/cli/src/parsers/migration.ts | 38 +++++ packages/cli/src/types/index.ts | 1 + packages/cli/src/types/migration.ts | 11 ++ packages/cli/test/built-cli-migration.test.ts | 24 +++ packages/cli/test/migration.test.ts | 41 +++++ packages/knowledge/package.json | 1 + packages/knowledge/src/index.ts | 12 ++ packages/knowledge/src/migration/analyze.ts | 148 ++++++++++++++++++ packages/knowledge/src/migration/render.ts | 21 +++ packages/knowledge/src/migration/types.ts | 57 +++++++ .../knowledge/test/migration-safety.test.ts | 69 ++++++++ packages/tool-adapters/package.json | 3 +- packages/tool-adapters/src/index.ts | 1 + packages/tool-adapters/src/migration/index.ts | 2 + packages/tool-adapters/src/migration/plan.ts | 36 +++++ packages/tool-adapters/src/migration/types.ts | 30 ++++ packages/tool-adapters/test/migration.test.ts | 32 ++++ pnpm-lock.yaml | 60 +++++++ 23 files changed, 627 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/migration.ts create mode 100644 packages/cli/src/parsers/migration.ts create mode 100644 packages/cli/src/types/migration.ts create mode 100644 packages/cli/test/built-cli-migration.test.ts create mode 100644 packages/cli/test/migration.test.ts create mode 100644 packages/knowledge/src/migration/analyze.ts create mode 100644 packages/knowledge/src/migration/render.ts create mode 100644 packages/knowledge/src/migration/types.ts create mode 100644 packages/knowledge/test/migration-safety.test.ts create mode 100644 packages/tool-adapters/src/migration/index.ts create mode 100644 packages/tool-adapters/src/migration/plan.ts create mode 100644 packages/tool-adapters/src/migration/types.ts create mode 100644 packages/tool-adapters/test/migration.test.ts diff --git a/packages/cli/src/commands/migration.ts b/packages/cli/src/commands/migration.ts new file mode 100644 index 00000000..1baf0dbf --- /dev/null +++ b/packages/cli/src/commands/migration.ts @@ -0,0 +1,18 @@ +import { resolve } from "node:path"; +import { analyzeMigrationSafety, renderMigrationSafetyMarkdown } from "@submuxhq/codedecay-knowledge"; +import { parseMigrationArgs } from "../parsers/args"; +import type { CliCommandContext, CliRuntime, MigrationOptions } from "../types"; + +export interface RunMigrationCommandDependencies { + resolveRepoRoot(cwd: string, options: MigrationOptions): string; + writeOutput(input: { cwd: string; output?: string | undefined; rendered: string; runtime: CliRuntime }): void; +} + +export function runMigrationCommand(context: CliCommandContext, dependencies: RunMigrationCommandDependencies): void { + 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 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/commands/registry.ts b/packages/cli/src/commands/registry.ts index 976993fd..caece532 100644 --- a/packages/cli/src/commands/registry.ts +++ b/packages/cli/src/commands/registry.ts @@ -16,6 +16,7 @@ import { runMemoryLearnCommand as runMemoryLearnCommandWithDependencies } from "./memory"; import { runMcpCommand as runMcpCommandWithDependencies } from "./mcp"; +import { runMigrationCommand as runMigrationCommandWithDependencies } from "./migration"; import { runProductCommand as runProductCommandWithDependencies } from "./product"; import { runRedteamCommand as runRedteamCommandWithDependencies } from "./redteam"; import { runRevalidateCommand as runRevalidateCommandWithDependencies } from "./revalidate"; @@ -93,6 +94,10 @@ export function createCommandHandlers(options: CommandRegistryOptions): Record runMcpCommandWithDependencies(context, { cliPath: options.cliPath }), + migration: (context) => runMigrationCommandWithDependencies(context, { + resolveRepoRoot: getRepoRootForCli, + writeOutput: writeCliOutput + }), memory: (context) => runMemoryCommandWithDependencies(context, { resolveRepoRoot: getRepoRootForCli }), "memory-import": (context) => runMemoryImportCommandWithDependencies(context, { resolveRepoRoot: getRepoRootForCli }), "memory-learn": (context) => runMemoryLearnCommandWithDependencies(context, { resolveRepoRoot: getRepoRootForCli }), diff --git a/packages/cli/src/docs/command-docs/analysis.ts b/packages/cli/src/docs/command-docs/analysis.ts index a8f02004..772aeaac 100644 --- a/packages/cli/src/docs/command-docs/analysis.ts +++ b/packages/cli/src/docs/command-docs/analysis.ts @@ -1,6 +1,22 @@ import type { CommandDoc } from "../../renderers/discovery"; export const ANALYSIS_COMMAND_DOCS: Record = { + migration: { + name: "migration", + summary: "Plan schema migration and mixed-version safety checks.", + usage: ["codedecay migration [options]"], + description: ["Parse repo-local PostgreSQL or Prisma migration SQL, identify destructive and backfill risks, and emit a five-state deployment compatibility matrix without contacting a database."], + options: [ + { 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: "--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."] + }, runtime: { name: "runtime", summary: "Ingest local runtime exports as redacted engineering evidence.", diff --git a/packages/cli/src/docs/command-docs/order.ts b/packages/cli/src/docs/command-docs/order.ts index 6b997750..3466a415 100644 --- a/packages/cli/src/docs/command-docs/order.ts +++ b/packages/cli/src/docs/command-docs/order.ts @@ -1,3 +1,3 @@ -export const COMMAND_ORDER = ["ai", "session", "context", "analyze", "runtime", "benchmark", "snapshot", "redteam", "revalidate", "llm-review", "agent", "loop", "doctor", "config", "memory", "memory-import", "memory-learn", "execute", "differential", "product", "dashboard", "mcp"] as const; +export const COMMAND_ORDER = ["ai", "session", "context", "analyze", "runtime", "migration", "benchmark", "snapshot", "redteam", "revalidate", "llm-review", "agent", "loop", "doctor", "config", "memory", "memory-import", "memory-learn", "execute", "differential", "product", "dashboard", "mcp"] as const; export const UTILITY_COMMAND_ORDER = ["help", "man", "update", "uninstall", "version"] as const; export const ROOT_FLAG_ALIASES = ["--help", "-h", "--version", "-V"] as const; diff --git a/packages/cli/src/parsers/args.ts b/packages/cli/src/parsers/args.ts index c2b54017..39109ac2 100644 --- a/packages/cli/src/parsers/args.ts +++ b/packages/cli/src/parsers/args.ts @@ -12,6 +12,7 @@ export { parseLlmReviewArgs } from "./llm-review"; export { parseLoopArgs } from "./loop"; export { parseMcpArgs } from "./mcp"; export { parseMemoryArgs, parseMemoryImportArgs, parseMemoryLearnArgs, parseMemorySetupArgs } from "./memory"; +export { parseMigrationArgs } from "./migration"; export { parseRevalidateArgs } from "./revalidate"; export { parseRuntimeArgs } from "./runtime"; export { parseProductArgs } from "./product"; diff --git a/packages/cli/src/parsers/migration.ts b/packages/cli/src/parsers/migration.ts new file mode 100644 index 00000000..019c2999 --- /dev/null +++ b/packages/cli/src/parsers/migration.ts @@ -0,0 +1,38 @@ +import type { MigrationOptions } from "../types"; +import { requireValue } from "./primitives"; +import { HelpRequested, throwUnknownOption } from "./shared"; + +export function parseMigrationArgs(args: string[]): MigrationOptions { + const options: MigrationOptions = { files: [], rollbackFiles: [], targetKind: "unspecified", format: "markdown" }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (!arg) continue; + if (arg === "--help" || arg === "-h") throw new HelpRequested(); + const [flag, inline] = splitArg(arg); + const value = () => inline ?? requireValue(args, index, flag); + if (flag === "--file") options.files.push(value()); + else if (flag === "--rollback-file") options.rollbackFiles.push(value()); + else if (flag === "--cwd") options.cwd = value(); + 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; } + if (inline === undefined) index += 1; + } + return options; +} + +function splitArg(arg: string): [string, string | undefined] { + const index = arg.indexOf("="); + return index < 0 ? [arg, undefined] : [arg.slice(0, index), arg.slice(index + 1)]; +} + +function parseFormat(value: string): MigrationOptions["format"] { + if (value === "json" || value === "markdown") return value; + throw new Error(`Invalid migration format "${value}". Expected json or markdown.`); +} + +function parseTarget(value: string): MigrationOptions["targetKind"] { + if (value === "unspecified" || value === "disposable-local" || value === "remote-unapproved" || value === "production-like") return value; + throw new Error(`Invalid migration target kind "${value}".`); +} diff --git a/packages/cli/src/types/index.ts b/packages/cli/src/types/index.ts index 0a12fccd..ab8122a0 100644 --- a/packages/cli/src/types/index.ts +++ b/packages/cli/src/types/index.ts @@ -12,6 +12,7 @@ export * from "./execution"; export * from "./llm-review"; export * from "./loop"; export * from "./maintenance"; +export * from "./migration"; export * from "./mcp"; export * from "./memory"; export * from "./product"; diff --git a/packages/cli/src/types/migration.ts b/packages/cli/src/types/migration.ts new file mode 100644 index 00000000..9a19e146 --- /dev/null +++ b/packages/cli/src/types/migration.ts @@ -0,0 +1,11 @@ +import type { ConfigFormat } from "./common"; +import type { MigrationTargetKind } from "@submuxhq/codedecay-knowledge"; + +export interface MigrationOptions { + cwd?: string | undefined; + files: string[]; + rollbackFiles: string[]; + targetKind: MigrationTargetKind; + format: ConfigFormat; + output?: string | undefined; +} diff --git a/packages/cli/test/built-cli-migration.test.ts b/packages/cli/test/built-cli-migration.test.ts new file mode 100644 index 00000000..e30f33fe --- /dev/null +++ b/packages/cli/test/built-cli-migration.test.ts @@ -0,0 +1,24 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { createRepo, ensureBuiltCli, runBuilt } from "./helpers/built-cli"; + +beforeAll(ensureBuiltCli, 120_000); + +describe("built codedecay migration workflow", () => { + it("parses Prisma migration SQL from the bundled CLI without database access", () => { + const root = createRepo({ "prisma/migrations/001_email/migration.sql": 'ALTER TABLE "User" ADD COLUMN "email" text NOT NULL;\n' }); + const result = runBuilt(["migration", "--cwd", root, "--file", "prisma/migrations/001_email/migration.sql", "--target-kind", "disposable-local", "--format", "json"]); + const report = JSON.parse(result.stdout) as { operations: Array<{ kind: string; risk: string }>; safety: Record }; + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(report.operations).toEqual([expect.objectContaining({ kind: "add-column", risk: "blocker" })]); + expect(report.safety).toMatchObject({ commandsExecuted: false, databaseConnected: false, migrationApplied: false, secretsRead: false }); + }); + + it("exposes migration help from the bundled command registry", () => { + createRepo({ "README.md": "fixture\n" }); + const result = runBuilt(["migration", "--help"]); + expect(result.status).toBe(0); + expect(result.stdout).toContain("CodeDecay migration"); + expect(result.stdout).toContain("--target-kind "); + }); +}); diff --git a/packages/cli/test/migration.test.ts b/packages/cli/test/migration.test.ts new file mode 100644 index 00000000..101db630 --- /dev/null +++ b/packages/cli/test/migration.test.ts @@ -0,0 +1,41 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runCli } from "../src/index"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +describe("codedecay migration CLI", () => { + it("writes a plan-only deployment matrix for a real child repository", async () => { + const root = createRepo(); + mkdirSync(join(root, "prisma", "migrations", "001_email"), { recursive: true }); + writeFileSync(join(root, "prisma", "migrations", "001_email", "migration.sql"), 'ALTER TABLE "User" ADD COLUMN "email" text NOT NULL;', "utf8"); + const result = await run(["migration", "--cwd", root, "--file", "prisma/migrations/001_email/migration.sql", "--target-kind", "disposable-local", "--format", "json", "--output", "reports/migration.json"]); + const report = JSON.parse(readFileSync(join(root, "reports", "migration.json"), "utf8")) as { blockers: string[]; matrix: Array<{ state: string; status: string }>; safety: Record }; + expect(result).toEqual({ exitCode: 0, stdout: "", stderr: "" }); + expect(report.blockers[0]).toContain("NOT NULL without a default"); + expect(report.matrix).toHaveLength(5); + expect(report.safety).toMatchObject({ databaseConnected: false, migrationApplied: false, secretsRead: false }); + }); + + it("makes an unspecified target and absent migration visible in Markdown", async () => { + const result = await run(["migration", "--cwd", createRepo()]); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Target: `unspecified`"); + expect(result.stdout).toContain("No migration SQL file was supplied"); + expect(result.stdout).toContain("No database was contacted"); + }); +}); + +async function run(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> { + let stdout = ""; let stderr = ""; + const exitCode = await runCli(args, { stdout: (text) => { stdout += text; }, stderr: (text) => { stderr += text; } }); + return { exitCode, stdout, stderr }; +} +function createRepo(): string { + const root = join(tmpdir(), `codedecay-migration-cli-${Date.now()}-${Math.random().toString(16).slice(2)}`); + mkdirSync(root, { recursive: true }); execFileSync("git", ["init", "-q"], { cwd: root }); roots.push(root); return root; +} diff --git a/packages/knowledge/package.json b/packages/knowledge/package.json index 4706a17c..bc5a7883 100644 --- a/packages/knowledge/package.json +++ b/packages/knowledge/package.json @@ -15,6 +15,7 @@ "@submuxhq/codedecay-core": "workspace:*", "@submuxhq/codedecay-memory": "workspace:*", "chokidar": "^4.0.3", + "pgsql-ast-parser": "^12.0.2", "yaml": "^2.8.1" }, "scripts": { diff --git a/packages/knowledge/src/index.ts b/packages/knowledge/src/index.ts index 1aeb1481..012e1d6b 100644 --- a/packages/knowledge/src/index.ts +++ b/packages/knowledge/src/index.ts @@ -44,6 +44,18 @@ export { SERVICE_TOPOLOGY_SCHEMA_VERSION } from "./topology/types"; export { ingestRuntimeEvidence } from "./runtime/ingest"; +export { analyzeMigrationSafety } from "./migration/analyze"; +export type { AnalyzeMigrationSafetyOptions } from "./migration/analyze"; +export { renderMigrationSafetyMarkdown } from "./migration/render"; +export { MIGRATION_EVIDENCE_SCHEMA_VERSION } from "./migration/types"; +export type { + MigrationMatrixState, + MigrationOperationEvidence, + MigrationOperationKind, + MigrationRisk, + MigrationSafetyReport, + MigrationTargetKind +} from "./migration/types"; export type { IngestRuntimeEvidenceOptions } from "./runtime/ingest"; export { renderRuntimeEvidenceMarkdown } from "./runtime/render"; export { RUNTIME_EVIDENCE_SCHEMA_VERSION } from "./runtime/types"; diff --git a/packages/knowledge/src/migration/analyze.ts b/packages/knowledge/src/migration/analyze.ts new file mode 100644 index 00000000..3a15715b --- /dev/null +++ b/packages/knowledge/src/migration/analyze.ts @@ -0,0 +1,148 @@ +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 { + MIGRATION_EVIDENCE_SCHEMA_VERSION, + type MigrationMatrixState, + type MigrationOperationEvidence, + type MigrationOperationKind, + type MigrationRisk, + type MigrationSafetyReport, + type MigrationTargetKind +} from "./types"; + +const MAX_FILES = 100; +const MAX_FILE_BYTES = 5 * 1024 * 1024; + +export interface AnalyzeMigrationSafetyOptions { + rootDir: string; + files: string[]; + targetKind?: MigrationTargetKind | undefined; + rollbackFiles?: string[] | undefined; + generatedAt?: string | undefined; +} + +export function analyzeMigrationSafety(options: AnalyzeMigrationSafetyOptions): MigrationSafetyReport { + const rootDir = realpathSync(options.rootDir); + const targetKind = options.targetKind ?? "unspecified"; + 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."); + 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." + ]; + 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); + return { + tool: "CodeDecay", + schemaVersion: MIGRATION_EVIDENCE_SCHEMA_VERSION, + generatedAt: options.generatedAt ?? new Date().toISOString(), + dialect: "postgresql", + targetKind, + sourceFiles: files, + rollbackFiles, + operations, + matrix, + blockers, + investigationTasks: matrix.flatMap((item) => item.verificationTask ? [item.verificationTask] : []), + limitations, + safety: { commandsExecuted: false, databaseConnected: false, migrationApplied: false, secretsRead: false, productionTargetAllowed: false } + }; +} + +function parseMigrationFile(rootDir: string, path: string, rollbackProvided: boolean): MigrationOperationEvidence[] { + const resolved = resolveInside(rootDir, path); + const size = statSync(resolved).size; + if (size > MAX_FILE_BYTES) throw new Error(`Migration file exceeds ${MAX_FILE_BYTES} byte limit: ${path}`); + let statements: Statement[]; + try { + statements = parse(readFileSync(resolved, "utf8")); + } catch (error: unknown) { + const message = error instanceof Error ? error.message.split("\n")[0] : String(error); + throw new Error(`Unable to parse PostgreSQL migration ${path}: ${message}`); + } + return statements.flatMap((statement, index) => normalizeStatement(statement, path, index + 1, rollbackProvided)); +} + +function normalizeStatement(statement: Statement, path: string, index: number, rollbackProvided: boolean): MigrationOperationEvidence[] { + const value = statement as unknown as Record; + const type = String(value.type ?? "other"); + const table = objectName(value.table) ?? objectName(value.name) ?? "unknown-object"; + 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)]; + 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 { + 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 === "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); + } + 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 { + 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, + rollbackSupported: rollbackProvided ? "unknown" : false, + limitations: [...limitations, ...(rollbackProvided ? ["Rollback SQL was supplied but not executed."] : ["No rollback SQL was supplied."])] + }; +} + +function createMatrix(operations: MigrationOperationEvidence[], rollbackProvided: boolean, targetKind: MigrationTargetKind): 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." } + ]; +} + +function boundedFiles(files: string[], label: string): string[] { + if (files.length > MAX_FILES) throw new Error(`At most ${MAX_FILES} ${label} files may be analyzed.`); + return [...new Set(files)].sort(); +} + +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 (!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}`); + return real; +} + +function objectName(value: unknown): string | undefined { + if (typeof value === "string") return value; + if (!value || typeof value !== "object") return undefined; + const record = value as Record; + return typeof record.name === "string" ? record.name : objectName(record.name); +} diff --git a/packages/knowledge/src/migration/render.ts b/packages/knowledge/src/migration/render.ts new file mode 100644 index 00000000..8c9469cb --- /dev/null +++ b/packages/knowledge/src/migration/render.ts @@ -0,0 +1,21 @@ +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", "" + ]; + 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}\``); + 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("", "### Verification Tasks", ""); + for (const task of report.investigationTasks) lines.push(`- ${task}`); + lines.push("", "### Limitations", ""); + for (const limitation of report.limitations) lines.push(`- ${limitation}`); + return `${lines.join("\n")}\n`; +} diff --git a/packages/knowledge/src/migration/types.ts b/packages/knowledge/src/migration/types.ts new file mode 100644 index 00000000..f8548a75 --- /dev/null +++ b/packages/knowledge/src/migration/types.ts @@ -0,0 +1,57 @@ +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 MigrationOperationKind = + | "create-object" + | "drop-object" + | "add-column" + | "drop-column" + | "alter-column" + | "create-index" + | "backfill" + | "other"; + +export interface MigrationOperationEvidence { + evidenceId: string; + kind: MigrationOperationKind; + object: string; + detail: string; + sourceRef: string; + risk: MigrationRisk; + destructive: boolean; + lockRisk: "low" | "unknown" | "high"; + requiresBackfill: boolean; + rollbackSupported: boolean | "unknown"; + limitations: string[]; +} + +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"; + evidenceIds: string[]; + reason: string; + verificationTask?: string | undefined; +} + +export interface MigrationSafetyReport { + tool: "CodeDecay"; + schemaVersion: typeof MIGRATION_EVIDENCE_SCHEMA_VERSION; + generatedAt: string; + dialect: "postgresql"; + targetKind: MigrationTargetKind; + sourceFiles: string[]; + rollbackFiles: string[]; + operations: MigrationOperationEvidence[]; + matrix: MigrationMatrixState[]; + blockers: string[]; + investigationTasks: string[]; + limitations: string[]; + safety: { + commandsExecuted: false; + databaseConnected: false; + migrationApplied: false; + secretsRead: false; + productionTargetAllowed: false; + }; +} diff --git a/packages/knowledge/test/migration-safety.test.ts b/packages/knowledge/test/migration-safety.test.ts new file mode 100644 index 00000000..ac58e21c --- /dev/null +++ b/packages/knowledge/test/migration-safety.test.ts @@ -0,0 +1,69 @@ +import { mkdirSync, rmSync, symlinkSync, 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"; + +const roots: string[] = []; +afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); + +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 }); + 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 }); + }); + + 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.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." }); + }); + + it("blocks production-like targets without reading a connection string", () => { + const root = tempRoot(); + write(root, "migration.sql", 'CREATE TABLE "Audit" ("id" text PRIMARY KEY);'); + const report = analyzeMigrationSafety({ rootDir: root, files: ["migration.sql"], targetKind: "production-like" }); + expect(report.blockers[0]).toContain("Production-like database targets are blocked"); + expect(report.matrix.filter((item) => item.state !== "old-app-old-schema").every((item) => item.status === "blocked")).toBe(true); + expect(JSON.stringify(report)).not.toContain("DATABASE_URL"); + }); + + it("rejects malformed SQL and paths that escape through symlinks", () => { + const root = tempRoot(); + const outside = tempRoot(); + write(root, "bad.sql", "ALTER TABLE ???"); + write(outside, "outside.sql", "DROP TABLE users;"); + symlinkSync(join(outside, "outside.sql"), join(root, "linked.sql")); + expect(() => analyzeMigrationSafety({ rootDir: root, files: ["bad.sql"] })).toThrow("Unable to parse PostgreSQL migration"); + expect(() => analyzeMigrationSafety({ rootDir: root, files: ["linked.sql"] })).toThrow("must stay inside repository"); + }); + + it("validates rollback SQL and blocks an unspecified target", () => { + 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"); + 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"]); + expect(report.blockers[0]).toContain("target classification is required"); + expect(report.matrix.find((item) => item.state === "new-app-new-schema")?.status).toBe("blocked"); + }); +}); + +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; +} +function write(root: string, path: string, content: string): void { writeFileSync(join(root, path), content, "utf8"); } diff --git a/packages/tool-adapters/package.json b/packages/tool-adapters/package.json index afabe3b1..e34df27c 100644 --- a/packages/tool-adapters/package.json +++ b/packages/tool-adapters/package.json @@ -14,7 +14,8 @@ "dependencies": { "@submuxhq/codedecay-config": "workspace:*", "@submuxhq/codedecay-execution": "workspace:*", - "@submuxhq/codedecay-harness": "workspace:*" + "@submuxhq/codedecay-harness": "workspace:*", + "@submuxhq/codedecay-knowledge": "workspace:*" }, "scripts": { "build": "tsup src/index.ts --format esm --dts --clean --tsconfig ../../tsconfig.base.json" diff --git a/packages/tool-adapters/src/index.ts b/packages/tool-adapters/src/index.ts index 4e0f7006..4f213a43 100644 --- a/packages/tool-adapters/src/index.ts +++ b/packages/tool-adapters/src/index.ts @@ -1,6 +1,7 @@ import { createAgentProcessHarness, createConfiguredAgentProcessHarness } from "./agent-process"; import { createCoverageHarness, createConfiguredCoverageHarness } from "./coverage"; export * from "./doctor"; +export * from "./migration"; import { createPactHarness, createConfiguredPactHarness } from "./pact"; import { createPlaywrightHarness, createConfiguredPlaywrightHarness } from "./playwright"; import { createSchemathesisHarness, createConfiguredSchemathesisHarness } from "./schemathesis"; diff --git a/packages/tool-adapters/src/migration/index.ts b/packages/tool-adapters/src/migration/index.ts new file mode 100644 index 00000000..daa5cbe7 --- /dev/null +++ b/packages/tool-adapters/src/migration/index.ts @@ -0,0 +1,2 @@ +export { createPrismaMigrationAdapterPlan } from "./plan"; +export type { MigrationAdapterPlan, PrismaMigrationAdapterOptions } from "./types"; diff --git a/packages/tool-adapters/src/migration/plan.ts b/packages/tool-adapters/src/migration/plan.ts new file mode 100644 index 00000000..de3a5385 --- /dev/null +++ b/packages/tool-adapters/src/migration/plan.ts @@ -0,0 +1,36 @@ +import type { MigrationAdapterPlan, PrismaMigrationAdapterOptions } from "./types"; + +const ENV_NAME = /^[A-Z][A-Z0-9_]*$/; + +export function createPrismaMigrationAdapterPlan(options: PrismaMigrationAdapterOptions): MigrationAdapterPlan { + validateLocalPath(options.fromSchema, "fromSchema"); + validateLocalPath(options.toSchema, "toSchema"); + if (!Number.isInteger(options.timeoutMs) || options.timeoutMs <= 0) throw new Error("Prisma migration timeoutMs must be a positive integer."); + if (!options.cleanupPlan.trim()) throw new Error("Prisma migration cleanupPlan is required."); + if (options.secretEnvNames.some((name) => !ENV_NAME.test(name))) throw new Error("Prisma migration secretEnvNames must contain environment variable names only."); + const blockers: string[] = []; + if (!options.approved) blockers.push("Migration adapter execution requires explicit approval."); + if (options.targetKind !== "disposable-local") blockers.push("Migration adapter execution is limited to disposable-local targets."); + return { + provider: "prisma", + mode: "schema-diff", + command: `prisma migrate diff --from-schema ${quote(options.fromSchema)} --to-schema ${quote(options.toSchema)} --script --exit-code`, + targetKind: options.targetKind, + requiresApproval: true, + executable: blockers.length === 0, + timeoutMs: options.timeoutMs, + cleanupPlan: options.cleanupPlan.trim(), + secretEnvNames: [...new Set(options.secretEnvNames)].sort(), + blockers, + safety: { readOnly: true, databaseConnected: false, migrationApplied: false } + }; +} + +function validateLocalPath(path: string, label: string): void { + if (!path.trim()) throw new Error(`Prisma migration ${label} is required.`); + if (path.startsWith("/") || path.includes("..") || /^[a-z]+:/i.test(path)) throw new Error(`Prisma migration ${label} must be a repo-local path.`); +} + +function quote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} diff --git a/packages/tool-adapters/src/migration/types.ts b/packages/tool-adapters/src/migration/types.ts new file mode 100644 index 00000000..40f1ee20 --- /dev/null +++ b/packages/tool-adapters/src/migration/types.ts @@ -0,0 +1,30 @@ +import type { MigrationTargetKind } from "@submuxhq/codedecay-knowledge"; + +export interface PrismaMigrationAdapterOptions { + provider: "prisma"; + fromSchema: string; + toSchema: string; + targetKind: MigrationTargetKind; + approved: boolean; + timeoutMs: number; + cleanupPlan: string; + secretEnvNames: string[]; +} + +export interface MigrationAdapterPlan { + provider: "prisma"; + mode: "schema-diff"; + command: string; + targetKind: MigrationTargetKind; + requiresApproval: true; + executable: boolean; + timeoutMs: number; + cleanupPlan: string; + secretEnvNames: string[]; + blockers: string[]; + safety: { + readOnly: true; + databaseConnected: false; + migrationApplied: false; + }; +} diff --git a/packages/tool-adapters/test/migration.test.ts b/packages/tool-adapters/test/migration.test.ts new file mode 100644 index 00000000..dfa5c45f --- /dev/null +++ b/packages/tool-adapters/test/migration.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { createPrismaMigrationAdapterPlan } from "../src/index"; + +describe("Prisma migration adapter plan", () => { + it("creates an approved read-only local schema diff plan", () => { + const plan = createPrismaMigrationAdapterPlan({ + provider: "prisma", + fromSchema: "fixtures/base/schema.prisma", + toSchema: "prisma/schema.prisma", + targetKind: "disposable-local", + approved: true, + timeoutMs: 30_000, + cleanupPlan: "Delete the disposable database directory.", + secretEnvNames: ["TEST_DATABASE_URL"] + }); + expect(plan).toMatchObject({ provider: "prisma", mode: "schema-diff", executable: true, blockers: [], safety: { readOnly: true, databaseConnected: false, migrationApplied: false } }); + expect(plan.command).toBe("prisma migrate diff --from-schema 'fixtures/base/schema.prisma' --to-schema 'prisma/schema.prisma' --script --exit-code"); + }); + + it("blocks unapproved and production-like plans", () => { + const plan = createPrismaMigrationAdapterPlan({ provider: "prisma", fromSchema: "base.prisma", toSchema: "head.prisma", targetKind: "production-like", approved: false, timeoutMs: 1000, cleanupPlan: "No resources created.", secretEnvNames: [] }); + expect(plan.executable).toBe(false); + expect(plan.blockers).toHaveLength(2); + }); + + it("rejects remote paths, missing cleanup, and secret values", () => { + const valid = { provider: "prisma" as const, fromSchema: "base.prisma", toSchema: "head.prisma", targetKind: "disposable-local" as const, approved: true, timeoutMs: 1000, cleanupPlan: "cleanup", secretEnvNames: [] }; + expect(() => createPrismaMigrationAdapterPlan({ ...valid, fromSchema: "https://example.com/schema.prisma" })).toThrow("repo-local path"); + expect(() => createPrismaMigrationAdapterPlan({ ...valid, cleanupPlan: "" })).toThrow("cleanupPlan is required"); + expect(() => createPrismaMigrationAdapterPlan({ ...valid, secretEnvNames: ["postgres://secret"] })).toThrow("environment variable names only"); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d702053..b806c504 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: chokidar: specifier: ^4.0.3 version: 4.0.3 + pgsql-ast-parser: + specifier: ^12.0.2 + version: 12.0.2 yaml: specifier: ^2.8.1 version: 2.9.0 @@ -278,6 +281,9 @@ importers: '@submuxhq/codedecay-harness': specifier: workspace:* version: link:../harness + '@submuxhq/codedecay-knowledge': + specifier: workspace:* + version: link:../knowledge packages: @@ -1140,6 +1146,9 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@4.1.1: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} @@ -1217,6 +1226,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -1512,6 +1524,9 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1523,6 +1538,10 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -1583,6 +1602,9 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + pgsql-ast-parser@12.0.2: + resolution: {integrity: sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1668,6 +1690,13 @@ packages: quick-format-unescaped@4.0.4: resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1704,6 +1733,10 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -2906,6 +2939,8 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@2.20.3: {} + commander@4.1.1: {} confbox@0.1.8: {} @@ -2957,6 +2992,8 @@ snapshots: dependencies: dequal: 2.0.3 + discontinuous-range@1.0.0: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -3276,6 +3313,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + moo@0.5.3: {} + ms@2.1.3: {} mz@2.7.0: @@ -3286,6 +3325,13 @@ snapshots: nanoid@3.3.16: {} + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + negotiator@1.0.0: {} npx-import-light@1.0.0: {} @@ -3335,6 +3381,11 @@ snapshots: perfect-debounce@1.0.0: {} + pgsql-ast-parser@12.0.2: + dependencies: + moo: 0.5.3 + nearley: 2.20.1 + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -3452,6 +3503,13 @@ snapshots: quick-format-unescaped@4.0.4: {} + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + range-parser@1.2.1: {} raw-body@3.0.2: @@ -3481,6 +3539,8 @@ snapshots: resolve-from@5.0.0: {} + ret@0.1.15: {} + rfdc@1.4.1: {} rollup@4.62.2: