Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions packages/cli/src/commands/migration.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
5 changes: 5 additions & 0 deletions packages/cli/src/commands/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -93,6 +94,10 @@ export function createCommandHandlers(options: CommandRegistryOptions): Record<s
mcp: (context) => 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 }),
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/docs/command-docs/analysis.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,22 @@
import type { CommandDoc } from "../../renderers/discovery";

export const ANALYSIS_COMMAND_DOCS: Record<string, CommandDoc> = {
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 <path>", description: "Repo-local migration SQL file; repeat for multiple files" },
{ flag: "--rollback-file <path>", description: "Repo-local rollback SQL file; repeat for multiple files" },
{ flag: "--target-kind <kind>", description: "unspecified, disposable-local, remote-unapproved, or production-like" },
{ flag: "--cwd <path>", description: "Repository working directory (default: current directory)" },
{ flag: "--format <format>", description: "json or markdown (default: markdown)" },
{ flag: "--output <path>", 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.",
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/docs/command-docs/order.ts
Original file line number Diff line number Diff line change
@@ -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;
1 change: 1 addition & 0 deletions packages/cli/src/parsers/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/src/parsers/migration.ts
Original file line number Diff line number Diff line change
@@ -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}".`);
}
1 change: 1 addition & 0 deletions packages/cli/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/types/migration.ts
Original file line number Diff line number Diff line change
@@ -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;
}
24 changes: 24 additions & 0 deletions packages/cli/test/built-cli-migration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean> };
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 <kind>");
});
});
41 changes: 41 additions & 0 deletions packages/cli/test/migration.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, boolean> };
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;
}
1 change: 1 addition & 0 deletions packages/knowledge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
12 changes: 12 additions & 0 deletions packages/knowledge/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
Loading
Loading