ai-spec-dev 0.31.0 → 0.35.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 (70) hide show
  1. package/.claude/commands/add-lesson.md +34 -0
  2. package/.claude/commands/check-layers.md +65 -0
  3. package/.claude/commands/installed-deps.md +35 -0
  4. package/.claude/commands/recall-lessons.md +40 -0
  5. package/.claude/commands/scan-singletons.md +45 -0
  6. package/.claude/commands/verify-imports.md +48 -0
  7. package/.claude/settings.local.json +15 -1
  8. package/README.md +531 -213
  9. package/RELEASE_LOG.md +460 -0
  10. package/cli/commands/config.ts +93 -0
  11. package/cli/commands/create.ts +1233 -0
  12. package/cli/commands/dashboard.ts +62 -0
  13. package/cli/commands/export.ts +66 -0
  14. package/cli/commands/init.ts +190 -0
  15. package/cli/commands/learn.ts +30 -0
  16. package/cli/commands/logs.ts +106 -0
  17. package/cli/commands/mock.ts +175 -0
  18. package/cli/commands/model.ts +156 -0
  19. package/cli/commands/restore.ts +22 -0
  20. package/cli/commands/review.ts +63 -0
  21. package/cli/commands/scan.ts +99 -0
  22. package/cli/commands/trend.ts +36 -0
  23. package/cli/commands/types.ts +69 -0
  24. package/cli/commands/update.ts +178 -0
  25. package/cli/commands/vcr.ts +70 -0
  26. package/cli/commands/workspace.ts +219 -0
  27. package/cli/index.ts +34 -2240
  28. package/cli/utils.ts +83 -0
  29. package/core/combined-generator.ts +13 -3
  30. package/core/dashboard-generator.ts +340 -0
  31. package/core/design-dialogue.ts +124 -0
  32. package/core/dsl-feedback.ts +285 -0
  33. package/core/error-feedback.ts +46 -2
  34. package/core/project-index.ts +301 -0
  35. package/core/reviewer.ts +84 -6
  36. package/core/run-logger.ts +109 -3
  37. package/core/run-trend.ts +261 -0
  38. package/core/self-evaluator.ts +139 -7
  39. package/core/spec-generator.ts +14 -8
  40. package/core/task-generator.ts +17 -0
  41. package/core/types-generator.ts +219 -0
  42. package/core/vcr.ts +210 -0
  43. package/dist/cli/index.js +6692 -4512
  44. package/dist/cli/index.js.map +1 -1
  45. package/dist/cli/index.mjs +6692 -4512
  46. package/dist/cli/index.mjs.map +1 -1
  47. package/dist/index.d.mts +19 -5
  48. package/dist/index.d.ts +19 -5
  49. package/dist/index.js +420 -224
  50. package/dist/index.js.map +1 -1
  51. package/dist/index.mjs +418 -224
  52. package/dist/index.mjs.map +1 -1
  53. package/docs-assets/purpose/architecture-overview.svg +64 -0
  54. package/docs-assets/purpose/create-pipeline.svg +113 -0
  55. package/docs-assets/purpose/task-layering.svg +74 -0
  56. package/package.json +6 -3
  57. package/prompts/codegen.prompt.ts +97 -9
  58. package/prompts/design.prompt.ts +59 -0
  59. package/prompts/spec.prompt.ts +8 -1
  60. package/prompts/tasks.prompt.ts +27 -2
  61. package/purpose.md +600 -174
  62. package/tests/dsl-extractor.test.ts +264 -0
  63. package/tests/dsl-feedback.test.ts +266 -0
  64. package/tests/dsl-validator.test.ts +283 -0
  65. package/tests/error-feedback.test.ts +292 -0
  66. package/tests/provider-utils.test.ts +173 -0
  67. package/tests/run-trend.test.ts +186 -0
  68. package/tests/self-evaluator.test.ts +339 -0
  69. package/tests/spec-assessor.test.ts +142 -0
  70. package/tests/task-generator.test.ts +230 -0
