diff --git a/.gitignore b/.gitignore
index 64dcab0..e8d56e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,4 +6,3 @@ dist/
.env
*.log
.superpowers/
-src/test/
diff --git a/.vscode-test.mjs b/.vscode-test.mjs
index b62ba25..2eace2e 100644
--- a/.vscode-test.mjs
+++ b/.vscode-test.mjs
@@ -1,5 +1,5 @@
import { defineConfig } from '@vscode/test-cli';
export default defineConfig({
- files: 'out/test/**/*.test.js',
+ files: 'out/tests/**/*.test.js',
});
diff --git a/README.md b/README.md
index 8d48ffb..4e23750 100644
--- a/README.md
+++ b/README.md
@@ -171,3 +171,7 @@ Webview 资源在编译/打包时自动复制到 `out/webview/`,随插件加
- **波浪线标记**:`vscode-code-reviewer.markers.enabled`。
详细配置项可在 VS Code 设置页搜索 `vscode-code-reviewer` 查看。
+
+## 演示视频
+
+演示视频(`docs/demo.mp4`,≤5 分钟:完整工作流 + IDE 集成效果 + 异常处理)**进行中**,将在后续提交中补齐。
diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md
index 9aaa26f..fbd9bb5 100644
--- a/_AI_USAGE_LOG.md
+++ b/_AI_USAGE_LOG.md
@@ -226,3 +226,5 @@
| 2026-08-25 19:13 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | diff 预览改为在侧边新建编辑器组打开(不覆盖当前正在查看的文件):fixPreview.ts openPreviewDiff 的 vscode.diff 调用补第 4 参数 { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },4 个触发点(fixIssue/fixAll × linter/custom-AI)均走此函数全覆盖;closePreviewEditor 遍历所有 tabGroups 与分组位置无关无需改动。lint 0 error(仅既有 mockDocument.ts 2 warning)/ compile 通过 / npm test 111 passing | 澄清阶段「新建编辑器组/侧边标签页」vs「全新 VSCode 窗口实例」两分支,用户选前者(全新窗口扩展 API 不支持且 diff 内容在内存中不可行);preserveFocus 加否为方案决策点,用户确认加(打开 diff 后焦点留在审查面板) | src/fix/fixPreview.ts | deepseek-v4-flash |
| 2026-08-25 21:35 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 sqlfluff 行号显示 LNaN:根因是 sqlfluff 4.x 对「jinja 标签位于注释内」的 JJ01 违规 JSON 缺失 end_line_no/end_line_pos,适配器 `v.end_line_no-1`=NaN → VSCode Range 构造时 start.isBefore(end) 对 NaN 恒 false 触发 start/end 交换 → range.start.line=NaN → 面板 L${line+1} 渲染 LNaN。修复:adapters/sqlfluff.ts 新增纯函数 resolveSqlFluffRange(缺失/null/NaN/非法值兜底:start 回退 1、end 回退 start;兼容旧版 line_no/line_pos key;start_line_pos=0 钳制 ≥1),check() 改用其构造 Range;SqlFluffViolation position 字段改可选;webview.ts buildIssueItem 与 utils/report.ts formatLine 加 Number.isFinite 防御(非法行号渲染 L?);新增 src/test/sqlfluff-range.test.ts 6 用例。验证:lint 0 error(仅既有 mockDocument.ts 2 warning)/ compile 通过 / npm test 111 passing | 中间产物:①根因排查多轮——先后排除旧版 sqlfluff schema(line_no key)与「注释场景缺 end 字段只坏终点不坏起点」假设,最终结合 VSCode extHostTypes/range.ts 源码确认 NaN 使 isBefore 恒 false 触发 start/end 交换,链路闭合;②sanitizePosition 初版参数类型 number|undefined,测试用例传 null 触发 TS 类型错误,接口与函数签名扩为 number|null|undefined | src/adapters/sqlfluff.ts src/panel/webview.ts src/utils/report.ts src/test/sqlfluff-range.test.ts(新建) | deepseek-v4-flash |
| 2026-08-25 21:54 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 从插件内置 PMD 配置移除 5 条实际不可触发的规则(AvoidAssertAsIdentifier/AvoidEnumAsIdentifier 语言版本上限 1.3/1.4、AccessorClassGeneration/AccessorMethodGeneration 上限 Java 10、LoosePackageCoupling 需显式 packages 配置),使内置配置=全部可触发,demo-pmd 覆盖率基线对齐 269+12=281 可达成 100%。改动:jars/pmd/pmd-java-ruleset.xml 三个分类 exclude 各追加(bestpractices 2 + design 1 + errorprone 2)并将 description 计数 274→269;src/rules/static-rules.json linterVersion.pmd 改 269、移除 5 条规则条目(pmd 295→290);scripts/translations/pmd-1.mjs/pmd-2.mjs 同步删除 5 条翻译种子项。验证:PMD 实际跑内置 ruleset 无加载异常 / lint 0 error / compile 通过 / npm test 117 passing | 中间产物:static-rules.json 第一处编辑误保留 AccessorClassGeneration 块(只删了 AccessorMethodGeneration),复查 JSON 计数发现后补删 | jars/pmd/pmd-java-ruleset.xml src/rules/static-rules.json scripts/translations/pmd-1.mjs scripts/translations/pmd-2.mjs | deepseek-v4-flash |
+| 2026-08-26 19:05 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 仓库目录对齐赛道二 §8 成果物规范:分支 vscode-code-reviewer 合并入 main(冲突文件 docs/superpowers/specs/2026-07-10-code-reviewer-design.md 取开发分支完整版,add/add 冲突已解决);src/test 迁移至根目录 tests/(16 个测试 + fixtures/manual),36 处 import '../' → '../src/';新建 tsconfig.test.json(rootDir ".",include tests + src/types 声明文件);.vscode-test.mjs files 改 'out/tests/**/*.test.js';package.json 新增 compile:test、test 改为 compile:test && vscode-test;.gitignore 移除 src/test/;3 个中文文件名 demo-*插件实测覆盖率报告.md 重命名为 demo-*-coverage-report.md;git rm --cached 解除 vscode-code-reviewer-1.0.0.vsix 跟踪;README 追加演示视频(进行中)章节。验证:lint 0 error / compile 通过 / compile:test 通过 / npm test 116 passing | 中间产物:tsconfig.test.json 初版 include 仅 tests/**/*,编译报 stylelint-config-recommended 声明缺失(主 tsconfig include src/**/* 含 src/types 声明文件而 test 配置未含),补 include src/types/**/* 后通过 | tests/ tests/fixtures/ tests/manual/ tsconfig.test.json(新建) .vscode-test.mjs package.json .gitignore README.md data/demo-eslint/reports/demo-eslint-coverage-report.md data/demo-sqlfluff/reports/demo-sqlfluff-coverage-report.md data/demo-stylelint/reports/demo-stylelint-coverage-report.md | deepseek-v4-flash |
+| 2026-08-26 19:16 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 补齐 tests/ 实验报告(§8 成果物 04,coverage/ 按用户确认忽略):新建 tests/test-execution-log.md(测试执行日志:命令/环境/结果 116 passing 0 failing);tests/test-cases.md(从 16 个测试文件提取 suite/test 声明,116 条用例清单+覆盖点+统计);tests/measure/measure-review-time.mjs(提效测量脚本:内联复刻 src/rules/builtin-rules.ts 的 ESLint/Stylelint 内置配置,用 eslint/stylelint 引擎直跑 data/demo-*/src 样例计时,输出 tests/measure/results/measure-results.json);tests/measure/performance-comparison.md(提效对比骨架:测量方法+对比维度+插件侧实测数据已填 demo-eslint 6 文件 294 诊断 192.2ms / demo-stylelint 2 文件 115 诊断 2106.7ms / 合计 409 诊断 2298.9ms,人工基线留待实测)。验证:measure 脚本实测运行产出 JSON / lint 0 error(仅既有 mockDocument 2 warning)/ 主流程 npm test 116 passing 不受影响 | 中间产物:measure 脚本初版 import '../../src/rules/builtin-rules'(ESM 无法直接加载 TS 报 ERR_MODULE_NOT_FOUND)→ 改 import 编译产物 '../../out/rules/builtin-rules.js' 又因模块顶层 import vscode 脱离扩展宿主报 MODULE_NOT_FOUND → 最终改为内联规则配置(与 data/demo-eslint/run-coverage.mjs 既有模式一致);measure() 计时函数初版未 await 异步 fn,result 为 Promise 导致 result.reduce 报错,改为 async/await 后通过 | tests/test-execution-log.md(新建) tests/test-cases.md(新建) tests/measure/measure-review-time.mjs(新建) tests/measure/performance-comparison.md(新建) tests/measure/results/measure-results.json(新建) | deepseek-v4-flash |
diff --git a/data/demo-eslint/reports/demo-eslint插件实测覆盖率报告.md b/data/demo-eslint/reports/demo-eslint-coverage-report.md
similarity index 100%
rename from data/demo-eslint/reports/demo-eslint插件实测覆盖率报告.md
rename to data/demo-eslint/reports/demo-eslint-coverage-report.md
diff --git a/data/demo-sqlfluff/reports/demo-sqlfluff插件实测覆盖率报告.md b/data/demo-sqlfluff/reports/demo-sqlfluff-coverage-report.md
similarity index 100%
rename from data/demo-sqlfluff/reports/demo-sqlfluff插件实测覆盖率报告.md
rename to data/demo-sqlfluff/reports/demo-sqlfluff-coverage-report.md
diff --git a/data/demo-stylelint/reports/demo-stylelint插件实测覆盖率报告.md b/data/demo-stylelint/reports/demo-stylelint-coverage-report.md
similarity index 100%
rename from data/demo-stylelint/reports/demo-stylelint插件实测覆盖率报告.md
rename to data/demo-stylelint/reports/demo-stylelint-coverage-report.md
diff --git a/package.json b/package.json
index 85898f8..fc97337 100644
--- a/package.json
+++ b/package.json
@@ -335,7 +335,8 @@
"package-prod": "node scripts/package-prod.mjs",
"pretest": "npm run compile && npm run lint",
"lint": "eslint src",
- "test": "vscode-test"
+ "compile:test": "tsc -p ./tsconfig.test.json",
+ "test": "npm run compile:test && vscode-test"
},
"dependencies": {
"@eslint/js": "^9.39.3",
diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts
new file mode 100644
index 0000000..2b79598
--- /dev/null
+++ b/tests/adapter.test.ts
@@ -0,0 +1,80 @@
+import * as assert from 'assert';
+import * as vscode from 'vscode';
+import { ESLintAdapter } from '../src/adapters/eslint';
+import { StylelintAdapter } from '../src/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));
+ });
+
+ test('ESLintAdapter surfaces octal literal as eslint:parse-error', async () => {
+ const adapter = new ESLintAdapter();
+ if (!adapter.isAvailable()) { return; }
+
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'var a = 010;\n',
+ language: 'javascript',
+ });
+
+ const result = await adapter.check(doc, __dirname);
+ assert.strictEqual(result.status, 'ok');
+ const parseErrors = result.diagnostics.filter(d => d.ruleId === 'eslint:parse-error');
+ assert.ok(parseErrors.length >= 1, 'expected a parse-error diagnostic for octal literal');
+ assert.strictEqual(parseErrors[0].severity, 'error');
+ });
+
+ test('ESLintAdapter surfaces \\8 escape as eslint:parse-error', async () => {
+ const adapter = new ESLintAdapter();
+ if (!adapter.isAvailable()) { return; }
+
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'var b = "\\8";\n',
+ language: 'javascript',
+ });
+
+ const result = await adapter.check(doc, __dirname);
+ assert.strictEqual(result.status, 'ok');
+ const parseErrors = result.diagnostics.filter(d => d.ruleId === 'eslint:parse-error');
+ assert.ok(parseErrors.length >= 1, 'expected a parse-error diagnostic for \\8 escape');
+ });
+
+ test('ESLintAdapter parses JSX in .js without parse error', async () => {
+ const adapter = new ESLintAdapter();
+ if (!adapter.isAvailable()) { return; }
+
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const App = () =>
hi
;\n',
+ language: 'javascript',
+ });
+
+ const result = await adapter.check(doc, __dirname);
+ assert.strictEqual(result.status, 'ok');
+ assert.ok(
+ !result.diagnostics.some(d => d.ruleId === 'eslint:parse-error'),
+ 'JSX in .js should not produce a parse error'
+ );
+ });
+});
diff --git a/tests/ai-empty-response.test.ts b/tests/ai-empty-response.test.ts
new file mode 100644
index 0000000..e954f74
--- /dev/null
+++ b/tests/ai-empty-response.test.ts
@@ -0,0 +1,100 @@
+import * as assert from 'assert';
+import { parseJsonResponse, chatWithRetry } from '../src/ai/engine';
+import { EmptyContentError } from '../src/ai/providers/base';
+import type { AIProvider } from '../src/ai/providers/base';
+import type { ChatOptions } from '../src/ai/providers/base';
+import { OpenAICompatibleProvider } from '../src/ai/providers/openai-compatible';
+
+const OPTIONS: ChatOptions = {
+ model: 'test',
+ temperature: 0,
+ maxTokens: 1024,
+ timeoutMs: 5000,
+};
+
+suite('AI Empty Response Handling', () => {
+ test('parseJsonResponse throws empty-response error on blank input', () => {
+ assert.throws(() => parseJsonResponse(''), /空响应|empty response/);
+ assert.throws(() => parseJsonResponse(' \n\t '), /空响应|empty response/);
+ });
+
+ test('parseJsonResponse parses valid JSON normally', () => {
+ const parsed = parseJsonResponse('{"findings":[]}') as { findings: unknown[] };
+ assert.deepStrictEqual(parsed.findings, []);
+ });
+
+ test('chatWithRetry retries once on EmptyContentError', async () => {
+ const calls: string[] = [];
+ const provider = {
+ chat: async (system: string): Promise => {
+ calls.push(system);
+ if (calls.length === 1) {
+ throw new EmptyContentError('finish_reason=length');
+ }
+ return '{"findings":[]}';
+ },
+ } as unknown as AIProvider;
+
+ const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS);
+ assert.strictEqual(result, '{"findings":[]}');
+ assert.strictEqual(calls.length, 2);
+ });
+
+ test('chatWithRetry propagates error when retry also returns empty', async () => {
+ const provider = {
+ chat: async (): Promise => {
+ throw new EmptyContentError('finish_reason=length');
+ },
+ } as unknown as AIProvider;
+
+ await assert.rejects(
+ () => chatWithRetry(provider, 'sys', 'user', OPTIONS),
+ EmptyContentError
+ );
+ });
+
+ test('chatWithRetry does not retry on non-empty-content errors', async () => {
+ let calls = 0;
+ const provider = {
+ chat: async (): Promise => {
+ calls++;
+ throw new Error('boom');
+ },
+ } as unknown as AIProvider;
+
+ await assert.rejects(() => chatWithRetry(provider, 'sys', 'user', OPTIONS), /boom/);
+ assert.strictEqual(calls, 1);
+ });
+
+ test('openai-compatible reports max_tokens truncation clearly', async () => {
+ const originalFetch = globalThis.fetch;
+ globalThis.fetch = (async () => ({
+ ok: true,
+ json: async () => ({
+ choices: [{ message: { content: null }, finish_reason: 'length' }],
+ }),
+ text: async () => '',
+ })) as unknown as typeof fetch;
+
+ try {
+ const provider = new OpenAICompatibleProvider('key', 'http://localhost', 'test', 'Test');
+ await assert.rejects(
+ () =>
+ provider.chat('sys', 'user', {
+ model: 'm',
+ temperature: 0,
+ maxTokens: 8192,
+ timeoutMs: 5000,
+ }),
+ (err: unknown) => {
+ assert.ok(err instanceof EmptyContentError);
+ assert.match((err as Error).message, /max_tokens/);
+ assert.match((err as Error).message, /8192/);
+ return true;
+ }
+ );
+ } finally {
+ globalThis.fetch = originalFetch;
+ }
+ });
+});
diff --git a/tests/ai-fix-engine.test.ts b/tests/ai-fix-engine.test.ts
new file mode 100644
index 0000000..d93e7e3
--- /dev/null
+++ b/tests/ai-fix-engine.test.ts
@@ -0,0 +1,115 @@
+import * as assert from 'assert';
+import * as vscode from 'vscode';
+import { AIProvider, ChatOptions } from '../src/ai/providers/base';
+import { LinterAdapter, LinterDiagnostic } from '../src/types';
+import { aiFixDiagnostic } from '../src/fix/aiFixEngine';
+
+class MockFixProvider extends AIProvider {
+ id = 'mock';
+ name = 'mock';
+ constructor(private responses: string[]) { super('key', 'url'); }
+ async chat(_system: string, _user: string, _options: ChatOptions): Promise {
+ const next = this.responses.shift();
+ return next ?? '{}';
+ }
+}
+
+class MockAdapter implements LinterAdapter {
+ id = 'mock';
+ supportedLanguages = ['javascript'];
+ isAvailable(): boolean { return true; }
+ async check(document: vscode.TextDocument, _workingDir: string) {
+ const text = document.getText();
+ const diagnostics: LinterDiagnostic[] = [];
+ if (text.includes('BAD')) {
+ diagnostics.push({
+ severity: 'error',
+ ruleId: 'eslint:mock-rule',
+ message: 'bad code',
+ range: new vscode.Range(0, 0, 0, 3),
+ });
+ }
+ return { diagnostics, status: 'ok' as const };
+ }
+}
+
+const options: ChatOptions = { model: 'mock', temperature: 0, maxTokens: 100, timeoutMs: 1000 };
+
+suite('AI FixEngine Tests', () => {
+ test('aiFixDiagnostic applies AI fix and converges', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const x = BAD;\n',
+ language: 'javascript',
+ });
+ const diag: LinterDiagnostic = {
+ severity: 'error',
+ ruleId: 'eslint:mock-rule',
+ message: 'bad code',
+ range: new vscode.Range(0, 8, 0, 11),
+ };
+ const provider = new MockFixProvider(['{"originalText":"BAD","newText":"GOOD"}']);
+ const result = await aiFixDiagnostic(doc, __dirname, new MockAdapter(), diag, 3, provider, options);
+
+ assert.strictEqual(result.success, true);
+ assert.strictEqual(result.appliedFixes.length, 1);
+ assert.strictEqual(result.appliedFixes[0].originalText, 'BAD');
+ assert.strictEqual(result.appliedFixes[0].newText, 'GOOD');
+ });
+
+ test('aiFixDiagnostic returns ai-match-failed when originalText not found', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const x = BAD;\n',
+ language: 'javascript',
+ });
+ const diag: LinterDiagnostic = {
+ severity: 'error',
+ ruleId: 'eslint:mock-rule',
+ message: 'bad code',
+ range: new vscode.Range(0, 8, 0, 11),
+ };
+ const provider = new MockFixProvider(['{"originalText":"NOPE","newText":"GOOD"}']);
+ const result = await aiFixDiagnostic(doc, __dirname, new MockAdapter(), diag, 3, provider, options);
+
+ assert.strictEqual(result.success, false);
+ assert.strictEqual(result.message, 'ai-match-failed');
+ });
+
+ test('aiFixDiagnostic returns ai-no-fix when AI provides empty fix', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const x = BAD;\n',
+ language: 'javascript',
+ });
+ const diag: LinterDiagnostic = {
+ severity: 'error',
+ ruleId: 'eslint:mock-rule',
+ message: 'bad code',
+ range: new vscode.Range(0, 8, 0, 11),
+ };
+ const provider = new MockFixProvider(['{"originalText":"","newText":""}']);
+ const result = await aiFixDiagnostic(doc, __dirname, new MockAdapter(), diag, 3, provider, options);
+
+ assert.strictEqual(result.success, false);
+ assert.strictEqual(result.message, 'ai-no-fix');
+ });
+
+ test('aiFixDiagnostic retries until issue resolved within maxIterations', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'BAD BAD\n',
+ language: 'javascript',
+ });
+ const diag: LinterDiagnostic = {
+ severity: 'error',
+ ruleId: 'eslint:mock-rule',
+ message: 'bad code',
+ range: new vscode.Range(0, 0, 0, 3),
+ };
+ const provider = new MockFixProvider([
+ '{"originalText":"BAD BAD","newText":"BAD GOOD"}',
+ '{"originalText":"BAD","newText":"GOOD"}',
+ ]);
+ const result = await aiFixDiagnostic(doc, __dirname, new MockAdapter(), diag, 3, provider, options);
+
+ assert.strictEqual(result.success, true);
+ assert.strictEqual(result.appliedFixes.length, 2);
+ });
+});
diff --git a/tests/config.test.ts b/tests/config.test.ts
new file mode 100644
index 0000000..f3d4323
--- /dev/null
+++ b/tests/config.test.ts
@@ -0,0 +1,33 @@
+import * as assert from 'assert';
+import { getAIConfig, getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage } from '../src/config/ai';
+import { getLinterForLanguage, getPMDJarPath, getPMDRulesetPath } from '../src/config/linter';
+import { getFixMaxIterations } from '../src/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('getFixMaxIterations returns default', () => {
+ assert.strictEqual(getFixMaxIterations(), 3);
+ });
+});
diff --git a/tests/customFixEngine.test.ts b/tests/customFixEngine.test.ts
new file mode 100644
index 0000000..0bb04fe
--- /dev/null
+++ b/tests/customFixEngine.test.ts
@@ -0,0 +1,100 @@
+import * as assert from 'assert';
+import * as vscode from 'vscode';
+import { AIProvider, ChatOptions } from '../src/ai/providers/base';
+import { aiFixReviewIssue } from '../src/fix/customFixEngine';
+import type { ReviewIssueInput } from '../src/fix/fixPrompt';
+
+class MockReviewFixProvider extends AIProvider {
+ id = 'mock';
+ name = 'mock';
+ constructor(private responses: string[]) { super('key', 'url'); }
+ async chat(_system: string, _user: string, _options: ChatOptions): Promise {
+ const next = this.responses.shift();
+ return next ?? '{}';
+ }
+}
+
+const options: ChatOptions = { model: 'mock', temperature: 0, maxTokens: 100, timeoutMs: 1000 };
+
+function reviewDiag(message = 'bad code', line = 0): ReviewIssueInput {
+ return { ruleId: 'custom:test-rule', line, message, suggestion: 'use GOOD instead' };
+}
+
+suite('Custom FixEngine Tests', () => {
+ test('aiFixReviewIssue applies AI fix and converges after verify', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const x = BAD;\n',
+ language: 'javascript',
+ });
+ const provider = new MockReviewFixProvider([
+ '{"originalText":"BAD","newText":"GOOD"}',
+ '{"fixed":true}',
+ ]);
+ const result = await aiFixReviewIssue(doc, reviewDiag(), 3, provider, options, true);
+
+ assert.strictEqual(result.success, true);
+ assert.strictEqual(result.newText, 'const x = GOOD;\n');
+ assert.strictEqual(result.appliedFixes.length, 1);
+ });
+
+ test('aiFixReviewIssue returns ai-match-failed when originalText not found', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const x = BAD;\n',
+ language: 'javascript',
+ });
+ const provider = new MockReviewFixProvider(['{"originalText":"NOPE","newText":"GOOD"}']);
+ const result = await aiFixReviewIssue(doc, reviewDiag(), 3, provider, options, true);
+
+ assert.strictEqual(result.success, false);
+ assert.strictEqual(result.message, 'ai-match-failed');
+ });
+
+ test('aiFixReviewIssue retries until verify passes within maxIterations', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const x = BAD;\n',
+ language: 'javascript',
+ });
+ const provider = new MockReviewFixProvider([
+ '{"originalText":"BAD","newText":"BAD2"}',
+ '{"fixed":false,"reason":"still bad"}',
+ '{"originalText":"BAD2","newText":"GOOD"}',
+ '{"fixed":true}',
+ ]);
+ const result = await aiFixReviewIssue(doc, reviewDiag(), 3, provider, options, true);
+
+ assert.strictEqual(result.success, true);
+ assert.strictEqual(result.appliedFixes.length, 2);
+ assert.strictEqual(result.newText, 'const x = GOOD;\n');
+ });
+
+ test('aiFixReviewIssue accepts last fix when verify never passes', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const x = BAD;\n',
+ language: 'javascript',
+ });
+ const provider = new MockReviewFixProvider([
+ '{"originalText":"BAD","newText":"GOOD"}',
+ '{"fixed":false,"reason":"still bad"}',
+ ]);
+ const result = await aiFixReviewIssue(doc, reviewDiag(), 1, provider, options, true);
+
+ assert.strictEqual(result.success, true);
+ assert.strictEqual(result.newText, 'const x = GOOD;\n');
+ assert.strictEqual(result.appliedFixes.length, 1);
+ });
+
+ test('aiFixReviewIssue retries empty fix once then fails with ai-no-fix', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const x = BAD;\n',
+ language: 'javascript',
+ });
+ const provider = new MockReviewFixProvider([
+ '{"originalText":"","newText":""}',
+ '{"originalText":"","newText":""}',
+ ]);
+ const result = await aiFixReviewIssue(doc, reviewDiag(), 3, provider, options, true);
+
+ assert.strictEqual(result.success, false);
+ assert.strictEqual(result.message, 'ai-no-fix');
+ });
+});
diff --git a/tests/dedup-prompt.test.ts b/tests/dedup-prompt.test.ts
new file mode 100644
index 0000000..c6f66b5
--- /dev/null
+++ b/tests/dedup-prompt.test.ts
@@ -0,0 +1,43 @@
+import * as assert from 'assert';
+import { buildDedupOnlyPrompt } from '../src/rules/converters/dedup-prompt';
+import { buildKnownRulesSection } from '../src/rules/converters/known-rules';
+import type { CustomRule } from '../src/types';
+
+const customRules: CustomRule[] = [
+ { id: 'no-todo', severity: 'warning', description: '禁止提交 TODO 注释', message: '发现 TODO 注释' },
+];
+
+const singleYaml = [
+ '- id: no-todo',
+ ' severity: warning',
+ ' description: 禁止提交 TODO 注释',
+ ' message: 发现 TODO 注释',
+].join('\n');
+
+suite('Dedup Prompt Tests', () => {
+
+ test('buildDedupOnlyPrompt 同时包含静态规则与自定义规则', () => {
+ const { system, user } = buildDedupOnlyPrompt(singleYaml, customRules);
+
+ assert.ok(system.includes('pmd/AvoidDeeplyNestedIfStmts'), '应包含 PMD 静态规则');
+ assert.ok(system.includes('eslint/no-unused-vars'), '应包含 ESLint 静态规则');
+ assert.ok(system.includes('sqlfluff/AL01'), '应包含 SQLFluff 静态规则');
+ assert.ok(system.includes('custom/no-todo'), '应包含自定义规则');
+ assert.ok(system.includes('duplicateLevel'), '应包含去重字段说明');
+ assert.ok(user.includes('no-todo'), 'user 侧应包含待去重规则');
+ });
+
+ test('无自定义规则时仍包含静态规则', () => {
+ const { system } = buildDedupOnlyPrompt(singleYaml, []);
+ assert.ok(system.includes('pmd/AvoidDeeplyNestedIfStmts'));
+ assert.ok(!system.includes('- custom/'), '无自定义规则时不应出现 custom/ 规则条目');
+ });
+
+ test('buildKnownRulesSection 三语文案齐全', () => {
+ const section = buildKnownRulesSection(customRules);
+ assert.ok(section.startsWith('## '), '应以清单标题开头');
+ assert.ok(section.includes('### pmd'), '应包含 linter 分组标题');
+ assert.ok(section.includes('### 已导入的自定义规则'), '应包含自定义规则分组标题');
+ assert.ok(section.includes('- custom/no-todo: 禁止提交 TODO 注释'), '应包含 custom/ 前缀规则行');
+ });
+});
diff --git a/tests/diagnostics.test.ts b/tests/diagnostics.test.ts
new file mode 100644
index 0000000..e09b45b
--- /dev/null
+++ b/tests/diagnostics.test.ts
@@ -0,0 +1,66 @@
+import * as assert from 'assert';
+import * as vscode from 'vscode';
+import { toVscodeDiagnostics } from '../src/diagnostics/diagnosticMarkers';
+import type { LinterDiagnostic } from '../src/types';
+
+suite('DiagnosticMarkers Tests', () => {
+ test('toVscodeDiagnostics maps severity correctly', () => {
+ const diags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:no-unused', message: 'unused', range: new vscode.Range(0, 0, 0, 5) },
+ { severity: 'warning', ruleId: 'pmd:avoid-duplicate', message: 'duplicate', range: new vscode.Range(1, 0, 1, 3) },
+ { severity: 'info', ruleId: 'custom:no-console', message: 'console', range: new vscode.Range(2, 0, 2, 4) },
+ ];
+
+ const result = toVscodeDiagnostics(diags);
+
+ assert.strictEqual(result[0].severity, vscode.DiagnosticSeverity.Error);
+ assert.strictEqual(result[1].severity, vscode.DiagnosticSeverity.Warning);
+ assert.strictEqual(result[2].severity, vscode.DiagnosticSeverity.Information);
+ });
+
+ test('toVscodeDiagnostics preserves range', () => {
+ const range = new vscode.Range(3, 2, 5, 10);
+ const diags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:no-var', message: 'no var', range },
+ ];
+
+ const result = toVscodeDiagnostics(diags);
+
+ assert.ok(result[0].range.isEqual(range));
+ });
+
+ test('toVscodeDiagnostics prefixes message with plugin and linter', () => {
+ const diags: LinterDiagnostic[] = [
+ { severity: 'warning', ruleId: 'eslint:no-console', message: 'avoid console', range: new vscode.Range(0, 0, 0, 1) },
+ ];
+
+ const result = toVscodeDiagnostics(diags);
+
+ assert.strictEqual(result[0].message, '[Code Purifier · eslint] no-console: avoid console');
+ });
+
+ test('toVscodeDiagnostics handles ruleId without linter prefix', () => {
+ const diags: LinterDiagnostic[] = [
+ { severity: 'warning', ruleId: 'custom-rule', message: 'custom message', range: new vscode.Range(0, 0, 0, 1) },
+ ];
+
+ const result = toVscodeDiagnostics(diags);
+
+ assert.strictEqual(result[0].message, '[Code Purifier] custom-rule: custom message');
+ });
+
+ test('toVscodeDiagnostics sets source and code for quick fix hover', () => {
+ const diags: LinterDiagnostic[] = [
+ { severity: 'warning', ruleId: 'eslint:no-var', message: 'no var', range: new vscode.Range(0, 0, 0, 1) },
+ ];
+
+ const result = toVscodeDiagnostics(diags);
+
+ assert.strictEqual(result[0].source, 'Code Purifier');
+ assert.strictEqual(result[0].code, 'eslint:no-var');
+ });
+
+ test('toVscodeDiagnostics returns empty array for empty input', () => {
+ assert.deepStrictEqual(toVscodeDiagnostics([]), []);
+ });
+});
diff --git a/tests/fixEngine.test.ts b/tests/fixEngine.test.ts
new file mode 100644
index 0000000..5b4bdd2
--- /dev/null
+++ b/tests/fixEngine.test.ts
@@ -0,0 +1,103 @@
+import * as assert from 'assert';
+import * as vscode from 'vscode';
+import { ESLintAdapter } from '../src/adapters/eslint';
+import { fixDiagnostic } from '../src/fix/fixEngine';
+
+suite('FixEngine Tests', () => {
+ test('ESLint adapter attaches fix object for autofixable issues', async () => {
+ const adapter = new ESLintAdapter();
+ if (!adapter.isAvailable()) { return; }
+
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'var name = "Hi " + user;\n',
+ language: 'javascript',
+ });
+
+ const result = await adapter.check(doc, __dirname);
+ assert.strictEqual(result.status, 'ok');
+ const fixable = result.diagnostics.filter(d => d.fix);
+ assert.ok(fixable.length >= 1, 'prefer-template/no-var should be autofixable');
+ const prefer = fixable.find(d => d.ruleId === 'eslint:prefer-template');
+ assert.ok(prefer, 'expected prefer-template diagnostic');
+ assert.ok(prefer!.fix!.range[0] < prefer!.fix!.range[1]);
+ assert.ok(prefer!.fix!.text.length > 0);
+ });
+
+ test('fixDiagnostic multi-round convergence produces applied fixes', async () => {
+ const adapter = new ESLintAdapter();
+ if (!adapter.isAvailable()) { return; }
+
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'var name = "Hi " + user;\n',
+ language: 'javascript',
+ });
+
+ const result = await adapter.check(doc, __dirname);
+ const prefer = result.diagnostics.find(d => d.ruleId === 'eslint:prefer-template' && d.fix);
+ if (!prefer) { return; }
+
+ const fixResult = await fixDiagnostic(doc, __dirname, adapter, prefer, 3);
+ if (fixResult.success) {
+ assert.ok(fixResult.appliedFixes.length >= 1);
+ } else {
+ assert.ok(fixResult.message === 'no-active-editor' || fixResult.message === 'not-autofixable', `unexpected failure: ${fixResult.message}`);
+ }
+ });
+
+ test('fixDiagnostic returns not-autofixable for non-fixable rule', async () => {
+ const adapter = new ESLintAdapter();
+ if (!adapter.isAvailable()) { return; }
+
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'function unused(a, b) { return a; }\n',
+ language: 'javascript',
+ });
+
+ const result = await adapter.check(doc, __dirname);
+ const unused = result.diagnostics.find(d => d.ruleId === 'eslint:no-unused-vars');
+ if (!unused) { return; }
+ assert.ok(!unused.fix, 'no-unused-vars is not autofixable');
+
+ const fixResult = await fixDiagnostic(doc, __dirname, adapter, unused, 3);
+ assert.strictEqual(fixResult.success, false);
+ assert.strictEqual(fixResult.message, 'not-autofixable');
+ });
+
+ test('fixDiagnostic no-change returns no-change', async () => {
+ const adapter = new ESLintAdapter();
+ if (!adapter.isAvailable()) { return; }
+
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'const ok = 1;\n',
+ language: 'javascript',
+ });
+
+ const result = await adapter.check(doc, __dirname);
+ const anyDiag = result.diagnostics.find(d => d.ruleId === 'eslint:no-extra-semi');
+ if (anyDiag) {
+ const fixResult = await fixDiagnostic(doc, __dirname, adapter, anyDiag, 3);
+ assert.ok(fixResult.success === false);
+ }
+ });
+
+ test('fixDiagnostic fixes only the targeted issue, not adjacent same-rule instance', async () => {
+ const adapter = new ESLintAdapter();
+ if (!adapter.isAvailable()) { return; }
+
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'var a = 1;\nvar b = 2;\n',
+ language: 'javascript',
+ });
+
+ const result = await adapter.check(doc, __dirname);
+ const first = result.diagnostics.find(d => d.ruleId === 'eslint:no-var' && d.fix && d.range.start.line === 0);
+ if (!first) { return; }
+
+ const fixResult = await fixDiagnostic(doc, __dirname, adapter, first, 3);
+ if (fixResult.success) {
+ assert.strictEqual(fixResult.appliedFixes.length, 1);
+ } else {
+ assert.ok(fixResult.message === 'not-autofixable' || fixResult.message === 'no-change', `unexpected failure: ${fixResult.message}`);
+ }
+ });
+});
diff --git a/tests/fixSession.test.ts b/tests/fixSession.test.ts
new file mode 100644
index 0000000..45ab61a
--- /dev/null
+++ b/tests/fixSession.test.ts
@@ -0,0 +1,95 @@
+import * as assert from 'assert';
+import * as vscode from 'vscode';
+import { FixSessionManager } from '../src/fix/fixSession';
+
+suite('FixSession Tests', () => {
+ const uri = vscode.Uri.file('C:/fake/project/src/app.js');
+
+ test('add/get/has roundtrip', () => {
+ const session = new FixSessionManager();
+ const key = 'eslint:no-extra-semi@5';
+ session.add(uri, {
+ key,
+ ruleId: 'eslint:no-extra-semi',
+ line: 5,
+ fixes: [{ originalText: 'var x = 1;;', newText: 'var x = 1;', line: 5 }],
+ source: 'linter',
+ });
+ assert.strictEqual(session.has(uri, key), true);
+ const entry = session.get(uri, key);
+ assert.ok(entry);
+ assert.strictEqual(entry!.ruleId, 'eslint:no-extra-semi');
+ assert.strictEqual(entry!.line, 5);
+ assert.strictEqual(entry!.fixes.length, 1);
+ });
+
+ test('get returns undefined for missing key', () => {
+ const session = new FixSessionManager();
+ assert.strictEqual(session.get(uri, 'eslint:x@1'), undefined);
+ assert.strictEqual(session.has(uri, 'eslint:x@1'), false);
+ });
+
+ test('getEntries filters per uri', () => {
+ const session = new FixSessionManager();
+ const other = vscode.Uri.file('C:/fake/project/src/other.js');
+ session.add(uri, { key: 'a@1', ruleId: 'a', line: 1, fixes: [{ originalText: '1', newText: '2', line: 1 }], source: 'linter' });
+ session.add(uri, { key: 'b@2', ruleId: 'b', line: 2, fixes: [{ originalText: '3', newText: '4', line: 2 }], source: 'linter' });
+ session.add(other, { key: 'c@3', ruleId: 'c', line: 3, fixes: [{ originalText: '5', newText: '6', line: 3 }], source: 'linter' });
+
+ const entries = session.getEntries(uri);
+ assert.strictEqual(entries.length, 2);
+ assert.deepStrictEqual(entries.map(e => e.ruleId).sort(), ['a', 'b']);
+ });
+
+ test('clear removes entries for uri only', () => {
+ const session = new FixSessionManager();
+ const other = vscode.Uri.file('C:/fake/project/src/other.js');
+ session.add(uri, { key: 'a@1', ruleId: 'a', line: 1, fixes: [{ originalText: '1', newText: '2', line: 1 }], source: 'linter' });
+ session.add(other, { key: 'c@3', ruleId: 'c', line: 3, fixes: [{ originalText: '5', newText: '6', line: 3 }], source: 'linter' });
+
+ session.clear(uri);
+ assert.strictEqual(session.getEntries(uri).length, 0);
+ assert.strictEqual(session.getEntries(other).length, 1);
+ });
+
+ test('recordFixes merges multi-round fixes under same key', () => {
+ const session = new FixSessionManager();
+ session.recordFixes(uri, 'eslint:no-extra-semi', 5, [
+ { originalText: ';;', newText: ';', line: 5 },
+ ]);
+ session.recordFixes(uri, 'eslint:no-extra-semi', 5, [
+ { originalText: ';', newText: '', line: 5 },
+ ]);
+
+ const entry = session.get(uri, 'eslint:no-extra-semi@5');
+ assert.ok(entry);
+ assert.strictEqual(entry!.fixes.length, 2);
+ });
+
+ test('undo restores original text via workspace edit', async () => {
+ const doc = await vscode.workspace.openTextDocument({
+ content: 'var x = 1;;\nvar y = 2;\n',
+ language: 'javascript',
+ });
+
+ const session = new FixSessionManager();
+ const key = session.recordFixes(doc.uri, 'eslint:no-extra-semi', 0, [
+ { originalText: ';;', newText: ';', line: 0 },
+ ]);
+
+ const edit = new vscode.WorkspaceEdit();
+ edit.replace(doc.uri, new vscode.Range(0, 0, 1, 0), 'var x = 1;\n');
+ const applied = await vscode.workspace.applyEdit(edit);
+ assert.ok(applied);
+
+ const docAfterFix = await vscode.workspace.openTextDocument(doc.uri);
+ assert.strictEqual(docAfterFix.getText(), 'var x = 1;\nvar y = 2;\n');
+
+ const undone = await session.undo(docAfterFix, key);
+ assert.ok(undone);
+
+ const docAfterUndo = await vscode.workspace.openTextDocument(doc.uri);
+ assert.strictEqual(docAfterUndo.getText(), 'var x = 1;;\nvar y = 2;\n');
+ assert.strictEqual(session.has(doc.uri, key), false);
+ });
+});
diff --git a/tests/fixtures/Sample.java b/tests/fixtures/Sample.java
new file mode 100644
index 0000000..05a44f0
--- /dev/null
+++ b/tests/fixtures/Sample.java
@@ -0,0 +1,7 @@
+public class Sample {
+ public void test() {
+ String password = "admin123";
+ System.out.println("debug");
+ System.out.println("debug");
+ }
+}
diff --git a/tests/fixtures/sample.css b/tests/fixtures/sample.css
new file mode 100644
index 0000000..36f0a6e
--- /dev/null
+++ b/tests/fixtures/sample.css
@@ -0,0 +1,2 @@
+.hello { color: black; background: #FFF; }
+#test { margin: 0px; }
diff --git a/tests/fixtures/sample.js b/tests/fixtures/sample.js
new file mode 100644
index 0000000..dd77265
--- /dev/null
+++ b/tests/fixtures/sample.js
@@ -0,0 +1,5 @@
+function test() {
+ var unused = 1;
+ console.log('debug');
+ return "hello world";
+}
diff --git a/tests/import-dedup.test.ts b/tests/import-dedup.test.ts
new file mode 100644
index 0000000..1c5813b
--- /dev/null
+++ b/tests/import-dedup.test.ts
@@ -0,0 +1,368 @@
+import * as assert from 'assert';
+import { buildFinalYaml, parseImportableYaml } from '../src/rules/import-service';
+import type { ImportableRule, PreviewDecision } from '../src/rules/import-types';
+
+function makeRules(data: Array>): 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 = {};
+ for (const rule of rules) {
+ keepRule[rule.id] = rule.duplicateLevel !== 'exact';
+ }
+ return { keepRule, confirmed: true };
+}
+
+function makeYaml(rules: Array<{ id: string; fields: Record }>): 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');
+ });
+
+ test('id 带引号→剥离引号保留纯 id', () => {
+ const yaml = `- id: '123'
+ severity: warning
+ description: final 字段可改为 static
+ message: final 字段可改为 static`;
+ const rules = parseImportableYaml(yaml);
+ assert.strictEqual(rules.length, 1);
+ assert.strictEqual(rules[0].id, '123');
+ });
+
+ test('scalar 字段带引号→剥离引号', () => {
+ const yaml = `- id: "quoted-rule"
+ severity: "error"
+ description: "desc text"
+ message: 'msg text'`;
+ const rules = parseImportableYaml(yaml);
+ assert.strictEqual(rules.length, 1);
+ assert.strictEqual(rules[0].id, 'quoted-rule');
+ assert.strictEqual(rules[0].severity, 'error');
+ assert.strictEqual(rules[0].description, 'desc text');
+ assert.strictEqual(rules[0].message, 'msg text');
+ });
+});
diff --git a/tests/manual/Buggy.java b/tests/manual/Buggy.java
new file mode 100644
index 0000000..33da75a
--- /dev/null
+++ b/tests/manual/Buggy.java
@@ -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);
+ }
+}
diff --git a/tests/manual/buggy.css b/tests/manual/buggy.css
new file mode 100644
index 0000000..6cb7db2
--- /dev/null
+++ b/tests/manual/buggy.css
@@ -0,0 +1,3 @@
+.hello { color: #FFFFFF; background: black; }
+#test { margin: 0px; }
+.foo { font-size: 12px; }
diff --git a/tests/manual/buggy.js b/tests/manual/buggy.js
new file mode 100644
index 0000000..2d2ac18
--- /dev/null
+++ b/tests/manual/buggy.js
@@ -0,0 +1,11 @@
+var x = 1;
+var y = 2;
+var x = 3;
+
+function test() {
+ var unused = 'hello';
+ console.log('debug');
+ return "world";
+}
+
+test();
diff --git a/tests/manual/buggy.jsp b/tests/manual/buggy.jsp
new file mode 100644
index 0000000..68b76b4
--- /dev/null
+++ b/tests/manual/buggy.jsp
@@ -0,0 +1,42 @@
+<%@ page language="java" contentType="text/html" %>
+<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
+
+
+ ${title}
+
+
+
+<%
+ String password = "admin123";
+ String name = "test";
+ int x = 1;
+%>
+
+${message} ${message}
+
+
+ Welcome, ${user.name}
+
+
+
+ ${item}
+
+
+
+
+
+ hello
+
+
+
+
+
diff --git a/tests/manual/buggy.sql b/tests/manual/buggy.sql
new file mode 100644
index 0000000..c7b0bea
--- /dev/null
+++ b/tests/manual/buggy.sql
@@ -0,0 +1,3 @@
+SELECT name FORM users;
+SELECT * FORM products;
+INSERT INTO customers VALUES (1, 'test');
diff --git a/tests/measure/measure-review-time.mjs b/tests/measure/measure-review-time.mjs
new file mode 100644
index 0000000..7899bfa
--- /dev/null
+++ b/tests/measure/measure-review-time.mjs
@@ -0,0 +1,150 @@
+import { ESLint } from 'eslint';
+import js from '@eslint/js';
+import ts from 'typescript-eslint';
+import stylelint from 'stylelint';
+import recommendedConfig from 'stylelint-config-recommended';
+import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const DATA = join(__dirname, '..', '..', 'data');
+const OUT_DIR = join(__dirname, 'results');
+
+// ===== 以下与 src/rules/builtin-rules.ts 逐字一致 =====
+const eslintExtraRules = {
+ 'eqeqeq': 'error', 'no-eq-null': 'error', 'no-self-compare': 'error',
+ 'no-promise-executor-return': 'error', 'no-shadow': 'error', 'no-unassigned-vars': 'error',
+ 'no-useless-assignment': 'error', 'block-scoped-var': 'error', 'default-case': 'error',
+ 'default-case-last': 'error', 'no-unmodified-loop-condition': 'error', 'no-unreachable-loop': 'error',
+ 'no-eval': 'error', 'no-extend-native': 'error', 'no-var': 'error',
+ 'no-await-in-loop': 'warn', 'prefer-template': 'warn', 'prefer-object-spread': 'warn',
+ 'prefer-rest-params': 'warn', 'prefer-spread': 'warn', 'prefer-object-has-own': 'warn',
+ 'no-useless-concat': 'warn', 'no-useless-return': 'warn', 'no-useless-computed-key': 'warn',
+ 'no-useless-rename': 'warn', 'no-param-reassign': 'warn', 'no-return-assign': 'error',
+ 'no-throw-literal': 'error', 'camelcase': 'warn', 'new-cap': 'warn', 'no-array-constructor': 'error',
+};
+const eslintExtraTsRules = {
+ '@typescript-eslint/no-non-null-assertion': 'error',
+ '@typescript-eslint/no-dynamic-delete': 'error',
+ '@typescript-eslint/no-useless-empty-export': 'error',
+ '@typescript-eslint/consistent-type-imports': 'error',
+ '@typescript-eslint/unified-signatures': 'error',
+ '@typescript-eslint/no-extraneous-class': 'warn',
+ '@typescript-eslint/no-useless-constructor': 'warn',
+ '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error',
+ '@typescript-eslint/no-invalid-void-type': 'warn',
+ '@typescript-eslint/prefer-literal-enum-member': 'warn',
+ '@typescript-eslint/prefer-enum-initializers': 'warn',
+ 'no-shadow': 'off',
+ '@typescript-eslint/no-shadow': 'error',
+ 'no-array-constructor': 'off',
+};
+const stylelintExtraRules = {
+ 'color-no-invalid-hex': true,
+ 'function-linear-gradient-no-nonstandard-direction': true,
+ 'function-no-unknown': true,
+ 'unit-no-unknown': true,
+ 'no-unknown-animations': true,
+ 'no-unknown-custom-media': true,
+ 'no-unknown-custom-properties': true,
+ 'at-rule-no-vendor-prefix': true,
+ 'media-feature-name-no-vendor-prefix': true,
+ 'property-no-vendor-prefix': true,
+ 'selector-no-vendor-prefix': true,
+ 'value-no-vendor-prefix': true,
+ 'color-hex-length': 'short',
+ 'color-function-notation': 'modern',
+ 'length-zero-no-unit': true,
+ 'selector-pseudo-element-colon-notation': 'double',
+ 'import-notation': 'string',
+ 'alpha-value-notation': 'number',
+ 'hue-degree-notation': 'angle',
+ 'keyframe-selector-notation': 'percentage',
+};
+
+const TS_FILES = ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts'];
+const JS_FILES = ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'];
+
+function timing(ms) {
+ return `${ms.toFixed(1)}ms`;
+}
+
+async function measure(name, fn) {
+ const start = process.hrtime.bigint();
+ const result = await fn();
+ const end = process.hrtime.bigint();
+ const ms = Number(end - start) / 1e6;
+ return { name, ms, result };
+}
+
+async function main() {
+ if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
+
+ const rows = [];
+ const summary = { tool: 'code-reviewer', measureDate: new Date().toISOString() };
+
+ const eslintConfig = [
+ js.configs.recommended,
+ { files: JS_FILES, languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } },
+ ...ts.configs.recommended.map(cfg => ({ ...cfg, files: cfg.files ?? TS_FILES })),
+ { rules: eslintExtraRules },
+ { files: TS_FILES, rules: eslintExtraTsRules },
+ ];
+
+ const eslintDemo = join(DATA, 'demo-eslint', 'src');
+ const eslintFiles = readdirSync(eslintDemo).filter(f => /\.(js|ts|mjs|cjs)$/.test(f));
+ if (eslintFiles.length > 0) {
+ const engine = new ESLint({ cwd: eslintDemo, overrideConfigFile: true, overrideConfig: eslintConfig });
+ const t = measure('eslint static analysis (demo-eslint/src)', async () => {
+ const results = [];
+ for (const f of eslintFiles) {
+ const text = readFileSync(join(eslintDemo, f), 'utf8');
+ const [res] = await engine.lintText(text, { filePath: join(eslintDemo, f) });
+ results.push({ file: f, messages: res.messages.length });
+ }
+ return results;
+ });
+ const { ms, result } = await t;
+ rows.push({ target: 'demo-eslint', files: eslintFiles.length, ms, diagnostics: result.reduce((s, r) => s + r.messages, 0) });
+ }
+
+ const stylelintDemo = join(DATA, 'demo-stylelint', 'src');
+ const stylelintFiles = readdirSync(stylelintDemo).filter(f => /\.css$/.test(f));
+ if (stylelintFiles.length > 0) {
+ const styleConfig = {
+ ...recommendedConfig,
+ rules: { ...recommendedConfig.rules, ...stylelintExtraRules },
+ };
+ const t = measure('stylelint static analysis (demo-stylelint/src)', async () => {
+ const results = [];
+ for (const f of stylelintFiles) {
+ const code = readFileSync(join(stylelintDemo, f), 'utf8');
+ const res = await stylelint.lint({ code, codeFilename: join(stylelintDemo, f), config: styleConfig, cwd: stylelintDemo });
+ const warnings = res.results.flatMap(r => r.warnings).length;
+ results.push({ file: f, warnings });
+ }
+ return results;
+ });
+ const { ms, result } = await t;
+ rows.push({ target: 'demo-stylelint', files: stylelintFiles.length, ms, diagnostics: result.reduce((s, r) => s + r.warnings, 0) });
+ }
+
+ const totalMs = rows.reduce((s, r) => s + r.ms, 0);
+ summary.rows = rows;
+ summary.totalMs = totalMs;
+
+ writeFileSync(join(OUT_DIR, 'measure-results.json'), JSON.stringify(summary, null, 2));
+
+ console.log('=== Code Purifier 静态分析耗时测量 ===');
+ for (const r of rows) {
+ console.log(`${r.target}: ${r.files} 文件, ${r.diagnostics} 条诊断, ${timing(r.ms)}`);
+ }
+ console.log(`合计: ${timing(totalMs)}`);
+ console.log(`结果已写入 tests/measure/results/measure-results.json`);
+}
+
+main().catch(err => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/tests/measure/performance-comparison.md b/tests/measure/performance-comparison.md
new file mode 100644
index 0000000..0274b72
--- /dev/null
+++ b/tests/measure/performance-comparison.md
@@ -0,0 +1,45 @@
+# 提效对比报告(基线 vs 提效后)
+
+## 一、测量方法
+
+**场景**:对给定样例代码进行代码规范检查,比较两种方式的耗时与产出。
+
+- **基线(人工审查)**:由人工阅读样例代码并列出规范问题(耗时依赖个人经验,需实测采集)。
+- **提效后(Code Purifier 插件静态分析链路)**:使用与插件内置配置逐字一致的 ESLint / Stylelint 引擎对 `data/demo-*/src/*` 执行静态分析,计时(可复现:`node tests/measure/measure-review-time.mjs`)。
+
+> 测量脚本只产插件侧耗时;**人工基线需实测后手动填入下方表格**,原始记录需保留(时间、审查人、发现数)。
+
+## 二、对比维度
+
+| 维度 | 基线(人工审查) | 提效后(插件静态分析) |
+|---|---|---|
+| 审查耗时 | 待实测填入 | 见下表(脚本产出) |
+| 发现规范问题数 | 待实测填入 | 脚本产出诊断数 |
+| 覆盖率口径 | 依赖个人经验 | 内置配置规则全覆盖(demo 实测 100%) |
+| 可重复性 | 低(因人而异) | 高(同配置逐字可复现) |
+
+## 三、插件侧实测数据(脚本产出)
+
+> 运行:`node tests/measure/measure-review-time.mjs`,结果另存 `tests/measure/results/measure-results.json`。
+> 实测日期:2026-08-26,Node.js v24.16.0。
+
+| 目标 | 文件数 | 诊断数 | 耗时 |
+|---|---|---|---|
+| demo-eslint | 6 | 294 | 192.2ms |
+| demo-stylelint | 2 | 115 | 2106.7ms |
+| **合计** | **8** | **409** | **2298.9ms** |
+
+## 四、人工基线(待实测)
+
+> 请按以下步骤实测后填写,并保留原始记录:
+> 1. 取 `data/demo-eslint/src/common.js`(约 12KB)为样例;
+> 2. 审查人逐行阅读,列出发现的规范问题(命名/风格/潜在 bug 等),记录耗时;
+> 3. 多人重复(建议 ≥3 人)取平均,填入下表。
+
+| 审查人 | 样例 | 耗时 | 发现问题数 | 日期 |
+|---|---|---|---|---|
+| _待填_ | common.js | _待填_ | _待填_ | _待填_ |
+
+## 五、提效结论(待数据完备后填写)
+
+_待人工基线数据填入后,据此计算提效幅度并总结结论。_
diff --git a/tests/measure/results/measure-results.json b/tests/measure/results/measure-results.json
new file mode 100644
index 0000000..88437c4
--- /dev/null
+++ b/tests/measure/results/measure-results.json
@@ -0,0 +1,19 @@
+{
+ "tool": "code-reviewer",
+ "measureDate": "2026-08-26T11:16:33.653Z",
+ "rows": [
+ {
+ "target": "demo-eslint",
+ "files": 6,
+ "ms": 192.1965,
+ "diagnostics": 294
+ },
+ {
+ "target": "demo-stylelint",
+ "files": 2,
+ "ms": 2106.6858,
+ "diagnostics": 115
+ }
+ ],
+ "totalMs": 2298.8823
+}
\ No newline at end of file
diff --git a/tests/merger.test.ts b/tests/merger.test.ts
new file mode 100644
index 0000000..5fa5ce4
--- /dev/null
+++ b/tests/merger.test.ts
@@ -0,0 +1,334 @@
+import * as assert from 'assert';
+import { mergeResults, MergedReport } from '../src/merger/merger';
+import { CustomRuleResult, AIFinding } from '../src/ai/schema';
+import { LinterDiagnostic } from '../src/types';
+
+function range(line: number): any {
+ return new (require('vscode').Range)(line, 0, line, 1);
+}
+
+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);
+ });
+
+ test('mergeResults sorts diagnostics by severity then line', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'info', ruleId: 'eslint:info-5', message: 'i5', range: range(5) },
+ { severity: 'error', ruleId: 'eslint:err-10', message: 'e10', range: range(10) },
+ { severity: 'warning', ruleId: 'eslint:warn-3', message: 'w3', range: range(3) },
+ { severity: 'error', ruleId: 'eslint:err-1', message: 'e1', range: range(1) },
+ ];
+ const customResults: CustomRuleResult[] = [
+ { ruleId: 'custom:i-9', line: 9, severity: 'info', message: 'i9' },
+ { ruleId: 'custom:e-2', line: 2, severity: 'error', message: 'e2' },
+ ];
+ const aiFindings: AIFinding[] = [
+ { ruleId: 'ai:w-7', severity: 'warning', category: 'style', title: 'w7', description: 'w7', line: 7, suggestion: '' },
+ { ruleId: 'ai:e-4', severity: 'error', category: 'bug', title: 'e4', description: 'e4', line: 4, suggestion: '' },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: customResults,
+ translatedDiagnostics: [],
+ aiFindings,
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.js',
+ language: 'javascript',
+ adapterIds: ['eslint'],
+ });
+
+ assert.deepStrictEqual(
+ report.linterDiagnostics.map(d => `${d.severity}:${d.range.start.line}`),
+ ['error:1', 'error:10', 'warning:3', 'info:5']
+ );
+ assert.deepStrictEqual(
+ report.customRuleDiagnostics.map(d => `${d.severity}:${d.range.start.line}`),
+ ['error:1', 'info:8']
+ );
+ assert.deepStrictEqual(
+ report.aiFindings.map(f => `${f.severity}:${f.line}`),
+ ['error:3', 'warning:6']
+ );
+ assert.deepStrictEqual(report.fixableLinterIndices, []);
+ assert.deepStrictEqual(report.fixableCustomIndices, []);
+ });
+
+ test('mergeResults marks fixable only diagnostics with fix object', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:fixable-1', message: 'f1', range: range(1), fix: { range: [0, 5], text: 'x' } },
+ { severity: 'warning', ruleId: 'eslint:nofix-2', message: 'n2', range: range(2) },
+ { severity: 'error', ruleId: 'eslint:fixable-3', message: 'f3', range: range(3), fix: { range: [6, 9], text: 'y' } },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: [],
+ translatedDiagnostics: [],
+ aiFindings: [],
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.js',
+ language: 'javascript',
+ adapterIds: ['eslint'],
+ });
+
+ const sorted = report.linterDiagnostics;
+ const fixableIndices = sorted.map((_, i) => (sorted[i].fix ? i : -1)).filter(i => i !== -1);
+ assert.deepStrictEqual(report.fixableLinterIndices, fixableIndices);
+ assert.strictEqual(report.fixableCustomIndices.length, 0);
+ });
+
+ test('mergeResults converts aiFindings line to 0-based', () => {
+ const aiFindings: AIFinding[] = [
+ { ruleId: 'ai:first', severity: 'warning', category: 'style', title: 'first', description: 'd', line: 1, suggestion: '' },
+ { ruleId: 'ai:last', severity: 'info', category: 'design', title: 'last', description: 'd', line: 8, suggestion: '' },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: [],
+ customRuleResults: [],
+ translatedDiagnostics: [],
+ aiFindings,
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.js',
+ language: 'javascript',
+ adapterIds: ['eslint'],
+ });
+
+ assert.deepStrictEqual(
+ report.aiFindings.map(f => f.line),
+ [0, 7]
+ );
+ });
+
+ test('mergeResults pairs translations by originalRuleId not index', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:no-unused', message: 'x is unused', range: range(1) },
+ { severity: 'warning', ruleId: 'eslint:no-console', message: 'console call', range: range(2) },
+ { severity: 'error', ruleId: 'eslint:no-eval', message: 'eval used', range: range(3) },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: [],
+ translatedDiagnostics: [
+ { originalRuleId: 'eslint:no-eval', translatedMessage: '评估使用', translatedSuggestion: '避免' },
+ { originalRuleId: 'eslint:no-unused', translatedMessage: '未使用', translatedSuggestion: '删除' },
+ ],
+ aiFindings: [],
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.js',
+ language: 'javascript',
+ adapterIds: ['eslint'],
+ });
+
+ assert.strictEqual(report.linterDiagnostics[0].message, '未使用');
+ assert.strictEqual(report.linterDiagnostics[0].suggestion, '删除');
+ assert.strictEqual(report.linterDiagnostics[1].message, '评估使用');
+ assert.strictEqual(report.linterDiagnostics[2].message, 'console call');
+ });
+
+ test('mergeResults handles duplicate ruleId translations in order', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:no-console', message: 'first console', range: range(1) },
+ { severity: 'error', ruleId: 'eslint:no-console', message: 'second console', range: range(2) },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: [],
+ translatedDiagnostics: [
+ { originalRuleId: 'eslint:no-console', translatedMessage: '首次', translatedSuggestion: '' },
+ { originalRuleId: 'eslint:no-console', translatedMessage: '二次', translatedSuggestion: '' },
+ ],
+ aiFindings: [],
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.js',
+ language: 'javascript',
+ adapterIds: ['eslint'],
+ });
+
+ assert.strictEqual(report.linterDiagnostics[0].message, '首次');
+ assert.strictEqual(report.linterDiagnostics[1].message, '二次');
+ });
+
+ test('mergeResults matches translations by normalized ruleId when prefix missing', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:no-var', message: 'Unexpected var', range: range(1) },
+ { severity: 'warning', ruleId: 'eslint:no-console', message: 'console call', range: range(2) },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: [],
+ translatedDiagnostics: [
+ { originalRuleId: 'no-var', translatedMessage: '应使用 let/const', translatedSuggestion: '改写成 let x = 1' },
+ { originalRuleId: 'no-console', translatedMessage: '避免 console', translatedSuggestion: '' },
+ ],
+ aiFindings: [],
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.js',
+ language: 'javascript',
+ adapterIds: ['eslint'],
+ });
+
+ assert.strictEqual(report.linterDiagnostics[0].message, '应使用 let/const');
+ assert.strictEqual(report.linterDiagnostics[0].suggestion, '改写成 let x = 1');
+ assert.strictEqual(report.linterDiagnostics[1].message, '避免 console');
+ });
+
+ test('mergeResults matches translations by normalized ruleId with slash variant', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:@typescript-eslint/no-unused-vars', message: 'x unused', range: range(1) },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: [],
+ translatedDiagnostics: [
+ { originalRuleId: '@typescript-eslint/no-unused-vars', translatedMessage: '未使用变量', translatedSuggestion: '删除 x' },
+ ],
+ aiFindings: [],
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.ts',
+ language: 'typescript',
+ adapterIds: ['eslint'],
+ });
+
+ assert.strictEqual(report.linterDiagnostics[0].message, '未使用变量');
+ assert.strictEqual(report.linterDiagnostics[0].suggestion, '删除 x');
+ });
+
+ test('mergeResults prefers exact ruleId match over normalized', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:no-shadow', message: 'shadow', range: range(1) },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: [],
+ translatedDiagnostics: [
+ { originalRuleId: 'no-shadow', translatedMessage: '归一化命中', translatedSuggestion: '' },
+ { originalRuleId: 'eslint:no-shadow', translatedMessage: '精确命中', translatedSuggestion: '' },
+ ],
+ aiFindings: [],
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.js',
+ language: 'javascript',
+ adapterIds: ['eslint'],
+ });
+
+ assert.strictEqual(report.linterDiagnostics[0].message, '精确命中');
+ });
+
+ test('mergeResults computes aiFixableLinterIndices excluding native fix and sqlfluff', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:no-var', message: 'v', range: range(1), fix: { range: [0, 3], text: 'let' } },
+ { severity: 'error', ruleId: 'eslint:no-undef', message: 'u', range: range(2) },
+ { severity: 'error', ruleId: 'sqlfluff:AL01', message: 's', range: range(3) },
+ { severity: 'error', ruleId: 'pmd:UnusedLocalVariable', message: 'p', range: range(4) },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: [],
+ translatedDiagnostics: [],
+ aiFindings: [],
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/a.java',
+ language: 'java',
+ adapterIds: ['pmd'],
+ aiFixAvailable: true,
+ });
+
+ assert.strictEqual(report.aiFixAvailable, true);
+ assert.deepStrictEqual(report.fixableLinterIndices, [0]);
+ assert.deepStrictEqual(report.aiFixableLinterIndices, [1, 3]);
+ });
+
+ test('mergeResults aiFixableLinterIndices empty when AI unavailable', () => {
+ const staticDiags: LinterDiagnostic[] = [
+ { severity: 'error', ruleId: 'eslint:no-undef', message: 'u', range: range(2) },
+ ];
+
+ const report = mergeResults({
+ staticDiagnostics: staticDiags,
+ customRuleResults: [],
+ translatedDiagnostics: [],
+ aiFindings: [],
+ errors: [],
+ degraded: false,
+ startTime: Date.now(),
+ filePath: '/test/sample.js',
+ language: 'javascript',
+ adapterIds: ['eslint'],
+ });
+
+ assert.deepStrictEqual(report.aiFixableLinterIndices, []);
+ assert.strictEqual(report.aiFixAvailable, false);
+ });
+});
diff --git a/tests/messages.test.ts b/tests/messages.test.ts
new file mode 100644
index 0000000..238885d
--- /dev/null
+++ b/tests/messages.test.ts
@@ -0,0 +1,72 @@
+import * as assert from 'assert';
+import { t, setLanguage, getLanguage, getMessageKeys, Language } from '../src/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');
+ });
+});
diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts
new file mode 100644
index 0000000..869db60
--- /dev/null
+++ b/tests/pipeline.test.ts
@@ -0,0 +1,37 @@
+import * as assert from 'assert';
+import * as vscode from 'vscode';
+import * as path from 'path';
+import { ESLintAdapter } from '../src/adapters/eslint';
+import { mergeResults } from '../src/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');
+ });
+});
diff --git a/tests/rule-filter.test.ts b/tests/rule-filter.test.ts
new file mode 100644
index 0000000..9024cce
--- /dev/null
+++ b/tests/rule-filter.test.ts
@@ -0,0 +1,145 @@
+import * as assert from 'assert';
+import { filterForDocument, filterAndSummarize } from '../src/rules/rule-filter';
+import type { CustomRule } from '../src/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);
+ });
+});
diff --git a/tests/sqlfluff-prs.test.ts b/tests/sqlfluff-prs.test.ts
new file mode 100644
index 0000000..36c00a6
--- /dev/null
+++ b/tests/sqlfluff-prs.test.ts
@@ -0,0 +1,36 @@
+import * as assert from 'assert';
+import { buildPRSMessage } from '../src/adapters/sqlfluff';
+
+suite('SqlFluff PRS Tests', () => {
+ test('buildPRSMessage extracts fragment from description', () => {
+ const desc = "Line 7, Position 3: Found unparsable section: 'engine=innodb default charset=utf8mb4;'";
+ const msg = buildPRSMessage(desc, 'oracle');
+
+ assert.ok(msg.includes('oracle'));
+ assert.ok(msg.includes("engine=innodb default charset=utf8mb4;"));
+ });
+
+ test('buildPRSMessage flattens newlines in fragment', () => {
+ const desc = "Found unparsable section: 'create table orders (\n id int\n);'";
+ const msg = buildPRSMessage(desc, 'mysql');
+
+ assert.ok(msg.includes('\\n'));
+ assert.ok(!msg.includes('\n'));
+ });
+
+ test('buildPRSMessage truncates long fragments', () => {
+ const longFragment = 'x'.repeat(500);
+ const desc = `Found unparsable section: '${longFragment}'`;
+ const msg = buildPRSMessage(desc, 'ansi');
+
+ assert.ok(msg.includes('...'));
+ assert.ok(!msg.includes(longFragment));
+ });
+
+ test('buildPRSMessage falls back to description when pattern missing', () => {
+ const desc = 'unexpected parse error output';
+ const msg = buildPRSMessage(desc, 'ansi');
+
+ assert.ok(msg.includes(desc));
+ });
+});
diff --git a/tests/sqlfluff-range.test.ts b/tests/sqlfluff-range.test.ts
new file mode 100644
index 0000000..652a730
--- /dev/null
+++ b/tests/sqlfluff-range.test.ts
@@ -0,0 +1,81 @@
+import * as assert from 'assert';
+import { resolveSqlFluffRange } from '../src/adapters/sqlfluff';
+
+suite('SqlFluff Range Tests', () => {
+ function assertValid([sl, sp, el, ep]: [number, number, number, number]): void {
+ assert.ok(Number.isFinite(sl), 'startLine must be finite');
+ assert.ok(Number.isFinite(sp), 'startPos must be finite');
+ assert.ok(Number.isFinite(el), 'endLine must be finite');
+ assert.ok(Number.isFinite(ep), 'endPos must be finite');
+ assert.ok(sl >= 0, 'startLine must be >= 0');
+ assert.ok(sp >= 0, 'startPos must be >= 0');
+ assert.ok(el >= sl, 'endLine must be >= startLine');
+ }
+
+ test('full positions convert to zero-based range', () => {
+ const r = resolveSqlFluffRange({
+ start_line_no: 2,
+ start_line_pos: 4,
+ end_line_no: 2,
+ end_line_pos: 9,
+ code: 'JJ01',
+ description: 'x',
+ });
+ assert.deepStrictEqual(r, [1, 3, 1, 8]);
+ assertValid(r);
+ });
+
+ test('missing end positions fall back to start (comment JJ01 case)', () => {
+ const r = resolveSqlFluffRange({
+ start_line_no: 2,
+ start_line_pos: 1,
+ code: 'JJ01',
+ description: 'x',
+ });
+ assert.deepStrictEqual(r, [1, 0, 1, 0]);
+ assertValid(r);
+ });
+
+ test('legacy line_no/line_pos keys are honored', () => {
+ const r = resolveSqlFluffRange({
+ line_no: 3,
+ line_pos: 5,
+ code: 'LT12',
+ description: 'x',
+ });
+ assert.deepStrictEqual(r, [2, 4, 2, 4]);
+ assertValid(r);
+ });
+
+ test('null positions fall back to defaults', () => {
+ const r = resolveSqlFluffRange({
+ start_line_no: null,
+ start_line_pos: null,
+ end_line_no: null,
+ end_line_pos: null,
+ code: 'TMP',
+ description: 'x',
+ });
+ assert.deepStrictEqual(r, [0, 0, 0, 0]);
+ assertValid(r);
+ });
+
+ test('zero positions are clamped to valid values', () => {
+ const r = resolveSqlFluffRange({
+ start_line_no: 1,
+ start_line_pos: 0,
+ end_line_no: 1,
+ end_line_pos: 0,
+ code: 'TMP',
+ description: 'x',
+ });
+ assert.deepStrictEqual(r, [0, 0, 0, 0]);
+ assertValid(r);
+ });
+
+ test('missing all positions produce a safe zero range', () => {
+ const r = resolveSqlFluffRange({ code: 'PRS', description: 'x' });
+ assert.deepStrictEqual(r, [0, 0, 0, 0]);
+ assertValid(r);
+ });
+});
diff --git a/tests/test-cases.md b/tests/test-cases.md
new file mode 100644
index 0000000..5660911
--- /dev/null
+++ b/tests/test-cases.md
@@ -0,0 +1,221 @@
+# 测试用例清单
+
+来源:`tests/*.test.ts` 中 `suite()` / `test()` 声明。共 **16 个测试文件、116 条用例**。
+
+## 1. adapter.test.ts — Adapter Tests(6)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 1 | ESLintAdapter has correct id and languages | 适配器标识与支持语言 |
+| 2 | StylelintAdapter has correct id and languages | 适配器标识与支持语言 |
+| 3 | ESLintAdapter check returns AdapterResult structure | 静态分析返回结构 |
+| 4 | ESLintAdapter surfaces octal literal as eslint:parse-error | 八进制字面量解析错误上报 |
+| 5 | ESLintAdapter surfaces \8 escape as eslint:parse-error | 非法转义解析错误上报 |
+| 6 | ESLintAdapter parses JSX in .js without parse error | JSX 语法解析 |
+
+## 2. ai-empty-response.test.ts — AI Empty Response Handling(6)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 7 | parseJsonResponse throws empty-response error on blank input | 空响应识别 |
+| 8 | parseJsonResponse parses valid JSON normally | 正常 JSON 解析 |
+| 9 | chatWithRetry retries once on EmptyContentError | 空内容自动重试 |
+| 10 | chatWithRetry propagates error when retry also returns empty | 重试仍空则抛错 |
+| 11 | chatWithRetry does not retry on non-empty-content errors | 非空内容错误不重试 |
+| 12 | openai-compatible reports max_tokens truncation clearly | token 截断提示 |
+
+## 3. ai-fix-engine.test.ts — AI FixEngine Tests(4)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 13 | aiFixDiagnostic applies AI fix and converges | AI 修复收敛 |
+| 14 | aiFixDiagnostic returns ai-match-failed when originalText not found | 原文未匹配 |
+| 15 | aiFixDiagnostic returns ai-no-fix when AI provides empty fix | AI 无修复 |
+| 16 | aiFixDiagnostic retries until issue resolved within maxIterations | 迭代上限内收敛 |
+
+## 4. config.test.ts — Config Tests(4)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 17 | getAIConfig returns default values | AI 配置默认值 |
+| 18 | AI individual getters return defaults | 单项配置 getter |
+| 19 | getLinterForLanguage returns configured linter | 语言→linter 映射 |
+| 20 | getFixMaxIterations returns default | 修复迭代默认值 |
+
+## 5. customFixEngine.test.ts — Custom FixEngine Tests(5)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 21 | aiFixReviewIssue applies AI fix and converges after verify | 自定义规则 AI 修复收敛 |
+| 22 | aiFixReviewIssue returns ai-match-failed when originalText not found | 原文未匹配 |
+| 23 | aiFixReviewIssue retries until verify passes within maxIterations | 验证驱动重试 |
+| 24 | aiFixReviewIssue accepts last fix when verify never passes | 验证不过时接受最后修复 |
+| 25 | aiFixReviewIssue retries empty fix once then fails with ai-no-fix | 空修复重试一次后失败 |
+
+## 6. dedup-prompt.test.ts — Dedup Prompt Tests(3)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 26 | buildDedupOnlyPrompt 同时提供静态规则与自定义规则 | 去重 prompt 组装 |
+| 27 | 自定义规则时包含静态规则 | 去重 prompt 规则合并 |
+| 28 | buildKnownRulesSection 生成的规则包含全部 | 已知规则段完整性 |
+
+## 7. diagnostics.test.ts — DiagnosticMarkers Tests(6)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 29 | toVscodeDiagnostics maps severity correctly | 严重级别映射 |
+| 30 | toVscodeDiagnostics preserves range | 范围保留 |
+| 31 | toVscodeDiagnostics prefixes message with plugin and linter | 消息前缀 |
+| 32 | toVscodeDiagnostics handles ruleId without linter prefix | 无前缀 ruleId |
+| 33 | toVscodeDiagnostics sets source and code for quick fix hover | hover 修复来源/代码 |
+| 34 | toVscodeDiagnostics returns empty array for empty input | 空输入 |
+
+## 8. fixEngine.test.ts — FixEngine Tests(5)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 35 | ESLint adapter attaches fix object for autofixable issues | 可自动修复诊断挂 fix |
+| 36 | fixDiagnostic multi-round convergence produces applied fixes | 多轮修复收敛 |
+| 37 | fixDiagnostic returns not-autofixable for non-fixable rule | 不可修复规则 |
+| 38 | fixDiagnostic no-change returns no-change | 无变化场景 |
+| 39 | fixDiagnostic fixes only the targeted issue, not adjacent same-rule instance | 定点修复不误伤相邻同类 |
+
+## 9. fixSession.test.ts — FixSession Tests(6)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 40 | add/get/has roundtrip | 会话读写 |
+| 41 | get returns undefined for missing key | 缺失键 |
+| 42 | getEntries filters per uri | 按 uri 过滤 |
+| 43 | clear removes entries for uri only | 按 uri 清理 |
+| 44 | recordFixes merges multi-round fixes under same key | 多轮修复合并 |
+| 45 | undo restores original text via workspace edit | 撤销恢复原文 |
+
+## 10. import-dedup.test.ts — Import Dedup Tests(25)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 46 | 重复规则:全局检查并标注 | 全局重复检查 |
+| 47 | 全局 exact:全部标注 | exact 去重 |
+| 48 | 全局 overlap:全部检查并标注 | overlap 去重 |
+| 49 | 跨规则 exact 标注(overlap 归 none) | 跨规则 exact 归属 |
+| 50 | 用户指定 exact 后取消标注 | 用户决策 exact |
+| 51 | 用户标注 overlap 将标注加 # 前缀 | overlap 标注头 |
+| 52 | 用户标注 none 加 # 前缀 | none 标注头 |
+| 53 | 已标注 duplicateLevel/duplicateOf/duplicateReason 剔出标注 | 已标注剔除 |
+| 54 | 标注规则 duplicateReason 标注头回填原由 | 原因回填 |
+| 55 | 编辑 description 确认 YAML 使用该值 | description 编辑 |
+| 56 | 编辑 severity 确认 YAML 使用该 severity | severity 编辑 |
+| 57 | 编辑 languages 确认 YAML 有 languages | languages 编辑 |
+| 58 | 编辑切换为标注、标注转为编辑值 | 编辑/标注互转 |
+| 59 | 无编辑时保留原始 yamlContent | 原始内容保留 |
+| 60 | severity 缺失默认为 warning | severity 默认 |
+| 61 | severity 非法值默认为 warning | severity 兜底 |
+| 62 | id 缺失生成 rule-N | id 自动命名 |
+| 63 | 多个 id 缺失依次 rule-1, rule-2... | 递增命名 |
+| 64 | description 缺失时使用 message(中文存在) | 双语回退 |
+| 65 | message 缺失时使用 description(中文存在) | 双语回退 |
+| 66 | description 与 message 同时缺失时兜底 | 全缺失兜底 |
+| 67 | 重复规则的重复校验回归 | 去重回归 |
+| 68 | id 带引号、字段带引号保留 id | 引号 id |
+| 69 | scalar 字段带引号、保留引号 | 引号 scalar |
+
+## 11. merger.test.ts — Merger Tests(12)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 70 | mergeResults counts correctly | 计数正确 |
+| 71 | mergeResults marks degraded when AI fails | AI 失败降级 |
+| 72 | mergeResults sorts diagnostics by severity then line | 排序 |
+| 73 | mergeResults marks fixable only diagnostics with fix object | 可修复标记 |
+| 74 | mergeResults converts aiFindings line to 0-based | 行号 0 基化 |
+| 75 | mergeResults pairs translations by originalRuleId not index | 按规则 id 配对翻译 |
+| 76 | mergeResults handles duplicate ruleId translations in order | 重复 id 有序 |
+| 77 | mergeResults matches translations by normalized ruleId when prefix missing | 缺前缀归一化匹配 |
+| 78 | mergeResults matches translations by normalized ruleId with slash variant | 斜杠变体匹配 |
+| 79 | mergeResults prefers exact ruleId match over normalized | 精确匹配优先 |
+| 80 | mergeResults computes aiFixableLinterIndices excluding native fix and sqlfluff | AI 可修复索引 |
+| 81 | mergeResults aiFixableLinterIndices empty when AI unavailable | AI 不可用置空 |
+
+## 12. messages.test.ts — I18n Tests(9)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 82 | all message keys have values for all three languages | 三语 key 完整 |
+| 83 | default language is zh-CN | 默认语言 |
+| 84 | setLanguage changes current language | 语言切换 |
+| 85 | t() falls back to key when key does not exist | 缺失 key 回退 |
+| 86 | t() returns zh-CN string in zh-CN language | 中文翻译 |
+| 87 | t() returns English string in en language | 英文翻译 |
+| 88 | t() returns Japanese string in ja language | 日文翻译 |
+| 89 | t() with template variables | 模板变量 |
+| 90 | t() with multiple template variables | 多模板变量 |
+
+## 13. pipeline.test.ts — Pipeline Tests(1)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 91 | Full pipeline: linter check + merge | 全链路(linter + 合并) |
+
+## 14. rule-filter.test.ts — Rule Filter Tests(15)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 92 | 规则匹配当前语言 | 语言匹配 |
+| 93 | 规则未匹配被过滤 | 不匹配过滤 |
+| 94 | 规则为空:全部保留 | 空规则集 |
+| 95 | 规则缺失:全部保留 | 缺失过滤 |
+| 96 | 规则按语言匹配后去重 | 去重 |
+| 97 | 规则未匹配语言则去除 | 语言不匹配去除 |
+| 98 | 规则为空时去重 | 空集去重 |
+| 99 | 规则 + 语言映射匹配时过滤结果优先 | 优先级 |
+| 100 | typescriptreact 同源映射到 typescript | TSX 映射 |
+| 101 | plsql 同源映射到 sql | PL/SQL 映射 |
+| 102 | JSP 映射 java 规则 | JSP→Java |
+| 103 | JSP 不映射 css 规则 | JSP→CSS 排除 |
+| 104 | 普通 HTML 触发 JSP 过滤 java 规则 | HTML→JSP |
+| 105 | 全部过滤触发 skippedRequestA=true | 全过滤标记 |
+| 106 | 部分过滤触发 skippedRequestA=false | 部分过滤标记 |
+
+## 15. sqlfluff-prs.test.ts — SqlFluff PRS Tests(4)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 107 | buildPRSMessage extracts fragment from description | PRS 片段提取 |
+| 108 | buildPRSMessage flattens newlines in fragment | 换行压平 |
+| 109 | buildPRSMessage truncates long fragments | 长片段截断 |
+| 110 | buildPRSMessage falls back to description when pattern missing | 无匹配回退 |
+
+## 16. sqlfluff-range.test.ts — SqlFluff Range Tests(6)
+
+| # | 用例 | 覆盖点 |
+|---|---|---|
+| 111 | full positions convert to zero-based range | 完整位置 0 基化 |
+| 112 | missing end positions fall back to start (comment JJ01 case) | 缺终点回退(JJ01) |
+| 113 | legacy line_no/line_pos keys are honored | 旧版 key 兼容 |
+| 114 | null positions fall back to defaults | null 兜底 |
+| 115 | zero positions are clamped to valid values | 零值钳制 |
+| 116 | missing all positions produce a safe zero range | 全缺失安全范围 |
+
+## 统计
+
+| 文件 | 用例数 |
+|---|---|
+| adapter.test.ts | 6 |
+| ai-empty-response.test.ts | 6 |
+| ai-fix-engine.test.ts | 4 |
+| config.test.ts | 4 |
+| customFixEngine.test.ts | 5 |
+| dedup-prompt.test.ts | 3 |
+| diagnostics.test.ts | 6 |
+| fixEngine.test.ts | 5 |
+| fixSession.test.ts | 6 |
+| import-dedup.test.ts | 25 |
+| merger.test.ts | 12 |
+| messages.test.ts | 9 |
+| pipeline.test.ts | 1 |
+| rule-filter.test.ts | 15 |
+| sqlfluff-prs.test.ts | 4 |
+| sqlfluff-range.test.ts | 6 |
+| **合计** | **116** |
diff --git a/tests/test-execution-log.md b/tests/test-execution-log.md
new file mode 100644
index 0000000..3b5027a
--- /dev/null
+++ b/tests/test-execution-log.md
@@ -0,0 +1,42 @@
+# 测试执行日志
+
+## 一、执行信息
+
+| 项目 | 内容 |
+|---|---|
+| 执行日期 | 2026-08-26 |
+| 测试命令 | `npm test` |
+| 前置链路 | `lint 0 error → compile 通过 → compile:test 通过` |
+| 测试运行器 | `@vscode/test-cli`(.vscode-test.mjs:`out/tests/**/*.test.js`) |
+| 插件版本 | 1.3.0 |
+| 测试文件数 | 16 个 `*.test.ts` |
+
+## 二、执行结果摘要
+
+| 指标 | 结果 |
+|---|---|
+| 通过(passing) | **116** |
+| 失败(failing) | 0 |
+| 跳过(pending) | 0 |
+| 运行时长 | ~5s(Extension Host 退出码 0) |
+
+> 详细逐条用例清单见 `tests/test-cases.md`。
+
+## 三、运行环境
+
+| 项目 | 内容 |
+|---|---|
+| 测试宿主 | VSCode Extension Test Host(@vscode/test-electron) |
+| 操作系统 | Windows(win32) |
+| Node.js | 插件 devDependencies 锁定版本(@types/node 22.x) |
+| 编译 | `tsc -p ./` + `node scripts/copy-webview-js.mjs`(主工程);`tsc -p ./tsconfig.test.json`(测试工程) |
+| Lint | `eslint src`:0 error(仅 `src/utils/mockDocument.ts` 2 处既有 curly warning) |
+
+## 四、复现方式
+
+```bash
+npm run lint # eslint src → 0 error
+npm run compile # tsc 主工程
+npm run compile:test # tsc 测试工程(out/tests)
+npm test # @vscode/test-cli 运行 out/tests/**/*.test.js
+```
diff --git a/tsconfig.test.json b/tsconfig.test.json
new file mode 100644
index 0000000..781fe98
--- /dev/null
+++ b/tsconfig.test.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "module": "Node16",
+ "target": "ES2022",
+ "outDir": "out",
+ "lib": [
+ "ES2022"
+ ],
+ "sourceMap": true,
+ "rootDir": ".",
+ "strict": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true
+ },
+ "include": [
+ "tests/**/*",
+ "src/types/**/*"
+ ],
+ "exclude": [
+ "node_modules",
+ "out"
+ ]
+}
diff --git a/vscode-code-reviewer-1.0.0.vsix b/vscode-code-reviewer-1.0.0.vsix
deleted file mode 100644
index 5031533..0000000
Binary files a/vscode-code-reviewer-1.0.0.vsix and /dev/null differ