ai-spec-dev 0.38.0 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/RELEASE_LOG.md +231 -0
  2. package/cli/commands/create.ts +9 -1176
  3. package/cli/commands/dashboard.ts +1 -1
  4. package/cli/pipeline/helpers.ts +34 -0
  5. package/cli/pipeline/multi-repo.ts +483 -0
  6. package/cli/pipeline/single-repo.ts +755 -0
  7. package/cli/utils.ts +2 -0
  8. package/core/code-generator.ts +52 -341
  9. package/core/codegen/helpers.ts +219 -0
  10. package/core/codegen/topo-sort.ts +98 -0
  11. package/core/constitution-consolidator.ts +2 -2
  12. package/core/dsl-coverage-checker.ts +298 -0
  13. package/core/dsl-extractor.ts +19 -46
  14. package/core/dsl-feedback.ts +1 -1
  15. package/core/dsl-validator.ts +74 -0
  16. package/core/error-feedback.ts +95 -11
  17. package/core/frontend-context-loader.ts +27 -5
  18. package/core/knowledge-memory.ts +52 -0
  19. package/core/mock/fixtures.ts +89 -0
  20. package/core/mock/proxy.ts +380 -0
  21. package/core/mock-server-generator.ts +12 -460
  22. package/core/requirement-decomposer.ts +4 -28
  23. package/core/reviewer.ts +1 -1
  24. package/core/safe-json.ts +76 -0
  25. package/core/spec-updater.ts +5 -21
  26. package/core/token-budget.ts +124 -0
  27. package/core/vcr.ts +20 -1
  28. package/dist/cli/index.js +4110 -3534
  29. package/dist/cli/index.js.map +1 -1
  30. package/dist/cli/index.mjs +4237 -3661
  31. package/dist/cli/index.mjs.map +1 -1
  32. package/dist/index.d.mts +18 -16
  33. package/dist/index.d.ts +18 -16
  34. package/dist/index.js +310 -182
  35. package/dist/index.js.map +1 -1
  36. package/dist/index.mjs +308 -180
  37. package/dist/index.mjs.map +1 -1
  38. package/package.json +2 -2
  39. package/purpose.md +173 -33
  40. package/tests/auto-consolidation.test.ts +109 -0
  41. package/tests/combined-generator.test.ts +81 -0
  42. package/tests/constitution-consolidator.test.ts +161 -0
  43. package/tests/constitution-generator.test.ts +94 -0
  44. package/tests/contract-bridge.test.ts +201 -0
  45. package/tests/design-dialogue.test.ts +108 -0
  46. package/tests/dsl-coverage-checker.test.ts +230 -0
  47. package/tests/dsl-feedback.test.ts +45 -0
  48. package/tests/dsl-validator-xref.test.ts +99 -0
  49. package/tests/error-feedback-repair.test.ts +319 -0
  50. package/tests/error-feedback-validation.test.ts +91 -0
  51. package/tests/frontend-context-loader.test.ts +609 -0
  52. package/tests/global-constitution.test.ts +110 -0
  53. package/tests/key-store.test.ts +73 -0
  54. package/tests/knowledge-memory.test.ts +327 -0
  55. package/tests/project-index.test.ts +206 -0
  56. package/tests/prompt-hasher.test.ts +19 -0
  57. package/tests/requirement-decomposer.test.ts +171 -0
  58. package/tests/reviewer.test.ts +4 -1
  59. package/tests/run-logger.test.ts +289 -0
  60. package/tests/run-snapshot.test.ts +113 -0
  61. package/tests/safe-json.test.ts +63 -0
  62. package/tests/spec-updater.test.ts +161 -0
  63. package/tests/test-generator.test.ts +146 -0
  64. package/tests/token-budget.test.ts +124 -0
  65. package/tests/vcr-hash.test.ts +101 -0
  66. package/tests/workspace-loader.test.ts +277 -0
