PRIX / src /local-context.ts
Rachit-Tw's picture
Upload 169 files
9284ad7 verified
Raw
History Blame Contribute Delete
10.1 kB
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
}
}