@spaceflow/review 0.83.0 → 2.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 +27 -0
- package/dist/index.js +214 -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 +266 -11
- package/src/review-spec/review-spec.service.ts +75 -56
- 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: ${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) : [];
|