| |
| |
| |
| |
| |
|
|
|
|
| import {ApiCheckout} from '../api-checkout'
|
| import {Project, DiagnosticCategory} from 'ts-morph'
|
|
|
| function getIndentation(line: string): string {
|
| const match = line.match(/^(\s*)/)
|
| return match ? match[1] : ''
|
| }
|
|
|
| function normalizeIndentation(newCode: string, referenceLine: string): string {
|
| const baseIndent = getIndentation(referenceLine)
|
| const lines = newCode.split('\n')
|
|
|
| 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
|
|
|
| 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')
|
| }
|
|
|
| export interface PatchRequest {
|
| filename: string
|
| functionName?: string
|
| functionStartLine?: number
|
| functionEndLine?: number
|
| lineStart?: number
|
| lineEnd?: number
|
| newCode: string
|
| language: string
|
| dryRun?: boolean
|
| }
|
|
|
| export interface PatchResult {
|
| success: boolean
|
| error?: string
|
| originalContent?: string
|
| modifiedContent?: string
|
| commitSha?: string
|
| appliedStartLine?: number
|
| appliedEndLine?: number
|
|
|
| originalCode?: string
|
| patchedCode?: string
|
| }
|
|
|
| |
| |
|
|
| export const applyPatchAPI = async (
|
| apiCheckout: ApiCheckout,
|
| request: PatchRequest
|
| ): Promise<PatchResult> => {
|
| try {
|
| console.log(`[PATCH] Applying to ${request.filename}`)
|
|
|
|
|
| const currentFile = await apiCheckout.getFile(request.filename)
|
| const originalLines = currentFile.content.split('\n')
|
|
|
|
|
| let modifiedContent: string
|
|
|
| if (request.functionStartLine && request.functionEndLine) {
|
|
|
| const startIdx = Math.max(0, request.functionStartLine - 1)
|
| const endIdx = Math.min(originalLines.length, request.functionEndLine)
|
|
|
| const referenceLine = originalLines[startIdx]
|
| const normalizedCode = normalizeIndentation(request.newCode, referenceLine)
|
|
|
| const before = originalLines.slice(0, startIdx)
|
| const after = originalLines.slice(endIdx)
|
|
|
| modifiedContent = [
|
| ...before,
|
| ...normalizedCode.split('\n'),
|
| ...after
|
| ].join('\n')
|
| } else if (request.lineStart && request.lineEnd) {
|
|
|
| const startIdx = Math.max(0, request.lineStart - 1)
|
| const endIdx = Math.min(originalLines.length, request.lineEnd)
|
|
|
|
|
| const originalRangeLen = endIdx - startIdx
|
| const newCodeLines = request.newCode.split('\n')
|
| let patchedNewCode = request.newCode
|
| if (originalRangeLen > 0 && newCodeLines.length > originalRangeLen * 3) {
|
| console.warn(
|
| `[PATCH] LLM response (${newCodeLines.length} lines) is ${Math.round(newCodeLines.length / originalRangeLen)}x larger than target range (${originalRangeLen} lines). Attempting to extract only changed lines...`
|
| )
|
| const originalRange = originalLines.slice(startIdx, endIdx)
|
| const extracted = extractChangedLines(originalRange, newCodeLines)
|
| if (extracted && extracted.length > 0 && extracted.length <= originalRangeLen * 2) {
|
| console.log(`[PATCH] Extracted ${extracted.length} changed lines from LLM response`)
|
| patchedNewCode = extracted.join('\n')
|
| } else {
|
| console.warn(`[PATCH] Could not extract changed lines. Proceeding with full LLM response (risks duplication).`)
|
| }
|
| }
|
|
|
| const referenceLine = originalLines[startIdx]
|
| const normalizedCode = normalizeIndentation(patchedNewCode, referenceLine)
|
|
|
| const before = originalLines.slice(0, startIdx)
|
| const after = originalLines.slice(endIdx)
|
|
|
| modifiedContent = [
|
| ...before,
|
| ...normalizedCode.split('\n'),
|
| ...after
|
| ].join('\n')
|
| } else {
|
| throw new Error(
|
| 'Invalid patch request: must specify either function or line range'
|
| )
|
| }
|
|
|
|
|
| const project = new Project({useInMemoryFileSystem: true})
|
| const ext = request.filename.endsWith('.tsx') ? '.tsx' : '.ts'
|
| const sourceFile = project.createSourceFile(`temp${ext}`, modifiedContent)
|
|
|
|
|
| if (request.language === 'python') {
|
| const indentationErrors = validatePythonIndentation(modifiedContent)
|
| if (indentationErrors.length > 0) {
|
| console.warn(
|
| `[PATCH] ⚠️ Python indentation errors detected (${indentationErrors.length}):`
|
| )
|
| for (const err of indentationErrors.slice(0, 5)) {
|
| console.warn(` - line ${err.line}: ${err.message}`)
|
| }
|
|
|
| }
|
| }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| const hasSyntaxErrors = false
|
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
|
|
| let commitSha: string | undefined = undefined
|
| if (!request.dryRun) {
|
| const commitMessage = `Fix: ${request.functionName || 'code issue'} in ${request.filename}`
|
| commitSha = await apiCheckout.updateFile(
|
| request.filename,
|
| modifiedContent,
|
| commitMessage
|
| )
|
| console.log(`[PATCH] ✓ Successfully applied patch to ${request.filename}`)
|
| } else {
|
| console.log(
|
| `[PATCH] ✓ Dry run complete for ${request.filename} (skipped commit)`
|
| )
|
| }
|
|
|
| return {
|
| success: true,
|
| originalContent: currentFile.content,
|
| modifiedContent,
|
| commitSha,
|
| appliedStartLine: request.functionStartLine || request.lineStart,
|
| appliedEndLine: request.functionEndLine || request.lineEnd,
|
|
|
| originalCode: currentFile.content,
|
| patchedCode: modifiedContent
|
| }
|
| } catch (error) {
|
| return {
|
| success: false,
|
| error: `Patch application failed: ${error}`
|
| }
|
| }
|
| }
|
|
|
| |
| |
|
|
| export async function rollbackPatchAPI(
|
| apiCheckout: ApiCheckout,
|
| filename: string,
|
| originalContent: string,
|
| commitSha: string
|
| ): Promise<PatchResult> {
|
| try {
|
| console.log(`[ROLLBACK] Rolling back ${filename}`)
|
|
|
|
|
| const commitMessage = `Rollback: Restore ${filename} to previous state`
|
| const newCommitSha = await apiCheckout.updateFile(
|
| filename,
|
| originalContent,
|
| commitMessage
|
| )
|
|
|
| console.log(`[ROLLBACK] ✓ Successfully rolled back ${filename}`)
|
|
|
| return {
|
| success: true,
|
| commitSha: newCommitSha
|
| }
|
| } catch (error) {
|
| return {
|
| success: false,
|
| error: `Rollback failed: ${error}`
|
| }
|
| }
|
| }
|
|
|
| |
| |
| |
|
|
| function extractChangedLines(originalRange: string[], llmOutput: string[]): string[] | null {
|
|
|
| const originalStr = originalRange.join('\n').trim()
|
|
|
|
|
| const fullOutput = llmOutput.join('\n')
|
|
|
|
|
|
|
| let diffStart = -1
|
| for (let i = 0; i < Math.min(originalRange.length, llmOutput.length); i++) {
|
| if (originalRange[i].trim() !== llmOutput[i].trim()) {
|
| diffStart = i
|
| break
|
| }
|
| }
|
|
|
| if (diffStart === -1) {
|
|
|
| return null
|
| }
|
|
|
|
|
| let diffEnd = diffStart + 1
|
| for (let i = diffStart + 1; i < llmOutput.length; i++) {
|
| const origIdx = i - diffStart + diffStart
|
| if (origIdx < originalRange.length && llmOutput[i].trim() === originalRange[origIdx].trim()) {
|
|
|
| break
|
| }
|
| diffEnd = i + 1
|
| }
|
|
|
| const changedLines = llmOutput.slice(diffStart, diffEnd)
|
|
|
|
|
| if (changedLines.length > originalRange.length * 3) {
|
| return null
|
| }
|
|
|
| return changedLines
|
| }
|
|
|
| interface IndentationError {
|
| line: number
|
| message: string
|
| }
|
|
|
| |
| |
| |
| |
| |
| |
|
|
| function validatePythonIndentation(code: string): IndentationError[] {
|
| const errors: IndentationError[] = []
|
| const lines = code.split('\n')
|
| let inDedentedBlock = false
|
| let blockIndent = 0
|
|
|
| for (let i = 0; i < lines.length; i++) {
|
| const line = lines[i]
|
| const trimmed = line.trim()
|
|
|
|
|
| if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('"""') || trimmed.startsWith("'''")) {
|
| continue
|
| }
|
|
|
| const indent = line.length - line.trimStart().length
|
|
|
|
|
| if (trimmed.endsWith(':') && !trimmed.startsWith('#')) {
|
| blockIndent = indent
|
| inDedentedBlock = true
|
| continue
|
| }
|
|
|
|
|
| if (inDedentedBlock && trimmed) {
|
| if (indent <= blockIndent && !trimmed.startsWith('#') && !trimmed.startsWith('"""') && !trimmed.startsWith("'''")) {
|
| errors.push({
|
| line: i + 1,
|
| message: `Expected indented block body after line ending with ':', found indentation ${indent} (expected > ${blockIndent})`
|
| })
|
| }
|
| inDedentedBlock = false
|
| }
|
|
|
|
|
| if (indent > 0 && indent % 4 !== 0 && indent % 2 !== 0) {
|
|
|
| errors.push({
|
| line: i + 1,
|
| message: `Suspicious indentation: ${indent} spaces (not a multiple of 2 or 4)`
|
| })
|
| }
|
| }
|
|
|
| return errors
|
| }
|
|
|