ai-spec-dev 0.29.1 → 0.30.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/README.md CHANGED
@@ -1223,7 +1223,8 @@ Attempting auto-fix (3 error(s))...
1223
1223
  ✔ All checks passed after 2 cycle(s).
1224
1224
  ```
1225
1225
 
1226
- - 按文件分组错误,逐文件提交 AI 修复(携带 DSL 上下文)
1226
+ - 按文件分组错误,**依据 import 依赖图排序后**逐文件修复(被依赖文件先修,cascade 错误在 cycle 1 消除率更高)
1227
+ - 每个文件修复 prompt 携带 DSL 上下文,避免「把错误藏起来」式修复
1227
1228
  - 任一 cycle 全部通过则提前结束
1228
1229
  - 2 次后仍有错误 → 给出警告提示(不中断流水线)
1229
1230
 
@@ -1523,12 +1524,12 @@ ai-spec-dev-poc/
1523
1524
  │ ├── combined-generator.ts # Spec + Tasks 合并单次 AI 调用
1524
1525
  │ ├── reviewer.ts # AI 代码审查(git diff / 文件内容双模式)
1525
1526
  │ ├── test-generator.ts # 测试骨架生成器(DSL → Jest/Vitest 骨架)
1526
- │ ├── error-feedback.ts # 错误反馈自动修复(测试+lint检测 · AI修复循环)
1527
+ │ ├── error-feedback.ts # 错误反馈自动修复(测试+lint检测 · 依赖图排序修复 · AI修复循环)
1527
1528
  │ ├── knowledge-memory.ts # 经验积累:审查 issue → 宪法§9
1528
1529
  │ ├── workspace-loader.ts # [Phase 4] 工作区配置加载 + repo 类型自动检测
1529
1530
  │ ├── requirement-decomposer.ts # [Phase 4] 需求跨 repo 拆分 + UX 决策生成
1530
1531
  │ ├── contract-bridge.ts # [Phase 4] 后端 DSL → 前端 TS 接口契约桥接
1531
- │ ├── frontend-context-loader.ts # [v0.8] 前端深度感知(hook/store/API封装/测试框架/分页 pattern 检测)
1532
+ │ ├── frontend-context-loader.ts # [v0.8] 前端深度感知(hook/store/API封装/测试框架/分页 pattern 检测;v0.30.0 升级为多行 import 解析,覆盖换行 named import 写法)
1532
1533
  │ └── global-constitution.ts # [v0.8] 全局宪法:加载 / 合并 / 保存(跨项目共享规范)
1533
1534
  ├── git/
1534
1535
  │ └── worktree.ts # Git Worktree 管理
package/RELEASE_LOG.md CHANGED
@@ -2,6 +2,30 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## [0.30.0] 2026-03-29 — 错误修复依赖图排序 + 前端 Import 多行感知解析
6
+
7
+ ### 改进内容
8
+
9
+ **Improvement #1 — Error Repair 升级为依赖图排序(`core/error-feedback.ts`)**
10
+
11
+ - **问题**:`attemptFix()` 对出错文件的修复顺序完全取决于 `Map` 的插入顺序(即错误首次出现的顺序),与文件间的 import 关系无关。典型场景:`userService.ts` 导出一个类型,`userController.ts` 和 `userStore.ts` 同时 import 它,三个文件都出错时,若 controller/store 先被修复,修复 prompt 中的 service 仍是破损版本,cycle 1 无法消除 cascade 错误,只能等 cycle 2 补救。
12
+ - **修复**:新增 `parseRelativeImports()` + `buildRepairOrder()` 两个函数:
13
+ - `parseRelativeImports(content, fromFile)` — 从文件内容中解析相对 import 路径(`./foo`、`../foo`),规范化为项目根相对路径(不含扩展名),跳过 `import type`(仅类型,不影响运行时错误)
14
+ - `buildRepairOrder(errorsByFile, workingDir)` — 读取所有出错文件的 import 声明,构建「出错文件子图」,使用 Kahn 拓扑排序将被依赖的文件排在前面,有环依赖的文件追加到末尾
15
+ - `attemptFix()` 调用 `await buildRepairOrder()` 替换原先直接遍历 `errorsByFile`
16
+ - **效果**:上例中 `userService.ts` 先被修复,cycle 1 即可消除 controller / store 的 cascade 错误,2 轮上限的利用率提升,复杂跨文件依赖错误的一次性修复率提高
17
+
18
+ **Improvement #2 — `httpClientImport` / `layoutImport` 升级为多行感知解析(`core/frontend-context-loader.ts`)**
19
+
20
+ - **问题**:旧版 `httpClientImport` 提取使用单行正则 `httpImportRegex`,`[^}]+` 不跨换行符,导致所有多行 named import(`import {\n request\n} from '@/utils/http'`)静默匹配失败,回退到 `undefined`,AI 自由发挥 import 路径;`layoutImport` 同样依赖单行正则,多行动态 import 写法(`const Layout = defineAsyncComponent(\n () => import('...')\n)`)无法识别。
21
+ - **修复**:
22
+ - 新增 `parseImportStatements(content)` — 轻量 import 解析器:先将多行 named import block(`import { ... }`)折叠为单行,再逐行匹配,返回 `{ line, modulePath, specifiers }` 结构化对象,跳过 `import type`;不引入任何新依赖
23
+ - 新增 `HTTP_MODULE_PATTERNS` — 三类模式数组(路径别名 `@/` `~/` `#/` / 已知 HTTP 库名 / 含关键词的相对路径),独立于 import 行格式,匹配逻辑与解析逻辑解耦
24
+ - 新增 `findHttpClientImport(content)` — 组合以上两者,替换旧版 `httpImportRegex` 的调用处
25
+ - `extractRouteModuleContext()` 中 `layoutImport` 静态 import 改用 `parseImportStatements()` 提取;动态 import(`const Layout = ...`)增加多行折叠预处理后再匹配,覆盖 `defineAsyncComponent` 等包装写法
26
+
27
+ ---
28
+
5
29
  ## [0.29.0] 2026-03-27 — 全量审查修复(RunLogger 插桩、update 快照、Score Trend 升级、死代码清理)
