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:
+433
-22
@@ -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
@@ -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
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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' : '');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user