# Step 13 — Phase 4.5: 自动修复 **依赖**: Step 09(AI Provider),Step 12(Merger) **参考设计**: §8 ## 目标 实现 AI 自动修复:动态上下文策略、两阶段匹配验证、批量修复倒序应用、快照撤销。 ## 新建文件 | # | 文件 | 说明 | |---|------|------| | 1 | `src/fixer/fixer.ts` | `generateFix()`, `applyFix()`, `applyBatchFixes()`, `undoLastFix()` | --- ## `src/fixer/fixer.ts` ```typescript import * as vscode from 'vscode'; import { LinterDiagnostic } from '../types'; import { AIProvider } from '../ai/providers/base'; import { getAIConfig } from '../config'; import { createProvider } from '../ai/factory'; export type FixCategory = 'naming' | 'style' | 'bug' | 'security' | 'performance'; export interface FixableDiagnostic { ruleId: string; message: string; line: number; severity: string; codeContext: string; source: 'linter' | 'custom'; category: FixCategory; } export interface CodeFix { startLine: number; endLine: number; originalText: string; newText: string; matched: boolean; actualRange?: vscode.Range; } const FIX_SYSTEM_PROMPT = `你是代码修复专家。根据提供的问题和代码上下文,输出修复后的代码。 仅输出 JSON:{ "originalText": "需要替换的原文", "newText": "修复后的新代码" }`; function detectCategory(diagnostic: LinterDiagnostic): FixCategory { if (diagnostic.ruleId.includes('naming') || diagnostic.ruleId.includes('Name')) { return 'naming'; } if (diagnostic.ruleId.includes('security') || diagnostic.ruleId.includes('injection') || diagnostic.ruleId.includes('secret')) { return 'security'; } if (diagnostic.ruleId.includes('perf')) { return 'performance'; } return 'style'; } function getContextRange(document: vscode.TextDocument, line: number, category: FixCategory): { startLine: number; endLine: number } { switch (category) { case 'naming': return { startLine: Math.max(0, line - 2), endLine: Math.min(document.lineCount - 1, line + 2), }; case 'style': return { startLine: Math.max(0, line - 5), endLine: Math.min(document.lineCount - 1, line + 5), }; case 'bug': case 'security': case 'performance': { const funcRange = findEnclosingFunction(document, line); return { startLine: funcRange?.start.line ?? Math.max(0, line - 10), endLine: funcRange?.end.line ?? Math.min(document.lineCount - 1, line + 10), }; } default: return { startLine: Math.max(0, line - 5), endLine: Math.min(document.lineCount - 1, line + 5), }; } } function findEnclosingFunction(document: vscode.TextDocument, line: number): { start: vscode.Position; end: vscode.Position } | null { const text = document.getText(); const lines = text.split('\n'); let braceDepth = 0; let funcStart = line; let funcEnd = line; for (let i = line; i >= 0; i--) { const l = lines[i]; braceDepth += (l.match(/\}/g) || []).length; braceDepth -= (l.match(/\{/g) || []).length; const isFunctionLine = /\b(function|def|class|method|public|private|protected|void|int|String|boolean|var|let|const|async)\s/.test(l); if (braceDepth < 0 && isFunctionLine) { funcStart = i; break; } } braceDepth = 0; for (let i = funcStart; i < lines.length; i++) { const l = lines[i]; braceDepth += (l.match(/\{/g) || []).length; braceDepth -= (l.match(/\}/g) || []).length; if (braceDepth === 0 && (l.match(/\{/g) || []).length > 0) { funcEnd = i; break; } } return { start: new vscode.Position(funcStart, 0), end: new vscode.Position(funcEnd, lines[funcEnd]?.length ?? 0), }; } function extractLines(document: vscode.TextDocument, startLine: number, endLine: number): string { const lines: string[] = []; for (let i = startLine; i <= endLine; i++) { const lineText = document.lineAt(i).text; lines.push(`${String(i + 1).padStart(4, ' ')}| ${lineText}`); } return lines.join('\n'); } function prepareContext(document: vscode.TextDocument, diagnostic: LinterDiagnostic, source: 'linter' | 'custom'): FixableDiagnostic | null { const line = diagnostic.range.start.line; const category = detectCategory(diagnostic); const { startLine, endLine } = getContextRange(document, line, category); const codeContext = extractLines(document, startLine, endLine); return { ruleId: diagnostic.ruleId, message: diagnostic.message, line, severity: diagnostic.severity, codeContext, source, category, }; } async function generateFix( provider: AIProvider, model: string, temperature: number, timeoutMs: number, diagnostic: FixableDiagnostic ): Promise { const userPrompt = `问题: [${diagnostic.ruleId}] ${diagnostic.message}\n代码上下文:\n${diagnostic.codeContext}`; try { const response = await provider.chat(FIX_SYSTEM_PROMPT, userPrompt, { model, temperature, timeoutMs, }); const trimmed = response.trim(); const start = trimmed.indexOf('{'); const end = trimmed.lastIndexOf('}'); if (start === -1 || end === -1) { return null; } const parsed = JSON.parse(trimmed.substring(start, end + 1)); return { startLine: diagnostic.line, endLine: diagnostic.line, originalText: parsed.originalText ?? '', newText: parsed.newText ?? '', matched: false, }; } catch { return null; } } function matchAndValidate(document: vscode.TextDocument, fix: CodeFix): { matched: boolean; actualRange?: vscode.Range } { const lineContent = document.lineAt(fix.startLine).text; if (lineContent === fix.originalText.split('\n')[0]) { const range = new vscode.Range(fix.startLine, 0, fix.endLine, document.lineAt(fix.endLine).text.length); if (document.getText(range) === fix.originalText) { return { matched: true, actualRange: range }; } } const index = document.getText().indexOf(fix.originalText); if (index !== -1) { return { matched: true, actualRange: new vscode.Range( document.positionAt(index), document.positionAt(index + fix.originalText.length) ), }; } return { matched: false }; } function applySingleFix(editor: vscode.TextEditor, fix: CodeFix): boolean { if (!fix.actualRange || !fix.matched) { return false; } return editor.edit(editBuilder => { editBuilder.replace(fix.actualRange!, fix.newText); }); } const snapshotStack: Map = new Map(); function saveSnapshot(document: vscode.TextDocument): void { const filePath = document.uri.fsPath; if (!snapshotStack.has(filePath)) { snapshotStack.set(filePath, []); } snapshotStack.get(filePath)!.push(document.getText()); } function undoLastFix(document: vscode.TextDocument): boolean { const stack = snapshotStack.get(document.uri.fsPath); if (!stack || stack.length === 0) { return false; const previousContent = stack.pop()!; const edit = new vscode.WorkspaceEdit(); edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), previousContent); return vscode.workspace.applyEdit(edit); } function hasSnapshot(document: vscode.TextDocument): boolean { const stack = snapshotStack.get(document.uri.fsPath); return !!(stack && stack.length > 0); } async function applyBatchFixes(document: vscode.TextDocument, fixes: CodeFix[]): Promise { saveSnapshot(document); const validFixes = fixes.filter(f => f.matched); const sorted = [...validFixes].sort((a, b) => b.startLine - a.startLine); const editor = vscode.window.activeTextEditor; if (!editor || editor.document.uri.toString() !== document.uri.toString()) { return 0; } let applied = 0; for (const fix of sorted) { if (applySingleFix(editor, fix)) { applied++; } } return applied; } export { prepareContext, generateFix, matchAndValidate, applySingleFix, applyBatchFixes, undoLastFix, saveSnapshot, hasSnapshot }; ``` --- ## 关键逻辑 **动态上下文策略**(按设计 §8.3): | 问题类型 | 上下文范围 | |---------|-----------| | naming | 问题行 ± 2 行 | | style | 问题行 ± 5 行 | | bug / security / performance | 整个函数/方法 | **两阶段匹配**(按设计 §8.4): 1. 按行号匹配原文首行 → 验证完整原文 2. 全文搜索 originalText **批量修复**: - 修复前保存快照(`saveSnapshot`) - 按位置倒序执行(`b.startLine - a.startLine`) - 只应用成功匹配的修复 **撤销机制**: - `snapshotStack` 按文件路径存储快照 - 面板底部撤销按钮根据 `hasSnapshot` 状态启用/禁用 --- ## 验收 - [ ] 文件创建完成 - [ ] `npm run compile` 通过 - [ ] `npm run lint` 通过