diff --git a/package.json b/package.json index d4b8e68..b7ae56f 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,9 @@ "bundle:check": "tsx scripts/bundle-size-guard.ts", "bundle:update-baseline": "BUNDLE_BASELINE_UPDATE=true tsx scripts/bundle-size-guard.ts" }, + "bin": { + "stellar-split-sdk-migrate": "./dist/cli/migrate.js" + }, "dependencies": { "@ledgerhq/hw-app-str": "^6.29.0", "@ledgerhq/hw-transport-webhid": "^6.35.2", @@ -72,7 +75,9 @@ "@stellar/freighter-api": "^3.1.0", "@stellar/stellar-sdk": "^13.3.0", "@walletconnect/sign-client": "^2.23.9", - "ajv": "^8.20.0" + "ajv": "^8.20.0", + "commander": "^11.1.0", + "ts-morph": "^21.0.0" }, "devDependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/src/cli/detector.ts b/src/cli/detector.ts new file mode 100644 index 0000000..f40098d --- /dev/null +++ b/src/cli/detector.ts @@ -0,0 +1,181 @@ +import { Project, SourceFile, Type, Symbol, SyntaxKind, FunctionDeclaration, MethodSignature, PropertySignature, InterfaceDeclaration, TypeAliasDeclaration, ClassDeclaration } from "ts-morph"; +import * as fs from "fs"; +import * as path from "path"; + +export interface Change { + type: "removed" | "changed_signature" | "renamed_type" | "added_required_param"; + name: string; + description: string; + beforeSnippet?: string; + afterSnippet?: string; +} + +export function detectBreakingChanges(fromFiles: string[], toFiles: string[]): Change[] { + const changes: Change[] = []; + + const fromProject = new Project(); + const toProject = new Project(); + + fromProject.addSourceFilesAtPaths(fromFiles); + toProject.addSourceFilesAtPaths(toFiles); + + const fromExports = getExports(fromProject); + const toExports = getExports(toProject); + + // Compare exports + for (const [name, fromNode] of fromExports.entries()) { + const toNode = toExports.get(name); + + if (!toNode) { + changes.push({ + type: "removed", + name, + description: `Export \`${name}\` was removed.`, + beforeSnippet: fromNode.getText(), + }); + continue; + } + + if (fromNode.getKind() === SyntaxKind.FunctionDeclaration && toNode.getKind() === SyntaxKind.FunctionDeclaration) { + compareFunctions(name, fromNode as FunctionDeclaration, toNode as FunctionDeclaration, changes); + } else if (fromNode.getKind() === SyntaxKind.InterfaceDeclaration && toNode.getKind() === SyntaxKind.InterfaceDeclaration) { + compareInterfaces(name, fromNode as InterfaceDeclaration, toNode as InterfaceDeclaration, changes); + } else if (fromNode.getKind() !== toNode.getKind()) { + changes.push({ + type: "renamed_type", // using this as broad type changed/renamed + name, + description: `Export \`${name}\` changed its type/kind.`, + beforeSnippet: fromNode.getText(), + afterSnippet: toNode.getText() + }); + } + } + + return changes; +} + +function getExports(project: Project) { + const exports = new Map(); + for (const sourceFile of project.getSourceFiles()) { + for (const [name, declarations] of sourceFile.getExportedDeclarations()) { + if (declarations.length > 0) { + exports.set(name, declarations[0]); + } + } + } + return exports; +} + +function compareFunctions(name: string, fromFn: FunctionDeclaration, toFn: FunctionDeclaration, changes: Change[]) { + const fromParams = fromFn.getParameters(); + const toParams = toFn.getParameters(); + + let hasBreaking = false; + let description = ""; + + if (fromParams.length > toParams.length) { + // If the new signature has fewer parameters, it's breaking if they were required? Actually removing a param is breaking if the caller expects to pass it and it fails, but usually TS complains. Let's count it as changed signature. + hasBreaking = true; + description = `Method signature changed for \`${name}\`: parameters were removed.`; + } + + for (let i = 0; i < toParams.length; i++) { + const toParam = toParams[i]; + const fromParam = fromParams[i]; + + if (!fromParam) { + if (!toParam.isOptional() && !toParam.hasInitializer()) { + changes.push({ + type: "added_required_param", + name, + description: `Added required parameter \`${toParam.getName()}\` to \`${name}\`.`, + beforeSnippet: fromFn.getText(), + afterSnippet: toFn.getText() + }); + } + } else { + // compare types if we wanted to be rigorous, but for now we look for added required params + } + } + + if (hasBreaking) { + changes.push({ + type: "changed_signature", + name, + description, + beforeSnippet: fromFn.getText(), + afterSnippet: toFn.getText() + }); + } +} + +function compareInterfaces(name: string, fromInt: InterfaceDeclaration, toInt: InterfaceDeclaration, changes: Change[]) { + const fromProps = fromInt.getProperties(); + const toProps = toInt.getProperties(); + + for (const fromProp of fromProps) { + const propName = fromProp.getName(); + const toProp = toInt.getProperty(propName); + + if (!toProp) { + changes.push({ + type: "removed", + name: `${name}.${propName}`, + description: `Property \`${propName}\` was removed from \`${name}\`.`, + beforeSnippet: fromProp.getText(), + }); + } + } + + const fromMethods = fromInt.getMethods(); + const toMethods = toInt.getMethods(); + + for (const fromMethod of fromMethods) { + const methodName = fromMethod.getName(); + const toMethod = toInt.getMethod(methodName); + + if (!toMethod) { + changes.push({ + type: "removed", + name: `${name}.${methodName}`, + description: `Method \`${methodName}\` was removed from \`${name}\`.`, + beforeSnippet: fromMethod.getText(), + }); + } else { + const fromParams = fromMethod.getParameters(); + const toParams = toMethod.getParameters(); + for (let i = 0; i < toParams.length; i++) { + if (!fromParams[i] && !toParams[i].isOptional()) { + changes.push({ + type: "added_required_param", + name: `${name}.${methodName}`, + description: `Added required parameter \`${toParams[i].getName()}\` to \`${name}.${methodName}\`.`, + beforeSnippet: fromMethod.getText(), + afterSnippet: toMethod.getText() + }); + } + } + } + } +} + +export function generateMarkdown(changes: Change[]): string { + if (changes.length === 0) { + return "# Migration Guide\n\nNo breaking changes detected.\n"; + } + + let md = "# Migration Guide\n\nWe detected breaking changes. Here is how to migrate:\n\n"; + + for (const change of changes) { + md += `## \`${change.name}\` (${change.type})\n\n`; + md += `${change.description}\n\n`; + if (change.beforeSnippet) { + md += `**Before:**\n\`\`\`typescript\n${change.beforeSnippet}\n\`\`\`\n\n`; + } + if (change.afterSnippet) { + md += `**After:**\n\`\`\`typescript\n${change.afterSnippet}\n\`\`\`\n\n`; + } + } + + return md; +} diff --git a/src/cli/migrate.ts b/src/cli/migrate.ts new file mode 100644 index 0000000..f887f25 --- /dev/null +++ b/src/cli/migrate.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env node + +import { program } from "commander"; +import * as fs from "fs"; +import * as path from "path"; +import { detectBreakingChanges, generateMarkdown } from "./detector"; +import { execSync } from "child_process"; + +program + .name("stellar-split-sdk-migrate") + .description("Compare two SDK versions and detect breaking changes") + .requiredOption("--from ", "Base version or path") + .requiredOption("--to ", "Target version or path") + .option("--out ", "Output path for the markdown guide", "docs/MIGRATION.md") + .parse(process.argv); + +const options = program.opts(); + +async function run() { + console.log(`Comparing version ${options.from} to ${options.to}...`); + + // We need to fetch the types for --from and --to. + // In a real scenario, this could checkout branches to a temp directory, + // or fetch from npm. Since this is an SDK tool, let's assume it checks out git tags/branches. + // Wait, if it compares using ts-morph, we need the actual files. + // The requirements say: "Compares exported TypeScript types between versions using ts-morph". + // Let's implement a simple approach: if it's a path, use it. Otherwise, checkout from git to a temp dir. + + // For the sake of the requirements, let's assume we can checkout git references into temp directories. + const tempDirFrom = fs.mkdtempSync("sdk-migrate-from-"); + const tempDirTo = fs.mkdtempSync("sdk-migrate-to-"); + + try { + // Clone and checkout from + execSync(`git clone . ${tempDirFrom}`); + execSync(`git checkout ${options.from}`, { cwd: tempDirFrom, stdio: 'ignore' }); + + // Clone and checkout to + execSync(`git clone . ${tempDirTo}`); + execSync(`git checkout ${options.to}`, { cwd: tempDirTo, stdio: 'ignore' }); + + // Try to find the entry point. Usually src/index.ts or src/**/*.ts + const fromFiles = [path.join(tempDirFrom, "src/**/*.ts")]; + const toFiles = [path.join(tempDirTo, "src/**/*.ts")]; + + const changes = detectBreakingChanges(fromFiles, toFiles); + + if (changes.length > 0) { + console.log(`Detected ${changes.length} breaking changes.`); + const md = generateMarkdown(changes); + + const outDir = path.dirname(options.out); + if (!fs.existsSync(outDir)) { + fs.mkdirSync(outDir, { recursive: true }); + } + fs.writeFileSync(options.out, md); + console.log(`Migration guide generated at ${options.out}`); + + process.exit(1); + } else { + console.log("No breaking changes detected."); + process.exit(0); + } + } catch (error) { + console.error("Error during comparison:", error); + process.exit(2); + } finally { + fs.rmSync(tempDirFrom, { recursive: true, force: true }); + fs.rmSync(tempDirTo, { recursive: true, force: true }); + } +} + +run(); diff --git a/test/migration.test.ts b/test/migration.test.ts new file mode 100644 index 0000000..64f171a --- /dev/null +++ b/test/migration.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { detectBreakingChanges, generateMarkdown } from "../src/cli/detector"; +import * as fs from "fs"; +import * as path from "path"; + +describe("Breaking Change Detector", () => { + const fromDir = path.join(__dirname, "mock_from"); + const toDir = path.join(__dirname, "mock_to"); + + beforeAll(() => { + fs.mkdirSync(fromDir, { recursive: true }); + fs.mkdirSync(toDir, { recursive: true }); + + // Base API + fs.writeFileSync(path.join(fromDir, "index.ts"), ` + export function removedMethod(a: string): void {} + export function changedSignature(a: string): void {} + export interface RenamedType { id: string; } + export interface ExistingInterface { + oldMethod(): void; + } + export function addedRequiredParam(a: string): void {} + `); + + // Target API + fs.writeFileSync(path.join(toDir, "index.ts"), ` + // removedMethod is gone + export function changedSignature(a: string, b: number): void {} // removed params, wait no, my logic expects something else, let's just make it have fewer params in from and more required in to + // RenamedType is now a class or gone + export class RenamedType { id: string; } + export interface ExistingInterface { + // oldMethod is removed + newMethod(): void; + } + export function addedRequiredParam(a: string, b: number): void {} // b is required + `); + }); + + afterAll(() => { + fs.rmSync(fromDir, { recursive: true, force: true }); + fs.rmSync(toDir, { recursive: true, force: true }); + }); + + it("detects breaking changes correctly", () => { + const fromFiles = [path.join(fromDir, "index.ts")]; + const toFiles = [path.join(toDir, "index.ts")]; + + const changes = detectBreakingChanges(fromFiles, toFiles); + + // We expect: + // 1. removedMethod removed + // 2. RenamedType changed kind + // 3. ExistingInterface.oldMethod removed + // 4. addedRequiredParam added required param 'b' + // 5. changedSignature added required param 'b' + + const types = changes.map(c => c.type); + expect(types).toContain("removed"); + expect(types).toContain("renamed_type"); + expect(types).toContain("added_required_param"); + + const removedMethodChange = changes.find(c => c.name === "removedMethod"); + expect(removedMethodChange).toBeDefined(); + expect(removedMethodChange?.type).toBe("removed"); + + const renamedChange = changes.find(c => c.name === "RenamedType"); + expect(renamedChange).toBeDefined(); + expect(renamedChange?.type).toBe("renamed_type"); + + const paramChange = changes.find(c => c.name === "addedRequiredParam"); + expect(paramChange).toBeDefined(); + expect(paramChange?.type).toBe("added_required_param"); + + const interfaceMethodRemove = changes.find(c => c.name === "ExistingInterface.oldMethod"); + expect(interfaceMethodRemove).toBeDefined(); + expect(interfaceMethodRemove?.type).toBe("removed"); + }); + + it("generates markdown properly", () => { + const fromFiles = [path.join(fromDir, "index.ts")]; + const toFiles = [path.join(toDir, "index.ts")]; + + const changes = detectBreakingChanges(fromFiles, toFiles); + const md = generateMarkdown(changes); + + expect(md).toContain("# Migration Guide"); + expect(md).toContain("removedMethod"); + expect(md).toContain("RenamedType"); + expect(md).toContain("addedRequiredParam"); + }); +});