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.
Files changed (67) hide show
  1. package/README.md +381 -1796
  2. package/RELEASE_LOG.md +231 -0
  3. package/cli/commands/create.ts +9 -1176
  4. package/cli/commands/dashboard.ts +1 -1
  5. package/cli/pipeline/helpers.ts +34 -0
  6. package/cli/pipeline/multi-repo.ts +483 -0
  7. package/cli/pipeline/single-repo.ts +755 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/code-generator.ts +52 -341
  10. package/core/codegen/helpers.ts +219 -0
  11. package/core/codegen/topo-sort.ts +98 -0
  12. package/core/constitution-consolidator.ts +2 -2
  13. package/core/dsl-coverage-checker.ts +298 -0
  14. package/core/dsl-extractor.ts +19 -46
  15. package/core/dsl-feedback.ts +1 -1
  16. package/core/dsl-validator.ts +74 -0
  17. package/core/error-feedback.ts +95 -11
  18. package/core/frontend-context-loader.ts +27 -5
  19. package/core/knowledge-memory.ts +52 -0
  20. package/core/mock/fixtures.ts +89 -0
  21. package/core/mock/proxy.ts +380 -0
  22. package/core/mock-server-generator.ts +12 -460
  23. package/core/requirement-decomposer.ts +4 -28
  24. package/core/reviewer.ts +1 -1
  25. package/core/safe-json.ts +76 -0
  26. package/core/spec-updater.ts +5 -21
  27. package/core/token-budget.ts +124 -0
  28. package/core/vcr.ts +20 -1
  29. package/dist/cli/index.js +4110 -3534
  30. package/dist/cli/index.js.map +1 -1
  31. package/dist/cli/index.mjs +4237 -3661
  32. package/dist/cli/index.mjs.map +1 -1
  33. package/dist/index.d.mts +18 -16
  34. package/dist/index.d.ts +18 -16
  35. package/dist/index.js +310 -182
  36. package/dist/index.js.map +1 -1
  37. package/dist/index.mjs +308 -180
  38. package/dist/index.mjs.map +1 -1
  39. package/package.json +2 -2
  40. package/purpose.md +173 -33
  41. package/tests/auto-consolidation.test.ts +109 -0
  42. package/tests/combined-generator.test.ts +81 -0
  43. package/tests/constitution-consolidator.test.ts +161 -0
  44. package/tests/constitution-generator.test.ts +94 -0
  45. package/tests/contract-bridge.test.ts +201 -0
  46. package/tests/design-dialogue.test.ts +108 -0
  47. package/tests/dsl-coverage-checker.test.ts +230 -0
  48. package/tests/dsl-feedback.test.ts +45 -0
  49. package/tests/dsl-validator-xref.test.ts +99 -0
  50. package/tests/error-feedback-repair.test.ts +319 -0
  51. package/tests/error-feedback-validation.test.ts +91 -0
  52. package/tests/frontend-context-loader.test.ts +609 -0
  53. package/tests/global-constitution.test.ts +110 -0
  54. package/tests/key-store.test.ts +73 -0
  55. package/tests/knowledge-memory.test.ts +327 -0
  56. package/tests/project-index.test.ts +206 -0
  57. package/tests/prompt-hasher.test.ts +19 -0
  58. package/tests/requirement-decomposer.test.ts +171 -0
  59. package/tests/reviewer.test.ts +4 -1
  60. package/tests/run-logger.test.ts +289 -0
  61. package/tests/run-snapshot.test.ts +113 -0
  62. package/tests/safe-json.test.ts +63 -0
  63. package/tests/spec-updater.test.ts +161 -0
  64. package/tests/test-generator.test.ts +146 -0
  65. package/tests/token-budget.test.ts +124 -0
  66. package/tests/vcr-hash.test.ts +101 -0
  67. 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 { spawn } from "child_process";
4
- import { SpecDSL, ApiEndpoint, FieldMap } from "./dsl-types";
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 (same approach as dsl-extractor.ts) ─────────────────────────
49
+ // ─── JSON Parser ─────────────────────────────────────────────────────────────
49
50
 
50
- function parseJsonFromOutput(raw: string): unknown {
51
- const trimmed = raw.trim();
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
+ }
@@ -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 parser (same pattern as requirement-decomposer.ts) ─────────────────
48
-
49
- function parseJsonFromOutput(raw: string): unknown {
50
- const trimmed = raw.trim();
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 {