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
@@ -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` 通过