PRININIT / src /autofix /patch-applicator.ts
Rachit-Tw's picture
Upload 50 files
7d9309d verified
Raw
History Blame Contribute Delete
13.8 kB
/**
* 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<ValidationResult> {
// 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}`)
}
}