@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.
@@ -1,5 +1,6 @@
1
1
  import { vi, type Mock } from "vitest";
2
2
  import { ReviewSpecService } from "./review-spec.service";
3
+ import { ChangedFileCollection } from "../changed-file-collection";
3
4
  import { readdir, readFile, mkdir, access, writeFile } from "fs/promises";
4
5
  import * as child_process from "child_process";
5
6
 
@@ -475,7 +476,7 @@ const MAX_COUNT = 100;
475
476
  { filename: "src/user/user.controller.ts" },
476
477
  ];
477
478
 
478
- const result = service.filterApplicableSpecs(specs, changedFiles);
479
+ const result = service.filterApplicableSpecs(specs, ChangedFileCollection.from(changedFiles));
479
480
 
480
481
  // 只按扩展名过滤,includes 在 LLM 审查后处理
481
482
  expect(result).toHaveLength(2);
@@ -509,7 +510,7 @@ const MAX_COUNT = 100;
509
510
 
510
511
  const changedFiles = [{ filename: "src/app.ts" }];
511
512
 
512
- const result = service.filterApplicableSpecs(specs, changedFiles);
513
+ const result = service.filterApplicableSpecs(specs, ChangedFileCollection.from(changedFiles));
513
514
 
514
515
  expect(result).toHaveLength(1);
515
516
  expect(result[0].filename).toBe("js&ts.base.md");
@@ -976,6 +977,13 @@ const MAX_COUNT = 100;
976
977
  expect(result).toBe("org__repo");
977
978
  });
978
979
 
980
+ it("should extract from directory URL", () => {
981
+ const result = (service as any).extractRepoName(
982
+ "https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
983
+ );
984
+ expect(result).toBe("xgj__review-spec");
985
+ });
986
+
979
987
  it("should handle single part path", () => {
980
988
  const result = (service as any).extractRepoName("repo");
981
989
  expect(result).toBe("repo");
@@ -1274,10 +1282,74 @@ const MAX_COUNT = 100;
1274
1282
  ]);
1275
1283
  expect(result.length).toBeGreaterThanOrEqual(0);
1276
1284
  });
1285
+
1286
+ it("should fallback to clone repo root URL when API fetch fails for directory URL", async () => {
1287
+ gitProvider.listRepositoryContents.mockRejectedValue(new Error("401 unauthorized"));
1288
+ (access as Mock)
1289
+ .mockRejectedValueOnce(new Error("not found"))
1290
+ .mockResolvedValueOnce(undefined);
1291
+ (mkdir as Mock).mockResolvedValue(undefined);
1292
+ (child_process.execSync as Mock).mockReturnValue("");
1293
+ (child_process.execFileSync as Mock).mockImplementation(() => {
1294
+ throw new Error("tea unavailable");
1295
+ });
1296
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1297
+
1298
+ const result = await service.resolveSpecSources([
1299
+ "https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
1300
+ ]);
1301
+
1302
+ expect(result.some((dir) => dir.includes("xgj__review-spec/references"))).toBe(true);
1303
+ const cloneCall = (child_process.execSync as Mock).mock.calls.find((call) =>
1304
+ String(call[0]).includes('git clone --depth 1 "https://git.bjxgj.com/xgj/review-spec.git"'),
1305
+ );
1306
+ expect(cloneCall).toBeTruthy();
1307
+ consoleSpy.mockRestore();
1308
+ });
1309
+
1310
+ it("should resolve remote specs via tea when provider API fails", async () => {
1311
+ gitProvider.listRepositoryContents.mockRejectedValue(new Error("401 unauthorized"));
1312
+ (child_process.execSync as Mock).mockReturnValue(""); // command -v tea
1313
+ (child_process.execFileSync as Mock)
1314
+ .mockReturnValueOnce(
1315
+ JSON.stringify([
1316
+ {
1317
+ name: "git.bjxgj.com",
1318
+ url: "https://git.bjxgj.com",
1319
+ },
1320
+ ]),
1321
+ ) // tea login list -o json
1322
+ .mockReturnValueOnce(
1323
+ JSON.stringify([
1324
+ {
1325
+ type: "file",
1326
+ name: "js.base.md",
1327
+ path: "references/js.base.md",
1328
+ },
1329
+ ]),
1330
+ ) // tea api contents
1331
+ .mockReturnValueOnce("# Test `[JsTs.Base]`"); // tea api raw file
1332
+ (readdir as Mock).mockResolvedValue([]);
1333
+ (mkdir as Mock).mockResolvedValue(undefined);
1334
+ (writeFile as Mock).mockResolvedValue(undefined);
1335
+ const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
1336
+
1337
+ const result = await service.resolveSpecSources([
1338
+ "https://git.bjxgj.com/xgj/review-spec/src/branch/main/references",
1339
+ ]);
1340
+
1341
+ expect(result.some((dir) => dir.includes("review-spec"))).toBe(true);
1342
+ expect(child_process.execFileSync).toHaveBeenCalledWith(
1343
+ "tea",
1344
+ ["api", "-l", "git.bjxgj.com", "/repos/xgj/review-spec/contents/references?ref=main"],
1345
+ expect.objectContaining({ encoding: "utf-8", stdio: "pipe" }),
1346
+ );
1347
+ consoleSpy.mockRestore();
1348
+ });
1277
1349
  });
