ai-spec-dev 0.25.0 → 0.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ {
2
+ "permissions": {
3
+ "allow": [
4
+ "Bash(npm run:*)",
5
+ "Bash(python3 -c \":*)",
6
+ "Bash(grep -E \"^./\\\\w+/|^./\\\\w+\\\\.ts$\")",
7
+ "Bash(wc -l core/*.ts prompts/*.ts)"
8
+ ]
9
+ }
10
+ }
package/README.md CHANGED
@@ -2,12 +2,9 @@
2
2
 
3
3
  > AI 驱动的功能开发编排工具 — 从一句话需求到可运行代码的完整流水线,支持单 Repo 及多 Repo 跨端联动
4
4
 
5
- [![Version](https://img.shields.io/badge/version-0.25.0-blue)](https://github.com/hzhongzhong/ai-spec)
6
- [![Author](https://img.shields.io/badge/author-hzhongzhong-green)](https://github.com/hzhongzhong)
7
-
8
5
  **单 Repo 模式:**
9
6
  ```
10
- 需求描述 → 项目宪法 → 项目感知 → Spec+Tasks → 交互式润色(Diff预览) → Spec质量预评估 → Approval Gate → DSL提取+校验 → Git 隔离 → 代码生成(同层并行) → TDD测试(--tdd) / 测试骨架 → 错误反馈自动修复 → 2-pass 代码审查 → 经验积累(宪法§9)
7
+ 需求描述 → 项目宪法 → 项目感知 → Spec+Tasks → 交互式润色(Diff预览) → Spec质量预评估 → Approval Gate → DSL提取+校验 → Git 隔离 → 代码生成(同层并行) → TDD测试(--tdd) / 测试骨架 → 错误反馈自动修复 → 3-pass 代码审查 → 经验积累(宪法§9)
11
8
  ```
12
9
 
13
10
  **多 Repo 模式(工作区):**
@@ -94,9 +91,16 @@ ai-spec create "给用户模块增加登录功能"
94
91
  [cycle 2/2] Running tests: npm test
95
92
  ✔ Tests passed.
96
93
  ✔ All checks passed after 2 cycle(s).
97
- [9/9] Automated code review (file-based)...
94
+ [9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)...
95
+ Pass 1/3: Architecture review...
96
+ Pass 2/3: Implementation review...
97
+ Pass 3/3: Impact & complexity assessment...
98
+ 🌊 影响等级: 中 🧮 复杂度等级: 低
98
99
  ─── Knowledge Memory ─────────────────────────────
99
100
  ✔ 2 lesson(s) appended to constitution (§9).
101
+ Run ID: 20260326-143022-ab3f in 94.3s · 8 files written
102
+ Log : .ai-spec-logs/20260326-143022-ab3f.json
103
+ To undo changes: ai-spec restore 20260326-143022-ab3f
100
104
  ```
101
105
 
102
106
  ---
@@ -153,7 +157,8 @@ ai-spec update [change] 增量更新:修改现有 Spec → 重提取 DSL
153
157
  ai-spec learn [lesson] 零摩擦知识注入:直接将工程决策或教训写入宪法 §9(无需运行 review)
154
158
  ai-spec export DSL → OpenAPI 3.1.0 YAML/JSON(可导入 Postman / Swagger UI / openapi-generator)
155
159
  ai-spec mock 从 DSL 生成 Mock Server / 前端 Proxy 配置 / MSW Handlers(联调利器)
156
- ai-spec review [file] 对当前 git diff 运行 2-pass AI 代码审查(架构层 + 实现层),并打印评分趋势
160
+ ai-spec review [file] 对当前 git diff 运行 3-pass AI 代码审查(架构层 + 实现层 + 影响面/复杂度),并打印评分趋势
161
+ ai-spec restore <runId> 回滚指定 run 修改的所有文件至原始状态(配合 Run ID 使用)
157
162
  ai-spec model 交互式切换 AI provider / model,写入 .ai-spec.json
158
163
  ai-spec config 查看 / 修改 / 重置项目级配置
159
164
  ai-spec workspace init 初始化多 Repo 工作区(生成 .ai-spec-workspace.json)
package/RELEASE_LOG.md CHANGED
@@ -2,6 +2,108 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## [0.28.0] 2026-03-26 — 三 Pass 代码审查(影响面评估 + 代码复杂度评估)
6
+
7
+ ### 新增内容
8
+
9
+ **Feature — Review Pass 3:影响面评估 + 代码复杂度评估**
10
+ - 文件:`prompts/codegen.prompt.ts`(新增 `reviewImpactComplexitySystemPrompt`)、`core/reviewer.ts`(两 Pass 升级为三 Pass)
11
+ - 原有两 Pass 不变;新增第三 Pass 专注于两个前两 Pass 刻意跳过的维度:
12
+
13
+ **影响面评估 (Impact Assessment)**
14
+ - 直接影响文件列表
15
+ - 间接影响范围(哪些模块/消费方/下游服务受影响)
16
+ - 破坏性变更检测(接口签名变更、Schema 变更、配置变更、导出重命名)
17
+ - 影响等级:低 / 中 / 高(附理由)
18
+
19
+ **代码复杂度评估 (Complexity Assessment)**
20
+ - 认知复杂度热点(最难理解的 1-3 个函数,说明为什么复杂)
21
+ - 耦合度分析(依赖注入 vs 硬编码、循环依赖风险)
22
+ - 可维护性风险(魔法数字、业务逻辑藏在生命周期钩子里、隐式时序耦合)
23
+ - 复杂度等级:低 / 中 / 高(附理由)
24
+
25
+ - `ReviewHistoryEntry` 新增 `impactLevel` 和 `complexityLevel` 字段,历史记录持久化到 `.ai-spec-reviews.json`
26
+ - CLI banner 更新为 `3-pass: architecture + implementation + impact/complexity`
27
+
28
+ ---
29
+
30
+ ## [0.27.0] 2026-03-26 — 三项工业化底座(Provider 可靠性、文件快照回滚、RunId 结构化日志)
31
+
32
+ ### 新增内容
33
+
34
+ **Feature #1 — Provider 统一可靠性封装**
35
+ - 新文件:`core/provider-utils.ts`
36
+ - 新增 `withReliability(fn, opts)` 包装器,覆盖所有 provider 的 `generate()` 调用(Gemini、Claude、OpenAI-compatible、MiMo)
37
+ - 能力:超时(默认 90s)+ 自动重试(2 次,退避 2s/6s)+ 结构化错误分类(`auth` / `rate_limit` / `timeout` / `network` / `provider`)
38
+ - Auth 错误(401/403)不重试,避免无效消耗;限流(429)和网络抖动均自动重试并打印黄色警告
39
+
40
+ **Feature #2 — 文件写入快照与一键回滚**
41
+ - 新文件:`core/run-snapshot.ts`
42
+ - 每次 `create` 运行前,自动备份将被覆盖的文件到 `.ai-spec-backup/<runId>/`
43
+ - 新增命令:`ai-spec restore <runId>`,将本次运行修改的所有文件恢复到原始状态
44
+ - 涵盖 codegen 写入(`code-generator.ts`)和错误修复写入(`error-feedback.ts`)两个落盘点
45
+ - 纯新建文件不备份(无需恢复);同一文件多次写入只备份一次(保留原始版本)
46
+
47
+ **Feature #3 — RunId + 结构化执行日志**
48
+ - 新文件:`core/run-logger.ts`
49
+ - 每次运行生成唯一 RunId(格式:`YYYYMMDD-HHMMSS-xxxx`),打印在 banner 下方
50
+ - 执行阶段、写入文件、错误信息实时写入 `.ai-spec-logs/<runId>.json`
51
+ - 运行结束时打印摘要:RunId + 耗时 + 写入文件数 + 错误数 + 日志路径
52
+ - 有文件被修改时自动提示:`To undo changes: ai-spec restore <runId>`
53
+
54
+ ---
55
+
56
+ ## [0.26.0] 2026-03-26 — 三项稳定性修复(多仓库 review、并行 batch 容错、tasks JSON 损坏)
57
+
58
+ ### 修复内容
59
+
60
+ **Fix #1 — 多仓库模式代码审查 git diff 为空**
61
+ - 文件:`cli/index.ts` → `runSingleRepoPipelineInWorkspace`
62
+ - 问题:`reviewer.reviewCode()` 内部调用 `execSync("git diff")`,运行在 `process.cwd()`(CLI 启动目录)而非当前 repo 的 `workingDir`(可能是 worktree 路径),导致 diff 为空或错误,审查结果没有意义。
63
+ - 修复:在 `reviewCode` 调用前后加 `process.chdir(workingDir)` / `process.chdir(originalDir)`,与单仓库模式保持一致。
64
+
65
+ **Fix #2 — 并行 batch 单任务抛异常导致整层崩溃**
66
+ - 文件:`core/code-generator.ts` → `runApiModeWithTasks` batch 执行循环
67
+ - 问题:`Promise.all(batchResultPromises)` 中任意一个 `executeTask` 抛出未捕获异常(磁盘满、mkdir 失败、provider 超时),整个 `Promise.all` 立即 reject,该层剩余所有任务都被丢弃,没有任何降级处理。
68
+ - 修复:每个 `executeTask` 调用后追加 `.catch((err) => ...)` 返回失败结果对象,确保单任务失败只影响自身,不中断同批次其他任务。
69
+
70
+ **Fix #3 — `loadTasksForSpec` 遇到损坏的 JSON 文件直接崩溃**
71
+ - 文件:`core/task-generator.ts` → `loadTasksForSpec`
72
+ - 问题:如果上次运行中途中断导致 `*-tasks.json` 是不完整的 JSON,`fs.readJson()` 抛出 parse 错误,没有任何 try-catch 包裹,用户看到的是裸 JS 异常而非友好提示。
73
+ - 修复:加 try-catch,catch 块打印"Tasks file corrupt,请重新运行 `ai-spec tasks`"并返回 `null`(触发重新生成),不再崩溃。
74
+
75
+ ---
76
+
77
+ ## [0.25.0] 2026-03-26 — 三项上下文提取修复(HTTP import、分页示例、工具崩溃误判)
78
+
79
+ ### 修复内容
80
+
81
+ **Fix #1 — HTTP import 幻觉防护失效**
82
+ - 文件:`core/frontend-context-loader.ts` → `httpImportRegex`
83
+ - 问题:旧正则只匹配 `axios`、`ky`、`@/` 开头的路径。使用 `import request from '@/utils/request'` 等自定义封装的项目(极其常见)提取结果为 `undefined`,AI 会自由发挥 import 路径。
84
+ - 修复:扩展匹配范围:
85
+ - 所有项目别名:`@/`、`~/`、`#/`、`@@/`
86
+ - 包含 http/request/fetch/client/api 关键词的相对路径
87
+ - 完整 HTTP 库列表:axios、ky、ky-universal、undici、node-fetch、cross-fetch、got、superagent、alova、openapi-fetch
88
+ - 排除 `import type` 语句(它们不是运行时 import)
89
+
90
+ **Fix #2 — 分页示例提取正则永远不匹配**
91
+ - 文件:`core/frontend-context-loader.ts` → 分页提取块
92
+ - 问题:
93
+ 1. 接口正则用 `[^}]*` 匹配接口体,遇到嵌套对象 `{ field: { ... } }` 立即截断
94
+ 2. 函数正则用 `\n\}` 匹配闭合括号,但缩进的 ` }` 永远不匹配
95
+ 3. 只处理 `export function`,遗漏了现代代码中更常见的 `export const x = () =>` 写法
96
+ - 修复:完全重写为**逐行 + 括号深度计数器**的两步提取法:
97
+ - Step 1:找到带分页字段(pageIndex/pageSize/page/…)的接口,用深度计数捕获完整块(支持嵌套对象)
98
+ - Step 2:找到引用该接口的导出函数(同时支持 `export function` 和 `export const = () =>`),同样用深度计数捕获函数体
99
+
100
+ **Fix #3 — `isToolCrash` 把用户代码错误当工具崩溃**
101
+ - 文件:`core/error-feedback.ts`
102
+ - 问题:旧判断条件是"输出包含 ReferenceError/TypeError 且包含 node_modules"。TypeScript 的自动修复测试运行时,测试框架的 stack trace 中也会包含 node_modules,导致用户自己代码里的 ReferenceError 被误判为工具崩溃跳过。
103
+ - 修复:改为精确判断:必须同时满足(1)存在未捕获 JS 错误,且(2)stack trace 中有 `at … node_modules/…` 帧——即错误起源于工具二进制本身,而不仅仅是"输出中某处出现了 node_modules"。
104
+
105
+ ---
106
+
5
107
  ## [0.24.0] 2026-03-25 — 四项质量修复(lesson 计数、export default、impliesRegistration、依赖拓扑排序)
6
108
 
7
109
  ### 修复内容
package/cli/index.ts CHANGED
@@ -72,6 +72,8 @@ import {
72
72
  } from "../core/mock-server-generator";
73
73
  import { SpecUpdater } from "../core/spec-updater";
74
74
  import { exportOpenApi } from "../core/openapi-exporter";
75
+ import { generateRunId, RunLogger, setActiveLogger } from "../core/run-logger";
76
+ import { RunSnapshot, setActiveSnapshot } from "../core/run-snapshot";
75
77
 
76
78
  // ─── Config File ──────────────────────────────────────────────────────────────
77
79
 
@@ -293,6 +295,17 @@ program
293
295
  codegenModel: codegenModelName,
294
296
  });
295
297
 
298
+ // ── Run tracking ──────────────────────────────────────────────────────────────
299
+ const runId = generateRunId();
300
+ console.log(chalk.gray(` Run ID: ${runId}`));
301
+ const runSnapshot = new RunSnapshot(currentDir, runId);
302
+ setActiveSnapshot(runSnapshot);
303
+ const runLogger = new RunLogger(currentDir, runId, {
304
+ provider: specProviderName,
305
+ model: specModelName,
306
+ });
307
+ setActiveLogger(runLogger);
308
+
296
309
  // ── Step 1: Context ───────────────────────────────────────────────────────
297
310
  console.log(chalk.blue("[1/6] Loading project context..."));
298
311
  const loader = new ContextLoader(currentDir);
@@ -606,7 +619,7 @@ program
606
619
  // ── Step 9: Code Review ───────────────────────────────────────────────────
607
620
  let reviewResult = "";
608
621
  if (!opts.skipReview) {
609
- console.log(chalk.blue("\n[9/9] Automated code review (2-pass: architecture + implementation)..."));
622
+ console.log(chalk.blue("\n[9/9] Automated code review (3-pass: architecture + implementation + impact/complexity)..."));
610
623
  const reviewer = new CodeReviewer(specProvider, currentDir);
611
624
  const savedSpec = await fs.readFile(specFile, "utf-8");
612
625
 
@@ -629,6 +642,7 @@ program
629
642
  }
630
643
 
631
644
  // ── Done ──────────────────────────────────────────────────────────────────
645
+ runLogger.finish();
632
646
  console.log(chalk.bold.green("\n✔ All done!"));
633
647
  console.log(chalk.gray(` Spec : ${specFile}`));
634
648
  if (savedDslFile) console.log(chalk.gray(` DSL : ${savedDslFile}`));
@@ -639,6 +653,10 @@ program
639
653
  if (workingDir !== currentDir) {
640
654
  console.log(chalk.gray(` Run \`cd ${workingDir}\` to enter the worktree.`));
641
655
  }
656
+ runLogger.printSummary();
657
+ if (runSnapshot.fileCount > 0) {
658
+ console.log(chalk.gray(` To undo changes: ai-spec restore ${runId}`));
659
+ }
642
660
  });