@@ -0,0 +1,110 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import * as fs from "fs-extra";
3
+ import * as path from "path";
4
+ import * as os from "os";
5
+ import {
6
+ GLOBAL_CONSTITUTION_FILE,
7
+ loadGlobalConstitution,
8
+ mergeConstitutions,
9
+ saveGlobalConstitution,
10
+ } from "../core/global-constitution";
11
+
12
+ describe("GLOBAL_CONSTITUTION_FILE", () => {
13
+ it("is .ai-spec-global-constitution.md", () => {
14
+ expect(GLOBAL_CONSTITUTION_FILE).toBe(".ai-spec-global-constitution.md");
15
+ });
16
+ });
17
+
18
+ describe("mergeConstitutions", () => {
19
+ it("wraps global content in comment markers", () => {
20
+ const result = mergeConstitutions("global rules", undefined);
21
+ expect(result).toContain("BEGIN GLOBAL CONSTITUTION");
22
+ expect(result).toContain("global rules");
23
+ expect(result).toContain("END GLOBAL CONSTITUTION");
24
+ });
25
+
26
+ it("appends project constitution with higher priority markers", () => {
27
+ const result = mergeConstitutions("global rules", "project rules");
28
+ expect(result).toContain("BEGIN GLOBAL CONSTITUTION");
29
+ expect(result).toContain("BEGIN PROJECT CONSTITUTION");
30
+ expect(result).toContain("HIGHER priority");
31
+ expect(result).toContain("project rules");
32
+ });
33
+
34
+ it("skips project section when projectContent is empty string", () => {
35
+ const result = mergeConstitutions("global rules", " ");
36
+ expect(result).not.toContain("PROJECT CONSTITUTION");
37
+ });
38
+
39
+ it("skips project section when projectContent is undefined", () => {
40
+ const result = mergeConstitutions("global rules", undefined);
41
+ expect(result).not.toContain("PROJECT CONSTITUTION");
42
+ });
43
+
44
+ it("trims whitespace from both contents", () => {
45
+ const result = mergeConstitutions(" global \n", " project \n");
46
+ expect(result).toContain("global");
47
+ expect(result).toContain("project");
48
+ });
49
+ });
50
+
51
+ describe("loadGlobalConstitution", () => {
52
+ let tmpDir: string;
53
+
54
+ beforeEach(async () => {
55
+ tmpDir = path.join(os.tmpdir(), `gc-test-${Date.now()}`);
56
+ await fs.ensureDir(tmpDir);
57
+ });
58
+
59
+ afterEach(async () => {
60
+ await fs.remove(tmpDir);
61
+ });
62
+
63
+ it("returns null when no file exists in any root", async () => {
64
+ const result = await loadGlobalConstitution([tmpDir]);
65
+ expect(result).toBeNull();
66
+ });
67
+
68
+ it("finds file in extraRoots", async () => {
69
+ await fs.writeFile(
70
+ path.join(tmpDir, GLOBAL_CONSTITUTION_FILE),
71
+ "team baseline rules",
72
+ "utf-8"
73
+ );
74
+ const result = await loadGlobalConstitution([tmpDir]);
75
+ expect(result).not.toBeNull();
76
+ expect(result!.content).toBe("team baseline rules");
77
+ expect(result!.source).toBe(path.join(tmpDir, GLOBAL_CONSTITUTION_FILE));
78
+ });
79
+
80
+ it("checks extraRoots before home directory", async () => {
81
+ const dir1 = path.join(tmpDir, "dir1");
82
+ const dir2 = path.join(tmpDir, "dir2");
83
+ await fs.ensureDir(dir1);
84
+ await fs.ensureDir(dir2);
85
+ await fs.writeFile(path.join(dir1, GLOBAL_CONSTITUTION_FILE), "first", "utf-8");
86
+ await fs.writeFile(path.join(dir2, GLOBAL_CONSTITUTION_FILE), "second", "utf-8");
87
+
88
+ const result = await loadGlobalConstitution([dir1, dir2]);
89
+ expect(result!.content).toBe("first");
90
+ });
91
+ });
92
+
93
+ describe("saveGlobalConstitution", () => {
94
+ let tmpDir: string;
95
+
96
+ beforeEach(async () => {
97
+ tmpDir = path.join(os.tmpdir(), `gc-save-${Date.now()}`);
98
+ await fs.ensureDir(tmpDir);
99
+ });
100
+
101
+ afterEach(async () => {
102
+ await fs.remove(tmpDir);
103
+ });
104
+
105
+ it("writes file to the target directory", async () => {
106
+ const filePath = await saveGlobalConstitution("my rules", tmpDir);
107
+ expect(filePath).toBe(path.join(tmpDir, GLOBAL_CONSTITUTION_FILE));
108
+ expect(await fs.readFile(filePath, "utf-8")).toBe("my rules");
109
+ });
110
+ });
@@ -0,0 +1,73 @@
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
+
6
+ // We need to mock the KEY_STORE_FILE path before importing key-store,
7
+ // because it uses os.homedir() at module level. Instead we test the
8
+ // exported functions by creating a temporary home directory.
9
+
10
+ // Since KEY_STORE_FILE is fixed to homedir, we'll test the actual functions
11
+ // only if we can write there. For safety, we import and test the store
12
+ // functions with a patched approach.
13
+
14
+ describe("key-store", () => {
15
+ let tmpDir: string;
16
+ let originalKeyStoreFile: string;
17
+
18
+ // We'll import dynamically and patch
19
+ let getSavedKey: typeof import("../core/key-store").getSavedKey;
20
+ let saveKey: typeof import("../core/key-store").saveKey;
21
+ let clearKey: typeof import("../core/key-store").clearKey;
22
+ let clearAllKeys: typeof import("../core/key-store").clearAllKeys;
23
+
24
+ beforeEach(async () => {
25
+ tmpDir = path.join(os.tmpdir(), `ks-test-${Date.now()}`);
26
+ await fs.ensureDir(tmpDir);
27
+
28
+ // Import fresh module each time
29
+ const mod = await import("../core/key-store");
30
+ getSavedKey = mod.getSavedKey;
31
+ saveKey = mod.saveKey;
32
+ clearKey = mod.clearKey;
33
+ clearAllKeys = mod.clearAllKeys;
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await fs.remove(tmpDir);
38
+ });
39
+
40
+ // Note: These tests use the real KEY_STORE_FILE path (~/.ai-spec-keys.json).
41
+ // We test the in-memory logic by saving/clearing a unique test provider name.
42
+
43
+ const testProvider = `__vitest_key_store_test_${Date.now()}`;
44
+
45
+ it("getSavedKey returns undefined for unknown provider", async () => {
46
+ const key = await getSavedKey(testProvider);
47
+ expect(key).toBeUndefined();
48
+ });
49
+
50
+ it("saveKey + getSavedKey round-trips", async () => {
51
+ await saveKey(testProvider, "sk-test-key-12345");
52
+ const key = await getSavedKey(testProvider);
53
+ expect(key).toBe("sk-test-key-12345");
54
+
55
+ // Clean up
56
+ await clearKey(testProvider);
57
+ });
58
+
59
+ it("clearKey removes a specific provider", async () => {
60
+ await saveKey(testProvider, "to-be-cleared");
61
+ await clearKey(testProvider);
62
+ const key = await getSavedKey(testProvider);
63
+ expect(key).toBeUndefined();
64
+ });
65
+
66
+ it("clearAllKeys removes the entire store file", async () => {
67
+ await saveKey(testProvider, "temp-key");
68
+ // clearAllKeys removes the file
69
+ await clearAllKeys();
70
+ const key = await getSavedKey(testProvider);
71
+ expect(key).toBeUndefined();
72
+ });
73
+ });
@@ -0,0 +1,327 @@
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
+ extractIssuesFromReview,
7
+ appendLessonsToConstitution,
8
+ appendDirectLesson,
9
+ accumulateReviewKnowledge,
10
+ } from "../core/knowledge-memory";
11
+
12
+ // ─── extractIssuesFromReview ────────────────────────────────────────────────
13
+
14
+ describe("extractIssuesFromReview", () => {
15
+ it("extracts issues from standard review format", () => {
16
+ const review = `## ⚠️ 问题
17
+ - SQL query is not parameterized — risk of SQL injection
18
+ - Missing error handling in the login endpoint
19
+ - N+1 query in getUserOrders
20
+
21
+ ## 💡 建议
22
+ - Consider using Redis cache
23
+ `;
24
+ const issues = extractIssuesFromReview(review);
25
+ expect(issues).toHaveLength(3);
26
+ expect(issues[0].description).toContain("SQL query");
27
+ expect(issues[1].description).toContain("error handling");
28
+ });
29
+
30
+ it("categorizes security issues", () => {
31
+ const review = `## ⚠ Issues\n- SQL injection risk in user input\n## 💡 Suggestions`;
32
+ const issues = extractIssuesFromReview(review);
33
+ expect(issues[0].category).toBe("security");
34
+ });
35
+
36
+ it("categorizes performance issues", () => {
37
+ const review = `## ⚠ Issues\n- N+1 query performance problem in orders\n## 💡 Suggestions`;
38
+ const issues = extractIssuesFromReview(review);
39
+ expect(issues[0].category).toBe("performance");
40
+ });
41
+
42
+ it("categorizes bug issues", () => {
43
+ const review = `## ⚠ Issues\n- Error thrown when input is empty\n## 💡 Suggestions`;
44
+ const issues = extractIssuesFromReview(review);
45
+ expect(issues[0].category).toBe("bug");
46
+ });
47
+
48
+ it("categorizes pattern issues", () => {
49
+ const review = `## ⚠ Issues\n- Naming convention violation: should use camelCase\n## 💡 Suggestions`;
50
+ const issues = extractIssuesFromReview(review);
51
+ expect(issues[0].category).toBe("pattern");
52
+ });
53
+
54
+ it("defaults to general category", () => {
55
+ const review = `## ⚠ Issues\n- Missing documentation for public API\n## 💡 Suggestions`;
56
+ const issues = extractIssuesFromReview(review);
57
+ expect(issues[0].category).toBe("general");
58
+ });
59
+
60
+ it("returns empty array when no issues section found", () => {
61
+ expect(extractIssuesFromReview("Everything looks great!")).toEqual([]);
62
+ });
63
+
64
+ it("skips short items (< 10 chars)", () => {
65
+ const review = `## ⚠ Issues\n- Too short\n- This is a properly detailed issue description\n## 💡 OK`;
66
+ const issues = extractIssuesFromReview(review);
67
+ expect(issues).toHaveLength(1);
68
+ expect(issues[0].description).toContain("properly detailed");
69
+ });
70
+
71
+ it("limits to 10 issues maximum", () => {
72
+ const items = Array.from({ length: 15 }, (_, i) =>
73
+ `- Issue number ${i + 1} with enough length to pass the filter`
74
+ ).join("\n");
75
+ const review = `## ⚠ Issues\n${items}\n## 💡 Done`;
76
+ const issues = extractIssuesFromReview(review);
77
+ expect(issues).toHaveLength(10);
78
+ });
79
+
80
+ it("strips markdown bold from description", () => {
81
+ const review = `## ⚠ Issues\n- **Security**: Missing authentication check on admin endpoint\n## 💡 Done`;
82
+ const issues = extractIssuesFromReview(review);
83
+ expect(issues[0].description).not.toContain("**");
84
+ expect(issues[0].description).toContain("Security");
85
+ });
86
+
87
+ it("handles numbered list items", () => {
88
+ const review = `## ⚠ Issues\n1. First issue with details\n2. Second issue with details\n## 💡 Done`;
89
+ const issues = extractIssuesFromReview(review);
90
+ expect(issues).toHaveLength(2);
91
+ });
92
+
93
+ it("truncates long descriptions to 200 chars", () => {
94
+ const longDesc = "A".repeat(300);
95
+ const review = `## ⚠ Issues\n- ${longDesc}\n## 💡 Done`;
96
+ const issues = extractIssuesFromReview(review);
97
+ expect(issues[0].description.length).toBeLessThanOrEqual(200);
98
+ });
99
+ });
100
+
101
+ // ─── appendLessonsToConstitution ────────────────────────────────────────────
102
+
103
+ describe("appendLessonsToConstitution", () => {
104
+ let tmpDir: string;
105
+ const CONSTITUTION = ".ai-spec-constitution.md";
106
+
107
+ beforeEach(async () => {
108
+ tmpDir = path.join(os.tmpdir(), `km-test-${Date.now()}`);
109
+ await fs.ensureDir(tmpDir);
110
+ });
111
+
112
+ afterEach(async () => {
113
+ await fs.remove(tmpDir);
114
+ });
115
+
116
+ it("creates §9 section when it does not exist", async () => {
117
+ await fs.writeFile(
118
+ path.join(tmpDir, CONSTITUTION),
119
+ "# Project Constitution\n\n## 1. Architecture\n\nSome rules here.\n"
120
+ );
121
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
122
+
123
+ await appendLessonsToConstitution(tmpDir, [
124
+ { description: "Always validate user input before DB queries", category: "security" },
125
+ ]);
126
+
127
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
128
+ expect(content).toContain("## 9. 积累教训");
129
+ expect(content).toContain("Always validate user input");
130
+ expect(content).toContain("🔒");
131
+ spy.mockRestore();
132
+ });
133
+
134
+ it("appends to existing §9 section", async () => {
135
+ await fs.writeFile(
136
+ path.join(tmpDir, CONSTITUTION),
137
+ "# Project Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n- 📝 **[2026-03-01]** Old lesson\n"
138
+ );
139
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
140
+
141
+ await appendLessonsToConstitution(tmpDir, [
142
+ { description: "Use parameterized queries to prevent injection", category: "security" },
143
+ ]);
144
+
145
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
146
+ expect(content).toContain("Old lesson");
147
+ expect(content).toContain("parameterized queries");
148
+ spy.mockRestore();
149
+ });
150
+
151
+ it("deduplicates — skips issues already in constitution", async () => {
152
+ await fs.writeFile(
153
+ path.join(tmpDir, CONSTITUTION),
154
+ "# Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n- 📝 **[2026-03-01]** Always validate user input before DB queries\n"
155
+ );
156
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
157
+
158
+ await appendLessonsToConstitution(tmpDir, [
159
+ { description: "Always validate user input before DB queries", category: "security" },
160
+ ]);
161
+
162
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
163
+ // Should only have one entry — the original
164
+ const matches = content.match(/validate user input/g);
165
+ expect(matches).toHaveLength(1);
166
+ spy.mockRestore();
167
+ });
168
+
169
+ it("skips when no constitution file exists", async () => {
170
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
171
+ await appendLessonsToConstitution(tmpDir, [
172
+ { description: "Some lesson text here with enough length", category: "general" },
173
+ ]);
174
+ // Should not throw, just log a message
175
+ expect(spy).toHaveBeenCalled();
176
+ spy.mockRestore();
177
+ });
178
+
179
+ it("does nothing when issues array is empty", async () => {
180
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n");
181
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
182
+ await appendLessonsToConstitution(tmpDir, []);
183
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
184
+ expect(content).not.toContain("§9");
185
+ expect(content).not.toContain("积累教训");
186
+ spy.mockRestore();
187
+ });
188
+
189
+ it("adds correct badge per category", async () => {
190
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n");
191
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
192
+
193
+ await appendLessonsToConstitution(tmpDir, [
194
+ { description: "Security: XSS vulnerability in search input", category: "security" },
195
+ { description: "Performance: slow query needs an index", category: "performance" },
196
+ { description: "Bug: null pointer when user has no orders", category: "bug" },
197
+ { description: "Pattern: naming convention for store files", category: "pattern" },
198
+ { description: "General: remember to update the changelog", category: "general" },
199
+ ]);
200
+
201
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
202
+ expect(content).toContain("🔒");
203
+ expect(content).toContain("⚡");
204
+ expect(content).toContain("🐛");
205
+ expect(content).toContain("📐");
206
+ expect(content).toContain("📝");
207
+ spy.mockRestore();
208
+ });
209
+
210
+ it("includes date stamp in lesson entries", async () => {
211
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n");
212
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
213
+
214
+ await appendLessonsToConstitution(tmpDir, [
215
+ { description: "Test lesson with proper length for the check", category: "general" },
216
+ ]);
217
+
218
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
219
+ // Should have a date like **[2026-04-02]**
220
+ expect(content).toMatch(/\*\*\[\d{4}-\d{2}-\d{2}\]\*\*/);
221
+ spy.mockRestore();
222
+ });
223
+ });
224
+
225
+ // ─── appendDirectLesson ─────────────────────────────────────────────────────
226
+
227
+ describe("appendDirectLesson", () => {
228
+ let tmpDir: string;
229
+ const CONSTITUTION = ".ai-spec-constitution.md";
230
+
231
+ beforeEach(async () => {
232
+ tmpDir = path.join(os.tmpdir(), `km-direct-${Date.now()}`);
233
+ await fs.ensureDir(tmpDir);
234
+ });
235
+
236
+ afterEach(async () => {
237
+ await fs.remove(tmpDir);
238
+ });
239
+
240
+ it("appends a lesson and returns appended: true", async () => {
241
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n");
242
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
243
+ const result = await appendDirectLesson(tmpDir, "Never use SELECT * in production queries");
244
+ expect(result.appended).toBe(true);
245
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
246
+ expect(content).toContain("SELECT *");
247
+ spy.mockRestore();
248
+ });
249
+
250
+ it("returns appended: false when constitution missing", async () => {
251
+ const result = await appendDirectLesson(tmpDir, "Some lesson");
252
+ expect(result.appended).toBe(false);
253
+ expect(result.reason).toContain("No constitution");
254
+ });
255
+
256
+ it("deduplicates by first 60 chars", async () => {
257
+ const lesson = "Always use TypeScript strict mode for all new modules in the project";
258
+ await fs.writeFile(
259
+ path.join(tmpDir, CONSTITUTION),
260
+ `# Constitution\n\n## 9. 积累教训 (Accumulated Lessons)\n- 📝 **[2026-03-01]** ${lesson}\n`
261
+ );
262
+ const result = await appendDirectLesson(tmpDir, lesson);
263
+ expect(result.appended).toBe(false);
264
+ expect(result.reason).toContain("already exists");
265
+ });
266
+
267
+ it("creates §9 section if missing", async () => {
268
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n## 1. Rules\n");
269
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
270
+ await appendDirectLesson(tmpDir, "Lesson about proper error handling in controllers");
271
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
272
+ expect(content).toContain("## 9. 积累教训");
273
+ spy.mockRestore();
274
+ });
275
+ });
276
+
277
+ // ─── accumulateReviewKnowledge (integration) ────────────────────────────────
278
+
279
+ describe("accumulateReviewKnowledge", () => {
280
+ let tmpDir: string;
281
+ const CONSTITUTION = ".ai-spec-constitution.md";
282
+
283
+ beforeEach(async () => {
284
+ tmpDir = path.join(os.tmpdir(), `km-accum-${Date.now()}`);
285
+ await fs.ensureDir(tmpDir);
286
+ });
287
+
288
+ afterEach(async () => {
289
+ await fs.remove(tmpDir);
290
+ });
291
+
292
+ const mockProvider = {
293
+ generate: vi.fn().mockResolvedValue(""),
294
+ providerName: "test",
295
+ modelName: "test-model",
296
+ };
297
+
298
+ it("extracts issues from review and appends to constitution", async () => {
299
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n");
300
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
301
+
302
+ const review = `## ⚠️ 问题
303
+ - Missing input validation on POST /api/users — no email format check
304
+ - No rate limiting on login endpoint could allow brute force
305
+
306
+ ## 💡 建议
307
+ - Add helmet middleware
308
+ `;
309
+ await accumulateReviewKnowledge(mockProvider, tmpDir, review);
310
+
311
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
312
+ expect(content).toContain("input validation");
313
+ expect(content).toContain("rate limiting");
314
+ spy.mockRestore();
315
+ });
316
+
317
+ it("does nothing when review has no issues section", async () => {
318
+ await fs.writeFile(path.join(tmpDir, CONSTITUTION), "# Constitution\n");
319
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
320
+
321
+ await accumulateReviewKnowledge(mockProvider, tmpDir, "Score: 10/10\nPerfect code!");
322
+
323
+ const content = await fs.readFile(path.join(tmpDir, CONSTITUTION), "utf-8");
324
+ expect(content).not.toContain("积累教训");
325
+ spy.mockRestore();
326
+ });
327
+ });