ai-spec-dev 0.42.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.
Files changed (70) hide show
  1. package/README.md +86 -40
  2. package/cli/commands/config.ts +129 -1
  3. package/cli/commands/create.ts +246 -11
  4. package/cli/commands/fix-history.ts +176 -0
  5. package/cli/commands/init.ts +344 -106
  6. package/cli/index.ts +3 -7
  7. package/cli/pipeline/helpers.ts +6 -0
  8. package/cli/pipeline/multi-repo.ts +291 -26
  9. package/cli/pipeline/single-repo.ts +103 -2
  10. package/cli/utils.ts +95 -4
  11. package/core/code-generator.ts +63 -14
  12. package/core/config-defaults.ts +44 -0
  13. package/core/constitution-generator.ts +2 -1
  14. package/core/cross-stack-verifier.ts +395 -0
  15. package/core/dsl-extractor.ts +2 -1
  16. package/core/error-feedback.ts +3 -2
  17. package/core/fix-history.ts +333 -0
  18. package/core/import-fixer.ts +827 -0
  19. package/core/import-verifier.ts +569 -0
  20. package/core/knowledge-memory.ts +55 -6
  21. package/core/openapi-exporter.ts +3 -2
  22. package/core/repo-store.ts +95 -0
  23. package/core/reviewer.ts +14 -13
  24. package/core/run-logger.ts +3 -4
  25. package/core/run-snapshot.ts +2 -3
  26. package/core/run-trend.ts +3 -4
  27. package/core/self-evaluator.ts +44 -7
  28. package/core/spec-generator.ts +30 -45
  29. package/core/token-budget.ts +3 -8
  30. package/core/types-generator.ts +2 -2
  31. package/core/vcr.ts +3 -1
  32. package/dist/cli/index.js +3889 -1937
  33. package/dist/cli/index.js.map +1 -1
  34. package/dist/cli/index.mjs +3888 -1936
  35. package/dist/cli/index.mjs.map +1 -1
  36. package/dist/index.d.mts +17 -2
  37. package/dist/index.d.ts +17 -2
  38. package/dist/index.js +292 -181
  39. package/dist/index.js.map +1 -1
  40. package/dist/index.mjs +292 -181
  41. package/dist/index.mjs.map +1 -1
  42. package/package.json +2 -2
  43. package/tests/cross-stack-verifier.test.ts +301 -0
  44. package/tests/fix-history.test.ts +335 -0
  45. package/tests/import-fixer.test.ts +944 -0
  46. package/tests/import-verifier.test.ts +420 -0
  47. package/tests/knowledge-memory.test.ts +40 -0
  48. package/tests/self-evaluator.test.ts +97 -0
  49. package/cli/commands/model.ts +0 -156
  50. package/cli/commands/scan.ts +0 -99
  51. package/cli/commands/workspace.ts +0 -219
  52. package/demo-backend/.ai-spec-constitution.md +0 -65
  53. package/demo-backend/package.json +0 -21
  54. package/demo-backend/prisma/schema.prisma +0 -22
  55. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +0 -186
  56. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +0 -211
  57. package/demo-backend/src/controllers/bookmark.controller.test.ts +0 -255
  58. package/demo-backend/src/controllers/bookmark.controller.ts +0 -187
  59. package/demo-backend/src/index.ts +0 -17
  60. package/demo-backend/src/routes/bookmark.routes.test.ts +0 -264
  61. package/demo-backend/src/routes/bookmark.routes.ts +0 -11
  62. package/demo-backend/src/routes/index.ts +0 -8
  63. package/demo-backend/src/services/bookmark.service.test.ts +0 -433
  64. package/demo-backend/src/services/bookmark.service.ts +0 -261
  65. package/demo-backend/tsconfig.json +0 -12
  66. package/demo-frontend/.ai-spec-constitution.md +0 -95
  67. package/demo-frontend/package.json +0 -23
  68. package/demo-frontend/src/App.tsx +0 -12
  69. package/demo-frontend/src/main.tsx +0 -9
  70. package/demo-frontend/tsconfig.json +0 -13
