feat: AI 修复链路扩展 + 修复预览两步确认 + 自定义规则/AI 审查条目支持 AI 修复

- AI 修复:无原生 fix 的 linter 条目走 AI 修复(aiFixEngine/fixPrompt,AI 重检收敛),自定义规则与 AI 审查条目新增 AI 修复按钮(customFixEngine),自定义规则条目展开显示 AI 建议(suggestion 字段)

- 修复预览:面板触发修复先用内置 diff 预览,面板内「应用/取消」两步确认后才写入(fixPreview/fixPending,单条与分 tab 批量均支持)

- 修复面板交互:linter/custom/ai 分 tab「全部修复」、已修复+撤销、pending 按钮状态同步

- bug 修复:修复按钮失败后卡  不恢复;custom/ai 修复不稳定(空修复重试、AI 重检收敛判定放宽、降级接受最后一次有效修复)

- 移除 AI codeDiff 展示块
This commit is contained in:
范智鹏
2026-08-20 22:26:30 +08:00
parent 3d8119d9c9
commit d16d680f0f
20 changed files with 1655 additions and 85 deletions
+433 -22
View File
@@ -5,7 +5,7 @@ import { loadActiveRules } from '../rules/yaml-parser';
import { filterAndSummarize, filterForDocument } from '../rules/rule-filter';
import { mergeResults, MergedReport } from '../merger/merger';
import { reportToMarkdown } from '../utils/report';
import { getApiKey } from '../config';
import { getApiKey, getAIProvider, getAIBaseUrl, getAIModel, getAITemperature, getAITimeout, getAIMaxTokens, getFixMaxIterations } from '../config';
import { ReviewPanel } from '../panel/webview';
import { t } from '../i18n/messages';
import { exportTemplate } from '../rules/export-service';
@@ -13,10 +13,17 @@ import { extractMethodScope } from '../scope/method-extractor';
import { ReviewStatusCache } from '../scope/status-cache';
import { MethodCodeLensProvider } from '../views/codeLensProvider';
import { DiagnosticMarkers, isMarkersEnabled } from '../diagnostics/diagnosticMarkers';
import { fixDiagnostic } from '../fix/fixEngine';
import { fixDiagnostic, type FixResult, type AppliedFix } from '../fix/fixEngine';
import { aiFixDiagnostic } from '../fix/aiFixEngine';
import { aiFixReviewIssue } from '../fix/customFixEngine';
import type { ReviewIssueInput } from '../fix/fixPrompt';
import { FixSessionManager } from '../fix/fixSession';
import { getFixMaxIterations } from '../config';
import type { CustomRule } from '../types';
import { registerFixPreviewProvider, openPreviewDiff, closePreviewEditor, applyNewText } from '../fix/fixPreview';
import { FixPendingStore } from '../fix/fixPending';
import { mockDocument } from '../utils/mockDocument';
import { createProvider } from '../ai/factory';
import type { AIProvider } from '../ai/providers/base';
import type { LinterAdapter, LinterDiagnostic, CustomRule } from '../types';
let currentReport: MergedReport | null = null;
@@ -32,6 +39,88 @@ function resolveFixDocument(
return active?.document;
}
async function createFixProvider(context: vscode.ExtensionContext): Promise<AIProvider | null> {
const apiKey = await getApiKey(context);
if (!apiKey) { return null; }
try {
return createProvider(getAIProvider(), apiKey, getAIBaseUrl(), context.extensionUri);
} catch (err) {
console.error('[code-reviewer] create fix provider failed:', err);
return null;
}
}
async function resolveFix(
context: vscode.ExtensionContext,
document: vscode.TextDocument,
workingDir: string,
adapter: LinterAdapter,
diag: LinterDiagnostic,
maxIterations: number,
dryRun?: boolean
): Promise<FixResult> {
if (diag.fix) {
return fixDiagnostic(document, workingDir, adapter, diag, maxIterations, dryRun);
}
const provider = await createFixProvider(context);
if (!provider) {
return { success: false, attempts: 0, message: 'ai-unavailable', appliedFixes: [] };
}
return aiFixDiagnostic(document, workingDir, adapter, diag, maxIterations, provider, {
model: getAIModel(),
temperature: getAITemperature(),
maxTokens: getAIMaxTokens(),
timeoutMs: getAITimeout() * 1000,
}, dryRun);
}
async function resolveReviewIssueFix(
context: vscode.ExtensionContext,
document: vscode.TextDocument,
diag: ReviewIssueInput,
maxIterations: number,
dryRun?: boolean
): Promise<FixResult> {
const provider = await createFixProvider(context);
if (!provider) {
return { success: false, attempts: 0, message: 'ai-unavailable', appliedFixes: [] };
}
return aiFixReviewIssue(document, diag, maxIterations, provider, {
model: getAIModel(),
temperature: getAITemperature(),
maxTokens: getAIMaxTokens(),
timeoutMs: getAITimeout() * 1000,
}, dryRun);
}
function findCustomIssue(ruleId?: string, line?: number): ReviewIssueInput | undefined {
if (!currentReport) { return undefined; }
const d = currentReport.customRuleDiagnostics.find(d =>
d.ruleId === ruleId && (line === undefined || d.range.start.line === line)
);
if (!d) { return undefined; }
return {
ruleId: d.ruleId,
line: d.range.start.line,
message: d.message,
suggestion: d.suggestion,
};
}
function findAIIssue(ruleId?: string, line?: number): ReviewIssueInput | undefined {
if (!currentReport) { return undefined; }
const f = currentReport.aiFindings.find(f =>
f.ruleId === ruleId && (line === undefined || f.line === line)
);
if (!f) { return undefined; }
return {
ruleId: f.ruleId,
line: f.line,
message: f.title,
suggestion: f.suggestion,
};
}
async function refreshAfterFix(
document: vscode.TextDocument,
orchestrator: Orchestrator,
@@ -55,6 +144,7 @@ async function refreshAfterFix(
ruleId: d.ruleId,
severity: d.severity,
message: d.message,
suggestion: d.suggestion,
line: d.range.start.line + 1,
})),
translatedDiagnostics: currentReport.translatedDiagnostics,
@@ -65,6 +155,7 @@ async function refreshAfterFix(
filePath: document.uri.fsPath,
language: document.languageId,
adapterIds: result.adapterIds,
aiFixAvailable: currentReport.aiFixAvailable,
customRuleFilterInfo: currentReport.customRuleFilterInfo,
});
const panel = ReviewPanel.createOrShow(extensionUri);
@@ -94,6 +185,7 @@ export function registerCommands(
statusCache: ReviewStatusCache,
markers: DiagnosticMarkers,
fixSession: FixSessionManager,
pendingStore: FixPendingStore,
): void {
context.subscriptions.push(
@@ -106,6 +198,7 @@ export function registerCommands(
const document = editor.document;
fixSession.clear(document.uri);
pendingStore.clear(document.fileName);
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
@@ -126,6 +219,8 @@ export function registerCommands(
const code = document.getText();
const aiResult = await runAIReview(context, code, staticResult.diagnostics, filterResult.relevant);
const aiFixAvailable = !!(await getApiKey(context));
currentReport = mergeResults({
staticDiagnostics: staticResult.diagnostics,
customRuleResults: aiResult.customRuleResults,
@@ -137,6 +232,7 @@ export function registerCommands(
filePath: document.uri.fsPath,
language: document.languageId,
adapterIds: staticResult.adapterIds,
aiFixAvailable,
customRuleFilterInfo: {
totalActive: allRules.length,
injected: filterResult.relevant.length,
@@ -238,6 +334,7 @@ export function registerCommands(
filePath: document.uri.fsPath,
language: document.languageId,
adapterIds: [],
aiFixAvailable: false,
customRuleFilterInfo: undefined,
});
@@ -312,8 +409,72 @@ export function registerCommands(
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.fixIssue', async (payload?: { line?: number; ruleId?: string; source?: string; origin?: 'hover' | 'panel' }) => {
try {
const source = payload?.source === 'custom' || payload?.source === 'ai' ? payload.source : 'linter';
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, payload?.origin);
if (!document) { console.log('[code-reviewer] fixIssue: no target document'); return; }
const isPreview = payload?.origin === 'panel';
const maxIterations = getFixMaxIterations();
if (source === 'custom' || source === 'ai') {
const reviewDiag = source === 'custom'
? findCustomIssue(payload?.ruleId, payload?.line)
: findAIIssue(payload?.ruleId, payload?.line);
if (!reviewDiag) {
vscode.window.showWarningMessage(t('fix.noFix'));
return;
}
const result = await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: t('fix.aiRunning'),
cancellable: false,
}, () => resolveReviewIssueFix(context, document, reviewDiag, maxIterations, isPreview));
if (!result.success) {
const msg = result.message === 'ai-unavailable'
? t('fix.noAI')
: t('fix.aiFailed', { 0: result.message ?? '' });
vscode.window.showWarningMessage(msg);
if (payload?.ruleId) {
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${payload.ruleId}@${payload.line ?? -1}`, on: false });
}
return;
}
if (isPreview) {
const newText = result.newText ?? document.getText();
const key = `${reviewDiag.ruleId}@${reviewDiag.line}`;
const existing = pendingStore.getSingle(document.fileName, key);
if (existing?.diffUri) {
await closePreviewEditor(existing.diffUri);
}
const diffUri = await openPreviewDiff({
originalText: document.getText(),
newText,
title: `${t('fix.previewTitle')}: ${reviewDiag.ruleId} @ ${reviewDiag.line + 1}`,
fileName: document.fileName,
});
pendingStore.setSingle({
key,
ruleId: reviewDiag.ruleId,
line: reviewDiag.line,
filePath: document.fileName,
source,
originalText: document.getText(),
newText,
appliedFixes: result.appliedFixes,
diffUri,
});
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: true });
return;
}
fixSession.recordFixes(document.uri, reviewDiag.ruleId, reviewDiag.line, result.appliedFixes, source);
await document.save();
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
vscode.window.showInformationMessage(t('fix.applied'));
return;
}
const cached = orchestrator.getAnalysisResult(document.uri);
if (!cached) { console.log(`[code-reviewer] fixIssue: no cached analysis for ${document.uri.toString()}`); return; }
@@ -325,7 +486,7 @@ export function registerCommands(
const line = payload?.line;
const ruleId = payload?.ruleId;
const diag = cached.diagnostics.find(d =>
d.ruleId === ruleId && d.fix && (line === undefined || d.range.start.line === line)
d.ruleId === ruleId && (line === undefined || d.range.start.line === line)
) ?? cached.diagnostics.find(d => d.fix);
if (!diag) {
@@ -333,10 +494,50 @@ export function registerCommands(
return;
}
const maxIterations = getFixMaxIterations();
const result = await fixDiagnostic(document, workingDir, adapter, diag, maxIterations);
const needsAi = !diag.fix;
const result = await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: needsAi ? t('fix.aiRunning') : t('fix.running'),
cancellable: false,
}, () => resolveFix(context, document, workingDir, adapter, diag, maxIterations, isPreview));
if (!result.success) {
vscode.window.showWarningMessage(t('fix.failed', { 0: result.message ?? '' }));
const msg = diag.fix
? t('fix.failed', { 0: result.message ?? '' })
: (result.message === 'ai-unavailable'
? t('fix.noAI')
: t('fix.aiFailed', { 0: result.message ?? '' }));
vscode.window.showWarningMessage(msg);
if (ruleId) {
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${ruleId}@${line ?? -1}`, on: false });
}
return;
}
if (isPreview) {
const newText = result.newText ?? document.getText();
const key = `${diag.ruleId}@${diag.range.start.line}`;
const existing = pendingStore.getSingle(document.fileName, key);
if (existing?.diffUri) {
await closePreviewEditor(existing.diffUri);
}
const diffUri = await openPreviewDiff({
originalText: document.getText(),
newText,
title: `${t('fix.previewTitle')}: ${diag.ruleId} @ ${diag.range.start.line + 1}`,
fileName: document.fileName,
});
pendingStore.setSingle({
key,
ruleId: diag.ruleId,
line: diag.range.start.line,
filePath: document.fileName,
source: 'linter',
originalText: document.getText(),
newText,
appliedFixes: result.appliedFixes,
diffUri,
});
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: true });
return;
}
@@ -349,15 +550,97 @@ export function registerCommands(
} catch (err) {
console.error('[code-reviewer] fixIssue failed:', err);
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
if (payload?.ruleId) {
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${payload.ruleId}@${payload.line ?? -1}`, on: false });
}
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.fixAll', async () => {
vscode.commands.registerCommand('codeReviewer.fixAll', async (payload?: { source?: 'linter' | 'custom' | 'ai' }) => {
try {
const source = payload?.source === 'custom' || payload?.source === 'ai' ? payload.source : 'linter';
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
if (!document) { console.log('[code-reviewer] fixAll: no target document'); return; }
const maxIterations = getFixMaxIterations();
const aiAvailable = !!(await getApiKey(context));
if (source === 'custom' || source === 'ai') {
if (!aiAvailable) {
vscode.window.showWarningMessage(t('fix.noAI'));
return;
}
const issues: ReviewIssueInput[] = source === 'custom'
? (currentReport?.customRuleDiagnostics ?? []).map(d => ({
ruleId: d.ruleId,
line: d.range.start.line,
message: d.message,
suggestion: d.suggestion,
}))
: (currentReport?.aiFindings ?? []).map(f => ({
ruleId: f.ruleId,
line: f.line,
message: f.title,
suggestion: f.suggestion,
}));
if (issues.length === 0) {
vscode.window.showInformationMessage(t('fix.noFix'));
return;
}
let currentText = document.getText();
const results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[] = [];
let success = 0;
let skipped = 0;
await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: t('fix.running'),
cancellable: false,
}, async (progress) => {
for (let i = 0; i < issues.length; i++) {
const issue = issues[i];
progress.report({ message: `${t('fix.progress')} ${i + 1}/${issues.length}` });
const mock = mockDocument(currentText, document.languageId, document.fileName);
const result = await resolveReviewIssueFix(context, mock, issue, maxIterations, true);
if (result.success && result.newText && result.newText !== currentText) {
results.push({ ruleId: issue.ruleId, line: issue.line, appliedFixes: result.appliedFixes });
currentText = result.newText;
success++;
} else {
skipped++;
}
}
});
if (success === 0) {
vscode.window.showWarningMessage(t('fix.failed', { 0: 'no-fix-applied' }));
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
return;
}
const batch = pendingStore.getBatch(document.fileName);
if (batch?.diffUri) {
await closePreviewEditor(batch.diffUri);
}
const diffUri = await openPreviewDiff({
originalText: document.getText(),
newText: currentText,
title: t('fix.previewTitle'),
fileName: document.fileName,
});
pendingStore.setBatch({
filePath: document.fileName,
source,
originalText: document.getText(),
newText: currentText,
results,
diffUri,
});
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: true });
return;
}
const cached = orchestrator.getAnalysisResult(document.uri);
if (!cached) { console.log(`[code-reviewer] fixAll: no cached analysis for ${document.uri.toString()}`); return; }
@@ -366,13 +649,14 @@ export function registerCommands(
const adapter = orchestrator.getAdapter(cached.adapterId);
if (!adapter) { console.log(`[code-reviewer] fixAll: adapter not found: ${cached.adapterId}`); return; }
const fixables = cached.diagnostics.filter(d => d.fix);
const fixables = cached.diagnostics.filter(d => d.fix || (aiAvailable && !d.ruleId.startsWith('sqlfluff:')));
if (fixables.length === 0) {
vscode.window.showInformationMessage(t('fix.noFix'));
return;
}
const maxIterations = getFixMaxIterations();
let currentText = document.getText();
const results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[] = [];
let success = 0;
let skipped = 0;
await vscode.window.withProgress({
@@ -383,27 +667,52 @@ export function registerCommands(
for (let i = 0; i < fixables.length; i++) {
const diag = fixables[i];
progress.report({ message: `${t('fix.progress')} ${i + 1}/${fixables.length}` });
const fresh = orchestrator.getAnalysisResult(document.uri);
const freshDiag = fresh?.diagnostics.find(d =>
d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line && d.fix
) ?? diag;
if (!freshDiag || !freshDiag.fix) { skipped++; continue; }
const result = await fixDiagnostic(document, workingDir, adapter, freshDiag, maxIterations);
if (result.success) {
fixSession.recordFixes(document.uri, freshDiag.ruleId, freshDiag.range.start.line, result.appliedFixes);
const mock = mockDocument(currentText, document.languageId, document.fileName);
const fresh = await adapter.check(mock, workingDir);
const freshDiag = fresh.diagnostics.find(d =>
d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line
) ?? fresh.diagnostics.find(d => d.ruleId === diag.ruleId);
if (!freshDiag) { skipped++; continue; }
const result = await resolveFix(context, mock, workingDir, adapter, freshDiag, maxIterations, true);
if (result.success && result.newText && result.newText !== currentText) {
results.push({ ruleId: freshDiag.ruleId, line: freshDiag.range.start.line, appliedFixes: result.appliedFixes });
currentText = result.newText;
success++;
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
} else {
skipped++;
}
}
});
await document.save();
vscode.window.showInformationMessage(t('fix.allComplete', { 0: String(success), 1: String(skipped) }));
if (success === 0) {
vscode.window.showWarningMessage(t('fix.failed', { 0: 'no-fix-applied' }));
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
return;
}
const batch = pendingStore.getBatch(document.fileName);
if (batch?.diffUri) {
await closePreviewEditor(batch.diffUri);
}
const diffUri = await openPreviewDiff({
originalText: document.getText(),
newText: currentText,
title: t('fix.previewTitle'),
fileName: document.fileName,
});
pendingStore.setBatch({
filePath: document.fileName,
source: 'linter',
originalText: document.getText(),
newText: currentText,
results,
diffUri,
});
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: true });
} catch (err) {
console.error('[code-reviewer] fixAll failed:', err);
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
}
})
);
@@ -433,6 +742,108 @@ export function registerCommands(
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.applyFixPreview', async (payload?: { line?: number; ruleId?: string }) => {
try {
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
if (!document) { console.log('[code-reviewer] applyFixPreview: no target document'); return; }
const ruleId = payload?.ruleId ?? '';
const line = payload?.line ?? -1;
if (!ruleId) { return; }
const key = `${ruleId}@${line}`;
const pending = pendingStore.getSingle(document.fileName, key);
if (!pending) { return; }
const applied = await applyNewText(document, pending.newText);
if (!applied) {
vscode.window.showWarningMessage(t('fix.failed', { 0: 'apply-failed' }));
return;
}
fixSession.recordFixes(document.uri, pending.ruleId, pending.line, pending.appliedFixes, pending.source);
if (pending.diffUri) {
await closePreviewEditor(pending.diffUri);
}
pendingStore.deleteSingle(document.fileName, key);
await document.save();
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
vscode.window.showInformationMessage(t('fix.applied'));
} catch (err) {
console.error('[code-reviewer] applyFixPreview failed:', err);
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.cancelFixPreview', async (payload?: { line?: number; ruleId?: string }) => {
try {
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
if (!document) { console.log('[code-reviewer] cancelFixPreview: no target document'); return; }
const ruleId = payload?.ruleId ?? '';
const line = payload?.line ?? -1;
if (!ruleId) { return; }
const key = `${ruleId}@${line}`;
const pending = pendingStore.getSingle(document.fileName, key);
if (!pending) { return; }
if (pending.diffUri) {
await closePreviewEditor(pending.diffUri);
}
pendingStore.deleteSingle(document.fileName, key);
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: false });
} catch (err) {
console.error('[code-reviewer] cancelFixPreview failed:', err);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.applyAllPreview', async () => {
try {
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
if (!document) { console.log('[code-reviewer] applyAllPreview: no target document'); return; }
const batch = pendingStore.getBatch(document.fileName);
if (!batch) { return; }
const applied = await applyNewText(document, batch.newText);
if (!applied) {
vscode.window.showWarningMessage(t('fix.failed', { 0: 'apply-failed' }));
return;
}
for (const r of batch.results) {
fixSession.recordFixes(document.uri, r.ruleId, r.line, r.appliedFixes, batch.source);
}
if (batch.diffUri) {
await closePreviewEditor(batch.diffUri);
}
pendingStore.deleteBatch(document.fileName);
await document.save();
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
vscode.window.showInformationMessage(t('fix.allComplete', { 0: String(batch.results.length), 1: String(0) }));
} catch (err) {
console.error('[code-reviewer] applyAllPreview failed:', err);
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.cancelAllPreview', async () => {
try {
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
if (!document) { console.log('[code-reviewer] cancelAllPreview: no target document'); return; }
const batch = pendingStore.getBatch(document.fileName);
if (!batch) { return; }
if (batch.diffUri) {
await closePreviewEditor(batch.diffUri);
}
pendingStore.deleteBatch(document.fileName);
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
} catch (err) {
console.error('[code-reviewer] cancelAllPreview failed:', err);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.exportTemplate', () => exportTemplate())
);
+18 -15
View File
@@ -105,7 +105,8 @@ function buildCustomRuleSystemPrompt(): string {
return `あなたはコードルールレビュアーです。以下のカスタムルールに違反しているかどうかのみを評価してください。
意味を理解し、テキストの一致ではなく判断してください。
JSONのみを出力、形式:
{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明" }] }
{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明", "suggestion": "具体的な修正提案" }] }
"suggestion" は実行可能な修正提案を必ず含めてください。
ルールに違反していない場合は空の配列を返してください。
出力言語:ja`;
@@ -114,7 +115,8 @@ JSONのみを出力、形式:
return `You are a code rule reviewer. Only evaluate whether the following custom rules are violated.
Understand semantics, not text matching.
Output JSON only, format:
{ "customRuleResults": [{ "ruleId": "rule id", "line": line number, "severity": "error|warning|info", "message": "violation description" }] }
{ "customRuleResults": [{ "ruleId": "rule id", "line": line number, "severity": "error|warning|info", "message": "violation description", "suggestion": "concrete fix suggestion" }] }
Always include a concrete actionable "suggestion" for each violation.
If no rules are violated, return an empty array.
Output language: en`;
@@ -122,7 +124,8 @@ Output language: en`;
return `你是代码规则审查员,只评估以下自定义规则是否被违反。
理解语义而非文本匹配。
仅输出 JSON,格式:
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述" }] }
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述", "suggestion": "具体的修复建议" }] }
每条违规都必须给出可执行的 "suggestion" 修复建议。
如果没有违反任何规则,返回空数组。
输出语言:zh-CN`;
@@ -146,8 +149,8 @@ translatedDiagnosticsの要件:
JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。
形式:
{
"translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案", "codeDiff": "任意" }],
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "codeDiff": "任意", "line": 行番号 }]
"translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案" }],
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "line": 行番号 }]
}
出力言語:ja`;
@@ -168,8 +171,8 @@ translatedDiagnostics requirements:
Output JSON only. Double quotes in strings must be escaped with \\".
Format:
{
"translatedDiagnostics": [{ "originalRuleId": "original id", "translatedMessage": "translated message", "translatedSuggestion": "suggestion", "codeDiff": "optional" }],
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "title", "description": "description", "suggestion": "suggestion", "codeDiff": "optional", "line": line number }]
"translatedDiagnostics": [{ "originalRuleId": "original id", "translatedMessage": "translated message", "translatedSuggestion": "suggestion" }],
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "title", "description": "description", "suggestion": "suggestion", "line": line number }]
}
Output language: en`;
@@ -189,8 +192,8 @@ translatedDiagnostics 要求:
仅输出 JSON,字符串中的双引号必须用 \\" 转义。
格式:
{
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议", "codeDiff": "可选" }],
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "codeDiff": "可选", "line": 行号 }]
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议" }],
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "line": 行号 }]
}
输出语言:zh-CN`;
@@ -426,7 +429,8 @@ Report violations in "customRuleResults".\n\n`
"ruleId": "original rule id",
"line": line_number,
"severity": "error|warning|info",
"message": "violation description"
"message": "violation description",
"suggestion": "concrete fix suggestion"
}
],\n`
: '';
@@ -463,7 +467,6 @@ ${ruleOutput} "findings": [
"title": "issue title",
"description": "detailed description",
"suggestion": "fix suggestion",
"codeDiff": "optional fix diff",
"line": line_number,
"path": "trigger path description, e.g. if(order==null) -> NPE on .getId()"
}
@@ -487,7 +490,8 @@ function buildMethodSystemPromptZh(hasRules: boolean): string {
"ruleId": "原始规则 ID",
"line": 行号,
"severity": "error|warning|info",
"message": "违规描述"
"message": "违规描述",
"suggestion": "具体的修复建议"
}
],\n`
: '';
@@ -524,7 +528,6 @@ ${ruleOutput} "findings": [
"title": "问题标题",
"description": "详细描述",
"suggestion": "修复建议",
"codeDiff": "可选的修复 diff",
"line": 行号,
"path": "触发路径描述,如 if(order==null) -> NPE on .getId()"
}
@@ -548,7 +551,8 @@ function buildMethodSystemPromptJa(hasRules: boolean): string {
"ruleId": "元のルールID",
"line": 行番号,
"severity": "error|warning|info",
"message": "違反の説明"
"message": "違反の説明",
"suggestion": "具体的な修正提案"
}
],\n`
: '';
@@ -585,7 +589,6 @@ ${ruleOutput} "findings": [
"title": "問題のタイトル",
"description": "詳細な説明",
"suggestion": "修正提案",
"codeDiff": "オプションの修正diff",
"line": 行番号,
"path": "トリガーパス説明、例: if(order==null) -> .getId() で NPE"
}
+1 -2
View File
@@ -2,7 +2,6 @@ export interface TranslatedDiagnostic {
originalRuleId: string;
translatedMessage: string;
translatedSuggestion: string;
codeDiff?: string;
}
export interface CustomRuleResult {
@@ -10,6 +9,7 @@ export interface CustomRuleResult {
line: number;
severity: 'error' | 'warning' | 'info';
message: string;
suggestion?: string;
}
export interface AIFinding {
@@ -19,7 +19,6 @@ export interface AIFinding {
title: string;
description: string;
suggestion: string;
codeDiff?: string;
line: number;
}
+6 -1
View File
@@ -9,6 +9,8 @@ import { MethodCodeLensProvider } from './views/codeLensProvider';
import { DiagnosticMarkers, isMarkersEnabled } from './diagnostics/diagnosticMarkers';
import { FixCodeActionProvider } from './fix/codeActionProvider';
import { FixSessionManager } from './fix/fixSession';
import { FixPendingStore } from './fix/fixPending';
import { registerFixPreviewProvider } from './fix/fixPreview';
let orchestrator: Orchestrator;
let markers: DiagnosticMarkers;
@@ -71,6 +73,8 @@ export function activate(context: vscode.ExtensionContext) {
const codeLensProvider = new MethodCodeLensProvider(statusCache);
const fixSession = new FixSessionManager();
const pendingStore = new FixPendingStore();
registerFixPreviewProvider(context);
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider(
@@ -94,6 +98,7 @@ export function activate(context: vscode.ExtensionContext) {
statusCache.clearDocument(document.uri);
markers.clear(document.uri);
fixSession.clear(document.uri);
pendingStore.clear(document.fileName);
})
);
@@ -111,7 +116,7 @@ export function activate(context: vscode.ExtensionContext) {
})
);
registerCommands(context, orchestrator, codeLensProvider, statusCache, markers, fixSession);
registerCommands(context, orchestrator, codeLensProvider, statusCache, markers, fixSession, pendingStore);
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument((document) => {
+139
View File
@@ -0,0 +1,139 @@
import * as vscode from 'vscode';
import type { AIProvider, ChatOptions } from '../ai/providers/base';
import { chatWithRetry, parseJsonResponse } from '../ai/engine';
import type { LinterAdapter, LinterDiagnostic } from '../types';
import { mockDocument } from '../utils/mockDocument';
import { buildFixSystemPrompt, buildFixUserPrompt, buildFixContext, type ReviewIssueInput } from './fixPrompt';
import type { AppliedFix, FixResult } from './fixEngine';
interface AiCodeFix {
originalText: string;
newText: string;
}
async function requestFix(
provider: AIProvider,
options: ChatOptions,
diag: LinterDiagnostic,
context: string
): Promise<AiCodeFix | null> {
const issueInput: ReviewIssueInput = {
ruleId: diag.ruleId,
line: diag.range.start.line,
message: diag.message,
suggestion: diag.suggestion,
};
const attempt = async (): Promise<AiCodeFix | null> => {
try {
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(issueInput, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
} catch {
return null;
}
};
const first = await attempt();
if (first) { return first; }
return attempt();
}
function sameRuleAtRegion(
diagnostics: LinterDiagnostic[],
ruleId: string,
start: number,
end: number,
mock: vscode.TextDocument
): boolean {
for (const d of diagnostics) {
if (d.ruleId !== ruleId) { continue; }
const dStart = mock.offsetAt(d.range.start);
const dEnd = mock.offsetAt(d.range.end);
if (dStart < end && dEnd > start) { return true; }
}
return false;
}
export async function aiFixDiagnostic(
document: vscode.TextDocument,
workingDir: string,
adapter: LinterAdapter,
diag: LinterDiagnostic,
maxIterations: number,
provider: AIProvider,
options: ChatOptions,
dryRun?: boolean
): Promise<FixResult> {
const originalText = document.getText();
let currentText = originalText;
const appliedFixes: AppliedFix[] = [];
let converged = false;
for (let round = 1; round <= maxIterations; round++) {
const context = buildFixContext(currentText, diag.range.start.line);
const fix = await requestFix(provider, options, diag, context);
if (!fix || fix.originalText.trim() === '') {
return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
}
const startIndex = currentText.indexOf(fix.originalText);
if (startIndex === -1) {
return { success: false, attempts: round, message: 'ai-match-failed', appliedFixes };
}
const endIndex = startIndex + fix.originalText.length;
const nextText = currentText.slice(0, startIndex) + fix.newText + currentText.slice(endIndex);
if (nextText === currentText) {
return { success: false, attempts: round, message: 'no-change', appliedFixes };
}
appliedFixes.push({
originalText: fix.originalText,
newText: fix.newText,
line: diag.range.start.line,
});
currentText = nextText;
try {
const mock = mockDocument(currentText, document.languageId, document.fileName);
const verify = await adapter.check(mock, workingDir);
const fixedStart = startIndex;
const fixedEnd = startIndex + fix.newText.length;
if (!sameRuleAtRegion(verify.diagnostics, diag.ruleId, fixedStart, fixedEnd, mock)) {
converged = true;
break;
}
} catch {
converged = false;
break;
}
}
if (!converged) {
return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
}
if (currentText === originalText) {
return { success: true, attempts: 0, appliedFixes };
}
if (dryRun) {
return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
}
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(originalText.length)
);
edit.replace(document.uri, fullRange, currentText);
const applied = await vscode.workspace.applyEdit(edit);
if (!applied) {
return { success: false, attempts: maxIterations, message: 'apply-failed', appliedFixes };
}
return { success: true, attempts: maxIterations, appliedFixes };
}
+151
View File
@@ -0,0 +1,151 @@
import * as vscode from 'vscode';
import type { AIProvider, ChatOptions } from '../ai/providers/base';
import { chatWithRetry, parseJsonResponse } from '../ai/engine';
import { buildFixSystemPrompt, buildFixUserPrompt, buildFixContext, buildVerifySystemPrompt, buildVerifyUserPrompt, type ReviewIssueInput } from './fixPrompt';
import type { AppliedFix, FixResult } from './fixEngine';
interface AiCodeFix {
originalText: string;
newText: string;
}
interface AiVerifyResult {
fixed: boolean;
reason?: string;
}
async function requestFix(
provider: AIProvider,
options: ChatOptions,
diag: ReviewIssueInput,
context: string
): Promise<AiCodeFix | null> {
const attempt = async (): Promise<AiCodeFix | null> => {
try {
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(diag, context), options);
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
if (originalText.trim() === '') { return null; }
return { originalText, newText };
} catch {
return null;
}
};
const first = await attempt();
if (first) { return first; }
return attempt();
}
async function verifyFixed(
provider: AIProvider,
options: ChatOptions,
diag: ReviewIssueInput,
code: string
): Promise<AiVerifyResult> {
try {
const response = await chatWithRetry(provider, buildVerifySystemPrompt(), buildVerifyUserPrompt(diag, code), options);
const parsed = parseJsonResponse(response) as Partial<AiVerifyResult> & { fixed?: unknown };
const f = parsed.fixed;
const fixedValue = typeof f === 'string' ? f : String(f);
return { fixed: f === true || fixedValue === 'true', reason: parsed.reason };
} catch {
return { fixed: false };
}
}
export async function aiFixReviewIssue(
document: vscode.TextDocument,
diag: ReviewIssueInput,
maxIterations: number,
provider: AIProvider,
options: ChatOptions,
dryRun?: boolean
): Promise<FixResult> {
const originalText = document.getText();
let currentText = originalText;
const appliedFixes: AppliedFix[] = [];
let converged = false;
for (let round = 1; round <= maxIterations; round++) {
const context = buildFixContext(currentText, diag.line);
const fix = await requestFix(provider, options, diag, context);
if (!fix || fix.originalText.trim() === '') {
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'ai-no-fix');
return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
}
const startIndex = currentText.indexOf(fix.originalText);
if (startIndex === -1) {
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'ai-match-failed');
return { success: false, attempts: round, message: 'ai-match-failed', appliedFixes };
}
const endIndex = startIndex + fix.originalText.length;
const nextText = currentText.slice(0, startIndex) + fix.newText + currentText.slice(endIndex);
if (nextText === currentText) {
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'no-change');
return { success: false, attempts: round, message: 'no-change', appliedFixes };
}
appliedFixes.push({
originalText: fix.originalText,
newText: fix.newText,
line: diag.line,
});
currentText = nextText;
const verify = await verifyFixed(provider, options, diag, currentText);
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'applied', fix.newText.slice(0, 60), 'verify', verify.fixed, verify.reason ?? '');
if (verify.fixed) {
converged = true;
break;
}
}
if (!converged) {
if (appliedFixes.length > 0) {
console.log('[code-reviewer] review-fix', diag.ruleId, 'accept-last-fix', appliedFixes.length);
if (currentText === originalText) {
return { success: true, attempts: 0, appliedFixes };
}
if (dryRun) {
return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
}
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(originalText.length)
);
edit.replace(document.uri, fullRange, currentText);
const applied = await vscode.workspace.applyEdit(edit);
if (!applied) {
return { success: false, attempts: maxIterations, message: 'apply-failed', appliedFixes };
}
return { success: true, attempts: maxIterations, appliedFixes };
}
return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
}
if (currentText === originalText) {
return { success: true, attempts: 0, appliedFixes };
}
if (dryRun) {
return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
}
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(originalText.length)
);
edit.replace(document.uri, fullRange, currentText);
const applied = await vscode.workspace.applyEdit(edit);
if (!applied) {
return { success: false, attempts: maxIterations, message: 'apply-failed', appliedFixes };
}
return { success: true, attempts: maxIterations, appliedFixes };
}
+7 -1
View File
@@ -13,6 +13,7 @@ export interface FixResult {
attempts: number;
message?: string;
appliedFixes: AppliedFix[];
newText?: string;
}
function applyFixToText(text: string, fix: { range: [number, number]; text: string }): string {
@@ -58,7 +59,8 @@ export async function fixDiagnostic(
workingDir: string,
adapter: LinterAdapter,
diag: LinterDiagnostic,
maxIterations: number
maxIterations: number,
dryRun?: boolean
): Promise<FixResult> {
const originalText = document.getText();
let currentText = originalText;
@@ -120,6 +122,10 @@ export async function fixDiagnostic(
return { success: true, attempts: 0, appliedFixes };
}
if (dryRun) {
return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
}
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(
document.positionAt(0),
+77
View File
@@ -0,0 +1,77 @@
import * as vscode from 'vscode';
import type { AppliedFix } from './fixEngine';
export interface PendingFix {
key: string;
ruleId: string;
line: number;
filePath: string;
source: 'linter' | 'custom' | 'ai';
originalText: string;
newText: string;
appliedFixes: AppliedFix[];
diffUri?: vscode.Uri;
}
export interface PendingBatch {
filePath: string;
source: 'linter' | 'custom' | 'ai';
originalText: string;
newText: string;
results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[];
diffUri?: vscode.Uri;
}
function fileKey(filePath: string): string {
return `file:${filePath}`;
}
export class FixPendingStore {
private singles = new Map<string, PendingFix>();
private batches = new Map<string, PendingBatch>();
setSingle(fix: PendingFix): void {
this.singles.set(fileKey(fix.filePath) + '|' + fix.key, fix);
}
getSingle(filePath: string, key: string): PendingFix | undefined {
return this.singles.get(fileKey(filePath) + '|' + key);
}
hasSingle(filePath: string, key: string): boolean {
return this.singles.has(fileKey(filePath) + '|' + key);
}
deleteSingle(filePath: string, key: string): void {
this.singles.delete(fileKey(filePath) + '|' + key);
}
singleKeys(filePath: string): string[] {
const prefix = fileKey(filePath) + '|';
const keys: string[] = [];
for (const k of this.singles.keys()) {
if (k.startsWith(prefix)) { keys.push(k.slice(prefix.length)); }
}
return keys;
}
setBatch(batch: PendingBatch): void {
this.batches.set(fileKey(batch.filePath), batch);
}
getBatch(filePath: string): PendingBatch | undefined {
return this.batches.get(fileKey(filePath));
}
deleteBatch(filePath: string): void {
this.batches.delete(fileKey(filePath));
}
clear(filePath: string): void {
const prefix = fileKey(filePath) + '|';
for (const k of this.singles.keys()) {
if (k.startsWith(prefix)) { this.singles.delete(k); }
}
this.batches.delete(fileKey(filePath));
}
}
+77
View File
@@ -0,0 +1,77 @@
import * as vscode from 'vscode';
const scheme = 'codeReviewerPreview';
class FixPreviewContentProvider implements vscode.TextDocumentContentProvider {
private texts = new Map<string, string>();
provideTextDocumentContent(uri: vscode.Uri): string {
return this.texts.get(uri.toString()) ?? '';
}
set(uri: vscode.Uri, text: string): void {
this.texts.set(uri.toString(), text);
}
clear(uri: vscode.Uri): void {
this.texts.delete(uri.toString());
}
}
let previewProvider: FixPreviewContentProvider | null = null;
export function registerFixPreviewProvider(context: vscode.ExtensionContext): void {
if (previewProvider) { return; }
previewProvider = new FixPreviewContentProvider();
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(scheme, previewProvider));
}
export interface PreviewRequest {
originalText: string;
newText: string;
title: string;
fileName: string;
}
export async function openPreviewDiff(req: PreviewRequest): Promise<vscode.Uri | undefined> {
if (!previewProvider) { return undefined; }
const stamp = Date.now();
const originalUri = vscode.Uri.parse(`${scheme}://original/${encodeURIComponent(req.fileName)}-${stamp}`);
const newUri = vscode.Uri.parse(`${scheme}://new/${encodeURIComponent(req.fileName)}-${stamp}`);
previewProvider.set(originalUri, req.originalText);
previewProvider.set(newUri, req.newText);
await vscode.commands.executeCommand('vscode.diff', originalUri, newUri, req.title);
return newUri;
}
export async function closePreviewEditor(uri: vscode.Uri): Promise<void> {
if (!previewProvider) { return; }
for (const group of vscode.window.tabGroups.all) {
for (const tab of group.tabs) {
if (tab.input instanceof vscode.TabInputTextDiff) {
const modified = tab.input.modified;
if (modified.toString() === uri.toString()) {
await vscode.window.tabGroups.close(tab);
}
}
}
}
previewProvider.clear(uri);
}
export async function applyNewText(
document: vscode.TextDocument,
newText: string
): Promise<boolean> {
const originalText = document.getText();
if (newText === originalText) { return true; }
const edit = new vscode.WorkspaceEdit();
const fullRange = new vscode.Range(
document.positionAt(0),
document.positionAt(originalText.length)
);
edit.replace(document.uri, fullRange, newText);
return vscode.workspace.applyEdit(edit);
}
+91
View File
@@ -0,0 +1,91 @@
import type { LinterDiagnostic } from '../types';
import { getLanguage } from '../i18n/messages';
export interface ReviewIssueInput {
ruleId: string;
line: number;
message: string;
suggestion?: string;
}
export function buildFixSystemPrompt(): string {
const lang = getLanguage();
if (lang === 'ja') {
return `あなたはシニアコード修正の専門家です。与えられた問題とコードコンテキストから、最小で正しい修正を生成してください。
JSONのみを出力:{ "originalText": "置換対象の原文(コードコンテキスト内に完全一致すること)", "newText": "修正後の新コード" }
要件:
- originalText は提供されたコードコンテキスト内に逐語的に存在し、正確な空白・インデントも含むこと
- 指定された問題のみ修正し、無関係なコードは変更しないこと
- コードスタイルとインデントを維持すること
- 必ず修正コードを出力すること。空の修正を出力しないこと。問題を完全に解消できない場合でも、問題を緩和・改善する最小のコード片を出力すること`;
}
if (lang === 'en') {
return `You are a senior code fixer. Given the code issue and context, produce the minimal correct fix.
Output JSON only: { "originalText": "the exact original snippet to replace (must be found verbatim in the code context)", "newText": "the fixed replacement snippet" }
Requirements:
- originalText must exist verbatim in the provided code context, including exact whitespace and indentation
- Fix only the reported issue; do not modify unrelated code
- Preserve code style and indentation
- Always output a fix snippet; never output an empty fix. Even if the issue cannot be fully resolved, output the minimal snippet that mitigates or improves it`;
}
return `你是资深代码修复专家。根据给定的代码问题与上下文,给出最小且正确的修复。
仅输出 JSON{ "originalText": "需要被替换的原文片段(必须在代码上下文中逐字存在,含精确的前后空白与缩进)", "newText": "修复后的新代码片段" }
要求:
- originalText 必须在提供的代码上下文中逐字存在,包含精确的缩进与前后空白
- 只修复指定问题,不要改动无关代码
- 保持代码风格与缩进
- 必须输出修复片段,禁止输出空修复;即使无法完全消除问题,也要给出能缓解/改善问题的最小代码片段`;
}
export function buildFixUserPrompt(diag: ReviewIssueInput, context: string): string {
const lang = getLanguage();
const issueLabel = lang === 'ja' ? '問題' : lang === 'en' ? 'Issue' : '问题';
const suggestionLabel = lang === 'ja' ? '参考提案' : lang === 'en' ? 'Reference suggestion' : '参考建议';
const contextLabel = lang === 'ja' ? 'コードコンテキスト(行番号付き)' : lang === 'en' ? 'Code context (with line numbers)' : '代码上下文(带行号)';
const parts: string[] = [];
parts.push(`## ${issueLabel}\n[${diag.ruleId}] ${diag.message}`);
if (diag.suggestion && diag.suggestion.trim() !== '') {
parts.push(`## ${suggestionLabel}\n${diag.suggestion}`);
}
parts.push(`## ${contextLabel}\n${context}`);
return parts.join('\n\n');
}
export function buildFixContext(code: string, line: number): string {
const lines = code.split('\n');
const start = Math.max(0, line - 6);
const end = Math.min(lines.length - 1, line + 6);
const out: string[] = [];
for (let i = start; i <= end; i++) {
out.push(`${String(i + 1).padStart(4, ' ')}| ${lines[i]}`);
}
return out.join('\n');
}
export function buildVerifySystemPrompt(): string {
const lang = getLanguage();
if (lang === 'ja') {
return `あなたはコードレビュアーです。修正後のコードに指定された問題がまだ存在するか確認してください。
JSONのみを出力:{ "fixed": true|false, "reason": "まだ残る場合の理由" }
問題が完全に解消されていれば "fixed": true、まだ残っていれば "fixed": false を返してください。`;
}
if (lang === 'en') {
return `You are a code reviewer. Check whether the reported issue still exists in the fixed code.
Output JSON only: { "fixed": true|false, "reason": "reason if it still remains" }
Return "fixed": true if the issue is fully resolved, otherwise "fixed": false.`;
}
return `你是代码审查员。检查修复后的代码中指定问题是否仍然存在。
仅输出 JSON{ "fixed": true|false, "reason": "如果问题仍存在的原因" }
问题已完全解决返回 "fixed": true,仍存在返回 "fixed": false。`;
}
export function buildVerifyUserPrompt(diag: ReviewIssueInput, code: string): string {
const lang = getLanguage();
const issueLabel = lang === 'ja' ? '問題' : lang === 'en' ? 'Issue' : '问题';
const codeLabel = lang === 'ja' ? '修正後コード' : lang === 'en' ? 'Fixed code' : '修复后代码';
const parts: string[] = [];
parts.push(`## ${issueLabel}\n[${diag.ruleId}] ${diag.message}`);
parts.push(`## ${codeLabel}\n${code}`);
return parts.join('\n\n');
}
+3 -3
View File
@@ -6,7 +6,7 @@ export interface FixedEntry {
ruleId: string;
line: number;
fixes: AppliedFix[];
source: 'linter';
source: 'linter' | 'custom' | 'ai';
}
function keyOf(ruleId: string, line: number): string {
@@ -77,7 +77,7 @@ export class FixSessionManager {
}
}
recordFixes(uri: vscode.Uri, ruleId: string, line: number, fixes: AppliedFix[]): string {
recordFixes(uri: vscode.Uri, ruleId: string, line: number, fixes: AppliedFix[], source: 'linter' | 'custom' | 'ai' = 'linter'): string {
const key = keyOf(ruleId, line);
const fullKey = uri.toString() + '|' + key;
const existing = this.fixedEntries.get(fullKey);
@@ -89,7 +89,7 @@ export class FixSessionManager {
ruleId,
line,
fixes: [...fixes],
source: 'linter',
source,
});
}
return key;
+45
View File
@@ -85,6 +85,41 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Undo failed, the code may have been modified manually',
ja: '取り消しに失敗しました。コードが手動で変更された可能性があります',
},
'fix.aiRunning': {
'zh-CN': 'AI 修复中...',
en: 'AI fixing...',
ja: 'AI修正中...',
},
'fix.aiFailed': {
'zh-CN': 'AI 修复失败: {0}',
en: 'AI fix failed: {0}',
ja: 'AI修正に失敗しました: {0}',
},
'fix.noAI': {
'zh-CN': '该问题需要 AI 修复,请先在设置面板配置 AI',
en: 'This issue needs AI fix, please configure AI in the Setup panel first',
ja: 'この問題はAI修正が必要です。設定パネルでAIを設定してください',
},
'fix.confirmApply': {
'zh-CN': '确认应用修复?',
en: 'Confirm applying the fix?',
ja: '修正を適用しますか?',
},
'fix.apply': {
'zh-CN': '应用',
en: 'Apply',
ja: '適用',
},
'fix.cancel': {
'zh-CN': '取消',
en: 'Cancel',
ja: 'キャンセル',
},
'fix.previewTitle': {
'zh-CN': '修复预览',
en: 'Fix preview',
ja: '修正プレビュー',
},
'export.needRunFirst': {
'zh-CN': '请先运行完整审查生成报告',
@@ -833,11 +868,21 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Fix All',
ja: 'すべて修正',
},
'report.fixAllApply': {
'zh-CN': '全部应用',
en: 'Apply All',
ja: 'すべて適用',
},
'report.fixLabel': {
'zh-CN': '修复',
en: 'Fix',
ja: '修正',
},
'report.fixAILabel': {
'zh-CN': 'AI 修复',
en: 'AI Fix',
ja: 'AI修正',
},
'report.fixedIssues': {
'zh-CN': '已修复',
en: 'Fixed',
+11
View File
@@ -17,7 +17,9 @@ export interface MergedReport {
language: string;
adapterNames: string[];
fixableLinterIndices: number[];
aiFixableLinterIndices: number[];
fixableCustomIndices: number[];
aiFixAvailable: boolean;
customRuleFilterInfo?: {
totalActive: number;
injected: number;
@@ -37,6 +39,7 @@ interface MergeInput {
filePath: string;
language: string;
adapterIds: string[];
aiFixAvailable?: boolean;
customRuleFilterInfo?: {
totalActive: number;
injected: number;
@@ -90,6 +93,7 @@ export function mergeResults(input: MergeInput): MergedReport {
severity: r.severity as Severity,
ruleId: r.ruleId,
message: r.message,
suggestion: r.suggestion,
range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
})),
d => d.range.start.line
@@ -121,6 +125,11 @@ export function mergeResults(input: MergeInput): MergedReport {
.map((d, i) => (d.fix ? i : -1))
.filter(i => i !== -1);
const aiFixAvailable = !!input.aiFixAvailable;
const aiFixableLinterIndices = linterDiagnostics
.map((d, i) => (!d.fix && aiFixAvailable && !d.ruleId.startsWith('sqlfluff:') ? i : -1))
.filter(i => i !== -1);
const fixableCustomIndices: number[] = [];
return {
@@ -138,7 +147,9 @@ export function mergeResults(input: MergeInput): MergedReport {
language: input.language,
adapterNames: input.adapterIds,
fixableLinterIndices,
aiFixableLinterIndices,
fixableCustomIndices,
aiFixAvailable,
customRuleFilterInfo: input.customRuleFilterInfo,
};
}
+108 -36
View File
@@ -4,7 +4,7 @@ import { t, onLanguageChange, getLanguage } from '../i18n/messages';
import type { FixSessionManager } from '../fix/fixSession';
interface PanelMessage {
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo';
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo' | 'applyFix' | 'cancelFix' | 'applyAll' | 'cancelAll';
line?: number;
ruleId?: string;
source?: 'linter' | 'custom' | 'ai';
@@ -23,6 +23,13 @@ const SVG_HEADER_ICON = svgIcon();
const BADGE_CLASS: Record<string, string> = { linter: 'badge-linter', custom: 'badge-custom', ai: 'badge-ai' };
interface FixedEntryView {
ruleId: string;
line: number;
key: string;
source: 'linter' | 'custom' | 'ai';
}
function badgeHtml(source: string): string {
let label: string;
switch (source) {
@@ -110,6 +117,10 @@ export class ReviewPanel {
}
}
postMessage(message: unknown): void {
this.panel.webview.postMessage(message);
}
private buildHtml(report: MergedReport): string {
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
@@ -139,6 +150,7 @@ export class ReviewPanel {
: '';
const fixableLinterSet = new Set(report.fixableLinterIndices);
const aiFixableLinterSet = new Set(report.aiFixableLinterIndices);
const fixableCustomSet = new Set(report.fixableCustomIndices);
const fixedEntries = this.fixSession?.getEntries(vscode.Uri.file(report.filePath)) ?? [];
@@ -286,13 +298,13 @@ ${errorBox}
</div>
<div class="tab-content active" id="tab-linter">
${this.buildLinterList(report, fixableLinterSet, fixedEntries)}
${this.buildLinterList(report, fixableLinterSet, aiFixableLinterSet, fixedEntries)}
</div>
<div class="tab-content" id="tab-custom">
${this.buildCustomList(report)}
${this.buildCustomList(report, fixedEntries)}
</div>
<div class="tab-content" id="tab-ai">
${this.buildAIList(report)}
${this.buildAIList(report, fixedEntries)}
</div>
<div class="actions">
@@ -306,74 +318,115 @@ ${errorBox}
</html>`;
}
private buildLinterList(report: MergedReport, fixableSet: Set<number>, fixedEntries: Array<{ ruleId: string; line: number; key: string }>): string {
private buildLinterList(report: MergedReport, fixableSet: Set<number>, aiFixableSet: Set<number>, fixedEntries: FixedEntryView[]): string {
if (report.linterDiagnostics.length === 0 && fixedEntries.length === 0) {
return `<div class="empty">${t('report.noIssues')}</div>`;
}
const toolName = report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : t('report.sourceLinter');
const hasFixable = fixableSet.size > 0;
let html = `<div class="section-header"><span class="section-header-title">${esc(toolName)} · ${t('report.issuesCount', { 0: report.linterCount })}</span>${hasFixable ? `<button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button>` : ''}</div>`;
const hasFixable = fixableSet.size > 0 || aiFixableSet.size > 0;
const fixAllBtn = hasFixable
? `<button class="btn" data-fix-all-btn onclick="send('fixAll')">${t('report.fixAll')}</button>`
+ `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
+ `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
: '';
let html = `<div class="section-header"><span class="section-header-title">${esc(toolName)} · ${t('report.issuesCount', { 0: report.linterCount })}</span>${fixAllBtn}</div>`;
if (report.linterDiagnostics.length === 0) {
html += `<div class="empty">${t('report.noIssues')}</div>`;
} else {
html += report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i))).join('');
html += report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i), aiFixableSet.has(i))).join('');
}
if (fixedEntries.length > 0) {
html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: fixedEntries.length })}</span></div>`;
html += fixedEntries.map(f => this.buildFixedItem(f.ruleId, f.line, f.key)).join('');
const linterFixed = fixedEntries.filter(f => f.source === 'linter');
if (linterFixed.length > 0) {
html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: linterFixed.length })}</span></div>`;
html += linterFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'linter')).join('');
}
return html;
}
private buildFixedItem(ruleId: string, line: number, key: string): string {
private buildFixedItem(ruleId: string, line: number, key: string, source: 'linter' | 'custom' | 'ai'): string {
return `<div class="item item-fixed">
<div class="item-severity item-severity-fixed"></div>
<div class="item-body">
<div class="item-row1">
<span class="item-icon icon-fixed"></span>
<span class="item-badge badge-linter">${t('report.sourceLinter')}</span>
${badgeHtml(source)}
<span class="item-rule">${esc(ruleId)}</span>
<span class="item-message">${t('report.fixedLabel')}</span>
<button class="item-undo" onclick="event.stopPropagation();send('undo', ${line}, '${esc(ruleId)}', 'linter')">↩ ${t('report.undoFix')}</button>
<button class="item-undo" onclick="event.stopPropagation();send('undo', ${line}, '${esc(ruleId)}', '${source}')">↩ ${t('report.undoFix')}</button>
</div>
</div>
</div>`;
}
private buildCustomList(report: MergedReport): string {
private buildCustomList(report: MergedReport, fixedEntries: FixedEntryView[]): string {
const filterInfo = report.customRuleFilterInfo;
if (filterInfo?.skippedRequestA) {
return `<div class="empty">${t('report.skipCustomRules')}</div>`;
}
if (report.customRuleDiagnostics.length === 0) {
const customFixed = fixedEntries.filter(f => f.source === 'custom');
const fixedKeys = new Set(customFixed.map(f => `${f.ruleId}@${f.line}`));
const remaining = report.customRuleDiagnostics.filter(d => !fixedKeys.has(`${d.ruleId}@${d.range.start.line}`));
if (remaining.length === 0 && customFixed.length === 0) {
return `<div class="empty">${t('report.noRuleViolations')}</div>`;
}
const filterLabel = filterInfo
? t('report.injectedRules', { 0: filterInfo.injected, 1: filterInfo.totalActive })
: '';
return `<div class="section-header"><span class="section-header-title">${t('report.sourceCustom')} · ${t('report.issuesCount', { 0: report.customRuleCount })}${filterLabel}</span></div>`
+ report.customRuleDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false)).join('');
const hasFixable = remaining.length > 0;
const fixAllBtn = hasFixable
? `<button class="btn" data-fix-all-btn onclick="send('fixAll', undefined, undefined, 'custom')">${t('report.fixAll')}</button>`
+ `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
+ `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
: '';
let html = `<div class="section-header"><span class="section-header-title">${t('report.sourceCustom')} · ${t('report.issuesCount', { 0: remaining.length })}${filterLabel}</span>${fixAllBtn}</div>`;
if (remaining.length === 0) {
html += `<div class="empty">${t('report.noRuleViolations')}</div>`;
} else {
html += remaining.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false, true)).join('');
}
if (customFixed.length > 0) {
html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: customFixed.length })}</span></div>`;
html += customFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'custom')).join('');
}
return html;
}
private buildAIList(report: MergedReport): string {
if (report.aiFindings.length === 0) {
private buildAIList(report: MergedReport, fixedEntries: FixedEntryView[]): string {
const aiFixed = fixedEntries.filter(f => f.source === 'ai');
const fixedKeys = new Set(aiFixed.map(f => `${f.ruleId}@${f.line}`));
const remaining = report.aiFindings.filter(f => !fixedKeys.has(`${f.ruleId}@${f.line}`));
if (remaining.length === 0 && aiFixed.length === 0) {
return `<div class="empty">${t('report.noAIFindings')}</div>`;
}
const parts: string[] = [`<div class="section-header"><span class="section-header-title">${t('report.sourceAI')} · ${t('report.itemsCount', { 0: report.aiCount })}</span></div>`];
for (const f of report.aiFindings) {
const details: string[] = [];
const path = (f as { path?: string }).path;
if (path) {
details.push(`<div class="detail-text">🔗 ${esc(path)}</div>`);
const hasFixable = remaining.length > 0;
const fixAllBtn = hasFixable
? `<button class="btn" data-fix-all-btn onclick="send('fixAll', undefined, undefined, 'ai')">${t('report.fixAll')}</button>`
+ `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
+ `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
: '';
const parts: string[] = [`<div class="section-header"><span class="section-header-title">${t('report.sourceAI')} · ${t('report.itemsCount', { 0: remaining.length })}</span>${fixAllBtn}</div>`];
if (remaining.length === 0) {
parts.push(`<div class="empty">${t('report.noAIFindings')}</div>`);
} else {
for (const f of remaining) {
const details: string[] = [];
const path = (f as { path?: string }).path;
if (path) {
details.push(`<div class="detail-text">🔗 ${esc(path)}</div>`);
}
details.push(`<div class="detail-text">${esc(f.description)}</div>`);
if (f.category) {
details.push(`<span class="detail-category">🎯 ${esc(f.category)}</span>`);
}
if (f.suggestion) {
details.push(`<div class="detail-suggestion">💡 ${esc(f.suggestion)}</div>`);
}
parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, true, details.join('')));
}
details.push(`<div class="detail-text">${esc(f.description)}</div>`);
if (f.category) {
details.push(`<span class="detail-category">🎯 ${esc(f.category)}</span>`);
}
if (f.suggestion) {
details.push(`<div class="detail-suggestion">💡 ${esc(f.suggestion)}</div>`);
}
parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, details.join('')));
}
if (aiFixed.length > 0) {
parts.push(`<div class="section-header" style="padding-top:16px"><span class="section-header-title"> ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: aiFixed.length })}</span></div>`);
parts.push(aiFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'ai')).join(''));
}
return parts.join('');
}
@@ -386,6 +439,7 @@ ${errorBox}
source: string,
suggestion?: string,
fixable?: boolean,
aiFixable?: boolean,
detailHtml?: string,
expandable: boolean = true
): string {
@@ -403,7 +457,13 @@ ${errorBox}
parts.push(`<span class="item-message">${esc(message)}</span>`);
parts.push(`<span class="item-line" onclick="event.stopPropagation();send('navigate', ${line}, '${esc(ruleId)}', '${source}')">L${lineNum}</span>`);
if (fixable) {
parts.push(`<button class="item-fix" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🔧 ${t('report.fixLabel')}</button>`);
parts.push(`<button class="item-fix" data-fix-key="${esc(ruleId)}@${line}" data-label="🔧 ${esc(t('report.fixLabel'))}" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🔧 ${t('report.fixLabel')}</button>`);
parts.push(`<button class="item-fix btn-apply" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('applyFix', ${line}, '${esc(ruleId)}', '${source}')">✅ ${t('fix.apply')}</button>`);
parts.push(`<button class="item-fix btn-cancel" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('cancelFix', ${line}, '${esc(ruleId)}', '${source}')">✖ ${t('fix.cancel')}</button>`);
} else if (aiFixable) {
parts.push(`<button class="item-fix" data-fix-key="${esc(ruleId)}@${line}" data-label="🤖 ${esc(t('report.fixAILabel'))}" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🤖 ${t('report.fixAILabel')}</button>`);
parts.push(`<button class="item-fix btn-apply" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('applyFix', ${line}, '${esc(ruleId)}', '${source}')">✅ ${t('fix.apply')}</button>`);
parts.push(`<button class="item-fix btn-cancel" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('cancelFix', ${line}, '${esc(ruleId)}', '${source}')">✖ ${t('fix.cancel')}</button>`);
}
parts.push('</div>');
@@ -452,11 +512,23 @@ ${errorBox}
vscode.commands.executeCommand('codeReviewer.fixIssue', { ...message, origin: 'panel' });
break;
case 'fixAll':
vscode.commands.executeCommand('codeReviewer.fixAll');
vscode.commands.executeCommand('codeReviewer.fixAll', { source: message.source ?? 'linter' });
break;
case 'undo':
vscode.commands.executeCommand('codeReviewer.undoFix', message);
break;
case 'applyFix':
vscode.commands.executeCommand('codeReviewer.applyFixPreview', message);
break;
case 'cancelFix':
vscode.commands.executeCommand('codeReviewer.cancelFixPreview', message);
break;
case 'applyAll':
vscode.commands.executeCommand('codeReviewer.applyAllPreview');
break;
case 'cancelAll':
vscode.commands.executeCommand('codeReviewer.cancelAllPreview');
break;
}
}
-5
View File
@@ -86,11 +86,6 @@ export function reportToMarkdown(report: MergedReport): string {
if (finding.suggestion) {
lines.push(` ${t('report.suggestion')}: ${finding.suggestion}`);
}
if (finding.codeDiff) {
lines.push(' ```diff');
lines.push(` ${finding.codeDiff.split('\n').join('\n ')}`);
lines.push(' ```');
}
}
lines.push('');
}
+21
View File
@@ -16,3 +16,24 @@ function toggleItem(el) {
if (event.target.closest('.item-line')) { return; }
el.classList.toggle('expanded');
}
window.addEventListener('message', function(e) {
const msg = e.data;
if (!msg || typeof msg.type !== 'string') { return; }
if (msg.type === 'pending') {
const key = msg.key;
document.querySelectorAll('[data-fix-key="' + key + '"]').forEach(function(btn) {
const isConfirm = btn.classList.contains('btn-apply') || btn.classList.contains('btn-cancel');
btn.style.display = msg.on ? (isConfirm ? '' : 'none') : (isConfirm ? 'none' : '');
if (!msg.on && !isConfirm) {
btn.disabled = false;
if (btn.dataset.label) { btn.textContent = btn.dataset.label; }
}
});
} else if (msg.type === 'batchPending') {
document.querySelectorAll('[data-fix-all-btn]').forEach(function(btn) {
const isConfirm = btn.classList.contains('btn-apply-all') || btn.classList.contains('btn-cancel-all');
btn.style.display = msg.on ? (isConfirm ? '' : 'none') : (isConfirm ? 'none' : '');
});
}
});