@spaceflow/review 0.82.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 +45 -0
- package/dist/index.js +231 -210
- 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-issue-filter.spec.ts +52 -0
- package/src/review-llm.ts +6 -8
- package/src/review-source-resolver.ts +22 -4
- 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.spec.ts +44 -0
- package/src/review.service.ts +15 -4
- package/dist/551.js +0 -9
package/package.json
CHANGED
|
@@ -59,6 +59,17 @@ export class ChangedFileCollection implements Iterable<ChangedFile> {
|
|
|
59
59
|
return this._files.map(fn);
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* 获取指定文件的变更状态
|
|
64
|
+
*/
|
|
65
|
+
getStatus(filename: string): string | undefined {
|
|
66
|
+
if (!filename) return undefined;
|
|
67
|
+
for (const f of this._files) {
|
|
68
|
+
if (f.filename === filename) return f.status;
|
|
69
|
+
}
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
62
73
|
countByStatus(): FileStatusCount {
|
|
63
74
|
let added = 0,
|
|
64
75
|
modified = 0,
|
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"
|
|
@@ -86,6 +86,7 @@ describe("ReviewIssueFilter", () => {
|
|
|
86
86
|
getCommitsBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
87
87
|
getDiffBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
88
88
|
getFileContent: vi.fn().mockResolvedValue(""),
|
|
89
|
+
getFileBlame: vi.fn().mockResolvedValue(new Map()),
|
|
89
90
|
getFilesForCommit: vi.fn().mockResolvedValue([]),
|
|
90
91
|
getWorkingFileContent: vi.fn().mockReturnValue(""),
|
|
91
92
|
getCommitDiff: vi.fn().mockReturnValue([]),
|
|
@@ -264,6 +265,7 @@ describe("ReviewIssueFilter", () => {
|
|
|
264
265
|
"abc",
|
|
265
266
|
1,
|
|
266
267
|
false,
|
|
268
|
+
undefined,
|
|
267
269
|
3,
|
|
268
270
|
);
|
|
269
271
|
expect(result.has("test.ts")).toBe(true);
|
|
@@ -279,6 +281,56 @@ describe("ReviewIssueFilter", () => {
|
|
|
279
281
|
expect(lines![0][0]).toBe("abc1234");
|
|
280
282
|
expect(lines![1][0]).toBe("abc1234");
|
|
281
283
|
});
|
|
284
|
+
|
|
285
|
+
it("should mask merge commit line hash when showAll is false", async () => {
|
|
286
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nnew line");
|
|
287
|
+
mockGitSdkService.getFileBlame.mockResolvedValue(new Map([[2, "merge12"]]));
|
|
288
|
+
const changedFiles = [
|
|
289
|
+
{ filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+new line" },
|
|
290
|
+
];
|
|
291
|
+
const commits = [{ sha: "abc1234567890", commit: { message: "feat: add line" } }];
|
|
292
|
+
|
|
293
|
+
const result = await resolver.getFileContents(
|
|
294
|
+
"o",
|
|
295
|
+
"r",
|
|
296
|
+
changedFiles,
|
|
297
|
+
commits,
|
|
298
|
+
"abc",
|
|
299
|
+
1,
|
|
300
|
+
false,
|
|
301
|
+
false,
|
|
302
|
+
undefined,
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
const lines = result.get("test.ts");
|
|
306
|
+
expect(lines).toBeDefined();
|
|
307
|
+
expect(lines![1][0]).toBe("-------");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should keep merge commit line hash when showAll is true", async () => {
|
|
311
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nnew line");
|
|
312
|
+
mockGitSdkService.getFileBlame.mockResolvedValue(new Map([[2, "merge12"]]));
|
|
313
|
+
const changedFiles = [
|
|
314
|
+
{ filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+new line" },
|
|
315
|
+
];
|
|
316
|
+
const commits = [{ sha: "abc1234567890", commit: { message: "feat: add line" } }];
|
|
317
|
+
|
|
318
|
+
const result = await resolver.getFileContents(
|
|
319
|
+
"o",
|
|
320
|
+
"r",
|
|
321
|
+
changedFiles,
|
|
322
|
+
commits,
|
|
323
|
+
"abc",
|
|
324
|
+
1,
|
|
325
|
+
false,
|
|
326
|
+
true,
|
|
327
|
+
undefined,
|
|
328
|
+
);
|
|
329
|
+
|
|
330
|
+
const lines = result.get("test.ts");
|
|
331
|
+
expect(lines).toBeDefined();
|
|
332
|
+
expect(lines![1][0]).toBe("merge12");
|
|
333
|
+
});
|
|
282
334
|
});
|
|
283
335
|
|
|
284
336
|
describe("getChangedFilesBetweenRefs", () => {
|
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) : [];
|
|
@@ -150,6 +150,7 @@ export class ReviewSourceResolver {
|
|
|
150
150
|
headSha,
|
|
151
151
|
context.prNumber,
|
|
152
152
|
isLocalMode,
|
|
153
|
+
context.showAll,
|
|
153
154
|
context.verbose,
|
|
154
155
|
);
|
|
155
156
|
return {
|
|
@@ -313,19 +314,30 @@ export class ReviewSourceResolver {
|
|
|
313
314
|
rawChangedFiles: ChangedFile[],
|
|
314
315
|
isDirectFileMode: boolean,
|
|
315
316
|
): Promise<CommitsAndFiles> {
|
|
316
|
-
const {
|
|
317
|
+
const {
|
|
318
|
+
owner,
|
|
319
|
+
repo,
|
|
320
|
+
prNumber,
|
|
321
|
+
verbose,
|
|
322
|
+
includes,
|
|
323
|
+
files,
|
|
324
|
+
commits: filterCommits,
|
|
325
|
+
showAll,
|
|
326
|
+
} = context;
|
|
317
327
|
let changedFiles = ChangedFileCollection.from(rawChangedFiles);
|
|
318
328
|
|
|
319
|
-
// 0. 过滤掉 merge commit
|
|
320
|
-
{
|
|
329
|
+
// 0. 过滤掉 merge commit(showAll=false 时启用)
|
|
330
|
+
if (!showAll) {
|
|
321
331
|
const before = commits.length;
|
|
322
332
|
commits = commits.filter((c) => {
|
|
323
333
|
const message = c.commit?.message || "";
|
|
324
|
-
return
|
|
334
|
+
return !/^merge\b/i.test(message);
|
|
325
335
|
});
|
|
326
336
|
if (before !== commits.length && shouldLog(verbose, 1)) {
|
|
327
337
|
console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
|
|
328
338
|
}
|
|
339
|
+
} else if (shouldLog(verbose, 2)) {
|
|
340
|
+
console.log(` showAll=true,跳过 Merge Commit 过滤`);
|
|
329
341
|
}
|
|
330
342
|
|
|
331
343
|
// 1. 按指定的 files 过滤
|
|
@@ -426,10 +438,13 @@ export class ReviewSourceResolver {
|
|
|
426
438
|
ref: string,
|
|
427
439
|
prNumber?: number,
|
|
428
440
|
isLocalMode?: boolean,
|
|
441
|
+
showAll?: boolean,
|
|
429
442
|
verbose?: VerboseLevel,
|
|
430
443
|
): Promise<FileContentsMap> {
|
|
431
444
|
const contents: FileContentsMap = new Map();
|
|
432
445
|
const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
|
|
446
|
+
const validCommitHashes = new Set(commits.map((c) => c.sha?.slice(0, 7)).filter(Boolean));
|
|
447
|
+
const shouldMaskUnknownChangedLines = !showAll && validCommitHashes.size > 0;
|
|
433
448
|
|
|
434
449
|
if (shouldLog(verbose, 1)) {
|
|
435
450
|
console.log(`📊 正在构建行号到变更的映射...`);
|
|
@@ -503,6 +518,9 @@ export class ReviewSourceResolver {
|
|
|
503
518
|
return ["-------", line];
|
|
504
519
|
}
|
|
505
520
|
const hash = blameMap?.get(lineNum) ?? latestCommitHash;
|
|
521
|
+
if (shouldMaskUnknownChangedLines && !validCommitHashes.has(hash)) {
|
|
522
|
+
return ["-------", line];
|
|
523
|
+
}
|
|
506
524
|
return [hash, line];
|
|
507
525
|
});
|
|
508
526
|
contents.set(file.filename, contentLines);
|