@spaceflow/review 0.81.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.
@@ -1,10 +1,32 @@
1
- import { vi, type Mock } from "vitest";
1
+ import { vi } from "vitest";
2
2
  import { parseChangedLinesFromPatch } from "@spaceflow/core";
3
- import { readFile } from "fs/promises";
4
- import { ReviewService, ReviewContext, ReviewPrompt } from "./review.service";
3
+ import { ReviewService, ReviewContext } from "./review.service";
5
4
  import type { ReviewOptions } from "./review.config";
6
5
  import { PullRequestModel } from "./pull-request-model";
7
6
  import { ReviewResultModel } from "./review-result-model";
7
+ import type { ReviewResult, ReviewIssue, FileSummary } from "./review-spec/types";
8
+
9
+ function mockResult(overrides: Partial<ReviewResult> = {}): ReviewResult {
10
+ return { success: true, description: "", issues: [], summary: [], round: 1, ...overrides };
11
+ }
12
+
13
+ function mockIssue(overrides: Partial<ReviewIssue> = {}): ReviewIssue {
14
+ return {
15
+ file: "",
16
+ line: "1",
17
+ code: "",
18
+ ruleId: "",
19
+ specFile: "",
20
+ reason: "",
21
+ severity: "error",
22
+ round: 1,
23
+ ...overrides,
24
+ };
25
+ }
26
+
27
+ function mockSummary(overrides: Partial<FileSummary> = {}): FileSummary {
28
+ return { file: "", resolved: 0, unresolved: 0, summary: "", ...overrides };
29
+ }
8
30
 
9
31
  vi.mock("c12");
10
32
  vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
@@ -43,8 +65,49 @@ vi.mock("openai", () => {
43
65
  };
44
66
  });
45
67
 
