@spaceflow/review 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +533 -0
- package/README.md +124 -0
- package/dist/551.js +9 -0
- package/dist/index.js +5704 -0
- package/package.json +50 -0
- package/src/README.md +364 -0
- package/src/__mocks__/@anthropic-ai/claude-agent-sdk.js +3 -0
- package/src/__mocks__/json-stringify-pretty-compact.ts +4 -0
- package/src/deletion-impact.service.spec.ts +974 -0
- package/src/deletion-impact.service.ts +879 -0
- package/src/dto/mcp.dto.ts +42 -0
- package/src/index.ts +32 -0
- package/src/issue-verify.service.spec.ts +460 -0
- package/src/issue-verify.service.ts +309 -0
- package/src/locales/en/review.json +31 -0
- package/src/locales/index.ts +11 -0
- package/src/locales/zh-cn/review.json +31 -0
- package/src/parse-title-options.spec.ts +251 -0
- package/src/parse-title-options.ts +185 -0
- package/src/review-report/formatters/deletion-impact.formatter.ts +144 -0
- package/src/review-report/formatters/index.ts +4 -0
- package/src/review-report/formatters/json.formatter.ts +8 -0
- package/src/review-report/formatters/markdown.formatter.ts +291 -0
- package/src/review-report/formatters/terminal.formatter.ts +130 -0
- package/src/review-report/index.ts +4 -0
- package/src/review-report/review-report.module.ts +8 -0
- package/src/review-report/review-report.service.ts +58 -0
- package/src/review-report/types.ts +26 -0
- package/src/review-spec/index.ts +3 -0
- package/src/review-spec/review-spec.module.ts +10 -0
- package/src/review-spec/review-spec.service.spec.ts +1543 -0
- package/src/review-spec/review-spec.service.ts +902 -0
- package/src/review-spec/types.ts +143 -0
- package/src/review.command.ts +244 -0
- package/src/review.config.ts +58 -0
- package/src/review.mcp.ts +184 -0
- package/src/review.module.ts +52 -0
- package/src/review.service.spec.ts +3007 -0
- package/src/review.service.ts +2603 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +34 -0
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Injectable,
|
|
3
|
+
type ChangedFile,
|
|
4
|
+
type VerboseLevel,
|
|
5
|
+
shouldLog,
|
|
6
|
+
GitProviderService,
|
|
7
|
+
parseRepoUrl,
|
|
8
|
+
type RemoteRepoRef,
|
|
9
|
+
type RepositoryContent,
|
|
10
|
+
} from "@spaceflow/core";
|
|
11
|
+
import { Optional } from "@nestjs/common";
|
|
12
|
+
import { readdir, readFile, mkdir, access, writeFile } from "fs/promises";
|
|
13
|
+
import { join, basename, extname } from "path";
|
|
14
|
+
import { homedir } from "os";
|
|
15
|
+
import { execSync } from "child_process";
|
|
16
|
+
import micromatch from "micromatch";
|
|
17
|
+
import { ReviewSpec, ReviewRule, RuleExample, Severity } from "./types";
|
|
18
|
+
|
|
19
|
+
/** 远程规则缓存 TTL(毫秒),默认 5 分钟 */
|
|
20
|
+
const REMOTE_SPEC_CACHE_TTL = 5 * 60 * 1000;
|
|
21
|
+
|
|
22
|
+
@Injectable()
|
|
23
|
+
export class ReviewSpecService {
|
|
24
|
+
constructor(@Optional() protected readonly gitProvider?: GitProviderService) {}
|
|
25
|
+
/**
|
|
26
|
+
* 检查规则 ID 是否匹配(精确匹配或前缀匹配)
|
|
27
|
+
* 例如: "JsTs.FileName" 匹配 "JsTs.FileName" 和 "JsTs.FileName.UpperCamel"
|
|
28
|
+
*/
|
|
29
|
+
protected matchRuleId(ruleId: string, pattern: string): boolean {
|
|
30
|
+
if (!ruleId || !pattern) {
|
|
31
|
+
console.warn(
|
|
32
|
+
`matchRuleId: 参数为空 (ruleId=${JSON.stringify(ruleId)}, pattern=${JSON.stringify(pattern)})`,
|
|
33
|
+
);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return ruleId === pattern || ruleId.startsWith(pattern + ".");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 从 Map 中查找匹配的规则值(精确匹配优先,然后前缀匹配)
|
|
41
|
+
*/
|
|
42
|
+
protected findByRuleId<T>(ruleId: string, map: Map<string, T>): T | undefined {
|
|
43
|
+
if (!ruleId) {
|
|
44
|
+
console.warn(`findByRuleId: ruleId 为空 (ruleId=${JSON.stringify(ruleId)})`);
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
// 精确匹配
|
|
48
|
+
if (map.has(ruleId)) {
|
|
49
|
+
return map.get(ruleId);
|
|
50
|
+
}
|
|
51
|
+
// 前缀匹配
|
|
52
|
+
for (const [key, value] of map) {
|
|
53
|
+
if (ruleId.startsWith(key + ".")) {
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* 根据变更文件的扩展名过滤适用的规则文件
|
|
61
|
+
* 只按扩展名过滤,includes 和 override 在 LLM 审查后处理
|
|
62
|
+
*/
|
|
63
|
+
filterApplicableSpecs(specs: ReviewSpec[], changedFiles: { filename?: string }[]): ReviewSpec[] {
|
|
64
|
+
const changedExtensions = new Set<string>();
|
|
65
|
+
|
|
66
|
+
for (const file of changedFiles) {
|
|
67
|
+
if (file.filename) {
|
|
68
|
+
const ext = extname(file.filename).slice(1).toLowerCase();
|
|
69
|
+
if (ext) {
|
|
70
|
+
changedExtensions.add(ext);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return specs.filter((spec) => spec.extensions.some((ext) => changedExtensions.has(ext)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async loadReviewSpecs(specDir: string): Promise<ReviewSpec[]> {
|
|
79
|
+
const specs: ReviewSpec[] = [];
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const files = await readdir(specDir);
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
if (!file.endsWith(".md")) continue;
|
|
86
|
+
|
|
87
|
+
const content = await readFile(join(specDir, file), "utf-8");
|
|
88
|
+
const spec = this.parseSpecFile(file, content);
|
|
89
|
+
if (spec) {
|
|
90
|
+
specs.push(spec);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// 目录不存在时静默跳过(这些是可选的配置目录)
|
|
95
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
96
|
+
console.warn(`警告: 无法读取规则目录 ${specDir}:`, error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return specs;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async resolveSpecSources(sources: string[]): Promise<string[]> {
|
|
104
|
+
const dirs: string[] = [];
|
|
105
|
+
|
|
106
|
+
for (const source of sources) {
|
|
107
|
+
// 优先尝试解析为远程仓库 URL(浏览器复制的链接)
|
|
108
|
+
const repoRef = parseRepoUrl(source);
|
|
109
|
+
if (repoRef && this.gitProvider) {
|
|
110
|
+
const dir = await this.fetchRemoteSpecs(repoRef);
|
|
111
|
+
if (dir) {
|
|
112
|
+
dirs.push(dir);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (this.isRepoUrl(source)) {
|
|
117
|
+
const dir = await this.cloneSpecRepo(source);
|
|
118
|
+
if (dir) {
|
|
119
|
+
dirs.push(dir);
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
// 检查是否是 deps 目录,如果是则扫描子目录的 references
|
|
123
|
+
const resolvedDirs = await this.resolveDepsDir(source);
|
|
124
|
+
dirs.push(...resolvedDirs);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return dirs;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 通过 Git API 从远程仓库拉取规则文件
|
|
133
|
+
* 缓存到 ~/.spaceflow/review-spec-cache/ 目录,带 TTL
|
|
134
|
+
*/
|
|
135
|
+
protected async fetchRemoteSpecs(ref: RemoteRepoRef): Promise<string | null> {
|
|
136
|
+
const cacheKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
|
|
137
|
+
const cacheDir = join(homedir(), ".spaceflow", "review-spec-cache", cacheKey);
|
|
138
|
+
// 检查缓存是否有效(非 CI 环境下使用 TTL)
|
|
139
|
+
const isCI = !!process.env.CI;
|
|
140
|
+
if (!isCI) {
|
|
141
|
+
try {
|
|
142
|
+
const timestampFile = join(cacheDir, ".timestamp");
|
|
143
|
+
const timestamp = await readFile(timestampFile, "utf-8");
|
|
144
|
+
const age = Date.now() - Number(timestamp);
|
|
145
|
+
if (age < REMOTE_SPEC_CACHE_TTL) {
|
|
146
|
+
const entries = await readdir(cacheDir);
|
|
147
|
+
if (entries.some((f) => f.endsWith(".md"))) {
|
|
148
|
+
return cacheDir;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
} catch {
|
|
152
|
+
// 缓存不存在或无效,继续拉取
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
console.log(
|
|
157
|
+
` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
|
|
158
|
+
);
|
|
159
|
+
const contents = await this.gitProvider!.listRepositoryContents(
|
|
160
|
+
ref.owner,
|
|
161
|
+
ref.repo,
|
|
162
|
+
ref.path || undefined,
|
|
163
|
+
ref.ref,
|
|
164
|
+
);
|
|
165
|
+
const mdFiles = contents.filter(
|
|
166
|
+
(f: RepositoryContent) => f.type === "file" && f.name.endsWith(".md"),
|
|
167
|
+
);
|
|
168
|
+
if (mdFiles.length === 0) {
|
|
169
|
+
console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
await mkdir(cacheDir, { recursive: true });
|
|
173
|
+
for (const file of mdFiles) {
|
|
174
|
+
const content = await this.gitProvider!.getFileContent(
|
|
175
|
+
ref.owner,
|
|
176
|
+
ref.repo,
|
|
177
|
+
file.path,
|
|
178
|
+
ref.ref,
|
|
179
|
+
);
|
|
180
|
+
await writeFile(join(cacheDir, file.name), content, "utf-8");
|
|
181
|
+
}
|
|
182
|
+
// 写入时间戳
|
|
183
|
+
await writeFile(join(cacheDir, ".timestamp"), String(Date.now()), "utf-8");
|
|
184
|
+
console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到缓存`);
|
|
185
|
+
return cacheDir;
|
|
186
|
+
} catch (error) {
|
|
187
|
+
console.warn(` ⚠️ 远程规则拉取失败:`, error instanceof Error ? error.message : error);
|
|
188
|
+
// 尝试使用过期缓存
|
|
189
|
+
try {
|
|
190
|
+
const entries = await readdir(cacheDir);
|
|
191
|
+
if (entries.some((f) => f.endsWith(".md"))) {
|
|
192
|
+
console.log(` 📦 使用过期缓存`);
|
|
193
|
+
return cacheDir;
|
|
194
|
+
}
|
|
195
|
+
} catch {
|
|
196
|
+
// 无缓存可用
|
|
197
|
+
}
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 解析 deps 目录,扫描子目录中的 references 文件夹
|
|
204
|
+
* 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
|
|
205
|
+
*/
|
|
206
|
+
protected async resolveDepsDir(dir: string): Promise<string[]> {
|
|
207
|
+
const dirs: string[] = [];
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
211
|
+
|
|
212
|
+
// 检查目录本身是否包含 .md 文件
|
|
213
|
+
const hasMdFiles = entries.some((e) => e.isFile() && e.name.endsWith(".md"));
|
|
214
|
+
if (hasMdFiles) {
|
|
215
|
+
dirs.push(dir);
|
|
216
|
+
return dirs;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 扫描子目录
|
|
220
|
+
for (const entry of entries) {
|
|
221
|
+
if (entry.isDirectory()) {
|
|
222
|
+
const subDir = join(dir, entry.name);
|
|
223
|
+
// 优先检查 references 子目录
|
|
224
|
+
const referencesDir = join(subDir, "references");
|
|
225
|
+
try {
|
|
226
|
+
await access(referencesDir);
|
|
227
|
+
dirs.push(referencesDir);
|
|
228
|
+
} catch {
|
|
229
|
+
// 没有 references 子目录,检查子目录本身是否有 .md 文件
|
|
230
|
+
try {
|
|
231
|
+
const subEntries = await readdir(subDir);
|
|
232
|
+
if (subEntries.some((f) => f.endsWith(".md"))) {
|
|
233
|
+
dirs.push(subDir);
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
// 忽略无法读取的子目录
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
} catch {
|
|
242
|
+
// 目录不存在时静默跳过
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return dirs;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
protected isRepoUrl(source: string): boolean {
|
|
249
|
+
return (
|
|
250
|
+
source.startsWith("http://") ||
|
|
251
|
+
source.startsWith("https://") ||
|
|
252
|
+
source.startsWith("git@") ||
|
|
253
|
+
source.includes("://")
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
protected async cloneSpecRepo(repoUrl: string): Promise<string | null> {
|
|
258
|
+
const repoName = this.extractRepoName(repoUrl);
|
|
259
|
+
if (!repoName) {
|
|
260
|
+
console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
await access(cacheDir);
|
|
268
|
+
// console.log(` 使用缓存的规则仓库: ${cacheDir}`);
|
|
269
|
+
try {
|
|
270
|
+
execSync("git pull --ff-only", { cwd: cacheDir, stdio: "pipe" });
|
|
271
|
+
// console.log(` 已更新规则仓库`);
|
|
272
|
+
} catch {
|
|
273
|
+
console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
|
|
274
|
+
}
|
|
275
|
+
return cacheDir;
|
|
276
|
+
} catch {
|
|
277
|
+
// console.log(` 克隆规则仓库: ${repoUrl}`);
|
|
278
|
+
try {
|
|
279
|
+
await mkdir(join(homedir(), ".spaceflow", "review-spec"), { recursive: true });
|
|
280
|
+
execSync(`git clone --depth 1 "${repoUrl}" "${cacheDir}"`, { stdio: "pipe" });
|
|
281
|
+
// console.log(` 克隆完成: ${cacheDir}`);
|
|
282
|
+
return cacheDir;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
protected extractRepoName(repoUrl: string): string | null {
|
|
291
|
+
let path = repoUrl;
|
|
292
|
+
path = path.replace(/\.git$/, "");
|
|
293
|
+
path = path.replace(/^git@[^:]+:/, "");
|
|
294
|
+
path = path.replace(/^https?:\/\/[^/]+\//, "");
|
|
295
|
+
|
|
296
|
+
const parts = path.split("/").filter(Boolean);
|
|
297
|
+
if (parts.length >= 2) {
|
|
298
|
+
return `${parts[parts.length - 2]}__${parts[parts.length - 1]}`;
|
|
299
|
+
} else if (parts.length === 1) {
|
|
300
|
+
return parts[0];
|
|
301
|
+
}
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
parseSpecFile(filename: string, content: string): ReviewSpec | null {
|
|
306
|
+
const nameWithoutExt = basename(filename, ".md");
|
|
307
|
+
const parts = nameWithoutExt.split(".");
|
|
308
|
+
|
|
309
|
+
if (parts.length < 2) {
|
|
310
|
+
console.warn(`警告: 规则文件名格式不正确: ${filename}`);
|
|
311
|
+
return null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const extensionsPart = parts[0];
|
|
315
|
+
const type = parts.slice(1).join(".");
|
|
316
|
+
const extensions = extensionsPart.split("&").map((ext) => ext.toLowerCase());
|
|
317
|
+
|
|
318
|
+
const rules = this.extractRules(content);
|
|
319
|
+
|
|
320
|
+
// 文件级别的 override 来自第一个规则(标题规则)的 overrides
|
|
321
|
+
const fileOverrides = rules.length > 0 ? rules[0].overrides : [];
|
|
322
|
+
// 文件级别的 severity 来自第一个规则(标题规则)的 severity,默认为 error
|
|
323
|
+
const fileSeverity = (rules.length > 0 ? rules[0].severity : undefined) || "error";
|
|
324
|
+
// 文件级别的 includes 从内容中提取
|
|
325
|
+
const fileIncludes = this.extractIncludes(content);
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
filename,
|
|
329
|
+
extensions,
|
|
330
|
+
type,
|
|
331
|
+
content,
|
|
332
|
+
rules,
|
|
333
|
+
overrides: fileOverrides,
|
|
334
|
+
severity: fileSeverity,
|
|
335
|
+
includes: fileIncludes,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
protected extractRules(content: string): ReviewRule[] {
|
|
340
|
+
const rules: ReviewRule[] = [];
|
|
341
|
+
const ruleRegex = /^(#{1,3})\s+(.+?)\s+`\[([^\]]+)\]`/gm;
|
|
342
|
+
|
|
343
|
+
const matches: { index: number; length: number; title: string; id: string }[] = [];
|
|
344
|
+
let match;
|
|
345
|
+
while ((match = ruleRegex.exec(content)) !== null) {
|
|
346
|
+
matches.push({
|
|
347
|
+
index: match.index,
|
|
348
|
+
length: match[0].length,
|
|
349
|
+
title: match[2].trim(),
|
|
350
|
+
id: match[3],
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
for (let i = 0; i < matches.length; i++) {
|
|
355
|
+
const current = matches[i];
|
|
356
|
+
const startIndex = current.index + current.length;
|
|
357
|
+
const endIndex = i + 1 < matches.length ? matches[i + 1].index : content.length;
|
|
358
|
+
|
|
359
|
+
const ruleContent = content.slice(startIndex, endIndex).trim();
|
|
360
|
+
const examples = this.extractExamples(ruleContent);
|
|
361
|
+
const overrides = this.extractOverrides(ruleContent);
|
|
362
|
+
|
|
363
|
+
// 提取描述:在第一个例子之前的文本
|
|
364
|
+
let description = ruleContent;
|
|
365
|
+
const firstExampleIndex = ruleContent.search(/(?:^|\n)###\s+(?:good|bad)/i);
|
|
366
|
+
if (firstExampleIndex !== -1) {
|
|
367
|
+
description = ruleContent.slice(0, firstExampleIndex).trim();
|
|
368
|
+
} else {
|
|
369
|
+
// 如果没有例子,则整个 ruleContent 都是描述
|
|
370
|
+
description = ruleContent;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const severity = this.extractSeverity(ruleContent);
|
|
374
|
+
const includes = this.extractConfigValues(ruleContent, "includes");
|
|
375
|
+
|
|
376
|
+
rules.push({
|
|
377
|
+
id: current.id,
|
|
378
|
+
title: current.title,
|
|
379
|
+
description,
|
|
380
|
+
examples,
|
|
381
|
+
overrides,
|
|
382
|
+
severity,
|
|
383
|
+
includes: includes.length > 0 ? includes : undefined,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return rules;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 通用配置解析方法
|
|
392
|
+
* 格式: > - <configName> `value1` `value2` ...
|
|
393
|
+
* 同名配置项后面的覆盖前面的
|
|
394
|
+
*/
|
|
395
|
+
protected extractConfigValues(content: string, configName: string): string[] {
|
|
396
|
+
const configRegex = new RegExp(`^>\\s*-\\s*${configName}\\s+(.+)$`, "gm");
|
|
397
|
+
let values: string[] = [];
|
|
398
|
+
let match;
|
|
399
|
+
|
|
400
|
+
while ((match = configRegex.exec(content)) !== null) {
|
|
401
|
+
const valuesStr = match[1];
|
|
402
|
+
const valueRegex = /`([^`]+)`/g;
|
|
403
|
+
let valueMatch;
|
|
404
|
+
const lineValues: string[] = [];
|
|
405
|
+
while ((valueMatch = valueRegex.exec(valuesStr)) !== null) {
|
|
406
|
+
lineValues.push(valueMatch[1]);
|
|
407
|
+
}
|
|
408
|
+
// 同名配置项覆盖
|
|
409
|
+
values = lineValues;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return values;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
protected extractOverrides(content: string): string[] {
|
|
416
|
+
// override 的值格式是 `[RuleId]`,需要去掉方括号
|
|
417
|
+
return this.extractConfigValues(content, "override").map((v) =>
|
|
418
|
+
v.startsWith("[") && v.endsWith("]") ? v.slice(1, -1) : v,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
protected extractSeverity(content: string): Severity | undefined {
|
|
423
|
+
const values = this.extractConfigValues(content, "severity");
|
|
424
|
+
if (values.length > 0) {
|
|
425
|
+
const value = values[values.length - 1];
|
|
426
|
+
if (value === "off" || value === "warn" || value === "error") {
|
|
427
|
+
return value;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return undefined;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
protected extractIncludes(content: string): string[] {
|
|
434
|
+
// 只提取文件开头(第一个 ## 规则标题之前)的 includes 配置
|
|
435
|
+
// 避免规则级的 includes 覆盖文件级的 includes
|
|
436
|
+
const firstRuleIndex = content.indexOf("\n## ");
|
|
437
|
+
const headerContent = firstRuleIndex > 0 ? content.slice(0, firstRuleIndex) : content;
|
|
438
|
+
return this.extractConfigValues(headerContent, "includes");
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
protected extractExamples(content: string): RuleExample[] {
|
|
442
|
+
const examples: RuleExample[] = [];
|
|
443
|
+
const sections = content.split(/(?:^|\n)###\s+/);
|
|
444
|
+
|
|
445
|
+
for (const section of sections) {
|
|
446
|
+
const trimmedSection = section.trim();
|
|
447
|
+
if (!trimmedSection) continue;
|
|
448
|
+
|
|
449
|
+
let type: "good" | "bad" | null = null;
|
|
450
|
+
if (/^good\b/i.test(trimmedSection)) {
|
|
451
|
+
type = "good";
|
|
452
|
+
} else if (/^bad\b/i.test(trimmedSection)) {
|
|
453
|
+
type = "bad";
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!type) continue;
|
|
457
|
+
|
|
458
|
+
const codeBlockRegex = /```(\w*)\n([\s\S]*?)```/g;
|
|
459
|
+
let codeMatch;
|
|
460
|
+
while ((codeMatch = codeBlockRegex.exec(trimmedSection)) !== null) {
|
|
461
|
+
const lang = codeMatch[1] || "text";
|
|
462
|
+
const code = codeMatch[2].trim();
|
|
463
|
+
examples.push({ lang, code, type });
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return examples;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* 收集所有 override 声明并排除被覆盖的规则
|
|
472
|
+
* override 使用前缀匹配:如果规则 ID 以 override 值开头,则被排除
|
|
473
|
+
*/
|
|
474
|
+
applyOverrides(specs: ReviewSpec[], verbose?: VerboseLevel): ReviewSpec[] {
|
|
475
|
+
// 收集所有 override 声明(文件级别 + 规则级别)
|
|
476
|
+
const allOverrides: string[] = [];
|
|
477
|
+
for (const spec of specs) {
|
|
478
|
+
allOverrides.push(...spec.overrides);
|
|
479
|
+
for (const rule of spec.rules) {
|
|
480
|
+
allOverrides.push(...rule.overrides);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (allOverrides.length === 0) {
|
|
485
|
+
return specs;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 过滤规则:排除 ID 以任意 override 值开头的规则
|
|
489
|
+
return specs
|
|
490
|
+
.map((spec) => ({
|
|
491
|
+
...spec,
|
|
492
|
+
rules: spec.rules.filter((rule) => {
|
|
493
|
+
const isOverridden = allOverrides.some((override) => this.matchRuleId(rule.id, override));
|
|
494
|
+
if (isOverridden && shouldLog(verbose, 2)) {
|
|
495
|
+
console.error(` 规则 [${rule.id}] 被 override 排除`);
|
|
496
|
+
}
|
|
497
|
+
return !isOverridden;
|
|
498
|
+
}),
|
|
499
|
+
}))
|
|
500
|
+
.filter((spec) => spec.rules.length > 0);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 根据 spec 的 includes 配置过滤 issues
|
|
505
|
+
* 只保留文件名匹配对应 spec includes 模式的 issues
|
|
506
|
+
* 如果 spec 没有 includes 配置,则保留该 spec 的所有 issues
|
|
507
|
+
*/
|
|
508
|
+
filterIssuesByIncludes<T extends { file: string; ruleId: string }>(
|
|
509
|
+
issues: T[],
|
|
510
|
+
specs: ReviewSpec[],
|
|
511
|
+
): T[] {
|
|
512
|
+
// 构建 spec filename -> includes 的映射
|
|
513
|
+
const specIncludesMap = new Map<string, string[]>();
|
|
514
|
+
for (const spec of specs) {
|
|
515
|
+
// 从规则 ID 前缀推断 spec filename
|
|
516
|
+
for (const rule of spec.rules) {
|
|
517
|
+
specIncludesMap.set(rule.id, spec.includes);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
return issues.filter((issue) => {
|
|
522
|
+
// 找到该 issue 对应的 spec includes
|
|
523
|
+
const includes = this.findByRuleId(issue.ruleId, specIncludesMap) ?? [];
|
|
524
|
+
|
|
525
|
+
// 如果没有 includes 配置,保留该 issue
|
|
526
|
+
if (includes.length === 0) {
|
|
527
|
+
return true;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// 检查文件是否匹配 includes 模式
|
|
531
|
+
const matches = micromatch.isMatch(issue.file, includes, { matchBase: true });
|
|
532
|
+
if (!matches) {
|
|
533
|
+
// console.log(` Issue [${issue.ruleId}] 在文件 ${issue.file} 不匹配 includes 模式,跳过`);
|
|
534
|
+
}
|
|
535
|
+
return matches;
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* 根据 override 配置过滤 issues,排除被覆盖规则产生的 issues
|
|
541
|
+
*
|
|
542
|
+
* ## Override 机制说明
|
|
543
|
+
* Override 允许高优先级规则"覆盖"低优先级规则。当规则 A 声明 `overrides: ["B"]` 时,
|
|
544
|
+
* 规则 B 产生的 issues 会被过滤掉,避免重复报告或低优先级噪音。
|
|
545
|
+
*
|
|
546
|
+
* ## 作用域规则
|
|
547
|
+
* Override 是**作用域感知**的:只有当 issue 的文件匹配 override 所在 spec 的 includes 时,
|
|
548
|
+
* 该 override 才会生效。这允许不同目录/文件类型使用不同的规则优先级。
|
|
549
|
+
*
|
|
550
|
+
* 示例:
|
|
551
|
+
* ```yaml
|
|
552
|
+
* # controller-spec.yaml (includes: ["*.controller.ts"])
|
|
553
|
+
* overrides: ["JsTs.Base.Rule1"] # 只在 controller 文件中覆盖 Rule1
|
|
554
|
+
* ```
|
|
555
|
+
* 上述 override 不会影响 `*.service.ts` 文件中的 `Rule1` issues。
|
|
556
|
+
*
|
|
557
|
+
* ## 处理流程
|
|
558
|
+
* 1. **收集阶段**:遍历所有 specs,收集 overrides 并保留其作用域(includes)信息
|
|
559
|
+
* - 文件级 overrides (`spec.overrides`) - 继承 spec 的 includes 作用域
|
|
560
|
+
* - 规则级 overrides (`rule.overrides`) - 同样继承 spec 的 includes 作用域
|
|
561
|
+
* 2. **过滤阶段**:对每个 issue,检查是否存在匹配的 override
|
|
562
|
+
* - 需同时满足:ruleId 匹配 AND issue 文件在 override 的 includes 作用域内
|
|
563
|
+
* - 如果 includes 为空,表示全局作用域(匹配所有文件)
|
|
564
|
+
*
|
|
565
|
+
* @param issues - 待过滤的 issues 列表,每个 issue 必须包含 ruleId 字段,可选 file 字段
|
|
566
|
+
* @param specs - 已加载的 ReviewSpec 列表
|
|
567
|
+
* @param verbose - 日志详细级别:1=基础统计,3=详细收集过程
|
|
568
|
+
* @returns 过滤后的 issues 列表(排除了被 override 的规则产生的 issues)
|
|
569
|
+
*/
|
|
570
|
+
filterIssuesByOverrides<T extends { ruleId: string; file?: string }>(
|
|
571
|
+
issues: T[],
|
|
572
|
+
specs: ReviewSpec[],
|
|
573
|
+
verbose?: VerboseLevel,
|
|
574
|
+
): T[] {
|
|
575
|
+
// ========== 阶段1: 收集 spec -> overrides 的映射(保留作用域信息) ==========
|
|
576
|
+
// 每个 override 需要记录其来源 spec 的 includes,用于作用域判断
|
|
577
|
+
const scopedOverrides: Array<{
|
|
578
|
+
override: string;
|
|
579
|
+
includes: string[];
|
|
580
|
+
source: string; // 用于日志:spec filename 或 rule id
|
|
581
|
+
}> = [];
|
|
582
|
+
|
|
583
|
+
for (const spec of specs) {
|
|
584
|
+
// 文件级 overrides:作用域为该 spec 的 includes
|
|
585
|
+
if (shouldLog(verbose, 3) && spec.overrides.length > 0) {
|
|
586
|
+
console.error(` 📋 ${spec.filename} 文件级 overrides: ${spec.overrides.join(", ")}`);
|
|
587
|
+
}
|
|
588
|
+
for (const override of spec.overrides) {
|
|
589
|
+
scopedOverrides.push({
|
|
590
|
+
override,
|
|
591
|
+
includes: spec.includes,
|
|
592
|
+
source: spec.filename,
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// 规则级 overrides:继承该 spec 的 includes 作用域
|
|
597
|
+
for (const rule of spec.rules) {
|
|
598
|
+
if (shouldLog(verbose, 3) && rule.overrides.length > 0) {
|
|
599
|
+
console.error(
|
|
600
|
+
` 📋 ${spec.filename} 规则 [${rule.id}] overrides: ${rule.overrides.join(", ")}`,
|
|
601
|
+
);
|
|
602
|
+
}
|
|
603
|
+
for (const override of rule.overrides) {
|
|
604
|
+
scopedOverrides.push({
|
|
605
|
+
override,
|
|
606
|
+
includes: spec.includes,
|
|
607
|
+
source: `${spec.filename}#${rule.id}`,
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// 输出收集结果汇总(verbose=3 时)
|
|
614
|
+
if (shouldLog(verbose, 3)) {
|
|
615
|
+
const overrideList = scopedOverrides.map((o) => o.override);
|
|
616
|
+
console.error(
|
|
617
|
+
` 🔍 收集到的 overrides 总计: ${overrideList.length > 0 ? overrideList.join(", ") : "(无)"}`,
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 快速路径:无 override 声明时直接返回原列表
|
|
622
|
+
if (scopedOverrides.length === 0) {
|
|
623
|
+
return issues;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ========== 阶段2: 过滤 issues(作用域感知) ==========
|
|
627
|
+
// 对每个 issue,只检查其文件匹配的 spec 中声明的 overrides
|
|
628
|
+
const beforeCount = issues.length;
|
|
629
|
+
const skipped: Array<{ issue: T; override: string; source: string }> = [];
|
|
630
|
+
const filtered = issues.filter((issue) => {
|
|
631
|
+
const issueFile = "file" in issue ? (issue as { file: string }).file : "";
|
|
632
|
+
|
|
633
|
+
// 查找第一个匹配的 override(需同时满足:ruleId 匹配 AND 文件在 includes 作用域内)
|
|
634
|
+
const matched = scopedOverrides.find((scoped) => {
|
|
635
|
+
// 检查 ruleId 是否匹配 override 模式
|
|
636
|
+
if (!this.matchRuleId(issue.ruleId, scoped.override)) {
|
|
637
|
+
return false;
|
|
638
|
+
}
|
|
639
|
+
// 检查 issue 文件是否在该 override 的作用域内
|
|
640
|
+
// 如果 includes 为空,表示全局作用域(匹配所有文件)
|
|
641
|
+
if (scoped.includes.length === 0) {
|
|
642
|
+
return true;
|
|
643
|
+
}
|
|
644
|
+
// 使用 micromatch 检查文件是否匹配 includes 模式
|
|
645
|
+
return issueFile && micromatch.isMatch(issueFile, scoped.includes, { matchBase: true });
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
if (matched) {
|
|
649
|
+
skipped.push({ issue, override: matched.override, source: matched.source });
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
return true;
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// ========== 阶段3: 输出过滤结果日志 ==========
|
|
656
|
+
if (skipped.length > 0 && shouldLog(verbose, 1)) {
|
|
657
|
+
console.error(` Override 过滤: ${beforeCount} -> ${filtered.length} 个问题`);
|
|
658
|
+
for (const { issue, override, source } of skipped) {
|
|
659
|
+
const file = "file" in issue ? (issue as { file: string }).file : "";
|
|
660
|
+
const line = "line" in issue ? (issue as { line: string }).line : "";
|
|
661
|
+
console.error(
|
|
662
|
+
` ❌ [${issue.ruleId}] ${file}:${line} (override: ${override} from ${source})`,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
return filtered;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* 根据变更文件的 patch 信息过滤 issues
|
|
671
|
+
* 只保留 issue 的行号在实际变更行范围内的问题
|
|
672
|
+
*/
|
|
673
|
+
filterIssuesByCommits<T extends { file: string; line: string }>(
|
|
674
|
+
issues: T[],
|
|
675
|
+
changedFiles: { filename?: string; patch?: string }[],
|
|
676
|
+
): T[] {
|
|
677
|
+
// 构建文件 -> 变更行集合的映射
|
|
678
|
+
const fileChangedLines = new Map<string, Set<number>>();
|
|
679
|
+
|
|
680
|
+
for (const file of changedFiles) {
|
|
681
|
+
if (!file.filename || !file.patch) continue;
|
|
682
|
+
const lines = this.parseChangedLinesFromPatch(file.patch);
|
|
683
|
+
fileChangedLines.set(file.filename, lines);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return issues.filter((issue) => {
|
|
687
|
+
const changedLines = fileChangedLines.get(issue.file);
|
|
688
|
+
|
|
689
|
+
// 如果没有该文件的 patch 信息,保留 issue
|
|
690
|
+
if (!changedLines || changedLines.size === 0) {
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// 解析 issue 的行号(支持单行或范围如 "123" 或 "123-125")
|
|
695
|
+
const issueLines = this.parseLineRange(issue.line);
|
|
696
|
+
|
|
697
|
+
// 检查 issue 的任意行是否在变更行范围内
|
|
698
|
+
const matches = issueLines.some((line) => changedLines.has(line));
|
|
699
|
+
if (!matches) {
|
|
700
|
+
// console.log(` Issue ${issue.file}:${issue.line} 不在变更行范围内,跳过`);
|
|
701
|
+
}
|
|
702
|
+
return matches;
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* 从 unified diff patch 中解析变更的行号(新文件中的行号)
|
|
708
|
+
*/
|
|
709
|
+
protected parseChangedLinesFromPatch(patch: string): Set<number> {
|
|
710
|
+
const changedLines = new Set<number>();
|
|
711
|
+
const lines = patch.split("\n");
|
|
712
|
+
|
|
713
|
+
let currentLine = 0;
|
|
714
|
+
|
|
715
|
+
for (const line of lines) {
|
|
716
|
+
// 解析 hunk header: @@ -oldStart,oldCount +newStart,newCount @@
|
|
717
|
+
const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
718
|
+
if (hunkMatch) {
|
|
719
|
+
currentLine = parseInt(hunkMatch[1], 10);
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
724
|
+
// 新增行
|
|
725
|
+
changedLines.add(currentLine);
|
|
726
|
+
currentLine++;
|
|
727
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
728
|
+
// 删除行不增加行号
|
|
729
|
+
} else {
|
|
730
|
+
// 上下文行
|
|
731
|
+
currentLine++;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return changedLines;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* 解析行号字符串,支持单行 "123" 或范围 "123-125"
|
|
740
|
+
* 返回行号数组,如果解析失败返回空数组
|
|
741
|
+
*/
|
|
742
|
+
parseLineRange(lineStr: string): number[] {
|
|
743
|
+
const lines: number[] = [];
|
|
744
|
+
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
745
|
+
|
|
746
|
+
if (rangeMatch) {
|
|
747
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
748
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
749
|
+
for (let i = start; i <= end; i++) {
|
|
750
|
+
lines.push(i);
|
|
751
|
+
}
|
|
752
|
+
} else {
|
|
753
|
+
const line = parseInt(lineStr, 10);
|
|
754
|
+
if (!isNaN(line)) {
|
|
755
|
+
lines.push(line);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
return lines;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* 构建 specs 的 prompt 部分
|
|
764
|
+
*/
|
|
765
|
+
buildSpecsSection(specs: ReviewSpec[]): string {
|
|
766
|
+
return specs
|
|
767
|
+
.map((spec) => {
|
|
768
|
+
const firstRule = spec.rules[0];
|
|
769
|
+
const rulesText = spec.rules
|
|
770
|
+
.slice(1)
|
|
771
|
+
.map((rule) => {
|
|
772
|
+
let text = `#### [${rule.id}] ${rule.title}\n`;
|
|
773
|
+
if (rule.description) {
|
|
774
|
+
text += `${rule.description}\n`;
|
|
775
|
+
}
|
|
776
|
+
if (rule.examples.length > 0) {
|
|
777
|
+
for (const example of rule.examples) {
|
|
778
|
+
text += `##### ${example.type === "good" ? "推荐做法 (Good)" : "不推荐做法 (Bad)"}\n`;
|
|
779
|
+
text += `\`\`\`${example.lang}\n${example.code}\n\`\`\`\n`;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return text;
|
|
783
|
+
})
|
|
784
|
+
.join("\n");
|
|
785
|
+
|
|
786
|
+
return `### ${firstRule.title}\n- 规范文件: ${spec.filename}\n- 适用扩展名: ${spec.extensions.join(", ")}\n\n${rulesText}`;
|
|
787
|
+
})
|
|
788
|
+
.join("\n\n-------------------\n\n");
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* 根据 ruleId 查找规则定义
|
|
793
|
+
* 支持精确匹配和前缀匹配
|
|
794
|
+
*/
|
|
795
|
+
findRuleById(ruleId: string, specs: ReviewSpec[]): { rule: ReviewRule; spec: ReviewSpec } | null {
|
|
796
|
+
for (const spec of specs) {
|
|
797
|
+
for (const rule of spec.rules) {
|
|
798
|
+
if (this.matchRuleId(ruleId, rule.id)) {
|
|
799
|
+
return { rule, spec };
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
return null;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* 过滤 issues,只保留 ruleId 存在于 specs 中的问题
|
|
808
|
+
*/
|
|
809
|
+
filterIssuesByRuleExistence<T extends { ruleId: string }>(issues: T[], specs: ReviewSpec[]): T[] {
|
|
810
|
+
return issues.filter((issue) => {
|
|
811
|
+
const ruleInfo = this.findRuleById(issue.ruleId, specs);
|
|
812
|
+
if (!ruleInfo) {
|
|
813
|
+
// console.log(` Issue [${issue.ruleId}] 规则不存在,跳过`);
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
return true;
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* 去重规范文件中的重复 ruleId
|
|
822
|
+
* 后加载的规则覆盖先加载的(符合配置优先级:命令行 > 配置文件 > 默认路径)
|
|
823
|
+
* @returns 去重后的 specs 数组
|
|
824
|
+
*/
|
|
825
|
+
deduplicateSpecs(specs: ReviewSpec[]): ReviewSpec[] {
|
|
826
|
+
// 记录 ruleId -> { specIndex, ruleIndex } 的映射,用于检测重复
|
|
827
|
+
const ruleIdMap = new Map<string, { specIndex: number; ruleIndex: number }>();
|
|
828
|
+
// 记录需要从每个 spec 中移除的 rule 索引
|
|
829
|
+
const rulesToRemove = new Map<number, Set<number>>();
|
|
830
|
+
|
|
831
|
+
for (let specIndex = 0; specIndex < specs.length; specIndex++) {
|
|
832
|
+
const spec = specs[specIndex];
|
|
833
|
+
for (let ruleIndex = 0; ruleIndex < spec.rules.length; ruleIndex++) {
|
|
834
|
+
const rule = spec.rules[ruleIndex];
|
|
835
|
+
const existing = ruleIdMap.get(rule.id);
|
|
836
|
+
|
|
837
|
+
if (existing) {
|
|
838
|
+
// 标记先前的规则为待移除(后加载的覆盖先加载的)
|
|
839
|
+
if (!rulesToRemove.has(existing.specIndex)) {
|
|
840
|
+
rulesToRemove.set(existing.specIndex, new Set());
|
|
841
|
+
}
|
|
842
|
+
rulesToRemove.get(existing.specIndex)!.add(existing.ruleIndex);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// 更新映射为当前规则
|
|
846
|
+
ruleIdMap.set(rule.id, { specIndex, ruleIndex });
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// 如果没有重复,直接返回原数组
|
|
851
|
+
if (rulesToRemove.size === 0) {
|
|
852
|
+
return specs;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// 构建去重后的 specs
|
|
856
|
+
const result: ReviewSpec[] = [];
|
|
857
|
+
for (let specIndex = 0; specIndex < specs.length; specIndex++) {
|
|
858
|
+
const spec = specs[specIndex];
|
|
859
|
+
const removeSet = rulesToRemove.get(specIndex);
|
|
860
|
+
|
|
861
|
+
if (!removeSet || removeSet.size === 0) {
|
|
862
|
+
result.push(spec);
|
|
863
|
+
} else {
|
|
864
|
+
const filteredRules = spec.rules.filter((_, ruleIndex) => !removeSet.has(ruleIndex));
|
|
865
|
+
if (filteredRules.length > 0) {
|
|
866
|
+
result.push({ ...spec, rules: filteredRules });
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return result;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* 格式化 issues,用规则定义的 severity 覆盖 AI 返回的值
|
|
876
|
+
*/
|
|
877
|
+
formatIssues<T extends { ruleId: string; severity?: Severity }>(
|
|
878
|
+
issues: T[],
|
|
879
|
+
{ specs, changedFiles }: { specs: ReviewSpec[]; changedFiles: ChangedFile[] },
|
|
880
|
+
): T[] {
|
|
881
|
+
// 构建 ruleId -> severity 的映射
|
|
882
|
+
const ruleSeverityMap = new Map<string, Severity>();
|
|
883
|
+
|
|
884
|
+
for (const spec of specs) {
|
|
885
|
+
for (const rule of spec.rules) {
|
|
886
|
+
// 规则级别的 severity 优先,否则使用文件级别的 severity
|
|
887
|
+
const severity = rule.severity ?? spec.severity;
|
|
888
|
+
ruleSeverityMap.set(rule.id, severity);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
return issues.map((issue) => {
|
|
893
|
+
const ruleSeverity = this.findByRuleId(issue.ruleId, ruleSeverityMap);
|
|
894
|
+
|
|
895
|
+
if (ruleSeverity && ruleSeverity !== issue.severity) {
|
|
896
|
+
return { ...issue, severity: ruleSeverity };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
return issue;
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
}
|