ai-spec-dev 0.38.0 → 0.41.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 (66) hide show
  1. package/RELEASE_LOG.md +231 -0
  2. package/cli/commands/create.ts +9 -1176
  3. package/cli/commands/dashboard.ts +1 -1
  4. package/cli/pipeline/helpers.ts +34 -0
  5. package/cli/pipeline/multi-repo.ts +483 -0
  6. package/cli/pipeline/single-repo.ts +755 -0
  7. package/cli/utils.ts +2 -0
  8. package/core/code-generator.ts +52 -341
  9. package/core/codegen/helpers.ts +219 -0
  10. package/core/codegen/topo-sort.ts +98 -0
  11. package/core/constitution-consolidator.ts +2 -2
  12. package/core/dsl-coverage-checker.ts +298 -0
  13. package/core/dsl-extractor.ts +19 -46
  14. package/core/dsl-feedback.ts +1 -1
  15. package/core/dsl-validator.ts +74 -0
  16. package/core/error-feedback.ts +95 -11
  17. package/core/frontend-context-loader.ts +27 -5
  18. package/core/knowledge-memory.ts +52 -0
  19. package/core/mock/fixtures.ts +89 -0
  20. package/core/mock/proxy.ts +380 -0
  21. package/core/mock-server-generator.ts +12 -460
  22. package/core/requirement-decomposer.ts +4 -28
  23. package/core/reviewer.ts +1 -1
  24. package/core/safe-json.ts +76 -0
  25. package/core/spec-updater.ts +5 -21
  26. package/core/token-budget.ts +124 -0
  27. package/core/vcr.ts +20 -1
  28. package/dist/cli/index.js +4110 -3534
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/cli/index.mjs +4237 -3661
  31. package/dist/cli/index.mjs.map +1 -1
  32. package/dist/index.d.mts +18 -16
  33. package/dist/index.d.ts +18 -16
  34. package/dist/index.js +310 -182
  35. package/dist/index.js.map +1 -1
  36. package/dist/index.mjs +308 -180
  37. package/dist/index.mjs.map +1 -1
  38. package/package.json +2 -2
  39. package/purpose.md +173 -33
  40. package/tests/auto-consolidation.test.ts +109 -0
  41. package/tests/combined-generator.test.ts +81 -0
  42. package/tests/constitution-consolidator.test.ts +161 -0
  43. package/tests/constitution-generator.test.ts +94 -0
  44. package/tests/contract-bridge.test.ts +201 -0
  45. package/tests/design-dialogue.test.ts +108 -0
  46. package/tests/dsl-coverage-checker.test.ts +230 -0
  47. package/tests/dsl-feedback.test.ts +45 -0
  48. package/tests/dsl-validator-xref.test.ts +99 -0
  49. package/tests/error-feedback-repair.test.ts +319 -0
  50. package/tests/error-feedback-validation.test.ts +91 -0
  51. package/tests/frontend-context-loader.test.ts +609 -0
  52. package/tests/global-constitution.test.ts +110 -0
  53. package/tests/key-store.test.ts +73 -0
  54. package/tests/knowledge-memory.test.ts +327 -0
  55. package/tests/project-index.test.ts +206 -0
  56. package/tests/prompt-hasher.test.ts +19 -0
  57. package/tests/requirement-decomposer.test.ts +171 -0
  58. package/tests/reviewer.test.ts +4 -1
  59. package/tests/run-logger.test.ts +289 -0
  60. package/tests/run-snapshot.test.ts +113 -0
  61. package/tests/safe-json.test.ts +63 -0
  62. package/tests/spec-updater.test.ts +161 -0
  63. package/tests/test-generator.test.ts +146 -0
  64. package/tests/token-budget.test.ts +124 -0
  65. package/tests/vcr-hash.test.ts +101 -0
  66. package/tests/workspace-loader.test.ts +277 -0