1278
1350
 
1279
1351
  describe("fetchRemoteSpecs", () => {
1280
- it("should fetch and cache remote specs", async () => {
1352
+ it("should fetch and persist remote specs into review-spec dir", async () => {
1281
1353
  gitProvider.listRepositoryContents.mockResolvedValue([
1282
1354
  { type: "file", name: "rule.md", path: "rule.md" },
1283
1355
  ]);
@@ -1301,7 +1373,7 @@ const MAX_COUNT = 100;
1301
1373
  consoleSpy.mockRestore();
1302
1374
  });
1303
1375
 
1304
- it("should handle API failure and use expired cache", async () => {
1376
+ it("should handle API failure and use local specs directory", async () => {
1305
1377
  gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
1306
1378
  (readdir as Mock).mockResolvedValue(["cached.md"]);
1307
1379
  const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -1313,7 +1385,7 @@ const MAX_COUNT = 100;
1313
1385
  logSpy.mockRestore();
1314
1386
  });
1315
1387
 
1316
- it("should handle API failure without cache", async () => {
1388
+ it("should handle API failure without local specs directory", async () => {
1317
1389
  gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
1318
1390
  (readdir as Mock).mockRejectedValue(new Error("no cache"));
1319
1391
  const consoleSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
@@ -1325,15 +1397,12 @@ const MAX_COUNT = 100;
1325
1397
  logSpy.mockRestore();
1326
1398
  });
1327
1399
 
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()));
1400
+ it("should use local specs directory as fallback", async () => {
1401
+ gitProvider.listRepositoryContents.mockRejectedValue(new Error("API error"));
1332
1402
  (readdir as Mock).mockResolvedValue(["cached.md"]);
1333
1403
  const ref = { owner: "org", repo: "repo" };
1334
1404
  const result = await (service as any).fetchRemoteSpecs(ref);
1335
1405
  expect(result).toBeTruthy();
1336
- process.env.CI = originalCI;
1337
1406
  });
1338
1407
  });
1339
1408
 
@@ -1416,6 +1485,19 @@ const MAX_COUNT = 100;
1416
1485
  expect(result).toBeTruthy();
1417
1486
  });
1418
1487
 
1488
+ it("should return sub directory when subPath is provided", async () => {
1489
+ (access as Mock)
1490
+ .mockRejectedValueOnce(new Error("not found"))
1491
+ .mockResolvedValueOnce(undefined);
1492
+ (mkdir as Mock).mockResolvedValue(undefined);
1493
+ (child_process.execSync as Mock).mockReturnValue("");
1494
+ const result = await (service as any).cloneSpecRepo(
1495
+ "https://github.com/org/repo.git",
1496
+ "references",
1497
+ );
1498
+ expect(result).toContain("org__repo/references");
1499
+ });
1500
+
1419
1501
  it("should handle clone failure", async () => {
1420
1502
  (access as Mock).mockRejectedValue(new Error("not found"));
1421
1503
  (mkdir as Mock).mockResolvedValue(undefined);
@@ -1451,7 +1533,7 @@ const MAX_COUNT = 100;
1451
1533
  },
1452
1534
  ];
1453
1535
  const changedFiles = [{ filename: "Makefile" }, { filename: "src/app.ts" }];
1454
- const result = service.filterApplicableSpecs(specs, changedFiles);
1536
+ const result = service.filterApplicableSpecs(specs, ChangedFileCollection.from(changedFiles));
1455
1537
  expect(result).toHaveLength(1);
1456
1538
  });
