docs: upload code files and config
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
# Step 01 — Phase 1: 基础层
|
||||
|
||||
**依赖**: 无
|
||||
**参考设计**: §3.2, §6.4, §13.2
|
||||
|
||||
## 目标
|
||||
|
||||
建立公共类型定义和配置管理,为所有上层模块提供基础能力。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/types.ts` | 公共类型定义 |
|
||||
| 2 | `src/config/ai.ts` | AI 配置 getter |
|
||||
| 3 | `src/config/linter.ts` | Linter 配置 getter |
|
||||
| 4 | `src/config/fixer.ts` | 修复器配置 getter |
|
||||
| 5 | `src/config/secret.ts` | API Key SecretStorage |
|
||||
| 6 | `src/config/index.ts` | 统一导出 |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/types.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export type Severity = 'error' | 'warning' | 'info';
|
||||
|
||||
export type AdapterStatus = 'ok' | 'tool-unavailable' | 'execution-failed';
|
||||
|
||||
export interface LinterDiagnostic {
|
||||
severity: Severity;
|
||||
ruleId: string;
|
||||
message: string;
|
||||
range: vscode.Range;
|
||||
suggestion?: string;
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
status: AdapterStatus;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export interface LinterAdapter {
|
||||
id: string;
|
||||
supportedLanguages: string[];
|
||||
check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult>;
|
||||
isAvailable(): boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/config/ai.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface AIConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
endpoint: string;
|
||||
temperature: number;
|
||||
timeout: number;
|
||||
outputLanguage: string;
|
||||
}
|
||||
|
||||
export function getAIConfig(): AIConfig {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
return {
|
||||
provider: config.get<string>('ai.provider', 'deepseek'),
|
||||
model: config.get<string>('ai.model', 'deepseek-chat'),
|
||||
endpoint: config.get<string>('ai.endpoint', 'https://api.deepseek.com/v1'),
|
||||
temperature: config.get<number>('ai.temperature', 0.2),
|
||||
timeout: config.get<number>('ai.timeout', 300),
|
||||
outputLanguage: config.get<string>('ai.outputLanguage', 'zh-CN'),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**配置 Key**(前缀 `vscode-code-reviewer.`):
|
||||
|
||||
| Key | 类型 | 默认值 |
|
||||
|-----|------|--------|
|
||||
| `ai.provider` | enum | `deepseek` |
|
||||
| `ai.model` | string | `deepseek-chat` |
|
||||
| `ai.endpoint` | string | `https://api.deepseek.com/v1` |
|
||||
| `ai.temperature` | number | 0.2 |
|
||||
| `ai.timeout` | number | 300 |
|
||||
| `ai.outputLanguage` | string | `zh-CN` |
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/config/linter.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface LinterConfig {
|
||||
languageMap: Record<string, string>;
|
||||
pmdJarPath: string;
|
||||
pmdRulesetPath: string;
|
||||
pmdJspRulesetPath: string;
|
||||
sqlLintConfigFile: string;
|
||||
}
|
||||
|
||||
export function getLinterConfig(): LinterConfig {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
return {
|
||||
languageMap: {
|
||||
javascript: config.get<string>('linters.javascript', 'eslint'),
|
||||
typescript: config.get<string>('linters.typescript', 'eslint'),
|
||||
java: config.get<string>('linters.java', 'pmd'),
|
||||
jsp: config.get<string>('linters.jsp', 'jsp'),
|
||||
css: config.get<string>('linters.css', 'stylelint'),
|
||||
sql: config.get<string>('linters.sql', 'sql-lint'),
|
||||
plsql: config.get<string>('linters.plsql', 'sql-lint'),
|
||||
},
|
||||
pmdJarPath: config.get<string>('pmd.jarPath', ''),
|
||||
pmdRulesetPath: config.get<string>('pmd.rulesetPath', ''),
|
||||
pmdJspRulesetPath: config.get<string>('pmd.jspRulesetPath', ''),
|
||||
sqlLintConfigFile: config.get<string>('sql-lint.configFile', ''),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
| Key | 类型 | 默认值 | enum |
|
||||
|-----|------|--------|------|
|
||||
| `linters.javascript` | enum | `eslint` | `""` / `eslint` |
|
||||
| `linters.typescript` | enum | `eslint` | `""` / `eslint` |
|
||||
| `linters.java` | enum | `pmd` | `""` / `pmd` |
|
||||
| `linters.jsp` | enum | `jsp` | `""` / `jsp` |
|
||||
| `linters.css` | enum | `stylelint` | `""` / `stylelint` |
|
||||
| `linters.sql` | enum | `sql-lint` | `""` / `sql-lint` |
|
||||
| `linters.plsql` | enum | `sql-lint` | `""` / `sql-lint` |
|
||||
| `pmd.jarPath` | string | `""` | — |
|
||||
| `pmd.rulesetPath` | string | `""` | — |
|
||||
| `pmd.jspRulesetPath` | string | `""` | — |
|
||||
| `sql-lint.configFile` | string | `""` | — |
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/config/fixer.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface FixerConfig {
|
||||
contextLines: number;
|
||||
}
|
||||
|
||||
export function getFixerConfig(): FixerConfig {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
return {
|
||||
contextLines: config.get<number>('fixer.contextLines', 5),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `src/config/secret.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const SECRET_KEY = 'vscode-code-reviewer.apiKey';
|
||||
|
||||
export async function getApiKey(context: vscode.ExtensionContext): Promise<string | undefined> {
|
||||
return context.secrets.get(SECRET_KEY);
|
||||
}
|
||||
|
||||
export async function setApiKey(context: vscode.ExtensionContext, key: string): Promise<void> {
|
||||
await context.secrets.store(SECRET_KEY, key);
|
||||
}
|
||||
|
||||
export async function deleteApiKey(context: vscode.ExtensionContext): Promise<void> {
|
||||
await context.secrets.delete(SECRET_KEY);
|
||||
}
|
||||
|
||||
export async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise<boolean> {
|
||||
const key = await getApiKey(context);
|
||||
return !!key;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. `src/config/index.ts`
|
||||
|
||||
```typescript
|
||||
export { getAIConfig, AIConfig } from './ai';
|
||||
export { getLinterConfig, LinterConfig } from './linter';
|
||||
export { getFixerConfig, FixerConfig } from './fixer';
|
||||
export { getApiKey, setApiKey, deleteApiKey, isApiKeyConfigured } from './secret';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 6 个文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,34 @@
|
||||
# Step 02 — Phase 2.1: 适配器接口
|
||||
|
||||
**依赖**: Step 01
|
||||
**参考设计**: §3.2
|
||||
|
||||
## 目标
|
||||
|
||||
将 `LinterAdapter` 接口从 `types.ts` 精化到适配器模块中,作为所有适配器的统一接口。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/adapter.ts` | 重新导出/精化 LinterAdapter 接口 |
|
||||
|
||||
---
|
||||
|
||||
## `src/adapters/adapter.ts`
|
||||
|
||||
```typescript
|
||||
import { LinterAdapter } from '../types';
|
||||
|
||||
export type { LinterAdapter };
|
||||
export type { LinterDiagnostic, AdapterResult, AdapterStatus } from '../types';
|
||||
```
|
||||
|
||||
> 如果后续适配器需要额外的共享类型,在此文件中扩展。
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
@@ -0,0 +1,94 @@
|
||||
# Step 03 — Phase 2.2: ESLint 适配器
|
||||
|
||||
**依赖**: Step 02
|
||||
**参考设计**: §3.3
|
||||
|
||||
## 目标
|
||||
|
||||
实现 JavaScript / TypeScript 的 ESLint 适配器,用 eslint npm 包直接检查代码。
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
npm install --save eslint@^9.39.3
|
||||
```
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/eslint.ts` | `ESLintAdapter` |
|
||||
|
||||
---
|
||||
|
||||
## `src/adapters/eslint.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
|
||||
export class ESLintAdapter implements LinterAdapter {
|
||||
id = 'eslint';
|
||||
supportedLanguages = ['javascript', 'typescript'];
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const code = document.getText();
|
||||
const { ESLint } = await import('eslint');
|
||||
const eslint = new ESLint({ cwd: workingDir });
|
||||
const results = await eslint.lintText(code, { filePath: document.uri.fsPath });
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const result of results) {
|
||||
for (const msg of result.messages) {
|
||||
if (!msg.ruleId) { continue; }
|
||||
const line = Math.max(0, (msg.line ?? 1) - 1);
|
||||
const column = Math.max(0, (msg.column ?? 1) - 1);
|
||||
const range = new vscode.Range(line, column, line, column + 1);
|
||||
diagnostics.push({
|
||||
severity: msg.severity === 2 ? 'error' : msg.severity === 1 ? 'warning' : 'info',
|
||||
ruleId: `eslint:${msg.ruleId}`,
|
||||
message: msg.message,
|
||||
range,
|
||||
suggestion: msg.fix ? msg.fix.text : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'ESLint 未安装,请执行 npm install eslint' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
require.resolve('eslint');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `check()`: 使用 `ESLint.lintText(code, { filePath })` 直接检查代码文本,支持虚拟文档
|
||||
- 将 ESLint 严重级别映射为 `error(2) → 'error'`, `warning(1) → 'warning'`, `0 → 'info'`
|
||||
- ruleId 加前缀 `eslint:`,如 `eslint:no-unused-vars`
|
||||
- `isAvailable()`: 通过 `require.resolve` 检测 eslint 是否已安装
|
||||
- 错误处理:模块未找到 → `tool-unavailable`,其他错误 → `execution-failed`
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,92 @@
|
||||
# Step 04 — Phase 2.3: Stylelint 适配器
|
||||
|
||||
**依赖**: Step 02
|
||||
**参考设计**: §3.3
|
||||
|
||||
## 目标
|
||||
|
||||
实现 CSS 的 Stylelint 适配器,用 stylelint npm 包直接检查代码。
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
npm install --save stylelint@^17.14.0
|
||||
```
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/stylelint.ts` | `StylelintAdapter` |
|
||||
|
||||
---
|
||||
|
||||
## `src/adapters/stylelint.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
|
||||
export class StylelintAdapter implements LinterAdapter {
|
||||
id = 'stylelint';
|
||||
supportedLanguages = ['css'];
|
||||
|
||||
async check(document: vscode.TextDocument, _workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const code = document.getText();
|
||||
const stylelint = await import('stylelint');
|
||||
const result = await stylelint.default.lint({
|
||||
code,
|
||||
codeFilename: document.uri.fsPath,
|
||||
config: { rules: {} },
|
||||
});
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const warning of result.results[0]?.warnings ?? []) {
|
||||
const line = Math.max(0, (warning.line ?? 1) - 1);
|
||||
const column = Math.max(0, (warning.column ?? 1) - 1);
|
||||
const range = new vscode.Range(line, column, line, column + 1);
|
||||
diagnostics.push({
|
||||
severity: warning.severity === 'error' ? 'error' : 'warning',
|
||||
ruleId: `stylelint:${warning.rule}`,
|
||||
message: warning.text,
|
||||
range,
|
||||
});
|
||||
}
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('Cannot find module')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Stylelint 未安装,请执行 npm install stylelint' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
require.resolve('stylelint');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `check()`: 使用 `stylelint.lint({ code, codeFilename })` 直接检查代码文本,支持虚拟文档
|
||||
- ruleId 加前缀 `stylelint:`,如 `stylelint:color-no-invalid-hex`
|
||||
- `isAvailable()`: 通过 `require.resolve` 检测 stylelint 是否已安装
|
||||
- 错误处理:模块未找到 → `tool-unavailable`,其他错误 → `execution-failed`
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,119 @@
|
||||
# Step 05 — Phase 2.4: sql-lint 适配器
|
||||
|
||||
**依赖**: Step 02
|
||||
**参考设计**: §3.3
|
||||
|
||||
## 目标
|
||||
|
||||
实现 SQL / PL/SQL 的适配器,通过 CLI 子进程调用 sqlfluff。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/sql-lint.ts` | `SqlLintAdapter` |
|
||||
|
||||
---
|
||||
|
||||
## `src/adapters/sql-lint.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import * as child_process from 'child_process';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { getLinterConfig } from '../config';
|
||||
|
||||
const DIALECT_MAP: Record<string, string> = {
|
||||
sql: 'ansi',
|
||||
plsql: 'postgres',
|
||||
};
|
||||
|
||||
export class SqlLintAdapter implements LinterAdapter {
|
||||
id = 'sql-lint';
|
||||
supportedLanguages = ['sql', 'plsql'];
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const code = document.getText();
|
||||
const languageId = document.languageId;
|
||||
const dialect = DIALECT_MAP[languageId] ?? 'ansi';
|
||||
const config = getLinterConfig();
|
||||
|
||||
const args = ['lint', '--format', 'json', '--dialect', dialect, '-'];
|
||||
if (config.sqlLintConfigFile) {
|
||||
args.push('--config', config.sqlLintConfigFile);
|
||||
}
|
||||
|
||||
const result = await this.execSqlfluff(code, args, workingDir);
|
||||
const output = JSON.parse(result);
|
||||
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const violation of output) {
|
||||
const line = Math.max(0, (violation.line_no ?? violation.line_pos ?? 1) - 1);
|
||||
const col = Math.max(0, (violation.line_pos ?? 1) - 1);
|
||||
const range = new vscode.Range(line, col, line, col + 1);
|
||||
diagnostics.push({
|
||||
severity: 'warning',
|
||||
ruleId: `sql-lint:${violation.code ?? violation.rule ?? 'unknown'}`,
|
||||
message: violation.description ?? violation.message ?? '',
|
||||
range,
|
||||
});
|
||||
}
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('ENOENT') || message.includes('not found')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'sqlfluff 未安装,请执行 pip install sqlfluff' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
private execSqlfluff(code: string, args: string[], cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = child_process.spawn('sqlfluff', args, { cwd });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 || stdout.length > 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(stderr || `sqlfluff exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
proc.stdin.write(code);
|
||||
proc.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
child_process.execSync('sqlfluff --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `check()`: spawn `sqlfluff lint --format json --dialect <dialect> -`,stdin 传入代码
|
||||
- 方言映射:`sql → ansi`, `plsql → postgres`
|
||||
- ruleId 加前缀 `sql-lint:`
|
||||
- `isAvailable()`: `sqlfluff --version` 检测
|
||||
- 通过 stdin 传代码,无需真实文件(支持虚拟文档)
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,259 @@
|
||||
# Step 06 — Phase 2.5: PMD 适配器
|
||||
|
||||
**依赖**: Step 02
|
||||
**参考设计**: §3.3, §3.4
|
||||
|
||||
## 目标
|
||||
|
||||
实现 Java 的 PMD 适配器。通过 Java 子进程调用 `PmdRunner` 包装器,支持 stdin 传入代码(虚拟文档)。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/adapters/pmd.ts` | `PmdAdapter` |
|
||||
| 2 | `jars/pmd/PmdRunner.java` | PMD Java 包装器(stdin + JSON 输出) |
|
||||
| 3 | `jars/pmd/pmd-java-ruleset.xml` | Java 规则集 |
|
||||
| 4 | `jars/pmd/pmd-jsp-ruleset.xml` | JSP 规则集 |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
jars/pmd/
|
||||
├── lib/ # PMD 依赖 JAR(需下载)
|
||||
├── PmdRunner.java # 包装器
|
||||
├── pmd-java-ruleset.xml
|
||||
└── pmd-jsp-ruleset.xml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `jars/pmd/PmdRunner.java`
|
||||
|
||||
```java
|
||||
import java.io.*;
|
||||
import java.nio.file.*;
|
||||
import net.sourceforge.pmd.*;
|
||||
import net.sourceforge.pmd.renderers.*;
|
||||
|
||||
public class PmdRunner {
|
||||
public static void main(String[] args) throws Exception {
|
||||
if (args.length < 2) {
|
||||
System.err.println("Usage: PmdRunner <filePath|- for stdin> <rulesetPath>");
|
||||
System.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
String filePath = args[0];
|
||||
String rulesetPath = args[1];
|
||||
|
||||
Path tempFile = null;
|
||||
if ("-".equals(filePath)) {
|
||||
String code = new String(System.in.readAllBytes());
|
||||
tempFile = Files.createTempFile("pmd-stdin-", ".java");
|
||||
Files.writeString(tempFile, code);
|
||||
filePath = tempFile.toString();
|
||||
}
|
||||
|
||||
try {
|
||||
PMDConfiguration config = new PMDConfiguration();
|
||||
config.setInputFilePath(Path.of(filePath));
|
||||
config.addRuleSet(Path.of(rulesetPath));
|
||||
config.setReportFormat("json");
|
||||
|
||||
StringWriter writer = new StringWriter();
|
||||
config.setReportWriter(writer);
|
||||
|
||||
PmdAnalysis pmd = PmdAnalysis.create(config);
|
||||
pmd.performAnalysis();
|
||||
|
||||
System.out.print(writer.toString());
|
||||
} finally {
|
||||
if (tempFile != null) {
|
||||
Files.deleteIfExists(tempFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**编译命令**(classpath 需指向 `jars/pmd/lib/*`):
|
||||
|
||||
```bash
|
||||
javac -cp "jars/pmd/lib/*" -d jars/pmd/ jars/pmd/PmdRunner.java
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `jars/pmd/pmd-java-ruleset.xml`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="Java Rules"
|
||||
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
|
||||
<description>Java Code Review Rules</description>
|
||||
<rule ref="category/java/bestpractices.xml"/>
|
||||
<rule ref="category/java/codestyle.xml"/>
|
||||
<rule ref="category/java/design.xml"/>
|
||||
<rule ref="category/java/errorprone.xml"/>
|
||||
<rule ref="category/java/performance.xml"/>
|
||||
<rule ref="category/java/security.xml"/>
|
||||
</ruleset>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `jars/pmd/pmd-jsp-ruleset.xml`
|
||||
|
||||
```xml
|
||||
<?xml version="1.0"?>
|
||||
<ruleset name="JSP Rules"
|
||||
xmlns="http://pmd.sourceforge.net/ruleset/2.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://pmd.sourceforge.net/ruleset/2.0.0 https://pmd.sourceforge.io/ruleset_2_0_0.xsd">
|
||||
<description>JSP Code Review Rules</description>
|
||||
<rule ref="category/jsp/bestpractices.xml"/>
|
||||
<rule ref="category/jsp/codestyle.xml"/>
|
||||
<rule ref="category/jsp/design.xml"/>
|
||||
<rule ref="category/jsp/errorprone.xml"/>
|
||||
</ruleset>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/adapters/pmd.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as child_process from 'child_process';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { getLinterConfig } from '../config';
|
||||
|
||||
export class PmdAdapter implements LinterAdapter {
|
||||
id = 'pmd';
|
||||
supportedLanguages = ['java'];
|
||||
|
||||
private getPmdLibClasspath(): string {
|
||||
const extRoot = vscode.extensions.getExtension?.('vscode-code-reviewer')?.extensionPath
|
||||
?? path.join(__dirname, '..', '..');
|
||||
const pmdLib = path.join(extRoot, 'jars', 'pmd', 'lib');
|
||||
return path.join(pmdLib, '*');
|
||||
}
|
||||
|
||||
private getPmdRunnerClasspath(): string {
|
||||
const extRoot = vscode.extensions.getExtension?.('vscode-code-reviewer')?.extensionPath
|
||||
?? path.join(__dirname, '..', '..');
|
||||
return path.join(extRoot, 'jars', 'pmd');
|
||||
}
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
try {
|
||||
const config = getLinterConfig();
|
||||
const ruleset = config.pmdRulesetPath
|
||||
|| path.join(this.getPmdRunnerClasspath(), 'pmd-java-ruleset.xml');
|
||||
const classpath = `${this.getPmdLibClasspath()};${this.getPmdRunnerClasspath()}`;
|
||||
|
||||
const isVirtual = document.uri.scheme === 'untitled';
|
||||
const fileArg = isVirtual ? '-' : document.uri.fsPath;
|
||||
|
||||
const javaArgs = ['-cp', classpath, 'PmdRunner', fileArg, ruleset];
|
||||
const result = await this.execPmd(javaArgs, isVirtual ? document.getText() : null, workingDir);
|
||||
|
||||
const diagnostics = this.parsePmdOutput(result);
|
||||
return { diagnostics, status: 'ok' };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
if (message.includes('ENOENT') || message.includes('java')) {
|
||||
return { diagnostics: [], status: 'tool-unavailable', errorMessage: 'Java 11+ 未安装或不在 PATH 中' };
|
||||
}
|
||||
return { diagnostics: [], status: 'execution-failed', errorMessage: message };
|
||||
}
|
||||
}
|
||||
|
||||
private execPmd(args: string[], stdinInput: string | null, cwd: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = child_process.spawn('java', args, { cwd });
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
proc.stdout.on('data', (data: Buffer) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data: Buffer) => { stderr += data.toString(); });
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 || code === 4 || stdout.length > 0) {
|
||||
resolve(stdout);
|
||||
} else {
|
||||
reject(new Error(stderr || `PMD exited with code ${code}`));
|
||||
}
|
||||
});
|
||||
proc.on('error', reject);
|
||||
if (stdinInput !== null) {
|
||||
proc.stdin.write(stdinInput);
|
||||
proc.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private parsePmdOutput(output: string): LinterDiagnostic[] {
|
||||
if (!output.trim()) { return []; }
|
||||
try {
|
||||
const data = JSON.parse(output);
|
||||
const diagnostics: LinterDiagnostic[] = [];
|
||||
for (const file of data.files ?? []) {
|
||||
for (const violation of file.violations ?? []) {
|
||||
const line = Math.max(0, (violation.beginline ?? 1) - 1);
|
||||
const col = Math.max(0, (violation.begincolumn ?? 1) - 1);
|
||||
const endCol = Math.max(col, (violation.endcolumn ?? col + 1) - 1);
|
||||
const range = new vscode.Range(line, col, line, endCol);
|
||||
diagnostics.push({
|
||||
severity: this.mapPriority(violation.priority),
|
||||
ruleId: `pmd:${violation.rule}`,
|
||||
message: violation.description ?? '',
|
||||
range,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private mapPriority(priority: number): 'error' | 'warning' | 'info' {
|
||||
if (priority <= 2) { return 'error'; }
|
||||
if (priority === 3) { return 'warning'; }
|
||||
return 'info';
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
try {
|
||||
child_process.execSync('java -version 2>&1', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- 虚拟文档通过 stdin 传入代码(`PmdRunner "-" ruleset`)
|
||||
- 真实文件传文件路径(`PmdRunner filePath ruleset`)
|
||||
- classpath 使用 `;` 分隔(Windows 目标平台)
|
||||
- isVirtual 判断依据:`document.uri.scheme === 'untitled'`
|
||||
- PMD exit code 0 和 4 都视为成功(4 表示有 violations 但执行成功)
|
||||
- isAvailable 检测 Java 是否可用
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 4 个文件创建完成
|
||||
- [ ] PMD JAR 依赖已下载到 `jars/pmd/lib/`
|
||||
- [ ] `PmdRunner.java` 编译成功
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,212 @@
|
||||
# Step 07 — Phase 2.6: JSP 适配器
|
||||
|
||||
**依赖**: Step 03, 04, 06(需要 PMD / ESLint / Stylelint 适配器)
|
||||
**参考设计**: §3.5
|
||||
|
||||
## 目标
|
||||
|
||||
实现 JSP 组合适配器。将 JSP 文件拆分后分发检查,合并结果。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/jsp/jsp-extractor.ts` | JSP 内嵌代码块提取器 |
|
||||
| 2 | `src/adapters/jsp.ts` | `JspAdapter`(组合适配器) |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/jsp/jsp-extractor.ts`
|
||||
|
||||
```typescript
|
||||
export interface JspSection {
|
||||
language: 'javascript' | 'css' | 'java';
|
||||
code: string;
|
||||
lineOffset: number;
|
||||
sourceStart: number;
|
||||
sourceEnd: number;
|
||||
}
|
||||
|
||||
export function extractJspSections(content: string): JspSection[] {
|
||||
const sections: JspSection[] = [];
|
||||
|
||||
const scriptRegex = /<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = scriptRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'javascript',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
const styleRegex = /<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi;
|
||||
while ((match = styleRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'css',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
const scriptletRegex = /<%=?([\s\S]*?)%>/g;
|
||||
while ((match = scriptletRegex.exec(content)) !== null) {
|
||||
const code = match[1];
|
||||
const beforeMatch = content.substring(0, match.index);
|
||||
const lineOffset = beforeMatch.split('\n').length - 1;
|
||||
sections.push({
|
||||
language: 'java',
|
||||
code,
|
||||
lineOffset,
|
||||
sourceStart: match.index,
|
||||
sourceEnd: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/adapters/jsp.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { PmdAdapter } from './pmd';
|
||||
import { ESLintAdapter } from './eslint';
|
||||
import { StylelintAdapter } from './stylelint';
|
||||
import { extractJspSections, JspSection } from '../jsp/jsp-extractor';
|
||||
import { getLinterConfig } from '../config';
|
||||
|
||||
export class JspAdapter implements LinterAdapter {
|
||||
id = 'jsp';
|
||||
supportedLanguages = ['jsp'];
|
||||
|
||||
private pmdAdapter = new PmdAdapter();
|
||||
private eslintAdapter = new ESLintAdapter();
|
||||
private stylelintAdapter = new StylelintAdapter();
|
||||
|
||||
async check(document: vscode.TextDocument, workingDir: string): Promise<AdapterResult> {
|
||||
const allDiagnostics: LinterDiagnostic[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
const config = getLinterConfig();
|
||||
const jsEnabled = config.languageMap.javascript !== '';
|
||||
const cssEnabled = config.languageMap.css !== '';
|
||||
const javaEnabled = config.languageMap.java !== '';
|
||||
|
||||
const pmdResult = await this.pmdAdapter.check(document, workingDir);
|
||||
allDiagnostics.push(...pmdResult.diagnostics);
|
||||
if (pmdResult.status !== 'ok') {
|
||||
errors.push(`PMD: ${pmdResult.errorMessage ?? pmdResult.status}`);
|
||||
}
|
||||
|
||||
const sections = extractJspSections(document.getText());
|
||||
|
||||
for (const section of sections) {
|
||||
const isEnabled = (section.language === 'javascript' && jsEnabled)
|
||||
|| (section.language === 'css' && cssEnabled)
|
||||
|| (section.language === 'java' && javaEnabled);
|
||||
if (!isEnabled) { continue; }
|
||||
|
||||
const adapter = this.getAdapter(section.language);
|
||||
if (!adapter) { continue; }
|
||||
|
||||
try {
|
||||
const virtualDoc = await vscode.workspace.openTextDocument({
|
||||
content: section.code,
|
||||
language: section.language,
|
||||
});
|
||||
const result = await adapter.check(virtualDoc, workingDir);
|
||||
|
||||
for (const diag of result.diagnostics) {
|
||||
const adjustedRange = new vscode.Range(
|
||||
diag.range.start.line + section.lineOffset,
|
||||
diag.range.start.character,
|
||||
diag.range.end.line + section.lineOffset,
|
||||
diag.range.end.character,
|
||||
);
|
||||
allDiagnostics.push({ ...diag, range: adjustedRange });
|
||||
}
|
||||
|
||||
if (result.status !== 'ok') {
|
||||
errors.push(`${section.language}: ${result.errorMessage ?? result.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
errors.push(`${section.language}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
const hasErrors = errors.length > 0;
|
||||
const hasUnavailable = errors.some(e => e.includes('未安装') || e.includes('tool-unavailable'));
|
||||
|
||||
return {
|
||||
diagnostics: allDiagnostics,
|
||||
status: hasErrors ? (hasUnavailable ? 'tool-unavailable' : 'execution-failed') : 'ok',
|
||||
errorMessage: errors.join('; '),
|
||||
};
|
||||
}
|
||||
|
||||
private getAdapter(language: string): LinterAdapter | null {
|
||||
switch (language) {
|
||||
case 'javascript': return this.eslintAdapter;
|
||||
case 'css': return this.stylelintAdapter;
|
||||
case 'java': return this.pmdAdapter;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
**JspAdapter.check() 三步流程**:
|
||||
|
||||
```
|
||||
1. 调用 PmdAdapter.check(document) → JSP 规范检查
|
||||
2. extractJspSections(document.getText()) → 提取内嵌代码块
|
||||
3. 对每个 section:
|
||||
a. 按 language 选择对应适配器(ESLint/Stylelint/PMD)
|
||||
b. 创建虚拟文档 (vscode.workspace.openTextDocument)
|
||||
c. 调用 adapter.check(virtualDoc)
|
||||
d. 修正行号偏移 (section.lineOffset)
|
||||
4. 合并所有结果
|
||||
```
|
||||
|
||||
**提取器正则规则**:
|
||||
|
||||
| 代码块类型 | 正则 | 目标适配器 |
|
||||
|-----------|------|-----------|
|
||||
| `<script>` | `/<script\b[^>]*>([\s\S]*?)<\/script\s*>/gi` | ESLint |
|
||||
| `<style>` | `/<style\b[^>]*>([\s\S]*?)<\/style\s*>/gi` | Stylelint |
|
||||
| `<%=? %>` | `/<%=?([\s\S]*?)%>/g` | PMD |
|
||||
|
||||
- 行号偏移修正:`lineOffset` 从提取位置之前的换行符数计算
|
||||
- 根据 `linters.<language>` 配置决定是否启用对应子适配器
|
||||
- 组合状态:任一子适配器不可用则标记 `tool-unavailable`
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 2 个文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,139 @@
|
||||
# Step 08 — Phase 3: 编排器 Orchestrator
|
||||
|
||||
**依赖**: Step 02~07(所有适配器)
|
||||
**参考设计**: §3.6, §4
|
||||
|
||||
## 目标
|
||||
|
||||
实现单文件保存触发、多适配器调度、500ms debounce、结果聚合的编排器。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/orchestrator/orchestrator.ts` | `Orchestrator` 类 |
|
||||
|
||||
---
|
||||
|
||||
## `src/orchestrator/orchestrator.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterAdapter, LinterDiagnostic, AdapterResult } from '../types';
|
||||
import { getLinterConfig } from '../config';
|
||||
import { ESLintAdapter } from '../adapters/eslint';
|
||||
import { PmdAdapter } from '../adapters/pmd';
|
||||
import { StylelintAdapter } from '../adapters/stylelint';
|
||||
import { SqlLintAdapter } from '../adapters/sql-lint';
|
||||
import { JspAdapter } from '../adapters/jsp';
|
||||
|
||||
export interface StaticAnalysisResult {
|
||||
diagnostics: LinterDiagnostic[];
|
||||
errors: string[];
|
||||
adapterIds: string[];
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export class Orchestrator {
|
||||
private adapters: LinterAdapter[];
|
||||
|
||||
constructor() {
|
||||
this.adapters = [
|
||||
new ESLintAdapter(),
|
||||
new PmdAdapter(),
|
||||
new StylelintAdapter(),
|
||||
new SqlLintAdapter(),
|
||||
new JspAdapter(),
|
||||
];
|
||||
}
|
||||
|
||||
getAdapterMap(): Map<string, LinterAdapter> {
|
||||
const map = new Map<string, LinterAdapter>();
|
||||
for (const adapter of this.adapters) {
|
||||
for (const lang of adapter.supportedLanguages) {
|
||||
map.set(lang, adapter);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async runStaticAnalysis(
|
||||
document: vscode.TextDocument,
|
||||
workingDir: string
|
||||
): Promise<StaticAnalysisResult> {
|
||||
const startTime = Date.now();
|
||||
const config = getLinterConfig();
|
||||
const languageId = document.languageId;
|
||||
|
||||
const selectedLinter = config.languageMap[languageId];
|
||||
if (!selectedLinter || selectedLinter === '') {
|
||||
return { diagnostics: [], errors: [], adapterIds: [], duration: 0 };
|
||||
}
|
||||
|
||||
const adapter = this.adapters.find(a => a.id === selectedLinter);
|
||||
if (!adapter) {
|
||||
return {
|
||||
diagnostics: [],
|
||||
errors: [`未找到适配器: ${selectedLinter}`],
|
||||
adapterIds: [],
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await adapter.check(document, workingDir);
|
||||
const errors: string[] = [];
|
||||
if (result.status !== 'ok') {
|
||||
errors.push(`[${adapter.id}] ${result.errorMessage ?? result.status}`);
|
||||
}
|
||||
|
||||
return {
|
||||
diagnostics: result.diagnostics,
|
||||
errors,
|
||||
adapterIds: [adapter.id],
|
||||
duration: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
getAdaptersByIds(ids: string[]): LinterAdapter[] {
|
||||
return ids.map(id => this.adapters.find(a => a.id === id)).filter(Boolean) as LinterAdapter[];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## debounce 工具函数
|
||||
|
||||
在 `src/orchestrator/orchestrator.ts` 中或另外创建 `src/utils/debounce.ts`:
|
||||
|
||||
```typescript
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(fn: T, ms: number): (...args: Parameters<T>) => void {
|
||||
let timer: NodeJS.Timeout;
|
||||
return (...args: Parameters<T>) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => fn(...args), ms);
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `getAdapterMap()`: 建立 language → adapter 的映射表
|
||||
- `runStaticAnalysis()`:
|
||||
1. 获取文档 languageId
|
||||
2. 查 `linters.<language>` 配置 → 确定使用的适配器
|
||||
3. 调用 `adapter.check(document, workingDir)`
|
||||
4. 收集结果,区分 status
|
||||
5. 返回聚合 `StaticAnalysisResult`
|
||||
- 硬编码注册 5 个适配器(工厂函数方式)
|
||||
- 禁用某语言的配置项为空字符串时不执行检查
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,204 @@
|
||||
# Step 09 — Phase 4.1: AI Provider 基础设施
|
||||
|
||||
**依赖**: Step 01(配置模块)
|
||||
**参考设计**: §5.2
|
||||
|
||||
## 目标
|
||||
|
||||
实现 AI Provider 策略模式基础设施:抽象基类、2 个 Provider 实现、工厂函数。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/ai/providers/base.ts` | `AIProvider` 抽象基类 + `ChatOptions` |
|
||||
| 2 | `src/ai/providers/deepseek.ts` | `DeepSeekProvider` |
|
||||
| 3 | `src/ai/providers/openai.ts` | `OpenAIProvider` |
|
||||
| 4 | `src/ai/factory.ts` | `createProvider()` 工厂 |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/ai/providers/base.ts`
|
||||
|
||||
```typescript
|
||||
export interface ChatOptions {
|
||||
model: string;
|
||||
temperature: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
|
||||
export abstract class AIProvider {
|
||||
abstract id: string;
|
||||
abstract name: string;
|
||||
|
||||
constructor(
|
||||
protected apiKey: string,
|
||||
protected endpoint: string
|
||||
) {}
|
||||
|
||||
abstract chat(
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
options: ChatOptions
|
||||
): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/ai/providers/deepseek.ts`
|
||||
|
||||
```typescript
|
||||
import { AIProvider, ChatOptions } from './base';
|
||||
|
||||
export class DeepSeekProvider extends AIProvider {
|
||||
id = 'deepseek';
|
||||
name = 'DeepSeek';
|
||||
|
||||
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
|
||||
const url = `${this.endpoint}/chat/completions`;
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: options.model,
|
||||
temperature: options.temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new Error('API Key 无效,请重新设置');
|
||||
}
|
||||
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
return data.choices[0]?.message?.content ?? '';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/ai/providers/openai.ts`
|
||||
|
||||
```typescript
|
||||
import { AIProvider, ChatOptions } from './base';
|
||||
|
||||
export class OpenAIProvider extends AIProvider {
|
||||
id = 'openai';
|
||||
name = 'OpenAI';
|
||||
|
||||
async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise<string> {
|
||||
const url = `${this.endpoint}/chat/completions`;
|
||||
|
||||
const body = JSON.stringify({
|
||||
model: options.model,
|
||||
temperature: options.temperature,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (response.status === 401) {
|
||||
throw new Error('API Key 无效,请重新设置');
|
||||
}
|
||||
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json() as {
|
||||
choices: Array<{ message: { content: string } }>;
|
||||
};
|
||||
return data.choices[0]?.message?.content ?? '';
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/ai/factory.ts`
|
||||
|
||||
```typescript
|
||||
import { AIProvider } from './providers/base';
|
||||
import { DeepSeekProvider } from './providers/deepseek';
|
||||
import { OpenAIProvider } from './providers/openai';
|
||||
|
||||
type ProviderConstructor = new (apiKey: string, endpoint: string) => AIProvider;
|
||||
|
||||
const registry: Record<string, ProviderConstructor> = {
|
||||
deepseek: DeepSeekProvider,
|
||||
openai: OpenAIProvider,
|
||||
};
|
||||
|
||||
export function createProvider(providerId: string, apiKey: string, endpoint: string): AIProvider {
|
||||
const Cls = registry[providerId];
|
||||
if (!Cls) {
|
||||
throw new Error(`未知的 Provider: ${providerId}`);
|
||||
}
|
||||
return new Cls(apiKey, endpoint);
|
||||
}
|
||||
|
||||
export function getProviderIds(): string[] {
|
||||
return Object.keys(registry);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- Provider 统一实现 `chat(systemPrompt, userPrompt, options): Promise<string>`
|
||||
- 支持 AbortController 超时控制
|
||||
- HTTP 401 → 抛出 "API Key 无效" 错误
|
||||
- 工厂函数通过注册表字符串查找,便于添加新的 Provider
|
||||
- 两个 Provider 实现几乎相同(都是 openai 兼容 API),可考虑后续合并
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 4 个文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,253 @@
|
||||
# Step 10 — Phase 4.2: AI 引擎 + Schema
|
||||
|
||||
**依赖**: Step 09
|
||||
**参考设计**: §5.3, §5.4, §5.5, §5.6
|
||||
|
||||
## 目标
|
||||
|
||||
实现 AI 审查引擎:两并行请求(自定义规则评估 + 翻译深度审查)、JSON 解析、错误降级。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/ai/schema.ts` | AI 响应结构类型定义 |
|
||||
| 2 | `src/ai/engine.ts` | `runAIReview()` 主函数 |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/ai/schema.ts`
|
||||
|
||||
```typescript
|
||||
export interface TranslatedDiagnostic {
|
||||
originalRuleId: string;
|
||||
translatedMessage: string;
|
||||
translatedSuggestion: string;
|
||||
codeDiff?: string;
|
||||
}
|
||||
|
||||
export interface CustomRuleResult {
|
||||
ruleId: string;
|
||||
line: number;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AIFinding {
|
||||
ruleId: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
category: 'bug' | 'performance' | 'security' | 'style' | 'design';
|
||||
title: string;
|
||||
description: string;
|
||||
suggestion: string;
|
||||
codeDiff?: string;
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface AIResponse {
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
customRuleResults: CustomRuleResult[];
|
||||
findings: AIFinding[];
|
||||
}
|
||||
|
||||
export interface AIEngineResult {
|
||||
customRuleResults: CustomRuleResult[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
findings: AIFinding[];
|
||||
degraded: boolean;
|
||||
error?: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/ai/engine.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { AIProvider } from './providers/base';
|
||||
import { createProvider } from './factory';
|
||||
import { getAIConfig, getApiKey } from '../config';
|
||||
import { LinterDiagnostic } from '../types';
|
||||
import { CustomRule } from '../rules/yaml-parser';
|
||||
import {
|
||||
AIEngineResult,
|
||||
CustomRuleResult,
|
||||
TranslatedDiagnostic,
|
||||
AIFinding,
|
||||
} from './schema';
|
||||
|
||||
function buildCustomRulePrompt(rules: CustomRule[]): string {
|
||||
return rules.map(r =>
|
||||
`- [${r.id}] (${r.severity}) ${r.description}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function buildLinterDiagnosticsPrompt(diagnostics: LinterDiagnostic[]): string {
|
||||
return diagnostics.map(d =>
|
||||
`- [${d.ruleId}] L${d.range.start.line + 1}: ${d.message}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
function addLineNumbers(code: string): string {
|
||||
return code.split('\n').map((line, i) => `${String(i + 1).padStart(4, ' ')}| ${line}`).join('\n');
|
||||
}
|
||||
|
||||
function parseJsonResponse(raw: string): object {
|
||||
const trimmed = raw.trim();
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) {
|
||||
throw new Error('响应中未找到 JSON');
|
||||
}
|
||||
return JSON.parse(trimmed.substring(start, end + 1));
|
||||
}
|
||||
|
||||
const CUSTOM_RULE_SYSTEM_PROMPT = `你是代码规则审查员,只评估以下自定义规则是否被违反。
|
||||
理解语义而非文本匹配。
|
||||
仅输出 JSON,格式:
|
||||
{ "customRuleResults": [{ "ruleId": "规则ID", "line": 行号, "severity": "error|warning|info", "message": "触发描述" }] }
|
||||
如果没有违反任何规则,返回空数组。`;
|
||||
|
||||
const DEEP_REVIEW_SYSTEM_PROMPT = `你是资深代码审查专家,完成两个任务:
|
||||
1. 将英文静态分析结果翻译为输出语言,并补充修复建议
|
||||
2. 深度审查代码,发现静态分析未覆盖的问题
|
||||
重点:安全漏洞、逻辑错误、性能问题、设计缺陷
|
||||
不要重复静态分析已报告的问题。
|
||||
|
||||
仅输出 JSON,格式:
|
||||
{
|
||||
"translatedDiagnostics": [{ "originalRuleId": "原始ID", "translatedMessage": "翻译", "translatedSuggestion": "建议", "codeDiff": "可选" }],
|
||||
"findings": [{ "ruleId": "kebab-case", "severity": "error|warning|info", "category": "bug|performance|security|style|design", "title": "标题", "description": "描述", "suggestion": "建议", "codeDiff": "可选", "line": 行号 }]
|
||||
}`;
|
||||
|
||||
export async function runAIReview(
|
||||
context: vscode.ExtensionContext,
|
||||
code: string,
|
||||
staticDiagnostics: LinterDiagnostic[],
|
||||
customRules: CustomRule[]
|
||||
): Promise<AIEngineResult> {
|
||||
const config = getAIConfig();
|
||||
const apiKey = await getApiKey(context);
|
||||
|
||||
if (!apiKey) {
|
||||
return {
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
findings: [],
|
||||
degraded: true,
|
||||
error: '未配置 API Key',
|
||||
};
|
||||
}
|
||||
|
||||
let provider: AIProvider;
|
||||
try {
|
||||
provider = createProvider(config.provider, apiKey, config.endpoint);
|
||||
} catch (err) {
|
||||
return {
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
findings: [],
|
||||
degraded: true,
|
||||
error: `创建 Provider 失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
|
||||
const options = {
|
||||
model: config.model,
|
||||
temperature: config.temperature,
|
||||
timeoutMs: config.timeout * 1000,
|
||||
};
|
||||
|
||||
const numberedCode = addLineNumbers(code);
|
||||
|
||||
const requestA =
|
||||
customRules.length > 0
|
||||
? provider.chat(
|
||||
CUSTOM_RULE_SYSTEM_PROMPT,
|
||||
`## 自定义规则\n${buildCustomRulePrompt(customRules)}\n\n## 代码(带行号)\n${numberedCode}`,
|
||||
options
|
||||
)
|
||||
: Promise.resolve('{}');
|
||||
|
||||
const requestB = provider.chat(
|
||||
`${DEEP_REVIEW_SYSTEM_PROMPT}\n输出语言:${config.outputLanguage}`,
|
||||
`## 代码(带行号)\n${numberedCode}\n\n## 静态分析结果(英文)\n${buildLinterDiagnosticsPrompt(staticDiagnostics)}`,
|
||||
options
|
||||
);
|
||||
|
||||
const [resultA, resultB] = await Promise.allSettled([requestA, requestB]);
|
||||
|
||||
const errors: string[] = [];
|
||||
|
||||
let customRuleResults: CustomRuleResult[] = [];
|
||||
if (resultA.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = parseJsonResponse(resultA.value) as { customRuleResults?: CustomRuleResult[] };
|
||||
customRuleResults = (parsed.customRuleResults ?? []).map(r => ({
|
||||
...r,
|
||||
ruleId: `custom:${r.ruleId}`,
|
||||
}));
|
||||
} catch {
|
||||
errors.push('自定义规则响应解析失败');
|
||||
}
|
||||
} else {
|
||||
errors.push(`自定义规则请求失败: ${resultA.reason}`);
|
||||
}
|
||||
|
||||
let translatedDiagnostics: TranslatedDiagnostic[] = [];
|
||||
let findings: AIFinding[] = [];
|
||||
if (resultB.status === 'fulfilled') {
|
||||
try {
|
||||
const parsed = parseJsonResponse(resultB.value) as {
|
||||
translatedDiagnostics?: TranslatedDiagnostic[];
|
||||
findings?: AIFinding[];
|
||||
};
|
||||
translatedDiagnostics = parsed.translatedDiagnostics ?? [];
|
||||
findings = parsed.findings ?? [];
|
||||
} catch {
|
||||
errors.push('AI 审查响应解析失败');
|
||||
}
|
||||
} else {
|
||||
errors.push(`AI 审查请求失败: ${resultB.reason}`);
|
||||
}
|
||||
|
||||
const degraded = errors.length > 0;
|
||||
return {
|
||||
customRuleResults,
|
||||
translatedDiagnostics,
|
||||
findings,
|
||||
degraded,
|
||||
error: errors.join('; '),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
**两并行请求**:
|
||||
|
||||
| 请求 | 内容 | System Prompt |
|
||||
|------|------|---------------|
|
||||
| A | 自定义规则评估(需完整代码) | 只评估规则、语义理解、输出 JSON |
|
||||
| B | 翻译 + 深度审查(需静态分析结果) | 翻译诊断 + 深度审查、不重复静态分析、输出 JSON |
|
||||
|
||||
**降级策略**:
|
||||
1. `Promise.allSettled` 确保单请求失败不影响另一个
|
||||
2. API Key 未配置 → 所有 AI 功能降级
|
||||
3. JSON 解析失败 → 该请求降级,记录错误
|
||||
4. Provider 创建失败 → 全部降级
|
||||
|
||||
**Prompt 设计**:
|
||||
- 请求 A: 注入自定义规则的 description 列表
|
||||
- 请求 B: 注入静态分析英文诊断列表 + 输出语言配置
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 2 个文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,223 @@
|
||||
# Step 11 — Phase 4.3: 自定义规则系统
|
||||
|
||||
**依赖**: Step 01(配置模块)
|
||||
**参考设计**: §12
|
||||
|
||||
## 目标
|
||||
|
||||
实现 YAML 规则加载器,从 `.code-review/rules/*.yaml` 读取规则定义,按 `config.yaml` 过滤启用状态。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/rules/yaml-parser.ts` | `loadActiveRules()` + `parseConfigYaml()` |
|
||||
|
||||
## 现有参考文件(已存在,不修改)
|
||||
|
||||
```
|
||||
.code-review/
|
||||
├── config.yaml # 规则启用配置
|
||||
└── rules/
|
||||
├── security-rules.yaml # 安全规则
|
||||
└── coding-conventions.yaml # 编码规范
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `src/rules/yaml-parser.ts`
|
||||
|
||||
```typescript
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface CustomRule {
|
||||
id: string;
|
||||
severity: 'error' | 'warning' | 'info';
|
||||
description: string;
|
||||
message: string;
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
interface RuleYamlItem {
|
||||
id: string;
|
||||
severity: string;
|
||||
description: string;
|
||||
message: string;
|
||||
languages?: string[];
|
||||
}
|
||||
|
||||
interface RuleConfig {
|
||||
enabled?: string[];
|
||||
rules?: Record<string, { enabled: boolean }>;
|
||||
}
|
||||
|
||||
function parseYamlSimple(content: string): object[] {
|
||||
const items: Array<Record<string, unknown>> = [];
|
||||
let current: Record<string, unknown> | null = null;
|
||||
let currentKey = '';
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) { continue; }
|
||||
|
||||
if (trimmed.startsWith('- ')) {
|
||||
if (current) { items.push(current); }
|
||||
current = {};
|
||||
const indentMatch = trimmed.match(/^- (\w[\w-]*)\s*:\s*(.*)$/);
|
||||
if (indentMatch) {
|
||||
const key = indentMatch[1];
|
||||
let value: unknown = indentMatch[2].trim();
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = value.slice(1, -1).split(',').map(s =>
|
||||
s.trim().replace(/^['"]|['"]$/g, '')
|
||||
);
|
||||
}
|
||||
current[key] = value;
|
||||
}
|
||||
} else if (current) {
|
||||
const propMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*(.*)$/);
|
||||
if (propMatch) {
|
||||
const key = propMatch[1];
|
||||
let value: unknown = propMatch[2].trim();
|
||||
if (!value || value === '[]') {
|
||||
value = [];
|
||||
} else if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = value.slice(1, -1).split(',').map(s =>
|
||||
s.trim().replace(/^['"]|['"]$/g, '')
|
||||
);
|
||||
}
|
||||
current[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current) { items.push(current); }
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function parseConfigYaml(content: string): RuleConfig {
|
||||
const config: RuleConfig = { enabled: [], rules: {} };
|
||||
let section: string | null = null;
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) { continue; }
|
||||
|
||||
if (trimmed === 'enabled:') {
|
||||
section = 'enabled';
|
||||
continue;
|
||||
}
|
||||
if (trimmed === 'rules:') {
|
||||
section = 'rules';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (section === 'enabled' && trimmed.startsWith('- ')) {
|
||||
const name = trimmed.substring(2).trim();
|
||||
if (!config.enabled) { config.enabled = []; }
|
||||
config.enabled!.push(name);
|
||||
}
|
||||
|
||||
if (section === 'rules') {
|
||||
const ruleMatch = trimmed.match(/^(\w[\w-]*)\s*:\s*$/);
|
||||
if (ruleMatch) {
|
||||
currentKey = ruleMatch[1];
|
||||
if (!config.rules) { config.rules = {}; }
|
||||
config.rules[currentKey] = { enabled: true };
|
||||
} else if (currentKey) {
|
||||
const propMatch = trimmed.match(/^(\w+)\s*:\s*(.*)$/);
|
||||
if (propMatch) {
|
||||
const key = propMatch[1];
|
||||
const value = propMatch[2].trim();
|
||||
if (!config.rules) { config.rules = {}; }
|
||||
if (!config.rules[currentKey]) { config.rules[currentKey] = { enabled: true }; }
|
||||
(config.rules[currentKey] as Record<string, unknown>)[key] =
|
||||
value === 'false' ? false : value === 'true' ? true : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export function loadActiveRules(workspaceRoot: string): CustomRule[] {
|
||||
const rulesDir = path.join(workspaceRoot, '.code-review', 'rules');
|
||||
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
|
||||
|
||||
if (!fs.existsSync(rulesDir)) { return []; }
|
||||
|
||||
let ruleConfig: RuleConfig = {};
|
||||
if (fs.existsSync(configPath)) {
|
||||
const configContent = fs.readFileSync(configPath, 'utf-8');
|
||||
ruleConfig = parseConfigYaml(configContent);
|
||||
}
|
||||
|
||||
const enabledFiles = new Set(ruleConfig.enabled ?? []);
|
||||
const disabledRules = new Set(
|
||||
Object.entries(ruleConfig.rules ?? {})
|
||||
.filter(([, v]) => v.enabled === false)
|
||||
.map(([k]) => k)
|
||||
);
|
||||
|
||||
const allRules: CustomRule[] = [];
|
||||
|
||||
const files = fs.readdirSync(rulesDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'));
|
||||
for (const file of files) {
|
||||
if (enabledFiles.size > 0 && !enabledFiles.has(file)) { continue; }
|
||||
|
||||
const content = fs.readFileSync(path.join(rulesDir, file), 'utf-8');
|
||||
const items = parseYamlSimple(content) as RuleYamlItem[];
|
||||
|
||||
for (const item of items) {
|
||||
if (disabledRules.has(item.id)) { continue; }
|
||||
if (!item.id || !item.severity || !item.description || !item.message) { continue; }
|
||||
|
||||
const severity = ['error', 'warning', 'info'].includes(item.severity)
|
||||
? (item.severity as 'error' | 'warning' | 'info')
|
||||
: 'warning';
|
||||
|
||||
allRules.push({
|
||||
id: item.id,
|
||||
severity,
|
||||
description: item.description,
|
||||
message: item.message,
|
||||
languages: item.languages,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return allRules;
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 如果项目已安装 `js-yaml` 依赖,可替换 `parseYamlSimple` 为 `yaml.load()`。此处用简易解析器避免额外依赖。
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
**加载流程**(按设计 §12.3):
|
||||
|
||||
```
|
||||
1. 扫描 .code-review/rules/*.yaml → 加载所有规则定义
|
||||
2. 读取 .code-review/config.yaml
|
||||
3. 按文件级 enabled 列表过滤
|
||||
4. 按规则级 rules.<id>.enabled 覆盖
|
||||
5. 返回激活的规则列表
|
||||
```
|
||||
|
||||
- `enabled`: 文件级启用列表,为空时默认全部启用
|
||||
- `rules.<id>.enabled: false`: 单独禁用特定规则
|
||||
- 规则 ID 不含 `custom:` 前缀,运行时由 AI 引擎自动拼接
|
||||
- 规则各字段与设计 §12.2 一致
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,112 @@
|
||||
# Step 12 — Phase 4.4: 结果合并
|
||||
|
||||
**依赖**: Step 08, 10, 11
|
||||
**参考设计**: §14.1
|
||||
|
||||
## 目标
|
||||
|
||||
将适配器诊断、AI 翻译诊断、自定义规则结果、AI 深度审查结果合并为一个 `MergedReport`。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/merger/merger.ts` | `mergeResults()` → `MergedReport` |
|
||||
|
||||
---
|
||||
|
||||
## `src/merger/merger.ts`
|
||||
|
||||
```typescript
|
||||
import { LinterDiagnostic, Severity } from '../types';
|
||||
import { TranslatedDiagnostic, CustomRuleResult, AIFinding } from '../ai/schema';
|
||||
|
||||
export interface MergedReport {
|
||||
linterDiagnostics: LinterDiagnostic[];
|
||||
customRuleDiagnostics: LinterDiagnostic[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
aiFindings: AIFinding[];
|
||||
linterCount: number;
|
||||
customRuleCount: number;
|
||||
aiCount: number;
|
||||
errors: string[];
|
||||
degraded: boolean;
|
||||
duration: number;
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterNames: string[];
|
||||
fixableLinterIndices: number[];
|
||||
fixableCustomIndices: number[];
|
||||
}
|
||||
|
||||
interface MergeInput {
|
||||
staticDiagnostics: LinterDiagnostic[];
|
||||
customRuleResults: CustomRuleResult[];
|
||||
translatedDiagnostics: TranslatedDiagnostic[];
|
||||
aiFindings: AIFinding[];
|
||||
errors: string[];
|
||||
degraded: boolean;
|
||||
startTime: number;
|
||||
filePath: string;
|
||||
language: string;
|
||||
adapterIds: string[];
|
||||
}
|
||||
|
||||
export function mergeResults(input: MergeInput): MergedReport {
|
||||
const customRuleDiagnostics: LinterDiagnostic[] = input.customRuleResults.map(r => ({
|
||||
severity: r.severity as Severity,
|
||||
ruleId: r.ruleId,
|
||||
message: r.message,
|
||||
range: new (require('vscode').Range)(Math.max(0, r.line - 1), 0, Math.max(0, r.line - 1), 1),
|
||||
}));
|
||||
|
||||
const linterCount = input.staticDiagnostics.length;
|
||||
const customRuleCount = customRuleDiagnostics.length;
|
||||
const aiCount = input.aiFindings.length;
|
||||
|
||||
const fixableLinterIndices = input.staticDiagnostics
|
||||
.map((_, i) => i)
|
||||
.filter(i => input.staticDiagnostics[i].suggestion);
|
||||
|
||||
const fixableCustomIndices = customRuleDiagnostics
|
||||
.map((_, i) => i);
|
||||
|
||||
return {
|
||||
linterDiagnostics: input.staticDiagnostics,
|
||||
customRuleDiagnostics,
|
||||
translatedDiagnostics: input.translatedDiagnostics,
|
||||
aiFindings: input.aiFindings,
|
||||
linterCount,
|
||||
customRuleCount,
|
||||
aiCount,
|
||||
errors: input.errors,
|
||||
degraded: input.degraded,
|
||||
duration: Date.now() - input.startTime,
|
||||
filePath: input.filePath,
|
||||
language: input.language,
|
||||
adapterNames: input.adapterIds,
|
||||
fixableLinterIndices,
|
||||
fixableCustomIndices,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: `require('vscode')` 在合并器中创建 Range。如果遇到问题,可使用 `import * as vscode from 'vscode'` 替代,或在调用处传入 `vscode` 模块。
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `customRuleResults` → `customRuleDiagnostics`(LinterDiagnostic 格式,范围设为首字符)
|
||||
- 统计计数器:linterCount / customRuleCount / aiCount(面板 Tab 计数用)
|
||||
- `fixableLinterIndices`: 有 suggestion 的 linter 诊断索引
|
||||
- `fixableCustomIndices`: 所有自定义规则诊断索引(均可尝试 AI 修复)
|
||||
- duration: 从 startTime 到调用时刻的耗时
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,283 @@
|
||||
# Step 13 — Phase 4.5: 自动修复
|
||||
|
||||
**依赖**: Step 09(AI Provider),Step 12(Merger)
|
||||
**参考设计**: §8
|
||||
|
||||
## 目标
|
||||
|
||||
实现 AI 自动修复:动态上下文策略、两阶段匹配验证、批量修复倒序应用、快照撤销。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/fixer/fixer.ts` | `generateFix()`, `applyFix()`, `applyBatchFixes()`, `undoLastFix()` |
|
||||
|
||||
---
|
||||
|
||||
## `src/fixer/fixer.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { LinterDiagnostic } from '../types';
|
||||
import { AIProvider } from '../ai/providers/base';
|
||||
import { getAIConfig } from '../config';
|
||||
import { createProvider } from '../ai/factory';
|
||||
|
||||
export type FixCategory = 'naming' | 'style' | 'bug' | 'security' | 'performance';
|
||||
|
||||
export interface FixableDiagnostic {
|
||||
ruleId: string;
|
||||
message: string;
|
||||
line: number;
|
||||
severity: string;
|
||||
codeContext: string;
|
||||
source: 'linter' | 'custom';
|
||||
category: FixCategory;
|
||||
}
|
||||
|
||||
export interface CodeFix {
|
||||
startLine: number;
|
||||
endLine: number;
|
||||
originalText: string;
|
||||
newText: string;
|
||||
matched: boolean;
|
||||
actualRange?: vscode.Range;
|
||||
}
|
||||
|
||||
const FIX_SYSTEM_PROMPT = `你是代码修复专家。根据提供的问题和代码上下文,输出修复后的代码。
|
||||
仅输出 JSON:{ "originalText": "需要替换的原文", "newText": "修复后的新代码" }`;
|
||||
|
||||
function detectCategory(diagnostic: LinterDiagnostic): FixCategory {
|
||||
if (diagnostic.ruleId.includes('naming') || diagnostic.ruleId.includes('Name')) { return 'naming'; }
|
||||
if (diagnostic.ruleId.includes('security') || diagnostic.ruleId.includes('injection') || diagnostic.ruleId.includes('secret')) { return 'security'; }
|
||||
if (diagnostic.ruleId.includes('perf')) { return 'performance'; }
|
||||
return 'style';
|
||||
}
|
||||
|
||||
function getContextRange(document: vscode.TextDocument, line: number, category: FixCategory): { startLine: number; endLine: number } {
|
||||
switch (category) {
|
||||
case 'naming':
|
||||
return {
|
||||
startLine: Math.max(0, line - 2),
|
||||
endLine: Math.min(document.lineCount - 1, line + 2),
|
||||
};
|
||||
case 'style':
|
||||
return {
|
||||
startLine: Math.max(0, line - 5),
|
||||
endLine: Math.min(document.lineCount - 1, line + 5),
|
||||
};
|
||||
case 'bug':
|
||||
case 'security':
|
||||
case 'performance': {
|
||||
const funcRange = findEnclosingFunction(document, line);
|
||||
return {
|
||||
startLine: funcRange?.start.line ?? Math.max(0, line - 10),
|
||||
endLine: funcRange?.end.line ?? Math.min(document.lineCount - 1, line + 10),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return {
|
||||
startLine: Math.max(0, line - 5),
|
||||
endLine: Math.min(document.lineCount - 1, line + 5),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function findEnclosingFunction(document: vscode.TextDocument, line: number): { start: vscode.Position; end: vscode.Position } | null {
|
||||
const text = document.getText();
|
||||
const lines = text.split('\n');
|
||||
let braceDepth = 0;
|
||||
let funcStart = line;
|
||||
let funcEnd = line;
|
||||
|
||||
for (let i = line; i >= 0; i--) {
|
||||
const l = lines[i];
|
||||
braceDepth += (l.match(/\}/g) || []).length;
|
||||
braceDepth -= (l.match(/\{/g) || []).length;
|
||||
const isFunctionLine = /\b(function|def|class|method|public|private|protected|void|int|String|boolean|var|let|const|async)\s/.test(l);
|
||||
if (braceDepth < 0 && isFunctionLine) {
|
||||
funcStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
braceDepth = 0;
|
||||
for (let i = funcStart; i < lines.length; i++) {
|
||||
const l = lines[i];
|
||||
braceDepth += (l.match(/\{/g) || []).length;
|
||||
braceDepth -= (l.match(/\}/g) || []).length;
|
||||
if (braceDepth === 0 && (l.match(/\{/g) || []).length > 0) {
|
||||
funcEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: new vscode.Position(funcStart, 0),
|
||||
end: new vscode.Position(funcEnd, lines[funcEnd]?.length ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
function extractLines(document: vscode.TextDocument, startLine: number, endLine: number): string {
|
||||
const lines: string[] = [];
|
||||
for (let i = startLine; i <= endLine; i++) {
|
||||
const lineText = document.lineAt(i).text;
|
||||
lines.push(`${String(i + 1).padStart(4, ' ')}| ${lineText}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function prepareContext(document: vscode.TextDocument, diagnostic: LinterDiagnostic, source: 'linter' | 'custom'): FixableDiagnostic | null {
|
||||
const line = diagnostic.range.start.line;
|
||||
const category = detectCategory(diagnostic);
|
||||
const { startLine, endLine } = getContextRange(document, line, category);
|
||||
const codeContext = extractLines(document, startLine, endLine);
|
||||
|
||||
return {
|
||||
ruleId: diagnostic.ruleId,
|
||||
message: diagnostic.message,
|
||||
line,
|
||||
severity: diagnostic.severity,
|
||||
codeContext,
|
||||
source,
|
||||
category,
|
||||
};
|
||||
}
|
||||
|
||||
async function generateFix(
|
||||
provider: AIProvider,
|
||||
model: string,
|
||||
temperature: number,
|
||||
timeoutMs: number,
|
||||
diagnostic: FixableDiagnostic
|
||||
): Promise<CodeFix | null> {
|
||||
const userPrompt = `问题: [${diagnostic.ruleId}] ${diagnostic.message}\n代码上下文:\n${diagnostic.codeContext}`;
|
||||
|
||||
try {
|
||||
const response = await provider.chat(FIX_SYSTEM_PROMPT, userPrompt, {
|
||||
model,
|
||||
temperature,
|
||||
timeoutMs,
|
||||
});
|
||||
|
||||
const trimmed = response.trim();
|
||||
const start = trimmed.indexOf('{');
|
||||
const end = trimmed.lastIndexOf('}');
|
||||
if (start === -1 || end === -1) { return null; }
|
||||
|
||||
const parsed = JSON.parse(trimmed.substring(start, end + 1));
|
||||
return {
|
||||
startLine: diagnostic.line,
|
||||
endLine: diagnostic.line,
|
||||
originalText: parsed.originalText ?? '',
|
||||
newText: parsed.newText ?? '',
|
||||
matched: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function matchAndValidate(document: vscode.TextDocument, fix: CodeFix): { matched: boolean; actualRange?: vscode.Range } {
|
||||
const lineContent = document.lineAt(fix.startLine).text;
|
||||
if (lineContent === fix.originalText.split('\n')[0]) {
|
||||
const range = new vscode.Range(fix.startLine, 0, fix.endLine, document.lineAt(fix.endLine).text.length);
|
||||
if (document.getText(range) === fix.originalText) {
|
||||
return { matched: true, actualRange: range };
|
||||
}
|
||||
}
|
||||
|
||||
const index = document.getText().indexOf(fix.originalText);
|
||||
if (index !== -1) {
|
||||
return {
|
||||
matched: true,
|
||||
actualRange: new vscode.Range(
|
||||
document.positionAt(index),
|
||||
document.positionAt(index + fix.originalText.length)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { matched: false };
|
||||
}
|
||||
|
||||
function applySingleFix(editor: vscode.TextEditor, fix: CodeFix): boolean {
|
||||
if (!fix.actualRange || !fix.matched) { return false; }
|
||||
return editor.edit(editBuilder => {
|
||||
editBuilder.replace(fix.actualRange!, fix.newText);
|
||||
});
|
||||
}
|
||||
|
||||
const snapshotStack: Map<string, string[]> = new Map();
|
||||
|
||||
function saveSnapshot(document: vscode.TextDocument): void {
|
||||
const filePath = document.uri.fsPath;
|
||||
if (!snapshotStack.has(filePath)) { snapshotStack.set(filePath, []); }
|
||||
snapshotStack.get(filePath)!.push(document.getText());
|
||||
}
|
||||
|
||||
function undoLastFix(document: vscode.TextDocument): boolean {
|
||||
const stack = snapshotStack.get(document.uri.fsPath);
|
||||
if (!stack || stack.length === 0) { return false;
|
||||
const previousContent = stack.pop()!;
|
||||
const edit = new vscode.WorkspaceEdit();
|
||||
edit.replace(document.uri, new vscode.Range(0, 0, document.lineCount, 0), previousContent);
|
||||
return vscode.workspace.applyEdit(edit);
|
||||
}
|
||||
|
||||
function hasSnapshot(document: vscode.TextDocument): boolean {
|
||||
const stack = snapshotStack.get(document.uri.fsPath);
|
||||
return !!(stack && stack.length > 0);
|
||||
}
|
||||
|
||||
async function applyBatchFixes(document: vscode.TextDocument, fixes: CodeFix[]): Promise<number> {
|
||||
saveSnapshot(document);
|
||||
const validFixes = fixes.filter(f => f.matched);
|
||||
const sorted = [...validFixes].sort((a, b) => b.startLine - a.startLine);
|
||||
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor || editor.document.uri.toString() !== document.uri.toString()) { return 0; }
|
||||
|
||||
let applied = 0;
|
||||
for (const fix of sorted) {
|
||||
if (applySingleFix(editor, fix)) { applied++; }
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
export { prepareContext, generateFix, matchAndValidate, applySingleFix, applyBatchFixes, undoLastFix, saveSnapshot, hasSnapshot };
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
**动态上下文策略**(按设计 §8.3):
|
||||
|
||||
| 问题类型 | 上下文范围 |
|
||||
|---------|-----------|
|
||||
| naming | 问题行 ± 2 行 |
|
||||
| style | 问题行 ± 5 行 |
|
||||
| bug / security / performance | 整个函数/方法 |
|
||||
|
||||
**两阶段匹配**(按设计 §8.4):
|
||||
1. 按行号匹配原文首行 → 验证完整原文
|
||||
2. 全文搜索 originalText
|
||||
|
||||
**批量修复**:
|
||||
- 修复前保存快照(`saveSnapshot`)
|
||||
- 按位置倒序执行(`b.startLine - a.startLine`)
|
||||
- 只应用成功匹配的修复
|
||||
|
||||
**撤销机制**:
|
||||
- `snapshotStack` 按文件路径存储快照
|
||||
- 面板底部撤销按钮根据 `hasSnapshot` 状态启用/禁用
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,142 @@
|
||||
# Step 14 — Phase 4.6: 报告导出
|
||||
|
||||
**依赖**: Step 12(Merger)
|
||||
**参考设计**: §14.2
|
||||
|
||||
## 目标
|
||||
|
||||
将 `MergedReport` 导出为 Markdown 格式报告。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/utils/report.ts` | `reportToMarkdown()` |
|
||||
|
||||
---
|
||||
|
||||
## `src/utils/report.ts`
|
||||
|
||||
```typescript
|
||||
import { MergedReport } from '../merger/merger';
|
||||
|
||||
function severityEmoji(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'error': return '🔴';
|
||||
case 'warning': return '🟡';
|
||||
case 'info': return '🔵';
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
|
||||
function formatLine(line: number): string {
|
||||
return `L${line + 1}`;
|
||||
}
|
||||
|
||||
export function reportToMarkdown(report: MergedReport): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push('# 代码审查报告');
|
||||
lines.push('');
|
||||
lines.push(`**文件:** \`${report.filePath}\``);
|
||||
lines.push(`**语言:** ${report.language}`);
|
||||
lines.push(`**耗时:** ${(report.duration / 1000).toFixed(1)}s`);
|
||||
if (report.adapterNames.length > 0) {
|
||||
lines.push(`**分析工具:** ${report.adapterNames.join(', ')}`);
|
||||
}
|
||||
if (report.degraded) {
|
||||
lines.push('');
|
||||
lines.push('> ⚠️ 部分 AI 功能不可用,报告已降级');
|
||||
}
|
||||
if (report.errors.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('## 错误');
|
||||
for (const err of report.errors) {
|
||||
lines.push(`- ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
|
||||
const total = report.linterCount + report.customRuleCount + report.aiCount;
|
||||
const errors = report.linterDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'error').length;
|
||||
const warnings = report.linterDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'warning').length;
|
||||
const infos = total - errors - warnings;
|
||||
|
||||
lines.push(`**总计:** ${total} | **错误:** ${errors} | **警告:** ${warnings} | **建议:** ${infos}`);
|
||||
lines.push('');
|
||||
|
||||
if (report.linterDiagnostics.length > 0) {
|
||||
lines.push(`## 🔧 静态分析 · ${report.linterCount} 个问题`);
|
||||
lines.push('');
|
||||
for (const diag of report.linterDiagnostics) {
|
||||
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
|
||||
lines.push(` ${diag.message}`);
|
||||
if (diag.suggestion) {
|
||||
lines.push(` 建议: ${diag.suggestion}`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.customRuleDiagnostics.length > 0) {
|
||||
lines.push(`## 📋 自定义规则 · ${report.customRuleCount} 个问题`);
|
||||
lines.push('');
|
||||
for (const diag of report.customRuleDiagnostics) {
|
||||
lines.push(`- ${severityEmoji(diag.severity)} \`${diag.ruleId}\` ${formatLine(diag.range.start.line)}`);
|
||||
lines.push(` ${diag.message}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (report.aiFindings.length > 0) {
|
||||
lines.push(`## 🤖 AI 审查 · ${report.aiCount} 条建议`);
|
||||
lines.push('');
|
||||
for (const finding of report.aiFindings) {
|
||||
lines.push(`- ${severityEmoji(finding.severity)} [AI] [${finding.category}] \`${finding.ruleId}\` ${formatLine(finding.line)}`);
|
||||
lines.push(` **${finding.title}**`);
|
||||
lines.push(` ${finding.description}`);
|
||||
if (finding.suggestion) {
|
||||
lines.push(` 建议: ${finding.suggestion}`);
|
||||
}
|
||||
if (finding.codeDiff) {
|
||||
lines.push(' ```diff');
|
||||
lines.push(` ${finding.codeDiff.split('\n').join('\n ')}`);
|
||||
lines.push(' ```');
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
lines.push('✅ 未发现问题');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- 报告格式与设计 §14.2 一致
|
||||
- 按来源分三个段:静态分析 / 自定义规则 / AI 审查
|
||||
- 统计卡片:总计 + 按严重级别拆分
|
||||
- AI 审查结果包含 category、codeDiff(diff 代码块)
|
||||
- 降级和错误信息在报告顶部单独显示
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 文件创建完成
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,252 @@
|
||||
# Step 15 — Phase 5.1: 命令注册 + extension.ts 更新
|
||||
|
||||
**依赖**: Step 08, 14(Orchestrator + 报告导出),Step 13(Fixer 可选)
|
||||
**参考设计**: §6
|
||||
|
||||
## 目标
|
||||
|
||||
注册 8 个命令,更新 `package.json` 贡献点,更新 `extension.ts` 入口串联所有模块。
|
||||
|
||||
## 文件变更
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `src/activation/commands.ts` | 新建 | 所有命令处理函数注册 |
|
||||
| 2 | `src/extension.ts` | 修改 | 替换 helloWorld 为正式入口 |
|
||||
| 3 | `package.json` | 修改 | 添加 commands、viewsContainers、views、menus、configuration |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/activation/commands.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { Orchestrator } from '../orchestrator/orchestrator';
|
||||
import { runAIReview } from '../ai/engine';
|
||||
import { loadActiveRules } from '../rules/yaml-parser';
|
||||
import { mergeResults } from '../merger/merger';
|
||||
import { reportToMarkdown } from '../utils/report';
|
||||
import { getApiKey } from '../config';
|
||||
|
||||
export function registerCommands(context: vscode.ExtensionContext, orchestrator: Orchestrator): void {
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.review', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showWarningMessage('请先打开一个文件');
|
||||
return;
|
||||
}
|
||||
|
||||
const document = editor.document;
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '正在审查...',
|
||||
cancellable: false,
|
||||
}, async (progress) => {
|
||||
progress.report({ message: '运行静态分析...' });
|
||||
|
||||
const startTime = Date.now();
|
||||
const staticResult = await orchestrator.runStaticAnalysis(document, workingDir);
|
||||
|
||||
progress.report({ message: '运行 AI 审查...' });
|
||||
|
||||
const customRules = loadActiveRules(workspaceRoot);
|
||||
const code = document.getText();
|
||||
const aiResult = await runAIReview(context, code, staticResult.diagnostics, customRules);
|
||||
|
||||
const report = mergeResults({
|
||||
staticDiagnostics: staticResult.diagnostics,
|
||||
customRuleResults: aiResult.customRuleResults,
|
||||
translatedDiagnostics: aiResult.translatedDiagnostics,
|
||||
aiFindings: aiResult.findings,
|
||||
errors: [...staticResult.errors, ...(aiResult.error ? [aiResult.error] : [])],
|
||||
degraded: aiResult.degraded,
|
||||
startTime,
|
||||
filePath: document.uri.fsPath,
|
||||
language: document.languageId,
|
||||
adapterIds: staticResult.adapterIds,
|
||||
});
|
||||
|
||||
// TODO: Phase 5.3 — 推送报告到审查面板
|
||||
vscode.window.showInformationMessage(
|
||||
`审查完成: ${report.linterCount + report.customRuleCount + report.aiCount} 个问题`
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.reviewSelection', async () => {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) { return; }
|
||||
|
||||
const selection = editor.selection;
|
||||
if (selection.isEmpty) {
|
||||
vscode.window.showWarningMessage('请先选中要审查的代码');
|
||||
return;
|
||||
}
|
||||
|
||||
const code = editor.document.getText(selection);
|
||||
const apiKey = await getApiKey(context);
|
||||
if (!apiKey) {
|
||||
vscode.window.showWarningMessage('请先在设置面板中配置 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const customRules = loadActiveRules(workspaceRoot);
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '审查选中代码...',
|
||||
cancellable: false,
|
||||
}, async () => {
|
||||
const aiResult = await runAIReview(context, code, [], customRules);
|
||||
vscode.window.showInformationMessage(
|
||||
`选中代码审查完成: ${aiResult.customRuleResults.length + aiResult.findings.length} 个问题`
|
||||
);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.openPanel', () => {
|
||||
// TODO: Phase 5.3 — 打开审查面板
|
||||
vscode.window.showInformationMessage('审查面板功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.exportReport', async () => {
|
||||
// TODO: 与审查面板集成后获取最新 report
|
||||
vscode.window.showInformationMessage('请先运行完整审查生成报告');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.addCustomRule', () => {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) {
|
||||
vscode.window.showWarningMessage('请先打开工作区');
|
||||
return;
|
||||
}
|
||||
// TODO: 打开规则向导,保存到 .code-review/rules/
|
||||
vscode.window.showInformationMessage('添加自定义规则功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixIssue', () => {
|
||||
// TODO: Phase 4.5 — 单条修复(与审查面板交互)
|
||||
vscode.window.showInformationMessage('单条修复功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.fixAll', () => {
|
||||
// TODO: Phase 4.5 — 批量修复
|
||||
vscode.window.showInformationMessage('批量修复功能开发中');
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.openSetup', () => {
|
||||
vscode.commands.executeCommand('workbench.view.extension.code-reviewer');
|
||||
})
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/extension.ts` 修改
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { Orchestrator } from './orchestrator/orchestrator';
|
||||
import { registerCommands } from './activation/commands';
|
||||
|
||||
let orchestrator: Orchestrator;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
console.log('CodeGuard 代码审查插件已激活');
|
||||
|
||||
orchestrator = new Orchestrator();
|
||||
registerCommands(context, orchestrator);
|
||||
|
||||
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.workspace.onDidSaveTextDocument((document) => {
|
||||
const key = document.uri.toString();
|
||||
const existing = debounceTimers.get(key);
|
||||
if (existing) { clearTimeout(existing); }
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
debounceTimers.delete(key);
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
const workingDir = workspaceRoot || vscode.Uri.joinPath(document.uri, '..').fsPath;
|
||||
orchestrator.runStaticAnalysis(document, workingDir);
|
||||
}, 500);
|
||||
|
||||
debounceTimers.set(key, timer);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function deactivate() {
|
||||
orchestrator = undefined!;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `package.json` 修改
|
||||
|
||||
将 `configuration` 部分替换为完整的命令、视图容器、菜单和配置项。
|
||||
|
||||
**commands**:
|
||||
```json
|
||||
{
|
||||
"commands": [
|
||||
{ "command": "codeReviewer.review", "title": "CodeGuard: 运行代码审查" },
|
||||
{ "command": "codeReviewer.reviewSelection", "title": "CodeGuard: 审查选中代码" },
|
||||
{ "command": "codeReviewer.openPanel", "title": "CodeGuard: 打开审查面板" },
|
||||
{ "command": "codeReviewer.exportReport", "title": "CodeGuard: 导出报告" },
|
||||
{ "command": "codeReviewer.addCustomRule", "title": "CodeGuard: 添加自定义规则" },
|
||||
{ "command": "codeReviewer.fixIssue", "title": "CodeGuard: 修复此问题" },
|
||||
{ "command": "codeReviewer.fixAll", "title": "CodeGuard: 批量修复" },
|
||||
{ "command": "codeReviewer.openSetup", "title": "CodeGuard: 打开设置面板" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**keybindings**:
|
||||
```json
|
||||
{
|
||||
"keybindings": [
|
||||
{
|
||||
"command": "codeReviewer.review",
|
||||
"key": "ctrl+shift+r",
|
||||
"when": "editorTextFocus"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**viewsContainers** + **views** + **menus** + **configuration**: 见设计 §6.2-6.4
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 3 个文件变更完成
|
||||
- [ ] `Ctrl+Shift+R` 可触发审查
|
||||
- [ ] 保存文件后 500ms 自动运行静态分析
|
||||
- [ ] 命令面板显示 8 个命令
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,421 @@
|
||||
# Step 16 — Phase 5.2: 设置面板
|
||||
|
||||
**依赖**: Step 15
|
||||
**参考设计**: §13
|
||||
|
||||
## 目标
|
||||
|
||||
实现侧边栏设置面板 `codeReviewer.setupView`:快速引导、AI 配置、API Key、输出语言、自定义规则管理。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/views/setupView.ts` | `SetupViewProvider` implements `vscode.TreeDataProvider` |
|
||||
|
||||
## 面板参考
|
||||
|
||||
UI 预览文件: `docs/superpowers/specs/setup-panel-preview.html`
|
||||
|
||||
---
|
||||
|
||||
## `src/views/setupView.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import { getAIConfig, setApiKey, getApiKey, isApiKeyConfigured } from '../config';
|
||||
import { createProvider } from '../ai/factory';
|
||||
import { loadActiveRules } from '../rules/yaml-parser';
|
||||
import { CustomRule } from '../rules/yaml-parser';
|
||||
|
||||
type SetupItemType = 'section' | 'step' | 'providerGroup' | 'provider' | 'model' | 'apiKey' | 'language' | 'rule' | 'ruleAdd' | 'action';
|
||||
|
||||
class SetupItem extends vscode.TreeItem {
|
||||
constructor(
|
||||
public readonly label: string,
|
||||
public readonly itemType: SetupItemType,
|
||||
public readonly collapsibleState: vscode.TreeItemCollapsibleState,
|
||||
public readonly command?: vscode.Command,
|
||||
public readonly iconPath?: vscode.ThemeIcon,
|
||||
public readonly description?: string,
|
||||
public readonly contextValue?: string,
|
||||
) {
|
||||
super(label, collapsibleState);
|
||||
}
|
||||
}
|
||||
|
||||
export class SetupViewProvider implements vscode.TreeDataProvider<SetupItem> {
|
||||
private _onDidChangeTreeData = new vscode.EventEmitter<SetupItem | undefined>();
|
||||
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
|
||||
|
||||
private customRules: CustomRule[] = [];
|
||||
private apiKeyConfigured = false;
|
||||
private connectionTested = false;
|
||||
private connectionSuccess = false;
|
||||
|
||||
constructor(private context: vscode.ExtensionContext) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? '';
|
||||
this.customRules = loadActiveRules(workspaceRoot);
|
||||
this.apiKeyConfigured = await isApiKeyConfigured(this.context);
|
||||
this._onDidChangeTreeData.fire(undefined);
|
||||
}
|
||||
|
||||
getTreeItem(element: SetupItem): vscode.TreeItem {
|
||||
return element;
|
||||
}
|
||||
|
||||
async getChildren(element?: SetupItem): Promise<SetupItem[]> {
|
||||
if (!element) {
|
||||
return this.getRootItems();
|
||||
}
|
||||
|
||||
switch (element.itemType) {
|
||||
case 'providerGroup': return this.getProviderItems();
|
||||
case 'apiKey': return this.getApiKeyItems();
|
||||
case 'language': return this.getLanguageItems();
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
private getRootItems(): SetupItem[] {
|
||||
const items: SetupItem[] = [];
|
||||
|
||||
const step1Done = this.apiKeyConfigured;
|
||||
const step2Done = this.customRules.some(r => r.id);
|
||||
const step3Done = this.connectionTested && this.connectionSuccess;
|
||||
|
||||
items.push(new SetupItem(
|
||||
'快速开始',
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Expanded,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'section'
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step1Done ? '① 完成 AI 模型配置' : '① 配置 AI 模型及 API Key',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
step1Done ? undefined : {
|
||||
command: 'codeReviewer.focusApiKey',
|
||||
title: '配置 API Key',
|
||||
},
|
||||
step1Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step2Done ? '② 完成规则启用' : '② 启用自定义规则',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
undefined,
|
||||
step2Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
step3Done ? '③ 完成连接测试' : '③ 保存并测试连接',
|
||||
'step',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
step3Done ? undefined : {
|
||||
command: 'codeReviewer.saveAndTest',
|
||||
title: '测试连接',
|
||||
},
|
||||
step3Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'审核引擎',
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'AI 模型配置',
|
||||
'providerGroup',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'API Key',
|
||||
'apiKey',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
'输出语言',
|
||||
'language',
|
||||
vscode.TreeItemCollapsibleState.Collapsed
|
||||
));
|
||||
|
||||
items.push(new SetupItem(
|
||||
`自定义规则 [${this.customRules.length} 条]`,
|
||||
'section',
|
||||
vscode.TreeItemCollapsibleState.Expanded
|
||||
));
|
||||
|
||||
for (const rule of this.customRules) {
|
||||
items.push(new SetupItem(
|
||||
rule.id,
|
||||
'rule',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.toggleRule',
|
||||
title: '切换规则',
|
||||
arguments: [rule.id],
|
||||
},
|
||||
undefined,
|
||||
rule.severity,
|
||||
'rule'
|
||||
));
|
||||
}
|
||||
|
||||
items.push(new SetupItem(
|
||||
'输入规则名称... [+ 添加]',
|
||||
'ruleAdd',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.addCustomRule',
|
||||
title: '添加规则',
|
||||
}
|
||||
));
|
||||
|
||||
const connectionLabel = this.connectionTested
|
||||
? (this.connectionSuccess ? '✓ 已连接' : '✗ 重试')
|
||||
: '保存并测试连接';
|
||||
|
||||
items.push(new SetupItem(
|
||||
connectionLabel,
|
||||
'action',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.saveAndTest',
|
||||
title: '测试连接',
|
||||
}
|
||||
));
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
private getProviderItems(): SetupItem[] {
|
||||
const config = getAIConfig();
|
||||
return [
|
||||
new SetupItem(`提供商: ${config.provider}`,
|
||||
'provider',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectProvider',
|
||||
title: '选择提供商',
|
||||
}
|
||||
),
|
||||
new SetupItem(`模型: ${config.model}`,
|
||||
'model',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectModel',
|
||||
title: '选择模型',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private getApiKeyItems(): SetupItem[] {
|
||||
return [
|
||||
new SetupItem(
|
||||
'设置 API Key...',
|
||||
'apiKey',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.setApiKey',
|
||||
title: '设置 API Key',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private getLanguageItems(): SetupItem[] {
|
||||
const config = getAIConfig();
|
||||
return [
|
||||
new SetupItem(
|
||||
`当前: ${config.outputLanguage === 'zh-CN' ? '中文(简体)' : config.outputLanguage}`,
|
||||
'language',
|
||||
vscode.TreeItemCollapsibleState.None,
|
||||
{
|
||||
command: 'codeReviewer.selectLanguage',
|
||||
title: '选择输出语言',
|
||||
}
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 额外命令(在 `commands.ts` 中注册)
|
||||
|
||||
```typescript
|
||||
// 设置面板交互命令
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.setApiKey', async () => {
|
||||
const key = await vscode.window.showInputBox({
|
||||
prompt: '请输入 API Key',
|
||||
password: true,
|
||||
placeHolder: 'sk-...',
|
||||
});
|
||||
if (key) {
|
||||
await setApiKey(context, key);
|
||||
setupProvider?.refresh();
|
||||
vscode.window.showInformationMessage('API Key 已保存');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectProvider', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const current = config.get<string>('ai.provider', 'deepseek');
|
||||
const selected = await vscode.window.showQuickPick(['deepseek', 'openai'], {
|
||||
placeHolder: '选择模型提供商',
|
||||
});
|
||||
if (selected) {
|
||||
await config.update('ai.provider', selected, vscode.ConfigurationTarget.Global);
|
||||
setupProvider?.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectModel', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const current = config.get<string>('ai.model', '');
|
||||
const selected = await vscode.window.showInputBox({
|
||||
prompt: '输入模型名称',
|
||||
value: current,
|
||||
placeHolder: 'deepseek-chat',
|
||||
});
|
||||
if (selected) {
|
||||
await config.update('ai.model', selected, vscode.ConfigurationTarget.Global);
|
||||
setupProvider?.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.selectLanguage', async () => {
|
||||
const config = vscode.workspace.getConfiguration('vscode-code-reviewer');
|
||||
const current = config.get<string>('ai.outputLanguage', 'zh-CN');
|
||||
const selected = await vscode.window.showQuickPick(
|
||||
[
|
||||
{ label: '中文(简体)', value: 'zh-CN' },
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: '日本語', value: 'ja' },
|
||||
],
|
||||
{ placeHolder: '选择输出语言' }
|
||||
);
|
||||
if (selected) {
|
||||
await config.update('ai.outputLanguage', selected.value, vscode.ConfigurationTarget.Global);
|
||||
setupProvider?.refresh();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.saveAndTest', async () => {
|
||||
const apiKey = await getApiKey(context);
|
||||
if (!apiKey) {
|
||||
vscode.window.showWarningMessage('请先设置 API Key');
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getAIConfig();
|
||||
|
||||
await vscode.window.withProgress({
|
||||
location: vscode.ProgressLocation.Notification,
|
||||
title: '测试连接...',
|
||||
cancellable: false,
|
||||
}, async () => {
|
||||
try {
|
||||
const provider = createProvider(config.provider, apiKey, config.endpoint);
|
||||
await provider.chat('回复 ok', 'ping', {
|
||||
model: config.model,
|
||||
temperature: 0,
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
connectionTested = true;
|
||||
connectionSuccess = true;
|
||||
setupProvider?.refresh();
|
||||
vscode.window.showInformationMessage('✓ 连接成功', { modal: false });
|
||||
} catch (err) {
|
||||
connectionTested = true;
|
||||
connectionSuccess = false;
|
||||
setupProvider?.refresh();
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
vscode.window.showErrorMessage(`✗ 连接失败: ${message}`, { modal: false });
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand('codeReviewer.toggleRule', async (ruleId: string) => {
|
||||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (!workspaceRoot) { return; }
|
||||
|
||||
const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml');
|
||||
if (!fs.existsSync(configPath)) { return; }
|
||||
|
||||
let content = fs.readFileSync(configPath, 'utf-8');
|
||||
const enabledPattern = new RegExp(`^(\\s*${ruleId}\\s*:\\s*\\n\\s*enabled\\s*:\\s*)(true|false)`, 'm');
|
||||
const match = enabledPattern.exec(content);
|
||||
|
||||
if (match) {
|
||||
const newValue = match[2] === 'true' ? 'false' : 'true';
|
||||
content = content.replace(enabledPattern, `$1${newValue}`);
|
||||
} else {
|
||||
content += `\n ${ruleId}:\n enabled: false\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(configPath, content, 'utf-8');
|
||||
setupProvider?.refresh();
|
||||
})
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 在 `extension.ts` 中注册视图
|
||||
|
||||
```typescript
|
||||
import { SetupViewProvider } from './views/setupView';
|
||||
|
||||
let setupProvider: SetupViewProvider;
|
||||
|
||||
export function activate(context: vscode.ExtensionContext) {
|
||||
setupProvider = new SetupViewProvider(context);
|
||||
vscode.window.registerTreeDataProvider('codeReviewer.setupView', setupProvider);
|
||||
// ... 其余
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**: 需要 `setupProvider` 作为模块级变量暴露给 `commands.ts`。
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 侧边栏显示 CodeGuard 设置视图
|
||||
- [ ] 快速开始三步引导可见
|
||||
- [ ] API Key 可输入并保存到 SecretStorage
|
||||
- [ ] 提供商/模型可切换
|
||||
- [ ] 自定义规则列表展示并支持开关
|
||||
- [ ] 保存并测试连接按钮功能正常
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,337 @@
|
||||
# Step 17 — Phase 5.3: 审查面板(Webview)
|
||||
|
||||
**依赖**: Step 12, 15
|
||||
**参考设计**: §7, §14
|
||||
|
||||
## 目标
|
||||
|
||||
实现 Webview 审查报告面板:三 Tab 切换、统计卡片、问题列表、postMessage 通信、降级提示。
|
||||
|
||||
## 新建文件
|
||||
|
||||
| # | 文件 | 说明 |
|
||||
|---|------|------|
|
||||
| 1 | `src/panel/webview.ts` | `ReviewPanel` 类(Webview 管理 + HTML 生成) |
|
||||
|
||||
## 面板参考
|
||||
|
||||
UI 预览文件: `docs/superpowers/specs/review-panel-preview.html`
|
||||
|
||||
---
|
||||
|
||||
## `src/panel/webview.ts`
|
||||
|
||||
```typescript
|
||||
import * as vscode from 'vscode';
|
||||
import { MergedReport } from '../merger/merger';
|
||||
|
||||
interface PanelMessage {
|
||||
type: 'navigate' | 'rerun' | 'export' | 'settings' | 'fix' | 'fixAll';
|
||||
line?: number;
|
||||
ruleId?: string;
|
||||
source?: 'linter' | 'custom' | 'ai';
|
||||
}
|
||||
|
||||
export class ReviewPanel {
|
||||
public static currentPanel: ReviewPanel | undefined;
|
||||
private readonly panel: vscode.WebviewPanel;
|
||||
private disposables: vscode.Disposable[] = [];
|
||||
|
||||
private constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
column: vscode.ViewColumn
|
||||
) {
|
||||
this.panel = vscode.window.createWebviewPanel(
|
||||
'codeReviewer.reviewPanel',
|
||||
'代码审查报告',
|
||||
column,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
localResourceRoots: [],
|
||||
}
|
||||
);
|
||||
|
||||
this.panel.onDidDispose(() => this.dispose(), null, this.disposables);
|
||||
|
||||
this.panel.webview.onDidReceiveMessage(
|
||||
(message: PanelMessage) => this.handleMessage(message),
|
||||
null,
|
||||
this.disposables
|
||||
);
|
||||
}
|
||||
|
||||
static createOrShow(extensionUri: vscode.Uri, column?: vscode.ViewColumn): ReviewPanel {
|
||||
if (ReviewPanel.currentPanel) {
|
||||
ReviewPanel.currentPanel.panel.reveal(column);
|
||||
return ReviewPanel.currentPanel;
|
||||
}
|
||||
|
||||
ReviewPanel.currentPanel = new ReviewPanel(extensionUri, column ?? vscode.ViewColumn.Two);
|
||||
return ReviewPanel.currentPanel;
|
||||
}
|
||||
|
||||
update(report: MergedReport): void {
|
||||
this.panel.webview.html = this.buildHtml(report);
|
||||
}
|
||||
|
||||
private buildHtml(report: MergedReport): string {
|
||||
const total = report.linterCount + report.customRuleCount + report.aiCount;
|
||||
const errorCount = report.linterDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'error').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'error').length;
|
||||
const warnCount = report.linterDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.customRuleDiagnostics.filter(d => d.severity === 'warning').length
|
||||
+ report.aiFindings.filter(f => f.severity === 'warning').length;
|
||||
const infoCount = total - errorCount - warnCount;
|
||||
|
||||
const fileName = report.filePath.split(/[/\\]/).pop() ?? '';
|
||||
|
||||
const degradedBanner = report.degraded
|
||||
? `<div class="banner ${report.errors.length > 0 ? 'banner-error' : 'banner-warn'}">
|
||||
${report.errors.length > 0 ? '⚠ AI 审查失败' : '⚠ 部分 AI 功能不可用'}
|
||||
${report.errors.join('; ')}
|
||||
</div>`
|
||||
: '';
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>代码审查报告</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: var(--vscode-font-family); font-size: var(--vscode-font-size); color: var(--vscode-foreground); background: var(--vscode-editor-background); padding: 16px; }
|
||||
.header { margin-bottom: 16px; }
|
||||
.header h1 { font-size: 18px; margin-bottom: 4px; }
|
||||
.header .meta { font-size: 12px; color: var(--vscode-descriptionForeground); }
|
||||
.banner { padding: 8px 12px; border-radius: 4px; margin-bottom: 12px; font-size: 13px; }
|
||||
.banner-warn { background: #332B00; border: 1px solid #665C00; color: #FFF3B0; }
|
||||
.banner-error { background: #330000; border: 1px solid #660000; color: #FFB0B0; }
|
||||
.stats { display: flex; gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { flex: 1; padding: 12px; border-radius: 6px; text-align: center; background: var(--vscode-sideBar-background); }
|
||||
.stat-card .num { font-size: 24px; font-weight: 600; }
|
||||
.stat-card .label { font-size: 12px; color: var(--vscode-descriptionForeground); margin-top: 2px; }
|
||||
.stat-total .num { color: var(--vscode-foreground); }
|
||||
.stat-error .num { color: #E06C75; }
|
||||
.stat-warn .num { color: #D19A66; }
|
||||
.stat-info .num { color: #61AFEF; }
|
||||
.tabs { display: flex; gap: 0; margin-bottom: 12px; border-bottom: 1px solid var(--vscode-panel-border); }
|
||||
.tab { padding: 8px 16px; cursor: pointer; border: none; background: none; color: var(--vscode-descriptionForeground); font-family: var(--vscode-font-family); font-size: 13px; border-bottom: 2px solid transparent; }
|
||||
.tab.active { color: var(--vscode-foreground); border-bottom-color: #7C3AED; }
|
||||
.tab .count { margin-left: 6px; font-size: 11px; opacity: 0.7; }
|
||||
.issue-list { display: none; }
|
||||
.issue-list.active { display: block; }
|
||||
.issue { padding: 8px 12px; border-radius: 4px; margin-bottom: 6px; background: var(--vscode-sideBar-background); cursor: pointer; display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.issue:hover { background: var(--vscode-list-hoverBackground); }
|
||||
.issue-left { flex: 1; }
|
||||
.issue-title { font-size: 13px; margin-bottom: 2px; }
|
||||
.issue-detail { font-size: 12px; color: var(--vscode-descriptionForeground); }
|
||||
.issue-actions { display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.btn { padding: 2px 8px; border-radius: 3px; border: 1px solid var(--vscode-button-border); background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground); cursor: pointer; font-size: 11px; }
|
||||
.btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.btn-primary { background: #7C3AED; border-color: #7C3AED; color: #fff; }
|
||||
.btn-primary:hover { background: #6D28D9; }
|
||||
.severity { display: inline-block; width: 16px; text-align: center; }
|
||||
.actions { display: flex; gap: 8px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--vscode-panel-border); }
|
||||
.empty { text-align: center; padding: 24px; color: var(--vscode-descriptionForeground); font-size: 13px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📋 代码审查报告</h1>
|
||||
<div class="meta">${fileName} · ${report.language} · ${(report.duration / 1000).toFixed(1)}s</div>
|
||||
</div>
|
||||
${degradedBanner}
|
||||
<div class="stats">
|
||||
<div class="stat-card stat-total"><div class="num">${total}</div><div class="label">总计</div></div>
|
||||
<div class="stat-card stat-error"><div class="num">${errorCount}</div><div class="label">错误</div></div>
|
||||
<div class="stat-card stat-warn"><div class="num">${warnCount}</div><div class="label">警告</div></div>
|
||||
<div class="stat-card stat-info"><div class="num">${infoCount}</div><div class="label">建议</div></div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('linter')">🔧 静态分析 <span class="count">${report.linterCount}</span></button>
|
||||
<button class="tab" onclick="switchTab('custom')">📋 自定义规则 <span class="count">${report.customRuleCount}</span></button>
|
||||
<button class="tab" onclick="switchTab('ai')">🤖 AI 审查 <span class="count">${report.aiCount}</span></button>
|
||||
</div>
|
||||
<div id="tab-linter" class="issue-list active">
|
||||
${this.buildLinterList(report)}
|
||||
</div>
|
||||
<div id="tab-custom" class="issue-list">
|
||||
${this.buildCustomList(report)}
|
||||
</div>
|
||||
<div id="tab-ai" class="issue-list">
|
||||
${this.buildAIList(report)}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="send('rerun')">🔄 重新审查</button>
|
||||
<button class="btn" onclick="send('export')">📄 导出</button>
|
||||
<button class="btn" onclick="send('settings')">⚙️ 设置</button>
|
||||
<button class="btn" onclick="send('fixAll')">🔧 批量修复</button>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
function send(type, line, ruleId, source) {
|
||||
vscode.postMessage({ type, line, ruleId, source });
|
||||
}
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.issue-list').forEach(l => l.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
private buildLinterList(report: MergedReport): string {
|
||||
if (report.linterDiagnostics.length === 0) {
|
||||
return '<div class="empty">✅ 静态分析未发现问题</div>';
|
||||
}
|
||||
return report.linterDiagnostics.map((d, i) => `
|
||||
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
|
||||
<div class="issue-detail">${this.escape(d.message)}</div>
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'linter')">修复</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
private buildCustomList(report: MergedReport): string {
|
||||
if (report.customRuleDiagnostics.length === 0) {
|
||||
return '<div class="empty">✅ 自定义规则未发现问题</div>';
|
||||
}
|
||||
return report.customRuleDiagnostics.map((d, i) => `
|
||||
<div class="issue" onclick="send('navigate', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">${this.sevIcon(d.severity)}</span> <code>${this.escape(d.ruleId)}</code> L${d.range.start.line + 1}</div>
|
||||
<div class="issue-detail">${this.escape(d.message)}</div>
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${d.range.start.line}, '${this.escape(d.ruleId)}', 'custom')">修复</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
private buildAIList(report: MergedReport): string {
|
||||
const total = report.translatedDiagnostics.length + report.aiFindings.length;
|
||||
if (total === 0) {
|
||||
return '<div class="empty">🤖 AI 审查未发现新问题</div>';
|
||||
}
|
||||
const parts: string[] = [];
|
||||
|
||||
for (const td of report.translatedDiagnostics) {
|
||||
parts.push(`
|
||||
<div class="issue">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">🔵</span> <code>${this.escape(td.originalRuleId)}</code></div>
|
||||
<div class="issue-detail">${this.escape(td.translatedMessage)}</div>
|
||||
${td.translatedSuggestion ? `<div class="issue-detail">建议: ${this.escape(td.translatedSuggestion)}</div>` : ''}
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
for (const f of report.aiFindings) {
|
||||
parts.push(`
|
||||
<div class="issue" onclick="send('navigate', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">
|
||||
<div class="issue-left">
|
||||
<div class="issue-title"><span class="severity">${this.sevIcon(f.severity)}</span> [${f.category}] <strong>${this.escape(f.title)}</strong></div>
|
||||
<div class="issue-detail">${this.escape(f.description)}</div>
|
||||
${f.suggestion ? `<div class="issue-detail">建议: ${this.escape(f.suggestion)}</div>` : ''}
|
||||
</div>
|
||||
<div class="issue-actions">
|
||||
<button class="btn" onclick="event.stopPropagation();send('fix', ${f.line}, '${this.escape(f.ruleId)}', 'ai')">修复</button>
|
||||
</div>
|
||||
</div>`);
|
||||
}
|
||||
|
||||
return parts.join('');
|
||||
}
|
||||
|
||||
private sevIcon(severity: string): string {
|
||||
switch (severity) {
|
||||
case 'error': return '🔴';
|
||||
case 'warning': return '🟡';
|
||||
case 'info': return '🔵';
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
|
||||
private escape(str: string): string {
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
private handleMessage(message: PanelMessage): void {
|
||||
switch (message.type) {
|
||||
case 'navigate':
|
||||
if (message.line !== undefined) {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (editor) {
|
||||
const line = Math.max(0, message.line);
|
||||
const range = new vscode.Range(line, 0, line, 0);
|
||||
editor.selection = new vscode.Selection(range.start, range.end);
|
||||
editor.revealRange(range, vscode.TextEditorRevealType.InCenter);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'rerun':
|
||||
vscode.commands.executeCommand('codeReviewer.review');
|
||||
break;
|
||||
case 'export':
|
||||
vscode.commands.executeCommand('codeReviewer.exportReport');
|
||||
break;
|
||||
case 'settings':
|
||||
vscode.commands.executeCommand('codeReviewer.openSetup');
|
||||
break;
|
||||
case 'fix':
|
||||
vscode.commands.executeCommand('codeReviewer.fixIssue', message);
|
||||
break;
|
||||
case 'fixAll':
|
||||
vscode.commands.executeCommand('codeReviewer.fixAll');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
ReviewPanel.currentPanel = undefined;
|
||||
this.panel.dispose();
|
||||
for (const d of this.disposables) { d.dispose(); }
|
||||
this.disposables = [];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 额外修改:`commands.ts` 中集成审查面板调用
|
||||
|
||||
```typescript
|
||||
import { ReviewPanel } from '../panel/webview';
|
||||
|
||||
// 在 'codeReviewer.review' 命令中,静态分析完成后:
|
||||
const panel = ReviewPanel.createOrShow(context.extensionUri);
|
||||
panel.update(report);
|
||||
|
||||
// 'codeReviewer.exportReport' 命令:
|
||||
const markdown = reportToMarkdown(report);
|
||||
const doc = await vscode.workspace.openTextDocument({ content: markdown, language: 'markdown' });
|
||||
await vscode.window.showTextDocument(doc);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 审查面板可打开(Webview)
|
||||
- [ ] 三 Tab 切换正常工作
|
||||
- [ ] 统计卡片数值正确
|
||||
- [ ] 问题列表可点击跳转到代码位置
|
||||
- [ ] 降级提示条在 AI 失败时显示
|
||||
- [ ] 修复/重新审查/导出按钮发送正确消息
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
@@ -0,0 +1,109 @@
|
||||
# Step 18 — Phase 6.1: 构建脚本
|
||||
|
||||
**依赖**: Step 15(extension.ts 完成)
|
||||
**参考设计**: §15
|
||||
|
||||
## 目标
|
||||
|
||||
搭建 esbuild 构建流程,替换 tsc 为打包构建,更新 package.json 脚本。
|
||||
|
||||
## 文件变更
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `scripts/build.mjs` | 新建 | esbuild 打包脚本 |
|
||||
| 2 | `package.json` | 修改 | 更新 build/vscode:prepublish 脚本 |
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
npm install --save-dev esbuild@^0.28.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `scripts/build.mjs`
|
||||
|
||||
```javascript
|
||||
import * as esbuild from 'esbuild';
|
||||
import { copyFileSync, mkdirSync, existsSync, cpSync } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = resolve(__dirname, '..');
|
||||
const outDir = resolve(rootDir, 'out');
|
||||
|
||||
if (!existsSync(outDir)) {
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
}
|
||||
|
||||
await esbuild.build({
|
||||
entryPoints: [resolve(rootDir, 'src', 'extension.ts')],
|
||||
bundle: true,
|
||||
outfile: resolve(outDir, 'extension.js'),
|
||||
external: [
|
||||
'vscode',
|
||||
'eslint',
|
||||
'stylelint',
|
||||
'child_process',
|
||||
'fs',
|
||||
'path',
|
||||
'url',
|
||||
'os',
|
||||
],
|
||||
format: 'cjs',
|
||||
platform: 'node',
|
||||
target: 'node22',
|
||||
minify: true,
|
||||
sourcemap: false,
|
||||
treeShaking: true,
|
||||
});
|
||||
|
||||
const jarsSrc = resolve(rootDir, 'jars');
|
||||
const jarsDest = resolve(outDir, 'jars');
|
||||
if (existsSync(jarsSrc)) {
|
||||
cpSync(jarsSrc, jarsDest, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('Build complete.');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `package.json` 脚本更新
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"compile": "tsc -p ./",
|
||||
"watch": "tsc -watch -p ./",
|
||||
"build": "node scripts/build.mjs",
|
||||
"vscode:prepublish": "npm run build",
|
||||
"pretest": "npm run compile && npm run lint",
|
||||
"lint": "eslint src",
|
||||
"test": "vscode-test"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键逻辑
|
||||
|
||||
- `external`: vscode API、npm 包、Node 内置模块不打包
|
||||
- `format: 'cjs'`: VSCode 扩展需要 CommonJS
|
||||
- `target: 'node22'`: 对应 VSCode 1.120+ 的 Node 版本
|
||||
- `minify: true`: 产物压缩
|
||||
- `treeShaking: true`: 移除未使用代码
|
||||
- 复制 `jars/` 目录到 `out/` 供运行时加载
|
||||
- `vscode:prepublish` 改为 `npm run build`(生产打包)
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] `npm run build` 成功执行
|
||||
- [ ] `out/extension.js` 生成(单文件 bundle)
|
||||
- [ ] `out/jars/` 目录存在
|
||||
- [ ] F5 启动扩展开发宿主功能正常
|
||||
@@ -0,0 +1,124 @@
|
||||
# Step 19 — Phase 6.2: 工具脚本
|
||||
|
||||
**依赖**: Step 18
|
||||
**参考设计**: §15, §17
|
||||
|
||||
## 目标
|
||||
|
||||
实现 PMD JAR 下载脚本和生产打包脚本。
|
||||
|
||||
## 文件变更
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `scripts/download-pmd.mjs` | 新建 | 下载 PMD 7.26.0 JAR 依赖 |
|
||||
| 2 | `scripts/package-prod.mjs` | 新建 | 生产打包脚本 |
|
||||
| 3 | `package.json` | 修改 | 添加 download-pmd / package-prod 脚本 |
|
||||
|
||||
## 前置准备
|
||||
|
||||
```bash
|
||||
npm install --save-dev @vscode/vsce@^3.9.2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. `scripts/download-pmd.mjs`
|
||||
|
||||
```javascript
|
||||
import { execSync } from 'child_process';
|
||||
import { existsSync, mkdirSync, createWriteStream } from 'fs';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { get } from 'https';
|
||||
import { unlinkSync, readdirSync } from 'fs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = resolve(__dirname, '..');
|
||||
const libDir = resolve(rootDir, 'jars', 'pmd', 'lib');
|
||||
|
||||
const PMD_VERSION = '7.26.0';
|
||||
const PMD_JARS = [
|
||||
`pmd-core-${PMD_VERSION}.jar`,
|
||||
`pmd-java-${PMD_VERSION}.jar`,
|
||||
`pmd-javascript-${PMD_VERSION}.jar`,
|
||||
`pmd-jsp-${PMD_VERSION}.jar`,
|
||||
];
|
||||
|
||||
const MAVEN_BASE = `https://repo1.maven.org/maven2/net/sourceforge/pmd`;
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = createWriteStream(dest);
|
||||
get(url, (response) => {
|
||||
if (response.statusCode === 302 || response.statusCode === 301) {
|
||||
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
response.pipe(file);
|
||||
file.on('finish', () => { file.close(); resolve(); });
|
||||
file.on('error', (err) => { unlinkSync(dest); reject(err); });
|
||||
}).on('error', (err) => { unlinkSync(dest); reject(err); });
|
||||
});
|
||||
}
|
||||
|
||||
if (!existsSync(libDir)) {
|
||||
mkdirSync(libDir, { recursive: true });
|
||||
}
|
||||
|
||||
for (const jar of PMD_JARS) {
|
||||
const moduleName = jar.replace(`-${PMD_VERSION}.jar`, '').replace('pmd-', '');
|
||||
const url = `${MAVEN_BASE}/pmd-${moduleName}/${PMD_VERSION}/${jar}`;
|
||||
const dest = resolve(libDir, jar);
|
||||
|
||||
if (existsSync(dest)) {
|
||||
console.log(`Skip: ${jar} (exists)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`Downloading: ${url}`);
|
||||
await downloadFile(url, dest);
|
||||
console.log(`Done: ${jar}`);
|
||||
}
|
||||
|
||||
console.log('PMD JARs download complete.');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `scripts/package-prod.mjs`
|
||||
|
||||
```javascript
|
||||
import { execSync } from 'child_process';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = resolve(__dirname, '..');
|
||||
|
||||
execSync('node scripts/build.mjs', { cwd: rootDir, stdio: 'inherit' });
|
||||
execSync('npx vsce package', { cwd: rootDir, stdio: 'inherit' });
|
||||
|
||||
console.log('Production package complete.');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `package.json` 脚本更新
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"download-pmd": "node scripts/download-pmd.mjs",
|
||||
"package-prod": "node scripts/package-prod.mjs"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] `npm run download-pmd` 成功下载 PMD JAR 到 `jars/pmd/lib/`
|
||||
- [ ] `npm run package-prod` 成功生成 `.vsix` 文件
|
||||
- [ ] `.vsix` 可安装到 VSCode
|
||||
@@ -0,0 +1,252 @@
|
||||
# Step 20 — Phase 6.3: 测试
|
||||
|
||||
**依赖**: Step 19(构建完成)
|
||||
**参考设计**: §16
|
||||
|
||||
## 目标
|
||||
|
||||
实现测试:适配器测试、配置测试、合并逻辑测试、完整流程测试。
|
||||
|
||||
## 文件变更
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `src/test/fixtures/` | 新建 | 测试用代码样本目录 |
|
||||
| 2 | `src/test/adapter.test.ts` | 新建 | 适配器输出解析测试 |
|
||||
| 3 | `src/test/config.test.ts` | 新建 | 配置读取测试 |
|
||||
| 4 | `src/test/merger.test.ts` | 新建 | 多源结果合并 + 统计计算测试 |
|
||||
| 5 | `src/test/pipeline.test.ts` | 新建 | 全链路集成测试 |
|
||||
| 6 | `src/test/extension.test.ts` | 修改 | 替换占位测试 |
|
||||
|
||||
---
|
||||
|
||||
## 1. `src/test/fixtures/` 目录
|
||||
|
||||
### `src/test/fixtures/sample.js`
|
||||
|
||||
```javascript
|
||||
function test() {
|
||||
var unused = 1;
|
||||
console.log('debug');
|
||||
return "hello world";
|
||||
}
|
||||
```
|
||||
|
||||
### `src/test/fixtures/sample.css`
|
||||
|
||||
```css
|
||||
.hello { color: black; background: #FFF; }
|
||||
#test { margin: 0px; }
|
||||
```
|
||||
|
||||
### `src/test/fixtures/Sample.java`
|
||||
|
||||
```java
|
||||
public class Sample {
|
||||
public void test() {
|
||||
String password = "admin123";
|
||||
System.out.println("debug");
|
||||
System.out.println("debug");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. `src/test/adapter.test.ts`
|
||||
|
||||
```typescript
|
||||
import * as assert from 'assert';
|
||||
import * as vscode from 'vscode';
|
||||
import { ESLintAdapter } from '../adapters/eslint';
|
||||
import { StylelintAdapter } from '../adapters/stylelint';
|
||||
|
||||
suite('Adapter Tests', () => {
|
||||
test('ESLintAdapter has correct id and languages', () => {
|
||||
const adapter = new ESLintAdapter();
|
||||
assert.strictEqual(adapter.id, 'eslint');
|
||||
assert.deepStrictEqual(adapter.supportedLanguages, ['javascript', 'typescript']);
|
||||
});
|
||||
|
||||
test('StylelintAdapter has correct id and languages', () => {
|
||||
const adapter = new StylelintAdapter();
|
||||
assert.strictEqual(adapter.id, 'stylelint');
|
||||
assert.deepStrictEqual(adapter.supportedLanguages, ['css']);
|
||||
});
|
||||
|
||||
test('ESLintAdapter check returns AdapterResult structure', async () => {
|
||||
const adapter = new ESLintAdapter();
|
||||
if (!adapter.isAvailable()) { return; }
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
content: 'const x = 1;\nconsole.log(x);\n',
|
||||
language: 'javascript',
|
||||
});
|
||||
|
||||
const result = await adapter.check(doc, __dirname);
|
||||
assert.ok(result.status === 'ok' || result.status === 'tool-unavailable');
|
||||
assert.ok(Array.isArray(result.diagnostics));
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. `src/test/config.test.ts`
|
||||
|
||||
```typescript
|
||||
import * as assert from 'assert';
|
||||
import { getAIConfig } from '../config/ai';
|
||||
import { getLinterConfig } from '../config/linter';
|
||||
import { getFixerConfig } from '../config/fixer';
|
||||
|
||||
suite('Config Tests', () => {
|
||||
test('getAIConfig returns default values', () => {
|
||||
const config = getAIConfig();
|
||||
assert.strictEqual(config.provider, 'deepseek');
|
||||
assert.strictEqual(config.model, 'deepseek-chat');
|
||||
assert.strictEqual(config.temperature, 0.2);
|
||||
assert.strictEqual(config.timeout, 300);
|
||||
assert.strictEqual(config.outputLanguage, 'zh-CN');
|
||||
});
|
||||
|
||||
test('getLinterConfig returns default language map', () => {
|
||||
const config = getLinterConfig();
|
||||
assert.strictEqual(config.languageMap.javascript, 'eslint');
|
||||
assert.strictEqual(config.languageMap.java, 'pmd');
|
||||
assert.strictEqual(config.languageMap.css, 'stylelint');
|
||||
assert.strictEqual(config.languageMap.jsp, 'jsp');
|
||||
});
|
||||
|
||||
test('getFixerConfig returns default values', () => {
|
||||
const config = getFixerConfig();
|
||||
assert.strictEqual(config.contextLines, 5);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. `src/test/merger.test.ts`
|
||||
|
||||
```typescript
|
||||
import * as assert from 'assert';
|
||||
import { mergeResults, MergedReport } from '../merger/merger';
|
||||
import { CustomRuleResult, AIFinding } from '../ai/schema';
|
||||
import { LinterDiagnostic } from '../types';
|
||||
|
||||
suite('Merger Tests', () => {
|
||||
test('mergeResults counts correctly', () => {
|
||||
const staticDiags: LinterDiagnostic[] = [
|
||||
{ severity: 'error', ruleId: 'eslint:no-unused', message: 'x is unused', range: new (require('vscode').Range)(0, 0, 0, 1) },
|
||||
];
|
||||
const customResults: CustomRuleResult[] = [
|
||||
{ ruleId: 'custom:no-console', line: 5, severity: 'warning', message: 'avoid console.log' },
|
||||
];
|
||||
const aiFindings: AIFinding[] = [
|
||||
{ ruleId: 'hardcoded-secret', severity: 'error', category: 'security', title: 'Hardcoded', description: 'Found secret', suggestion: 'Use env', line: 3 },
|
||||
];
|
||||
|
||||
const report = mergeResults({
|
||||
staticDiagnostics: staticDiags,
|
||||
customRuleResults: customResults,
|
||||
translatedDiagnostics: [],
|
||||
aiFindings,
|
||||
errors: [],
|
||||
degraded: false,
|
||||
startTime: Date.now(),
|
||||
filePath: '/test/sample.js',
|
||||
language: 'javascript',
|
||||
adapterIds: ['eslint'],
|
||||
});
|
||||
|
||||
assert.strictEqual(report.linterCount, 1);
|
||||
assert.strictEqual(report.customRuleCount, 1);
|
||||
assert.strictEqual(report.aiCount, 1);
|
||||
assert.strictEqual(report.degraded, false);
|
||||
assert.strictEqual(report.language, 'javascript');
|
||||
});
|
||||
|
||||
test('mergeResults marks degraded when AI fails', () => {
|
||||
const report = mergeResults({
|
||||
staticDiagnostics: [],
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
aiFindings: [],
|
||||
errors: ['AI 请求超时'],
|
||||
degraded: true,
|
||||
startTime: Date.now(),
|
||||
filePath: '/test/sample.js',
|
||||
language: 'javascript',
|
||||
adapterIds: ['eslint'],
|
||||
});
|
||||
|
||||
assert.strictEqual(report.degraded, true);
|
||||
assert.strictEqual(report.errors.length, 1);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `src/test/pipeline.test.ts`
|
||||
|
||||
```typescript
|
||||
import * as assert from 'assert';
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import { ESLintAdapter } from '../adapters/eslint';
|
||||
import { mergeResults } from '../merger/merger';
|
||||
|
||||
suite('Pipeline Tests', () => {
|
||||
test('Full pipeline: linter check + merge', async () => {
|
||||
const adapter = new ESLintAdapter();
|
||||
if (!adapter.isAvailable()) { return; }
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument({
|
||||
content: 'var x = 1;\nvar y = 2;\n',
|
||||
language: 'javascript',
|
||||
});
|
||||
|
||||
const staticResult = await adapter.check(doc, __dirname);
|
||||
assert.ok(staticResult.status === 'ok');
|
||||
|
||||
const report = mergeResults({
|
||||
staticDiagnostics: staticResult.diagnostics,
|
||||
customRuleResults: [],
|
||||
translatedDiagnostics: [],
|
||||
aiFindings: [],
|
||||
errors: [],
|
||||
degraded: false,
|
||||
startTime: Date.now(),
|
||||
filePath: 'virtual-doc',
|
||||
language: 'javascript',
|
||||
adapterIds: ['eslint'],
|
||||
});
|
||||
|
||||
assert.ok(typeof report.duration === 'number');
|
||||
assert.ok(typeof report.linterCount === 'number');
|
||||
assert.strictEqual(report.language, 'javascript');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证命令
|
||||
|
||||
```bash
|
||||
npm test # compile + lint + test
|
||||
# 或
|
||||
npm run compile && npm run lint && npm run test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收
|
||||
|
||||
- [ ] 所有测试文件创建完成
|
||||
- [ ] 测试夹具文件(fixtures)就位
|
||||
- [ ] `npm run compile` 通过
|
||||
- [ ] `npm run lint` 通过
|
||||
- [ ] `npm test` 通过
|
||||
Reference in New Issue
Block a user