@spaceflow/review 0.29.1

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +533 -0
  2. package/README.md +124 -0
  3. package/dist/551.js +9 -0
  4. package/dist/index.js +5704 -0
  5. package/package.json +50 -0
  6. package/src/README.md +364 -0
  7. package/src/__mocks__/@anthropic-ai/claude-agent-sdk.js +3 -0
  8. package/src/__mocks__/json-stringify-pretty-compact.ts +4 -0
  9. package/src/deletion-impact.service.spec.ts +974 -0
  10. package/src/deletion-impact.service.ts +879 -0
  11. package/src/dto/mcp.dto.ts +42 -0
  12. package/src/index.ts +32 -0
  13. package/src/issue-verify.service.spec.ts +460 -0
  14. package/src/issue-verify.service.ts +309 -0
  15. package/src/locales/en/review.json +31 -0
  16. package/src/locales/index.ts +11 -0
  17. package/src/locales/zh-cn/review.json +31 -0
  18. package/src/parse-title-options.spec.ts +251 -0
  19. package/src/parse-title-options.ts +185 -0
  20. package/src/review-report/formatters/deletion-impact.formatter.ts +144 -0
  21. package/src/review-report/formatters/index.ts +4 -0
  22. package/src/review-report/formatters/json.formatter.ts +8 -0
  23. package/src/review-report/formatters/markdown.formatter.ts +291 -0
  24. package/src/review-report/formatters/terminal.formatter.ts +130 -0
  25. package/src/review-report/index.ts +4 -0
  26. package/src/review-report/review-report.module.ts +8 -0
  27. package/src/review-report/review-report.service.ts +58 -0
  28. package/src/review-report/types.ts +26 -0
  29. package/src/review-spec/index.ts +3 -0
  30. package/src/review-spec/review-spec.module.ts +10 -0
  31. package/src/review-spec/review-spec.service.spec.ts +1543 -0
  32. package/src/review-spec/review-spec.service.ts +902 -0
  33. package/src/review-spec/types.ts +143 -0
  34. package/src/review.command.ts +244 -0
  35. package/src/review.config.ts +58 -0
  36. package/src/review.mcp.ts +184 -0
  37. package/src/review.module.ts +52 -0
  38. package/src/review.service.spec.ts +3007 -0
  39. package/src/review.service.ts +2603 -0
  40. package/tsconfig.json +8 -0
  41. package/vitest.config.ts +34 -0