1457
1539
 
@@ -1469,7 +1551,7 @@ const MAX_COUNT = 100;
1469
1551
  },
1470
1552
  ];
1471
1553
  const changedFiles = [{}];
1472
- const result = service.filterApplicableSpecs(specs, changedFiles);
1554
+ const result = service.filterApplicableSpecs(specs, ChangedFileCollection.from(changedFiles));
1473
1555
  expect(result).toHaveLength(0);
1474
1556
  });
1475
1557
  });
@@ -7,19 +7,27 @@ import {
7
7
  type RemoteRepoRef,
8
8
  type RepositoryContent,
9
9
  } from "@spaceflow/core";
10
- import { readdir, readFile, mkdir, access, writeFile } from "fs/promises";
11
- import { join, basename, extname } from "path";
10
+ import { ChangedFileCollection } from "../changed-file-collection";
11
+ import { readdir, readFile, mkdir, access, writeFile, unlink } from "fs/promises";
12
+ import { join, basename } from "path";
12
13
  import { homedir } from "os";
13
- import { execSync } from "child_process";
14
+ import { execSync, execFileSync } from "child_process";
14
15
  import micromatch from "micromatch";
15
16
  import { ReviewSpec, ReviewRule, RuleExample, Severity } from "./types";
16
17
  import { extractGlobsFromIncludes } from "../review-includes-filter";
17
18
 
