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:
@@ -6,4 +6,3 @@ dist/
|
||||
.env
|
||||
*.log
|
||||
.superpowers/
|
||||
src/test/
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from '@vscode/test-cli';
|
||||
|
||||
export default defineConfig({
|
||||
files: 'out/test/**/*.test.js',
|
||||
files: 'out/tests/**/*.test.js',
|
||||
});
|
||||
|
||||
@@ -171,3 +171,7 @@ Webview 资源在编译/打包时自动复制到 `out/webview/`,随插件加
|
||||
- **波浪线标记**:`vscode-code-reviewer.markers.enabled`。
|
||||
|
||||
详细配置项可在 VS Code 设置页搜索 `vscode-code-reviewer` 查看。
|
||||
|
||||
## 演示视频
|
||||
|
||||
演示视频(`docs/demo.mp4`,≤5 分钟:完整工作流 + IDE 集成效果 + 异常处理)**进行中**,将在后续提交中补齐。
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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/ 前缀规则行');
|
||||
});
|
||||
});
|
||||
@@ -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([]), []);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
public class Sample {
|
||||
public void test() {
|
||||
String password = "admin123";
|
||||
System.out.println("debug");
|
||||
System.out.println("debug");
|
||||
}
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
.hello { color: black; background: #FFF; }
|
||||
#test { margin: 0px; }
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
function test() {
|
||||
var unused = 1;
|
||||
console.log('debug');
|
||||
return "hello world";
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
.hello { color: #FFFFFF; background: black; }
|
||||
#test { margin: 0px; }
|
||||
.foo { font-size: 12px; }
|
||||
@@ -0,0 +1,11 @@
|
||||
var x = 1;
|
||||
var y = 2;
|
||||
var x = 3;
|
||||
|
||||
function test() {
|
||||
var unused = 'hello';
|
||||
console.log('debug');
|
||||
return "world";
|
||||
}
|
||||
|
||||
test();
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
SELECT name FORM users;
|
||||
SELECT * FORM products;
|
||||
INSERT INTO customers VALUES (1, 'test');
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 | _待填_ | _待填_ | _待填_ |
|
||||
|
||||
## 五、提效结论(待数据完备后填写)
|
||||
|
||||
_待人工基线数据填入后,据此计算提效幅度并总结结论。_
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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** |
|
||||
@@ -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
|
||||
```
|
||||
@@ -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.
Reference in New Issue
Block a user