@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.82.0",
3
+ "version": "1.0.0",
4
4
  "description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
5
5
  "license": "MIT",
6
6
  "author": "Lydanne",
@@ -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 { extractGlobsFromIncludes } from "../review-includes-filter";
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
- if (includes.length === 0) return true;
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
- type: ex.type,
139
- lang: ex.lang,
140
- code: ex.code,
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
- type: ex.type,
174
- lang: ex.lang,
175
- code: ex.code,
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
- type: ex.type,
213
- lang: ex.lang,
214
- code: ex.code,
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 { extractCodeBlockTypes, extractGlobsFromIncludes } from "./review-includes-filter";
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
- // 需先提取纯 glob(去掉 added|/modified| 前缀,过滤 code-* 空串),避免 micromatch 报错
78
+ // 使用 matchIncludes 支持 status|glob 前缀语法
79
79
  if (spec.includes.length > 0) {
80
- const globs = extractGlobsFromIncludes(spec.includes);
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 { owner, repo, prNumber, verbose, includes, files, commits: filterCommits } = context;
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 !message.startsWith("Merge ");
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);