feat: Linter 规则精细化增强 + 模板导入/导出闭环

ESLint: +29 条 P1/P2 规则 + 12 条 TS 专属规则(含 no-shadow/no-array-constructor 冲突处理)
Stylelint: 集成 stylelint-config-recommended + 27 条额外规则
PMD: 排除 20 弃用 + 17 噪音规则,补启 Security/Multithreading,274+12 条精选
SQL-lint: 内置精选 57 条规则配置 + 按 tier 分级 severity + 无项目配置时自动注入临时配置
模板导出: export-service.ts 导出 2-sheet xlsx(复用 xlsx 零新依赖)
模板导入: template-converter.ts 固定列映射解析 + dedup-prompt.ts AI 语义去重
导入预览增强: 错误规则分组置顶只读、跳过行提示、空数据提示
i18n: 新增 18 条模板导入/导出相关翻译
WebView: 静态分析/自定义规则项默认可展开显示 suggestion
This commit is contained in:
范智鹏
2026-07-30 23:17:20 +08:00
parent d2dd16a043
commit 5848eaa82a
27 changed files with 6610 additions and 153 deletions
@@ -0,0 +1,906 @@
# 自定义规则 · 模板导出与导入功能设计书
> 项目:vscode-code-reviewer(净码特工 · Code Purifier
> 仓库:sdjndaq/2026-ai-b3 分支:vscode-code-reviewer
> 版本:v2.0(整合导出 + 模板导入)
> 日期:2026-07-30
---
## 一、概述
### 1.1 背景
当前插件的自定义规则导入是「不确定规格 + AI 兜底」模式:任意格式的文件(md/txt/xlsx/docx/pptx)经 converter 转成 markdown,由 AI 自由理解列含义并输出 YAML,再由 AI 顺带完成去重判定。该模式存在三个痛点:
1. **无标准模板**:用户批量录入规则时无从获取标准格式,凭经验构造导致列名错乱、AI 解析失败率高。
2. **解析全靠 AI**:即使是结构化的 Excel,也要走 AI 解析,耗时长、消耗 token、字段映射可能误判。
3. **去重与解析耦合**:去重判定由 AI 在解析阶段一并完成,无法对「已是标准格式的输入」单独做去重。
### 1.2 目标
形成「导出模板 → 填写 → 模板导入」的闭环:
- **导出模板**:在侧边栏设置面板新增「导出模板」按钮,生成标准格式 `.xlsx` 模板。
- **模板导入**:新增 checkbox「从模板导入」,勾选后走「程序解析 + AI 去重」链路——解析阶段跳过 AI(快、确定、零 token),去重阶段复用 AI 的语义比对能力。
### 1.3 范围
- **含**:导出模板功能;模板导入功能(程序解析 + 准入校验 + AI 去重 + 预览)。
- **不含**:导出当前规则;YAML 直通链路与 AI 链路的校验补齐(本次不动)。
- **不触碰**:现有 `ExcelConverter``prompt-builder.ts``addRule` 原 AI 流程,保证零回归。
>
> 注:`import-preview.ts` **需改动**(新增错误规则组渲染),但仅做增量扩展,不改动现有三组的渲染逻辑。
---
## 二、现状分析
### 2.1 规则数据模型
`src/types.ts`
```ts
interface CustomRule {
id: string;
severity: 'error' | 'warning' | 'info';
description: string;
message: string;
languages?: string[];
excludeLanguages?: string[];
}
```
`src/rules/import-types.ts` 扩展:
```ts
interface ImportableRule extends CustomRule {
duplicateOf?: string;
duplicateLevel?: 'exact' | 'overlap' | 'none';
duplicateReason?: string;
}
```
### 2.2 现有导入链路(AI 模式,本次不改)
```
addRule(name)
→ showOpenDialog(过滤器:yaml/yml/md/txt/xlsx/xls/docx/pptx
→ 分流:
├─ .yaml/.yml → fs.copyFileSync(零校验直通)
└─ 其他 → ImportService.importRules()
→ 各 Converter 转成 markdown
→ convertContentWithAI()
├─ buildSystemPrompt('spreadsheet'|'freeform', existingRules)
│ └─ buildDedupPromptSection(existingRules) ← 把现有规则喂给 AI
└─ AI 输出 YAML(含 duplicateLevel 等去重字段)
→ parseImportableYaml() 统计 exactCount/overlapCount
→ showImportPreview() 预览
→ 用户确认 → 写入 .code-review/rules/<name>.yaml
```
### 2.3 去重机制:由 AI 完成(关键事实)
**去重判定由 AI 在解析阶段一并完成,本地无独立去重函数。**
证据:
- `prompt-builder.ts:83-85` 让 AI 输出 `duplicateOf/duplicateLevel/duplicateReason`
- `prompt-builder.ts:463` `buildDedupPromptSection(existingRules, lang)` 把现有规则(`loadActiveRules()` + `static-rules.json`)拼进 system prompt。
- `import-service.ts:274-275` 只统计 AI 给的 `duplicateLevel`,无本地比对。
- 搜索 `markDuplicates/detectDuplicates/findDuplicates` 零命中。
**含义**:若模板导入跳过 AI,去重也随之消失——即使与已有规则完全相同也不会标记。因此模板导入需保留 AI 去重环节。
### 2.4 现有 UI(关键事实:无 checkbox、无独立导入按钮)
`src/views/setupView.ts` 的「自定义规则」区块(第 985-993 行):
```html
<div class="field">
<div class="input-group">
<input type="text" id="newRuleInput" placeholder="规则名称">
<button onclick="addRule()">添加</button>
</div>
<div class="error-hint" id="ruleNameError"></div>
<div class="field-hint"></div>
</div>
```
- **没有独立「导入」按钮**:导入是 `addRule(name)` 流程的一部分(填名 → 选文件 → 自动转换)。
- **没有任何 checkbox**`type="checkbox"` 仅出现在 `docs/superpowers/specs/setup-panel-preview.html`(那是规则项的 enable/disable 开关)。
### 2.5 已有依赖
- `xlsx` 库已由 `excel-converter.ts` 引入,导出/导入均可复用,零新增依赖。
---
## 三、功能设计
### 3.1 模板格式(导出/导入共用)
生成 `.xlsx` 文件,含 2 个 sheet。
#### Sheet 1「规则」(表头 + 1 行示例)
| id | severity | description | message | languages | excludeLanguages |
|----|----------|-------------|---------|-----------|------------------|
| no-todo | warning | 禁止提交 TODO 注释 | 发现 TODO 注释,请清理后提交 | javascript,typescript | |
- 第 1 行:表头,固定 6 列,与 `CustomRule` 字段一一对应。
- 第 2 行:示例行,用户可删除或覆盖。
- 第 3 行起:用户自行追加规则。
#### Sheet 2「说明」(字段含义 + 填写规范)
| 字段 | 含义 | 取值/格式 | 示例 |
|------|------|-----------|------|
| id | 规则唯一标识 | 小写字母、数字、连字符,全局唯一 | no-todo |
| severity | 严重级别 | error / warning / info | warning |
| description | 规则简述(给人看) | 自由文本 | 禁止提交 TODO 注释 |
| message | 命中时展示给开发者的提示语 | 自由文本 | 发现 TODO 注释,请清理后提交 |
| languages | 生效的语言 | 逗号分隔,留空表示对所有语言生效 | javascript,typescript |
| excludeLanguages | 排除的语言 | 逗号分隔,可留空 | |
**填写说明:**
1. severity 仅接受 error / warning / info 三个值。
2. languages / excludeLanguages 多值用英文逗号分隔。
3. 示例行可删除,仅作填写参考。
4. 该模板可直接用于「从模板导入」功能回环校验。
### 3.2 导出模板交互
1. 用户在侧边栏「自定义规则」区块点击「📤 导出模板」按钮。
2. 弹出系统保存对话框,默认文件名 `code-review-rules-template.xlsx`,过滤器 `Excel (*.xlsx)`
3. 用户选定位置后写盘。
4. 右下角信息提示:`✓ 模板已导出`,附带「打开文件夹」按钮。
5. 用户填写模板 → 回到插件,勾选「从模板导入」→ 点「添加」→ 预览 → 确认写入。
### 3.3 模板导入交互
```
侧边栏「自定义规则」区块:
[规则名输入框] [ 添加]
☐ 从模板导入(跳过 AI 解析,仅支持 Excel 模板) ← 新增 checkbox
[📤 导出模板] ← 新增按钮
```
- **不勾选**(默认):点「添加」→ 走现有 `addRule` AI 链路(支持 yaml/md/txt/xlsx/docx/pptx),行为不变。
- **勾选**:点「添加」→ 走模板直通链路(仅 xlsx/xls,含两道准入校验,程序解析 + AI 去重)。
### 3.4 模板导入完整流程
```
勾选 checkbox + 填规则名 + 点「添加」
[1] 选文件(showOpenDialog,过滤器仅 xlsx/xls
[2] 程序解析 + 数据行校验(TemplateConverter,无 AI
├─ 准入校验1:文件格式(必须 xlsx/xls 且可读) → 失败则报错终止
├─ 准入校验2:表头结构(含 id/severity/description/message)→ 失败则报错终止
├─ 逐行解析 + 校验 → ImportableRule[](错误规则带 validationIssues
├─ 分离:validRules(无 issue+ errorRules(有 issue
└─ yamlContent ← 仅 validRules 拼接(标准 YAML,无 duplicateLevel
[3] AI 去重(仅对 validRules,新增专用 prompt
├─ loadActiveRules() 取工作区已有规则
├─ buildDedupOnlyPrompt(yamlContent, existingRules) ← 新增
│ 告诉 AI:"以下 YAML 已是标准格式,只需对照现有规则标 duplicateLevel,不要改字段"
├─ AI 返回带 duplicateOf/duplicateLevel/duplicateReason 的 YAML
└─ parseImportableYaml() 解析(复用现有)
(注:errorRules 不送 AI,无 duplicateLevel
[4] 预览(showImportPreview,复用 + 新增错误规则组)
├─ 顶部提示"已跳过 N 行空数据"(若有)
├─ 🚫 错误规则组(置顶,无 checkbox,只读,标注"将自动丢弃")
├─ ⛔ 完全重复组(默认不勾选)
├─ ⚠️ 部分重叠组
├─ ✅ 新规则组
└─ 用户确认 → 写入选中的有效规则 → 错误规则自动丢弃
(边界:validRules 全空时禁用确认按钮,提示"无有效规则可导入")
```
### 3.5 校验策略(两层:准入 + 数据行)
校验分两层,准入校验失败硬报错,数据行问题软提示进预览。
#### 第一层:准入校验(硬报错,不进预览)
**第一关:文件格式校验**
```ts
const ext = path.extname(srcPath).toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
throw new Error(t('import.template.badFormat')); // "文件格式错误,请使用 Excel 模板"
}
let wb: XLSX.WorkBook;
try { wb = XLSX.readFile(srcPath); }
catch { throw new Error(t('import.template.badFormat')); }
```
**第二关:数据结构校验(是不是模板文件)**
```ts
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: '' });
if (rows.length === 0) {
throw new Error(t('import.template.empty')); // "文件为空"
}
const header = Object.keys(rows[0]).map(k => k.trim().toLowerCase());
const REQUIRED = ['id', 'severity', 'description', 'message'];
const missing = REQUIRED.filter(h => !header.includes(h));
if (missing.length > 0) {
throw new Error(t('import.template.notTemplate', { 0: missing.join(', ') }));
}
```
| 场景 | 处理 |
|------|------|
| 选了 .txt/.docx/.md | 第一关拒,提示"文件格式错误" |
| 选了损坏的 .xlsx | 第一关拒,同上 |
| .xlsx 表头非标准(如中文列名) | 第二关拒,提示"缺少列: ..." |
| .xlsx 表头标准 | 通过准入,进入数据行校验 |
#### 第二层:数据行校验(软提示,进预览,自动丢弃)
**核心思路**:数据行的问题不阻塞导入流程,而是带上 `validationIssues` 字段,和有效规则一起进入预览面板,作为独立的"错误规则"分组展示。**错误规则不参与去重、不写入 YAML,确认导入时自动丢弃**;有效规则正常导入。用户可查看错误原因后修改文件重新导入。
**`ImportableRule` 扩展**
```ts
interface ValidationIssue {
field: string; // 'severity' | 'description' | 'message'
severity: 'error' | 'warning';
message: string; // 如 'severity 非法: "warn"'、'description 为空'
}
interface ImportableRule extends CustomRule {
duplicateOf?: string;
duplicateLevel?: 'exact' | 'overlap' | 'none';
duplicateReason?: string;
validationIssues?: ValidationIssue[]; // 新增:非空则为错误规则
rowNumber?: number; // 新增:原始行号
}
```
**校验规则**(只记录错误,不修补;不校验表内重复):
| 问题 | 处理 | 是否导入 |
|------|------|---------|
| severity 为空或拼错(如 "warn" | 记录 issueseverity 临时填 'warning' 仅用于结构完整 | ❌ 自动丢弃 |
| description 为空 | 记录 issue | ❌ 自动丢弃 |
| message 为空 | 记录 issue | ❌ 自动丢弃 |
| 空行/空 id | 静默丢弃(无法标识) | — |
> 关键:错误规则**不进 yamlContent**、**不送 AI 去重**、**确认导入时自动丢弃**。只有有效规则(无 `validationIssues`)才参与 AI 去重和写入 YAML。
>
> **不校验表内 id 重复**:本次不考虑规则文件内多条规则 id 相同的情况,交由 AI 去重环节按现有逻辑处理(与已有规则的比对)。
**预览面板分组**(复用现有 `showImportPreview`,错误规则组置顶):
```
┌─ 规则导入预览 ──────────────────────────┐
│ [🚫 错误规则:2] [⛔ 完全重复:1] [⚠️ 部分重叠:2] [✅ 新规则:3] │
│ │
│ 🚫 错误规则 (2) [只读] │
│ ⚠ 第3行 severity 非法: "warn" │
│ id: no-magic-numbers │
│ description: 禁止魔法数字 │
│ message: 请用常量替代 │
│ (将自动丢弃,请修改文件后重新导入)│
│ │
│ ⚠ 第5行 description 为空 │
│ id: check-null │
│ message: 检查空值 │
│ (将自动丢弃,请修改文件后重新导入)│
│ │
│ ⛔ 完全重复 (1) ...(现有逻辑) │
│ ⚠️ 部分重叠 (2) ...(现有逻辑) │
│ ✅ 新规则 (3) ...(现有逻辑) │
│ │
│ [ 确认导入 ] [ 取消 ] │
└──────────────────────────────────────────┘
```
- 错误规则组置顶,无 checkbox,纯只读展示
- 错误规则不参与 AI 去重(不送 AI),无 `duplicateLevel`
- 仅有效规则(无 `validationIssues`)参与 AI 去重,按 exact/overlap/none 分组
- 确认导入时写入选中的有效规则,错误规则自动丢弃,导入流程正常完成
### 3.6 示例行处理
模板里的 `no-todo` 示例行按用户确认"不做特殊处理"——当普通规则解析,交由 AI 去重环节判定。若工作区已有同名规则,会被标记为 `exact` 重复,预览面板默认不勾选;若无冲突则正常进入预览,由用户自行决定保留或删除。
---
## 四、技术方案
### 4.1 改动文件清单
| 类型 | 文件 | 改动 |
|------|------|------|
| 新增 | `src/rules/export-service.ts` | 模板生成服务,复用 `xlsx` 库 |
| 新增 | `src/rules/converters/template-converter.ts` | 程序解析 + 两道准入校验 + 生成 yamlContent |
| 新增 | `src/rules/converters/dedup-prompt.ts` | `buildDedupOnlyPrompt(yamlContent, existingRules)` 专用去重 prompt(中英) |
| 修改 | `src/rules/import-types.ts` | `ImportableRule` 增加 `validationIssues` / `rowNumber` 字段;新增 `ValidationIssue` 接口;`ConversionResult` 增加 `skippedCount?: number` 字段 |
| 修改 | `src/rules/import-service.ts` | 新增 `importTemplate(srcPath, name, existingRules)`:调 TemplateConverter → AI 去重 → 返回 ConversionResult |
| 修改 | `src/rules/import-preview.ts` | `renderPreviewHtml` 增加错误规则组(置于现有三组之上,无 checkbox 只读);`keepRule` 默认值考虑 `validationIssues`(错误规则不进入 keepRule);`renderRuleItem` 展示 issue 详情 + "将自动丢弃"提示;顶部展示"已跳过 N 行空数据"(来自 `skippedCount`);`validRules` 为空时禁用"确认导入"按钮并提示"无有效规则可导入" |
| 修改 | `src/views/setupView.ts` | 加 checkbox + 导出按钮;`addRule``useTemplateMode` 分流;新增 `case 'exportTemplate'` |
| 修改 | `src/activation/commands.ts` | 注册 `codeReviewer.exportTemplate` 命令 |
| 修改 | `package.json` | `contributes.commands` 增 1 条 |
| 修改 | `src/i18n/messages.ts` | checkbox/校验/去重 prompt/导出 文案(中英) |
**不动**`ExcelConverter``prompt-builder.ts`、现有 `addRule` 原 AI 流程。`import-preview.ts` 仅做增量扩展(新增错误规则组),不改动现有三组渲染逻辑。
### 4.2 新增 `src/rules/export-service.ts`(导出模板)
```ts
import * as XLSX from 'xlsx';
import * as vscode from 'vscode';
import { t } from '../i18n/messages';
const RULES_HEADER = ['id', 'severity', 'description', 'message', 'languages', 'excludeLanguages'];
const RULES_EXAMPLE = [
'no-todo', 'warning', '禁止提交 TODO 注释',
'发现 TODO 注释,请清理后提交', 'javascript,typescript', '',
];
const GUIDE_AOA: string[][] = [
['字段', '含义', '取值/格式', '示例'],
['id', '规则唯一标识', '小写字母、数字、连字符,全局唯一', 'no-todo'],
['severity', '严重级别', 'error / warning / info', 'warning'],
['description', '规则简述(给人看)', '自由文本', '禁止提交 TODO 注释'],
['message', '命中时展示给开发者的提示语', '自由文本', '发现 TODO 注释,请清理后提交'],
['languages', '生效的语言', '逗号分隔,留空表示对所有语言生效', 'javascript,typescript'],
['excludeLanguages', '排除的语言', '逗号分隔,可留空', ''],
['', '', '', ''],
['填写说明', '', '', ''],
['1. severity 仅接受 error / warning / info 三个值', '', '', ''],
['2. languages / excludeLanguages 多值用英文逗号分隔', '', '', ''],
['3. 示例行可删除,仅作填写参考', '', '', ''],
['4. 该模板可直接用于「从模板导入」功能回环校验', '', '', ''],
];
export async function exportTemplate(): Promise<void> {
const uri = await vscode.window.showSaveDialog({
defaultUri: vscode.Uri.file('code-review-rules-template.xlsx'),
filters: { 'Excel': ['xlsx'] },
saveLabel: t('exportTemplate.saveLabel'),
});
if (!uri) { return; }
const wb = XLSX.utils.book_new();
const wsRules = XLSX.utils.aoa_to_sheet([RULES_HEADER, RULES_EXAMPLE]);
wsRules['!cols'] = [
{ wch: 16 }, { wch: 10 }, { wch: 32 }, { wch: 40 }, { wch: 24 }, { wch: 20 },
];
XLSX.utils.book_append_sheet(wb, wsRules, '规则');
const wsGuide = XLSX.utils.aoa_to_sheet(GUIDE_AOA);
wsGuide['!cols'] = [{ wch: 22 }, { wch: 28 }, { wch: 42 }, { wch: 36 }];
XLSX.utils.book_append_sheet(wb, wsGuide, '说明');
try {
XLSX.writeFile(wb, uri.fsPath);
const openFolder = t('exportTemplate.openFolder');
const choice = await vscode.window.showInformationMessage(
t('exportTemplate.success'), openFolder,
);
if (choice === openFolder) {
vscode.commands.executeCommand('revealFileInOS', uri);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(t('exportTemplate.fail', { 0: msg }));
}
}
```
### 4.3 新增 `src/rules/converters/template-converter.ts`(程序解析 + 准入校验 + 数据行校验)
```ts
import * as path from 'path';
import * as XLSX from 'xlsx';
import type { ImportableRule, ValidationIssue } from '../import-types';
import type { Severity } from '../../types';
import { t } from '../../i18n/messages';
const REQUIRED_HEADERS = ['id', 'severity', 'description', 'message'];
const VALID_SEVERITY = ['error', 'warning', 'info'];
function splitList(v: unknown): string[] {
const s = String(v ?? '').trim();
if (!s) { return []; }
// 兼容逗号、分号、顿号、换行
return s.split(/[,;、\n]/).map(x => x.trim()).filter(Boolean);
}
export interface TemplateParseResult {
rules: ImportableRule[]; // 所有规则(含错误的)
validRules: ImportableRule[]; // 仅有效规则(无 validationIssues
yamlContent: string; // 仅有效规则的 YAML(送 AI 去重)
skippedCount: number; // 被丢弃的空行数
}
export function parseTemplate(srcPath: string): TemplateParseResult {
// 第一关:文件格式校验
const ext = path.extname(srcPath).toLowerCase();
if (!['.xlsx', '.xls'].includes(ext)) {
throw new Error(t('import.template.badFormat'));
}
let wb: XLSX.WorkBook;
try { wb = XLSX.readFile(srcPath); }
catch { throw new Error(t('import.template.badFormat')); }
// 第二关:数据结构校验
const sheet = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<Record<string, string>>(sheet, { defval: '' });
if (rows.length === 0) {
throw new Error(t('import.template.empty'));
}
const header = Object.keys(rows[0]).map(k => k.trim().toLowerCase());
const missing = REQUIRED_HEADERS.filter(h => !header.includes(h));
if (missing.length > 0) {
throw new Error(t('import.template.notTemplate', { 0: missing.join(', ') }));
}
// 数据行解析 + 校验(只记录错误,不修补、不丢弃有 id 的行)
const allRows = rows.length;
const rules: ImportableRule[] = rows
.map((r, idx) => ({ r, rowNo: idx + 2 })) // 先记原始行号(表头占第1行,数据从第2行起)
.filter(({ r }) => String(r.id ?? '').trim()) // 再 filter 空行/空 id
.map(({ r, rowNo }) => {
const issues: ValidationIssue[] = [];
// severity 校验(不修补,仅记录;临时填 warning 保证结构完整)
const sevRaw = String(r.severity ?? '').trim().toLowerCase();
const severity: Severity = VALID_SEVERITY.includes(sevRaw) ? (sevRaw as Severity) : 'warning';
if (!VALID_SEVERITY.includes(sevRaw)) {
issues.push({ field: 'severity', severity: 'warning', message: `severity 非法: "${r.severity ?? ''}"` });
}
// description 校验(不修补,仅记录)
const description = String(r.description ?? '').trim();
if (!description) {
issues.push({ field: 'description', severity: 'error', message: 'description 为空' });
}
// message 校验(不修补,仅记录)
const message = String(r.message ?? '').trim();
if (!message) {
issues.push({ field: 'message', severity: 'error', message: 'message 为空' });
}
return {
id: String(r.id).trim(),
severity,
description,
message,
languages: splitList(r.languages),
excludeLanguages: splitList(r.excludeLanguages),
rowNumber: rowNo,
validationIssues: issues.length > 0 ? issues : undefined,
};
});
// 仅有效规则进 yamlContent(错误规则不送 AI 去重)
// 注:本次不校验表内 id 重复,交由 AI 去重环节处理
const validRules = rules.filter(r => !r.validationIssues);
const yamlContent = buildYaml(validRules);
return { rules, validRules, yamlContent, skippedCount: allRows - rules.length };
}
function buildYaml(rules: ImportableRule[]): string {
const lines: string[] = [];
for (const r of rules) {
lines.push(`- id: ${r.id}`);
lines.push(` severity: ${r.severity}`);
lines.push(` description: ${r.description}`);
lines.push(` message: ${r.message}`);
if (r.languages?.length) {
lines.push(` languages: [${r.languages.join(', ')}]`);
}
if (r.excludeLanguages?.length) {
lines.push(` excludeLanguages: [${r.excludeLanguages.join(', ')}]`);
}
}
return lines.join('\n');
}
```
### 4.4 新增 `src/rules/converters/dedup-prompt.ts`(专用去重 prompt
```ts
import type { CustomRule } from '../../types';
import { getLanguage, type Language } from '../../i18n/messages';
export function buildDedupOnlyPrompt(
yamlContent: string,
existingRules: CustomRule[],
): { system: string; user: string } {
const lang = getLanguage();
const s = PROMPTS[lang];
const existingList = existingRules.length === 0
? s.noExisting
: existingRules.map(r =>
`- id: ${r.id} | severity: ${r.severity} | description: ${r.description} | message: ${r.message}`
).join('\n');
const system = [
s.role,
s.taskTitle,
s.taskLines.join('\n'),
s.rulesTitle,
s.rulesLines.join('\n'),
s.existingTitle,
existingList,
].join('\n\n');
const user = s.userPrefix + '\n\n' + yamlContent;
return { system, user };
}
const PROMPTS: Record<Language, {
role: string;
taskTitle: string;
taskLines: string[];
rulesTitle: string;
rulesLines: string[];
existingTitle: string;
noExisting: string;
userPrefix: string;
}> = {
'zh-CN': {
role: '你是规则去重助手。',
taskTitle: '## 任务',
taskLines: [
'下面 USER 部分是已标准化的规则 YAML,只需对照现有规则标 duplicateLevel,不要修改任何原有字段。',
'为每条规则补充以下字段:',
'- duplicateOf: 重复的规则 IDlinter 如 eslint/no-console;自定义如 custom/my-rule',
'- duplicateLevel: exact(完全相同)/ overlap(部分重叠)/ none',
'- duplicateReason: overlap 时必填',
],
rulesTitle: '## 判定规则',
rulesLines: [
'1. exactid 相同,或 description+message 语义完全一致',
'2. overlap:检测目标/场景部分重叠',
'3. 不要修改原有字段(id/severity/description/message/languages/excludeLanguages',
'4. 只补充去重字段',
],
existingTitle: '## 现有规则',
noExisting: '(无)',
userPrefix: '## 待去重的规则 YAML',
},
'en': {
role: 'You are a rule deduplication assistant.',
taskTitle: '## Task',
taskLines: [
'The USER section below is standardized rule YAML. Only mark duplicateLevel against existing rules; do NOT modify any original fields.',
'For each rule, add:',
'- duplicateOf: the duplicated rule ID (e.g. eslint/no-console; custom/my-rule)',
'- duplicateLevel: exact (identical) / overlap (partial) / none',
'- duplicateReason: required when overlap',
],
rulesTitle: '## Judgement Rules',
rulesLines: [
'1. exact: same id, or semantically identical description+message',
'2. overlap: partially overlapping target/scenario',
'3. Do NOT modify original fields (id/severity/description/message/languages/excludeLanguages)',
'4. Only add dedup fields',
],
existingTitle: '## Existing Rules',
noExisting: '(none)',
userPrefix: '## YAML to deduplicate',
},
'ja': {
role: 'あなたはルール重複排除アシスタントです。',
taskTitle: '## タスク',
taskLines: [
'下記 USER 部分は標準化されたルール YAML です。既存ルールと照合して duplicateLevel を付与するのみ、元フィールドは変更しないでください。',
'各ルールに以下を追加:',
'- duplicateOf: 重複ルール ID(例: eslint/no-console; custom/my-rule',
'- duplicateLevel: exact(完全一致)/ overlap(部分重複)/ none',
'- duplicateReason: overlap 時必須',
],
rulesTitle: '## 判定ルール',
rulesLines: [
'1. exact: id 同一、または description+message が意味的に完全一致',
'2. overlap: 検出対象/シナリオが部分重複',
'3. 元フィールドを変更しない(id/severity/description/message/languages/excludeLanguages',
'4. 重複フィールドのみ追加',
],
existingTitle: '## 既存ルール',
noExisting: '(なし)',
userPrefix: '## 重複排除対象の YAML',
},
};
```
### 4.5 修改 `src/rules/import-service.ts`(新增 importTemplate
```ts
import { parseTemplate } from './converters/template-converter';
import { buildDedupOnlyPrompt } from './converters/dedup-prompt';
import { loadActiveRules } from './yaml-parser';
// convertContentWithAI / parseImportableYaml / normalizeRuleIds 均为本文件已有函数,直接使用,无需 import
export class ImportService {
// 原有 importRules 不动
async importTemplate(
srcPath: string,
name: string,
workspaceRoot: string,
): Promise<ConversionResult> {
// [1] 程序解析 + 准入校验 + 数据行校验(失败即 throw)
const { rules, validRules, yamlContent, skippedCount } = parseTemplate(srcPath);
// [2] AI 去重(仅对有效规则;错误规则不送 AI)
let dedupedValidRules: ImportableRule[] = validRules;
if (validRules.length > 0) {
const existingRules = loadActiveRules(workspaceRoot);
const { system, user } = buildDedupOnlyPrompt(yamlContent, existingRules);
const dedupedYaml = await convertContentWithAI(system, user);
dedupedValidRules = parseImportableYaml(dedupedYaml);
}
// [3] 合并:有效规则(带去重标记)+ 错误规则(无去重标记,仅展示)
const errorRules = rules.filter(r => r.validationIssues);
const allRules = [...dedupedValidRules, ...errorRules];
const exactCount = allRules.filter(r => r.duplicateLevel === 'exact').length;
const overlapCount = allRules.filter(r => r.duplicateLevel === 'overlap').length;
return {
rules: allRules,
yamlContent,
sourceFileName: path.basename(srcPath),
exactCount,
overlapCount,
skippedCount, // 传递给预览面板用于顶部提示
};
}
}
```
> 注意:错误规则(`validationIssues` 非空)不参与 AI 去重,无 `duplicateLevel`,在预览中归入"错误规则"组。确认导入时只写入选中的有效规则。
>
> **边界:validRules 全空**。若文件全是错误规则(`validRules.length === 0`),跳过 AI 去重,`allRules` 全是错误规则。预览面板进入后应:
> - 顶部提示"无有效规则可导入,请修改文件后重新导入"
> - 禁用"确认导入"按钮,仅允许"取消"
>
> 此边界由 `import-preview.ts` 在收到 `validRules` 数量为 0 时处理(见 4.1 改动描述)。
>
> **ConversionResult 需扩展 `skippedCount?: number` 字段**,用于预览面板顶部提示"已跳过 N 行空数据"。
### 4.6 修改 `src/views/setupView.ts`UI + 分流)
**HTML 改动**(「自定义规则」区块):
```html
<div class="field">
<div class="input-group">
<input type="text" id="newRuleInput" placeholder="${t('setup.ruleNamePlaceholder')}">
<button class="btn btn-sm" style="background:#7c3aed;color:#fff;border-color:#7c3aed;"
onclick="addRule()">${t('setup.add')}</button>
</div>
<div class="error-hint" id="ruleNameError">${t('setup.ruleNameRequired')}</div>
<div class="field-hint">${t('setup.ruleNameHint')}</div>
<!-- 新增 checkbox -->
<label style="display:flex;align-items:center;gap:6px;margin-top:8px;font-size:12px;">
<input type="checkbox" id="useTemplateMode">
${t('setup.fromTemplate')}
</label>
<!-- 新增导出按钮 -->
<button class="btn btn-sm" style="margin-top:6px;"
onclick="exportTemplate()">${t('setup.exportTemplate')}</button>
</div>
```
**webview script**(获取 checkbox 状态):
```js
function addRule() {
const name = document.getElementById('newRuleInput').value;
const useTemplateMode = document.getElementById('useTemplateMode').checked;
vscode.postMessage({ type: 'addRule', name, useTemplateMode });
}
function exportTemplate() {
vscode.postMessage({ type: 'exportTemplate' });
}
```
**`onDidReceiveMessage` 分流**
```ts
import { exportTemplate } from '../rules/export-service';
// 在 SetupViewProvider 的 onDidReceiveMessage 中:
case 'addRule':
await this.addRule(msg.name, msg.useTemplateMode); // 传入 useTemplateMode
await this.pushConfig();
break;
case 'exportTemplate':
await exportTemplate();
break;
```
**`addRule` 方法分流**
```ts
private async addRule(name: string, useTemplateMode?: boolean): Promise<void> {
if (!name.trim()) { return; }
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (!workspaceRoot) { return; }
if (useTemplateMode) {
// 模板直通链路
const result = await vscode.window.showOpenDialog({
canSelectMany: false,
openLabel: t('setup.selectTemplateFile'),
filters: { 'Excel': ['xlsx', 'xls'] }, // 仅 Excel
});
if (!result || result.length === 0) { return; }
try {
const conversion = await vscode.window.withProgress({
location: vscode.ProgressLocation.Notification,
title: t('setup.importingTemplate'),
}, async () => {
return await this.importService.importTemplate(result[0].fsPath, name, workspaceRoot);
});
const decision = await showImportPreview(conversion);
if (decision?.confirmed) {
await this.saveImportedRules(name, conversion, decision, workspaceRoot);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
vscode.window.showErrorMessage(msg); // 准入校验失败直接提示
}
} else {
// 原 AI 链路(不动)
// ... 现有 addRule 逻辑
}
}
```
### 4.7 修改 `src/activation/commands.ts`(注册导出命令)
```ts
import { exportTemplate } from '../rules/export-service';
// 在 registerCommands 中追加
context.subscriptions.push(
vscode.commands.registerCommand('codeReviewer.exportTemplate', () => exportTemplate()),
);
```
### 4.8 修改 `package.json`
`contributes.commands` 追加:
```json
{
"command": "codeReviewer.exportTemplate",
"title": "Code Purifier: 导出规则模板"
}
```
### 4.9 修改 `src/i18n/messages.ts`(新增文案)
| key | 中文 | 英文 | 日文 |
|-----|------|------|------|
| `setup.fromTemplate` | 从模板导入(跳过 AI 解析,仅支持 Excel 模板) | Import from template (skip AI parsing, Excel only) | テンプレートからインポート(AI 解析スキップ、Excel のみ) |
| `setup.exportTemplate` | 📤 导出模板 | 📤 Export Template | 📤 テンプレートをエクスポート |
| `setup.selectTemplateFile` | 选择模板文件 | Select template file | テンプレートファイルを選択 |
| `setup.importingTemplate` | 正在导入模板... | Importing template... | テンプレートをインポート中... |
| `import.template.badFormat` | 文件格式错误,请使用 Excel 模板(.xlsx/.xls | Bad file format, please use Excel template (.xlsx/.xls) | ファイル形式エラー、Excel テンプレートを使用してください |
| `import.template.empty` | 文件为空 | File is empty | ファイルが空です |
| `import.template.notTemplate` | 不是模板文件,缺少列: {0} | Not a template file, missing columns: {0} | テンプレートファイルではありません、欠損列: {0} |
| `exportTemplate.title` | 导出模板 | Export Template | テンプレートをエクスポート |
| `exportTemplate.saveLabel` | 导出模板 | Export Template | エクスポート |
| `exportTemplate.success` | 模板已导出 | Template exported | テンプレートをエクスポートしました |
| `exportTemplate.fail` | 导出失败:{0} | Export failed: {0} | エクスポート失敗: {0} |
| `exportTemplate.openFolder` | 打开文件夹 | Reveal in Folder | フォルダを開く |
| `import.sectionInvalid` | 错误规则 | Error Rules | エラー行 |
| `import.cannotImport` | 将自动丢弃,请修改文件后重新导入 | Will be auto-discarded, please fix the file and retry | 自動破棄されます、ファイルを修正して再インポートしてください |
| `import.template.skipped` | 已跳过 {0} 行空数据 | Skipped {0} empty rows | {0} 行の空データをスキップしました |
---
## 五、关键设计决策
| 决策点 | 选择 | 理由 |
|--------|------|------|
| 是否新增依赖 | 否,复用 `xlsx` | 导入侧已引入,零额外体积 |
| 模板内容 | 表头 + 1 行示例 + 说明 sheet | 贴合「模板」语义,降低首次使用门槛 |
| 是否同时导出当前规则 | 否 | 用户确认暂不需要,保持最小化 |
| 导出按钮位置 | setupView「自定义规则」工具栏 | 紧邻「导入」,成对出现 |
| 模板列名 | 与 `CustomRule` 字段完全一致 | 保证导入回环自洽 |
| 模板导入触发方式 | checkbox「从模板导入」 | 不加独立按钮,用户显式选择模式 |
| 模板导入解析方式 | 程序解析(无 AI) | 快、确定、零 token 用于解析 |
| 模板导入去重方式 | AI 去重(新增专用 prompt) | 复用 AI 语义比对能力,去重与解析解耦 |
| 准入校验失败 | 硬报错阻止 | 文件格式错或结构错直接拒绝,不进预览 |
| severity 拼错 | 记录 issue + 进错误规则组,自动丢弃 | 错误规则不导入不阻塞流程,用户改文件重导 |
| description/message 空 | 记录 issue + 进错误规则组,自动丢弃 | 同上,不修补不导入 |
| 表内 id 重复 | 不校验,交由 AI 去重环节处理 | 本次不处理规则文件内重复,简化实现 |
| 错误规则是否参与去重 | 否,不送 AI | 错误规则不导入,无需标 duplicateLevel |
| 错误规则是否阻塞导入 | 否,有效规则正常导入 | 自动丢弃错误规则,流程不中断 |
| 空行/空 id | 静默丢弃 + 顶部提示跳过数 | 无法标识的行无法修复 |
| 数据行校验展示 | 复用现有预览,新增「错误规则」组(置于现有三组之上) | 不新建面板,与去重分组正交 |
| 示例行处理 | 不做特殊处理 | 当普通规则,交由 AI 去重判定 |
| 是否触碰导入侧 AI 链路 | 否 | 零回归 |
| 是否触碰现有去重 prompt | 否,新增专用 prompt | 任务聚焦,避免影响原 AI 解析链路 |
---
## 六、验收标准
### 6.1 导出模板
1. 侧边栏「自定义规则」区块出现「导出模板」按钮。
2. 点击弹出保存对话框,默认文件名 `code-review-rules-template.xlsx`
3. 生成文件含 2 个 sheet:「规则」(表头+示例行)、「说明」(字段说明+填写规范)。
4. 列名依次为 `id, severity, description, message, languages, excludeLanguages`
5. 导出成功后右下角提示,点击「打开文件夹」可定位文件。
6. 命令面板执行 `Code Purifier: 导出规则模板` 触发同样流程。
7. 中英文/日文切换下,按钮文案与提示信息正确。
8. 取消保存对话框时不报错、无残留。
### 6.2 模板导入
1. 「自定义规则」区块出现 checkbox「从模板导入」。
2. 不勾选时点「添加」,走原 AI 链路,行为与现状完全一致(零回归)。
3. 勾选时点「添加」,文件选择对话框仅过滤 `xlsx/xls`
4. 选非 Excel 文件(如 .txt)→ 提示"文件格式错误",不进入预览。
5. 选损坏的 .xlsx → 提示"文件格式错误",不进入预览。
6. 选表头非标准的 .xlsx → 提示"不是模板文件,缺少列: ...",不进入预览。
7. 选标准模板 → 进入预览,exact 重复默认不勾选。
8. 预览界面复用 `showImportPreview`,新增「错误规则」组(置顶,无 checkbox 只读),其余三组渲染逻辑不变。
9. 确认导入后写入 `.code-review/rules/<name>.yaml`
10. 导入速度明显快于 AI 链路(解析阶段无 AI 调用)。
11. 准入校验失败时右下角错误提示,无残留文件。
12. 数据行有 severity 拼错时,该行进入"错误规则"组,标注错误原因,确认导入时自动丢弃。
13. 数据行 description/message 为空时,该行进入"错误规则"组,标注错误原因,确认导入时自动丢弃。
14. 规则文件内多条 id 相同的规则,不校验表内重复,按普通规则进入有效规则组,交由 AI 去重环节处理。
15. 空行被跳过时,预览面板顶部提示"已跳过 N 行空数据"N 来自 `skippedCount`)。
16. 错误规则不参与 AI 去重,无 duplicateLevel 标记。
17. 确认导入时只写入选中的有效规则,错误规则自动丢弃,导入流程正常完成(不阻塞)。
18. 文件全是错误规则(validRules 为空)时,预览面板禁用"确认导入"按钮,提示"无有效规则可导入,请修改文件后重新导入"。
### 6.3 闭环验证
1. 导出模板 → 不修改直接勾选「从模板导入」导入 → 预览出现 `no-todo` 规则(示例行)。
2. 导出模板 → 删除示例行 → 填入 2 条新规则 → 导入 → 预览出现 2 条新规则。
3. 导出模板 → 填入与工作区已有规则相同的 id → 导入 → 预览标记为 exact 重复。
---
## 七、风险与回归
| 风险 | 等级 | 应对 |
|------|------|------|
| 导入侧 AI 链路被误改 | 中 | 评审约束:不动 `ExcelConverter``prompt-builder.ts``import-preview.ts`、原 `addRule` AI 分支 |
| 专用去重 prompt 与原解析 prompt 行为不一致 | 中 | 去重 prompt 明确"不要修改原有字段",且预览环节人工确认兜底 |
| `xlsx` 写文件在无写权限目录失败 | 低 | `try/catch` 捕获并友好提示 |
| 跨平台路径(Win/Mac/Linux | 低 | 使用 `vscode.Uri.fsPath`,不硬编码分隔符 |
| 国际化漏键 | 低 | 中英日三语同步增补,走现有 `t()` 机制 |
| AI 去重环节失败(网络/限流) | 中 | 复用现有 `convertContentWithAI` 的错误处理,提示用户重试 |
| validRules 全空(文件全是错误规则) | 低 | 预览禁用确认按钮,提示"无有效规则可导入",不写空文件 |
---
## 八、后续可扩展点(本次不做)
- **导出当前规则**:复用 `loadActiveRules()`,把工作区规则写入 Excel(备份/迁移)。
- **模板多语言**:根据 `getAIOutputLanguage()` 切换示例与说明语言。
- **模板版本号**:在「说明」sheet 增加 `version` 字段,便于未来字段演进时的兼容判断。
- **YAML 直通链路校验补齐**:当前 `.yaml/.yml` 直接 `copyFileSync` 零校验,可补结构化校验。
- **AI 链路校验补齐**:AI 解析结果也可过一遍 `validateRules()` 硬校验。
- **本地去重函数**:若未来想完全脱离 AI,可实现 `detectDuplicates(rules, existing)` 做纯本地比对。