docs: upload code files and config

This commit is contained in:
Developer
2026-07-13 18:41:33 +08:00
parent 6a64aece24
commit cafe67db6d
38 changed files with 7448 additions and 126 deletions
+9
View File
@@ -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]
+17
View File
@@ -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]
+12
View File
@@ -0,0 +1,12 @@
---
name: stage-1-propose
description: ① 用户提出阶段 — 等待用户一句话描述任务
---
## 行为
- 不做任何操作
- 等待用户输入
- 确认收到任务后,进入 Stage ②
## 退出条件
- 用户已给出明确的一句任务描述
+20
View File
@@ -0,0 +1,20 @@
---
name: stage-2-clarify
description: ② 需求澄清阶段 — 沿设计树追问直到所有分支达成共识
---
## 行为规则
1. 先从代码库中探索,代码库能回答的绝不问人
2. 沿设计树的每个分支逐步追问
3. 一次只问一个问题,等用户反馈后再继续
4. 主干问题给出推荐答案,分支问题可开放式
5. 逐层解决决策间的依赖关系
6. 直到每个分支都达成共识
## 禁止
- 一次性问多个问题
- 跳过依赖关系
## 退出条件
- 设计树所有分支已走通
- 用户确认共识达成
+17
View File
@@ -0,0 +1,17 @@
---
name: stage-3-design
description: ③ 方案设计阶段 — 输出完整技术方案
---
## 行为
1. 基于 Stage ② 达成的共识,输出完整方案
2. 方案必须包含:
- 架构概览(模块划分、数据流)
- 文件变更清单(新建/修改/删除)
- 关键接口/API 定义
- 涉及的技术选型或依赖说明
- 变更的影响范围
3. 方案具体到能直接进入编码
## 退出条件
- 方案文档已产出
+16
View File
@@ -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. 不加注释(除非项目风格要求)
## 退出条件
- 所有方案文件已实现
+16
View File
@@ -0,0 +1,16 @@
---
name: stage-6-verify
description: ⑥ 审查验证阶段 — 运行检查 + AI 自审查 + 人类验收
---
## 行为
1. 运行 lint / 类型检查 / 编译
2. 运行已有测试
3. AI 自审查(潜在 bug、边界情况、安全)
4. 汇总结果:
- 通过 → 完成
- 有严重代码问题 → 回 Stage ⑤
- 有方案层面问题 → 回 Stage ③
## 退出条件
- 代码通过所有检查
- 人类最终验收确认
+87
View File
@@ -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
- 导入风格:ESMimport/export
- 不加注释
- 异步用 async/await
- ESLint 配置:`eslint.config.mjs`typescript-eslint parser
## 设计文档
设计 spec 存放路径:`docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`
编码须严格遵循已批准的 spec,不得自作主张。
+19
View File
@@ -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 APIopenai 兼容接口)
- esbuild(打包构建)
- ESLint + typescript-eslint(自检)
- Mocha + @vscode/test-electron(测试)
- LLM APIopenai 兼容接口)
- PMD 7.26.0Java/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 SecretStorageget/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 配置 getterprovider/model/endpoint/temperature/timeout/outputLanguage |
| `src/config/linter.ts` | 新建 | Linter 配置 getterlinters.xxx 语言-linter 映射、PMD 路径) |
| `src/config/fixer.ts` | 新建 | 修复器配置(contextLines |
| `src/config/secret.ts` | 新建 | API Key SecretStorageget/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 09AI Provider),Step 12Merger
**参考设计**: §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 12Merger
**参考设计**: §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、codeDiffdiff 代码块)
- 降级和错误信息在报告顶部单独显示
---
## 验收
- [ ] 文件创建完成
- [ ] `npm run compile` 通过
- [ ] `npm run lint` 通过
@@ -0,0 +1,252 @@
# Step 15 — Phase 5.1: 命令注册 + extension.ts 更新
**依赖**: Step 08, 14Orchestrator + 报告导出),Step 13Fixer 可选)
**参考设计**: §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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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 15extension.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 &middot; Java &middot; 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>
+75
View File
@@ -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">&times;</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">&times;</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">&times;</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">&times;</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>
+85
View File
@@ -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 秒自动消失
+4
View File
@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["AGENTS.md"]
}