ai-spec-dev 0.35.0 → 0.37.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/RELEASE_LOG.md +139 -0
- package/cli/commands/config.ts +18 -0
- package/cli/commands/create.ts +16 -1
- package/cli/utils.ts +4 -0
- package/core/code-generator.ts +6 -4
- package/core/dsl-extractor.ts +9 -1
- package/core/dsl-feedback.ts +7 -1
- package/core/dsl-validator.ts +32 -0
- package/core/key-store.ts +5 -4
- package/core/provider-utils.ts +39 -4
- package/dist/cli/index.js +121 -14
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +122 -15
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +16 -1
- package/dist/index.d.ts +16 -1
- package/dist/index.js +77 -8
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +77 -9
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/tests/code-generator.test.ts +253 -0
- package/tests/context-loader.test.ts +207 -0
- package/tests/dsl-validator.test.ts +105 -0
- package/tests/mock-server-generator.test.ts +404 -0
- package/tests/openapi-exporter.test.ts +310 -0
- package/tests/reviewer.test.ts +214 -0
- package/tests/spec-generator.test.ts +228 -0
- package/tests/spec-versioning.test.ts +205 -0
- package/tests/types-generator.test.ts +347 -0
- package/tests/vcr.test.ts +355 -0
- package/.claude/commands/add-lesson.md +0 -34
- package/.claude/commands/check-layers.md +0 -65
- package/.claude/commands/installed-deps.md +0 -35
- package/.claude/commands/recall-lessons.md +0 -40
- package/.claude/commands/scan-singletons.md +0 -45
- package/.claude/commands/verify-imports.md +0 -48
- package/.claude/settings.local.json +0 -24
package/package.json
CHANGED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
extractBehavioralContract,
|
|
4
|
+
printTaskProgress,
|
|
5
|
+
} from "../core/code-generator";
|
|
6
|
+
import type { SpecTask } from "../core/task-generator";
|
|
7
|
+
|
|
8
|
+
// ─── extractBehavioralContract ───────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
describe("extractBehavioralContract", () => {
|
|
11
|
+
it("captures export interface with full body", () => {
|
|
12
|
+
const content = `import { Foo } from "./foo";
|
|
13
|
+
|
|
14
|
+
export interface User {
|
|
15
|
+
id: string;
|
|
16
|
+
name: string;
|
|
17
|
+
email: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const x = 1;`;
|
|
21
|
+
const result = extractBehavioralContract(content);
|
|
22
|
+
expect(result).toContain("export interface User {");
|
|
23
|
+
expect(result).toContain("id: string;");
|
|
24
|
+
expect(result).toContain("email: string;");
|
|
25
|
+
expect(result).toContain("}");
|
|
26
|
+
// Should NOT contain non-export lines
|
|
27
|
+
expect(result).not.toContain("const x = 1");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("captures export enum with full body", () => {
|
|
31
|
+
const content = `export enum Status {
|
|
32
|
+
ACTIVE = "active",
|
|
33
|
+
INACTIVE = "inactive",
|
|
34
|
+
}`;
|
|
35
|
+
const result = extractBehavioralContract(content);
|
|
36
|
+
expect(result).toContain("export enum Status {");
|
|
37
|
+
expect(result).toContain("ACTIVE");
|
|
38
|
+
expect(result).toContain("INACTIVE");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("captures export type alias", () => {
|
|
42
|
+
const content = `export type UserId = string;`;
|
|
43
|
+
const result = extractBehavioralContract(content);
|
|
44
|
+
expect(result).toContain("export type UserId = string;");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("captures export function signature (single line)", () => {
|
|
48
|
+
const content = `export function createUser(name: string): User {
|
|
49
|
+
return { name };
|
|
50
|
+
}`;
|
|
51
|
+
const result = extractBehavioralContract(content);
|
|
52
|
+
expect(result).toContain("export function createUser");
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("captures export const declaration", () => {
|
|
56
|
+
const content = `export const API_BASE = "/api/v1";`;
|
|
57
|
+
const result = extractBehavioralContract(content);
|
|
58
|
+
expect(result).toContain("export const API_BASE");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("captures throw statements as error contracts", () => {
|
|
62
|
+
const content = `function validate(input: string) {
|
|
63
|
+
if (!input) throw new Error("INPUT_REQUIRED");
|
|
64
|
+
if (input.length > 100) throw new ValidationError("TOO_LONG");
|
|
65
|
+
}`;
|
|
66
|
+
const result = extractBehavioralContract(content);
|
|
67
|
+
expect(result).toContain("Error contracts");
|
|
68
|
+
expect(result).toContain("INPUT_REQUIRED");
|
|
69
|
+
expect(result).toContain("TOO_LONG");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("limits throw captures to 20", () => {
|
|
73
|
+
const throwLines = Array.from(
|
|
74
|
+
{ length: 25 },
|
|
75
|
+
(_, i) => ` throw new Error("ERR_${i}");`
|
|
76
|
+
);
|
|
77
|
+
const content = `function foo() {\n${throwLines.join("\n")}\n}`;
|
|
78
|
+
const result = extractBehavioralContract(content);
|
|
79
|
+
expect(result).toContain("ERR_19");
|
|
80
|
+
expect(result).not.toContain("ERR_20");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("captures export class with full body", () => {
|
|
84
|
+
const content = `export class UserService {
|
|
85
|
+
async findById(id: string): Promise<User> {
|
|
86
|
+
return db.user.findUnique({ where: { id } });
|
|
87
|
+
}
|
|
88
|
+
}`;
|
|
89
|
+
const result = extractBehavioralContract(content);
|
|
90
|
+
expect(result).toContain("export class UserService {");
|
|
91
|
+
expect(result).toContain("findById");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("captures defineStore full block", () => {
|
|
95
|
+
const content = `export const useTaskStore = defineStore("tasks", () => {
|
|
96
|
+
const tasks = ref([]);
|
|
97
|
+
function fetchTasks() {}
|
|
98
|
+
return { tasks, fetchTasks };
|
|
99
|
+
});`;
|
|
100
|
+
const result = extractBehavioralContract(content);
|
|
101
|
+
expect(result).toContain("defineStore");
|
|
102
|
+
expect(result).toContain("fetchTasks");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("captures return { } block as public API", () => {
|
|
106
|
+
const content = `export function useAuth() {
|
|
107
|
+
const user = ref(null);
|
|
108
|
+
function login() {}
|
|
109
|
+
return {
|
|
110
|
+
user,
|
|
111
|
+
login,
|
|
112
|
+
};
|
|
113
|
+
}`;
|
|
114
|
+
const result = extractBehavioralContract(content);
|
|
115
|
+
expect(result).toContain("public API");
|
|
116
|
+
expect(result).toContain("login");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("captures export default function with body", () => {
|
|
120
|
+
const content = `export default function HomePage() {
|
|
121
|
+
return <div>Home</div>;
|
|
122
|
+
}`;
|
|
123
|
+
const result = extractBehavioralContract(content);
|
|
124
|
+
expect(result).toContain("export default function HomePage");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("captures export default async function", () => {
|
|
128
|
+
const content = `export default async function handler(req, res) {
|
|
129
|
+
res.json({ ok: true });
|
|
130
|
+
}`;
|
|
131
|
+
const result = extractBehavioralContract(content);
|
|
132
|
+
expect(result).toContain("export default async function handler");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("falls back to first 3000 chars when no exports found", () => {
|
|
136
|
+
const content = "a".repeat(5000);
|
|
137
|
+
const result = extractBehavioralContract(content);
|
|
138
|
+
expect(result.length).toBe(3000);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("returns empty exports + throws correctly combined", () => {
|
|
142
|
+
const content = `export interface Foo {
|
|
143
|
+
bar: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function internal() {
|
|
147
|
+
throw new Error("FAIL");
|
|
148
|
+
}`;
|
|
149
|
+
const result = extractBehavioralContract(content);
|
|
150
|
+
expect(result).toContain("export interface Foo");
|
|
151
|
+
expect(result).toContain("Error contracts");
|
|
152
|
+
expect(result).toContain("FAIL");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("handles nested braces in interface correctly", () => {
|
|
156
|
+
const content = `export interface Config {
|
|
157
|
+
db: {
|
|
158
|
+
host: string;
|
|
159
|
+
port: number;
|
|
160
|
+
};
|
|
161
|
+
cache: {
|
|
162
|
+
ttl: number;
|
|
163
|
+
};
|
|
164
|
+
}`;
|
|
165
|
+
const result = extractBehavioralContract(content);
|
|
166
|
+
expect(result).toContain("host: string;");
|
|
167
|
+
expect(result).toContain("ttl: number;");
|
|
168
|
+
// The closing brace of the outer interface should be present
|
|
169
|
+
const lines = result.split("\n");
|
|
170
|
+
const lastNonEmpty = lines.filter((l) => l.trim()).pop();
|
|
171
|
+
expect(lastNonEmpty?.trim()).toBe("}");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("handles abstract class export", () => {
|
|
175
|
+
const content = `export abstract class BaseService {
|
|
176
|
+
abstract findAll(): Promise<any[]>;
|
|
177
|
+
}`;
|
|
178
|
+
const result = extractBehavioralContract(content);
|
|
179
|
+
expect(result).toContain("export abstract class BaseService");
|
|
180
|
+
expect(result).toContain("findAll");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ─── printTaskProgress ───────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
describe("printTaskProgress", () => {
|
|
187
|
+
const baseTask: SpecTask = {
|
|
188
|
+
id: "T-001",
|
|
189
|
+
title: "Create User model",
|
|
190
|
+
layer: "data",
|
|
191
|
+
description: "Define Prisma model for User",
|
|
192
|
+
filesToTouch: ["prisma/schema.prisma"],
|
|
193
|
+
acceptanceCriteria: ["User model exists"],
|
|
194
|
+
verificationSteps: [],
|
|
195
|
+
dependencies: [],
|
|
196
|
+
status: "pending",
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
it("prints progress bar in run mode without throwing", () => {
|
|
200
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
201
|
+
expect(() => printTaskProgress(2, 5, baseTask, "run")).not.toThrow();
|
|
202
|
+
const output = spy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
203
|
+
expect(output).toContain("T-001");
|
|
204
|
+
expect(output).toContain("Create User model");
|
|
205
|
+
spy.mockRestore();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("prints progress bar in skip mode", () => {
|
|
209
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
210
|
+
printTaskProgress(3, 5, { ...baseTask, status: "done" }, "skip");
|
|
211
|
+
const output = spy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
212
|
+
expect(output).toContain("already done");
|
|
213
|
+
spy.mockRestore();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it("calculates correct percentage", () => {
|
|
217
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
218
|
+
printTaskProgress(1, 4, baseTask, "run");
|
|
219
|
+
const output = spy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
220
|
+
expect(output).toContain("25%");
|
|
221
|
+
spy.mockRestore();
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("handles 0 total without crashing", () => {
|
|
225
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
226
|
+
expect(() => printTaskProgress(0, 0, baseTask, "run")).not.toThrow();
|
|
227
|
+
spy.mockRestore();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("shows 100% when all completed", () => {
|
|
231
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
232
|
+
printTaskProgress(5, 5, baseTask, "run");
|
|
233
|
+
const output = spy.mock.calls.map((c) => c.join(" ")).join("\n");
|
|
234
|
+
expect(output).toContain("100%");
|
|
235
|
+
spy.mockRestore();
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("uses layer icon for known layers", () => {
|
|
239
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
240
|
+
printTaskProgress(0, 1, { ...baseTask, layer: "api" }, "run");
|
|
241
|
+
// api layer has an icon, just verify no crash
|
|
242
|
+
expect(spy).toHaveBeenCalled();
|
|
243
|
+
spy.mockRestore();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("handles unknown layer gracefully", () => {
|
|
247
|
+
const spy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
248
|
+
expect(() =>
|
|
249
|
+
printTaskProgress(0, 1, { ...baseTask, layer: "unknown" as any }, "run")
|
|
250
|
+
).not.toThrow();
|
|
251
|
+
spy.mockRestore();
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,207 @@
|
|
|
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
|
+
ContextLoader,
|
|
7
|
+
isFrontendDeps,
|
|
8
|
+
FRONTEND_FRAMEWORKS,
|
|
9
|
+
} from "../core/context-loader";
|
|
10
|
+
|
|
11
|
+
// ─── isFrontendDeps ──────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
describe("isFrontendDeps", () => {
|
|
14
|
+
it("returns true for react", () => {
|
|
15
|
+
expect(isFrontendDeps(["react", "express"])).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns true for vue", () => {
|
|
19
|
+
expect(isFrontendDeps(["vue", "axios"])).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns true for next", () => {
|
|
23
|
+
expect(isFrontendDeps(["next", "typescript"])).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns true for nuxt", () => {
|
|
27
|
+
expect(isFrontendDeps(["nuxt"])).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("returns true for svelte", () => {
|
|
31
|
+
expect(isFrontendDeps(["svelte"])).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("returns false for backend-only deps", () => {
|
|
35
|
+
expect(isFrontendDeps(["express", "prisma", "mongoose"])).toBe(false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns false for empty deps", () => {
|
|
39
|
+
expect(isFrontendDeps([])).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("FRONTEND_FRAMEWORKS has at least 5 entries", () => {
|
|
43
|
+
expect(FRONTEND_FRAMEWORKS.length).toBeGreaterThanOrEqual(5);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── ContextLoader ───────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe("ContextLoader", () => {
|
|
50
|
+
let tmpDir: string;
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
tmpDir = path.join(os.tmpdir(), `context-test-${Date.now()}`);
|
|
54
|
+
await fs.ensureDir(tmpDir);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(async () => {
|
|
58
|
+
await fs.remove(tmpDir);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("loads context from a Node.js project with package.json", async () => {
|
|
62
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
63
|
+
dependencies: { express: "^4.0.0", prisma: "^5.0.0" },
|
|
64
|
+
devDependencies: { typescript: "^5.0.0" },
|
|
65
|
+
});
|
|
66
|
+
await fs.ensureDir(path.join(tmpDir, "src"));
|
|
67
|
+
await fs.writeFile(path.join(tmpDir, "src", "index.ts"), "console.log('hello')");
|
|
68
|
+
|
|
69
|
+
const loader = new ContextLoader(tmpDir);
|
|
70
|
+
const ctx = await loader.loadProjectContext();
|
|
71
|
+
|
|
72
|
+
expect(ctx.dependencies).toContain("express");
|
|
73
|
+
expect(ctx.dependencies).toContain("prisma");
|
|
74
|
+
expect(ctx.dependencies).toContain("typescript");
|
|
75
|
+
expect(ctx.techStack).toContain("Express");
|
|
76
|
+
expect(ctx.techStack).toContain("Prisma");
|
|
77
|
+
expect(ctx.techStack).toContain("TypeScript");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("detects React in techStack", async () => {
|
|
81
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
82
|
+
dependencies: { react: "^18.0.0", "react-dom": "^18.0.0" },
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const loader = new ContextLoader(tmpDir);
|
|
86
|
+
const ctx = await loader.loadProjectContext();
|
|
87
|
+
expect(ctx.techStack).toContain("React");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("detects Vue in techStack", async () => {
|
|
91
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), {
|
|
92
|
+
dependencies: { vue: "^3.0.0" },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const loader = new ContextLoader(tmpDir);
|
|
96
|
+
const ctx = await loader.loadProjectContext();
|
|
97
|
+
expect(ctx.techStack).toContain("Vue");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("loads Prisma schema when present", async () => {
|
|
101
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), { dependencies: {} });
|
|
102
|
+
await fs.ensureDir(path.join(tmpDir, "prisma"));
|
|
103
|
+
await fs.writeFile(
|
|
104
|
+
path.join(tmpDir, "prisma", "schema.prisma"),
|
|
105
|
+
"model User { id Int @id }"
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const loader = new ContextLoader(tmpDir);
|
|
109
|
+
const ctx = await loader.loadProjectContext();
|
|
110
|
+
expect(ctx.schema).toContain("model User");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns empty context for empty directory", async () => {
|
|
114
|
+
const loader = new ContextLoader(tmpDir);
|
|
115
|
+
const ctx = await loader.loadProjectContext();
|
|
116
|
+
expect(ctx.techStack).toEqual([]);
|
|
117
|
+
expect(ctx.dependencies).toEqual([]);
|
|
118
|
+
expect(ctx.apiStructure).toEqual([]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("loads constitution when .ai-spec-constitution.md exists", async () => {
|
|
122
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), { dependencies: {} });
|
|
123
|
+
await fs.writeFile(
|
|
124
|
+
path.join(tmpDir, ".ai-spec-constitution.md"),
|
|
125
|
+
"## 1. Architecture\nUse layered architecture"
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
const loader = new ContextLoader(tmpDir);
|
|
129
|
+
const ctx = await loader.loadProjectContext();
|
|
130
|
+
expect(ctx.constitution).toContain("Architecture");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("scans API structure from src/routes", async () => {
|
|
134
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), { dependencies: {} });
|
|
135
|
+
await fs.ensureDir(path.join(tmpDir, "src", "routes"));
|
|
136
|
+
await fs.writeFile(
|
|
137
|
+
path.join(tmpDir, "src", "routes", "user.ts"),
|
|
138
|
+
"export default router;"
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const loader = new ContextLoader(tmpDir);
|
|
142
|
+
const ctx = await loader.loadProjectContext();
|
|
143
|
+
expect(ctx.apiStructure.some((f) => f.includes("user.ts"))).toBe(true);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("loads shared config files (i18n, constants)", async () => {
|
|
147
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), { dependencies: {} });
|
|
148
|
+
await fs.ensureDir(path.join(tmpDir, "src", "constants"));
|
|
149
|
+
await fs.writeFile(
|
|
150
|
+
path.join(tmpDir, "src", "constants", "index.ts"),
|
|
151
|
+
"export const API_BASE = '/api';"
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const loader = new ContextLoader(tmpDir);
|
|
155
|
+
const ctx = await loader.loadProjectContext();
|
|
156
|
+
expect(ctx.sharedConfigFiles?.some((f) => f.category === "constants")).toBe(true);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("loads error patterns when error handler files exist", async () => {
|
|
160
|
+
await fs.writeJson(path.join(tmpDir, "package.json"), { dependencies: {} });
|
|
161
|
+
await fs.ensureDir(path.join(tmpDir, "src"));
|
|
162
|
+
await fs.writeFile(
|
|
163
|
+
path.join(tmpDir, "src", "errorHandler.ts"),
|
|
164
|
+
"export function handleError(err) { console.error(err); }"
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const loader = new ContextLoader(tmpDir);
|
|
168
|
+
const ctx = await loader.loadProjectContext();
|
|
169
|
+
expect(ctx.errorPatterns).toContain("handleError");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("loads PHP project context from composer.json", async () => {
|
|
173
|
+
await fs.writeJson(path.join(tmpDir, "composer.json"), {
|
|
174
|
+
require: {
|
|
175
|
+
php: "^8.1",
|
|
176
|
+
"laravel/framework": "^10.0",
|
|
177
|
+
},
|
|
178
|
+
"require-dev": {
|
|
179
|
+
phpunit: "^10.0",
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const loader = new ContextLoader(tmpDir);
|
|
184
|
+
const ctx = await loader.loadProjectContext();
|
|
185
|
+
expect(ctx.techStack).toContain("PHP");
|
|
186
|
+
expect(ctx.techStack).toContain("Laravel");
|
|
187
|
+
expect(ctx.dependencies).toContain("laravel/framework");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("loads Java project context from pom.xml", async () => {
|
|
191
|
+
await fs.writeFile(
|
|
192
|
+
path.join(tmpDir, "pom.xml"),
|
|
193
|
+
`<project>
|
|
194
|
+
<artifactId>my-app</artifactId>
|
|
195
|
+
<dependencies>
|
|
196
|
+
<dependency><artifactId>spring-boot-starter-web</artifactId></dependency>
|
|
197
|
+
<dependency><artifactId>mybatis-spring-boot-starter</artifactId></dependency>
|
|
198
|
+
</dependencies>
|
|
199
|
+
</project>`
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
const loader = new ContextLoader(tmpDir);
|
|
203
|
+
const ctx = await loader.loadProjectContext();
|
|
204
|
+
expect(ctx.techStack).toContain("Java");
|
|
205
|
+
expect(ctx.techStack).toContain("Spring Boot");
|
|
206
|
+
});
|
|
207
|
+
});
|
|
@@ -264,6 +264,111 @@ describe("validateDsl — model validation", () => {
|
|
|
264
264
|
});
|
|
265
265
|
});
|
|
266
266
|
|
|
267
|
+
// ─── Endpoint ID uniqueness ──────────────────────────────────────────────────
|
|
268
|
+
|
|
269
|
+
describe("validateDsl — endpoint ID uniqueness", () => {
|
|
270
|
+
it("accepts endpoints with unique IDs", () => {
|
|
271
|
+
const ep1 = { ...VALID_DSL.endpoints[0], id: "EP-001" };
|
|
272
|
+
const ep2 = { ...VALID_DSL.endpoints[0], id: "EP-002", path: "/api/auth/logout" };
|
|
273
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep1, ep2] });
|
|
274
|
+
expect(result.valid).toBe(true);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("rejects duplicate endpoint IDs", () => {
|
|
278
|
+
const ep1 = { ...VALID_DSL.endpoints[0], id: "EP-001" };
|
|
279
|
+
const ep2 = { ...VALID_DSL.endpoints[0], id: "EP-001", path: "/api/auth/logout" };
|
|
280
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep1, ep2] });
|
|
281
|
+
expect(result.valid).toBe(false);
|
|
282
|
+
if (!result.valid) {
|
|
283
|
+
expect(result.errors.some((e) => e.message.includes("Duplicate endpoint id"))).toBe(true);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("reports the correct path for the duplicate", () => {
|
|
288
|
+
const ep1 = { ...VALID_DSL.endpoints[0], id: "EP-001" };
|
|
289
|
+
const ep2 = { ...VALID_DSL.endpoints[0], id: "EP-001", path: "/api/other" };
|
|
290
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: [ep1, ep2] });
|
|
291
|
+
if (!result.valid) {
|
|
292
|
+
const dupError = result.errors.find((e) => e.message.includes("Duplicate"));
|
|
293
|
+
expect(dupError?.path).toBe("endpoints[1].id");
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("detects multiple groups of duplicates", () => {
|
|
298
|
+
const eps = [
|
|
299
|
+
{ ...VALID_DSL.endpoints[0], id: "EP-001" },
|
|
300
|
+
{ ...VALID_DSL.endpoints[0], id: "EP-002", path: "/api/b" },
|
|
301
|
+
{ ...VALID_DSL.endpoints[0], id: "EP-001", path: "/api/c" },
|
|
302
|
+
{ ...VALID_DSL.endpoints[0], id: "EP-002", path: "/api/d" },
|
|
303
|
+
];
|
|
304
|
+
const result = validateDsl({ ...VALID_DSL, endpoints: eps });
|
|
305
|
+
expect(result.valid).toBe(false);
|
|
306
|
+
if (!result.valid) {
|
|
307
|
+
const dupErrors = result.errors.filter((e) => e.message.includes("Duplicate endpoint id"));
|
|
308
|
+
expect(dupErrors.length).toBe(2);
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ─── Model field name uniqueness ─────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
describe("validateDsl — model field name uniqueness", () => {
|
|
316
|
+
it("accepts models with unique field names", () => {
|
|
317
|
+
const result = validateDsl(VALID_DSL);
|
|
318
|
+
expect(result.valid).toBe(true);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("rejects duplicate field names within a model", () => {
|
|
322
|
+
const model = {
|
|
323
|
+
name: "User",
|
|
324
|
+
fields: [
|
|
325
|
+
{ name: "id", type: "String", required: true },
|
|
326
|
+
{ name: "email", type: "String", required: true },
|
|
327
|
+
{ name: "id", type: "Int", required: true },
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
const result = validateDsl({ ...VALID_DSL, models: [model] });
|
|
331
|
+
expect(result.valid).toBe(false);
|
|
332
|
+
if (!result.valid) {
|
|
333
|
+
expect(result.errors.some((e) => e.message.includes("Duplicate field name"))).toBe(true);
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("reports the correct path for the duplicate field", () => {
|
|
338
|
+
const model = {
|
|
339
|
+
name: "User",
|
|
340
|
+
fields: [
|
|
341
|
+
{ name: "name", type: "String", required: true },
|
|
342
|
+
{ name: "name", type: "String", required: false },
|
|
343
|
+
],
|
|
344
|
+
};
|
|
345
|
+
const result = validateDsl({ ...VALID_DSL, models: [model] });
|
|
346
|
+
if (!result.valid) {
|
|
347
|
+
const dupError = result.errors.find((e) => e.message.includes("Duplicate field name"));
|
|
348
|
+
expect(dupError?.path).toBe("models[0].fields[1].name");
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("allows same field name in different models", () => {
|
|
353
|
+
const model1 = {
|
|
354
|
+
name: "User",
|
|
355
|
+
fields: [
|
|
356
|
+
{ name: "id", type: "String", required: true },
|
|
357
|
+
{ name: "name", type: "String", required: true },
|
|
358
|
+
],
|
|
359
|
+
};
|
|
360
|
+
const model2 = {
|
|
361
|
+
name: "Post",
|
|
362
|
+
fields: [
|
|
363
|
+
{ name: "id", type: "String", required: true },
|
|
364
|
+
{ name: "name", type: "String", required: true },
|
|
365
|
+
],
|
|
366
|
+
};
|
|
367
|
+
const result = validateDsl({ ...VALID_DSL, models: [model1, model2] });
|
|
368
|
+
expect(result.valid).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
267
372
|
// ─── Error collection (all errors in one pass) ───────────────────────────────
|
|
268
373
|
|
|
269
374
|
describe("validateDsl — error accumulation", () => {
|