mirror of
https://git.coastit.co.za/caitlin/CRM_ENVR.git
synced 2026-07-10 11:15:43 +02:00
347 lines
12 KiB
JavaScript
347 lines
12 KiB
JavaScript
// ── Code Repair Agent ─────────────────────────────────────────────
|
|
// Phase 2: Auto-fix TypeScript build errors using qwen2.5-coder:1.5b-base
|
|
//
|
|
// Usage:
|
|
// node scripts/code-repair-agent.mjs # one-shot: fix build errors
|
|
// node scripts/code-repair-agent.mjs --watch # watch mode: re-fix on file changes
|
|
// node scripts/code-repair-agent.mjs --ci # CI mode: fix + commit
|
|
//
|
|
// Requires: Ollama running at OLLAMA_HOST (default http://localhost:11434)
|
|
// Model: qwen2.5-coder:1.5b-base
|
|
//
|
|
// ── Architecture ──────────────────────────────────────────────────
|
|
// 1. Capture build output from `npx tsc --noEmit`
|
|
// 2. Parse error locations (file, line, column, message)
|
|
// 3. Read the failing source file + surrounding context (~20 lines)
|
|
// 4. Send to qwen2.5-coder via Ollama API for fix suggestion
|
|
// 5. Apply fix if confidence > threshold (0.7)
|
|
// 6. Re-run build to verify fix
|
|
// 7. If breaking change detected (wide-reaching import changes), escalate
|
|
// 8. In --ci mode: auto-commit with [bot] prefix
|
|
|
|
import { execSync } from "node:child_process"
|
|
import { readFileSync, writeFileSync, existsSync, appendFileSync } from "node:fs"
|
|
import { resolve, dirname } from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { createRequire } from "node:module"
|
|
|
|
const SELF = dirname(fileURLToPath(import.meta.url))
|
|
const _require = createRequire(import.meta.url)
|
|
const ROOT = resolve(SELF, "..")
|
|
const OLLAMA_HOST = process.env.OLLAMA_HOST || "http://localhost:11434"
|
|
const MODEL = process.env.REPAIR_MODEL || "qwen2.5-coder:1.5b-base"
|
|
const MAX_ITERATIONS = 5
|
|
const CONFIDENCE_THRESHOLD = 0.7
|
|
const ESCALATION_TIMEOUT_MS = 30 * 60 * 1000 // 30 min
|
|
|
|
// ── Helpers ────────────────────────────────────────────────────────
|
|
|
|
function log(msg, level = "info") {
|
|
const prefix = level === "error" ? "✗" : level === "warn" ? "⚠" : "✓"
|
|
console.log(` ${prefix} [repair] ${msg}`)
|
|
}
|
|
|
|
function runTsc() {
|
|
try {
|
|
const out = execSync("npx tsc --noEmit", { stdio: "pipe", timeout: 60000, cwd: ROOT })
|
|
return { ok: true, output: out.toString() }
|
|
} catch (e) {
|
|
return { ok: false, output: e.stdout?.toString() || e.message || "" }
|
|
}
|
|
}
|
|
|
|
function parseErrors(output) {
|
|
const errors = []
|
|
// Match: src/file.ts(line,col): error TSxxxx: message
|
|
const lineRegex = /(src\/[^:]+)\((\d+),(\d+)\):\s+error\s+TS\d+:\s+(.+)/g
|
|
let m
|
|
while ((m = lineRegex.exec(output)) !== null) {
|
|
errors.push({
|
|
file: resolve(ROOT, m[1]),
|
|
line: parseInt(m[2], 10),
|
|
column: parseInt(m[3], 10),
|
|
message: m[4].trim(),
|
|
})
|
|
}
|
|
return errors
|
|
}
|
|
|
|
function readContext(filePath, errorLine, contextLines = 20) {
|
|
try {
|
|
const lines = readFileSync(filePath, "utf-8").split("\n")
|
|
const start = Math.max(0, errorLine - 1 - contextLines)
|
|
const end = Math.min(lines.length, errorLine - 1 + contextLines)
|
|
return {
|
|
content: lines.slice(start, end).join("\n"),
|
|
contextLine: errorLine - 1 - start, // 0-indexed line of the error in the snippet
|
|
totalLines: lines.length,
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// ── Ollama Integration ─────────────────────────────────────────────
|
|
|
|
async function queryOllama(prompt) {
|
|
const response = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
model: MODEL,
|
|
prompt,
|
|
stream: false,
|
|
options: { temperature: 0.3, num_predict: 2048 },
|
|
}),
|
|
})
|
|
if (!response.ok) throw new Error(`Ollama returned ${response.status}`)
|
|
const data = await response.json()
|
|
return data.response || ""
|
|
}
|
|
|
|
function buildRepairPrompt(filePath, errorMessage, codeContext) {
|
|
return `You are a TypeScript repair agent. Fix the following error.
|
|
|
|
FILE: ${filePath}
|
|
ERROR: ${errorMessage}
|
|
|
|
CODE CONTEXT (line ${codeContext.contextLine + 1} is the error line):
|
|
\`\`\`typescript
|
|
${codeContext.content}
|
|
\`\`\`
|
|
|
|
Respond with ONLY the fix JSON in this exact format:
|
|
{"fix": "the exact replacement code for the error line(s)", "confidence": 0.0-1.0, "explanation": "brief explanation"}
|
|
|
|
Rules:
|
|
- Keep the fix minimal and precise
|
|
- Only change what's necessary to fix the error
|
|
- If the error is a missing import, suggest the import statement
|
|
- If the error is a type mismatch, suggest the corrected code
|
|
- confidence < 0.7 will not be auto-applied
|
|
- If you cannot determine a fix, set confidence to 0`
|
|
}
|
|
|
|
function parseFixResponse(text) {
|
|
try {
|
|
// Extract JSON from response (it might have markdown fences)
|
|
const jsonMatch = text.match(/\{[\s\S]*"fix"[\s\S]*"confidence"[\s\S]*\}/)
|
|
if (!jsonMatch) return null
|
|
return JSON.parse(jsonMatch[0])
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function applyFix(filePath, fixSuggestion) {
|
|
if (!fixSuggestion || !fixSuggestion.fix) return { applied: false, reason: "No fix suggestion" }
|
|
|
|
try {
|
|
const original = readFileSync(filePath, "utf-8")
|
|
writeFileSync(filePath, fixSuggestion.fix, "utf-8")
|
|
return { applied: true, backup: original }
|
|
} catch (e) {
|
|
return { applied: false, reason: e.message }
|
|
}
|
|
}
|
|
|
|
// ── Breaking Change Escalation ─────────────────────────────────────
|
|
|
|
function isBreakingChange(errors, fixes) {
|
|
// Heuristic: if fix touches import structure or exports, it's breaking
|
|
for (const fix of fixes) {
|
|
if (!fix || !fix.fix) continue
|
|
if (fix.fix.includes("export ") || fix.fix.includes("import ") || fix.fix.includes("delete ")) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function escalateBreakingChange(errors, fixes) {
|
|
const logFile = resolve(ROOT, "repair-escalation.log")
|
|
const timestamp = new Date().toISOString()
|
|
const entry = [
|
|
`=== Breaking Change Escalation @ ${timestamp} ===`,
|
|
`Errors: ${errors.map(e => `${e.file}:${e.line}: ${e.message}`).join("\n ")}`,
|
|
`Fixes: ${fixes.map(f => f?.fix?.slice(0, 200)).join("\n ")}`,
|
|
`Auto-apply in ${ESCALATION_TIMEOUT_MS / 60000} minutes or cancel with Ctrl+C`,
|
|
"========================================\n",
|
|
].join("\n")
|
|
|
|
appendFileSync(logFile, entry)
|
|
log(`Breaking change escalated — see ${logFile}`, "warn")
|
|
log(`Auto-applying in ${ESCALATION_TIMEOUT_MS / 60000} min (Ctrl+C to cancel)...`, "warn")
|
|
|
|
// Wait for timeout or user intervention
|
|
await new Promise(resolve => setTimeout(resolve, ESCALATION_TIMEOUT_MS))
|
|
log("Escalation timeout reached — applying fixes")
|
|
return true
|
|
}
|
|
|
|
// ── Git Auto-Commit ────────────────────────────────────────────────
|
|
|
|
function gitCommit(message) {
|
|
try {
|
|
execSync("git add -A", { stdio: "pipe", cwd: ROOT })
|
|
execSync(`git commit -m "[bot] ${message}"`, { stdio: "pipe", cwd: ROOT })
|
|
log(`Committed: [bot] ${message}`)
|
|
return true
|
|
} catch (e) {
|
|
log(`Git commit failed: ${e.message}`, "warn")
|
|
return false
|
|
}
|
|
}
|
|
|
|
// ── Main Repair Loop ───────────────────────────────────────────────
|
|
|
|
async function repairOnce(doCommit = false) {
|
|
log("Running build check...")
|
|
const build = runTsc()
|
|
if (build.ok) {
|
|
log("No build errors found")
|
|
return { fixed: false, errors: [] }
|
|
}
|
|
|
|
const errors = parseErrors(build.output)
|
|
if (errors.length === 0) {
|
|
log("Could not parse build errors from output", "warn")
|
|
log(build.output.slice(0, 1000))
|
|
return { fixed: false, errors: [] }
|
|
}
|
|
|
|
log(`Found ${errors.length} error(s):`)
|
|
for (const e of errors.slice(0, 10)) {
|
|
const shortFile = e.file.replace(ROOT, "").replace(/^\//, "")
|
|
log(`${shortFile}:${e.line}:${e.column} — ${e.message}`)
|
|
}
|
|
|
|
const fixes = []
|
|
for (const error of errors) {
|
|
const context = readContext(error.file, error.line)
|
|
if (!context) {
|
|
log(`Cannot read ${error.file}`, "error")
|
|
continue
|
|
}
|
|
|
|
log(`Asking ${MODEL} to fix ${error.file}:${error.line}...`)
|
|
try {
|
|
const prompt = buildRepairPrompt(error.file, error.message, context)
|
|
const response = await queryOllama(prompt)
|
|
const fix = parseFixResponse(response)
|
|
|
|
if (fix && fix.confidence >= CONFIDENCE_THRESHOLD) {
|
|
const result = applyFix(error.file, fix)
|
|
if (result.applied) {
|
|
log(`${fix.explanation || "Applied fix"} (confidence: ${fix.confidence})`)
|
|
fixes.push(fix)
|
|
} else {
|
|
log(`Failed to apply fix: ${result.reason}`, "error")
|
|
}
|
|
} else {
|
|
log(`Low confidence (${fix?.confidence || 0}) or invalid response — skipping`, "warn")
|
|
}
|
|
} catch (e) {
|
|
log(`Ollama error: ${e.message}`, "error")
|
|
}
|
|
}
|
|
|
|
if (fixes.length === 0) {
|
|
log("No fixes could be applied", "error")
|
|
return { fixed: false, errors }
|
|
}
|
|
|
|
// Verify by re-running build
|
|
log("Verifying fixes...")
|
|
const verify = runTsc()
|
|
if (verify.ok) {
|
|
log("All fixes verified — build passes!")
|
|
if (doCommit) gitCommit(`Auto-fix: ${fixes.length} error(s) resolved`)
|
|
return { fixed: true, errors: [], fixes }
|
|
}
|
|
|
|
const remainingErrors = parseErrors(verify.output)
|
|
log(`${remainingErrors.length} error(s) remaining after fix`, "warn")
|
|
|
|
// Check for breaking changes
|
|
if (isBreakingChange(remainingErrors, fixes)) {
|
|
log("Breaking change detected — escalating", "warn")
|
|
const approved = await escalateBreakingChange(remainingErrors, fixes)
|
|
if (approved) {
|
|
if (doCommit) gitCommit(`Breaking change applied after escalation`)
|
|
return { fixed: true, errors: remainingErrors, fixes, escalated: true }
|
|
}
|
|
}
|
|
|
|
return { fixed: remainingErrors.length === 0, errors: remainingErrors, fixes }
|
|
}
|
|
|
|
async function repairLoop(maxIterations = MAX_ITERATIONS, doCommit = false) {
|
|
for (let i = 0; i < maxIterations; i++) {
|
|
log(`=== Repair iteration ${i + 1}/${maxIterations} ===`)
|
|
const result = await repairOnce(doCommit)
|
|
if (!result.fixed) {
|
|
if (result.errors?.length > 0) {
|
|
log(`${result.errors.length} error(s) remaining — run again for deeper fix`, "warn")
|
|
}
|
|
break
|
|
}
|
|
if (result.errors?.length === 0) break
|
|
}
|
|
log("Repair agent finished")
|
|
}
|
|
|
|
// ── Watch Mode ─────────────────────────────────────────────────────
|
|
|
|
function watchMode() {
|
|
log("Watch mode active — monitoring src/ for changes...", "info")
|
|
const chokidar = (() => {
|
|
try {
|
|
return _require("chokidar")
|
|
} catch {
|
|
return null
|
|
}
|
|
})()
|
|
|
|
if (!chokidar) {
|
|
log("chokidar not available — using simple polling (5s interval)", "warn")
|
|
setInterval(async () => {
|
|
const result = await repairOnce(false)
|
|
if (result.fixed) log("Auto-fix applied")
|
|
}, 5000)
|
|
return
|
|
}
|
|
|
|
const watcher = chokidar.watch("src/**/*.{ts,tsx}", { cwd: ROOT, ignoreInitial: true })
|
|
let debounceTimer = null
|
|
|
|
watcher.on("change", async (filePath) => {
|
|
clearTimeout(debounceTimer)
|
|
debounceTimer = setTimeout(async () => {
|
|
log(`Change detected: ${filePath}`)
|
|
await repairLoop(3, false)
|
|
}, 1000)
|
|
})
|
|
|
|
log("Watching for changes...")
|
|
}
|
|
|
|
// ── Entry ──────────────────────────────────────────────────────────
|
|
|
|
const args = process.argv.slice(2)
|
|
const isWatch = args.includes("--watch") || args.includes("-w")
|
|
const isCI = args.includes("--ci") || args.includes("-c")
|
|
|
|
async function main() {
|
|
if (isWatch) {
|
|
watchMode()
|
|
} else {
|
|
await repairLoop(MAX_ITERATIONS, isCI)
|
|
}
|
|
}
|
|
|
|
main().catch((e) => {
|
|
console.error("Repair agent crashed:", e)
|
|
process.exit(1)
|
|
})
|