ai-spec-dev 0.38.0 → 0.42.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 (89) hide show
  1. package/.ai-spec-workspace.json +17 -0
  2. package/.ai-spec.json +7 -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 +764 -0
  8. package/cli/utils.ts +2 -0
  9. package/core/cli-ui.ts +136 -0
  10. package/core/code-generator.ts +56 -343
  11. package/core/codegen/helpers.ts +219 -0
  12. package/core/codegen/topo-sort.ts +98 -0
  13. package/core/constitution-consolidator.ts +2 -2
  14. package/core/dsl-coverage-checker.ts +298 -0
  15. package/core/dsl-extractor.ts +19 -46
  16. package/core/dsl-feedback.ts +1 -1
  17. package/core/dsl-validator.ts +74 -0
  18. package/core/error-feedback.ts +99 -13
  19. package/core/frontend-context-loader.ts +27 -5
  20. package/core/knowledge-memory.ts +52 -0
  21. package/core/mock/fixtures.ts +89 -0
  22. package/core/mock/proxy.ts +380 -0
  23. package/core/mock-server-generator.ts +12 -460
  24. package/core/provider-utils.ts +8 -7
  25. package/core/requirement-decomposer.ts +4 -28
  26. package/core/reviewer.ts +1 -1
  27. package/core/safe-json.ts +76 -0
  28. package/core/spec-updater.ts +5 -21
  29. package/core/token-budget.ts +124 -0
  30. package/core/vcr.ts +20 -1
  31. package/demo-backend/.ai-spec-constitution.md +65 -0
  32. package/demo-backend/package.json +21 -0
  33. package/demo-backend/prisma/schema.prisma +22 -0
  34. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.dsl.json +186 -0
  35. package/demo-backend/specs/feature-1-bookmark-id-uuid-title-string-required-url-str-v1.md +211 -0
  36. package/demo-backend/src/controllers/bookmark.controller.test.ts +255 -0
  37. package/demo-backend/src/controllers/bookmark.controller.ts +187 -0
  38. package/demo-backend/src/index.ts +17 -0
  39. package/demo-backend/src/routes/bookmark.routes.test.ts +264 -0
  40. package/demo-backend/src/routes/bookmark.routes.ts +11 -0
  41. package/demo-backend/src/routes/index.ts +8 -0
  42. package/demo-backend/src/services/bookmark.service.test.ts +433 -0
  43. package/demo-backend/src/services/bookmark.service.ts +261 -0
  44. package/demo-backend/tsconfig.json +12 -0
  45. package/demo-frontend/.ai-spec-constitution.md +95 -0
  46. package/demo-frontend/package.json +23 -0
  47. package/demo-frontend/src/App.tsx +12 -0
  48. package/demo-frontend/src/main.tsx +9 -0
  49. package/demo-frontend/tsconfig.json +13 -0
  50. package/dist/cli/index.js +4351 -3666
  51. package/dist/cli/index.js.map +1 -1
  52. package/dist/cli/index.mjs +3997 -3312
  53. package/dist/cli/index.mjs.map +1 -1
  54. package/dist/index.d.mts +18 -16
  55. package/dist/index.d.ts +18 -16
  56. package/dist/index.js +388 -188
  57. package/dist/index.js.map +1 -1
  58. package/dist/index.mjs +386 -186
  59. package/dist/index.mjs.map +1 -1
  60. package/package.json +2 -2
  61. package/tests/auto-consolidation.test.ts +109 -0
  62. package/tests/combined-generator.test.ts +81 -0
  63. package/tests/constitution-consolidator.test.ts +161 -0
  64. package/tests/constitution-generator.test.ts +94 -0
  65. package/tests/contract-bridge.test.ts +201 -0
  66. package/tests/design-dialogue.test.ts +108 -0
  67. package/tests/dsl-coverage-checker.test.ts +230 -0
  68. package/tests/dsl-feedback.test.ts +45 -0
  69. package/tests/dsl-validator-xref.test.ts +99 -0
  70. package/tests/error-feedback-repair.test.ts +319 -0
  71. package/tests/error-feedback-validation.test.ts +91 -0
  72. package/tests/frontend-context-loader.test.ts +609 -0
  73. package/tests/global-constitution.test.ts +110 -0
  74. package/tests/key-store.test.ts +73 -0
  75. package/tests/knowledge-memory.test.ts +327 -0
  76. package/tests/project-index.test.ts +206 -0
  77. package/tests/prompt-hasher.test.ts +19 -0
  78. package/tests/requirement-decomposer.test.ts +171 -0
  79. package/tests/reviewer.test.ts +4 -1
  80. package/tests/run-logger.test.ts +289 -0
  81. package/tests/run-snapshot.test.ts +113 -0
  82. package/tests/safe-json.test.ts +63 -0
  83. package/tests/spec-updater.test.ts +161 -0
  84. package/tests/test-generator.test.ts +146 -0
  85. package/tests/token-budget.test.ts +124 -0
  86. package/tests/vcr-hash.test.ts +101 -0
  87. package/tests/workspace-loader.test.ts +277 -0
  88. package/RELEASE_LOG.md +0 -2731
  89. package/purpose.md +0 -1294
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spec-dev",
3
- "version": "0.38.0",
3
+ "version": "0.42.0",
4
4
  "description": "AI-driven Development Orchestrator SDK & CLI",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -27,12 +27,12 @@
