Building an AI-assisted code refactoring tool with Claude
Why AI refactoring tools need more than a chat interface
Asking Claude "refactor this function" in a chat interface works fine for small snippets. It falls apart for real codebases because the model lacks the context it needs to make correct changes: what other files import this function, what types it uses from elsewhere, what tests cover it, what the naming conventions of the project are. Without that context, the model either produces generic changes that break callsites or over-hedges and proposes trivial cosmetic rewrites.
Building a useful refactoring tool means solving the context problem. You need to gather the right information automatically, assemble it into a prompt that gives the model enough to make sound decisions, and then validate and apply the output safely. This is a software engineering problem as much as an AI problem.
This article builds a complete refactoring tool in TypeScript. It uses TypeScript's compiler API for accurate code analysis, assembles targeted context rather than dumping entire files into the prompt, uses Claude to generate structured diffs, validates the output before applying it, and integrates with a CLI workflow. Every section is production-ready code you can adapt directly.
Setting up the project
mkdir ai-refactor && cd ai-refactor
npm init -y
npm install @anthropic-ai/sdk typescript ts-node zod
npm install --save-dev @types/node
// tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"rootDir": "src"
}
}
Step 1: Extracting code context with the TypeScript compiler API
The foundation of a good refactoring tool is accurate code analysis. The TypeScript compiler API gives you the AST, type information, and reference tracking that you need — far more reliable than regex or string search.
// src/analyzer.ts
import ts from "typescript";
import * as path from "path";
import * as fs from "fs";
export interface FunctionContext {
name: string;
sourceFile: string;
startLine: number;
endLine: number;
code: string;
signature: string;
dependencies: ImportedSymbol[];
callers: CallerInfo[];
jsDocComment?: string;
}
export interface ImportedSymbol {
name: string;
from: string;
isTypeOnly: boolean;
}
export interface CallerInfo {
file: string;
line: number;
snippet: string;
}
export class CodeAnalyzer {
private program: ts.Program;
private checker: ts.TypeChecker;
private sourceFiles: Map<string, ts.SourceFile>;
constructor(private projectRoot: string) {
const configPath = ts.findConfigFile(projectRoot, ts.sys.fileExists, "tsconfig.json");
if (!configPath) throw new Error("tsconfig.json not found");
const config = ts.readConfigFile(configPath, ts.sys.readFile);
const parsed = ts.parseJsonConfigFileContent(
config.config,
ts.sys,
path.dirname(configPath)
);
this.program = ts.createProgram(parsed.fileNames, parsed.options);
this.checker = this.program.getTypeChecker();
this.sourceFiles = new Map(
this.program.getSourceFiles().map((sf) => [sf.fileName, sf])
);
}
findFunction(fileName: string, functionName: string): FunctionContext | null {
const sf = this.sourceFiles.get(path.resolve(this.projectRoot, fileName));
if (!sf) return null;
let result: FunctionContext | null = null;
const visit = (node: ts.Node): void => {
if (
(ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node) ||
ts.isArrowFunction(node) || ts.isFunctionExpression(node)) &&
this.getFunctionName(node) === functionName
) {
result = this.extractFunctionContext(node, sf, fileName);
return;
}
ts.forEachChild(node, visit);
};
ts.forEachChild(sf, visit);
return result;
}
private getFunctionName(node: ts.Node): string | undefined {
if (ts.isFunctionDeclaration(node) && node.name) {
return node.name.text;
}
if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) {
return node.name.text;
}
// Arrow/function expressions assigned to variables
if (node.parent && ts.isVariableDeclaration(node.parent) &&
ts.isIdentifier(node.parent.name)) {
return node.parent.name.text;
}
return undefined;
}
private extractFunctionContext(
node: ts.Node,
sf: ts.SourceFile,
fileName: string
): FunctionContext {
const start = sf.getLineAndCharacterOfPosition(node.getStart());
const end = sf.getLineAndCharacterOfPosition(node.getEnd());
const code = node.getText(sf);
const name = this.getFunctionName(node) ?? "anonymous";
// Extract JSDoc comment
const jsDoc = ts.getJSDocCommentsAndTags(node)
.map((d) => d.getText(sf))
.join("\n");
// Get function signature from type checker
const symbol = this.checker.getSymbolAtLocation(
ts.isFunctionDeclaration(node) ? node.name! : node
);
const signature = symbol
? this.checker.typeToString(this.checker.getTypeOfSymbolAtLocation(symbol, node))
: "";
// Collect imports used by this function
const dependencies = this.collectDependencies(code, sf);
// Find all call sites
const callers = this.findCallers(name, sf.fileName);
return {
name,
sourceFile: fileName,
startLine: start.line + 1,
endLine: end.line + 1,
code,
signature,
dependencies,
callers,
jsDocComment: jsDoc || undefined,
};
}
private collectDependencies(code: string, sf: ts.SourceFile): ImportedSymbol[] {
const identifiers = new Set<string>();
const tempSf = ts.createSourceFile("temp.ts", code, ts.ScriptTarget.Latest);
const collectIdents = (node: ts.Node): void => {
if (ts.isIdentifier(node)) identifiers.add(node.text);
ts.forEachChild(node, collectIdents);
};
ts.forEachChild(tempSf, collectIdents);
const deps: ImportedSymbol[] = [];
sf.statements
.filter(ts.isImportDeclaration)
.forEach((imp) => {
const clause = imp.importClause;
if (!clause) return;
const moduleSpecifier = (imp.moduleSpecifier as ts.StringLiteral).text;
if (clause.name && identifiers.has(clause.name.text)) {
deps.push({ name: clause.name.text, from: moduleSpecifier, isTypeOnly: false });
}
clause.namedBindings && ts.isNamedImports(clause.namedBindings) &&
clause.namedBindings.elements.forEach((el) => {
if (identifiers.has(el.name.text)) {
deps.push({
name: el.name.text,
from: moduleSpecifier,
isTypeOnly: !!clause.isTypeOnly || !!el.isTypeOnly,
});
}
});
});
return deps;
}
private findCallers(functionName: string, excludeFile: string): CallerInfo[] {
const callers: CallerInfo[] = [];
for (const sf of this.program.getSourceFiles()) {
if (sf.fileName === excludeFile || sf.fileName.includes("node_modules")) continue;
const content = sf.getText();
const lines = content.split("\n");
lines.forEach((line, i) => {
if (new RegExp(`\\b${functionName}\\s*\\(`).test(line)) {
callers.push({
file: sf.fileName,
line: i + 1,
snippet: line.trim(),
});
}
});
}
return callers.slice(0, 10); // cap at 10 to avoid bloating the prompt
}
}
Step 2: Assembling the context prompt
Good context assembly is the difference between a model that proposes working changes and one that produces code that looks plausible but breaks the build.
// src/context-builder.ts
import { FunctionContext } from "./analyzer";
export interface RefactorRequest {
context: FunctionContext;
instruction: string;
projectConventions?: string;
relatedTypes?: string; // Type definitions the function uses
}
export function buildRefactoringPrompt(request: RefactorRequest): string {
const { context, instruction, projectConventions, relatedTypes } = request;
const sections: string[] = [];
// Project conventions (if provided)
if (projectConventions) {
sections.push(`## Project conventions\n${projectConventions}`);
}
// The target function
sections.push(`## Function to refactor
File: ${context.sourceFile} (lines ${context.startLine}–${context.endLine})
${context.jsDocComment ? `\nJSDoc:\n${context.jsDocComment}\n` : ""}
\`\`\`typescript
${context.code}
\`\`\``);
// Type signature
if (context.signature) {
sections.push(`## Current type signature\n\`\`\`typescript\n${context.signature}\n\`\`\``);
}
// Dependencies
if (context.dependencies.length > 0) {
const deps = context.dependencies
.map((d) => `- ${d.isTypeOnly ? "type " : ""}${d.name} from "${d.from}"`)
.join("\n");
sections.push(`## Imported dependencies used by this function\n${deps}`);
}
// Related types
if (relatedTypes) {
sections.push(`## Relevant type definitions\n\`\`\`typescript\n${relatedTypes}\n\`\`\``);
}
// Call sites
if (context.callers.length > 0) {
const callerLines = context.callers
.map((c) => `- ${c.file}:${c.line}: \`${c.snippet}\``)
.join("\n");
sections.push(`## Known call sites (${context.callers.length} found)\n${callerLines}`);
}
// The actual instruction
sections.push(`## Refactoring instruction\n${instruction}`);
// Output format instruction
sections.push(`## Required output format
Return a JSON object with this exact structure:
\`\`\`json
{
"summary": "One sentence describing what changed and why",
"changes": [
{
"file": "path/to/file.ts",
"type": "replace",
"startLine": 42,
"endLine": 58,
"originalCode": "exact original code being replaced",
"newCode": "replacement code"
}
],
"breakingChanges": ["List any breaking changes to the public API, empty array if none"],
"requiredImports": ["List any new imports needed, empty array if none"],
"testSuggestions": ["List test cases that should be updated or added"]
}
\`\`\`
Rules:
- Only output valid JSON, no markdown outside the JSON block.
- originalCode must exactly match the text in the source file.
- Do not change code outside the specified function unless strictly necessary.
- If call sites need updating due to a signature change, include them as additional changes.
- Preserve all existing JSDoc comments unless updating them is part of the instruction.`);
return sections.join("\n\n");
}
Step 3: Calling Claude and parsing the response
// src/refactor-engine.ts
import Anthropic from "@anthropic-ai/sdk";
import { z } from "zod";
import { RefactorRequest } from "./context-builder";
import { buildRefactoringPrompt } from "./context-builder";
const changeSchema = z.object({
file: z.string(),
type: z.enum(["replace", "insert", "delete"]),
startLine: z.number().int().positive(),
endLine: z.number().int().positive(),
originalCode: z.string(),
newCode: z.string(),
});
const refactorResponseSchema = z.object({
summary: z.string(),
changes: z.array(changeSchema),
breakingChanges: z.array(z.string()),
requiredImports: z.array(z.string()),
testSuggestions: z.array(z.string()),
});
export type RefactorResponse = z.infer<typeof refactorResponseSchema>;
export class RefactorEngine {
private client: Anthropic;
constructor() {
this.client = new Anthropic();
}
async refactor(request: RefactorRequest): Promise<RefactorResponse> {
const prompt = buildRefactoringPrompt(request);
const response = await this.client.messages.create({
model: "claude-opus-4-5",
max_tokens: 4096,
system: `You are an expert TypeScript engineer specializing in code refactoring.
You produce minimal, correct changes that accomplish the stated goal while preserving
existing behavior. You always consider call sites and type safety.
You return only the JSON output format specified, nothing else.`,
messages: [{ role: "user", content: prompt }],
});
const rawText = response.content
.filter((b) => b.type === "text")
.map((b) => (b as Anthropic.TextBlock).text)
.join("");
return this.parseResponse(rawText);
}
private parseResponse(raw: string): RefactorResponse {
// Strip markdown code fences if present
const cleaned = raw
.replace(/^```(?:json)?\s*/m, "")
.replace(/\s*```$/m, "")
.trim();
let parsed: unknown;
try {
parsed = JSON.parse(cleaned);
} catch {
// Try to extract JSON from within the text
const match = cleaned.match(/\{[\s\S]*\}/);
if (!match) throw new Error(`Could not parse JSON from response:\n${raw.slice(0, 500)}`);
parsed = JSON.parse(match[0]);
}
return refactorResponseSchema.parse(parsed);
}
}
Step 4: Validating changes before applying
Never apply model-generated code changes without validation. Verify that the original code the model claims to replace actually exists in the file at the stated location.
// src/validator.ts
import * as fs from "fs";
import { RefactorResponse } from "./refactor-engine";
export interface ValidationResult {
valid: boolean;
errors: string[];
warnings: string[];
}
export function validateChanges(response: RefactorResponse): ValidationResult {
const errors: string[] = [];
const warnings: string[] = [];
for (const change of response.changes) {
if (!fs.existsSync(change.file)) {
errors.push(`File does not exist: ${change.file}`);
continue;
}
const content = fs.readFileSync(change.file, "utf-8");
const lines = content.split("\n");
// Validate line range
if (change.startLine > lines.length || change.endLine > lines.length) {
errors.push(
`Line range ${change.startLine}-${change.endLine} is out of bounds for ${change.file} (${lines.length} lines)`
);
continue;
}
// Verify the original code matches what's actually in the file
const actualCode = lines
.slice(change.startLine - 1, change.endLine)
.join("\n")
.trim();
const expectedCode = change.originalCode.trim();
if (actualCode !== expectedCode) {
// Try a fuzzy match to catch whitespace differences
const normalize = (s: string) => s.replace(/\s+/g, " ").trim();
if (normalize(actualCode) === normalize(expectedCode)) {
warnings.push(
`Whitespace mismatch in ${change.file}:${change.startLine}–${change.endLine} (will proceed)`
);
} else {
errors.push(
`Code mismatch in ${change.file}:${change.startLine}–${change.endLine}\n` +
`Expected:\n${expectedCode.slice(0, 200)}\n` +
`Actual:\n${actualCode.slice(0, 200)}`
);
}
}
}
if (response.breakingChanges.length > 0) {
warnings.push(
`Breaking changes detected:\n${response.breakingChanges.map((c) => ` - ${c}`).join("\n")}`
);
}
return { valid: errors.length === 0, errors, warnings };
}
Step 5: Applying changes safely
Apply changes in reverse line-number order so that earlier changes don't shift line numbers for later ones.
// src/applier.ts
import * as fs from "fs";
import { RefactorResponse } from "./refactor-engine";
export function applyChanges(response: RefactorResponse, dryRun = false): string[] {
const applied: string[] = [];
// Group changes by file
const byFile = new Map<string, typeof response.changes>();
for (const change of response.changes) {
const existing = byFile.get(change.file) ?? [];
existing.push(change);
byFile.set(change.file, existing);
}
for (const [file, changes] of byFile) {
const content = fs.readFileSync(file, "utf-8");
const lines = content.split("\n");
// Apply in reverse order to preserve line numbers
const sorted = [...changes].sort((a, b) => b.startLine - a.startLine);
for (const change of sorted) {
if (change.type === "replace") {
const newLines = change.newCode.split("\n");
lines.splice(
change.startLine - 1,
change.endLine - change.startLine + 1,
...newLines
);
} else if (change.type === "delete") {
lines.splice(
change.startLine - 1,
change.endLine - change.startLine + 1
);
} else if (change.type === "insert") {
const newLines = change.newCode.split("\n");
lines.splice(change.startLine, 0, ...newLines);
}
}
const newContent = lines.join("\n");
if (!dryRun) {
// Write backup before modifying
fs.writeFileSync(`${file}.bak`, content);
fs.writeFileSync(file, newContent);
}
applied.push(file);
}
return applied;
}
Step 6: CLI interface
// src/cli.ts
import { CodeAnalyzer } from "./analyzer";
import { RefactorEngine } from "./refactor-engine";
import { validateChanges } from "./validator";
import { applyChanges } from "./applier";
import * as readline from "readline";
async function main() {
const [, , filePath, functionName, ...instructionParts] = process.argv;
if (!filePath || !functionName || instructionParts.length === 0) {
console.error("Usage: npx ts-node src/cli.ts <file> <function> <instruction>");
process.exit(1);
}
const instruction = instructionParts.join(" ");
const projectRoot = process.cwd();
console.log(`Analyzing ${functionName} in ${filePath}...`);
const analyzer = new CodeAnalyzer(projectRoot);
const context = analyzer.findFunction(filePath, functionName);
if (!context) {
console.error(`Function "${functionName}" not found in ${filePath}`);
process.exit(1);
}
console.log(`Found function at lines ${context.startLine}–${context.endLine}`);
console.log(`${context.callers.length} call site(s) found`);
console.log("Calling Claude...");
const engine = new RefactorEngine();
const response = await engine.refactor({ context, instruction });
console.log(`\nSummary: ${response.summary}`);
console.log(`\nProposed changes:`);
for (const change of response.changes) {
console.log(` ${change.file}:${change.startLine}–${change.endLine}`);
}
if (response.breakingChanges.length > 0) {
console.log("\nBreaking changes:");
response.breakingChanges.forEach((c) => console.log(` ⚠ ${c}`));
}
const validation = validateChanges(response);
if (!validation.valid) {
console.error("\nValidation failed:");
validation.errors.forEach((e) => console.error(` ✗ ${e}`));
process.exit(1);
}
if (validation.warnings.length > 0) {
console.warn("\nWarnings:");
validation.warnings.forEach((w) => console.warn(` ⚠ ${w}`));
}
// Show diff preview
console.log("\nDiff preview:");
for (const change of response.changes) {
console.log(`\n--- ${change.file} (${change.startLine}–${change.endLine})`);
change.originalCode.split("\n").forEach((l) => console.log(`- ${l}`));
change.newCode.split("\n").forEach((l) => console.log(`+ ${l}`));
}
// Prompt for confirmation
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
const answer = await new Promise<string>((resolve) =>
rl.question("\nApply changes? [y/N] ", resolve)
);
rl.close();
if (answer.toLowerCase() !== "y") {
console.log("Aborted. No changes made.");
process.exit(0);
}
const applied = applyChanges(response);
console.log(`\nApplied changes to ${applied.length} file(s). Backups saved as .bak`);
if (response.testSuggestions.length > 0) {
console.log("\nTest suggestions:");
response.testSuggestions.forEach((s) => console.log(` - ${s}`));
}
}
main().catch((err) => {
console.error("Error:", err.message);
process.exit(1);
});
Usage example
# Extract a deeply nested callback into async/await
npx ts-node src/cli.ts src/services/user.ts createUser \
"Convert callback-based implementation to async/await, preserving error handling"
# Add null checks to a function with optional parameters
npx ts-node src/cli.ts src/utils/format.ts formatCurrency \
"Add proper null/undefined guards and return empty string for null/undefined input"
# Split a large function into smaller single-responsibility functions
npx ts-node src/cli.ts src/controllers/order.ts processOrder \
"Extract payment processing and inventory check into separate functions"
Extending the tool
Add TypeScript compilation check: After applying changes, run tsc --noEmit and restore backups if it fails. This catches type errors introduced by the refactor.
import { execSync } from "child_process";
function verifyTypecheck(projectRoot: string): boolean {
try {
execSync("npx tsc --noEmit", { cwd: projectRoot, stdio: "pipe" });
return true;
} catch {
return false;
}
}
Add editor integration: Expose the engine as a Language Server Protocol command or VS Code extension command. The same CodeAnalyzer and RefactorEngine classes work in both contexts.
Add project conventions from CLAUDE.md: Read your project's CLAUDE.md file and include it in the prompt as the projectConventions field. This grounds the model's suggestions in your actual coding standards.
Add support for multi-file refactors: Some refactoring operations touch more than one file — renaming a shared utility, splitting a module, or migrating from one library to another. Extend RefactorRequest with an optional additionalFiles field that lists related source files to include in the prompt. Use the same CodeAnalyzer to extract relevant functions from each and concatenate their context sections.
export interface RefactorRequest {
context: FunctionContext;
instruction: string;
projectConventions?: string;
relatedTypes?: string;
additionalFiles?: Array<{ path: string; relevantFunctions: string[] }>;
}
Collect the additional context before building the prompt:
async function buildMultiFileContext(
analyzer: CodeAnalyzer,
additionalFiles: RefactorRequest["additionalFiles"]
): Promise<string> {
if (!additionalFiles?.length) return "";
const sections: string[] = [];
for (const { path, relevantFunctions } of additionalFiles) {
for (const fnName of relevantFunctions) {
const ctx = analyzer.findFunction(path, fnName);
if (ctx) {
sections.push(`### Related function: ${ctx.name} in ${ctx.sourceFile}\n\`\`\`typescript\n${ctx.code}\n\`\`\``);
}
}
}
return sections.length
? `## Related files and functions\n\n${sections.join("\n\n")}`
: "";
}
Run tests after applying: Integrate a test runner invocation after applyChanges. If the test suite fails, restore the .bak files automatically:
import { execSync, SpawnSyncReturns } from "child_process";
function runTests(projectRoot: string): { passed: boolean; output: string } {
try {
const result = execSync("npx jest --passWithNoTests", {
cwd: projectRoot,
stdio: "pipe",
timeout: 60_000,
});
return { passed: true, output: result.toString() };
} catch (err: unknown) {
const spawnErr = err as SpawnSyncReturns<Buffer>;
return { passed: false, output: spawnErr.stderr?.toString() ?? "" };
}
}
function restoreBackups(files: string[]): void {
for (const file of files) {
const backupPath = `${file}.bak`;
if (fs.existsSync(backupPath)) {
fs.copyFileSync(backupPath, file);
fs.unlinkSync(backupPath);
console.log(`Restored ${file} from backup`);
}
}
}
Call runTests in the CLI after applying changes. If it returns passed: false, call restoreBackups(applied) before exiting. This turns the tool into a safe-by-default loop: propose, validate, apply, test, revert on failure.
The combination of AST-based analysis, targeted context assembly, structured output, pre-apply validation, and post-apply testing produces a tool that is reliable enough to use in a real codebase — not just for toy examples.