@@ -0,0 +1,609 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs-extra";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ import {
6
+ parseImportStatements,
7
+ findHttpClientImport,
8
+ loadFrontendContext,
9
+ buildFrontendContextSection,
10
+ FrontendContext,
11
+ } from "../core/frontend-context-loader";
12
+
13
+ // ─── parseImportStatements ──────────────────────────────────────────────────
14
+
15
+ describe("parseImportStatements", () => {
16
+ it("parses a simple default import", () => {
17
+ const result = parseImportStatements(`import axios from 'axios'`);
18
+ expect(result).toHaveLength(1);
19
+ expect(result[0].modulePath).toBe("axios");
20
+ expect(result[0].specifiers).toBe("axios");
21
+ });
22
+
23
+ it("parses named imports on a single line", () => {
24
+ const result = parseImportStatements(`import { get, post } from '@/utils/http'`);
25
+ expect(result).toHaveLength(1);
26
+ expect(result[0].modulePath).toBe("@/utils/http");
27
+ });
28
+
29
+ it("parses multi-line named imports", () => {
30
+ const code = `import {
31
+ request,
32
+ get
33
+ } from '@/utils/http'`;
34
+ const result = parseImportStatements(code);
35
+ expect(result).toHaveLength(1);
36
+ expect(result[0].modulePath).toBe("@/utils/http");
37
+ expect(result[0].specifiers).toContain("request");
38
+ });
39
+
40
+ it("skips import type statements", () => {
41
+ const code = `import type { User } from './types'\nimport axios from 'axios'`;
42
+ const result = parseImportStatements(code);
43
+ expect(result).toHaveLength(1);
44
+ expect(result[0].modulePath).toBe("axios");
45
+ });
46
+
47
+ it("skips imports inside block comments", () => {
48
+ const code = `/* import fake from 'fake' */\nimport real from 'real'`;
49
+ const result = parseImportStatements(code);
50
+ expect(result).toHaveLength(1);
51
+ expect(result[0].modulePath).toBe("real");
52
+ });
53
+
54
+ it("returns empty array for no imports", () => {
55
+ expect(parseImportStatements("const x = 1;")).toEqual([]);
56
+ });
57
+
58
+ it("handles multiple imports", () => {
59
+ const code = `import a from 'a'\nimport b from 'b'\nimport c from 'c'`;
60
+ expect(parseImportStatements(code)).toHaveLength(3);
61
+ });
62
+
63
+ it("preserves the full import line", () => {
64
+ const result = parseImportStatements(`import request from '@/utils/http'`);
65
+ expect(result[0].line).toBe("import request from '@/utils/http'");
66
+ });
67
+ });
68
+
69
+ // ─── findHttpClientImport ───────────────────────────────────────────────────
70
+
71
+ describe("findHttpClientImport", () => {
72
+ it("finds axios import", () => {
73
+ expect(findHttpClientImport(`import axios from 'axios'`)).toBe("import axios from 'axios'");
74
+ });
75
+
76
+ it("finds @/ alias import with http keyword", () => {
77
+ const line = `import request from '@/utils/http'`;
78
+ expect(findHttpClientImport(line)).toBe("import request from '@/utils/http'");
79
+ });
80
+
81
+ it("finds ~/ alias import", () => {
82
+ const line = `import http from '~/lib/http'`;
83
+ expect(findHttpClientImport(line)).toBe("import http from '~/lib/http'");
84
+ });
85
+
86
+ it("finds #/ alias import", () => {
87
+ expect(findHttpClientImport(`import api from '#/utils/request'`)).toBe(
88
+ "import api from '#/utils/request'"
89
+ );
90
+ });
91
+
92
+ it("finds ky library", () => {
93
+ expect(findHttpClientImport(`import ky from 'ky'`)).toBe("import ky from 'ky'");
94
+ });
95
+
96
+ it("finds undici library", () => {
97
+ expect(findHttpClientImport(`import { fetch } from 'undici'`)).toBe(
98
+ "import { fetch } from 'undici'"
99
+ );
100
+ });
101
+
102
+ it("finds relative import with request keyword", () => {
103
+ expect(findHttpClientImport(`import request from '../lib/request'`)).toBe(
104
+ "import request from '../lib/request'"
105
+ );
106
+ });
107
+
108
+ it("finds alova library", () => {
109
+ expect(findHttpClientImport(`import { useRequest } from 'alova'`)).toBe(
110
+ "import { useRequest } from 'alova'"
111
+ );
112
+ });
113
+
114
+ it("returns undefined when no HTTP import found", () => {
115
+ expect(findHttpClientImport(`import { User } from './types'`)).toBeUndefined();
116
+ });
117
+
118
+ it("skips import type even if path matches", () => {
119
+ expect(findHttpClientImport(`import type { AxiosInstance } from 'axios'`)).toBeUndefined();
120
+ });
121
+
122
+ it("finds multi-line named import from HTTP module", () => {
123
+ const code = `import {\n request,\n post\n} from '@/utils/http'`;
124
+ expect(findHttpClientImport(code)).toContain("@/utils/http");
125
+ });
126
+
127
+ it("returns first match when multiple HTTP imports exist", () => {
128
+ const code = `import request from '@/utils/http'\nimport axios from 'axios'`;
129
+ expect(findHttpClientImport(code)).toContain("@/utils/http");
130
+ });
131
+ });
132
+
133
+ // ─── loadFrontendContext — integration tests with mock filesystem ────────────
134
+
135
+ describe("loadFrontendContext", () => {
136
+ let tmpDir: string;
137
+
138
+ beforeEach(async () => {
139
+ tmpDir = path.join(os.tmpdir(), `fcl-test-${Date.now()}`);
140
+ await fs.ensureDir(tmpDir);
141
+ });
142
+
143
+ afterEach(async () => {
144
+ await fs.remove(tmpDir);
145
+ });
146
+
147
+ async function writePkg(deps: Record<string, string> = {}, devDeps: Record<string, string> = {}) {
148
+ await fs.writeJson(path.join(tmpDir, "package.json"), {
149
+ name: "test",
150
+ dependencies: deps,
151
+ devDependencies: devDeps,
152
+ });
153
+ }
154
+
155
+ it("returns defaults when no package.json exists", async () => {
156
+ const ctx = await loadFrontendContext(tmpDir);
157
+ expect(ctx.framework).toBe("unknown");
158
+ expect(ctx.httpClient).toBe("fetch");
159
+ expect(ctx.stateManagement).toEqual([]);
160
+ });
161
+
162
+ // ── Framework detection ──────────────────────────────────────────────────
163
+
164
+ it("detects React framework", async () => {
165
+ await writePkg({ react: "^18.0.0", "react-dom": "^18.0.0" });
166
+ const ctx = await loadFrontendContext(tmpDir);
167
+ expect(ctx.framework).toBe("react");
168
+ });
169
+
170
+ it("detects Vue framework", async () => {
171
+ await writePkg({ vue: "^3.0.0" });
172
+ const ctx = await loadFrontendContext(tmpDir);
173
+ expect(ctx.framework).toBe("vue");
174
+ });
175
+
176
+ it("detects Next.js framework", async () => {
177
+ await writePkg({ react: "^18.0.0", next: "^14.0.0" });
178
+ const ctx = await loadFrontendContext(tmpDir);
179
+ expect(ctx.framework).toBe("next");
180
+ });
181
+
182
+ it("detects React Native framework", async () => {
183
+ await writePkg({ "react-native": "^0.72.0", react: "^18.0.0" });
184
+ const ctx = await loadFrontendContext(tmpDir);
185
+ expect(ctx.framework).toBe("react-native");
186
+ });
187
+
188
+ // ── State management detection ───────────────────────────────────────────
189
+
190
+ it("detects multiple state management libs", async () => {
191
+ await writePkg({ react: "^18", zustand: "^4", jotai: "^2" });
192
+ const ctx = await loadFrontendContext(tmpDir);
193
+ expect(ctx.stateManagement).toContain("zustand");
194
+ expect(ctx.stateManagement).toContain("jotai");
195
+ });
196
+
197
+ it("detects pinia for Vue", async () => {
198
+ await writePkg({ vue: "^3", pinia: "^2" });
199
+ const ctx = await loadFrontendContext(tmpDir);
200
+ expect(ctx.stateManagement).toContain("pinia");
201
+ });
202
+
203
+ // ── HTTP client detection ────────────────────────────────────────────────
204
+
205
+ it("detects axios HTTP client", async () => {
206
+ await writePkg({ react: "^18", axios: "^1.0" });
207
+ const ctx = await loadFrontendContext(tmpDir);
208
+ expect(ctx.httpClient).toBe("axios");
209
+ });
210
+
211
+ it("detects swr as HTTP client", async () => {
212
+ await writePkg({ react: "^18", swr: "^2.0" });
213
+ const ctx = await loadFrontendContext(tmpDir);
214
+ expect(ctx.httpClient).toBe("swr");
215
+ });
216
+
217
+ // ── UI library detection ─────────────────────────────────────────────────
218
+
219
+ it("detects antd UI library", async () => {
220
+ await writePkg({ react: "^18", antd: "^5" });
221
+ const ctx = await loadFrontendContext(tmpDir);
222
+ expect(ctx.uiLibrary).toBe("antd");
223
+ });
224
+
225
+ it("detects element-plus for Vue", async () => {
226
+ await writePkg({ vue: "^3", "element-plus": "^2" });
227
+ const ctx = await loadFrontendContext(tmpDir);
228
+ expect(ctx.uiLibrary).toBe("element-plus");
229
+ });
230
+
231
+ it("returns 'none' when no UI library detected", async () => {
232
+ await writePkg({ react: "^18" });
233
+ const ctx = await loadFrontendContext(tmpDir);
234
+ expect(ctx.uiLibrary).toBe("none");
235
+ });
236
+
237
+ // ── Routing detection ────────────────────────────────────────────────────
238
+
239
+ it("detects react-router", async () => {
240
+ await writePkg({ react: "^18", "react-router-dom": "^6" });
241
+ const ctx = await loadFrontendContext(tmpDir);
242
+ expect(ctx.routingPattern).toBe("react-router");
243
+ });
244
+
245
+ it("detects vue-router", async () => {
246
+ await writePkg({ vue: "^3", "vue-router": "^4" });
247
+ const ctx = await loadFrontendContext(tmpDir);
248
+ expect(ctx.routingPattern).toBe("vue-router");
249
+ });
250
+
251
+ it("detects next-app-router when app/ dir exists", async () => {
252
+ await writePkg({ react: "^18", next: "^14" });
253
+ await fs.ensureDir(path.join(tmpDir, "app"));
254
+ const ctx = await loadFrontendContext(tmpDir);
255
+ expect(ctx.routingPattern).toBe("next-app-router");
256
+ });
257
+
258
+ it("detects next-pages-router when no app/ dir", async () => {
259
+ await writePkg({ react: "^18", next: "^14" });
260
+ const ctx = await loadFrontendContext(tmpDir);
261
+ expect(ctx.routingPattern).toBe("next-pages-router");
262
+ });
263
+
264
+ // ── Test framework detection ─────────────────────────────────────────────
265
+
266
+ it("detects RTL test framework", async () => {
267
+ await writePkg({ react: "^18" }, { "@testing-library/react": "^14" });
268
+ const ctx = await loadFrontendContext(tmpDir);
269
+ expect(ctx.testFramework).toBe("rtl");
270
+ });
271
+
272
+ it("detects vitest", async () => {
273
+ await writePkg({ react: "^18" }, { vitest: "^1" });
274
+ const ctx = await loadFrontendContext(tmpDir);
275
+ expect(ctx.testFramework).toBe("vitest");
276
+ });
277
+
278
+ it("detects cypress", async () => {
279
+ await writePkg({ react: "^18" }, { cypress: "^13" });
280
+ const ctx = await loadFrontendContext(tmpDir);
281
+ expect(ctx.testFramework).toBe("cypress");
282
+ });
283
+
284
+ // ── API file discovery ───────────────────────────────────────────────────
285
+
286
+ it("discovers API files in src/api/", async () => {
287
+ await writePkg({ react: "^18" });
288
+ const apiDir = path.join(tmpDir, "src/api");
289
+ await fs.ensureDir(apiDir);
290
+ await fs.writeFile(path.join(apiDir, "user.ts"), "export function getUser() {}");
291
+ const ctx = await loadFrontendContext(tmpDir);
292
+ expect(ctx.existingApiFiles).toContain("src/api/user.ts");
293
+ });
294
+
295
+ it("discovers API files in src/services/", async () => {
296
+ await writePkg({ vue: "^3" });
297
+ const svcDir = path.join(tmpDir, "src/services");
298
+ await fs.ensureDir(svcDir);
299
+ await fs.writeFile(path.join(svcDir, "auth.ts"), "export function login() {}");
300
+ const ctx = await loadFrontendContext(tmpDir);
301
+ expect(ctx.existingApiFiles).toContain("src/services/auth.ts");
302
+ });
303
+
304
+ it("excludes test files from API discovery", async () => {
305
+ await writePkg({ react: "^18" });
306
+ const apiDir = path.join(tmpDir, "src/api");
307
+ await fs.ensureDir(apiDir);
308
+ await fs.writeFile(path.join(apiDir, "user.ts"), "export function getUser() {}");
309
+ await fs.writeFile(path.join(apiDir, "user.test.ts"), "test('user', () => {})");
310
+ const ctx = await loadFrontendContext(tmpDir);
311
+ expect(ctx.existingApiFiles).toContain("src/api/user.ts");
312
+ expect(ctx.existingApiFiles).not.toContain("src/api/user.test.ts");
313
+ });
314
+
315
+ // ── httpClientImport extraction ──────────────────────────────────────────
316
+
317
+ it("extracts httpClientImport from API files", async () => {
318
+ await writePkg({ react: "^18" });
319
+ const apiDir = path.join(tmpDir, "src/api");
320
+ await fs.ensureDir(apiDir);
321
+ await fs.writeFile(
322
+ path.join(apiDir, "user.ts"),
323
+ `import request from '@/utils/http'\n\nexport function getUser() { return request.get('/user') }`
324
+ );
325
+ const ctx = await loadFrontendContext(tmpDir);
326
+ expect(ctx.httpClientImport).toBe("import request from '@/utils/http'");
327
+ });
328
+
329
+ it("extracts httpClientImport from multi-line import", async () => {
330
+ await writePkg({ react: "^18" });
331
+ const apiDir = path.join(tmpDir, "src/api");
332
+ await fs.ensureDir(apiDir);
333
+ await fs.writeFile(
334
+ path.join(apiDir, "user.ts"),
335
+ `import {\n request,\n get\n} from '@/utils/http'\n\nexport function getUser() {}`
336
+ );
337
+ const ctx = await loadFrontendContext(tmpDir);
338
+ expect(ctx.httpClientImport).toBeDefined();
339
+ expect(ctx.httpClientImport).toContain("@/utils/http");
340
+ });
341
+
342
+ // ── Store file discovery ─────────────────────────────────────────────────
343
+
344
+ it("discovers store files in src/stores/", async () => {
345
+ await writePkg({ vue: "^3", pinia: "^2" });
346
+ const storeDir = path.join(tmpDir, "src/stores");
347
+ await fs.ensureDir(storeDir);
348
+ await fs.writeFile(path.join(storeDir, "user.ts"), "export const useUserStore = defineStore('user', {})");
349
+ const ctx = await loadFrontendContext(tmpDir);
350
+ expect(ctx.storeFiles).toContain("src/stores/user.ts");
351
+ });
352
+
353
+ // ── Hook file discovery ──────────────────────────────────────────────────
354
+
355
+ it("discovers hook files", async () => {
356
+ await writePkg({ react: "^18" });
357
+ const hookDir = path.join(tmpDir, "src/hooks");
358
+ await fs.ensureDir(hookDir);
359
+ await fs.writeFile(path.join(hookDir, "useAuth.ts"), "export function useAuth() {}");
360
+ const ctx = await loadFrontendContext(tmpDir);
361
+ expect(ctx.hookFiles).toContain("src/hooks/useAuth.ts");
362
+ });
363
+
364
+ // ── Reusable components ──────────────────────────────────────────────────
365
+
366
+ it("discovers reusable components", async () => {
367
+ await writePkg({ vue: "^3" });
368
+ const compDir = path.join(tmpDir, "src/components");
369
+ await fs.ensureDir(compDir);
370
+ await fs.writeFile(path.join(compDir, "AppButton.vue"), "<template><button /></template>");
371
+ const ctx = await loadFrontendContext(tmpDir);
372
+ expect(ctx.reusableComponents).toContain("src/components/AppButton.vue");
373
+ });
374
+
375
+ // ── Page examples ────────────────────────────────────────────────────────
376
+
377
+ it("reads page examples from src/views/", async () => {
378
+ await writePkg({ vue: "^3" });
379
+ const viewDir = path.join(tmpDir, "src/views");
380
+ await fs.ensureDir(viewDir);
381
+ await fs.writeFile(path.join(viewDir, "Home.vue"), "<template><div>Home</div></template>");
382
+ const ctx = await loadFrontendContext(tmpDir);
383
+ expect(ctx.pageExamples.length).toBeGreaterThan(0);
384
+ expect(ctx.pageExamples[0]).toContain("Home");
385
+ });
386
+
387
+ // ── Pagination example extraction ────────────────────────────────────────
388
+
389
+ it("extracts pagination example with interface + function", async () => {
390
+ await writePkg({ react: "^18" });
391
+ const apiDir = path.join(tmpDir, "src/api");
392
+ await fs.ensureDir(apiDir);
393
+ await fs.writeFile(
394
+ path.join(apiDir, "user.ts"),
395
+ `import request from '@/utils/http'
396
+
397
+ interface UserListParams {
398
+ pageIndex: number;
399
+ pageSize: number;
400
+ name?: string;
401
+ }
402
+
403
+ export function getUserList(params: UserListParams) {
404
+ return request.post('/api/user/list', params);
405
+ }
406
+ `
407
+ );
408
+ const ctx = await loadFrontendContext(tmpDir);
409
+ expect(ctx.paginationExample).toBeDefined();
410
+ expect(ctx.paginationExample).toContain("pageIndex");
411
+ expect(ctx.paginationExample).toContain("getUserList");
412
+ });
413
+
414
+ it("extracts pagination with arrow function style", async () => {
415
+ await writePkg({ react: "^18" });
416
+ const apiDir = path.join(tmpDir, "src/api");
417
+ await fs.ensureDir(apiDir);
418
+ await fs.writeFile(
419
+ path.join(apiDir, "order.ts"),
420
+ `import http from '@/utils/http'
421
+
422
+ interface OrderQuery {
423
+ page: number;
424
+ size: number;
425
+ status?: string;
426
+ }
427
+
428
+ export const getOrders = (params: OrderQuery) => {
429
+ return http.get('/api/orders', { params });
430
+ }
431
+ `
432
+ );
433
+ const ctx = await loadFrontendContext(tmpDir);
434
+ expect(ctx.paginationExample).toBeDefined();
435
+ expect(ctx.paginationExample).toContain("page");
436
+ expect(ctx.paginationExample).toContain("getOrders");
437
+ });
438
+
439
+ it("extracts pagination with nested object in interface", async () => {
440
+ await writePkg({ react: "^18" });
441
+ const apiDir = path.join(tmpDir, "src/api");
442
+ await fs.ensureDir(apiDir);
443
+ await fs.writeFile(
444
+ path.join(apiDir, "product.ts"),
445
+ `import request from '@/utils/http'
446
+
447
+ interface ProductListParams {
448
+ pageIndex: number;
449
+ pageSize: number;
450
+ filter: {
451
+ status?: string;
452
+ category?: number;
453
+ };
454
+ }
455
+
456
+ export function getProductList(params: ProductListParams) {
457
+ return request.post('/api/product/list', params);
458
+ }
459
+ `
460
+ );
461
+ const ctx = await loadFrontendContext(tmpDir);
462
+ expect(ctx.paginationExample).toBeDefined();
463
+ expect(ctx.paginationExample).toContain("filter");
464
+ expect(ctx.paginationExample).toContain("category");
465
+ });
466
+
467
+ it("skips type.ts and index.ts for pagination extraction", async () => {
468
+ await writePkg({ react: "^18" });
469
+ const apiDir = path.join(tmpDir, "src/api");
470
+ await fs.ensureDir(apiDir);
471
+ await fs.writeFile(
472
+ path.join(apiDir, "types.ts"),
473
+ `interface PageParams { pageIndex: number; pageSize: number; }`
474
+ );
475
+ await fs.writeFile(
476
+ path.join(apiDir, "index.ts"),
477
+ `export * from './user'`
478
+ );
479
+ const ctx = await loadFrontendContext(tmpDir);
480
+ expect(ctx.paginationExample).toBeUndefined();
481
+ });
482
+
483
+ // ── Route module context ─────────────────────────────────────────────────
484
+
485
+ it("extracts layout import from static import", async () => {
486
+ await writePkg({ vue: "^3", "vue-router": "^4" });
487
+ const routeDir = path.join(tmpDir, "src/router/modules");
488
+ await fs.ensureDir(routeDir);
489
+ await fs.writeFile(
490
+ path.join(routeDir, "user.ts"),
491
+ `import Layout from '@/layout/index.vue'\n\nexport default {\n path: '/user',\n component: Layout,\n children: []\n}`
492
+ );
493
+ const ctx = await loadFrontendContext(tmpDir);
494
+ expect(ctx.layoutImport).toBe("import Layout from '@/layout/index.vue'");
495
+ expect(ctx.routeModuleExample).toBeDefined();
496
+ expect(ctx.routeModuleExample!.path).toContain("user.ts");
497
+ });
498
+
499
+ it("extracts layout import from dynamic import pattern", async () => {
500
+ await writePkg({ vue: "^3", "vue-router": "^4" });
501
+ const routeDir = path.join(tmpDir, "src/router/modules");
502
+ await fs.ensureDir(routeDir);
503
+ await fs.writeFile(
504
+ path.join(routeDir, "admin.ts"),
505
+ `const Layout = () => import('@/layout/index.vue')\n\nexport default {\n path: '/admin',\n component: Layout\n}`
506
+ );
507
+ const ctx = await loadFrontendContext(tmpDir);
508
+ expect(ctx.layoutImport).toBeDefined();
509
+ expect(ctx.routeModuleExample).toBeDefined();
510
+ });
511
+
512
+ // ── Graceful degradation ─────────────────────────────────────────────────
513
+
514
+ it("returns partial context on corrupted package.json", async () => {
515
+ await fs.writeFile(path.join(tmpDir, "package.json"), "not json");
516
+ const ctx = await loadFrontendContext(tmpDir);
517
+ expect(ctx.framework).toBe("unknown");
518
+ });
519
+
520
+ it("handles empty dependencies gracefully", async () => {
521
+ await fs.writeJson(path.join(tmpDir, "package.json"), { name: "test" });
522
+ const ctx = await loadFrontendContext(tmpDir);
523
+ expect(ctx.framework).toBe("unknown");
524
+ expect(ctx.stateManagement).toEqual([]);
525
+ });
526
+ });
527
+
528
+ // ─── buildFrontendContextSection ────────────────────────────────────────────
529
+
530
+ describe("buildFrontendContextSection", () => {
531
+ function makeCtx(overrides: Partial<FrontendContext> = {}): FrontendContext {
532
+ return {
533
+ framework: "react",
534
+ stateManagement: ["zustand"],
535
+ httpClient: "axios",
536
+ uiLibrary: "antd",
537
+ routingPattern: "react-router",
538
+ testFramework: "vitest",
539
+ existingApiFiles: [],
540
+ apiWrapperContent: [],
541
+ hookFiles: [],
542
+ hookPatterns: [],
543
+ storeFiles: [],
544
+ storePatterns: [],
545
+ reusableComponents: [],
546
+ pageExamples: [],
547
+ componentPatterns: [],
548
+ ...overrides,
549
+ };
550
+ }
551
+
552
+ it("includes framework and basic info", () => {
553
+ const section = buildFrontendContextSection(makeCtx());
554
+ expect(section).toContain("Framework : react");
555
+ expect(section).toContain("HTTP Client : axios");
556
+ expect(section).toContain("UI Library : antd");
557
+ });
558
+
559
+ it("includes layout import when present", () => {
560
+ const section = buildFrontendContextSection(
561
+ makeCtx({ layoutImport: "import Layout from '@/layout/index.vue'" })
562
+ );
563
+ expect(section).toContain("COPY THIS EXACTLY");
564
+ expect(section).toContain("import Layout from '@/layout/index.vue'");
565
+ });
566
+
567
+ it("includes httpClientImport when present", () => {
568
+ const section = buildFrontendContextSection(
569
+ makeCtx({ httpClientImport: "import request from '@/utils/http'" })
570
+ );
571
+ expect(section).toContain("COPY THIS EXACTLY");
572
+ expect(section).toContain("import request from '@/utils/http'");
573
+ });
574
+
575
+ it("includes pagination example when present", () => {
576
+ const section = buildFrontendContextSection(
577
+ makeCtx({ paginationExample: "interface Params { pageIndex: number }" })
578
+ );
579
+ expect(section).toContain("COPY THIS EXACTLY for all paginated");
580
+ expect(section).toContain("pageIndex");
581
+ });
582
+
583
+ it("includes store patterns with CRITICAL warning", () => {
584
+ const section = buildFrontendContextSection(
585
+ makeCtx({ storePatterns: ["// store example"] })
586
+ );
587
+ expect(section).toContain("CRITICAL");
588
+ expect(section).toContain("NOT make HTTP requests directly");
589
+ });
590
+
591
+ it("includes reusable components list", () => {
592
+ const section = buildFrontendContextSection(
593
+ makeCtx({ reusableComponents: ["src/components/Button.vue", "src/components/Modal.vue"] })
594
+ );
595
+ expect(section).toContain("check this list before creating");
596
+ expect(section).toContain("Button.vue");
597
+ });
598
+
599
+ it("wraps output in delimiter tags", () => {
600
+ const section = buildFrontendContextSection(makeCtx());
601
+ expect(section).toContain("=== Frontend Project Context ===");
602
+ expect(section).toContain("=== End of Frontend Context ===");
603
+ });
604
+
605
+ it("shows 'none detected' for empty state management", () => {
606
+ const section = buildFrontendContextSection(makeCtx({ stateManagement: [] }));
607
+ expect(section).toContain("none detected");
608
+ });
609
+ });