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,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
|
+
});
|