6
30
 
7
31
  ### 修复内容
@@ -197,6 +197,104 @@ function parseErrors(output: string, source: ErrorEntry["source"]): ErrorEntry[]
197
197
  return errors;
198
198
  }
199
199
 
200
+ // ─── Dependency-Ordered Repair ──────────────────────────────────────────────────
201
+
202
+ /**
203
+ * Extract relative import paths from a file's content.
204
+ * Returns paths normalized to project-root-relative form (no extension).
205
+ * Only considers relative imports (./foo, ../foo) — skips aliases and node_modules.
206
+ */
207
+ function parseRelativeImports(content: string, fromFileRel: string): string[] {
208
+ const relDir = path.dirname(fromFileRel);
209
+ const results: string[] = [];
210
+
211
+ // Normalize multi-line imports so `import {\n foo,\n} from '...'` becomes one line
212
+ const normalized = content.replace(/import\s*\{[^}]*\}/gs, (m) => m.replace(/\n\s*/g, " "));
213
+
214
+ for (const line of normalized.split("\n")) {
215
+ const trimmed = line.trim();
216
+ // Skip type-only imports — they don't affect runtime errors
217
+ if (/^import\s+type\b/.test(trimmed)) continue;
218
+ // Match only relative imports (starting with ./ or ../)
219
+ const match = trimmed.match(/^import\b[^'"]*from\s+['"](\.\.?\/[^'"]+)['"]/);
220
+ if (!match) continue;
221
+
222
+ const resolved = path.normalize(path.join(relDir, match[1]));
223
+ results.push(resolved);
224
+ }
225
+
226
+ return results;
227
+ }
228
+
229
+ /**
230
+ * Sort errored files so that dependencies (upstream exports) are fixed before their importers.
231
+ * Example: if A exports a type used by B and C, fix A first so cycle 1 can cascade correctly.
232
+ * Uses Kahn's topological sort; cycles are appended at the end (unavoidable, fix last).
233
+ */
234
+ async function buildRepairOrder(
235
+ errorsByFile: Map<string, ErrorEntry[]>,
236
+ workingDir: string
237
+ ): Promise<[string, ErrorEntry[]][]> {
238
+ const files = Array.from(errorsByFile.keys());
239
+ if (files.length <= 1) return Array.from(errorsByFile.entries());
240
+
241
+ // Build: file → errored files it depends on (imports from)
242
+ const deps = new Map<string, string[]>(files.map((f) => [f, []]));
243
+
244
+ for (const file of files) {
245
+ try {
246
+ const content = await fs.readFile(path.join(workingDir, file), "utf-8");
247
+ const importedPaths = parseRelativeImports(content, file);
248
+
249
+ for (const importedPath of importedPaths) {
250
+ const matched = files.find((f) => {
251
+ if (f === file) return false;
252
+ const fNoExt = f.replace(/\.[^.]+$/, "");
253
+ return (
254
+ importedPath === fNoExt ||
255
+ importedPath === f ||
256
+ `${importedPath}.ts` === f ||
257
+ `${importedPath}.tsx` === f ||
258
+ `${importedPath}.js` === f ||
259
+ `${importedPath}.jsx` === f
260
+ );
261
+ });
262
+ if (matched) deps.get(file)!.push(matched);
263
+ }
264
+ } catch {
265
+ // file unreadable — treat as no deps
266
+ }
267
+ }
268
+
269
+ // Reverse adjacency: dep → files that import it (its dependents)
270
+ const dependents = new Map<string, string[]>(files.map((f) => [f, []]));
271
+ for (const [file, fileDeps] of deps) {
272
+ for (const dep of fileDeps) dependents.get(dep)!.push(file);
273
+ }
274
+
275
+ // Kahn's algorithm: files with no deps go first
276
+ const inDegree = new Map(files.map((f) => [f, deps.get(f)!.length]));
277
+ const queue = files.filter((f) => inDegree.get(f) === 0);
278
+ const sorted: string[] = [];
279
+
280
+ while (queue.length > 0) {
281
+ const file = queue.shift()!;
282
+ sorted.push(file);
283
+ for (const dependent of dependents.get(file) ?? []) {
284
+ const degree = (inDegree.get(dependent) ?? 1) - 1;
285
+ inDegree.set(dependent, degree);
286
+ if (degree === 0) queue.push(dependent);
287
+ }
288
+ }
289
+
290
+ // Append remaining files (cycles) — fix them last since ordering is ambiguous
291
+ for (const f of files) {
292
+ if (!sorted.includes(f)) sorted.push(f);
293
+ }
294
+
295
+ return sorted.map((f) => [f, errorsByFile.get(f)!]);
296
+ }
297
+
200
298
  // ─── Auto-Fix ───────────────────────────────────────────────────────────────────