18
- /** 远程规则缓存 TTL(毫秒),默认 5 分钟 */
19
- const REMOTE_SPEC_CACHE_TTL = 5 * 60 * 1000;
20
-
21
19
  export class ReviewSpecService {
22
20
  constructor(protected readonly gitProvider?: GitProviderService) {}
21
+
22
+ protected normalizeServerUrl(url: string): string {
23
+ return url.trim().replace(/\/+$/, "");
24
+ }
25
+
26
+ protected logVerbose(verbose: VerboseLevel | undefined, level: number, message: string): void {
27
+ if (shouldLog(verbose, level as VerboseLevel)) {
28
+ console.log(message);
29
+ }
30
+ }
23
31
  /**
24
32
  * 检查规则 ID 是否匹配(精确匹配或前缀匹配)
25
33
  * 例如: "JsTs.FileName" 匹配 "JsTs.FileName" 和 "JsTs.FileName.UpperCamel"
@@ -58,17 +66,8 @@ export class ReviewSpecService {
58
66
  * 根据变更文件的扩展名过滤适用的规则文件
59
67
  * 只按扩展名过滤,includes 和 override 在 LLM 审查后处理
60
68
  */
61
- filterApplicableSpecs(specs: ReviewSpec[], changedFiles: { filename?: string }[]): ReviewSpec[] {
62
- const changedExtensions = new Set<string>();
63
-
64
- for (const file of changedFiles) {
65
- if (file.filename) {
66
- const ext = extname(file.filename).slice(1).toLowerCase();
67
- if (ext) {
68
- changedExtensions.add(ext);
69
- }
70
- }
71
- }
69
+ filterApplicableSpecs(specs: ReviewSpec[], changedFiles: ChangedFileCollection): ReviewSpec[] {
70
+ const changedExtensions = changedFiles.extensions();
72
71
 
73
72
  console.log(
74
73
  `[filterApplicableSpecs] changedExtensions=${JSON.stringify([...changedExtensions])}, specs count=${specs.length}`,
@@ -114,58 +113,249 @@ export class ReviewSpecService {
114
113
  return specs;
115
114
  }
116
115
 
117
- async resolveSpecSources(sources: string[]): Promise<string[]> {
116
+ async resolveSpecSources(sources: string[], verbose?: VerboseLevel): Promise<string[]> {
118
117
  const dirs: string[] = [];
119
118
 
120
119
  for (const source of sources) {
121
- // 优先尝试解析为远程仓库 URL(浏览器复制的链接)
120
+ this.logVerbose(verbose, 3, ` 🔎 规则来源: ${source}`);
122
121
  const repoRef = parseRepoUrl(source);
122
+ if (repoRef) {
123
+ this.logVerbose(
124
+ verbose,
125
+ 3,
126
+ ` 解析远程仓库: ${repoRef.serverUrl}/${repoRef.owner}/${repoRef.repo} path=${repoRef.path || "(root)"} ref=${repoRef.ref || "(default)"}`,
127
+ );
128
+ } else {
129
+ this.logVerbose(verbose, 3, ` 非仓库 URL,按本地目录处理`);
130
+ }
123
131
  if (repoRef && this.gitProvider) {
124
- const dir = await this.fetchRemoteSpecs(repoRef);
132
+ this.logVerbose(verbose, 3, ` 尝试方式 #1: Git Provider API`);
133
+ const dir = await this.fetchRemoteSpecs(repoRef, verbose);
125
134
  if (dir) {
126
135
  dirs.push(dir);
136
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: Git Provider API -> ${dir}`);
127
137
  continue;
128
138
  }
139
+ this.logVerbose(verbose, 3, ` ❌ Git Provider API 未获取到规则,继续尝试`);
140
+ }
141
+ if (repoRef) {
142
+ this.logVerbose(verbose, 3, ` 尝试方式 #2: tea api`);
143
+ const teaDir = await this.fetchRemoteSpecsViaTea(repoRef, verbose);
144
+ if (teaDir) {
145
+ dirs.push(teaDir);
146
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: tea api -> ${teaDir}`);
147
+ continue;
148
+ }
149
+ this.logVerbose(verbose, 3, ` ❌ tea api 未获取到规则,继续尝试`);
150
+ }
151
+ // API 拉取失败或未配置 provider 时,回退到 git clone(使用仓库根 URL,而非目录 URL)
152
+ if (repoRef) {
153
+ this.logVerbose(verbose, 3, ` 尝试方式 #3: git clone 回退`);
154
+ const fallbackCloneUrl = this.buildRepoCloneUrl(repoRef);
155
+ this.logVerbose(verbose, 3, ` clone URL: ${fallbackCloneUrl}`);
156
+ const fallbackDir = await this.cloneSpecRepo(fallbackCloneUrl, repoRef.path, verbose);
157
+ if (fallbackDir) {
158
+ dirs.push(fallbackDir);
159
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: git clone 回退 -> ${fallbackDir}`);
160
+ continue;
161
+ }
162
+ this.logVerbose(verbose, 3, ` ❌ git clone 回退失败`);
129
163
  }
130
164
  if (this.isRepoUrl(source)) {
131
- const dir = await this.cloneSpecRepo(source);
165
+ this.logVerbose(verbose, 3, ` 尝试方式 #4: 直接 clone 来源 URL`);
166
+ const dir = await this.cloneSpecRepo(source, undefined, verbose);
132
167
  if (dir) {
133
168
  dirs.push(dir);
169
+ this.logVerbose(verbose, 2, ` ✅ 采用方式: 直接 clone 来源 URL -> ${dir}`);
170
+ } else {
171
+ this.logVerbose(verbose, 3, ` ❌ 直接 clone 来源 URL 失败`);
134
172
  }
135
173
  } else {
136
174
  // 检查是否是 deps 目录,如果是则扫描子目录的 references
137
175
  const resolvedDirs = await this.resolveDepsDir(source);
138
176
  dirs.push(...resolvedDirs);
177
+ this.logVerbose(
178
+ verbose,
179
+ 3,
180
+ ` deps 目录解析结果: ${resolvedDirs.length > 0 ? resolvedDirs.join(", ") : "(空)"}`,
181
+ );
139
182
  }
140
183
  }
141
184
 
142
185
  return dirs;
143
186
  }
144
187
 
188
+ protected buildRemoteSpecDir(ref: RemoteRepoRef): string {
189
+ const dirKey = `${ref.owner}__${ref.repo}${ref.path ? `__${ref.path.replace(/\//g, "_")}` : ""}${ref.ref ? `@${ref.ref}` : ""}`;
190
+ return join(homedir(), ".spaceflow", "review-spec", dirKey);
191
+ }
192
+
193
+ protected async getLocalSpecsDir(dir: string): Promise<string | null> {
194
+ try {
195
+ const entries = await readdir(dir);
196
+ if (!entries.some((f) => f.endsWith(".md"))) {
197
+ return null;
198
+ }
199
+ return dir;
200
+ } catch {
201
+ return null;
202
+ }
203
+ }
204
+
205
+ protected async prepareRemoteSpecDirForWrite(dir: string): Promise<void> {
206
+ await mkdir(dir, { recursive: true });
207
+ try {
208
+ const entries = await readdir(dir);
209
+ for (const name of entries) {
210
+ if (name.endsWith(".md") || name === ".timestamp") {
211
+ await unlink(join(dir, name));
212
+ }
213
+ }
214
+ } catch {
215
+ // 忽略目录清理失败,后续写入时再处理
216
+ }
217
+ }
218
+
219
+ protected isTeaInstalled(): boolean {
220
+ try {
221
+ execSync("command -v tea", { stdio: "pipe" });
222
+ return true;
223
+ } catch {
224
+ return false;
225
+ }
226
+ }
227
+
228
+ protected getTeaLoginForServer(serverUrl: string): string | null {
229
+ try {
230
+ const output = execFileSync("tea", ["login", "list", "-o", "json"], {
231
+ encoding: "utf-8",
232
+ stdio: "pipe",
233
+ });
234
+ const normalizedServerUrl = this.normalizeServerUrl(serverUrl);
235
+ const logins = JSON.parse(output) as Array<{ name?: string; url?: string }>;
236
+ const matched = logins.find(
237
+ (login) => login.url && this.normalizeServerUrl(login.url) === normalizedServerUrl,
238
+ );
239
+ return matched?.name ?? null;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ protected runTeaApi(endpoint: string, loginName: string): string {
246
+ const args = ["api", "-l", loginName, endpoint];
247
+ return execFileSync("tea", args, {
248
+ encoding: "utf-8",
249
+ stdio: "pipe",
250
+ });
251
+ }
252
+
253
+ protected encodePathSegments(path: string): string {
254
+ if (!path) return "";
255
+ return path
256
+ .split("/")
257
+ .filter(Boolean)
258
+ .map((segment) => encodeURIComponent(segment))
259
+ .join("/");
260
+ }
261
+
262
+ protected buildTeaContentsEndpoint(ref: RemoteRepoRef): string {
263
+ const owner = encodeURIComponent(ref.owner);
264
+ const repo = encodeURIComponent(ref.repo);
265
+ const encodedPath = this.encodePathSegments(ref.path || "");
266
+ const pathPart = encodedPath ? `/${encodedPath}` : "";
267
+ const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
268
+ return `/repos/${owner}/${repo}/contents${pathPart}${query}`;
269
+ }
270
+
271
+ protected buildTeaRawFileEndpoint(ref: RemoteRepoRef, filePath: string): string {
272
+ const owner = encodeURIComponent(ref.owner);
273
+ const repo = encodeURIComponent(ref.repo);
274
+ const encodedFilePath = this.encodePathSegments(filePath);
275
+ const query = ref.ref ? `?ref=${encodeURIComponent(ref.ref)}` : "";
276
+ return `/repos/${owner}/${repo}/raw/${encodedFilePath}${query}`;
277
+ }
278
+
145
279
  /**
146
- * 通过 Git API 从远程仓库拉取规则文件
147
- * 缓存到 ~/.spaceflow/review-spec-cache/ 目录,带 TTL
280
+ * 使用 tea api 拉取远程规则
281
+ * 前置条件:本地安装 tea 且已登录目标服务器
148
282
  */
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
- // 缓存不存在或无效,继续拉取
283
+ protected async fetchRemoteSpecsViaTea(
284
+ ref: RemoteRepoRef,
285
+ verbose?: VerboseLevel,
286
+ ): Promise<string | null> {
287
+ if (!this.isTeaInstalled()) {
288
+ this.logVerbose(verbose, 3, ` tea 不可用(未安装)`);
289
+ return null;
290
+ }
291
+ const loginName = this.getTeaLoginForServer(ref.serverUrl);
292
+ if (!loginName) {
293
+ this.logVerbose(
294
+ verbose,
295
+ 3,
296
+ ` tea 未登录目标服务器: ${this.normalizeServerUrl(ref.serverUrl)}`,
297
+ );
298
+ return null;
299
+ }
300
+ this.logVerbose(verbose, 3, ` tea 登录名: ${loginName}`);
301
+ const specDir = this.buildRemoteSpecDir(ref);
302
+ this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
303
+ try {
304
+ console.log(
305
+ ` 📡 使用 tea 拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
306
+ );
307
+ const contentsEndpoint = this.buildTeaContentsEndpoint(ref);
308
+ this.logVerbose(verbose, 3, ` tea api endpoint(contents): ${contentsEndpoint}`);
309
+ const contentsRaw = this.runTeaApi(contentsEndpoint, loginName);
310
+ const contents = JSON.parse(contentsRaw) as Array<{
311
+ type?: string;
312
+ name?: string;
313
+ path?: string;
314
+ }>;
315
+ const mdFiles = contents.filter(
316
+ (f) => f.type === "file" && !!f.name && f.name.endsWith(".md") && !!f.path,
317
+ );
318
+ if (mdFiles.length === 0) {
319
+ console.warn(" ⚠️ tea 远程目录中未找到 .md 规则文件");
320
+ return null;
167
321
  }
322
+ const fetchedFiles: Array<{ name: string; content: string }> = [];
323
+ for (const file of mdFiles) {
324
+ const fileEndpoint = this.buildTeaRawFileEndpoint(ref, file.path!);
325
+ this.logVerbose(verbose, 3, ` tea api endpoint(raw): ${fileEndpoint}`);
326
+ const fileContent = this.runTeaApi(fileEndpoint, loginName);
327
+ fetchedFiles.push({ name: file.name!, content: fileContent });
328
+ }
329
+ await this.prepareRemoteSpecDirForWrite(specDir);
330
+ for (const file of fetchedFiles) {
331
+ await writeFile(join(specDir, file.name), file.content, "utf-8");
332
+ }
333
+ console.log(` ✅ 已通过 tea 拉取 ${mdFiles.length} 个规则文件到本地目录`);
334
+ return specDir;
335
+ } catch (error) {
336
+ console.warn(` ⚠️ tea 拉取规则失败:`, error instanceof Error ? error.message : error);
337
+ const localDir = await this.getLocalSpecsDir(specDir);
338
+ if (localDir) {
339
+ const mdCount = await this.getSpecFileCount(localDir);
340
+ this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
341
+ console.log(` 📦 使用本地已存在规则目录`);
342
+ return localDir;
343
+ }
344
+ this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
345
+ return null;
168
346
  }
347
+ }
348
+
349
+ /**
350
+ * 通过 Git API 从远程仓库拉取规则文件
351
+ * 保存到 ~/.spaceflow/review-spec/ 目录
352
+ */
353
+ protected async fetchRemoteSpecs(
354
+ ref: RemoteRepoRef,
355
+ verbose?: VerboseLevel,
356
+ ): Promise<string | null> {
357
+ const specDir = this.buildRemoteSpecDir(ref);
358
+ this.logVerbose(verbose, 3, ` 本地规则目录: ${specDir}`);
169
359
  try {
170
360
  console.log(
171
361
  ` 📡 从远程仓库拉取规则: ${ref.owner}/${ref.repo}${ref.path ? `/${ref.path}` : ""}${ref.ref ? `@${ref.ref}` : ""}`,
@@ -183,7 +373,7 @@ export class ReviewSpecService {
183
373
  console.warn(` ⚠️ 远程目录中未找到 .md 规则文件`);
184
374
  return null;
185
375
  }
186
- await mkdir(cacheDir, { recursive: true });
376
+ const fetchedFiles: Array<{ name: string; content: string }> = [];
187
377
  for (const file of mdFiles) {
188
378
  const content = await this.gitProvider!.getFileContent(
189
379
  ref.owner,
@@ -191,28 +381,37 @@ export class ReviewSpecService {
191
381
  file.path,
192
382
  ref.ref,
193
383
  );
194
- await writeFile(join(cacheDir, file.name), content, "utf-8");
384
+ fetchedFiles.push({ name: file.name, content });
195
385
  }
196
- // 写入时间戳
197
- await writeFile(join(cacheDir, ".timestamp"), String(Date.now()), "utf-8");
198
- console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到缓存`);
199
- return cacheDir;
386
+ await this.prepareRemoteSpecDirForWrite(specDir);
387
+ for (const file of fetchedFiles) {
388
+ await writeFile(join(specDir, file.name), file.content, "utf-8");
389
+ }
390
+ console.log(` ✅ 已拉取 ${mdFiles.length} 个规则文件到本地目录`);
391
+ return specDir;
200
392
  } catch (error) {
201
393
  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
- // 无缓存可用
394
+ const localDir = await this.getLocalSpecsDir(specDir);
395
+ if (localDir) {
396
+ const mdCount = await this.getSpecFileCount(localDir);
397
+ this.logVerbose(verbose, 3, ` 本地目录命中: ${localDir} (.md=${mdCount})`);
398
+ console.log(` 📦 使用本地已存在规则目录`);
399
+ return localDir;
211
400
  }
401
+ this.logVerbose(verbose, 3, ` 本地目录未命中: ${specDir}`);
212
402
  return null;
213
403
  }
214
404
  }
215
405
 
406
+ protected async getSpecFileCount(dir: string): Promise<number> {
407
+ try {
408
+ const entries = await readdir(dir);
409
+ return entries.filter((f) => f.endsWith(".md")).length;
410
+ } catch {
411
+ return 0;
412
+ }
413
+ }
414
+
216
415
  /**
217
416
  * 解析 deps 目录,扫描子目录中的 references 文件夹
218
417
  * 如果目录本身包含 .md 文件则直接返回,否则扫描子目录
@@ -268,7 +467,30 @@ export class ReviewSpecService {
268
467
  );
269
468
  }
270
469
 
271
- protected async cloneSpecRepo(repoUrl: string): Promise<string | null> {
470
+ protected buildRepoCloneUrl(ref: RemoteRepoRef): string {
471
+ return `${ref.serverUrl}/${ref.owner}/${ref.repo}.git`;
472
+ }
473
+
474
+ protected async resolveClonedSpecDir(cacheDir: string, subPath?: string): Promise<string> {
475
+ const normalizedSubPath = subPath?.trim().replace(/^\/+|\/+$/g, "");
476
+ if (!normalizedSubPath) {
477
+ return cacheDir;
478
+ }
479
+ const targetDir = join(cacheDir, normalizedSubPath);
480
+ try {
481
+ await access(targetDir);
482
+ return targetDir;
483
+ } catch {
484
+ console.warn(` 警告: 克隆仓库中未找到子目录 ${normalizedSubPath},改为使用仓库根目录`);
485
+ return cacheDir;
486
+ }
487
+ }
488
+
489
+ protected async cloneSpecRepo(
490
+ repoUrl: string,
491
+ subPath?: string,
492
+ verbose?: VerboseLevel,
493
+ ): Promise<string | null> {
272
494
  const repoName = this.extractRepoName(repoUrl);
273
495
  if (!repoName) {
274
496
  console.warn(`警告: 无法解析仓库名称: ${repoUrl}`);
@@ -276,24 +498,27 @@ export class ReviewSpecService {
276
498
  }
277
499
 
278
500
  const cacheDir = join(homedir(), ".spaceflow", "review-spec", repoName);
501
+ this.logVerbose(verbose, 3, ` clone 目标目录: ${cacheDir}`);
279
502
 
280
503
  try {
281
504
  await access(cacheDir);
282
505
  // console.log(` 使用缓存的规则仓库: ${cacheDir}`);
506
+ this.logVerbose(verbose, 3, ` 发现已存在仓库目录,尝试 git pull`);
283
507
  try {
284
508
  execSync("git pull --ff-only", { cwd: cacheDir, stdio: "pipe" });
285
509
  // console.log(` 已更新规则仓库`);
286
510
  } catch {
287
511
  console.warn(` 警告: 无法更新规则仓库,使用现有版本`);
288
512
  }
289
- return cacheDir;
513
+ return this.resolveClonedSpecDir(cacheDir, subPath);
290
514
  } catch {
291
515
  // console.log(` 克隆规则仓库: ${repoUrl}`);
292
516
  try {
517
+ this.logVerbose(verbose, 3, ` 本地仓库目录不存在,执行 git clone`);
293
518
  await mkdir(join(homedir(), ".spaceflow", "review-spec"), { recursive: true });
294
519
  execSync(`git clone --depth 1 "${repoUrl}" "${cacheDir}"`, { stdio: "pipe" });
295
520
  // console.log(` 克隆完成: ${cacheDir}`);
296
- return cacheDir;
521
+ return this.resolveClonedSpecDir(cacheDir, subPath);
297
522
  } catch (error) {
298
523
  console.warn(`警告: 无法克隆仓库 ${repoUrl}:`, error);
299
524
  return null;
@@ -302,6 +527,11 @@ export class ReviewSpecService {
302
527
  }
303
528
 
304
529
  protected extractRepoName(repoUrl: string): string | null {
530
+ const parsedRef = parseRepoUrl(repoUrl);
531
+ if (parsedRef) {
532
+ return `${parsedRef.owner}__${parsedRef.repo}`;
533
+ }
534
+
305
535
  let path = repoUrl;
306
536
  path = path.replace(/\.git$/, "");
307
537
  path = path.replace(/^git@[^:]+:/, "");