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:
范智鹏
2026-08-18 21:41:51 +08:00
parent 3cf5c7165e
commit 3d8119d9c9
28 changed files with 1320 additions and 355 deletions
+10 -1
View File
@@ -202,4 +202,13 @@
| 2026-08-09 14:31 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 审查报告 #11 启动分析未捕获错误:核实属实且更严重——void analyzeOpenDocuments()/void runStaticAndApply()/scheduleAnalysis 定时器三处均为未捕获 Promise 拒绝路径,runStaticAndApply 内部无 try-catchmarkers.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.0package.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 诊断(不再静默丢弃);Bts.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_NAMES8 项)不一致,漏了 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_NAMES8 项)不一致,漏了 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 consthasProjectSqlfluffConfig 重构为 findProjectSqlFluffConfig(返回文件路径);新增 readDialectFromConfigFile(行扫描 [sqlfluff]/[tool.sqlfluff] 段 dialect,支持 = X 与 = "X",不引 TOML 依赖)与导出 resolveSqlFluffDialect(解析链:显式 sqlfluff.dialect > 全局 configFile > 项目配置 > 内置默认 oracle;有配置文件但无 dialect 键回退 sqlfluff 默认 ansi);setupView.ts AdapterConfigStatus 加 sqlfluffDialect 字段并在 collectAdapterStatus 填充、pushConfig i18n 注入 labelsetupView.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 映射 classexplicit→蓝/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.fixeslint/stylelint 适配器提取 fixorchestrator 增分析结果缓存+getAdapter;新建 src/fix/fixEngine 多轮循环内存模拟+单次提交、fixSession 多片段合并撤销、codeActionProvider hover 修复链接);commands 实现 fixIssue/fixAll/undoFix+refreshAfterFixextension 注册 CodeAction+fixSessionwebview 修复状态渲染+AI/custom 修复按钮 bug 修复;merger 只标记带 fix 诊断可修复;config fixer.maxIterationsi18n 增 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 不显示「快速修复」链接的 bugtoVscodeDiagnostics 生成的 vscode.Diagnostic 未设置 code/sourceVSCode 仅在诊断带 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.reviewCtrl+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 精确 Mapengine.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 扫描池+spliceeslint.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/toggleItemcurly 花括号写法过 lint);webview.ts 面板 options localResourceRoots 改 [extensionUri]、构造函数 asWebviewUri 计算 scriptUri 存字段、buildHtml 内联 script 改 <script src>copy-webview-js.mjs 与 build.mjs 的 webview 拷贝 files 数组加 reviewPanel.jscompile 与生产打包均拷到 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 全部改用它取 documentfixEngine.ts 删除 no-active-editor 强校验分支直接 applyEditWorkspaceEdit 对任何打开文档生效无需焦点);webview.ts handleMessage 改 asyncnavigate 用 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 编译报 TS2531find 回调内 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[]; // QuickFixcommand 指向 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 → 面板)
- 顺带修复既有 bugAI 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` 映射徽章 classexplicit→蓝/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
View File
@@ -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
View File
@@ -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);
+1 -1
View File
@@ -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
View File
@@ -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'));
}
})
);
+1
View File
@@ -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
View File
@@ -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;
+51 -6
View File
@@ -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');
+2
View File
@@ -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,
});
}
}
+18
View File
@@ -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
View File
@@ -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);
}
+4 -1
View File
@@ -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
View File
@@ -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) => {
+43
View File
@@ -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;
}
}
+135
View File
@@ -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 };
}
+131
View File
@@ -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;
}
}
-227
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+29
View File
@@ -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
View File
@@ -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;
}
}
+6
View File
@@ -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 {
+48
View File
@@ -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;
}
+18
View File
@@ -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');
}
+14
View File
@@ -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>' +
+11
View File
@@ -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,
});
}