@spaceflow/review 0.79.0 → 0.81.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.80.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.79.0...@spaceflow/review@0.80.0) (2026-04-08)
4
+
5
+ ### 新特性
6
+
7
+ * **review:** 在 review.md 中补充 `-f` 模式忽略 `includes` 过滤的说明,新增测试用例验证直接文件模式下的过滤行为 ([9c15284](https://github.com/Lydanne/spaceflow/commit/9c1528486f93b89442ba6eea4d1cb4503be5f6c7))
8
+
9
+ ### 其他修改
10
+
11
+ * **review-summary:** released version 0.48.0 [no ci] ([71629d2](https://github.com/Lydanne/spaceflow/commit/71629d27024b4c27c5bbe8564a2b87d45548882d))
12
+
13
+ ## [0.79.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.78.0...@spaceflow/review@0.79.0) (2026-04-08)
14
+
15
+ ### 新特性
16
+
17
+ * **review:** 完善 review 命令文档,新增直接文件审查模式说明和配置优先级补充 ([25916c8](https://github.com/Lydanne/spaceflow/commit/25916c8bb7b11b748b6fc5e5ea9a62c954d5c7fc))
18
+
19
+ ### 其他修改
20
+
21
+ * **review-summary:** released version 0.47.0 [no ci] ([7cd6664](https://github.com/Lydanne/spaceflow/commit/7cd66645f528355b3849d49ac3fb563267bd16e9))
22
+
3
23
  ## [0.78.0](https://github.com/Lydanne/spaceflow/compare/@spaceflow/review@0.77.0...@spaceflow/review@0.78.0) (2026-04-07)
4
24
 
5
25
  ### 新特性
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
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";
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"
@@ -414,54 +421,219 @@ class ReviewSpecService {
414
421
  }
415
422
  return specs;
416
423
  }
417
- async resolveSpecSources(sources) {
424
+ async resolveSpecSources(sources, verbose) {
418
425
  const dirs = [];
419
426
  for (const source of sources){
420
- // 优先尝试解析为远程仓库 URL(浏览器复制的链接)
427
+ this.logVerbose(verbose, 3, ` 🔎 规则来源: ${source}`);
421
428
  const repoRef = parseRepoUrl(source);
429
+ if (repoRef) {
430
+ this.logVerbose(verbose, 3, ` 解析远程仓库: ${repoRef.serverUrl}/${repoRef.owner}/${repoRef.repo} path=${repoRef.path || "(root)"} ref=${repoRef.ref || "(default)"}`);
431
+ } else {
432
+ this.logVerbose(verbose, 3, ` 非仓库 URL,按本地目录处理`);
433
+ }
422
434
  if (repoRef && this.gitProvider) {
423
- const dir = await this.fetchRemoteSpecs(repoRef);
435
+ this.logVerbose(verbose, 3, ` 尝试方式 #1: Git Provider API`);
436
+ const dir = await this.fetchRemoteSpecs(repoRef, verbose);
424
437
  if (dir) {
425
438
  dirs.push(dir);
439
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: Git Provider API -> ${dir}`);
440
+ continue;
441
+ }
442
+ this.logVerbose(verbose, 3, ` ❌ Git Provider API 未获取到规则,继续尝试`);
443
+ }
444
+ if (repoRef) {
445
+ this.logVerbose(verbose, 3, ` 尝试方式 #2: tea api`);
446
+ const teaDir = await this.fetchRemoteSpecsViaTea(repoRef, verbose);
447
+ if (teaDir) {
448
+ dirs.push(teaDir);
449
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: tea api -> ${teaDir}`);
450
+ continue;
451
+ }
452
+ this.logVerbose(verbose, 3, ` ❌ tea api 未获取到规则,继续尝试`);
453
+ }
454
+ // API 拉取失败或未配置 provider 时,回退到 git clone(使用仓库根 URL,而非目录 URL)
455
+ if (repoRef) {
456
+ this.logVerbose(verbose, 3, ` 尝试方式 #3: git clone 回退`);
457
+ const fallbackCloneUrl = this.buildRepoCloneUrl(repoRef);
458
+ this.logVerbose(verbose, 3, ` clone URL: ${fallbackCloneUrl}`);
459
+ const fallbackDir = await this.cloneSpecRepo(fallbackCloneUrl, repoRef.path, verbose);
460
+ if (fallbackDir) {
461
+ dirs.push(fallbackDir);
462
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: git clone 回退 -> ${fallbackDir}`);
426
463
  continue;
427
464
  }
465
+ this.logVerbose(verbose, 3, ` ❌ git clone 回退失败`);
428
466
  }
429
467
  if (this.isRepoUrl(source)) {
430
- const dir = await this.cloneSpecRepo(source);
468
+ this.logVerbose(verbose, 3, ` 尝试方式 #4: 直接 clone 来源 URL`);
469
+ const dir = await this.cloneSpecRepo(source, undefined, verbose);
431
470
  if (dir) {
432
471
  dirs.push(dir);
472
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: 直接 clone 来源 URL -> ${dir}`);
473
+ } else {
474
+ this.logVerbose(verbose, 3, ` ❌ 直接 clone 来源 URL 失败`);
433
475
  }
434
476
  } else {
435
477
  // 检查是否是 deps 目录,如果是则扫描子目录的 references
436
478
  const resolvedDirs = await this.resolveDepsDir(source);
437
479
  dirs.push(...resolvedDirs);
480
+ this.logVerbose(verbose, 3, ` deps 目录解析结果: ${resolvedDirs.length > 0 ? resolvedDirs.join(", ") : "(空)"}`);
438
481
  }
439
482
  }
440
483
  return dirs;
441
484
  }
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
- }
485
+ buildRemoteSpecDir(ref) {
486
+ const dirKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
487
+ return join(homedir(), ".spaceflow", "review-spec", dirKey);
488
+ }
489
+ async getLocalSpecsDir(dir) {
490
+ try {
491
+ const entries = await readdir(dir);
492
+ if (!entries.some((f)=>f.endsWith(".md"))) {
493
+ return null;
494
+ }
495
+ return dir;
496
+ } catch {
497
+ return null;
498
+ }
499
+ }
500
+ async prepareRemoteSpecDirForWrite(dir) {
501
+ await mkdir(dir, {
502
+ recursive: true
503
+ });
504
+ try {
505
+ const entries = await readdir(dir);
506
+ for (const name of entries){
507
+ if (name.endsWith(".md") || name === ".timestamp") {
508
+ await unlink(join(dir, name));
460
509
  }
461
- } catch {
462
- // 缓存不存在或无效,继续拉取
463
510
  }
511
+ } catch {
512
+ // 忽略目录清理失败,后续写入时再处理
513
+ }
514
+ }
515
+ isTeaInstalled() {
516
+ try {
517
+ execSync("command -v tea", {
518
+ stdio: "pipe"
519
+ });
520
+ return true;
521
+ } catch {
522
+ return false;
523
+ }
524
+ }
525
+ getTeaLoginForServer(serverUrl) {
526
+ try {
527
+ const output = execFileSync("tea", [
528
+ "login",
529
+ "list",
530
+ "-o",
531
+ "json"
532
+ ], {
533
+ encoding: "utf-8",
534
+ stdio: "pipe"
535
+ });
536
+ const normalizedServerUrl = this.normalizeServerUrl(serverUrl);
537
+ const logins = JSON.parse(output);
538
+ const matched = logins.find((login)=>login.url && this.normalizeServerUrl(login.url) === normalizedServerUrl);
539
+ return matched?.name ?? null;
540
+ } catch {
541
+ return null;
542
+ }
543
+ }
544
+ runTeaApi(endpoint, loginName) {
545
+ const args = [
546
+ "api",
547
+ "-l",
548
+ loginName,
549
+ endpoint
550
+ ];
551
+ return execFileSync("tea", args, {
552
+ encoding: "utf-8",
553
+ stdio: "pipe"
554
+ });
555
+ }
556
+ encodePathSegments(path) {
557
+ if (!path) return "";
558
+ return path.split("/").filter(Boolean).map((segment)=>encodeURIComponent(segment)).join("/");
559
+ }
560
+ buildTeaContentsEndpoint(ref) {
561
+ const owner = encodeURIComponent(ref.owner);
562
+ const repo = encodeURIComponent(ref.repo);
563
+ const encodedPath = this.encodePathSegments(ref.path || "");
564
+ const pathPart = encodedPath ? `/${encodedPath}` : "";
565
+ const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
566
+ return `/repos/${owner}/${repo}/contents${pathPart}${query}`;
567
+ }
568
+ buildTeaRawFileEndpoint(ref, filePath) {
569
+ const owner = encodeURIComponent(ref.owner);
570
+ const repo = encodeURIComponent(ref.repo);
571
+ const encodedFilePath = this.encodePathSegments(filePath);
572
+ const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
573
+ return `/repos/${owner}/${repo}/raw/${encodedFilePath}${query}`;
574
+ }
575
+ /**
576
+ * 使用 tea api 拉取远程规则
577
+ * 前置条件:本地安装 tea 且已登录目标服务器
578
+ */ async fetchRemoteSpecsViaTea(ref, verbose) {
579
+ if (!this.isTeaInstalled()) {
580
+ this.logVerbose(verbose, 3, ` tea 不可用(未安装)`);
581
+ return null;
582
+ }
583
+ const loginName = this.getTeaLoginForServer(ref.serverUrl);
584
+ if (!loginName) {
585
+ this.logVerbose(verbose, 3, ` tea 未登录目标服务器: ${this.normalizeServerUrl(ref.serverUrl)}`);
586
+ return null;
587
+ }
588
+ this.logVerbose(verbose, 3, ` tea 登录名: ${loginName}`);
589
+ const specDir = this.buildRemoteSpecDir(ref);
590
+ this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
591
+ try {
592
+ console.log(` 📡 使用 tea 拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`);
593
+ const contentsEndpoint = this.buildTeaContentsEndpoint(ref);
594
+ this.logVerbose(verbose, 3, ` tea api endpoint(contents): ${contentsEndpoint}`);
595
+ const contentsRaw = this.runTeaApi(contentsEndpoint, loginName);
596
+ const contents = JSON.parse(contentsRaw);
597
+ const mdFiles = contents.filter((f)=>f.type === "file" && !!f.name && f.name.endsWith(".md") && !!f.path);
598
+ if (mdFiles.length === 0) {
599
+ console.warn(" ⚠️ tea 远程目录中未找到 .md 规则文件");
600
+ return null;
601
+ }
602
+ const fetchedFiles = [];
603
+ for (const file of mdFiles){
604
+ const fileEndpoint = this.buildTeaRawFileEndpoint(ref, file.path);
605
+ this.logVerbose(verbose, 3, ` tea api endpoint(raw): ${fileEndpoint}`);
606
+ const fileContent = this.runTeaApi(fileEndpoint, loginName);
607
+ fetchedFiles.push({
608
+ name: file.name,
609
+ content: fileContent
610
+ });
611
+ }
612
+ await this.prepareRemoteSpecDirForWrite(specDir);
613
+ for (const file of fetchedFiles){
614
+ await writeFile(join(specDir, file.name), file.content, "utf-8");
615
+ }
616
+ console.log(` ✅ 已通过 tea 拉取 ${mdFiles.length} 个规则文件到本地目录`);
617
+ return specDir;
618
+ } catch (error) {
619
+ console.warn(` ⚠️ tea 拉取规则失败:`, error instanceof Error ? error.message : error);
620
+ const localDir = await this.getLocalSpecsDir(specDir);
621
+ if (localDir) {
622
+ const mdCount = await this.getSpecFileCount(localDir);
623
+ this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
624
+ console.log(` 📦 使用本地已存在规则目录`);
625
+ return localDir;
626
+ }
627
+ this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
628
+ return null;
464
629
  }
630
+ }
631
+ /**
632
+ * 通过 Git API 从远程仓库拉取规则文件
633
+ * 保存到 ~/.spaceflow/review-spec/ 目录
634
+ */ async fetchRemoteSpecs(ref, verbose) {
635
+ const specDir = this.buildRemoteSpecDir(ref);
636
+ this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
465
637
  try {
466
638
  console.log(` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`);
467
639
  const contents = await this.gitProvider.listRepositoryContents(ref.owner, ref.repo, ref.path || undefined, ref.ref);
@@ -470,32 +642,41 @@ class ReviewSpecService {
470
642
  console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
471
643
  return null;
472
644
  }
473
- await mkdir(cacheDir, {
474
- recursive: true
475
- });
645
+ const fetchedFiles = [];
476
646
  for (const file of mdFiles){
477
647
  const content = await this.gitProvider.getFileContent(ref.owner, ref.repo, file.path, ref.ref);
478
- await writeFile(join(cacheDir, file.name), content, "utf-8");
648
+ fetchedFiles.push({
649
+ name: file.name,
650
+ content
651
+ });
479
652
  }
480
- // 写入时间戳
481
- await writeFile(join(cacheDir, ".timestamp"), String(Date.now()), "utf-8");
482
- console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到缓存`);
483
- return cacheDir;
653
+ await this.prepareRemoteSpecDirForWrite(specDir);
654
+ for (const file of fetchedFiles){
655
+ await writeFile(join(specDir, file.name), file.content, "utf-8");
656
+ }
657
+ console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到本地目录`);
658
+ return specDir;
484
659
  } catch (error) {
485
660
  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
- }
661
+ const localDir = await this.getLocalSpecsDir(specDir);
662
+ if (localDir) {
663
+ const mdCount = await this.getSpecFileCount(localDir);
664
+ this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
665
+ console.log(` 📦 使用本地已存在规则目录`);
666
+ return localDir;
667
+ }
668
+ this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
496
669
  return null;
497
670
  }
498
671
  }
672
+ async getSpecFileCount(dir) {
673
+ try {
674
+ const entries = await readdir(dir);
675
+ return entries.filter((f)=>f.endsWith(".md")).length;
676
+ } catch {
677
+ return 0;
678
+ }
679
+ }
499
680
  /**
500
681
  * 解析 deps 目录,扫描子目录中的 references 文件夹
501
682
  * 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
@@ -541,16 +722,35 @@ class ReviewSpecService {
541
722
  isRepoUrl(source) {
542
723
  return source.startsWith("http://") || source.startsWith("https://") || source.startsWith("git@") || source.includes("://");
543
724
  }
544
- async cloneSpecRepo(repoUrl) {
725
+ buildRepoCloneUrl(ref) {
726
+ return `${ref.serverUrl}/${ref.owner}/${ref.repo}.git`;
727
+ }
728
+ async resolveClonedSpecDir(cacheDir, subPath) {
729
+ const normalizedSubPath = subPath?.trim().replace(/^\/+|\/+$/g, "");
730
+ if (!normalizedSubPath) {
731
+ return cacheDir;
732
+ }
733
+ const targetDir = join(cacheDir, normalizedSubPath);
734
+ try {
735
+ await access(targetDir);
736
+ return targetDir;
737
+ } catch {
738
+ console.warn(` 警告: 克隆仓库中未找到子目录 ${normalizedSubPath},改为使用仓库根目录`);
739
+ return cacheDir;
740
+ }
741
+ }
742
+ async cloneSpecRepo(repoUrl, subPath, verbose) {
545
743
  const repoName = this.extractRepoName(repoUrl);
546
744
  if (!repoName) {
547
745
  console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
548
746
  return null;
549
747
  }
550
748
  const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
749
+ this.logVerbose(verbose, 3, ` clone 目标目录: ${cacheDir}`);
551
750
  try {
552
751
  await access(cacheDir);
553
752
  // console.log(` 使用缓存的规则仓库: ${cacheDir}`);
753
+ this.logVerbose(verbose, 3, ` 发现已存在仓库目录,尝试 git pull`);
554
754
  try {
555
755
  execSync("git pull --ff-only", {
556
756
  cwd: cacheDir,
@@ -560,10 +760,11 @@ class ReviewSpecService {
560
760
  } catch {
561
761
  console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
562
762
  }
563
- return cacheDir;
763
+ return this.resolveClonedSpecDir(cacheDir, subPath);
564
764
  } catch {
565
765
  // console.log(` 克隆规则仓库: ${repoUrl}`);
566
766
  try {
767
+ this.logVerbose(verbose, 3, ` 本地仓库目录不存在,执行 git clone`);
567
768
  await mkdir(join(homedir(), ".spaceflow", "review-spec"), {
568
769
  recursive: true
569
770
  });
@@ -571,7 +772,7 @@ class ReviewSpecService {
571
772
  stdio: "pipe"
572
773
  });
573
774
  // console.log(` 克隆完成: ${cacheDir}`);
574
- return cacheDir;
775
+ return this.resolveClonedSpecDir(cacheDir, subPath);
575
776
  } catch (error) {
576
777
  console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
577
778
  return null;
@@ -579,6 +780,10 @@ class ReviewSpecService {
579
780
  }
580
781
  }
581
782
  extractRepoName(repoUrl) {
783
+ const parsedRef = parseRepoUrl(repoUrl);
784
+ if (parsedRef) {
785
+ return `${parsedRef.owner}__${parsedRef.repo}`;
786
+ }
582
787
  let path = repoUrl;
583
788
  path = path.replace(/\.git$/, "");
584
789
  path = path.replace(/^git@[^:]+:/, "");
@@ -3177,7 +3382,7 @@ class ReviewContextBuilder {
3177
3382
  }
3178
3383
  async getContextFromEnv(options) {
3179
3384
  const reviewConf = this.config.getPluginConfig("review");
3180
- if (shouldLog(options.verbose, 2)) {
3385
+ if (shouldLog(options.verbose, 3)) {
3181
3386
  console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
3182
3387
  }
3183
3388
  const ciConf = this.config.get("ci");
@@ -3536,7 +3741,7 @@ class ReviewIssueFilter {
3536
3741
  if (shouldLog(verbose, 1)) {
3537
3742
  console.log(`📂 解析规则来源: ${specSources.length} 个`);
3538
3743
  }
3539
- const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
3744
+ const specDirs = await this.reviewSpecService.resolveSpecSources(specSources, verbose);
3540
3745
  if (shouldLog(verbose, 2)) {
3541
3746
  console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
3542
3747
  }
@@ -5404,7 +5609,11 @@ class ReviewService {
5404
5609
  }
5405
5610
  }
5406
5611
  // 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
5407
- if (includes && includes.length > 0) {
5612
+ if (isDirectFileMode && includes && includes.length > 0) {
5613
+ if (shouldLog(verbose, 1)) {
5614
+ console.log(`ℹ️ 直接文件模式下忽略 includes 过滤`);
5615
+ }
5616
+ } else if (includes && includes.length > 0) {
5408
5617
  const beforeFiles = changedFiles.length;
5409
5618
  if (shouldLog(verbose, 2)) {
5410
5619
  console.log(`[resolveSourceData] filterFilesByIncludes: before=${JSON.stringify(changedFiles.map((f)=>({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spaceflow/review",
3
- "version": "0.79.0",
3
+ "version": "0.81.0",
4
4
  "description": "Spaceflow 代码审查插件,使用 LLM 对 PR 代码进行自动审查",
5
5
  "license": "MIT",
6
6
  "author": "Lydanne",
@@ -25,7 +25,7 @@
25
25
  "@vitest/coverage-v8": "^4.0.18",
26
26
  "unplugin-swc": "^1.5.9",
27
27
  "vitest": "^4.0.18",
28
- "@spaceflow/cli": "0.40.0"
28
+ "@spaceflow/cli": "0.41.0"
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@spaceflow/core": "0.30.0"
@@ -65,7 +65,7 @@ export class ReviewContextBuilder {
65
65
 
66
66
  async getContextFromEnv(options: ReviewOptions): Promise<ReviewContext> {
67
67
  const reviewConf = this.config.getPluginConfig<ReviewConfig>("review");
68
- if (shouldLog(options.verbose, 2)) {
68
+ if (shouldLog(options.verbose, 3)) {
69
69
  console.log(`[getContextFromEnv] reviewConf: ${JSON.stringify(reviewConf)}`);
70
70
  }
71
71
  const ciConf = this.config.get<CiConfig>("ci");
@@ -268,7 +268,9 @@ export class ReviewContextBuilder {
268
268
  */
269
269
  private normalizeSingleFilePath(file: string, cwd: string): string {
270
270
  const normalizedInput = normalize(file);
271
- const relativePath = isAbsolute(normalizedInput) ? relative(cwd, normalizedInput) : normalizedInput;
271
+ const relativePath = isAbsolute(normalizedInput)
272
+ ? relative(cwd, normalizedInput)
273
+ : normalizedInput;
272
274
  return relativePath.replaceAll("\\", "/").replace(/^\.\/+/, "");
273
275
  }
274
276
 
@@ -39,7 +39,7 @@ export class ReviewIssueFilter {
39
39
  if (shouldLog(verbose, 1)) {
40
40
  console.log(`📂 解析规则来源: ${specSources.length} 个`);
41
41
  }
42
- const specDirs = await this.reviewSpecService.resolveSpecSources(specSources);
42
+ const specDirs = await this.reviewSpecService.resolveSpecSources(specSources, verbose);
43
43
  if (shouldLog(verbose, 2)) {
44
44
  console.log(` 解析到 ${specDirs.length} 个规则目录`, specDirs);
45
45
  }
@@ -976,6 +976,13 @@ const MAX_COUNT = 100;
976
976
  expect(result).toBe("org__repo");
977
977
  });
978
978
 
979
+ it("should extract from directory URL", () => {
980
+ const result = (service as any).extractRepoName(
981
+ "https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
982
+ );
983
+ expect(result).toBe("xgj__review-spec");
984
+ });
985
+
979
986
  it("should handle single part path", () => {
980
987
  const result = (service as any).extractRepoName("repo");
981
988
  expect(result).toBe("repo");
@@ -1274,10 +1281,74 @@ const MAX_COUNT = 100;
1274
1281
  ]);
1275
1282
  expect(result.length).toBeGreaterThanOrEqual(0);
1276
1283
  });
1284
+
1285
+ it("should fallback to clone repo root URL when API fetch fails for directory URL", async () => {
1286
+ gitProvider.listRepositoryContents.mockRejectedValue(new Error("401 unauthorized"));
1287
+ (access as Mock)
1288
+ .mockRejectedValueOnce(new Error("not found"))
1289
+ .mockResolvedValueOnce(undefined);
1290
+ (mkdir as Mock).mockResolvedValue(undefined);
1291
+ (child_process.execSync as Mock).mockReturnValue("");
1292
+ (child_process.execFileSync as Mock).mockImplementation(() => {
1293
+ throw new Error("tea unavailable");
1294
+ });
1295
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1296
+
1297
+ const result = await service.resolveSpecSources([
1298
+ "https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
1299
+ ]);
1300
+
1301
+ expect(result.some((dir) => dir.includes("xgj__review-spec/references"))).toBe(true);
1302
+ const cloneCall = (child_process.execSync as Mock).mock.calls.find((call) =>
1303
+ String(call[0]).includes('git clone --depth 1 "https://git.bjxgj.com/xgj/review-spec.git"'),
1304
+ );
1305
+ expect(cloneCall).toBeTruthy();
1306
+ consoleSpy.mockRestore();
1307
+ });
1308
+
1309
+ it("should resolve remote specs via tea when provider API fails", async () => {
1310
+ gitProvider.listRepositoryContents.mockRejectedValue(new Error("401 unauthorized"));
1311
+ (child_process.execSync as Mock).mockReturnValue(""); // command -v tea
1312
+ (child_process.execFileSync as Mock)
1313
+ .mockReturnValueOnce(
1314
+ JSON.stringify([
1315
+ {
1316
+ name: "git.bjxgj.com",
1317
+ url: "https://git.bjxgj.com",
1318
+ },
1319
+ ]),
1320
+ ) // tea login list -o json
1321
+ .mockReturnValueOnce(
1322
+ JSON.stringify([
1323
+ {
1324
+ type: "file",
1325
+ name: "js.base.md",
1326
+ path: "references/js.base.md",
1327
+ },
1328
+ ]),
1329
+ ) // tea api contents
1330
+ .mockReturnValueOnce("# Test `[JsTs.Base]`"); // tea api raw file
1331
+ (readdir as Mock).mockResolvedValue([]);
1332
+ (mkdir as Mock).mockResolvedValue(undefined);
1333
+ (writeFile as Mock).mockResolvedValue(undefined);
1334
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1335
+
1336
+ const result = await service.resolveSpecSources([
1337
+ "https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
1338
+ ]);
1339
+
1340
+ expect(result.some((dir) => dir.includes("review-spec"))).toBe(true);
1341
+ expect(child_process.execFileSync).toHaveBeenCalledWith(
1342
+ "tea",
1343
+ ["api", "-l", "git.bjxgj.com", "/repos/xgj/review-spec/contents/references?ref=main"],
1344
+ expect.objectContaining({ encoding: "utf-8", stdio: "pipe" }),
1345
+ );
1346
+ consoleSpy.mockRestore();
1347
+ });
1277
1348
  });
1278
1349
 
1279
1350
  describe("fetchRemoteSpecs", () => {
1280
- it("should fetch and cache remote specs", async () => {
1351
+ it("should fetch and persist remote specs into review-spec dir", async () => {
1281
1352
  gitProvider.listRepositoryContents.mockResolvedValue([
1282
1353
  { type: "file", name: "rule.md", path: "rule.md" },
1283
1354
  ]);
@@ -1301,7 +1372,7 @@ const MAX_COUNT = 100;
1301
1372
  consoleSpy.mockRestore();
1302
1373
  });
1303
1374
 
1304
- it("should handle API failure and use expired cache", async () => {
1375
+ it("should handle API failure and use local specs directory", async () => {
1305
1376
  gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
1306
1377
  (readdir as Mock).mockResolvedValue(["cached.md"]);
1307
1378
  const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -1313,7 +1384,7 @@ const MAX_COUNT = 100;
1313
1384
  logSpy.mockRestore();
1314
1385
  });
1315
1386
 
1316
- it("should handle API failure without cache", async () => {
1387
+ it("should handle API failure without local specs directory", async () => {
1317
1388
  gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
1318
1389
  (readdir as Mock).mockRejectedValue(new Error("no cache"));
1319
1390
  const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -1325,15 +1396,12 @@ const MAX_COUNT = 100;
1325
1396
  logSpy.mockRestore();
1326
1397
  });
1327
1398
 
1328
- it("should use valid cache in non-CI environment", async () => {
1329
- const originalCI = process.env.CI;
1330
- delete process.env.CI;
1331
- (readFile as Mock).mockResolvedValue(String(Date.now()));
1399
+ it("should use local specs directory as fallback", async () => {
1400
+ gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
1332
1401
  (readdir as Mock).mockResolvedValue(["cached.md"]);
1333
1402
  const ref = { owner: "org", repo: "repo" };
1334
1403
  const result = await (service as any).fetchRemoteSpecs(ref);
1335
1404
  expect(result).toBeTruthy();
1336
- process.env.CI = originalCI;
1337
1405
  });
1338
1406
  });
1339
1407
 
@@ -1416,6 +1484,19 @@ const MAX_COUNT = 100;
1416
1484
  expect(result).toBeTruthy();
1417
1485
  });
1418
1486
 
1487
+ it("should return sub directory when subPath is provided", async () => {
1488
+ (access as Mock)
1489
+ .mockRejectedValueOnce(new Error("not found"))
1490
+ .mockResolvedValueOnce(undefined);
1491
+ (mkdir as Mock).mockResolvedValue(undefined);
1492
+ (child_process.execSync as Mock).mockReturnValue("");
1493
+ const result = await (service as any).cloneSpecRepo(
1494
+ "https://github.com/org/repo.git",
1495
+ "references",
1496
+ );
1497
+ expect(result).toContain("org__repo/references");
1498
+ });
1499
+
1419
1500
  it("should handle clone failure", async () => {
1420
1501
  (access as Mock).mockRejectedValue(new Error("not found"));
1421
1502
  (mkdir as Mock).mockResolvedValue(undefined);
@@ -7,19 +7,26 @@ import {
7
7
  type RemoteRepoRef,
8
8
  type RepositoryContent,
9
9
  } from "@spaceflow/core";
10
- import { readdir, readFile, mkdir, access, writeFile } from "fs/promises";
10
+ import { readdir, readFile, mkdir, access, writeFile, unlink } from "fs/promises";
11
11
  import { join, basename, extname } from "path";
12
12
  import { homedir } from "os";
13
- import { execSync } from "child_process";
13
+ import { execSync, execFileSync } from "child_process";
14
14
  import micromatch from "micromatch";
15
15
  import { ReviewSpec, ReviewRule, RuleExample, Severity } from "./types";
16
16
  import { extractGlobsFromIncludes } from "../review-includes-filter";
17
17
 
18
- /** 远程规则缓存 TTL(毫秒),默认 5 分钟 */
19
- const REMOTE_SPEC_CACHE_TTL = 5 * 60 * 1000;
20
-
21
18
  export class ReviewSpecService {
22
19
  constructor(protected readonly gitProvider?: GitProviderService) {}
20
+
21
+ protected normalizeServerUrl(url: string): string {
22
+ return url.trim().replace(/\/+$/, "");
23
+ }
24
+
25
+ protected logVerbose(verbose: VerboseLevel | undefined, level: number, message: string): void {
26
+ if (shouldLog(verbose, level as VerboseLevel)) {
27
+ console.log(message);
28
+ }
29
+ }
23
30
  /**
24
31
  * 检查规则 ID 是否匹配(精确匹配或前缀匹配)
25
32
  * 例如: "JsTs.FileName" 匹配 "JsTs.FileName" 和 "JsTs.FileName.UpperCamel"
@@ -114,58 +121,249 @@ export class ReviewSpecService {
114
121
  return specs;
115
122
  }
116
123
 
117
- async resolveSpecSources(sources: string[]): Promise<string[]> {
124
+ async resolveSpecSources(sources: string[], verbose?: VerboseLevel): Promise<string[]> {
118
125
  const dirs: string[] = [];
119
126
 
120
127
  for (const source of sources) {
121
- // 优先尝试解析为远程仓库 URL(浏览器复制的链接)
128
+ this.logVerbose(verbose, 3, ` 🔎 规则来源: ${source}`);
122
129
  const repoRef = parseRepoUrl(source);
130
+ if (repoRef) {
131
+ this.logVerbose(
132
+ verbose,
133
+ 3,
134
+ ` 解析远程仓库: ${repoRef.serverUrl}/${repoRef.owner}/${repoRef.repo} path=${repoRef.path || "(root)"} ref=${repoRef.ref || "(default)"}`,
135
+ );
136
+ } else {
137
+ this.logVerbose(verbose, 3, ` 非仓库 URL,按本地目录处理`);
138
+ }
123
139
  if (repoRef && this.gitProvider) {
124
- const dir = await this.fetchRemoteSpecs(repoRef);
140
+ this.logVerbose(verbose, 3, ` 尝试方式 #1: Git Provider API`);
141
+ const dir = await this.fetchRemoteSpecs(repoRef, verbose);
125
142
  if (dir) {
126
143
  dirs.push(dir);
144
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: Git Provider API -> ${dir}`);
145
+ continue;
146
+ }
147
+ this.logVerbose(verbose, 3, ` ❌ Git Provider API 未获取到规则,继续尝试`);
148
+ }
149
+ if (repoRef) {
150
+ this.logVerbose(verbose, 3, ` 尝试方式 #2: tea api`);
151
+ const teaDir = await this.fetchRemoteSpecsViaTea(repoRef, verbose);
152
+ if (teaDir) {
153
+ dirs.push(teaDir);
154
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: tea api -> ${teaDir}`);
155
+ continue;
156
+ }
157
+ this.logVerbose(verbose, 3, ` ❌ tea api 未获取到规则,继续尝试`);
158
+ }
159
+ // API 拉取失败或未配置 provider 时,回退到 git clone(使用仓库根 URL,而非目录 URL)
160
+ if (repoRef) {
161
+ this.logVerbose(verbose, 3, ` 尝试方式 #3: git clone 回退`);
162
+ const fallbackCloneUrl = this.buildRepoCloneUrl(repoRef);
163
+ this.logVerbose(verbose, 3, ` clone URL: ${fallbackCloneUrl}`);
164
+ const fallbackDir = await this.cloneSpecRepo(fallbackCloneUrl, repoRef.path, verbose);
165
+ if (fallbackDir) {
166
+ dirs.push(fallbackDir);
167
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: git clone 回退 -> ${fallbackDir}`);
127
168
  continue;
128
169
  }
170
+ this.logVerbose(verbose, 3, ` ❌ git clone 回退失败`);
129
171
  }
130
172
  if (this.isRepoUrl(source)) {
131
- const dir = await this.cloneSpecRepo(source);
173
+ this.logVerbose(verbose, 3, ` 尝试方式 #4: 直接 clone 来源 URL`);
174
+ const dir = await this.cloneSpecRepo(source, undefined, verbose);
132
175
  if (dir) {
133
176
  dirs.push(dir);
177
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: 直接 clone 来源 URL -> ${dir}`);
178
+ } else {
179
+ this.logVerbose(verbose, 3, ` ❌ 直接 clone 来源 URL 失败`);
134
180
  }
135
181
  } else {
136
182
  // 检查是否是 deps 目录,如果是则扫描子目录的 references
137
183
  const resolvedDirs = await this.resolveDepsDir(source);
138
184
  dirs.push(...resolvedDirs);
185
+ this.logVerbose(
186
+ verbose,
187
+ 3,
188
+ ` deps 目录解析结果: ${resolvedDirs.length > 0 ? resolvedDirs.join(", ") : "(空)"}`,
189
+ );
139
190
  }
140
191
  }
141
192
 
142
193
  return dirs;
143
194
  }
144
195
 
196
+ protected buildRemoteSpecDir(ref: RemoteRepoRef): string {
197
+ const dirKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
198
+ return join(homedir(), ".spaceflow", "review-spec", dirKey);
199
+ }
200
+
201
+ protected async getLocalSpecsDir(dir: string): Promise<string | null> {
202
+ try {
203
+ const entries = await readdir(dir);
204
+ if (!entries.some((f) => f.endsWith(".md"))) {
205
+ return null;
206
+ }
207
+ return dir;
208
+ } catch {
209
+ return null;
210
+ }
211
+ }
212
+
213
+ protected async prepareRemoteSpecDirForWrite(dir: string): Promise<void> {
214
+ await mkdir(dir, { recursive: true });
215
+ try {
216
+ const entries = await readdir(dir);
217
+ for (const name of entries) {
218
+ if (name.endsWith(".md") || name === ".timestamp") {
219
+ await unlink(join(dir, name));
220
+ }
221
+ }
222
+ } catch {
223
+ // 忽略目录清理失败,后续写入时再处理
224
+ }
225
+ }
226
+
227
+ protected isTeaInstalled(): boolean {
228
+ try {
229
+ execSync("command -v tea", { stdio: "pipe" });
230
+ return true;
231
+ } catch {
232
+ return false;
233
+ }
234
+ }
235
+
236
+ protected getTeaLoginForServer(serverUrl: string): string | null {
237
+ try {
238
+ const output = execFileSync("tea", ["login", "list", "-o", "json"], {
239
+ encoding: "utf-8",
240
+ stdio: "pipe",
241
+ });
242
+ const normalizedServerUrl = this.normalizeServerUrl(serverUrl);
243
+ const logins = JSON.parse(output) as Array<{ name?: string; url?: string }>;
244
+ const matched = logins.find(
245
+ (login) => login.url && this.normalizeServerUrl(login.url) === normalizedServerUrl,
246
+ );
247
+ return matched?.name ?? null;
248
+ } catch {
249
+ return null;
250
+ }
251
+ }
252
+
253
+ protected runTeaApi(endpoint: string, loginName: string): string {
254
+ const args = ["api", "-l", loginName, endpoint];
255
+ return execFileSync("tea", args, {
256
+ encoding: "utf-8",
257
+ stdio: "pipe",
258
+ });
259
+ }
260
+
261
+ protected encodePathSegments(path: string): string {
262
+ if (!path) return "";
263
+ return path
264
+ .split("/")
265
+ .filter(Boolean)
266
+ .map((segment) => encodeURIComponent(segment))
267
+ .join("/");
268
+ }
269
+
270
+ protected buildTeaContentsEndpoint(ref: RemoteRepoRef): string {
271
+ const owner = encodeURIComponent(ref.owner);
272
+ const repo = encodeURIComponent(ref.repo);
273
+ const encodedPath = this.encodePathSegments(ref.path || "");
274
+ const pathPart = encodedPath ? `/${encodedPath}` : "";
275
+ const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
276
+ return `/repos/${owner}/${repo}/contents${pathPart}${query}`;
277
+ }
278
+
279
+ protected buildTeaRawFileEndpoint(ref: RemoteRepoRef, filePath: string): string {
280
+ const owner = encodeURIComponent(ref.owner);
281
+ const repo = encodeURIComponent(ref.repo);
282
+ const encodedFilePath = this.encodePathSegments(filePath);
283
+ const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
284
+ return `/repos/${owner}/${repo}/raw/${encodedFilePath}${query}`;
285
+ }
286
+
145
287
  /**
146
- * 通过 Git API 从远程仓库拉取规则文件
147
- * 缓存到 ~/.spaceflow/review-spec-cache/ 目录,带 TTL
288
+ * 使用 tea api 拉取远程规则
289
+ * 前置条件:本地安装 tea 且已登录目标服务器
148
290
  */
149
- protected async fetchRemoteSpecs(ref: RemoteRepoRef): Promise<string | null> {
150
- const cacheKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
151
- const cacheDir = join(homedir(), ".spaceflow", "review-spec-cache", cacheKey);
152
- // 检查缓存是否有效(非 CI 环境下使用 TTL)
153
- const isCI = !!process.env.CI;
154
- if (!isCI) {
155
- try {
156
- const timestampFile = join(cacheDir, ".timestamp");
157
- const timestamp = await readFile(timestampFile, "utf-8");
158
- const age = Date.now() - Number(timestamp);
159
- if (age < REMOTE_SPEC_CACHE_TTL) {
160
- const entries = await readdir(cacheDir);
161
- if (entries.some((f) => f.endsWith(".md"))) {
162
- return cacheDir;
163
- }
164
- }
165
- } catch {
166
- // 缓存不存在或无效,继续拉取
291
+ protected async fetchRemoteSpecsViaTea(
292
+ ref: RemoteRepoRef,
293
+ verbose?: VerboseLevel,
294
+ ): Promise<string | null> {
295
+ if (!this.isTeaInstalled()) {
296
+ this.logVerbose(verbose, 3, ` tea 不可用(未安装)`);
297
+ return null;
298
+ }
299
+ const loginName = this.getTeaLoginForServer(ref.serverUrl);
300
+ if (!loginName) {
301
+ this.logVerbose(
302
+ verbose,
303
+ 3,
304
+ ` tea 未登录目标服务器: ${this.normalizeServerUrl(ref.serverUrl)}`,
305
+ );
306
+ return null;
307
+ }
308
+ this.logVerbose(verbose, 3, ` tea 登录名: ${loginName}`);
309
+ const specDir = this.buildRemoteSpecDir(ref);
310
+ this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
311
+ try {
312
+ console.log(
313
+ ` 📡 使用 tea 拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
314
+ );
315
+ const contentsEndpoint = this.buildTeaContentsEndpoint(ref);
316
+ this.logVerbose(verbose, 3, ` tea api endpoint(contents): ${contentsEndpoint}`);
317
+ const contentsRaw = this.runTeaApi(contentsEndpoint, loginName);
318
+ const contents = JSON.parse(contentsRaw) as Array<{
319
+ type?: string;
320
+ name?: string;
321
+ path?: string;
322
+ }>;
323
+ const mdFiles = contents.filter(
324
+ (f) => f.type === "file" && !!f.name && f.name.endsWith(".md") && !!f.path,
325
+ );
326
+ if (mdFiles.length === 0) {
327
+ console.warn(" ⚠️ tea 远程目录中未找到 .md 规则文件");
328
+ return null;
329
+ }
330
+ const fetchedFiles: Array<{ name: string; content: string }> = [];
331
+ for (const file of mdFiles) {
332
+ const fileEndpoint = this.buildTeaRawFileEndpoint(ref, file.path!);
333
+ this.logVerbose(verbose, 3, ` tea api endpoint(raw): ${fileEndpoint}`);
334
+ const fileContent = this.runTeaApi(fileEndpoint, loginName);
335
+ fetchedFiles.push({ name: file.name!, content: fileContent });
336
+ }
337
+ await this.prepareRemoteSpecDirForWrite(specDir);
338
+ for (const file of fetchedFiles) {
339
+ await writeFile(join(specDir, file.name), file.content, "utf-8");
167
340
  }
341
+ console.log(` ✅ 已通过 tea 拉取 ${mdFiles.length} 个规则文件到本地目录`);
342
+ return specDir;
343
+ } catch (error) {
344
+ console.warn(` ⚠️ tea 拉取规则失败:`, error instanceof Error ? error.message : error);
345
+ const localDir = await this.getLocalSpecsDir(specDir);
346
+ if (localDir) {
347
+ const mdCount = await this.getSpecFileCount(localDir);
348
+ this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
349
+ console.log(` 📦 使用本地已存在规则目录`);
350
+ return localDir;
351
+ }
352
+ this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
353
+ return null;
168
354
  }
355
+ }
356
+
357
+ /**
358
+ * 通过 Git API 从远程仓库拉取规则文件
359
+ * 保存到 ~/.spaceflow/review-spec/ 目录
360
+ */
361
+ protected async fetchRemoteSpecs(
362
+ ref: RemoteRepoRef,
363
+ verbose?: VerboseLevel,
364
+ ): Promise<string | null> {
365
+ const specDir = this.buildRemoteSpecDir(ref);
366
+ this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
169
367
  try {
170
368
  console.log(
171
369
  ` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
@@ -183,7 +381,7 @@ export class ReviewSpecService {
183
381
  console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
184
382
  return null;
185
383
  }
186
- await mkdir(cacheDir, { recursive: true });
384
+ const fetchedFiles: Array<{ name: string; content: string }> = [];
187
385
  for (const file of mdFiles) {
188
386
  const content = await this.gitProvider!.getFileContent(
189
387
  ref.owner,
@@ -191,28 +389,37 @@ export class ReviewSpecService {
191
389
  file.path,
192
390
  ref.ref,
193
391
  );
194
- await writeFile(join(cacheDir, file.name), content, "utf-8");
392
+ fetchedFiles.push({ name: file.name, content });
195
393
  }
196
- // 写入时间戳
197
- await writeFile(join(cacheDir, ".timestamp"), String(Date.now()), "utf-8");
198
- console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到缓存`);
199
- return cacheDir;
394
+ await this.prepareRemoteSpecDirForWrite(specDir);
395
+ for (const file of fetchedFiles) {
396
+ await writeFile(join(specDir, file.name), file.content, "utf-8");
397
+ }
398
+ console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到本地目录`);
399
+ return specDir;
200
400
  } catch (error) {
201
401
  console.warn(` ⚠️ 远程规则拉取失败:`, error instanceof Error ? error.message : error);
202
- // 尝试使用过期缓存
203
- try {
204
- const entries = await readdir(cacheDir);
205
- if (entries.some((f) => f.endsWith(".md"))) {
206
- console.log(` 📦 使用过期缓存`);
207
- return cacheDir;
208
- }
209
- } catch {
210
- // 无缓存可用
402
+ const localDir = await this.getLocalSpecsDir(specDir);
403
+ if (localDir) {
404
+ const mdCount = await this.getSpecFileCount(localDir);
405
+ this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
406
+ console.log(` 📦 使用本地已存在规则目录`);
407
+ return localDir;
211
408
  }
409
+ this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
212
410
  return null;
213
411
  }
214
412
  }
215
413
 
414
+ protected async getSpecFileCount(dir: string): Promise<number> {
415
+ try {
416
+ const entries = await readdir(dir);
417
+ return entries.filter((f) => f.endsWith(".md")).length;
418
+ } catch {
419
+ return 0;
420
+ }
421
+ }
422
+
216
423
  /**
217
424
  * 解析 deps 目录,扫描子目录中的 references 文件夹
218
425
  * 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
@@ -268,7 +475,32 @@ export class ReviewSpecService {
268
475
  );
269
476
  }
270
477
 
271
- protected async cloneSpecRepo(repoUrl: string): Promise<string | null> {
478
+ protected buildRepoCloneUrl(ref: RemoteRepoRef): string {
479
+ return `${ref.serverUrl}/${ref.owner}/${ref.repo}.git`;
480
+ }
481
+
482
+ protected async resolveClonedSpecDir(cacheDir: string, subPath?: string): Promise<string> {
483
+ const normalizedSubPath = subPath?.trim().replace(/^\/+|\/+$/g, "");
484
+ if (!normalizedSubPath) {
485
+ return cacheDir;
486
+ }
487
+ const targetDir = join(cacheDir, normalizedSubPath);
488
+ try {
489
+ await access(targetDir);
490
+ return targetDir;
491
+ } catch {
492
+ console.warn(
493
+ ` 警告: 克隆仓库中未找到子目录 ${normalizedSubPath},改为使用仓库根目录`,
494
+ );
495
+ return cacheDir;
496
+ }
497
+ }
498
+
499
+ protected async cloneSpecRepo(
500
+ repoUrl: string,
501
+ subPath?: string,
502
+ verbose?: VerboseLevel,
503
+ ): Promise<string | null> {
272
504
  const repoName = this.extractRepoName(repoUrl);
273
505
  if (!repoName) {
274
506
  console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
@@ -276,24 +508,27 @@ export class ReviewSpecService {
276
508
  }
277
509
 
278
510
  const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
511
+ this.logVerbose(verbose, 3, ` clone 目标目录: ${cacheDir}`);
279
512
 
280
513
  try {
281
514
  await access(cacheDir);
282
515
  // console.log(` 使用缓存的规则仓库: ${cacheDir}`);
516
+ this.logVerbose(verbose, 3, ` 发现已存在仓库目录,尝试 git pull`);
283
517
  try {
284
518
  execSync("git pull --ff-only", { cwd: cacheDir, stdio: "pipe" });
285
519
  // console.log(` 已更新规则仓库`);
286
520
  } catch {
287
521
  console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
288
522
  }
289
- return cacheDir;
523
+ return this.resolveClonedSpecDir(cacheDir, subPath);
290
524
  } catch {
291
525
  // console.log(` 克隆规则仓库: ${repoUrl}`);
292
526
  try {
527
+ this.logVerbose(verbose, 3, ` 本地仓库目录不存在,执行 git clone`);
293
528
  await mkdir(join(homedir(), ".spaceflow", "review-spec"), { recursive: true });
294
529
  execSync(`git clone --depth 1 "${repoUrl}" "${cacheDir}"`, { stdio: "pipe" });
295
530
  // console.log(` 克隆完成: ${cacheDir}`);
296
- return cacheDir;
531
+ return this.resolveClonedSpecDir(cacheDir, subPath);
297
532
  } catch (error) {
298
533
  console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
299
534
  return null;
@@ -302,6 +537,11 @@ export class ReviewSpecService {
302
537
  }
303
538
 
304
539
  protected extractRepoName(repoUrl: string): string | null {
540
+ const parsedRef = parseRepoUrl(repoUrl);
541
+ if (parsedRef) {
542
+ return `${parsedRef.owner}__${parsedRef.repo}`;
543
+ }
544
+
305
545
  let path = repoUrl;
306
546
  path = path.replace(/\.git$/, "");
307
547
  path = path.replace(/^git@[^:]+:/, "");
@@ -1489,6 +1489,26 @@ describe("ReviewService", () => {
1489
1489
  expect(mockGitSdkService.getUncommittedFiles).not.toHaveBeenCalled();
1490
1490
  expect(mockGitSdkService.getStagedFiles).not.toHaveBeenCalled();
1491
1491
  });
1492
+
1493
+ it("should ignore includes filtering in direct file mode", async () => {
1494
+ const context: ReviewContext = {
1495
+ owner: "o",
1496
+ repo: "r",
1497
+ dryRun: true,
1498
+ ci: false,
1499
+ specSources: ["/spec"],
1500
+ files: ["miniprogram/utils/asyncSharedUtilsLoader.js"],
1501
+ includes: ["**/*.ts", "added|**/*.js"],
1502
+ localMode: false,
1503
+ };
1504
+
1505
+ const result = await (service as any).resolveSourceData(context);
1506
+
1507
+ expect(result.isDirectFileMode).toBe(true);
1508
+ expect(result.changedFiles).toEqual([
1509
+ { filename: "miniprogram/utils/asyncSharedUtilsLoader.js", status: "modified" },
1510
+ ]);
1511
+ });
1492
1512
  });
1493
1513
 
1494
1514
  describe("ReviewService.getFilesForCommit", () => {
@@ -412,7 +412,11 @@ export class ReviewService {
412
412
  }
413
413
 
414
414
  // 3. 使用 includes 过滤文件和 commits(支持 added|/modified|/deleted| 前缀语法)
415
- if (includes && includes.length > 0) {
415
+ if (isDirectFileMode && includes && includes.length > 0) {
416
+ if (shouldLog(verbose, 1)) {
417
+ console.log(`ℹ️ 直接文件模式下忽略 includes 过滤`);
418
+ }
419
+ } else if (includes && includes.length > 0) {
416
420
  const beforeFiles = changedFiles.length;
417
421
  if (shouldLog(verbose, 2)) {
418
422
  console.log(