diff --git a/.superpowers/brainstorm/46388-20260720204105/state/server-stopped b/.superpowers/brainstorm/46388-20260720204105/state/server-stopped new file mode 100644 index 0000000..3f66b1c --- /dev/null +++ b/.superpowers/brainstorm/46388-20260720204105/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1784553065353} diff --git a/.superpowers/brainstorm/46388-20260720204105/state/server.err b/.superpowers/brainstorm/46388-20260720204105/state/server.err new file mode 100644 index 0000000..e69de29 diff --git a/.superpowers/brainstorm/session-32496-20260720204127/state/server-stopped b/.superpowers/brainstorm/session-32496-20260720204127/state/server-stopped new file mode 100644 index 0000000..d941efd --- /dev/null +++ b/.superpowers/brainstorm/session-32496-20260720204127/state/server-stopped @@ -0,0 +1 @@ +{"reason":"owner process exited","timestamp":1784551347138} diff --git a/.superpowers/brainstorm/session-32496-20260720204127/state/server.err b/.superpowers/brainstorm/session-32496-20260720204127/state/server.err new file mode 100644 index 0000000..e69de29 diff --git a/.vscodeignore b/.vscodeignore index 5679d79..4d8b611 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -17,3 +17,4 @@ docs/** scripts/** **/*.d.ts .opencode/** +tech-support-flowchart/** diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 9aa9e66..eb22ca0 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -108,15 +108,33 @@ | 2026-07-25 22:57 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 规则预览编辑:规则卡片折叠/展开编辑表单(severity下拉框、description/message多行文本、languages/excludeLanguages标签输入、id只读);renderRulesToYaml 从 rules 数组生成 YAML;无编辑时回退原始逻辑;补充5个编辑场景测试 | import-preview.ts 初版尝试 extension 端实时追踪 modifiedRules(updateRule 消息),后改为 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 个 converter(md/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缺/非法→warning,id缺→rule-N,desc+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 结构组织多语言文本 | 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 i18n:ADAPTER_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_MAP;engine.ts / import-service.ts / setupView.ts 透传 extensionUri;模型下拉改为 input+datalist;setupView.ts 新增 FileSystemWatcher;setupView.js 改为填充 datalist;package.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 | diff --git a/docs/rules/team-rules-2sheet.xlsx b/docs/rules/team-rules-2sheet.xlsx new file mode 100644 index 0000000..8664d0e Binary files /dev/null and b/docs/rules/team-rules-2sheet.xlsx differ diff --git a/docs/rules/team-rules-en.xlsx b/docs/rules/team-rules-en.xlsx new file mode 100644 index 0000000..ea90686 Binary files /dev/null and b/docs/rules/team-rules-en.xlsx differ diff --git a/docs/rules/team-rules-ja.xlsx b/docs/rules/team-rules-ja.xlsx new file mode 100644 index 0000000..334483c Binary files /dev/null and b/docs/rules/team-rules-ja.xlsx differ diff --git a/docs/rules/team-rules.xlsx b/docs/rules/team-rules.xlsx new file mode 100644 index 0000000..d29a836 Binary files /dev/null and b/docs/rules/team-rules.xlsx differ diff --git a/docs/superpowers/specs/2026-07-21-converter-architecture-design.md b/docs/superpowers/specs/2026-07-21-converter-architecture-design.md new file mode 100644 index 0000000..41f19df --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-converter-architecture-design.md @@ -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; +} +``` + +- `supportedExtensions`:声明支持的扩展名列表(如 `['.yaml', '.yml']`) +- `convert()`:源文件 → 写 YAML 到 `yamlPath`,返回是否成功;失败时内部可弹错误提示 +- 沿用 `LinterAdapter` 风格的接口约定 + +### 3.2 ImportService + +```typescript +// src/rules/import-service.ts + +class ImportService { + private converters: Map = new Map(); + + registerConverter(converter: RuleConverter): void; + convert(srcPath: string, yamlPath: string, context: vscode.ExtensionContext): Promise; +} +``` + +- `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 基础设施不变 diff --git a/docs/superpowers/specs/2026-07-21-excel-ai-converter-design.md b/docs/superpowers/specs/2026-07-21-excel-ai-converter-design.md new file mode 100644 index 0000000..ac22574 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-excel-ai-converter-design.md @@ -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:不受影响 diff --git a/docs/superpowers/specs/2026-07-23-rule-prefilter-design.md b/docs/superpowers/specs/2026-07-23-rule-prefilter-design.md new file mode 100644 index 0000000..fe87f6d --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-rule-prefilter-design.md @@ -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..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 = { + typescriptreact: ['typescript', 'typescriptreact', 'tsx'], + javascriptreact: ['javascript', 'javascriptreact', 'jsx'], +}; + +const LANGUAGE_GROUPS: Record = { + 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/excel(AI 转换路径) | 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.0,6 个 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; + 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 = 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 { + 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 { + return new Promise((resolve) => { + const panel = vscode.window.createWebviewPanel( + 'ruleImportPreview', + '规则导入预览', + vscode.ViewColumn.Active, + { enableScripts: true }, + ); + + const keepRule: Record = {}; + 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 { + 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 加语言约束 prompt,Part 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=5,filteredOut=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 升级时重新提炼 | 版本随插件发布绑定,不会在用户侧变化 | diff --git a/docs/superpowers/specs/2026-07-24-custom-rule-dedup-enhancement-design.md b/docs/superpowers/specs/2026-07-24-custom-rule-dedup-enhancement-design.md new file mode 100644 index 0000000..81b3545 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-custom-rule-dedup-enhancement-design.md @@ -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; +} +``` + +### 3.3 Converters(5 个文件,模式一致) + +每个 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 { + 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 { + 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 重复) \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-24-excel-multi-sheet-design.md b/docs/superpowers/specs/2026-07-24-excel-multi-sheet-design.md new file mode 100644 index 0000000..09fc253 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-excel-multi-sheet-design.md @@ -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:不受影响 \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-24-word-ppt-import-design.md b/docs/superpowers/specs/2026-07-24-word-ppt-import-design.md new file mode 100644 index 0000000..12b8251 --- /dev/null +++ b/docs/superpowers/specs/2026-07-24-word-ppt-import-design.md @@ -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/.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 已产出 Markdown,AI 可识别表格/段落/列表结构 +``` + +### 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 文件 diff --git a/docs/superpowers/specs/2026-07-25-import-preview-edit-design.md b/docs/superpowers/specs/2026-07-25-import-preview-edit-design.md new file mode 100644 index 0000000..d6d6078 --- /dev/null +++ b/docs/superpowers/specs/2026-07-25-import-preview-edit-design.md @@ -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; + 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/, 添加) | 比逗号分隔文本框更直观,防格式错误 | +| 展开方式 | 点击卡片切换展开/折叠 | 简单直接,不增加额外按钮 | +| 保留/注释与编辑 | 共存,互不影响 | 出用户需求:保留/注释状态不影响字段编辑 | diff --git a/docs/superpowers/specs/2026-07-26-custom-rule-import-ux-design.md b/docs/superpowers/specs/2026-07-26-custom-rule-import-ux-design.md new file mode 100644 index 0000000..5f99314 --- /dev/null +++ b/docs/superpowers/specs/2026-07-26-custom-rule-import-ux-design.md @@ -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 显示为只读 ``(`import-preview.ts` 的 `renderRuleCard`)。改为 `` 元素: + +- 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 改可编辑为向后兼容改动(原 `` 改 ``,收集逻辑已支持)。`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 提交 diff --git a/docs/superpowers/specs/2026-07-27-adapter-optimization-design.md b/docs/superpowers/specs/2026-07-27-adapter-optimization-design.md new file mode 100644 index 0000000..28e3742 --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-adapter-optimization-design.md @@ -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 配置、规则文件导入、连接测试、代码审查命令等功能完全不受影响。适配器卡片列表由独立的 `
` 容器渲染,即使渲染函数出错也不影响其他区域。 + +--- + +## 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; + 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(`linters.${language}`, ''); +} + +export function getPmdJarPath(): string { + return vscode.workspace.getConfiguration(ROOT).get('pmd.jarPath', ''); +} + +export function getPmdRulesetPath(): string { + return vscode.workspace.getConfiguration(ROOT).get('pmd.rulesetPath', ''); +} + +export function getPmdJspRulesetPath(): string { + return vscode.workspace.getConfiguration(ROOT).get('pmd.jspRulesetPath', ''); +} + +export function getSqlLintConfigFile(): string { + return vscode.workspace.getConfiguration(ROOT).get('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 = { + 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('linters.eslintConfigPath', ''); +} + +/** 获取 Stylelint 自定义配置路径 */ +export function getStylelintConfigPath(): string { + return vscode.workspace.getConfiguration(ROOT).get('linters.stylelintConfigPath', ''); +} + +/** 获取适配器启用状态 */ +export function isAdapterEnabled(adapterId: string): boolean { + return vscode.workspace.getConfiguration(ROOT).get(`linter.${adapterId}.enabled`, true); +} + +/** 设置适配器启用状态(写入 Global Settings) */ +export async function setAdapterEnabled(adapterId: string, enabled: boolean): Promise { + 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 = { + 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> = { + 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 输出到 stderr,execSync 会因非零退出码抛错 + // 但即使版本信息在 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 + +
+

静态分析适配器

+
+
+``` + +同时在 ` + + +<% + String password = "admin123"; + String name = "test"; + int x = 1; +%> + +

${message} ${message}

+ + +

Welcome, ${user.name}

+
+ + + ${item} + + + + + + hello + + + + + diff --git a/src/test/manual/buggy.sql b/src/test/manual/buggy.sql new file mode 100644 index 0000000..c7b0bea --- /dev/null +++ b/src/test/manual/buggy.sql @@ -0,0 +1,3 @@ +SELECT name FORM users; +SELECT * FORM products; +INSERT INTO customers VALUES (1, 'test'); diff --git a/src/test/merger.test.ts b/src/test/merger.test.ts new file mode 100644 index 0000000..a7880c7 --- /dev/null +++ b/src/test/merger.test.ts @@ -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); + }); +}); diff --git a/src/test/messages.test.ts b/src/test/messages.test.ts new file mode 100644 index 0000000..617957d --- /dev/null +++ b/src/test/messages.test.ts @@ -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'); + }); +}); diff --git a/src/test/pipeline.test.ts b/src/test/pipeline.test.ts new file mode 100644 index 0000000..d5dc7a5 --- /dev/null +++ b/src/test/pipeline.test.ts @@ -0,0 +1,37 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as path from 'path'; +import { ESLintAdapter } from '../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'); + }); +}); diff --git a/src/test/rule-filter.test.ts b/src/test/rule-filter.test.ts new file mode 100644 index 0000000..b468f2d --- /dev/null +++ b/src/test/rule-filter.test.ts @@ -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); + }); +}); diff --git a/src/views/setupView.js b/src/views/setupView.js index f745190..0944418 100644 --- a/src/views/setupView.js +++ b/src/views/setupView.js @@ -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 '
' + '' + escapeHtml(f) + '' + '
'; }).join(''); } else { - countBadge.textContent = '0 个文件'; - countBadge.className = 'badge badge-unconfigured'; - ruleList.innerHTML = '
暂无规则文件
'; + ruleList.innerHTML = '
' + msg.i18n.noRuleFiles + '
'; } _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' + ? '' + a.dependencyLabel + ' ✓' + : '' + a.dependencyLabel + ' ✗'; + var configBadge = a.configured + ? '' + i18n.configYes + '' + : '' + i18n.configNo + ''; + var toggleTooltip = a.enabled + ? i18n.toggleDisable.replace('{0}', a.name) + : i18n.toggleEnable.replace('{0}', a.name); + + return '
' + + '
' + + '' + a.name + '' + + '
' + + '
' + + '
' + + '' + modeBadge.text + '' + + depBadge + + configBadge + + '
' + + '
' + i18n.langLabel + '' + escapeHtml(a.languages) + '
' + + '
' + a.guideText + '
' + + '
' + + '' + + '' + + '
' + + '
'; + }).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; diff --git a/src/views/setupView.ts b/src/views/setupView.ts index 6bdfb88..e0c67fa 100644 --- a/src/views/setupView.ts +++ b/src/views/setupView.ts @@ -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 ` + + Custom PMD 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; + 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 = { + 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> = { + 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 { + 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 { + const fingerprint = await this.getConfigFingerprint(); + await this.context.globalState.update('connectionState', { + tested: this.connectionTested, + success: this.connectionSuccess, + fingerprint, + }); + } + + private async clearConnectionState(): Promise { + 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 { @@ -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 { @@ -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; }
- - - - - ${t('setup.header')} +
+ + + + + ${t('setup.header')} +
+
@@ -513,37 +886,9 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
- +
-
${t('setup.engineSection')}
-
-
- -
-
${t('setup.commonRules')}
-
${t('setup.linterStatic')}
-
-
-
- -
-
${t('setup.customRules')}
-
${t('setup.teamCoding')}
-
-
-
- -
-
${t('setup.aiReview')}
-
${t('setup.deepReview')}
-
-
-
-
- - -
-
${t('setup.aiConfig')}
+
${t('setup.aiConnectionConfig')}
${t('setup.provider')} @@ -556,18 +901,13 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
- +
${t('setup.modelHint')}
-
-
- - -
-
API Key
-
+
${t('setup.apiKey')} ${t('setup.notConfigured')} @@ -583,28 +923,65 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
- +
-
${t('setup.outputLang')}
-
- - -
-
- - -
-
${t('setup.customRulesSection')}
-
-
- ${t('setup.ruleList')} - ${t('setup.ruleCount', { 0: '0' })} +
${t('setup.engineSection')}
+
+
+
+ + 0/4 +
+
+
${t('setup.commonRules')}
+
${t('setup.linterStatic')}
+
+
-
+
+
+ + 0 +
+
+
${t('setup.customRules')}
+
${t('setup.teamCoding')}
+
+ +
+
+
+ + ${t('setup.notConfigured')} +
+
+
${t('setup.aiReview')}
+
${t('setup.deepReview')}
+
+ +
+
+ + + + @@ -636,6 +1034,116 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco `; } + + 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 { + 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 {