/** * Safe Patch Applicator * * Applies LLM-generated code patches SAFELY. * Uses function-level replacement, NOT regex hacks. */ import * as fs from 'fs' import * as path from 'path' import {execSync} from 'child_process' export interface PatchResult { success: boolean error?: string originalCode: string patchedCode: string backupPath?: string } export interface PatchRequest { filename: string // For function-level replacement functionName?: string functionStartLine?: number functionEndLine?: number // For line-range replacement (fallback) lineStart?: number lineEnd?: number // The new code to insert newCode: string language: string } /** * Apply a patch safely using the most appropriate strategy: * 1. Function-level AST-aware replacement (preferred) * 2. Line-range replacement with verification (fallback) */ export function applyPatch( workingDir: string, request: PatchRequest ): PatchResult { const absPath = path.resolve(workingDir, request.filename) if (!fs.existsSync(absPath)) { return { success: false, error: `File not found: ${absPath}`, originalCode: '', patchedCode: '' } } try { const originalCode = fs.readFileSync(absPath, 'utf8') const lines = originalCode.split('\n') // Strategy 1: Function-level replacement (preferred) if ( request.functionName && request.functionStartLine && request.functionEndLine ) { const result = applyFunctionLevelPatch(lines, request) if (result.success) { // Write backup first const backupPath = `${absPath}.backup.${Date.now()}` fs.writeFileSync(backupPath, originalCode) // Write patched code fs.writeFileSync(absPath, result.patchedCode) return { ...result, originalCode, backupPath } } // Fall through to line-range if function-level fails } // Strategy 2: Line-range replacement if (request.lineStart && request.lineEnd) { const result = applyLineRangePatch(lines, request) if (result.success) { const backupPath = `${absPath}.backup.${Date.now()}` fs.writeFileSync(backupPath, originalCode) fs.writeFileSync(absPath, result.patchedCode) return { ...result, originalCode, backupPath } } return result } return { success: false, error: 'No valid patch strategy available', originalCode, patchedCode: originalCode } } catch (err: any) { return { success: false, error: `Patch application failed: ${err.message}`, originalCode: '', patchedCode: '' } } } /** * Apply function-level replacement * Replaces the entire function with the new implementation */ function applyFunctionLevelPatch( lines: string[], request: PatchRequest ): PatchResult { const originalCode = lines.join('\n') if (!request.functionStartLine || !request.functionEndLine) { return { success: false, error: 'Function boundaries not specified', originalCode, patchedCode: originalCode } } // Convert to 0-indexed const startIdx = request.functionStartLine - 1 const endIdx = request.functionEndLine if (startIdx < 0 || endIdx > lines.length || startIdx >= endIdx) { return { success: false, error: `Invalid function boundaries: ${request.functionStartLine}-${request.functionEndLine}`, originalCode, patchedCode: originalCode } } // Verify the function exists at expected location (heuristic check) const originalFunction = lines.slice(startIdx, endIdx).join('\n') const functionName = request.functionName || 'unknown' // Simple heuristic: check if function name appears in the first line const firstLine = lines[startIdx].toLowerCase() const expectedName = functionName.toLowerCase() // Check for function patterns const hasFunctionPattern = firstLine.includes('function') || firstLine.includes('=>') || firstLine.includes('def ') || // Python firstLine.includes(`${expectedName}`) if (!hasFunctionPattern) { return { success: false, error: `Function "${functionName}" not found at expected location. Heuristic check failed.`, originalCode, patchedCode: originalCode } } // Normalize new code indentation const newCode = normalizeIndentation(request.newCode, lines[startIdx]) // Build the patched file const beforeFunction = lines.slice(0, startIdx) const afterFunction = lines.slice(endIdx) const patchedLines = [...beforeFunction, newCode, ...afterFunction] const patchedCode = patchedLines.join('\n') // Safety check: basic syntax validation const syntaxError = quickSyntaxCheck(patchedCode, request.language) if (syntaxError) { return { success: false, error: `Syntax check failed: ${syntaxError}`, originalCode, patchedCode: originalCode } } return { success: true, originalCode, patchedCode } } /** * Apply line-range replacement (fallback) * Replaces specific lines with new code */ function applyLineRangePatch( lines: string[], request: PatchRequest ): PatchResult { const originalCode = lines.join('\n') if (!request.lineStart || !request.lineEnd) { return { success: false, error: 'Line range not specified', originalCode, patchedCode: originalCode } } // Convert to 0-indexed const startIdx = request.lineStart - 1 const endIdx = request.lineEnd if (startIdx < 0 || endIdx > lines.length || startIdx >= endIdx) { return { success: false, error: `Invalid line range: ${request.lineStart}-${request.lineEnd}`, originalCode, patchedCode: originalCode } } // Get base indentation from the first line being replaced const baseIndent = getIndentation(lines[startIdx]) // Normalize and apply new code indentation const newCode = normalizeIndentation(request.newCode, lines[startIdx]) const newCodeLines = newCode.split('\n') // Build the patched file const beforeRange = lines.slice(0, startIdx) const afterRange = lines.slice(endIdx) const patchedLines = [...beforeRange, ...newCodeLines, ...afterRange] const patchedCode = patchedLines.join('\n') // Safety check const syntaxError = quickSyntaxCheck(patchedCode, request.language) if (syntaxError) { return { success: false, error: `Syntax check failed: ${syntaxError}`, originalCode, patchedCode: originalCode } } return { success: true, originalCode, patchedCode } } /** * Get indentation of a line */ function getIndentation(line: string): string { const match = line.match(/^(\s*)/) return match ? match[1] : '' } /** * Normalize indentation of new code to match original context */ function normalizeIndentation(newCode: string, referenceLine: string): string { const baseIndent = getIndentation(referenceLine) const lines = newCode.split('\n') // Find minimum indentation in new code let minIndent = Infinity for (const line of lines) { if (line.trim()) { const indent = getIndentation(line).length minIndent = Math.min(minIndent, indent) } } if (minIndent === Infinity) return newCode // Remove min indent from all lines and add base indent const normalized = lines.map(line => { if (!line.trim()) return '' const currentIndent = getIndentation(line).length const relativeIndent = currentIndent - minIndent return baseIndent + ' '.repeat(relativeIndent) + line.trimStart() }) return normalized.join('\n') } /** * Semantic validation after patching * Runs tsc --noEmit, eslint, or npm test if available */ export interface ValidationResult { passed: boolean command: string stdout: string stderr: string exitCode: number } export async function validatePatchSemantically( workingDir: string, filename: string, language: string ): Promise { // Try validation commands in order of preference const validationCommands: Array<{cmd: string; label: string}> = [] if (language === 'typescript') { // Check for TypeScript compiler validationCommands.push( {cmd: `npx tsc --noEmit ${filename}`, label: 'TypeScript compiler'}, {cmd: 'npx tsc --noEmit', label: 'Project-wide TypeScript check'} ) } // Check for ESLint validationCommands.push( {cmd: `npx eslint ${filename} --max-warnings=0`, label: 'ESLint'}, {cmd: 'npx eslint . --max-warnings=0', label: 'Project-wide ESLint'} ) // Check for npm test (if tests exist) validationCommands.push({ cmd: 'npm test -- --passWithNoTests --testPathPattern=' + filename, label: 'npm test' }) for (const {cmd, label} of validationCommands) { try { console.log(`[VALIDATION] Running ${label}...`) const stdout = execSync(cmd, { cwd: workingDir, encoding: 'utf8', timeout: 60000, stdio: ['pipe', 'pipe', 'pipe'] }) console.log(`[VALIDATION] ✓ ${label} passed`) return { passed: true, command: cmd, stdout, stderr: '', exitCode: 0 } } catch (err: any) { // Command failed - this might be expected for missing tools if ( err.status === 127 || err.message?.includes('not found') || err.message?.includes('ENOENT') ) { console.log(`[VALIDATION] ${label} not available, skipping...`) continue // Try next command } // Validation actually failed console.log(`[VALIDATION] ✗ ${label} failed (exit ${err.status})`) return { passed: false, command: cmd, stdout: err.stdout || '', stderr: err.stderr || '', exitCode: err.status || 1 } } } // No validation tools available console.log(`[VALIDATION] ⚠ No semantic validation tools available`) return { passed: true, // Allow patch if we can't validate command: 'none', stdout: '', stderr: 'No validation tools available', exitCode: 0 } } /** * Quick syntax validation * Not comprehensive, catches obvious issues */ function quickSyntaxCheck(code: string, language: string): string | null { // Basic brace/parenthesis balance check let braceCount = 0 let parenCount = 0 let bracketCount = 0 let inString = false let stringChar = '' for (let i = 0; i < code.length; i++) { const char = code[i] const prevChar = i > 0 ? code[i - 1] : '' // Handle strings if (!inString && (char === '"' || char === "'" || char === '`')) { inString = true stringChar = char continue } if (inString && char === stringChar && prevChar !== '\\') { inString = false continue } if (inString) continue // Count braces if (char === '{') braceCount++ else if (char === '}') braceCount-- else if (char === '(') parenCount++ else if (char === ')') parenCount-- else if (char === '[') bracketCount++ else if (char === ']') bracketCount-- // Early termination on severe imbalance if (braceCount < -2 || parenCount < -2 || bracketCount < -2) { return `Unbalanced ${braceCount < 0 ? 'braces' : parenCount < 0 ? 'parentheses' : 'brackets'}` } } if (braceCount !== 0) return `Unbalanced braces: ${braceCount > 0 ? 'missing' : 'extra'} ${Math.abs(braceCount)} }` if (parenCount !== 0) return `Unbalanced parentheses: ${parenCount > 0 ? 'missing' : 'extra'} ${Math.abs(parenCount)} )` if (bracketCount !== 0) return `Unbalanced brackets: ${bracketCount > 0 ? 'missing' : 'extra'} ${Math.abs(bracketCount)} ]` // Language-specific checks if (language === 'typescript' || language === 'javascript') { // Check for common syntax issues if (/\bfunction\s*\([^)]*\)\s*[^=>{]/.test(code)) { return 'Function without body' } } return null } /** * Rollback a patch using backup file */ export function rollbackPatch( backupPath: string, originalPath: string ): boolean { try { if (!fs.existsSync(backupPath)) { console.error(`[PATCH] Backup not found: ${backupPath}`) return false } fs.copyFileSync(backupPath, originalPath) console.log(`[PATCH] Rolled back ${originalPath} from ${backupPath}`) return true } catch (err: any) { console.error(`[PATCH] Rollback failed: ${err.message}`) return false } } /** * Clean up backup files older than specified age */ export function cleanupOldBackups( workingDir: string, maxAgeMs: number = 24 * 60 * 60 * 1000 ): void { try { const files = fs.readdirSync(workingDir) const now = Date.now() for (const file of files) { if (file.includes('.backup.')) { const backupPath = path.join(workingDir, file) const stats = fs.statSync(backupPath) const age = now - stats.mtimeMs if (age > maxAgeMs) { fs.unlinkSync(backupPath) console.log(`[PATCH] Cleaned up old backup: ${file}`) } } } } catch (err: any) { console.error(`[PATCH] Cleanup failed: ${err.message}`) } }