ai-spec-dev 0.38.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/RELEASE_LOG.md +231 -0
  2. package/cli/commands/create.ts +9 -1176
  3. package/cli/commands/dashboard.ts +1 -1
  4. package/cli/pipeline/helpers.ts +34 -0
  5. package/cli/pipeline/multi-repo.ts +483 -0
  6. package/cli/pipeline/single-repo.ts +755 -0
  7. package/cli/utils.ts +2 -0
  8. package/core/code-generator.ts +52 -341
  9. package/core/codegen/helpers.ts +219 -0
  10. package/core/codegen/topo-sort.ts +98 -0
  11. package/core/constitution-consolidator.ts +2 -2
  12. package/core/dsl-coverage-checker.ts +298 -0
  13. package/core/dsl-extractor.ts +19 -46
  14. package/core/dsl-feedback.ts +1 -1
  15. package/core/dsl-validator.ts +74 -0
  16. package/core/error-feedback.ts +95 -11
  17. package/core/frontend-context-loader.ts +27 -5
  18. package/core/knowledge-memory.ts +52 -0
  19. package/core/mock/fixtures.ts +89 -0
  20. package/core/mock/proxy.ts +380 -0
  21. package/core/mock-server-generator.ts +12 -460
  22. package/core/requirement-decomposer.ts +4 -28
  23. package/core/reviewer.ts +1 -1
  24. package/core/safe-json.ts +76 -0
  25. package/core/spec-updater.ts +5 -21
  26. package/core/token-budget.ts +124 -0
  27. package/core/vcr.ts +20 -1
  28. package/dist/cli/index.js +4110 -3534
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/cli/index.mjs +4237 -3661
  31. package/dist/cli/index.mjs.map +1 -1
  32. package/dist/index.d.mts +18 -16
  33. package/dist/index.d.ts +18 -16
  34. package/dist/index.js +310 -182
  35. package/dist/index.js.map +1 -1
  36. package/dist/index.mjs +308 -180
  37. package/dist/index.mjs.map +1 -1
  38. package/package.json +2 -2
  39. package/purpose.md +173 -33
  40. package/tests/auto-consolidation.test.ts +109 -0
  41. package/tests/combined-generator.test.ts +81 -0
  42. package/tests/constitution-consolidator.test.ts +161 -0
  43. package/tests/constitution-generator.test.ts +94 -0
  44. package/tests/contract-bridge.test.ts +201 -0
  45. package/tests/design-dialogue.test.ts +108 -0
  46. package/tests/dsl-coverage-checker.test.ts +230 -0
  47. package/tests/dsl-feedback.test.ts +45 -0
  48. package/tests/dsl-validator-xref.test.ts +99 -0
  49. package/tests/error-feedback-repair.test.ts +319 -0
  50. package/tests/error-feedback-validation.test.ts +91 -0
  51. package/tests/frontend-context-loader.test.ts +609 -0
  52. package/tests/global-constitution.test.ts +110 -0
  53. package/tests/key-store.test.ts +73 -0
  54. package/tests/knowledge-memory.test.ts +327 -0
  55. package/tests/project-index.test.ts +206 -0
  56. package/tests/prompt-hasher.test.ts +19 -0
  57. package/tests/requirement-decomposer.test.ts +171 -0
  58. package/tests/reviewer.test.ts +4 -1
  59. package/tests/run-logger.test.ts +289 -0
  60. package/tests/run-snapshot.test.ts +113 -0
  61. package/tests/safe-json.test.ts +63 -0
  62. package/tests/spec-updater.test.ts +161 -0
  63. package/tests/test-generator.test.ts +146 -0
  64. package/tests/token-budget.test.ts +124 -0
  65. package/tests/vcr-hash.test.ts +101 -0
  66. package/tests/workspace-loader.test.ts +277 -0
