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,289 @@
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
+ RunLogger,
7
+ generateRunId,
8
+ reconstructRunLogFromJsonl,
9
+ setActiveLogger,
10
+ getActiveLogger,
11
+ } from "../core/run-logger";
12
+
13
+ describe("generateRunId", () => {
14
+ it("returns a non-empty string", () => {
15
+ expect(generateRunId()).toBeTruthy();
16
+ });
17
+
18
+ it("has expected format YYYYMMDD-HHMMSS-rand", () => {
19
+ const id = generateRunId();
20
+ expect(id).toMatch(/^\d{8}-\d{6}-[a-z0-9]{4}$/);
21
+ });
22
+
23
+ it("produces unique IDs", () => {
24
+ const ids = new Set(Array.from({ length: 20 }, () => generateRunId()));
25
+ // With 4-char random suffix, collisions within 20 calls are extremely unlikely
26
+ expect(ids.size).toBeGreaterThanOrEqual(15);
27
+ });
28
+ });
29
+
30
+ describe("RunLogger", () => {
31
+ let tmpDir: string;
32
+
33
+ beforeEach(async () => {
34
+ tmpDir = path.join(os.tmpdir(), `rl-test-${Date.now()}`);
35
+ await fs.ensureDir(tmpDir);
36
+ });
37
+
38
+ afterEach(async () => {
39
+ await fs.remove(tmpDir);
40
+ });
41
+
42
+ it("creates log directory and files on construction", async () => {
43
+ const logger = new RunLogger(tmpDir, "test-run-001");
44
+ // flush is async — give it a tick
45
+ await new Promise((r) => setTimeout(r, 50));
46
+ const logDir = path.join(tmpDir, ".ai-spec-logs");
47
+ expect(await fs.pathExists(logDir)).toBe(true);
48
+ expect(await fs.pathExists(path.join(logDir, "test-run-001.json"))).toBe(true);
49
+ expect(await fs.pathExists(path.join(logDir, "test-run-001.jsonl"))).toBe(true);
50
+ });
51
+
52
+ it("stores provider and model metadata", async () => {
53
+ const logger = new RunLogger(tmpDir, "test-run-002", {
54
+ provider: "gemini",
55
+ model: "gemini-2.5-pro",
56
+ specPath: "specs/test.md",
57
+ });
58
+ await new Promise((r) => setTimeout(r, 50));
59
+ const json = await fs.readJson(path.join(tmpDir, ".ai-spec-logs", "test-run-002.json"));
60
+ expect(json.provider).toBe("gemini");
61
+ expect(json.model).toBe("gemini-2.5-pro");
62
+ expect(json.specPath).toBe("specs/test.md");
63
+ });
64
+
65
+ it("records stage start/end with duration", async () => {
66
+ const logger = new RunLogger(tmpDir, "test-run-003");
67
+ logger.stageStart("spec");
68
+ await new Promise((r) => setTimeout(r, 20));
69
+ logger.stageEnd("spec", { tasks: 5 });
70
+ await new Promise((r) => setTimeout(r, 50));
71
+
72
+ const json = await fs.readJson(path.join(tmpDir, ".ai-spec-logs", "test-run-003.json"));
73
+ const startEntry = json.entries.find((e: { event: string }) => e.event === "spec");
74
+ const endEntry = json.entries.find((e: { event: string }) => e.event === "spec:done");
75
+ expect(startEntry).toBeDefined();
76
+ expect(endEntry).toBeDefined();
77
+ expect(endEntry.data.durationMs).toBeGreaterThanOrEqual(0);
78
+ expect(endEntry.data.tasks).toBe(5);
79
+ });
80
+
81
+ it("records stage failures with error message", async () => {
82
+ const logger = new RunLogger(tmpDir, "test-run-004");
83
+ logger.stageStart("dsl");
84
+ logger.stageFail("dsl", "JSON parse error");
85
+ await new Promise((r) => setTimeout(r, 50));
86
+
87
+ const json = await fs.readJson(path.join(tmpDir, ".ai-spec-logs", "test-run-004.json"));
88
+ expect(json.errors).toContain("[dsl] JSON parse error");
89
+ const failEntry = json.entries.find((e: { event: string }) => e.event === "dsl:failed");
90
+ expect(failEntry).toBeDefined();
91
+ expect(failEntry.data.error).toBe("JSON parse error");
92
+ });
93
+
94
+ it("records promptHash via setPromptHash", async () => {
95
+ const logger = new RunLogger(tmpDir, "test-run-005");
96
+ logger.setPromptHash("abc12345");
97
+ await new Promise((r) => setTimeout(r, 50));
98
+
99
+ const json = await fs.readJson(path.join(tmpDir, ".ai-spec-logs", "test-run-005.json"));
100
+ expect(json.promptHash).toBe("abc12345");
101
+ });
102
+
103
+ it("records harnessScore via setHarnessScore", async () => {
104
+ const logger = new RunLogger(tmpDir, "test-run-006");
105
+ logger.setHarnessScore(7.5);
106
+ await new Promise((r) => setTimeout(r, 50));
107
+
108
+ const json = await fs.readJson(path.join(tmpDir, ".ai-spec-logs", "test-run-006.json"));
109
+ expect(json.harnessScore).toBe(7.5);
110
+ });
111
+
112
+ it("records filesWritten and deduplicates", async () => {
113
+ const logger = new RunLogger(tmpDir, "test-run-007");
114
+ logger.fileWritten("src/api/user.ts");
115
+ logger.fileWritten("src/api/user.ts"); // duplicate
116
+ logger.fileWritten("src/api/order.ts");
117
+ await new Promise((r) => setTimeout(r, 50));
118
+
119
+ const json = await fs.readJson(path.join(tmpDir, ".ai-spec-logs", "test-run-007.json"));
120
+ expect(json.filesWritten).toEqual(["src/api/user.ts", "src/api/order.ts"]);
121
+ });
122
+
123
+ it("finish() sets endedAt and totalDurationMs", async () => {
124
+ const logger = new RunLogger(tmpDir, "test-run-008");
125
+ await new Promise((r) => setTimeout(r, 20));
126
+ logger.finish();
127
+ await new Promise((r) => setTimeout(r, 50));
128
+
129
+ const json = await fs.readJson(path.join(tmpDir, ".ai-spec-logs", "test-run-008.json"));
130
+ expect(json.endedAt).toBeTruthy();
131
+ expect(json.totalDurationMs).toBeGreaterThanOrEqual(0);
132
+ });
133
+
134
+ it("printSummary does not throw", () => {
135
+ const logger = new RunLogger(tmpDir, "test-run-009");
136
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
137
+ logger.finish();
138
+ expect(() => logger.printSummary()).not.toThrow();
139
+ spy.mockRestore();
140
+ });
141
+
142
+ it("writes JSONL header on construction", async () => {
143
+ const logger = new RunLogger(tmpDir, "test-run-010", { provider: "claude" });
144
+ await new Promise((r) => setTimeout(r, 50));
145
+
146
+ const jsonlContent = fs.readFileSync(
147
+ path.join(tmpDir, ".ai-spec-logs", "test-run-010.jsonl"),
148
+ "utf-8"
149
+ );
150
+ const lines = jsonlContent.trim().split("\n");
151
+ const header = JSON.parse(lines[0]);
152
+ expect(header.type).toBe("header");
153
+ expect(header.runId).toBe("test-run-010");
154
+ expect(header.provider).toBe("claude");
155
+ });
156
+
157
+ it("appends entry/error/file/meta/footer lines to JSONL", async () => {
158
+ const logger = new RunLogger(tmpDir, "test-run-011");
159
+ await new Promise((r) => setTimeout(r, 30));
160
+ logger.stageStart("codegen");
161
+ logger.fileWritten("src/a.ts");
162
+ logger.stageFail("codegen", "timeout");
163
+ logger.setPromptHash("deadbeef");
164
+ logger.setHarnessScore(6.0);
165
+ logger.finish();
166
+ await new Promise((r) => setTimeout(r, 50));
167
+
168
+ const jsonlContent = fs.readFileSync(
169
+ path.join(tmpDir, ".ai-spec-logs", "test-run-011.jsonl"),
170
+ "utf-8"
171
+ );
172
+ const lines = jsonlContent.trim().split("\n").map((l) => JSON.parse(l));
173
+ const types = lines.map((l) => l.type);
174
+
175
+ expect(types).toContain("header");
176
+ expect(types).toContain("entry");
177
+ expect(types).toContain("file");
178
+ expect(types).toContain("error");
179
+ expect(types).toContain("meta");
180
+ expect(types).toContain("footer");
181
+ });
182
+ });
183
+
184
+ // ─── reconstructRunLogFromJsonl ─────────────────────────────────────────────
185
+
186
+ describe("reconstructRunLogFromJsonl", () => {
187
+ let tmpDir: string;
188
+
189
+ beforeEach(async () => {
190
+ tmpDir = path.join(os.tmpdir(), `rl-recon-${Date.now()}`);
191
+ await fs.ensureDir(tmpDir);
192
+ });
193
+
194
+ afterEach(async () => {
195
+ await fs.remove(tmpDir);
196
+ });
197
+
198
+ function writeJsonl(filename: string, lines: Record<string, unknown>[]) {
199
+ const content = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
200
+ fs.writeFileSync(path.join(tmpDir, filename), content);
201
+ }
202
+
203
+ it("reconstructs a complete RunLog from JSONL", () => {
204
+ writeJsonl("run.jsonl", [
205
+ { type: "header", runId: "r001", startedAt: "2026-04-01T00:00:00Z", workingDir: "/tmp", provider: "gemini", model: "pro" },
206
+ { type: "entry", ts: "2026-04-01T00:00:01Z", event: "spec" },
207
+ { type: "entry", ts: "2026-04-01T00:00:02Z", event: "spec:done", durationMs: 1000 },
208
+ { type: "file", path: "src/api/user.ts" },
209
+ { type: "error", message: "[codegen] timeout" },
210
+ { type: "meta", key: "promptHash", value: "abc123" },
211
+ { type: "meta", key: "harnessScore", value: 7.5 },
212
+ { type: "footer", endedAt: "2026-04-01T00:00:10Z", totalDurationMs: 10000, harnessScore: 7.5 },
213
+ ]);
214
+
215
+ const log = reconstructRunLogFromJsonl(path.join(tmpDir, "run.jsonl"));
216
+ expect(log).not.toBeNull();
217
+ expect(log!.runId).toBe("r001");
218
+ expect(log!.provider).toBe("gemini");
219
+ expect(log!.model).toBe("pro");
220
+ expect(log!.entries).toHaveLength(2);
221
+ expect(log!.entries[1].durationMs).toBe(1000);
222
+ expect(log!.filesWritten).toEqual(["src/api/user.ts"]);
223
+ expect(log!.errors).toEqual(["[codegen] timeout"]);
224
+ expect(log!.promptHash).toBe("abc123");
225
+ expect(log!.harnessScore).toBe(7.5);
226
+ expect(log!.endedAt).toBe("2026-04-01T00:00:10Z");
227
+ expect(log!.totalDurationMs).toBe(10000);
228
+ });
229
+
230
+ it("returns null for non-existent file", () => {
231
+ expect(reconstructRunLogFromJsonl("/no/such/file.jsonl")).toBeNull();
232
+ });
233
+
234
+ it("returns null when header is missing (no runId)", () => {
235
+ writeJsonl("no-header.jsonl", [
236
+ { type: "entry", ts: "2026-04-01T00:00:01Z", event: "spec" },
237
+ ]);
238
+ expect(reconstructRunLogFromJsonl(path.join(tmpDir, "no-header.jsonl"))).toBeNull();
239
+ });
240
+
241
+ it("skips corrupt JSON lines without crashing", () => {
242
+ const filePath = path.join(tmpDir, "corrupt.jsonl");
243
+ fs.writeFileSync(
244
+ filePath,
245
+ `{"type":"header","runId":"r002","startedAt":"2026-04-01T00:00:00Z","workingDir":"/tmp"}\n`
246
+ + `{not valid json}\n`
247
+ + `{"type":"entry","ts":"2026-04-01T00:00:01Z","event":"spec"}\n`
248
+ );
249
+ const log = reconstructRunLogFromJsonl(filePath);
250
+ expect(log).not.toBeNull();
251
+ expect(log!.entries).toHaveLength(1);
252
+ });
253
+
254
+ it("reconstructs partial log from crashed run (no footer)", () => {
255
+ writeJsonl("crashed.jsonl", [
256
+ { type: "header", runId: "r003", startedAt: "2026-04-01T00:00:00Z", workingDir: "/tmp" },
257
+ { type: "entry", ts: "2026-04-01T00:00:01Z", event: "spec" },
258
+ { type: "file", path: "src/a.ts" },
259
+ ]);
260
+ const log = reconstructRunLogFromJsonl(path.join(tmpDir, "crashed.jsonl"));
261
+ expect(log).not.toBeNull();
262
+ expect(log!.runId).toBe("r003");
263
+ expect(log!.entries).toHaveLength(1);
264
+ expect(log!.filesWritten).toEqual(["src/a.ts"]);
265
+ expect(log!.endedAt).toBeUndefined();
266
+ });
267
+
268
+ it("handles empty file", () => {
269
+ fs.writeFileSync(path.join(tmpDir, "empty.jsonl"), "");
270
+ expect(reconstructRunLogFromJsonl(path.join(tmpDir, "empty.jsonl"))).toBeNull();
271
+ });
272
+ });
273
+
274
+ // ─── Singleton accessors ────────────────────────────────────────────────────
275
+
276
+ describe("active logger singleton", () => {
277
+ it("get returns null by default", () => {
278
+ // Reset by setting null manually — singleton is module-level
279
+ setActiveLogger(null as unknown as RunLogger);
280
+ // getActiveLogger returns what was set
281
+ });
282
+
283
+ it("set/get round-trips", () => {
284
+ const tmpDir = os.tmpdir();
285
+ const logger = new RunLogger(tmpDir, "singleton-test");
286
+ setActiveLogger(logger);
287
+ expect(getActiveLogger()).toBe(logger);
288
+ });
289
+ });
@@ -0,0 +1,113 @@
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
+ RunSnapshot,
7
+ setActiveSnapshot,
8
+ getActiveSnapshot,
9
+ } from "../core/run-snapshot";
10
+
11
+ describe("RunSnapshot", () => {
12
+ let tmpDir: string;
13
+
14
+ beforeEach(async () => {
15
+ tmpDir = path.join(os.tmpdir(), `snap-test-${Date.now()}`);
16
+ await fs.ensureDir(tmpDir);
17
+ });
18
+
19
+ afterEach(async () => {
20
+ await fs.remove(tmpDir);
21
+ });
22
+
23
+ it("starts with fileCount = 0", () => {
24
+ const snap = new RunSnapshot(tmpDir, "run-001");
25
+ expect(snap.fileCount).toBe(0);
26
+ });
27
+
28
+ it("snapshotFile copies an existing file to backup dir", async () => {
29
+ const filePath = path.join(tmpDir, "src", "app.ts");
30
+ await fs.ensureDir(path.dirname(filePath));
31
+ await fs.writeFile(filePath, "original content", "utf-8");
32
+
33
+ const snap = new RunSnapshot(tmpDir, "run-002");
34
+ await snap.snapshotFile(filePath);
35
+
36
+ expect(snap.fileCount).toBe(1);
37
+ const backupPath = path.join(tmpDir, ".ai-spec-backup", "run-002", "src", "app.ts");
38
+ expect(await fs.pathExists(backupPath)).toBe(true);
39
+ expect(await fs.readFile(backupPath, "utf-8")).toBe("original content");
40
+ });
41
+
42
+ it("snapshotFile is no-op for non-existent files", async () => {
43
+ const snap = new RunSnapshot(tmpDir, "run-003");
44
+ await snap.snapshotFile(path.join(tmpDir, "does-not-exist.ts"));
45
+ expect(snap.fileCount).toBe(0);
46
+ });
47
+
48
+ it("snapshotFile deduplicates — only backs up once per file", async () => {
49
+ const filePath = path.join(tmpDir, "file.ts");
50
+ await fs.writeFile(filePath, "v1", "utf-8");
51
+
52
+ const snap = new RunSnapshot(tmpDir, "run-004");
53
+ await snap.snapshotFile(filePath);
54
+ // Overwrite the original
55
+ await fs.writeFile(filePath, "v2", "utf-8");
56
+ await snap.snapshotFile(filePath);
57
+
58
+ expect(snap.fileCount).toBe(1);
59
+ // Backup should still be v1
60
+ const backupPath = path.join(tmpDir, ".ai-spec-backup", "run-004", "file.ts");
61
+ expect(await fs.readFile(backupPath, "utf-8")).toBe("v1");
62
+ });
63
+
64
+ it("snapshotFile handles relative paths", async () => {
65
+ const filePath = path.join(tmpDir, "rel.ts");
66
+ await fs.writeFile(filePath, "relative", "utf-8");
67
+
68
+ const snap = new RunSnapshot(tmpDir, "run-005");
69
+ await snap.snapshotFile("rel.ts");
70
+
71
+ expect(snap.fileCount).toBe(1);
72
+ });
73
+
74
+ it("restore() restores all snapshotted files", async () => {
75
+ const f1 = path.join(tmpDir, "a.ts");
76
+ const f2 = path.join(tmpDir, "sub", "b.ts");
77
+ await fs.writeFile(f1, "original-a", "utf-8");
78
+ await fs.ensureDir(path.dirname(f2));
79
+ await fs.writeFile(f2, "original-b", "utf-8");
80
+
81
+ const snap = new RunSnapshot(tmpDir, "run-006");
82
+ await snap.snapshotFile(f1);
83
+ await snap.snapshotFile(f2);
84
+
85
+ // Overwrite originals
86
+ await fs.writeFile(f1, "modified-a", "utf-8");
87
+ await fs.writeFile(f2, "modified-b", "utf-8");
88
+
89
+ const restored = await snap.restore();
90
+ expect(restored).toHaveLength(2);
91
+ expect(await fs.readFile(f1, "utf-8")).toBe("original-a");
92
+ expect(await fs.readFile(f2, "utf-8")).toBe("original-b");
93
+ });
94
+
95
+ it("restore() returns empty array when no backup exists", async () => {
96
+ const snap = new RunSnapshot(tmpDir, "run-007");
97
+ const restored = await snap.restore();
98
+ expect(restored).toEqual([]);
99
+ });
100
+ });
101
+
102
+ describe("active snapshot singleton", () => {
103
+ it("set/get round-trips", () => {
104
+ const snap = new RunSnapshot(os.tmpdir(), "singleton-test");
105
+ setActiveSnapshot(snap);
106
+ expect(getActiveSnapshot()).toBe(snap);
107
+ });
108
+
109
+ it("returns null when not set", () => {
110
+ setActiveSnapshot(null as unknown as RunSnapshot);
111
+ expect(getActiveSnapshot()).toBeNull();
112
+ });
113
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { safeParseJson, parseJsonFromAiOutput } from "../core/safe-json";
3
+
4
+ describe("safeParseJson", () => {
5
+ it("parses bare JSON object", () => {
6
+ expect(safeParseJson('{"a": 1}')).toEqual({ a: 1 });
7
+ });
8
+
9
+ it("parses bare JSON array", () => {
10
+ expect(safeParseJson('[1, 2, 3]')).toEqual([1, 2, 3]);
11
+ });
12
+
13
+ it("parses fenced JSON", () => {
14
+ const input = "Here is the result:\n```json\n{\"x\": true}\n```\nDone.";
15
+ expect(safeParseJson(input)).toEqual({ x: true });
16
+ });
17
+
18
+ it("parses JSON embedded in text", () => {
19
+ const input = "The output is: {\"key\": \"value\"} and that's it.";
20
+ expect(safeParseJson(input)).toEqual({ key: "value" });
21
+ });
22
+
23
+ it("returns null for non-JSON text", () => {
24
+ expect(safeParseJson("hello world")).toBeNull();
25
+ });
26
+
27
+ it("returns null for malformed JSON", () => {
28
+ expect(safeParseJson("{broken")).toBeNull();
29
+ });
30
+
31
+ it("handles whitespace-wrapped JSON", () => {
32
+ expect(safeParseJson(" \n {\"ok\": true} \n ")).toEqual({ ok: true });
33
+ });
34
+
35
+ it("handles fenced JSON with language tag", () => {
36
+ const input = "```json\n[1, 2]\n```";
37
+ expect(safeParseJson(input)).toEqual([1, 2]);
38
+ });
39
+
40
+ it("supports generic type parameter", () => {
41
+ const result = safeParseJson<{ name: string }>('{"name": "test"}');
42
+ expect(result?.name).toBe("test");
43
+ });
44
+
45
+ it("handles embedded array in text", () => {
46
+ const input = 'The tasks are: [{"id": 1}, {"id": 2}] above.';
47
+ expect(safeParseJson(input)).toEqual([{ id: 1 }, { id: 2 }]);
48
+ });
49
+ });
50
+
51
+ describe("parseJsonFromAiOutput", () => {
52
+ it("returns parsed JSON on valid input", () => {
53
+ expect(parseJsonFromAiOutput('{"a": 1}')).toEqual({ a: 1 });
54
+ });
55
+
56
+ it("throws SyntaxError on invalid input", () => {
57
+ expect(() => parseJsonFromAiOutput("no json here")).toThrow(SyntaxError);
58
+ });
59
+
60
+ it("throws on completely empty input", () => {
61
+ expect(() => parseJsonFromAiOutput("")).toThrow(SyntaxError);
62
+ });
63
+ });
@@ -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 { SpecUpdater } from "../core/spec-updater";
6
+
7
+ describe("SpecUpdater.findLatestSpec", () => {
8
+ let tmpDir: string;
9
+
10
+ beforeEach(async () => {
11
+ tmpDir = path.join(os.tmpdir(), `su-test-${Date.now()}`);
12
+ await fs.ensureDir(tmpDir);
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await fs.remove(tmpDir);
17
+ });
18
+
19
+ it("returns null when specs dir does not exist", async () => {
20
+ const result = await SpecUpdater.findLatestSpec(path.join(tmpDir, "specs"));
21
+ expect(result).toBeNull();
22
+ });
23
+
24
+ it("returns null when no spec files match the pattern", async () => {
25
+ const specsDir = path.join(tmpDir, "specs");
26
+ await fs.ensureDir(specsDir);
27
+ await fs.writeFile(path.join(specsDir, "readme.md"), "not a spec");
28
+ const result = await SpecUpdater.findLatestSpec(specsDir);
29
+ expect(result).toBeNull();
30
+ });
31
+
32
+ it("finds the latest version", async () => {
33
+ const specsDir = path.join(tmpDir, "specs");
34
+ await fs.ensureDir(specsDir);
35
+ await fs.writeFile(path.join(specsDir, "feature-orders-v1.md"), "# v1 spec");
36
+ await fs.writeFile(path.join(specsDir, "feature-orders-v2.md"), "# v2 spec");
37
+ await fs.writeFile(path.join(specsDir, "feature-orders-v3.md"), "# v3 spec");
38
+
39
+ const result = await SpecUpdater.findLatestSpec(specsDir);
40
+ expect(result).not.toBeNull();
41
+ expect(result!.version).toBe(3);
42
+ expect(result!.slug).toBe("orders");
43
+ expect(result!.content).toBe("# v3 spec");
44
+ });
45
+
46
+ it("returns slug correctly from filename", async () => {
47
+ const specsDir = path.join(tmpDir, "specs");
48
+ await fs.ensureDir(specsDir);
49
+ await fs.writeFile(path.join(specsDir, "feature-user-auth-v1.md"), "# auth");
50
+
51
+ const result = await SpecUpdater.findLatestSpec(specsDir);
52
+ expect(result!.slug).toBe("user-auth");
53
+ });
54
+
55
+ it("handles multiple features and picks highest version per slug", async () => {
56
+ const specsDir = path.join(tmpDir, "specs");
57
+ await fs.ensureDir(specsDir);
58
+ await fs.writeFile(path.join(specsDir, "feature-orders-v1.md"), "orders v1");
59
+ await fs.writeFile(path.join(specsDir, "feature-auth-v5.md"), "auth v5");
60
+
61
+ const result = await SpecUpdater.findLatestSpec(specsDir);
62
+ // Should return the highest version across ALL features
63
+ expect(result!.version).toBe(5);
64
+ expect(result!.slug).toBe("auth");
65
+ });
66
+ });
67
+
68
+ describe("SpecUpdater.update", () => {
69
+ let tmpDir: string;
70
+ const mockProvider = {
71
+ generate: vi.fn(),
72
+ providerName: "test",
73
+ modelName: "test-model",
74
+ };
75
+ const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
76
+
77
+ beforeEach(async () => {
78
+ tmpDir = path.join(os.tmpdir(), `su-update-${Date.now()}`);
79
+ await fs.ensureDir(tmpDir);
80
+ mockProvider.generate.mockReset();
81
+ });
82
+
83
+ afterEach(async () => {
84
+ await fs.remove(tmpDir);
85
+ });
86
+
87
+ const validDslJson = JSON.stringify({
88
+ feature: { title: "Orders", description: "Updated" },
89
+ models: [],
90
+ endpoints: [],
91
+ behaviors: [],
92
+ });
93
+
94
+ it("generates updated spec and saves new version", async () => {
95
+ const specsDir = path.join(tmpDir, "specs");
96
+ await fs.ensureDir(specsDir);
97
+ const specPath = path.join(specsDir, "feature-orders-v1.md");
98
+ await fs.writeFile(specPath, "# Original Spec\n\nExisting content.");
99
+
100
+ // Mock: 1) spec update, 2+) DslExtractor.extract (retries on validation)
101
+ mockProvider.generate
102
+ .mockResolvedValueOnce("# Updated Spec\n\nNew content.")
103
+ .mockResolvedValue(validDslJson);
104
+
105
+ const updater = new SpecUpdater(mockProvider);
106
+ const result = await updater.update("Add pagination", specPath, tmpDir);
107
+
108
+ expect(result.newVersion).toBe(2);
109
+ expect(result.newSpecPath).toContain("feature-orders-v2.md");
110
+ expect(await fs.readFile(result.newSpecPath, "utf-8")).toContain("Updated Spec");
111
+ });
112
+
113
+ it("returns null DSL when extraction fails", async () => {
114
+ const specsDir = path.join(tmpDir, "specs");
115
+ await fs.ensureDir(specsDir);
116
+ const specPath = path.join(specsDir, "feature-test-v1.md");
117
+ await fs.writeFile(specPath, "# Spec");
118
+
119
+ // Mock: 1) spec update, 2+3) DslExtractor.extract retries → all invalid
120
+ mockProvider.generate
121
+ .mockResolvedValueOnce("# Updated Spec")
122
+ .mockResolvedValue("not json at all");
123
+
124
+ const updater = new SpecUpdater(mockProvider);
125
+ const result = await updater.update("Change something", specPath, tmpDir);
126
+
127
+ expect(result.newSpecPath).toContain("v2.md");
128
+ expect(result.updatedDsl).toBeNull();
129
+ expect(result.newDslPath).toBeNull();
130
+ });
131
+
132
+ it("throws when spec generation fails", async () => {
133
+ const specsDir = path.join(tmpDir, "specs");
134
+ await fs.ensureDir(specsDir);
135
+ const specPath = path.join(specsDir, "feature-fail-v1.md");
136
+ await fs.writeFile(specPath, "# Spec");
137
+
138
+ mockProvider.generate.mockRejectedValueOnce(new Error("API down"));
139
+
140
+ const updater = new SpecUpdater(mockProvider);
141
+ await expect(updater.update("Change", specPath, tmpDir)).rejects.toThrow("Spec update generation failed");
142
+ });
143
+
144
+ it("strips markdown fences from AI output", async () => {
145
+ const specsDir = path.join(tmpDir, "specs");
146
+ await fs.ensureDir(specsDir);
147
+ const specPath = path.join(specsDir, "feature-fenced-v1.md");
148
+ await fs.writeFile(specPath, "# Spec");
149
+
150
+ // Mock: 1) spec update (fenced), 2+) DslExtractor.extract retries → invalid
151
+ mockProvider.generate
152
+ .mockResolvedValueOnce("```markdown\n# Clean Spec\n```")
153
+ .mockResolvedValue("not valid dsl");
154
+
155
+ const updater = new SpecUpdater(mockProvider);
156
+ const result = await updater.update("Update", specPath, tmpDir);
157
+ const content = await fs.readFile(result.newSpecPath, "utf-8");
158
+ expect(content).not.toContain("```");
159
+ expect(content).toContain("# Clean Spec");
160
+ });
161
+ });