feat: implement core code review extension
This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { Orchestrator } from '../orchestrator/orchestrator';
|
||||
import { runAIReview } from '../ai/engine';
|
||||
import { loadActiveRules } from '../rules/yaml-parser';
|
||||
import { mergeResults, MergedReport } from '../merger/merger';
|
||||
import { reportToMarkdown } from '../utils/report';
|
||||
import { getAIConfig, setApiKey, getApiKey, isApiKeyConfigured } from '../config';
|
||||
import { createProvider } from '../ai/factory';
|
||||
import { SetupViewProvider } from '../views/setupView';
|
||||
import { ReviewPanel } from '../panel/webview';
|
||||
|
||||
let currentReport: MergedReport | null = null;
|
||||
|
||||
export function registerCommands(
|
||||
context: vscode.ExtensionContext,
|
||||
orchestrator: Orchestrator,
|
||||
setupProvider: SetupViewProvider
|
||||
): void {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.review', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage('请先打开一个文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const document = editor.document;
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '正在审查...',
|
||||
cancellable: false,
|
||||
}, async (progress) => {
|
||||
progress.report({ message: '运行静态分析...' });
|
||||
|
||||
const startTime = Date.now();
|
||||
const staticResult = await orchestrator.runStaticAnalysis(document, workingDir);
|
||||
|
||||
progress.report({ message: '运行 AI 审查...' });
|
||||
|
||||
const customRules = loadActiveRules(workspaceRoot);
|
||||
const code = document.getText();
|
||||
const aiResult = await runAIReview(context, code, staticResult.diagnostics, customRules);
|
||||
|
||||
currentReport = mergeResults({
|
||||
staticDiagnostics: staticResult.diagnostics,
|
||||
customRuleResults: aiResult.customRuleResults,
|
||||
translatedDiagnostics: aiResult.translatedDiagnostics,
|
||||
aiFindings: aiResult.findings,
|
||||
errors: [...staticResult.errors, ...(aiResult.error ? [aiResult.error] : [])],
|
||||
degraded: aiResult.degraded,
|
||||
startTime,
|
||||
filePath: document.uri.fsPath,
|
||||
language: document.languageId,
|
||||
adapterIds: staticResult.adapterIds,
|
||||
});
|
||||
|
||||
const panel = ReviewPanel.createOrShow(context.extensionUri);
|
||||
panel.update(currentReport);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.reviewSelection', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) { return; }
|
||||
|
||||
const selection = editor.selection;
|
||||
if (selection.isEmpty) {
|
||||
vscode.window.showWarningMessage('请先选中要审查的代码');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = editor.document.getText(selection);
|
||||
const apiKey = await getApiKey(context);
|
||||
if (!apiKey) {
|
||||
vscode.window.showWarningMessage('请先在设置面板中配置 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const customRules = loadActiveRules(workspaceRoot);
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '审查选中代码...',
|
||||
cancellable: false,
|
||||
}, async () => {
|
||||
const aiResult = await runAIReview(context, code, [], customRules);
|
||||
vscode.window.showInformationMessage(
|
||||
`选中代码审查完成: ${aiResult.customRuleResults.length + aiResult.findings.length} 个问题`
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.openPanel', () => {
|
||||
ReviewPanel.createOrShow(context.extensionUri);
|
||||
if (currentReport) {
|
||||
const panel = ReviewPanel.createOrShow(context.extensionUri);
|
||||
panel.update(currentReport);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.exportReport', async () => {
|
||||
if (!currentReport) {
|
||||
vscode.window.showWarningMessage('请先运行完整审查生成报告');
|
||||
return;
|
||||
}
|
||||
const markdown = reportToMarkdown(currentReport);
|
||||
const doc = await vscode.workspace.openTextDocument({ content: markdown, language: 'markdown' });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.addCustomRule', () => {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showWarningMessage('请先打开工作区');
|
||||
return;
|
||||
}
|
||||
vscode.window.showInformationMessage('添加自定义规则功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixIssue', () => {
|
||||
vscode.window.showInformationMessage('单条修复功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixAll', () => {
|
||||
vscode.window.showInformationMessage('批量修复功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.openSetup', () => {
|
||||
vscode.commands.executeCommand('workbench.view.extension.code-reviewer');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.setApiKey', async () => {
|
||||
const key = await vscode.window.showInputBox({
|
||||
prompt: '请输入 API Key',
|
||||
password: true,
|
||||
placeHolder: 'sk-...',
|
||||
});
|
||||
if (key) {
|
||||
await setApiKey(context, key);
|
||||
setupProvider.refresh();
|
||||
vscode.window.showInformationMessage('API Key 已保存');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.focusApiKey', async () => {
|
||||
const key = await vscode.window.showInputBox({
|
||||
prompt: '请输入 API Key',
|
||||
password: true,
|
||||
placeHolder: 'sk-...',
|
||||
});
|
||||
if (key) {
|
||||
await setApiKey(context, key);
|
||||
setupProvider.refresh();
|
||||
vscode.window.showInformationMessage('API Key 已保存');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectProvider', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const selected = await vscode.window.showQuickPick(['deepseek', 'openai'], {
|
||||
placeHolder: '选择模型提供商',
|
||||
});
|
||||
if (selected) {
|
||||
await config.update('ai.provider', selected, vscode.ConfigurationTarget.Global);
|
||||
setupProvider.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectModel', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const current = config.get<string>('ai.model', '');
|
||||
const selected = await vscode.window.showInputBox({
|
||||
prompt: '输入模型名称',
|
||||
value: current,
|
||||
placeHolder: 'deepseek-chat',
|
||||
});
|
||||
if (selected) {
|
||||
await config.update('ai.model', selected, vscode.ConfigurationTarget.Global);
|
||||
setupProvider.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectLanguage', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const selected = await vscode.window.showQuickPick(
|
||||
[
|
||||
{ label: '中文(简体)', description: 'zh-CN' },
|
||||
{ label: 'English', description: 'en' },
|
||||
{ label: '日本語', description: 'ja' },
|
||||
],
|
||||
{ placeHolder: '选择输出语言' }
|
||||
);
|
||||
if (selected) {
|
||||
await config.update('ai.outputLanguage', selected.description, vscode.ConfigurationTarget.Global);
|
||||
setupProvider.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.saveAndTest', async () => {
|
||||
const apiKey = await getApiKey(context);
|
||||
if (!apiKey) {
|
||||
vscode.window.showWarningMessage('请先设置 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getAIConfig();
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '测试连接...',
|
||||
cancellable: false,
|
||||
}, async () => {
|
||||
try {
|
||||
const provider = createProvider(config.provider, apiKey, config.endpoint);
|
||||
await provider.chat('回复 ok', 'ping', {
|
||||
model: config.model,
|
||||
temperature: 0,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
setupProvider.connectionTested = true;
|
||||
setupProvider.connectionSuccess = true;
|
||||
setupProvider.refresh();
|
||||
vscode.window.showInformationMessage('✓ 连接成功', { modal: false });
|
||||
} catch (err) {
|
||||
setupProvider.connectionTested = true;
|
||||
setupProvider.connectionSuccess = false;
|
||||
setupProvider.refresh();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
vscode.window.showErrorMessage(`✗ 连接失败: ${message}`, { modal: false });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.toggleRule', async (ruleId: string) => {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) { return; }
|
||||
|
||||
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
|
||||
if (!fs.existsSync(configPath)) { return; }
|
||||
|
||||
let content = fs.readFileSync(configPath, 'utf-8');
|
||||
const enabledPattern = new RegExp(`^(\\s*${ruleId}\\s*:\\s*\\n\\s*enabled\\s*:\\s*)(true|false)`, 'm');
|
||||
|
||||
if (enabledPattern.test(content)) {
|
||||
const match = enabledPattern.exec(content);
|
||||
if (match) {
|
||||
const newValue = match[2] === 'true' ? 'false' : 'true';
|
||||
content = content.replace(enabledPattern, `$1${newValue}`);
|
||||
}
|
||||
} else {
|
||||
content += `\n ${ruleId}:\n enabled: false\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
setupProvider.refresh();
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
Severity,
|
||||
AdapterStatus,
|
||||
LinterDiagnostic,
|
||||
AdapterResult,
|
||||
LinterAdapter,
|
||||
} from '../types';
|
||||
@@ -0,0 +1,49 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ESLint } from 'eslint';
|
||||
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
|
||||
|
||||
export class ESLintAdapter implements LinterAdapter {
|
||||
id = 'eslint';
|
||||
supportedLanguages = ['javascript', 'typescript'];
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const engine = new ESLint({ cwd: workingDir });
|
||||
const results = await engine.lintText(document.getText(), {
|
||||
filePath: document.fileName || 'untitled.ts',
|
||||
});
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const result of results) {
|
||||
for (const msg of result.messages) {
|
||||
if (msg.ruleId === null) { continue; }
|
||||
|
||||
diagnostics.push({
|
||||
severity: msg.severity === 2 ? 'error' : 'warning',
|
||||
ruleId: `eslint:${msg.ruleId}`,
|
||||
message: msg.message,
|
||||
range: new vscode.Range(
|
||||
msg.line - 1,
|
||||
msg.column - 1,
|
||||
(msg.endLine ?? msg.line) - 1,
|
||||
(msg.endColumn ?? msg.column) - 1
|
||||
),
|
||||
suggestion: msg.fix?.text,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnostics: [],
|
||||
status: 'execution-failed',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { PmdAdapter } from './pmd';
|
||||
import { ESLintAdapter } from './eslint';
|
||||
import { StylelintAdapter } from './stylelint';
|
||||
import { extractJspSections } from '../jsp/jsp-extractor';
|
||||
import { getLinterForLanguage } from '../config';
|
||||
|
||||
export class JspAdapter implements LinterAdapter {
|
||||
id = 'jsp';
|
||||
supportedLanguages = ['jsp'];
|
||||
|
||||
private pmdAdapter = new PmdAdapter();
|
||||
private eslintAdapter = new ESLintAdapter();
|
||||
private stylelintAdapter = new StylelintAdapter();
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
const allDiagnostics: LinterDiagnostic[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const jsEnabled = getLinterForLanguage('javascript') !== '';
|
||||
const cssEnabled = getLinterForLanguage('css') !== '';
|
||||
const javaEnabled = getLinterForLanguage('java') !== '';
|
||||
|
||||
const pmdResult = await this.pmdAdapter.check(document, workingDir);
|
||||
allDiagnostics.push(...pmdResult.diagnostics);
|
||||
if (pmdResult.status !== 'ok') {
|
||||
errors.push(`PMD: ${pmdResult.errorMessage ?? pmdResult.status}`);
|
||||
}
|
||||
|
||||
const sections = extractJspSections(document.getText());
|
||||
|
||||
for (const section of sections) {
|
||||
const isEnabled = (section.language === 'javascript' && jsEnabled)
|
||||
|| (section.language === 'css' && cssEnabled)
|
||||
|| (section.language === 'java' && javaEnabled);
|
||||
if (!isEnabled) { continue; }
|
||||
|
||||
const adapter = this.getAdapter(section.language);
|
||||
if (!adapter) { continue; }
|
||||
|
||||
try {
|
||||
const virtualDoc = await vscode.workspace.openTextDocument({
|
||||
content: section.code,
|
||||
language: section.language,
|
||||
});
|
||||
const result = await adapter.check(virtualDoc, workingDir);
|
||||
|
||||
for (const diag of result.diagnostics) {
|
||||
const adjustedRange = new vscode.Range(
|
||||
diag.range.start.line + section.lineOffset,
|
||||
diag.range.start.character,
|
||||
diag.range.end.line + section.lineOffset,
|
||||
diag.range.end.character,
|
||||
);
|
||||
allDiagnostics.push({ ...diag, range: adjustedRange });
|
||||
}
|
||||
|
||||
if (result.status !== 'ok') {
|
||||
errors.push(`${section.language}: ${result.errorMessage ?? result.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`${section.language}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = errors.length > 0;
|
||||
const hasUnavailable = errors.some(e => e.includes('未安装') || e.includes('tool-unavailable'));
|
||||
|
||||
return {
|
||||
diagnostics: allDiagnostics,
|
||||
status: hasErrors ? (hasUnavailable ? 'tool-unavailable' : 'execution-failed') : 'ok',
|
||||
errorMessage: errors.join('; '),
|
||||
};
|
||||
}
|
||||
|
||||
private getAdapter(language: string): LinterAdapter | null {
|
||||
switch (language) {
|
||||
case 'javascript': return this.eslintAdapter;
|
||||
case 'css': return this.stylelintAdapter;
|
||||
case 'java': return this.pmdAdapter;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { getPMDRulesetPath } from '../config';
|
||||
|
||||
export class PmdAdapter implements LinterAdapter {
|
||||
id = 'pmd';
|
||||
supportedLanguages = ['java'];
|
||||
|
||||
private getPmdLibClasspath(): string {
|
||||
const extRoot = this.getExtensionRoot();
|
||||
const pmdLib = path.join(extRoot, 'jars', 'pmd', 'lib');
|
||||
return path.join(pmdLib, '*');
|
||||
}
|
||||
|
||||
private getPmdRunnerClasspath(): string {
|
||||
const extRoot = this.getExtensionRoot();
|
||||
return path.join(extRoot, 'jars', 'pmd');
|
||||
}
|
||||
|
||||
private getExtensionRoot(): string {
|
||||
try {
|
||||
const extPath = vscode.extensions.getExtension?.('vscode-code-reviewer')?.extensionPath;
|
||||
if (extPath) { return extPath; }
|
||||
} catch { /* extension not available */ }
|
||||
return path.join(__dirname, '..', '..');
|
||||
}
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const ruleset = getPMDRulesetPath()
|
||||
|| path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
|
||||
const classpath = `${this.getPmdLibClasspath()};${this.getPmdRunnerClasspath()}`;
|
||||
|
||||
const isVirtual = document.uri.scheme === 'untitled';
|
||||
const fileArg = isVirtual ? '-' : document.uri.fsPath;
|
||||
|
||||
const javaArgs = ['-cp', classpath, 'PmdRunner', fileArg, ruleset];
|
||||
const result = await this.execPmd(javaArgs, isVirtual ? document.getText() : null, workingDir);
|
||||
|
||||
const diagnostics = this.parsePmdOutput(result);
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('ENOENT') || message.includes('java')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Java 11+ 未安装或不在 PATH 中' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
private execPmd(args: string[], stdinInput: string | null, cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('java', args, { cwd });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 || code === 4 || stdout.length > 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(stderr || `PMD exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
if (stdinInput !== null) {
|
||||
proc.stdin.write(stdinInput);
|
||||
proc.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private parsePmdOutput(output: string): LinterDiagnostic[] {
|
||||
if (!output.trim()) { return []; }
|
||||
try {
|
||||
const data = JSON.parse(output);
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const file of data.files ?? []) {
|
||||
for (const violation of file.violations ?? []) {
|
||||
const line = Math.max(0, (violation.beginline ?? 1) - 1);
|
||||
const col = Math.max(0, (violation.begincolumn ?? 1) - 1);
|
||||
const endCol = Math.max(col, (violation.endcolumn ?? col + 1) - 1);
|
||||
const range = new vscode.Range(line, col, line, endCol);
|
||||
diagnostics.push({
|
||||
severity: this.mapPriority(violation.priority),
|
||||
ruleId: `pmd:${violation.rule}`,
|
||||
message: violation.description ?? '',
|
||||
range,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapPriority(priority: number): 'error' | 'warning' | 'info' {
|
||||
if (priority <= 2) { return 'error'; }
|
||||
if (priority === 3) { return 'warning'; }
|
||||
return 'info';
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
execSync('java -version 2>&1', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { spawn } from 'child_process';
|
||||
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
|
||||
|
||||
const DIALECT_MAP: Record<string, string> = {
|
||||
sql: 'ansi',
|
||||
plsql: 'postgres',
|
||||
};
|
||||
|
||||
interface SqlFluffViolation {
|
||||
line_no: number;
|
||||
line_pos: number;
|
||||
rule_code: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface SqlFluffResult {
|
||||
filepath: string;
|
||||
violations: SqlFluffViolation[];
|
||||
}
|
||||
|
||||
function runSqlfluff(dialect: string, code: string, cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('sqlfluff', ['lint', '--dialect', dialect, '--format', 'json', '-'], {
|
||||
cwd,
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
child.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
|
||||
child.on('error', (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === 'ENOENT') {
|
||||
reject(new Error('tool-unavailable'));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
child.on('close', (code: number | null) => {
|
||||
if (stdout) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(stderr || `sqlfluff exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
|
||||
child.stdin.write(code);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
export class SqlLintAdapter implements LinterAdapter {
|
||||
id = 'sql-lint';
|
||||
supportedLanguages = ['sql', 'plsql'];
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
const languageId = document.languageId;
|
||||
const dialect = DIALECT_MAP[languageId] || 'ansi';
|
||||
|
||||
try {
|
||||
const stdout = await runSqlfluff(dialect, document.getText(), workingDir);
|
||||
const results: SqlFluffResult[] = JSON.parse(stdout);
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
for (const v of result.violations) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
ruleId: `sql-lint:${v.rule_code}`,
|
||||
message: v.description,
|
||||
range: new vscode.Range(
|
||||
v.line_no - 1,
|
||||
v.line_pos - 1,
|
||||
v.line_no - 1,
|
||||
v.line_pos - 1
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message === 'tool-unavailable') {
|
||||
return {
|
||||
diagnostics: [],
|
||||
status: 'tool-unavailable',
|
||||
errorMessage: 'sqlfluff 未安装,请执行 pip install sqlfluff',
|
||||
};
|
||||
}
|
||||
return {
|
||||
diagnostics: [],
|
||||
status: 'execution-failed',
|
||||
errorMessage: message,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as vscode from 'vscode';
|
||||
import stylelint from 'stylelint';
|
||||
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
|
||||
|
||||
export class StylelintAdapter implements LinterAdapter {
|
||||
id = 'stylelint';
|
||||
supportedLanguages = ['css'];
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const result = await stylelint.lint({
|
||||
code: document.getText(),
|
||||
codeFilename: document.fileName,
|
||||
cwd: workingDir,
|
||||
});
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const res of result.results) {
|
||||
for (const w of res.warnings) {
|
||||
diagnostics.push({
|
||||
severity: w.severity,
|
||||
ruleId: `stylelint:${w.rule}`,
|
||||
message: w.text,
|
||||
range: new vscode.Range(
|
||||
w.line - 1,
|
||||
w.column - 1,
|
||||
(w.endLine ?? w.line) - 1,
|
||||
(w.endColumn ?? w.column) - 1
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (error) {
|
||||
return {
|
||||
diagnostics: [],
|
||||
status: 'execution-failed',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { AIProvider } from './providers/base';
|
||||
import { createProvider } from './factory';
|
||||
import { getAIProvider, getAIModel, getAIEndpoint, getAITemperature, getAITimeout, getAIOutputLanguage, getApiKey } from '../config';
|
||||
import type { LinterDiagnostic, CustomRule } from '../types';
|
||||
import type {
|
||||
AIEngineResult,
|
||||
CustomRuleResult,
|
||||
TranslatedDiagnostic,
|
||||
AIFinding,
|
||||
} from './schema';
|
||||
|
||||
function buildCustomRulePrompt(rules: CustomRule[]): string {
|
||||
return rules.map(r =>
|
||||
`- [${r.id}] (${r.severity}) ${r.description}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function buildLinterDiagnosticsPrompt(diagnostics: LinterDiagnostic[]): string {
|
||||
return diagnostics.map(d =>
|
||||
`- [${d.ruleId}] L${d.range.start.line + 1}: ${d.message}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function addLineNumbers(code: string): string {
|
||||
return code.split('\n').map((line, i) => `${String(i + 1).padStart(4, ' ')}| ${line}`).join('\n');
|
||||
}
|
||||
|
||||
function parseJsonResponse(raw: string): object {
|
||||
const trimmed = raw.trim();
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error('响应中未找到 JSON');
|
||||
}
|
||||
return JSON.parse(trimmed.substring(start, end + 1));
|
||||
}
|
||||
|
||||
const CUSTOM_RULE_SYSTEM_PROMPT = `你是代码规则审查员,只评估以下自定义规则是否被违反。
|
||||
理解语义而非文本匹配。
|
||||
仅输出 JSON,格式:
|
||||
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述" }] }
|
||||
如果没有违反任何规则,返回空数组。`;
|
||||
|
||||
const DEEP_REVIEW_SYSTEM_PROMPT = `你是资深代码审查专家,完成两个任务:
|
||||
1. 将英文静态分析结果翻译为输出语言,并补充修复建议
|
||||
2. 深度审查代码,发现静态分析未覆盖的问题
|
||||
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
|
||||
不要重复静态分析已报告的问题。
|
||||
|
||||
仅输出 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": 行号 }]
|
||||
}`;
|
||||
|
||||
export async function runAIReview(
|
||||
context: vscode.ExtensionContext,
|
||||
code: string,
|
||||
staticDiagnostics: LinterDiagnostic[],
|
||||
customRules: CustomRule[]
|
||||
): Promise<AIEngineResult> {
|
||||
const apiKey = await getApiKey(context);
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
findings: [],
|
||||
degraded: true,
|
||||
error: '未配置 API Key',
|
||||
};
|
||||
}
|
||||
|
||||
const providerId = getAIProvider();
|
||||
const endpoint = getAIEndpoint();
|
||||
|
||||
let provider: AIProvider;
|
||||
try {
|
||||
provider = createProvider(providerId, apiKey, endpoint);
|
||||
} catch (err) {
|
||||
return {
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
findings: [],
|
||||
degraded: true,
|
||||
error: `创建 Provider 失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const options = {
|
||||
model: getAIModel(),
|
||||
temperature: getAITemperature(),
|
||||
timeoutMs: getAITimeout() * 1000,
|
||||
};
|
||||
|
||||
const numberedCode = addLineNumbers(code);
|
||||
|
||||
const requestA =
|
||||
customRules.length > 0
|
||||
? provider.chat(
|
||||
CUSTOM_RULE_SYSTEM_PROMPT,
|
||||
`## 自定义规则\n${buildCustomRulePrompt(customRules)}\n\n## 代码(带行号)\n${numberedCode}`,
|
||||
options
|
||||
)
|
||||
: Promise.resolve('{}');
|
||||
|
||||
const requestB = provider.chat(
|
||||
`${DEEP_REVIEW_SYSTEM_PROMPT}\n输出语言:${getAIOutputLanguage()}`,
|
||||
`## 代码(带行号)\n${numberedCode}\n\n## 静态分析结果(英文)\n${buildLinterDiagnosticsPrompt(staticDiagnostics)}`,
|
||||
options
|
||||
);
|
||||
|
||||
const [resultA, resultB] = await Promise.allSettled([requestA, requestB]);
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
let customRuleResults: CustomRuleResult[] = [];
|
||||
if (resultA.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = parseJsonResponse(resultA.value) as { customRuleResults?: CustomRuleResult[] };
|
||||
customRuleResults = (parsed.customRuleResults ?? []).map(r => ({
|
||||
...r,
|
||||
ruleId: `custom:${r.ruleId}`,
|
||||
}));
|
||||
} catch {
|
||||
errors.push('自定义规则响应解析失败');
|
||||
}
|
||||
} else {
|
||||
errors.push(`自定义规则请求失败: ${resultA.reason}`);
|
||||
}
|
||||
|
||||
let translatedDiagnostics: TranslatedDiagnostic[] = [];
|
||||
let findings: AIFinding[] = [];
|
||||
if (resultB.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = parseJsonResponse(resultB.value) as {
|
||||
translatedDiagnostics?: TranslatedDiagnostic[];
|
||||
findings?: AIFinding[];
|
||||
};
|
||||
translatedDiagnostics = parsed.translatedDiagnostics ?? [];
|
||||
findings = parsed.findings ?? [];
|
||||
} catch {
|
||||
errors.push('AI 审查响应解析失败');
|
||||
}
|
||||
} else {
|
||||
errors.push(`AI 审查请求失败: ${resultB.reason}`);
|
||||
}
|
||||
|
||||
const degraded = errors.length > 0;
|
||||
return {
|
||||
customRuleResults,
|
||||
translatedDiagnostics,
|
||||
findings,
|
||||
degraded,
|
||||
error: errors.join('; '),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { AIProvider } from './providers/base';
|
||||
import { DeepSeekProvider } from './providers/deepseek';
|
||||
import { OpenAIProvider } from './providers/openai';
|
||||
|
||||
type ProviderConstructor = new (apiKey: string, endpoint: string) => AIProvider;
|
||||
|
||||
const registry: Record<string, ProviderConstructor> = {
|
||||
deepseek: DeepSeekProvider,
|
||||
openai: OpenAIProvider,
|
||||
};
|
||||
|
||||
export function createProvider(providerId: string, apiKey: string, endpoint: string): AIProvider {
|
||||
const Cls = registry[providerId];
|
||||
if (!Cls) {
|
||||
throw new Error(`未知的 Provider: ${providerId}`);
|
||||
}
|
||||
return new Cls(apiKey, endpoint);
|
||||
}
|
||||
|
||||
export function getProviderIds(): string[] {
|
||||
return Object.keys(registry);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export interface ChatOptions {
|
||||
model: string;
|
||||
temperature: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export abstract class AIProvider {
|
||||
abstract id: string;
|
||||
abstract name: string;
|
||||
|
||||
constructor(
|
||||
protected apiKey: string,
|
||||
protected endpoint: string
|
||||
) {}
|
||||
|
||||
abstract chat(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: ChatOptions
|
||||
): Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AIProvider, ChatOptions } from './base';
|
||||
|
||||
export class DeepSeekProvider extends AIProvider {
|
||||
id = 'deepseek';
|
||||
name = 'DeepSeek';
|
||||
|
||||
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
|
||||
const url = `${this.endpoint}/chat/completions`;
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: options.model,
|
||||
temperature: options.temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new Error('API Key 无效,请重新设置');
|
||||
}
|
||||
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
return data.choices[0]?.message?.content ?? '';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AIProvider, ChatOptions } from './base';
|
||||
|
||||
export class OpenAIProvider extends AIProvider {
|
||||
id = 'openai';
|
||||
name = 'OpenAI';
|
||||
|
||||
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
|
||||
const url = `${this.endpoint}/chat/completions`;
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: options.model,
|
||||
temperature: options.temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new Error('API Key 无效,请重新设置');
|
||||
}
|
||||
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
return data.choices[0]?.message?.content ?? '';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export interface TranslatedDiagnostic {
|
||||
originalRuleId: string;
|
||||
translatedMessage: string;
|
||||
translatedSuggestion: string;
|
||||
codeDiff?: string;
|
||||
}
|
||||
|
||||
export interface CustomRuleResult {
|
||||
ruleId: string;
|
||||
line: number;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AIFinding {
|
||||
ruleId: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: 'bug' | 'performance' | 'security' | 'style' | 'design';
|
||||
title: string;
|
||||
description: string;
|
||||
suggestion: string;
|
||||
codeDiff?: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface AIResponse {
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
customRuleResults: CustomRuleResult[];
|
||||
findings: AIFinding[];
|
||||
}
|
||||
|
||||
export interface AIEngineResult {
|
||||
customRuleResults: CustomRuleResult[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
findings: AIFinding[];
|
||||
degraded: boolean;
|
||||
error?: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const ROOT = 'vscode-code-reviewer';
|
||||
|
||||
export function getAIProvider(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.provider', 'deepseek');
|
||||
}
|
||||
|
||||
export function getAIModel(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.model', 'deepseek-chat');
|
||||
}
|
||||
|
||||
export function getAIEndpoint(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.endpoint', 'https://api.deepseek.com/v1');
|
||||
}
|
||||
|
||||
export function getAITemperature(): number {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<number>('ai.temperature', 0.2);
|
||||
}
|
||||
|
||||
export function getAITimeout(): number {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<number>('ai.timeout', 300);
|
||||
}
|
||||
|
||||
export function getAIOutputLanguage(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.outputLanguage', 'zh-CN');
|
||||
}
|
||||
|
||||
export function getAIConfig() {
|
||||
return {
|
||||
provider: getAIProvider(),
|
||||
model: getAIModel(),
|
||||
endpoint: getAIEndpoint(),
|
||||
outputLanguage: getAIOutputLanguage(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const ROOT = 'vscode-code-reviewer';
|
||||
|
||||
export function getContextLines(): number {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<number>('fixer.contextLines', 5);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './ai';
|
||||
export * from './linter';
|
||||
export * from './fixer';
|
||||
export * from './secret';
|
||||
@@ -0,0 +1,23 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const ROOT = 'vscode-code-reviewer';
|
||||
|
||||
export function getLinterForLanguage(language: string): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>(`linters.${language}`, '');
|
||||
}
|
||||
|
||||
export function getPMDJarPath(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jarPath', '');
|
||||
}
|
||||
|
||||
export function getPMDRulesetPath(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.rulesetPath', '');
|
||||
}
|
||||
|
||||
export function getPMDJspRulesetPath(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jspRulesetPath', '');
|
||||
}
|
||||
|
||||
export function getSqlLintConfigFile(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('sql-lint.configFile', '');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const SECRET_KEY = 'vscode-code-reviewer.apiKey';
|
||||
|
||||
export async function getApiKey(context: vscode.ExtensionContext): Promise<string | undefined> {
|
||||
return context.secrets.get(SECRET_KEY);
|
||||
}
|
||||
|
||||
export async function setApiKey(context: vscode.ExtensionContext, value: string): Promise<void> {
|
||||
await context.secrets.store(SECRET_KEY, value);
|
||||
}
|
||||
|
||||
export async function deleteApiKey(context: vscode.ExtensionContext): Promise<void> {
|
||||
await context.secrets.delete(SECRET_KEY);
|
||||
}
|
||||
|
||||
export async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise<boolean> {
|
||||
const key = await getApiKey(context);
|
||||
return !!key;
|
||||
}
|
||||
+32
-18
@@ -1,26 +1,40 @@
|
||||
// The module 'vscode' contains the VS Code extensibility API
|
||||
// Import the module and reference it with the alias vscode in your code below
|
||||
import * as vscode from 'vscode';
|
||||
import { Orchestrator } from './orchestrator/orchestrator';
|
||||
import { registerCommands } from './activation/commands';
|
||||
import { SetupViewProvider } from './views/setupView';
|
||||
|
||||
let orchestrator: Orchestrator;
|
||||
|
||||
// This method is called when your extension is activated
|
||||
// Your extension is activated the very first time the command is executed
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
console.log('CodeGuard 代码审查插件已激活');
|
||||
|
||||
// Use the console to output diagnostic information (console.log) and errors (console.error)
|
||||
// This line of code will only be executed once when your extension is activated
|
||||
console.log('Congratulations, your extension "vscode-code-reviewer" is now active!');
|
||||
orchestrator = new Orchestrator();
|
||||
|
||||
// The command has been defined in the package.json file
|
||||
// Now provide the implementation of the command with registerCommand
|
||||
// The commandId parameter must match the command field in package.json
|
||||
const disposable = vscode.commands.registerCommand('vscode-code-reviewer.helloWorld', () => {
|
||||
// The code you place here will be executed every time your command is executed
|
||||
// Display a message box to the user
|
||||
vscode.window.showInformationMessage('Hello World from vscode-code-reviewer!');
|
||||
});
|
||||
const setupProvider = new SetupViewProvider(context);
|
||||
vscode.window.registerTreeDataProvider('codeReviewer.setupView', setupProvider);
|
||||
|
||||
context.subscriptions.push(disposable);
|
||||
registerCommands(context, orchestrator, setupProvider);
|
||||
|
||||
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidSaveTextDocument((document) => {
|
||||
const key = document.uri.toString();
|
||||
const existing = debounceTimers.get(key);
|
||||
if (existing) { clearTimeout(existing); }
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
debounceTimers.delete(key);
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
orchestrator.runStaticAnalysis(document, workingDir);
|
||||
}, 500);
|
||||
|
||||
debounceTimers.set(key, timer);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// This method is called when your extension is deactivated
|
||||
export function deactivate() {}
|
||||
export function deactivate() {
|
||||
orchestrator = undefined!;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { LinterDiagnostic } from '../types';
|
||||
import type { AIProvider } from '../ai/providers/base';
|
||||
|
||||
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,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
export interface JspSection {
|
||||
language: 'javascript' | 'css' | 'java';
|
||||
code: string;
|
||||
lineOffset: number;
|
||||
sourceStart: number;
|
||||
sourceEnd: number;
|
||||
}
|
||||
|
||||
export function extractJspSections(content: string): JspSection[] {
|
||||
const sections: JspSection[] = [];
|
||||
|
||||
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = scriptRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'javascript',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
const styleRegex = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
|
||||
while ((match = styleRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'css',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
const scriptletRegex = /<%=?([\s\S]*?)%>/g;
|
||||
while ((match = scriptletRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'java',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { LinterDiagnostic, Severity } from '../types';
|
||||
import type { TranslatedDiagnostic, CustomRuleResult, AIFinding } from '../ai/schema';
|
||||
|
||||
export interface MergedReport {
|
||||
linterDiagnostics: LinterDiagnostic[];
|
||||
customRuleDiagnostics: LinterDiagnostic[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
aiFindings: AIFinding[];
|
||||
linterCount: number;
|
||||
customRuleCount: number;
|
||||
aiCount: number;
|
||||
errors: string[];
|
||||
degraded: boolean;
|
||||
duration: number;
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterNames: string[];
|
||||
fixableLinterIndices: number[];
|
||||
fixableCustomIndices: number[];
|
||||
}
|
||||
|
||||
interface MergeInput {
|
||||
staticDiagnostics: LinterDiagnostic[];
|
||||
customRuleResults: CustomRuleResult[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
aiFindings: AIFinding[];
|
||||
errors: string[];
|
||||
degraded: boolean;
|
||||
startTime: number;
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterIds: string[];
|
||||
}
|
||||
|
||||
export function mergeResults(input: MergeInput): MergedReport {
|
||||
const customRuleDiagnostics: LinterDiagnostic[] = input.customRuleResults.map(r => ({
|
||||
severity: r.severity as Severity,
|
||||
ruleId: r.ruleId,
|
||||
message: r.message,
|
||||
range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
|
||||
}));
|
||||
|
||||
const linterCount = input.staticDiagnostics.length;
|
||||
const customRuleCount = customRuleDiagnostics.length;
|
||||
const aiCount = input.aiFindings.length;
|
||||
|
||||
const fixableLinterIndices = input.staticDiagnostics
|
||||
.map((_, i) => i)
|
||||
.filter(i => input.staticDiagnostics[i].suggestion);
|
||||
|
||||
const fixableCustomIndices = customRuleDiagnostics
|
||||
.map((_, i) => i);
|
||||
|
||||
return {
|
||||
linterDiagnostics: input.staticDiagnostics,
|
||||
customRuleDiagnostics,
|
||||
translatedDiagnostics: input.translatedDiagnostics,
|
||||
aiFindings: input.aiFindings,
|
||||
linterCount,
|
||||
customRuleCount,
|
||||
aiCount,
|
||||
errors: input.errors,
|
||||
degraded: input.degraded,
|
||||
duration: Date.now() - input.startTime,
|
||||
filePath: input.filePath,
|
||||
language: input.language,
|
||||
adapterNames: input.adapterIds,
|
||||
fixableLinterIndices,
|
||||
fixableCustomIndices,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { LinterAdapter, LinterDiagnostic } from '../types';
|
||||
import { getLinterForLanguage } from '../config';
|
||||
import { ESLintAdapter } from '../adapters/eslint';
|
||||
import { PmdAdapter } from '../adapters/pmd';
|
||||
import { StylelintAdapter } from '../adapters/stylelint';
|
||||
import { SqlLintAdapter } from '../adapters/sql-lint';
|
||||
import { JspAdapter } from '../adapters/jsp';
|
||||
|
||||
export interface StaticAnalysisResult {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
errors: string[];
|
||||
adapterIds: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class Orchestrator {
|
||||
private adapters: LinterAdapter[];
|
||||
|
||||
constructor() {
|
||||
this.adapters = [
|
||||
new ESLintAdapter(),
|
||||
new PmdAdapter(),
|
||||
new StylelintAdapter(),
|
||||
new SqlLintAdapter(),
|
||||
new JspAdapter(),
|
||||
];
|
||||
}
|
||||
|
||||
getAdapterMap(): Map<string, LinterAdapter> {
|
||||
const map = new Map<string, LinterAdapter>();
|
||||
for (const adapter of this.adapters) {
|
||||
for (const lang of adapter.supportedLanguages) {
|
||||
map.set(lang, adapter);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async runStaticAnalysis(
|
||||
document: vscode.TextDocument,
|
||||
workingDir: string
|
||||
): Promise<StaticAnalysisResult> {
|
||||
const startTime = Date.now();
|
||||
const languageId = document.languageId;
|
||||
|
||||
const selectedLinter = getLinterForLanguage(languageId);
|
||||
if (!selectedLinter) {
|
||||
return { diagnostics: [], errors: [], adapterIds: [], duration: 0 };
|
||||
}
|
||||
|
||||
const adapter = this.adapters.find(a => a.id === selectedLinter);
|
||||
if (!adapter) {
|
||||
return {
|
||||
diagnostics: [],
|
||||
errors: [`未找到适配器: ${selectedLinter}`],
|
||||
adapterIds: [],
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await adapter.check(document, workingDir);
|
||||
const errors: string[] = [];
|
||||
if (result.status !== 'ok') {
|
||||
errors.push(`[${adapter.id}] ${result.errorMessage ?? result.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostics: result.diagnostics,
|
||||
errors,
|
||||
adapterIds: [adapter.id],
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
getAdaptersByIds(ids: string[]): LinterAdapter[] {
|
||||
return ids.map(id => this.adapters.find(a => a.id === id)).filter(Boolean) as LinterAdapter[];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { MergedReport } from '../merger/merger';
|
||||
|
||||
interface PanelMessage {
|
||||
type: 'navigate' | 'rerun' | 'export' | 'settings' | 'fix' | 'fixAll';
|
||||
line?: number;
|
||||
ruleId?: string;
|
||||
source?: 'linter' | 'custom' | 'ai';
|
||||
}
|
||||
|
||||
export class ReviewPanel {
|
||||
public static currentPanel: ReviewPanel | undefined;
|
||||
private readonly panel: vscode.WebviewPanel;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
private constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
column: vscode.ViewColumn
|
||||
) {
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'codeReviewer.reviewPanel',
|
||||
'代码审查报告',
|
||||
column,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [],
|
||||
}
|
||||
);
|
||||
|
||||
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
|
||||
|
||||
this.panel.webview.onDidReceiveMessage(
|
||||
(message: PanelMessage) => this.handleMessage(message),
|
||||
null,
|
||||
this.disposables
|
||||
);
|
||||
}
|
||||
|
||||
static createOrShow(extensionUri: vscode.Uri, column?: vscode.ViewColumn): ReviewPanel {
|
||||
if (ReviewPanel.currentPanel) {
|
||||
ReviewPanel.currentPanel.panel.reveal(column);
|
||||
return ReviewPanel.currentPanel;
|
||||
}
|
||||
|
||||
ReviewPanel.currentPanel = new ReviewPanel(extensionUri, column ?? vscode.ViewColumn.Two);
|
||||
return ReviewPanel.currentPanel;
|
||||
}
|
||||
|
||||
update(report: MergedReport): void {
|
||||
this.panel.webview.html = this.buildHtml(report);
|
||||
}
|
||||
|
||||
private buildHtml(report: MergedReport): string {
|
||||
const total = report.linterCount + report.customRuleCount + report.aiCount;
|
||||
const errorCount = report.linterDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'error').length;
|
||||
const warnCount = report.linterDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'warning').length;
|
||||
const infoCount = total - errorCount - warnCount;
|
||||
|
||||
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
|
||||
|
||||
const degradedBanner = report.degraded
|
||||
? `<div class="banner ${report.errors.length > 0 ? 'banner-error' : 'banner-warn'}">
|
||||
${report.errors.length > 0 ? '⚠ AI 审查失败' : '⚠ 部分 AI 功能不可用'}
|
||||
${report.errors.join('; ')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>代码审查报告</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 16px; }
|
||||
.header { margin-bottom: 16px; }
|
||||
.header h1 { font-size: 18px; margin-bottom: 4px; }
|
||||
.header .meta { font-size: 12px; color: var(--vscode-descriptionForeground); }
|
||||
.banner { padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; }
|
||||
.banner-warn { background: #332B00; border: 1px solid #665C00; color: #FFF3B0; }
|
||||
.banner-error { background: #330000; border: 1px solid #660000; color: #FFB0B0; }
|
||||
.stats { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { flex: 1; padding: 12px; border-radius: 6px; text-align: center; background: var(--vscode-sideBar-background); }
|
||||
.stat-card .num { font-size: 24px; font-weight: 600; }
|
||||
.stat-card .label { font-size: 12px; color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
.stat-total .num { color: var(--vscode-foreground); }
|
||||
.stat-error .num { color: #E06C75; }
|
||||
.stat-warn .num { color: #D19A66; }
|
||||
.stat-info .num { color: #61AFEF; }
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 12px; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.tab { padding: 8px 16px; cursor: pointer; border: none; background: none; color: var(--vscode-descriptionForeground); font-family: var(--vscode-font-family); font-size: 13px; border-bottom: 2px solid transparent; }
|
||||
.tab.active { color: var(--vscode-foreground); border-bottom-color: #7C3AED; }
|
||||
.tab .count { margin-left: 6px; font-size: 11px; opacity: 0.7; }
|
||||
.issue-list { display: none; }
|
||||
.issue-list.active { display: block; }
|
||||
.issue { padding: 8px 12px; border-radius: 4px; margin-bottom: 6px; background: var(--vscode-sideBar-background); cursor: pointer; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.issue:hover { background: var(--vscode-list-hoverBackground); }
|
||||
.issue-left { flex: 1; }
|
||||
.issue-title { font-size: 13px; margin-bottom: 2px; }
|
||||
.issue-detail { font-size: 12px; color: var(--vscode-descriptionForeground); }
|
||||
.issue-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.btn { padding: 2px 8px; border-radius: 3px; border: 1px solid var(--vscode-button-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); cursor: pointer; font-size: 11px; }
|
||||
.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.btn-primary { background: #7C3AED; border-color: #7C3AED; color: #fff; }
|
||||
.btn-primary:hover { background: #6D28D9; }
|
||||
.severity { display: inline-block; width: 16px; text-align: center; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--vscode-panel-border); }
|
||||
.empty { text-align: center; padding: 24px; color: var(--vscode-descriptionForeground); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📋 代码审查报告</h1>
|
||||
<div class="meta">${fileName} · ${report.language} · ${(report.duration / 1000).toFixed(1)}s</div>
|
||||
</div>
|
||||
${degradedBanner}
|
||||
<div class="stats">
|
||||
<div class="stat-card stat-total"><div class="num">${total}</div><div class="label">总计</div></div>
|
||||
<div class="stat-card stat-error"><div class="num">${errorCount}</div><div class="label">错误</div></div>
|
||||
<div class="stat-card stat-warn"><div class="num">${warnCount}</div><div class="label">警告</div></div>
|
||||
<div class="stat-card stat-info"><div class="num">${infoCount}</div><div class="label">建议</div></div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('linter')">🔧 静态分析 <span class="count">${report.linterCount}</span></button>
|
||||
<button class="tab" onclick="switchTab('custom')">📋 自定义规则 <span class="count">${report.customRuleCount}</span></button>
|
||||
<button class="tab" onclick="switchTab('ai')">🤖 AI 审查 <span class="count">${report.aiCount}</span></button>
|
||||
</div>
|
||||
<div id="tab-linter" class="issue-list active">
|
||||
${this.buildLinterList(report)}
|
||||
</div>
|
||||
<div id="tab-custom" class="issue-list">
|
||||
${this.buildCustomList(report)}
|
||||
</div>
|
||||
<div id="tab-ai" class="issue-list">
|
||||
${this.buildAIList(report)}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="send('rerun')">🔄 重新审查</button>
|
||||
<button class="btn" onclick="send('export')">📄 导出</button>
|
||||
<button class="btn" onclick="send('settings')">⚙️ 设置</button>
|
||||
<button class="btn" onclick="send('fixAll')">🔧 批量修复</button>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
function send(type, line, ruleId, source) {
|
||||
vscode.postMessage({ type, line, ruleId, source });
|
||||
}
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.issue-list').forEach(l => l.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildLinterList(report: MergedReport): string {
|
||||
if (report.linterDiagnostics.length === 0) {
|
||||
return '<div class="empty">✅ 静态分析未发现问题</div>';
|
||||
}
|
||||
return report.linterDiagnostics.map((d, i) => `
|
||||
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
|
||||
<div class="issue-detail">${this.escape(d.message)}</div>
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">修复</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
private buildCustomList(report: MergedReport): string {
|
||||
if (report.customRuleDiagnostics.length === 0) {
|
||||
return '<div class="empty">✅ 自定义规则未发现问题</div>';
|
||||
}
|
||||
return report.customRuleDiagnostics.map((d, i) => `
|
||||
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
|
||||
<div class="issue-detail">${this.escape(d.message)}</div>
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">修复</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
private buildAIList(report: MergedReport): string {
|
||||
const total = report.translatedDiagnostics.length + report.aiFindings.length;
|
||||
if (total === 0) {
|
||||
return '<div class="empty">🤖 AI 审查未发现新问题</div>';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const td of report.translatedDiagnostics) {
|
||||
parts.push(`
|
||||
<div class="issue">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">🔵</span> <code>${this.escape(td.originalRuleId)}</code></div>
|
||||
<div class="issue-detail">${this.escape(td.translatedMessage)}</div>
|
||||
${td.translatedSuggestion ? `<div class="issue-detail">建议: ${this.escape(td.translatedSuggestion)}</div>` : ''}
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
for (const f of report.aiFindings) {
|
||||
parts.push(`
|
||||
<div class="issue" onclick="send('navigate', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">${this.sevIcon(f.severity)}</span> [${f.category}] <strong>${this.escape(f.title)}</strong></div>
|
||||
<div class="issue-detail">${this.escape(f.description)}</div>
|
||||
${f.suggestion ? `<div class="issue-detail">建议: ${this.escape(f.suggestion)}</div>` : ''}
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">修复</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
private sevIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'error': return '🔴';
|
||||
case 'warning': return '🟡';
|
||||
case 'info': return '🔵';
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
|
||||
private escape(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
private handleMessage(message: PanelMessage): 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);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'rerun':
|
||||
vscode.commands.executeCommand('codeReviewer.review');
|
||||
break;
|
||||
case 'export':
|
||||
vscode.commands.executeCommand('codeReviewer.exportReport');
|
||||
break;
|
||||
case 'settings':
|
||||
vscode.commands.executeCommand('codeReviewer.openSetup');
|
||||
break;
|
||||
case 'fix':
|
||||
vscode.commands.executeCommand('codeReviewer.fixIssue', message);
|
||||
break;
|
||||
case 'fixAll':
|
||||
vscode.commands.executeCommand('codeReviewer.fixAll');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
ReviewPanel.currentPanel = undefined;
|
||||
this.panel.dispose();
|
||||
for (const d of this.disposables) { d.dispose(); }
|
||||
this.disposables = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { CustomRule, Severity } from '../types';
|
||||
|
||||
interface RuleYamlItem {
|
||||
id: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
message: string;
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
interface RuleConfig {
|
||||
enabled?: string[];
|
||||
rules?: Record<string, { enabled: boolean }>;
|
||||
}
|
||||
|
||||
function parseYamlSimple(content: string): object[] {
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
let current: Record<string, unknown> | null = null;
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) { continue; }
|
||||
|
||||
if (trimmed.startsWith('- ')) {
|
||||
if (current) { items.push(current); }
|
||||
current = {};
|
||||
const indentMatch = trimmed.match(/^- (\w[\w-]*)\s*:\s*(.*)$/);
|
||||
if (indentMatch) {
|
||||
const key = indentMatch[1];
|
||||
const raw = indentMatch[2].trim();
|
||||
if (raw.startsWith('[') && raw.endsWith(']')) {
|
||||
current[key] = raw.slice(1, -1).split(',').map(s =>
|
||||
s.trim().replace(/^['"]|['"]$/g, '')
|
||||
);
|
||||
} else {
|
||||
current[key] = raw;
|
||||
}
|
||||
}
|
||||
} else if (current) {
|
||||
const propMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
|
||||
if (propMatch) {
|
||||
const key = propMatch[1];
|
||||
const raw = propMatch[2].trim();
|
||||
if (!raw || raw === '[]') {
|
||||
current[key] = [];
|
||||
} else if (raw.startsWith('[') && raw.endsWith(']')) {
|
||||
current[key] = raw.slice(1, -1).split(',').map(s =>
|
||||
s.trim().replace(/^['"]|['"]$/g, '')
|
||||
);
|
||||
} else {
|
||||
current[key] = raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current) { items.push(current); }
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseConfigYaml(content: string): RuleConfig {
|
||||
const config: RuleConfig = { enabled: [], rules: {} };
|
||||
let section: string | null = null;
|
||||
let currentKey = '';
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) { continue; }
|
||||
|
||||
if (trimmed === 'enabled:') {
|
||||
section = 'enabled';
|
||||
continue;
|
||||
}
|
||||
if (trimmed === 'rules:') {
|
||||
section = 'rules';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section === 'enabled' && trimmed.startsWith('- ')) {
|
||||
const name = trimmed.substring(2).trim();
|
||||
if (!config.enabled) { config.enabled = []; }
|
||||
config.enabled!.push(name);
|
||||
}
|
||||
|
||||
if (section === 'rules') {
|
||||
const ruleMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*$/);
|
||||
if (ruleMatch) {
|
||||
currentKey = ruleMatch[1];
|
||||
if (!config.rules) { config.rules = {}; }
|
||||
config.rules[currentKey] = { enabled: true };
|
||||
} else if (currentKey) {
|
||||
const propMatch = trimmed.match(/^(\w+)\s*:\s*(.*)$/);
|
||||
if (propMatch) {
|
||||
const key = propMatch[1];
|
||||
const value = propMatch[2].trim();
|
||||
if (!config.rules) { config.rules = {}; }
|
||||
if (!config.rules[currentKey]) { config.rules[currentKey] = { enabled: true }; }
|
||||
(config.rules[currentKey] as Record<string, unknown>)[key] =
|
||||
value === 'false' ? false : value === 'true' ? true : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function loadActiveRules(workspaceRoot: string): CustomRule[] {
|
||||
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
|
||||
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
|
||||
|
||||
if (!fs.existsSync(rulesDir)) { return []; }
|
||||
|
||||
let ruleConfig: RuleConfig = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
ruleConfig = parseConfigYaml(configContent);
|
||||
}
|
||||
|
||||
const enabledFiles = new Set(ruleConfig.enabled ?? []);
|
||||
const disabledRules = new Set(
|
||||
Object.entries(ruleConfig.rules ?? {})
|
||||
.filter(([, v]) => v.enabled === false)
|
||||
.map(([k]) => k)
|
||||
);
|
||||
|
||||
const allRules: CustomRule[] = [];
|
||||
|
||||
const files = fs.readdirSync(rulesDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
for (const file of files) {
|
||||
if (enabledFiles.size > 0 && !enabledFiles.has(file)) { continue; }
|
||||
|
||||
const content = fs.readFileSync(path.join(rulesDir, file), 'utf-8');
|
||||
const items = parseYamlSimple(content) as RuleYamlItem[];
|
||||
|
||||
for (const item of items) {
|
||||
if (disabledRules.has(item.id)) { continue; }
|
||||
if (!item.id || !item.severity || !item.description || !item.message) { continue; }
|
||||
|
||||
const severity = (['error', 'warning', 'info'].includes(item.severity)
|
||||
? (item.severity as Severity)
|
||||
: 'warning');
|
||||
|
||||
allRules.push({
|
||||
id: item.id,
|
||||
severity,
|
||||
description: item.description,
|
||||
message: item.message,
|
||||
languages: item.languages,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allRules;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface CustomRule {
|
||||
id: string;
|
||||
severity: Severity;
|
||||
description: string;
|
||||
message: string;
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
|
||||
|
||||
export interface LinterDiagnostic {
|
||||
severity: Severity;
|
||||
ruleId: string;
|
||||
message: string;
|
||||
range: vscode.Range;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
status: AdapterStatus;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface LinterAdapter {
|
||||
id: string;
|
||||
supportedLanguages: string[];
|
||||
check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult>;
|
||||
isAvailable(): boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number): (...args: Parameters<T>) => void {
|
||||
let timer: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { MergedReport } from '../merger/merger';
|
||||
|
||||
function severityEmoji(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'error': return '🔴';
|
||||
case 'warning': return '🟡';
|
||||
case 'info': return '🔵';
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
|
||||
function formatLine(line: number): string {
|
||||
return `L${line + 1}`;
|
||||
}
|
||||
|
||||
export function reportToMarkdown(report: MergedReport): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# 代码审查报告');
|
||||
lines.push('');
|
||||
lines.push(`**文件:** \`${report.filePath}\``);
|
||||
lines.push(`**语言:** ${report.language}`);
|
||||
lines.push(`**耗时:** ${(report.duration / 1000).toFixed(1)}s`);
|
||||
if (report.adapterNames.length > 0) {
|
||||
lines.push(`**分析工具:** ${report.adapterNames.join(', ')}`);
|
||||
}
|
||||
if (report.degraded) {
|
||||
lines.push('');
|
||||
lines.push('> ⚠️ 部分 AI 功能不可用,报告已降级');
|
||||
}
|
||||
if (report.errors.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('## 错误');
|
||||
for (const err of report.errors) {
|
||||
lines.push(`- ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
const total = report.linterCount + report.customRuleCount + report.aiCount;
|
||||
const errors = report.linterDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'error').length;
|
||||
const warnings = report.linterDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'warning').length;
|
||||
const infos = total - errors - warnings;
|
||||
|
||||
lines.push(`**总计:** ${total} | **错误:** ${errors} | **警告:** ${warnings} | **建议:** ${infos}`);
|
||||
lines.push('');
|
||||
|
||||
if (report.linterDiagnostics.length > 0) {
|
||||
lines.push(`## 🔧 静态分析 · ${report.linterCount} 个问题`);
|
||||
lines.push('');
|
||||
for (const diag of report.linterDiagnostics) {
|
||||
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
|
||||
lines.push(` ${diag.message}`);
|
||||
if (diag.suggestion) {
|
||||
lines.push(` 建议: ${diag.suggestion}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.customRuleDiagnostics.length > 0) {
|
||||
lines.push(`## 📋 自定义规则 · ${report.customRuleCount} 个问题`);
|
||||
lines.push('');
|
||||
for (const diag of report.customRuleDiagnostics) {
|
||||
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
|
||||
lines.push(` ${diag.message}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.aiFindings.length > 0) {
|
||||
lines.push(`## 🤖 AI 审查 · ${report.aiCount} 条建议`);
|
||||
lines.push('');
|
||||
for (const finding of report.aiFindings) {
|
||||
lines.push(`- ${severityEmoji(finding.severity)} [AI] [${finding.category}] \`${finding.ruleId}\` ${formatLine(finding.line)}`);
|
||||
lines.push(` **${finding.title}**`);
|
||||
lines.push(` ${finding.description}`);
|
||||
if (finding.suggestion) {
|
||||
lines.push(` 建议: ${finding.suggestion}`);
|
||||
}
|
||||
if (finding.codeDiff) {
|
||||
lines.push(' ```diff');
|
||||
lines.push(` ${finding.codeDiff.split('\n').join('\n ')}`);
|
||||
lines.push(' ```');
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
lines.push('✅ 未发现问题');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { getAIConfig, setApiKey, getApiKey, isApiKeyConfigured } from '../config';
|
||||
import { createProvider } from '../ai/factory';
|
||||
import { loadActiveRules } from '../rules/yaml-parser';
|
||||
import type { CustomRule } from '../types';
|
||||
|
||||
type SetupItemType = 'section' | 'step' | 'providerGroup' | 'provider' | 'model' | 'apiKey' | 'language' | 'rule' | 'ruleAdd' | 'action';
|
||||
|
||||
class SetupItem extends vscode.TreeItem {
|
||||
constructor(
|
||||
public readonly label: string,
|
||||
public readonly itemType: SetupItemType,
|
||||
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
|
||||
public readonly command?: vscode.Command,
|
||||
public readonly iconPath?: vscode.ThemeIcon,
|
||||
public readonly description?: string,
|
||||
public readonly contextValue?: string,
|
||||
) {
|
||||
super(label, collapsibleState);
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupViewProvider implements vscode.TreeDataProvider<SetupItem> {
|
||||
private _onDidChangeTreeData = new vscode.EventEmitter<SetupItem | undefined>();
|
||||
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||
|
||||
private customRules: CustomRule[] = [];
|
||||
private apiKeyConfigured = false;
|
||||
public connectionTested = false;
|
||||
public connectionSuccess = false;
|
||||
|
||||
constructor(private context: vscode.ExtensionContext) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
this.customRules = loadActiveRules(workspaceRoot);
|
||||
this.apiKeyConfigured = await isApiKeyConfigured(this.context);
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
getTreeItem(element: SetupItem): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
async getChildren(element?: SetupItem): Promise<SetupItem[]> {
|
||||
if (!element) {
|
||||
return this.getRootItems();
|
||||
}
|
||||
|
||||
switch (element.itemType) {
|
||||
case 'providerGroup': return this.getProviderItems();
|
||||
case 'apiKey': return this.getApiKeyItems();
|
||||
case 'language': return this.getLanguageItems();
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getRootItems(): SetupItem[] {
|
||||
const items: SetupItem[] = [];
|
||||
|
||||
const step1Done = this.apiKeyConfigured;
|
||||
const step2Done = this.customRules.some(r => r.id);
|
||||
const step3Done = this.connectionTested && this.connectionSuccess;
|
||||
|
||||
items.push(new SetupItem(
|
||||
'快速开始',
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Expanded,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'section'
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step1Done ? '① 完成 AI 模型配置' : '① 配置 AI 模型及 API Key',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
step1Done ? undefined : {
|
||||
command: 'codeReviewer.focusApiKey',
|
||||
title: '配置 API Key',
|
||||
},
|
||||
step1Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step2Done ? '② 完成规则启用' : '② 启用自定义规则',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
undefined,
|
||||
step2Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step3Done ? '③ 完成连接测试' : '③ 保存并测试连接',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
step3Done ? undefined : {
|
||||
command: 'codeReviewer.saveAndTest',
|
||||
title: '测试连接',
|
||||
},
|
||||
step3Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'审核引擎',
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'AI 模型配置',
|
||||
'providerGroup',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'API Key',
|
||||
'apiKey',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'输出语言',
|
||||
'language',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
`自定义规则 [${this.customRules.length} 条]`,
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Expanded
|
||||
));
|
||||
|
||||
for (const rule of this.customRules) {
|
||||
items.push(new SetupItem(
|
||||
rule.id,
|
||||
'rule',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.toggleRule',
|
||||
title: '切换规则',
|
||||
arguments: [rule.id],
|
||||
},
|
||||
undefined,
|
||||
rule.severity,
|
||||
'rule'
|
||||
));
|
||||
}
|
||||
|
||||
items.push(new SetupItem(
|
||||
'输入规则名称... [+ 添加]',
|
||||
'ruleAdd',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.addCustomRule',
|
||||
title: '添加规则',
|
||||
}
|
||||
));
|
||||
|
||||
const connectionLabel = this.connectionTested
|
||||
? (this.connectionSuccess ? '✓ 已连接' : '✗ 重试')
|
||||
: '保存并测试连接';
|
||||
|
||||
items.push(new SetupItem(
|
||||
connectionLabel,
|
||||
'action',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.saveAndTest',
|
||||
title: '测试连接',
|
||||
}
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private getProviderItems(): SetupItem[] {
|
||||
const config = getAIConfig();
|
||||
return [
|
||||
new SetupItem(`提供商: ${config.provider}`,
|
||||
'provider',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectProvider',
|
||||
title: '选择提供商',
|
||||
}
|
||||
),
|
||||
new SetupItem(`模型: ${config.model}`,
|
||||
'model',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectModel',
|
||||
title: '选择模型',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private getApiKeyItems(): SetupItem[] {
|
||||
return [
|
||||
new SetupItem(
|
||||
'设置 API Key...',
|
||||
'apiKey',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.setApiKey',
|
||||
title: '设置 API Key',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private getLanguageItems(): SetupItem[] {
|
||||
const config = getAIConfig();
|
||||
return [
|
||||
new SetupItem(
|
||||
`当前: ${config.outputLanguage === 'zh-CN' ? '中文(简体)' : config.outputLanguage}`,
|
||||
'language',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectLanguage',
|
||||
title: '选择输出语言',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user