643
661
 
644
662
  // ═══════════════════════════════════════════════════════════════════════════════
@@ -1252,7 +1270,16 @@ async function runSingleRepoPipelineInWorkspace(opts: {
1252
1270
  console.log(chalk.blue(` [${repoName}] Running code review...`));
1253
1271
  try {
1254
1272
  const reviewer = new CodeReviewer(specProvider);
1255
- const reviewResult = await reviewer.reviewCode(finalSpec);
1273
+ // git diff must run in the repo's working directory (may be a worktree),
1274
+ // not in wherever the CLI was invoked from.
1275
+ const originalDir = process.cwd();
1276
+ let reviewResult: string;
1277
+ try {
1278
+ process.chdir(workingDir);
1279
+ reviewResult = await reviewer.reviewCode(finalSpec);
1280
+ } finally {
1281
+ process.chdir(originalDir);
1282
+ }
1256
1283
  await accumulateReviewKnowledge(specProvider, repoAbsPath, reviewResult);
1257
1284
  console.log(chalk.green(` Code review complete.`));
1258
1285
  } catch (err) {
@@ -2127,6 +2154,27 @@ program
2127
2154
  }
2128
2155
  });
2129
2156
 
2157
+ // ═══════════════════════════════════════════════════════════════════════════════
2158
+ // Command: restore
2159
+ // ═══════════════════════════════════════════════════════════════════════════════
2160
+
2161
+ program
2162
+ .command("restore")
2163
+ .description("Restore files modified by a previous run")
2164
+ .argument("<runId>", "Run ID shown at the end of a create / generate run")
2165
+ .action(async (runId: string) => {
2166
+ const currentDir = process.cwd();
2167
+ const snapshot = new RunSnapshot(currentDir, runId);
2168
+ console.log(chalk.blue(`Restoring run: ${runId}...`));
2169
+ const restored = await snapshot.restore();
2170
+ if (restored.length === 0) {
2171
+ console.log(chalk.yellow(" No backup found for this run ID."));
2172
+ } else {
2173
+ restored.forEach((f) => console.log(chalk.green(` ✔ restored: ${f}`)));
2174
+ console.log(chalk.bold.green(`\n✔ ${restored.length} file(s) restored.`));
2175
+ }
2176
+ });
2177
+
2130
2178
  // Show welcome screen when invoked with no arguments