@@ -0,0 +1,420 @@
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
+ parseImports,
7
+ parseNamedExports,
8
+ resolveSpecifier,
9
+ resolveToActualFile,
10
+ loadPathAliases,
11
+ verifyImports,
12
+ } from "../core/import-verifier";
13
+
14
+ // ─── parseImports ─────────────────────────────────────────────────────────────
15
+
16
+ describe("parseImports", () => {
17
+ it("parses a basic named import", () => {
18
+ const refs = parseImports(`import { foo, bar } from './utils'`, "a.ts");
19
+ expect(refs).toHaveLength(1);
20
+ expect(refs[0]).toMatchObject({
21
+ source: "./utils",
22
+ importedNames: ["foo", "bar"],
23
+ isTypeOnly: false,
24
+ hasDefault: false,
25
+ });
26
+ });
27
+
28
+ it("parses a default + named import", () => {
29
+ const refs = parseImports(`import React, { useState, useEffect } from 'react'`, "a.tsx");
30
+ expect(refs[0]).toMatchObject({
31
+ source: "react",
32
+ defaultName: "React",
33
+ hasDefault: true,
34
+ importedNames: ["useState", "useEffect"],
35
+ });
36
+ });
37
+
38
+ it("parses type-only imports", () => {
39
+ const refs = parseImports(`import type { TaskItem } from '@/apis/task/types'`, "a.ts");
40
+ expect(refs[0]).toMatchObject({
41
+ source: "@/apis/task/types",
42
+ importedNames: ["TaskItem"],
43
+ isTypeOnly: true,
44
+ });
45
+ });
46
+
47
+ it("handles `as` aliasing — returns the ORIGINAL exported name, not the local binding", () => {
48
+ // Rationale: importedNames is used to validate against target file exports.
49
+ // The target exports `foo`, not `bar`, so we must return `foo`.
50
+ const refs = parseImports(`import { foo as bar } from './utils'`, "a.ts");
51
+ expect(refs[0].importedNames).toEqual(["foo"]);
52
+ });
53
+
54
+ it("handles `type` modifier inside named imports", () => {
55
+ const refs = parseImports(`import { foo, type Bar } from './utils'`, "a.ts");
56
+ expect(refs[0].importedNames).toEqual(["foo", "Bar"]);
57
+ });
58
+
59
+ it("parses side-effect imports", () => {
60
+ const refs = parseImports(`import './polyfill'`, "a.ts");
61
+ expect(refs[0]).toMatchObject({
62
+ source: "./polyfill",
63
+ importedNames: [],
64
+ });
65
+ });
66
+
67
+ it("captures correct line numbers", () => {
68
+ const src = [
69
+ "// header",
70
+ "import { foo } from './a'",
71
+ "",
72
+ "import { bar } from './b'",
73
+ ].join("\n");
74
+ const refs = parseImports(src, "x.ts");
75
+ expect(refs).toHaveLength(2);
76
+ expect(refs[0].line).toBe(2);
77
+ expect(refs[1].line).toBe(4);
78
+ });
79
+
80
+ it("handles namespace imports without crashing", () => {
81
+ const refs = parseImports(`import * as utils from './utils'`, "a.ts");
82
+ expect(refs).toHaveLength(1);
83
+ expect(refs[0].source).toBe("./utils");
84
+ expect(refs[0].importedNames).toEqual([]);
85
+ });
86
+
87
+ it("handles multiline imports", () => {
88
+ const src = `import {
89
+ foo,
90
+ bar,
91
+ baz
92
+ } from './utils'`;
93
+ const refs = parseImports(src, "a.ts");
94
+ expect(refs).toHaveLength(1);
95
+ expect(refs[0].importedNames).toEqual(["foo", "bar", "baz"]);
96
+ });
97
+ });
98
+
99
+ // ─── parseNamedExports ────────────────────────────────────────────────────────
100
+
101
+ describe("parseNamedExports", () => {
102
+ it("extracts export const / function / class / interface / type / enum", () => {
103
+ const src = `
104
+ export const A = 1;
105
+ export function B() {}
106
+ export class C {}
107
+ export interface D {}
108
+ export type E = string;
109
+ export enum F { x }
110
+ `;
111
+ const { names } = parseNamedExports(src);
112
+ expect([...names].sort()).toEqual(["A", "B", "C", "D", "E", "F"]);
113
+ });
114
+
115
+ it("extracts export { ... } blocks with as aliases", () => {
116
+ const src = `
117
+ const a = 1; const b = 2;
118
+ export { a, b as renamed };
119
+ `;
120
+ const { names } = parseNamedExports(src);
121
+ expect(names.has("a")).toBe(true);
122
+ expect(names.has("renamed")).toBe(true);
123
+ });
124
+
125
+ it("detects wildcard re-export", () => {
126
+ const src = `export * from './foo'`;
127
+ const { hasWildcard } = parseNamedExports(src);
128
+ expect(hasWildcard).toBe(true);
129
+ });
130
+
131
+ it("detects default export", () => {
132
+ const src = `export default function () {}`;
133
+ const { hasDefault } = parseNamedExports(src);
134
+ expect(hasDefault).toBe(true);
135
+ });
136
+
137
+ it("handles `export type { ... }` block", () => {
138
+ const src = `export type { A, B as C } from './types'`;
139
+ const { names } = parseNamedExports(src);
140
+ expect(names.has("A")).toBe(true);
141
+ expect(names.has("C")).toBe(true);
142
+ });
143
+ });
144
+
145
+ // ─── resolveSpecifier ─────────────────────────────────────────────────────────
146
+
147
+ describe("resolveSpecifier", () => {
148
+ const aliases = {
149
+ baseUrl: ".",
150
+ paths: [{ alias: "@/*", target: "src/*" }],
151
+ };
152
+
153
+ it("resolves relative path", () => {
154
+ const result = resolveSpecifier("./utils", "/repo/src/foo/index.ts", "/repo", aliases);
155
+ expect(result).toBe("/repo/src/foo/utils");
156
+ });
157
+
158
+ it("resolves parent-relative path", () => {
159
+ const result = resolveSpecifier("../shared/types", "/repo/src/foo/a.ts", "/repo", aliases);
160
+ expect(result).toBe("/repo/src/shared/types");
161
+ });
162
+
163
+ it("resolves @/* alias to src/*", () => {
164
+ const result = resolveSpecifier("@/apis/task", "/repo/src/views/x.vue", "/repo", aliases);
165
+ expect(result).toBe("/repo/src/apis/task");
166
+ });
167
+
168
+ it("returns null for external packages", () => {
169
+ expect(resolveSpecifier("vue", "/repo/src/x.ts", "/repo", aliases)).toBeNull();
170
+ expect(resolveSpecifier("@arco-design/web-vue", "/repo/src/x.ts", "/repo", aliases)).toBeNull();
171
+ expect(resolveSpecifier("pinia", "/repo/src/x.ts", "/repo", aliases)).toBeNull();
172
+ });
173
+ });
174
+
175
+ // ─── End-to-end with tmp dir ──────────────────────────────────────────────────
176
+
177
+ describe("verifyImports (end-to-end)", () => {
178
+ let tmpDir: string;
179
+
180
+ beforeEach(async () => {
181
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "import-v-"));
182
+ // Set up a minimal tsconfig with @/* alias
183
+ await fs.writeJson(path.join(tmpDir, "tsconfig.json"), {
184
+ compilerOptions: {
185
+ baseUrl: ".",
186
+ paths: { "@/*": ["src/*"] },
187
+ },
188
+ });
189
+ });
190
+
191
+ afterEach(async () => {
192
+ await fs.remove(tmpDir);
193
+ });
194
+
195
+ async function write(rel: string, content: string): Promise<string> {
196
+ const abs = path.join(tmpDir, rel);
197
+ await fs.ensureDir(path.dirname(abs));
198
+ await fs.writeFile(abs, content, "utf-8");
199
+ return abs;
200
+ }
201
+
202
+ it("reports clean when all imports resolve and exports exist", async () => {
203
+ await write("src/apis/task/index.ts", `
204
+ export interface TaskItem { id: number; title: string }
205
+ export function fetchTasks() {}
206
+ `);
207
+ const consumer = await write("src/stores/task.ts", `
208
+ import { fetchTasks } from '@/apis/task'
209
+ import type { TaskItem } from '@/apis/task'
210
+ `);
211
+
212
+ const report = await verifyImports([consumer], tmpDir);
213
+ expect(report.brokenImports).toHaveLength(0);
214
+ expect(report.matchedImports).toBe(2);
215
+ });
216
+
217
+ it("flags file_not_found when import target doesn't exist", async () => {
218
+ const consumer = await write("src/stores/task.ts", `
219
+ import type { TaskItem } from '@/apis/task/types'
220
+ `);
221
+
222
+ const report = await verifyImports([consumer], tmpDir);
223
+ expect(report.brokenImports).toHaveLength(1);
224
+ expect(report.brokenImports[0].reason).toBe("file_not_found");
225
+ expect(report.brokenImports[0].ref.source).toBe("@/apis/task/types");
226
+ });
227
+
228
+ it("flags missing_export when file exists but symbol is not exported", async () => {
229
+ await write("src/apis/task/types.ts", `
230
+ export interface SomethingElse { id: number }
231
+ `);
232
+ const consumer = await write("src/stores/task.ts", `
233
+ import type { TaskItem } from '@/apis/task/types'
234
+ `);
235
+
236
+ const report = await verifyImports([consumer], tmpDir);
237
+ expect(report.brokenImports).toHaveLength(1);
238
+ expect(report.brokenImports[0].reason).toBe("missing_export");
239
+ expect(report.brokenImports[0].missingExports).toEqual(["TaskItem"]);
240
+ });
241
+
242
+ it("trusts wildcard re-exports (no false positives)", async () => {
243
+ await write("src/types/index.ts", `export * from './task'`);
244
+ await write("src/types/task.ts", `export interface TaskItem { id: number }`);
245
+ const consumer = await write("src/stores/task.ts", `
246
+ import type { TaskItem } from '@/types'
247
+ `);
248
+
249
+ const report = await verifyImports([consumer], tmpDir);
250
+ expect(report.brokenImports).toHaveLength(0);
251
+ });
252
+
253
+ it("validates exports across multiple generated files in the same run", async () => {
254
+ const api = await write("src/apis/task/index.ts", `
255
+ export interface TaskItem { id: number }
256
+ export function fetchTasks() {}
257
+ `);
258
+ const store = await write("src/stores/task.ts", `
259
+ import { fetchTasks } from '@/apis/task'
260
+ import type { TaskItem } from '@/apis/task'
261
+ `);
262
+
263
+ const report = await verifyImports([api, store], tmpDir);
264
+ expect(report.brokenImports).toHaveLength(0);
265
+ });
266
+
267
+ it("handles relative imports inside the generated set", async () => {
268
+ await write("src/utils/format.ts", `export const formatDate = (d: Date) => d.toISOString()`);
269
+ const consumer = await write("src/views/x.ts", `
270
+ import { formatDate } from '../utils/format'
271
+ `);
272
+
273
+ const report = await verifyImports([consumer], tmpDir);
274
+ expect(report.brokenImports).toHaveLength(0);
275
+ });
276
+
277
+ it("skips external package imports", async () => {
278
+ const consumer = await write("src/x.ts", `
279
+ import { defineStore } from 'pinia'
280
+ import { ref } from 'vue'
281
+ import dayjs from 'dayjs'
282
+ `);
283
+
284
+ const report = await verifyImports([consumer], tmpDir);
285
+ expect(report.externalImports).toBe(3);
286
+ expect(report.brokenImports).toHaveLength(0);
287
+ });
288
+
289
+ it("parses imports inside Vue SFC <script setup> blocks", async () => {
290
+ const vueFile = await write("src/views/Task.vue", `
291
+ <template><div /></template>
292
+
293
+ <script setup lang="ts">
294
+ import { ref } from 'vue'
295
+ import type { TaskItem } from '@/apis/task/types'
296
+ const items = ref<TaskItem[]>([])
297
+ </script>
298
+ `);
299
+
300
+ const report = await verifyImports([vueFile], tmpDir);
301
+ // 'vue' is external (skipped), '@/apis/task/types' is broken (file doesn't exist)
302
+ expect(report.externalImports).toBe(1);
303
+ expect(report.brokenImports).toHaveLength(1);
304
+ expect(report.brokenImports[0].ref.source).toBe("@/apis/task/types");
305
+ });
306
+
307
+ it("end-to-end reproduces the rushbuy task.ts hallucination", async () => {
308
+ // The exact case from the user's screenshot:
309
+ // - src/apis/taskManagement/index.ts exports the functions but NOT a types submodule
310
+ // - src/stores/modules/task.ts imports TaskItem from '@/apis/taskManagement/types' (doesn't exist)
311
+ await write("src/apis/taskManagement/index.ts", `
312
+ export function fetchTasks() {}
313
+ export function createTask() {}
314
+ export function updateTask() {}
315
+ export function deleteTask() {}
316
+ `);
317
+ const store = await write("src/stores/modules/task.ts", `
318
+ import { defineStore } from 'pinia'
319
+ import { reactive, ref } from 'vue'
320
+ import { fetchTasks, createTask, updateTask, deleteTask } from '@/apis/taskManagement'
321
+ import type { TaskItem, TaskCreateParams, TaskUpdateParams } from '@/apis/taskManagement/types'
322
+ `);
323
+
324
+ const report = await verifyImports([store], tmpDir);
325
+ // Should detect the broken `@/apis/taskManagement/types` import
326
+ const brokenSources = report.brokenImports.map((b) => b.ref.source);
327
+ expect(brokenSources).toContain("@/apis/taskManagement/types");
328
+ // The 4 functions from index.ts should resolve cleanly
329
+ expect(report.matchedImports).toBe(1); // only the named imports from '@/apis/taskManagement' verified
330
+ });
331
+ });
332
+
333
+ // ─── loadPathAliases ──────────────────────────────────────────────────────────
334
+
335
+ describe("loadPathAliases", () => {
336
+ let tmpDir: string;
337
+
338
+ beforeEach(async () => {
339
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "alias-"));
340
+ });
341
+
342
+ afterEach(async () => {
343
+ await fs.remove(tmpDir);
344
+ });
345
+
346
+ it("falls back to @/* → src/* when no tsconfig found", async () => {
347
+ const aliases = await loadPathAliases(tmpDir);
348
+ expect(aliases.paths).toEqual([{ alias: "@/*", target: "src/*" }]);
349
+ });
350
+
351
+ it("reads paths from tsconfig.json", async () => {
352
+ await fs.writeJson(path.join(tmpDir, "tsconfig.json"), {
353
+ compilerOptions: {
354
+ baseUrl: "./src",
355
+ paths: { "~/*": ["./*"] },
356
+ },
357
+ });
358
+ const aliases = await loadPathAliases(tmpDir);
359
+ expect(aliases.baseUrl).toBe("./src");
360
+ expect(aliases.paths).toEqual([{ alias: "~/*", target: "./*" }]);
361
+ });
362
+
363
+ it("strips JSON comments and trailing commas", async () => {
364
+ await fs.writeFile(
365
+ path.join(tmpDir, "tsconfig.json"),
366
+ `{
367
+ // header comment
368
+ "compilerOptions": {
369
+ /* block comment */
370
+ "baseUrl": ".",
371
+ "paths": {
372
+ "@/*": ["src/*"], // trailing comma below
373
+ },
374
+ },
375
+ }`
376
+ );
377
+ const aliases = await loadPathAliases(tmpDir);
378
+ expect(aliases.paths).toEqual([{ alias: "@/*", target: "src/*" }]);
379
+ });
380
+ });
381
+
382
+ // ─── resolveToActualFile ──────────────────────────────────────────────────────
383
+
384
+ describe("resolveToActualFile", () => {
385
+ let tmpDir: string;
386
+
387
+ beforeEach(async () => {
388
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "resolve-"));
389
+ });
390
+
391
+ afterEach(async () => {
392
+ await fs.remove(tmpDir);
393
+ });
394
+
395
+ it("finds .ts file when extension omitted", async () => {
396
+ await fs.writeFile(path.join(tmpDir, "foo.ts"), "");
397
+ const found = await resolveToActualFile(path.join(tmpDir, "foo"));
398
+ expect(found).toBe(path.join(tmpDir, "foo.ts"));
399
+ });
400
+
401
+ it("finds index.ts inside a directory", async () => {
402
+ await fs.ensureDir(path.join(tmpDir, "foo"));
403
+ await fs.writeFile(path.join(tmpDir, "foo", "index.ts"), "");
404
+ const found = await resolveToActualFile(path.join(tmpDir, "foo"));
405
+ expect(found).toBe(path.join(tmpDir, "foo", "index.ts"));
406
+ });
407
+
408
+ it("returns null when nothing matches", async () => {
409
+ const found = await resolveToActualFile(path.join(tmpDir, "ghost"));
410
+ expect(found).toBeNull();
411
+ });
412
+
413
+ it("prefers exact match over directory index", async () => {
414
+ await fs.writeFile(path.join(tmpDir, "foo.ts"), "");
415
+ await fs.ensureDir(path.join(tmpDir, "foo"));
416
+ await fs.writeFile(path.join(tmpDir, "foo", "index.ts"), "");
417
+ const found = await resolveToActualFile(path.join(tmpDir, "foo"));
418
+ expect(found).toBe(path.join(tmpDir, "foo.ts"));
419
+ });
420
+ });
@@ -324,4 +324,44 @@ describe("accumulateReviewKnowledge", () => {
324
324
  expect(content).not.toContain("积累教训");
325
325
  spy.mockRestore();
326
326
  });
