Migración de código con IA: Cómo usar LLMs para modernizar codebases legacy sin perder la cordura
Tu CTO acaba de anunciar que van a migrar 200,000 líneas de AngularJS a React. El plazo es seis meses. La mitad del equipo nunca tocó AngularJS. Alguien en la reunión dice "¿No podemos usar IA para esto?" y de repente todos te están mirando.
Esto está pasando en miles de empresas ahora mismo. Codebases legacy — AngularJS, jQuery, Java 8, Python 2, COBOL, PHP viejo — necesitan modernizarse. El approach tradicional (reescritura manual) tarda años y mata la motivación. El approach nuevo (tirárselo a un LLM y rezar) suena genial hasta que te das cuenta de que la IA inventó tres bugs nuevos por cada uno que arregló.
La verdad está en el medio. Los LLMs pueden acelerar masivamente la migración de código, pero solo si construís el pipeline correcto alrededor. Esta guía se trata de armar ese pipeline: qué funciona, qué no, y cómo evitar los errores que convierten un proyecto de migración con IA en un desastre peor que el código legacy.
Por qué la migración de código es ideal para LLMs (y por qué es más difícil de lo que pensás)
La migración de código tiene propiedades que la hacen especialmente apta para LLMs:
- Es puro patrón: La mayoría de las migraciones repiten la misma transformación en cientos de archivos. Los controladores AngularJS siguen un template. Los POJOs de Java siguen un template. Los LLMs son buenos reconociendo y aplicando patrones.
- Input/output definido: Tenés un "antes" (framework viejo) y un "después" (framework nuevo) claros. Las reglas de transformación son conocibles.
- Verificable: A diferencia de escritura creativa o resúmenes, la migración de código tiene un check de corrección duro — ¿compila? ¿pasan los tests?
Pero hay desafíos fundamentales que la mayoría de los approaches "dale con ChatGPT" se pierden:
El problema del context window
Un controlador AngularJS real no existe solo. Importa servicios que importan otros servicios. Referencia templates que referencian directivas. Depende de cadenas de herencia de scope que cruzan múltiples archivos. Un LLM que solo ve el archivo del controlador va a producir código React sintácticamente correcto pero semánticamente incorrecto.
┌─────────────────────────────────────────────────────────────┐
│ El Iceberg de la Migración │
│ │
│ ┌──────────────┐ │
│ │ Controller │ ← El LLM ve esto │
│ │ (1 archivo) │ │
│ ─────────┴──────────────┴───────── │
│ / \ │
│ / ┌──────────┐ ┌──────────┐ \ │
│ / │ Services │ │Templates │ \ │
│ / │ (12 dep) │ │ (3 HTML) │ \ │
│ / └──────────┘ └──────────┘ \ │
│ / ┌──────────┐ ┌──────────┐ ┌──────────┐ \ │
│ / │ Scope │ │ Route │ │ Estado │ \ │
│ / │ Chain │ │ Config │ │ Global │ \ │
│ / └──────────┘ └──────────┘ └──────────┘ \ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ← El LLM necesita TODO esto para migrar bien │
└─────────────────────────────────────────────────────────────┘
Cuando la sintaxis compila pero el comportamiento cambia
Migrar código no es transformar sintaxis. Es traducir paradigmas. AngularJS usa two-way data binding con $scope. React usa flujo de datos unidireccional con hooks. No hay mapeo 1:1. Un LLM que convierte mecánicamente $scope.name a useState('name') va a generar código que compila pero se comporta diferente en edge cases: race conditions en formularios, watchers retrasados, timing del digest cycle. Y ahí es donde se pudre todo.
La regla 80/20 de la migración con IA
En la práctica, los LLMs manejan bien el 80% del trabajo — las transformaciones aburridas y repetitivas. El 20% restante — lógica de negocio compleja, edge cases específicos del framework, concerns transversales — necesita juicio humano. Tu pipeline tiene que estar diseñado alrededor de esta realidad.
La arquitectura: un pipeline de migración con AST
El approach de "copiá y pegá en ChatGPT" no escala. Esto es lo que realmente funciona para migraciones de producción:
┌─────────────────────────────────────────────────────────────┐
│ Pipeline de Migración con IA │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 1. Parse │───▶│ 2. Chunk │───▶│ 3. Enri- │ │
│ │ (AST) │ │ (Split) │ │ quecer │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 6. Test │◀───│ 5. Post- │◀───│ 4. LLM │ │
│ │(Verificar)│ │ Process │ │Transform │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │ │
│ ▼ │
│ ┌──────────┐ │
│ │ 7. Review│ ¿Confianza < 90%? → Flag para review humano │
│ │ Humano │ │
│ └──────────┘ │
└─────────────────────────────────────────────────────────────┘
Vamos paso por paso.
Step 1: Parsear a AST
No le des código fuente crudo al LLM. Parsealo a un Abstract Syntax Tree primero. Esto te da awareness estructural que el texto plano no provee.
import { Project, SyntaxKind } from 'ts-morph'; interface MigrationUnit { filePath: string; type: 'component' | 'service' | 'directive' | 'filter' | 'config'; className: string; dependencies: string[]; templatePath?: string; sourceCode: string; ast: any; complexity: number; } function parseAngularModule(filePath: string): MigrationUnit[] { const project = new Project(); const sourceFile = project.addSourceFileAtPath(filePath); const units: MigrationUnit[] = []; sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression).forEach(call => { const expr = call.getExpression().getText(); if (expr.includes('.component') || expr.includes('.controller')) { const args = call.getArguments(); const name = args[0]?.getText().replace(/['"]/g, ''); const deps = extractDependencies(call); const complexity = calculateComplexity(call); units.push({ filePath, type: expr.includes('.component') ? 'component' : 'service', className: name, dependencies: deps, sourceCode: call.getFullText(), ast: call.getStructure(), complexity, }); } }); return units; } function calculateComplexity(node: any): number { let complexity = 1; node.getDescendantsOfKind(SyntaxKind.IfStatement).forEach(() => complexity++); node.getDescendantsOfKind(SyntaxKind.SwitchStatement).forEach(() => complexity++); node.getDescendantsOfKind(SyntaxKind.ForStatement).forEach(() => complexity++); node.getDescendantsOfKind(SyntaxKind.WhileStatement).forEach(() => complexity++); node.getDescendantsOfKind(SyntaxKind.ConditionalExpression).forEach(() => complexity++); return complexity; }
¿Por qué AST primero?
- Priorizar: Migrar archivos de baja complejidad primero (mayor tasa de éxito del LLM)
- Dividir inteligentemente: Cortar en límites de función/clase, no en líneas arbitrarias
- Rastrear dependencias: Saber qué archivos necesitan migrarse juntos
- Validar output: Comparar la estructura AST de entrada y salida para detectar regresiones
Step 2: Chunking por unidad de migración
No migres archivos completos. Migrá unidades lógicas — un componente, un servicio, una función utilitaria a la vez. Cada llamada al LLM queda enfocada y dentro del context window.
interface MigrationBatch { primary: MigrationUnit; dependencies: MigrationUnit[]; templates: string[]; totalTokens: number; } function createBatches( units: MigrationUnit[], maxTokens: number = 12000 ): MigrationBatch[] { const sorted = units.sort((a, b) => a.complexity - b.complexity); return sorted.map(unit => { const deps = unit.dependencies .map(dep => units.find(u => u.className === dep)) .filter(Boolean) as MigrationUnit[]; const depContext = deps.map(d => extractTypeSignature(d)); const totalTokens = estimateTokens( unit.sourceCode + depContext.join('\n') ); return { primary: unit, dependencies: deps, templates: unit.templatePath ? [readFileSync(unit.templatePath, 'utf-8')] : [], totalTokens, }; }); }
Step 3: Enriquecer con contexto
Acá es donde la mayoría de los intentos de migración con IA fallan. El LLM necesita contexto más allá del archivo que se está migrando:
interface MigrationContext { batch: MigrationBatch; targetFramework: { version: string; stateManagement: string; styling: string; routing: string; }; conventions: { fileNaming: string; exportStyle: string; hookPrefix: string; testFramework: string; }; // Migraciones ya completadas (few-shot learning) examples: { before: string; after: string; explanation: string; }[]; edgeCases: string[]; }
El campo examples es clave. Una vez que migrás manualmente 3-5 componentes representativos, incluílos como ejemplos few-shot en cada llamada al LLM. La calidad del output mejora dramáticamente porque el modelo aprende tus convenciones específicas.
Step 4: Transformación LLM
El prompt importa enormemente. Las decisiones clave:
- Rule 8 es la más importante: "Preservar TODA la lógica de negocio exactamente." Los LLMs quieren "mejorar" el código durante la migración. No querés eso. Migración y refactoring son PRs separados.
- Few-shot examples: Incluí 2-3 migraciones reales de tu proyecto. Esto vale más que cualquier cantidad de texto de instrucción.
- Contexto de dependencias: Incluí type signatures de las dependencias para que el LLM sepa qué hay disponible sin gastar tokens en detalles de implementación.
Step 5: Post-procesamiento
No confíes en el output crudo del LLM. Post-procesalo:
async function postProcess( llmOutput: string, context: MigrationContext ): Promise<{ code: string; confidence: number; issues: string[]; }> { const issues: string[] = []; let confidence = 100; try { const project = new Project({ useInMemoryFileSystem: true }); const sourceFile = project.createSourceFile('output.tsx', llmOutput); const diagnostics = sourceFile.getPreEmitDiagnostics(); if (diagnostics.length > 0) { confidence -= diagnostics.length * 10; issues.push( ...diagnostics.map(d => `TS Error: ${d.getMessageText()}`) ); } const imports = sourceFile.getImportDeclarations(); for (const imp of imports) { const moduleSpecifier = imp.getModuleSpecifierValue(); if (!isValidImport(moduleSpecifier, context)) { confidence -= 15; issues.push(`Import no resuelto: ${moduleSpecifier}`); } } const text = sourceFile.getFullText(); if (text.includes('// TODO') || text.includes('// FIXME')) { confidence -= 5; issues.push('El LLM agregó comentarios TODO/FIXME'); } const hookViolations = checkHookRules(sourceFile); if (hookViolations.length > 0) { confidence -= hookViolations.length * 20; issues.push(...hookViolations); } const originalFunctions = extractFunctionNames( context.batch.primary.sourceCode ); const migratedFunctions = extractFunctionNames(llmOutput); const missing = originalFunctions.filter( f => !migratedFunctions.some(m => isSimilarName(f, m)) ); if (missing.length > 0) { confidence -= missing.length * 15; issues.push(`Funciones faltantes: ${missing.join(', ')}`); } return { code: llmOutput, confidence, issues }; } catch (e) { return { code: llmOutput, confidence: 0, issues: [`Error de parseo: ${e.message}`], }; } }
Step 6: Testing automatizado
El paso más crítico. Ninguna migración debería comitearse sin verificación automatizada:
async function verifyMigration( originalPath: string, migratedPath: string, testSuite: string ): Promise<MigrationVerification> { const results: MigrationVerification = { compiles: false, testsPass: false, renderMatches: false, accessibilityPass: false, performanceRegression: false, }; const compileResult = await exec(`npx tsc --noEmit ${migratedPath}`); results.compiles = compileResult.exitCode === 0; if (testSuite) { const testResult = await exec(`npx vitest run ${testSuite}`); results.testsPass = testResult.exitCode === 0; } results.renderMatches = await compareScreenshots( originalPath, migratedPath ); return results; }
Step 7: Review humano con scoring de confianza
No todos los archivos migrados necesitan el mismo nivel de atención humana:
function triageMigration( result: PostProcessResult, verification: MigrationVerification ): 'auto-merge' | 'quick-review' | 'deep-review' | 'manual-rewrite' { if (result.confidence >= 95 && verification.testsPass && verification.compiles) { return 'auto-merge'; } if (result.confidence >= 80 && verification.compiles) { return 'quick-review'; } if (result.confidence >= 50) { return 'deep-review'; } return 'manual-rewrite'; }
En la práctica, para una migración AngularJS→React bien preparada:
- ~60% archivos: Auto-merge o review rápido
- ~25% archivos: Review profundo (mayormente state management complejo)
- ~15% archivos: Reescritura manual (herencia de $scope compleja, hacks del digest cycle)
Patrones de migración en el mundo real
Patrón 1: AngularJS → React
La migración enterprise más común hoy.
// ❌ ANTES: Controlador AngularJS angular.module('app').controller('UserListCtrl', ['$scope', '$http', 'UserService', 'NotificationService', function($scope, $http, UserService, NotificationService) { $scope.users = []; $scope.loading = true; $scope.searchTerm = ''; $scope.selectedRole = 'all'; $scope.loadUsers = function() { $scope.loading = true; UserService.getAll({ role: $scope.selectedRole }) .then(function(users) { $scope.users = users; $scope.loading = false; }) .catch(function(err) { NotificationService.error('Failed to load users'); $scope.loading = false; }); }; $scope.filteredUsers = function() { if (!$scope.searchTerm) return $scope.users; return $scope.users.filter(function(user) { return user.name.toLowerCase() .includes($scope.searchTerm.toLowerCase()); }); }; $scope.$watch('selectedRole', function(newVal, oldVal) { if (newVal !== oldVal) $scope.loadUsers(); }); $scope.loadUsers(); } ]);
// ✅ DESPUÉS: React + TypeScript import { useState, useEffect, useMemo, useCallback } from 'react'; import { useUserService } from '@/hooks/useUserService'; import { useNotification } from '@/hooks/useNotification'; interface User { id: string; name: string; email: string; role: string; } type RoleFilter = 'all' | 'admin' | 'user' | 'moderator'; export function UserList() { const [users, setUsers] = useState<User[]>([]); const [loading, setLoading] = useState(true); const [searchTerm, setSearchTerm] = useState(''); const [selectedRole, setSelectedRole] = useState<RoleFilter>('all'); const userService = useUserService(); const { showError } = useNotification(); const loadUsers = useCallback(async () => { setLoading(true); try { const data = await userService.getAll({ role: selectedRole }); setUsers(data); } catch { showError('Failed to load users'); } finally { setLoading(false); } }, [selectedRole, userService, showError]); useEffect(() => { loadUsers(); }, [loadUsers]); const filteredUsers = useMemo(() => { if (!searchTerm) return users; return users.filter(user => user.name.toLowerCase().includes(searchTerm.toLowerCase()) ); }, [users, searchTerm]); if (loading) return <LoadingSpinner />; return ( <div> <SearchInput value={searchTerm} onChange={setSearchTerm} /> <RoleFilter value={selectedRole} onChange={setSelectedRole} /> <UserTable users={filteredUsers} /> </div> ); }
Lo que el LLM hace bien: Mapeo de estado, conversión básica de hooks, dependencias de effects.
Lo que el LLM hace mal: Arrays de dependencias de useCallback (deps faltantes), límites de optimización de useMemo, patrones de error handling específicos de tu sistema de notificaciones.
Patrón 2: Java 8 → Kotlin
// ❌ ANTES: Java 8 public class OrderProcessor { private final OrderRepository orderRepo; private final PaymentService paymentService; private final NotificationService notificationService; public OrderProcessor(OrderRepository orderRepo, PaymentService paymentService, NotificationService notificationService) { this.orderRepo = orderRepo; this.paymentService = paymentService; this.notificationService = notificationService; } public OrderResult processOrder(OrderRequest request) { if (request == null || request.getItems() == null || request.getItems().isEmpty()) { throw new IllegalArgumentException("Invalid order"); } BigDecimal total = request.getItems().stream() .map(item -> item.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); if (total.compareTo(BigDecimal.valueOf(10000)) > 0) { request.setDiscount(total.multiply( BigDecimal.valueOf(0.1))); } PaymentResult payment = paymentService.charge( request.getCustomerId(), total); if (!payment.isSuccessful()) { return OrderResult.failed(payment.getErrorMessage()); } Order order = orderRepo.save( Order.from(request, payment.getTransactionId())); notificationService.sendConfirmation( request.getCustomerId(), order); return OrderResult.success(order); } }
// ✅ DESPUÉS: Kotlin class OrderProcessor( private val orderRepo: OrderRepository, private val paymentService: PaymentService, private val notificationService: NotificationService, ) { fun processOrder(request: OrderRequest): OrderResult { require(request.items.isNotEmpty()) { "Invalid order" } val total = request.items.sumOf { item -> item.price * item.quantity.toBigDecimal() } if (total > 10_000.toBigDecimal()) { request.discount = total * 0.1.toBigDecimal() } val payment = paymentService.charge(request.customerId, total) if (!payment.isSuccessful) { return OrderResult.failed(payment.errorMessage) } val order = orderRepo.save( Order.from(request, payment.transactionId) ) notificationService.sendConfirmation(request.customerId, order) return OrderResult.success(order) } }
Lo que el LLM hace bien: Null safety, require en vez de checks explícitos de null, sintaxis de acceso a propiedades, trailing commas, simplificación de expresiones.
Lo que el LLM hace mal: Operator overloading custom para BigDecimal, extensiones de colección específicas de Kotlin (sumOf), manejo de errores idiomático con Result o sealed classes.
Patrón 3: Python 2 → Python 3
# ❌ ANTES: Python 2 class DataProcessor(object): def __init__(self, config): self.config = config self.logger = logging.getLogger(__name__) def process_batch(self, items): results = [] for item in items: try: processed = self._transform(item) results.append(processed) except Exception, e: self.logger.error( u"Failed to process item %s: %s" % (item.get('id', 'unknown'), unicode(e)) ) return results def _transform(self, item): if isinstance(item, basestring): item = {'value': item} keys = item.keys() keys.sort() output = {} for key in keys: value = item[key] if isinstance(value, unicode): output[key] = value.encode('utf-8') elif isinstance(value, (int, long)): output[key] = float(value) else: output[key] = value return output
# ✅ DESPUÉS: Python 3 class DataProcessor: def __init__(self, config): self.config = config self.logger = logging.getLogger(__name__) def process_batch(self, items): results = [] for item in items: try: processed = self._transform(item) results.append(processed) except Exception as e: self.logger.error( f"Failed to process item {item.get('id', 'unknown')}: {e}" ) return results def _transform(self, item): if isinstance(item, str): item = {'value': item} output = {} for key in sorted(item.keys()): value = item[key] if isinstance(value, str): output[key] = value elif isinstance(value, int): output[key] = float(value) else: output[key] = value return output
Lo que el LLM hace bien: Sintaxis except Exception as e, f-strings, eliminar unicode/basestring/long, eliminar herencia de (object).
Lo que el LLM hace mal: Cambios sutiles de comportamiento — dict.keys() de Python 2 devuelve una lista (mutable), Python 3 devuelve una vista. El LLM envuelve correctamente con sorted(), pero pierde casos donde el código muta la lista de keys durante iteración.
El playbook de prompt engineering para migraciones
Después de miles de transformaciones, estos patrones producen consistentemente los mejores resultados:
1. System message: la persona de migración
You are a staff-level software engineer performing a code migration.
Your output will be committed directly to a production repository.
CRITICAL RULES:
- Preserve ALL business logic exactly as-is. Do NOT refactor, optimize,
or "improve" the code during migration.
- If you are unsure about a conversion, mark it with
__MIGRATION_REVIEW__ in a comment.
- Do NOT add explanatory comments about the migration itself.
- Do NOT change variable names unless required by target language
conventions.
- Output ONLY the migrated code. No markdown fences, no explanations.
2. Few-shot examples le ganan a instrucciones largas
En vez de escribir 50 reglas sobre cómo convertir AngularJS a React, incluí 3 ejemplos reales. El modelo aprende patrones mejor de ejemplos concretos que de reglas abstractas.
3. El principio "Preservar, no mejorar"
La regla más importante. Los LLMs van a intentar:
- Agregar error handling que no existía
- Optimizar algoritmos
- Renombrar variables para que sean "más claras"
- Agregar tipos TypeScript demasiado estrictos o relajados
Cada uno de estos cambios introduce riesgo. Migración y mejora deberían ser PRs separados.
4. Marcadores de confianza
Decile al LLM que marque incertidumbre:
If any conversion is ambiguous (e.g., unclear scope inheritance,
non-obvious side effects), add this exact comment:
// __MIGRATION_REVIEW__: [reason for uncertainty]
This will be caught by our post-processing pipeline and flagged
for human review.
Esto es infinitamente más útil que cruzar los dedos para que el LLM acierte.
Guardrails de producción: lo que puede salir mal
1. El import alucinado
El LLM inventa imports para paquetes que no existen. Vio import { useQueryClient } from '@tanstack/react-query' en sus datos de entrenamiento, así que lo usa — aunque tu proyecto use SWR.
Fix: Post-procesá todos los imports contra tu package.json real y la estructura de archivos del proyecto.
2. El cambio de comportamiento silencioso
El bug más peligroso. El código compila, los tests pasan (porque los tests son superficiales), pero el comportamiento cambió sutilmente. Ejemplos comunes:
- Timing de event handlers (digest cycle de Angular vs state batching de React)
- Diferencias en manejo de null/undefined
- Orden de operaciones async
Fix: Invertí en tests de integración antes de arrancar la migración. Escribílos contra el codebase viejo, después verificá que pasen contra el código nuevo.
3. El componente sobre-engineerizado
El LLM convierte un controller AngularJS simple en un componente React con 4 custom hooks, 3 context providers y un reducer. Técnicamente correcto pero imposible de mantener.
Fix: Agregá al prompt: "Preferir simplicidad. Usar useState a menos que la lógica de estado sea lo suficientemente compleja para requerir useReducer. No crear custom hooks para lógica que solo se usa en un componente."
4. La explosión de copy-paste
Al migrar componentes similares, el LLM produce código casi idéntico sin extraer lógica compartida. Terminás con 20 componentes que cada uno tiene su propia copia del mismo patrón de data fetching.
Fix: Después del primer pase de migración, corré un segundo pase enfocado en extraer patrones compartidos en hooks y utilities. Esto es mejor como paso separado porque el LLM tiene acceso a todos los archivos migrados a la vez.
Midiendo el éxito de la migración
| Métrica | Objetivo | Cómo medir |
|---|---|---|
| Tasa de auto-merge | > 50% | Archivos que pasan todas las verificaciones |
| Éxito de compilación | > 90% | Primera compilación TypeScript |
| Tasa de tests | > 85% | Test suite existente vs código migrado |
| Preservación de lógica | 100% | Suite de tests de integración |
| Líneas migradas / día | 2,000+ | Después de tunear el pipeline |
| Tiempo de review / archivo | < 15 min | Promedio del tier "quick-review" |
El dashboard de migración
Armá un dashboard simple que trackee progreso por módulo:
interface MigrationStatus { module: string; totalFiles: number; migrated: number; autoMerged: number; inReview: number; manualRewrite: number; avgConfidence: number; blockers: string[]; }
Esta visibilidad es crucial para la comunicación con stakeholders. "Migramos 147 de 200 archivos con 92% de tasa de auto-merge" es mucho más convincente que "va bien".
Qué LLM usar
A abril 2026, basado en testing extensivo de migraciones:
| Modelo | Mejor para | Limitaciones |
|---|---|---|
| Claude 4 Sonnet | Preservación de lógica compleja, precisión TypeScript, retención fiel de lógica de negocio | Context window de 200K puede limitar en batches multi-archivo grandes |
| GPT-5 | Soporte amplio de lenguajes, formato consistente, fuerte en seguir instrucciones | Tendencia a sobre-refactorizar; mayor costo por token |
| Gemini 2.5 Pro | Contexto largo (1M tokens), comprensión multi-archivo, costo-efectivo a escala | A veces inventa APIs inexistentes |
| DeepSeek V3 | Costo-efectivo para transformaciones simples, fuerte en patrones Python/Java | Menor precisión en lógica de negocio compleja y dependencias cross-file |
Recomendación: Usá Claude 4 Sonnet o GPT-5 para la migración inicial (preservación de lógica compleja), después Gemini 2.5 Pro o DeepSeek para pases de limpieza en archivos simples. La diferencia de costo es 10-50x, y los archivos simples no necesitan el modelo caro.
La verdad incómoda: lo que la IA no va a poder migrar
- Decisiones de arquitectura: ¿La app monolítica de AngularJS debería ser un micro-frontend? La IA no te lo va a decir.
- Diseño de state management: ¿Zustand, Redux o Context? El LLM usa lo que le digas, pero no toma la decisión arquitectónica.
- Optimización de performance: El LLM no conoce tus patrones de tráfico ni tus bottlenecks.
- Validación de reglas de negocio: Si el código original tiene un bug que funciona "como se espera" (compensado en otro lado), el LLM lo va a reproducir fielmente.
- Concerns transversales: Logging, monitoreo, error tracking, feature flags — necesitan diseño humano en la nueva arquitectura.
Timeline: un plan de migración realista
Para una migración de 200K LOC de AngularJS a React:
| Fase | Duración | Qué pasa |
|---|---|---|
| 1. Setup | 2 semanas | Armar pipeline, configurar LLM, crear 5-10 migraciones manuales de ejemplo |
| 2. Piloto | 2 semanas | Migrar 1 módulo (20-30 archivos) end-to-end, tunear prompts |
| 3. Escalar | 8-10 semanas | El pipeline procesa los módulos restantes en orden de dependencia |
| 4. Pulir | 4 semanas | Arreglar edge cases, extraer patrones compartidos, tunear performance |
| 5. Validar | 2 semanas | Testing de regresión completo, sign-off de stakeholders |
Total: ~4-5 meses vs los 12-18 meses tradicionales para migración manual.
Lo clave: las fases 1 y 2 son las más importantes. Si tu pipeline puede migrar el módulo piloto con >80% de tasa de auto-merge, el resto es ejecución. Si el piloto falla, tenés que repensar el approach antes de escalar.
Checklist de migración
Preparación
- Test suite existente con >70% de cobertura en paths críticos
- Convenciones del framework target documentadas
- 5-10 migraciones de referencia hechas a mano
- Parser AST configurado para el lenguaje fuente
- Pipeline CI incluyendo verificación de compilación y tests
Pipeline
- Prompt template testeado en 20+ archivos representativos
- Post-procesamiento atrapa imports inválidos
- Scoring de confianza calibrado (threshold de auto-merge definido)
- Ejemplos few-shot incluidos para cada tipo de archivo
- Orden de resolución de dependencias calculado
Ejecución
- Migrando en orden de dependencias (nodos hoja primero)
- Cada batch verificado antes de pasar al siguiente
- Dashboard trackeando progreso y scores de confianza
- Reviewers humanos asignados por módulo
- Estrategia de rollback definida por módulo
Validación
- Tests de integración pasan contra código migrado
- Tests de regresión visual completados
- Benchmarks de performance igualan o mejoran
- Review de seguridad en paths de auth/datos
- Sign-off del equipo de producto
La migración de código con IA no es magia. Es ingeniería — armar un pipeline que aproveche los LLMs para lo que son buenos (transformación de patrones) mientras los rodeás de guardrails para lo que son malos (garantías de corrección). Armá bien el pipeline y podés convertir una migración de un año en un trimestre. Armalo mal y vas a pasar ese trimestre debuggeando por qué la IA decidió reescribir tu lógica de auth. A construir.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit