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

- 适配器 i18n 接入(eslint/pmd/sql-lint/stylelint)
- Provider 动态注册机制(registry.ts + providers.json + factory 重构)
- SetupView 全面重构(setupView.ts 新增 600+ 行)
- i18n 消息扩展(messages.ts +210 行)
- 规则导入流程优化(import-service / prompt-builder)
- 新增 PMD jars 依赖及测试用例
This commit is contained in:
范智鹏
2026-07-28 22:57:15 +08:00
parent 83712aa260
commit effcf30802
72 changed files with 6568 additions and 238 deletions
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1784553065353}
@@ -0,0 +1 @@
{"reason":"owner process exited","timestamp":1784551347138}
+1
View File
@@ -17,3 +17,4 @@ docs/**
scripts/**
**/*.d.ts
.opencode/**
tech-support-flowchart/**
+21 -3
View File
@@ -108,15 +108,33 @@
| 2026-07-25 22:57 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 规则预览编辑:规则卡片折叠/展开编辑表单(severity下拉框、description/message多行文本、languages/excludeLanguages标签输入、id只读);renderRulesToYaml 从 rules 数组生成 YAML;无编辑时回退原始逻辑;补充5个编辑场景测试 | import-preview.ts 初版尝试 extension 端实时追踪 modifiedRulesupdateRule 消息),后改为 confirm 时 webview 端一次性收集 DOM 值发送;测试断言 'java' 子串匹配 'javascript' 误判 + 注释行缩进不匹配,各迭代一次修复 | src/rules/import-types.ts src/rules/import-service.ts src/rules/import-preview.ts src/test/import-dedup.test.ts docs/superpowers/specs/2026-07-25-import-preview-edit-design.md | deepseek-v4-flash |
| 2026-07-25 23:08 | ① 用户提出 → ⑤ 编码实现 | 预览编辑表单 UI 优化:id 字段添加标签说明;每个字段标签后加括号注释(如 severity(严重级别)) | 无 | src/rules/import-preview.ts | deepseek-v4-flash |
| 2026-07-25 23:45 | ① 用户提出 → ⑤ 编码实现 | 预览编辑表单布局调整:移除顶部多余 id 大标题;id 标签与值同行 + 切换按钮同行右对齐;id 样式简化为纯文本 | id 位置迭代:一行 → 上下 → 一行 + 按钮同行 | src/rules/import-preview.ts | deepseek-v4-flash |
| 2026-07-25 23:50 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 去除代码审查报告画面底部的设置按钮(消息类型、HTML 按钮、消息处理 handler 全部移除) | 无 | src/panel/webview.ts | deepseek-v4-flash-free |
| 2026-07-25 23:50 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 去除代码审查报告画面底部的设置按钮(消息类型、HTML 按钮、消息处理 handler 全部移除) | 无 | src/panel/webview.ts | deepseek-v4-flash |
| 2026-07-26 00:43 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | i18n 国际化:复用 ai.outputLanguage 控制 UI 语言;src/i18n/messages.ts 单文件注册 ~100 条文案(Language 类型、t/setLanguage/onLanguageChange/getLanguage);配置变更 → onDidChangeConfiguration → 自动切换;webview 全量重渲染 + 语言下拉即时生效;commands.ts/report.ts/setupView.ts/webview.ts/import-preview.ts + 适配器/AI引擎/converter 错误消息全部替换为 t();提示词与 package.json 保持不变 | 提示词翻译 vs 不翻译(最终不翻译);方案 A 单文件 vs 方案 B 多 JSON(最终选 A);语言切换同步 setLanguage vs 纯配置驱动(最终选配置驱动单一入口);import-preview.ts 尝试用 esc() 导致编译错误 → 改为直接传入 sourceFileName | docs/superpowers/specs/2026-07-26-i18n-design.md src/i18n/messages.ts src/test/messages.test.ts src/extension.ts src/activation/commands.ts src/panel/webview.ts src/views/setupView.ts src/views/import-preview.ts src/utils/report.ts src/adapters/pmd.ts src/adapters/sql-lint.ts src/ai/engine.ts src/ai/providers/openai-compatible.ts src/rules/import-service.ts src/rules/converters/excel-converter.ts src/rules/converters/docx-converter.ts src/rules/converters/pptx-converter.ts | deepseek-v4-pro |
| 2026-07-26 13:00 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 语言设置标签修正:节标题"输出语言"→"语言"、字段提示"AI 审查结果输出语言"→"更改插件的显示语言"、package.json description"AI 输出语言"→"插件语言"(三语同步更新) | 无 | src/i18n/messages.ts package.json | deepseek-v4-flash |
| 2026-07-26 13:27 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | i18n 补全:report 面板(injectedCount/injectedRules/issuesCount/itemsCount)和 import-preview(全部文本标签/统计/校验消息)共 26 条新键三语,替换 webview.ts 4 处 + import-preview.ts 全部硬编码中文 | 无 | src/i18n/messages.ts src/panel/webview.ts src/rules/import-preview.ts | deepseek-v4-flash |
| 2026-07-26 14:16 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | AI 转换器输出语言修复:5 个 convertermd/txt/excel/docx/pptx)和 import-service.ts fallback prompt 去掉字段描述中的(中文)硬编码,追加 `\n输出语言:${getAIOutputLanguage()}` | 无 | src/rules/converters/md-converter.ts src/rules/converters/txt-converter.ts src/rules/converters/excel-converter.ts src/rules/converters/docx-converter.ts src/rules/converters/pptx-converter.ts src/rules/import-service.ts | deepseek-v4-flash |
| 2026-07-26 14:54 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 规则名称空值时输入框红框+错误提示文字 | 无 | src/i18n/messages.ts src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash-free |
| 2026-07-26 15:26 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | repairJsonEscapes 去掉 replace 无差别解引号,防止 AI 正确转义的 \" 被破坏导致 JSON 解析失败 | 无 | src/ai/engine.ts | deepseek-v4-flash-free |
| 2026-07-26 14:54 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 规则名称空值时输入框红框+错误提示文字 | 无 | src/i18n/messages.ts src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-26 15:26 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | repairJsonEscapes 去掉 replace 无差别解引号,防止 AI 正确转义的 \" 被破坏导致 JSON 解析失败 | 无 | src/ai/engine.ts | deepseek-v4-flash |
| 2026-07-26 21:43 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 自定义规则导入体验改进:提示词增强(集中到 prompt-builder.ts 的 buildSystemPrompt,增加输入容忍说明/非规则过滤/id+severity推断/description+message互推/对照示例)+ 解析侧兜底(severity缺/非法→warningid缺→rule-Ndesc+msg皆缺→丢弃)+ 预览 id 可编辑(header+panel 双 input 同步,占位 id 橙色高亮,校验加 id 非空)+ 5 个 converter 移除本地 buildSystemPrompt 改为调用公共函数 + 8 个新增测试 | 设计文档 docs/superpowers/specs/2026-07-26-custom-rule-import-ux-design.md(用户已提供);Stage ② 澄清:导入入口指引去掉/desc-message互填策略「AI提示词要求,解析侧仅两人皆缺才丢弃」/提示词抽公共/id两处同步/占位 id 保留高亮 | src/rules/converters/prompt-builder.ts src/rules/converters/md-converter.ts src/rules/converters/txt-converter.ts src/rules/converters/docx-converter.ts src/rules/converters/pptx-converter.ts src/rules/converters/excel-converter.ts src/rules/import-service.ts src/rules/import-preview.ts src/i18n/messages.ts src/test/import-dedup.test.ts | deepseek-v4-pro |
| 2026-07-26 22:00 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 语言下拉框选项文本固定为母语名称(中文(简体)/English/日本語),不再随界面语言切换而翻译;删除未使用的 getLanguageLabel 函数和 languageLabel 字段 | 无 | src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-26 22:28 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | AI 规则导入提示词三语支持:prompt-builder.ts 重构为多语言模板(zh-CN/en/ja),AI 直接看到目标语言的完整提示词,不再依赖单行语言指令切换;import-service.ts fallback prompt 和 YAML 注释(重复/重叠/手动注释等)通过 t() 本地化 | 配置项设计:先用 Record<Language, PromptStrings> 结构组织多语言文本 | src/rules/converters/prompt-builder.ts src/rules/import-service.ts src/i18n/messages.ts | deepseek-v4-flash |
| 2026-07-26 22:41 | ⑤ 编码实现 → ⑥ 审查验证 | 修复日语提示词遗漏:角色描述开头追加「すべての説明とメッセージは日本語で出力してください」;finalInstruction 追加日语输出指令;示例标签 输入/输出/Input/Output 按语言本地化 | 无 | src/rules/converters/prompt-builder.ts | deepseek-v4-flash |
| 2026-07-26 22:51 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | AI 引擎提示词三语化:engine.ts 的 CUSTOM_RULE_SYSTEM_PROMPT 和 DEEP_REVIEW_SYSTEM_PROMPT 从硬编码中文改为三语模板(zh-CN/en/ja),内置输出语言指令;parseJsonResponse 错误消息改为 t() 本地化;用户消息标签(自定义规则/代码/静态分析结果)按语言本地化 | 无 | src/ai/engine.ts src/i18n/messages.ts | deepseek-v4-flash |
| 2026-07-27 20:30 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 静态分析适配器优化:三层配置模式(全局>项目>内置)+ 侧边栏适配器配置面板(4张卡片/开关/依赖检测/配置文件模板)+ 6个新配置项 + 9个文件变更 | 设计文档 docs/superpowers/specs/2026-07-27-adapter-optimization-design.md(用户提供已定稿);Stage ② 澄清 8 个问题(orchestrator 保持一对一调度/ESLint 移除 overrideConfigFile/jarPath 实际接入/sql-lint configFile 实际接入/JSP 不纳入面板/setupView.js 无需构建变更/ESLint 保持现有 fallback/configFile 显式传递) | package.json src/config/linter.ts src/adapters/eslint.ts src/adapters/stylelint.ts src/adapters/pmd.ts src/adapters/sql-lint.ts src/orchestrator/orchestrator.ts src/views/setupView.ts src/views/setupView.js docs/superpowers/specs/2026-07-27-adapter-optimization-design.md | deepseek-v4-flash |
| 2026-07-27 21:24 | ① 用户提出 → ② 需求澄清 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 PMD/SQL-Lint 的 configured 徽章逻辑:新增 `dependencyStatus === 'ready'` 条件,依赖就绪时 builtin 模式也显示"已配置";重新打包 v1.1.0 | 先用了 `configMode !== 'builtin' || !meta.hasExternalDependency` 导致 PMD/SQL-Lint 在 builtin+依赖就绪时仍显示"未配置",用户指正后改为 `!meta.hasExternalDependency || configMode !== 'builtin' || dependencyStatus === 'ready'` | src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-27 22:01 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 静态分析适配器设置并入审核引擎:共通规则标签改为点击展开/收起,展开后显示适配器卡片;tab 加箭头指示器 + 已启用计数徽章 | 先编辑 out/webview/setupView.js 后发现 npm run compile 会从 src 覆盖过来,补改 src/views/setupView.js;先加了 CSS 后发现 .engine-tab flex-direction:column 导致箭头/圆点/文字/徽章垂直堆叠,补了 .engine-tab-row 水平容器 | src/views/setupView.ts src/views/setupView.js out/webview/setupView.js | deepseek-v4-flash |
| 2026-07-27 22:05 | ① 用户提出 → ② 需求澄清 → ⑤ 编码实现 → ⑥ 审查验证 | 共通规则标签布局调整:箭头移至右下角;恢复圆点/标签/描述的列布局与其他标签一致;已启用计数徽章移至右上与圆点同行;清理废弃 CSS.engine-tab-row、margin-left:auto | 布局迭代:先水平行→用户说跟其他标签不一致→改为列布局配 .engine-tab-top(圆点+徽章同行)+ .engine-tab-footer(箭头) | src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-27 22:15 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 适配器卡片新增语言说明行「可审查的语言:...」;ADAPTER_METADATA/AdapterConfigStatus 新增 languages 字段并透传渲染 | 设计:先给"语言"前缀→用户说太笼统→改为"适用"→用户改为"可审查的语言:";PMD 语言范围讨论后定为"Java(含 JSP 中的 Java 代码)" | src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-27 22:35 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 按钮重命名(配置文件→创建项目配置,VS Code设置→修改全局设置)+ 配置模式图例(内置/项目/全局三色说明)+ 全部 tooltip 统一为自定义 CSS 主题风格,覆盖标签/开关/按钮 | 无 | src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-27 22:57 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | tooltip 方向调整:开关按钮提示显示在左侧,创建项目配置按钮提示显示在右侧,避免超出侧边栏边界 | 先加 max-width+white-space 换行被用户否决,用户提出左右方向方案 | src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-27 23:12 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 适配器面板 UI + 配置文件模板 i18n 全面接入:28 条新 i18n 键;HTML 模板硬编码替换为 t();模板常量改为运行时函数;setupView.js 全篇硬编码替换为 msg.i18n | 无 | src/i18n/messages.ts src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-27 23:18 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 适配器 guideText/languages i18nADAPTER_METADATA 移除硬编码字段,collectAdapterStatus() 改用 t() 按语言生成 | 先考虑过保留 ADAPTER_METADATA 字段 + t() 组合方式后选择删除字段+运行时生成 | src/i18n/messages.ts src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-28 19:00 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 规则导入 AI 输出稳定性优化:ChatOptions 加 seed、OpenAI 请求体条件加 seed、import-service temperature→0 maxTokens→8192 seed=42、新增 normalizeRuleIds()、prompt-builder 三语言追加顺序/id/severity/格式稳定性约束 | 设计书由用户提供(design-rules-import-stability.md | src/ai/providers/base.ts src/ai/providers/openai-compatible.ts src/rules/import-service.ts src/rules/converters/prompt-builder.ts | deepseek-v4-flash |
| 2026-07-28 21:02 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 设置画面自定义规则标签改为可点击展开面板,右上角规则计数 badge 颜色对齐 amber 原点,展开面板添加边框框线 | 无 | src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-28 21:10 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | AI 审核标签改为可点击展开面板,右上角连接状态 badge,展开显示提供商/模型/语言状态 + 审查能力说明 | 无 | src/i18n/messages.ts src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-28 21:15 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 新增"AI 连接配置"区块(provider/model/apiKey/baseUrl),放在审核引擎上方,删除下方重复的③ AI 模型配置和④ API Key 区块 | 无 | src/i18n/messages.ts src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-28 21:20 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 语言下拉移至 header 右侧,删除独立语言区块 | 无 | src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-28 21:30 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 连接状态持久化:globalState 存储 connectionState(含配置指纹),重新打开插件时恢复,AI 配置变化时清除 | 无 | src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-28 22:27 | ① 用户提出 → ② 需求澄清 → ③ 方案设计→ ④ 人类审批 | 基于当前代码生成「AI 供应商与模型动态化设计书」:更新所有行号引用、代码片段、UI 结构描述;补充遗漏的 import-service.ts 第 382 行 createProvider() 调用点(第 08 节和第 10 节);修复 factory.ts 改造代码中 getAllProviderMeta() 无限递归 bug(改用 import alias | 无 | docs/superpowers/specs/2026-07-28-provider-registry-dynamic-design.md | GLM-5.2 |
| 2026-07-28 22:36 | ⑤ 编码实现 → ⑥ 审查验证 | 实现供应商注册表动态化:新建 providers.json / src/ai/types.ts / src/ai/registry.ts;重构 factory.ts 为动态加载 + PROTOCOL_MAPengine.ts / import-service.ts / setupView.ts 透传 extensionUri;模型下拉改为 input+datalistsetupView.ts 新增 FileSystemWatchersetupView.js 改为填充 datalistpackage.json 去除 ai.provider enum 锁定,版本 1.1.0→1.2.0;新增 i18n setup.modelPlaceholder | PROTOCOL_MAP 类型先用 4 参数构造函数签名报错(Gemini/Claude 仅 2 参数),改为 `new (...args: any[]) => AIProvider` | providers.json src/ai/types.ts src/ai/registry.ts src/ai/factory.ts src/ai/engine.ts src/rules/import-service.ts src/views/setupView.ts src/views/setupView.js package.json src/i18n/messages.ts | deepseek-v4-flash |
| 2026-07-28 22:49 | ① 用户提出 → ② 需求澄清→ ⑤ 编码实现 → ⑥ 审查验证 | 模型名称去掉 datalist 下拉列表,改为纯输入框; | 无 | src/views/setupView.ts | deepseek-v4-flash |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,116 @@
# 规则导入转换器架构设计
## 1. 背景
当前 `src/views/setupView.ts``addRule()` 方法(~97 行)将所有文件类型的导入逻辑硬编码在一起:
- `.yaml/.yml`:直接复制
- `.md/.txt`:读取 → AI 转换 → 写入
每增加一种文件类型就得修改 `addRule()`,导致方法膨胀、可维护性下降。同时 AI 转换逻辑(system prompt、异常处理、清理)也与 UI 层耦合。
## 2. 目标
将文件导入逻辑从 UI 层解耦,设计统一的 **Converter 接口 + 注册机制**,使新增文件类型只需添加一个 converter 实现并注册。
## 3. 架构
```
setupView.ts (addRule)
ImportService.convert(srcPath, yamlPath, context)
├── 查表: ext → RuleConverter
RuleConverter (接口)
├── YamlConverter (直接复制)
├── MdConverter (AI 转换)
└── TxtConverter (AI 转换)
```
### 3.1 RuleConverter 接口
```typescript
// src/rules/converters/converter.ts
export interface RuleConverter {
supportedExtensions: string[];
convert(srcPath: string, yamlPath: string, context: vscode.ExtensionContext): Promise<boolean>;
}
```
- `supportedExtensions`:声明支持的扩展名列表(如 `['.yaml', '.yml']`
- `convert()`:源文件 → 写 YAML 到 `yamlPath`,返回是否成功;失败时内部可弹错误提示
- 沿用 `LinterAdapter` 风格的接口约定
### 3.2 ImportService
```typescript
// src/rules/import-service.ts
class ImportService {
private converters: Map<string, RuleConverter> = new Map();
registerConverter(converter: RuleConverter): void;
convert(srcPath: string, yamlPath: string, context: vscode.ExtensionContext): Promise<boolean>;
}
```
- `registerConverter()` 遍历 converter 的 `supportedExtensions`,建立 ext → converter 映射
- `convert()` 根据 `path.extname(srcPath)` 查找 converter,找不到则返回 false 并弹错误
- 重复扩展名注册时后者覆盖前者(允许用户自定义覆盖)
### 3.3 各 Converter 职责
| Converter | 扩展名 | 行为 |
|-----------|--------|------|
| `YamlConverter` | `.yaml`, `.yml` | `fs.copyFileSync(srcPath, yamlPath)` |
| `MdConverter` | `.md` | 读文件 → AI 转换 → 写 YAML(逻辑从 `addRule()` 原样移入) |
| `TxtConverter` | `.txt` | 同上,共享 AI 转换逻辑 |
AI 转换逻辑(system prompt、API 调用、清理)提取到 `md-converter.ts``txt-converter.ts` 中,不再耦合 UI。
### 3.4 setupView.ts 变化
`addRule()` 缩减为:
```
1. showOpenDialog(弹窗选文件,保持不变)
2. 检查 yamlPath 是否已存在(保持不变)
3. 调用 ImportService.convert(srcPath, yamlPath, context)
4. refreshRules() 刷新列表(保持不变)
```
AI 转换、文件复制等细节从 `addRule()` 中删除。
## 4. 文件变更清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/rules/converters/converter.ts` | 新增 | RuleConverter 接口 |
| `src/rules/converters/yaml-converter.ts` | 新增 | .yaml/.yml 转换器 |
| `src/rules/converters/md-converter.ts` | 新增 | .md AI 转换器 |
| `src/rules/converters/txt-converter.ts` | 新增 | .txt AI 转换器 |
| `src/rules/import-service.ts` | 新增 | ImportService 类 |
| `src/views/setupView.ts` | 修改 | addRule() 简化,注册 converter |
| `src/extension.ts` | 无变更(setupView 自行注册 converters |
## 5. 未来扩展(Phase 2 — Excel
Converter 架构天然支持扩展。后续添加 Excel 只需:
1. 新增 `src/rules/converters/excel-converter.ts`
2. 实现 `RuleConverter` 接口(依赖 `xlsx` 库)
3.`setupView.ts``extension.ts``registerConverter(new ExcelConverter())`
4. `showOpenDialog` 的 filter 中添加 `xlsx`
无需修改任何现有 converter 或 import-service 逻辑。
## 6. 不涉及变更
- 运行时规则加载(`yaml-parser.ts`)不变,仍只读 `.yaml/.yml`
- 文件选择弹窗的 filter 不变(仍为 yaml/yml/md/txt),等 Excel 阶段再扩展
- 规则命名逻辑(自动追加 `.yaml`)不变
- `getApiKey``createProvider` 等 AI 基础设施不变
@@ -0,0 +1,46 @@
# Excel AI 转换器设计
## 1. 背景
当前 ExcelConverter 通过固定列名(id、severity、description、message)解析 Excel 并程序化生成 YAML。非技术人员无法随意使用自己习惯的表头命名,体验受限。
## 2. 目标
去掉对固定列名的依赖,将 Excel 内容以 Markdown 表格形式喂给 AI,让 AI 理解任意表头结构并生成 YAML 规则。与 `.md`/`.txt` 的 AI 转换模式保持一致。
## 3. 设计
### 3.1 数据流
```
用户选择 .xlsx/.xls 文件
ExcelConverter.convert()
├── XLSX.readFile → sheet_to_json
├── 行数据序列化为 Markdown 表格字符串
├── 调用 convertContentWithAI(content, context) // 复用 import-service.ts
└── AI 返回 YAML → 写入 yamlPath
```
### 3.2 变更范围
只改一个文件:`src/rules/converters/excel-converter.ts`
- 删除:列名白名单校验(REQUIRED_FIELDS)、severity 合法值校验(VALID_SEVERITIES)、程序化 YAML 拼接逻辑
- 新增:`buildMarkdownTable(rows)` 将 JSON 行转 Markdown 表格
- 复用:`convertContentWithAI()` 做 AI 转换并写文件
- 保留:`XLSX.readFile` 的 try/catch 错误处理
### 3.3 Markdown 表格格式示例
```
| id | severity | description | message | languages |
|----|----------|-------------|---------|-----------|
| rule-1 | warning | 禁止直接使用 console.log | 请使用 logger 代替 | javascript, typescript |
```
### 3.4 不涉及变更
- `setupView.ts`ExcelConverter 已注册、过滤器已含 xlsx/xls,无需改动
- `import-service.ts``convertContentWithAI` 已存在,无需改动
- 其他 converter:不受影响
@@ -0,0 +1,1230 @@
# 自定义规则优化设计(语言预过滤 + 导入去重)
> 适用项目:vscode-code-reviewer(净码特工 · Code Purifier
> 设计日期:2026-07-23
> 参考文档:`docs/superpowers/specs/2026-07-10-code-reviewer-design.md` §5.3
> 关联日志:`_AI_USAGE_LOG.md` 2026-07-17 / 2026-07-21 自定义规则演进记录
> 源码基准:`src/`(第二个上传包,含完整源码)
> **⚠ 现状澄清(编码前必读)**
>
> 设计文档 §12 描述的 config.yaml 两级过滤机制(文件级 `enabled` 列表 + 规则级 `rules.<id>.enabled` 覆盖)**已废弃**,不再使用。
>
> 当前自定义规则采用**全启用模式**:
> - `loadActiveRules(workspaceRoot)` 直接扫描 `.code-review/rules/*.yaml`,加载其中的**全部规则**,不做任何启用/禁用过滤。
> - 规则文件**存在即生效**,删除整个 `.yaml` 文件即禁用该文件内所有规则。
> - `.code-review/config.yaml` 即便仍存在于工程中,也不再被 `loadActiveRules` 读取(属历史遗留文件,编码时忽略)。
>
> 本设计分两部分:
> - **Part A(语言预过滤)**:在 `loadActiveRules` 之后新增按语言裁剪环节,`loadActiveRules` 本身不改动。
> - **Part B(导入去重)**:在 Converter 的 AI 转换流程中新增重复检测,重复规则以注释形式写入 YAML。
>
> 两者修改的文件几乎不重叠(唯一交集是 converters 的 prompt),可合并为一次实施。
---
# Part A:语言预过滤与字段容错
## A1. 背景与目标
### A1.1 现状问题
当前 `commands.ts` 第 41-43 行直接将 `loadActiveRules` 返回的全部规则传给 `runAIReview`
```typescript
const customRules = loadActiveRules(workspaceRoot);
const aiResult = await runAIReview(context, code, staticResult.diagnostics, customRules);
```
无论审查什么语言,全部规则都注入 AI 引擎请求 A(自定义规则评估)的 prompt。导致两个问题:
1. **浪费 token 且可能误导 AI**:审查 Java 文件时,只对 JS 生效的 `no-console-log` 也被塞进 prompt,既消耗 token,又可能让 AI 对 Java 代码套用 JS 规则。
2. **无规则语言仍发请求 A**`engine.ts` 第 141-148 行已有 `customRules.length > 0 ? provider.chat(...) : Promise.resolve('{}')` 的空规则处理,但预过滤前 customRules 永远不为空(只要有规则文件),所以 CSS/SQL 文件仍会触发请求 A 的 API 调用。
### A1.2 引出的依赖风险
预过滤的可靠性完全建立在规则 `languages` 字段准确这一前提上。而该字段由 AI 导入转换(`rules/converters/*`)生成,质量不可控。若 AI 错误标注 `languages`,预过滤会**主动制造漏报**(该触发的规则被误剔除),比不做预过滤更糟。
### A1.3 设计目标
- 在注入 prompt 前按文档语言裁剪无关规则,节省 token。
- 无相关规则时,传入空数组触发 `engine.ts` 已有的 `Promise.resolve('{}')` 路径,省一整次 API 调用。
- **核心容错原则**`languages` 字段准就省 token,字段不准就退化为现状(全注入),**只让情况变好,不让情况变坏**。
- 导入端(Part B)与过滤端双重加固,引导 AI 在不确定时留空而非猜测。
### A1.4 非目标
- 规则的确定性兜底(正则/AST 快通道)——另立设计。
- 规则命中率统计反馈。
- 规则 id 去重告警。
---
## A2. 可靠性传递链与风险分析
### A2.1 传递链
```
源文件(md/txt/excel)质量
AI 转换质量(Converter
languages 字段准确性 ← 预过滤的前提
预过滤裁剪正确性
是否漏报 / 误报
```
### A2.2 languages 字段三种状态及后果
| 状态 | 来源 | 对预过滤的影响 | 危险等级 |
|------|------|--------------|---------|
| 缺失/空 | AI 未能判断,或源文件无语言线索 | 全语言生效,规则保留注入 | **低**(退化为现状,不漏报) |
| 错误标注 | AI 猜错(如把通用规则标成仅 java) | 规则被误剔除,该触发的没触发 | **高**(主动制造漏报) |
| 正确标注 | AI 准确识别语言线索 | 预过滤生效,理想状态 | 无风险 |
**关键结论**:缺失是安全的(退化为现状),错误标注是危险的(比现状更差)。所有设计应引导行为朝"留空"方向倾斜。
### A2.3 AI 导入转换的能力边界
| 规则描述特征 | 例子 | AI 能否正确推断 languages | 风险 |
|-------------|------|-------------------------|------|
| 含明确语言关键词 | "Java 类名用 PascalCase" | 能 | 低 |
| 含语言线索但不唯一 | "变量名用驼峰" | 难(驼峰 JS/Java 都有) | 中 |
| 纯通用语义 | "禁止硬编码密码" | 不能确定 | 高 |
| 完全无语言信息 | "函数不超过 50 行" | 不能 | 高 |
---
## A3. 整体设计
### A3.1 容错原则
> **软过滤而非硬过滤**:对 `languages` 非空的规则才裁剪,`languages` 为空的规则始终保留。这样即使 AI 乱标,最坏情况是"留空规则过多导致预过滤不生效"(退化为现状),而不会误剔除任何规则。
### A3.2 数据流
```
loadActiveRules(workspaceRoot) ← 返回全部激活规则(不变)
filterForDocument(rules, document) ← 新增:按语言裁剪(软过滤)
runAIReview(context, code, diag, relevantRules)
engine.ts 内部:customRules.length === 0 ?
├─ 是 → Promise.resolve('{}') ← 已有逻辑,不发 API 调用
└─ 否 → provider.chat(...) ← 正常发请求 A
```
> **重要**`engine.ts` 不需要改动。现有第 141-148 行已正确处理空规则数组。预过滤只需在 `commands.ts` 调用 `runAIReview` 前插入过滤步骤。
### A3.3 双重加固
- **导入端(Part B**Converter prompt 约束 + 导入后预览确认 + 可选 `excludeLanguages` 黑名单。
- **过滤端(Part A)**:软过滤 + 语言别名映射 + JSP 并集 + 运行时反馈。
---
## A4. 详细设计
### A4.1 数据结构变更
`src/types.ts``CustomRule` 接口新增 `excludeLanguages` 字段:
```typescript
// src/types.ts
export interface CustomRule {
id: string; // 不含 custom: 前缀,运行时自动拼接
severity: Severity; // 'error' | 'warning' | 'info'
description: string; // AI 评估依据(自然语言)
message: string; // 触发时显示
languages?: string[]; // 白名单:适用语言,空/缺省=全语言
excludeLanguages?: string[]; // 黑名单(新增):明确排除的语言
}
```
**字段语义**
| languages | excludeLanguages | 生效范围 |
|-----------|------------------|---------|
| 空/缺省 | 空/缺省 | 全语言生效 |
| `[java, javascript]` | 空 | 仅 java、javascript |
| 空/缺省 | `[css, sql]` | 除 css、sql 外全部 |
| `[java]` | `[css]` | 仅 java(先按白名单保留,再按黑名单剔除) |
> 两者同时存在时,先按白名单保留,再按黑名单剔除。实践中建议二选一,导入时由 AI 判断哪种更明确。
### A4.2 YAML schema 扩展
```yaml
# 白名单(适用于明确单一/少数语言的规则)
- id: no-console-log
severity: warning
description: 生产代码不应保留 console.log 调试语句
message: 请使用日志框架替代 console.log
languages: [javascript, typescript]
# 黑名单(新增字段,适用于通用规则排除明确不适用语言)
- id: no-hardcoded-secret
severity: error
description: 禁止在代码中硬编码 API Key、密码等敏感信息
message: 检测到硬编码密钥,请使用环境变量或密钥管理工具
excludeLanguages: [css, sql] # 这条规则除 CSS/SQL 外都适用
```
### A4.3 过滤模块设计
#### A4.3.1 新建文件
`src/rules/rule-filter.ts`
#### A4.3.2 语言别名映射
```typescript
// src/rules/rule-filter.ts
import * as vscode from 'vscode';
import type { CustomRule } from '../types';
const LANGUAGE_ALIASES: Record<string, string[]> = {
typescriptreact: ['typescript', 'typescriptreact', 'tsx'],
javascriptreact: ['javascript', 'javascriptreact', 'jsx'],
};
const LANGUAGE_GROUPS: Record<string, string[]> = {
sql: ['sql', 'plsql'],
plsql: ['sql', 'plsql'],
};
function expandLanguageId(languageId: string): string[] {
const aliases = LANGUAGE_ALIASES[languageId] ?? [languageId];
const groups = LANGUAGE_GROUPS[languageId] ?? [];
return [...new Set([...aliases, ...groups, languageId])];
}
```
#### A4.3.3 JSP 子语言集合
```typescript
const JSP_SUB_LANGUAGES = ['java', 'javascript', 'typescript', 'css', 'jsp', 'html'];
const JSP_EXTENSIONS = ['.jsp', '.jspx'];
function isJspFile(document: vscode.TextDocument): boolean {
return JSP_EXTENSIONS.some(ext => document.fileName.toLowerCase().endsWith(ext));
}
```
#### A4.3.4 单条规则匹配函数
```typescript
function matchesLanguage(rule: CustomRule, expandedLangs: string[]): boolean {
// 1. 黑名单优先:规则明确排除当前语言 → 剔除
if (rule.excludeLanguages && rule.excludeLanguages.length > 0) {
if (rule.excludeLanguages.some(l => expandedLangs.includes(l))) {
return false;
}
}
// 2. 白名单为空 → 全语言保留(软过滤的安全默认)
if (!rule.languages || rule.languages.length === 0) {
return true;
}
// 3. 白名单非空 → 检查是否包含当前语言
return rule.languages.some(l => expandedLangs.includes(l));
}
```
#### A4.3.5 主过滤函数
```typescript
export function filterForDocument(
rules: CustomRule[],
document: vscode.TextDocument
): CustomRule[] {
const langId = document.languageId;
if (langId === 'html' && isJspFile(document)) {
return rules.filter(rule => matchesLanguage(rule, JSP_SUB_LANGUAGES));
}
const expandedLangs = expandLanguageId(langId);
return rules.filter(rule => matchesLanguage(rule, expandedLangs));
}
```
#### A4.3.6 导出汇总(用于运行时反馈)
```typescript
export interface FilterResult {
relevant: CustomRule[];
filteredOut: CustomRule[];
skippedRequestA: boolean;
}
export function filterAndSummarize(
rules: CustomRule[],
document: vscode.TextDocument
): FilterResult {
const relevant = filterForDocument(rules, document);
const filteredOut = rules.filter(r => !relevant.includes(r));
return {
relevant,
filteredOut,
skippedRequestA: relevant.length === 0,
};
}
```
### A4.4 调用处接入
`src/activation/commands.ts``review` 命令中插入过滤。当前第 41-43 行:
```typescript
// 现状
const customRules = loadActiveRules(workspaceRoot);
const code = document.getText();
const aiResult = await runAIReview(context, code, staticResult.diagnostics, customRules);
```
改为:
```typescript
// 改造后
const allRules = loadActiveRules(workspaceRoot);
const filterResult = filterAndSummarize(allRules, document);
const code = document.getText();
const aiResult = await runAIReview(context, code, staticResult.diagnostics, filterResult.relevant);
```
> `runAIReview` 签名不变(`context, code, staticDiagnostics, customRules`)。传入空数组时 `engine.ts` 已有的 `Promise.resolve('{}')` 逻辑会跳过请求 A 的 API 调用。
### A4.5 yaml-parser 适配
`src/rules/yaml-parser.ts` 第 5-11 行的 `RuleYamlItem` 接口和第 72-78 行的解析逻辑需新增 `excludeLanguages` 字段。
> **注意**`loadActiveRules` 的全启用加载逻辑(直接扫描 `rules/*.yaml` 返回全部规则)**保持不变**,不要引入任何 config.yaml 过滤。
```typescript
// src/rules/yaml-parser.ts
interface RuleYamlItem {
id: string;
severity: string;
description: string;
message: string;
languages?: string[];
excludeLanguages?: string[]; // 新增
}
// loadActiveRules 内部 push 时增加字段
allRules.push({
id: item.id,
severity,
description: item.description,
message: item.message,
languages: item.languages,
excludeLanguages: item.excludeLanguages, // 新增
});
```
> 现有 `parseYamlSimple` 已能解析 `[a, b]` 格式的数组(第 28-31 行、第 43-46 行),`excludeLanguages` 的解析无需额外代码。
### A4.6 运行时反馈
#### A4.6.1 MergedReport 扩展
当前 `src/merger/merger.ts``MergedReport` 接口(第 5-21 行)新增字段:
```typescript
export interface MergedReport {
// ... 现有字段不变 ...
linterDiagnostics: LinterDiagnostic[];
customRuleDiagnostics: LinterDiagnostic[];
// ...
// 新增:规则过滤信息
customRuleFilterInfo?: {
totalActive: number;
injected: number;
filteredOut: number;
skippedRequestA: boolean;
};
}
```
#### A4.6.2 MergeInput 扩展
```typescript
interface MergeInput {
// ... 现有字段不变 ...
customRuleFilterInfo?: {
totalActive: number;
injected: number;
filteredOut: number;
skippedRequestA: boolean;
};
}
```
#### A4.6.3 commands.ts 传入
```typescript
currentReport = mergeResults({
// ... 现有参数 ...
customRuleFilterInfo: {
totalActive: allRules.length,
injected: filterResult.relevant.length,
filteredOut: filterResult.filteredOut.length,
skippedRequestA: filterResult.skippedRequestA,
},
});
```
#### A4.6.4 面板展示
`src/panel/webview.ts` 自定义规则 Tab 的 header 中追加:
```
自定义规则 · 2 个问题(注入 4/7 条规则)
```
`skippedRequestA` 为 true,显示提示:
```
当前文件语言无匹配的自定义规则,已跳过规则评估
```
---
## A5. 边界情况
| 场景 | 处理方式 |
|------|---------|
| `languages``excludeLanguages` 同时非空 | 先按白名单保留,再按黑名单剔除 |
| 规则 `languages` 含拼写错误 | 不匹配任何语言,规则被剔除。依赖导入预览纠正 |
| `.tsx` 文件 | `LANGUAGE_ALIASES` 映射到 `typescript`,规则 `languages: [typescript]` 命中 |
| `.plsql` 文件 | `LANGUAGE_GROUPS``sql` 同组,规则 `languages: [sql]` 命中 |
| JSP 文件(html ID + .jsp 扩展名) | 取 `JSP_SUB_LANGUAGES` 并集,保留 Java/JS/CSS 相关规则 |
| 普通 HTML 文件(html ID,非 .jsp | 走正常 `expandLanguageId('html')`,无 JSP 扩展名检测 |
| 全部规则被过滤(如 CSS 文件) | `skippedRequestA = true`engine.ts 走 `Promise.resolve('{}')` |
| `loadActiveRules` 返回空 | 同上 |
| 规则 `languages` 为空数组 `[]` | 视同缺失,全语言保留 |
| 工程中残留 `config.yaml` | **忽略**,不读取 |
---
# Part B:导入去重与注释化
## B1. 背景与目标
### B1.1 问题
当前导入流程(`setupView.ts` 第 181-210 行 `addRule` 方法)只做三件事:选文件 → 查重名 → 调用 `importService.convert(srcPath, yamlPath, context)` 转换写盘。**没有任何与静态分析规则的比对**。
用户导入的规则可能与 linter 内置规则检测同样的问题(如"禁止未使用变量"重复 ESLint `no-unused-vars`),导致 AI 请求 A 和静态分析重复报告同一问题。
### B1.2 设计目标
- 导入时自动检测自定义规则是否与 linter 内置规则重复。
- 重复规则以注释形式写入 YAML`yaml-parser` 已跳过 `#` 开头的行),不进入激活规则集。
- 非重复规则正常写入。
- 用户可在预览面板一键恢复被注释的规则。
- 导入后用户确认才写盘,防止 AI 误判静默禁用规则。
### B1.3 已确认决策
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 检测范围 | 只对 md/txt/excelAI 转换路径) | YamlConverter 直接复制,用户手写 YAML 说明知道在做什么 |
| 确认流程 | 强制预览确认 | 误判会静默禁用规则,风险高 |
| 判定结果分档 | 三档:exact / overlap / none | 二元判定丢失"部分重叠"信息,overlap 交用户决定 |
| 规则清单来源 | 离线提炼,手工定稿打包 | 四个 linter 内置且版本固定,规则集是确定常量,无需运行时提取 |
| 全部重复 | 仍写盘(全是注释) | 预览提示"全部规则与静态分析重复",用户可取消注释恢复 |
---
## B2. 整体设计
### B2.1 数据流
```
源文件(md/txt/xlsx
Converter AI 转换(prompt 含语言约束 + 规则清单 + 重复检测)
中间 YAML(含 duplicateOf + duplicateLevel + duplicateReason
后处理:解析规则列表,按 exact/overlap/none 分组
预览 Webview 面板(三分区展示,用户确认,可翻转规则状态)
注释化(保留 duplicateLevel 信息)+ 写盘
```
### B2.2 静态规则清单(离线提炼)
四个 linter 是插件内置的,版本固定,规则集是确定常量。**一次性离线提炼**成 `static-rules.json`,打包进插件,运行时直接 import。不需要构建脚本、不需要运行时提取、不需要构建环境装任何工具。
#### 规则来源与提炼方法
| Linter | 版本(源码确定) | 规则集权威来源 | 预计规则数 |
|--------|---------------|-------------|----------|
| ESLint | `@eslint/js` recommended | `npx eslint --print-config` 或官方规则页 | ~50 条 |
| ts-eslint | `typescript-eslint` recommended | 官方 rules 页 recommended 标记 | ~40 条 |
| Stylelint | 源码 `DEFAULT_CONFIG` 硬编码 | `stylelint.ts` 第 17-32 行直接读 | 13 条 |
| PMD | 7.26.06 个 category | `pmd-java-ruleset.xml` + `java -jar pmd --rules` | ~100+ 条 |
| sqlfluff | CLI 默认 | `sqlfluff rules --dialect ansi` | ~60 条 |
提炼时在装有对应版本工具的环境中跑一次上述命令,将输出整理成 `id + description` 格式,人工验收后定稿。
#### 产物文件
`src/rules/static-rules.json`
```json
{
"version": "1.0.0",
"linterVersion": {
"eslint": "9.x (recommended)",
"ts-eslint": "8.x (recommended)",
"stylelint": "16.x (内置13条)",
"pmd": "7.26.0 (6 categories)",
"sqlfluff": "3.x (default)"
},
"rules": {
"eslint": [
{ "id": "eslint:no-unused-vars", "description": "未使用的变量" },
{ "id": "eslint:no-console", "description": "console 语句" },
{ "id": "eslint:no-debugger", "description": "debugger 语句" },
{ "id": "eslint:eqeqeq", "description": "要求严格相等 ===" }
],
"ts-eslint": [
{ "id": "ts-eslint:no-explicit-any", "description": "禁止显式 any 类型" },
{ "id": "ts-eslint:no-non-null-assertion", "description": "禁止非空断言 !" }
],
"stylelint": [
{ "id": "stylelint:block-no-empty", "description": "空规则块" },
{ "id": "stylelint:declaration-block-no-duplicate-properties", "description": "重复属性" }
],
"pmd": [
{ "id": "pmd:EmptyCatchBlock", "description": "空 catch 块" },
{ "id": "pmd:AvoidDuplicateLiterals", "description": "重复字符串字面量" },
{ "id": "pmd:ExcessiveMethodLength", "description": "过长方法" }
],
"sql-lint": [
{ "id": "sql-lint:L001", "description": "不必要的空格" },
{ "id": "sql-lint:L010", "description": "关键字大小写" }
]
}
}
```
#### 维护策略
linter 升级时重新提炼。文件中的 `linterVersion` 字段记录对应版本,便于追溯。因为 linter 版本随插件发布绑定,不会在用户侧变化,所以不需要运行时校验。
#### 运行时加载
```typescript
// src/rules/converters/prompt-builder.ts(新建)
import staticRules from '../static-rules.json';
export function buildDedupPromptSection(): string {
const lines: string[] = ['## 静态分析规则清单(用于重复检测)\n'];
for (const [linter, rules] of Object.entries(staticRules.rules)) {
lines.push(`### ${linter} (${rules.length} 条)`);
for (const rule of rules) {
lines.push(`- ${rule.id}: ${rule.description}`);
}
lines.push('');
}
lines.push('判定时请精确匹配上述规则 ID,而非模糊匹配分类。');
return lines.join('\n');
}
```
### B2.3 三档判定机制
"重复"不是二元的,有程度差异。AI 判定结果分三档,每档处理方式不同:
| 等级 | 含义 | 例子 | 默认处理 |
|------|------|------|---------|
| **exact** | 检测目标 + 触发条件完全一致 | "禁止 console.log" ↔ `eslint/no-console` | 注释化 |
| **overlap** | 检测目标相同但触发条件有差异 | "禁止 console 输出,需用 logger 替代" ↔ `no-console`(自定义规则有额外要求) | **预览标注,用户决定** |
| **none** | 检测目标不同 | "禁止 console.log 输出敏感信息" ↔ `no-console`(检测目标不同) | 保留 |
AI 输出三档字段:
```yaml
- id: no-console-log
description: 禁止 console.log
duplicateOf: eslint/no-console
duplicateLevel: exact
duplicateReason: 检测目标完全一致,均为禁止 console 语句
- id: no-console-with-context
description: 禁止 console 输出,需用 logger 替代并记录上下文
duplicateOf: eslint/no-console
duplicateLevel: overlap
duplicateReason: 检测目标相同,但本规则额外要求用 logger 替代并记录上下文
- id: no-sensitive-console
description: 禁止在 console.log 中输出敏感信息
duplicateLevel: none
```
后处理默认行为:
```typescript
function getDefaultKeep(rule: ImportableRule): boolean {
if (rule.duplicateLevel === 'exact') return false; // 确定重复 → 注释
if (rule.duplicateLevel === 'overlap') return true; // 部分重叠 → 默认保留,预览标注
return true; // 不重复 → 保留
}
```
**判定标准(写进 prompt**
> 只有当自定义规则的检测目标、触发条件与某个 linter 内置规则**高度一致**(会报出同样的问题行)时,标记 `exact`。检测目标相同但自定义规则有额外要求或更窄范围时,标记 `overlap`。检测目标不同时,标记 `none`。
>
> 仅"话题相似"不算重复。例如:
> - "未使用变量应删除" → exact(重复 eslint/no-unused-vars
> - "未使用变量需要记录到日志" → none(检测目标不同,后者有额外动作)
> - "禁止 console.log" → exact(重复 eslint/no-console
> - "禁止在 console.log 中输出敏感信息" → none(检测目标不同)
### B2.4 注释化机制
`yaml-parser.ts` 第 19 行已跳过所有 `#` 开头的行:
```typescript
if (!trimmed || trimmed.startsWith('#')) { continue; }
```
注释化的规则天然不会被加载,**无需改 parser**。后处理对被注释的规则整块加 `#` 前缀,注释头保留 `duplicateLevel``duplicateReason`
最终文件示例:
```yaml
# [DUPLICATE: exact] 重复 eslint/no-console(检测目标完全一致)
# 如需启用,删除以下每行开头的 # 即可
# - id: no-console-log
# severity: warning
# description: 禁止 console.log
# message: 请使用日志框架替代 console.log
# languages: [javascript, typescript]
# [DUPLICATE: overlap] 与 eslint/no-console 部分重叠
# 重叠原因:检测目标相同,但本规则额外要求用 logger 替代并记录上下文
# - id: no-console-with-context
# severity: warning
# description: 禁止 console 输出,需用 logger 替代并记录上下文
# message: 请使用 logger 替代 console 并记录上下文
# languages: [javascript, typescript]
- id: no-hardcoded-secret
severity: error
description: 禁止硬编码 API Key
message: 检测到硬编码密钥
languages: [java, javascript]
```
用户想恢复某条规则,手动删掉 `#` 前缀即可——parser 会重新加载它。`duplicateLevel``duplicateReason` 保留在注释头,方便追溯。
---
## B3. 详细设计
### B3.1 数据结构
`duplicateOf` 是导入中间态字段,不属于 `CustomRule` 运行时接口。定义独立的导入中间类型:
```typescript
// src/rules/import-types.ts(新建)
import type { CustomRule } from '../types';
/**
* 导入中间态规则:AI 转换输出、后处理、预览使用。
* 写盘后 duplicateOf 不出现在最终 YAML 规则体中(重复规则整块被注释)。
*/
export interface ImportableRule extends CustomRule {
duplicateOf?: string; // 格式 "linter/ruleId",如 "eslint/no-unused-vars"
duplicateLevel?: 'exact' | 'overlap' | 'none'; // 重复程度
duplicateReason?: string; // AI 给出的重叠原因(overlap 档必填)
}
export interface ConversionResult {
rules: ImportableRule[];
yamlContent: string; // AI 输出的原始 YAML 文本(含 duplicate* 字段)
sourceFileName: string;
exactCount: number; // duplicateLevel=exact 的规则数
overlapCount: number; // duplicateLevel=overlap 的规则数
}
export interface PreviewDecision {
/** 用户最终确认的规则状态:ruleId → 是否保留(true=激活,false=注释) */
keepRule: Record<string, boolean>;
confirmed: boolean;
}
```
### B3.2 Converter prompt(语言约束 + 重复检测合并)
`md-converter.ts``txt-converter.ts``excel-converter.ts` 的 AI 转换 prompt 中追加以下内容。**语言约束(Part A)和重复检测(Part B)在同一次 prompt 修改中一起加入**:
```text
## 语言字段规则(严格遵守)
对于每条规则的 languages 字段,按以下优先级判断:
1. 规则描述中含明确语言关键词(如 "Java"、"JavaScript"、"TypeScript"、"CSS"):
→ 使用 languages 白名单,列出明确提到的语言。
2. 规则适用于大多数语言,只有少数明确不适用:
→ 使用 excludeLanguages 黑名单,列出明确不适用者。
3. 无法确定适用语言,或规则为通用规范(如命名、安全、复杂度):
→ languages 与 excludeLanguages 均留空(表示全语言生效)。
→ 严禁猜测。留空比猜测错误更安全。
输出 YAML 中,languages 和 excludeLanguages 不可同时非空。
语言名使用小写:java, javascript, typescript, css, sql, plsql, jsp。
## 静态分析重复检测
本插件的静态分析已覆盖以下 linter 内置规则。请精确匹配规则 ID,而非模糊匹配分类。
${buildDedupPromptSection()}
对于每条规则,判断其检测目标与触发条件是否与上述某个 linter 规则重复,并标注重复程度:
- **exact**:检测目标与触发条件完全一致(会报出同样的问题行)→ 输出 duplicateOf + duplicateLevel: exact
- **overlap**:检测目标相同,但本规则有额外要求或更窄范围 → 输出 duplicateOf + duplicateLevel: overlap + duplicateReason(说明差异)
- **none**:检测目标不同 → 输出 duplicateLevel: none(不输出 duplicateOf
仅"话题相似"不算重复。例如:
- "未使用变量应删除" → exact(重复 eslint/no-unused-vars
- "未使用变量需要记录到日志" → none(检测目标不同,后者有额外动作)
- "禁止 console.log" → exact(重复 eslint/no-console
- "禁止在 console.log 中输出敏感信息" → none(检测目标不同)
```
### B3.3 ImportService 改造
当前 `importService.convert(srcPath, yamlPath, context)` 直接转换写盘。拆分为两步:先转换返回结果,再由调用方预览确认后写盘。
```typescript
// src/rules/import-service.ts(改造)
export class ImportService {
private converters: Map<string, RuleConverter> = new Map();
registerConverter(converter: RuleConverter): void {
for (const ext of converter.supportedExtensions) {
this.converters.set(ext, converter);
}
}
/**
* 转换源文件为规则列表 + YAML 文本(不写盘)。
* YamlConverter 直接复制源文件内容。
* Md/Txt/Excel Converter 调用 AI 转换。
*/
async convert(
srcPath: string,
context: vscode.ExtensionContext,
): Promise<ConversionResult> {
const ext = path.extname(srcPath).slice(1).toLowerCase();
const converter = this.converters.get(ext);
if (!converter) {
throw new Error(`不支持的文件格式: .${ext}`);
}
const yamlContent = await converter.convert(srcPath, context);
const rules = parseImportableYaml(yamlContent);
const exactCount = rules.filter(r => r.duplicateLevel === 'exact').length;
const overlapCount = rules.filter(r => r.duplicateLevel === 'overlap').length;
return {
rules,
yamlContent,
sourceFileName: path.basename(srcPath),
exactCount,
overlapCount,
};
}
/**
* 根据用户预览决策,注释化重复规则后写盘。
*/
applyConversion(
result: ConversionResult,
decision: PreviewDecision,
targetPath: string,
): void {
const finalYaml = buildFinalYaml(result.yamlContent, result.rules, decision);
const dir = path.dirname(targetPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(targetPath, finalYaml, 'utf-8');
}
}
```
### B3.4 注释化后处理
对 AI 输出的 YAML 文本做行级操作,将用户标记为注释的规则整块加 `#` 前缀。
```typescript
// src/rules/import-service.ts
/**
* 根据用户决策构建最终 YAML 文本。
* 默认行为:有 duplicateOf 的规则注释,无 duplicateOf 的规则保留。
* 用户可在预览中翻转任意规则的状态。
*/
function buildFinalYaml(
yamlContent: string,
rules: ImportableRule[],
decision: PreviewDecision,
): string {
// 默认:exact → 注释;overlap → 保留(预览标注);none → 保留
const defaultKeep = (rule: ImportableRule) => rule.duplicateLevel !== 'exact';
// 用户决策覆盖默认
const shouldKeep = (rule: ImportableRule) =>
decision.keepRule[rule.id] ?? defaultKeep(rule);
const lines = yamlContent.split('\n');
const output: string[] = [];
let currentRuleId: string | null = null;
let currentRuleKeep = true;
let ruleLines: string[] = [];
function flushRule(): void {
if (currentRuleId === null) {
// 非规则行(空行、注释等),直接输出
output.push(...ruleLines);
} else if (currentRuleKeep) {
// 保留规则:移除 duplicateOf 行,其余原样输出
const filtered = ruleLines.filter(
line => !line.trim().startsWith('duplicateOf:')
);
output.push(...filtered);
} else {
// 注释规则:加说明头(含 duplicateLevel + duplicateReason+ 整块加 # 前缀
const rule = rules.find(r => r.id === currentRuleId);
const level = rule?.duplicateLevel ?? 'exact';
const dupInfo = rule?.duplicateOf ?? 'unknown';
const reason = rule?.duplicateReason ?? '';
if (level === 'exact') {
output.push(`# [DUPLICATE: exact] 重复 ${dupInfo}(检测目标完全一致)`);
} else {
output.push(`# [DUPLICATE: ${level}] 与 ${dupInfo} 部分重叠`);
if (reason) { output.push(`# 重叠原因:${reason}`); }
}
output.push('# 如需启用,删除以下每行开头的 # 即可');
for (const line of ruleLines) {
output.push(line.trim() ? `# ${line}` : '#');
}
}
ruleLines = [];
}
for (const line of lines) {
const ruleStart = line.match(/^-\s+id:\s*(.+)/);
if (ruleStart) {
flushRule();
currentRuleId = ruleStart[1].trim();
currentRuleKeep = shouldKeep({ id: currentRuleId } as ImportableRule);
ruleLines = [line];
} else if (currentRuleId) {
ruleLines.push(line);
} else {
ruleLines.push(line);
}
}
flushRule();
return output.join('\n');
}
```
### B3.5 YAML 解析(导入中间态)
复用 `yaml-parser.ts``parseYamlSimple` 逻辑,额外读取 `duplicateOf` 字段:
```typescript
// src/rules/import-service.ts
function parseImportableYaml(content: string): ImportableRule[] {
// 复用 parseYamlSimple 的解析逻辑(可导出或内联)
const items = parseSimpleYaml(content);
return items
.filter(item => item.id && item.severity && item.description && item.message)
.map(item => ({
id: item.id,
severity: item.severity,
description: item.description,
message: item.message,
languages: item.languages,
excludeLanguages: item.excludeLanguages,
duplicateOf: item.duplicateOf, // 导入中间态字段
}));
}
```
> `parseSimpleYaml` 已能解析 `key: value` 和 `key: [a, b]` 格式,`duplicateOf: eslint/no-unused-vars` 会被正确解析为字符串。
### B3.6 预览 Webview 面板
`vscode.window.createWebviewPanel` 创建临时面板,展示规则卡片列表,用户确认后才写盘。
```typescript
// src/rules/import-preview.ts(新建)
import * as vscode from 'vscode';
import type { ConversionResult, PreviewDecision } from './import-types';
export async function showImportPreview(
result: ConversionResult,
extensionUri: vscode.Uri,
): Promise<PreviewDecision | null> {
return new Promise((resolve) => {
const panel = vscode.window.createWebviewPanel(
'ruleImportPreview',
'规则导入预览',
vscode.ViewColumn.Active,
{ enableScripts: true },
);
const keepRule: Record<string, boolean> = {};
for (const rule of result.rules) {
// 默认:exact → 注释(false);overlap/none → 保留(true
keepRule[rule.id] = rule.duplicateLevel !== 'exact';
}
panel.webview.html = renderPreviewHtml(result, keepRule);
panel.webview.onDidReceiveMessage((msg) => {
if (msg.type === 'toggleRule') {
keepRule[msg.ruleId] = msg.keep;
// 更新 UI 状态由前端处理
} else if (msg.type === 'confirm') {
resolve({ keepRule, confirmed: true });
panel.dispose();
} else if (msg.type === 'cancel') {
resolve(null);
panel.dispose();
}
});
panel.onDidDispose(() => resolve(null));
});
}
```
#### 预览 HTML 结构
```
┌─────────────────────────────────────────────────┐
│ 规则导入预览 · 来源:team-conventions.md │
│ 检测到 7 条规则 │
│ · 2 条与静态分析完全重复(将注释) │
│ · 2 条与静态分析部分重叠(请确认) │
│ · 3 条无重复(将保留) │
├─────────────────────────────────────────────────┤
│ ⛔ 完全重复 (2 条) — 将以注释导入 │
│ ┌─────────────────────────────────────────┐ │
│ │ no-console-log warning │ │
│ │ 重复:eslint/no-console │ │
│ │ [恢复此规则] │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ⚠️ 部分重叠 (2 条) — 默认保留,请确认 │
│ ┌─────────────────────────────────────────┐ │
│ │ no-console-with-context warning │ │
│ │ 与 eslint/no-console 部分重叠 │ │
│ │ 重叠原因:检测目标相同,但本规则额外要求 │ │
│ │ 用 logger 替代并记录上下文 │ │
│ │ [保留] [注释] ← 用户选择 │ │
│ └─────────────────────────────────────────┘ │
│ │
│ ✅ 无重复 (3 条) — 将保留 │
│ ┌─────────────────────────────────────────┐ │
│ │ no-hardcoded-secret error │ │
│ │ 禁止硬编码 API Key │ │
│ └─────────────────────────────────────────┘ │
│ [确认导入] [取消] │
└─────────────────────────────────────────────────┘
```
**关键交互**
- exact 档规则默认折叠(反正重复了),用户点"展开"才看详情,有「恢复此规则」按钮
- overlap 档规则默认展开并高亮(需用户判断),置顶展示,有「保留」「注释」两个按钮
- none 档规则默认折叠,点击可展开查看
- 用户操作后即时更新"X 条保留 / Y 条注释"计数
- 状态变更通过 `postMessage({ type: 'toggleRule', ruleId, keep })` 通知扩展端
### B3.7 setupView.ts 接入
当前 `addRule` 方法(第 181-210 行)改为:
```typescript
// src/views/setupView.ts — addRule 方法改造
private async addRule(name: string): Promise<void> {
if (!name.trim()) { return; }
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { return; }
const result = await vscode.window.showOpenDialog({
canSelectMany: false,
openLabel: '选择规则文件',
filters: { '规则文件': ['yaml', 'yml', 'md', 'txt', 'xlsx', 'xls'] },
});
if (!result || result.length === 0) { return; }
const srcPath = result[0].fsPath;
const ext = path.extname(srcPath).slice(1).toLowerCase;
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
if (!fs.existsSync(rulesDir)) {
fs.mkdirSync(rulesDir, { recursive: true });
}
const yamlFileName = name.endsWith('.yaml') ? name : `${name}.yaml`;
const yamlPath = path.join(rulesDir, yamlFileName);
if (fs.existsSync(yamlPath)) {
vscode.window.showErrorMessage(`文件 ${yamlFileName} 已存在`);
return;
}
// 分支:YAML 直接复制(不预览);其他格式 AI 转换 + 预览
if (ext === 'yaml' || ext === 'yml') {
fs.copyFileSync(srcPath, yamlPath);
vscode.window.showInformationMessage(`规则文件已导入: ${yamlFileName}`);
} else {
// AI 转换
const conversion = await this.importService.convert(srcPath, this.context);
// 预览确认
const decision = await showImportPreview(conversion, this.context.extensionUri);
if (!decision || !decision.confirmed) {
vscode.window.showInformationMessage('导入已取消');
return;
}
// 注释化 + 写盘
this.importService.applyConversion(conversion, decision, yamlPath);
vscode.window.showInformationMessage(
`规则已导入: ${yamlFileName}${conversion.rules.length} 条,` +
`${conversion.exactCount} 条完全重复已注释,` +
`${conversion.overlapCount} 条部分重叠已标注)`
);
}
}
```
---
## B4. 边界情况
| 场景 | 处理方式 |
|------|---------|
| AI 未输出 `duplicateLevel` 字段 | 视为 none,规则正常保留 |
| AI 输出 `duplicateLevel: exact` 但无 `duplicateOf` | 注释化时 dupInfo 显示 unknown,不影响注释行为 |
| AI 输出 `duplicateLevel: overlap` 但无 `duplicateReason` | 预览中 overlap 区显示"未提供重叠原因",仍允许用户决定 |
| AI 对所有规则都标记 `exact` | 全部注释,文件仅含注释行。预览提示"全部规则与静态分析完全重复" |
| AI 对所有规则都标记 `overlap` | 全部保留(overlap 默认保留),预览全部置顶展示供用户确认 |
| AI 误判(非重复标为 exact) | 用户在预览面板点「恢复此规则」一键纠正 |
| AI 漏判(重复标为 none) | 规则正常激活,运行时与 linter 重复报告同一问题。可接受——漏判不造成数据丢失 |
| `duplicateOf` 格式不规范 | 后处理时原样写入注释头,不影响功能 |
| 源文件为空或无有效规则 | Converter 返回空规则列表,预览提示"未检测到有效规则",不写盘 |
| 用户在预览中取消 | 不写盘,不创建文件 |
| YAML 直接导入 | 跳过 AI 转换和预览,直接复制到目标路径 |
| `static-rules.json` 缺失或格式错误 | `buildDedupPromptSection` 返回空字符串,prompt 不含规则清单,AI 降级为模糊判断 |
---
# 合并章节
## C1. 完整改动清单
### Part A 改动
| 文件 | 操作 | 内容 |
|------|------|------|
| `src/types.ts` | 修改 | `CustomRule` 新增 `excludeLanguages?: string[]` |
| `src/rules/rule-filter.ts` | **新建** | `filterForDocument` + `filterAndSummarize` + 语言别名/JSP 处理 |
| `src/activation/commands.ts` | 修改 | review 命令中 load → filterAndSummarize → runAIReview |
| `src/rules/yaml-parser.ts` | 修改 | `RuleYamlItem` + 解析新增 `excludeLanguages` |
| `src/merger/merger.ts` | 修改 | `MergedReport` + `MergeInput` 新增 `customRuleFilterInfo` |
| `src/panel/webview.ts` | 修改 | 自定义规则 Tab 展示过滤信息 |
| `src/test/rule-filter.test.ts` | **新建** | 过滤模块单元测试 |
### Part B 改动
| 文件 | 操作 | 内容 |
|------|------|------|
| `src/rules/static-rules.json` | **新建(离线定稿)** | 四个 linter 的规则 ID + 描述清单,人工提炼验收 |
| `src/rules/converters/prompt-builder.ts` | **新建** | `buildDedupPromptSection` 从 static-rules.json 生成 prompt 片段 |
| `src/rules/import-types.ts` | **新建** | `ImportableRule`(含 duplicateLevel/duplicateReason+ `ConversionResult` + `PreviewDecision` |
| `src/rules/import-preview.ts` | **新建** | `showImportPreview` Webview 面板(三分区展示) |
| `src/rules/import-service.ts` | 修改 | 拆分 `convert`(不写盘)+ 新增 `applyConversion`(三档注释化写盘) |
| `src/rules/converters/md-converter.ts` | 修改 | prompt 追加语言约束 + 规则清单注入 + 三档重复检测 |
| `src/rules/converters/txt-converter.ts` | 修改 | 同上 |
| `src/rules/converters/excel-converter.ts` | 修改 | 同上 |
| `src/views/setupView.ts` | 修改 | `addRule` 分支:YAML 直接复制 / 其他 AI 转换+预览 |
| `src/test/import-dedup.test.ts` | **新建** | 注释化后处理单元测试(含三档场景) |
### 两部分共享
| 文件 | 说明 |
|------|------|
| `src/types.ts` | Part A 加 `excludeLanguages`Part B 不改此文件(`duplicateLevel` 等在 `import-types.ts` |
| `src/rules/converters/*.ts` | Part A 加语言约束 promptPart B 加规则清单 + 重复检测 prompt——**同一次 prompt 修改** |
**改动统计**:新建 7 文件(含 `static-rules.json`),修改 9 文件。
---
## C2. 测试要求
### C2.1 Part A 单元测试
文件:`src/test/rule-filter.test.ts`
| # | 用例 | 输入 | 预期 |
|---|------|------|------|
| 1 | 白名单命中 | rule.languages=[java]document=java | 保留 |
| 2 | 白名单未命中 | rule.languages=[javascript]document=java | 剔除 |
| 3 | 白名单为空 | rule.languages=[]document=任意 | 保留 |
| 4 | 白名单缺失 | rule 无 languages 字段,document=任意 | 保留 |
| 5 | 黑名单命中剔除 | rule.excludeLanguages=[css]document=css | 剔除 |
| 6 | 黑名单未命中保留 | rule.excludeLanguages=[css]document=java | 保留 |
| 7 | 黑名单为空 | rule.excludeLanguages=[]document=任意 | 保留 |
| 8 | 白名单+黑名单交集 | languages=[java]excludeLanguages=[java]document=java | 剔除 |
| 9 | tsx 别名 | rule.languages=[typescript]document=typescriptreact | 保留 |
| 10 | plsql 同组 | rule.languages=[sql]document=plsql | 保留 |
| 11 | JSP 并集-java规则 | rule.languages=[java]document=html+.jsp | 保留 |
| 12 | JSP 并集-css规则 | rule.languages=[css]document=html+.jsp | 保留 |
| 13 | 普通HTML不触发JSP | rule.languages=[java]document=html(非.jsp | 剔除 |
| 14 | 全部过滤 | 7条规则,document=css | relevant=[]skippedRequestA=true |
| 15 | 部分过滤 | 7条规则,document=java | relevant=5filteredOut=2 |
### C2.2 Part B 单元测试
文件:`src/test/import-dedup.test.ts`
| # | 用例 | 输入 | 预期 |
|---|------|------|------|
| 1 | 无重复规则 | 5 条规则均为 duplicateLevel=none | 全部保留,无注释行 |
| 2 | 全部 exact | 3 条规则均为 duplicateLevel=exact | 全部注释,文件仅含 # 行 |
| 3 | 全部 overlap | 3 条规则均为 duplicateLevel=overlap | 全部保留(overlap 默认保留),无注释行 |
| 4 | 混合三档 | 2 exact + 2 overlap + 3 none | 2 条注释,5 条保留 |
| 5 | 用户恢复 exact 规则 | 默认注释的 exact 规则,用户 toggle 为保留 | 该规则无 # 前缀,其余不变 |
| 6 | 用户注释 overlap 规则 | 默认保留的 overlap 规则,用户 toggle 为注释 | 该规则加 # 前缀,含 overlap 注释头 |
| 7 | 用户注释 none 规则 | 默认保留的 none 规则,用户 toggle 为注释 | 该规则加 # 前缀 |
| 8 | duplicateLevel 行移除 | 保留规则的 YAML 中含 duplicateLevel 行 | 写盘后该行被移除 |
| 9 | 注释规则含 duplicateReason | overlap 规则被注释 | 注释头含"重叠原因"行 |
| 10 | 空规则列表 | Converter 返回 0 条规则 | 不写盘,返回取消 |
| 11 | YAML 直接导入 | ext=yaml | 直接复制,不调 AI,不弹预览 |
| 12 | static-rules.json 缺失 | buildDedupPromptSection 调用时文件不存在 | 返回空字符串,不中断 |
### C2.3 集成测试
`src/test/pipeline.test.ts` 中追加:
- 审查 CSS 文件时,验证请求 A 被跳过(`customRuleResults` 为空,`degraded` 为 false)。
- 审查 Java 文件时,验证只有 Java 相关规则出现在 `customRuleResults` 中。
- 导入含重复规则的 md 文件,验证预览面板显示重复标记,确认后文件中重复规则被注释。
---
## C3. 验证标准
| 验证项 | 方法 | 通过标准 |
|--------|------|---------|
| 编译 | `npm run compile` | 无 TypeScript 错误 |
| Lint | `npm run lint` | 无 ESLint 错误 |
| Part A 单测 | `npm test` | rule-filter.test.ts 全部通过 |
| Part B 单测 | `npm test` | import-dedup.test.ts 全部通过 |
| 预过滤-Java | 用 `src/test/manual/Buggy.java` 审查 | 自定义规则 Tab 显示"注入 5/7 条"custom 结果非空 |
| 预过滤-CSS | 用 `src/test/manual/buggy.css` 审查 | 自定义规则 Tab 显示"已跳过规则评估" |
| 预过滤-JSP | 用 `src/test/manual/buggy.jsp` 审查 | Java+JS 规则均保留注入 |
| 容错验证 | 构造 languages 误标的规则 | 留空规则始终保留,不因误标漏报 |
| 导入-YAML | 导入 .yaml 文件 | 直接复制,不弹预览 |
| 导入-MD-exact | 导入含完全重复规则的 .md 文件 | 预览显示"完全重复"区,确认后该规则被注释 |
| 导入-MD-overlap | 导入含部分重叠规则的 .md 文件 | 预览显示"部分重叠"区并置顶,含重叠原因,用户可选保留/注释 |
| 导入-恢复 | 在预览中点「恢复此规则」 | 该规则在写盘后无 # 前缀,可被 yaml-parser 加载 |
| 导入-取消 | 在预览中点「取消」 | 不写盘,不创建文件 |
| static-rules.json | 检查文件存在且格式正确 | 含五个 linter 分区,每条规则有 id + description |
---
## C4. 实施顺序
两部分合并实施,按依赖关系排序:
**第一批:Part A 最小闭环(预过滤 + 跳过请求 A)**
1. `types.ts` — 扩展 CustomRule 接口(加 `excludeLanguages`
2. `rule-filter.ts` — 新建过滤模块(核心,可独立测试)
3. `rule-filter.test.ts` — 编写单元测试
4. `yaml-parser.ts` — 适配 `excludeLanguages` 解析
5. `commands.ts` — review 命令接入 `filterAndSummarize`
> 步骤 1-5 完成后,预过滤即可生效。`engine.ts` 不需要改动(现有空规则处理已足够)。
**第二批:Part B 导入去重**
6. `static-rules.json` — 离线提炼四个 linter 规则集,人工验收定稿
7. `prompt-builder.ts` — 新建,从 static-rules.json 生成 prompt 片段
8. `import-types.ts` — 新建导入中间态类型(含三档字段)
9. `import-service.ts` — 拆分 convert/applyConversion + 三档注释化后处理
10. `import-dedup.test.ts` — 编写注释化单元测试(含三档场景)
11. `converters/*.ts` — prompt 追加语言约束 + 规则清单注入 + 三档重复检测(一次改完三个)
12. `import-preview.ts` — 新建预览 Webview 面板(三分区展示)
13. `setupView.ts` — addRule 分支改造
**第三批:体验增强(可后续迭代)**
14. `merger.ts` — MergedReport 新增 `customRuleFilterInfo`
15. `webview.ts` — 审查面板展示过滤信息
16. 集成测试 + 功能验证
> 第一批和第二批可并行开发(文件不重叠),第三批依赖第一批完成。
---
## C5. 设计决策记录
### Part A 决策
| 决策项 | 选择 | 理由 |
|--------|------|------|
| config.yaml 过滤机制 | 不实现/不保留(已废弃) | 全启用模式,规则文件存在即生效 |
| 过滤位置 | load 与 execute 之间(独立纯函数) | 不耦合加载逻辑,不改 runAIReview 签名 |
| engine.ts 改动 | **不需要** | 现有 `customRules.length > 0 ? ... : Promise.resolve('{}')` 已处理空规则 |
| 过滤策略 | 软过滤(留空规则始终保留) | 字段不准时退化为现状,不会误剔除 |
| languages 为空语义 | 全语言生效 | 安全默认 |
| 新增 excludeLanguages | 是 | 通用规则用黑名单比白名单更安全 |
| JSP 处理 | 子语言并集 | AI 一次评估整个 JSP,需看到所有相关规则 |
| SQL/PLSQL | 同组 | 与 sql-lint 适配器覆盖范围一致 |
### Part B 决策
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 检测范围 | 只对 md/txt/excel | YamlConverter 直接复制,用户手写 YAML 无需检测 |
| 确认流程 | 强制预览确认 | 误判会静默禁用规则,风险高 |
| 判定结果分档 | 三档:exact / overlap / none | 二元判定丢失"部分重叠"信息,overlap 交用户决定 |
| 规则清单来源 | 离线提炼,手工定稿打包 | 四个 linter 内置且版本固定,规则集是确定常量,无需运行时提取 |
| 规则清单精度 | 规则 ID + 一句话描述(Level 2) | 精确映射比模糊概述更准,prompt 增加约 1500 token 可接受 |
| 全部重复 | 仍写盘(全是注释) | 用户可取消注释恢复 |
| 注释化机制 | 行级 `#` 前缀 | yaml-parser 已跳过 `#` 行,无需改 parser |
| duplicateLevel 字段位置 | 导入中间态(`import-types.ts`),不加到 CustomRule | 它是导入时信息,不是运行时规则属性 |
| 预览形式 | Webview 结构化面板,三分区展示 | exact 折叠 / overlap 置顶展开 / none 折叠 |
| 预览交互 | 每条规则可 toggle 保留/注释 | 用户可一键纠正 AI 误判 |
| Converter prompt | 语言约束 + 规则清单 + 重复检测合并 | 同一次 AI 调用,一次 prompt 修改 |
| static-rules.json 维护 | linter 升级时重新提炼 | 版本随插件发布绑定,不会在用户侧变化 |
@@ -0,0 +1,217 @@
# 自定义规则导入去重增强方案
## 1. 架构概览
### 现状
```
static-rules.json ──→ buildDedupPromptSection() ──→ 嵌入 AI 提示词 → AI 判断 exact/overlap/none
```
### 目标
```
static-rules.json ──┐
├──→ buildDedupPromptSection(existingRules) ──→ AI 判断 exact/overlap/none
已导入自定义规则 ────┘
loadActiveRules() ── 自动加载 .code-review/rules/*.yaml
```
### 数据流
```
ImportService.convert()
│ ① 调用 loadActiveRules(workspaceRoot) 加载已有自定义规则
│ ② 将 existingRules 传递给 converter.convert()
│ │
│ └─ converter.buildSystemPrompt(existingRules)
│ │
│ └─ buildDedupPromptSection(existingRules)
│ │
│ ├─ 输出静态 linter 规则(不变)
│ └─ 追加已导入自定义规则,ID 前缀 custom/
│ ③ AI 返回结果,含 duplicateOf: custom/xxx、duplicateLevel 等字段
│ ④ parseImportableYaml() 解析,字段不变
showImportPreview()
│ ⑤ 识别 duplicateOf 的 custom/ 前缀,显示不同文案
buildFinalYaml()
│ ⑥ 注释头文案区分 custom/ 前缀
applyConversion() → 写入 .yaml
```
---
## 2. 文件变更清单
| 文件 | 变更类型 | 说明 |
|------|----------|------|
| `src/rules/converters/prompt-builder.ts` | 修改 | `buildDedupPromptSection()` 增加 `existingCustomRules` 参数 |
| `src/rules/converters/converter.ts` | 修改 | `convert()` 增加可选参数 `existingRules` |
| `src/rules/converters/md-converter.ts` | 修改 | 传递 `existingRules``buildSystemPrompt()` |
| `src/rules/converters/txt-converter.ts` | 修改 | 同上 |
| `src/rules/converters/excel-converter.ts` | 修改 | 同上 |
| `src/rules/converters/docx-converter.ts` | 修改 | 同上 |
| `src/rules/converters/pptx-converter.ts` | 修改 | 同上 |
| `src/rules/import-service.ts` | 修改 | `convert()` 加载已有规则;`buildFinalYaml()` 格式化 custom 注释头 |
| `src/rules/import-preview.ts` | 修改 | 识别 `custom/` 前缀显示区分文案 |
| `src/test/import-dedup.test.ts` | 修改 | 追加测试用例(自定义规则 exact/overlap/none |
---
## 3. 关键接口变更
### 3.1 `prompt-builder.ts`
```typescript
import type { CustomRule } from '../../types';
// 新增参数
export function buildDedupPromptSection(existingCustomRules?: CustomRule[]): string;
```
输出格式变更:
```
## 已知规则清单(用于重复检测)
### 内置 Linter 规则
#### eslint (XX 条)
- eslint/rule-id: description
...
### 已导入的自定义规则 (N 条)
- custom/my-rule: 禁止 console.log
- custom/no-var: 使用 const/let 替代 var
判定时请精确匹配上述规则 ID,而非模糊匹配分类。
```
### 3.2 `converter.ts`
```typescript
import type { CustomRule } from '../../types';
export interface RuleConverter {
supportedExtensions: string[];
convert(
srcPath: string,
context: vscode.ExtensionContext,
existingRules?: CustomRule[], // 新增参数
): Promise<string | null>;
}
```
### 3.3 Converters5 个文件,模式一致)
每个 converter 的 `buildSystemPrompt` 改为接受 `existingRules` 参数并向下传递。
**改前:**
```typescript
function buildSystemPrompt(): string {
return `...${buildDedupPromptSection()}...`;
}
```
**改后:**
```typescript
function buildSystemPrompt(existingRules?: CustomRule[]): string {
return `...${buildDedupPromptSection(existingRules)}...`;
}
```
对应 `convert()` 方法:
```typescript
async convert(srcPath: string, context: vscode.ExtensionContext, existingRules?: CustomRule[]): Promise<string | null> {
const content = fs.readFileSync(srcPath, 'utf-8');
return convertContentWithAI(content, context, buildSystemPrompt(existingRules));
}
```
### 3.4 `import-service.ts`
#### `ImportService.convert()` — 新增加载已有规则
```typescript
import { loadActiveRules } from './yaml-parser';
async convert(srcPath: string, context: vscode.ExtensionContext): Promise<ConversionResult> {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const existingRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
const ext = path.extname(srcPath).toLowerCase();
const converter = this.converters.get(ext);
// ...
const yamlContent = await converter.convert(srcPath, context, existingRules);
// ...
}
```
#### `buildFinalYaml()` — 注释头区分 custom 前缀
在生成 `# [DUPLICATE]` 注释头时,判断 `duplicateOf` 是否以 `custom/` 开头:
- `custom/xxx``# [DUPLICATE: exact] 重复自定义规则 xxx(检测目标完全一致)`
- `eslint/xxx``# [DUPLICATE: exact] 重复 eslint/xxx(检测目标完全一致)`(不变)
- overlap 同理
### 3.5 `import-preview.ts`
新增辅助函数,在 `renderRuleCard` 中调用:
```typescript
function formatDuplicateOf(dupOf: string | undefined): { type: 'custom' | 'linter'; ruleName: string } {
if (!dupOf) return { type: 'linter', ruleName: 'unknown' };
if (dupOf.startsWith('custom/')) {
return { type: 'custom', ruleName: dupOf.slice(7) };
}
return { type: 'linter', ruleName: dupOf };
}
```
UI 显示规则:
| duplicateOf | type | 显示文案 |
|-------------|------|----------|
| `custom/no-console-log` | custom | `重复:自定义规则 no-console-log` |
| `eslint/no-console` | linter | `重复:eslint/no-console`(不变) |
| overlap + custom | custom | `与自定义规则 no-console-log 部分重叠` |
| overlap + linter | linter | `与 eslint/no-console 部分重叠`(不变) |
---
## 4. 不变的部分
- `ImportableRule` 接口(字段不变,duplicateOf 的值新加 `custom/` 前缀由 AI 输出)
- `PreviewDecision` 接口
- `ConversionResult` 接口
- `YamlConverter`(YAML 直接复制不走 AI,不需要去重)
- `parseImportableYaml()`(解析逻辑不变,duplicateOf 字段值变化不影响解析)
- `buildFinalYaml()` 的保留逻辑(去重字段剥离、# 注释前缀)不变
---
## 5. 影响范围
### 正面
- 导入新规则时自动对比已有自定义规则,避免重复导入
- 已导入规则之间交叉重复也可检测(规则文件 A 和 B 之间有重复 ID/语义)
### 风险与应对
- **提示词长度增加**:如果已有规则很多(>50条),prompt 会变长。应对:当前实测 1788 条静态规则 + 50 条自定义规则约 80KB token,主流模型可承受。如果未来规则量过大,可考虑只传 ID 列表。
- **自参考**:正在导入的文件尚未写入 `.code-review/rules/`,不会出现自己检测自己的情况。
- **custom/ 前缀规范**:需要 AI 理解并准确输出,已在 prompt 中明确指定格式,并在已有示例中示范。
### 测试覆盖
需要在 `src/test/import-dedup.test.ts` 中新增:
1. 自定义规则 exact → 默认注释
2. 自定义规则 overlap → 默认保留
3. 自定义规则 exact 用户恢复 → 取消注释
4. 混合场景(部分与 linter 重复、部分与 custom 重复)
@@ -0,0 +1,66 @@
# Excel 多 Sheet 导入设计
## 1. 背景
当前 `ExcelConverter.convert()` 仅读取 Excel 文件的第一个 Sheet`workbook.SheetNames[0]`),后续 Sheet 被忽略。用户需要导入包含多个 Sheet 的 Excel 文件,将全部规则合并输出。
## 2. 目标
支持多 Sheet Excel 导入,所有 Sheet 合并为一份 YAML 输出。不改变 `RuleConverter` 接口签名。
## 3. 设计
### 3.1 数据流
```
用户选择 .xlsx/.xls 文件(多 Sheet
ExcelConverter.convert()
├── XLSX.readFile → 获取 workbook
├── 遍历 workbook.SheetNames
│ ├── 每个 sheet → sheet_to_json → buildMarkdownTable
│ └── 添加 "## SheetName" 标题分隔
├── 合并为一张大 Markdown 文本
├── 调用 convertContentWithAI(combined, context)
└── AI 返回 YAML(合并所有规则)→ 写入 yamlPath
```
### 3.2 变更范围
只改一个文件:`src/rules/converters/excel-converter.ts`
| 变更 | 说明 |
|------|------|
| 删除 `const sheetName = workbook.SheetNames[0]` | 不再只取第一个 Sheet |
| 新增 `buildSheetsMarkdown(workbook)` | 遍历所有 Sheet,逐个转 Markdown 表格并拼接 |
| 修改 `convert()` 内部 | 调用 `buildSheetsMarkdown` 替代单 Sheet 逻辑 |
| 保留 `buildMarkdownTable(rows)` | 复用现有单 Sheet 转表格函数 |
| 保留错误处理 | try/catch、空 Sheet 校验 |
### 3.3 合并格式示例
```
## Sheet1 - 命名规范
| id | severity | description | message |
|----|----------|-------------|---------|
| naming-1 | error | 类名使用 PascalCase | 请修改为 PascalCase |
## Sheet2 - 安全规范
| id | severity | description | message |
|----|----------|-------------|---------|
| security-1 | error | 禁止硬编码密码 | 请将密码移入配置文件 |
```
### 3.4 边界处理
- **空 Sheet**:跳过(不报错),继续处理其他 Sheet
- **全部 Sheet 为空**:报错提示"Excel 工作表中没有数据"
- **不同 Sheet 列结构不同**:各自独立转 Markdown 表格,AI 自主理解
- **单 Sheet 文件**:行为不变,兼容现有功能
### 3.5 不涉及变更
- `setupView.ts`ExcelConverter 已注册,无需改动
- `import-service.ts``convertContentWithAI` 已存在,无需改动
- `converter.ts``RuleConverter` 接口不变
- 其他 converter:不受影响
@@ -0,0 +1,136 @@
# Word / PPT 规则导入设计
## 1. 背景
当前自定义规则导入支持 YAML、Markdown、TXT、Excel.xlsx/.xls)格式,用户需要补充 Word.docx)和 PowerPoint.pptx)文件导入能力。
## 2. 目标
- 支持 .docx 文件导入:通过 mammoth 提取 Markdown 文本,AI 转换为规则 YAML
- 支持 .pptx 文件导入:通过 officeparser 提取幻灯片文本,AI 转换为规则 YAML
- 沿用现有 Converter 架构(RuleConverter 接口 + ImportService 注册机制)
## 3. 架构
### 3.1 数据流
```
setupView.ts (addRule)
ImportService.convert(srcPath, context)
├── path.extname → DocxConverter (.docx)
│ └── mammoth.extractRawText() / convertToMarkdown()
│ └── Markdown 文本 → convertContentWithAI() → YAML
├── path.extname → PptxConverter (.pptx)
│ └── officeparser.parsePptx()
│ └── 纯文本(幻灯片逐页分隔)→ convertContentWithAI() → YAML
└── parseImportableYaml() → 预览 → 写入 .code-review/rules/<name>.yaml
```
### 3.2 现有架构映射
| 组件 | 文件 | 说明 |
|------|------|------|
| RuleConverter 接口 | `src/rules/converters/converter.ts` | 不变 |
| ImportService | `src/rules/import-service.ts` | 不变 |
| convertContentWithAI | `src/rules/import-service.ts` | 复用 |
| setupView 注册 | `src/views/setupView.ts` | 新增两个 converter 注册 + 更新文件过滤器 |
## 4. 文件变更清单
| 文件 | 操作 | 说明 |
|------|------|------|
| `src/rules/converters/docx-converter.ts` | **新建** | .docx → mammoth → AI → YAML |
| `src/rules/converters/pptx-converter.ts` | **新建** | .pptx → officeparser → AI → YAML |
| `src/views/setupView.ts` | **修改** | 注册两个新 converter + 更新文件过滤器 |
| `package.json` | **修改** | 添加 mammoth + officeparser 依赖 |
## 5. 关键实现
### 5.1 DocxConverter
```typescript
// src/rules/converters/docx-converter.ts
// 依赖:mammoth(将 .docx 提取为 Markdown/HTML
//
// 行为:
// 1. mammoth.convertToMarkdown() 提取 Markdown(含表格→Markdown 表格)
// 2. 将 Markdown 文本传入 convertContentWithAI()
// 3. AI 按已有 prompt 生成 YAML 规则
//
// 复用现有 buildSystemPrompt() 模式
// systemPrompt 与 md-converter.ts 相同(自然语言规则描述 → YAML)
// 由于 mammoth 已产出 MarkdownAI 可识别表格/段落/列表结构
```
### 5.2 PptxConverter
```typescript
// src/rules/converters/pptx-converter.ts
// 依赖:officeparser(将 .pptx 提取为纯文本)
//
// 行为:
// 1. officeparser.parsePptx() 提取所有幻灯片文本
// 2. 幻灯片之间用 "\n\n--- Slide N ---\n\n" 分隔
// 3. 合并文本传入 convertContentWithAI()
// 4. AI 按已有 prompt 生成 YAML 规则
//
// systemPrompt 与 md-converter.ts 相同
```
### 5.3 setupView.ts 变更
```typescript
// 1. 导入新 converter
import { DocxConverter } from '../rules/converters/docx-converter';
import { PptxConverter } from '../rules/converters/pptx-converter';
// 2. 构造函数中注册
this.importService.registerConverter(new DocxConverter());
this.importService.registerConverter(new PptxConverter());
// 3. addRule() 文件过滤器增加扩展名
filters: { '规则文件': ['yaml', 'yml', 'md', 'txt', 'xlsx', 'xls', 'docx', 'pptx'] },
```
### 5.4 package.json 依赖
```json
"dependencies": {
...,
"mammoth": "^1.8.0",
"officeparser": "^4.2.0"
}
```
## 6. 错误处理
| 场景 | 处理方式 |
|------|----------|
| docx 文件损坏 | mammoth 抛出异常 → try/catch 弹错误提示 → return null |
| pptx 文件损坏 | officeparser 抛出异常 → try/catch 弹错误提示 → return null |
| docx 无内容 | mammoth 返回空字符串 → convertContentWithAI 检测到空内容 → 弹"所选文件为空" |
| pptx 无内容 | officeparser 返回空字符串 → 同上 |
| officeparser 未安装依赖 | 运行时缺 jszip 报错 → catch 弹提示 |
## 7. 不涉及变更
- `converter.ts`RuleConverter 接口不变
- `import-service.ts`ImportService、convertContentWithAI、parseImportableYaml 均不变
- `import-preview.ts`:预览 Webview 不变
- `yaml-parser.ts`:运行时加载不变
- `rule-filter.ts`:规则过滤不变
- 其他 converter:不受影响
- 前端 webview JS`src/views/setupView.js`):无变更
## 8. 测试
新增文件不涉及现有测试变更。可通过以下方式验证:
1. 准备一个含规则表格的 .docx 文件和一个含规则列表的 .pptx 文件
2. 在设置面板中点击"+ 添加",分别选择 .docx 和 .pptx 文件
3. 检查预览是否正确解析规则,命中等
4. 确认 .code-review/rules/ 下生成正确的 .yaml 文件
@@ -0,0 +1,263 @@
# 规则导入预览编辑设计
> 适用项目:vscode-code-reviewer
> 设计日期:2026-07-25
> 参考文档:`docs/superpowers/specs/2026-07-23-rule-prefilter-design.md` Part B(导入去重)
## 1. 现状
`import-preview.ts` 中预览 Webview 面板仅支持:
- 按 exact/overlap/none 三区展示规则卡片
- overlap 规则可切换「保留/注释」
- exact 规则有「恢复」按钮
- **无字段编辑能力**
用户确认后,`buildFinalYaml` 对原始 `yamlContent` 做行级操作(加/去 `#` 前缀)。编辑后的字段值无法传入。
## 2. 目标
导入预览中支持:
- 每条规则卡片默认证折叠,点击展开完整编辑表单
- 可编辑字段:severity(下拉框)、description(多行文本)、message(多行文本)、languages(标签式输入)、excludeLanguages(标签式输入)
- id 只读显示(不可编辑)
- 所有规则类型(exact/overlap/none)均可编辑
- 展开表单右上角显示「保留/注释」切换,两种状态下都可编辑字段
- 确认后基于修改后的 rules 数组直接生成最终 YAML 写盘
## 3. 数据流变更
```
现状:
importService.convert() → ConversionResult → showImportPreview() → PreviewDecision{keepRule}
buildFinalYaml(操作原始 yamlContent 行)
改造后:
importService.convert() → ConversionResult → showImportPreview() → PreviewDecision{keepRule, editedRules}
renderRulesToYaml(从 rules 数组生成新 YAML)
```
关键变化:写盘不再基于原始 `yamlContent` 文本行操作,而是从(可能被用户编辑过的)rules 数组渲染出完整 YAML。
## 4. 数据结构变更
### 4.1 PreviewDecision 扩展
```typescript
// src/rules/import-types.ts
export interface PreviewDecision {
keepRule: Record<string, boolean>;
confirmed: boolean;
editedRules?: ImportableRule[]; // 用户编辑后的完整规则数组(含未修改的规则)
}
```
`editedRules``undefined` 表示用户未做任何字段修改,回退到原始 rules。非 `undefined` 时用于生成最终 YAML。
## 5. 预览 Webview 重设计
### 5.1 卡片结构
```
┌─────────────────────────────────────────────┐
│ no-console-log [▼ 展开] │ ← 折叠态:id + severity 标签 + 描述摘要
│ ⚠ warning │
│ 禁止在 console.log 中输出敏感信息 │
└─────────────────────────────────────────────┘
展开态: ← 点击卡片任意位置展开
┌─────────────────────────────────────────────┐
│ no-console-log [保留] [注释] │ ← 顶部:id + 保留/注释切换
├─────────────────────────────────────────────┤
│ severity │
│ [▼ error ▼] ← 下拉框 │
│ │
│ description │
│ ┌─────────────────────────────────────────┐│
│ │ 禁止在 console.log 中输出敏感信息 ││ ← textarea
│ │ ││
│ └─────────────────────────────────────────┘│
│ │
│ message │
│ ┌─────────────────────────────────────────┐│
│ │ 检测到敏感信息输出到 console,请移除 ││ ← textarea
│ │ ││
│ └─────────────────────────────────────────┘│
│ │
│ languages │
│ [java ×] [typescript ×] [▌ ] │ ← 标签式输入
│ │
│ excludeLanguages │
│ [▌ ] │ ← 标签式输入
└─────────────────────────────────────────────┘
```
### 5.2 交互行为
| 操作 | 行为 |
|------|------|
| 点击折叠卡片 | 展开表单,其余卡片不受影响 |
| 展开状态下再次点击顶部 | 折叠回摘要 |
| 修改字段 | 实时保存在前端 `modifiedRules` map 中 |
| 切换保留/注释 | 更新 keepRule,不影响已编辑的字段值 |
| 点「确认导入」 | 发送 `confirm` 消息 + 全部修改后的 rules 数据 |
| 点「取消」 | 不写盘 |
### 5.3 标签式输入实现
languages / excludeLanguages 使用纯 HTML/CSS/JS 实现:
- 文本输入框 + 已添加标签的行内显示
- 输入语言名后按 `Enter``,` 添加为标签
- 标签显示为 chip 样式,右侧 `×` 按钮删除
- 去重(同名不重复添加)
- 支持粘贴逗号分隔列表
### 5.4 字段校验(确认时)
| 字段 | 规则 |
|------|------|
| severity | 必须是 `error` / `warning` / `info` 之一(下拉框天然保证) |
| description | 非空,trim() 后长度 > 0 |
| message | 非空,trim() 后长度 > 0 |
| languages | 可选,每个值非空字符串 |
| excludeLanguages | 可选,每个值非空字符串 |
校验不通过时弹 `vscode.window.showErrorMessage` 提示具体字段名,不关闭面板。
## 6. YAML 生成函数
新增 `renderRulesToYaml` 替代原有行级操作逻辑:
```typescript
// src/rules/import-service.ts
function renderRulesToYaml(
rules: ImportableRule[],
decision: PreviewDecision,
): string {
const lines: string[] = [];
for (const rule of rules) {
const keep = decision.keepRule[rule.id] ?? rule.duplicateLevel !== 'exact';
// 构造该规则的标准 YAML 行
const ruleLines: string[] = [];
ruleLines.push(`- id: ${rule.id}`);
ruleLines.push(` severity: ${rule.severity}`);
ruleLines.push(` description: ${rule.description}`);
ruleLines.push(` message: ${rule.message}`);
if (rule.languages && rule.languages.length > 0) {
ruleLines.push(` languages: [${rule.languages.join(', ')}]`);
}
if (rule.excludeLanguages && rule.excludeLanguages.length > 0) {
ruleLines.push(` excludeLanguages: [${rule.excludeLanguages.join(', ')}]`);
}
if (keep) {
lines.push(...ruleLines);
} else {
// 注释化:加注释头 + 每行加 #
const dupLevel = rule.duplicateLevel ?? 'none';
const dupOf = rule.duplicateOf ?? 'manual';
if (dupLevel === 'exact') {
lines.push(`# [DUPLICATE: exact] 重复 ${dupOf}(检测目标完全一致)`);
} else if (dupLevel === 'overlap') {
lines.push(`# [DUPLICATE: overlap] 与 ${dupOf} 部分重叠`);
if (rule.duplicateReason) {
lines.push(`# 重叠原因:${rule.duplicateReason}`);
}
} else {
lines.push(`# [手动注释] 用户选择不启用此规则`);
}
lines.push(`# 如需启用,删除以下每行开头的 # 即可`);
for (const rl of ruleLines) {
lines.push(`# ${rl}`);
}
}
lines.push(''); // 规则间空行
}
return lines.join('\n');
}
```
`buildFinalYaml` 改为判断入口:
```typescript
export function buildFinalYaml(
yamlContent: string,
rules: ImportableRule[],
decision: PreviewDecision,
): string {
if (decision.editedRules && decision.editedRules.length > 0) {
// 用户编辑过字段,从编辑后的 rules 渲染
return renderRulesToYaml(decision.editedRules, decision);
}
// 无字段编辑,使用原始 yamlContent(保持向后兼容)
return buildFinalYamlFromRaw(yamlContent, rules, decision);
}
// 原 buildFinalYaml 逻辑重命名为 buildFinalYamlFromRaw
```
## 7. 前端消息协议扩展
```typescript
// 新增消息类型
interface UpdateRuleMessage {
type: 'updateRule';
ruleId: string;
rule: ImportableRule; // 该规则的完整最新字段值
}
// confirm 消息扩展:确认时携带编辑数据
interface ConfirmMessage {
type: 'confirm';
editedRules?: ImportableRule[]; // 附加全部规则的最新字段值
}
```
## 8. 文件变更清单
| 文件 | 操作 | 内容 |
|------|------|------|
| `src/rules/import-types.ts` | 修改 | `PreviewDecision` 新增 `editedRules?: ImportableRule[]` |
| `src/rules/import-preview.ts` | 重写 | 可折叠卡片 + 展开编辑表单 + 校验 + 传递编辑数据 |
| `src/rules/import-service.ts` | 修改 | 新增 `renderRulesToYaml``buildFinalYaml` 做入口判断 |
| `src/views/setupView.ts` | 不改 | `applyConversion` 调用不变(PreviewDecision 接口向后兼容) |
| `src/test/import-dedup.test.ts` | 修改 | 补充编辑后生成 YAML 的测试用例 |
## 9. 测试用例
追加到 `src/test/import-dedup.test.ts`
| # | 用例 | 输入 | 预期 |
|---|------|------|------|
| 13 | 编辑 description 后确认 | rule 的 description 被修改为新值 | 最终 YAML 中该规则的 description 为新值 |
| 14 | 编辑 severity 后确认 | rule 的 severity 从 warning 改为 error | 最终 YAML 中 severity 为 error |
| 15 | 编辑 languages 后确认 | 添加 javascript 到 languages | 最终 YAML 含 `languages: [javascript]` |
| 16 | 编辑后切换为注释 | 编辑字段后 toggle 为注释 | 规则被注释,注释内容为编辑后的值 |
| 17 | 无编辑场景回退 | `editedRules` 为 undefined | 行为与当前 `buildFinalYamlFromRaw` 一致 |
## 10. 实施顺序
1. `import-types.ts` — PreviewDecision 扩展
2. `import-service.ts` — 新增 `renderRulesToYaml`,改造 `buildFinalYaml`
3. `import-preview.ts` — 重写 Webview(卡片展开 + 编辑表单 + 校验 + 传递编辑数据)
4. `import-dedup.test.ts` — 补充测试
## 11. 设计决策记录
| 决策项 | 选择 | 理由 |
|--------|------|------|
| 编辑数据传递方式 | confirm 时携带 editedRules 数组 | 无需逐字段实时回传,减少消息往返 |
| 无编辑时行为 | 回退到原始 yamlContent 行操作 | 保证未编辑场景零变化、零风险 |
| languages 输入 | 标签式 chip 输入(Enter/, 添加) | 比逗号分隔文本框更直观,防格式错误 |
| 展开方式 | 点击卡片切换展开/折叠 | 简单直接,不增加额外按钮 |
| 保留/注释与编辑 | 共存,互不影响 | 出用户需求:保留/注释状态不影响字段编辑 |
@@ -0,0 +1,263 @@
# 自定义规则导入体验改进设计
## 背景与问题
当前自定义规则导入功能支持六种文件格式,其中五种(`.md` / `.txt` / `.docx` / `.xlsx` / `.pptx`)经由 AI 转换为结构化 YAML,`.yaml` / `.yml` 则直接复制落盘。转换流水线本身已跑通,但用户体验存在一个隐性断层:文件扩展名虽宽松,为了让 AI 准确识别规则字段,用户实际上仍需按某种"事实上的格式"组织内容——Excel 要表头语义化、文本要分段清晰、字段顺序要合理。格式门槛从文件扩展名转移到了内容结构上,用户不知道该怎么写,AI 转换结果也因此不稳定。
具体表现有两点。其一,系统提示词只规定了输出字段(id / severity / description / message 等),对输入几乎没有任何描述与容忍说明,AI 遇到松散内容时行为不可预期。其二,解析侧 `parseImportableYaml` 对必填字段采取"缺一即丢弃"的静默策略,`severity` 缺失或非法、`id` 缺失都会让整条规则消失,用户在预览面板根本看不到被丢弃的规则,无从修正。
## 目标
降低输入内容组织门槛,让 AI 能处理自然语言段落、松散列表、混合表格等各种写法;同时给用户最小化的内容指引,消除"不知道怎么组织内容"的困惑。核心字段(id / severity)由 AI 基于规则内容自动推断,解析侧提供兜底,预览阶段可人工修正。
## 方案概览
三处改动协同发挥作用:
1. **提示词增强** — 明确告知 AI 接受松散输入,主动推断 id 与 severity
2. **解析侧兜底** — severity 缺失降级为 warning,id 缺失生成占位符,丢弃可见
3. **预览与入口** — id 改可编辑,占位 id 高亮提示,导入入口加简短指引
设计原则是"AI 推断为主,解析兜底为底,人工预览为终"。AI 推断承担大部分场景,解析兜底保证下限不崩,预览阶段保留人工否决权。
## 改动一:提示词增强
位置:`src/rules/converters/prompt-builder.ts` 与六个转换器的 `buildSystemPrompt`
当前提示词只规定了输出字段清单,对输入形态只字未提。增强内容如下。
### 输入容忍说明
在系统提示词开头增加输入形态描述,告知 AI 接受以下任一形态:
- 自然语言段落(一段话描述多条规则)
- 无序列表(每条规则一行或一段)
- 表格(列名不固定,语义可推断)
- 混合形式(段落 + 列表 + 表格组合)
明确要求 AI 不得因输入形态非标准而拒绝转换,应主动从松散描述中提取规则语义。
### 非规则内容过滤
导入文件的内容不全是规则,可能混入项目介绍、背景说明、代码示例、章节标题等非规则内容。增加要求:
- AI 应识别并跳过非规则内容,只将真正的规则转为 YAML 条目
- 代码示例、项目介绍等仅作为上下文帮助理解规则语义,本身不输出为规则
- 若某段内容无法判断为规则(既无规则意图也无违反提示),直接忽略,不强行转成规则
- 此要求与解析侧兜底呼应:即使 AI 误将非规则内容输出为条目,解析时因 description 与 message 同时缺失会被丢弃
### id 推断要求
当前提示词仅说明"id: 规则唯一标识(kebab-case 英文)",未要求 AI 主动生成。改为:
- 每条规则**必须**输出 `id`,基于规则描述内容自动生成
- id 规范:kebab-case 英文、语义化、简短(如 `no-console-log``avoid-magic-number``require-error-handling`)
- 输入中即使无显式 id 标识,也要根据 description / message 的语义推断出合适的 id
- 多条规则间 id 不得重复
### severity 推断要求
当前提示词仅列出 severity 三档,未说明缺失时如何处理。增加:
- severity 必须输出,按规则语义推断:`error`(会导致 bug / 安全问题 / 数据损坏)、`warning`(潜在问题 / 不良实践)、`info`(风格 / 可读性建议)
- 输入中即使无显式严重级别,也要根据规则描述的后果严重程度推断
### description / message 互推要求
当前提示词将 description 与 message 视为两个独立必填字段。两者本意相近:description 偏"规则是什么",message 偏"违反时说什么"。增加:
- 两者必须至少输出一项;若输入只暗示了规则内容而未区分"描述"与"提示",AI 应据已有信息推断并补全另一项
- description 缺失时,由 message 反推简短描述;message 缺失时,由 description 推导违反提示
- 两者语义可相近,无需强行区分口吻
### 输入输出对照示例
在提示词中附 2-3 个"松散输入 → 标准输出"的对照示例,锚定 AI 的行为预期。示例应覆盖不同输入形态,包括含非规则内容的混合输入:
```
输入(松散段落):
"不要用 console.log,生产环境会泄露信息。还有不要留下未使用的变量,看着乱。"
输出:
- id: no-console-log
severity: warning
description: 禁止使用 console.log
message: 请使用 logger 工具替代 console.log
duplicateLevel: none
- id: no-unused-vars
severity: warning
description: 禁止未使用的变量
message: 未使用的变量应删除或注释
duplicateOf: eslint/no-unused-vars
duplicateLevel: exact
```
```
输入(含非规则内容的混合输入):
"本项目是一个电商后台管理系统,主要使用 Java + Spring Boot 开发。
代码规范要求:Service 层方法必须有日志记录,方便排查问题。
示例代码:
public void createOrder(Order order) { ... }
另外,Controller 层返回值统一用 Result 包装,不要直接返回 Map。"
输出:
- id: require-service-logging
severity: warning
description: Service 层方法必须有日志记录
message: Service 方法缺少日志记录,请补充以便排查问题
languages: [java]
duplicateLevel: none
- id: require-result-wrapper
severity: warning
description: Controller 返回值必须用 Result 包装
message: 请用 Result 包装返回值,不要直接返回 Map
languages: [java]
duplicateLevel: none
```
第二个示例中,项目介绍与代码示例均未转为规则,仅提取出两条真正的代码规范。示例的作用是让 AI 理解"一段话也能拆成多条规则"且"非规则内容应跳过",而非要求用户按此格式输入。
## 改动二:解析侧兜底
位置:`src/rules/import-service.ts``parseImportableYaml`
### 当前行为
`parseImportableYaml` 过滤条件为 `item.id && item.severity && item.description && item.message`,缺任一即丢弃,且丢弃不可见。
### 新行为
| 字段 | 缺失或非法时 | 处理 |
|------|-------------|------|
| `id` | 缺失 | 生成占位 id `rule-${序号}`(rule-1、rule-2…) |
| `severity` | 缺失或非三档之一 | 降级为 `warning` |
| `description` / `message` | 仅一项缺失 | 用存在的那个填充缺失项(description↔message 互为兜底) |
| `description``message` | 同时缺失 | 丢弃该条 |
`description``message` 本意相近,均为规则的文本表达:前者偏"规则是什么",后者偏"违反时说什么"。两者只要有一项存在,即可作为另一项的兜底来源(直接复制,或由 AI 在转换阶段据一项推断另一项);两者同时缺失,说明这段内容根本不是一条规则,丢弃合理。id 与 severity 属于"可推断字段",缺失时兜底补全而非丢弃。
### 丢弃无需可视化
被丢弃的内容因"既无 description 也无 message",本就不是规则,不属于"字段不全的规则",因此不设 `droppedCount` 字段,预览面板也不提示丢弃数。解析后的 `ImportableRule` 数组只包含确为规则的内容,预览展示与确认流程保持简洁。
## 改动三:预览与入口
位置:`src/rules/import-preview.ts``src/views/setupView.ts``src/i18n/messages.ts`
### 预览面板 id 可编辑
当前预览面板的 id 显示为只读 `<span>`(`import-preview.ts``renderRuleCard`)。改为 `<input>` 元素:
- id 输入框可编辑,用户可修改 AI 生成的 id 或补充占位 id
- 占位 id(`rule-N`)用高亮样式提示"请补充或确认"(如橙色边框 + 提示文案)
- `collectEditedRules()` 已收集 id,无需改动收集逻辑
### 确认校验扩展
当前 `validate()` 仅校验 description 与 message 非空。扩展为:
- id 非空(占位 id `rule-N` 视为非空,但高亮提示用户确认)
- description 非空
- message 非空
任一为空则阻止提交,在 `validationError` 区域显示具体提示。
### 导入入口指引
`setupView.ts` 的文件选择按钮旁增加一行简短说明,告知用户:
- 支持自由文本:一段话、列表、表格均可
- 每条规则尽量说清"查什么 + 违反时提示什么"
- 不强制字段顺序和格式,id 与严重级别可由系统自动推断
文案走 i18n,新增对应 key。
## 字段处理策略总表
改动完成后,四个核心字段的全链路处理策略如下:
| 字段 | AI 推断 | 解析兜底 | 预览可编辑 | 确认校验 |
|------|---------|---------|-----------|---------|
| `id` | 必生成,基于内容语义 | 缺则占位 `rule-N` | 是(改为 input) | 非空 |
| `severity` | 必输出,按语义推断 | 缺/非法→`warning` | 是(已可) | — |
| `description` | 必输出,缺失时由 message 推 | 缺则用 message 填充 | 是(已可) | 非空 |
| `message` | 必输出,缺失时由 description 推 | 缺则用 description 填充 | 是(已可) | 非空 |
`description``message` 同时缺失时丢弃该条(非规则,不提示)。`languages` / `excludeLanguages` / `duplicateOf` / `duplicateLevel` / `duplicateReason` 字段处理逻辑不变,维持现状。
## 改动文件清单
| 文件 | 改动内容 |
|------|---------|
| `src/rules/converters/prompt-builder.ts` | 提示词增强:输入容忍说明 + 非规则内容过滤 + id/severity 推断要求 + description/message 互推要求 + 对照示例 |
| `src/rules/converters/md-converter.ts` | 同步 `buildSystemPrompt` 增强内容(与 prompt-builder 对齐) |
| `src/rules/converters/txt-converter.ts` | 同上 |
| `src/rules/converters/docx-converter.ts` | 同上 |
| `src/rules/converters/excel-converter.ts` | 同上,表格输入说明略调 |
| `src/rules/converters/pptx-converter.ts` | 同上 |
| `src/rules/import-service.ts` | `parseImportableYaml` 放宽:severity 兜底、id 占位、description/message 互填 |
| `src/rules/import-preview.ts` | id 改可编辑 input + 占位 id 高亮 + 确认校验加 id 非空 |
| `src/views/setupView.ts` | 导入入口加简短内容指引文案 |
| `src/i18n/messages.ts` | 新增文案 key:占位提示、指引文案 |
注:六个转换器的 `buildSystemPrompt` 当前各自重复了完整提示词。改动一时可考虑将公共部分抽到 `prompt-builder.ts` 统一构建,减少重复;但为控制改动范围,本次仍保持各转换器独立维护提示词,仅同步增强内容。是否抽公共函数留待实现阶段判断。
## 测试更新
### 现有测试核查
| 测试文件 | 核查点 |
|---------|-------|
| `src/test/import-dedup.test.ts` | 是否依赖"缺 id 即丢弃"的旧行为;若是,同步调整断言 |
| `src/test/rule-filter.test.ts` | 是否依赖 severity 严格校验;`loadActiveRules` 行为不变,预期无影响 |
### 新增测试
| 测试场景 | 验证点 |
|---------|-------|
| severity 缺失 | 解析后降级为 `warning`,规则保留 |
| severity 非法值 | 解析后降级为 `warning`,规则保留 |
| id 缺失 | 解析后生成占位 id `rule-N`,规则保留 |
| description 缺失、message 存在 | 解析后 description 由 message 填充,规则保留 |
| message 缺失、description 存在 | 解析后 message 由 description 填充,规则保留 |
| description 与 message 同时缺失 | 该条被丢弃(非规则) |
| 正常完整输入 | 行为不变,无回归 |
## 不改动的部分
- 文件格式扩展名范围不变(仍六种)
- YAML 直传路径不变(不经 AI,本就最宽松)
- 预览 Webview 的整体布局与交互逻辑不变(仅 id 区域改动)
- 去重检测逻辑不变(`prompt-builder.ts``buildDedupPromptSection` 不动)
- 规则应用阶段不变(`rule-filter.ts``filterForDocument` 不动)
## 风险与权衡
### AI 推断的不确定性
改动一依赖 AI 行为,效果有不确定性:AI 可能生成不理想的 id(如过长、非 kebab-case、语义不准)。缓解措施:解析兜底保证下限(规则不丢失),预览阶段用户可逐条修正 id。即使 AI 推断不完美,也比"规则直接消失"好。
### 放宽丢弃条件的副作用
改动二放宽了丢弃条件,可能引入语义不完整的规则(如 id 为占位符、severity 兜底为 warning 但实际应是 error、description 与 message 文本雷同)。缓解措施:预览阶段用户仍可逐条删除/注释/修正,占位 id 高亮提示用户确认。可控。
### 提示词膨胀
六个转换器各维护一份完整提示词,改动一需同步六处,提示词长度增加。缓解措施:实现阶段评估是否抽公共函数到 `prompt-builder.ts`,若改动范围可控则保持现状。
### 兼容性
预览面板 id 改可编辑为向后兼容改动(原 `<span>``<input>`,收集逻辑已支持)。`parseImportableYaml` 放宽丢弃条件不影响现有调用方,原"完整字段才进入预览"的规则仍正常通过。
## 验证顺序
按项目规范 `lint → compile → test`:
1. `npm run lint` — ESLint 通过
2. `npm run compile` — TypeScript 编译通过
3. `npm test` — 全部测试通过,含新增测试用例
4. F5 启动 Extension Dev Host,手动验证:
- 导入松散文本(一段话描述多条规则),确认 AI 正确拆分并生成 id
- 导入缺 severity 的 YAML,确认降级为 warning 且规则保留
- 预览面板修改 id,确认可编辑
- 确认校验阻止空 id 提交
@@ -0,0 +1,983 @@
# 静态分析适配器优化设计书(AI 编码用)
> 面向 AI 编码助手的技术实现规格。覆盖三种配置模式优先级机制、侧边栏适配器配置面板、外部依赖检测、配置文件模板生成等全部优化项。包含 TypeScript 接口定义、方法签名、文件级变更规格和实现伪代码。
- **项目**: vscode-code-reviewer (Code Purifier)
- **分支**: vscode-code-reviewer
- **日期**: 2026-07-27
- **版本**: 1.0.1 → 1.1.0
---
## 目录
- [01 设计目标与范围](#01-设计目标与范围)
- [02 现有架构分析](#02-现有架构分析)
- [03 配置模式优先级机制](#03-配置模式优先级机制)
- [04 数据结构设计](#04-数据结构设计)
- [05 配置项 Schema 变更](#05-配置项-schema-变更)
- [06 状态检测逻辑](#06-状态检测逻辑)
- [07 侧边栏面板实现](#07-侧边栏面板实现)
- [08 文件级变更规格](#08-文件级变更规格)
- [09 默认配置模板](#09-默认配置模板)
- [10 测试要点](#10-测试要点)
---
## 01 设计目标与范围
本次优化在不破坏现有代码审查流程的前提下,为四个静态分析适配器(ESLint、Stylelint、PMD、SQL-Lint)增加统一的三层配置模式机制,并在侧边栏设置面板中新增"静态分析适配器"可视化配置区域。用户无需查阅外部文档即可完成全部适配器配置。
### 优化项清单
| # | 优化项 | 涉及文件 | 变更类型 |
|---|--------|----------|----------|
| 1 | 三层配置模式优先级(全局 > 项目级 > 内置) | `config/linter.ts`、各适配器文件 | 新增逻辑 |
| 2 | 适配器启用/禁用开关 | `package.json``config/linter.ts``orchestrator/*` | 新增配置 + 逻辑 |
| 3 | 侧边栏适配器配置面板(4 张卡片) | `views/setupView.ts``views/setupView.js` | 新增 UI + 逻辑 |
| 4 | 配置模式自动检测 | `views/setupView.ts` | 新增方法 |
| 5 | 外部依赖检测(Java / Python+sqlfluff | `views/setupView.ts` | 新增方法 |
| 6 | 配置文件模板创建与打开 | `views/setupView.ts` | 新增方法 |
| 7 | 配置变更实时刷新 | `views/setupView.ts` | 新增监听器 |
| 8 | ESLint / Stylelint 自定义配置路径支持 | `package.json``config/linter.ts``adapters/eslint.ts``adapters/stylelint.ts` | 新增配置 + 逻辑 |
> **零破坏性原则**:所有变更均为新增或追加,不修改任何现有逻辑分支。现有的 AI 配置、规则文件导入、连接测试、代码审查命令等功能完全不受影响。适配器卡片列表由独立的 `<div id="adapter-list">` 容器渲染,即使渲染函数出错也不影响其他区域。
---
## 02 现有架构分析
### 源码目录结构
```
src/
├── activation/ # 插件激活逻辑
├── adapters/ # 静态分析适配器
│ ├── adapter.ts # 类型 re-export
│ ├── eslint.ts # ESLint 适配器
│ ├── stylelint.ts # Stylelint 适配器
│ ├── pmd.ts # PMD 适配器
│ ├── sql-lint.ts # SQL-Lint 适配器
│ └── jsp.ts # JSP 适配器
├── ai/ # AI 审查引擎
├── config/ # 配置读取层
│ ├── index.ts
│ ├── ai.ts
│ ├── linter.ts # ← 本次重点变更
│ ├── fixer.ts
│ └── secret.ts
├── orchestrator/ # 审查编排器(调度适配器)
├── views/
│ ├── setupView.ts # ← 本次重点变更
│ └── setupView.js # ← 本次重点变更
├── panel/ # 审查结果面板
├── types.ts # 核心类型定义
├── extension.ts # 插件入口
└── ...
```
### 现有核心类型(src/types.ts
```typescript
import * as vscode from 'vscode';
export type Severity = 'error' | 'warning' | 'info';
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
export interface LinterDiagnostic {
severity: Severity;
ruleId: string;
message: string;
range: vscode.Range;
suggestion?: string;
}
export interface AdapterResult {
diagnostics: LinterDiagnostic[];
status: AdapterStatus;
errorMessage?: string;
}
export interface LinterAdapter {
id: string;
supportedLanguages: string[];
check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult>;
isAvailable(): boolean;
}
```
### 现有配置读取层(src/config/linter.ts
```typescript
import * as vscode from 'vscode';
const ROOT = 'vscode-code-reviewer';
export function getLinterForLanguage(language: string): string {
return vscode.workspace.getConfiguration(ROOT).get<string>(`linters.${language}`, '');
}
export function getPmdJarPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jarPath', '');
}
export function getPmdRulesetPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.rulesetPath', '');
}
export function getPmdJspRulesetPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('pmd.jspRulesetPath', '');
}
export function getSqlLintConfigFile(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('sql-lint.configFile', '');
}
```
### 现有 package.json 配置项(相关部分)
| 配置键 | 类型 | 默认值 | 说明 |
|--------|------|--------|------|
| `linters.javascript` | string (enum) | `"eslint"` | JS 语言的 linter 选择 |
| `linters.typescript` | string (enum) | `"eslint"` | TS 语言的 linter 选择 |
| `linters.java` | string (enum) | `"pmd"` | Java 语言的 linter 选择 |
| `linters.css` | string (enum) | `"stylelint"` | CSS 语言的 linter 选择 |
| `linters.sql` | string (enum) | `"sql-lint"` | SQL 语言的 linter 选择 |
| `pmd.jarPath` | string | `""` | PMD jar 目录路径 |
| `pmd.rulesetPath` | string | `""` | PMD 规则集 XML 路径 |
| `pmd.jspRulesetPath` | string | `""` | JSP 规则集 XML 路径 |
| `sql-lint.configFile` | string | `""` | sqlfluff 配置文件路径 |
> **现有缺口**ESLint 和 Stylelint 适配器目前没有自定义配置路径的设置项(只有 `linters.javascript` 等语言级 linter 选择开关),无法通过 VS Code Settings 指定自定义 ESLint/Stylelint 配置文件路径。也没有适配器级别的启用/禁用开关。
---
## 03 配置模式优先级机制
每个适配器支持三种配置模式,优先级从高到低。高优先级配置存在时,低优先级配置自动忽略。
| 优先级 | 模式名称 | 配置来源 | 徽章标识 |
|--------|----------|----------|----------|
| **最高** | 全局配置(VS Code Settings | `linters.eslintConfigPath` / `linters.stylelintConfigPath` / `pmd.rulesetPath` / `sql-lint.configFile` 等设置项 | 全局配置 |
| **中** | 项目级配置 | 项目根目录下的配置文件(`.eslintrc.*` / `.stylelintrc.*` / `ruleset.xml` / `.sqlfluff` | 项目配置 |
| **最低** | 内置规则(零配置) | 插件打包的内置规则集 | 内置规则 |
### 各适配器的配置文件探测列表
`detectConfigMode()` 方法需要按以下列表在项目根目录探测文件是否存在:
| 适配器 | 项目级配置文件名(按探测顺序) | VS Code Settings 键 |
|--------|-------------------------------|---------------------|
| **ESLint** | `.eslintrc.js``.eslintrc.json``.eslintrc.yaml``.eslintrc.yml``.eslintrc``eslint.config.js``eslint.config.mjs` | `linters.eslintConfigPath` |
| **Stylelint** | `.stylelintrc.js``.stylelintrc.json``.stylelintrc.yaml``.stylelintrc.yml``.stylelintrc``stylelint.config.js` | `linters.stylelintConfigPath` |
| **PMD** | `ruleset.xml` | `pmd.rulesetPath` |
| **SQL-Lint** | `.sqlfluff` | `sql-lint.configFile` |
### PMD 特殊处理:双配置项
PMD 适配器有两类独立配置项:**规则集(rulesetPath**和**运行时(jarPath)**。两者独立配置,组合关系如下:
| jarPath | rulesetPath | 结果 |
|---------|-------------|------|
| 空 | 空 | 内置引擎 + 内置规则集 |
| 空 | 有值 | 内置引擎 + 自定义规则集 |
| 有值 | 空 | 自定义引擎 + 内置规则集 |
| 有值 | 有值 | 自定义引擎 + 自定义规则集 |
> **jarPath 回退规则**`jarPath` 指向的目录中必须存在 `PmdRunner.class` 文件,否则插件回退到内置 PMD。如果只设置了 `jarPath` 而没设置 `rulesetPath`,规则集仍使用内置的 `pmd-java-ruleset.xml`。
---
## 04 数据结构设计
### 新增类型定义
以下接口需添加到 `src/types.ts``src/views/setupView.ts` 顶部(推荐放在 setupView.ts 内部,因为仅该文件使用):
```typescript
/** 配置模式枚举 */
export type ConfigMode = 'builtin' | 'project' | 'global';
/** 外部依赖就绪状态 */
export type DependencyStatus = 'ready' | 'missing' | 'none';
/** 单个适配器的配置面板状态 */
export interface AdapterConfigStatus {
/** 适配器唯一标识 */
id: string;
/** 显示名称(PMD / SQL-Lint / ESLint / Stylelint */
name: string;
/** 是否已启用 */
enabled: boolean;
/** 当前生效的配置模式 */
configMode: ConfigMode;
/** 外部依赖状态(ESLint/Stylelint 为 'none' */
dependencyStatus: DependencyStatus;
/** 外部依赖显示文本(如 "Java"、"Python + sqlfluff" */
dependencyLabel?: string;
/** 配置是否已完成(全局或项目级配置存在时为 true) */
configured: boolean;
/** 面板显示的操作指南文本 */
guideText: string;
/** 项目级配置文件名(用于"配置文件"按钮创建/打开) */
projectConfigFileName: string;
/** VS Code Settings 跳转目标键 */
settingsTarget: string;
}
/** 侧边栏消息:适配器操作类型 */
export type AdapterMessageAction =
| 'openAdapterConfig'
| 'openSettings'
| 'toggleAdapter';
/** 侧边栏消息:适配器操作载荷 */
export interface AdapterMessage {
action: AdapterMessageAction;
adapterId: string;
}
```
### 适配器元数据常量表
`setupView.ts` 中定义一个静态常量表,描述四个适配器的元信息,供 `collectAdapterStatus()` 使用:
```typescript
const ADAPTER_METADATA: Record<string, {
name: string;
projectConfigFileName: string;
settingsTarget: string;
guideText: string;
hasExternalDependency: boolean;
dependencyLabel?: string;
configFileTemplate: string;
}> = {
pmd: {
name: 'PMD',
projectConfigFileName: 'ruleset.xml',
settingsTarget: 'vscode-code-reviewer.pmd',
guideText: '需要 Java 运行环境;项目根目录创建 ruleset.xml 或在设置中配置 pmd.rulesetPath',
hasExternalDependency: true,
dependencyLabel: 'Java',
configFileTemplate: PMD_RULESET_TEMPLATE,
},
'sql-lint': {
name: 'SQL-Lint',
projectConfigFileName: '.sqlfluff',
settingsTarget: 'vscode-code-reviewer.sql-lint',
guideText: '需要 Python 环境和 sqlfluff;运行 pip install sqlfluff,项目根目录创建 .sqlfluff',
hasExternalDependency: true,
dependencyLabel: 'Python + sqlfluff',
configFileTemplate: SQLFLUFF_TEMPLATE,
},
eslint: {
name: 'ESLint',
projectConfigFileName: '.eslintrc.js',
settingsTarget: 'vscode-code-reviewer.linters',
guideText: '项目根目录创建 .eslintrc.js 或在 VS Code 设置中配置 eslintConfigPath',
hasExternalDependency: false,
configFileTemplate: ESLINT_TEMPLATE,
},
stylelint: {
name: 'Stylelint',
projectConfigFileName: '.stylelintrc.js',
settingsTarget: 'vscode-code-reviewer.linters',
guideText: '项目根目录创建 .stylelintrc 或在 VS Code 设置中配置 stylelintConfigPath',
hasExternalDependency: false,
configFileTemplate: STYLELINT_TEMPLATE,
},
};
```
---
## 05 配置项 Schema 变更
### package.json — 新增配置项
`contributes.configuration.properties` 中追加以下配置项:
```jsonc
// ESLint 自定义配置路径
"vscode-code-reviewer.linters.eslintConfigPath": {
"type": "string",
"default": "",
"description": "ESLint 自定义配置文件路径(绝对路径)。留空则使用项目 .eslintrc 或内置规则"
}
// Stylelint 自定义配置路径
"vscode-code-reviewer.linters.stylelintConfigPath": {
"type": "string",
"default": "",
"description": "Stylelint 自定义配置文件路径(绝对路径)。留空则使用项目 .stylelintrc 或内置规则"
}
// 适配器启用/禁用开关(4 项)
"vscode-code-reviewer.linter.pmd.enabled": {
"type": "boolean",
"default": true,
"description": "启用/禁用 PMD 适配器"
}
"vscode-code-reviewer.linter.sql-lint.enabled": {
"type": "boolean",
"default": true,
"description": "启用/禁用 SQL-Lint 适配器"
}
"vscode-code-reviewer.linter.eslint.enabled": {
"type": "boolean",
"default": true,
"description": "启用/禁用 ESLint 适配器"
}
"vscode-code-reviewer.linter.stylelint.enabled": {
"type": "boolean",
"default": true,
"description": "启用/禁用 Stylelint 适配器"
}
```
> **命名空间注意**:现有配置使用 `linters.*`(复数)作为语言级 linter 选择,`pmd.*` / `sql-lint.*` 作为各适配器的独立配置。新增的启用/禁用开关统一放在 `linter.*`(单数)命名空间下,避免与现有 `linters.*` 冲突。ESLint 和 Stylelint 的自定义配置路径放在 `linters.*` 下,与现有 `linters.javascript` 等保持同级。
### config/linter.ts — 新增读取函数
```typescript
/** 获取 ESLint 自定义配置路径 */
export function getEslintConfigPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('linters.eslintConfigPath', '');
}
/** 获取 Stylelint 自定义配置路径 */
export function getStylelintConfigPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('linters.stylelintConfigPath', '');
}
/** 获取适配器启用状态 */
export function isAdapterEnabled(adapterId: string): boolean {
return vscode.workspace.getConfiguration(ROOT).get<boolean>(`linter.${adapterId}.enabled`, true);
}
/** 设置适配器启用状态(写入 Global Settings */
export async function setAdapterEnabled(adapterId: string, enabled: boolean): Promise<void> {
await vscode.workspace.getConfiguration(ROOT).update(
`linter.${adapterId}.enabled`,
enabled,
vscode.ConfigurationTarget.Global
);
}
```
### 适配器集成:启用/禁用检查
`orchestrator/` 中调度适配器前,需检查该适配器是否已启用。在调用 `adapter.check()` 之前添加守卫:
```typescript
import { isAdapterEnabled } from '../config/linter';
// 在 orchestrator 的适配器调度循环中
for (const adapter of adapters) {
if (!isAdapterEnabled(adapter.id)) {
continue; // 跳过已禁用的适配器
}
if (!adapter.isAvailable()) {
continue; // 跳过不可用的适配器
}
const result = await adapter.check(document, workingDir);
// ... 处理结果
}
```
---
## 06 状态检测逻辑
### detectConfigMode() — 配置模式检测
该方法检测指定适配器当前生效的配置模式。优先检查全局配置,其次项目级配置,最后回退到内置。
```typescript
import * as path from 'path';
import * as fs from 'fs';
import * as vscode from 'vscode';
import { getEslintConfigPath, getStylelintConfigPath, getPmdRulesetPath, getSqlLintConfigFile }
from '../config/linter';
/** 各适配器项目级配置文件探测列表 */
const PROJECT_CONFIG_FILES: Record<string, string[]> = {
eslint: ['.eslintrc.js', '.eslintrc.json', '.eslintrc.yaml', '.eslintrc.yml', '.eslintrc', 'eslint.config.js', 'eslint.config.mjs'],
stylelint: ['.stylelintrc.js', '.stylelintrc.json', '.stylelintrc.yaml', '.stylelintrc.yml', '.stylelintrc', 'stylelint.config.js'],
pmd: ['ruleset.xml'],
'sql-lint': ['.sqlfluff'],
};
/** 各适配器全局配置路径读取函数 */
const GLOBAL_CONFIG_GETTERS: Record<string, () => string> = {
eslint: getEslintConfigPath,
stylelint: getStylelintConfigPath,
pmd: getPmdRulesetPath,
'sql-lint': getSqlLintConfigFile,
};
function detectConfigMode(adapterId: string): ConfigMode {
// 1. 检查全局配置(VS Code Settings
const globalPath = GLOBAL_CONFIG_GETTERS[adapterId]?.();
if (globalPath && globalPath.trim() !== '') {
return 'global';
}
// 2. 检查项目级配置文件
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders && workspaceFolders.length > 0) {
const rootPath = workspaceFolders[0].uri.fsPath;
const configFiles = PROJECT_CONFIG_FILES[adapterId] ?? [];
for (const fileName of configFiles) {
const filePath = path.join(rootPath, fileName);
if (fs.existsSync(filePath)) {
return 'project';
}
}
}
// 3. 回退到内置
return 'builtin';
}
```
### checkJavaReady() — Java 环境检测
通过执行 `java -version` 检测 Java 运行环境是否可用。使用 `child_process.execSync` 同步执行,捕获 stderr 输出(Java 版本信息输出到 stderr)。
```typescript
import { execSync } from 'child_process';
function checkJavaReady(): boolean {
try {
const output = execSync('java -version', {
encoding: 'utf-8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
return true;
} catch {
// java -version 输出到 stderrexecSync 会因非零退出码抛错
// 但即使版本信息在 stderr 中,只要命令存在就算就绪
try {
const result = execSync('java -version 2>&1', {
encoding: 'utf-8',
timeout: 5000,
});
return result.includes('version');
} catch {
return false;
}
}
}
```
### checkPythonReady() — Python + sqlfluff 检测
先检测 Python(尝试 `python3``python`),再检测 `sqlfluff` 命令是否可用。
```typescript
function checkPythonReady(): boolean {
// 1. 检测 Python
let pythonCmd = '';
for (const cmd of ['python3', 'python']) {
try {
execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
pythonCmd = cmd;
break;
} catch { continue; }
}
if (!pythonCmd) return false;
// 2. 检测 sqlfluff
try {
execSync('sqlfluff --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
return true;
} catch {
return false;
}
}
```
> **依赖检测注意事项**:依赖检测仅验证运行环境是否存在,不验证具体版本。`java -version` 能正常输出即判定为就绪,但不检查是否满足 Java 8+ 要求。检测操作使用 `execSync` 同步执行,需设置 5 秒超时防止卡死。检测结果需缓存,避免每次刷新面板都执行命令行检测(建议在 `collectAdapterStatus()` 中缓存,配置变更时重新检测)。
### collectAdapterStatus() — 汇总适配器状态
遍历 `ADAPTER_METADATA`,调用上述检测方法,组装 `AdapterConfigStatus[]` 数组。
```typescript
function collectAdapterStatus(): AdapterConfigStatus[] {
const statuses: AdapterConfigStatus[] = [];
for (const [id, meta] of Object.entries(ADAPTER_METADATA)) {
const configMode = detectConfigMode(id);
const enabled = isAdapterEnabled(id);
let dependencyStatus: DependencyStatus = 'none';
if (meta.hasExternalDependency) {
if (id === 'pmd') {
dependencyStatus = checkJavaReady() ? 'ready' : 'missing';
} else if (id === 'sql-lint') {
dependencyStatus = checkPythonReady() ? 'ready' : 'missing';
}
}
const configured = configMode !== 'builtin' || !meta.hasExternalDependency;
statuses.push({
id,
name: meta.name,
enabled,
configMode,
dependencyStatus,
dependencyLabel: meta.dependencyLabel,
configured,
guideText: meta.guideText,
projectConfigFileName: meta.projectConfigFileName,
settingsTarget: meta.settingsTarget,
});
}
return statuses;
}
```
---
## 07 侧边栏面板实现
### 面板位置
新增区域位于侧边栏中**"审核引擎"区域之后、"AI 模型配置"区域之前**。视线流程为:快速开始引导 → 引擎状态总览 → **适配器配置引导(本节)** → AI 模型配置 → 规则文件管理。
### setupView.ts — pushConfig() 变更
`pushConfig()` 方法中追加 `adapterStatus` 字段,将采集的适配器状态推送到前端:
```typescript
private pushConfig() {
// ... 现有配置推送逻辑保持不变 ...
// 追加适配器状态
const adapterStatus = this.collectAdapterStatus();
this.view?.webview.postMessage({
type: 'initConfig',
// ... 现有字段 ...
adapterStatus,
});
}
```
### setupView.ts — onDidReceiveMessage 新增消息处理
`resolveWebviewView``onDidReceiveMessage` 回调中,新增三个 case
```typescript
this.view.webview.onDidReceiveMessage(async (message) => {
switch (message.type) {
// ... 现有 case 保持不变 ...
case 'openAdapterConfig': {
await this.handleAdapterConfig(message.adapterId);
break;
}
case 'openSettings': {
await vscode.commands.executeCommand(
'workbench.action.openSettings',
message.settingsTarget
);
break;
}
case 'toggleAdapter': {
await setAdapterEnabled(message.adapterId, message.enabled);
// 配置变更后 pushConfig 会由 onDidChangeConfiguration 触发
break;
}
}
});
```
### handleAdapterConfig() — 配置文件创建/打开
```typescript
private async handleAdapterConfig(adapterId: string) {
const meta = ADAPTER_METADATA[adapterId];
if (!meta) return;
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
vscode.window.showWarningMessage('请先打开一个工作区文件夹');
return;
}
const rootPath = workspaceFolders[0].uri.fsPath;
const filePath = path.join(rootPath, meta.projectConfigFileName);
if (!fs.existsSync(filePath)) {
// 文件不存在 → 创建默认模板
fs.writeFileSync(filePath, meta.configFileTemplate, 'utf-8');
vscode.window.showInformationMessage(`配置文件已创建: ${meta.projectConfigFileName}`);
}
// 打开文件
const doc = await vscode.workspace.openTextDocument(filePath);
await vscode.window.showTextDocument(doc);
}
```
### resolveWebviewView — 配置变更监听
`resolveWebviewView` 方法中注册 `onDidChangeConfiguration` 监听器,当 `vscode-code-reviewer.linter``vscode-code-reviewer.linters``vscode-code-reviewer.pmd``vscode-code-reviewer.sql-lint` 命名空间下的配置变更时,触发 `pushConfig()` 刷新:
```typescript
resolveWebviewView(view: vscode.WebviewView) {
this.view = view;
// ... 现有初始化逻辑 ...
// 新增:配置变更监听
const configChangeDisposable = vscode.workspace.onDidChangeConfiguration((e) => {
if (
e.affectsConfiguration('vscode-code-reviewer.linter') ||
e.affectsConfiguration('vscode-code-reviewer.linters') ||
e.affectsConfiguration('vscode-code-reviewer.pmd') ||
e.affectsConfiguration('vscode-code-reviewer.sql-lint')
) {
this.pushConfig();
}
});
// 随 webview 销毁自动清理
view.onDidDispose(() => {
configChangeDisposable.dispose();
});
}
```
### setupView.ts — getHtml() 模板变更
`getHtml()` 方法返回的 HTML 模板中,在"审核引擎"区域和"AI 模型配置"区域之间插入以下 HTML:
```html
<!-- 适配器配置面板 -->
<div class="section adapter-section-panel">
<h3 class="section-title">静态分析适配器</h3>
<div id="adapter-list"></div>
</div>
```
同时在 `<style>` 块中追加适配器卡片样式(约 55 行 CSS):
```css
/* 适配器卡片 */
.adapter-card {
background: var(--bg2);
border: 1px solid var(--rule);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: opacity 0.2s;
}
.adapter-card.disabled { opacity: 0.45; }
.adapter-card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.adapter-card-name { font-size: 12px; font-weight: 600; color: #ccc; }
.adapter-toggle {
width: 30px; height: 16px; border-radius: 8px;
background: #3fb950; position: relative; cursor: pointer;
transition: background 0.2s;
}
.adapter-toggle.off { background: #3c3c3c; }
.adapter-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; border-radius: 50%; background: #fff;
transition: transform 0.2s;
transform: translateX(14px);
}
.adapter-toggle.off::after { transform: translateX(0); background: #ccc; }
.adapter-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 5px; }
.adapter-badge {
display: inline-flex; align-items: center; padding: 1px 5px;
border-radius: 3px; font-size: 9px; font-weight: 600;
font-family: var(--font-mono);
}
.adapter-badge-info { background: rgba(139,92,246,0.15); color: #8b5cf6; }
.adapter-badge-ok { background: rgba(63,185,80,0.15); color: #3fb950; }
.adapter-badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
.adapter-badge-error { background: rgba(248,81,73,0.15); color: #f48771; }
.adapter-guide { font-size: 10px; color: #9d9d9d; line-height: 1.4; margin-bottom: 7px; }
.adapter-actions { display: flex; gap: 5px; }
.adapter-btn {
padding: 2px 8px; border-radius: 3px; font-size: 10px;
border: 1px solid #3c3c3c; background: transparent; color: #ccc;
cursor: pointer; font-family: var(--font);
}
.adapter-btn:hover { background: var(--bg3); }
```
### setupView.js — renderAdapters() 前端渲染函数
`setupView.js` 中新增 `renderAdapters()` 函数,接收 `adapterStatus` 数组并渲染卡片 HTML
```javascript
function renderAdapters(adapterStatus) {
const container = document.getElementById('adapter-list');
if (!container || !adapterStatus) return;
const modeBadgeMap = {
builtin: { class: 'adapter-badge-info', text: '内置规则' },
project: { class: 'adapter-badge-ok', text: '项目配置' },
global: { class: 'adapter-badge-warn', text: '全局配置' },
};
const html = adapterStatus.map(a => {
const modeBadge = modeBadgeMap[a.configMode];
const depBadge = a.dependencyStatus === 'none' ? '' :
a.dependencyStatus === 'ready'
? `<span class="adapter-badge adapter-badge-ok">${a.dependencyLabel} ✓</span>`
: `<span class="adapter-badge adapter-badge-error">${a.dependencyLabel} ✗</span>`;
const configBadge = a.configured
? '<span class="adapter-badge adapter-badge-ok">已配置</span>'
: '<span class="adapter-badge adapter-badge-warn">未配置</span>';
return `
<div class="adapter-card ${a.enabled ? '' : 'disabled'}">
<div class="adapter-card-header">
<span class="adapter-card-name">${a.name}</span>
<div class="adapter-toggle ${a.enabled ? '' : 'off'}"
data-adapter-id="${a.id}"></div>
</div>
<div class="adapter-badges">
<span class="adapter-badge ${modeBadge.class}">${modeBadge.text}</span>
${depBadge}
${configBadge}
</div>
<div class="adapter-guide">${a.guideText}</div>
<div class="adapter-actions">
<button class="adapter-btn" data-action="openAdapterConfig"
data-adapter-id="${a.id}">配置文件</button>
<button class="adapter-btn" data-action="openSettings"
data-settings-target="${a.settingsTarget}">VS Code 设置</button>
</div>
</div>
`;
}).join('');
container.innerHTML = html;
// 绑定事件
container.querySelectorAll('.adapter-toggle').forEach(el => {
el.addEventListener('click', () => {
const adapterId = el.dataset.adapterId;
const isEnabled = !el.classList.contains('off');
vscode.postMessage({
type: 'toggleAdapter',
adapterId,
enabled: !isEnabled,
});
});
});
container.querySelectorAll('.adapter-btn').forEach(el => {
el.addEventListener('click', () => {
const action = el.dataset.action;
const adapterId = el.dataset.adapterId;
const settingsTarget = el.dataset.settingsTarget;
if (action === 'openAdapterConfig') {
vscode.postMessage({ type: 'openAdapterConfig', adapterId });
} else if (action === 'openSettings') {
vscode.postMessage({ type: 'openSettings', settingsTarget });
}
});
});
}
// 在 initConfig 消息处理中调用
window.addEventListener('message', event => {
const message = event.data;
if (message.type === 'initConfig') {
// ... 现有初始化逻辑 ...
renderAdapters(message.adapterStatus);
}
});
```
---
## 08 文件级变更规格
### [MODIFY] package.json
**范围**: `configuration.properties`
`contributes.configuration.properties` 对象中追加 6 个新配置项:
- `linters.eslintConfigPath` — ESLint 自定义配置路径(string, default: ""
- `linters.stylelintConfigPath` — Stylelint 自定义配置路径(string, default: ""
- `linter.pmd.enabled` — PMD 启用开关(boolean, default: true
- `linter.sql-lint.enabled` — SQL-Lint 启用开关(boolean, default: true
- `linter.eslint.enabled` — ESLint 启用开关(boolean, default: true
- `linter.stylelint.enabled` — Stylelint 启用开关(boolean, default: true
不修改任何现有配置项。版本号从 `1.0.1` 升至 `1.1.0`
### [MODIFY] src/config/linter.ts
**范围**: 新增 4 个导出函数
- `getEslintConfigPath(): string` — 读取 `linters.eslintConfigPath`
- `getStylelintConfigPath(): string` — 读取 `linters.stylelintConfigPath`
- `isAdapterEnabled(adapterId: string): boolean` — 读取 `linter.{adapterId}.enabled`
- `setAdapterEnabled(adapterId: string, enabled: boolean): Promise<void>` — 写入 Global Settings
不修改任何现有函数。新增函数追加在文件末尾。
### [MODIFY] src/views/setupView.ts
**范围**: `SetupViewProvider`
变更内容(全部为新增或追加):
| 变更位置 | 变更内容 |
|----------|----------|
| 文件顶部 | 新增 `import` 语句(path、fs、execSync、linter 配置函数) |
| 类外部 | 新增 `ConfigMode``DependencyStatus``AdapterConfigStatus` 类型定义 |
| 类外部 | 新增 `ADAPTER_METADATA` 常量表 |
| 类外部 | 新增 `PROJECT_CONFIG_FILES``GLOBAL_CONFIG_GETTERS` 常量 |
| 类内部 — 新增方法 | `detectConfigMode(adapterId): ConfigMode` |
| 类内部 — 新增方法 | `checkJavaReady(): boolean` |
| 类内部 — 新增方法 | `checkPythonReady(): boolean` |
| 类内部 — 新增方法 | `collectAdapterStatus(): AdapterConfigStatus[]` |
| 类内部 — 新增方法 | `handleAdapterConfig(adapterId: string): Promise<void>` |
| 类内部 — 修改方法 | `pushConfig()` 追加 `adapterStatus` 字段到 postMessage |
| 类内部 — 修改方法 | `resolveWebviewView()``onDidReceiveMessage` 新增 `openAdapterConfig` / `openSettings` / `toggleAdapter` 三个 case |
| 类内部 — 修改方法 | `resolveWebviewView()` 新增 `onDidChangeConfiguration` 监听器 |
| 类内部 — 修改方法 | `getHtml()` 模板新增 HTML`<div id="adapter-list">`)和 CSS(约 55 行样式) |
### [MODIFY] src/views/setupView.js
**范围**: 前端脚本
- 新增 `renderAdapters(adapterStatus)` 函数
-`initConfig` 消息处理回调中追加 `renderAdapters(message.adapterStatus)` 调用
不修改任何现有函数的逻辑分支。
### [MODIFY] src/adapters/eslint.ts
**范围**: `ESLintAdapter`
`check()` 方法中,优先使用 `getEslintConfigPath()` 返回的自定义配置路径。如果为空,再检测项目根目录的 `.eslintrc.*` 文件。如果两者都不存在,使用内置 `eslint:recommended` 规则集。
### [MODIFY] src/adapters/stylelint.ts
**范围**: `StylelintAdapter`
同 ESLint,在 `check()` 方法中优先使用 `getStylelintConfigPath()` 返回的自定义配置路径。如果为空,再检测项目根目录的 `.stylelintrc.*` 文件。如果两者都不存在,使用内置 11 条默认规则。
### [MODIFY] src/orchestrator/*
**范围**: 审查编排器
在适配器调度循环中,调用 `adapter.check()` 之前新增 `isAdapterEnabled(adapter.id)` 守卫,跳过已禁用的适配器。
---
## 09 默认配置模板
点击"配置文件"按钮时,如果项目根目录尚不存在对应配置文件,插件自动创建以下默认模板并打开。模板内容作为字符串常量定义在 `setupView.ts` 中。
### PMD 默认模板(ruleset.xml
```typescript
const PMD_RULESET_TEMPLATE = `<?xml version="1.0" encoding="UTF-8"?>
<ruleset xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>Custom PMD Ruleset</description>
<rule ref="category/java/bestpractices.xml" />
<rule ref="category/java/codestyle.xml" />
</ruleset>`;
```
### SQL-Lint 默认模板(.sqlfluff
```typescript
const SQLFLUFF_TEMPLATE = `[sqlfluff]
dialect = postgres
rules = all`;
```
### ESLint 默认模板(.eslintrc.js
```typescript
const ESLINT_TEMPLATE = `module.exports = {
root: true,
env: { node: true, es2022: true },
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
rules: {
'no-unused-vars': 'warn',
'no-console': 'off',
'semi': ['error', 'always'],
},
};`;
```
### Stylelint 默认模板(.stylelintrc.js
```typescript
const STYLELINT_TEMPLATE = `module.exports = {
extends: 'stylelint-config-standard',
rules: {
'indentation': 2,
'no-empty': true,
},
};`;
```
> **文件存在时不覆盖**:如果项目根目录已存在对应配置文件,点击"配置文件"按钮会直接打开该文件,不会覆盖已有内容。创建新文件时会弹出 `showInformationMessage` 通知提示"配置文件已创建"。
---
## 10 测试要点
### 配置模式检测测试
| 测试场景 | 前置条件 | 期望结果 |
|----------|----------|----------|
| 全局配置优先 | Settings 中 `eslintConfigPath` 有值 + 项目根有 `.eslintrc.js` | `detectConfigMode('eslint')` 返回 `'global'` |
| 项目配置次之 | Settings 中 `eslintConfigPath` 为空 + 项目根有 `.eslintrc.json` | 返回 `'project'` |
| 内置回退 | Settings 为空 + 项目根无配置文件 | 返回 `'builtin'` |
| PMD 双配置组合 | `jarPath` 有值但目录无 `PmdRunner.class` | 回退到内置引擎 |
### 外部依赖检测测试
| 测试场景 | 期望结果 |
|----------|----------|
| Java 已安装(`java -version` 正常输出) | `checkJavaReady()` 返回 `true` |
| Java 未安装(命令不存在) | 返回 `false` |
| Python3 + sqlfluff 均已安装 | `checkPythonReady()` 返回 `true` |
| Python 已安装但 sqlfluff 未安装 | 返回 `false` |
| 仅 python(无 python3+ sqlfluff 已安装 | 返回 `true` |
### 侧边栏面板交互测试
| 测试场景 | 操作步骤 | 期望结果 |
|----------|----------|----------|
| 切换适配器开关 | 点击 PMD 卡片的开关 | 卡片变半透明 + `linter.pmd.enabled` 写入 `false` + 代码审查时 PMD 被跳过 |
| 创建配置文件 | 点击 ESLint 的"配置文件"按钮(项目根无 .eslintrc.js) | 项目根创建 `.eslintrc.js` + 文件在编辑器中打开 + 弹出通知 |
| 打开已有配置文件 | 点击 Stylelint 的"配置文件"按钮(项目根已有 .stylelintrc.js | 直接打开文件,不覆盖内容 |
| 跳转 VS Code 设置 | 点击 SQL-Lint 的"VS Code 设置"按钮 | VS Code 设置面板打开,定位到 `vscode-code-reviewer.sql-lint` |
| 配置变更实时刷新 | 在 VS Code 设置面板修改 `pmd.rulesetPath` | 侧边栏 PMD 卡片徽章自动更新为"全局配置" |
### 零破坏性回归测试
> **回归验证清单**:以下功能在本次变更后必须仍然正常工作:AI 代码审查命令(`Ctrl+Shift+R`)、选中代码审查、审查结果面板导出、自定义规则管理、AI 模型配置与连接测试、规则文件导入。适配器卡片渲染失败时不应影响侧边栏其他区域的正常显示。
---
*本设计书基于 vscode-code-reviewer 插件 vscode-code-reviewer 分支(v1.0.1)编写,覆盖静态分析适配器三层配置模式机制和侧边栏适配器配置面板的全部技术实现规格。AI 编码助手应按第 08 节文件级变更规格逐文件实施,以第 04-06 节的数据结构和检测逻辑为实现依据。*
@@ -0,0 +1,174 @@
# 适配器面板 UI + 配置文件模板 i18n 设计书
> - **项目**: vscode-code-reviewer
> - **日期**: 2026-07-28
> - **设计范围**: 适配器面板 UI 文本 + 配置文件模板注释的国际化接入
---
## 01 问题
适配器优化引入的新 UI 文本和配置文件模板注释为硬编码中文,未走 `t()` 通路,英文/日语用户看到的是中文文本。
涉及范围:
1. **`setupView.ts`** HTML 模板中 3 处硬编码(tooltip、图例、子标题)
2. **`setupView.js`** 前端渲染中 ~8 处硬编码(徽章、按钮、标签、tooltip)
3. **`setupView.ts`** 配置文件模板常量中 ~9 处中文注释
---
## 02 方案
### 2.1 数据流
```
用户切换语言 → pushConfig() 发送 initConfig 消息
├─ 已有字段:config, providers, ruleFiles, adapterStatus
└─ 新增字段:i18n(包含前端需要的全部翻译文本)
setupView.js 从 msg.i18n.* 读取文本,不再硬编码
```
### 2.2 setupView.ts HTML 模板变更
直接在 `getHtml()` 模板字符串中将硬编码文本替换为 `${t('key')}`,与现有 pattern 一致。
涉及键:
| 位置 | 当前文本 | i18n 键 |
|------|---------|---------|
| `data-tooltip` | 点击展开/收起静态分析适配器 | `setup.adapter.tooltipTab` |
| `.engine-subtitle` | 静态分析适配器 | `setup.adapter.subtitle` |
| `.mode-legend-item` 图例 x3 | 内置规则 / 项目配置 / 全局配置 | `setup.adapter.modeBuiltin` / `modeProject` / `modeGlobal` |
| `.mode-legend-desc` | 插件按 内置<全局<项目的优先级... | `setup.adapter.modeLegend` |
### 2.3 setupView.js 前端变更
`pushConfig()``initConfig` 消息中新增 `i18n` 对象:
```typescript
// pushConfig() 中
this._view.webview.postMessage({
type: 'initConfig',
// ... 现有字段 ...
i18n: {
configured: t('setup.configured'),
notConfigured: t('setup.notConfigured'),
connected: t('setup.connected'),
retry: t('setup.retry'),
saveTest: t('setup.saveAndTest'),
ruleCount: t('setup.ruleCountFormat'),
noRuleFiles: t('setup.noRuleFiles'),
// 适配器相关
modeBuiltin: t('setup.adapter.modeBuiltin'),
modeProject: t('setup.adapter.modeProject'),
modeGlobal: t('setup.adapter.modeGlobal'),
configYes: t('setup.adapter.configYes'),
configNo: t('setup.adapter.configNo'),
langLabel: t('setup.adapter.langLabel'),
btnCreateConfig: t('setup.adapter.btnCreateConfig'),
btnEditGlobal: t('setup.adapter.btnEditGlobal'),
tooltipCreatePrefix: t('setup.adapter.tooltipCreate'),
tooltipEdit: t('setup.adapter.tooltipEdit'),
tooltipToggleEnable: t('setup.adapter.toggleEnable'),
tooltipToggleDisable: t('setup.adapter.toggleDisable'),
},
});
```
`setupView.js` 中所有 `'硬编码文本'` 替换为 `msg.i18n.xxx`
### 2.4 配置文件模板变更
四个模板常量从静态字符串改为**运行时函数**,使用 `t()` 生成注释:
```typescript
function getPmdRulesetTemplate(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<ruleset ...>
<description>Custom PMD Ruleset</description>
<!-- ${t('setup.template.pmdBestPractices')} -->
<rule ref="category/java/bestpractices.xml" />
<!-- ${t('setup.template.pmdCodeStyle')} -->
<rule ref="category/java/codestyle.xml" />
</ruleset>`;
}
```
`ADAPTER_METADATA` 中的 `configFileTemplate` 改为 getter 函数(`() => string`),`handleAdapterConfig()` 在写入时调用。
### 2.5 修复已有硬编码
顺便修复 `setupView.js` 中已有的硬编码字符串:
| 行 | 当前文本 | i18n 键 |
|----|---------|---------|
| 131, 136 | '已配置' / '未配置' | `setup.configured` / `setup.notConfigured` (已存在) |
| 142 | '✓ 已连接' | `setup.connected` |
| 144 | '✗ 重试' | `setup.retry` |
| 146 | '保存并测试连接' | `setup.saveAndTest` (已存在) |
| 153 | '{n} 个文件' | `setup.ruleCountFormat` |
| 161 | '0 个文件' | 同上 |
| 163 | '暂无规则文件' | `setup.noRuleFiles` |
---
## 03 新增 i18n 键清单
### 面板 UI(约12条)
| 键 | zh-CN | en | ja |
|---|-------|----|----|
| `setup.configured` | 已配置 | Configured | 設定済み |
| `setup.connected` | ✓ 已连接 | ✓ Connected | ✓ 接続済み |
| `setup.retry` | ✗ 重试 | ✗ Retry | ✗ 再試行 |
| `setup.ruleCountFormat` | {0} 个文件 | {0} file(s) | {0} ファイル |
| `setup.noRuleFiles` | 暂无规则文件 | No rule files | ルールファイルなし |
| `setup.adapter.subtitle` | 静态分析适配器 | Static Analysis Adapters | 静的解析アダプター |
| `setup.adapter.modeBuiltin` | 内置规则 | Built-in Rules | 組み込みルール |
| `setup.adapter.modeProject` | 项目配置 | Project Config | プロジェクト設定 |
| `setup.adapter.modeGlobal` | 全局配置 | Global Config | グローバル設定 |
| `setup.adapter.modeLegend` | 插件按 内置<全局<项目的优先级自动选择配置来源 | Auto-selects config by priority: Built-in < Global < Project | 優先順位に従って自動選択: 組み込み < グローバル < プロジェクト |
| `setup.adapter.configYes` | 已配置 | Configured | 設定済み |
| `setup.adapter.configNo` | 未配置 | Not configured | 未設定 |
| `setup.adapter.langLabel` | 可审查的语言: | Languages: | 対応言語: |
| `setup.adapter.tooltipTab` | 点击展开/收起静态分析适配器 | Click to expand/collapse static analysis adapters | クリックで静的解析アダプターを展開/折りたたむ |
| `setup.adapter.btnCreateConfig` | 创建项目配置 | Create Project Config | プロジェクト設定を作成 |
| `setup.adapter.btnEditGlobal` | 修改全局设置 | Modify Global Settings | グローバル設定を変更 |
| `setup.adapter.tooltipCreate` | 在项目根目录创建 {0} | Create {0} in project root | プロジェクトルートに {0} を作成 |
| `setup.adapter.tooltipEdit` | 修改 VS Code 设置中的全局参数 | Modify global parameters in VS Code settings | VS Code設定のグローバルパラメータを変更 |
| `setup.adapter.toggleEnable` | 启用 {0} 适配器 | Enable {0} adapter | {0} アダプターを有効化 |
| `setup.adapter.toggleDisable` | 禁用 {0} 适配器 | Disable {0} adapter | {0} アダプターを無効化 |
### 模板注释(约6条)
| 键 | zh-CN | en | ja |
|---|-------|----|----|
| `setup.template.pmdBestPractices` | Java 最佳实践(如:避免空 catch、关闭流等) | Java best practices (avoid empty catch, close streams, etc.) | Javaベストプラクティス(空のcatch回避、ストリームクローズ等) |
| `setup.template.pmdCodeStyle` | Java 代码风格(如:命名规范、花括号位置等) | Java code style (naming conventions, brace placement, etc.) | Javaコードスタイル(命名規則、ブレース位置等) |
| `setup.template.sqlfluffDialect` | 数据库方言:postgres / mysql / bigquery / snowflake 等 | Database dialect: postgres / mysql / bigquery / snowflake etc. | データベース方言:postgres / mysql / bigquery / snowflake など |
| `setup.template.sqlfluffRules` | all = 启用全部规则,也可指定规则名逗号分隔 | all = enable all rules, or specify rule names separated by commas | all = すべてのルールを有効、ルール名をカンマ区切りで指定可 |
| `setup.template.eslintComment1` | 未使用的变量 → 警告 | Unused variables → warning | 未使用変数 → 警告 |
| `setup.template.eslintComment2` | 允许使用 console | Allow console | consoleを許可 |
| `setup.template.eslintComment3` | 强制分号 | Enforce semicolons | セミコロンを強制 |
| `setup.template.stylelintComment1` | 缩进 2 空格 | Indentation: 2 spaces | インデント: 2スペース |
| `setup.template.stylelintComment2` | 禁止空规则 | No empty rules | 空ルールを禁止 |
---
## 04 文件变更清单
| 文件 | 变更类型 | 说明 |
|------|---------|------|
| `src/i18n/messages.ts` | 修改 | 新增 ~28 条 i18n 键(面板 UI + 模板注释) |
| `src/views/setupView.ts` | 修改 | HTML 模板硬编码替换为 `t()`;模板常量改为函数 |
| `src/views/setupView.js` | 修改 | 从 `msg.i18n` 读取文本替换全篇硬编码字符串 |
零破坏性原则:不修改任何现有 i18n 键,不修改现有逻辑分支,不修改 HTML 结构/JS 事件绑定逻辑。
---
## 05 风险与注意事项
- `ADAPTER_METADATA``configFileTemplate` 的类型从 `string` 改为 `() => string`,调用方 `handleAdapterConfig()` 需相应调整
- 部分键与已有键重复(如 `setup.configured` / `setup.notConfigured`),需确认是否复用现有 `setup.notConfigured`
- 模板中 XML/JS 注释 `<!-- -->` / `//` / `#` 本身不翻译,仅注释内容翻译
@@ -0,0 +1,855 @@
# AI 供应商与模型动态化设计书
> 将写死的供应商注册表外置为 JSON 配置文件,模型下拉框改为可编辑输入框,并修复 `package.json` 不一致问题。
- **项目**: vscode-code-reviewer (Code Purifier)
- **分支**: vscode-code-reviewer
- **日期**: 2026-07-28
- **版本**: 1.1.0 → 1.2.0
---
## 目录
- [01 问题分析](#01-问题分析)
- [02 设计目标与范围](#02-设计目标与范围)
- [03 方案 A:模型输入框可编辑化](#03-方案-a模型输入框可编辑化)
- [04 方案 B:供应商注册表外置化](#04-方案-b供应商注册表外置化)
- [05 修复 package.json 不一致](#05-修复-packagejson-不一致)
- [06 数据结构设计](#06-数据结构设计)
- [07 注册表加载器实现](#07-注册表加载器实现)
- [08 工厂函数改造](#08-工厂函数改造)
- [09 侧边栏面板改造](#09-侧边栏面板改造)
- [10 文件级变更规格](#10-文件级变更规格)
- [11 迁移路径](#11-迁移路径)
- [12 测试要点](#12-测试要点)
---
## 01 问题分析
当前 `src/ai/factory.ts` 中的 `registry` 是一个纯静态对象,硬编码了 8 个供应商及其模型列表。这种写法在大模型快速迭代的环境下存在三类问题。
### 1.1 模型列表过期
`factory.ts` 中的模型列表是编码时手写的快照,一旦供应商发布新模型或下线旧模型,插件无法感知。用户只能使用列表中预置的模型名,即使供应商 API 已经支持新模型,也必须等插件发版后才能选用。
### 1.2 默认值不一致
| 位置 | 内容 | 问题 |
|------|------|------|
| `package.json` 第 92-96 行 | `ai.provider` enum 仅有 `deepseek``openai` | 工厂注册了 8 个供应商,但配置 schema 只声明了 2 个 |
| `package.json` 第 100 行 | 默认模型 `deepseek-chat` | `factory.ts` 中 DeepSeek 的模型列表为 `deepseek-v4-pro``deepseek-v4-flash`,不包含 `deepseek-chat` |
用户首次安装后,默认模型在下拉框中找不到对应项,`populateModelOptions()` 会回退到列表第一个模型,导致实际使用的模型与配置中记录的不一致。
### 1.3 无法使用列表外模型
`setupView.ts` 中模型选择是 `<select>` 元素,只能从预置列表中选择。如果用户想使用新发布的模型、供应商列表中不存在的 OpenAI 兼容服务(如本地 Ollama、vLLM 部署),无法手动输入模型名。
---
## 02 设计目标与范围
### 优化项清单
| # | 优化项 | 方案 | 涉及文件 | 变更类型 |
|---|--------|------|----------|----------|
| 1 | 模型选择改为可编辑输入框 | A | `views/setupView.ts``views/setupView.js` | 修改 UI + 逻辑 |
| 2 | 供应商注册表外置为 JSON | B | 新增 `providers.json``ai/registry.ts` | 新增文件 |
| 3 | 工厂函数改为动态加载 | B | `ai/factory.ts` | 重构 |
| 4 | 支持用户自定义供应商覆盖 | B | `ai/registry.ts` | 新增逻辑 |
| 5 | 修复 `package.json` 不一致 | 附加 | `package.json` | 修改 schema |
### 零破坏性原则
所有变更不改变 AI 审查引擎的消费侧接口。`engine.ts` 调用 `createProvider()``getAIModel()` 的方式不变,`runAIReview()` 的逻辑分支不受影响。现有用户的配置(provider、model、baseUrl、apiKey)在升级后自动保留,无需重新配置。
---
## 03 方案 A:模型输入框可编辑化
### 现状
`setupView.ts` 第 889-891 行渲染模型选择器:
```html
<select id="modelSelect">${(providers[config.provider]?.models ?? []).map(m =>
`<option value="${m}"${config.model === m ? ' selected' : ''}>${m}</option>`
).join('\n ')}</select>
```
`setupView.js` 第 88-102 行的 `populateModelOptions()``<select>` 填充 `<option>`
```javascript
function populateModelOptions(providerId, selectModel) {
var modelSelect = document.getElementById('modelSelect');
if (!modelSelect) { return; }
var models = (PROVIDERS[providerId] && PROVIDERS[providerId].models) || [];
modelSelect.innerHTML = '';
for (var i = 0; i < models.length; i++) {
var opt = document.createElement('option');
opt.value = models[i];
opt.textContent = models[i];
modelSelect.appendChild(opt);
}
if (models.length > 0) {
modelSelect.value = selectModel && models.indexOf(selectModel) !== -1 ? selectModel : models[0];
}
}
```
### 改造方案
`<select>` 替换为 `<input type="text" list="...">` + `<datalist>``datalist` 提供预置建议列表,同时允许用户手动输入任意模型名。
#### HTML 变更(setupView.ts `getHtml()`
```html
<div class="field">
<label class="field-label">${t('setup.model')}</label>
<input type="text" id="modelInput" list="modelOptions"
value="${config.model || ''}"
placeholder="${t('setup.modelPlaceholder')}"
onchange="postMsg('setModel', this.value)">
<datalist id="modelOptions">
${(providers[config.provider]?.models ?? []).map(m =>
`<option value="${m}">`
).join('\n ')}
</datalist>
</div>
```
#### JS 变更(setupView.js `populateModelOptions()`
```javascript
function populateModelOptions(providerId, currentModel) {
var datalist = document.getElementById('modelOptions');
var input = document.getElementById('modelInput');
if (!datalist || !input) { return; }
var models = (PROVIDERS[providerId] && PROVIDERS[providerId].models) || [];
// 刷新 datalist 选项
datalist.innerHTML = '';
for (var i = 0; i < models.length; i++) {
var opt = document.createElement('option');
opt.value = models[i];
datalist.appendChild(opt);
}
// 保留用户已输入的模型名,仅当为空时填入第一个建议
if (!input.value && models.length > 0) {
input.value = models[0];
postMsg('setModel', models[0]);
}
}
```
#### 事件绑定变更
`setupView.js` 第 109-111 行原来监听 `modelSelect``change` 事件,改为监听 `modelInput`
```javascript
document.getElementById('modelInput').addEventListener('change', function () {
postMsg('setModel', this.value.trim());
});
```
#### initConfig 消息处理变更
`setupView.js` 第 143-147 行原来调用 `populateModelOptions(c.provider, c.model)` 并设置 `select.value`。改为直接设置 `input.value`
```javascript
if (c.provider && PROVIDERS[c.provider]) {
populateModelOptions(c.provider, c.model);
var mi = document.getElementById('modelInput');
if (mi && c.model) { mi.value = c.model; }
var ps = document.getElementById('providerSelect');
if (ps) { ps.value = c.provider; }
}
```
### 新增国际化键
| 键 | zh-CN | en | ja |
|----|-------|-----|-----|
| `setup.modelPlaceholder` | 输入或选择模型名 | Enter or select model name | モデル名を入力または選択 |
---
## 04 方案 B:供应商注册表外置化
### 整体架构
```
插件内置 providers.json(随 VSIX 打包)
registry.ts 加载并解析
用户工作区 .code-review/providers.json(可选覆盖)
合并后的 ProviderConfig[] 供 factory.ts 和 setupView.ts 消费
```
### providers.json 格式
```json
{
"providers": [
{
"id": "deepseek",
"name": "DeepSeek",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://api.deepseek.com/v1",
"models": ["deepseek-chat", "deepseek-reasoner"]
},
{
"id": "openai",
"name": "OpenAI",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://api.openai.com/v1",
"models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"]
},
{
"id": "gemini",
"name": "Google Gemini",
"protocol": "gemini",
"defaultBaseUrl": "https://generativelanguage.googleapis.com/v1",
"models": ["gemini-2.0-flash", "gemini-1.5-pro"]
},
{
"id": "claude",
"name": "Anthropic Claude",
"protocol": "claude",
"defaultBaseUrl": "https://api.anthropic.com/v1",
"models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514"]
},
{
"id": "hunyuan",
"name": "腾讯混元",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://api.hunyuan.cloud.tencent.com/v1",
"models": ["hunyuan-pro"]
},
{
"id": "zhipu",
"name": "智谱AI",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://open.bigmodel.cn/api/paas/v4",
"models": ["glm-4-plus", "glm-4-flash"]
},
{
"id": "moonshot",
"name": "月之暗面",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://api.moonshot.cn/v1",
"models": ["moonshot-v1-8k", "moonshot-v1-32k"]
},
{
"id": "tongyi",
"name": "阿里通义",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"models": ["qwen-plus", "qwen-turbo"]
}
]
}
```
### 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `id` | string | 是 | 供应商唯一标识,用于配置存储和工厂查找 |
| `name` | string | 是 | 显示名称,出现在侧边栏下拉框 |
| `protocol` | string | 是 | 协议类型,决定实例化哪个 Provider 类。可选值:`openai-compatible``gemini``claude` |
| `defaultBaseUrl` | string | 是 | 该供应商的默认 API 端点 |
| `models` | string[] | 是 | 预置模型列表,作为 datalist 建议项。用户可输入列表外的模型名 |
### 用户覆盖机制
用户可在工作区根目录的 `.code-review/providers.json` 中放置同名文件,格式与内置文件一致。加载器按以下规则合并:
| 情况 | 合并结果 |
|------|----------|
| 用户文件包含内置中已有的供应商(id 相同) | 用户配置完整覆盖该供应商的所有字段 |
| 用户文件包含内置中没有的供应商 | 追加为新供应商 |
| 用户文件缺少内置中的某供应商 | 保留内置的该供应商 |
示例:用户想添加本地 Ollama 服务,在工作区放置 `.code-review/providers.json`
```json
{
"providers": [
{
"id": "ollama",
"name": "Ollama (本地)",
"protocol": "openai-compatible",
"defaultBaseUrl": "http://localhost:11434/v1",
"models": ["llama3.1", "qwen2.5"]
},
{
"id": "deepseek",
"name": "DeepSeek (自定义代理)",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://my-proxy.example.com/v1",
"models": ["deepseek-chat", "deepseek-coder"]
}
]
}
```
合并后:`ollama` 作为新供应商追加,`deepseek` 的 name/baseUrl/models 被用户配置完整覆盖,其余 6 个内置供应商保留。
---
## 05 修复 package.json 不一致
### `ai.provider` 配置项
去掉 `enum` 限制,改为自由 string,因为供应商列表现在是动态的:
```jsonc
"vscode-code-reviewer.ai.provider": {
"type": "string",
"default": "deepseek",
"description": "AI 模型提供商"
}
```
#### `ai.model` 配置项
去掉 `enum` 限制(如果有),保持为自由 string:
```jsonc
"vscode-code-reviewer.ai.model": {
"type": "string",
"default": "deepseek-chat",
"description": "AI 模型名称"
}
```
> 默认模型保持 `deepseek-chat`,与内置 `providers.json` 中 DeepSeek 的第一个模型对齐。用户升级后如果之前未手动改过模型,会自动匹配到列表中的建议项。
---
## 06 数据结构设计
### ProviderConfig 接口
```typescript
export type ProviderProtocol = 'openai-compatible' | 'gemini' | 'claude';
export interface ProviderConfig {
id: string;
name: string;
protocol: ProviderProtocol;
defaultBaseUrl: string;
models: string[];
}
export interface ProvidersFile {
providers: ProviderConfig[];
}
```
### ProviderMeta 接口(供 UI 消费)
```typescript
export interface ProviderMeta {
name: string;
models: string[];
}
```
`getAllProviderMeta()` 的返回类型保持 `Record<string, ProviderMeta>`,与现有结构一致。
---
## 07 注册表加载器实现
### 新建文件:`src/ai/registry.ts`
```typescript
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import type { ProviderConfig, ProvidersFile, ProviderMeta } from './types';
let cachedProviders: ProviderConfig[] | null = null;
/** 读取插件内置的 providers.json */
function loadBuiltinProviders(extensionUri: vscode.Uri): ProviderConfig[] {
const filePath = vscode.Uri.joinPath(extensionUri, 'providers.json').fsPath;
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as ProvidersFile;
return data.providers ?? [];
} catch {
return [];
}
}
/** 读取用户工作区的 .code-review/providers.json */
function loadUserProviders(): ProviderConfig[] {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { return []; }
const filePath = path.join(workspaceRoot, '.code-review', 'providers.json');
if (!fs.existsSync(filePath)) { return []; }
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as ProvidersFile;
return data.providers ?? [];
} catch {
return [];
}
}
/** 合并内置与用户配置:用户配置按 id 覆盖内置 */
function mergeProviders(
builtin: ProviderConfig[],
user: ProviderConfig[]
): ProviderConfig[] {
const map = new Map<string, ProviderConfig>();
for (const p of builtin) {
map.set(p.id, p);
}
for (const p of user) {
map.set(p.id, p);
}
return Array.from(map.values());
}
/**
* 获取合并后的供应商列表。
* 首次调用时加载并缓存,后续调用直接返回缓存。
* extensionUri 仅首次调用时需要传入。
*/
export function getProviders(extensionUri?: vscode.Uri): ProviderConfig[] {
if (cachedProviders) { return cachedProviders; }
if (!extensionUri) {
return [];
}
const builtin = loadBuiltinProviders(extensionUri);
const user = loadUserProviders();
cachedProviders = mergeProviders(builtin, user);
return cachedProviders;
}
/** 清除缓存,强制下次调用时重新加载 */
export function invalidateProviderCache(): void {
cachedProviders = null;
}
/** 按 id 查找单个供应商 */
export function getProviderById(
extensionUri: vscode.Uri,
id: string
): ProviderConfig | undefined {
return getProviders(extensionUri).find(p => p.id === id);
}
/** 获取所有供应商的 UI 元数据 */
export function getAllProviderMeta(
extensionUri: vscode.Uri
): Record<string, ProviderMeta> {
const result: Record<string, ProviderMeta> = {};
for (const p of getProviders(extensionUri)) {
result[p.id] = {
name: p.name,
models: p.models,
};
}
return result;
}
```
### 缓存失效策略
`setupView.ts``resolveWebviewView()` 中注册文件系统监听器,当用户编辑 `.code-review/providers.json` 后自动刷新:
```typescript
const watcher = vscode.workspace.createFileSystemWatcher(
'**/.code-review/providers.json'
);
watcher.onDidChange(() => {
invalidateProviderCache();
this.pushConfig();
});
watcher.onDidCreate(() => {
invalidateProviderCache();
this.pushConfig();
});
webviewView.onDidDispose(() => {
watcher.dispose();
});
```
---
## 08 工厂函数改造
### 现状
`factory.ts` 中的 `registry` 是静态对象,每个供应商的 `cls` 字段直接引用具体类。`createProvider()` 通过 `new info.cls(apiKey, baseUrl)` 实例化。
### 改造方案
`factory.ts` 不再持有静态注册表,改为从 `registry.ts` 动态加载。`protocol` 字段决定实例化哪个类。
### 改造后的 factory.ts
```typescript
import * as vscode from 'vscode';
import type { AIProvider } from './providers/base';
import type { ProviderConfig, ProviderMeta, ProviderProtocol } from './types';
import { OpenAICompatibleProvider } from './providers/openai-compatible';
import { GeminiProvider } from './providers/gemini';
import { ClaudeProvider } from './providers/claude';
import {
getProviders,
getProviderById,
getAllProviderMeta as getAllProviderMetaFromRegistry,
invalidateProviderCache,
} from './registry';
const PROTOCOL_MAP: Record<ProviderProtocol,
new (apiKey: string, baseUrl: string, id: string, name: string) => any
> = {
'openai-compatible': OpenAICompatibleProvider,
gemini: GeminiProvider,
claude: ClaudeProvider,
};
export function createProvider(
providerId: string,
apiKey: string,
baseUrl: string,
extensionUri: vscode.Uri
): AIProvider {
const config = getProviderById(extensionUri, providerId);
if (!config) {
throw new Error(`未知的 Provider: ${providerId}`);
}
const Cls = PROTOCOL_MAP[config.protocol];
if (!Cls) {
throw new Error(`未知的协议类型: ${config.protocol}`);
}
if (config.protocol === 'openai-compatible') {
return new Cls(apiKey, baseUrl, config.id, config.name);
}
return new Cls(apiKey, baseUrl);
}
export function getProviderModels(extensionUri: vscode.Uri, providerId: string): string[] {
return getProviderById(extensionUri, providerId)?.models ?? [];
}
export function getAllProviderMeta(
extensionUri: vscode.Uri
): Record<string, ProviderMeta> {
return getAllProviderMetaFromRegistry(extensionUri);
}
export { invalidateProviderCache };
```
### GeminiProvider / ClaudeProvider 适配
现有的 `GeminiProvider``ClaudeProvider` 构造函数签名为 `constructor(apiKey, baseUrl)`,不接收 `id``name`。保持不变,在 `createProvider()` 中根据 protocol 分别调用不同的构造方式。
### engine.ts 调用链变更
`engine.ts` 第 195 行调用 `createProvider()` 时需要传入 `extensionUri`
```typescript
// 现状
provider = createProvider(providerId, apiKey, baseUrl);
// 改造后
provider = createProvider(providerId, apiKey, baseUrl, context.extensionUri);
```
`runAIReview()` 已经接收 `context: vscode.ExtensionContext` 参数(第 173 行),`context.extensionUri` 可直接获取。
### testConnection() 调用链变更
`setupView.ts` 第 380 行的 `testConnection()` 同样需要传入 `extensionUri`
```typescript
const provider = createProvider(config.provider, apiKey, config.baseUrl, this.context.extensionUri);
```
### convertContentWithAI() 调用链变更
`src/rules/import-service.ts` 第 382 行的 `convertContentWithAI()` 也调用了 `createProvider()`。该函数已接收 `context: vscode.ExtensionContext` 参数(第 367 行),可直接使用 `context.extensionUri`
```typescript
// 现状
const provider = createProvider(config.provider, apiKey, config.baseUrl);
// 改造后
const provider = createProvider(config.provider, apiKey, config.baseUrl, context.extensionUri);
```
---
## 09 侧边栏面板改造
### setupView.ts 变更
#### resolveWebviewView() — 初始化时传入 extensionUri
第 203 行:
```typescript
// 现状
const providers = getAllProviderMeta();
// 改造后
const providers = getAllProviderMeta(this.context.extensionUri);
```
#### pushConfig() — 推送供应商元数据
第 335 行:
```typescript
// 现状
providers: getAllProviderMeta(),
// 改造后
providers: getAllProviderMeta(this.context.extensionUri),
```
#### setProvider 消息处理 — 传入 extensionUri
第 245 行:
```typescript
// 现状
const models = getProviderModels(msg.value);
// 改造后
const models = getProviderModels(this.context.extensionUri, msg.value);
```
#### getHtml() — 供应商下拉框渲染
供应商下拉框保持 `<select>` 不变(供应商数量有限,且不允许用户手动输入供应商 id),但 `providers` 数据来源改为 `getAllProviderMeta(this.context.extensionUri)`
模型区域改为方案 A 中的 `<input> + <datalist>`
#### resolveWebviewView() — 新增 FileSystemWatcher
新增对 `.code-review/providers.json` 的文件系统监听,在 `onDidDispose` 中一并释放。
### setupView.js 变更
#### modelSelect → modelInput
按方案 A 的改造方案,将所有 `modelSelect` 引用替换为 `modelInput``populateModelOptions()` 改为填充 `datalist`
#### initConfig 消息处理 — 设置 modelInput 值
```javascript
if (c.provider && PROVIDERS[c.provider]) {
populateModelOptions(c.provider, c.model);
var mi = document.getElementById('modelInput');
if (mi) { mi.value = c.model || ''; }
var ps = document.getElementById('providerSelect');
if (ps) { ps.value = c.provider; }
}
```
---
## 10 文件级变更规格
### [NEW] `providers.json`(项目根目录)
插件内置的供应商配置文件,随 VSIX 打包。内容见 04 节的 JSON 示例。包含 8 个供应商的完整配置。
### [NEW] `src/ai/types.ts`
提取 `ProviderConfig``ProvidersFile``ProviderMeta``ProviderProtocol` 类型定义。`factory.ts``registry.ts` 均从此文件导入。
```typescript
export type ProviderProtocol = 'openai-compatible' | 'gemini' | 'claude';
export interface ProviderConfig {
id: string;
name: string;
protocol: ProviderProtocol;
defaultBaseUrl: string;
models: string[];
}
export interface ProvidersFile {
providers: ProviderConfig[];
}
export interface ProviderMeta {
name: string;
models: string[];
}
```
### [NEW] `src/ai/registry.ts`
注册表加载器,实现内置 JSON 读取、用户覆盖合并、缓存管理。完整实现见 07 节。
### [MODIFY] `src/ai/factory.ts`
| 变更位置 | 变更内容 |
|----------|----------|
| 删除 | 静态 `registry` 对象及其全部内容 |
| 删除 | `ProviderInfo` 接口定义 |
| 修改 | `createProvider()` 签名新增 `extensionUri` 参数,改为从 registry 动态查找 |
| 修改 | `getProviderModels()` 签名新增 `extensionUri` 参数 |
| 修改 | `getAllProviderMeta()` 签名新增 `extensionUri` 参数,委托给 registry.ts |
| 新增 | `PROTOCOL_MAP` 常量,映射 protocol → Provider 类 |
| 新增 | `invalidateProviderCache` re-export |
| 删除 | `getProviderIds()``getProviderInfo()``getProviderDefaultBaseUrl()`(改为通过 registry.ts 获取) |
### [MODIFY] `src/ai/engine.ts`
| 变更位置 | 变更内容 |
|----------|----------|
| 第 195 行 | `createProvider(providerId, apiKey, baseUrl)``createProvider(providerId, apiKey, baseUrl, context.extensionUri)` |
### [MODIFY] `src/rules/import-service.ts`
| 变更位置 | 变更内容 |
|----------|----------|
| 第 382 行 | `createProvider(config.provider, apiKey, config.baseUrl)``createProvider(config.provider, apiKey, config.baseUrl, context.extensionUri)` |
### [MODIFY] `src/views/setupView.ts`
| 变更位置 | 变更内容 |
|----------|----------|
| 第 203 行 | `getAllProviderMeta()` 调用改为传入 `this.context.extensionUri` |
| 第 245 行 | `getProviderModels(msg.value)` 调用改为传入 `this.context.extensionUri, msg.value` |
| 第 335 行 | `getAllProviderMeta()` 调用改为传入 `this.context.extensionUri` |
| 第 380 行 | `createProvider()` 调用新增 `this.context.extensionUri` 参数 |
| `resolveWebviewView()` | 新增 `FileSystemWatcher` 监听 `.code-review/providers.json` 变更 |
| 第 889-891 行 | 模型区域 `<select id="modelSelect">` 替换为 `<input id="modelInput" list="modelOptions">` + `<datalist id="modelOptions">` |
### [MODIFY] `src/views/setupView.js`
| 变更位置 | 变更内容 |
|----------|----------|
| 第 88-102 行 `populateModelOptions()` | 改为填充 `<datalist>` 而非 `<select>`,保留用户已输入的值 |
| 第 109-111 行 `modelSelect` change 事件 | 改为 `modelInput` change 事件 |
| 第 143-147 行 `initConfig` 消息处理 | `modelSelect.value` 设置改为 `modelInput.value` 设置 |
### [MODIFY] `package.json`
| 变更位置 | 变更内容 |
|----------|----------|
| 第 92-96 行 `ai.provider` 配置项 | 删除 `enum` 数组,改为自由 string |
| 版本号 | `1.1.0``1.2.0` |
### [MODIFY] `src/i18n/messages.ts`
新增 `setup.modelPlaceholder` 国际化键(三语言)。
### `.vscodeignore`
确认 `providers.json` 不在忽略列表中,确保随 VSIX 打包。
---
## 11 迁移路径
### 11.1 内置 providers.json 中的模型名修正
当前 `factory.ts` 中的模型名(如 `deepseek-v4-pro``GPT-5.6 Sol``Claude Fable 5`)并非真实模型名。新的 `providers.json` 中应使用各供应商 API 实际接受的模型标识符:
| 供应商 | 当前(虚构) | 修正为(真实) |
|--------|-------------|---------------|
| DeepSeek | `deepseek-v4-pro`, `deepseek-v4-flash` | `deepseek-chat`, `deepseek-reasoner` |
| OpenAI | `GPT-5.6 Sol`, `GPT-5.6 Terra` 等 | `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo` |
| Gemini | `Gemini 3.1 Pro` 等 | `gemini-2.0-flash`, `gemini-1.5-pro` |
| Claude | `Claude Fable 5` 等 | `claude-sonnet-4-20250514`, `claude-opus-4-20250514` |
| 混元 | `Hy3` | `hunyuan-pro` |
| 智谱 | `GLM-5.2` 等 | `glm-4-plus`, `glm-4-flash` |
| 月之暗面 | `Kimi K2.7 Code` 等 | `moonshot-v1-8k`, `moonshot-v1-32k` |
| 通义 | `Qwen3-2507` 等 | `qwen-plus`, `qwen-turbo` |
> 以上模型名基于截至 2026-07 各供应商 API 文档的公开模型标识符。实际发布前需再次验证各供应商 API 文档的最新模型列表。
### 11.2 用户配置兼容性
| 用户已有配置 | 升级后行为 |
|-------------|-----------|
| `ai.provider` = `deepseek` | 保留,`providers.json` 中存在该 id,正常工作 |
| `ai.model` = `deepseek-chat` | 保留,新 `providers.json` 中 DeepSeek 列表包含此项,datalist 中可见 |
| `ai.model` = `deepseek-v4-pro`(旧列表中的值) | 保留,`modelInput` 中显示该值,但不在 datalist 建议中。用户可继续使用或手动修改。API 是否接受取决于供应商 |
| `ai.baseUrl` 有值 | 保留不变,切换供应商时不自动覆盖 |
| API KeySecretStorage | 保留不变 |
| `ai.outputLanguage` | 保留不变 |
### 11.3 向后兼容保证
`createProvider()` 的调用方(`engine.ts``setupView.ts`)需要传入 `extensionUri`。这是签名变更,但均在插件内部调用,不影响用户侧 API。`runAIReview()` 的外部调用签名不变。
---
## 12 测试要点
### 方案 A:模型输入框
| 测试场景 | 操作步骤 | 期望结果 |
|----------|----------|----------|
| 选择预置模型 | 点击模型输入框,从 datalist 建议中选择一个 | 输入框填入选中值,`ai.model` 配置更新 |
| 手动输入模型名 | 在模型输入框中输入 `my-custom-model` 并失焦 | 输入框保留输入值,`ai.model` 更新为 `my-custom-model` |
| 切换供应商后保留手输模型 | 先手动输入模型名,再切换供应商 | 模型输入框清空并填入新供应商的第一个建议模型 |
| 空值处理 | 清空模型输入框并失焦 | `ai.model` 更新为空字符串,AI 审查时报错提示需配置模型 |
### 方案 B:供应商注册表
| 测试场景 | 前置条件 | 期望结果 |
|----------|----------|----------|
| 内置 JSON 加载 | 无用户覆盖文件 | `getProviders()` 返回 8 个内置供应商 |
| 用户追加供应商 | 工作区有 `.code-review/providers.json`,包含 `ollama` | 合并后返回 9 个供应商,`ollama` 出现在下拉框 |
| 用户覆盖内置供应商 | 用户文件中 `deepseek``defaultBaseUrl` 改为代理地址 | 合并后 DeepSeek 的 `defaultBaseUrl` 为用户配置的代理地址 |
| 用户文件格式错误 | `.code-review/providers.json` 内容为非法 JSON | 加载器静默返回空数组,不影响内置供应商加载 |
| 用户文件不存在 | 工作区无 `.code-review/` 目录 | 仅返回内置 8 个供应商 |
| 文件监听刷新 | 编辑并保存 `.code-review/providers.json` | 缓存失效,侧边栏自动刷新供应商列表 |
| 缓存命中 | 连续调用两次 `getProviders()` | 第二次直接返回缓存,不重复读取文件系统 |
### 附加修复项
| 测试场景 | 操作步骤 | 期望结果 |
|----------|----------|----------|
| package.json 无 enum 限制 | 在 settings.json 中手动写入 `ai.provider: "custom-provider"` | VS Code 不报 schema 校验错误,插件正常加载 |
| 连接测试使用 extensionUri | 配置完 API Key 后点击"保存并测试" | `createProvider()` 正确接收 extensionUri,测试请求发送到正确端点 |
### 回归测试
| 测试场景 | 期望结果 |
|----------|----------|
| AI 代码审查(`Ctrl+Shift+R`) | 正常运行,使用配置的供应商和模型 |
| 选中代码审查 | 正常运行 |
| 审查结果面板导出 | 正常导出报告 |
| 自定义规则管理 | 规则文件导入、删除正常 |
| 静态分析适配器面板 | 4 张适配器卡片正常渲染,不受供应商改造影响 |
| 保存文件自动分析 | 500ms 防抖后触发静态分析 |
---
*本设计书基于 vscode-code-reviewer 插件 v1.1.0 编写,覆盖方案 A(模型输入框可编辑化)、方案 B(供应商注册表外置化)及 `package.json` 修复的全部技术实现规格。实施时按第 10 节文件级变更规格逐文件执行,以第 06-09 节的数据结构和逻辑为实现依据。*
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+31 -5
View File
@@ -2,7 +2,7 @@
"name": "vscode-code-reviewer",
"displayName": "净码特工 · Code Purifier",
"description": "AI 驱动的代码审查与规范检查一体化 VSCode 插件",
"version": "1.0.1",
"version": "1.2.0",
"engines": {
"vscode": "^1.120.0"
},
@@ -89,10 +89,6 @@
"vscode-code-reviewer.ai.provider": {
"type": "string",
"default": "deepseek",
"enum": [
"deepseek",
"openai"
],
"description": "AI 模型提供商"
},
"vscode-code-reviewer.ai.model": {
@@ -222,6 +218,36 @@
"default": "",
"description": "sqlfluff 配置文件路径"
},
"vscode-code-reviewer.linters.eslintConfigPath": {
"type": "string",
"default": "",
"description": "ESLint 自定义配置文件路径(绝对路径)。留空则使用项目 .eslintrc 或内置规则"
},
"vscode-code-reviewer.linters.stylelintConfigPath": {
"type": "string",
"default": "",
"description": "Stylelint 自定义配置文件路径(绝对路径)。留空则使用项目 .stylelintrc 或内置规则"
},
"vscode-code-reviewer.linter.pmd.enabled": {
"type": "boolean",
"default": true,
"description": "启用/禁用 PMD 适配器"
},
"vscode-code-reviewer.linter.sql-lint.enabled": {
"type": "boolean",
"default": true,
"description": "启用/禁用 SQL-Lint 适配器"
},
"vscode-code-reviewer.linter.eslint.enabled": {
"type": "boolean",
"default": true,
"description": "启用/禁用 ESLint 适配器"
},
"vscode-code-reviewer.linter.stylelint.enabled": {
"type": "boolean",
"default": true,
"description": "启用/禁用 Stylelint 适配器"
},
"vscode-code-reviewer.fixer.contextLines": {
"type": "number",
"default": 5,
+60
View File
@@ -0,0 +1,60 @@
{
"providers": [
{
"id": "deepseek",
"name": "DeepSeek",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://api.deepseek.com/v1",
"models": ["deepseek-chat", "deepseek-reasoner"]
},
{
"id": "openai",
"name": "OpenAI",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://api.openai.com/v1",
"models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"]
},
{
"id": "gemini",
"name": "Google Gemini",
"protocol": "gemini",
"defaultBaseUrl": "https://generativelanguage.googleapis.com/v1",
"models": ["gemini-2.0-flash", "gemini-1.5-pro"]
},
{
"id": "claude",
"name": "Anthropic Claude",
"protocol": "claude",
"defaultBaseUrl": "https://api.anthropic.com/v1",
"models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514"]
},
{
"id": "hunyuan",
"name": "腾讯混元",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://api.hunyuan.cloud.tencent.com/v1",
"models": ["hunyuan-pro"]
},
{
"id": "zhipu",
"name": "智谱AI",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://open.bigmodel.cn/api/paas/v4",
"models": ["glm-4-plus", "glm-4-flash"]
},
{
"id": "moonshot",
"name": "月之暗面",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://api.moonshot.cn/v1",
"models": ["moonshot-v1-8k", "moonshot-v1-32k"]
},
{
"id": "tongyi",
"name": "阿里通义",
"protocol": "openai-compatible",
"defaultBaseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
"models": ["qwen-plus", "qwen-turbo"]
}
]
}
+40 -3
View File
@@ -1,8 +1,45 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { ESLint } from 'eslint';
import js from '@eslint/js';
import ts from 'typescript-eslint';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
import { getEslintConfigPath } from '../config';
const PROJECT_CONFIG_FILES = [
'.eslintrc.js',
'.eslintrc.json',
'.eslintrc.yaml',
'.eslintrc.yml',
'.eslintrc',
'eslint.config.js',
'eslint.config.mjs',
];
function findProjectConfig(dir: string): string | null {
for (const name of PROJECT_CONFIG_FILES) {
const p = path.join(dir, name);
if (fs.existsSync(p)) {
return p;
}
}
return null;
}
function resolveEslintConfig(workingDir: string): { configFile?: string; overrideConfig?: any[] } {
const globalPath = getEslintConfigPath();
if (globalPath && globalPath.trim() !== '') {
return { configFile: globalPath };
}
const projectConfig = findProjectConfig(workingDir);
if (projectConfig) {
return { configFile: projectConfig };
}
return { overrideConfig: ESLintAdapter.getDefaultConfig() };
}
export class ESLintAdapter implements LinterAdapter {
id = 'eslint';
@@ -10,7 +47,7 @@ export class ESLintAdapter implements LinterAdapter {
private static defaultConfig: any[] | null = null;
private static getDefaultConfig(): any[] {
public static getDefaultConfig(): any[] {
if (!ESLintAdapter.defaultConfig) {
ESLintAdapter.defaultConfig = [
js.configs.recommended,
@@ -26,10 +63,10 @@ export class ESLintAdapter implements LinterAdapter {
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const resolved = resolveEslintConfig(workingDir);
const engine = new ESLint({
cwd: workingDir,
overrideConfigFile: true,
overrideConfig: ESLintAdapter.getDefaultConfig(),
...resolved,
});
const ext = document.languageId === 'typescript' ? 'ts' : 'js';
const isVirtual = document.uri.scheme === 'untitled';
+21 -3
View File
@@ -3,16 +3,26 @@ import * as path from 'path';
import { existsSync } from 'fs';
import { execSync, spawn } from 'child_process';
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
import { getPMDRulesetPath } from '../config';
import { getPMDJarPath, getPMDRulesetPath } from '../config';
import { t } from '../i18n/messages';
export class PmdAdapter implements LinterAdapter {
id = 'pmd';
supportedLanguages = ['java'];
private pmdDir: string | null = null;
private jarPathChecked = false;
private resolvePmdDir(): string {
if (this.pmdDir) { return this.pmdDir; }
const jarPath = getPMDJarPath();
if (jarPath && jarPath.trim() !== '') {
if (existsSync(path.join(jarPath, 'PmdRunner.class'))) {
this.pmdDir = jarPath;
return this.pmdDir;
}
}
const candidates: string[] = [];
try {
const ext = vscode.extensions.getExtension?.('vscode-code-reviewer');
@@ -43,8 +53,16 @@ export class PmdAdapter implements LinterAdapter {
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const ruleset = getPMDRulesetPath()
|| path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
const globalRuleset = getPMDRulesetPath();
let ruleset: string;
if (globalRuleset && globalRuleset.trim() !== '') {
ruleset = globalRuleset;
} else {
const projectRuleset = path.join(workingDir, 'ruleset.xml');
ruleset = existsSync(projectRuleset)
? projectRuleset
: path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
}
const classpath = `${this.getPmdLibClasspath()};${this.getPmdRunnerClasspath()}`;
const isVirtual = document.uri.scheme === 'untitled';
+23 -3
View File
@@ -1,6 +1,9 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { spawn } from 'child_process';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
import { getSqlLintConfigFile } from '../config';
import { t } from '../i18n/messages';
const DIALECT_MAP: Record<string, string> = {
@@ -22,9 +25,14 @@ interface SqlFluffResult {
violations: SqlFluffViolation[];
}
function runSqlfluff(dialect: string, code: string, cwd: string): Promise<string> {
function runSqlfluff(dialect: string, code: string, cwd: string, configPath?: string): Promise<string> {
return new Promise((resolve, reject) => {
const child = spawn('sqlfluff', ['lint', '--dialect', dialect, '--format', 'json', '-'], {
const args = ['lint', '--dialect', dialect, '--format', 'json'];
if (configPath) {
args.push('--config', configPath);
}
args.push('-');
const child = spawn('sqlfluff', args, {
cwd,
timeout: 30000,
});
@@ -68,8 +76,20 @@ export class SqlLintAdapter implements LinterAdapter {
const languageId = document.languageId;
const dialect = DIALECT_MAP[languageId] || 'ansi';
let configPath: string | undefined;
const globalConfig = getSqlLintConfigFile();
if (globalConfig && globalConfig.trim() !== '') {
configPath = globalConfig;
} else {
const projectConfig = path.join(workingDir, '.sqlfluff');
if (fs.existsSync(projectConfig)) {
configPath = projectConfig;
}
}
try {
const stdout = await runSqlfluff(dialect, document.getText(), workingDir);
const stdout = await runSqlfluff(dialect, document.getText(), workingDir, configPath);
const results: SqlFluffResult[] = JSON.parse(stdout);
const diagnostics: LinterDiagnostic[] = [];
+6 -1
View File
@@ -2,6 +2,7 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import type { LinterAdapter, AdapterResult, LinterDiagnostic, Severity } from './adapter';
import { getStylelintConfigPath } from '../config';
const CONFIG_FILE_NAMES = [
'.stylelintrc',
@@ -36,6 +37,7 @@ interface LinterOptions {
codeFilename?: string;
cwd?: string;
config?: Record<string, unknown>;
configFile?: string;
}
interface LinterResult {
@@ -88,7 +90,10 @@ export class StylelintAdapter implements LinterAdapter {
cwd: workingDir,
};
if (!hasExternalConfig(workingDir)) {
const globalPath = getStylelintConfigPath();
if (globalPath && globalPath.trim() !== '') {
lintOptions.configFile = globalPath;
} else if (!hasExternalConfig(workingDir)) {
lintOptions.config = DEFAULT_CONFIG;
}
+1 -1
View File
@@ -192,7 +192,7 @@ export async function runAIReview(
let provider: AIProvider;
try {
provider = createProvider(providerId, apiKey, baseUrl);
provider = createProvider(providerId, apiKey, baseUrl, context.extensionUri);
} catch (err) {
return {
customRuleResults: [],
+40 -107
View File
@@ -1,118 +1,51 @@
import { AIProvider } from './providers/base';
import * as vscode from 'vscode';
import type { AIProvider } from './providers/base';
import type { ProviderMeta, ProviderProtocol } from './types';
import { OpenAICompatibleProvider } from './providers/openai-compatible';
import { GeminiProvider } from './providers/gemini';
import { ClaudeProvider } from './providers/claude';
import {
getProviderById,
getAllProviderMeta as getAllProviderMetaFromRegistry,
invalidateProviderCache,
} from './registry';
interface ProviderInfo {
cls: new (apiKey: string, baseUrl: string) => AIProvider;
defaultBaseUrl: string;
models: string[];
name: string;
}
const registry: Record<string, ProviderInfo> = {
deepseek: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'deepseek', 'DeepSeek');
}
},
defaultBaseUrl: 'https://api.deepseek.com/v1',
models: ['deepseek-v4-pro', 'deepseek-v4-flash'],
name: 'DeepSeek',
},
openai: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'openai', 'OpenAI');
}
},
defaultBaseUrl: 'https://api.openai.com/v1',
models: ['GPT-5.6 Sol', 'GPT-5.6 Terra', 'GPT-5.6 Luna', 'GPT-5.5', 'GPT-5.4'],
name: 'OpenAI',
},
gemini: {
cls: GeminiProvider,
defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1',
models: ['Gemini 3.1 Pro', 'Gemini 3.1 Flash', 'Gemini 3.1 Flash-Lite', 'Gemini 3 Pro', 'Gemini 3 Flash'],
name: 'Google Gemini',
},
claude: {
cls: ClaudeProvider,
defaultBaseUrl: 'https://api.anthropic.com/v1',
models: ['Claude Fable 5', 'Claude Mythos 5', 'Claude Opus 4.8', 'Claude Sonnet 4.6'],
name: 'Anthropic Claude',
},
hunyuan: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'hunyuan', '腾讯混元');
}
},
defaultBaseUrl: 'https://api.hunyuan.cloud.tencent.com/v1',
models: ['Hy3'],
name: '腾讯混元',
},
zhipu: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'zhipu', '智谱AI');
}
},
defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4',
models: ['GLM-5.2', 'GLM-5.1', 'GLM-5'],
name: '智谱AI',
},
moonshot: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'moonshot', '月之暗面');
}
},
defaultBaseUrl: 'https://api.moonshot.cn/v1',
models: ['Kimi K2.7 Code', 'Kimi K2.6', 'Kimi K2.5'],
name: '月之暗面',
},
tongyi: {
cls: class extends OpenAICompatibleProvider {
constructor(apiKey: string, baseUrl: string) {
super(apiKey, baseUrl, 'tongyi', '阿里通义');
}
},
defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
models: ['Qwen3-2507', 'Qwen3.6-35B-A3B', 'Qwen3.5-397B-A17B'],
name: '阿里通义',
},
const PROTOCOL_MAP: Record<ProviderProtocol, new (...args: any[]) => AIProvider> = {
'openai-compatible': OpenAICompatibleProvider,
gemini: GeminiProvider,
claude: ClaudeProvider,
};
export function createProvider(providerId: string, apiKey: string, baseUrl: string): AIProvider {
const info = registry[providerId];
if (!info) {
export function createProvider(
providerId: string,
apiKey: string,
baseUrl: string,
extensionUri: vscode.Uri
): AIProvider {
const config = getProviderById(extensionUri, providerId);
if (!config) {
throw new Error(`未知的 Provider: ${providerId}`);
}
return new info.cls(apiKey, baseUrl);
}
export function getProviderIds(): string[] {
return Object.keys(registry);
}
export function getProviderInfo(providerId: string): ProviderInfo | undefined {
return registry[providerId];
}
export function getProviderDefaultBaseUrl(providerId: string): string {
return registry[providerId]?.defaultBaseUrl ?? '';
}
export function getProviderModels(providerId: string): string[] {
return registry[providerId]?.models ?? [];
}
export function getAllProviderMeta(): Record<string, { name: string; models: string[] }> {
const result: Record<string, { name: string; models: string[] }> = {};
for (const [id, info] of Object.entries(registry)) {
result[id] = { name: info.name, models: info.models };
const Cls = PROTOCOL_MAP[config.protocol];
if (!Cls) {
throw new Error(`未知的协议类型: ${config.protocol}`);
}
return result;
if (config.protocol === 'openai-compatible') {
return new Cls(apiKey, baseUrl, config.id, config.name);
}
return new Cls(apiKey, baseUrl);
}
export function getProviderModels(extensionUri: vscode.Uri, providerId: string): string[] {
return getProviderById(extensionUri, providerId)?.models ?? [];
}
export function getAllProviderMeta(
extensionUri: vscode.Uri
): Record<string, ProviderMeta> {
return getAllProviderMetaFromRegistry(extensionUri);
}
export { invalidateProviderCache };
+1
View File
@@ -3,6 +3,7 @@ export interface ChatOptions {
temperature: number;
maxTokens: number;
timeoutMs: number;
seed?: number;
}
export abstract class AIProvider {
+8 -2
View File
@@ -14,7 +14,7 @@ export class OpenAICompatibleProvider extends AIProvider {
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
const url = `${this.baseUrl}/chat/completions`;
const body = JSON.stringify({
const bodyObj: Record<string, unknown> = {
model: options.model,
max_tokens: options.maxTokens,
temperature: options.temperature,
@@ -22,7 +22,13 @@ export class OpenAICompatibleProvider extends AIProvider {
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
],
});
};
if (options.seed !== undefined) {
bodyObj.seed = options.seed;
}
const body = JSON.stringify(bodyObj);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
+87
View File
@@ -0,0 +1,87 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import type { ProviderConfig, ProvidersFile, ProviderMeta } from './types';
let cachedProviders: ProviderConfig[] | null = null;
function loadBuiltinProviders(extensionUri: vscode.Uri): ProviderConfig[] {
const filePath = vscode.Uri.joinPath(extensionUri, 'providers.json').fsPath;
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as ProvidersFile;
return data.providers ?? [];
} catch {
return [];
}
}
function loadUserProviders(): ProviderConfig[] {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { return []; }
const filePath = path.join(workspaceRoot, '.code-review', 'providers.json');
if (!fs.existsSync(filePath)) { return []; }
try {
const raw = fs.readFileSync(filePath, 'utf-8');
const data = JSON.parse(raw) as ProvidersFile;
return data.providers ?? [];
} catch {
return [];
}
}
function mergeProviders(
builtin: ProviderConfig[],
user: ProviderConfig[]
): ProviderConfig[] {
const map = new Map<string, ProviderConfig>();
for (const p of builtin) {
map.set(p.id, p);
}
for (const p of user) {
map.set(p.id, p);
}
return Array.from(map.values());
}
export function getProviders(extensionUri?: vscode.Uri): ProviderConfig[] {
if (cachedProviders) { return cachedProviders; }
if (!extensionUri) {
return [];
}
const builtin = loadBuiltinProviders(extensionUri);
const user = loadUserProviders();
cachedProviders = mergeProviders(builtin, user);
return cachedProviders;
}
export function invalidateProviderCache(): void {
cachedProviders = null;
}
export function getProviderById(
extensionUri: vscode.Uri,
id: string
): ProviderConfig | undefined {
return getProviders(extensionUri).find(p => p.id === id);
}
export function getAllProviderMeta(
extensionUri: vscode.Uri
): Record<string, ProviderMeta> {
const result: Record<string, ProviderMeta> = {};
for (const p of getProviders(extensionUri)) {
result[p.id] = {
name: p.name,
models: p.models,
};
}
return result;
}
+18
View File
@@ -0,0 +1,18 @@
export type ProviderProtocol = 'openai-compatible' | 'gemini' | 'claude';
export interface ProviderConfig {
id: string;
name: string;
protocol: ProviderProtocol;
defaultBaseUrl: string;
models: string[];
}
export interface ProvidersFile {
providers: ProviderConfig[];
}
export interface ProviderMeta {
name: string;
models: string[];
}
+20
View File
@@ -21,3 +21,23 @@ export function getPMDJspRulesetPath(): string {
export function getSqlLintConfigFile(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('sql-lint.configFile', '');
}
export function getEslintConfigPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('linters.eslintConfigPath', '');
}
export function getStylelintConfigPath(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('linters.stylelintConfigPath', '');
}
export function isAdapterEnabled(adapterId: string): boolean {
return vscode.workspace.getConfiguration(ROOT).get<boolean>(`linter.${adapterId}.enabled`, true);
}
export async function setAdapterEnabled(adapterId: string, enabled: boolean): Promise<void> {
await vscode.workspace.getConfiguration(ROOT).update(
`linter.${adapterId}.enabled`,
enabled,
vscode.ConfigurationTarget.Global
);
}
+210
View File
@@ -212,6 +212,11 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Press <b>Ctrl + Shift + R</b> to trigger review, results appear in the <b>Review Report</b> panel',
ja: '<b>Ctrl + Shift + R</b> でレビューを実行、結果は<b>レビューレポート</b>に表示',
},
'setup.aiConnectionConfig': {
'zh-CN': 'AI 连接配置',
en: 'AI Connection Config',
ja: 'AI接続設定',
},
'setup.engineSection': {
'zh-CN': '审核引擎',
en: 'Review Engines',
@@ -267,6 +272,11 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Model Name',
ja: 'モデル名',
},
'setup.modelPlaceholder': {
'zh-CN': '输入或选择模型名',
en: 'Enter or select model name',
ja: 'モデル名を入力または選択',
},
'setup.modelHint': {
'zh-CN': '建议使用支持结构化输出的模型。',
en: 'Use a model that supports structured output.',
@@ -342,6 +352,206 @@ const messages: Record<string, Record<Language, string>> = {
en: 'Save & Test Connection',
ja: '保存して接続テスト',
},
'setup.configured': {
'zh-CN': '已配置',
en: 'Configured',
ja: '設定済み',
},
'setup.connected': {
'zh-CN': '✓ 已连接',
en: '✓ Connected',
ja: '✓ 接続済み',
},
'setup.retry': {
'zh-CN': '✗ 重试',
en: '✗ Retry',
ja: '✗ 再試行',
},
'setup.ruleCountFormat': {
'zh-CN': '{0} 个文件',
en: '{0} file(s)',
ja: '{0} ファイル',
},
'setup.noRuleFiles': {
'zh-CN': '暂无规则文件',
en: 'No rule files',
ja: 'ルールファイルなし',
},
'setup.adapter.subtitle': {
'zh-CN': '静态分析适配器',
en: 'Static Analysis Adapters',
ja: '静的解析アダプター',
},
'setup.adapter.modeBuiltin': {
'zh-CN': '内置规则',
en: 'Built-in Rules',
ja: '組み込みルール',
},
'setup.adapter.modeProject': {
'zh-CN': '项目配置',
en: 'Project Config',
ja: 'プロジェクト設定',
},
'setup.adapter.modeGlobal': {
'zh-CN': '全局配置',
en: 'Global Config',
ja: 'グローバル設定',
},
'setup.adapter.modeLegend': {
'zh-CN': '插件按 内置<全局<项目的优先级自动选择配置来源',
en: 'Auto-selects config by priority: Built-in < Global < Project',
ja: '優先順位に従って自動選択: 組み込み < グローバル < プロジェクト',
},
'setup.adapter.configYes': {
'zh-CN': '已配置',
en: 'Configured',
ja: '設定済み',
},
'setup.adapter.configNo': {
'zh-CN': '未配置',
en: 'Not configured',
ja: '未設定',
},
'setup.adapter.langLabel': {
'zh-CN': '可审查的语言:',
en: 'Languages:',
ja: '対応言語:',
},
'setup.adapter.tooltipTab': {
'zh-CN': '点击展开/收起静态分析适配器',
en: 'Click to expand/collapse static analysis adapters',
ja: 'クリックで静的解析アダプターを展開/折りたたむ',
},
'setup.adapter.btnCreateConfig': {
'zh-CN': '创建项目配置',
en: 'Create Project Config',
ja: 'プロジェクト設定を作成',
},
'setup.adapter.btnEditGlobal': {
'zh-CN': '修改全局设置',
en: 'Modify Global Settings',
ja: 'グローバル設定を変更',
},
'setup.adapter.tooltipCreate': {
'zh-CN': '在项目根目录创建 {0}',
en: 'Create {0} in project root',
ja: 'プロジェクトルートに {0} を作成',
},
'setup.adapter.tooltipEdit': {
'zh-CN': '修改 VS Code 设置中的全局参数',
en: 'Modify global parameters in VS Code settings',
ja: 'VS Code設定のグローバルパラメータを変更',
},
'setup.adapter.toggleEnable': {
'zh-CN': '启用 {0} 适配器',
en: 'Enable {0} adapter',
ja: '{0} アダプターを有効化',
},
'setup.adapter.toggleDisable': {
'zh-CN': '禁用 {0} 适配器',
en: 'Disable {0} adapter',
ja: '{0} アダプターを無効化',
},
'setup.template.pmdBestPractices': {
'zh-CN': 'Java 最佳实践(如:避免空 catch、关闭流等)',
en: 'Java best practices (avoid empty catch, close streams, etc.)',
ja: 'Javaベストプラクティス(空のcatch回避、ストリームクローズ等)',
},
'setup.template.pmdCodeStyle': {
'zh-CN': 'Java 代码风格(如:命名规范、花括号位置等)',
en: 'Java code style (naming conventions, brace placement, etc.)',
ja: 'Javaコードスタイル(命名規則、ブレース位置等)',
},
'setup.template.sqlfluffDialect': {
'zh-CN': '数据库方言:postgres / mysql / bigquery / snowflake 等',
en: 'Database dialect: postgres / mysql / bigquery / snowflake etc.',
ja: 'データベース方言:postgres / mysql / bigquery / snowflake など',
},
'setup.template.sqlfluffRules': {
'zh-CN': 'all = 启用全部规则,也可指定规则名逗号分隔',
en: 'all = enable all rules, or specify rule names separated by commas',
ja: 'all = すべてのルールを有効、ルール名をカンマ区切りで指定可',
},
'setup.template.eslintComment1': {
'zh-CN': '未使用的变量 → 警告',
en: 'Unused variables → warning',
ja: '未使用変数 → 警告',
},
'setup.template.eslintComment2': {
'zh-CN': '允许使用 console',
en: 'Allow console',
ja: 'consoleを許可',
},
'setup.template.eslintComment3': {
'zh-CN': '强制分号',
en: 'Enforce semicolons',
ja: 'セミコロンを強制',
},
'setup.template.stylelintComment1': {
'zh-CN': '缩进 2 空格',
en: 'Indentation: 2 spaces',
ja: 'インデント: 2スペース',
},
'setup.template.stylelintComment2': {
'zh-CN': '禁止空规则',
en: 'No empty rules',
ja: '空ルールを禁止',
},
'setup.adapter.pmdLanguages': {
'zh-CN': 'Java(含 JSP 中的 Java 代码,例如<% ... %>',
en: 'Java (including Java code in JSP, e.g. <% ... %>)',
ja: 'JavaJSP内のJavaコードを含む、例: <% ... %>',
},
'setup.adapter.pmdGuide': {
'zh-CN': '需要 Java 运行环境;项目根目录创建 ruleset.xml 或在设置中配置 pmd.rulesetPath',
en: 'Requires Java runtime; create ruleset.xml in project root or set pmd.rulesetPath in settings',
ja: 'Java実行環境が必要。プロジェクトルートにruleset.xmlを作成するか、設定でpmd.rulesetPathを設定してください',
},
'setup.adapter.sqlLanguages': {
'zh-CN': 'SQL',
en: 'SQL',
ja: 'SQL',
},
'setup.adapter.sqlGuide': {
'zh-CN': '需要 Python 环境和 sqlfluff;运行 pip install sqlfluff,项目根目录创建 .sqlfluff',
en: 'Requires Python and sqlfluff; run pip install sqlfluff, create .sqlfluff in project root',
ja: 'Python環境とsqlfluffが必要。pip install sqlfluff を実行し、プロジェクトルートに.sqlfluffを作成してください',
},
'setup.adapter.eslintLanguages': {
'zh-CN': 'JS, TS, JSX, TSX(含 JSP 中的 JavaScript 代码,例如<script>',
en: 'JS, TS, JSX, TSX (including JavaScript in JSP, e.g. <script>)',
ja: 'JS, TS, JSX, TSXJSP内のJavaScriptコードを含む、例: <script>',
},
'setup.adapter.eslintGuide': {
'zh-CN': '项目根目录创建 .eslintrc.js 或在 VS Code 设置中配置 eslintConfigPath',
en: 'Create .eslintrc.js in project root or set eslintConfigPath in VS Code settings',
ja: 'プロジェクトルートに.eslintrc.jsを作成するか、VS Code設定でeslintConfigPathを設定してください',
},
'setup.adapter.stylelintLanguages': {
'zh-CN': 'CSS, SCSS, Less(含 JSP 中的 CSS 代码,例如<style>',
en: 'CSS, SCSS, Less (including CSS in JSP, e.g. <style>)',
ja: 'CSS, SCSS, LessJSP内のCSSコードを含む、例: <style>',
},
'setup.adapter.stylelintGuide': {
'zh-CN': '项目根目录创建 .stylelintrc 或在 VS Code 设置中配置 stylelintConfigPath',
en: 'Create .stylelintrc in project root or set stylelintConfigPath in VS Code settings',
ja: 'プロジェクトルートに.stylelintrcを作成するか、VS Code設定でstylelintConfigPathを設定してください',
},
'setup.aiReviewStatusCapability': {
'zh-CN': '审查能力',
en: 'Capability',
ja: 'レビュー機能',
},
'setup.aiReviewCapability': {
'zh-CN': '修改建议 · 自定义规则审查 · 深度代码审查',
en: 'Fix Suggestions · Custom Rules Review · Deep Code Review',
ja: '修正提案 · カスタムルールレビュー · 詳細コードレビュー',
},
'setup.notConnected': {
'zh-CN': '未连接',
en: 'Not connected',
ja: '未接続',
},
'importPreview.title': {
'zh-CN': '规则导入预览',
en: 'Rule Import Preview',
+10 -1
View File
@@ -1,6 +1,6 @@
import * as vscode from 'vscode';
import type { LinterAdapter, LinterDiagnostic } from '../types';
import { getLinterForLanguage } from '../config';
import { getLinterForLanguage, isAdapterEnabled } from '../config';
import { ESLintAdapter } from '../adapters/eslint';
import { PmdAdapter } from '../adapters/pmd';
import { StylelintAdapter } from '../adapters/stylelint';
@@ -59,6 +59,15 @@ export class Orchestrator {
};
}
if (!isAdapterEnabled(adapter.id)) {
return {
diagnostics: [],
errors: [],
adapterIds: [],
duration: Date.now() - startTime,
};
}
const result = await adapter.check(document, workingDir);
const errors: string[] = [];
if (result.status !== 'ok') {
+31 -4
View File
@@ -43,6 +43,10 @@ const p: Record<Lang, PromptStrings> = {
'- 混合形式(段落 + 列表 + 表格组合)',
'',
'不得因输入格式非标准而拒绝转换。应主动从松散描述中提取规则语义。',
'',
'- 按输入文档的段落或列表项顺序处理,保持原文顺序输出',
'- 不要合并或拆分原文中已是独立条目的规则',
'- 一个段落包含多条规则时才拆分,单条规则不要拆成多条',
],
nonRuleFilterTitle: '## 非规则内容过滤',
nonRuleFilterLines: [
@@ -58,12 +62,17 @@ const p: Record<Lang, PromptStrings> = {
' **必须**基于规则描述内容自动生成语义化的 id',
' 即使输入中无显式 id 标识,也必须根据 description/message 的语义推断出合适的 id',
' 多条规则之间 id 不得重复',
' id 只能使用 description/message 中已有的英文单词或短语,转换为 kebab-case',
' 不要自行创造原文中没有的英文词汇',
' 如果输入全中文,从语义提取核心关键词翻译为简短英文(2-4 个词)',
'- severity: 严重级别(error / warning / info',
' **必须**输出。按规则语义推断:',
' error: 会导致 bug / 安全问题 / 数据损坏',
' warning: 潜在问题 / 不良实践',
' info: 风格 / 可读性建议',
' 即使输入中无显式严重级别,也必须根据规则后果的严重程度推断',
' 如果无法从输入中确定严重级别,默认填 warning',
' 只有明确涉及安全、数据泄露、崩溃风险时才填 error',
'- description: 规则简短描述',
' **必须**输出。若输入中不明显,从 message 的内容反向推导出简短描述',
'- message: 违反时的提示消息',
@@ -135,7 +144,7 @@ const p: Record<Lang, PromptStrings> = {
'',
'仅输出 YAML,不要额外说明。',
],
finalInstruction: '',
finalInstruction: '只输出 YAML 内容,不要输出 markdown 代码块标记,不要输出解释性文字',
dedupHeader: '## 已知规则清单(用于重复检测)',
dedupLinterLabel: (name, count) => `### ${name} (${count} 条)`,
dedupCustomLabel: (count) => `### 已导入的自定义规则 (${count} 条)`,
@@ -154,6 +163,10 @@ const p: Record<Lang, PromptStrings> = {
'- Mixed forms (paragraphs + lists + tables)',
'',
'Do not reject conversion due to non-standard input format. Actively extract rule semantics from loose descriptions.',
'',
'- Process in the order of the input document paragraphs or list items, preserving original order',
'- Do not merge or split entries that are already independent rules in the original text',
'- Only split when a single paragraph contains multiple rules; do not split a single rule into multiple',
],
nonRuleFilterTitle: '## Non-Rule Content Filtering',
nonRuleFilterLines: [
@@ -169,12 +182,17 @@ const p: Record<Lang, PromptStrings> = {
' **Must** generate a semantic id based on the rule description content',
' Even if no explicit id is present in the input, infer a suitable id from the description/message semantics',
' IDs must not be duplicated across rules',
' id must use only English words or phrases already present in description/message, converted to kebab-case',
' Do not invent English words not found in the original text',
' If input is entirely in Chinese, extract core semantic keywords and translate to short English (2-4 words)',
'- severity: Severity level (error / warning / info)',
' **Must** output. Infer based on rule semantics:',
' error: causes bugs / security issues / data corruption',
' warning: potential issues / bad practices',
' info: style / readability suggestions',
' Even if no explicit severity is given, infer from the rule\'s impact',
' If severity cannot be determined from input, default to warning',
' Only use error when the rule clearly involves security, data leakage, or crash risk',
'- description: Short rule description',
' **Must** output. If not obvious from input, derive from message content',
'- message: Violation message',
@@ -246,7 +264,7 @@ const p: Record<Lang, PromptStrings> = {
'',
'Output YAML only, no extra explanation.',
],
finalInstruction: 'All descriptions and messages must be written in English.',
finalInstruction: 'All descriptions and messages must be written in English.\nOutput YAML only, no markdown code fences, no explanatory text',
dedupHeader: '## Known Rules (for duplicate detection)',
dedupLinterLabel: (name, count) => `### ${name} (${count} rules)`,
dedupCustomLabel: (count) => `### Imported custom rules (${count} rules)`,
@@ -262,9 +280,13 @@ const p: Record<Lang, PromptStrings> = {
'- 自然言語の段落(1つ以上の段落でルールを記述)',
'- 順不同リスト(各ルールが1行または1段落)',
'- テーブル(列名は固定されていません。意味から推測してください)',
'- 混合形式(段落 + リスト + テーブルの組み合わせ',
'- 混合形式(段落 + リスト + テーブルの組み込み',
'',
'非標準的な入力形式であっても変換を拒否してはいけません。緩やかな記述からルールの意味を積極的に抽出してください。',
'',
'- 入力ドキュメントの段落またはリスト項目の順序で処理し、原文の順序を保持する',
'- 原文ですでに独立したエントリであるルールを結合または分割しない',
'- 単一の段落に複数のルールが含まれる場合のみ分割し、単一ルールを複数に分割しない',
],
nonRuleFilterTitle: '## 非ルールコンテンツのフィルタリング',
nonRuleFilterLines: [
@@ -280,12 +302,17 @@ const p: Record<Lang, PromptStrings> = {
' **必須** ルール説明内容に基づいて意味的なidを自動生成する',
' 入力に明示的なidがない場合でも、description/messageの意味から適切なidを推測する',
' 複数ルール間でidが重複してはいけない',
' idはdescription/messageにすでに存在する英単語またはフレーズのみを使用し、kebab-caseに変換する',
' 原文にない英単語を独自に作成しない',
' 入力がすべて日本語の場合は、セマンティクスから核心キーワードを抽出し、短い英語(2〜4語)に翻訳する',
'- severity: 重大度(error / warning / info',
' **必須**で出力。ルールの意味に従って推測:',
' error: バグ/セキュリティ問題/データ破損を引き起こす',
' warning: 潜在的な問題/悪い慣行',
' info: スタイル/可読性の提案',
' 入力に明示的な重大度がない場合でも、ルールの影響の重大さから推測する',
' 入力から重大度を判断できない場合は、デフォルトでwarningとする',
' セキュリティ、データ漏洩、クラッシュリスクに明確に関連する場合のみerrorとする',
'- description: ルールの簡単な説明',
' **必須**で出力。入力で不明確な場合、messageの内容から逆算して短い説明を導出',
'- message: 違反時のメッセージ',
@@ -357,7 +384,7 @@ const p: Record<Lang, PromptStrings> = {
'',
'YAMLのみを出力し、追加説明は不要です。',
],
finalInstruction: 'すべてのdescriptionとmessageは日本語で出力してください。',
finalInstruction: 'すべてのdescriptionとmessageは日本語で出力してください。\nYAML のみ出力、マークダウンコードブロックなし、説明テキストなし',
dedupHeader: '## 既知ルール一覧(重複検出用)',
dedupLinterLabel: (name, count) => `### ${name}${count} 件)`,
dedupCustomLabel: (count) => `### インポート済みカスタムルール(${count} 件)`,
+30 -4
View File
@@ -218,6 +218,30 @@ function buildFinalYamlFromRaw(
return output.join('\n');
}
function normalizeRuleIds(rules: ImportableRule[]): ImportableRule[] {
const seen = new Set<string>();
return rules.map((rule, idx) => {
let id = rule.id
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (!id) {
id = `rule-${idx + 1}`;
}
if (seen.has(id)) {
let suffix = 2;
while (seen.has(`${id}-${suffix}`)) { suffix++; }
id = `${id}-${suffix}`;
}
seen.add(id);
return { ...rule, id };
});
}
export class ImportService {
private converters: Map<string, RuleConverter> = new Map();
@@ -245,7 +269,8 @@ export class ImportService {
throw new Error(t('import.conversionFailed'));
}
const rules = parseImportableYaml(yamlContent);
const parsedRules = parseImportableYaml(yamlContent);
const rules = normalizeRuleIds(parsedRules);
const exactCount = rules.filter(r => r.duplicateLevel === 'exact').length;
const overlapCount = rules.filter(r => r.duplicateLevel === 'overlap').length;
@@ -354,7 +379,7 @@ export async function convertContentWithAI(
}
const config = getAIConfig();
const provider = createProvider(config.provider, apiKey, config.baseUrl);
const provider = createProvider(config.provider, apiKey, config.baseUrl, context.extensionUri);
const prompt = systemPrompt ?? buildFallbackPrompt();
@@ -362,9 +387,10 @@ export async function convertContentWithAI(
try {
yamlOutput = await provider.chat(prompt, content, {
model: config.model,
temperature: 0.1,
maxTokens: 4096,
temperature: 0,
maxTokens: 8192,
timeoutMs: getAITimeout() * 1000,
seed: 42,
});
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
+32
View File
@@ -0,0 +1,32 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
import { ESLintAdapter } from '../adapters/eslint';
import { StylelintAdapter } from '../adapters/stylelint';
suite('Adapter Tests', () => {
test('ESLintAdapter has correct id and languages', () => {
const adapter = new ESLintAdapter();
assert.strictEqual(adapter.id, 'eslint');
assert.deepStrictEqual(adapter.supportedLanguages, ['javascript', 'typescript']);
});
test('StylelintAdapter has correct id and languages', () => {
const adapter = new StylelintAdapter();
assert.strictEqual(adapter.id, 'stylelint');
assert.deepStrictEqual(adapter.supportedLanguages, ['css']);
});
test('ESLintAdapter check returns AdapterResult structure', async () => {
const adapter = new ESLintAdapter();
if (!adapter.isAvailable()) { return; }
const doc = await vscode.workspace.openTextDocument({
content: 'const x = 1;\nconsole.log(x);\n',
language: 'javascript',
});
const result = await adapter.check(doc, __dirname);
assert.ok(result.status === 'ok' || result.status === 'tool-unavailable');
assert.ok(Array.isArray(result.diagnostics));
});
});
+33
View File
@@ -0,0 +1,33 @@
import * as assert from 'assert';
import { getAIConfig, getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage } from '../config/ai';
import { getLinterForLanguage, getPMDJarPath, getPMDRulesetPath } from '../config/linter';
import { getContextLines } from '../config/fixer';
suite('Config Tests', () => {
test('getAIConfig returns default values', () => {
const config = getAIConfig();
assert.strictEqual(config.provider, 'deepseek');
assert.strictEqual(config.model, 'deepseek-chat');
assert.strictEqual(config.baseUrl, 'https://api.deepseek.com/v1');
assert.strictEqual(config.outputLanguage, 'zh-CN');
});
test('AI individual getters return defaults', () => {
assert.strictEqual(getAIProvider(), 'deepseek');
assert.strictEqual(getAIModel(), 'deepseek-chat');
assert.strictEqual(getAIBaseUrl(), 'https://api.deepseek.com/v1');
assert.strictEqual(getAITemperature(), 0.2);
assert.strictEqual(getAITimeout(), 300);
assert.strictEqual(getAIMaxTokens(), 8192);
assert.strictEqual(getAIOutputLanguage(), 'zh-CN');
});
test('getLinterForLanguage returns configured linter', () => {
assert.strictEqual(getLinterForLanguage('javascript'), 'eslint');
assert.strictEqual(getLinterForLanguage('java'), 'pmd');
});
test('getContextLines returns default', () => {
assert.strictEqual(getContextLines(), 5);
});
});
+7
View File
@@ -0,0 +1,7 @@
public class Sample {
public void test() {
String password = "admin123";
System.out.println("debug");
System.out.println("debug");
}
}
+2
View File
@@ -0,0 +1,2 @@
.hello { color: black; background: #FFF; }
#test { margin: 0px; }
+5
View File
@@ -0,0 +1,5 @@
function test() {
var unused = 1;
console.log('debug');
return "hello world";
}
+345
View File
@@ -0,0 +1,345 @@
import * as assert from 'assert';
import { buildFinalYaml, parseImportableYaml } from '../rules/import-service';
import type { ImportableRule, PreviewDecision } from '../rules/import-types';
function makeRules(data: Array<Partial<ImportableRule>>): ImportableRule[] {
return data.map(d => ({
id: d.id ?? 'test-rule',
severity: d.severity ?? 'warning',
description: d.description ?? 'test description',
message: d.message ?? 'test message',
languages: d.languages,
excludeLanguages: d.excludeLanguages,
duplicateOf: d.duplicateOf,
duplicateLevel: d.duplicateLevel,
duplicateReason: d.duplicateReason,
}));
}
function defaultDecision(rules: ImportableRule[]): PreviewDecision {
const keepRule: Record<string, boolean> = {};
for (const rule of rules) {
keepRule[rule.id] = rule.duplicateLevel !== 'exact';
}
return { keepRule, confirmed: true };
}
function makeYaml(rules: Array<{ id: string; fields: Record<string, string> }>): string {
return rules.map(r => {
const lines = [`- id: ${r.id}`];
for (const [key, value] of Object.entries(r.fields)) {
lines.push(` ${key}: ${value}`);
}
return lines.join('\n');
}).join('\n\n') + '\n';
}
suite('Import Dedup Tests', () => {
test('无重复规则→全部保留,无注释行', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateLevel: 'none' },
{ id: 'rule-b' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateLevel: 'none' } },
{ id: 'rule-b', fields: { severity: 'info', description: 'desc b', message: 'msg b' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.includes('# [DUPLICATE'), 'Should have no duplicate headers');
assert.ok(result.includes('id: rule-a'));
assert.ok(result.includes('id: rule-b'));
assert.ok(!result.includes('duplicateLevel:'), 'duplicateLevel should be stripped');
});
test('全部 exact→全部注释', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
{ id: 'rule-b', duplicateOf: 'eslint/no-debugger', duplicateLevel: 'exact' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
{ id: 'rule-b', fields: { severity: 'error', description: 'desc b', message: 'msg b', duplicateOf: 'eslint/no-debugger', duplicateLevel: 'exact' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.match(/^[^#]*- id:/m), 'No non-commented rule lines');
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(result.includes('# 如需启用'));
});
test('全部 overlap→全部保留,无注释行', () => {
const rules = makeRules([
{ id: 'rule-a', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求 logger' },
{ id: 'rule-b', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap', duplicateReason: '更窄范围' },
]);
const yaml = makeYaml([
{ id: 'rule-a', fields: { severity: 'warning', description: 'desc a', message: 'msg a', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求 logger' } },
{ id: 'rule-b', fields: { severity: 'warning', description: 'desc b', message: 'msg b', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap', duplicateReason: '更窄范围' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(!result.includes('# [DUPLICATE'), 'Overlap rules should not be commented by default');
assert.ok(result.includes('id: rule-a'));
assert.ok(result.includes('id: rule-b'));
});
test('混合三档→exact注释,overlap和none保留', () => {
const rules = makeRules([
{ id: 'exact-rule', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
{ id: 'overlap-rule', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap' },
{ id: 'none-rule', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'exact-rule', fields: { severity: 'warning', description: 'd1', message: 'm1', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
{ id: 'overlap-rule', fields: { severity: 'warning', description: 'd2', message: 'm2', duplicateOf: 'eslint/no-unused', duplicateLevel: 'overlap' } },
{ id: 'none-rule', fields: { severity: 'info', description: 'd3', message: 'm3', duplicateLevel: 'none' } },
]);
const result = buildFinalYaml(yaml, rules, defaultDecision(rules));
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(!result.match(/^-\s+id:\s+exact-rule/m), 'exact rule should be commented');
assert.ok(result.match(/^-\s+id:\s+overlap-rule/m), 'overlap rule should be active');
assert.ok(result.match(/^-\s+id:\s+none-rule/m), 'none rule should be active');
});
test('用户恢复 exact 规则→取消注释', () => {
const rules = makeRules([
{ id: 'restored', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' },
]);
const yaml = makeYaml([
{ id: 'restored', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
]);
const decision: PreviewDecision = { keepRule: { restored: true }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.includes('[DUPLICATE'), 'Restored rule should have no duplicate annotation');
assert.ok(result.match(/^-\s+id:\s+restored/m), 'Restored rule should be active');
});
test('用户注释 overlap 规则→加 # 前缀和注释头', () => {
const rules = makeRules([
{ id: 'commented', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求' },
]);
const yaml = makeYaml([
{ id: 'commented', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '额外要求' } },
]);
const decision: PreviewDecision = { keepRule: { commented: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('[DUPLICATE: overlap]'));
assert.ok(result.includes('重叠原因:额外要求'));
assert.ok(!result.match(/^-\s+id:\s+commented/m), 'Commented rule should have # prefix');
});
test('用户注释 none 规则→加 # 前缀', () => {
const rules = makeRules([
{ id: 'comment-none', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'comment-none', fields: { severity: 'warning', description: 'd', message: 'm', duplicateLevel: 'none' } },
]);
const decision: PreviewDecision = { keepRule: { 'comment-none': false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.match(/^-\s+id:\s+comment-none/m), 'Should be commented');
assert.ok(result.includes('# - id: comment-none'));
});
test('保留规则→duplicateLevel/duplicateOf/duplicateReason 行被移除', () => {
const rules = makeRules([
{ id: 'kept', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: 'reason' },
]);
const yaml = makeYaml([
{ id: 'kept', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: 'reason' } },
]);
const decision: PreviewDecision = { keepRule: { kept: true }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(!result.includes('duplicateOf:'));
assert.ok(!result.includes('duplicateLevel:'));
assert.ok(!result.includes('duplicateReason:'));
assert.ok(result.includes('id: kept'));
});
test('注释规则含 duplicateReason→注释头包含重叠原因', () => {
const rules = makeRules([
{ id: 'r1', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '检测目标相同但额外要求 logger' },
]);
const yaml = makeYaml([
{ id: 'r1', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'overlap', duplicateReason: '检测目标相同但额外要求 logger' } },
]);
const decision: PreviewDecision = { keepRule: { r1: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('重叠原因:检测目标相同但额外要求 logger'));
});
test('编辑 description 后确认→YAML 使用新值', () => {
const rules = makeRules([
{ id: 'edit-desc', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-desc', fields: { severity: 'warning', description: '旧描述', message: '旧消息' } },
]);
const editedRules = makeRules([
{ id: 'edit-desc', severity: 'warning', description: '新描述', message: '新消息' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-desc': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('description: 新描述'));
assert.ok(!result.includes('旧描述'));
assert.ok(result.includes('message: 新消息'));
});
test('编辑 severity 后确认→YAML 使用新 severity', () => {
const rules = makeRules([
{ id: 'edit-sev', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-sev', fields: { severity: 'warning', description: 'd', message: 'm' } },
]);
const editedRules = makeRules([
{ id: 'edit-sev', severity: 'error', description: 'd', message: 'm' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-sev': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('severity: error'));
assert.ok(!result.includes('severity: warning'));
});
test('编辑 languages 后确认→YAML 含新 languages', () => {
const rules = makeRules([
{ id: 'edit-lang', duplicateLevel: 'none', languages: ['java'] },
]);
const yaml = makeYaml([
{ id: 'edit-lang', fields: { severity: 'warning', description: 'd', message: 'm', languages: '[java]' } },
]);
const editedRules = makeRules([
{ id: 'edit-lang', severity: 'warning', description: 'd', message: 'm', languages: ['javascript', 'typescript'] },
]);
const decision: PreviewDecision = { keepRule: { 'edit-lang': true }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('languages: [javascript, typescript]'));
assert.ok(!result.includes('languages: [java]'), 'old java language should be gone');
assert.ok(!result.includes(' [java]'), 'standalone java tag should be gone');
});
test('编辑后切换为注释→注释内容为编辑后的值', () => {
const rules = makeRules([
{ id: 'edit-comment', duplicateLevel: 'none' },
]);
const yaml = makeYaml([
{ id: 'edit-comment', fields: { severity: 'warning', description: '原描述', message: '原消息' } },
]);
const editedRules = makeRules([
{ id: 'edit-comment', severity: 'error', description: '新描述', message: '新消息' },
]);
const decision: PreviewDecision = { keepRule: { 'edit-comment': false }, confirmed: true, editedRules };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('# severity: error'), 'should have commented severity: error');
assert.ok(result.includes('# description: 新描述'));
assert.ok(result.includes('# message: 新消息'));
assert.ok(result.includes('[手动注释]'));
});
test('无编辑场景→回退到原始 yamlContent 处理', () => {
const rules = makeRules([
{ id: 'fallback', duplicateLevel: 'exact', duplicateOf: 'eslint/no-console' },
]);
const yaml = makeYaml([
{ id: 'fallback', fields: { severity: 'warning', description: 'd', message: 'm', duplicateOf: 'eslint/no-console', duplicateLevel: 'exact' } },
]);
const decision: PreviewDecision = { keepRule: { fallback: false }, confirmed: true };
const result = buildFinalYaml(yaml, rules, decision);
assert.ok(result.includes('[DUPLICATE: exact]'));
assert.ok(!result.match(/^-\s+id:\s+fallback/m));
assert.ok(result.includes('duplicateOf:'));
});
});
suite('parseImportableYaml Fallback Tests', () => {
test('severity 缺失→降级为 warning', () => {
const yaml = `- id: test-rule
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].severity, 'warning');
});
test('severity 非法值→降级为 warning', () => {
const yaml = `- id: test-rule
severity: critical
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].severity, 'warning');
});
test('id 缺失→生成 rule-N', () => {
const yaml = `- severity: warning
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].id, 'rule-1');
});
test('多条 id 缺失→rule-1, rule-2...', () => {
const yaml = `- severity: warning
description: desc a
message: msg a
- severity: info
description: desc b
message: msg b`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 2);
assert.strictEqual(rules[0].id, 'rule-1');
assert.strictEqual(rules[1].id, 'rule-2');
});
test('description 缺失、message 存在→互填', () => {
const yaml = `- id: test-rule
severity: error
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].description, 'test msg');
assert.strictEqual(rules[0].message, 'test msg');
});
test('message 缺失、description 存在→互填', () => {
const yaml = `- id: test-rule
severity: error
description: test desc`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].description, 'test desc');
assert.strictEqual(rules[0].message, 'test desc');
});
test('description 与 message 同时缺失→丢弃', () => {
const yaml = `- id: test-rule
severity: error
- id: test-rule2
severity: warning
description: test desc
message: test msg`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 1);
assert.strictEqual(rules[0].id, 'test-rule2');
});
test('正常完整输入→无回归', () => {
const yaml = `- id: no-console-log
severity: error
description: 禁止使用 console.log
message: 请使用 logger
- id: no-unused-vars
severity: warning
description: 禁止未使用变量
message: 删除或注释未使用变量
duplicateOf: eslint/no-unused-vars
duplicateLevel: exact`;
const rules = parseImportableYaml(yaml);
assert.strictEqual(rules.length, 2);
assert.strictEqual(rules[0].severity, 'error');
assert.strictEqual(rules[0].description, '禁止使用 console.log');
assert.strictEqual(rules[1].duplicateLevel, 'exact');
});
});
+16
View File
@@ -0,0 +1,16 @@
public class Buggy {
public void test() {
String password = "admin123";
String name = "test";
int x = 1;
int y = 2;
int z = x + y;
System.out.println("debug");
System.out.println("done");
}
public void duplicate() {
String password = "secret";
System.out.println(password);
}
}
+3
View File
@@ -0,0 +1,3 @@
.hello { color: #FFFFFF; background: black; }
#test { margin: 0px; }
.foo { font-size: 12px; }
+11
View File
@@ -0,0 +1,11 @@
var x = 1;
var y = 2;
var x = 3;
function test() {
var unused = 'hello';
console.log('debug');
return "world";
}
test();
+42
View File
@@ -0,0 +1,42 @@
<%@ page language="java" contentType="text/html" %>
<%@ taglib uri="http://java.sun.com/jsp/jstl/core" prefix="c" %>
<html>
<head>
<title>${title}</title>
<style>
.btn { color: #fff; background: blue; }
.btn { font-size: 14px; }
body { color: #fff; }
body { margin: 0; }
</style>
</head>
<body>
<%
String password = "admin123";
String name = "test";
int x = 1;
%>
<p>${message} ${message}</p>
<c:if test="${not empty user}">
<p>Welcome, ${user.name}</p>
</c:if>
<c:forEach items="${list}" var="item">
<span>${item}</span>
</c:forEach>
<c:out value="${xssInput}" />
<c:forEach begin="1" end="5">
<span>hello</span>
</c:forEach>
<script>
var msg = "hello";
var msg = "world";
console.log(msg);
</script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
SELECT name FORM users;
SELECT * FORM products;
INSERT INTO customers VALUES (1, 'test');
+55
View File
@@ -0,0 +1,55 @@
import * as assert from 'assert';
import { mergeResults, MergedReport } from '../merger/merger';
import { CustomRuleResult, AIFinding } from '../ai/schema';
import { LinterDiagnostic } from '../types';
suite('Merger Tests', () => {
test('mergeResults counts correctly', () => {
const staticDiags: LinterDiagnostic[] = [
{ severity: 'error', ruleId: 'eslint:no-unused', message: 'x is unused', range: new (require('vscode').Range)(0, 0, 0, 1) },
];
const customResults: CustomRuleResult[] = [
{ ruleId: 'custom:no-console', line: 5, severity: 'warning', message: 'avoid console.log' },
];
const aiFindings: AIFinding[] = [
{ ruleId: 'hardcoded-secret', severity: 'error', category: 'security', title: 'Hardcoded', description: 'Found secret', suggestion: 'Use env', line: 3 },
];
const report = mergeResults({
staticDiagnostics: staticDiags,
customRuleResults: customResults,
translatedDiagnostics: [],
aiFindings,
errors: [],
degraded: false,
startTime: Date.now(),
filePath: '/test/sample.js',
language: 'javascript',
adapterIds: ['eslint'],
});
assert.strictEqual(report.linterCount, 1);
assert.strictEqual(report.customRuleCount, 1);
assert.strictEqual(report.aiCount, 1);
assert.strictEqual(report.degraded, false);
assert.strictEqual(report.language, 'javascript');
});
test('mergeResults marks degraded when AI fails', () => {
const report = mergeResults({
staticDiagnostics: [],
customRuleResults: [],
translatedDiagnostics: [],
aiFindings: [],
errors: ['AI 请求超时'],
degraded: true,
startTime: Date.now(),
filePath: '/test/sample.js',
language: 'javascript',
adapterIds: ['eslint'],
});
assert.strictEqual(report.degraded, true);
assert.strictEqual(report.errors.length, 1);
});
});
+72
View File
@@ -0,0 +1,72 @@
import * as assert from 'assert';
import { t, setLanguage, getLanguage, getMessageKeys, Language } from '../i18n/messages';
suite('I18n Tests', () => {
test('all message keys have values for all three languages', () => {
const languages: Language[] = ['zh-CN', 'en', 'ja'];
const keys = getMessageKeys();
for (const lang of languages) {
setLanguage(lang);
for (const key of keys) {
const result = t(key);
assert.ok(result, `Key "${key}" is empty for language "${lang}"`);
assert.notStrictEqual(result, key, `Key "${key}" has no translation for language "${lang}"`);
}
}
});
test('default language is zh-CN', () => {
setLanguage('zh-CN');
assert.strictEqual(getLanguage(), 'zh-CN');
});
test('setLanguage changes current language', () => {
setLanguage('en');
assert.strictEqual(getLanguage(), 'en');
setLanguage('ja');
assert.strictEqual(getLanguage(), 'ja');
setLanguage('zh-CN');
});
test('t() falls back to key when key does not exist', () => {
setLanguage('zh-CN');
const result = t('nonexistent.key');
assert.strictEqual(result, 'nonexistent.key');
});
test('t() returns zh-CN string in zh-CN language', () => {
setLanguage('zh-CN');
assert.strictEqual(t('review.noEditor'), '请先打开一个文件');
});
test('t() returns English string in en language', () => {
setLanguage('en');
assert.strictEqual(t('review.noEditor'), 'Please open a file first');
});
test('t() returns Japanese string in ja language', () => {
setLanguage('ja');
assert.strictEqual(t('review.noEditor'), '最初にファイルを開いてください');
});
test('t() with template variables', () => {
setLanguage('en');
assert.strictEqual(
t('export.saved', { 0: '/home/user/report.md' }),
'Report saved to /home/user/report.md'
);
});
test('t() with multiple template variables', () => {
setLanguage('zh-CN');
assert.strictEqual(
t('report.totalSummary', { 0: '10', 1: '3', 2: '5', 3: '2' }),
'总计: 10 | 错误: 3 | 警告: 5 | 建议: 2'
);
});
teardown(() => {
setLanguage('zh-CN');
});
});
+37
View File
@@ -0,0 +1,37 @@
import * as assert from 'assert';
import * as vscode from 'vscode';
import * as path from 'path';
import { ESLintAdapter } from '../adapters/eslint';
import { mergeResults } from '../merger/merger';
suite('Pipeline Tests', () => {
test('Full pipeline: linter check + merge', async () => {
const adapter = new ESLintAdapter();
if (!adapter.isAvailable()) { return; }
const doc = await vscode.workspace.openTextDocument({
content: 'var x = 1;\nvar y = 2;\n',
language: 'javascript',
});
const staticResult = await adapter.check(doc, __dirname);
assert.ok(staticResult.status === 'ok');
const report = mergeResults({
staticDiagnostics: staticResult.diagnostics,
customRuleResults: [],
translatedDiagnostics: [],
aiFindings: [],
errors: [],
degraded: false,
startTime: Date.now(),
filePath: 'virtual-doc',
language: 'javascript',
adapterIds: ['eslint'],
});
assert.ok(typeof report.duration === 'number');
assert.ok(typeof report.linterCount === 'number');
assert.strictEqual(report.language, 'javascript');
});
});
+145
View File
@@ -0,0 +1,145 @@
import * as assert from 'assert';
import { filterForDocument, filterAndSummarize } from '../rules/rule-filter';
import type { CustomRule } from '../types';
function mockDoc(languageId: string, fileName: string): { languageId: string; fileName: string } {
return { languageId, fileName };
}
const baseRules: CustomRule[] = [
{ id: 'java-rule', severity: 'error', description: '', message: '', languages: ['java'] },
{ id: 'js-rule', severity: 'error', description: '', message: '', languages: ['javascript'] },
{ id: 'ts-rule', severity: 'error', description: '', message: '', languages: ['typescript'] },
{ id: 'css-rule', severity: 'error', description: '', message: '', languages: ['css'] },
{ id: 'universal', severity: 'warning', description: '', message: '' },
{ id: 'no-css', severity: 'info', description: '', message: '', excludeLanguages: ['css'] },
{ id: 'empty-langs', severity: 'info', description: '', message: '', languages: [] },
];
suite('Rule Filter Tests', () => {
test('白名单命中', () => {
const doc = mockDoc('java', '/test/Foo.java');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('java-rule'));
assert.ok(ids.includes('universal'));
});
test('白名单未命中', () => {
const doc = mockDoc('java', '/test/Foo.java');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(!ids.includes('js-rule'));
assert.ok(!ids.includes('css-rule'));
});
test('白名单为空→全语言保留', () => {
const doc = mockDoc('css', '/test/test.css');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('universal'));
assert.ok(ids.includes('empty-langs'));
});
test('白名单缺失→全语言保留', () => {
const doc = mockDoc('css', '/test/test.css');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('universal'));
});
test('黑名单命中→剔除', () => {
const doc = mockDoc('css', '/test/test.css');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(!ids.includes('no-css'));
});
test('黑名单未命中→保留', () => {
const doc = mockDoc('java', '/test/Foo.java');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('no-css'));
});
test('黑名单为空→保留', () => {
const rules: CustomRule[] = [
{ id: 'r1', severity: 'info', description: '', message: '', excludeLanguages: [] },
];
const doc = mockDoc('css', '/test/test.css');
const result = filterForDocument(rules, doc as any);
assert.strictEqual(result.length, 1);
});
test('白名单+黑名单交集→黑名单胜出剔除', () => {
const rules: CustomRule[] = [
{ id: 'r1', severity: 'info', description: '', message: '', languages: ['java'], excludeLanguages: ['java'] },
];
const doc = mockDoc('java', '/test/Foo.java');
const result = filterForDocument(rules, doc as any);
assert.strictEqual(result.length, 0);
});
test('typescriptreact 别名→命中 typescript 规则', () => {
const doc = mockDoc('typescriptreact', '/test/App.tsx');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('ts-rule'));
});
test('plsql 同组→命中 sql 规则', () => {
const rules: CustomRule[] = [
{ id: 'sql-rule', severity: 'error', description: '', message: '', languages: ['sql'] },
{ id: 'plsql-rule', severity: 'error', description: '', message: '', languages: ['plsql'] },
];
const doc = mockDoc('plsql', '/test/test.plsql');
const result = filterForDocument(rules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('sql-rule'));
assert.ok(ids.includes('plsql-rule'));
});
test('JSP 并集→保留 java 规则', () => {
const doc = mockDoc('html', '/test/test.jsp');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('java-rule'));
assert.ok(ids.includes('js-rule'));
assert.ok(ids.includes('ts-rule'));
assert.ok(ids.includes('css-rule'));
});
test('JSP 并集→保留 css 规则', () => {
const doc = mockDoc('html', '/test/test.jspx');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(ids.includes('css-rule'));
});
test('普通HTML不触发JSP→剔除java规则', () => {
const doc = mockDoc('html', '/test/index.html');
const result = filterForDocument(baseRules, doc as any);
const ids = result.map(r => r.id);
assert.ok(!ids.includes('java-rule'));
});
test('全部过滤→skippedRequestA=true', () => {
const rules: CustomRule[] = [
{ id: 'java-rule', severity: 'error', description: '', message: '', languages: ['java'] },
];
const doc = mockDoc('css', '/test/test.css');
const result = filterAndSummarize(rules, doc as any);
assert.strictEqual(result.relevant.length, 0);
assert.strictEqual(result.filteredOut.length, 1);
assert.strictEqual(result.skippedRequestA, true);
});
test('部分过滤→skippedRequestA=false', () => {
const doc = mockDoc('java', '/test/Foo.java');
const result = filterAndSummarize(baseRules, doc as any);
assert.ok(result.relevant.length > 0);
assert.ok(result.filteredOut.length > 0);
assert.strictEqual(result.skippedRequestA, false);
});
});
+170 -29
View File
@@ -36,6 +36,7 @@
var _step1Done = false;
var _step2Done = false;
var _i18n = {};
function updateSteps(step3Done) {
var steps = [_step1Done, _step2Done, step3Done];
@@ -51,25 +52,49 @@
}
}
function toggleCommonRules() {
var body = document.getElementById('commonRulesBody');
var arrow = document.getElementById('commonRulesArrow');
if (!body || !arrow) { return; }
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
arrow.classList.toggle('expanded', isHidden);
}
function toggleCustomRules() {
var body = document.getElementById('customRulesBody');
var arrow = document.getElementById('customRulesArrow');
if (!body || !arrow) { return; }
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
arrow.classList.toggle('expanded', isHidden);
}
function toggleAIReview() {
var body = document.getElementById('aiReviewBody');
var arrow = document.getElementById('aiReviewArrow');
if (!body || !arrow) { return; }
var isHidden = body.style.display === 'none';
body.style.display = isHidden ? 'block' : 'none';
arrow.classList.toggle('expanded', isHidden);
}
function escapeHtml(text) {
var d = document.createElement('div');
d.textContent = text;
return d.innerHTML;
}
function populateModelOptions(providerId, selectModel) {
var modelSelect = document.getElementById('modelSelect');
if (!modelSelect) { return; }
var models = (PROVIDERS[providerId] && PROVIDERS[providerId].models) || [];
modelSelect.innerHTML = '';
for (var i = 0; i < models.length; i++) {
var opt = document.createElement('option');
opt.value = models[i];
opt.textContent = models[i];
modelSelect.appendChild(opt);
}
if (models.length > 0) {
modelSelect.value = selectModel && models.indexOf(selectModel) !== -1 ? selectModel : models[0];
function populateModelOptions(providerId, currentModel) {
var input = document.getElementById('modelInput');
if (!input) { return; }
if (!input.value) {
var models = (PROVIDERS[providerId] && PROVIDERS[providerId].models) || [];
if (models.length > 0) {
input.value = models[0];
postMsg('setModel', models[0]);
}
}
}
@@ -78,8 +103,8 @@
postMsg('setProvider', this.value);
});
document.getElementById('modelSelect').addEventListener('change', function () {
postMsg('setModel', this.value);
document.getElementById('modelInput').addEventListener('change', function () {
postMsg('setModel', this.value.trim());
});
document.getElementById('baseUrlInput').addEventListener('change', function () {
@@ -90,6 +115,7 @@
var msg = event.data;
if (msg.type === 'initConfig') {
_i18n = msg.i18n || {};
var c = msg.config;
document.getElementById('languageSelect').value = c.language || 'zh-CN';
@@ -113,50 +139,75 @@
if (c.provider && PROVIDERS[c.provider]) {
populateModelOptions(c.provider, c.model);
var mi = document.getElementById('modelInput');
if (mi) { mi.value = c.model || ''; }
var ps = document.getElementById('providerSelect');
if (ps) { ps.value = c.provider; }
}
var pb = document.getElementById('providerBadge');
if (pb) {
pb.textContent = c.provider && c.model ? '已配置' : '未配置';
pb.textContent = c.provider && c.model ? msg.i18n.configured : msg.i18n.notConfigured;
pb.className = 'badge ' + (c.provider && c.model ? 'badge-configured' : 'badge-unconfigured');
}
var akb = document.getElementById('apiKeyBadge');
akb.textContent = c.apiKeyConfigured && c.baseUrlConfigured ? '已配置' : '未配置';
akb.textContent = c.apiKeyConfigured && c.baseUrlConfigured ? msg.i18n.configured : msg.i18n.notConfigured;
akb.className = 'badge ' + (c.apiKeyConfigured && c.baseUrlConfigured ? 'badge-configured' : 'badge-unconfigured');
var connDone = msg.connectionTested && msg.connectionSuccess;
var btnTest = document.getElementById('btnTest');
if (connDone) {
btnTest.innerHTML = '✓ 已连接';
btnTest.innerHTML = msg.i18n.connected;
} else if (msg.connectionTested && !msg.connectionSuccess) {
btnTest.innerHTML = '✗ 重试';
btnTest.innerHTML = msg.i18n.retry;
} else {
btnTest.innerHTML = '保存并测试连接';
btnTest.innerHTML = msg.i18n.saveTest;
}
btnTest.disabled = false;
var ruleList = document.getElementById('ruleList');
var countBadge = document.getElementById('ruleCountBadge');
var ruleList = document.getElementById('ruleListInEngine');
var customBadge = document.getElementById('customRuleCountBadge');
if (customBadge) {
customBadge.textContent = msg.ruleFiles ? String(msg.ruleFiles.length) : '0';
}
if (msg.ruleFiles && msg.ruleFiles.length > 0) {
countBadge.textContent = msg.ruleFiles.length + ' 个文件';
countBadge.className = 'badge badge-configured';
ruleList.innerHTML = msg.ruleFiles.map(function (f) {
return '<div class="rule-item">' +
'<span class="rule-name">' + escapeHtml(f) + '</span>' +
'<button class="rule-del" onclick="deleteFile(\'' + escapeHtml(f) + '\')">&times;</button></div>';
}).join('');
} else {
countBadge.textContent = '0 个文件';
countBadge.className = 'badge badge-unconfigured';
ruleList.innerHTML = '<div style="font-size:12px;color:#484f58;padding:8px 0;text-align:center;">暂无规则文件</div>';
ruleList.innerHTML = '<div style="font-size:12px;color:#484f58;padding:8px 0;text-align:center;">' + msg.i18n.noRuleFiles + '</div>';
}
_step1Done = c.provider && c.model && c.apiKeyConfigured && c.baseUrlConfigured;
_step2Done = msg.ruleFiles && msg.ruleFiles.length > 0;
updateSteps(msg.connectionTested && msg.connectionSuccess);
var aiProviderDisplay = document.getElementById('aiProviderDisplay');
var aiModelDisplay = document.getElementById('aiModelDisplay');
var aiConnDisplay = document.getElementById('aiConnectionDisplay');
var aiBadge = document.getElementById('aiReviewStatusBadge');
if (aiProviderDisplay) {
var p = PROVIDERS[c.provider];
aiProviderDisplay.textContent = p ? p.name : c.provider;
}
if (aiModelDisplay) {
aiModelDisplay.textContent = c.model || '';
}
if (aiConnDisplay) {
var connected = msg.connectionTested && msg.connectionSuccess;
aiConnDisplay.textContent = connected ? msg.i18n.connected : msg.i18n.notConnected;
}
if (aiBadge) {
var connected = msg.connectionTested && msg.connectionSuccess;
aiBadge.textContent = connected ? msg.i18n.connected : msg.i18n.notConnected;
aiBadge.style.background = connected ? 'rgba(63,185,80,0.15)' : 'rgba(139,148,158,0.12)';
aiBadge.style.color = connected ? '#3fb950' : 'var(--vscode-descriptionForeground)';
}
renderAdapters(msg.adapterStatus);
}
if (msg.type === 'testResult') {
@@ -168,15 +219,105 @@
var btnTest = document.getElementById('btnTest');
btnTest.disabled = false;
if (msg.success) {
btnTest.innerHTML = '✓ 已连接';
btnTest.innerHTML = _i18n.connected || '✓ Connected';
} else {
btnTest.innerHTML = '✗ 重试';
btnTest.innerHTML = _i18n.retry || '✗ Retry';
}
updateSteps(msg.success);
}
});
function renderAdapters(adapterStatus) {
var container = document.getElementById('adapter-list');
if (!container || !adapterStatus) { return; }
var i18n = _i18n;
var modeBadgeMap = {
builtin: { class: 'adapter-badge-info', text: i18n.modeBuiltin || '' },
project: { class: 'adapter-badge-ok', text: i18n.modeProject || '' },
global: { class: 'adapter-badge-warn', text: i18n.modeGlobal || '' },
};
var html = adapterStatus.map(function(a) {
var modeBadge = modeBadgeMap[a.configMode];
var depBadge = a.dependencyStatus === 'none' ? '' :
a.dependencyStatus === 'ready'
? '<span class="adapter-badge adapter-badge-ok">' + a.dependencyLabel + ' ✓</span>'
: '<span class="adapter-badge adapter-badge-error">' + a.dependencyLabel + ' ✗</span>';
var configBadge = a.configured
? '<span class="adapter-badge adapter-badge-ok">' + i18n.configYes + '</span>'
: '<span class="adapter-badge adapter-badge-warn">' + i18n.configNo + '</span>';
var toggleTooltip = a.enabled
? i18n.toggleDisable.replace('{0}', a.name)
: i18n.toggleEnable.replace('{0}', a.name);
return '<div class="adapter-card' + (a.enabled ? '' : ' disabled') + '">' +
'<div class="adapter-card-header">' +
'<span class="adapter-card-name">' + a.name + '</span>' +
'<div class="adapter-toggle' + (a.enabled ? '' : ' off') + '" data-adapter-id="' + a.id + '" data-tooltip="' + toggleTooltip + '"></div>' +
'</div>' +
'<div class="adapter-badges">' +
'<span class="adapter-badge ' + modeBadge.class + '">' + modeBadge.text + '</span>' +
depBadge +
configBadge +
'</div>' +
'<div class="adapter-languages"><span class="adapter-lang-label">' + i18n.langLabel + '</span>' + escapeHtml(a.languages) + '</div>' +
'<div class="adapter-guide">' + a.guideText + '</div>' +
'<div class="adapter-actions">' +
'<button class="adapter-btn" data-action="openAdapterConfig" data-adapter-id="' + a.id + '" data-tooltip="' + i18n.tooltipCreate.replace('{0}', a.projectConfigFileName) + '">' + i18n.btnCreateConfig + '</button>' +
'<button class="adapter-btn" data-action="openSettings" data-settings-target="' + a.settingsTarget + '" data-tooltip="' + i18n.tooltipEdit + '">' + i18n.btnEditGlobal + '</button>' +
'</div>' +
'</div>';
}).join('');
container.innerHTML = html;
container.querySelectorAll('.adapter-toggle').forEach(function(el) {
el.addEventListener('click', function() {
var adapterId = el.dataset.adapterId;
var isEnabled = !el.classList.contains('off');
vscode.postMessage({
type: 'toggleAdapter',
adapterId: adapterId,
enabled: !isEnabled,
});
});
});
container.querySelectorAll('.adapter-btn').forEach(function(el) {
el.addEventListener('click', function() {
var action = el.dataset.action;
var adapterId = el.dataset.adapterId;
var settingsTarget = el.dataset.settingsTarget;
if (action === 'openAdapterConfig') {
vscode.postMessage({ type: 'openAdapterConfig', adapterId: adapterId });
} else if (action === 'openSettings') {
vscode.postMessage({ type: 'openSettings', settingsTarget: settingsTarget });
}
});
});
var enabledCount = adapterStatus.filter(function(a) { return a.enabled; }).length;
var badge = document.getElementById('adapterCountBadge');
if (badge) { badge.textContent = enabledCount + '/4'; }
}
var tab = document.getElementById('tabCommonRules');
if (tab) {
tab.addEventListener('click', toggleCommonRules);
}
var customTab = document.getElementById('tabCustomRules');
if (customTab) {
customTab.addEventListener('click', toggleCustomRules);
}
var aiTab = document.getElementById('tabAIReview');
if (aiTab) {
aiTab.addEventListener('click', toggleAIReview);
}
vscode.postMessage({ type: 'ready' });
window.postMsg = postMsg;
+580 -72
View File
@@ -1,9 +1,10 @@
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { execSync } from 'child_process';
import { getAIProvider, getAIModel, getAIOutputLanguage, getAIConfig } from '../config/ai';
import { getApiKey, setApiKey } from '../config/secret';
import { createProvider, getAllProviderMeta, getProviderModels } from '../ai/factory';
import { createProvider, getAllProviderMeta, getProviderModels, invalidateProviderCache } from '../ai/factory';
import { listRuleFiles } from '../rules/yaml-parser';
import { ImportService } from '../rules/import-service';
import { showImportPreview } from '../rules/import-preview';
@@ -14,6 +15,128 @@ import { ExcelConverter } from '../rules/converters/excel-converter';
import { DocxConverter } from '../rules/converters/docx-converter';
import { PptxConverter } from '../rules/converters/pptx-converter';
import { t, onLanguageChange } from '../i18n/messages';
import { getEslintConfigPath, getStylelintConfigPath, getPMDRulesetPath, getSqlLintConfigFile, isAdapterEnabled, setAdapterEnabled } from '../config/linter';
type ConfigMode = 'builtin' | 'project' | 'global';
type DependencyStatus = 'ready' | 'missing' | 'none';
interface AdapterConfigStatus {
id: string;
name: string;
enabled: boolean;
configMode: ConfigMode;
dependencyStatus: DependencyStatus;
dependencyLabel?: string;
configured: boolean;
guideText: string;
languages: string;
projectConfigFileName: string;
settingsTarget: string;
}
function getPmdRulesetTemplate(): string {
return `<?xml version="1.0" encoding="UTF-8"?>
<ruleset xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
<description>Custom PMD Ruleset</description>
<!-- ${t('setup.template.pmdBestPractices')} -->
<rule ref="category/java/bestpractices.xml" />
<!-- ${t('setup.template.pmdCodeStyle')} -->
<rule ref="category/java/codestyle.xml" />
</ruleset>`;
}
function getSqlfluffTemplate(): string {
return `[sqlfluff]
# ${t('setup.template.sqlfluffDialect')}
dialect = postgres
# ${t('setup.template.sqlfluffRules')}
rules = all`;
}
function getEslintTemplate(): string {
return `module.exports = {
root: true,
env: { node: true, es2022: true },
parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
rules: {
'no-unused-vars': 'warn', // ${t('setup.template.eslintComment1')}
'no-console': 'off', // ${t('setup.template.eslintComment2')}
'semi': ['error', 'always'], // ${t('setup.template.eslintComment3')}
},
};`;
}
function getStylelintTemplate(): string {
return `module.exports = {
extends: 'stylelint-config-standard',
rules: {
'indentation': 2, // ${t('setup.template.stylelintComment1')}
'no-empty': true, // ${t('setup.template.stylelintComment2')}
},
};`;
}
const ADAPTER_METADATA: Record<string, {
name: string;
projectConfigFileName: string;
settingsTarget: string;
hasExternalDependency: boolean;
dependencyLabel?: string;
configFileTemplate: () => string;
i18nKey: string;
}> = {
pmd: {
name: 'PMD',
projectConfigFileName: 'ruleset.xml',
settingsTarget: 'vscode-code-reviewer.pmd',
hasExternalDependency: true,
dependencyLabel: 'Java',
configFileTemplate: getPmdRulesetTemplate,
i18nKey: 'pmd',
},
'sql-lint': {
name: 'SQL-Lint',
projectConfigFileName: '.sqlfluff',
settingsTarget: 'vscode-code-reviewer.sql-lint',
hasExternalDependency: true,
dependencyLabel: 'Python + sqlfluff',
configFileTemplate: getSqlfluffTemplate,
i18nKey: 'sql',
},
eslint: {
name: 'ESLint',
projectConfigFileName: '.eslintrc.js',
settingsTarget: 'vscode-code-reviewer.linters',
hasExternalDependency: false,
configFileTemplate: getEslintTemplate,
i18nKey: 'eslint',
},
stylelint: {
name: 'Stylelint',
projectConfigFileName: '.stylelintrc.js',
settingsTarget: 'vscode-code-reviewer.linters',
hasExternalDependency: false,
configFileTemplate: getStylelintTemplate,
i18nKey: 'stylelint',
},
};
const PROJECT_CONFIG_FILES: Record<string, string[]> = {
eslint: ['.eslintrc.js', '.eslintrc.json', '.eslintrc.yaml', '.eslintrc.yml', '.eslintrc', 'eslint.config.js', 'eslint.config.mjs'],
stylelint: ['.stylelintrc.js', '.stylelintrc.json', '.stylelintrc.yaml', '.stylelintrc.yml', '.stylelintrc', 'stylelint.config.js'],
pmd: ['ruleset.xml'],
'sql-lint': ['.sqlfluff'],
};
const GLOBAL_CONFIG_GETTERS: Record<string, () => string> = {
eslint: getEslintConfigPath,
stylelint: getStylelintConfigPath,
pmd: getPMDRulesetPath,
'sql-lint': getSqlLintConfigFile,
};
export class SetupViewProvider implements vscode.WebviewViewProvider {
private _view?: vscode.WebviewView;
@@ -25,6 +148,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
private _scriptUri: vscode.Uri | null = null;
constructor(private context: vscode.ExtensionContext) {
this.restoreConnectionState();
this.importService.registerConverter(new YamlConverter());
this.importService.registerConverter(new MdConverter());
this.importService.registerConverter(new TxtConverter());
@@ -33,6 +157,36 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
this.importService.registerConverter(new PptxConverter());
}
private async getConfigFingerprint(): Promise<string> {
const cfg = getAIConfig();
const hasApiKey = await isApiKeyConfigured(this.context);
const hasBaseUrl = isBaseUrlConfigured();
return `${cfg.provider}|${cfg.model}|${hasApiKey}|${hasBaseUrl}`;
}
private restoreConnectionState(): void {
const saved = this.context.globalState.get<{ tested: boolean; success: boolean; fingerprint: string }>('connectionState');
if (saved) {
this.connectionTested = saved.tested;
this.connectionSuccess = saved.success;
}
}
private async saveConnectionState(): Promise<void> {
const fingerprint = await this.getConfigFingerprint();
await this.context.globalState.update('connectionState', {
tested: this.connectionTested,
success: this.connectionSuccess,
fingerprint,
});
}
private async clearConnectionState(): Promise<void> {
this.connectionTested = false;
this.connectionSuccess = false;
await this.context.globalState.update('connectionState', undefined);
}
resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
@@ -46,7 +200,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
};
const config = getAIConfig();
const providers = getAllProviderMeta();
const providers = getAllProviderMeta(this.context.extensionUri);
const scriptUri = webviewView.webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview', 'setupView.js')
);
@@ -54,6 +208,13 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
this._providers = providers;
this._config = aiConfig;
this._scriptUri = scriptUri;
this.getConfigFingerprint().then(fingerprint => {
const saved = this.context.globalState.get<{ fingerprint: string }>('connectionState');
if (saved && saved.fingerprint !== fingerprint) {
this.clearConnectionState();
this.pushConfig();
}
});
webviewView.webview.html = this.getHtml(providers, aiConfig, scriptUri);
const langDisposable = onLanguageChange(() => {
@@ -75,25 +236,29 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
break;
case 'setApiKey':
await setApiKey(this.context, msg.value);
await this.clearConnectionState();
await this.pushConfig();
break;
case 'setProvider': {
const cfg = vscode.workspace.getConfiguration('vscode-code-reviewer');
await cfg.update('ai.provider', msg.value, vscode.ConfigurationTarget.Global);
const models = getProviderModels(msg.value);
const models = getProviderModels(this.context.extensionUri, msg.value);
if (models.length > 0) {
await cfg.update('ai.model', models[0], vscode.ConfigurationTarget.Global);
}
await this.clearConnectionState();
await this.pushConfig();
break;
}
case 'setModel':
await vscode.workspace.getConfiguration('vscode-code-reviewer').update('ai.model', msg.value, vscode.ConfigurationTarget.Global);
await this.clearConnectionState();
await this.pushConfig();
break;
case 'setBaseUrl':
await vscode.workspace.getConfiguration('vscode-code-reviewer')
.update('ai.baseUrl', msg.value || undefined, vscode.ConfigurationTarget.Global);
await this.clearConnectionState();
await this.pushConfig();
break;
case 'setLanguage':
@@ -115,8 +280,51 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
await this.resetConfig();
await this.pushConfig();
break;
case 'openAdapterConfig':
await this.handleAdapterConfig(msg.adapterId);
await this.pushConfig();
break;
case 'openSettings':
await vscode.commands.executeCommand(
'workbench.action.openSettings',
msg.settingsTarget
);
break;
case 'toggleAdapter':
await setAdapterEnabled(msg.adapterId, msg.enabled);
break;
}
});
const configChangeDisposable = vscode.workspace.onDidChangeConfiguration((e) => {
if (
e.affectsConfiguration('vscode-code-reviewer.linter') ||
e.affectsConfiguration('vscode-code-reviewer.linters') ||
e.affectsConfiguration('vscode-code-reviewer.pmd') ||
e.affectsConfiguration('vscode-code-reviewer.sql-lint')
) {
this.pushConfig();
}
});
const watcher = vscode.workspace.createFileSystemWatcher(
'**/.code-review/providers.json'
);
watcher.onDidChange(() => {
invalidateProviderCache();
this.pushConfig();
});
watcher.onDidCreate(() => {
invalidateProviderCache();
this.pushConfig();
});
webviewView.onDidDispose(() => {
configChangeDisposable.dispose();
watcher.dispose();
});
}
private async pushConfig(): Promise<void> {
@@ -139,10 +347,33 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
language: config.outputLanguage,
apiKeyConfigured,
},
providers: getAllProviderMeta(),
providers: getAllProviderMeta(this.context.extensionUri),
ruleFiles,
connectionTested: this.connectionTested,
connectionSuccess: this.connectionSuccess,
adapterStatus: this.collectAdapterStatus(),
i18n: {
configured: t('setup.configured'),
notConfigured: t('setup.notConfigured'),
connected: t('setup.connected'),
notConnected: t('setup.notConnected'),
retry: t('setup.retry'),
saveTest: t('setup.saveAndTest'),
ruleCountFormat: t('setup.ruleCountFormat'),
noRuleFiles: t('setup.noRuleFiles'),
modeBuiltin: t('setup.adapter.modeBuiltin'),
modeProject: t('setup.adapter.modeProject'),
modeGlobal: t('setup.adapter.modeGlobal'),
configYes: t('setup.adapter.configYes'),
configNo: t('setup.adapter.configNo'),
langLabel: t('setup.adapter.langLabel'),
btnCreateConfig: t('setup.adapter.btnCreateConfig'),
btnEditGlobal: t('setup.adapter.btnEditGlobal'),
tooltipCreate: t('setup.adapter.tooltipCreate'),
tooltipEdit: t('setup.adapter.tooltipEdit'),
toggleEnable: t('setup.adapter.toggleEnable'),
toggleDisable: t('setup.adapter.toggleDisable'),
},
});
}
@@ -161,7 +392,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
const config = getAIConfig();
try {
const provider = createProvider(config.provider, apiKey, config.baseUrl);
const provider = createProvider(config.provider, apiKey, config.baseUrl, this.context.extensionUri);
await provider.chat('回复 ok', 'ping', {
model: config.model,
temperature: 0,
@@ -170,10 +401,12 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
});
this.connectionTested = true;
this.connectionSuccess = true;
await this.saveConnectionState();
this._view?.webview.postMessage({ type: 'testResult', success: true, message: t('setup.testSuccess') });
} catch (err) {
this.connectionTested = true;
this.connectionSuccess = false;
await this.saveConnectionState();
const message = err instanceof Error ? err.message : String(err);
this._view?.webview.postMessage({ type: 'testResult', success: false, message: t('setup.testFail', { 0: message }) });
}
@@ -253,8 +486,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
await config.update('ai.model', undefined, vscode.ConfigurationTarget.Global);
await config.update('ai.outputLanguage', undefined, vscode.ConfigurationTarget.Global);
await this.deleteApiKey();
this.connectionTested = false;
this.connectionSuccess = false;
await this.clearConnectionState();
}
private async deleteApiKey(): Promise<void> {
@@ -290,6 +522,13 @@ body {
padding: 10px 0 14px; font-size: 15px; font-weight: 600; color: var(--vscode-foreground);
border-bottom: 1px solid var(--vscode-panel-border); margin-bottom: 12px;
}
.panel-header-title {
display: flex; align-items: center; gap: 8px; flex: 1;
}
.panel-header .lang-select {
width: auto; min-width: 100px; padding: 2px 24px 2px 8px;
font-size: 11px; min-height: 24px; flex-shrink: 0;
}
/* Section */
.section { margin-bottom: 16px; }
@@ -472,17 +711,151 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
border-top-color: #fff; border-radius: 50%;
animation: spin .6s linear infinite;
}
/* Adapter cards */
.adapter-card {
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
border: 1px solid var(--vscode-panel-border);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
transition: opacity 0.2s;
}
.adapter-card.disabled { opacity: 0.45; }
.adapter-card-header {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.adapter-card-name { font-size: 12px; font-weight: 600; color: var(--vscode-foreground); }
.adapter-toggle {
width: 30px; height: 16px; border-radius: 8px;
background: #3fb950; position: relative; cursor: pointer;
transition: background 0.2s;
}
.adapter-toggle.off { background: #3c3c3c; }
.adapter-toggle::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 12px; height: 12px; border-radius: 50%; background: #fff;
transition: transform 0.2s;
transform: translateX(14px);
}
.adapter-toggle.off::after { transform: translateX(0); background: #ccc; }
.adapter-badges { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 5px; }
.adapter-badge {
display: inline-flex; align-items: center; padding: 1px 5px;
border-radius: 3px; font-size: 9px; font-weight: 600;
}
.adapter-badge-info { background: rgba(139,92,246,0.15); color: #8b5cf6; }
.adapter-badge-ok { background: rgba(63,185,80,0.15); color: #3fb950; }
.adapter-badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
.adapter-badge-error { background: rgba(248,81,73,0.15); color: #f48771; }
.adapter-languages { font-size: 10px; color: var(--vscode-descriptionForeground); margin-bottom: 4px; }
.adapter-lang-label { color: var(--vscode-descriptionForeground); }
.adapter-guide { font-size: 10px; color: var(--vscode-descriptionForeground); line-height: 1.4; margin-bottom: 7px; }
.adapter-actions { display: flex; gap: 5px; }
.adapter-btn {
padding: 2px 8px; border-radius: 3px; font-size: 10px;
border: 1px solid var(--vscode-panel-border); background: transparent;
color: var(--vscode-foreground); cursor: pointer;
}
.adapter-btn:hover { background: var(--vscode-panel-border); }
/* Clickable engine tab */
.engine-tab-top {
display: flex; justify-content: space-between; align-items: flex-start; width: 100%;
}
.engine-tab.clickable { cursor: pointer; user-select: none; position: relative; }
.engine-tab.clickable:hover { border-color: var(--vscode-focusBorder); }
[data-tooltip] { position: relative; }
[data-tooltip]:hover::before {
content: attr(data-tooltip);
position: absolute; bottom: calc(100% + 14px); left: 50%;
transform: translateX(-50%);
background: var(--vscode-editorWidget-background, var(--vscode-editor-background));
color: var(--vscode-foreground);
border: 1px solid var(--vscode-widget-border, var(--vscode-panel-border));
border-radius: 4px; padding: 4px 8px;
font-size: 12px; white-space: nowrap;
z-index: 200; pointer-events: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.adapter-toggle[data-tooltip]:hover::before {
top: 50%; right: calc(100% + 8px);
bottom: auto; left: auto; transform: translateY(-50%);
}
.adapter-btn[data-action="openAdapterConfig"][data-tooltip]:hover::before {
top: 50%; left: calc(100% + 8px);
bottom: auto; right: auto; transform: translateY(-50%);
}
.engine-indicator {
font-size: 9px; color: var(--vscode-descriptionForeground);
transition: transform .15s; flex-shrink: 0;
}
.engine-indicator.expanded { transform: rotate(90deg); }
.engine-badge {
display: inline-flex; align-items: center;
padding: 1px 6px; border-radius: 8px;
background: rgba(139,92,246,0.15); color: #8b5cf6;
font-size: 10px; font-weight: 600;
white-space: nowrap; flex-shrink: 0;
}
.engine-tab-footer {
display: flex; align-items: center; gap: 6px;
justify-content: flex-end; width: 100%; margin-top: 6px;
}
.engine-common-rules-body {
margin-top: 8px; padding: 0 2px;
}
.engine-custom-rules-body,
.engine-ai-review-body {
margin-top: 8px;
padding: 12px;
border: 1px solid var(--vscode-panel-border);
border-radius: 6px;
}
.ai-review-status-grid {
display: flex; flex-direction: column; gap: 4px;
margin-bottom: 10px;
}
.status-row {
display: flex; align-items: center; justify-content: space-between;
font-size: 12px;
}
.status-label { color: var(--vscode-descriptionForeground); }
.status-value { color: var(--vscode-foreground); font-weight: 500; }
.engine-subtitle {
font-size: 11px; font-weight: 600;
color: var(--vscode-descriptionForeground); margin-bottom: 8px;
}
.mode-legend {
display: flex; flex-wrap: wrap; gap: 6px 12px;
margin-bottom: 10px; padding: 6px 8px;
background: var(--vscode-sideBar-background, var(--vscode-editor-background));
border: 1px solid var(--vscode-panel-border); border-radius: 6px;
font-size: 10px; color: var(--vscode-descriptionForeground);
}
.mode-legend-item { display: inline-flex; align-items: center; gap: 4px; }
.mode-legend-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.mode-legend-desc { width: 100%; font-size: 10px; opacity: 0.75; }
</style>
</head>
<body>
<div class="panel">
<div class="panel-header">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
${t('setup.header')}
<div class="panel-header-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
${t('setup.header')}
</div>
<select class="lang-select" id="languageSelect" onchange="postMsg('setLanguage', this.value)">
<option value="zh-CN"${config.outputLanguage === 'zh-CN' ? ' selected' : ''}></option>
<option value="en"${config.outputLanguage === 'en' ? ' selected' : ''}>English</option>
<option value="ja"${config.outputLanguage === 'ja' ? ' selected' : ''}></option>
</select>
</div>
<!-- 1. 快速开始 -->
@@ -513,37 +886,9 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
</div>
</div>
<!-- 2. 审核引擎 -->
<!-- 2. AI 连接配置 -->
<div class="section">
<div class="section-title">${t('setup.engineSection')}</div>
<div class="engines">
<div class="engine-tab">
<span class="engine-dot dot-purple"></span>
<div>
<div class="engine-label">${t('setup.commonRules')}</div>
<div class="engine-desc">${t('setup.linterStatic')}</div>
</div>
</div>
<div class="engine-tab">
<span class="engine-dot dot-amber"></span>
<div>
<div class="engine-label">${t('setup.customRules')}</div>
<div class="engine-desc">${t('setup.teamCoding')}</div>
</div>
</div>
<div class="engine-tab">
<span class="engine-dot dot-green"></span>
<div>
<div class="engine-label">${t('setup.aiReview')}</div>
<div class="engine-desc">${t('setup.deepReview')}</div>
</div>
</div>
</div>
</div>
<!-- 3. AI 模型配置 -->
<div class="section">
<div class="section-title">${t('setup.aiConfig')}</div>
<div class="section-title">${t('setup.aiConnectionConfig')}</div>
<div class="card">
<div class="card-row">
<span class="card-label">${t('setup.provider')}</span>
@@ -556,18 +901,13 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
</div>
<div class="field">
<label class="field-label">${t('setup.model')}</label>
<select id="modelSelect">${(providers[config.provider]?.models ?? []).map(m =>
`<option value="${m}"${config.model === m ? ' selected' : ''}>${m}</option>`
).join('\n ')}</select>
<input type="text" id="modelInput"
value="${config.model || ''}"
placeholder="${t('setup.modelPlaceholder')}"
onchange="postMsg('setModel', this.value)">
</div>
<div class="field-hint">${t('setup.modelHint')}</div>
</div>
</div>
<!-- 4. API Key -->
<div class="section">
<div class="section-title">API Key</div>
<div class="card">
<div style="border-top:1px solid var(--vscode-panel-border);margin:10px -12px;"></div>
<div class="card-row">
<span class="card-label">${t('setup.apiKey')}</span>
<span class="badge" id="apiKeyBadge">${t('setup.notConfigured')}</span>
@@ -583,28 +923,65 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
</div>
</div>
<!-- 5. 输出语言 -->
<!-- 3. 审核引擎 -->
<div class="section">
<div class="section-title">${t('setup.outputLang')}</div>
<div class="field">
<label class="field-label">${t('setup.outputLangHint')}</label>
<select id="languageSelect" onchange="postMsg('setLanguage', this.value)">
<option value="zh-CN"${config.outputLanguage === 'zh-CN' ? ' selected' : ''}></option>
<option value="en"${config.outputLanguage === 'en' ? ' selected' : ''}>English</option>
<option value="ja"${config.outputLanguage === 'ja' ? ' selected' : ''}></option>
</select>
</div>
</div>
<!-- 6. 自定义规则 -->
<div class="section">
<div class="section-title">${t('setup.customRulesSection')}</div>
<div class="card">
<div class="card-row">
<span class="card-label">${t('setup.ruleList')}</span>
<span class="badge" style="background:rgba(139,148,158,0.12);color:#8b949e;" id="ruleCountBadge">${t('setup.ruleCount', { 0: '0' })}</span>
<div class="section-title">${t('setup.engineSection')}</div>
<div class="engines">
<div class="engine-tab clickable" id="tabCommonRules" data-tooltip="${t('setup.adapter.tooltipTab')}">
<div class="engine-tab-top">
<span class="engine-dot dot-purple"></span>
<span class="engine-badge" id="adapterCountBadge">0/4</span>
</div>
<div>
<div class="engine-label">${t('setup.commonRules')}</div>
<div class="engine-desc">${t('setup.linterStatic')}</div>
</div>
<div class="engine-tab-footer">
<span class="engine-indicator" id="commonRulesArrow"></span>
</div>
</div>
<div id="ruleList"></div>
<div class="engine-tab clickable" id="tabCustomRules" data-tooltip="${t('setup.adapter.tooltipTab')}">
<div class="engine-tab-top">
<span class="engine-dot dot-amber"></span>
<span class="engine-badge" id="customRuleCountBadge" style="background:rgba(210,153,34,0.15);color:#d29922;">0</span>
</div>
<div>
<div class="engine-label">${t('setup.customRules')}</div>
<div class="engine-desc">${t('setup.teamCoding')}</div>
</div>
<div class="engine-tab-footer">
<span class="engine-indicator" id="customRulesArrow"></span>
</div>
</div>
<div class="engine-tab clickable" id="tabAIReview" data-tooltip="${t('setup.adapter.tooltipTab')}">
<div class="engine-tab-top">
<span class="engine-dot dot-green"></span>
<span class="engine-badge" id="aiReviewStatusBadge" style="background:rgba(139,148,158,0.12);color:var(--vscode-descriptionForeground);">${t('setup.notConfigured')}</span>
</div>
<div>
<div class="engine-label">${t('setup.aiReview')}</div>
<div class="engine-desc">${t('setup.deepReview')}</div>
</div>
<div class="engine-tab-footer">
<span class="engine-indicator" id="aiReviewArrow"></span>
</div>
</div>
</div>
<div class="engine-common-rules-body" id="commonRulesBody" style="display:none;">
<div class="engine-subtitle">${t('setup.adapter.subtitle')}</div>
<div class="mode-legend">
<span class="mode-legend-item"><span class="mode-legend-dot" style="background:#8b5cf6;"></span>${t('setup.adapter.modeBuiltin')}</span>
<span class="mode-legend-item"><span class="mode-legend-dot" style="background:#3fb950;"></span>${t('setup.adapter.modeProject')}</span>
<span class="mode-legend-item"><span class="mode-legend-dot" style="background:#d29922;"></span>${t('setup.adapter.modeGlobal')}</span>
<span class="mode-legend-desc">${t('setup.adapter.modeLegend')}</span>
</div>
<div id="adapter-list"></div>
</div>
<div class="engine-custom-rules-body" id="customRulesBody" style="display:none;">
<div class="engine-subtitle">${t('setup.ruleList')}</div>
<div id="ruleListInEngine"></div>
<div class="field" style="margin-top:8px;">
<div class="input-group">
<input type="text" id="newRuleInput" placeholder="${t('setup.ruleNamePlaceholder')}">
@@ -614,6 +991,27 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
<div class="field-hint">${t('setup.ruleNameHint')}</div>
</div>
</div>
<div class="engine-ai-review-body" id="aiReviewBody" style="display:none;">
<div class="ai-review-status-grid">
<div class="status-row">
<span class="status-label">${t('setup.provider')}</span>
<span class="status-value" id="aiProviderDisplay"></span>
</div>
<div class="status-row">
<span class="status-label">${t('setup.model')}</span>
<span class="status-value" id="aiModelDisplay"></span>
</div>
<div class="status-row">
<span class="status-label"></span>
<span class="status-value" id="aiConnectionDisplay"></span>
</div>
</div>
<div class="status-row">
<span class="status-label">${t('setup.aiReviewStatusCapability')}</span>
<span class="status-value">${t('setup.aiReviewCapability')}</span>
</div>
</div>
</div>
<!-- Actions -->
@@ -636,6 +1034,116 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
</body>
</html>`;
}
private detectConfigMode(adapterId: string): ConfigMode {
const globalPath = GLOBAL_CONFIG_GETTERS[adapterId]?.();
if (globalPath && globalPath.trim() !== '') {
return 'global';
}
const workspaceFolders = vscode.workspace.workspaceFolders;
if (workspaceFolders && workspaceFolders.length > 0) {
const rootPath = workspaceFolders[0].uri.fsPath;
const configFiles = PROJECT_CONFIG_FILES[adapterId] ?? [];
for (const fileName of configFiles) {
const filePath = path.join(rootPath, fileName);
if (fs.existsSync(filePath)) {
return 'project';
}
}
}
return 'builtin';
}
private checkJavaReady(): boolean {
try {
const result = execSync('java -version 2>&1', {
encoding: 'utf-8',
timeout: 5000,
});
return result.includes('version');
} catch {
return false;
}
}
private checkPythonReady(): boolean {
let pythonCmd = '';
for (const cmd of ['python3', 'python']) {
try {
execSync(`${cmd} --version`, { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
pythonCmd = cmd;
break;
} catch { continue; }
}
if (!pythonCmd) { return false; }
try {
execSync('sqlfluff --version', { encoding: 'utf-8', timeout: 5000, stdio: 'pipe' });
return true;
} catch {
return false;
}
}
private collectAdapterStatus(): AdapterConfigStatus[] {
const statuses: AdapterConfigStatus[] = [];
for (const [id, meta] of Object.entries(ADAPTER_METADATA)) {
const configMode = this.detectConfigMode(id);
const enabled = isAdapterEnabled(id);
let dependencyStatus: DependencyStatus = 'none';
if (meta.hasExternalDependency) {
if (id === 'pmd') {
dependencyStatus = this.checkJavaReady() ? 'ready' : 'missing';
} else if (id === 'sql-lint') {
dependencyStatus = this.checkPythonReady() ? 'ready' : 'missing';
}
}
const configured = !meta.hasExternalDependency || configMode !== 'builtin' || dependencyStatus === 'ready';
statuses.push({
id,
name: meta.name,
enabled,
configMode,
dependencyStatus,
dependencyLabel: meta.dependencyLabel,
configured,
guideText: t(`setup.adapter.${meta.i18nKey}Guide`),
languages: t(`setup.adapter.${meta.i18nKey}Languages`),
projectConfigFileName: meta.projectConfigFileName,
settingsTarget: meta.settingsTarget,
});
}
return statuses;
}
private async handleAdapterConfig(adapterId: string): Promise<void> {
const meta = ADAPTER_METADATA[adapterId];
if (!meta) { return; }
const workspaceFolders = vscode.workspace.workspaceFolders;
if (!workspaceFolders || workspaceFolders.length === 0) {
vscode.window.showWarningMessage('请先打开一个工作区文件夹');
return;
}
const rootPath = workspaceFolders[0].uri.fsPath;
const filePath = path.join(rootPath, meta.projectConfigFileName);
if (!fs.existsSync(filePath)) {
fs.writeFileSync(filePath, meta.configFileTemplate(), 'utf-8');
vscode.window.showInformationMessage(`配置文件已创建: ${meta.projectConfigFileName}`);
}
const doc = await vscode.workspace.openTextDocument(filePath);
await vscode.window.showTextDocument(doc);
}
}
async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise<boolean> {