diff --git a/DESIGN.md b/DESIGN.md index 8f0fe4a..c918ed4 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -37,7 +37,7 @@ 团队采用自定义方法论 **SDAD(Stage-Driven Agent Development,阶段驱动智能体开发)**,定义于仓库根目录 `AGENTS.md`,以 AI 编码智能体(DeepSeek `deepseek-v4-flash / deepseek-v4-pro`)作为执行主体、人类作为阶段守门人。流程**顺序执行、不可跳过或合并**;②需求澄清为自循环、可能重复多次直至共识,④人类审批为**必经门**。**范式步骤名称即 AI 使用日志「范式步骤」列的取值**,一一对应、可逐条核验: -**① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批(必经门) → ⑤ 编码实现 → ⑥ 审查验证** +**① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证** ```mermaid flowchart TB @@ -61,7 +61,7 @@ flowchart TB S6 -.->|"修改建议 → 回退③"| S3 end - LOG["日志规则(每阶段完成后自动执行)
立即向 _AI_USAGE_LOG.md 追加:日期时间(不可乱填或估算)· 范式步骤 · 修改摘要 · 中间过程 · 涉及文件 · 使用模型;完成记录后方可进入下一步"] + LOG["日志规则(每阶段完成后自动执行)
立即向 _AI_USAGE_LOG.md 追加:日期时间(不可乱填或估算)· 范式步骤 · 修改摘要 · 中间产物 · 涉及文件 · 使用模型;完成记录后方可进入下一步"] LINE -.->|"每阶段完成即记录"| LOG @@ -92,7 +92,7 @@ flowchart TB | ⑤ 编码实现 | 「Phase 1 基础层:类型定义 + 配置模块」「Phase 2.2 ESLint 适配器落地」「Phase 3 编排器」「Phase 4.2 AI 引擎 + Schema:流式逐块解析 + 验证 JSON」「Phase 5.1 命令注册器 + extension.ts 骨架:8 个命令注册」「Phase 6.1 构建脚本」 | `src/` 各模块源码 | deepseek-v4-flash / deepseek-v4-pro | | ⑥ 审查验证 | 「编译/测试(lint & compile & test)」「美化 VSIX:修复 .vscodeignore 体积和 README」「交付检查:打标 VSIX」;回归记录「Bug fix: codeReviewer.openSetup 命令 catch 与用户交互不足」「Bug fix: stylelint 规则列表不存在 → .vscodeignore 忽略 .mjs」「修复界面原型:TreeView 和 Webview 不同步」「面板语言不匹配以及交互卡顿(分组延迟推送)」「AI 引擎重试逻辑优化」 | 通过 lint+test 的构建产物、.vsix 包、回归后的修复代码 | npm / F5 / vsce / deepseek-v4-flash | -> **闭环反馈机制**:① 顺序执行、不可跳过或合并,每个阶段完成后立即向 `_AI_USAGE_LOG.md` 追加一条记录(日期时间 / 范式步骤 / 修改摘要 / 中间过程 / 涉及文件 / 使用模型)方可进入下一步;② 需求澄清为自循环——沿设计树一次一问、重复多次直至共识达成;③ 人类审批为必经门——方案层面问题回退③方案设计、否决回退②需求澄清,未通过不得进入编码;④ 审查验证发现严重代码问题回退⑤编码实现(必要时上溯③方案设计),修改建议回退对应环节修复后回归验证;⑤ 线上缺陷作为新任务回到①用户提出重新走流程——由此构成「提出→澄清→设计→审批→编码→验证」的完整闭环。 +> **闭环反馈机制**:① 顺序执行、不可跳过或合并,每个阶段完成后立即向 `_AI_USAGE_LOG.md` 追加一条记录(日期时间 / 范式步骤 / 修改摘要 / 中间产物 / 涉及文件 / 使用模型)方可进入下一步;② 需求澄清为自循环——沿设计树一次一问、重复多次直至共识达成;③ 人类审批为必经门——方案层面问题回退③方案设计、否决回退②需求澄清,未通过不得进入编码;④ 审查验证发现严重代码问题回退⑤编码实现(必要时上溯③方案设计),修改建议回退对应环节修复后回归验证;⑤ 线上缺陷作为新任务回到①用户提出重新走流程——由此构成「提出→澄清→设计→审批→编码→验证」的完整闭环。 --- @@ -254,7 +254,7 @@ flowchart TB | DeepSeek(`deepseek-v4-flash` / `deepseek-v4-pro`) | AI 编码智能体本体:由人类下达阶段指令,按 SDAD 技能逐步执行需求归纳、方案生成、原型生成、逐文件编码、缺陷修复(日志「使用工具」列全程留痕) | | `AGENTS.md` | 方法论载体:五大铁律、六阶段技能加载规则、常用命令表;每次会话注入给智能体 | | `.opencode/skills/stage-1-propose … stage-6-verify` | 六阶段执行技能:按阶段 `load skill stage-N-*` 切换智能体行为模式 | -| `_AI_USAGE_LOG.md` | 过程留痕账本:时间|范式步骤|工具类型|交互模式|耗时|使用工具,与范式图步骤名一一对应 | +| `_AI_USAGE_LOG.md` | 过程留痕账本:日期时间|范式步骤|修改摘要|中间产物|涉及文件|使用模型,与范式图步骤名一一对应 | | `docs/superpowers/specs/` | 阶段产物库:design spec、implementation plan、20 个逐文件 step 文档、原型 HTML——「严禁提前实现」的依据 | --- diff --git a/README.md b/README.md index 8ed4dd5..8d48ffb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,37 @@ # vscode-code-reviewer · 净码特工 (Code Purifier) +> **项目性质:新规**(从零开发的全新作品) + AI 驱动的代码审查与规范检查一体化 VS Code 插件。集成 ESLint、Stylelint、PMD、SQLFluff 等多种 linter,并支持 AI 辅助审查与自动修复。 +## 项目概述 + +「净码特工 (Code Purifier)」是一款面向企业级多语言代码库的一体化代码审查与规范检查 VS Code 扩展。它将传统静态检查(ESLint、Stylelint、PMD、SQLFluff)与 AI 大模型审查深度融合,覆盖 JavaScript、TypeScript、Java、JSP、HTML、CSS、SQL、PL/SQL 八类语言,提供一键审查、方法级审查、AI 自动修复与修复预览、报告导出、自定义规则导入等能力,并内置 Webview 面板实现"配置—审查—修复—导出"的闭环体验。 + +## 效果总结(核心指标摘要) + +> 待补充:请填写提效/效果类核心指标摘要(新规项目须提供对比数据证明价值),例如:单文件平均审查耗时、AI 修复准确率、缺陷检出率、规则覆盖率等基线 vs 提效后对比。 + +| 指标 | 基线 | 提效后 | 提升幅度 | +|------|------|--------|----------| +| (示例)单文件审查耗时 | 待填 | 待填 | 待填 | +| (示例)AI 修复采纳率 | — | 待填 | 待填 | +| (示例)规则检出覆盖率 | 待填 | 待填 | 待填 | + +## 团队分工 + +> 待补充:请如实填写团队成员及分工。 + +| 成员 | 分工 | +|------|------| +| 待填 | 待填 | + +## 规模与技术难度自我评估 + +- **代码规模**:源码约 65 个 TypeScript 文件、约 1.1 万行,按 17 个模块分层组织(`activation` / `adapters` / `ai` / `config` / `diagnostics` / `fix` / `i18n` / `jsp` / `merger` / `orchestrator` / `panel` / `rules` / `scope` / `services` / `types` / `utils` / `views`)。 +- **技术难度**:(整体偏高)涉及多 linter 统一适配层抽象、多 AI 供应商动态注册(Provider 策略模式 + 运行期注册表)、AI 修复链路(生成—校验—预览—快照撤销)、Office 文档(Word/Excel/PPT)规则导入解析、PMD 的 Java 辅助类路径探测、SQLFluff 方言映射等跨语言、跨进程、跨服务的复杂工程问题。 +- **可运行性**:依赖清单与锁文件(`package.json` / `package-lock.json`)随仓库提供,源码经 `npm install && npm run compile` 即可构建,按 `F5` 启动调试。 + ## 功能特性 - 一键运行代码审查 diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 91b2515..9aaa26f 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -221,3 +221,8 @@ | 2026-08-20 22:20 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 custom/ai 修复不稳定(用户实测「有时能修有时不能」,排除聚焦因素后确认是 AI 链路稳定性问题)。根因三处:①fixPrompt.buildFixSystemPrompt 只要求「无法修复就输出空」,给了 AI 轻易放弃的空间(→ ai-no-fix);②customFixEngine.verifyFixed 用 parsed.fixed === true 严格相等,AI 返回字符串 "true" 永不收敛(→ max-iterations);③收敛失败整体回滚,一次不确定的 AI 重检否定已生成的有效修复。修复(按用户确认的 C1 方案):①prompt 三语加硬性要求「必须输出修复片段,禁止输出空修复;即使无法完全消除也要给缓解/改善的最小片段」;②requestFix 对空修复/解析失败自动重试 1 次(customFixEngine 与 aiFixEngine 同步,前者重试后仍空才 null);③verifyFixed 宽松判定 f===true || String(f)==='true';④C1 收敛降级——循环结束未收敛时若 appliedFixes.length>0 则接受最后一次修复(返回 success+newText 走正常 diff 预览),仅从未生成出可匹配修复才失败;⑤每轮加 console.log('[code-reviewer] review-fix', ruleId, round, {ai-no-fix/ai-match-failed/no-change/applied/verify}) 诊断日志。测试:customFixEngine.test.ts 原 max-iterations 用例改为「accepts last fix when verify never passes」(断言 success=true+newText),新增「retries empty fix once then fails with ai-no-fix」用例(两次空响应)。npm test 111 passing / lint 0 error / compile 通过 | 中间产物:①TS2367 类型不重叠——parsed.fixed 类型 boolean|undefined 与 'true' 字符串比较报错,用 & { fixed?: unknown } 断言 + String(f) 收窄解决;②aiFixEngine 的 ai-no-fix 测试用例只给 1 次空响应,重试后取默认 '{}'(无 originalText)仍返回 ai-no-fix,无需改断言;③C1 实现位置纠结——先想在循环外统一处理,实际把降级分支写在 !converged 内并与 dryRun/apply 复用收尾 | src/fix/fixPrompt.ts src/fix/customFixEngine.ts src/fix/aiFixEngine.ts src/test/customFixEngine.test.ts | deepseek-v4-flash | | 2026-08-20 22:32 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 重写 README.md:补齐运行环境要求(VSCode 1.120+/Node/Java/sqlfluff)、安装步骤(VSIX 安装 + 源码构建)、运行方法(端用户命令表 + 开发者命令表)、前端 Webview 面板启动方式、API 密钥配置(SecretStorage + 配置项表 + 自定义供应商)、依赖清单(运行/开发依赖表)、其他配置项 | 无 | README.md | deepseek-v4-flash | | 2026-08-20 22:33 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 审查验证阶段修正 README:devDependencies 清单中移除不存在的 eslint 条目(eslint 仅存在于 dependencies) | 初版 README 依赖清单误列 eslint 于 dev 表 → 核对 package.json 后移除 | README.md | deepseek-v4-flash | +| 2026-08-22 14:11 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 审核时预生成修复 + 展开问题即显示 diff:types.ts 新增 AiFixSnippet/LinterFix.originalText/LinterDiagnostic.aiFix;schema.ts CustomRuleResult/AIFinding/TranslatedDiagnostic 加可选 fix;engine.ts 三语 prompt(customRuleResults/findings/translatedDiagnostics/方法审查)输出格式加 fix 字段及原文不含行号前缀说明(解析用 ...r 展开自动透传);utils/diff.ts 新建行级 diff(LCS);merger.ts MergeInput 加 code?,静态诊断从 code 按 fix.range 切片补 fix.originalText,customRuleDiagnostics 带 aiFix,翻译 fix 合并进 aiFix;fixPrompt.ts ReviewIssueInput 加 fix;customFixEngine/aiFixEngine 开头预生成片段 indexOf 匹配直接应用(dryRun 返回 newText/否则 applyEdit),匹配失败回退实时 LLM 循环;commands.ts mergeResults 三处传 code、refreshAfterFix 重建时保留 aiFix、findCustomIssue/findAIIssue/fixAll 带 fix、新增 enrichLinterDiagnostic 从 currentReport.linterDiagnostics 补 aiFix(fixIssue/fixAll linter 路径);webview.ts buildIssueItem 增 fixDiffHtml 参数、展开区渲染行级 -/+ diff(.fix-diff/.diff-del/.diff-add CSS)、三列表按 aiFix/fix 计算 diff、suggestion 与 diff 并存;messages.ts 新增 report.fixPreview 三语;spec 文档 docs/superpowers/specs/2026-08-22-pregen-fix-design.md。lint 0 error(仅 mockDocument.ts 2 个既有 curly warning)/ compile 通过 / npm test 111 passing | 方案迭代:初版仅 custom+AI findings 预生成,用户确认扩展含 linter 无 autofix 诊断(translatedDiagnostics 带 fix);merger 初版原地改 fix.originalText 会污染 orchestrator 缓存共享对象,改为 spread 新建副本;fixAll 策略用户采纳「预生成优先+失败回退实时、不做偏移修正」 | docs/superpowers/specs/2026-08-22-pregen-fix-design.md(新建) src/types.ts src/ai/schema.ts src/ai/engine.ts src/utils/diff.ts(新建) src/merger/merger.ts src/fix/fixPrompt.ts src/fix/customFixEngine.ts src/fix/aiFixEngine.ts src/activation/commands.ts src/panel/webview.ts src/i18n/messages.ts | deepseek-v4-flash | +| 2026-08-22 14:50 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 无预生成 fix 的问题展开区显示占位提示(不再空白):webview.ts buildIssueItem 新增 fixPlaceholder——(fixable||aiFixable) 且 fixDiffHtml 为空时渲染「无预生成修复,点击修复将实时生成」提示条(.detail-no-fix 虚线框样式),展开区渲染条件与分支扩展为 detailHtml/suggestion/fixDiffHtml/fixPlaceholder 四选;messages.ts 新增 report.fixUnavailable 三语 key。lint 0 error(仅既有 mockDocument.ts 2 warning)/ compile 通过 / npm test 111 passing | 无 | src/panel/webview.ts src/i18n/messages.ts | deepseek-v4-flash | +| 2026-08-25 19:13 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | diff 预览改为在侧边新建编辑器组打开(不覆盖当前正在查看的文件):fixPreview.ts openPreviewDiff 的 vscode.diff 调用补第 4 参数 { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true },4 个触发点(fixIssue/fixAll × linter/custom-AI)均走此函数全覆盖;closePreviewEditor 遍历所有 tabGroups 与分组位置无关无需改动。lint 0 error(仅既有 mockDocument.ts 2 warning)/ compile 通过 / npm test 111 passing | 澄清阶段「新建编辑器组/侧边标签页」vs「全新 VSCode 窗口实例」两分支,用户选前者(全新窗口扩展 API 不支持且 diff 内容在内存中不可行);preserveFocus 加否为方案决策点,用户确认加(打开 diff 后焦点留在审查面板) | src/fix/fixPreview.ts | deepseek-v4-flash | +| 2026-08-25 21:35 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 sqlfluff 行号显示 LNaN:根因是 sqlfluff 4.x 对「jinja 标签位于注释内」的 JJ01 违规 JSON 缺失 end_line_no/end_line_pos,适配器 `v.end_line_no-1`=NaN → VSCode Range 构造时 start.isBefore(end) 对 NaN 恒 false 触发 start/end 交换 → range.start.line=NaN → 面板 L${line+1} 渲染 LNaN。修复:adapters/sqlfluff.ts 新增纯函数 resolveSqlFluffRange(缺失/null/NaN/非法值兜底:start 回退 1、end 回退 start;兼容旧版 line_no/line_pos key;start_line_pos=0 钳制 ≥1),check() 改用其构造 Range;SqlFluffViolation position 字段改可选;webview.ts buildIssueItem 与 utils/report.ts formatLine 加 Number.isFinite 防御(非法行号渲染 L?);新增 src/test/sqlfluff-range.test.ts 6 用例。验证:lint 0 error(仅既有 mockDocument.ts 2 warning)/ compile 通过 / npm test 111 passing | 中间产物:①根因排查多轮——先后排除旧版 sqlfluff schema(line_no key)与「注释场景缺 end 字段只坏终点不坏起点」假设,最终结合 VSCode extHostTypes/range.ts 源码确认 NaN 使 isBefore 恒 false 触发 start/end 交换,链路闭合;②sanitizePosition 初版参数类型 number|undefined,测试用例传 null 触发 TS 类型错误,接口与函数签名扩为 number|null|undefined | src/adapters/sqlfluff.ts src/panel/webview.ts src/utils/report.ts src/test/sqlfluff-range.test.ts(新建) | deepseek-v4-flash | +| 2026-08-25 21:54 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 从插件内置 PMD 配置移除 5 条实际不可触发的规则(AvoidAssertAsIdentifier/AvoidEnumAsIdentifier 语言版本上限 1.3/1.4、AccessorClassGeneration/AccessorMethodGeneration 上限 Java 10、LoosePackageCoupling 需显式 packages 配置),使内置配置=全部可触发,demo-pmd 覆盖率基线对齐 269+12=281 可达成 100%。改动:jars/pmd/pmd-java-ruleset.xml 三个分类 exclude 各追加(bestpractices 2 + design 1 + errorprone 2)并将 description 计数 274→269;src/rules/static-rules.json linterVersion.pmd 改 269、移除 5 条规则条目(pmd 295→290);scripts/translations/pmd-1.mjs/pmd-2.mjs 同步删除 5 条翻译种子项。验证:PMD 实际跑内置 ruleset 无加载异常 / lint 0 error / compile 通过 / npm test 117 passing | 中间产物:static-rules.json 第一处编辑误保留 AccessorClassGeneration 块(只删了 AccessorMethodGeneration),复查 JSON 计数发现后补删 | jars/pmd/pmd-java-ruleset.xml src/rules/static-rules.json scripts/translations/pmd-1.mjs scripts/translations/pmd-2.mjs | deepseek-v4-flash | diff --git a/docs/superpowers/specs/2026-08-22-pregen-fix-design.md b/docs/superpowers/specs/2026-08-22-pregen-fix-design.md new file mode 100644 index 0000000..7061c18 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-pregen-fix-design.md @@ -0,0 +1,95 @@ +# 审核时预生成修复 + 展开问题即显示 diff + +日期:2026-08-22 +状态:已批准 + +## 目标 + +1. 审核时 AI 为三类问题(custom 规则问题、AI 深度审查发现、linter 无 autofix 诊断)预生成可直接应用的修复片段 `{ originalText, newText }`。 +2. 点击面板"修复"按钮直接从报告取片段匹配应用,无需再调 LLM;匹配失败自动回退实时 AI 修复。 +3. 面板展开问题时,直接以行级 `-/+` 对比显示修复前后代码(所有可修复问题,含 linter 自带 fix)。 +4. `fixAll` 采用"预生成片段优先,失败回退实时"策略,不做偏移修正。 + +## 数据流 + +``` +审核(发现 + 预生成修复片段) → 面板 + ├─ 点修复 → 直接匹配应用(免等待) → 失败 → 回退实时 LLM + └─ 展开问题 → 行级 -/+ diff 立即渲染 +``` + +## 关键决策 + +| 决策点 | 结论 | +|---|---| +| 预生成覆盖 | custom + AI findings + linter 无 autofix 诊断 | +| 匹配失败 | 回退实时 AI 修复 | +| 点击修复 | 保留 diff 预览确认,内容来自预生成片段 | +| 展开 diff 形式 | 行级 -/+ 对比(红删/绿增) | +| 展开 diff 范围 | 所有可修复问题(含 linter 自带 fix) | +| fixAll 策略 | 预生成片段优先,失败回退实时,不做偏移修正 | +| 兼容性 | `fix`/`aiFix` 字段可选,缺失则该问题无预生成修复/diff,行为不变 | + +## 文件变更 + +| 文件 | 变更 | +|---|---| +| `src/types.ts` | 新增 `AiFixSnippet { originalText; newText }`;`LinterFix` 加 `originalText?`;`LinterDiagnostic` 加 `aiFix?: AiFixSnippet` | +| `src/ai/schema.ts` | `CustomRuleResult`、`AIFinding`、`TranslatedDiagnostic` 加可选 `fix?: AiFixSnippet` | +| `src/ai/engine.ts` | 三语 prompt(zh/en/ja)的 `customRuleResults`/`findings`/`translatedDiagnostics` 输出格式加 `fix` 字段(含"原文逐字、不含行号前缀"说明);`runAIReview`/`runMethodReview` 解析容错保留 `fix` | +| `src/merger/merger.ts` | `MergeInput` 加 `code?`;映射 `staticDiagnostics` 时从 `code` 按 `fix.range` 切片补 `fix.originalText`;映射 `customRuleResults` 时带 `aiFix` | +| `src/activation/commands.ts` | 所有 `mergeResults` 调用点传 `document.getText()` 作为 `code`;`refreshAfterFix` 重建时保留 `aiFix`;`findCustomIssue`/`findAIIssue`/`fixAll` 带 `fix` | +| `src/fix/fixPrompt.ts` | `ReviewIssueInput` 加 `fix?: AiFixSnippet` | +| `src/fix/customFixEngine.ts` | `aiFixReviewIssue` 开头尝试预生成片段直接应用,失败进实时循环 | +| `src/fix/aiFixEngine.ts` | `aiFixDiagnostic` 开头尝试预生成片段直接应用 | +| `src/utils/diff.ts`(新建) | 轻量行级 diff(LCS),返回 `{type:'del'\|'add'\|'same', text}[]` | +| `src/panel/webview.ts` | 展开区渲染行级 diff(`.diff-del` 红 / `.diff-add` 绿 + CSS);三列表构造 detailHtml 时并入 diff;suggestion 与 diff 同时展示 | + +## 核心逻辑 + +### 预生成片段直接应用(两个修复引擎开头共用模式) + +```ts +const p = diag.fix ?? diag.aiFix; +if (p?.originalText && p?.newText) { + const i = currentText.indexOf(p.originalText); + if (i !== -1) { + const next = currentText.slice(0, i) + p.newText + currentText.slice(i + p.originalText.length); + if (next !== currentText) { + appliedFixes.push({ originalText: p.originalText, newText: p.newText, line }); + return dryRun ? { success: true, appliedFixes, newText: next } + : applyWorkspaceEdit(document, next, appliedFixes); + } + } +} +// 匹配失败 → 继续现有 for 循环(实时 LLM) +``` + +### 展开 diff 渲染 + +```ts +// 提取每条问题的修复片段 +linter: d.fix?.originalText + d.fix?.text // 或 d.aiFix +custom: d.aiFix +ai: f.fix +// 生成行级 diff HTML +diffHtml = computeLineDiff(originalText, newText) + .map(l => `
${esc(l.text)}
`).join('') +``` + +### fixAll 策略 + +逐条串行处理:每条问题先尝试预生成片段在当前累积文本 `currentText` 上匹配,命中则直接应用(不调 LLM);失败或无片段则走现有实时修复。完成后保留批量 diff 预览 + 应用确认。 + +## 影响与风险 + +- 审核输出变长(三个来源都输出 fix),token/耗时略增——换取点修复零等待 + 展开即见 diff +- linter 原文片段依赖 `code` 全文切片;`refreshAfterFix` 需传当前文本,否则 diff 基于旧文本(匹配失败会回退,体验无损) +- 自写 diff 仅行级对比,不做字符级高亮 + +## 验证 + +- `npm run lint` → `npm run compile` → `npm test` +- 手动验证:custom / AI findings / linter 无 autofix 三条路径的"展开即见 diff"与"点修复免等待" +- 修改代码后再点修复应回退实时 +- fixAll 批量场景多数问题秒过,个别失败回退实时 diff --git a/jars/pmd/pmd-java-ruleset.xml b/jars/pmd/pmd-java-ruleset.xml index 6028609..a93b11e 100644 --- a/jars/pmd/pmd-java-ruleset.xml +++ b/jars/pmd/pmd-java-ruleset.xml @@ -3,7 +3,7 @@ xmlns="http://pmd.sourceforge.net/ruleset/2.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd"> - Java Code Review Rules (274 active rules, 7 categories) + Java Code Review Rules (269 active rules, 7 categories) @@ -15,6 +15,8 @@ + + @@ -39,6 +41,7 @@ + @@ -48,6 +51,8 @@ + + diff --git a/scripts/translations/pmd-1.mjs b/scripts/translations/pmd-1.mjs index c57c7a7..68bf9fd 100644 --- a/scripts/translations/pmd-1.mjs +++ b/scripts/translations/pmd-1.mjs @@ -1,7 +1,5 @@ export default { 'pmd/AbstractClassWithoutAbstractMethod': { zh: '抽象类不包含任何抽象方法', ja: '抽象クラスに抽象メソッドが含まれていない' }, - 'pmd/AccessorClassGeneration': { zh: '避免从外部通过私有构造函数实例化', ja: '外部からプライベートコンストラクタでインスタンス化することを避ける' }, - 'pmd/AccessorMethodGeneration': { zh: '避免合成访问器方法', ja: '合成アクセッサメソッドを避ける' }, 'pmd/ArrayIsStoredDirectly': { zh: '存储到构造函数/方法前应克隆对象', ja: 'コンストラクタやメソッドに格納する前にオブジェクトをクローンする' }, 'pmd/AssertStatementInTest': { zh: '测试代码中不应使用断言语句', ja: 'テストコードで assert 文を使用すべきでない' }, 'pmd/AvoidMessageDigestField': { zh: '不要将 MessageDigest 声明为字段(线程安全)', ja: 'MessageDigest をフィールドとして宣言しない(スレッド安全性)' }, @@ -136,7 +134,6 @@ export default { 'pmd/InvalidJavaBean': { zh: 'Bean 不符合 JavaBeans 规范', ja: 'Bean が JavaBeans 仕様に従っていない' }, 'pmd/LawOfDemeter': { zh: '潜在的迪米特法则违规', ja: '潜在的な LoD 違反' }, 'pmd/LogicInversion': { zh: '使用相反的运算符替代 !', ja: '! の代わりに反対の演算子を使用する' }, - 'pmd/LoosePackageCoupling': { zh: '避免使用包层次之外的类', ja: 'パッケージ階層外のクラスの使用を避ける' }, 'pmd/MutableStaticState': { zh: '非私有非 final 的静态字段', ja: '非 private かつ非 final の静的フィールド' }, 'pmd/NcssCount': { zh: '非注释源码语句度量', ja: '非コメントソース文のメトリクス' }, 'pmd/NPathComplexity': { zh: 'NPath 复杂度阈值', ja: 'NPath 複雑度の閾値' }, diff --git a/scripts/translations/pmd-2.mjs b/scripts/translations/pmd-2.mjs index 2998a55..2db6089 100644 --- a/scripts/translations/pmd-2.mjs +++ b/scripts/translations/pmd-2.mjs @@ -3,13 +3,11 @@ export default { 'pmd/AssignmentInOperand': { zh: '避免在操作数中赋值', ja: 'オペランド内での代入を避ける' }, 'pmd/AssignmentToNonFinalStatic': { zh: '构造函数中对非 final 静态字段的不安全赋值', ja: 'コンストラクタ内の非 final 静的フィールドへの安全でない代入' }, 'pmd/AvoidAccessibilityAlteration': { zh: '不要使用 setAccessible(true)', ja: 'setAccessible(true) を使用しない' }, - 'pmd/AvoidAssertAsIdentifier': { zh: 'assert 是保留字(Java <1.4)', ja: 'assert は予約語である(Java <1.4)' }, 'pmd/AvoidBranchingStatementAsLastInLoop': { zh: '循环体最后的跳转语句', ja: 'ループの最後の分岐文' }, 'pmd/AvoidCallingFinalize': { zh: '不要显式调用 finalize()', ja: 'finalize() を明示的に呼ばない' }, 'pmd/AvoidCatchingGenericException': { zh: '不要捕获泛化异常', ja: '汎用例外を捕捉しない' }, 'pmd/AvoidDecimalLiteralsInBigDecimalConstructor': { zh: 'BigDecimal 使用 String 构造函数', ja: 'BigDecimal には String コンストラクタを使用する' }, 'pmd/AvoidDuplicateLiterals': { zh: '避免重复的 String 字面量', ja: '重複する文字列リテラルを避ける' }, - 'pmd/AvoidEnumAsIdentifier': { zh: 'enum 是保留字(Java <1.5)', ja: 'enum は予約語である(Java <1.5)' }, 'pmd/AvoidFieldNameMatchingMethodName': { zh: '字段名与方法名相同', ja: 'フィールド名とメソッド名が一致する' }, 'pmd/AvoidFieldNameMatchingTypeName': { zh: '字段名与类型名相同', ja: 'フィールド名と型名が一致する' }, 'pmd/AvoidInstanceofChecksInCatchClause': { zh: '使用单独的 catch 子句', ja: '個別の catch 句を使用する' }, diff --git a/src/activation/commands.ts b/src/activation/commands.ts index 321617a..68d9f84 100644 --- a/src/activation/commands.ts +++ b/src/activation/commands.ts @@ -104,6 +104,7 @@ function findCustomIssue(ruleId?: string, line?: number): ReviewIssueInput | und line: d.range.start.line, message: d.message, suggestion: d.suggestion, + fix: d.aiFix, }; } @@ -118,9 +119,21 @@ function findAIIssue(ruleId?: string, line?: number): ReviewIssueInput | undefin line: f.line, message: f.title, suggestion: f.suggestion, + fix: f.fix, }; } +function enrichLinterDiagnostic(diag: LinterDiagnostic): LinterDiagnostic { + if (diag.aiFix || !currentReport) { return diag; } + const reportDiag = currentReport.linterDiagnostics.find(d => + d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line + ); + if (reportDiag?.aiFix) { + return { ...diag, aiFix: reportDiag.aiFix }; + } + return diag; +} + async function refreshAfterFix( document: vscode.TextDocument, orchestrator: Orchestrator, @@ -145,6 +158,7 @@ async function refreshAfterFix( severity: d.severity, message: d.message, suggestion: d.suggestion, + fix: d.aiFix, line: d.range.start.line + 1, })), translatedDiagnostics: currentReport.translatedDiagnostics, @@ -157,6 +171,7 @@ async function refreshAfterFix( adapterIds: result.adapterIds, aiFixAvailable: currentReport.aiFixAvailable, customRuleFilterInfo: currentReport.customRuleFilterInfo, + code: document.getText(), }); const panel = ReviewPanel.createOrShow(extensionUri); panel.setFixSession(fixSession); @@ -239,6 +254,7 @@ export function registerCommands( filteredOut: filterResult.filteredOut.length, skippedRequestA: filterResult.skippedRequestA, }, + code: document.getText(), }); const panel = ReviewPanel.createOrShow(context.extensionUri); @@ -336,6 +352,7 @@ export function registerCommands( adapterIds: [], aiFixAvailable: false, customRuleFilterInfo: undefined, + code: document.getText(), }); statusCache.set(document.uri, scope.name, totalIssues); @@ -499,7 +516,7 @@ export function registerCommands( location: vscode.ProgressLocation.Notification, title: needsAi ? t('fix.aiRunning') : t('fix.running'), cancellable: false, - }, () => resolveFix(context, document, workingDir, adapter, diag, maxIterations, isPreview)); + }, () => resolveFix(context, document, workingDir, adapter, enrichLinterDiagnostic(diag), maxIterations, isPreview)); if (!result.success) { const msg = diag.fix ? t('fix.failed', { 0: result.message ?? '' }) @@ -577,12 +594,14 @@ export function registerCommands( line: d.range.start.line, message: d.message, suggestion: d.suggestion, + fix: d.aiFix, })) : (currentReport?.aiFindings ?? []).map(f => ({ ruleId: f.ruleId, line: f.line, message: f.title, suggestion: f.suggestion, + fix: f.fix, })); if (issues.length === 0) { vscode.window.showInformationMessage(t('fix.noFix')); @@ -673,7 +692,7 @@ export function registerCommands( d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line ) ?? fresh.diagnostics.find(d => d.ruleId === diag.ruleId); if (!freshDiag) { skipped++; continue; } - const result = await resolveFix(context, mock, workingDir, adapter, freshDiag, maxIterations, true); + const result = await resolveFix(context, mock, workingDir, adapter, enrichLinterDiagnostic(freshDiag), maxIterations, true); if (result.success && result.newText && result.newText !== currentText) { results.push({ ruleId: freshDiag.ruleId, line: freshDiag.range.start.line, appliedFixes: result.appliedFixes }); currentText = result.newText; diff --git a/src/adapters/sqlfluff.ts b/src/adapters/sqlfluff.ts index 930047d..1ac7914 100644 --- a/src/adapters/sqlfluff.ts +++ b/src/adapters/sqlfluff.ts @@ -115,14 +115,29 @@ export function resolveSqlFluffDialect(workspaceRoot: string): SqlFluffDialectIn } interface SqlFluffViolation { - start_line_no: number; - start_line_pos: number; - end_line_no: number; - end_line_pos: number; + start_line_no?: number | null; + start_line_pos?: number | null; + end_line_no?: number | null; + end_line_pos?: number | null; + line_no?: number | null; + line_pos?: number | null; code: string; description: string; } +function sanitizePosition(value: number | null | undefined, fallback: number): number { + const n = Number(value); + return Number.isFinite(n) && n >= 1 ? n : fallback; +} + +export function resolveSqlFluffRange(v: SqlFluffViolation): [number, number, number, number] { + const startLine = sanitizePosition(v.start_line_no ?? v.line_no, 1); + const startPos = sanitizePosition(v.start_line_pos ?? v.line_pos, 1); + const endLine = sanitizePosition(v.end_line_no, startLine); + const endPos = sanitizePosition(v.end_line_pos, startPos); + return [startLine - 1, startPos - 1, endLine - 1, endPos - 1]; +} + interface SqlFluffResult { filepath: string; violations: SqlFluffViolation[]; @@ -213,12 +228,7 @@ export class SqlFluffAdapter implements LinterAdapter { severity: isPRS ? 'error' : tierToSeverity(tierMap.get(v.code)), ruleId: `sqlfluff:${v.code}`, message: isPRS ? buildPRSMessage(v.description, effectiveDialect) : v.description, - range: new vscode.Range( - v.start_line_no - 1, - v.start_line_pos - 1, - v.end_line_no - 1, - v.end_line_pos - 1 - ), + range: new vscode.Range(...resolveSqlFluffRange(v)), }); } } diff --git a/src/ai/engine.ts b/src/ai/engine.ts index 244102f..ca71096 100644 --- a/src/ai/engine.ts +++ b/src/ai/engine.ts @@ -105,8 +105,8 @@ function buildCustomRuleSystemPrompt(): string { return `あなたはコードルールレビュアーです。以下のカスタムルールに違反しているかどうかのみを評価してください。 意味を理解し、テキストの一致ではなく判断してください。 JSONのみを出力、形式: -{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明", "suggestion": "具体的な修正提案" }] } -"suggestion" は実行可能な修正提案を必ず含めてください。 +{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明", "suggestion": "具体的な修正提案", "fix": { "originalText": "置換対象のコード原文(コードコンテキスト内に完全一致すること、行番号プレフィックスなし)", "newText": "修正後のコード片" } }] } +各違反に対して必ず実行可能な "suggestion" を含め、可能な場合は適用可能な "fix" も提供してください。 ルールに違反していない場合は空の配列を返してください。 出力言語:ja`; @@ -115,8 +115,8 @@ JSONのみを出力、形式: return `You are a code rule reviewer. Only evaluate whether the following custom rules are violated. Understand semantics, not text matching. Output JSON only, format: -{ "customRuleResults": [{ "ruleId": "rule id", "line": line number, "severity": "error|warning|info", "message": "violation description", "suggestion": "concrete fix suggestion" }] } -Always include a concrete actionable "suggestion" for each violation. +{ "customRuleResults": [{ "ruleId": "rule id", "line": line number, "severity": "error|warning|info", "message": "violation description", "suggestion": "concrete fix suggestion", "fix": { "originalText": "the exact code snippet to replace (must exist verbatim in the code context, without the line-number prefix)", "newText": "the fixed code snippet" } }] } +Always include a concrete actionable "suggestion" for each violation, and provide an applicable "fix" snippet when possible. If no rules are violated, return an empty array. Output language: en`; @@ -124,8 +124,8 @@ Output language: en`; return `你是代码规则审查员,只评估以下自定义规则是否被违反。 理解语义而非文本匹配。 仅输出 JSON,格式: -{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述", "suggestion": "具体的修复建议" }] } -每条违规都必须给出可执行的 "suggestion" 修复建议。 +{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述", "suggestion": "具体的修复建议", "fix": { "originalText": "待替换的代码原文(必须在代码上下文中逐字存在,不含行号前缀)", "newText": "修复后的代码片段" } }] } +每条违规都必须给出可执行的 "suggestion" 修复建议,并尽量提供可应用的 "fix" 修复片段。 如果没有违反任何规则,返回空数组。 输出语言:zh-CN`; @@ -137,7 +137,7 @@ function buildDeepReviewSystemPrompt(): string { return `あなたはシニアコードレビュー専門家です。2つのタスクを実行してください: 1. 英語の静的解析結果を出力言語に翻訳し、修正提案を追加する 2. コードを詳細にレビューし、静的解析でカバーされていない問題を発見する -重点分野:セキュリティ脆弱性、論理エラー、パフォーマンス問題、設計欠陥 +重点分野:セキュリティ脆弱性、ロジックエラー、パフォーマンス問題、設計欠陥 静的解析ですでに報告された問題を重複しないでください。 translatedDiagnosticsの要件: @@ -145,12 +145,17 @@ translatedDiagnosticsの要件: - "originalRuleId" はリスト内のルールID(eslint: 等のプレフィックスを含む)をそのままコピーし、書き換えないでください - "translatedMessage" と "translatedSuggestion" は両方必須で、空にしないでください - "translatedSuggestion" は具体的で実行可能な修正提案(例:この書き方に置き換える)を示してください +- 可能な場合は各診断に適用可能な "fix" を提供してください + +findingsの要件: +- 可能な場合は各発見に適用可能な "fix" を提供してください +- "fix.originalText" は提供されたコード内に逐語的に存在すること(行番号プレフィックスなし) JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。 形式: { - "translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案" }], - "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "line": 行番号 }] + "translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案", "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" } }], + "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "line": 行番号, "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" } }] } 出力言語:ja`; @@ -167,12 +172,17 @@ translatedDiagnostics requirements: - "originalRuleId" must be copied verbatim from the listed rule IDs (keep prefixes like eslint:), do not rewrite - "translatedMessage" and "translatedSuggestion" are both required and must not be empty - "translatedSuggestion" should be a concrete actionable fix suggestion (e.g. what to replace it with), not just a replacement snippet +- Provide an applicable "fix" snippet for each diagnostic when possible + +findings requirements: +- Provide an applicable "fix" snippet for each finding when possible +- "fix.originalText" must exist verbatim in the provided code (without the line-number prefix) Output JSON only. Double quotes in strings must be escaped with \\". Format: { - "translatedDiagnostics": [{ "originalRuleId": "original id", "translatedMessage": "translated message", "translatedSuggestion": "suggestion" }], - "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "title", "description": "description", "suggestion": "suggestion", "line": line number }] + "translatedDiagnostics": [{ "originalRuleId": "original id", "translatedMessage": "translated message", "translatedSuggestion": "suggestion", "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" } }], + "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "title", "description": "description", "suggestion": "suggestion", "line": line number, "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" } }] } Output language: en`; @@ -188,12 +198,17 @@ translatedDiagnostics 要求: - "originalRuleId" 必须原样复制列表中的规则 ID(保留 eslint: 等前缀),不得改写 - "translatedMessage" 与 "translatedSuggestion" 均为必填字段,不得为空 - "translatedSuggestion" 给出具体可执行的修复建议(如应替换成什么写法),不要只给替换片段 +- 尽量为每条诊断提供 "fix" 可应用修复片段 + +findings 要求: +- 尽量为每条发现提供 "fix" 可应用修复片段 +- "fix.originalText" 必须在提供的代码中逐字存在(不含行号前缀) 仅输出 JSON,字符串中的双引号必须用 \\" 转义。 格式: { - "translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议" }], - "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "line": 行号 }] + "translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议", "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" } }], + "findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "line": 行号, "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" } }] } 输出语言:zh-CN`; @@ -430,7 +445,8 @@ Report violations in "customRuleResults".\n\n` "line": line_number, "severity": "error|warning|info", "message": "violation description", - "suggestion": "concrete fix suggestion" + "suggestion": "concrete fix suggestion", + "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" } } ],\n` : ''; @@ -456,6 +472,9 @@ F. Testability: side effect isolation, dependency mockability, deterministic out - Check whether this method's return value is correctly handled by callers - Check whether exceptions are caught or declared by callers +Provide an applicable "fix" snippet for each finding when possible. +"fix.originalText" must exist verbatim in the provided method code (without the line-number prefix). + Output JSON only. Double quotes in strings must be escaped with \\". Format: { @@ -468,7 +487,8 @@ ${ruleOutput} "findings": [ "description": "detailed description", "suggestion": "fix suggestion", "line": line_number, - "path": "trigger path description, e.g. if(order==null) -> NPE on .getId()" + "path": "trigger path description, e.g. if(order==null) -> NPE on .getId()", + "fix": { "originalText": "exact snippet to replace (without line-number prefix)", "newText": "fixed snippet" } } ] } @@ -491,7 +511,8 @@ function buildMethodSystemPromptZh(hasRules: boolean): string { "line": 行号, "severity": "error|warning|info", "message": "违规描述", - "suggestion": "具体的修复建议" + "suggestion": "具体的修复建议", + "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" } } ],\n` : ''; @@ -517,6 +538,9 @@ F. 可测试性:副作用隔离、依赖可 Mock 性、确定性输出 - 检查本方法的返回值是否被调用者正确处理 - 检查异常是否被调用者捕获或声明 +尽可能为每条发现提供可应用的 "fix" 修复片段。 +"fix.originalText" 必须在提供的方法代码中逐字存在(不含行号前缀)。 + 输出 JSON,字符串中的双引号必须用 \\" 转义。 格式: { @@ -529,7 +553,8 @@ ${ruleOutput} "findings": [ "description": "详细描述", "suggestion": "修复建议", "line": 行号, - "path": "触发路径描述,如 if(order==null) -> NPE on .getId()" + "path": "触发路径描述,如 if(order==null) -> NPE on .getId()", + "fix": { "originalText": "待替换的代码原文(不含行号前缀)", "newText": "修复后的代码片段" } } ] } @@ -552,7 +577,8 @@ function buildMethodSystemPromptJa(hasRules: boolean): string { "line": 行番号, "severity": "error|warning|info", "message": "違反の説明", - "suggestion": "具体的な修正提案" + "suggestion": "具体的な修正提案", + "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" } } ],\n` : ''; @@ -578,6 +604,9 @@ F. テスタビリティ:副作用の分離、依存のモック化容易性 - このメソッドの戻り値が呼び出し元で正しく処理されているか確認 - 例外が呼び出し元でキャッチまたは宣言されているか確認 +可能な場合は各発見に適用可能な "fix" を提供してください。 +"fix.originalText" は提供されたメソッドコード内に逐語的に存在すること(行番号プレフィックスなし)。 + JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。 形式: { @@ -590,7 +619,8 @@ ${ruleOutput} "findings": [ "description": "詳細な説明", "suggestion": "修正提案", "line": 行番号, - "path": "トリガーパス説明、例: if(order==null) -> .getId() で NPE" + "path": "トリガーパス説明、例: if(order==null) -> .getId() で NPE", + "fix": { "originalText": "置換対象のコード原文(行番号プレフィックスなし)", "newText": "修正後のコード片" } } ] } diff --git a/src/ai/schema.ts b/src/ai/schema.ts index 898e571..980fc12 100644 --- a/src/ai/schema.ts +++ b/src/ai/schema.ts @@ -2,6 +2,10 @@ export interface TranslatedDiagnostic { originalRuleId: string; translatedMessage: string; translatedSuggestion: string; + fix?: { + originalText: string; + newText: string; + }; } export interface CustomRuleResult { @@ -10,6 +14,10 @@ export interface CustomRuleResult { severity: 'error' | 'warning' | 'info'; message: string; suggestion?: string; + fix?: { + originalText: string; + newText: string; + }; } export interface AIFinding { @@ -20,6 +28,10 @@ export interface AIFinding { description: string; suggestion: string; line: number; + fix?: { + originalText: string; + newText: string; + }; } export type MethodFindingCategory = diff --git a/src/fix/aiFixEngine.ts b/src/fix/aiFixEngine.ts index 5744e87..fe5aae5 100644 --- a/src/fix/aiFixEngine.ts +++ b/src/fix/aiFixEngine.ts @@ -72,6 +72,44 @@ export async function aiFixDiagnostic( const appliedFixes: AppliedFix[] = []; let converged = false; + const pre = diag.aiFix; + if (pre?.originalText && pre?.newText) { + const startIndex = currentText.indexOf(pre.originalText); + if (startIndex !== -1) { + const endIndex = startIndex + pre.originalText.length; + const nextText = currentText.slice(0, startIndex) + pre.newText + currentText.slice(endIndex); + if (nextText !== currentText) { + appliedFixes.push({ + originalText: pre.originalText, + newText: pre.newText, + line: diag.range.start.line, + }); + currentText = nextText; + converged = true; + } + } + } + + if (converged) { + if (currentText === originalText) { + return { success: true, attempts: 0, appliedFixes }; + } + if (dryRun) { + return { success: true, attempts: 0, appliedFixes, newText: currentText }; + } + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(originalText.length) + ); + edit.replace(document.uri, fullRange, currentText); + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + return { success: false, attempts: 0, message: 'apply-failed', appliedFixes }; + } + return { success: true, attempts: 0, appliedFixes }; + } + for (let round = 1; round <= maxIterations; round++) { const context = buildFixContext(currentText, diag.range.start.line); const fix = await requestFix(provider, options, diag, context); diff --git a/src/fix/customFixEngine.ts b/src/fix/customFixEngine.ts index 1c915c3..f1dd727 100644 --- a/src/fix/customFixEngine.ts +++ b/src/fix/customFixEngine.ts @@ -68,6 +68,44 @@ export async function aiFixReviewIssue( const appliedFixes: AppliedFix[] = []; let converged = false; + const pre = diag.fix; + if (pre?.originalText && pre?.newText) { + const startIndex = currentText.indexOf(pre.originalText); + if (startIndex !== -1) { + const endIndex = startIndex + pre.originalText.length; + const nextText = currentText.slice(0, startIndex) + pre.newText + currentText.slice(endIndex); + if (nextText !== currentText) { + appliedFixes.push({ + originalText: pre.originalText, + newText: pre.newText, + line: diag.line, + }); + currentText = nextText; + converged = true; + } + } + } + + if (converged) { + if (currentText === originalText) { + return { success: true, attempts: 0, appliedFixes }; + } + if (dryRun) { + return { success: true, attempts: 0, appliedFixes, newText: currentText }; + } + const edit = new vscode.WorkspaceEdit(); + const fullRange = new vscode.Range( + document.positionAt(0), + document.positionAt(originalText.length) + ); + edit.replace(document.uri, fullRange, currentText); + const applied = await vscode.workspace.applyEdit(edit); + if (!applied) { + return { success: false, attempts: 0, message: 'apply-failed', appliedFixes }; + } + return { success: true, attempts: 0, appliedFixes }; + } + for (let round = 1; round <= maxIterations; round++) { const context = buildFixContext(currentText, diag.line); const fix = await requestFix(provider, options, diag, context); diff --git a/src/fix/fixPreview.ts b/src/fix/fixPreview.ts index b3e149d..988f109 100644 --- a/src/fix/fixPreview.ts +++ b/src/fix/fixPreview.ts @@ -41,7 +41,10 @@ export async function openPreviewDiff(req: PreviewRequest): Promise> = { en: 'AI Fix', ja: 'AI修正', }, + 'report.fixPreview': { + 'zh-CN': '修复预览', + en: 'Fix Preview', + ja: '修正プレビュー', + }, + 'report.fixUnavailable': { + 'zh-CN': '无预生成修复,点击修复将实时生成', + en: 'No pre-generated fix, click Fix to generate on demand', + ja: '事前生成された修正がありません。修正ボタンで生成します', + }, 'report.fixedIssues': { 'zh-CN': '已修复', en: 'Fixed', diff --git a/src/merger/merger.ts b/src/merger/merger.ts index bce534b..11968d3 100644 --- a/src/merger/merger.ts +++ b/src/merger/merger.ts @@ -1,5 +1,5 @@ import * as vscode from 'vscode'; -import type { LinterDiagnostic, Severity } from '../types'; +import type { LinterDiagnostic, Severity, AiFixSnippet } from '../types'; import type { TranslatedDiagnostic, CustomRuleResult, AIFinding } from '../ai/schema'; export interface MergedReport { @@ -40,6 +40,7 @@ interface MergeInput { language: string; adapterIds: string[]; aiFixAvailable?: boolean; + code?: string; customRuleFilterInfo?: { totalActive: number; injected: number; @@ -88,12 +89,15 @@ function sortBySeverityAndLine(items: T[], lineO } export function mergeResults(input: MergeInput): MergedReport { + const code = input.code ?? ''; + const customRuleDiagnostics: LinterDiagnostic[] = sortBySeverityAndLine( input.customRuleResults.map(r => ({ severity: r.severity as Severity, ruleId: r.ruleId, message: r.message, suggestion: r.suggestion, + aiFix: r.fix as AiFixSnippet | undefined, range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1), })), d => d.range.start.line @@ -104,10 +108,17 @@ export function mergeResults(input: MergeInput): MergedReport { const linterDiagnostics = sortBySeverityAndLine( input.staticDiagnostics.map(d => { const td = findTranslation(translationPool, d.ruleId); - if (td) { - return { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion }; + let fixed = td ? { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion } : d; + if (fixed.fix && code.length > 0 && !fixed.fix.originalText) { + const [start, end] = fixed.fix.range; + if (start >= 0 && end >= start && end <= code.length) { + fixed = { ...fixed, fix: { ...fixed.fix, originalText: code.slice(start, end) } }; + } } - return d; + if (td?.fix) { + fixed = { ...fixed, aiFix: td.fix as AiFixSnippet }; + } + return fixed; }), d => d.range.start.line ); diff --git a/src/panel/webview.ts b/src/panel/webview.ts index fcd4516..1ddd2ec 100644 --- a/src/panel/webview.ts +++ b/src/panel/webview.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { MergedReport } from '../merger/merger'; import { t, onLanguageChange, getLanguage } from '../i18n/messages'; import type { FixSessionManager } from '../fix/fixSession'; +import { computeLineDiff } from '../utils/diff'; interface PanelMessage { type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo' | 'applyFix' | 'cancelFix' | 'applyAll' | 'cancelAll'; @@ -260,9 +261,19 @@ export class ReviewPanel { .detail-text { color: var(--vscode-descriptionForeground); font-size: 13px; line-height: 1.7; } .detail-text code { font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; } .detail-suggestion { margin-top: 8px; padding: 8px 12px; background: rgba(97,175,239,0.08); border: 1px solid rgba(97,175,239,0.2); border-radius: 6px; font-size: 13px; color: #79c0ff; } + .detail-no-fix { margin-top: 4px; padding: 8px 12px; background: rgba(139,148,158,0.08); border: 1px dashed var(--vscode-panel-border); border-radius: 6px; font-size: 12px; color: var(--vscode-descriptionForeground); } .detail-original { margin-top: 6px; font-size: 12px; color: var(--vscode-descriptionForeground); font-style: italic; } .detail-category { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; background: rgba(139,148,158,0.1); color: var(--vscode-descriptionForeground); margin-top: 6px; } + .detail-fix-title { margin: 8px 0 4px; font-size: 11px; font-weight: 600; color: var(--vscode-descriptionForeground); text-transform: uppercase; letter-spacing: .03em; } + .fix-diff { margin-top: 4px; border: 1px solid var(--vscode-panel-border); border-radius: 6px; overflow: hidden; } + .diff-line { display: flex; align-items: flex-start; font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 12px; line-height: 1.6; padding: 1px 8px; white-space: pre-wrap; word-break: break-all; } + .diff-marker { flex-shrink: 0; width: 16px; color: var(--vscode-descriptionForeground); user-select: none; } + .diff-text { flex: 1; min-width: 0; } + .diff-same { color: var(--vscode-foreground); } + .diff-del { background: rgba(224,108,117,0.15); color: #E06C75; } + .diff-add { background: rgba(87,171,90,0.15); color: #57ab5a; } + .empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 48px 20px; color: var(--vscode-descriptionForeground); text-align: center; font-style: italic; font-size: 13px; } .actions { display: flex; gap: 8px; padding: 16px 16px 20px; border-top: 1px solid var(--vscode-panel-border); } @@ -333,7 +344,7 @@ ${errorBox} if (report.linterDiagnostics.length === 0) { html += `
${t('report.noIssues')}
`; } else { - html += report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i), aiFixableSet.has(i))).join(''); + html += report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i), aiFixableSet.has(i), undefined, true, this.buildFixDiffHtml(d.aiFix?.originalText ?? d.fix?.originalText, d.aiFix?.newText ?? d.fix?.text))).join(''); } const linterFixed = fixedEntries.filter(f => f.source === 'linter'); if (linterFixed.length > 0) { @@ -382,7 +393,7 @@ ${errorBox} if (remaining.length === 0) { html += `
${t('report.noRuleViolations')}
`; } else { - html += remaining.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false, true)).join(''); + html += remaining.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false, true, undefined, true, this.buildFixDiffHtml(d.aiFix?.originalText, d.aiFix?.newText))).join(''); } if (customFixed.length > 0) { html += `
✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: customFixed.length })}
`; @@ -421,7 +432,7 @@ ${errorBox} if (f.suggestion) { details.push(`
💡 ${esc(f.suggestion)}
`); } - parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, true, details.join(''))); + parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, true, details.join(''), true, this.buildFixDiffHtml(f.fix?.originalText, f.fix?.newText))); } } if (aiFixed.length > 0) { @@ -441,10 +452,11 @@ ${errorBox} fixable?: boolean, aiFixable?: boolean, detailHtml?: string, - expandable: boolean = true + expandable: boolean = true, + fixDiffHtml: string = '' ): string { const sevCls = severityClass(severity); - const lineNum = line + 1; + const lineNum = Number.isFinite(line) ? line + 1 : '?'; const parts: string[] = []; parts.push(`
`); @@ -467,11 +479,22 @@ ${errorBox} } parts.push('
'); - if (expandable && (detailHtml || (suggestion && suggestion !== message))) { + const fixPlaceholder = (fixable || aiFixable) && !fixDiffHtml + ? `
${esc(t('report.fixUnavailable'))}
` + : ''; + + if (expandable && (detailHtml || (suggestion && suggestion !== message) || fixDiffHtml || fixPlaceholder)) { parts.push('
'); + if (fixDiffHtml) { + parts.push(`
${t('report.fixPreview')}
`); + parts.push(fixDiffHtml); + } else if (fixPlaceholder) { + parts.push(`
${t('report.fixPreview')}
`); + parts.push(fixPlaceholder); + } if (detailHtml) { parts.push(detailHtml); - } else if (suggestion) { + } else if (suggestion && suggestion !== message) { parts.push(`
💡 ${esc(suggestion)}
`); } parts.push('
'); @@ -482,6 +505,14 @@ ${errorBox} return parts.join(''); } + private buildFixDiffHtml(originalText?: string, newText?: string): string { + if (!originalText || !newText || originalText === newText) { return ''; } + const lines = computeLineDiff(originalText, newText); + return `
${lines.map(l => + `
${l.type === 'del' ? '-' : l.type === 'add' ? '+' : ' '}${esc(l.text) || ' '}
` + ).join('')}
`; + } + private async handleMessage(message: PanelMessage): Promise { switch (message.type) { case 'navigate': diff --git a/src/rules/static-rules.json b/src/rules/static-rules.json index d67b256..56c627f 100644 --- a/src/rules/static-rules.json +++ b/src/rules/static-rules.json @@ -4,7 +4,7 @@ "eslint": "9.x (92 rules)", "ts-eslint": "8.x (35 rules)", "stylelint": "16.x (68 rules)", - "pmd": "7.26.0 (274 Java rules + 12 JSP rules)", + "pmd": "7.26.0 (269 Java rules + 12 JSP rules)", "sqlfluff": "4.2.2 (57 recommended)" }, "rules": { @@ -1197,18 +1197,6 @@ "descriptionZh": "抽象类不包含任何抽象方法", "descriptionJa": "抽象クラスに抽象メソッドが含まれていない" }, - { - "id": "pmd/AccessorClassGeneration", - "description": "Avoid instantiation through private constructors from outside", - "descriptionZh": "避免从外部通过私有构造函数实例化", - "descriptionJa": "外部からプライベートコンストラクタでインスタンス化することを避ける" - }, - { - "id": "pmd/AccessorMethodGeneration", - "description": "Avoid synthetic accessor methods", - "descriptionZh": "避免合成访问器方法", - "descriptionJa": "合成アクセッサメソッドを避ける" - }, { "id": "pmd/ArrayIsStoredDirectly", "description": "Clone objects before storing in constructors/methods", @@ -2013,12 +2001,6 @@ "descriptionZh": "使用相反的运算符替代 !", "descriptionJa": "! の代わりに反対の演算子を使用する" }, - { - "id": "pmd/LoosePackageCoupling", - "description": "Avoid using classes from outside package hierarchy", - "descriptionZh": "避免使用包层次之外的类", - "descriptionJa": "パッケージ階層外のクラスの使用を避ける" - }, { "id": "pmd/MutableStaticState", "description": "Non-private non-final static fields", @@ -2121,12 +2103,6 @@ "descriptionZh": "不要使用 setAccessible(true)", "descriptionJa": "setAccessible(true) を使用しない" }, - { - "id": "pmd/AvoidAssertAsIdentifier", - "description": "assert is reserved word (Java <1.4)", - "descriptionZh": "assert 是保留字(Java <1.4)", - "descriptionJa": "assert は予約語である(Java <1.4)" - }, { "id": "pmd/AvoidBranchingStatementAsLastInLoop", "description": "Branching statement as last in loop", @@ -2157,12 +2133,6 @@ "descriptionZh": "避免重复的 String 字面量", "descriptionJa": "重複する文字列リテラルを避ける" }, - { - "id": "pmd/AvoidEnumAsIdentifier", - "description": "enum is reserved word (Java <1.5)", - "descriptionZh": "enum 是保留字(Java <1.5)", - "descriptionJa": "enum は予約語である(Java <1.5)" - }, { "id": "pmd/AvoidFieldNameMatchingMethodName", "description": "Field name matching method name", diff --git a/src/types.ts b/src/types.ts index 5442432..7caee74 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,9 +13,15 @@ export type Severity = 'error' | 'warning' | 'info'; export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed'; +export interface AiFixSnippet { + originalText: string; + newText: string; +} + export interface LinterFix { range: [number, number]; text: string; + originalText?: string; } export interface LinterDiagnostic { @@ -25,6 +31,7 @@ export interface LinterDiagnostic { range: vscode.Range; suggestion?: string; fix?: LinterFix; + aiFix?: AiFixSnippet; } export interface AdapterResult { diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..4a29d73 --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,49 @@ +export interface DiffLine { + type: 'del' | 'add' | 'same'; + text: string; +} + +export function computeLineDiff(originalText: string, newText: string): DiffLine[] { + const a = originalText.split('\n'); + const b = newText.split('\n'); + + const n = a.length; + const m = b.length; + const lcs: number[][] = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + if (a[i] === b[j]) { + lcs[i][j] = lcs[i + 1][j + 1] + 1; + } else { + lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]); + } + } + } + + const out: DiffLine[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + out.push({ type: 'same', text: a[i] }); + i++; + j++; + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + out.push({ type: 'del', text: a[i] }); + i++; + } else { + out.push({ type: 'add', text: b[j] }); + j++; + } + } + while (i < n) { + out.push({ type: 'del', text: a[i] }); + i++; + } + while (j < m) { + out.push({ type: 'add', text: b[j] }); + j++; + } + return out; +} diff --git a/src/utils/report.ts b/src/utils/report.ts index c1c0eb7..eb4bedc 100644 --- a/src/utils/report.ts +++ b/src/utils/report.ts @@ -11,7 +11,7 @@ function severityEmoji(severity: string): string { } function formatLine(line: number): string { - return `L${line + 1}`; + return Number.isFinite(line) ? `L${line + 1}` : 'L?'; } export function reportToMarkdown(report: MergedReport): string {