@spaceflow/review 0.77.0 → 0.78.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.
@@ -0,0 +1,149 @@
1
+ import type { PromptFn } from "./types";
2
+ import { validateArray } from "./types";
3
+ import type { PullRequestCommit, ChangedFile } from "@spaceflow/core";
4
+ import type { FileContentsMap } from "../review-spec";
5
+
6
+ /**
7
+ * 内存使用限制常量
8
+ */
9
+ const MEMORY_LIMITS = {
10
+ MAX_TOTAL_LENGTH: 8000, // 代码变更内容最大总长度
11
+ MAX_FILES: 30, // 最大文件数量
12
+ MAX_SNIPPET_LENGTH: 50, // 每个文件最大代码行数
13
+ MAX_COMMITS: 10, // 最大 commit 数量(用于标题生成)
14
+ MAX_FILES_FOR_TITLE: 20, // 标题生成时最大文件数量
15
+ } as const;
16
+
17
+ /**
18
+ * PR 描述生成提示词
19
+ */
20
+ export interface PrDescriptionContext {
21
+ commits: PullRequestCommit[];
22
+ changedFiles: ChangedFile[];
23
+ fileContents?: FileContentsMap;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ export const buildPrDescriptionPrompt: PromptFn<PrDescriptionContext> = (ctx) => {
28
+ // 验证必需的输入参数
29
+ validateArray(ctx.commits, "commits");
30
+ validateArray(ctx.changedFiles, "changedFiles");
31
+
32
+ const commitMessages = ctx.commits
33
+ .map((c) => `- ${c.sha?.slice(0, 7)}: ${c.commit?.message?.split("\n")[0]}`)
34
+ .join("\n");
35
+ const fileChanges = ctx.changedFiles
36
+ .slice(0, MEMORY_LIMITS.MAX_FILES)
37
+ .map((f) => `- ${f.filename} (${f.status})`)
38
+ .join("\n");
39
+
40
+ // 构建代码变更内容(只包含变更行,优化内存使用)
41
+ let codeChangesSection = "";
42
+ if (ctx.fileContents && ctx.fileContents.size > 0) {
43
+ const codeSnippets: string[] = [];
44
+ let totalLength = 0;
45
+
46
+ // 使用 Map.entries() 进行更高效的迭代
47
+ for (const [filename, lines] of ctx.fileContents) {
48
+ if (totalLength >= MEMORY_LIMITS.MAX_TOTAL_LENGTH) break;
49
+
50
+ // 只提取有变更的行(commitHash 不是 "-------")
51
+ const changedLines = lines
52
+ .map(([hash, code], idx) => (hash !== "-------" ? `${idx + 1}: ${code}` : null))
53
+ .filter(Boolean);
54
+
55
+ if (changedLines.length > 0) {
56
+ // 限制每个文件的代码行数,避免单个文件占用过多内存
57
+ const limitedLines = changedLines.slice(0, MEMORY_LIMITS.MAX_SNIPPET_LENGTH);
58
+ const snippet = `### ${filename}\n\`\`\`\n${limitedLines.join("\n")}\n\`\`\``;
59
+
60
+ // 检查添加此片段是否会超过内存限制
61
+ if (totalLength + snippet.length <= MEMORY_LIMITS.MAX_TOTAL_LENGTH) {
62
+ codeSnippets.push(snippet);
63
+ totalLength += snippet.length;
64
+ } else {
65
+ // 如果添加当前片段会超过限制,尝试截断它
66
+ const remainingLength = MEMORY_LIMITS.MAX_TOTAL_LENGTH - totalLength;
67
+ if (remainingLength > 100) {
68
+ // 至少保留 100 字符的片段
69
+ // snippet 格式为 "### filename\n```\ncode\n```"
70
+ // 截断时去掉结尾的 ``` 再追加,避免双重代码块
71
+ const closingTag = "\n```";
72
+ const contentEnd = snippet.lastIndexOf(closingTag);
73
+ const truncateAt = Math.max(
74
+ 0,
75
+ contentEnd > 0 ? Math.min(remainingLength - 20, contentEnd) : remainingLength - 20,
76
+ );
77
+ const truncatedSnippet = snippet.substring(0, truncateAt) + "\n..." + closingTag;
78
+ codeSnippets.push(truncatedSnippet);
79
+ break;
80
+ }
81
+ break;
82
+ }
83
+ }
84
+ }
85
+
86
+ if (codeSnippets.length > 0) {
87
+ codeChangesSection = `\n\n## 代码变更内容\n${codeSnippets.join("\n\n")}`;
88
+ }
89
+ }
90
+
91
+ return {
92
+ systemPrompt: "",
93
+ userPrompt: `请根据以下 PR 的 commit 记录、文件变更和代码内容,用简洁的中文总结这个 PR 实现了什么功能。
94
+ 要求:
95
+ 1. 第一行输出 PR 标题,格式必须是: Feat xxx 或 Fix xxx 或 Refactor xxx(根据变更类型选择,整体不超过 50 个字符)
96
+ 2. 空一行后输出详细描述
97
+ 3. 描述应该简明扼要,突出核心功能点
98
+ 4. 使用 Markdown 格式
99
+ 5. 不要逐条列出 commit,而是归纳总结
100
+ 6. 重点分析代码变更的实际功能
101
+
102
+ ## Commit 记录 (${ctx.commits.length} 个)
103
+ ${commitMessages || "无"}
104
+
105
+ ## 文件变更 (${ctx.changedFiles.length} 个文件)
106
+ ${fileChanges || "无"}${ctx.changedFiles.length > MEMORY_LIMITS.MAX_FILES ? `\n... 等 ${ctx.changedFiles.length - MEMORY_LIMITS.MAX_FILES} 个文件` : ""}${codeChangesSection}`,
107
+ };
108
+ };
109
+
110
+ /**
111
+ * PR 标题生成提示词
112
+ */
113
+ export interface PrTitleContext {
114
+ commits: PullRequestCommit[];
115
+ changedFiles: ChangedFile[];
116
+ [key: string]: unknown;
117
+ }
118
+
119
+ export const buildPrTitlePrompt: PromptFn<PrTitleContext> = (ctx) => {
120
+ // 验证必需的输入参数
121
+ validateArray(ctx.commits, "commits");
122
+ validateArray(ctx.changedFiles, "changedFiles");
123
+
124
+ const commitMessages = ctx.commits
125
+ .slice(0, MEMORY_LIMITS.MAX_COMMITS)
126
+ .map((c) => c.commit?.message?.split("\n")[0])
127
+ .filter(Boolean)
128
+ .join("\n");
129
+ const fileChanges = ctx.changedFiles
130
+ .slice(0, MEMORY_LIMITS.MAX_FILES_FOR_TITLE)
131
+ .map((f) => `${f.filename} (${f.status})`)
132
+ .join("\n");
133
+
134
+ return {
135
+ systemPrompt: "",
136
+ userPrompt: `请根据以下 commit 记录和文件变更,生成一个简短的 PR 标题。
137
+ 要求:
138
+ 1. 格式必须是: Feat: xxx 或 Fix: xxx 或 Refactor: xxx
139
+ 2. 根据变更内容选择合适的前缀(新功能用 Feat,修复用 Fix,重构用 Refactor)
140
+ 3. xxx 部分用简短的中文描述(整体不超过 50 个字符)
141
+ 4. 只输出标题,不要加任何解释
142
+
143
+ Commit 记录:
144
+ ${commitMessages || "无"}
145
+
146
+ 文件变更:
147
+ ${fileChanges || "无"}`,
148
+ };
149
+ };
@@ -0,0 +1,106 @@
1
+ import type { LlmJsonPutSchema } from "@spaceflow/core";
2
+
3
+ /**
4
+ * 代码审查结果 JSON Schema
5
+ */
6
+ export const REVIEW_SCHEMA: LlmJsonPutSchema = {
7
+ type: "object",
8
+ properties: {
9
+ issues: {
10
+ type: "array",
11
+ items: {
12
+ type: "object",
13
+ properties: {
14
+ file: { type: "string", description: "发生问题的文件路径" },
15
+ line: {
16
+ type: "string",
17
+ description:
18
+ "问题所在的行号,只支持单行或多行 (如 123 或 123-125),不允许使用 `,` 分隔多个行号",
19
+ },
20
+ ruleId: { type: "string", description: "违反的规则 ID(如 JsTs.FileName.UpperCamel)" },
21
+ specFile: {
22
+ type: "string",
23
+ description: "规则来源的规范文件名(如 js&ts.file-name.md)",
24
+ },
25
+ reason: { type: "string", description: "问题的简要概括" },
26
+ suggestion: {
27
+ type: "string",
28
+ description:
29
+ "修改后的完整代码片段。要求以代码为主体,并在代码中使用详细的中文注释解释逻辑改进点。不要包含 Markdown 反引号。",
30
+ },
31
+ commit: { type: "string", description: "相关的 7 位 commit SHA" },
32
+ severity: {
33
+ type: "string",
34
+ description: "问题严重程度,根据规则文档中的 severity 标记确定",
35
+ enum: ["error", "warn"],
36
+ },
37
+ },
38
+ required: ["file", "line", "ruleId", "specFile", "reason"],
39
+ additionalProperties: false,
40
+ },
41
+ },
42
+ summary: { type: "string", description: "本次代码审查的整体总结" },
43
+ },
44
+ required: ["issues", "summary"],
45
+ additionalProperties: false,
46
+ };
47
+
48
+ /**
49
+ * 删除影响分析结果 JSON Schema
50
+ */
51
+ export const DELETION_IMPACT_SCHEMA: LlmJsonPutSchema = {
52
+ type: "object",
53
+ properties: {
54
+ impacts: {
55
+ type: "array",
56
+ items: {
57
+ type: "object",
58
+ properties: {
59
+ file: { type: "string", description: "被删除代码所在的文件路径" },
60
+ deletedCode: { type: "string", description: "被删除的代码片段摘要(前50字符)" },
61
+ riskLevel: {
62
+ type: "string",
63
+ enum: ["high", "medium", "low", "none"],
64
+ description:
65
+ "风险等级:high=可能导致功能异常,medium=可能影响部分功能,low=影响较小,none=无影响",
66
+ },
67
+ affectedFiles: {
68
+ type: "array",
69
+ items: { type: "string" },
70
+ description: "可能受影响的文件列表",
71
+ },
72
+ reason: { type: "string", description: "影响分析的详细说明" },
73
+ suggestion: { type: "string", description: "建议的处理方式" },
74
+ },
75
+ required: ["file", "deletedCode", "riskLevel", "affectedFiles", "reason"],
76
+ additionalProperties: false,
77
+ },
78
+ },
79
+ summary: { type: "string", description: "删除代码影响的整体总结" },
80
+ },
81
+ required: ["impacts", "summary"],
82
+ additionalProperties: false,
83
+ };
84
+
85
+ /**
86
+ * 问题验证结果 JSON Schema
87
+ */
88
+ export const VERIFY_SCHEMA: LlmJsonPutSchema = {
89
+ type: "object",
90
+ properties: {
91
+ fixed: {
92
+ type: "boolean",
93
+ description: "问题是否已被修复",
94
+ },
95
+ valid: {
96
+ type: "boolean",
97
+ description: "问题是否有效,有效的条件就是你需要看看代码是否符合规范",
98
+ },
99
+ reason: {
100
+ type: "string",
101
+ description: "判断依据,说明为什么认为问题已修复或仍存在",
102
+ },
103
+ },
104
+ required: ["fixed", "valid", "reason"],
105
+ additionalProperties: false,
106
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 提示词回调函数类型定义
3
+ */
4
+ export interface PromptContext {
5
+ [key: string]: unknown;
6
+ }
7
+
8
+ export interface PromptResult {
9
+ systemPrompt: string;
10
+ userPrompt: string;
11
+ }
12
+
13
+ /**
14
+ * 输入验证错误类
15
+ */
16
+ export class PromptValidationError extends Error {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = "PromptValidationError";
20
+ }
21
+ }
22
+
23
+ /**
24
+ * 输入验证工具函数
25
+ */
26
+ export function validateRequired<T>(value: T | undefined | null, fieldName: string): T {
27
+ if (value === undefined || value === null) {
28
+ throw new PromptValidationError(`${fieldName} is required but was ${value}`);
29
+ }
30
+ return value;
31
+ }
32
+
33
+ export function validateNonEmptyString(
34
+ value: string | undefined | null,
35
+ fieldName: string,
36
+ ): string {
37
+ if (value === undefined || value === null || value.trim() === "") {
38
+ throw new PromptValidationError(`${fieldName} is required and cannot be empty`);
39
+ }
40
+ return value;
41
+ }
42
+
43
+ export function validateArray<T>(value: T[] | undefined | null, fieldName: string): T[] {
44
+ if (!Array.isArray(value)) {
45
+ throw new PromptValidationError(`${fieldName} must be an array but was ${typeof value}`);
46
+ }
47
+ return value;
48
+ }
49
+
50
+ /**
51
+ * 提示词函数类型 - 接收上下文,返回 systemPrompt 和 userPrompt
52
+ */
53
+ export type PromptFn<T extends PromptContext = PromptContext> = (ctx: T) => PromptResult;
@@ -28,6 +28,7 @@ export interface ReviewContext extends ReviewOptions {
28
28
  specSources: string[];
29
29
  verbose?: VerboseLevel;
30
30
  includes?: string[];
31
+ whenModifiedCode?: string[];
31
32
  files?: string[];
32
33
  commits?: string[];
33
34
  concurrency?: number;
@@ -64,6 +65,9 @@ export class ReviewContextBuilder {
64
65
 
65
66
  async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
66
67
  const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
68
+ if (shouldLog(options.verbose, 2)) {
69
+ console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
70
+ }
67
71
  const ciConf = this.config.get<CiConfig>("ci");
68
72
  const repository = ciConf?.repository;
69
73
 
@@ -152,6 +156,12 @@ export class ReviewContextBuilder {
152
156
  }
153
157
 
154
158
  // 合并参数优先级:命令行 > PR 标题 > 配置文件 > 默认值
159
+ const ctxIncludes = options.includes ?? titleOptions.includes ?? reviewConf.includes;
160
+ if (shouldLog(options.verbose, 2)) {
161
+ console.log(
162
+ `[getContextFromEnv] includes: commandLine=${JSON.stringify(options.includes)}, title=${JSON.stringify(titleOptions.includes)}, config=${JSON.stringify(reviewConf.includes)}, final=${JSON.stringify(ctxIncludes)}`,
163
+ );
164
+ }
155
165
  return {
156
166
  owner,
157
167
  repo,
@@ -162,7 +172,8 @@ export class ReviewContextBuilder {
162
172
  dryRun: options.dryRun || titleOptions.dryRun || false,
163
173
  ci: options.ci ?? false,
164
174
  verbose: normalizeVerbose(options.verbose ?? titleOptions.verbose),
165
- includes: options.includes ?? titleOptions.includes ?? reviewConf.includes,
175
+ includes: ctxIncludes,
176
+ whenModifiedCode: options.whenModifiedCode ?? reviewConf.whenModifiedCode,
166
177
  llmMode: options.llmMode ?? titleOptions.llmMode ?? reviewConf.llmMode,
167
178
  files: this.normalizeFilePaths(options.files),
168
179
  commits: options.commits,
@@ -193,9 +204,10 @@ export class ReviewContextBuilder {
193
204
  flush: options.flush ?? false,
194
205
  eventAction: options.eventAction,
195
206
  localMode,
196
- skipDuplicateWorkflow:
197
- options.skipDuplicateWorkflow ?? reviewConf.skipDuplicateWorkflow ?? false,
207
+ duplicateWorkflowResolved:
208
+ options.duplicateWorkflowResolved ?? reviewConf.duplicateWorkflowResolved ?? "delete",
198
209
  autoApprove: options.autoApprove ?? reviewConf.autoApprove ?? false,
210
+ systemRules: options.systemRules ?? reviewConf.systemRules,
199
211
  };
200
212
  }
201
213
 
@@ -390,14 +402,26 @@ export class ReviewContextBuilder {
390
402
  // 为每个 issue 填充 author
391
403
  return issues.map((issue) => {
392
404
  if (issue.author) {
405
+ const shortHash = issue.commit?.slice(0, 7);
406
+ if (shortHash?.includes("---")) {
407
+ return { ...issue, commit: undefined, valid: "false" };
408
+ }
393
409
  if (shouldLog(verbose, 2)) {
394
410
  console.log(`[fillIssueAuthors] issue already has author: ${issue.author.login}`);
395
411
  }
396
412
  return issue;
397
413
  }
398
414
  const shortHash = issue.commit?.slice(0, 7);
399
- const author =
400
- shortHash && !shortHash.includes("---") ? commitAuthorMap.get(shortHash) : undefined;
415
+ const isValidHash = Boolean(shortHash && !shortHash.includes("---"));
416
+ if (!isValidHash) {
417
+ if (shouldLog(verbose, 2)) {
418
+ console.log(
419
+ `[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit} is invalid hash, marking as invalid`,
420
+ );
421
+ }
422
+ return { ...issue, commit: undefined, valid: "false" };
423
+ }
424
+ const author = commitAuthorMap.get(shortHash!);
401
425
  if (shouldLog(verbose, 2)) {
402
426
  console.log(
403
427
  `[fillIssueAuthors] issue: file=${issue.file}, commit=${issue.commit}, shortHash=${shortHash}, foundAuthor=${author?.login}, finalAuthor=${(author || defaultAuthor)?.login}`,
@@ -3,6 +3,7 @@ import {
3
3
  parseIncludePattern,
4
4
  filterFilesByIncludes,
5
5
  extractGlobsFromIncludes,
6
+ extractCodeBlockTypes,
6
7
  } from "./review-includes-filter";
7
8
 
8
9
  describe("review-includes-filter", () => {
@@ -216,6 +217,15 @@ describe("review-includes-filter", () => {
216
217
  const glFiles = [{ filename: "src/old.ts", status: "deleted" }];
217
218
  expect(filterFilesByIncludes(glFiles, [`deleted|${glob}`])).toHaveLength(1);
218
219
  });
220
+
221
+ it("全量 diff 语义:文件在当前分支首次引入后被多次修改,status 仍为 added,added| 始终匹配", () => {
222
+ // 场景:a.ts 在 commit1 创建(status=added),在 commit2 修改
223
+ // 全量 diff(当前分支 vs base)时,compare API 返回的 status 仍为 added
224
+ // 因此 added|*.ts 在后续每次 review 中都能匹配到该文件
225
+ const diffFiles = [{ filename: "src/a.ts", status: "added" }];
226
+ const result = filterFilesByIncludes(diffFiles, [`added|${glob}`]);
227
+ expect(result.map((f) => f.filename)).toEqual(["src/a.ts"]);
228
+ });
219
229
  });
220
230
 
221
231
  describe("extractGlobsFromIncludes", () => {
@@ -245,4 +255,30 @@ describe("review-includes-filter", () => {
245
255
  expect(extractGlobsFromIncludes([])).toEqual([]);
246
256
  });
247
257
  });
258
+
259
+ describe("extractCodeBlockTypes", () => {
260
+ it("提取纯类型名", () => {
261
+ const result = extractCodeBlockTypes(["function", "class"]);
262
+ expect(result).toEqual(["function", "class"]);
263
+ });
264
+
265
+ it("status|code-* 语法不被识别,返回空", () => {
266
+ const result = extractCodeBlockTypes(["added|code-function", "added|code-class"]);
267
+ expect(result).toEqual([]);
268
+ });
269
+
270
+ it("混合:纯类型名保留,status 前缀语法忽略", () => {
271
+ const result = extractCodeBlockTypes(["added|code-function", "class"]);
272
+ expect(result).toEqual(["class"]);
273
+ });
274
+
275
+ it("去重:同一类型出现多次只返回一次", () => {
276
+ const result = extractCodeBlockTypes(["function", "function"]);
277
+ expect(result).toEqual(["function"]);
278
+ });
279
+
280
+ it("空数组返回空数组", () => {
281
+ expect(extractCodeBlockTypes([])).toEqual([]);
282
+ });
283
+ });
248
284
  });
@@ -4,13 +4,36 @@ import micromatch from "micromatch";
4
4
  * includes 模式中的变更类型前缀
5
5
  *
6
6
  * 语法:`<status>|<glob>`,例如:
7
- * - `added|*\/**\/*.ts` → 仅匹配新增文件
8
- * - `modified|*\/**\/*.ts` → 仅匹配修改文件
9
- * - `deleted|*\/**\/*.ts` → 仅匹配删除文件
10
- * - `*\/**\/*.ts` → 不限变更类型(原有行为)
7
+ * - `added|*\/**\/*.ts` → 仅匹配新增文件
8
+ * - `modified|*\/**\/*.ts` → 仅匹配修改文件
9
+ * - `deleted|*\/**\/*.ts` → 仅匹配删除文件
10
+ * - `*\/**\/*.ts` → 不限变更类型(原有行为)
11
+ *
12
+ * ## 全量 diff 语义说明
13
+ *
14
+ * 每次 review 是对当前分支与 base 分支(develop/master)的**全量 diff**,
15
+ * 文件的 status 由平台 compare API 给出,表示该文件**相对 base 分支**的变更类型。
16
+ *
17
+ * 因此,若 `a.ts` 是在当前分支上首次引入的(相对 base 不存在),
18
+ * 无论后续经过多少次 commit 修改,其 status **始终为 `added`**。
19
+ * 这意味着 `added|*.ts` 在后续每次 review 中都会继续匹配该文件,
20
+ * 这是符合预期的行为——只要文件相对 base 是"新建"的,`added|` 就应该始终生效。
11
21
  */
12
22
  export type IncludeStatusPrefix = "added" | "modified" | "deleted";
13
23
 
24
+ /**
25
+ * 代码结构类型,用于 whenModifiedCode 配置
26
+ */
27
+ export type CodeBlockType = "function" | "class" | "interface" | "type" | "method";
28
+
29
+ export const CODE_BLOCK_TYPES: CodeBlockType[] = [
30
+ "function",
31
+ "class",
32
+ "interface",
33
+ "type",
34
+ "method",
35
+ ];
36
+
14
37
  /** status 值到前缀的映射(兼容 GitHub/GitLab/Gitea 各平台) */
15
38
  const STATUS_ALIAS: Record<string, IncludeStatusPrefix> = {
16
39
  added: "added",
@@ -51,6 +74,7 @@ export function parseIncludePattern(pattern: string): ParsedIncludePattern {
51
74
  // 前缀无法识别(如 extglob 中的 `|`),当作普通 glob 处理
52
75
  return { status: undefined, glob: pattern };
53
76
  }
77
+
54
78
  return { status, glob };
55
79
  }
56
80
 
@@ -78,6 +102,7 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
78
102
  if (!includes || includes.length === 0) return files;
79
103
 
80
104
  const parsed = includes.map(parseIncludePattern);
105
+ console.log(`[filterFilesByIncludes] parsed patterns=${JSON.stringify(parsed)}`);
81
106
 
82
107
  // 排除模式(以 ! 开头),用于最终全局过滤
83
108
  const negativeGlobs = parsed
@@ -90,8 +115,13 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
90
115
  // 有 status 前缀的 patterns
91
116
  const statusPatterns = parsed.filter((p) => p.status !== undefined);
92
117
 
118
+ console.log(
119
+ `[filterFilesByIncludes] negativeGlobs=${JSON.stringify(negativeGlobs)}, plainGlobs=${JSON.stringify(plainGlobs)}, statusPatterns=${JSON.stringify(statusPatterns)}`,
120
+ );
121
+
93
122
  return files.filter((file) => {
94
123
  const filename = file.filename ?? "";
124
+ const fileStatus = STATUS_ALIAS[file.status?.toLowerCase() ?? ""] ?? "modified";
95
125
  if (!filename) return false;
96
126
 
97
127
  // 最终排除:命中排除模式的文件直接过滤掉
@@ -99,18 +129,19 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
99
129
  negativeGlobs.length > 0 &&
100
130
  micromatch.isMatch(filename, negativeGlobs, { matchBase: true })
101
131
  ) {
132
+ console.log(`[filterFilesByIncludes] ${filename} excluded by negativeGlobs`);
102
133
  return false;
103
134
  }
104
135
 
105
136
  // 正向匹配:无前缀 glob
106
137
  if (plainGlobs.length > 0 && micromatch.isMatch(filename, plainGlobs, { matchBase: true })) {
138
+ console.log(`[filterFilesByIncludes] ${filename} matched plainGlobs`);
107
139
  return true;
108
140
  }
109
141
 
110
142
  // 正向匹配:有 status 前缀的 glob,按文件实际 status 过滤
111
143
  // glob 可以带 ! 前缀表示在该 status 范围内排除,如 added|!**/*.spec.ts
112
144
  if (statusPatterns.length > 0) {
113
- const fileStatus = STATUS_ALIAS[file.status?.toLowerCase() ?? ""] ?? "modified";
114
145
  // 按 status 分组,每组内正向 glob + 排除 glob 合并后批量匹配
115
146
  const matchingStatusGlobs = statusPatterns
116
147
  .filter(({ status }) => status === fileStatus)
@@ -126,11 +157,17 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
126
157
  const matchesNegative =
127
158
  negativeStatusGlobs.length > 0 &&
128
159
  micromatch.isMatch(filename, negativeStatusGlobs, { matchBase: true });
129
- if (matchesPositive && !matchesNegative) return true;
160
+ if (matchesPositive && !matchesNegative) {
161
+ console.log(
162
+ `[filterFilesByIncludes] ${filename} (status=${fileStatus}) matched statusPatterns`,
163
+ );
164
+ return true;
165
+ }
130
166
  }
131
167
  }
132
168
  }
133
169
 
170
+ console.log(`[filterFilesByIncludes] ${filename} (status=${fileStatus}) NOT matched`);
134
171
  return false;
135
172
  });
136
173
  }
@@ -140,5 +177,20 @@ export function filterFilesByIncludes<T extends FileWithStatus>(
140
177
  * 带 status 前缀的模式会去掉前缀,仅保留 glob 部分。
141
178
  */
142
179
  export function extractGlobsFromIncludes(includes: string[]): string[] {
143
- return includes.map((p) => parseIncludePattern(p).glob);
180
+ return includes.map((p) => parseIncludePattern(p).glob).filter((g) => g.length > 0);
181
+ }
182
+
183
+ /**
184
+ * 从 whenModifiedCode 配置中解析代码结构过滤类型。
185
+ * 只接受简单的类型名称,如 "function"、"class"、"interface"、"type"、"method"
186
+ */
187
+ export function extractCodeBlockTypes(whenModifiedCode: string[]): CodeBlockType[] {
188
+ const types = new Set<CodeBlockType>();
189
+ for (const entry of whenModifiedCode) {
190
+ const trimmed = entry.trim();
191
+ if ((CODE_BLOCK_TYPES as string[]).includes(trimmed)) {
192
+ types.add(trimmed as CodeBlockType);
193
+ }
194
+ }
195
+ return [...types];
144
196
  }
@@ -20,7 +20,7 @@ import {
20
20
  FileContentLine,
21
21
  } from "./review-spec";
22
22
  import { IssueVerifyService } from "./issue-verify.service";
23
- import { generateIssueKey } from "./review-pr-comment-utils";
23
+ import { generateIssueKey } from "./utils/review-pr-comment";
24
24
  import type { ReviewContext } from "./review-context";
25
25
 
26
26
  export class ReviewIssueFilter {