feat: AI 修复链路扩展 + 修复预览两步确认 + 自定义规则/AI 审查条目支持 AI 修复
- AI 修复:无原生 fix 的 linter 条目走 AI 修复(aiFixEngine/fixPrompt,AI 重检收敛),自定义规则与 AI 审查条目新增 AI 修复按钮(customFixEngine),自定义规则条目展开显示 AI 建议(suggestion 字段)
- 修复预览:面板触发修复先用内置 diff 预览,面板内「应用/取消」两步确认后才写入(fixPreview/fixPending,单条与分 tab 批量均支持)
- 修复面板交互:linter/custom/ai 分 tab「全部修复」、已修复+撤销、pending 按钮状态同步
- bug 修复:修复按钮失败后卡 ⏳ 不恢复;custom/ai 修复不稳定(空修复重试、AI 重检收敛判定放宽、降级接受最后一次有效修复)
- 移除 AI codeDiff 展示块
This commit is contained in:
@@ -212,3 +212,10 @@
|
||||
| 2026-08-18 20:28 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复审查面板点击「修复」无反应:根因是 webview.ts 审查面板用内联 <script>,被 VS Code 默认 CSP 屏蔽(与 setupView 2026-07-15 历史 bug 相同),send/switchTab/toggleItem 全部未定义——修复按钮点击到 this.textContent='⏳...' 后 send() 抛 ReferenceError 消息从未发到扩展;症状为按钮卡 ⏳、无弹窗、代码不变,且 L1 行号跳转/tab 切换同样失效(用户确认)。修复:新建 src/views/reviewPanel.js(提取 send/switchTab/toggleItem,curly 花括号写法过 lint);webview.ts 面板 options localResourceRoots 改 [extensionUri]、构造函数 asWebviewUri 计算 scriptUri 存字段、buildHtml 内联 script 改 <script src>;copy-webview-js.mjs 与 build.mjs 的 webview 拷贝 files 数组加 reviewPanel.js(compile 与生产打包均拷到 out/webview/)。防御性改进:commands.ts fixIssue/fixAll 主体包 try/catch + console.error + showErrorMessage,静默 return 处补 console.log 便于排查。npm test 99 passing / lint 0 error / compile 通过,out/webview/reviewPanel.js 已生成 | 中间产物:根因排查多轮——先疑 fixDiagnostic 静默返回/挂起,再疑 fixSession 缓存缺失,最后用户确认「L1 跳转也失效」锁定 webview 内联 JS 被 CSP 屏蔽;reviewPanel.js 初版单行 if 无花括号会被 eslint curly 警告,改花括号 | src/views/reviewPanel.js(新建) src/panel/webview.ts src/activation/commands.ts scripts/copy-webview-js.mjs scripts/build.mjs | deepseek-v4-flash |
|
||||
| 2026-08-18 20:58 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复面板点「修复」必须先聚焦文件才生效:根因是两层活动编辑器依赖——commands.ts fixIssue/fixAll/undoFix 用 vscode.window.activeTextEditor 取文档,面板有焦点时活动编辑器未必是审查文件;fixEngine.ts fixDiagnostic 应用修复前又强制校验 activeTextEditor 必须等于目标文档(no-active-editor 分支)。改动:commands.ts 新增 resolveFixDocument(report, active, origin)——hover 用活动编辑器、面板(panel/fixAll/undo)用 currentReport.filePath 在 vscode.workspace.textDocuments 中定位文档、无报告回退活动编辑器;fixIssue/fixAll/undoFix 全部改用它取 document;fixEngine.ts 删除 no-active-editor 强校验分支直接 applyEdit(WorkspaceEdit 对任何打开文档生效无需焦点);webview.ts handleMessage 改 async,navigate 用 currentReport 的 Uri 经 showTextDocument({preview:true}) 打开/跳转。npm test 99 passing / lint 0 error / compile 通过 | 中间产物:resolveFixDocument 面板分支初版带「报告文件未打开时回退活动编辑器」会被误修当前活动文件,改为面板来源仅用报告文件、找不到返回 undefined 记日志;fixEngine.test.ts 仍保留 no-active-editor 断言分支但该路径已不再产生(不影响) | src/activation/commands.ts src/fix/fixEngine.ts src/panel/webview.ts | deepseek-v4-flash |
|
||||
| 2026-08-18 21:28 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 三个问题合并修复:①修复后不保存文件——commands.ts fixIssue 成功、fixAll 循环结束、undoFix 成功后各加 await document.save() 自动写盘。②点第一个修复连带修相邻同规则问题——根因 fixEngine.ts issueStillExists 用 ±2 行窗口把相邻实例误判为同一问题;改为按修复后文本区域重叠判断(重 lint 诊断的 fix.range 是否与被修复区间 [start, start+fix.text.length] 重叠),不重叠即另一处实例停止,只修被点击那一处;findClosestFixable/prevLine 保留用于定位目标。③并排窗口点行号在面板列新开文件副本——根因 webview.ts navigate 的 showTextDocument({preview:true}) 未指定列,默认在活动列(面板所在列)打开;改为先从 vscode.window.visibleTextEditors 找该文件已打开的编辑器取其 viewColumn 传入 showTextDocument,未打开才 preview:true 打开;修复 TS2531 用局部变量 report 收窄 currentReport。fixEngine.test.ts 新增用例(相邻两行 no-var 点第一个只产出 1 个 appliedFix)。npm test 100 passing / lint 0 error / compile 通过 | 中间产物:webview navigate 编译报 TS2531(find 回调内 this.currentReport 未收窄)→ 局部变量 const report=this.currentReport 解决 | src/activation/commands.ts src/fix/fixEngine.ts src/panel/webview.ts src/test/fixEngine.test.ts | deepseek-v4-flash |
|
||||
| 2026-08-18 22:07 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 静态分析修复能力扩展(AI 修复 fallback):为无原生 fix 的静态分析条目(PMD/ESLint·Stylelint 不可自动修项)增加 AI 修复。新建 src/fix/aiFixEngine.ts——aiFixDiagnostic:按规则类别取 ±6 行上下文(含已有 suggestion 作为参考建议注入 prompt,三语系统提示词,AI 返回 {originalText,newText})→ 全文 indexOf 匹配 → 替换 → adapter.check 重 lint 用「修复区域重叠」验证收敛(≤maxIterations,不消除带反馈重试)→ 单次 WorkspaceEdit 提交,复用 FixResult/AppliedFix(撤销、自动保存链路原样生效)。merger.ts:MergeInput/MergedReport 增 aiFixAvailable,新增 aiFixableLinterIndices(无 native fix 且 aiFixAvailable 且 ruleId 非 sqlfluff: 前缀)。commands.ts:新增 createFixProvider(复用 ai/factory)+ resolveFix(diag.fix→native,否则 AI,AI 未配置→ai-unavailable);review 计算 aiFixAvailable 传入 mergeResults,refreshAfterFix 透传;fixIssue 无 fix 时 withProgress 走 AI 并区分报错(fix.noAI/fix.aiFailed);fixAll 的 fixables 扩展为 native+AI 可修项、freshDiag 按 ruleId+line 匹配。webview.ts:linter 列表按 fixable/aiFixable 分渲染 🔧 修复 / 🤖 AI 修复按钮,fixAll 按钮条件含 aiFixable。i18n 新增 fix.aiRunning/aiFailed/noAI 与 report.fixAILabel 三语。测试:ai-fix-engine.test.ts 4 用例(mock provider+adapter:成功收敛/匹配失败/空修复/重试收敛)+ merger 2 用例。SQLFluff/JSP 本期排除。npm test 106 passing / lint 0 error / compile 通过 | 中间产物:AI 修复验证收敛初版考虑「按目标行 ±3 窗口」会误判相邻实例为未消除导致再次修复(重蹈 over-fix),最终沿用 native 的修复区域重叠判断;fixAll freshDiag 初版保留 ?? diag 回退会重修已消失问题,改为查不到即 skipped++ | src/fix/aiFixEngine.ts(新建) src/merger/merger.ts src/activation/commands.ts src/panel/webview.ts src/i18n/messages.ts src/test/ai-fix-engine.test.ts(新建) src/test/merger.test.ts | deepseek-v4-flash |
|
||||
| 2026-08-19 21:29 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复前 Diff 预览确认功能:面板触发的修复应用前用 VSCode 内置 diff 编辑器展示修改前后对比,确认后才写入。fixEngine.ts/aiFixEngine.ts:FixResult 增 newText?,fixDiagnostic/aiFixDiagnostic 增 dryRun 参数(为真时跳过 WorkspaceEdit 返回 newText)。新建 src/fix/fixPreview.ts:TextDocumentContentProvider(scheme codeReviewerPreview,diff 两侧均走 provider 避免脏文档左屏显示磁盘旧内容) + previewAndConfirm(vscode.diff 打开内置 diff + showInformationMessage 应用/取消对话框,选完统一关 diff 编辑器再清理 provider)+ applyNewText(整文档 WorkspaceEdit)。commands.ts:resolveFix 透传 dryRun;fixIssue 面板 origin 走 dryRun→diff 预览→确认后 applyNewText+recordFixes,hover 保持直接应用零变化;fixAll 改为内存累积(mockDocument 逐条 dryRun 计算 currentText 累积)→合并单 diff 预览→确认后一次性 applyNewText+逐条 recordFixes(原逐个写盘+多次刷新改为一次写入)。i18n 新增 fix.confirmApply/fix.apply/fix.cancel/fix.previewTitle 三语。extension.ts 注册 registerFixPreviewProvider。spec:docs/superpowers/specs/2026-08-19-fix-preview-design.md。npm test 106 passing / lint 0 error(仅 mockDocument.ts 2 个既有 curly warning)/ compile 通过 | 中间产物:①applyNewText 初版签名含 fixSession/ruleId/line/appliedFixes/record 五参(把记录职责内聚),实现时发现 fixAll 需逐条 recordFixes、单条预览也需在 diff 模块外记录,简化为仅 (document,newText) 纯写入,记录回归调用方;②fixAll 的 results 类型初版写成 typeof fixSession extends never 类型体操编译报错,改为显式 { ruleId; line; appliedFixes: AppliedFix[] }[] 并补 AppliedFix import;③自审查发现修复 bug——预览路径(isPreview)下原 recordFixes 条件 (origin!=='hover' && !isPreview) 恒假,撤销会话丢失,改为 if(isPreview){...记录} else if(origin!=='hover'){...记录};④freshDiag 行号匹配初版写 Math.abs(diff)<=0 语义即严格相等,简化为 ===;⑤previewAndConfirm 初版仅取消时关 diff,应用时残留 diff 编辑器会因 provider.clear 变空白,改为无论应用/取消都先 closePreviewEditors 再 clear | docs/superpowers/specs/2026-08-19-fix-preview-design.md(新建) src/fix/fixPreview.ts(新建) src/fix/fixEngine.ts src/fix/aiFixEngine.ts src/activation/commands.ts src/extension.ts src/i18n/messages.ts | deepseek-v4-flash |
|
||||
| 2026-08-20 19:21 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复实测问题:确认按钮不保持。实测发现 previewAndConfirm 用 showInformationMessage({modal:false}) 的右下角通知几秒后自动消失(返回 undefined 被当取消并关闭 diff)。经用户决策改为「面板内两步确认」:新建 src/fix/fixPending.ts(PendingFix/PendingBatch/FixPendingStore 待确认存储,按 filePath+key 索引,含 diffUri);fixPreview.ts 删 previewAndConfirm,新增 openPreviewDiff(返回 newUri)/closePreviewEditor(关 diff 标签+清理 provider)/applyNewText 简化;commands.ts 新增 4 命令 applyFixPreview/cancelFixPreview/applyAllPreview/cancelAllPreview(应用=写入+recordFixes+关 diff+删 pending+save+refresh;取消=关 diff+删 pending+postMessage 还原按钮),fixIssue panel 路径只存 pending+开 diff+postMessage 翻转按钮,fixAll 只存 batch+开 diff+postMessage,review 与文档关闭时 pendingStore.clear;webview.ts PanelMessage 增 applyFix/cancelFix/applyAll/cancelAll,buildIssueItem 对可修条目渲染隐藏的 btn-apply/btn-cancel(data-fix-key=ruleId@line),buildLinterList 顶部渲染隐藏 btn-apply-all/btn-cancel-all,handleMessage 路由到新命令,ReviewPanel 增公开 postMessage 方法;reviewPanel.js 增 window message 监听按 key/class 翻转按钮显隐(不全量重建面板,滚动/标签页保持);extension.ts 创建/传递 FixPendingStore;i18n 新增 report.fixAllApply 三语(复用 fix.apply/fix.cancel/fix.previewTitle)。spec 同步更新确认交互为两步确认。npm test 106 passing / lint 0 error / compile 通过。问题2(diff 左右结构)按用户要求暂缓 | 中间产物:①初版考虑 modal:true 居中模态框(按钮保持但挡 diff)用户弃选改面板两步确认;②ReviewPanel.currentPanel.webview 私有属性 4 处编译报错 TS2339,加公开 postMessage() 方法并用 PowerShell 批量替换调用点;③reviewPanel.js 翻转初版用 inline-flex 恢复显示,CSS 无此值改回清空 display 由默认样式接管 | src/fix/fixPending.ts(新建) src/fix/fixPreview.ts src/activation/commands.ts src/panel/webview.ts src/views/reviewPanel.js src/extension.ts src/i18n/messages.ts docs/superpowers/specs/2026-08-19-fix-preview-design.md | deepseek-v4-flash |
|
||||
| 2026-08-20 19:47 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 自定义规则条目支持展开显示 AI 修复建议:用户反馈自定义规则(custom tab)条目不可展开(无 suggestion、无修复意见),linter 条目可展开带 AI 建议。澄清后确定为「展开显示 AI 建议文本(💡)」,不加 AI 修复按钮。改动:schema.ts CustomRuleResult 加 suggestion?: string;engine.ts buildCustomRuleSystemPrompt(完整审查 zh/en/ja 三段)输出 JSON 格式加 "suggestion" 并要求每条违规必给可执行建议,buildMethodSystemPromptEn/Zh/Ja(方法审查)customRuleResults 输出段加 "suggestion"(runAIReview/runMethodReview 已用 ...r 展开映射自动透传无需改);merger.ts custom 诊断映射补 suggestion: r.suggestion;webview.ts 无需改(buildCustomList 已传 d.suggestion,非空即触发 buildIssueItem 展开详情复用 detail-suggestion 渲染)。spec:docs/superpowers/specs/2026-08-20-custom-rule-suggestion-design.md。npm test 106 passing / lint 0 error / compile 通过 | 中间产物:澄清阶段在「仅展开建议文本」与「展开+AI 修复按钮」间选择,用户选前者(自定义规则无 linter 可验证,修复按钮需额外适配,暂缓) | src/ai/schema.ts src/ai/engine.ts src/merger/merger.ts docs/superpowers/specs/2026-08-20-custom-rule-suggestion-design.md(新建) | deepseek-v4-flash |
|
||||
| 2026-08-20 20:18 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复功能扩展到自定义规则与 AI 审查:面板 custom/ai 条目标题支持 AI 修复,分 tab 批量(各 tab 各自「全部修复」),两步确认复用,各 tab 显示已修复+撤销。新建 src/fix/fixPrompt.ts(共享修复/重检 prompt builder 三语 + ReviewIssueInput 接口,从 aiFixEngine 提取)+ src/fix/customFixEngine.ts(aiFixReviewIssue:AI 生成修复→indexOf 匹配替换→AI 重检 {fixed,reason} 收敛,未消除带反馈重试 ≤maxIterations,无 adapter,支持 dryRun)+ src/test/customFixEngine.test.ts(4 用例)。aiFixEngine.ts 改用共享 fixPrompt(requestFix 内构造 ReviewIssueInput 适配 range→line)。fixSession.ts FixedEntry 加 source:'linter'|'custom'|'ai',recordFixes 加 source 参数;fixPending.ts PendingFix/PendingBatch 加 source。commands.ts 新增 resolveReviewIssueFix/findCustomIssue/findAIIssue,fixIssue 按 payload.source 分流(custom/ai 走 AI 重检),fixAll 分 tab 批量(payload.source),applyFixPreview/applyAllPreview recordFixes 传 source,refreshAfterFix 保留 custom suggestion(原映射丢 suggestion)。webview.ts custom/ai 列表启用 aiFixable 渲染修复按钮+各 tab fixAll 按钮传 source+已修复区块按 source 过滤与徽章+FixedEntryView 接口。删除 codeDiff:schema.ts 两字段、engine.ts 完整+方法审查 prompt 全部 codeDiff 行、report.ts ```diff``` 展示块。spec:docs/superpowers/specs/2026-08-20-custom-ai-fix-design.md。npm test 110 passing / lint 0 error / compile 通过 | 中间产物:①澄清阶段用户在「AI 生成后不验证」与「AI 重检收敛」间两轮选择,最终定为 AI 重检收敛(无 linter 可重 lint,用 AI 重检替代);②codeDiff 去留讨论——用户问「带着有需要吗」,说明其现状仅报告展示用,用户决定删除;③refreshAfterFix 的 customRuleResults 映射初版未含 suggestion,审查中发现会破坏上轮 custom 展开功能,补上;④aiFixEngine requestFix 传入 LinterDiagnostic 与 fixPrompt.ReviewIssueInput 类型不匹配(line vs range)编译报错 TS2345,改为构造 ReviewIssueInput | src/fix/fixPrompt.ts(新建) src/fix/customFixEngine.ts(新建) src/fix/aiFixEngine.ts src/fix/fixSession.ts src/fix/fixPending.ts src/activation/commands.ts src/panel/webview.ts src/ai/schema.ts src/ai/engine.ts src/utils/report.ts src/test/customFixEngine.test.ts(新建) docs/superpowers/specs/2026-08-20-custom-ai-fix-design.md(新建) | deepseek-v4-flash |
|
||||
| 2026-08-20 21:56 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复面板「修复」按钮卡 ⏳ 不恢复的 bug(实测:custom 修复失败 ai-no-fix 后按钮永久卡 ⏳、点取消后也卡 ⏳)。根因双处:①reviewPanel.js pending/batchPending 消息翻转只切 display,从不重置「修复」按钮的 textContent/disabled——点击时 JS 设 disabled=true+textContent='⏳...'(webview.ts buildIssueItem onclick),取消(on:false)恢复显示后仍停留在 ⏳+disabled;②fixIssue/fixAll 命令层失败路径(!result.success、success===0、catch)只 showWarningMessage 就 return,从不 postMessage 恢复按钮。修复:webview.ts buildIssueItem 修复按钮加 data-label 存原始文案(🔧 修复/🤖 AI 修复,esc 转义);reviewPanel.js pending 消息 on:false 时对非 confirm 按钮(data-label)恢复 textContent+disabled=false;commands.ts fixIssue custom/linter 分支失败+catch 补 postMessage {type:'pending',key,on:false}(key=ruleId@line),fixAll 两处 success===0+catch 补 {type:'batchPending',on:false}。npm test 110 passing / lint 0 error / compile 通过。ai-no-fix 与「聚焦文件才生效」的关系仍在排查(用户复现:不聚焦连点 3 次必现失败;聚焦后成功,但代码逻辑 panel 来源取文档不依赖焦点,待进一步定位) | 中间产物:①初版考虑在失败弹窗后靠 refreshAfterFix 全量重建面板恢复按钮,发现失败路径根本不走 refreshAfterFix,改显式 postMessage;②data-label 文案初版直接拼 emoji+t(),审查确认 esc() 转义 & < > " 后 dataset 读取自动解码安全;③是否给 catch 也补恢复曾有犹豫,确认异常路径同样需恢复故一并补上 | src/panel/webview.ts src/views/reviewPanel.js src/activation/commands.ts | deepseek-v4-flash |
|
||||
| 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 |
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
# 修复前 Diff 预览确认功能设计书
|
||||
|
||||
> 日期:2026-08-19
|
||||
> 状态:已批准(Stage ④ 通过);2026-08-19 修订确认交互为「面板内两步确认」
|
||||
> 流程:① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前自动修复(`src/fix/`)在用户点击「修复 / AI 修复 / 全部修复」后**立即写盘**,用户无法在写入前确认改动内容,误修后只能依赖撤销功能回退。
|
||||
|
||||
本次功能目标:在面板触发的修复**应用前**,用 VSCode 内置 diff 编辑器展示「修改前 → 修改后」的代码对比,用户确认后才真正写入。
|
||||
|
||||
## 2. 需求澄清结论(Stage ② 共识)
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 触发时机 | 修复**前**预览确认,确认后才写入 |
|
||||
| 展示方式 | VSCode 内置 diff 编辑器(`vscode.diff`,左旧右新) |
|
||||
| 预览范围 | 单个修复 + 全部修复都走预览 |
|
||||
| 全部修复 | 合并成一个 diff,确认后一次性应用 |
|
||||
| AI 修复 | 原生 fix 与 AI fix 两条路径都预览 |
|
||||
| 入口范围 | **仅面板按钮**预览;hover 快速修复保持直接应用(零行为变化) |
|
||||
| 确认交互 | **面板内两步确认**:点「修复」→ 打开 diff + 面板按钮变「应用/取消」(常驻不消失);回面板点「应用」才写入。「全部修复」同理(「全部应用/取消」) |
|
||||
|
||||
> 修订说明:初版设计为 diff + `showInformationMessage` 确认对话框,实测发现非模态通知几秒后自动消失(返回值 undefined 被当取消并关闭 diff)。改为面板内两步确认——按钮翻转用 webview 轻量消息完成,不全量重建面板,滚动/标签页不丢失。
|
||||
|
||||
## 3. 现状关键点(已核实)
|
||||
|
||||
- `fixEngine.ts:123-129` 与 `aiFixEngine.ts:164-170`:多轮循环在**内存字符串**上计算最终 `currentText`,最后一步才用整文档 `WorkspaceEdit` 写入。
|
||||
- 计算与写入天然可分,只需让引擎支持"只算不写"(`dryRun`)即可在写入前弹 diff。
|
||||
- 引擎内部已用 `mockDocument` 对内存文本执行 `adapter.check`,支持对累积文本反复计算,无需改核心算法。
|
||||
|
||||
## 4. 架构概览
|
||||
|
||||
```
|
||||
面板 fix/fixAll 消息 → codeReviewer.fixIssue / codeReviewer.fixAll
|
||||
│ (origin === 'panel')
|
||||
▼
|
||||
FixEngine/AiFixEngine (dryRun=true) → 返回最终 newText(不写盘)
|
||||
│
|
||||
▼
|
||||
fixPreview.openPreviewDiff() → vscode.diff 内置 diff 编辑器(左原文本 / 右新文本)
|
||||
│
|
||||
▼
|
||||
FixPendingStore.setSingle/setBatch + ReviewPanel.postMessage('pending'/'batchPending')
|
||||
│ 面板条目按钮翻转:「修复」→「应用 / 取消」
|
||||
▼
|
||||
codeReviewer.applyFixPreview / applyAllPreview ← 面板「应用」按钮
|
||||
│
|
||||
▼
|
||||
fixPreview.applyNewText() → 整文档 WorkspaceEdit 一次性写入 + recordFixes + save + refresh
|
||||
codeReviewer.cancelFixPreview / cancelAllPreview → 关闭 diff + 还原按钮(不写入)
|
||||
```
|
||||
|
||||
**数据流**:
|
||||
|
||||
1. `fixIssue`(panel)/ `fixAll` 以 `dryRun=true` 调用修复引擎,拿到最终 `newText`,**不写盘**。
|
||||
2. `openPreviewDiff` 用 `TextDocumentContentProvider`(scheme `codeReviewerPreview`)提供新文本,`vscode.diff` 打开内置 diff 编辑器。
|
||||
3. 结果存入 `FixPendingStore`,向面板 `postMessage` 翻转按钮(`pending` 单条 / `batchPending` 全部)——「修复」隐藏、「应用/取消」显示,**常驻不消失**。
|
||||
4. 用户回面板点「应用」→ `applyFixPreview`/`applyAllPreview` 整文档 `WorkspaceEdit` 写入、记录撤销会话、保存、刷新;点「取消」→ `cancelFixPreview`/`cancelAllPreview` 关闭 diff、还原按钮、不写入。
|
||||
|
||||
## 5. 文件变更清单
|
||||
|
||||
### 新建
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/fix/fixPreview.ts` | diff 内容提供者、`openPreviewDiff`、`closePreviewEditor`、`applyNewText` |
|
||||
| `src/fix/fixPending.ts` | 待确认修复存储(PendingFix / PendingBatch / FixPendingStore) |
|
||||
| `docs/superpowers/specs/2026-08-19-fix-preview-design.md` | 本设计书 |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/fix/fixEngine.ts` | `FixResult` 增 `newText?: string`;`fixDiagnostic` 增 `dryRun` 参数,为真时跳过写盘 |
|
||||
| `src/fix/aiFixEngine.ts` | `aiFixDiagnostic` 增 `dryRun` 参数,为真时跳过写盘并返回 `newText` |
|
||||
| `src/activation/commands.ts` | `fixIssue` panel 路径两步确认;`fixAll` 内存累积 + 合并 diff + 存 pending;新增 `applyFixPreview`/`cancelFixPreview`/`applyAllPreview`/`cancelAllPreview` 命令 |
|
||||
| `src/panel/webview.ts` | 消息类型增 `applyFix`/`cancelFix`/`applyAll`/`cancelAll`;条目与顶部渲染隐藏的应用/取消按钮;`postMessage` 公开方法 |
|
||||
| `src/views/reviewPanel.js` | `window.addEventListener('message')` 翻转按钮显隐 |
|
||||
| `src/extension.ts` | 创建/传递 `FixPendingStore`;文档关闭时清 pending |
|
||||
| `src/i18n/messages.ts` | 新增 `report.fixAllApply`;复用 `fix.apply` / `fix.cancel` / `fix.previewTitle` |
|
||||
|
||||
## 6. 关键接口定义
|
||||
|
||||
### 6.1 `FixResult`(fixEngine.ts)
|
||||
|
||||
```typescript
|
||||
export interface FixResult {
|
||||
success: boolean;
|
||||
attempts: number;
|
||||
message?: string;
|
||||
appliedFixes: AppliedFix[];
|
||||
newText?: string; // 新增:最终整文档文本(dryRun 时返回)
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 引擎 dryRun
|
||||
|
||||
```typescript
|
||||
export async function fixDiagnostic(
|
||||
document: vscode.TextDocument,
|
||||
workingDir: string,
|
||||
adapter: LinterAdapter,
|
||||
diag: LinterDiagnostic,
|
||||
maxIterations: number,
|
||||
dryRun?: boolean
|
||||
): Promise<FixResult>;
|
||||
|
||||
export async function aiFixDiagnostic(
|
||||
document, workingDir, adapter, diag, maxIterations,
|
||||
provider, options,
|
||||
dryRun?: boolean
|
||||
): Promise<FixResult>;
|
||||
```
|
||||
|
||||
- `dryRun=true`:跳过末尾 `WorkspaceEdit` 与 `applyEdit`,返回 `newText: currentText`。
|
||||
- `dryRun=false`(默认):保持现有写盘行为,**不返回** `newText`(或一并返回,调用方忽略)。
|
||||
- hover 入口仍走默认路径,行为零变化。
|
||||
|
||||
### 6.3 `src/fix/fixPreview.ts`
|
||||
|
||||
```typescript
|
||||
export class FixPreviewContentProvider implements vscode.TextDocumentContentProvider {
|
||||
provideTextDocumentContent(uri: vscode.Uri): string;
|
||||
set(uri: vscode.Uri, text: string): void;
|
||||
clear(uri: vscode.Uri): void;
|
||||
}
|
||||
|
||||
export interface PreviewRequest {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export async function openPreviewDiff(req: PreviewRequest): Promise<vscode.Uri | undefined>;
|
||||
// 打开内置 diff 编辑器,返回右侧 newUri(供后续关闭)
|
||||
|
||||
export async function closePreviewEditor(uri: vscode.Uri): Promise<void>;
|
||||
// 关闭对应 diff 编辑器标签并清理 provider 内容
|
||||
|
||||
export async function applyNewText(document: vscode.TextDocument, newText: string): Promise<boolean>;
|
||||
// 整文档 WorkspaceEdit 一次性写入(recordFixes 由调用方完成)
|
||||
```
|
||||
|
||||
### 6.4 `src/fix/fixPending.ts`
|
||||
|
||||
```typescript
|
||||
export interface PendingFix {
|
||||
key: string; // `${ruleId}@${line}`
|
||||
ruleId: string;
|
||||
line: number;
|
||||
filePath: string;
|
||||
originalText: string;
|
||||
newText: string;
|
||||
appliedFixes: AppliedFix[];
|
||||
diffUri?: vscode.Uri;
|
||||
}
|
||||
|
||||
export interface PendingBatch {
|
||||
filePath: string;
|
||||
originalText: string;
|
||||
newText: string;
|
||||
results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[];
|
||||
diffUri?: vscode.Uri;
|
||||
}
|
||||
|
||||
export class FixPendingStore {
|
||||
setSingle(fix: PendingFix): void;
|
||||
getSingle(filePath: string, key: string): PendingFix | undefined;
|
||||
deleteSingle(filePath: string, key: string): void;
|
||||
setBatch(batch: PendingBatch): void;
|
||||
getBatch(filePath: string): PendingBatch | undefined;
|
||||
deleteBatch(filePath: string): void;
|
||||
clear(filePath: string): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 6.5 面板消息协议
|
||||
|
||||
- 新增消息:`applyFix`/`cancelFix`(带 line/ruleId)→ `codeReviewer.applyFixPreview`/`cancelFixPreview`;`applyAll`/`cancelAll` → `applyAllPreview`/`cancelAllPreview`。
|
||||
- 扩展 → 面板:`{ type: 'pending', key, on: boolean }`(翻转单条按钮)、`{ type: 'batchPending', on: boolean }`(翻转顶部按钮)。
|
||||
|
||||
## 7. 核心逻辑
|
||||
|
||||
### 7.1 单个修复(fixIssue,panel origin)
|
||||
|
||||
```
|
||||
document = resolveFixDocument(...)
|
||||
diag = 命中诊断
|
||||
result = resolveFix(..., dryRun=true) // 计算,不写盘
|
||||
if !result.success → 提示失败,return
|
||||
key = `${ruleId}@${line}`
|
||||
existing = pendingStore.getSingle(filePath, key) // 重复预览先关旧 diff
|
||||
diffUri = openPreviewDiff({ originalText, newText, title, fileName })
|
||||
pendingStore.setSingle({ key, ruleId, line, filePath, originalText, newText, appliedFixes, diffUri })
|
||||
ReviewPanel.postMessage({ type:'pending', key, on:true }) // 面板按钮 → 「应用 / 取消」
|
||||
return // 不写盘
|
||||
```
|
||||
|
||||
用户回面板:
|
||||
- 点「应用」→ `applyFixPreview`:`applyNewText(document, newText)` → `recordFixes` → 关 diff → 删 pending → `save` → `refreshAfterFix` → 提示成功。
|
||||
- 点「取消」→ `cancelFixPreview`:关 diff → 删 pending → postMessage 还原按钮。
|
||||
|
||||
### 7.2 全部修复(fixAll)
|
||||
|
||||
```
|
||||
fixables = 可修复诊断列表(含 AI)
|
||||
let currentText = document.getText()
|
||||
results: { ruleId, line, appliedFixes }[] = []
|
||||
for each fixable:
|
||||
fresh = 对 currentText 构造 mock doc 重跑 check 得 freshDiag
|
||||
result = fixDiagnostic / aiFixDiagnostic(dryRun=true)
|
||||
if result.success: results.push(...); currentText = result.newText!
|
||||
diffUri = openPreviewDiff({ originalText, newText: currentText, title, fileName })
|
||||
pendingStore.setBatch({ filePath, originalText, newText, results, diffUri })
|
||||
ReviewPanel.postMessage({ type:'batchPending', on:true }) // 顶部按钮 → 「全部应用 / 取消」
|
||||
return // 不写盘
|
||||
```
|
||||
|
||||
- 「全部应用」→ `applyAllPreview`:`applyNewText(document, currentText)` → 逐条 `recordFixes` → 关 diff → 删 batch → `save` → `refreshAfterFix` → 提示批量完成。
|
||||
- 「取消全部」→ `cancelAllPreview`:关 diff → 删 batch → 还原按钮。
|
||||
|
||||
> 说明:多轮引擎已在内部对**传入文本**反复 `adapter.check`,因此 fixAll 只需把上一个 fix 的 `newText` 作为下一个的输入(用 mockDocument 包装为 TextDocument 再传引擎),即可得到合并后的最终文本;位置漂移由引擎内 `findClosestFixable` 按行号就近匹配处理。
|
||||
|
||||
### 7.3 diff 提供者
|
||||
|
||||
- 注册 `vscode.workspace.registerTextDocumentContentProvider('codeReviewerPreview', provider)`(extension.ts 调 `registerFixPreviewProvider` 一次)。
|
||||
- 新文本 URI:`codeReviewerPreview://new/<fileName>-<timestamp>`,内容即 `newText`;原文本 URI 同样走 provider(避免脏文档时左屏显示磁盘旧内容与引擎计算不一致)。
|
||||
|
||||
### 7.4 面板按钮翻转(reviewPanel.js)
|
||||
|
||||
```javascript
|
||||
window.addEventListener('message', e => {
|
||||
const msg = e.data;
|
||||
if (msg.type === 'pending') {
|
||||
// 按 data-fix-key 找到「修复 / 应用 / 取消」三个按钮,按 msg.on 显隐
|
||||
} else if (msg.type === 'batchPending') {
|
||||
// 按 data-fix-all-btn 找到「全部修复 / 全部应用 / 取消」三个按钮,按 msg.on 显隐
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
- 应用/取消按钮在 buildHtml 时默认 `display:none`,收到消息后显隐切换,**不全量重建面板**,滚动位置与标签页保持。
|
||||
|
||||
### 7.5 hover 入口保持不变
|
||||
|
||||
- `codeActionProvider.ts` 触发的 `fixIssue` 传 `origin: 'hover'`,命令内直接 `dryRun=false` 执行,不弹 diff、不弹确认框。
|
||||
|
||||
## 8. i18n 新增 key
|
||||
|
||||
| key | zh-CN | en | ja |
|
||||
|-----|-------|----|----|
|
||||
| `fix.previewTitle` | 修复预览 | Fix preview | 修正プレビュー |
|
||||
| `fix.apply` | 应用 | Apply | 適用 |
|
||||
| `fix.cancel` | 取消 | Cancel | キャンセル |
|
||||
| `report.fixAllApply` | 全部应用 | Apply All | すべて適用 |
|
||||
|
||||
## 9. 配置变更
|
||||
|
||||
无新增配置。
|
||||
|
||||
## 10. 测试计划
|
||||
|
||||
- 现有 `npm test`(106 tests)回归通过。
|
||||
- 引擎 dryRun 路径回归:原有 fixEngine/aiFixEngine 测试走默认(写盘)路径不受影响。
|
||||
|
||||
## 11. 验证顺序
|
||||
|
||||
`lint → compile → test`(`npm run lint` / `npm run compile` / `npm test`)
|
||||
@@ -0,0 +1,139 @@
|
||||
# 修复功能扩展到自定义规则与 AI 审查设计书
|
||||
|
||||
> 日期:2026-08-20
|
||||
> 状态:已批准(Stage ④ 通过)
|
||||
> 流程:① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前自动修复只支持静态分析(linter)条目:`fixIssue`/`fixAll` 从 `orchestrator.getAnalysisResult()` 取 linter 诊断,走 native fix 或 `aiFixDiagnostic`(依赖 LinterAdapter 重 lint 验证收敛)。
|
||||
|
||||
用户要求将修复能力扩展到**自定义规则(custom tab)**与 **AI 审查(ai tab)** 条目。
|
||||
|
||||
## 2. 需求澄清结论(Stage ② 共识)
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 修复范围 | 面板 custom + ai 条目标题均支持 AI 修复 |
|
||||
| 验证方式 | custom/ai 无对应 linter 适配器,无法重 lint 验证 → **AI 重检收敛**(新文本提交 AI 确认问题消除,未消除带反馈重试 ≤maxIterations) |
|
||||
| 入口交互 | **面板按钮 + 两步确认**(与 linter 一致:打开 diff → 应用/取消) |
|
||||
| 全部修复 | **分 tab 批量**:linter/custom/ai 各自有「全部修复」按钮 |
|
||||
| 修复引擎 | **新建 `customFixEngine.ts`**,复用 AI 修复 prompt,无 adapter 依赖 |
|
||||
| 修复后展示 | 各 tab 顶部显示「✅ 已修复 + 撤销」区块 |
|
||||
| ai codeDiff | **删除**该字段(schema/prompt/报告展示全部移除) |
|
||||
|
||||
## 3. 现状关键点(已核实)
|
||||
|
||||
- `fixIssue`(commands.ts:360)从 `orchestrator.getAnalysisResult()` 取 linter 诊断,`resolveFix` 依赖 adapter。
|
||||
- custom 诊断在 `currentReport.customRuleDiagnostics`(`LinterDiagnostic[]`);ai 诊断在 `currentReport.aiFindings`(`AIFinding[]`,`f.line` 已转 0-based)。
|
||||
- `aiFixEngine.requestFix`(依赖 provider/options/diag/context)可复用;其收敛验证 `adapter.check` 对 custom/ai 不可用。
|
||||
- 两步确认基础设施已完备:`fixPending.ts`、`openPreviewDiff`/`closePreviewEditor`/`applyNewText`、4 个 preview 命令、`reviewPanel.js` 按钮翻转、`pendingStore`。
|
||||
- `codeDiff` 字段定义于 schema,唯一用途是 report.ts 导出 Markdown 时展示 ` ```diff ` 块,从未用于真实修复。
|
||||
|
||||
## 4. 架构概览
|
||||
|
||||
```
|
||||
面板 custom/ai 条目标题「🤖 AI 修复」 / 各 tab「全部修复」
|
||||
│ send('fix', line, ruleId, 'custom'|'ai') / send('fixAll', ..., 'custom'|'ai')
|
||||
▼
|
||||
codeReviewer.fixIssue / fixAll (payload.source 分流)
|
||||
│ source === 'custom'|'ai'
|
||||
▼
|
||||
findCustomIssue / findAIIssue 从 currentReport 定位 ReviewIssueInput
|
||||
│
|
||||
▼
|
||||
customFixEngine.aiFixReviewIssue(document, diag, maxIterations, provider, options, dryRun)
|
||||
每轮:buildFixContext → requestFix(AI 生成 {originalText,newText}) → indexOf 匹配替换
|
||||
→ verifyFixed(AI 重检 {fixed,reason}) → 未消除带反馈重试 ≤maxIterations
|
||||
│ dryRun=true 返回 newText(不写盘)
|
||||
▼
|
||||
两步确认:openPreviewDiff + pendingStore + postMessage 翻转按钮
|
||||
▼ 面板「应用」→ applyFixPreview/applyAllPreview → applyNewText + recordFixes(source) + save + refresh
|
||||
```
|
||||
|
||||
## 5. 文件变更清单
|
||||
|
||||
### 新建
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/fix/fixPrompt.ts` | 共享修复/重检 prompt builder(三语):`buildFixSystemPrompt`/`buildFixUserPrompt`/`buildFixContext`/`buildVerifySystemPrompt`/`buildVerifyUserPrompt`,`ReviewIssueInput` 接口 |
|
||||
| `src/fix/customFixEngine.ts` | `aiFixReviewIssue`:AI 生成修复 + AI 重检收敛,无 adapter 依赖,支持 dryRun |
|
||||
| `src/test/customFixEngine.test.ts` | 4 用例(收敛/匹配失败/重试收敛/最大轮次失败) |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/fix/aiFixEngine.ts` | 移除内联 prompt builder,改用共享 fixPrompt(行为不变) |
|
||||
| `src/fix/fixSession.ts` | `FixedEntry.source: 'linter'|'custom'|'ai'`;`recordFixes` 加 source 参数(默认 linter) |
|
||||
| `src/fix/fixPending.ts` | `PendingFix`/`PendingBatch` 加 `source` 字段 |
|
||||
| `src/activation/commands.ts` | `resolveReviewIssueFix`/`findCustomIssue`/`findAIIssue`;`fixIssue` 按 source 分流(custom/ai 走 AI 重检);`fixAll` 分 tab 批量;`applyFixPreview`/`applyAllPreview` recordFixes 传 source;`refreshAfterFix` 保留 custom suggestion |
|
||||
| `src/panel/webview.ts` | custom/ai 列表启用 AI 修复按钮 + 各自「全部修复」按钮 + 已修复区块(按 source 过滤/徽章);`handleMessage.fixAll` 传 source;`FixedEntryView` 接口 |
|
||||
| `src/ai/schema.ts` | 删 `TranslatedDiagnostic.codeDiff`、`AIFinding.codeDiff` |
|
||||
| `src/ai/engine.ts` | 删完整审查 + 方法审查 prompt 中的 codeDiff 要求 |
|
||||
| `src/utils/report.ts` | 删 Markdown 导出的 ` ```diff ` codeDiff 块 |
|
||||
|
||||
## 6. 关键接口
|
||||
|
||||
### 6.1 `src/fix/fixPrompt.ts`
|
||||
|
||||
```typescript
|
||||
export interface ReviewIssueInput {
|
||||
ruleId: string;
|
||||
line: number; // 0-based
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
export function buildFixSystemPrompt(): string;
|
||||
export function buildFixUserPrompt(diag: ReviewIssueInput, context: string): string;
|
||||
export function buildFixContext(code: string, line: number): string;
|
||||
export function buildVerifySystemPrompt(): string;
|
||||
export function buildVerifyUserPrompt(diag: ReviewIssueInput, code: string): string;
|
||||
```
|
||||
|
||||
### 6.2 `src/fix/customFixEngine.ts`
|
||||
|
||||
```typescript
|
||||
export async function aiFixReviewIssue(
|
||||
document: vscode.TextDocument,
|
||||
diag: ReviewIssueInput,
|
||||
maxIterations: number,
|
||||
provider: AIProvider,
|
||||
options: ChatOptions,
|
||||
dryRun?: boolean
|
||||
): Promise<FixResult>; // 复用 FixResult/AppliedFix,无 adapter
|
||||
```
|
||||
|
||||
流程:每轮 `requestFix`(AI 生成 `{originalText,newText}`)→ `indexOf` 匹配 → 替换 → `verifyFixed`(AI 重检 `{fixed,reason}`)→ 未消除带 reason 反馈重试。
|
||||
|
||||
### 6.3 命令协议
|
||||
|
||||
- `fixIssue` payload.source:`'linter'|'custom'|'ai'`(webview 已传),命令分流。
|
||||
- `fixAll` payload:`{ source?: 'linter'|'custom'|'ai' }`,分 tab 批量。
|
||||
- 面板消息 `fixAll` 透传 source:`send('fixAll', undefined, undefined, 'custom')`。
|
||||
|
||||
### 6.4 面板
|
||||
|
||||
- custom/ai 条目:`buildIssueItem(..., aiFixable=true)` → 显示「🤖 AI 修复」+ 隐藏的应用/取消按钮(复用现有两步确认)。
|
||||
- 各 tab 顶部「全部修复」→ `send('fixAll', ..., source)`。
|
||||
- 已修复区块:`fixedEntries` 按 `source` 过滤 + `buildFixedItem` 按 source 渲染徽章;custom/ai 列表用 fixedKeys 过滤已修条目。
|
||||
|
||||
## 7. 删除 codeDiff
|
||||
|
||||
- schema.ts:`TranslatedDiagnostic.codeDiff`、`AIFinding.codeDiff` 移除。
|
||||
- engine.ts:完整审查(zh/en/ja)与方法审查(En/Zh/Ja)prompt 中 `"codeDiff"` 要求行全部移除。
|
||||
- report.ts:`reportToMarkdown` 中 ` ```diff ` 代码块展示移除。
|
||||
|
||||
## 8. i18n
|
||||
|
||||
无需新增 key(复用 `fix.aiRunning`/`fix.aiFailed`/`fix.noAI`/`report.fixAll`/`report.fixAILabel`/`report.fixedIssues`/`report.undoFix`)。
|
||||
|
||||
## 9. 测试计划
|
||||
|
||||
- 新增 `customFixEngine.test.ts` 4 用例:收敛、匹配失败、重检后重试收敛、最大轮次失败。
|
||||
- 现有 106 用例回归通过(共 110 passing)。
|
||||
|
||||
## 10. 验证顺序
|
||||
|
||||
`lint → compile → test`(`npm run lint` / `npm run compile` / `npm test`)。
|
||||
@@ -0,0 +1,50 @@
|
||||
# 自定义规则条目展开显示 AI 修复建议功能设计书
|
||||
|
||||
> 日期:2026-08-20
|
||||
> 状态:已批准(Stage ④ 通过)
|
||||
> 流程:① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
审查面板「自定义规则」(custom tab)条目当前**不可展开**:点击无反应、无 AI 修复建议详情。用户期望自定义规则条目与静态分析(linter)条目一致,展开后显示 AI 给出的修复建议(💡 建议文本)。
|
||||
|
||||
## 2. 需求澄清结论(Stage ② 共识)
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 实现程度 | 展开显示 AI 修复建议**文本**(💡),不提供 AI 修复改代码按钮 |
|
||||
| 展示方式 | 复用现有 `buildIssueItem` 的 `detail-suggestion` 展开详情 |
|
||||
| 入口 | 完整审查 + 方法审查两条路径的自定义规则条目 |
|
||||
|
||||
## 3. 现状关键点(已核实)
|
||||
|
||||
- `CustomRuleResult`(schema.ts:8-13)仅含 `ruleId/line/severity/message`,无 `suggestion` 字段。
|
||||
- 自定义规则 AI prompt(engine.ts `buildCustomRuleSystemPrompt` 完整审查、`buildMethodSystemPrompt*` 方法审查)输出 JSON 格式均无 `suggestion`。
|
||||
- `merger.ts:92-97` 映射 custom 诊断时未传 `suggestion` → `LinterDiagnostic.suggestion` 为空。
|
||||
- `webview.ts:365` `buildIssueItem(..., d.suggestion, false, false)` 因 `suggestion` 为空,展开条件 `suggestion && suggestion !== message` 不满足 → 不可展开。
|
||||
|
||||
## 4. 方案
|
||||
|
||||
### 4.1 `src/ai/schema.ts`
|
||||
`CustomRuleResult` 增加可选字段 `suggestion?: string`。
|
||||
|
||||
### 4.2 `src/ai/engine.ts`
|
||||
- `buildCustomRuleSystemPrompt`(完整审查,zh/en/ja 三段):输出 JSON 格式加 `"suggestion"` 字段,并要求每条违规必须给出可执行的修复建议。
|
||||
- `buildMethodSystemPromptEn/Zh/Ja`(方法审查):`customRuleResults` 输出段加 `"suggestion"` 字段。
|
||||
- `runAIReview` / `runMethodReview` 已用 `...r` 展开映射,suggestion 自动透传,无需改动。
|
||||
|
||||
### 4.3 `src/merger/merger.ts`
|
||||
custom 诊断映射补 `suggestion: r.suggestion`。
|
||||
|
||||
### 4.4 `src/panel/webview.ts`
|
||||
无需改动——`buildCustomList` 已传 `d.suggestion`,suggestion 非空时 `buildIssueItem` 展开条件即满足,复用现有 `detail-suggestion` 渲染。
|
||||
|
||||
## 5. 影响面与风险
|
||||
|
||||
- **兼容性**:`suggestion` 为可选字段,AI 未返回时条目不可展开(与现状一致),零回归。
|
||||
- **AI 一致性**:仅提示词要求,AI 不保证 100% 返回——有则展开,无则不展开。
|
||||
- 不新增 i18n;hover 快速修复、AI 修复按钮、两步确认逻辑均不受影响。
|
||||
|
||||
## 6. 验证
|
||||
|
||||
`lint → compile → test`(`npm run lint` / `npm run compile` / `npm test`)。
|
||||
+433
-22
@@ -5,7 +5,7 @@ import { loadActiveRules } from '../rules/yaml-parser';
|
||||
import { filterAndSummarize, filterForDocument } from '../rules/rule-filter';
|
||||
import { mergeResults, MergedReport } from '../merger/merger';
|
||||
import { reportToMarkdown } from '../utils/report';
|
||||
import { getApiKey } from '../config';
|
||||
import { getApiKey, getAIProvider, getAIBaseUrl, getAIModel, getAITemperature, getAITimeout, getAIMaxTokens, getFixMaxIterations } from '../config';
|
||||
import { ReviewPanel } from '../panel/webview';
|
||||
import { t } from '../i18n/messages';
|
||||
import { exportTemplate } from '../rules/export-service';
|
||||
@@ -13,10 +13,17 @@ import { extractMethodScope } from '../scope/method-extractor';
|
||||
import { ReviewStatusCache } from '../scope/status-cache';
|
||||
import { MethodCodeLensProvider } from '../views/codeLensProvider';
|
||||
import { DiagnosticMarkers, isMarkersEnabled } from '../diagnostics/diagnosticMarkers';
|
||||
import { fixDiagnostic } from '../fix/fixEngine';
|
||||
import { fixDiagnostic, type FixResult, type AppliedFix } from '../fix/fixEngine';
|
||||
import { aiFixDiagnostic } from '../fix/aiFixEngine';
|
||||
import { aiFixReviewIssue } from '../fix/customFixEngine';
|
||||
import type { ReviewIssueInput } from '../fix/fixPrompt';
|
||||
import { FixSessionManager } from '../fix/fixSession';
|
||||
import { getFixMaxIterations } from '../config';
|
||||
import type { CustomRule } from '../types';
|
||||
import { registerFixPreviewProvider, openPreviewDiff, closePreviewEditor, applyNewText } from '../fix/fixPreview';
|
||||
import { FixPendingStore } from '../fix/fixPending';
|
||||
import { mockDocument } from '../utils/mockDocument';
|
||||
import { createProvider } from '../ai/factory';
|
||||
import type { AIProvider } from '../ai/providers/base';
|
||||
import type { LinterAdapter, LinterDiagnostic, CustomRule } from '../types';
|
||||
|
||||
let currentReport: MergedReport | null = null;
|
||||
|
||||
@@ -32,6 +39,88 @@ function resolveFixDocument(
|
||||
return active?.document;
|
||||
}
|
||||
|
||||
async function createFixProvider(context: vscode.ExtensionContext): Promise<AIProvider | null> {
|
||||
const apiKey = await getApiKey(context);
|
||||
if (!apiKey) { return null; }
|
||||
try {
|
||||
return createProvider(getAIProvider(), apiKey, getAIBaseUrl(), context.extensionUri);
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] create fix provider failed:', err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveFix(
|
||||
context: vscode.ExtensionContext,
|
||||
document: vscode.TextDocument,
|
||||
workingDir: string,
|
||||
adapter: LinterAdapter,
|
||||
diag: LinterDiagnostic,
|
||||
maxIterations: number,
|
||||
dryRun?: boolean
|
||||
): Promise<FixResult> {
|
||||
if (diag.fix) {
|
||||
return fixDiagnostic(document, workingDir, adapter, diag, maxIterations, dryRun);
|
||||
}
|
||||
const provider = await createFixProvider(context);
|
||||
if (!provider) {
|
||||
return { success: false, attempts: 0, message: 'ai-unavailable', appliedFixes: [] };
|
||||
}
|
||||
return aiFixDiagnostic(document, workingDir, adapter, diag, maxIterations, provider, {
|
||||
model: getAIModel(),
|
||||
temperature: getAITemperature(),
|
||||
maxTokens: getAIMaxTokens(),
|
||||
timeoutMs: getAITimeout() * 1000,
|
||||
}, dryRun);
|
||||
}
|
||||
|
||||
async function resolveReviewIssueFix(
|
||||
context: vscode.ExtensionContext,
|
||||
document: vscode.TextDocument,
|
||||
diag: ReviewIssueInput,
|
||||
maxIterations: number,
|
||||
dryRun?: boolean
|
||||
): Promise<FixResult> {
|
||||
const provider = await createFixProvider(context);
|
||||
if (!provider) {
|
||||
return { success: false, attempts: 0, message: 'ai-unavailable', appliedFixes: [] };
|
||||
}
|
||||
return aiFixReviewIssue(document, diag, maxIterations, provider, {
|
||||
model: getAIModel(),
|
||||
temperature: getAITemperature(),
|
||||
maxTokens: getAIMaxTokens(),
|
||||
timeoutMs: getAITimeout() * 1000,
|
||||
}, dryRun);
|
||||
}
|
||||
|
||||
function findCustomIssue(ruleId?: string, line?: number): ReviewIssueInput | undefined {
|
||||
if (!currentReport) { return undefined; }
|
||||
const d = currentReport.customRuleDiagnostics.find(d =>
|
||||
d.ruleId === ruleId && (line === undefined || d.range.start.line === line)
|
||||
);
|
||||
if (!d) { return undefined; }
|
||||
return {
|
||||
ruleId: d.ruleId,
|
||||
line: d.range.start.line,
|
||||
message: d.message,
|
||||
suggestion: d.suggestion,
|
||||
};
|
||||
}
|
||||
|
||||
function findAIIssue(ruleId?: string, line?: number): ReviewIssueInput | undefined {
|
||||
if (!currentReport) { return undefined; }
|
||||
const f = currentReport.aiFindings.find(f =>
|
||||
f.ruleId === ruleId && (line === undefined || f.line === line)
|
||||
);
|
||||
if (!f) { return undefined; }
|
||||
return {
|
||||
ruleId: f.ruleId,
|
||||
line: f.line,
|
||||
message: f.title,
|
||||
suggestion: f.suggestion,
|
||||
};
|
||||
}
|
||||
|
||||
async function refreshAfterFix(
|
||||
document: vscode.TextDocument,
|
||||
orchestrator: Orchestrator,
|
||||
@@ -55,6 +144,7 @@ async function refreshAfterFix(
|
||||
ruleId: d.ruleId,
|
||||
severity: d.severity,
|
||||
message: d.message,
|
||||
suggestion: d.suggestion,
|
||||
line: d.range.start.line + 1,
|
||||
})),
|
||||
translatedDiagnostics: currentReport.translatedDiagnostics,
|
||||
@@ -65,6 +155,7 @@ async function refreshAfterFix(
|
||||
filePath: document.uri.fsPath,
|
||||
language: document.languageId,
|
||||
adapterIds: result.adapterIds,
|
||||
aiFixAvailable: currentReport.aiFixAvailable,
|
||||
customRuleFilterInfo: currentReport.customRuleFilterInfo,
|
||||
});
|
||||
const panel = ReviewPanel.createOrShow(extensionUri);
|
||||
@@ -94,6 +185,7 @@ export function registerCommands(
|
||||
statusCache: ReviewStatusCache,
|
||||
markers: DiagnosticMarkers,
|
||||
fixSession: FixSessionManager,
|
||||
pendingStore: FixPendingStore,
|
||||
): void {
|
||||
|
||||
context.subscriptions.push(
|
||||
@@ -106,6 +198,7 @@ export function registerCommands(
|
||||
|
||||
const document = editor.document;
|
||||
fixSession.clear(document.uri);
|
||||
pendingStore.clear(document.fileName);
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
|
||||
@@ -126,6 +219,8 @@ export function registerCommands(
|
||||
const code = document.getText();
|
||||
const aiResult = await runAIReview(context, code, staticResult.diagnostics, filterResult.relevant);
|
||||
|
||||
const aiFixAvailable = !!(await getApiKey(context));
|
||||
|
||||
currentReport = mergeResults({
|
||||
staticDiagnostics: staticResult.diagnostics,
|
||||
customRuleResults: aiResult.customRuleResults,
|
||||
@@ -137,6 +232,7 @@ export function registerCommands(
|
||||
filePath: document.uri.fsPath,
|
||||
language: document.languageId,
|
||||
adapterIds: staticResult.adapterIds,
|
||||
aiFixAvailable,
|
||||
customRuleFilterInfo: {
|
||||
totalActive: allRules.length,
|
||||
injected: filterResult.relevant.length,
|
||||
@@ -238,6 +334,7 @@ export function registerCommands(
|
||||
filePath: document.uri.fsPath,
|
||||
language: document.languageId,
|
||||
adapterIds: [],
|
||||
aiFixAvailable: false,
|
||||
customRuleFilterInfo: undefined,
|
||||
});
|
||||
|
||||
@@ -312,8 +409,72 @@ export function registerCommands(
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixIssue', async (payload?: { line?: number; ruleId?: string; source?: string; origin?: 'hover' | 'panel' }) => {
|
||||
try {
|
||||
const source = payload?.source === 'custom' || payload?.source === 'ai' ? payload.source : 'linter';
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, payload?.origin);
|
||||
if (!document) { console.log('[code-reviewer] fixIssue: no target document'); return; }
|
||||
|
||||
const isPreview = payload?.origin === 'panel';
|
||||
const maxIterations = getFixMaxIterations();
|
||||
|
||||
if (source === 'custom' || source === 'ai') {
|
||||
const reviewDiag = source === 'custom'
|
||||
? findCustomIssue(payload?.ruleId, payload?.line)
|
||||
: findAIIssue(payload?.ruleId, payload?.line);
|
||||
if (!reviewDiag) {
|
||||
vscode.window.showWarningMessage(t('fix.noFix'));
|
||||
return;
|
||||
}
|
||||
const result = await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: t('fix.aiRunning'),
|
||||
cancellable: false,
|
||||
}, () => resolveReviewIssueFix(context, document, reviewDiag, maxIterations, isPreview));
|
||||
if (!result.success) {
|
||||
const msg = result.message === 'ai-unavailable'
|
||||
? t('fix.noAI')
|
||||
: t('fix.aiFailed', { 0: result.message ?? '' });
|
||||
vscode.window.showWarningMessage(msg);
|
||||
if (payload?.ruleId) {
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${payload.ruleId}@${payload.line ?? -1}`, on: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
const newText = result.newText ?? document.getText();
|
||||
const key = `${reviewDiag.ruleId}@${reviewDiag.line}`;
|
||||
const existing = pendingStore.getSingle(document.fileName, key);
|
||||
if (existing?.diffUri) {
|
||||
await closePreviewEditor(existing.diffUri);
|
||||
}
|
||||
const diffUri = await openPreviewDiff({
|
||||
originalText: document.getText(),
|
||||
newText,
|
||||
title: `${t('fix.previewTitle')}: ${reviewDiag.ruleId} @ ${reviewDiag.line + 1}`,
|
||||
fileName: document.fileName,
|
||||
});
|
||||
pendingStore.setSingle({
|
||||
key,
|
||||
ruleId: reviewDiag.ruleId,
|
||||
line: reviewDiag.line,
|
||||
filePath: document.fileName,
|
||||
source,
|
||||
originalText: document.getText(),
|
||||
newText,
|
||||
appliedFixes: result.appliedFixes,
|
||||
diffUri,
|
||||
});
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: true });
|
||||
return;
|
||||
}
|
||||
|
||||
fixSession.recordFixes(document.uri, reviewDiag.ruleId, reviewDiag.line, result.appliedFixes, source);
|
||||
await document.save();
|
||||
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
|
||||
vscode.window.showInformationMessage(t('fix.applied'));
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = orchestrator.getAnalysisResult(document.uri);
|
||||
if (!cached) { console.log(`[code-reviewer] fixIssue: no cached analysis for ${document.uri.toString()}`); return; }
|
||||
|
||||
@@ -325,7 +486,7 @@ export function registerCommands(
|
||||
const line = payload?.line;
|
||||
const ruleId = payload?.ruleId;
|
||||
const diag = cached.diagnostics.find(d =>
|
||||
d.ruleId === ruleId && d.fix && (line === undefined || d.range.start.line === line)
|
||||
d.ruleId === ruleId && (line === undefined || d.range.start.line === line)
|
||||
) ?? cached.diagnostics.find(d => d.fix);
|
||||
|
||||
if (!diag) {
|
||||
@@ -333,10 +494,50 @@ export function registerCommands(
|
||||
return;
|
||||
}
|
||||
|
||||
const maxIterations = getFixMaxIterations();
|
||||
const result = await fixDiagnostic(document, workingDir, adapter, diag, maxIterations);
|
||||
const needsAi = !diag.fix;
|
||||
const result = await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: needsAi ? t('fix.aiRunning') : t('fix.running'),
|
||||
cancellable: false,
|
||||
}, () => resolveFix(context, document, workingDir, adapter, diag, maxIterations, isPreview));
|
||||
if (!result.success) {
|
||||
vscode.window.showWarningMessage(t('fix.failed', { 0: result.message ?? '' }));
|
||||
const msg = diag.fix
|
||||
? t('fix.failed', { 0: result.message ?? '' })
|
||||
: (result.message === 'ai-unavailable'
|
||||
? t('fix.noAI')
|
||||
: t('fix.aiFailed', { 0: result.message ?? '' }));
|
||||
vscode.window.showWarningMessage(msg);
|
||||
if (ruleId) {
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${ruleId}@${line ?? -1}`, on: false });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPreview) {
|
||||
const newText = result.newText ?? document.getText();
|
||||
const key = `${diag.ruleId}@${diag.range.start.line}`;
|
||||
const existing = pendingStore.getSingle(document.fileName, key);
|
||||
if (existing?.diffUri) {
|
||||
await closePreviewEditor(existing.diffUri);
|
||||
}
|
||||
const diffUri = await openPreviewDiff({
|
||||
originalText: document.getText(),
|
||||
newText,
|
||||
title: `${t('fix.previewTitle')}: ${diag.ruleId} @ ${diag.range.start.line + 1}`,
|
||||
fileName: document.fileName,
|
||||
});
|
||||
pendingStore.setSingle({
|
||||
key,
|
||||
ruleId: diag.ruleId,
|
||||
line: diag.range.start.line,
|
||||
filePath: document.fileName,
|
||||
source: 'linter',
|
||||
originalText: document.getText(),
|
||||
newText,
|
||||
appliedFixes: result.appliedFixes,
|
||||
diffUri,
|
||||
});
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: true });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -349,15 +550,97 @@ export function registerCommands(
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] fixIssue failed:', err);
|
||||
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
|
||||
if (payload?.ruleId) {
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key: `${payload.ruleId}@${payload.line ?? -1}`, on: false });
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixAll', async () => {
|
||||
vscode.commands.registerCommand('codeReviewer.fixAll', async (payload?: { source?: 'linter' | 'custom' | 'ai' }) => {
|
||||
try {
|
||||
const source = payload?.source === 'custom' || payload?.source === 'ai' ? payload.source : 'linter';
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
|
||||
if (!document) { console.log('[code-reviewer] fixAll: no target document'); return; }
|
||||
const maxIterations = getFixMaxIterations();
|
||||
const aiAvailable = !!(await getApiKey(context));
|
||||
|
||||
if (source === 'custom' || source === 'ai') {
|
||||
if (!aiAvailable) {
|
||||
vscode.window.showWarningMessage(t('fix.noAI'));
|
||||
return;
|
||||
}
|
||||
const issues: ReviewIssueInput[] = source === 'custom'
|
||||
? (currentReport?.customRuleDiagnostics ?? []).map(d => ({
|
||||
ruleId: d.ruleId,
|
||||
line: d.range.start.line,
|
||||
message: d.message,
|
||||
suggestion: d.suggestion,
|
||||
}))
|
||||
: (currentReport?.aiFindings ?? []).map(f => ({
|
||||
ruleId: f.ruleId,
|
||||
line: f.line,
|
||||
message: f.title,
|
||||
suggestion: f.suggestion,
|
||||
}));
|
||||
if (issues.length === 0) {
|
||||
vscode.window.showInformationMessage(t('fix.noFix'));
|
||||
return;
|
||||
}
|
||||
|
||||
let currentText = document.getText();
|
||||
const results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[] = [];
|
||||
let success = 0;
|
||||
let skipped = 0;
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: t('fix.running'),
|
||||
cancellable: false,
|
||||
}, async (progress) => {
|
||||
for (let i = 0; i < issues.length; i++) {
|
||||
const issue = issues[i];
|
||||
progress.report({ message: `${t('fix.progress')} ${i + 1}/${issues.length}` });
|
||||
const mock = mockDocument(currentText, document.languageId, document.fileName);
|
||||
const result = await resolveReviewIssueFix(context, mock, issue, maxIterations, true);
|
||||
if (result.success && result.newText && result.newText !== currentText) {
|
||||
results.push({ ruleId: issue.ruleId, line: issue.line, appliedFixes: result.appliedFixes });
|
||||
currentText = result.newText;
|
||||
success++;
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (success === 0) {
|
||||
vscode.window.showWarningMessage(t('fix.failed', { 0: 'no-fix-applied' }));
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = pendingStore.getBatch(document.fileName);
|
||||
if (batch?.diffUri) {
|
||||
await closePreviewEditor(batch.diffUri);
|
||||
}
|
||||
const diffUri = await openPreviewDiff({
|
||||
originalText: document.getText(),
|
||||
newText: currentText,
|
||||
title: t('fix.previewTitle'),
|
||||
fileName: document.fileName,
|
||||
});
|
||||
pendingStore.setBatch({
|
||||
filePath: document.fileName,
|
||||
source,
|
||||
originalText: document.getText(),
|
||||
newText: currentText,
|
||||
results,
|
||||
diffUri,
|
||||
});
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = orchestrator.getAnalysisResult(document.uri);
|
||||
if (!cached) { console.log(`[code-reviewer] fixAll: no cached analysis for ${document.uri.toString()}`); return; }
|
||||
|
||||
@@ -366,13 +649,14 @@ export function registerCommands(
|
||||
const adapter = orchestrator.getAdapter(cached.adapterId);
|
||||
if (!adapter) { console.log(`[code-reviewer] fixAll: adapter not found: ${cached.adapterId}`); return; }
|
||||
|
||||
const fixables = cached.diagnostics.filter(d => d.fix);
|
||||
const fixables = cached.diagnostics.filter(d => d.fix || (aiAvailable && !d.ruleId.startsWith('sqlfluff:')));
|
||||
if (fixables.length === 0) {
|
||||
vscode.window.showInformationMessage(t('fix.noFix'));
|
||||
return;
|
||||
}
|
||||
|
||||
const maxIterations = getFixMaxIterations();
|
||||
let currentText = document.getText();
|
||||
const results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[] = [];
|
||||
let success = 0;
|
||||
let skipped = 0;
|
||||
await vscode.window.withProgress({
|
||||
@@ -383,27 +667,52 @@ export function registerCommands(
|
||||
for (let i = 0; i < fixables.length; i++) {
|
||||
const diag = fixables[i];
|
||||
progress.report({ message: `${t('fix.progress')} ${i + 1}/${fixables.length}` });
|
||||
const fresh = orchestrator.getAnalysisResult(document.uri);
|
||||
const freshDiag = fresh?.diagnostics.find(d =>
|
||||
d.ruleId === diag.ruleId && d.range.start.line === diag.range.start.line && d.fix
|
||||
) ?? diag;
|
||||
if (!freshDiag || !freshDiag.fix) { skipped++; continue; }
|
||||
const result = await fixDiagnostic(document, workingDir, adapter, freshDiag, maxIterations);
|
||||
if (result.success) {
|
||||
fixSession.recordFixes(document.uri, freshDiag.ruleId, freshDiag.range.start.line, result.appliedFixes);
|
||||
const mock = mockDocument(currentText, document.languageId, document.fileName);
|
||||
const fresh = await adapter.check(mock, workingDir);
|
||||
const freshDiag = fresh.diagnostics.find(d =>
|
||||
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);
|
||||
if (result.success && result.newText && result.newText !== currentText) {
|
||||
results.push({ ruleId: freshDiag.ruleId, line: freshDiag.range.start.line, appliedFixes: result.appliedFixes });
|
||||
currentText = result.newText;
|
||||
success++;
|
||||
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await document.save();
|
||||
vscode.window.showInformationMessage(t('fix.allComplete', { 0: String(success), 1: String(skipped) }));
|
||||
if (success === 0) {
|
||||
vscode.window.showWarningMessage(t('fix.failed', { 0: 'no-fix-applied' }));
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = pendingStore.getBatch(document.fileName);
|
||||
if (batch?.diffUri) {
|
||||
await closePreviewEditor(batch.diffUri);
|
||||
}
|
||||
const diffUri = await openPreviewDiff({
|
||||
originalText: document.getText(),
|
||||
newText: currentText,
|
||||
title: t('fix.previewTitle'),
|
||||
fileName: document.fileName,
|
||||
});
|
||||
pendingStore.setBatch({
|
||||
filePath: document.fileName,
|
||||
source: 'linter',
|
||||
originalText: document.getText(),
|
||||
newText: currentText,
|
||||
results,
|
||||
diffUri,
|
||||
});
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: true });
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] fixAll failed:', err);
|
||||
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -433,6 +742,108 @@ export function registerCommands(
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.applyFixPreview', async (payload?: { line?: number; ruleId?: string }) => {
|
||||
try {
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
|
||||
if (!document) { console.log('[code-reviewer] applyFixPreview: no target document'); return; }
|
||||
const ruleId = payload?.ruleId ?? '';
|
||||
const line = payload?.line ?? -1;
|
||||
if (!ruleId) { return; }
|
||||
const key = `${ruleId}@${line}`;
|
||||
const pending = pendingStore.getSingle(document.fileName, key);
|
||||
if (!pending) { return; }
|
||||
|
||||
const applied = await applyNewText(document, pending.newText);
|
||||
if (!applied) {
|
||||
vscode.window.showWarningMessage(t('fix.failed', { 0: 'apply-failed' }));
|
||||
return;
|
||||
}
|
||||
fixSession.recordFixes(document.uri, pending.ruleId, pending.line, pending.appliedFixes, pending.source);
|
||||
if (pending.diffUri) {
|
||||
await closePreviewEditor(pending.diffUri);
|
||||
}
|
||||
pendingStore.deleteSingle(document.fileName, key);
|
||||
await document.save();
|
||||
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
|
||||
vscode.window.showInformationMessage(t('fix.applied'));
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] applyFixPreview failed:', err);
|
||||
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.cancelFixPreview', async (payload?: { line?: number; ruleId?: string }) => {
|
||||
try {
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
|
||||
if (!document) { console.log('[code-reviewer] cancelFixPreview: no target document'); return; }
|
||||
const ruleId = payload?.ruleId ?? '';
|
||||
const line = payload?.line ?? -1;
|
||||
if (!ruleId) { return; }
|
||||
const key = `${ruleId}@${line}`;
|
||||
const pending = pendingStore.getSingle(document.fileName, key);
|
||||
if (!pending) { return; }
|
||||
if (pending.diffUri) {
|
||||
await closePreviewEditor(pending.diffUri);
|
||||
}
|
||||
pendingStore.deleteSingle(document.fileName, key);
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'pending', key, on: false });
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] cancelFixPreview failed:', err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.applyAllPreview', async () => {
|
||||
try {
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
|
||||
if (!document) { console.log('[code-reviewer] applyAllPreview: no target document'); return; }
|
||||
const batch = pendingStore.getBatch(document.fileName);
|
||||
if (!batch) { return; }
|
||||
|
||||
const applied = await applyNewText(document, batch.newText);
|
||||
if (!applied) {
|
||||
vscode.window.showWarningMessage(t('fix.failed', { 0: 'apply-failed' }));
|
||||
return;
|
||||
}
|
||||
for (const r of batch.results) {
|
||||
fixSession.recordFixes(document.uri, r.ruleId, r.line, r.appliedFixes, batch.source);
|
||||
}
|
||||
if (batch.diffUri) {
|
||||
await closePreviewEditor(batch.diffUri);
|
||||
}
|
||||
pendingStore.deleteBatch(document.fileName);
|
||||
await document.save();
|
||||
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
|
||||
vscode.window.showInformationMessage(t('fix.allComplete', { 0: String(batch.results.length), 1: String(0) }));
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] applyAllPreview failed:', err);
|
||||
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.cancelAllPreview', async () => {
|
||||
try {
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
|
||||
if (!document) { console.log('[code-reviewer] cancelAllPreview: no target document'); return; }
|
||||
const batch = pendingStore.getBatch(document.fileName);
|
||||
if (!batch) { return; }
|
||||
if (batch.diffUri) {
|
||||
await closePreviewEditor(batch.diffUri);
|
||||
}
|
||||
pendingStore.deleteBatch(document.fileName);
|
||||
ReviewPanel.currentPanel?.postMessage({ type: 'batchPending', on: false });
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] cancelAllPreview failed:', err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.exportTemplate', () => exportTemplate())
|
||||
);
|
||||
|
||||
+18
-15
@@ -105,7 +105,8 @@ function buildCustomRuleSystemPrompt(): string {
|
||||
return `あなたはコードルールレビュアーです。以下のカスタムルールに違反しているかどうかのみを評価してください。
|
||||
意味を理解し、テキストの一致ではなく判断してください。
|
||||
JSONのみを出力、形式:
|
||||
{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明" }] }
|
||||
{ "customRuleResults": [{ "ruleId": "ルールID", "line": 行番号, "severity": "error|warning|info", "message": "違反の説明", "suggestion": "具体的な修正提案" }] }
|
||||
"suggestion" は実行可能な修正提案を必ず含めてください。
|
||||
ルールに違反していない場合は空の配列を返してください。
|
||||
|
||||
出力言語:ja`;
|
||||
@@ -114,7 +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" }] }
|
||||
{ "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.
|
||||
If no rules are violated, return an empty array.
|
||||
|
||||
Output language: en`;
|
||||
@@ -122,7 +124,8 @@ Output language: en`;
|
||||
return `你是代码规则审查员,只评估以下自定义规则是否被违反。
|
||||
理解语义而非文本匹配。
|
||||
仅输出 JSON,格式:
|
||||
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述" }] }
|
||||
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述", "suggestion": "具体的修复建议" }] }
|
||||
每条违规都必须给出可执行的 "suggestion" 修复建议。
|
||||
如果没有违反任何规则,返回空数组。
|
||||
|
||||
输出语言:zh-CN`;
|
||||
@@ -146,8 +149,8 @@ translatedDiagnosticsの要件:
|
||||
JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。
|
||||
形式:
|
||||
{
|
||||
"translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案", "codeDiff": "任意" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "codeDiff": "任意", "line": 行番号 }]
|
||||
"translatedDiagnostics": [{ "originalRuleId": "元のID", "translatedMessage": "翻訳メッセージ", "translatedSuggestion": "提案" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "タイトル", "description": "説明", "suggestion": "提案", "line": 行番号 }]
|
||||
}
|
||||
|
||||
出力言語:ja`;
|
||||
@@ -168,8 +171,8 @@ translatedDiagnostics requirements:
|
||||
Output JSON only. Double quotes in strings must be escaped with \\".
|
||||
Format:
|
||||
{
|
||||
"translatedDiagnostics": [{ "originalRuleId": "original id", "translatedMessage": "translated message", "translatedSuggestion": "suggestion", "codeDiff": "optional" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "title", "description": "description", "suggestion": "suggestion", "codeDiff": "optional", "line": line number }]
|
||||
"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 }]
|
||||
}
|
||||
|
||||
Output language: en`;
|
||||
@@ -189,8 +192,8 @@ translatedDiagnostics 要求:
|
||||
仅输出 JSON,字符串中的双引号必须用 \\" 转义。
|
||||
格式:
|
||||
{
|
||||
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议", "codeDiff": "可选" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "codeDiff": "可选", "line": 行号 }]
|
||||
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "line": 行号 }]
|
||||
}
|
||||
|
||||
输出语言:zh-CN`;
|
||||
@@ -426,7 +429,8 @@ Report violations in "customRuleResults".\n\n`
|
||||
"ruleId": "original rule id",
|
||||
"line": line_number,
|
||||
"severity": "error|warning|info",
|
||||
"message": "violation description"
|
||||
"message": "violation description",
|
||||
"suggestion": "concrete fix suggestion"
|
||||
}
|
||||
],\n`
|
||||
: '';
|
||||
@@ -463,7 +467,6 @@ ${ruleOutput} "findings": [
|
||||
"title": "issue title",
|
||||
"description": "detailed description",
|
||||
"suggestion": "fix suggestion",
|
||||
"codeDiff": "optional fix diff",
|
||||
"line": line_number,
|
||||
"path": "trigger path description, e.g. if(order==null) -> NPE on .getId()"
|
||||
}
|
||||
@@ -487,7 +490,8 @@ function buildMethodSystemPromptZh(hasRules: boolean): string {
|
||||
"ruleId": "原始规则 ID",
|
||||
"line": 行号,
|
||||
"severity": "error|warning|info",
|
||||
"message": "违规描述"
|
||||
"message": "违规描述",
|
||||
"suggestion": "具体的修复建议"
|
||||
}
|
||||
],\n`
|
||||
: '';
|
||||
@@ -524,7 +528,6 @@ ${ruleOutput} "findings": [
|
||||
"title": "问题标题",
|
||||
"description": "详细描述",
|
||||
"suggestion": "修复建议",
|
||||
"codeDiff": "可选的修复 diff",
|
||||
"line": 行号,
|
||||
"path": "触发路径描述,如 if(order==null) -> NPE on .getId()"
|
||||
}
|
||||
@@ -548,7 +551,8 @@ function buildMethodSystemPromptJa(hasRules: boolean): string {
|
||||
"ruleId": "元のルールID",
|
||||
"line": 行番号,
|
||||
"severity": "error|warning|info",
|
||||
"message": "違反の説明"
|
||||
"message": "違反の説明",
|
||||
"suggestion": "具体的な修正提案"
|
||||
}
|
||||
],\n`
|
||||
: '';
|
||||
@@ -585,7 +589,6 @@ ${ruleOutput} "findings": [
|
||||
"title": "問題のタイトル",
|
||||
"description": "詳細な説明",
|
||||
"suggestion": "修正提案",
|
||||
"codeDiff": "オプションの修正diff",
|
||||
"line": 行番号,
|
||||
"path": "トリガーパス説明、例: if(order==null) -> .getId() で NPE"
|
||||
}
|
||||
|
||||
+1
-2
@@ -2,7 +2,6 @@ export interface TranslatedDiagnostic {
|
||||
originalRuleId: string;
|
||||
translatedMessage: string;
|
||||
translatedSuggestion: string;
|
||||
codeDiff?: string;
|
||||
}
|
||||
|
||||
export interface CustomRuleResult {
|
||||
@@ -10,6 +9,7 @@ export interface CustomRuleResult {
|
||||
line: number;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface AIFinding {
|
||||
@@ -19,7 +19,6 @@ export interface AIFinding {
|
||||
title: string;
|
||||
description: string;
|
||||
suggestion: string;
|
||||
codeDiff?: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -9,6 +9,8 @@ import { MethodCodeLensProvider } from './views/codeLensProvider';
|
||||
import { DiagnosticMarkers, isMarkersEnabled } from './diagnostics/diagnosticMarkers';
|
||||
import { FixCodeActionProvider } from './fix/codeActionProvider';
|
||||
import { FixSessionManager } from './fix/fixSession';
|
||||
import { FixPendingStore } from './fix/fixPending';
|
||||
import { registerFixPreviewProvider } from './fix/fixPreview';
|
||||
|
||||
let orchestrator: Orchestrator;
|
||||
let markers: DiagnosticMarkers;
|
||||
@@ -71,6 +73,8 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
const codeLensProvider = new MethodCodeLensProvider(statusCache);
|
||||
|
||||
const fixSession = new FixSessionManager();
|
||||
const pendingStore = new FixPendingStore();
|
||||
registerFixPreviewProvider(context);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCodeActionsProvider(
|
||||
@@ -94,6 +98,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
statusCache.clearDocument(document.uri);
|
||||
markers.clear(document.uri);
|
||||
fixSession.clear(document.uri);
|
||||
pendingStore.clear(document.fileName);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -111,7 +116,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
registerCommands(context, orchestrator, codeLensProvider, statusCache, markers, fixSession);
|
||||
registerCommands(context, orchestrator, codeLensProvider, statusCache, markers, fixSession, pendingStore);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidSaveTextDocument((document) => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { AIProvider, ChatOptions } from '../ai/providers/base';
|
||||
import { chatWithRetry, parseJsonResponse } from '../ai/engine';
|
||||
import type { LinterAdapter, LinterDiagnostic } from '../types';
|
||||
import { mockDocument } from '../utils/mockDocument';
|
||||
import { buildFixSystemPrompt, buildFixUserPrompt, buildFixContext, type ReviewIssueInput } from './fixPrompt';
|
||||
import type { AppliedFix, FixResult } from './fixEngine';
|
||||
|
||||
interface AiCodeFix {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
async function requestFix(
|
||||
provider: AIProvider,
|
||||
options: ChatOptions,
|
||||
diag: LinterDiagnostic,
|
||||
context: string
|
||||
): Promise<AiCodeFix | null> {
|
||||
const issueInput: ReviewIssueInput = {
|
||||
ruleId: diag.ruleId,
|
||||
line: diag.range.start.line,
|
||||
message: diag.message,
|
||||
suggestion: diag.suggestion,
|
||||
};
|
||||
const attempt = async (): Promise<AiCodeFix | null> => {
|
||||
try {
|
||||
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(issueInput, context), options);
|
||||
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
|
||||
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
|
||||
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
|
||||
if (originalText.trim() === '') { return null; }
|
||||
return { originalText, newText };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const first = await attempt();
|
||||
if (first) { return first; }
|
||||
return attempt();
|
||||
}
|
||||
|
||||
function sameRuleAtRegion(
|
||||
diagnostics: LinterDiagnostic[],
|
||||
ruleId: string,
|
||||
start: number,
|
||||
end: number,
|
||||
mock: vscode.TextDocument
|
||||
): boolean {
|
||||
for (const d of diagnostics) {
|
||||
if (d.ruleId !== ruleId) { continue; }
|
||||
const dStart = mock.offsetAt(d.range.start);
|
||||
const dEnd = mock.offsetAt(d.range.end);
|
||||
if (dStart < end && dEnd > start) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function aiFixDiagnostic(
|
||||
document: vscode.TextDocument,
|
||||
workingDir: string,
|
||||
adapter: LinterAdapter,
|
||||
diag: LinterDiagnostic,
|
||||
maxIterations: number,
|
||||
provider: AIProvider,
|
||||
options: ChatOptions,
|
||||
dryRun?: boolean
|
||||
): Promise<FixResult> {
|
||||
const originalText = document.getText();
|
||||
let currentText = originalText;
|
||||
const appliedFixes: AppliedFix[] = [];
|
||||
let converged = false;
|
||||
|
||||
for (let round = 1; round <= maxIterations; round++) {
|
||||
const context = buildFixContext(currentText, diag.range.start.line);
|
||||
const fix = await requestFix(provider, options, diag, context);
|
||||
if (!fix || fix.originalText.trim() === '') {
|
||||
return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
|
||||
}
|
||||
|
||||
const startIndex = currentText.indexOf(fix.originalText);
|
||||
if (startIndex === -1) {
|
||||
return { success: false, attempts: round, message: 'ai-match-failed', appliedFixes };
|
||||
}
|
||||
|
||||
const endIndex = startIndex + fix.originalText.length;
|
||||
const nextText = currentText.slice(0, startIndex) + fix.newText + currentText.slice(endIndex);
|
||||
if (nextText === currentText) {
|
||||
return { success: false, attempts: round, message: 'no-change', appliedFixes };
|
||||
}
|
||||
|
||||
appliedFixes.push({
|
||||
originalText: fix.originalText,
|
||||
newText: fix.newText,
|
||||
line: diag.range.start.line,
|
||||
});
|
||||
currentText = nextText;
|
||||
|
||||
try {
|
||||
const mock = mockDocument(currentText, document.languageId, document.fileName);
|
||||
const verify = await adapter.check(mock, workingDir);
|
||||
const fixedStart = startIndex;
|
||||
const fixedEnd = startIndex + fix.newText.length;
|
||||
if (!sameRuleAtRegion(verify.diagnostics, diag.ruleId, fixedStart, fixedEnd, mock)) {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
converged = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!converged) {
|
||||
return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
|
||||
}
|
||||
|
||||
if (currentText === originalText) {
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return { success: true, attempts: maxIterations, 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: maxIterations, message: 'apply-failed', appliedFixes };
|
||||
}
|
||||
|
||||
return { success: true, attempts: maxIterations, appliedFixes };
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { AIProvider, ChatOptions } from '../ai/providers/base';
|
||||
import { chatWithRetry, parseJsonResponse } from '../ai/engine';
|
||||
import { buildFixSystemPrompt, buildFixUserPrompt, buildFixContext, buildVerifySystemPrompt, buildVerifyUserPrompt, type ReviewIssueInput } from './fixPrompt';
|
||||
import type { AppliedFix, FixResult } from './fixEngine';
|
||||
|
||||
interface AiCodeFix {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
}
|
||||
|
||||
interface AiVerifyResult {
|
||||
fixed: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
async function requestFix(
|
||||
provider: AIProvider,
|
||||
options: ChatOptions,
|
||||
diag: ReviewIssueInput,
|
||||
context: string
|
||||
): Promise<AiCodeFix | null> {
|
||||
const attempt = async (): Promise<AiCodeFix | null> => {
|
||||
try {
|
||||
const response = await chatWithRetry(provider, buildFixSystemPrompt(), buildFixUserPrompt(diag, context), options);
|
||||
const parsed = parseJsonResponse(response) as Partial<AiCodeFix>;
|
||||
const originalText = typeof parsed.originalText === 'string' ? parsed.originalText : '';
|
||||
const newText = typeof parsed.newText === 'string' ? parsed.newText : '';
|
||||
if (originalText.trim() === '') { return null; }
|
||||
return { originalText, newText };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const first = await attempt();
|
||||
if (first) { return first; }
|
||||
return attempt();
|
||||
}
|
||||
|
||||
async function verifyFixed(
|
||||
provider: AIProvider,
|
||||
options: ChatOptions,
|
||||
diag: ReviewIssueInput,
|
||||
code: string
|
||||
): Promise<AiVerifyResult> {
|
||||
try {
|
||||
const response = await chatWithRetry(provider, buildVerifySystemPrompt(), buildVerifyUserPrompt(diag, code), options);
|
||||
const parsed = parseJsonResponse(response) as Partial<AiVerifyResult> & { fixed?: unknown };
|
||||
const f = parsed.fixed;
|
||||
const fixedValue = typeof f === 'string' ? f : String(f);
|
||||
return { fixed: f === true || fixedValue === 'true', reason: parsed.reason };
|
||||
} catch {
|
||||
return { fixed: false };
|
||||
}
|
||||
}
|
||||
|
||||
export async function aiFixReviewIssue(
|
||||
document: vscode.TextDocument,
|
||||
diag: ReviewIssueInput,
|
||||
maxIterations: number,
|
||||
provider: AIProvider,
|
||||
options: ChatOptions,
|
||||
dryRun?: boolean
|
||||
): Promise<FixResult> {
|
||||
const originalText = document.getText();
|
||||
let currentText = originalText;
|
||||
const appliedFixes: AppliedFix[] = [];
|
||||
let converged = false;
|
||||
|
||||
for (let round = 1; round <= maxIterations; round++) {
|
||||
const context = buildFixContext(currentText, diag.line);
|
||||
const fix = await requestFix(provider, options, diag, context);
|
||||
if (!fix || fix.originalText.trim() === '') {
|
||||
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'ai-no-fix');
|
||||
return { success: false, attempts: round, message: 'ai-no-fix', appliedFixes };
|
||||
}
|
||||
|
||||
const startIndex = currentText.indexOf(fix.originalText);
|
||||
if (startIndex === -1) {
|
||||
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'ai-match-failed');
|
||||
return { success: false, attempts: round, message: 'ai-match-failed', appliedFixes };
|
||||
}
|
||||
|
||||
const endIndex = startIndex + fix.originalText.length;
|
||||
const nextText = currentText.slice(0, startIndex) + fix.newText + currentText.slice(endIndex);
|
||||
if (nextText === currentText) {
|
||||
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'no-change');
|
||||
return { success: false, attempts: round, message: 'no-change', appliedFixes };
|
||||
}
|
||||
|
||||
appliedFixes.push({
|
||||
originalText: fix.originalText,
|
||||
newText: fix.newText,
|
||||
line: diag.line,
|
||||
});
|
||||
currentText = nextText;
|
||||
|
||||
const verify = await verifyFixed(provider, options, diag, currentText);
|
||||
console.log('[code-reviewer] review-fix', diag.ruleId, 'round', round, 'applied', fix.newText.slice(0, 60), 'verify', verify.fixed, verify.reason ?? '');
|
||||
if (verify.fixed) {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!converged) {
|
||||
if (appliedFixes.length > 0) {
|
||||
console.log('[code-reviewer] review-fix', diag.ruleId, 'accept-last-fix', appliedFixes.length);
|
||||
if (currentText === originalText) {
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
if (dryRun) {
|
||||
return { success: true, attempts: maxIterations, 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: maxIterations, message: 'apply-failed', appliedFixes };
|
||||
}
|
||||
return { success: true, attempts: maxIterations, appliedFixes };
|
||||
}
|
||||
return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
|
||||
}
|
||||
|
||||
if (currentText === originalText) {
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return { success: true, attempts: maxIterations, 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: maxIterations, message: 'apply-failed', appliedFixes };
|
||||
}
|
||||
|
||||
return { success: true, attempts: maxIterations, appliedFixes };
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export interface FixResult {
|
||||
attempts: number;
|
||||
message?: string;
|
||||
appliedFixes: AppliedFix[];
|
||||
newText?: string;
|
||||
}
|
||||
|
||||
function applyFixToText(text: string, fix: { range: [number, number]; text: string }): string {
|
||||
@@ -58,7 +59,8 @@ export async function fixDiagnostic(
|
||||
workingDir: string,
|
||||
adapter: LinterAdapter,
|
||||
diag: LinterDiagnostic,
|
||||
maxIterations: number
|
||||
maxIterations: number,
|
||||
dryRun?: boolean
|
||||
): Promise<FixResult> {
|
||||
const originalText = document.getText();
|
||||
let currentText = originalText;
|
||||
@@ -120,6 +122,10 @@ export async function fixDiagnostic(
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return { success: true, attempts: maxIterations, appliedFixes, newText: currentText };
|
||||
}
|
||||
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
const fullRange = new vscode.Range(
|
||||
document.positionAt(0),
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { AppliedFix } from './fixEngine';
|
||||
|
||||
export interface PendingFix {
|
||||
key: string;
|
||||
ruleId: string;
|
||||
line: number;
|
||||
filePath: string;
|
||||
source: 'linter' | 'custom' | 'ai';
|
||||
originalText: string;
|
||||
newText: string;
|
||||
appliedFixes: AppliedFix[];
|
||||
diffUri?: vscode.Uri;
|
||||
}
|
||||
|
||||
export interface PendingBatch {
|
||||
filePath: string;
|
||||
source: 'linter' | 'custom' | 'ai';
|
||||
originalText: string;
|
||||
newText: string;
|
||||
results: { ruleId: string; line: number; appliedFixes: AppliedFix[] }[];
|
||||
diffUri?: vscode.Uri;
|
||||
}
|
||||
|
||||
function fileKey(filePath: string): string {
|
||||
return `file:${filePath}`;
|
||||
}
|
||||
|
||||
export class FixPendingStore {
|
||||
private singles = new Map<string, PendingFix>();
|
||||
private batches = new Map<string, PendingBatch>();
|
||||
|
||||
setSingle(fix: PendingFix): void {
|
||||
this.singles.set(fileKey(fix.filePath) + '|' + fix.key, fix);
|
||||
}
|
||||
|
||||
getSingle(filePath: string, key: string): PendingFix | undefined {
|
||||
return this.singles.get(fileKey(filePath) + '|' + key);
|
||||
}
|
||||
|
||||
hasSingle(filePath: string, key: string): boolean {
|
||||
return this.singles.has(fileKey(filePath) + '|' + key);
|
||||
}
|
||||
|
||||
deleteSingle(filePath: string, key: string): void {
|
||||
this.singles.delete(fileKey(filePath) + '|' + key);
|
||||
}
|
||||
|
||||
singleKeys(filePath: string): string[] {
|
||||
const prefix = fileKey(filePath) + '|';
|
||||
const keys: string[] = [];
|
||||
for (const k of this.singles.keys()) {
|
||||
if (k.startsWith(prefix)) { keys.push(k.slice(prefix.length)); }
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
setBatch(batch: PendingBatch): void {
|
||||
this.batches.set(fileKey(batch.filePath), batch);
|
||||
}
|
||||
|
||||
getBatch(filePath: string): PendingBatch | undefined {
|
||||
return this.batches.get(fileKey(filePath));
|
||||
}
|
||||
|
||||
deleteBatch(filePath: string): void {
|
||||
this.batches.delete(fileKey(filePath));
|
||||
}
|
||||
|
||||
clear(filePath: string): void {
|
||||
const prefix = fileKey(filePath) + '|';
|
||||
for (const k of this.singles.keys()) {
|
||||
if (k.startsWith(prefix)) { this.singles.delete(k); }
|
||||
}
|
||||
this.batches.delete(fileKey(filePath));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const scheme = 'codeReviewerPreview';
|
||||
|
||||
class FixPreviewContentProvider implements vscode.TextDocumentContentProvider {
|
||||
private texts = new Map<string, string>();
|
||||
|
||||
provideTextDocumentContent(uri: vscode.Uri): string {
|
||||
return this.texts.get(uri.toString()) ?? '';
|
||||
}
|
||||
|
||||
set(uri: vscode.Uri, text: string): void {
|
||||
this.texts.set(uri.toString(), text);
|
||||
}
|
||||
|
||||
clear(uri: vscode.Uri): void {
|
||||
this.texts.delete(uri.toString());
|
||||
}
|
||||
}
|
||||
|
||||
let previewProvider: FixPreviewContentProvider | null = null;
|
||||
|
||||
export function registerFixPreviewProvider(context: vscode.ExtensionContext): void {
|
||||
if (previewProvider) { return; }
|
||||
previewProvider = new FixPreviewContentProvider();
|
||||
context.subscriptions.push(vscode.workspace.registerTextDocumentContentProvider(scheme, previewProvider));
|
||||
}
|
||||
|
||||
export interface PreviewRequest {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
title: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
export async function openPreviewDiff(req: PreviewRequest): Promise<vscode.Uri | undefined> {
|
||||
if (!previewProvider) { return undefined; }
|
||||
const stamp = Date.now();
|
||||
const originalUri = vscode.Uri.parse(`${scheme}://original/${encodeURIComponent(req.fileName)}-${stamp}`);
|
||||
const newUri = vscode.Uri.parse(`${scheme}://new/${encodeURIComponent(req.fileName)}-${stamp}`);
|
||||
previewProvider.set(originalUri, req.originalText);
|
||||
previewProvider.set(newUri, req.newText);
|
||||
|
||||
await vscode.commands.executeCommand('vscode.diff', originalUri, newUri, req.title);
|
||||
return newUri;
|
||||
}
|
||||
|
||||
export async function closePreviewEditor(uri: vscode.Uri): Promise<void> {
|
||||
if (!previewProvider) { return; }
|
||||
for (const group of vscode.window.tabGroups.all) {
|
||||
for (const tab of group.tabs) {
|
||||
if (tab.input instanceof vscode.TabInputTextDiff) {
|
||||
const modified = tab.input.modified;
|
||||
if (modified.toString() === uri.toString()) {
|
||||
await vscode.window.tabGroups.close(tab);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
previewProvider.clear(uri);
|
||||
}
|
||||
|
||||
export async function applyNewText(
|
||||
document: vscode.TextDocument,
|
||||
newText: string
|
||||
): Promise<boolean> {
|
||||
const originalText = document.getText();
|
||||
if (newText === originalText) { return true; }
|
||||
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
const fullRange = new vscode.Range(
|
||||
document.positionAt(0),
|
||||
document.positionAt(originalText.length)
|
||||
);
|
||||
edit.replace(document.uri, fullRange, newText);
|
||||
return vscode.workspace.applyEdit(edit);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { LinterDiagnostic } from '../types';
|
||||
import { getLanguage } from '../i18n/messages';
|
||||
|
||||
export interface ReviewIssueInput {
|
||||
ruleId: string;
|
||||
line: number;
|
||||
message: string;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export function buildFixSystemPrompt(): string {
|
||||
const lang = getLanguage();
|
||||
if (lang === 'ja') {
|
||||
return `あなたはシニアコード修正の専門家です。与えられた問題とコードコンテキストから、最小で正しい修正を生成してください。
|
||||
JSONのみを出力:{ "originalText": "置換対象の原文(コードコンテキスト内に完全一致すること)", "newText": "修正後の新コード" }
|
||||
要件:
|
||||
- originalText は提供されたコードコンテキスト内に逐語的に存在し、正確な空白・インデントも含むこと
|
||||
- 指定された問題のみ修正し、無関係なコードは変更しないこと
|
||||
- コードスタイルとインデントを維持すること
|
||||
- 必ず修正コードを出力すること。空の修正を出力しないこと。問題を完全に解消できない場合でも、問題を緩和・改善する最小のコード片を出力すること`;
|
||||
}
|
||||
if (lang === 'en') {
|
||||
return `You are a senior code fixer. Given the code issue and context, produce the minimal correct fix.
|
||||
Output JSON only: { "originalText": "the exact original snippet to replace (must be found verbatim in the code context)", "newText": "the fixed replacement snippet" }
|
||||
Requirements:
|
||||
- originalText must exist verbatim in the provided code context, including exact whitespace and indentation
|
||||
- Fix only the reported issue; do not modify unrelated code
|
||||
- Preserve code style and indentation
|
||||
- Always output a fix snippet; never output an empty fix. Even if the issue cannot be fully resolved, output the minimal snippet that mitigates or improves it`;
|
||||
}
|
||||
return `你是资深代码修复专家。根据给定的代码问题与上下文,给出最小且正确的修复。
|
||||
仅输出 JSON:{ "originalText": "需要被替换的原文片段(必须在代码上下文中逐字存在,含精确的前后空白与缩进)", "newText": "修复后的新代码片段" }
|
||||
要求:
|
||||
- originalText 必须在提供的代码上下文中逐字存在,包含精确的缩进与前后空白
|
||||
- 只修复指定问题,不要改动无关代码
|
||||
- 保持代码风格与缩进
|
||||
- 必须输出修复片段,禁止输出空修复;即使无法完全消除问题,也要给出能缓解/改善问题的最小代码片段`;
|
||||
}
|
||||
|
||||
export function buildFixUserPrompt(diag: ReviewIssueInput, context: string): string {
|
||||
const lang = getLanguage();
|
||||
const issueLabel = lang === 'ja' ? '問題' : lang === 'en' ? 'Issue' : '问题';
|
||||
const suggestionLabel = lang === 'ja' ? '参考提案' : lang === 'en' ? 'Reference suggestion' : '参考建议';
|
||||
const contextLabel = lang === 'ja' ? 'コードコンテキスト(行番号付き)' : lang === 'en' ? 'Code context (with line numbers)' : '代码上下文(带行号)';
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push(`## ${issueLabel}\n[${diag.ruleId}] ${diag.message}`);
|
||||
if (diag.suggestion && diag.suggestion.trim() !== '') {
|
||||
parts.push(`## ${suggestionLabel}\n${diag.suggestion}`);
|
||||
}
|
||||
parts.push(`## ${contextLabel}\n${context}`);
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
export function buildFixContext(code: string, line: number): string {
|
||||
const lines = code.split('\n');
|
||||
const start = Math.max(0, line - 6);
|
||||
const end = Math.min(lines.length - 1, line + 6);
|
||||
const out: string[] = [];
|
||||
for (let i = start; i <= end; i++) {
|
||||
out.push(`${String(i + 1).padStart(4, ' ')}| ${lines[i]}`);
|
||||
}
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
export function buildVerifySystemPrompt(): string {
|
||||
const lang = getLanguage();
|
||||
if (lang === 'ja') {
|
||||
return `あなたはコードレビュアーです。修正後のコードに指定された問題がまだ存在するか確認してください。
|
||||
JSONのみを出力:{ "fixed": true|false, "reason": "まだ残る場合の理由" }
|
||||
問題が完全に解消されていれば "fixed": true、まだ残っていれば "fixed": false を返してください。`;
|
||||
}
|
||||
if (lang === 'en') {
|
||||
return `You are a code reviewer. Check whether the reported issue still exists in the fixed code.
|
||||
Output JSON only: { "fixed": true|false, "reason": "reason if it still remains" }
|
||||
Return "fixed": true if the issue is fully resolved, otherwise "fixed": false.`;
|
||||
}
|
||||
return `你是代码审查员。检查修复后的代码中指定问题是否仍然存在。
|
||||
仅输出 JSON:{ "fixed": true|false, "reason": "如果问题仍存在的原因" }
|
||||
问题已完全解决返回 "fixed": true,仍存在返回 "fixed": false。`;
|
||||
}
|
||||
|
||||
export function buildVerifyUserPrompt(diag: ReviewIssueInput, code: string): string {
|
||||
const lang = getLanguage();
|
||||
const issueLabel = lang === 'ja' ? '問題' : lang === 'en' ? 'Issue' : '问题';
|
||||
const codeLabel = lang === 'ja' ? '修正後コード' : lang === 'en' ? 'Fixed code' : '修复后代码';
|
||||
const parts: string[] = [];
|
||||
parts.push(`## ${issueLabel}\n[${diag.ruleId}] ${diag.message}`);
|
||||
parts.push(`## ${codeLabel}\n${code}`);
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
@@ -6,7 +6,7 @@ export interface FixedEntry {
|
||||
ruleId: string;
|
||||
line: number;
|
||||
fixes: AppliedFix[];
|
||||
source: 'linter';
|
||||
source: 'linter' | 'custom' | 'ai';
|
||||
}
|
||||
|
||||
function keyOf(ruleId: string, line: number): string {
|
||||
@@ -77,7 +77,7 @@ export class FixSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
recordFixes(uri: vscode.Uri, ruleId: string, line: number, fixes: AppliedFix[]): string {
|
||||
recordFixes(uri: vscode.Uri, ruleId: string, line: number, fixes: AppliedFix[], source: 'linter' | 'custom' | 'ai' = 'linter'): string {
|
||||
const key = keyOf(ruleId, line);
|
||||
const fullKey = uri.toString() + '|' + key;
|
||||
const existing = this.fixedEntries.get(fullKey);
|
||||
@@ -89,7 +89,7 @@ export class FixSessionManager {
|
||||
ruleId,
|
||||
line,
|
||||
fixes: [...fixes],
|
||||
source: 'linter',
|
||||
source,
|
||||
});
|
||||
}
|
||||
return key;
|
||||
|
||||
@@ -85,6 +85,41 @@ const messages: Record<string, Record<Language, string>> = {
|
||||
en: 'Undo failed, the code may have been modified manually',
|
||||
ja: '取り消しに失敗しました。コードが手動で変更された可能性があります',
|
||||
},
|
||||
'fix.aiRunning': {
|
||||
'zh-CN': 'AI 修复中...',
|
||||
en: 'AI fixing...',
|
||||
ja: 'AI修正中...',
|
||||
},
|
||||
'fix.aiFailed': {
|
||||
'zh-CN': 'AI 修复失败: {0}',
|
||||
en: 'AI fix failed: {0}',
|
||||
ja: 'AI修正に失敗しました: {0}',
|
||||
},
|
||||
'fix.noAI': {
|
||||
'zh-CN': '该问题需要 AI 修复,请先在设置面板配置 AI',
|
||||
en: 'This issue needs AI fix, please configure AI in the Setup panel first',
|
||||
ja: 'この問題はAI修正が必要です。設定パネルでAIを設定してください',
|
||||
},
|
||||
'fix.confirmApply': {
|
||||
'zh-CN': '确认应用修复?',
|
||||
en: 'Confirm applying the fix?',
|
||||
ja: '修正を適用しますか?',
|
||||
},
|
||||
'fix.apply': {
|
||||
'zh-CN': '应用',
|
||||
en: 'Apply',
|
||||
ja: '適用',
|
||||
},
|
||||
'fix.cancel': {
|
||||
'zh-CN': '取消',
|
||||
en: 'Cancel',
|
||||
ja: 'キャンセル',
|
||||
},
|
||||
'fix.previewTitle': {
|
||||
'zh-CN': '修复预览',
|
||||
en: 'Fix preview',
|
||||
ja: '修正プレビュー',
|
||||
},
|
||||
|
||||
'export.needRunFirst': {
|
||||
'zh-CN': '请先运行完整审查生成报告',
|
||||
@@ -833,11 +868,21 @@ const messages: Record<string, Record<Language, string>> = {
|
||||
en: 'Fix All',
|
||||
ja: 'すべて修正',
|
||||
},
|
||||
'report.fixAllApply': {
|
||||
'zh-CN': '全部应用',
|
||||
en: 'Apply All',
|
||||
ja: 'すべて適用',
|
||||
},
|
||||
'report.fixLabel': {
|
||||
'zh-CN': '修复',
|
||||
en: 'Fix',
|
||||
ja: '修正',
|
||||
},
|
||||
'report.fixAILabel': {
|
||||
'zh-CN': 'AI 修复',
|
||||
en: 'AI Fix',
|
||||
ja: 'AI修正',
|
||||
},
|
||||
'report.fixedIssues': {
|
||||
'zh-CN': '已修复',
|
||||
en: 'Fixed',
|
||||
|
||||
@@ -17,7 +17,9 @@ export interface MergedReport {
|
||||
language: string;
|
||||
adapterNames: string[];
|
||||
fixableLinterIndices: number[];
|
||||
aiFixableLinterIndices: number[];
|
||||
fixableCustomIndices: number[];
|
||||
aiFixAvailable: boolean;
|
||||
customRuleFilterInfo?: {
|
||||
totalActive: number;
|
||||
injected: number;
|
||||
@@ -37,6 +39,7 @@ interface MergeInput {
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterIds: string[];
|
||||
aiFixAvailable?: boolean;
|
||||
customRuleFilterInfo?: {
|
||||
totalActive: number;
|
||||
injected: number;
|
||||
@@ -90,6 +93,7 @@ export function mergeResults(input: MergeInput): MergedReport {
|
||||
severity: r.severity as Severity,
|
||||
ruleId: r.ruleId,
|
||||
message: r.message,
|
||||
suggestion: r.suggestion,
|
||||
range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
|
||||
})),
|
||||
d => d.range.start.line
|
||||
@@ -121,6 +125,11 @@ export function mergeResults(input: MergeInput): MergedReport {
|
||||
.map((d, i) => (d.fix ? i : -1))
|
||||
.filter(i => i !== -1);
|
||||
|
||||
const aiFixAvailable = !!input.aiFixAvailable;
|
||||
const aiFixableLinterIndices = linterDiagnostics
|
||||
.map((d, i) => (!d.fix && aiFixAvailable && !d.ruleId.startsWith('sqlfluff:') ? i : -1))
|
||||
.filter(i => i !== -1);
|
||||
|
||||
const fixableCustomIndices: number[] = [];
|
||||
|
||||
return {
|
||||
@@ -138,7 +147,9 @@ export function mergeResults(input: MergeInput): MergedReport {
|
||||
language: input.language,
|
||||
adapterNames: input.adapterIds,
|
||||
fixableLinterIndices,
|
||||
aiFixableLinterIndices,
|
||||
fixableCustomIndices,
|
||||
aiFixAvailable,
|
||||
customRuleFilterInfo: input.customRuleFilterInfo,
|
||||
};
|
||||
}
|
||||
|
||||
+108
-36
@@ -4,7 +4,7 @@ import { t, onLanguageChange, getLanguage } from '../i18n/messages';
|
||||
import type { FixSessionManager } from '../fix/fixSession';
|
||||
|
||||
interface PanelMessage {
|
||||
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo';
|
||||
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo' | 'applyFix' | 'cancelFix' | 'applyAll' | 'cancelAll';
|
||||
line?: number;
|
||||
ruleId?: string;
|
||||
source?: 'linter' | 'custom' | 'ai';
|
||||
@@ -23,6 +23,13 @@ const SVG_HEADER_ICON = svgIcon();
|
||||
|
||||
const BADGE_CLASS: Record<string, string> = { linter: 'badge-linter', custom: 'badge-custom', ai: 'badge-ai' };
|
||||
|
||||
interface FixedEntryView {
|
||||
ruleId: string;
|
||||
line: number;
|
||||
key: string;
|
||||
source: 'linter' | 'custom' | 'ai';
|
||||
}
|
||||
|
||||
function badgeHtml(source: string): string {
|
||||
let label: string;
|
||||
switch (source) {
|
||||
@@ -110,6 +117,10 @@ export class ReviewPanel {
|
||||
}
|
||||
}
|
||||
|
||||
postMessage(message: unknown): void {
|
||||
this.panel.webview.postMessage(message);
|
||||
}
|
||||
|
||||
private buildHtml(report: MergedReport): string {
|
||||
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
|
||||
|
||||
@@ -139,6 +150,7 @@ export class ReviewPanel {
|
||||
: '';
|
||||
|
||||
const fixableLinterSet = new Set(report.fixableLinterIndices);
|
||||
const aiFixableLinterSet = new Set(report.aiFixableLinterIndices);
|
||||
const fixableCustomSet = new Set(report.fixableCustomIndices);
|
||||
|
||||
const fixedEntries = this.fixSession?.getEntries(vscode.Uri.file(report.filePath)) ?? [];
|
||||
@@ -286,13 +298,13 @@ ${errorBox}
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="tab-linter">
|
||||
${this.buildLinterList(report, fixableLinterSet, fixedEntries)}
|
||||
${this.buildLinterList(report, fixableLinterSet, aiFixableLinterSet, fixedEntries)}
|
||||
</div>
|
||||
<div class="tab-content" id="tab-custom">
|
||||
${this.buildCustomList(report)}
|
||||
${this.buildCustomList(report, fixedEntries)}
|
||||
</div>
|
||||
<div class="tab-content" id="tab-ai">
|
||||
${this.buildAIList(report)}
|
||||
${this.buildAIList(report, fixedEntries)}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
@@ -306,74 +318,115 @@ ${errorBox}
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildLinterList(report: MergedReport, fixableSet: Set<number>, fixedEntries: Array<{ ruleId: string; line: number; key: string }>): string {
|
||||
private buildLinterList(report: MergedReport, fixableSet: Set<number>, aiFixableSet: Set<number>, fixedEntries: FixedEntryView[]): string {
|
||||
if (report.linterDiagnostics.length === 0 && fixedEntries.length === 0) {
|
||||
return `<div class="empty">${t('report.noIssues')}</div>`;
|
||||
}
|
||||
const toolName = report.adapterNames.length > 0 ? report.adapterNames.join(' + ') : t('report.sourceLinter');
|
||||
const hasFixable = fixableSet.size > 0;
|
||||
let html = `<div class="section-header"><span class="section-header-title">${esc(toolName)} · ${t('report.issuesCount', { 0: report.linterCount })}</span>${hasFixable ? `<button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button>` : ''}</div>`;
|
||||
const hasFixable = fixableSet.size > 0 || aiFixableSet.size > 0;
|
||||
const fixAllBtn = hasFixable
|
||||
? `<button class="btn" data-fix-all-btn onclick="send('fixAll')">${t('report.fixAll')}</button>`
|
||||
+ `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
|
||||
+ `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
|
||||
: '';
|
||||
let html = `<div class="section-header"><span class="section-header-title">${esc(toolName)} · ${t('report.issuesCount', { 0: report.linterCount })}</span>${fixAllBtn}</div>`;
|
||||
if (report.linterDiagnostics.length === 0) {
|
||||
html += `<div class="empty">${t('report.noIssues')}</div>`;
|
||||
} 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))).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))).join('');
|
||||
}
|
||||
if (fixedEntries.length > 0) {
|
||||
html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: fixedEntries.length })}</span></div>`;
|
||||
html += fixedEntries.map(f => this.buildFixedItem(f.ruleId, f.line, f.key)).join('');
|
||||
const linterFixed = fixedEntries.filter(f => f.source === 'linter');
|
||||
if (linterFixed.length > 0) {
|
||||
html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: linterFixed.length })}</span></div>`;
|
||||
html += linterFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'linter')).join('');
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
private buildFixedItem(ruleId: string, line: number, key: string): string {
|
||||
private buildFixedItem(ruleId: string, line: number, key: string, source: 'linter' | 'custom' | 'ai'): string {
|
||||
return `<div class="item item-fixed">
|
||||
<div class="item-severity item-severity-fixed"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-fixed"></span>
|
||||
<span class="item-badge badge-linter">${t('report.sourceLinter')}</span>
|
||||
${badgeHtml(source)}
|
||||
<span class="item-rule">${esc(ruleId)}</span>
|
||||
<span class="item-message">${t('report.fixedLabel')}</span>
|
||||
<button class="item-undo" onclick="event.stopPropagation();send('undo', ${line}, '${esc(ruleId)}', 'linter')">↩ ${t('report.undoFix')}</button>
|
||||
<button class="item-undo" onclick="event.stopPropagation();send('undo', ${line}, '${esc(ruleId)}', '${source}')">↩ ${t('report.undoFix')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private buildCustomList(report: MergedReport): string {
|
||||
private buildCustomList(report: MergedReport, fixedEntries: FixedEntryView[]): string {
|
||||
const filterInfo = report.customRuleFilterInfo;
|
||||
if (filterInfo?.skippedRequestA) {
|
||||
return `<div class="empty">${t('report.skipCustomRules')}</div>`;
|
||||
}
|
||||
if (report.customRuleDiagnostics.length === 0) {
|
||||
const customFixed = fixedEntries.filter(f => f.source === 'custom');
|
||||
const fixedKeys = new Set(customFixed.map(f => `${f.ruleId}@${f.line}`));
|
||||
const remaining = report.customRuleDiagnostics.filter(d => !fixedKeys.has(`${d.ruleId}@${d.range.start.line}`));
|
||||
if (remaining.length === 0 && customFixed.length === 0) {
|
||||
return `<div class="empty">${t('report.noRuleViolations')}</div>`;
|
||||
}
|
||||
const filterLabel = filterInfo
|
||||
? t('report.injectedRules', { 0: filterInfo.injected, 1: filterInfo.totalActive })
|
||||
: '';
|
||||
return `<div class="section-header"><span class="section-header-title">${t('report.sourceCustom')} · ${t('report.issuesCount', { 0: report.customRuleCount })}${filterLabel}</span></div>`
|
||||
+ report.customRuleDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false)).join('');
|
||||
const hasFixable = remaining.length > 0;
|
||||
const fixAllBtn = hasFixable
|
||||
? `<button class="btn" data-fix-all-btn onclick="send('fixAll', undefined, undefined, 'custom')">${t('report.fixAll')}</button>`
|
||||
+ `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
|
||||
+ `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
|
||||
: '';
|
||||
let html = `<div class="section-header"><span class="section-header-title">${t('report.sourceCustom')} · ${t('report.issuesCount', { 0: remaining.length })}${filterLabel}</span>${fixAllBtn}</div>`;
|
||||
if (remaining.length === 0) {
|
||||
html += `<div class="empty">${t('report.noRuleViolations')}</div>`;
|
||||
} else {
|
||||
html += remaining.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, false, true)).join('');
|
||||
}
|
||||
if (customFixed.length > 0) {
|
||||
html += `<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: customFixed.length })}</span></div>`;
|
||||
html += customFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'custom')).join('');
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
private buildAIList(report: MergedReport): string {
|
||||
if (report.aiFindings.length === 0) {
|
||||
private buildAIList(report: MergedReport, fixedEntries: FixedEntryView[]): string {
|
||||
const aiFixed = fixedEntries.filter(f => f.source === 'ai');
|
||||
const fixedKeys = new Set(aiFixed.map(f => `${f.ruleId}@${f.line}`));
|
||||
const remaining = report.aiFindings.filter(f => !fixedKeys.has(`${f.ruleId}@${f.line}`));
|
||||
if (remaining.length === 0 && aiFixed.length === 0) {
|
||||
return `<div class="empty">${t('report.noAIFindings')}</div>`;
|
||||
}
|
||||
const parts: string[] = [`<div class="section-header"><span class="section-header-title">${t('report.sourceAI')} · ${t('report.itemsCount', { 0: report.aiCount })}</span></div>`];
|
||||
for (const f of report.aiFindings) {
|
||||
const details: string[] = [];
|
||||
const path = (f as { path?: string }).path;
|
||||
if (path) {
|
||||
details.push(`<div class="detail-text">🔗 ${esc(path)}</div>`);
|
||||
const hasFixable = remaining.length > 0;
|
||||
const fixAllBtn = hasFixable
|
||||
? `<button class="btn" data-fix-all-btn onclick="send('fixAll', undefined, undefined, 'ai')">${t('report.fixAll')}</button>`
|
||||
+ `<button class="btn btn-apply-all" data-fix-all-btn style="display:none" onclick="send('applyAll')">✅ ${t('report.fixAllApply')}</button>`
|
||||
+ `<button class="btn btn-cancel-all" data-fix-all-btn style="display:none" onclick="send('cancelAll')">✖ ${t('fix.cancel')}</button>`
|
||||
: '';
|
||||
const parts: string[] = [`<div class="section-header"><span class="section-header-title">${t('report.sourceAI')} · ${t('report.itemsCount', { 0: remaining.length })}</span>${fixAllBtn}</div>`];
|
||||
if (remaining.length === 0) {
|
||||
parts.push(`<div class="empty">${t('report.noAIFindings')}</div>`);
|
||||
} else {
|
||||
for (const f of remaining) {
|
||||
const details: string[] = [];
|
||||
const path = (f as { path?: string }).path;
|
||||
if (path) {
|
||||
details.push(`<div class="detail-text">🔗 ${esc(path)}</div>`);
|
||||
}
|
||||
details.push(`<div class="detail-text">${esc(f.description)}</div>`);
|
||||
if (f.category) {
|
||||
details.push(`<span class="detail-category">🎯 ${esc(f.category)}</span>`);
|
||||
}
|
||||
if (f.suggestion) {
|
||||
details.push(`<div class="detail-suggestion">💡 ${esc(f.suggestion)}</div>`);
|
||||
}
|
||||
parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, true, details.join('')));
|
||||
}
|
||||
details.push(`<div class="detail-text">${esc(f.description)}</div>`);
|
||||
if (f.category) {
|
||||
details.push(`<span class="detail-category">🎯 ${esc(f.category)}</span>`);
|
||||
}
|
||||
if (f.suggestion) {
|
||||
details.push(`<div class="detail-suggestion">💡 ${esc(f.suggestion)}</div>`);
|
||||
}
|
||||
parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, details.join('')));
|
||||
}
|
||||
if (aiFixed.length > 0) {
|
||||
parts.push(`<div class="section-header" style="padding-top:16px"><span class="section-header-title">✅ ${t('report.fixedIssues')} · ${t('report.issuesCount', { 0: aiFixed.length })}</span></div>`);
|
||||
parts.push(aiFixed.map(f => this.buildFixedItem(f.ruleId, f.line, f.key, 'ai')).join(''));
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
@@ -386,6 +439,7 @@ ${errorBox}
|
||||
source: string,
|
||||
suggestion?: string,
|
||||
fixable?: boolean,
|
||||
aiFixable?: boolean,
|
||||
detailHtml?: string,
|
||||
expandable: boolean = true
|
||||
): string {
|
||||
@@ -403,7 +457,13 @@ ${errorBox}
|
||||
parts.push(`<span class="item-message">${esc(message)}</span>`);
|
||||
parts.push(`<span class="item-line" onclick="event.stopPropagation();send('navigate', ${line}, '${esc(ruleId)}', '${source}')">L${lineNum}</span>`);
|
||||
if (fixable) {
|
||||
parts.push(`<button class="item-fix" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🔧 ${t('report.fixLabel')}</button>`);
|
||||
parts.push(`<button class="item-fix" data-fix-key="${esc(ruleId)}@${line}" data-label="🔧 ${esc(t('report.fixLabel'))}" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🔧 ${t('report.fixLabel')}</button>`);
|
||||
parts.push(`<button class="item-fix btn-apply" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('applyFix', ${line}, '${esc(ruleId)}', '${source}')">✅ ${t('fix.apply')}</button>`);
|
||||
parts.push(`<button class="item-fix btn-cancel" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('cancelFix', ${line}, '${esc(ruleId)}', '${source}')">✖ ${t('fix.cancel')}</button>`);
|
||||
} else if (aiFixable) {
|
||||
parts.push(`<button class="item-fix" data-fix-key="${esc(ruleId)}@${line}" data-label="🤖 ${esc(t('report.fixAILabel'))}" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';send('fix', ${line}, '${esc(ruleId)}', '${source}')">🤖 ${t('report.fixAILabel')}</button>`);
|
||||
parts.push(`<button class="item-fix btn-apply" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('applyFix', ${line}, '${esc(ruleId)}', '${source}')">✅ ${t('fix.apply')}</button>`);
|
||||
parts.push(`<button class="item-fix btn-cancel" data-fix-key="${esc(ruleId)}@${line}" style="display:none" onclick="event.stopPropagation();send('cancelFix', ${line}, '${esc(ruleId)}', '${source}')">✖ ${t('fix.cancel')}</button>`);
|
||||
}
|
||||
parts.push('</div>');
|
||||
|
||||
@@ -452,11 +512,23 @@ ${errorBox}
|
||||
vscode.commands.executeCommand('codeReviewer.fixIssue', { ...message, origin: 'panel' });
|
||||
break;
|
||||
case 'fixAll':
|
||||
vscode.commands.executeCommand('codeReviewer.fixAll');
|
||||
vscode.commands.executeCommand('codeReviewer.fixAll', { source: message.source ?? 'linter' });
|
||||
break;
|
||||
case 'undo':
|
||||
vscode.commands.executeCommand('codeReviewer.undoFix', message);
|
||||
break;
|
||||
case 'applyFix':
|
||||
vscode.commands.executeCommand('codeReviewer.applyFixPreview', message);
|
||||
break;
|
||||
case 'cancelFix':
|
||||
vscode.commands.executeCommand('codeReviewer.cancelFixPreview', message);
|
||||
break;
|
||||
case 'applyAll':
|
||||
vscode.commands.executeCommand('codeReviewer.applyAllPreview');
|
||||
break;
|
||||
case 'cancelAll':
|
||||
vscode.commands.executeCommand('codeReviewer.cancelAllPreview');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,11 +86,6 @@ export function reportToMarkdown(report: MergedReport): string {
|
||||
if (finding.suggestion) {
|
||||
lines.push(` ${t('report.suggestion')}: ${finding.suggestion}`);
|
||||
}
|
||||
if (finding.codeDiff) {
|
||||
lines.push(' ```diff');
|
||||
lines.push(` ${finding.codeDiff.split('\n').join('\n ')}`);
|
||||
lines.push(' ```');
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
@@ -16,3 +16,24 @@ function toggleItem(el) {
|
||||
if (event.target.closest('.item-line')) { return; }
|
||||
el.classList.toggle('expanded');
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
const msg = e.data;
|
||||
if (!msg || typeof msg.type !== 'string') { return; }
|
||||
if (msg.type === 'pending') {
|
||||
const key = msg.key;
|
||||
document.querySelectorAll('[data-fix-key="' + key + '"]').forEach(function(btn) {
|
||||
const isConfirm = btn.classList.contains('btn-apply') || btn.classList.contains('btn-cancel');
|
||||
btn.style.display = msg.on ? (isConfirm ? '' : 'none') : (isConfirm ? 'none' : '');
|
||||
if (!msg.on && !isConfirm) {
|
||||
btn.disabled = false;
|
||||
if (btn.dataset.label) { btn.textContent = btn.dataset.label; }
|
||||
}
|
||||
});
|
||||
} else if (msg.type === 'batchPending') {
|
||||
document.querySelectorAll('[data-fix-all-btn]').forEach(function(btn) {
|
||||
const isConfirm = btn.classList.contains('btn-apply-all') || btn.classList.contains('btn-cancel-all');
|
||||
btn.style.display = msg.on ? (isConfirm ? '' : 'none') : (isConfirm ? 'none' : '');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user