@@ -0,0 +1,339 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { runSelfEval, modelNameTokens } from "../core/self-evaluator";
3
+ import type { RunLogger } from "../core/run-logger";
4
+ import type { SpecDSL } from "../core/dsl-types";
5
+
6
+ // ─── Stub logger (no filesystem I/O in tests) ─────────────────────────────────
7
+
8
+ const stubLogger = {
9
+ setHarnessScore: vi.fn(),
10
+ stageEnd: vi.fn(),
11
+ stageStart: vi.fn(),
12
+ stageFail: vi.fn(),
13
+ fileWritten: vi.fn(),
14
+ finish: vi.fn(),
15
+ printSummary: vi.fn(),
16
+ } as unknown as RunLogger;
17
+
18
+ // ─── DSL fixtures ─────────────────────────────────────────────────────────────
19
+
20
+ const BASE_DSL: SpecDSL = {
21
+ version: "1.0",
22
+ feature: { id: "order", title: "Order", description: "Order management" },
23
+ models: [
24
+ {
25
+ name: "Order",
26
+ fields: [
27
+ { name: "id", type: "String", required: true },
28
+ { name: "status", type: "String", required: true },
29
+ ],
30
+ },
31
+ {
32
+ name: "OrderItem",
33
+ fields: [
34
+ { name: "id", type: "String", required: true },
35
+ { name: "quantity", type: "Int", required: true },
36
+ ],
37
+ },
38
+ ],
39
+ endpoints: [
40
+ {
41
+ id: "EP-001", method: "POST", path: "/api/orders",
42
+ description: "Create order", auth: true,
43
+ successStatus: 201, successDescription: "Order created",
44
+ },
45
+ {
46
+ id: "EP-002", method: "GET", path: "/api/orders/:id",
47
+ description: "Get order", auth: true,
48
+ successStatus: 200, successDescription: "Order returned",
49
+ },
50
+ ],
51
+ behaviors: [],
52
+ };
53
+
54
+ const REVIEW_WITH_SCORE = "## Architecture\nLooks good.\nScore: 8/10\n---\n## Implementation\nMostly OK.\nScore: 7/10";
55
+
56
+ // ─── modelNameTokens ──────────────────────────────────────────────────────────
57
+
58
+ describe("modelNameTokens", () => {
59
+ it("returns lowercase version of simple name", () => {
60
+ expect(modelNameTokens("User")).toContain("user");
61
+ });
62
+
63
+ it("returns kebab-case for PascalCase compound name", () => {
64
+ expect(modelNameTokens("OrderItem")).toContain("order-item");
65
+ });
66
+
67
+ it("returns snake_case for PascalCase compound name", () => {
68
+ expect(modelNameTokens("OrderItem")).toContain("order_item");
69
+ });
70
+
71
+ it("returns flat lowercase for PascalCase compound name", () => {
72
+ expect(modelNameTokens("OrderItem")).toContain("orderitem");
73
+ });
74
+
75
+ it("handles single-word names without extra tokens", () => {
76
+ const tokens = modelNameTokens("User");
77
+ expect(tokens).toEqual(["user"]);
78
+ });
79
+
80
+ it("handles three-word compound names", () => {
81
+ const tokens = modelNameTokens("UserOrderItem");
82
+ expect(tokens).toContain("user-order-item");
83
+ expect(tokens).toContain("user_order_item");
84
+ });
85
+ });
86
+
87
+ // ─── runSelfEval — DSL Coverage scoring ───────────────────────────────────────
88
+
89
+ describe("runSelfEval — dslCoverageScore", () => {
90
+ it("scores 0 when no files were generated", () => {
91
+ const result = runSelfEval({
92
+ dsl: BASE_DSL,
93
+ generatedFiles: [],
94
+ compilePassed: true,
95
+ reviewText: "",
96
+ promptHash: "abc123",
97
+ logger: stubLogger,
98
+ });
99
+ expect(result.dslCoverageScore).toBe(0);
100
+ });
101
+
102
+ it("scores 10 when endpoint layer + model layer both covered and models matched", () => {
103
+ const result = runSelfEval({
104
+ dsl: BASE_DSL,
105
+ generatedFiles: [
106
+ "src/api/order.ts", // endpoint layer
107
+ "src/models/order.ts", // model layer + matches "Order"
108
+ "src/models/orderItem.ts", // matches "OrderItem"
109
+ ],
110
+ compilePassed: true,
111
+ reviewText: "",
112
+ promptHash: "abc123",
113
+ logger: stubLogger,
114
+ });
115
+ expect(result.dslCoverageScore).toBe(10);
116
+ });
117
+
118
+ it("deducts 4 when endpoint layer is missing but endpoints declared", () => {
119
+ const result = runSelfEval({
120
+ dsl: BASE_DSL,
121
+ generatedFiles: ["src/models/order.ts", "src/models/order-item.ts"],
122
+ compilePassed: true,
123
+ reviewText: "",
124
+ promptHash: "abc123",
125
+ logger: stubLogger,
126
+ });
127
+ expect(result.dslCoverageScore).toBeLessThanOrEqual(6); // 10 - 4
128
+ });
129
+
130
+ it("deducts 3 when model layer is missing but models declared", () => {
131
+ const result = runSelfEval({
132
+ dsl: BASE_DSL,
133
+ generatedFiles: ["src/api/order.ts", "src/routes/order.ts"],
134
+ compilePassed: true,
135
+ reviewText: "",
136
+ promptHash: "abc123",
137
+ logger: stubLogger,
138
+ });
139
+ // -3 for missing model layer, also model name coverage penalty
140
+ expect(result.dslCoverageScore).toBeLessThanOrEqual(7);
141
+ });
142
+
143
+ it("deducts 2 when model name coverage < 50%", () => {
144
+ // 2 models declared (Order, OrderItem), 0 matched in file paths
145
+ const result = runSelfEval({
146
+ dsl: BASE_DSL,
147
+ generatedFiles: ["src/api/endpoint.ts", "src/models/schema.ts"],
148
+ compilePassed: true,
149
+ reviewText: "",
150
+ promptHash: "abc123",
151
+ logger: stubLogger,
152
+ });
153
+ // Model layer exists (src/models/schema.ts) but 0/2 names matched → -2
154
+ expect(result.detail.modelNameCoverage).toBe(0);
155
+ expect(result.dslCoverageScore).toBeLessThanOrEqual(8);
156
+ });
157
+
158
+ it("deducts 1 when model name coverage is 50–79%", () => {
159
+ // 2 models (Order, OrderItem), only Order matched
160
+ const result = runSelfEval({
161
+ dsl: BASE_DSL,
162
+ generatedFiles: ["src/api/order.ts", "src/models/order.ts"],
163
+ compilePassed: true,
164
+ reviewText: "",
165
+ promptHash: "abc123",
166
+ logger: stubLogger,
167
+ });
168
+ expect(result.detail.modelNameMatched).toBe(1); // "order" matches "Order"
169
+ expect(result.detail.modelNameCoverage).toBe(0.5);
170
+ // -1 for 50% coverage
171
+ expect(result.dslCoverageScore).toBeLessThanOrEqual(9);
172
+ });
173
+
174
+ it("deducts 1 for endpoint file adequacy when ≥5 endpoints but only 1 layer file", () => {
175
+ const manyEndpoints: SpecDSL = {
176
+ ...BASE_DSL,
177
+ endpoints: Array.from({ length: 5 }, (_, i) => ({
178
+ id: `EP-00${i + 1}`, method: "GET" as const,
179
+ path: `/api/resource/${i}`, description: `Resource ${i} endpoint description`,
180
+ auth: true, successStatus: 200, successDescription: "OK",
181
+ })),
182
+ };
183
+ const result = runSelfEval({
184
+ dsl: manyEndpoints,
185
+ generatedFiles: ["src/api/resource.ts", "src/models/resource.ts"],
186
+ compilePassed: true,
187
+ reviewText: "",
188
+ promptHash: "abc123",
189
+ logger: stubLogger,
190
+ });
191
+ // Only 1 endpoint-layer file for 5 endpoints → -1
192
+ expect(result.detail.endpointLayerFiles).toBe(1);
193
+ expect(result.dslCoverageScore).toBeLessThanOrEqual(9);
194
+ });
195
+
196
+ it("dslCoverageScore is always clamped to [0, 10]", () => {
197
+ const result = runSelfEval({
198
+ dsl: BASE_DSL,
199
+ generatedFiles: ["src/utils/helper.ts"], // no endpoint/model layer
200
+ compilePassed: false,
201
+ reviewText: "",
202
+ promptHash: "abc123",
203
+ logger: stubLogger,
204
+ });
205
+ expect(result.dslCoverageScore).toBeGreaterThanOrEqual(0);
206
+ expect(result.dslCoverageScore).toBeLessThanOrEqual(10);
207
+ });
208
+
209
+ it("ignores model name coverage when model layer is missing (no double penalty)", () => {
210
+ // If model layer is missing, we already deducted 3; don't also deduct for name coverage
211
+ const result = runSelfEval({
212
+ dsl: BASE_DSL,
213
+ generatedFiles: ["src/api/order.ts"], // endpoint layer only, no model layer
214
+ compilePassed: true,
215
+ reviewText: "",
216
+ promptHash: "abc123",
217
+ logger: stubLogger,
218
+ });
219
+ // -3 for missing model layer, no additional -2 since modelLayerCovered = false
220
+ expect(result.dslCoverageScore).toBe(7);
221
+ });
222
+ });
223
+
224
+ // ─── runSelfEval — compileScore ───────────────────────────────────────────────
225
+
226
+ describe("runSelfEval — compileScore", () => {
227
+ const FILES = ["src/api/order.ts", "src/models/order.ts"];
228
+
229
+ it("scores 10 when compilePassed is true", () => {
230
+ const result = runSelfEval({
231
+ dsl: null, generatedFiles: FILES,
232
+ compilePassed: true, reviewText: "", promptHash: "x", logger: stubLogger,
233
+ });
234
+ expect(result.compileScore).toBe(10);
235
+ });
236
+
237
+ it("scores 5 when compilePassed is false", () => {
238
+ const result = runSelfEval({
239
+ dsl: null, generatedFiles: FILES,
240
+ compilePassed: false, reviewText: "", promptHash: "x", logger: stubLogger,
241
+ });
242
+ expect(result.compileScore).toBe(5);
243
+ });
244
+ });
245
+
246
+ // ─── runSelfEval — reviewScore ────────────────────────────────────────────────
247
+
248
+ describe("runSelfEval — reviewScore", () => {
249
+ const FILES = ["src/api/order.ts"];
250
+
251
+ it("extracts score from review text", () => {
252
+ const result = runSelfEval({
253
+ dsl: null, generatedFiles: FILES,
254
+ compilePassed: true, reviewText: REVIEW_WITH_SCORE, promptHash: "x", logger: stubLogger,
255
+ });
256
+ // extractReviewScore picks the first "Score: X/10" match
257
+ expect(result.reviewScore).toBeCloseTo(8, 0);
258
+ });
259
+
260
+ it("returns null reviewScore when review text is empty", () => {
261
+ const result = runSelfEval({
262
+ dsl: null, generatedFiles: FILES,
263
+ compilePassed: true, reviewText: "", promptHash: "x", logger: stubLogger,
264
+ });
265
+ expect(result.reviewScore).toBeNull();
266
+ });
267
+
268
+ it("returns null reviewScore when no Score: pattern present", () => {
269
+ const result = runSelfEval({
270
+ dsl: null, generatedFiles: FILES,
271
+ compilePassed: true, reviewText: "Looks good overall.", promptHash: "x", logger: stubLogger,
272
+ });
273
+ expect(result.reviewScore).toBeNull();
274
+ });
275
+ });
276
+
277
+ // ─── runSelfEval — harnessScore weighted average ─────────────────────────────
278
+
279
+ describe("runSelfEval — harnessScore", () => {
280
+ it("uses DSL×0.55 + Compile×0.45 weights when review is skipped", () => {
281
+ const result = runSelfEval({
282
+ dsl: null,
283
+ generatedFiles: ["src/api/x.ts"],
284
+ compilePassed: true, // compileScore = 10
285
+ reviewText: "", // reviewScore = null
286
+ promptHash: "x",
287
+ logger: stubLogger,
288
+ });
289
+ // dslCoverageScore = 10 (no DSL declared), compileScore = 10
290
+ const expected = Math.round((10 * 0.55 + 10 * 0.45) * 10) / 10;
291
+ expect(result.harnessScore).toBe(expected);
292
+ });
293
+
294
+ it("uses DSL×0.4 + Compile×0.3 + Review×0.3 weights when review is present", () => {
295
+ // Force predictable scores: dslCoverage=10, compile=10, review=8
296
+ const result = runSelfEval({
297
+ dsl: null,
298
+ generatedFiles: ["src/api/x.ts"],
299
+ compilePassed: true,
300
+ reviewText: "Score: 8/10",
301
+ promptHash: "x",
302
+ logger: stubLogger,
303
+ });
304
+ const expected = Math.round((10 * 0.4 + 10 * 0.3 + 8 * 0.3) * 10) / 10;
305
+ expect(result.harnessScore).toBe(expected);
306
+ });
307
+
308
+ it("harnessScore is always in [0, 10]", () => {
309
+ const result = runSelfEval({
310
+ dsl: BASE_DSL,
311
+ generatedFiles: [], // worst case
312
+ compilePassed: false,
313
+ reviewText: "Score: 1/10",
314
+ promptHash: "x",
315
+ logger: stubLogger,
316
+ });
317
+ expect(result.harnessScore).toBeGreaterThanOrEqual(0);
318
+ expect(result.harnessScore).toBeLessThanOrEqual(10);
319
+ });
320
+
321
+ it("records promptHash in result", () => {
322
+ const result = runSelfEval({
323
+ dsl: null, generatedFiles: [],
324
+ compilePassed: false, reviewText: "",
325
+ promptHash: "deadbeef", logger: stubLogger,
326
+ });
327
+ expect(result.promptHash).toBe("deadbeef");
328
+ });
329
+
330
+ it("calls logger.setHarnessScore with the computed score", () => {
331
+ const logger = { ...stubLogger, setHarnessScore: vi.fn(), stageEnd: vi.fn() } as unknown as RunLogger;
332
+ const result = runSelfEval({
333
+ dsl: null, generatedFiles: ["src/api/x.ts"],
334
+ compilePassed: true, reviewText: "",
335
+ promptHash: "x", logger,
336
+ });
337
+ expect(logger.setHarnessScore).toHaveBeenCalledWith(result.harnessScore);
338
+ });
339
+ });
@@ -0,0 +1,142 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { assessSpec, printSpecAssessment, SpecAssessment } from "../core/spec-assessor";
3
+ import type { AIProvider } from "../core/spec-generator";
4
+
5
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
6
+
7
+ const VALID_ASSESSMENT: SpecAssessment = {
8
+ coverageScore: 8,
9
+ clarityScore: 7,
10
+ constitutionScore: 9,
11
+ overallScore: 8,
12
+ issues: ["Missing rate-limit error handling"],
13
+ suggestions: ["Add 429 Too Many Requests to login endpoint"],
14
+ dslExtractable: true,
15
+ };
16
+
17
+ function makeProvider(response: string): AIProvider {
18
+ return { generate: vi.fn().mockResolvedValue(response) };
19
+ }
20
+
21
+ // ─── assessSpec — JSON parsing ────────────────────────────────────────────────
22
+
23
+ describe("assessSpec — JSON parsing", () => {
24
+ it("parses bare JSON returned by the provider", async () => {
25
+ const provider = makeProvider(JSON.stringify(VALID_ASSESSMENT));
26
+ const result = await assessSpec(provider, "spec content");
27
+ expect(result).toMatchObject({
28
+ coverageScore: 8,
29
+ clarityScore: 7,
30
+ overallScore: 8,
31
+ dslExtractable: true,
32
+ });
33
+ });
34
+
35
+ it("parses JSON wrapped in markdown code fence", async () => {
36
+ const fenced = "```json\n" + JSON.stringify(VALID_ASSESSMENT) + "\n```";
37
+ const provider = makeProvider(fenced);
38
+ const result = await assessSpec(provider, "spec content");
39
+ expect(result?.overallScore).toBe(8);
40
+ });
41
+
42
+ it("parses JSON wrapped in plain code fence (no language tag)", async () => {
43
+ const fenced = "```\n" + JSON.stringify(VALID_ASSESSMENT) + "\n```";
44
+ const provider = makeProvider(fenced);
45
+ const result = await assessSpec(provider, "spec content");
46
+ expect(result?.coverageScore).toBe(8);
47
+ });
48
+
49
+ it("returns null when provider returns invalid JSON", async () => {
50
+ const provider = makeProvider("This is not JSON at all.");
51
+ const result = await assessSpec(provider, "spec content");
52
+ expect(result).toBeNull();
53
+ });
54
+
55
+ it("returns null when JSON is missing required score fields", async () => {
56
+ const provider = makeProvider(JSON.stringify({ issues: [], suggestions: [] }));
57
+ const result = await assessSpec(provider, "spec content");
58
+ expect(result).toBeNull();
59
+ });
60
+
61
+ it("returns null when provider throws", async () => {
62
+ const provider: AIProvider = { generate: vi.fn().mockRejectedValue(new Error("network error")) };
63
+ const result = await assessSpec(provider, "spec content");
64
+ expect(result).toBeNull();
65
+ });
66
+ });
67
+
68
+ // ─── assessSpec — prompt construction ────────────────────────────────────────
69
+
70
+ describe("assessSpec — prompt construction", () => {
71
+ it("includes spec content in the prompt", async () => {
72
+ const provider = makeProvider(JSON.stringify(VALID_ASSESSMENT));
73
+ await assessSpec(provider, "MY SPEC CONTENT");
74
+ const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
75
+ expect(prompt).toContain("MY SPEC CONTENT");
76
+ });
77
+
78
+ it("includes constitution in the prompt when provided", async () => {
79
+ const provider = makeProvider(JSON.stringify(VALID_ASSESSMENT));
80
+ await assessSpec(provider, "spec", "USE_SNAKE_CASE");
81
+ const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
82
+ expect(prompt).toContain("USE_SNAKE_CASE");
83
+ });
84
+
85
+ it("does not mention constitution when not provided", async () => {
86
+ const provider = makeProvider(JSON.stringify(VALID_ASSESSMENT));
87
+ await assessSpec(provider, "spec");
88
+ const [prompt] = (provider.generate as ReturnType<typeof vi.fn>).mock.calls[0];
89
+ expect(prompt).not.toContain("Project Constitution");
90
+ });
91
+ });
92
+
93
+ // ─── printSpecAssessment ──────────────────────────────────────────────────────
94
+
95
+ describe("printSpecAssessment", () => {
96
+ it("runs without throwing for a high-score assessment", () => {
97
+ const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
98
+ expect(() => printSpecAssessment(VALID_ASSESSMENT)).not.toThrow();
99
+ consoleSpy.mockRestore();
100
+ });
101
+
102
+ it("shows DSL warning when dslExtractable is false", () => {
103
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
104
+ printSpecAssessment({ ...VALID_ASSESSMENT, dslExtractable: false });
105
+ const allOutput = spy.mock.calls.map((c) => c.join(" ")).join("\n");
106
+ expect(allOutput).toMatch(/unreliable|DSL extraction/i);
107
+ spy.mockRestore();
108
+ });
109
+
110
+ it("does not show DSL warning when dslExtractable is true", () => {
111
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
112
+ printSpecAssessment({ ...VALID_ASSESSMENT, dslExtractable: true });
113
+ const allOutput = spy.mock.calls.map((c) => c.join(" ")).join("\n");
114
+ expect(allOutput).not.toMatch(/unreliable/i);
115
+ spy.mockRestore();
116
+ });
117
+
118
+ it("prints issues when present", () => {
119
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
120
+ printSpecAssessment({ ...VALID_ASSESSMENT, issues: ["Issue A", "Issue B"] });
121
+ const allOutput = spy.mock.calls.map((c) => c.join(" ")).join("\n");
122
+ expect(allOutput).toContain("Issue A");
123
+ expect(allOutput).toContain("Issue B");
124
+ spy.mockRestore();
125
+ });
126
+
127
+ it("prints suggestions when present", () => {
128
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
129
+ printSpecAssessment({ ...VALID_ASSESSMENT, suggestions: ["Try this"] });
130
+ const allOutput = spy.mock.calls.map((c) => c.join(" ")).join("\n");
131
+ expect(allOutput).toContain("Try this");
132
+ spy.mockRestore();
133
+ });
134
+
135
+ it("handles empty issues and suggestions gracefully", () => {
136
+ const spy = vi.spyOn(console, "log").mockImplementation(() => {});
137
+ expect(() =>
138
+ printSpecAssessment({ ...VALID_ASSESSMENT, issues: [], suggestions: [] })
139
+ ).not.toThrow();
140
+ spy.mockRestore();
141
+ });
142
+ });