feat: 自动修复重构与审查面板交互修复 + 静态分析 AI 翻译配对 + SQLFluff 方言显示

- 自动修复:废弃 AI 修复,改 linter 原生 fix 多轮收敛;CodeAction hover + 面板修复/全部修复 + 快照 diff 撤销;hover 修复不入「已修复」列表、重新审查清空;修复/撤销后自动保存;单条修复只修目标问题(区间重叠收敛,不再连带相邻同规则)
- 审查面板:内联 JS 外部化(reviewPanel.js)修复 CSP 屏蔽导致的修复按钮/行号跳转/tab 失效;面板操作不依赖文件焦点(resolveFixDocument);行号跳转定位已打开编辑器,不在面板列新开副本
- 静态分析:translatedDiagnostics 规则 ID 归一化配对 + 深度审查 prompt 强化,静态分析条目显示中文翻译与逐条 AI 建议
- 波浪线:诊断补 source/code,hover 显示快速修复链接
- SQLFluff:设置面板方言徽章(显式/全局/项目/内置来源配色)
This commit is contained in:
范智鹏
2026-08-18 21:41:51 +08:00
parent 3cf5c7165e
commit 3d8119d9c9
28 changed files with 1320 additions and 355 deletions
+171 -4
View File
@@ -13,10 +13,66 @@ 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 { FixSessionManager } from '../fix/fixSession';
import { getFixMaxIterations } from '../config';
import type { CustomRule } from '../types';
let currentReport: MergedReport | null = null;
function resolveFixDocument(
report: MergedReport | null,
active: vscode.TextEditor | undefined,
origin?: 'hover' | 'panel'
): vscode.TextDocument | undefined {
if (origin === 'hover') { return active?.document; }
if (report) {
return vscode.workspace.textDocuments.find(d => d.uri.fsPath === report.filePath);
}
return active?.document;
}
async function refreshAfterFix(
document: vscode.TextDocument,
orchestrator: Orchestrator,
markers: DiagnosticMarkers,
codeLensProvider: MethodCodeLensProvider,
extensionUri: vscode.Uri,
fixSession: FixSessionManager
): Promise<void> {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
const result = await orchestrator.runStaticAnalysis(document, workingDir);
if (isMarkersEnabled()) {
markers.apply(document.uri, result.diagnostics);
}
codeLensProvider.refresh();
if (currentReport && currentReport.filePath === document.uri.fsPath) {
currentReport = mergeResults({
staticDiagnostics: result.diagnostics,
customRuleResults: currentReport.customRuleDiagnostics.map(d => ({
ruleId: d.ruleId,
severity: d.severity,
message: d.message,
line: d.range.start.line + 1,
})),
translatedDiagnostics: currentReport.translatedDiagnostics,
aiFindings: currentReport.aiFindings,
errors: result.errors,
degraded: currentReport.degraded,
startTime: Date.now(),
filePath: document.uri.fsPath,
language: document.languageId,
adapterIds: result.adapterIds,
customRuleFilterInfo: currentReport.customRuleFilterInfo,
});
const panel = ReviewPanel.createOrShow(extensionUri);
panel.setFixSession(fixSession);
panel.update(currentReport);
}
}
async function openSetupPanel(): Promise<void> {
try {
await vscode.commands.executeCommand('workbench.view.extension.code-reviewer');
@@ -37,6 +93,7 @@ export function registerCommands(
codeLensProvider: MethodCodeLensProvider,
statusCache: ReviewStatusCache,
markers: DiagnosticMarkers,
fixSession: FixSessionManager,
): void {
context.subscriptions.push(
@@ -48,6 +105,7 @@ export function registerCommands(
}
const document = editor.document;
fixSession.clear(document.uri);
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
@@ -88,6 +146,7 @@ export function registerCommands(
});
const panel = ReviewPanel.createOrShow(context.extensionUri);
panel.setFixSession(fixSession);
panel.update(currentReport);
if (isMarkersEnabled()) {
@@ -186,6 +245,7 @@ export function registerCommands(
codeLensProvider.refresh();
const panel = ReviewPanel.createOrShow(context.extensionUri);
panel.setFixSession(fixSession);
panel.update(currentReport);
vscode.window.showInformationMessage(
@@ -200,6 +260,7 @@ export function registerCommands(
ReviewPanel.createOrShow(context.extensionUri);
if (currentReport) {
const panel = ReviewPanel.createOrShow(context.extensionUri);
panel.setFixSession(fixSession);
panel.update(currentReport);
}
})
@@ -249,14 +310,120 @@ export function registerCommands(
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.fixIssue', () => {
vscode.window.showInformationMessage(t('review.fixNotAvailable'));
vscode.commands.registerCommand('codeReviewer.fixIssue', async (payload?: { line?: number; ruleId?: string; source?: string; origin?: 'hover' | 'panel' }) => {
try {
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, payload?.origin);
if (!document) { console.log('[code-reviewer] fixIssue: no target document'); return; }
const cached = orchestrator.getAnalysisResult(document.uri);
if (!cached) { console.log(`[code-reviewer] fixIssue: no cached analysis for ${document.uri.toString()}`); return; }
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
const adapter = orchestrator.getAdapter(cached.adapterId);
if (!adapter) { console.log(`[code-reviewer] fixIssue: adapter not found: ${cached.adapterId}`); return; }
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)
) ?? cached.diagnostics.find(d => d.fix);
if (!diag) {
vscode.window.showWarningMessage(t('fix.noFix'));
return;
}
const maxIterations = getFixMaxIterations();
const result = await fixDiagnostic(document, workingDir, adapter, diag, maxIterations);
if (!result.success) {
vscode.window.showWarningMessage(t('fix.failed', { 0: result.message ?? '' }));
return;
}
if (payload?.origin !== 'hover') {
fixSession.recordFixes(document.uri, diag.ruleId, diag.range.start.line, result.appliedFixes);
}
await document.save();
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
vscode.window.showInformationMessage(t('fix.applied'));
} catch (err) {
console.error('[code-reviewer] fixIssue failed:', err);
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.fixAll', () => {
vscode.window.showInformationMessage(t('review.fixAllNotAvailable'));
vscode.commands.registerCommand('codeReviewer.fixAll', async () => {
try {
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
if (!document) { console.log('[code-reviewer] fixAll: no target document'); return; }
const cached = orchestrator.getAnalysisResult(document.uri);
if (!cached) { console.log(`[code-reviewer] fixAll: no cached analysis for ${document.uri.toString()}`); return; }
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
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);
if (fixables.length === 0) {
vscode.window.showInformationMessage(t('fix.noFix'));
return;
}
const maxIterations = getFixMaxIterations();
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 < 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);
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) }));
} catch (err) {
console.error('[code-reviewer] fixAll failed:', err);
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.undoFix', async (payload?: { line?: number; ruleId?: string; source?: string }) => {
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
if (!document) { console.log('[code-reviewer] undoFix: no target document'); return; }
const line = payload?.line ?? -1;
const ruleId = payload?.ruleId ?? '';
if (!ruleId) { return; }
const key = `${ruleId}@${line}`;
const ok = await fixSession.undo(document, key);
if (ok) {
await document.save();
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
vscode.window.showInformationMessage(t('fix.undone'));
} else {
vscode.window.showWarningMessage(t('fix.undoFailed'));
}
})
);
+1
View File
@@ -145,6 +145,7 @@ export class ESLintAdapter implements LinterAdapter {
(msg.endColumn ?? msg.column) - 1
),
suggestion: msg.fix?.text,
fix: msg.fix ? { range: [msg.fix.range[0], msg.fix.range[1]], text: msg.fix.text } : undefined,
});
}
}
+1 -47
View File
@@ -5,53 +5,7 @@ import { ESLintAdapter } from './eslint';
import { StylelintAdapter } from './stylelint';
import { extractJspSections, type JspSection } from '../jsp/jsp-extractor';
import { getLinterForLanguage } from '../config';
function mockDocument(code: string, language: string): vscode.TextDocument {
const lines = code.split('\n');
const uri = vscode.Uri.parse('untitled:virtual');
const ext = language === 'javascript' ? 'js' : language === 'typescript' ? 'ts' : language === 'css' ? 'css' : 'java';
return {
uri,
fileName: `untitled.${ext}`,
isUntitled: true,
languageId: language,
version: 1,
isDirty: false,
isClosed: false,
eol: vscode.EndOfLine.LF,
lineCount: lines.length,
getText: () => code,
lineAt: (arg: number | vscode.Position) => {
const line = typeof arg === 'number' ? arg : arg.line;
const text = lines[line] ?? '';
return {
lineNumber: line,
text,
range: new vscode.Range(line, 0, line, text.length),
rangeIncludingLineBreak: new vscode.Range(line, 0, line, text.length),
firstNonWhitespaceCharacterIndex: text.search(/\S|$/),
isEmptyOrWhitespace: text.trim().length === 0,
};
},
offsetAt: (p: vscode.Position) => {
let offset = 0;
for (let i = 0; i < p.line; i++) offset += lines[i].length + 1;
return offset + p.character;
},
positionAt: (offset: number) => {
let remaining = offset;
for (let i = 0; i < lines.length; i++) {
if (remaining <= lines[i].length) return new vscode.Position(i, remaining);
remaining -= lines[i].length + 1;
}
return new vscode.Position(lines.length - 1, lines[lines.length - 1].length);
},
getWordRangeAtPosition: () => undefined,
validateRange: (r: vscode.Range) => r,
validatePosition: (p: vscode.Position) => p,
save: () => Promise.resolve(false),
} as unknown as vscode.TextDocument;
}
import { mockDocument } from '../utils/mockDocument';
const WRAP_TEMPLATES: Record<NonNullable<JspSection['scriptletKind']>, {
header: string;
+51 -6
View File
@@ -14,7 +14,7 @@ const DIALECT_MAP: Record<string, string> = {
plsql: 'oracle',
};
const SUPPORTED_DIALECTS = [
export const SUPPORTED_DIALECTS = [
'ansi', 'athena', 'bigquery', 'clickhouse', 'databricks', 'db2', 'doris',
'duckdb', 'exasol', 'flink', 'greenplum', 'hive', 'impala', 'mariadb',
'materialize', 'mysql', 'oracle', 'postgres', 'redshift', 'snowflake',
@@ -51,7 +51,7 @@ export function buildPRSMessage(description: string, dialect: string): string {
return t('adapter.sqlfluffPRS', { 0: dialect, 1: fragment });
}
function hasProjectSqlfluffConfig(workspaceRoot: string): boolean {
function findProjectSqlFluffConfig(workspaceRoot: string): string | undefined {
const candidates: Array<{ file: string; marker: string | null }> = [
{ file: '.sqlfluff', marker: null },
{ file: 'setup.cfg', marker: '[sqlfluff]' },
@@ -62,11 +62,56 @@ function hasProjectSqlfluffConfig(workspaceRoot: string): boolean {
for (const candidate of candidates) {
const filePath = path.join(workspaceRoot, candidate.file);
if (!fs.existsSync(filePath)) { continue; }
if (candidate.marker === null) { return true; }
if (candidate.marker === null) { return filePath; }
const content = fs.readFileSync(filePath, 'utf-8');
if (content.includes(candidate.marker)) { return true; }
if (content.includes(candidate.marker)) { return filePath; }
}
return false;
return undefined;
}
function readDialectFromConfigFile(filePath: string): string | undefined {
try {
const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
let inSection = false;
for (const line of lines) {
const trimmed = line.trim();
if (/^\[(tool\.)?sqlfluff\]\s*$/.test(trimmed)) {
inSection = true;
continue;
}
if (!inSection) { continue; }
if (/^\[/.test(trimmed)) { break; }
const match = /^dialect\s*[:=]\s*"?([A-Za-z0-9_]+)"?/.exec(trimmed);
if (match) { return match[1]; }
}
} catch {}
return undefined;
}
export type SqlFluffDialectSource = 'explicit' | 'global' | 'project' | 'builtin';
export interface SqlFluffDialectInfo {
dialect: string;
source: SqlFluffDialectSource;
}
export function resolveSqlFluffDialect(workspaceRoot: string): SqlFluffDialectInfo {
const explicit = getSqlFluffDialect();
if (explicit && SUPPORTED_DIALECTS.includes(explicit)) {
return { dialect: explicit, source: 'explicit' };
}
const globalConfig = getSqlFluffConfigFile();
if (globalConfig && globalConfig.trim() !== '') {
return { dialect: readDialectFromConfigFile(globalConfig) ?? 'ansi', source: 'global' };
}
const projectConfig = findProjectSqlFluffConfig(workspaceRoot);
if (projectConfig) {
return { dialect: readDialectFromConfigFile(projectConfig) ?? 'ansi', source: 'project' };
}
return { dialect: 'oracle', source: 'builtin' };
}
interface SqlFluffViolation {
@@ -149,7 +194,7 @@ export class SqlFluffAdapter implements LinterAdapter {
const globalConfig = getSqlFluffConfigFile();
if (globalConfig && globalConfig.trim() !== '') {
configPath = globalConfig;
} else if (hasProjectSqlfluffConfig(workingDir)) {
} else if (findProjectSqlFluffConfig(workingDir)) {
} else {
tempConfigPath = path.join(os.tmpdir(), `vscode-code-reviewer-sqlfluff-${Date.now()}.cfg`);
fs.writeFileSync(tempConfigPath, buildBuiltinSqlfluffConfig(cliDialect ?? fallbackDialect), 'utf-8');
+2
View File
@@ -47,6 +47,7 @@ interface LinterResult {
rule: string;
severity: string;
text: string;
fix?: { range: [number, number]; text: string };
}>;
}>;
}
@@ -109,6 +110,7 @@ export class StylelintAdapter implements LinterAdapter {
(w.endLine ?? w.line) - 1,
(w.endColumn ?? w.column) - 1
),
fix: w.fix ? { range: [w.fix.range[0], w.fix.range[1]], text: w.fix.text } : undefined,
});
}
}
+18
View File
@@ -137,6 +137,12 @@ function buildDeepReviewSystemPrompt(): string {
重点分野:セキュリティ脆弱性、論理エラー、パフォーマンス問題、設計欠陥
静的解析ですでに報告された問題を重複しないでください。
translatedDiagnosticsの要件:
- 下記の「静的解析結果」に列挙された各診断に対して1件ずつ翻訳を返してください。件数と順序を一致させ、欠落させないでください
- "originalRuleId" はリスト内のルールID(eslint: 等のプレフィックスを含む)をそのままコピーし、書き換えないでください
- "translatedMessage" と "translatedSuggestion" は両方必須で、空にしないでください
- "translatedSuggestion" は具体的で実行可能な修正提案(例:この書き方に置き換える)を示してください
JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。
形式:
{
@@ -153,6 +159,12 @@ JSONのみを出力。文字列内の二重引用符は \\" でエスケープ
Focus on: security vulnerabilities, logic errors, performance issues, design flaws
Do not duplicate issues already reported by static analysis.
translatedDiagnostics requirements:
- Return exactly one translation for every diagnostic listed in "Static Analysis Results", same count and order, do not omit any
- "originalRuleId" must be copied verbatim from the listed rule IDs (keep prefixes like eslint:), do not rewrite
- "translatedMessage" and "translatedSuggestion" are both required and must not be empty
- "translatedSuggestion" should be a concrete actionable fix suggestion (e.g. what to replace it with), not just a replacement snippet
Output JSON only. Double quotes in strings must be escaped with \\".
Format:
{
@@ -168,6 +180,12 @@ Output language: en`;
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
不要重复静态分析已报告的问题。
translatedDiagnostics 要求:
- 必须为"静态分析结果"中列出的每一条诊断都返回一条翻译,条数与顺序一致,不得遗漏
- "originalRuleId" 必须原样复制列表中的规则 ID(保留 eslint: 等前缀),不得改写
- "translatedMessage" 与 "translatedSuggestion" 均为必填字段,不得为空
- "translatedSuggestion" 给出具体可执行的修复建议(如应替换成什么写法),不要只给替换片段
仅输出 JSON,字符串中的双引号必须用 \\" 转义。
格式:
{
+2 -2
View File
@@ -2,6 +2,6 @@ import * as vscode from 'vscode';
const ROOT = 'vscode-code-reviewer';
export function getContextLines(): number {
return vscode.workspace.getConfiguration(ROOT).get<number>('fixer.contextLines', 5);
export function getFixMaxIterations(): number {
return vscode.workspace.getConfiguration(ROOT).get<number>('fixer.maxIterations', 3);
}
+4 -1
View File
@@ -25,7 +25,10 @@ export function toVscodeDiagnostics(diagnostics: LinterDiagnostic[]): vscode.Dia
: d.severity === 'warning'
? vscode.DiagnosticSeverity.Warning
: vscode.DiagnosticSeverity.Information;
return new vscode.Diagnostic(d.range, formatDiagnosticMessage(d), severity);
const diag = new vscode.Diagnostic(d.range, formatDiagnosticMessage(d), severity);
diag.source = PLUGIN_NAME;
diag.code = d.ruleId;
return diag;
});
}
+14 -1
View File
@@ -7,6 +7,8 @@ import { getAIOutputLanguage } from './config';
import { ReviewStatusCache } from './scope/status-cache';
import { MethodCodeLensProvider } from './views/codeLensProvider';
import { DiagnosticMarkers, isMarkersEnabled } from './diagnostics/diagnosticMarkers';
import { FixCodeActionProvider } from './fix/codeActionProvider';
import { FixSessionManager } from './fix/fixSession';
let orchestrator: Orchestrator;
let markers: DiagnosticMarkers;
@@ -68,6 +70,16 @@ export function activate(context: vscode.ExtensionContext) {
const statusCache = new ReviewStatusCache();
const codeLensProvider = new MethodCodeLensProvider(statusCache);
const fixSession = new FixSessionManager();
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider(
{ scheme: 'file' },
new FixCodeActionProvider(orchestrator),
{ providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }
)
);
void analyzeOpenDocuments();
context.subscriptions.push(
@@ -81,6 +93,7 @@ export function activate(context: vscode.ExtensionContext) {
vscode.workspace.onDidCloseTextDocument((document) => {
statusCache.clearDocument(document.uri);
markers.clear(document.uri);
fixSession.clear(document.uri);
})
);
@@ -98,7 +111,7 @@ export function activate(context: vscode.ExtensionContext) {
})
);
registerCommands(context, orchestrator, codeLensProvider, statusCache, markers);
registerCommands(context, orchestrator, codeLensProvider, statusCache, markers, fixSession);
context.subscriptions.push(
vscode.workspace.onDidSaveTextDocument((document) => {
+43
View File
@@ -0,0 +1,43 @@
import * as vscode from 'vscode';
import type { Orchestrator } from '../orchestrator/orchestrator';
export class FixCodeActionProvider implements vscode.CodeActionProvider {
constructor(private orchestrator: Orchestrator) {}
provideCodeActions(
document: vscode.TextDocument,
_range: vscode.Range,
context: vscode.CodeActionContext,
_token: vscode.CancellationToken
): vscode.CodeAction[] {
const cached = this.orchestrator.getAnalysisResult(document.uri);
if (!cached) { return []; }
const actions: vscode.CodeAction[] = [];
for (const diag of cached.diagnostics) {
if (!diag.fix) { continue; }
const overlapsContext = context.diagnostics.some(d =>
diag.range.intersection(d.range)
);
if (!overlapsContext) { continue; }
const action = new vscode.CodeAction(
`Code Purifier: 修复 ${diag.ruleId}`,
vscode.CodeActionKind.QuickFix
);
action.command = {
command: 'codeReviewer.fixIssue',
title: '修复',
arguments: [{
line: diag.range.start.line,
ruleId: diag.ruleId,
source: 'linter',
origin: 'hover',
}],
};
action.diagnostics = [...context.diagnostics];
actions.push(action);
}
return actions;
}
}
+135
View File
@@ -0,0 +1,135 @@
import * as vscode from 'vscode';
import type { LinterAdapter, LinterDiagnostic } from '../types';
import { mockDocument } from '../utils/mockDocument';
export interface AppliedFix {
originalText: string;
newText: string;
line: number;
}
export interface FixResult {
success: boolean;
attempts: number;
message?: string;
appliedFixes: AppliedFix[];
}
function applyFixToText(text: string, fix: { range: [number, number]; text: string }): string {
const [start, end] = fix.range;
if (start < 0 || end < start || end > text.length) { return text; }
return text.slice(0, start) + fix.text + text.slice(end);
}
function findClosestFixable(
diagnostics: LinterDiagnostic[],
ruleId: string,
line: number
): LinterDiagnostic | null {
let best: LinterDiagnostic | null = null;
let bestDist = Number.MAX_SAFE_INTEGER;
for (const d of diagnostics) {
if (d.ruleId !== ruleId || !d.fix) { continue; }
const dist = Math.abs(d.range.start.line - line);
if (dist < bestDist) {
bestDist = dist;
best = d;
}
}
return best;
}
function issueStillExists(
diagnostics: LinterDiagnostic[],
ruleId: string,
fixedStart: number,
fixedEnd: number
): boolean {
for (const d of diagnostics) {
if (d.ruleId !== ruleId || !d.fix) { continue; }
const [s, e] = d.fix.range;
if (s < fixedEnd && e > fixedStart) { return true; }
}
return false;
}
export async function fixDiagnostic(
document: vscode.TextDocument,
workingDir: string,
adapter: LinterAdapter,
diag: LinterDiagnostic,
maxIterations: number
): Promise<FixResult> {
const originalText = document.getText();
let currentText = originalText;
let prevLine = diag.range.start.line;
let converged = false;
const appliedFixes: AppliedFix[] = [];
for (let round = 1; round <= maxIterations; round++) {
let result;
try {
const mock = mockDocument(currentText, document.languageId, document.fileName);
result = await adapter.check(mock, workingDir);
} catch {
return { success: false, attempts: round, message: 'lint-execution-failed', appliedFixes };
}
const target = findClosestFixable(result.diagnostics, diag.ruleId, prevLine);
if (!target) {
return { success: false, attempts: round, message: 'not-autofixable', appliedFixes };
}
const fix = target.fix!;
const [start, end] = fix.range;
const originalFragment = currentText.slice(start, end);
const nextText = applyFixToText(currentText, fix);
if (nextText === currentText) {
return { success: false, attempts: round, message: 'no-change', appliedFixes };
}
appliedFixes.push({
originalText: originalFragment,
newText: fix.text,
line: target.range.start.line,
});
currentText = nextText;
prevLine = target.range.start.line;
const fixedStart = start;
const fixedEnd = start + fix.text.length;
let verify;
try {
verify = await adapter.check(mockDocument(currentText, document.languageId, document.fileName), workingDir);
} catch {
converged = false;
break;
}
if (!issueStillExists(verify.diagnostics, diag.ruleId, fixedStart, fixedEnd)) {
converged = true;
break;
}
}
if (!converged) {
return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
}
if (currentText === originalText) {
return { success: true, attempts: 0, appliedFixes };
}
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 };
}
+131
View File
@@ -0,0 +1,131 @@
import * as vscode from 'vscode';
import type { AppliedFix } from './fixEngine';
export interface FixedEntry {
key: string;
ruleId: string;
line: number;
fixes: AppliedFix[];
source: 'linter';
}
function keyOf(ruleId: string, line: number): string {
return `${ruleId}@${line}`;
}
interface LocatedEdit {
start: number;
end: number;
text: string;
}
function locateNewText(document: vscode.TextDocument, fix: AppliedFix): number {
const text = document.getText();
const lines = text.split('\n');
let index = -1;
if (fix.newText !== '') {
const firstLineOfNew = fix.newText.split('\n')[0];
if (fix.line >= 0 && fix.line < lines.length && lines[fix.line].includes(firstLineOfNew)) {
const offset = document.offsetAt(new vscode.Position(fix.line, 0));
index = text.indexOf(fix.newText, offset);
}
if (index === -1) {
index = text.indexOf(fix.newText);
}
return index;
}
if (fix.line >= 0 && fix.line < lines.length) {
const offset = document.offsetAt(new vscode.Position(fix.line, 0));
const lineEnd = text.indexOf('\n', offset);
const end = lineEnd === -1 ? text.length : lineEnd;
index = offset + lines[fix.line].search(/\S|$/);
if (index > end) { index = offset; }
}
return index;
}
export class FixSessionManager {
private fixedEntries = new Map<string, FixedEntry>();
add(uri: vscode.Uri, entry: FixedEntry): void {
this.fixedEntries.set(uri.toString() + '|' + entry.key, entry);
}
get(uri: vscode.Uri, key: string): FixedEntry | undefined {
return this.fixedEntries.get(uri.toString() + '|' + key);
}
has(uri: vscode.Uri, key: string): boolean {
return this.fixedEntries.has(uri.toString() + '|' + key);
}
getEntries(uri: vscode.Uri): FixedEntry[] {
const prefix = uri.toString() + '|';
const result: FixedEntry[] = [];
for (const [k, v] of this.fixedEntries) {
if (k.startsWith(prefix)) { result.push(v); }
}
return result;
}
clear(uri: vscode.Uri): void {
const prefix = uri.toString() + '|';
for (const k of this.fixedEntries.keys()) {
if (k.startsWith(prefix)) { this.fixedEntries.delete(k); }
}
}
recordFixes(uri: vscode.Uri, ruleId: string, line: number, fixes: AppliedFix[]): string {
const key = keyOf(ruleId, line);
const fullKey = uri.toString() + '|' + key;
const existing = this.fixedEntries.get(fullKey);
if (existing) {
existing.fixes.push(...fixes);
} else {
this.fixedEntries.set(fullKey, {
key,
ruleId,
line,
fixes: [...fixes],
source: 'linter',
});
}
return key;
}
async undo(document: vscode.TextDocument, key: string): Promise<boolean> {
const entry = this.fixedEntries.get(document.uri.toString() + '|' + key);
if (!entry || entry.fixes.length === 0) { return false; }
const edits: LocatedEdit[] = [];
for (let i = entry.fixes.length - 1; i >= 0; i--) {
const fix = entry.fixes[i];
const index = locateNewText(document, fix);
if (index === -1) { return false; }
edits.push({
start: index,
end: index + fix.newText.length,
text: fix.originalText,
});
}
const workspaceEdit = new vscode.WorkspaceEdit();
for (const e of edits) {
workspaceEdit.replace(
document.uri,
new vscode.Range(
document.positionAt(e.start),
document.positionAt(e.end)
),
e.text
);
}
const applied = await vscode.workspace.applyEdit(workspaceEdit);
if (applied) {
this.fixedEntries.delete(document.uri.toString() + '|' + key);
}
return applied;
}
}
-227
View File
@@ -1,227 +0,0 @@
import * as vscode from 'vscode';
import type { LinterDiagnostic } from '../types';
import type { AIProvider } from '../ai/providers/base';
import { getAIMaxTokens } from '../config';
export type FixCategory = 'naming' | 'style' | 'bug' | 'security' | 'performance';
export interface FixableDiagnostic {
ruleId: string;
message: string;
line: number;
severity: string;
codeContext: string;
source: 'linter' | 'custom';
category: FixCategory;
}
export interface CodeFix {
startLine: number;
endLine: number;
originalText: string;
newText: string;
matched: boolean;
actualRange?: vscode.Range;
}
const FIX_SYSTEM_PROMPT = `你是代码修复专家。根据提供的问题和代码上下文,输出修复后的代码。
仅输出 JSON{ "originalText": "需要替换的原文", "newText": "修复后的新代码" }`;
function detectCategory(diagnostic: LinterDiagnostic): FixCategory {
if (diagnostic.ruleId.includes('naming') || diagnostic.ruleId.includes('Name')) { return 'naming'; }
if (diagnostic.ruleId.includes('security') || diagnostic.ruleId.includes('injection') || diagnostic.ruleId.includes('secret')) { return 'security'; }
if (diagnostic.ruleId.includes('perf')) { return 'performance'; }
return 'style';
}
function getContextRange(document: vscode.TextDocument, line: number, category: FixCategory): { startLine: number; endLine: number } {
switch (category) {
case 'naming':
return {
startLine: Math.max(0, line - 2),
endLine: Math.min(document.lineCount - 1, line + 2),
};
case 'style':
return {
startLine: Math.max(0, line - 5),
endLine: Math.min(document.lineCount - 1, line + 5),
};
case 'bug':
case 'security':
case 'performance': {
const funcRange = findEnclosingFunction(document, line);
return {
startLine: funcRange?.start.line ?? Math.max(0, line - 10),
endLine: funcRange?.end.line ?? Math.min(document.lineCount - 1, line + 10),
};
}
default:
return {
startLine: Math.max(0, line - 5),
endLine: Math.min(document.lineCount - 1, line + 5),
};
}
}
function findEnclosingFunction(document: vscode.TextDocument, line: number): { start: vscode.Position; end: vscode.Position } | null {
const text = document.getText();
const lines = text.split('\n');
let braceDepth = 0;
let funcStart = line;
let funcEnd = line;
for (let i = line; i >= 0; i--) {
const l = lines[i];
braceDepth += (l.match(/\}/g) || []).length;
braceDepth -= (l.match(/\{/g) || []).length;
const isFunctionLine = /\b(function|def|class|method|public|private|protected|void|int|String|boolean|var|let|const|async)\s/.test(l);
if (braceDepth < 0 && isFunctionLine) {
funcStart = i;
break;
}
}
braceDepth = 0;
for (let i = funcStart; i < lines.length; i++) {
const l = lines[i];
braceDepth += (l.match(/\{/g) || []).length;
braceDepth -= (l.match(/\}/g) || []).length;
if (braceDepth === 0 && (l.match(/\{/g) || []).length > 0) {
funcEnd = i;
break;
}
}
return {
start: new vscode.Position(funcStart, 0),
end: new vscode.Position(funcEnd, lines[funcEnd]?.length ?? 0),
};
}
function extractLines(document: vscode.TextDocument, startLine: number, endLine: number): string {
const lines: string[] = [];
for (let i = startLine; i <= endLine; i++) {
const lineText = document.lineAt(i).text;
lines.push(`${String(i + 1).padStart(4, ' ')}| ${lineText}`);
}
return lines.join('\n');
}
export function prepareContext(document: vscode.TextDocument, diagnostic: LinterDiagnostic, source: 'linter' | 'custom'): FixableDiagnostic | null {
const line = diagnostic.range.start.line;
const category = detectCategory(diagnostic);
const { startLine, endLine } = getContextRange(document, line, category);
const codeContext = extractLines(document, startLine, endLine);
return {
ruleId: diagnostic.ruleId,
message: diagnostic.message,
line,
severity: diagnostic.severity,
codeContext,
source,
category,
};
}
export async function generateFix(
provider: AIProvider,
model: string,
temperature: number,
timeoutMs: number,
diagnostic: FixableDiagnostic
): Promise<CodeFix | null> {
const userPrompt = `问题: [${diagnostic.ruleId}] ${diagnostic.message}\n代码上下文:\n${diagnostic.codeContext}`;
try {
const response = await provider.chat(FIX_SYSTEM_PROMPT, userPrompt, {
model,
temperature,
maxTokens: getAIMaxTokens(),
timeoutMs,
});
const trimmed = response.trim();
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start === -1 || end === -1) { return null; }
const parsed = JSON.parse(trimmed.substring(start, end + 1));
return {
startLine: diagnostic.line,
endLine: diagnostic.line,
originalText: parsed.originalText ?? '',
newText: parsed.newText ?? '',
matched: false,
};
} catch {
return null;
}
}
export function matchAndValidate(document: vscode.TextDocument, fix: CodeFix): { matched: boolean; actualRange?: vscode.Range } {
const lineContent = document.lineAt(fix.startLine).text;
if (lineContent === fix.originalText.split('\n')[0]) {
const range = new vscode.Range(fix.startLine, 0, fix.endLine, document.lineAt(fix.endLine).text.length);
if (document.getText(range) === fix.originalText) {
return { matched: true, actualRange: range };
}
}
const index = document.getText().indexOf(fix.originalText);
if (index !== -1) {
return {
matched: true,
actualRange: new vscode.Range(
document.positionAt(index),
document.positionAt(index + fix.originalText.length)
),
};
}
return { matched: false };
}
export async function applySingleFix(editor: vscode.TextEditor, fix: CodeFix): Promise<boolean> {
if (!fix.actualRange || !fix.matched) { return false; }
return editor.edit(editBuilder => {
editBuilder.replace(fix.actualRange!, fix.newText);
});
}
const snapshotStack: Map<string, string[]> = new Map();
export function saveSnapshot(document: vscode.TextDocument): void {
const filePath = document.uri.fsPath;
if (!snapshotStack.has(filePath)) { snapshotStack.set(filePath, []); }
snapshotStack.get(filePath)!.push(document.getText());
}
export async function undoLastFix(document: vscode.TextDocument): Promise<boolean> {
const stack = snapshotStack.get(document.uri.fsPath);
if (!stack || stack.length === 0) { return false; }
const previousContent = stack.pop()!;
const edit = new vscode.WorkspaceEdit();
edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), previousContent);
return vscode.workspace.applyEdit(edit);
}
export function hasSnapshot(document: vscode.TextDocument): boolean {
const stack = snapshotStack.get(document.uri.fsPath);
return !!(stack && stack.length > 0);
}
export async function applyBatchFixes(document: vscode.TextDocument, fixes: CodeFix[]): Promise<number> {
saveSnapshot(document);
const validFixes = fixes.filter(f => f.matched);
const sorted = [...validFixes].sort((a, b) => b.startLine - a.startLine);
const editor = vscode.window.activeTextEditor;
if (!editor || editor.document.uri.toString() !== document.uri.toString()) { return 0; }
let applied = 0;
for (const fix of sorted) {
if (await applySingleFix(editor, fix)) { applied++; }
}
return applied;
}
+63 -8
View File
@@ -45,15 +45,45 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Selection review complete: {0} issue(s)',
ja: '選択コードのレビュー完了: {0} 件の問題',
},
'review.fixNotAvailable': {
'zh-CN': '单条修复功能开发中',
en: 'Single fix is under development',
ja: '単一修正機能は開発中です',
'fix.noFix': {
'zh-CN': '该问题无法自动修复',
en: 'This issue cannot be auto-fixed',
ja: 'この問題は自動修正できません',
},
'review.fixAllNotAvailable': {
'zh-CN': '批量修复功能开发中',
en: 'Batch fix is under development',
ja: '一括修正機能は開発中です',
'fix.failed': {
'zh-CN': '修复失败: {0}',
en: 'Fix failed: {0}',
ja: '修正に失敗しました: {0}',
},
'fix.applied': {
'zh-CN': '修复完成',
en: 'Fix applied',
ja: '修正を適用しました',
},
'fix.running': {
'zh-CN': '批量修复中...',
en: 'Fixing all issues...',
ja: '一括修正中...',
},
'fix.progress': {
'zh-CN': '修复进度',
en: 'Fix progress',
ja: '修正進捗',
},
'fix.allComplete': {
'zh-CN': '批量修复完成:成功 {0},跳过 {1}',
en: 'Batch fix complete: {0} fixed, {1} skipped',
ja: '一括修正完了: {0} 成功、{1} スキップ',
},
'fix.undone': {
'zh-CN': '已撤销修复',
en: 'Fix undone',
ja: '修正を取り消しました',
},
'fix.undoFailed': {
'zh-CN': '撤销失败,代码可能已被手动修改',
en: 'Undo failed, the code may have been modified manually',
ja: '取り消しに失敗しました。コードが手動で変更された可能性があります',
},
'export.needRunFirst': {
@@ -427,6 +457,11 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Languages:',
ja: '対応言語:',
},
'setup.adapter.sqlfluffDialectLabel': {
'zh-CN': '方言',
en: 'Dialect',
ja: '方言',
},
'setup.adapter.tooltipTab': {
'zh-CN': '点击展开/收起静态分析适配器',
en: 'Click to expand/collapse static analysis adapters',
@@ -798,6 +833,26 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Fix All',
ja: 'すべて修正',
},
'report.fixLabel': {
'zh-CN': '修复',
en: 'Fix',
ja: '修正',
},
'report.fixedIssues': {
'zh-CN': '已修复',
en: 'Fixed',
ja: '修正済み',
},
'report.fixedLabel': {
'zh-CN': '已修复',
en: 'Fixed',
ja: '修正済み',
},
'report.undoFix': {
'zh-CN': '撤销',
en: 'Undo',
ja: '取り消し',
},
'report.rerun': {
'zh-CN': '重新审查',
en: 'Re-run Review',
+35 -11
View File
@@ -47,6 +47,35 @@ interface MergeInput {
const SEVERITY_RANK: Record<string, number> = { error: 0, warning: 1, info: 2 };
const RULE_NAMESPACE_PREFIXES = ['eslint:', 'stylelint:', 'sqlfluff:', 'pmd:', 'custom:', 'method:'];
function normalizeRuleId(id: string): string {
let result = id.trim();
for (const p of RULE_NAMESPACE_PREFIXES) {
if (result.startsWith(p)) {
result = result.slice(p.length);
break;
}
}
const segments = result.split(/[:/]/);
return segments[segments.length - 1];
}
function findTranslation(pool: TranslatedDiagnostic[], ruleId: string): TranslatedDiagnostic | undefined {
for (let i = 0; i < pool.length; i++) {
if (pool[i].originalRuleId === ruleId) {
return pool.splice(i, 1)[0];
}
}
const norm = normalizeRuleId(ruleId);
for (let i = 0; i < pool.length; i++) {
if (normalizeRuleId(pool[i].originalRuleId) === norm) {
return pool.splice(i, 1)[0];
}
}
return undefined;
}
function sortBySeverityAndLine<T extends { severity: string }>(items: T[], lineOf: (item: T) => number): T[] {
return [...items].sort((a, b) => {
const rankDiff = (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3);
@@ -66,17 +95,11 @@ export function mergeResults(input: MergeInput): MergedReport {
d => d.range.start.line
);
const translationsByRule = new Map<string, TranslatedDiagnostic[]>();
for (const td of input.translatedDiagnostics) {
const list = translationsByRule.get(td.originalRuleId) ?? [];
list.push(td);
translationsByRule.set(td.originalRuleId, list);
}
const translationPool = [...input.translatedDiagnostics];
const linterDiagnostics = sortBySeverityAndLine(
input.staticDiagnostics.map(d => {
const list = translationsByRule.get(d.ruleId);
const td = list?.shift();
const td = findTranslation(translationPool, d.ruleId);
if (td) {
return { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion };
}
@@ -94,10 +117,11 @@ export function mergeResults(input: MergeInput): MergedReport {
const customRuleCount = customRuleDiagnostics.length;
const aiCount = aiFindings.length;
const fixableLinterIndices = linterDiagnostics.map((_, i) => i);
const fixableLinterIndices = linterDiagnostics
.map((d, i) => (d.fix ? i : -1))
.filter(i => i !== -1);
const fixableCustomIndices = customRuleDiagnostics
.map((_, i) => i);
const fixableCustomIndices: number[] = [];
return {
linterDiagnostics,
+29
View File
@@ -14,8 +14,15 @@ export interface StaticAnalysisResult {
duration: number;
}
export interface CachedAnalysis {
diagnostics: LinterDiagnostic[];
adapterId: string;
workingDir: string;
}
export class Orchestrator {
private adapters: LinterAdapter[];
private analysisCache = new Map<string, CachedAnalysis>();
constructor() {
// 单语言单 linter 设计:按 linters.<language> 单选配置分派一个适配器;
@@ -66,6 +73,12 @@ export class Orchestrator {
errors.push(`[${adapter.id}] ${result.errorMessage ?? result.status}`);
}
this.analysisCache.set(document.uri.toString(), {
diagnostics: result.diagnostics,
adapterId: adapter.id,
workingDir,
});
return {
diagnostics: result.diagnostics,
errors,
@@ -73,4 +86,20 @@ export class Orchestrator {
duration: Date.now() - startTime,
};
}
setAnalysisResult(uri: vscode.Uri, result: CachedAnalysis): void {
this.analysisCache.set(uri.toString(), result);
}
getAnalysisResult(uri: vscode.Uri): CachedAnalysis | undefined {
return this.analysisCache.get(uri.toString());
}
clearAnalysisResult(uri: vscode.Uri): void {
this.analysisCache.delete(uri.toString());
}
getAdapter(adapterId: string): LinterAdapter | undefined {
return this.adapters.find(a => a.id === adapterId);
}
}
+82 -42
View File
@@ -1,12 +1,14 @@
import * as vscode from 'vscode';
import { MergedReport } from '../merger/merger';
import { t, onLanguageChange, getLanguage } from '../i18n/messages';
import type { FixSessionManager } from '../fix/fixSession';
interface PanelMessage {
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll';
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo';
line?: number;
ruleId?: string;
source?: 'linter' | 'custom' | 'ai';
origin?: 'hover' | 'panel';
}
function esc(str: string): string {
@@ -46,6 +48,8 @@ export class ReviewPanel {
private readonly panel: vscode.WebviewPanel;
private disposables: vscode.Disposable[] = [];
private currentReport: MergedReport | null = null;
private fixSession: FixSessionManager | null = null;
private readonly scriptUri: vscode.Uri;
private constructor(
private readonly extensionUri: vscode.Uri,
@@ -58,10 +62,14 @@ export class ReviewPanel {
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [],
localResourceRoots: [this.extensionUri],
}
);
this.scriptUri = this.panel.webview.asWebviewUri(
vscode.Uri.joinPath(this.extensionUri, 'out', 'webview', 'reviewPanel.js')
);
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
this.panel.webview.onDidReceiveMessage(
@@ -95,6 +103,13 @@ export class ReviewPanel {
this.panel.webview.html = this.buildHtml(report);
}
setFixSession(session: FixSessionManager): void {
this.fixSession = session;
if (this.currentReport) {
this.panel.webview.html = this.buildHtml(this.currentReport);
}
}
private buildHtml(report: MergedReport): string {
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
@@ -126,6 +141,8 @@ export class ReviewPanel {
const fixableLinterSet = new Set(report.fixableLinterIndices);
const fixableCustomSet = new Set(report.fixableCustomIndices);
const fixedEntries = this.fixSession?.getEntries(vscode.Uri.file(report.filePath)) ?? [];
const tabCount = (e: number, w: number, i: number) => {
const pts: string[] = [];
if (e > 0) { pts.push(`<span class="tab-count tab-count-error">${e}</span>`); }
@@ -218,6 +235,12 @@ export class ReviewPanel {
.item-fix { flex-shrink: 0; padding: 2px 8px; border: 1px solid var(--vscode-panel-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border-radius: 4px; cursor: pointer; font-size: 11px; transition: background .15s; line-height: 18px; }
.item-fix:hover { background: var(--vscode-button-secondaryHoverBackground); }
.item-fix:disabled { opacity: .4; cursor: not-allowed; }
.item-fixed { border-color: rgba(87,171,90,0.4); background: rgba(87,171,90,0.08); }
.item-fixed:hover { border-color: rgba(87,171,90,0.6); }
.item-severity-fixed { background: #57ab5a; }
.icon-fixed { background: #57ab5a; }
.item-undo { flex-shrink: 0; padding: 2px 8px; border: 1px solid rgba(87,171,90,0.5); background: rgba(87,171,90,0.15); color: #57ab5a; border-radius: 4px; cursor: pointer; font-size: 11px; line-height: 18px; }
.item-undo:hover { background: rgba(87,171,90,0.25); }
.item-detail { display: none; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--vscode-panel-border); }
.item.expanded .item-detail { display: block; animation: fadeSlideIn .2s ease; }
@@ -263,10 +286,10 @@ ${errorBox}
</div>
<div class="tab-content active" id="tab-linter">
${this.buildLinterList(report, fixableLinterSet)}
${this.buildLinterList(report, fixableLinterSet, fixedEntries)}
</div>
<div class="tab-content" id="tab-custom">
${this.buildCustomList(report, fixableCustomSet)}
${this.buildCustomList(report)}
</div>
<div class="tab-content" id="tab-ai">
${this.buildAIList(report)}
@@ -278,38 +301,46 @@ ${errorBox}
</div>
</div>
<script>
const vscode = acquireVsCodeApi();
function send(type, line, ruleId, source) {
vscode.postMessage({ type, line, ruleId, source });
}
function switchTab(tabId) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(tc) { tc.classList.remove('active'); });
document.querySelector('.tab[data-tab="' + tabId + '"]').classList.add('active');
document.getElementById('tab-' + tabId).classList.add('active');
}
function toggleItem(el) {
if (event.target.closest('button')) return;
if (event.target.closest('.item-line')) return;
el.classList.toggle('expanded');
}
</script>
<script src="${this.scriptUri}"></script>
</body>
</html>`;
}
private buildLinterList(report: MergedReport, fixableSet: Set<number>): string {
if (report.linterDiagnostics.length === 0) {
private buildLinterList(report: MergedReport, fixableSet: Set<number>, fixedEntries: Array<{ ruleId: string; line: number; key: string }>): 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;
return `<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>`
+ report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i))).join('');
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>`;
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('');
}
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('');
}
return html;
}
private buildCustomList(report: MergedReport, fixableSet: Set<number>): string {
private buildFixedItem(ruleId: string, line: number, key: string): 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>
<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>
</div>
</div>
</div>`;
}
private buildCustomList(report: MergedReport): string {
const filterInfo = report.customRuleFilterInfo;
if (filterInfo?.skippedRequestA) {
return `<div class="empty">${t('report.skipCustomRules')}</div>`;
@@ -317,19 +348,18 @@ ${errorBox}
if (report.customRuleDiagnostics.length === 0) {
return `<div class="empty">${t('report.noRuleViolations')}</div>`;
}
const hasFixable = fixableSet.size > 0;
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>${hasFixable ? `<button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button>` : ''}</div>`
+ report.customRuleDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, fixableSet.has(i))).join('');
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('');
}
private buildAIList(report: MergedReport): string {
if (report.aiFindings.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><button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button></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;
@@ -343,7 +373,7 @@ ${errorBox}
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, true, details.join('')));
parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, details.join('')));
}
return parts.join('');
}
@@ -373,7 +403,7 @@ ${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.fixAll')}</button>`);
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('</div>');
@@ -392,17 +422,24 @@ ${errorBox}
return parts.join('');
}
private handleMessage(message: PanelMessage): void {
private async handleMessage(message: PanelMessage): Promise<void> {
switch (message.type) {
case 'navigate':
if (message.line !== undefined) {
const editor = vscode.window.activeTextEditor;
if (editor) {
const line = Math.max(0, message.line);
const range = new vscode.Range(line, 0, line, 0);
editor.selection = new vscode.Selection(range.start, range.end);
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
}
if (message.line !== undefined && this.currentReport) {
const report = this.currentReport;
const uri = vscode.Uri.file(report.filePath);
const existing = vscode.window.visibleTextEditors.find(
e => e.document.uri.fsPath === report.filePath
);
const showOptions: vscode.TextDocumentShowOptions =
existing && existing.viewColumn !== undefined
? { viewColumn: existing.viewColumn }
: { preview: true };
const editor = await vscode.window.showTextDocument(uri, showOptions);
const line = Math.max(0, message.line);
const range = new vscode.Range(line, 0, line, 0);
editor.selection = new vscode.Selection(range.start, range.end);
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
}
break;
case 'rerun':
@@ -412,11 +449,14 @@ ${errorBox}
vscode.commands.executeCommand('codeReviewer.exportReport');
break;
case 'fix':
vscode.commands.executeCommand('codeReviewer.fixIssue', message);
vscode.commands.executeCommand('codeReviewer.fixIssue', { ...message, origin: 'panel' });
break;
case 'fixAll':
vscode.commands.executeCommand('codeReviewer.fixAll');
break;
case 'undo':
vscode.commands.executeCommand('codeReviewer.undoFix', message);
break;
}
}
+6
View File
@@ -13,12 +13,18 @@ export type Severity = 'error' | 'warning' | 'info';
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
export interface LinterFix {
range: [number, number];
text: string;
}
export interface LinterDiagnostic {
severity: Severity;
ruleId: string;
message: string;
range: vscode.Range;
suggestion?: string;
fix?: LinterFix;
}
export interface AdapterResult {
+48
View File
@@ -0,0 +1,48 @@
import * as vscode from 'vscode';
export function mockDocument(code: string, language: string, fileName?: string): vscode.TextDocument {
const lines = code.split('\n');
const uri = vscode.Uri.parse('untitled:virtual');
const ext = language === 'javascript' ? 'js' : language === 'typescript' ? 'ts' : language === 'css' ? 'css' : 'java';
return {
uri,
fileName: fileName ?? `untitled.${ext}`,
isUntitled: true,
languageId: language,
version: 1,
isDirty: false,
isClosed: false,
eol: vscode.EndOfLine.LF,
lineCount: lines.length,
getText: () => code,
lineAt: (arg: number | vscode.Position) => {
const line = typeof arg === 'number' ? arg : arg.line;
const text = lines[line] ?? '';
return {
lineNumber: line,
text,
range: new vscode.Range(line, 0, line, text.length),
rangeIncludingLineBreak: new vscode.Range(line, 0, line, text.length),
firstNonWhitespaceCharacterIndex: text.search(/\S|$/),
isEmptyOrWhitespace: text.trim().length === 0,
};
},
offsetAt: (p: vscode.Position) => {
let offset = 0;
for (let i = 0; i < p.line; i++) offset += lines[i].length + 1;
return offset + p.character;
},
positionAt: (offset: number) => {
let remaining = offset;
for (let i = 0; i < lines.length; i++) {
if (remaining <= lines[i].length) return new vscode.Position(i, remaining);
remaining -= lines[i].length + 1;
}
return new vscode.Position(lines.length - 1, lines[lines.length - 1].length);
},
getWordRangeAtPosition: () => undefined,
validateRange: (r: vscode.Range) => r,
validatePosition: (p: vscode.Position) => p,
save: () => Promise.resolve(false),
} as unknown as vscode.TextDocument;
}
+18
View File
@@ -0,0 +1,18 @@
const vscode = acquireVsCodeApi();
function send(type, line, ruleId, source) {
vscode.postMessage({ type, line, ruleId, source });
}
function switchTab(tabId) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(tc) { tc.classList.remove('active'); });
document.querySelector('.tab[data-tab="' + tabId + '"]').classList.add('active');
document.getElementById('tab-' + tabId).classList.add('active');
}
function toggleItem(el) {
if (event.target.closest('button')) { return; }
if (event.target.closest('.item-line')) { return; }
el.classList.toggle('expanded');
}
+14
View File
@@ -270,6 +270,19 @@
var helpIcon = helpText
? '<span class="help-icon" data-help="' + escapeAttr(helpText) + '">?</span>'
: '';
var dialectBadgeClassMap = {
explicit: 'adapter-badge-explicit',
global: 'adapter-badge-warn',
project: 'adapter-badge-ok',
builtin: 'adapter-badge-info',
};
var dialectBadgeClass = dialectBadgeClassMap[a.sqlfluffDialectSource] || 'adapter-badge-info';
var dialectBadge = a.id === 'sqlfluff'
? '<span class="adapter-badge ' + dialectBadgeClass + '">' +
escapeHtml(i18n.sqlfluffDialectLabel) + ' ' +
escapeHtml(a.sqlfluffDialect || '') +
'</span>'
: '';
return '<div class="adapter-card' + (a.enabled ? '' : ' disabled') + '">' +
'<div class="adapter-card-header">' +
@@ -281,6 +294,7 @@
'</div>' +
'<div class="adapter-badges">' +
'<span class="adapter-badge ' + modeBadge.class + '">' + modeBadge.text + '</span>' +
dialectBadge +
depBadge +
configBadge +
'</div>' +
+11
View File
@@ -17,6 +17,7 @@ import { DocxConverter } from '../rules/converters/docx-converter';
import { PptxConverter } from '../rules/converters/pptx-converter';
import { t, getLanguage, onLanguageChange } from '../i18n/messages';
import { getEslintConfigPath, getStylelintConfigPath, getPMDRulesetPath, getSqlFluffConfigFile, isAdapterEnabled, setAdapterEnabled } from '../config/linter';
import { resolveSqlFluffDialect, type SqlFluffDialectSource } from '../adapters/sqlfluff';
import {
buildEslintProjectConfigText,
buildStylelintProjectConfigText,
@@ -39,6 +40,8 @@ interface AdapterConfigStatus {
languages: string;
projectConfigFileName: string;
settingsTarget: string;
sqlfluffDialect?: string;
sqlfluffDialectSource?: SqlFluffDialectSource;
}
const SQLFLUFF_CONFIG_DIALECT = 'mysql';
@@ -334,6 +337,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
configYes: t('setup.adapter.configYes'),
configNo: t('setup.adapter.configNo'),
langLabel: t('setup.adapter.langLabel'),
sqlfluffDialectLabel: t('setup.adapter.sqlfluffDialectLabel'),
btnCreateConfig: t('setup.adapter.btnCreateConfig'),
btnEditGlobal: t('setup.adapter.btnEditGlobal'),
tooltipCreate: t('setup.adapter.tooltipCreate'),
@@ -773,6 +777,7 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
.adapter-badge-ok { background: rgba(63,185,80,0.15); color: #3fb950; }
.adapter-badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
.adapter-badge-error { background: rgba(248,81,73,0.15); color: #f48771; }
.adapter-badge-explicit { background: rgba(56,139,253,0.15); color: #388bfd; }
.adapter-languages { font-size: 10px; color: var(--vscode-descriptionForeground); margin-bottom: 4px; }
.adapter-lang-label { color: var(--vscode-descriptionForeground); }
.adapter-actions { display: flex; gap: 5px; }
@@ -1175,6 +1180,10 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
const configured = !meta.hasExternalDependency || configMode !== 'builtin' || dependencyStatus === 'ready';
const dialectInfo = id === 'sqlfluff'
? resolveSqlFluffDialect(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '')
: undefined;
statuses.push({
id,
name: meta.name,
@@ -1186,6 +1195,8 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
languages: t(`setup.adapter.${meta.i18nKey}Languages`),
projectConfigFileName: meta.projectConfigFileName,
settingsTarget: meta.settingsTarget,
sqlfluffDialect: dialectInfo?.dialect,
sqlfluffDialectSource: dialectInfo?.source,
});
}