@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/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { LlmJsonPut, REVIEW_STATE, addLocaleResources, calculateNewLineNumber, createStreamLoggerState, defineExtension, logStreamEvent, normalizeVerbose, parallel, parseChangedLinesFromPatch, parseDiffText, parseHunksFromPatch, parseRepoUrl, parseVerbose, shouldLog, t, z } from "@spaceflow/core";
2
- import { access, mkdir, readFile, readdir, writeFile } from "fs/promises";
1
+ import { LlmJsonPut, REVIEW_STATE, addLocaleResources, createStreamLoggerState, defineExtension, logStreamEvent, normalizeVerbose, parallel, parseChangedLinesFromPatch, parseDiffText, parseRepoUrl, parseVerbose, shouldLog, t, z } from "@spaceflow/core";
2
+ import { access, mkdir, readFile, readdir, unlink, writeFile } from "fs/promises";
3
3
  import { basename, dirname, extname, isAbsolute, join, normalize, relative } from "path";
4
4
  import { homedir } from "os";
5
- import { execSync, spawn } from "child_process";
5
+ import { execFileSync, execSync, spawn } from "child_process";
6
6
  import micromatch_0 from "micromatch";
7
7
  import { existsSync } from "fs";
8
8
  var __webpack_modules__ = ({});