68
+ class TestReviewService extends ReviewService {
69
+ // 暴露 protected 成员用于测试
70
+ get _contextBuilder() {
71
+ return this.contextBuilder;
72
+ }
73
+ get _issueFilter() {
74
+ return this.issueFilter;
75
+ }
76
+ get _llmProcessor() {
77
+ return this.llmProcessor;
78
+ }
79
+ get _resultModelDeps() {
80
+ return this.resultModelDeps;
81
+ }
82
+ get _config() {
83
+ return this.config;
84
+ }
85
+ get _reviewReportService() {
86
+ return this.reviewReportService;
87
+ }
88
+ get _llmProxyService() {
89
+ return this.llmProxyService;
90
+ }
91
+ get _sourceResolver() {
92
+ return this.sourceResolver;
93
+ }
94
+
95
+ executeCollectOnly(context: Partial<ReviewContext>) {
96
+ return super.executeCollectOnly(context as ReviewContext);
97
+ }
98
+ executeDeletionOnly(context: Partial<ReviewContext>) {
99
+ return super.executeDeletionOnly(context as ReviewContext);
100
+ }
101
+ ensureClaudeCli(ci?: boolean) {
102
+ return super.ensureClaudeCli(ci);
103
+ }
104
+ resolveSourceData(context: Partial<ReviewContext>) {
105
+ return super.resolveSourceData(context as ReviewContext);
106
+ }
107
+ }
108
+
46
109
  describe("ReviewService", () => {
47
- let service: ReviewService;
110
+ let service: TestReviewService;
48
111
  let gitProvider: any;
49
112
  let configService: any;
50
113
  let mockReviewSpecService: any;
@@ -148,7 +211,7 @@ describe("ReviewService", () => {
148
211
  getAvailableAdapters: vi.fn().mockReturnValue(["claude-code", "openai"]),
149
212
  };
150
213
 
151
- service = new ReviewService(
214
+ service = new TestReviewService(
152
215
  gitProvider as any,
153
216
  configService as any,
154
217
  mockReviewSpecService as any,
@@ -216,12 +279,8 @@ describe("ReviewService", () => {
216
279
 
217
280
  describe("ReviewService.execute", () => {
218
281
  beforeEach(() => {
219
- vi.spyOn(service as any, "runLLMReview").mockResolvedValue({
220
- success: true,
221
- issues: [],
222
- summary: [],
223
- });
224
- vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
282
+ vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(mockResult());
283
+ vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
225
284
  vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(null as any);
226
285
  });
227
286
 
@@ -434,7 +493,7 @@ describe("ReviewService", () => {
434
493
  llmMode: "openai",
435
494
  whenModifiedCode: ["function", "class"],
436
495
  };
437
- const buildReviewPromptSpy = vi.spyOn(service as any, "buildReviewPrompt");
496
+ const buildReviewPromptSpy = vi.spyOn(service._llmProcessor, "buildReviewPrompt");
438
497
 
439
498
  const result = await service.execute(context);
440
499
 
@@ -543,97 +602,7 @@ describe("ReviewService", () => {
543
602
  });
544
603
  });
545
604
 
546
- describe("ReviewService.getPrNumberFromEvent", () => {
547
- const originalEnv = process.env;
548
-
549
- beforeEach(() => {
550
- process.env = { ...originalEnv };
551
- });
552
-
553
- afterEach(() => {
554
- process.env = originalEnv;
555
- });
556
-
557
- it("should return undefined if GITHUB_EVENT_PATH and GITEA_EVENT_PATH are not set", async () => {
558
- delete process.env.GITHUB_EVENT_PATH;
559
- delete process.env.GITEA_EVENT_PATH;
560
- const prNumber = await (service as any).getPrNumberFromEvent();
561
- expect(prNumber).toBeUndefined();
562
- });
563
-
564
- it("should parse prNumber from GITHUB_EVENT_PATH", async () => {
565
- const mockEventPath = "/tmp/event.json";
566
- process.env.GITHUB_EVENT_PATH = mockEventPath;
567
- const mockEventContent = JSON.stringify({ pull_request: { number: 456 } });
568
-
569
- (readFile as Mock).mockResolvedValue(mockEventContent);
570
-
571
- const prNumber = await (service as any).getPrNumberFromEvent();
572
- expect(prNumber).toBe(456);
573
- });
574
-
575
- it("should parse prNumber from GITEA_EVENT_PATH when GITHUB_EVENT_PATH is not set", async () => {
576
- delete process.env.GITHUB_EVENT_PATH;
577
- const mockEventPath = "/tmp/gitea-event.json";
578
- process.env.GITEA_EVENT_PATH = mockEventPath;
579
- const mockEventContent = JSON.stringify({ pull_request: { number: 789 } });
580
-
581
- (readFile as Mock).mockResolvedValue(mockEventContent);
582
-
583
- const prNumber = await (service as any).getPrNumberFromEvent();
584
- expect(prNumber).toBe(789);
585
- });
586
- });
587
-
588
- describe("ReviewService.runLLMReview", () => {
589
- it("should call callLLM when llmMode is claude", async () => {
590
- const callLLMSpy = vi
591
- .spyOn((service as any).llmProcessor, "callLLM")
592
- .mockResolvedValue({ issues: [], summary: [] });
593
-
594
- const mockPrompt: ReviewPrompt = {
595
- filePrompts: [{ filename: "test.ts", systemPrompt: "system", userPrompt: "user" }],
596
- };
597
-
598
- await (service as any).runLLMReview("claude-code", mockPrompt);
599
-
600
- expect(callLLMSpy).toHaveBeenCalledWith("claude-code", mockPrompt, {});
601
- });
602
-
603
- it("should call callLLM when llmMode is openai", async () => {
604
- const callLLMSpy = vi
605
- .spyOn((service as any).llmProcessor, "callLLM")
606
- .mockResolvedValue({ issues: [], summary: [] });
607
-
608
- const mockPrompt: ReviewPrompt = {
609
- filePrompts: [{ filename: "test.ts", systemPrompt: "system", userPrompt: "user" }],
610
- };
611
-
612
- await (service as any).runLLMReview("openai", mockPrompt);
613
-
614
- expect(callLLMSpy).toHaveBeenCalledWith("openai", mockPrompt, {});
615
- });
616
- });
617
-
618
605
  describe("ReviewService Logic", () => {
619
- it("normalizeIssues should split comma separated lines", () => {
620
- const issues = [
621
- {
622
- file: "test.ts",
623
- line: "10, 12",
624
- ruleId: "R1",
625
- specFile: "s1.md",
626
- reason: "r1",
627
- suggestion: "fix",
628
- } as any,
629
- ];
630
- const normalized = (service as any).llmProcessor.normalizeIssues(issues);
631
- expect(normalized).toHaveLength(2);
632
- expect(normalized[0].line).toBe("10");
633
- expect(normalized[1].line).toBe("12");
634
- expect(normalized[1].suggestion).toContain("参考 test.ts:10");
635
- });
636
-
637
606
  it("parseChangedLinesFromPatch should correctly parse additions", () => {
638
607
  const patch = `@@ -1,3 +1,4 @@
639
608
  line1
@@ -646,558 +615,25 @@ describe("ReviewService", () => {
646
615
  });
647
616
  });
648
617
 
649
- describe("ReviewService.getFileContents", () => {
650
- beforeEach(() => {
651
- mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
652
- const lines: number[] = [];
653
- const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
654
- if (rangeMatch) {
655
- const start = parseInt(rangeMatch[1], 10);
656
- const end = parseInt(rangeMatch[2], 10);
657
- for (let i = start; i <= end; i++) {
658
- lines.push(i);
659
- }
660
- } else {
661
- const line = parseInt(lineStr, 10);
662
- if (!isNaN(line)) {
663
- lines.push(line);
664
- }
665
- }
666
- return lines;
667
- });
668
- });
669
-
670
- it("should mark changed lines with commit hash from PR patch", async () => {
671
- const changedFiles = [
672
- {
673
- filename: "test.ts",
674
- status: "modified",
675
- patch: `@@ -1,3 +1,5 @@
676
- line1
677
- +new line 1
678
- +new line 2
679
- line2
680
- line3`,
681
- },
682
- ];
683
- const commits = [{ sha: "abc1234567890" }];
684
-
685
- gitProvider.getFileContent.mockResolvedValue("line1\nnew line 1\nnew line 2\nline2\nline3");
686
-
687
- const result = await (service as any).getFileContents(
688
- "owner",
689
- "repo",
690
- changedFiles,
691
- commits,
692
- "abc1234",
693
- 123,
694
- );
695
-
696
- expect(result.size).toBe(1);
697
- const fileContent = result.get("test.ts");
698
- expect(fileContent).toBeDefined();
699
- // 第 1 行未变更
700
- expect(fileContent[0][0]).toBe("-------");
701
- // 第 2、3 行是新增的
702
- expect(fileContent[1][0]).toBe("abc1234");
703
- expect(fileContent[2][0]).toBe("abc1234");
704
- // 第 4、5 行未变更
705
- expect(fileContent[3][0]).toBe("-------");
706
- expect(fileContent[4][0]).toBe("-------");
707
- });
708
-
709
- it("should handle files without patch (all lines unmarked)", async () => {
710
- const changedFiles = [
711
- {
712
- filename: "test.ts",
713
- status: "modified",
714
- // 没有 patch 字段
715
- },
716
- ];
717
- const commits = [{ sha: "abc1234567890" }];
718
-
719
- gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3");
720
-
721
- const result = await (service as any).getFileContents(
722
- "owner",
723
- "repo",
724
- changedFiles,
725
- commits,
726
- "abc1234",
727
- 123,
728
- );
729
-
730
- const fileContent = result.get("test.ts");
731
- expect(fileContent).toBeDefined();
732
- // 所有行都未标记变更
733
- expect(fileContent[0][0]).toBe("-------");
734
- expect(fileContent[1][0]).toBe("-------");
735
- expect(fileContent[2][0]).toBe("-------");
736
- });
737
-
738
- it("should mark all lines as changed for added files without patch", async () => {
739
- const changedFiles = [
740
- {
741
- filename: "new-file.ts",
742
- status: "added",
743
- additions: 3,
744
- deletions: 0,
745
- // 没有 patch 字段(Gitea API 可能不返回新增文件的完整 patch)
746
- },
747
- ];
748
- const commits = [{ sha: "abc1234567890" }];
749
-
750
- gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3");
751
-
752
- const result = await (service as any).getFileContents(
753
- "owner",
754
- "repo",
755
- changedFiles,
756
- commits,
757
- "abc1234",
758
- 123,
759
- );
760
-
761
- const fileContent = result.get("new-file.ts");
762
- expect(fileContent).toBeDefined();
763
- // 新增文件的所有行都应该标记为变更
764
- expect(fileContent[0][0]).toBe("abc1234");
765
- expect(fileContent[1][0]).toBe("abc1234");
766
- expect(fileContent[2][0]).toBe("abc1234");
767
- });
768
-
769
- it("should skip deleted files", async () => {
770
- const changedFiles = [
771
- {
772
- filename: "deleted.ts",
773
- status: "deleted",
774
- },
775
- ];
776
- const commits = [{ sha: "abc1234567890" }];
777
-
778
- const result = await (service as any).getFileContents(
779
- "owner",
780
- "repo",
781
- changedFiles,
782
- commits,
783
- "abc1234",
784
- 123,
785
- );
786
-
787
- expect(result.size).toBe(0);
788
- });
789
- });
790
-
791
- describe("ReviewService.getChangedFilesBetweenRefs", () => {
792
- it("should return files with patch from getDiffBetweenRefs", async () => {
793
- const diffFiles = [
794
- {
795
- filename: "test.ts",
796
- patch: "@@ -1,3 +1,5 @@\n line1\n+new line",
797
- },
798
- ];
799
- const statusFiles = [
800
- {
801
- filename: "test.ts",
802
- status: "modified",
803
- },
804
- ];
805
-
806
- mockGitSdkService.getDiffBetweenRefs.mockResolvedValue(diffFiles);
807
- mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue(statusFiles);
808
-
809
- const result = await (service as any).getChangedFilesBetweenRefs(
810
- "owner",
811
- "repo",
812
- "main",
813
- "feature",
814
- );
815
-
816
- expect(result).toHaveLength(1);
817
- expect(result[0].filename).toBe("test.ts");
818
- expect(result[0].status).toBe("modified");
819
- expect(result[0].patch).toBe("@@ -1,3 +1,5 @@\n line1\n+new line");
820
- });
821
-
822
- it("should use default status when file not in statusFiles", async () => {
823
- const diffFiles = [
824
- {
825
- filename: "new.ts",
826
- patch: "@@ -0,0 +1,3 @@\n+line1",
827
- },
828
- ];
829
- const statusFiles: any[] = [];
830
-
831
- mockGitSdkService.getDiffBetweenRefs.mockResolvedValue(diffFiles);
832
- mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue(statusFiles);
833
-
834
- const result = await (service as any).getChangedFilesBetweenRefs(
835
- "owner",
836
- "repo",
837
- "main",
838
- "feature",
839
- );
840
-
841
- expect(result).toHaveLength(1);
842
- expect(result[0].filename).toBe("new.ts");
843
- expect(result[0].status).toBe("modified");
844
- });
845
- });
846
-
847
- describe("ReviewService.filterIssuesByValidCommits", () => {
848
- beforeEach(() => {
849
- mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
850
- const lines: number[] = [];
851
- const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
852
- if (rangeMatch) {
853
- const start = parseInt(rangeMatch[1], 10);
854
- const end = parseInt(rangeMatch[2], 10);
855
- for (let i = start; i <= end; i++) {
856
- lines.push(i);
857
- }
858
- } else {
859
- const line = parseInt(lineStr, 10);
860
- if (!isNaN(line)) {
861
- lines.push(line);
862
- }
863
- }
864
- return lines;
865
- });
866
- });
867
-
868
- it("should filter out issues on non-changed lines", () => {
869
- const issues = [
870
- {
871
- file: "test.ts",
872
- line: "1",
873
- ruleId: "R1",
874
- specFile: "s1.md",
875
- reason: "issue on unchanged line",
876
- },
877
- {
878
- file: "test.ts",
879
- line: "2",
880
- ruleId: "R2",
881
- specFile: "s1.md",
882
- reason: "issue on changed line",
883
- },
884
- ];
885
- const commits = [{ sha: "abc1234567890" }];
886
- const fileContents = new Map([
887
- [
888
- "test.ts",
889
- [
890
- ["-------", "line1"],
891
- ["abc1234", "new line"],
892
- ["-------", "line2"],
893
- ],
894
- ],
895
- ]);
896
-
897
- const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
898
-
899
- expect(result).toHaveLength(1);
900
- expect(result[0].line).toBe("2");
901
- });
902
-
903
- it("should keep issues when file not in fileContents", () => {
904
- const issues = [
905
- { file: "unknown.ts", line: "1", ruleId: "R1", specFile: "s1.md", reason: "issue" },
906
- ];
907
- const commits = [{ sha: "abc1234567890" }];
908
- const fileContents = new Map();
909
-
910
- const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
911
-
912
- expect(result).toHaveLength(1);
913
- });
914
-
915
- it("should keep issues with range if any line is changed", () => {
916
- const issues = [
917
- { file: "test.ts", line: "1-3", ruleId: "R1", specFile: "s1.md", reason: "range issue" },
918
- ];
919
- const commits = [{ sha: "abc1234567890" }];
920
- const fileContents = new Map([
921
- [
922
- "test.ts",
923
- [
924
- ["-------", "line1"],
925
- ["abc1234", "new line"],
926
- ["-------", "line3"],
927
- ],
928
- ],
929
- ]);
930
-
931
- const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
932
-
933
- expect(result).toHaveLength(1);
934
- });
935
-
936
- it("should filter out issues with range if no line is changed", () => {
937
- const issues = [
938
- { file: "test.ts", line: "1-3", ruleId: "R1", specFile: "s1.md", reason: "range issue" },
939
- ];
940
- const commits = [{ sha: "abc1234567890" }];
941
- const fileContents = new Map([
942
- [
943
- "test.ts",
944
- [
945
- ["-------", "line1"],
946
- ["-------", "line2"],
947
- ["-------", "line3"],
948
- ],
949
- ],
950
- ]);
951
-
952
- const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
953
-
954
- expect(result).toHaveLength(0);
955
- });
956
- });
957
-
958
- describe("ReviewService.resolveAnalyzeDeletions", () => {
959
- it("should return boolean directly", () => {
960
- expect(
961
- (service as any).resolveAnalyzeDeletions(true, { ci: false, hasPrNumber: false }),
962
- ).toBe(true);
963
- expect((service as any).resolveAnalyzeDeletions(false, { ci: true, hasPrNumber: true })).toBe(
964
- false,
965
- );
966
- });
967
-
968
- it("should resolve 'ci' mode", () => {
969
- expect((service as any).resolveAnalyzeDeletions("ci", { ci: true, hasPrNumber: false })).toBe(
970
- true,
971
- );
972
- expect(
973
- (service as any).resolveAnalyzeDeletions("ci", { ci: false, hasPrNumber: false }),
974
- ).toBe(false);
975
- });
976
-
977
- it("should resolve 'pr' mode", () => {
978
- expect((service as any).resolveAnalyzeDeletions("pr", { ci: false, hasPrNumber: true })).toBe(
979
- true,
980
- );
981
- expect(
982
- (service as any).resolveAnalyzeDeletions("pr", { ci: false, hasPrNumber: false }),
983
- ).toBe(false);
984
- });
985
-
986
- it("should resolve 'terminal' mode", () => {
987
- expect(
988
- (service as any).resolveAnalyzeDeletions("terminal", { ci: false, hasPrNumber: false }),
989
- ).toBe(true);
990
- expect(
991
- (service as any).resolveAnalyzeDeletions("terminal", { ci: true, hasPrNumber: false }),
992
- ).toBe(false);
993
- });
994
-
995
- it("should return false for unknown mode", () => {
996
- expect(
997
- (service as any).resolveAnalyzeDeletions("unknown", { ci: false, hasPrNumber: false }),
998
- ).toBe(false);
999
- });
1000
- });
1001
-
1002
- describe("ReviewService.filterDuplicateIssues", () => {
1003
- it("should filter issues that exist in valid existing issues", () => {
1004
- const newIssues = [
1005
- { file: "a.ts", line: "1", ruleId: "R1" },
1006
- { file: "b.ts", line: "2", ruleId: "R2" },
1007
- ];
1008
- const existingIssues = [{ file: "a.ts", line: "1", ruleId: "R1", valid: "true" }];
1009
- const result = (service as any).filterDuplicateIssues(newIssues, existingIssues);
1010
- expect(result.filteredIssues).toHaveLength(1);
1011
- expect(result.filteredIssues[0].file).toBe("b.ts");
1012
- expect(result.skippedCount).toBe(1);
1013
- });
1014
-
1015
- it("should also filter invalid existing issues to prevent repeated reporting", () => {
1016
- const newIssues = [{ file: "a.ts", line: "1", ruleId: "R1" }];
1017
- const existingIssues = [{ file: "a.ts", line: "1", ruleId: "R1", valid: "false" }];
1018
- const result = (service as any).filterDuplicateIssues(newIssues, existingIssues);
1019
- expect(result.filteredIssues).toHaveLength(0);
1020
- expect(result.skippedCount).toBe(1);
1021
- });
1022
- });
1023
-
1024
- describe("ReviewService.normalizeFilePaths", () => {
1025
- it("should return undefined for empty array", () => {
1026
- expect((service as any).normalizeFilePaths([])).toEqual([]);
1027
- });
1028
-
1029
- it("should return undefined for undefined input", () => {
1030
- expect((service as any).normalizeFilePaths(undefined)).toBeUndefined();
1031
- });
1032
-
1033
- it("should keep relative paths as-is", () => {
1034
- const result = (service as any).normalizeFilePaths(["src/app.ts", "lib/util.ts"]);
1035
- expect(result).toEqual(["src/app.ts", "lib/util.ts"]);
1036
- });
1037
- });
1038
-
1039
- describe("ReviewService.fillIssueCode", () => {
1040
- beforeEach(() => {
1041
- mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
1042
- const lines: number[] = [];
1043
- const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
1044
- if (rangeMatch) {
1045
- const start = parseInt(rangeMatch[1], 10);
1046
- const end = parseInt(rangeMatch[2], 10);
1047
- for (let i = start; i <= end; i++) {
1048
- lines.push(i);
1049
- }
1050
- } else {
1051
- const line = parseInt(lineStr, 10);
1052
- if (!isNaN(line)) {
1053
- lines.push(line);
1054
- }
1055
- }
1056
- return lines;
1057
- });
1058
- });
1059
-
1060
- it("should fill code from file contents", async () => {
1061
- const issues = [{ file: "test.ts", line: "2" }];
1062
- const fileContents = new Map([
1063
- [
1064
- "test.ts",
1065
- [
1066
- ["-------", "line1"],
1067
- ["abc1234", "line2"],
1068
- ["-------", "line3"],
1069
- ],
1070
- ],
1071
- ]);
1072
- const result = await (service as any).fillIssueCode(issues, fileContents);
1073
- expect(result[0].code).toBe("line2");
1074
- });
1075
-
1076
- it("should handle range lines", async () => {
1077
- const issues = [{ file: "test.ts", line: "1-2" }];
1078
- const fileContents = new Map([
1079
- [
1080
- "test.ts",
1081
- [
1082
- ["-------", "line1"],
1083
- ["abc1234", "line2"],
1084
- ["-------", "line3"],
1085
- ],
1086
- ],
1087
- ]);
1088
- const result = await (service as any).fillIssueCode(issues, fileContents);
1089
- expect(result[0].code).toBe("line1\nline2");
1090
- });
1091
-
1092
- it("should return issue unchanged if file not found", async () => {
1093
- const issues = [{ file: "missing.ts", line: "1" }];
1094
- const result = await (service as any).fillIssueCode(issues, new Map());
1095
- expect(result[0].code).toBeUndefined();
1096
- });
1097
-
1098
- it("should return issue unchanged if line out of range", async () => {
1099
- const issues = [{ file: "test.ts", line: "999" }];
1100
- const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
1101
- const result = await (service as any).fillIssueCode(issues, fileContents);
1102
- expect(result[0].code).toBeUndefined();
1103
- });
1104
-
1105
- it("should return issue unchanged if line is NaN", async () => {
1106
- const issues = [{ file: "test.ts", line: "abc" }];
1107
- const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
1108
- const result = await (service as any).fillIssueCode(issues, fileContents);
1109
- expect(result[0].code).toBeUndefined();
1110
- });
1111
- });
1112
-
1113
- describe("ReviewService.fillIssueAuthors", () => {
1114
- it("should fill author from commit with platform user", async () => {
1115
- const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
1116
- const commits = [
1117
- {
1118
- sha: "abc1234567890",
1119
- author: { id: 1, login: "dev1" },
1120
- commit: { author: { name: "Dev", email: "dev@test.com" } },
1121
- },
1122
- ];
1123
- const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
1124
- expect(result[0].author.login).toBe("dev1");
1125
- });
1126
-
1127
- it("should use default author when commit not matched", async () => {
1128
- const issues = [{ file: "test.ts", line: "1", commit: "zzz9999" }];
1129
- const commits = [
1130
- {
1131
- sha: "abc1234567890",
1132
- author: { id: 1, login: "dev1" },
1133
- commit: { author: { name: "Dev", email: "dev@test.com" } },
1134
- },
1135
- ];
1136
- const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
1137
- expect(result[0].author.login).toBe("dev1");
1138
- });
1139
-
1140
- it("should keep existing author", async () => {
1141
- const issues = [{ file: "test.ts", line: "1", author: { id: "99", login: "existing" } }];
1142
- const commits = [{ sha: "abc1234567890", author: { id: 1, login: "dev1" } }];
1143
- const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
1144
- expect(result[0].author.login).toBe("existing");
1145
- });
1146
-
1147
- it("should use git author name when no platform user", async () => {
1148
- const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
1149
- const commits = [
1150
- {
1151
- sha: "abc1234567890",
1152
- author: null,
1153
- committer: null,
1154
- commit: { author: { name: "GitUser", email: "git@test.com" } },
1155
- },
1156
- ];
1157
- const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
1158
- expect(result[0].author.login).toBe("GitUser");
1159
- });
1160
-
1161
- it("should mark invalid when existing author but ------- commit hash", async () => {
1162
- const issues = [
1163
- { file: "test.ts", line: "1", commit: "-------", author: { id: "1", login: "dev1" } },
1164
- ];
1165
- const result = await (service as any).fillIssueAuthors(issues, [], "o", "r");
1166
- expect(result[0].commit).toBeUndefined();
1167
- expect(result[0].valid).toBe("false");
1168
- });
1169
-
1170
- it("should handle issues with ------- commit hash", async () => {
1171
- const issues = [{ file: "test.ts", line: "1", commit: "-------" }];
1172
- const commits = [
1173
- { sha: "abc1234567890", author: { id: 1, login: "dev1" }, commit: { author: {} } },
1174
- ];
1175
- const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
1176
- expect(result[0].commit).toBeUndefined();
1177
- expect(result[0].valid).toBe("false");
1178
- });
1179
- });
1180
-
1181
618
  describe("ReviewService.executeCollectOnly", () => {
1182
619
  it("should return empty result when no existing review", async () => {
1183
620
  gitProvider.listPullReviews.mockResolvedValue([] as any);
1184
621
  const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
1185
- const result = await (service as any).executeCollectOnly(context);
622
+ const result = await service.executeCollectOnly(context);
1186
623
  expect(result.success).toBe(true);
1187
624
  expect(result.issues).toEqual([]);
1188
625
  });
1189
626
 
1190
627
  it("should throw when no prNumber", async () => {
1191
628
  const context = { owner: "o", repo: "r", ci: false, dryRun: false };
1192
- await expect((service as any).executeCollectOnly(context)).rejects.toThrow(
629
+ await expect(service.executeCollectOnly(context)).rejects.toThrow(
1193
630
  "collectOnly 模式必须指定 PR 编号",
1194
631
  );
1195
632
  });
1196
633
 
1197
634
  it("should collect and return existing review result", async () => {
1198
- const mockResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
1199
- const mockReviewReportService = (service as any).reviewReportService;
1200
- mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
635
+ const existingResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
636
+ service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
1201
637
  gitProvider.listPullReviews.mockResolvedValue([] as any);
1202
638
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
1203
639
  gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
@@ -1205,22 +641,65 @@ describe("ReviewService", () => {
1205
641
  vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
1206
642
  ReviewResultModel.create(
1207
643
  new PullRequestModel(gitProvider as any, "o", "r", 1),
1208
- mockResult as any,
1209
- (service as any).resultModelDeps,
644
+ existingResult as any,
645
+ service._resultModelDeps,
1210
646
  ),
1211
647
  );
1212
648
  const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
1213
- const result = await (service as any).executeCollectOnly(context);
649
+ const result = await service.executeCollectOnly(context);
1214
650
  expect(result.issues).toHaveLength(1);
1215
651
  expect(result.stats).toBeDefined();
1216
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
+ });
1217
697
  });
1218
698
 
1219
699
  describe("ReviewService.execute - flush mode", () => {
1220
700
  it("should route to executeCollectOnly when flush is true", async () => {
1221
- const mockResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
1222
- const mockReviewReportService = (service as any).reviewReportService;
1223
- mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
701
+ const flushResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
702
+ service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
1224
703
  gitProvider.listPullReviews.mockResolvedValue([] as any);
1225
704
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
1226
705
  gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
@@ -1228,8 +707,8 @@ describe("ReviewService", () => {
1228
707
  vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
1229
708
  ReviewResultModel.create(
1230
709
  new PullRequestModel(gitProvider as any, "o", "r", 1),
1231
- mockResult as any,
1232
- (service as any).resultModelDeps,
710
+ flushResult as any,
711
+ service._resultModelDeps,
1233
712
  ),
1234
713
  );
1235
714
  const context = {
@@ -1250,14 +729,11 @@ describe("ReviewService", () => {
1250
729
  describe("ReviewService.executeDeletionOnly", () => {
1251
730
  it("should throw when no llmMode", async () => {
1252
731
  const context = { owner: "o", repo: "r", prNumber: 1, ci: false, dryRun: false };
1253
- await expect((service as any).executeDeletionOnly(context)).rejects.toThrow(
1254
- "必须指定 LLM 类型",
1255
- );
732
+ await expect(service.executeDeletionOnly(context)).rejects.toThrow("必须指定 LLM 类型");
1256
733
  });
1257
734
 
1258
735
  it("should execute deletion analysis with PR", async () => {
1259
- const mockReviewReportService = (service as any).reviewReportService;
1260
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
736
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1261
737
  mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
1262
738
  issues: [],
1263
739
  summary: "ok",
@@ -1273,9 +749,8 @@ describe("ReviewService", () => {
1273
749
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
1274
750
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
1275
751
  gitProvider.createIssueComment.mockResolvedValue({} as any);
1276
- const configReader = (service as any).config;
1277
- configReader.getPluginConfig.mockReturnValue({});
1278
- const context = {
752
+ vi.mocked(service._config.getPluginConfig).mockReturnValue({});
753
+ const context: Partial<ReviewContext> = {
1279
754
  owner: "o",
1280
755
  repo: "r",
1281
756
  prNumber: 1,
@@ -1285,14 +760,13 @@ describe("ReviewService", () => {
1285
760
  deletionAnalysisMode: "openai",
1286
761
  verbose: 1,
1287
762
  };
1288
- const result = await (service as any).executeDeletionOnly(context);
763
+ const result = await service.executeDeletionOnly(context);
1289
764
  expect(result.success).toBe(true);
1290
765
  expect(result.deletionImpact).toBeDefined();
1291
766
  });
1292
767
 
1293
768
  it("should post comment in CI mode for deletionOnly", async () => {
1294
- const mockReviewReportService = (service as any).reviewReportService;
1295
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
769
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1296
770
  mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
1297
771
  issues: [],
1298
772
  summary: "ok",
@@ -1308,9 +782,8 @@ describe("ReviewService", () => {
1308
782
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
1309
783
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
1310
784
  gitProvider.createIssueComment.mockResolvedValue({} as any);
1311
- const configReader = (service as any).config;
1312
- configReader.getPluginConfig.mockReturnValue({});
1313
- const context = {
785
+ vi.mocked(service._config.getPluginConfig).mockReturnValue({});
786
+ const context: Partial<ReviewContext> = {
1314
787
  owner: "o",
1315
788
  repo: "r",
1316
789
  prNumber: 1,
@@ -1320,7 +793,7 @@ describe("ReviewService", () => {
1320
793
  deletionAnalysisMode: "openai",
1321
794
  verbose: 1,
1322
795
  };
1323
- const result = await (service as any).executeDeletionOnly(context);
796
+ const result = await service.executeDeletionOnly(context);
1324
797
  expect(result.success).toBe(true);
1325
798
  expect(gitProvider.createIssueComment).toHaveBeenCalled();
1326
799
  });
@@ -1352,7 +825,7 @@ describe("ReviewService", () => {
1352
825
 
1353
826
  it("should auto-detect prNumber from event in CI mode", async () => {
1354
827
  configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
1355
- vi.spyOn((service as any).contextBuilder, "getPrNumberFromEvent").mockResolvedValue(42);
828
+ vi.spyOn(service._contextBuilder, "getPrNumberFromEvent").mockResolvedValue(42);
1356
829
  gitProvider.getPullRequest.mockResolvedValue({ title: "feat: test" } as any);
1357
830
  const options = { dryRun: false, ci: true, verbose: 1 };
1358
831
  const context = await service.getContextFromEnv(options as any);
@@ -1433,8 +906,7 @@ describe("ReviewService", () => {
1433
906
 
1434
907
  it("should merge references from options and config", async () => {
1435
908
  configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
1436
- const configReader = (service as any).config;
1437
- configReader.getPluginConfig.mockReturnValue({ references: ["config-ref"] });
909
+ vi.mocked(service._config.getPluginConfig).mockReturnValue({ references: ["config-ref"] });
1438
910
  const options = { dryRun: false, ci: false, references: ["opt-ref"] };
1439
911
  const context = await service.getContextFromEnv(options as any);
1440
912
  expect(context.specSources).toContain("opt-ref");
@@ -1453,17 +925,7 @@ describe("ReviewService", () => {
1453
925
  describe("ReviewService.ensureClaudeCli", () => {
1454
926
  it("should not throw when claude is installed", async () => {
1455
927
  vi.spyOn(require("child_process"), "execSync").mockImplementation(() => Buffer.from("1.0.0"));
1456
- await expect((service as any).ensureClaudeCli()).resolves.toBeUndefined();
1457
- });
1458
- });
1459
-
1460
- describe("ReviewService.getCommitsBetweenRefs", () => {
1461
- it("should return commits from git sdk", async () => {
1462
- mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
1463
- { sha: "abc", commit: { message: "fix" } },
1464
- ]);
1465
- const result = await (service as any).getCommitsBetweenRefs("main", "feature");
1466
- expect(result).toHaveLength(1);
928
+ await expect(service.ensureClaudeCli()).resolves.toBeUndefined();
1467
929
  });
1468
930
  });
1469
931
 
@@ -1479,11 +941,11 @@ describe("ReviewService", () => {
1479
941
  localMode: false,
1480
942
  };
1481
943
 
1482
- const result = await (service as any).resolveSourceData(context);
944
+ const result = await service.resolveSourceData(context);
1483
945
 
1484
946
  expect(result.isDirectFileMode).toBe(true);
1485
947
  expect(result.isLocalMode).toBe(true);
1486
- expect(result.changedFiles).toEqual([
948
+ expect(result.changedFiles.toArray()).toEqual([
1487
949
  { filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
1488
950
  ]);
1489
951
  expect(mockGitSdkService.getUncommittedFiles).not.toHaveBeenCalled();
@@ -1502,138 +964,24 @@ describe("ReviewService", () => {
1502
964
  localMode: false,
1503
965
  };
1504
966
 
1505
- const result = await (service as any).resolveSourceData(context);
967
+ const result = await service.resolveSourceData(context);
1506
968
 
1507
969
  expect(result.isDirectFileMode).toBe(true);
1508
- expect(result.changedFiles).toEqual([
970
+ expect(result.changedFiles.toArray()).toEqual([
1509
971
  { filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
1510
972
  ]);
1511
973
  });
1512
974
  });
1513
975
 
1514
- describe("ReviewService.getFilesForCommit", () => {
1515
- it("should return files from git sdk", async () => {
1516
- mockGitSdkService.getFilesForCommit.mockResolvedValue([
1517
- { filename: "a.ts", status: "modified" },
1518
- ]);
1519
- const result = await (service as any).getFilesForCommit("abc123");
1520
- expect(result).toHaveLength(1);
1521
- });
1522
- });
1523
-
1524
- describe("ReviewService.buildReviewPrompt", () => {
1525
- it("should build prompts for changed files", async () => {
1526
- const specs = [{ extensions: ["ts"], includes: [], rules: [{ id: "R1" }] }];
1527
- const changedFiles = [{ filename: "test.ts", status: "modified" }];
1528
- const fileContents = new Map([
1529
- [
1530
- "test.ts",
1531
- [
1532
- ["abc1234", "const x = 1;"],
1533
- ["-------", "const y = 2;"],
1534
- ],
1535
- ],
1536
- ]);
1537
- const commits = [{ sha: "abc1234567890", commit: { message: "fix" } }];
1538
- const result = await (service as any).buildReviewPrompt(
1539
- specs,
1540
- changedFiles,
1541
- fileContents,
1542
- commits,
1543
- );
1544
- expect(result.filePrompts).toHaveLength(1);
1545
- expect(result.filePrompts[0].filename).toBe("test.ts");
1546
- expect(result.filePrompts[0].userPrompt).toContain("test.ts");
1547
- expect(result.filePrompts[0].systemPrompt).toContain("代码审查专家");
1548
- });
1549
-
1550
- it("should skip deleted files", async () => {
1551
- const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
1552
- const changedFiles = [{ filename: "deleted.ts", status: "deleted" }];
1553
- const result = await (service as any).buildReviewPrompt(specs, changedFiles, new Map(), []);
1554
- expect(result.filePrompts).toHaveLength(0);
1555
- });
1556
-
1557
- it("should handle missing file contents", async () => {
1558
- const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
1559
- const changedFiles = [{ filename: "test.ts", status: "modified" }];
1560
- const result = await (service as any).buildReviewPrompt(specs, changedFiles, new Map(), []);
1561
- expect(result.filePrompts).toHaveLength(1);
1562
- expect(result.filePrompts[0].userPrompt).toContain("无法获取内容");
1563
- });
1564
-
1565
- it("should include existing result in prompt", async () => {
1566
- const specs = [{ extensions: ["ts"], includes: [], rules: [] }];
1567
- const changedFiles = [{ filename: "test.ts", status: "modified" }];
1568
- const fileContents = new Map([["test.ts", [["-------", "code"]]]]);
1569
- const existingResult = {
1570
- issues: [{ file: "test.ts", line: "1", ruleId: "R1", reason: "bad code" }],
1571
- summary: [{ file: "test.ts", summary: "has issues" }],
1572
- };
1573
- const result = await (service as any).buildReviewPrompt(
1574
- specs,
1575
- changedFiles,
1576
- fileContents,
1577
- [],
1578
- existingResult,
1579
- );
1580
- expect(result.filePrompts[0].userPrompt).toContain("bad code");
1581
- });
1582
- });
1583
-
1584
- describe("ReviewService.generatePrDescription", () => {
1585
- it("should generate description from LLM", async () => {
1586
- const llmProxy = (service as any).llmProxyService;
1587
- const mockStream = (async function* () {
1588
- yield { type: "text", content: "# Feat: 新功能\n\n详细描述" };
1589
- })();
1590
- llmProxy.chatStream.mockReturnValue(mockStream);
1591
- const commits = [{ sha: "abc123", commit: { message: "feat: add" } }];
1592
- const changedFiles = [{ filename: "a.ts", status: "modified" }];
1593
- const result = await (service as any).generatePrDescription(commits, changedFiles, "openai");
1594
- expect(result.title).toBe("Feat: 新功能");
1595
- expect(result.description).toContain("详细描述");
1596
- });
1597
-
1598
- it("should fallback on LLM error", async () => {
1599
- const llmProxy = (service as any).llmProxyService;
1600
- const mockStream = (async function* () {
1601
- yield { type: "error", message: "fail" };
1602
- })();
1603
- llmProxy.chatStream.mockReturnValue(mockStream);
1604
- const commits = [{ sha: "abc123", commit: { message: "feat: add" } }];
1605
- const changedFiles = [{ filename: "a.ts", status: "modified" }];
1606
- const result = await (service as any).generatePrDescription(commits, changedFiles, "openai");
1607
- expect(result.title).toBeDefined();
1608
- });
1609
-
1610
- it("should include code changes section when fileContents provided", async () => {
1611
- const llmProxy = (service as any).llmProxyService;
1612
- const mockStream = (async function* () {
1613
- yield { type: "text", content: "Feat: test\n\ndesc" };
1614
- })();
1615
- llmProxy.chatStream.mockReturnValue(mockStream);
1616
- const commits = [{ sha: "abc123", commit: { message: "feat" } }];
1617
- const changedFiles = [{ filename: "a.ts", status: "modified" }];
1618
- const fileContents = new Map([["a.ts", [["abc1234", "new code"]]]]);
1619
- const result = await (service as any).generatePrDescription(
1620
- commits,
1621
- changedFiles,
1622
- "openai",
1623
- fileContents,
1624
- );
1625
- expect(result.title).toBeDefined();
1626
- });
1627
- });
1628
-
1629
976
  describe("ReviewService.execute - CI with existingResult", () => {
1630
977
  beforeEach(() => {
1631
- vi.spyOn(service as any, "runLLMReview").mockResolvedValue({
1632
- success: true,
1633
- issues: [{ file: "test.ts", line: "5", ruleId: "R1", reason: "new issue" }],
1634
- summary: [{ file: "test.ts", summary: "ok" }],
1635
- });
1636
- vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
978
+ vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(
979
+ mockResult({
980
+ issues: [mockIssue({ file: "test.ts", line: "5", ruleId: "R1", reason: "new issue" })],
981
+ summary: [mockSummary({ file: "test.ts", summary: "ok" })],
982
+ }),
983
+ );
984
+ vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
1637
985
  });
1638
986
 
1639
987
  it("should merge existing issues with new issues in CI mode", async () => {
@@ -1649,11 +997,11 @@ describe("ReviewService", () => {
1649
997
  summary: [],
1650
998
  round: 1,
1651
999
  } as any,
1652
- (service as any).resultModelDeps,
1000
+ service._resultModelDeps,
1653
1001
  ),
1654
1002
  );
1655
- const configReader = (service as any).config;
1656
- configReader.getPluginConfig.mockReturnValue({});
1003
+ const configReader = service._config;
1004
+ vi.mocked(configReader.getPluginConfig).mockReturnValue({});
1657
1005
  gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
1658
1006
  gitProvider.getPullRequestCommits.mockResolvedValue([
1659
1007
  { sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
@@ -1671,7 +1019,7 @@ describe("ReviewService", () => {
1671
1019
  specSources: ["/spec"],
1672
1020
  dryRun: false,
1673
1021
  ci: true,
1674
- llmMode: "openai",
1022
+ llmMode: "openai" as const,
1675
1023
  verifyFixes: false,
1676
1024
  verbose: 1,
1677
1025
  };
@@ -1681,7 +1029,7 @@ describe("ReviewService", () => {
1681
1029
  });
1682
1030
 
1683
1031
  it("should verify fixes when verifyFixes is true", async () => {
1684
- vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
1032
+ vi.mocked(ReviewResultModel.loadFromPr).mockResolvedValue(
1685
1033
  ReviewResultModel.create(
1686
1034
  new PullRequestModel(gitProvider as any, "o", "r", 1),
1687
1035
  {
@@ -1691,11 +1039,11 @@ describe("ReviewService", () => {
1691
1039
  summary: [],
1692
1040
  round: 1,
1693
1041
  } as any,
1694
- (service as any).resultModelDeps,
1042
+ service._resultModelDeps,
1695
1043
  ),
1696
1044
  );
1697
- const configReader = (service as any).config;
1698
- configReader.getPluginConfig.mockReturnValue({});
1045
+ const configReader = service._config;
1046
+ vi.mocked(configReader.getPluginConfig).mockReturnValue({});
1699
1047
  gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
1700
1048
  gitProvider.getPullRequestCommits.mockResolvedValue([
1701
1049
  { sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
@@ -1725,12 +1073,8 @@ describe("ReviewService", () => {
1725
1073
 
1726
1074
  describe("ReviewService.execute - filterCommits branch", () => {
1727
1075
  beforeEach(() => {
1728
- vi.spyOn(service as any, "runLLMReview").mockResolvedValue({
1729
- success: true,
1730
- issues: [],
1731
- summary: [],
1732
- });
1733
- vi.spyOn(service as any, "getFileContents").mockResolvedValue(new Map());
1076
+ vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(mockResult());
1077
+ vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
1734
1078
  vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(null as any);
1735
1079
  });
1736
1080
 
@@ -1828,27 +1172,9 @@ describe("ReviewService", () => {
1828
1172
  });
1829
1173
  });
1830
1174
 
1831
- describe("ReviewService.fillIssueAuthors - searchUsers success", () => {
1832
- it("should use searchUsers result for git-only authors", async () => {
1833
- gitProvider.searchUsers.mockResolvedValue([{ id: 42, login: "found-user" }] as any);
1834
- const issues = [{ file: "test.ts", line: "1", commit: "abc1234" }];
1835
- const commits = [
1836
- {
1837
- sha: "abc1234567890",
1838
- author: null,
1839
- committer: null,
1840
- commit: { author: { name: "GitUser", email: "git@test.com" } },
1841
- },
1842
- ];
1843
- const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
1844
- expect(result[0].author.login).toBe("found-user");
1845
- });
1846
- });
1847
-
1848
1175
  describe("ReviewService.executeDeletionOnly - baseRef/headRef mode", () => {
1849
1176
  it("should execute with baseRef/headRef instead of PR", async () => {
1850
- const mockReviewReportService = (service as any).reviewReportService;
1851
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
1177
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1852
1178
  mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
1853
1179
  issues: [],
1854
1180
  summary: "ok",
@@ -1859,7 +1185,7 @@ describe("ReviewService", () => {
1859
1185
  mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
1860
1186
  { sha: "abc", commit: { message: "fix" } },
1861
1187
  ]);
1862
- const context = {
1188
+ const context: Partial<ReviewContext> = {
1863
1189
  owner: "o",
1864
1190
  repo: "r",
1865
1191
  baseRef: "main",
@@ -1869,13 +1195,12 @@ describe("ReviewService", () => {
1869
1195
  llmMode: "openai",
1870
1196
  deletionAnalysisMode: "openai",
1871
1197
  };
1872
- const result = await (service as any).executeDeletionOnly(context);
1198
+ const result = await service.executeDeletionOnly(context);
1873
1199
  expect(result.success).toBe(true);
1874
1200
  });
1875
1201
 
1876
1202
  it("should filter files by includes in deletionOnly", async () => {
1877
- const mockReviewReportService = (service as any).reviewReportService;
1878
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
1203
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1879
1204
  mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
1880
1205
  issues: [],
1881
1206
  summary: "ok",
@@ -1887,7 +1212,7 @@ describe("ReviewService", () => {
1887
1212
  { filename: "a.ts", status: "modified" },
1888
1213
  { filename: "b.md", status: "modified" },
1889
1214
  ] as any);
1890
- const context = {
1215
+ const context: Partial<ReviewContext> = {
1891
1216
  owner: "o",
1892
1217
  repo: "r",
1893
1218
  prNumber: 1,
@@ -1897,7 +1222,7 @@ describe("ReviewService", () => {
1897
1222
  deletionAnalysisMode: "openai",
1898
1223
  includes: ["*.ts"],
1899
1224
  };
1900
- const result = await (service as any).executeDeletionOnly(context);
1225
+ const result = await service.executeDeletionOnly(context);
1901
1226
  expect(result.success).toBe(true);
1902
1227
  });
1903
1228
  });
@@ -1913,19 +1238,20 @@ describe("ReviewService", () => {
1913
1238
 
1914
1239
  describe("ReviewService.executeCollectOnly - CI post comment", () => {
1915
1240
  it("should post comment in CI mode", async () => {
1916
- const mockResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
1917
- const mockReviewReportService = (service as any).reviewReportService;
1918
- mockReviewReportService.parseMarkdown.mockReturnValue({ result: mockResult });
1919
- mockReviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats");
1920
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
1241
+ const ciResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
1242
+ vi.mocked(service._reviewReportService.parseMarkdown).mockReturnValue({
1243
+ result: ciResult,
1244
+ } as any);
1245
+ service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
1246
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1921
1247
  gitProvider.listIssueComments.mockResolvedValue([
1922
1248
  { id: 10, body: "<!-- spaceflow-review --> content" },
1923
1249
  ] as any);
1924
1250
  vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
1925
1251
  ReviewResultModel.create(
1926
1252
  new PullRequestModel(gitProvider as any, "o", "r", 1),
1927
- mockResult as any,
1928
- (service as any).resultModelDeps,
1253
+ ciResult as any,
1254
+ service._resultModelDeps,
1929
1255
  ),
1930
1256
  );
1931
1257
  gitProvider.listPullReviews.mockResolvedValue([] as any);
@@ -1933,321 +1259,27 @@ describe("ReviewService", () => {
1933
1259
  gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
1934
1260
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
1935
1261
  gitProvider.updateIssueComment.mockResolvedValue({} as any);
1936
- const configReader = (service as any).config;
1937
- configReader.getPluginConfig.mockReturnValue({});
1938
- const context = { owner: "o", repo: "r", prNumber: 1, ci: true, dryRun: false, verbose: 1 };
1939
- const result = await (service as any).executeCollectOnly(context);
1262
+ vi.mocked(service._config.getPluginConfig).mockReturnValue({});
1263
+ const context: Partial<ReviewContext> = {
1264
+ owner: "o",
1265
+ repo: "r",
1266
+ prNumber: 1,
1267
+ ci: true,
1268
+ dryRun: false,
1269
+ verbose: 1,
1270
+ };
1271
+ const result = await service.executeCollectOnly(context);
1940
1272
  expect(result.issues).toHaveLength(1);
1941
1273
  expect(gitProvider.updateIssueComment).toHaveBeenCalled();
1942
1274
  });
1943
1275
  });
1944
1276
 
1945
- describe("ReviewService.getFileContents", () => {
1946
- it("should get file contents with PR mode", async () => {
1947
- gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3" as any);
1948
- const changedFiles = [
1949
- {
1950
- filename: "test.ts",
1951
- status: "modified",
1952
- patch: "@@ -1,2 +1,3 @@\n line1\n+line2\n line3",
1953
- },
1954
- ];
1955
- const commits = [{ sha: "abc1234567890" }];
1956
- const result = await (service as any).getFileContents(
1957
- "o",
1958
- "r",
1959
- changedFiles,
1960
- commits,
1961
- "abc",
1962
- 1,
1963
- );
1964
- expect(result.has("test.ts")).toBe(true);
1965
- expect(result.get("test.ts")).toHaveLength(3);
1966
- });
1967
-
1968
- it("should get file contents with git sdk mode (no PR)", async () => {
1969
- mockGitSdkService.getFileContent.mockResolvedValue("line1\nline2");
1970
- const changedFiles = [
1971
- { filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+line2" },
1972
- ];
1973
- const commits = [{ sha: "abc1234567890" }];
1974
- const result = await (service as any).getFileContents(
1975
- "o",
1976
- "r",
1977
- changedFiles,
1978
- commits,
1979
- "HEAD",
1980
- );
1981
- expect(result.has("test.ts")).toBe(true);
1982
- });
1983
-
1984
- it("should skip deleted files", async () => {
1985
- const changedFiles = [{ filename: "deleted.ts", status: "deleted" }];
1986
- const result = await (service as any).getFileContents("o", "r", changedFiles, [], "HEAD", 1);
1987
- expect(result.size).toBe(0);
1988
- });
1989
-
1990
- it("should handle file content fetch error", async () => {
1991
- gitProvider.getFileContent.mockRejectedValue(new Error("not found") as any);
1992
- const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1993
- const changedFiles = [{ filename: "missing.ts", status: "modified" }];
1994
- const result = await (service as any).getFileContents("o", "r", changedFiles, [], "HEAD", 1);
1995
- expect(result.size).toBe(0);
1996
- expect(consoleSpy).toHaveBeenCalled();
1997
- consoleSpy.mockRestore();
1998
- });
1999
-
2000
- it("should get file contents with verbose=3 logging", async () => {
2001
- gitProvider.getFileContent.mockResolvedValue("line1\nline2" as any);
2002
- const changedFiles = [
2003
- { filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+line2" },
2004
- ];
2005
- const commits = [{ sha: "abc1234567890" }];
2006
- const result = await (service as any).getFileContents(
2007
- "o",
2008
- "r",
2009
- changedFiles,
2010
- commits,
2011
- "abc",
2012
- 1,
2013
- 3,
2014
- );
2015
- expect(result.has("test.ts")).toBe(true);
2016
- });
2017
-
2018
- it("should mark all lines as changed for new files without patch", async () => {
2019
- gitProvider.getFileContent.mockResolvedValue("line1\nline2" as any);
2020
- const changedFiles = [{ filename: "new.ts", status: "added", additions: 2, deletions: 0 }];
2021
- const commits = [{ sha: "abc1234567890" }];
2022
- const result = await (service as any).getFileContents(
2023
- "o",
2024
- "r",
2025
- changedFiles,
2026
- commits,
2027
- "abc",
2028
- 1,
2029
- );
2030
- expect(result.has("new.ts")).toBe(true);
2031
- const lines = result.get("new.ts");
2032
- expect(lines[0][0]).toBe("abc1234");
2033
- expect(lines[1][0]).toBe("abc1234");
2034
- });
2035
- });
2036
-
2037
- describe("ReviewService.getChangedFilesBetweenRefs", () => {
2038
- it("should merge diff and status info", async () => {
2039
- mockGitSdkService.getDiffBetweenRefs.mockResolvedValue([
2040
- { filename: "a.ts", patch: "diff content" },
2041
- ]);
2042
- mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue([
2043
- { filename: "a.ts", status: "added" },
2044
- ]);
2045
- const result = await (service as any).getChangedFilesBetweenRefs("o", "r", "main", "feature");
2046
- expect(result).toHaveLength(1);
2047
- expect(result[0].status).toBe("added");
2048
- expect(result[0].patch).toBe("diff content");
2049
- });
2050
- });
2051
-
2052
- describe("ReviewService.buildBasicDescription", () => {
2053
- it("should build description from commits and files", async () => {
2054
- const llmProxy = (service as any).llmProxyService;
2055
- const mockStream = (async function* () {
2056
- yield { type: "text", content: "Feat: test" };
2057
- })();
2058
- llmProxy.chatStream.mockReturnValue(mockStream);
2059
- const commits = [{ sha: "abc", commit: { message: "feat: add feature" } }];
2060
- const changedFiles = [
2061
- { filename: "a.ts", status: "added" },
2062
- { filename: "b.ts", status: "modified" },
2063
- { filename: "c.ts", status: "deleted" },
2064
- ];
2065
- const result = await (service as any).buildBasicDescription(commits, changedFiles);
2066
- expect(result.description).toContain("提交记录");
2067
- expect(result.description).toContain("文件变更");
2068
- expect(result.description).toContain("新增 1");
2069
- expect(result.description).toContain("修改 1");
2070
- expect(result.description).toContain("删除 1");
2071
- });
2072
-
2073
- it("should handle empty commits", async () => {
2074
- const llmProxy = (service as any).llmProxyService;
2075
- const mockStream = (async function* () {
2076
- yield { type: "text", content: "Feat: empty" };
2077
- })();
2078
- llmProxy.chatStream.mockReturnValue(mockStream);
2079
- const result = await (service as any).buildBasicDescription([], []);
2080
- expect(result.title).toBeDefined();
2081
- });
2082
- });
2083
-
2084
- describe("ReviewService.getFilesForCommit - no PR", () => {
2085
- it("should use git sdk when no prNumber", async () => {
2086
- mockGitSdkService.getFilesForCommit.mockResolvedValue(["a.ts", "b.ts"]);
2087
- const result = await (service as any).getFilesForCommit("o", "r", "abc123");
2088
- expect(result).toEqual(["a.ts", "b.ts"]);
2089
- });
2090
-
2091
- it("should use git provider when prNumber provided", async () => {
2092
- gitProvider.getCommit.mockResolvedValue({ files: [{ filename: "a.ts" }] } as any);
2093
- const result = await (service as any).getFilesForCommit("o", "r", "abc123", 1);
2094
- expect(result).toEqual(["a.ts"]);
2095
- });
2096
-
2097
- it("should handle null files from getCommit", async () => {
2098
- gitProvider.getCommit.mockResolvedValue({ files: null } as any);
2099
- const result = await (service as any).getFilesForCommit("o", "r", "abc123", 1);
2100
- expect(result).toEqual([]);
2101
- });
2102
- });
2103
-
2104
- describe("ReviewService.filterIssuesByValidCommits", () => {
2105
- beforeEach(() => {
2106
- mockReviewSpecService.parseLineRange = vi.fn().mockImplementation((lineStr: string) => {
2107
- const lines: number[] = [];
2108
- const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
2109
- if (rangeMatch) {
2110
- const start = parseInt(rangeMatch[1], 10);
2111
- const end = parseInt(rangeMatch[2], 10);
2112
- for (let i = start; i <= end; i++) {
2113
- lines.push(i);
2114
- }
2115
- } else {
2116
- const line = parseInt(lineStr, 10);
2117
- if (!isNaN(line)) {
2118
- lines.push(line);
2119
- }
2120
- }
2121
- return lines;
2122
- });
2123
- });
2124
-
2125
- it("should filter issues by valid commit hashes", () => {
2126
- const commits = [{ sha: "abc1234567890" }];
2127
- const fileContents = new Map([
2128
- [
2129
- "test.ts",
2130
- [
2131
- ["-------", "line1"],
2132
- ["abc1234", "line2"],
2133
- ["-------", "line3"],
2134
- ],
2135
- ],
2136
- ]);
2137
- const issues = [
2138
- { file: "test.ts", line: "2", ruleId: "R1" }, // 应该保留,hash匹配
2139
- { file: "test.ts", line: "1", ruleId: "R2" }, // 应该过滤,hash不匹配
2140
- { file: "test.ts", line: "3", ruleId: "R3" }, // 应该过滤,hash不匹配
2141
- ];
2142
- const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 2);
2143
- expect(result).toHaveLength(1);
2144
- expect(result[0].ruleId).toBe("R1");
2145
- });
2146
-
2147
- it("should log filtering summary", () => {
2148
- const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
2149
- const commits = [{ sha: "abc1234567890" }];
2150
- const fileContents = new Map([
2151
- [
2152
- "test.ts",
2153
- [
2154
- ["-------", "line1"],
2155
- ["abc1234", "line2"],
2156
- ],
2157
- ],
2158
- ]);
2159
- const issues = [
2160
- { file: "test.ts", line: "1", ruleId: "R1" },
2161
- { file: "test.ts", line: "2", ruleId: "R2" },
2162
- ];
2163
- (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 1);
2164
- expect(consoleSpy).toHaveBeenCalledWith(" 过滤非本次 PR commits 问题后: 2 -> 1 个问题");
2165
- consoleSpy.mockRestore();
2166
- });
2167
-
2168
- it("should keep issues when file not in fileContents", () => {
2169
- const commits = [{ sha: "abc1234567890" }];
2170
- const fileContents = new Map();
2171
- const issues = [{ file: "missing.ts", line: "1", ruleId: "R1" }];
2172
- const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
2173
- expect(result).toEqual(issues);
2174
- });
2175
-
2176
- it("should keep issues when line range cannot be parsed", () => {
2177
- const commits = [{ sha: "abc1234567890" }];
2178
- const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
2179
- const issues = [{ file: "test.ts", line: "abc", ruleId: "R1" }];
2180
- const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
2181
- expect(result).toEqual(issues);
2182
- });
2183
-
2184
- it("should handle range line numbers", () => {
2185
- const commits = [{ sha: "abc1234567890" }];
2186
- const fileContents = new Map([
2187
- [
2188
- "test.ts",
2189
- [
2190
- ["-------", "line1"],
2191
- ["abc1234", "line2"],
2192
- ["-------", "line3"],
2193
- ],
2194
- ],
2195
- ]);
2196
- const issues = [{ file: "test.ts", line: "1-3", ruleId: "R1" }];
2197
- const result = (service as any).filterIssuesByValidCommits(issues, commits, fileContents);
2198
- expect(result).toHaveLength(1); // 只要范围内有一行匹配就保留
2199
- });
2200
-
2201
- it("should log when file not in fileContents at verbose level 3", () => {
2202
- const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
2203
- const commits = [{ sha: "abc1234567890" }];
2204
- const fileContents = new Map();
2205
- const issues = [{ file: "missing.ts", line: "1", ruleId: "R1" }];
2206
- (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
2207
- expect(consoleSpy).toHaveBeenCalledWith(
2208
- " ✅ Issue missing.ts:1 - 文件不在 fileContents 中,保留",
2209
- );
2210
- consoleSpy.mockRestore();
2211
- });
2212
-
2213
- it("should log when line range cannot be parsed at verbose level 3", () => {
2214
- const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
2215
- const commits = [{ sha: "abc1234567890" }];
2216
- const fileContents = new Map([["test.ts", [["-------", "line1"]]]]);
2217
- const issues = [{ file: "test.ts", line: "abc", ruleId: "R1" }];
2218
- (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
2219
- expect(consoleSpy).toHaveBeenCalledWith(" ✅ Issue test.ts:abc - 无法解析行号,保留");
2220
- consoleSpy.mockRestore();
2221
- });
2222
-
2223
- it("should log detailed hash matching at verbose level 3", () => {
2224
- const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
2225
- const commits = [{ sha: "abc1234567890" }];
2226
- const fileContents = new Map([
2227
- [
2228
- "test.ts",
2229
- [
2230
- ["-------", "line1"],
2231
- ["abc1234", "line2"],
2232
- ],
2233
- ],
2234
- ]);
2235
- const issues = [{ file: "test.ts", line: "2", ruleId: "R1" }];
2236
- (service as any).filterIssuesByValidCommits(issues, commits, fileContents, 3);
2237
- expect(consoleSpy).toHaveBeenCalledWith(" 🔍 有效 commit hashes: abc1234");
2238
- expect(consoleSpy).toHaveBeenCalledWith(
2239
- " ✅ Issue test.ts:2 - 行 2 hash=abc1234 匹配,保留",
2240
- );
2241
- consoleSpy.mockRestore();
2242
- });
2243
- });
2244
-
2245
1277
  describe("ReviewService.ensureClaudeCli", () => {
2246
1278
  it("should do nothing when claude is already installed", async () => {
2247
1279
  const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
2248
1280
  // execSync is already mocked globally
2249
1281
 
2250
- await (service as any).ensureClaudeCli();
1282
+ await service.ensureClaudeCli();
2251
1283
  expect(consoleSpy).not.toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
2252
1284
  consoleSpy.mockRestore();
2253
1285
  });
@@ -2262,7 +1294,7 @@ describe("ReviewService", () => {
2262
1294
  })
2263
1295
  .mockImplementationOnce(() => Buffer.from(""));
2264
1296
 
2265
- await (service as any).ensureClaudeCli();
1297
+ await service.ensureClaudeCli();
2266
1298
  expect(consoleSpy).toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
2267
1299
  expect(consoleSpy).toHaveBeenCalledWith("✅ Claude CLI 安装完成");
2268
1300
  consoleSpy.mockRestore();
@@ -2278,7 +1310,7 @@ describe("ReviewService", () => {
2278
1310
  throw new Error("install failed");
2279
1311
  });
2280
1312
 
2281
- await expect((service as any).ensureClaudeCli()).rejects.toThrow(
1313
+ await expect(service.ensureClaudeCli()).rejects.toThrow(
2282
1314
  "Claude CLI 安装失败: install failed",
2283
1315
  );
2284
1316
  });