327
+
328
+ it("skips accumulation when review score is >= 9.0 (quality gate)", async () => {
329
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n");
330
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
331
+
332
+ // Score 9.5 — excellent run, even with an issues section it should be skipped
333
+ const review = `Score: 9.5/10
334
+
335
+ ## ⚠️ 问题
336
+ - Minor: variable name could be more descriptive
337
+
338
+ ## 💡 建议
339
+ - Consider extracting a helper
340
+ `;
341
+ await accumulateReviewKnowledge(mockProvider, tmpDir, review);
342
+
343
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
344
+ expect(content).not.toContain("积累教训");
345
+ spy.mockRestore();
346
+ });
347
+
348
+ it("embeds review score tag in lesson entries", async () => {
349
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n");
350
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
351
+
352
+ const review = `Score: 6.5/10
353
+
354
+ ## ⚠️ 问题
355
+ - Missing error boundary around async data fetch — crashes on network failure
356
+
357
+ ## 💡 建议
358
+ - Add retry logic
359
+ `;
360
+ await accumulateReviewKnowledge(mockProvider, tmpDir, review);
361
+
362
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
363
+ expect(content).toContain("(r:6.5)");
364
+ expect(content).toContain("error boundary");
365
+ spy.mockRestore();
366
+ });
327
367
  });