@@ -332,12 +332,19 @@ const CODE_BLOCK_TYPES = [
332
332
 
333
333
 
334
334
 
335
- /** 远程规则缓存 TTL(毫秒),默认 5 分钟 */ const REMOTE_SPEC_CACHE_TTL = 5 * 60 * 1000;
336
335
  class ReviewSpecService {
337
336
  gitProvider;
338
337
  constructor(gitProvider){
339
338
  this.gitProvider = gitProvider;
340
339
  }
340
+ normalizeServerUrl(url) {
341
+ return url.trim().replace(/\/+$/, "");
342
+ }
343
+ logVerbose(verbose, level, message) {
344
+ if (shouldLog(verbose, level)) {
345
+ console.log(message);
346
+ }
347
+ }
341
348
  /**
342
349
  * 检查规则 ID 是否匹配(精确匹配或前缀匹配)
343
350
  * 例如: "JsTs.FileName" 匹配 "JsTs.FileName" 和 "JsTs.FileName.UpperCamel"
@@ -371,15 +378,7 @@ class ReviewSpecService {
371
378
  * 根据变更文件的扩展名过滤适用的规则文件
372
379
  * 只按扩展名过滤,includes 和 override 在 LLM 审查后处理
373
380
  */ filterApplicableSpecs(specs, changedFiles) {
374
- const changedExtensions = new Set();
375
- for (const file of changedFiles){
376
- if (file.filename) {
377
- const ext = extname(file.filename).slice(1).toLowerCase();
378
- if (ext) {
379
- changedExtensions.add(ext);
380
- }
381
- }
382
- }
381
+ const changedExtensions = changedFiles.extensions();
383
382
  console.log(`[filterApplicableSpecs] changedExtensions=${JSON.stringify([
384
383
  ...changedExtensions
385
384
  ])}, specs count=${specs.length}`);
@@ -414,54 +413,219 @@ class ReviewSpecService {
414
413
  }
415
414
  return specs;
416
415
  }
417
- async resolveSpecSources(sources) {
416
+ async resolveSpecSources(sources, verbose) {
418
417
  const dirs = [];
419
418
  for (const source of sources){
420
- // 优先尝试解析为远程仓库 URL(浏览器复制的链接)
419
+ this.logVerbose(verbose, 3, ` 🔎 规则来源: ${source}`);
421
420
  const repoRef = parseRepoUrl(source);
421
+ if (repoRef) {
422
+ this.logVerbose(verbose, 3, ` 解析远程仓库: ${repoRef.serverUrl}/${repoRef.owner}/${repoRef.repo} path=${repoRef.path || "(root)"} ref=${repoRef.ref || "(default)"}`);
423
+ } else {
424
+ this.logVerbose(verbose, 3, ` 非仓库 URL,按本地目录处理`);
425
+ }
422
426
  if (repoRef && this.gitProvider) {
423
- const dir = await this.fetchRemoteSpecs(repoRef);
427
+ this.logVerbose(verbose, 3, ` 尝试方式 #1: Git Provider API`);
428
+ const dir = await this.fetchRemoteSpecs(repoRef, verbose);
424
429
  if (dir) {
425
430
  dirs.push(dir);
431
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: Git Provider API -> ${dir}`);
432
+ continue;
433
+ }
434
+ this.logVerbose(verbose, 3, ` ❌ Git Provider API 未获取到规则,继续尝试`);
435
+ }
436
+ if (repoRef) {
437
+ this.logVerbose(verbose, 3, ` 尝试方式 #2: tea api`);
438
+ const teaDir = await this.fetchRemoteSpecsViaTea(repoRef, verbose);
439
+ if (teaDir) {
440
+ dirs.push(teaDir);
441
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: tea api -> ${teaDir}`);
426
442
  continue;
427
443
  }
444
+ this.logVerbose(verbose, 3, ` ❌ tea api 未获取到规则,继续尝试`);
445
+ }
446
+ // API 拉取失败或未配置 provider 时,回退到 git clone(使用仓库根 URL,而非目录 URL)
447
+ if (repoRef) {
448
+ this.logVerbose(verbose, 3, ` 尝试方式 #3: git clone 回退`);
449
+ const fallbackCloneUrl = this.buildRepoCloneUrl(repoRef);
450
+ this.logVerbose(verbose, 3, ` clone URL: ${fallbackCloneUrl}`);
451
+ const fallbackDir = await this.cloneSpecRepo(fallbackCloneUrl, repoRef.path, verbose);
452
+ if (fallbackDir) {
453
+ dirs.push(fallbackDir);
454
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: git clone 回退 -> ${fallbackDir}`);
455
+ continue;
456
+ }
457
+ this.logVerbose(verbose, 3, ` ❌ git clone 回退失败`);
428
458
  }
429
459
  if (this.isRepoUrl(source)) {
430
- const dir = await this.cloneSpecRepo(source);
460
+ this.logVerbose(verbose, 3, ` 尝试方式 #4: 直接 clone 来源 URL`);
461
+ const dir = await this.cloneSpecRepo(source, undefined, verbose);
431
462
  if (dir) {
432
463
  dirs.push(dir);
464
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: 直接 clone 来源 URL -> ${dir}`);
465
+ } else {
466
+ this.logVerbose(verbose, 3, ` ❌ 直接 clone 来源 URL 失败`);
433
467
  }
434
468
  } else {
435
469
  // 检查是否是 deps 目录,如果是则扫描子目录的 references
436
470
  const resolvedDirs = await this.resolveDepsDir(source);
437
471
  dirs.push(...resolvedDirs);
472
+ this.logVerbose(verbose, 3, ` deps 目录解析结果: ${resolvedDirs.length > 0 ? resolvedDirs.join(", ") : "(空)"}`);
438
473
  }
439
474
  }
440
475
  return dirs;
441
476
  }
442
- /**
443
- * 通过 Git API 从远程仓库拉取规则文件
444
- * 缓存到 ~/.spaceflow/review-spec-cache/ 目录,带 TTL
445
- */ async fetchRemoteSpecs(ref) {
446
- const cacheKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
447
- const cacheDir = join(homedir(), ".spaceflow", "review-spec-cache", cacheKey);
448
- // 检查缓存是否有效(非 CI 环境下使用 TTL)
449
- const isCI = !!process.env.CI;
450
- if (!isCI) {
451
- try {
452
- const timestampFile = join(cacheDir, ".timestamp");
453
- const timestamp = await readFile(timestampFile, "utf-8");
454
- const age = Date.now() - Number(timestamp);
455
- if (age < REMOTE_SPEC_CACHE_TTL) {
456
- const entries = await readdir(cacheDir);
457
- if (entries.some((f)=>f.endsWith(".md"))) {
458
- return cacheDir;
459
- }
477
+ buildRemoteSpecDir(ref) {
478
+ const dirKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
479
+ return join(homedir(), ".spaceflow", "review-spec", dirKey);
480
+ }
481
+ async getLocalSpecsDir(dir) {
482
+ try {
483
+ const entries = await readdir(dir);
484
+ if (!entries.some((f)=>f.endsWith(".md"))) {
485
+ return null;
486
+ }
487
+ return dir;
488
+ } catch {
489
+ return null;
490
+ }
491
+ }
492
+ async prepareRemoteSpecDirForWrite(dir) {
493
+ await mkdir(dir, {
494
+ recursive: true
495
+ });
496
+ try {
497
+ const entries = await readdir(dir);
498
+ for (const name of entries){
499
+ if (name.endsWith(".md") || name === ".timestamp") {
500
+ await unlink(join(dir, name));
460
501
  }
461
- } catch {
462
- // 缓存不存在或无效,继续拉取
463
502
  }
503
+ } catch {
504
+ // 忽略目录清理失败,后续写入时再处理
505
+ }
506
+ }
507
+ isTeaInstalled() {
508
+ try {
509
+ execSync("command -v tea", {
510
+ stdio: "pipe"
511
+ });
512
+ return true;
513
+ } catch {
514
+ return false;
515
+ }
516
+ }
517
+ getTeaLoginForServer(serverUrl) {
518
+ try {
519
+ const output = execFileSync("tea", [
520
+ "login",
521
+ "list",
522
+ "-o",
523
+ "json"
524
+ ], {
525
+ encoding: "utf-8",
526
+ stdio: "pipe"
527
+ });
528
+ const normalizedServerUrl = this.normalizeServerUrl(serverUrl);
529
+ const logins = JSON.parse(output);
530
+ const matched = logins.find((login)=>login.url && this.normalizeServerUrl(login.url) === normalizedServerUrl);
531
+ return matched?.name ?? null;
532
+ } catch {
533
+ return null;
534
+ }
535
+ }
536
+ runTeaApi(endpoint, loginName) {
537
+ const args = [
538
+ "api",
539
+ "-l",
540
+ loginName,
541
+ endpoint
542
+ ];
543
+ return execFileSync("tea", args, {
544
+ encoding: "utf-8",
545
+ stdio: "pipe"
546
+ });
547
+ }
548
+ encodePathSegments(path) {
549
+ if (!path) return "";
550
+ return path.split("/").filter(Boolean).map((segment)=>encodeURIComponent(segment)).join("/");
551
+ }
552
+ buildTeaContentsEndpoint(ref) {
553
+ const owner = encodeURIComponent(ref.owner);
554
+ const repo = encodeURIComponent(ref.repo);
555
+ const encodedPath = this.encodePathSegments(ref.path || "");
556
+ const pathPart = encodedPath ? `/${encodedPath}` : "";
557
+ const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
558
+ return `/repos/${owner}/${repo}/contents${pathPart}${query}`;
559
+ }
560
+ buildTeaRawFileEndpoint(ref, filePath) {
561
+ const owner = encodeURIComponent(ref.owner);
562
+ const repo = encodeURIComponent(ref.repo);
563
+ const encodedFilePath = this.encodePathSegments(filePath);
564
+ const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
565
+ return `/repos/${owner}/${repo}/raw/${encodedFilePath}${query}`;
566
+ }
567
+ /**
568
+ * 使用 tea api 拉取远程规则
569
+ * 前置条件:本地安装 tea 且已登录目标服务器
570
+ */ async fetchRemoteSpecsViaTea(ref, verbose) {
571
+ if (!this.isTeaInstalled()) {
572
+ this.logVerbose(verbose, 3, ` tea 不可用(未安装)`);
573
+ return null;
574
+ }
575
+ const loginName = this.getTeaLoginForServer(ref.serverUrl);
576
+ if (!loginName) {
577
+ this.logVerbose(verbose, 3, ` tea 未登录目标服务器: ${this.normalizeServerUrl(ref.serverUrl)}`);
578
+ return null;
579
+ }
580
+ this.logVerbose(verbose, 3, ` tea 登录名: ${loginName}`);
581
+ const specDir = this.buildRemoteSpecDir(ref);
582
+ this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
583
+ try {
584
+ console.log(` 📡 使用 tea 拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`);
585
+ const contentsEndpoint = this.buildTeaContentsEndpoint(ref);
586
+ this.logVerbose(verbose, 3, ` tea api endpoint(contents): ${contentsEndpoint}`);
587
+ const contentsRaw = this.runTeaApi(contentsEndpoint, loginName);
588
+ const contents = JSON.parse(contentsRaw);
589
+ const mdFiles = contents.filter((f)=>f.type === "file" && !!f.name && f.name.endsWith(".md") && !!f.path);
590
+ if (mdFiles.length === 0) {
591
+ console.warn(" ⚠️ tea 远程目录中未找到 .md 规则文件");
592
+ return null;
593
+ }
594
+ const fetchedFiles = [];
595
+ for (const file of mdFiles){
596
+ const fileEndpoint = this.buildTeaRawFileEndpoint(ref, file.path);
597
+ this.logVerbose(verbose, 3, ` tea api endpoint(raw): ${fileEndpoint}`);
598
+ const fileContent = this.runTeaApi(fileEndpoint, loginName);
599
+ fetchedFiles.push({
600
+ name: file.name,
601
+ content: fileContent
602
+ });
603
+ }
604
+ await this.prepareRemoteSpecDirForWrite(specDir);
605
+ for (const file of fetchedFiles){
606
+ await writeFile(join(specDir, file.name), file.content, "utf-8");
607
+ }
608
+ console.log(` ✅ 已通过 tea 拉取 ${mdFiles.length} 个规则文件到本地目录`);
609
+ return specDir;
610
+ } catch (error) {
611
+ console.warn(` ⚠️ tea 拉取规则失败:`, error instanceof Error ? error.message : error);
612
+ const localDir = await this.getLocalSpecsDir(specDir);
613
+ if (localDir) {
614
+ const mdCount = await this.getSpecFileCount(localDir);
615
+ this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
616
+ console.log(` 📦 使用本地已存在规则目录`);
617
+ return localDir;
618
+ }
619
+ this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
620
+ return null;
464
621
  }
622
+ }
623
+ /**
624
+ * 通过 Git API 从远程仓库拉取规则文件
625
+ * 保存到 ~/.spaceflow/review-spec/ 目录
626
+ */ async fetchRemoteSpecs(ref, verbose) {
627
+ const specDir = this.buildRemoteSpecDir(ref);
628
+ this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
465
629
  try {
466
630
  console.log(` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`);
467
631
  const contents = await this.gitProvider.listRepositoryContents(ref.owner, ref.repo, ref.path || undefined, ref.ref);
@@ -470,32 +634,41 @@ class ReviewSpecService {
470
634
  console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
471
635
  return null;
472
636
  }
473
- await mkdir(cacheDir, {
474
- recursive: true
475
- });
637
+ const fetchedFiles = [];
476
638
  for (const file of mdFiles){
477
639
  const content = await this.gitProvider.getFileContent(ref.owner, ref.repo, file.path, ref.ref);
478
- await writeFile(join(cacheDir, file.name), content, "utf-8");
640
+ fetchedFiles.push({
641
+ name: file.name,
642
+ content
643
+ });
479
644
  }
480
- // 写入时间戳
481
- await writeFile(join(cacheDir, ".timestamp"), String(Date.now()), "utf-8");
482
- console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到缓存`);
483
- return cacheDir;
645
+ await this.prepareRemoteSpecDirForWrite(specDir);
646
+ for (const file of fetchedFiles){
647
+ await writeFile(join(specDir, file.name), file.content, "utf-8");
648
+ }
649
+ console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到本地目录`);
650
+ return specDir;
484
651
  } catch (error) {
485
652
  console.warn(` ⚠️ 远程规则拉取失败:`, error instanceof Error ? error.message : error);
486
- // 尝试使用过期缓存
487
- try {
488
- const entries = await readdir(cacheDir);
489
- if (entries.some((f)=>f.endsWith(".md"))) {
490
- console.log(` 📦 使用过期缓存`);
491
- return cacheDir;
492
- }
493
- } catch {
494
- // 无缓存可用
495
- }
653
+ const localDir = await this.getLocalSpecsDir(specDir);
654
+ if (localDir) {
655
+ const mdCount = await this.getSpecFileCount(localDir);
656
+ this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
657
+ console.log(` 📦 使用本地已存在规则目录`);
658
+ return localDir;
659
+ }
660
+ this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
496
661
  return null;
497
662
  }
498
663
  }
664
+ async getSpecFileCount(dir) {
665
+ try {
666
+ const entries = await readdir(dir);
667
+ return entries.filter((f)=>f.endsWith(".md")).length;
668
+ } catch {
669
+ return 0;
670
+ }
671
+ }
499
672
  /**
500
673
  * 解析 deps 目录,扫描子目录中的 references 文件夹
501
674
  * 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
@@ -541,16 +714,35 @@ class ReviewSpecService {
541
714
  isRepoUrl(source) {
542
715
  return source.startsWith("http://") || source.startsWith("https://") || source.startsWith("git@") || source.includes("://");
543
716
  }
544
- async cloneSpecRepo(repoUrl) {
717
+ buildRepoCloneUrl(ref) {
718
+ return `${ref.serverUrl}/${ref.owner}/${ref.repo}.git`;
719
+ }
720
+ async resolveClonedSpecDir(cacheDir, subPath) {
721
+ const normalizedSubPath = subPath?.trim().replace(/^\/+|\/+$/g, "");
722
+ if (!normalizedSubPath) {
723
+ return cacheDir;
724
+ }
725
+ const targetDir = join(cacheDir, normalizedSubPath);
726
+ try {
727
+ await access(targetDir);
728
+ return targetDir;
729
+ } catch {
730
+ console.warn(` 警告: 克隆仓库中未找到子目录 ${normalizedSubPath},改为使用仓库根目录`);
731
+ return cacheDir;
732
+ }
733
+ }
734
+ async cloneSpecRepo(repoUrl, subPath, verbose) {
545
735
  const repoName = this.extractRepoName(repoUrl);
546
736
  if (!repoName) {
547
737
  console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
548
738
  return null;
549
739
  }
550
740
  const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
741
+ this.logVerbose(verbose, 3, ` clone 目标目录: ${cacheDir}`);
551
742
  try {
552
743
  await access(cacheDir);
553
744
  // console.log(` 使用缓存的规则仓库: ${cacheDir}`);
745
+ this.logVerbose(verbose, 3, ` 发现已存在仓库目录,尝试 git pull`);
554
746
  try {
555
747
  execSync("git pull --ff-only", {
556
748
  cwd: cacheDir,
@@ -560,10 +752,11 @@ class ReviewSpecService {
560
752
  } catch {
561
753
  console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
562
754
  }
563
- return cacheDir;
755
+ return this.resolveClonedSpecDir(cacheDir, subPath);
564
756
  } catch {
565
757
  // console.log(` 克隆规则仓库: ${repoUrl}`);
566
758
  try {
759
+ this.logVerbose(verbose, 3, ` 本地仓库目录不存在,执行 git clone`);
567
760
  await mkdir(join(homedir(), ".spaceflow", "review-spec"), {
568
761
  recursive: true
569
762
  });
@@ -571,7 +764,7 @@ class ReviewSpecService {
571
764
  stdio: "pipe"
572
765
  });
573
766
  // console.log(` 克隆完成: ${cacheDir}`);
574
- return cacheDir;
767
+ return this.resolveClonedSpecDir(cacheDir, subPath);
575
768
  } catch (error) {
576
769
  console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
577
770
  return null;
@@ -579,6 +772,10 @@ class ReviewSpecService {
579
772
  }
580
773
  }
581
774
  extractRepoName(repoUrl) {
775
+ const parsedRef = parseRepoUrl(repoUrl);
776
+ if (parsedRef) {
777
+ return `${parsedRef.owner}__${parsedRef.repo}`;
778
+ }
582
779
  let path = repoUrl;
583
780
  path = path.replace(/\.git$/, "");
584
781
  path = path.replace(/^git@[^:]+:/, "");
@@ -1509,13 +1706,10 @@ class MarkdownFormatter {
1509
1706
  `| 指标 | 数量 |`,
1510
1707
  `|------|------|`
1511
1708
  ];
1512
- lines.push(`| 总问题数 | ${stats.total} |`);
1513
- lines.push(`| 🟢 已修复 | ${stats.fixed} |`);
1514
- lines.push(`| 已解决 | ${stats.resolved} |`);
1515
- lines.push(`| 无效 | ${stats.invalid} |`);
1516
- lines.push(`| ⚠️ 待处理 | ${stats.pending} |`);
1517
- lines.push(`| 修复率 | ${stats.fixRate}% |`);
1518
- lines.push(`| 解决率 | ${stats.resolveRate}% |`);
1709
+ lines.push(`| 有效问题 | ${stats.validTotal} (🟢已验收 ${stats.fixed}, ⚪已解决 ${stats.resolved}, ⚠️待处理 ${stats.pending}) |`);
1710
+ lines.push(`| 无效问题 | ${stats.invalid} |`);
1711
+ lines.push(`| 验收率 | ${stats.fixRate}% (${stats.fixed}/${stats.validTotal}) |`);
1712
+ lines.push(`| 解决率 | ${stats.resolveRate}% (${stats.resolved}/${stats.validTotal}) |`);
1519
1713
  return lines.join("\n");
1520
1714
  }
1521
1715
  }
@@ -2165,10 +2359,13 @@ function generateIssueKey(issue) {
2165
2359
  * - 合并历史 issues(this.issues)+ newIssues
2166
2360
  * - 复制 newResult 的元信息(title/description/deletionImpact 等)
2167
2361
  *
2168
- * 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes、去重等)。
2362
+ * 调用方应在调用前完成对历史 issues 的预处理(syncResolved、invalidateChangedFiles、verifyFixes 等)。
2169
2363
  */ nextRound(newResult) {
2170
2364
  const nextRoundNum = this._result.round + 1;
2171
- const taggedNewIssues = newResult.issues.map((issue)=>({
2365
+ // 去重:过滤掉已存在于历史 issues 中的新问题(含 valid:false 的都参与去重)
2366
+ const existingKeys = new Set(this._result.issues.map((i)=>generateIssueKey(i)));
2367
+ const dedupedNewIssues = newResult.issues.filter((i)=>!existingKeys.has(generateIssueKey(i)));
2368
+ const taggedNewIssues = dedupedNewIssues.map((issue)=>({
2172
2369
  ...issue,
2173
2370
  round: nextRoundNum
2174
2371
  }));
@@ -2393,8 +2590,9 @@ function generateIssueKey(issue) {
2393
2590
  }
2394
2591
  /**
2395
2592
  * 将有变更文件的历史 issue 标记为无效。
2396
- * 简化策略:如果文件在最新 commit 中有变更,则将该文件的所有历史问题标记为无效。
2397
- */ async invalidateChangedFiles(headSha, verbose) {
2593
+ * 策略:如果文件在最新 commit 中有变更,则将该文件的历史问题标记为无效,但以下情况保留:
2594
+ * - issue 已被用户手动 resolved 且当前代码行内容与 issue.code 不同(说明用户 resolve 后代码已变,应保留其 resolve 状态)
2595
+ */ async invalidateChangedFiles(headSha, fileContents, verbose) {
2398
2596
  if (!headSha) {
2399
2597
  if (shouldLog(verbose, 1)) {
2400
2598
  console.log(` ⚠️ 无法获取 PR head SHA,跳过变更文件检查`);
@@ -2423,13 +2621,32 @@ function generateIssueKey(issue) {
2423
2621
  }
2424
2622
  // 将变更文件的历史 issue 标记为无效
2425
2623
  let invalidatedCount = 0;
2624
+ let preservedCount = 0;
2426
2625
  this._result.issues = this._result.issues.map((issue)=>{
2427
- // 如果 issue 已修复、已解决或已无效,不需要处理
2428
- if (issue.fixed || issue.resolved || issue.valid === "false") {
2626
+ // 如果 issue 已修复或已无效,不需要处理
2627
+ if (issue.fixed || issue.valid === "false") {
2429
2628
  return issue;
2430
2629
  }
2431
- // 如果 issue 所在文件有变更,标记为无效
2630
+ // 如果 issue 所在文件有变更
2432
2631
  if (changedFileSet.has(issue.file)) {
2632
+ // 已 resolved 的 issue:检查当前代码行是否与 issue.code 不同
2633
+ // 不同说明用户 resolve 后代码确实变了,保留其 resolve 状态
2634
+ if (issue.resolved && issue.code && fileContents) {
2635
+ const contentLines = fileContents.get(issue.file);
2636
+ if (contentLines) {
2637
+ const lineNums = issue.line.split("-").map(Number).filter((n)=>!isNaN(n));
2638
+ const startLine = lineNums[0];
2639
+ const endLine = lineNums[lineNums.length - 1];
2640
+ const currentCode = contentLines.slice(startLine - 1, endLine).map(([, line])=>line).join("\n").trim();
2641
+ if (currentCode !== issue.code) {
2642
+ preservedCount++;
2643
+ if (shouldLog(verbose, 1)) {
2644
+ console.log(` ✅ Issue ${issue.file}:${issue.line} 已 resolved 且代码已变更,保留`);
2645
+ }
2646
+ return issue;
2647
+ }
2648
+ }
2649
+ }
2433
2650
  invalidatedCount++;
2434
2651
  if (shouldLog(verbose, 1)) {
2435
2652
  console.log(` 🗑️ Issue ${issue.file}:${issue.line} 所在文件有变更,标记为无效`);
@@ -2442,8 +2659,11 @@ function generateIssueKey(issue) {
2442
2659
  }
2443
2660
  return issue;
2444
2661
  });
2445
- if (invalidatedCount > 0 && shouldLog(verbose, 1)) {
2446
- console.log(` 📊 共标记 ${invalidatedCount} 个历史问题为无效(文件有变更)`);
2662
+ if ((invalidatedCount > 0 || preservedCount > 0) && shouldLog(verbose, 1)) {
2663
+ const parts = [];
2664
+ if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
2665
+ if (preservedCount > 0) parts.push(`保留 ${preservedCount} 个已 resolved`);
2666
+ console.log(` 📊 Issue 处理: ${parts.join(",")}`);
2447
2667
  }
2448
2668
  } catch (error) {
2449
2669
  if (shouldLog(verbose, 1)) {
@@ -2995,6 +3215,80 @@ function generateIssueKey(issue) {
2995
3215
  }).optional()
2996
3216
  });
2997
3217
 
3218
+ ;// CONCATENATED MODULE: ./src/changed-file-collection.ts
3219
+
3220
+ /**
3221
+ * 变更文件集合,封装 ChangedFile[] 并提供常用访问器。
3222
+ */ class ChangedFileCollection {
3223
+ _files;
3224
+ constructor(files){
3225
+ this._files = files;
3226
+ }
3227
+ static from(files) {
3228
+ return new ChangedFileCollection(files);
3229
+ }
3230
+ static empty() {
3231
+ return new ChangedFileCollection([]);
3232
+ }
3233
+ get length() {
3234
+ return this._files.length;
3235
+ }
3236
+ toArray() {
3237
+ return [
3238
+ ...this._files
3239
+ ];
3240
+ }
3241
+ [Symbol.iterator]() {
3242
+ return this._files[Symbol.iterator]();
3243
+ }
3244
+ filenames() {
3245
+ return this._files.map((f)=>f.filename ?? "").filter(Boolean);
3246
+ }
3247
+ extensions() {
3248
+ const exts = new Set();
3249
+ for (const f of this._files){
3250
+ if (f.filename) {
3251
+ const ext = extname(f.filename).replace(/^\./, "").toLowerCase();
3252
+ if (ext) exts.add(ext);
3253
+ }
3254
+ }
3255
+ return exts;
3256
+ }
3257
+ has(filename) {
3258
+ return this._files.some((f)=>f.filename === filename);
3259
+ }
3260
+ filter(predicate) {
3261
+ return new ChangedFileCollection(this._files.filter(predicate));
3262
+ }
3263
+ map(fn) {
3264
+ return this._files.map(fn);
3265
+ }
3266
+ countByStatus() {
3267
+ let added = 0, modified = 0, deleted = 0;
3268
+ for (const f of this._files){
3269
+ if (f.status === "added") added++;
3270
+ else if (f.status === "modified") modified++;
3271
+ else if (f.status === "deleted") deleted++;
3272
+ }
3273
+ return {
3274
+ added,
3275
+ modified,
3276
+ deleted
3277
+ };
3278
+ }
3279
+ nonDeletedFiles() {
3280
+ return this.filter((f)=>f.status !== "deleted" && !!f.filename);
3281
+ }
3282
+ filterByFilenames(names) {
3283
+ const nameSet = new Set(names);
3284
+ return this.filter((f)=>!!f.filename && nameSet.has(f.filename));
3285
+ }
3286
+ filterByCommitFiles(commitFilenames) {
3287
+ const nameSet = new Set(commitFilenames);
3288
+ return this.filter((f)=>!!f.filename && nameSet.has(f.filename));
3289
+ }
3290
+ }
3291
+
2998
3292
  ;// CONCATENATED MODULE: ./src/parse-title-options.ts
2999
3293
 
3000
3294
  /**
@@ -3177,7 +3471,7 @@ class ReviewContextBuilder {
3177
3471
  }
3178
3472
  async getContextFromEnv(options) {
3179
3473
  const reviewConf = this.config.getPluginConfig("review");
3180
- if (shouldLog(options.verbose, 2)) {
3474
+ if (shouldLog(options.verbose, 3)) {
3181
3475
  console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
3182
3476
  }
3183
3477
  const ciConf = this.config.get("ci");
@@ -3536,7 +3830,7 @@ class ReviewIssueFilter {
3536
3830
  if (shouldLog(verbose, 1)) {
3537
3831
  console.log(`📂 解析规则来源: ${specSources.length} 个`);
3538
3832
  }
3539
- const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
3833
+ const specDirs = await this.reviewSpecService.resolveSpecSources(specSources, verbose);
3540
3834
  if (shouldLog(verbose, 2)) {
3541
3835
  console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
3542
3836
  }
@@ -3558,9 +3852,8 @@ class ReviewIssueFilter {
3558
3852
  }
3559
3853
  /**
3560
3854
  * LLM 验证历史问题是否已修复
3561
- * 如果传入 preloaded(specs/fileContents),直接使用;否则从 PR 获取
3562
- */ async verifyAndUpdateIssues(context, issues, commits, preloaded, pr) {
3563
- const { llmMode, specSources, verbose } = context;
3855
+ */ async verifyAndUpdateIssues(context, issues, commits, preloaded) {
3856
+ const { llmMode, verbose } = context;
3564
3857
  const unfixedIssues = issues.filter((i)=>i.valid !== "false" && !i.fixed);
3565
3858
  if (unfixedIssues.length === 0) {
3566
3859
  return issues;
@@ -3571,26 +3864,10 @@ class ReviewIssueFilter {
3571
3864
  }
3572
3865
  return issues;
3573
3866
  }
3574
- if (!preloaded && (!specSources?.length || !pr)) {
3575
- if (shouldLog(verbose, 1)) {
3576
- console.log(` ⏭️ 跳过 LLM 验证(缺少 specSources 或 pr)`);
3577
- }
3578
- return issues;
3579
- }
3580
3867
  if (shouldLog(verbose, 1)) {
3581
3868
  console.log(`\n🔍 开始 LLM 验证 ${unfixedIssues.length} 个未修复问题...`);
3582
3869
  }
3583
- let specs;
3584
- let fileContents;
3585
- if (preloaded) {
3586
- specs = preloaded.specs;
3587
- fileContents = preloaded.fileContents;
3588
- } else {
3589
- const changedFiles = await pr.getFiles();
3590
- const headSha = await pr.getHeadSha();
3591
- specs = await this.loadSpecs(specSources, verbose);
3592
- fileContents = await this.getFileContents(pr.owner, pr.repo, changedFiles, commits, headSha, pr.number, verbose);
3593
- }
3870
+ const { specs, fileContents } = preloaded;
3594
3871
  return await this.issueVerifyService.verifyIssueFixes(issues, fileContents, specs, llmMode, verbose, context.verifyConcurrency);
3595
3872
  }
3596
3873
  async getChangedFilesBetweenRefs(_owner, _repo, baseRef, headRef) {
@@ -3627,77 +3904,6 @@ class ReviewIssueFilter {
3627
3904
  return this.gitSdk.getFilesForCommit(sha);
3628
3905
  }
3629
3906
  }
3630
- /**
3631
- * 获取文件内容并构建行号到 commit hash 的映射
3632
- * 返回 Map<filename, Array<[commitHash, lineCode]>>
3633
- */ async getFileContents(owner, repo, changedFiles, commits, ref, prNumber, verbose, isLocalMode) {
3634
- const contents = new Map();
3635
- const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
3636
- // 优先使用 changedFiles 中的 patch 字段(来自 PR 的整体 diff base...head)
3637
- // 这样行号是相对于最终文件的,而不是每个 commit 的父 commit
3638
- // buildLineCommitMap 遍历每个 commit 的 diff,行号可能与最终文件不一致
3639
- if (shouldLog(verbose, 1)) {
3640
- console.log(`📊 正在构建行号到变更的映射...`);
3641
- }
3642
- for (const file of changedFiles){
3643
- if (file.filename && file.status !== "deleted") {
3644
- try {
3645
- let rawContent;
3646
- if (isLocalMode) {
3647
- // 本地模式:读取工作区文件的当前内容
3648
- rawContent = this.gitSdk.getWorkingFileContent(file.filename);
3649
- } else if (prNumber) {
3650
- rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
3651
- } else {
3652
- rawContent = await this.gitSdk.getFileContent(ref, file.filename);
3653
- }
3654
- const lines = rawContent.split("\n");
3655
- // 优先使用 file.patch(PR 整体 diff),这是相对于最终文件的行号
3656
- let changedLines = parseChangedLinesFromPatch(file.patch);
3657
- // 如果 changedLines 为空,需要判断是否应该将所有行标记为变更
3658
- // 情况1: 文件是新增的(status 为 added/A)
3659
- // 情况2: patch 为空但文件有 additions(部分 Git Provider API 可能不返回完整 patch)
3660
- const isNewFile = file.status === "added" || file.status === "A" || file.additions && file.additions > 0 && file.deletions === 0 && !file.patch;
3661
- if (changedLines.size === 0 && isNewFile) {
3662
- changedLines = new Set(lines.map((_, i)=>i + 1));
3663
- if (shouldLog(verbose, 2)) {
3664
- console.log(` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`);
3665
- }
3666
- }
3667
- if (shouldLog(verbose, 3)) {
3668
- console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
3669
- console.log(` latestCommitHash: ${latestCommitHash}`);
3670
- if (changedLines.size > 0 && changedLines.size <= 20) {
3671
- console.log(` 变更行号: ${Array.from(changedLines).sort((a, b)=>a - b).join(", ")}`);
3672
- } else if (changedLines.size > 20) {
3673
- console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
3674
- }
3675
- if (!file.patch) {
3676
- console.log(` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`);
3677
- } else {
3678
- console.log(` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`);
3679
- }
3680
- }
3681
- const contentLines = lines.map((line, index)=>{
3682
- const lineNum = index + 1;
3683
- // 如果该行在 PR 的整体 diff 中被标记为变更,则使用最新 commit hash
3684
- const hash = changedLines.has(lineNum) ? latestCommitHash : "-------";
3685
- return [
3686
- hash,
3687
- line
3688
- ];
3689
- });
3690
- contents.set(file.filename, contentLines);
3691
- } catch (error) {
3692
- console.warn(`警告: 无法获取文件内容: ${file.filename}`, error);
3693
- }
3694
- }
3695
- }
3696
- if (shouldLog(verbose, 1)) {
3697
- console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
3698
- }
3699
- return contents;
3700
- }
3701
3907
  async fillIssueCode(issues, fileContents) {
3702
3908
  return issues.map((issue)=>{
3703
3909
  const contentLines = fileContents.get(issue.file);
@@ -3722,85 +3928,18 @@ class ReviewIssueFilter {
3722
3928
  });
3723
3929
  }
3724
3930
  /**
3725
- * 根据代码变更更新历史 issue 的行号
3726
- * 当代码发生变化时,之前发现的 issue 行号可能已经不准确
3727
- * 此方法通过分析 diff 来计算新的行号
3728
- */ updateIssueLineNumbers(issues, filePatchMap, verbose) {
3729
- let updatedCount = 0;
3730
- let invalidatedCount = 0;
3731
- const updatedIssues = issues.map((issue)=>{
3732
- // 如果 issue 已修复、已解决或无效,不需要更新行号
3733
- if (issue.fixed || issue.resolved || issue.valid === "false") {
3734
- return issue;
3735
- }
3736
- const patch = filePatchMap.get(issue.file);
3737
- if (!patch) {
3738
- // 文件没有变更,行号不变
3739
- return issue;
3740
- }
3741
- const lines = this.reviewSpecService.parseLineRange(issue.line);
3742
- if (lines.length === 0) {
3743
- return issue;
3744
- }
3745
- const startLine = lines[0];
3746
- const endLine = lines[lines.length - 1];
3747
- const hunks = parseHunksFromPatch(patch);
3748
- // 计算新的起始行号
3749
- const newStartLine = calculateNewLineNumber(startLine, hunks);
3750
- if (newStartLine === null) {
3751
- // 起始行被删除,直接标记为无效问题
3752
- invalidatedCount++;
3753
- if (shouldLog(verbose, 1)) {
3754
- console.log(`📍 Issue ${issue.file}:${issue.line} 对应的代码已被删除,标记为无效`);
3755
- }
3756
- return {
3757
- ...issue,
3758
- valid: "false",
3759
- originalLine: issue.originalLine ?? issue.line
3760
- };
3761
- }
3762
- // 如果是范围行号,计算新的结束行号
3763
- let newLine;
3764
- if (startLine === endLine) {
3765
- newLine = String(newStartLine);
3766
- } else {
3767
- const newEndLine = calculateNewLineNumber(endLine, hunks);
3768
- if (newEndLine === null || newEndLine === newStartLine) {
3769
- // 结束行被删除或范围缩小为单行,使用起始行
3770
- newLine = String(newStartLine);
3771
- } else {
3772
- newLine = `${newStartLine}-${newEndLine}`;
3773
- }
3774
- }
3775
- // 如果行号发生变化,更新 issue
3776
- if (newLine !== issue.line) {
3777
- updatedCount++;
3778
- if (shouldLog(verbose, 1)) {
3779
- console.log(`📍 Issue 行号更新: ${issue.file}:${issue.line} -> ${issue.file}:${newLine}`);
3780
- }
3781
- return {
3782
- ...issue,
3783
- line: newLine,
3784
- originalLine: issue.originalLine ?? issue.line
3785
- };
3786
- }
3787
- return issue;
3788
- });
3789
- if ((updatedCount > 0 || invalidatedCount > 0) && shouldLog(verbose, 1)) {
3790
- const parts = [];
3791
- if (updatedCount > 0) parts.push(`更新 ${updatedCount} 个行号`);
3792
- if (invalidatedCount > 0) parts.push(`标记 ${invalidatedCount} 个无效`);
3793
- console.log(`📊 Issue 行号处理: ${parts.join(",")}`);
3794
- }
3795
- return updatedIssues;
3796
- }
3797
- /**
3798
3931
  * 过滤掉不属于本次 PR commits 的问题(排除 merge commit 引入的代码)
3799
3932
  * 根据 fileContents 中问题行的实际 commit hash 进行验证,而不是依赖 LLM 填写的 commit
3800
3933
  */ filterIssuesByValidCommits(issues, commits, fileContents, verbose) {
3801
3934
  const validCommitHashes = new Set(commits.map((c)=>c.sha?.slice(0, 7)).filter(Boolean));
3935
+ // commits 为空时(如分支比较模式本地无 commit 信息),退化为"行是否在 diff 变更范围内"模式
3936
+ const useChangedLinesMode = validCommitHashes.size === 0;
3802
3937
  if (shouldLog(verbose, 3)) {
3803
- console.log(` 🔍 有效 commit hashes: ${Array.from(validCommitHashes).join(", ")}`);
3938
+ if (useChangedLinesMode) {
3939
+ console.log(` 🔍 commits 为空,使用变更行模式过滤`);
3940
+ } else {
3941
+ console.log(` 🔍 有效 commit hashes: ${Array.from(validCommitHashes).join(", ")}`);
3942
+ }
3804
3943
  }
3805
3944
  const beforeCount = issues.length;
3806
3945
  const filtered = issues.filter((issue)=>{
@@ -3819,12 +3958,14 @@ class ReviewIssueFilter {
3819
3958
  }
3820
3959
  return true;
3821
3960
  }
3822
- // 检查问题行范围内是否有任意一行属于本次 PR 的有效 commits
3961
+ // 检查问题行范围内是否有任意一行属于本次变更(diff 范围)
3823
3962
  for (const lineNum of lineNums){
3824
3963
  const lineData = contentLines[lineNum - 1];
3825
3964
  if (lineData) {
3826
3965
  const [actualHash] = lineData;
3827
- if (actualHash !== "-------" && validCommitHashes.has(actualHash)) {
3966
+ const isChangedLine = actualHash !== "-------";
3967
+ const isValid = useChangedLinesMode ? isChangedLine : isChangedLine && validCommitHashes.has(actualHash);
3968
+ if (isValid) {
3828
3969
  if (shouldLog(verbose, 3)) {
3829
3970
  console.log(` ✅ Issue ${issue.file}:${issue.line} - 行 ${lineNum} hash=${actualHash} 匹配,保留`);
3830
3971
  }
@@ -3832,9 +3973,9 @@ class ReviewIssueFilter {
3832
3973
  }
3833
3974
  }
3834
3975
  }
3835
- // 问题行都不属于本次 PR 的有效 commits
3976
+ // 问题行都不属于本次变更范围
3836
3977
  if (shouldLog(verbose, 2)) {
3837
- console.log(` Issue ${issue.file}:${issue.line} 不在本次 PR 变更行范围内,跳过`);
3978
+ console.log(` Issue ${issue.file}:${issue.line} 不在本次变更行范围内,跳过`);
3838
3979
  }
3839
3980
  if (shouldLog(verbose, 3)) {
3840
3981
  const hashes = lineNums.map((ln)=>{
@@ -3846,7 +3987,7 @@ class ReviewIssueFilter {
3846
3987
  return false;
3847
3988
  });
3848
3989
  if (beforeCount !== filtered.length && shouldLog(verbose, 1)) {
3849
- console.log(` 过滤非本次 PR commits 问题后: ${beforeCount} -> ${filtered.length} 个问题`);
3990
+ console.log(` 变更行过滤后: ${beforeCount} -> ${filtered.length} 个问题`);
3850
3991
  }
3851
3992
  return filtered;
3852
3993
  }
@@ -3866,43 +4007,6 @@ class ReviewIssueFilter {
3866
4007
  generateIssueKey(issue) {
3867
4008
  return generateIssueKey(issue);
3868
4009
  }
3869
- /**
3870
- * 构建文件行号到 commit hash 的映射
3871
- * 遍历每个 commit,获取其修改的文件和行号
3872
- * 优先使用 API,失败时回退到 git 命令
3873
- */ async buildLineCommitMap(owner, repo, commits, verbose) {
3874
- // Map<filename, Map<lineNumber, commitHash>>
3875
- const fileLineMap = new Map();
3876
- // 按时间顺序遍历 commits(早的在前),后面的 commit 会覆盖前面的
3877
- for (const commit of commits){
3878
- if (!commit.sha) continue;
3879
- const shortHash = commit.sha.slice(0, 7);
3880
- let files = [];
3881
- // 优先使用 getCommitDiff API 获取 diff 文本
3882
- try {
3883
- const diffText = await this.gitProvider.getCommitDiff(owner, repo, commit.sha);
3884
- files = parseDiffText(diffText);
3885
- } catch {
3886
- // API 失败,回退到 git 命令
3887
- files = this.gitSdk.getCommitDiff(commit.sha);
3888
- }
3889
- if (shouldLog(verbose, 2)) console.log(` commit ${shortHash}: ${files.length} 个文件变更`);
3890
- for (const file of files){
3891
- // 解析这个 commit 修改的行号
3892
- const changedLines = parseChangedLinesFromPatch(file.patch);
3893
- // 获取或创建文件的行号映射
3894
- if (!fileLineMap.has(file.filename)) {
3895
- fileLineMap.set(file.filename, new Map());
3896
- }
3897
- const lineMap = fileLineMap.get(file.filename);
3898
- // 记录每行对应的 commit hash
3899
- for (const lineNum of changedLines){
3900
- lineMap.set(lineNum, shortHash);
3901
- }
3902
- }
3903
- }
3904
- return fileLineMap;
3905
- }
3906
4010
  }
3907
4011
 
3908
4012
  ;// CONCATENATED MODULE: ./src/utils/review-llm.ts
@@ -4533,6 +4637,7 @@ const buildDeletionImpactAgentPrompt = (ctx)=>{
4533
4637
  const { spec, rule } = ctx.ruleInfo;
4534
4638
  ruleSection = `### ${spec.filename} (${spec.type})\n\n${spec.content.slice(0, 200)}...\n\n#### 规则\n- ${rule.id}: ${rule.title}\n ${rule.description}`;
4535
4639
  }
4640
+ const originalCodeSection = ctx.issue.code ? `\n## 问题发现时的原始代码(用于对比)\n\n\`\`\`\n${ctx.issue.code}\n\`\`\`\n` : "";
4536
4641
  const userPrompt = `## 规则定义
4537
4642
 
4538
4643
  ${ruleSection}
@@ -4540,18 +4645,19 @@ ${ruleSection}
4540
4645
  ## 之前发现的问题
4541
4646
 
4542
4647
  - **文件**: ${ctx.issue.file}
4543
- - **行号**: ${ctx.issue.line}
4648
+ - **行号**: ${ctx.issue.line}(问题发现时的行号,可能因代码变更而偏移)
4544
4649
  - **规则**: ${ctx.issue.ruleId} (来自 ${ctx.issue.specFile})
4545
4650
  - **问题描述**: ${ctx.issue.reason}
4546
4651
  ${ctx.issue.suggestion ? `- **原建议**: ${ctx.issue.suggestion}` : ""}
4547
-
4652
+ ${originalCodeSection}
4548
4653
  ## 当前文件内容
4549
4654
 
4550
4655
  \`\`\`
4551
4656
  ${linesWithNumbers}
4552
4657
  \`\`\`
4553
4658
 
4554
- 请判断这个问题是否有效,以及是否已经被修复。`;
4659
+ 请判断这个问题是否有效,以及是否已经被修复。
4660
+ **注意**:如果提供了"问题发现时的原始代码",请优先通过搜索该代码片段来定位问题位置,而不是仅依赖行号(行号可能因代码变更已经偏移)。`;
4555
4661
  return {
4556
4662
  systemPrompt,
4557
4663
  userPrompt
@@ -4703,8 +4809,8 @@ class ReviewLlmProcessor {
4703
4809
  }
4704
4810
  async buildReviewPrompt(specs, changedFiles, fileContents, commits, existingResult, whenModifiedCode, verbose, systemRules) {
4705
4811
  const round = (existingResult?.round ?? 0) + 1;
4706
- const { staticIssues, skippedFiles } = applyStaticRules(changedFiles, fileContents, systemRules, round, verbose);
4707
- const fileDataList = changedFiles.filter((f)=>f.status !== "deleted" && f.filename).map((file)=>{
4812
+ const { staticIssues, skippedFiles } = applyStaticRules(changedFiles.toArray(), fileContents, systemRules, round, verbose);
4813
+ const fileDataList = changedFiles.nonDeletedFiles().map((file)=>{
4708
4814
  const filename = file.filename;
4709
4815
  if (skippedFiles.has(filename)) return null;
4710
4816
  const contentLines = fileContents.get(filename);
@@ -4985,7 +5091,7 @@ class ReviewLlmProcessor {
4985
5091
  */ async generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose) {
4986
5092
  const { userPrompt } = buildPrDescriptionPrompt({
4987
5093
  commits,
4988
- changedFiles,
5094
+ changedFiles: changedFiles.toArray(),
4989
5095
  fileContents
4990
5096
  });
4991
5097
  try {
@@ -5025,7 +5131,7 @@ class ReviewLlmProcessor {
5025
5131
  */ async generatePrTitle(commits, changedFiles) {
5026
5132
  const { userPrompt } = buildPrTitlePrompt({
5027
5133
  commits,
5028
- changedFiles
5134
+ changedFiles: changedFiles.toArray()
5029
5135
  });
5030
5136
  try {
5031
5137
  const stream = this.llmProxyService.chatStream([
@@ -5068,9 +5174,7 @@ class ReviewLlmProcessor {
5068
5174
  }
5069
5175
  }
5070
5176
  if (changedFiles.length > 0) {
5071
- const added = changedFiles.filter((f)=>f.status === "added").length;
5072
- const modified = changedFiles.filter((f)=>f.status === "modified").length;
5073
- const deleted = changedFiles.filter((f)=>f.status === "deleted").length;
5177
+ const { added, modified, deleted } = changedFiles.countByStatus();
5074
5178
  const stats = [];
5075
5179
  if (added > 0) stats.push(`新增 ${added}`);
5076
5180
  if (modified > 0) stats.push(`修改 ${modified}`);
@@ -5084,200 +5188,153 @@ class ReviewLlmProcessor {
5084
5188
  }
5085
5189
  }
5086
5190
 
5087
- ;// CONCATENATED MODULE: ./src/review.service.ts
5088
-
5089
-
5090
-
5191
+ ;// CONCATENATED MODULE: ./src/review-source-resolver.ts
5091
5192
 
5092
5193
 
5093
5194
 
5094
5195
 
5095
5196
 
5096
5197
 
5097
-
5098
-
5099
- class ReviewService {
5198
+ /**
5199
+ * 审查源数据解析器:根据审查模式(本地/PR/分支比较)获取 commits、changedFiles 等输入数据,
5200
+ * 并应用前置过滤管道(merge commit、files、commits、includes)。
5201
+ *
5202
+ * 从 ReviewService 中提取,职责单一化:只负责"获取和过滤源数据",不涉及 LLM 审查、报告生成等。
5203
+ */ class ReviewSourceResolver {
5100
5204
  gitProvider;
5101
- config;
5102
- reviewSpecService;
5103
- llmProxyService;
5104
- reviewReportService;
5105
- issueVerifyService;
5106
- deletionImpactService;
5107
5205
  gitSdk;
5108
- contextBuilder;
5109
5206
  issueFilter;
5110
- llmProcessor;
5111
- resultModelDeps;
5112
- constructor(gitProvider, config, reviewSpecService, llmProxyService, reviewReportService, issueVerifyService, deletionImpactService, gitSdk){
5207
+ constructor(gitProvider, gitSdk, issueFilter){
5113
5208
  this.gitProvider = gitProvider;
5114
- this.config = config;
5115
- this.reviewSpecService = reviewSpecService;
5116
- this.llmProxyService = llmProxyService;
5117
- this.reviewReportService = reviewReportService;
5118
- this.issueVerifyService = issueVerifyService;
5119
- this.deletionImpactService = deletionImpactService;
5120
5209
  this.gitSdk = gitSdk;
5121
- this.contextBuilder = new ReviewContextBuilder(gitProvider, config, gitSdk);
5122
- this.issueFilter = new ReviewIssueFilter(gitProvider, config, reviewSpecService, issueVerifyService, gitSdk);
5123
- this.llmProcessor = new ReviewLlmProcessor(llmProxyService, reviewSpecService);
5124
- this.resultModelDeps = {
5125
- gitProvider,
5126
- config,
5127
- reviewSpecService,
5128
- reviewReportService
5129
- };
5130
- }
5131
- async getContextFromEnv(options) {
5132
- return this.contextBuilder.getContextFromEnv(options);
5210
+ this.issueFilter = issueFilter;
5133
5211
  }
5134
5212
  /**
5135
- * 执行代码审查的主方法
5136
- * 该方法负责协调整个审查流程,包括:
5137
- * 1. 加载审查规范(specs)
5138
- * 2. 获取 PR/分支的变更文件和提交记录
5139
- * 3. 调用 LLM 进行代码审查
5140
- * 4. 处理历史 issue(更新行号、验证修复状态)
5141
- * 5. 生成并发布审查报告
5213
+ * 解析输入数据:根据模式(本地/PR/分支比较)获取 commits、changedFiles 等。
5214
+ * 包含前置过滤(merge commit、files、commits、includes)。
5215
+ * 如果需要提前返回(如同分支、重复 workflow),通过 earlyReturn 字段传递。
5142
5216
  *
5143
- * @param context 审查上下文,包含 owner、repo、prNumber 等信息
5144
- * @returns 审查结果,包含发现的问题列表和统计信息
5145
- */ async execute(context) {
5146
- const { specSources, verbose, llmMode, deletionOnly } = context;
5147
- if (shouldLog(verbose, 1)) {
5148
- console.log(`🔍 Review 启动`);
5149
- console.log(` DRY-RUN mode: ${context.dryRun ? "enabled" : "disabled"}`);
5150
- console.log(` CI mode: ${context.ci ? "enabled" : "disabled"}`);
5151
- if (context.localMode) console.log(` Local mode: ${context.localMode}`);
5152
- console.log(` Verbose: ${verbose}`);
5153
- }
5154
- // 早期分流
5155
- if (deletionOnly) return this.executeDeletionOnly(context);
5156
- if (context.eventAction === "closed" || context.flush) return this.executeCollectOnly(context);
5157
- // 1. 解析输入数据(本地/PR/分支模式 + 前置过滤)
5158
- const source = await this.resolveSourceData(context);
5159
- if (source.earlyReturn) return source.earlyReturn;
5160
- const { prModel, commits, changedFiles, headSha, isDirectFileMode } = source;
5161
- const effectiveWhenModifiedCode = isDirectFileMode ? undefined : context.whenModifiedCode;
5162
- if (isDirectFileMode && context.whenModifiedCode?.length && shouldLog(verbose, 1)) {
5163
- console.log(`ℹ️ 直接文件模式下忽略 whenModifiedCode 过滤`);
5164
- }
5165
- // 2. 规则匹配
5166
- const specs = await this.issueFilter.loadSpecs(specSources, verbose);
5167
- const applicableSpecs = this.reviewSpecService.filterApplicableSpecs(specs, changedFiles);
5168
- if (shouldLog(verbose, 2)) {
5169
- console.log(`[execute] loadSpecs: loaded ${specs.length} specs from sources: ${JSON.stringify(specSources)}`);
5170
- console.log(`[execute] filterApplicableSpecs: ${applicableSpecs.length} applicable out of ${specs.length}, changedFiles=${JSON.stringify(changedFiles.map((f)=>f.filename))}`);
5171
- }
5172
- if (shouldLog(verbose, 1)) {
5173
- console.log(` 适用的规则文件: ${applicableSpecs.length}`);
5174
- }
5175
- if (applicableSpecs.length === 0 || changedFiles.length === 0) {
5176
- return this.handleNoApplicableSpecs(context, applicableSpecs, changedFiles, commits);
5217
+ * 数据获取流程:
5218
+ * 1. 本地模式 → resolveLocalFiles(暂存区/未提交变更,无变更时回退分支比较)
5219
+ * 2. 直接文件模式(-f)→ 构造 changedFiles
5220
+ * 3. PR 模式 resolvePrData(含重复 workflow 检查)
5221
+ * 4. 分支比较模式 resolveBranchCompareData
5222
+ *
5223
+ * 前置过滤管道(applyPreFilters):
5224
+ * 0. merge commit 过滤
5225
+ * 1. --files 过滤
5226
+ * 2. --commits 过滤
5227
+ * 3. --includes 过滤(支持 status| 前缀语法)
5228
+ */ async resolve(context) {
5229
+ const { prNumber, verbose, files, localMode } = context;
5230
+ const isDirectFileMode = !!(files && files.length > 0 && !prNumber);
5231
+ let isLocalMode = !!localMode;
5232
+ let effectiveBaseRef = context.baseRef;
5233
+ let effectiveHeadRef = context.headRef;
5234
+ let prModel;
5235
+ let commits = [];
5236
+ let changedFiles = [];
5237
+ // ── 阶段 1:按模式获取 commits + changedFiles ──────────
5238
+ if (isLocalMode) {
5239
+ const local = this.resolveLocalFiles(localMode, verbose);
5240
+ if (local.earlyReturn) return {
5241
+ ...local.earlyReturn,
5242
+ changedFiles: ChangedFileCollection.from(local.earlyReturn.changedFiles),
5243
+ isDirectFileMode: false,
5244
+ fileContents: new Map()
5245
+ };
5246
+ isLocalMode = local.isLocalMode;
5247
+ changedFiles = local.changedFiles;
5248
+ effectiveBaseRef = local.effectiveBaseRef ?? effectiveBaseRef;
5249
+ effectiveHeadRef = local.effectiveHeadRef ?? effectiveHeadRef;
5177
5250
  }
5178
- // 3. 获取文件内容 + LLM 审查
5179
- const fileContents = await this.getFileContents(context.owner, context.repo, changedFiles, commits, headSha, context.prNumber, verbose, source.isLocalMode);
5180
- if (!llmMode) throw new Error("必须指定 LLM 类型");
5181
- // 获取上一次的审查结果(用于提示词优化和轮次推进)
5182
- let existingResultModel = null;
5183
- if (context.ci && prModel) {
5184
- existingResultModel = await ReviewResultModel.loadFromPr(prModel, this.resultModelDeps);
5185
- if (existingResultModel && shouldLog(verbose, 1)) {
5186
- console.log(`📋 获取到上一次审查结果,包含 ${existingResultModel.issues.length} 个问题`);
5251
+ if (isDirectFileMode) {
5252
+ // 直接文件审查模式(-f):绕过 diff,直接按指定文件构造审查输入
5253
+ if (shouldLog(verbose, 1)) {
5254
+ console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
5187
5255
  }
5188
- }
5189
- if (shouldLog(verbose, 1)) {
5190
- console.log(`🔄 当前审查轮次: ${(existingResultModel?.round ?? 0) + 1}`);
5191
- }
5192
- const reviewPrompt = await this.buildReviewPrompt(specs, changedFiles, fileContents, commits, existingResultModel?.result ?? null, effectiveWhenModifiedCode, verbose, context.systemRules);
5193
- const result = await this.runLLMReview(llmMode, reviewPrompt, {
5194
- verbose,
5195
- concurrency: context.concurrency,
5196
- timeout: context.timeout,
5197
- retries: context.retries,
5198
- retryDelay: context.retryDelay
5199
- });
5200
- // 填充 PR 功能描述和标题
5201
- const prInfo = context.generateDescription ? await this.generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose) : await this.buildBasicDescription(commits, changedFiles);
5202
- result.title = prInfo.title;
5203
- result.description = prInfo.description;
5204
- if (shouldLog(verbose, 1)) {
5205
- console.log(`📝 LLM 审查完成,发现 ${result.issues.length} 个问题`);
5206
- }
5207
- // 4. 过滤新 issues
5208
- result.issues = await this.fillIssueCode(result.issues, fileContents);
5209
- result.issues = this.filterNewIssues(result.issues, specs, applicableSpecs, {
5210
- commits,
5211
- fileContents,
5212
- changedFiles,
5213
- isDirectFileMode,
5214
- context
5215
- });
5216
- // 静态规则产生的系统问题直接合并,不经过过滤管道
5217
- if (reviewPrompt.staticIssues?.length) {
5218
- result.issues = [
5219
- ...reviewPrompt.staticIssues,
5220
- ...result.issues
5221
- ];
5256
+ changedFiles = files.map((f)=>({
5257
+ filename: f,
5258
+ status: "modified"
5259
+ }));
5260
+ isLocalMode = true;
5261
+ } else if (prNumber) {
5262
+ const prData = await this.resolvePrData(context);
5263
+ if (prData.earlyReturn) {
5264
+ return {
5265
+ ...prData,
5266
+ changedFiles: ChangedFileCollection.from(prData.changedFiles),
5267
+ headSha: prData.headSha,
5268
+ isLocalMode,
5269
+ isDirectFileMode,
5270
+ fileContents: new Map()
5271
+ };
5272
+ }
5273
+ prModel = prData.prModel;
5274
+ commits = prData.commits;
5275
+ changedFiles = prData.changedFiles;
5276
+ } else if (effectiveBaseRef && effectiveHeadRef) {
5277
+ if (changedFiles.length === 0) {
5278
+ const branchData = await this.resolveBranchCompareData(context, effectiveBaseRef, effectiveHeadRef);
5279
+ commits = branchData.commits;
5280
+ changedFiles = branchData.changedFiles;
5281
+ }
5282
+ } else if (!isLocalMode) {
5222
5283
  if (shouldLog(verbose, 1)) {
5223
- console.log(`⚙️ 追加 ${reviewPrompt.staticIssues.length} 个静态规则系统问题`);
5284
+ console.log(`❌ 错误: 缺少 prNumber 或 baseRef/headRef`, {
5285
+ prNumber,
5286
+ baseRef: context.baseRef,
5287
+ headRef: context.headRef
5288
+ });
5224
5289
  }
5290
+ throw new Error("必须指定 PR 编号或者 base/head 分支");
5225
5291
  }
5226
- if (shouldLog(verbose, 1)) {
5227
- console.log(`📝 最终发现 ${result.issues.length} 个问题`);
5228
- }
5229
- // 5. 构建最终的 ReviewResultModel
5230
- const finalModel = await this.buildFinalModel(context, result, {
5292
+ // ── 阶段 2:前置过滤管道 ─────────────────────────────
5293
+ ({ commits, changedFiles } = await this.applyPreFilters(context, commits, changedFiles, isDirectFileMode));
5294
+ const headSha = prModel ? await prModel.getHeadSha() : context.headRef || "HEAD";
5295
+ const collectedFiles = ChangedFileCollection.from(changedFiles);
5296
+ const fileContents = await this.getFileContents(context.owner, context.repo, collectedFiles.toArray(), commits, headSha, context.prNumber, isLocalMode, context.verbose);
5297
+ return {
5231
5298
  prModel,
5232
5299
  commits,
5300
+ changedFiles: collectedFiles,
5233
5301
  headSha,
5234
- specs,
5302
+ isLocalMode,
5303
+ isDirectFileMode,
5235
5304
  fileContents
5236
- }, existingResultModel);
5237
- // 6. 保存 + 输出
5238
- await this.saveAndOutput(context, finalModel, commits);
5239
- return finalModel.result;
5305
+ };
5240
5306
  }
5241
- // ─── 提取的子方法 ──────────────────────────────────────
5307
+ // ─── 数据获取子方法 ──────────────────────────────────────
5242
5308
  /**
5243
- * 解析输入数据:根据模式(本地/PR/分支比较)获取 commits、changedFiles 等。
5244
- * 包含前置过滤(merge commit、files、commits、includes)。
5245
- * 如果需要提前返回(如同分支、重复 workflow),通过 earlyReturn 字段传递。
5246
- */ async resolveSourceData(context) {
5247
- const { owner, repo, prNumber, baseRef, headRef, verbose, ci, includes, files, commits: filterCommits, localMode, duplicateWorkflowResolved } = context;
5248
- const isDirectFileMode = !!(files && files.length > 0 && !prNumber);
5249
- let isLocalMode = !!localMode;
5250
- let effectiveBaseRef = baseRef;
5251
- let effectiveHeadRef = headRef;
5252
- let prModel;
5253
- let commits = [];
5254
- let changedFiles = [];
5255
- if (isLocalMode) {
5256
- // 本地模式:从 git 获取未提交/暂存区的变更
5309
+ * 本地模式:获取暂存区或未提交的变更文件。
5310
+ * 如果本地无变更,自动回退到分支比较模式并检测 base/head 分支。
5311
+ * 同分支时通过 earlyReturn 提前终止。
5312
+ */ resolveLocalFiles(localMode, verbose) {
5313
+ if (shouldLog(verbose, 1)) {
5314
+ console.log(`📥 本地模式: 获取${localMode === "staged" ? "暂存区" : "未提交"}的代码变更`);
5315
+ }
5316
+ const localFiles = localMode === "staged" ? this.gitSdk.getStagedFiles() : this.gitSdk.getUncommittedFiles();
5317
+ if (localFiles.length === 0) {
5318
+ // 本地无变更,回退到分支比较模式
5257
5319
  if (shouldLog(verbose, 1)) {
5258
- console.log(`📥 本地模式: 获取${localMode === "staged" ? "暂存区" : "未提交"}的代码变更`);
5320
+ console.log(`ℹ️ 没有${localMode === "staged" ? "暂存区" : "未提交"}的代码变更,回退到分支比较模式`);
5259
5321
  }
5260
- const localFiles = localMode === "staged" ? this.gitSdk.getStagedFiles() : this.gitSdk.getUncommittedFiles();
5261
- if (localFiles.length === 0) {
5262
- // 本地无变更,回退到分支比较模式
5263
- if (shouldLog(verbose, 1)) {
5264
- console.log(`ℹ️ 没有${localMode === "staged" ? "暂存区" : "未提交"}的代码变更,回退到分支比较模式`);
5265
- }
5266
- isLocalMode = false;
5267
- effectiveHeadRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
5268
- effectiveBaseRef = this.gitSdk.getDefaultBranch();
5269
- if (shouldLog(verbose, 1)) {
5270
- console.log(`📌 自动检测分支: base=${effectiveBaseRef}, head=${effectiveHeadRef}`);
5271
- }
5272
- // 同分支无法比较,提前返回
5273
- if (effectiveBaseRef === effectiveHeadRef) {
5274
- console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
5275
- return {
5322
+ const effectiveHeadRef = this.gitSdk.getCurrentBranch() ?? "HEAD";
5323
+ const effectiveBaseRef = this.gitSdk.getDefaultBranch();
5324
+ if (shouldLog(verbose, 1)) {
5325
+ console.log(`📌 自动检测分支: base=${effectiveBaseRef}, head=${effectiveHeadRef}`);
5326
+ }
5327
+ // 同分支无法比较,提前返回
5328
+ if (effectiveBaseRef === effectiveHeadRef) {
5329
+ console.log(`ℹ️ 当前分支 ${effectiveHeadRef} 与默认分支相同,没有可审查的代码变更`);
5330
+ return {
5331
+ changedFiles: [],
5332
+ isLocalMode: false,
5333
+ earlyReturn: {
5276
5334
  commits: [],
5277
5335
  changedFiles: [],
5278
5336
  headSha: "HEAD",
5279
5337
  isLocalMode: false,
5280
- isDirectFileMode: false,
5281
5338
  earlyReturn: {
5282
5339
  success: true,
5283
5340
  description: "",
@@ -5285,176 +5342,533 @@ class ReviewService {
5285
5342
  summary: [],
5286
5343
  round: 1
5287
5344
  }
5288
- };
5289
- }
5290
- } else {
5291
- // 一次性获取所有 diff,避免每个文件调用一次 git 命令
5292
- const localDiffs = localMode === "staged" ? this.gitSdk.getStagedDiff() : this.gitSdk.getUncommittedDiff();
5293
- const diffMap = new Map(localDiffs.map((d)=>[
5294
- d.filename,
5295
- d.patch
5296
- ]));
5297
- changedFiles = localFiles.map((f)=>({
5298
- filename: f.filename,
5299
- status: f.status,
5300
- patch: diffMap.get(f.filename)
5301
- }));
5302
- if (shouldLog(verbose, 1)) {
5303
- console.log(` Changed files: ${changedFiles.length}`);
5304
- }
5345
+ }
5346
+ };
5305
5347
  }
5348
+ return {
5349
+ changedFiles: [],
5350
+ isLocalMode: false,
5351
+ effectiveBaseRef,
5352
+ effectiveHeadRef
5353
+ };
5306
5354
  }
5307
- // 直接文件审查模式(-f):绕过 diff,直接按指定文件构造审查输入
5308
- if (isDirectFileMode) {
5355
+ // 一次性获取所有 diff,避免每个文件调用一次 git 命令
5356
+ const localDiffs = localMode === "staged" ? this.gitSdk.getStagedDiff() : this.gitSdk.getUncommittedDiff();
5357
+ const diffMap = new Map(localDiffs.map((d)=>[
5358
+ d.filename,
5359
+ d.patch
5360
+ ]));
5361
+ const changedFiles = localFiles.map((f)=>({
5362
+ filename: f.filename,
5363
+ status: f.status,
5364
+ patch: diffMap.get(f.filename)
5365
+ }));
5366
+ if (shouldLog(verbose, 1)) {
5367
+ console.log(` Changed files: ${changedFiles.length}`);
5368
+ }
5369
+ return {
5370
+ changedFiles,
5371
+ isLocalMode: true
5372
+ };
5373
+ }
5374
+ /**
5375
+ * PR 模式:获取 PR 信息、commits、changedFiles。
5376
+ * 同时检查是否有同名 review workflow 正在运行(防止重复审查)。
5377
+ */ async resolvePrData(context) {
5378
+ const { owner, repo, prNumber, verbose, ci, duplicateWorkflowResolved } = context;
5379
+ if (shouldLog(verbose, 1)) {
5380
+ console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
5381
+ }
5382
+ const prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
5383
+ const prInfo = await prModel.getInfo();
5384
+ const commits = await prModel.getCommits();
5385
+ const changedFiles = await prModel.getFiles();
5386
+ if (shouldLog(verbose, 1)) {
5387
+ console.log(` PR: ${prInfo?.title}`);
5388
+ console.log(` Commits: ${commits.length}`);
5389
+ console.log(` Changed files: ${changedFiles.length}`);
5390
+ }
5391
+ // 检查是否有其他同名 review workflow 正在运行中
5392
+ if (duplicateWorkflowResolved !== "off" && ci && prInfo?.head?.sha) {
5393
+ const duplicateResult = await this.checkDuplicateWorkflow(prModel, prInfo.head.sha, duplicateWorkflowResolved, verbose);
5394
+ if (duplicateResult) {
5395
+ return {
5396
+ prModel,
5397
+ commits,
5398
+ changedFiles,
5399
+ headSha: prInfo.head.sha,
5400
+ earlyReturn: duplicateResult
5401
+ };
5402
+ }
5403
+ }
5404
+ return {
5405
+ prModel,
5406
+ commits,
5407
+ changedFiles
5408
+ };
5409
+ }
5410
+ /**
5411
+ * 分支比较模式:获取 base...head 之间的 changedFiles 和 commits。
5412
+ */ async resolveBranchCompareData(context, baseRef, headRef) {
5413
+ const { owner, repo, verbose } = context;
5414
+ if (shouldLog(verbose, 1)) {
5415
+ console.log(`📥 获取 ${baseRef}...${headRef} 的差异 (owner: ${owner}, repo: ${repo})`);
5416
+ }
5417
+ const changedFiles = await this.issueFilter.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
5418
+ const commits = await this.issueFilter.getCommitsBetweenRefs(baseRef, headRef);
5419
+ if (shouldLog(verbose, 1)) {
5420
+ console.log(` Changed files: ${changedFiles.length}`);
5421
+ console.log(` Commits: ${commits.length}`);
5422
+ }
5423
+ return {
5424
+ commits,
5425
+ changedFiles
5426
+ };
5427
+ }
5428
+ // ─── 前置过滤 ──────────────────────────────────────────
5429
+ /**
5430
+ * 前置过滤管道:对 commits 和 changedFiles 依次执行过滤。
5431
+ *
5432
+ * 过滤顺序:
5433
+ * 0. merge commit — 排除以 "Merge " 开头的 commit
5434
+ * 1. --files — 仅保留用户指定的文件
5435
+ * 2. --commits — 仅保留用户指定的 commit 及其涉及的文件
5436
+ * 3. --includes — glob 模式过滤文件和 commits(支持 status| 前缀语法)
5437
+ */ async applyPreFilters(context, commits, rawChangedFiles, isDirectFileMode) {
5438
+ const { owner, repo, prNumber, verbose, includes, files, commits: filterCommits } = context;
5439
+ let changedFiles = ChangedFileCollection.from(rawChangedFiles);
5440
+ // 0. 过滤掉 merge commit
5441
+ {
5442
+ const before = commits.length;
5443
+ commits = commits.filter((c)=>{
5444
+ const message = c.commit?.message || "";
5445
+ return !message.startsWith("Merge ");
5446
+ });
5447
+ if (before !== commits.length && shouldLog(verbose, 1)) {
5448
+ console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
5449
+ }
5450
+ }
5451
+ // 1. 按指定的 files 过滤
5452
+ if (files && files.length > 0) {
5453
+ const before = changedFiles.length;
5454
+ changedFiles = changedFiles.filterByFilenames(files);
5309
5455
  if (shouldLog(verbose, 1)) {
5310
- console.log(`📥 直接审查指定文件模式 (${files.length} 个文件)`);
5456
+ console.log(` Files 过滤文件: ${before} -> ${changedFiles.length} 个文件`);
5311
5457
  }
5312
- changedFiles = files.map((f)=>({
5313
- filename: f,
5314
- status: "modified"
5315
- }));
5316
- isLocalMode = true;
5317
- } else if (prNumber) {
5458
+ }
5459
+ // 2. 按指定的 commits 过滤(同时过滤文件:仅保留属于匹配 commits 的文件)
5460
+ if (filterCommits && filterCommits.length > 0) {
5461
+ const beforeCommits = commits.length;
5462
+ commits = commits.filter((c)=>filterCommits.some((fc)=>fc && c.sha?.startsWith(fc)));
5318
5463
  if (shouldLog(verbose, 1)) {
5319
- console.log(`📥 获取 PR #${prNumber} 信息 (owner: ${owner}, repo: ${repo})`);
5464
+ console.log(` Commits 过滤: ${beforeCommits} -> ${commits.length} 个`);
5320
5465
  }
5321
- prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
5322
- const prInfo = await prModel.getInfo();
5323
- commits = await prModel.getCommits();
5324
- changedFiles = await prModel.getFiles();
5466
+ const beforeFiles = changedFiles.length;
5467
+ const commitFilenames = new Set();
5468
+ for (const commit of commits){
5469
+ if (!commit.sha) continue;
5470
+ const commitFiles = await this.issueFilter.getFilesForCommit(owner, repo, commit.sha, prNumber);
5471
+ commitFiles.forEach((f)=>commitFilenames.add(f));
5472
+ }
5473
+ changedFiles = changedFiles.filterByCommitFiles(commitFilenames);
5325
5474
  if (shouldLog(verbose, 1)) {
5326
- console.log(` PR: ${prInfo?.title}`);
5327
- console.log(` Commits: ${commits.length}`);
5328
- console.log(` Changed files: ${changedFiles.length}`);
5329
- }
5330
- // 检查是否有其他同名 review workflow 正在运行中
5331
- if (duplicateWorkflowResolved !== "off" && ci && prInfo?.head?.sha) {
5332
- const duplicateResult = await this.checkDuplicateWorkflow(prModel, prInfo.head.sha, duplicateWorkflowResolved, verbose);
5333
- if (duplicateResult) {
5334
- return {
5335
- prModel,
5336
- commits,
5337
- changedFiles,
5338
- headSha: prInfo.head.sha,
5339
- isLocalMode,
5340
- isDirectFileMode,
5341
- earlyReturn: duplicateResult
5342
- };
5475
+ console.log(` Commits 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
5476
+ }
5477
+ }
5478
+ // 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
5479
+ if (isDirectFileMode && includes && includes.length > 0) {
5480
+ if (shouldLog(verbose, 1)) {
5481
+ console.log(`ℹ️ 直接文件模式下忽略 includes 过滤`);
5482
+ }
5483
+ } else if (includes && includes.length > 0) {
5484
+ const beforeFiles = changedFiles.length;
5485
+ if (shouldLog(verbose, 2)) {
5486
+ console.log(`[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f)=>({
5487
+ filename: f.filename,
5488
+ status: f.status
5489
+ })))}, includes=${JSON.stringify(includes)}`);
5490
+ }
5491
+ changedFiles = ChangedFileCollection.from(filterFilesByIncludes(changedFiles.toArray(), includes));
5492
+ if (shouldLog(verbose, 1)) {
5493
+ console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
5494
+ }
5495
+ if (shouldLog(verbose, 2)) {
5496
+ console.log(`[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f)=>f.filename))}`);
5497
+ }
5498
+ // 按 includes glob 过滤 commits:仅保留涉及匹配文件的 commits
5499
+ const globs = extractGlobsFromIncludes(includes);
5500
+ const beforeCommits = commits.length;
5501
+ const filteredCommits = [];
5502
+ for (const commit of commits){
5503
+ if (!commit.sha) continue;
5504
+ const commitFiles = await this.issueFilter.getFilesForCommit(owner, repo, commit.sha, prNumber);
5505
+ if (micromatch_0.some(commitFiles, globs)) {
5506
+ filteredCommits.push(commit);
5343
5507
  }
5344
5508
  }
5345
- } else if (effectiveBaseRef && effectiveHeadRef) {
5346
- if (changedFiles.length === 0) {
5347
- if (shouldLog(verbose, 1)) {
5348
- console.log(`📥 获取 ${effectiveBaseRef}...${effectiveHeadRef} 的差异 (owner: ${owner}, repo: ${repo})`);
5509
+ commits = filteredCommits;
5510
+ if (shouldLog(verbose, 1)) {
5511
+ console.log(` Includes 过滤 Commits: ${beforeCommits} -> ${commits.length} 个`);
5512
+ }
5513
+ }
5514
+ return {
5515
+ commits,
5516
+ changedFiles: changedFiles.toArray()
5517
+ };
5518
+ }
5519
+ // ─── 文件内容 ─────────────────────────────────────────
5520
+ /**
5521
+ * 获取文件内容并构建行号到 commit hash 的映射
5522
+ * 返回 Map<filename, Array<[commitHash, lineCode]>>
5523
+ */ async getFileContents(owner, repo, changedFiles, commits, ref, prNumber, isLocalMode, verbose) {
5524
+ const contents = new Map();
5525
+ const latestCommitHash = commits[commits.length - 1]?.sha?.slice(0, 7) || "+local+";
5526
+ if (shouldLog(verbose, 1)) {
5527
+ console.log(`📊 正在构建行号到变更的映射...`);
5528
+ }
5529
+ for (const file of changedFiles){
5530
+ if (file.filename && file.status !== "deleted") {
5531
+ try {
5532
+ let rawContent;
5533
+ if (isLocalMode) {
5534
+ rawContent = this.gitSdk.getWorkingFileContent(file.filename);
5535
+ } else if (prNumber) {
5536
+ rawContent = await this.gitProvider.getFileContent(owner, repo, file.filename, ref);
5537
+ } else {
5538
+ rawContent = await this.gitSdk.getFileContent(ref, file.filename);
5539
+ }
5540
+ const lines = rawContent.split("\n");
5541
+ let changedLines = parseChangedLinesFromPatch(file.patch);
5542
+ const isNewFile = file.status === "added" || file.status === "A" || file.additions && file.additions > 0 && file.deletions === 0 && !file.patch;
5543
+ if (changedLines.size === 0 && isNewFile) {
5544
+ changedLines = new Set(lines.map((_, i)=>i + 1));
5545
+ if (shouldLog(verbose, 2)) {
5546
+ console.log(` ℹ️ ${file.filename}: 新增文件无 patch,将所有 ${lines.length} 行标记为变更`);
5547
+ }
5548
+ }
5549
+ let blameMap;
5550
+ if (!isLocalMode) {
5551
+ try {
5552
+ blameMap = await this.gitSdk.getFileBlame(ref, file.filename);
5553
+ } catch {
5554
+ // blame 失败时回退到 latestCommitHash
5555
+ }
5556
+ }
5557
+ if (shouldLog(verbose, 3)) {
5558
+ console.log(` 📄 ${file.filename}: ${lines.length} 行, ${changedLines.size} 行变更`);
5559
+ console.log(` blame: ${blameMap ? `${blameMap.size} 行` : `不可用,回退到 ${latestCommitHash}`}`);
5560
+ if (changedLines.size > 0 && changedLines.size <= 20) {
5561
+ console.log(` 变更行号: ${Array.from(changedLines).sort((a, b)=>a - b).join(", ")}`);
5562
+ } else if (changedLines.size > 20) {
5563
+ console.log(` 变更行号: (共 ${changedLines.size} 行,省略详情)`);
5564
+ }
5565
+ if (!file.patch) {
5566
+ console.log(` ⚠️ 该文件没有 patch 信息 (status=${file.status}, additions=${file.additions}, deletions=${file.deletions})`);
5567
+ } else {
5568
+ console.log(` patch 前 200 字符: ${file.patch.slice(0, 200).replace(/\n/g, "\\n")}`);
5569
+ }
5570
+ }
5571
+ const contentLines = lines.map((line, index)=>{
5572
+ const lineNum = index + 1;
5573
+ if (!changedLines.has(lineNum)) {
5574
+ return [
5575
+ "-------",
5576
+ line
5577
+ ];
5578
+ }
5579
+ const hash = blameMap?.get(lineNum) ?? latestCommitHash;
5580
+ return [
5581
+ hash,
5582
+ line
5583
+ ];
5584
+ });
5585
+ contents.set(file.filename, contentLines);
5586
+ } catch (error) {
5587
+ console.warn(`警告: 无法获取文件内容: ${file.filename}`, error);
5588
+ }
5589
+ }
5590
+ }
5591
+ if (shouldLog(verbose, 1)) {
5592
+ console.log(`📊 映射构建完成,共 ${contents.size} 个文件`);
5593
+ }
5594
+ return contents;
5595
+ }
5596
+ // ─── 重复 workflow 检查 ──────────────────────────────────
5597
+ /**
5598
+ * 检查是否有其他同名 review workflow 正在运行中。
5599
+ * 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论。
5600
+ */ async checkDuplicateWorkflow(prModel, headSha, mode, verbose) {
5601
+ const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
5602
+ const prMatch = ref.match(/refs\/pull\/(\d+)/);
5603
+ const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prModel.number;
5604
+ try {
5605
+ const runningWorkflows = await prModel.listWorkflowRuns({
5606
+ status: "in_progress"
5607
+ });
5608
+ const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
5609
+ const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
5610
+ const duplicateReviewRuns = runningWorkflows.filter((w)=>w.sha === headSha && w.name === currentWorkflowName && (!currentRunId || String(w.id) !== currentRunId));
5611
+ if (duplicateReviewRuns.length > 0) {
5612
+ if (mode === "delete") {
5613
+ // 删除模式:清理旧的 AI Review 评论和 PR Review
5614
+ if (shouldLog(verbose, 1)) {
5615
+ console.log(`🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`);
5616
+ }
5617
+ await this.cleanupDuplicateAiReviews(prModel, verbose);
5618
+ // 清理后继续执行当前审查
5619
+ return null;
5349
5620
  }
5350
- changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, effectiveBaseRef, effectiveHeadRef);
5351
- commits = await this.getCommitsBetweenRefs(effectiveBaseRef, effectiveHeadRef);
5621
+ // 跳过模式(默认)
5352
5622
  if (shouldLog(verbose, 1)) {
5353
- console.log(` Changed files: ${changedFiles.length}`);
5354
- console.log(` Commits: ${commits.length}`);
5623
+ console.log(`⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`);
5355
5624
  }
5625
+ return {
5626
+ success: true,
5627
+ description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
5628
+ issues: [],
5629
+ summary: [],
5630
+ round: 1
5631
+ };
5356
5632
  }
5357
- } else if (!isLocalMode) {
5633
+ } catch (error) {
5358
5634
  if (shouldLog(verbose, 1)) {
5359
- console.log(`❌ 错误: 缺少 prNumber baseRef/headRef`, {
5360
- prNumber,
5361
- baseRef,
5362
- headRef
5363
- });
5635
+ console.warn(`⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`, error instanceof Error ? error.message : error);
5364
5636
  }
5365
- throw new Error("必须指定 PR 编号或者 base/head 分支");
5366
5637
  }
5367
- // ── 前置过滤 ──────────────────────────────────────────
5368
- // 0. 过滤掉 merge commit
5369
- {
5370
- const before = commits.length;
5371
- commits = commits.filter((c)=>{
5372
- const message = c.commit?.message || "";
5373
- return !message.startsWith("Merge ");
5374
- });
5375
- if (before !== commits.length && shouldLog(verbose, 1)) {
5376
- console.log(` 跳过 Merge Commits: ${before} -> ${commits.length} 个`);
5377
- }
5638
+ return null;
5639
+ }
5640
+ /**
5641
+ * 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
5642
+ */ async cleanupDuplicateAiReviews(prModel, verbose) {
5643
+ try {
5644
+ // 删除 Issue Comments(主评论)
5645
+ const comments = await prModel.getComments();
5646
+ const aiComments = comments.filter((c)=>c.body?.includes(REVIEW_COMMENT_MARKER));
5647
+ let deletedComments = 0;
5648
+ for (const comment of aiComments){
5649
+ if (comment.id) {
5650
+ try {
5651
+ await prModel.deleteComment(comment.id);
5652
+ deletedComments++;
5653
+ } catch {
5654
+ // 忽略删除失败
5655
+ }
5656
+ }
5657
+ }
5658
+ if (deletedComments > 0 && shouldLog(verbose, 1)) {
5659
+ console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
5660
+ }
5661
+ // 删除 PR Reviews(行级评论)
5662
+ const reviews = await prModel.getReviews();
5663
+ const aiReviews = reviews.filter((r)=>r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
5664
+ let deletedReviews = 0;
5665
+ for (const review of aiReviews){
5666
+ if (review.id) {
5667
+ try {
5668
+ await prModel.deleteReview(review.id);
5669
+ deletedReviews++;
5670
+ } catch {
5671
+ // 已提交的 review 无法删除,忽略
5672
+ }
5673
+ }
5674
+ }
5675
+ if (deletedReviews > 0 && shouldLog(verbose, 1)) {
5676
+ console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
5677
+ }
5678
+ } catch (error) {
5679
+ if (shouldLog(verbose, 1)) {
5680
+ console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
5681
+ }
5682
+ }
5683
+ }
5684
+ }
5685
+
5686
+ ;// CONCATENATED MODULE: ./src/review.service.ts
5687
+
5688
+
5689
+
5690
+
5691
+
5692
+
5693
+
5694
+
5695
+
5696
+
5697
+
5698
+ class ReviewService {
5699
+ gitProvider;
5700
+ config;
5701
+ reviewSpecService;
5702
+ llmProxyService;
5703
+ reviewReportService;
5704
+ issueVerifyService;
5705
+ deletionImpactService;
5706
+ gitSdk;
5707
+ contextBuilder;
5708
+ issueFilter;
5709
+ llmProcessor;
5710
+ resultModelDeps;
5711
+ sourceResolver;
5712
+ constructor(gitProvider, config, reviewSpecService, llmProxyService, reviewReportService, issueVerifyService, deletionImpactService, gitSdk){
5713
+ this.gitProvider = gitProvider;
5714
+ this.config = config;
5715
+ this.reviewSpecService = reviewSpecService;
5716
+ this.llmProxyService = llmProxyService;
5717
+ this.reviewReportService = reviewReportService;
5718
+ this.issueVerifyService = issueVerifyService;
5719
+ this.deletionImpactService = deletionImpactService;
5720
+ this.gitSdk = gitSdk;
5721
+ this.contextBuilder = new ReviewContextBuilder(gitProvider, config, gitSdk);
5722
+ this.issueFilter = new ReviewIssueFilter(gitProvider, config, reviewSpecService, issueVerifyService, gitSdk);
5723
+ this.llmProcessor = new ReviewLlmProcessor(llmProxyService, reviewSpecService);
5724
+ this.sourceResolver = new ReviewSourceResolver(gitProvider, gitSdk, this.issueFilter);
5725
+ this.resultModelDeps = {
5726
+ gitProvider,
5727
+ config,
5728
+ reviewSpecService,
5729
+ reviewReportService
5730
+ };
5731
+ }
5732
+ async getContextFromEnv(options) {
5733
+ return this.contextBuilder.getContextFromEnv(options);
5734
+ }
5735
+ /**
5736
+ * 执行代码审查的主方法
5737
+ * 该方法负责协调整个审查流程,包括:
5738
+ * 1. 加载审查规范(specs)
5739
+ * 2. 获取 PR/分支的变更文件和提交记录
5740
+ * 3. 调用 LLM 进行代码审查
5741
+ * 4. 处理历史 issue(更新行号、验证修复状态)
5742
+ * 5. 生成并发布审查报告
5743
+ *
5744
+ * @param context 审查上下文,包含 owner、repo、prNumber 等信息
5745
+ * @returns 审查结果,包含发现的问题列表和统计信息
5746
+ */ async execute(context) {
5747
+ const { specSources, verbose, llmMode, deletionOnly } = context;
5748
+ if (shouldLog(verbose, 1)) {
5749
+ console.log(`🔍 Review 启动`);
5750
+ console.log(` DRY-RUN mode: ${context.dryRun ? "enabled" : "disabled"}`);
5751
+ console.log(` CI mode: ${context.ci ? "enabled" : "disabled"}`);
5752
+ if (context.localMode) console.log(` Local mode: ${context.localMode}`);
5753
+ console.log(` Verbose: ${verbose}`);
5754
+ }
5755
+ // 早期分流
5756
+ if (deletionOnly) return this.executeDeletionOnly(context);
5757
+ if (context.eventAction === "closed" || context.flush) return this.executeCollectOnly(context);
5758
+ // 1. 解析输入数据(本地/PR/分支模式 + 前置过滤)
5759
+ const source = await this.resolveSourceData(context);
5760
+ if (source.earlyReturn) return source.earlyReturn;
5761
+ const effectiveWhenModifiedCode = source.isDirectFileMode ? undefined : context.whenModifiedCode;
5762
+ if (source.isDirectFileMode && context.whenModifiedCode?.length && shouldLog(verbose, 1)) {
5763
+ console.log(`ℹ️ 直接文件模式下忽略 whenModifiedCode 过滤`);
5378
5764
  }
5379
- // 1. 按指定的 files 过滤
5380
- if (files && files.length > 0) {
5381
- const before = changedFiles.length;
5382
- changedFiles = changedFiles.filter((f)=>files.includes(f.filename || ""));
5383
- if (shouldLog(verbose, 1)) {
5384
- console.log(` Files 过滤文件: ${before} -> ${changedFiles.length} 个文件`);
5385
- }
5765
+ // 2. 规则匹配
5766
+ const allSpecs = await this.issueFilter.loadSpecs(specSources, verbose);
5767
+ const specs = this.reviewSpecService.filterApplicableSpecs(allSpecs, source.changedFiles);
5768
+ if (shouldLog(verbose, 2)) {
5769
+ console.log(`[execute] loadSpecs: loaded ${specs.length} specs from sources: ${JSON.stringify(specSources)}`);
5770
+ console.log(`[execute] filterApplicableSpecs: ${specs.length} applicable out of ${allSpecs.length}, changedFiles=${JSON.stringify(source.changedFiles.filenames())}`);
5386
5771
  }
5387
- // 2. 按指定的 commits 过滤
5388
- if (filterCommits && filterCommits.length > 0) {
5389
- const beforeCommits = commits.length;
5390
- commits = commits.filter((c)=>filterCommits.some((fc)=>fc && c.sha?.startsWith(fc)));
5391
- if (shouldLog(verbose, 1)) {
5392
- console.log(` Commits 过滤: ${beforeCommits} -> ${commits.length} 个`);
5393
- }
5394
- const beforeFiles = changedFiles.length;
5395
- const commitFilenames = new Set();
5396
- for (const commit of commits){
5397
- if (!commit.sha) continue;
5398
- const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
5399
- commitFiles.forEach((f)=>commitFilenames.add(f));
5400
- }
5401
- changedFiles = changedFiles.filter((f)=>commitFilenames.has(f.filename || ""));
5402
- if (shouldLog(verbose, 1)) {
5403
- console.log(` 按 Commits 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
5404
- }
5772
+ if (shouldLog(verbose, 1)) {
5773
+ console.log(` 适用的规则文件: ${specs.length}`);
5405
5774
  }
5406
- // 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
5407
- if (isDirectFileMode && includes && includes.length > 0) {
5408
- if (shouldLog(verbose, 1)) {
5409
- console.log(`ℹ️ 直接文件模式下忽略 includes 过滤`);
5410
- }
5411
- } else if (includes && includes.length > 0) {
5412
- const beforeFiles = changedFiles.length;
5413
- if (shouldLog(verbose, 2)) {
5414
- console.log(`[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f)=>({
5415
- filename: f.filename,
5416
- status: f.status
5417
- })))}, includes=${JSON.stringify(includes)}`);
5418
- }
5419
- changedFiles = filterFilesByIncludes(changedFiles, includes);
5420
- if (shouldLog(verbose, 1)) {
5421
- console.log(` Includes 过滤文件: ${beforeFiles} -> ${changedFiles.length} 个文件`);
5422
- }
5423
- if (shouldLog(verbose, 2)) {
5424
- console.log(`[resolveSourceData] filterFilesByIncludes: after=${JSON.stringify(changedFiles.map((f)=>f.filename))}`);
5425
- }
5426
- const globs = extractGlobsFromIncludes(includes);
5427
- const beforeCommits = commits.length;
5428
- const filteredCommits = [];
5429
- for (const commit of commits){
5430
- if (!commit.sha) continue;
5431
- const commitFiles = await this.getFilesForCommit(owner, repo, commit.sha, prNumber);
5432
- if (micromatch_0.some(commitFiles, globs)) {
5433
- filteredCommits.push(commit);
5434
- }
5435
- }
5436
- commits = filteredCommits;
5437
- if (shouldLog(verbose, 1)) {
5438
- console.log(` Includes 过滤 Commits: ${beforeCommits} -> ${commits.length} 个`);
5775
+ if (specs.length === 0 || source.changedFiles.length === 0) {
5776
+ return this.handleNoApplicableSpecs(context, specs, source.changedFiles, source.commits);
5777
+ }
5778
+ // 3. LLM 审查
5779
+ const { fileContents } = source;
5780
+ if (!llmMode) throw new Error("必须指定 LLM 类型");
5781
+ // 获取上一次的审查结果(用于提示词优化和轮次推进)
5782
+ let existingResultModel = null;
5783
+ if (context.ci && source.prModel) {
5784
+ existingResultModel = await ReviewResultModel.loadFromPr(source.prModel, this.resultModelDeps);
5785
+ if (existingResultModel && shouldLog(verbose, 1)) {
5786
+ console.log(`📋 获取到上一次审查结果,包含 ${existingResultModel.issues.length} 个问题`);
5439
5787
  }
5440
5788
  }
5441
- const headSha = prModel ? await prModel.getHeadSha() : headRef || "HEAD";
5442
- return {
5443
- prModel,
5789
+ if (shouldLog(verbose, 1)) {
5790
+ console.log(`🔄 当前审查轮次: ${(existingResultModel?.round ?? 0) + 1}`);
5791
+ }
5792
+ const reviewPrompt = await this.llmProcessor.buildReviewPrompt(specs, source.changedFiles, fileContents, source.commits, existingResultModel?.result ?? null, effectiveWhenModifiedCode, verbose, context.systemRules);
5793
+ // 4. 运行 LLM 审查 + 过滤新 issues
5794
+ const result = await this.buildReviewResult(context, reviewPrompt, llmMode, {
5795
+ specs,
5796
+ fileContents,
5797
+ changedFiles: source.changedFiles,
5798
+ commits: source.commits,
5799
+ isDirectFileMode: source.isDirectFileMode
5800
+ });
5801
+ // 5. 构建最终的 ReviewResultModel
5802
+ const finalModel = await this.buildFinalModel(context, result, {
5803
+ prModel: source.prModel,
5804
+ commits: source.commits,
5805
+ headSha: source.headSha,
5806
+ specs,
5807
+ fileContents
5808
+ }, existingResultModel);
5809
+ // 6. 保存 + 输出
5810
+ await this.saveAndOutput(context, finalModel, source.commits);
5811
+ return finalModel.result;
5812
+ }
5813
+ /**
5814
+ * 运行 LLM 审查并构建过滤后的 ReviewResult:
5815
+ * - 调用 LLM 生成问题列表
5816
+ * - 填充 PR 标题/描述
5817
+ * - 过滤新 issues(去重、commit 范围等)
5818
+ * - 合并静态规则问题
5819
+ */ async buildReviewResult(context, reviewPrompt, llmMode, source) {
5820
+ const { verbose } = context;
5821
+ const { specs, fileContents, changedFiles, commits, isDirectFileMode } = source;
5822
+ const result = await this.llmProcessor.runLLMReview(llmMode, reviewPrompt, {
5823
+ verbose,
5824
+ concurrency: context.concurrency,
5825
+ timeout: context.timeout,
5826
+ retries: context.retries,
5827
+ retryDelay: context.retryDelay
5828
+ });
5829
+ // 填充 PR 功能描述和标题
5830
+ const prInfo = context.generateDescription ? await this.llmProcessor.generatePrDescription(commits, changedFiles, llmMode, fileContents, verbose) : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
5831
+ result.title = prInfo.title;
5832
+ result.description = prInfo.description;
5833
+ if (shouldLog(verbose, 1)) {
5834
+ console.log(`📝 LLM 审查完成,发现 ${result.issues.length} 个问题`);
5835
+ }
5836
+ result.issues = await this.issueFilter.fillIssueCode(result.issues, fileContents);
5837
+ result.issues = this.filterNewIssues(result.issues, specs, {
5444
5838
  commits,
5839
+ fileContents,
5445
5840
  changedFiles,
5446
- headSha,
5447
- isLocalMode,
5448
- isDirectFileMode
5449
- };
5841
+ isDirectFileMode,
5842
+ context
5843
+ });
5844
+ // 静态规则产生的系统问题直接合并,不经过过滤管道
5845
+ if (reviewPrompt.staticIssues?.length) {
5846
+ result.issues = [
5847
+ ...reviewPrompt.staticIssues,
5848
+ ...result.issues
5849
+ ];
5850
+ if (shouldLog(verbose, 1)) {
5851
+ console.log(`⚙️ 追加 ${reviewPrompt.staticIssues.length} 个静态规则系统问题`);
5852
+ }
5853
+ }
5854
+ if (shouldLog(verbose, 1)) {
5855
+ console.log(`📝 最终发现 ${result.issues.length} 个问题`);
5856
+ }
5857
+ return result;
5858
+ }
5859
+ /**
5860
+ * 解析输入数据:委托给 ReviewSourceResolver。
5861
+ * @see ReviewSourceResolver#resolve
5862
+ */ async resolveSourceData(context) {
5863
+ return this.sourceResolver.resolve(context);
5450
5864
  }
5451
5865
  /**
5452
5866
  * LLM 审查后的 issue 过滤管道:
5453
5867
  * includes → 规则存在性 → overrides → 变更行过滤 → 格式化
5454
- */ filterNewIssues(issues, specs, applicableSpecs, opts) {
5868
+ */ filterNewIssues(issues, specs, opts) {
5455
5869
  const { commits, fileContents, changedFiles, isDirectFileMode, context } = opts;
5456
5870
  const { verbose } = context;
5457
- let filtered = this.reviewSpecService.filterIssuesByIncludes(issues, applicableSpecs);
5871
+ let filtered = this.reviewSpecService.filterIssuesByIncludes(issues, specs);
5458
5872
  if (shouldLog(verbose, 1)) {
5459
5873
  console.log(` 应用 includes 过滤后: ${filtered.length} 个问题`);
5460
5874
  }
@@ -5462,26 +5876,26 @@ class ReviewService {
5462
5876
  if (shouldLog(verbose, 1)) {
5463
5877
  console.log(` 应用规则存在性过滤后: ${filtered.length} 个问题`);
5464
5878
  }
5465
- filtered = this.reviewSpecService.filterIssuesByOverrides(filtered, applicableSpecs, verbose);
5879
+ filtered = this.reviewSpecService.filterIssuesByOverrides(filtered, specs, verbose);
5466
5880
  // 变更行过滤
5467
5881
  if (shouldLog(verbose, 3)) {
5468
5882
  console.log(` 🔍 变更行过滤条件检查:`);
5469
5883
  console.log(` showAll=${context.showAll}, isDirectFileMode=${isDirectFileMode}, commits.length=${commits.length}`);
5470
5884
  }
5471
- if (!context.showAll && !isDirectFileMode && commits.length > 0) {
5885
+ if (!context.showAll && !isDirectFileMode) {
5472
5886
  if (shouldLog(verbose, 2)) {
5473
5887
  console.log(` 🔍 开始变更行过滤,当前 ${filtered.length} 个问题`);
5474
5888
  }
5475
- filtered = this.filterIssuesByValidCommits(filtered, commits, fileContents, verbose);
5889
+ filtered = this.issueFilter.filterIssuesByValidCommits(filtered, commits, fileContents, verbose);
5476
5890
  if (shouldLog(verbose, 2)) {
5477
5891
  console.log(` 🔍 变更行过滤完成,剩余 ${filtered.length} 个问题`);
5478
5892
  }
5479
5893
  } else if (shouldLog(verbose, 1)) {
5480
- console.log(` 跳过变更行过滤 (${context.showAll ? "showAll=true" : isDirectFileMode ? "直接审查文件模式" : "commits.length=0"})`);
5894
+ console.log(` 跳过变更行过滤 (${context.showAll ? "showAll=true" : "直接审查文件模式"})`);
5481
5895
  }
5482
5896
  filtered = this.reviewSpecService.formatIssues(filtered, {
5483
5897
  specs,
5484
- changedFiles
5898
+ changedFiles: changedFiles.toArray()
5485
5899
  });
5486
5900
  if (shouldLog(verbose, 1)) {
5487
5901
  console.log(` 应用格式化后: ${filtered.length} 个问题`);
@@ -5493,6 +5907,7 @@ class ReviewService {
5493
5907
  */ async buildFinalModel(context, result, source, existingResultModel) {
5494
5908
  const { prModel, commits, headSha, specs, fileContents } = source;
5495
5909
  const { verbose, ci } = context;
5910
+ result.headSha = headSha;
5496
5911
  if (ci && prModel && existingResultModel && existingResultModel.issues.length > 0) {
5497
5912
  if (shouldLog(verbose, 1)) {
5498
5913
  console.log(`📋 已有评论中存在 ${existingResultModel.issues.length} 个问题`);
@@ -5502,32 +5917,24 @@ class ReviewService {
5502
5917
  // 如果文件有变更,将该文件的历史问题标记为无效
5503
5918
  const reviewConf = this.config.getPluginConfig("review");
5504
5919
  if (reviewConf.invalidateChangedFiles !== "off" && reviewConf.invalidateChangedFiles !== "keep") {
5505
- await existingResultModel.invalidateChangedFiles(headSha, verbose);
5920
+ await existingResultModel.invalidateChangedFiles(headSha, fileContents, verbose);
5506
5921
  }
5507
5922
  // 验证历史问题是否已修复
5508
5923
  if (context.verifyFixes) {
5509
5924
  existingResultModel.issues = await this.issueFilter.verifyAndUpdateIssues(context, existingResultModel.issues, commits, {
5510
5925
  specs,
5511
5926
  fileContents
5512
- }, prModel);
5927
+ });
5513
5928
  } else {
5514
5929
  if (shouldLog(verbose, 1)) {
5515
5930
  console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
5516
5931
  }
5517
5932
  }
5518
- // 去重:与所有历史 issues 去重
5519
- const { filteredIssues: newIssues, skippedCount } = this.filterDuplicateIssues(result.issues, existingResultModel.issues);
5520
- if (skippedCount > 0 && shouldLog(verbose, 1)) {
5521
- console.log(` 跳过 ${skippedCount} 个重复问题,新增 ${newIssues.length} 个问题`);
5522
- }
5523
- result.issues = newIssues;
5524
- result.headSha = headSha;
5525
- // 自动 round 递增 + issues 合并
5933
+ // 自动 round 递增 + 去重 + issues 合并
5526
5934
  return existingResultModel.nextRound(result);
5527
5935
  }
5528
5936
  // 首次审查或无历史结果
5529
5937
  result.round = 1;
5530
- result.headSha = headSha;
5531
5938
  result.issues = result.issues.map((issue)=>({
5532
5939
  ...issue,
5533
5940
  round: 1
@@ -5541,7 +5948,7 @@ class ReviewService {
5541
5948
  const prModel = finalModel.pr.number > 0 ? finalModel.pr : undefined;
5542
5949
  // 填充 author 信息
5543
5950
  if (commits.length > 0) {
5544
- finalModel.issues = await this.fillIssueAuthors(finalModel.issues, commits, owner, repo, verbose);
5951
+ finalModel.issues = await this.contextBuilder.fillIssueAuthors(finalModel.issues, commits, owner, repo, verbose);
5545
5952
  }
5546
5953
  // 删除代码影响分析(在 save 之前完成,避免多次 save 产生重复的 Round 评论)
5547
5954
  if (context.analyzeDeletions && llmMode) {
@@ -5608,16 +6015,27 @@ class ReviewService {
5608
6015
  }
5609
6016
  // 2. 获取 commits 并填充 author 信息
5610
6017
  const commits = await prModel.getCommits();
5611
- resultModel.issues = await this.fillIssueAuthors(resultModel.issues, commits, owner, repo, verbose);
6018
+ resultModel.issues = await this.contextBuilder.fillIssueAuthors(resultModel.issues, commits, owner, repo, verbose);
5612
6019
  // 3. 同步已解决的评论状态
5613
6020
  await resultModel.syncResolved();
5614
6021
  // 4. 同步评论 reactions(👍/👎/☹️)
5615
6022
  await resultModel.syncReactions(verbose);
5616
6023
  // 5. LLM 验证历史问题是否已修复
5617
- try {
5618
- resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(context, resultModel.issues, commits, undefined, prModel);
5619
- } catch (error) {
5620
- console.warn("⚠️ LLM 验证修复状态失败,跳过:", error);
6024
+ if (context.verifyFixes && context.specSources?.length) {
6025
+ try {
6026
+ const changedFiles = await prModel.getFiles();
6027
+ const headSha = await prModel.getHeadSha();
6028
+ const verifySpecs = await this.issueFilter.loadSpecs(context.specSources, verbose);
6029
+ const verifyFileContents = await this.sourceResolver.getFileContents(owner, repo, changedFiles, commits, headSha, prNumber, false, verbose);
6030
+ resultModel.issues = await this.issueFilter.verifyAndUpdateIssues(context, resultModel.issues, commits, {
6031
+ specs: verifySpecs,
6032
+ fileContents: verifyFileContents
6033
+ });
6034
+ } catch (error) {
6035
+ console.warn("⚠️ LLM 验证修复状态失败,跳过:", error);
6036
+ }
6037
+ } else if (!context.verifyFixes && shouldLog(verbose, 1)) {
6038
+ console.log(` ⏭️ 跳过历史问题验证 (verifyFixes=false)`);
5621
6039
  }
5622
6040
  // 6. 统计问题状态并设置到 result
5623
6041
  const stats = resultModel.updateStats();
@@ -5660,20 +6078,20 @@ class ReviewService {
5660
6078
  // 获取 commits 和 changedFiles 用于生成描述
5661
6079
  let prModel;
5662
6080
  let commits = [];
5663
- let changedFiles = [];
6081
+ let changedFiles = ChangedFileCollection.empty();
5664
6082
  if (prNumber) {
5665
6083
  prModel = new PullRequestModel(this.gitProvider, owner, repo, prNumber);
5666
6084
  commits = await prModel.getCommits();
5667
- changedFiles = await prModel.getFiles();
6085
+ changedFiles = ChangedFileCollection.from(await prModel.getFiles());
5668
6086
  } else if (baseRef && headRef) {
5669
- changedFiles = await this.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef);
5670
- commits = await this.getCommitsBetweenRefs(baseRef, headRef);
6087
+ changedFiles = ChangedFileCollection.from(await this.issueFilter.getChangedFilesBetweenRefs(owner, repo, baseRef, headRef));
6088
+ commits = await this.issueFilter.getCommitsBetweenRefs(baseRef, headRef);
5671
6089
  }
5672
6090
  // 使用 includes 过滤文件(支持 added|/modified|/deleted| 前缀语法)
5673
6091
  if (context.includes && context.includes.length > 0) {
5674
- changedFiles = filterFilesByIncludes(changedFiles, context.includes);
6092
+ changedFiles = ChangedFileCollection.from(filterFilesByIncludes(changedFiles.toArray(), context.includes));
5675
6093
  }
5676
- const prDesc = context.generateDescription ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose) : await this.buildBasicDescription(commits, changedFiles);
6094
+ const prDesc = context.generateDescription ? await this.llmProcessor.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose) : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
5677
6095
  const result = {
5678
6096
  success: true,
5679
6097
  title: prDesc.title,
@@ -5721,13 +6139,13 @@ class ReviewService {
5721
6139
  }
5722
6140
  const currentRound = (existingResultModel?.round ?? 0) + 1;
5723
6141
  // 即使没有适用的规则,也为每个变更文件生成摘要
5724
- const summary = changedFiles.filter((f)=>f.filename && f.status !== "deleted").map((f)=>({
6142
+ const summary = changedFiles.nonDeletedFiles().map((f)=>({
5725
6143
  file: f.filename,
5726
6144
  resolved: 0,
5727
6145
  unresolved: 0,
5728
6146
  summary: applicableSpecs.length === 0 ? "无适用的审查规则" : "已跳过"
5729
6147
  }));
5730
- const prDesc = context.generateDescription && llmMode ? await this.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose) : await this.buildBasicDescription(commits, changedFiles);
6148
+ const prDesc = context.generateDescription && llmMode ? await this.llmProcessor.generatePrDescription(commits, changedFiles, llmMode, undefined, verbose) : await this.llmProcessor.buildBasicDescription(commits, changedFiles);
5731
6149
  const result = {
5732
6150
  success: true,
5733
6151
  title: prDesc.title,
@@ -5753,139 +6171,6 @@ class ReviewService {
5753
6171
  return result;
5754
6172
  }
5755
6173
  /**
5756
- * 检查是否有其他同名 review workflow 正在运行中
5757
- * 根据 duplicateWorkflowResolved 配置决定是跳过还是删除旧评论
5758
- */ async checkDuplicateWorkflow(prModel, headSha, mode, verbose) {
5759
- const ref = process.env.GITHUB_REF || process.env.GITEA_REF || "";
5760
- const prMatch = ref.match(/refs\/pull\/(\d+)/);
5761
- const currentPrNumber = prMatch ? parseInt(prMatch[1], 10) : prModel.number;
5762
- try {
5763
- const runningWorkflows = await prModel.listWorkflowRuns({
5764
- status: "in_progress"
5765
- });
5766
- const currentWorkflowName = process.env.GITHUB_WORKFLOW || process.env.GITEA_WORKFLOW;
5767
- const currentRunId = process.env.GITHUB_RUN_ID || process.env.GITEA_RUN_ID;
5768
- const duplicateReviewRuns = runningWorkflows.filter((w)=>w.sha === headSha && w.name === currentWorkflowName && (!currentRunId || String(w.id) !== currentRunId));
5769
- if (duplicateReviewRuns.length > 0) {
5770
- if (mode === "delete") {
5771
- // 删除模式:清理旧的 AI Review 评论和 PR Review
5772
- if (shouldLog(verbose, 1)) {
5773
- console.log(`🗑️ 检测到 ${duplicateReviewRuns.length} 个同名 workflow,清理旧的 AI Review 评论...`);
5774
- }
5775
- await this.cleanupDuplicateAiReviews(prModel, verbose);
5776
- // 清理后继续执行当前审查
5777
- return null;
5778
- }
5779
- // 跳过模式(默认)
5780
- if (shouldLog(verbose, 1)) {
5781
- console.log(`⏭️ 跳过审查: 当前 PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中`);
5782
- }
5783
- return {
5784
- success: true,
5785
- description: `跳过审查: PR #${currentPrNumber} 有 ${duplicateReviewRuns.length} 个同名 workflow 正在运行中,等待完成后重新审查`,
5786
- issues: [],
5787
- summary: [],
5788
- round: 1
5789
- };
5790
- }
5791
- } catch (error) {
5792
- if (shouldLog(verbose, 1)) {
5793
- console.warn(`⚠️ 无法检查重复 workflow(可能缺少 repo owner 权限),跳过此检查:`, error instanceof Error ? error.message : error);
5794
- }
5795
- }
5796
- return null;
5797
- }
5798
- /**
5799
- * 清理重复的 AI Review 评论(Issue Comments 和 PR Reviews)
5800
- */ async cleanupDuplicateAiReviews(prModel, verbose) {
5801
- try {
5802
- // 删除 Issue Comments(主评论)
5803
- const comments = await prModel.getComments();
5804
- const aiComments = comments.filter((c)=>c.body?.includes(REVIEW_COMMENT_MARKER));
5805
- let deletedComments = 0;
5806
- for (const comment of aiComments){
5807
- if (comment.id) {
5808
- try {
5809
- await prModel.deleteComment(comment.id);
5810
- deletedComments++;
5811
- } catch {
5812
- // 忽略删除失败
5813
- }
5814
- }
5815
- }
5816
- if (deletedComments > 0 && shouldLog(verbose, 1)) {
5817
- console.log(` 已删除 ${deletedComments} 个重复的 AI Review 主评论`);
5818
- }
5819
- // 删除 PR Reviews(行级评论)
5820
- const reviews = await prModel.getReviews();
5821
- const aiReviews = reviews.filter((r)=>r.body?.includes(REVIEW_LINE_COMMENTS_MARKER));
5822
- let deletedReviews = 0;
5823
- for (const review of aiReviews){
5824
- if (review.id) {
5825
- try {
5826
- await prModel.deleteReview(review.id);
5827
- deletedReviews++;
5828
- } catch {
5829
- // 已提交的 review 无法删除,忽略
5830
- }
5831
- }
5832
- }
5833
- if (deletedReviews > 0 && shouldLog(verbose, 1)) {
5834
- console.log(` 已删除 ${deletedReviews} 个重复的 AI Review PR Review`);
5835
- }
5836
- } catch (error) {
5837
- if (shouldLog(verbose, 1)) {
5838
- console.warn(`⚠️ 清理旧评论失败:`, error instanceof Error ? error.message : error);
5839
- }
5840
- }
5841
- }
5842
- // --- Delegation methods for backward compatibility with tests ---
5843
- async fillIssueAuthors(...args) {
5844
- return this.contextBuilder.fillIssueAuthors(...args);
5845
- }
5846
- async getFileContents(...args) {
5847
- return this.issueFilter.getFileContents(...args);
5848
- }
5849
- async getFilesForCommit(...args) {
5850
- return this.issueFilter.getFilesForCommit(...args);
5851
- }
5852
- async getChangedFilesBetweenRefs(...args) {
5853
- return this.issueFilter.getChangedFilesBetweenRefs(...args);
5854
- }
5855
- async getCommitsBetweenRefs(...args) {
5856
- return this.issueFilter.getCommitsBetweenRefs(...args);
5857
- }
5858
- filterIssuesByValidCommits(...args) {
5859
- return this.issueFilter.filterIssuesByValidCommits(...args);
5860
- }
5861
- filterDuplicateIssues(...args) {
5862
- return this.issueFilter.filterDuplicateIssues(...args);
5863
- }
5864
- async fillIssueCode(...args) {
5865
- return this.issueFilter.fillIssueCode(...args);
5866
- }
5867
- async runLLMReview(...args) {
5868
- return this.llmProcessor.runLLMReview(...args);
5869
- }
5870
- async buildReviewPrompt(...args) {
5871
- return this.llmProcessor.buildReviewPrompt(...args);
5872
- }
5873
- async generatePrDescription(...args) {
5874
- return this.llmProcessor.generatePrDescription(...args);
5875
- }
5876
- async buildBasicDescription(...args) {
5877
- return this.llmProcessor.buildBasicDescription(...args);
5878
- }
5879
- normalizeFilePaths(...args) {
5880
- return this.contextBuilder.normalizeFilePaths(...args);
5881
- }
5882
- resolveAnalyzeDeletions(...args) {
5883
- return this.contextBuilder.resolveAnalyzeDeletions(...args);
5884
- }
5885
- async getPrNumberFromEvent(...args) {
5886
- return this.contextBuilder.getPrNumberFromEvent(...args);
5887
- }
5888
- /**
5889
6174
  * 确保 Claude CLI 已安装
5890
6175
  */ async ensureClaudeCli(ci) {
5891
6176
  try {
@@ -6766,6 +7051,7 @@ class DeletionImpactService {
6766
7051
 
6767
7052
 
6768
7053
 
7054
+
6769
7055
  /** MCP 工具输入 schema */ const listRulesInputSchema = z.object({});
6770
7056
  const getRulesForFileInputSchema = z.object({
6771
7057
  filePath: z.string().describe(t("review:mcp.dto.filePath")),
@@ -6862,11 +7148,11 @@ const tools = [
6862
7148
  const workDir = ctx.cwd;
6863
7149
  const allSpecs = await loadAllSpecs(workDir, ctx);
6864
7150
  const specService = new ReviewSpecService();
6865
- const applicableSpecs = specService.filterApplicableSpecs(allSpecs, [
7151
+ const applicableSpecs = specService.filterApplicableSpecs(allSpecs, ChangedFileCollection.from([
6866
7152
  {
6867
7153
  filename: filePath
6868
7154
  }
6869
- ]);
7155
+ ]));
6870
7156
  const micromatchModule = await __webpack_require__.e(/* import() */ "551").then(__webpack_require__.bind(__webpack_require__, 946));
6871
7157
  const micromatch = micromatchModule.default || micromatchModule;
6872
7158
  const rules = applicableSpecs.flatMap((spec)=>spec.rules.filter((rule)=>{