27
27
  "@google/generative-ai": "^0.21.0",
28
28
  "@inquirer/editor": "^5.0.10",
29
29
  "@inquirer/prompts": "^8.3.2",
30
+ "@rollup/rollup-darwin-arm64": "^4.60.1",
30
31
  "axios": "^1.13.6",
31
32
  "chalk": "^4.1.2",
32
33
  "commander": "^13.1.0",
33
34
  "dotenv": "^16.4.7",
34
35
  "fs-extra": "^11.3.0",
35
- "inquirer": "^8.2.6",
36
36
  "openai": "^6.31.0",
37
37
  "undici": "^7.24.4"
38
38
  },
@@ -0,0 +1,109 @@
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 { maybeAutoConsolidate } from "../core/knowledge-memory";
6
+ import { CONSTITUTION_FILE } from "../core/constitution-generator";
7
+
8
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
9
+
10
+ describe("maybeAutoConsolidate", () => {
11
+ let tmpDir: string;
12
+ const mockProvider = {
13
+ generate: vi.fn(),
14
+ providerName: "test",
15
+ modelName: "test-model",
16
+ };
17
+
18
+ beforeEach(async () => {
19
+ tmpDir = path.join(os.tmpdir(), `ac-test-${Date.now()}`);
20
+ await fs.ensureDir(tmpDir);
21
+ mockProvider.generate.mockReset();
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await fs.remove(tmpDir);
26
+ });
27
+
28
+ it("returns false when no constitution file", async () => {
29
+ const result = await maybeAutoConsolidate(mockProvider, tmpDir);
30
+ expect(result).toBe(false);
31
+ expect(mockProvider.generate).not.toHaveBeenCalled();
32
+ });
33
+
34
+ it("returns false when lesson count below threshold", async () => {
35
+ const lessons = Array.from({ length: 5 }, (_, i) =>
36
+ `- 📝 **[2026-01-0${i + 1}]** Lesson ${i + 1}`
37
+ ).join("\n");
38
+ await fs.writeFile(
39
+ path.join(tmpDir, CONSTITUTION_FILE),
40
+ `# Constitution\n## 9. 积累教训 (Accumulated Lessons)\n${lessons}\n`
41
+ );
42
+
43
+ const result = await maybeAutoConsolidate(mockProvider, tmpDir, { threshold: 12 });
44
+ expect(result).toBe(false);
45
+ expect(mockProvider.generate).not.toHaveBeenCalled();
46
+ });
47
+
48
+ it("triggers consolidation when lesson count meets threshold", async () => {
49
+ const lessons = Array.from({ length: 15 }, (_, i) =>
50
+ `- 📝 **[2026-01-${String(i + 1).padStart(2, "0")}]** Lesson number ${i + 1} with detail text`
51
+ ).join("\n");
52
+ await fs.writeFile(
53
+ path.join(tmpDir, CONSTITUTION_FILE),
54
+ `# Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n${lessons}\n`
55
+ );
56
+
57
+ mockProvider.generate.mockResolvedValueOnce(
58
+ "# Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n- 📝 **[2026-04-02]** Consolidated lesson\n"
59
+ );
60
+
61
+ const result = await maybeAutoConsolidate(mockProvider, tmpDir, { threshold: 12 });
62
+ expect(result).toBe(true);
63
+ expect(mockProvider.generate).toHaveBeenCalled();
64
+ });
65
+
66
+ it("returns false when consolidation fails", async () => {
67
+ const lessons = Array.from({ length: 15 }, (_, i) =>
68
+ `- 📝 **[2026-01-${String(i + 1).padStart(2, "0")}]** Lesson ${i + 1} detail`
69
+ ).join("\n");
70
+ await fs.writeFile(
71
+ path.join(tmpDir, CONSTITUTION_FILE),
72
+ `# Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n${lessons}\n`
73
+ );
74
+
75
+ mockProvider.generate.mockRejectedValueOnce(new Error("API error"));
76
+
77
+ const result = await maybeAutoConsolidate(mockProvider, tmpDir, { threshold: 12 });
78
+ expect(result).toBe(false);
79
+ });
80
+
81
+ it("respects custom threshold", async () => {
82
+ const lessons = Array.from({ length: 4 }, (_, i) =>
83
+ `- 📝 **[2026-01-0${i + 1}]** Lesson ${i + 1} text`
84
+ ).join("\n");
85
+ await fs.writeFile(
86
+ path.join(tmpDir, CONSTITUTION_FILE),
87
+ `# Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n${lessons}\n`
88
+ );
89
+
90
+ mockProvider.generate.mockResolvedValueOnce("# Constitution\n## 9. 积累教训\n- consolidated\n");
91
+
92
+ const result = await maybeAutoConsolidate(mockProvider, tmpDir, { threshold: 3 });
93
+ expect(result).toBe(true);
94
+ });
95
+
96
+ it("uses default threshold of 12", async () => {
97
+ const lessons = Array.from({ length: 10 }, (_, i) =>
98
+ `- 📝 **[2026-01-${String(i + 1).padStart(2, "0")}]** Lesson ${i + 1}`
99
+ ).join("\n");
100
+ await fs.writeFile(
101
+ path.join(tmpDir, CONSTITUTION_FILE),
102
+ `# Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n${lessons}\n`
103
+ );
104
+
105
+ const result = await maybeAutoConsolidate(mockProvider, tmpDir);
106
+ expect(result).toBe(false); // 10 < 12 default
107
+ expect(mockProvider.generate).not.toHaveBeenCalled();
108
+ });
109
+ });
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import { generateSpecWithTasks } from "../core/combined-generator";
3
+
4
+ const mockProvider = {
5
+ generate: vi.fn(),
6
+ providerName: "test",
7
+ modelName: "test-model",
8
+ };
9
+
10
+ beforeEach(() => {
11
+ mockProvider.generate.mockReset();
12
+ });
13
+
14
+ describe("generateSpecWithTasks", () => {
15
+ it("parses spec and tasks from combined output", async () => {
16
+ mockProvider.generate.mockResolvedValueOnce(
17
+ `# Feature Spec\n\nSome spec content.\n\n---TASKS_JSON---\n[{"id":"TASK-001","title":"Create model","description":"Create Order model","layer":"data","filesToTouch":["src/models/order.ts"],"acceptanceCriteria":["Model exists"],"verificationSteps":["Check file"],"dependencies":[],"priority":"high"}]`
18
+ );
19
+
20
+ const result = await generateSpecWithTasks(mockProvider, "Build order system");
21
+ expect(result.spec).toContain("Feature Spec");
22
+ expect(result.spec).not.toContain("TASKS_JSON");
23
+ expect(result.tasks).toHaveLength(1);
24
+ expect(result.tasks[0].id).toBe("TASK-001");
25
+ expect(result.tasks[0].layer).toBe("data");
26
+ });
27
+
28
+ it("returns empty tasks when separator is missing", async () => {
29
+ mockProvider.generate.mockResolvedValueOnce(
30
+ "# Feature Spec\n\nNo tasks separator here."
31
+ );
32
+
33
+ const result = await generateSpecWithTasks(mockProvider, "Build feature");
34
+ expect(result.spec).toContain("Feature Spec");
35
+ expect(result.tasks).toEqual([]);
36
+ });
37
+
38
+ it("returns empty tasks when JSON after separator is invalid", async () => {
39
+ mockProvider.generate.mockResolvedValueOnce(
40
+ "# Spec\n---TASKS_JSON---\nnot valid json"
41
+ );
42
+
43
+ const result = await generateSpecWithTasks(mockProvider, "Build feature");
44
+ expect(result.spec).toBe("# Spec");
45
+ expect(result.tasks).toEqual([]);
46
+ });
47
+
48
+ it("includes architecture decision in prompt when provided", async () => {
49
+ mockProvider.generate.mockResolvedValueOnce("# Spec\n---TASKS_JSON---\n[]");
50
+
51
+ await generateSpecWithTasks(mockProvider, "Build feature", undefined, "Use microservices");
52
+ const prompt = mockProvider.generate.mock.calls[0][0] as string;
53
+ expect(prompt).toContain("Architecture Decision");
54
+ expect(prompt).toContain("Use microservices");
55
+ });
56
+
57
+ it("includes context when ProjectContext is provided", async () => {
58
+ mockProvider.generate.mockResolvedValueOnce("# Spec\n---TASKS_JSON---\n[]");
59
+
60
+ const context = {
61
+ techStack: ["express", "prisma"],
62
+ dependencies: ["express", "prisma"],
63
+ apiStructure: ["src/routes/user.ts"],
64
+ fileStructure: ["src/index.ts"],
65
+ } as any;
66
+
67
+ await generateSpecWithTasks(mockProvider, "Build order feature", context);
68
+ const prompt = mockProvider.generate.mock.calls[0][0] as string;
69
+ expect(prompt).toContain("Build order feature");
70
+ });
71
+
72
+ it("trims spec and tasks", async () => {
73
+ mockProvider.generate.mockResolvedValueOnce(
74
+ " \n# Spec \n\n---TASKS_JSON---\n [{\"id\":\"T1\",\"title\":\"t\",\"description\":\"d\",\"layer\":\"data\",\"filesToTouch\":[],\"acceptanceCriteria\":[],\"verificationSteps\":[],\"dependencies\":[],\"priority\":\"high\"}] \n"
75
+ );
76
+
77
+ const result = await generateSpecWithTasks(mockProvider, "idea");
78
+ expect(result.spec).toBe("# Spec");
79
+ expect(result.tasks).toHaveLength(1);
80
+ });
81
+ });
@@ -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
+ });