@@ -0,0 +1,380 @@
1
+ import * as path from "path";
2
+ import * as fs from "fs-extra";
3
+ import { spawn } from "child_process";
4
+ import { ApiEndpoint } from "../dsl-types";
5
+
6
+ // ─── Types ───────────────────────────────────────────────────────────────────
7
+
8
+ export interface ProxyApplyResult {
9
+ framework: string;
10
+ applied: boolean;
11
+ devCommand: string | null;
12
+ note?: string;
13
+ }
14
+
15
+ const MOCK_LOCK_FILE = ".ai-spec-mock.lock.json";
16
+
17
+ interface MockLock {
18
+ framework: string;
19
+ mockPort: number;
20
+ frontendDir: string;
21
+ mockServerPid?: number;
22
+ actions: Array<
23
+ | { type: "wrote-file"; filePath: string }
24
+ | { type: "patched-pkg-proxy"; originalProxy?: string | null }
25
+ | { type: "added-pkg-script"; key: string; originalValue?: string | null }
26
+ >;
27
+ }
28
+
29
+ // ─── Framework Detection ─────────────────────────────────────────────────────
30
+
31
+ export function detectFrontendFramework(projectDir: string): "vite" | "next" | "webpack" | "cra" | "unknown" {
32
+ // Check vite.config
33
+ for (const f of ["vite.config.ts", "vite.config.js", "vite.config.mts"]) {
34
+ if (fs.existsSync(path.join(projectDir, f))) return "vite";
35
+ }
36
+ // Check next.config
37
+ for (const f of ["next.config.js", "next.config.ts", "next.config.mjs"]) {
38
+ if (fs.existsSync(path.join(projectDir, f))) return "next";
39
+ }
40
+ // Check for CRA (react-scripts in package.json)
41
+ const pkgPath = path.join(projectDir, "package.json");
42
+ if (fs.existsSync(pkgPath)) {
43
+ try {
44
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
45
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
46
+ if (deps["react-scripts"]) return "cra";
47
+ } catch { /* ignore */ }
48
+ }
49
+ // Check webpack.config
50
+ for (const f of ["webpack.config.js", "webpack.config.ts"]) {
51
+ if (fs.existsSync(path.join(projectDir, f))) return "webpack";
52
+ }
53
+ return "unknown";
54
+ }
55
+
56
+ // ─── Proxy Config Generators ─────────────────────────────────────────────────
57
+
58
+ export function generateViteProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
59
+ // Collect unique path prefixes
60
+ const prefixes = new Set<string>();
61
+ for (const ep of endpoints) {
62
+ const parts = ep.path.split("/").filter(Boolean);
63
+ if (parts.length > 0) prefixes.add(`/${parts[0]}`);
64
+ }
65
+ const target = `http://localhost:${mockPort}`;
66
+ const proxyEntries = Array.from(prefixes)
67
+ .map((p) => ` '${p}': { target: '${target}', changeOrigin: true }`)
68
+ .join(",\n");
69
+
70
+ return `// Add this proxy block to your vite.config.ts / vite.config.js
71
+ // Inside the defineConfig({ server: { proxy: { ... } } }) section:
72
+ //
73
+ // server: {
74
+ // proxy: {
75
+ ${proxyEntries
76
+ .split("\n")
77
+ .map((l) => `// ${l}`)
78
+ .join("\n")}
79
+ // }
80
+ // }
81
+
82
+ // ─── Standalone proxy snippet for vite.config.ts ─────────────────────────────
83
+ // import { defineConfig } from 'vite';
84
+ // export default defineConfig({
85
+ // server: {
86
+ // proxy: {
87
+ ${proxyEntries
88
+ .split("\n")
89
+ .map((l) => `// ${l.trim()}`)
90
+ .join("\n")}
91
+ // }
92
+ // }
93
+ // });
94
+ `;
95
+ }
96
+
97
+ export function generateNextProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
98
+ const prefixes = new Set<string>();
99
+ for (const ep of endpoints) {
100
+ const parts = ep.path.split("/").filter(Boolean);
101
+ if (parts.length > 0) prefixes.add(`/${parts[0]}`);
102
+ }
103
+ const rewrites = Array.from(prefixes).map(
104
+ (p) => ` { source: '${p}/:path*', destination: 'http://localhost:${mockPort}${p}/:path*' }`
105
+ );
106
+
107
+ return `// Add this to your next.config.js rewrites():
108
+ //
109
+ // module.exports = {
110
+ // async rewrites() {
111
+ // return [
112
+ ${rewrites.map((r) => `// ${r}`).join(",\n")}
113
+ // ];
114
+ // },
115
+ // };
116
+ `;
117
+ }
118
+
119
+ export function generateWebpackProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
120
+ const prefixes = new Set<string>();
121
+ for (const ep of endpoints) {
122
+ const parts = ep.path.split("/").filter(Boolean);
123
+ if (parts.length > 0) prefixes.add(`/${parts[0]}`);
124
+ }
125
+ const proxyEntries = Array.from(prefixes)
126
+ .map(
127
+ (p) =>
128
+ ` '${p}': {\n target: 'http://localhost:${mockPort}',\n changeOrigin: true\n }`
129
+ )
130
+ .join(",\n");
131
+
132
+ return `// Add this to your webpack.config.js devServer.proxy section:
133
+ //
134
+ // devServer: {
135
+ // proxy: {
136
+ ${proxyEntries
137
+ .split("\n")
138
+ .map((l) => `// ${l}`)
139
+ .join("\n")}
140
+ // }
141
+ // }
142
+ `;
143
+ }
144
+
145
+ export function generateCraProxyBlock(mockPort: number): string {
146
+ return `// For Create React App: add a "proxy" field to package.json
147
+ // This only proxies requests that don't match static files:
148
+ //
149
+ // {
150
+ // "proxy": "http://localhost:${mockPort}"
151
+ // }
152
+ //
153
+ // Or use src/setupProxy.js for per-path control:
154
+ // const { createProxyMiddleware } = require('http-proxy-middleware');
155
+ // module.exports = function(app) {
156
+ // app.use('/api', createProxyMiddleware({ target: 'http://localhost:${mockPort}', changeOrigin: true }));
157
+ // };
158
+ `;
159
+ }
160
+
161
+ export function generateProxyConfig(
162
+ dsl: { endpoints: ApiEndpoint[] },
163
+ mockPort: number,
164
+ projectDir: string
165
+ ): { content: string; filename: string } {
166
+ const framework = detectFrontendFramework(projectDir);
167
+
168
+ switch (framework) {
169
+ case "vite":
170
+ return {
171
+ filename: "mock/proxy.vite.comment.txt",
172
+ content: generateViteProxyBlock(mockPort, dsl.endpoints),
173
+ };
174
+ case "next":
175
+ return {
176
+ filename: "mock/proxy.next.comment.txt",
177
+ content: generateNextProxyBlock(mockPort, dsl.endpoints),
178
+ };
179
+ case "cra":
180
+ return {
181
+ filename: "mock/proxy.cra.comment.txt",
182
+ content: generateCraProxyBlock(mockPort),
183
+ };
184
+ default:
185
+ return {
186
+ filename: "mock/proxy.webpack.comment.txt",
187
+ content: generateWebpackProxyBlock(mockPort, dsl.endpoints),
188
+ };
189
+ }
190
+ }
191
+
192
+ // ─── Vite Mock Config ────────────────────────────────────────────────────────
193
+
194
+ function findViteConfigFile(projectDir: string): string | null {
195
+ for (const f of ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"]) {
196
+ if (fs.existsSync(path.join(projectDir, f))) return f;
197
+ }
198
+ return null;
199
+ }
200
+
201
+ function buildViteProxyEntries(endpoints: ApiEndpoint[], mockPort: number): string {
202
+ const prefixes = new Set<string>();
203
+ for (const ep of endpoints) {
204
+ const parts = ep.path.split("/").filter(Boolean);
205
+ if (parts.length > 0) prefixes.add(`/${parts[0]}`);
206
+ }
207
+ if (prefixes.size === 0) prefixes.add("/api");
208
+ const target = `http://localhost:${mockPort}`;
209
+ return Array.from(prefixes)
210
+ .map((p) => ` '${p}': { target: '${target}', changeOrigin: true },`)
211
+ .join("\n");
212
+ }
213
+
214
+ function generateViteMockConfigTs(baseConfigFile: string, mockPort: number, endpoints: ApiEndpoint[]): string {
215
+ const importPath = `./${baseConfigFile.replace(/\.(ts|mts|js|mjs)$/, "")}`;
216
+ const proxyEntries = buildViteProxyEntries(endpoints, mockPort);
217
+ return `// Auto-generated by ai-spec mock --serve
218
+ // LOCAL DEVELOPMENT ONLY — do not commit this file
219
+ // Remove with: ai-spec mock --restore
220
+ import { defineConfig, mergeConfig } from 'vite';
221
+
222
+ export default defineConfig(async (env) => {
223
+ const mod = await import('${importPath}');
224
+ const baseConfigOrFn = mod.default;
225
+ const baseConfig =
226
+ typeof baseConfigOrFn === 'function'
227
+ ? await baseConfigOrFn(env)
228
+ : baseConfigOrFn;
229
+
230
+ return mergeConfig(baseConfig ?? {}, {
231
+ server: {
232
+ proxy: {
233
+ ${proxyEntries}
234
+ },
235
+ },
236
+ });
237
+ });
238
+ `;
239
+ }
240
+
241
+ // ─── Proxy Patching (applyMockProxy / restoreMockProxy) ──────────────────────
242
+
243
+ /**
244
+ * Patch the frontend project's proxy config to point to the mock server.
245
+ * Vite: writes vite.config.ai-spec-mock.ts + adds "dev:mock" npm script.
246
+ * CRA : patches package.json "proxy" field (original backed up in lock file).
247
+ * Saves .ai-spec-mock.lock.json so restoreMockProxy() can undo all changes.
248
+ */
249
+ export async function applyMockProxy(
250
+ frontendDir: string,
251
+ mockPort: number,
252
+ endpoints: ApiEndpoint[] = []
253
+ ): Promise<ProxyApplyResult> {
254
+ const framework = detectFrontendFramework(frontendDir);
255
+ const actions: MockLock["actions"] = [];
256
+
257
+ if (framework === "vite") {
258
+ const viteConfigFile = findViteConfigFile(frontendDir) ?? "vite.config.ts";
259
+ const mockConfigContent = generateViteMockConfigTs(viteConfigFile, mockPort, endpoints);
260
+ const mockConfigPath = path.join(frontendDir, "vite.config.ai-spec-mock.ts");
261
+ await fs.writeFile(mockConfigPath, mockConfigContent, "utf-8");
262
+ actions.push({ type: "wrote-file", filePath: "vite.config.ai-spec-mock.ts" });
263
+
264
+ const pkgPath = path.join(frontendDir, "package.json");
265
+ if (await fs.pathExists(pkgPath)) {
266
+ const pkg = await fs.readJson(pkgPath);
267
+ pkg.scripts = pkg.scripts ?? {};
268
+ const originalValue: string | null = pkg.scripts["dev:mock"] ?? null;
269
+ pkg.scripts["dev:mock"] = "vite --config vite.config.ai-spec-mock.ts";
270
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
271
+ actions.push({ type: "added-pkg-script", key: "dev:mock", originalValue });
272
+ }
273
+
274
+ const lock: MockLock = { framework, mockPort, frontendDir, actions };
275
+ await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
276
+ return { framework, applied: true, devCommand: "npm run dev:mock" };
277
+ }
278
+
279
+ if (framework === "cra") {
280
+ const pkgPath = path.join(frontendDir, "package.json");
281
+ if (await fs.pathExists(pkgPath)) {
282
+ const pkg = await fs.readJson(pkgPath);
283
+ const originalProxy: string | null = pkg.proxy ?? null;
284
+ pkg.proxy = `http://localhost:${mockPort}`;
285
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
286
+ actions.push({ type: "patched-pkg-proxy", originalProxy });
287
+ const lock: MockLock = { framework, mockPort, frontendDir, actions };
288
+ await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
289
+ return { framework, applied: true, devCommand: "npm start" };
290
+ }
291
+ return { framework, applied: false, devCommand: null, note: "No package.json found." };
292
+ }
293
+
294
+ // next / webpack / unknown — save lock but no auto-patch
295
+ const lock: MockLock = { framework, mockPort, frontendDir, actions };
296
+ await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
297
+ const manualNote =
298
+ framework === "next"
299
+ ? `Add rewrites in next.config.js to proxy API calls to http://localhost:${mockPort}`
300
+ : `Add proxy in webpack.config.js devServer to target http://localhost:${mockPort}`;
301
+ return { framework, applied: false, devCommand: null, note: manualNote };
302
+ }
303
+
304
+ /**
305
+ * Undo all proxy changes made by applyMockProxy().
306
+ * Also kills the mock server if its PID was stored in the lock file.
307
+ */
308
+ export async function restoreMockProxy(
309
+ frontendDir: string
310
+ ): Promise<{ restored: boolean; note?: string }> {
311
+ const lockPath = path.join(frontendDir, MOCK_LOCK_FILE);
312
+ if (!(await fs.pathExists(lockPath))) {
313
+ return { restored: false, note: "No lock file found — nothing to restore." };
314
+ }
315
+
316
+ const lock: MockLock = await fs.readJson(lockPath);
317
+
318
+ for (const action of lock.actions) {
319
+ if (action.type === "wrote-file") {
320
+ const fp = path.join(frontendDir, action.filePath);
321
+ if (await fs.pathExists(fp)) await fs.remove(fp);
322
+ } else if (action.type === "added-pkg-script") {
323
+ const pkgPath = path.join(frontendDir, "package.json");
324
+ if (await fs.pathExists(pkgPath)) {
325
+ const pkg = await fs.readJson(pkgPath);
326
+ if (action.originalValue == null) {
327
+ delete pkg.scripts?.[action.key];
328
+ } else {
329
+ pkg.scripts = pkg.scripts ?? {};
330
+ pkg.scripts[action.key] = action.originalValue;
331
+ }
332
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
333
+ }
334
+ } else if (action.type === "patched-pkg-proxy") {
335
+ const pkgPath = path.join(frontendDir, "package.json");
336
+ if (await fs.pathExists(pkgPath)) {
337
+ const pkg = await fs.readJson(pkgPath);
338
+ if (action.originalProxy == null) {
339
+ delete pkg.proxy;
340
+ } else {
341
+ pkg.proxy = action.originalProxy;
342
+ }
343
+ await fs.writeJson(pkgPath, pkg, { spaces: 2 });
344
+ }
345
+ }
346
+ }
347
+
348
+ if (lock.mockServerPid) {
349
+ try { process.kill(lock.mockServerPid, "SIGTERM"); } catch { /* already dead */ }
350
+ }
351
+
352
+ await fs.remove(lockPath);
353
+ return { restored: true };
354
+ }
355
+
356
+ /**
357
+ * Start mock/server.js as a detached background process.
358
+ * Returns the spawned PID so it can be stored for later cleanup.
359
+ */
360
+ export function startMockServerBackground(serverJsPath: string, port: number): number {
361
+ const child = spawn("node", [serverJsPath], {
362
+ detached: true,
363
+ stdio: "ignore",
364
+ env: { ...process.env, MOCK_PORT: String(port) },
365
+ });
366
+ child.unref();
367
+ return child.pid!;
368
+ }
369
+
370
+ /**
371
+ * Save the mock server PID into an existing lock file.
372
+ */
373
+ export async function saveMockServerPid(frontendDir: string, pid: number): Promise<void> {
374
+ const lockPath = path.join(frontendDir, MOCK_LOCK_FILE);
375
+ if (await fs.pathExists(lockPath)) {
376
+ const lock: MockLock = await fs.readJson(lockPath);
377
+ lock.mockServerPid = pid;
378
+ await fs.writeJson(lockPath, lock, { spaces: 2 });
379
+ }
380
+ }