ai-spec-dev 0.1.0 → 0.14.1
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/.claude/settings.local.json +18 -0
- package/README.md +1211 -146
- package/RELEASE_LOG.md +1444 -0
- package/cli/index.ts +1961 -0
- package/cli/welcome.ts +151 -0
- package/core/code-generator.ts +740 -0
- package/core/combined-generator.ts +63 -0
- package/core/constitution-consolidator.ts +141 -0
- package/core/constitution-generator.ts +89 -0
- package/core/context-loader.ts +453 -0
- package/core/contract-bridge.ts +217 -0
- package/core/dsl-extractor.ts +337 -0
- package/core/dsl-types.ts +166 -0
- package/core/dsl-validator.ts +450 -0
- package/core/error-feedback.ts +354 -0
- package/core/frontend-context-loader.ts +602 -0
- package/core/global-constitution.ts +88 -0
- package/core/key-store.ts +49 -0
- package/core/knowledge-memory.ts +171 -0
- package/core/mock-server-generator.ts +571 -0
- package/core/openapi-exporter.ts +361 -0
- package/core/requirement-decomposer.ts +198 -0
- package/core/reviewer.ts +259 -0
- package/core/spec-assessor.ts +99 -0
- package/core/spec-generator.ts +428 -0
- package/core/spec-refiner.ts +89 -0
- package/core/spec-updater.ts +227 -0
- package/core/spec-versioning.ts +213 -0
- package/core/task-generator.ts +174 -0
- package/core/test-generator.ts +273 -0
- package/core/workspace-loader.ts +256 -0
- package/dist/cli/index.js +6717 -672
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +6717 -670
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +147 -27
- package/dist/index.d.ts +147 -27
- package/dist/index.js +2337 -286
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2329 -285
- package/dist/index.mjs.map +1 -1
- package/git/worktree.ts +109 -0
- package/index.ts +9 -0
- package/package.json +4 -28
- package/prompts/codegen.prompt.ts +259 -0
- package/prompts/consolidate.prompt.ts +73 -0
- package/prompts/constitution.prompt.ts +63 -0
- package/prompts/decompose.prompt.ts +168 -0
- package/prompts/dsl.prompt.ts +203 -0
- package/prompts/frontend-spec.prompt.ts +191 -0
- package/prompts/global-constitution.prompt.ts +61 -0
- package/prompts/spec-assess.prompt.ts +53 -0
- package/prompts/spec.prompt.ts +102 -0
- package/prompts/tasks.prompt.ts +35 -0
- package/prompts/testgen.prompt.ts +84 -0
- package/prompts/update.prompt.ts +131 -0
- package/purpose.docx +0 -0
- package/purpose.md +444 -0
- package/tsconfig.json +14 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs-extra";
|
|
3
|
+
import { SpecDSL, ApiEndpoint, FieldMap } from "./dsl-types";
|
|
4
|
+
|
|
5
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface MockServerOptions {
|
|
8
|
+
/** Port for the mock server (default: 3001) */
|
|
9
|
+
port?: number;
|
|
10
|
+
/** Generate MSW handlers file */
|
|
11
|
+
msw?: boolean;
|
|
12
|
+
/** Generate frontend proxy config */
|
|
13
|
+
proxy?: boolean;
|
|
14
|
+
/** Output directory for generated files (default: mock/) */
|
|
15
|
+
outputDir?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MockGenerationResult {
|
|
19
|
+
files: Array<{ path: string; description: string }>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ─── Fixture Generator ────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Convert a type-description string to a fixture value (JavaScript literal).
|
|
26
|
+
*/
|
|
27
|
+
function typeToFixture(fieldName: string, typeDesc: string): unknown {
|
|
28
|
+
const t = typeDesc.toLowerCase();
|
|
29
|
+
|
|
30
|
+
if (t.includes("boolean") || t === "bool") return true;
|
|
31
|
+
if (t.includes("int") || t.includes("number") || t.includes("float") || t.includes("decimal")) {
|
|
32
|
+
if (fieldName.toLowerCase().includes("id")) return 1;
|
|
33
|
+
if (fieldName.toLowerCase().includes("count") || fieldName.toLowerCase().includes("total")) return 42;
|
|
34
|
+
if (fieldName.toLowerCase().includes("price") || fieldName.toLowerCase().includes("amount")) return 9.99;
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
if (t.includes("datetime") || t.includes("date") || t.includes("timestamp")) {
|
|
38
|
+
return "2024-01-15T10:30:00.000Z";
|
|
39
|
+
}
|
|
40
|
+
if (t.includes("[]") || t.includes("array") || t.includes("list")) return [];
|
|
41
|
+
if (t.includes("object") || t.includes("json") || t.includes("record")) return {};
|
|
42
|
+
|
|
43
|
+
// String heuristics by field name
|
|
44
|
+
const name = fieldName.toLowerCase();
|
|
45
|
+
if (name === "id" || name.endsWith("id")) return "abc123";
|
|
46
|
+
if (name.includes("email")) return "user@example.com";
|
|
47
|
+
if (name.includes("phone")) return "+1-555-0100";
|
|
48
|
+
if (name.includes("url") || name.includes("image") || name.includes("avatar")) return "https://example.com/sample.jpg";
|
|
49
|
+
if (name.includes("token") || name.includes("secret")) return "mock-token-xyz";
|
|
50
|
+
if (name.includes("name")) return "Example Name";
|
|
51
|
+
if (name.includes("title")) return "Example Title";
|
|
52
|
+
if (name.includes("description") || name.includes("content") || name.includes("body")) return "Example description text";
|
|
53
|
+
if (name.includes("status")) return "active";
|
|
54
|
+
if (name.includes("type") || name.includes("role")) return "default";
|
|
55
|
+
if (name.includes("code")) return "CODE001";
|
|
56
|
+
|
|
57
|
+
return `example_${fieldName}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildFixtureObject(fields: FieldMap): Record<string, unknown> {
|
|
61
|
+
const obj: Record<string, unknown> = {};
|
|
62
|
+
for (const [name, type] of Object.entries(fields)) {
|
|
63
|
+
obj[name] = typeToFixture(name, type);
|
|
64
|
+
}
|
|
65
|
+
return obj;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a fixture response object for an endpoint.
|
|
70
|
+
* For endpoints without explicit response schemas, generate minimal fixtures from model context.
|
|
71
|
+
*/
|
|
72
|
+
function buildEndpointFixture(endpoint: ApiEndpoint, dsl: SpecDSL): unknown {
|
|
73
|
+
const method = endpoint.method;
|
|
74
|
+
const status = endpoint.successStatus;
|
|
75
|
+
|
|
76
|
+
// DELETE with 204 → no body
|
|
77
|
+
if (status === 204) return null;
|
|
78
|
+
|
|
79
|
+
// Try to derive fixture from model names mentioned in endpoint description
|
|
80
|
+
const descLower = endpoint.description.toLowerCase();
|
|
81
|
+
const matchedModel = dsl.models.find((m) =>
|
|
82
|
+
descLower.includes(m.name.toLowerCase())
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
if (matchedModel) {
|
|
86
|
+
const fields: FieldMap = {};
|
|
87
|
+
for (const f of matchedModel.fields) {
|
|
88
|
+
fields[f.name] = f.type;
|
|
89
|
+
}
|
|
90
|
+
const item = buildFixtureObject(fields);
|
|
91
|
+
|
|
92
|
+
// List endpoints return arrays
|
|
93
|
+
if (method === "GET" && (descLower.includes("list") || descLower.includes("all") || descLower.includes("paginate"))) {
|
|
94
|
+
return {
|
|
95
|
+
data: [item, { ...item, id: "def456" }],
|
|
96
|
+
total: 2,
|
|
97
|
+
page: 1,
|
|
98
|
+
pageSize: 10,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return { data: item };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Fallback based on method
|
|
105
|
+
if (method === "POST") return { data: { id: "abc123", createdAt: "2024-01-15T10:30:00.000Z" } };
|
|
106
|
+
if (method === "GET") return { data: { id: "abc123" } };
|
|
107
|
+
return { success: true };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Express Mock Server Generator ───────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function generateMockServerJs(dsl: SpecDSL, port: number): string {
|
|
113
|
+
const lines: string[] = [];
|
|
114
|
+
|
|
115
|
+
lines.push(`// ─── Mock Server ─────────────────────────────────────────────`);
|
|
116
|
+
lines.push(`// Auto-generated by ai-spec mock`);
|
|
117
|
+
lines.push(`// Feature: ${dsl.feature.title}`);
|
|
118
|
+
lines.push(`// DO NOT use in production. Standalone dev mock only.`);
|
|
119
|
+
lines.push(``);
|
|
120
|
+
lines.push(`const express = require('express');`);
|
|
121
|
+
lines.push(`const app = express();`);
|
|
122
|
+
lines.push(``);
|
|
123
|
+
lines.push(`app.use(express.json());`);
|
|
124
|
+
lines.push(``);
|
|
125
|
+
lines.push(`// ─── CORS (dev only) ─────────────────────────────────────────`);
|
|
126
|
+
lines.push(`app.use((req, res, next) => {`);
|
|
127
|
+
lines.push(` res.setHeader('Access-Control-Allow-Origin', '*');`);
|
|
128
|
+
lines.push(` res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');`);
|
|
129
|
+
lines.push(` res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');`);
|
|
130
|
+
lines.push(` if (req.method === 'OPTIONS') return res.sendStatus(204);`);
|
|
131
|
+
lines.push(` next();`);
|
|
132
|
+
lines.push(`});`);
|
|
133
|
+
lines.push(``);
|
|
134
|
+
|
|
135
|
+
// Auth middleware
|
|
136
|
+
const hasAuthEndpoints = dsl.endpoints.some((e) => e.auth);
|
|
137
|
+
if (hasAuthEndpoints) {
|
|
138
|
+
lines.push(`// ─── Auth check (returns 401 if Authorization header missing) ──`);
|
|
139
|
+
lines.push(`function requireAuth(req, res, next) {`);
|
|
140
|
+
lines.push(` const authHeader = req.headers['authorization'];`);
|
|
141
|
+
lines.push(` if (!authHeader || !authHeader.startsWith('Bearer ')) {`);
|
|
142
|
+
lines.push(` return res.status(401).json({ code: 'UNAUTHORIZED', message: 'Missing or invalid Authorization header' });`);
|
|
143
|
+
lines.push(` }`);
|
|
144
|
+
lines.push(` next();`);
|
|
145
|
+
lines.push(`}`);
|
|
146
|
+
lines.push(``);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
lines.push(`// ─── Routes ──────────────────────────────────────────────────`);
|
|
150
|
+
|
|
151
|
+
for (const endpoint of dsl.endpoints) {
|
|
152
|
+
const fixture = buildEndpointFixture(endpoint, dsl);
|
|
153
|
+
const fixtureJson = JSON.stringify(fixture, null, 2)
|
|
154
|
+
.split("\n")
|
|
155
|
+
.map((l, i) => (i === 0 ? l : ` ${l}`))
|
|
156
|
+
.join("\n");
|
|
157
|
+
|
|
158
|
+
const expressMethod = endpoint.method.toLowerCase();
|
|
159
|
+
// Convert DSL path params :param to express :param (already compatible)
|
|
160
|
+
const expressPath = endpoint.path;
|
|
161
|
+
|
|
162
|
+
lines.push(`// ${endpoint.id}: ${endpoint.description}`);
|
|
163
|
+
if (endpoint.auth) {
|
|
164
|
+
lines.push(`app.${expressMethod}('${expressPath}', requireAuth, (req, res) => {`);
|
|
165
|
+
} else {
|
|
166
|
+
lines.push(`app.${expressMethod}('${expressPath}', (req, res) => {`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (fixture === null) {
|
|
170
|
+
lines.push(` res.sendStatus(${endpoint.successStatus});`);
|
|
171
|
+
} else {
|
|
172
|
+
lines.push(` res.status(${endpoint.successStatus}).json(${fixtureJson});`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Error simulation query param: ?simulate_error=ERRORCODE
|
|
176
|
+
if (endpoint.errors && endpoint.errors.length > 0) {
|
|
177
|
+
const firstError = endpoint.errors[0];
|
|
178
|
+
lines.push(` // Simulate error: GET /this/path?simulate_error=${firstError.code}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
lines.push(`});`);
|
|
182
|
+
lines.push(``);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
lines.push(`// ─── Start ───────────────────────────────────────────────────`);
|
|
186
|
+
lines.push(`const PORT = process.env.MOCK_PORT || ${port};`);
|
|
187
|
+
lines.push(`app.listen(PORT, () => {`);
|
|
188
|
+
lines.push(` console.log(\`[ai-spec mock] Mock server running at http://localhost:\${PORT}\`);`);
|
|
189
|
+
lines.push(` console.log('[ai-spec mock] Endpoints:');`);
|
|
190
|
+
for (const endpoint of dsl.endpoints) {
|
|
191
|
+
lines.push(` console.log(' ${endpoint.method.padEnd(7)} ${endpoint.path}');`);
|
|
192
|
+
}
|
|
193
|
+
lines.push(`});`);
|
|
194
|
+
lines.push(``);
|
|
195
|
+
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Proxy Config Generators ──────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
function detectFrontendFramework(projectDir: string): "vite" | "next" | "webpack" | "cra" | "unknown" {
|
|
202
|
+
// Check vite.config
|
|
203
|
+
for (const f of ["vite.config.ts", "vite.config.js", "vite.config.mts"]) {
|
|
204
|
+
if (fs.existsSync(path.join(projectDir, f))) return "vite";
|
|
205
|
+
}
|
|
206
|
+
// Check next.config
|
|
207
|
+
for (const f of ["next.config.js", "next.config.ts", "next.config.mjs"]) {
|
|
208
|
+
if (fs.existsSync(path.join(projectDir, f))) return "next";
|
|
209
|
+
}
|
|
210
|
+
// Check for CRA (react-scripts in package.json)
|
|
211
|
+
const pkgPath = path.join(projectDir, "package.json");
|
|
212
|
+
if (fs.existsSync(pkgPath)) {
|
|
213
|
+
try {
|
|
214
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
215
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
216
|
+
if (deps["react-scripts"]) return "cra";
|
|
217
|
+
} catch { /* ignore */ }
|
|
218
|
+
}
|
|
219
|
+
// Check webpack.config
|
|
220
|
+
for (const f of ["webpack.config.js", "webpack.config.ts"]) {
|
|
221
|
+
if (fs.existsSync(path.join(projectDir, f))) return "webpack";
|
|
222
|
+
}
|
|
223
|
+
return "unknown";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function generateViteProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
|
|
227
|
+
// Collect unique path prefixes
|
|
228
|
+
const prefixes = new Set<string>();
|
|
229
|
+
for (const ep of endpoints) {
|
|
230
|
+
const parts = ep.path.split("/").filter(Boolean);
|
|
231
|
+
if (parts.length > 0) prefixes.add(`/${parts[0]}`);
|
|
232
|
+
}
|
|
233
|
+
const target = `http://localhost:${mockPort}`;
|
|
234
|
+
const proxyEntries = Array.from(prefixes)
|
|
235
|
+
.map((p) => ` '${p}': { target: '${target}', changeOrigin: true }`)
|
|
236
|
+
.join(",\n");
|
|
237
|
+
|
|
238
|
+
return `// Add this proxy block to your vite.config.ts / vite.config.js
|
|
239
|
+
// Inside the defineConfig({ server: { proxy: { ... } } }) section:
|
|
240
|
+
//
|
|
241
|
+
// server: {
|
|
242
|
+
// proxy: {
|
|
243
|
+
${proxyEntries
|
|
244
|
+
.split("\n")
|
|
245
|
+
.map((l) => `// ${l}`)
|
|
246
|
+
.join("\n")}
|
|
247
|
+
// }
|
|
248
|
+
// }
|
|
249
|
+
|
|
250
|
+
// ─── Standalone proxy snippet for vite.config.ts ─────────────────────────────
|
|
251
|
+
// import { defineConfig } from 'vite';
|
|
252
|
+
// export default defineConfig({
|
|
253
|
+
// server: {
|
|
254
|
+
// proxy: {
|
|
255
|
+
${proxyEntries
|
|
256
|
+
.split("\n")
|
|
257
|
+
.map((l) => `// ${l.trim()}`)
|
|
258
|
+
.join("\n")}
|
|
259
|
+
// }
|
|
260
|
+
// }
|
|
261
|
+
// });
|
|
262
|
+
`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function generateNextProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
|
|
266
|
+
const prefixes = new Set<string>();
|
|
267
|
+
for (const ep of endpoints) {
|
|
268
|
+
const parts = ep.path.split("/").filter(Boolean);
|
|
269
|
+
if (parts.length > 0) prefixes.add(`/${parts[0]}`);
|
|
270
|
+
}
|
|
271
|
+
const rewrites = Array.from(prefixes).map(
|
|
272
|
+
(p) => ` { source: '${p}/:path*', destination: 'http://localhost:${mockPort}${p}/:path*' }`
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
return `// Add this to your next.config.js rewrites():
|
|
276
|
+
//
|
|
277
|
+
// module.exports = {
|
|
278
|
+
// async rewrites() {
|
|
279
|
+
// return [
|
|
280
|
+
${rewrites.map((r) => `// ${r}`).join(",\n")}
|
|
281
|
+
// ];
|
|
282
|
+
// },
|
|
283
|
+
// };
|
|
284
|
+
`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function generateWebpackProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
|
|
288
|
+
const prefixes = new Set<string>();
|
|
289
|
+
for (const ep of endpoints) {
|
|
290
|
+
const parts = ep.path.split("/").filter(Boolean);
|
|
291
|
+
if (parts.length > 0) prefixes.add(`/${parts[0]}`);
|
|
292
|
+
}
|
|
293
|
+
const proxyEntries = Array.from(prefixes)
|
|
294
|
+
.map(
|
|
295
|
+
(p) =>
|
|
296
|
+
` '${p}': {\n target: 'http://localhost:${mockPort}',\n changeOrigin: true\n }`
|
|
297
|
+
)
|
|
298
|
+
.join(",\n");
|
|
299
|
+
|
|
300
|
+
return `// Add this to your webpack.config.js devServer.proxy section:
|
|
301
|
+
//
|
|
302
|
+
// devServer: {
|
|
303
|
+
// proxy: {
|
|
304
|
+
${proxyEntries
|
|
305
|
+
.split("\n")
|
|
306
|
+
.map((l) => `// ${l}`)
|
|
307
|
+
.join("\n")}
|
|
308
|
+
// }
|
|
309
|
+
// }
|
|
310
|
+
`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function generateCraProxyBlock(mockPort: number): string {
|
|
314
|
+
return `// For Create React App: add a "proxy" field to package.json
|
|
315
|
+
// This only proxies requests that don't match static files:
|
|
316
|
+
//
|
|
317
|
+
// {
|
|
318
|
+
// "proxy": "http://localhost:${mockPort}"
|
|
319
|
+
// }
|
|
320
|
+
//
|
|
321
|
+
// Or use src/setupProxy.js for per-path control:
|
|
322
|
+
// const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
323
|
+
// module.exports = function(app) {
|
|
324
|
+
// app.use('/api', createProxyMiddleware({ target: 'http://localhost:${mockPort}', changeOrigin: true }));
|
|
325
|
+
// };
|
|
326
|
+
`;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function generateProxyConfig(
|
|
330
|
+
dsl: SpecDSL,
|
|
331
|
+
mockPort: number,
|
|
332
|
+
projectDir: string
|
|
333
|
+
): { content: string; filename: string } {
|
|
334
|
+
const framework = detectFrontendFramework(projectDir);
|
|
335
|
+
|
|
336
|
+
switch (framework) {
|
|
337
|
+
case "vite":
|
|
338
|
+
return {
|
|
339
|
+
filename: "mock/proxy.vite.comment.txt",
|
|
340
|
+
content: generateViteProxyBlock(mockPort, dsl.endpoints),
|
|
341
|
+
};
|
|
342
|
+
case "next":
|
|
343
|
+
return {
|
|
344
|
+
filename: "mock/proxy.next.comment.txt",
|
|
345
|
+
content: generateNextProxyBlock(mockPort, dsl.endpoints),
|
|
346
|
+
};
|
|
347
|
+
case "cra":
|
|
348
|
+
return {
|
|
349
|
+
filename: "mock/proxy.cra.comment.txt",
|
|
350
|
+
content: generateCraProxyBlock(mockPort),
|
|
351
|
+
};
|
|
352
|
+
default:
|
|
353
|
+
return {
|
|
354
|
+
filename: "mock/proxy.webpack.comment.txt",
|
|
355
|
+
content: generateWebpackProxyBlock(mockPort, dsl.endpoints),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ─── MSW Handler Generator ────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
function generateMswHandlers(dsl: SpecDSL): string {
|
|
363
|
+
const lines: string[] = [];
|
|
364
|
+
|
|
365
|
+
lines.push(`// ─── MSW Handlers ────────────────────────────────────────────`);
|
|
366
|
+
lines.push(`// Auto-generated by ai-spec mock --msw`);
|
|
367
|
+
lines.push(`// Feature: ${dsl.feature.title}`);
|
|
368
|
+
lines.push(`// Place this file at: src/mocks/handlers.ts`);
|
|
369
|
+
lines.push(``);
|
|
370
|
+
lines.push(`import { http, HttpResponse } from 'msw';`);
|
|
371
|
+
lines.push(``);
|
|
372
|
+
|
|
373
|
+
for (const endpoint of dsl.endpoints) {
|
|
374
|
+
const fixture = buildEndpointFixture(endpoint, dsl);
|
|
375
|
+
const fixtureJson = JSON.stringify(fixture, null, 4)
|
|
376
|
+
.split("\n")
|
|
377
|
+
.map((l, i) => (i === 0 ? l : ` ${l}`))
|
|
378
|
+
.join("\n");
|
|
379
|
+
const method = endpoint.method.toLowerCase();
|
|
380
|
+
lines.push(`// ${endpoint.id}: ${endpoint.description}`);
|
|
381
|
+
if (fixture === null) {
|
|
382
|
+
lines.push(`http.${method}('${endpoint.path}', () => {`);
|
|
383
|
+
lines.push(` return new HttpResponse(null, { status: ${endpoint.successStatus} });`);
|
|
384
|
+
} else {
|
|
385
|
+
lines.push(`http.${method}('${endpoint.path}', () => {`);
|
|
386
|
+
lines.push(` return HttpResponse.json(${fixtureJson}, { status: ${endpoint.successStatus} });`);
|
|
387
|
+
}
|
|
388
|
+
lines.push(`}),`);
|
|
389
|
+
lines.push(``);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// Wrap in export
|
|
393
|
+
const handlerLines = lines.join("\n");
|
|
394
|
+
|
|
395
|
+
return `// ─── MSW Handlers ────────────────────────────────────────────
|
|
396
|
+
// Auto-generated by ai-spec mock --msw
|
|
397
|
+
// Feature: ${dsl.feature.title}
|
|
398
|
+
// Place this file at: src/mocks/handlers.ts
|
|
399
|
+
|
|
400
|
+
import { http, HttpResponse } from 'msw';
|
|
401
|
+
|
|
402
|
+
export const handlers = [
|
|
403
|
+
${dsl.endpoints
|
|
404
|
+
.map((endpoint) => {
|
|
405
|
+
const fixture = buildEndpointFixture(endpoint, dsl);
|
|
406
|
+
const method = endpoint.method.toLowerCase();
|
|
407
|
+
const indent = " ";
|
|
408
|
+
const fixtureJson = JSON.stringify(fixture, null, 2)
|
|
409
|
+
.split("\n")
|
|
410
|
+
.map((l, i) => (i === 0 ? l : `${indent} ${l}`))
|
|
411
|
+
.join("\n");
|
|
412
|
+
|
|
413
|
+
const comment = `${indent}// ${endpoint.id}: ${endpoint.description}`;
|
|
414
|
+
let handler: string;
|
|
415
|
+
if (fixture === null) {
|
|
416
|
+
handler = `${indent}http.${method}('${endpoint.path}', () => {\n${indent} return new HttpResponse(null, { status: ${endpoint.successStatus} });\n${indent}})`;
|
|
417
|
+
} else {
|
|
418
|
+
handler = `${indent}http.${method}('${endpoint.path}', () => {\n${indent} return HttpResponse.json(${fixtureJson}, { status: ${endpoint.successStatus} });\n${indent}})`;
|
|
419
|
+
}
|
|
420
|
+
return `${comment}\n${handler}`;
|
|
421
|
+
})
|
|
422
|
+
.join(",\n\n")}
|
|
423
|
+
];
|
|
424
|
+
`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function generateMswBrowser(): string {
|
|
428
|
+
return `// src/mocks/browser.ts
|
|
429
|
+
// MSW browser setup — import and call start() in your app entry point (dev only)
|
|
430
|
+
import { setupWorker } from 'msw/browser';
|
|
431
|
+
import { handlers } from './handlers';
|
|
432
|
+
|
|
433
|
+
export const worker = setupWorker(...handlers);
|
|
434
|
+
`;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Generate mock server, proxy config, and/or MSW handlers from a SpecDSL.
|
|
441
|
+
* Writes files relative to projectDir.
|
|
442
|
+
*/
|
|
443
|
+
export async function generateMockAssets(
|
|
444
|
+
dsl: SpecDSL,
|
|
445
|
+
projectDir: string,
|
|
446
|
+
opts: MockServerOptions = {}
|
|
447
|
+
): Promise<MockGenerationResult> {
|
|
448
|
+
const port = opts.port ?? 3001;
|
|
449
|
+
const outputDir = path.join(projectDir, opts.outputDir ?? "mock");
|
|
450
|
+
const result: MockGenerationResult = { files: [] };
|
|
451
|
+
|
|
452
|
+
await fs.ensureDir(outputDir);
|
|
453
|
+
|
|
454
|
+
// 1. Express mock server (always generated)
|
|
455
|
+
const serverJs = generateMockServerJs(dsl, port);
|
|
456
|
+
const serverPath = path.join(outputDir, "server.js");
|
|
457
|
+
await fs.writeFile(serverPath, serverJs, "utf-8");
|
|
458
|
+
result.files.push({
|
|
459
|
+
path: path.relative(projectDir, serverPath),
|
|
460
|
+
description: `Express mock server — run with: node mock/server.js`,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
// README inside mock/
|
|
464
|
+
const mockReadme = `# Mock Server
|
|
465
|
+
|
|
466
|
+
Auto-generated by \`ai-spec mock\` for **${dsl.feature.title}**.
|
|
467
|
+
|
|
468
|
+
## Quick start
|
|
469
|
+
|
|
470
|
+
\`\`\`bash
|
|
471
|
+
# Install express (one-time)
|
|
472
|
+
npm install --save-dev express
|
|
473
|
+
|
|
474
|
+
# Start the mock server
|
|
475
|
+
node mock/server.js
|
|
476
|
+
# → Listening on http://localhost:${port}
|
|
477
|
+
\`\`\`
|
|
478
|
+
|
|
479
|
+
## Endpoints
|
|
480
|
+
|
|
481
|
+
| Method | Path | Auth | Status |
|
|
482
|
+
|--------|------|------|--------|
|
|
483
|
+
${dsl.endpoints
|
|
484
|
+
.map(
|
|
485
|
+
(e) =>
|
|
486
|
+
`| ${e.method} | \`${e.path}\` | ${e.auth ? "✓" : "—"} | ${e.successStatus} |`
|
|
487
|
+
)
|
|
488
|
+
.join("\n")}
|
|
489
|
+
|
|
490
|
+
## Error simulation
|
|
491
|
+
|
|
492
|
+
Append \`?simulate_error=1\` to any request to test error handling (not yet auto-wired — edit server.js manually).
|
|
493
|
+
`;
|
|
494
|
+
const readmePath = path.join(outputDir, "README.md");
|
|
495
|
+
await fs.writeFile(readmePath, mockReadme, "utf-8");
|
|
496
|
+
result.files.push({
|
|
497
|
+
path: path.relative(projectDir, readmePath),
|
|
498
|
+
description: "Mock server usage guide",
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
// 2. Proxy config (optional)
|
|
502
|
+
if (opts.proxy) {
|
|
503
|
+
const { content, filename } = generateProxyConfig(dsl, port, projectDir);
|
|
504
|
+
const proxyPath = path.join(projectDir, filename);
|
|
505
|
+
await fs.ensureDir(path.dirname(proxyPath));
|
|
506
|
+
await fs.writeFile(proxyPath, content, "utf-8");
|
|
507
|
+
result.files.push({
|
|
508
|
+
path: filename,
|
|
509
|
+
description: "Proxy config snippet — copy instructions into your framework config",
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// 3. MSW handlers (optional)
|
|
514
|
+
if (opts.msw) {
|
|
515
|
+
const mswDir = path.join(projectDir, "src", "mocks");
|
|
516
|
+
await fs.ensureDir(mswDir);
|
|
517
|
+
|
|
518
|
+
const handlersContent = generateMswHandlers(dsl);
|
|
519
|
+
const handlersPath = path.join(mswDir, "handlers.ts");
|
|
520
|
+
await fs.writeFile(handlersPath, handlersContent, "utf-8");
|
|
521
|
+
result.files.push({
|
|
522
|
+
path: path.relative(projectDir, handlersPath),
|
|
523
|
+
description: "MSW request handlers",
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const browserContent = generateMswBrowser();
|
|
527
|
+
const browserPath = path.join(mswDir, "browser.ts");
|
|
528
|
+
await fs.writeFile(browserPath, browserContent, "utf-8");
|
|
529
|
+
result.files.push({
|
|
530
|
+
path: path.relative(projectDir, browserPath),
|
|
531
|
+
description: "MSW browser worker setup",
|
|
532
|
+
});
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Find the latest DSL file in a project directory.
|
|
540
|
+
* Looks for *.dsl.json files under .ai-spec/
|
|
541
|
+
*/
|
|
542
|
+
export async function findLatestDslFile(projectDir: string): Promise<string | null> {
|
|
543
|
+
const specDir = path.join(projectDir, ".ai-spec");
|
|
544
|
+
if (!(await fs.pathExists(specDir))) return null;
|
|
545
|
+
|
|
546
|
+
const allFiles: string[] = [];
|
|
547
|
+
|
|
548
|
+
async function scan(dir: string): Promise<void> {
|
|
549
|
+
const entries = await fs.readdir(dir);
|
|
550
|
+
for (const entry of entries) {
|
|
551
|
+
const abs = path.join(dir, entry);
|
|
552
|
+
const stat = await fs.stat(abs);
|
|
553
|
+
if (stat.isDirectory()) {
|
|
554
|
+
await scan(abs);
|
|
555
|
+
} else if (entry.endsWith(".dsl.json")) {
|
|
556
|
+
allFiles.push(abs);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
await scan(specDir);
|
|
562
|
+
|
|
563
|
+
if (allFiles.length === 0) return null;
|
|
564
|
+
|
|
565
|
+
// Return most recently modified
|
|
566
|
+
const withMtimes = await Promise.all(
|
|
567
|
+
allFiles.map(async (f) => ({ f, mtime: (await fs.stat(f)).mtime }))
|
|
568
|
+
);
|
|
569
|
+
withMtimes.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
|
|
570
|
+
return withMtimes[0].f;
|
|
571
|
+
}
|