2131
2179
  if (process.argv.length <= 2) {
2132
2180
  (async () => {
@@ -8,6 +8,8 @@ import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
8
8
  import { SpecTask, loadTasksForSpec, updateTaskStatus } from "./task-generator";
9
9
  import { loadDslForSpec, buildDslContextSection } from "./dsl-extractor";
10
10
  import { loadFrontendContext, buildFrontendContextSection } from "./frontend-context-loader";
11
+ import { getActiveSnapshot } from "./run-snapshot";
12
+ import { getActiveLogger } from "./run-logger";
11
13
 
12
14
  // ─── Shared Config Helper ───────────────────────────────────────────────────
13
15
 
@@ -720,7 +722,15 @@ Output ONLY a valid JSON array:
720
722
 
721
723
  for (const batch of taskBatches) {
722
724
  const batchIsParallel = batch.length > 1;
723
- const batchResultPromises = batch.map((task) => executeTask(task, batchIsParallel));
725
+ // Wrap each task in .catch() so a single unexpected failure (disk full,
726
+ // provider timeout, mkdir error) degrades gracefully instead of rejecting
727
+ // the entire Promise.all and aborting all sibling tasks in the batch.
728
+ const batchResultPromises = batch.map((task) =>
729
+ executeTask(task, batchIsParallel).catch((err): TaskResult => {
730
+ console.log(chalk.yellow(` ⚠ ${task.id} threw unexpectedly: ${(err as Error).message}`));
731
+ return { task, files: [], createdFiles: [], success: 0, total: 0, impliesRegistration: false };
732
+ })
733
+ );
724
734
  const batchResults = await Promise.all(batchResultPromises);
725
735
  layerResults.push(...batchResults);
726
736
  // Update cache after each batch so the next batch sees the exports.
@@ -838,8 +848,10 @@ ${existingContent || "Output only the complete file content."}`;
838
848
  try {
839
849
  const raw = await this.provider.generate(codePrompt, systemPrompt);
840
850
  const fileContent = stripCodeFences(raw);
851
+ await getActiveSnapshot()?.snapshotFile(fullPath);
841
852
  await fs.ensureDir(path.dirname(fullPath));
842
853
  await fs.writeFile(fullPath, fileContent, "utf-8");
854
+ getActiveLogger()?.fileWritten(item.file);
843
855
  console.log(`${prefix}${existingContent ? chalk.yellow("~") : chalk.green("+")} ${chalk.bold(item.file)} ${chalk.green("✔")}`);
844
856
  successCount++;
845
857
  writtenFiles.push(item.file);
@@ -6,6 +6,7 @@ import { AIProvider } from "./spec-generator";
6
6
  import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
7
7
  import { SpecDSL } from "./dsl-types";
8
8
  import { buildDslContextSection } from "./dsl-extractor";
9
+ import { getActiveSnapshot } from "./run-snapshot";
9
10
 
10
11
  // ─── Types ──────────────────────────────────────────────────────────────────────
11
12
 
@@ -242,6 +243,7 @@ Output ONLY the complete fixed file content. No markdown fences, no explanations
242
243
  try {
243
244
  const raw = await provider.generate(prompt, getCodeGenSystemPrompt());
244
245
  const fixed = raw.replace(/^```\w*\n?/gm, "").replace(/\n?```$/gm, "").trim();
246
+ await getActiveSnapshot()?.snapshotFile(fullPath);
245
247
  await fs.writeFile(fullPath, fixed, "utf-8");
246
248
  results.push({ fixed: true, file, explanation: `Fixed ${fileErrors.length} error(s)` });
247
249
  console.log(chalk.green(` ✔ Auto-fixed: ${file}`));
@@ -300,13 +302,22 @@ export async function runErrorFeedback(
300
302
  console.log(chalk.gray(`\n [cycle ${cycle}/${maxCycles}] Type-check: ${buildCmd}`));
301
303
  const buildResult = runCommand(buildCmd, workingDir);
302
304
  if (!buildResult.success) {
303
- // Detect tool crash (ReferenceError / TypeError inside node_modules)
304
- // this means the type-check tool itself is broken (e.g. vue-tsc version
305
- // incompatibility), NOT a user code error. Skip silently instead of
306
- // feeding garbage into the auto-fix loop.
307
- const isToolCrash =
308
- /ReferenceError:|TypeError:|SyntaxError:/.test(buildResult.output) &&
309
- buildResult.output.includes("node_modules");
305
+ // Detect tool crash the type-check binary itself threw an unhandled
306
+ // exception (e.g. vue-tsc / tsc version incompatibility).
307
+ //
308
+ // Two conditions must BOTH be true:
309
+ // 1. The output contains an uncaught JS error (ReferenceError, TypeError, …)
310
+ // 2. The stack trace has at least one frame inside node_modules,
311
+ // meaning the crash originated in the tool binary, not user code.
312
+ //
313
+ // TypeScript compilation errors from user code are formatted as
314
+ // "src/foo.ts:10:5 - error TS2345: …"
315
+ // and do NOT produce "at …" stack frames, so they are never misclassified.
316
+ const hasUncaughtError = /ReferenceError:|TypeError:|SyntaxError:/.test(buildResult.output);
317
+ const hasToolStackFrame = buildResult.output
318
+ .split("\n")
319
+ .some((l) => l.trim().startsWith("at ") && l.includes("node_modules"));
320
+ const isToolCrash = hasUncaughtError && hasToolStackFrame;
310
321
  if (isToolCrash) {
311
322
  console.log(chalk.yellow(` ⚠ Type-check tool crashed (possible version incompatibility). Skipping.`));
312
323
  console.log(chalk.gray(` Tip: run \`${buildCmd}\` manually to investigate.`));
@@ -299,10 +299,16 @@ export async function loadFrontendContext(
299
299
  }
300
300
  }
301
301
 
302
- // Extract the exact HTTP client import line from an existing API file
302
+ // Extract the exact HTTP client import line from an existing API file.
303
303
  // e.g. "import request from '@/utils/http'" or "import axios from 'axios'"
304
304
  // This is ground truth — prevents the AI from inventing a different import path.
305
- const httpImportRegex = /^import\s+(?:\w+|\{[^}]+\})\s+from\s+['"](@\/[^'"]+|axios|ky)['"]/m;
305
+ //
306
+ // Matches (in priority order):
307
+ // 1. Project alias imports: @/, ~/, #/, @@/ (common in Vite/webpack projects)
308
+ // 2. Named HTTP libraries: axios, ky, undici, node-fetch, etc.
309
+ // 3. Relative imports whose path contains http-utility keywords
310
+ const httpImportRegex =
311
+ /^import(?!\s+type)\s+(?:[\w*]+|\{[^}]+\})\s+from\s+['"]((?:@{1,2}|~|#)[/\\][^'"]+|\.{1,2}\/[^'"]*(?:http|request|fetch|client|api)[^'"]*|axios|ky(?:-universal)?|undici|node-fetch|cross-fetch|got|superagent|alova|openapi-fetch)['"]/im;
306
312
  for (const relPath of ctx.existingApiFiles.slice(0, 5)) {
307
313
  try {
308
314
  const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
@@ -316,36 +322,78 @@ export async function loadFrontendContext(
316
322
  }
317
323
  }
318
324
 
319
- // Pagination pattern extraction — find a real paginated list function from the existing codebase.
320
- // Scans API files for interfaces/params that contain pagination field names,
321
- // then extracts the matching function as ground truth for the AI to copy.
325
+ // Pagination pattern extraction — line-based scan for robustness.
326
+ //
327
+ // Two-step approach:
328
+ // 1. Find an interface/type whose name suggests a request/query shape AND
329
+ // whose body contains at least one pagination field name.
330
+ // Uses line-by-line scanning with a brace-depth counter so nested
331
+ // objects inside the interface don't confuse the closing-brace match.
332
+ // 2. Find the first exported function (regular or arrow) that references
333
+ // that interface type, then capture its body the same way.
334
+ //
335
+ // Handles both `export function` and `export const x = (...) =>` styles.
322
336
  const paginationFieldNames = ["pageIndex", "pageSize", "pageNum", "current", "page", "size", "offset", "limit"];
323
- const paginationInterfaceRegex = new RegExp(
324
- `(?:interface|type)\\s+(\\w*(?:Params|Query|Request|Filter|Page)\\w*)\\s*\\{[^}]*\\b(?:${paginationFieldNames.join("|")})\\b[^}]*\\}`,
325
- "s"
326
- );
327
- const apiExportFnRegex = /export\s+(?:async\s+)?function\s+\w+\s*\([^)]*\)[^{]*\{[\s\S]*?\n\}/g;
337
+
328
338
  for (const relPath of ctx.existingApiFiles) {
329
- // Skip type-only and index files
330
339
  if (/types?\.ts$|index\.ts$/.test(relPath)) continue;
331
340
  try {
332
341
  const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
333
- // Only process files that have pagination field names at all
334
342
  if (!paginationFieldNames.some((f) => content.includes(f))) continue;
335
- // Check this file has an interface with pagination fields
336
- const interfaceMatch = content.match(paginationInterfaceRegex);
337
- if (!interfaceMatch) continue;
338
- // Find the first exported function that uses this interface
339
- const interfaceName = interfaceMatch[1];
340
- const fnRegex = new RegExp(
341
- `export\\s+(?:async\\s+)?function\\s+\\w+\\s*\\(\\s*\\w+\\s*:\\s*${interfaceName}[^)]*\\)[\\s\\S]*?\\n\\}`,
342
- ""
343
- );
344
- const fnMatch = content.match(fnRegex);
345
- if (fnMatch) {
346
- ctx.paginationExample = `// From ${relPath}\n${interfaceMatch[0]}\n\n${fnMatch[0]}`;
343
+
344
+ const lines = content.split("\n");
345
+
346
+ // ── Step 1: locate a pagination interface/type ──────────────────────
347
+ let interfaceName = "";
348
+ let interfaceBlock = "";
349
+
350
+ for (let i = 0; i < lines.length; i++) {
351
+ const m = lines[i].match(/(?:interface|type)\s+(\w*(?:Params|Query|Request|Filter|Page)\w*)\s*[={<]/);
352
+ if (!m) continue;
353
+
354
+ // Capture the full block via brace-depth counter
355
+ const blockLines: string[] = [];
356
+ let depth = 0;
357
+ for (let j = i; j < Math.min(i + 40, lines.length); j++) {
358
+ blockLines.push(lines[j]);
359
+ depth += (lines[j].match(/\{/g) ?? []).length;
360
+ depth -= (lines[j].match(/\}/g) ?? []).length;
361
+ if (depth === 0 && j > i) break;
362
+ }
363
+
364
+ const blockText = blockLines.join("\n");
365
+ // Only use interfaces that actually contain a pagination field
366
+ if (!paginationFieldNames.some((f) => new RegExp(`\\b${f}\\b`).test(blockText))) continue;
367
+
368
+ interfaceName = m[1];
369
+ interfaceBlock = blockText;
370
+ break;
371
+ }
372
+
373
+ if (!interfaceName) continue;
374
+
375
+ // ── Step 2: find an exported function that uses this interface ───────
376
+ for (let i = 0; i < lines.length; i++) {
377
+ const line = lines[i];
378
+ // Match both `export function` and `export const x = (`
379
+ if (!/export\s+(async\s+)?(function|const)/.test(line)) continue;
380
+ if (!line.includes(interfaceName)) continue;
381
+
382
+ // Capture function body with brace-depth counter (max 30 lines)
383
+ const fnLines: string[] = [];
384
+ let depth = 0;
385
+ for (let j = i; j < Math.min(i + 30, lines.length); j++) {
386
+ fnLines.push(lines[j]);
387
+ depth += (lines[j].match(/\{/g) ?? []).length;
388
+ depth -= (lines[j].match(/\}/g) ?? []).length;
389
+ if (depth === 0 && j > i) break;
390
+ }
391
+
392
+ ctx.paginationExample = `// From ${relPath}\n${interfaceBlock}\n\n${fnLines.join("\n")}`;
347
393
  break;
348
394
  }
395
+
396
+ if (ctx.paginationExample) break;
349
397
  } catch {
350
398
  // skip
351
399
  }
@@ -0,0 +1,90 @@
1
+ import chalk from "chalk";
2
+
3
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
4
+
5
+ // ─── Error Classification ──────────────────────────────────────────────────────
6
+
7
+ export type ProviderErrorKind = "auth" | "rate_limit" | "timeout" | "network" | "provider";
8
+
9
+ export class ProviderError extends Error {
10
+ constructor(
11
+ message: string,
12
+ public readonly kind: ProviderErrorKind,
13
+ public readonly originalError?: unknown
14
+ ) {
15
+ super(message);
16
+ this.name = "ProviderError";
17
+ }
18
+ }
19
+
20
+ function classifyError(err: unknown, label: string): ProviderError {
21
+ const e = err as Error & { status?: number; code?: string; response?: { status?: number } };
22
+ const status = e.status ?? e.response?.status;
23
+
24
+ if (status === 401 || status === 403)
25
+ return new ProviderError(`Auth error — check your API key (${label})`, "auth", err);
26
+ if (status === 429)
27
+ return new ProviderError(`Rate limit hit (${label}) — try again later or switch provider`, "rate_limit", err);
28
+ if ((e as Error & { _timeout?: boolean })._timeout || e.message?.toLowerCase().includes("timed out"))
29
+ return new ProviderError(`Request timed out (${label})`, "timeout", err);
30
+ if (e.code === "ECONNRESET" || e.code === "ENOTFOUND" || e.code === "ECONNREFUSED")
31
+ return new ProviderError(`Network error — check connection/proxy (${label}): ${e.message}`, "network", err);
32
+ return new ProviderError(`Provider error (${label}): ${e.message}`, "provider", err);
33
+ }
34
+
35
+ function isRetryable(err: unknown): boolean {
36
+ const e = err as Error & { status?: number; code?: string; response?: { status?: number } };
37
+ const status = e.status ?? e.response?.status;
38
+ if (status === 401 || status === 403) return false; // wrong key — retrying won't help
39
+ if (status === 429 || (status !== undefined && status >= 500)) return true;
40
+ if (e.code === "ECONNRESET" || e.code === "ENOTFOUND" || e.code === "ECONNREFUSED") return true;
41
+ if (e.message?.toLowerCase().includes("timed out")) return true;
42
+ return true; // unknown errors: retry once
43
+ }
44
+
45
+ // ─── Reliability Wrapper ───────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Wrap any async AI provider call with:
49
+ * - Configurable timeout (default 90s)
50
+ * - Automatic retry with exponential backoff (default 2 retries)
51
+ * - Structured error classification (auth / rate_limit / timeout / network / provider)
52
+ */
53
+ export async function withReliability<T>(
54
+ fn: () => Promise<T>,
55
+ opts?: {
56
+ retries?: number;
57
+ timeoutMs?: number;
58
+ label?: string;
59
+ onRetry?: (attempt: number, err: unknown) => void;
60
+ }
61
+ ): Promise<T> {
62
+ const { retries = 2, timeoutMs = 90_000, label = "AI call", onRetry } = opts ?? {};
63
+
64
+ for (let attempt = 0; attempt <= retries; attempt++) {
65
+ try {
66
+ return await Promise.race([
67
+ fn(),
68
+ new Promise<never>((_, reject) =>
69
+ setTimeout(
70
+ () => reject(Object.assign(new Error(`timed out after ${timeoutMs / 1000}s`), { _timeout: true })),
71
+ timeoutMs
72
+ )
73
+ ),
74
+ ]);
75
+ } catch (err) {
76
+ if (!isRetryable(err) || attempt === retries) {
77
+ throw classifyError(err, label);
78
+ }
79
+ const waitMs = attempt === 0 ? 2_000 : 6_000;
80
+ console.warn(
81
+ chalk.yellow(` ⚠ ${label} failed (attempt ${attempt + 1}/${retries + 1}), retrying in ${waitMs / 1000}s`) +
82
+ chalk.gray(` — ${(err as Error).message}`)
83
+ );
84
+ onRetry?.(attempt + 1, err);
85
+ await sleep(waitMs);
86
+ }
87
+ }
88
+ /* istanbul ignore next */
89
+ throw new Error("unreachable");
90
+ }