284 lines
8.6 KiB
Markdown
284 lines
8.6 KiB
Markdown
# 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<CodeFix | null> {
|
||
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<string, string[]> = 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<number> {
|
||
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` 通过
|