@spaceflow/review 0.77.0 → 0.79.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 +67 -0
- package/dist/index.js +1095 -500
- package/package.json +1 -1
- package/src/deletion-impact.service.ts +13 -128
- package/src/issue-verify.service.ts +18 -82
- package/src/mcp/index.ts +4 -1
- package/src/prompt/code-review.ts +95 -0
- package/src/prompt/deletion-impact.ts +105 -0
- package/src/prompt/index.ts +37 -0
- package/src/prompt/issue-verify.ts +86 -0
- package/src/prompt/pr-description.ts +149 -0
- package/src/prompt/schemas.ts +106 -0
- package/src/prompt/types.ts +53 -0
- package/src/review-context.ts +53 -15
- package/src/review-includes-filter.spec.ts +36 -0
- package/src/review-includes-filter.ts +59 -7
- package/src/review-issue-filter.ts +1 -1
- package/src/review-llm.ts +116 -207
- package/src/review-result-model.ts +28 -6
- package/src/review-spec/review-spec.service.ts +26 -5
- package/src/review.config.ts +31 -5
- package/src/review.service.spec.ts +75 -3
- package/src/review.service.ts +120 -13
- package/src/system-rules/index.ts +48 -0
- package/src/system-rules/max-lines-per-file.ts +57 -0
- package/src/types/review-llm.ts +2 -0
- package/src/utils/review-llm.spec.ts +277 -0
- package/src/utils/review-llm.ts +152 -7
- package/src/utils/review-pr-comment.spec.ts +340 -0
- package/src/{review-pr-comment-utils.ts → utils/review-pr-comment.ts} +2 -2
|
@@ -127,6 +127,10 @@ describe("ReviewService", () => {
|
|
|
127
127
|
parseDiffText: vi.fn().mockReturnValue([]),
|
|
128
128
|
getRemoteUrl: vi.fn().mockReturnValue(null),
|
|
129
129
|
parseRepositoryFromRemoteUrl: vi.fn().mockReturnValue(null),
|
|
130
|
+
getUncommittedFiles: vi.fn().mockReturnValue([]),
|
|
131
|
+
getStagedFiles: vi.fn().mockReturnValue([]),
|
|
132
|
+
getUncommittedDiff: vi.fn().mockReturnValue([]),
|
|
133
|
+
getStagedDiff: vi.fn().mockReturnValue([]),
|
|
130
134
|
getChangedFilesBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
131
135
|
getCommitsBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
132
136
|
getDiffBetweenRefs: vi.fn().mockResolvedValue([]),
|
|
@@ -419,6 +423,26 @@ describe("ReviewService", () => {
|
|
|
419
423
|
expect(result.success).toBe(true);
|
|
420
424
|
});
|
|
421
425
|
|
|
426
|
+
it("should ignore whenModifiedCode in direct file mode", async () => {
|
|
427
|
+
const context: ReviewContext = {
|
|
428
|
+
owner: "owner",
|
|
429
|
+
repo: "repo",
|
|
430
|
+
files: ["src/app.ts"],
|
|
431
|
+
specSources: ["/spec/dir"],
|
|
432
|
+
dryRun: true,
|
|
433
|
+
ci: false,
|
|
434
|
+
llmMode: "openai",
|
|
435
|
+
whenModifiedCode: ["function", "class"],
|
|
436
|
+
};
|
|
437
|
+
const buildReviewPromptSpy = vi.spyOn(service as any, "buildReviewPrompt");
|
|
438
|
+
|
|
439
|
+
const result = await service.execute(context);
|
|
440
|
+
|
|
441
|
+
expect(result.success).toBe(true);
|
|
442
|
+
expect(buildReviewPromptSpy).toHaveBeenCalled();
|
|
443
|
+
expect(buildReviewPromptSpy.mock.calls[0][5]).toBeUndefined();
|
|
444
|
+
});
|
|
445
|
+
|
|
422
446
|
it("should filter files by includes pattern", async () => {
|
|
423
447
|
const context: ReviewContext = {
|
|
424
448
|
owner: "owner",
|
|
@@ -1134,13 +1158,23 @@ describe("ReviewService", () => {
|
|
|
1134
1158
|
expect(result[0].author.login).toBe("GitUser");
|
|
1135
1159
|
});
|
|
1136
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
|
+
|
|
1137
1170
|
it("should handle issues with ------- commit hash", async () => {
|
|
1138
1171
|
const issues = [{ file: "test.ts", line: "1", commit: "-------" }];
|
|
1139
1172
|
const commits = [
|
|
1140
1173
|
{ sha: "abc1234567890", author: { id: 1, login: "dev1" }, commit: { author: {} } },
|
|
1141
1174
|
];
|
|
1142
1175
|
const result = await (service as any).fillIssueAuthors(issues, commits, "o", "r");
|
|
1143
|
-
expect(result[0].
|
|
1176
|
+
expect(result[0].commit).toBeUndefined();
|
|
1177
|
+
expect(result[0].valid).toBe("false");
|
|
1144
1178
|
});
|
|
1145
1179
|
});
|
|
1146
1180
|
|
|
@@ -1353,14 +1387,28 @@ describe("ReviewService", () => {
|
|
|
1353
1387
|
|
|
1354
1388
|
it("should normalize absolute file paths", async () => {
|
|
1355
1389
|
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
1390
|
+
const absPath = `${process.cwd()}/src/file.ts`;
|
|
1356
1391
|
const options = {
|
|
1357
1392
|
dryRun: false,
|
|
1358
1393
|
ci: false,
|
|
1359
|
-
files: [
|
|
1394
|
+
files: [absPath, "./relative.ts"],
|
|
1360
1395
|
};
|
|
1361
1396
|
const context = await service.getContextFromEnv(options as any);
|
|
1362
1397
|
expect(context.files).toBeDefined();
|
|
1363
|
-
expect(context.files
|
|
1398
|
+
expect(context.files).toEqual(["src/file.ts", "relative.ts"]);
|
|
1399
|
+
});
|
|
1400
|
+
|
|
1401
|
+
it("should force direct file mode when files are specified", async () => {
|
|
1402
|
+
configService.get.mockReturnValue({ repository: "owner/repo", refName: "main" });
|
|
1403
|
+
const options = {
|
|
1404
|
+
dryRun: false,
|
|
1405
|
+
ci: false,
|
|
1406
|
+
local: "uncommitted" as const,
|
|
1407
|
+
files: ["./miniprogram/utils/asyncSharedUtilsLoader.js"],
|
|
1408
|
+
};
|
|
1409
|
+
const context = await service.getContextFromEnv(options as any);
|
|
1410
|
+
expect(context.localMode).toBe(false);
|
|
1411
|
+
expect(context.files).toEqual(["miniprogram/utils/asyncSharedUtilsLoader.js"]);
|
|
1364
1412
|
});
|
|
1365
1413
|
|
|
1366
1414
|
it("should auto-detect base/head with verbose logging", async () => {
|
|
@@ -1419,6 +1467,30 @@ describe("ReviewService", () => {
|
|
|
1419
1467
|
});
|
|
1420
1468
|
});
|
|
1421
1469
|
|
|
1470
|
+
describe("ReviewService.resolveSourceData - direct file mode", () => {
|
|
1471
|
+
it("should bypass local uncommitted scanning when files are specified", async () => {
|
|
1472
|
+
const context: ReviewContext = {
|
|
1473
|
+
owner: "o",
|
|
1474
|
+
repo: "r",
|
|
1475
|
+
dryRun: true,
|
|
1476
|
+
ci: false,
|
|
1477
|
+
specSources: ["/spec"],
|
|
1478
|
+
files: ["miniprogram/utils/asyncSharedUtilsLoader.js"],
|
|
1479
|
+
localMode: false,
|
|
1480
|
+
};
|
|
1481
|
+
|
|
1482
|
+
const result = await (service as any).resolveSourceData(context);
|
|
1483
|
+
|
|
1484
|
+
expect(result.isDirectFileMode).toBe(true);
|
|
1485
|
+
expect(result.isLocalMode).toBe(true);
|
|
1486
|
+
expect(result.changedFiles).toEqual([
|
|
1487
|
+
{ filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
|
|
1488
|
+
]);
|
|
1489
|
+
expect(mockGitSdkService.getUncommittedFiles).not.toHaveBeenCalled();
|
|
1490
|
+
expect(mockGitSdkService.getStagedFiles).not.toHaveBeenCalled();
|
|
1491
|
+
});
|
|
1492
|
+
});
|
|
1493
|
+
|
|
1422
1494
|
describe("ReviewService.getFilesForCommit", () => {
|
|
1423
1495
|
it("should return files from git sdk", async () => {
|
|
1424
1496
|
mockGitSdkService.getFilesForCommit.mockResolvedValue([
|
package/src/review.service.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { filterFilesByIncludes, extractGlobsFromIncludes } from "./review-includ
|
|
|
23
23
|
import { ReviewLlmProcessor } from "./review-llm";
|
|
24
24
|
import { PullRequestModel } from "./pull-request-model";
|
|
25
25
|
import { ReviewResultModel, type ReviewResultModelDeps } from "./review-result-model";
|
|
26
|
+
import { REVIEW_COMMENT_MARKER, REVIEW_LINE_COMMENTS_MARKER } from "./utils/review-pr-comment";
|
|
26
27
|
|
|
27
28
|
export type { ReviewContext } from "./review-context";
|
|
28
29
|
export type { FileReviewPrompt, ReviewPrompt, LLMReviewOptions } from "./review-llm";
|
|
@@ -96,10 +97,22 @@ export class ReviewService {
|
|
|
96
97
|
if (source.earlyReturn) return source.earlyReturn;
|
|
97
98
|
|
|
98
99
|
const { prModel, commits, changedFiles, headSha, isDirectFileMode } = source;
|
|
100
|
+
const effectiveWhenModifiedCode = isDirectFileMode ? undefined : context.whenModifiedCode;
|
|
101
|
+
if (isDirectFileMode && context.whenModifiedCode?.length && shouldLog(verbose, 1)) {
|
|
102
|
+
console.log(`ℹ️ 直接文件模式下忽略 whenModifiedCode 过滤`);
|
|
103
|
+
}
|
|
99
104
|
|
|
100
105
|
// 2. 规则匹配
|
|
101
106
|
const specs = await this.issueFilter.loadSpecs(specSources, verbose);
|
|
102
107
|
const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
|
|
108
|
+
if (shouldLog(verbose, 2)) {
|
|
109
|
+
console.log(
|
|
110
|
+
`[execute] loadSpecs: loaded ${specs.length} specs from sources: ${JSON.stringify(specSources)}`,
|
|
111
|
+
);
|
|
112
|
+
console.log(
|
|
113
|
+
`[execute] filterApplicableSpecs: ${applicableSpecs.length} applicable out of ${specs.length}, changedFiles=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
103
116
|
if (shouldLog(verbose, 1)) {
|
|
104
117
|
console.log(` 适用的规则文件: ${applicableSpecs.length}`);
|
|
105
118
|
}
|
|
@@ -138,6 +151,9 @@ export class ReviewService {
|
|
|
138
151
|
fileContents,
|
|
139
152
|
commits,
|
|
140
153
|
existingResultModel?.result ?? null,
|
|
154
|
+
effectiveWhenModifiedCode,
|
|
155
|
+
verbose,
|
|
156
|
+
context.systemRules,
|
|
141
157
|
);
|
|
142
158
|
const result = await this.runLLMReview(llmMode, reviewPrompt, {
|
|
143
159
|
verbose,
|
|
@@ -166,6 +182,14 @@ export class ReviewService {
|
|
|
166
182
|
isDirectFileMode,
|
|
167
183
|
context,
|
|
168
184
|
});
|
|
185
|
+
|
|
186
|
+
// 静态规则产生的系统问题直接合并,不经过过滤管道
|
|
187
|
+
if (reviewPrompt.staticIssues?.length) {
|
|
188
|
+
result.issues = [...reviewPrompt.staticIssues, ...result.issues];
|
|
189
|
+
if (shouldLog(verbose, 1)) {
|
|
190
|
+
console.log(`⚙️ 追加 ${reviewPrompt.staticIssues.length} 个静态规则系统问题`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
169
193
|
if (shouldLog(verbose, 1)) {
|
|
170
194
|
console.log(`📝 最终发现 ${result.issues.length} 个问题`);
|
|
171
195
|
}
|
|
@@ -211,10 +235,10 @@ export class ReviewService {
|
|
|
211
235
|
files,
|
|
212
236
|
commits: filterCommits,
|
|
213
237
|
localMode,
|
|
214
|
-
|
|
238
|
+
duplicateWorkflowResolved,
|
|
215
239
|
} = context;
|
|
216
240
|
|
|
217
|
-
const isDirectFileMode = !!(files && files.length > 0 &&
|
|
241
|
+
const isDirectFileMode = !!(files && files.length > 0 && !prNumber);
|
|
218
242
|
let isLocalMode = !!localMode;
|
|
219
243
|
let effectiveBaseRef = baseRef;
|
|
220
244
|
let effectiveHeadRef = headRef;
|
|
@@ -274,8 +298,16 @@ export class ReviewService {
|
|
|
274
298
|
}
|
|
275
299
|
}
|
|
276
300
|
|
|
301
|
+
// 直接文件审查模式(-f):绕过 diff,直接按指定文件构造审查输入
|
|
302
|
+
if (isDirectFileMode) {
|
|
303
|
+
if (shouldLog(verbose, 1)) {
|
|
304
|
+
console.log(`📥 直接审查指定文件模式 (${files!.length} 个文件)`);
|
|
305
|
+
}
|
|
306
|
+
changedFiles = files!.map((f) => ({ filename: f, status: "modified" as const }));
|
|
307
|
+
isLocalMode = true;
|
|
308
|
+
}
|
|
277
309
|
// PR 模式、分支比较模式、或本地模式回退后的分支比较
|
|
278
|
-
if (prNumber) {
|
|
310
|
+
else if (prNumber) {
|
|
279
311
|
if (shouldLog(verbose, 1)) {
|
|
280
312
|
console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
|
|
281
313
|
}
|
|
@@ -290,9 +322,14 @@ export class ReviewService {
|
|
|
290
322
|
}
|
|
291
323
|
|
|
292
324
|
// 检查是否有其他同名 review workflow 正在运行中
|
|
293
|
-
if (
|
|
294
|
-
const
|
|
295
|
-
|
|
325
|
+
if (duplicateWorkflowResolved !== "off" && ci && prInfo?.head?.sha) {
|
|
326
|
+
const duplicateResult = await this.checkDuplicateWorkflow(
|
|
327
|
+
prModel,
|
|
328
|
+
prInfo.head.sha,
|
|
329
|
+
duplicateWorkflowResolved,
|
|
330
|
+
verbose,
|
|
331
|
+
);
|
|
332
|
+
if (duplicateResult) {
|
|
296
333
|
return {
|
|
297
334
|
prModel,
|
|
298
335
|
commits,
|
|
@@ -300,17 +337,12 @@ export class ReviewService {
|
|
|
300
337
|
headSha: prInfo.head.sha,
|
|
301
338
|
isLocalMode,
|
|
302
339
|
isDirectFileMode,
|
|
303
|
-
earlyReturn:
|
|
340
|
+
earlyReturn: duplicateResult,
|
|
304
341
|
};
|
|
305
342
|
}
|
|
306
343
|
}
|
|
307
344
|
} else if (effectiveBaseRef && effectiveHeadRef) {
|
|
308
|
-
if (
|
|
309
|
-
if (shouldLog(verbose, 1)) {
|
|
310
|
-
console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
|
|
311
|
-
}
|
|
312
|
-
changedFiles = files.map((f) => ({ filename: f, status: "modified" as const }));
|
|
313
|
-
} else if (changedFiles.length === 0) {
|
|
345
|
+
if (changedFiles.length === 0) {
|
|
314
346
|
if (shouldLog(verbose, 1)) {
|
|
315
347
|
console.log(
|
|
316
348
|
`📥 获取 ${effectiveBaseRef}...${effectiveHeadRef} 的差异 (owner: ${owner}, repo: ${repo})`,
|
|
@@ -382,10 +414,20 @@ export class ReviewService {
|
|
|
382
414
|
// 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
|
|
383
415
|
if (includes && includes.length > 0) {
|
|
384
416
|
const beforeFiles = changedFiles.length;
|
|
417
|
+
if (shouldLog(verbose, 2)) {
|
|
418
|
+
console.log(
|
|
419
|
+
`[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f) => ({ filename: f.filename, status: f.status })))}, includes=${JSON.stringify(includes)}`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
385
422
|
changedFiles = filterFilesByIncludes(changedFiles, includes);
|
|
386
423
|
if (shouldLog(verbose, 1)) {
|
|
387
424
|
console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
|
|
388
425
|
}
|
|
426
|
+
if (shouldLog(verbose, 2)) {
|
|
427
|
+
console.log(
|
|
428
|
+
`[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f) => f.filename))}`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
389
431
|
|
|
390
432
|
const globs = extractGlobsFromIncludes(includes);
|
|
391
433
|
const beforeCommits = commits.length;
|
|
@@ -843,10 +885,12 @@ export class ReviewService {
|
|
|
843
885
|
|
|
844
886
|
/**
|
|
845
887
|
* 检查是否有其他同名 review workflow 正在运行中
|
|
888
|
+
* 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论
|
|
846
889
|
*/
|
|
847
890
|
private async checkDuplicateWorkflow(
|
|
848
891
|
prModel: PullRequestModel,
|
|
849
892
|
headSha: string,
|
|
893
|
+
mode: "skip" | "delete",
|
|
850
894
|
verbose?: VerboseLevel,
|
|
851
895
|
): Promise<ReviewResult | null> {
|
|
852
896
|
const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
|
|
@@ -866,6 +910,19 @@ export class ReviewService {
|
|
|
866
910
|
(!currentRunId || String(w.id) !== currentRunId),
|
|
867
911
|
);
|
|
868
912
|
if (duplicateReviewRuns.length > 0) {
|
|
913
|
+
if (mode === "delete") {
|
|
914
|
+
// 删除模式:清理旧的 AI Review 评论和 PR Review
|
|
915
|
+
if (shouldLog(verbose, 1)) {
|
|
916
|
+
console.log(
|
|
917
|
+
`🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`,
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
await this.cleanupDuplicateAiReviews(prModel, verbose);
|
|
921
|
+
// 清理后继续执行当前审查
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// 跳过模式(默认)
|
|
869
926
|
if (shouldLog(verbose, 1)) {
|
|
870
927
|
console.log(
|
|
871
928
|
`⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`,
|
|
@@ -890,6 +947,56 @@ export class ReviewService {
|
|
|
890
947
|
return null;
|
|
891
948
|
}
|
|
892
949
|
|
|
950
|
+
/**
|
|
951
|
+
* 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
|
|
952
|
+
*/
|
|
953
|
+
private async cleanupDuplicateAiReviews(
|
|
954
|
+
prModel: PullRequestModel,
|
|
955
|
+
verbose?: VerboseLevel,
|
|
956
|
+
): Promise<void> {
|
|
957
|
+
try {
|
|
958
|
+
// 删除 Issue Comments(主评论)
|
|
959
|
+
const comments = await prModel.getComments();
|
|
960
|
+
const aiComments = comments.filter((c) => c.body?.includes(REVIEW_COMMENT_MARKER));
|
|
961
|
+
let deletedComments = 0;
|
|
962
|
+
for (const comment of aiComments) {
|
|
963
|
+
if (comment.id) {
|
|
964
|
+
try {
|
|
965
|
+
await prModel.deleteComment(comment.id);
|
|
966
|
+
deletedComments++;
|
|
967
|
+
} catch {
|
|
968
|
+
// 忽略删除失败
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
if (deletedComments > 0 && shouldLog(verbose, 1)) {
|
|
973
|
+
console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
// 删除 PR Reviews(行级评论)
|
|
977
|
+
const reviews = await prModel.getReviews();
|
|
978
|
+
const aiReviews = reviews.filter((r) => r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
|
|
979
|
+
let deletedReviews = 0;
|
|
980
|
+
for (const review of aiReviews) {
|
|
981
|
+
if (review.id) {
|
|
982
|
+
try {
|
|
983
|
+
await prModel.deleteReview(review.id);
|
|
984
|
+
deletedReviews++;
|
|
985
|
+
} catch {
|
|
986
|
+
// 已提交的 review 无法删除,忽略
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
if (deletedReviews > 0 && shouldLog(verbose, 1)) {
|
|
991
|
+
console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
|
|
992
|
+
}
|
|
993
|
+
} catch (error) {
|
|
994
|
+
if (shouldLog(verbose, 1)) {
|
|
995
|
+
console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
893
1000
|
// --- Delegation methods for backward compatibility with tests ---
|
|
894
1001
|
|
|
895
1002
|
protected async fillIssueAuthors(...args: Parameters<ReviewContextBuilder["fillIssueAuthors"]>) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ChangedFile, VerboseLevel } from "@spaceflow/core";
|
|
2
|
+
import type { ReviewIssue, FileContentsMap } from "../review-spec";
|
|
3
|
+
import type { SystemRules } from "../review.config";
|
|
4
|
+
import { checkMaxLinesPerFile } from "./max-lines-per-file";
|
|
5
|
+
|
|
6
|
+
export {
|
|
7
|
+
RULE_ID as SYSTEM_RULE_MAX_LINES,
|
|
8
|
+
SPEC_FILE as SYSTEM_SPEC_FILE,
|
|
9
|
+
} from "./max-lines-per-file";
|
|
10
|
+
|
|
11
|
+
export interface ApplyStaticRulesResult {
|
|
12
|
+
staticIssues: ReviewIssue[];
|
|
13
|
+
/** 被静态规则排除的文件名集合,不应再进入 LLM 审查 */
|
|
14
|
+
skippedFiles: Set<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 对变更文件执行所有已启用的静态规则检查。
|
|
19
|
+
* 返回系统问题列表和需要跳过 LLM 审查的文件集合。
|
|
20
|
+
*/
|
|
21
|
+
export function applyStaticRules(
|
|
22
|
+
changedFiles: ChangedFile[],
|
|
23
|
+
fileContents: FileContentsMap,
|
|
24
|
+
staticRules: SystemRules | undefined,
|
|
25
|
+
round: number,
|
|
26
|
+
verbose?: VerboseLevel,
|
|
27
|
+
): ApplyStaticRulesResult {
|
|
28
|
+
const staticIssues: ReviewIssue[] = [];
|
|
29
|
+
const skippedFiles = new Set<string>();
|
|
30
|
+
|
|
31
|
+
if (!staticRules) {
|
|
32
|
+
return { staticIssues, skippedFiles };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (staticRules.maxLinesPerFile) {
|
|
36
|
+
const result = checkMaxLinesPerFile(
|
|
37
|
+
changedFiles,
|
|
38
|
+
fileContents,
|
|
39
|
+
staticRules.maxLinesPerFile,
|
|
40
|
+
round,
|
|
41
|
+
verbose,
|
|
42
|
+
);
|
|
43
|
+
staticIssues.push(...result.staticIssues);
|
|
44
|
+
result.skippedFiles.forEach((f) => skippedFiles.add(f));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { staticIssues, skippedFiles };
|
|
48
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ChangedFile, VerboseLevel } from "@spaceflow/core";
|
|
2
|
+
import { shouldLog } from "@spaceflow/core";
|
|
3
|
+
import type { ReviewIssue, FileContentsMap } from "../review-spec";
|
|
4
|
+
import type { Severity } from "../review.config";
|
|
5
|
+
|
|
6
|
+
export const RULE_ID = "system:max-lines-per-file";
|
|
7
|
+
export const SPEC_FILE = "__system__";
|
|
8
|
+
|
|
9
|
+
export interface MaxLinesPerFileResult {
|
|
10
|
+
staticIssues: ReviewIssue[];
|
|
11
|
+
/** 超限文件名集合,需从 LLM 审查中排除 */
|
|
12
|
+
skippedFiles: Set<string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function checkMaxLinesPerFile(
|
|
16
|
+
changedFiles: ChangedFile[],
|
|
17
|
+
fileContents: FileContentsMap,
|
|
18
|
+
rule: [number, Severity],
|
|
19
|
+
round: number,
|
|
20
|
+
verbose?: VerboseLevel,
|
|
21
|
+
): MaxLinesPerFileResult {
|
|
22
|
+
const [maxLine, severity] = rule;
|
|
23
|
+
const staticIssues: ReviewIssue[] = [];
|
|
24
|
+
const skippedFiles = new Set<string>();
|
|
25
|
+
|
|
26
|
+
if (maxLine <= 0) {
|
|
27
|
+
return { staticIssues, skippedFiles };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const file of changedFiles) {
|
|
31
|
+
if (file.status === "deleted" || !file.filename) continue;
|
|
32
|
+
const filename = file.filename;
|
|
33
|
+
const contentLines = fileContents.get(filename);
|
|
34
|
+
if (!contentLines || contentLines.length <= maxLine) continue;
|
|
35
|
+
|
|
36
|
+
if (shouldLog(verbose, 1)) {
|
|
37
|
+
console.log(
|
|
38
|
+
`⚠️ [system-rules/maxLinesPerFile] ${filename}: ${contentLines.length} 行超过限制 ${maxLine} 行,跳过 LLM 审查`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
skippedFiles.add(filename);
|
|
43
|
+
staticIssues.push({
|
|
44
|
+
file: filename,
|
|
45
|
+
line: "1",
|
|
46
|
+
code: "",
|
|
47
|
+
ruleId: RULE_ID,
|
|
48
|
+
specFile: SPEC_FILE,
|
|
49
|
+
reason: `文件共 ${contentLines.length} 行,超过静态规则限制 ${maxLine} 行,已跳过 LLM 审查。请考虑拆分文件或调大 staticRules.maxLinesPerFile 配置。`,
|
|
50
|
+
severity,
|
|
51
|
+
round,
|
|
52
|
+
date: new Date().toISOString(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { staticIssues, skippedFiles };
|
|
57
|
+
}
|
package/src/types/review-llm.ts
CHANGED