ai-spec-dev 0.46.0 → 0.56.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.
Files changed (41) hide show
  1. package/README.md +60 -30
  2. package/cli/commands/config.ts +129 -1
  3. package/cli/commands/create.ts +14 -0
  4. package/cli/commands/fix-history.ts +176 -0
  5. package/cli/commands/init.ts +36 -1
  6. package/cli/index.ts +2 -6
  7. package/cli/pipeline/helpers.ts +6 -0
  8. package/cli/pipeline/multi-repo.ts +300 -26
  9. package/cli/pipeline/single-repo.ts +103 -2
  10. package/cli/utils.ts +23 -0
  11. package/core/code-generator.ts +63 -14
  12. package/core/cross-stack-verifier.ts +482 -0
  13. package/core/fix-history.ts +333 -0
  14. package/core/import-fixer.ts +827 -0
  15. package/core/import-verifier.ts +569 -0
  16. package/core/knowledge-memory.ts +55 -6
  17. package/core/self-evaluator.ts +44 -7
  18. package/core/spec-generator.ts +3 -3
  19. package/core/types-generator.ts +2 -2
  20. package/dist/cli/index.js +3968 -2353
  21. package/dist/cli/index.js.map +1 -1
  22. package/dist/cli/index.mjs +3810 -2195
  23. package/dist/cli/index.mjs.map +1 -1
  24. package/dist/index.d.mts +14 -0
  25. package/dist/index.d.ts +14 -0
  26. package/dist/index.js +249 -128
  27. package/dist/index.js.map +1 -1
  28. package/dist/index.mjs +249 -128
  29. package/dist/index.mjs.map +1 -1
  30. package/package.json +2 -2
  31. package/tests/cross-stack-verifier.test.ts +402 -0
  32. package/tests/fix-history.test.ts +335 -0
  33. package/tests/import-fixer.test.ts +944 -0
  34. package/tests/import-verifier.test.ts +420 -0
  35. package/tests/knowledge-memory.test.ts +40 -0
  36. package/tests/self-evaluator.test.ts +97 -0
  37. package/.ai-spec-workspace.json +0 -17
  38. package/.ai-spec.json +0 -7
  39. package/cli/commands/model.ts +0 -152
  40. package/cli/commands/scan.ts +0 -99
  41. package/cli/commands/workspace.ts +0 -219
