docs: upload code files and config
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# 各规则文件的启用状态
|
||||
enabled:
|
||||
- security-rules.yaml
|
||||
- coding-conventions.yaml
|
||||
|
||||
# 各规则的单独启用/禁用(覆盖文件级设置)
|
||||
rules:
|
||||
no-magic-numbers:
|
||||
enabled: false
|
||||
@@ -0,0 +1,23 @@
|
||||
- id: no-console-log
|
||||
severity: warning
|
||||
description: 生产代码不应保留 console.log 调试语句
|
||||
message: 请使用日志框架替代 console.log
|
||||
languages: [javascript, typescript]
|
||||
|
||||
- id: naming-convention-camel
|
||||
severity: info
|
||||
description: JavaScript/TypeScript 变量和方法使用 camelCase 命名
|
||||
message: 变量名应使用 camelCase(例如 userName 而非 user_name)
|
||||
languages: [javascript, typescript]
|
||||
|
||||
- id: naming-convention-pascal
|
||||
severity: info
|
||||
description: Java 类名使用 PascalCase
|
||||
message: 类名应使用 PascalCase(例如 UserService 而非 userService)
|
||||
languages: [java]
|
||||
|
||||
- id: no-magic-numbers
|
||||
severity: info
|
||||
description: 禁止在代码中使用未命名的魔术数字
|
||||
message: 请将魔法数字提取为命名常量
|
||||
languages: [java, javascript, typescript]
|
||||
@@ -0,0 +1,17 @@
|
||||
- id: no-hardcoded-secret
|
||||
severity: error
|
||||
description: 禁止在代码中硬编码 API Key、密码等敏感信息
|
||||
message: 检测到硬编码密钥,请使用环境变量或密钥管理工具
|
||||
languages: [java, javascript, typescript]
|
||||
|
||||
- id: no-sql-injection
|
||||
severity: error
|
||||
description: 禁止使用字符串拼接的方式构造 SQL 语句
|
||||
message: 使用参数化查询(PreparedStatement)替代字符串拼接
|
||||
languages: [java]
|
||||
|
||||
- id: no-command-injection
|
||||
severity: error
|
||||
description: 禁止直接拼接用户输入作为系统命令执行
|
||||
message: 使用安全的 API 替代 Runtime.exec(),避免命令注入
|
||||
languages: [java]
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: stage-1-propose
|
||||
description: ① 用户提出阶段 — 等待用户一句话描述任务
|
||||
---
|
||||
## 行为
|
||||
- 不做任何操作
|
||||
- 等待用户输入
|
||||
- 确认收到任务后,进入 Stage ②
|
||||
|
||||
## 退出条件
|
||||
- 用户已给出明确的一句任务描述
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: stage-2-clarify
|
||||
description: ② 需求澄清阶段 — 沿设计树追问直到所有分支达成共识
|
||||
---
|
||||
## 行为规则
|
||||
1. 先从代码库中探索,代码库能回答的绝不问人
|
||||
2. 沿设计树的每个分支逐步追问
|
||||
3. 一次只问一个问题,等用户反馈后再继续
|
||||
4. 主干问题给出推荐答案,分支问题可开放式
|
||||
5. 逐层解决决策间的依赖关系
|
||||
6. 直到每个分支都达成共识
|
||||
|
||||
## 禁止
|
||||
- 一次性问多个问题
|
||||
- 跳过依赖关系
|
||||
|
||||
## 退出条件
|
||||
- 设计树所有分支已走通
|
||||
- 用户确认共识达成
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: stage-3-design
|
||||
description: ③ 方案设计阶段 — 输出完整技术方案
|
||||
---
|
||||
## 行为
|
||||
1. 基于 Stage ② 达成的共识,输出完整方案
|
||||
2. 方案必须包含:
|
||||
- 架构概览(模块划分、数据流)
|
||||
- 文件变更清单(新建/修改/删除)
|
||||
- 关键接口/API 定义
|
||||
- 涉及的技术选型或依赖说明
|
||||
- 变更的影响范围
|
||||
3. 方案具体到能直接进入编码
|
||||
|
||||
## 退出条件
|
||||
- 方案文档已产出
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: stage-4-approve
|
||||
description: ④ 人类审批阶段 — 等待用户决策方案
|
||||
---
|
||||
## 行为
|
||||
1. 呈现方案摘要(变更了什么、为什么这么改)
|
||||
2. 等待用户决策
|
||||
|
||||
## 用户可能的操作
|
||||
- 通过 ✅ → 进入 Stage ⑤
|
||||
- 否决并提供原因 ❌ → 回退到 Stage ②或③
|
||||
- 提出修改建议 ✏️ → 回退到 Stage ③
|
||||
|
||||
## 退出条件
|
||||
- 用户明确确认"通过"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: stage-5-implement
|
||||
description: ⑤ 编码实现阶段 — 严格按审批通过的方案编码
|
||||
---
|
||||
## 行为
|
||||
1. 严格按方案执行,不自行发挥
|
||||
2. 实现过程中发现方案有遗漏/矛盾:
|
||||
- 暂停并提问,不自作主张
|
||||
3. 遵循项目代码风格
|
||||
4. 不加注释(除非项目风格要求)
|
||||
|
||||
## 退出条件
|
||||
- 所有方案文件已实现
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: stage-6-verify
|
||||
description: ⑥ 审查验证阶段 — 运行检查 + AI 自审查 + 人类验收
|
||||
---
|
||||
## 行为
|
||||
1. 运行 lint / 类型检查 / 编译
|
||||
2. 运行已有测试
|
||||
3. AI 自审查(潜在 bug、边界情况、安全)
|
||||
4. 汇总结果:
|
||||
- 通过 → 完成
|
||||
- 有严重代码问题 → 回 Stage ⑤
|
||||
- 有方案层面问题 → 回 Stage ③
|
||||
|
||||
## 退出条件
|
||||
- 代码通过所有检查
|
||||
- 人类最终验收确认
|
||||
@@ -0,0 +1,87 @@
|
||||
# 开发范式:Stage-Driven Agent Development (SDAD)
|
||||
|
||||
## 阶段流程(必须严格遵守,不可跳过或合并)
|
||||
① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证
|
||||
|
||||
## 强制规则
|
||||
- 阶段顺序不可调换,每个阶段完成后才进入下一阶段
|
||||
- Stage ④ 人类审批是必经门,未通过不得进入编码
|
||||
- 编码过程中发现方案有遗漏/矛盾,先暂停提问,不自作主张
|
||||
|
||||
## 日志规则(自动执行)
|
||||
每次创建或修改代码文件后,在项目根目录的 `_AI_USAGE_LOG.md` 中追加一条记录,必须包含以下字段:
|
||||
- **日期时间**:当前时间
|
||||
- **范式步骤**:当前已完成的阶段累积链(如 `① 用户提出 → ② 需求澄清`),按实际执行顺序
|
||||
- **修改摘要**:简要描述改了什么
|
||||
- **中间产物**:AI 交互过程中被淘汰的草稿或过程稿
|
||||
- **涉及文件**:文件路径列表
|
||||
- **使用模型**:当前使用的模型名
|
||||
|
||||
## 阶段执行指引
|
||||
- Stage ① @用户提出:load skill stage-1-propose
|
||||
- Stage ② @需求澄清:load skill stage-2-clarify
|
||||
- Stage ③ @方案设计:load skill stage-3-design
|
||||
- Stage ④ @人类审批:load skill stage-4-approve
|
||||
- Stage ⑤ @编码实现:load skill stage-5-implement
|
||||
- Stage ⑥ @审查验证:load skill stage-6-verify
|
||||
|
||||
---
|
||||
|
||||
# 项目:vscode-code-reviewer
|
||||
|
||||
VSCode 代码审查与规范检查一体化插件。
|
||||
|
||||
## 开发者命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `npm run compile` | TypeScript 编译(tsc -p ./) |
|
||||
| `npm run watch` | tsc watch 模式 |
|
||||
| `npm run lint` | ESLint 检查 `src/` |
|
||||
| `npm test` | 编译 → lint → 运行测试 |
|
||||
| F5 (VSCode) | 启动 Extension Dev Host |
|
||||
|
||||
测试运行器:`@vscode/test-cli`,配置在 `.vscode-test.mjs`,测试文件匹配 `out/test/**/*.test.js`。
|
||||
|
||||
验证顺序:`lint → compile → test`
|
||||
|
||||
## 架构
|
||||
|
||||
三层架构,见 `docs/superpowers/specs/2026-07-10-code-reviewer-design.md`:
|
||||
|
||||
```
|
||||
UI 层 — TreeView 面板 / Inline Diagnostic / Code Action
|
||||
核心层 — Linter 管理器 + AI 审查引擎(均实现 Analyzer 接口)
|
||||
基础层 — 配置管理 / 规则管理 / 报告导出
|
||||
```
|
||||
|
||||
所有 linter 和 AI 审查器统一实现 `Analyzer` 接口(定义在 `src/analyzers/analyzer.ts`)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── extension.ts # 入口:activate/deactivate
|
||||
├── activation/ # 注册命令、视图、CodeAction
|
||||
├── analyzers/ # Analyzer 接口 + 各 linter/AI 实现
|
||||
├── manager/linterManager.ts # Linter 管理器
|
||||
├── views/ # TreeView 提供者
|
||||
├── services/ # AI API、配置服务
|
||||
├── utils/ # 工具函数
|
||||
└── types.ts # 公共类型
|
||||
```
|
||||
|
||||
命令 ID 前缀统一为 `codeReviewer.`(如 `codeReviewer.analyzeFile`)。
|
||||
|
||||
## 代码规范
|
||||
|
||||
- 变量/函数:camelCase,类:PascalCase
|
||||
- 导入风格:ESM(import/export)
|
||||
- 不加注释
|
||||
- 异步用 async/await
|
||||
- ESLint 配置:`eslint.config.mjs`(typescript-eslint parser)
|
||||
|
||||
## 设计文档
|
||||
|
||||
设计 spec 存放路径:`docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`
|
||||
编码须严格遵循已批准的 spec,不得自作主张。
|
||||
@@ -0,0 +1,19 @@
|
||||
# AI Usage Log
|
||||
|
||||
| 日期时间 | 范式步骤 | 修改摘要 | 中间产物 | 涉及文件 | 使用模型 |
|
||||
|---------|---------|---------|---------|---------|---------|
|
||||
| 2026-07-10 18:10 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 编写代码审查插件设计文档(design spec) | design spec | docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 18:35 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 重构 AGENTS.md,补充项目开发命令、架构、代码规范等工程上下文 | 无 | AGENTS.md | deepseek-v4-flash |
|
||||
| 2026-07-10 18:50 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 新增适配器层设计(6 小节),更新架构为四层,简化编排器,同步文件结构 | 无 | docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 19:05 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 新增自定义规则系统、设计文档 §12 章节、模板文件 | 无 | docs/superpowers/specs/2026-07-10-code-reviewer-design.md .code-review/rules/security-rules.yaml .code-review/rules/coding-conventions.yaml .code-review/config.yaml | deepseek-v4-flash |
|
||||
| 2026-07-10 19:18 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 完善 AI 审查引擎设计(Provider 策略模式、两并行请求、Prompt 模板、错误处理),同步命令/配置 | 无 | docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 19:31 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 新增自动修复模块设计(动态上下文、两阶段匹配、批量预览、快照撤销) | 无 | docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 19:39 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 新增审查面板设计(Tab 布局、交互设计 UI、消息通信 PanelMessage) | 无 | docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 22:42 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 新增审查面板 HTML 预览 + UI 说明文档 | 无 | docs/superpowers/specs/review-panel-preview.html docs/superpowers/specs/review-panel-ui.md | deepseek-v4-flash |
|
||||
| 2026-07-10 ~22:45 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 新增 §13 设置面板节(设计决策、配置模块、面板布局、规则文件管理) | 无 | docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 22:51 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 新增设置面板 HTML 预览 + UI 说明文档 | 无 | docs/superpowers/specs/setup-panel-preview.html docs/superpowers/specs/setup-panel-ui.md | deepseek-v4-flash |
|
||||
| 2026-07-10 23:03 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 重建设置面板 UI(快速开始三步骤、引擎标签、卡片表单、紫色主按钮、按钮状态机),更新 §13 设计文档及 HTML/UI 说明 | 无 | docs/superpowers/specs/setup-panel-preview.html docs/superpowers/specs/setup-panel-ui.md docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 23:04 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 设置面板新增输出语言下拉框(中文(简体)/ English / 日本語),同步更新 HTML/UI 文档/设计文档 | 无 | docs/superpowers/specs/setup-panel-preview.html docs/superpowers/specs/setup-panel-ui.md docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 23:07 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 补充遗漏内容:配置项新增 ai.provider/ai.model/ai.endpoint 和 7 种语言的 linters enum,补全菜单集成和视图容器 JSON;新增结果合并器 MergedReport 接口和 Markdown 导出模板;新增 esbuild 构建脚本和开发流程;新增测试文件结构和四类测试场景;新增运行时/开发依赖清单和外部工具版本;新增 31 个源文件清单和 8 个核心函数签名 | 无 | docs/superpowers/specs/2026-07-10-code-reviewer-design.md | deepseek-v4-flash |
|
||||
| 2026-07-10 23:30 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 | 拆分为6 Phase 分步实施计划 | implementation plan | docs/superpowers/specs/2026-07-10-implementation-plan.md | deepseek-v4-pro |
|
||||
| 2026-07-10 23:33 | ③ 方案设计 | 将实施计划拆分为 20 个独立步骤文件 | step-by-step files | docs/superpowers/specs/implementation-steps/*.md | deepseek-v4-pro |
|
||||
@@ -6,75 +6,179 @@ VSCode 代码审查与规范检查一体化工具。集成多语言静态分析
|
||||
|
||||
### 核心能力
|
||||
|
||||
- 多语言代码静态分析(ESLint、Ruff、Clippy 等)
|
||||
- AI 辅助深度审查与问题解释
|
||||
- 自定义规则检查
|
||||
- 自动修复建议与批量修复
|
||||
- 可视化审查报告面板
|
||||
- 多语言代码静态分析(ESLint、PMD、Stylelint、sql-lint 等)
|
||||
- AI 辅助深度审查与问题解释/翻译
|
||||
- 自定义规则检查(YAML 定义 + AI 语义评估)
|
||||
- 自动修复建议与批量修复(快照撤销)
|
||||
- Webview 审查报告面板
|
||||
|
||||
---
|
||||
|
||||
## 2. 架构总览
|
||||
|
||||
插件分为三层:
|
||||
插件分为四层:
|
||||
|
||||
```
|
||||
UI 层 — TreeView 面板 / Inline Diagnostic / Code Action
|
||||
核心层 — Linter 管理器 + AI 审查引擎(均实现 Analyzer 接口)
|
||||
UI 层 — Webview 审查面板 / Inline Diagnostic / Code Action
|
||||
核心层 — Orchestrator(编排器)+ AI 审查引擎
|
||||
适配层 — Linter 适配器(统一 LinterAdapter 接口)
|
||||
基础层 — 配置管理 / 规则管理 / 报告导出
|
||||
```
|
||||
|
||||
所有 linter 和 AI 审查器统一实现 `Analyzer` 接口,结果聚合后通过 VSCode `DiagnosticCollection` 展示。
|
||||
所有 linter 统一实现 `LinterAdapter` 接口,通过 Orchestrator 调度,结果聚合后通过 Webview 面板展示。
|
||||
|
||||
---
|
||||
|
||||
## 3. Analyzer 接口
|
||||
## 3. 适配器层
|
||||
|
||||
### 3.1 设计决策
|
||||
|
||||
| 决策项 | 结论 | 说明 |
|
||||
|--------|------|------|
|
||||
| 支持语言 | Java / JS/TS / CSS / SQL / JSP | 不支持 Python、Go |
|
||||
| 注册方式 | 硬编码(方案 A) | 适配器数量少,无需过度设计 |
|
||||
| 审查粒度 | 单文件 | `check()` 接收单个 `TextDocument` |
|
||||
| 目标平台 | Windows | PMD classpath 分隔符使用 `;` |
|
||||
|
||||
### 3.2 统一接口
|
||||
|
||||
```typescript
|
||||
interface AnalyzerResult {
|
||||
file: string;
|
||||
line: number;
|
||||
column: number;
|
||||
severity: 'error' | 'warning' | 'info' | 'hint';
|
||||
interface LinterDiagnostic {
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
ruleId: string; // 格式: "linter名:规则ID"
|
||||
message: string;
|
||||
ruleId: string;
|
||||
source: string;
|
||||
fix?: Fix;
|
||||
aiExplanation?: string;
|
||||
range: vscode.Range;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
interface Analyzer {
|
||||
readonly name: string;
|
||||
readonly language: string[];
|
||||
analyze(document: vscode.TextDocument): Promise<AnalyzerResult[]>;
|
||||
fix?(result: AnalyzerResult): vscode.TextEdit[];
|
||||
interface AdapterResult {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
status: 'ok' | 'tool-unavailable' | 'execution-failed';
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
interface LinterAdapter {
|
||||
id: string;
|
||||
supportedLanguages: string[];
|
||||
check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult>;
|
||||
isAvailable(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 内置 Analyzer
|
||||
**错误状态说明**:
|
||||
|
||||
| Analyzer | 语言 | 调用方式 |
|
||||
|----------|------|---------|
|
||||
| EslintAnalyzer | JS/TS/JSX/TSX | eslint CLI --format json |
|
||||
| RuffAnalyzer | Python | ruff check --output-format json |
|
||||
| ClippyAnalyzer | Rust | cargo clippy --message-format json |
|
||||
| AiAnalyzer | 通用 | LLM API |
|
||||
| 状态 | 含义 | 用户感知 |
|
||||
|------|------|---------|
|
||||
| `ok` | 检查成功 | 正常显示结果 |
|
||||
| `tool-unavailable` | 工具未安装/未找到 | 提示用户安装对应工具 |
|
||||
| `execution-failed` | 工具已安装但执行出错 | 显示错误信息,引导排查 |
|
||||
|
||||
### 3.3 适配器清单
|
||||
|
||||
| 适配器 | 语言 | 实现方式 | 特殊处理 |
|
||||
|--------|------|----------|----------|
|
||||
| ESLint | JS/TS | eslint npm 包 `lintText()` | 直接接收代码文本,支持虚拟文档 |
|
||||
| PMD | Java | Java 子进程调用 | 支持 stdin 传入代码,支持虚拟文档 |
|
||||
| Stylelint | CSS | stylelint npm 包 `lint({ code })` | 直接接收代码文本,支持虚拟文档 |
|
||||
| sql-lint | SQL | sqlfluff CLI 调用 | 支持 SQL 和 PL/SQL,方言映射 |
|
||||
| JSP | JSP | 组合适配器(PMD + ESLint + Stylelint) | 提取内嵌代码块后分发检查 |
|
||||
|
||||
### 3.4 PMD 适配器特殊设计
|
||||
|
||||
PMD 是 Java 工具,需要特殊处理:
|
||||
|
||||
1. **JAR 文件管理**:插件自带 `jars/pmd/` 目录存放 PMD 依赖
|
||||
2. **规则集配置**:支持自定义规则集 XML 文件
|
||||
3. **Java 包装器**:`PmdRunner.java` 简化调用,输出 JSON 格式
|
||||
4. **虚拟文档支持**:通过 stdin 传入代码,无需真实文件
|
||||
|
||||
**PmdRunner.java 核心逻辑**(`jars/pmd/PmdRunner.java`):
|
||||
|
||||
```
|
||||
参数: filePath (传 "-" 表示从 stdin 读取), ruleset
|
||||
→ 构建 PMDConfiguration
|
||||
→ 配置 JSON 渲染器
|
||||
→ 若 filePath 为 "-",从 stdin 读代码 → 写入临时文件
|
||||
→ 执行 PMD 分析
|
||||
→ 输出 JSON 到 stdout
|
||||
→ 清理临时文件
|
||||
```
|
||||
|
||||
**PmdAdapter.check() 虚拟文档处理**:
|
||||
|
||||
```
|
||||
if 虚拟文档 (uri.scheme === 'untitled')
|
||||
→ java -cp "classpath;dist" PmdRunner "-" ruleset
|
||||
→ stdin 传入 document.getText()
|
||||
else
|
||||
→ java -cp "classpath;dist" PmdRunner document.fileName ruleset
|
||||
```
|
||||
|
||||
### 3.5 JSP 适配器设计
|
||||
|
||||
JSP 适配器是**组合适配器**,自身不做检查,而是将 JSP 文件拆分后交给其他适配器:
|
||||
|
||||
```
|
||||
JSP 文件输入
|
||||
│
|
||||
├─ 1. 调用 PMD 检查 JSP 规范
|
||||
│ → 使用 pmd-jsp-ruleset.xml
|
||||
│
|
||||
└─ 2. jsp-extractor 提取内嵌代码块
|
||||
├─ <script> → JavaScript → ESLint
|
||||
├─ <style> → CSS → Stylelint
|
||||
└─ <% %> scriptlet → Java → PMD
|
||||
│
|
||||
└─ 行号偏移修正 → 合并结果
|
||||
```
|
||||
|
||||
**提取器(jsp-extractor.ts)**:
|
||||
|
||||
| 代码块类型 | 正则匹配 | 目标适配器 |
|
||||
|-----------|---------|-----------|
|
||||
| `<script>` 标签 | `/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi` | ESLint |
|
||||
| `<style>` 标签 | `/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi` | Stylelint |
|
||||
| `<% %>` scriptlet | `/<%=?([\s\S]*?)%>/g` | PMD |
|
||||
|
||||
**JspAdapter.check() 流程**:
|
||||
|
||||
```
|
||||
1. 调用 PmdAdapter.check(document) → JSP 规范检查
|
||||
2. extractJspSections(document.getText()) → 提取内嵌代码块
|
||||
3. 对每个 section:
|
||||
a. 按 language 选择对应适配器
|
||||
b. 创建虚拟文档 (vscode.workspace.openTextDocument)
|
||||
c. 调用 adapter.check(virtualDoc)
|
||||
d. 修正行号偏移 (section.lineOffset)
|
||||
4. 合并所有结果
|
||||
```
|
||||
|
||||
### 3.6 适配器注册
|
||||
|
||||
采用**硬编码注册**,在 Orchestrator 中维护适配器列表:
|
||||
|
||||
```typescript
|
||||
// orchestrator.ts
|
||||
function getAdapters(): LinterAdapter[] {
|
||||
return [
|
||||
new ESLintAdapter(),
|
||||
new PmdAdapter(),
|
||||
new StylelintAdapter(),
|
||||
new SqlLintAdapter(),
|
||||
new JspAdapter(),
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
通过 `linters.<language>` 配置项控制每种语言使用的 linter(空字符串表示禁用)。
|
||||
|
||||
---
|
||||
|
||||
## 4. Linter 管理器
|
||||
## 4. Orchestrator(编排器)
|
||||
|
||||
### 自动检测
|
||||
### 注册方式
|
||||
|
||||
插件扫描工作区根目录,根据配置文件自动识别启用的 linter:
|
||||
|
||||
| Linter | 检测标志 |
|
||||
|--------|---------|
|
||||
| ESLint | `.eslintrc*` 或 `package.json` 中的 `eslintConfig` |
|
||||
| Ruff | `ruff.toml` 或 `pyproject.toml` 中的 `[tool.ruff]` |
|
||||
| Clippy | `Cargo.toml` 含 clippy 依赖 |
|
||||
|
||||
未检测到配置时可降级为默认配置运行。
|
||||
采用**硬编码注册**,适配器列表在 `orchestrator.ts` 中静态定义(见 §3.6)。
|
||||
|
||||
### 执行策略
|
||||
|
||||
@@ -82,36 +186,215 @@ interface Analyzer {
|
||||
- **手动触发**:命令面板、右键菜单、文件树右键
|
||||
- **进度反馈**:运行时显示 `withProgress`
|
||||
|
||||
### 调度逻辑
|
||||
|
||||
```
|
||||
1. 获取当前文档语言 ID
|
||||
2. 查询 linters.<language> 配置 → 确定使用的适配器
|
||||
3. 调用适配器 check(document, workingDir)
|
||||
4. 收集结果,区分 status
|
||||
5. 返回聚合后的 diagnostics 列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. AI 审查引擎
|
||||
|
||||
`AiAnalyzer` 作为特殊 Analyzer 注册,通过 LLM API 审查代码。
|
||||
### 5.1 设计决策
|
||||
|
||||
### 配置
|
||||
| 决策项 | 结论 | 说明 |
|
||||
|--------|------|------|
|
||||
| 任务拆分 | **两并行请求** | 请求 A(自定义规则评估)+ 请求 B(翻译 + 深度审查) |
|
||||
| 代码长度限制 | **暂不限制** | 后续可添加大文件截断 |
|
||||
| 模型支持 | **Provider 层抽象** | 支持 DeepSeek / OpenAI,统一接口 |
|
||||
| 成本控制 | **暂不考虑** | 不做 Token 上限、调用次数限制 |
|
||||
| 修复复用 | **复用 Provider** | 单条修复和批量修复共用 Provider 层 |
|
||||
| 错误感知 | **区分提示** | 不同错误类型显示不同提示信息 |
|
||||
| Provider 与面板共用 | **ai.provider 通用** | 面板下拉框直接读写 `ai.provider` |
|
||||
|
||||
### 5.2 Provider 层架构
|
||||
|
||||
采用**策略模式**,将不同模型的 API 调用封装为独立 Provider,上层只依赖统一接口。
|
||||
|
||||
```
|
||||
src/ai/
|
||||
├── providers/
|
||||
│ ├── base.ts # Provider 接口/抽象基类
|
||||
│ ├── deepseek.ts # DeepSeek 实现
|
||||
│ └── openai.ts # OpenAI 实现
|
||||
├── factory.ts # Provider 工厂
|
||||
├── engine.ts # 审查引擎(业务逻辑)
|
||||
└── schema.ts # 响应结构定义
|
||||
```
|
||||
|
||||
**Provider 接口**:
|
||||
|
||||
```typescript
|
||||
interface AiReviewConfig {
|
||||
provider: 'openai' | 'custom';
|
||||
apiKey: string;
|
||||
// providers/base.ts
|
||||
export interface ChatOptions {
|
||||
model: string;
|
||||
maxTokens: number;
|
||||
customEndpoint?: string;
|
||||
temperature: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export abstract class AIProvider {
|
||||
abstract id: string;
|
||||
abstract name: string;
|
||||
|
||||
constructor(
|
||||
protected apiKey: string,
|
||||
protected endpoint: string
|
||||
) {}
|
||||
|
||||
abstract chat(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: ChatOptions
|
||||
): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
### 工作流
|
||||
**Provider 工厂**:
|
||||
|
||||
1. 用户触发 AI 审查(整个文件或选中代码)
|
||||
2. 收集代码 + 上下文 → 构建 Prompt
|
||||
3. 调用 LLM API → 解析 JSON 响应
|
||||
4. 结果转为 `AnalyzerResult[]` → 注入 Diagnostic
|
||||
```typescript
|
||||
// factory.ts
|
||||
const registry: Record<string, new (apiKey: string, endpoint: string) => AIProvider> = {
|
||||
deepseek: DeepSeekProvider,
|
||||
openai: OpenAIProvider,
|
||||
};
|
||||
|
||||
### Prompt 策略
|
||||
export function createProvider(providerId: string, apiKey: string, endpoint: string): AIProvider {
|
||||
const Cls = registry[providerId];
|
||||
if (!Cls) throw new Error(`Unknown provider: ${providerId}`);
|
||||
return new Cls(apiKey, endpoint);
|
||||
}
|
||||
```
|
||||
|
||||
- System Prompt:审查专家角色 + JSON 格式约束
|
||||
- 支持传入自定义规则列表
|
||||
- 响应强制 JSON 格式
|
||||
### 5.3 两并行请求方案
|
||||
|
||||
```
|
||||
请求 A(规则审查) 请求 B(翻译 + 深度审查)
|
||||
┌──────────────────┐ ┌─────────────────────────┐
|
||||
│ 自定义规则评估 │ │ 静态分析结果翻译 │
|
||||
│ (需要完整代码) │ │ (需静态分析结果) │
|
||||
└──────────────────┘ │ AI 深度审查 │
|
||||
│ (需代码 + 静态分析) │
|
||||
└─────────────────────────┘
|
||||
```
|
||||
|
||||
**请求分配理由**:
|
||||
|
||||
| 任务 | 请求 | 理由 |
|
||||
|------|------|------|
|
||||
| 自定义规则评估 | A | 需完整代码,与深度审查关注点不同 |
|
||||
| 静态分析翻译 | B | 与深度审查共享静态分析上下文,放一起省 Token |
|
||||
| AI 深度审查 | B | 需代码 + 静态分析结果,与翻译同上下文 |
|
||||
|
||||
**执行流程**:
|
||||
|
||||
```typescript
|
||||
async function runAIReview(code: string, staticDiagnostics: LinterDiagnostic[], customRules: CustomRule[]) {
|
||||
const [resultA, resultB] = await Promise.allSettled([
|
||||
callCustomRuleReview(code, customRules), // 请求 A
|
||||
callTranslateAndDeepReview(code, staticDiagnostics), // 请求 B
|
||||
]);
|
||||
|
||||
return {
|
||||
customRuleResults: resultA.status === 'fulfilled' ? resultA.value : [],
|
||||
translatedDiagnostics: resultB.status === 'fulfilled' ? resultB.value.translated : [],
|
||||
findings: resultB.status === 'fulfilled' ? resultB.value.findings : [],
|
||||
degraded: resultA.status === 'rejected' || resultB.status === 'rejected',
|
||||
error: collectErrors(resultA, resultB),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Prompt 设计
|
||||
|
||||
**请求 A — 自定义规则评估**:
|
||||
|
||||
```
|
||||
System: 你是代码规则审查员,只评估以下自定义规则是否被违反。
|
||||
理解语义而非文本匹配。
|
||||
仅输出 JSON,格式:{ customRuleResults: [{ ruleId, line, severity, message }] }
|
||||
|
||||
User: ## 自定义规则
|
||||
<规则列表>
|
||||
|
||||
## 代码(带行号)
|
||||
<代码内容>
|
||||
```
|
||||
|
||||
**请求 B — 翻译 + 深度审查**:
|
||||
|
||||
```
|
||||
System: 你是资深代码审查专家,完成两个任务:
|
||||
1. 将英文静态分析结果翻译为中文,并补充修复建议
|
||||
2. 深度审查代码,发现静态分析未覆盖的问题
|
||||
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
|
||||
不要重复静态分析已报告的问题
|
||||
输出语言:<language>
|
||||
仅输出 JSON
|
||||
|
||||
User: ## 代码(带行号)
|
||||
<代码内容>
|
||||
|
||||
## 静态分析结果(英文)
|
||||
<诊断列表>
|
||||
```
|
||||
|
||||
### 5.5 AI 响应结构
|
||||
|
||||
```typescript
|
||||
// ai/schema.ts
|
||||
interface AIResponse {
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
customRuleResults: CustomRuleResult[];
|
||||
findings: AIFinding[];
|
||||
}
|
||||
|
||||
interface TranslatedDiagnostic {
|
||||
originalRuleId: string;
|
||||
translatedMessage: string;
|
||||
translatedSuggestion: string;
|
||||
codeDiff?: string;
|
||||
}
|
||||
|
||||
interface CustomRuleResult {
|
||||
ruleId: string; // 格式: "custom:规则id"
|
||||
line: number;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface AIFinding {
|
||||
ruleId: string; // kebab-case,如 no-hardcoded-secret
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: 'bug' | 'performance' | 'security' | 'style' | 'design';
|
||||
title: string;
|
||||
description: string;
|
||||
suggestion: string;
|
||||
codeDiff?: string; // unified diff 格式
|
||||
line: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 5.6 错误处理与用户感知
|
||||
|
||||
| 错误类型 | 用户感知 | 处理方式 |
|
||||
|---------|---------|---------|
|
||||
| 未配置 API Key | 面板显示「请先设置 API Key」+ 设置按钮 | 降级,静态分析结果正常显示 |
|
||||
| API Key 无效 | 面板显示「API Key 无效,请重新设置」+ 设置按钮 | 降级,弹错误提示 |
|
||||
| 网络超时 | 面板显示「AI 请求超时,可重试」+ 重试按钮 | 降级,静态分析结果正常显示 |
|
||||
| 模型服务不可用 | 面板显示具体错误信息 | 降级 |
|
||||
| JSON 解析失败 | 面板显示「AI 响应格式异常」+ 查看原始响应 | 降级,记录原始响应 |
|
||||
| 部分请求失败 | 面板显示「部分 AI 功能不可用」+ 详情 | 部分降级 |
|
||||
|
||||
**降级策略**:
|
||||
|
||||
1. 单请求失败不影响另一个请求(`Promise.allSettled`)
|
||||
2. 所有 AI 功能都失败时,纯静态分析结果仍然展示
|
||||
3. 失败信息在面板顶部以黄色/红色提示条展示
|
||||
|
||||
---
|
||||
|
||||
@@ -119,97 +402,422 @@ interface AiReviewConfig {
|
||||
|
||||
### Commands
|
||||
|
||||
| 命令 | 说明 |
|
||||
|------|------|
|
||||
| `codeReviewer.analyzeFile` | 审查当前文件 |
|
||||
| `codeReviewer.analyzeWorkspace` | 审查整个工作区 |
|
||||
| `codeReviewer.aiReview` | AI 深度审查 |
|
||||
| `codeReviewer.aiExplain` | AI 解释选中问题 |
|
||||
| `codeReviewer.fixAll` | 批量修复 |
|
||||
| `codeReviewer.exportReport` | 导出报告 |
|
||||
| 命令 | 功能 | 快捷键 |
|
||||
|------|------|--------|
|
||||
| `vscode-code-reviewer.review` | 运行完整审查(静态分析 + AI) | Ctrl+Shift+R |
|
||||
| `vscode-code-reviewer.reviewSelection` | 审查选中代码 | — |
|
||||
| `vscode-code-reviewer.openPanel` | 显示审查报告面板 | — |
|
||||
| `vscode-code-reviewer.exportReport` | 导出 Markdown 报告 | — |
|
||||
| `vscode-code-reviewer.addCustomRule` | 添加自定义规则 | — |
|
||||
| `vscode-code-reviewer.fixIssue` | 修复单条问题 | — |
|
||||
| `vscode-code-reviewer.fixAll` | 批量修复 | — |
|
||||
| `vscode-code-reviewer.openSetup` | 打开设置面板(侧边栏) | — |
|
||||
|
||||
### Views
|
||||
|
||||
- `codeReviewProblems` — 审查问题树视图(按文件分组)
|
||||
- `codeReviewSummary` — 审查概况树视图(统计信息 + 导出/批量修复入口)
|
||||
- `codeReviewer.setupView` — 设置面板(侧边栏视图)
|
||||
|
||||
### 菜单集成
|
||||
|
||||
```json
|
||||
{
|
||||
"menus": {
|
||||
"editor/context": [
|
||||
{
|
||||
"command": "vscode-code-reviewer.review",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "vscode-code-reviewer.reviewSelection",
|
||||
"when": "editorHasSelection"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 视图容器
|
||||
|
||||
```json
|
||||
{
|
||||
"viewsContainers": {
|
||||
"activitybar": [
|
||||
{
|
||||
"id": "code-reviewer",
|
||||
"title": "CodeGuard 代码审查",
|
||||
"icon": "images/icon.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"views": {
|
||||
"code-reviewer": [
|
||||
{
|
||||
"type": "tree",
|
||||
"id": "codeReviewer.setupView",
|
||||
"name": "设置"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
| 配置项 | 类型 | 说明 |
|
||||
以下配置项通过 VS Code 原生设置界面(`Ctrl+,`)修改。AI 基础配置、API Key 和自定义规则的启用/禁用则在侧边栏设置面板中管理。
|
||||
|
||||
**AI 配置**:
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `vscode-code-reviewer.ai.provider` | enum | `deepseek` | 模型提供商:`deepseek` / `openai` |
|
||||
| `vscode-code-reviewer.ai.model` | string | `deepseek-chat` | 模型名称 |
|
||||
| `vscode-code-reviewer.ai.endpoint` | string | `https://api.deepseek.com/v1` | API 端点 |
|
||||
| `vscode-code-reviewer.ai.temperature` | number | 0.2 | AI 温度参数(建议 0.1-0.3) |
|
||||
| `vscode-code-reviewer.ai.timeout` | number | 300 | AI 请求超时(秒) |
|
||||
| `vscode-code-reviewer.ai.outputLanguage` | string | `zh-CN` | 输出语言:`zh-CN` / `en` / `ja` |
|
||||
|
||||
**Linter 配置**(每种语言单选,空字符串=禁用):
|
||||
|
||||
| 配置项 | 类型 | 默认值 | enum |
|
||||
|--------|------|--------|------|
|
||||
| `vscode-code-reviewer.linters.javascript` | enum | `eslint` | `""` / `eslint` |
|
||||
| `vscode-code-reviewer.linters.typescript` | enum | `eslint` | `""` / `eslint` |
|
||||
| `vscode-code-reviewer.linters.java` | enum | `pmd` | `""` / `pmd` |
|
||||
| `vscode-code-reviewer.linters.jsp` | enum | `jsp` | `""` / `jsp` |
|
||||
| `vscode-code-reviewer.linters.css` | enum | `stylelint` | `""` / `stylelint` |
|
||||
| `vscode-code-reviewer.linters.sql` | enum | `sql-lint` | `""` / `sql-lint` |
|
||||
| `vscode-code-reviewer.linters.plsql` | enum | `sql-lint` | `""` / `sql-lint` |
|
||||
|
||||
**PMD 配置**:
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `vscode-code-reviewer.pmd.jarPath` | string | `""` | PMD jar 路径(空=使用插件内置) |
|
||||
| `vscode-code-reviewer.pmd.rulesetPath` | string | `""` | Java 规则集 XML 路径(空=使用内置) |
|
||||
| `vscode-code-reviewer.pmd.jspRulesetPath` | string | `""` | JSP 规则集 XML 路径(空=使用内置) |
|
||||
|
||||
**其他**:
|
||||
|
||||
| 配置项 | 类型 | 默认值 | 说明 |
|
||||
|--------|------|--------|------|
|
||||
| `vscode-code-reviewer.sql-lint.configFile` | string | `""` | sqlfluff 配置文件路径 |
|
||||
| `vscode-code-reviewer.fixer.contextLines` | number | 5 | AI 修复时提取的上下文行数 |
|
||||
|
||||
> API Key 不在此配置,通过 VS Code `SecretStorage` 存储,在设置面板中管理。
|
||||
|
||||
---
|
||||
|
||||
## 7. 审查面板(Webview)
|
||||
|
||||
### 7.1 面板功能
|
||||
|
||||
采用 Webview 实现,分 Tab 展示三类审查结果:
|
||||
|
||||
| Tab | 来源 | 内容 |
|
||||
|-----|------|------|
|
||||
| 🔧 静态分析 | Linter 适配器 | ESLint / PMD / Stylelint / sql-lint / JSP 的问题列表 |
|
||||
| 📋 自定义规则 | AI 请求 A | `custom:*` 规则评估结果 |
|
||||
| 🤖 AI 审查 | AI 请求 B | 翻译后的诊断 + 深度审查发现 |
|
||||
|
||||
每条问题支持:点击跳转到代码位置、单条修复、添加忽略注释。
|
||||
|
||||
### 7.2 交互设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📋 代码审查报告 │
|
||||
│ xxx.java · Java · 3.2s │
|
||||
├─────────────────────────────────────┤
|
||||
│ [总计:10] [错误:3] [警告:5] [建议:2] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🔧 静态分析 | 📋 自定义规则 | 🤖 AI │
|
||||
├─────────────────────────────────────┤
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🔴 PMD L23 [修复] [忽略]│ │
|
||||
│ │ 避免重复字符串常量 │ │
|
||||
│ │ NoScriptlets.md │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ 🟡 custom L45 [修复] [忽略]│ │
|
||||
│ │ SQL 拼接风险 │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
├─────────────────────────────────────┤
|
||||
│ [🔄 重新审查] [📄 导出] [⚙️ 设置] │
|
||||
│ [↩ 撤销上次修复] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**降级提示**:当 AI 请求部分/全部失败时,面板顶部显示黄色/红色提示条,静态分析结果正常展示。
|
||||
|
||||
### 7.3 消息通信
|
||||
|
||||
Webview 与扩展通过 `postMessage` 通信:
|
||||
|
||||
```typescript
|
||||
interface PanelMessage {
|
||||
type: 'navigate' | 'rerun' | 'export' | 'settings' | 'fix' | 'fixAll';
|
||||
line?: number;
|
||||
ruleId?: string;
|
||||
source?: 'linter' | 'custom' | 'ai';
|
||||
}
|
||||
|
||||
// 扩展 → Webview:推送审查结果
|
||||
interface PanelUpdate {
|
||||
report: MergedReport;
|
||||
degraded: boolean;
|
||||
errors: string[];
|
||||
hasSnapshot: boolean; // 撤销按钮状态
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 与结果合并器的关系
|
||||
|
||||
```
|
||||
Linter 诊断 ─┐
|
||||
AI 翻译 ─┤
|
||||
自定义规则 ─┤→ Merger.mergeResults() → MergedReport → Webview 渲染
|
||||
AI 深度审查 ─┤
|
||||
执行错误 ─┘
|
||||
|
||||
用户操作 → PanelMessage → extension → 执行对应命令 → 更新面板
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 自动修复模块
|
||||
|
||||
### 8.1 设计决策
|
||||
|
||||
| 决策项 | 结论 | 说明 |
|
||||
|--------|------|------|
|
||||
| `codeReviewer.linters` | object | 启用的 linter 配置 |
|
||||
| `codeReviewer.ai.enabled` | boolean | 启用 AI 审查 |
|
||||
| `codeReviewer.ai.provider` | enum | AI 提供商 |
|
||||
| `codeReviewer.ai.apiKey` | string | API 密钥 |
|
||||
| `codeReviewer.ai.model` | string | 模型名 |
|
||||
| 上下文行数 | **动态调整** | 根据问题类型决定上下文范围,不固定 5 行 |
|
||||
| 修复匹配策略 | **行号 + 代码匹配** | 先按行号匹配原文,失败则在文件中搜索 |
|
||||
| 批量修复 | **预览后应用** | 先展示所有修改,用户确认后才执行 |
|
||||
| 修复后验证 | **不做验证** | 不重新跑 linter 或编译,信任 AI 输出 |
|
||||
| 撤销机制 | **快照 + 撤销按钮** | 修复前保存快照,面板底部提供"撤销上次修复" |
|
||||
| 修复范围限定 | **不限定** | 用户自行判断哪些问题可修复 |
|
||||
| Code Action | **复用 AI Provider** | 单条修复与批量修复共用 §5.2 的 Provider 层 |
|
||||
|
||||
---
|
||||
### 8.2 修复流程
|
||||
|
||||
## 7. 审查报告面板
|
||||
```
|
||||
用户触发修复(单条 / 批量)
|
||||
↓
|
||||
prepareContext() 获取代码上下文(动态行数,见 §8.3)
|
||||
↓
|
||||
generateFix() 调用 AI 生成修复方案(复用 §5.2 Provider)
|
||||
↓
|
||||
matchAndValidate() 匹配验证(行号 → 代码搜索,见 §8.4)
|
||||
↓
|
||||
applyFix() 应用修复到编辑器(单条 / 批量倒序)
|
||||
↓
|
||||
保存快照,更新撤销按钮状态
|
||||
```
|
||||
|
||||
### 问题列表视图(codeReviewProblems)
|
||||
### 8.3 动态上下文策略
|
||||
|
||||
- 按文件分组展示所有问题
|
||||
- 每个问题显示:严重级别图标、规则 ID、消息、行号、来源标记
|
||||
- 点击跳转到对应位置
|
||||
- 右键菜单:AI 解释 / 快速修复 / 忽略规则
|
||||
根据问题分类决定上下文的行数范围:
|
||||
|
||||
### 概况视图(codeReviewSummary)
|
||||
| 问题类型 | 上下文范围 | 说明 |
|
||||
|---------|-----------|------|
|
||||
| 命名问题(`naming`) | 问题行 ± 2 行 | 只需那一行 |
|
||||
| 代码风格(`style`) | 问题行 ± 5 行 | 需要少量上下文 |
|
||||
| 逻辑错误(`bug`) | 整个函数/方法 | 需完整逻辑块 |
|
||||
| 安全漏洞(`security`) | 整个函数/方法 | 需完整逻辑块 |
|
||||
| 性能问题(`performance`) | 整个函数/方法 | 需完整逻辑块 |
|
||||
|
||||
- 总计、按严重级别分布、按来源分布、按文件分布
|
||||
- 导出报告按钮、批量修复按钮
|
||||
```typescript
|
||||
interface FixableDiagnostic {
|
||||
ruleId: string;
|
||||
message: string;
|
||||
line: number;
|
||||
severity: string;
|
||||
codeContext: string; // 带行号的代码片段
|
||||
source: 'linter' | 'custom';
|
||||
category: 'naming' | 'style' | 'bug' | 'security' | 'performance';
|
||||
}
|
||||
|
||||
---
|
||||
function prepareContext(document: vscode.TextDocument, diagnostic: LinterDiagnostic): FixableDiagnostic | null {
|
||||
let startLine: number, endLine: number;
|
||||
|
||||
## 8. Code Action 与批量修复
|
||||
switch (diagnostic.category) {
|
||||
case 'naming':
|
||||
({ startLine, endLine } = rangeAround(diagnostic.line, 2)); break;
|
||||
case 'style':
|
||||
({ startLine, endLine } = rangeAround(diagnostic.line, 5)); break;
|
||||
case 'bug':
|
||||
case 'security':
|
||||
case 'performance':
|
||||
const funcRange = findEnclosingFunction(document, diagnostic.line);
|
||||
startLine = funcRange?.start.line ?? diagnostic.line - 10;
|
||||
endLine = funcRange?.end.line ?? diagnostic.line + 10;
|
||||
break;
|
||||
default:
|
||||
({ startLine, endLine } = rangeAround(diagnostic.line, 5));
|
||||
}
|
||||
|
||||
### Code Action 提供者
|
||||
const codeContext = extractLines(document, startLine, endLine);
|
||||
return { ...diagnostic, codeContext };
|
||||
}
|
||||
```
|
||||
|
||||
`CodeReviewCodeActionProvider` 为 diagnostic 提供:
|
||||
### 8.4 修复匹配策略
|
||||
|
||||
1. **快速修复** — 若 analyzer 提供了 `fix()` 方法
|
||||
2. **AI 解释** — 调用 AI 翻译/解释问题
|
||||
3. **添加忽略注释** — 自动插入 linter 忽略标记
|
||||
两阶段匹配,确保修复应用到正确位置:
|
||||
|
||||
### 批量修复
|
||||
```
|
||||
阶段 1 — 行号匹配
|
||||
提取问题行的原文 → 与 AI 返回的 originalText 首行对比
|
||||
✓ 匹配 → 验证完整原文是否一致
|
||||
✗ 不匹配 → 进入阶段 2
|
||||
|
||||
- 遍历所有含 `fix` 的问题,按文件分组
|
||||
- 使用 `WorkspaceEdit` 批量应用 TextEdit
|
||||
- 显示修复总结
|
||||
阶段 2 — 全文搜索
|
||||
在文件中搜索 originalText
|
||||
✓ 找到 → 通过 positionAt() 计算实际 range
|
||||
✗ 未找到 → 标记为匹配失败
|
||||
```
|
||||
|
||||
### 忽略机制
|
||||
```typescript
|
||||
interface CodeFix {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
originalText: string; // AI 认为要替换的原文
|
||||
newText: string; // 修复后的新代码
|
||||
}
|
||||
|
||||
- 右键"忽略此规则" → 写入 `.codereviewerignore`
|
||||
function matchAndValidate(document: vscode.TextDocument, fix: CodeFix): { matched: boolean; actualRange?: vscode.Range } {
|
||||
// 阶段 1:按行号匹配
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段 2:全文搜索
|
||||
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 };
|
||||
}
|
||||
```
|
||||
|
||||
**失败处理**:
|
||||
- 单条修复失败 → 提示"代码已变更,无法定位问题位置"
|
||||
- 批量修复部分失败 → 只应用成功匹配的修复,失败的在预览面板中标记
|
||||
|
||||
### 8.5 批量修复预览
|
||||
|
||||
批量修复不直接应用,先展示预览面板:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ 🔧 批量修复预览 │
|
||||
├────────────────────────────────────────┤
|
||||
│ 共 5 处修改,请确认后应用 │
|
||||
│ │
|
||||
│ ✓ L23 eslint:no-unused-vars │
|
||||
│ - var unused = 1; │
|
||||
│ + let count = 0; │
|
||||
│ │
|
||||
│ ✗ L67 custom:no-console │
|
||||
│ 代码已变更,无法定位 │
|
||||
│ │
|
||||
│ [取消] [应用全部 (4 处)] │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**应用顺序**:按位置**倒序**执行(从文件末尾开始),避免行号偏移:
|
||||
|
||||
```typescript
|
||||
function applyBatchFixes(document: vscode.TextDocument, fixes: CodeFix[]): number {
|
||||
const validFixes = fixes.filter(f => f.matched);
|
||||
const sorted = [...validFixes].sort((a, b) => b.startLine - a.startLine);
|
||||
let applied = 0;
|
||||
for (const fix of sorted) {
|
||||
if (applySingleFix(document, fix)) applied++;
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
```
|
||||
|
||||
### 8.6 撤销机制
|
||||
|
||||
每次修复前保存当前文件内容的快照:
|
||||
|
||||
```typescript
|
||||
const snapshotStack: Map<string, string[]> = new Map();
|
||||
|
||||
function saveSnapshot(document: vscode.TextDocument): void {
|
||||
const filePath = document.uri.fsPath;
|
||||
if (!snapshotStack.has(filePath)) snapshotStack.set(filePath, []);
|
||||
snapshotStack.get(filePath)!.push(document.getText());
|
||||
}
|
||||
|
||||
function undoLastFix(document: vscode.TextDocument): 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);
|
||||
}
|
||||
```
|
||||
|
||||
面板底部提供撤销按钮,上次修复成功时启用,无快照时禁用(灰色)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 文件结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── extension.ts
|
||||
├── activation/
|
||||
│ ├── registerCommands.ts
|
||||
│ ├── registerViews.ts
|
||||
│ └── registerCodeActions.ts
|
||||
├── analyzers/
|
||||
│ ├── analyzer.ts
|
||||
│ ├── eslintAnalyzer.ts
|
||||
│ ├── ruffAnalyzer.ts
|
||||
│ ├── clippyAnalyzer.ts
|
||||
│ └── aiAnalyzer.ts
|
||||
├── manager/
|
||||
│ └── linterManager.ts
|
||||
├── views/
|
||||
│ ├── problemTreeProvider.ts
|
||||
│ └── summaryTreeProvider.ts
|
||||
├── services/
|
||||
│ ├── aiService.ts
|
||||
│ └── configService.ts
|
||||
├── utils/
|
||||
│ ├── diagnosticHelper.ts
|
||||
│ └── linterDetector.ts
|
||||
└── types.ts
|
||||
vscode-code-reviewer/
|
||||
├── src/
|
||||
│ ├── extension.ts # 扩展入口
|
||||
│ ├── orchestrator/
|
||||
│ │ └── orchestrator.ts # 多 linter 调度与结果聚合
|
||||
│ ├── adapters/ # 语言适配器(策略模式)
|
||||
│ │ ├── adapter.ts # LinterAdapter 统一接口定义
|
||||
│ │ ├── eslint.ts # JavaScript / TypeScript
|
||||
│ │ ├── pmd.ts # Java
|
||||
│ │ ├── stylelint.ts # CSS
|
||||
│ │ ├── sql-lint.ts # SQL / PL/SQL
|
||||
│ │ └── jsp.ts # JSP 组合适配器
|
||||
│ ├── jsp/
|
||||
│ │ └── jsp-extractor.ts # JSP 内嵌代码块提取器
|
||||
│ ├── ai/ # AI 审查引擎(后续细化)
|
||||
│ ├── fixer/ # 自动修复模块(§8)
|
||||
│ ├── merger/ # 结果合并与报告(§14)
|
||||
│ ├── rules/ # 自定义规则管理(后续细化)
|
||||
│ ├── config/ # 配置管理(§13 设置面板)
|
||||
│ ├── panel/ # UI 面板(§7 Webview 审查面板)
|
||||
│ └── util/ # 工具函数
|
||||
├── jars/ # 外部工具(PMD)
|
||||
│ ├── pmd/lib/ # PMD 依赖库
|
||||
│ ├── PmdRunner.java # PMD 调用包装器(支持 stdin)
|
||||
│ ├── pmd-java-ruleset.xml # Java 规则集
|
||||
│ └── pmd-jsp-ruleset.xml # JSP 规则集
|
||||
├── images/ # 图标资源
|
||||
├── scripts/ # 构建脚本
|
||||
│ ├── build.mjs # esbuild 打包
|
||||
│ ├── download-pmd.mjs # PMD 下载脚本
|
||||
│ └── package-prod.mjs # 生产打包
|
||||
├── .code-review/ # 自定义规则目录(后续细化)
|
||||
│ ├── rules/ # 规则定义文件 (*.yaml)
|
||||
│ └── config.yaml # 规则启用状态配置
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── eslint.config.mjs
|
||||
```
|
||||
|
||||
---
|
||||
@@ -218,8 +826,6 @@ src/
|
||||
|
||||
- 与 GitHub/GitLab PR Review API 集成
|
||||
- 多人协作审查工作流
|
||||
- WebView 可视化报告页面
|
||||
- 自定义规则 DSL
|
||||
|
||||
---
|
||||
|
||||
@@ -227,6 +833,516 @@ src/
|
||||
|
||||
- VSCode Extension API (^1.120.0)
|
||||
- TypeScript (ES2022, Node16 module)
|
||||
- ESLint + typescript-eslint
|
||||
- Mocha + @vscode/test-electron
|
||||
- LLM API(openai 兼容接口)
|
||||
- esbuild(打包构建)
|
||||
- ESLint + typescript-eslint(自检)
|
||||
- Mocha + @vscode/test-electron(测试)
|
||||
- LLM API(openai 兼容接口)
|
||||
- PMD 7.26.0(Java/JSP 静态分析)
|
||||
- Java 11+(PMD 运行环境)
|
||||
|
||||
---
|
||||
|
||||
## 12. 自定义规则系统
|
||||
|
||||
### 12.1 设计决策
|
||||
|
||||
| 决策项 | 结论 | 说明 |
|
||||
|--------|------|------|
|
||||
| 规则定义方式 | 纯描述(自然语言) | 不依赖正则,AI 理解语义后判断是否违反 |
|
||||
| 规则执行方式 | 纯 AI 评估 | 规则随代码发给 AI,由 AI 判断是否触发 |
|
||||
| 规则 ID 前缀 | 统一 `custom:` | UI 展示为 `custom:no-console`,区别于 linter 规则 |
|
||||
| 规则文件组织 | 多文件 + 文件夹 | `.code-review/rules/*.yaml`,按类别拆分 |
|
||||
| 规则定位 | 项目级团队约定 | 与静态分析、AI 深度审查三者互补 |
|
||||
| 严重程度 | 必须标注 | `error` / `warning` / `info` |
|
||||
|
||||
### 12.2 规则文件格式
|
||||
|
||||
自定义规则以 YAML 文件存储在工作区根目录 `.code-review/rules/` 下,按类别拆分:
|
||||
|
||||
```
|
||||
.code-review/
|
||||
├── rules/
|
||||
│ ├── security-rules.yaml # 安全类规则
|
||||
│ ├── coding-conventions.yaml # 编码规范类规则
|
||||
│ └── naming-conventions.yaml # 命名约定类规则(可选)
|
||||
└── config.yaml # 启用状态配置
|
||||
```
|
||||
|
||||
**规则 YAML 字段说明**:
|
||||
|
||||
| 字段 | 必填 | 类型 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `id` | 是 | string | 规则唯一标识(不含 `custom:` 前缀,运行时自动拼接) |
|
||||
| `severity` | 是 | string | `error` / `warning` / `info` |
|
||||
| `description` | 是 | string | 规则的自然语言描述(AI 评估依据) |
|
||||
| `message` | 是 | string | 触发时显示给用户的信息 |
|
||||
| `languages` | 否 | string[] | 生效的语言列表,为空表示对所有语言生效 |
|
||||
|
||||
**模板文件** — `security-rules.yaml`:
|
||||
|
||||
```yaml
|
||||
- id: no-hardcoded-secret
|
||||
severity: error
|
||||
description: 禁止在代码中硬编码 API Key、密码等敏感信息
|
||||
message: 检测到硬编码密钥,请使用环境变量或密钥管理工具
|
||||
languages: [java, javascript, typescript]
|
||||
|
||||
- id: no-sql-injection
|
||||
severity: error
|
||||
description: 禁止使用字符串拼接的方式构造 SQL 语句
|
||||
message: 使用参数化查询(PreparedStatement)替代字符串拼接
|
||||
languages: [java]
|
||||
```
|
||||
|
||||
**模板文件** — `coding-conventions.yaml`:
|
||||
|
||||
```yaml
|
||||
- id: no-console-log
|
||||
severity: warning
|
||||
description: 生产代码不应保留 console.log 调试语句
|
||||
message: 请使用日志框架替代 console.log
|
||||
languages: [javascript, typescript]
|
||||
|
||||
- id: no-magic-numbers
|
||||
severity: info
|
||||
description: 禁止在代码中使用未命名的魔术数字
|
||||
message: 请将魔法数字提取为命名常量
|
||||
languages: [java, javascript, typescript]
|
||||
```
|
||||
|
||||
### 12.3 启用/禁用配置
|
||||
|
||||
**`config.yaml`**:
|
||||
|
||||
```yaml
|
||||
# 各规则文件的启用状态
|
||||
enabled:
|
||||
- security-rules.yaml
|
||||
- coding-conventions.yaml
|
||||
|
||||
# 各规则的单独启用/禁用(覆盖文件级设置)
|
||||
rules:
|
||||
no-magic-numbers:
|
||||
enabled: false
|
||||
```
|
||||
|
||||
**加载逻辑**(`rules/yaml-parser.ts`):
|
||||
|
||||
```
|
||||
1. 扫描 .code-review/rules/*.yaml → 加载所有规则定义
|
||||
2. 读取 .code-review/config.yaml
|
||||
3. 按文件级 enabled 列表过滤
|
||||
4. 按规则级 rules.<id>.enabled 覆盖
|
||||
5. 返回激活的规则列表
|
||||
```
|
||||
|
||||
### 12.4 与各组件的交互
|
||||
|
||||
```
|
||||
自定义规则
|
||||
│
|
||||
├── 定义阶段 ── YAML 解析器 (rules/yaml-parser.ts)
|
||||
│ → 解析 .code-review/rules/*.yaml
|
||||
│ → 合并 config.yaml 启用状态
|
||||
│
|
||||
└── 执行阶段 ── AI 引擎 (ai/engine.ts 请求 A)
|
||||
→ 将规则 description 注入 System Prompt
|
||||
→ AI 评估代码是否违反规则
|
||||
→ 返回 custom:规则ID 格式的结果
|
||||
→ Merger 合并入最终报告
|
||||
|
||||
---
|
||||
|
||||
## 13. 设置面板
|
||||
|
||||
### 13.1 设计决策
|
||||
|
||||
| 决策项 | 结论 | 说明 |
|
||||
|--------|------|------|
|
||||
| 配置组织方式 | 按模块分组 | `aiConfig` / `linterConfig` / `fixerConfig` 三个命名空间 |
|
||||
| API Key 存储 | 全局一个 SecretStorage | 切换 provider 时由用户更新 Key |
|
||||
| 配置变更监听 | 不监听 | 下次审查时读取最新配置即可 |
|
||||
| 配置验证 | 不做 | 依赖 `package.json` 的 enum/minimum 等约束 |
|
||||
| 多根工作区 | 暂不考虑 | 取第一个工作区 |
|
||||
| 设置面板形态 | 仅侧边栏视图 | 不另开独立面板 |
|
||||
| provider 配置 | ai.provider 与面板共用 | 面板下拉框直接读写 `ai.provider` |
|
||||
| linter 配置 | 单选 enum | `linters.<language>` 值只能是预设 linter 或空字符串(禁用) |
|
||||
| 快速引导 | 三步渐进 | 步骤自动标记完成,引导用户完成初始配置 |
|
||||
| 主按钮色彩 | 紫色 | 保存并测试连接按钮 `#7c3aed`,区别于绿色确认按钮 |
|
||||
|
||||
### 13.2 配置模块结构
|
||||
|
||||
```
|
||||
src/config/
|
||||
├── index.ts # 统一导出
|
||||
├── ai.ts # AI 配置(provider/model/endpoint/temperature/timeout/outputLanguage)
|
||||
├── linter.ts # Linter 配置(linters.<language>、PMD 路径)
|
||||
├── fixer.ts # 修复器配置(contextLines)
|
||||
└── secret.ts # API Key SecretStorage(get/set/delete/isConfigured)
|
||||
```
|
||||
|
||||
**配置读取方式**:每个模块导出 getter,运行时从 `vscode.workspace.getConfiguration` 读取,不做缓存。
|
||||
|
||||
**API Key 存储**:使用 VS Code `SecretStorage` 存储一个全局 Key(`vscode-code-reviewer.apiKey`),切换 provider 时由用户更新。
|
||||
|
||||
### 13.3 面板布局
|
||||
|
||||
侧边栏视图 `codeReviewer.setupView`:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────┐
|
||||
│ ⚙ 代码审查 · 设置 │
|
||||
├──────────────────────────────────┤
|
||||
│ 快速开始 │
|
||||
│ ┌─① 配置 AI 模型及 API Key │
|
||||
│ │ ② 启用自定义规则 │
|
||||
│ │ ③ 保存并测试连接 Ctrl+R │
|
||||
│ └─ │
|
||||
│ │
|
||||
│ 审核引擎 │
|
||||
│ 🔵 共通规则 — Linter 静态分析 │
|
||||
│ 🟡 自定义规则 — 团队编码规范 │
|
||||
│ 🟢 AI 审核 — 深度代码审查 │
|
||||
│ │
|
||||
│ AI 模型配置 │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ 模型提供商 [已配置] │ │
|
||||
│ │ [DeepSeek ▼] │ │
|
||||
│ │ 模型名称 │ │
|
||||
│ │ [deepseek-chat ▼] │ │
|
||||
│ │ 建议使用结构化输出模型 │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ API Key │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ API Key [已配置] │ │
|
||||
│ │ [•••••••••••••••••] │ │
|
||||
│ │ Base URL │ │
|
||||
│ │ [https://api.de...] │ │
|
||||
│ │ Key 仅本地安全存储 │ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ 输出语言 │
|
||||
│ AI 审查结果输出语言 │
|
||||
│ [中文(简体) ▼] │
|
||||
│ │
|
||||
│ 自定义规则 │
|
||||
│ ┌──────────────────────────┐ │
|
||||
│ │ 规则列表 [3 条启用] │ │
|
||||
│ │ [开关] no-console-in... ×│ │
|
||||
│ │ [开关] max-function-l... ×│ │
|
||||
│ │ [开关] require-javad... ×│ │
|
||||
│ │ [开关] no-any-type ×│ │
|
||||
│ │ [输入规则名称...] [+ 添加]│ │
|
||||
│ └──────────────────────────┘ │
|
||||
│ │
|
||||
│ [重置] [保存并测试连接] │
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
**各区域说明**:
|
||||
|
||||
| 区域 | 元素 | 存储 |
|
||||
|------|------|------|
|
||||
| 快速开始 | 三步引导(圆形序号,完成变紫色) | — |
|
||||
| 审核引擎 | 三个模块标签(紫/琥珀/绿圆点) | — |
|
||||
| AI 模型配置 | 卡片:提供商下拉框 + 模型下拉框 + 状态标签 | `ai.provider` / `ai.model` |
|
||||
| API Key | 卡片:密码输入框 + Base URL + 状态标签 | SecretStorage / `ai.endpoint` |
|
||||
| 输出语言 | 下拉框 | `ai.outputLanguage` |
|
||||
| 自定义规则 | 卡片:开关列表 + 添加行 | `.code-review/config.yaml` |
|
||||
|
||||
**状态标签**:`已配置`(绿色药丸) / `未配置`(灰色药丸),实时反映配置完成状态。
|
||||
|
||||
### 13.4 快速引导流程
|
||||
|
||||
面板顶部提供三步渐进引导,步骤自动标记:
|
||||
|
||||
| 步骤 | 触发完成条件 | 行为 |
|
||||
|------|------------|------|
|
||||
| ① 配置 AI 模型及 API Key | API Key 输入框有值 | 圆形序号 `①` 变紫色 |
|
||||
| ② 启用自定义规则 | 任意规则开关打开 | 圆形序号 `②` 变紫色 |
|
||||
| ③ 保存并测试连接 | 前两步完成 + 测试成功 | 圆形序号 `③` 变紫色,底部显示快捷键 `Ctrl+Shift+R` |
|
||||
|
||||
### 13.5 保存与测试连接
|
||||
|
||||
| 阶段 | 按钮状态 | 显示 |
|
||||
|------|---------|------|
|
||||
| 待测试 | 正常 | `保存并测试连接`(紫色) |
|
||||
| 测试中 | 禁用 + spinner | `<spinner> 测试中...` |
|
||||
| 成功 | 可用 | `✓ 已连接`(绿色),Toast 绿色提示 5 秒 |
|
||||
| 失败 | 可用 | `✗ 重试`(紫色),Toast 红色提示 5 秒 |
|
||||
|
||||
### 13.6 自定义规则文件管理
|
||||
|
||||
面板中规则列表展示所有已加载的规则,每条规则显示:
|
||||
|
||||
- 开关(紫色滑块)
|
||||
- 规则名称(等宽字体)
|
||||
- 删除按钮 `×`
|
||||
|
||||
**用户操作**:
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 点击开关 | 即时切换,在 `config.yaml` 写入 `rules.<id>.enabled` 覆盖项 |
|
||||
| 点击添加 | 打开规则向导 → 保存到 `common-rules.yaml` |
|
||||
| 点击删除 | 在 `config.yaml` 写入 `enabled: false` |
|
||||
|
||||
> 不修改规则 YAML 文件本身,仅通过 `config.yaml` 的覆盖项控制启用状态,避免破坏团队规则定义。
|
||||
|
||||
---
|
||||
|
||||
## 14. 结果合并与报告
|
||||
|
||||
### 14.1 合并逻辑
|
||||
|
||||
来自适配器、AI 引擎、自定义规则三路结果通过 `Merger` 合并为一个报告:
|
||||
|
||||
```typescript
|
||||
interface MergedReport {
|
||||
linterDiagnostics: LinterDiagnostic[];
|
||||
customRuleDiagnostics: LinterDiagnostic[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
aiFindings: AIFinding[];
|
||||
linterCount: number;
|
||||
customRuleCount: number;
|
||||
aiCount: number;
|
||||
errors: string[];
|
||||
degraded: boolean;
|
||||
duration: number;
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterNames: string[];
|
||||
fixableLinterIndices: number[];
|
||||
fixableCustomIndices: number[];
|
||||
}
|
||||
```
|
||||
|
||||
**核心函数**:
|
||||
|
||||
```typescript
|
||||
function mergeResults(
|
||||
staticDiagnostics: LinterDiagnostic[],
|
||||
aiResponse: AIResponse,
|
||||
errors: string[],
|
||||
degraded: boolean,
|
||||
startTime: number,
|
||||
filePath: string,
|
||||
language: string,
|
||||
adapterIds: string[]
|
||||
): MergedReport;
|
||||
```
|
||||
|
||||
### 14.2 Markdown 导出格式
|
||||
|
||||
```markdown
|
||||
# 代码审查报告
|
||||
|
||||
**文件:** `xxx.java`
|
||||
**语言:** Java
|
||||
**耗时:** 3.2s
|
||||
|
||||
## 🔧 PMD · 5 个问题
|
||||
- 🔴 `pmd:AvoidDuplicateLiterals` L23
|
||||
避免重复字符串常量...
|
||||
|
||||
## 📋 自定义规则 · 2 个问题
|
||||
- 🔴 `no-sql-injection` L45
|
||||
检测到 SQL 拼接...
|
||||
|
||||
## 🤖 AI 审查 · 3 条建议
|
||||
- 🟡 [AI] [security] `hardcoded-secret` L12
|
||||
发现硬编码密码...
|
||||
建议: 使用环境变量...
|
||||
```diff
|
||||
- String password = "admin123";
|
||||
+ String password = System.getenv("DB_PASSWORD");
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 15. 构建与打包
|
||||
|
||||
### 15.1 构建脚本
|
||||
|
||||
```javascript
|
||||
// scripts/build.mjs
|
||||
import * as esbuild from 'esbuild';
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: ['src/extension.ts'],
|
||||
bundle: true,
|
||||
outfile: 'out/extension.js',
|
||||
external: ['vscode'],
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
});
|
||||
```
|
||||
|
||||
### 15.2 开发流程
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 编译
|
||||
npm run compile
|
||||
|
||||
# 监听模式
|
||||
npm run watch
|
||||
|
||||
# 运行测试
|
||||
npm test
|
||||
|
||||
# 本地调试
|
||||
# 在 VS Code 中按 F5 启动扩展开发宿主
|
||||
```
|
||||
|
||||
### 15.3 生产打包
|
||||
|
||||
```bash
|
||||
# 打包 vsix
|
||||
vsce package
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 16. 测试策略
|
||||
|
||||
### 16.1 测试文件结构
|
||||
|
||||
```
|
||||
src/test/
|
||||
├── fixtures/ # 测试数据
|
||||
│ ├── admin-system/ # Java 项目示例
|
||||
│ │ ├── src/main/java/...
|
||||
│ │ ├── src/main/webapp/...
|
||||
│ │ └── pom.xml
|
||||
│ └── UserService.java # 单文件测试
|
||||
├── adapter.test.ts # 适配器测试
|
||||
├── ai-engine.test.ts # AI 引擎测试
|
||||
├── config.test.ts # 配置测试
|
||||
├── merger.test.ts # 合并逻辑测试
|
||||
├── pipeline.test.ts # 完整流程测试
|
||||
└── extension.test.ts # 扩展入口测试
|
||||
```
|
||||
|
||||
### 16.2 关键测试场景
|
||||
|
||||
| 场景 | 说明 |
|
||||
|------|------|
|
||||
| 适配器测试 | 验证各语言 linter 输出解析正确性 |
|
||||
| AI 引擎测试 | 验证 Prompt 构建、JSON 解析、错误处理 |
|
||||
| 合并测试 | 验证多源结果合并、统计计算 |
|
||||
| 完整流程测试 | 模拟从代码输入到报告输出全链路 |
|
||||
|
||||
---
|
||||
|
||||
## 17. 依赖管理
|
||||
|
||||
### 17.1 运行时依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"eslint": "^9.39.3",
|
||||
"stylelint": "^17.14.0",
|
||||
"node-sql-parser": "^5.4.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 17.2 开发依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"devDependencies": {
|
||||
"@types/vscode": "^1.120.0",
|
||||
"@types/node": "22.x",
|
||||
"typescript": "^5.9.3",
|
||||
"esbuild": "^0.28.1",
|
||||
"eslint": "^9.39.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"@vscode/test-cli": "^0.0.12",
|
||||
"@vscode/test-electron": "^2.5.2",
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 17.3 外部工具
|
||||
|
||||
| 工具 | 版本 | 用途 | 获取方式 |
|
||||
|------|------|------|----------|
|
||||
| PMD | 7.26.0 | Java/JSP 静态分析 | 自带 `jars/pmd/` |
|
||||
| Java | 11+ | PMD 运行环境 | 用户环境 |
|
||||
|
||||
---
|
||||
|
||||
## 18. 附录
|
||||
|
||||
### A. 文件清单
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `src/extension.ts` | 扩展入口,命令注册、流程编排 |
|
||||
| `src/adapters/adapter.ts` | LinterAdapter 接口 + AdapterResult |
|
||||
| `src/adapters/eslint.ts` | JS/TS 适配器 |
|
||||
| `src/adapters/pmd.ts` | Java 适配器(支持虚拟文档) |
|
||||
| `src/adapters/stylelint.ts` | CSS 适配器 |
|
||||
| `src/adapters/sql-lint.ts` | SQL/PLSQL 适配器 |
|
||||
| `src/adapters/jsp.ts` | JSP 组合适配器 |
|
||||
| `src/jsp/jsp-extractor.ts` | JSP 内嵌代码块提取器 |
|
||||
| `src/orchestrator/orchestrator.ts` | 编排调度 |
|
||||
| `src/ai/engine.ts` | AI 审查引擎 |
|
||||
| `src/ai/schema.ts` | AI 响应结构定义 |
|
||||
| `src/ai/providers/base.ts` | AIProvider 抽象基类 |
|
||||
| `src/ai/providers/deepseek.ts` | DeepSeek 实现 |
|
||||
| `src/ai/providers/openai.ts` | OpenAI 实现 |
|
||||
| `src/ai/factory.ts` | Provider 工厂函数 |
|
||||
| `src/fixer/fixer.ts` | 自动修复逻辑 |
|
||||
| `src/merger/merger.ts` | 结果合并 |
|
||||
| `src/rules/yaml-parser.ts` | YAML 规则解析 |
|
||||
| `src/config/ai.ts` | AI 配置 |
|
||||
| `src/config/linter.ts` | Linter 配置 |
|
||||
| `src/config/fixer.ts` | 修复器配置 |
|
||||
| `src/config/secret.ts` | API Key SecretStorage |
|
||||
| `src/config/index.ts` | 配置统一导出 |
|
||||
| `src/panel/webview.ts` | 审查报告面板 |
|
||||
| `src/**/setupView.ts` | 侧边栏设置视图 |
|
||||
| `jars/pmd/PmdRunner.java` | PMD Java 包装器(支持 stdin) |
|
||||
| `jars/pmd-java-ruleset.xml` | Java 规则集 |
|
||||
| `jars/pmd-jsp-ruleset.xml` | JSP 规则集 |
|
||||
| `scripts/build.mjs` | esbuild 构建脚本 |
|
||||
| `.code-review/rules/*.yaml` | 自定义规则定义 |
|
||||
| `.code-review/config.yaml` | 规则启用状态 |
|
||||
|
||||
### B. 关键接口签名
|
||||
|
||||
```typescript
|
||||
// 适配器
|
||||
function getAdapters(): LinterAdapter[];
|
||||
|
||||
// 静态分析编排
|
||||
function runStaticAnalysis(documents: vscode.TextDocument[], workingDir: string): Promise<StaticAnalysisResult>;
|
||||
|
||||
// AI 审查
|
||||
function runAIReview(context: vscode.ExtensionContext, code: string, diagnostics: LinterDiagnostic[], customRules: CustomRule[]): Promise<AIEngineResult>;
|
||||
|
||||
// 结果合并
|
||||
function mergeResults(staticDiagnostics: LinterDiagnostic[], aiResponse: AIResponse, errors: string[], degraded: boolean, startTime: number, filePath: string, language: string, adapterIds: string[]): MergedReport;
|
||||
|
||||
// 自动修复
|
||||
function generateFix(context: vscode.ExtensionContext, diagnostic: FixableDiagnostic): Promise<CodeFix | null>;
|
||||
function applyFix(fix: CodeFix): Promise<boolean>;
|
||||
|
||||
// 报告导出
|
||||
function reportToMarkdown(report: MergedReport): string;
|
||||
|
||||
// 自定义规则
|
||||
function loadActiveRules(workspaceRoot: string): CustomRule[];
|
||||
```
|
||||
@@ -0,0 +1,645 @@
|
||||
# vscode-code-reviewer 实施拆分方案
|
||||
|
||||
## 决策汇总
|
||||
|
||||
| 决策项 | 选择 |
|
||||
|--------|------|
|
||||
| 拆分策略 | 按层级自底向上 |
|
||||
| 适配器优先级 | 按复杂度递增:ESLint → Stylelint → sql-lint → PMD → JSP |
|
||||
| 核心层B顺序 | 按依赖链:Provider → AI引擎 → 规则解析 → 结果合并 → 自动修复 → 报告导出 |
|
||||
| UI层顺序 | 按依赖顺序:命令注册 → 设置面板 → 审查面板 → Code Action |
|
||||
| 构建与测试 | 集中在最后 Phase |
|
||||
|
||||
## 整体 Phase 划分
|
||||
|
||||
```
|
||||
Phase 1: 基础层 Types + Config 管理
|
||||
Phase 2: 适配层 5 个 Linter 适配器
|
||||
Phase 3: 核心层 A 编排器 Orchestrator
|
||||
Phase 4: 核心层 B AI引擎 + 规则 + 合并 + 修复 + 报告
|
||||
Phase 5: UI 层 命令 + 设置面板 + 审查面板
|
||||
Phase 6: 构建与测试 esbuild + 测试
|
||||
```
|
||||
|
||||
## 依赖关系图
|
||||
|
||||
```
|
||||
Phase 1 ────→ Phase 2 ────→ Phase 3 ────→ Phase 4 ────→ Phase 5 ────→ Phase 6
|
||||
↑ ↑
|
||||
│ │
|
||||
(2.1 接口) (4.1 Provider)
|
||||
(2.2~2.6 适配器) (4.2~4.6 其余)
|
||||
```
|
||||
|
||||
Phase 2 内部必须按 2.1 → 2.2 → 2.3 → 2.4 → 2.5 → 2.6 顺序,JSP 适配器依赖 PMD/ESLint/Stylelint。
|
||||
Phase 4 内部必须按 4.1 → 4.2 → 4.3 → 4.4 → 4.5 → 4.6 顺序,Fixer 依赖 Provider 和 Merger。
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 基础层
|
||||
|
||||
**依赖**: 无
|
||||
**目标**: 建立公共类型定义和配置管理,为所有上层模块提供基础能力
|
||||
**参考设计**: §3.2, §6.4, §13.2
|
||||
|
||||
### 文件清单
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/types.ts` | 新建 | `LinterDiagnostic`、`AdapterResult`、`LinterAdapter` 等公共类型 |
|
||||
| `src/config/index.ts` | 新建 | 统一导出 |
|
||||
| `src/config/ai.ts` | 新建 | AI 配置 getter(provider/model/endpoint/temperature/timeout/outputLanguage) |
|
||||
| `src/config/linter.ts` | 新建 | Linter 配置 getter(linters.xxx 语言-linter 映射、PMD 路径) |
|
||||
| `src/config/fixer.ts` | 新建 | 修复器配置(contextLines) |
|
||||
| `src/config/secret.ts` | 新建 | API Key SecretStorage(get/set/delete/isConfigured) |
|
||||
|
||||
### 关键类型
|
||||
|
||||
```typescript
|
||||
// src/types.ts
|
||||
|
||||
export type Severity = 'error' | 'warning' | 'info';
|
||||
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
|
||||
|
||||
export interface LinterDiagnostic {
|
||||
severity: Severity;
|
||||
ruleId: string;
|
||||
message: string;
|
||||
range: vscode.Range;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
status: AdapterStatus;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface LinterAdapter {
|
||||
id: string;
|
||||
supportedLanguages: string[];
|
||||
check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult>;
|
||||
isAvailable(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 配置 Key 映射
|
||||
|
||||
所有配置以 `vscode-code-reviewer.` 为前缀:
|
||||
|
||||
| 模块 | 配置项 | 类型 | 默认值 |
|
||||
|------|--------|------|--------|
|
||||
| ai | `ai.provider` | enum | `deepseek` |
|
||||
| ai | `ai.model` | string | `deepseek-chat` |
|
||||
| ai | `ai.endpoint` | string | `https://api.deepseek.com/v1` |
|
||||
| ai | `ai.temperature` | number | 0.2 |
|
||||
| ai | `ai.timeout` | number | 300 |
|
||||
| ai | `ai.outputLanguage` | string | `zh-CN` |
|
||||
| linter | `linters.javascript` | enum | `eslint` |
|
||||
| linter | `linters.typescript` | enum | `eslint` |
|
||||
| linter | `linters.java` | enum | `pmd` |
|
||||
| linter | `linters.jsp` | enum | `jsp` |
|
||||
| linter | `linters.css` | enum | `stylelint` |
|
||||
| linter | `linters.sql` | enum | `sql-lint` |
|
||||
| linter | `linters.plsql` | enum | `sql-lint` |
|
||||
| linter | `pmd.jarPath` | string | `""` |
|
||||
| linter | `pmd.rulesetPath` | string | `""` |
|
||||
| linter | `pmd.jspRulesetPath` | string | `""` |
|
||||
| linter | `sql-lint.configFile` | string | `""` |
|
||||
| fixer | `fixer.contextLines` | number | 5 |
|
||||
| secret | API Key | SecretStorage | `vscode-code-reviewer.apiKey` |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: 适配层
|
||||
|
||||
**依赖**: Phase 1
|
||||
**目标**: 实现 5 个语言适配器,统一 `LinterAdapter` 接口
|
||||
**参考设计**: §3
|
||||
|
||||
### Phase 2.1: 适配器接口
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/adapters/adapter.ts` | 新建 | 重新导出 `LinterAdapter` 接口(或在此处定义,取决于代码组织) |
|
||||
|
||||
### Phase 2.2: ESLint 适配器
|
||||
|
||||
**参考设计**: §3.3
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/adapters/eslint.ts` | 新建 | `ESLintAdapter` implements `LinterAdapter` |
|
||||
| | | `supportedLanguages`: `['javascript', 'typescript']` |
|
||||
| | | `check()`: 使用 eslint npm 包 `lintText()` |
|
||||
| | | `isAvailable()`: 检测 eslint 是否已安装 |
|
||||
|
||||
**npm 依赖**: `eslint` ^9.39.3
|
||||
|
||||
### Phase 2.3: Stylelint 适配器
|
||||
|
||||
**参考设计**: §3.3
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/adapters/stylelint.ts` | 新建 | `StylelintAdapter` implements `LinterAdapter` |
|
||||
| | | `supportedLanguages`: `['css']` |
|
||||
| | | `check()`: 使用 stylelint npm 包 `lint({ code })` |
|
||||
| | | `isAvailable()`: 检测 stylelint 是否已安装 |
|
||||
|
||||
**npm 依赖**: `stylelint` ^17.14.0
|
||||
|
||||
### Phase 2.4: sql-lint 适配器
|
||||
|
||||
**参考设计**: §3.3
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/adapters/sql-lint.ts` | 新建 | `SqlLintAdapter` implements `LinterAdapter` |
|
||||
| | | `supportedLanguages`: `['sql', 'plsql']` |
|
||||
| | | `check()`: CLI 子进程调用 sqlfluff |
|
||||
| | | `isAvailable()`: 检测 sqlfluff CLI 是否可用 |
|
||||
| | | 方言映射:sql → ansi, plsql → postgres |
|
||||
|
||||
### Phase 2.5: PMD 适配器
|
||||
|
||||
**参考设计**: §3.3, §3.4
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/adapters/pmd.ts` | 新建 | `PmdAdapter` implements `LinterAdapter` |
|
||||
| | | `supportedLanguages`: `['java']` |
|
||||
| | | `check()`: Java 子进程调用 PmdRunner |
|
||||
| | | 虚拟文档 (untitled) 通过 stdin 传入代码 |
|
||||
| | | 真实文件传文件路径 |
|
||||
| | | `isAvailable()`: 检测 Java 11+ 和 PMD JAR |
|
||||
| `jars/pmd/PmdRunner.java` | 新建 | PMD 包装器:stdin 支持 + JSON 渲染器 |
|
||||
| `jars/pmd/pmd-java-ruleset.xml` | 新建 | Java 规则集 |
|
||||
| `jars/pmd/pmd-jsp-ruleset.xml` | 新建 | JSP 规则集 |
|
||||
|
||||
**PmdRunner.java 核心逻辑**:
|
||||
```
|
||||
参数: filePath (传 "-" 表示从 stdin 读取), ruleset
|
||||
→ 构建 PMDConfiguration
|
||||
→ 配置 JSON 渲染器
|
||||
→ 若 filePath 为 "-",从 stdin 读代码 → 写入临时文件
|
||||
→ 执行 PMD 分析
|
||||
→ 输出 JSON 到 stdout
|
||||
→ 清理临时文件
|
||||
```
|
||||
|
||||
**PMD JAR 目录结构**:
|
||||
```
|
||||
jars/pmd/
|
||||
├── lib/ # PMD 依赖 JAR(需下载)
|
||||
├── PmdRunner.java # 包装器(编译为 .class)
|
||||
├── pmd-java-ruleset.xml
|
||||
└── pmd-jsp-ruleset.xml
|
||||
```
|
||||
|
||||
### Phase 2.6: JSP 适配器
|
||||
|
||||
**依赖**: Phase 2.5, 2.2, 2.3(需要 PMD/ESLint/Stylelint 适配器)
|
||||
**参考设计**: §3.5
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/jsp/jsp-extractor.ts` | 新建 | JSP 内嵌代码块提取器 |
|
||||
| `src/adapters/jsp.ts` | 新建 | `JspAdapter` implements `LinterAdapter`(组合适配器) |
|
||||
| | | `supportedLanguages`: `['jsp']` |
|
||||
| | | `check()`: 三步流程 |
|
||||
|
||||
**JspAdapter.check() 流程**:
|
||||
```
|
||||
1. 调用 PmdAdapter.check(document) → JSP 规范检查
|
||||
2. extractJspSections(document.getText()) → 提取内嵌代码块
|
||||
3. 对每个 section:
|
||||
a. 按 language 选择对应适配器
|
||||
b. 创建虚拟文档 (vscode.workspace.openTextDocument)
|
||||
c. 调用 adapter.check(virtualDoc)
|
||||
d. 修正行号偏移 (section.lineOffset)
|
||||
4. 合并所有结果
|
||||
```
|
||||
|
||||
**提取器正则规则**:
|
||||
|
||||
| 代码块类型 | 正则匹配 | 目标适配器 |
|
||||
|-----------|---------|-----------|
|
||||
| `<script>` 标签 | `/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi` | ESLint |
|
||||
| `<style>` 标签 | `/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi` | Stylelint |
|
||||
| `<% %>` scriptlet | `/<%=?([\s\S]*?)%>/g` | PMD |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 核心层 A — 编排器
|
||||
|
||||
**依赖**: Phase 2
|
||||
**参考设计**: §3.6, §4
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/orchestrator/orchestrator.ts` | 新建 | `Orchestrator` 类 |
|
||||
|
||||
**关键函数**:
|
||||
- `getAdapters()` — 硬编码返回 5 个适配器实例
|
||||
- `runStaticAnalysis(document, workingDir)` — 按语言选择适配器 → 调用 `check()` → 聚合
|
||||
- 保存监听 + debounce 500ms
|
||||
|
||||
**调度逻辑**:
|
||||
```
|
||||
1. 获取当前文档语言 ID
|
||||
2. 查询 linters.<language> 配置 → 确定使用的适配器
|
||||
3. 调用适配器 check(document, workingDir)
|
||||
4. 收集结果,区分 status
|
||||
5. 返回聚合后的 diagnostics 列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: 核心层 B
|
||||
|
||||
**依赖**: Phase 3
|
||||
**参考设计**: §5, §8, §12, §14
|
||||
|
||||
### Phase 4.1: AI Provider 基础设施
|
||||
|
||||
**参考设计**: §5.2
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/ai/providers/base.ts` | 新建 | `AIProvider` 抽象基类 + `ChatOptions` 接口 |
|
||||
| `src/ai/providers/deepseek.ts` | 新建 | `DeepSeekProvider extends AIProvider` |
|
||||
| `src/ai/providers/openai.ts` | 新建 | `OpenAIProvider extends AIProvider` |
|
||||
| `src/ai/factory.ts` | 新建 | `createProvider(providerId, apiKey, endpoint)` 工厂函数 |
|
||||
|
||||
**Provider 接口**:
|
||||
```typescript
|
||||
interface ChatOptions {
|
||||
model: string;
|
||||
temperature: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
abstract class AIProvider {
|
||||
abstract id: string;
|
||||
abstract name: string;
|
||||
constructor(protected apiKey: string, protected endpoint: string) {}
|
||||
abstract chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4.2: AI 引擎 + Schema
|
||||
|
||||
**参考设计**: §5.3, §5.4, §5.5, §5.6
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/ai/schema.ts` | 新建 | `AIResponse`, `TranslatedDiagnostic`, `CustomRuleResult`, `AIFinding` 接口 |
|
||||
| `src/ai/engine.ts` | 新建 | `runAIReview()` 主函数 |
|
||||
|
||||
**引擎核心逻辑**:
|
||||
```typescript
|
||||
async function runAIReview(code, staticDiagnostics, customRules) {
|
||||
const [resultA, resultB] = await Promise.allSettled([
|
||||
callCustomRuleReview(code, customRules), // 请求 A
|
||||
callTranslateAndDeepReview(code, staticDiagnostics), // 请求 B
|
||||
]);
|
||||
return {
|
||||
customRuleResults: ...,
|
||||
translatedDiagnostics: ...,
|
||||
findings: ...,
|
||||
degraded: resultA.status === 'rejected' || resultB.status === 'rejected',
|
||||
error: ...,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**两并行请求**:
|
||||
- 请求 A: 自定义规则评估(system prompt 注入规则 description)
|
||||
- 请求 B: 静态分析翻译 + AI 深度审查
|
||||
|
||||
**降级策略**:
|
||||
1. 单请求失败不影响另一个请求(`Promise.allSettled`)
|
||||
2. 所有 AI 功能都失败时,纯静态分析结果仍然展示
|
||||
3. 失败信息在面板顶部以黄色/红色提示条展示
|
||||
|
||||
### Phase 4.3: 自定义规则系统
|
||||
|
||||
**参考设计**: §12
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/rules/yaml-parser.ts` | 新建 | `loadActiveRules(workspaceRoot)` |
|
||||
|
||||
**加载逻辑**:
|
||||
```
|
||||
1. 扫描 .code-review/rules/*.yaml → 加载所有规则定义
|
||||
2. 读取 .code-review/config.yaml
|
||||
3. 按文件级 enabled 列表过滤
|
||||
4. 按规则级 rules.<id>.enabled 覆盖
|
||||
5. 返回激活的规则列表
|
||||
```
|
||||
|
||||
**CustomRule 类型**:
|
||||
```typescript
|
||||
interface CustomRule {
|
||||
id: string; // 不含 custom: 前缀,运行时自动拼接
|
||||
severity: Severity;
|
||||
description: string; // AI 评估依据
|
||||
message: string; // 触发时显示
|
||||
languages?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4.4: 结果合并
|
||||
|
||||
**参考设计**: §14.1
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/merger/merger.ts` | 新建 | `mergeResults()` → `MergedReport` |
|
||||
|
||||
```typescript
|
||||
interface MergedReport {
|
||||
linterDiagnostics: LinterDiagnostic[];
|
||||
customRuleDiagnostics: LinterDiagnostic[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
aiFindings: AIFinding[];
|
||||
linterCount: number;
|
||||
customRuleCount: number;
|
||||
aiCount: number;
|
||||
errors: string[];
|
||||
degraded: boolean;
|
||||
duration: number;
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterNames: string[];
|
||||
fixableLinterIndices: number[];
|
||||
fixableCustomIndices: number[];
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4.5: 自动修复
|
||||
|
||||
**参考设计**: §8
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/fixer/fixer.ts` | 新建 | `generateFix()`, `applyFix()`, `applyBatchFixes()`, `undoLastFix()` |
|
||||
|
||||
**核心流程**:
|
||||
```
|
||||
用户触发修复(单条 / 批量)
|
||||
↓
|
||||
prepareContext() 获取代码上下文(动态行数)
|
||||
↓
|
||||
generateFix() 调用 AI 生成修复方案(复用 Provider)
|
||||
↓
|
||||
matchAndValidate() 匹配验证(行号 → 代码搜索)
|
||||
↓
|
||||
applyFix() 应用修复到编辑器(单条 / 批量倒序)
|
||||
↓
|
||||
保存快照,更新撤销按钮状态
|
||||
```
|
||||
|
||||
**动态上下文策略**:
|
||||
|
||||
| 问题类型 | 上下文范围 |
|
||||
|---------|-----------|
|
||||
| 命名(naming) | 问题行 ± 2 行 |
|
||||
| 代码风格(style) | 问题行 ± 5 行 |
|
||||
| 逻辑/安全/性能(bug/security/performance) | 整个函数/方法 |
|
||||
|
||||
**两阶段匹配**:
|
||||
- 阶段 1: 按行号匹配原文
|
||||
- 阶段 2: 全文搜索 originalText
|
||||
|
||||
### Phase 4.6: 报告导出
|
||||
|
||||
**参考设计**: §14.2
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/utils/report.ts` | 新建 | `reportToMarkdown(report: MergedReport): string` |
|
||||
|
||||
**Markdown 格式**: 文件信息 → 统计摘要 → 分来源列出问题(linter/自定义/AI)
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: UI 层
|
||||
|
||||
**依赖**: Phase 4
|
||||
**参考设计**: §6, §7, §13
|
||||
|
||||
### Phase 5.1: 命令注册 + extension.ts 更新
|
||||
|
||||
**参考设计**: §6.1
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/activation/commands.ts` | 新建 | 注册所有命令处理函数 |
|
||||
| `package.json` | 修改 | 替换 helloWorld 为正式命令、添加 viewsContainers/views/menus/configuration |
|
||||
| `src/extension.ts` | 修改 | activate 中注册命令、视图、监听保存事件 |
|
||||
|
||||
**8 个命令**:
|
||||
|
||||
| 命令 ID | 功能 | 快捷键 |
|
||||
|---------|------|--------|
|
||||
| `codeReviewer.review` | 运行完整审查(静态分析 + AI) | Ctrl+Shift+R |
|
||||
| `codeReviewer.reviewSelection` | 审查选中代码 | — |
|
||||
| `codeReviewer.openPanel` | 显示审查报告面板 | — |
|
||||
| `codeReviewer.exportReport` | 导出 Markdown 报告 | — |
|
||||
| `codeReviewer.addCustomRule` | 添加自定义规则 | — |
|
||||
| `codeReviewer.fixIssue` | 修复单条问题 | — |
|
||||
| `codeReviewer.fixAll` | 批量修复 | — |
|
||||
| `codeReviewer.openSetup` | 打开设置面板(侧边栏) | — |
|
||||
|
||||
**package.json 需添加的贡献点**:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"viewsContainers": {
|
||||
"activitybar": [{
|
||||
"id": "code-reviewer",
|
||||
"title": "CodeGuard 代码审查",
|
||||
"icon": "images/icon.png"
|
||||
}]
|
||||
},
|
||||
"views": {
|
||||
"code-reviewer": [{
|
||||
"type": "tree",
|
||||
"id": "codeReviewer.setupView",
|
||||
"name": "设置"
|
||||
}]
|
||||
},
|
||||
"menus": {
|
||||
"editor/context": [
|
||||
{ "command": "codeReviewer.review", "group": "navigation" },
|
||||
{ "command": "codeReviewer.reviewSelection", "when": "editorHasSelection" }
|
||||
]
|
||||
},
|
||||
"configuration": {
|
||||
// 见 Phase 1 配置 Key 映射
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5.2: 设置面板(侧边栏 TreeView)
|
||||
|
||||
**参考设计**: §13
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/views/setupView.ts` | 新建 | `SetupViewProvider implements vscode.TreeDataProvider` |
|
||||
|
||||
**面板区域**:
|
||||
|
||||
| 区域 | 元素 | 数据源 |
|
||||
|------|------|--------|
|
||||
| 快速开始 | 三步引导(①②③ 圆形序号,完成变紫色) | 实时状态 |
|
||||
| 审核引擎 | 三个模块标签(紫/琥珀/绿圆点) | 静态 |
|
||||
| AI 模型配置 | 提供商下拉框 + 模型下拉框 + 状态标签 | `ai.provider` / `ai.model` |
|
||||
| API Key | 密码输入框 + Base URL + 状态标签 | SecretStorage / `ai.endpoint` |
|
||||
| 输出语言 | 下拉框 | `ai.outputLanguage` |
|
||||
| 自定义规则 | 开关列表 + 添加行 | `.code-review/config.yaml` |
|
||||
|
||||
**快速引导**:
|
||||
|
||||
| 步骤 | 触发条件 | 效果 |
|
||||
|------|----------|------|
|
||||
| ① 配置 AI 模型及 API Key | API Key 有值 | 序号变紫色 |
|
||||
| ② 启用自定义规则 | 任意规则开关打开 | 序号变紫色 |
|
||||
| ③ 保存并测试连接 | 前两步完成 + 测试成功 | 序号变紫色 |
|
||||
|
||||
### Phase 5.3: 审查面板(Webview)
|
||||
|
||||
**参考设计**: §7
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/panel/webview.ts` | 新建 | `ReviewPanel` 类(Webview 管理) |
|
||||
|
||||
**面板布局**:
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 📋 代码审查报告 │
|
||||
│ xxx.java · Java · 3.2s │
|
||||
├─────────────────────────────────────┤
|
||||
│ [总计:10] [错误:3] [警告:5] [建议:2] │
|
||||
├─────────────────────────────────────┤
|
||||
│ 🔧 静态分析 | 📋 自定义规则 | 🤖 AI │
|
||||
├─────────────────────────────────────┤
|
||||
│ 问题列表(可跳转、修复、忽略) │
|
||||
├─────────────────────────────────────┤
|
||||
│ [🔄 重新审查] [📄 导出] [⚙️ 设置] │
|
||||
│ [↩ 撤销上次修复] │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**消息协议**:
|
||||
|
||||
```typescript
|
||||
// Webview → Extension
|
||||
interface PanelMessage {
|
||||
type: 'navigate' | 'rerun' | 'export' | 'settings' | 'fix' | 'fixAll';
|
||||
line?: number;
|
||||
ruleId?: string;
|
||||
source?: 'linter' | 'custom' | 'ai';
|
||||
}
|
||||
|
||||
// Extension → Webview
|
||||
interface PanelUpdate {
|
||||
report: MergedReport;
|
||||
degraded: boolean;
|
||||
errors: string[];
|
||||
hasSnapshot: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: 构建与测试
|
||||
|
||||
**依赖**: Phase 5
|
||||
**参考设计**: §15, §16, §17
|
||||
|
||||
### Phase 6.1: 构建脚本
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `scripts/build.mjs` | 新建 | esbuild 打包(bundle + minify + external vscode) |
|
||||
| `package.json` | 修改 | 更新 vscode:prepublish / 添加 build 脚本 |
|
||||
|
||||
**esbuild 配置**:
|
||||
```javascript
|
||||
entryPoints: ['src/extension.ts'],
|
||||
bundle: true,
|
||||
outfile: 'out/extension.js',
|
||||
external: ['vscode'],
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
```
|
||||
|
||||
### Phase 6.2: 工具脚本
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `scripts/download-pmd.mjs` | 新建 | 自动下载 PMD 7.26.0 JAR 依赖 |
|
||||
| `scripts/package-prod.mjs` | 新建 | 生产打包:esbuild + copy assets + vsce |
|
||||
|
||||
### Phase 6.3: 测试
|
||||
|
||||
| 文件 | 操作 | 内容 |
|
||||
|------|------|------|
|
||||
| `src/test/fixtures/` | 新建 | 测试用代码样本目录 |
|
||||
| `src/test/adapter.test.ts` | 新建 | ESLint/Stylelint/sql-lint 输出解析测试 |
|
||||
| `src/test/config.test.ts` | 新建 | 配置读取正确性测试 |
|
||||
| `src/test/merger.test.ts` | 新建 | 多源结果合并 + 统计计算测试 |
|
||||
| `src/test/pipeline.test.ts` | 新建 | 全链路集成测试 |
|
||||
| `src/test/extension.test.ts` | 修改 | 替换占位测试 |
|
||||
|
||||
---
|
||||
|
||||
## 文件变更总数
|
||||
|
||||
| Phase | 新建 | 修改 | 合计 |
|
||||
|-------|------|------|------|
|
||||
| Phase 1 | 6 | 0 | 6 |
|
||||
| Phase 2 | 10 | 0 | 10 |
|
||||
| Phase 3 | 1 | 0 | 1 |
|
||||
| Phase 4 | 9 | 0 | 9 |
|
||||
| Phase 5 | 3 | 2 | 5 |
|
||||
| Phase 6 | 4 | 2 | 6 |
|
||||
| **总计** | **33** | **4** | **37** |
|
||||
|
||||
---
|
||||
|
||||
## npm 依赖(需在 Phase 2/4 时添加)
|
||||
|
||||
### 运行时依赖
|
||||
|
||||
```json
|
||||
{
|
||||
"eslint": "^9.39.3",
|
||||
"stylelint": "^17.14.0",
|
||||
"node-sql-parser": "^5.4.0"
|
||||
}
|
||||
```
|
||||
|
||||
### 开发依赖(新增)
|
||||
|
||||
```json
|
||||
{
|
||||
"esbuild": "^0.28.1",
|
||||
"@vscode/vsce": "^3.9.2"
|
||||
}
|
||||
```
|
||||
|
||||
### 外部工具
|
||||
|
||||
| 工具 | 版本 | 用途 | 获取方式 |
|
||||
|------|------|------|----------|
|
||||
| PMD | 7.26.0 | Java/JSP 静态分析 | 自带 `jars/pmd/lib/` |
|
||||
| Java | 11+ | PMD 运行环境 | 用户环境 |
|
||||
| sqlfluff | — | SQL 静态分析 | 用户环境(pip install sqlfluff) |
|
||||
@@ -0,0 +1,206 @@
|
||||
# Step 01 — Phase 1: 基础层
|
||||
|
||||
**依赖**: 无
|
||||
**参考设计**: §3.2, §6.4, §13.2
|
||||
|
||||
## 目标
|
||||
|
||||
建立公共类型定义和配置管理,为所有上层模块提供基础能力。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/types.ts` | 公共类型定义 |
|
||||
| 2 | `src/config/ai.ts` | AI 配置 getter |
|
||||
| 3 | `src/config/linter.ts` | Linter 配置 getter |
|
||||
| 4 | `src/config/fixer.ts` | 修复器配置 getter |
|
||||
| 5 | `src/config/secret.ts` | API Key SecretStorage |
|
||||
| 6 | `src/config/index.ts` | 统一导出 |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/types.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
|
||||
|
||||
export interface LinterDiagnostic {
|
||||
severity: Severity;
|
||||
ruleId: string;
|
||||
message: string;
|
||||
range: vscode.Range;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
status: AdapterStatus;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface LinterAdapter {
|
||||
id: string;
|
||||
supportedLanguages: string[];
|
||||
check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult>;
|
||||
isAvailable(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/config/ai.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface AIConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
endpoint: string;
|
||||
temperature: number;
|
||||
timeout: number;
|
||||
outputLanguage: string;
|
||||
}
|
||||
|
||||
export function getAIConfig(): AIConfig {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
return {
|
||||
provider: config.get<string>('ai.provider', 'deepseek'),
|
||||
model: config.get<string>('ai.model', 'deepseek-chat'),
|
||||
endpoint: config.get<string>('ai.endpoint', 'https://api.deepseek.com/v1'),
|
||||
temperature: config.get<number>('ai.temperature', 0.2),
|
||||
timeout: config.get<number>('ai.timeout', 300),
|
||||
outputLanguage: config.get<string>('ai.outputLanguage', 'zh-CN'),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**配置 Key**(前缀 `vscode-code-reviewer.`):
|
||||
|
||||
| Key | 类型 | 默认值 |
|
||||
|-----|------|--------|
|
||||
| `ai.provider` | enum | `deepseek` |
|
||||
| `ai.model` | string | `deepseek-chat` |
|
||||
| `ai.endpoint` | string | `https://api.deepseek.com/v1` |
|
||||
| `ai.temperature` | number | 0.2 |
|
||||
| `ai.timeout` | number | 300 |
|
||||
| `ai.outputLanguage` | string | `zh-CN` |
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/config/linter.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface LinterConfig {
|
||||
languageMap: Record<string, string>;
|
||||
pmdJarPath: string;
|
||||
pmdRulesetPath: string;
|
||||
pmdJspRulesetPath: string;
|
||||
sqlLintConfigFile: string;
|
||||
}
|
||||
|
||||
export function getLinterConfig(): LinterConfig {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
return {
|
||||
languageMap: {
|
||||
javascript: config.get<string>('linters.javascript', 'eslint'),
|
||||
typescript: config.get<string>('linters.typescript', 'eslint'),
|
||||
java: config.get<string>('linters.java', 'pmd'),
|
||||
jsp: config.get<string>('linters.jsp', 'jsp'),
|
||||
css: config.get<string>('linters.css', 'stylelint'),
|
||||
sql: config.get<string>('linters.sql', 'sql-lint'),
|
||||
plsql: config.get<string>('linters.plsql', 'sql-lint'),
|
||||
},
|
||||
pmdJarPath: config.get<string>('pmd.jarPath', ''),
|
||||
pmdRulesetPath: config.get<string>('pmd.rulesetPath', ''),
|
||||
pmdJspRulesetPath: config.get<string>('pmd.jspRulesetPath', ''),
|
||||
sqlLintConfigFile: config.get<string>('sql-lint.configFile', ''),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
| Key | 类型 | 默认值 | enum |
|
||||
|-----|------|--------|------|
|
||||
| `linters.javascript` | enum | `eslint` | `""` / `eslint` |
|
||||
| `linters.typescript` | enum | `eslint` | `""` / `eslint` |
|
||||
| `linters.java` | enum | `pmd` | `""` / `pmd` |
|
||||
| `linters.jsp` | enum | `jsp` | `""` / `jsp` |
|
||||
| `linters.css` | enum | `stylelint` | `""` / `stylelint` |
|
||||
| `linters.sql` | enum | `sql-lint` | `""` / `sql-lint` |
|
||||
| `linters.plsql` | enum | `sql-lint` | `""` / `sql-lint` |
|
||||
| `pmd.jarPath` | string | `""` | — |
|
||||
| `pmd.rulesetPath` | string | `""` | — |
|
||||
| `pmd.jspRulesetPath` | string | `""` | — |
|
||||
| `sql-lint.configFile` | string | `""` | — |
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/config/fixer.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface FixerConfig {
|
||||
contextLines: number;
|
||||
}
|
||||
|
||||
export function getFixerConfig(): FixerConfig {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
return {
|
||||
contextLines: config.get<number>('fixer.contextLines', 5),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `src/config/secret.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const SECRET_KEY = 'vscode-code-reviewer.apiKey';
|
||||
|
||||
export async function getApiKey(context: vscode.ExtensionContext): Promise<string | undefined> {
|
||||
return context.secrets.get(SECRET_KEY);
|
||||
}
|
||||
|
||||
export async function setApiKey(context: vscode.ExtensionContext, key: string): Promise<void> {
|
||||
await context.secrets.store(SECRET_KEY, key);
|
||||
}
|
||||
|
||||
export async function deleteApiKey(context: vscode.ExtensionContext): Promise<void> {
|
||||
await context.secrets.delete(SECRET_KEY);
|
||||
}
|
||||
|
||||
export async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise<boolean> {
|
||||
const key = await getApiKey(context);
|
||||
return !!key;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. `src/config/index.ts`
|
||||
|
||||
```typescript
|
||||
export { getAIConfig, AIConfig } from './ai';
|
||||
export { getLinterConfig, LinterConfig } from './linter';
|
||||
export { getFixerConfig, FixerConfig } from './fixer';
|
||||
export { getApiKey, setApiKey, deleteApiKey, isApiKeyConfigured } from './secret';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 6 个文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,34 @@
|
||||
# Step 02 — Phase 2.1: 适配器接口
|
||||
|
||||
**依赖**: Step 01
|
||||
**参考设计**: §3.2
|
||||
|
||||
## 目标
|
||||
|
||||
将 `LinterAdapter` 接口从 `types.ts` 精化到适配器模块中,作为所有适配器的统一接口。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/adapter.ts` | 重新导出/精化 LinterAdapter 接口 |
|
||||
|
||||
---
|
||||
|
||||
## `src/adapters/adapter.ts`
|
||||
|
||||
```typescript
|
||||
import { LinterAdapter } from '../types';
|
||||
|
||||
export type { LinterAdapter };
|
||||
export type { LinterDiagnostic, AdapterResult, AdapterStatus } from '../types';
|
||||
```
|
||||
|
||||
> 如果后续适配器需要额外的共享类型,在此文件中扩展。
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
@@ -0,0 +1,94 @@
|
||||
# Step 03 — Phase 2.2: ESLint 适配器
|
||||
|
||||
**依赖**: Step 02
|
||||
**参考设计**: §3.3
|
||||
|
||||
## 目标
|
||||
|
||||
实现 JavaScript / TypeScript 的 ESLint 适配器,用 eslint npm 包直接检查代码。
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
npm install --save eslint@^9.39.3
|
||||
```
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/eslint.ts` | `ESLintAdapter` |
|
||||
|
||||
---
|
||||
|
||||
## `src/adapters/eslint.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
|
||||
export class ESLintAdapter implements LinterAdapter {
|
||||
id = 'eslint';
|
||||
supportedLanguages = ['javascript', 'typescript'];
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const code = document.getText();
|
||||
const { ESLint } = await import('eslint');
|
||||
const eslint = new ESLint({ cwd: workingDir });
|
||||
const results = await eslint.lintText(code, { filePath: document.uri.fsPath });
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const result of results) {
|
||||
for (const msg of result.messages) {
|
||||
if (!msg.ruleId) { continue; }
|
||||
const line = Math.max(0, (msg.line ?? 1) - 1);
|
||||
const column = Math.max(0, (msg.column ?? 1) - 1);
|
||||
const range = new vscode.Range(line, column, line, column + 1);
|
||||
diagnostics.push({
|
||||
severity: msg.severity === 2 ? 'error' : msg.severity === 1 ? 'warning' : 'info',
|
||||
ruleId: `eslint:${msg.ruleId}`,
|
||||
message: msg.message,
|
||||
range,
|
||||
suggestion: msg.fix ? msg.fix.text : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'ESLint 未安装,请执行 npm install eslint' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
require.resolve('eslint');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `check()`: 使用 `ESLint.lintText(code, { filePath })` 直接检查代码文本,支持虚拟文档
|
||||
- 将 ESLint 严重级别映射为 `error(2) → 'error'`, `warning(1) → 'warning'`, `0 → 'info'`
|
||||
- ruleId 加前缀 `eslint:`,如 `eslint:no-unused-vars`
|
||||
- `isAvailable()`: 通过 `require.resolve` 检测 eslint 是否已安装
|
||||
- 错误处理:模块未找到 → `tool-unavailable`,其他错误 → `execution-failed`
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,92 @@
|
||||
# Step 04 — Phase 2.3: Stylelint 适配器
|
||||
|
||||
**依赖**: Step 02
|
||||
**参考设计**: §3.3
|
||||
|
||||
## 目标
|
||||
|
||||
实现 CSS 的 Stylelint 适配器,用 stylelint npm 包直接检查代码。
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
npm install --save stylelint@^17.14.0
|
||||
```
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/stylelint.ts` | `StylelintAdapter` |
|
||||
|
||||
---
|
||||
|
||||
## `src/adapters/stylelint.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
|
||||
export class StylelintAdapter implements LinterAdapter {
|
||||
id = 'stylelint';
|
||||
supportedLanguages = ['css'];
|
||||
|
||||
async check(document: vscode.TextDocument, _workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const code = document.getText();
|
||||
const stylelint = await import('stylelint');
|
||||
const result = await stylelint.default.lint({
|
||||
code,
|
||||
codeFilename: document.uri.fsPath,
|
||||
config: { rules: {} },
|
||||
});
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const warning of result.results[0]?.warnings ?? []) {
|
||||
const line = Math.max(0, (warning.line ?? 1) - 1);
|
||||
const column = Math.max(0, (warning.column ?? 1) - 1);
|
||||
const range = new vscode.Range(line, column, line, column + 1);
|
||||
diagnostics.push({
|
||||
severity: warning.severity === 'error' ? 'error' : 'warning',
|
||||
ruleId: `stylelint:${warning.rule}`,
|
||||
message: warning.text,
|
||||
range,
|
||||
});
|
||||
}
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Stylelint 未安装,请执行 npm install stylelint' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
require.resolve('stylelint');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `check()`: 使用 `stylelint.lint({ code, codeFilename })` 直接检查代码文本,支持虚拟文档
|
||||
- ruleId 加前缀 `stylelint:`,如 `stylelint:color-no-invalid-hex`
|
||||
- `isAvailable()`: 通过 `require.resolve` 检测 stylelint 是否已安装
|
||||
- 错误处理:模块未找到 → `tool-unavailable`,其他错误 → `execution-failed`
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,119 @@
|
||||
# Step 05 — Phase 2.4: sql-lint 适配器
|
||||
|
||||
**依赖**: Step 02
|
||||
**参考设计**: §3.3
|
||||
|
||||
## 目标
|
||||
|
||||
实现 SQL / PL/SQL 的适配器,通过 CLI 子进程调用 sqlfluff。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/sql-lint.ts` | `SqlLintAdapter` |
|
||||
|
||||
---
|
||||
|
||||
## `src/adapters/sql-lint.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import * as child_process from 'child_process';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { getLinterConfig } from '../config';
|
||||
|
||||
const DIALECT_MAP: Record<string, string> = {
|
||||
sql: 'ansi',
|
||||
plsql: 'postgres',
|
||||
};
|
||||
|
||||
export class SqlLintAdapter implements LinterAdapter {
|
||||
id = 'sql-lint';
|
||||
supportedLanguages = ['sql', 'plsql'];
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const code = document.getText();
|
||||
const languageId = document.languageId;
|
||||
const dialect = DIALECT_MAP[languageId] ?? 'ansi';
|
||||
const config = getLinterConfig();
|
||||
|
||||
const args = ['lint', '--format', 'json', '--dialect', dialect, '-'];
|
||||
if (config.sqlLintConfigFile) {
|
||||
args.push('--config', config.sqlLintConfigFile);
|
||||
}
|
||||
|
||||
const result = await this.execSqlfluff(code, args, workingDir);
|
||||
const output = JSON.parse(result);
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const violation of output) {
|
||||
const line = Math.max(0, (violation.line_no ?? violation.line_pos ?? 1) - 1);
|
||||
const col = Math.max(0, (violation.line_pos ?? 1) - 1);
|
||||
const range = new vscode.Range(line, col, line, col + 1);
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
ruleId: `sql-lint:${violation.code ?? violation.rule ?? 'unknown'}`,
|
||||
message: violation.description ?? violation.message ?? '',
|
||||
range,
|
||||
});
|
||||
}
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('ENOENT') || message.includes('not found')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'sqlfluff 未安装,请执行 pip install sqlfluff' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
private execSqlfluff(code: string, args: string[], cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = child_process.spawn('sqlfluff', args, { cwd });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 || stdout.length > 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(stderr || `sqlfluff exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
proc.stdin.write(code);
|
||||
proc.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
child_process.execSync('sqlfluff --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `check()`: spawn `sqlfluff lint --format json --dialect <dialect> -`,stdin 传入代码
|
||||
- 方言映射:`sql → ansi`, `plsql → postgres`
|
||||
- ruleId 加前缀 `sql-lint:`
|
||||
- `isAvailable()`: `sqlfluff --version` 检测
|
||||
- 通过 stdin 传代码,无需真实文件(支持虚拟文档)
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,259 @@
|
||||
# Step 06 — Phase 2.5: PMD 适配器
|
||||
|
||||
**依赖**: Step 02
|
||||
**参考设计**: §3.3, §3.4
|
||||
|
||||
## 目标
|
||||
|
||||
实现 Java 的 PMD 适配器。通过 Java 子进程调用 `PmdRunner` 包装器,支持 stdin 传入代码(虚拟文档)。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/pmd.ts` | `PmdAdapter` |
|
||||
| 2 | `jars/pmd/PmdRunner.java` | PMD Java 包装器(stdin + JSON 输出) |
|
||||
| 3 | `jars/pmd/pmd-java-ruleset.xml` | Java 规则集 |
|
||||
| 4 | `jars/pmd/pmd-jsp-ruleset.xml` | JSP 规则集 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
jars/pmd/
|
||||
├── lib/ # PMD 依赖 JAR(需下载)
|
||||
├── PmdRunner.java # 包装器
|
||||
├── pmd-java-ruleset.xml
|
||||
└── pmd-jsp-ruleset.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `jars/pmd/PmdRunner.java`
|
||||
|
||||
```java
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import net.sourceforge.pmd.*;
|
||||
import net.sourceforge.pmd.renderers.*;
|
||||
|
||||
public class PmdRunner {
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length < 2) {
|
||||
System.err.println("Usage: PmdRunner <filePath|- for stdin> <rulesetPath>");
|
||||
System.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
String filePath = args[0];
|
||||
String rulesetPath = args[1];
|
||||
|
||||
Path tempFile = null;
|
||||
if ("-".equals(filePath)) {
|
||||
String code = new String(System.in.readAllBytes());
|
||||
tempFile = Files.createTempFile("pmd-stdin-", ".java");
|
||||
Files.writeString(tempFile, code);
|
||||
filePath = tempFile.toString();
|
||||
}
|
||||
|
||||
try {
|
||||
PMDConfiguration config = new PMDConfiguration();
|
||||
config.setInputFilePath(Path.of(filePath));
|
||||
config.addRuleSet(Path.of(rulesetPath));
|
||||
config.setReportFormat("json");
|
||||
|
||||
StringWriter writer = new StringWriter();
|
||||
config.setReportWriter(writer);
|
||||
|
||||
PmdAnalysis pmd = PmdAnalysis.create(config);
|
||||
pmd.performAnalysis();
|
||||
|
||||
System.out.print(writer.toString());
|
||||
} finally {
|
||||
if (tempFile != null) {
|
||||
Files.deleteIfExists(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**编译命令**(classpath 需指向 `jars/pmd/lib/*`):
|
||||
|
||||
```bash
|
||||
javac -cp "jars/pmd/lib/*" -d jars/pmd/ jars/pmd/PmdRunner.java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `jars/pmd/pmd-java-ruleset.xml`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="Java Rules"
|
||||
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
|
||||
<description>Java Code Review Rules</description>
|
||||
<rule ref="category/java/bestpractices.xml"/>
|
||||
<rule ref="category/java/codestyle.xml"/>
|
||||
<rule ref="category/java/design.xml"/>
|
||||
<rule ref="category/java/errorprone.xml"/>
|
||||
<rule ref="category/java/performance.xml"/>
|
||||
<rule ref="category/java/security.xml"/>
|
||||
</ruleset>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `jars/pmd/pmd-jsp-ruleset.xml`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="JSP Rules"
|
||||
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
|
||||
<description>JSP Code Review Rules</description>
|
||||
<rule ref="category/jsp/bestpractices.xml"/>
|
||||
<rule ref="category/jsp/codestyle.xml"/>
|
||||
<rule ref="category/jsp/design.xml"/>
|
||||
<rule ref="category/jsp/errorprone.xml"/>
|
||||
</ruleset>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/adapters/pmd.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as child_process from 'child_process';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { getLinterConfig } from '../config';
|
||||
|
||||
export class PmdAdapter implements LinterAdapter {
|
||||
id = 'pmd';
|
||||
supportedLanguages = ['java'];
|
||||
|
||||
private getPmdLibClasspath(): string {
|
||||
const extRoot = vscode.extensions.getExtension?.('vscode-code-reviewer')?.extensionPath
|
||||
?? path.join(__dirname, '..', '..');
|
||||
const pmdLib = path.join(extRoot, 'jars', 'pmd', 'lib');
|
||||
return path.join(pmdLib, '*');
|
||||
}
|
||||
|
||||
private getPmdRunnerClasspath(): string {
|
||||
const extRoot = vscode.extensions.getExtension?.('vscode-code-reviewer')?.extensionPath
|
||||
?? path.join(__dirname, '..', '..');
|
||||
return path.join(extRoot, 'jars', 'pmd');
|
||||
}
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const config = getLinterConfig();
|
||||
const ruleset = config.pmdRulesetPath
|
||||
|| path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
|
||||
const classpath = `${this.getPmdLibClasspath()};${this.getPmdRunnerClasspath()}`;
|
||||
|
||||
const isVirtual = document.uri.scheme === 'untitled';
|
||||
const fileArg = isVirtual ? '-' : document.uri.fsPath;
|
||||
|
||||
const javaArgs = ['-cp', classpath, 'PmdRunner', fileArg, ruleset];
|
||||
const result = await this.execPmd(javaArgs, isVirtual ? document.getText() : null, workingDir);
|
||||
|
||||
const diagnostics = this.parsePmdOutput(result);
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('ENOENT') || message.includes('java')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Java 11+ 未安装或不在 PATH 中' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
private execPmd(args: string[], stdinInput: string | null, cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = child_process.spawn('java', args, { cwd });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 || code === 4 || stdout.length > 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(stderr || `PMD exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
if (stdinInput !== null) {
|
||||
proc.stdin.write(stdinInput);
|
||||
proc.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private parsePmdOutput(output: string): LinterDiagnostic[] {
|
||||
if (!output.trim()) { return []; }
|
||||
try {
|
||||
const data = JSON.parse(output);
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const file of data.files ?? []) {
|
||||
for (const violation of file.violations ?? []) {
|
||||
const line = Math.max(0, (violation.beginline ?? 1) - 1);
|
||||
const col = Math.max(0, (violation.begincolumn ?? 1) - 1);
|
||||
const endCol = Math.max(col, (violation.endcolumn ?? col + 1) - 1);
|
||||
const range = new vscode.Range(line, col, line, endCol);
|
||||
diagnostics.push({
|
||||
severity: this.mapPriority(violation.priority),
|
||||
ruleId: `pmd:${violation.rule}`,
|
||||
message: violation.description ?? '',
|
||||
range,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapPriority(priority: number): 'error' | 'warning' | 'info' {
|
||||
if (priority <= 2) { return 'error'; }
|
||||
if (priority === 3) { return 'warning'; }
|
||||
return 'info';
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
child_process.execSync('java -version 2>&1', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- 虚拟文档通过 stdin 传入代码(`PmdRunner "-" ruleset`)
|
||||
- 真实文件传文件路径(`PmdRunner filePath ruleset`)
|
||||
- classpath 使用 `;` 分隔(Windows 目标平台)
|
||||
- isVirtual 判断依据:`document.uri.scheme === 'untitled'`
|
||||
- PMD exit code 0 和 4 都视为成功(4 表示有 violations 但执行成功)
|
||||
- isAvailable 检测 Java 是否可用
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 4 个文件创建完成
|
||||
- [ ] PMD JAR 依赖已下载到 `jars/pmd/lib/`
|
||||
- [ ] `PmdRunner.java` 编译成功
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,212 @@
|
||||
# Step 07 — Phase 2.6: JSP 适配器
|
||||
|
||||
**依赖**: Step 03, 04, 06(需要 PMD / ESLint / Stylelint 适配器)
|
||||
**参考设计**: §3.5
|
||||
|
||||
## 目标
|
||||
|
||||
实现 JSP 组合适配器。将 JSP 文件拆分后分发检查,合并结果。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/jsp/jsp-extractor.ts` | JSP 内嵌代码块提取器 |
|
||||
| 2 | `src/adapters/jsp.ts` | `JspAdapter`(组合适配器) |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/jsp/jsp-extractor.ts`
|
||||
|
||||
```typescript
|
||||
export interface JspSection {
|
||||
language: 'javascript' | 'css' | 'java';
|
||||
code: string;
|
||||
lineOffset: number;
|
||||
sourceStart: number;
|
||||
sourceEnd: number;
|
||||
}
|
||||
|
||||
export function extractJspSections(content: string): JspSection[] {
|
||||
const sections: JspSection[] = [];
|
||||
|
||||
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = scriptRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'javascript',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
const styleRegex = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
|
||||
while ((match = styleRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'css',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
const scriptletRegex = /<%=?([\s\S]*?)%>/g;
|
||||
while ((match = scriptletRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'java',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/adapters/jsp.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { PmdAdapter } from './pmd';
|
||||
import { ESLintAdapter } from './eslint';
|
||||
import { StylelintAdapter } from './stylelint';
|
||||
import { extractJspSections, JspSection } from '../jsp/jsp-extractor';
|
||||
import { getLinterConfig } from '../config';
|
||||
|
||||
export class JspAdapter implements LinterAdapter {
|
||||
id = 'jsp';
|
||||
supportedLanguages = ['jsp'];
|
||||
|
||||
private pmdAdapter = new PmdAdapter();
|
||||
private eslintAdapter = new ESLintAdapter();
|
||||
private stylelintAdapter = new StylelintAdapter();
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
const allDiagnostics: LinterDiagnostic[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const config = getLinterConfig();
|
||||
const jsEnabled = config.languageMap.javascript !== '';
|
||||
const cssEnabled = config.languageMap.css !== '';
|
||||
const javaEnabled = config.languageMap.java !== '';
|
||||
|
||||
const pmdResult = await this.pmdAdapter.check(document, workingDir);
|
||||
allDiagnostics.push(...pmdResult.diagnostics);
|
||||
if (pmdResult.status !== 'ok') {
|
||||
errors.push(`PMD: ${pmdResult.errorMessage ?? pmdResult.status}`);
|
||||
}
|
||||
|
||||
const sections = extractJspSections(document.getText());
|
||||
|
||||
for (const section of sections) {
|
||||
const isEnabled = (section.language === 'javascript' && jsEnabled)
|
||||
|| (section.language === 'css' && cssEnabled)
|
||||
|| (section.language === 'java' && javaEnabled);
|
||||
if (!isEnabled) { continue; }
|
||||
|
||||
const adapter = this.getAdapter(section.language);
|
||||
if (!adapter) { continue; }
|
||||
|
||||
try {
|
||||
const virtualDoc = await vscode.workspace.openTextDocument({
|
||||
content: section.code,
|
||||
language: section.language,
|
||||
});
|
||||
const result = await adapter.check(virtualDoc, workingDir);
|
||||
|
||||
for (const diag of result.diagnostics) {
|
||||
const adjustedRange = new vscode.Range(
|
||||
diag.range.start.line + section.lineOffset,
|
||||
diag.range.start.character,
|
||||
diag.range.end.line + section.lineOffset,
|
||||
diag.range.end.character,
|
||||
);
|
||||
allDiagnostics.push({ ...diag, range: adjustedRange });
|
||||
}
|
||||
|
||||
if (result.status !== 'ok') {
|
||||
errors.push(`${section.language}: ${result.errorMessage ?? result.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`${section.language}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = errors.length > 0;
|
||||
const hasUnavailable = errors.some(e => e.includes('未安装') || e.includes('tool-unavailable'));
|
||||
|
||||
return {
|
||||
diagnostics: allDiagnostics,
|
||||
status: hasErrors ? (hasUnavailable ? 'tool-unavailable' : 'execution-failed') : 'ok',
|
||||
errorMessage: errors.join('; '),
|
||||
};
|
||||
}
|
||||
|
||||
private getAdapter(language: string): LinterAdapter | null {
|
||||
switch (language) {
|
||||
case 'javascript': return this.eslintAdapter;
|
||||
case 'css': return this.stylelintAdapter;
|
||||
case 'java': return this.pmdAdapter;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
**JspAdapter.check() 三步流程**:
|
||||
|
||||
```
|
||||
1. 调用 PmdAdapter.check(document) → JSP 规范检查
|
||||
2. extractJspSections(document.getText()) → 提取内嵌代码块
|
||||
3. 对每个 section:
|
||||
a. 按 language 选择对应适配器(ESLint/Stylelint/PMD)
|
||||
b. 创建虚拟文档 (vscode.workspace.openTextDocument)
|
||||
c. 调用 adapter.check(virtualDoc)
|
||||
d. 修正行号偏移 (section.lineOffset)
|
||||
4. 合并所有结果
|
||||
```
|
||||
|
||||
**提取器正则规则**:
|
||||
|
||||
| 代码块类型 | 正则 | 目标适配器 |
|
||||
|-----------|------|-----------|
|
||||
| `<script>` | `/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi` | ESLint |
|
||||
| `<style>` | `/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi` | Stylelint |
|
||||
| `<%=? %>` | `/<%=?([\s\S]*?)%>/g` | PMD |
|
||||
|
||||
- 行号偏移修正:`lineOffset` 从提取位置之前的换行符数计算
|
||||
- 根据 `linters.<language>` 配置决定是否启用对应子适配器
|
||||
- 组合状态:任一子适配器不可用则标记 `tool-unavailable`
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 2 个文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,139 @@
|
||||
# Step 08 — Phase 3: 编排器 Orchestrator
|
||||
|
||||
**依赖**: Step 02~07(所有适配器)
|
||||
**参考设计**: §3.6, §4
|
||||
|
||||
## 目标
|
||||
|
||||
实现单文件保存触发、多适配器调度、500ms debounce、结果聚合的编排器。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/orchestrator/orchestrator.ts` | `Orchestrator` 类 |
|
||||
|
||||
---
|
||||
|
||||
## `src/orchestrator/orchestrator.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { getLinterConfig } from '../config';
|
||||
import { ESLintAdapter } from '../adapters/eslint';
|
||||
import { PmdAdapter } from '../adapters/pmd';
|
||||
import { StylelintAdapter } from '../adapters/stylelint';
|
||||
import { SqlLintAdapter } from '../adapters/sql-lint';
|
||||
import { JspAdapter } from '../adapters/jsp';
|
||||
|
||||
export interface StaticAnalysisResult {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
errors: string[];
|
||||
adapterIds: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class Orchestrator {
|
||||
private adapters: LinterAdapter[];
|
||||
|
||||
constructor() {
|
||||
this.adapters = [
|
||||
new ESLintAdapter(),
|
||||
new PmdAdapter(),
|
||||
new StylelintAdapter(),
|
||||
new SqlLintAdapter(),
|
||||
new JspAdapter(),
|
||||
];
|
||||
}
|
||||
|
||||
getAdapterMap(): Map<string, LinterAdapter> {
|
||||
const map = new Map<string, LinterAdapter>();
|
||||
for (const adapter of this.adapters) {
|
||||
for (const lang of adapter.supportedLanguages) {
|
||||
map.set(lang, adapter);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async runStaticAnalysis(
|
||||
document: vscode.TextDocument,
|
||||
workingDir: string
|
||||
): Promise<StaticAnalysisResult> {
|
||||
const startTime = Date.now();
|
||||
const config = getLinterConfig();
|
||||
const languageId = document.languageId;
|
||||
|
||||
const selectedLinter = config.languageMap[languageId];
|
||||
if (!selectedLinter || selectedLinter === '') {
|
||||
return { diagnostics: [], errors: [], adapterIds: [], duration: 0 };
|
||||
}
|
||||
|
||||
const adapter = this.adapters.find(a => a.id === selectedLinter);
|
||||
if (!adapter) {
|
||||
return {
|
||||
diagnostics: [],
|
||||
errors: [`未找到适配器: ${selectedLinter}`],
|
||||
adapterIds: [],
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await adapter.check(document, workingDir);
|
||||
const errors: string[] = [];
|
||||
if (result.status !== 'ok') {
|
||||
errors.push(`[${adapter.id}] ${result.errorMessage ?? result.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostics: result.diagnostics,
|
||||
errors,
|
||||
adapterIds: [adapter.id],
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
getAdaptersByIds(ids: string[]): LinterAdapter[] {
|
||||
return ids.map(id => this.adapters.find(a => a.id === id)).filter(Boolean) as LinterAdapter[];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## debounce 工具函数
|
||||
|
||||
在 `src/orchestrator/orchestrator.ts` 中或另外创建 `src/utils/debounce.ts`:
|
||||
|
||||
```typescript
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number): (...args: Parameters<T>) => void {
|
||||
let timer: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `getAdapterMap()`: 建立 language → adapter 的映射表
|
||||
- `runStaticAnalysis()`:
|
||||
1. 获取文档 languageId
|
||||
2. 查 `linters.<language>` 配置 → 确定使用的适配器
|
||||
3. 调用 `adapter.check(document, workingDir)`
|
||||
4. 收集结果,区分 status
|
||||
5. 返回聚合 `StaticAnalysisResult`
|
||||
- 硬编码注册 5 个适配器(工厂函数方式)
|
||||
- 禁用某语言的配置项为空字符串时不执行检查
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,204 @@
|
||||
# Step 09 — Phase 4.1: AI Provider 基础设施
|
||||
|
||||
**依赖**: Step 01(配置模块)
|
||||
**参考设计**: §5.2
|
||||
|
||||
## 目标
|
||||
|
||||
实现 AI Provider 策略模式基础设施:抽象基类、2 个 Provider 实现、工厂函数。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/ai/providers/base.ts` | `AIProvider` 抽象基类 + `ChatOptions` |
|
||||
| 2 | `src/ai/providers/deepseek.ts` | `DeepSeekProvider` |
|
||||
| 3 | `src/ai/providers/openai.ts` | `OpenAIProvider` |
|
||||
| 4 | `src/ai/factory.ts` | `createProvider()` 工厂 |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/ai/providers/base.ts`
|
||||
|
||||
```typescript
|
||||
export interface ChatOptions {
|
||||
model: string;
|
||||
temperature: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export abstract class AIProvider {
|
||||
abstract id: string;
|
||||
abstract name: string;
|
||||
|
||||
constructor(
|
||||
protected apiKey: string,
|
||||
protected endpoint: string
|
||||
) {}
|
||||
|
||||
abstract chat(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: ChatOptions
|
||||
): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/ai/providers/deepseek.ts`
|
||||
|
||||
```typescript
|
||||
import { AIProvider, ChatOptions } from './base';
|
||||
|
||||
export class DeepSeekProvider extends AIProvider {
|
||||
id = 'deepseek';
|
||||
name = 'DeepSeek';
|
||||
|
||||
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
|
||||
const url = `${this.endpoint}/chat/completions`;
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: options.model,
|
||||
temperature: options.temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new Error('API Key 无效,请重新设置');
|
||||
}
|
||||
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
return data.choices[0]?.message?.content ?? '';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/ai/providers/openai.ts`
|
||||
|
||||
```typescript
|
||||
import { AIProvider, ChatOptions } from './base';
|
||||
|
||||
export class OpenAIProvider extends AIProvider {
|
||||
id = 'openai';
|
||||
name = 'OpenAI';
|
||||
|
||||
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
|
||||
const url = `${this.endpoint}/chat/completions`;
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: options.model,
|
||||
temperature: options.temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new Error('API Key 无效,请重新设置');
|
||||
}
|
||||
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
return data.choices[0]?.message?.content ?? '';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/ai/factory.ts`
|
||||
|
||||
```typescript
|
||||
import { AIProvider } from './providers/base';
|
||||
import { DeepSeekProvider } from './providers/deepseek';
|
||||
import { OpenAIProvider } from './providers/openai';
|
||||
|
||||
type ProviderConstructor = new (apiKey: string, endpoint: string) => AIProvider;
|
||||
|
||||
const registry: Record<string, ProviderConstructor> = {
|
||||
deepseek: DeepSeekProvider,
|
||||
openai: OpenAIProvider,
|
||||
};
|
||||
|
||||
export function createProvider(providerId: string, apiKey: string, endpoint: string): AIProvider {
|
||||
const Cls = registry[providerId];
|
||||
if (!Cls) {
|
||||
throw new Error(`未知的 Provider: ${providerId}`);
|
||||
}
|
||||
return new Cls(apiKey, endpoint);
|
||||
}
|
||||
|
||||
export function getProviderIds(): string[] {
|
||||
return Object.keys(registry);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- Provider 统一实现 `chat(systemPrompt, userPrompt, options): Promise<string>`
|
||||
- 支持 AbortController 超时控制
|
||||
- HTTP 401 → 抛出 "API Key 无效" 错误
|
||||
- 工厂函数通过注册表字符串查找,便于添加新的 Provider
|
||||
- 两个 Provider 实现几乎相同(都是 openai 兼容 API),可考虑后续合并
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 4 个文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,253 @@
|
||||
# Step 10 — Phase 4.2: AI 引擎 + Schema
|
||||
|
||||
**依赖**: Step 09
|
||||
**参考设计**: §5.3, §5.4, §5.5, §5.6
|
||||
|
||||
## 目标
|
||||
|
||||
实现 AI 审查引擎:两并行请求(自定义规则评估 + 翻译深度审查)、JSON 解析、错误降级。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/ai/schema.ts` | AI 响应结构类型定义 |
|
||||
| 2 | `src/ai/engine.ts` | `runAIReview()` 主函数 |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/ai/schema.ts`
|
||||
|
||||
```typescript
|
||||
export interface TranslatedDiagnostic {
|
||||
originalRuleId: string;
|
||||
translatedMessage: string;
|
||||
translatedSuggestion: string;
|
||||
codeDiff?: string;
|
||||
}
|
||||
|
||||
export interface CustomRuleResult {
|
||||
ruleId: string;
|
||||
line: number;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AIFinding {
|
||||
ruleId: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: 'bug' | 'performance' | 'security' | 'style' | 'design';
|
||||
title: string;
|
||||
description: string;
|
||||
suggestion: string;
|
||||
codeDiff?: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface AIResponse {
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
customRuleResults: CustomRuleResult[];
|
||||
findings: AIFinding[];
|
||||
}
|
||||
|
||||
export interface AIEngineResult {
|
||||
customRuleResults: CustomRuleResult[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
findings: AIFinding[];
|
||||
degraded: boolean;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/ai/engine.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { AIProvider } from './providers/base';
|
||||
import { createProvider } from './factory';
|
||||
import { getAIConfig, getApiKey } from '../config';
|
||||
import { LinterDiagnostic } from '../types';
|
||||
import { CustomRule } from '../rules/yaml-parser';
|
||||
import {
|
||||
AIEngineResult,
|
||||
CustomRuleResult,
|
||||
TranslatedDiagnostic,
|
||||
AIFinding,
|
||||
} from './schema';
|
||||
|
||||
function buildCustomRulePrompt(rules: CustomRule[]): string {
|
||||
return rules.map(r =>
|
||||
`- [${r.id}] (${r.severity}) ${r.description}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function buildLinterDiagnosticsPrompt(diagnostics: LinterDiagnostic[]): string {
|
||||
return diagnostics.map(d =>
|
||||
`- [${d.ruleId}] L${d.range.start.line + 1}: ${d.message}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function addLineNumbers(code: string): string {
|
||||
return code.split('\n').map((line, i) => `${String(i + 1).padStart(4, ' ')}| ${line}`).join('\n');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
return JSON.parse(trimmed.substring(start, end + 1));
|
||||
}
|
||||
|
||||
const CUSTOM_RULE_SYSTEM_PROMPT = `你是代码规则审查员,只评估以下自定义规则是否被违反。
|
||||
理解语义而非文本匹配。
|
||||
仅输出 JSON,格式:
|
||||
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述" }] }
|
||||
如果没有违反任何规则,返回空数组。`;
|
||||
|
||||
const DEEP_REVIEW_SYSTEM_PROMPT = `你是资深代码审查专家,完成两个任务:
|
||||
1. 将英文静态分析结果翻译为输出语言,并补充修复建议
|
||||
2. 深度审查代码,发现静态分析未覆盖的问题
|
||||
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
|
||||
不要重复静态分析已报告的问题。
|
||||
|
||||
仅输出 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": 行号 }]
|
||||
}`;
|
||||
|
||||
export async function runAIReview(
|
||||
context: vscode.ExtensionContext,
|
||||
code: string,
|
||||
staticDiagnostics: LinterDiagnostic[],
|
||||
customRules: CustomRule[]
|
||||
): Promise<AIEngineResult> {
|
||||
const config = getAIConfig();
|
||||
const apiKey = await getApiKey(context);
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
findings: [],
|
||||
degraded: true,
|
||||
error: '未配置 API Key',
|
||||
};
|
||||
}
|
||||
|
||||
let provider: AIProvider;
|
||||
try {
|
||||
provider = createProvider(config.provider, apiKey, config.endpoint);
|
||||
} catch (err) {
|
||||
return {
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
findings: [],
|
||||
degraded: true,
|
||||
error: `创建 Provider 失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const options = {
|
||||
model: config.model,
|
||||
temperature: config.temperature,
|
||||
timeoutMs: config.timeout * 1000,
|
||||
};
|
||||
|
||||
const numberedCode = addLineNumbers(code);
|
||||
|
||||
const requestA =
|
||||
customRules.length > 0
|
||||
? provider.chat(
|
||||
CUSTOM_RULE_SYSTEM_PROMPT,
|
||||
`## 自定义规则\n${buildCustomRulePrompt(customRules)}\n\n## 代码(带行号)\n${numberedCode}`,
|
||||
options
|
||||
)
|
||||
: Promise.resolve('{}');
|
||||
|
||||
const requestB = provider.chat(
|
||||
`${DEEP_REVIEW_SYSTEM_PROMPT}\n输出语言:${config.outputLanguage}`,
|
||||
`## 代码(带行号)\n${numberedCode}\n\n## 静态分析结果(英文)\n${buildLinterDiagnosticsPrompt(staticDiagnostics)}`,
|
||||
options
|
||||
);
|
||||
|
||||
const [resultA, resultB] = await Promise.allSettled([requestA, requestB]);
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
let customRuleResults: CustomRuleResult[] = [];
|
||||
if (resultA.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = parseJsonResponse(resultA.value) as { customRuleResults?: CustomRuleResult[] };
|
||||
customRuleResults = (parsed.customRuleResults ?? []).map(r => ({
|
||||
...r,
|
||||
ruleId: `custom:${r.ruleId}`,
|
||||
}));
|
||||
} catch {
|
||||
errors.push('自定义规则响应解析失败');
|
||||
}
|
||||
} else {
|
||||
errors.push(`自定义规则请求失败: ${resultA.reason}`);
|
||||
}
|
||||
|
||||
let translatedDiagnostics: TranslatedDiagnostic[] = [];
|
||||
let findings: AIFinding[] = [];
|
||||
if (resultB.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = parseJsonResponse(resultB.value) as {
|
||||
translatedDiagnostics?: TranslatedDiagnostic[];
|
||||
findings?: AIFinding[];
|
||||
};
|
||||
translatedDiagnostics = parsed.translatedDiagnostics ?? [];
|
||||
findings = parsed.findings ?? [];
|
||||
} catch {
|
||||
errors.push('AI 审查响应解析失败');
|
||||
}
|
||||
} else {
|
||||
errors.push(`AI 审查请求失败: ${resultB.reason}`);
|
||||
}
|
||||
|
||||
const degraded = errors.length > 0;
|
||||
return {
|
||||
customRuleResults,
|
||||
translatedDiagnostics,
|
||||
findings,
|
||||
degraded,
|
||||
error: errors.join('; '),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
**两并行请求**:
|
||||
|
||||
| 请求 | 内容 | System Prompt |
|
||||
|------|------|---------------|
|
||||
| A | 自定义规则评估(需完整代码) | 只评估规则、语义理解、输出 JSON |
|
||||
| B | 翻译 + 深度审查(需静态分析结果) | 翻译诊断 + 深度审查、不重复静态分析、输出 JSON |
|
||||
|
||||
**降级策略**:
|
||||
1. `Promise.allSettled` 确保单请求失败不影响另一个
|
||||
2. API Key 未配置 → 所有 AI 功能降级
|
||||
3. JSON 解析失败 → 该请求降级,记录错误
|
||||
4. Provider 创建失败 → 全部降级
|
||||
|
||||
**Prompt 设计**:
|
||||
- 请求 A: 注入自定义规则的 description 列表
|
||||
- 请求 B: 注入静态分析英文诊断列表 + 输出语言配置
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 2 个文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,223 @@
|
||||
# Step 11 — Phase 4.3: 自定义规则系统
|
||||
|
||||
**依赖**: Step 01(配置模块)
|
||||
**参考设计**: §12
|
||||
|
||||
## 目标
|
||||
|
||||
实现 YAML 规则加载器,从 `.code-review/rules/*.yaml` 读取规则定义,按 `config.yaml` 过滤启用状态。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/rules/yaml-parser.ts` | `loadActiveRules()` + `parseConfigYaml()` |
|
||||
|
||||
## 现有参考文件(已存在,不修改)
|
||||
|
||||
```
|
||||
.code-review/
|
||||
├── config.yaml # 规则启用配置
|
||||
└── rules/
|
||||
├── security-rules.yaml # 安全规则
|
||||
└── coding-conventions.yaml # 编码规范
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `src/rules/yaml-parser.ts`
|
||||
|
||||
```typescript
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface CustomRule {
|
||||
id: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
description: string;
|
||||
message: string;
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
interface RuleYamlItem {
|
||||
id: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
message: string;
|
||||
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;
|
||||
let currentKey = '';
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) { continue; }
|
||||
|
||||
if (trimmed.startsWith('- ')) {
|
||||
if (current) { items.push(current); }
|
||||
current = {};
|
||||
const indentMatch = trimmed.match(/^- (\w[\w-]*)\s*:\s*(.*)$/);
|
||||
if (indentMatch) {
|
||||
const key = indentMatch[1];
|
||||
let value: unknown = indentMatch[2].trim();
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = value.slice(1, -1).split(',').map(s =>
|
||||
s.trim().replace(/^['"]|['"]$/g, '')
|
||||
);
|
||||
}
|
||||
current[key] = value;
|
||||
}
|
||||
} else if (current) {
|
||||
const propMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
|
||||
if (propMatch) {
|
||||
const key = propMatch[1];
|
||||
let value: unknown = propMatch[2].trim();
|
||||
if (!value || value === '[]') {
|
||||
value = [];
|
||||
} else if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = value.slice(1, -1).split(',').map(s =>
|
||||
s.trim().replace(/^['"]|['"]$/g, '')
|
||||
);
|
||||
}
|
||||
current[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current) { items.push(current); }
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseConfigYaml(content: string): RuleConfig {
|
||||
const config: RuleConfig = { enabled: [], rules: {} };
|
||||
let section: string | null = null;
|
||||
|
||||
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 'error' | 'warning' | 'info')
|
||||
: 'warning';
|
||||
|
||||
allRules.push({
|
||||
id: item.id,
|
||||
severity,
|
||||
description: item.description,
|
||||
message: item.message,
|
||||
languages: item.languages,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allRules;
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 如果项目已安装 `js-yaml` 依赖,可替换 `parseYamlSimple` 为 `yaml.load()`。此处用简易解析器避免额外依赖。
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
**加载流程**(按设计 §12.3):
|
||||
|
||||
```
|
||||
1. 扫描 .code-review/rules/*.yaml → 加载所有规则定义
|
||||
2. 读取 .code-review/config.yaml
|
||||
3. 按文件级 enabled 列表过滤
|
||||
4. 按规则级 rules.<id>.enabled 覆盖
|
||||
5. 返回激活的规则列表
|
||||
```
|
||||
|
||||
- `enabled`: 文件级启用列表,为空时默认全部启用
|
||||
- `rules.<id>.enabled: false`: 单独禁用特定规则
|
||||
- 规则 ID 不含 `custom:` 前缀,运行时由 AI 引擎自动拼接
|
||||
- 规则各字段与设计 §12.2 一致
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,112 @@
|
||||
# Step 12 — Phase 4.4: 结果合并
|
||||
|
||||
**依赖**: Step 08, 10, 11
|
||||
**参考设计**: §14.1
|
||||
|
||||
## 目标
|
||||
|
||||
将适配器诊断、AI 翻译诊断、自定义规则结果、AI 深度审查结果合并为一个 `MergedReport`。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/merger/merger.ts` | `mergeResults()` → `MergedReport` |
|
||||
|
||||
---
|
||||
|
||||
## `src/merger/merger.ts`
|
||||
|
||||
```typescript
|
||||
import { LinterDiagnostic, Severity } from '../types';
|
||||
import { TranslatedDiagnostic, CustomRuleResult, AIFinding } from '../ai/schema';
|
||||
|
||||
export interface MergedReport {
|
||||
linterDiagnostics: LinterDiagnostic[];
|
||||
customRuleDiagnostics: LinterDiagnostic[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
aiFindings: AIFinding[];
|
||||
linterCount: number;
|
||||
customRuleCount: number;
|
||||
aiCount: number;
|
||||
errors: string[];
|
||||
degraded: boolean;
|
||||
duration: number;
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterNames: string[];
|
||||
fixableLinterIndices: number[];
|
||||
fixableCustomIndices: number[];
|
||||
}
|
||||
|
||||
interface MergeInput {
|
||||
staticDiagnostics: LinterDiagnostic[];
|
||||
customRuleResults: CustomRuleResult[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
aiFindings: AIFinding[];
|
||||
errors: string[];
|
||||
degraded: boolean;
|
||||
startTime: number;
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterIds: string[];
|
||||
}
|
||||
|
||||
export function mergeResults(input: MergeInput): MergedReport {
|
||||
const customRuleDiagnostics: LinterDiagnostic[] = input.customRuleResults.map(r => ({
|
||||
severity: r.severity as Severity,
|
||||
ruleId: r.ruleId,
|
||||
message: r.message,
|
||||
range: new (require('vscode').Range)(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
|
||||
}));
|
||||
|
||||
const linterCount = input.staticDiagnostics.length;
|
||||
const customRuleCount = customRuleDiagnostics.length;
|
||||
const aiCount = input.aiFindings.length;
|
||||
|
||||
const fixableLinterIndices = input.staticDiagnostics
|
||||
.map((_, i) => i)
|
||||
.filter(i => input.staticDiagnostics[i].suggestion);
|
||||
|
||||
const fixableCustomIndices = customRuleDiagnostics
|
||||
.map((_, i) => i);
|
||||
|
||||
return {
|
||||
linterDiagnostics: input.staticDiagnostics,
|
||||
customRuleDiagnostics,
|
||||
translatedDiagnostics: input.translatedDiagnostics,
|
||||
aiFindings: input.aiFindings,
|
||||
linterCount,
|
||||
customRuleCount,
|
||||
aiCount,
|
||||
errors: input.errors,
|
||||
degraded: input.degraded,
|
||||
duration: Date.now() - input.startTime,
|
||||
filePath: input.filePath,
|
||||
language: input.language,
|
||||
adapterNames: input.adapterIds,
|
||||
fixableLinterIndices,
|
||||
fixableCustomIndices,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: `require('vscode')` 在合并器中创建 Range。如果遇到问题,可使用 `import * as vscode from 'vscode'` 替代,或在调用处传入 `vscode` 模块。
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `customRuleResults` → `customRuleDiagnostics`(LinterDiagnostic 格式,范围设为首字符)
|
||||
- 统计计数器:linterCount / customRuleCount / aiCount(面板 Tab 计数用)
|
||||
- `fixableLinterIndices`: 有 suggestion 的 linter 诊断索引
|
||||
- `fixableCustomIndices`: 所有自定义规则诊断索引(均可尝试 AI 修复)
|
||||
- duration: 从 startTime 到调用时刻的耗时
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,283 @@
|
||||
# Step 13 — Phase 4.5: 自动修复
|
||||
|
||||
**依赖**: Step 09(AI Provider),Step 12(Merger)
|
||||
**参考设计**: §8
|
||||
|
||||
## 目标
|
||||
|
||||
实现 AI 自动修复:动态上下文策略、两阶段匹配验证、批量修复倒序应用、快照撤销。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/fixer/fixer.ts` | `generateFix()`, `applyFix()`, `applyBatchFixes()`, `undoLastFix()` |
|
||||
|
||||
---
|
||||
|
||||
## `src/fixer/fixer.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterDiagnostic } from '../types';
|
||||
import { AIProvider } from '../ai/providers/base';
|
||||
import { getAIConfig } from '../config';
|
||||
import { createProvider } from '../ai/factory';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
function applySingleFix(editor: vscode.TextEditor, fix: CodeFix): 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();
|
||||
|
||||
function saveSnapshot(document: vscode.TextDocument): void {
|
||||
const filePath = document.uri.fsPath;
|
||||
if (!snapshotStack.has(filePath)) { snapshotStack.set(filePath, []); }
|
||||
snapshotStack.get(filePath)!.push(document.getText());
|
||||
}
|
||||
|
||||
function undoLastFix(document: vscode.TextDocument): 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);
|
||||
}
|
||||
|
||||
function hasSnapshot(document: vscode.TextDocument): boolean {
|
||||
const stack = snapshotStack.get(document.uri.fsPath);
|
||||
return !!(stack && stack.length > 0);
|
||||
}
|
||||
|
||||
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 (applySingleFix(editor, fix)) { applied++; }
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
export { prepareContext, generateFix, matchAndValidate, applySingleFix, applyBatchFixes, undoLastFix, saveSnapshot, hasSnapshot };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
**动态上下文策略**(按设计 §8.3):
|
||||
|
||||
| 问题类型 | 上下文范围 |
|
||||
|---------|-----------|
|
||||
| naming | 问题行 ± 2 行 |
|
||||
| style | 问题行 ± 5 行 |
|
||||
| bug / security / performance | 整个函数/方法 |
|
||||
|
||||
**两阶段匹配**(按设计 §8.4):
|
||||
1. 按行号匹配原文首行 → 验证完整原文
|
||||
2. 全文搜索 originalText
|
||||
|
||||
**批量修复**:
|
||||
- 修复前保存快照(`saveSnapshot`)
|
||||
- 按位置倒序执行(`b.startLine - a.startLine`)
|
||||
- 只应用成功匹配的修复
|
||||
|
||||
**撤销机制**:
|
||||
- `snapshotStack` 按文件路径存储快照
|
||||
- 面板底部撤销按钮根据 `hasSnapshot` 状态启用/禁用
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,142 @@
|
||||
# Step 14 — Phase 4.6: 报告导出
|
||||
|
||||
**依赖**: Step 12(Merger)
|
||||
**参考设计**: §14.2
|
||||
|
||||
## 目标
|
||||
|
||||
将 `MergedReport` 导出为 Markdown 格式报告。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/utils/report.ts` | `reportToMarkdown()` |
|
||||
|
||||
---
|
||||
|
||||
## `src/utils/report.ts`
|
||||
|
||||
```typescript
|
||||
import { MergedReport } from '../merger/merger';
|
||||
|
||||
function severityEmoji(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'error': return '🔴';
|
||||
case 'warning': return '🟡';
|
||||
case 'info': return '🔵';
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
|
||||
function formatLine(line: number): string {
|
||||
return `L${line + 1}`;
|
||||
}
|
||||
|
||||
export function reportToMarkdown(report: MergedReport): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# 代码审查报告');
|
||||
lines.push('');
|
||||
lines.push(`**文件:** \`${report.filePath}\``);
|
||||
lines.push(`**语言:** ${report.language}`);
|
||||
lines.push(`**耗时:** ${(report.duration / 1000).toFixed(1)}s`);
|
||||
if (report.adapterNames.length > 0) {
|
||||
lines.push(`**分析工具:** ${report.adapterNames.join(', ')}`);
|
||||
}
|
||||
if (report.degraded) {
|
||||
lines.push('');
|
||||
lines.push('> ⚠️ 部分 AI 功能不可用,报告已降级');
|
||||
}
|
||||
if (report.errors.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('## 错误');
|
||||
for (const err of report.errors) {
|
||||
lines.push(`- ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
const total = report.linterCount + report.customRuleCount + report.aiCount;
|
||||
const errors = report.linterDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'error').length;
|
||||
const warnings = report.linterDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'warning').length;
|
||||
const infos = total - errors - warnings;
|
||||
|
||||
lines.push(`**总计:** ${total} | **错误:** ${errors} | **警告:** ${warnings} | **建议:** ${infos}`);
|
||||
lines.push('');
|
||||
|
||||
if (report.linterDiagnostics.length > 0) {
|
||||
lines.push(`## 🔧 静态分析 · ${report.linterCount} 个问题`);
|
||||
lines.push('');
|
||||
for (const diag of report.linterDiagnostics) {
|
||||
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
|
||||
lines.push(` ${diag.message}`);
|
||||
if (diag.suggestion) {
|
||||
lines.push(` 建议: ${diag.suggestion}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.customRuleDiagnostics.length > 0) {
|
||||
lines.push(`## 📋 自定义规则 · ${report.customRuleCount} 个问题`);
|
||||
lines.push('');
|
||||
for (const diag of report.customRuleDiagnostics) {
|
||||
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
|
||||
lines.push(` ${diag.message}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.aiFindings.length > 0) {
|
||||
lines.push(`## 🤖 AI 审查 · ${report.aiCount} 条建议`);
|
||||
lines.push('');
|
||||
for (const finding of report.aiFindings) {
|
||||
lines.push(`- ${severityEmoji(finding.severity)} [AI] [${finding.category}] \`${finding.ruleId}\` ${formatLine(finding.line)}`);
|
||||
lines.push(` **${finding.title}**`);
|
||||
lines.push(` ${finding.description}`);
|
||||
if (finding.suggestion) {
|
||||
lines.push(` 建议: ${finding.suggestion}`);
|
||||
}
|
||||
if (finding.codeDiff) {
|
||||
lines.push(' ```diff');
|
||||
lines.push(` ${finding.codeDiff.split('\n').join('\n ')}`);
|
||||
lines.push(' ```');
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
lines.push('✅ 未发现问题');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- 报告格式与设计 §14.2 一致
|
||||
- 按来源分三个段:静态分析 / 自定义规则 / AI 审查
|
||||
- 统计卡片:总计 + 按严重级别拆分
|
||||
- AI 审查结果包含 category、codeDiff(diff 代码块)
|
||||
- 降级和错误信息在报告顶部单独显示
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,252 @@
|
||||
# Step 15 — Phase 5.1: 命令注册 + extension.ts 更新
|
||||
|
||||
**依赖**: Step 08, 14(Orchestrator + 报告导出),Step 13(Fixer 可选)
|
||||
**参考设计**: §6
|
||||
|
||||
## 目标
|
||||
|
||||
注册 8 个命令,更新 `package.json` 贡献点,更新 `extension.ts` 入口串联所有模块。
|
||||
|
||||
## 文件变更
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `src/activation/commands.ts` | 新建 | 所有命令处理函数注册 |
|
||||
| 2 | `src/extension.ts` | 修改 | 替换 helloWorld 为正式入口 |
|
||||
| 3 | `package.json` | 修改 | 添加 commands、viewsContainers、views、menus、configuration |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/activation/commands.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { Orchestrator } from '../orchestrator/orchestrator';
|
||||
import { runAIReview } from '../ai/engine';
|
||||
import { loadActiveRules } from '../rules/yaml-parser';
|
||||
import { mergeResults } from '../merger/merger';
|
||||
import { reportToMarkdown } from '../utils/report';
|
||||
import { getApiKey } from '../config';
|
||||
|
||||
export function registerCommands(context: vscode.ExtensionContext, orchestrator: Orchestrator): void {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.review', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage('请先打开一个文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const document = editor.document;
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '正在审查...',
|
||||
cancellable: false,
|
||||
}, async (progress) => {
|
||||
progress.report({ message: '运行静态分析...' });
|
||||
|
||||
const startTime = Date.now();
|
||||
const staticResult = await orchestrator.runStaticAnalysis(document, workingDir);
|
||||
|
||||
progress.report({ message: '运行 AI 审查...' });
|
||||
|
||||
const customRules = loadActiveRules(workspaceRoot);
|
||||
const code = document.getText();
|
||||
const aiResult = await runAIReview(context, code, staticResult.diagnostics, customRules);
|
||||
|
||||
const report = mergeResults({
|
||||
staticDiagnostics: staticResult.diagnostics,
|
||||
customRuleResults: aiResult.customRuleResults,
|
||||
translatedDiagnostics: aiResult.translatedDiagnostics,
|
||||
aiFindings: aiResult.findings,
|
||||
errors: [...staticResult.errors, ...(aiResult.error ? [aiResult.error] : [])],
|
||||
degraded: aiResult.degraded,
|
||||
startTime,
|
||||
filePath: document.uri.fsPath,
|
||||
language: document.languageId,
|
||||
adapterIds: staticResult.adapterIds,
|
||||
});
|
||||
|
||||
// TODO: Phase 5.3 — 推送报告到审查面板
|
||||
vscode.window.showInformationMessage(
|
||||
`审查完成: ${report.linterCount + report.customRuleCount + report.aiCount} 个问题`
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.reviewSelection', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) { return; }
|
||||
|
||||
const selection = editor.selection;
|
||||
if (selection.isEmpty) {
|
||||
vscode.window.showWarningMessage('请先选中要审查的代码');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = editor.document.getText(selection);
|
||||
const apiKey = await getApiKey(context);
|
||||
if (!apiKey) {
|
||||
vscode.window.showWarningMessage('请先在设置面板中配置 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const customRules = loadActiveRules(workspaceRoot);
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '审查选中代码...',
|
||||
cancellable: false,
|
||||
}, async () => {
|
||||
const aiResult = await runAIReview(context, code, [], customRules);
|
||||
vscode.window.showInformationMessage(
|
||||
`选中代码审查完成: ${aiResult.customRuleResults.length + aiResult.findings.length} 个问题`
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.openPanel', () => {
|
||||
// TODO: Phase 5.3 — 打开审查面板
|
||||
vscode.window.showInformationMessage('审查面板功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.exportReport', async () => {
|
||||
// TODO: 与审查面板集成后获取最新 report
|
||||
vscode.window.showInformationMessage('请先运行完整审查生成报告');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.addCustomRule', () => {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showWarningMessage('请先打开工作区');
|
||||
return;
|
||||
}
|
||||
// TODO: 打开规则向导,保存到 .code-review/rules/
|
||||
vscode.window.showInformationMessage('添加自定义规则功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixIssue', () => {
|
||||
// TODO: Phase 4.5 — 单条修复(与审查面板交互)
|
||||
vscode.window.showInformationMessage('单条修复功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixAll', () => {
|
||||
// TODO: Phase 4.5 — 批量修复
|
||||
vscode.window.showInformationMessage('批量修复功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.openSetup', () => {
|
||||
vscode.commands.executeCommand('workbench.view.extension.code-reviewer');
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/extension.ts` 修改
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { Orchestrator } from './orchestrator/orchestrator';
|
||||
import { registerCommands } from './activation/commands';
|
||||
|
||||
let orchestrator: Orchestrator;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
console.log('CodeGuard 代码审查插件已激活');
|
||||
|
||||
orchestrator = new Orchestrator();
|
||||
registerCommands(context, orchestrator);
|
||||
|
||||
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidSaveTextDocument((document) => {
|
||||
const key = document.uri.toString();
|
||||
const existing = debounceTimers.get(key);
|
||||
if (existing) { clearTimeout(existing); }
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
debounceTimers.delete(key);
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
orchestrator.runStaticAnalysis(document, workingDir);
|
||||
}, 500);
|
||||
|
||||
debounceTimers.set(key, timer);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
orchestrator = undefined!;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `package.json` 修改
|
||||
|
||||
将 `configuration` 部分替换为完整的命令、视图容器、菜单和配置项。
|
||||
|
||||
**commands**:
|
||||
```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: 打开设置面板" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**keybindings**:
|
||||
```json
|
||||
{
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "codeReviewer.review",
|
||||
"key": "ctrl+shift+r",
|
||||
"when": "editorTextFocus"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**viewsContainers** + **views** + **menus** + **configuration**: 见设计 §6.2-6.4
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 3 个文件变更完成
|
||||
- [ ] `Ctrl+Shift+R` 可触发审查
|
||||
- [ ] 保存文件后 500ms 自动运行静态分析
|
||||
- [ ] 命令面板显示 8 个命令
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,421 @@
|
||||
# Step 16 — Phase 5.2: 设置面板
|
||||
|
||||
**依赖**: Step 15
|
||||
**参考设计**: §13
|
||||
|
||||
## 目标
|
||||
|
||||
实现侧边栏设置面板 `codeReviewer.setupView`:快速引导、AI 配置、API Key、输出语言、自定义规则管理。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/views/setupView.ts` | `SetupViewProvider` implements `vscode.TreeDataProvider` |
|
||||
|
||||
## 面板参考
|
||||
|
||||
UI 预览文件: `docs/superpowers/specs/setup-panel-preview.html`
|
||||
|
||||
---
|
||||
|
||||
## `src/views/setupView.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { getAIConfig, setApiKey, getApiKey, isApiKeyConfigured } from '../config';
|
||||
import { createProvider } from '../ai/factory';
|
||||
import { loadActiveRules } from '../rules/yaml-parser';
|
||||
import { CustomRule } from '../rules/yaml-parser';
|
||||
|
||||
type SetupItemType = 'section' | 'step' | 'providerGroup' | 'provider' | 'model' | 'apiKey' | 'language' | 'rule' | 'ruleAdd' | 'action';
|
||||
|
||||
class SetupItem extends vscode.TreeItem {
|
||||
constructor(
|
||||
public readonly label: string,
|
||||
public readonly itemType: SetupItemType,
|
||||
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
|
||||
public readonly command?: vscode.Command,
|
||||
public readonly iconPath?: vscode.ThemeIcon,
|
||||
public readonly description?: string,
|
||||
public readonly contextValue?: string,
|
||||
) {
|
||||
super(label, collapsibleState);
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupViewProvider implements vscode.TreeDataProvider<SetupItem> {
|
||||
private _onDidChangeTreeData = new vscode.EventEmitter<SetupItem | undefined>();
|
||||
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||
|
||||
private customRules: CustomRule[] = [];
|
||||
private apiKeyConfigured = false;
|
||||
private connectionTested = false;
|
||||
private connectionSuccess = false;
|
||||
|
||||
constructor(private context: vscode.ExtensionContext) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
this.customRules = loadActiveRules(workspaceRoot);
|
||||
this.apiKeyConfigured = await isApiKeyConfigured(this.context);
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
getTreeItem(element: SetupItem): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
async getChildren(element?: SetupItem): Promise<SetupItem[]> {
|
||||
if (!element) {
|
||||
return this.getRootItems();
|
||||
}
|
||||
|
||||
switch (element.itemType) {
|
||||
case 'providerGroup': return this.getProviderItems();
|
||||
case 'apiKey': return this.getApiKeyItems();
|
||||
case 'language': return this.getLanguageItems();
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getRootItems(): SetupItem[] {
|
||||
const items: SetupItem[] = [];
|
||||
|
||||
const step1Done = this.apiKeyConfigured;
|
||||
const step2Done = this.customRules.some(r => r.id);
|
||||
const step3Done = this.connectionTested && this.connectionSuccess;
|
||||
|
||||
items.push(new SetupItem(
|
||||
'快速开始',
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Expanded,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'section'
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step1Done ? '① 完成 AI 模型配置' : '① 配置 AI 模型及 API Key',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
step1Done ? undefined : {
|
||||
command: 'codeReviewer.focusApiKey',
|
||||
title: '配置 API Key',
|
||||
},
|
||||
step1Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step2Done ? '② 完成规则启用' : '② 启用自定义规则',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
undefined,
|
||||
step2Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step3Done ? '③ 完成连接测试' : '③ 保存并测试连接',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
step3Done ? undefined : {
|
||||
command: 'codeReviewer.saveAndTest',
|
||||
title: '测试连接',
|
||||
},
|
||||
step3Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'审核引擎',
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'AI 模型配置',
|
||||
'providerGroup',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'API Key',
|
||||
'apiKey',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'输出语言',
|
||||
'language',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
`自定义规则 [${this.customRules.length} 条]`,
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Expanded
|
||||
));
|
||||
|
||||
for (const rule of this.customRules) {
|
||||
items.push(new SetupItem(
|
||||
rule.id,
|
||||
'rule',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.toggleRule',
|
||||
title: '切换规则',
|
||||
arguments: [rule.id],
|
||||
},
|
||||
undefined,
|
||||
rule.severity,
|
||||
'rule'
|
||||
));
|
||||
}
|
||||
|
||||
items.push(new SetupItem(
|
||||
'输入规则名称... [+ 添加]',
|
||||
'ruleAdd',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.addCustomRule',
|
||||
title: '添加规则',
|
||||
}
|
||||
));
|
||||
|
||||
const connectionLabel = this.connectionTested
|
||||
? (this.connectionSuccess ? '✓ 已连接' : '✗ 重试')
|
||||
: '保存并测试连接';
|
||||
|
||||
items.push(new SetupItem(
|
||||
connectionLabel,
|
||||
'action',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.saveAndTest',
|
||||
title: '测试连接',
|
||||
}
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private getProviderItems(): SetupItem[] {
|
||||
const config = getAIConfig();
|
||||
return [
|
||||
new SetupItem(`提供商: ${config.provider}`,
|
||||
'provider',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectProvider',
|
||||
title: '选择提供商',
|
||||
}
|
||||
),
|
||||
new SetupItem(`模型: ${config.model}`,
|
||||
'model',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectModel',
|
||||
title: '选择模型',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private getApiKeyItems(): SetupItem[] {
|
||||
return [
|
||||
new SetupItem(
|
||||
'设置 API Key...',
|
||||
'apiKey',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.setApiKey',
|
||||
title: '设置 API Key',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private getLanguageItems(): SetupItem[] {
|
||||
const config = getAIConfig();
|
||||
return [
|
||||
new SetupItem(
|
||||
`当前: ${config.outputLanguage === 'zh-CN' ? '中文(简体)' : config.outputLanguage}`,
|
||||
'language',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectLanguage',
|
||||
title: '选择输出语言',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 额外命令(在 `commands.ts` 中注册)
|
||||
|
||||
```typescript
|
||||
// 设置面板交互命令
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.setApiKey', async () => {
|
||||
const key = await vscode.window.showInputBox({
|
||||
prompt: '请输入 API Key',
|
||||
password: true,
|
||||
placeHolder: 'sk-...',
|
||||
});
|
||||
if (key) {
|
||||
await setApiKey(context, key);
|
||||
setupProvider?.refresh();
|
||||
vscode.window.showInformationMessage('API Key 已保存');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectProvider', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const current = config.get<string>('ai.provider', 'deepseek');
|
||||
const selected = await vscode.window.showQuickPick(['deepseek', 'openai'], {
|
||||
placeHolder: '选择模型提供商',
|
||||
});
|
||||
if (selected) {
|
||||
await config.update('ai.provider', selected, vscode.ConfigurationTarget.Global);
|
||||
setupProvider?.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectModel', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const current = config.get<string>('ai.model', '');
|
||||
const selected = await vscode.window.showInputBox({
|
||||
prompt: '输入模型名称',
|
||||
value: current,
|
||||
placeHolder: 'deepseek-chat',
|
||||
});
|
||||
if (selected) {
|
||||
await config.update('ai.model', selected, vscode.ConfigurationTarget.Global);
|
||||
setupProvider?.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectLanguage', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const current = config.get<string>('ai.outputLanguage', 'zh-CN');
|
||||
const selected = await vscode.window.showQuickPick(
|
||||
[
|
||||
{ label: '中文(简体)', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: '日本語', value: 'ja' },
|
||||
],
|
||||
{ placeHolder: '选择输出语言' }
|
||||
);
|
||||
if (selected) {
|
||||
await config.update('ai.outputLanguage', selected.value, vscode.ConfigurationTarget.Global);
|
||||
setupProvider?.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.saveAndTest', async () => {
|
||||
const apiKey = await getApiKey(context);
|
||||
if (!apiKey) {
|
||||
vscode.window.showWarningMessage('请先设置 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getAIConfig();
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '测试连接...',
|
||||
cancellable: false,
|
||||
}, async () => {
|
||||
try {
|
||||
const provider = createProvider(config.provider, apiKey, config.endpoint);
|
||||
await provider.chat('回复 ok', 'ping', {
|
||||
model: config.model,
|
||||
temperature: 0,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
connectionTested = true;
|
||||
connectionSuccess = true;
|
||||
setupProvider?.refresh();
|
||||
vscode.window.showInformationMessage('✓ 连接成功', { modal: false });
|
||||
} catch (err) {
|
||||
connectionTested = true;
|
||||
connectionSuccess = false;
|
||||
setupProvider?.refresh();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
vscode.window.showErrorMessage(`✗ 连接失败: ${message}`, { modal: false });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.toggleRule', async (ruleId: string) => {
|
||||
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 enabledPattern = new RegExp(`^(\\s*${ruleId}\\s*:\\s*\\n\\s*enabled\\s*:\\s*)(true|false)`, 'm');
|
||||
const match = enabledPattern.exec(content);
|
||||
|
||||
if (match) {
|
||||
const newValue = match[2] === 'true' ? 'false' : 'true';
|
||||
content = content.replace(enabledPattern, `$1${newValue}`);
|
||||
} else {
|
||||
content += `\n ${ruleId}:\n enabled: false\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
setupProvider?.refresh();
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 在 `extension.ts` 中注册视图
|
||||
|
||||
```typescript
|
||||
import { SetupViewProvider } from './views/setupView';
|
||||
|
||||
let setupProvider: SetupViewProvider;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
setupProvider = new SetupViewProvider(context);
|
||||
vscode.window.registerTreeDataProvider('codeReviewer.setupView', setupProvider);
|
||||
// ... 其余
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 需要 `setupProvider` 作为模块级变量暴露给 `commands.ts`。
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 侧边栏显示 CodeGuard 设置视图
|
||||
- [ ] 快速开始三步引导可见
|
||||
- [ ] API Key 可输入并保存到 SecretStorage
|
||||
- [ ] 提供商/模型可切换
|
||||
- [ ] 自定义规则列表展示并支持开关
|
||||
- [ ] 保存并测试连接按钮功能正常
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,337 @@
|
||||
# Step 17 — Phase 5.3: 审查面板(Webview)
|
||||
|
||||
**依赖**: Step 12, 15
|
||||
**参考设计**: §7, §14
|
||||
|
||||
## 目标
|
||||
|
||||
实现 Webview 审查报告面板:三 Tab 切换、统计卡片、问题列表、postMessage 通信、降级提示。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/panel/webview.ts` | `ReviewPanel` 类(Webview 管理 + HTML 生成) |
|
||||
|
||||
## 面板参考
|
||||
|
||||
UI 预览文件: `docs/superpowers/specs/review-panel-preview.html`
|
||||
|
||||
---
|
||||
|
||||
## `src/panel/webview.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { MergedReport } from '../merger/merger';
|
||||
|
||||
interface PanelMessage {
|
||||
type: 'navigate' | 'rerun' | 'export' | 'settings' | 'fix' | 'fixAll';
|
||||
line?: number;
|
||||
ruleId?: string;
|
||||
source?: 'linter' | 'custom' | 'ai';
|
||||
}
|
||||
|
||||
export class ReviewPanel {
|
||||
public static currentPanel: ReviewPanel | undefined;
|
||||
private readonly panel: vscode.WebviewPanel;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
private constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
column: vscode.ViewColumn
|
||||
) {
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'codeReviewer.reviewPanel',
|
||||
'代码审查报告',
|
||||
column,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [],
|
||||
}
|
||||
);
|
||||
|
||||
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
|
||||
|
||||
this.panel.webview.onDidReceiveMessage(
|
||||
(message: PanelMessage) => this.handleMessage(message),
|
||||
null,
|
||||
this.disposables
|
||||
);
|
||||
}
|
||||
|
||||
static createOrShow(extensionUri: vscode.Uri, column?: vscode.ViewColumn): ReviewPanel {
|
||||
if (ReviewPanel.currentPanel) {
|
||||
ReviewPanel.currentPanel.panel.reveal(column);
|
||||
return ReviewPanel.currentPanel;
|
||||
}
|
||||
|
||||
ReviewPanel.currentPanel = new ReviewPanel(extensionUri, column ?? vscode.ViewColumn.Two);
|
||||
return ReviewPanel.currentPanel;
|
||||
}
|
||||
|
||||
update(report: MergedReport): void {
|
||||
this.panel.webview.html = this.buildHtml(report);
|
||||
}
|
||||
|
||||
private buildHtml(report: MergedReport): string {
|
||||
const total = report.linterCount + report.customRuleCount + report.aiCount;
|
||||
const errorCount = report.linterDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'error').length;
|
||||
const warnCount = report.linterDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'warning').length;
|
||||
const infoCount = total - errorCount - warnCount;
|
||||
|
||||
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>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>代码审查报告</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 16px; }
|
||||
.header { margin-bottom: 16px; }
|
||||
.header h1 { font-size: 18px; margin-bottom: 4px; }
|
||||
.header .meta { font-size: 12px; color: var(--vscode-descriptionForeground); }
|
||||
.banner { padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; }
|
||||
.banner-warn { background: #332B00; border: 1px solid #665C00; color: #FFF3B0; }
|
||||
.banner-error { background: #330000; border: 1px solid #660000; color: #FFB0B0; }
|
||||
.stats { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { flex: 1; padding: 12px; border-radius: 6px; text-align: center; background: var(--vscode-sideBar-background); }
|
||||
.stat-card .num { font-size: 24px; font-weight: 600; }
|
||||
.stat-card .label { font-size: 12px; color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
.stat-total .num { color: var(--vscode-foreground); }
|
||||
.stat-error .num { color: #E06C75; }
|
||||
.stat-warn .num { color: #D19A66; }
|
||||
.stat-info .num { color: #61AFEF; }
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 12px; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.tab { padding: 8px 16px; cursor: pointer; border: none; background: none; color: var(--vscode-descriptionForeground); font-family: var(--vscode-font-family); font-size: 13px; border-bottom: 2px solid transparent; }
|
||||
.tab.active { color: var(--vscode-foreground); border-bottom-color: #7C3AED; }
|
||||
.tab .count { margin-left: 6px; font-size: 11px; opacity: 0.7; }
|
||||
.issue-list { display: none; }
|
||||
.issue-list.active { display: block; }
|
||||
.issue { padding: 8px 12px; border-radius: 4px; margin-bottom: 6px; background: var(--vscode-sideBar-background); cursor: pointer; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.issue:hover { background: var(--vscode-list-hoverBackground); }
|
||||
.issue-left { flex: 1; }
|
||||
.issue-title { font-size: 13px; margin-bottom: 2px; }
|
||||
.issue-detail { font-size: 12px; color: var(--vscode-descriptionForeground); }
|
||||
.issue-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.btn { padding: 2px 8px; border-radius: 3px; border: 1px solid var(--vscode-button-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); cursor: pointer; font-size: 11px; }
|
||||
.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.btn-primary { background: #7C3AED; border-color: #7C3AED; color: #fff; }
|
||||
.btn-primary:hover { background: #6D28D9; }
|
||||
.severity { display: inline-block; width: 16px; text-align: center; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--vscode-panel-border); }
|
||||
.empty { text-align: center; padding: 24px; color: var(--vscode-descriptionForeground); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📋 代码审查报告</h1>
|
||||
<div class="meta">${fileName} · ${report.language} · ${(report.duration / 1000).toFixed(1)}s</div>
|
||||
</div>
|
||||
${degradedBanner}
|
||||
<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>
|
||||
<div class="stat-card stat-warn"><div class="num">${warnCount}</div><div class="label">警告</div></div>
|
||||
<div class="stat-card stat-info"><div class="num">${infoCount}</div><div class="label">建议</div></div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('linter')">🔧 静态分析 <span class="count">${report.linterCount}</span></button>
|
||||
<button class="tab" onclick="switchTab('custom')">📋 自定义规则 <span class="count">${report.customRuleCount}</span></button>
|
||||
<button class="tab" onclick="switchTab('ai')">🤖 AI 审查 <span class="count">${report.aiCount}</span></button>
|
||||
</div>
|
||||
<div id="tab-linter" class="issue-list active">
|
||||
${this.buildLinterList(report)}
|
||||
</div>
|
||||
<div id="tab-custom" class="issue-list">
|
||||
${this.buildCustomList(report)}
|
||||
</div>
|
||||
<div id="tab-ai" class="issue-list">
|
||||
${this.buildAIList(report)}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="send('rerun')">🔄 重新审查</button>
|
||||
<button class="btn" onclick="send('export')">📄 导出</button>
|
||||
<button class="btn" onclick="send('settings')">⚙️ 设置</button>
|
||||
<button class="btn" onclick="send('fixAll')">🔧 批量修复</button>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
function send(type, line, ruleId, source) {
|
||||
vscode.postMessage({ type, line, ruleId, source });
|
||||
}
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.issue-list').forEach(l => l.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildLinterList(report: MergedReport): string {
|
||||
if (report.linterDiagnostics.length === 0) {
|
||||
return '<div class="empty">✅ 静态分析未发现问题</div>';
|
||||
}
|
||||
return report.linterDiagnostics.map((d, i) => `
|
||||
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">
|
||||
<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>
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">修复</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
private buildCustomList(report: MergedReport): string {
|
||||
if (report.customRuleDiagnostics.length === 0) {
|
||||
return '<div class="empty">✅ 自定义规则未发现问题</div>';
|
||||
}
|
||||
return report.customRuleDiagnostics.map((d, i) => `
|
||||
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">
|
||||
<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>
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">修复</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
private buildAIList(report: MergedReport): string {
|
||||
const total = report.translatedDiagnostics.length + report.aiFindings.length;
|
||||
if (total === 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')">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">${this.sevIcon(f.severity)}</span> [${f.category}] <strong>${this.escape(f.title)}</strong></div>
|
||||
<div class="issue-detail">${this.escape(f.description)}</div>
|
||||
${f.suggestion ? `<div class="issue-detail">建议: ${this.escape(f.suggestion)}</div>` : ''}
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">修复</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
private sevIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'error': return '🔴';
|
||||
case 'warning': return '🟡';
|
||||
case 'info': return '🔵';
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
|
||||
private escape(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
private handleMessage(message: PanelMessage): 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);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'rerun':
|
||||
vscode.commands.executeCommand('codeReviewer.review');
|
||||
break;
|
||||
case 'export':
|
||||
vscode.commands.executeCommand('codeReviewer.exportReport');
|
||||
break;
|
||||
case 'settings':
|
||||
vscode.commands.executeCommand('codeReviewer.openSetup');
|
||||
break;
|
||||
case 'fix':
|
||||
vscode.commands.executeCommand('codeReviewer.fixIssue', message);
|
||||
break;
|
||||
case 'fixAll':
|
||||
vscode.commands.executeCommand('codeReviewer.fixAll');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
ReviewPanel.currentPanel = undefined;
|
||||
this.panel.dispose();
|
||||
for (const d of this.disposables) { d.dispose(); }
|
||||
this.disposables = [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 额外修改:`commands.ts` 中集成审查面板调用
|
||||
|
||||
```typescript
|
||||
import { ReviewPanel } from '../panel/webview';
|
||||
|
||||
// 在 'codeReviewer.review' 命令中,静态分析完成后:
|
||||
const panel = ReviewPanel.createOrShow(context.extensionUri);
|
||||
panel.update(report);
|
||||
|
||||
// 'codeReviewer.exportReport' 命令:
|
||||
const markdown = reportToMarkdown(report);
|
||||
const doc = await vscode.workspace.openTextDocument({ content: markdown, language: 'markdown' });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 审查面板可打开(Webview)
|
||||
- [ ] 三 Tab 切换正常工作
|
||||
- [ ] 统计卡片数值正确
|
||||
- [ ] 问题列表可点击跳转到代码位置
|
||||
- [ ] 降级提示条在 AI 失败时显示
|
||||
- [ ] 修复/重新审查/导出按钮发送正确消息
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,109 @@
|
||||
# Step 18 — Phase 6.1: 构建脚本
|
||||
|
||||
**依赖**: Step 15(extension.ts 完成)
|
||||
**参考设计**: §15
|
||||
|
||||
## 目标
|
||||
|
||||
搭建 esbuild 构建流程,替换 tsc 为打包构建,更新 package.json 脚本。
|
||||
|
||||
## 文件变更
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `scripts/build.mjs` | 新建 | esbuild 打包脚本 |
|
||||
| 2 | `package.json` | 修改 | 更新 build/vscode:prepublish 脚本 |
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
npm install --save-dev esbuild@^0.28.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `scripts/build.mjs`
|
||||
|
||||
```javascript
|
||||
import * as esbuild from 'esbuild';
|
||||
import { copyFileSync, mkdirSync, existsSync, cpSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = resolve(__dirname, '..');
|
||||
const outDir = resolve(rootDir, 'out');
|
||||
|
||||
if (!existsSync(outDir)) {
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [resolve(rootDir, 'src', 'extension.ts')],
|
||||
bundle: true,
|
||||
outfile: resolve(outDir, 'extension.js'),
|
||||
external: [
|
||||
'vscode',
|
||||
'eslint',
|
||||
'stylelint',
|
||||
'child_process',
|
||||
'fs',
|
||||
'path',
|
||||
'url',
|
||||
'os',
|
||||
],
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
treeShaking: true,
|
||||
});
|
||||
|
||||
const jarsSrc = resolve(rootDir, 'jars');
|
||||
const jarsDest = resolve(outDir, 'jars');
|
||||
if (existsSync(jarsSrc)) {
|
||||
cpSync(jarsSrc, jarsDest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('Build complete.');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `package.json` 脚本更新
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"build": "node scripts/build.mjs",
|
||||
"vscode:prepublish": "npm run build",
|
||||
"pretest": "npm run compile && npm run lint",
|
||||
"lint": "eslint src",
|
||||
"test": "vscode-test"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `external`: vscode API、npm 包、Node 内置模块不打包
|
||||
- `format: 'cjs'`: VSCode 扩展需要 CommonJS
|
||||
- `target: 'node22'`: 对应 VSCode 1.120+ 的 Node 版本
|
||||
- `minify: true`: 产物压缩
|
||||
- `treeShaking: true`: 移除未使用代码
|
||||
- 复制 `jars/` 目录到 `out/` 供运行时加载
|
||||
- `vscode:prepublish` 改为 `npm run build`(生产打包)
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] `npm run build` 成功执行
|
||||
- [ ] `out/extension.js` 生成(单文件 bundle)
|
||||
- [ ] `out/jars/` 目录存在
|
||||
- [ ] F5 启动扩展开发宿主功能正常
|
||||
@@ -0,0 +1,124 @@
|
||||
# Step 19 — Phase 6.2: 工具脚本
|
||||
|
||||
**依赖**: Step 18
|
||||
**参考设计**: §15, §17
|
||||
|
||||
## 目标
|
||||
|
||||
实现 PMD JAR 下载脚本和生产打包脚本。
|
||||
|
||||
## 文件变更
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `scripts/download-pmd.mjs` | 新建 | 下载 PMD 7.26.0 JAR 依赖 |
|
||||
| 2 | `scripts/package-prod.mjs` | 新建 | 生产打包脚本 |
|
||||
| 3 | `package.json` | 修改 | 添加 download-pmd / package-prod 脚本 |
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
npm install --save-dev @vscode/vsce@^3.9.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `scripts/download-pmd.mjs`
|
||||
|
||||
```javascript
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, mkdirSync, createWriteStream } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { get } from 'https';
|
||||
import { unlinkSync, readdirSync } from 'fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
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/net/sourceforge/pmd`;
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = createWriteStream(dest);
|
||||
get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
response.pipe(file);
|
||||
file.on('finish', () => { file.close(); resolve(); });
|
||||
file.on('error', (err) => { unlinkSync(dest); reject(err); });
|
||||
}).on('error', (err) => { unlinkSync(dest); reject(err); });
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (existsSync(dest)) {
|
||||
console.log(`Skip: ${jar} (exists)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Downloading: ${url}`);
|
||||
await downloadFile(url, dest);
|
||||
console.log(`Done: ${jar}`);
|
||||
}
|
||||
|
||||
console.log('PMD JARs download complete.');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `scripts/package-prod.mjs`
|
||||
|
||||
```javascript
|
||||
import { execSync } from 'child_process';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = resolve(__dirname, '..');
|
||||
|
||||
execSync('node scripts/build.mjs', { cwd: rootDir, stdio: 'inherit' });
|
||||
execSync('npx vsce package', { cwd: rootDir, stdio: 'inherit' });
|
||||
|
||||
console.log('Production package complete.');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `package.json` 脚本更新
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"download-pmd": "node scripts/download-pmd.mjs",
|
||||
"package-prod": "node scripts/package-prod.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] `npm run download-pmd` 成功下载 PMD JAR 到 `jars/pmd/lib/`
|
||||
- [ ] `npm run package-prod` 成功生成 `.vsix` 文件
|
||||
- [ ] `.vsix` 可安装到 VSCode
|
||||
@@ -0,0 +1,252 @@
|
||||
# Step 20 — Phase 6.3: 测试
|
||||
|
||||
**依赖**: Step 19(构建完成)
|
||||
**参考设计**: §16
|
||||
|
||||
## 目标
|
||||
|
||||
实现测试:适配器测试、配置测试、合并逻辑测试、完整流程测试。
|
||||
|
||||
## 文件变更
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `src/test/fixtures/` | 新建 | 测试用代码样本目录 |
|
||||
| 2 | `src/test/adapter.test.ts` | 新建 | 适配器输出解析测试 |
|
||||
| 3 | `src/test/config.test.ts` | 新建 | 配置读取测试 |
|
||||
| 4 | `src/test/merger.test.ts` | 新建 | 多源结果合并 + 统计计算测试 |
|
||||
| 5 | `src/test/pipeline.test.ts` | 新建 | 全链路集成测试 |
|
||||
| 6 | `src/test/extension.test.ts` | 修改 | 替换占位测试 |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/test/fixtures/` 目录
|
||||
|
||||
### `src/test/fixtures/sample.js`
|
||||
|
||||
```javascript
|
||||
function test() {
|
||||
var unused = 1;
|
||||
console.log('debug');
|
||||
return "hello world";
|
||||
}
|
||||
```
|
||||
|
||||
### `src/test/fixtures/sample.css`
|
||||
|
||||
```css
|
||||
.hello { color: black; background: #FFF; }
|
||||
#test { margin: 0px; }
|
||||
```
|
||||
|
||||
### `src/test/fixtures/Sample.java`
|
||||
|
||||
```java
|
||||
public class Sample {
|
||||
public void test() {
|
||||
String password = "admin123";
|
||||
System.out.println("debug");
|
||||
System.out.println("debug");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/test/adapter.test.ts`
|
||||
|
||||
```typescript
|
||||
import * as assert from 'assert';
|
||||
import * as vscode from 'vscode';
|
||||
import { ESLintAdapter } from '../adapters/eslint';
|
||||
import { StylelintAdapter } from '../adapters/stylelint';
|
||||
|
||||
suite('Adapter Tests', () => {
|
||||
test('ESLintAdapter has correct id and languages', () => {
|
||||
const adapter = new ESLintAdapter();
|
||||
assert.strictEqual(adapter.id, 'eslint');
|
||||
assert.deepStrictEqual(adapter.supportedLanguages, ['javascript', 'typescript']);
|
||||
});
|
||||
|
||||
test('StylelintAdapter has correct id and languages', () => {
|
||||
const adapter = new StylelintAdapter();
|
||||
assert.strictEqual(adapter.id, 'stylelint');
|
||||
assert.deepStrictEqual(adapter.supportedLanguages, ['css']);
|
||||
});
|
||||
|
||||
test('ESLintAdapter check returns AdapterResult structure', async () => {
|
||||
const adapter = new ESLintAdapter();
|
||||
if (!adapter.isAvailable()) { return; }
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
content: 'const x = 1;\nconsole.log(x);\n',
|
||||
language: 'javascript',
|
||||
});
|
||||
|
||||
const result = await adapter.check(doc, __dirname);
|
||||
assert.ok(result.status === 'ok' || result.status === 'tool-unavailable');
|
||||
assert.ok(Array.isArray(result.diagnostics));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/test/config.test.ts`
|
||||
|
||||
```typescript
|
||||
import * as assert from 'assert';
|
||||
import { getAIConfig } from '../config/ai';
|
||||
import { getLinterConfig } from '../config/linter';
|
||||
import { getFixerConfig } from '../config/fixer';
|
||||
|
||||
suite('Config Tests', () => {
|
||||
test('getAIConfig returns default values', () => {
|
||||
const config = getAIConfig();
|
||||
assert.strictEqual(config.provider, 'deepseek');
|
||||
assert.strictEqual(config.model, 'deepseek-chat');
|
||||
assert.strictEqual(config.temperature, 0.2);
|
||||
assert.strictEqual(config.timeout, 300);
|
||||
assert.strictEqual(config.outputLanguage, 'zh-CN');
|
||||
});
|
||||
|
||||
test('getLinterConfig returns default language map', () => {
|
||||
const config = getLinterConfig();
|
||||
assert.strictEqual(config.languageMap.javascript, 'eslint');
|
||||
assert.strictEqual(config.languageMap.java, 'pmd');
|
||||
assert.strictEqual(config.languageMap.css, 'stylelint');
|
||||
assert.strictEqual(config.languageMap.jsp, 'jsp');
|
||||
});
|
||||
|
||||
test('getFixerConfig returns default values', () => {
|
||||
const config = getFixerConfig();
|
||||
assert.strictEqual(config.contextLines, 5);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/test/merger.test.ts`
|
||||
|
||||
```typescript
|
||||
import * as assert from 'assert';
|
||||
import { mergeResults, MergedReport } from '../merger/merger';
|
||||
import { CustomRuleResult, AIFinding } from '../ai/schema';
|
||||
import { LinterDiagnostic } from '../types';
|
||||
|
||||
suite('Merger Tests', () => {
|
||||
test('mergeResults counts correctly', () => {
|
||||
const staticDiags: LinterDiagnostic[] = [
|
||||
{ severity: 'error', ruleId: 'eslint:no-unused', message: 'x is unused', range: new (require('vscode').Range)(0, 0, 0, 1) },
|
||||
];
|
||||
const customResults: CustomRuleResult[] = [
|
||||
{ ruleId: 'custom:no-console', line: 5, severity: 'warning', message: 'avoid console.log' },
|
||||
];
|
||||
const aiFindings: AIFinding[] = [
|
||||
{ ruleId: 'hardcoded-secret', severity: 'error', category: 'security', title: 'Hardcoded', description: 'Found secret', suggestion: 'Use env', line: 3 },
|
||||
];
|
||||
|
||||
const report = mergeResults({
|
||||
staticDiagnostics: staticDiags,
|
||||
customRuleResults: customResults,
|
||||
translatedDiagnostics: [],
|
||||
aiFindings,
|
||||
errors: [],
|
||||
degraded: false,
|
||||
startTime: Date.now(),
|
||||
filePath: '/test/sample.js',
|
||||
language: 'javascript',
|
||||
adapterIds: ['eslint'],
|
||||
});
|
||||
|
||||
assert.strictEqual(report.linterCount, 1);
|
||||
assert.strictEqual(report.customRuleCount, 1);
|
||||
assert.strictEqual(report.aiCount, 1);
|
||||
assert.strictEqual(report.degraded, false);
|
||||
assert.strictEqual(report.language, 'javascript');
|
||||
});
|
||||
|
||||
test('mergeResults marks degraded when AI fails', () => {
|
||||
const report = mergeResults({
|
||||
staticDiagnostics: [],
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
aiFindings: [],
|
||||
errors: ['AI 请求超时'],
|
||||
degraded: true,
|
||||
startTime: Date.now(),
|
||||
filePath: '/test/sample.js',
|
||||
language: 'javascript',
|
||||
adapterIds: ['eslint'],
|
||||
});
|
||||
|
||||
assert.strictEqual(report.degraded, true);
|
||||
assert.strictEqual(report.errors.length, 1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `src/test/pipeline.test.ts`
|
||||
|
||||
```typescript
|
||||
import * as assert from 'assert';
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { ESLintAdapter } from '../adapters/eslint';
|
||||
import { mergeResults } from '../merger/merger';
|
||||
|
||||
suite('Pipeline Tests', () => {
|
||||
test('Full pipeline: linter check + merge', async () => {
|
||||
const adapter = new ESLintAdapter();
|
||||
if (!adapter.isAvailable()) { return; }
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
content: 'var x = 1;\nvar y = 2;\n',
|
||||
language: 'javascript',
|
||||
});
|
||||
|
||||
const staticResult = await adapter.check(doc, __dirname);
|
||||
assert.ok(staticResult.status === 'ok');
|
||||
|
||||
const report = mergeResults({
|
||||
staticDiagnostics: staticResult.diagnostics,
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
aiFindings: [],
|
||||
errors: [],
|
||||
degraded: false,
|
||||
startTime: Date.now(),
|
||||
filePath: 'virtual-doc',
|
||||
language: 'javascript',
|
||||
adapterIds: ['eslint'],
|
||||
});
|
||||
|
||||
assert.ok(typeof report.duration === 'number');
|
||||
assert.ok(typeof report.linterCount === 'number');
|
||||
assert.strictEqual(report.language, 'javascript');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npm test # compile + lint + test
|
||||
# 或
|
||||
npm run compile && npm run lint && npm run test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 所有测试文件创建完成
|
||||
- [ ] 测试夹具文件(fixtures)就位
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
- [ ] `npm test` 通过
|
||||
@@ -0,0 +1,703 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>代码审查报告 - UI 预览</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 16px 12px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.header h2 {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.header .meta {
|
||||
color: #8b949e;
|
||||
font-size: 12px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #30363d;
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background .15s, border-color .15s;
|
||||
}
|
||||
.btn:hover { background: #30363d; }
|
||||
.btn-primary {
|
||||
background: #238636;
|
||||
color: #fff;
|
||||
border-color: rgba(240,246,252,0.1);
|
||||
}
|
||||
.btn-primary:hover { background: #2ea043; }
|
||||
|
||||
/* ─── Banner ─── */
|
||||
.banner {
|
||||
margin: 12px 16px 0;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.banner-warning {
|
||||
background: rgba(200, 160, 0, 0.15);
|
||||
border: 1px solid rgba(200, 160, 0, 0.35);
|
||||
color: #cca700;
|
||||
}
|
||||
.banner-error {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border: 1px solid rgba(248, 81, 73, 0.35);
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
/* ─── Errors box ─── */
|
||||
.errors-box {
|
||||
margin: 12px 16px 0;
|
||||
padding: 10px 12px;
|
||||
background: rgba(248, 81, 73, 0.1);
|
||||
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.errors-box-title {
|
||||
font-weight: 600;
|
||||
color: #f48771;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.errors-box-item {
|
||||
font-size: 12px;
|
||||
color: #f48771;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
/* ─── Summary Stats ─── */
|
||||
.summary {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.stat-card {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
background: #161b22;
|
||||
text-align: center;
|
||||
}
|
||||
.stat-card .num {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-card .label {
|
||||
font-size: 11px;
|
||||
color: #8b949e;
|
||||
margin-top: 1px;
|
||||
}
|
||||
.stat-error .num { color: #f48771; }
|
||||
.stat-warning .num { color: #d29922; }
|
||||
.stat-info .num { color: #58a6ff; }
|
||||
.stat-total .num { color: #e6edf3; }
|
||||
|
||||
/* ─── Tab Bar ─── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
margin: 16px 16px 0;
|
||||
border-bottom: 1px solid #21262d;
|
||||
}
|
||||
.tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: color .15s, border-color .15s;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover { color: #e6edf3; }
|
||||
.tab.active {
|
||||
color: #e6edf3;
|
||||
border-bottom-color: #f78166;
|
||||
}
|
||||
.tab-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 9px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
}
|
||||
.tab-count-error { background: rgba(248, 81, 73, 0.2); color: #f48771; }
|
||||
.tab-count-warning { background: rgba(200, 160, 0, 0.2); color: #cca700; }
|
||||
.tab-count-info { background: rgba(88, 166, 255, 0.2); color: #58a6ff; }
|
||||
|
||||
/* ─── Tab Content ─── */
|
||||
.tab-content { display: none; padding: 8px 16px 20px; }
|
||||
.tab-content.active { display: block; }
|
||||
|
||||
/* ─── Section header ─── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.section-header:first-child { padding-top: 0; }
|
||||
.section-header-title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
}
|
||||
|
||||
/* ─── Item ─── */
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
margin-top: 6px;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
background: #161b22;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s;
|
||||
}
|
||||
.item:first-child { margin-top: 0; }
|
||||
.item:hover {
|
||||
border-color: #58a6ff;
|
||||
background: #1c2128;
|
||||
}
|
||||
.item.expanded {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
.item-severity {
|
||||
flex-shrink: 0;
|
||||
width: 5px;
|
||||
align-self: stretch;
|
||||
border-radius: 3px;
|
||||
margin: -10px 0 -10px -12px;
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
.item-severity-error { background: #f48771; }
|
||||
.item-severity-warning { background: #cca700; }
|
||||
.item-severity-info { background: #58a6ff; }
|
||||
|
||||
.item-body { flex: 1; min-width: 0; }
|
||||
.item-row1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
.item-icon {
|
||||
flex-shrink: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
}
|
||||
.icon-error { background: #f48771; }
|
||||
.icon-warning { background: #cca700; }
|
||||
.icon-info { background: #58a6ff; }
|
||||
|
||||
.item-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 7px;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .03em;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.badge-linter { background: rgba(139, 148, 158, 0.15); color: #8b949e; }
|
||||
.badge-custom { background: rgba(191, 133, 255, 0.15); color: #ce93d8; }
|
||||
.badge-ai { background: rgba(79, 195, 247, 0.15); color: #4dd0e1; }
|
||||
|
||||
.item-message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: #e6edf3;
|
||||
font-size: 14px;
|
||||
}
|
||||
.item-line {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #8b949e;
|
||||
font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
background: rgba(139, 148, 158, 0.08);
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.item-rule {
|
||||
flex-shrink: 0;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.item-fix {
|
||||
flex-shrink: 0;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid #30363d;
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: background .15s;
|
||||
line-height: 18px;
|
||||
}
|
||||
.item-fix:hover { background: #30363d; }
|
||||
.item-fix:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ─── Detail / Expanded ─── */
|
||||
.item-detail { display: none; margin-top: 8px; padding-top: 8px; border-top: 1px solid #21262d; }
|
||||
.item.expanded .item-detail { display: block; animation: fadeSlideIn .2s ease; }
|
||||
@keyframes fadeSlideIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
.detail-text { color: #8b949e; font-size: 13px; line-height: 1.7; }
|
||||
.detail-text code { font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; }
|
||||
.detail-suggestion {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(88, 166, 255, 0.08);
|
||||
border: 1px solid rgba(88, 166, 255, 0.2);
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #79c0ff;
|
||||
}
|
||||
.detail-diff {
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
.detail-diff del {
|
||||
color: #f48771;
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
.detail-diff ins {
|
||||
color: #3fb950;
|
||||
background: rgba(63, 185, 80, 0.15);
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
.detail-original {
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
font-style: italic;
|
||||
}
|
||||
.detail-category {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: rgba(139,148,158,0.1);
|
||||
color: #8b949e;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
/* ─── Empty state ─── */
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px 20px;
|
||||
color: #8b949e;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* ─── Actions bar ─── */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 16px 16px 20px;
|
||||
border-top: 1px solid #21262d;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<div>
|
||||
<h2>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="#58a6ff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/>
|
||||
<rect x="9" y="3" width="6" height="4" rx="1"/>
|
||||
<path d="M9 14l2 2 4-4"/>
|
||||
</svg>
|
||||
代码审查报告
|
||||
</h2>
|
||||
<div class="meta">
|
||||
src/UserService.java · Java · 8.3s
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="banner banner-warning">⚠ AI 审查未完成,报告仅包含静态分析结果</div>
|
||||
|
||||
<div class="errors-box">
|
||||
<div class="errors-box-title">✖ 执行错误</div>
|
||||
<div class="errors-box-item">未配置 sql 语言的 linter,请在设置中添加 vscode-code-reviewer.linters.sql</div>
|
||||
<div class="errors-box-item">AI 审查失败: API 请求超时</div>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<div class="stat-card stat-total"><div class="num">12</div><div class="label">总计问题</div></div>
|
||||
<div class="stat-card stat-error"><div class="num">3</div><div class="label">错误</div></div>
|
||||
<div class="stat-card stat-warning"><div class="num">7</div><div class="label">警告</div></div>
|
||||
<div class="stat-card stat-info"><div class="num">2</div><div class="label">建议</div></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-bar">
|
||||
<div class="tab active" data-tab="linter" onclick="switchTab('linter')">
|
||||
🔧 ESLint + PMD
|
||||
<span class="tab-count tab-count-error">2</span>
|
||||
<span class="tab-count tab-count-warning">4</span>
|
||||
<span class="tab-count tab-count-info">1</span>
|
||||
</div>
|
||||
<div class="tab" data-tab="custom" onclick="switchTab('custom')">
|
||||
📋 自定义规则
|
||||
<span class="tab-count tab-count-error">1</span>
|
||||
<span class="tab-count tab-count-warning">1</span>
|
||||
</div>
|
||||
<div class="tab" data-tab="ai" onclick="switchTab('ai')">
|
||||
🤖 AI 审查
|
||||
<span class="tab-count tab-count-warning">2</span>
|
||||
<span class="tab-count tab-count-info">1</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="tab-linter">
|
||||
<div class="section-header">
|
||||
<span class="section-header-title">ESLint + PMD · 7 个问题</span>
|
||||
<button class="btn">🔧 全部修复</button>
|
||||
</div>
|
||||
|
||||
<div class="item static-item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-error"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-error"></span>
|
||||
<span class="item-badge badge-linter">Linter</span>
|
||||
<span class="item-rule">pmd:AvoidUsingHardCodedPassword</span>
|
||||
<span class="item-message">硬编码密码:密码不应硬编码在代码中</span>
|
||||
<span class="item-line">L13</span>
|
||||
<button class="item-fix" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';">🔧 修复</button>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-original">原文: Hard-coded password detected (DB_PASS) in field definition</div>
|
||||
<div class="detail-suggestion">将密码移至环境变量或配置中心,通过 system.getenv() 读取,避免硬编码在源码中</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item static-item expanded" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-error"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-error"></span>
|
||||
<span class="item-badge badge-linter">Linter</span>
|
||||
<span class="item-rule">pmd:SQLInjection</span>
|
||||
<span class="item-message">SQL 注入:直接拼接用户输入可能导致 SQL 注入攻击</span>
|
||||
<span class="item-line">L18</span>
|
||||
<button class="item-fix" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';">🔧 修复</button>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-original">原文: SQL injection detected: string concatenation in query</div>
|
||||
<div class="detail-suggestion">改用 PreparedStatement 参数化查询,避免字符串拼接</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item static-item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-warning"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-warning"></span>
|
||||
<span class="item-badge badge-linter">Linter</span>
|
||||
<span class="item-rule">pmd:CloseResource</span>
|
||||
<span class="item-message">资源未关闭:Statement/ResultSet 未在 finally 中关闭</span>
|
||||
<span class="item-line">L19</span>
|
||||
<button class="item-fix" onclick="event.stopPropagation(); this.disabled=true; this.textContent='⏳...';">🔧 修复</button>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-original">原文: Ensure that resources like Statement and ResultSet are closed</div>
|
||||
<div class="detail-suggestion">使用 try-with-resources 自动关闭 Statement 和 ResultSet,或确保在 finally 块中关闭它们</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item static-item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-warning"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-warning"></span>
|
||||
<span class="item-badge badge-linter">Linter</span>
|
||||
<span class="item-rule">pmd:MethodNamingConventions</span>
|
||||
<span class="item-message">方法命名不规范:FetchUserById 应以小写字母开头</span>
|
||||
<span class="item-line">L37</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-original">原文: Method name 'FetchUserById' does not follow camelCase naming convention</div>
|
||||
<div class="detail-suggestion">将方法名改为 fetchUserById,遵循 Java camelCase 命名规范</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item static-item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-warning"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-warning"></span>
|
||||
<span class="item-badge badge-linter">Linter</span>
|
||||
<span class="item-rule">pmd:FieldNamingConventions</span>
|
||||
<span class="item-message">字段命名:api_key 应使用大写常量命名</span>
|
||||
<span class="item-line">L44</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-original">原文: Field name 'api_key' does not follow naming convention</div>
|
||||
<div class="detail-suggestion">对于 static final 常量,应将名称改为 API_KEY 并使用下划线分隔</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item static-item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-warning"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-warning"></span>
|
||||
<span class="item-badge badge-linter">Linter</span>
|
||||
<span class="item-rule">eslint:no-console</span>
|
||||
<span class="item-message">生产代码不应保留 console 语句</span>
|
||||
<span class="item-line">L57</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-original">原文: Unexpected console statement (no-console)</div>
|
||||
<div class="detail-suggestion">移除 console.log 调试语句,或使用专门的日志框架替代</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item static-item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-info"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-info"></span>
|
||||
<span class="item-badge badge-linter">Linter</span>
|
||||
<span class="item-rule">pmd:UnusedLocalVariable</span>
|
||||
<span class="item-message">未使用的局部变量:unusedVar 已声明但未使用</span>
|
||||
<span class="item-line">L56</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-original">原文: Avoid unused local variables such as 'unusedVar'</div>
|
||||
<div class="detail-suggestion">删除未使用的变量声明,或确认是否需要该变量</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tab-custom">
|
||||
<div class="section-header">
|
||||
<span class="section-header-title">自定义规则 · 2 个问题</span>
|
||||
</div>
|
||||
|
||||
<div class="item static-item expanded" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-error"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-error"></span>
|
||||
<span class="item-badge badge-custom">自定义</span>
|
||||
<span class="item-rule">no-hardcoded-credentials</span>
|
||||
<span class="item-message">禁止在代码中硬编码数据库密码和 API Key</span>
|
||||
<span class="item-line">L13</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-text">规则 no-hardcoded-credentials 触发: 禁止在代码中硬编码数据库密码和 API Key</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item static-item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-warning"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-warning"></span>
|
||||
<span class="item-badge badge-custom">自定义</span>
|
||||
<span class="item-rule">avoid-console-log</span>
|
||||
<span class="item-message">生产代码不应使用 console.log 调试</span>
|
||||
<span class="item-line">L57</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-text">规则 avoid-console-log 触发: 生产代码不应使用 console.log 调试</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="tab-ai">
|
||||
<div class="section-header">
|
||||
<span class="section-header-title">AI 审查建议 · 3 条</span>
|
||||
</div>
|
||||
|
||||
<div class="item expanded" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-warning"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-warning"></span>
|
||||
<span class="item-badge badge-ai">AI</span>
|
||||
<span class="item-rule">sql-injection</span>
|
||||
<span class="item-message">SQL注入风险:用户状态参数直接拼接 SQL 查询</span>
|
||||
<span class="item-line">L18</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-text">getActiveUsers 方法中,status 参数直接拼接到 SQL 查询字符串中,攻击者可传入恶意值导致 SQL 注入。同时缺少对 Connection 为 null 的防御性检查。</div>
|
||||
<span class="detail-category">🎯 安全漏洞</span>
|
||||
<div class="detail-suggestion">使用 PreparedStatement 参数化查询;添加 Connection 非空校验;使用 try-with-resources 管理资源</div>
|
||||
<div class="detail-diff"><del>- String query = "SELECT username FROM users WHERE status = '" + status + "'";</del>
|
||||
<ins>+ String query = "SELECT username FROM users WHERE status = ?";</ins>
|
||||
<ins>+ PreparedStatement ps = conn.prepareStatement(query);</ins>
|
||||
<ins>+ ps.setString(1, status);</ins>
|
||||
<ins>+ ResultSet rs = ps.executeQuery();</ins></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-warning"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-warning"></span>
|
||||
<span class="item-badge badge-ai">AI</span>
|
||||
<span class="item-rule">hardcoded-secret</span>
|
||||
<span class="item-message">硬编码密钥:数据库密码和 API Key 直接写在源码中</span>
|
||||
<span class="item-line">L13</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-text">DB_PASS 和 api_key 都是敏感凭据,硬编码在类中会导致密钥泄露。任何有权访问代码的人(包括通过反编译 .class 文件)都可获取这些凭据。</div>
|
||||
<span class="detail-category">🎯 安全漏洞</span>
|
||||
<div class="detail-suggestion">使用环境变量或密钥管理服务(如 Vault)存储敏感信息,在运行时注入</div>
|
||||
<div class="detail-diff"><del>- private static final String DB_PASS = "admin123";</del>
|
||||
<ins>+ private static final String DB_PASS = System.getenv("DB_PASS");</ins></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="item" onclick="toggleItem(this)">
|
||||
<div class="item-severity item-severity-info"></div>
|
||||
<div class="item-body">
|
||||
<div class="item-row1">
|
||||
<span class="item-icon icon-info"></span>
|
||||
<span class="item-badge badge-ai">AI</span>
|
||||
<span class="item-rule">unused-method</span>
|
||||
<span class="item-message">未使用的公共方法:unusedMethod 疑似死代码</span>
|
||||
<span class="item-line">L55</span>
|
||||
</div>
|
||||
<div class="item-detail">
|
||||
<div class="detail-text">unusedMethod 方法仅在 main 中未被调用,且其内部存在 DEBUG 打印和未使用的局部变量,可能是开发阶段遗留的测试代码,建议确认后删除。</div>
|
||||
<span class="detail-category">🎯 代码设计</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary">🔄 重新审查</button>
|
||||
<button class="btn">📄 导出报告</button>
|
||||
<button class="btn">⚙️ 设置</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
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;
|
||||
el.classList.toggle('expanded');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,75 @@
|
||||
# 审查结果面板 UI 说明
|
||||
|
||||
## 顶部区域
|
||||
| 元素 | 描述 |
|
||||
|------|------|
|
||||
| 标题 | SVG 图标 + "代码审查报告" |
|
||||
| 元信息 | 文件路径 · 语言 · 耗时(秒) |
|
||||
|
||||
## 降级横幅(AI 失败时显示)
|
||||
```
|
||||
⚠ AI 审查未完成,报告仅包含静态分析结果
|
||||
```
|
||||
|
||||
## 执行错误框
|
||||
列出 linter 未配置/未安装、适配器异常等信息。
|
||||
|
||||
## 统计卡片
|
||||
| 卡片 | 颜色 | 说明 |
|
||||
|------|------|------|
|
||||
| 总计问题 | 白色 | 所有问题数量之和 |
|
||||
| 错误 | 红色 `#f48771` | error 级别数量 |
|
||||
| 警告 | 黄色 `#d29922` | warning 级别数量 |
|
||||
| 建议 | 蓝色 `#58a6ff` | info 级别数量 |
|
||||
|
||||
## 三栏标签页
|
||||
| 标签 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| `🔧 {linter名称}` | ESLint / PMD / SqlLint / Stylelint | 静态分析检测到的问题 |
|
||||
| `📋 自定义规则` | `.code-review-rules.yaml` → AI 评估 | 团队自定义规则违规 |
|
||||
| `🤖 AI 审查` | DeepSeek API | AI 深度审查发现的问题 |
|
||||
|
||||
每个标签右侧有药丸计数(红/黄/蓝分别对应错误/警告/建议数量)。
|
||||
|
||||
## 问题条目
|
||||
每一条问题卡片包含:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│▌ ● BADGE ruleId 消息文本... L行号 │ ← 点击展开详情
|
||||
│▌ 🔧 │ ← 修复按钮
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| 元素 | 说明 |
|
||||
|------|------|
|
||||
| 左侧色条 `▌` | 红色=错误, 黄色=警告, 蓝色=建议(5px 宽圆角条) |
|
||||
| 彩色圆点 `●` | 同色圆点辅助标识 |
|
||||
| Badge | `Linter`(灰色)/ `自定义`(紫色)/ `AI`(青色) |
|
||||
| ruleId | 规则标识,如 `pmd:SQLInjection` |
|
||||
| 消息 | 中文问题描述(单行省略) |
|
||||
| 行号 | `L18` 格式,灰色背景 |
|
||||
| 🔧 修复 | 仅 fixable 条目显示,点击后变 `⏳...` 并禁用 |
|
||||
|
||||
## 展开详情(点击条目切换)
|
||||
- **原文对照**:原始英文问题描述(linter 条目)
|
||||
- **💡 修复建议**:蓝色背景框,给出具体修复方向
|
||||
- **diff 代码对比**:深色背景 + `del`(红色) / `ins`(绿色) 显示修改前后
|
||||
- **🎯 AI 分类标签**:如"安全漏洞"、"代码设计"等(仅 AI 条目)
|
||||
|
||||
## 底部操作栏
|
||||
| 按钮 | 功能 |
|
||||
|------|------|
|
||||
| 🔄 重新审查 | 重新运行审查流程 |
|
||||
| 📄 导出报告 | 复制到剪贴板 / 保存为 Markdown |
|
||||
| ⚙️ 设置 | 打开 VS Code 设置(vscode-code-reviewer) |
|
||||
|
||||
## 交互行为
|
||||
- 点击条目 ← 展开/收起详情
|
||||
- 点击条目空白区 ← 跳转到编辑器对应行
|
||||
- 点击 🔧 修复 ← 调 AI 生成修复并应用,完成后自动重新审查
|
||||
- 标签页切换 ← 三栏独立切换(保留展开状态 via `retainContextWhenHidden`)
|
||||
|
||||
## 空状态
|
||||
- 某栏无问题:居中斜体提示,如"未发现任何问题"、"无 AI 审查建议"
|
||||
- Linter 未启用:提示"请检查上方错误信息"
|
||||
@@ -0,0 +1,577 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>设置面板 - UI 预览</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #c9d1d9;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
}
|
||||
.panel {
|
||||
width: 380px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ─── Header ─── */
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #21262d;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.panel-header svg { flex-shrink: 0; }
|
||||
|
||||
/* ─── Section ─── */
|
||||
.section { padding: 14px 16px; border-bottom: 1px solid #21262d; }
|
||||
.section:last-of-type { border-bottom: none; }
|
||||
.section-title {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
color: #8b949e;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ─── Quick Start ─── */
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
}
|
||||
.step-circle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
border: 2px solid #30363d;
|
||||
transition: background .3s, border-color .3s, color .3s;
|
||||
}
|
||||
.step-circle.done {
|
||||
background: rgba(139, 92, 246, 0.2);
|
||||
border-color: #8b5cf6;
|
||||
color: #a78bfa;
|
||||
}
|
||||
.step-text { font-size: 13px; color: #e6edf3; flex: 1; }
|
||||
.step-hint { font-size: 11px; color: #8b949e; margin-top: 1px; }
|
||||
.step-hint kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', Consolas, monospace;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 3px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* ─── Engines ─── */
|
||||
.engines { display: flex; flex-direction: column; gap: 6px; }
|
||||
.engine-tab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 6px;
|
||||
background: #0d1117;
|
||||
cursor: default;
|
||||
}
|
||||
.engine-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dot-purple { background: #8b5cf6; }
|
||||
.dot-amber { background: #d29922; }
|
||||
.dot-green { background: #3fb950; }
|
||||
.engine-label { font-size: 13px; color: #e6edf3; }
|
||||
.engine-desc { font-size: 11px; color: #8b949e; }
|
||||
|
||||
/* ─── Card ─── */
|
||||
.card {
|
||||
background: #0d1117;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.card-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.card-row:last-child { margin-bottom: 0; }
|
||||
.card-label {
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* ─── Status badge ─── */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 1px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
}
|
||||
.badge-configured {
|
||||
background: rgba(35, 134, 54, 0.15);
|
||||
color: #3fb950;
|
||||
}
|
||||
.badge-unconfigured {
|
||||
background: rgba(139, 148, 158, 0.12);
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
/* ─── Field ─── */
|
||||
.field { margin-bottom: 8px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
.field-label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
select, input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
select:focus, input:focus { border-color: #8b5cf6; }
|
||||
select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%238b949e' viewBox='0 0 16 16'%3E%3Cpath d='M4.427 6.427l3.396 3.396a.25.25 0 0 0 .354 0l3.396-3.396A.25.25 0 0 0 11.396 6H4.604a.25.25 0 0 0-.177.427z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 8px center;
|
||||
padding-right: 30px;
|
||||
cursor: pointer;
|
||||
}
|
||||
input::placeholder { color: #484f58; }
|
||||
.field-hint {
|
||||
font-size: 11px;
|
||||
color: #484f58;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ─── Input with button ─── */
|
||||
.input-group { display: flex; gap: 4px; }
|
||||
.input-group input { flex: 1; }
|
||||
.input-group .btn { flex-shrink: 0; }
|
||||
|
||||
/* ─── Buttons ─── */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px 12px;
|
||||
border: 1px solid #30363d;
|
||||
background: #21262d;
|
||||
color: #c9d1d9;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background .15s, border-color .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: #30363d; }
|
||||
.btn-primary {
|
||||
background: #7c3aed;
|
||||
color: #fff;
|
||||
border-color: #7c3aed;
|
||||
}
|
||||
.btn-primary:hover { background: #8b5cf6; }
|
||||
.btn-primary:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn-sm { padding: 2px 8px; font-size: 11px; line-height: 20px; }
|
||||
|
||||
/* ─── Toggle switch ─── */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.switch input { display: none; }
|
||||
.switch-track {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 9px;
|
||||
background: #30363d;
|
||||
transition: background .2s;
|
||||
}
|
||||
.switch input:checked + .switch-track { background: #7c3aed; }
|
||||
.switch-thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
background: #e6edf3;
|
||||
transition: transform .2s;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
||||
}
|
||||
.switch input:checked ~ .switch-thumb { transform: translateX(14px); }
|
||||
|
||||
/* ─── Rule item ─── */
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
margin-top: 4px;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 6px;
|
||||
background: #0d1117;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.rule-item:first-child { margin-top: 0; }
|
||||
.rule-item:hover { border-color: #30363d; }
|
||||
.rule-name {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
font-family: 'SF Mono', Consolas, 'Liberation Mono', Menlo, monospace;
|
||||
color: #e6edf3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rule-del {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #8b949e;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color .15s, background .15s;
|
||||
}
|
||||
.rule-del:hover {
|
||||
color: #f48771;
|
||||
background: rgba(248,81,73,0.15);
|
||||
}
|
||||
|
||||
/* ─── Actions bar ─── */
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid #21262d;
|
||||
}
|
||||
.actions .btn { flex: 1; justify-content: center; }
|
||||
|
||||
/* ─── Toast ─── */
|
||||
.toast {
|
||||
display: none;
|
||||
margin: 0 16px 12px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
animation: fadeIn .2s ease;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.toast-success {
|
||||
display: block;
|
||||
background: rgba(35, 134, 54, 0.15);
|
||||
border: 1px solid rgba(35, 134, 54, 0.3);
|
||||
color: #3fb950;
|
||||
}
|
||||
.toast-error {
|
||||
display: block;
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border: 1px solid rgba(248, 81, 73, 0.3);
|
||||
color: #f48771;
|
||||
}
|
||||
|
||||
/* ─── Spinner ─── */
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.spinner {
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255,255,255,0.2);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: spin .6s linear infinite;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="panel">
|
||||
<!-- Header -->
|
||||
<div class="panel-header">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#8b5cf6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
代码审查 · 设置
|
||||
</div>
|
||||
|
||||
<!-- 1. 快速开始 -->
|
||||
<div class="section">
|
||||
<div class="section-title">快速开始</div>
|
||||
<div class="step">
|
||||
<div class="step-circle done">①</div>
|
||||
<div>
|
||||
<div class="step-text">配置 AI 模型及 API Key</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-circle">②</div>
|
||||
<div>
|
||||
<div class="step-text">启用自定义规则</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-circle">③</div>
|
||||
<div>
|
||||
<div class="step-text">保存并测试连接</div>
|
||||
<div class="step-hint">快捷键 <kbd>Ctrl+Shift+R</kbd></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 审核引擎 -->
|
||||
<div class="section">
|
||||
<div class="section-title">审核引擎</div>
|
||||
<div class="engines">
|
||||
<div class="engine-tab">
|
||||
<span class="engine-dot dot-purple"></span>
|
||||
<div>
|
||||
<div class="engine-label">共通规则</div>
|
||||
<div class="engine-desc">Linter 静态分析</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="engine-tab">
|
||||
<span class="engine-dot dot-amber"></span>
|
||||
<div>
|
||||
<div class="engine-label">自定义规则</div>
|
||||
<div class="engine-desc">团队编码规范</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="engine-tab">
|
||||
<span class="engine-dot dot-green"></span>
|
||||
<div>
|
||||
<div class="engine-label">AI 审核</div>
|
||||
<div class="engine-desc">深度代码审查</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 3. AI 模型配置 -->
|
||||
<div class="section">
|
||||
<div class="section-title">AI 模型配置</div>
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<span class="card-label">模型提供商</span>
|
||||
<span class="badge badge-configured">已配置</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<select>
|
||||
<option>DeepSeek</option>
|
||||
<option>OpenAI</option>
|
||||
<option>Azure OpenAI</option>
|
||||
<option>腾讯混元</option>
|
||||
<option>Ollama</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">模型名称</label>
|
||||
<select>
|
||||
<option selected>deepseek-chat</option>
|
||||
<option>deepseek-reasoner</option>
|
||||
<option>gpt-4o</option>
|
||||
<option>gpt-4o-mini</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-hint">建议使用支持结构化输出的模型。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 4. API Key -->
|
||||
<div class="section">
|
||||
<div class="section-title">API Key</div>
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<span class="card-label">API Key</span>
|
||||
<span class="badge badge-configured">已配置</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<input type="password" value="sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" readonly>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Base URL</label>
|
||||
<input type="text" value="https://api.deepseek.com/v1">
|
||||
</div>
|
||||
<div class="field-hint">Key 仅存储在本地 VS Code 安全存储中。</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 输出语言 -->
|
||||
<div class="section">
|
||||
<div class="section-title">输出语言</div>
|
||||
<div class="field">
|
||||
<label class="field-label">AI 审查结果输出语言</label>
|
||||
<select>
|
||||
<option selected>中文(简体)</option>
|
||||
<option>English</option>
|
||||
<option>日本語</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 6. 自定义规则 -->
|
||||
<div class="section">
|
||||
<div class="section-title">自定义规则</div>
|
||||
<div class="card">
|
||||
<div class="card-row">
|
||||
<span class="card-label">规则列表</span>
|
||||
<span class="badge badge-configured">3 条启用</span>
|
||||
</div>
|
||||
|
||||
<div class="rule-item">
|
||||
<label class="switch">
|
||||
<input type="checkbox" checked>
|
||||
<div class="switch-track"></div>
|
||||
<div class="switch-thumb"></div>
|
||||
</label>
|
||||
<span class="rule-name">no-console-in-production</span>
|
||||
<button class="rule-del">×</button>
|
||||
</div>
|
||||
<div class="rule-item">
|
||||
<label class="switch">
|
||||
<input type="checkbox" checked>
|
||||
<div class="switch-track"></div>
|
||||
<div class="switch-thumb"></div>
|
||||
</label>
|
||||
<span class="rule-name">max-function-lines: 80</span>
|
||||
<button class="rule-del">×</button>
|
||||
</div>
|
||||
<div class="rule-item">
|
||||
<label class="switch">
|
||||
<input type="checkbox" checked>
|
||||
<div class="switch-track"></div>
|
||||
<div class="switch-thumb"></div>
|
||||
</label>
|
||||
<span class="rule-name">require-javadoc-public</span>
|
||||
<button class="rule-del">×</button>
|
||||
</div>
|
||||
<div class="rule-item">
|
||||
<label class="switch">
|
||||
<input type="checkbox">
|
||||
<div class="switch-track"></div>
|
||||
<div class="switch-thumb"></div>
|
||||
</label>
|
||||
<span class="rule-name">no-any-type</span>
|
||||
<button class="rule-del">×</button>
|
||||
</div>
|
||||
|
||||
<div class="field" style="margin-top: 8px;">
|
||||
<div class="input-group">
|
||||
<input type="text" placeholder="输入规则名称...">
|
||||
<button class="btn btn-sm" style="background:#7c3aed;color:#fff;border-color:#7c3aed;">+ 添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="actions">
|
||||
<button class="btn" onclick="showToast('reset')">重置</button>
|
||||
<button class="btn btn-primary" id="btn-test" onclick="testConnection()">保存并测试连接</button>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showToast(type, msg) {
|
||||
var el = document.getElementById('toast');
|
||||
el.className = 'toast';
|
||||
if (type === 'success') {
|
||||
el.classList.add('toast-success');
|
||||
el.textContent = msg || '✓ 保存成功,API 连接正常';
|
||||
} else if (type === 'error') {
|
||||
el.classList.add('toast-error');
|
||||
el.textContent = msg || '✗ 连接失败,请检查配置';
|
||||
}
|
||||
setTimeout(function() { el.className = 'toast'; }, 5000);
|
||||
}
|
||||
|
||||
function testConnection() {
|
||||
var btn = document.getElementById('btn-test');
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="spinner"></span> 测试中...';
|
||||
|
||||
setTimeout(function() {
|
||||
// Simulate: 80% success
|
||||
if (Math.random() > 0.2) {
|
||||
btn.innerHTML = '✓ 已连接';
|
||||
btn.className = 'btn btn-primary';
|
||||
showToast('success');
|
||||
// Mark step ① and ③ as done
|
||||
document.querySelectorAll('.step-circle')[0].classList.add('done');
|
||||
document.querySelectorAll('.step-circle')[2].classList.add('done');
|
||||
} else {
|
||||
btn.innerHTML = '✗ 重试';
|
||||
btn.className = 'btn btn-primary';
|
||||
showToast('error', '✗ API Key 无效,请重新设置');
|
||||
}
|
||||
btn.disabled = false;
|
||||
}, 1500);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,85 @@
|
||||
# 设置面板 UI 说明
|
||||
|
||||
## 内容区
|
||||
|
||||
### 1. 快速开始
|
||||
|
||||
三步引导卡片:
|
||||
|
||||
| 步骤 | 描述 | 完成状态 |
|
||||
|------|------|---------|
|
||||
| ① | 配置 AI 模型及 API Key | 圆形序号变紫色(`done`) |
|
||||
| ② | 启用自定义规则 | 同上 |
|
||||
| ③ | 保存并测试连接,含快捷键提示 `Ctrl+Shift+R` | 同上 |
|
||||
|
||||
`Ctrl+Shift+R` 为 `vscode-code-reviewer.review` 快捷键。
|
||||
|
||||
### 2. 审核引擎
|
||||
|
||||
三个模块标签:
|
||||
|
||||
| 标签 | 圆点颜色 | 描述 |
|
||||
|------|---------|------|
|
||||
| 🔵 共通规则 | 蓝色 `#58a6ff` | Linter 静态分析 |
|
||||
| 🟡 自定义规则 | 琥珀色 `#d29922` | 团队编码规范 |
|
||||
| 🟢 AI 审核 | 绿色 `#3fb950` | 深度代码审查 |
|
||||
|
||||
### 3. AI 模型配置
|
||||
|
||||
卡片式表单:
|
||||
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| 模型提供商 | 下拉选择 | OpenAI / Azure OpenAI / DeepSeek / 腾讯混元 / Ollama |
|
||||
| 模型名称 | 下拉选择 | 按提供商动态切换可选模型列表 |
|
||||
| 状态标签 | `已配置` / `未配置` | 右侧药丸标签(绿/灰) |
|
||||
|
||||
底部提示:建议使用支持结构化输出的模型。
|
||||
|
||||
### 4. API Key
|
||||
|
||||
卡片式表单:
|
||||
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| API Key | 密码输入框 | `sk-...` 格式,已输入显示为圆点掩码 |
|
||||
| Base URL | 文本输入框 | 可选,按提供商自动填充默认地址 |
|
||||
| 状态标签 | `已配置` / `未配置` | 右侧药丸标签 |
|
||||
|
||||
底部提示:Key 仅存储在本地 VS Code 安全存储中。
|
||||
|
||||
### 5a. 输出语言
|
||||
|
||||
| 字段 | 控件 | 说明 |
|
||||
|------|------|------|
|
||||
| AI 审查结果输出语言 | 下拉选择 | 中文(简体)/ English / 日本語 |
|
||||
|
||||
配置项 `ai.outputLanguage`,默认 `zh-CN`。
|
||||
|
||||
### 5b. 自定义规则
|
||||
|
||||
| 元素 | 说明 |
|
||||
|------|------|
|
||||
| 状态标签 | 显示已启用数,如 `3 条启用` |
|
||||
| 规则行 | 开关滑块 + 规则名(等宽字体)+ 删除按钮 `×` |
|
||||
| 添加行 | 文本框 + 紫色 `+ 添加` 按钮 |
|
||||
|
||||
示例规则:`no-console-in-production`、`max-function-lines: 80`、`require-javadoc-public`、`no-any-type`
|
||||
|
||||
## 底部操作栏
|
||||
|
||||
| 按钮 | 样式 | 功能 |
|
||||
|------|------|------|
|
||||
| `重置` | 次级按钮(暗色边框) | 恢复默认设置 |
|
||||
| `保存并测试连接` | 主按钮(紫色 `#7c3aed`) | 保存配置 → 调用 API 测试 → 显示结果 |
|
||||
| 测试中 | 按钮禁用 + spinner 动画 | — |
|
||||
| 测试成功 | 按钮变为 `✓ 已连接` | Toast 绿色提示,5 秒消失 |
|
||||
| 测试失败 | 按钮变为 `✗ 重试` | Toast 红色提示,5 秒消失 |
|
||||
|
||||
## 交互行为
|
||||
|
||||
- 输入 API Key → 步骤①自动标记完成(圆形变紫色)
|
||||
- 开启任意规则 → 步骤②自动标记完成
|
||||
- 步骤③取决于前两步完成 + 测试连接成功
|
||||
- 规则开关即时切换,可删除单条规则
|
||||
- 测试结果 Toast 5 秒自动消失
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"instructions": ["AGENTS.md"]
|
||||
}
|
||||
Reference in New Issue
Block a user