refactor: 品牌重命名 + maxTokens 支持 + 设置面板简化
- CodeGuard → Code Purifier / 净码特工(displayName、命令、配置标题) - 新增 ai.maxTokens 配置项,所有 Provider 及 fixer 传入 maxTokens - AI 引擎增强:repairJsonEscapes + JSON 解析 fallback + 详细错误信息 - 设置面板规则管理改为文件级(list/delete .yaml),addRule 改为 AI 从 Markdown 生成 YAML - yaml-parser 简化:移除 config.yaml 的 enable/disable 过滤逻辑 - 审查报告面板:errorBanner 优先显示具体错误、lint 诊断显示 suggestion、移除 translatedDiagnostics 独立渲染 - merger 中 translatedDiagnostics 覆盖原始 lint 诊断 message/suggestion - HTML linter 配置项、测试用例重写、typescript-eslint 移入 dependencies
This commit is contained in:
+22
-2
@@ -1,20 +1,40 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { ESLint } from 'eslint';
|
||||
import js from '@eslint/js';
|
||||
import ts from 'typescript-eslint';
|
||||
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
|
||||
|
||||
export class ESLintAdapter implements LinterAdapter {
|
||||
id = 'eslint';
|
||||
supportedLanguages = ['javascript', 'typescript'];
|
||||
|
||||
private static defaultConfig: any[] | null = null;
|
||||
|
||||
private static getDefaultConfig(): any[] {
|
||||
if (!ESLintAdapter.defaultConfig) {
|
||||
ESLintAdapter.defaultConfig = [
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
];
|
||||
}
|
||||
return ESLintAdapter.defaultConfig;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const engine = new ESLint({ cwd: workingDir });
|
||||
const engine = new ESLint({
|
||||
cwd: workingDir,
|
||||
overrideConfigFile: true,
|
||||
overrideConfig: ESLintAdapter.getDefaultConfig(),
|
||||
});
|
||||
const ext = document.languageId === 'typescript' ? 'ts' : 'js';
|
||||
const isVirtual = document.uri.scheme === 'untitled';
|
||||
const results = await engine.lintText(document.getText(), {
|
||||
filePath: document.fileName || 'untitled.ts',
|
||||
filePath: isVirtual ? `untitled.${ext}` : document.fileName,
|
||||
});
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
|
||||
+49
-6
@@ -6,9 +6,56 @@ import { StylelintAdapter } from './stylelint';
|
||||
import { extractJspSections } 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;
|
||||
}
|
||||
|
||||
export class JspAdapter implements LinterAdapter {
|
||||
id = 'jsp';
|
||||
supportedLanguages = ['jsp'];
|
||||
supportedLanguages = ['jsp', 'html'];
|
||||
|
||||
private pmdAdapter = new PmdAdapter();
|
||||
private eslintAdapter = new ESLintAdapter();
|
||||
@@ -40,11 +87,7 @@ export class JspAdapter implements LinterAdapter {
|
||||
if (!adapter) { continue; }
|
||||
|
||||
try {
|
||||
const virtualDoc = await vscode.workspace.openTextDocument({
|
||||
content: section.code,
|
||||
language: section.language,
|
||||
});
|
||||
const result = await adapter.check(virtualDoc, workingDir);
|
||||
const result = await adapter.check(mockDocument(section.code, section.language), workingDir);
|
||||
|
||||
for (const diag of result.diagnostics) {
|
||||
const adjustedRange = new vscode.Range(
|
||||
|
||||
+27
-14
@@ -1,5 +1,6 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { getPMDRulesetPath } from '../config';
|
||||
@@ -7,24 +8,36 @@ import { getPMDRulesetPath } from '../config';
|
||||
export class PmdAdapter implements LinterAdapter {
|
||||
id = 'pmd';
|
||||
supportedLanguages = ['java'];
|
||||
private pmdDir: string | null = null;
|
||||
|
||||
private resolvePmdDir(): string {
|
||||
if (this.pmdDir) { return this.pmdDir; }
|
||||
const candidates: string[] = [];
|
||||
try {
|
||||
const ext = vscode.extensions.getExtension?.('vscode-code-reviewer');
|
||||
if (ext?.extensionPath) {
|
||||
candidates.push(path.join(ext.extensionPath, 'jars', 'pmd'));
|
||||
candidates.push(path.join(ext.extensionPath, 'out', 'jars', 'pmd'));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
candidates.push(path.join(path.resolve(__dirname, '..'), 'jars', 'pmd'));
|
||||
candidates.push(path.join(path.resolve(__dirname, '..', '..'), 'jars', 'pmd'));
|
||||
for (const dir of candidates) {
|
||||
if (existsSync(path.join(dir, 'PmdRunner.class'))) {
|
||||
this.pmdDir = dir;
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
this.pmdDir = candidates[0];
|
||||
return this.pmdDir;
|
||||
}
|
||||
|
||||
private getPmdLibClasspath(): string {
|
||||
const extRoot = this.getExtensionRoot();
|
||||
const pmdLib = path.join(extRoot, 'jars', 'pmd', 'lib');
|
||||
return path.join(pmdLib, '*');
|
||||
return path.join(this.resolvePmdDir(), 'lib', '*');
|
||||
}
|
||||
|
||||
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, '..', '..');
|
||||
return this.resolvePmdDir();
|
||||
}
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
@@ -43,7 +56,7 @@ export class PmdAdapter implements LinterAdapter {
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('ENOENT') || message.includes('java')) {
|
||||
if (message.includes('ENOENT') || message.includes('java not found') || message.includes('Cannot find')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Java 11+ 未安装或不在 PATH 中' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
|
||||
@@ -8,9 +8,11 @@ const DIALECT_MAP: Record<string, string> = {
|
||||
};
|
||||
|
||||
interface SqlFluffViolation {
|
||||
line_no: number;
|
||||
line_pos: number;
|
||||
rule_code: string;
|
||||
start_line_no: number;
|
||||
start_line_pos: number;
|
||||
end_line_no: number;
|
||||
end_line_pos: number;
|
||||
code: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
@@ -74,13 +76,13 @@ export class SqlLintAdapter implements LinterAdapter {
|
||||
for (const v of result.violations) {
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
ruleId: `sql-lint:${v.rule_code}`,
|
||||
ruleId: `sql-lint:${v.code}`,
|
||||
message: v.description,
|
||||
range: new vscode.Range(
|
||||
v.line_no - 1,
|
||||
v.line_pos - 1,
|
||||
v.line_no - 1,
|
||||
v.line_pos - 1
|
||||
v.start_line_no - 1,
|
||||
v.start_line_pos - 1,
|
||||
v.end_line_no - 1,
|
||||
v.end_line_pos - 1
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,41 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { LinterAdapter, AdapterResult, LinterDiagnostic, Severity } from './adapter';
|
||||
|
||||
const CONFIG_FILE_NAMES = [
|
||||
'.stylelintrc',
|
||||
'.stylelintrc.json',
|
||||
'.stylelintrc.yaml',
|
||||
'.stylelintrc.yml',
|
||||
'.stylelintrc.js',
|
||||
'stylelint.config.js',
|
||||
'stylelint.config.mjs',
|
||||
'stylelint.config.cjs',
|
||||
];
|
||||
|
||||
const DEFAULT_CONFIG: Record<string, unknown> = {
|
||||
rules: {
|
||||
'color-hex-length': 'short',
|
||||
'color-named': 'never',
|
||||
'color-no-invalid-hex': true,
|
||||
'length-zero-no-unit': true,
|
||||
'font-family-no-missing-generic-family-keyword': true,
|
||||
'block-no-empty': true,
|
||||
'declaration-block-no-duplicate-properties': true,
|
||||
'no-descending-specificity': true,
|
||||
'unit-no-unknown': true,
|
||||
'property-no-unknown': true,
|
||||
'selector-pseudo-class-no-unknown': true,
|
||||
'selector-pseudo-element-no-unknown': true,
|
||||
},
|
||||
};
|
||||
|
||||
interface LinterOptions {
|
||||
code?: string;
|
||||
codeFilename?: string;
|
||||
cwd?: string;
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface LinterResult {
|
||||
@@ -21,6 +52,14 @@ interface LinterResult {
|
||||
}>;
|
||||
}
|
||||
|
||||
function hasExternalConfig(dir: string): boolean {
|
||||
try {
|
||||
return CONFIG_FILE_NAMES.some(name => fs.existsSync(path.join(dir, name)));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export class StylelintAdapter implements LinterAdapter {
|
||||
id = 'stylelint';
|
||||
supportedLanguages = ['css'];
|
||||
@@ -29,7 +68,8 @@ export class StylelintAdapter implements LinterAdapter {
|
||||
|
||||
private async getModule(): Promise<{ lint: (opts: LinterOptions) => Promise<LinterResult> }> {
|
||||
if (!this._module) {
|
||||
this._module = await import('stylelint') as { lint: (opts: LinterOptions) => Promise<LinterResult> };
|
||||
const mod = await import('stylelint');
|
||||
this._module = (mod.default ?? mod) as { lint: (opts: LinterOptions) => Promise<LinterResult> };
|
||||
}
|
||||
return this._module;
|
||||
}
|
||||
@@ -41,11 +81,18 @@ export class StylelintAdapter implements LinterAdapter {
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const stylelint = await this.getModule();
|
||||
const result = await stylelint.lint({
|
||||
|
||||
const lintOptions: LinterOptions = {
|
||||
code: document.getText(),
|
||||
codeFilename: document.fileName,
|
||||
cwd: workingDir,
|
||||
});
|
||||
};
|
||||
|
||||
if (!hasExternalConfig(workingDir)) {
|
||||
lintOptions.config = DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
const result = await stylelint.lint(lintOptions);
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const res of result.results) {
|
||||
|
||||
+50
-8
@@ -1,7 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { AIProvider } from './providers/base';
|
||||
import { createProvider } from './factory';
|
||||
import { getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIOutputLanguage, getApiKey } from '../config';
|
||||
import { getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage, getApiKey } from '../config';
|
||||
import type { LinterDiagnostic, CustomRule } from '../types';
|
||||
import type {
|
||||
AIEngineResult,
|
||||
@@ -26,14 +26,54 @@ function addLineNumbers(code: string): string {
|
||||
return code.split('\n').map((line, i) => `${String(i + 1).padStart(4, ' ')}| ${line}`).join('\n');
|
||||
}
|
||||
|
||||
function repairJsonEscapes(str: string): string {
|
||||
let s = str.replace(/\\"\\n/g, '\\n').replace(/\\"/g, '"');
|
||||
let inString = false;
|
||||
let out = '';
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '\\') {
|
||||
out += ch;
|
||||
if (i + 1 < s.length) { out += s[++i]; }
|
||||
} else if (ch === '"') {
|
||||
if (!inString) {
|
||||
inString = true;
|
||||
out += ch;
|
||||
} else {
|
||||
let j = i + 1;
|
||||
while (j < s.length && s[j] === ' ') { j++; }
|
||||
if (j < s.length && ':,\]}'.includes(s[j])) {
|
||||
inString = false;
|
||||
out += ch;
|
||||
} else {
|
||||
out += '\\"';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out += ch;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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');
|
||||
throw new Error(`响应中未找到 JSON。原始响应(前200字符):${trimmed.slice(0, 200)}`);
|
||||
}
|
||||
let jsonStr = trimmed.substring(start, end + 1);
|
||||
try {
|
||||
return JSON.parse(jsonStr);
|
||||
} catch {
|
||||
jsonStr = repairJsonEscapes(jsonStr);
|
||||
try {
|
||||
return JSON.parse(jsonStr);
|
||||
} catch {
|
||||
throw new Error(`JSON 解析失败。原始响应(前200字符):${jsonStr.slice(0, 200)}`);
|
||||
}
|
||||
}
|
||||
return JSON.parse(trimmed.substring(start, end + 1));
|
||||
}
|
||||
|
||||
const CUSTOM_RULE_SYSTEM_PROMPT = `你是代码规则审查员,只评估以下自定义规则是否被违反。
|
||||
@@ -48,7 +88,8 @@ const DEEP_REVIEW_SYSTEM_PROMPT = `你是资深代码审查专家,完成两个
|
||||
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
|
||||
不要重复静态分析已报告的问题。
|
||||
|
||||
仅输出 JSON,格式:
|
||||
仅输出 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": 行号 }]
|
||||
@@ -91,6 +132,7 @@ export async function runAIReview(
|
||||
const options = {
|
||||
model: getAIModel(),
|
||||
temperature: getAITemperature(),
|
||||
maxTokens: getAIMaxTokens(),
|
||||
timeoutMs: getAITimeout() * 1000,
|
||||
};
|
||||
|
||||
@@ -123,8 +165,8 @@ export async function runAIReview(
|
||||
...r,
|
||||
ruleId: `custom:${r.ruleId}`,
|
||||
}));
|
||||
} catch {
|
||||
errors.push('自定义规则响应解析失败');
|
||||
} catch (e) {
|
||||
errors.push(`自定义规则响应解析失败: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`自定义规则请求失败: ${resultA.reason}`);
|
||||
@@ -140,8 +182,8 @@ export async function runAIReview(
|
||||
};
|
||||
translatedDiagnostics = parsed.translatedDiagnostics ?? [];
|
||||
findings = parsed.findings ?? [];
|
||||
} catch {
|
||||
errors.push('AI 审查响应解析失败');
|
||||
} catch (e) {
|
||||
errors.push(`AI 审查响应解析失败: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
} else {
|
||||
errors.push(`AI 审查请求失败: ${resultB.reason}`);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface ChatOptions {
|
||||
model: string;
|
||||
temperature: number;
|
||||
maxTokens: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ export class ClaudeProvider extends AIProvider {
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: options.model,
|
||||
max_tokens: 4096,
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
system: systemPrompt,
|
||||
messages: [
|
||||
|
||||
@@ -18,6 +18,7 @@ export class GeminiProvider extends AIProvider {
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: options.temperature,
|
||||
maxOutputTokens: options.maxTokens,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ export class OpenAICompatibleProvider extends AIProvider {
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: options.model,
|
||||
max_tokens: options.maxTokens,
|
||||
temperature: options.temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
|
||||
@@ -22,6 +22,10 @@ export function getAITimeout(): number {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<number>('ai.timeout', 300);
|
||||
}
|
||||
|
||||
export function getAIMaxTokens(): number {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<number>('ai.maxTokens', 8192);
|
||||
}
|
||||
|
||||
export function getAIOutputLanguage(): string {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.outputLanguage', 'zh-CN');
|
||||
}
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { SetupViewProvider } from './views/setupView';
|
||||
let orchestrator: Orchestrator;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
console.log('CodeGuard 代码审查插件已激活');
|
||||
console.log('净码特工 · Code Purifier 已激活');
|
||||
|
||||
orchestrator = new Orchestrator();
|
||||
|
||||
|
||||
@@ -136,6 +136,7 @@ export async function generateFix(
|
||||
const response = await provider.chat(FIX_SYSTEM_PROMPT, userPrompt, {
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: 4096,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ export function mergeResults(input: MergeInput): MergedReport {
|
||||
range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
|
||||
}));
|
||||
|
||||
const linterDiagnostics = input.staticDiagnostics.map((d, i) => {
|
||||
const td = input.translatedDiagnostics[i];
|
||||
if (td) {
|
||||
return { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion };
|
||||
}
|
||||
return d;
|
||||
});
|
||||
|
||||
const linterCount = input.staticDiagnostics.length;
|
||||
const customRuleCount = customRuleDiagnostics.length;
|
||||
const aiCount = input.aiFindings.length;
|
||||
@@ -53,7 +61,7 @@ export function mergeResults(input: MergeInput): MergedReport {
|
||||
.map((_, i) => i);
|
||||
|
||||
return {
|
||||
linterDiagnostics: input.staticDiagnostics,
|
||||
linterDiagnostics,
|
||||
customRuleDiagnostics,
|
||||
translatedDiagnostics: input.translatedDiagnostics,
|
||||
aiFindings: input.aiFindings,
|
||||
|
||||
+8
-20
@@ -63,12 +63,11 @@ export class ReviewPanel {
|
||||
|
||||
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>`
|
||||
: '';
|
||||
const errorBanner = report.errors.length > 0
|
||||
? `<div class="banner banner-error">⚠ ${report.errors.join('; ')}</div>`
|
||||
: report.degraded
|
||||
? '<div class="banner banner-warn">⚠ 部分 AI 功能不可用</div>'
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
@@ -119,7 +118,7 @@ export class ReviewPanel {
|
||||
<h1>📋 代码审查报告</h1>
|
||||
<div class="meta">${fileName} · ${report.language} · ${(report.duration / 1000).toFixed(1)}s</div>
|
||||
</div>
|
||||
${degradedBanner}
|
||||
${errorBanner}
|
||||
<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>
|
||||
@@ -171,6 +170,7 @@ ${degradedBanner}
|
||||
<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>
|
||||
${d.suggestion ? `<div class="issue-detail">建议: ${this.escape(d.suggestion)}</div>` : ''}
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">修复</button>
|
||||
@@ -195,23 +195,11 @@ ${degradedBanner}
|
||||
}
|
||||
|
||||
private buildAIList(report: MergedReport): string {
|
||||
const total = report.translatedDiagnostics.length + report.aiFindings.length;
|
||||
if (total === 0) {
|
||||
if (report.aiFindings.length === 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')">
|
||||
|
||||
@@ -10,11 +10,6 @@ interface RuleYamlItem {
|
||||
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;
|
||||
@@ -60,89 +55,20 @@ function parseYamlSimple(content: string): object[] {
|
||||
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,
|
||||
@@ -152,6 +78,11 @@ export function loadActiveRules(workspaceRoot: string): CustomRule[] {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allRules;
|
||||
}
|
||||
|
||||
export function listRuleFiles(workspaceRoot: string): string[] {
|
||||
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
|
||||
if (!fs.existsSync(rulesDir)) { return []; }
|
||||
return fs.readdirSync(rulesDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import * as assert from 'assert';
|
||||
|
||||
// You can import and use all API from the 'vscode' module
|
||||
// as well as import your extension to test it
|
||||
import * as vscode from 'vscode';
|
||||
// import * as myExtension from '../../extension';
|
||||
|
||||
suite('Extension Test Suite', () => {
|
||||
vscode.window.showInformationMessage('Start all tests.');
|
||||
|
||||
test('Sample test', () => {
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(5));
|
||||
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
|
||||
test('Extension registers codeReviewer commands', async () => {
|
||||
const ext = vscode.extensions.getExtension('undefined.vscode-code-reviewer')
|
||||
|| vscode.extensions.all.find(e => e.id.includes('vscode-code-reviewer'));
|
||||
if (ext && !ext.isActive) {
|
||||
await ext.activate();
|
||||
}
|
||||
|
||||
const commands = await vscode.commands.getCommands(false);
|
||||
const reviewerCommands = commands.filter(c => c.startsWith('codeReviewer.'));
|
||||
assert.ok(reviewerCommands.length > 0, 'No codeReviewer.* commands found');
|
||||
assert.ok(reviewerCommands.includes('codeReviewer.review'));
|
||||
assert.ok(reviewerCommands.includes('codeReviewer.openPanel'));
|
||||
});
|
||||
});
|
||||
|
||||
+11
-17
@@ -16,12 +16,8 @@
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function toggleRule(ruleId) {
|
||||
vscode.postMessage({ type: 'toggleRule', ruleId: ruleId });
|
||||
}
|
||||
|
||||
function deleteRule(ruleId) {
|
||||
vscode.postMessage({ type: 'deleteRule', ruleId: ruleId });
|
||||
function deleteFile(fileName) {
|
||||
vscode.postMessage({ type: 'deleteFile', fileName: fileName });
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
@@ -113,23 +109,22 @@
|
||||
|
||||
var ruleList = document.getElementById('ruleList');
|
||||
var countBadge = document.getElementById('ruleCountBadge');
|
||||
if (msg.rules && msg.rules.length > 0) {
|
||||
countBadge.textContent = msg.rules.length + ' 条';
|
||||
if (msg.ruleFiles && msg.ruleFiles.length > 0) {
|
||||
countBadge.textContent = msg.ruleFiles.length + ' 个文件';
|
||||
countBadge.className = 'badge badge-configured';
|
||||
ruleList.innerHTML = msg.rules.map(function (r) {
|
||||
ruleList.innerHTML = msg.ruleFiles.map(function (f) {
|
||||
return '<div class="rule-item">' +
|
||||
'<label class="switch"><input type="checkbox" onclick="toggleRule(\'' + r.id + '\')" ' + (r.severity !== 'info' ? 'checked' : '') + '><div class="switch-track"></div><div class="switch-thumb"></div></label>' +
|
||||
'<span class="rule-name">' + escapeHtml(r.id) + '</span>' +
|
||||
'<button class="rule-del" onclick="deleteRule(\'' + r.id + '\')">×</button></div>';
|
||||
'<span class="rule-name">' + escapeHtml(f) + '</span>' +
|
||||
'<button class="rule-del" onclick="deleteFile(\'' + escapeHtml(f) + '\')">×</button></div>';
|
||||
}).join('');
|
||||
} else {
|
||||
countBadge.textContent = '0 条';
|
||||
countBadge.textContent = '0 个文件';
|
||||
countBadge.className = 'badge badge-unconfigured';
|
||||
ruleList.innerHTML = '<div style="font-size:12px;color:#484f58;padding:8px 0;text-align:center;">暂无规则</div>';
|
||||
ruleList.innerHTML = '<div style="font-size:12px;color:#484f58;padding:8px 0;text-align:center;">暂无规则文件</div>';
|
||||
}
|
||||
|
||||
var step1Done = c.provider && c.model && c.apiKeyConfigured && c.baseUrlConfigured;
|
||||
var step2Done = msg.rules && msg.rules.length > 0;
|
||||
var step2Done = msg.ruleFiles && msg.ruleFiles.length > 0;
|
||||
var step3Done = msg.connectionTested && msg.connectionSuccess;
|
||||
var steps = [step1Done, step2Done, step3Done];
|
||||
for (var i = 0; i < steps.length; i++) {
|
||||
@@ -164,6 +159,5 @@
|
||||
|
||||
window.postMsg = postMsg;
|
||||
window.addRule = addRule;
|
||||
window.toggleRule = toggleRule;
|
||||
window.deleteRule = deleteRule;
|
||||
window.deleteFile = deleteFile;
|
||||
})();
|
||||
|
||||
+81
-67
@@ -4,8 +4,7 @@ import * as fs from 'fs';
|
||||
import { getAIProvider, getAIModel, getAIOutputLanguage, getAIConfig } from '../config/ai';
|
||||
import { getApiKey, setApiKey } from '../config/secret';
|
||||
import { createProvider, getAllProviderMeta, getProviderModels } from '../ai/factory';
|
||||
import { loadActiveRules } from '../rules/yaml-parser';
|
||||
import type { CustomRule } from '../types';
|
||||
import { listRuleFiles } from '../rules/yaml-parser';
|
||||
|
||||
const languageLabels: Record<string, string> = {
|
||||
'zh-CN': '中文(简体)',
|
||||
@@ -35,7 +34,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
|
||||
const config = getAIConfig();
|
||||
const providers = getAllProviderMeta();
|
||||
const scriptUri = webviewView.webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.context.extensionUri, 'out', 'views', 'setupView.js')
|
||||
vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview', 'setupView.js')
|
||||
);
|
||||
const aiConfig = getAIConfig();
|
||||
webviewView.webview.html = this.getHtml(providers, aiConfig, scriptUri);
|
||||
@@ -79,12 +78,8 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
|
||||
case 'saveAndTest':
|
||||
await this.testConnection();
|
||||
break;
|
||||
case 'toggleRule':
|
||||
await this.toggleRule(msg.ruleId);
|
||||
await this.pushConfig();
|
||||
break;
|
||||
case 'deleteRule':
|
||||
await this.deleteRule(msg.ruleId);
|
||||
case 'deleteFile':
|
||||
await this.deleteFile(msg.fileName);
|
||||
await this.pushConfig();
|
||||
break;
|
||||
case 'addRule':
|
||||
@@ -105,13 +100,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
|
||||
const config = getAIConfig();
|
||||
const apiKeyConfigured = await isApiKeyConfigured(this.context);
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const rules = loadActiveRules(workspaceRoot);
|
||||
|
||||
const ruleItems = rules.map(r => ({
|
||||
id: r.id,
|
||||
severity: r.severity,
|
||||
description: r.description,
|
||||
}));
|
||||
const ruleFiles = listRuleFiles(workspaceRoot);
|
||||
|
||||
const baseUrlConfigured = isBaseUrlConfigured();
|
||||
|
||||
@@ -127,7 +116,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
|
||||
apiKeyConfigured,
|
||||
},
|
||||
providers: getAllProviderMeta(),
|
||||
rules: ruleItems,
|
||||
ruleFiles,
|
||||
connectionTested: this.connectionTested,
|
||||
connectionSuccess: this.connectionSuccess,
|
||||
});
|
||||
@@ -152,6 +141,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
|
||||
await provider.chat('回复 ok', 'ping', {
|
||||
model: config.model,
|
||||
temperature: 0,
|
||||
maxTokens: 1024,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
this.connectionTested = true;
|
||||
@@ -167,47 +157,13 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
|
||||
await this.pushConfig();
|
||||
}
|
||||
|
||||
private async toggleRule(ruleId: string): Promise<void> {
|
||||
private async deleteFile(fileName: string): Promise<void> {
|
||||
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 pattern = new RegExp(`^(\\s*${ruleId}\\s*:\\s*\\n\\s*enabled\\s*:\\s*)(true|false)`, 'm');
|
||||
|
||||
if (pattern.test(content)) {
|
||||
const match = pattern.exec(content);
|
||||
if (match) {
|
||||
const newValue = match[2] === 'true' ? 'false' : 'true';
|
||||
content = content.replace(pattern, `$1${newValue}`);
|
||||
}
|
||||
} else {
|
||||
content += `\n ${ruleId}:\n enabled: false\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
}
|
||||
|
||||
private async deleteRule(ruleId: string): Promise<void> {
|
||||
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 pattern = new RegExp(`^\\s*${ruleId}\\s*:\\n(?:\\s+.*\\n)*`, 'm');
|
||||
content = content.replace(pattern, '');
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
|
||||
const userRulesPath = path.join(workspaceRoot, '.code-review', 'rules', 'user-rules.yaml');
|
||||
if (fs.existsSync(userRulesPath)) {
|
||||
let rulesContent = fs.readFileSync(userRulesPath, 'utf-8');
|
||||
const rulePattern = new RegExp(`(?:^|\\n)- id: ${ruleId}\\n(?: .*\\n)*`, '');
|
||||
rulesContent = rulesContent.replace(rulePattern, '');
|
||||
fs.writeFileSync(userRulesPath, rulesContent, 'utf-8');
|
||||
const filePath = path.join(workspaceRoot, '.code-review', 'rules', fileName);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,27 +173,85 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) {return;}
|
||||
|
||||
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
|
||||
if (!fs.existsSync(configPath)) {
|
||||
fs.writeFileSync(configPath, 'rules:\n', 'utf-8');
|
||||
const result = await vscode.window.showOpenDialog({
|
||||
canSelectMany: false,
|
||||
openLabel: '选择 Markdown 规则文件',
|
||||
filters: { 'Markdown': ['md'] },
|
||||
});
|
||||
if (!result || result.length === 0) {return;}
|
||||
|
||||
const mdPath = result[0].fsPath;
|
||||
const mdContent = fs.readFileSync(mdPath, 'utf-8');
|
||||
if (!mdContent.trim()) {
|
||||
vscode.window.showErrorMessage('所选文件为空');
|
||||
return;
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(configPath, 'utf-8');
|
||||
if (!content.includes('rules:')) {
|
||||
content += '\nrules:\n';
|
||||
const apiKey = await getApiKey(this.context);
|
||||
if (!apiKey) {
|
||||
vscode.window.showErrorMessage('请先在设置面板中配置 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
content += ` ${name}:\n enabled: true\n`;
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
|
||||
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
|
||||
if (!fs.existsSync(rulesDir)) {
|
||||
fs.mkdirSync(rulesDir, { recursive: true });
|
||||
}
|
||||
|
||||
const userRulesPath = path.join(rulesDir, 'user-rules.yaml');
|
||||
const ruleEntry = `\n- id: ${name}\n severity: warning\n description: 请编辑规则描述\n message: 违反规则,请修改\n`;
|
||||
fs.appendFileSync(userRulesPath, ruleEntry, 'utf-8');
|
||||
const yamlFileName = name.endsWith('.yaml') ? name : `${name}.yaml`;
|
||||
const yamlPath = path.join(rulesDir, yamlFileName);
|
||||
|
||||
if (fs.existsSync(yamlPath)) {
|
||||
vscode.window.showErrorMessage(`文件 ${yamlFileName} 已存在`);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getAIConfig();
|
||||
const provider = createProvider(config.provider, apiKey, config.baseUrl);
|
||||
|
||||
const systemPrompt = `你是一个代码审查规则转换器。将用户提供的自然语言规则描述,转换为结构化的 YAML 格式,用于代码审查工具。
|
||||
|
||||
每条规则需要包含以下字段:
|
||||
- id: 规则唯一标识(kebab-case 英文)
|
||||
- severity: 严重级别(error / warning / info)
|
||||
- description: 规则简短描述(中文)
|
||||
- message: 违反时的提示消息(中文)
|
||||
- languages: 适用语言数组(可选,如 [javascript, typescript])
|
||||
|
||||
输出格式示例:
|
||||
- id: no-console-log
|
||||
severity: warning
|
||||
description: 禁止使用 console.log
|
||||
message: 请使用 logger 工具替代 console.log
|
||||
languages: [javascript, typescript]
|
||||
|
||||
仅输出 YAML,不要额外说明。`;
|
||||
|
||||
let yamlOutput: string;
|
||||
try {
|
||||
yamlOutput = await provider.chat(systemPrompt, mdContent, {
|
||||
model: config.model,
|
||||
temperature: 0.1,
|
||||
maxTokens: 4096,
|
||||
timeoutMs: 30000,
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
vscode.window.showErrorMessage(`AI 生成规则失败: ${msg}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cleaned = yamlOutput
|
||||
.replace(/```(yaml|yml)?\s*/gi, '')
|
||||
.replace(/```\s*$/gm, '')
|
||||
.trim();
|
||||
|
||||
if (!cleaned) {
|
||||
vscode.window.showErrorMessage('AI 返回内容为空');
|
||||
return;
|
||||
}
|
||||
|
||||
fs.writeFileSync(yamlPath, cleaned, 'utf-8');
|
||||
}
|
||||
|
||||
private async resetConfig(): Promise<void> {
|
||||
|
||||
Reference in New Issue
Block a user