Building an AI-Powered Code Migration Pipeline: AST Parsing, LLM-Driven Refactoring, and Automated Validation for Framework Upgrades
A practical guide to building a code migration pipeline that combines AST parsing with LLM-driven refactoring for framework upgrades. Covers ts-morph integration, prompt construction from AST context, validation strategies, and how to handle the 20% of cases where LLMs need human oversight.
Code migrations are one of those engineering tasks that feel like they should be solved by now. React class to function components. Express to Hono. Python 2 to 3. AngularJS to Angular. The pattern repeats across every organization at some point, and the tooling available for it has always been inadequate.
Regex-based codemods are the traditional answer. They work until they don’t. The moment you have a class component that spreads lifecycle methods across inheritance chains, or a route handler that uses this.req in a closure, regex collapses. You’re not transforming text: you’re transforming semantics. Text transformation tools cannot do that reliably.
AST-based codemods (jscodeshift, ts-morph) are better. They understand structure. But they still require you to encode every transformation rule explicitly. For a migration from React class to function components, you need to write handlers for componentDidMount, componentDidUpdate, componentWillUnmount, shouldComponentUpdate, getDerivedStateFromProps, and their interaction with this.state and this.props. By the time you’ve covered 80% of the cases, you’ve written a small compiler.
LLMs can close the gap, but not by replacing the AST layer. The combination works: AST parsing gives the LLM structured, unambiguous context about what code does, and the LLM handles the semantic judgment calls that rule-based systems cannot encode. The validation layer catches the cases where the LLM gets it wrong.
Why Regex-Based Codemods Break
The practical failure mode of regex codemods is not that they crash. It’s that they silently produce incorrect output that passes syntax checks. Consider migrating an Express route to Hono:
// Before
app.get('/users/:id', async (req, res) => {
const { id } = req.params;
const user = await db.getUser(id);
if (!user) return res.status(404).json({ error: 'Not found' });
res.json(user);
});
A regex substitution can rename req to c and res.json to c.json. But it cannot reason about the fact that Hono’s c.json() takes the body as the first argument and status as the second, while Express’s res.status(404).json() chains them. That’s a semantic difference that requires understanding both APIs, not just finding and replacing strings.
The real cost of regex migrations is the review time. Engineers spend more time auditing automated output than they would have spent doing it manually, because they cannot trust the automation and every file needs a full read-through.
AST Parsing as Context Extraction
The first layer of the pipeline is AST extraction. You are not using the AST to perform the transformation. You are using it to extract precise context that you will feed to the LLM.
For TypeScript files, ts-morph gives you a typed AST with full type information. For JavaScript or mixed codebases, jscodeshift is the more common choice. This article uses ts-morph because the type information it surfaces is valuable for the LLM prompt.
import { Project, SourceFile, ClassDeclaration, MethodDeclaration } from 'ts-morph';
interface ComponentContext {
className: string;
stateType: string | null;
propsType: string | null;
lifecycleMethods: string[];
instanceVariables: string[];
renderReturn: string;
originalSource: string;
}
function extractClassComponentContext(sourceFile: SourceFile): ComponentContext | null {
const classDecl = sourceFile
.getClasses()
.find(c => {
const baseClass = c.getBaseClass();
return baseClass?.getName() === 'Component' || baseClass?.getName() === 'PureComponent';
});
if (!classDecl) return null;
const typeArgs = classDecl.getBaseClass()?.getTypeArguments() ?? [];
const propsType = typeArgs[0]?.getText() ?? null;
const stateType = typeArgs[1]?.getText() ?? null;
const lifecycleMethods = classDecl.getMethods()
.map(m => m.getName())
.filter(name => [
'componentDidMount',
'componentDidUpdate',
'componentWillUnmount',
'shouldComponentUpdate',
'getDerivedStateFromProps',
'getSnapshotBeforeUpdate',
].includes(name));
const instanceVars = classDecl.getProperties()
.filter(p => !p.isStatic())
.map(p => `${p.getName()}: ${p.getType().getText()}`);
const renderMethod = classDecl.getMethod('render');
const renderReturn = renderMethod?.getBodyText() ?? '';
return {
className: classDecl.getName() ?? 'UnknownComponent',
stateType,
propsType,
lifecycleMethods,
instanceVariables: instanceVars,
renderReturn,
originalSource: sourceFile.getFullText(),
};
}
The key insight here: the AST gives you structured facts. The lifecycleMethods array tells the LLM exactly which hooks it needs to generate. The stateType tells it the shape of state. You are not asking the LLM to figure this out by reading raw code. You are handing it a structured summary and the original source as reference.
Feeding AST Context to the LLM
The prompt construction step is where most teams get this wrong. They either send the entire file as a blob and hope the LLM figures it out, or they send only the extracted context and the LLM hallucinates the parts it cannot see.
The right approach is layered: structured context first, then the full source as ground truth, then an explicit constraint about what must be preserved.
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
interface MigrationPromptConfig {
context: ComponentContext;
targetPattern: 'react-class-to-function' | 'express-to-hono' | 'python2-to-python3';
preserveComments: boolean;
}
function buildMigrationPrompt(config: MigrationPromptConfig): string {
const { context, targetPattern } = config;
if (targetPattern !== 'react-class-to-function') {
throw new Error(`Unsupported pattern: ${targetPattern}`);
}
return `You are migrating a React class component to a function component.
## Component Facts (extracted from AST)
- Class name: ${context.className}
- Props type: ${context.propsType ?? 'none'}
- State type: ${context.stateType ?? 'none'}
- Lifecycle methods present: ${context.lifecycleMethods.join(', ') || 'none'}
- Instance variables: ${context.instanceVariables.join(', ') || 'none'}
## Migration Rules
- Convert state to useState hooks. If stateType is present, preserve it.
- Convert componentDidMount to useEffect with empty dependency array.
- Convert componentDidUpdate to useEffect with correct dependency array. The deps MUST include every variable read inside the method body.
- Convert componentWillUnmount to useEffect cleanup function.
- Remove this.setState calls, replace with the setter from useState.
- Remove this.props references, use the props parameter directly.
- Keep all comments exactly as they appear.
- Do not rename the component.
- Export the function component the same way the class was exported.
## Original Source
\`\`\`tsx
${context.originalSource}
\`\`\`
Output ONLY the migrated TypeScript/TSX code. No explanations, no markdown fences around the output, no commentary. Start with the import statements.`;
}
async function migrateWithLLM(config: MigrationPromptConfig): Promise<string> {
const prompt = buildMigrationPrompt(config);
const message = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 4096,
temperature: 0,
messages: [
{
role: 'user',
content: prompt,
},
],
});
const content = message.content[0];
if (content.type !== 'text') {
throw new Error('Expected text response from LLM');
}
return content.text;
}
Temperature 0 is non-negotiable for code migration. You want deterministic output so that re-running the pipeline on the same input produces the same result. This matters for debugging failures and for the diff review step.
Validation Strategies
LLM output cannot ship without validation. The question is what validation is realistic given that you are migrating code, not generating from scratch.
Three layers work well together:
Type checking: Run the TypeScript compiler on the LLM output. If it produces type errors, the migration failed. This is cheap to implement and catches a significant proportion of LLM errors.
Test execution: If the file has an associated test file, run it against the migrated code. This catches behavioral regressions that type checking misses.
Snapshot comparison: For React components, render the component in both class and function form and compare the output. This is the most reliable behavioral check but requires a test environment.
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as ts from 'typescript';
interface ValidationResult {
typeCheckPassed: boolean;
testsRan: boolean;
testsPassed: boolean;
typeErrors: string[];
testOutput: string;
}
function typeCheckMigratedFile(
migratedSource: string,
filePath: string,
tsConfigPath: string
): { passed: boolean; errors: string[] } {
const tempPath = filePath.replace('.tsx', '.migrated.tsx');
try {
fs.writeFileSync(tempPath, migratedSource, 'utf-8');
const configFile = ts.readConfigFile(tsConfigPath, ts.sys.readFile);
const parsedConfig = ts.parseJsonConfigFileContent(
configFile.config,
ts.sys,
path.dirname(tsConfigPath)
);
const program = ts.createProgram(
[...parsedConfig.fileNames, tempPath],
parsedConfig.options
);
const diagnostics = ts
.getPreEmitDiagnostics(program, program.getSourceFile(tempPath))
.filter(d => d.category === ts.DiagnosticCategory.Error);
const errors = diagnostics.map(d =>
ts.flattenDiagnosticMessageText(d.messageText, '\n')
);
return { passed: errors.length === 0, errors };
} finally {
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
}
}
async function validateMigration(
migratedSource: string,
originalFilePath: string,
tsConfigPath: string
): Promise<ValidationResult> {
const { passed: typeCheckPassed, errors: typeErrors } = typeCheckMigratedFile(
migratedSource,
originalFilePath,
tsConfigPath
);
const testFilePath = originalFilePath
.replace(/\.tsx?$/, '.test.tsx')
.replace('/src/', '/src/');
let testsRan = false;
let testsPassed = false;
let testOutput = '';
if (fs.existsSync(testFilePath) && typeCheckPassed) {
const tempPath = originalFilePath.replace('.tsx', '.migrated.tsx');
fs.writeFileSync(tempPath, migratedSource, 'utf-8');
try {
const result = execSync(
`npx jest ${testFilePath} --testPathPattern=${testFilePath} --no-coverage 2>&1`,
{ encoding: 'utf-8', timeout: 30_000 }
);
testsRan = true;
testsPassed = true;
testOutput = result;
} catch (err: unknown) {
testsRan = true;
testsPassed = false;
testOutput = err instanceof Error && 'stdout' in err
? String((err as NodeJS.ErrnoException & { stdout: string }).stdout)
: String(err);
} finally {
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
}
}
return { typeCheckPassed, testsRan, testsPassed, typeErrors, testOutput };
}
The 80/20 Problem
LLMs handle about 80% of migration cases correctly on the first pass. The remaining 20% fall into predictable categories:
| Failure category | Frequency | Characteristics |
|---|---|---|
| Complex lifecycle interactions | High | componentDidUpdate with multiple conditions, derived state from props |
| Ref forwarding | Medium | createRef, callback refs, React.forwardRef |
| Context API (old and new) | Medium | contextTypes, getChildContext, mixed old/new usage |
| HOC wrapping | Medium | Components wrapped with connect, withRouter, decorator patterns |
Implicit this in callbacks | High | Closures that capture this without explicit binding |
| Generic type parameters | Low | Components with generic props that need preservation |
The correct response to the 20% is not to retry with a different prompt. Retrying with temperature 0 produces the same result. Retrying with temperature > 0 produces different results but not necessarily correct ones.
The right path is human review, but with the LLM’s attempt as a starting point and explicit annotation of what failed.
The Human-in-the-Loop Step
When validation fails, write a review item to a queue rather than stopping the pipeline. The reviewer sees the original file, the LLM’s attempt, the validation errors, and the specific failure category.
interface ReviewItem {
id: string;
originalPath: string;
originalSource: string;
llmAttempt: string;
validationResult: ValidationResult;
failureCategory: string;
context: ComponentContext;
createdAt: string;
}
function categorizeFailure(
context: ComponentContext,
validationResult: ValidationResult
): string {
const errors = validationResult.typeErrors.join(' ');
if (errors.includes('this') && context.lifecycleMethods.length > 0) {
return 'implicit-this-in-lifecycle';
}
if (context.instanceVariables.some(v => v.includes('Ref'))) {
return 'ref-handling';
}
if (errors.includes('contextType') || errors.includes('getChildContext')) {
return 'legacy-context-api';
}
if (context.lifecycleMethods.includes('componentDidUpdate')) {
return 'complex-lifecycle-interaction';
}
return 'unknown';
}
async function queueForReview(
reviewItem: ReviewItem,
queuePath: string
): Promise<void> {
const reviewPath = path.join(queuePath, `${reviewItem.id}.json`);
fs.writeFileSync(reviewPath, JSON.stringify(reviewItem, null, 2), 'utf-8');
console.log(`Queued for review: ${reviewItem.originalPath} (${reviewItem.failureCategory})`);
}
The reviewer’s job is not to debug the LLM prompt. It’s to fix the specific file and record the fix pattern. After accumulating a few examples in each category, you can write targeted ts-morph transforms for those cases and remove them from the LLM path entirely. The failure queue is your roadmap for improving the pipeline.
Pipeline Orchestration
The full pipeline for a batch migration looks like this:
interface PipelineConfig {
sourceGlob: string;
tsConfigPath: string;
reviewQueuePath: string;
dryRun: boolean;
}
interface PipelineResult {
total: number;
succeeded: number;
failedValidation: number;
queuedForReview: number;
skipped: number;
}
async function runMigrationPipeline(config: PipelineConfig): Promise<PipelineResult> {
const { glob } = await import('glob');
const files = await glob(config.sourceGlob);
const result: PipelineResult = {
total: files.length,
succeeded: 0,
failedValidation: 0,
queuedForReview: 0,
skipped: 0,
};
const project = new Project({ tsConfigFilePath: config.tsConfigPath });
for (const filePath of files) {
const sourceFile = project.addSourceFileAtPath(filePath);
const context = extractClassComponentContext(sourceFile);
if (!context) {
result.skipped++;
continue;
}
console.log(`Migrating: ${filePath}`);
let migratedSource: string;
try {
migratedSource = await migrateWithLLM({
context,
targetPattern: 'react-class-to-function',
preserveComments: true,
});
} catch (err) {
console.error(`LLM call failed for ${filePath}:`, err);
result.skipped++;
continue;
}
const validation = await validateMigration(
migratedSource,
filePath,
config.tsConfigPath
);
if (validation.typeCheckPassed && (!validation.testsRan || validation.testsPassed)) {
if (!config.dryRun) {
fs.writeFileSync(filePath, migratedSource, 'utf-8');
}
result.succeeded++;
} else {
const reviewItem: ReviewItem = {
id: `${Date.now()}-${path.basename(filePath, '.tsx')}`,
originalPath: filePath,
originalSource: context.originalSource,
llmAttempt: migratedSource,
validationResult: validation,
failureCategory: categorizeFailure(context, validation),
context,
createdAt: new Date().toISOString(),
};
await queueForReview(reviewItem, config.reviewQueuePath);
result.queuedForReview++;
result.failedValidation++;
}
// Rate limit LLM calls — 1 file per second is a reasonable default
await new Promise(resolve => setTimeout(resolve, 1000));
}
return result;
}
Tradeoffs
| Approach | Coverage | Reliability | Maintenance cost | When to use |
|---|---|---|---|---|
| Regex codemod | 60-70% | Low (silent failures) | Low initially, high over time | Never for production migrations |
| Pure AST codemod | 75-85% | High | High upfront, low ongoing | Well-defined, rule-expressible migrations |
| LLM only (no AST) | 80-85% | Medium (unpredictable failures) | Low | Prototyping, small codebases |
| AST context + LLM + validation | 90-95% | High | Medium | Production migrations at scale |
| AST + LLM + validation + human queue | 99%+ | High | Medium | Any migration where correctness matters |
Production Considerations
A few things that will bite you on a real codebase:
File size limits: LLMs have context windows. Files over 800 lines often exceed what you can fit with the full prompt context included. Split at the component boundary, not at an arbitrary line count.
Rate limits: For a 500-file migration, you will hit API rate limits. Implement exponential backoff and run the pipeline during off-peak hours. Build checkpointing so a failed run can resume without re-migrating already completed files.
Idempotency: The pipeline should be safe to re-run. Write migrated files atomically (temp file, then rename) and skip files that are already in function component form (AST check before calling the LLM).
Diffing for review: Generate a unified diff for each migrated file and commit it to a branch. Reviewers work with the diff, not the full file. A 100-line component’s migration typically produces a 40-60 line diff that’s reviewable in 2 minutes.
Cost: At current API pricing, a 500-file migration costs roughly $15-40 depending on file size. Track token usage per file and flag outliers. Files over $0.20 each usually indicate a context problem worth investigating.
The 20% that goes to human review is not a failure of the pipeline. It’s the pipeline working correctly: the validation layer caught cases where automated trust would have been misplaced, and a reviewer will fix them in a fraction of the time it would have taken to debug silent regressions in production. The goal is a high-confidence migration, not a fully automated one.
More in AI / ML
How Mixture of Experts Works: Sparse Gating, Expert Routing, and the Architecture Behind Efficient Large Language Models
A deep dive into MoE architecture: how the gating network routes tokens to experts, top-k selection, load balancing losses, capacity factor, token dropping, expert parallelism for serving, and the real production tradeoffs between dense transformers and sparse MoE models.
AI Agent Frameworks Compared: CrewAI, LangGraph, AutoGen, and Mastra for Production Systems
A practical comparison of CrewAI, LangGraph, AutoGen, and Mastra for building production AI agent systems. Covers architecture philosophy, state management, tool integration, observability, and deployment patterns with TypeScript code examples.
Google's Agent2Agent Protocol: How A2A Enables Cross-Framework Agent Communication in Production Systems
A deep dive into Google's Agent2Agent (A2A) protocol covering agent cards, task lifecycle, message parts, streaming via SSE, push notifications, and how A2A complements MCP. Includes TypeScript implementation examples, comparison with MCP and direct API integration, and production deployment patterns for multi-vendor agent ecosystems.
How Transformer Models Work: Self-Attention, Positional Encoding, and the Architecture Behind Modern LLMs
A technical deep dive into the Transformer architecture: tokenization, positional encoding, self-attention with Q/K/V matrices, multi-head attention, the encoder-decoder split, training dynamics, and what it all means for engineers building on top of LLMs.