@spaceflow/review 0.82.0 → 0.83.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 CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.82.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.81.0...@spaceflow/review@0.82.0) (2026-04-09)
4
+
5
+ ### 代码重构
6
+
7
+ * **review:** 优化 ChangedFileCollection 的防御性编程,确保数据不可变性和类型安全 ([4ce2dc8](https://github.com/Lydanne/spaceflow/commit/4ce2dc82e54df8684832bc210fe03b31a24961ea))
8
+ * **review:** 优化变更文件失效逻辑,在代码已变更时保留已 resolved 问题的状态 ([0fd947b](https://github.com/Lydanne/spaceflow/commit/0fd947b0e774aac52149fadcdbbc70f4091402f2))
9
+ * **review:** 优化统计表格格式,将有效问题细分状态合并为单行,并在修复率和解决率中显示分数详情 ([6b3fdac](https://github.com/Lydanne/spaceflow/commit/6b3fdacdae9d55557ca68b3cb2dd347ca8a0e848))
10
+ * **review:** 在 commits 为空时退化为变更行模式,并调整日志文案统一使用"变更行过滤"术语 ([fca208b](https://github.com/Lydanne/spaceflow/commit/fca208bf87fb80167700b10fecfe2a436b4dedf5))
11
+ * **review:** 将 getFileContents 方法从 ReviewIssueFilter 迁移至 ReviewSourceResolver,并在 resolve 阶段预加载文件内容 ([f861b17](https://github.com/Lydanne/spaceflow/commit/f861b17fb12a788f595229605eee1e5bfe735d27))
12
+ * **review:** 将统计表格中的"修复"术语统一改为"验收",提升状态语义准确性 ([e9782ac](https://github.com/Lydanne/spaceflow/commit/e9782ac89ef6cdc6f60d9683cb3c1cff0078644a))
13
+ * **review:** 提取 buildReviewResult 方法,将 LLM 审查和问题过滤逻辑从 execute 方法中分离 ([f981512](https://github.com/Lydanne/spaceflow/commit/f981512be9c3e8b0659eca201f67d673cd320745))
14
+ * **review:** 提取 ReviewSourceResolver 类,将源数据解析和前置过滤逻辑从 ReviewService 中分离 ([d40f23f](https://github.com/Lydanne/spaceflow/commit/d40f23f9f9c039d07e49f0248c974877c3c3fa68))
15
+ * **review:** 新增 ChangedFileCollection 类,封装变更文件集合并提供常用访问器,统一文件操作接口 ([22020f4](https://github.com/Lydanne/spaceflow/commit/22020f4c1c21ddd3adc422990e9ac3bc846ba593))
16
+ * **review:** 新增 git blame 支持,在分支比较模式下按实际 commit hash 过滤问题,并在 commits 为空时退化为变更行模式 ([ef5ba45](https://github.com/Lydanne/spaceflow/commit/ef5ba45c741ee94aa20ca18adb2836eba6692abe))
17
+ * **review:** 移除行号自动更新机制,改为在验证提示中提供原始代码片段辅助定位,并将去重逻辑从 ReviewService 上移至 ReviewResultModel.nextRound ([174b924](https://github.com/Lydanne/spaceflow/commit/174b924931c0f09e3714462a466f8844097a2af6))
18
+ * **review:** 简化规则过滤逻辑,移除冗余的 applicableSpecs 变量,统一使用 specs 表示已过滤的适用规则 ([571ad26](https://github.com/Lydanne/spaceflow/commit/571ad2656e743a091017384f31533ac67b3499d3))
19
+
20
+ ### 文档更新
21
+
22
+ * **review:** 移除 buildLineCommitMap 方法及相关文档和注释 ([92e78d2](https://github.com/Lydanne/spaceflow/commit/92e78d2905f206bbb04e6a4873cd6a25c79b3882))
23
+
24
+ ### 测试用例
25
+
26
+ * **review:** 新增 ReviewContextBuilder 和 ReviewIssueFilter 单元测试,覆盖上下文构建、问题过滤和文件内容获取等核心功能 ([432cc60](https://github.com/Lydanne/spaceflow/commit/432cc60c95a44bc6060a09be3a47f2c23aa8cd9d))
27
+
28
+ ### 其他修改
29
+
30
+ * **core:** released version 0.31.0 [no ci] ([aed2199](https://github.com/Lydanne/spaceflow/commit/aed2199c5df44a0dca6f2e992c39b040571aaf49))
31
+ * **publish:** released version 0.56.0 [no ci] ([91a3da8](https://github.com/Lydanne/spaceflow/commit/91a3da86f91e1085c1b2d9affd72ace03ebd8e93))
32
+ * **review-summary:** released version 0.50.0 [no ci] ([1e1fc71](https://github.com/Lydanne/spaceflow/commit/1e1fc71842b06732e5132c26ec91e2f4fde32542))
33
+ * **scripts:** released version 0.33.0 [no ci] ([515099f](https://github.com/Lydanne/spaceflow/commit/515099f1dd6eda61cea9494750e5a73db4f6696a))
34
+ * **shell:** released version 0.33.0 [no ci] ([c9f6615](https://github.com/Lydanne/spaceflow/commit/c9f66152b33ab14343c9060b6d8bb30552262a61))
35
+
3
36
  ## [0.81.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.80.0...@spaceflow/review@0.81.0) (2026-04-09)
4
37
 
5
38
  ### 新特性
package/dist/index.js CHANGED
@@ -5293,7 +5293,7 @@ class ReviewLlmProcessor {
5293
5293
  ({ commits, changedFiles } = await this.applyPreFilters(context, commits, changedFiles, isDirectFileMode));
5294
5294
  const headSha = prModel ? await prModel.getHeadSha() : context.headRef || "HEAD";
5295
5295
  const collectedFiles = ChangedFileCollection.from(changedFiles);
5296
- const fileContents = await this.getFileContents(context.owner, context.repo, collectedFiles.toArray(), commits, headSha, context.prNumber, isLocalMode, context.verbose);
5296
+ const fileContents = await this.getFileContents(context.owner, context.repo, collectedFiles.toArray(), commits, headSha, context.prNumber, isLocalMode, context.showAll, context.verbose);
5297
5297
  return {
5298
5298
  prModel,
5299
5299
  commits,
@@ -5435,18 +5435,20 @@ class ReviewLlmProcessor {
5435
5435
  * 2. --commits — 仅保留用户指定的 commit 及其涉及的文件
5436
5436
  * 3. --includes — glob 模式过滤文件和 commits(支持 status| 前缀语法)
5437
5437
  */ async applyPreFilters(context, commits, rawChangedFiles, isDirectFileMode) {
5438
- const { owner, repo, prNumber, verbose, includes, files, commits: filterCommits } = context;
5438
+ const { owner, repo, prNumber, verbose, includes, files, commits: filterCommits, showAll } = context;
5439
5439
  let changedFiles = ChangedFileCollection.from(rawChangedFiles);
5440
- // 0. 过滤掉 merge commit
5441
- {
5440
+ // 0. 过滤掉 merge commit(showAll=false 时启用)
5441
+ if (!showAll) {
5442
5442
  const before = commits.length;
5443
5443
  commits = commits.filter((c)=>{
5444
5444
  const message = c.commit?.message || "";
5445
- return !message.startsWith("Merge ");
5445
+ return !/^merge\b/i.test(message);
5446
5446
  });
5447
5447
  if (before !== commits.length && shouldLog(verbose, 1)) {
5448
5448
  console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
5449
5449
  }
5450
+ } else if (shouldLog(verbose, 2)) {
5451
+ console.log(` showAll=true,跳过 Merge Commit 过滤`);
5450
5452
  }
5451
5453
  // 1. 按指定的 files 过滤
5452
5454
  if (files && files.length > 0) {
@@ -5520,9 +5522,11 @@ class ReviewLlmProcessor {
5520
5522
  /**
5521
5523
  * 获取文件内容并构建行号到 commit hash 的映射
5522
5524
  * 返回 Map<filename, Array<[commitHash, lineCode]>>
5523
- */ async getFileContents(owner, repo, changedFiles, commits, ref, prNumber, isLocalMode, verbose) {
5525
+ */ async getFileContents(owner, repo, changedFiles, commits, ref, prNumber, isLocalMode, showAll, verbose) {
5524
5526
  const contents = new Map();
5525
5527
  const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
5528
+ const validCommitHashes = new Set(commits.map((c)=>c.sha?.slice(0, 7)).filter(Boolean));
5529
+ const shouldMaskUnknownChangedLines = !showAll && validCommitHashes.size > 0;
5526
5530
  if (shouldLog(verbose, 1)) {
5527
5531
  console.log(`📊 正在构建行号到变更的映射...`);
5528
5532
  }
@@ -5577,6 +5581,12 @@ class ReviewLlmProcessor {
5577
5581
  ];
5578
5582
  }
5579
5583
  const hash = blameMap?.get(lineNum) ?? latestCommitHash;
5584
+ if (shouldMaskUnknownChangedLines && !validCommitHashes.has(hash)) {
5585
+ return [
5586
+ "-------",
5587
+ line
5588
+ ];
5589
+ }
5580
5590
  return [
5581
5591
  hash,
5582
5592
  line
@@ -6014,7 +6024,11 @@ class ReviewService {
6014
6024
  console.log(`📋 找到 ${resultModel.issues.length} 个历史问题`);
6015
6025
  }
6016
6026
  // 2. 获取 commits 并填充 author 信息
6017
- const commits = await prModel.getCommits();
6027
+ const allCommits = await prModel.getCommits();
6028
+ const commits = context.showAll ? allCommits : allCommits.filter((c)=>!/^merge\b/i.test(c.commit?.message || ""));
6029
+ if (allCommits.length !== commits.length && shouldLog(verbose, 1)) {
6030
+ console.log(` 跳过 Merge Commits: ${allCommits.length} -> ${commits.length} 个`);
6031
+ }
6018
6032
  resultModel.issues = await this.contextBuilder.fillIssueAuthors(resultModel.issues, commits, owner, repo, verbose);
6019
6033
  // 3. 同步已解决的评论状态
6020
6034
  await resultModel.syncResolved();
@@ -6026,7 +6040,7 @@ class ReviewService {
6026
6040
  const changedFiles = await prModel.getFiles();
6027
6041
  const headSha = await prModel.getHeadSha();
6028
6042
  const verifySpecs = await this.issueFilter.loadSpecs(context.specSources, verbose);
6029
- const verifyFileContents = await this.sourceResolver.getFileContents(owner, repo, changedFiles, commits, headSha, prNumber, false, verbose);
6043
+ const verifyFileContents = await this.sourceResolver.getFileContents(owner, repo, changedFiles, commits, headSha, prNumber, false, context.showAll, verbose);
6030
6044
  resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(context, resultModel.issues, commits, {
6031
6045
  specs: verifySpecs,
6032
6046
  fileContents: verifyFileContents
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.82.0",
3
+ "version": "0.83.0",
4
4
  "description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
5
5
  "license": "MIT",
6
6
  "author": "Lydanne",
@@ -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", () => {
@@ -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);
@@ -650,6 +650,50 @@ describe("ReviewService", () => {
650
650
  expect(result.issues).toHaveLength(1);
651
651
  expect(result.stats).toBeDefined();
652
652
  });
653
+
654
+ it("should filter merge commits before getFileContents when verifyFixes enabled", async () => {
655
+ const existingResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
656
+ service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
657
+ gitProvider.listPullReviews.mockResolvedValue([] as any);
658
+ gitProvider.listPullReviewComments.mockResolvedValue([] as any);
659
+ gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "head1234" } } as any);
660
+ gitProvider.getPullRequestFiles.mockResolvedValue([
661
+ { filename: "a.ts", status: "modified" },
662
+ ] as any);
663
+ gitProvider.getPullRequestCommits.mockResolvedValue([
664
+ { sha: "merge1111", commit: { message: "Merge branch 'main' into feature" } },
665
+ { sha: "feat22222", commit: { message: "feat: add logic" } },
666
+ ] as any);
667
+
668
+ vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
669
+ ReviewResultModel.create(
670
+ new PullRequestModel(gitProvider as any, "o", "r", 1),
671
+ existingResult as any,
672
+ service._resultModelDeps,
673
+ ),
674
+ );
675
+ const getFileContentsSpy = vi
676
+ .spyOn(service._sourceResolver, "getFileContents")
677
+ .mockResolvedValue(new Map() as any);
678
+
679
+ const context = {
680
+ owner: "o",
681
+ repo: "r",
682
+ prNumber: 1,
683
+ ci: false,
684
+ dryRun: false,
685
+ verifyFixes: true,
686
+ specSources: ["/spec/dir"],
687
+ showAll: false,
688
+ };
689
+
690
+ await service.executeCollectOnly(context);
691
+
692
+ expect(getFileContentsSpy).toHaveBeenCalled();
693
+ const passedCommits = getFileContentsSpy.mock.calls[0][3] as any[];
694
+ expect(passedCommits).toHaveLength(1);
695
+ expect(passedCommits[0].sha).toBe("feat22222");
696
+ });
653
697
  });
654
698
 
655
699
  describe("ReviewService.execute - flush mode", () => {
@@ -496,7 +496,13 @@ export class ReviewService {
496
496
  }
497
497
 
498
498
  // 2. 获取 commits 并填充 author 信息
499
- const commits = await prModel.getCommits();
499
+ const allCommits = await prModel.getCommits();
500
+ const commits = context.showAll
501
+ ? allCommits
502
+ : allCommits.filter((c) => !/^merge\b/i.test(c.commit?.message || ""));
503
+ if (allCommits.length !== commits.length && shouldLog(verbose, 1)) {
504
+ console.log(` 跳过 Merge Commits: ${allCommits.length} -> ${commits.length} 个`);
505
+ }
500
506
  resultModel.issues = await this.contextBuilder.fillIssueAuthors(
501
507
  resultModel.issues,
502
508
  commits,
@@ -525,6 +531,7 @@ export class ReviewService {
525
531
  headSha,
526
532
  prNumber,
527
533
  false,
534
+ context.showAll,
528
535
  verbose,
529
536
  );
530
537
  resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(