201
299
 
202
300
  async function attemptFix(
@@ -207,7 +305,7 @@ async function attemptFix(
207
305
  ): Promise<FixResult[]> {
208
306
  const results: FixResult[] = [];
209
307
 
210
- // Group errors by file (fix one file at a time)
308
+ // Group errors by file, then sort by dependency order so upstream files are fixed first
211
309
  const errorsByFile = new Map<string, ErrorEntry[]>();
212
310
  for (const err of errors) {
213
311
  const file = err.file || "(unknown)";
@@ -215,7 +313,9 @@ async function attemptFix(
215
313
  errorsByFile.get(file)!.push(err);
216
314
  }
217
315
 
218
- for (const [file, fileErrors] of errorsByFile) {
316
+ const sortedEntries = await buildRepairOrder(errorsByFile, workingDir);
317
+
318
+ for (const [file, fileErrors] of sortedEntries) {
219
319
  const fullPath = path.join(workingDir, file);
220
320
  let existingContent = "";
221
321
  try {
@@ -68,6 +68,84 @@ export interface FrontendContext {
68
68
  paginationExample?: string;
69
69
  }
70
70
 
71
+ // ─── Lightweight Import Parser ────────────────────────────────────────────────
72
+
73
+ interface ImportStatement {
74
+ /** The full original import line (for verbatim injection into prompts) */
75
+ line: string;
76
+ /** Resolved module path (e.g. '@/utils/http', 'axios', '../lib/request') */
77
+ modulePath: string;
78
+ /** Everything between `import` and `from` (specifiers) */
79
+ specifiers: string;
80
+ }
81
+
82
+ /**
83
+ * Parse all non-type import statements from a TypeScript/JavaScript file.
84
+ *
85
+ * Improvements over a single-line regex:
86
+ * - Handles multi-line named imports: `import {\n foo,\n bar\n} from '...'`
87
+ * - Skips `import type { ... }` to avoid false positives
88
+ * - Returns structured objects so callers can inspect specifiers vs module path
89
+ * without re-running a second regex
90
+ */
91
+ function parseImportStatements(content: string): ImportStatement[] {
92
+ // 1. Strip block comments (/* ... */) to avoid matching imports inside comments
93
+ const stripped = content.replace(/\/\*[\s\S]*?\*\//g, (m) => "\n".repeat(m.split("\n").length - 1));
94
+
95
+ // 2. Collapse multi-line named import blocks onto a single logical line so
96
+ // a single-line pattern can match them reliably.
97
+ // e.g. `import {\n foo,\n bar\n} from 'x'` → `import { foo, bar } from 'x'`
98
+ const collapsed = stripped.replace(/import\s*\{[^}]*\}/gs, (m) => m.replace(/\n\s*/g, " "));
99
+
100
+ const results: ImportStatement[] = [];
101
+
102
+ for (const rawLine of collapsed.split("\n")) {
103
+ const line = rawLine.trim();
104
+ if (!line) continue;
105
+ // Skip type-only imports — they never affect runtime behaviour
106
+ if (/^import\s+type\b/.test(line)) continue;
107
+ if (!line.startsWith("import")) continue;
108
+
109
+ const match = line.match(/^(import\s+([\s\S]+?)\s+from\s+['"]([^'"]+)['"])/);
110
+ if (!match) continue;
111
+
112
+ results.push({
113
+ line: match[1],
114
+ modulePath: match[3],
115
+ specifiers: match[2],
116
+ });
117
+ }
118
+
119
+ return results;
120
+ }
121
+
122
+ const HTTP_MODULE_PATTERNS: RegExp[] = [
123
+ // Project path aliases (@/, @@/, ~/, #/) — catches '@/utils/request', '~/lib/http', etc.
124
+ /^(?:@{1,2}|~|#)[/\\]/,
125
+ // Well-known HTTP libraries (exact name match)
126
+ /^(?:axios|ky(?:-universal)?|undici|node-fetch|cross-fetch|got|superagent|alova|openapi-fetch)$/,
127
+ // Relative imports whose path contains an HTTP-utility keyword
128
+ /\.{1,2}\/[^'"]*(?:http|request|fetch|client|api)[^'"]*/,
129
+ ];
130
+
131
+ /**
132
+ * Find the HTTP client import line from an API file's content.
133
+ * Returns the verbatim import statement or undefined if not found.
134
+ *
135
+ * More reliable than the old single-line regex because:
136
+ * - Handles multi-line named imports (e.g. `import {\n request\n} from '@/utils/http'`)
137
+ * - Module-path matching is done on the resolved path string, not the full line,
138
+ * so a long specifier list doesn't prevent a match
139
+ */
140
+ function findHttpClientImport(content: string): string | undefined {
141
+ for (const stmt of parseImportStatements(content)) {
142
+ if (HTTP_MODULE_PATTERNS.some((p) => p.test(stmt.modulePath))) {
143
+ return stmt.line;
144
+ }
145
+ }
146
+ return undefined;
147
+ }
148
+
71
149
  // ─── Detection Maps ────────────────────────────────────────────────────────────
72
150
 
73
151
  const STATE_MANAGEMENT_LIBS = [
@@ -303,18 +381,15 @@ export async function loadFrontendContext(
303
381
  // e.g. "import request from '@/utils/http'" or "import axios from 'axios'"
304
382
  // This is ground truth — prevents the AI from inventing a different import path.
305
383
  //
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;
384
+ // Uses parseImportStatements() + HTTP_MODULE_PATTERNS instead of a single-line regex
385
+ // so multi-line named imports (e.g. `import {\n request\n} from '@/utils/http'`)
386
+ // are handled correctly without a secondary normalisation pass at call-site.
312
387
  for (const relPath of ctx.existingApiFiles.slice(0, 5)) {
313
388
  try {
314
389
  const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
315
- const match = content.match(httpImportRegex);
316
- if (match) {
317
- ctx.httpClientImport = match[0].trim();
390
+ const found = findHttpClientImport(content);
391
+ if (found) {
392
+ ctx.httpClientImport = found;
318
393
  break;
319
394
  }
320
395
  } catch {
@@ -498,23 +573,45 @@ async function extractRouteModuleContext(
498
573
 
499
574
  if (moduleFiles.length === 0) return;
500
575
 
501
- // Layout import regexmatches common patterns:
502
- // const Layout = () => import('@/layout/index.vue')
503
- // import Layout from '@/layouts/default/index.vue'
504
- // const Layout = defineAsyncComponent(() => import('@/layout/index.vue'))
505
- const layoutImportRegex =
506
- /^(?:const\s+Layout\s*=.*import\(['"][^'"]+['"]\)|import\s+Layout\s+from\s+['"][^'"]+['"])/m;
576
+ // Layout import extractionhandles two patterns:
577
+ // 1. Static: `import Layout from '@/layout/index.vue'`
578
+ // 2. Dynamic: `const Layout = () => import('@/layout/index.vue')`
579
+ // `const Layout = defineAsyncComponent(() => import('@/layout/index.vue'))`
580
+ //
581
+ // Pattern 1 is resolved via parseImportStatements() (handles multi-line named imports).
582
+ // Pattern 2 is matched with a targeted regex on the collapsed (single-line) content.
583
+ const dynamicLayoutRegex =
584
+ /const\s+Layout\s*=\s*(?:defineAsyncComponent\s*\(\s*)?(?:\(\s*\))?\s*(?:=>|function[^(]*\()\s*(?:[^)]*\))?\s*(?:=>)?\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)/;
507
585
 
508
586
  for (const relPath of moduleFiles) {
509
587
  try {
510
588
  const content = await fs.readFile(path.join(projectRoot, relPath), "utf-8");
511
- const match = content.match(layoutImportRegex);
512
- if (match) {
513
- ctx.layoutImport = match[0].trim();
514
- // Use this file as the route module template (cap at 100 lines)
589
+
590
+ // ── Pattern 1: static import Layout ────────────────────────────────────
591
+ const stmts = parseImportStatements(content);
592
+ const staticLayout = stmts.find(
593
+ (s) => /\bLayout\b/.test(s.specifiers) && /layout/i.test(s.modulePath)
594
+ );
595
+ if (staticLayout) {
596
+ ctx.layoutImport = staticLayout.line;
515
597
  const preview = content.split("\n").slice(0, 100).join("\n");
516
598
  ctx.routeModuleExample = { path: relPath, content: preview };
517
- break; // first match is enough
599
+ break;
600
+ }
601
+
602
+ // ── Pattern 2: dynamic / async-component import ─────────────────────────
603
+ // Collapse multi-line dynamic import declarations before matching
604
+ const singleLine = content.replace(/const\s+Layout\s*=[\s\S]*?import\s*\([^)]+\)/gm, (m) =>
605
+ m.replace(/\n\s*/g, " ")
606
+ );
607
+ const dynMatch = singleLine.match(dynamicLayoutRegex);
608
+ if (dynMatch) {
609
+ // Re-extract the full const declaration line from the original content
610
+ const constMatch = content.match(/^const\s+Layout\s*=.+/m);
611
+ ctx.layoutImport = constMatch ? constMatch[0].trim() : dynMatch[0].trim();
612
+ const preview = content.split("\n").slice(0, 100).join("\n");
613
+ ctx.routeModuleExample = { path: relPath, content: preview };
614
+ break;
518
615
  }
519
616
  } catch {
520
617
  // skip
package/dist/cli/index.js CHANGED
@@ -5518,6 +5518,41 @@ async function loadDslForSpec(specFilePath) {
5518
5518
  // core/frontend-context-loader.ts
5519
5519
  var fs7 = __toESM(require("fs-extra"));
5520
5520
  var path6 = __toESM(require("path"));
5521
+ function parseImportStatements(content) {
5522
+ const stripped = content.replace(/\/\*[\s\S]*?\*\//g, (m) => "\n".repeat(m.split("\n").length - 1));
5523
+ const collapsed = stripped.replace(/import\s*\{[^}]*\}/gs, (m) => m.replace(/\n\s*/g, " "));
5524
+ const results = [];
5525
+ for (const rawLine of collapsed.split("\n")) {
5526
+ const line = rawLine.trim();
5527
+ if (!line) continue;
5528
+ if (/^import\s+type\b/.test(line)) continue;
5529
+ if (!line.startsWith("import")) continue;
5530
+ const match = line.match(/^(import\s+([\s\S]+?)\s+from\s+['"]([^'"]+)['"])/);
5531
+ if (!match) continue;
5532
+ results.push({
5533
+ line: match[1],
5534
+ modulePath: match[3],
5535
+ specifiers: match[2]
5536
+ });
5537
+ }
5538
+ return results;
5539
+ }
5540
+ var HTTP_MODULE_PATTERNS = [
5541
+ // Project path aliases (@/, @@/, ~/, #/) — catches '@/utils/request', '~/lib/http', etc.
5542
+ /^(?:@{1,2}|~|#)[/\\]/,
5543
+ // Well-known HTTP libraries (exact name match)
5544
+ /^(?:axios|ky(?:-universal)?|undici|node-fetch|cross-fetch|got|superagent|alova|openapi-fetch)$/,
5545
+ // Relative imports whose path contains an HTTP-utility keyword
5546
+ /\.{1,2}\/[^'"]*(?:http|request|fetch|client|api)[^'"]*/
5547
+ ];
5548
+ function findHttpClientImport(content) {
5549
+ for (const stmt of parseImportStatements(content)) {
5550
+ if (HTTP_MODULE_PATTERNS.some((p) => p.test(stmt.modulePath))) {
5551
+ return stmt.line;
5552
+ }
5553
+ }
5554
+ return void 0;
5555
+ }
5521
5556
  var STATE_MANAGEMENT_LIBS = [
5522
5557
  "zustand",
5523
5558
  "redux",
@@ -5706,13 +5741,12 @@ ${preview}`);
5706
5741
  } catch {
5707
5742
  }
5708
5743
  }
5709
- const httpImportRegex = /^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;
5710
5744
  for (const relPath of ctx.existingApiFiles.slice(0, 5)) {
5711
5745
  try {
5712
5746
  const content = await fs7.readFile(path6.join(projectRoot, relPath), "utf-8");
5713
- const match = content.match(httpImportRegex);
5714
- if (match) {
5715
- ctx.httpClientImport = match[0].trim();
5747
+ const found = findHttpClientImport(content);
5748
+ if (found) {
5749
+ ctx.httpClientImport = found;
5716
5750
  break;
5717
5751
  }
5718
5752
  } catch {
@@ -5842,13 +5876,28 @@ async function extractRouteModuleContext(projectRoot, ctx) {
5842
5876
  moduleFiles.push(...files);
5843
5877
  }
5844
5878
  if (moduleFiles.length === 0) return;
5845
- const layoutImportRegex = /^(?:const\s+Layout\s*=.*import\(['"][^'"]+['"]\)|import\s+Layout\s+from\s+['"][^'"]+['"])/m;
5879
+ const dynamicLayoutRegex = /const\s+Layout\s*=\s*(?:defineAsyncComponent\s*\(\s*)?(?:\(\s*\))?\s*(?:=>|function[^(]*\()\s*(?:[^)]*\))?\s*(?:=>)?\s*import\s*\(\s*['"]([^'"]+)['"]\s*\)/;
5846
5880
  for (const relPath of moduleFiles) {
5847
5881
  try {
5848
5882
  const content = await fs7.readFile(path6.join(projectRoot, relPath), "utf-8");
5849
- const match = content.match(layoutImportRegex);
5850
- if (match) {
5851
- ctx.layoutImport = match[0].trim();
5883
+ const stmts = parseImportStatements(content);
5884
+ const staticLayout = stmts.find(
5885
+ (s) => /\bLayout\b/.test(s.specifiers) && /layout/i.test(s.modulePath)
5886
+ );
5887
+ if (staticLayout) {
5888
+ ctx.layoutImport = staticLayout.line;
5889
+ const preview = content.split("\n").slice(0, 100).join("\n");
5890
+ ctx.routeModuleExample = { path: relPath, content: preview };
5891
+ break;
5892
+ }
5893
+ const singleLine = content.replace(
5894
+ /const\s+Layout\s*=[\s\S]*?import\s*\([^)]+\)/gm,
5895
+ (m) => m.replace(/\n\s*/g, " ")
5896
+ );
5897
+ const dynMatch = singleLine.match(dynamicLayoutRegex);
5898
+ if (dynMatch) {
5899
+ const constMatch = content.match(/^const\s+Layout\s*=.+/m);
5900
+ ctx.layoutImport = constMatch ? constMatch[0].trim() : dynMatch[0].trim();
5852
5901
  const preview = content.split("\n").slice(0, 100).join("\n");
5853
5902
  ctx.routeModuleExample = { path: relPath, content: preview };
5854
5903
  break;
@@ -7833,6 +7882,60 @@ function parseErrors(output, source) {
7833
7882
  }
7834
7883
  return errors;
7835
7884
  }
7885
+ function parseRelativeImports(content, fromFileRel) {
7886
+ const relDir = path15.dirname(fromFileRel);
7887
+ const results = [];
7888
+ const normalized = content.replace(/import\s*\{[^}]*\}/gs, (m) => m.replace(/\n\s*/g, " "));
7889
+ for (const line of normalized.split("\n")) {
7890
+ const trimmed = line.trim();
7891
+ if (/^import\s+type\b/.test(trimmed)) continue;
7892
+ const match = trimmed.match(/^import\b[^'"]*from\s+['"](\.\.?\/[^'"]+)['"]/);
7893
+ if (!match) continue;
7894
+ const resolved = path15.normalize(path15.join(relDir, match[1]));
7895
+ results.push(resolved);
7896
+ }
7897
+ return results;
7898
+ }
7899
+ async function buildRepairOrder(errorsByFile, workingDir) {
7900
+ const files = Array.from(errorsByFile.keys());
7901
+ if (files.length <= 1) return Array.from(errorsByFile.entries());
7902
+ const deps = new Map(files.map((f) => [f, []]));
7903
+ for (const file of files) {
7904
+ try {
7905
+ const content = await fs16.readFile(path15.join(workingDir, file), "utf-8");
7906
+ const importedPaths = parseRelativeImports(content, file);
7907
+ for (const importedPath of importedPaths) {
7908
+ const matched = files.find((f) => {
7909
+ if (f === file) return false;
7910
+ const fNoExt = f.replace(/\.[^.]+$/, "");
7911
+ return importedPath === fNoExt || importedPath === f || `${importedPath}.ts` === f || `${importedPath}.tsx` === f || `${importedPath}.js` === f || `${importedPath}.jsx` === f;
7912
+ });
7913
+ if (matched) deps.get(file).push(matched);
7914
+ }
7915
+ } catch {
7916
+ }
7917
+ }
7918
+ const dependents = new Map(files.map((f) => [f, []]));
7919
+ for (const [file, fileDeps] of deps) {
7920
+ for (const dep of fileDeps) dependents.get(dep).push(file);
7921
+ }
7922
+ const inDegree = new Map(files.map((f) => [f, deps.get(f).length]));
7923
+ const queue = files.filter((f) => inDegree.get(f) === 0);
7924
+ const sorted = [];
7925
+ while (queue.length > 0) {
7926
+ const file = queue.shift();
7927
+ sorted.push(file);
7928
+ for (const dependent of dependents.get(file) ?? []) {
7929
+ const degree = (inDegree.get(dependent) ?? 1) - 1;
7930
+ inDegree.set(dependent, degree);
7931
+ if (degree === 0) queue.push(dependent);
7932
+ }
7933
+ }
7934
+ for (const f of files) {
7935
+ if (!sorted.includes(f)) sorted.push(f);
7936
+ }
7937
+ return sorted.map((f) => [f, errorsByFile.get(f)]);
7938
+ }
7836
7939
  async function attemptFix(provider, errors, workingDir, dsl) {
7837
7940
  const results = [];
7838
7941
  const errorsByFile = /* @__PURE__ */ new Map();
@@ -7841,7 +7944,8 @@ async function attemptFix(provider, errors, workingDir, dsl) {
7841
7944
  if (!errorsByFile.has(file)) errorsByFile.set(file, []);
7842
7945
  errorsByFile.get(file).push(err);
7843
7946
  }
7844
- for (const [file, fileErrors] of errorsByFile) {
7947
+ const sortedEntries = await buildRepairOrder(errorsByFile, workingDir);
7948
+ for (const [file, fileErrors] of sortedEntries) {
7845
7949
  const fullPath = path15.join(workingDir, file);
7846
7950
  let existingContent = "";
7847
7951
  try {