ai-spec-dev 0.46.0 → 0.55.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 +60 -30
- package/cli/commands/config.ts +129 -1
- package/cli/commands/create.ts +14 -0
- package/cli/commands/fix-history.ts +176 -0
- package/cli/commands/init.ts +36 -1
- package/cli/index.ts +2 -6
- package/cli/pipeline/helpers.ts +6 -0
- package/cli/pipeline/multi-repo.ts +291 -26
- package/cli/pipeline/single-repo.ts +103 -2
- package/cli/utils.ts +23 -0
- package/core/code-generator.ts +63 -14
- package/core/cross-stack-verifier.ts +395 -0
- package/core/fix-history.ts +333 -0
- package/core/import-fixer.ts +827 -0
- package/core/import-verifier.ts +569 -0
- package/core/knowledge-memory.ts +55 -6
- package/core/self-evaluator.ts +44 -7
- package/core/spec-generator.ts +3 -3
- package/core/types-generator.ts +2 -2
- package/dist/cli/index.js +3759 -2207
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +3747 -2195
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +14 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +249 -128
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +249 -128
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/tests/cross-stack-verifier.test.ts +301 -0
- package/tests/fix-history.test.ts +335 -0
- package/tests/import-fixer.test.ts +944 -0
- package/tests/import-verifier.test.ts +420 -0
- package/tests/knowledge-memory.test.ts +40 -0
- package/tests/self-evaluator.test.ts +97 -0
- package/cli/commands/model.ts +0 -152
- package/cli/commands/scan.ts +0 -99
- package/cli/commands/workspace.ts +0 -219
|
@@ -0,0 +1,944 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import * as fs from "fs-extra";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import * as os from "os";
|
|
5
|
+
import {
|
|
6
|
+
planDeterministicFix,
|
|
7
|
+
buildAiFixPrompt,
|
|
8
|
+
parseAiFixActions,
|
|
9
|
+
applyFixAction,
|
|
10
|
+
runImportFix,
|
|
11
|
+
findRenameCandidate,
|
|
12
|
+
FixAction,
|
|
13
|
+
} from "../core/import-fixer";
|
|
14
|
+
import type { SpecDSL } from "../core/dsl-types";
|
|
15
|
+
import type { BrokenImport } from "../core/import-verifier";
|
|
16
|
+
import type { AIProvider } from "../core/spec-generator";
|
|
17
|
+
import { verifyImports } from "../core/import-verifier";
|
|
18
|
+
|
|
19
|
+
// ─── Test fixtures ────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const TASK_DSL: SpecDSL = {
|
|
22
|
+
version: "1.0",
|
|
23
|
+
feature: { id: "task", title: "Task management", description: "CRUD for tasks" },
|
|
24
|
+
models: [
|
|
25
|
+
{
|
|
26
|
+
name: "Task",
|
|
27
|
+
fields: [
|
|
28
|
+
{ name: "id", type: "Int", required: true },
|
|
29
|
+
{ name: "title", type: "String", required: true },
|
|
30
|
+
{ name: "desc", type: "String", required: false },
|
|
31
|
+
{ name: "createdAt", type: "DateTime", required: true },
|
|
32
|
+
],
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: "TaskCreateParams",
|
|
36
|
+
fields: [
|
|
37
|
+
{ name: "title", type: "String", required: true },
|
|
38
|
+
{ name: "desc", type: "String", required: false },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
endpoints: [],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
function makeBrokenImport(opts: {
|
|
46
|
+
source: string;
|
|
47
|
+
importedNames: string[];
|
|
48
|
+
reason: "file_not_found" | "missing_export";
|
|
49
|
+
file?: string;
|
|
50
|
+
line?: number;
|
|
51
|
+
suggestion?: string;
|
|
52
|
+
resolvedPath?: string;
|
|
53
|
+
missingExports?: string[];
|
|
54
|
+
}): BrokenImport {
|
|
55
|
+
return {
|
|
56
|
+
ref: {
|
|
57
|
+
source: opts.source,
|
|
58
|
+
importedNames: opts.importedNames,
|
|
59
|
+
isTypeOnly: false,
|
|
60
|
+
hasDefault: false,
|
|
61
|
+
file: opts.file ?? "src/x.ts",
|
|
62
|
+
line: opts.line ?? 1,
|
|
63
|
+
resolvedPath: opts.resolvedPath,
|
|
64
|
+
},
|
|
65
|
+
reason: opts.reason,
|
|
66
|
+
missingExports: opts.missingExports,
|
|
67
|
+
suggestion: opts.suggestion,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Stage A: planDeterministicFix ────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe("planDeterministicFix", () => {
|
|
74
|
+
it("creates a stub file when imported symbol matches a DSL model", () => {
|
|
75
|
+
const broken = makeBrokenImport({
|
|
76
|
+
source: "@/apis/task/type",
|
|
77
|
+
importedNames: ["Task"],
|
|
78
|
+
reason: "file_not_found",
|
|
79
|
+
suggestion: "expected at: src/apis/task/type.{ts,tsx,js,jsx,vue} or src/apis/task/type/index.*",
|
|
80
|
+
});
|
|
81
|
+
const action = planDeterministicFix(broken, TASK_DSL, "/repo");
|
|
82
|
+
expect(action).not.toBeNull();
|
|
83
|
+
expect(action!.kind).toBe("create_file");
|
|
84
|
+
if (action!.kind === "create_file") {
|
|
85
|
+
expect(action.path).toBe("src/apis/task/type.ts");
|
|
86
|
+
expect(action.content).toContain("export interface Task");
|
|
87
|
+
expect(action.content).toContain("title: string");
|
|
88
|
+
expect(action.content).toContain("desc?: string");
|
|
89
|
+
expect(action.source).toBe("deterministic");
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("handles multiple symbols matching multiple DSL models in one import", () => {
|
|
94
|
+
const broken = makeBrokenImport({
|
|
95
|
+
source: "@/apis/task/type",
|
|
96
|
+
importedNames: ["Task", "TaskCreateParams"],
|
|
97
|
+
reason: "file_not_found",
|
|
98
|
+
suggestion: "expected at: src/apis/task/type.{ts}",
|
|
99
|
+
});
|
|
100
|
+
const action = planDeterministicFix(broken, TASK_DSL, "/repo");
|
|
101
|
+
expect(action!.kind).toBe("create_file");
|
|
102
|
+
if (action!.kind === "create_file") {
|
|
103
|
+
expect(action.content).toContain("export interface Task");
|
|
104
|
+
expect(action.content).toContain("export interface TaskCreateParams");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns null when even one symbol does not match any DSL model", () => {
|
|
109
|
+
const broken = makeBrokenImport({
|
|
110
|
+
source: "@/apis/task/type",
|
|
111
|
+
importedNames: ["Task", "MysteryHelper"],
|
|
112
|
+
reason: "file_not_found",
|
|
113
|
+
suggestion: "expected at: src/apis/task/type.{ts}",
|
|
114
|
+
});
|
|
115
|
+
expect(planDeterministicFix(broken, TASK_DSL, "/repo")).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("matches case-insensitively", () => {
|
|
119
|
+
const broken = makeBrokenImport({
|
|
120
|
+
source: "@/types",
|
|
121
|
+
importedNames: ["task"],
|
|
122
|
+
reason: "file_not_found",
|
|
123
|
+
suggestion: "expected at: src/types.{ts}",
|
|
124
|
+
});
|
|
125
|
+
const action = planDeterministicFix(broken, TASK_DSL, "/repo");
|
|
126
|
+
expect(action).not.toBeNull();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("handles missing_export by appending to the resolved file", () => {
|
|
130
|
+
const broken = makeBrokenImport({
|
|
131
|
+
source: "@/apis/task",
|
|
132
|
+
importedNames: ["Task"],
|
|
133
|
+
reason: "missing_export",
|
|
134
|
+
missingExports: ["Task"],
|
|
135
|
+
resolvedPath: "/repo/src/apis/task/index.ts",
|
|
136
|
+
});
|
|
137
|
+
const action = planDeterministicFix(broken, TASK_DSL, "/repo");
|
|
138
|
+
expect(action!.kind).toBe("append_to_file");
|
|
139
|
+
if (action!.kind === "append_to_file") {
|
|
140
|
+
expect(action.path).toBe("src/apis/task/index.ts");
|
|
141
|
+
expect(action.content).toContain("export interface Task");
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null when there are no named imports", () => {
|
|
146
|
+
const broken = makeBrokenImport({
|
|
147
|
+
source: "@/missing",
|
|
148
|
+
importedNames: [],
|
|
149
|
+
reason: "file_not_found",
|
|
150
|
+
});
|
|
151
|
+
expect(planDeterministicFix(broken, TASK_DSL, "/repo")).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ─── Stage B prompt + parser ──────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
describe("buildAiFixPrompt", () => {
|
|
158
|
+
it("includes broken imports, DSL models, and existing files", () => {
|
|
159
|
+
const broken = makeBrokenImport({
|
|
160
|
+
source: "@/utils/format",
|
|
161
|
+
importedNames: ["formatDate"],
|
|
162
|
+
reason: "file_not_found",
|
|
163
|
+
file: "src/views/x.vue",
|
|
164
|
+
line: 12,
|
|
165
|
+
});
|
|
166
|
+
const prompt = buildAiFixPrompt({
|
|
167
|
+
brokenImports: [broken],
|
|
168
|
+
generatedFilePaths: ["src/views/x.vue", "src/utils/index.ts"],
|
|
169
|
+
dsl: TASK_DSL,
|
|
170
|
+
});
|
|
171
|
+
expect(prompt).toContain("formatDate");
|
|
172
|
+
expect(prompt).toContain("@/utils/format");
|
|
173
|
+
expect(prompt).toContain("Task: ");
|
|
174
|
+
expect(prompt).toContain("src/utils/index.ts");
|
|
175
|
+
expect(prompt).toContain("create_file");
|
|
176
|
+
expect(prompt).toContain("rewrite_import");
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("works without DSL", () => {
|
|
180
|
+
const broken = makeBrokenImport({
|
|
181
|
+
source: "@/missing",
|
|
182
|
+
importedNames: ["X"],
|
|
183
|
+
reason: "file_not_found",
|
|
184
|
+
});
|
|
185
|
+
const prompt = buildAiFixPrompt({
|
|
186
|
+
brokenImports: [broken],
|
|
187
|
+
generatedFilePaths: [],
|
|
188
|
+
dsl: null,
|
|
189
|
+
});
|
|
190
|
+
expect(prompt).toContain("=== No DSL available ===");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("parseAiFixActions", () => {
|
|
195
|
+
it("parses a clean JSON array of valid actions", () => {
|
|
196
|
+
const raw = JSON.stringify([
|
|
197
|
+
{ kind: "create_file", path: "src/types.ts", content: "export type X = string", reason: "X" },
|
|
198
|
+
{ kind: "rewrite_import", file: "src/x.ts", oldLine: "import { X } from './a'", newLine: "import { X } from './b'", reason: "Y" },
|
|
199
|
+
{ kind: "append_to_file", path: "src/api.ts", content: "export const X = 1", reason: "Z" },
|
|
200
|
+
]);
|
|
201
|
+
const actions = parseAiFixActions(raw);
|
|
202
|
+
expect(actions).toHaveLength(3);
|
|
203
|
+
expect(actions[0].source).toBe("ai");
|
|
204
|
+
expect(actions[1].kind).toBe("rewrite_import");
|
|
205
|
+
expect(actions[2].kind).toBe("append_to_file");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("strips markdown code fences", () => {
|
|
209
|
+
const raw = '```json\n[{"kind":"create_file","path":"a.ts","content":"x","reason":"y"}]\n```';
|
|
210
|
+
const actions = parseAiFixActions(raw);
|
|
211
|
+
expect(actions).toHaveLength(1);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("filters out malformed action objects", () => {
|
|
215
|
+
const raw = JSON.stringify([
|
|
216
|
+
{ kind: "create_file", path: "valid.ts", content: "x", reason: "ok" },
|
|
217
|
+
{ kind: "create_file" }, // missing fields
|
|
218
|
+
{ kind: "unknown_kind", path: "x.ts" },
|
|
219
|
+
"not an object",
|
|
220
|
+
]);
|
|
221
|
+
const actions = parseAiFixActions(raw);
|
|
222
|
+
expect(actions).toHaveLength(1);
|
|
223
|
+
if (actions[0].kind === "create_file") {
|
|
224
|
+
expect(actions[0].path).toBe("valid.ts");
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("returns [] on completely invalid input", () => {
|
|
229
|
+
expect(parseAiFixActions("not json at all")).toEqual([]);
|
|
230
|
+
expect(parseAiFixActions("")).toEqual([]);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ─── Action executor ──────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
describe("applyFixAction", () => {
|
|
237
|
+
let tmpDir: string;
|
|
238
|
+
|
|
239
|
+
beforeEach(async () => {
|
|
240
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fix-exec-"));
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
afterEach(async () => {
|
|
244
|
+
await fs.remove(tmpDir);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("creates a new file with content", async () => {
|
|
248
|
+
const action: FixAction = {
|
|
249
|
+
kind: "create_file",
|
|
250
|
+
path: "src/types.ts",
|
|
251
|
+
content: "export interface X {}",
|
|
252
|
+
reason: "test",
|
|
253
|
+
source: "deterministic",
|
|
254
|
+
};
|
|
255
|
+
const result = await applyFixAction(action, tmpDir);
|
|
256
|
+
expect(result.applied).toBe(true);
|
|
257
|
+
const written = await fs.readFile(path.join(tmpDir, "src/types.ts"), "utf-8");
|
|
258
|
+
expect(written).toBe("export interface X {}");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("refuses to overwrite an existing non-empty file", async () => {
|
|
262
|
+
const filePath = path.join(tmpDir, "src/types.ts");
|
|
263
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
264
|
+
await fs.writeFile(filePath, "existing content");
|
|
265
|
+
const action: FixAction = {
|
|
266
|
+
kind: "create_file",
|
|
267
|
+
path: "src/types.ts",
|
|
268
|
+
content: "new content",
|
|
269
|
+
reason: "test",
|
|
270
|
+
source: "deterministic",
|
|
271
|
+
};
|
|
272
|
+
const result = await applyFixAction(action, tmpDir);
|
|
273
|
+
expect(result.applied).toBe(false);
|
|
274
|
+
expect(result.reason).toContain("already exists");
|
|
275
|
+
// Ensure original is intact
|
|
276
|
+
expect(await fs.readFile(filePath, "utf-8")).toBe("existing content");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("rewrites an import line in an existing file", async () => {
|
|
280
|
+
const filePath = path.join(tmpDir, "src/store.ts");
|
|
281
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
282
|
+
await fs.writeFile(filePath, "import { Task } from '@/apis/task/type'\nconst x = 1");
|
|
283
|
+
const action: FixAction = {
|
|
284
|
+
kind: "rewrite_import",
|
|
285
|
+
file: "src/store.ts",
|
|
286
|
+
oldLine: "import { Task } from '@/apis/task/type'",
|
|
287
|
+
newLine: "import { Task } from '@/apis/task'",
|
|
288
|
+
reason: "test",
|
|
289
|
+
source: "ai",
|
|
290
|
+
};
|
|
291
|
+
const result = await applyFixAction(action, tmpDir);
|
|
292
|
+
expect(result.applied).toBe(true);
|
|
293
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
294
|
+
expect(written).toContain("import { Task } from '@/apis/task'");
|
|
295
|
+
expect(written).not.toContain("/type'");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it("skips rewrite_import when oldLine is missing", async () => {
|
|
299
|
+
const filePath = path.join(tmpDir, "src/x.ts");
|
|
300
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
301
|
+
await fs.writeFile(filePath, "// nothing to fix here");
|
|
302
|
+
const action: FixAction = {
|
|
303
|
+
kind: "rewrite_import",
|
|
304
|
+
file: "src/x.ts",
|
|
305
|
+
oldLine: "import nonexistent from 'foo'",
|
|
306
|
+
newLine: "import other from 'bar'",
|
|
307
|
+
reason: "test",
|
|
308
|
+
source: "ai",
|
|
309
|
+
};
|
|
310
|
+
const result = await applyFixAction(action, tmpDir);
|
|
311
|
+
expect(result.applied).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("appends to an existing file", async () => {
|
|
315
|
+
const filePath = path.join(tmpDir, "src/api.ts");
|
|
316
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
317
|
+
await fs.writeFile(filePath, "export function foo() {}");
|
|
318
|
+
const action: FixAction = {
|
|
319
|
+
kind: "append_to_file",
|
|
320
|
+
path: "src/api.ts",
|
|
321
|
+
content: "\nexport interface Task { id: number }",
|
|
322
|
+
reason: "test",
|
|
323
|
+
source: "deterministic",
|
|
324
|
+
};
|
|
325
|
+
const result = await applyFixAction(action, tmpDir);
|
|
326
|
+
expect(result.applied).toBe(true);
|
|
327
|
+
const written = await fs.readFile(filePath, "utf-8");
|
|
328
|
+
expect(written).toContain("export function foo()");
|
|
329
|
+
expect(written).toContain("export interface Task");
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("skips append when content already present", async () => {
|
|
333
|
+
const filePath = path.join(tmpDir, "src/api.ts");
|
|
334
|
+
await fs.ensureDir(path.dirname(filePath));
|
|
335
|
+
await fs.writeFile(filePath, "export interface Task { id: number }");
|
|
336
|
+
const action: FixAction = {
|
|
337
|
+
kind: "append_to_file",
|
|
338
|
+
path: "src/api.ts",
|
|
339
|
+
content: "export interface Task { id: number }",
|
|
340
|
+
reason: "test",
|
|
341
|
+
source: "deterministic",
|
|
342
|
+
};
|
|
343
|
+
const result = await applyFixAction(action, tmpDir);
|
|
344
|
+
expect(result.applied).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ─── End-to-end: runImportFix integration ─────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe("runImportFix (end-to-end)", () => {
|
|
351
|
+
let tmpDir: string;
|
|
352
|
+
|
|
353
|
+
beforeEach(async () => {
|
|
354
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fix-e2e-"));
|
|
355
|
+
await fs.writeJson(path.join(tmpDir, "tsconfig.json"), {
|
|
356
|
+
compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"] } },
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
afterEach(async () => {
|
|
361
|
+
await fs.remove(tmpDir);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
async function write(rel: string, content: string): Promise<string> {
|
|
365
|
+
const abs = path.join(tmpDir, rel);
|
|
366
|
+
await fs.ensureDir(path.dirname(abs));
|
|
367
|
+
await fs.writeFile(abs, content, "utf-8");
|
|
368
|
+
return abs;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
it("Stage A only: deterministic fix from DSL works end-to-end with re-verification", async () => {
|
|
372
|
+
// Set up a generated file that imports a non-existent types file
|
|
373
|
+
const consumer = await write(
|
|
374
|
+
"src/stores/task.ts",
|
|
375
|
+
`import type { Task } from '@/apis/task/type'\nconst x: Task = { id: 1, title: 't', createdAt: '' } as Task`
|
|
376
|
+
);
|
|
377
|
+
|
|
378
|
+
// Initial verify: 1 broken
|
|
379
|
+
const initialReport = await verifyImports([consumer], tmpDir);
|
|
380
|
+
expect(initialReport.brokenImports).toHaveLength(1);
|
|
381
|
+
|
|
382
|
+
// Run fix (no AI provider — Stage A only)
|
|
383
|
+
const fixReport = await runImportFix({
|
|
384
|
+
brokenImports: initialReport.brokenImports,
|
|
385
|
+
dsl: TASK_DSL,
|
|
386
|
+
repoRoot: tmpDir,
|
|
387
|
+
generatedFilePaths: ["src/stores/task.ts"],
|
|
388
|
+
// no provider
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
expect(fixReport.deterministicCount).toBe(1);
|
|
392
|
+
expect(fixReport.applied).toHaveLength(1);
|
|
393
|
+
expect(fixReport.applied[0].kind).toBe("create_file");
|
|
394
|
+
|
|
395
|
+
// The new types file should now exist
|
|
396
|
+
const typeFile = path.join(tmpDir, "src/apis/task/type.ts");
|
|
397
|
+
expect(await fs.pathExists(typeFile)).toBe(true);
|
|
398
|
+
const typeContent = await fs.readFile(typeFile, "utf-8");
|
|
399
|
+
expect(typeContent).toContain("export interface Task");
|
|
400
|
+
|
|
401
|
+
// Re-verify: 0 broken
|
|
402
|
+
const reverify = await verifyImports([consumer], tmpDir);
|
|
403
|
+
expect(reverify.brokenImports).toHaveLength(0);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("Stage A returns nothing when no DSL is provided", async () => {
|
|
407
|
+
const consumer = await write(
|
|
408
|
+
"src/x.ts",
|
|
409
|
+
`import { Task } from '@/missing'`
|
|
410
|
+
);
|
|
411
|
+
const initial = await verifyImports([consumer], tmpDir);
|
|
412
|
+
const result = await runImportFix({
|
|
413
|
+
brokenImports: initial.brokenImports,
|
|
414
|
+
dsl: null,
|
|
415
|
+
repoRoot: tmpDir,
|
|
416
|
+
generatedFilePaths: ["src/x.ts"],
|
|
417
|
+
});
|
|
418
|
+
expect(result.deterministicCount).toBe(0);
|
|
419
|
+
expect(result.aiFixedCount).toBe(0);
|
|
420
|
+
expect(result.unresolvedCount).toBe(1);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
it("Stage B: invokes AI provider when Stage A cannot resolve", async () => {
|
|
424
|
+
const consumer = await write(
|
|
425
|
+
"src/x.ts",
|
|
426
|
+
`import { mysteryHelper } from '@/utils/missing'`
|
|
427
|
+
);
|
|
428
|
+
const initial = await verifyImports([consumer], tmpDir);
|
|
429
|
+
|
|
430
|
+
// Mock AI provider that returns a fix action
|
|
431
|
+
const mockProvider: AIProvider = {
|
|
432
|
+
providerName: "mock",
|
|
433
|
+
modelName: "test",
|
|
434
|
+
generate: vi.fn().mockResolvedValue(
|
|
435
|
+
JSON.stringify([
|
|
436
|
+
{
|
|
437
|
+
kind: "create_file",
|
|
438
|
+
path: "src/utils/missing.ts",
|
|
439
|
+
content: "export function mysteryHelper() {}",
|
|
440
|
+
reason: "stub",
|
|
441
|
+
},
|
|
442
|
+
])
|
|
443
|
+
),
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const result = await runImportFix({
|
|
447
|
+
brokenImports: initial.brokenImports,
|
|
448
|
+
dsl: TASK_DSL, // DSL doesn't have mysteryHelper, so Stage A passes
|
|
449
|
+
repoRoot: tmpDir,
|
|
450
|
+
generatedFilePaths: ["src/x.ts"],
|
|
451
|
+
provider: mockProvider,
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
expect(result.deterministicCount).toBe(0);
|
|
455
|
+
expect(result.aiFixedCount).toBe(1);
|
|
456
|
+
expect(result.applied).toHaveLength(1);
|
|
457
|
+
expect(mockProvider.generate).toHaveBeenCalledOnce();
|
|
458
|
+
|
|
459
|
+
// Re-verify
|
|
460
|
+
const reverify = await verifyImports([consumer], tmpDir);
|
|
461
|
+
expect(reverify.brokenImports).toHaveLength(0);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("hybrid: Stage A handles DSL imports, Stage B handles the rest", async () => {
|
|
465
|
+
const consumer = await write(
|
|
466
|
+
"src/x.ts",
|
|
467
|
+
`import type { Task } from '@/apis/task/type'\nimport { helper } from '@/utils/helper'`
|
|
468
|
+
);
|
|
469
|
+
const initial = await verifyImports([consumer], tmpDir);
|
|
470
|
+
expect(initial.brokenImports).toHaveLength(2);
|
|
471
|
+
|
|
472
|
+
const mockProvider: AIProvider = {
|
|
473
|
+
providerName: "mock",
|
|
474
|
+
modelName: "test",
|
|
475
|
+
generate: vi.fn().mockResolvedValue(
|
|
476
|
+
JSON.stringify([
|
|
477
|
+
{
|
|
478
|
+
kind: "create_file",
|
|
479
|
+
path: "src/utils/helper.ts",
|
|
480
|
+
content: "export function helper() {}",
|
|
481
|
+
reason: "AI stub",
|
|
482
|
+
},
|
|
483
|
+
])
|
|
484
|
+
),
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const result = await runImportFix({
|
|
488
|
+
brokenImports: initial.brokenImports,
|
|
489
|
+
dsl: TASK_DSL,
|
|
490
|
+
repoRoot: tmpDir,
|
|
491
|
+
generatedFilePaths: ["src/x.ts"],
|
|
492
|
+
provider: mockProvider,
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
expect(result.deterministicCount).toBe(1); // Task → DSL stub
|
|
496
|
+
expect(result.aiFixedCount).toBe(1); // helper → AI stub
|
|
497
|
+
expect(result.applied).toHaveLength(2);
|
|
498
|
+
|
|
499
|
+
const reverify = await verifyImports([consumer], tmpDir);
|
|
500
|
+
expect(reverify.brokenImports).toHaveLength(0);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
it("Stage B failure does not crash the dispatcher", async () => {
|
|
504
|
+
const consumer = await write(
|
|
505
|
+
"src/x.ts",
|
|
506
|
+
`import { x } from '@/missing'`
|
|
507
|
+
);
|
|
508
|
+
const initial = await verifyImports([consumer], tmpDir);
|
|
509
|
+
|
|
510
|
+
const mockProvider: AIProvider = {
|
|
511
|
+
providerName: "mock",
|
|
512
|
+
modelName: "test",
|
|
513
|
+
generate: vi.fn().mockRejectedValue(new Error("API down")),
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const result = await runImportFix({
|
|
517
|
+
brokenImports: initial.brokenImports,
|
|
518
|
+
dsl: null,
|
|
519
|
+
repoRoot: tmpDir,
|
|
520
|
+
generatedFilePaths: ["src/x.ts"],
|
|
521
|
+
provider: mockProvider,
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
expect(result.applied).toHaveLength(0);
|
|
525
|
+
expect(result.unresolvedCount).toBe(1);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("reproduces and fixes the rushbuy task.ts hallucination end-to-end", async () => {
|
|
529
|
+
// Exact case: store + view both import { Task } from '@/apis/task/type' which doesn't exist
|
|
530
|
+
const store = await write(
|
|
531
|
+
"src/stores/modules/task.ts",
|
|
532
|
+
`import { defineStore } from 'pinia'
|
|
533
|
+
import { fetchTasks } from '@/apis/task'
|
|
534
|
+
import type { Task } from '@/apis/task/type'`
|
|
535
|
+
);
|
|
536
|
+
const view = await write(
|
|
537
|
+
"src/views/task-management/index.vue",
|
|
538
|
+
`<template><div /></template>
|
|
539
|
+
<script setup lang="ts">
|
|
540
|
+
import type { Task } from '@/apis/task/type'
|
|
541
|
+
const items: Task[] = []
|
|
542
|
+
</script>`
|
|
543
|
+
);
|
|
544
|
+
// Existing api file (so that the @/apis/task import resolves)
|
|
545
|
+
await write("src/apis/task/index.ts", `export function fetchTasks() {}`);
|
|
546
|
+
|
|
547
|
+
const initial = await verifyImports([store, view], tmpDir);
|
|
548
|
+
// 2 broken: both reference @/apis/task/type
|
|
549
|
+
expect(initial.brokenImports.length).toBeGreaterThanOrEqual(2);
|
|
550
|
+
|
|
551
|
+
const result = await runImportFix({
|
|
552
|
+
brokenImports: initial.brokenImports,
|
|
553
|
+
dsl: TASK_DSL,
|
|
554
|
+
repoRoot: tmpDir,
|
|
555
|
+
generatedFilePaths: [store, view],
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(result.deterministicCount).toBeGreaterThanOrEqual(1); // Task is in DSL
|
|
559
|
+
expect(result.applied.length).toBeGreaterThanOrEqual(1);
|
|
560
|
+
|
|
561
|
+
// The auto-generated type file should now exist
|
|
562
|
+
expect(await fs.pathExists(path.join(tmpDir, "src/apis/task/type.ts"))).toBe(true);
|
|
563
|
+
|
|
564
|
+
// Re-verify: both imports should now resolve
|
|
565
|
+
const reverify = await verifyImports([store, view], tmpDir);
|
|
566
|
+
expect(reverify.brokenImports).toHaveLength(0);
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// ─── findRenameCandidate ──────────────────────────────────────────────────────
|
|
571
|
+
|
|
572
|
+
describe("findRenameCandidate", () => {
|
|
573
|
+
it("returns null when no candidates score above threshold", () => {
|
|
574
|
+
expect(findRenameCandidate("Task", ["User", "Order"])).toBeNull();
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("prefers exact match (case-insensitive)", () => {
|
|
578
|
+
expect(findRenameCandidate("task", ["Task", "TaskItem"])).toBe("Task");
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("returns the shortest prefix match (Task → TaskItem)", () => {
|
|
582
|
+
const result = findRenameCandidate("Task", [
|
|
583
|
+
"TaskItem",
|
|
584
|
+
"TaskPageResponse",
|
|
585
|
+
"TaskListParams",
|
|
586
|
+
]);
|
|
587
|
+
expect(result).toBe("TaskItem");
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
it("returns a suffix match when no prefix", () => {
|
|
591
|
+
expect(findRenameCandidate("Item", ["TaskItem", "Order"])).toBe("TaskItem");
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
it("returns a substring match as last resort", () => {
|
|
595
|
+
expect(findRenameCandidate("Page", ["OrderDetail", "TaskPageResponse"])).toBe(
|
|
596
|
+
"TaskPageResponse"
|
|
597
|
+
);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
it("is deterministic across ties — shortest name wins", () => {
|
|
601
|
+
const result = findRenameCandidate("A", ["ABigName", "AB", "AAAA"]);
|
|
602
|
+
expect(result).toBe("AB"); // shortest among the top-scoring prefix matches
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
// ─── Stage A rename rewrite (Bug 1 fix) ───────────────────────────────────────
|
|
607
|
+
|
|
608
|
+
describe("planDeterministicFix — Strategy 3 (rename rewrite)", () => {
|
|
609
|
+
it("generates rewrite_import when target has similar export", () => {
|
|
610
|
+
const broken: BrokenImport = {
|
|
611
|
+
ref: {
|
|
612
|
+
source: "@/apis/task/type",
|
|
613
|
+
importedNames: ["Task"],
|
|
614
|
+
isTypeOnly: true,
|
|
615
|
+
hasDefault: false,
|
|
616
|
+
file: "src/stores/task.ts",
|
|
617
|
+
line: 4,
|
|
618
|
+
resolvedPath: "/repo/src/apis/task/type.ts",
|
|
619
|
+
},
|
|
620
|
+
reason: "missing_export",
|
|
621
|
+
missingExports: ["Task"],
|
|
622
|
+
availableExports: [
|
|
623
|
+
"TaskItem",
|
|
624
|
+
"TaskPageResponse",
|
|
625
|
+
"TaskListParams",
|
|
626
|
+
"CreateTaskParams",
|
|
627
|
+
],
|
|
628
|
+
};
|
|
629
|
+
const sourceLine = "import type { Task } from '@/apis/task/type'";
|
|
630
|
+
const action = planDeterministicFix(broken, TASK_DSL, "/repo", sourceLine);
|
|
631
|
+
|
|
632
|
+
expect(action).not.toBeNull();
|
|
633
|
+
expect(action!.kind).toBe("rewrite_import");
|
|
634
|
+
if (action!.kind === "rewrite_import") {
|
|
635
|
+
expect(action.oldLine).toBe(sourceLine);
|
|
636
|
+
expect(action.newLine).toContain("TaskItem as Task");
|
|
637
|
+
expect(action.reason).toContain("Task → TaskItem");
|
|
638
|
+
expect(action.source).toBe("deterministic");
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("preserves `type` modifier when rewriting type-only imports", () => {
|
|
643
|
+
const broken: BrokenImport = {
|
|
644
|
+
ref: {
|
|
645
|
+
source: "@/x", importedNames: ["Foo"], isTypeOnly: true, hasDefault: false,
|
|
646
|
+
file: "a.ts", line: 1, resolvedPath: "/repo/src/x.ts",
|
|
647
|
+
},
|
|
648
|
+
reason: "missing_export",
|
|
649
|
+
missingExports: ["Foo"],
|
|
650
|
+
availableExports: ["FooBar"],
|
|
651
|
+
};
|
|
652
|
+
const sourceLine = "import type { Foo } from '@/x'";
|
|
653
|
+
const action = planDeterministicFix(broken, TASK_DSL, "/repo", sourceLine);
|
|
654
|
+
expect(action!.kind).toBe("rewrite_import");
|
|
655
|
+
if (action!.kind === "rewrite_import") {
|
|
656
|
+
expect(action.newLine).toContain("import type { FooBar as Foo }");
|
|
657
|
+
}
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("rename strategy works even without DSL (no models available)", () => {
|
|
661
|
+
const broken: BrokenImport = {
|
|
662
|
+
ref: {
|
|
663
|
+
source: "@/x", importedNames: ["Foo"], isTypeOnly: false, hasDefault: false,
|
|
664
|
+
file: "a.ts", line: 1, resolvedPath: "/repo/src/x.ts",
|
|
665
|
+
},
|
|
666
|
+
reason: "missing_export",
|
|
667
|
+
missingExports: ["Foo"],
|
|
668
|
+
availableExports: ["FooBar"],
|
|
669
|
+
};
|
|
670
|
+
const sourceLine = "import { Foo } from '@/x'";
|
|
671
|
+
// null DSL — rename strategy should still work
|
|
672
|
+
const action = planDeterministicFix(broken, null, "/repo", sourceLine);
|
|
673
|
+
expect(action).not.toBeNull();
|
|
674
|
+
expect(action!.kind).toBe("rewrite_import");
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
it("returns null when no similar export exists", () => {
|
|
678
|
+
const broken: BrokenImport = {
|
|
679
|
+
ref: {
|
|
680
|
+
source: "@/x", importedNames: ["SomethingUnique"], isTypeOnly: false, hasDefault: false,
|
|
681
|
+
file: "a.ts", line: 1, resolvedPath: "/repo/src/x.ts",
|
|
682
|
+
},
|
|
683
|
+
reason: "missing_export",
|
|
684
|
+
missingExports: ["SomethingUnique"],
|
|
685
|
+
availableExports: ["TotallyDifferent", "NothingRelated"],
|
|
686
|
+
};
|
|
687
|
+
const sourceLine = "import { SomethingUnique } from '@/x'";
|
|
688
|
+
const action = planDeterministicFix(broken, TASK_DSL, "/repo", sourceLine);
|
|
689
|
+
expect(action).toBeNull();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it("handles multi-symbol imports where all need rename", () => {
|
|
693
|
+
const broken: BrokenImport = {
|
|
694
|
+
ref: {
|
|
695
|
+
source: "@/x", importedNames: ["A", "B"], isTypeOnly: false, hasDefault: false,
|
|
696
|
+
file: "a.ts", line: 1, resolvedPath: "/repo/src/x.ts",
|
|
697
|
+
},
|
|
698
|
+
reason: "missing_export",
|
|
699
|
+
missingExports: ["A", "B"],
|
|
700
|
+
availableExports: ["AItem", "BItem", "CItem"],
|
|
701
|
+
};
|
|
702
|
+
const sourceLine = "import { A, B } from '@/x'";
|
|
703
|
+
const action = planDeterministicFix(broken, TASK_DSL, "/repo", sourceLine);
|
|
704
|
+
expect(action!.kind).toBe("rewrite_import");
|
|
705
|
+
if (action!.kind === "rewrite_import") {
|
|
706
|
+
expect(action.newLine).toContain("AItem as A");
|
|
707
|
+
expect(action.newLine).toContain("BItem as B");
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
// ─── End-to-end: reproduce the v0.54 Task → TaskItem case ─────────────────────
|
|
713
|
+
|
|
714
|
+
describe("runImportFix — rushbuy Task/TaskItem rename scenario (Bug 1 from v0.54 test run)", () => {
|
|
715
|
+
let tmpDir: string;
|
|
716
|
+
|
|
717
|
+
beforeEach(async () => {
|
|
718
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fix-rename-"));
|
|
719
|
+
await fs.writeJson(path.join(tmpDir, "tsconfig.json"), {
|
|
720
|
+
compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"] } },
|
|
721
|
+
});
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
afterEach(async () => {
|
|
725
|
+
await fs.remove(tmpDir);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
it("Stage A rewrites { Task } → { TaskItem as Task } when target file exports TaskItem", async () => {
|
|
729
|
+
// Type file exists with TaskItem (the actual AI-generated name)
|
|
730
|
+
await fs.ensureDir(path.join(tmpDir, "src/apis/task"));
|
|
731
|
+
await fs.writeFile(
|
|
732
|
+
path.join(tmpDir, "src/apis/task/type.ts"),
|
|
733
|
+
`
|
|
734
|
+
export interface TaskItem {
|
|
735
|
+
id: number;
|
|
736
|
+
title: string;
|
|
737
|
+
desc?: string;
|
|
738
|
+
createdAt: string;
|
|
739
|
+
}
|
|
740
|
+
export interface TaskPageResponse { items: TaskItem[]; total: number }
|
|
741
|
+
export interface TaskListParams { page: number; pageSize: number }
|
|
742
|
+
`
|
|
743
|
+
);
|
|
744
|
+
// Consumer imports the wrong name (Task, not TaskItem)
|
|
745
|
+
const consumerPath = path.join(tmpDir, "src/stores/task.ts");
|
|
746
|
+
await fs.ensureDir(path.dirname(consumerPath));
|
|
747
|
+
await fs.writeFile(
|
|
748
|
+
consumerPath,
|
|
749
|
+
`import type { Task } from '@/apis/task/type'
|
|
750
|
+
const items: Task[] = []
|
|
751
|
+
export { items }`
|
|
752
|
+
);
|
|
753
|
+
|
|
754
|
+
const initialReport = await verifyImports([consumerPath], tmpDir);
|
|
755
|
+
expect(initialReport.brokenImports).toHaveLength(1);
|
|
756
|
+
expect(initialReport.brokenImports[0].reason).toBe("missing_export");
|
|
757
|
+
expect(initialReport.brokenImports[0].availableExports).toContain("TaskItem");
|
|
758
|
+
|
|
759
|
+
const fixReport = await runImportFix({
|
|
760
|
+
brokenImports: initialReport.brokenImports,
|
|
761
|
+
dsl: TASK_DSL,
|
|
762
|
+
repoRoot: tmpDir,
|
|
763
|
+
generatedFilePaths: [consumerPath],
|
|
764
|
+
// no provider — Stage A only
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
// Stage A should have handled this via Strategy 3 (rename rewrite)
|
|
768
|
+
expect(fixReport.deterministicCount).toBe(1);
|
|
769
|
+
expect(fixReport.applied).toHaveLength(1);
|
|
770
|
+
expect(fixReport.applied[0].kind).toBe("rewrite_import");
|
|
771
|
+
expect(fixReport.unresolvedCount).toBe(0);
|
|
772
|
+
|
|
773
|
+
// Verify the file actually got rewritten
|
|
774
|
+
const rewritten = await fs.readFile(consumerPath, "utf-8");
|
|
775
|
+
expect(rewritten).toContain("TaskItem as Task");
|
|
776
|
+
expect(rewritten).not.toContain("import type { Task } from");
|
|
777
|
+
|
|
778
|
+
// Final verification: no more broken imports
|
|
779
|
+
const reverify = await verifyImports([consumerPath], tmpDir);
|
|
780
|
+
expect(reverify.brokenImports).toHaveLength(0);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
// ─── Bug 1: skipped actions must be tracked + unresolvedCount must be honest ──
|
|
785
|
+
|
|
786
|
+
describe("runImportFix — skipped actions tracking (Bug 1 from v0.54 test run)", () => {
|
|
787
|
+
let tmpDir: string;
|
|
788
|
+
|
|
789
|
+
beforeEach(async () => {
|
|
790
|
+
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "fix-skip-"));
|
|
791
|
+
await fs.writeJson(path.join(tmpDir, "tsconfig.json"), {
|
|
792
|
+
compilerOptions: { baseUrl: ".", paths: { "@/*": ["src/*"] } },
|
|
793
|
+
});
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
afterEach(async () => {
|
|
797
|
+
await fs.remove(tmpDir);
|
|
798
|
+
});
|
|
799
|
+
|
|
800
|
+
it("skipped actions appear in report with reason", async () => {
|
|
801
|
+
// Pre-existing file
|
|
802
|
+
await fs.ensureDir(path.join(tmpDir, "src/apis"));
|
|
803
|
+
await fs.writeFile(path.join(tmpDir, "src/apis/existing.ts"), "export const X = 1");
|
|
804
|
+
await fs.writeFile(
|
|
805
|
+
path.join(tmpDir, "src/a.ts"),
|
|
806
|
+
`import { Missing } from '@/apis/missing-file'`
|
|
807
|
+
);
|
|
808
|
+
const initial = await verifyImports([path.join(tmpDir, "src/a.ts")], tmpDir);
|
|
809
|
+
|
|
810
|
+
// Mock AI that returns create_file for a path that ALREADY EXISTS
|
|
811
|
+
const mockProvider: AIProvider = {
|
|
812
|
+
providerName: "mock",
|
|
813
|
+
modelName: "test",
|
|
814
|
+
generate: vi.fn().mockResolvedValue(
|
|
815
|
+
JSON.stringify([
|
|
816
|
+
{
|
|
817
|
+
kind: "create_file",
|
|
818
|
+
path: "src/apis/existing.ts", // conflict with existing
|
|
819
|
+
content: "export const X = 2",
|
|
820
|
+
reason: "test",
|
|
821
|
+
},
|
|
822
|
+
])
|
|
823
|
+
),
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
const report = await runImportFix({
|
|
827
|
+
brokenImports: initial.brokenImports,
|
|
828
|
+
dsl: null,
|
|
829
|
+
repoRoot: tmpDir,
|
|
830
|
+
generatedFilePaths: [path.join(tmpDir, "src/a.ts")],
|
|
831
|
+
provider: mockProvider,
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// The action was planned by Stage B but the executor refused (file exists)
|
|
835
|
+
expect(report.applied).toHaveLength(0);
|
|
836
|
+
expect(report.skipped).toHaveLength(1);
|
|
837
|
+
expect(report.skipped[0].reason).toContain("already exists");
|
|
838
|
+
expect(report.unresolvedCount).toBe(1); // still broken after the refused fix
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it("rewrite_import skipped when oldLine does not match — visible in report", async () => {
|
|
842
|
+
// Target file exists
|
|
843
|
+
await fs.ensureDir(path.join(tmpDir, "src/apis/task"));
|
|
844
|
+
await fs.writeFile(
|
|
845
|
+
path.join(tmpDir, "src/apis/task/type.ts"),
|
|
846
|
+
"export interface TaskItem { id: number }"
|
|
847
|
+
);
|
|
848
|
+
// Consumer file
|
|
849
|
+
await fs.writeFile(
|
|
850
|
+
path.join(tmpDir, "src/a.ts"),
|
|
851
|
+
`import { Task } from '@/apis/task/type'`
|
|
852
|
+
);
|
|
853
|
+
const initial = await verifyImports([path.join(tmpDir, "src/a.ts")], tmpDir);
|
|
854
|
+
|
|
855
|
+
// Mock AI returns rewrite_import with a wrong oldLine (slight formatting difference)
|
|
856
|
+
const mockProvider: AIProvider = {
|
|
857
|
+
providerName: "mock",
|
|
858
|
+
modelName: "test",
|
|
859
|
+
generate: vi.fn().mockResolvedValue(
|
|
860
|
+
JSON.stringify([
|
|
861
|
+
{
|
|
862
|
+
kind: "rewrite_import",
|
|
863
|
+
file: "src/a.ts",
|
|
864
|
+
oldLine: `import {Task} from '@/apis/task/type'`, // missing space — doesn't match
|
|
865
|
+
newLine: `import { TaskItem as Task } from '@/apis/task/type'`,
|
|
866
|
+
reason: "test rename",
|
|
867
|
+
},
|
|
868
|
+
])
|
|
869
|
+
),
|
|
870
|
+
};
|
|
871
|
+
|
|
872
|
+
// Disable Stage A by passing null DSL AND no available exports path
|
|
873
|
+
// Actually, Strategy 3 will also try to rewrite since availableExports is populated.
|
|
874
|
+
// So this test only makes sense when we want to see what happens if ONLY Stage B runs
|
|
875
|
+
// with a bad action. In practice, Stage A will succeed first here. Skip Stage A by
|
|
876
|
+
// zeroing the brokenImport's availableExports.
|
|
877
|
+
const brokenNoExports = initial.brokenImports.map((b) => ({
|
|
878
|
+
...b,
|
|
879
|
+
availableExports: undefined,
|
|
880
|
+
}));
|
|
881
|
+
|
|
882
|
+
const report = await runImportFix({
|
|
883
|
+
brokenImports: brokenNoExports,
|
|
884
|
+
dsl: null,
|
|
885
|
+
repoRoot: tmpDir,
|
|
886
|
+
generatedFilePaths: [path.join(tmpDir, "src/a.ts")],
|
|
887
|
+
provider: mockProvider,
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
expect(report.applied).toHaveLength(0);
|
|
891
|
+
expect(report.skipped).toHaveLength(1);
|
|
892
|
+
expect(report.skipped[0].reason).toContain("old import line not found");
|
|
893
|
+
expect(report.unresolvedCount).toBe(1);
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
it("unresolvedCount reflects actually applied fixes, not planned count", async () => {
|
|
897
|
+
// Two broken imports, AI plans fixes for both but only one applies
|
|
898
|
+
await fs.ensureDir(path.join(tmpDir, "src"));
|
|
899
|
+
await fs.writeFile(path.join(tmpDir, "src/existing.ts"), "export const Y = 1");
|
|
900
|
+
await fs.writeFile(
|
|
901
|
+
path.join(tmpDir, "src/a.ts"),
|
|
902
|
+
`import { X } from '@/missing-a'
|
|
903
|
+
import { Y } from '@/missing-b'`
|
|
904
|
+
);
|
|
905
|
+
const initial = await verifyImports([path.join(tmpDir, "src/a.ts")], tmpDir);
|
|
906
|
+
expect(initial.brokenImports).toHaveLength(2);
|
|
907
|
+
|
|
908
|
+
const mockProvider: AIProvider = {
|
|
909
|
+
providerName: "mock",
|
|
910
|
+
modelName: "test",
|
|
911
|
+
generate: vi.fn().mockResolvedValue(
|
|
912
|
+
JSON.stringify([
|
|
913
|
+
// First action: valid create_file → will apply
|
|
914
|
+
{
|
|
915
|
+
kind: "create_file",
|
|
916
|
+
path: "src/missing-a.ts",
|
|
917
|
+
content: "export const X = 1",
|
|
918
|
+
reason: "ok",
|
|
919
|
+
},
|
|
920
|
+
// Second action: create_file for existing file → will be skipped
|
|
921
|
+
{
|
|
922
|
+
kind: "create_file",
|
|
923
|
+
path: "src/existing.ts",
|
|
924
|
+
content: "export const Y = 2",
|
|
925
|
+
reason: "conflict",
|
|
926
|
+
},
|
|
927
|
+
])
|
|
928
|
+
),
|
|
929
|
+
};
|
|
930
|
+
|
|
931
|
+
const report = await runImportFix({
|
|
932
|
+
brokenImports: initial.brokenImports,
|
|
933
|
+
dsl: null,
|
|
934
|
+
repoRoot: tmpDir,
|
|
935
|
+
generatedFilePaths: [path.join(tmpDir, "src/a.ts")],
|
|
936
|
+
provider: mockProvider,
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
expect(report.aiFixedCount).toBe(2); // AI planned 2
|
|
940
|
+
expect(report.applied).toHaveLength(1); // 1 actually applied
|
|
941
|
+
expect(report.skipped).toHaveLength(1); // 1 skipped
|
|
942
|
+
expect(report.unresolvedCount).toBe(1); // honest count: 1 broken still unresolved
|
|
943
|
+
});
|
|
944
|
+
});
|