@spaceflow/review 0.81.0 → 0.82.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,12 +641,12 @@ 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
  });
@@ -1218,9 +654,8 @@ describe("ReviewService", () => {
1218
654
 
1219
655
  describe("ReviewService.execute - flush mode", () => {
1220
656
  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");
657
+ const flushResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
658
+ service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
1224
659
  gitProvider.listPullReviews.mockResolvedValue([] as any);
1225
660
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
1226
661
  gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
@@ -1228,8 +663,8 @@ describe("ReviewService", () => {
1228
663
  vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
1229
664
  ReviewResultModel.create(
1230
665
  new PullRequestModel(gitProvider as any, "o", "r", 1),
1231
- mockResult as any,
1232
- (service as any).resultModelDeps,
666
+ flushResult as any,
667
+ service._resultModelDeps,
1233
668
  ),
1234
669
  );
1235
670
  const context = {
@@ -1250,14 +685,11 @@ describe("ReviewService", () => {
1250
685
  describe("ReviewService.executeDeletionOnly", () => {
1251
686
  it("should throw when no llmMode", async () => {
1252
687
  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
- );
688
+ await expect(service.executeDeletionOnly(context)).rejects.toThrow("必须指定 LLM 类型");
1256
689
  });
1257
690
 
1258
691
  it("should execute deletion analysis with PR", async () => {
1259
- const mockReviewReportService = (service as any).reviewReportService;
1260
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
692
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1261
693
  mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
1262
694
  issues: [],
1263
695
  summary: "ok",
@@ -1273,9 +705,8 @@ describe("ReviewService", () => {
1273
705
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
1274
706
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
1275
707
  gitProvider.createIssueComment.mockResolvedValue({} as any);
1276
- const configReader = (service as any).config;
1277
- configReader.getPluginConfig.mockReturnValue({});
1278
- const context = {
708
+ vi.mocked(service._config.getPluginConfig).mockReturnValue({});
709
+ const context: Partial<ReviewContext> = {
1279
710
  owner: "o",
1280
711
  repo: "r",
1281
712
  prNumber: 1,
@@ -1285,14 +716,13 @@ describe("ReviewService", () => {
1285
716
  deletionAnalysisMode: "openai",
1286
717
  verbose: 1,
1287
718
  };
1288
- const result = await (service as any).executeDeletionOnly(context);
719
+ const result = await service.executeDeletionOnly(context);
1289
720
  expect(result.success).toBe(true);
1290
721
  expect(result.deletionImpact).toBeDefined();
1291
722
  });
1292
723
 
1293
724
  it("should post comment in CI mode for deletionOnly", async () => {
1294
- const mockReviewReportService = (service as any).reviewReportService;
1295
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
725
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1296
726
  mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
1297
727
  issues: [],
1298
728
  summary: "ok",
@@ -1308,9 +738,8 @@ describe("ReviewService", () => {
1308
738
  gitProvider.listPullReviewComments.mockResolvedValue([] as any);
1309
739
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
1310
740
  gitProvider.createIssueComment.mockResolvedValue({} as any);
1311
- const configReader = (service as any).config;
1312
- configReader.getPluginConfig.mockReturnValue({});
1313
- const context = {
741
+ vi.mocked(service._config.getPluginConfig).mockReturnValue({});
742
+ const context: Partial<ReviewContext> = {
1314
743
  owner: "o",
1315
744
  repo: "r",
1316
745
  prNumber: 1,
@@ -1320,7 +749,7 @@ describe("ReviewService", () => {
1320
749
  deletionAnalysisMode: "openai",
1321
750
  verbose: 1,
1322
751
  };
1323
- const result = await (service as any).executeDeletionOnly(context);
752
+ const result = await service.executeDeletionOnly(context);
1324
753
  expect(result.success).toBe(true);
1325
754
  expect(gitProvider.createIssueComment).toHaveBeenCalled();
1326
755
  });
@@ -1352,7 +781,7 @@ describe("ReviewService", () => {
1352
781
 
1353
782
  it("should auto-detect prNumber from event in CI mode", async () => {
1354
783
  configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
1355
- vi.spyOn((service as any).contextBuilder, "getPrNumberFromEvent").mockResolvedValue(42);
784
+ vi.spyOn(service._contextBuilder, "getPrNumberFromEvent").mockResolvedValue(42);
1356
785
  gitProvider.getPullRequest.mockResolvedValue({ title: "feat: test" } as any);
1357
786
  const options = { dryRun: false, ci: true, verbose: 1 };
1358
787
  const context = await service.getContextFromEnv(options as any);
@@ -1433,8 +862,7 @@ describe("ReviewService", () => {
1433
862
 
1434
863
  it("should merge references from options and config", async () => {
1435
864
  configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
1436
- const configReader = (service as any).config;
1437
- configReader.getPluginConfig.mockReturnValue({ references: ["config-ref"] });
865
+ vi.mocked(service._config.getPluginConfig).mockReturnValue({ references: ["config-ref"] });
1438
866
  const options = { dryRun: false, ci: false, references: ["opt-ref"] };
1439
867
  const context = await service.getContextFromEnv(options as any);
1440
868
  expect(context.specSources).toContain("opt-ref");
@@ -1453,17 +881,7 @@ describe("ReviewService", () => {
1453
881
  describe("ReviewService.ensureClaudeCli", () => {
1454
882
  it("should not throw when claude is installed", async () => {
1455
883
  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);
884
+ await expect(service.ensureClaudeCli()).resolves.toBeUndefined();
1467
885
  });
1468
886
  });
1469
887
 
@@ -1479,11 +897,11 @@ describe("ReviewService", () => {
1479
897
  localMode: false,
1480
898
  };
1481
899
 
1482
- const result = await (service as any).resolveSourceData(context);
900
+ const result = await service.resolveSourceData(context);
1483
901
 
1484
902
  expect(result.isDirectFileMode).toBe(true);
1485
903
  expect(result.isLocalMode).toBe(true);
1486
- expect(result.changedFiles).toEqual([
904
+ expect(result.changedFiles.toArray()).toEqual([
1487
905
  { filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
1488
906
  ]);
1489
907
  expect(mockGitSdkService.getUncommittedFiles).not.toHaveBeenCalled();
@@ -1502,138 +920,24 @@ describe("ReviewService", () => {
1502
920
  localMode: false,
1503
921
  };
1504
922
 
1505
- const result = await (service as any).resolveSourceData(context);
923
+ const result = await service.resolveSourceData(context);
1506
924
 
1507
925
  expect(result.isDirectFileMode).toBe(true);
1508
- expect(result.changedFiles).toEqual([
926
+ expect(result.changedFiles.toArray()).toEqual([
1509
927
  { filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
1510
928
  ]);
1511
929
  });
1512
930
  });
1513
931
 
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
932
  describe("ReviewService.execute - CI with existingResult", () => {
1630
933
  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());
934
+ vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(
935
+ mockResult({
936
+ issues: [mockIssue({ file: "test.ts", line: "5", ruleId: "R1", reason: "new issue" })],
937
+ summary: [mockSummary({ file: "test.ts", summary: "ok" })],
938
+ }),
939
+ );
940
+ vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
1637
941
  });
1638
942
 
1639
943
  it("should merge existing issues with new issues in CI mode", async () => {
@@ -1649,11 +953,11 @@ describe("ReviewService", () => {
1649
953
  summary: [],
1650
954
  round: 1,
1651
955
  } as any,
1652
- (service as any).resultModelDeps,
956
+ service._resultModelDeps,
1653
957
  ),
1654
958
  );
1655
- const configReader = (service as any).config;
1656
- configReader.getPluginConfig.mockReturnValue({});
959
+ const configReader = service._config;
960
+ vi.mocked(configReader.getPluginConfig).mockReturnValue({});
1657
961
  gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
1658
962
  gitProvider.getPullRequestCommits.mockResolvedValue([
1659
963
  { sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
@@ -1671,7 +975,7 @@ describe("ReviewService", () => {
1671
975
  specSources: ["/spec"],
1672
976
  dryRun: false,
1673
977
  ci: true,
1674
- llmMode: "openai",
978
+ llmMode: "openai" as const,
1675
979
  verifyFixes: false,
1676
980
  verbose: 1,
1677
981
  };
@@ -1681,7 +985,7 @@ describe("ReviewService", () => {
1681
985
  });
1682
986
 
1683
987
  it("should verify fixes when verifyFixes is true", async () => {
1684
- vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
988
+ vi.mocked(ReviewResultModel.loadFromPr).mockResolvedValue(
1685
989
  ReviewResultModel.create(
1686
990
  new PullRequestModel(gitProvider as any, "o", "r", 1),
1687
991
  {
@@ -1691,11 +995,11 @@ describe("ReviewService", () => {
1691
995
  summary: [],
1692
996
  round: 1,
1693
997
  } as any,
1694
- (service as any).resultModelDeps,
998
+ service._resultModelDeps,
1695
999
  ),
1696
1000
  );
1697
- const configReader = (service as any).config;
1698
- configReader.getPluginConfig.mockReturnValue({});
1001
+ const configReader = service._config;
1002
+ vi.mocked(configReader.getPluginConfig).mockReturnValue({});
1699
1003
  gitProvider.getPullRequest.mockResolvedValue({ title: "PR", head: { sha: "abc" } } as any);
1700
1004
  gitProvider.getPullRequestCommits.mockResolvedValue([
1701
1005
  { sha: "abc123", commit: { message: "fix" }, author: { id: 1, login: "dev" } },
@@ -1725,12 +1029,8 @@ describe("ReviewService", () => {
1725
1029
 
1726
1030
  describe("ReviewService.execute - filterCommits branch", () => {
1727
1031
  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());
1032
+ vi.spyOn(service._llmProcessor, "runLLMReview").mockResolvedValue(mockResult());
1033
+ vi.spyOn(service._sourceResolver, "getFileContents").mockResolvedValue(new Map());
1734
1034
  vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(null as any);
1735
1035
  });
1736
1036
 
@@ -1828,27 +1128,9 @@ describe("ReviewService", () => {
1828
1128
  });
1829
1129
  });
1830
1130
 
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
1131
  describe("ReviewService.executeDeletionOnly - baseRef/headRef mode", () => {
1849
1132
  it("should execute with baseRef/headRef instead of PR", async () => {
1850
- const mockReviewReportService = (service as any).reviewReportService;
1851
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
1133
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1852
1134
  mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
1853
1135
  issues: [],
1854
1136
  summary: "ok",
@@ -1859,7 +1141,7 @@ describe("ReviewService", () => {
1859
1141
  mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
1860
1142
  { sha: "abc", commit: { message: "fix" } },
1861
1143
  ]);
1862
- const context = {
1144
+ const context: Partial<ReviewContext> = {
1863
1145
  owner: "o",
1864
1146
  repo: "r",
1865
1147
  baseRef: "main",
@@ -1869,13 +1151,12 @@ describe("ReviewService", () => {
1869
1151
  llmMode: "openai",
1870
1152
  deletionAnalysisMode: "openai",
1871
1153
  };
1872
- const result = await (service as any).executeDeletionOnly(context);
1154
+ const result = await service.executeDeletionOnly(context);
1873
1155
  expect(result.success).toBe(true);
1874
1156
  });
1875
1157
 
1876
1158
  it("should filter files by includes in deletionOnly", async () => {
1877
- const mockReviewReportService = (service as any).reviewReportService;
1878
- mockReviewReportService.formatMarkdown.mockReturnValue("report");
1159
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1879
1160
  mockDeletionImpactService.analyzeDeletionImpact.mockResolvedValue({
1880
1161
  issues: [],
1881
1162
  summary: "ok",
@@ -1887,7 +1168,7 @@ describe("ReviewService", () => {
1887
1168
  { filename: "a.ts", status: "modified" },
1888
1169
  { filename: "b.md", status: "modified" },
1889
1170
  ] as any);
1890
- const context = {
1171
+ const context: Partial<ReviewContext> = {
1891
1172
  owner: "o",
1892
1173
  repo: "r",
1893
1174
  prNumber: 1,
@@ -1897,7 +1178,7 @@ describe("ReviewService", () => {
1897
1178
  deletionAnalysisMode: "openai",
1898
1179
  includes: ["*.ts"],
1899
1180
  };
1900
- const result = await (service as any).executeDeletionOnly(context);
1181
+ const result = await service.executeDeletionOnly(context);
1901
1182
  expect(result.success).toBe(true);
1902
1183
  });
1903
1184
  });
@@ -1913,19 +1194,20 @@ describe("ReviewService", () => {
1913
1194
 
1914
1195
  describe("ReviewService.executeCollectOnly - CI post comment", () => {
1915
1196
  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");
1197
+ const ciResult = { issues: [{ file: "a.ts", line: "1", ruleId: "R1" }], summary: [] };
1198
+ vi.mocked(service._reviewReportService.parseMarkdown).mockReturnValue({
1199
+ result: ciResult,
1200
+ } as any);
1201
+ service._reviewReportService.formatStatsTerminal = vi.fn().mockReturnValue("stats") as any;
1202
+ vi.mocked(service._reviewReportService.formatMarkdown).mockReturnValue("report");
1921
1203
  gitProvider.listIssueComments.mockResolvedValue([
1922
1204
  { id: 10, body: "<!-- spaceflow-review --> content" },
1923
1205
  ] as any);
1924
1206
  vi.spyOn(ReviewResultModel, "loadFromPr").mockResolvedValue(
1925
1207
  ReviewResultModel.create(
1926
1208
  new PullRequestModel(gitProvider as any, "o", "r", 1),
1927
- mockResult as any,
1928
- (service as any).resultModelDeps,
1209
+ ciResult as any,
1210
+ service._resultModelDeps,
1929
1211
  ),
1930
1212
  );
1931
1213
  gitProvider.listPullReviews.mockResolvedValue([] as any);
@@ -1933,321 +1215,27 @@ describe("ReviewService", () => {
1933
1215
  gitProvider.getPullRequestCommits.mockResolvedValue([] as any);
1934
1216
  gitProvider.getPullRequest.mockResolvedValue({ head: { sha: "abc" } } as any);
1935
1217
  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);
1218
+ vi.mocked(service._config.getPluginConfig).mockReturnValue({});
1219
+ const context: Partial<ReviewContext> = {
1220
+ owner: "o",
1221
+ repo: "r",
1222
+ prNumber: 1,
1223
+ ci: true,
1224
+ dryRun: false,
1225
+ verbose: 1,
1226
+ };
1227
+ const result = await service.executeCollectOnly(context);
1940
1228
  expect(result.issues).toHaveLength(1);
1941
1229
  expect(gitProvider.updateIssueComment).toHaveBeenCalled();
1942
1230
  });
1943
1231
  });
1944
1232
 
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
1233
  describe("ReviewService.ensureClaudeCli", () => {
2246
1234
  it("should do nothing when claude is already installed", async () => {
2247
1235
  const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
2248
1236
  // execSync is already mocked globally
2249
1237
 
2250
- await (service as any).ensureClaudeCli();
1238
+ await service.ensureClaudeCli();
2251
1239
  expect(consoleSpy).not.toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
2252
1240
  consoleSpy.mockRestore();
2253
1241
  });
@@ -2262,7 +1250,7 @@ describe("ReviewService", () => {
2262
1250
  })
2263
1251
  .mockImplementationOnce(() => Buffer.from(""));
2264
1252
 
2265
- await (service as any).ensureClaudeCli();
1253
+ await service.ensureClaudeCli();
2266
1254
  expect(consoleSpy).toHaveBeenCalledWith("🔧 Claude CLI 未安装,正在安装...");
2267
1255
  expect(consoleSpy).toHaveBeenCalledWith("✅ Claude CLI 安装完成");
2268
1256
  consoleSpy.mockRestore();
@@ -2278,7 +1266,7 @@ describe("ReviewService", () => {
2278
1266
  throw new Error("install failed");
2279
1267
  });
2280
1268
 
2281
- await expect((service as any).ensureClaudeCli()).rejects.toThrow(
1269
+ await expect(service.ensureClaudeCli()).rejects.toThrow(
2282
1270
  "Claude CLI 安装失败: install failed",
2283
1271
  );
2284
1272
  });