@spaceflow/review 0.80.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.
- package/CHANGELOG.md +27 -0
- package/dist/index.js +1048 -762
- package/package.json +3 -3
- package/src/README.md +0 -1
- package/src/changed-file-collection.ts +87 -0
- package/src/mcp/index.ts +5 -1
- package/src/prompt/issue-verify.ts +8 -3
- package/src/review-context.spec.ts +214 -0
- package/src/review-context.ts +4 -2
- package/src/review-issue-filter.spec.ts +742 -0
- package/src/review-issue-filter.ts +21 -280
- package/src/review-llm.spec.ts +287 -0
- package/src/review-llm.ts +19 -23
- package/src/review-report/formatters/markdown.formatter.ts +6 -7
- package/src/review-result-model.spec.ts +35 -4
- package/src/review-result-model.ts +58 -10
- package/src/review-source-resolver.ts +636 -0
- package/src/review-spec/review-spec.service.spec.ts +94 -12
- package/src/review-spec/review-spec.service.ts +289 -59
- package/src/review.service.spec.ts +142 -1154
- package/src/review.service.ts +177 -534
- package/src/types/changed-file-collection.ts +5 -0
- package/src/types/review-source-resolver.ts +55 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
import { ReviewIssueFilter } from "./review-issue-filter";
|
|
3
|
+
import { ReviewSourceResolver } from "./review-source-resolver";
|
|
4
|
+
import type { ReviewIssue, FileContentsMap } from "./review-spec/types";
|
|
5
|
+
|
|
6
|
+
function mockIssue(overrides: Partial<ReviewIssue> = {}): ReviewIssue {
|
|
7
|
+
return {
|
|
8
|
+
file: "",
|
|
9
|
+
line: "1",
|
|
10
|
+
code: "",
|
|
11
|
+
ruleId: "",
|
|
12
|
+
specFile: "",
|
|
13
|
+
reason: "",
|
|
14
|
+
severity: "error",
|
|
15
|
+
round: 1,
|
|
16
|
+
...overrides,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("ReviewIssueFilter", () => {
|
|
21
|
+
let filter: ReviewIssueFilter;
|
|
22
|
+
let resolver: ReviewSourceResolver;
|
|
23
|
+
let gitProvider: any;
|
|
24
|
+
let configService: any;
|
|
25
|
+
let mockReviewSpecService: any;
|
|
26
|
+
let mockIssueVerifyService: any;
|
|
27
|
+
let mockGitSdkService: any;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.clearAllMocks();
|
|
31
|
+
gitProvider = {
|
|
32
|
+
getFileContent: vi.fn(),
|
|
33
|
+
getCommit: vi.fn(),
|
|
34
|
+
getCommitDiff: vi.fn(),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
configService = {
|
|
38
|
+
get: vi.fn(),
|
|
39
|
+
getPluginConfig: vi.fn().mockReturnValue({}),
|
|
40
|
+
registerSchema: vi.fn(),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
mockReviewSpecService = {
|
|
44
|
+
resolveSpecSources: vi.fn().mockResolvedValue(["/mock/spec/dir"]),
|
|
45
|
+
loadReviewSpecs: vi.fn().mockResolvedValue([
|
|
46
|
+
{
|
|
47
|
+
filename: "ts.base.md",
|
|
48
|
+
extensions: ["ts"],
|
|
49
|
+
rules: [{ id: "R1", title: "Rule 1", description: "D1", examples: [], overrides: [] }],
|
|
50
|
+
overrides: [],
|
|
51
|
+
includes: [],
|
|
52
|
+
},
|
|
53
|
+
]),
|
|
54
|
+
applyOverrides: vi.fn().mockImplementation((specs) => specs),
|
|
55
|
+
filterApplicableSpecs: vi.fn().mockImplementation((specs) => specs),
|
|
56
|
+
filterIssuesByIncludes: vi.fn().mockImplementation((issues) => issues),
|
|
57
|
+
filterIssuesByOverrides: vi.fn().mockImplementation((issues) => issues),
|
|
58
|
+
filterIssuesByCommits: vi.fn().mockImplementation((issues) => issues),
|
|
59
|
+
formatIssues: vi.fn().mockImplementation((issues) => issues),
|
|
60
|
+
buildSpecsSection: vi.fn().mockReturnValue("mock specs section"),
|
|
61
|
+
filterIssuesByRuleExistence: vi.fn().mockImplementation((issues) => issues),
|
|
62
|
+
deduplicateSpecs: vi.fn().mockImplementation((specs) => specs),
|
|
63
|
+
parseLineRange: vi.fn().mockImplementation((lineStr: string) => {
|
|
64
|
+
const lines: number[] = [];
|
|
65
|
+
const rangeMatch = lineStr.match(/^(\d+)-(\d+)$/);
|
|
66
|
+
if (rangeMatch) {
|
|
67
|
+
const start = parseInt(rangeMatch[1], 10);
|
|
68
|
+
const end = parseInt(rangeMatch[2], 10);
|
|
69
|
+
for (let i = start; i <= end; i++) lines.push(i);
|
|
70
|
+
} else {
|
|
71
|
+
const line = parseInt(lineStr, 10);
|
|
72
|
+
if (!isNaN(line)) lines.push(line);
|
|
73
|
+
}
|
|
74
|
+
return lines;
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
mockIssueVerifyService = {
|
|
79
|
+
verifyIssueFixes: vi.fn().mockImplementation((issues) => Promise.resolve(issues)),
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
mockGitSdkService = {
|
|
83
|
+
parseChangedLinesFromPatch: vi.fn().mockReturnValue(new Set()),
|
|
84
|
+
parseDiffText: vi.fn().mockReturnValue([]),
|
|
85
|
+
getChangedFilesBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
86
|
+
getCommitsBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
87
|
+
getDiffBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
88
|
+
getFileContent: vi.fn().mockResolvedValue(""),
|
|
89
|
+
getFilesForCommit: vi.fn().mockResolvedValue([]),
|
|
90
|
+
getWorkingFileContent: vi.fn().mockReturnValue(""),
|
|
91
|
+
getCommitDiff: vi.fn().mockReturnValue([]),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
filter = new ReviewIssueFilter(
|
|
95
|
+
gitProvider as any,
|
|
96
|
+
configService as any,
|
|
97
|
+
mockReviewSpecService as any,
|
|
98
|
+
mockIssueVerifyService as any,
|
|
99
|
+
mockGitSdkService as any,
|
|
100
|
+
);
|
|
101
|
+
resolver = new ReviewSourceResolver(gitProvider as any, mockGitSdkService as any, filter);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterEach(() => {
|
|
105
|
+
vi.clearAllMocks();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("getFileContents", () => {
|
|
109
|
+
it("should get file contents with additions mapped to commit hash", async () => {
|
|
110
|
+
const changedFiles = [
|
|
111
|
+
{
|
|
112
|
+
filename: "test.ts",
|
|
113
|
+
status: "modified",
|
|
114
|
+
patch: `@@ -1,3 +1,5 @@
|
|
115
|
+
line1
|
|
116
|
+
+new line 1
|
|
117
|
+
+new line 2
|
|
118
|
+
line2
|
|
119
|
+
line3`,
|
|
120
|
+
},
|
|
121
|
+
];
|
|
122
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
123
|
+
|
|
124
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nnew line 1\nnew line 2\nline2\nline3");
|
|
125
|
+
|
|
126
|
+
const result = await resolver.getFileContents(
|
|
127
|
+
"owner",
|
|
128
|
+
"repo",
|
|
129
|
+
changedFiles,
|
|
130
|
+
commits,
|
|
131
|
+
"abc1234",
|
|
132
|
+
123,
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(result.size).toBe(1);
|
|
136
|
+
const fileContent = result.get("test.ts");
|
|
137
|
+
expect(fileContent).toHaveLength(5);
|
|
138
|
+
expect(fileContent![0][0]).toBe("-------");
|
|
139
|
+
expect(fileContent![1][0]).toBe("abc1234");
|
|
140
|
+
expect(fileContent![2][0]).toBe("abc1234");
|
|
141
|
+
expect(fileContent![3][0]).toBe("-------");
|
|
142
|
+
expect(fileContent![4][0]).toBe("-------");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("should assign ------- to all lines when no patch is available", async () => {
|
|
146
|
+
const changedFiles = [
|
|
147
|
+
{
|
|
148
|
+
filename: "test.ts",
|
|
149
|
+
status: "modified",
|
|
150
|
+
},
|
|
151
|
+
];
|
|
152
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
153
|
+
|
|
154
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3");
|
|
155
|
+
|
|
156
|
+
const result = await resolver.getFileContents(
|
|
157
|
+
"owner",
|
|
158
|
+
"repo",
|
|
159
|
+
changedFiles,
|
|
160
|
+
commits,
|
|
161
|
+
"abc1234",
|
|
162
|
+
123,
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const fileContent = result.get("test.ts");
|
|
166
|
+
expect(fileContent).toBeDefined();
|
|
167
|
+
expect(fileContent!.every(([hash]) => hash === "-------")).toBe(true);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("should mark all lines as changed for new files without patch", async () => {
|
|
171
|
+
const changedFiles = [
|
|
172
|
+
{
|
|
173
|
+
filename: "new-file.ts",
|
|
174
|
+
status: "added",
|
|
175
|
+
additions: 3,
|
|
176
|
+
deletions: 0,
|
|
177
|
+
},
|
|
178
|
+
];
|
|
179
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
180
|
+
|
|
181
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3");
|
|
182
|
+
|
|
183
|
+
const result = await resolver.getFileContents(
|
|
184
|
+
"owner",
|
|
185
|
+
"repo",
|
|
186
|
+
changedFiles,
|
|
187
|
+
commits,
|
|
188
|
+
"abc1234",
|
|
189
|
+
123,
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const fileContent = result.get("new-file.ts");
|
|
193
|
+
expect(fileContent).toBeDefined();
|
|
194
|
+
expect(fileContent!.every(([hash]) => hash === "abc1234")).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("should skip deleted files", async () => {
|
|
198
|
+
const changedFiles = [
|
|
199
|
+
{
|
|
200
|
+
filename: "deleted.ts",
|
|
201
|
+
status: "deleted",
|
|
202
|
+
},
|
|
203
|
+
];
|
|
204
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
205
|
+
|
|
206
|
+
const result = await resolver.getFileContents(
|
|
207
|
+
"owner",
|
|
208
|
+
"repo",
|
|
209
|
+
changedFiles,
|
|
210
|
+
commits,
|
|
211
|
+
"abc1234",
|
|
212
|
+
123,
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
expect(result.size).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("should get file contents with PR mode", async () => {
|
|
219
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2\nline3" as any);
|
|
220
|
+
const changedFiles = [
|
|
221
|
+
{
|
|
222
|
+
filename: "test.ts",
|
|
223
|
+
status: "modified",
|
|
224
|
+
patch: "@@ -1,2 +1,3 @@\n line1\n+line2\n line3",
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
228
|
+
const result = await resolver.getFileContents("o", "r", changedFiles, commits, "abc", 1);
|
|
229
|
+
expect(result.has("test.ts")).toBe(true);
|
|
230
|
+
expect(result.get("test.ts")).toHaveLength(3);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("should get file contents with git sdk mode (no PR)", async () => {
|
|
234
|
+
mockGitSdkService.getFileContent.mockResolvedValue("line1\nline2");
|
|
235
|
+
const changedFiles = [
|
|
236
|
+
{ filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+line2" },
|
|
237
|
+
];
|
|
238
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
239
|
+
const result = await resolver.getFileContents("o", "r", changedFiles, commits, "HEAD");
|
|
240
|
+
expect(result.has("test.ts")).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("should handle file content fetch error", async () => {
|
|
244
|
+
gitProvider.getFileContent.mockRejectedValue(new Error("not found") as any);
|
|
245
|
+
const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
246
|
+
const changedFiles = [{ filename: "missing.ts", status: "modified" }];
|
|
247
|
+
const result = await resolver.getFileContents("o", "r", changedFiles, [], "HEAD", 1);
|
|
248
|
+
expect(result.size).toBe(0);
|
|
249
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
250
|
+
consoleSpy.mockRestore();
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("should get file contents with verbose=3 logging", async () => {
|
|
254
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2" as any);
|
|
255
|
+
const changedFiles = [
|
|
256
|
+
{ filename: "test.ts", status: "modified", patch: "@@ -1,1 +1,2 @@\n line1\n+line2" },
|
|
257
|
+
];
|
|
258
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
259
|
+
const result = await resolver.getFileContents(
|
|
260
|
+
"o",
|
|
261
|
+
"r",
|
|
262
|
+
changedFiles,
|
|
263
|
+
commits,
|
|
264
|
+
"abc",
|
|
265
|
+
1,
|
|
266
|
+
false,
|
|
267
|
+
3,
|
|
268
|
+
);
|
|
269
|
+
expect(result.has("test.ts")).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("should mark all lines as changed for new files without patch (additions only)", async () => {
|
|
273
|
+
gitProvider.getFileContent.mockResolvedValue("line1\nline2" as any);
|
|
274
|
+
const changedFiles = [{ filename: "new.ts", status: "added", additions: 2, deletions: 0 }];
|
|
275
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
276
|
+
const result = await resolver.getFileContents("o", "r", changedFiles, commits, "abc", 1);
|
|
277
|
+
expect(result.has("new.ts")).toBe(true);
|
|
278
|
+
const lines = result.get("new.ts");
|
|
279
|
+
expect(lines![0][0]).toBe("abc1234");
|
|
280
|
+
expect(lines![1][0]).toBe("abc1234");
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("getChangedFilesBetweenRefs", () => {
|
|
285
|
+
it("should return files with patch from getDiffBetweenRefs", async () => {
|
|
286
|
+
const diffFiles = [
|
|
287
|
+
{
|
|
288
|
+
filename: "test.ts",
|
|
289
|
+
patch: "@@ -1,3 +1,5 @@\n line1\n+new line",
|
|
290
|
+
},
|
|
291
|
+
];
|
|
292
|
+
const statusFiles = [
|
|
293
|
+
{
|
|
294
|
+
filename: "test.ts",
|
|
295
|
+
status: "modified",
|
|
296
|
+
},
|
|
297
|
+
];
|
|
298
|
+
|
|
299
|
+
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue(diffFiles);
|
|
300
|
+
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue(statusFiles);
|
|
301
|
+
|
|
302
|
+
const result = await filter.getChangedFilesBetweenRefs("owner", "repo", "main", "feature");
|
|
303
|
+
|
|
304
|
+
expect(result).toHaveLength(1);
|
|
305
|
+
expect(result[0].filename).toBe("test.ts");
|
|
306
|
+
expect(result[0].status).toBe("modified");
|
|
307
|
+
expect(result[0].patch).toBe("@@ -1,3 +1,5 @@\n line1\n+new line");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should handle files in diff but not in status", async () => {
|
|
311
|
+
const diffFiles = [
|
|
312
|
+
{
|
|
313
|
+
filename: "new.ts",
|
|
314
|
+
patch: "@@ -0,0 +1,3 @@\n+line1",
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
const statusFiles: any[] = [];
|
|
318
|
+
|
|
319
|
+
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue(diffFiles);
|
|
320
|
+
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue(statusFiles);
|
|
321
|
+
|
|
322
|
+
const result = await filter.getChangedFilesBetweenRefs("owner", "repo", "main", "feature");
|
|
323
|
+
|
|
324
|
+
expect(result).toHaveLength(1);
|
|
325
|
+
expect(result[0].filename).toBe("new.ts");
|
|
326
|
+
expect(result[0].status).toBe("modified");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("should merge diff and status info", async () => {
|
|
330
|
+
mockGitSdkService.getDiffBetweenRefs.mockResolvedValue([
|
|
331
|
+
{ filename: "a.ts", patch: "diff content" },
|
|
332
|
+
]);
|
|
333
|
+
mockGitSdkService.getChangedFilesBetweenRefs.mockResolvedValue([
|
|
334
|
+
{ filename: "a.ts", status: "added" },
|
|
335
|
+
]);
|
|
336
|
+
const result = await filter.getChangedFilesBetweenRefs("o", "r", "main", "feature");
|
|
337
|
+
expect(result).toHaveLength(1);
|
|
338
|
+
expect(result[0].status).toBe("added");
|
|
339
|
+
expect(result[0].patch).toBe("diff content");
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("filterIssuesByValidCommits", () => {
|
|
344
|
+
it("should keep issues on changed lines", () => {
|
|
345
|
+
const issues = [
|
|
346
|
+
mockIssue({
|
|
347
|
+
file: "test.ts",
|
|
348
|
+
line: "1",
|
|
349
|
+
ruleId: "R1",
|
|
350
|
+
specFile: "s1.md",
|
|
351
|
+
reason: "issue on unchanged",
|
|
352
|
+
}),
|
|
353
|
+
mockIssue({
|
|
354
|
+
file: "test.ts",
|
|
355
|
+
line: "2",
|
|
356
|
+
ruleId: "R2",
|
|
357
|
+
specFile: "s1.md",
|
|
358
|
+
reason: "issue on changed",
|
|
359
|
+
}),
|
|
360
|
+
];
|
|
361
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
362
|
+
const fileContents: FileContentsMap = new Map([
|
|
363
|
+
[
|
|
364
|
+
"test.ts",
|
|
365
|
+
[
|
|
366
|
+
["-------", "line1"],
|
|
367
|
+
["abc1234", "new line"],
|
|
368
|
+
["-------", "line2"],
|
|
369
|
+
],
|
|
370
|
+
],
|
|
371
|
+
]);
|
|
372
|
+
|
|
373
|
+
const result = filter.filterIssuesByValidCommits(issues, commits, fileContents);
|
|
374
|
+
|
|
375
|
+
expect(result).toHaveLength(1);
|
|
376
|
+
expect(result[0].line).toBe("2");
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it("should keep issues when file not in fileContents", () => {
|
|
380
|
+
const issues = [
|
|
381
|
+
mockIssue({ file: "unknown.ts", ruleId: "R1", specFile: "s1.md", reason: "issue" }),
|
|
382
|
+
];
|
|
383
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
384
|
+
const fileContents: FileContentsMap = new Map();
|
|
385
|
+
|
|
386
|
+
const result = filter.filterIssuesByValidCommits(issues, commits, fileContents);
|
|
387
|
+
|
|
388
|
+
expect(result).toHaveLength(1);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("should keep issues with range if any line is changed", () => {
|
|
392
|
+
const issues = [
|
|
393
|
+
mockIssue({
|
|
394
|
+
file: "test.ts",
|
|
395
|
+
line: "1-3",
|
|
396
|
+
ruleId: "R1",
|
|
397
|
+
specFile: "s1.md",
|
|
398
|
+
reason: "range issue",
|
|
399
|
+
}),
|
|
400
|
+
];
|
|
401
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
402
|
+
const fileContents: FileContentsMap = new Map([
|
|
403
|
+
[
|
|
404
|
+
"test.ts",
|
|
405
|
+
[
|
|
406
|
+
["-------", "line1"],
|
|
407
|
+
["abc1234", "new line"],
|
|
408
|
+
["-------", "line3"],
|
|
409
|
+
],
|
|
410
|
+
],
|
|
411
|
+
]);
|
|
412
|
+
|
|
413
|
+
const result = filter.filterIssuesByValidCommits(issues, commits, fileContents);
|
|
414
|
+
|
|
415
|
+
expect(result).toHaveLength(1);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it("should filter issue when all lines match non-commit hash", () => {
|
|
419
|
+
const issues = [
|
|
420
|
+
mockIssue({
|
|
421
|
+
file: "test.ts",
|
|
422
|
+
line: "1-3",
|
|
423
|
+
ruleId: "R1",
|
|
424
|
+
specFile: "s1.md",
|
|
425
|
+
reason: "range issue",
|
|
426
|
+
}),
|
|
427
|
+
];
|
|
428
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
429
|
+
const fileContents: FileContentsMap = new Map([
|
|
430
|
+
[
|
|
431
|
+
"test.ts",
|
|
432
|
+
[
|
|
433
|
+
["def5678", "line1"],
|
|
434
|
+
["def5678", "line2"],
|
|
435
|
+
["def5678", "line3"],
|
|
436
|
+
],
|
|
437
|
+
],
|
|
438
|
+
]);
|
|
439
|
+
|
|
440
|
+
const result = filter.filterIssuesByValidCommits(issues, commits, fileContents);
|
|
441
|
+
|
|
442
|
+
expect(result).toHaveLength(0);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it("should filter issues by valid commit hashes", () => {
|
|
446
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
447
|
+
const fileContents = new Map([
|
|
448
|
+
[
|
|
449
|
+
"test.ts",
|
|
450
|
+
[
|
|
451
|
+
["-------", "line1"],
|
|
452
|
+
["abc1234", "line2"],
|
|
453
|
+
["-------", "line3"],
|
|
454
|
+
],
|
|
455
|
+
],
|
|
456
|
+
]);
|
|
457
|
+
const issues = [
|
|
458
|
+
mockIssue({ file: "test.ts", line: "2", ruleId: "R1" }),
|
|
459
|
+
mockIssue({ file: "test.ts", line: "1", ruleId: "R2" }),
|
|
460
|
+
mockIssue({ file: "test.ts", line: "3", ruleId: "R3" }),
|
|
461
|
+
];
|
|
462
|
+
const result = filter.filterIssuesByValidCommits(
|
|
463
|
+
issues,
|
|
464
|
+
commits,
|
|
465
|
+
fileContents as FileContentsMap,
|
|
466
|
+
2,
|
|
467
|
+
);
|
|
468
|
+
expect(result).toHaveLength(1);
|
|
469
|
+
expect(result[0].ruleId).toBe("R1");
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
it("should log filtering summary", () => {
|
|
473
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
474
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
475
|
+
const fileContents = new Map([
|
|
476
|
+
[
|
|
477
|
+
"test.ts",
|
|
478
|
+
[
|
|
479
|
+
["-------", "line1"],
|
|
480
|
+
["abc1234", "line2"],
|
|
481
|
+
],
|
|
482
|
+
],
|
|
483
|
+
]);
|
|
484
|
+
const issues = [
|
|
485
|
+
mockIssue({ file: "test.ts", line: "1", ruleId: "R1" }),
|
|
486
|
+
mockIssue({ file: "test.ts", line: "2", ruleId: "R2" }),
|
|
487
|
+
];
|
|
488
|
+
filter.filterIssuesByValidCommits(issues, commits, fileContents as FileContentsMap, 1);
|
|
489
|
+
expect(consoleSpy).toHaveBeenCalledWith(" 变更行过滤后: 2 -> 1 个问题");
|
|
490
|
+
consoleSpy.mockRestore();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("should keep issues when file not in fileContents (simple)", () => {
|
|
494
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
495
|
+
const fileContents = new Map();
|
|
496
|
+
const issues = [mockIssue({ file: "missing.ts", ruleId: "R1" })];
|
|
497
|
+
const result = filter.filterIssuesByValidCommits(issues, commits, fileContents);
|
|
498
|
+
expect(result).toEqual(issues);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("should keep issues when line range cannot be parsed", () => {
|
|
502
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
503
|
+
const fileContents: FileContentsMap = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
504
|
+
const issues = [mockIssue({ file: "test.ts", line: "abc", ruleId: "R1" })];
|
|
505
|
+
const result = filter.filterIssuesByValidCommits(issues, commits, fileContents);
|
|
506
|
+
expect(result).toEqual(issues);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it("should handle range line numbers", () => {
|
|
510
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
511
|
+
const fileContents = new Map([
|
|
512
|
+
[
|
|
513
|
+
"test.ts",
|
|
514
|
+
[
|
|
515
|
+
["-------", "line1"],
|
|
516
|
+
["abc1234", "line2"],
|
|
517
|
+
["-------", "line3"],
|
|
518
|
+
],
|
|
519
|
+
],
|
|
520
|
+
]);
|
|
521
|
+
const issues = [mockIssue({ file: "test.ts", line: "1-3", ruleId: "R1" })];
|
|
522
|
+
const result = filter.filterIssuesByValidCommits(
|
|
523
|
+
issues,
|
|
524
|
+
commits,
|
|
525
|
+
fileContents as FileContentsMap,
|
|
526
|
+
);
|
|
527
|
+
expect(result).toHaveLength(1);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("should log when file not in fileContents at verbose level 3", () => {
|
|
531
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
532
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
533
|
+
const fileContents = new Map();
|
|
534
|
+
const issues = [mockIssue({ file: "missing.ts", ruleId: "R1" })];
|
|
535
|
+
filter.filterIssuesByValidCommits(issues, commits, fileContents, 3);
|
|
536
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
537
|
+
" ✅ Issue missing.ts:1 - 文件不在 fileContents 中,保留",
|
|
538
|
+
);
|
|
539
|
+
consoleSpy.mockRestore();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("should log when line range cannot be parsed at verbose level 3", () => {
|
|
543
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
544
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
545
|
+
const fileContents: FileContentsMap = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
546
|
+
const issues = [mockIssue({ file: "test.ts", line: "abc", ruleId: "R1" })];
|
|
547
|
+
filter.filterIssuesByValidCommits(issues, commits, fileContents, 3);
|
|
548
|
+
expect(consoleSpy).toHaveBeenCalledWith(" ✅ Issue test.ts:abc - 无法解析行号,保留");
|
|
549
|
+
consoleSpy.mockRestore();
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it("should log detailed hash matching at verbose level 3", () => {
|
|
553
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
554
|
+
const commits = [{ sha: "abc1234567890" }];
|
|
555
|
+
const fileContents = new Map([
|
|
556
|
+
[
|
|
557
|
+
"test.ts",
|
|
558
|
+
[
|
|
559
|
+
["-------", "line1"],
|
|
560
|
+
["abc1234", "line2"],
|
|
561
|
+
],
|
|
562
|
+
],
|
|
563
|
+
]);
|
|
564
|
+
const issues = [mockIssue({ file: "test.ts", line: "2", ruleId: "R1" })];
|
|
565
|
+
filter.filterIssuesByValidCommits(issues, commits, fileContents as FileContentsMap, 3);
|
|
566
|
+
expect(consoleSpy).toHaveBeenCalledWith(" 🔍 有效 commit hashes: abc1234");
|
|
567
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
568
|
+
" ✅ Issue test.ts:2 - 行 2 hash=abc1234 匹配,保留",
|
|
569
|
+
);
|
|
570
|
+
consoleSpy.mockRestore();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it("should use changed-lines mode when commits is empty", () => {
|
|
574
|
+
const issues = [
|
|
575
|
+
mockIssue({ file: "test.ts", line: "1", ruleId: "R1" }),
|
|
576
|
+
mockIssue({ file: "test.ts", line: "2", ruleId: "R2" }),
|
|
577
|
+
mockIssue({ file: "test.ts", line: "3", ruleId: "R3" }),
|
|
578
|
+
];
|
|
579
|
+
const commits: never[] = [];
|
|
580
|
+
const fileContents: FileContentsMap = new Map([
|
|
581
|
+
[
|
|
582
|
+
"test.ts",
|
|
583
|
+
[
|
|
584
|
+
["-------", "unchanged line"],
|
|
585
|
+
["abc1234", "changed line"],
|
|
586
|
+
["-------", "unchanged line"],
|
|
587
|
+
],
|
|
588
|
+
],
|
|
589
|
+
]);
|
|
590
|
+
|
|
591
|
+
const result = filter.filterIssuesByValidCommits(issues, commits, fileContents);
|
|
592
|
+
|
|
593
|
+
expect(result).toHaveLength(1);
|
|
594
|
+
expect(result[0].line).toBe("2");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it("should log 'commits 为空' message at verbose level 3 when commits is empty", () => {
|
|
598
|
+
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
599
|
+
const commits: never[] = [];
|
|
600
|
+
const fileContents: FileContentsMap = new Map([["test.ts", [["abc1234", "changed line"]]]]);
|
|
601
|
+
const issues = [mockIssue({ file: "test.ts", line: "1", ruleId: "R1" })];
|
|
602
|
+
filter.filterIssuesByValidCommits(issues, commits, fileContents, 3);
|
|
603
|
+
expect(consoleSpy).toHaveBeenCalledWith(" 🔍 commits 为空,使用变更行模式过滤");
|
|
604
|
+
consoleSpy.mockRestore();
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("should filter all issues when commits is empty and no changed lines", () => {
|
|
608
|
+
const issues = [
|
|
609
|
+
mockIssue({ file: "test.ts", line: "1", ruleId: "R1" }),
|
|
610
|
+
mockIssue({ file: "test.ts", line: "2", ruleId: "R2" }),
|
|
611
|
+
];
|
|
612
|
+
const commits: never[] = [];
|
|
613
|
+
const fileContents: FileContentsMap = new Map([
|
|
614
|
+
[
|
|
615
|
+
"test.ts",
|
|
616
|
+
[
|
|
617
|
+
["-------", "unchanged line"],
|
|
618
|
+
["-------", "unchanged line"],
|
|
619
|
+
],
|
|
620
|
+
],
|
|
621
|
+
]);
|
|
622
|
+
|
|
623
|
+
const result = filter.filterIssuesByValidCommits(issues, commits, fileContents);
|
|
624
|
+
|
|
625
|
+
expect(result).toHaveLength(0);
|
|
626
|
+
});
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
describe("filterDuplicateIssues", () => {
|
|
630
|
+
it("should filter issues that exist in valid existing issues", () => {
|
|
631
|
+
const newIssues = [
|
|
632
|
+
mockIssue({ file: "a.ts", line: "1", ruleId: "R1" }),
|
|
633
|
+
mockIssue({ file: "b.ts", line: "2", ruleId: "R2" }),
|
|
634
|
+
];
|
|
635
|
+
const existingIssues = [mockIssue({ file: "a.ts", line: "1", ruleId: "R1", valid: "true" })];
|
|
636
|
+
const result = filter.filterDuplicateIssues(newIssues, existingIssues);
|
|
637
|
+
expect(result.filteredIssues).toHaveLength(1);
|
|
638
|
+
expect(result.filteredIssues[0].file).toBe("b.ts");
|
|
639
|
+
expect(result.skippedCount).toBe(1);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("should also filter invalid existing issues to prevent repeated reporting", () => {
|
|
643
|
+
const newIssues = [mockIssue({ file: "a.ts", line: "1", ruleId: "R1" })];
|
|
644
|
+
const existingIssues = [mockIssue({ file: "a.ts", line: "1", ruleId: "R1", valid: "false" })];
|
|
645
|
+
const result = filter.filterDuplicateIssues(newIssues, existingIssues);
|
|
646
|
+
expect(result.filteredIssues).toHaveLength(0);
|
|
647
|
+
expect(result.skippedCount).toBe(1);
|
|
648
|
+
});
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
describe("fillIssueCode", () => {
|
|
652
|
+
it("should fill code from fileContents", async () => {
|
|
653
|
+
const issues = [mockIssue({ file: "test.ts", line: "2" })];
|
|
654
|
+
const fileContents: FileContentsMap = new Map([
|
|
655
|
+
[
|
|
656
|
+
"test.ts",
|
|
657
|
+
[
|
|
658
|
+
["-------", "line1"],
|
|
659
|
+
["abc1234", "line2"],
|
|
660
|
+
["-------", "line3"],
|
|
661
|
+
],
|
|
662
|
+
],
|
|
663
|
+
]);
|
|
664
|
+
const result = await filter.fillIssueCode(issues, fileContents);
|
|
665
|
+
expect(result[0].code).toBe("line2");
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
it("should handle range lines", async () => {
|
|
669
|
+
const issues = [mockIssue({ file: "test.ts", line: "1-2" })];
|
|
670
|
+
const fileContents: FileContentsMap = new Map([
|
|
671
|
+
[
|
|
672
|
+
"test.ts",
|
|
673
|
+
[
|
|
674
|
+
["-------", "line1"],
|
|
675
|
+
["abc1234", "line2"],
|
|
676
|
+
["-------", "line3"],
|
|
677
|
+
],
|
|
678
|
+
],
|
|
679
|
+
]);
|
|
680
|
+
const result = await filter.fillIssueCode(issues, fileContents);
|
|
681
|
+
expect(result[0].code).toBe("line1\nline2");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("should return issue unchanged if file not found", async () => {
|
|
685
|
+
const issues = [mockIssue({ file: "missing.ts" })];
|
|
686
|
+
const result = await filter.fillIssueCode(issues, new Map());
|
|
687
|
+
expect(result[0].code).toBe("");
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("should return issue unchanged if line out of range", async () => {
|
|
691
|
+
const issues = [mockIssue({ file: "test.ts", line: "999" })];
|
|
692
|
+
const fileContents: FileContentsMap = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
693
|
+
const result = await filter.fillIssueCode(issues, fileContents);
|
|
694
|
+
expect(result[0].code).toBe("");
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("should return issue unchanged if line is NaN", async () => {
|
|
698
|
+
const issues = [mockIssue({ file: "test.ts", line: "abc" })];
|
|
699
|
+
const fileContents: FileContentsMap = new Map([["test.ts", [["-------", "line1"]]]]);
|
|
700
|
+
const result = await filter.fillIssueCode(issues, fileContents);
|
|
701
|
+
expect(result[0].code).toBe("");
|
|
702
|
+
});
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
describe("getCommitsBetweenRefs", () => {
|
|
706
|
+
it("should return commits from git sdk", async () => {
|
|
707
|
+
mockGitSdkService.getCommitsBetweenRefs.mockResolvedValue([
|
|
708
|
+
{ sha: "abc", commit: { message: "fix" } },
|
|
709
|
+
]);
|
|
710
|
+
const result = await filter.getCommitsBetweenRefs("main", "feature");
|
|
711
|
+
expect(result).toHaveLength(1);
|
|
712
|
+
});
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
describe("getFilesForCommit", () => {
|
|
716
|
+
it("should return files from git sdk", async () => {
|
|
717
|
+
mockGitSdkService.getFilesForCommit.mockResolvedValue([
|
|
718
|
+
{ filename: "a.ts", status: "modified" },
|
|
719
|
+
]);
|
|
720
|
+
const result = await filter.getFilesForCommit("o", "r", "abc123");
|
|
721
|
+
expect(result).toHaveLength(1);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
it("should use git sdk when no prNumber", async () => {
|
|
725
|
+
mockGitSdkService.getFilesForCommit.mockResolvedValue(["a.ts", "b.ts"]);
|
|
726
|
+
const result = await filter.getFilesForCommit("o", "r", "abc123");
|
|
727
|
+
expect(result).toEqual(["a.ts", "b.ts"]);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("should use git provider when prNumber provided", async () => {
|
|
731
|
+
gitProvider.getCommit.mockResolvedValue({ files: [{ filename: "a.ts" }] } as any);
|
|
732
|
+
const result = await filter.getFilesForCommit("o", "r", "abc123", 1);
|
|
733
|
+
expect(result).toEqual(["a.ts"]);
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("should handle null files from getCommit", async () => {
|
|
737
|
+
gitProvider.getCommit.mockResolvedValue({ files: null } as any);
|
|
738
|
+
const result = await filter.getFilesForCommit("o", "r", "abc123", 1);
|
|
739
|
+
expect(result).toEqual([]);
|
|
740
|
+
});
|
|
741
|
+
});
|
|
742
|
+
});
|