@@ -0,0 +1,827 @@
1
+ /**
2
+ * import-fixer.ts — Auto-repair for import hallucinations caught by import-verifier.
3
+ *
4
+ * Two-stage repair strategy:
5
+ *
6
+ * Stage A: Deterministic (DSL-driven)
7
+ * ---------------------------------------------------------
8
+ * For each broken import, check whether the missing symbols
9
+ * match a model declared in the project DSL. If yes, generate
10
+ * a stub TypeScript file from the DSL field schemas. No AI call.
11
+ *
12
+ * Stage B: AI fix loop
13
+ * ---------------------------------------------------------
14
+ * Anything Stage A could not handle (e.g. helper functions,
15
+ * non-DSL types) is bundled into a single targeted prompt sent
16
+ * to the codegen provider. The AI returns a JSON list of fix
17
+ * actions which we apply deterministically.
18
+ *
19
+ * Both stages converge through a shared FixAction interface, so the
20
+ * executor and re-verification path is identical regardless of source.
21
+ */
22
+
23
+ import * as fs from "fs-extra";
24
+ import * as path from "path";
25
+ import chalk from "chalk";
26
+ import { SpecDSL, DataModel } from "./dsl-types";
27
+ import { renderModelInterface } from "./types-generator";
28
+ import { AIProvider } from "./spec-generator";
29
+ import { BrokenImport, ImportRef } from "./import-verifier";
30
+ import { stripCodeFences, parseJsonArray } from "./codegen/helpers";
31
+ import { appendFixEntry } from "./fix-history";
32
+
33
+ // ─── Types ────────────────────────────────────────────────────────────────────
34
+
35
+ export type FixAction =
36
+ | {
37
+ kind: "create_file";
38
+ /** Repo-relative path of the file to create */
39
+ path: string;
40
+ /** Full file contents */
41
+ content: string;
42
+ /** Human-readable explanation of why this fix was chosen */
43
+ reason: string;
44
+ /** Where it came from: "deterministic" or "ai" */
45
+ source: "deterministic" | "ai";
46
+ }
47
+ | {
48
+ kind: "rewrite_import";
49
+ /** Repo-relative path of the file containing the broken import */
50
+ file: string;
51
+ /** Original import line text (will be replaced verbatim) */
52
+ oldLine: string;
53
+ /** New import line text */
54
+ newLine: string;
55
+ reason: string;
56
+ source: "deterministic" | "ai";
57
+ }
58
+ | {
59
+ kind: "append_to_file";
60
+ /** Repo-relative path of an existing file to append to */
61
+ path: string;
62
+ /** Content to append (will be added with a leading newline) */
63
+ content: string;
64
+ reason: string;
65
+ source: "deterministic" | "ai";
66
+ };
67
+
68
+ export interface FixReport {
69
+ /** All actions that were planned (not necessarily applied) */
70
+ planned: FixAction[];
71
+ /** Actions actually applied to the filesystem */
72
+ applied: FixAction[];
73
+ /**
74
+ * Actions that were planned but the executor refused to apply
75
+ * (e.g. file already exists, `oldLine` not found in target file).
76
+ * Surfacing these is critical for debugging why Stage B "planned 2, applied 0" cases.
77
+ */
78
+ skipped: Array<{ action: FixAction; reason: string }>;
79
+ /** Broken imports Stage A planned a deterministic fix for */
80
+ deterministicCount: number;
81
+ /** Broken imports Stage B planned an AI fix for */
82
+ aiFixedCount: number;
83
+ /**
84
+ * Broken imports that remain broken after fix attempts. Computed as
85
+ * `brokenImports.length - uniqueBrokenActuallyApplied.size` so it only
86
+ * counts broken imports whose corresponding fix was actually applied.
87
+ */
88
+ unresolvedCount: number;
89
+ /** Application errors (e.g. file write failures that threw) */
90
+ errors: Array<{ action: FixAction; error: string }>;
91
+ }
92
+
93
+ // ─── Stage A: Deterministic DSL-driven fix ────────────────────────────────────
94
+
95
+ /**
96
+ * Match a symbol name to a DSL model.
97
+ * - Exact match (case-sensitive): "Task" → Task
98
+ * - Case-insensitive: "task" → Task
99
+ * - Stripped of common suffixes: "TaskItem" → first try Task, then TaskItem
100
+ */
101
+ function findDslModel(name: string, dsl: SpecDSL): DataModel | null {
102
+ const exact = dsl.models.find((m) => m.name === name);
103
+ if (exact) return exact;
104
+ const ci = dsl.models.find((m) => m.name.toLowerCase() === name.toLowerCase());
105
+ if (ci) return ci;
106
+ return null;
107
+ }
108
+
109
+ /**
110
+ * Find the best available export that looks like a rename of the requested symbol.
111
+ *
112
+ * Scoring rules (highest priority first):
113
+ * 1. Exact match (case-insensitive)
114
+ * 2. Requested name is a prefix of the available name (e.g. "Task" → "TaskItem")
115
+ * 3. Requested name is a suffix of the available name (e.g. "Item" → "TaskItem")
116
+ * 4. Requested name is a substring of the available name
117
+ *
118
+ * Among equal-scoring candidates, the shortest name wins (most specific match).
119
+ * Returns null if nothing scores above the threshold.
120
+ */
121
+ export function findRenameCandidate(
122
+ requested: string,
123
+ available: string[]
124
+ ): string | null {
125
+ if (available.length === 0) return null;
126
+ const reqLower = requested.toLowerCase();
127
+
128
+ interface Candidate {
129
+ name: string;
130
+ score: number;
131
+ length: number;
132
+ }
133
+ const candidates: Candidate[] = [];
134
+
135
+ for (const name of available) {
136
+ const nameLower = name.toLowerCase();
137
+ let score = 0;
138
+ if (nameLower === reqLower) score = 100;
139
+ else if (nameLower.startsWith(reqLower)) score = 80;
140
+ else if (nameLower.endsWith(reqLower)) score = 60;
141
+ else if (nameLower.includes(reqLower)) score = 40;
142
+ if (score >= 40) {
143
+ candidates.push({ name, score, length: name.length });
144
+ }
145
+ }
146
+
147
+ if (candidates.length === 0) return null;
148
+ candidates.sort((a, b) => {
149
+ if (b.score !== a.score) return b.score - a.score;
150
+ return a.length - b.length;
151
+ });
152
+ return candidates[0].name;
153
+ }
154
+
155
+ /**
156
+ * Build a new import line where the broken named symbols are rewritten to use
157
+ * their rename candidates via `{ Original as Renamed }` aliasing.
158
+ *
159
+ * Example:
160
+ * oldLine: import type { Task } from '@/apis/task/type'
161
+ * renameMap: { Task: 'TaskItem' }
162
+ * result: import type { TaskItem as Task } from '@/apis/task/type'
163
+ */
164
+ function rewriteImportWithRenames(
165
+ oldLine: string,
166
+ renameMap: Map<string, string>
167
+ ): string {
168
+ // Match the named-imports block: anything between { and }
169
+ return oldLine.replace(/\{([^}]+)\}/, (_, inner) => {
170
+ const parts = inner
171
+ .split(",")
172
+ .map((p: string) => p.trim())
173
+ .filter(Boolean)
174
+ .map((p: string) => {
175
+ // Strip optional `type` modifier to find the bare name
176
+ const typePrefix = /^type\s+/.test(p) ? "type " : "";
177
+ const bare = p.replace(/^type\s+/, "");
178
+ // Already aliased? (`Foo as Bar`) — leave alone
179
+ if (/\bas\b/.test(bare)) return p;
180
+ const newName = renameMap.get(bare);
181
+ if (!newName) return p;
182
+ return `${typePrefix}${newName} as ${bare}`;
183
+ });
184
+ return `{ ${parts.join(", ")} }`;
185
+ });
186
+ }
187
+
188
+ /**
189
+ * Try to convert one broken import into a deterministic fix action.
190
+ *
191
+ * Returns null if Stage A cannot handle this import (Stage B will be tried).
192
+ *
193
+ * Three strategies, tried in order:
194
+ *
195
+ * 1. DSL file stub (file_not_found + all names match DSL models)
196
+ * → create_file with rendered interfaces
197
+ *
198
+ * 2. DSL append (missing_export + all names match DSL models)
199
+ * → append_to_file with rendered interfaces
200
+ *
201
+ * 3. Rename rewrite (missing_export + target file has similar exports)
202
+ * → rewrite_import with `{ OriginalName as RequestedName }` aliasing
203
+ * This catches the common case where the AI generated a types file with
204
+ * `TaskItem` but consumers import `{ Task }` in the same run.
205
+ */
206
+ export function planDeterministicFix(
207
+ broken: BrokenImport,
208
+ dsl: SpecDSL | null,
209
+ repoRoot: string,
210
+ sourceLine?: string
211
+ ): FixAction | null {
212
+ const ref = broken.ref;
213
+ if (ref.importedNames.length === 0) return null;
214
+
215
+ // ── Strategy 3: rename rewrite (try FIRST because it's the safest — it doesn't
216
+ // ── create new files, just rewrites an existing import to use the right symbol)
217
+ if (
218
+ broken.reason === "missing_export" &&
219
+ broken.availableExports &&
220
+ broken.availableExports.length > 0 &&
221
+ sourceLine
222
+ ) {
223
+ const renameMap = new Map<string, string>();
224
+ const missingNames = broken.missingExports ?? ref.importedNames;
225
+ for (const missing of missingNames) {
226
+ const candidate = findRenameCandidate(missing, broken.availableExports);
227
+ if (candidate) renameMap.set(missing, candidate);
228
+ }
229
+ if (renameMap.size === missingNames.length) {
230
+ // Every missing symbol has a rename target — build a full rewrite action
231
+ const newLine = rewriteImportWithRenames(sourceLine, renameMap);
232
+ if (newLine !== sourceLine) {
233
+ const renames = [...renameMap.entries()]
234
+ .map(([old, neu]) => `${old} → ${neu}`)
235
+ .join(", ");
236
+ return {
237
+ kind: "rewrite_import",
238
+ file: ref.file,
239
+ oldLine: sourceLine,
240
+ newLine,
241
+ reason: `Rename import to match actual exports: ${renames}`,
242
+ source: "deterministic",
243
+ };
244
+ }
245
+ }
246
+ }
247
+
248
+ // Strategies 1 + 2 need DSL models
249
+ if (!dsl) return null;
250
+
251
+ // Resolve every named import against DSL models.
252
+ const matchedModels: DataModel[] = [];
253
+ for (const name of ref.importedNames) {
254
+ const model = findDslModel(name, dsl);
255
+ if (!model) return null; // not a DSL model — Stage A passes
256
+ matchedModels.push(model);
257
+ }
258
+
259
+ // Build the TypeScript stub content using the same renderer as `ai-spec types`
260
+ const interfaces = matchedModels
261
+ .map((m) => renderModelInterface(m.name, m.fields, m.description))
262
+ .join("\n\n");
263
+ const header = `/**\n * Auto-generated by ai-spec import-fixer (deterministic).\n * Source: DSL models — ${matchedModels.map((m) => m.name).join(", ")}\n */\n\n`;
264
+ const content = header + interfaces + "\n";
265
+
266
+ // ── Strategy 1: DSL-driven create_file for file_not_found
267
+ if (broken.reason === "file_not_found") {
268
+ const expectedRel = extractExpectedPath(broken.suggestion ?? "", ref);
269
+ if (!expectedRel) return null;
270
+ return {
271
+ kind: "create_file",
272
+ path: expectedRel,
273
+ content,
274
+ reason: `Stub file generated from DSL model(s): ${matchedModels.map((m) => m.name).join(", ")}`,
275
+ source: "deterministic",
276
+ };
277
+ }
278
+
279
+ // ── Strategy 2: DSL-driven append_to_file for missing_export
280
+ if (broken.reason === "missing_export" && broken.ref.resolvedPath) {
281
+ const targetRel = path.relative(repoRoot, broken.ref.resolvedPath);
282
+ return {
283
+ kind: "append_to_file",
284
+ path: targetRel,
285
+ content: "\n" + interfaces + "\n",
286
+ reason: `Append DSL-derived interfaces: ${matchedModels.map((m) => m.name).join(", ")}`,
287
+ source: "deterministic",
288
+ };
289
+ }
290
+
291
+ return null;
292
+ }
293
+
294
+ /**
295
+ * Pull the first expected file path out of a verifier suggestion string.
296
+ * Falls back to deriving from the import source if the suggestion is unusable.
297
+ */
298
+ function extractExpectedPath(suggestion: string, ref: ImportRef): string | null {
299
+ // Verifier suggestion format:
300
+ // "expected at: src/apis/task/type.{ts,tsx,...} or src/apis/task/type/index.*"
301
+ // Capture the first whitespace-bounded token, then strip the brace-expansion suffix.
302
+ const m = suggestion.match(/expected at:\s*(\S+)/);
303
+ if (m) {
304
+ const clean = m[1].replace(/\.\{[^}]+\}$/, "");
305
+ return clean + ".ts";
306
+ }
307
+ // Fallback: derive from the import source assuming @/* → src/*
308
+ if (ref.source.startsWith("@/")) {
309
+ return "src/" + ref.source.slice(2) + ".ts";
310
+ }
311
+ return null;
312
+ }
313
+
314
+ // ─── Stage B: AI fix loop ─────────────────────────────────────────────────────
315
+
316
+ /**
317
+ * Build the focused prompt sent to the codegen provider for AI fixes.
318
+ * Designed to minimize tokens while giving the AI enough context.
319
+ */
320
+ export function buildAiFixPrompt(opts: {
321
+ brokenImports: BrokenImport[];
322
+ generatedFilePaths: string[];
323
+ dsl: SpecDSL | null;
324
+ }): string {
325
+ const { brokenImports, generatedFilePaths, dsl } = opts;
326
+
327
+ const brokenSection = brokenImports
328
+ .map((b) => {
329
+ const lines: string[] = [];
330
+ lines.push(`- ${b.ref.file}:${b.ref.line}`);
331
+ if (b.reason === "missing_export") {
332
+ lines.push(
333
+ ` ❌ missing_export: { ${b.missingExports!.join(", ")} } from '${b.ref.source}'`
334
+ );
335
+ lines.push(
336
+ ` ⚠ TARGET FILE EXISTS — prefer rewrite_import over create_file.`
337
+ );
338
+ if (b.availableExports && b.availableExports.length > 0) {
339
+ lines.push(
340
+ ` available exports in that file: ${b.availableExports.slice(0, 12).join(", ")}${b.availableExports.length > 12 ? ", ..." : ""}`
341
+ );
342
+ }
343
+ } else {
344
+ lines.push(
345
+ ` ❌ file_not_found: { ${b.ref.importedNames.join(", ")} } from '${b.ref.source}'`
346
+ );
347
+ if (b.suggestion) lines.push(` ${b.suggestion}`);
348
+ }
349
+ return lines.join("\n");
350
+ })
351
+ .join("\n\n");
352
+
353
+ const dslSection = dsl
354
+ ? `=== Available DSL Models ===\n${dsl.models
355
+ .map(
356
+ (m) =>
357
+ `${m.name}: { ${m.fields.map((f) => `${f.name}${f.required ? "" : "?"}: ${f.type}`).join(", ")} }`
358
+ )
359
+ .join("\n")}`
360
+ : "=== No DSL available ===";
361
+
362
+ return `You are repairing broken imports in a freshly generated codebase. Output ONLY a JSON array of fix actions — no markdown, no commentary.
363
+
364
+ === Broken Imports ===
365
+ ${brokenSection}
366
+
367
+ ${dslSection}
368
+
369
+ === Existing Generated Files ===
370
+ ${generatedFilePaths.map((f) => `- ${f}`).join("\n")}
371
+
372
+ === Output Format ===
373
+ Return a JSON array. Each element is one of:
374
+
375
+ { "kind": "create_file", "path": "src/apis/task/type.ts", "content": "export interface Task { ... }", "reason": "..." }
376
+ { "kind": "rewrite_import", "file": "src/views/x.vue", "oldLine": "import { Task } from '@/apis/task/type'", "newLine": "import { Task } from '@/apis/task'", "reason": "..." }
377
+ { "kind": "append_to_file", "path": "src/apis/task/index.ts", "content": "export interface Task { id: number }", "reason": "..." }
378
+
379
+ Rules:
380
+ 1. **CRITICAL — missing_export handling**: When the broken import reason is
381
+ missing_export, the target file ALREADY EXISTS with other valid exports.
382
+ You MUST NOT use create_file (executor will refuse to overwrite).
383
+ Instead, use rewrite_import to alias the existing export, e.g.
384
+ oldLine: import { Task } from '@/apis/task/type'
385
+ newLine: import { TaskItem as Task } from '@/apis/task/type'
386
+ Pick the closest-matching name from "available exports in that file".
387
+ 2. **oldLine must match the source VERBATIM** including indentation and quotes.
388
+ If unsure, prefer leaving the import alone over guessing.
389
+ 3. For file_not_found, prefer rewrite_import when the symbol clearly exists at
390
+ a different path in the "Existing Generated Files" list. Only use create_file
391
+ when the symbol genuinely does not exist anywhere.
392
+ 4. Use DSL models as the schema source of truth when you do need to create files.
393
+ 5. Do not introduce imports or symbols beyond what is needed to fix the broken references.
394
+ 6. Output ONLY the JSON array. No prose. No markdown fences.`;
395
+ }
396
+
397
+ /**
398
+ * Parse the AI's JSON output into FixAction array. Tolerates code fences,
399
+ * extra whitespace, and partial responses.
400
+ */
401
+ export function parseAiFixActions(rawResponse: string): FixAction[] {
402
+ const cleaned = stripCodeFences(rawResponse).trim();
403
+ // Try direct JSON.parse first
404
+ try {
405
+ const parsed = JSON.parse(cleaned);
406
+ if (Array.isArray(parsed)) return validateActions(parsed);
407
+ } catch {
408
+ /* fall through */
409
+ }
410
+ // Fallback: use the codegen helper that finds an array inside arbitrary text
411
+ const arr = parseJsonArray(cleaned);
412
+ if (Array.isArray(arr)) return validateActions(arr as unknown[]);
413
+ return [];
414
+ }
415
+
416
+ function validateActions(arr: unknown[]): FixAction[] {
417
+ const valid: FixAction[] = [];
418
+ for (const item of arr) {
419
+ if (!item || typeof item !== "object") continue;
420
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
421
+ const a = item as any;
422
+ if (a.kind === "create_file" && typeof a.path === "string" && typeof a.content === "string") {
423
+ valid.push({
424
+ kind: "create_file",
425
+ path: a.path,
426
+ content: a.content,
427
+ reason: a.reason ?? "AI-generated fix",
428
+ source: "ai",
429
+ });
430
+ } else if (
431
+ a.kind === "rewrite_import" &&
432
+ typeof a.file === "string" &&
433
+ typeof a.oldLine === "string" &&
434
+ typeof a.newLine === "string"
435
+ ) {
436
+ valid.push({
437
+ kind: "rewrite_import",
438
+ file: a.file,
439
+ oldLine: a.oldLine,
440
+ newLine: a.newLine,
441
+ reason: a.reason ?? "AI-generated fix",
442
+ source: "ai",
443
+ });
444
+ } else if (
445
+ a.kind === "append_to_file" &&
446
+ typeof a.path === "string" &&
447
+ typeof a.content === "string"
448
+ ) {
449
+ valid.push({
450
+ kind: "append_to_file",
451
+ path: a.path,
452
+ content: a.content,
453
+ reason: a.reason ?? "AI-generated fix",
454
+ source: "ai",
455
+ });
456
+ }
457
+ }
458
+ return valid;
459
+ }
460
+
461
+ // ─── Action executor ──────────────────────────────────────────────────────────
462
+
463
+ /**
464
+ * Apply a single FixAction to the filesystem. Idempotent where possible:
465
+ * - create_file: skip if file already exists with identical content
466
+ * - rewrite_import: skip if oldLine cannot be found (already fixed)
467
+ * - append_to_file: skip if content already present in file
468
+ */
469
+ export async function applyFixAction(
470
+ action: FixAction,
471
+ repoRoot: string
472
+ ): Promise<{ applied: boolean; reason?: string }> {
473
+ if (action.kind === "create_file") {
474
+ const abs = path.join(repoRoot, action.path);
475
+ if (await fs.pathExists(abs)) {
476
+ const existing = await fs.readFile(abs, "utf-8");
477
+ if (existing === action.content) return { applied: false, reason: "identical content already exists" };
478
+ // Don't overwrite a non-empty file silently — back up and warn
479
+ return { applied: false, reason: `file already exists at ${action.path} — refusing to overwrite` };
480
+ }
481
+ await fs.ensureDir(path.dirname(abs));
482
+ await fs.writeFile(abs, action.content, "utf-8");
483
+ return { applied: true };
484
+ }
485
+
486
+ if (action.kind === "rewrite_import") {
487
+ const abs = path.join(repoRoot, action.file);
488
+ if (!(await fs.pathExists(abs))) return { applied: false, reason: "target file not found" };
489
+ const src = await fs.readFile(abs, "utf-8");
490
+ if (!src.includes(action.oldLine)) {
491
+ return { applied: false, reason: "old import line not found (may already be fixed)" };
492
+ }
493
+ const updated = src.replace(action.oldLine, action.newLine);
494
+ await fs.writeFile(abs, updated, "utf-8");
495
+ return { applied: true };
496
+ }
497
+
498
+ if (action.kind === "append_to_file") {
499
+ const abs = path.join(repoRoot, action.path);
500
+ if (!(await fs.pathExists(abs))) return { applied: false, reason: "target file not found" };
501
+ const existing = await fs.readFile(abs, "utf-8");
502
+ if (existing.includes(action.content.trim())) {
503
+ return { applied: false, reason: "content already present" };
504
+ }
505
+ await fs.writeFile(abs, existing + action.content, "utf-8");
506
+ return { applied: true };
507
+ }
508
+
509
+ return { applied: false, reason: "unknown action kind" };
510
+ }
511
+
512
+ // ─── Broken import ↔ fix action matching (for Stage B) ───────────────────────
513
+
514
+ /**
515
+ * Heuristic: figure out which broken import a given AI-produced action was
516
+ * meant to fix. Used so we can write the right entry to the fix-history ledger.
517
+ *
518
+ * - create_file: match if the target path is under the same directory as the
519
+ * broken import's expected resolution (e.g. '@/utils/foo' → 'src/utils/foo.ts')
520
+ * - rewrite_import: match if the oldLine contains the broken source literal
521
+ * - append_to_file: match if the target path contains a segment of the broken source
522
+ *
523
+ * Returns -1 if nothing matches with reasonable confidence.
524
+ */
525
+ function findBestBrokenMatch(
526
+ action: FixAction,
527
+ candidates: BrokenImport[]
528
+ ): number {
529
+ for (let i = 0; i < candidates.length; i++) {
530
+ const broken = candidates[i];
531
+ if (action.kind === "rewrite_import") {
532
+ if (action.oldLine.includes(broken.ref.source)) return i;
533
+ } else if (action.kind === "create_file") {
534
+ // Convert @/apis/foo → src/apis/foo (common alias), then match prefix
535
+ const normalizedSource = broken.ref.source.replace(/^@\//, "src/");
536
+ if (action.path.includes(normalizedSource)) return i;
537
+ } else if (action.kind === "append_to_file") {
538
+ const normalizedSource = broken.ref.source.replace(/^@\//, "src/");
539
+ if (action.path.includes(normalizedSource.split("/").slice(0, -1).join("/"))) return i;
540
+ }
541
+ }
542
+ return -1;
543
+ }
544
+
545
+ // ─── Ledger recording ─────────────────────────────────────────────────────────
546
+
547
+ /**
548
+ * Write one fix entry to `.ai-spec-fix-history.json`. Failures here are
549
+ * non-fatal — the fix itself already succeeded, we just couldn't record it.
550
+ */
551
+ async function recordFixToHistory(
552
+ repoRoot: string,
553
+ runId: string,
554
+ action: FixAction,
555
+ broken: BrokenImport
556
+ ): Promise<void> {
557
+ // Figure out the target path depending on action kind
558
+ let target = "";
559
+ if (action.kind === "create_file") target = action.path;
560
+ else if (action.kind === "rewrite_import") target = action.file;
561
+ else target = action.path;
562
+
563
+ await appendFixEntry(repoRoot, {
564
+ ts: new Date().toISOString(),
565
+ runId,
566
+ brokenImport: {
567
+ source: broken.ref.source,
568
+ names: broken.ref.importedNames,
569
+ reason: broken.reason,
570
+ file: broken.ref.file,
571
+ line: broken.ref.line,
572
+ },
573
+ fix: {
574
+ kind: action.kind,
575
+ target,
576
+ stage: action.source,
577
+ },
578
+ });
579
+ }
580
+
581
+ // ─── Dispatcher ───────────────────────────────────────────────────────────────
582
+
583
+ /**
584
+ * Run the full fix loop:
585
+ * 1. For each broken import, try Stage A (deterministic).
586
+ * 2. Bundle the rest into one Stage B prompt and call the AI provider.
587
+ * 3. Apply all collected actions.
588
+ *
589
+ * The caller is responsible for re-running the verifier afterwards to confirm
590
+ * the fixes worked.
591
+ */
592
+ export async function runImportFix(opts: {
593
+ brokenImports: BrokenImport[];
594
+ dsl: SpecDSL | null;
595
+ repoRoot: string;
596
+ generatedFilePaths: string[];
597
+ /** Provider used for Stage B AI fixes. If absent, Stage B is skipped. */
598
+ provider?: AIProvider;
599
+ /** Run ID of the pipeline run — recorded in the fix-history ledger */
600
+ runId?: string;
601
+ /** When true, successful fixes are appended to `.ai-spec-fix-history.json` */
602
+ recordHistory?: boolean;
603
+ }): Promise<FixReport> {
604
+ const { brokenImports, dsl, repoRoot, generatedFilePaths, provider, runId, recordHistory } = opts;
605
+
606
+ // Each planned item tracks which broken import it came from (for ledger writes)
607
+ interface PlannedItem {
608
+ action: FixAction;
609
+ broken: BrokenImport;
610
+ }
611
+ const plannedItems: PlannedItem[] = [];
612
+ const applied: FixAction[] = [];
613
+ const skipped: Array<{ action: FixAction; reason: string }> = [];
614
+ const errors: Array<{ action: FixAction; error: string }> = [];
615
+ let deterministicCount = 0;
616
+ let aiFixedCount = 0;
617
+
618
+ // Helper: read the exact source line for a broken import so Stage A can
619
+ // propose a `rewrite_import` action with an `oldLine` that matches verbatim.
620
+ // Caches per file to avoid reading the same file repeatedly for multiple imports.
621
+ const lineCache = new Map<string, string[]>();
622
+ async function getSourceLine(broken: BrokenImport): Promise<string | undefined> {
623
+ const fileRel = broken.ref.file;
624
+ const fileAbs = path.isAbsolute(fileRel) ? fileRel : path.join(repoRoot, fileRel);
625
+ let lines = lineCache.get(fileAbs);
626
+ if (!lines) {
627
+ try {
628
+ const src = await fs.readFile(fileAbs, "utf-8");
629
+ lines = src.split("\n");
630
+ lineCache.set(fileAbs, lines);
631
+ } catch {
632
+ return undefined;
633
+ }
634
+ }
635
+ // broken.ref.line is 1-indexed
636
+ return lines[broken.ref.line - 1];
637
+ }
638
+
639
+ // ── Stage A: deterministic ──────────────────────────────────────────────────
640
+ // Stage A now runs even without a DSL because Strategy 3 (rename rewrite)
641
+ // only needs the target file's available exports, not the DSL.
642
+ const remaining: BrokenImport[] = [];
643
+ for (const broken of brokenImports) {
644
+ const sourceLine = await getSourceLine(broken);
645
+ const action = planDeterministicFix(broken, dsl, repoRoot, sourceLine);
646
+ if (action) {
647
+ plannedItems.push({ action, broken });
648
+ deterministicCount++;
649
+ } else {
650
+ remaining.push(broken);
651
+ }
652
+ }
653
+
654
+ // ── Stage B: AI fix for what's left ─────────────────────────────────────────
655
+ if (remaining.length > 0 && provider) {
656
+ try {
657
+ const prompt = buildAiFixPrompt({
658
+ brokenImports: remaining,
659
+ generatedFilePaths,
660
+ dsl,
661
+ });
662
+ const response = await provider.generate(
663
+ prompt,
664
+ "You are a precise code-repair tool. Output only the requested JSON array."
665
+ );
666
+ const aiActions = parseAiFixActions(response);
667
+
668
+ // Associate each AI action back to the broken import it likely fixed.
669
+ // Heuristic: prefer the remaining broken whose source matches the action's
670
+ // file/path; fall back to consuming remaining in order.
671
+ const unmatched = [...remaining];
672
+ for (const action of aiActions) {
673
+ const matchIdx = findBestBrokenMatch(action, unmatched);
674
+ if (matchIdx >= 0) {
675
+ plannedItems.push({ action, broken: unmatched[matchIdx] });
676
+ unmatched.splice(matchIdx, 1);
677
+ } else if (unmatched.length > 0) {
678
+ plannedItems.push({ action, broken: unmatched.shift()! });
679
+ } else {
680
+ // Action has no corresponding broken — still plan it but leave broken undefined
681
+ plannedItems.push({ action, broken: remaining[0] });
682
+ }
683
+ aiFixedCount++;
684
+ }
685
+ } catch (err) {
686
+ console.log(chalk.yellow(` ⚠ AI import fix failed: ${(err as Error).message}`));
687
+ }
688
+ }
689
+
690
+ // ── Execute actions + record ledger entries ────────────────────────────────
691
+ // Track which broken imports actually got a successfully-applied fix so we
692
+ // can compute unresolvedCount honestly (not based on planned count).
693
+ const resolvedBrokenRefs = new Set<BrokenImport>();
694
+ for (const item of plannedItems) {
695
+ try {
696
+ const result = await applyFixAction(item.action, repoRoot);
697
+ if (result.applied) {
698
+ applied.push(item.action);
699
+ resolvedBrokenRefs.add(item.broken);
700
+ // Record to fix history only when the caller opted in AND we have a runId
701
+ if (recordHistory && runId) {
702
+ try {
703
+ await recordFixToHistory(repoRoot, runId, item.action, item.broken);
704
+ } catch (err) {
705
+ console.log(
706
+ chalk.gray(` (fix-history write skipped: ${(err as Error).message})`)
707
+ );
708
+ }
709
+ }
710
+ } else {
711
+ // Action was planned but executor refused (file exists, oldLine missing, etc.)
712
+ skipped.push({
713
+ action: item.action,
714
+ reason: result.reason ?? "unknown",
715
+ });
716
+ }
717
+ } catch (err) {
718
+ errors.push({ action: item.action, error: (err as Error).message });
719
+ }
720
+ }
721
+
722
+ // For FixReport compatibility, extract the plain action list
723
+ const planned = plannedItems.map((p) => p.action);
724
+
725
+ // Honest unresolved count: broken imports whose fix either was never planned
726
+ // OR was planned but failed to apply. `aiFixedCount` still reports *planned*
727
+ // AI actions so users can see AI's activity, but this is the real number.
728
+ const unresolvedCount = brokenImports.length - resolvedBrokenRefs.size;
729
+
730
+ return {
731
+ planned,
732
+ applied,
733
+ skipped,
734
+ deterministicCount,
735
+ aiFixedCount,
736
+ unresolvedCount,
737
+ errors,
738
+ };
739
+ }
740
+
741
+ // ─── Display ──────────────────────────────────────────────────────────────────
742
+
743
+ function formatAction(action: FixAction): string {
744
+ if (action.kind === "create_file") return `+ ${action.path}`;
745
+ if (action.kind === "rewrite_import") return `~ ${action.file} (rewrite import)`;
746
+ return `~ ${action.path} (append)`;
747
+ }
748
+
749
+ export function printFixReport(repoName: string, report: FixReport): void {
750
+ console.log(chalk.cyan(`\n─── Import Auto-Fix [${repoName}] ────────────────────────`));
751
+ console.log(
752
+ chalk.gray(
753
+ ` Stage A (deterministic): ${report.deterministicCount} action(s) planned`
754
+ )
755
+ );
756
+ console.log(
757
+ chalk.gray(
758
+ ` Stage B (AI): ${report.aiFixedCount} action(s) planned`
759
+ )
760
+ );
761
+
762
+ const summaryTag = report.unresolvedCount === 0
763
+ ? chalk.green(`Unresolved: 0`)
764
+ : chalk.red(`Unresolved: ${report.unresolvedCount}`);
765
+ console.log(
766
+ chalk.gray(
767
+ ` Applied: ${report.applied.length}/${report.planned.length} · Skipped: ${report.skipped.length} · Errors: ${report.errors.length} · `
768
+ ) + summaryTag
769
+ );
770
+
771
+ if (report.planned.length === 0) {
772
+ console.log(
773
+ chalk.gray(
774
+ `\n ⊘ No fixes planned (Stage A found no matching DSL models / renameable exports, and Stage B produced nothing).`
775
+ )
776
+ );
777
+ console.log(chalk.cyan("─".repeat(60)));
778
+ return;
779
+ }
780
+
781
+ // ── Applied actions ────────────────────────────────────────────────────────
782
+ if (report.applied.length > 0) {
783
+ console.log(chalk.green(`\n ✔ Applied (${report.applied.length}):`));
784
+ for (const action of report.applied) {
785
+ const tag = action.source === "deterministic" ? chalk.green("[DSL]") : chalk.cyan("[AI ]");
786
+ console.log(` ${tag} ${formatAction(action)}`);
787
+ console.log(chalk.gray(` ${action.reason}`));
788
+ }
789
+ }
790
+
791
+ // ── Skipped actions (with reason — critical for debugging) ────────────────
792
+ if (report.skipped.length > 0) {
793
+ console.log(chalk.yellow(`\n ⊘ Skipped (${report.skipped.length}) — executor refused to apply:`));
794
+ for (const s of report.skipped) {
795
+ const tag = s.action.source === "deterministic" ? chalk.green("[DSL]") : chalk.cyan("[AI ]");
796
+ console.log(` ${tag} ${formatAction(s.action)}`);
797
+ console.log(chalk.gray(` reason: ${s.reason}`));
798
+ if (s.action.kind === "rewrite_import") {
799
+ console.log(chalk.gray(` old: ${s.action.oldLine.slice(0, 80)}`));
800
+ console.log(chalk.gray(` new: ${s.action.newLine.slice(0, 80)}`));
801
+ }
802
+ }
803
+ }
804
+
805
+ // ── Hard errors (applyFixAction threw) ────────────────────────────────────
806
+ if (report.errors.length > 0) {
807
+ console.log(chalk.red(`\n ✘ Errors (${report.errors.length}) — action threw during execution:`));
808
+ for (const e of report.errors.slice(0, 5)) {
809
+ console.log(chalk.gray(` ${formatAction(e.action)} → ${e.error}`));
810
+ }
811
+ if (report.errors.length > 5) {
812
+ console.log(chalk.gray(` ... and ${report.errors.length - 5} more`));
813
+ }
814
+ }
815
+
816
+ // ── Bottom line ───────────────────────────────────────────────────────────
817
+ if (report.unresolvedCount > 0) {
818
+ console.log(
819
+ chalk.yellow(
820
+ `\n ⚠ ${report.unresolvedCount} broken import(s) remain after fix attempts. Manual review needed.`
821
+ )
822
+ );
823
+ } else if (report.applied.length > 0) {
824
+ console.log(chalk.green(`\n ✔ All broken imports resolved.`));
825
+ }
826
+ console.log(chalk.cyan("─".repeat(60)));
827
+ }