@spaceflow/review 0.83.0 → 1.0.0
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 +12 -0
- package/dist/index.js +209 -202
- package/package.json +1 -1
- package/src/changed-file-collection.ts +11 -0
- package/src/mcp/index.ts +23 -16
- package/src/prompt/specs-section.ts +47 -0
- package/src/review-includes-filter.spec.ts +83 -0
- package/src/review-includes-filter.ts +80 -0
- package/src/review-llm.ts +6 -8
- package/src/review-spec/review-spec.service.spec.ts +229 -11
- package/src/review-spec/review-spec.service.ts +69 -55
- package/src/review-spec/types.ts +8 -2
- package/src/review.service.ts +7 -3
- package/dist/551.js +0 -9
package/src/mcp/index.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { t, z, type SpaceflowContext, type GitProviderService } from "@spaceflow
|
|
|
2
2
|
import { ReviewSpecService } from "../review-spec";
|
|
3
3
|
import { ChangedFileCollection } from "../changed-file-collection";
|
|
4
4
|
import type { ReviewConfig } from "../review.config";
|
|
5
|
-
import {
|
|
5
|
+
import { matchIncludes } from "../review-includes-filter";
|
|
6
6
|
import { join } from "path";
|
|
7
7
|
import { existsSync } from "fs";
|
|
8
8
|
|
|
@@ -115,16 +115,11 @@ export const tools = [
|
|
|
115
115
|
allSpecs,
|
|
116
116
|
ChangedFileCollection.from([{ filename: filePath }]),
|
|
117
117
|
);
|
|
118
|
-
const micromatchModule = await import("micromatch");
|
|
119
|
-
const micromatch = micromatchModule.default || micromatchModule;
|
|
120
118
|
const rules = applicableSpecs.flatMap((spec) =>
|
|
121
119
|
spec.rules
|
|
122
120
|
.filter((rule) => {
|
|
123
121
|
const includes = rule.includes || spec.includes;
|
|
124
|
-
|
|
125
|
-
const globs = extractGlobsFromIncludes(includes);
|
|
126
|
-
if (globs.length === 0) return true;
|
|
127
|
-
return micromatch.isMatch(filePath, globs, { matchBase: true });
|
|
122
|
+
return matchIncludes(includes, filePath);
|
|
128
123
|
})
|
|
129
124
|
.map((rule) => ({
|
|
130
125
|
id: rule.id,
|
|
@@ -135,9 +130,13 @@ export const tools = [
|
|
|
135
130
|
...(includeExamples && rule.examples.length > 0
|
|
136
131
|
? {
|
|
137
132
|
examples: rule.examples.map((ex) => ({
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
133
|
+
title: ex.title,
|
|
134
|
+
description: ex.description,
|
|
135
|
+
content: ex.content.map((c) => ({
|
|
136
|
+
title: c.title,
|
|
137
|
+
type: c.type,
|
|
138
|
+
description: c.description,
|
|
139
|
+
})),
|
|
141
140
|
})),
|
|
142
141
|
}
|
|
143
142
|
: {}),
|
|
@@ -170,9 +169,13 @@ export const tools = [
|
|
|
170
169
|
includes: spec.includes,
|
|
171
170
|
overrides: rule.overrides,
|
|
172
171
|
examples: rule.examples.map((ex) => ({
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
172
|
+
title: ex.title,
|
|
173
|
+
description: ex.description,
|
|
174
|
+
content: ex.content.map((c) => ({
|
|
175
|
+
title: c.title,
|
|
176
|
+
type: c.type,
|
|
177
|
+
description: c.description,
|
|
178
|
+
})),
|
|
176
179
|
})),
|
|
177
180
|
};
|
|
178
181
|
},
|
|
@@ -209,9 +212,13 @@ export const tools = [
|
|
|
209
212
|
...(includeExamples && rule.examples.length > 0
|
|
210
213
|
? {
|
|
211
214
|
examples: rule.examples.map((ex) => ({
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
title: ex.title,
|
|
216
|
+
description: ex.description,
|
|
217
|
+
content: ex.content.map((c) => ({
|
|
218
|
+
title: c.title,
|
|
219
|
+
type: c.type,
|
|
220
|
+
description: c.description,
|
|
221
|
+
})),
|
|
215
222
|
})),
|
|
216
223
|
}
|
|
217
224
|
: { hasExamples: rule.examples.length > 0 }),
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ReviewSpec, RuleExample, RuleContent } from "../review-spec/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 构建 specs 的 prompt 部分
|
|
5
|
+
*/
|
|
6
|
+
export function buildSpecsSection(specs: ReviewSpec[]): string {
|
|
7
|
+
return specs
|
|
8
|
+
.map((spec) => {
|
|
9
|
+
const firstRule = spec.rules[0];
|
|
10
|
+
const rulesText = spec.rules
|
|
11
|
+
.slice(1)
|
|
12
|
+
.map((rule) => {
|
|
13
|
+
let text = `#### [${rule.id}] ${rule.title}\n`;
|
|
14
|
+
if (rule.description) {
|
|
15
|
+
text += `${rule.description}\n`;
|
|
16
|
+
}
|
|
17
|
+
if (rule.examples.length > 0) {
|
|
18
|
+
for (const example of rule.examples) {
|
|
19
|
+
text += formatExample(example);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return text;
|
|
23
|
+
})
|
|
24
|
+
.join("\n");
|
|
25
|
+
|
|
26
|
+
return `### ${firstRule.title}\n- 规范文件: ${spec.filename}\n- 适用扩展名: ${spec.extensions.join(", ")}\n\n${rulesText}`;
|
|
27
|
+
})
|
|
28
|
+
.join("\n\n-------------------\n\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatExample(example: RuleExample): string {
|
|
32
|
+
let text = "";
|
|
33
|
+
if (example.title) {
|
|
34
|
+
text += `##### ${example.title}\n`;
|
|
35
|
+
}
|
|
36
|
+
if (example.description) {
|
|
37
|
+
text += `${example.description}\n`;
|
|
38
|
+
}
|
|
39
|
+
for (const item of example.content) {
|
|
40
|
+
text += formatContent(item);
|
|
41
|
+
}
|
|
42
|
+
return text;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatContent(item: RuleContent): string {
|
|
46
|
+
return `###### ${item.type}${item.title ? `: ${item.title}` : ""}\n${item.description}\n`;
|
|
47
|
+
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
filterFilesByIncludes,
|
|
5
5
|
extractGlobsFromIncludes,
|
|
6
6
|
extractCodeBlockTypes,
|
|
7
|
+
matchIncludes,
|
|
7
8
|
} from "./review-includes-filter";
|
|
8
9
|
|
|
9
10
|
describe("review-includes-filter", () => {
|
|
@@ -281,4 +282,86 @@ describe("review-includes-filter", () => {
|
|
|
281
282
|
expect(extractCodeBlockTypes([])).toEqual([]);
|
|
282
283
|
});
|
|
283
284
|
});
|
|
285
|
+
|
|
286
|
+
describe("matchIncludes", () => {
|
|
287
|
+
const glob = "**/*.ts";
|
|
288
|
+
|
|
289
|
+
it("includes 为空时返回 true", () => {
|
|
290
|
+
expect(matchIncludes([], "src/foo.ts")).toBe(true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("filename 为空时返回 false", () => {
|
|
294
|
+
expect(matchIncludes([glob], "")).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("不传 fileStatus 时降级为纯 glob 匹配", () => {
|
|
298
|
+
expect(matchIncludes([glob], "src/foo.ts")).toBe(true);
|
|
299
|
+
expect(matchIncludes([glob], "src/foo.vue")).toBe(false);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("不传 fileStatus 时 status 前缀降级为纯 glob 匹配", () => {
|
|
303
|
+
// added|**/*.ts 在无 status 信息时,降级为 glob **/*.ts 匹配
|
|
304
|
+
expect(matchIncludes([`added|${glob}`], "src/foo.ts")).toBe(true);
|
|
305
|
+
expect(matchIncludes([`added|${glob}`], "src/foo.vue")).toBe(false);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("无前缀 glob 不限 status,匹配所有符合的文件", () => {
|
|
309
|
+
expect(matchIncludes([glob], "src/foo.ts", "added")).toBe(true);
|
|
310
|
+
expect(matchIncludes([glob], "src/foo.ts", "modified")).toBe(true);
|
|
311
|
+
expect(matchIncludes([glob], "src/foo.ts", "removed")).toBe(true);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("added| 前缀只匹配 added 状态文件", () => {
|
|
315
|
+
expect(matchIncludes([`added|${glob}`], "src/foo.ts", "added")).toBe(true);
|
|
316
|
+
expect(matchIncludes([`added|${glob}`], "src/foo.ts", "modified")).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("modified| 前缀只匹配 modified 状态文件", () => {
|
|
320
|
+
expect(matchIncludes([`modified|${glob}`], "src/foo.ts", "modified")).toBe(true);
|
|
321
|
+
expect(matchIncludes([`modified|${glob}`], "src/foo.ts", "added")).toBe(false);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("deleted| 前缀匹配 removed 和 deleted 状态文件", () => {
|
|
325
|
+
expect(matchIncludes([`deleted|${glob}`], "src/old.ts", "removed")).toBe(true);
|
|
326
|
+
expect(matchIncludes([`deleted|${glob}`], "src/old.ts", "deleted")).toBe(true);
|
|
327
|
+
expect(matchIncludes([`deleted|${glob}`], "src/old.ts", "modified")).toBe(false);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("排除模式 ! 优先过滤", () => {
|
|
331
|
+
expect(matchIncludes([glob, "!**/*.spec.ts"], "src/foo.spec.ts", "added")).toBe(false);
|
|
332
|
+
expect(matchIncludes([glob, "!**/*.spec.ts"], "src/foo.ts", "added")).toBe(true);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("多个 status 前缀之间是 OR 关系", () => {
|
|
336
|
+
expect(matchIncludes([`added|${glob}`, `modified|${glob}`], "src/foo.ts", "added")).toBe(
|
|
337
|
+
true,
|
|
338
|
+
);
|
|
339
|
+
expect(matchIncludes([`added|${glob}`, `modified|${glob}`], "src/foo.ts", "modified")).toBe(
|
|
340
|
+
true,
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("无前缀 glob 与 status 前缀混用时任一命中即保留", () => {
|
|
345
|
+
expect(matchIncludes([glob, "added|**/*.vue"], "src/foo.ts", "modified")).toBe(true);
|
|
346
|
+
expect(matchIncludes([glob, "added|**/*.vue"], "src/foo.vue", "added")).toBe(true);
|
|
347
|
+
expect(matchIncludes([glob, "added|**/*.vue"], "src/foo.vue", "modified")).toBe(false);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it("status 内排除语法 added|!**/*.spec.ts", () => {
|
|
351
|
+
expect(matchIncludes([`added|${glob}`, "added|!**/*.spec.ts"], "src/foo.ts", "added")).toBe(
|
|
352
|
+
true,
|
|
353
|
+
);
|
|
354
|
+
expect(
|
|
355
|
+
matchIncludes([`added|${glob}`, "added|!**/*.spec.ts"], "src/foo.spec.ts", "added"),
|
|
356
|
+
).toBe(false);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("fileStatus 为 undefined 时走降级路径(纯 glob 匹配)", () => {
|
|
360
|
+
// matchIncludes 中 undefined 表示无 status 信息,降级为纯 glob 匹配
|
|
361
|
+
// 这与 filterFilesByIncludes 中 status=undefined fallback 为 modified 不同
|
|
362
|
+
// 因为 matchIncludes 用于 spec includes 场景,无 status 时应宽松匹配
|
|
363
|
+
expect(matchIncludes([`modified|${glob}`], "src/foo.ts")).toBe(true);
|
|
364
|
+
expect(matchIncludes([`added|${glob}`], "src/foo.ts")).toBe(true);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
284
367
|
});
|
|
@@ -180,6 +180,86 @@ export function extractGlobsFromIncludes(includes: string[]): string[] {
|
|
|
180
180
|
return includes.map((p) => parseIncludePattern(p).glob).filter((g) => g.length > 0);
|
|
181
181
|
}
|
|
182
182
|
|
|
183
|
+
/**
|
|
184
|
+
* 检查单个文件是否匹配 includes 模式列表,支持 `status|glob` 前缀语法。
|
|
185
|
+
*
|
|
186
|
+
* - 当 `fileStatus` 未提供时,status 前缀的 includes 降级为纯 glob 匹配(向后兼容)
|
|
187
|
+
* - 当 `fileStatus` 提供时,完整支持 `added|`/`modified|`/`deleted|` 前缀语义
|
|
188
|
+
*
|
|
189
|
+
* 算法与 `filterFilesByIncludes` 一致,但针对单文件场景优化:
|
|
190
|
+
* 1. 排除模式(`!`) 优先过滤
|
|
191
|
+
* 2. 无前缀正向 glob 匹配
|
|
192
|
+
* 3. 有 status 前缀的 glob 按文件实际 status 过滤
|
|
193
|
+
*
|
|
194
|
+
* @param includes include 模式列表
|
|
195
|
+
* @param filename 待匹配的文件名
|
|
196
|
+
* @param fileStatus 文件变更状态(如 "added"/"modified"/"removed"),不提供时降级为纯 glob
|
|
197
|
+
* @returns 是否匹配
|
|
198
|
+
*/
|
|
199
|
+
export function matchIncludes(includes: string[], filename: string, fileStatus?: string): boolean {
|
|
200
|
+
if (!includes || includes.length === 0) return true;
|
|
201
|
+
if (!filename) return false;
|
|
202
|
+
|
|
203
|
+
const parsed = includes.map(parseIncludePattern);
|
|
204
|
+
|
|
205
|
+
// 无 status 信息时降级为纯 glob 匹配(向后兼容)
|
|
206
|
+
if (!fileStatus) {
|
|
207
|
+
const globs = extractGlobsFromIncludes(includes);
|
|
208
|
+
if (globs.length === 0) return true;
|
|
209
|
+
return micromatch.isMatch(filename, globs, { matchBase: true });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const normalizedStatus = STATUS_ALIAS[fileStatus.toLowerCase()] ?? "modified";
|
|
213
|
+
|
|
214
|
+
// 排除模式(以 ! 开头),用于最终全局过滤
|
|
215
|
+
const negativeGlobs = parsed
|
|
216
|
+
.filter((p) => p.status === undefined && p.glob.startsWith("!"))
|
|
217
|
+
.map((p) => p.glob.slice(1));
|
|
218
|
+
// 无前缀的正向 globs
|
|
219
|
+
const plainGlobs = parsed
|
|
220
|
+
.filter((p) => p.status === undefined && !p.glob.startsWith("!"))
|
|
221
|
+
.map((p) => p.glob);
|
|
222
|
+
// 有 status 前缀的 patterns
|
|
223
|
+
const statusPatterns = parsed.filter((p) => p.status !== undefined);
|
|
224
|
+
|
|
225
|
+
// 最终排除:命中排除模式的文件直接过滤掉
|
|
226
|
+
if (
|
|
227
|
+
negativeGlobs.length > 0 &&
|
|
228
|
+
micromatch.isMatch(filename, negativeGlobs, { matchBase: true })
|
|
229
|
+
) {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 正向匹配:无前缀 glob
|
|
234
|
+
if (plainGlobs.length > 0 && micromatch.isMatch(filename, plainGlobs, { matchBase: true })) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// 正向匹配:有 status 前缀的 glob,按文件实际 status 过滤
|
|
239
|
+
if (statusPatterns.length > 0) {
|
|
240
|
+
const matchingStatusGlobs = statusPatterns
|
|
241
|
+
.filter(({ status }) => status === normalizedStatus)
|
|
242
|
+
.map(({ glob }) => glob);
|
|
243
|
+
if (matchingStatusGlobs.length > 0) {
|
|
244
|
+
const positiveGlobs = matchingStatusGlobs.filter((g) => !g.startsWith("!"));
|
|
245
|
+
const negativeStatusGlobs = matchingStatusGlobs
|
|
246
|
+
.filter((g) => g.startsWith("!"))
|
|
247
|
+
.map((g) => g.slice(1));
|
|
248
|
+
if (positiveGlobs.length > 0) {
|
|
249
|
+
const matchesPositive = micromatch.isMatch(filename, positiveGlobs, { matchBase: true });
|
|
250
|
+
const matchesNegative =
|
|
251
|
+
negativeStatusGlobs.length > 0 &&
|
|
252
|
+
micromatch.isMatch(filename, negativeStatusGlobs, { matchBase: true });
|
|
253
|
+
if (matchesPositive && !matchesNegative) {
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
|
|
183
263
|
/**
|
|
184
264
|
* 从 whenModifiedCode 配置中解析代码结构过滤类型。
|
|
185
265
|
* 只接受简单的类型名称,如 "function"、"class"、"interface"、"type"、"method"
|
package/src/review-llm.ts
CHANGED
|
@@ -19,11 +19,10 @@ import {
|
|
|
19
19
|
} from "./review-spec";
|
|
20
20
|
import { readdir } from "fs/promises";
|
|
21
21
|
import { dirname, extname } from "path";
|
|
22
|
-
import micromatch from "micromatch";
|
|
23
22
|
import type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./types/review-llm";
|
|
24
23
|
import { buildLinesWithNumbers, buildCommitsSection, extractCodeBlocks } from "./utils/review-llm";
|
|
25
24
|
import { ChangedFileCollection } from "./changed-file-collection";
|
|
26
|
-
import {
|
|
25
|
+
import { matchIncludes, extractCodeBlockTypes } from "./review-includes-filter";
|
|
27
26
|
import {
|
|
28
27
|
REVIEW_SCHEMA,
|
|
29
28
|
buildFileReviewPrompt,
|
|
@@ -63,8 +62,9 @@ export class ReviewLlmProcessor {
|
|
|
63
62
|
* 根据文件过滤 specs,只返回与该文件匹配的规则
|
|
64
63
|
* - 如果 spec 有 includes 配置,只有当文件名匹配 includes 模式时才包含该 spec
|
|
65
64
|
* - 如果 spec 没有 includes 配置,则按扩展名匹配
|
|
65
|
+
* - 支持 `added|`/`modified|`/`deleted|` 前缀语法,需传入 fileStatus
|
|
66
66
|
*/
|
|
67
|
-
filterSpecsForFile(specs: ReviewSpec[], filename: string): ReviewSpec[] {
|
|
67
|
+
filterSpecsForFile(specs: ReviewSpec[], filename: string, fileStatus?: string): ReviewSpec[] {
|
|
68
68
|
const ext = extname(filename).slice(1).toLowerCase();
|
|
69
69
|
if (!ext) return [];
|
|
70
70
|
|
|
@@ -75,11 +75,9 @@ export class ReviewLlmProcessor {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
// 如果有 includes 配置,检查文件名是否匹配 includes 模式
|
|
78
|
-
//
|
|
78
|
+
// 使用 matchIncludes 支持 status|glob 前缀语法
|
|
79
79
|
if (spec.includes.length > 0) {
|
|
80
|
-
|
|
81
|
-
if (globs.length === 0) return true;
|
|
82
|
-
return micromatch.isMatch(filename, globs, { matchBase: true });
|
|
80
|
+
return matchIncludes(spec.includes, filename, fileStatus);
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
// 没有 includes 配置,扩展名匹配即可
|
|
@@ -124,7 +122,7 @@ export class ReviewLlmProcessor {
|
|
|
124
122
|
const fileDirectoryInfo = await this.getFileDirectoryInfo(filename);
|
|
125
123
|
|
|
126
124
|
// 根据文件过滤 specs,只注入与当前文件匹配的规则
|
|
127
|
-
const fileSpecs = this.filterSpecsForFile(specs, filename);
|
|
125
|
+
const fileSpecs = this.filterSpecsForFile(specs, filename, file.status);
|
|
128
126
|
|
|
129
127
|
// 从全局 whenModifiedCode 配置中解析代码结构过滤类型
|
|
130
128
|
const codeBlockTypes = whenModifiedCode ? extractCodeBlockTypes(whenModifiedCode) : [];
|
|
@@ -35,7 +35,7 @@ describe("ReviewSpecService", () => {
|
|
|
35
35
|
- 排除配置文件
|
|
36
36
|
- 排除测试文件
|
|
37
37
|
|
|
38
|
-
|
|
38
|
+
#### Good: 合理的常量命名
|
|
39
39
|
\`\`\`js
|
|
40
40
|
const MAX_COUNT = 100;
|
|
41
41
|
\`\`\``;
|
|
@@ -59,10 +59,13 @@ const MAX_COUNT = 100;
|
|
|
59
59
|
expect(specs[0].rules[1].description).toContain("排除配置文件");
|
|
60
60
|
expect(specs[0].rules[1].description).toContain("排除测试文件");
|
|
61
61
|
expect(specs[0].rules[1].examples).toHaveLength(1);
|
|
62
|
-
expect(specs[0].rules[1].examples[0]).
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
expect(specs[0].rules[1].examples[0].title).toBe("");
|
|
63
|
+
expect(specs[0].rules[1].examples[0].description).toBe("");
|
|
64
|
+
expect(specs[0].rules[1].examples[0].content).toHaveLength(1);
|
|
65
|
+
expect(specs[0].rules[1].examples[0].content[0]).toEqual({
|
|
66
|
+
title: "合理的常量命名",
|
|
65
67
|
type: "good",
|
|
68
|
+
description: "const MAX_COUNT = 100;",
|
|
66
69
|
});
|
|
67
70
|
|
|
68
71
|
expect(specs[1].filename).toBe("vue.file-name.md");
|
|
@@ -309,7 +312,7 @@ const MAX_COUNT = 100;
|
|
|
309
312
|
|
|
310
313
|
> - severity \`warn\`
|
|
311
314
|
|
|
312
|
-
|
|
315
|
+
#### Good: 合理的常量命名
|
|
313
316
|
\`\`\`js
|
|
314
317
|
const MAX_COUNT = 100;
|
|
315
318
|
\`\`\``;
|
|
@@ -566,6 +569,92 @@ const MAX_COUNT = 100;
|
|
|
566
569
|
|
|
567
570
|
expect(result).toHaveLength(2);
|
|
568
571
|
});
|
|
572
|
+
|
|
573
|
+
it("should filter issues by spec includes with added| prefix when fileStatusMap provided", () => {
|
|
574
|
+
const specs = [
|
|
575
|
+
{
|
|
576
|
+
filename: "js.models.md",
|
|
577
|
+
extensions: ["js"],
|
|
578
|
+
type: "models",
|
|
579
|
+
content: "",
|
|
580
|
+
overrides: [],
|
|
581
|
+
severity: "error" as const,
|
|
582
|
+
includes: ["added|*.model.js"],
|
|
583
|
+
rules: [
|
|
584
|
+
{ id: "Js.Models", title: "Models", description: "", examples: [], overrides: [] },
|
|
585
|
+
],
|
|
586
|
+
},
|
|
587
|
+
];
|
|
588
|
+
|
|
589
|
+
const issues = [
|
|
590
|
+
{ file: "user/models/user.model.js", ruleId: "Js.Models.Rule1", reason: "test" },
|
|
591
|
+
{ file: "user/models/product.model.js", ruleId: "Js.Models.Rule2", reason: "test" },
|
|
592
|
+
];
|
|
593
|
+
|
|
594
|
+
// added 文件保留,modified 文件被 added| 前缀过滤
|
|
595
|
+
const changedFiles = ChangedFileCollection.from([
|
|
596
|
+
{ filename: "user/models/user.model.js", status: "added" },
|
|
597
|
+
{ filename: "user/models/product.model.js", status: "modified" },
|
|
598
|
+
]);
|
|
599
|
+
const result = service.filterIssuesByIncludes(issues, specs, changedFiles);
|
|
600
|
+
expect(result).toHaveLength(1);
|
|
601
|
+
expect(result[0].file).toBe("user/models/user.model.js");
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("should fall back to pure glob matching when fileStatusMap not provided", () => {
|
|
605
|
+
const specs = [
|
|
606
|
+
{
|
|
607
|
+
filename: "js.models.md",
|
|
608
|
+
extensions: ["js"],
|
|
609
|
+
type: "models",
|
|
610
|
+
content: "",
|
|
611
|
+
overrides: [],
|
|
612
|
+
severity: "error" as const,
|
|
613
|
+
includes: ["added|*.model.js"],
|
|
614
|
+
rules: [
|
|
615
|
+
{ id: "Js.Models", title: "Models", description: "", examples: [], overrides: [] },
|
|
616
|
+
],
|
|
617
|
+
},
|
|
618
|
+
];
|
|
619
|
+
|
|
620
|
+
const issues = [
|
|
621
|
+
{ file: "user/models/user.model.js", ruleId: "Js.Models.Rule1", reason: "test" },
|
|
622
|
+
{ file: "src/app.js", ruleId: "Js.Models.Rule2", reason: "test" },
|
|
623
|
+
];
|
|
624
|
+
|
|
625
|
+
// 无 status map 时,added| 前缀降级为纯 glob 匹配
|
|
626
|
+
const result = service.filterIssuesByIncludes(issues, specs);
|
|
627
|
+
expect(result).toHaveLength(1);
|
|
628
|
+
expect(result[0].file).toBe("user/models/user.model.js");
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
it("should filter issues by spec includes with modified| prefix", () => {
|
|
632
|
+
const specs = [
|
|
633
|
+
{
|
|
634
|
+
filename: "js.nest.md",
|
|
635
|
+
extensions: ["ts"],
|
|
636
|
+
type: "nest",
|
|
637
|
+
content: "",
|
|
638
|
+
overrides: [],
|
|
639
|
+
severity: "error" as const,
|
|
640
|
+
includes: ["modified|*.controller.ts"],
|
|
641
|
+
rules: [{ id: "JsTs.Nest", title: "Nest", description: "", examples: [], overrides: [] }],
|
|
642
|
+
},
|
|
643
|
+
];
|
|
644
|
+
|
|
645
|
+
const issues = [
|
|
646
|
+
{ file: "user.controller.ts", ruleId: "JsTs.Nest.Rule1", reason: "test" },
|
|
647
|
+
{ file: "app.controller.ts", ruleId: "JsTs.Nest.Rule2", reason: "test" },
|
|
648
|
+
];
|
|
649
|
+
|
|
650
|
+
const changedFiles = ChangedFileCollection.from([
|
|
651
|
+
{ filename: "user.controller.ts", status: "modified" },
|
|
652
|
+
{ filename: "app.controller.ts", status: "added" },
|
|
653
|
+
]);
|
|
654
|
+
const result = service.filterIssuesByIncludes(issues, specs, changedFiles);
|
|
655
|
+
expect(result).toHaveLength(1);
|
|
656
|
+
expect(result[0].file).toBe("user.controller.ts");
|
|
657
|
+
});
|
|
569
658
|
});
|
|
570
659
|
|
|
571
660
|
describe("matchRuleId", () => {
|
|
@@ -714,7 +803,13 @@ const MAX_COUNT = 100;
|
|
|
714
803
|
id: "JsTs.Base.Rule1",
|
|
715
804
|
title: "Rule1",
|
|
716
805
|
description: "rule desc",
|
|
717
|
-
examples: [
|
|
806
|
+
examples: [
|
|
807
|
+
{
|
|
808
|
+
title: "",
|
|
809
|
+
description: "",
|
|
810
|
+
content: [{ title: "", type: "good" as const, description: "const x = 1;" }],
|
|
811
|
+
},
|
|
812
|
+
],
|
|
718
813
|
overrides: [],
|
|
719
814
|
},
|
|
720
815
|
],
|
|
@@ -723,7 +818,7 @@ const MAX_COUNT = 100;
|
|
|
723
818
|
const result = service.buildSpecsSection(specs);
|
|
724
819
|
expect(result).toContain("基础规范");
|
|
725
820
|
expect(result).toContain("Rule1");
|
|
726
|
-
expect(result).toContain("
|
|
821
|
+
expect(result).toContain("good");
|
|
727
822
|
});
|
|
728
823
|
|
|
729
824
|
it("should handle rules without examples", () => {
|
|
@@ -1163,7 +1258,7 @@ const MAX_COUNT = 100;
|
|
|
1163
1258
|
];
|
|
1164
1259
|
const issues = [{ ruleId: "JsTs.FileName", file: "src/app.ts", line: "10" }];
|
|
1165
1260
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1166
|
-
const result = service.filterIssuesByOverrides(issues, specs, 1);
|
|
1261
|
+
const result = service.filterIssuesByOverrides(issues, specs, undefined, 1);
|
|
1167
1262
|
expect(result).toHaveLength(0);
|
|
1168
1263
|
expect(consoleSpy).toHaveBeenCalled();
|
|
1169
1264
|
consoleSpy.mockRestore();
|
|
@@ -1192,7 +1287,7 @@ const MAX_COUNT = 100;
|
|
|
1192
1287
|
];
|
|
1193
1288
|
const issues = [{ ruleId: "Other.Rule", file: "src/app.ts" }];
|
|
1194
1289
|
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
1195
|
-
service.filterIssuesByOverrides(issues, specs, 3);
|
|
1290
|
+
service.filterIssuesByOverrides(issues, specs, undefined, 3);
|
|
1196
1291
|
expect(consoleSpy).toHaveBeenCalled();
|
|
1197
1292
|
consoleSpy.mockRestore();
|
|
1198
1293
|
});
|
|
@@ -1558,14 +1653,16 @@ const MAX_COUNT = 100;
|
|
|
1558
1653
|
|
|
1559
1654
|
describe("extractExamples - bad type", () => {
|
|
1560
1655
|
it("should extract bad examples", () => {
|
|
1561
|
-
const content =
|
|
1656
|
+
const content = `#### Bad: 不合理的命名
|
|
1562
1657
|
|
|
1563
1658
|
\`\`\`ts
|
|
1564
1659
|
const bad_name = 1;
|
|
1565
1660
|
\`\`\``;
|
|
1566
1661
|
const examples = (service as any).extractExamples(content);
|
|
1567
1662
|
expect(examples).toHaveLength(1);
|
|
1568
|
-
expect(examples[0].
|
|
1663
|
+
expect(examples[0].content).toHaveLength(1);
|
|
1664
|
+
expect(examples[0].content[0].type).toBe("bad");
|
|
1665
|
+
expect(examples[0].content[0].title).toBe("不合理的命名");
|
|
1569
1666
|
});
|
|
1570
1667
|
});
|
|
1571
1668
|
|
|
@@ -1617,4 +1714,125 @@ const bad_name = 1;
|
|
|
1617
1714
|
expect(result[0].file).toBe("user.controller.ts");
|
|
1618
1715
|
});
|
|
1619
1716
|
});
|
|
1717
|
+
|
|
1718
|
+
describe("extractExamples - #### level with colon", () => {
|
|
1719
|
+
it("should extract examples from #### Good: / #### Bad: format without group", () => {
|
|
1720
|
+
const content = `#### Good: 合理的常量命名
|
|
1721
|
+
|
|
1722
|
+
\`\`\`javascript
|
|
1723
|
+
const MAX_COUNT = 100;
|
|
1724
|
+
\`\`\`
|
|
1725
|
+
|
|
1726
|
+
#### Bad: 不合理的常量命名
|
|
1727
|
+
|
|
1728
|
+
\`\`\`javascript
|
|
1729
|
+
const maxCount = 100;
|
|
1730
|
+
\`\`\``;
|
|
1731
|
+
const examples = (service as any).extractExamples(content);
|
|
1732
|
+
expect(examples).toHaveLength(1);
|
|
1733
|
+
expect(examples[0].title).toBe("");
|
|
1734
|
+
expect(examples[0].description).toBe("");
|
|
1735
|
+
expect(examples[0].content).toHaveLength(2);
|
|
1736
|
+
expect(examples[0].content[0]).toEqual({
|
|
1737
|
+
title: "合理的常量命名",
|
|
1738
|
+
type: "good",
|
|
1739
|
+
description: "const MAX_COUNT = 100;",
|
|
1740
|
+
});
|
|
1741
|
+
expect(examples[0].content[1]).toEqual({
|
|
1742
|
+
title: "不合理的常量命名",
|
|
1743
|
+
type: "bad",
|
|
1744
|
+
description: "const maxCount = 100;",
|
|
1745
|
+
});
|
|
1746
|
+
});
|
|
1747
|
+
|
|
1748
|
+
it("should parse full rule with #### examples", () => {
|
|
1749
|
+
const mockContent = `# 基础代码规范 \`[JsTs.Base]\`
|
|
1750
|
+
|
|
1751
|
+
## 常量名使用大写加下划线命名 \`[JsTs.Base.ConstUpperCase]\`
|
|
1752
|
+
|
|
1753
|
+
- 不检查 nodejs 的导包定义
|
|
1754
|
+
- 常量检查只需检查 const 声明的静态值
|
|
1755
|
+
|
|
1756
|
+
#### Good: 合理的常量命名
|
|
1757
|
+
|
|
1758
|
+
\`\`\`javascript
|
|
1759
|
+
const MAX_COUNT = 100;
|
|
1760
|
+
\`\`\`
|
|
1761
|
+
|
|
1762
|
+
#### Bad: 不合理的常量命名
|
|
1763
|
+
|
|
1764
|
+
\`\`\`javascript
|
|
1765
|
+
const maxCount = 100;
|
|
1766
|
+
\`\`\``;
|
|
1767
|
+
|
|
1768
|
+
const spec = service.parseSpecFile("js&ts.base.md", mockContent);
|
|
1769
|
+
expect(spec).not.toBeNull();
|
|
1770
|
+
expect(spec!.rules).toHaveLength(2);
|
|
1771
|
+
expect(spec!.rules[1].id).toBe("JsTs.Base.ConstUpperCase");
|
|
1772
|
+
expect(spec!.rules[1].description).toContain("不检查 nodejs");
|
|
1773
|
+
expect(spec!.rules[1].description).not.toContain("Good");
|
|
1774
|
+
expect(spec!.rules[1].examples).toHaveLength(1);
|
|
1775
|
+
expect(spec!.rules[1].examples[0].content).toHaveLength(2);
|
|
1776
|
+
expect(spec!.rules[1].examples[0].content[0].type).toBe("good");
|
|
1777
|
+
expect(spec!.rules[1].examples[0].content[1].type).toBe("bad");
|
|
1778
|
+
});
|
|
1779
|
+
|
|
1780
|
+
it("should parse multiple example groups with ### Example:", () => {
|
|
1781
|
+
const mockContent = `# 基础代码规范 \`[JsTs.Base]\`
|
|
1782
|
+
|
|
1783
|
+
## 常量名使用大写加下划线命名 \`[JsTs.Base.ConstUpperCase]\`
|
|
1784
|
+
|
|
1785
|
+
- 不检查 nodejs 的导包定义
|
|
1786
|
+
- 常量检查只需检查 const 声明的静态值
|
|
1787
|
+
|
|
1788
|
+
### Example: 下面的明明规则说明
|
|
1789
|
+
|
|
1790
|
+
#### Good: 合理的常量命名
|
|
1791
|
+
|
|
1792
|
+
\`\`\`javascript
|
|
1793
|
+
const MAX_COUNT = 100;
|
|
1794
|
+
\`\`\`
|
|
1795
|
+
|
|
1796
|
+
#### Bad: 不合理的常量命名
|
|
1797
|
+
|
|
1798
|
+
\`\`\`javascript
|
|
1799
|
+
const maxCount = 100;
|
|
1800
|
+
\`\`\`
|
|
1801
|
+
|
|
1802
|
+
### Example: 另一种场景
|
|
1803
|
+
|
|
1804
|
+
#### Good: 枚举值命名
|
|
1805
|
+
|
|
1806
|
+
\`\`\`javascript
|
|
1807
|
+
const STATUS_ACTIVE = "active";
|
|
1808
|
+
\`\`\`
|
|
1809
|
+
|
|
1810
|
+
#### Bad: 枚举值小驼峰
|
|
1811
|
+
|
|
1812
|
+
\`\`\`javascript
|
|
1813
|
+
const statusActive = "active";
|
|
1814
|
+
\`\`\``;
|
|
1815
|
+
|
|
1816
|
+
const spec = service.parseSpecFile("js&ts.base.md", mockContent);
|
|
1817
|
+
expect(spec).not.toBeNull();
|
|
1818
|
+
expect(spec!.rules[1].id).toBe("JsTs.Base.ConstUpperCase");
|
|
1819
|
+
expect(spec!.rules[1].description).toContain("不检查 nodejs");
|
|
1820
|
+
expect(spec!.rules[1].description).not.toContain("Example");
|
|
1821
|
+
expect(spec!.rules[1].examples).toHaveLength(2);
|
|
1822
|
+
|
|
1823
|
+
// 第一组
|
|
1824
|
+
expect(spec!.rules[1].examples[0].title).toBe("Example");
|
|
1825
|
+
expect(spec!.rules[1].examples[0].description).toBe("下面的明明规则说明");
|
|
1826
|
+
expect(spec!.rules[1].examples[0].content).toHaveLength(2);
|
|
1827
|
+
expect(spec!.rules[1].examples[0].content[0].type).toBe("good");
|
|
1828
|
+
expect(spec!.rules[1].examples[0].content[1].type).toBe("bad");
|
|
1829
|
+
|
|
1830
|
+
// 第二组
|
|
1831
|
+
expect(spec!.rules[1].examples[1].title).toBe("Example");
|
|
1832
|
+
expect(spec!.rules[1].examples[1].description).toBe("另一种场景");
|
|
1833
|
+
expect(spec!.rules[1].examples[1].content).toHaveLength(2);
|
|
1834
|
+
expect(spec!.rules[1].examples[1].content[0].type).toBe("good");
|
|
1835
|
+
expect(spec!.rules[1].examples[1].content[1].type).toBe("bad");
|
|
1836
|
+
});
|
|
1837
|
+
});
|
|
1620
1838
|
});
|