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
@@ -0,0 +1,161 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import * as fs from "fs-extra";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ import {
6
+ ConstitutionConsolidator,
7
+ checkConsolidationNeeded,
8
+ } from "../core/constitution-consolidator";
9
+ import { CONSTITUTION_FILE } from "../core/constitution-generator";
10
+
11
+ describe("checkConsolidationNeeded", () => {
12
+ it("prints warning when lessonCount >= threshold", () => {
13
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
14
+ checkConsolidationNeeded("/tmp", 10);
15
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("ai-spec init --consolidate"));
16
+ spy.mockRestore();
17
+ });
18
+
19
+ it("does not print when lessonCount < threshold", () => {
20
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
21
+ checkConsolidationNeeded("/tmp", 3);
22
+ expect(spy).not.toHaveBeenCalled();
23
+ spy.mockRestore();
24
+ });
25
+
26
+ it("uses custom threshold", () => {
27
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
28
+ checkConsolidationNeeded("/tmp", 5, 5);
29
+ expect(spy).toHaveBeenCalled();
30
+ spy.mockRestore();
31
+ });
32
+ });
33
+
34
+ describe("ConstitutionConsolidator", () => {
35
+ let tmpDir: string;
36
+ const mockProvider = {
37
+ generate: vi.fn(),
38
+ providerName: "test",
39
+ modelName: "test-model",
40
+ };
41
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
42
+
43
+ beforeEach(async () => {
44
+ tmpDir = path.join(os.tmpdir(), `cc-test-${Date.now()}`);
45
+ await fs.ensureDir(tmpDir);
46
+ mockProvider.generate.mockReset();
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await fs.remove(tmpDir);
51
+ });
52
+
53
+ it("throws when constitution file does not exist", async () => {
54
+ const consolidator = new ConstitutionConsolidator(mockProvider);
55
+ await expect(consolidator.consolidate(tmpDir)).rejects.toThrow("No constitution file");
56
+ });
57
+
58
+ it("skips when lesson count is below threshold", async () => {
59
+ await fs.writeFile(
60
+ path.join(tmpDir, CONSTITUTION_FILE),
61
+ "# Constitution\n## 1. Architecture\nSome rules.\n## 9. 积累教训 (Accumulated Lessons)\n- lesson 1\n- lesson 2\n"
62
+ );
63
+ const consolidator = new ConstitutionConsolidator(mockProvider);
64
+ const result = await consolidator.consolidate(tmpDir, { minLessons: 5 });
65
+ expect(result.written).toBe(false);
66
+ expect(mockProvider.generate).not.toHaveBeenCalled();
67
+ });
68
+
69
+ it("consolidates when lesson count meets threshold", async () => {
70
+ const lessons = Array.from({ length: 6 }, (_, i) =>
71
+ `- 📝 **[2026-03-0${i + 1}]** Lesson number ${i + 1} with enough detail`
72
+ ).join("\n");
73
+ const constitution = `# Constitution\n## 1. Architecture\nRules.\n\n## 9. 积累教训 (Accumulated Lessons)\n${lessons}\n`;
74
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION_FILE), constitution);
75
+
76
+ mockProvider.generate.mockResolvedValueOnce(
77
+ "# Constitution\n## 1. Architecture\nRules + consolidated lessons.\n\n## 9. 积累教训 (Accumulated Lessons)\n- 📝 **[2026-04-02]** Consolidated lesson\n"
78
+ );
79
+
80
+ const consolidator = new ConstitutionConsolidator(mockProvider);
81
+ const result = await consolidator.consolidate(tmpDir, { minLessons: 5 });
82
+
83
+ expect(result.written).toBe(true);
84
+ expect(result.backupPath).not.toBeNull();
85
+ expect(await fs.pathExists(result.backupPath!)).toBe(true);
86
+ expect(result.before.lessonCount).toBe(6);
87
+ });
88
+
89
+ it("dry-run does not write changes", async () => {
90
+ const lessons = Array.from({ length: 6 }, (_, i) =>
91
+ `- 📝 **[2026-03-0${i + 1}]** Lesson ${i + 1} with detail text here`
92
+ ).join("\n");
93
+ await fs.writeFile(
94
+ path.join(tmpDir, CONSTITUTION_FILE),
95
+ `# Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n${lessons}\n`
96
+ );
97
+
98
+ mockProvider.generate.mockResolvedValueOnce("# Consolidated");
99
+
100
+ const consolidator = new ConstitutionConsolidator(mockProvider);
101
+ const result = await consolidator.consolidate(tmpDir, { dryRun: true, minLessons: 5 });
102
+
103
+ expect(result.written).toBe(false);
104
+ expect(result.backupPath).toBeNull();
105
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION_FILE), "utf-8");
106
+ expect(content).toContain("积累教训"); // original unchanged
107
+ });
108
+
109
+ it("creates backup before writing", async () => {
110
+ const lessons = Array.from({ length: 6 }, (_, i) =>
111
+ `- 📝 **[2026-03-0${i + 1}]** Lesson ${i + 1} with details here`
112
+ ).join("\n");
113
+ const original = `# Constitution\n\n## 9. 积累教训\n${lessons}\n`;
114
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION_FILE), original);
115
+
116
+ mockProvider.generate.mockResolvedValueOnce("# New constitution");
117
+
118
+ const consolidator = new ConstitutionConsolidator(mockProvider);
119
+ const result = await consolidator.consolidate(tmpDir, { minLessons: 5 });
120
+
121
+ expect(result.backupPath).not.toBeNull();
122
+ const backup = await fs.readFile(result.backupPath!, "utf-8");
123
+ expect(backup).toBe(original);
124
+ });
125
+
126
+ it("strips markdown fences from AI output", async () => {
127
+ const lessons = Array.from({ length: 6 }, (_, i) =>
128
+ `- 📝 **[2026-03-0${i + 1}]** Lesson ${i + 1} for testing fences`
129
+ ).join("\n");
130
+ await fs.writeFile(
131
+ path.join(tmpDir, CONSTITUTION_FILE),
132
+ `# Constitution\n\n## 9. 积累教训\n${lessons}\n`
133
+ );
134
+
135
+ mockProvider.generate.mockResolvedValueOnce("```markdown\n# Clean Constitution\n```");
136
+
137
+ const consolidator = new ConstitutionConsolidator(mockProvider);
138
+ const result = await consolidator.consolidate(tmpDir, { minLessons: 5 });
139
+
140
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION_FILE), "utf-8");
141
+ expect(content).not.toContain("```");
142
+ expect(content).toContain("Clean Constitution");
143
+ });
144
+
145
+ it("throws when AI call fails", async () => {
146
+ const lessons = Array.from({ length: 6 }, (_, i) =>
147
+ `- 📝 **[2026-03-0${i + 1}]** Lesson ${i + 1} text for error test`
148
+ ).join("\n");
149
+ await fs.writeFile(
150
+ path.join(tmpDir, CONSTITUTION_FILE),
151
+ `# Constitution\n\n## 9. 积累教训\n${lessons}\n`
152
+ );
153
+
154
+ mockProvider.generate.mockRejectedValueOnce(new Error("API error"));
155
+
156
+ const consolidator = new ConstitutionConsolidator(mockProvider);
157
+ await expect(
158
+ consolidator.consolidate(tmpDir, { minLessons: 5 })
159
+ ).rejects.toThrow("AI consolidation failed");
160
+ });
161
+ });
@@ -0,0 +1,94 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import * as fs from "fs-extra";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ import {
6
+ ConstitutionGenerator,
7
+ CONSTITUTION_FILE,
8
+ loadConstitution,
9
+ printConstitutionHint,
10
+ } from "../core/constitution-generator";
11
+
12
+ describe("CONSTITUTION_FILE", () => {
13
+ it("is .ai-spec-constitution.md", () => {
14
+ expect(CONSTITUTION_FILE).toBe(".ai-spec-constitution.md");
15
+ });
16
+ });
17
+
18
+ describe("ConstitutionGenerator", () => {
19
+ let tmpDir: string;
20
+ const mockProvider = {
21
+ generate: vi.fn(),
22
+ providerName: "test",
23
+ modelName: "test-model",
24
+ };
25
+
26
+ beforeEach(async () => {
27
+ tmpDir = path.join(os.tmpdir(), `cg-test-${Date.now()}`);
28
+ await fs.ensureDir(tmpDir);
29
+ mockProvider.generate.mockReset();
30
+ });
31
+
32
+ afterEach(async () => {
33
+ await fs.remove(tmpDir);
34
+ });
35
+
36
+ it("generate() calls provider with context from project", async () => {
37
+ await fs.writeJson(path.join(tmpDir, "package.json"), {
38
+ dependencies: { express: "4.0.0" },
39
+ });
40
+ mockProvider.generate.mockResolvedValueOnce("# Constitution\n## 1. Architecture");
41
+
42
+ const gen = new ConstitutionGenerator(mockProvider);
43
+ const result = await gen.generate(tmpDir);
44
+ expect(result).toContain("Constitution");
45
+ expect(mockProvider.generate).toHaveBeenCalledOnce();
46
+ });
47
+
48
+ it("saveConstitution() writes file to project root", async () => {
49
+ const gen = new ConstitutionGenerator(mockProvider);
50
+ const filePath = await gen.saveConstitution(tmpDir, "# My Constitution");
51
+ expect(filePath).toBe(path.join(tmpDir, CONSTITUTION_FILE));
52
+ expect(await fs.readFile(filePath, "utf-8")).toBe("# My Constitution");
53
+ });
54
+ });
55
+
56
+ describe("loadConstitution", () => {
57
+ let tmpDir: string;
58
+
59
+ beforeEach(async () => {
60
+ tmpDir = path.join(os.tmpdir(), `cg-load-${Date.now()}`);
61
+ await fs.ensureDir(tmpDir);
62
+ });
63
+
64
+ afterEach(async () => {
65
+ await fs.remove(tmpDir);
66
+ });
67
+
68
+ it("returns content when file exists", async () => {
69
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION_FILE), "rules here");
70
+ const content = await loadConstitution(tmpDir);
71
+ expect(content).toBe("rules here");
72
+ });
73
+
74
+ it("returns undefined when file does not exist", async () => {
75
+ const content = await loadConstitution(tmpDir);
76
+ expect(content).toBeUndefined();
77
+ });
78
+ });
79
+
80
+ describe("printConstitutionHint", () => {
81
+ it("prints hint when exists is false", () => {
82
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
83
+ printConstitutionHint(false);
84
+ expect(spy).toHaveBeenCalledWith(expect.stringContaining("ai-spec init"));
85
+ spy.mockRestore();
86
+ });
87
+
88
+ it("does not print when exists is true", () => {
89
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
90
+ printConstitutionHint(true);
91
+ expect(spy).not.toHaveBeenCalled();
92
+ spy.mockRestore();
93
+ });
94
+ });
@@ -0,0 +1,201 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildFrontendApiContract, buildContractContextSection } from "../core/contract-bridge";
3
+ import { SpecDSL } from "../core/dsl-types";
4
+
5
+ // ─── Minimal DSL fixture ─────────────────────────────────────────────────────
6
+
7
+ function makeDsl(overrides?: Partial<SpecDSL>): SpecDSL {
8
+ return {
9
+ feature: { title: "Order Management", description: "CRUD for orders" },
10
+ models: [
11
+ {
12
+ name: "Order",
13
+ fields: [
14
+ { name: "id", type: "Int", required: true, unique: true },
15
+ { name: "total", type: "Float", required: true, unique: false },
16
+ { name: "status", type: "String", required: true, unique: false },
17
+ { name: "createdAt", type: "DateTime", required: false, unique: false },
18
+ ],
19
+ },
20
+ ],
21
+ endpoints: [
22
+ {
23
+ id: "createOrder",
24
+ method: "POST",
25
+ path: "/api/orders",
26
+ auth: true,
27
+ description: "Create a new order",
28
+ request: { body: { total: "number", items: "string[]" }, params: {}, query: {} },
29
+ successStatus: 201,
30
+ successDescription: "Created order with id",
31
+ errors: [
32
+ { status: 400, code: "INVALID_INPUT", description: "Bad request" },
33
+ { status: 401, code: "UNAUTHORIZED", description: "Not authenticated" },
34
+ ],
35
+ },
36
+ {
37
+ id: "listOrders",
38
+ method: "GET",
39
+ path: "/api/orders",
40
+ auth: true,
41
+ description: "List all orders",
42
+ request: { body: {}, params: {}, query: { page: "number", limit: "number" } },
43
+ successStatus: 200,
44
+ successDescription: "Returns list of orders",
45
+ errors: [],
46
+ },
47
+ {
48
+ id: "deleteOrder",
49
+ method: "DELETE",
50
+ path: "/api/orders/:id",
51
+ auth: true,
52
+ description: "Delete an order",
53
+ request: { body: {}, params: { id: "string" }, query: {} },
54
+ successStatus: 204,
55
+ successDescription: "No content",
56
+ errors: [{ status: 404, code: "NOT_FOUND", description: "Order not found" }],
57
+ },
58
+ ],
59
+ behaviors: [],
60
+ ...overrides,
61
+ } as SpecDSL;
62
+ }
63
+
64
+ // ─── buildFrontendApiContract ────────────────────────────────────────────────
65
+
66
+ describe("buildFrontendApiContract", () => {
67
+ it("returns correct number of endpoints", () => {
68
+ const contract = buildFrontendApiContract(makeDsl());
69
+ expect(contract.endpoints).toHaveLength(3);
70
+ });
71
+
72
+ it("preserves method and path", () => {
73
+ const contract = buildFrontendApiContract(makeDsl());
74
+ expect(contract.endpoints[0].method).toBe("POST");
75
+ expect(contract.endpoints[0].path).toBe("/api/orders");
76
+ });
77
+
78
+ it("preserves auth flag", () => {
79
+ const contract = buildFrontendApiContract(makeDsl());
80
+ expect(contract.endpoints[0].auth).toBe(true);
81
+ });
82
+
83
+ it("extracts error codes", () => {
84
+ const contract = buildFrontendApiContract(makeDsl());
85
+ expect(contract.endpoints[0].errorCodes).toEqual(["INVALID_INPUT", "UNAUTHORIZED"]);
86
+ expect(contract.endpoints[2].errorCodes).toEqual(["NOT_FOUND"]);
87
+ });
88
+
89
+ it("generates request shape as TypeScript interface", () => {
90
+ const contract = buildFrontendApiContract(makeDsl());
91
+ expect(contract.endpoints[0].requestShape).toContain("interface");
92
+ expect(contract.endpoints[0].requestShape).toContain("total");
93
+ expect(contract.endpoints[0].requestShape).toContain("items");
94
+ });
95
+
96
+ it("generates response shape as TypeScript interface", () => {
97
+ const contract = buildFrontendApiContract(makeDsl());
98
+ expect(contract.endpoints[0].responseShape).toContain("interface");
99
+ });
100
+
101
+ it("uses model fields for GET response on matching path", () => {
102
+ const contract = buildFrontendApiContract(makeDsl());
103
+ const listEndpoint = contract.endpoints[1]; // GET /api/orders
104
+ expect(listEndpoint.responseShape).toContain("id");
105
+ expect(listEndpoint.responseShape).toContain("total");
106
+ expect(listEndpoint.responseShape).toContain("status");
107
+ });
108
+
109
+ it("generates 204 No Content interface for DELETE", () => {
110
+ const contract = buildFrontendApiContract(makeDsl());
111
+ const deleteEndpoint = contract.endpoints[2];
112
+ expect(deleteEndpoint.responseShape).toContain("204 No Content");
113
+ });
114
+
115
+ it("generates type definitions block", () => {
116
+ const contract = buildFrontendApiContract(makeDsl());
117
+ expect(contract.typeDefinitions).toContain("interface");
118
+ expect(contract.typeDefinitions.length).toBeGreaterThan(50);
119
+ });
120
+
121
+ it("generates summary with feature title", () => {
122
+ const contract = buildFrontendApiContract(makeDsl());
123
+ expect(contract.summary).toContain("Order Management");
124
+ expect(contract.summary).toContain("3"); // 3 endpoints
125
+ expect(contract.summary).toContain("Order"); // model name
126
+ });
127
+
128
+ it("summary shows auth label per endpoint", () => {
129
+ const contract = buildFrontendApiContract(makeDsl());
130
+ expect(contract.summary).toContain("[auth required]");
131
+ });
132
+
133
+ it("handles empty errors array", () => {
134
+ const contract = buildFrontendApiContract(makeDsl());
135
+ const listEndpoint = contract.endpoints[1];
136
+ expect(listEndpoint.errorCodes).toEqual([]);
137
+ });
138
+
139
+ it("handles DSL with no models", () => {
140
+ const contract = buildFrontendApiContract(makeDsl({ models: [] }));
141
+ expect(contract.endpoints).toHaveLength(3);
142
+ expect(contract.summary).not.toContain("Data models:");
143
+ });
144
+
145
+ it("infers TS types correctly in request shape", () => {
146
+ const contract = buildFrontendApiContract(makeDsl());
147
+ // Query params — page and limit should be number
148
+ const listReq = contract.endpoints[1].requestShape;
149
+ expect(listReq).toContain("number");
150
+ });
151
+
152
+ it("handles endpoint with token response description", () => {
153
+ const dsl = makeDsl({
154
+ endpoints: [
155
+ {
156
+ id: "login",
157
+ method: "POST",
158
+ path: "/api/auth/login",
159
+ auth: false,
160
+ description: "Login",
161
+ request: { body: { email: "string", password: "string" }, params: {}, query: {} },
162
+ successStatus: 200,
163
+ successDescription: "Returns JWT token",
164
+ errors: [],
165
+ },
166
+ ],
167
+ });
168
+ const contract = buildFrontendApiContract(dsl);
169
+ expect(contract.endpoints[0].responseShape).toContain("token");
170
+ });
171
+ });
172
+
173
+ // ─── buildContractContextSection ─────────────────────────────────────────────
174
+
175
+ describe("buildContractContextSection", () => {
176
+ it("wraps contract in boundary markers", () => {
177
+ const contract = buildFrontendApiContract(makeDsl());
178
+ const section = buildContractContextSection(contract);
179
+ expect(section).toContain("=== Backend API Contract");
180
+ expect(section).toContain("=== End of Backend API Contract ===");
181
+ });
182
+
183
+ it("includes summary", () => {
184
+ const contract = buildFrontendApiContract(makeDsl());
185
+ const section = buildContractContextSection(contract);
186
+ expect(section).toContain("Order Management");
187
+ });
188
+
189
+ it("includes TypeScript definitions", () => {
190
+ const contract = buildFrontendApiContract(makeDsl());
191
+ const section = buildContractContextSection(contract);
192
+ expect(section).toContain("TypeScript Interface Definitions");
193
+ expect(section).toContain("interface");
194
+ });
195
+
196
+ it("includes instruction not to change paths/methods", () => {
197
+ const contract = buildFrontendApiContract(makeDsl());
198
+ const section = buildContractContextSection(contract);
199
+ expect(section).toContain("do NOT change paths");
200
+ });
201
+ });
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { DesignDialogue } from "../core/design-dialogue";
3
+
4
+ // Mock @inquirer/prompts
5
+ vi.mock("@inquirer/prompts", () => ({
6
+ select: vi.fn(),
7
+ }));
8
+
9
+ import { select } from "@inquirer/prompts";
10
+ const mockedSelect = vi.mocked(select);
11
+
12
+ const mockProvider = {
13
+ generate: vi.fn(),
14
+ providerName: "test",
15
+ modelName: "test-model",
16
+ };
17
+
18
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
19
+
20
+ beforeEach(() => {
21
+ mockProvider.generate.mockReset();
22
+ mockedSelect.mockReset();
23
+ });
24
+
25
+ const contextHints = {
26
+ techStack: ["express", "prisma"],
27
+ repoType: "node-express",
28
+ };
29
+
30
+ describe("DesignDialogue", () => {
31
+ it("returns selectedApproach when user picks an option", async () => {
32
+ const optionsText = "### Option A — REST approach\nUse REST.\n### Option B — GraphQL\nUse GraphQL.\n### Option C — gRPC\nUse gRPC.";
33
+ mockProvider.generate.mockResolvedValueOnce(optionsText);
34
+ mockedSelect.mockResolvedValueOnce("Option A — REST approach");
35
+
36
+ const dialogue = new DesignDialogue(mockProvider);
37
+ const result = await dialogue.run("Build user service", contextHints);
38
+
39
+ expect(result.optionsText).toBe(optionsText);
40
+ expect(result.selectedApproach).toContain("Option A");
41
+ });
42
+
43
+ it("returns null selectedApproach when user skips", async () => {
44
+ mockProvider.generate.mockResolvedValueOnce("### Option A — REST\n### Option B — GraphQL");
45
+ mockedSelect.mockResolvedValueOnce("__skip__");
46
+
47
+ const dialogue = new DesignDialogue(mockProvider);
48
+ const result = await dialogue.run("Build API", contextHints);
49
+
50
+ expect(result.selectedApproach).toBeNull();
51
+ });
52
+
53
+ it("blends approaches when user selects blend", async () => {
54
+ mockProvider.generate.mockResolvedValueOnce("### Option A — REST\nDetails\n### Option B — GraphQL\nDetails");
55
+ mockedSelect.mockResolvedValueOnce("__blend__");
56
+ mockProvider.generate.mockResolvedValueOnce("Use REST for CRUD and GraphQL for complex queries.");
57
+
58
+ const dialogue = new DesignDialogue(mockProvider);
59
+ const result = await dialogue.run("Build API", contextHints);
60
+
61
+ expect(result.selectedApproach).toContain("Blended approach");
62
+ expect(result.selectedApproach).toContain("REST for CRUD");
63
+ });
64
+
65
+ it("returns null when blend fails", async () => {
66
+ mockProvider.generate.mockResolvedValueOnce("### Option A — REST\n### Option B — GraphQL");
67
+ mockedSelect.mockResolvedValueOnce("__blend__");
68
+ mockProvider.generate.mockRejectedValueOnce(new Error("fail"));
69
+
70
+ const dialogue = new DesignDialogue(mockProvider);
71
+ const result = await dialogue.run("Build API", contextHints);
72
+
73
+ expect(result.selectedApproach).toBeNull();
74
+ });
75
+
76
+ it("returns null when AI options generation fails", async () => {
77
+ mockProvider.generate.mockRejectedValueOnce(new Error("timeout"));
78
+
79
+ const dialogue = new DesignDialogue(mockProvider);
80
+ const result = await dialogue.run("Build API", contextHints);
81
+
82
+ expect(result.optionsText).toBe("");
83
+ expect(result.selectedApproach).toBeNull();
84
+ });
85
+
86
+ it("extracts full option text when user selects a specific option", async () => {
87
+ const optionsText = "### Option A — Monolith\nKeep it simple.\n### Option B — Microservices\nScale independently.\n---";
88
+ mockProvider.generate.mockResolvedValueOnce(optionsText);
89
+ mockedSelect.mockResolvedValueOnce("Option A — Monolith");
90
+
91
+ const dialogue = new DesignDialogue(mockProvider);
92
+ const result = await dialogue.run("Build system", contextHints);
93
+
94
+ expect(result.selectedApproach).toContain("Keep it simple");
95
+ });
96
+
97
+ it("caps selected approach to 400 chars", async () => {
98
+ const longDesc = "A".repeat(500);
99
+ const optionsText = `### Option A — Long\n${longDesc}\n### Option B — Short\nShort.`;
100
+ mockProvider.generate.mockResolvedValueOnce(optionsText);
101
+ mockedSelect.mockResolvedValueOnce("Option A — Long");
102
+
103
+ const dialogue = new DesignDialogue(mockProvider);
104
+ const result = await dialogue.run("Build", contextHints);
105
+
106
+ expect(result.selectedApproach!.length).toBeLessThanOrEqual(400);
107
+ });
108
+ });