refactor: 品牌重命名 + maxTokens 支持 + 设置面板简化

- CodeGuard → Code Purifier / 净码特工(displayName、命令、配置标题)
- 新增 ai.maxTokens 配置项,所有 Provider 及 fixer 传入 maxTokens
- AI 引擎增强:repairJsonEscapes + JSON 解析 fallback + 详细错误信息
- 设置面板规则管理改为文件级(list/delete .yaml),addRule 改为 AI 从 Markdown 生成 YAML
- yaml-parser 简化:移除 config.yaml 的 enable/disable 过滤逻辑
- 审查报告面板:errorBanner 优先显示具体错误、lint 诊断显示 suggestion、移除 translatedDiagnostics 独立渲染
- merger 中 translatedDiagnostics 覆盖原始 lint 诊断 message/suggestion
- HTML linter 配置项、测试用例重写、typescript-eslint 移入 dependencies
This commit is contained in:
范智鹏
2026-07-20 20:24:40 +08:00
parent 805737fcfc
commit a734cdf009
31 changed files with 462 additions and 293 deletions
+1 -1
View File
@@ -13,4 +13,4 @@ VS Code 代码审查与规范检查一体化插件。集成 ESLint、Stylelint
## 配置
通过 `Ctrl+Shift+P``CodeGuard: 打开设置面板` 进行配置,或编辑 `.vscode/settings.json`
通过 `Ctrl+Shift+P``净码特工: 打开设置面板` 进行配置,或编辑 `.vscode/settings.json`
+28
View File
@@ -58,3 +58,31 @@
| 2026-07-16 19:06 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 保存并测试连接时,Base URL 未配置也报错提示,不再静默使用默认值 | 无 | src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-16 21:18 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 快速开始三步序号动态高亮:完成=绿色(#3fb950)、有后继步骤完成但本步未完成=黄色(#f0883e)、默认=灰色 | 三态逻辑先在"仅步骤3触发才变黄"和"任意后继触发变黄"间迭代一次 | src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-16 21:57 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 addRule/deleteRule 不同步规则定义文件:addRule 现在在 .code-review/rules/user-rules.yaml 创建模板定义;deleteRule 从 user-rules.yaml 移除规则项 | 无 | src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-16 22:36 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 品牌命名:CodeGuard → 净码特工 · Code Purifier,更新 package.json/extension.ts/README.md 及所有历史文档 | brainstorm 过渡方案: 码鉴 CodeLens / 智评 AIReview / 码析 CodeSense 均被废弃 | package.json src/extension.ts README.md docs/superpowers/specs/*.md | deepseek-v4-flash |
| 2026-07-17 20:21 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 | 设置面板显示所有规则(含禁用):CustomRule 增加 enabled 字段,loadActiveRules 返回全部规则并用 enabled 标记启用状态,设置面板展示全部规则(禁用开关为 off),AI 审查前过滤仅启用规则 | 无 | src/types.ts src/rules/yaml-parser.ts src/activation/commands.ts src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-17 20:39 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 设置面板改为文件列表展示(无开关无规则详情):去掉 enable/disable 机制;新增 listRuleFiles()addRule 改为选择 .md 文件 → AI 生成规则 YAML → 写入文件;deleteFile 删除整个 .yaml 文件 | 方案经历 3 轮迭代:①按文件分组+开关 → ②只显示文件名+删除+添加 → ③添加改为 AI 读取.md 生成 | src/types.ts src/rules/yaml-parser.ts src/activation/commands.ts src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash |
| 2026-07-17 21:03 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Phase 6.3 测试:7 个测试文件(fixtures/ 3 样本 + 4 测试 suite+ 修复 webview JS 和 TS 输出碰撞问题 | config.test.ts 按实际 API 调整(getLinterConfig→getLinterForLanguage 等);extension.test.ts 改用 getCommands 绕过 publisher 缺失;发现并修复 setupView.js 被 copy 脚本覆盖 TS 编译产物致扩展无法激活的 bug | src/test/fixtures/sample.js src/test/fixtures/sample.css src/test/fixtures/Sample.java src/test/adapter.test.ts src/test/config.test.ts src/test/merger.test.ts src/test/pipeline.test.ts src/test/extension.test.ts scripts/copy-webview-js.mjs scripts/build.mjs src/views/setupView.ts | deepseek-v4-flash |
| 2026-07-18 14:19 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 | 手动测试样本:每种语言带真实 bug 的文件,供用户安装插件后手动验证审查功能 | 无 | src/test/manual/buggy.js src/test/manual/buggy.css src/test/manual/buggy.sql src/test/manual/Buggy.java src/test/manual/buggy.jsp | deepseek-v4-flash |
| 2026-07-18 14:51 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | bug fix: stylelint 无配置文件导致静态分析跳过 CSS bug。在 StylelintAdapter 中内置 11 条默认规则,检测到 workingDir 无外部 .stylelintrc 时自动 fallback | 无 | src/adapters/stylelint.ts | deepseek-v4-flash |
| 2026-07-18 15:23 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 | 修正内置规则:移除 stylelint v17 无效的 color-hex-case,替换为 color-hex-length + color-named | 先用 color-hex-case('lower')发现无效 → 测试验证后替换 | src/adapters/stylelint.ts | deepseek-v4-flash |
| 2026-07-18 15:42 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | bug fix: import('stylelint') 返回 ESM namespace {default:{lint}},直接调 i.lint 为 undefined 抛 TypeError 被静默吞掉;同时修复静态分析错误不显示的问题(error 挂在 degraded 下,AI成功则不显示) | 第1轮只改 stylelint.ts,用户测试仍无问题→第2轮发现 import 未取 .default + panel 缺独立错误横幅 | src/adapters/stylelint.ts src/panel/webview.ts | deepseek-v4-flash |
| 2026-07-18 15:51 | ① 用户提出 → ⑤ 编码实现 | AI 审查解析失败时附带原始响应文本方便排查;修复错误横幅重复显示(errorBanner + degradedBanner 合并为单条) | 无 | src/ai/engine.ts src/panel/webview.ts | deepseek-v4-flash |
| 2026-07-18 16:20 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 静态分析标签页 linter 消息按 outputLanguage 翻译:在 buildLinterList() 和 reportToMarkdown() 中从 translatedDiagnostics 查找匹配翻译替换原始消息 | 无 | src/panel/webview.ts src/utils/report.ts | deepseek-v4-flash |
| 2026-07-18 16:22 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 ruleId/originalRuleId 不匹配:同时注册带前缀和不带前缀两种 key 到 transMap | 第一次只用 exact match 失效,改为双 key | src/panel/webview.ts src/utils/report.ts | deepseek-v4-flash |
| 2026-07-18 16:24 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 重构:翻译逻辑从视图层移到数据层 merger.ts mergeResults 中直接替换 linterDiagnostics message | 视图层 transMap 方案废弃;改用数据层替换 | src/merger/merger.ts src/panel/webview.ts src/utils/report.ts | deepseek-v4-flash |
| 2026-07-18 16:36 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 静态分析标签页同步显示 translatedSuggestionmerger.ts 匹配翻译时一并设置 suggestion 字段 | 无 | src/merger/merger.ts | deepseek-v4-flash |
| 2026-07-18 16:38 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 静态分析标签页 WebView 渲染 suggestionbuildLinterList 中增加建议行显示 | 无 | src/panel/webview.ts | deepseek-v4-flash |
| 2026-07-18 16:40 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | AI 响应解析失败时附带原始响应文本:catch 中传入 e.message 而非固定字符串 | 无 | src/ai/engine.ts | deepseek-v4-flash |
| 2026-07-18 16:42 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 AI 返回 JSON 中未转义双引号导致解析失败:prompt 增加转义说明 + repairJsonEscapes 容错后处理 | 无 | src/ai/engine.ts | deepseek-v4-flash |
| 2026-07-18 19:17 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: AI 审查标签页不再显示 translatedDiagnostics(静态分析翻译结果重复渲染),只保留 aiFindings | 无 | src/panel/webview.ts | deepseek-v4-flash |
| 2026-07-18 19:28 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 诊断 PMD 报"Java 11+ 未安装"原因(缺少 PMD JAR + PmdRunner.java 未编译 + API 不兼容 PMD 7.26.0);下载 PMD 7.26.0 JAR 包 4 个到 jars/pmd/lib/;重写 PmdRunner.java 适配 7.26.0 APIaddRuleSet(Path)→addRuleSet(String)setInputFilePath/setReportWriter→config.createRenderer()+pmd.files().addFile());编译生成 PmdRunner.class | 初版编译原 PmdRunner.java 报 API 不兼容(2 个方法不存在),改为 PMD 7.26.0 新版 API | jars/pmd/lib/pmd-core-7.26.0.jar jars/pmd/lib/pmd-java-7.26.0.jar jars/pmd/lib/pmd-javascript-7.26.0.jar jars/pmd/lib/pmd-jsp-7.26.0.jar jars/pmd/PmdRunner.java jars/pmd/PmdRunner.class | deepseek-v4-flash |
| 2026-07-19 13:38 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 真正根因:缺少 12 个 PMD transitive 依赖(slf4j/antlr/saxon 等);CycloneDX SBOM 解析依赖版本→下载全部 16 JAR;简化 PmdRunner.java;修复 pmd.ts catch 误判(message.includes('java') 太宽);重构 build.mjs 去掉 jars→out 重复拷贝 | 先试 pmd-dist ZIP(404)→改 GitHub Release(网络不通)→最后用 Maven CycloneDX 解析依赖版本 | jars/pmd/lib/*.jar(16) jars/pmd/PmdRunner.java src/adapters/pmd.ts scripts/build.mjs scripts/download-pmd.mjs vscode-code-reviewer-0.0.1.vsix | deepseek-v4-flash |
| 2026-07-19 13:48 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 PmdRunner.class 找不到:重构路径解析为 resolvePmdDir(),探测 4 个候选位置并用 fs.existsSync(PmdRunner.class) 验证;简化 getExtensionRoot 回退逻辑 | 无 | src/adapters/pmd.ts | deepseek-v4-flash |
| 2026-07-19 14:33 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 AI 审查 JSON 解析失败:repairJsonEscapes 漏了 `:` 导致 key 结束 `"` 被误加 `\`;保留 prompt 转义指令,预处理增加 `/\\"/g``"` 处理过度转义 | 无 | src/ai/engine.ts | deepseek-v4-flash |
| 2026-07-19 14:56 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | ESLintAdapter 集成 eslint:recommended + @typescript-eslint/recommended 作为内置规则集;typescript-eslint 从 devDependencies 移至 dependencies | 方案 1(只改项目 eslint.config.mjs)被否;方案 2(适配器兜底自定义规则集)用户质疑"谁定义的推荐"→改为官方推荐规则集 | src/adapters/eslint.ts package.json | deepseek-v4-flash |
| 2026-07-19 21:50 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: JSP 中 `<script>` 块 JS 代码误用 `.ts` 后缀 lint 导致 `no-redeclare` 漏检 — ESLintAdapter 兜底 filePath 从硬编码 `'untitled.ts'` 改为检查 `document.uri.scheme === 'untitled'` 决定扩展名 | 第一轮用 `document.fileName || 'untitled.js'` 不够 — virtual doc 的 fileName 是 `'Untitled-1'` (truthy),兜底永远用不上;第二轮改为检查 `document.uri.scheme === 'untitled'` | src/adapters/eslint.ts | deepseek-v4-flash |
| 2026-07-19 22:04 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: VS Code 将 `.jsp` 识别为 `html` 语言 ID 导致 Orchestrator 静默返回空 — 尝试用 `contributes.languages` 注册 `.jsp`/`.jspx``jsp` 语言 | 方案曾被采纳,后因副作用被替换 | package.json | deepseek-v4-flash |
| 2026-07-19 22:25 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 改用 `JspAdapter` 支持 `html` 语言 ID + 新增 `linters.html` 配置(默认 `jsp`),移除 `contributes.languages`,保留 HTML 编辑功能;`JspAdapter` 改为 mockDocument 替代 `openTextDocument`,避免 untitled 临时文档 | `contributes.languages` → 丢失 HTML 语言特性 → 弃用;`closeTextDocument` → API 不存在 → 改为 mockDocument | src/adapters/jsp.ts package.json | deepseek-v4-flash |
| 2026-07-19 22:35 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 根因修复:AI 响应被截断因未设置 `max_tokens`。ChatOptions 加 maxTokens 字段;config 加 getAIMaxTokens()(默认 8192);package.json 加配置项;所有 provider 实现使用该值 | 无 | src/ai/providers/base.ts src/config/ai.ts package.json src/ai/providers/openai-compatible.ts src/ai/providers/claude.ts src/ai/providers/gemini.ts src/ai/engine.ts src/fixer/fixer.ts src/views/setupView.ts src/test/config.test.ts | deepseek-v4-flash |
| 2026-07-20 20:01 | ① 用户提出 → ⑤ 编码实现 | Bug fix: SqlFluffViolation 接口字段与 sqlfluff JSON 输出不匹配(line_no→start_line_noline_pos→start_line_posrule_code→code,缺 end 字段),导致行号显示 LNaN、ruleId 显示 undefined | 无 | src/adapters/sql-lint.ts | deepseek-v4-flash |
| 2026-07-20 20:17 | ① 用户提出 → ⑤ 编码实现 | Bug fix: mergeResults 中用 originalRuleId 作 Map key 导致同 ruleId 的多条诊断(如两个 PRS)后覆盖前,翻译错误;改为按索引位置一一对应匹配 | 无 | src/merger/merger.ts | deepseek-v4-flash |
@@ -444,7 +444,7 @@ interface AIFinding {
"activitybar": [
{
"id": "code-reviewer",
"title": "CodeGuard 代码审查",
"title": "净码特工",
"icon": "images/icon.png"
}
]
@@ -461,7 +461,7 @@ applyFix() 应用修复到编辑器(单条 / 批量倒序)
"viewsContainers": {
"activitybar": [{
"id": "code-reviewer",
"title": "CodeGuard 代码审查",
"title": "净码特工",
"icon": "images/icon.png"
}]
},
@@ -173,7 +173,7 @@ import { registerCommands } from './activation/commands';
let orchestrator: Orchestrator;
export function activate(context: vscode.ExtensionContext) {
console.log('CodeGuard 代码审查插件已激活');
console.log('净码特工 · Code Purifier 已激活');
orchestrator = new Orchestrator();
registerCommands(context, orchestrator);
@@ -213,14 +213,14 @@ export function deactivate() {
```json
{
"commands": [
{ "command": "codeReviewer.review", "title": "CodeGuard: 运行代码审查" },
{ "command": "codeReviewer.reviewSelection", "title": "CodeGuard: 审查选中代码" },
{ "command": "codeReviewer.openPanel", "title": "CodeGuard: 打开审查面板" },
{ "command": "codeReviewer.exportReport", "title": "CodeGuard: 导出报告" },
{ "command": "codeReviewer.addCustomRule", "title": "CodeGuard: 添加自定义规则" },
{ "command": "codeReviewer.fixIssue", "title": "CodeGuard: 修复此问题" },
{ "command": "codeReviewer.fixAll", "title": "CodeGuard: 批量修复" },
{ "command": "codeReviewer.openSetup", "title": "CodeGuard: 打开设置面板" }
{ "command": "codeReviewer.review", "title": "净码特工: 运行代码审查" },
{ "command": "codeReviewer.reviewSelection", "title": "净码特工: 审查选中代码" },
{ "command": "codeReviewer.openPanel", "title": "净码特工: 打开审查面板" },
{ "command": "codeReviewer.exportReport", "title": "净码特工: 导出报告" },
{ "command": "codeReviewer.addCustomRule", "title": "净码特工: 添加自定义规则" },
{ "command": "codeReviewer.fixIssue", "title": "净码特工: 修复此问题" },
{ "command": "codeReviewer.fixAll", "title": "净码特工: 批量修复" },
{ "command": "codeReviewer.openSetup", "title": "净码特工: 打开设置面板" }
]
}
```
@@ -411,7 +411,7 @@ export function activate(context: vscode.ExtensionContext) {
## 验收
- [ ] 侧边栏显示 CodeGuard 设置视图
- [ ] 侧边栏显示 净码特工 设置视图
- [ ] 快速开始三步引导可见
- [ ] API Key 可输入并保存到 SecretStorage
- [ ] 提供商/模型可切换
+1 -1
View File
@@ -1,7 +1,7 @@
import typescriptEslint from "typescript-eslint";
export default [{
files: ["**/*.ts"],
files: ["**/*.js", "**/*.ts"],
}, {
plugins: {
"@typescript-eslint": typescriptEslint.plugin,
+9 -7
View File
@@ -24,15 +24,17 @@ public class PmdRunner {
try {
PMDConfiguration config = new PMDConfiguration();
config.setInputFilePath(Path.of(filePath));
config.addRuleSet(Path.of(rulesetPath));
config.setReportFormat("json");
config.addRuleSet(rulesetPath);
StringWriter writer = new StringWriter();
config.setReportWriter(writer);
Writer writer = new StringWriter();
JsonRenderer renderer = new JsonRenderer();
renderer.setWriter(writer);
PmdAnalysis pmd = PmdAnalysis.create(config);
pmd.performAnalysis();
try (PmdAnalysis pmd = PmdAnalysis.create(config)) {
pmd.files().addFile(Path.of(filePath));
pmd.addRenderer(renderer);
pmd.performAnalysis();
}
System.out.print(writer.toString());
} finally {
+29 -15
View File
@@ -1,7 +1,7 @@
{
"name": "vscode-code-reviewer",
"displayName": "vscode-code-reviewer",
"description": "VS Code 插件设计 —— B3代码审查与规范检查一体化",
"displayName": "净码特工 · Code Purifier",
"description": "AI 驱动的代码审查与规范检查一体化 VSCode 插件",
"version": "0.0.1",
"engines": {
"vscode": "^1.120.0"
@@ -15,35 +15,35 @@
"commands": [
{
"command": "codeReviewer.review",
"title": "CodeGuard: 运行代码审查"
"title": "Code Purifier: 运行代码审查"
},
{
"command": "codeReviewer.reviewSelection",
"title": "CodeGuard: 审查选中代码"
"title": "Code Purifier: 审查选中代码"
},
{
"command": "codeReviewer.openPanel",
"title": "CodeGuard: 打开审查面板"
"title": "Code Purifier: 打开审查面板"
},
{
"command": "codeReviewer.exportReport",
"title": "CodeGuard: 导出报告"
"title": "Code Purifier: 导出报告"
},
{
"command": "codeReviewer.addCustomRule",
"title": "CodeGuard: 添加自定义规则"
"title": "Code Purifier: 添加自定义规则"
},
{
"command": "codeReviewer.fixIssue",
"title": "CodeGuard: 修复此问题"
"title": "Code Purifier: 修复此问题"
},
{
"command": "codeReviewer.fixAll",
"title": "CodeGuard: 批量修复"
"title": "Code Purifier: 批量修复"
},
{
"command": "codeReviewer.openSetup",
"title": "CodeGuard: 打开设置面板"
"title": "Code Purifier: 打开设置面板"
}
],
"keybindings": [
@@ -57,7 +57,7 @@
"activitybar": [
{
"id": "code-reviewer",
"title": "CodeGuard 代码审查",
"title": "Code Purifier",
"icon": "images/icon.png"
}
]
@@ -84,7 +84,7 @@
]
},
"configuration": {
"title": "CodeGuard",
"title": "Code Purifier",
"properties": {
"vscode-code-reviewer.ai.provider": {
"type": "string",
@@ -110,6 +110,11 @@
"default": 0.2,
"description": "AI 温度参数"
},
"vscode-code-reviewer.ai.maxTokens": {
"type": "number",
"default": 8192,
"description": "AI 单次请求最大输出 Token 数"
},
"vscode-code-reviewer.ai.timeout": {
"type": "number",
"default": 300,
@@ -161,6 +166,15 @@
],
"description": "JSP linter"
},
"vscode-code-reviewer.linters.html": {
"type": "string",
"default": "jsp",
"enum": [
"",
"jsp"
],
"description": "HTML linter"
},
"vscode-code-reviewer.linters.css": {
"type": "string",
"default": "stylelint",
@@ -229,7 +243,8 @@
},
"dependencies": {
"eslint": "^9.39.3",
"stylelint": "^17.14.0"
"stylelint": "^17.14.0",
"typescript-eslint": "^8.56.1"
},
"devDependencies": {
"@types/mocha": "^10.0.10",
@@ -239,7 +254,6 @@
"@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.9.2",
"esbuild": "^0.28.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1"
"typescript": "^5.9.3"
}
}
+3 -9
View File
@@ -1,5 +1,5 @@
import * as esbuild from 'esbuild';
import { copyFileSync, mkdirSync, existsSync, cpSync } from 'fs';
import { copyFileSync, mkdirSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
@@ -33,20 +33,14 @@ await esbuild.build({
treeShaking: true,
});
const jarsSrc = resolve(rootDir, 'jars');
const jarsDest = resolve(outDir, 'jars');
if (existsSync(jarsSrc)) {
cpSync(jarsSrc, jarsDest, { recursive: true, force: true });
}
/* copy webview JS files */
const viewsSrc = resolve(rootDir, 'src', 'views');
const viewsDest = resolve(outDir, 'views');
const webviewDir = resolve(outDir, 'webview');
if (existsSync(viewsSrc)) {
const files = ['setupView.js'];
for (const f of files) {
const src = resolve(viewsSrc, f);
const dest = resolve(viewsDest, f);
const dest = resolve(webviewDir, f);
if (existsSync(src)) {
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(src, dest);
+2 -1
View File
@@ -10,7 +10,8 @@ const srcViews = resolve(rootDir, 'src', 'views');
const files = ['setupView.js'];
for (const f of files) {
const src = resolve(srcViews, f);
const dest = resolve(outViews, f);
const webviewDir = resolve(rootDir, 'out', 'webview');
const dest = resolve(webviewDir, f);
if (existsSync(src)) {
mkdirSync(dirname(dest), { recursive: true });
copyFileSync(src, dest);
+32 -17
View File
@@ -8,14 +8,26 @@ const rootDir = resolve(__dirname, '..');
const libDir = resolve(rootDir, 'jars', 'pmd', 'lib');
const PMD_VERSION = '7.26.0';
const PMD_JARS = [
`pmd-core-${PMD_VERSION}.jar`,
`pmd-java-${PMD_VERSION}.jar`,
`pmd-javascript-${PMD_VERSION}.jar`,
`pmd-jsp-${PMD_VERSION}.jar`,
];
const MAVEN_BASE = 'https://repo1.maven.org/maven2';
const MAVEN_BASE = `https://repo1.maven.org/maven2/net/sourceforge/pmd`;
const JARS = [
{ path: `net/sourceforge/pmd/pmd-core/${PMD_VERSION}/pmd-core-${PMD_VERSION}.jar` },
{ path: `net/sourceforge/pmd/pmd-java/${PMD_VERSION}/pmd-java-${PMD_VERSION}.jar` },
{ path: `net/sourceforge/pmd/pmd-javascript/${PMD_VERSION}/pmd-javascript-${PMD_VERSION}.jar` },
{ path: `net/sourceforge/pmd/pmd-jsp/${PMD_VERSION}/pmd-jsp-${PMD_VERSION}.jar` },
{ path: 'org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar' },
{ path: 'org/slf4j/jul-to-slf4j/1.7.36/jul-to-slf4j-1.7.36.jar' },
{ path: 'org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar' },
{ path: 'net/sf/saxon/Saxon-HE/12.9/Saxon-HE-12.9.jar' },
{ path: 'org/apache/commons/commons-lang3/3.20.0/commons-lang3-3.20.0.jar' },
{ path: 'org/ow2/asm/asm/9.10.1/asm-9.10.1.jar' },
{ path: 'com/google/code/gson/gson/2.14.0/gson-2.14.0.jar' },
{ path: 'org/checkerframework/checker-qual/4.2.0/checker-qual-4.2.0.jar' },
{ path: 'org/pcollections/pcollections/4.0.2/pcollections-4.0.2.jar' },
{ path: 'com/github/oowekyala/ooxml/nice-xml-messages/3.1/nice-xml-messages-3.1.jar' },
{ path: 'org/xmlresolver/xmlresolver/5.3.3/xmlresolver-5.3.3.jar' },
{ path: 'com/google/errorprone/error_prone_annotations/2.47.0/error_prone_annotations-2.47.0.jar' },
];
function downloadFile(url, dest) {
return new Promise((resolvePromise, reject) => {
@@ -25,6 +37,10 @@ function downloadFile(url, dest) {
downloadFile(response.headers.location, dest).then(resolvePromise).catch(reject);
return;
}
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode} for ${url}`));
return;
}
response.pipe(file);
file.on('finish', () => { file.close(); resolvePromise(); });
file.on('error', (err) => { unlinkSync(dest); reject(err); });
@@ -36,19 +52,18 @@ if (!existsSync(libDir)) {
mkdirSync(libDir, { recursive: true });
}
for (const jar of PMD_JARS) {
const moduleName = jar.replace(`-${PMD_VERSION}.jar`, '').replace('pmd-', '');
const url = `${MAVEN_BASE}/pmd-${moduleName}/${PMD_VERSION}/${jar}`;
const dest = resolve(libDir, jar);
let count = 0;
for (const jar of JARS) {
const filename = jar.path.split('/').pop();
const dest = resolve(libDir, filename);
if (existsSync(dest)) {
console.log(`Skip: ${jar} (exists)`);
console.log(`Skip: ${filename}`);
continue;
}
console.log(`Downloading: ${url}`);
const url = `${MAVEN_BASE}/${jar.path}`;
console.log(`Downloading ${filename}...`);
await downloadFile(url, dest);
console.log(`Done: ${jar}`);
count++;
}
console.log('PMD JARs download complete.');
console.log(`Done. Downloaded ${count} new JARs. Total: ${JARS.length} JARs in ${libDir}`);
+22 -2
View File
@@ -1,20 +1,40 @@
import * as vscode from 'vscode';
import { ESLint } from 'eslint';
import js from '@eslint/js';
import ts from 'typescript-eslint';
import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter';
export class ESLintAdapter implements LinterAdapter {
id = 'eslint';
supportedLanguages = ['javascript', 'typescript'];
private static defaultConfig: any[] | null = null;
private static getDefaultConfig(): any[] {
if (!ESLintAdapter.defaultConfig) {
ESLintAdapter.defaultConfig = [
js.configs.recommended,
...ts.configs.recommended,
];
}
return ESLintAdapter.defaultConfig;
}
isAvailable(): boolean {
return true;
}
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const engine = new ESLint({ cwd: workingDir });
const engine = new ESLint({
cwd: workingDir,
overrideConfigFile: true,
overrideConfig: ESLintAdapter.getDefaultConfig(),
});
const ext = document.languageId === 'typescript' ? 'ts' : 'js';
const isVirtual = document.uri.scheme === 'untitled';
const results = await engine.lintText(document.getText(), {
filePath: document.fileName || 'untitled.ts',
filePath: isVirtual ? `untitled.${ext}` : document.fileName,
});
const diagnostics: LinterDiagnostic[] = [];
+49 -6
View File
@@ -6,9 +6,56 @@ import { StylelintAdapter } from './stylelint';
import { extractJspSections } 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;
}
export class JspAdapter implements LinterAdapter {
id = 'jsp';
supportedLanguages = ['jsp'];
supportedLanguages = ['jsp', 'html'];
private pmdAdapter = new PmdAdapter();
private eslintAdapter = new ESLintAdapter();
@@ -40,11 +87,7 @@ export class JspAdapter implements LinterAdapter {
if (!adapter) { continue; }
try {
const virtualDoc = await vscode.workspace.openTextDocument({
content: section.code,
language: section.language,
});
const result = await adapter.check(virtualDoc, workingDir);
const result = await adapter.check(mockDocument(section.code, section.language), workingDir);
for (const diag of result.diagnostics) {
const adjustedRange = new vscode.Range(
+27 -14
View File
@@ -1,5 +1,6 @@
import * as vscode from 'vscode';
import * as path from 'path';
import { existsSync } from 'fs';
import { execSync, spawn } from 'child_process';
import type { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
import { getPMDRulesetPath } from '../config';
@@ -7,24 +8,36 @@ import { getPMDRulesetPath } from '../config';
export class PmdAdapter implements LinterAdapter {
id = 'pmd';
supportedLanguages = ['java'];
private pmdDir: string | null = null;
private resolvePmdDir(): string {
if (this.pmdDir) { return this.pmdDir; }
const candidates: string[] = [];
try {
const ext = vscode.extensions.getExtension?.('vscode-code-reviewer');
if (ext?.extensionPath) {
candidates.push(path.join(ext.extensionPath, 'jars', 'pmd'));
candidates.push(path.join(ext.extensionPath, 'out', 'jars', 'pmd'));
}
} catch { /* ignore */ }
candidates.push(path.join(path.resolve(__dirname, '..'), 'jars', 'pmd'));
candidates.push(path.join(path.resolve(__dirname, '..', '..'), 'jars', 'pmd'));
for (const dir of candidates) {
if (existsSync(path.join(dir, 'PmdRunner.class'))) {
this.pmdDir = dir;
return dir;
}
}
this.pmdDir = candidates[0];
return this.pmdDir;
}
private getPmdLibClasspath(): string {
const extRoot = this.getExtensionRoot();
const pmdLib = path.join(extRoot, 'jars', 'pmd', 'lib');
return path.join(pmdLib, '*');
return path.join(this.resolvePmdDir(), 'lib', '*');
}
private getPmdRunnerClasspath(): string {
const extRoot = this.getExtensionRoot();
return path.join(extRoot, 'jars', 'pmd');
}
private getExtensionRoot(): string {
try {
const extPath = vscode.extensions.getExtension?.('vscode-code-reviewer')?.extensionPath;
if (extPath) { return extPath; }
} catch { /* extension not available */ }
return path.join(__dirname, '..', '..');
return this.resolvePmdDir();
}
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
@@ -43,7 +56,7 @@ export class PmdAdapter implements LinterAdapter {
return { diagnostics, status: 'ok' };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (message.includes('ENOENT') || message.includes('java')) {
if (message.includes('ENOENT') || message.includes('java not found') || message.includes('Cannot find')) {
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Java 11+ 未安装或不在 PATH 中' };
}
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
+10 -8
View File
@@ -8,9 +8,11 @@ const DIALECT_MAP: Record<string, string> = {
};
interface SqlFluffViolation {
line_no: number;
line_pos: number;
rule_code: string;
start_line_no: number;
start_line_pos: number;
end_line_no: number;
end_line_pos: number;
code: string;
description: string;
}
@@ -74,13 +76,13 @@ export class SqlLintAdapter implements LinterAdapter {
for (const v of result.violations) {
diagnostics.push({
severity: 'warning',
ruleId: `sql-lint:${v.rule_code}`,
ruleId: `sql-lint:${v.code}`,
message: v.description,
range: new vscode.Range(
v.line_no - 1,
v.line_pos - 1,
v.line_no - 1,
v.line_pos - 1
v.start_line_no - 1,
v.start_line_pos - 1,
v.end_line_no - 1,
v.end_line_pos - 1
),
});
}
+50 -3
View File
@@ -1,10 +1,41 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import type { LinterAdapter, AdapterResult, LinterDiagnostic, Severity } from './adapter';
const CONFIG_FILE_NAMES = [
'.stylelintrc',
'.stylelintrc.json',
'.stylelintrc.yaml',
'.stylelintrc.yml',
'.stylelintrc.js',
'stylelint.config.js',
'stylelint.config.mjs',
'stylelint.config.cjs',
];
const DEFAULT_CONFIG: Record<string, unknown> = {
rules: {
'color-hex-length': 'short',
'color-named': 'never',
'color-no-invalid-hex': true,
'length-zero-no-unit': true,
'font-family-no-missing-generic-family-keyword': true,
'block-no-empty': true,
'declaration-block-no-duplicate-properties': true,
'no-descending-specificity': true,
'unit-no-unknown': true,
'property-no-unknown': true,
'selector-pseudo-class-no-unknown': true,
'selector-pseudo-element-no-unknown': true,
},
};
interface LinterOptions {
code?: string;
codeFilename?: string;
cwd?: string;
config?: Record<string, unknown>;
}
interface LinterResult {
@@ -21,6 +52,14 @@ interface LinterResult {
}>;
}
function hasExternalConfig(dir: string): boolean {
try {
return CONFIG_FILE_NAMES.some(name => fs.existsSync(path.join(dir, name)));
} catch {
return false;
}
}
export class StylelintAdapter implements LinterAdapter {
id = 'stylelint';
supportedLanguages = ['css'];
@@ -29,7 +68,8 @@ export class StylelintAdapter implements LinterAdapter {
private async getModule(): Promise<{ lint: (opts: LinterOptions) => Promise<LinterResult> }> {
if (!this._module) {
this._module = await import('stylelint') as { lint: (opts: LinterOptions) => Promise<LinterResult> };
const mod = await import('stylelint');
this._module = (mod.default ?? mod) as { lint: (opts: LinterOptions) => Promise<LinterResult> };
}
return this._module;
}
@@ -41,11 +81,18 @@ export class StylelintAdapter implements LinterAdapter {
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
try {
const stylelint = await this.getModule();
const result = await stylelint.lint({
const lintOptions: LinterOptions = {
code: document.getText(),
codeFilename: document.fileName,
cwd: workingDir,
});
};
if (!hasExternalConfig(workingDir)) {
lintOptions.config = DEFAULT_CONFIG;
}
const result = await stylelint.lint(lintOptions);
const diagnostics: LinterDiagnostic[] = [];
for (const res of result.results) {
+50 -8
View File
@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import type { AIProvider } from './providers/base';
import { createProvider } from './factory';
import { getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIOutputLanguage, getApiKey } from '../config';
import { getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIMaxTokens, getAIOutputLanguage, getApiKey } from '../config';
import type { LinterDiagnostic, CustomRule } from '../types';
import type {
AIEngineResult,
@@ -26,14 +26,54 @@ function addLineNumbers(code: string): string {
return code.split('\n').map((line, i) => `${String(i + 1).padStart(4, ' ')}| ${line}`).join('\n');
}
function repairJsonEscapes(str: string): string {
let s = str.replace(/\\"\\n/g, '\\n').replace(/\\"/g, '"');
let inString = false;
let out = '';
for (let i = 0; i < s.length; i++) {
const ch = s[i];
if (ch === '\\') {
out += ch;
if (i + 1 < s.length) { out += s[++i]; }
} else if (ch === '"') {
if (!inString) {
inString = true;
out += ch;
} else {
let j = i + 1;
while (j < s.length && s[j] === ' ') { j++; }
if (j < s.length && ':,\]}'.includes(s[j])) {
inString = false;
out += ch;
} else {
out += '\\"';
}
}
} else {
out += ch;
}
}
return out;
}
function parseJsonResponse(raw: string): object {
const trimmed = raw.trim();
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
if (start === -1 || end === -1) {
throw new Error('响应中未找到 JSON');
throw new Error(`响应中未找到 JSON。原始响应(前200字符):${trimmed.slice(0, 200)}`);
}
let jsonStr = trimmed.substring(start, end + 1);
try {
return JSON.parse(jsonStr);
} catch {
jsonStr = repairJsonEscapes(jsonStr);
try {
return JSON.parse(jsonStr);
} catch {
throw new Error(`JSON 解析失败。原始响应(前200字符):${jsonStr.slice(0, 200)}`);
}
}
return JSON.parse(trimmed.substring(start, end + 1));
}
const CUSTOM_RULE_SYSTEM_PROMPT = `你是代码规则审查员,只评估以下自定义规则是否被违反。
@@ -48,7 +88,8 @@ const DEEP_REVIEW_SYSTEM_PROMPT = `你是资深代码审查专家,完成两个
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
不要重复静态分析已报告的问题。
仅输出 JSON格式:
仅输出 JSON字符串中的双引号必须用 \" 转义。
格式:
{
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议", "codeDiff": "可选" }],
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "codeDiff": "可选", "line": 行号 }]
@@ -91,6 +132,7 @@ export async function runAIReview(
const options = {
model: getAIModel(),
temperature: getAITemperature(),
maxTokens: getAIMaxTokens(),
timeoutMs: getAITimeout() * 1000,
};
@@ -123,8 +165,8 @@ export async function runAIReview(
...r,
ruleId: `custom:${r.ruleId}`,
}));
} catch {
errors.push('自定义规则响应解析失败');
} catch (e) {
errors.push(`自定义规则响应解析失败: ${e instanceof Error ? e.message : String(e)}`);
}
} else {
errors.push(`自定义规则请求失败: ${resultA.reason}`);
@@ -140,8 +182,8 @@ export async function runAIReview(
};
translatedDiagnostics = parsed.translatedDiagnostics ?? [];
findings = parsed.findings ?? [];
} catch {
errors.push('AI 审查响应解析失败');
} catch (e) {
errors.push(`AI 审查响应解析失败: ${e instanceof Error ? e.message : String(e)}`);
}
} else {
errors.push(`AI 审查请求失败: ${resultB.reason}`);
+1
View File
@@ -1,6 +1,7 @@
export interface ChatOptions {
model: string;
temperature: number;
maxTokens: number;
timeoutMs: number;
}
+1 -1
View File
@@ -9,7 +9,7 @@ export class ClaudeProvider extends AIProvider {
const body = JSON.stringify({
model: options.model,
max_tokens: 4096,
max_tokens: options.maxTokens,
temperature: options.temperature,
system: systemPrompt,
messages: [
+1
View File
@@ -18,6 +18,7 @@ export class GeminiProvider extends AIProvider {
],
generationConfig: {
temperature: options.temperature,
maxOutputTokens: options.maxTokens,
},
});
+1
View File
@@ -15,6 +15,7 @@ export class OpenAICompatibleProvider extends AIProvider {
const body = JSON.stringify({
model: options.model,
max_tokens: options.maxTokens,
temperature: options.temperature,
messages: [
{ role: 'system', content: systemPrompt },
+4
View File
@@ -22,6 +22,10 @@ export function getAITimeout(): number {
return vscode.workspace.getConfiguration(ROOT).get<number>('ai.timeout', 300);
}
export function getAIMaxTokens(): number {
return vscode.workspace.getConfiguration(ROOT).get<number>('ai.maxTokens', 8192);
}
export function getAIOutputLanguage(): string {
return vscode.workspace.getConfiguration(ROOT).get<string>('ai.outputLanguage', 'zh-CN');
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { SetupViewProvider } from './views/setupView';
let orchestrator: Orchestrator;
export function activate(context: vscode.ExtensionContext) {
console.log('CodeGuard 代码审查插件已激活');
console.log('净码特工 · Code Purifier 已激活');
orchestrator = new Orchestrator();
+1
View File
@@ -136,6 +136,7 @@ export async function generateFix(
const response = await provider.chat(FIX_SYSTEM_PROMPT, userPrompt, {
model,
temperature,
maxTokens: 4096,
timeoutMs,
});
+9 -1
View File
@@ -41,6 +41,14 @@ export function mergeResults(input: MergeInput): MergedReport {
range: new vscode.Range(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
}));
const linterDiagnostics = input.staticDiagnostics.map((d, i) => {
const td = input.translatedDiagnostics[i];
if (td) {
return { ...d, message: td.translatedMessage, suggestion: td.translatedSuggestion || d.suggestion };
}
return d;
});
const linterCount = input.staticDiagnostics.length;
const customRuleCount = customRuleDiagnostics.length;
const aiCount = input.aiFindings.length;
@@ -53,7 +61,7 @@ export function mergeResults(input: MergeInput): MergedReport {
.map((_, i) => i);
return {
linterDiagnostics: input.staticDiagnostics,
linterDiagnostics,
customRuleDiagnostics,
translatedDiagnostics: input.translatedDiagnostics,
aiFindings: input.aiFindings,
+8 -20
View File
@@ -63,12 +63,11 @@ export class ReviewPanel {
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
const degradedBanner = report.degraded
? `<div class="banner ${report.errors.length > 0 ? 'banner-error' : 'banner-warn'}">
${report.errors.length > 0 ? '⚠ AI 审查失败' : '⚠ 部分 AI 功能不可用'}
${report.errors.join('; ')}
</div>`
: '';
const errorBanner = report.errors.length > 0
? `<div class="banner banner-error">⚠ ${report.errors.join('; ')}</div>`
: report.degraded
? '<div class="banner banner-warn">⚠ 部分 AI 功能不可用</div>'
: '';
return `<!DOCTYPE html>
<html lang="zh">
@@ -119,7 +118,7 @@ export class ReviewPanel {
<h1>📋 代码审查报告</h1>
<div class="meta">${fileName} · ${report.language} · ${(report.duration / 1000).toFixed(1)}s</div>
</div>
${degradedBanner}
${errorBanner}
<div class="stats">
<div class="stat-card stat-total"><div class="num">${total}</div><div class="label">总计</div></div>
<div class="stat-card stat-error"><div class="num">${errorCount}</div><div class="label">错误</div></div>
@@ -171,6 +170,7 @@ ${degradedBanner}
<div class="issue-left">
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
<div class="issue-detail">${this.escape(d.message)}</div>
${d.suggestion ? `<div class="issue-detail">建议: ${this.escape(d.suggestion)}</div>` : ''}
</div>
<div class="issue-actions">
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">修复</button>
@@ -195,23 +195,11 @@ ${degradedBanner}
}
private buildAIList(report: MergedReport): string {
const total = report.translatedDiagnostics.length + report.aiFindings.length;
if (total === 0) {
if (report.aiFindings.length === 0) {
return '<div class="empty">🤖 AI 审查未发现新问题</div>';
}
const parts: string[] = [];
for (const td of report.translatedDiagnostics) {
parts.push(`
<div class="issue">
<div class="issue-left">
<div class="issue-title"><span class="severity">🔵</span> <code>${this.escape(td.originalRuleId)}</code></div>
<div class="issue-detail">${this.escape(td.translatedMessage)}</div>
${td.translatedSuggestion ? `<div class="issue-detail">建议: ${this.escape(td.translatedSuggestion)}</div>` : ''}
</div>
</div>`);
}
for (const f of report.aiFindings) {
parts.push(`
<div class="issue" onclick="send('navigate', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">
+6 -75
View File
@@ -10,11 +10,6 @@ interface RuleYamlItem {
languages?: string[];
}
interface RuleConfig {
enabled?: string[];
rules?: Record<string, { enabled: boolean }>;
}
function parseYamlSimple(content: string): object[] {
const items: Array<Record<string, unknown>> = [];
let current: Record<string, unknown> | null = null;
@@ -60,89 +55,20 @@ function parseYamlSimple(content: string): object[] {
return items;
}
function parseConfigYaml(content: string): RuleConfig {
const config: RuleConfig = { enabled: [], rules: {} };
let section: string | null = null;
let currentKey = '';
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) { continue; }
if (trimmed === 'enabled:') {
section = 'enabled';
continue;
}
if (trimmed === 'rules:') {
section = 'rules';
continue;
}
if (section === 'enabled' && trimmed.startsWith('- ')) {
const name = trimmed.substring(2).trim();
if (!config.enabled) { config.enabled = []; }
config.enabled!.push(name);
}
if (section === 'rules') {
const ruleMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*$/);
if (ruleMatch) {
currentKey = ruleMatch[1];
if (!config.rules) { config.rules = {}; }
config.rules[currentKey] = { enabled: true };
} else if (currentKey) {
const propMatch = trimmed.match(/^(\w+)\s*:\s*(.*)$/);
if (propMatch) {
const key = propMatch[1];
const value = propMatch[2].trim();
if (!config.rules) { config.rules = {}; }
if (!config.rules[currentKey]) { config.rules[currentKey] = { enabled: true }; }
(config.rules[currentKey] as Record<string, unknown>)[key] =
value === 'false' ? false : value === 'true' ? true : value;
}
}
}
}
return config;
}
export function loadActiveRules(workspaceRoot: string): CustomRule[] {
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
if (!fs.existsSync(rulesDir)) { return []; }
let ruleConfig: RuleConfig = {};
if (fs.existsSync(configPath)) {
const configContent = fs.readFileSync(configPath, 'utf-8');
ruleConfig = parseConfigYaml(configContent);
}
const enabledFiles = new Set(ruleConfig.enabled ?? []);
const disabledRules = new Set(
Object.entries(ruleConfig.rules ?? {})
.filter(([, v]) => v.enabled === false)
.map(([k]) => k)
);
const allRules: CustomRule[] = [];
const files = fs.readdirSync(rulesDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
for (const file of files) {
if (enabledFiles.size > 0 && !enabledFiles.has(file)) { continue; }
const content = fs.readFileSync(path.join(rulesDir, file), 'utf-8');
const items = parseYamlSimple(content) as RuleYamlItem[];
for (const item of items) {
if (disabledRules.has(item.id)) { continue; }
if (!item.id || !item.severity || !item.description || !item.message) { continue; }
const severity = (['error', 'warning', 'info'].includes(item.severity)
? (item.severity as Severity)
: 'warning');
allRules.push({
id: item.id,
severity,
@@ -152,6 +78,11 @@ export function loadActiveRules(workspaceRoot: string): CustomRule[] {
});
}
}
return allRules;
}
export function listRuleFiles(workspaceRoot: string): string[] {
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
if (!fs.existsSync(rulesDir)) { return []; }
return fs.readdirSync(rulesDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
}
+12 -7
View File
@@ -1,15 +1,20 @@
import * as assert from 'assert';
// You can import and use all API from the 'vscode' module
// as well as import your extension to test it
import * as vscode from 'vscode';
// import * as myExtension from '../../extension';
suite('Extension Test Suite', () => {
vscode.window.showInformationMessage('Start all tests.');
test('Sample test', () => {
assert.strictEqual(-1, [1, 2, 3].indexOf(5));
assert.strictEqual(-1, [1, 2, 3].indexOf(0));
test('Extension registers codeReviewer commands', async () => {
const ext = vscode.extensions.getExtension('undefined.vscode-code-reviewer')
|| vscode.extensions.all.find(e => e.id.includes('vscode-code-reviewer'));
if (ext && !ext.isActive) {
await ext.activate();
}
const commands = await vscode.commands.getCommands(false);
const reviewerCommands = commands.filter(c => c.startsWith('codeReviewer.'));
assert.ok(reviewerCommands.length > 0, 'No codeReviewer.* commands found');
assert.ok(reviewerCommands.includes('codeReviewer.review'));
assert.ok(reviewerCommands.includes('codeReviewer.openPanel'));
});
});
+11 -17
View File
@@ -16,12 +16,8 @@
input.value = '';
}
function toggleRule(ruleId) {
vscode.postMessage({ type: 'toggleRule', ruleId: ruleId });
}
function deleteRule(ruleId) {
vscode.postMessage({ type: 'deleteRule', ruleId: ruleId });
function deleteFile(fileName) {
vscode.postMessage({ type: 'deleteFile', fileName: fileName });
}
function escapeHtml(text) {
@@ -113,23 +109,22 @@
var ruleList = document.getElementById('ruleList');
var countBadge = document.getElementById('ruleCountBadge');
if (msg.rules && msg.rules.length > 0) {
countBadge.textContent = msg.rules.length + ' ';
if (msg.ruleFiles && msg.ruleFiles.length > 0) {
countBadge.textContent = msg.ruleFiles.length + ' 个文件';
countBadge.className = 'badge badge-configured';
ruleList.innerHTML = msg.rules.map(function (r) {
ruleList.innerHTML = msg.ruleFiles.map(function (f) {
return '<div class="rule-item">' +
'<label class="switch"><input type="checkbox" onclick="toggleRule(\'' + r.id + '\')" ' + (r.severity !== 'info' ? 'checked' : '') + '><div class="switch-track"></div><div class="switch-thumb"></div></label>' +
'<span class="rule-name">' + escapeHtml(r.id) + '</span>' +
'<button class="rule-del" onclick="deleteRule(\'' + r.id + '\')">&times;</button></div>';
'<span class="rule-name">' + escapeHtml(f) + '</span>' +
'<button class="rule-del" onclick="deleteFile(\'' + escapeHtml(f) + '\')">&times;</button></div>';
}).join('');
} else {
countBadge.textContent = '0 ';
countBadge.textContent = '0 个文件';
countBadge.className = 'badge badge-unconfigured';
ruleList.innerHTML = '<div style="font-size:12px;color:#484f58;padding:8px 0;text-align:center;">暂无规则</div>';
ruleList.innerHTML = '<div style="font-size:12px;color:#484f58;padding:8px 0;text-align:center;">暂无规则文件</div>';
}
var step1Done = c.provider && c.model && c.apiKeyConfigured && c.baseUrlConfigured;
var step2Done = msg.rules && msg.rules.length > 0;
var step2Done = msg.ruleFiles && msg.ruleFiles.length > 0;
var step3Done = msg.connectionTested && msg.connectionSuccess;
var steps = [step1Done, step2Done, step3Done];
for (var i = 0; i < steps.length; i++) {
@@ -164,6 +159,5 @@
window.postMsg = postMsg;
window.addRule = addRule;
window.toggleRule = toggleRule;
window.deleteRule = deleteRule;
window.deleteFile = deleteFile;
})();
+81 -67
View File
@@ -4,8 +4,7 @@ import * as fs from 'fs';
import { getAIProvider, getAIModel, getAIOutputLanguage, getAIConfig } from '../config/ai';
import { getApiKey, setApiKey } from '../config/secret';
import { createProvider, getAllProviderMeta, getProviderModels } from '../ai/factory';
import { loadActiveRules } from '../rules/yaml-parser';
import type { CustomRule } from '../types';
import { listRuleFiles } from '../rules/yaml-parser';
const languageLabels: Record<string, string> = {
'zh-CN': '中文(简体)',
@@ -35,7 +34,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
const config = getAIConfig();
const providers = getAllProviderMeta();
const scriptUri = webviewView.webview.asWebviewUri(
vscode.Uri.joinPath(this.context.extensionUri, 'out', 'views', 'setupView.js')
vscode.Uri.joinPath(this.context.extensionUri, 'out', 'webview', 'setupView.js')
);
const aiConfig = getAIConfig();
webviewView.webview.html = this.getHtml(providers, aiConfig, scriptUri);
@@ -79,12 +78,8 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
case 'saveAndTest':
await this.testConnection();
break;
case 'toggleRule':
await this.toggleRule(msg.ruleId);
await this.pushConfig();
break;
case 'deleteRule':
await this.deleteRule(msg.ruleId);
case 'deleteFile':
await this.deleteFile(msg.fileName);
await this.pushConfig();
break;
case 'addRule':
@@ -105,13 +100,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
const config = getAIConfig();
const apiKeyConfigured = await isApiKeyConfigured(this.context);
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
const rules = loadActiveRules(workspaceRoot);
const ruleItems = rules.map(r => ({
id: r.id,
severity: r.severity,
description: r.description,
}));
const ruleFiles = listRuleFiles(workspaceRoot);
const baseUrlConfigured = isBaseUrlConfigured();
@@ -127,7 +116,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
apiKeyConfigured,
},
providers: getAllProviderMeta(),
rules: ruleItems,
ruleFiles,
connectionTested: this.connectionTested,
connectionSuccess: this.connectionSuccess,
});
@@ -152,6 +141,7 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
await provider.chat('回复 ok', 'ping', {
model: config.model,
temperature: 0,
maxTokens: 1024,
timeoutMs: 15000,
});
this.connectionTested = true;
@@ -167,47 +157,13 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
await this.pushConfig();
}
private async toggleRule(ruleId: string): Promise<void> {
private async deleteFile(fileName: string): Promise<void> {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) {return;}
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
if (!fs.existsSync(configPath)) {return;}
let content = fs.readFileSync(configPath, 'utf-8');
const pattern = new RegExp(`^(\\s*${ruleId}\\s*:\\s*\\n\\s*enabled\\s*:\\s*)(true|false)`, 'm');
if (pattern.test(content)) {
const match = pattern.exec(content);
if (match) {
const newValue = match[2] === 'true' ? 'false' : 'true';
content = content.replace(pattern, `$1${newValue}`);
}
} else {
content += `\n ${ruleId}:\n enabled: false\n`;
}
fs.writeFileSync(configPath, content, 'utf-8');
}
private async deleteRule(ruleId: string): Promise<void> {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) {return;}
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
if (!fs.existsSync(configPath)) {return;}
let content = fs.readFileSync(configPath, 'utf-8');
const pattern = new RegExp(`^\\s*${ruleId}\\s*:\\n(?:\\s+.*\\n)*`, 'm');
content = content.replace(pattern, '');
fs.writeFileSync(configPath, content, 'utf-8');
const userRulesPath = path.join(workspaceRoot, '.code-review', 'rules', 'user-rules.yaml');
if (fs.existsSync(userRulesPath)) {
let rulesContent = fs.readFileSync(userRulesPath, 'utf-8');
const rulePattern = new RegExp(`(?:^|\\n)- id: ${ruleId}\\n(?: .*\\n)*`, '');
rulesContent = rulesContent.replace(rulePattern, '');
fs.writeFileSync(userRulesPath, rulesContent, 'utf-8');
const filePath = path.join(workspaceRoot, '.code-review', 'rules', fileName);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
}
@@ -217,27 +173,85 @@ export class SetupViewProvider implements vscode.WebviewViewProvider {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) {return;}
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
if (!fs.existsSync(configPath)) {
fs.writeFileSync(configPath, 'rules:\n', 'utf-8');
const result = await vscode.window.showOpenDialog({
canSelectMany: false,
openLabel: '选择 Markdown 规则文件',
filters: { 'Markdown': ['md'] },
});
if (!result || result.length === 0) {return;}
const mdPath = result[0].fsPath;
const mdContent = fs.readFileSync(mdPath, 'utf-8');
if (!mdContent.trim()) {
vscode.window.showErrorMessage('所选文件为空');
return;
}
let content = fs.readFileSync(configPath, 'utf-8');
if (!content.includes('rules:')) {
content += '\nrules:\n';
const apiKey = await getApiKey(this.context);
if (!apiKey) {
vscode.window.showErrorMessage('请先在设置面板中配置 API Key');
return;
}
content += ` ${name}:\n enabled: true\n`;
fs.writeFileSync(configPath, content, 'utf-8');
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
if (!fs.existsSync(rulesDir)) {
fs.mkdirSync(rulesDir, { recursive: true });
}
const userRulesPath = path.join(rulesDir, 'user-rules.yaml');
const ruleEntry = `\n- id: ${name}\n severity: warning\n description: 请编辑规则描述\n message: 违反规则,请修改\n`;
fs.appendFileSync(userRulesPath, ruleEntry, 'utf-8');
const yamlFileName = name.endsWith('.yaml') ? name : `${name}.yaml`;
const yamlPath = path.join(rulesDir, yamlFileName);
if (fs.existsSync(yamlPath)) {
vscode.window.showErrorMessage(`文件 ${yamlFileName} 已存在`);
return;
}
const config = getAIConfig();
const provider = createProvider(config.provider, apiKey, config.baseUrl);
const systemPrompt = `你是一个代码审查规则转换器。将用户提供的自然语言规则描述,转换为结构化的 YAML 格式,用于代码审查工具。
每条规则需要包含以下字段:
- id: 规则唯一标识(kebab-case 英文)
- severity: 严重级别(error / warning / info
- description: 规则简短描述(中文)
- message: 违反时的提示消息(中文)
- languages: 适用语言数组(可选,如 [javascript, typescript]
输出格式示例:
- id: no-console-log
severity: warning
description: 禁止使用 console.log
message: 请使用 logger 工具替代 console.log
languages: [javascript, typescript]
仅输出 YAML,不要额外说明。`;
let yamlOutput: string;
try {
yamlOutput = await provider.chat(systemPrompt, mdContent, {
model: config.model,
temperature: 0.1,
maxTokens: 4096,
timeoutMs: 30000,
});
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(`AI 生成规则失败: ${msg}`);
return;
}
const cleaned = yamlOutput
.replace(/```(yaml|yml)?\s*/gi, '')
.replace(/```\s*$/gm, '')
.trim();
if (!cleaned) {
vscode.window.showErrorMessage('AI 返回内容为空');
return;
}
fs.writeFileSync(yamlPath, cleaned, 'utf-8');
}
private async resetConfig(): Promise<void> {