feat: 适配器 i18n + Provider 动态注册 + SetupView 重构 + jars 资源

- 适配器 i18n 接入(eslint/pmd/sql-lint/stylelint)
- Provider 动态注册机制(registry.ts + providers.json + factory 重构)
- SetupView 全面重构(setupView.ts 新增 600+ 行)
- i18n 消息扩展(messages.ts +210 行)
- 规则导入流程优化(import-service / prompt-builder)
- 新增 PMD jars 依赖及测试用例
This commit is contained in:
范智鹏
2026-07-28 22:57:15 +08:00
parent 83712aa260
commit effcf30802
72 changed files with 6568 additions and 238 deletions
+40 -3
View File
@@ -1,8 +1,45 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { ESLint } from 'eslint';
import js from '@eslint/js';
import ts from 'typescript-eslint';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
import { getEslintConfigPath } from '../config';
const PROJECT_CONFIG_FILES = [
'.eslintrc.js',
'.eslintrc.json',
'.eslintrc.yaml',
'.eslintrc.yml',
'.eslintrc',
'eslint.config.js',
'eslint.config.mjs',
];
function findProjectConfig(dir: string): string | null {
for (const name of PROJECT_CONFIG_FILES) {
const p = path.join(dir, name);
if (fs.existsSync(p)) {
return p;
}
}
return null;
}
function resolveEslintConfig(workingDir: string): { configFile?: string; overrideConfig?: any[] } {
const globalPath = getEslintConfigPath();
if (globalPath && globalPath.trim() !== '') {
return { configFile: globalPath };
}
const projectConfig = findProjectConfig(workingDir);
if (projectConfig) {
return { configFile: projectConfig };
}
return { overrideConfig: ESLintAdapter.getDefaultConfig() };
}
export class ESLintAdapter implements LinterAdapter {
id = 'eslint';
@@ -10,7 +47,7 @@ export class ESLintAdapter implements LinterAdapter {
private static defaultConfig: any[] | null = null;
private static getDefaultConfig(): any[] {
public static getDefaultConfig(): any[] {
if (!ESLintAdapter.defaultConfig) {
ESLintAdapter.defaultConfig = [
js.configs.recommended,
@@ -26,10 +63,10 @@ export class ESLintAdapter implements LinterAdapter {
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const resolved = resolveEslintConfig(workingDir);
const engine = new ESLint({
cwd: workingDir,
overrideConfigFile: true,
overrideConfig: ESLintAdapter.getDefaultConfig(),
...resolved,
});
const ext = document.languageId === 'typescript' ? 'ts' : 'js';
const isVirtual = document.uri.scheme === 'untitled';
+21 -3
View File
@@ -3,16 +3,26 @@ 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';
import { getPMDJarPath, getPMDRulesetPath } from '../config';
import { t } from '../i18n/messages';
export class PmdAdapter implements LinterAdapter {
id = 'pmd';
supportedLanguages = ['java'];
private pmdDir: string | null = null;
private jarPathChecked = false;
private resolvePmdDir(): string {
if (this.pmdDir) { return this.pmdDir; }
const jarPath = getPMDJarPath();
if (jarPath && jarPath.trim() !== '') {
if (existsSync(path.join(jarPath, 'PmdRunner.class'))) {
this.pmdDir = jarPath;
return this.pmdDir;
}
}
const candidates: string[] = [];
try {
const ext = vscode.extensions.getExtension?.('vscode-code-reviewer');
@@ -43,8 +53,16 @@ export class PmdAdapter implements LinterAdapter {
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const ruleset = getPMDRulesetPath()
|| path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
const globalRuleset = getPMDRulesetPath();
let ruleset: string;
if (globalRuleset && globalRuleset.trim() !== '') {
ruleset = globalRuleset;
} else {
const projectRuleset = path.join(workingDir, 'ruleset.xml');
ruleset = existsSync(projectRuleset)
? projectRuleset
: path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
}
const classpath = `${this.getPmdLibClasspath()};${this.getPmdRunnerClasspath()}`;
const isVirtual = document.uri.scheme === 'untitled';
+23 -3
View File
@@ -1,6 +1,9 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { spawn } from 'child_process';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
import { getSqlLintConfigFile } from '../config';
import { t } from '../i18n/messages';
const DIALECT_MAP: Record<string, string> = {
@@ -22,9 +25,14 @@ interface SqlFluffResult {
violations: SqlFluffViolation[];
}
function runSqlfluff(dialect: string, code: string, cwd: string): Promise<string> {
function runSqlfluff(dialect: string, code: string, cwd: string, configPath?: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('sqlfluff', ['lint', '--dialect', dialect, '--format', 'json', '-'], {
const args = ['lint', '--dialect', dialect, '--format', 'json'];
if (configPath) {
args.push('--config', configPath);
}
args.push('-');
const child = spawn('sqlfluff', args, {
cwd,
timeout: 30000,
});
@@ -68,8 +76,20 @@ export class SqlLintAdapter implements LinterAdapter {
const languageId = document.languageId;
const dialect = DIALECT_MAP[languageId] || 'ansi';
let configPath: string | undefined;
const globalConfig = getSqlLintConfigFile();
if (globalConfig && globalConfig.trim() !== '') {
configPath = globalConfig;
} else {
const projectConfig = path.join(workingDir, '.sqlfluff');
if (fs.existsSync(projectConfig)) {
configPath = projectConfig;
}
}
try {
const stdout = await runSqlfluff(dialect, document.getText(), workingDir);
const stdout = await runSqlfluff(dialect, document.getText(), workingDir, configPath);
const results: SqlFluffResult[] = JSON.parse(stdout);
const diagnostics: LinterDiagnostic[] = [];
+6 -1
View File
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import type { LinterAdapter, AdapterResult, LinterDiagnostic, Severity } from './adapter';
import { getStylelintConfigPath } from '../config';
const CONFIG_FILE_NAMES = [
'.stylelintrc',
@@ -36,6 +37,7 @@ interface LinterOptions {
codeFilename?: string;
cwd?: string;
config?: Record<string, unknown>;
configFile?: string;
}
interface LinterResult {
@@ -88,7 +90,10 @@ export class StylelintAdapter implements LinterAdapter {
cwd: workingDir,
};
if (!hasExternalConfig(workingDir)) {
const globalPath = getStylelintConfigPath();
if (globalPath && globalPath.trim() !== '') {
lintOptions.configFile = globalPath;
} else if (!hasExternalConfig(workingDir)) {
lintOptions.config = DEFAULT_CONFIG;
}
+1 -1
View File
@@ -192,7 +192,7 @@ export async function runAIReview(
let provider: AIProvider;
try {
provider = createProvider(providerId, apiKey, baseUrl);
provider = createProvider(providerId, apiKey, baseUrl, context.extensionUri);
} catch (err) {
return {
customRuleResults: [],
+40 -107
View File
@@ -1,118 +1,51 @@
import { AIProvider } from './providers/base';
import * as vscode from 'vscode';
import type { AIProvider } from './providers/base';
import type { ProviderMeta, ProviderProtocol } from './types';
import { OpenAICompatibleProvider } from './providers/openai-compatible';
import { GeminiProvider } from './providers/gemini';
import { ClaudeProvider } from './providers/claude';
import {
getProviderById,
getAllProviderMeta as getAllProviderMetaFromRegistry,
invalidateProviderCache,
} from './registry';
interface ProviderInfo {
cls: new (apiKey: string, baseUrl: string) => AIProvider;
defaultBaseUrl: string;
models: string[];
name: string;
}
const registry: Record<string, ProviderInfo> = {
deepseek: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'deepseek', 'DeepSeek');
}
},
defaultBaseUrl: 'https://api.deepseek.com/v1',
models: ['deepseek-v4-pro', 'deepseek-v4-flash'],
name: 'DeepSeek',
},
openai: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'openai', 'OpenAI');
}
},
defaultBaseUrl: 'https://api.openai.com/v1',
models: ['GPT-5.6 Sol', 'GPT-5.6 Terra', 'GPT-5.6 Luna', 'GPT-5.5', 'GPT-5.4'],
name: 'OpenAI',
},
gemini: {
cls: GeminiProvider,
defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1',
models: ['Gemini 3.1 Pro', 'Gemini 3.1 Flash', 'Gemini 3.1 Flash-Lite', 'Gemini 3 Pro', 'Gemini 3 Flash'],
name: 'Google Gemini',
},
claude: {
cls: ClaudeProvider,
defaultBaseUrl: 'https://api.anthropic.com/v1',
models: ['Claude Fable 5', 'Claude Mythos 5', 'Claude Opus 4.8', 'Claude Sonnet 4.6'],
name: 'Anthropic Claude',
},
hunyuan: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'hunyuan', '腾讯混元');
}
},
defaultBaseUrl: 'https://api.hunyuan.cloud.tencent.com/v1',
models: ['Hy3'],
name: '腾讯混元',
},
zhipu: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'zhipu', '智谱AI');
}
},
defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4',
models: ['GLM-5.2', 'GLM-5.1', 'GLM-5'],
name: '智谱AI',
},
moonshot: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'moonshot', '月之暗面');
}
},
defaultBaseUrl: 'https://api.moonshot.cn/v1',
models: ['Kimi K2.7 Code', 'Kimi K2.6', 'Kimi K2.5'],
name: '月之暗面',
},
tongyi: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'tongyi', '阿里通义');
}
},
defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
models: ['Qwen3-2507', 'Qwen3.6-35B-A3B', 'Qwen3.5-397B-A17B'],
name: '阿里通义',
},
const PROTOCOL_MAP: Record<ProviderProtocol, new (...args: any[]) => AIProvider> = {
'openai-compatible': OpenAICompatibleProvider,
gemini: GeminiProvider,
claude: ClaudeProvider,
};
export function createProvider(providerId: string, apiKey: string, baseUrl: string): AIProvider {
const info = registry[providerId];
if (!info) {
export function createProvider(
providerId: string,
apiKey: string,
baseUrl: string,
extensionUri: vscode.Uri
): AIProvider {
const config = getProviderById(extensionUri, providerId);
if (!config) {
throw new Error(`未知的 Provider: ${providerId}`);
}
return new info.cls(apiKey, baseUrl);
}
export function getProviderIds(): string[] {
return Object.keys(registry);
}
export function getProviderInfo(providerId: string): ProviderInfo | undefined {
return registry[providerId];
}
export function getProviderDefaultBaseUrl(providerId: string): string {
return registry[providerId]?.defaultBaseUrl ?? '';
}
export function getProviderModels(providerId: string): string[] {
return registry[providerId]?.models ?? [];
}
export function getAllProviderMeta(): Record<string, { name: string; models: string[] }> {
const result: Record<string, { name: string; models: string[] }> = {};
for (const [id, info] of Object.entries(registry)) {
result[id] = { name: info.name, models: info.models };
const Cls = PROTOCOL_MAP[config.protocol];
if (!Cls) {
throw new Error(`未知的协议类型: ${config.protocol}`);
}
return result;
if (config.protocol === 'openai-compatible') {
return new Cls(apiKey, baseUrl, config.id, config.name);
}
return new Cls(apiKey, baseUrl);
}
export function getProviderModels(extensionUri: vscode.Uri, providerId: string): string[] {
return getProviderById(extensionUri, providerId)?.models ?? [];
}
export function getAllProviderMeta(
extensionUri: vscode.Uri
): Record<string, ProviderMeta> {
return getAllProviderMetaFromRegistry(extensionUri);
}
export { invalidateProviderCache };
+1
View File
@@ -3,6 +3,7 @@ export interface ChatOptions {
temperature: number;
maxTokens: number;
timeoutMs: number;
seed?: number;
}
export abstract class AIProvider {
+8 -2
View File
@@ -14,7 +14,7 @@ export class OpenAICompatibleProvider extends AIProvider {
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
const url = `${this.baseUrl}/chat/completions`;
const body = JSON.stringify({
const bodyObj: Record<string, unknown> = {
model: options.model,
max_tokens: options.maxTokens,
temperature: options.temperature,
@@ -22,7 +22,13 @@ export class OpenAICompatibleProvider extends AIProvider {
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
});
};
if (options.seed !== undefined) {
bodyObj.seed = options.seed;
}
const body = JSON.stringify(bodyObj);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
+87
View File
@@ -0,0 +1,87 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import type { ProviderConfig, ProvidersFile, ProviderMeta } from './types';
let cachedProviders: ProviderConfig[] | null = null;
function loadBuiltinProviders(extensionUri: vscode.Uri): ProviderConfig[] {
const filePath = vscode.Uri.joinPath(extensionUri, 'providers.json').fsPath;
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as ProvidersFile;
return data.providers ?? [];
} catch {
return [];
}
}
function loadUserProviders(): ProviderConfig[] {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { return []; }
const filePath = path.join(workspaceRoot, '.code-review', 'providers.json');
if (!fs.existsSync(filePath)) { return []; }
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as ProvidersFile;
return data.providers ?? [];
} catch {
return [];
}
}
function mergeProviders(
builtin: ProviderConfig[],
user: ProviderConfig[]
): ProviderConfig[] {
const map = new Map<string, ProviderConfig>();
for (const p of builtin) {
map.set(p.id, p);
}
for (const p of user) {
map.set(p.id, p);
}
return Array.from(map.values());
}
export function getProviders(extensionUri?: vscode.Uri): ProviderConfig[] {
if (cachedProviders) { return cachedProviders; }
if (!extensionUri) {
return [];
}
const builtin = loadBuiltinProviders(extensionUri);
const user = loadUserProviders();
cachedProviders = mergeProviders(builtin, user);
return cachedProviders;
}
export function invalidateProviderCache(): void {
cachedProviders = null;
}
export function getProviderById(
extensionUri: vscode.Uri,
id: string
): ProviderConfig | undefined {
return getProviders(extensionUri).find(p => p.id === id);
}
export function getAllProviderMeta(
extensionUri: vscode.Uri
): Record<string, ProviderMeta> {
const result: Record<string, ProviderMeta> = {};
for (const p of getProviders(extensionUri)) {
result[p.id] = {
name: p.name,
models: p.models,
};
}
return result;
}
+18
View File
@@ -0,0 +1,18 @@
export type ProviderProtocol = 'openai-compatible' | 'gemini' | 'claude';
export interface ProviderConfig {
id: string;
name: string;
protocol: ProviderProtocol;
defaultBaseUrl: string;
models: string[];
}
export interface ProvidersFile {
providers: ProviderConfig[];
}
export interface ProviderMeta {
name: string;
models: string[];
}
+20
View File
@@ -21,3 +21,23 @@ export function getPMDJspRulesetPath(): string {
export function getSqlLintConfigFile(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('sql-lint.configFile', '');
}
export function getEslintConfigPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('linters.eslintConfigPath', '');
}
export function getStylelintConfigPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('linters.stylelintConfigPath', '');
}
export function isAdapterEnabled(adapterId: string): boolean {
return vscode.workspace.getConfiguration(ROOT).get<boolean>(`linter.${adapterId}.enabled`, true);
}
export async function setAdapterEnabled(adapterId: string, enabled: boolean): Promise<void> {
await vscode.workspace.getConfiguration(ROOT).update(
`linter.${adapterId}.enabled`,
enabled,
vscode.ConfigurationTarget.Global
);
}
+210
View File
@@ -212,6 +212,11 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Press <b>Ctrl + Shift + R</b> to trigger review, results appear in the <b>Review Report</b> panel',
ja: '<b>Ctrl + Shift + R</b> でレビューを実行、結果は<b>レビューレポート</b>に表示',
},
'setup.aiConnectionConfig': {
'zh-CN': 'AI 连接配置',
en: 'AI Connection Config',
ja: 'AI接続設定',
},
'setup.engineSection': {
'zh-CN': '审核引擎',
en: 'Review Engines',
@@ -267,6 +272,11 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Model Name',
ja: 'モデル名',
},
'setup.modelPlaceholder': {
'zh-CN': '输入或选择模型名',
en: 'Enter or select model name',
ja: 'モデル名を入力または選択',
},
'setup.modelHint': {
'zh-CN': '建议使用支持结构化输出的模型。',
en: 'Use a model that supports structured output.',
@@ -342,6 +352,206 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Save & Test Connection',
ja: '保存して接続テスト',
},
'setup.configured': {
'zh-CN': '已配置',
en: 'Configured',
ja: '設定済み',
},
'setup.connected': {
'zh-CN': '✓ 已连接',
en: '✓ Connected',
ja: '✓ 接続済み',
},
'setup.retry': {
'zh-CN': '✗ 重试',
en: '✗ Retry',
ja: '✗ 再試行',
},
'setup.ruleCountFormat': {
'zh-CN': '{0} 个文件',
en: '{0} file(s)',
ja: '{0} ファイル',
},
'setup.noRuleFiles': {
'zh-CN': '暂无规则文件',
en: 'No rule files',
ja: 'ルールファイルなし',
},
'setup.adapter.subtitle': {
'zh-CN': '静态分析适配器',
en: 'Static Analysis Adapters',
ja: '静的解析アダプター',
},
'setup.adapter.modeBuiltin': {
'zh-CN': '内置规则',
en: 'Built-in Rules',
ja: '組み込みルール',
},
'setup.adapter.modeProject': {
'zh-CN': '项目配置',
en: 'Project Config',
ja: 'プロジェクト設定',
},
'setup.adapter.modeGlobal': {
'zh-CN': '全局配置',
en: 'Global Config',
ja: 'グローバル設定',
},
'setup.adapter.modeLegend': {
'zh-CN': '插件按 内置<全局<项目的优先级自动选择配置来源',
en: 'Auto-selects config by priority: Built-in < Global < Project',
ja: '優先順位に従って自動選択: 組み込み < グローバル < プロジェクト',
},
'setup.adapter.configYes': {
'zh-CN': '已配置',
en: 'Configured',
ja: '設定済み',
},
'setup.adapter.configNo': {
'zh-CN': '未配置',
en: 'Not configured',
ja: '未設定',
},
'setup.adapter.langLabel': {
'zh-CN': '可审查的语言:',
en: 'Languages:',
ja: '対応言語:',
},
'setup.adapter.tooltipTab': {
'zh-CN': '点击展开/收起静态分析适配器',
en: 'Click to expand/collapse static analysis adapters',
ja: 'クリックで静的解析アダプターを展開/折りたたむ',
},
'setup.adapter.btnCreateConfig': {
'zh-CN': '创建项目配置',
en: 'Create Project Config',
ja: 'プロジェクト設定を作成',
},
'setup.adapter.btnEditGlobal': {
'zh-CN': '修改全局设置',
en: 'Modify Global Settings',
ja: 'グローバル設定を変更',
},
'setup.adapter.tooltipCreate': {
'zh-CN': '在项目根目录创建 {0}',
en: 'Create {0} in project root',
ja: 'プロジェクトルートに {0} を作成',
},
'setup.adapter.tooltipEdit': {
'zh-CN': '修改 VS Code 设置中的全局参数',
en: 'Modify global parameters in VS Code settings',
ja: 'VS Code設定のグローバルパラメータを変更',
},
'setup.adapter.toggleEnable': {
'zh-CN': '启用 {0} 适配器',
en: 'Enable {0} adapter',
ja: '{0} アダプターを有効化',
},
'setup.adapter.toggleDisable': {
'zh-CN': '禁用 {0} 适配器',
en: 'Disable {0} adapter',
ja: '{0} アダプターを無効化',
},
'setup.template.pmdBestPractices': {
'zh-CN': 'Java 最佳实践(如:避免空 catch、关闭流等)',
en: 'Java best practices (avoid empty catch, close streams, etc.)',
ja: 'Javaベストプラクティス(空のcatch回避、ストリームクローズ等)',
},
'setup.template.pmdCodeStyle': {
'zh-CN': 'Java 代码风格(如:命名规范、花括号位置等)',
en: 'Java code style (naming conventions, brace placement, etc.)',
ja: 'Javaコードスタイル(命名規則、ブレース位置等)',
},
'setup.template.sqlfluffDialect': {
'zh-CN': '数据库方言:postgres / mysql / bigquery / snowflake 等',
en: 'Database dialect: postgres / mysql / bigquery / snowflake etc.',
ja: 'データベース方言:postgres / mysql / bigquery / snowflake など',
},
'setup.template.sqlfluffRules': {
'zh-CN': 'all = 启用全部规则,也可指定规则名逗号分隔',
en: 'all = enable all rules, or specify rule names separated by commas',
ja: 'all = すべてのルールを有効、ルール名をカンマ区切りで指定可',
},
'setup.template.eslintComment1': {
'zh-CN': '未使用的变量 → 警告',
en: 'Unused variables → warning',
ja: '未使用変数 → 警告',
},
'setup.template.eslintComment2': {
'zh-CN': '允许使用 console',
en: 'Allow console',
ja: 'consoleを許可',
},
'setup.template.eslintComment3': {
'zh-CN': '强制分号',
en: 'Enforce semicolons',
ja: 'セミコロンを強制',
},
'setup.template.stylelintComment1': {
'zh-CN': '缩进 2 空格',
en: 'Indentation: 2 spaces',
ja: 'インデント: 2スペース',
},
'setup.template.stylelintComment2': {
'zh-CN': '禁止空规则',
en: 'No empty rules',
ja: '空ルールを禁止',
},
'setup.adapter.pmdLanguages': {
'zh-CN': 'Java(含 JSP 中的 Java 代码,例如<% ... %>',
en: 'Java (including Java code in JSP, e.g. <% ... %>)',
ja: 'JavaJSP内のJavaコードを含む、例: <% ... %>',
},
'setup.adapter.pmdGuide': {
'zh-CN': '需要 Java 运行环境;项目根目录创建 ruleset.xml 或在设置中配置 pmd.rulesetPath',
en: 'Requires Java runtime; create ruleset.xml in project root or set pmd.rulesetPath in settings',
ja: 'Java実行環境が必要。プロジェクトルートにruleset.xmlを作成するか、設定でpmd.rulesetPathを設定してください',
},
'setup.adapter.sqlLanguages': {
'zh-CN': 'SQL',
en: 'SQL',
ja: 'SQL',
},
'setup.adapter.sqlGuide': {
'zh-CN': '需要 Python 环境和 sqlfluff;运行 pip install sqlfluff,项目根目录创建 .sqlfluff',
en: 'Requires Python and sqlfluff; run pip install sqlfluff, create .sqlfluff in project root',
ja: 'Python環境とsqlfluffが必要。pip install sqlfluff を実行し、プロジェクトルートに.sqlfluffを作成してください',
},
'setup.adapter.eslintLanguages': {
'zh-CN': 'JS, TS, JSX, TSX(含 JSP 中的 JavaScript 代码,例如<script>',
en: 'JS, TS, JSX, TSX (including JavaScript in JSP, e.g. <script>)',
ja: 'JS, TS, JSX, TSXJSP内のJavaScriptコードを含む、例: <script>',
},
'setup.adapter.eslintGuide': {
'zh-CN': '项目根目录创建 .eslintrc.js 或在 VS Code 设置中配置 eslintConfigPath',
en: 'Create .eslintrc.js in project root or set eslintConfigPath in VS Code settings',
ja: 'プロジェクトルートに.eslintrc.jsを作成するか、VS Code設定でeslintConfigPathを設定してください',
},
'setup.adapter.stylelintLanguages': {
'zh-CN': 'CSS, SCSS, Less(含 JSP 中的 CSS 代码,例如<style>',
en: 'CSS, SCSS, Less (including CSS in JSP, e.g. <style>)',
ja: 'CSS, SCSS, LessJSP内のCSSコードを含む、例: <style>',
},
'setup.adapter.stylelintGuide': {
'zh-CN': '项目根目录创建 .stylelintrc 或在 VS Code 设置中配置 stylelintConfigPath',
en: 'Create .stylelintrc in project root or set stylelintConfigPath in VS Code settings',
ja: 'プロジェクトルートに.stylelintrcを作成するか、VS Code設定でstylelintConfigPathを設定してください',
},
'setup.aiReviewStatusCapability': {
'zh-CN': '审查能力',
en: 'Capability',
ja: 'レビュー機能',
},
'setup.aiReviewCapability': {
'zh-CN': '修改建议 · 自定义规则审查 · 深度代码审查',
en: 'Fix Suggestions · Custom Rules Review · Deep Code Review',
ja: '修正提案 · カスタムルールレビュー · 詳細コードレビュー',
},
'setup.notConnected': {
'zh-CN': '未连接',
en: 'Not connected',
ja: '未接続',
},
'importPreview.title': {
'zh-CN': '规则导入预览',
en: 'Rule Import Preview',
+10 -1
View File
@@ -1,6 +1,6 @@
import * as vscode from 'vscode';
import type { LinterAdapter, LinterDiagnostic } from '../types';
import { getLinterForLanguage } from '../config';
import { getLinterForLanguage, isAdapterEnabled } from '../config';
import { ESLintAdapter } from '../adapters/eslint';
import { PmdAdapter } from '../adapters/pmd';
import { StylelintAdapter } from '../adapters/stylelint';
@@ -59,6 +59,15 @@ export class Orchestrator {
};
}
if (!isAdapterEnabled(adapter.id)) {
return {
diagnostics: [],
errors: [],
adapterIds: [],
duration: Date.now() - startTime,
};
}
const result = await adapter.check(document, workingDir);
const errors: string[] = [];
if (result.status !== 'ok') {
+31 -4
View File
@@ -43,6 +43,10 @@ const p: Record<Lang, PromptStrings> = {
'- 混合形式(段落 + 列表 + 表格组合)',
'',
'不得因输入格式非标准而拒绝转换。应主动从松散描述中提取规则语义。',
'',
'- 按输入文档的段落或列表项顺序处理,保持原文顺序输出',
'- 不要合并或拆分原文中已是独立条目的规则',
'- 一个段落包含多条规则时才拆分,单条规则不要拆成多条',
],
nonRuleFilterTitle: '## 非规则内容过滤',
nonRuleFilterLines: [
@@ -58,12 +62,17 @@ const p: Record<Lang, PromptStrings> = {
' **必须**基于规则描述内容自动生成语义化的 id',
' 即使输入中无显式 id 标识,也必须根据 description/message 的语义推断出合适的 id',
' 多条规则之间 id 不得重复',
' id 只能使用 description/message 中已有的英文单词或短语,转换为 kebab-case',
' 不要自行创造原文中没有的英文词汇',
' 如果输入全中文,从语义提取核心关键词翻译为简短英文(2-4 个词)',
'- severity: 严重级别(error / warning / info',
' **必须**输出。按规则语义推断:',
' error: 会导致 bug / 安全问题 / 数据损坏',
' warning: 潜在问题 / 不良实践',
' info: 风格 / 可读性建议',
' 即使输入中无显式严重级别,也必须根据规则后果的严重程度推断',
' 如果无法从输入中确定严重级别,默认填 warning',
' 只有明确涉及安全、数据泄露、崩溃风险时才填 error',
'- description: 规则简短描述',
' **必须**输出。若输入中不明显,从 message 的内容反向推导出简短描述',
'- message: 违反时的提示消息',
@@ -135,7 +144,7 @@ const p: Record<Lang, PromptStrings> = {
'',
'仅输出 YAML,不要额外说明。',
],
finalInstruction: '',
finalInstruction: '只输出 YAML 内容,不要输出 markdown 代码块标记,不要输出解释性文字',
dedupHeader: '## 已知规则清单(用于重复检测)',
dedupLinterLabel: (name, count) => `### ${name} (${count} 条)`,
dedupCustomLabel: (count) => `### 已导入的自定义规则 (${count} 条)`,
@@ -154,6 +163,10 @@ const p: Record<Lang, PromptStrings> = {
'- Mixed forms (paragraphs + lists + tables)',
'',
'Do not reject conversion due to non-standard input format. Actively extract rule semantics from loose descriptions.',
'',
'- Process in the order of the input document paragraphs or list items, preserving original order',
'- Do not merge or split entries that are already independent rules in the original text',
'- Only split when a single paragraph contains multiple rules; do not split a single rule into multiple',
],
nonRuleFilterTitle: '## Non-Rule Content Filtering',
nonRuleFilterLines: [
@@ -169,12 +182,17 @@ const p: Record<Lang, PromptStrings> = {
' **Must** generate a semantic id based on the rule description content',
' Even if no explicit id is present in the input, infer a suitable id from the description/message semantics',
' IDs must not be duplicated across rules',
' id must use only English words or phrases already present in description/message, converted to kebab-case',
' Do not invent English words not found in the original text',
' If input is entirely in Chinese, extract core semantic keywords and translate to short English (2-4 words)',
'- severity: Severity level (error / warning / info)',
' **Must** output. Infer based on rule semantics:',
' error: causes bugs / security issues / data corruption',
' warning: potential issues / bad practices',
' info: style / readability suggestions',
' Even if no explicit severity is given, infer from the rule\'s impact',
' If severity cannot be determined from input, default to warning',
' Only use error when the rule clearly involves security, data leakage, or crash risk',
'- description: Short rule description',
' **Must** output. If not obvious from input, derive from message content',
'- message: Violation message',
@@ -246,7 +264,7 @@ const p: Record<Lang, PromptStrings> = {
'',
'Output YAML only, no extra explanation.',
],
finalInstruction: 'All descriptions and messages must be written in English.',
finalInstruction: 'All descriptions and messages must be written in English.\nOutput YAML only, no markdown code fences, no explanatory text',
dedupHeader: '## Known Rules (for duplicate detection)',
dedupLinterLabel: (name, count) => `### ${name} (${count} rules)`,
dedupCustomLabel: (count) => `### Imported custom rules (${count} rules)`,
@@ -262,9 +280,13 @@ const p: Record<Lang, PromptStrings> = {
'- 自然言語の段落(1つ以上の段落でルールを記述)',
'- 順不同リスト(各ルールが1行または1段落)',
'- テーブル(列名は固定されていません。意味から推測してください)',
'- 混合形式(段落 + リスト + テーブルの組み合わせ',
'- 混合形式(段落 + リスト + テーブルの組み込み',
'',
'非標準的な入力形式であっても変換を拒否してはいけません。緩やかな記述からルールの意味を積極的に抽出してください。',
'',
'- 入力ドキュメントの段落またはリスト項目の順序で処理し、原文の順序を保持する',
'- 原文ですでに独立したエントリであるルールを結合または分割しない',
'- 単一の段落に複数のルールが含まれる場合のみ分割し、単一ルールを複数に分割しない',
],
nonRuleFilterTitle: '## 非ルールコンテンツのフィルタリング',
nonRuleFilterLines: [
@@ -280,12 +302,17 @@ const p: Record<Lang, PromptStrings> = {
' **必須** ルール説明内容に基づいて意味的なidを自動生成する',
' 入力に明示的なidがない場合でも、description/messageの意味から適切なidを推測する',
' 複数ルール間でidが重複してはいけない',
' idはdescription/messageにすでに存在する英単語またはフレーズのみを使用し、kebab-caseに変換する',
' 原文にない英単語を独自に作成しない',
' 入力がすべて日本語の場合は、セマンティクスから核心キーワードを抽出し、短い英語(2〜4語)に翻訳する',
'- severity: 重大度(error / warning / info',
' **必須**で出力。ルールの意味に従って推測:',
' error: バグ/セキュリティ問題/データ破損を引き起こす',
' warning: 潜在的な問題/悪い慣行',
' info: スタイル/可読性の提案',
' 入力に明示的な重大度がない場合でも、ルールの影響の重大さから推測する',
' 入力から重大度を判断できない場合は、デフォルトでwarningとする',
' セキュリティ、データ漏洩、クラッシュリスクに明確に関連する場合のみerrorとする',
'- description: ルールの簡単な説明',
' **必須**で出力。入力で不明確な場合、messageの内容から逆算して短い説明を導出',
'- message: 違反時のメッセージ',
@@ -357,7 +384,7 @@ const p: Record<Lang, PromptStrings> = {
'',
'YAMLのみを出力し、追加説明は不要です。',
],
finalInstruction: 'すべてのdescriptionとmessageは日本語で出力してください。',
finalInstruction: 'すべてのdescriptionとmessageは日本語で出力してください。\nYAML のみ出力、マークダウンコードブロックなし、説明テキストなし',
dedupHeader: '## 既知ルール一覧(重複検出用)',
dedupLinterLabel: (name, count) => `### ${name}${count} 件)`,
dedupCustomLabel: (count) => `### インポート済みカスタムルール(${count} 件)`,
+30 -4
View File
@@ -218,6 +218,30 @@ function buildFinalYamlFromRaw(
return output.join('\n');
}
function normalizeRuleIds(rules: ImportableRule[]): ImportableRule[] {
const seen = new Set<string>();
return rules.map((rule, idx) => {
let id = rule.id
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (!id) {
id = `rule-${idx + 1}`;
}
if (seen.has(id)) {
let suffix = 2;
while (seen.has(`${id}-${suffix}`)) { suffix++; }
id = `${id}-${suffix}`;
}
seen.add(id);
return { ...rule, id };
});
}
export class ImportService {
private converters: Map<string, RuleConverter> = new Map();
@@ -245,7 +269,8 @@ export class ImportService {
throw new Error(t('import.conversionFailed'));
}
const rules = parseImportableYaml(yamlContent);
const parsedRules = parseImportableYaml(yamlContent);
const rules = normalizeRuleIds(parsedRules);
const exactCount = rules.filter(r => r.duplicateLevel === 'exact').length;
const overlapCount = rules.filter(r => r.duplicateLevel === 'overlap').length;
@@ -354,7 +379,7 @@ export async function convertContentWithAI(
}
const config = getAIConfig();
const provider = createProvider(config.provider, apiKey, config.baseUrl);
const provider = createProvider(config.provider, apiKey, config.baseUrl, context.extensionUri);
const prompt = systemPrompt ?? buildFallbackPrompt();
@@ -362,9 +387,10 @@ export async function convertContentWithAI(
try {
yamlOutput = await provider.chat(prompt, content, {
model: config.model,
temperature: 0.1,
maxTokens: 4096,
temperature: 0,
maxTokens: 8192,
timeoutMs: getAITimeout() * 1000,
seed: 42,
});
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
+32
View File
@@ -0,0 +1,32 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
import { ESLintAdapter } from '../adapters/eslint';
import { StylelintAdapter } from '../adapters/stylelint';
suite('Adapter Tests', () => {
test('ESLintAdapter has correct id and languages', () => {
const adapter = new ESLintAdapter();
assert.strictEqual(adapter.id, 'eslint');
assert.deepStrictEqual(adapter.supportedLanguages, ['javascript', 'typescript']);
});
test('StylelintAdapter has correct id and languages', () => {
const adapter = new StylelintAdapter();
assert.strictEqual(adapter.id, 'stylelint');
assert.deepStrictEqual(adapter.supportedLanguages, ['css']);
});
test('ESLintAdapter check returns AdapterResult structure', async () => {
const adapter = new ESLintAdapter();
if (!adapter.isAvailable()) { return; }
const doc = await vscode.workspace.openTextDocument({
content: 'const x = 1;\nconsole.log(x);\n',
language: 'javascript',
});
const result = await adapter.check(doc, __dirname);
assert.ok(result.status === 'ok' || result.status === 'tool-unavailable');
assert.ok(Array.isArray(result.diagnostics));
});
});
+33
View File
@@ -0,0 +1,33 @@
import * as assert from 'assert';
import { getAIConfig, getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage } from '../config/ai';
import { getLinterForLanguage, getPMDJarPath, getPMDRulesetPath } from '../config/linter';
import { getContextLines } from '../config/fixer';
suite('Config Tests', () => {
test('getAIConfig returns default values', () => {
const config = getAIConfig();
assert.strictEqual(config.provider, 'deepseek');
assert.strictEqual(config.model, 'deepseek-chat');
assert.strictEqual(config.baseUrl, 'https://api.deepseek.com/v1');
assert.strictEqual(config.outputLanguage, 'zh-CN');
});
test('AI individual getters return defaults', () => {
assert.strictEqual(getAIProvider(), 'deepseek');
assert.strictEqual(getAIModel(), 'deepseek-chat');
assert.strictEqual(getAIBaseUrl(), 'https://api.deepseek.com/v1');
assert.strictEqual(getAITemperature(), 0.2);
assert.strictEqual(getAITimeout(), 300);
assert.strictEqual(getAIMaxTokens(), 8192);
assert.strictEqual(getAIOutputLanguage(), 'zh-CN');
});
test('getLinterForLanguage returns configured linter', () => {
assert.strictEqual(getLinterForLanguage('javascript'), 'eslint');
assert.strictEqual(getLinterForLanguage('java'), 'pmd');
});
test('getContextLines returns default', () => {
assert.strictEqual(getContextLines(), 5);
});
});
+7
View File
@@ -0,0 +1,7 @@
public class Sample {
public void test() {
String password = "admin123";
System.out.println("debug");
System.out.println("debug");
}
}
+2
View File
@@ -0,0 +1,2 @@
.hello { color: black; background: #FFF; }
#test { margin: 0px; }
+5
View File
@@ -0,0 +1,5 @@
function test() {
var unused = 1;
console.log('debug');
return "hello world";
}
+345
View File
@@ -0,0 +1,345 @@
import * as assert from 'assert';
import { buildFinalYaml, parseImportableYaml } from '../rules/import-service';
import type { ImportableRule, PreviewDecision } from '../rules/import-types';
function makeRules(data: Array<Partial<ImportableRule>>): ImportableRule[] {
return data.map(d => ({
id: d.id ?? 'test-rule',
severity: d.severity ?? 'warning',
description: d.description ?? 'test description',
message: d.message ?? 'test message',
languages: d.languages,
excludeLanguages: d.excludeLanguages,
duplicateOf: d.duplicateOf,
duplicateLevel: d.duplicateLevel,
duplicateReason: d.duplicateReason,
}));
}
function defaultDecision(rules: ImportableRule[]): PreviewDecision {
const keepRule: Record<string, boolean> = {};
for (const rule of rules) {
keepRule[rule.id] = rule.duplicateLevel !== 'exact';
}
return { keepRule, confirmed: true };
}
function makeYaml(rules: Array<{ id: string; fields: Record<string, string> }>): string {
return rules.map(r => {
const lines = [`- id: ${r.id}`];
for (const [key, value] of Object.entries(r.fields)) {
lines.push(` ${key}: ${value}`);
}
return lines.join('\n');
}).join('\n\n') + '\n';
}
suite('Import Dedup Tests', () => {
test('无重复规则→全部保留,无注释行', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateLevel: 'none' },
{ id: 'rule-b' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateLevel: 'none' } },
{ id: 'rule-b', fields: { severity: 'info', description: 'desc b', message: 'msg b' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.includes('# [DUPLICATE'), 'Should have no duplicate headers');
assert.ok(result.includes('id: rule-a'));
assert.ok(result.includes('id: rule-b'));
assert.ok(!result.includes('duplicateLevel:'), 'duplicateLevel should be stripped');
});
test('全部 exact→全部注释', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
{ id: 'rule-b', duplicateOf: 'eslint/no-debugger', duplicateLevel: 'exact' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
{ id: 'rule-b', fields: { severity: 'error', description: 'desc b', message: 'msg b', duplicateOf: 'eslint/no-debugger', duplicateLevel: 'exact' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.match(/^[^#]*- id:/m), 'No non-commented rule lines');
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(result.includes('# 如需启用'));
});
test('全部 overlap→全部保留,无注释行', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求 logger' },
{ id: 'rule-b', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap', duplicateReason: '更窄范围' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求 logger' } },
{ id: 'rule-b', fields: { severity: 'warning', description: 'desc b', message: 'msg b', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap', duplicateReason: '更窄范围' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.includes('# [DUPLICATE'), 'Overlap rules should not be commented by default');
assert.ok(result.includes('id: rule-a'));
assert.ok(result.includes('id: rule-b'));
});
test('混合三档→exact注释,overlap和none保留', () => {
const rules = makeRules([
{ id: 'exact-rule', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
{ id: 'overlap-rule', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap' },
{ id: 'none-rule', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'exact-rule', fields: { severity: 'warning', description: 'd1', message: 'm1', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
{ id: 'overlap-rule', fields: { severity: 'warning', description: 'd2', message: 'm2', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap' } },
{ id: 'none-rule', fields: { severity: 'info', description: 'd3', message: 'm3', duplicateLevel: 'none' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(!result.match(/^-\s+id:\s+exact-rule/m), 'exact rule should be commented');
assert.ok(result.match(/^-\s+id:\s+overlap-rule/m), 'overlap rule should be active');
assert.ok(result.match(/^-\s+id:\s+none-rule/m), 'none rule should be active');
});
test('用户恢复 exact 规则→取消注释', () => {
const rules = makeRules([
{ id: 'restored', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
]);
const yaml = makeYaml([
{ id: 'restored', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
]);
const decision: PreviewDecision = { keepRule: { restored: true }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.includes('[DUPLICATE'), 'Restored rule should have no duplicate annotation');
assert.ok(result.match(/^-\s+id:\s+restored/m), 'Restored rule should be active');
});
test('用户注释 overlap 规则→加 # 前缀和注释头', () => {
const rules = makeRules([
{ id: 'commented', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求' },
]);
const yaml = makeYaml([
{ id: 'commented', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求' } },
]);
const decision: PreviewDecision = { keepRule: { commented: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('[DUPLICATE: overlap]'));
assert.ok(result.includes('重叠原因:额外要求'));
assert.ok(!result.match(/^-\s+id:\s+commented/m), 'Commented rule should have # prefix');
});
test('用户注释 none 规则→加 # 前缀', () => {
const rules = makeRules([
{ id: 'comment-none', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'comment-none', fields: { severity: 'warning', description: 'd', message: 'm', duplicateLevel: 'none' } },
]);
const decision: PreviewDecision = { keepRule: { 'comment-none': false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.match(/^-\s+id:\s+comment-none/m), 'Should be commented');
assert.ok(result.includes('# - id: comment-none'));
});
test('保留规则→duplicateLevel/duplicateOf/duplicateReason 行被移除', () => {
const rules = makeRules([
{ id: 'kept', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: 'reason' },
]);
const yaml = makeYaml([
{ id: 'kept', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: 'reason' } },
]);
const decision: PreviewDecision = { keepRule: { kept: true }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.includes('duplicateOf:'));
assert.ok(!result.includes('duplicateLevel:'));
assert.ok(!result.includes('duplicateReason:'));
assert.ok(result.includes('id: kept'));
});
test('注释规则含 duplicateReason→注释头包含重叠原因', () => {
const rules = makeRules([
{ id: 'r1', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '检测目标相同但额外要求 logger' },
]);
const yaml = makeYaml([
{ id: 'r1', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '检测目标相同但额外要求 logger' } },
]);
const decision: PreviewDecision = { keepRule: { r1: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('重叠原因:检测目标相同但额外要求 logger'));
});
test('编辑 description 后确认→YAML 使用新值', () => {
const rules = makeRules([
{ id: 'edit-desc', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-desc', fields: { severity: 'warning', description: '旧描述', message: '旧消息' } },
]);
const editedRules = makeRules([
{ id: 'edit-desc', severity: 'warning', description: '新描述', message: '新消息' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-desc': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('description: 新描述'));
assert.ok(!result.includes('旧描述'));
assert.ok(result.includes('message: 新消息'));
});
test('编辑 severity 后确认→YAML 使用新 severity', () => {
const rules = makeRules([
{ id: 'edit-sev', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-sev', fields: { severity: 'warning', description: 'd', message: 'm' } },
]);
const editedRules = makeRules([
{ id: 'edit-sev', severity: 'error', description: 'd', message: 'm' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-sev': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('severity: error'));
assert.ok(!result.includes('severity: warning'));
});
test('编辑 languages 后确认→YAML 含新 languages', () => {
const rules = makeRules([
{ id: 'edit-lang', duplicateLevel: 'none', languages: ['java'] },
]);
const yaml = makeYaml([
{ id: 'edit-lang', fields: { severity: 'warning', description: 'd', message: 'm', languages: '[java]' } },
]);
const editedRules = makeRules([
{ id: 'edit-lang', severity: 'warning', description: 'd', message: 'm', languages: ['javascript', 'typescript'] },
]);
const decision: PreviewDecision = { keepRule: { 'edit-lang': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('languages: [javascript, typescript]'));
assert.ok(!result.includes('languages: [java]'), 'old java language should be gone');
assert.ok(!result.includes(' [java]'), 'standalone java tag should be gone');
});
test('编辑后切换为注释→注释内容为编辑后的值', () => {
const rules = makeRules([
{ id: 'edit-comment', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-comment', fields: { severity: 'warning', description: '原描述', message: '原消息' } },
]);
const editedRules = makeRules([
{ id: 'edit-comment', severity: 'error', description: '新描述', message: '新消息' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-comment': false }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('# severity: error'), 'should have commented severity: error');
assert.ok(result.includes('# description: 新描述'));
assert.ok(result.includes('# message: 新消息'));
assert.ok(result.includes('[手动注释]'));
});
test('无编辑场景→回退到原始 yamlContent 处理', () => {
const rules = makeRules([
{ id: 'fallback', duplicateLevel: 'exact', duplicateOf: 'eslint/no-console' },
]);
const yaml = makeYaml([
{ id: 'fallback', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
]);
const decision: PreviewDecision = { keepRule: { fallback: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(!result.match(/^-\s+id:\s+fallback/m));
assert.ok(result.includes('duplicateOf:'));
});
});
suite('parseImportableYaml Fallback Tests', () => {
test('severity 缺失→降级为 warning', () => {
const yaml = `- id: test-rule
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].severity, 'warning');
});
test('severity 非法值→降级为 warning', () => {
const yaml = `- id: test-rule
severity: critical
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].severity, 'warning');
});
test('id 缺失→生成 rule-N', () => {
const yaml = `- severity: warning
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].id, 'rule-1');
});
test('多条 id 缺失→rule-1, rule-2...', () => {
const yaml = `- severity: warning
description: desc a
message: msg a
- severity: info
description: desc b
message: msg b`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 2);
assert.strictEqual(rules[0].id, 'rule-1');
assert.strictEqual(rules[1].id, 'rule-2');
});
test('description 缺失、message 存在→互填', () => {
const yaml = `- id: test-rule
severity: error
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].description, 'test msg');
assert.strictEqual(rules[0].message, 'test msg');
});
test('message 缺失、description 存在→互填', () => {
const yaml = `- id: test-rule
severity: error
description: test desc`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].description, 'test desc');
assert.strictEqual(rules[0].message, 'test desc');
});
test('description 与 message 同时缺失→丢弃', () => {
const yaml = `- id: test-rule
severity: error
- id: test-rule2
severity: warning
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].id, 'test-rule2');
});
test('正常完整输入→无回归', () => {
const yaml = `- id: no-console-log
severity: error
description: 禁止使用 console.log
message: 请使用 logger 替代
- id: no-unused-vars
severity: warning
description: 禁止未使用变量
message: 删除或注释未使用变量
duplicateOf: eslint/no-unused-vars
duplicateLevel: exact`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 2);
assert.strictEqual(rules[0].severity, 'error');
assert.strictEqual(rules[0].description, '禁止使用 console.log');
assert.strictEqual(rules[1].duplicateLevel, 'exact');
});
});
+16
View File
@@ -0,0 +1,16 @@
public class Buggy {
public void test() {
String password = "admin123";
String name = "test";
int x = 1;
int y = 2;
int z = x + y;
System.out.println("debug");
System.out.println("done");
}
public void duplicate() {
String password = "secret";
System.out.println(password);
}
}
+3
View File
@@ -0,0 +1,3 @@
.hello { color: #FFFFFF; background: black; }
#test { margin: 0px; }
.foo { font-size: 12px; }
+11
View File
@@ -0,0 +1,11 @@
var x = 1;
var y = 2;
var x = 3;
function test() {
var unused = 'hello';
console.log('debug');
return "world";
}
test();
+42
View File
@@ -0,0 +1,42 @@
<%@ page language="java" contentType="text/html" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<html>
<head>
<title>${title}</title>
<style>
.btn { color: #fff; background: blue; }
.btn { font-size: 14px; }
body { color: #fff; }
body { margin: 0; }
</style>
</head>
<body>
<%
String password = "admin123";
String name = "test";
int x = 1;
%>
<p>${message} ${message}</p>
<c:if test="${not empty user}">
<p>Welcome, ${user.name}</p>
</c:if>
<c:forEach items="${list}" var="item">
<span>${item}</span>
</c:forEach>
<c:out value="${xssInput}" />
<c:forEach begin="1" end="5">
<span>hello</span>
</c:forEach>
<script>
var msg = "hello";
var msg = "world";
console.log(msg);
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
SELECT name FORM users;
SELECT * FORM products;
INSERT INTO customers VALUES (1, 'test');
+55
View File
@@ -0,0 +1,55 @@
import * as assert from 'assert';
import { mergeResults, MergedReport } from '../merger/merger';
import { CustomRuleResult, AIFinding } from '../ai/schema';
import { LinterDiagnostic } from '../types';
suite('Merger Tests', () => {
test('mergeResults counts correctly', () => {
const staticDiags: LinterDiagnostic[] = [
{ severity: 'error', ruleId: 'eslint:no-unused', message: 'x is unused', range: new (require('vscode').Range)(0, 0, 0, 1) },
];
const customResults: CustomRuleResult[] = [
{ ruleId: 'custom:no-console', line: 5, severity: 'warning', message: 'avoid console.log' },
];
const aiFindings: AIFinding[] = [
{ ruleId: 'hardcoded-secret', severity: 'error', category: 'security', title: 'Hardcoded', description: 'Found secret', suggestion: 'Use env', line: 3 },
];
const report = mergeResults({
staticDiagnostics: staticDiags,
customRuleResults: customResults,
translatedDiagnostics: [],
aiFindings,
errors: [],
degraded: false,
startTime: Date.now(),
filePath: '/test/sample.js',
language: 'javascript',
adapterIds: ['eslint'],
});
assert.strictEqual(report.linterCount, 1);
assert.strictEqual(report.customRuleCount, 1);
assert.strictEqual(report.aiCount, 1);
assert.strictEqual(report.degraded, false);
assert.strictEqual(report.language, 'javascript');
});
test('mergeResults marks degraded when AI fails', () => {
const report = mergeResults({
staticDiagnostics: [],
customRuleResults: [],
translatedDiagnostics: [],
aiFindings: [],
errors: ['AI 请求超时'],
degraded: true,
startTime: Date.now(),
filePath: '/test/sample.js',
language: 'javascript',
adapterIds: ['eslint'],
});
assert.strictEqual(report.degraded, true);
assert.strictEqual(report.errors.length, 1);
});
});
+72
View File
@@ -0,0 +1,72 @@
import * as assert from 'assert';
import { t, setLanguage, getLanguage, getMessageKeys, Language } from '../i18n/messages';
suite('I18n Tests', () => {
test('all message keys have values for all three languages', () => {
const languages: Language[] = ['zh-CN', 'en', 'ja'];
const keys = getMessageKeys();
for (const lang of languages) {
setLanguage(lang);
for (const key of keys) {
const result = t(key);
assert.ok(result, `Key "${key}" is empty for language "${lang}"`);
assert.notStrictEqual(result, key, `Key "${key}" has no translation for language "${lang}"`);
}
}
});
test('default language is zh-CN', () => {
setLanguage('zh-CN');
assert.strictEqual(getLanguage(), 'zh-CN');
});
test('setLanguage changes current language', () => {
setLanguage('en');
assert.strictEqual(getLanguage(), 'en');
setLanguage('ja');
assert.strictEqual(getLanguage(), 'ja');
setLanguage('zh-CN');
});
test('t() falls back to key when key does not exist', () => {
setLanguage('zh-CN');
const result = t('nonexistent.key');
assert.strictEqual(result, 'nonexistent.key');
});
test('t() returns zh-CN string in zh-CN language', () => {
setLanguage('zh-CN');
assert.strictEqual(t('review.noEditor'), '请先打开一个文件');
});
test('t() returns English string in en language', () => {
setLanguage('en');
assert.strictEqual(t('review.noEditor'), 'Please open a file first');
});
test('t() returns Japanese string in ja language', () => {
setLanguage('ja');
assert.strictEqual(t('review.noEditor'), '最初にファイルを開いてください');
});
test('t() with template variables', () => {
setLanguage('en');
assert.strictEqual(
t('export.saved', { 0: '/home/user/report.md' }),
'Report saved to /home/user/report.md'
);
});
test('t() with multiple template variables', () => {
setLanguage('zh-CN');
assert.strictEqual(
t('report.totalSummary', { 0: '10', 1: '3', 2: '5', 3: '2' }),
'总计: 10 | 错误: 3 | 警告: 5 | 建议: 2'
);
});
teardown(() => {
setLanguage('zh-CN');
});
});
+37
View File
@@ -0,0 +1,37 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
import * as path from 'path';
import { ESLintAdapter } from '../adapters/eslint';
import { mergeResults } from '../merger/merger';
suite('Pipeline Tests', () => {
test('Full pipeline: linter check + merge', async () => {
const adapter = new ESLintAdapter();
if (!adapter.isAvailable()) { return; }
const doc = await vscode.workspace.openTextDocument({
content: 'var x = 1;\nvar y = 2;\n',
language: 'javascript',
});
const staticResult = await adapter.check(doc, __dirname);
assert.ok(staticResult.status === 'ok');
const report = mergeResults({
staticDiagnostics: staticResult.diagnostics,
customRuleResults: [],
translatedDiagnostics: [],
aiFindings: [],
errors: [],
degraded: false,
startTime: Date.now(),
filePath: 'virtual-doc',
language: 'javascript',
adapterIds: ['eslint'],
});
assert.ok(typeof report.duration === 'number');
assert.ok(typeof report.linterCount === 'number');
assert.strictEqual(report.language, 'javascript');
});
});
+145
View File
@@ -0,0 +1,145 @@
import * as assert from 'assert';
import { filterForDocument, filterAndSummarize } from '../rules/rule-filter';
import type { CustomRule } from '../types';
function mockDoc(languageId: string, fileName: string): { languageId: string; fileName: string } {
return { languageId, fileName };
}
const baseRules: CustomRule[] = [
{ id: 'java-rule', severity: 'error', description: '', message: '', languages: ['java'] },
{ id: 'js-rule', severity: 'error', description: '', message: '', languages: ['javascript'] },
{ id: 'ts-rule', severity: 'error', description: '', message: '', languages: ['typescript'] },
{ id: 'css-rule', severity: 'error', description: '', message: '', languages: ['css'] },
{ id: 'universal', severity: 'warning', description: '', message: '' },
{ id: 'no-css', severity: 'info', description: '', message: '', excludeLanguages: ['css'] },
{ id: 'empty-langs', severity: 'info', description: '', message: '', languages: [] },
];
suite('Rule Filter Tests', () => {
test('白名单命中', () => {
const doc = mockDoc('java', '/test/Foo.java');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('java-rule'));
assert.ok(ids.includes('universal'));
});
test('白名单未命中', () => {
const doc = mockDoc('java', '/test/Foo.java');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(!ids.includes('js-rule'));
assert.ok(!ids.includes('css-rule'));
});
test('白名单为空→全语言保留', () => {
const doc = mockDoc('css', '/test/test.css');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('universal'));
assert.ok(ids.includes('empty-langs'));
});
test('白名单缺失→全语言保留', () => {
const doc = mockDoc('css', '/test/test.css');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('universal'));
});
test('黑名单命中→剔除', () => {
const doc = mockDoc('css', '/test/test.css');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(!ids.includes('no-css'));
});
test('黑名单未命中→保留', () => {
const doc = mockDoc('java', '/test/Foo.java');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('no-css'));
});
test('黑名单为空→保留', () => {
const rules: CustomRule[] = [
{ id: 'r1', severity: 'info', description: '', message: '', excludeLanguages: [] },
];
const doc = mockDoc('css', '/test/test.css');
const result = filterForDocument(rules, doc as any);
assert.strictEqual(result.length, 1);
});
test('白名单+黑名单交集→黑名单胜出剔除', () => {
const rules: CustomRule[] = [
{ id: 'r1', severity: 'info', description: '', message: '', languages: ['java'], excludeLanguages: ['java'] },
];
const doc = mockDoc('java', '/test/Foo.java');
const result = filterForDocument(rules, doc as any);
assert.strictEqual(result.length, 0);
});
test('typescriptreact 别名→命中 typescript 规则', () => {
const doc = mockDoc('typescriptreact', '/test/App.tsx');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('ts-rule'));
});
test('plsql 同组→命中 sql 规则', () => {
const rules: CustomRule[] = [
{ id: 'sql-rule', severity: 'error', description: '', message: '', languages: ['sql'] },
{ id: 'plsql-rule', severity: 'error', description: '', message: '', languages: ['plsql'] },
];
const doc = mockDoc('plsql', '/test/test.plsql');
const result = filterForDocument(rules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('sql-rule'));
assert.ok(ids.includes('plsql-rule'));
});
test('JSP 并集→保留 java 规则', () => {
const doc = mockDoc('html', '/test/test.jsp');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('java-rule'));
assert.ok(ids.includes('js-rule'));
assert.ok(ids.includes('ts-rule'));
assert.ok(ids.includes('css-rule'));
});
test('JSP 并集→保留 css 规则', () => {
const doc = mockDoc('html', '/test/test.jspx');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('css-rule'));
});
test('普通HTML不触发JSP→剔除java规则', () => {
const doc = mockDoc('html', '/test/index.html');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(!ids.includes('java-rule'));
});
test('全部过滤→skippedRequestA=true', () => {
const rules: CustomRule[] = [
{ id: 'java-rule', severity: 'error', description: '', message: '', languages: ['java'] },
];
const doc = mockDoc('css', '/test/test.css');
const result = filterAndSummarize(rules, doc as any);
assert.strictEqual(result.relevant.length, 0);
assert.strictEqual(result.filteredOut.length, 1);
assert.strictEqual(result.skippedRequestA, true);
});
test('部分过滤→skippedRequestA=false', () => {
const doc = mockDoc('java', '/test/Foo.java');
const result = filterAndSummarize(baseRules, doc as any);
assert.ok(result.relevant.length > 0);
assert.ok(result.filteredOut.length > 0);
assert.strictEqual(result.skippedRequestA, false);
});
});
+170 -29
View File
@@ -36,6 +36,7 @@
var _step1Done = false;
var _step2Done = false;
var _i18n = {};
function updateSteps(step3Done) {
var steps = [_step1Done, _step2Done, step3Done];
@@ -51,25 +52,49 @@
}
}
function toggleCommonRules() {
var body = document.getElementById('commonRulesBody');
var arrow = document.getElementById('commonRulesArrow');
if (!body || !arrow) { return; }
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
arrow.classList.toggle('expanded', isHidden);
}
function toggleCustomRules() {
var body = document.getElementById('customRulesBody');
var arrow = document.getElementById('customRulesArrow');
if (!body || !arrow) { return; }
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
arrow.classList.toggle('expanded', isHidden);
}
function toggleAIReview() {
var body = document.getElementById('aiReviewBody');
var arrow = document.getElementById('aiReviewArrow');
if (!body || !arrow) { return; }
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
arrow.classList.toggle('expanded', isHidden);
}
function escapeHtml(text) {
var d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function populateModelOptions(providerId, selectModel) {
var modelSelect = document.getElementById('modelSelect');
if (!modelSelect) { return; }
var models = (PROVIDERS[providerId] && PROVIDERS[providerId].models) || [];
modelSelect.innerHTML = '';
for (var i = 0; i < models.length; i++) {
var opt = document.createElement('option');
opt.value = models[i];
opt.textContent = models[i];
modelSelect.appendChild(opt);
}
if (models.length > 0) {
modelSelect.value = selectModel && models.indexOf(selectModel) !== -1 ? selectModel : models[0];
function populateModelOptions(providerId, currentModel) {
var input = document.getElementById('modelInput');
if (!input) { return; }
if (!input.value) {
var models = (PROVIDERS[providerId] && PROVIDERS[providerId].models) || [];
if (models.length > 0) {
input.value = models[0];
postMsg('setModel', models[0]);
}
}
}
@@ -78,8 +103,8 @@
postMsg('setProvider', this.value);
});
document.getElementById('modelSelect').addEventListener('change', function () {
postMsg('setModel', this.value);
document.getElementById('modelInput').addEventListener('change', function () {
postMsg('setModel', this.value.trim());
});
document.getElementById('baseUrlInput').addEventListener('change', function () {
@@ -90,6 +115,7 @@
var msg = event.data;
if (msg.type === 'initConfig') {
_i18n = msg.i18n || {};
var c = msg.config;
document.getElementById('languageSelect').value = c.language || 'zh-CN';
@@ -113,50 +139,75 @@
if (c.provider && PROVIDERS[c.provider]) {
populateModelOptions(c.provider, c.model);
var mi = document.getElementById('modelInput');
if (mi) { mi.value = c.model || ''; }
var ps = document.getElementById('providerSelect');
if (ps) { ps.value = c.provider; }
}
var pb = document.getElementById('providerBadge');
if (pb) {
pb.textContent = c.provider && c.model ? '已配置' : '未配置';
pb.textContent = c.provider && c.model ? msg.i18n.configured : msg.i18n.notConfigured;
pb.className = 'badge ' + (c.provider && c.model ? 'badge-configured' : 'badge-unconfigured');
}
var akb = document.getElementById('apiKeyBadge');
akb.textContent = c.apiKeyConfigured && c.baseUrlConfigured ? '已配置' : '未配置';
akb.textContent = c.apiKeyConfigured && c.baseUrlConfigured ? msg.i18n.configured : msg.i18n.notConfigured;
akb.className = 'badge ' + (c.apiKeyConfigured && c.baseUrlConfigured ? 'badge-configured' : 'badge-unconfigured');
var connDone = msg.connectionTested && msg.connectionSuccess;
var btnTest = document.getElementById('btnTest');
if (connDone) {
btnTest.innerHTML = '✓ 已连接';
btnTest.innerHTML = msg.i18n.connected;
} else if (msg.connectionTested && !msg.connectionSuccess) {
btnTest.innerHTML = '✗ 重试';
btnTest.innerHTML = msg.i18n.retry;
} else {
btnTest.innerHTML = '保存并测试连接';
btnTest.innerHTML = msg.i18n.saveTest;
}
btnTest.disabled = false;
var ruleList = document.getElementById('ruleList');
var countBadge = document.getElementById('ruleCountBadge');
var ruleList = document.getElementById('ruleListInEngine');
var customBadge = document.getElementById('customRuleCountBadge');
if (customBadge) {
customBadge.textContent = msg.ruleFiles ? String(msg.ruleFiles.length) : '0';
}
if (msg.ruleFiles && msg.ruleFiles.length > 0) {
countBadge.textContent = msg.ruleFiles.length + ' 个文件';
countBadge.className = 'badge badge-configured';
ruleList.innerHTML = msg.ruleFiles.map(function (f) {
return '<div class="rule-item">' +
'<span class="rule-name">' + escapeHtml(f) + '</span>' +
'<button class="rule-del" onclick="deleteFile(\'' + escapeHtml(f) + '\')">&times;</button></div>';
}).join('');
} else {
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;">' + msg.i18n.noRuleFiles + '</div>';
}
_step1Done = c.provider && c.model && c.apiKeyConfigured && c.baseUrlConfigured;
_step2Done = msg.ruleFiles && msg.ruleFiles.length > 0;
updateSteps(msg.connectionTested && msg.connectionSuccess);
var aiProviderDisplay = document.getElementById('aiProviderDisplay');
var aiModelDisplay = document.getElementById('aiModelDisplay');
var aiConnDisplay = document.getElementById('aiConnectionDisplay');
var aiBadge = document.getElementById('aiReviewStatusBadge');
if (aiProviderDisplay) {
var p = PROVIDERS[c.provider];
aiProviderDisplay.textContent = p ? p.name : c.provider;
}
if (aiModelDisplay) {
aiModelDisplay.textContent = c.model || '';
}
if (aiConnDisplay) {
var connected = msg.connectionTested && msg.connectionSuccess;
aiConnDisplay.textContent = connected ? msg.i18n.connected : msg.i18n.notConnected;
}
if (aiBadge) {
var connected = msg.connectionTested && msg.connectionSuccess;
aiBadge.textContent = connected ? msg.i18n.connected : msg.i18n.notConnected;
aiBadge.style.background = connected ? 'rgba(63,185,80,0.15)' : 'rgba(139,148,158,0.12)';
aiBadge.style.color = connected ? '#3fb950' : 'var(--vscode-descriptionForeground)';
}
renderAdapters(msg.adapterStatus);
}
if (msg.type === 'testResult') {
@@ -168,15 +219,105 @@
var btnTest = document.getElementById('btnTest');
btnTest.disabled = false;
if (msg.success) {
btnTest.innerHTML = '✓ 已连接';
btnTest.innerHTML = _i18n.connected || '✓ Connected';
} else {
btnTest.innerHTML = '✗ 重试';
btnTest.innerHTML = _i18n.retry || '✗ Retry';
}
updateSteps(msg.success);
}
});
function renderAdapters(adapterStatus) {
var container = document.getElementById('adapter-list');
if (!container || !adapterStatus) { return; }
var i18n = _i18n;
var modeBadgeMap = {
builtin: { class: 'adapter-badge-info', text: i18n.modeBuiltin || '' },
project: { class: 'adapter-badge-ok', text: i18n.modeProject || '' },
global: { class: 'adapter-badge-warn', text: i18n.modeGlobal || '' },
};
var html = adapterStatus.map(function(a) {
var modeBadge = modeBadgeMap[a.configMode];
var depBadge = a.dependencyStatus === 'none' ? '' :
a.dependencyStatus === 'ready'
? '<span class="adapter-badge adapter-badge-ok">' + a.dependencyLabel + ' ✓</span>'
: '<span class="adapter-badge adapter-badge-error">' + a.dependencyLabel + ' ✗</span>';
var configBadge = a.configured
? '<span class="adapter-badge adapter-badge-ok">' + i18n.configYes + '</span>'
: '<span class="adapter-badge adapter-badge-warn">' + i18n.configNo + '</span>';
var toggleTooltip = a.enabled
? i18n.toggleDisable.replace('{0}', a.name)
: i18n.toggleEnable.replace('{0}', a.name);
return '<div class="adapter-card' + (a.enabled ? '' : ' disabled') + '">' +
'<div class="adapter-card-header">' +
'<span class="adapter-card-name">' + a.name + '</span>' +
'<div class="adapter-toggle' + (a.enabled ? '' : ' off') + '" data-adapter-id="' + a.id + '" data-tooltip="' + toggleTooltip + '"></div>' +
'</div>' +
'<div class="adapter-badges">' +
'<span class="adapter-badge ' + modeBadge.class + '">' + modeBadge.text + '</span>' +
depBadge +
configBadge +
'</div>' +
'<div class="adapter-languages"><span class="adapter-lang-label">' + i18n.langLabel + '</span>' + escapeHtml(a.languages) + '</div>' +
'<div class="adapter-guide">' + a.guideText + '</div>' +
'<div class="adapter-actions">' +
'<button class="adapter-btn" data-action="openAdapterConfig" data-adapter-id="' + a.id + '" data-tooltip="' + i18n.tooltipCreate.replace('{0}', a.projectConfigFileName) + '">' + i18n.btnCreateConfig + '</button>' +
'<button class="adapter-btn" data-action="openSettings" data-settings-target="' + a.settingsTarget + '" data-tooltip="' + i18n.tooltipEdit + '">' + i18n.btnEditGlobal + '</button>' +
'</div>' +
'</div>';
}).join('');
container.innerHTML = html;
container.querySelectorAll('.adapter-toggle').forEach(function(el) {
el.addEventListener('click', function() {
var adapterId = el.dataset.adapterId;
var isEnabled = !el.classList.contains('off');
vscode.postMessage({
type: 'toggleAdapter',
adapterId: adapterId,
enabled: !isEnabled,
});
});
});
container.querySelectorAll('.adapter-btn').forEach(function(el) {
el.addEventListener('click', function() {
var action = el.dataset.action;
var adapterId = el.dataset.adapterId;
var settingsTarget = el.dataset.settingsTarget;
if (action === 'openAdapterConfig') {
vscode.postMessage({ type: 'openAdapterConfig', adapterId: adapterId });
} else if (action === 'openSettings') {
vscode.postMessage({ type: 'openSettings', settingsTarget: settingsTarget });
}
});
});
var enabledCount = adapterStatus.filter(function(a) { return a.enabled; }).length;
var badge = document.getElementById('adapterCountBadge');
if (badge) { badge.textContent = enabledCount + '/4'; }
}
var tab = document.getElementById('tabCommonRules');
if (tab) {
tab.addEventListener('click', toggleCommonRules);
}
var customTab = document.getElementById('tabCustomRules');
if (customTab) {
customTab.addEventListener('click', toggleCustomRules);
}
var aiTab = document.getElementById('tabAIReview');
if (aiTab) {
aiTab.addEventListener('click', toggleAIReview);
}
vscode.postMessage({ type: 'ready' });
window.postMsg = postMsg;
+580 -72
View File
@@ -1,9 +1,10 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { execSync } from 'child_process';
import { getAIProvider, getAIModel, getAIOutputLanguage, getAIConfig } from '../config/ai';
import { getApiKey, setApiKey } from '../config/secret';
import { createProvider, getAllProviderMeta, getProviderModels } from '../ai/factory';
import { createProvider, getAllProviderMeta, getProviderModels, invalidateProviderCache } from '../ai/factory';
import { listRuleFiles } from '../rules/yaml-parser';
import { ImportService } from '../rules/import-service';
import { showImportPreview } from '../rules/import-preview';
@@ -14,6 +15,128 @@ import { ExcelConverter } from '../rules/converters/excel-converter';
import { DocxConverter } from '../rules/converters/docx-converter';
import { PptxConverter } from '../rules/converters/pptx-converter';
import { t, onLanguageChange } from '../i18n/messages';
import { getEslintConfigPath, getStylelintConfigPath, getPMDRulesetPath, getSqlLintConfigFile, isAdapterEnabled, setAdapterEnabled } from '../config/linter';
type ConfigMode = 'builtin' | 'project' | 'global';
type DependencyStatus = 'ready' | 'missing' | 'none';
interface AdapterConfigStatus {
id: string;
name: string;
enabled: boolean;
configMode: ConfigMode;
dependencyStatus: DependencyStatus;
dependencyLabel?: string;
configured: boolean;
guideText: string;
languages: string;
projectConfigFileName: string;
settingsTarget: string;
}
function getPmdRulesetTemplate(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<ruleset xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>Custom PMD Ruleset</description>
<!-- ${t('setup.template.pmdBestPractices')} -->
<rule ref="category/java/bestpractices.xml" />
<!-- ${t('setup.template.pmdCodeStyle')} -->
<rule ref="category/java/codestyle.xml" />
</ruleset>`;
}
function getSqlfluffTemplate(): string {
return `[sqlfluff]
# ${t('setup.template.sqlfluffDialect')}
dialect = postgres
# ${t('setup.template.sqlfluffRules')}
rules = all`;
}
function getEslintTemplate(): string {
return `module.exports = {
root: true,
env: { node: true, es2022: true },
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
rules: {
'no-unused-vars': 'warn', // ${t('setup.template.eslintComment1')}
'no-console': 'off', // ${t('setup.template.eslintComment2')}
'semi': ['error', 'always'], // ${t('setup.template.eslintComment3')}
},
};`;
}
function getStylelintTemplate(): string {
return `module.exports = {
extends: 'stylelint-config-standard',
rules: {
'indentation': 2, // ${t('setup.template.stylelintComment1')}
'no-empty': true, // ${t('setup.template.stylelintComment2')}
},
};`;
}
const ADAPTER_METADATA: Record<string, {
name: string;
projectConfigFileName: string;
settingsTarget: string;
hasExternalDependency: boolean;
dependencyLabel?: string;
configFileTemplate: () => string;
i18nKey: string;
}> = {
pmd: {
name: 'PMD',
projectConfigFileName: 'ruleset.xml',
settingsTarget: 'vscode-code-reviewer.pmd',
hasExternalDependency: true,
dependencyLabel: 'Java',
configFileTemplate: getPmdRulesetTemplate,
i18nKey: 'pmd',
},
'sql-lint': {
name: 'SQL-Lint',
projectConfigFileName: '.sqlfluff',
settingsTarget: 'vscode-code-reviewer.sql-lint',
hasExternalDependency: true,
dependencyLabel: 'Python + sqlfluff',
configFileTemplate: getSqlfluffTemplate,
i18nKey: 'sql',
},
eslint: {
name: 'ESLint',
projectConfigFileName: '.eslintrc.js',
settingsTarget: 'vscode-code-reviewer.linters',
hasExternalDependency: false,
configFileTemplate: getEslintTemplate,
i18nKey: 'eslint',
},
stylelint: {
name: 'Stylelint',
projectConfigFileName: '.stylelintrc.js',
settingsTarget: 'vscode-code-reviewer.linters',
hasExternalDependency: false,
configFileTemplate: getStylelintTemplate,
i18nKey: 'stylelint',
},
};
const PROJECT_CONFIG_FILES: Record<string, string[]> = {
eslint: ['.eslintrc.js', '.eslintrc.json', '.eslintrc.yaml', '.eslintrc.yml', '.eslintrc', 'eslint.config.js', 'eslint.config.mjs'],
stylelint: ['.stylelintrc.js', '.stylelintrc.json', '.stylelintrc.yaml', '.stylelintrc.yml', '.stylelintrc', 'stylelint.config.js'],
pmd: ['ruleset.xml'],
'sql-lint': ['.sqlfluff'],
};
const GLOBAL_CONFIG_GETTERS: Record<string, () => string> = {
eslint: getEslintConfigPath,
stylelint: getStylelintConfigPath,
pmd: getPMDRulesetPath,
'sql-lint': getSqlLintConfigFile,
};
export class SetupViewProvider implements vscode.WebviewViewProvider {
private _view?: vscode.WebviewView;
@@ -25,6 +148,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
private _scriptUri: vscode.Uri | null = null;
constructor(private context: vscode.ExtensionContext) {
this.restoreConnectionState();
this.importService.registerConverter(new YamlConverter());
this.importService.registerConverter(new MdConverter());
this.importService.registerConverter(new TxtConverter());
@@ -33,6 +157,36 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
this.importService.registerConverter(new PptxConverter());
}
private async getConfigFingerprint(): Promise<string> {
const cfg = getAIConfig();
const hasApiKey = await isApiKeyConfigured(this.context);
const hasBaseUrl = isBaseUrlConfigured();
return `${cfg.provider}|${cfg.model}|${hasApiKey}|${hasBaseUrl}`;
}
private restoreConnectionState(): void {
const saved = this.context.globalState.get<{ tested: boolean; success: boolean; fingerprint: string }>('connectionState');
if (saved) {
this.connectionTested = saved.tested;
this.connectionSuccess = saved.success;
}
}
private async saveConnectionState(): Promise<void> {
const fingerprint = await this.getConfigFingerprint();
await this.context.globalState.update('connectionState', {
tested: this.connectionTested,
success: this.connectionSuccess,
fingerprint,
});
}
private async clearConnectionState(): Promise<void> {
this.connectionTested = false;
this.connectionSuccess = false;
await this.context.globalState.update('connectionState', undefined);
}
resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
@@ -46,7 +200,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
};
const config = getAIConfig();
const providers = getAllProviderMeta();
const providers = getAllProviderMeta(this.context.extensionUri);
const scriptUri = webviewView.webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview', 'setupView.js')
);
@@ -54,6 +208,13 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
this._providers = providers;
this._config = aiConfig;
this._scriptUri = scriptUri;
this.getConfigFingerprint().then(fingerprint => {
const saved = this.context.globalState.get<{ fingerprint: string }>('connectionState');
if (saved && saved.fingerprint !== fingerprint) {
this.clearConnectionState();
this.pushConfig();
}
});
webviewView.webview.html = this.getHtml(providers, aiConfig, scriptUri);
const langDisposable = onLanguageChange(() => {
@@ -75,25 +236,29 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
break;
case 'setApiKey':
await setApiKey(this.context, msg.value);
await this.clearConnectionState();
await this.pushConfig();
break;
case 'setProvider': {
const cfg = vscode.workspace.getConfiguration('vscode-code-reviewer');
await cfg.update('ai.provider', msg.value, vscode.ConfigurationTarget.Global);
const models = getProviderModels(msg.value);
const models = getProviderModels(this.context.extensionUri, msg.value);
if (models.length > 0) {
await cfg.update('ai.model', models[0], vscode.ConfigurationTarget.Global);
}
await this.clearConnectionState();
await this.pushConfig();
break;
}
case 'setModel':
await vscode.workspace.getConfiguration('vscode-code-reviewer').update('ai.model', msg.value, vscode.ConfigurationTarget.Global);
await this.clearConnectionState();
await this.pushConfig();
break;
case 'setBaseUrl':
await vscode.workspace.getConfiguration('vscode-code-reviewer')
.update('ai.baseUrl', msg.value || undefined, vscode.ConfigurationTarget.Global);
await this.clearConnectionState();
await this.pushConfig();
break;
case 'setLanguage':
@@ -115,8 +280,51 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
await this.resetConfig();
await this.pushConfig();
break;
case 'openAdapterConfig':
await this.handleAdapterConfig(msg.adapterId);
await this.pushConfig();
break;
case 'openSettings':
await vscode.commands.executeCommand(
'workbench.action.openSettings',
msg.settingsTarget
);
break;
case 'toggleAdapter':
await setAdapterEnabled(msg.adapterId, msg.enabled);
break;
}
});
const configChangeDisposable = vscode.workspace.onDidChangeConfiguration((e) => {
if (
e.affectsConfiguration('vscode-code-reviewer.linter') ||
e.affectsConfiguration('vscode-code-reviewer.linters') ||
e.affectsConfiguration('vscode-code-reviewer.pmd') ||
e.affectsConfiguration('vscode-code-reviewer.sql-lint')
) {
this.pushConfig();
}
});
const watcher = vscode.workspace.createFileSystemWatcher(
'**/.code-review/providers.json'
);
watcher.onDidChange(() => {
invalidateProviderCache();
this.pushConfig();
});
watcher.onDidCreate(() => {
invalidateProviderCache();
this.pushConfig();
});
webviewView.onDidDispose(() => {
configChangeDisposable.dispose();
watcher.dispose();
});
}
private async pushConfig(): Promise<void> {
@@ -139,10 +347,33 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
language: config.outputLanguage,
apiKeyConfigured,
},
providers: getAllProviderMeta(),
providers: getAllProviderMeta(this.context.extensionUri),
ruleFiles,
connectionTested: this.connectionTested,
connectionSuccess: this.connectionSuccess,
adapterStatus: this.collectAdapterStatus(),
i18n: {
configured: t('setup.configured'),
notConfigured: t('setup.notConfigured'),
connected: t('setup.connected'),
notConnected: t('setup.notConnected'),
retry: t('setup.retry'),
saveTest: t('setup.saveAndTest'),
ruleCountFormat: t('setup.ruleCountFormat'),
noRuleFiles: t('setup.noRuleFiles'),
modeBuiltin: t('setup.adapter.modeBuiltin'),
modeProject: t('setup.adapter.modeProject'),
modeGlobal: t('setup.adapter.modeGlobal'),
configYes: t('setup.adapter.configYes'),
configNo: t('setup.adapter.configNo'),
langLabel: t('setup.adapter.langLabel'),
btnCreateConfig: t('setup.adapter.btnCreateConfig'),
btnEditGlobal: t('setup.adapter.btnEditGlobal'),
tooltipCreate: t('setup.adapter.tooltipCreate'),
tooltipEdit: t('setup.adapter.tooltipEdit'),
toggleEnable: t('setup.adapter.toggleEnable'),
toggleDisable: t('setup.adapter.toggleDisable'),
},
});
}
@@ -161,7 +392,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
const config = getAIConfig();
try {
const provider = createProvider(config.provider, apiKey, config.baseUrl);
const provider = createProvider(config.provider, apiKey, config.baseUrl, this.context.extensionUri);
await provider.chat('回复 ok', 'ping', {
model: config.model,
temperature: 0,
@@ -170,10 +401,12 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
});
this.connectionTested = true;
this.connectionSuccess = true;
await this.saveConnectionState();
this._view?.webview.postMessage({ type: 'testResult', success: true, message: t('setup.testSuccess') });
} catch (err) {
this.connectionTested = true;
this.connectionSuccess = false;
await this.saveConnectionState();
const message = err instanceof Error ? err.message : String(err);
this._view?.webview.postMessage({ type: 'testResult', success: false, message: t('setup.testFail', { 0: message }) });
}
@@ -253,8 +486,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
await config.update('ai.model', undefined, vscode.ConfigurationTarget.Global);
await config.update('ai.outputLanguage', undefined, vscode.ConfigurationTarget.Global);
await this.deleteApiKey();
this.connectionTested = false;
this.connectionSuccess = false;
await this.clearConnectionState();
}
private async deleteApiKey(): Promise<void> {
@@ -290,6 +522,13 @@ body {
padding: 10px 0 14px; font-size: 15px; font-weight: 600; color: var(--vscode-foreground);
border-bottom: 1px solid var(--vscode-panel-border); margin-bottom: 12px;
}
.panel-header-title {
display: flex; align-items: center; gap: 8px; flex: 1;
}
.panel-header .lang-select {
width: auto; min-width: 100px; padding: 2px 24px 2px 8px;
font-size: 11px; min-height: 24px; flex-shrink: 0;
}
/* Section */
.section { margin-bottom: 16px; }
@@ -472,17 +711,151 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
border-top-color: #fff; border-radius: 50%;
animation: spin .6s linear infinite;
}
/* Adapter cards */
.adapter-card {
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: opacity 0.2s;
}
.adapter-card.disabled { opacity: 0.45; }
.adapter-card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.adapter-card-name { font-size: 12px; font-weight: 600; color: var(--vscode-foreground); }
.adapter-toggle {
width: 30px; height: 16px; border-radius: 8px;
background: #3fb950; position: relative; cursor: pointer;
transition: background 0.2s;
}
.adapter-toggle.off { background: #3c3c3c; }
.adapter-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; border-radius: 50%; background: #fff;
transition: transform 0.2s;
transform: translateX(14px);
}
.adapter-toggle.off::after { transform: translateX(0); background: #ccc; }
.adapter-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 5px; }
.adapter-badge {
display: inline-flex; align-items: center; padding: 1px 5px;
border-radius: 3px; font-size: 9px; font-weight: 600;
}
.adapter-badge-info { background: rgba(139,92,246,0.15); color: #8b5cf6; }
.adapter-badge-ok { background: rgba(63,185,80,0.15); color: #3fb950; }
.adapter-badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
.adapter-badge-error { background: rgba(248,81,73,0.15); color: #f48771; }
.adapter-languages { font-size: 10px; color: var(--vscode-descriptionForeground); margin-bottom: 4px; }
.adapter-lang-label { color: var(--vscode-descriptionForeground); }
.adapter-guide { font-size: 10px; color: var(--vscode-descriptionForeground); line-height: 1.4; margin-bottom: 7px; }
.adapter-actions { display: flex; gap: 5px; }
.adapter-btn {
padding: 2px 8px; border-radius: 3px; font-size: 10px;
border: 1px solid var(--vscode-panel-border); background: transparent;
color: var(--vscode-foreground); cursor: pointer;
}
.adapter-btn:hover { background: var(--vscode-panel-border); }
/* Clickable engine tab */
.engine-tab-top {
display: flex; justify-content: space-between; align-items: flex-start; width: 100%;
}
.engine-tab.clickable { cursor: pointer; user-select: none; position: relative; }
.engine-tab.clickable:hover { border-color: var(--vscode-focusBorder); }
[data-tooltip] { position: relative; }
[data-tooltip]:hover::before {
content: attr(data-tooltip);
position: absolute; bottom: calc(100% + 14px); left: 50%;
transform: translateX(-50%);
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-foreground);
border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
border-radius: 4px; padding: 4px 8px;
font-size: 12px; white-space: nowrap;
z-index: 200; pointer-events: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.adapter-toggle[data-tooltip]:hover::before {
top: 50%; right: calc(100% + 8px);
bottom: auto; left: auto; transform: translateY(-50%);
}
.adapter-btn[data-action="openAdapterConfig"][data-tooltip]:hover::before {
top: 50%; left: calc(100% + 8px);
bottom: auto; right: auto; transform: translateY(-50%);
}
.engine-indicator {
font-size: 9px; color: var(--vscode-descriptionForeground);
transition: transform .15s; flex-shrink: 0;
}
.engine-indicator.expanded { transform: rotate(90deg); }
.engine-badge {
display: inline-flex; align-items: center;
padding: 1px 6px; border-radius: 8px;
background: rgba(139,92,246,0.15); color: #8b5cf6;
font-size: 10px; font-weight: 600;
white-space: nowrap; flex-shrink: 0;
}
.engine-tab-footer {
display: flex; align-items: center; gap: 6px;
justify-content: flex-end; width: 100%; margin-top: 6px;
}
.engine-common-rules-body {
margin-top: 8px; padding: 0 2px;
}
.engine-custom-rules-body,
.engine-ai-review-body {
margin-top: 8px;
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
}
.ai-review-status-grid {
display: flex; flex-direction: column; gap: 4px;
margin-bottom: 10px;
}
.status-row {
display: flex; align-items: center; justify-content: space-between;
font-size: 12px;
}
.status-label { color: var(--vscode-descriptionForeground); }
.status-value { color: var(--vscode-foreground); font-weight: 500; }
.engine-subtitle {
font-size: 11px; font-weight: 600;
color: var(--vscode-descriptionForeground); margin-bottom: 8px;
}
.mode-legend {
display: flex; flex-wrap: wrap; gap: 6px 12px;
margin-bottom: 10px; padding: 6px 8px;
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
border: 1px solid var(--vscode-panel-border); border-radius: 6px;
font-size: 10px; color: var(--vscode-descriptionForeground);
}
.mode-legend-item { display: inline-flex; align-items: center; gap: 4px; }
.mode-legend-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.mode-legend-desc { width: 100%; font-size: 10px; opacity: 0.75; }
</style>
</head>
<body>
<div class="panel">
<div class="panel-header">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
${t('setup.header')}
<div class="panel-header-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
${t('setup.header')}
</div>
<select class="lang-select" id="languageSelect" onchange="postMsg('setLanguage', this.value)">
<option value="zh-CN"${config.outputLanguage === 'zh-CN' ? ' selected' : ''}>中文(简体)</option>
<option value="en"${config.outputLanguage === 'en' ? ' selected' : ''}>English</option>
<option value="ja"${config.outputLanguage === 'ja' ? ' selected' : ''}>日本語</option>
</select>
</div>
<!-- 1. 快速开始 -->
@@ -513,37 +886,9 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
</div>
</div>
<!-- 2. 审核引擎 -->
<!-- 2. AI 连接配置 -->
<div class="section">
<div class="section-title">${t('setup.engineSection')}</div>
<div class="engines">
<div class="engine-tab">
<span class="engine-dot dot-purple"></span>
<div>
<div class="engine-label">${t('setup.commonRules')}</div>
<div class="engine-desc">${t('setup.linterStatic')}</div>
</div>
</div>
<div class="engine-tab">
<span class="engine-dot dot-amber"></span>
<div>
<div class="engine-label">${t('setup.customRules')}</div>
<div class="engine-desc">${t('setup.teamCoding')}</div>
</div>
</div>
<div class="engine-tab">
<span class="engine-dot dot-green"></span>
<div>
<div class="engine-label">${t('setup.aiReview')}</div>
<div class="engine-desc">${t('setup.deepReview')}</div>
</div>
</div>
</div>
</div>
<!-- 3. AI 模型配置 -->
<div class="section">
<div class="section-title">${t('setup.aiConfig')}</div>
<div class="section-title">${t('setup.aiConnectionConfig')}</div>
<div class="card">
<div class="card-row">
<span class="card-label">${t('setup.provider')}</span>
@@ -556,18 +901,13 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
</div>
<div class="field">
<label class="field-label">${t('setup.model')}</label>
<select id="modelSelect">${(providers[config.provider]?.models ?? []).map(m =>
`<option value="${m}"${config.model === m ? ' selected' : ''}>${m}</option>`
).join('\n ')}</select>
<input type="text" id="modelInput"
value="${config.model || ''}"
placeholder="${t('setup.modelPlaceholder')}"
onchange="postMsg('setModel', this.value)">
</div>
<div class="field-hint">${t('setup.modelHint')}</div>
</div>
</div>
<!-- 4. API Key -->
<div class="section">
<div class="section-title">API Key</div>
<div class="card">
<div style="border-top:1px solid var(--vscode-panel-border);margin:10px -12px;"></div>
<div class="card-row">
<span class="card-label">${t('setup.apiKey')}</span>
<span class="badge" id="apiKeyBadge">${t('setup.notConfigured')}</span>
@@ -583,28 +923,65 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
</div>
</div>
<!-- 5. 输出语言 -->
<!-- 3. 审核引擎 -->
<div class="section">
<div class="section-title">${t('setup.outputLang')}</div>
<div class="field">
<label class="field-label">${t('setup.outputLangHint')}</label>
<select id="languageSelect" onchange="postMsg('setLanguage', this.value)">
<option value="zh-CN"${config.outputLanguage === 'zh-CN' ? ' selected' : ''}>中文(简体)</option>
<option value="en"${config.outputLanguage === 'en' ? ' selected' : ''}>English</option>
<option value="ja"${config.outputLanguage === 'ja' ? ' selected' : ''}>日本語</option>
</select>
</div>
</div>
<!-- 6. 自定义规则 -->
<div class="section">
<div class="section-title">${t('setup.customRulesSection')}</div>
<div class="card">
<div class="card-row">
<span class="card-label">${t('setup.ruleList')}</span>
<span class="badge" style="background:rgba(139,148,158,0.12);color:#8b949e;" id="ruleCountBadge">${t('setup.ruleCount', { 0: '0' })}</span>
<div class="section-title">${t('setup.engineSection')}</div>
<div class="engines">
<div class="engine-tab clickable" id="tabCommonRules" data-tooltip="${t('setup.adapter.tooltipTab')}">
<div class="engine-tab-top">
<span class="engine-dot dot-purple"></span>
<span class="engine-badge" id="adapterCountBadge">0/4</span>
</div>
<div>
<div class="engine-label">${t('setup.commonRules')}</div>
<div class="engine-desc">${t('setup.linterStatic')}</div>
</div>
<div class="engine-tab-footer">
<span class="engine-indicator" id="commonRulesArrow">▶</span>
</div>
</div>
<div id="ruleList"></div>
<div class="engine-tab clickable" id="tabCustomRules" data-tooltip="${t('setup.adapter.tooltipTab')}">
<div class="engine-tab-top">
<span class="engine-dot dot-amber"></span>
<span class="engine-badge" id="customRuleCountBadge" style="background:rgba(210,153,34,0.15);color:#d29922;">0</span>
</div>
<div>
<div class="engine-label">${t('setup.customRules')}</div>
<div class="engine-desc">${t('setup.teamCoding')}</div>
</div>
<div class="engine-tab-footer">
<span class="engine-indicator" id="customRulesArrow">▶</span>
</div>
</div>
<div class="engine-tab clickable" id="tabAIReview" data-tooltip="${t('setup.adapter.tooltipTab')}">
<div class="engine-tab-top">
<span class="engine-dot dot-green"></span>
<span class="engine-badge" id="aiReviewStatusBadge" style="background:rgba(139,148,158,0.12);color:var(--vscode-descriptionForeground);">${t('setup.notConfigured')}</span>
</div>
<div>
<div class="engine-label">${t('setup.aiReview')}</div>
<div class="engine-desc">${t('setup.deepReview')}</div>
</div>
<div class="engine-tab-footer">
<span class="engine-indicator" id="aiReviewArrow">▶</span>
</div>
</div>
</div>
<div class="engine-common-rules-body" id="commonRulesBody" style="display:none;">
<div class="engine-subtitle">${t('setup.adapter.subtitle')}</div>
<div class="mode-legend">
<span class="mode-legend-item"><span class="mode-legend-dot" style="background:#8b5cf6;"></span>${t('setup.adapter.modeBuiltin')}</span>
<span class="mode-legend-item"><span class="mode-legend-dot" style="background:#3fb950;"></span>${t('setup.adapter.modeProject')}</span>
<span class="mode-legend-item"><span class="mode-legend-dot" style="background:#d29922;"></span>${t('setup.adapter.modeGlobal')}</span>
<span class="mode-legend-desc">${t('setup.adapter.modeLegend')}</span>
</div>
<div id="adapter-list"></div>
</div>
<div class="engine-custom-rules-body" id="customRulesBody" style="display:none;">
<div class="engine-subtitle">${t('setup.ruleList')}</div>
<div id="ruleListInEngine"></div>
<div class="field" style="margin-top:8px;">
<div class="input-group">
<input type="text" id="newRuleInput" placeholder="${t('setup.ruleNamePlaceholder')}">
@@ -614,6 +991,27 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
<div class="field-hint">${t('setup.ruleNameHint')}</div>
</div>
</div>
<div class="engine-ai-review-body" id="aiReviewBody" style="display:none;">
<div class="ai-review-status-grid">
<div class="status-row">
<span class="status-label">${t('setup.provider')}</span>
<span class="status-value" id="aiProviderDisplay"></span>
</div>
<div class="status-row">
<span class="status-label">${t('setup.model')}</span>
<span class="status-value" id="aiModelDisplay"></span>
</div>
<div class="status-row">
<span class="status-label">连接状态</span>
<span class="status-value" id="aiConnectionDisplay"></span>
</div>
</div>
<div class="status-row">
<span class="status-label">${t('setup.aiReviewStatusCapability')}</span>
<span class="status-value">${t('setup.aiReviewCapability')}</span>
</div>
</div>
</div>
<!-- Actions -->
@@ -636,6 +1034,116 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
</body>
</html>`;
}
private detectConfigMode(adapterId: string): ConfigMode {
const globalPath = GLOBAL_CONFIG_GETTERS[adapterId]?.();
if (globalPath && globalPath.trim() !== '') {
return 'global';
}
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders && workspaceFolders.length > 0) {
const rootPath = workspaceFolders[0].uri.fsPath;
const configFiles = PROJECT_CONFIG_FILES[adapterId] ?? [];
for (const fileName of configFiles) {
const filePath = path.join(rootPath, fileName);
if (fs.existsSync(filePath)) {
return 'project';
}
}
}
return 'builtin';
}
private checkJavaReady(): boolean {
try {
const result = execSync('java -version 2>&1', {
encoding: 'utf-8',
timeout: 5000,
});
return result.includes('version');
} catch {
return false;
}
}
private checkPythonReady(): boolean {
let pythonCmd = '';
for (const cmd of ['python3', 'python']) {
try {
execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
pythonCmd = cmd;
break;
} catch { continue; }
}
if (!pythonCmd) { return false; }
try {
execSync('sqlfluff --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
return true;
} catch {
return false;
}
}
private collectAdapterStatus(): AdapterConfigStatus[] {
const statuses: AdapterConfigStatus[] = [];
for (const [id, meta] of Object.entries(ADAPTER_METADATA)) {
const configMode = this.detectConfigMode(id);
const enabled = isAdapterEnabled(id);
let dependencyStatus: DependencyStatus = 'none';
if (meta.hasExternalDependency) {
if (id === 'pmd') {
dependencyStatus = this.checkJavaReady() ? 'ready' : 'missing';
} else if (id === 'sql-lint') {
dependencyStatus = this.checkPythonReady() ? 'ready' : 'missing';
}
}
const configured = !meta.hasExternalDependency || configMode !== 'builtin' || dependencyStatus === 'ready';
statuses.push({
id,
name: meta.name,
enabled,
configMode,
dependencyStatus,
dependencyLabel: meta.dependencyLabel,
configured,
guideText: t(`setup.adapter.${meta.i18nKey}Guide`),
languages: t(`setup.adapter.${meta.i18nKey}Languages`),
projectConfigFileName: meta.projectConfigFileName,
settingsTarget: meta.settingsTarget,
});
}
return statuses;
}
private async handleAdapterConfig(adapterId: string): Promise<void> {
const meta = ADAPTER_METADATA[adapterId];
if (!meta) { return; }
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
vscode.window.showWarningMessage('请先打开一个工作区文件夹');
return;
}
const rootPath = workspaceFolders[0].uri.fsPath;
const filePath = path.join(rootPath, meta.projectConfigFileName);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, meta.configFileTemplate(), 'utf-8');
vscode.window.showInformationMessage(`配置文件已创建: ${meta.projectConfigFileName}`);
}
const doc = await vscode.workspace.openTextDocument(filePath);
await vscode.window.showTextDocument(doc);
}
}
async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise<boolean> {