- 方法级审查:CodeLens 触发 + 单次 AI 调用(规则匹配 + 6 维度深度审查),新增 method-extractor / status-cache / codeLensProvider - 模板导入:severity 保留原始值 + 占位 id、去重对照统一 known-rules、重复提示条双语翻译、箭头展开/折叠 UI、520 条静态规则补 zh/ja 翻译 - SQL:sql-lint 重命名 sqlfluff + sqlfluff.dialect 方言可配置 + 默认方言调整 - ESLint:v9 flat config 接线修复(overrideConfigFile)+ legacy 迁移提示 - AI:空响应 EmptyContentError + 重试一次 + max_tokens 截断专用报错 - JSP:整文件检查走 PMD JSP 规则集 + scriptlet 包装解析 + 行号映射 - 诊断按 severity + 行号排序
17 KiB
导入预览 · 错误规则可编辑与「添加」设计书
版本:v1.0 日期:2026-07-31 适用项目:vscode-code-reviewer 参考文档:
2026-07-30-export-template-design-v2.md(错误规则组雏形)、2026-07-25-import-preview-edit-design.md(预览编辑)
一、方案概览
1.1 目标
模板导入(勾选「使用模板文件导入」)进入预览后,错误规则不再是纯只读展示,而是:
- 卡片提供完整编辑表单(与有效规则一致:id / severity / description / message / languages / excludeLanguages)
- 每张错误卡片有独立「添加」按钮,点击后:
- 重新校验格式(id/description/message 非空、severity 合法)
- 校验通过后单条 AI 去重(复用
buildDedupOnlyPrompt),失败先重试一次,仍失败降级为none - 按去重结果(exact/overlap/none)把该规则移入对应分区,变为正常可编辑规则(带保留/注释切换)
- 添加时若 id 与预览中已有规则重复 → 阻止并提示修改 id
- 未添加的错误规则维持现状:确认导入时自动丢弃、不参与校验
- 确认写盘必须包含已添加的规则(避免走 raw yaml 路径丢失)
1.2 数据流
现状(仅展示):
parseTemplate → errorRules(带 validationIssues)→ 预览只读展示 → 确认时自动丢弃
改造后:
parseTemplate → errorRules → 预览可编辑错误卡片
├─ 用户编辑字段 → 点击「添加」
│ ├─ [扩展端] 格式校验 → 失败 → postMessage addError(卡片内提示)
│ ├─ [扩展端] id 冲突检查(对照预览已有有效规则)→ 冲突 → addError
│ ├─ [扩展端] AI 单条去重(重试 1 次 → 降级 none)→ postMessage ruleAdded
│ └─ [前端] DOM 手术:错误卡片移入 exact/overlap/none 分区,
│ 去除 data-error、换保留/注释按钮、更新计数
└─ 确认导入
├─ 已添加或已编辑 → 前端回传 editedRules(含已添加规则)→ renderRulesToYaml 写盘
└─ 无编辑无添加 → 走原 buildFinalYamlFromRaw(零回归)
1.3 范围
| 类型 | 内容 |
|---|---|
| 含 | 错误卡片完整编辑表单;「添加」按钮(校验 + 单条 AI 去重 + 移入分区);id 冲突拦截;去重失败重试/降级;确认写盘含已添加规则;计数动态更新 |
| 不含 | 非模板导入路径(AI 链路无 validationIssues,不受影响);批量「全部添加」按钮;错误规则的本地重复检查 |
| 不触碰 | parseTemplate 校验逻辑、buildDedupOnlyPrompt、applyConversion、其余转换器 |
二、架构设计
2.1 模块划分
src/rules/
├── import-service.ts ← 修改: 新增 export async function dedupSingleRule()
├── import-preview.ts ← 修改: 错误卡片可编辑 + 添加流程(前端 JS + 扩展消息处理)
└── import-types.ts ← 不改(复用现有类型)
src/i18n/messages.ts ← 修改: 新增 key,调整 import.cannotImport 文案
2.2 关键接口
// import-service.ts 新增导出函数
export interface DedupResult {
duplicateLevel: 'exact' | 'overlap' | 'none';
duplicateOf?: string;
duplicateReason?: string;
}
// 单条规则 AI 去重:失败重试 1 次,仍失败返回 null(调用方降级为 none)
export async function dedupSingleRule(
rule: ImportableRule,
context: vscode.ExtensionContext,
): Promise<DedupResult | null>
// import-preview.ts — Webview 消息协议扩展
// 前端 → 扩展
interface AddErrorRuleMessage {
type: 'addErrorRule';
ruleId: string; // 原始卡片 id(用于在 result.rules 中定位)
rule: { // 当前卡片全部字段(含用户编辑)
id: string;
severity: string;
description: string;
message: string;
languages?: string[];
excludeLanguages?: string[];
};
}
// 扩展 → 前端
interface AddErrorMessage {
type: 'addError';
ruleId: string;
message: string;
}
interface RuleAddedMessage {
type: 'ruleAdded';
ruleId: string; // 原始卡片 id(前端按此定位 DOM)
id: string; // 去重后的最终 id(可能被用户编辑过)
duplicateLevel: 'exact' | 'overlap' | 'none';
duplicateOf?: string;
duplicateReason?: string;
dedupFailed: boolean; // true 表示降级为 none
}
convertContentWithAI 增加可选第 4 参 quiet?: boolean(去重失败时抑制内置 error toast,改由前端降级提示)。现有调用点均传 3 参,向后兼容。
三、详细实现
3.1 import-service.ts:新增 dedupSingleRule
export interface DedupResult {
duplicateLevel: 'exact' | 'overlap' | 'none';
duplicateOf?: string;
duplicateReason?: string;
}
export async function dedupSingleRule(
rule: ImportableRule,
context: vscode.ExtensionContext,
): Promise<DedupResult | null> {
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const existingRules = workspaceRoot ? loadActiveRules(workspaceRoot) : [];
const singleYaml = [
`- id: ${rule.id}`,
` severity: ${rule.severity}`,
` description: ${rule.description}`,
` message: ${rule.message}`,
...(rule.languages?.length ? [` languages: [${rule.languages.join(', ')}]`] : []),
...(rule.excludeLanguages?.length ? [` excludeLanguages: [${rule.excludeLanguages.join(', ')}]`] : []),
].join('\n');
const { system, user } = buildDedupOnlyPrompt(singleYaml, existingRules);
for (let attempt = 0; attempt < 2; attempt++) {
const out = await convertContentWithAI(user, context, system, true); // quiet
if (!out) continue;
const parsed = parseImportableYaml(out);
if (parsed.length === 0) continue;
const r = parsed[0];
return {
duplicateLevel: r.duplicateLevel ?? 'none',
duplicateOf: r.duplicateOf,
duplicateReason: r.duplicateReason,
};
}
return null;
}
3.2 import-service.ts:convertContentWithAI 增加 quiet 参数
export async function convertContentWithAI(
content: string,
context: vscode.ExtensionContext,
systemPrompt?: string,
quiet?: boolean,
): Promise<string | null> {
// ... getApiKey 失败:quiet 时仅返回 null,不弹 toast
// ... provider.chat 异常:quiet 时仅返回 null,不弹 toast
// ... 其余逻辑不变
}
3.3 import-preview.ts:错误卡片渲染改造
废弃 renderErrorCard / renderErrorSection 的只读版,统一由 renderRuleCard 承担,新增 isError 分支:
renderRuleCard(rule, { isError })
├─ 普通卡片:现状逻辑不变
└─ 错误卡片:
├─ 卡片属性 data-ruleid + data-error="true" + 错误边框(保留现 opacity/border 样式)
├─ 折叠态摘要:id(可编辑)+ 错误徽标(新 i18n,如「需修复后添加」)
├─ 展开态表单:与普通卡片完全一致(id 可改、severity 下拉、
│ description/message textarea、languages/excludeLanguages 标签)
├─ 表单顶部:错误原因列表(复用现 issues 渲染)
├─ 顶部操作区:用「添加」按钮替代「保留/注释」toggle(新 i18n)
└─ 无 duplicateInfo(尚未去重)
默认展开错误卡片(body-<id> display:block),让用户立即看到错误原因。
分区容器加 data-section 属性(前端 DOM 手术定位用):
- 错误分区容器:
data-section="error"(原renderErrorSection,标题/图标不变) renderSection三个分区容器分别加data-section="exact" | "overlap" | "none"
顶部 summary 计数加 id(前端更新用):
⛔ 完全重复 N 条→<span id="count-exact">⚠️ 部分重叠 N 条→<span id="count-overlap">✅ 无重复 N 条→<span id="count-none">- 错误分区标题计数 →
<span id="count-error">(放在renderErrorSection的 section-title 内)
确认按钮不再静态禁用:改为 JS 动态控制。初始无有效规则时保持禁用 + 显示 emptyValidHint;一旦「添加」成功移入有效分区,JS 启用按钮并隐藏提示。
3.4 import-preview.ts:扩展端消息处理
showImportPreview 的 onDidReceiveMessage 增加分支:
panel.webview.onDidReceiveMessage(async (msg) => {
if (msg.type === 'toggleRule') {
keepRule[msg.ruleId] = msg.keep;
} else if (msg.type === 'addErrorRule') {
await handleAddErrorRule(msg, result, keepRule, context, panel);
} else if (msg.type === 'confirm') {
resolve({ keepRule, confirmed: true, editedRules: msg.editedRules });
panel.dispose();
} else if (msg.type === 'cancel') {
resolve(null);
panel.dispose();
}
});
handleAddErrorRule 逻辑(顺序严格):
async function handleAddErrorRule(msg, result, keepRule, context, panel) {
const rule = msg.rule;
// [1] 格式校验(复用前端同一套规则)
const err = validateRule(rule); // id/severity/description/message
if (err) {
panel.webview.postMessage({ type: 'addError', ruleId: msg.ruleId, message: err });
return;
}
// [2] id 冲突检查(对照 result.rules 中非错误规则,忽略大小写)
const conflict = result.rules.some(r =>
!r.validationIssues?.length && r.id.toLowerCase() === rule.id.toLowerCase()
);
if (conflict) {
panel.webview.postMessage({
type: 'addError', ruleId: msg.ruleId,
message: t('import.idConflict', { 0: rule.id }),
});
return;
}
// [3] AI 单条去重(内部已重试 1 次),失败降级 none
const dedup = await dedupSingleRule(rule, context);
const level = dedup?.duplicateLevel ?? 'none';
const dedupFailed = !dedup;
// [4] 更新 result.rules 中该条规则(按原始 id 定位)
const idx = result.rules.findIndex(r => r.id === msg.ruleId);
if (idx >= 0) {
result.rules[idx] = {
...result.rules[idx],
id: rule.id,
severity: rule.severity,
description: rule.description,
message: rule.message,
languages: rule.languages,
excludeLanguages: rule.excludeLanguages,
duplicateLevel: level,
duplicateOf: dedup?.duplicateOf,
duplicateReason: dedup?.duplicateReason,
validationIssues: undefined,
};
}
// [5] keepRule 按新 id 记录(exact → false)
keepRule[rule.id] = level !== 'exact';
// [6] 通知前端移动卡片
panel.webview.postMessage({
type: 'ruleAdded',
ruleId: msg.ruleId, // 原始 id,前端定位 DOM
id: rule.id, // 新 id
duplicateLevel: level,
duplicateOf: dedup?.duplicateOf,
duplicateReason: dedup?.duplicateReason,
dedupFailed,
});
}
function validateRule(rule): string | null {
if (!rule.id || !rule.id.trim()) return t('import.validationIdEmpty');
if (!['error', 'warning', 'info'].includes(rule.severity)) return t('import.validationSeverityInvalid');
if (!rule.description || !rule.description.trim()) return t('import.validationDescEmpty', { 0: rule.id });
if (!rule.message || !rule.message.trim()) return t('import.validationMsgEmpty', { 0: rule.id });
return null;
}
context需传入showImportPreview(新增参数)或在模块内暂存——采用新增参数:showImportPreview(result, context)。
3.5 import-preview.ts:前端 JS 改造
新增 / 修改函数:
// 从单张卡片提取当前字段(供添加与校验复用;从 collectEditedRules 抽取公共逻辑)
function collectCardRule(ruleId) { /* 读 id-display-input / select / textareas / tag-lists */ }
let addedRules = 0; // 已成功添加的错误规则数
// 点击「添加」
function addErrorRule(ruleId) {
const btn = document.querySelector(`[data-addbtn="${ruleId}"]`);
btn.disabled = true; btn.textContent = ADDING_TEXT;
const rule = collectCardRule(ruleId);
vscode.postMessage({ type: 'addErrorRule', ruleId, rule });
}
// 接收扩展消息
window.addEventListener('message', e => {
const msg = e.data;
if (msg.type === 'addError') {
// 卡片内展示 msg.message,恢复添加按钮可点击
} else if (msg.type === 'ruleAdded') {
moveCardToSection(msg);
}
});
function moveCardToSection(msg) {
const card = document.querySelector(`.rule-card[data-ruleid="${msg.ruleId}"]`);
// 1. 更新 id 相关:card.dataset.ruleid = msg.id;两个 id input 值为 msg.id
// 2. 移除 data-error 与错误边框样式
// 3. 还原「添加」按钮为保留/注释 toggle(按 keepRule 状态)—— 需从扩展同步 keep 状态:
// msg.duplicateLevel === 'exact' 时默认注释态,否则保留态
// 4. 更新徽标:exact →「将注释」;overlap/none →「保留」(badge-exact/overlap/none)
// 5. 追加 duplicateInfo(exact/overlap 文案,复用现有拼接逻辑)
// 6. 移入对应分区:document.querySelector(`[data-section="${section}"]`).appendChild(card)
// 7. addedRules++;更新计数与确认按钮状态
}
// 分区标题计数(N 条)与顶部 summary 计数统一重算
function updateSectionCounts() {
// 按 data-section 遍历,重算 4 个分区标题计数 + count-exact/overlap/none
// 错误分区为空 → 隐藏整个分区容器
// 无任何有效规则 → 禁用确认按钮 + 显示 emptyValidHint;否则启用
}
// 确认:hasEdits 或 addedRules>0 时必带 editedRules
function doConfirm() {
const err = validate();
if (err) { /* 现逻辑 */ return; }
const edited = collectEditedRules();
const hasEdits = Object.keys(editedRules).length > 0;
const withData = (hasEdits || addedRules > 0) ? edited : undefined;
vscode.postMessage({ type: 'confirm', editedRules: withData });
}
updateSummary()(保留/注释计数)已按 data-error 跳过错误卡片,无需改动;moveCardToSection 移除 data-error 后该卡片自动纳入统计。
3.6 关键边界
| 边界 | 处理 |
|---|---|
| 添加时 id 冲突(预览内已有有效规则) | addError 提示改 id,卡片留在错误分区 |
| AI 去重首次失败 | 自动重试 1 次(共 2 次尝试,quiet 模式不弹错) |
| 重试仍失败 | 降级 none 移入无重复分区,前端提示「AI 去重失败,已以无重复方式添加」 |
| 添加后 id 被修改导致与后续规则重复 | 后续添加时冲突检查覆盖全量有效规则,会拦截 |
| 只添加未编辑字段 | addedRules>0 仍回传 editedRules,走 renderRulesToYaml,已添加规则不会丢失 |
| 错误规则未添加即确认 | 维持现状:data-error 卡片被 collectEditedRules/validate 跳过,自动丢弃 |
| 初始无有效规则 | 确认按钮禁用;「添加」成功第一条后 JS 启用 |
| 未配置 API Key | convertContentWithAI 返回 null(quiet),重试后降级 none,流程不中断 |
3.7 i18n 新增 / 调整
| key | zh-CN | en | ja |
|---|---|---|---|
import.add(新增) |
添加 | Add | 追加 |
import.adding(新增) |
校验并去重中... | Validating & deduping... | 検証・重複排除中... |
import.idConflict(新增) |
id {0} 与已有规则重复,请修改 id | id {0} conflicts with an existing rule, change the id | id {0} が既存ルールと重複、id を変更してください |
import.addDedupFallback(新增) |
AI 去重失败,已以无重复方式添加 | AI dedup failed, added as no-duplicate | AI 重複排除失敗、重複なしとして追加 |
import.validationSeverityInvalid(新增) |
severity 非法 | Invalid severity | severity が不正です |
import.cannotImport(调整) |
需修复后点击添加 | Fix then click Add | 修正して「追加」をクリック |
四、文件变更清单
| 文件 | 操作 | 内容 |
|---|---|---|
src/rules/import-service.ts |
修改 | 新增 dedupSingleRule(含 DedupResult 接口);convertContentWithAI 增加 quiet? 参数 |
src/rules/import-preview.ts |
修改 | 错误卡片完整表单 + 添加流程;showImportPreview(result, context);handleAddErrorRule;前端 addErrorRule/moveCardToSection/updateSectionCounts/doConfirm;分区 data-section 与计数 id;确认按钮动态控制 |
src/i18n/messages.ts |
修改 | 新增 5 个 key,调整 import.cannotImport 三语文案 |
src/rules/import-types.ts |
不改 | — |
src/rules/converters/template-converter.ts |
不改 | 校验逻辑不动 |
五、验证
验证顺序 lint → compile(当前仓库无测试文件):
npm run lint(ESLintsrc/)npm run compile(tsc)- 手动验证(Extension Dev Host):
- 导出模板 → 填一行错误数据(如空 description、拼错 severity)→ 模板导入 → 预览中错误卡片可展开编辑
- 修复后点「添加」→ 卡片移入对应分区、计数更新、可切换保留/注释
- 不改任何字段再添加一条 → 确认导入 → 写盘 YAML 含已添加规则
- id 冲突 → 添加被拦截并提示
- 断网/无 Key → 重试后降级无重复分区,卡片有降级提示
- 未添加的错误规则 → 确认后自动丢弃