diff --git a/.gitignore b/.gitignore index 64dcab0..e8d56e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,3 @@ dist/ .env *.log .superpowers/ -src/test/ diff --git a/.vscode-test.mjs b/.vscode-test.mjs index b62ba25..2eace2e 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,5 +1,5 @@ import { defineConfig } from '@vscode/test-cli'; export default defineConfig({ - files: 'out/test/**/*.test.js', + files: 'out/tests/**/*.test.js', }); diff --git a/README.md b/README.md index 8d48ffb..4e23750 100644 --- a/README.md +++ b/README.md @@ -171,3 +171,7 @@ Webview 资源在编译/打包时自动复制到 `out/webview/`,随插件加 - **波浪线标记**:`vscode-code-reviewer.markers.enabled`。 详细配置项可在 VS Code 设置页搜索 `vscode-code-reviewer` 查看。 + +## 演示视频 + +演示视频(`docs/demo.mp4`,≤5 分钟:完整工作流 + IDE 集成效果 + 异常处理)**进行中**,将在后续提交中补齐。 diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 9aaa26f..fbd9bb5 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -226,3 +226,5 @@ | 2026-08-25 19:13 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | diff 预览改为在侧边新建编辑器组打开(不覆盖当前正在查看的文件):fixPreview.ts openPreviewDiff 的 vscode.diff 调用补第 4 参数 { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },4 个触发点(fixIssue/fixAll × linter/custom-AI)均走此函数全覆盖;closePreviewEditor 遍历所有 tabGroups 与分组位置无关无需改动。lint 0 error(仅既有 mockDocument.ts 2 warning)/ compile 通过 / npm test 111 passing | 澄清阶段「新建编辑器组/侧边标签页」vs「全新 VSCode 窗口实例」两分支,用户选前者(全新窗口扩展 API 不支持且 diff 内容在内存中不可行);preserveFocus 加否为方案决策点,用户确认加(打开 diff 后焦点留在审查面板) | src/fix/fixPreview.ts | deepseek-v4-flash | | 2026-08-25 21:35 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 sqlfluff 行号显示 LNaN:根因是 sqlfluff 4.x 对「jinja 标签位于注释内」的 JJ01 违规 JSON 缺失 end_line_no/end_line_pos,适配器 `v.end_line_no-1`=NaN → VSCode Range 构造时 start.isBefore(end) 对 NaN 恒 false 触发 start/end 交换 → range.start.line=NaN → 面板 L${line+1} 渲染 LNaN。修复:adapters/sqlfluff.ts 新增纯函数 resolveSqlFluffRange(缺失/null/NaN/非法值兜底:start 回退 1、end 回退 start;兼容旧版 line_no/line_pos key;start_line_pos=0 钳制 ≥1),check() 改用其构造 Range;SqlFluffViolation position 字段改可选;webview.ts buildIssueItem 与 utils/report.ts formatLine 加 Number.isFinite 防御(非法行号渲染 L?);新增 src/test/sqlfluff-range.test.ts 6 用例。验证:lint 0 error(仅既有 mockDocument.ts 2 warning)/ compile 通过 / npm test 111 passing | 中间产物:①根因排查多轮——先后排除旧版 sqlfluff schema(line_no key)与「注释场景缺 end 字段只坏终点不坏起点」假设,最终结合 VSCode extHostTypes/range.ts 源码确认 NaN 使 isBefore 恒 false 触发 start/end 交换,链路闭合;②sanitizePosition 初版参数类型 number|undefined,测试用例传 null 触发 TS 类型错误,接口与函数签名扩为 number|null|undefined | src/adapters/sqlfluff.ts src/panel/webview.ts src/utils/report.ts src/test/sqlfluff-range.test.ts(新建) | deepseek-v4-flash | | 2026-08-25 21:54 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 从插件内置 PMD 配置移除 5 条实际不可触发的规则(AvoidAssertAsIdentifier/AvoidEnumAsIdentifier 语言版本上限 1.3/1.4、AccessorClassGeneration/AccessorMethodGeneration 上限 Java 10、LoosePackageCoupling 需显式 packages 配置),使内置配置=全部可触发,demo-pmd 覆盖率基线对齐 269+12=281 可达成 100%。改动:jars/pmd/pmd-java-ruleset.xml 三个分类 exclude 各追加(bestpractices 2 + design 1 + errorprone 2)并将 description 计数 274→269;src/rules/static-rules.json linterVersion.pmd 改 269、移除 5 条规则条目(pmd 295→290);scripts/translations/pmd-1.mjs/pmd-2.mjs 同步删除 5 条翻译种子项。验证:PMD 实际跑内置 ruleset 无加载异常 / lint 0 error / compile 通过 / npm test 117 passing | 中间产物:static-rules.json 第一处编辑误保留 AccessorClassGeneration 块(只删了 AccessorMethodGeneration),复查 JSON 计数发现后补删 | jars/pmd/pmd-java-ruleset.xml src/rules/static-rules.json scripts/translations/pmd-1.mjs scripts/translations/pmd-2.mjs | deepseek-v4-flash | +| 2026-08-26 19:05 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 仓库目录对齐赛道二 §8 成果物规范:分支 vscode-code-reviewer 合并入 main(冲突文件 docs/superpowers/specs/2026-07-10-code-reviewer-design.md 取开发分支完整版,add/add 冲突已解决);src/test 迁移至根目录 tests/(16 个测试 + fixtures/manual),36 处 import '../' → '../src/';新建 tsconfig.test.json(rootDir ".",include tests + src/types 声明文件);.vscode-test.mjs files 改 'out/tests/**/*.test.js';package.json 新增 compile:test、test 改为 compile:test && vscode-test;.gitignore 移除 src/test/;3 个中文文件名 demo-*插件实测覆盖率报告.md 重命名为 demo-*-coverage-report.md;git rm --cached 解除 vscode-code-reviewer-1.0.0.vsix 跟踪;README 追加演示视频(进行中)章节。验证:lint 0 error / compile 通过 / compile:test 通过 / npm test 116 passing | 中间产物:tsconfig.test.json 初版 include 仅 tests/**/*,编译报 stylelint-config-recommended 声明缺失(主 tsconfig include src/**/* 含 src/types 声明文件而 test 配置未含),补 include src/types/**/* 后通过 | tests/ tests/fixtures/ tests/manual/ tsconfig.test.json(新建) .vscode-test.mjs package.json .gitignore README.md data/demo-eslint/reports/demo-eslint-coverage-report.md data/demo-sqlfluff/reports/demo-sqlfluff-coverage-report.md data/demo-stylelint/reports/demo-stylelint-coverage-report.md | deepseek-v4-flash | +| 2026-08-26 19:16 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 补齐 tests/ 实验报告(§8 成果物 04,coverage/ 按用户确认忽略):新建 tests/test-execution-log.md(测试执行日志:命令/环境/结果 116 passing 0 failing);tests/test-cases.md(从 16 个测试文件提取 suite/test 声明,116 条用例清单+覆盖点+统计);tests/measure/measure-review-time.mjs(提效测量脚本:内联复刻 src/rules/builtin-rules.ts 的 ESLint/Stylelint 内置配置,用 eslint/stylelint 引擎直跑 data/demo-*/src 样例计时,输出 tests/measure/results/measure-results.json);tests/measure/performance-comparison.md(提效对比骨架:测量方法+对比维度+插件侧实测数据已填 demo-eslint 6 文件 294 诊断 192.2ms / demo-stylelint 2 文件 115 诊断 2106.7ms / 合计 409 诊断 2298.9ms,人工基线留待实测)。验证:measure 脚本实测运行产出 JSON / lint 0 error(仅既有 mockDocument 2 warning)/ 主流程 npm test 116 passing 不受影响 | 中间产物:measure 脚本初版 import '../../src/rules/builtin-rules'(ESM 无法直接加载 TS 报 ERR_MODULE_NOT_FOUND)→ 改 import 编译产物 '../../out/rules/builtin-rules.js' 又因模块顶层 import vscode 脱离扩展宿主报 MODULE_NOT_FOUND → 最终改为内联规则配置(与 data/demo-eslint/run-coverage.mjs 既有模式一致);measure() 计时函数初版未 await 异步 fn,result 为 Promise 导致 result.reduce 报错,改为 async/await 后通过 | tests/test-execution-log.md(新建) tests/test-cases.md(新建) tests/measure/measure-review-time.mjs(新建) tests/measure/performance-comparison.md(新建) tests/measure/results/measure-results.json(新建) | deepseek-v4-flash | diff --git a/data/demo-eslint/reports/demo-eslint插件实测覆盖率报告.md b/data/demo-eslint/reports/demo-eslint-coverage-report.md similarity index 100% rename from data/demo-eslint/reports/demo-eslint插件实测覆盖率报告.md rename to data/demo-eslint/reports/demo-eslint-coverage-report.md diff --git a/data/demo-sqlfluff/reports/demo-sqlfluff插件实测覆盖率报告.md b/data/demo-sqlfluff/reports/demo-sqlfluff-coverage-report.md similarity index 100% rename from data/demo-sqlfluff/reports/demo-sqlfluff插件实测覆盖率报告.md rename to data/demo-sqlfluff/reports/demo-sqlfluff-coverage-report.md diff --git a/data/demo-stylelint/reports/demo-stylelint插件实测覆盖率报告.md b/data/demo-stylelint/reports/demo-stylelint-coverage-report.md similarity index 100% rename from data/demo-stylelint/reports/demo-stylelint插件实测覆盖率报告.md rename to data/demo-stylelint/reports/demo-stylelint-coverage-report.md diff --git a/package.json b/package.json index 85898f8..fc97337 100644 --- a/package.json +++ b/package.json @@ -335,7 +335,8 @@ "package-prod": "node scripts/package-prod.mjs", "pretest": "npm run compile && npm run lint", "lint": "eslint src", - "test": "vscode-test" + "compile:test": "tsc -p ./tsconfig.test.json", + "test": "npm run compile:test && vscode-test" }, "dependencies": { "@eslint/js": "^9.39.3", diff --git a/tests/adapter.test.ts b/tests/adapter.test.ts new file mode 100644 index 0000000..2b79598 --- /dev/null +++ b/tests/adapter.test.ts @@ -0,0 +1,80 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { ESLintAdapter } from '../src/adapters/eslint'; +import { StylelintAdapter } from '../src/adapters/stylelint'; + +suite('Adapter Tests', () => { + test('ESLintAdapter has correct id and languages', () => { + const adapter = new ESLintAdapter(); + assert.strictEqual(adapter.id, 'eslint'); + assert.deepStrictEqual(adapter.supportedLanguages, ['javascript', 'typescript']); + }); + + test('StylelintAdapter has correct id and languages', () => { + const adapter = new StylelintAdapter(); + assert.strictEqual(adapter.id, 'stylelint'); + assert.deepStrictEqual(adapter.supportedLanguages, ['css']); + }); + + test('ESLintAdapter check returns AdapterResult structure', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = 1;\nconsole.log(x);\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + assert.ok(result.status === 'ok' || result.status === 'tool-unavailable'); + assert.ok(Array.isArray(result.diagnostics)); + }); + + test('ESLintAdapter surfaces octal literal as eslint:parse-error', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'var a = 010;\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + assert.strictEqual(result.status, 'ok'); + const parseErrors = result.diagnostics.filter(d => d.ruleId === 'eslint:parse-error'); + assert.ok(parseErrors.length >= 1, 'expected a parse-error diagnostic for octal literal'); + assert.strictEqual(parseErrors[0].severity, 'error'); + }); + + test('ESLintAdapter surfaces \\8 escape as eslint:parse-error', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'var b = "\\8";\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + assert.strictEqual(result.status, 'ok'); + const parseErrors = result.diagnostics.filter(d => d.ruleId === 'eslint:parse-error'); + assert.ok(parseErrors.length >= 1, 'expected a parse-error diagnostic for \\8 escape'); + }); + + test('ESLintAdapter parses JSX in .js without parse error', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'const App = () =>
hi
;\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + assert.strictEqual(result.status, 'ok'); + assert.ok( + !result.diagnostics.some(d => d.ruleId === 'eslint:parse-error'), + 'JSX in .js should not produce a parse error' + ); + }); +}); diff --git a/tests/ai-empty-response.test.ts b/tests/ai-empty-response.test.ts new file mode 100644 index 0000000..e954f74 --- /dev/null +++ b/tests/ai-empty-response.test.ts @@ -0,0 +1,100 @@ +import * as assert from 'assert'; +import { parseJsonResponse, chatWithRetry } from '../src/ai/engine'; +import { EmptyContentError } from '../src/ai/providers/base'; +import type { AIProvider } from '../src/ai/providers/base'; +import type { ChatOptions } from '../src/ai/providers/base'; +import { OpenAICompatibleProvider } from '../src/ai/providers/openai-compatible'; + +const OPTIONS: ChatOptions = { + model: 'test', + temperature: 0, + maxTokens: 1024, + timeoutMs: 5000, +}; + +suite('AI Empty Response Handling', () => { + test('parseJsonResponse throws empty-response error on blank input', () => { + assert.throws(() => parseJsonResponse(''), /空响应|empty response/); + assert.throws(() => parseJsonResponse(' \n\t '), /空响应|empty response/); + }); + + test('parseJsonResponse parses valid JSON normally', () => { + const parsed = parseJsonResponse('{"findings":[]}') as { findings: unknown[] }; + assert.deepStrictEqual(parsed.findings, []); + }); + + test('chatWithRetry retries once on EmptyContentError', async () => { + const calls: string[] = []; + const provider = { + chat: async (system: string): Promise => { + calls.push(system); + if (calls.length === 1) { + throw new EmptyContentError('finish_reason=length'); + } + return '{"findings":[]}'; + }, + } as unknown as AIProvider; + + const result = await chatWithRetry(provider, 'sys', 'user', OPTIONS); + assert.strictEqual(result, '{"findings":[]}'); + assert.strictEqual(calls.length, 2); + }); + + test('chatWithRetry propagates error when retry also returns empty', async () => { + const provider = { + chat: async (): Promise => { + throw new EmptyContentError('finish_reason=length'); + }, + } as unknown as AIProvider; + + await assert.rejects( + () => chatWithRetry(provider, 'sys', 'user', OPTIONS), + EmptyContentError + ); + }); + + test('chatWithRetry does not retry on non-empty-content errors', async () => { + let calls = 0; + const provider = { + chat: async (): Promise => { + calls++; + throw new Error('boom'); + }, + } as unknown as AIProvider; + + await assert.rejects(() => chatWithRetry(provider, 'sys', 'user', OPTIONS), /boom/); + assert.strictEqual(calls, 1); + }); + + test('openai-compatible reports max_tokens truncation clearly', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => ({ + ok: true, + json: async () => ({ + choices: [{ message: { content: null }, finish_reason: 'length' }], + }), + text: async () => '', + })) as unknown as typeof fetch; + + try { + const provider = new OpenAICompatibleProvider('key', 'http://localhost', 'test', 'Test'); + await assert.rejects( + () => + provider.chat('sys', 'user', { + model: 'm', + temperature: 0, + maxTokens: 8192, + timeoutMs: 5000, + }), + (err: unknown) => { + assert.ok(err instanceof EmptyContentError); + assert.match((err as Error).message, /max_tokens/); + assert.match((err as Error).message, /8192/); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/tests/ai-fix-engine.test.ts b/tests/ai-fix-engine.test.ts new file mode 100644 index 0000000..d93e7e3 --- /dev/null +++ b/tests/ai-fix-engine.test.ts @@ -0,0 +1,115 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { AIProvider, ChatOptions } from '../src/ai/providers/base'; +import { LinterAdapter, LinterDiagnostic } from '../src/types'; +import { aiFixDiagnostic } from '../src/fix/aiFixEngine'; + +class MockFixProvider extends AIProvider { + id = 'mock'; + name = 'mock'; + constructor(private responses: string[]) { super('key', 'url'); } + async chat(_system: string, _user: string, _options: ChatOptions): Promise { + const next = this.responses.shift(); + return next ?? '{}'; + } +} + +class MockAdapter implements LinterAdapter { + id = 'mock'; + supportedLanguages = ['javascript']; + isAvailable(): boolean { return true; } + async check(document: vscode.TextDocument, _workingDir: string) { + const text = document.getText(); + const diagnostics: LinterDiagnostic[] = []; + if (text.includes('BAD')) { + diagnostics.push({ + severity: 'error', + ruleId: 'eslint:mock-rule', + message: 'bad code', + range: new vscode.Range(0, 0, 0, 3), + }); + } + return { diagnostics, status: 'ok' as const }; + } +} + +const options: ChatOptions = { model: 'mock', temperature: 0, maxTokens: 100, timeoutMs: 1000 }; + +suite('AI FixEngine Tests', () => { + test('aiFixDiagnostic applies AI fix and converges', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = BAD;\n', + language: 'javascript', + }); + const diag: LinterDiagnostic = { + severity: 'error', + ruleId: 'eslint:mock-rule', + message: 'bad code', + range: new vscode.Range(0, 8, 0, 11), + }; + const provider = new MockFixProvider(['{"originalText":"BAD","newText":"GOOD"}']); + const result = await aiFixDiagnostic(doc, __dirname, new MockAdapter(), diag, 3, provider, options); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.appliedFixes.length, 1); + assert.strictEqual(result.appliedFixes[0].originalText, 'BAD'); + assert.strictEqual(result.appliedFixes[0].newText, 'GOOD'); + }); + + test('aiFixDiagnostic returns ai-match-failed when originalText not found', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = BAD;\n', + language: 'javascript', + }); + const diag: LinterDiagnostic = { + severity: 'error', + ruleId: 'eslint:mock-rule', + message: 'bad code', + range: new vscode.Range(0, 8, 0, 11), + }; + const provider = new MockFixProvider(['{"originalText":"NOPE","newText":"GOOD"}']); + const result = await aiFixDiagnostic(doc, __dirname, new MockAdapter(), diag, 3, provider, options); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.message, 'ai-match-failed'); + }); + + test('aiFixDiagnostic returns ai-no-fix when AI provides empty fix', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = BAD;\n', + language: 'javascript', + }); + const diag: LinterDiagnostic = { + severity: 'error', + ruleId: 'eslint:mock-rule', + message: 'bad code', + range: new vscode.Range(0, 8, 0, 11), + }; + const provider = new MockFixProvider(['{"originalText":"","newText":""}']); + const result = await aiFixDiagnostic(doc, __dirname, new MockAdapter(), diag, 3, provider, options); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.message, 'ai-no-fix'); + }); + + test('aiFixDiagnostic retries until issue resolved within maxIterations', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'BAD BAD\n', + language: 'javascript', + }); + const diag: LinterDiagnostic = { + severity: 'error', + ruleId: 'eslint:mock-rule', + message: 'bad code', + range: new vscode.Range(0, 0, 0, 3), + }; + const provider = new MockFixProvider([ + '{"originalText":"BAD BAD","newText":"BAD GOOD"}', + '{"originalText":"BAD","newText":"GOOD"}', + ]); + const result = await aiFixDiagnostic(doc, __dirname, new MockAdapter(), diag, 3, provider, options); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.appliedFixes.length, 2); + }); +}); diff --git a/tests/config.test.ts b/tests/config.test.ts new file mode 100644 index 0000000..f3d4323 --- /dev/null +++ b/tests/config.test.ts @@ -0,0 +1,33 @@ +import * as assert from 'assert'; +import { getAIConfig, getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage } from '../src/config/ai'; +import { getLinterForLanguage, getPMDJarPath, getPMDRulesetPath } from '../src/config/linter'; +import { getFixMaxIterations } from '../src/config/fixer'; + +suite('Config Tests', () => { + test('getAIConfig returns default values', () => { + const config = getAIConfig(); + assert.strictEqual(config.provider, 'deepseek'); + assert.strictEqual(config.model, 'deepseek-chat'); + assert.strictEqual(config.baseUrl, 'https://api.deepseek.com/v1'); + assert.strictEqual(config.outputLanguage, 'zh-CN'); + }); + + test('AI individual getters return defaults', () => { + assert.strictEqual(getAIProvider(), 'deepseek'); + assert.strictEqual(getAIModel(), 'deepseek-chat'); + assert.strictEqual(getAIBaseUrl(), 'https://api.deepseek.com/v1'); + assert.strictEqual(getAITemperature(), 0.2); + assert.strictEqual(getAITimeout(), 300); + assert.strictEqual(getAIMaxTokens(), 8192); + assert.strictEqual(getAIOutputLanguage(), 'zh-CN'); + }); + + test('getLinterForLanguage returns configured linter', () => { + assert.strictEqual(getLinterForLanguage('javascript'), 'eslint'); + assert.strictEqual(getLinterForLanguage('java'), 'pmd'); + }); + + test('getFixMaxIterations returns default', () => { + assert.strictEqual(getFixMaxIterations(), 3); + }); +}); diff --git a/tests/customFixEngine.test.ts b/tests/customFixEngine.test.ts new file mode 100644 index 0000000..0bb04fe --- /dev/null +++ b/tests/customFixEngine.test.ts @@ -0,0 +1,100 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { AIProvider, ChatOptions } from '../src/ai/providers/base'; +import { aiFixReviewIssue } from '../src/fix/customFixEngine'; +import type { ReviewIssueInput } from '../src/fix/fixPrompt'; + +class MockReviewFixProvider extends AIProvider { + id = 'mock'; + name = 'mock'; + constructor(private responses: string[]) { super('key', 'url'); } + async chat(_system: string, _user: string, _options: ChatOptions): Promise { + const next = this.responses.shift(); + return next ?? '{}'; + } +} + +const options: ChatOptions = { model: 'mock', temperature: 0, maxTokens: 100, timeoutMs: 1000 }; + +function reviewDiag(message = 'bad code', line = 0): ReviewIssueInput { + return { ruleId: 'custom:test-rule', line, message, suggestion: 'use GOOD instead' }; +} + +suite('Custom FixEngine Tests', () => { + test('aiFixReviewIssue applies AI fix and converges after verify', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = BAD;\n', + language: 'javascript', + }); + const provider = new MockReviewFixProvider([ + '{"originalText":"BAD","newText":"GOOD"}', + '{"fixed":true}', + ]); + const result = await aiFixReviewIssue(doc, reviewDiag(), 3, provider, options, true); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.newText, 'const x = GOOD;\n'); + assert.strictEqual(result.appliedFixes.length, 1); + }); + + test('aiFixReviewIssue returns ai-match-failed when originalText not found', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = BAD;\n', + language: 'javascript', + }); + const provider = new MockReviewFixProvider(['{"originalText":"NOPE","newText":"GOOD"}']); + const result = await aiFixReviewIssue(doc, reviewDiag(), 3, provider, options, true); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.message, 'ai-match-failed'); + }); + + test('aiFixReviewIssue retries until verify passes within maxIterations', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = BAD;\n', + language: 'javascript', + }); + const provider = new MockReviewFixProvider([ + '{"originalText":"BAD","newText":"BAD2"}', + '{"fixed":false,"reason":"still bad"}', + '{"originalText":"BAD2","newText":"GOOD"}', + '{"fixed":true}', + ]); + const result = await aiFixReviewIssue(doc, reviewDiag(), 3, provider, options, true); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.appliedFixes.length, 2); + assert.strictEqual(result.newText, 'const x = GOOD;\n'); + }); + + test('aiFixReviewIssue accepts last fix when verify never passes', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = BAD;\n', + language: 'javascript', + }); + const provider = new MockReviewFixProvider([ + '{"originalText":"BAD","newText":"GOOD"}', + '{"fixed":false,"reason":"still bad"}', + ]); + const result = await aiFixReviewIssue(doc, reviewDiag(), 1, provider, options, true); + + assert.strictEqual(result.success, true); + assert.strictEqual(result.newText, 'const x = GOOD;\n'); + assert.strictEqual(result.appliedFixes.length, 1); + }); + + test('aiFixReviewIssue retries empty fix once then fails with ai-no-fix', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'const x = BAD;\n', + language: 'javascript', + }); + const provider = new MockReviewFixProvider([ + '{"originalText":"","newText":""}', + '{"originalText":"","newText":""}', + ]); + const result = await aiFixReviewIssue(doc, reviewDiag(), 3, provider, options, true); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.message, 'ai-no-fix'); + }); +}); diff --git a/tests/dedup-prompt.test.ts b/tests/dedup-prompt.test.ts new file mode 100644 index 0000000..c6f66b5 --- /dev/null +++ b/tests/dedup-prompt.test.ts @@ -0,0 +1,43 @@ +import * as assert from 'assert'; +import { buildDedupOnlyPrompt } from '../src/rules/converters/dedup-prompt'; +import { buildKnownRulesSection } from '../src/rules/converters/known-rules'; +import type { CustomRule } from '../src/types'; + +const customRules: CustomRule[] = [ + { id: 'no-todo', severity: 'warning', description: '禁止提交 TODO 注释', message: '发现 TODO 注释' }, +]; + +const singleYaml = [ + '- id: no-todo', + ' severity: warning', + ' description: 禁止提交 TODO 注释', + ' message: 发现 TODO 注释', +].join('\n'); + +suite('Dedup Prompt Tests', () => { + + test('buildDedupOnlyPrompt 同时包含静态规则与自定义规则', () => { + const { system, user } = buildDedupOnlyPrompt(singleYaml, customRules); + + assert.ok(system.includes('pmd/AvoidDeeplyNestedIfStmts'), '应包含 PMD 静态规则'); + assert.ok(system.includes('eslint/no-unused-vars'), '应包含 ESLint 静态规则'); + assert.ok(system.includes('sqlfluff/AL01'), '应包含 SQLFluff 静态规则'); + assert.ok(system.includes('custom/no-todo'), '应包含自定义规则'); + assert.ok(system.includes('duplicateLevel'), '应包含去重字段说明'); + assert.ok(user.includes('no-todo'), 'user 侧应包含待去重规则'); + }); + + test('无自定义规则时仍包含静态规则', () => { + const { system } = buildDedupOnlyPrompt(singleYaml, []); + assert.ok(system.includes('pmd/AvoidDeeplyNestedIfStmts')); + assert.ok(!system.includes('- custom/'), '无自定义规则时不应出现 custom/ 规则条目'); + }); + + test('buildKnownRulesSection 三语文案齐全', () => { + const section = buildKnownRulesSection(customRules); + assert.ok(section.startsWith('## '), '应以清单标题开头'); + assert.ok(section.includes('### pmd'), '应包含 linter 分组标题'); + assert.ok(section.includes('### 已导入的自定义规则'), '应包含自定义规则分组标题'); + assert.ok(section.includes('- custom/no-todo: 禁止提交 TODO 注释'), '应包含 custom/ 前缀规则行'); + }); +}); diff --git a/tests/diagnostics.test.ts b/tests/diagnostics.test.ts new file mode 100644 index 0000000..e09b45b --- /dev/null +++ b/tests/diagnostics.test.ts @@ -0,0 +1,66 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { toVscodeDiagnostics } from '../src/diagnostics/diagnosticMarkers'; +import type { LinterDiagnostic } from '../src/types'; + +suite('DiagnosticMarkers Tests', () => { + test('toVscodeDiagnostics maps severity correctly', () => { + const diags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-unused', message: 'unused', range: new vscode.Range(0, 0, 0, 5) }, + { severity: 'warning', ruleId: 'pmd:avoid-duplicate', message: 'duplicate', range: new vscode.Range(1, 0, 1, 3) }, + { severity: 'info', ruleId: 'custom:no-console', message: 'console', range: new vscode.Range(2, 0, 2, 4) }, + ]; + + const result = toVscodeDiagnostics(diags); + + assert.strictEqual(result[0].severity, vscode.DiagnosticSeverity.Error); + assert.strictEqual(result[1].severity, vscode.DiagnosticSeverity.Warning); + assert.strictEqual(result[2].severity, vscode.DiagnosticSeverity.Information); + }); + + test('toVscodeDiagnostics preserves range', () => { + const range = new vscode.Range(3, 2, 5, 10); + const diags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-var', message: 'no var', range }, + ]; + + const result = toVscodeDiagnostics(diags); + + assert.ok(result[0].range.isEqual(range)); + }); + + test('toVscodeDiagnostics prefixes message with plugin and linter', () => { + const diags: LinterDiagnostic[] = [ + { severity: 'warning', ruleId: 'eslint:no-console', message: 'avoid console', range: new vscode.Range(0, 0, 0, 1) }, + ]; + + const result = toVscodeDiagnostics(diags); + + assert.strictEqual(result[0].message, '[Code Purifier · eslint] no-console: avoid console'); + }); + + test('toVscodeDiagnostics handles ruleId without linter prefix', () => { + const diags: LinterDiagnostic[] = [ + { severity: 'warning', ruleId: 'custom-rule', message: 'custom message', range: new vscode.Range(0, 0, 0, 1) }, + ]; + + const result = toVscodeDiagnostics(diags); + + assert.strictEqual(result[0].message, '[Code Purifier] custom-rule: custom message'); + }); + + test('toVscodeDiagnostics sets source and code for quick fix hover', () => { + const diags: LinterDiagnostic[] = [ + { severity: 'warning', ruleId: 'eslint:no-var', message: 'no var', range: new vscode.Range(0, 0, 0, 1) }, + ]; + + const result = toVscodeDiagnostics(diags); + + assert.strictEqual(result[0].source, 'Code Purifier'); + assert.strictEqual(result[0].code, 'eslint:no-var'); + }); + + test('toVscodeDiagnostics returns empty array for empty input', () => { + assert.deepStrictEqual(toVscodeDiagnostics([]), []); + }); +}); diff --git a/tests/fixEngine.test.ts b/tests/fixEngine.test.ts new file mode 100644 index 0000000..5b4bdd2 --- /dev/null +++ b/tests/fixEngine.test.ts @@ -0,0 +1,103 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { ESLintAdapter } from '../src/adapters/eslint'; +import { fixDiagnostic } from '../src/fix/fixEngine'; + +suite('FixEngine Tests', () => { + test('ESLint adapter attaches fix object for autofixable issues', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'var name = "Hi " + user;\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + assert.strictEqual(result.status, 'ok'); + const fixable = result.diagnostics.filter(d => d.fix); + assert.ok(fixable.length >= 1, 'prefer-template/no-var should be autofixable'); + const prefer = fixable.find(d => d.ruleId === 'eslint:prefer-template'); + assert.ok(prefer, 'expected prefer-template diagnostic'); + assert.ok(prefer!.fix!.range[0] < prefer!.fix!.range[1]); + assert.ok(prefer!.fix!.text.length > 0); + }); + + test('fixDiagnostic multi-round convergence produces applied fixes', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'var name = "Hi " + user;\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + const prefer = result.diagnostics.find(d => d.ruleId === 'eslint:prefer-template' && d.fix); + if (!prefer) { return; } + + const fixResult = await fixDiagnostic(doc, __dirname, adapter, prefer, 3); + if (fixResult.success) { + assert.ok(fixResult.appliedFixes.length >= 1); + } else { + assert.ok(fixResult.message === 'no-active-editor' || fixResult.message === 'not-autofixable', `unexpected failure: ${fixResult.message}`); + } + }); + + test('fixDiagnostic returns not-autofixable for non-fixable rule', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'function unused(a, b) { return a; }\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + const unused = result.diagnostics.find(d => d.ruleId === 'eslint:no-unused-vars'); + if (!unused) { return; } + assert.ok(!unused.fix, 'no-unused-vars is not autofixable'); + + const fixResult = await fixDiagnostic(doc, __dirname, adapter, unused, 3); + assert.strictEqual(fixResult.success, false); + assert.strictEqual(fixResult.message, 'not-autofixable'); + }); + + test('fixDiagnostic no-change returns no-change', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'const ok = 1;\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + const anyDiag = result.diagnostics.find(d => d.ruleId === 'eslint:no-extra-semi'); + if (anyDiag) { + const fixResult = await fixDiagnostic(doc, __dirname, adapter, anyDiag, 3); + assert.ok(fixResult.success === false); + } + }); + + test('fixDiagnostic fixes only the targeted issue, not adjacent same-rule instance', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'var a = 1;\nvar b = 2;\n', + language: 'javascript', + }); + + const result = await adapter.check(doc, __dirname); + const first = result.diagnostics.find(d => d.ruleId === 'eslint:no-var' && d.fix && d.range.start.line === 0); + if (!first) { return; } + + const fixResult = await fixDiagnostic(doc, __dirname, adapter, first, 3); + if (fixResult.success) { + assert.strictEqual(fixResult.appliedFixes.length, 1); + } else { + assert.ok(fixResult.message === 'not-autofixable' || fixResult.message === 'no-change', `unexpected failure: ${fixResult.message}`); + } + }); +}); diff --git a/tests/fixSession.test.ts b/tests/fixSession.test.ts new file mode 100644 index 0000000..45ab61a --- /dev/null +++ b/tests/fixSession.test.ts @@ -0,0 +1,95 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { FixSessionManager } from '../src/fix/fixSession'; + +suite('FixSession Tests', () => { + const uri = vscode.Uri.file('C:/fake/project/src/app.js'); + + test('add/get/has roundtrip', () => { + const session = new FixSessionManager(); + const key = 'eslint:no-extra-semi@5'; + session.add(uri, { + key, + ruleId: 'eslint:no-extra-semi', + line: 5, + fixes: [{ originalText: 'var x = 1;;', newText: 'var x = 1;', line: 5 }], + source: 'linter', + }); + assert.strictEqual(session.has(uri, key), true); + const entry = session.get(uri, key); + assert.ok(entry); + assert.strictEqual(entry!.ruleId, 'eslint:no-extra-semi'); + assert.strictEqual(entry!.line, 5); + assert.strictEqual(entry!.fixes.length, 1); + }); + + test('get returns undefined for missing key', () => { + const session = new FixSessionManager(); + assert.strictEqual(session.get(uri, 'eslint:x@1'), undefined); + assert.strictEqual(session.has(uri, 'eslint:x@1'), false); + }); + + test('getEntries filters per uri', () => { + const session = new FixSessionManager(); + const other = vscode.Uri.file('C:/fake/project/src/other.js'); + session.add(uri, { key: 'a@1', ruleId: 'a', line: 1, fixes: [{ originalText: '1', newText: '2', line: 1 }], source: 'linter' }); + session.add(uri, { key: 'b@2', ruleId: 'b', line: 2, fixes: [{ originalText: '3', newText: '4', line: 2 }], source: 'linter' }); + session.add(other, { key: 'c@3', ruleId: 'c', line: 3, fixes: [{ originalText: '5', newText: '6', line: 3 }], source: 'linter' }); + + const entries = session.getEntries(uri); + assert.strictEqual(entries.length, 2); + assert.deepStrictEqual(entries.map(e => e.ruleId).sort(), ['a', 'b']); + }); + + test('clear removes entries for uri only', () => { + const session = new FixSessionManager(); + const other = vscode.Uri.file('C:/fake/project/src/other.js'); + session.add(uri, { key: 'a@1', ruleId: 'a', line: 1, fixes: [{ originalText: '1', newText: '2', line: 1 }], source: 'linter' }); + session.add(other, { key: 'c@3', ruleId: 'c', line: 3, fixes: [{ originalText: '5', newText: '6', line: 3 }], source: 'linter' }); + + session.clear(uri); + assert.strictEqual(session.getEntries(uri).length, 0); + assert.strictEqual(session.getEntries(other).length, 1); + }); + + test('recordFixes merges multi-round fixes under same key', () => { + const session = new FixSessionManager(); + session.recordFixes(uri, 'eslint:no-extra-semi', 5, [ + { originalText: ';;', newText: ';', line: 5 }, + ]); + session.recordFixes(uri, 'eslint:no-extra-semi', 5, [ + { originalText: ';', newText: '', line: 5 }, + ]); + + const entry = session.get(uri, 'eslint:no-extra-semi@5'); + assert.ok(entry); + assert.strictEqual(entry!.fixes.length, 2); + }); + + test('undo restores original text via workspace edit', async () => { + const doc = await vscode.workspace.openTextDocument({ + content: 'var x = 1;;\nvar y = 2;\n', + language: 'javascript', + }); + + const session = new FixSessionManager(); + const key = session.recordFixes(doc.uri, 'eslint:no-extra-semi', 0, [ + { originalText: ';;', newText: ';', line: 0 }, + ]); + + const edit = new vscode.WorkspaceEdit(); + edit.replace(doc.uri, new vscode.Range(0, 0, 1, 0), 'var x = 1;\n'); + const applied = await vscode.workspace.applyEdit(edit); + assert.ok(applied); + + const docAfterFix = await vscode.workspace.openTextDocument(doc.uri); + assert.strictEqual(docAfterFix.getText(), 'var x = 1;\nvar y = 2;\n'); + + const undone = await session.undo(docAfterFix, key); + assert.ok(undone); + + const docAfterUndo = await vscode.workspace.openTextDocument(doc.uri); + assert.strictEqual(docAfterUndo.getText(), 'var x = 1;;\nvar y = 2;\n'); + assert.strictEqual(session.has(doc.uri, key), false); + }); +}); diff --git a/tests/fixtures/Sample.java b/tests/fixtures/Sample.java new file mode 100644 index 0000000..05a44f0 --- /dev/null +++ b/tests/fixtures/Sample.java @@ -0,0 +1,7 @@ +public class Sample { + public void test() { + String password = "admin123"; + System.out.println("debug"); + System.out.println("debug"); + } +} diff --git a/tests/fixtures/sample.css b/tests/fixtures/sample.css new file mode 100644 index 0000000..36f0a6e --- /dev/null +++ b/tests/fixtures/sample.css @@ -0,0 +1,2 @@ +.hello { color: black; background: #FFF; } +#test { margin: 0px; } diff --git a/tests/fixtures/sample.js b/tests/fixtures/sample.js new file mode 100644 index 0000000..dd77265 --- /dev/null +++ b/tests/fixtures/sample.js @@ -0,0 +1,5 @@ +function test() { + var unused = 1; + console.log('debug'); + return "hello world"; +} diff --git a/tests/import-dedup.test.ts b/tests/import-dedup.test.ts new file mode 100644 index 0000000..1c5813b --- /dev/null +++ b/tests/import-dedup.test.ts @@ -0,0 +1,368 @@ +import * as assert from 'assert'; +import { buildFinalYaml, parseImportableYaml } from '../src/rules/import-service'; +import type { ImportableRule, PreviewDecision } from '../src/rules/import-types'; + +function makeRules(data: Array>): ImportableRule[] { + return data.map(d => ({ + id: d.id ?? 'test-rule', + severity: d.severity ?? 'warning', + description: d.description ?? 'test description', + message: d.message ?? 'test message', + languages: d.languages, + excludeLanguages: d.excludeLanguages, + duplicateOf: d.duplicateOf, + duplicateLevel: d.duplicateLevel, + duplicateReason: d.duplicateReason, + })); +} + +function defaultDecision(rules: ImportableRule[]): PreviewDecision { + const keepRule: Record = {}; + for (const rule of rules) { + keepRule[rule.id] = rule.duplicateLevel !== 'exact'; + } + return { keepRule, confirmed: true }; +} + +function makeYaml(rules: Array<{ id: string; fields: Record }>): string { + return rules.map(r => { + const lines = [`- id: ${r.id}`]; + for (const [key, value] of Object.entries(r.fields)) { + lines.push(` ${key}: ${value}`); + } + return lines.join('\n'); + }).join('\n\n') + '\n'; +} + +suite('Import Dedup Tests', () => { + + test('无重复规则→全部保留,无注释行', () => { + const rules = makeRules([ + { id: 'rule-a', duplicateLevel: 'none' }, + { id: 'rule-b' }, + ]); + const yaml = makeYaml([ + { id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateLevel: 'none' } }, + { id: 'rule-b', fields: { severity: 'info', description: 'desc b', message: 'msg b' } }, + ]); + const result = buildFinalYaml(yaml, rules, defaultDecision(rules)); + assert.ok(!result.includes('# [DUPLICATE'), 'Should have no duplicate headers'); + assert.ok(result.includes('id: rule-a')); + assert.ok(result.includes('id: rule-b')); + assert.ok(!result.includes('duplicateLevel:'), 'duplicateLevel should be stripped'); + }); + + test('全部 exact→全部注释', () => { + const rules = makeRules([ + { id: 'rule-a', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' }, + { id: 'rule-b', duplicateOf: 'eslint/no-debugger', duplicateLevel: 'exact' }, + ]); + const yaml = makeYaml([ + { id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } }, + { id: 'rule-b', fields: { severity: 'error', description: 'desc b', message: 'msg b', duplicateOf: 'eslint/no-debugger', duplicateLevel: 'exact' } }, + ]); + const result = buildFinalYaml(yaml, rules, defaultDecision(rules)); + assert.ok(!result.match(/^[^#]*- id:/m), 'No non-commented rule lines'); + assert.ok(result.includes('[DUPLICATE: exact]')); + assert.ok(result.includes('# 如需启用')); + }); + + test('全部 overlap→全部保留,无注释行', () => { + const rules = makeRules([ + { id: 'rule-a', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求 logger' }, + { id: 'rule-b', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap', duplicateReason: '更窄范围' }, + ]); + const yaml = makeYaml([ + { id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求 logger' } }, + { id: 'rule-b', fields: { severity: 'warning', description: 'desc b', message: 'msg b', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap', duplicateReason: '更窄范围' } }, + ]); + const result = buildFinalYaml(yaml, rules, defaultDecision(rules)); + assert.ok(!result.includes('# [DUPLICATE'), 'Overlap rules should not be commented by default'); + assert.ok(result.includes('id: rule-a')); + assert.ok(result.includes('id: rule-b')); + }); + + test('混合三档→exact注释,overlap和none保留', () => { + const rules = makeRules([ + { id: 'exact-rule', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' }, + { id: 'overlap-rule', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap' }, + { id: 'none-rule', duplicateLevel: 'none' }, + ]); + const yaml = makeYaml([ + { id: 'exact-rule', fields: { severity: 'warning', description: 'd1', message: 'm1', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } }, + { id: 'overlap-rule', fields: { severity: 'warning', description: 'd2', message: 'm2', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap' } }, + { id: 'none-rule', fields: { severity: 'info', description: 'd3', message: 'm3', duplicateLevel: 'none' } }, + ]); + const result = buildFinalYaml(yaml, rules, defaultDecision(rules)); + assert.ok(result.includes('[DUPLICATE: exact]')); + assert.ok(!result.match(/^-\s+id:\s+exact-rule/m), 'exact rule should be commented'); + assert.ok(result.match(/^-\s+id:\s+overlap-rule/m), 'overlap rule should be active'); + assert.ok(result.match(/^-\s+id:\s+none-rule/m), 'none rule should be active'); + }); + + test('用户恢复 exact 规则→取消注释', () => { + const rules = makeRules([ + { id: 'restored', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' }, + ]); + const yaml = makeYaml([ + { id: 'restored', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } }, + ]); + const decision: PreviewDecision = { keepRule: { restored: true }, confirmed: true }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(!result.includes('[DUPLICATE'), 'Restored rule should have no duplicate annotation'); + assert.ok(result.match(/^-\s+id:\s+restored/m), 'Restored rule should be active'); + }); + + test('用户注释 overlap 规则→加 # 前缀和注释头', () => { + const rules = makeRules([ + { id: 'commented', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求' }, + ]); + const yaml = makeYaml([ + { id: 'commented', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求' } }, + ]); + const decision: PreviewDecision = { keepRule: { commented: false }, confirmed: true }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(result.includes('[DUPLICATE: overlap]')); + assert.ok(result.includes('重叠原因:额外要求')); + assert.ok(!result.match(/^-\s+id:\s+commented/m), 'Commented rule should have # prefix'); + }); + + test('用户注释 none 规则→加 # 前缀', () => { + const rules = makeRules([ + { id: 'comment-none', duplicateLevel: 'none' }, + ]); + const yaml = makeYaml([ + { id: 'comment-none', fields: { severity: 'warning', description: 'd', message: 'm', duplicateLevel: 'none' } }, + ]); + const decision: PreviewDecision = { keepRule: { 'comment-none': false }, confirmed: true }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(!result.match(/^-\s+id:\s+comment-none/m), 'Should be commented'); + assert.ok(result.includes('# - id: comment-none')); + }); + + test('保留规则→duplicateLevel/duplicateOf/duplicateReason 行被移除', () => { + const rules = makeRules([ + { id: 'kept', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: 'reason' }, + ]); + const yaml = makeYaml([ + { id: 'kept', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: 'reason' } }, + ]); + const decision: PreviewDecision = { keepRule: { kept: true }, confirmed: true }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(!result.includes('duplicateOf:')); + assert.ok(!result.includes('duplicateLevel:')); + assert.ok(!result.includes('duplicateReason:')); + assert.ok(result.includes('id: kept')); + }); + + test('注释规则含 duplicateReason→注释头包含重叠原因', () => { + const rules = makeRules([ + { id: 'r1', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '检测目标相同但额外要求 logger' }, + ]); + const yaml = makeYaml([ + { id: 'r1', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '检测目标相同但额外要求 logger' } }, + ]); + const decision: PreviewDecision = { keepRule: { r1: false }, confirmed: true }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(result.includes('重叠原因:检测目标相同但额外要求 logger')); + }); + + test('编辑 description 后确认→YAML 使用新值', () => { + const rules = makeRules([ + { id: 'edit-desc', duplicateLevel: 'none' }, + ]); + const yaml = makeYaml([ + { id: 'edit-desc', fields: { severity: 'warning', description: '旧描述', message: '旧消息' } }, + ]); + const editedRules = makeRules([ + { id: 'edit-desc', severity: 'warning', description: '新描述', message: '新消息' }, + ]); + const decision: PreviewDecision = { keepRule: { 'edit-desc': true }, confirmed: true, editedRules }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(result.includes('description: 新描述')); + assert.ok(!result.includes('旧描述')); + assert.ok(result.includes('message: 新消息')); + }); + + test('编辑 severity 后确认→YAML 使用新 severity', () => { + const rules = makeRules([ + { id: 'edit-sev', duplicateLevel: 'none' }, + ]); + const yaml = makeYaml([ + { id: 'edit-sev', fields: { severity: 'warning', description: 'd', message: 'm' } }, + ]); + const editedRules = makeRules([ + { id: 'edit-sev', severity: 'error', description: 'd', message: 'm' }, + ]); + const decision: PreviewDecision = { keepRule: { 'edit-sev': true }, confirmed: true, editedRules }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(result.includes('severity: error')); + assert.ok(!result.includes('severity: warning')); + }); + + test('编辑 languages 后确认→YAML 含新 languages', () => { + const rules = makeRules([ + { id: 'edit-lang', duplicateLevel: 'none', languages: ['java'] }, + ]); + const yaml = makeYaml([ + { id: 'edit-lang', fields: { severity: 'warning', description: 'd', message: 'm', languages: '[java]' } }, + ]); + const editedRules = makeRules([ + { id: 'edit-lang', severity: 'warning', description: 'd', message: 'm', languages: ['javascript', 'typescript'] }, + ]); + const decision: PreviewDecision = { keepRule: { 'edit-lang': true }, confirmed: true, editedRules }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(result.includes('languages: [javascript, typescript]')); + assert.ok(!result.includes('languages: [java]'), 'old java language should be gone'); + assert.ok(!result.includes(' [java]'), 'standalone java tag should be gone'); + }); + + test('编辑后切换为注释→注释内容为编辑后的值', () => { + const rules = makeRules([ + { id: 'edit-comment', duplicateLevel: 'none' }, + ]); + const yaml = makeYaml([ + { id: 'edit-comment', fields: { severity: 'warning', description: '原描述', message: '原消息' } }, + ]); + const editedRules = makeRules([ + { id: 'edit-comment', severity: 'error', description: '新描述', message: '新消息' }, + ]); + const decision: PreviewDecision = { keepRule: { 'edit-comment': false }, confirmed: true, editedRules }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(result.includes('# severity: error'), 'should have commented severity: error'); + assert.ok(result.includes('# description: 新描述')); + assert.ok(result.includes('# message: 新消息')); + assert.ok(result.includes('[手动注释]')); + }); + + test('无编辑场景→回退到原始 yamlContent 处理', () => { + const rules = makeRules([ + { id: 'fallback', duplicateLevel: 'exact', duplicateOf: 'eslint/no-console' }, + ]); + const yaml = makeYaml([ + { id: 'fallback', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } }, + ]); + const decision: PreviewDecision = { keepRule: { fallback: false }, confirmed: true }; + const result = buildFinalYaml(yaml, rules, decision); + assert.ok(result.includes('[DUPLICATE: exact]')); + assert.ok(!result.match(/^-\s+id:\s+fallback/m)); + assert.ok(result.includes('duplicateOf:')); + }); +}); + +suite('parseImportableYaml Fallback Tests', () => { + + test('severity 缺失→降级为 warning', () => { + const yaml = `- id: test-rule + description: test desc + message: test msg`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].severity, 'warning'); + }); + + test('severity 非法值→降级为 warning', () => { + const yaml = `- id: test-rule + severity: critical + description: test desc + message: test msg`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].severity, 'warning'); + }); + + test('id 缺失→生成 rule-N', () => { + const yaml = `- severity: warning + description: test desc + message: test msg`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].id, 'rule-1'); + }); + + test('多条 id 缺失→rule-1, rule-2...', () => { + const yaml = `- severity: warning + description: desc a + message: msg a +- severity: info + description: desc b + message: msg b`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 2); + assert.strictEqual(rules[0].id, 'rule-1'); + assert.strictEqual(rules[1].id, 'rule-2'); + }); + + test('description 缺失、message 存在→互填', () => { + const yaml = `- id: test-rule + severity: error + message: test msg`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].description, 'test msg'); + assert.strictEqual(rules[0].message, 'test msg'); + }); + + test('message 缺失、description 存在→互填', () => { + const yaml = `- id: test-rule + severity: error + description: test desc`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].description, 'test desc'); + assert.strictEqual(rules[0].message, 'test desc'); + }); + + test('description 与 message 同时缺失→丢弃', () => { + const yaml = `- id: test-rule + severity: error +- id: test-rule2 + severity: warning + description: test desc + message: test msg`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].id, 'test-rule2'); + }); + + test('正常完整输入→无回归', () => { + const yaml = `- id: no-console-log + severity: error + description: 禁止使用 console.log + message: 请使用 logger 替代 +- id: no-unused-vars + severity: warning + description: 禁止未使用变量 + message: 删除或注释未使用变量 + duplicateOf: eslint/no-unused-vars + duplicateLevel: exact`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 2); + assert.strictEqual(rules[0].severity, 'error'); + assert.strictEqual(rules[0].description, '禁止使用 console.log'); + assert.strictEqual(rules[1].duplicateLevel, 'exact'); + }); + + test('id 带引号→剥离引号保留纯 id', () => { + const yaml = `- id: '123' + severity: warning + description: final 字段可改为 static + message: final 字段可改为 static`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].id, '123'); + }); + + test('scalar 字段带引号→剥离引号', () => { + const yaml = `- id: "quoted-rule" + severity: "error" + description: "desc text" + message: 'msg text'`; + const rules = parseImportableYaml(yaml); + assert.strictEqual(rules.length, 1); + assert.strictEqual(rules[0].id, 'quoted-rule'); + assert.strictEqual(rules[0].severity, 'error'); + assert.strictEqual(rules[0].description, 'desc text'); + assert.strictEqual(rules[0].message, 'msg text'); + }); +}); diff --git a/tests/manual/Buggy.java b/tests/manual/Buggy.java new file mode 100644 index 0000000..33da75a --- /dev/null +++ b/tests/manual/Buggy.java @@ -0,0 +1,16 @@ +public class Buggy { + public void test() { + String password = "admin123"; + String name = "test"; + int x = 1; + int y = 2; + int z = x + y; + System.out.println("debug"); + System.out.println("done"); + } + + public void duplicate() { + String password = "secret"; + System.out.println(password); + } +} diff --git a/tests/manual/buggy.css b/tests/manual/buggy.css new file mode 100644 index 0000000..6cb7db2 --- /dev/null +++ b/tests/manual/buggy.css @@ -0,0 +1,3 @@ +.hello { color: #FFFFFF; background: black; } +#test { margin: 0px; } +.foo { font-size: 12px; } diff --git a/tests/manual/buggy.js b/tests/manual/buggy.js new file mode 100644 index 0000000..2d2ac18 --- /dev/null +++ b/tests/manual/buggy.js @@ -0,0 +1,11 @@ +var x = 1; +var y = 2; +var x = 3; + +function test() { + var unused = 'hello'; + console.log('debug'); + return "world"; +} + +test(); diff --git a/tests/manual/buggy.jsp b/tests/manual/buggy.jsp new file mode 100644 index 0000000..68b76b4 --- /dev/null +++ b/tests/manual/buggy.jsp @@ -0,0 +1,42 @@ +<%@ page language="java" contentType="text/html" %> +<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %> + + + ${title} + + + +<% + String password = "admin123"; + String name = "test"; + int x = 1; +%> + +

${message} ${message}

+ + +

Welcome, ${user.name}

+
+ + + ${item} + + + + + + hello + + + + + diff --git a/tests/manual/buggy.sql b/tests/manual/buggy.sql new file mode 100644 index 0000000..c7b0bea --- /dev/null +++ b/tests/manual/buggy.sql @@ -0,0 +1,3 @@ +SELECT name FORM users; +SELECT * FORM products; +INSERT INTO customers VALUES (1, 'test'); diff --git a/tests/measure/measure-review-time.mjs b/tests/measure/measure-review-time.mjs new file mode 100644 index 0000000..7899bfa --- /dev/null +++ b/tests/measure/measure-review-time.mjs @@ -0,0 +1,150 @@ +import { ESLint } from 'eslint'; +import js from '@eslint/js'; +import ts from 'typescript-eslint'; +import stylelint from 'stylelint'; +import recommendedConfig from 'stylelint-config-recommended'; +import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DATA = join(__dirname, '..', '..', 'data'); +const OUT_DIR = join(__dirname, 'results'); + +// ===== 以下与 src/rules/builtin-rules.ts 逐字一致 ===== +const eslintExtraRules = { + 'eqeqeq': 'error', 'no-eq-null': 'error', 'no-self-compare': 'error', + 'no-promise-executor-return': 'error', 'no-shadow': 'error', 'no-unassigned-vars': 'error', + 'no-useless-assignment': 'error', 'block-scoped-var': 'error', 'default-case': 'error', + 'default-case-last': 'error', 'no-unmodified-loop-condition': 'error', 'no-unreachable-loop': 'error', + 'no-eval': 'error', 'no-extend-native': 'error', 'no-var': 'error', + 'no-await-in-loop': 'warn', 'prefer-template': 'warn', 'prefer-object-spread': 'warn', + 'prefer-rest-params': 'warn', 'prefer-spread': 'warn', 'prefer-object-has-own': 'warn', + 'no-useless-concat': 'warn', 'no-useless-return': 'warn', 'no-useless-computed-key': 'warn', + 'no-useless-rename': 'warn', 'no-param-reassign': 'warn', 'no-return-assign': 'error', + 'no-throw-literal': 'error', 'camelcase': 'warn', 'new-cap': 'warn', 'no-array-constructor': 'error', +}; +const eslintExtraTsRules = { + '@typescript-eslint/no-non-null-assertion': 'error', + '@typescript-eslint/no-dynamic-delete': 'error', + '@typescript-eslint/no-useless-empty-export': 'error', + '@typescript-eslint/consistent-type-imports': 'error', + '@typescript-eslint/unified-signatures': 'error', + '@typescript-eslint/no-extraneous-class': 'warn', + '@typescript-eslint/no-useless-constructor': 'warn', + '@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error', + '@typescript-eslint/no-invalid-void-type': 'warn', + '@typescript-eslint/prefer-literal-enum-member': 'warn', + '@typescript-eslint/prefer-enum-initializers': 'warn', + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'error', + 'no-array-constructor': 'off', +}; +const stylelintExtraRules = { + 'color-no-invalid-hex': true, + 'function-linear-gradient-no-nonstandard-direction': true, + 'function-no-unknown': true, + 'unit-no-unknown': true, + 'no-unknown-animations': true, + 'no-unknown-custom-media': true, + 'no-unknown-custom-properties': true, + 'at-rule-no-vendor-prefix': true, + 'media-feature-name-no-vendor-prefix': true, + 'property-no-vendor-prefix': true, + 'selector-no-vendor-prefix': true, + 'value-no-vendor-prefix': true, + 'color-hex-length': 'short', + 'color-function-notation': 'modern', + 'length-zero-no-unit': true, + 'selector-pseudo-element-colon-notation': 'double', + 'import-notation': 'string', + 'alpha-value-notation': 'number', + 'hue-degree-notation': 'angle', + 'keyframe-selector-notation': 'percentage', +}; + +const TS_FILES = ['**/*.ts', '**/*.tsx', '**/*.mts', '**/*.cts']; +const JS_FILES = ['**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs']; + +function timing(ms) { + return `${ms.toFixed(1)}ms`; +} + +async function measure(name, fn) { + const start = process.hrtime.bigint(); + const result = await fn(); + const end = process.hrtime.bigint(); + const ms = Number(end - start) / 1e6; + return { name, ms, result }; +} + +async function main() { + if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true }); + + const rows = []; + const summary = { tool: 'code-reviewer', measureDate: new Date().toISOString() }; + + const eslintConfig = [ + js.configs.recommended, + { files: JS_FILES, languageOptions: { parserOptions: { ecmaFeatures: { jsx: true } } } }, + ...ts.configs.recommended.map(cfg => ({ ...cfg, files: cfg.files ?? TS_FILES })), + { rules: eslintExtraRules }, + { files: TS_FILES, rules: eslintExtraTsRules }, + ]; + + const eslintDemo = join(DATA, 'demo-eslint', 'src'); + const eslintFiles = readdirSync(eslintDemo).filter(f => /\.(js|ts|mjs|cjs)$/.test(f)); + if (eslintFiles.length > 0) { + const engine = new ESLint({ cwd: eslintDemo, overrideConfigFile: true, overrideConfig: eslintConfig }); + const t = measure('eslint static analysis (demo-eslint/src)', async () => { + const results = []; + for (const f of eslintFiles) { + const text = readFileSync(join(eslintDemo, f), 'utf8'); + const [res] = await engine.lintText(text, { filePath: join(eslintDemo, f) }); + results.push({ file: f, messages: res.messages.length }); + } + return results; + }); + const { ms, result } = await t; + rows.push({ target: 'demo-eslint', files: eslintFiles.length, ms, diagnostics: result.reduce((s, r) => s + r.messages, 0) }); + } + + const stylelintDemo = join(DATA, 'demo-stylelint', 'src'); + const stylelintFiles = readdirSync(stylelintDemo).filter(f => /\.css$/.test(f)); + if (stylelintFiles.length > 0) { + const styleConfig = { + ...recommendedConfig, + rules: { ...recommendedConfig.rules, ...stylelintExtraRules }, + }; + const t = measure('stylelint static analysis (demo-stylelint/src)', async () => { + const results = []; + for (const f of stylelintFiles) { + const code = readFileSync(join(stylelintDemo, f), 'utf8'); + const res = await stylelint.lint({ code, codeFilename: join(stylelintDemo, f), config: styleConfig, cwd: stylelintDemo }); + const warnings = res.results.flatMap(r => r.warnings).length; + results.push({ file: f, warnings }); + } + return results; + }); + const { ms, result } = await t; + rows.push({ target: 'demo-stylelint', files: stylelintFiles.length, ms, diagnostics: result.reduce((s, r) => s + r.warnings, 0) }); + } + + const totalMs = rows.reduce((s, r) => s + r.ms, 0); + summary.rows = rows; + summary.totalMs = totalMs; + + writeFileSync(join(OUT_DIR, 'measure-results.json'), JSON.stringify(summary, null, 2)); + + console.log('=== Code Purifier 静态分析耗时测量 ==='); + for (const r of rows) { + console.log(`${r.target}: ${r.files} 文件, ${r.diagnostics} 条诊断, ${timing(r.ms)}`); + } + console.log(`合计: ${timing(totalMs)}`); + console.log(`结果已写入 tests/measure/results/measure-results.json`); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/tests/measure/performance-comparison.md b/tests/measure/performance-comparison.md new file mode 100644 index 0000000..0274b72 --- /dev/null +++ b/tests/measure/performance-comparison.md @@ -0,0 +1,45 @@ +# 提效对比报告(基线 vs 提效后) + +## 一、测量方法 + +**场景**:对给定样例代码进行代码规范检查,比较两种方式的耗时与产出。 + +- **基线(人工审查)**:由人工阅读样例代码并列出规范问题(耗时依赖个人经验,需实测采集)。 +- **提效后(Code Purifier 插件静态分析链路)**:使用与插件内置配置逐字一致的 ESLint / Stylelint 引擎对 `data/demo-*/src/*` 执行静态分析,计时(可复现:`node tests/measure/measure-review-time.mjs`)。 + +> 测量脚本只产插件侧耗时;**人工基线需实测后手动填入下方表格**,原始记录需保留(时间、审查人、发现数)。 + +## 二、对比维度 + +| 维度 | 基线(人工审查) | 提效后(插件静态分析) | +|---|---|---| +| 审查耗时 | 待实测填入 | 见下表(脚本产出) | +| 发现规范问题数 | 待实测填入 | 脚本产出诊断数 | +| 覆盖率口径 | 依赖个人经验 | 内置配置规则全覆盖(demo 实测 100%) | +| 可重复性 | 低(因人而异) | 高(同配置逐字可复现) | + +## 三、插件侧实测数据(脚本产出) + +> 运行:`node tests/measure/measure-review-time.mjs`,结果另存 `tests/measure/results/measure-results.json`。 +> 实测日期:2026-08-26,Node.js v24.16.0。 + +| 目标 | 文件数 | 诊断数 | 耗时 | +|---|---|---|---| +| demo-eslint | 6 | 294 | 192.2ms | +| demo-stylelint | 2 | 115 | 2106.7ms | +| **合计** | **8** | **409** | **2298.9ms** | + +## 四、人工基线(待实测) + +> 请按以下步骤实测后填写,并保留原始记录: +> 1. 取 `data/demo-eslint/src/common.js`(约 12KB)为样例; +> 2. 审查人逐行阅读,列出发现的规范问题(命名/风格/潜在 bug 等),记录耗时; +> 3. 多人重复(建议 ≥3 人)取平均,填入下表。 + +| 审查人 | 样例 | 耗时 | 发现问题数 | 日期 | +|---|---|---|---|---| +| _待填_ | common.js | _待填_ | _待填_ | _待填_ | + +## 五、提效结论(待数据完备后填写) + +_待人工基线数据填入后,据此计算提效幅度并总结结论。_ diff --git a/tests/measure/results/measure-results.json b/tests/measure/results/measure-results.json new file mode 100644 index 0000000..88437c4 --- /dev/null +++ b/tests/measure/results/measure-results.json @@ -0,0 +1,19 @@ +{ + "tool": "code-reviewer", + "measureDate": "2026-08-26T11:16:33.653Z", + "rows": [ + { + "target": "demo-eslint", + "files": 6, + "ms": 192.1965, + "diagnostics": 294 + }, + { + "target": "demo-stylelint", + "files": 2, + "ms": 2106.6858, + "diagnostics": 115 + } + ], + "totalMs": 2298.8823 +} \ No newline at end of file diff --git a/tests/merger.test.ts b/tests/merger.test.ts new file mode 100644 index 0000000..5fa5ce4 --- /dev/null +++ b/tests/merger.test.ts @@ -0,0 +1,334 @@ +import * as assert from 'assert'; +import { mergeResults, MergedReport } from '../src/merger/merger'; +import { CustomRuleResult, AIFinding } from '../src/ai/schema'; +import { LinterDiagnostic } from '../src/types'; + +function range(line: number): any { + return new (require('vscode').Range)(line, 0, line, 1); +} + +suite('Merger Tests', () => { + test('mergeResults counts correctly', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-unused', message: 'x is unused', range: new (require('vscode').Range)(0, 0, 0, 1) }, + ]; + const customResults: CustomRuleResult[] = [ + { ruleId: 'custom:no-console', line: 5, severity: 'warning', message: 'avoid console.log' }, + ]; + const aiFindings: AIFinding[] = [ + { ruleId: 'hardcoded-secret', severity: 'error', category: 'security', title: 'Hardcoded', description: 'Found secret', suggestion: 'Use env', line: 3 }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: customResults, + translatedDiagnostics: [], + aiFindings, + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.strictEqual(report.linterCount, 1); + assert.strictEqual(report.customRuleCount, 1); + assert.strictEqual(report.aiCount, 1); + assert.strictEqual(report.degraded, false); + assert.strictEqual(report.language, 'javascript'); + }); + + test('mergeResults marks degraded when AI fails', () => { + const report = mergeResults({ + staticDiagnostics: [], + customRuleResults: [], + translatedDiagnostics: [], + aiFindings: [], + errors: ['AI 请求超时'], + degraded: true, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.strictEqual(report.degraded, true); + assert.strictEqual(report.errors.length, 1); + }); + + test('mergeResults sorts diagnostics by severity then line', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'info', ruleId: 'eslint:info-5', message: 'i5', range: range(5) }, + { severity: 'error', ruleId: 'eslint:err-10', message: 'e10', range: range(10) }, + { severity: 'warning', ruleId: 'eslint:warn-3', message: 'w3', range: range(3) }, + { severity: 'error', ruleId: 'eslint:err-1', message: 'e1', range: range(1) }, + ]; + const customResults: CustomRuleResult[] = [ + { ruleId: 'custom:i-9', line: 9, severity: 'info', message: 'i9' }, + { ruleId: 'custom:e-2', line: 2, severity: 'error', message: 'e2' }, + ]; + const aiFindings: AIFinding[] = [ + { ruleId: 'ai:w-7', severity: 'warning', category: 'style', title: 'w7', description: 'w7', line: 7, suggestion: '' }, + { ruleId: 'ai:e-4', severity: 'error', category: 'bug', title: 'e4', description: 'e4', line: 4, suggestion: '' }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: customResults, + translatedDiagnostics: [], + aiFindings, + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.deepStrictEqual( + report.linterDiagnostics.map(d => `${d.severity}:${d.range.start.line}`), + ['error:1', 'error:10', 'warning:3', 'info:5'] + ); + assert.deepStrictEqual( + report.customRuleDiagnostics.map(d => `${d.severity}:${d.range.start.line}`), + ['error:1', 'info:8'] + ); + assert.deepStrictEqual( + report.aiFindings.map(f => `${f.severity}:${f.line}`), + ['error:3', 'warning:6'] + ); + assert.deepStrictEqual(report.fixableLinterIndices, []); + assert.deepStrictEqual(report.fixableCustomIndices, []); + }); + + test('mergeResults marks fixable only diagnostics with fix object', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:fixable-1', message: 'f1', range: range(1), fix: { range: [0, 5], text: 'x' } }, + { severity: 'warning', ruleId: 'eslint:nofix-2', message: 'n2', range: range(2) }, + { severity: 'error', ruleId: 'eslint:fixable-3', message: 'f3', range: range(3), fix: { range: [6, 9], text: 'y' } }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: [], + translatedDiagnostics: [], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + const sorted = report.linterDiagnostics; + const fixableIndices = sorted.map((_, i) => (sorted[i].fix ? i : -1)).filter(i => i !== -1); + assert.deepStrictEqual(report.fixableLinterIndices, fixableIndices); + assert.strictEqual(report.fixableCustomIndices.length, 0); + }); + + test('mergeResults converts aiFindings line to 0-based', () => { + const aiFindings: AIFinding[] = [ + { ruleId: 'ai:first', severity: 'warning', category: 'style', title: 'first', description: 'd', line: 1, suggestion: '' }, + { ruleId: 'ai:last', severity: 'info', category: 'design', title: 'last', description: 'd', line: 8, suggestion: '' }, + ]; + + const report = mergeResults({ + staticDiagnostics: [], + customRuleResults: [], + translatedDiagnostics: [], + aiFindings, + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.deepStrictEqual( + report.aiFindings.map(f => f.line), + [0, 7] + ); + }); + + test('mergeResults pairs translations by originalRuleId not index', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-unused', message: 'x is unused', range: range(1) }, + { severity: 'warning', ruleId: 'eslint:no-console', message: 'console call', range: range(2) }, + { severity: 'error', ruleId: 'eslint:no-eval', message: 'eval used', range: range(3) }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: [], + translatedDiagnostics: [ + { originalRuleId: 'eslint:no-eval', translatedMessage: '评估使用', translatedSuggestion: '避免' }, + { originalRuleId: 'eslint:no-unused', translatedMessage: '未使用', translatedSuggestion: '删除' }, + ], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.strictEqual(report.linterDiagnostics[0].message, '未使用'); + assert.strictEqual(report.linterDiagnostics[0].suggestion, '删除'); + assert.strictEqual(report.linterDiagnostics[1].message, '评估使用'); + assert.strictEqual(report.linterDiagnostics[2].message, 'console call'); + }); + + test('mergeResults handles duplicate ruleId translations in order', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-console', message: 'first console', range: range(1) }, + { severity: 'error', ruleId: 'eslint:no-console', message: 'second console', range: range(2) }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: [], + translatedDiagnostics: [ + { originalRuleId: 'eslint:no-console', translatedMessage: '首次', translatedSuggestion: '' }, + { originalRuleId: 'eslint:no-console', translatedMessage: '二次', translatedSuggestion: '' }, + ], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.strictEqual(report.linterDiagnostics[0].message, '首次'); + assert.strictEqual(report.linterDiagnostics[1].message, '二次'); + }); + + test('mergeResults matches translations by normalized ruleId when prefix missing', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-var', message: 'Unexpected var', range: range(1) }, + { severity: 'warning', ruleId: 'eslint:no-console', message: 'console call', range: range(2) }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: [], + translatedDiagnostics: [ + { originalRuleId: 'no-var', translatedMessage: '应使用 let/const', translatedSuggestion: '改写成 let x = 1' }, + { originalRuleId: 'no-console', translatedMessage: '避免 console', translatedSuggestion: '' }, + ], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.strictEqual(report.linterDiagnostics[0].message, '应使用 let/const'); + assert.strictEqual(report.linterDiagnostics[0].suggestion, '改写成 let x = 1'); + assert.strictEqual(report.linterDiagnostics[1].message, '避免 console'); + }); + + test('mergeResults matches translations by normalized ruleId with slash variant', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:@typescript-eslint/no-unused-vars', message: 'x unused', range: range(1) }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: [], + translatedDiagnostics: [ + { originalRuleId: '@typescript-eslint/no-unused-vars', translatedMessage: '未使用变量', translatedSuggestion: '删除 x' }, + ], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.ts', + language: 'typescript', + adapterIds: ['eslint'], + }); + + assert.strictEqual(report.linterDiagnostics[0].message, '未使用变量'); + assert.strictEqual(report.linterDiagnostics[0].suggestion, '删除 x'); + }); + + test('mergeResults prefers exact ruleId match over normalized', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-shadow', message: 'shadow', range: range(1) }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: [], + translatedDiagnostics: [ + { originalRuleId: 'no-shadow', translatedMessage: '归一化命中', translatedSuggestion: '' }, + { originalRuleId: 'eslint:no-shadow', translatedMessage: '精确命中', translatedSuggestion: '' }, + ], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.strictEqual(report.linterDiagnostics[0].message, '精确命中'); + }); + + test('mergeResults computes aiFixableLinterIndices excluding native fix and sqlfluff', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-var', message: 'v', range: range(1), fix: { range: [0, 3], text: 'let' } }, + { severity: 'error', ruleId: 'eslint:no-undef', message: 'u', range: range(2) }, + { severity: 'error', ruleId: 'sqlfluff:AL01', message: 's', range: range(3) }, + { severity: 'error', ruleId: 'pmd:UnusedLocalVariable', message: 'p', range: range(4) }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: [], + translatedDiagnostics: [], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/a.java', + language: 'java', + adapterIds: ['pmd'], + aiFixAvailable: true, + }); + + assert.strictEqual(report.aiFixAvailable, true); + assert.deepStrictEqual(report.fixableLinterIndices, [0]); + assert.deepStrictEqual(report.aiFixableLinterIndices, [1, 3]); + }); + + test('mergeResults aiFixableLinterIndices empty when AI unavailable', () => { + const staticDiags: LinterDiagnostic[] = [ + { severity: 'error', ruleId: 'eslint:no-undef', message: 'u', range: range(2) }, + ]; + + const report = mergeResults({ + staticDiagnostics: staticDiags, + customRuleResults: [], + translatedDiagnostics: [], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: '/test/sample.js', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.deepStrictEqual(report.aiFixableLinterIndices, []); + assert.strictEqual(report.aiFixAvailable, false); + }); +}); diff --git a/tests/messages.test.ts b/tests/messages.test.ts new file mode 100644 index 0000000..238885d --- /dev/null +++ b/tests/messages.test.ts @@ -0,0 +1,72 @@ +import * as assert from 'assert'; +import { t, setLanguage, getLanguage, getMessageKeys, Language } from '../src/i18n/messages'; + +suite('I18n Tests', () => { + test('all message keys have values for all three languages', () => { + const languages: Language[] = ['zh-CN', 'en', 'ja']; + const keys = getMessageKeys(); + + for (const lang of languages) { + setLanguage(lang); + for (const key of keys) { + const result = t(key); + assert.ok(result, `Key "${key}" is empty for language "${lang}"`); + assert.notStrictEqual(result, key, `Key "${key}" has no translation for language "${lang}"`); + } + } + }); + + test('default language is zh-CN', () => { + setLanguage('zh-CN'); + assert.strictEqual(getLanguage(), 'zh-CN'); + }); + + test('setLanguage changes current language', () => { + setLanguage('en'); + assert.strictEqual(getLanguage(), 'en'); + setLanguage('ja'); + assert.strictEqual(getLanguage(), 'ja'); + setLanguage('zh-CN'); + }); + + test('t() falls back to key when key does not exist', () => { + setLanguage('zh-CN'); + const result = t('nonexistent.key'); + assert.strictEqual(result, 'nonexistent.key'); + }); + + test('t() returns zh-CN string in zh-CN language', () => { + setLanguage('zh-CN'); + assert.strictEqual(t('review.noEditor'), '请先打开一个文件'); + }); + + test('t() returns English string in en language', () => { + setLanguage('en'); + assert.strictEqual(t('review.noEditor'), 'Please open a file first'); + }); + + test('t() returns Japanese string in ja language', () => { + setLanguage('ja'); + assert.strictEqual(t('review.noEditor'), '最初にファイルを開いてください'); + }); + + test('t() with template variables', () => { + setLanguage('en'); + assert.strictEqual( + t('export.saved', { 0: '/home/user/report.md' }), + 'Report saved to /home/user/report.md' + ); + }); + + test('t() with multiple template variables', () => { + setLanguage('zh-CN'); + assert.strictEqual( + t('report.totalSummary', { 0: '10', 1: '3', 2: '5', 3: '2' }), + '总计: 10 | 错误: 3 | 警告: 5 | 建议: 2' + ); + }); + + teardown(() => { + setLanguage('zh-CN'); + }); +}); diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts new file mode 100644 index 0000000..869db60 --- /dev/null +++ b/tests/pipeline.test.ts @@ -0,0 +1,37 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as path from 'path'; +import { ESLintAdapter } from '../src/adapters/eslint'; +import { mergeResults } from '../src/merger/merger'; + +suite('Pipeline Tests', () => { + test('Full pipeline: linter check + merge', async () => { + const adapter = new ESLintAdapter(); + if (!adapter.isAvailable()) { return; } + + const doc = await vscode.workspace.openTextDocument({ + content: 'var x = 1;\nvar y = 2;\n', + language: 'javascript', + }); + + const staticResult = await adapter.check(doc, __dirname); + assert.ok(staticResult.status === 'ok'); + + const report = mergeResults({ + staticDiagnostics: staticResult.diagnostics, + customRuleResults: [], + translatedDiagnostics: [], + aiFindings: [], + errors: [], + degraded: false, + startTime: Date.now(), + filePath: 'virtual-doc', + language: 'javascript', + adapterIds: ['eslint'], + }); + + assert.ok(typeof report.duration === 'number'); + assert.ok(typeof report.linterCount === 'number'); + assert.strictEqual(report.language, 'javascript'); + }); +}); diff --git a/tests/rule-filter.test.ts b/tests/rule-filter.test.ts new file mode 100644 index 0000000..9024cce --- /dev/null +++ b/tests/rule-filter.test.ts @@ -0,0 +1,145 @@ +import * as assert from 'assert'; +import { filterForDocument, filterAndSummarize } from '../src/rules/rule-filter'; +import type { CustomRule } from '../src/types'; + +function mockDoc(languageId: string, fileName: string): { languageId: string; fileName: string } { + return { languageId, fileName }; +} + +const baseRules: CustomRule[] = [ + { id: 'java-rule', severity: 'error', description: '', message: '', languages: ['java'] }, + { id: 'js-rule', severity: 'error', description: '', message: '', languages: ['javascript'] }, + { id: 'ts-rule', severity: 'error', description: '', message: '', languages: ['typescript'] }, + { id: 'css-rule', severity: 'error', description: '', message: '', languages: ['css'] }, + { id: 'universal', severity: 'warning', description: '', message: '' }, + { id: 'no-css', severity: 'info', description: '', message: '', excludeLanguages: ['css'] }, + { id: 'empty-langs', severity: 'info', description: '', message: '', languages: [] }, +]; + +suite('Rule Filter Tests', () => { + + test('白名单命中', () => { + const doc = mockDoc('java', '/test/Foo.java'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(ids.includes('java-rule')); + assert.ok(ids.includes('universal')); + }); + + test('白名单未命中', () => { + const doc = mockDoc('java', '/test/Foo.java'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(!ids.includes('js-rule')); + assert.ok(!ids.includes('css-rule')); + }); + + test('白名单为空→全语言保留', () => { + const doc = mockDoc('css', '/test/test.css'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(ids.includes('universal')); + assert.ok(ids.includes('empty-langs')); + }); + + test('白名单缺失→全语言保留', () => { + const doc = mockDoc('css', '/test/test.css'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(ids.includes('universal')); + }); + + test('黑名单命中→剔除', () => { + const doc = mockDoc('css', '/test/test.css'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(!ids.includes('no-css')); + }); + + test('黑名单未命中→保留', () => { + const doc = mockDoc('java', '/test/Foo.java'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(ids.includes('no-css')); + }); + + test('黑名单为空→保留', () => { + const rules: CustomRule[] = [ + { id: 'r1', severity: 'info', description: '', message: '', excludeLanguages: [] }, + ]; + const doc = mockDoc('css', '/test/test.css'); + const result = filterForDocument(rules, doc as any); + assert.strictEqual(result.length, 1); + }); + + test('白名单+黑名单交集→黑名单胜出剔除', () => { + const rules: CustomRule[] = [ + { id: 'r1', severity: 'info', description: '', message: '', languages: ['java'], excludeLanguages: ['java'] }, + ]; + const doc = mockDoc('java', '/test/Foo.java'); + const result = filterForDocument(rules, doc as any); + assert.strictEqual(result.length, 0); + }); + + test('typescriptreact 别名→命中 typescript 规则', () => { + const doc = mockDoc('typescriptreact', '/test/App.tsx'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(ids.includes('ts-rule')); + }); + + test('plsql 同组→命中 sql 规则', () => { + const rules: CustomRule[] = [ + { id: 'sql-rule', severity: 'error', description: '', message: '', languages: ['sql'] }, + { id: 'plsql-rule', severity: 'error', description: '', message: '', languages: ['plsql'] }, + ]; + const doc = mockDoc('plsql', '/test/test.plsql'); + const result = filterForDocument(rules, doc as any); + const ids = result.map(r => r.id); + assert.ok(ids.includes('sql-rule')); + assert.ok(ids.includes('plsql-rule')); + }); + + test('JSP 并集→保留 java 规则', () => { + const doc = mockDoc('html', '/test/test.jsp'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(ids.includes('java-rule')); + assert.ok(ids.includes('js-rule')); + assert.ok(ids.includes('ts-rule')); + assert.ok(ids.includes('css-rule')); + }); + + test('JSP 并集→保留 css 规则', () => { + const doc = mockDoc('html', '/test/test.jspx'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(ids.includes('css-rule')); + }); + + test('普通HTML不触发JSP→剔除java规则', () => { + const doc = mockDoc('html', '/test/index.html'); + const result = filterForDocument(baseRules, doc as any); + const ids = result.map(r => r.id); + assert.ok(!ids.includes('java-rule')); + }); + + test('全部过滤→skippedRequestA=true', () => { + const rules: CustomRule[] = [ + { id: 'java-rule', severity: 'error', description: '', message: '', languages: ['java'] }, + ]; + const doc = mockDoc('css', '/test/test.css'); + const result = filterAndSummarize(rules, doc as any); + assert.strictEqual(result.relevant.length, 0); + assert.strictEqual(result.filteredOut.length, 1); + assert.strictEqual(result.skippedRequestA, true); + }); + + test('部分过滤→skippedRequestA=false', () => { + const doc = mockDoc('java', '/test/Foo.java'); + const result = filterAndSummarize(baseRules, doc as any); + assert.ok(result.relevant.length > 0); + assert.ok(result.filteredOut.length > 0); + assert.strictEqual(result.skippedRequestA, false); + }); +}); diff --git a/tests/sqlfluff-prs.test.ts b/tests/sqlfluff-prs.test.ts new file mode 100644 index 0000000..36c00a6 --- /dev/null +++ b/tests/sqlfluff-prs.test.ts @@ -0,0 +1,36 @@ +import * as assert from 'assert'; +import { buildPRSMessage } from '../src/adapters/sqlfluff'; + +suite('SqlFluff PRS Tests', () => { + test('buildPRSMessage extracts fragment from description', () => { + const desc = "Line 7, Position 3: Found unparsable section: 'engine=innodb default charset=utf8mb4;'"; + const msg = buildPRSMessage(desc, 'oracle'); + + assert.ok(msg.includes('oracle')); + assert.ok(msg.includes("engine=innodb default charset=utf8mb4;")); + }); + + test('buildPRSMessage flattens newlines in fragment', () => { + const desc = "Found unparsable section: 'create table orders (\n id int\n);'"; + const msg = buildPRSMessage(desc, 'mysql'); + + assert.ok(msg.includes('\\n')); + assert.ok(!msg.includes('\n')); + }); + + test('buildPRSMessage truncates long fragments', () => { + const longFragment = 'x'.repeat(500); + const desc = `Found unparsable section: '${longFragment}'`; + const msg = buildPRSMessage(desc, 'ansi'); + + assert.ok(msg.includes('...')); + assert.ok(!msg.includes(longFragment)); + }); + + test('buildPRSMessage falls back to description when pattern missing', () => { + const desc = 'unexpected parse error output'; + const msg = buildPRSMessage(desc, 'ansi'); + + assert.ok(msg.includes(desc)); + }); +}); diff --git a/tests/sqlfluff-range.test.ts b/tests/sqlfluff-range.test.ts new file mode 100644 index 0000000..652a730 --- /dev/null +++ b/tests/sqlfluff-range.test.ts @@ -0,0 +1,81 @@ +import * as assert from 'assert'; +import { resolveSqlFluffRange } from '../src/adapters/sqlfluff'; + +suite('SqlFluff Range Tests', () => { + function assertValid([sl, sp, el, ep]: [number, number, number, number]): void { + assert.ok(Number.isFinite(sl), 'startLine must be finite'); + assert.ok(Number.isFinite(sp), 'startPos must be finite'); + assert.ok(Number.isFinite(el), 'endLine must be finite'); + assert.ok(Number.isFinite(ep), 'endPos must be finite'); + assert.ok(sl >= 0, 'startLine must be >= 0'); + assert.ok(sp >= 0, 'startPos must be >= 0'); + assert.ok(el >= sl, 'endLine must be >= startLine'); + } + + test('full positions convert to zero-based range', () => { + const r = resolveSqlFluffRange({ + start_line_no: 2, + start_line_pos: 4, + end_line_no: 2, + end_line_pos: 9, + code: 'JJ01', + description: 'x', + }); + assert.deepStrictEqual(r, [1, 3, 1, 8]); + assertValid(r); + }); + + test('missing end positions fall back to start (comment JJ01 case)', () => { + const r = resolveSqlFluffRange({ + start_line_no: 2, + start_line_pos: 1, + code: 'JJ01', + description: 'x', + }); + assert.deepStrictEqual(r, [1, 0, 1, 0]); + assertValid(r); + }); + + test('legacy line_no/line_pos keys are honored', () => { + const r = resolveSqlFluffRange({ + line_no: 3, + line_pos: 5, + code: 'LT12', + description: 'x', + }); + assert.deepStrictEqual(r, [2, 4, 2, 4]); + assertValid(r); + }); + + test('null positions fall back to defaults', () => { + const r = resolveSqlFluffRange({ + start_line_no: null, + start_line_pos: null, + end_line_no: null, + end_line_pos: null, + code: 'TMP', + description: 'x', + }); + assert.deepStrictEqual(r, [0, 0, 0, 0]); + assertValid(r); + }); + + test('zero positions are clamped to valid values', () => { + const r = resolveSqlFluffRange({ + start_line_no: 1, + start_line_pos: 0, + end_line_no: 1, + end_line_pos: 0, + code: 'TMP', + description: 'x', + }); + assert.deepStrictEqual(r, [0, 0, 0, 0]); + assertValid(r); + }); + + test('missing all positions produce a safe zero range', () => { + const r = resolveSqlFluffRange({ code: 'PRS', description: 'x' }); + assert.deepStrictEqual(r, [0, 0, 0, 0]); + assertValid(r); + }); +}); diff --git a/tests/test-cases.md b/tests/test-cases.md new file mode 100644 index 0000000..5660911 --- /dev/null +++ b/tests/test-cases.md @@ -0,0 +1,221 @@ +# 测试用例清单 + +来源:`tests/*.test.ts` 中 `suite()` / `test()` 声明。共 **16 个测试文件、116 条用例**。 + +## 1. adapter.test.ts — Adapter Tests(6) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 1 | ESLintAdapter has correct id and languages | 适配器标识与支持语言 | +| 2 | StylelintAdapter has correct id and languages | 适配器标识与支持语言 | +| 3 | ESLintAdapter check returns AdapterResult structure | 静态分析返回结构 | +| 4 | ESLintAdapter surfaces octal literal as eslint:parse-error | 八进制字面量解析错误上报 | +| 5 | ESLintAdapter surfaces \8 escape as eslint:parse-error | 非法转义解析错误上报 | +| 6 | ESLintAdapter parses JSX in .js without parse error | JSX 语法解析 | + +## 2. ai-empty-response.test.ts — AI Empty Response Handling(6) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 7 | parseJsonResponse throws empty-response error on blank input | 空响应识别 | +| 8 | parseJsonResponse parses valid JSON normally | 正常 JSON 解析 | +| 9 | chatWithRetry retries once on EmptyContentError | 空内容自动重试 | +| 10 | chatWithRetry propagates error when retry also returns empty | 重试仍空则抛错 | +| 11 | chatWithRetry does not retry on non-empty-content errors | 非空内容错误不重试 | +| 12 | openai-compatible reports max_tokens truncation clearly | token 截断提示 | + +## 3. ai-fix-engine.test.ts — AI FixEngine Tests(4) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 13 | aiFixDiagnostic applies AI fix and converges | AI 修复收敛 | +| 14 | aiFixDiagnostic returns ai-match-failed when originalText not found | 原文未匹配 | +| 15 | aiFixDiagnostic returns ai-no-fix when AI provides empty fix | AI 无修复 | +| 16 | aiFixDiagnostic retries until issue resolved within maxIterations | 迭代上限内收敛 | + +## 4. config.test.ts — Config Tests(4) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 17 | getAIConfig returns default values | AI 配置默认值 | +| 18 | AI individual getters return defaults | 单项配置 getter | +| 19 | getLinterForLanguage returns configured linter | 语言→linter 映射 | +| 20 | getFixMaxIterations returns default | 修复迭代默认值 | + +## 5. customFixEngine.test.ts — Custom FixEngine Tests(5) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 21 | aiFixReviewIssue applies AI fix and converges after verify | 自定义规则 AI 修复收敛 | +| 22 | aiFixReviewIssue returns ai-match-failed when originalText not found | 原文未匹配 | +| 23 | aiFixReviewIssue retries until verify passes within maxIterations | 验证驱动重试 | +| 24 | aiFixReviewIssue accepts last fix when verify never passes | 验证不过时接受最后修复 | +| 25 | aiFixReviewIssue retries empty fix once then fails with ai-no-fix | 空修复重试一次后失败 | + +## 6. dedup-prompt.test.ts — Dedup Prompt Tests(3) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 26 | buildDedupOnlyPrompt 同时提供静态规则与自定义规则 | 去重 prompt 组装 | +| 27 | 自定义规则时包含静态规则 | 去重 prompt 规则合并 | +| 28 | buildKnownRulesSection 生成的规则包含全部 | 已知规则段完整性 | + +## 7. diagnostics.test.ts — DiagnosticMarkers Tests(6) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 29 | toVscodeDiagnostics maps severity correctly | 严重级别映射 | +| 30 | toVscodeDiagnostics preserves range | 范围保留 | +| 31 | toVscodeDiagnostics prefixes message with plugin and linter | 消息前缀 | +| 32 | toVscodeDiagnostics handles ruleId without linter prefix | 无前缀 ruleId | +| 33 | toVscodeDiagnostics sets source and code for quick fix hover | hover 修复来源/代码 | +| 34 | toVscodeDiagnostics returns empty array for empty input | 空输入 | + +## 8. fixEngine.test.ts — FixEngine Tests(5) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 35 | ESLint adapter attaches fix object for autofixable issues | 可自动修复诊断挂 fix | +| 36 | fixDiagnostic multi-round convergence produces applied fixes | 多轮修复收敛 | +| 37 | fixDiagnostic returns not-autofixable for non-fixable rule | 不可修复规则 | +| 38 | fixDiagnostic no-change returns no-change | 无变化场景 | +| 39 | fixDiagnostic fixes only the targeted issue, not adjacent same-rule instance | 定点修复不误伤相邻同类 | + +## 9. fixSession.test.ts — FixSession Tests(6) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 40 | add/get/has roundtrip | 会话读写 | +| 41 | get returns undefined for missing key | 缺失键 | +| 42 | getEntries filters per uri | 按 uri 过滤 | +| 43 | clear removes entries for uri only | 按 uri 清理 | +| 44 | recordFixes merges multi-round fixes under same key | 多轮修复合并 | +| 45 | undo restores original text via workspace edit | 撤销恢复原文 | + +## 10. import-dedup.test.ts — Import Dedup Tests(25) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 46 | 重复规则:全局检查并标注 | 全局重复检查 | +| 47 | 全局 exact:全部标注 | exact 去重 | +| 48 | 全局 overlap:全部检查并标注 | overlap 去重 | +| 49 | 跨规则 exact 标注(overlap 归 none) | 跨规则 exact 归属 | +| 50 | 用户指定 exact 后取消标注 | 用户决策 exact | +| 51 | 用户标注 overlap 将标注加 # 前缀 | overlap 标注头 | +| 52 | 用户标注 none 加 # 前缀 | none 标注头 | +| 53 | 已标注 duplicateLevel/duplicateOf/duplicateReason 剔出标注 | 已标注剔除 | +| 54 | 标注规则 duplicateReason 标注头回填原由 | 原因回填 | +| 55 | 编辑 description 确认 YAML 使用该值 | description 编辑 | +| 56 | 编辑 severity 确认 YAML 使用该 severity | severity 编辑 | +| 57 | 编辑 languages 确认 YAML 有 languages | languages 编辑 | +| 58 | 编辑切换为标注、标注转为编辑值 | 编辑/标注互转 | +| 59 | 无编辑时保留原始 yamlContent | 原始内容保留 | +| 60 | severity 缺失默认为 warning | severity 默认 | +| 61 | severity 非法值默认为 warning | severity 兜底 | +| 62 | id 缺失生成 rule-N | id 自动命名 | +| 63 | 多个 id 缺失依次 rule-1, rule-2... | 递增命名 | +| 64 | description 缺失时使用 message(中文存在) | 双语回退 | +| 65 | message 缺失时使用 description(中文存在) | 双语回退 | +| 66 | description 与 message 同时缺失时兜底 | 全缺失兜底 | +| 67 | 重复规则的重复校验回归 | 去重回归 | +| 68 | id 带引号、字段带引号保留 id | 引号 id | +| 69 | scalar 字段带引号、保留引号 | 引号 scalar | + +## 11. merger.test.ts — Merger Tests(12) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 70 | mergeResults counts correctly | 计数正确 | +| 71 | mergeResults marks degraded when AI fails | AI 失败降级 | +| 72 | mergeResults sorts diagnostics by severity then line | 排序 | +| 73 | mergeResults marks fixable only diagnostics with fix object | 可修复标记 | +| 74 | mergeResults converts aiFindings line to 0-based | 行号 0 基化 | +| 75 | mergeResults pairs translations by originalRuleId not index | 按规则 id 配对翻译 | +| 76 | mergeResults handles duplicate ruleId translations in order | 重复 id 有序 | +| 77 | mergeResults matches translations by normalized ruleId when prefix missing | 缺前缀归一化匹配 | +| 78 | mergeResults matches translations by normalized ruleId with slash variant | 斜杠变体匹配 | +| 79 | mergeResults prefers exact ruleId match over normalized | 精确匹配优先 | +| 80 | mergeResults computes aiFixableLinterIndices excluding native fix and sqlfluff | AI 可修复索引 | +| 81 | mergeResults aiFixableLinterIndices empty when AI unavailable | AI 不可用置空 | + +## 12. messages.test.ts — I18n Tests(9) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 82 | all message keys have values for all three languages | 三语 key 完整 | +| 83 | default language is zh-CN | 默认语言 | +| 84 | setLanguage changes current language | 语言切换 | +| 85 | t() falls back to key when key does not exist | 缺失 key 回退 | +| 86 | t() returns zh-CN string in zh-CN language | 中文翻译 | +| 87 | t() returns English string in en language | 英文翻译 | +| 88 | t() returns Japanese string in ja language | 日文翻译 | +| 89 | t() with template variables | 模板变量 | +| 90 | t() with multiple template variables | 多模板变量 | + +## 13. pipeline.test.ts — Pipeline Tests(1) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 91 | Full pipeline: linter check + merge | 全链路(linter + 合并) | + +## 14. rule-filter.test.ts — Rule Filter Tests(15) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 92 | 规则匹配当前语言 | 语言匹配 | +| 93 | 规则未匹配被过滤 | 不匹配过滤 | +| 94 | 规则为空:全部保留 | 空规则集 | +| 95 | 规则缺失:全部保留 | 缺失过滤 | +| 96 | 规则按语言匹配后去重 | 去重 | +| 97 | 规则未匹配语言则去除 | 语言不匹配去除 | +| 98 | 规则为空时去重 | 空集去重 | +| 99 | 规则 + 语言映射匹配时过滤结果优先 | 优先级 | +| 100 | typescriptreact 同源映射到 typescript | TSX 映射 | +| 101 | plsql 同源映射到 sql | PL/SQL 映射 | +| 102 | JSP 映射 java 规则 | JSP→Java | +| 103 | JSP 不映射 css 规则 | JSP→CSS 排除 | +| 104 | 普通 HTML 触发 JSP 过滤 java 规则 | HTML→JSP | +| 105 | 全部过滤触发 skippedRequestA=true | 全过滤标记 | +| 106 | 部分过滤触发 skippedRequestA=false | 部分过滤标记 | + +## 15. sqlfluff-prs.test.ts — SqlFluff PRS Tests(4) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 107 | buildPRSMessage extracts fragment from description | PRS 片段提取 | +| 108 | buildPRSMessage flattens newlines in fragment | 换行压平 | +| 109 | buildPRSMessage truncates long fragments | 长片段截断 | +| 110 | buildPRSMessage falls back to description when pattern missing | 无匹配回退 | + +## 16. sqlfluff-range.test.ts — SqlFluff Range Tests(6) + +| # | 用例 | 覆盖点 | +|---|---|---| +| 111 | full positions convert to zero-based range | 完整位置 0 基化 | +| 112 | missing end positions fall back to start (comment JJ01 case) | 缺终点回退(JJ01) | +| 113 | legacy line_no/line_pos keys are honored | 旧版 key 兼容 | +| 114 | null positions fall back to defaults | null 兜底 | +| 115 | zero positions are clamped to valid values | 零值钳制 | +| 116 | missing all positions produce a safe zero range | 全缺失安全范围 | + +## 统计 + +| 文件 | 用例数 | +|---|---| +| adapter.test.ts | 6 | +| ai-empty-response.test.ts | 6 | +| ai-fix-engine.test.ts | 4 | +| config.test.ts | 4 | +| customFixEngine.test.ts | 5 | +| dedup-prompt.test.ts | 3 | +| diagnostics.test.ts | 6 | +| fixEngine.test.ts | 5 | +| fixSession.test.ts | 6 | +| import-dedup.test.ts | 25 | +| merger.test.ts | 12 | +| messages.test.ts | 9 | +| pipeline.test.ts | 1 | +| rule-filter.test.ts | 15 | +| sqlfluff-prs.test.ts | 4 | +| sqlfluff-range.test.ts | 6 | +| **合计** | **116** | diff --git a/tests/test-execution-log.md b/tests/test-execution-log.md new file mode 100644 index 0000000..3b5027a --- /dev/null +++ b/tests/test-execution-log.md @@ -0,0 +1,42 @@ +# 测试执行日志 + +## 一、执行信息 + +| 项目 | 内容 | +|---|---| +| 执行日期 | 2026-08-26 | +| 测试命令 | `npm test` | +| 前置链路 | `lint 0 error → compile 通过 → compile:test 通过` | +| 测试运行器 | `@vscode/test-cli`(.vscode-test.mjs:`out/tests/**/*.test.js`) | +| 插件版本 | 1.3.0 | +| 测试文件数 | 16 个 `*.test.ts` | + +## 二、执行结果摘要 + +| 指标 | 结果 | +|---|---| +| 通过(passing) | **116** | +| 失败(failing) | 0 | +| 跳过(pending) | 0 | +| 运行时长 | ~5s(Extension Host 退出码 0) | + +> 详细逐条用例清单见 `tests/test-cases.md`。 + +## 三、运行环境 + +| 项目 | 内容 | +|---|---| +| 测试宿主 | VSCode Extension Test Host(@vscode/test-electron) | +| 操作系统 | Windows(win32) | +| Node.js | 插件 devDependencies 锁定版本(@types/node 22.x) | +| 编译 | `tsc -p ./` + `node scripts/copy-webview-js.mjs`(主工程);`tsc -p ./tsconfig.test.json`(测试工程) | +| Lint | `eslint src`:0 error(仅 `src/utils/mockDocument.ts` 2 处既有 curly warning) | + +## 四、复现方式 + +```bash +npm run lint # eslint src → 0 error +npm run compile # tsc 主工程 +npm run compile:test # tsc 测试工程(out/tests) +npm test # @vscode/test-cli 运行 out/tests/**/*.test.js +``` diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..781fe98 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "outDir": "out", + "lib": [ + "ES2022" + ], + "sourceMap": true, + "rootDir": ".", + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": [ + "tests/**/*", + "src/types/**/*" + ], + "exclude": [ + "node_modules", + "out" + ] +} diff --git a/vscode-code-reviewer-1.0.0.vsix b/vscode-code-reviewer-1.0.0.vsix deleted file mode 100644 index 5031533..0000000 Binary files a/vscode-code-reviewer-1.0.0.vsix and /dev/null differ