AI-Powered Codemod and Refactoring Generation
AI-Powered Codemod and Refactoring Generation
Real-World Problem Context
A frontend team maintaining a large React application (600 components, 180 routes, 95,000 lines of TypeScript) faces a series of large-scale migrations: upgrading from React Router v5 to v6 (changing every <Switch> to <Routes>, every useHistory() to useNavigate(), and every render-prop <Route> to element-based <Route>), migrating from Enzyme to React Testing Library (400 test files), replacing a legacy internal UI library with a new design system (280 component usages across 150 files), and converting all class components to function components with hooks (45 remaining classes). Each migration follows a mechanical pattern — the same transformation applied hundreds of times — but each instance has contextual variations that simple find-and-replace cannot handle. The team estimates 6-8 weeks of manual refactoring. They build an AI-powered codemod pipeline: (1) an AST analysis engine that identifies all instances of a pattern in the codebase, (2) an LLM that generates the transformation logic by examining before/after examples, (3) a validation layer that runs TypeScript checks and tests after each transformation, and (4) a human review interface that groups similar transformations for batch approval. This post covers how each stage works.
Problem Statements
-
Pattern Detection and Instance Classification: How do you scan an entire codebase to find every instance of a pattern that needs transformation (e.g., every
useHistory()call, every Enzymeshallow()wrapper, every<LegacyButton>usage), classify each instance by complexity (simple mechanical replacement vs. context-dependent transformation), and estimate the total migration scope? -
Context-Aware Transformation Generation: How does an LLM generate correct AST transformations that handle variations — a
useHistory().push()inside a try-catch differs from one in a useEffect callback; an Enzymewrapper.find('.class')differs fromwrapper.find(ComponentName)— without producing code that breaks TypeScript types, removes necessary logic, or introduces subtle bugs? -
Safe Automated Application at Scale: How do you apply hundreds of transformations across a codebase while maintaining correctness — running TypeScript type-checking after each batch, executing affected tests, detecting when a transformation produces a behavioral change (not just a syntactic one), and rolling back individual transformations that fail?
Deep Dive: Internal Mechanisms
1. AST-Based Pattern Detection
/*
* Step 1: Parse every file into an AST and find all instances
* of the pattern to transform.
*
* Using jscodeshift (Facebook's AST transformation toolkit)
* which wraps recast (AST manipulation preserving formatting).
*
* AST Flow:
* ┌──────────┐ ┌──────────┐ ┌───────────┐
* │ Source │───▶│ Parse │───▶│ AST │
* │ File │ │ (babel) │ │ (tree) │
* └──────────┘ └──────────┘ └───────────┘
* │
* ┌────────▼────────┐
* │ Pattern matching │
* │ (find all nodes │
* │ matching shape) │
* └────────┬────────┘
* │
* ┌────────▼────────┐
* │ Classify each │
* │ instance by │
* │ complexity │
* └─────────────────┘
*/
const jscodeshift = require('jscodeshift');
const j = jscodeshift;
// Pattern scanner: finds all instances and classifies them
function scanForPattern(fileSource, filePath, pattern) {
const root = j(fileSource);
const instances = [];
switch (pattern.type) {
case 'useHistory-to-useNavigate': {
// Find all useHistory() calls:
root.find(j.CallExpression, {
callee: { name: 'useHistory' }
}).forEach(path => {
const parent = path.parent;
const usage = analyzeHistoryUsage(path, root);
instances.push({
file: filePath,
line: path.node.loc?.start.line,
pattern: 'useHistory',
complexity: usage.complexity,
context: {
// What methods are called on the history object:
methods: usage.methods, // ['push', 'replace', 'goBack']
// Is it destructured?
isDestructured: usage.isDestructured,
// Is it passed to another function?
isPassedDown: usage.isPassedDown,
// Is it used in a callback/effect?
inCallback: usage.inCallback,
// Surrounding code for LLM context:
surroundingCode: extractSurroundingCode(path, 5),
},
});
});
break;
}
case 'enzyme-to-rtl': {
// Find shallow(), mount(), render() from enzyme:
root.find(j.ImportDeclaration, {
source: { value: 'enzyme' }
}).forEach(importPath => {
const specifiers = importPath.node.specifiers;
for (const spec of specifiers) {
const importedName = spec.local.name;
// Find all usages of the imported function:
root.find(j.CallExpression, {
callee: { name: importedName }
}).forEach(callPath => {
const testContext = analyzeTestContext(callPath, root);
instances.push({
file: filePath,
line: callPath.node.loc?.start.line,
pattern: `enzyme-${importedName}`,
complexity: testContext.complexity,
context: {
enzymeMethod: importedName,
assertions: testContext.assertions,
queries: testContext.queries,
componentProps: testContext.componentProps,
surroundingCode: extractSurroundingCode(callPath, 10),
},
});
});
}
});
break;
}
case 'component-replacement': {
// Find all usages of a specific component:
const { oldComponent, newComponent } = pattern;
root.find(j.JSXOpeningElement, {
name: { name: oldComponent }
}).forEach(path => {
const props = extractJSXProps(path);
const children = extractJSXChildren(path.parent);
instances.push({
file: filePath,
line: path.node.loc?.start.line,
pattern: `replace-${oldComponent}`,
complexity: classifyComponentReplacement(props, children, pattern),
context: {
currentProps: props,
hasChildren: children.length > 0,
childrenTypes: children.map(c => c.type),
propMapping: pattern.propMapping, // { old: new } mapping
surroundingCode: extractSurroundingCode(path, 3),
},
});
});
break;
}
}
return instances;
}
function analyzeHistoryUsage(historyCallPath, root) {
// Find the variable the history object is assigned to:
const parentNode = historyCallPath.parent.node;
let variableName;
if (parentNode.type === 'VariableDeclarator') {
variableName = parentNode.id.name;
}
if (!variableName) {
return { complexity: 'high', methods: [], isDestructured: false };
}
// Find all usages of that variable:
const methods = new Set();
let isPassedDown = false;
let inCallback = false;
root.find(j.MemberExpression, {
object: { name: variableName }
}).forEach(memberPath => {
methods.add(memberPath.node.property.name);
// Check if inside a callback:
let ancestor = memberPath.parent;
while (ancestor) {
if (ancestor.node.type === 'ArrowFunctionExpression' ||
ancestor.node.type === 'FunctionExpression') {
inCallback = true;
break;
}
ancestor = ancestor.parent;
}
});
// Check if history is passed as a prop or argument:
root.find(j.JSXAttribute, {
value: { expression: { name: variableName } }
}).forEach(() => { isPassedDown = true; });
// Classify complexity:
let complexity = 'simple';
if (methods.has('listen')) complexity = 'complex';
if (methods.has('block')) complexity = 'complex';
if (isPassedDown) complexity = 'complex';
if (methods.size > 2) complexity = 'medium';
return {
complexity,
methods: [...methods],
isDestructured: parentNode.id.type === 'ObjectPattern',
isPassedDown,
inCallback,
};
}
2. LLM-Based Transformation Generation
/*
* The LLM generates codemod transformations from examples.
*
* Process:
* 1. Show the LLM 3-5 manually transformed examples
* 2. LLM generates a jscodeshift transform function
* 3. Run the generated transform on a test set
* 4. Human reviews results, provides corrections
* 5. LLM refines the transform
* 6. Repeat until accuracy > 95%
*/
async function generateCodemodFromExamples(examples, pattern) {
const prompt = `You are an expert at writing jscodeshift codemods for JavaScript/TypeScript.
TASK: Generate a jscodeshift transform that performs the following migration:
Pattern: ${pattern.description}
EXAMPLES (before → after):
${examples.map((ex, i) => `
--- Example ${i + 1} ---
BEFORE:
\`\`\`typescript
${ex.before}
\`\`\`
AFTER:
\`\`\`typescript
${ex.after}
\`\`\`
EXPLANATION: ${ex.explanation}
`).join('\n')}
REQUIREMENTS:
1. Use jscodeshift API (j = jscodeshift)
2. Preserve all comments and formatting (recast handles this)
3. Handle TypeScript type annotations
4. Handle edge cases visible in the examples
5. Add JSDoc explaining what the transform does
6. Return a standard jscodeshift transform: module.exports = function(fileInfo, api) { ... }
EDGE CASES TO HANDLE:
${pattern.edgeCases?.map(ec => `- ${ec}`).join('\n') || 'None specified'}
Generate ONLY the transform code, no explanation.`;
const generatedCode = await callLLM(prompt, {
model: 'gpt-4o',
temperature: 0.1,
maxTokens: 2000,
});
return extractCodeBlock(generatedCode);
}
// Example: Generate a useHistory → useNavigate codemod:
async function generateRouterV6Codemod() {
const examples = [
{
before: `import { useHistory } from 'react-router-dom';
function MyComponent() {
const history = useHistory();
const handleClick = () => {
history.push('/dashboard');
};
return <button onClick={handleClick}>Go</button>;
}`,
after: `import { useNavigate } from 'react-router-dom';
function MyComponent() {
const navigate = useNavigate();
const handleClick = () => {
navigate('/dashboard');
};
return <button onClick={handleClick}>Go</button>;
}`,
explanation: 'Simple push → navigate. Variable renamed from history to navigate.',
},
{
before: `import { useHistory } from 'react-router-dom';
function SearchPage() {
const history = useHistory();
const handleSearch = (query) => {
history.push({
pathname: '/search',
search: '?q=' + query,
state: { fromHome: true },
});
};
const goBack = () => {
history.goBack();
};
return <div>...</div>;
}`,
after: `import { useNavigate } from 'react-router-dom';
function SearchPage() {
const navigate = useNavigate();
const handleSearch = (query) => {
navigate({
pathname: '/search',
search: '?q=' + query,
}, { state: { fromHome: true } });
};
const goBack = () => {
navigate(-1);
};
return <div>...</div>;
}`,
explanation: 'push with object + state → navigate with path object and options. goBack → navigate(-1). State moves to second argument.',
},
{
before: `import { useHistory } from 'react-router-dom';
function ProtectedAction() {
const history = useHistory();
const handleAction = async () => {
try {
await submitAction();
history.replace('/success');
} catch (err) {
history.push('/error', { error: err.message });
}
};
return <button onClick={handleAction}>Submit</button>;
}`,
after: `import { useNavigate } from 'react-router-dom';
function ProtectedAction() {
const navigate = useNavigate();
const handleAction = async () => {
try {
await submitAction();
navigate('/success', { replace: true });
} catch (err) {
navigate('/error', { state: { error: err.message } });
}
};
return <button onClick={handleAction}>Submit</button>;
}`,
explanation: 'history.replace(path) → navigate(path, { replace: true }). push with state as second arg → navigate with { state } option.',
},
];
return generateCodemodFromExamples(examples, {
description: 'Migrate from React Router v5 useHistory() to v6 useNavigate()',
edgeCases: [
'history.listen() has no direct equivalent — flag for manual review',
'history.block() has no direct equivalent — flag for manual review',
'history passed as prop to child components',
'Destructured: const { push, replace } = useHistory()',
'history.length access',
],
});
}
3. Codemod Validation Pipeline
/*
* Every generated codemod goes through a validation pipeline
* BEFORE being applied to the real codebase:
*
* ┌──────────────────────────────────────────────────────────┐
* │ Validation Pipeline │
* │ │
* │ 1. Syntax check: Does the output parse? │
* │ 2. Type check: Does it pass TypeScript? │
* │ 3. Semantic check: Does the behavior change? │
* │ 4. Test check: Do existing tests still pass? │
* │ 5. Snapshot: Visual diff of the code change │
* └──────────────────────────────────────────────────────────┘
*/
class CodemodValidator {
constructor(projectRoot) {
this.projectRoot = projectRoot;
}
async validate(transform, files) {
const results = {
total: files.length,
passed: 0,
failed: 0,
skipped: 0,
errors: [],
};
for (const file of files) {
const originalSource = await fs.readFile(file, 'utf-8');
try {
// Step 1: Apply transform
const transformed = applyTransform(transform, originalSource, file);
if (transformed === originalSource) {
results.skipped++;
continue;
}
// Step 2: Syntax check
const syntaxValid = await this.checkSyntax(transformed, file);
if (!syntaxValid.ok) {
results.failed++;
results.errors.push({
file,
stage: 'syntax',
error: syntaxValid.error,
});
continue;
}
// Step 3: Type check (write temp file and run tsc)
const typeValid = await this.checkTypes(transformed, file);
if (!typeValid.ok) {
results.failed++;
results.errors.push({
file,
stage: 'typecheck',
error: typeValid.error,
});
continue;
}
// Step 4: Run affected tests
const testsPass = await this.runAffectedTests(file);
if (!testsPass.ok) {
results.failed++;
results.errors.push({
file,
stage: 'tests',
error: testsPass.error,
});
continue;
}
results.passed++;
} catch (err) {
results.failed++;
results.errors.push({
file,
stage: 'transform',
error: err.message,
});
}
}
return results;
}
async checkSyntax(source, filePath) {
try {
const parser = filePath.endsWith('.tsx') ? 'tsx' : 'typescript';
const ast = require('@babel/parser').parse(source, {
sourceType: 'module',
plugins: [parser, 'decorators-legacy', 'classProperties'],
});
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
}
async checkTypes(source, originalPath) {
// Write to a temporary file alongside the original:
const tempPath = originalPath.replace(/\.(tsx?)$/, '.codemod-temp.$1');
try {
await fs.writeFile(tempPath, source);
const { execSync } = require('child_process');
execSync(`npx tsc --noEmit --pretty false ${tempPath}`, {
cwd: this.projectRoot,
stdio: 'pipe',
});
return { ok: true };
} catch (err) {
return { ok: false, error: err.stderr?.toString() || err.message };
} finally {
await fs.unlink(tempPath).catch(() => {});
}
}
async runAffectedTests(filePath) {
try {
const { execSync } = require('child_process');
// Use Jest's --findRelatedTests to run only tests that import this file:
execSync(`npx jest --findRelatedTests ${filePath} --passWithNoTests`, {
cwd: this.projectRoot,
stdio: 'pipe',
timeout: 30000,
});
return { ok: true };
} catch (err) {
return { ok: false, error: err.stderr?.toString() || err.message };
}
}
}
4. Enzyme to React Testing Library Migration
/*
* One of the most complex codemods: Enzyme → RTL.
*
* Challenge: Enzyme tests interact with component INTERNALS
* (find by component name, access state, simulate events),
* while RTL tests interact with the DOM (find by role, text,
* fire real events). This is a PARADIGM shift, not just an
* API rename.
*
* Pattern mapping:
* ┌──────────────────────────┬───────────────────────────────┐
* │ Enzyme │ React Testing Library │
* ├──────────────────────────┼───────────────────────────────┤
* │ shallow(<Comp />) │ render(<Comp />) │
* │ mount(<Comp />) │ render(<Comp />) │
* │ wrapper.find('.class') │ screen.getByRole/getByText │
* │ wrapper.find(ChildComp) │ screen.getByRole/getByTestId │
* │ wrapper.simulate('click')│ fireEvent.click(element) │
* │ wrapper.prop('onClick') │ (test behavior, not props) │
* │ wrapper.state('value') │ (test rendered output) │
* │ wrapper.instance() │ (not available — test DOM) │
* │ wrapper.text() │ screen.getByText() │
* │ expect(wrapper).toHave │ expect(element).toBeInThe... │
* │ Length(1) │ Document() │
* └──────────────────────────┴───────────────────────────────┘
*/
async function generateEnzymeToRTLTransform(testFile) {
const source = await fs.readFile(testFile, 'utf-8');
// Analyze the test structure first:
const analysis = analyzeEnzymeTest(source);
const prompt = `Convert this Enzyme test to React Testing Library.
ORIGINAL TEST:
\`\`\`typescript
${source}
\`\`\`
ANALYSIS:
- Enzyme methods used: ${analysis.methods.join(', ')}
- Components tested: ${analysis.components.join(', ')}
- Selectors used: ${analysis.selectors.map(s => `${s.type}: ${s.value}`).join(', ')}
- State/instance access: ${analysis.accessesInternals ? 'YES — needs paradigm shift' : 'No'}
- Event simulations: ${analysis.events.join(', ')}
RULES:
1. Import { render, screen, fireEvent } from '@testing-library/react'
2. Import '@testing-library/jest-dom' for matchers
3. Replace shallow/mount with render()
4. Replace wrapper.find('.class') with screen.getByRole, getByText, or getByTestId
5. Replace wrapper.simulate('click') with fireEvent.click(element)
6. Replace wrapper.text() checks with screen.getByText
7. Replace wrapper.find(Component).length checks with screen.queryByRole
8. If wrapper.state() or wrapper.instance() is used, rewrite the test
to verify RENDERED OUTPUT instead of internal state
9. Replace enzyme matchers with @testing-library/jest-dom matchers:
- toHaveLength(1) → toBeInTheDocument()
- toHaveLength(0) → not.toBeInTheDocument() (use queryBy)
10. Preserve the test description and intent
11. Use userEvent over fireEvent where possible for user interactions
12. Add async/await if the test needs waitFor for async updates
IMPORTANT:
- Do NOT just rename APIs — RTL tests should test USER BEHAVIOR
- If Enzyme test checks internal state, rewrite to check what the user SEES
- Preserve test coverage: every assertion should have an equivalent
Return ONLY the converted test code.`;
return await callLLM(prompt, {
model: 'gpt-4o',
temperature: 0.1,
maxTokens: 3000,
});
}
function analyzeEnzymeTest(source) {
const root = j(source);
const analysis = {
methods: new Set(),
components: [],
selectors: [],
events: [],
accessesInternals: false,
};
// Find enzyme imports:
root.find(j.ImportDeclaration, {
source: { value: 'enzyme' }
}).forEach(path => {
path.node.specifiers.forEach(s => {
analysis.methods.add(s.local.name);
});
});
// Find .simulate() calls:
root.find(j.CallExpression, {
callee: { property: { name: 'simulate' } }
}).forEach(path => {
const args = path.node.arguments;
if (args[0]?.value) {
analysis.events.push(args[0].value);
}
});
// Find .state() or .instance() calls:
root.find(j.CallExpression, {
callee: { property: { name: 'state' } }
}).forEach(() => { analysis.accessesInternals = true; });
root.find(j.CallExpression, {
callee: { property: { name: 'instance' } }
}).forEach(() => { analysis.accessesInternals = true; });
// Find .find() selectors:
root.find(j.CallExpression, {
callee: { property: { name: 'find' } }
}).forEach(path => {
const arg = path.node.arguments[0];
if (arg?.type === 'StringLiteral') {
analysis.selectors.push({ type: 'css', value: arg.value });
} else if (arg?.type === 'Identifier') {
analysis.selectors.push({ type: 'component', value: arg.name });
}
});
analysis.methods = [...analysis.methods];
return analysis;
}
5. Component Library Migration Engine
/*
* Migrating from one component library to another:
* e.g., Legacy UI → New Design System
*
* This requires:
* 1. Mapping old components to new ones
* 2. Mapping old props to new props
* 3. Handling children transformations
* 4. Adding new required props (e.g., aria-labels)
* 5. Handling cases where 1 old component = 2+ new components
*/
class ComponentMigrationEngine {
constructor(mappings) {
// Mapping definition:
this.mappings = mappings;
// Example:
// {
// 'LegacyButton': {
// newComponent: 'Button',
// newImport: '@design-system/react',
// propMapping: {
// 'type': 'variant', // rename prop
// 'isDisabled': 'disabled', // rename
// 'onClick': 'onPress', // rename
// 'size': (value) => { // transform value
// return { size: value === 'small' ? 'sm' : value === 'large' ? 'lg' : 'md' };
// },
// },
// removedProps: ['legacy-id'],
// addedProps: { 'data-testid': (oldProps) => `btn-${oldProps['legacy-id'] || 'default'}` },
// wrapper: null, // or a wrapping component
// },
// 'LegacyModal': {
// newComponent: 'Dialog',
// newImport: '@design-system/react',
// propMapping: { 'isOpen': 'open', 'onClose': 'onDismiss' },
// childTransform: (children) => {
// // Wrap children in Dialog.Content:
// return `<Dialog.Content>${children}</Dialog.Content>`;
// },
// },
// }
}
generateTransform() {
return (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
let hasChanges = false;
const newImports = new Map();
const removedImports = new Set();
for (const [oldName, mapping] of Object.entries(this.mappings)) {
// Find all JSX usages of the old component:
root.find(j.JSXOpeningElement, {
name: { name: oldName }
}).forEach(path => {
hasChanges = true;
// Replace component name:
path.node.name.name = mapping.newComponent;
// Transform props:
const newAttributes = [];
for (const attr of path.node.attributes) {
if (attr.type !== 'JSXAttribute') {
newAttributes.push(attr); // Spread attributes
continue;
}
const propName = attr.name.name;
// Skip removed props:
if (mapping.removedProps?.includes(propName)) continue;
// Check for prop mapping:
if (mapping.propMapping?.[propName]) {
const mapped = mapping.propMapping[propName];
if (typeof mapped === 'string') {
attr.name.name = mapped;
} else if (typeof mapped === 'function') {
// Value transformation — complex, needs AI:
// Flag for manual review
attr.name.name = propName + '__NEEDS_REVIEW__';
}
}
newAttributes.push(attr);
}
// Add new required props:
if (mapping.addedProps) {
for (const [name, valueGen] of Object.entries(mapping.addedProps)) {
newAttributes.push(
j.jsxAttribute(
j.jsxIdentifier(name),
j.stringLiteral(typeof valueGen === 'function'
? valueGen({}) : valueGen)
)
);
}
}
path.node.attributes = newAttributes;
// Track import changes:
newImports.set(mapping.newComponent, mapping.newImport);
removedImports.add(oldName);
});
// Handle closing tags too:
root.find(j.JSXClosingElement, {
name: { name: oldName }
}).forEach(path => {
path.node.name.name = mapping.newComponent;
});
}
if (!hasChanges) return fileInfo.source;
// Update imports:
this.updateImports(root, j, newImports, removedImports);
return root.toSource({ quote: 'single' });
};
}
updateImports(root, j, newImports, removedImports) {
// Remove old imports:
root.find(j.ImportDeclaration).forEach(path => {
path.node.specifiers = path.node.specifiers.filter(
spec => !removedImports.has(spec.local.name)
);
if (path.node.specifiers.length === 0) {
j(path).remove();
}
});
// Add new imports (grouped by source):
const importsBySource = new Map();
for (const [component, source] of newImports) {
if (!importsBySource.has(source)) {
importsBySource.set(source, []);
}
importsBySource.get(source).push(component);
}
for (const [source, components] of importsBySource) {
// Check if import from this source already exists:
const existing = root.find(j.ImportDeclaration, {
source: { value: source }
});
if (existing.length > 0) {
// Add to existing import:
for (const comp of components) {
existing.get().node.specifiers.push(
j.importSpecifier(j.identifier(comp))
);
}
} else {
// Create new import:
const importDecl = j.importDeclaration(
components.map(c => j.importSpecifier(j.identifier(c))),
j.stringLiteral(source)
);
// Insert after last import:
const lastImport = root.find(j.ImportDeclaration);
if (lastImport.length > 0) {
lastImport.at(-1).insertAfter(importDecl);
} else {
root.get().node.program.body.unshift(importDecl);
}
}
}
}
}
6. Class Component to Hooks Conversion
/*
* Converting class components to function components with hooks.
*
* Mapping:
* ┌────────────────────────────┬────────────────────────────────┐
* │ Class │ Function + Hooks │
* ├────────────────────────────┼────────────────────────────────┤
* │ this.state = { ... } │ useState(...) │
* │ this.setState({ x }) │ setX(value) │
* │ componentDidMount │ useEffect(() => {}, []) │
* │ componentDidUpdate │ useEffect(() => {}) │
* │ componentWillUnmount │ useEffect(() => { return }) │
* │ this.props │ destructured params │
* │ this.myMethod │ const myMethod = ... │
* │ static contextType │ useContext() │
* │ createRef() │ useRef() │
* │ shouldComponentUpdate │ React.memo │
* │ getDerivedStateFromProps │ useState + useEffect or useMemo │
* └────────────────────────────┴────────────────────────────────┘
*/
async function convertClassToFunction(classSource, componentName) {
const root = j(classSource);
// Analyze the class:
const classAnalysis = analyzeClassComponent(root, componentName);
// For simple classes (state + lifecycle + render), use AST transform:
if (classAnalysis.complexity === 'simple') {
return astBasedClassToFunction(root, classAnalysis);
}
// For complex classes (refs, context, getDerivedState, HOCs), use AI:
const prompt = `Convert this React class component to a function component with hooks.
\`\`\`typescript
${classSource}
\`\`\`
CLASS ANALYSIS:
- State fields: ${classAnalysis.stateFields.join(', ')}
- Lifecycle methods: ${classAnalysis.lifecycleMethods.join(', ')}
- Instance methods: ${classAnalysis.instanceMethods.join(', ')}
- Uses refs: ${classAnalysis.usesRefs}
- Uses context: ${classAnalysis.usesContext}
- Has shouldComponentUpdate: ${classAnalysis.hasSCU}
- Has getDerivedStateFromProps: ${classAnalysis.hasDerivedState}
- Props accessed: ${classAnalysis.propsAccessed.join(', ')}
RULES:
1. Each this.state field → separate useState()
2. componentDidMount → useEffect with [] dependency
3. componentWillUnmount → return cleanup from useEffect
4. componentDidUpdate(prevProps) → useEffect with specific deps
5. this.refs / createRef → useRef()
6. Bound methods → useCallback (only if passed as props)
7. If shouldComponentUpdate exists → wrap with React.memo
8. Preserve TypeScript types: Props interface stays, add return type
9. Preserve ALL logic — do not simplify or remove error handling
10. Keep the same export (default/named)
Return ONLY the converted code.`;
return await callLLM(prompt, {
model: 'gpt-4o',
temperature: 0.1,
maxTokens: 3000,
});
}
function analyzeClassComponent(root, componentName) {
const classDecl = root.find(j.ClassDeclaration, {
id: { name: componentName }
});
if (classDecl.length === 0) return null;
const body = classDecl.get().node.body.body;
const analysis = {
stateFields: [],
lifecycleMethods: [],
instanceMethods: [],
propsAccessed: new Set(),
usesRefs: false,
usesContext: false,
hasSCU: false,
hasDerivedState: false,
complexity: 'simple',
};
const lifecycleNames = [
'componentDidMount', 'componentDidUpdate', 'componentWillUnmount',
'shouldComponentUpdate', 'getDerivedStateFromProps', 'getSnapshotBeforeUpdate',
'componentDidCatch', 'render',
];
for (const member of body) {
if (member.type === 'ClassMethod' || member.type === 'ClassProperty') {
const name = member.key.name;
if (lifecycleNames.includes(name)) {
analysis.lifecycleMethods.push(name);
if (name === 'shouldComponentUpdate') analysis.hasSCU = true;
if (name === 'getDerivedStateFromProps') {
analysis.hasDerivedState = true;
analysis.complexity = 'complex';
}
} else if (name === 'state' && member.type === 'ClassProperty') {
// Extract state field names:
if (member.value?.properties) {
analysis.stateFields = member.value.properties
.map(p => p.key.name)
.filter(Boolean);
}
} else if (name !== 'constructor') {
analysis.instanceMethods.push(name);
}
}
}
// Check for refs:
root.find(j.CallExpression, {
callee: { property: { name: 'createRef' } }
}).forEach(() => {
analysis.usesRefs = true;
analysis.complexity = 'complex';
});
// Check for context:
root.find(j.ClassProperty, {
key: { name: 'contextType' }
}).forEach(() => {
analysis.usesContext = true;
});
if (analysis.lifecycleMethods.includes('getSnapshotBeforeUpdate') ||
analysis.lifecycleMethods.includes('componentDidCatch')) {
analysis.complexity = 'complex';
}
return analysis;
}
7. Batch Application with Rollback
/*
* Apply transformations in batches with per-file rollback:
*
* ┌─────────────────────────────────────────────────────┐
* │ Batch Application Strategy │
* │ │
* │ 1. Git branch: codemod/migration-name │
* │ 2. For each batch of N files: │
* │ a. Apply transform to N files │
* │ b. Run TypeScript check │
* │ c. Run affected tests │
* │ d. If any fail: │
* │ - Revert failed files │
* │ - Flag for manual review │
* │ - Continue with remaining files │
* │ e. Git commit batch │
* │ 3. Create PR with summary │
* └─────────────────────────────────────────────────────┘
*/
class BatchCodemodRunner {
constructor(projectRoot, options = {}) {
this.projectRoot = projectRoot;
this.batchSize = options.batchSize || 10;
this.dryRun = options.dryRun ?? true;
this.results = {
applied: [],
failed: [],
skipped: [],
manualReview: [],
};
}
async run(transform, files, branchName) {
if (!this.dryRun) {
// Create a new branch:
await exec(`git checkout -b ${branchName}`, { cwd: this.projectRoot });
}
// Process in batches:
for (let i = 0; i < files.length; i += this.batchSize) {
const batch = files.slice(i, i + this.batchSize);
await this.processBatch(transform, batch, i / this.batchSize + 1);
}
return this.generateReport();
}
async processBatch(transform, files, batchNum) {
const batchResults = [];
for (const file of files) {
const original = await fs.readFile(file, 'utf-8');
try {
// Apply transform:
const transformed = applyTransform(transform, original, file);
if (transformed === original) {
this.results.skipped.push({ file, reason: 'no changes' });
continue;
}
if (this.dryRun) {
// Just record the diff:
batchResults.push({
file,
original,
transformed,
diff: generateDiff(original, transformed),
});
this.results.applied.push({ file, diff: generateDiff(original, transformed) });
} else {
// Write the file:
await fs.writeFile(file, transformed);
batchResults.push({ file, original, transformed });
}
} catch (err) {
this.results.failed.push({
file,
error: err.message,
stage: 'transform',
});
}
}
if (!this.dryRun && batchResults.length > 0) {
// Type check the batch:
const typeCheck = await this.runTypeCheck();
if (!typeCheck.ok) {
// Find which files caused type errors:
for (const result of batchResults) {
if (typeCheck.errorFiles.includes(result.file)) {
// Revert this file:
await fs.writeFile(result.file, result.original);
this.results.failed.push({
file: result.file,
error: typeCheck.errors[result.file],
stage: 'typecheck',
});
// Remove from applied:
this.results.applied = this.results.applied
.filter(r => r.file !== result.file);
}
}
}
// Run affected tests:
const testCheck = await this.runAffectedTests(
batchResults.map(r => r.file)
);
if (!testCheck.ok) {
for (const failedFile of testCheck.failedFiles) {
const result = batchResults.find(r => r.file === failedFile);
if (result) {
await fs.writeFile(failedFile, result.original);
this.results.failed.push({
file: failedFile,
error: testCheck.errors[failedFile],
stage: 'tests',
});
}
}
}
// Commit successful files:
const successFiles = batchResults
.filter(r => !this.results.failed.some(f => f.file === r.file))
.map(r => r.file);
if (successFiles.length > 0) {
await exec(`git add ${successFiles.join(' ')}`, { cwd: this.projectRoot });
await exec(
`git commit -m "codemod: batch ${batchNum} — ${successFiles.length} files"`,
{ cwd: this.projectRoot }
);
}
}
}
generateReport() {
return {
summary: {
total: this.results.applied.length + this.results.failed.length +
this.results.skipped.length + this.results.manualReview.length,
applied: this.results.applied.length,
failed: this.results.failed.length,
skipped: this.results.skipped.length,
manualReview: this.results.manualReview.length,
},
details: this.results,
};
}
}
8. Human Review Interface
/*
* Group similar transformations for efficient human review.
* Instead of reviewing 200 files individually, the reviewer
* sees 5-10 GROUPS of similar changes.
*
* Grouping logic:
* - Same transform pattern applied → same group
* - Similar surrounding code → same group
* - Same complexity level → same group
*/
function groupTransformationsForReview(results) {
const groups = new Map();
for (const result of results.applied) {
// Create a signature for this transformation:
const signature = createTransformSignature(result.diff);
if (!groups.has(signature)) {
groups.set(signature, {
signature,
description: describeTransformGroup(result.diff),
files: [],
sampleDiff: result.diff,
confidence: 'high',
});
}
groups.get(signature).files.push(result.file);
}
// Flag groups that need careful review:
for (const group of groups.values()) {
if (group.files.length === 1) {
group.confidence = 'medium'; // Unique transform, review carefully
}
}
return [...groups.values()].sort((a, b) => b.files.length - a.files.length);
}
function createTransformSignature(diff) {
// Abstract the diff to find the PATTERN (ignoring specific names):
return diff
.replace(/['"]\w+['"]/g, '"__STRING__"') // Normalize strings
.replace(/\b\w+Component\b/g, '__COMP__') // Normalize component names
.replace(/\b\w+Handler\b/g, '__HANDLER__') // Normalize handlers
.replace(/\/\w+/g, '/__PATH__') // Normalize paths
.slice(0, 200); // Truncate for comparison
}
// React component for the review UI:
function CodemodReviewUI({ groups, onApprove, onReject, onEdit }) {
const [currentGroup, setCurrentGroup] = useState(0);
const group = groups[currentGroup];
if (!group) return <div>All groups reviewed!</div>;
return (
<div className="codemod-review">
<div className="review-header">
<h2>Group {currentGroup + 1} of {groups.length}</h2>
<p>{group.description}</p>
<p>Affects <strong>{group.files.length}</strong> files</p>
<span className={`confidence ${group.confidence}`}>
Confidence: {group.confidence}
</span>
</div>
<div className="review-diff">
<h3>Sample Transformation</h3>
<DiffViewer diff={group.sampleDiff} />
</div>
<div className="review-files">
<h3>Files in this group:</h3>
<ul>
{group.files.map(f => (
<li key={f}>
<code>{f}</code>
<button onClick={() => onEdit(f)}>Review individually</button>
</li>
))}
</ul>
</div>
<div className="review-actions">
<button
className="approve"
onClick={() => {
onApprove(group);
setCurrentGroup(c => c + 1);
}}
>
Approve All ({group.files.length} files)
</button>
<button
className="reject"
onClick={() => {
onReject(group);
setCurrentGroup(c => c + 1);
}}
>
Reject Group
</button>
</div>
</div>
);
}
9. Iterative Refinement Loop
/*
* When the initial codemod fails on some files,
* feed the failures back to the AI to refine the transform:
*
* ┌──────────┐ ┌───────────┐ ┌──────────────┐
* │ Generate │────▶│ Apply & │────▶│ Collect │
* │ codemod │ │ validate │ │ failures │
* └──────────┘ └───────────┘ └──────┬───────┘
* ▲ │
* │ ┌───────────┐ │
* └───────────│ Refine │◀────────────┘
* │ with AI │
* └───────────┘
*/
async function iterativeCodemodRefinement(
pattern,
files,
maxIterations = 3
) {
let examples = pattern.initialExamples;
let transform = null;
let bestResults = null;
for (let iteration = 0; iteration < maxIterations; iteration++) {
// Generate (or refine) the transform:
transform = await generateCodemodFromExamples(examples, pattern);
// Validate against all files:
const validator = new CodemodValidator(pattern.projectRoot);
const results = await validator.validate(transform, files);
// Check if we've reached acceptable accuracy:
const accuracy = results.passed / (results.total - results.skipped);
if (accuracy >= 0.95 || iteration === maxIterations - 1) {
bestResults = results;
break;
}
// Feed failures back to the AI:
const failureFeedback = results.errors.slice(0, 5).map(err => ({
file: err.file,
stage: err.stage,
error: err.error,
originalCode: fs.readFileSync(err.file, 'utf-8').slice(0, 500),
}));
const refinementPrompt = `The codemod you generated has ${results.failed} failures out of ${results.total} files.
FAILURE EXAMPLES:
${failureFeedback.map((f, i) => `
--- Failure ${i + 1} (${f.stage} error) ---
File: ${f.file}
Error: ${f.error}
Original code snippet:
\`\`\`
${f.originalCode}
\`\`\`
`).join('\n')}
Current accuracy: ${(accuracy * 100).toFixed(0)}% (${results.passed}/${results.total - results.skipped})
Analyze the failures and generate an IMPROVED codemod that handles these edge cases.
Explain what you changed and why.`;
const refinedCode = await callLLM(refinementPrompt, {
model: 'gpt-4o',
temperature: 0.1,
maxTokens: 2500,
});
transform = extractCodeBlock(refinedCode);
bestResults = results;
}
return {
transform,
results: bestResults,
};
}
10. End-to-End Migration Orchestrator
/*
* Orchestrates a complete migration:
* 1. Scan codebase for all instances
* 2. Classify by complexity
* 3. Generate codemods per complexity tier
* 4. Apply in phases (simple → medium → complex)
* 5. Track progress and report
*
* ┌──────────────────────────────────────────────────────┐
* │ Migration Dashboard │
* │ │
* │ Migration: React Router v5 → v6 │
* │ ██████████████████░░░░░ 78% complete │
* │ │
* │ Simple: 142/142 ✅ (automated) │
* │ Medium: 38/52 ✅ (AI-assisted, 14 in review) │
* │ Complex: 0/12 ⏳ (manual — flagged for dev) │
* │ │
* │ TypeScript errors: 0 │
* │ Test failures: 2 (investigating) │
* └──────────────────────────────────────────────────────┘
*/
class MigrationOrchestrator {
constructor(config) {
this.config = config;
this.status = {
phase: 'scanning',
instances: { simple: [], medium: [], complex: [] },
progress: { simple: 0, medium: 0, complex: 0 },
errors: [],
};
}
async execute() {
// Phase 1: Scan
this.status.phase = 'scanning';
const allFiles = await glob(this.config.filePattern, {
cwd: this.config.projectRoot
});
const instances = [];
for (const file of allFiles) {
const source = await fs.readFile(
path.join(this.config.projectRoot, file), 'utf-8'
);
const fileInstances = scanForPattern(source, file, this.config.pattern);
instances.push(...fileInstances);
}
// Classify:
this.status.instances = {
simple: instances.filter(i => i.complexity === 'simple'),
medium: instances.filter(i => i.complexity === 'medium'),
complex: instances.filter(i => i.complexity === 'complex'),
};
this.emit('scan-complete', {
total: instances.length,
simple: this.status.instances.simple.length,
medium: this.status.instances.medium.length,
complex: this.status.instances.complex.length,
});
// Phase 2: Apply simple transformations (fully automated)
this.status.phase = 'simple';
const simpleRunner = new BatchCodemodRunner(this.config.projectRoot, {
batchSize: 20,
dryRun: this.config.dryRun,
});
const simpleTransform = this.config.simpleTransform;
const simpleFiles = [...new Set(
this.status.instances.simple.map(i => i.file)
)];
const simpleResults = await simpleRunner.run(
simpleTransform,
simpleFiles,
`codemod/${this.config.name}-simple`
);
this.status.progress.simple = simpleResults.summary.applied;
this.emit('phase-complete', { phase: 'simple', results: simpleResults });
// Phase 3: AI-assisted medium complexity
this.status.phase = 'medium';
const mediumFiles = [...new Set(
this.status.instances.medium.map(i => i.file)
)];
// Generate AI transform with refinement:
const { transform: mediumTransform, results: mediumResults } =
await iterativeCodemodRefinement(
this.config.pattern,
mediumFiles,
3
);
this.status.progress.medium = mediumResults.passed;
this.emit('phase-complete', { phase: 'medium', results: mediumResults });
// Phase 4: Flag complex cases for manual review
this.status.phase = 'complex';
const complexFiles = [...new Set(
this.status.instances.complex.map(i => i.file)
)];
// Generate AI suggestions (not auto-applied):
for (const file of complexFiles) {
const source = await fs.readFile(file, 'utf-8');
const suggestion = await generateAISuggestion(
source,
this.config.pattern
);
this.status.instances.complex
.filter(i => i.file === file)
.forEach(i => { i.suggestion = suggestion; });
}
this.emit('phase-complete', {
phase: 'complex',
files: complexFiles,
message: `${complexFiles.length} files flagged for manual review with AI suggestions`,
});
// Final report:
this.status.phase = 'complete';
return this.generateFinalReport();
}
generateFinalReport() {
const total = this.status.instances.simple.length +
this.status.instances.medium.length +
this.status.instances.complex.length;
const automated = this.status.progress.simple + this.status.progress.medium;
return {
migration: this.config.name,
totalInstances: total,
automated,
manualReview: this.status.instances.complex.length,
automationRate: ((automated / total) * 100).toFixed(1) + '%',
phases: {
simple: {
total: this.status.instances.simple.length,
completed: this.status.progress.simple,
},
medium: {
total: this.status.instances.medium.length,
completed: this.status.progress.medium,
},
complex: {
total: this.status.instances.complex.length,
completed: this.status.progress.complex,
files: this.status.instances.complex.map(i => ({
file: i.file,
line: i.line,
suggestion: i.suggestion,
})),
},
},
errors: this.status.errors,
};
}
}
// Usage:
const migration = new MigrationOrchestrator({
name: 'react-router-v5-to-v6',
projectRoot: '/app',
filePattern: 'src/**/*.{tsx,ts}',
dryRun: true,
pattern: {
type: 'useHistory-to-useNavigate',
description: 'Migrate React Router v5 useHistory to v6 useNavigate',
initialExamples: routerV6Examples,
edgeCases: ['history.listen', 'history.block', 'destructured usage'],
},
simpleTransform: simpleRouterV6Transform,
});
const report = await migration.execute();
Trade-offs & Considerations
| Aspect | Manual Refactoring | Find-and-Replace | jscodeshift Only | AI + jscodeshift |
|---|---|---|---|---|
| Accuracy | 99% (human review) | 60-80% | 85-95% | 90-98% |
| Speed (500 files) | 6-8 weeks | 1 day + 2 weeks fixing | 2-3 days | 1-2 days |
| Handles edge cases | Yes (human) | No | If coded explicitly | AI generates handlers |
| TypeScript safety | Depends on dev | No guarantees | Type-check after | Type-check integrated |
| Paradigm shifts | Yes | No | Limited | Yes (Enzyme→RTL) |
| Cost | Engineer weeks | Low | Medium (writing codemods) | LLM cost (~$5-20/migration) |
| Learning curve | None | None | jscodeshift API | jscodeshift + prompt engineering |
| Rollback safety | Git revert | Git revert | Per-file rollback | Per-file with auto-revert |
Best Practices
-
Classify instances by complexity before choosing the transformation approach — scan the entire codebase and group instances into simple (mechanical rename, direct 1:1 API mapping), medium (requires context-aware transformation but follows a learnable pattern), and complex (paradigm shift like Enzyme→RTL, or requires understanding business logic); apply deterministic AST transforms for simple cases, AI-generated codemods for medium cases, and AI-suggested + human-applied for complex cases; this tiered approach typically automates 70-85% of instances.
-
Validate every transformation with syntax parsing, TypeScript type-checking, and affected test execution — a codemod that produces syntactically valid code may still break types (changing a prop name without updating the type interface) or behavior (replacing
history.push()withnavigate()but missing the state argument); runtsc --noEmitafter each batch andjest --findRelatedTestsfor each file; revert individual files that fail and flag them for manual review rather than blocking the entire batch. -
Use iterative refinement: generate → validate → feed failures back to the AI → regenerate — the first AI-generated codemod typically handles 80% of cases; feeding the 20% failures back as additional examples lets the AI learn edge cases it missed; 2-3 refinement iterations usually reach 95%+ accuracy; set a maximum iteration count and flag remaining failures for manual review rather than over-fitting the codemod.
-
Group similar transformations for human review instead of reviewing each file individually — create diff signatures that abstract away specific names and paths to find the transformation pattern; a reviewer approving a group of 40 identical
useHistory → useNavigatechanges takes 2 minutes versus 40 minutes reviewing each file; flag unique or low-confidence transformations for individual review. -
Apply transformations in batches on a separate git branch with per-batch commits — each batch commit makes it easy to bisect and revert; run the full test suite after each batch, not just affected tests; create a PR with a migration progress summary showing automated vs. manual-review counts; this gives the team confidence that the migration is correct and provides an audit trail for every change.
Conclusion
AI-powered codemod generation transforms large-scale refactoring from weeks of manual work into a systematic, largely automated pipeline. The AST-based pattern scanner uses jscodeshift to parse every file and classify each migration instance by complexity — simple mechanical renames, medium context-dependent transformations, and complex paradigm shifts. The LLM generates jscodeshift transforms from before/after examples, handling variations that would require extensive manual coding: converting React Router v5's useHistory().push() with state objects to v6's useNavigate() with options, or transforming Enzyme's wrapper.find(Component).simulate('click') to React Testing Library's screen.getByRole() with fireEvent. The validation pipeline checks syntax (Babel parse), types (TypeScript tsc --noEmit), and behavior (Jest --findRelatedTests) after each transformation, reverting individual files that fail. The iterative refinement loop feeds failures back to the AI as additional examples, typically reaching 95%+ accuracy in 2-3 iterations. The batch application engine creates per-batch git commits on a separate branch, enabling easy bisection and rollback. The human review interface groups similar transformations by diff signature — a reviewer approves 40 identical changes at once instead of reviewing each file. The migration orchestrator ties it all together, reporting progress by complexity tier and automation rate. For a typical large codebase migration (React Router v5→v6, 200 files), this pipeline automates 80-90% of transformations, flags 10-15% for AI-suggested manual review, and completes in 1-2 days what would take 6-8 weeks of manual refactoring.
What did you think?