test: 目录对齐赛道二成果物规范(tests/ 迁移+实验报告+提效测量脚本)

- src/test 迁移至根目录 tests/(16 测试 + fixtures/manual),导入改为 ../src/
- 新增 tsconfig.test.json 独立编译测试;.vscode-test.mjs / package.json / .gitignore 同步
- 新增 tests/test-execution-log.md、tests/test-cases.md(116 用例清单)
- 新增 tests/measure/measure-review-time.mjs + performance-comparison.md(提效测量)
- 重命名 3 个中文报告文件为 demo-*-coverage-report.md
- git rm --cached 解除 vsix 跟踪;README 标注演示视频进行中
This commit is contained in:
范智鹏
2026-08-26 19:17:44 +08:00
parent 313ae24346
commit e9762bfbe1
39 changed files with 2406 additions and 3 deletions
-1
View File
@@ -6,4 +6,3 @@ dist/
.env
*.log
.superpowers/
src/test/
+1 -1
View File
@@ -1,5 +1,5 @@
import { defineConfig } from '@vscode/test-cli';
export default defineConfig({
files: 'out/test/**/*.test.js',
files: 'out/tests/**/*.test.js',
});
+4
View File
@@ -171,3 +171,7 @@ Webview 资源在编译/打包时自动复制到 `out/webview/`,随插件加
- **波浪线标记**`vscode-code-reviewer.markers.enabled`。
详细配置项可在 VS Code 设置页搜索 `vscode-code-reviewer` 查看。
## 演示视频
演示视频(`docs/demo.mp4`,≤5 分钟:完整工作流 + IDE 集成效果 + 异常处理)**进行中**,将在后续提交中补齐。
+2
View File
@@ -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 keystart_line_pos=0 钳制 ≥1),check() 改用其构造 RangeSqlFluffViolation 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 schemaline_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→269src/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.jsonrootDir "."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.mdgit 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 异步 fnresult 为 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 |
+2 -1
View File
@@ -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",
+80
View File
@@ -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 = () => <div>hi</div>;\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'
);
});
});
+100
View File
@@ -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<string> => {
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<string> => {
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<string> => {
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;
}
});
});
+115
View File
@@ -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<string> {
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);
});
});
+33
View File
@@ -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);
});
});
+100
View File
@@ -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<string> {
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');
});
});
+43
View File
@@ -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/ 前缀规则行');
});
});
+66
View File
@@ -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([]), []);
});
});
+103
View File
@@ -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}`);
}
});
});
+95
View File
@@ -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);
});
});
+7
View File
@@ -0,0 +1,7 @@
public class Sample {
public void test() {
String password = "admin123";
System.out.println("debug");
System.out.println("debug");
}
}
+2
View File
@@ -0,0 +1,2 @@
.hello { color: black; background: #FFF; }
#test { margin: 0px; }
+5
View File
@@ -0,0 +1,5 @@
function test() {
var unused = 1;
console.log('debug');
return "hello world";
}
+368
View File
@@ -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<Partial<ImportableRule>>): ImportableRule[] {
return data.map(d => ({
id: d.id ?? 'test-rule',
severity: d.severity ?? 'warning',
description: d.description ?? 'test description',
message: d.message ?? 'test message',
languages: d.languages,
excludeLanguages: d.excludeLanguages,
duplicateOf: d.duplicateOf,
duplicateLevel: d.duplicateLevel,
duplicateReason: d.duplicateReason,
}));
}
function defaultDecision(rules: ImportableRule[]): PreviewDecision {
const keepRule: Record<string, boolean> = {};
for (const rule of rules) {
keepRule[rule.id] = rule.duplicateLevel !== 'exact';
}
return { keepRule, confirmed: true };
}
function makeYaml(rules: Array<{ id: string; fields: Record<string, string> }>): string {
return rules.map(r => {
const lines = [`- id: ${r.id}`];
for (const [key, value] of Object.entries(r.fields)) {
lines.push(` ${key}: ${value}`);
}
return lines.join('\n');
}).join('\n\n') + '\n';
}
suite('Import Dedup Tests', () => {
test('无重复规则→全部保留,无注释行', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateLevel: 'none' },
{ id: 'rule-b' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateLevel: 'none' } },
{ id: 'rule-b', fields: { severity: 'info', description: 'desc b', message: 'msg b' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.includes('# [DUPLICATE'), 'Should have no duplicate headers');
assert.ok(result.includes('id: rule-a'));
assert.ok(result.includes('id: rule-b'));
assert.ok(!result.includes('duplicateLevel:'), 'duplicateLevel should be stripped');
});
test('全部 exact→全部注释', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
{ id: 'rule-b', duplicateOf: 'eslint/no-debugger', duplicateLevel: 'exact' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
{ id: 'rule-b', fields: { severity: 'error', description: 'desc b', message: 'msg b', duplicateOf: 'eslint/no-debugger', duplicateLevel: 'exact' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.match(/^[^#]*- id:/m), 'No non-commented rule lines');
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(result.includes('# 如需启用'));
});
test('全部 overlap→全部保留,无注释行', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求 logger' },
{ id: 'rule-b', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap', duplicateReason: '更窄范围' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求 logger' } },
{ id: 'rule-b', fields: { severity: 'warning', description: 'desc b', message: 'msg b', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap', duplicateReason: '更窄范围' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.includes('# [DUPLICATE'), 'Overlap rules should not be commented by default');
assert.ok(result.includes('id: rule-a'));
assert.ok(result.includes('id: rule-b'));
});
test('混合三档→exact注释,overlap和none保留', () => {
const rules = makeRules([
{ id: 'exact-rule', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
{ id: 'overlap-rule', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap' },
{ id: 'none-rule', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'exact-rule', fields: { severity: 'warning', description: 'd1', message: 'm1', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
{ id: 'overlap-rule', fields: { severity: 'warning', description: 'd2', message: 'm2', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap' } },
{ id: 'none-rule', fields: { severity: 'info', description: 'd3', message: 'm3', duplicateLevel: 'none' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(!result.match(/^-\s+id:\s+exact-rule/m), 'exact rule should be commented');
assert.ok(result.match(/^-\s+id:\s+overlap-rule/m), 'overlap rule should be active');
assert.ok(result.match(/^-\s+id:\s+none-rule/m), 'none rule should be active');
});
test('用户恢复 exact 规则→取消注释', () => {
const rules = makeRules([
{ id: 'restored', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
]);
const yaml = makeYaml([
{ id: 'restored', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
]);
const decision: PreviewDecision = { keepRule: { restored: true }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.includes('[DUPLICATE'), 'Restored rule should have no duplicate annotation');
assert.ok(result.match(/^-\s+id:\s+restored/m), 'Restored rule should be active');
});
test('用户注释 overlap 规则→加 # 前缀和注释头', () => {
const rules = makeRules([
{ id: 'commented', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求' },
]);
const yaml = makeYaml([
{ id: 'commented', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求' } },
]);
const decision: PreviewDecision = { keepRule: { commented: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('[DUPLICATE: overlap]'));
assert.ok(result.includes('重叠原因:额外要求'));
assert.ok(!result.match(/^-\s+id:\s+commented/m), 'Commented rule should have # prefix');
});
test('用户注释 none 规则→加 # 前缀', () => {
const rules = makeRules([
{ id: 'comment-none', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'comment-none', fields: { severity: 'warning', description: 'd', message: 'm', duplicateLevel: 'none' } },
]);
const decision: PreviewDecision = { keepRule: { 'comment-none': false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.match(/^-\s+id:\s+comment-none/m), 'Should be commented');
assert.ok(result.includes('# - id: comment-none'));
});
test('保留规则→duplicateLevel/duplicateOf/duplicateReason 行被移除', () => {
const rules = makeRules([
{ id: 'kept', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: 'reason' },
]);
const yaml = makeYaml([
{ id: 'kept', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: 'reason' } },
]);
const decision: PreviewDecision = { keepRule: { kept: true }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.includes('duplicateOf:'));
assert.ok(!result.includes('duplicateLevel:'));
assert.ok(!result.includes('duplicateReason:'));
assert.ok(result.includes('id: kept'));
});
test('注释规则含 duplicateReason→注释头包含重叠原因', () => {
const rules = makeRules([
{ id: 'r1', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '检测目标相同但额外要求 logger' },
]);
const yaml = makeYaml([
{ id: 'r1', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '检测目标相同但额外要求 logger' } },
]);
const decision: PreviewDecision = { keepRule: { r1: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('重叠原因:检测目标相同但额外要求 logger'));
});
test('编辑 description 后确认→YAML 使用新值', () => {
const rules = makeRules([
{ id: 'edit-desc', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-desc', fields: { severity: 'warning', description: '旧描述', message: '旧消息' } },
]);
const editedRules = makeRules([
{ id: 'edit-desc', severity: 'warning', description: '新描述', message: '新消息' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-desc': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('description: 新描述'));
assert.ok(!result.includes('旧描述'));
assert.ok(result.includes('message: 新消息'));
});
test('编辑 severity 后确认→YAML 使用新 severity', () => {
const rules = makeRules([
{ id: 'edit-sev', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-sev', fields: { severity: 'warning', description: 'd', message: 'm' } },
]);
const editedRules = makeRules([
{ id: 'edit-sev', severity: 'error', description: 'd', message: 'm' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-sev': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('severity: error'));
assert.ok(!result.includes('severity: warning'));
});
test('编辑 languages 后确认→YAML 含新 languages', () => {
const rules = makeRules([
{ id: 'edit-lang', duplicateLevel: 'none', languages: ['java'] },
]);
const yaml = makeYaml([
{ id: 'edit-lang', fields: { severity: 'warning', description: 'd', message: 'm', languages: '[java]' } },
]);
const editedRules = makeRules([
{ id: 'edit-lang', severity: 'warning', description: 'd', message: 'm', languages: ['javascript', 'typescript'] },
]);
const decision: PreviewDecision = { keepRule: { 'edit-lang': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('languages: [javascript, typescript]'));
assert.ok(!result.includes('languages: [java]'), 'old java language should be gone');
assert.ok(!result.includes(' [java]'), 'standalone java tag should be gone');
});
test('编辑后切换为注释→注释内容为编辑后的值', () => {
const rules = makeRules([
{ id: 'edit-comment', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-comment', fields: { severity: 'warning', description: '原描述', message: '原消息' } },
]);
const editedRules = makeRules([
{ id: 'edit-comment', severity: 'error', description: '新描述', message: '新消息' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-comment': false }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('# severity: error'), 'should have commented severity: error');
assert.ok(result.includes('# description: 新描述'));
assert.ok(result.includes('# message: 新消息'));
assert.ok(result.includes('[手动注释]'));
});
test('无编辑场景→回退到原始 yamlContent 处理', () => {
const rules = makeRules([
{ id: 'fallback', duplicateLevel: 'exact', duplicateOf: 'eslint/no-console' },
]);
const yaml = makeYaml([
{ id: 'fallback', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
]);
const decision: PreviewDecision = { keepRule: { fallback: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(!result.match(/^-\s+id:\s+fallback/m));
assert.ok(result.includes('duplicateOf:'));
});
});
suite('parseImportableYaml Fallback Tests', () => {
test('severity 缺失→降级为 warning', () => {
const yaml = `- id: test-rule
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].severity, 'warning');
});
test('severity 非法值→降级为 warning', () => {
const yaml = `- id: test-rule
severity: critical
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].severity, 'warning');
});
test('id 缺失→生成 rule-N', () => {
const yaml = `- severity: warning
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].id, 'rule-1');
});
test('多条 id 缺失→rule-1, rule-2...', () => {
const yaml = `- severity: warning
description: desc a
message: msg a
- severity: info
description: desc b
message: msg b`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 2);
assert.strictEqual(rules[0].id, 'rule-1');
assert.strictEqual(rules[1].id, 'rule-2');
});
test('description 缺失、message 存在→互填', () => {
const yaml = `- id: test-rule
severity: error
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].description, 'test msg');
assert.strictEqual(rules[0].message, 'test msg');
});
test('message 缺失、description 存在→互填', () => {
const yaml = `- id: test-rule
severity: error
description: test desc`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].description, 'test desc');
assert.strictEqual(rules[0].message, 'test desc');
});
test('description 与 message 同时缺失→丢弃', () => {
const yaml = `- id: test-rule
severity: error
- id: test-rule2
severity: warning
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].id, 'test-rule2');
});
test('正常完整输入→无回归', () => {
const yaml = `- id: no-console-log
severity: error
description: 禁止使用 console.log
message: 请使用 logger
- id: no-unused-vars
severity: warning
description: 禁止未使用变量
message: 删除或注释未使用变量
duplicateOf: eslint/no-unused-vars
duplicateLevel: exact`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 2);
assert.strictEqual(rules[0].severity, 'error');
assert.strictEqual(rules[0].description, '禁止使用 console.log');
assert.strictEqual(rules[1].duplicateLevel, 'exact');
});
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');
});
});
+16
View File
@@ -0,0 +1,16 @@
public class Buggy {
public void test() {
String password = "admin123";
String name = "test";
int x = 1;
int y = 2;
int z = x + y;
System.out.println("debug");
System.out.println("done");
}
public void duplicate() {
String password = "secret";
System.out.println(password);
}
}
+3
View File
@@ -0,0 +1,3 @@
.hello { color: #FFFFFF; background: black; }
#test { margin: 0px; }
.foo { font-size: 12px; }
+11
View File
@@ -0,0 +1,11 @@
var x = 1;
var y = 2;
var x = 3;
function test() {
var unused = 'hello';
console.log('debug');
return "world";
}
test();
+42
View File
@@ -0,0 +1,42 @@
<%@ page language="java" contentType="text/html" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<html>
<head>
<title>${title}</title>
<style>
.btn { color: #fff; background: blue; }
.btn { font-size: 14px; }
body { color: #fff; }
body { margin: 0; }
</style>
</head>
<body>
<%
String password = "admin123";
String name = "test";
int x = 1;
%>
<p>${message} ${message}</p>
<c:if test="${not empty user}">
<p>Welcome, ${user.name}</p>
</c:if>
<c:forEach items="${list}" var="item">
<span>${item}</span>
</c:forEach>
<c:out value="${xssInput}" />
<c:forEach begin="1" end="5">
<span>hello</span>
</c:forEach>
<script>
var msg = "hello";
var msg = "world";
console.log(msg);
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
SELECT name FORM users;
SELECT * FORM products;
INSERT INTO customers VALUES (1, 'test');
+150
View File
@@ -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);
});
+45
View File
@@ -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-26Node.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 | _待填_ | _待填_ | _待填_ |
## 五、提效结论(待数据完备后填写)
_待人工基线数据填入后,据此计算提效幅度并总结结论。_
@@ -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
}
+334
View File
@@ -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);
});
});
+72
View File
@@ -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');
});
});
+37
View File
@@ -0,0 +1,37 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
import * as path from 'path';
import { ESLintAdapter } from '../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');
});
});
+145
View File
@@ -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);
});
});
+36
View File
@@ -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));
});
});
+81
View File
@@ -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);
});
});
+221
View File
@@ -0,0 +1,221 @@
# 测试用例清单
来源:`tests/*.test.ts``suite()` / `test()` 声明。共 **16 个测试文件、116 条用例**
## 1. adapter.test.ts — Adapter Tests6
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Handling6
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests4
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests4
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests5
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests3
| # | 用例 | 覆盖点 |
|---|---|---|
| 26 | buildDedupOnlyPrompt 同时提供静态规则与自定义规则 | 去重 prompt 组装 |
| 27 | 自定义规则时包含静态规则 | 去重 prompt 规则合并 |
| 28 | buildKnownRulesSection 生成的规则包含全部 | 已知规则段完整性 |
## 7. diagnostics.test.ts — DiagnosticMarkers Tests6
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests5
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests6
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests25
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests12
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests9
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests1
| # | 用例 | 覆盖点 |
|---|---|---|
| 91 | Full pipeline: linter check + merge | 全链路(linter + 合并) |
## 14. rule-filter.test.ts — Rule Filter Tests15
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests4
| # | 用例 | 覆盖点 |
|---|---|---|
| 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 Tests6
| # | 用例 | 覆盖点 |
|---|---|---|
| 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** |
+42
View File
@@ -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 |
| 运行时长 | ~5sExtension Host 退出码 0 |
> 详细逐条用例清单见 `tests/test-cases.md`
## 三、运行环境
| 项目 | 内容 |
|---|---|
| 测试宿主 | VSCode Extension Test Host@vscode/test-electron |
| 操作系统 | Windowswin32 |
| 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
```
+23
View File
@@ -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"
]
}
Binary file not shown.