@@ -0,0 +1,974 @@
1
+ import { vi, type Mocked, type Mock } from "vitest";
2
+ import { Test, TestingModule } from "@nestjs/testing";
3
+ import { LlmProxyService, GitProviderService } from "@spaceflow/core";
4
+ import { DeletionImpactService } from "./deletion-impact.service";
5
+ import * as child_process from "child_process";
6
+ import { EventEmitter } from "events";
7
+
8
+ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
9
+ query: vi.fn(),
10
+ }));
11
+
12
+ vi.mock("child_process");
13
+
14
+ describe("DeletionImpactService", () => {
15
+ let service: DeletionImpactService;
16
+ let llmProxyService: Mocked<LlmProxyService>;
17
+ let gitProvider: Mocked<GitProviderService>;
18
+
19
+ beforeEach(async () => {
20
+ const mockLlmProxyService = {
21
+ chatStream: vi.fn(),
22
+ };
23
+
24
+ const mockGitProvider = {
25
+ getPullRequestFiles: vi.fn(),
26
+ getPullRequestDiff: vi.fn(),
27
+ };
28
+
29
+ const module: TestingModule = await Test.createTestingModule({
30
+ providers: [
31
+ DeletionImpactService,
32
+ { provide: LlmProxyService, useValue: mockLlmProxyService },
33
+ { provide: GitProviderService, useValue: mockGitProvider },
34
+ ],
35
+ }).compile();
36
+
37
+ service = module.get<DeletionImpactService>(DeletionImpactService);
38
+ llmProxyService = module.get(LlmProxyService) as Mocked<LlmProxyService>;
39
+ gitProvider = module.get(GitProviderService) as Mocked<GitProviderService>;
40
+ });
41
+
42
+ describe("analyzeDeletionImpact", () => {
43
+ it("should return early if no parameters provided", async () => {
44
+ const result = await service.analyzeDeletionImpact({}, "openai", 1);
45
+ expect(result.impacts).toHaveLength(0);
46
+ expect(result.summary).toBe("缺少必要参数");
47
+ });
48
+
49
+ it("should extract blocks and analyze with LLM (Gitea API source)", async () => {
50
+ const context = {
51
+ owner: "owner",
52
+ repo: "repo",
53
+ prNumber: 123,
54
+ };
55
+
56
+ const mockFiles = [
57
+ {
58
+ filename: "test.ts",
59
+ patch: "@@ -1,1 +1,0 @@\n-const oldCode = 1;",
60
+ deletions: 1,
61
+ },
62
+ ];
63
+ gitProvider.getPullRequestFiles.mockResolvedValue(mockFiles as any);
64
+
65
+ // Mock LLM response
66
+ const mockStream = (async function* () {
67
+ yield {
68
+ type: "result",
69
+ response: {
70
+ structuredOutput: {
71
+ impacts: [
72
+ {
73
+ file: "test.ts",
74
+ deletedCode: "const oldCode",
75
+ riskLevel: "low",
76
+ affectedFiles: [],
77
+ reason: "Clean up",
78
+ suggestion: "None",
79
+ },
80
+ ],
81
+ summary: "Safe deletion",
82
+ },
83
+ },
84
+ };
85
+ })();
86
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
87
+
88
+ // Mock git grep (for findCodeReferences)
89
+ const mockProcess = new EventEmitter() as any;
90
+ mockProcess.stdout = new EventEmitter();
91
+ mockProcess.stderr = new EventEmitter();
92
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
93
+
94
+ process.nextTick(() => {
95
+ mockProcess.stdout.emit("data", ""); // No references found
96
+ mockProcess.emit("close", 0);
97
+ });
98
+
99
+ const result = await service.analyzeDeletionImpact(context, "openai", 1);
100
+
101
+ expect(result.impacts).toHaveLength(1);
102
+ expect(result.summary).toBe("Safe deletion");
103
+ expect(gitProvider.getPullRequestFiles).toHaveBeenCalled();
104
+ });
105
+ });
106
+
107
+ describe("parseDeletedBlocksFromPatch", () => {
108
+ it("should correctly parse deleted lines from patch", () => {
109
+ const patch = "@@ -10,2 +10,1 @@\n-line 1\n-line 2\n+line 1 modified";
110
+ const blocks = (service as any).parseDeletedBlocksFromPatch("test.ts", patch);
111
+
112
+ expect(blocks).toHaveLength(1);
113
+ expect(blocks[0]).toEqual({
114
+ file: "test.ts",
115
+ startLine: 10,
116
+ endLine: 11,
117
+ content: "line 1\nline 2",
118
+ });
119
+ });
120
+ });
121
+
122
+ describe("extractIdentifiers", () => {
123
+ it("should extract function names", () => {
124
+ const code = "function testFunc() {}\nasync function asyncFunc() {}";
125
+ const ids = (service as any).extractIdentifiers(code);
126
+ expect(ids).toContain("testFunc");
127
+ expect(ids).toContain("asyncFunc");
128
+ });
129
+
130
+ it("should extract class names", () => {
131
+ const code = "class MyClass {}";
132
+ const ids = (service as any).extractIdentifiers(code);
133
+ expect(ids).toContain("MyClass");
134
+ });
135
+
136
+ it("should extract interface names", () => {
137
+ const code = "interface MyInterface {}";
138
+ const ids = (service as any).extractIdentifiers(code);
139
+ expect(ids).toContain("MyInterface");
140
+ });
141
+
142
+ it("should extract type names", () => {
143
+ const code = "type MyType = string;";
144
+ const ids = (service as any).extractIdentifiers(code);
145
+ expect(ids).toContain("MyType");
146
+ });
147
+
148
+ it("should extract exported variable names", () => {
149
+ const code = "export const MY_CONST = 1;\nexport let myVar = 2;";
150
+ const ids = (service as any).extractIdentifiers(code);
151
+ expect(ids).toContain("MY_CONST");
152
+ expect(ids).toContain("myVar");
153
+ });
154
+
155
+ it("should extract method names", () => {
156
+ const code = "async getData() {\n return [];\n}";
157
+ const ids = (service as any).extractIdentifiers(code);
158
+ expect(ids).toContain("getData");
159
+ });
160
+
161
+ it("should not extract control flow keywords as methods", () => {
162
+ const code = "if (true) {\n}\nfor (const x of arr) {\n}\nwhile (true) {\n}";
163
+ const ids = (service as any).extractIdentifiers(code);
164
+ expect(ids).not.toContain("if");
165
+ expect(ids).not.toContain("for");
166
+ expect(ids).not.toContain("while");
167
+ });
168
+
169
+ it("should deduplicate identifiers", () => {
170
+ const code = "function test() {}\nfunction test() {}";
171
+ const ids = (service as any).extractIdentifiers(code);
172
+ const testCount = ids.filter((id: string) => id === "test").length;
173
+ expect(testCount).toBe(1);
174
+ });
175
+ });
176
+
177
+ describe("extractDeletedBlocksFromChangedFiles", () => {
178
+ it("should extract blocks from files with patch", () => {
179
+ const changedFiles = [
180
+ {
181
+ filename: "test.ts",
182
+ patch: "@@ -5,3 +5,1 @@\n-const a = 1;\n-const b = 2;\n+const c = 3;",
183
+ },
184
+ ];
185
+ const blocks = (service as any).extractDeletedBlocksFromChangedFiles(changedFiles);
186
+ expect(blocks.length).toBeGreaterThanOrEqual(1);
187
+ expect(blocks[0].file).toBe("test.ts");
188
+ });
189
+
190
+ it("should skip files without filename or patch", () => {
191
+ const changedFiles = [{ filename: "", patch: "" }, { patch: "some patch" }];
192
+ const blocks = (service as any).extractDeletedBlocksFromChangedFiles(changedFiles);
193
+ expect(blocks).toHaveLength(0);
194
+ });
195
+
196
+ it("should filter out comment-only blocks", () => {
197
+ const changedFiles = [
198
+ {
199
+ filename: "test.ts",
200
+ patch: "@@ -1,2 +1,0 @@\n-// this is a comment\n-/* another comment */",
201
+ },
202
+ ];
203
+ const blocks = (service as any).extractDeletedBlocksFromChangedFiles(changedFiles);
204
+ expect(blocks).toHaveLength(0);
205
+ });
206
+ });
207
+
208
+ describe("extractDeletedBlocksFromDiffText", () => {
209
+ it("should parse diff text with multiple files", () => {
210
+ const diffText = `diff --git a/file1.ts b/file1.ts
211
+ --- a/file1.ts
212
+ +++ b/file1.ts
213
+ @@ -1,2 +1,1 @@
214
+ -const old1 = 1;
215
+ -const old2 = 2;
216
+ +const new1 = 1;
217
+ diff --git a/file2.ts b/file2.ts
218
+ --- a/file2.ts
219
+ +++ b/file2.ts
220
+ @@ -5,1 +5,0 @@
221
+ -function removed() {}`;
222
+ const blocks = (service as any).extractDeletedBlocksFromDiffText(diffText);
223
+ expect(blocks.length).toBeGreaterThanOrEqual(1);
224
+ });
225
+
226
+ it("should handle empty diff text", () => {
227
+ const blocks = (service as any).extractDeletedBlocksFromDiffText("");
228
+ expect(blocks).toHaveLength(0);
229
+ });
230
+
231
+ it("should skip files without header match", () => {
232
+ const diffText = "some random text without diff header";
233
+ const blocks = (service as any).extractDeletedBlocksFromDiffText(diffText);
234
+ expect(blocks).toHaveLength(0);
235
+ });
236
+
237
+ it("should save last delete block at end of file", () => {
238
+ const diffText = `diff --git a/test.ts b/test.ts
239
+ --- a/test.ts
240
+ +++ b/test.ts
241
+ @@ -1,2 +1,0 @@
242
+ -const a = 1;
243
+ -const b = 2;`;
244
+ const blocks = (service as any).extractDeletedBlocksFromDiffText(diffText);
245
+ expect(blocks).toHaveLength(1);
246
+ expect(blocks[0].content).toContain("const a = 1;");
247
+ expect(blocks[0].content).toContain("const b = 2;");
248
+ });
249
+ });
250
+
251
+ describe("filterMeaningfulBlocks", () => {
252
+ it("should keep blocks with meaningful code", () => {
253
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
254
+ const result = (service as any).filterMeaningfulBlocks(blocks);
255
+ expect(result).toHaveLength(1);
256
+ });
257
+
258
+ it("should filter out blocks with only comments", () => {
259
+ const blocks = [
260
+ { file: "test.ts", startLine: 1, endLine: 2, content: "// comment\n* another" },
261
+ ];
262
+ const result = (service as any).filterMeaningfulBlocks(blocks);
263
+ expect(result).toHaveLength(0);
264
+ });
265
+
266
+ it("should filter out blocks with only blank lines", () => {
267
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 2, content: " \n " }];
268
+ const result = (service as any).filterMeaningfulBlocks(blocks);
269
+ expect(result).toHaveLength(0);
270
+ });
271
+ });
272
+
273
+ describe("parseDeletedBlocksFromPatch - more branches", () => {
274
+ it("should handle multiple hunks", () => {
275
+ const patch = "@@ -1,1 +1,0 @@\n-line1\n@@ -10,1 +9,0 @@\n-line10";
276
+ const blocks = (service as any).parseDeletedBlocksFromPatch("test.ts", patch);
277
+ expect(blocks).toHaveLength(2);
278
+ });
279
+
280
+ it("should save block when encountering new hunk after deletions", () => {
281
+ const patch = "@@ -1,2 +1,0 @@\n-line1\n-line2\n@@ -10,1 +8,1 @@\n-old\n+new";
282
+ const blocks = (service as any).parseDeletedBlocksFromPatch("test.ts", patch);
283
+ expect(blocks.length).toBeGreaterThanOrEqual(1);
284
+ });
285
+
286
+ it("should handle added lines breaking delete block", () => {
287
+ const patch = "@@ -1,3 +1,2 @@\n-deleted1\n+added1\n-deleted2";
288
+ const blocks = (service as any).parseDeletedBlocksFromPatch("test.ts", patch);
289
+ expect(blocks.length).toBeGreaterThanOrEqual(1);
290
+ });
291
+
292
+ it("should save last block at end of patch", () => {
293
+ const patch = "@@ -1,1 +1,0 @@\n-const x = 1;";
294
+ const blocks = (service as any).parseDeletedBlocksFromPatch("test.ts", patch);
295
+ expect(blocks).toHaveLength(1);
296
+ expect(blocks[0].startLine).toBe(1);
297
+ expect(blocks[0].endLine).toBe(1);
298
+ });
299
+ });
300
+
301
+ describe("analyzeDeletionImpact - more branches", () => {
302
+ it("should use git-diff source with baseRef/headRef", async () => {
303
+ const mockProcess = new EventEmitter() as any;
304
+ mockProcess.stdout = new EventEmitter();
305
+ mockProcess.stderr = new EventEmitter();
306
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
307
+
308
+ const context = { baseRef: "main", headRef: "feature" };
309
+
310
+ // resolveRef 会调用 git rev-parse,然后 getDeletedCodeBlocks 调用 git diff
311
+ // 第一次 spawn: rev-parse main
312
+ // 第二次 spawn: rev-parse feature
313
+ // 第三次 spawn: git diff
314
+ let callCount = 0;
315
+ (child_process.spawn as Mock).mockImplementation(() => {
316
+ callCount++;
317
+ const proc = new EventEmitter() as any;
318
+ proc.stdout = new EventEmitter();
319
+ proc.stderr = new EventEmitter();
320
+ process.nextTick(() => {
321
+ if (callCount <= 2) {
322
+ // rev-parse 成功
323
+ proc.stdout.emit("data", "abc123");
324
+ proc.emit("close", 0);
325
+ } else {
326
+ // git diff 返回空
327
+ proc.stdout.emit("data", "");
328
+ proc.emit("close", 0);
329
+ }
330
+ });
331
+ return proc;
332
+ });
333
+
334
+ const result = await service.analyzeDeletionImpact(context, "openai", 1);
335
+ expect(result.impacts).toHaveLength(0);
336
+ expect(result.summary).toBe("没有发现删除的代码");
337
+ });
338
+
339
+ it("should use PR diff API when no patch in files", async () => {
340
+ const context = { owner: "o", repo: "r", prNumber: 1 };
341
+ gitProvider.getPullRequestFiles.mockResolvedValue([
342
+ { filename: "test.ts", deletions: 5 },
343
+ ] as any);
344
+ gitProvider.getPullRequestDiff.mockResolvedValue(
345
+ `diff --git a/test.ts b/test.ts
346
+ --- a/test.ts
347
+ +++ b/test.ts
348
+ @@ -1,1 +1,0 @@
349
+ -const removed = true;`,
350
+ );
351
+
352
+ // Mock git grep for findCodeReferences
353
+ const mockProcess = new EventEmitter() as any;
354
+ mockProcess.stdout = new EventEmitter();
355
+ mockProcess.stderr = new EventEmitter();
356
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
357
+ process.nextTick(() => {
358
+ mockProcess.emit("close", 1); // grep 没找到
359
+ });
360
+
361
+ const mockStream = (async function* () {
362
+ yield {
363
+ type: "result",
364
+ response: {
365
+ structuredOutput: { impacts: [], summary: "ok" },
366
+ },
367
+ };
368
+ })();
369
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
370
+
371
+ const result = await service.analyzeDeletionImpact(context, "openai", 1);
372
+ expect(gitProvider.getPullRequestDiff).toHaveBeenCalled();
373
+ expect(result).toBeDefined();
374
+ });
375
+
376
+ it("should handle PR diff API failure", async () => {
377
+ const context = { owner: "o", repo: "r", prNumber: 1 };
378
+ gitProvider.getPullRequestFiles.mockResolvedValue([
379
+ { filename: "test.ts", deletions: 5 },
380
+ ] as any);
381
+ gitProvider.getPullRequestDiff.mockRejectedValue(new Error("API error"));
382
+
383
+ const result = await service.analyzeDeletionImpact(context, "openai", 1);
384
+ expect(result.impacts).toHaveLength(0);
385
+ expect(result.summary).toBe("没有发现删除的代码");
386
+ });
387
+
388
+ it("should return early when no deletions in files", async () => {
389
+ const context = { owner: "o", repo: "r", prNumber: 1 };
390
+ gitProvider.getPullRequestFiles.mockResolvedValue([
391
+ { filename: "test.ts", additions: 5, deletions: 0 },
392
+ ] as any);
393
+
394
+ const result = await service.analyzeDeletionImpact(context, "openai", 1);
395
+ expect(result.impacts).toHaveLength(0);
396
+ });
397
+
398
+ it("should filter blocks by includes", async () => {
399
+ const context = {
400
+ owner: "o",
401
+ repo: "r",
402
+ prNumber: 1,
403
+ includes: ["*.service.ts"],
404
+ };
405
+ gitProvider.getPullRequestFiles.mockResolvedValue([
406
+ {
407
+ filename: "test.controller.ts",
408
+ patch: "@@ -1,1 +1,0 @@\n-const x = 1;",
409
+ deletions: 1,
410
+ },
411
+ ] as any);
412
+
413
+ const result = await service.analyzeDeletionImpact(context, "openai", 1);
414
+ expect(result.impacts).toHaveLength(0);
415
+ });
416
+
417
+ it("should use verbose logging", async () => {
418
+ const context = { owner: "o", repo: "r", prNumber: 1 };
419
+ gitProvider.getPullRequestFiles.mockResolvedValue([] as any);
420
+
421
+ const result = await service.analyzeDeletionImpact(context, "openai", 1);
422
+ expect(result.impacts).toHaveLength(0);
423
+ });
424
+ });
425
+
426
+ describe("analyzeWithLLM", () => {
427
+ it("should handle error event from stream", async () => {
428
+ const mockStream = (async function* () {
429
+ yield { type: "error", message: "LLM failed" };
430
+ })();
431
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
432
+
433
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
434
+ const refs = new Map<string, string[]>();
435
+ const result = await (service as any).analyzeWithLLM(blocks, refs, "openai");
436
+ expect(result.impacts).toHaveLength(0);
437
+ expect(result.summary).toBe("分析返回格式无效");
438
+ });
439
+
440
+ it("should handle invalid result from LLM", async () => {
441
+ const mockStream = (async function* () {
442
+ yield { type: "result", response: { structuredOutput: null } };
443
+ })();
444
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
445
+
446
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
447
+ const refs = new Map<string, string[]>();
448
+ const result = await (service as any).analyzeWithLLM(blocks, refs, "openai");
449
+ expect(result.impacts).toHaveLength(0);
450
+ expect(result.summary).toBe("分析返回格式无效");
451
+ });
452
+
453
+ it("should handle array result from LLM", async () => {
454
+ const mockStream = (async function* () {
455
+ yield { type: "result", response: { structuredOutput: [] } };
456
+ })();
457
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
458
+
459
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
460
+ const refs = new Map<string, string[]>();
461
+ const result = await (service as any).analyzeWithLLM(blocks, refs, "openai");
462
+ expect(result.impacts).toHaveLength(0);
463
+ });
464
+
465
+ it("should handle LLM call exception", async () => {
466
+ llmProxyService.chatStream.mockImplementation(() => {
467
+ throw new Error("Connection failed");
468
+ });
469
+
470
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
471
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
472
+ const refs = new Map<string, string[]>();
473
+ const result = await (service as any).analyzeWithLLM(blocks, refs, "openai");
474
+ expect(result.summary).toBe("LLM 调用失败");
475
+ consoleSpy.mockRestore();
476
+ });
477
+
478
+ it("should handle non-Error exception", async () => {
479
+ llmProxyService.chatStream.mockImplementation(() => {
480
+ throw "string error";
481
+ });
482
+
483
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
484
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
485
+ const refs = new Map<string, string[]>();
486
+ const result = await (service as any).analyzeWithLLM(blocks, refs, "openai");
487
+ expect(result.summary).toBe("LLM 调用失败");
488
+ consoleSpy.mockRestore();
489
+ });
490
+
491
+ it("should include references in prompt", async () => {
492
+ const mockStream = (async function* () {
493
+ yield {
494
+ type: "result",
495
+ response: { structuredOutput: { impacts: [], summary: "ok" } },
496
+ };
497
+ })();
498
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
499
+
500
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 5, content: "const x = 1;" }];
501
+ const refs = new Map([["test.ts:1-5", ["other.ts", "another.ts"]]]);
502
+ const result = await (service as any).analyzeWithLLM(blocks, refs, "openai");
503
+ expect(result.summary).toBe("ok");
504
+ });
505
+
506
+ it("should log prompts with verbose=2", async () => {
507
+ const mockStream = (async function* () {
508
+ yield {
509
+ type: "result",
510
+ response: { structuredOutput: { impacts: [], summary: "ok" } },
511
+ };
512
+ })();
513
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
514
+
515
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
516
+ const refs = new Map<string, string[]>();
517
+ const result = await (service as any).analyzeWithLLM(blocks, refs, "openai", 2);
518
+ expect(result.summary).toBe("ok");
519
+ });
520
+ });
521
+
522
+ describe("analyzeWithAgent", () => {
523
+ it("should handle successful agent analysis", async () => {
524
+ const mockStream = (async function* () {
525
+ yield {
526
+ type: "result",
527
+ response: { structuredOutput: { impacts: [], summary: "agent ok" } },
528
+ };
529
+ })();
530
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
531
+
532
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
533
+ const refs = new Map<string, string[]>();
534
+ const result = await (service as any).analyzeWithAgent("claude-code", blocks, refs);
535
+ expect(result.summary).toBe("agent ok");
536
+ });
537
+
538
+ it("should handle agent error event", async () => {
539
+ const mockStream = (async function* () {
540
+ yield { type: "error", message: "Agent failed" };
541
+ })();
542
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
543
+
544
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
545
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
546
+ const refs = new Map<string, string[]>();
547
+ const result = await (service as any).analyzeWithAgent("claude-code", blocks, refs);
548
+ expect(result.impacts).toHaveLength(0);
549
+ consoleSpy.mockRestore();
550
+ });
551
+
552
+ it("should handle agent call exception", async () => {
553
+ llmProxyService.chatStream.mockImplementation(() => {
554
+ throw new Error("Agent connection failed");
555
+ });
556
+
557
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
558
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
559
+ const refs = new Map<string, string[]>();
560
+ const result = await (service as any).analyzeWithAgent("claude-code", blocks, refs);
561
+ expect(result.summary).toBe("Agent 调用失败");
562
+ consoleSpy.mockRestore();
563
+ });
564
+
565
+ it("should handle non-Error agent exception", async () => {
566
+ llmProxyService.chatStream.mockImplementation(() => {
567
+ throw "agent string error";
568
+ });
569
+
570
+ const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
571
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
572
+ const refs = new Map<string, string[]>();
573
+ const result = await (service as any).analyzeWithAgent("claude-code", blocks, refs);
574
+ expect(result.summary).toBe("Agent 调用失败");
575
+ consoleSpy.mockRestore();
576
+ });
577
+
578
+ it("should handle invalid agent result", async () => {
579
+ const mockStream = (async function* () {
580
+ yield { type: "result", response: { structuredOutput: null } };
581
+ })();
582
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
583
+
584
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
585
+ const refs = new Map<string, string[]>();
586
+ const result = await (service as any).analyzeWithAgent("claude-code", blocks, refs);
587
+ expect(result.summary).toBe("分析返回格式无效");
588
+ });
589
+
590
+ it("should log with verbose=2", async () => {
591
+ const mockStream = (async function* () {
592
+ yield {
593
+ type: "result",
594
+ response: { structuredOutput: { impacts: [], summary: "ok" } },
595
+ };
596
+ })();
597
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
598
+
599
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
600
+ const refs = new Map<string, string[]>();
601
+ const result = await (service as any).analyzeWithAgent("claude-code", blocks, refs, 2);
602
+ expect(result.summary).toBe("ok");
603
+ });
604
+ });
605
+
606
+ describe("resolveRef", () => {
607
+ it("should return SHA directly for commit hash", async () => {
608
+ const result = await (service as any).resolveRef("abc1234", 1);
609
+ expect(result).toBe("abc1234");
610
+ });
611
+
612
+ it("should return origin/ ref directly", async () => {
613
+ const result = await (service as any).resolveRef("origin/main", 1);
614
+ expect(result).toBe("origin/main");
615
+ });
616
+
617
+ it("should throw for empty ref", async () => {
618
+ await expect((service as any).resolveRef("")).rejects.toThrow("ref 参数不能为空");
619
+ });
620
+
621
+ it("should try local branch first", async () => {
622
+ const mockProcess = new EventEmitter() as any;
623
+ mockProcess.stdout = new EventEmitter();
624
+ mockProcess.stderr = new EventEmitter();
625
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
626
+ process.nextTick(() => {
627
+ mockProcess.stdout.emit("data", "abc123");
628
+ mockProcess.emit("close", 0);
629
+ });
630
+
631
+ const result = await (service as any).resolveRef("main", 1);
632
+ expect(result).toBe("main");
633
+ });
634
+
635
+ it("should fallback to origin/ when local fails", async () => {
636
+ let callCount = 0;
637
+ (child_process.spawn as Mock).mockImplementation(() => {
638
+ callCount++;
639
+ const proc = new EventEmitter() as any;
640
+ proc.stdout = new EventEmitter();
641
+ proc.stderr = new EventEmitter();
642
+ process.nextTick(() => {
643
+ if (callCount === 1) {
644
+ // rev-parse --verify main 失败
645
+ proc.stderr.emit("data", "not found");
646
+ proc.emit("close", 1);
647
+ } else {
648
+ // rev-parse --verify origin/main 成功
649
+ proc.stdout.emit("data", "abc123");
650
+ proc.emit("close", 0);
651
+ }
652
+ });
653
+ return proc;
654
+ });
655
+
656
+ const result = await (service as any).resolveRef("main", 1);
657
+ expect(result).toBe("origin/main");
658
+ });
659
+
660
+ it("should try fetch when both local and origin fail", async () => {
661
+ let callCount = 0;
662
+ (child_process.spawn as Mock).mockImplementation(() => {
663
+ callCount++;
664
+ const proc = new EventEmitter() as any;
665
+ proc.stdout = new EventEmitter();
666
+ proc.stderr = new EventEmitter();
667
+ process.nextTick(() => {
668
+ if (callCount <= 2) {
669
+ // rev-parse 失败
670
+ proc.stderr.emit("data", "not found");
671
+ proc.emit("close", 1);
672
+ } else {
673
+ // fetch 成功
674
+ proc.stdout.emit("data", "");
675
+ proc.emit("close", 0);
676
+ }
677
+ });
678
+ return proc;
679
+ });
680
+
681
+ const result = await (service as any).resolveRef("develop", 1);
682
+ expect(result).toBe("origin/develop");
683
+ });
684
+
685
+ it("should return original ref when all attempts fail", async () => {
686
+ (child_process.spawn as Mock).mockImplementation(() => {
687
+ const proc = new EventEmitter() as any;
688
+ proc.stdout = new EventEmitter();
689
+ proc.stderr = new EventEmitter();
690
+ process.nextTick(() => {
691
+ proc.stderr.emit("data", "error");
692
+ proc.emit("close", 1);
693
+ });
694
+ return proc;
695
+ });
696
+
697
+ const result = await (service as any).resolveRef("nonexistent", 1);
698
+ expect(result).toBe("nonexistent");
699
+ });
700
+ });
701
+
702
+ describe("runGitCommand", () => {
703
+ it("should resolve with stdout on success", async () => {
704
+ const mockProcess = new EventEmitter() as any;
705
+ mockProcess.stdout = new EventEmitter();
706
+ mockProcess.stderr = new EventEmitter();
707
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
708
+ process.nextTick(() => {
709
+ mockProcess.stdout.emit("data", "output");
710
+ mockProcess.emit("close", 0);
711
+ });
712
+
713
+ const result = await (service as any).runGitCommand(["status"]);
714
+ expect(result).toBe("output");
715
+ });
716
+
717
+ it("should reject on non-zero exit code", async () => {
718
+ const mockProcess = new EventEmitter() as any;
719
+ mockProcess.stdout = new EventEmitter();
720
+ mockProcess.stderr = new EventEmitter();
721
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
722
+ process.nextTick(() => {
723
+ mockProcess.stderr.emit("data", "error msg");
724
+ mockProcess.emit("close", 1);
725
+ });
726
+
727
+ await expect((service as any).runGitCommand(["bad"])).rejects.toThrow("Git 命令失败");
728
+ });
729
+
730
+ it("should reject on spawn error", async () => {
731
+ const mockProcess = new EventEmitter() as any;
732
+ mockProcess.stdout = new EventEmitter();
733
+ mockProcess.stderr = new EventEmitter();
734
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
735
+ process.nextTick(() => {
736
+ mockProcess.emit("error", new Error("spawn failed"));
737
+ });
738
+
739
+ await expect((service as any).runGitCommand(["bad"])).rejects.toThrow("spawn failed");
740
+ });
741
+ });
742
+
743
+ describe("findCodeReferences", () => {
744
+ it("should find references using git grep", async () => {
745
+ (child_process.spawn as Mock).mockImplementation(() => {
746
+ const proc = new EventEmitter() as any;
747
+ proc.stdout = new EventEmitter();
748
+ proc.stderr = new EventEmitter();
749
+ process.nextTick(() => {
750
+ proc.stdout.emit("data", "other.ts\nanother.ts\n");
751
+ proc.emit("close", 0);
752
+ });
753
+ return proc;
754
+ });
755
+
756
+ const blocks = [
757
+ { file: "test.ts", startLine: 1, endLine: 5, content: "function myFunc() {}" },
758
+ ];
759
+ const refs = await (service as any).findCodeReferences(blocks);
760
+ expect(refs.size).toBeGreaterThanOrEqual(0);
761
+ });
762
+
763
+ it("should skip short identifiers", async () => {
764
+ (child_process.spawn as Mock).mockImplementation(() => {
765
+ const proc = new EventEmitter() as any;
766
+ proc.stdout = new EventEmitter();
767
+ proc.stderr = new EventEmitter();
768
+ process.nextTick(() => {
769
+ proc.emit("close", 1);
770
+ });
771
+ return proc;
772
+ });
773
+
774
+ const blocks = [{ file: "test.ts", startLine: 1, endLine: 1, content: "const x = 1;" }];
775
+ const refs = await (service as any).findCodeReferences(blocks);
776
+ expect(refs.size).toBe(0);
777
+ });
778
+
779
+ it("should handle grep errors gracefully", async () => {
780
+ (child_process.spawn as Mock).mockImplementation(() => {
781
+ const proc = new EventEmitter() as any;
782
+ proc.stdout = new EventEmitter();
783
+ proc.stderr = new EventEmitter();
784
+ process.nextTick(() => {
785
+ proc.stderr.emit("data", "error");
786
+ proc.emit("close", 1);
787
+ });
788
+ return proc;
789
+ });
790
+
791
+ const blocks = [
792
+ { file: "test.ts", startLine: 1, endLine: 5, content: "function longFuncName() {}" },
793
+ ];
794
+ const refs = await (service as any).findCodeReferences(blocks);
795
+ expect(refs.size).toBe(0);
796
+ });
797
+ });
798
+
799
+ describe("getDeletedCodeBlocks", () => {
800
+ it("should parse deleted blocks from git diff output", async () => {
801
+ let callCount = 0;
802
+ (child_process.spawn as Mock).mockImplementation(() => {
803
+ callCount++;
804
+ const proc = new EventEmitter() as any;
805
+ proc.stdout = new EventEmitter();
806
+ proc.stderr = new EventEmitter();
807
+ process.nextTick(() => {
808
+ if (callCount <= 2) {
809
+ // resolveRef: rev-parse 成功
810
+ proc.stdout.emit("data", "abc123");
811
+ proc.emit("close", 0);
812
+ } else {
813
+ // git diff 返回有删除的内容
814
+ proc.stdout.emit(
815
+ "data",
816
+ `diff --git a/test.ts b/test.ts\n--- a/test.ts\n+++ b/test.ts\n@@ -1,3 +1,1 @@\n-const old1 = 1;\n-const old2 = 2;\n+const new1 = 1;\n@@ -10,2 +8,0 @@\n-function removed() {}\n-// end`,
817
+ );
818
+ proc.emit("close", 0);
819
+ }
820
+ });
821
+ return proc;
822
+ });
823
+
824
+ const blocks = await (service as any).getDeletedCodeBlocks("main", "feature", 1);
825
+ expect(blocks.length).toBeGreaterThanOrEqual(1);
826
+ });
827
+
828
+ it("should handle diff with last block at end", async () => {
829
+ let callCount = 0;
830
+ (child_process.spawn as Mock).mockImplementation(() => {
831
+ callCount++;
832
+ const proc = new EventEmitter() as any;
833
+ proc.stdout = new EventEmitter();
834
+ proc.stderr = new EventEmitter();
835
+ process.nextTick(() => {
836
+ if (callCount <= 2) {
837
+ proc.stdout.emit("data", "abc123");
838
+ proc.emit("close", 0);
839
+ } else {
840
+ proc.stdout.emit(
841
+ "data",
842
+ `diff --git a/test.ts b/test.ts\n--- a/test.ts\n+++ b/test.ts\n@@ -1,2 +1,0 @@\n-const removed1 = true;\n-const removed2 = true;`,
843
+ );
844
+ proc.emit("close", 0);
845
+ }
846
+ });
847
+ return proc;
848
+ });
849
+
850
+ const blocks = await (service as any).getDeletedCodeBlocks("main", "feature");
851
+ expect(blocks.length).toBeGreaterThanOrEqual(1);
852
+ });
853
+ });
854
+
855
+ describe("analyzeDeletionImpact - git-diff with blocks", () => {
856
+ it("should analyze deleted blocks from git diff", async () => {
857
+ let callCount = 0;
858
+ (child_process.spawn as Mock).mockImplementation(() => {
859
+ callCount++;
860
+ const proc = new EventEmitter() as any;
861
+ proc.stdout = new EventEmitter();
862
+ proc.stderr = new EventEmitter();
863
+ process.nextTick(() => {
864
+ if (callCount <= 2) {
865
+ proc.stdout.emit("data", "abc123");
866
+ proc.emit("close", 0);
867
+ } else if (callCount === 3) {
868
+ // git diff 返回有删除的内容
869
+ proc.stdout.emit(
870
+ "data",
871
+ `diff --git a/test.ts b/test.ts\n--- a/test.ts\n+++ b/test.ts\n@@ -1,1 +1,0 @@\n-function removedFunc() {}`,
872
+ );
873
+ proc.emit("close", 0);
874
+ } else {
875
+ // git grep 失败(没有引用)
876
+ proc.stderr.emit("data", "");
877
+ proc.emit("close", 1);
878
+ }
879
+ });
880
+ return proc;
881
+ });
882
+
883
+ const mockStream = (async function* () {
884
+ yield {
885
+ type: "result",
886
+ response: {
887
+ structuredOutput: {
888
+ impacts: [
889
+ {
890
+ file: "test.ts",
891
+ deletedCode: "removedFunc",
892
+ riskLevel: "low",
893
+ affectedFiles: [],
894
+ reason: "safe",
895
+ },
896
+ ],
897
+ summary: "safe deletion",
898
+ },
899
+ },
900
+ };
901
+ })();
902
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
903
+
904
+ const context = { baseRef: "main", headRef: "feature" };
905
+ const result = await service.analyzeDeletionImpact(context, "openai", 1);
906
+ expect(result.impacts.length).toBeGreaterThanOrEqual(1);
907
+ expect(result.summary).toBe("safe deletion");
908
+ });
909
+ });
910
+
911
+ describe("analyzeDeletionImpact - agent mode", () => {
912
+ it("should use agent mode for claude-code", async () => {
913
+ const context = { owner: "o", repo: "r", prNumber: 1, analysisMode: "claude-code" as const };
914
+ gitProvider.getPullRequestFiles.mockResolvedValue([
915
+ {
916
+ filename: "test.ts",
917
+ patch: "@@ -1,1 +1,0 @@\n-const removed = true;",
918
+ deletions: 1,
919
+ },
920
+ ] as any);
921
+
922
+ const mockProcess = new EventEmitter() as any;
923
+ mockProcess.stdout = new EventEmitter();
924
+ mockProcess.stderr = new EventEmitter();
925
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
926
+ process.nextTick(() => {
927
+ mockProcess.emit("close", 1);
928
+ });
929
+
930
+ const mockStream = (async function* () {
931
+ yield {
932
+ type: "result",
933
+ response: { structuredOutput: { impacts: [], summary: "agent analysis" } },
934
+ };
935
+ })();
936
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
937
+
938
+ const result = await service.analyzeDeletionImpact(context, "openai");
939
+ expect(result.summary).toBe("agent analysis");
940
+ });
941
+ });
942
+
943
+ describe("analyzeDeletionImpact - defensive checks", () => {
944
+ it("should fix invalid impacts array", async () => {
945
+ const context = { owner: "o", repo: "r", prNumber: 1 };
946
+ gitProvider.getPullRequestFiles.mockResolvedValue([
947
+ {
948
+ filename: "test.ts",
949
+ patch: "@@ -1,1 +1,0 @@\n-const removed = true;",
950
+ deletions: 1,
951
+ },
952
+ ] as any);
953
+
954
+ const mockProcess = new EventEmitter() as any;
955
+ mockProcess.stdout = new EventEmitter();
956
+ mockProcess.stderr = new EventEmitter();
957
+ (child_process.spawn as Mock).mockReturnValue(mockProcess);
958
+ process.nextTick(() => {
959
+ mockProcess.emit("close", 1);
960
+ });
961
+
962
+ const mockStream = (async function* () {
963
+ yield {
964
+ type: "result",
965
+ response: { structuredOutput: { impacts: null, summary: "ok" } },
966
+ };
967
+ })();
968
+ llmProxyService.chatStream.mockReturnValue(mockStream as any);
969
+
970
+ const result = await service.analyzeDeletionImpact(context, "openai");
971
+ expect(result.impacts).toEqual([]);
972
+ });
973
+ });
974
+ });