Files
2026Technology-Competition/docs/superpowers/specs/implementation-steps/13-phase4.5-fixer.md
T

8.6 KiB
Raw Blame History

Step 13 — Phase 4.5: 自动修复

依赖: Step 09AI Provider),Step 12Merger
参考设计: §8

目标

实现 AI 自动修复:动态上下文策略、两阶段匹配验证、批量修复倒序应用、快照撤销。

新建文件

# 文件 说明
1 src/fixer/fixer.ts generateFix(), applyFix(), applyBatchFixes(), undoLastFix()

src/fixer/fixer.ts

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 通过