@@ -337,3 +337,100 @@ describe("runSelfEval — harnessScore", () => {
337
337
  expect(logger.setHarnessScore).toHaveBeenCalledWith(result.harnessScore);
338
338
  });
339
339
  });
340
+
341
+ // ─── runSelfEval — frontend / mobile repoType ─────────────────────────────────
342
+
343
+ describe("runSelfEval — frontend repoType", () => {
344
+ const FRONTEND_DSL: SpecDSL = {
345
+ ...BASE_DSL,
346
+ endpoints: [
347
+ { id: "EP-001", method: "GET", path: "/products", description: "List products", auth: false, successStatus: 200, successDescription: "OK" },
348
+ ],
349
+ models: [
350
+ { name: "Product", fields: [{ name: "id", type: "String", required: true }] },
351
+ ],
352
+ };
353
+
354
+ it("scores 10 for frontend files using page/store patterns", () => {
355
+ const result = runSelfEval({
356
+ dsl: FRONTEND_DSL,
357
+ generatedFiles: [
358
+ "src/pages/ProductList.tsx", // endpoint layer (pages)
359
+ "src/stores/product.ts", // model layer (stores)
360
+ "src/stores/productStore.ts", // matches "Product"
361
+ ],
362
+ compilePassed: true,
363
+ reviewText: "",
364
+ promptHash: "fe-001",
365
+ logger: stubLogger,
366
+ repoType: "frontend",
367
+ });
368
+ expect(result.dslCoverageScore).toBe(10);
369
+ });
370
+
371
+ it("scores 10 for Next.js App Router structure", () => {
372
+ const result = runSelfEval({
373
+ dsl: FRONTEND_DSL,
374
+ generatedFiles: [
375
+ "app/products/page.tsx", // endpoint layer (app/)
376
+ "src/types/product.ts", // model layer (types)
377
+ ],
378
+ compilePassed: true,
379
+ reviewText: "",
380
+ promptHash: "fe-002",
381
+ logger: stubLogger,
382
+ repoType: "frontend",
383
+ });
384
+ expect(result.dslCoverageScore).toBeGreaterThanOrEqual(7);
385
+ });
386
+
387
+ it("scores 10 for React Native screens/hooks pattern", () => {
388
+ const result = runSelfEval({
389
+ dsl: FRONTEND_DSL,
390
+ generatedFiles: [
391
+ "src/screens/ProductScreen.tsx", // endpoint layer (screens)
392
+ "src/hooks/useProduct.ts", // model layer (hooks)
393
+ ],
394
+ compilePassed: true,
395
+ reviewText: "",
396
+ promptHash: "fe-003",
397
+ logger: stubLogger,
398
+ repoType: "mobile",
399
+ });
400
+ expect(result.dslCoverageScore).toBeGreaterThanOrEqual(7);
401
+ });
402
+
403
+ it("deducts for missing page layer on frontend repo (not confused with backend)", () => {
404
+ const result = runSelfEval({
405
+ dsl: FRONTEND_DSL,
406
+ generatedFiles: [
407
+ // only model layer, no pages/views/screens
408
+ "src/stores/product.ts",
409
+ ],
410
+ compilePassed: true,
411
+ reviewText: "",
412
+ promptHash: "fe-004",
413
+ logger: stubLogger,
414
+ repoType: "frontend",
415
+ });
416
+ // endpoint layer missing → -4 deduction
417
+ expect(result.dslCoverageScore).toBeLessThanOrEqual(6);
418
+ });
419
+
420
+ it("backend files do NOT count as frontend endpoint layer", () => {
421
+ const result = runSelfEval({
422
+ dsl: FRONTEND_DSL,
423
+ generatedFiles: [
424
+ "src/controller/productController.ts", // backend pattern — should NOT match frontend
425
+ "src/stores/product.ts",
426
+ ],
427
+ compilePassed: true,
428
+ reviewText: "",
429
+ promptHash: "fe-005",
430
+ logger: stubLogger,
431
+ repoType: "frontend",
432
+ });
433
+ // controller does not match frontend endpoint patterns → endpoint layer missing → -4
434
+ expect(result.dslCoverageScore).toBeLessThanOrEqual(6);
435
+ });
436
+ });