Spaces:
Paused
Paused
| import {readFileSync, existsSync} from 'fs' | |
| import {execSync} from 'child_process' | |
| import {Options} from './options' | |
| import {Prompts} from './prompts' | |
| import {Bot} from './bot' | |
| import {Inputs} from './inputs' | |
| import {codeReview} from './review' | |
| import {extractPatternType} from './utils/patch-utils' | |
| import {confidenceCalibrator} from './confidence' | |
| import {als} from './context' | |
| import {feedbackTracker} from './feedback-tracker' | |
| import pLimit from 'p-limit' | |
| import Parser from 'web-tree-sitter' | |
| export interface LocalFinding { | |
| filename: string | |
| line: number | |
| endLine: number | |
| severity: 'critical' | 'major' | 'minor' | 'info' | |
| type: string | |
| message: string | |
| confidence: number | |
| suggestion?: string | |
| patternType: string | |
| } | |
| interface LocalFileChange { | |
| filename: string | |
| oldContent: string | |
| newContent: string | |
| diff: string | |
| hunks: Array<{ | |
| oldStart: number | |
| oldLines: number | |
| newStart: number | |
| newLines: number | |
| content: string | |
| }> | |
| } | |
| export class LocalContextEngine { | |
| private files: string[] | |
| private project: any | |
| private changes: Map<string, LocalFileChange> = new Map() | |
| constructor(files: string[]) { | |
| this.files = files | |
| this.project = null | |
| } | |
| async initialize(): Promise<void> { | |
| await this.loadChanges() | |
| this.initializeParser() | |
| } | |
| private async loadChanges(): Promise<void> { | |
| for (const file of this.files) { | |
| if (!existsSync(file)) continue | |
| try { | |
| const newContent = readFileSync(file, 'utf8') | |
| let oldContent = '' | |
| let diff = '' | |
| try { | |
| diff = execSync(`git diff HEAD -- "${file}"`, { | |
| encoding: 'utf8', | |
| cwd: process.cwd() | |
| }) | |
| const parentCommit = execSync('git rev-parse HEAD^', { | |
| encoding: 'utf8', | |
| cwd: process.cwd() | |
| }).trim() | |
| try { | |
| oldContent = execSync(`git show ${parentCommit}:${file}`, { | |
| encoding: 'utf8', | |
| cwd: process.cwd() | |
| }) | |
| } catch { | |
| oldContent = '' | |
| } | |
| } catch { | |
| diff = '' | |
| oldContent = '' | |
| } | |
| const hunks = this.parseDiff(diff) | |
| this.changes.set(file, { | |
| filename: file, | |
| oldContent, | |
| newContent, | |
| diff, | |
| hunks | |
| }) | |
| } catch (e) { | |
| console.warn(`Failed to load ${file}: ${e}`) | |
| } | |
| } | |
| } | |
| private parseDiff(diff: string): LocalFileChange['hunks'] { | |
| const hunks: LocalFileChange['hunks'] = [] | |
| const hunkRegex = /^@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/gm | |
| let match | |
| while ((match = hunkRegex.exec(diff)) !== null) { | |
| const oldStart = parseInt(match[1], 10) | |
| const oldLines = parseInt(match[2] || '1', 10) | |
| const newStart = parseInt(match[3], 10) | |
| const newLines = parseInt(match[4] || '1', 10) | |
| const startIdx = match.index + match[0].length | |
| const nextHunkIdx = diff.indexOf('@@', startIdx) | |
| const endIdx = nextHunkIdx === -1 ? diff.length : nextHunkIdx | |
| const content = diff.substring(startIdx, endIdx).trim() | |
| hunks.push({ | |
| oldStart, | |
| oldLines, | |
| newStart, | |
| newLines, | |
| content | |
| }) | |
| } | |
| return hunks | |
| } | |
| private initializeParser(): void { | |
| try { | |
| Parser.init() | |
| } catch (e) { | |
| console.warn(`Failed to initialize tree-sitter: ${e}`) | |
| } | |
| } | |
| async runReview( | |
| lightBot: Bot, | |
| heavyBot: Bot, | |
| options: Options, | |
| prompts: Prompts | |
| ): Promise<LocalFinding[]> { | |
| const findings: LocalFinding[] = [] | |
| const concurrencyLimit = pLimit(options.concurrencyLimit) | |
| const reviewPromises = Array.from(this.changes.entries()).map( | |
| ([filename, change]) => | |
| concurrencyLimit(async () => { | |
| return this.reviewFile( | |
| filename, | |
| change, | |
| lightBot, | |
| heavyBot, | |
| options, | |
| prompts | |
| ) | |
| }) | |
| ) | |
| const fileResults = await Promise.all(reviewPromises) | |
| for (const result of fileResults) { | |
| findings.push(...result) | |
| } | |
| return findings | |
| } | |
| private async reviewFile( | |
| filename: string, | |
| change: LocalFileChange, | |
| lightBot: Bot, | |
| heavyBot: Bot, | |
| options: Options, | |
| prompts: Prompts | |
| ): Promise<LocalFinding[]> { | |
| const findings: LocalFinding[] = [] | |
| const inputs = new Inputs() | |
| inputs.title = `Local review: ${filename}` | |
| inputs.description = '' | |
| inputs.systemMessage = options.systemMessage | |
| for (const hunk of change.hunks) { | |
| const ins = inputs.clone() | |
| const patchContent = this.formatPatchForReview( | |
| filename, | |
| hunk, | |
| change.oldContent | |
| ) | |
| ins.fileDiff = patchContent | |
| ins.filename = filename | |
| ins.patches = hunk.content | |
| const prompt = prompts.renderReviewFileDiff(ins) | |
| const [response] = await heavyBot.chat(prompt, {}) | |
| if (response && response.trim()) { | |
| const fileFindings = this.parseFindings( | |
| response, | |
| filename, | |
| hunk.newStart, | |
| hunk.newStart + hunk.newLines | |
| ) | |
| for (const finding of fileFindings) { | |
| if (options.meetsMinimumSeverity(finding.severity)) { | |
| if (options.meetsConfidenceThreshold(finding.confidence)) { | |
| findings.push(finding) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| return findings | |
| } | |
| private formatPatchForReview( | |
| filename: string, | |
| hunk: LocalFileChange['hunks'][0], | |
| oldContent: string | |
| ): string { | |
| const oldLines = oldContent.split('\n') | |
| const relevantOldLines = oldLines.slice( | |
| hunk.oldStart - 1, | |
| hunk.oldStart - 1 + hunk.oldLines | |
| ) | |
| const patchLines = hunk.content.split('\n') | |
| const newHunkLines = patchLines.filter( | |
| l => l.startsWith('+') && !l.startsWith('+++') | |
| ) | |
| const oldHunkLines = patchLines.filter( | |
| l => l.startsWith('-') && !l.startsWith('---') | |
| ) | |
| return `## File: ${filename} | |
| @@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@ | |
| ---old_hunk--- | |
| \`\`\` | |
| ${oldHunkLines.join('\n') || '(no old lines)'} | |
| \`\`\` | |
| ---new_hunk--- | |
| \`\`\` | |
| ${newHunkLines.join('\n') || '(no new lines)'} | |
| \`\`\` | |
| ` | |
| } | |
| private parseFindings( | |
| response: string, | |
| filename: string, | |
| startLine: number, | |
| endLine: number | |
| ): LocalFinding[] { | |
| const findings: LocalFinding[] = [] | |
| const lines = response.split('\n') | |
| let currentFinding: Partial<LocalFinding> | null = null | |
| let currentMessage = '' | |
| const lineRangeRegex = /^(\d+)-(\d+):/ | |
| const levelTypeRegex = /^(CRITICAL|MAJOR|MINOR|INFO)\s*\|\s*(\w+)/ | |
| const confidenceRegex = /CONFIDENCE:\s*(\d+)%?$/i | |
| for (const line of lines) { | |
| const lineRangeMatch = line.match(lineRangeRegex) | |
| if (lineRangeMatch) { | |
| if (currentFinding && currentFinding.message) { | |
| const calibrated = confidenceCalibrator.calibrate( | |
| currentFinding.confidence || 50, | |
| currentFinding.message, | |
| currentFinding.message.length, | |
| 2000, | |
| currentFinding.severity, | |
| 'local', | |
| 'local', | |
| extractPatternType(currentFinding.message), | |
| filename | |
| ) | |
| findings.push({ | |
| filename, | |
| line: currentFinding.line || startLine, | |
| endLine: currentFinding.endLine || endLine, | |
| severity: currentFinding.severity || 'minor', | |
| type: currentFinding.type || 'general', | |
| message: currentFinding.message.trim(), | |
| confidence: calibrated.score, | |
| suggestion: currentFinding.suggestion, | |
| patternType: extractPatternType(currentFinding.message) | |
| }) | |
| } | |
| currentFinding = { | |
| line: parseInt(lineRangeMatch[1], 10), | |
| endLine: parseInt(lineRangeMatch[2], 10), | |
| severity: 'minor', | |
| type: 'general', | |
| confidence: 50 | |
| } | |
| currentMessage = '' | |
| continue | |
| } | |
| const levelTypeMatch = line.match(levelTypeRegex) | |
| if (levelTypeMatch && currentFinding) { | |
| currentFinding.severity = | |
| levelTypeMatch[1].toLowerCase() as LocalFinding['severity'] | |
| currentFinding.type = levelTypeMatch[2].toLowerCase() | |
| continue | |
| } | |
| if (line.includes('```suggestion')) { | |
| const suggestionMatch = response.match(/```suggestion\n([\s\S]*?)```/) | |
| if (suggestionMatch && currentFinding) { | |
| currentFinding.suggestion = suggestionMatch[1].trim() | |
| } | |
| } | |
| const confidenceMatch = line.match(confidenceRegex) | |
| if (confidenceMatch && currentFinding) { | |
| currentFinding.confidence = parseInt(confidenceMatch[1], 10) | |
| } | |
| if (currentFinding) { | |
| currentMessage += `${line}\n` | |
| currentFinding.message = currentMessage | |
| } | |
| } | |
| if (currentFinding && currentFinding.message) { | |
| const calibrated = confidenceCalibrator.calibrate( | |
| currentFinding.confidence || 50, | |
| currentFinding.message, | |
| currentFinding.message.length, | |
| 2000, | |
| currentFinding.severity, | |
| 'local', | |
| 'local', | |
| extractPatternType(currentFinding.message), | |
| filename | |
| ) | |
| findings.push({ | |
| filename, | |
| line: currentFinding.line || startLine, | |
| endLine: currentFinding.endLine || endLine, | |
| severity: currentFinding.severity || 'minor', | |
| type: currentFinding.type || 'general', | |
| message: currentFinding.message.trim(), | |
| confidence: calibrated.score, | |
| suggestion: currentFinding.suggestion, | |
| patternType: extractPatternType(currentFinding.message) | |
| }) | |
| } | |
| return findings | |
| } | |
| } | |