feat: 自动修复重构与审查面板交互修复 + 静态分析 AI 翻译配对 + SQLFluff 方言显示
- 自动修复:废弃 AI 修复,改 linter 原生 fix 多轮收敛;CodeAction hover + 面板修复/全部修复 + 快照 diff 撤销;hover 修复不入「已修复」列表、重新审查清空;修复/撤销后自动保存;单条修复只修目标问题(区间重叠收敛,不再连带相邻同规则) - 审查面板:内联 JS 外部化(reviewPanel.js)修复 CSP 屏蔽导致的修复按钮/行号跳转/tab 失效;面板操作不依赖文件焦点(resolveFixDocument);行号跳转定位已打开编辑器,不在面板列新开副本 - 静态分析:translatedDiagnostics 规则 ID 归一化配对 + 深度审查 prompt 强化,静态分析条目显示中文翻译与逐条 AI 建议 - 波浪线:诊断补 source/code,hover 显示快速修复链接 - SQLFluff:设置面板方言徽章(显式/全局/项目/内置来源配色)
This commit is contained in:
+10
-1
@@ -202,4 +202,13 @@
|
||||
| 2026-08-09 14:31 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 审查报告 #11 启动分析未捕获错误:核实属实且更严重——void analyzeOpenDocuments()/void runStaticAndApply()/scheduleAnalysis 定时器三处均为未捕获 Promise 拒绝路径,runStaticAndApply 内部无 try-catch,markers.apply 无保护;纠正审查中「runStaticAnalysis 自身有 try-catch」的事实错误(orchestrator.ts 直接 await adapter.check(),无 try-catch,唯一防线是各适配器内部 catch)。经审批在 runStaticAndApply 包 try-catch + console.error 单点保护,覆盖三处调用。lint 0 error / compile / npm test 81 passing | 中间产物:无 | src/extension.ts | deepseek-v4-flash |
|
||||
| 2026-08-09 14:34 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 版本升级 1.2.0 → 1.3.0:package.json version 更新;CHANGELOG.md 由模板「Unreleased-Initial release」改为 1.3.0 正式条目(修复 6 项 + 清理 3 项);整合审查报告 #3-#11 全部修复作为本版本内容 | 中间产物:无 | package.json CHANGELOG.md | deepseek-v4-flash |
|
||||
| 2026-08-12 22:31 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: JS 审查不出 no-octal / no-nonoctal-decimal-escape — 根因是 flat config 默认 sourceType=module(严格模式)下 `010` 与 `\8`/`\9` 属 JS 语法错误,eslint.ts:114 又丢弃 ruleId===null 的致命解析错误。A:致命解析错误改为产出 eslint:parse-error error 诊断(不再静默丢弃);B:ts.configs.recommended 逐项补 files 守卫(其 base 无 files 会全局挂 TS parser 并误报 TS 规则到 JS),JS 回退 espree + js.configs.recommended,并新增 JS_FILES 独立 config 块加 ecmaFeatures.jsx 防 JSX 回归。本地被删的 eslint.config.mjs 已从 git 恢复以启用 lint。新增 3 个 adapter 测试(octal→parse-error、\8→parse-error、JSX 无 parse error),npm test 84 passing / lint 0 error(仅 jsp.ts 2 个既有 curly warning)| 中间产物:①调试复现阶段 node -e 转义踩坑(PowerShell 单引号 + \8 报 Unterminated string constant),改用 String.fromCharCode 构造测试串;②ts.configs.recommended 类型试错——cfg.files 在 CompatibleConfig 上不存在致编译报错,改用 (cfg as { files?: string[] }).files 断言;③JSX 方案三选一——全局 jsx / .jsx 单独挂 TS parser / JS_FILES 独立 config 块,实测后者通过 | src/adapters/eslint.ts src/test/adapter.test.ts | deepseek-v4-flash |
|
||||
| 2026-08-12 23:14 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: 设置面板 Stylelint 配置模式显示错误——项目根目录有 stylelint.config.mjs 时面板仍显示「内置配置」,根因是 setupView.ts 的 PROJECT_CONFIG_FILES.stylelint 清单(6 项)与 stylelint.ts 适配器 CONFIG_FILE_NAMES(8 项)不一致,漏了 stylelint.config.mjs / stylelint.config.cjs。按方案 B 最小改动补齐两个文件名,实际审查本就走项目配置无问题。lint 0 error / compile 通过 | 中间产物:曾评估方案 A(抽共享常量消除 drift,含顺带统一 ESLint legacy 清单)被用户否决,仅执行方案 B | src/views/setupView.ts | deepseek-v4-flash |
|
||||
| 2026-08-12 23:14 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: 设置面板 Stylelint 配置模式显示错误——项目根目录有 stylelint.config.mjs 时面板仍显示「内置配置」,根因是 setupView.ts 的 PROJECT_CONFIG_FILES.stylelint 清单(6 项)与 stylelint.ts 适配器 CONFIG_FILE_NAMES(8 项)不一致,漏了 stylelint.config.mjs / stylelint.config.cjs。按方案 B 最小改动补齐两个文件名,实际审查本就走项目配置无问题。lint 0 error / compile 通过 | 中间产物:曾评估方案 A(抽共享常量消除 drift,含顺带统一 ESLint legacy 清单)被用户否决,仅执行方案 B | src/views/setupView.ts | deepseek-v4-flash |
|
||||
| 2026-08-14 22:10 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 侧边栏设置面板 SQLFluff 卡片新增只读「当前生效方言」徽章(紧跟模式徽章、同 adapter-badge-info 样式):方言解析逻辑集中在 sqlfluff.ts——SUPPORTED_DIALECTS 改 export const;hasProjectSqlfluffConfig 重构为 findProjectSqlFluffConfig(返回文件路径);新增 readDialectFromConfigFile(行扫描 [sqlfluff]/[tool.sqlfluff] 段 dialect,支持 = X 与 = "X",不引 TOML 依赖)与导出 resolveSqlFluffDialect(解析链:显式 sqlfluff.dialect > 全局 configFile > 项目配置 > 内置默认 oracle;有配置文件但无 dialect 键回退 sqlfluff 默认 ansi);setupView.ts AdapterConfigStatus 加 sqlfluffDialect 字段并在 collectAdapterStatus 填充、pushConfig i18n 注入 label;setupView.js renderAdapters 对 sqlfluff 卡片追加方言徽章;messages.ts 新增 setup.adapter.sqlfluffDialectLabel 三语。npm test 84 passing / lint 0 error(仅 jsp.ts 2 个既有 curly warning) | 中间产物:设计初稿 v1 的「自动(默认 oracle)」占位文案方案在 Stage ④ 被用户否决(用户改口「展示的方言是现在生效的方言」)→ 澄清后改为完整解析链 v2;解析深度三选一(仅设置值→oracle / 设置值→项目配置→内置 / 含全局配置文件解析)用户选最完整的含全局解析 | src/adapters/sqlfluff.ts src/views/setupView.ts src/views/setupView.js src/i18n/messages.ts docs/superpowers/specs/2026-08-14-sqlfluff-dialect-display-design.md | deepseek-v4-flash |
|
||||
| 2026-08-14 22:24 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 方言徽章配色随来源变化:resolveSqlFluffDialect 返回值从 string 改为 { dialect, source }(source: explicit/global/project/builtin,导出 SqlFluffDialectSource/SqlFluffDialectInfo 类型);setupView.ts AdapterConfigStatus 加 sqlfluffDialectSource 字段、collectAdapterStatus 传解析信息、CSS 新增 .adapter-badge-explicit 蓝色(#388bfd)样式;setupView.js dialectBadgeClassMap 按 source 映射 class(explicit→蓝/global→黄 warn/project→绿 ok/builtin→紫 info),使方言徽章颜色表明控制它的配置范围。npm test 84 passing / lint 0 error / compile 通过;设计文档同步更新配色映射表 | 中间产物:显式设置徽章色三选一(同全局黄 / 独立蓝 / 按 inspect 实际作用域)用户选独立蓝 | src/adapters/sqlfluff.ts src/views/setupView.ts src/views/setupView.js docs/superpowers/specs/2026-08-14-sqlfluff-dialect-display-design.md | deepseek-v4-flash |
|
||||
| 2026-08-14 23:28 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 自动修复功能重构(设计+实现):废弃 AI 修复(src/fixer/fixer.ts 删除),改为 linter 原生 --fix 单条 fix 对象(ESLint msg.fix / Stylelint w.fix)驱动。设计:多轮循环+内存模拟+单次提交、diff 反向撤销(任意顺序)、CodeAction hover 修复链接+面板修复按钮+一键修复全部、修复后重跑静态分析刷新(波浪线/CodeLens/面板已修复变绿+撤销按钮+无行号)、轮次上限默认 3 可配置、JSP/PMD/SQLFluff 一期不支持修复。实现:types.ts 增 LinterFix/LinterDiagnostic.fix;eslint/stylelint 适配器提取 fix;orchestrator 增分析结果缓存+getAdapter;新建 src/fix/(fixEngine 多轮循环内存模拟+单次提交、fixSession 多片段合并撤销、codeActionProvider hover 修复链接);commands 实现 fixIssue/fixAll/undoFix+refreshAfterFix;extension 注册 CodeAction+fixSession;webview 修复状态渲染+AI/custom 修复按钮 bug 修复;merger 只标记带 fix 诊断可修复;config fixer.maxIterations;i18n 增 fix 系列 key;测试 95 passing / lint 0 error / compile 通过。自审查发现并修复:多轮同问题撤销只回退首片段(改多片段合并+倒序一次 applyEdit)、空 fix.text 撤销定位错误 | 中间产物:fixEngine 初版 findClosestFixable/issueStillExists 双函数逻辑简化;fixSession 初版单片段记录改多片段合并;撤销定位空字符串退化处理;merger fixable 断言调整 | src/types.ts src/utils/mockDocument.ts src/adapters/eslint.ts src/adapters/stylelint.ts src/adapters/jsp.ts src/orchestrator/orchestrator.ts src/fix/fixEngine.ts src/fix/fixSession.ts src/fix/codeActionProvider.ts src/activation/commands.ts src/extension.ts src/panel/webview.ts src/merger/merger.ts src/config/fixer.ts package.json src/i18n/messages.ts src/test/fixEngine.test.ts src/test/fixSession.test.ts src/test/config.test.ts src/test/merger.test.ts docs/superpowers/specs/2026-08-14-fixer-refactor-design.md | deepseek-v4-flash |
|
||||
| 2026-08-17 20:05 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复波浪线 hover 不显示「快速修复」链接的 bug:toVscodeDiagnostics 生成的 vscode.Diagnostic 未设置 code/source,VSCode 仅在诊断带 code 时渲染 hover 快速修复链接。补 diag.source='Code Purifier'、diag.code=ruleId。diagnostics.test.ts 补 source/code 断言。npm test 96 passing / lint 0 error / compile 通过;并用 demo-eslint/src/common.js 实测确认 13 条可修复项(no-var→let、prefer-template、no-array-constructor 等) | 中间产物:用临时 _fixcheck2.cjs 脚本实测 common.js 可修复项后删除 | src/diagnostics/diagnosticMarkers.ts src/test/diagnostics.test.ts | deepseek-v4-flash |
|
||||
| 2026-08-18 19:15 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复「已修复」列表生命周期两处问题:①codeReviewer.fixIssue 增加 origin 字段区分来源——codeActionProvider hover 快速修复传 origin:'hover'(不写 fixSession,不入「已修复」列表);面板「修复」按钮在 webview handleMessage 传 { ...message, origin:'panel' }(照常记录可撤销);fixAll/命令面板默认记录不变。②codeReviewer.review(Ctrl+Shift+R)开头新增 fixSession.clear(document.uri),重新审查即清空该文件「已修复」列表,只保留本轮修复。webview PanelMessage 增加 origin 字段。npm test 96 passing / lint 0 error / compile 通过 | 中间产物:澄清阶段曾纠结「新审查清空已修复列表」与「hover 不入列表」二选一,用户先选 hover 不入列表、后又追加重新审查清空,两者合并实现 | src/activation/commands.ts src/fix/codeActionProvider.ts src/panel/webview.ts | deepseek-v4-flash |
|
||||
| 2026-08-18 19:42 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复静态分析条目不显示 AI 中文翻译/建议:根因是 AI 返回的 translatedDiagnostics[].originalRuleId 常不带 eslint: 等前缀,merger 按 d.ruleId 精确匹配导致 0 命中,条目全部回退英文原文+原生 fix 文本兜底。改动:merger.ts 新增 normalizeRuleId(剥离命名空间前缀+取末段)+ findTranslation(先精确后归一化,顺序消费不重复),替换原 translationsByRule 精确 Map;engine.ts buildDeepReviewSystemPrompt 三语各追加 translatedDiagnostics 硬性要求(每一条诊断必须 1:1 翻译、originalRuleId 原样复制含前缀、translatedMessage/translatedSuggestion 必填、建议给具体可执行修复)。merger.test.ts 新增 3 用例(前缀缺失命中/slash 变体命中/精确优先)。npm test 99 passing / lint 0 error / compile 通过 | 中间产物:merger 匹配初版考虑「双 key 注册共享数组」方案因双 key 各持独立数组会导致重复消费,改为 findTranslation 扫描池+splice;eslint.config.mjs 本次会话中两次被 vsce 打包流程删除,均从 git 恢复;用户端已确认 AI 已配置连接成功且 AI tab 有内容(排除 AI 调用失败,锁定配对环节) | src/merger/merger.ts src/ai/engine.ts src/test/merger.test.ts | deepseek-v4-flash |
|
||||
| 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 |
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
# 自动修复功能重构设计书
|
||||
|
||||
> 日期:2026-08-14
|
||||
> 状态:待人类审批(Stage ④)
|
||||
> 流程:① 用户提出 → ② 需求澄清 → ③ 方案设计
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
现有 `src/fixer/fixer.ts` 基于 AI 生成修复文本,但未接线(`fixIssue`/`fixAll` 命令是空壳),且存在以下问题:
|
||||
|
||||
- AI 修复一次调用一次修复,命中率低、上下文小、无法批量收敛
|
||||
- 面板 webview 已发出 `fix`/`fixAll` 消息但无响应
|
||||
- 单条修复按钮文案错用「全部修复」;AI tab 误显示「全部修复」按钮
|
||||
|
||||
本次重构目标:基于 linter 原生 `--fix` 提取**单条 fix 对象**,多轮循环验证收敛,支持 CodeAction 内联修复与面板修复,采用快照 + diff 反向撤销,保证一次撤销回到修复前状态。
|
||||
|
||||
## 2. 需求澄清结论(Stage ② 共识)
|
||||
|
||||
| 决策项 | 结论 |
|
||||
|--------|------|
|
||||
| 交互入口 | 编辑器 hover 悬浮框修复链接(CodeAction QuickFix)+ 审查面板修复按钮 + 一键修复全部 |
|
||||
| 修复来源 | 仅 linter 静态诊断;PMD/SQLFluff/JSP 无单条 fix 能力则不显示修复入口 |
|
||||
| 修复生成 | 优先 linter 原生 `--fix`,提取单条 fix 对象精确修复(ESLint/Stylelint) |
|
||||
| 修复策略 | 多轮循环:每轮修复后重跑 linter 验证,未消除继续修,轮次上限默认 3 可配置 |
|
||||
| 批量修复 | 保留面板「一键修复全部」,逐条循环,修不了的跳过计数 |
|
||||
| 面板撤销 | 每条修复独立记录 diff,撤销时当前文本定位后反向应用该条;可任意顺序撤销互不影响 |
|
||||
| Ctrl+Z | 每个修复会话独立一次编辑提交,多轮循环中间态不入 undo 栈,按会话后进先出逐步回退 |
|
||||
| 修复后刷新 | 自动重跑静态分析 → 波浪线更新/消失、CodeLens 重新获取、面板标记已修复(变绿+撤销按钮+不显示行号但记录行号)、未修复条目行号自动校正 |
|
||||
|
||||
## 3. 架构概览
|
||||
|
||||
废弃 AI 修复模块,改为 linter 原生 fix 驱动:
|
||||
|
||||
```
|
||||
┌─ UI 层 ──────────────────────────────────────────────┐
|
||||
│ FixCodeActionProvider (hover 修复链接) ReviewPanel │
|
||||
└───────────────┬───────────────────────┬──────────────┘
|
||||
│ CodeAction / fix命令 │ fix/undo 消息
|
||||
┌───────────────▼───────────────────────▼──────────────┐
|
||||
│ 核心层 FixEngine (src/fix/) │
|
||||
│ fixDiagnostic() 多轮循环 内存模拟 → 单次提交 │
|
||||
│ FixSessionManager 快照 + FixedEntry diff 栈 │
|
||||
└──────┬───────────────────────────┬───────────────────┘
|
||||
│ adapter.check(文本) │ linter fix 对象
|
||||
┌──────▼───────────────────────────▼───────────────────┐
|
||||
│ 适配层 LinterAdapter.check() 提取 fix 到 LinterFix │
|
||||
│ ESLint(msg.fix) / Stylelint(w.fix) / PMD/SQLFluff ✗ │
|
||||
└──────────────────────┬───────────────────────────────┘
|
||||
│
|
||||
┌──────────────────────▼───────────────────────────────┐
|
||||
│ 基础层 Orchestrator(静态分析结果缓存) 配置 i18n │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**数据流**:
|
||||
|
||||
1. `adapter.check()` 返回诊断 + `fix` 对象 → 存入 Orchestrator 静态分析结果缓存(按 uri)
|
||||
2. 用户 hover 修复链接 / 面板点修复 → `codeReviewer.fixIssue` 命令 → `FixEngine.fixDiagnostic()`
|
||||
3. 多轮循环在**内存字符串**上模拟(mockDocument),最终一次 `WorkspaceEdit` 全文替换提交 → undo 栈只有一次编辑
|
||||
4. 成功记录 `FixedEntry` → 刷新:重跑静态分析 → markers 更新 → CodeLens 刷新 → 面板更新(已修复变绿/撤销按钮/无行号,未修复行号校正)
|
||||
|
||||
## 4. 文件变更清单
|
||||
|
||||
### 新建
|
||||
|
||||
| 文件 | 职责 |
|
||||
|------|------|
|
||||
| `src/fix/fixEngine.ts` | 多轮修复循环核心 |
|
||||
| `src/fix/fixSession.ts` | 修复会话、FixedEntry diff 记录、撤销定位 |
|
||||
| `src/fix/codeActionProvider.ts` | 编辑器 QuickFix CodeAction 提供者 |
|
||||
| `src/utils/mockDocument.ts` | 从 jsp.ts 提取的 mockDocument(内存文本→TextDocument),供 fix 引擎复用 |
|
||||
| `src/test/fixEngine.test.ts` | 修复引擎测试 |
|
||||
| `src/test/fixSession.test.ts` | 会话撤销测试 |
|
||||
|
||||
### 修改
|
||||
|
||||
| 文件 | 变更 |
|
||||
|------|------|
|
||||
| `src/types.ts` | `LinterDiagnostic` 增 `fix?: LinterFix`;新增 `LinterFix` 接口 |
|
||||
| `src/adapters/eslint.ts` | 提取 `msg.fix` 填充 fix 字段 |
|
||||
| `src/adapters/stylelint.ts` | 提取 `w.fix` 填充 fix 字段 |
|
||||
| `src/orchestrator/orchestrator.ts` | 增静态分析结果缓存(uri→diagnostics/adapter/workingDir) |
|
||||
| `src/activation/commands.ts` | 实现 `fixIssue`/`fixAll`,新增 `undoFix`,review 更新缓存 |
|
||||
| `src/extension.ts` | 注册 FixCodeActionProvider |
|
||||
| `src/panel/webview.ts` | 修复状态渲染 + undo 消息 + 修正 fixAll 按钮 bug |
|
||||
| `src/config/fixer.ts` | `getFixMaxIterations()`(替代废弃的 contextLines) |
|
||||
| `src/i18n/messages.ts` | 新增 fix 相关 key,删除 fixNotAvailable |
|
||||
| `package.json` | 配置 `fixer.maxIterations`,移除 `fixer.contextLines` |
|
||||
|
||||
### 删除
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/fixer/fixer.ts` | 整个旧 AI 修复模块 |
|
||||
|
||||
## 5. 关键接口定义
|
||||
|
||||
### 5.1 `src/types.ts` 新增
|
||||
|
||||
```typescript
|
||||
export interface LinterFix {
|
||||
range: [number, number]; // 0-based 字符偏移,相对 lint 传入文本
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LinterDiagnostic {
|
||||
severity: Severity;
|
||||
ruleId: string;
|
||||
message: string;
|
||||
range: vscode.Range;
|
||||
suggestion?: string;
|
||||
fix?: LinterFix; // 新增:可自动修复时存在
|
||||
}
|
||||
```
|
||||
|
||||
> ESLint 9 的 `LinterMessage.fix` 与 Stylelint 的 `Warning.fix?: EditInfo` 结构完全一致,均为 `{ range: [number, number], text: string }`,可直接透传。
|
||||
|
||||
### 5.2 `src/fix/fixEngine.ts`
|
||||
|
||||
```typescript
|
||||
export interface FixResult {
|
||||
success: boolean;
|
||||
attempts: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export async function fixDiagnostic(
|
||||
document: vscode.TextDocument,
|
||||
workingDir: string,
|
||||
adapter: LinterAdapter,
|
||||
diag: LinterDiagnostic,
|
||||
maxIterations: number
|
||||
): Promise<FixResult>;
|
||||
```
|
||||
|
||||
### 5.3 `src/fix/fixSession.ts`
|
||||
|
||||
```typescript
|
||||
export interface FixedEntry {
|
||||
key: string; // `${ruleId}@${line}`(修复前原始行号)
|
||||
ruleId: string;
|
||||
line: number; // 修复前原始行号(撤销定位用,面板不显示)
|
||||
originalText: string; // 修复前该片段
|
||||
newText: string; // 修复后该片段
|
||||
source: 'linter';
|
||||
}
|
||||
|
||||
export class FixSessionManager {
|
||||
add(uri: vscode.Uri, entry: FixedEntry): void;
|
||||
get(uri: vscode.Uri, key: string): FixedEntry | undefined;
|
||||
undo(document: vscode.TextDocument, key: string): Promise<boolean>;
|
||||
has(uri: vscode.Uri, key: string): boolean;
|
||||
clear(uri: vscode.Uri): void;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 `src/fix/codeActionProvider.ts`
|
||||
|
||||
```typescript
|
||||
export class FixCodeActionProvider implements vscode.CodeActionProvider {
|
||||
constructor(orchestrator: Orchestrator);
|
||||
provideCodeActions(...): vscode.CodeAction[]; // QuickFix,command 指向 codeReviewer.fixIssue
|
||||
}
|
||||
```
|
||||
|
||||
### 5.5 Orchestrator 缓存
|
||||
|
||||
```typescript
|
||||
setAnalysisResult(uri: vscode.Uri, result: {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
adapterId: string;
|
||||
workingDir: string;
|
||||
}): void;
|
||||
|
||||
getAnalysisResult(uri: vscode.Uri): {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
adapterId: string;
|
||||
workingDir: string;
|
||||
} | undefined;
|
||||
```
|
||||
|
||||
### 5.6 命令协议
|
||||
|
||||
- 面板消息沿用现有 `{ type, line, ruleId, source }`
|
||||
- 新增 `{ type: 'undo', line, ruleId, source }` → `codeReviewer.undoFix`
|
||||
|
||||
## 6. 核心逻辑
|
||||
|
||||
### 6.1 多轮循环(fixEngine.ts)
|
||||
|
||||
```
|
||||
currentText = document.getText()
|
||||
for round in 1..maxIterations:
|
||||
mock = mockDocument(currentText, languageId, fileName)
|
||||
result = adapter.check(mock, workingDir)
|
||||
target = 找到 ruleId 匹配且行号最接近、带 fix 的诊断
|
||||
if !target?.fix:
|
||||
success = false; message = '无法自动修复'; break
|
||||
newText = applyFix(currentText, target.fix) // 字符串级 offset 替换
|
||||
currentText = newText
|
||||
// 预验证:重跑 check 若该问题消失 → success
|
||||
if 未收敛(达到轮次上限仍存在):
|
||||
success = false; message = '达到轮次上限仍未修复'
|
||||
一次 WorkspaceEdit 将 document 全文替换为 currentText(单次提交)
|
||||
```
|
||||
|
||||
### 6.2 撤销(fixSession.ts)
|
||||
|
||||
- 每次修复成功后,从最终提交前后提取片段记录 `FixedEntry`
|
||||
- 撤销:打开文档 → 行号优先匹配 `newText` 首行 → 全文搜索回退 → `WorkspaceEdit` 替换回 `originalText`
|
||||
- 先修 A 再修 B,撤销 A:在当前文本定位 A 的 newText 反向替换,B 保留
|
||||
- 匹配失败(用户手动改动了相关区域):提示重新审查
|
||||
|
||||
### 6.3 CodeAction
|
||||
|
||||
- 从 Orchestrator 缓存取当前文档诊断,为每个带 `fix` 的生成 QuickFix CodeAction
|
||||
- 命令执行 `codeReviewer.fixIssue`(多轮 + 会话),不直接用 `action.edit`
|
||||
- hover 悬浮框自动显示修复链接(VSCode 标准行为)
|
||||
|
||||
### 6.4 面板状态
|
||||
|
||||
- 面板内部维护 `fixedEntries: Map<key, FixedEntry>`
|
||||
- linter tab = 新报告未修复条目(行号+修复按钮) + fixedEntries(变绿+撤销按钮+不显示行号)
|
||||
- 修复后自动刷新(重跑静态分析 → markers → CodeLens → 面板)
|
||||
- 顺带修复既有 bug:AI tab 误显示 fixAll 按钮、单条修复按钮文案错用「全部修复」
|
||||
|
||||
### 6.5 适配器 fix 提取
|
||||
|
||||
| 适配器 | fix 来源 | 配置解析链 |
|
||||
|--------|----------|-----------|
|
||||
| ESLint | `msg.fix` | 全局 `linters.eslintConfigPath` → 项目 `eslint.config.*` → 内置规则 |
|
||||
| Stylelint | `warning.fix` | 全局 `linters.stylelintConfigPath` → 项目 `.stylelintrc` → 内置规则 |
|
||||
| PMD | ✗(无 --fix) | — |
|
||||
| SQLFluff | ✗(CLI 整文件操作) | — |
|
||||
| JSP | ✗(fix 偏移映射复杂,一期不支持) | — |
|
||||
|
||||
## 7. 技术选型与影响范围
|
||||
|
||||
- **mockDocument**:jsp.ts 已有完整实现,提取复用,零新增依赖
|
||||
- **fix 对象结构**:ESLint `LinterMessage.fix` / Stylelint `EditInfo` 均为 `{range, text}`,已验证
|
||||
- **影响面**:types.ts(契约)、eslint/stylelint 适配器(提取 fix)、orchestrator(缓存)、commands/extension/panel/i18n/config、删除旧 fixer 模块
|
||||
- **撤销限制**:依赖当前文档文本匹配,修复后用户大幅改动相关区域会撤销失败并提示
|
||||
|
||||
## 8. 配置变更
|
||||
|
||||
```jsonc
|
||||
// package.json configuration.properties 新增
|
||||
"vscode-code-reviewer.fixer.maxIterations": {
|
||||
"type": "number",
|
||||
"default": 3,
|
||||
"description": "单条问题自动修复的最大循环轮次"
|
||||
}
|
||||
// 移除
|
||||
"vscode-code-reviewer.fixer.contextLines" // AI 修复遗留,废弃
|
||||
```
|
||||
|
||||
## 9. 测试计划
|
||||
|
||||
- `fixEngine.test.ts`:单条可修问题多轮收敛、不可修问题返回失败、达到轮次上限
|
||||
- `fixSession.test.ts`:顺序修复→撤销、交错撤销(先 A 后 B 撤销 A 保留 B)
|
||||
- 现有 `npm test`(84 tests)回归通过
|
||||
|
||||
## 10. 验证顺序
|
||||
|
||||
`lint → compile → test`(`npm run lint` / `npm run compile` / `npm test`)
|
||||
@@ -0,0 +1,161 @@
|
||||
# SQLFluff 方言只读展示 — 设计 Spec
|
||||
|
||||
日期:2026-08-14
|
||||
状态:已审批
|
||||
关联流程:① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批
|
||||
|
||||
## 1. 背景
|
||||
|
||||
侧边栏"设置"面板(`codeReviewer.setupView`)的 SQLFluff 适配器卡片目前只展示
|
||||
"创建项目配置 / 修改全局设置"两个按钮,不直接展示 SQL 审查当前生效的方言。
|
||||
用户希望在卡片内以"内置规则"徽章的同款样式,只读展示当前生效方言。
|
||||
|
||||
## 2. 共识(Stage ② 确认)
|
||||
|
||||
- 呈现形式:只读展示,不可在侧边栏直接修改;修改仍需去 VS Code 设置(`sqlfluff.dialect`)。
|
||||
- 位置:SQLFluff 卡片徽章行,紧跟"内置规则/项目配置/全局配置"模式徽章之后。
|
||||
- 样式:徽章配色随**方言实际来源**变化(显式=蓝/全局配置=黄/项目配置=绿/内置=紫),一眼可辨方言由哪个配置控制。
|
||||
- 展示内容:**当前实际生效方言**(非占位文案),按运行时优先级完整解析。
|
||||
- 解析深度(Stage ② 最终确认):含全局配置文件解析。
|
||||
|
||||
## 3. 方案
|
||||
|
||||
### 3.1 架构概览
|
||||
|
||||
无需架构调整,改动局限在设置面板三件套 + i18n 文案 + 方言解析逻辑集中在
|
||||
`src/adapters/sqlfluff.ts`,保证展示值与适配器实际使用完全一致。
|
||||
|
||||
数据流(复用现有链路):
|
||||
|
||||
```
|
||||
collectAdapterStatus() ──pushConfig()──> postMessage(initConfig.adapterStatus)
|
||||
└─> setupView.js renderAdapters() 渲染方言徽章
|
||||
```
|
||||
|
||||
设置变更实时刷新:`setupView.ts:266-275` 已监听 `vscode-code-reviewer.sqlfluff`
|
||||
变更并触发 `pushConfig()`,方言徽章自动跟随更新,无需额外订阅。
|
||||
|
||||
### 3.2 生效方言解析链(与 sqlfluff.ts 运行时行为逐一对齐)
|
||||
|
||||
| 优先级 | 来源 | 生效值 |
|
||||
|--------|------|--------|
|
||||
| 1 | 显式设置 `sqlfluff.dialect`(受支持集合内) | 该值(经 `--dialect` 覆盖一切) |
|
||||
| 2 | 全局 `sqlfluff.configFile` | 解析该文件 `[sqlfluff]`/`[tool.sqlfluff]` 段 `dialect`;无该键则 sqlfluff 默认 `ansi` |
|
||||
| 3 | 项目配置(`.sqlfluff`/`setup.cfg`/`tox.ini`/`pep8.ini`/`pyproject.toml`) | 同上,解析 dialect,无则 `ansi` |
|
||||
| 4 | 无任何配置 → 插件内置配置 | `oracle`(SQL/PLSQL 兜底方言) |
|
||||
|
||||
### 3.3 文件变更清单
|
||||
|
||||
| 文件 | 变更 | 说明 |
|
||||
|------|------|------|
|
||||
| `src/adapters/sqlfluff.ts` | 修改 | `SUPPORTED_DIALECTS` 改 `export const`;`hasProjectSqlfluffConfig` 重构为 `findProjectSqlFluffConfig()`(返回文件路径);新增 `readDialectFromConfigFile()`;`resolveSqlFluffDialect()` 返回 `{ dialect, source }`(source: explicit/global/project/builtin) |
|
||||
| `src/views/setupView.ts` | 修改 | 引入 `resolveSqlFluffDialect`;`AdapterConfigStatus` 加 `sqlfluffDialect?: string` 与 `sqlfluffDialectSource?`;`collectAdapterStatus()` 填充;`pushConfig()` i18n 加 label;新增 `.adapter-badge-explicit` 蓝色徽章样式 |
|
||||
| `src/views/setupView.js` | 修改 | `renderAdapters()` 对 sqlfluff 在模式徽章后追加方言徽章,按 `sqlfluffDialectSource` 映射徽章 class(explicit→蓝/global→warn/ project→ok/builtin→info) |
|
||||
| `src/i18n/messages.ts` | 修改 | 新增 `setup.adapter.sqlfluffDialectLabel` 三语文案 |
|
||||
|
||||
无新建、无删除。
|
||||
|
||||
### 3.3.1 方言徽章配色映射
|
||||
|
||||
| source | 含义 | 徽章 class | 颜色 |
|
||||
|--------|------|-----------|------|
|
||||
| `explicit` | 显式设置 `sqlfluff.dialect` | `adapter-badge-explicit` | 蓝 `#388bfd` |
|
||||
| `global` | 全局 `sqlfluff.configFile` | `adapter-badge-warn` | 黄 `#d29922` |
|
||||
| `project` | 项目 `.sqlfluff` 等 | `adapter-badge-ok` | 绿 `#3fb950` |
|
||||
| `builtin` | 插件内置默认 | `adapter-badge-info` | 紫 `#8b5cf6` |
|
||||
|
||||
### 3.4 关键实现
|
||||
|
||||
**sqlfluff.ts**
|
||||
|
||||
```ts
|
||||
export const SUPPORTED_DIALECTS = [ 'ansi', ..., 'vertica' ];
|
||||
|
||||
function findProjectSqlFluffConfig(workspaceRoot: string): string | undefined {
|
||||
// 由 hasProjectSqlfluffConfig 重构:返回实际命中的文件路径
|
||||
}
|
||||
|
||||
function readDialectFromConfigFile(filePath: string): string | undefined {
|
||||
// 行扫描 [sqlfluff] / [tool.sqlfluff] 段内 dialect = X 或 dialect = "X"
|
||||
}
|
||||
|
||||
export type SqlFluffDialectSource = 'explicit' | 'global' | 'project' | 'builtin';
|
||||
export interface SqlFluffDialectInfo { dialect: string; source: SqlFluffDialectSource; }
|
||||
|
||||
export function resolveSqlFluffDialect(workspaceRoot: string): SqlFluffDialectInfo {
|
||||
const explicit = getSqlFluffDialect();
|
||||
if (explicit && SUPPORTED_DIALECTS.includes(explicit)) {
|
||||
return { dialect: explicit, source: 'explicit' };
|
||||
}
|
||||
const globalCfg = getSqlFluffConfigFile();
|
||||
if (globalCfg && globalCfg.trim() !== '') {
|
||||
return { dialect: readDialectFromConfigFile(globalCfg) ?? 'ansi', source: 'global' };
|
||||
}
|
||||
const projectFile = findProjectSqlFluffConfig(workspaceRoot);
|
||||
if (projectFile) {
|
||||
return { dialect: readDialectFromConfigFile(projectFile) ?? 'ansi', source: 'project' };
|
||||
}
|
||||
return { dialect: 'oracle', source: 'builtin' };
|
||||
}
|
||||
```
|
||||
|
||||
**setupView.ts**
|
||||
|
||||
```ts
|
||||
import { resolveSqlFluffDialect, type SqlFluffDialectSource } from '../adapters/sqlfluff';
|
||||
|
||||
interface AdapterConfigStatus {
|
||||
// ...原有字段
|
||||
sqlfluffDialect?: string; // 仅 sqlfluff 生效
|
||||
sqlfluffDialectSource?: SqlFluffDialectSource;
|
||||
}
|
||||
|
||||
// collectAdapterStatus() sqlfluff 分支:
|
||||
const dialectInfo = id === 'sqlfluff'
|
||||
? resolveSqlFluffDialect(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '')
|
||||
: undefined;
|
||||
status.sqlfluffDialect = dialectInfo?.dialect;
|
||||
status.sqlfluffDialectSource = dialectInfo?.source;
|
||||
```
|
||||
|
||||
**setupView.js `renderAdapters()`**
|
||||
|
||||
```js
|
||||
var dialectBadgeClassMap = {
|
||||
explicit: 'adapter-badge-explicit',
|
||||
global: 'adapter-badge-warn',
|
||||
project: 'adapter-badge-ok',
|
||||
builtin: 'adapter-badge-info',
|
||||
};
|
||||
var dialectBadgeClass = dialectBadgeClassMap[a.sqlfluffDialectSource] || 'adapter-badge-info';
|
||||
var dialectBadge = a.id === 'sqlfluff'
|
||||
? '<span class="adapter-badge ' + dialectBadgeClass + '">' +
|
||||
escapeHtml(i18n.sqlfluffDialectLabel) + ' ' +
|
||||
escapeHtml(a.sqlfluffDialect || '') +
|
||||
'</span>'
|
||||
: '';
|
||||
```
|
||||
|
||||
**messages.ts 新增 key**
|
||||
|
||||
| key | zh-CN | en | ja |
|
||||
|-----|-------|-----|-----|
|
||||
| `setup.adapter.sqlfluffDialectLabel` | `方言` | `Dialect` | `方言` |
|
||||
|
||||
### 3.5 影响范围
|
||||
|
||||
- 仅设置面板 SQLFluff 卡片的 UI 展示,不影响分析执行逻辑、配置优先级、package.json。
|
||||
- `sqlfluff.dialect` 设置值本身不变;`src/config/linter.ts` 的 `getSqlFluffDialect`/`getSqlFluffConfigFile` 被复用。
|
||||
- 新增对 `adapters/sqlfluff` 模块的跨层 import(仅常量与纯函数,无副作用)。
|
||||
|
||||
### 3.6 验证方式
|
||||
|
||||
1. `npm test`(lint → compile → test)。
|
||||
2. 手工四态:显式 postgres / 全局配置文件含方言 / 项目 `.sqlfluff` 含方言 / 无配置 →
|
||||
侧边栏 SQLFluff 卡片分别显示"方言 postgres(蓝)" / 对应值(黄) / 对应值(绿) / "方言 oracle(紫)"。
|
||||
|
||||
## 4. 风险
|
||||
|
||||
- "无配置默认 oracle"与"有配置文件但无 dialect → ansi"的差异语义:与运行时行为一致,
|
||||
展示的是真实生效值,属预期。
|
||||
- `SUPPORTED_DIALECTS` 与 package.json enum 清单重复维护,本次仅复用代码内既有常量,不新增重复。
|
||||
+3
-3
@@ -303,10 +303,10 @@
|
||||
"default": true,
|
||||
"description": "启用/禁用 Stylelint 适配器"
|
||||
},
|
||||
"vscode-code-reviewer.fixer.contextLines": {
|
||||
"vscode-code-reviewer.fixer.maxIterations": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"description": "AI 修复时提取的上下文行数"
|
||||
"default": 3,
|
||||
"description": "单条问题自动修复的最大循环轮次"
|
||||
},
|
||||
"vscode-code-reviewer.codelens.enabled": {
|
||||
"type": "boolean",
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ await esbuild.build({
|
||||
const viewsSrc = resolve(rootDir, 'src', 'views');
|
||||
const webviewDir = resolve(outDir, 'webview');
|
||||
if (existsSync(viewsSrc)) {
|
||||
const files = ['setupView.js'];
|
||||
const files = ['setupView.js', 'reviewPanel.js'];
|
||||
for (const f of files) {
|
||||
const src = resolve(viewsSrc, f);
|
||||
const dest = resolve(webviewDir, f);
|
||||
|
||||
@@ -7,7 +7,7 @@ const rootDir = resolve(__dirname, '..');
|
||||
const outViews = resolve(rootDir, 'out', 'views');
|
||||
const srcViews = resolve(rootDir, 'src', 'views');
|
||||
|
||||
const files = ['setupView.js'];
|
||||
const files = ['setupView.js', 'reviewPanel.js'];
|
||||
for (const f of files) {
|
||||
const src = resolve(srcViews, f);
|
||||
const webviewDir = resolve(rootDir, 'out', 'webview');
|
||||
|
||||
+171
-4
@@ -13,10 +13,66 @@ 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 { FixSessionManager } from '../fix/fixSession';
|
||||
import { getFixMaxIterations } from '../config';
|
||||
import type { CustomRule } from '../types';
|
||||
|
||||
let currentReport: MergedReport | null = null;
|
||||
|
||||
function resolveFixDocument(
|
||||
report: MergedReport | null,
|
||||
active: vscode.TextEditor | undefined,
|
||||
origin?: 'hover' | 'panel'
|
||||
): vscode.TextDocument | undefined {
|
||||
if (origin === 'hover') { return active?.document; }
|
||||
if (report) {
|
||||
return vscode.workspace.textDocuments.find(d => d.uri.fsPath === report.filePath);
|
||||
}
|
||||
return active?.document;
|
||||
}
|
||||
|
||||
async function refreshAfterFix(
|
||||
document: vscode.TextDocument,
|
||||
orchestrator: Orchestrator,
|
||||
markers: DiagnosticMarkers,
|
||||
codeLensProvider: MethodCodeLensProvider,
|
||||
extensionUri: vscode.Uri,
|
||||
fixSession: FixSessionManager
|
||||
): Promise<void> {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
const result = await orchestrator.runStaticAnalysis(document, workingDir);
|
||||
if (isMarkersEnabled()) {
|
||||
markers.apply(document.uri, result.diagnostics);
|
||||
}
|
||||
codeLensProvider.refresh();
|
||||
|
||||
if (currentReport && currentReport.filePath === document.uri.fsPath) {
|
||||
currentReport = mergeResults({
|
||||
staticDiagnostics: result.diagnostics,
|
||||
customRuleResults: currentReport.customRuleDiagnostics.map(d => ({
|
||||
ruleId: d.ruleId,
|
||||
severity: d.severity,
|
||||
message: d.message,
|
||||
line: d.range.start.line + 1,
|
||||
})),
|
||||
translatedDiagnostics: currentReport.translatedDiagnostics,
|
||||
aiFindings: currentReport.aiFindings,
|
||||
errors: result.errors,
|
||||
degraded: currentReport.degraded,
|
||||
startTime: Date.now(),
|
||||
filePath: document.uri.fsPath,
|
||||
language: document.languageId,
|
||||
adapterIds: result.adapterIds,
|
||||
customRuleFilterInfo: currentReport.customRuleFilterInfo,
|
||||
});
|
||||
const panel = ReviewPanel.createOrShow(extensionUri);
|
||||
panel.setFixSession(fixSession);
|
||||
panel.update(currentReport);
|
||||
}
|
||||
}
|
||||
|
||||
async function openSetupPanel(): Promise<void> {
|
||||
try {
|
||||
await vscode.commands.executeCommand('workbench.view.extension.code-reviewer');
|
||||
@@ -37,6 +93,7 @@ export function registerCommands(
|
||||
codeLensProvider: MethodCodeLensProvider,
|
||||
statusCache: ReviewStatusCache,
|
||||
markers: DiagnosticMarkers,
|
||||
fixSession: FixSessionManager,
|
||||
): void {
|
||||
|
||||
context.subscriptions.push(
|
||||
@@ -48,6 +105,7 @@ export function registerCommands(
|
||||
}
|
||||
|
||||
const document = editor.document;
|
||||
fixSession.clear(document.uri);
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
|
||||
@@ -88,6 +146,7 @@ export function registerCommands(
|
||||
});
|
||||
|
||||
const panel = ReviewPanel.createOrShow(context.extensionUri);
|
||||
panel.setFixSession(fixSession);
|
||||
panel.update(currentReport);
|
||||
|
||||
if (isMarkersEnabled()) {
|
||||
@@ -186,6 +245,7 @@ export function registerCommands(
|
||||
codeLensProvider.refresh();
|
||||
|
||||
const panel = ReviewPanel.createOrShow(context.extensionUri);
|
||||
panel.setFixSession(fixSession);
|
||||
panel.update(currentReport);
|
||||
|
||||
vscode.window.showInformationMessage(
|
||||
@@ -200,6 +260,7 @@ export function registerCommands(
|
||||
ReviewPanel.createOrShow(context.extensionUri);
|
||||
if (currentReport) {
|
||||
const panel = ReviewPanel.createOrShow(context.extensionUri);
|
||||
panel.setFixSession(fixSession);
|
||||
panel.update(currentReport);
|
||||
}
|
||||
})
|
||||
@@ -249,14 +310,120 @@ export function registerCommands(
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixIssue', () => {
|
||||
vscode.window.showInformationMessage(t('review.fixNotAvailable'));
|
||||
vscode.commands.registerCommand('codeReviewer.fixIssue', async (payload?: { line?: number; ruleId?: string; source?: string; origin?: 'hover' | 'panel' }) => {
|
||||
try {
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, payload?.origin);
|
||||
if (!document) { console.log('[code-reviewer] fixIssue: no target document'); return; }
|
||||
const cached = orchestrator.getAnalysisResult(document.uri);
|
||||
if (!cached) { console.log(`[code-reviewer] fixIssue: no cached analysis for ${document.uri.toString()}`); return; }
|
||||
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
const adapter = orchestrator.getAdapter(cached.adapterId);
|
||||
if (!adapter) { console.log(`[code-reviewer] fixIssue: adapter not found: ${cached.adapterId}`); return; }
|
||||
|
||||
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)
|
||||
) ?? cached.diagnostics.find(d => d.fix);
|
||||
|
||||
if (!diag) {
|
||||
vscode.window.showWarningMessage(t('fix.noFix'));
|
||||
return;
|
||||
}
|
||||
|
||||
const maxIterations = getFixMaxIterations();
|
||||
const result = await fixDiagnostic(document, workingDir, adapter, diag, maxIterations);
|
||||
if (!result.success) {
|
||||
vscode.window.showWarningMessage(t('fix.failed', { 0: result.message ?? '' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (payload?.origin !== 'hover') {
|
||||
fixSession.recordFixes(document.uri, diag.ruleId, diag.range.start.line, result.appliedFixes);
|
||||
}
|
||||
await document.save();
|
||||
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
|
||||
vscode.window.showInformationMessage(t('fix.applied'));
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] fixIssue failed:', err);
|
||||
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixAll', () => {
|
||||
vscode.window.showInformationMessage(t('review.fixAllNotAvailable'));
|
||||
vscode.commands.registerCommand('codeReviewer.fixAll', async () => {
|
||||
try {
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
|
||||
if (!document) { console.log('[code-reviewer] fixAll: no target document'); return; }
|
||||
const cached = orchestrator.getAnalysisResult(document.uri);
|
||||
if (!cached) { console.log(`[code-reviewer] fixAll: no cached analysis for ${document.uri.toString()}`); return; }
|
||||
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
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);
|
||||
if (fixables.length === 0) {
|
||||
vscode.window.showInformationMessage(t('fix.noFix'));
|
||||
return;
|
||||
}
|
||||
|
||||
const maxIterations = getFixMaxIterations();
|
||||
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 < 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);
|
||||
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) }));
|
||||
} catch (err) {
|
||||
console.error('[code-reviewer] fixAll failed:', err);
|
||||
vscode.window.showErrorMessage(t('fix.failed', { 0: err instanceof Error ? err.message : String(err) }));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.undoFix', async (payload?: { line?: number; ruleId?: string; source?: string }) => {
|
||||
const document = resolveFixDocument(currentReport, vscode.window.activeTextEditor, 'panel');
|
||||
if (!document) { console.log('[code-reviewer] undoFix: no target document'); return; }
|
||||
const line = payload?.line ?? -1;
|
||||
const ruleId = payload?.ruleId ?? '';
|
||||
if (!ruleId) { return; }
|
||||
const key = `${ruleId}@${line}`;
|
||||
const ok = await fixSession.undo(document, key);
|
||||
if (ok) {
|
||||
await document.save();
|
||||
await refreshAfterFix(document, orchestrator, markers, codeLensProvider, context.extensionUri, fixSession);
|
||||
vscode.window.showInformationMessage(t('fix.undone'));
|
||||
} else {
|
||||
vscode.window.showWarningMessage(t('fix.undoFailed'));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ export class ESLintAdapter implements LinterAdapter {
|
||||
(msg.endColumn ?? msg.column) - 1
|
||||
),
|
||||
suggestion: msg.fix?.text,
|
||||
fix: msg.fix ? { range: [msg.fix.range[0], msg.fix.range[1]], text: msg.fix.text } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-47
@@ -5,53 +5,7 @@ import { ESLintAdapter } from './eslint';
|
||||
import { StylelintAdapter } from './stylelint';
|
||||
import { extractJspSections, type JspSection } from '../jsp/jsp-extractor';
|
||||
import { getLinterForLanguage } from '../config';
|
||||
|
||||
function mockDocument(code: string, language: string): vscode.TextDocument {
|
||||
const lines = code.split('\n');
|
||||
const uri = vscode.Uri.parse('untitled:virtual');
|
||||
const ext = language === 'javascript' ? 'js' : language === 'typescript' ? 'ts' : language === 'css' ? 'css' : 'java';
|
||||
return {
|
||||
uri,
|
||||
fileName: `untitled.${ext}`,
|
||||
isUntitled: true,
|
||||
languageId: language,
|
||||
version: 1,
|
||||
isDirty: false,
|
||||
isClosed: false,
|
||||
eol: vscode.EndOfLine.LF,
|
||||
lineCount: lines.length,
|
||||
getText: () => code,
|
||||
lineAt: (arg: number | vscode.Position) => {
|
||||
const line = typeof arg === 'number' ? arg : arg.line;
|
||||
const text = lines[line] ?? '';
|
||||
return {
|
||||
lineNumber: line,
|
||||
text,
|
||||
range: new vscode.Range(line, 0, line, text.length),
|
||||
rangeIncludingLineBreak: new vscode.Range(line, 0, line, text.length),
|
||||
firstNonWhitespaceCharacterIndex: text.search(/\S|$/),
|
||||
isEmptyOrWhitespace: text.trim().length === 0,
|
||||
};
|
||||
},
|
||||
offsetAt: (p: vscode.Position) => {
|
||||
let offset = 0;
|
||||
for (let i = 0; i < p.line; i++) offset += lines[i].length + 1;
|
||||
return offset + p.character;
|
||||
},
|
||||
positionAt: (offset: number) => {
|
||||
let remaining = offset;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (remaining <= lines[i].length) return new vscode.Position(i, remaining);
|
||||
remaining -= lines[i].length + 1;
|
||||
}
|
||||
return new vscode.Position(lines.length - 1, lines[lines.length - 1].length);
|
||||
},
|
||||
getWordRangeAtPosition: () => undefined,
|
||||
validateRange: (r: vscode.Range) => r,
|
||||
validatePosition: (p: vscode.Position) => p,
|
||||
save: () => Promise.resolve(false),
|
||||
} as unknown as vscode.TextDocument;
|
||||
}
|
||||
import { mockDocument } from '../utils/mockDocument';
|
||||
|
||||
const WRAP_TEMPLATES: Record<NonNullable<JspSection['scriptletKind']>, {
|
||||
header: string;
|
||||
|
||||
@@ -14,7 +14,7 @@ const DIALECT_MAP: Record<string, string> = {
|
||||
plsql: 'oracle',
|
||||
};
|
||||
|
||||
const SUPPORTED_DIALECTS = [
|
||||
export const SUPPORTED_DIALECTS = [
|
||||
'ansi', 'athena', 'bigquery', 'clickhouse', 'databricks', 'db2', 'doris',
|
||||
'duckdb', 'exasol', 'flink', 'greenplum', 'hive', 'impala', 'mariadb',
|
||||
'materialize', 'mysql', 'oracle', 'postgres', 'redshift', 'snowflake',
|
||||
@@ -51,7 +51,7 @@ export function buildPRSMessage(description: string, dialect: string): string {
|
||||
return t('adapter.sqlfluffPRS', { 0: dialect, 1: fragment });
|
||||
}
|
||||
|
||||
function hasProjectSqlfluffConfig(workspaceRoot: string): boolean {
|
||||
function findProjectSqlFluffConfig(workspaceRoot: string): string | undefined {
|
||||
const candidates: Array<{ file: string; marker: string | null }> = [
|
||||
{ file: '.sqlfluff', marker: null },
|
||||
{ file: 'setup.cfg', marker: '[sqlfluff]' },
|
||||
@@ -62,11 +62,56 @@ function hasProjectSqlfluffConfig(workspaceRoot: string): boolean {
|
||||
for (const candidate of candidates) {
|
||||
const filePath = path.join(workspaceRoot, candidate.file);
|
||||
if (!fs.existsSync(filePath)) { continue; }
|
||||
if (candidate.marker === null) { return true; }
|
||||
if (candidate.marker === null) { return filePath; }
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
if (content.includes(candidate.marker)) { return true; }
|
||||
if (content.includes(candidate.marker)) { return filePath; }
|
||||
}
|
||||
return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readDialectFromConfigFile(filePath: string): string | undefined {
|
||||
try {
|
||||
const lines = fs.readFileSync(filePath, 'utf-8').split(/\r?\n/);
|
||||
let inSection = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (/^\[(tool\.)?sqlfluff\]\s*$/.test(trimmed)) {
|
||||
inSection = true;
|
||||
continue;
|
||||
}
|
||||
if (!inSection) { continue; }
|
||||
if (/^\[/.test(trimmed)) { break; }
|
||||
const match = /^dialect\s*[:=]\s*"?([A-Za-z0-9_]+)"?/.exec(trimmed);
|
||||
if (match) { return match[1]; }
|
||||
}
|
||||
} catch {}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type SqlFluffDialectSource = 'explicit' | 'global' | 'project' | 'builtin';
|
||||
|
||||
export interface SqlFluffDialectInfo {
|
||||
dialect: string;
|
||||
source: SqlFluffDialectSource;
|
||||
}
|
||||
|
||||
export function resolveSqlFluffDialect(workspaceRoot: string): SqlFluffDialectInfo {
|
||||
const explicit = getSqlFluffDialect();
|
||||
if (explicit && SUPPORTED_DIALECTS.includes(explicit)) {
|
||||
return { dialect: explicit, source: 'explicit' };
|
||||
}
|
||||
|
||||
const globalConfig = getSqlFluffConfigFile();
|
||||
if (globalConfig && globalConfig.trim() !== '') {
|
||||
return { dialect: readDialectFromConfigFile(globalConfig) ?? 'ansi', source: 'global' };
|
||||
}
|
||||
|
||||
const projectConfig = findProjectSqlFluffConfig(workspaceRoot);
|
||||
if (projectConfig) {
|
||||
return { dialect: readDialectFromConfigFile(projectConfig) ?? 'ansi', source: 'project' };
|
||||
}
|
||||
|
||||
return { dialect: 'oracle', source: 'builtin' };
|
||||
}
|
||||
|
||||
interface SqlFluffViolation {
|
||||
@@ -149,7 +194,7 @@ export class SqlFluffAdapter implements LinterAdapter {
|
||||
const globalConfig = getSqlFluffConfigFile();
|
||||
if (globalConfig && globalConfig.trim() !== '') {
|
||||
configPath = globalConfig;
|
||||
} else if (hasProjectSqlfluffConfig(workingDir)) {
|
||||
} else if (findProjectSqlFluffConfig(workingDir)) {
|
||||
} else {
|
||||
tempConfigPath = path.join(os.tmpdir(), `vscode-code-reviewer-sqlfluff-${Date.now()}.cfg`);
|
||||
fs.writeFileSync(tempConfigPath, buildBuiltinSqlfluffConfig(cliDialect ?? fallbackDialect), 'utf-8');
|
||||
|
||||
@@ -47,6 +47,7 @@ interface LinterResult {
|
||||
rule: string;
|
||||
severity: string;
|
||||
text: string;
|
||||
fix?: { range: [number, number]; text: string };
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
@@ -109,6 +110,7 @@ export class StylelintAdapter implements LinterAdapter {
|
||||
(w.endLine ?? w.line) - 1,
|
||||
(w.endColumn ?? w.column) - 1
|
||||
),
|
||||
fix: w.fix ? { range: [w.fix.range[0], w.fix.range[1]], text: w.fix.text } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,12 @@ function buildDeepReviewSystemPrompt(): string {
|
||||
重点分野:セキュリティ脆弱性、論理エラー、パフォーマンス問題、設計欠陥
|
||||
静的解析ですでに報告された問題を重複しないでください。
|
||||
|
||||
translatedDiagnosticsの要件:
|
||||
- 下記の「静的解析結果」に列挙された各診断に対して1件ずつ翻訳を返してください。件数と順序を一致させ、欠落させないでください
|
||||
- "originalRuleId" はリスト内のルールID(eslint: 等のプレフィックスを含む)をそのままコピーし、書き換えないでください
|
||||
- "translatedMessage" と "translatedSuggestion" は両方必須で、空にしないでください
|
||||
- "translatedSuggestion" は具体的で実行可能な修正提案(例:この書き方に置き換える)を示してください
|
||||
|
||||
JSONのみを出力。文字列内の二重引用符は \\" でエスケープしてください。
|
||||
形式:
|
||||
{
|
||||
@@ -153,6 +159,12 @@ JSONのみを出力。文字列内の二重引用符は \\" でエスケープ
|
||||
Focus on: security vulnerabilities, logic errors, performance issues, design flaws
|
||||
Do not duplicate issues already reported by static analysis.
|
||||
|
||||
translatedDiagnostics requirements:
|
||||
- Return exactly one translation for every diagnostic listed in "Static Analysis Results", same count and order, do not omit any
|
||||
- "originalRuleId" must be copied verbatim from the listed rule IDs (keep prefixes like eslint:), do not rewrite
|
||||
- "translatedMessage" and "translatedSuggestion" are both required and must not be empty
|
||||
- "translatedSuggestion" should be a concrete actionable fix suggestion (e.g. what to replace it with), not just a replacement snippet
|
||||
|
||||
Output JSON only. Double quotes in strings must be escaped with \\".
|
||||
Format:
|
||||
{
|
||||
@@ -168,6 +180,12 @@ Output language: en`;
|
||||
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
|
||||
不要重复静态分析已报告的问题。
|
||||
|
||||
translatedDiagnostics 要求:
|
||||
- 必须为"静态分析结果"中列出的每一条诊断都返回一条翻译,条数与顺序一致,不得遗漏
|
||||
- "originalRuleId" 必须原样复制列表中的规则 ID(保留 eslint: 等前缀),不得改写
|
||||
- "translatedMessage" 与 "translatedSuggestion" 均为必填字段,不得为空
|
||||
- "translatedSuggestion" 给出具体可执行的修复建议(如应替换成什么写法),不要只给替换片段
|
||||
|
||||
仅输出 JSON,字符串中的双引号必须用 \\" 转义。
|
||||
格式:
|
||||
{
|
||||
|
||||
+2
-2
@@ -2,6 +2,6 @@ import * as vscode from 'vscode';
|
||||
|
||||
const ROOT = 'vscode-code-reviewer';
|
||||
|
||||
export function getContextLines(): number {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<number>('fixer.contextLines', 5);
|
||||
export function getFixMaxIterations(): number {
|
||||
return vscode.workspace.getConfiguration(ROOT).get<number>('fixer.maxIterations', 3);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,10 @@ export function toVscodeDiagnostics(diagnostics: LinterDiagnostic[]): vscode.Dia
|
||||
: d.severity === 'warning'
|
||||
? vscode.DiagnosticSeverity.Warning
|
||||
: vscode.DiagnosticSeverity.Information;
|
||||
return new vscode.Diagnostic(d.range, formatDiagnosticMessage(d), severity);
|
||||
const diag = new vscode.Diagnostic(d.range, formatDiagnosticMessage(d), severity);
|
||||
diag.source = PLUGIN_NAME;
|
||||
diag.code = d.ruleId;
|
||||
return diag;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+14
-1
@@ -7,6 +7,8 @@ import { getAIOutputLanguage } from './config';
|
||||
import { ReviewStatusCache } from './scope/status-cache';
|
||||
import { MethodCodeLensProvider } from './views/codeLensProvider';
|
||||
import { DiagnosticMarkers, isMarkersEnabled } from './diagnostics/diagnosticMarkers';
|
||||
import { FixCodeActionProvider } from './fix/codeActionProvider';
|
||||
import { FixSessionManager } from './fix/fixSession';
|
||||
|
||||
let orchestrator: Orchestrator;
|
||||
let markers: DiagnosticMarkers;
|
||||
@@ -68,6 +70,16 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
const statusCache = new ReviewStatusCache();
|
||||
const codeLensProvider = new MethodCodeLensProvider(statusCache);
|
||||
|
||||
const fixSession = new FixSessionManager();
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.languages.registerCodeActionsProvider(
|
||||
{ scheme: 'file' },
|
||||
new FixCodeActionProvider(orchestrator),
|
||||
{ providedCodeActionKinds: [vscode.CodeActionKind.QuickFix] }
|
||||
)
|
||||
);
|
||||
|
||||
void analyzeOpenDocuments();
|
||||
|
||||
context.subscriptions.push(
|
||||
@@ -81,6 +93,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
vscode.workspace.onDidCloseTextDocument((document) => {
|
||||
statusCache.clearDocument(document.uri);
|
||||
markers.clear(document.uri);
|
||||
fixSession.clear(document.uri);
|
||||
})
|
||||
);
|
||||
|
||||
@@ -98,7 +111,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
);
|
||||
|
||||
registerCommands(context, orchestrator, codeLensProvider, statusCache, markers);
|
||||
registerCommands(context, orchestrator, codeLensProvider, statusCache, markers, fixSession);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidSaveTextDocument((document) => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { Orchestrator } from '../orchestrator/orchestrator';
|
||||
|
||||
export class FixCodeActionProvider implements vscode.CodeActionProvider {
|
||||
constructor(private orchestrator: Orchestrator) {}
|
||||
|
||||
provideCodeActions(
|
||||
document: vscode.TextDocument,
|
||||
_range: vscode.Range,
|
||||
context: vscode.CodeActionContext,
|
||||
_token: vscode.CancellationToken
|
||||
): vscode.CodeAction[] {
|
||||
const cached = this.orchestrator.getAnalysisResult(document.uri);
|
||||
if (!cached) { return []; }
|
||||
|
||||
const actions: vscode.CodeAction[] = [];
|
||||
for (const diag of cached.diagnostics) {
|
||||
if (!diag.fix) { continue; }
|
||||
const overlapsContext = context.diagnostics.some(d =>
|
||||
diag.range.intersection(d.range)
|
||||
);
|
||||
if (!overlapsContext) { continue; }
|
||||
|
||||
const action = new vscode.CodeAction(
|
||||
`Code Purifier: 修复 ${diag.ruleId}`,
|
||||
vscode.CodeActionKind.QuickFix
|
||||
);
|
||||
action.command = {
|
||||
command: 'codeReviewer.fixIssue',
|
||||
title: '修复',
|
||||
arguments: [{
|
||||
line: diag.range.start.line,
|
||||
ruleId: diag.ruleId,
|
||||
source: 'linter',
|
||||
origin: 'hover',
|
||||
}],
|
||||
};
|
||||
action.diagnostics = [...context.diagnostics];
|
||||
actions.push(action);
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { LinterAdapter, LinterDiagnostic } from '../types';
|
||||
import { mockDocument } from '../utils/mockDocument';
|
||||
|
||||
export interface AppliedFix {
|
||||
originalText: string;
|
||||
newText: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface FixResult {
|
||||
success: boolean;
|
||||
attempts: number;
|
||||
message?: string;
|
||||
appliedFixes: AppliedFix[];
|
||||
}
|
||||
|
||||
function applyFixToText(text: string, fix: { range: [number, number]; text: string }): string {
|
||||
const [start, end] = fix.range;
|
||||
if (start < 0 || end < start || end > text.length) { return text; }
|
||||
return text.slice(0, start) + fix.text + text.slice(end);
|
||||
}
|
||||
|
||||
function findClosestFixable(
|
||||
diagnostics: LinterDiagnostic[],
|
||||
ruleId: string,
|
||||
line: number
|
||||
): LinterDiagnostic | null {
|
||||
let best: LinterDiagnostic | null = null;
|
||||
let bestDist = Number.MAX_SAFE_INTEGER;
|
||||
for (const d of diagnostics) {
|
||||
if (d.ruleId !== ruleId || !d.fix) { continue; }
|
||||
const dist = Math.abs(d.range.start.line - line);
|
||||
if (dist < bestDist) {
|
||||
bestDist = dist;
|
||||
best = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
function issueStillExists(
|
||||
diagnostics: LinterDiagnostic[],
|
||||
ruleId: string,
|
||||
fixedStart: number,
|
||||
fixedEnd: number
|
||||
): boolean {
|
||||
for (const d of diagnostics) {
|
||||
if (d.ruleId !== ruleId || !d.fix) { continue; }
|
||||
const [s, e] = d.fix.range;
|
||||
if (s < fixedEnd && e > fixedStart) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function fixDiagnostic(
|
||||
document: vscode.TextDocument,
|
||||
workingDir: string,
|
||||
adapter: LinterAdapter,
|
||||
diag: LinterDiagnostic,
|
||||
maxIterations: number
|
||||
): Promise<FixResult> {
|
||||
const originalText = document.getText();
|
||||
let currentText = originalText;
|
||||
let prevLine = diag.range.start.line;
|
||||
let converged = false;
|
||||
const appliedFixes: AppliedFix[] = [];
|
||||
|
||||
for (let round = 1; round <= maxIterations; round++) {
|
||||
let result;
|
||||
try {
|
||||
const mock = mockDocument(currentText, document.languageId, document.fileName);
|
||||
result = await adapter.check(mock, workingDir);
|
||||
} catch {
|
||||
return { success: false, attempts: round, message: 'lint-execution-failed', appliedFixes };
|
||||
}
|
||||
|
||||
const target = findClosestFixable(result.diagnostics, diag.ruleId, prevLine);
|
||||
if (!target) {
|
||||
return { success: false, attempts: round, message: 'not-autofixable', appliedFixes };
|
||||
}
|
||||
|
||||
const fix = target.fix!;
|
||||
const [start, end] = fix.range;
|
||||
const originalFragment = currentText.slice(start, end);
|
||||
const nextText = applyFixToText(currentText, fix);
|
||||
if (nextText === currentText) {
|
||||
return { success: false, attempts: round, message: 'no-change', appliedFixes };
|
||||
}
|
||||
|
||||
appliedFixes.push({
|
||||
originalText: originalFragment,
|
||||
newText: fix.text,
|
||||
line: target.range.start.line,
|
||||
});
|
||||
currentText = nextText;
|
||||
prevLine = target.range.start.line;
|
||||
|
||||
const fixedStart = start;
|
||||
const fixedEnd = start + fix.text.length;
|
||||
|
||||
let verify;
|
||||
try {
|
||||
verify = await adapter.check(mockDocument(currentText, document.languageId, document.fileName), workingDir);
|
||||
} catch {
|
||||
converged = false;
|
||||
break;
|
||||
}
|
||||
if (!issueStillExists(verify.diagnostics, diag.ruleId, fixedStart, fixedEnd)) {
|
||||
converged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!converged) {
|
||||
return { success: false, attempts: maxIterations, message: 'max-iterations', appliedFixes };
|
||||
}
|
||||
|
||||
if (currentText === originalText) {
|
||||
return { success: true, attempts: 0, appliedFixes };
|
||||
}
|
||||
|
||||
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,131 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { AppliedFix } from './fixEngine';
|
||||
|
||||
export interface FixedEntry {
|
||||
key: string;
|
||||
ruleId: string;
|
||||
line: number;
|
||||
fixes: AppliedFix[];
|
||||
source: 'linter';
|
||||
}
|
||||
|
||||
function keyOf(ruleId: string, line: number): string {
|
||||
return `${ruleId}@${line}`;
|
||||
}
|
||||
|
||||
interface LocatedEdit {
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function locateNewText(document: vscode.TextDocument, fix: AppliedFix): number {
|
||||
const text = document.getText();
|
||||
const lines = text.split('\n');
|
||||
let index = -1;
|
||||
|
||||
if (fix.newText !== '') {
|
||||
const firstLineOfNew = fix.newText.split('\n')[0];
|
||||
if (fix.line >= 0 && fix.line < lines.length && lines[fix.line].includes(firstLineOfNew)) {
|
||||
const offset = document.offsetAt(new vscode.Position(fix.line, 0));
|
||||
index = text.indexOf(fix.newText, offset);
|
||||
}
|
||||
if (index === -1) {
|
||||
index = text.indexOf(fix.newText);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
if (fix.line >= 0 && fix.line < lines.length) {
|
||||
const offset = document.offsetAt(new vscode.Position(fix.line, 0));
|
||||
const lineEnd = text.indexOf('\n', offset);
|
||||
const end = lineEnd === -1 ? text.length : lineEnd;
|
||||
index = offset + lines[fix.line].search(/\S|$/);
|
||||
if (index > end) { index = offset; }
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
export class FixSessionManager {
|
||||
private fixedEntries = new Map<string, FixedEntry>();
|
||||
|
||||
add(uri: vscode.Uri, entry: FixedEntry): void {
|
||||
this.fixedEntries.set(uri.toString() + '|' + entry.key, entry);
|
||||
}
|
||||
|
||||
get(uri: vscode.Uri, key: string): FixedEntry | undefined {
|
||||
return this.fixedEntries.get(uri.toString() + '|' + key);
|
||||
}
|
||||
|
||||
has(uri: vscode.Uri, key: string): boolean {
|
||||
return this.fixedEntries.has(uri.toString() + '|' + key);
|
||||
}
|
||||
|
||||
getEntries(uri: vscode.Uri): FixedEntry[] {
|
||||
const prefix = uri.toString() + '|';
|
||||
const result: FixedEntry[] = [];
|
||||
for (const [k, v] of this.fixedEntries) {
|
||||
if (k.startsWith(prefix)) { result.push(v); }
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
clear(uri: vscode.Uri): void {
|
||||
const prefix = uri.toString() + '|';
|
||||
for (const k of this.fixedEntries.keys()) {
|
||||
if (k.startsWith(prefix)) { this.fixedEntries.delete(k); }
|
||||
}
|
||||
}
|
||||
|
||||
recordFixes(uri: vscode.Uri, ruleId: string, line: number, fixes: AppliedFix[]): string {
|
||||
const key = keyOf(ruleId, line);
|
||||
const fullKey = uri.toString() + '|' + key;
|
||||
const existing = this.fixedEntries.get(fullKey);
|
||||
if (existing) {
|
||||
existing.fixes.push(...fixes);
|
||||
} else {
|
||||
this.fixedEntries.set(fullKey, {
|
||||
key,
|
||||
ruleId,
|
||||
line,
|
||||
fixes: [...fixes],
|
||||
source: 'linter',
|
||||
});
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
async undo(document: vscode.TextDocument, key: string): Promise<boolean> {
|
||||
const entry = this.fixedEntries.get(document.uri.toString() + '|' + key);
|
||||
if (!entry || entry.fixes.length === 0) { return false; }
|
||||
|
||||
const edits: LocatedEdit[] = [];
|
||||
for (let i = entry.fixes.length - 1; i >= 0; i--) {
|
||||
const fix = entry.fixes[i];
|
||||
const index = locateNewText(document, fix);
|
||||
if (index === -1) { return false; }
|
||||
edits.push({
|
||||
start: index,
|
||||
end: index + fix.newText.length,
|
||||
text: fix.originalText,
|
||||
});
|
||||
}
|
||||
|
||||
const workspaceEdit = new vscode.WorkspaceEdit();
|
||||
for (const e of edits) {
|
||||
workspaceEdit.replace(
|
||||
document.uri,
|
||||
new vscode.Range(
|
||||
document.positionAt(e.start),
|
||||
document.positionAt(e.end)
|
||||
),
|
||||
e.text
|
||||
);
|
||||
}
|
||||
const applied = await vscode.workspace.applyEdit(workspaceEdit);
|
||||
if (applied) {
|
||||
this.fixedEntries.delete(document.uri.toString() + '|' + key);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import * as vscode from 'vscode';
|
||||
import type { LinterDiagnostic } from '../types';
|
||||
import type { AIProvider } from '../ai/providers/base';
|
||||
import { getAIMaxTokens } from '../config';
|
||||
|
||||
export type FixCategory = 'naming' | 'style' | 'bug' | 'security' | 'performance';
|
||||
|
||||
export interface FixableDiagnostic {
|
||||
ruleId: string;
|
||||
message: string;
|
||||
line: number;
|
||||
severity: string;
|
||||
codeContext: string;
|
||||
source: 'linter' | 'custom';
|
||||
category: FixCategory;
|
||||
}
|
||||
|
||||
export interface CodeFix {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
originalText: string;
|
||||
newText: string;
|
||||
matched: boolean;
|
||||
actualRange?: vscode.Range;
|
||||
}
|
||||
|
||||
const FIX_SYSTEM_PROMPT = `你是代码修复专家。根据提供的问题和代码上下文,输出修复后的代码。
|
||||
仅输出 JSON:{ "originalText": "需要替换的原文", "newText": "修复后的新代码" }`;
|
||||
|
||||
function detectCategory(diagnostic: LinterDiagnostic): FixCategory {
|
||||
if (diagnostic.ruleId.includes('naming') || diagnostic.ruleId.includes('Name')) { return 'naming'; }
|
||||
if (diagnostic.ruleId.includes('security') || diagnostic.ruleId.includes('injection') || diagnostic.ruleId.includes('secret')) { return 'security'; }
|
||||
if (diagnostic.ruleId.includes('perf')) { return 'performance'; }
|
||||
return 'style';
|
||||
}
|
||||
|
||||
function getContextRange(document: vscode.TextDocument, line: number, category: FixCategory): { startLine: number; endLine: number } {
|
||||
switch (category) {
|
||||
case 'naming':
|
||||
return {
|
||||
startLine: Math.max(0, line - 2),
|
||||
endLine: Math.min(document.lineCount - 1, line + 2),
|
||||
};
|
||||
case 'style':
|
||||
return {
|
||||
startLine: Math.max(0, line - 5),
|
||||
endLine: Math.min(document.lineCount - 1, line + 5),
|
||||
};
|
||||
case 'bug':
|
||||
case 'security':
|
||||
case 'performance': {
|
||||
const funcRange = findEnclosingFunction(document, line);
|
||||
return {
|
||||
startLine: funcRange?.start.line ?? Math.max(0, line - 10),
|
||||
endLine: funcRange?.end.line ?? Math.min(document.lineCount - 1, line + 10),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
startLine: Math.max(0, line - 5),
|
||||
endLine: Math.min(document.lineCount - 1, line + 5),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function findEnclosingFunction(document: vscode.TextDocument, line: number): { start: vscode.Position; end: vscode.Position } | null {
|
||||
const text = document.getText();
|
||||
const lines = text.split('\n');
|
||||
let braceDepth = 0;
|
||||
let funcStart = line;
|
||||
let funcEnd = line;
|
||||
|
||||
for (let i = line; i >= 0; i--) {
|
||||
const l = lines[i];
|
||||
braceDepth += (l.match(/\}/g) || []).length;
|
||||
braceDepth -= (l.match(/\{/g) || []).length;
|
||||
const isFunctionLine = /\b(function|def|class|method|public|private|protected|void|int|String|boolean|var|let|const|async)\s/.test(l);
|
||||
if (braceDepth < 0 && isFunctionLine) {
|
||||
funcStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
braceDepth = 0;
|
||||
for (let i = funcStart; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
braceDepth += (l.match(/\{/g) || []).length;
|
||||
braceDepth -= (l.match(/\}/g) || []).length;
|
||||
if (braceDepth === 0 && (l.match(/\{/g) || []).length > 0) {
|
||||
funcEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: new vscode.Position(funcStart, 0),
|
||||
end: new vscode.Position(funcEnd, lines[funcEnd]?.length ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function extractLines(document: vscode.TextDocument, startLine: number, endLine: number): string {
|
||||
const lines: string[] = [];
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
const lineText = document.lineAt(i).text;
|
||||
lines.push(`${String(i + 1).padStart(4, ' ')}| ${lineText}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function prepareContext(document: vscode.TextDocument, diagnostic: LinterDiagnostic, source: 'linter' | 'custom'): FixableDiagnostic | null {
|
||||
const line = diagnostic.range.start.line;
|
||||
const category = detectCategory(diagnostic);
|
||||
const { startLine, endLine } = getContextRange(document, line, category);
|
||||
const codeContext = extractLines(document, startLine, endLine);
|
||||
|
||||
return {
|
||||
ruleId: diagnostic.ruleId,
|
||||
message: diagnostic.message,
|
||||
line,
|
||||
severity: diagnostic.severity,
|
||||
codeContext,
|
||||
source,
|
||||
category,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateFix(
|
||||
provider: AIProvider,
|
||||
model: string,
|
||||
temperature: number,
|
||||
timeoutMs: number,
|
||||
diagnostic: FixableDiagnostic
|
||||
): Promise<CodeFix | null> {
|
||||
const userPrompt = `问题: [${diagnostic.ruleId}] ${diagnostic.message}\n代码上下文:\n${diagnostic.codeContext}`;
|
||||
|
||||
try {
|
||||
const response = await provider.chat(FIX_SYSTEM_PROMPT, userPrompt, {
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: getAIMaxTokens(),
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const trimmed = response.trim();
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) { return null; }
|
||||
|
||||
const parsed = JSON.parse(trimmed.substring(start, end + 1));
|
||||
return {
|
||||
startLine: diagnostic.line,
|
||||
endLine: diagnostic.line,
|
||||
originalText: parsed.originalText ?? '',
|
||||
newText: parsed.newText ?? '',
|
||||
matched: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function matchAndValidate(document: vscode.TextDocument, fix: CodeFix): { matched: boolean; actualRange?: vscode.Range } {
|
||||
const lineContent = document.lineAt(fix.startLine).text;
|
||||
if (lineContent === fix.originalText.split('\n')[0]) {
|
||||
const range = new vscode.Range(fix.startLine, 0, fix.endLine, document.lineAt(fix.endLine).text.length);
|
||||
if (document.getText(range) === fix.originalText) {
|
||||
return { matched: true, actualRange: range };
|
||||
}
|
||||
}
|
||||
|
||||
const index = document.getText().indexOf(fix.originalText);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
matched: true,
|
||||
actualRange: new vscode.Range(
|
||||
document.positionAt(index),
|
||||
document.positionAt(index + fix.originalText.length)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { matched: false };
|
||||
}
|
||||
|
||||
export async function applySingleFix(editor: vscode.TextEditor, fix: CodeFix): Promise<boolean> {
|
||||
if (!fix.actualRange || !fix.matched) { return false; }
|
||||
return editor.edit(editBuilder => {
|
||||
editBuilder.replace(fix.actualRange!, fix.newText);
|
||||
});
|
||||
}
|
||||
|
||||
const snapshotStack: Map<string, string[]> = new Map();
|
||||
|
||||
export function saveSnapshot(document: vscode.TextDocument): void {
|
||||
const filePath = document.uri.fsPath;
|
||||
if (!snapshotStack.has(filePath)) { snapshotStack.set(filePath, []); }
|
||||
snapshotStack.get(filePath)!.push(document.getText());
|
||||
}
|
||||
|
||||
export async function undoLastFix(document: vscode.TextDocument): Promise<boolean> {
|
||||
const stack = snapshotStack.get(document.uri.fsPath);
|
||||
if (!stack || stack.length === 0) { return false; }
|
||||
const previousContent = stack.pop()!;
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), previousContent);
|
||||
return vscode.workspace.applyEdit(edit);
|
||||
}
|
||||
|
||||
export function hasSnapshot(document: vscode.TextDocument): boolean {
|
||||
const stack = snapshotStack.get(document.uri.fsPath);
|
||||
return !!(stack && stack.length > 0);
|
||||
}
|
||||
|
||||
export async function applyBatchFixes(document: vscode.TextDocument, fixes: CodeFix[]): Promise<number> {
|
||||
saveSnapshot(document);
|
||||
const validFixes = fixes.filter(f => f.matched);
|
||||
const sorted = [...validFixes].sort((a, b) => b.startLine - a.startLine);
|
||||
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document.uri.toString() !== document.uri.toString()) { return 0; }
|
||||
|
||||
let applied = 0;
|
||||
for (const fix of sorted) {
|
||||
if (await applySingleFix(editor, fix)) { applied++; }
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
+63
-8
@@ -45,15 +45,45 @@ const messages: Record<string, Record<Language, string>> = {
|
||||
en: 'Selection review complete: {0} issue(s)',
|
||||
ja: '選択コードのレビュー完了: {0} 件の問題',
|
||||
},
|
||||
'review.fixNotAvailable': {
|
||||
'zh-CN': '单条修复功能开发中',
|
||||
en: 'Single fix is under development',
|
||||
ja: '単一修正機能は開発中です',
|
||||
'fix.noFix': {
|
||||
'zh-CN': '该问题无法自动修复',
|
||||
en: 'This issue cannot be auto-fixed',
|
||||
ja: 'この問題は自動修正できません',
|
||||
},
|
||||
'review.fixAllNotAvailable': {
|
||||
'zh-CN': '批量修复功能开发中',
|
||||
en: 'Batch fix is under development',
|
||||
ja: '一括修正機能は開発中です',
|
||||
'fix.failed': {
|
||||
'zh-CN': '修复失败: {0}',
|
||||
en: 'Fix failed: {0}',
|
||||
ja: '修正に失敗しました: {0}',
|
||||
},
|
||||
'fix.applied': {
|
||||
'zh-CN': '修复完成',
|
||||
en: 'Fix applied',
|
||||
ja: '修正を適用しました',
|
||||
},
|
||||
'fix.running': {
|
||||
'zh-CN': '批量修复中...',
|
||||
en: 'Fixing all issues...',
|
||||
ja: '一括修正中...',
|
||||
},
|
||||
'fix.progress': {
|
||||
'zh-CN': '修复进度',
|
||||
en: 'Fix progress',
|
||||
ja: '修正進捗',
|
||||
},
|
||||
'fix.allComplete': {
|
||||
'zh-CN': '批量修复完成:成功 {0},跳过 {1}',
|
||||
en: 'Batch fix complete: {0} fixed, {1} skipped',
|
||||
ja: '一括修正完了: {0} 成功、{1} スキップ',
|
||||
},
|
||||
'fix.undone': {
|
||||
'zh-CN': '已撤销修复',
|
||||
en: 'Fix undone',
|
||||
ja: '修正を取り消しました',
|
||||
},
|
||||
'fix.undoFailed': {
|
||||
'zh-CN': '撤销失败,代码可能已被手动修改',
|
||||
en: 'Undo failed, the code may have been modified manually',
|
||||
ja: '取り消しに失敗しました。コードが手動で変更された可能性があります',
|
||||
},
|
||||
|
||||
'export.needRunFirst': {
|
||||
@@ -427,6 +457,11 @@ const messages: Record<string, Record<Language, string>> = {
|
||||
en: 'Languages:',
|
||||
ja: '対応言語:',
|
||||
},
|
||||
'setup.adapter.sqlfluffDialectLabel': {
|
||||
'zh-CN': '方言',
|
||||
en: 'Dialect',
|
||||
ja: '方言',
|
||||
},
|
||||
'setup.adapter.tooltipTab': {
|
||||
'zh-CN': '点击展开/收起静态分析适配器',
|
||||
en: 'Click to expand/collapse static analysis adapters',
|
||||
@@ -798,6 +833,26 @@ const messages: Record<string, Record<Language, string>> = {
|
||||
en: 'Fix All',
|
||||
ja: 'すべて修正',
|
||||
},
|
||||
'report.fixLabel': {
|
||||
'zh-CN': '修复',
|
||||
en: 'Fix',
|
||||
ja: '修正',
|
||||
},
|
||||
'report.fixedIssues': {
|
||||
'zh-CN': '已修复',
|
||||
en: 'Fixed',
|
||||
ja: '修正済み',
|
||||
},
|
||||
'report.fixedLabel': {
|
||||
'zh-CN': '已修复',
|
||||
en: 'Fixed',
|
||||
ja: '修正済み',
|
||||
},
|
||||
'report.undoFix': {
|
||||
'zh-CN': '撤销',
|
||||
en: 'Undo',
|
||||
ja: '取り消し',
|
||||
},
|
||||
'report.rerun': {
|
||||
'zh-CN': '重新审查',
|
||||
en: 'Re-run Review',
|
||||
|
||||
+35
-11
@@ -47,6 +47,35 @@ interface MergeInput {
|
||||
|
||||
const SEVERITY_RANK: Record<string, number> = { error: 0, warning: 1, info: 2 };
|
||||
|
||||
const RULE_NAMESPACE_PREFIXES = ['eslint:', 'stylelint:', 'sqlfluff:', 'pmd:', 'custom:', 'method:'];
|
||||
|
||||
function normalizeRuleId(id: string): string {
|
||||
let result = id.trim();
|
||||
for (const p of RULE_NAMESPACE_PREFIXES) {
|
||||
if (result.startsWith(p)) {
|
||||
result = result.slice(p.length);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const segments = result.split(/[:/]/);
|
||||
return segments[segments.length - 1];
|
||||
}
|
||||
|
||||
function findTranslation(pool: TranslatedDiagnostic[], ruleId: string): TranslatedDiagnostic | undefined {
|
||||
for (let i = 0; i < pool.length; i++) {
|
||||
if (pool[i].originalRuleId === ruleId) {
|
||||
return pool.splice(i, 1)[0];
|
||||
}
|
||||
}
|
||||
const norm = normalizeRuleId(ruleId);
|
||||
for (let i = 0; i < pool.length; i++) {
|
||||
if (normalizeRuleId(pool[i].originalRuleId) === norm) {
|
||||
return pool.splice(i, 1)[0];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function sortBySeverityAndLine<T extends { severity: string }>(items: T[], lineOf: (item: T) => number): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
const rankDiff = (SEVERITY_RANK[a.severity] ?? 3) - (SEVERITY_RANK[b.severity] ?? 3);
|
||||
@@ -66,17 +95,11 @@ export function mergeResults(input: MergeInput): MergedReport {
|
||||
d => d.range.start.line
|
||||
);
|
||||
|
||||
const translationsByRule = new Map<string, TranslatedDiagnostic[]>();
|
||||
for (const td of input.translatedDiagnostics) {
|
||||
const list = translationsByRule.get(td.originalRuleId) ?? [];
|
||||
list.push(td);
|
||||
translationsByRule.set(td.originalRuleId, list);
|
||||
}
|
||||
const translationPool = [...input.translatedDiagnostics];
|
||||
|
||||
const linterDiagnostics = sortBySeverityAndLine(
|
||||
input.staticDiagnostics.map(d => {
|
||||
const list = translationsByRule.get(d.ruleId);
|
||||
const td = list?.shift();
|
||||
const td = findTranslation(translationPool, d.ruleId);
|
||||
if (td) {
|
||||
return { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion };
|
||||
}
|
||||
@@ -94,10 +117,11 @@ export function mergeResults(input: MergeInput): MergedReport {
|
||||
const customRuleCount = customRuleDiagnostics.length;
|
||||
const aiCount = aiFindings.length;
|
||||
|
||||
const fixableLinterIndices = linterDiagnostics.map((_, i) => i);
|
||||
const fixableLinterIndices = linterDiagnostics
|
||||
.map((d, i) => (d.fix ? i : -1))
|
||||
.filter(i => i !== -1);
|
||||
|
||||
const fixableCustomIndices = customRuleDiagnostics
|
||||
.map((_, i) => i);
|
||||
const fixableCustomIndices: number[] = [];
|
||||
|
||||
return {
|
||||
linterDiagnostics,
|
||||
|
||||
@@ -14,8 +14,15 @@ export interface StaticAnalysisResult {
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface CachedAnalysis {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
adapterId: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
export class Orchestrator {
|
||||
private adapters: LinterAdapter[];
|
||||
private analysisCache = new Map<string, CachedAnalysis>();
|
||||
|
||||
constructor() {
|
||||
// 单语言单 linter 设计:按 linters.<language> 单选配置分派一个适配器;
|
||||
@@ -66,6 +73,12 @@ export class Orchestrator {
|
||||
errors.push(`[${adapter.id}] ${result.errorMessage ?? result.status}`);
|
||||
}
|
||||
|
||||
this.analysisCache.set(document.uri.toString(), {
|
||||
diagnostics: result.diagnostics,
|
||||
adapterId: adapter.id,
|
||||
workingDir,
|
||||
});
|
||||
|
||||
return {
|
||||
diagnostics: result.diagnostics,
|
||||
errors,
|
||||
@@ -73,4 +86,20 @@ export class Orchestrator {
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
setAnalysisResult(uri: vscode.Uri, result: CachedAnalysis): void {
|
||||
this.analysisCache.set(uri.toString(), result);
|
||||
}
|
||||
|
||||
getAnalysisResult(uri: vscode.Uri): CachedAnalysis | undefined {
|
||||
return this.analysisCache.get(uri.toString());
|
||||
}
|
||||
|
||||
clearAnalysisResult(uri: vscode.Uri): void {
|
||||
this.analysisCache.delete(uri.toString());
|
||||
}
|
||||
|
||||
getAdapter(adapterId: string): LinterAdapter | undefined {
|
||||
return this.adapters.find(a => a.id === adapterId);
|
||||
}
|
||||
}
|
||||
|
||||
+82
-42
@@ -1,12 +1,14 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { MergedReport } from '../merger/merger';
|
||||
import { t, onLanguageChange, getLanguage } from '../i18n/messages';
|
||||
import type { FixSessionManager } from '../fix/fixSession';
|
||||
|
||||
interface PanelMessage {
|
||||
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll';
|
||||
type: 'navigate' | 'rerun' | 'export' | 'fix' | 'fixAll' | 'undo';
|
||||
line?: number;
|
||||
ruleId?: string;
|
||||
source?: 'linter' | 'custom' | 'ai';
|
||||
origin?: 'hover' | 'panel';
|
||||
}
|
||||
|
||||
function esc(str: string): string {
|
||||
@@ -46,6 +48,8 @@ export class ReviewPanel {
|
||||
private readonly panel: vscode.WebviewPanel;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
private currentReport: MergedReport | null = null;
|
||||
private fixSession: FixSessionManager | null = null;
|
||||
private readonly scriptUri: vscode.Uri;
|
||||
|
||||
private constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
@@ -58,10 +62,14 @@ export class ReviewPanel {
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [],
|
||||
localResourceRoots: [this.extensionUri],
|
||||
}
|
||||
);
|
||||
|
||||
this.scriptUri = this.panel.webview.asWebviewUri(
|
||||
vscode.Uri.joinPath(this.extensionUri, 'out', 'webview', 'reviewPanel.js')
|
||||
);
|
||||
|
||||
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
|
||||
|
||||
this.panel.webview.onDidReceiveMessage(
|
||||
@@ -95,6 +103,13 @@ export class ReviewPanel {
|
||||
this.panel.webview.html = this.buildHtml(report);
|
||||
}
|
||||
|
||||
setFixSession(session: FixSessionManager): void {
|
||||
this.fixSession = session;
|
||||
if (this.currentReport) {
|
||||
this.panel.webview.html = this.buildHtml(this.currentReport);
|
||||
}
|
||||
}
|
||||
|
||||
private buildHtml(report: MergedReport): string {
|
||||
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
|
||||
|
||||
@@ -126,6 +141,8 @@ export class ReviewPanel {
|
||||
const fixableLinterSet = new Set(report.fixableLinterIndices);
|
||||
const fixableCustomSet = new Set(report.fixableCustomIndices);
|
||||
|
||||
const fixedEntries = this.fixSession?.getEntries(vscode.Uri.file(report.filePath)) ?? [];
|
||||
|
||||
const tabCount = (e: number, w: number, i: number) => {
|
||||
const pts: string[] = [];
|
||||
if (e > 0) { pts.push(`<span class="tab-count tab-count-error">${e}</span>`); }
|
||||
@@ -218,6 +235,12 @@ export class ReviewPanel {
|
||||
.item-fix { flex-shrink: 0; padding: 2px 8px; border: 1px solid var(--vscode-panel-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); border-radius: 4px; cursor: pointer; font-size: 11px; transition: background .15s; line-height: 18px; }
|
||||
.item-fix:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.item-fix:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.item-fixed { border-color: rgba(87,171,90,0.4); background: rgba(87,171,90,0.08); }
|
||||
.item-fixed:hover { border-color: rgba(87,171,90,0.6); }
|
||||
.item-severity-fixed { background: #57ab5a; }
|
||||
.icon-fixed { background: #57ab5a; }
|
||||
.item-undo { flex-shrink: 0; padding: 2px 8px; border: 1px solid rgba(87,171,90,0.5); background: rgba(87,171,90,0.15); color: #57ab5a; border-radius: 4px; cursor: pointer; font-size: 11px; line-height: 18px; }
|
||||
.item-undo:hover { background: rgba(87,171,90,0.25); }
|
||||
|
||||
.item-detail { display: none; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--vscode-panel-border); }
|
||||
.item.expanded .item-detail { display: block; animation: fadeSlideIn .2s ease; }
|
||||
@@ -263,10 +286,10 @@ ${errorBox}
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="tab-linter">
|
||||
${this.buildLinterList(report, fixableLinterSet)}
|
||||
${this.buildLinterList(report, fixableLinterSet, fixedEntries)}
|
||||
</div>
|
||||
<div class="tab-content" id="tab-custom">
|
||||
${this.buildCustomList(report, fixableCustomSet)}
|
||||
${this.buildCustomList(report)}
|
||||
</div>
|
||||
<div class="tab-content" id="tab-ai">
|
||||
${this.buildAIList(report)}
|
||||
@@ -278,38 +301,46 @@ ${errorBox}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
function send(type, line, ruleId, source) {
|
||||
vscode.postMessage({ type, line, ruleId, source });
|
||||
}
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-content').forEach(function(tc) { tc.classList.remove('active'); });
|
||||
document.querySelector('.tab[data-tab="' + tabId + '"]').classList.add('active');
|
||||
document.getElementById('tab-' + tabId).classList.add('active');
|
||||
}
|
||||
function toggleItem(el) {
|
||||
if (event.target.closest('button')) return;
|
||||
if (event.target.closest('.item-line')) return;
|
||||
el.classList.toggle('expanded');
|
||||
}
|
||||
</script>
|
||||
<script src="${this.scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildLinterList(report: MergedReport, fixableSet: Set<number>): string {
|
||||
if (report.linterDiagnostics.length === 0) {
|
||||
private buildLinterList(report: MergedReport, fixableSet: Set<number>, fixedEntries: Array<{ ruleId: string; line: number; key: string }>): 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;
|
||||
return `<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>`
|
||||
+ report.linterDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'linter', d.suggestion, fixableSet.has(i))).join('');
|
||||
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>`;
|
||||
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('');
|
||||
}
|
||||
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('');
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
private buildCustomList(report: MergedReport, fixableSet: Set<number>): string {
|
||||
private buildFixedItem(ruleId: string, line: number, key: string): 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>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private buildCustomList(report: MergedReport): string {
|
||||
const filterInfo = report.customRuleFilterInfo;
|
||||
if (filterInfo?.skippedRequestA) {
|
||||
return `<div class="empty">${t('report.skipCustomRules')}</div>`;
|
||||
@@ -317,19 +348,18 @@ ${errorBox}
|
||||
if (report.customRuleDiagnostics.length === 0) {
|
||||
return `<div class="empty">${t('report.noRuleViolations')}</div>`;
|
||||
}
|
||||
const hasFixable = fixableSet.size > 0;
|
||||
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>${hasFixable ? `<button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button>` : ''}</div>`
|
||||
+ report.customRuleDiagnostics.map((d, i) => this.buildIssueItem(d.severity, d.ruleId, d.message, d.range.start.line, 'custom', d.suggestion, fixableSet.has(i))).join('');
|
||||
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('');
|
||||
}
|
||||
|
||||
private buildAIList(report: MergedReport): string {
|
||||
if (report.aiFindings.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><button class="btn" onclick="send('fixAll')">${t('report.fixAll')}</button></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;
|
||||
@@ -343,7 +373,7 @@ ${errorBox}
|
||||
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, true, details.join('')));
|
||||
parts.push(this.buildIssueItem(f.severity, f.ruleId, f.title, f.line, 'ai', f.suggestion, false, details.join('')));
|
||||
}
|
||||
return parts.join('');
|
||||
}
|
||||
@@ -373,7 +403,7 @@ ${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.fixAll')}</button>`);
|
||||
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('</div>');
|
||||
|
||||
@@ -392,17 +422,24 @@ ${errorBox}
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
private handleMessage(message: PanelMessage): void {
|
||||
private async handleMessage(message: PanelMessage): Promise<void> {
|
||||
switch (message.type) {
|
||||
case 'navigate':
|
||||
if (message.line !== undefined) {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor) {
|
||||
const line = Math.max(0, message.line);
|
||||
const range = new vscode.Range(line, 0, line, 0);
|
||||
editor.selection = new vscode.Selection(range.start, range.end);
|
||||
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
|
||||
}
|
||||
if (message.line !== undefined && this.currentReport) {
|
||||
const report = this.currentReport;
|
||||
const uri = vscode.Uri.file(report.filePath);
|
||||
const existing = vscode.window.visibleTextEditors.find(
|
||||
e => e.document.uri.fsPath === report.filePath
|
||||
);
|
||||
const showOptions: vscode.TextDocumentShowOptions =
|
||||
existing && existing.viewColumn !== undefined
|
||||
? { viewColumn: existing.viewColumn }
|
||||
: { preview: true };
|
||||
const editor = await vscode.window.showTextDocument(uri, showOptions);
|
||||
const line = Math.max(0, message.line);
|
||||
const range = new vscode.Range(line, 0, line, 0);
|
||||
editor.selection = new vscode.Selection(range.start, range.end);
|
||||
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
|
||||
}
|
||||
break;
|
||||
case 'rerun':
|
||||
@@ -412,11 +449,14 @@ ${errorBox}
|
||||
vscode.commands.executeCommand('codeReviewer.exportReport');
|
||||
break;
|
||||
case 'fix':
|
||||
vscode.commands.executeCommand('codeReviewer.fixIssue', message);
|
||||
vscode.commands.executeCommand('codeReviewer.fixIssue', { ...message, origin: 'panel' });
|
||||
break;
|
||||
case 'fixAll':
|
||||
vscode.commands.executeCommand('codeReviewer.fixAll');
|
||||
break;
|
||||
case 'undo':
|
||||
vscode.commands.executeCommand('codeReviewer.undoFix', message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,12 +13,18 @@ export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
|
||||
|
||||
export interface LinterFix {
|
||||
range: [number, number];
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LinterDiagnostic {
|
||||
severity: Severity;
|
||||
ruleId: string;
|
||||
message: string;
|
||||
range: vscode.Range;
|
||||
suggestion?: string;
|
||||
fix?: LinterFix;
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export function mockDocument(code: string, language: string, fileName?: string): vscode.TextDocument {
|
||||
const lines = code.split('\n');
|
||||
const uri = vscode.Uri.parse('untitled:virtual');
|
||||
const ext = language === 'javascript' ? 'js' : language === 'typescript' ? 'ts' : language === 'css' ? 'css' : 'java';
|
||||
return {
|
||||
uri,
|
||||
fileName: fileName ?? `untitled.${ext}`,
|
||||
isUntitled: true,
|
||||
languageId: language,
|
||||
version: 1,
|
||||
isDirty: false,
|
||||
isClosed: false,
|
||||
eol: vscode.EndOfLine.LF,
|
||||
lineCount: lines.length,
|
||||
getText: () => code,
|
||||
lineAt: (arg: number | vscode.Position) => {
|
||||
const line = typeof arg === 'number' ? arg : arg.line;
|
||||
const text = lines[line] ?? '';
|
||||
return {
|
||||
lineNumber: line,
|
||||
text,
|
||||
range: new vscode.Range(line, 0, line, text.length),
|
||||
rangeIncludingLineBreak: new vscode.Range(line, 0, line, text.length),
|
||||
firstNonWhitespaceCharacterIndex: text.search(/\S|$/),
|
||||
isEmptyOrWhitespace: text.trim().length === 0,
|
||||
};
|
||||
},
|
||||
offsetAt: (p: vscode.Position) => {
|
||||
let offset = 0;
|
||||
for (let i = 0; i < p.line; i++) offset += lines[i].length + 1;
|
||||
return offset + p.character;
|
||||
},
|
||||
positionAt: (offset: number) => {
|
||||
let remaining = offset;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (remaining <= lines[i].length) return new vscode.Position(i, remaining);
|
||||
remaining -= lines[i].length + 1;
|
||||
}
|
||||
return new vscode.Position(lines.length - 1, lines[lines.length - 1].length);
|
||||
},
|
||||
getWordRangeAtPosition: () => undefined,
|
||||
validateRange: (r: vscode.Range) => r,
|
||||
validatePosition: (p: vscode.Position) => p,
|
||||
save: () => Promise.resolve(false),
|
||||
} as unknown as vscode.TextDocument;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
const vscode = acquireVsCodeApi();
|
||||
|
||||
function send(type, line, ruleId, source) {
|
||||
vscode.postMessage({ type, line, ruleId, source });
|
||||
}
|
||||
|
||||
function switchTab(tabId) {
|
||||
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
|
||||
document.querySelectorAll('.tab-content').forEach(function(tc) { tc.classList.remove('active'); });
|
||||
document.querySelector('.tab[data-tab="' + tabId + '"]').classList.add('active');
|
||||
document.getElementById('tab-' + tabId).classList.add('active');
|
||||
}
|
||||
|
||||
function toggleItem(el) {
|
||||
if (event.target.closest('button')) { return; }
|
||||
if (event.target.closest('.item-line')) { return; }
|
||||
el.classList.toggle('expanded');
|
||||
}
|
||||
@@ -270,6 +270,19 @@
|
||||
var helpIcon = helpText
|
||||
? '<span class="help-icon" data-help="' + escapeAttr(helpText) + '">?</span>'
|
||||
: '';
|
||||
var dialectBadgeClassMap = {
|
||||
explicit: 'adapter-badge-explicit',
|
||||
global: 'adapter-badge-warn',
|
||||
project: 'adapter-badge-ok',
|
||||
builtin: 'adapter-badge-info',
|
||||
};
|
||||
var dialectBadgeClass = dialectBadgeClassMap[a.sqlfluffDialectSource] || 'adapter-badge-info';
|
||||
var dialectBadge = a.id === 'sqlfluff'
|
||||
? '<span class="adapter-badge ' + dialectBadgeClass + '">' +
|
||||
escapeHtml(i18n.sqlfluffDialectLabel) + ' ' +
|
||||
escapeHtml(a.sqlfluffDialect || '') +
|
||||
'</span>'
|
||||
: '';
|
||||
|
||||
return '<div class="adapter-card' + (a.enabled ? '' : ' disabled') + '">' +
|
||||
'<div class="adapter-card-header">' +
|
||||
@@ -281,6 +294,7 @@
|
||||
'</div>' +
|
||||
'<div class="adapter-badges">' +
|
||||
'<span class="adapter-badge ' + modeBadge.class + '">' + modeBadge.text + '</span>' +
|
||||
dialectBadge +
|
||||
depBadge +
|
||||
configBadge +
|
||||
'</div>' +
|
||||
|
||||
@@ -17,6 +17,7 @@ import { DocxConverter } from '../rules/converters/docx-converter';
|
||||
import { PptxConverter } from '../rules/converters/pptx-converter';
|
||||
import { t, getLanguage, onLanguageChange } from '../i18n/messages';
|
||||
import { getEslintConfigPath, getStylelintConfigPath, getPMDRulesetPath, getSqlFluffConfigFile, isAdapterEnabled, setAdapterEnabled } from '../config/linter';
|
||||
import { resolveSqlFluffDialect, type SqlFluffDialectSource } from '../adapters/sqlfluff';
|
||||
import {
|
||||
buildEslintProjectConfigText,
|
||||
buildStylelintProjectConfigText,
|
||||
@@ -39,6 +40,8 @@ interface AdapterConfigStatus {
|
||||
languages: string;
|
||||
projectConfigFileName: string;
|
||||
settingsTarget: string;
|
||||
sqlfluffDialect?: string;
|
||||
sqlfluffDialectSource?: SqlFluffDialectSource;
|
||||
}
|
||||
|
||||
const SQLFLUFF_CONFIG_DIALECT = 'mysql';
|
||||
@@ -334,6 +337,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
|
||||
configYes: t('setup.adapter.configYes'),
|
||||
configNo: t('setup.adapter.configNo'),
|
||||
langLabel: t('setup.adapter.langLabel'),
|
||||
sqlfluffDialectLabel: t('setup.adapter.sqlfluffDialectLabel'),
|
||||
btnCreateConfig: t('setup.adapter.btnCreateConfig'),
|
||||
btnEditGlobal: t('setup.adapter.btnEditGlobal'),
|
||||
tooltipCreate: t('setup.adapter.tooltipCreate'),
|
||||
@@ -773,6 +777,7 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
|
||||
.adapter-badge-ok { background: rgba(63,185,80,0.15); color: #3fb950; }
|
||||
.adapter-badge-warn { background: rgba(210,153,34,0.15); color: #d29922; }
|
||||
.adapter-badge-error { background: rgba(248,81,73,0.15); color: #f48771; }
|
||||
.adapter-badge-explicit { background: rgba(56,139,253,0.15); color: #388bfd; }
|
||||
.adapter-languages { font-size: 10px; color: var(--vscode-descriptionForeground); margin-bottom: 4px; }
|
||||
.adapter-lang-label { color: var(--vscode-descriptionForeground); }
|
||||
.adapter-actions { display: flex; gap: 5px; }
|
||||
@@ -1175,6 +1180,10 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
|
||||
|
||||
const configured = !meta.hasExternalDependency || configMode !== 'builtin' || dependencyStatus === 'ready';
|
||||
|
||||
const dialectInfo = id === 'sqlfluff'
|
||||
? resolveSqlFluffDialect(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '')
|
||||
: undefined;
|
||||
|
||||
statuses.push({
|
||||
id,
|
||||
name: meta.name,
|
||||
@@ -1186,6 +1195,8 @@ input::placeholder { color: var(--vscode-input-placeholderForeground, var(--vsco
|
||||
languages: t(`setup.adapter.${meta.i18nKey}Languages`),
|
||||
projectConfigFileName: meta.projectConfigFileName,
|
||||
settingsTarget: meta.settingsTarget,
|
||||
sqlfluffDialect: dialectInfo?.dialect,
|
||||
sqlfluffDialectSource: dialectInfo?.source,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user