ai-spec-dev 0.37.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.
- package/README.md +381 -1796
- package/RELEASE_LOG.md +231 -0
- package/cli/commands/create.ts +9 -1176
- package/cli/commands/dashboard.ts +1 -1
- package/cli/pipeline/helpers.ts +34 -0
- package/cli/pipeline/multi-repo.ts +483 -0
- package/cli/pipeline/single-repo.ts +755 -0
- package/cli/utils.ts +2 -0
- package/core/code-generator.ts +52 -341
- package/core/codegen/helpers.ts +219 -0
- package/core/codegen/topo-sort.ts +98 -0
- package/core/constitution-consolidator.ts +2 -2
- package/core/dsl-coverage-checker.ts +298 -0
- package/core/dsl-extractor.ts +19 -46
- package/core/dsl-feedback.ts +1 -1
- package/core/dsl-validator.ts +74 -0
- package/core/error-feedback.ts +95 -11
- package/core/frontend-context-loader.ts +27 -5
- package/core/knowledge-memory.ts +52 -0
- package/core/mock/fixtures.ts +89 -0
- package/core/mock/proxy.ts +380 -0
- package/core/mock-server-generator.ts +12 -460
- package/core/requirement-decomposer.ts +4 -28
- package/core/reviewer.ts +1 -1
- package/core/safe-json.ts +76 -0
- package/core/spec-updater.ts +5 -21
- package/core/token-budget.ts +124 -0
- package/core/vcr.ts +20 -1
- package/dist/cli/index.js +4110 -3534
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +4237 -3661
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +18 -16
- package/dist/index.d.ts +18 -16
- package/dist/index.js +310 -182
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +308 -180
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/purpose.md +173 -33
- package/tests/auto-consolidation.test.ts +109 -0
- package/tests/combined-generator.test.ts +81 -0
- package/tests/constitution-consolidator.test.ts +161 -0
- package/tests/constitution-generator.test.ts +94 -0
- package/tests/contract-bridge.test.ts +201 -0
- package/tests/design-dialogue.test.ts +108 -0
- package/tests/dsl-coverage-checker.test.ts +230 -0
- package/tests/dsl-feedback.test.ts +45 -0
- package/tests/dsl-validator-xref.test.ts +99 -0
- package/tests/error-feedback-repair.test.ts +319 -0
- package/tests/error-feedback-validation.test.ts +91 -0
- package/tests/frontend-context-loader.test.ts +609 -0
- package/tests/global-constitution.test.ts +110 -0
- package/tests/key-store.test.ts +73 -0
- package/tests/knowledge-memory.test.ts +327 -0
- package/tests/project-index.test.ts +206 -0
- package/tests/prompt-hasher.test.ts +19 -0
- package/tests/requirement-decomposer.test.ts +171 -0
- package/tests/reviewer.test.ts +4 -1
- package/tests/run-logger.test.ts +289 -0
- package/tests/run-snapshot.test.ts +113 -0
- package/tests/safe-json.test.ts +63 -0
- package/tests/spec-updater.test.ts +161 -0
- package/tests/test-generator.test.ts +146 -0
- package/tests/token-budget.test.ts +124 -0
- package/tests/vcr-hash.test.ts +101 -0
- package/tests/workspace-loader.test.ts +277 -0
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import * as path from "path";
|
|
2
2
|
import * as fs from "fs-extra";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { SpecDSL } from "./dsl-types";
|
|
4
|
+
import { buildEndpointFixture } from "./mock/fixtures";
|
|
5
|
+
import { generateProxyConfig } from "./mock/proxy";
|
|
6
|
+
|
|
7
|
+
// Re-export public symbols for backward compatibility
|
|
8
|
+
export {
|
|
9
|
+
applyMockProxy,
|
|
10
|
+
restoreMockProxy,
|
|
11
|
+
startMockServerBackground,
|
|
12
|
+
saveMockServerPid,
|
|
13
|
+
ProxyApplyResult,
|
|
14
|
+
} from "./mock/proxy";
|
|
5
15
|
|
|
6
16
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
7
17
|
|
|
@@ -20,94 +30,6 @@ export interface MockGenerationResult {
|
|
|
20
30
|
files: Array<{ path: string; description: string }>;
|
|
21
31
|
}
|
|
22
32
|
|
|
23
|
-
// ─── Fixture Generator ────────────────────────────────────────────────────────
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Convert a type-description string to a fixture value (JavaScript literal).
|
|
27
|
-
*/
|
|
28
|
-
function typeToFixture(fieldName: string, typeDesc: string): unknown {
|
|
29
|
-
const t = typeDesc.toLowerCase();
|
|
30
|
-
|
|
31
|
-
if (t.includes("boolean") || t === "bool") return true;
|
|
32
|
-
if (t.includes("int") || t.includes("number") || t.includes("float") || t.includes("decimal")) {
|
|
33
|
-
if (fieldName.toLowerCase().includes("id")) return 1;
|
|
34
|
-
if (fieldName.toLowerCase().includes("count") || fieldName.toLowerCase().includes("total")) return 42;
|
|
35
|
-
if (fieldName.toLowerCase().includes("price") || fieldName.toLowerCase().includes("amount")) return 9.99;
|
|
36
|
-
return 1;
|
|
37
|
-
}
|
|
38
|
-
if (t.includes("datetime") || t.includes("date") || t.includes("timestamp")) {
|
|
39
|
-
return "2024-01-15T10:30:00.000Z";
|
|
40
|
-
}
|
|
41
|
-
if (t.includes("[]") || t.includes("array") || t.includes("list")) return [];
|
|
42
|
-
if (t.includes("object") || t.includes("json") || t.includes("record")) return {};
|
|
43
|
-
|
|
44
|
-
// String heuristics by field name
|
|
45
|
-
const name = fieldName.toLowerCase();
|
|
46
|
-
if (name === "id" || name.endsWith("id")) return "abc123";
|
|
47
|
-
if (name.includes("email")) return "user@example.com";
|
|
48
|
-
if (name.includes("phone")) return "+1-555-0100";
|
|
49
|
-
if (name.includes("url") || name.includes("image") || name.includes("avatar")) return "https://example.com/sample.jpg";
|
|
50
|
-
if (name.includes("token") || name.includes("secret")) return "mock-token-xyz";
|
|
51
|
-
if (name.includes("name")) return "Example Name";
|
|
52
|
-
if (name.includes("title")) return "Example Title";
|
|
53
|
-
if (name.includes("description") || name.includes("content") || name.includes("body")) return "Example description text";
|
|
54
|
-
if (name.includes("status")) return "active";
|
|
55
|
-
if (name.includes("type") || name.includes("role")) return "default";
|
|
56
|
-
if (name.includes("code")) return "CODE001";
|
|
57
|
-
|
|
58
|
-
return `example_${fieldName}`;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function buildFixtureObject(fields: FieldMap): Record<string, unknown> {
|
|
62
|
-
const obj: Record<string, unknown> = {};
|
|
63
|
-
for (const [name, type] of Object.entries(fields)) {
|
|
64
|
-
obj[name] = typeToFixture(name, type);
|
|
65
|
-
}
|
|
66
|
-
return obj;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Build a fixture response object for an endpoint.
|
|
71
|
-
* For endpoints without explicit response schemas, generate minimal fixtures from model context.
|
|
72
|
-
*/
|
|
73
|
-
function buildEndpointFixture(endpoint: ApiEndpoint, dsl: SpecDSL): unknown {
|
|
74
|
-
const method = endpoint.method;
|
|
75
|
-
const status = endpoint.successStatus;
|
|
76
|
-
|
|
77
|
-
// DELETE with 204 → no body
|
|
78
|
-
if (status === 204) return null;
|
|
79
|
-
|
|
80
|
-
// Try to derive fixture from model names mentioned in endpoint description
|
|
81
|
-
const descLower = endpoint.description.toLowerCase();
|
|
82
|
-
const matchedModel = dsl.models.find((m) =>
|
|
83
|
-
descLower.includes(m.name.toLowerCase())
|
|
84
|
-
);
|
|
85
|
-
|
|
86
|
-
if (matchedModel) {
|
|
87
|
-
const fields: FieldMap = {};
|
|
88
|
-
for (const f of matchedModel.fields) {
|
|
89
|
-
fields[f.name] = f.type;
|
|
90
|
-
}
|
|
91
|
-
const item = buildFixtureObject(fields);
|
|
92
|
-
|
|
93
|
-
// List endpoints return arrays
|
|
94
|
-
if (method === "GET" && (descLower.includes("list") || descLower.includes("all") || descLower.includes("paginate"))) {
|
|
95
|
-
return {
|
|
96
|
-
data: [item, { ...item, id: "def456" }],
|
|
97
|
-
total: 2,
|
|
98
|
-
page: 1,
|
|
99
|
-
pageSize: 10,
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
return { data: item };
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// Fallback based on method
|
|
106
|
-
if (method === "POST") return { data: { id: "abc123", createdAt: "2024-01-15T10:30:00.000Z" } };
|
|
107
|
-
if (method === "GET") return { data: { id: "abc123" } };
|
|
108
|
-
return { success: true };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
33
|
// ─── Express Mock Server Generator ───────────────────────────────────────────
|
|
112
34
|
|
|
113
35
|
function generateMockServerJs(dsl: SpecDSL, port: number): string {
|
|
@@ -197,167 +119,6 @@ function generateMockServerJs(dsl: SpecDSL, port: number): string {
|
|
|
197
119
|
return lines.join("\n");
|
|
198
120
|
}
|
|
199
121
|
|
|
200
|
-
// ─── Proxy Config Generators ──────────────────────────────────────────────────
|
|
201
|
-
|
|
202
|
-
function detectFrontendFramework(projectDir: string): "vite" | "next" | "webpack" | "cra" | "unknown" {
|
|
203
|
-
// Check vite.config
|
|
204
|
-
for (const f of ["vite.config.ts", "vite.config.js", "vite.config.mts"]) {
|
|
205
|
-
if (fs.existsSync(path.join(projectDir, f))) return "vite";
|
|
206
|
-
}
|
|
207
|
-
// Check next.config
|
|
208
|
-
for (const f of ["next.config.js", "next.config.ts", "next.config.mjs"]) {
|
|
209
|
-
if (fs.existsSync(path.join(projectDir, f))) return "next";
|
|
210
|
-
}
|
|
211
|
-
// Check for CRA (react-scripts in package.json)
|
|
212
|
-
const pkgPath = path.join(projectDir, "package.json");
|
|
213
|
-
if (fs.existsSync(pkgPath)) {
|
|
214
|
-
try {
|
|
215
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
216
|
-
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
217
|
-
if (deps["react-scripts"]) return "cra";
|
|
218
|
-
} catch { /* ignore */ }
|
|
219
|
-
}
|
|
220
|
-
// Check webpack.config
|
|
221
|
-
for (const f of ["webpack.config.js", "webpack.config.ts"]) {
|
|
222
|
-
if (fs.existsSync(path.join(projectDir, f))) return "webpack";
|
|
223
|
-
}
|
|
224
|
-
return "unknown";
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function generateViteProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
|
|
228
|
-
// Collect unique path prefixes
|
|
229
|
-
const prefixes = new Set<string>();
|
|
230
|
-
for (const ep of endpoints) {
|
|
231
|
-
const parts = ep.path.split("/").filter(Boolean);
|
|
232
|
-
if (parts.length > 0) prefixes.add(`/${parts[0]}`);
|
|
233
|
-
}
|
|
234
|
-
const target = `http://localhost:${mockPort}`;
|
|
235
|
-
const proxyEntries = Array.from(prefixes)
|
|
236
|
-
.map((p) => ` '${p}': { target: '${target}', changeOrigin: true }`)
|
|
237
|
-
.join(",\n");
|
|
238
|
-
|
|
239
|
-
return `// Add this proxy block to your vite.config.ts / vite.config.js
|
|
240
|
-
// Inside the defineConfig({ server: { proxy: { ... } } }) section:
|
|
241
|
-
//
|
|
242
|
-
// server: {
|
|
243
|
-
// proxy: {
|
|
244
|
-
${proxyEntries
|
|
245
|
-
.split("\n")
|
|
246
|
-
.map((l) => `// ${l}`)
|
|
247
|
-
.join("\n")}
|
|
248
|
-
// }
|
|
249
|
-
// }
|
|
250
|
-
|
|
251
|
-
// ─── Standalone proxy snippet for vite.config.ts ─────────────────────────────
|
|
252
|
-
// import { defineConfig } from 'vite';
|
|
253
|
-
// export default defineConfig({
|
|
254
|
-
// server: {
|
|
255
|
-
// proxy: {
|
|
256
|
-
${proxyEntries
|
|
257
|
-
.split("\n")
|
|
258
|
-
.map((l) => `// ${l.trim()}`)
|
|
259
|
-
.join("\n")}
|
|
260
|
-
// }
|
|
261
|
-
// }
|
|
262
|
-
// });
|
|
263
|
-
`;
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function generateNextProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
|
|
267
|
-
const prefixes = new Set<string>();
|
|
268
|
-
for (const ep of endpoints) {
|
|
269
|
-
const parts = ep.path.split("/").filter(Boolean);
|
|
270
|
-
if (parts.length > 0) prefixes.add(`/${parts[0]}`);
|
|
271
|
-
}
|
|
272
|
-
const rewrites = Array.from(prefixes).map(
|
|
273
|
-
(p) => ` { source: '${p}/:path*', destination: 'http://localhost:${mockPort}${p}/:path*' }`
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
return `// Add this to your next.config.js rewrites():
|
|
277
|
-
//
|
|
278
|
-
// module.exports = {
|
|
279
|
-
// async rewrites() {
|
|
280
|
-
// return [
|
|
281
|
-
${rewrites.map((r) => `// ${r}`).join(",\n")}
|
|
282
|
-
// ];
|
|
283
|
-
// },
|
|
284
|
-
// };
|
|
285
|
-
`;
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
function generateWebpackProxyBlock(mockPort: number, endpoints: ApiEndpoint[]): string {
|
|
289
|
-
const prefixes = new Set<string>();
|
|
290
|
-
for (const ep of endpoints) {
|
|
291
|
-
const parts = ep.path.split("/").filter(Boolean);
|
|
292
|
-
if (parts.length > 0) prefixes.add(`/${parts[0]}`);
|
|
293
|
-
}
|
|
294
|
-
const proxyEntries = Array.from(prefixes)
|
|
295
|
-
.map(
|
|
296
|
-
(p) =>
|
|
297
|
-
` '${p}': {\n target: 'http://localhost:${mockPort}',\n changeOrigin: true\n }`
|
|
298
|
-
)
|
|
299
|
-
.join(",\n");
|
|
300
|
-
|
|
301
|
-
return `// Add this to your webpack.config.js devServer.proxy section:
|
|
302
|
-
//
|
|
303
|
-
// devServer: {
|
|
304
|
-
// proxy: {
|
|
305
|
-
${proxyEntries
|
|
306
|
-
.split("\n")
|
|
307
|
-
.map((l) => `// ${l}`)
|
|
308
|
-
.join("\n")}
|
|
309
|
-
// }
|
|
310
|
-
// }
|
|
311
|
-
`;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function generateCraProxyBlock(mockPort: number): string {
|
|
315
|
-
return `// For Create React App: add a "proxy" field to package.json
|
|
316
|
-
// This only proxies requests that don't match static files:
|
|
317
|
-
//
|
|
318
|
-
// {
|
|
319
|
-
// "proxy": "http://localhost:${mockPort}"
|
|
320
|
-
// }
|
|
321
|
-
//
|
|
322
|
-
// Or use src/setupProxy.js for per-path control:
|
|
323
|
-
// const { createProxyMiddleware } = require('http-proxy-middleware');
|
|
324
|
-
// module.exports = function(app) {
|
|
325
|
-
// app.use('/api', createProxyMiddleware({ target: 'http://localhost:${mockPort}', changeOrigin: true }));
|
|
326
|
-
// };
|
|
327
|
-
`;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function generateProxyConfig(
|
|
331
|
-
dsl: SpecDSL,
|
|
332
|
-
mockPort: number,
|
|
333
|
-
projectDir: string
|
|
334
|
-
): { content: string; filename: string } {
|
|
335
|
-
const framework = detectFrontendFramework(projectDir);
|
|
336
|
-
|
|
337
|
-
switch (framework) {
|
|
338
|
-
case "vite":
|
|
339
|
-
return {
|
|
340
|
-
filename: "mock/proxy.vite.comment.txt",
|
|
341
|
-
content: generateViteProxyBlock(mockPort, dsl.endpoints),
|
|
342
|
-
};
|
|
343
|
-
case "next":
|
|
344
|
-
return {
|
|
345
|
-
filename: "mock/proxy.next.comment.txt",
|
|
346
|
-
content: generateNextProxyBlock(mockPort, dsl.endpoints),
|
|
347
|
-
};
|
|
348
|
-
case "cra":
|
|
349
|
-
return {
|
|
350
|
-
filename: "mock/proxy.cra.comment.txt",
|
|
351
|
-
content: generateCraProxyBlock(mockPort),
|
|
352
|
-
};
|
|
353
|
-
default:
|
|
354
|
-
return {
|
|
355
|
-
filename: "mock/proxy.webpack.comment.txt",
|
|
356
|
-
content: generateWebpackProxyBlock(mockPort, dsl.endpoints),
|
|
357
|
-
};
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
122
|
// ─── MSW Handler Generator ────────────────────────────────────────────────────
|
|
362
123
|
|
|
363
124
|
function generateMswHandlers(dsl: SpecDSL): string {
|
|
@@ -435,215 +196,6 @@ export const worker = setupWorker(...handlers);
|
|
|
435
196
|
`;
|
|
436
197
|
}
|
|
437
198
|
|
|
438
|
-
// ─── Proxy Patching (applyMockProxy / restoreMockProxy) ──────────────────────
|
|
439
|
-
|
|
440
|
-
const MOCK_LOCK_FILE = ".ai-spec-mock.lock.json";
|
|
441
|
-
|
|
442
|
-
interface MockLock {
|
|
443
|
-
framework: string;
|
|
444
|
-
mockPort: number;
|
|
445
|
-
frontendDir: string;
|
|
446
|
-
mockServerPid?: number;
|
|
447
|
-
actions: Array<
|
|
448
|
-
| { type: "wrote-file"; filePath: string }
|
|
449
|
-
| { type: "patched-pkg-proxy"; originalProxy?: string | null }
|
|
450
|
-
| { type: "added-pkg-script"; key: string; originalValue?: string | null }
|
|
451
|
-
>;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
export interface ProxyApplyResult {
|
|
455
|
-
framework: string;
|
|
456
|
-
applied: boolean;
|
|
457
|
-
devCommand: string | null;
|
|
458
|
-
note?: string;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
function findViteConfigFile(projectDir: string): string | null {
|
|
462
|
-
for (const f of ["vite.config.ts", "vite.config.mts", "vite.config.js", "vite.config.mjs"]) {
|
|
463
|
-
if (fs.existsSync(path.join(projectDir, f))) return f;
|
|
464
|
-
}
|
|
465
|
-
return null;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function buildViteProxyEntries(endpoints: ApiEndpoint[], mockPort: number): string {
|
|
469
|
-
const prefixes = new Set<string>();
|
|
470
|
-
for (const ep of endpoints) {
|
|
471
|
-
const parts = ep.path.split("/").filter(Boolean);
|
|
472
|
-
if (parts.length > 0) prefixes.add(`/${parts[0]}`);
|
|
473
|
-
}
|
|
474
|
-
if (prefixes.size === 0) prefixes.add("/api");
|
|
475
|
-
const target = `http://localhost:${mockPort}`;
|
|
476
|
-
return Array.from(prefixes)
|
|
477
|
-
.map((p) => ` '${p}': { target: '${target}', changeOrigin: true },`)
|
|
478
|
-
.join("\n");
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function generateViteMockConfigTs(baseConfigFile: string, mockPort: number, endpoints: ApiEndpoint[]): string {
|
|
482
|
-
const importPath = `./${baseConfigFile.replace(/\.(ts|mts|js|mjs)$/, "")}`;
|
|
483
|
-
const proxyEntries = buildViteProxyEntries(endpoints, mockPort);
|
|
484
|
-
return `// Auto-generated by ai-spec mock --serve
|
|
485
|
-
// LOCAL DEVELOPMENT ONLY — do not commit this file
|
|
486
|
-
// Remove with: ai-spec mock --restore
|
|
487
|
-
import { defineConfig, mergeConfig } from 'vite';
|
|
488
|
-
|
|
489
|
-
export default defineConfig(async (env) => {
|
|
490
|
-
const mod = await import('${importPath}');
|
|
491
|
-
const baseConfigOrFn = mod.default;
|
|
492
|
-
const baseConfig =
|
|
493
|
-
typeof baseConfigOrFn === 'function'
|
|
494
|
-
? await baseConfigOrFn(env)
|
|
495
|
-
: baseConfigOrFn;
|
|
496
|
-
|
|
497
|
-
return mergeConfig(baseConfig ?? {}, {
|
|
498
|
-
server: {
|
|
499
|
-
proxy: {
|
|
500
|
-
${proxyEntries}
|
|
501
|
-
},
|
|
502
|
-
},
|
|
503
|
-
});
|
|
504
|
-
});
|
|
505
|
-
`;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
/**
|
|
509
|
-
* Patch the frontend project's proxy config to point to the mock server.
|
|
510
|
-
* Vite: writes vite.config.ai-spec-mock.ts + adds "dev:mock" npm script.
|
|
511
|
-
* CRA : patches package.json "proxy" field (original backed up in lock file).
|
|
512
|
-
* Saves .ai-spec-mock.lock.json so restoreMockProxy() can undo all changes.
|
|
513
|
-
*/
|
|
514
|
-
export async function applyMockProxy(
|
|
515
|
-
frontendDir: string,
|
|
516
|
-
mockPort: number,
|
|
517
|
-
endpoints: ApiEndpoint[] = []
|
|
518
|
-
): Promise<ProxyApplyResult> {
|
|
519
|
-
const framework = detectFrontendFramework(frontendDir);
|
|
520
|
-
const actions: MockLock["actions"] = [];
|
|
521
|
-
|
|
522
|
-
if (framework === "vite") {
|
|
523
|
-
const viteConfigFile = findViteConfigFile(frontendDir) ?? "vite.config.ts";
|
|
524
|
-
const mockConfigContent = generateViteMockConfigTs(viteConfigFile, mockPort, endpoints);
|
|
525
|
-
const mockConfigPath = path.join(frontendDir, "vite.config.ai-spec-mock.ts");
|
|
526
|
-
await fs.writeFile(mockConfigPath, mockConfigContent, "utf-8");
|
|
527
|
-
actions.push({ type: "wrote-file", filePath: "vite.config.ai-spec-mock.ts" });
|
|
528
|
-
|
|
529
|
-
const pkgPath = path.join(frontendDir, "package.json");
|
|
530
|
-
if (await fs.pathExists(pkgPath)) {
|
|
531
|
-
const pkg = await fs.readJson(pkgPath);
|
|
532
|
-
pkg.scripts = pkg.scripts ?? {};
|
|
533
|
-
const originalValue: string | null = pkg.scripts["dev:mock"] ?? null;
|
|
534
|
-
pkg.scripts["dev:mock"] = "vite --config vite.config.ai-spec-mock.ts";
|
|
535
|
-
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
536
|
-
actions.push({ type: "added-pkg-script", key: "dev:mock", originalValue });
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
const lock: MockLock = { framework, mockPort, frontendDir, actions };
|
|
540
|
-
await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
|
|
541
|
-
return { framework, applied: true, devCommand: "npm run dev:mock" };
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
if (framework === "cra") {
|
|
545
|
-
const pkgPath = path.join(frontendDir, "package.json");
|
|
546
|
-
if (await fs.pathExists(pkgPath)) {
|
|
547
|
-
const pkg = await fs.readJson(pkgPath);
|
|
548
|
-
const originalProxy: string | null = pkg.proxy ?? null;
|
|
549
|
-
pkg.proxy = `http://localhost:${mockPort}`;
|
|
550
|
-
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
551
|
-
actions.push({ type: "patched-pkg-proxy", originalProxy });
|
|
552
|
-
const lock: MockLock = { framework, mockPort, frontendDir, actions };
|
|
553
|
-
await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
|
|
554
|
-
return { framework, applied: true, devCommand: "npm start" };
|
|
555
|
-
}
|
|
556
|
-
return { framework, applied: false, devCommand: null, note: "No package.json found." };
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// next / webpack / unknown — save lock but no auto-patch
|
|
560
|
-
const lock: MockLock = { framework, mockPort, frontendDir, actions };
|
|
561
|
-
await fs.writeJson(path.join(frontendDir, MOCK_LOCK_FILE), lock, { spaces: 2 });
|
|
562
|
-
const manualNote =
|
|
563
|
-
framework === "next"
|
|
564
|
-
? `Add rewrites in next.config.js to proxy API calls to http://localhost:${mockPort}`
|
|
565
|
-
: `Add proxy in webpack.config.js devServer to target http://localhost:${mockPort}`;
|
|
566
|
-
return { framework, applied: false, devCommand: null, note: manualNote };
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Undo all proxy changes made by applyMockProxy().
|
|
571
|
-
* Also kills the mock server if its PID was stored in the lock file.
|
|
572
|
-
*/
|
|
573
|
-
export async function restoreMockProxy(
|
|
574
|
-
frontendDir: string
|
|
575
|
-
): Promise<{ restored: boolean; note?: string }> {
|
|
576
|
-
const lockPath = path.join(frontendDir, MOCK_LOCK_FILE);
|
|
577
|
-
if (!(await fs.pathExists(lockPath))) {
|
|
578
|
-
return { restored: false, note: "No lock file found — nothing to restore." };
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
const lock: MockLock = await fs.readJson(lockPath);
|
|
582
|
-
|
|
583
|
-
for (const action of lock.actions) {
|
|
584
|
-
if (action.type === "wrote-file") {
|
|
585
|
-
const fp = path.join(frontendDir, action.filePath);
|
|
586
|
-
if (await fs.pathExists(fp)) await fs.remove(fp);
|
|
587
|
-
} else if (action.type === "added-pkg-script") {
|
|
588
|
-
const pkgPath = path.join(frontendDir, "package.json");
|
|
589
|
-
if (await fs.pathExists(pkgPath)) {
|
|
590
|
-
const pkg = await fs.readJson(pkgPath);
|
|
591
|
-
if (action.originalValue == null) {
|
|
592
|
-
delete pkg.scripts?.[action.key];
|
|
593
|
-
} else {
|
|
594
|
-
pkg.scripts = pkg.scripts ?? {};
|
|
595
|
-
pkg.scripts[action.key] = action.originalValue;
|
|
596
|
-
}
|
|
597
|
-
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
598
|
-
}
|
|
599
|
-
} else if (action.type === "patched-pkg-proxy") {
|
|
600
|
-
const pkgPath = path.join(frontendDir, "package.json");
|
|
601
|
-
if (await fs.pathExists(pkgPath)) {
|
|
602
|
-
const pkg = await fs.readJson(pkgPath);
|
|
603
|
-
if (action.originalProxy == null) {
|
|
604
|
-
delete pkg.proxy;
|
|
605
|
-
} else {
|
|
606
|
-
pkg.proxy = action.originalProxy;
|
|
607
|
-
}
|
|
608
|
-
await fs.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
if (lock.mockServerPid) {
|
|
614
|
-
try { process.kill(lock.mockServerPid, "SIGTERM"); } catch { /* already dead */ }
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
await fs.remove(lockPath);
|
|
618
|
-
return { restored: true };
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* Start mock/server.js as a detached background process.
|
|
623
|
-
* Returns the spawned PID so it can be stored for later cleanup.
|
|
624
|
-
*/
|
|
625
|
-
export function startMockServerBackground(serverJsPath: string, port: number): number {
|
|
626
|
-
const child = spawn("node", [serverJsPath], {
|
|
627
|
-
detached: true,
|
|
628
|
-
stdio: "ignore",
|
|
629
|
-
env: { ...process.env, MOCK_PORT: String(port) },
|
|
630
|
-
});
|
|
631
|
-
child.unref();
|
|
632
|
-
return child.pid!;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* Save the mock server PID into an existing lock file.
|
|
637
|
-
*/
|
|
638
|
-
export async function saveMockServerPid(frontendDir: string, pid: number): Promise<void> {
|
|
639
|
-
const lockPath = path.join(frontendDir, MOCK_LOCK_FILE);
|
|
640
|
-
if (await fs.pathExists(lockPath)) {
|
|
641
|
-
const lock: MockLock = await fs.readJson(lockPath);
|
|
642
|
-
lock.mockServerPid = pid;
|
|
643
|
-
await fs.writeJson(lockPath, lock, { spaces: 2 });
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
199
|
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
648
200
|
|
|
649
201
|
/**
|
|
@@ -3,6 +3,7 @@ import { WorkspaceConfig, RepoRole } from "./workspace-loader";
|
|
|
3
3
|
import { ProjectContext } from "./context-loader";
|
|
4
4
|
import { FrontendContext } from "./frontend-context-loader";
|
|
5
5
|
import { decomposeSystemPrompt, buildDecomposePrompt } from "../prompts/decompose.prompt";
|
|
6
|
+
import { parseJsonFromAiOutput } from "./safe-json";
|
|
6
7
|
|
|
7
8
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
8
9
|
|
|
@@ -45,35 +46,10 @@ export interface DecompositionResult {
|
|
|
45
46
|
coordinationNotes: string;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
// ─── JSON Parser
|
|
49
|
+
// ─── JSON Parser ─────────────────────────────────────────────────────────────
|
|
49
50
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (trimmed.startsWith("{")) {
|
|
54
|
-
return JSON.parse(trimmed);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const fenceStart = trimmed.indexOf("```");
|
|
58
|
-
if (fenceStart !== -1) {
|
|
59
|
-
const afterFence = trimmed.slice(fenceStart + 3);
|
|
60
|
-
const newlinePos = afterFence.indexOf("\n");
|
|
61
|
-
const jsonStart = newlinePos !== -1 ? newlinePos + 1 : 0;
|
|
62
|
-
const fenceEnd = afterFence.lastIndexOf("```");
|
|
63
|
-
if (fenceEnd > jsonStart) {
|
|
64
|
-
const jsonStr = afterFence.slice(jsonStart, fenceEnd).trim();
|
|
65
|
-
return JSON.parse(jsonStr);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const objStart = trimmed.indexOf("{");
|
|
70
|
-
const objEnd = trimmed.lastIndexOf("}");
|
|
71
|
-
if (objStart !== -1 && objEnd > objStart) {
|
|
72
|
-
return JSON.parse(trimmed.slice(objStart, objEnd + 1));
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
throw new SyntaxError("No JSON object found in AI output");
|
|
76
|
-
}
|
|
51
|
+
// Uses shared parseJsonFromAiOutput from safe-json.ts
|
|
52
|
+
const parseJsonFromOutput = parseJsonFromAiOutput;
|
|
77
53
|
|
|
78
54
|
// ─── Validator ────────────────────────────────────────────────────────────────
|
|
79
55
|
|
package/core/reviewer.ts
CHANGED
|
@@ -140,7 +140,7 @@ export class CodeReviewer {
|
|
|
140
140
|
|
|
141
141
|
private getGitDiff(): string {
|
|
142
142
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
143
|
-
const silent: any = { encoding: "utf-8", stdio: "pipe" };
|
|
143
|
+
const silent: any = { encoding: "utf-8", stdio: "pipe", cwd: this.projectRoot, timeout: 30_000 };
|
|
144
144
|
try {
|
|
145
145
|
execSync("git rev-parse --is-inside-work-tree", silent);
|
|
146
146
|
} catch {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* safe-json.ts — Shared JSON parsing utilities for AI output.
|
|
3
|
+
*
|
|
4
|
+
* Consolidates the duplicated parseJsonFromOutput logic from
|
|
5
|
+
* dsl-extractor.ts, requirement-decomposer.ts, and spec-updater.ts.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Parse JSON from raw AI output, returning `null` on failure.
|
|
10
|
+
*
|
|
11
|
+
* Handles:
|
|
12
|
+
* 1. Bare JSON starting with `{` or `[`
|
|
13
|
+
* 2. JSON inside ```json ... ``` fences
|
|
14
|
+
* 3. First `{ ... }` or `[ ... ]` pair found in text
|
|
15
|
+
*/
|
|
16
|
+
export function safeParseJson<T = unknown>(raw: string): T | null {
|
|
17
|
+
const trimmed = raw.trim();
|
|
18
|
+
|
|
19
|
+
// Case 1: bare JSON object or array
|
|
20
|
+
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(trimmed) as T;
|
|
23
|
+
} catch {
|
|
24
|
+
// fall through to other strategies
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Case 2: fenced JSON — extract between first ``` and last ```
|
|
29
|
+
const fenceStart = trimmed.indexOf("```");
|
|
30
|
+
if (fenceStart !== -1) {
|
|
31
|
+
const afterFence = trimmed.slice(fenceStart + 3);
|
|
32
|
+
const newlinePos = afterFence.indexOf("\n");
|
|
33
|
+
const jsonStart = newlinePos !== -1 ? newlinePos + 1 : 0;
|
|
34
|
+
const fenceEnd = afterFence.lastIndexOf("```");
|
|
35
|
+
if (fenceEnd > jsonStart) {
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(afterFence.slice(jsonStart, fenceEnd).trim()) as T;
|
|
38
|
+
} catch {
|
|
39
|
+
// fall through
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Case 3: find first `{...}` or `[...]` pair
|
|
45
|
+
const objStart = trimmed.indexOf("{");
|
|
46
|
+
const arrStart = trimmed.indexOf("[");
|
|
47
|
+
const start =
|
|
48
|
+
objStart !== -1 && (arrStart === -1 || objStart < arrStart)
|
|
49
|
+
? objStart
|
|
50
|
+
: arrStart;
|
|
51
|
+
if (start !== -1) {
|
|
52
|
+
const isObj = start === objStart;
|
|
53
|
+
const end = isObj ? trimmed.lastIndexOf("}") : trimmed.lastIndexOf("]");
|
|
54
|
+
if (end > start) {
|
|
55
|
+
try {
|
|
56
|
+
return JSON.parse(trimmed.slice(start, end + 1)) as T;
|
|
57
|
+
} catch {
|
|
58
|
+
// fall through
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Parse JSON from AI output, throwing on failure.
|
|
68
|
+
* Drop-in replacement for the previously duplicated parseJsonFromOutput.
|
|
69
|
+
*/
|
|
70
|
+
export function parseJsonFromAiOutput<T = unknown>(raw: string): T {
|
|
71
|
+
const result = safeParseJson<T>(raw);
|
|
72
|
+
if (result === null) {
|
|
73
|
+
throw new SyntaxError("No valid JSON found in AI output");
|
|
74
|
+
}
|
|
75
|
+
return result;
|
|
76
|
+
}
|
package/core/spec-updater.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
buildAffectedFilesPrompt,
|
|
16
16
|
} from "../prompts/update.prompt";
|
|
17
17
|
import { getCodeGenSystemPrompt } from "../prompts/codegen.prompt";
|
|
18
|
+
import { parseJsonFromAiOutput } from "./safe-json";
|
|
18
19
|
|
|
19
20
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
20
21
|
|
|
@@ -44,27 +45,10 @@ export interface SpecUpdaterOptions {
|
|
|
44
45
|
repoType?: string;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
|
-
// ─── JSON
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
if (trimmed.startsWith("{")) return JSON.parse(trimmed);
|
|
52
|
-
const fenceStart = trimmed.indexOf("```");
|
|
53
|
-
if (fenceStart !== -1) {
|
|
54
|
-
const afterFence = trimmed.slice(fenceStart + 3);
|
|
55
|
-
const newlinePos = afterFence.indexOf("\n");
|
|
56
|
-
const jsonStart = newlinePos !== -1 ? newlinePos + 1 : 0;
|
|
57
|
-
const fenceEnd = afterFence.lastIndexOf("```");
|
|
58
|
-
if (fenceEnd > jsonStart) return JSON.parse(afterFence.slice(jsonStart, fenceEnd).trim());
|
|
59
|
-
}
|
|
60
|
-
const objStart = trimmed.indexOf("{");
|
|
61
|
-
const arrStart = trimmed.indexOf("[");
|
|
62
|
-
const start = objStart !== -1 && (arrStart === -1 || objStart < arrStart) ? objStart : arrStart;
|
|
63
|
-
const isObj = start === objStart && objStart !== -1;
|
|
64
|
-
const end = isObj ? trimmed.lastIndexOf("}") : trimmed.lastIndexOf("]");
|
|
65
|
-
if (start !== -1 && end > start) return JSON.parse(trimmed.slice(start, end + 1));
|
|
66
|
-
throw new SyntaxError("No JSON found in output");
|
|
67
|
-
}
|
|
48
|
+
// ─── JSON Parser ─────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
// Uses shared parseJsonFromAiOutput from safe-json.ts
|
|
51
|
+
const parseJsonFromOutput = parseJsonFromAiOutput;
|
|
68
52
|
|
|
69
53
|
function parseAffectedFiles(raw: string): AffectedFile[] {
|
|
70
54
|
try {
|