agentic-forge 0.0.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 (110) hide show
  1. package/.gitattributes +24 -0
  2. package/.github/workflows/ci.yml +70 -0
  3. package/.markdownlint-cli2.jsonc +16 -0
  4. package/.prettierignore +3 -0
  5. package/.prettierrc +6 -0
  6. package/.vscode/agentic-forge.code-workspace +26 -0
  7. package/CHANGELOG.md +100 -0
  8. package/CLAUDE.md +158 -0
  9. package/CONTRIBUTING.md +152 -0
  10. package/LICENSE +21 -0
  11. package/README.md +145 -0
  12. package/agentic-forge-banner.png +0 -0
  13. package/biome.json +21 -0
  14. package/package.json +5 -0
  15. package/scripts/copy-assets.js +21 -0
  16. package/src/agents/explorer.md +97 -0
  17. package/src/agents/reviewer.md +137 -0
  18. package/src/checkpoints/manager.ts +119 -0
  19. package/src/claude/.claude/skills/analyze/SKILL.md +241 -0
  20. package/src/claude/.claude/skills/analyze/references/bug.md +62 -0
  21. package/src/claude/.claude/skills/analyze/references/debt.md +76 -0
  22. package/src/claude/.claude/skills/analyze/references/doc.md +67 -0
  23. package/src/claude/.claude/skills/analyze/references/security.md +76 -0
  24. package/src/claude/.claude/skills/analyze/references/style.md +72 -0
  25. package/src/claude/.claude/skills/create-checkpoint/SKILL.md +88 -0
  26. package/src/claude/.claude/skills/create-log/SKILL.md +75 -0
  27. package/src/claude/.claude/skills/fix-analyze/SKILL.md +102 -0
  28. package/src/claude/.claude/skills/git-branch/SKILL.md +71 -0
  29. package/src/claude/.claude/skills/git-commit/SKILL.md +107 -0
  30. package/src/claude/.claude/skills/git-pr/SKILL.md +96 -0
  31. package/src/claude/.claude/skills/orchestrate/SKILL.md +120 -0
  32. package/src/claude/.claude/skills/sdlc-plan/SKILL.md +163 -0
  33. package/src/claude/.claude/skills/sdlc-plan/references/bug.md +115 -0
  34. package/src/claude/.claude/skills/sdlc-plan/references/chore.md +105 -0
  35. package/src/claude/.claude/skills/sdlc-plan/references/feature.md +130 -0
  36. package/src/claude/.claude/skills/sdlc-review/SKILL.md +215 -0
  37. package/src/claude/.claude/skills/workflow-builder/SKILL.md +185 -0
  38. package/src/claude/.claude/skills/workflow-builder/references/REFERENCE.md +487 -0
  39. package/src/claude/.claude/skills/workflow-builder/references/workflow-example.yaml +427 -0
  40. package/src/cli.ts +182 -0
  41. package/src/commands/config-cmd.ts +28 -0
  42. package/src/commands/index.ts +21 -0
  43. package/src/commands/init.ts +96 -0
  44. package/src/commands/release-notes.ts +85 -0
  45. package/src/commands/resume.ts +103 -0
  46. package/src/commands/run.ts +234 -0
  47. package/src/commands/shortcuts.ts +11 -0
  48. package/src/commands/skills-dir.ts +11 -0
  49. package/src/commands/status.ts +112 -0
  50. package/src/commands/update.ts +64 -0
  51. package/src/commands/version.ts +27 -0
  52. package/src/commands/workflows.ts +129 -0
  53. package/src/config.ts +129 -0
  54. package/src/console.ts +790 -0
  55. package/src/executor.ts +354 -0
  56. package/src/git/worktree.ts +236 -0
  57. package/src/logging/logger.ts +95 -0
  58. package/src/orchestrator.ts +815 -0
  59. package/src/parser.ts +225 -0
  60. package/src/progress.ts +306 -0
  61. package/src/prompts/agentic-system.md +31 -0
  62. package/src/ralph-loop.ts +260 -0
  63. package/src/renderer.ts +164 -0
  64. package/src/runner.ts +634 -0
  65. package/src/signal-manager.ts +55 -0
  66. package/src/steps/base.ts +71 -0
  67. package/src/steps/conditional-step.ts +144 -0
  68. package/src/steps/index.ts +15 -0
  69. package/src/steps/parallel-step.ts +213 -0
  70. package/src/steps/prompt-step.ts +121 -0
  71. package/src/steps/ralph-loop-step.ts +186 -0
  72. package/src/steps/serial-step.ts +84 -0
  73. package/src/templates/analysis/bug.md.j2 +35 -0
  74. package/src/templates/analysis/debt.md.j2 +38 -0
  75. package/src/templates/analysis/doc.md.j2 +45 -0
  76. package/src/templates/analysis/security.md.j2 +35 -0
  77. package/src/templates/analysis/style.md.j2 +44 -0
  78. package/src/templates/analysis-summary.md.j2 +58 -0
  79. package/src/templates/checkpoint.md.j2 +27 -0
  80. package/src/templates/implementation-report.md.j2 +81 -0
  81. package/src/templates/memory.md.j2 +16 -0
  82. package/src/templates/plan-bug.md.j2 +42 -0
  83. package/src/templates/plan-chore.md.j2 +27 -0
  84. package/src/templates/plan-feature.md.j2 +41 -0
  85. package/src/templates/progress.json.j2 +16 -0
  86. package/src/templates/ralph-report.md.j2 +45 -0
  87. package/src/types.ts +141 -0
  88. package/src/workflows/analyze-codebase-merge.yaml +328 -0
  89. package/src/workflows/analyze-codebase.yaml +196 -0
  90. package/src/workflows/analyze-single.yaml +56 -0
  91. package/src/workflows/demo.yaml +180 -0
  92. package/src/workflows/one-shot.yaml +54 -0
  93. package/src/workflows/plan-build-review.yaml +160 -0
  94. package/src/workflows/ralph-loop.yaml +73 -0
  95. package/tests/config.test.ts +219 -0
  96. package/tests/console.test.ts +506 -0
  97. package/tests/executor.test.ts +339 -0
  98. package/tests/init.test.ts +86 -0
  99. package/tests/logger.test.ts +110 -0
  100. package/tests/parser.test.ts +290 -0
  101. package/tests/progress.test.ts +345 -0
  102. package/tests/ralph-loop.test.ts +418 -0
  103. package/tests/renderer.test.ts +350 -0
  104. package/tests/runner.test.ts +497 -0
  105. package/tests/setup.test.ts +7 -0
  106. package/tests/signal-manager.test.ts +26 -0
  107. package/tests/steps.test.ts +412 -0
  108. package/tests/worktree.test.ts +411 -0
  109. package/tsconfig.json +18 -0
  110. package/vitest.config.ts +8 -0
@@ -0,0 +1,506 @@
1
+ import { PassThrough } from "node:stream";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ Color,
5
+ ConsoleOutput,
6
+ OutputLevel,
7
+ colorize,
8
+ extractJson,
9
+ extractSummary,
10
+ supportsColor,
11
+ } from "../src/console.js";
12
+
13
+ /** Create a ConsoleOutput that writes to a capturable stream. */
14
+ function createTestConsole(level: "base" | "all" = "base") {
15
+ const stream = new PassThrough({ encoding: "utf-8" });
16
+ let output = "";
17
+ stream.on("data", (chunk: string) => {
18
+ output += chunk;
19
+ });
20
+ const console = new ConsoleOutput(level, stream);
21
+ return {
22
+ console,
23
+ getOutput: () => output,
24
+ };
25
+ }
26
+
27
+ describe("OutputLevel", () => {
28
+ it("has correct values", () => {
29
+ expect(OutputLevel.BASE).toBe("base");
30
+ expect(OutputLevel.ALL).toBe("all");
31
+ });
32
+ });
33
+
34
+ describe("Color", () => {
35
+ it("has ANSI escape code values", () => {
36
+ expect(Color.RESET).toBe("\x1b[0m");
37
+ expect(Color.BOLD).toBe("\x1b[1m");
38
+ expect(Color.RED.startsWith("\x1b[")).toBe(true);
39
+ });
40
+
41
+ it("has all expected colors", () => {
42
+ const colorNames = [
43
+ "RESET",
44
+ "BOLD",
45
+ "DIM",
46
+ "RED",
47
+ "GREEN",
48
+ "YELLOW",
49
+ "BLUE",
50
+ "MAGENTA",
51
+ "CYAN",
52
+ "WHITE",
53
+ "BRIGHT_RED",
54
+ "BRIGHT_GREEN",
55
+ "BRIGHT_YELLOW",
56
+ "BRIGHT_BLUE",
57
+ "BRIGHT_CYAN",
58
+ ];
59
+ expect(Object.keys(Color).length).toBe(colorNames.length);
60
+ });
61
+ });
62
+
63
+ describe("colorize", () => {
64
+ it("returns plain text when no color support", () => {
65
+ // In test environment, stdout is not a TTY so colors are stripped
66
+ const result = colorize("test", Color.RED);
67
+ expect(result).toBe("test");
68
+ });
69
+ });
70
+
71
+ describe("ConsoleOutput", () => {
72
+ it("defaults to BASE level", () => {
73
+ const { console: c } = createTestConsole();
74
+ expect(c.level).toBe(OutputLevel.BASE);
75
+ });
76
+
77
+ it("accepts custom level", () => {
78
+ const { console: c } = createTestConsole("all");
79
+ expect(c.level).toBe(OutputLevel.ALL);
80
+ });
81
+
82
+ it("writes to custom stream", () => {
83
+ const { console: c, getOutput } = createTestConsole();
84
+ c.info("test message");
85
+ expect(getOutput()).toContain("test message");
86
+ });
87
+
88
+ it("prints workflow start message", () => {
89
+ const { console: c, getOutput } = createTestConsole();
90
+ c.workflowStart("test-workflow", "workflow-123");
91
+ const output = getOutput();
92
+ expect(output).toContain("test-workflow");
93
+ expect(output).toContain("workflow-123");
94
+ });
95
+
96
+ it("prints workflow complete success", () => {
97
+ const { console: c, getOutput } = createTestConsole();
98
+ c.workflowComplete("test-workflow", "completed");
99
+ expect(getOutput()).toContain("COMPLETED");
100
+ });
101
+
102
+ it("prints workflow complete failure", () => {
103
+ const { console: c, getOutput } = createTestConsole();
104
+ c.workflowComplete("test-workflow", "failed");
105
+ expect(getOutput()).toContain("FAILED");
106
+ });
107
+
108
+ it("prints step start message", () => {
109
+ const { console: c, getOutput } = createTestConsole();
110
+ c.stepStart("analyze-bugs", "prompt");
111
+ const output = getOutput();
112
+ expect(output).toContain("analyze-bugs");
113
+ expect(output).toContain("prompt");
114
+ });
115
+
116
+ it("prints step complete message", () => {
117
+ const { console: c, getOutput } = createTestConsole();
118
+ c.stepComplete("test-step", "Task completed");
119
+ const output = getOutput();
120
+ expect(output).toContain("test-step");
121
+ expect(output).toContain("OK");
122
+ expect(output).toContain("Task completed");
123
+ });
124
+
125
+ it("prints step complete without summary", () => {
126
+ const { console: c, getOutput } = createTestConsole();
127
+ c.stepComplete("test-step");
128
+ const output = getOutput();
129
+ expect(output).toContain("test-step");
130
+ expect(output).toContain("OK");
131
+ });
132
+
133
+ it("truncates long step summary", () => {
134
+ const { console: c, getOutput } = createTestConsole();
135
+ const longSummary = Array.from({ length: 10 }, (_, i) => `Line ${i}: ${"x".repeat(300)}`).join(
136
+ "\n",
137
+ );
138
+ c.stepComplete("test-step", longSummary);
139
+ const output = getOutput();
140
+ expect(output).toContain("...");
141
+ expect(output).toContain("more lines");
142
+ });
143
+
144
+ it("prints step failed message", () => {
145
+ const { console: c, getOutput } = createTestConsole();
146
+ c.stepFailed("test-step", "Something went wrong");
147
+ const output = getOutput();
148
+ expect(output).toContain("test-step");
149
+ expect(output).toContain("FAIL");
150
+ expect(output).toContain("Something went wrong");
151
+ });
152
+
153
+ it("prints step retry message", () => {
154
+ const { console: c, getOutput } = createTestConsole();
155
+ c.stepRetry("test-step", 2, 3, "Timeout");
156
+ const output = getOutput();
157
+ expect(output).toContain("RETRY");
158
+ expect(output).toContain("2/3");
159
+ expect(output).toContain("Timeout");
160
+ });
161
+
162
+ it("prints ralph iteration start in BASE mode", () => {
163
+ const { console: c, getOutput } = createTestConsole("base");
164
+ c.ralphIterationStart("apply-fixes", 3, 10);
165
+ const output = getOutput();
166
+ expect(output).toContain("3/10");
167
+ expect(output).toContain("apply-fixes");
168
+ expect(output).toContain("iteration");
169
+ });
170
+
171
+ it("prints ralph iteration start in ALL mode", () => {
172
+ const { console: c, getOutput } = createTestConsole("all");
173
+ c.ralphIterationStart("apply-fixes", 3, 10);
174
+ const output = getOutput();
175
+ expect(output).toContain("3/10");
176
+ expect(output).toContain("apply-fixes");
177
+ expect(output).toContain("iteration");
178
+ });
179
+
180
+ it("skips ralph iteration summary in BASE mode", () => {
181
+ const { console: c, getOutput } = createTestConsole("base");
182
+ c.ralphIteration("apply-fixes", 3, 10, "Fixed 2 issues");
183
+ expect(getOutput()).toBe("");
184
+ });
185
+
186
+ it("prints ralph iteration summary in ALL mode", () => {
187
+ const { console: c, getOutput } = createTestConsole("all");
188
+ c.ralphIteration("apply-fixes", 3, 10, "Fixed 2 issues");
189
+ expect(getOutput()).toContain("Fixed 2 issues");
190
+ });
191
+
192
+ it("prints ralph complete message", () => {
193
+ const { console: c, getOutput } = createTestConsole();
194
+ c.ralphComplete("apply-fixes", 5, 10);
195
+ const output = getOutput();
196
+ expect(output).toContain("OK");
197
+ expect(output).toContain("apply-fixes");
198
+ expect(output).toContain("5/10");
199
+ });
200
+
201
+ it("prints ralph max iterations message", () => {
202
+ const { console: c, getOutput } = createTestConsole();
203
+ c.ralphMaxIterations("apply-fixes", 10);
204
+ const output = getOutput();
205
+ expect(output).toContain("WARN");
206
+ expect(output).toContain("max iterations");
207
+ expect(output).toContain("10");
208
+ });
209
+
210
+ it("prints info message", () => {
211
+ const { console: c, getOutput } = createTestConsole();
212
+ c.info("Information message");
213
+ const output = getOutput();
214
+ expect(output).toContain("INFO");
215
+ expect(output).toContain("Information message");
216
+ });
217
+
218
+ it("prints warning message", () => {
219
+ const { console: c, getOutput } = createTestConsole();
220
+ c.warning("Warning message");
221
+ const output = getOutput();
222
+ expect(output).toContain("WARN");
223
+ expect(output).toContain("Warning message");
224
+ });
225
+
226
+ it("prints error message", () => {
227
+ const { console: c, getOutput } = createTestConsole();
228
+ c.error("Error message");
229
+ const output = getOutput();
230
+ expect(output).toContain("ERROR");
231
+ expect(output).toContain("Error message");
232
+ });
233
+
234
+ it("streams assistant text in ALL mode", () => {
235
+ const { console: c, getOutput } = createTestConsole("all");
236
+ c.streamText("Hello\nWorld", "assistant");
237
+ const output = getOutput();
238
+ expect(output).toContain("Hello");
239
+ expect(output).toContain("World");
240
+ });
241
+
242
+ it("streams user text in ALL mode", () => {
243
+ const { console: c, getOutput } = createTestConsole("all");
244
+ c.streamText("User prompt here", "user");
245
+ const output = getOutput();
246
+ expect(output).toContain("[user]");
247
+ expect(output).toContain("User prompt here");
248
+ });
249
+
250
+ it("shows text in BASE mode with prefix", () => {
251
+ const { console: c, getOutput } = createTestConsole("base");
252
+ c.streamText("First line\nSecond line\nThird line");
253
+ const output = getOutput();
254
+ expect(output).toContain("Third line");
255
+ expect(output).toContain("...");
256
+ c.streamComplete();
257
+ });
258
+
259
+ it("skips empty text in ALL mode", () => {
260
+ const { console: c, getOutput } = createTestConsole("all");
261
+ c.streamText("", "assistant");
262
+ c.streamText(" ", "assistant");
263
+ expect(getOutput()).toBe("");
264
+ });
265
+
266
+ it("preserves multiline output in ALL mode", () => {
267
+ const { console: c, getOutput } = createTestConsole("all");
268
+ c.streamText("Line 1\nLine 2\nLine 3", "assistant");
269
+ const output = getOutput();
270
+ expect(output).toContain("Line 1");
271
+ expect(output).toContain("Line 2");
272
+ expect(output).toContain("Line 3");
273
+ });
274
+
275
+ it("accumulates text in BASE mode", () => {
276
+ const { console: c, getOutput } = createTestConsole("base");
277
+ c.streamText("Hello ");
278
+ c.streamText("world");
279
+ expect(c._baseAccumulatedText).toContain("Hello world");
280
+ const output = getOutput();
281
+ expect(output).toContain("Hello");
282
+ expect(output).toContain("world");
283
+ c.streamComplete();
284
+ });
285
+
286
+ it("resets accumulated text on stream_complete", () => {
287
+ const { console: c } = createTestConsole("base");
288
+ c.streamText("First stream message");
289
+ expect(c._baseAccumulatedText).toContain("First stream message");
290
+ c.streamComplete();
291
+ expect(c._baseAccumulatedText).toBe("");
292
+
293
+ c.streamText("Second stream message");
294
+ expect(c._baseAccumulatedText).toContain("Second stream message");
295
+ expect(c._baseAccumulatedText).not.toContain("First stream message");
296
+ c.streamComplete();
297
+ });
298
+
299
+ it("skips user prompts in BASE mode", () => {
300
+ const { console: c, getOutput } = createTestConsole("base");
301
+ c.streamText("This is the user prompt", "user");
302
+ c.streamText("This is the assistant response", "assistant");
303
+ expect(c._baseAccumulatedText).not.toContain("user prompt");
304
+ expect(c._baseAccumulatedText).toContain("assistant response");
305
+ const output = getOutput();
306
+ expect(output).not.toContain("user prompt");
307
+ expect(output).toContain("assistant response");
308
+ c.streamComplete();
309
+ });
310
+
311
+ it("registers parallel branches", () => {
312
+ const { console: c } = createTestConsole("base");
313
+ c.registerParallelBranches(["branch-a", "branch-b", "branch-c"]);
314
+ expect(c._parallelHandler).toBeDefined();
315
+ });
316
+
317
+ it("handles parallel branch streaming in BASE mode", () => {
318
+ const { console: c } = createTestConsole("base");
319
+ c.registerParallelBranches(["branch-a", "branch-b"]);
320
+ c.enterParallelMode();
321
+
322
+ c.setParallelBranch("branch-a");
323
+ c.streamText("Message from branch A", "assistant");
324
+
325
+ c.exitParallelMode();
326
+ });
327
+ });
328
+
329
+ describe("extractSummary", () => {
330
+ it("extracts summary with explicit marker", () => {
331
+ const output = `
332
+ Some header text.
333
+
334
+ ## Summary
335
+
336
+ This is the summary content.
337
+ It has multiple lines.
338
+
339
+ ## Next Section
340
+ `;
341
+ const summary = extractSummary(output);
342
+ expect(summary).toContain("summary content");
343
+ expect(summary).not.toContain("Next Section");
344
+ });
345
+
346
+ it("extracts summary with various markers", () => {
347
+ const cases = [
348
+ ["## Summary\nSummary text", "Summary text"],
349
+ ["### Summary\nSmaller summary", "Smaller summary"],
350
+ ["Summary:\nInline summary", "Inline summary"],
351
+ ["Result:\nResult text", "Result text"],
352
+ ["Completed:\nCompletion text", "Completion text"],
353
+ ["Done:\nDone text", "Done text"],
354
+ ] as const;
355
+
356
+ for (const [output, expected] of cases) {
357
+ const summary = extractSummary(output);
358
+ expect(summary).toContain(expected);
359
+ }
360
+ });
361
+
362
+ it("uses last lines when no marker present", () => {
363
+ const output = `
364
+ First line of output.
365
+ Second line.
366
+ Third line.
367
+ Fourth line.
368
+ Fifth line.
369
+ Sixth line.
370
+ Seventh line.
371
+ `;
372
+ const summary = extractSummary(output);
373
+ expect(summary).toContain("line");
374
+ });
375
+
376
+ it("returns empty for empty input", () => {
377
+ expect(extractSummary("")).toBe("");
378
+ expect(extractSummary(" ")).toBe("");
379
+ });
380
+
381
+ it("respects max_lines parameter", () => {
382
+ const output = `
383
+ ## Summary
384
+
385
+ Line 1
386
+ Line 2
387
+ Line 3
388
+ Line 4
389
+ Line 5
390
+ Line 6
391
+ Line 7
392
+ `;
393
+ const summary = extractSummary(output, 3);
394
+ const lines = summary.split("\n").filter((l) => l.trim());
395
+ expect(lines.length).toBeLessThanOrEqual(3);
396
+ });
397
+
398
+ it("respects max_chars parameter", () => {
399
+ const longLine = "x".repeat(1000);
400
+ const output = `## Summary\n${longLine}`;
401
+ const summary = extractSummary(output, 5, 100);
402
+ expect(summary.length).toBeLessThanOrEqual(100);
403
+ });
404
+
405
+ it("stops at next header", () => {
406
+ const output = `
407
+ ## Summary
408
+
409
+ Summary line 1.
410
+ Summary line 2.
411
+
412
+ ## Next Section
413
+
414
+ This should not be included.
415
+ `;
416
+ const summary = extractSummary(output);
417
+ expect(summary).toContain("Summary line");
418
+ expect(summary).not.toContain("should not be included");
419
+ });
420
+ });
421
+
422
+ describe("extractJson", () => {
423
+ it("extracts basic JSON from output", () => {
424
+ const output = `
425
+ Some text before.
426
+
427
+ \`\`\`json
428
+ {"success": true, "summary": "Task completed"}
429
+ \`\`\`
430
+
431
+ Some text after.
432
+ `;
433
+ const result = extractJson(output);
434
+ expect(result).not.toBeNull();
435
+ expect(result?.success).toBe(true);
436
+ expect(result?.summary).toBe("Task completed");
437
+ });
438
+
439
+ it("extracts nested JSON objects", () => {
440
+ const output = `
441
+ \`\`\`json
442
+ {
443
+ "success": true,
444
+ "checks": {
445
+ "tests": {"passed": true},
446
+ "lint": {"passed": true}
447
+ }
448
+ }
449
+ \`\`\`
450
+ `;
451
+ const result = extractJson(output);
452
+ expect(result).not.toBeNull();
453
+ expect(result?.success).toBe(true);
454
+ expect((result?.checks as Record<string, Record<string, boolean>>).tests.passed).toBe(true);
455
+ });
456
+
457
+ it("extracts JSON with arrays", () => {
458
+ const output = `
459
+ \`\`\`json
460
+ {
461
+ "issues": [
462
+ {"severity": "major", "message": "Issue 1"},
463
+ {"severity": "minor", "message": "Issue 2"}
464
+ ]
465
+ }
466
+ \`\`\`
467
+ `;
468
+ const result = extractJson(output);
469
+ expect(result).not.toBeNull();
470
+ expect((result?.issues as unknown[]).length).toBe(2);
471
+ });
472
+
473
+ it("uses last JSON block when multiple present", () => {
474
+ const output = `
475
+ \`\`\`json
476
+ {"version": 1, "status": "started"}
477
+ \`\`\`
478
+
479
+ \`\`\`json
480
+ {"version": 2, "status": "completed"}
481
+ \`\`\`
482
+ `;
483
+ const result = extractJson(output);
484
+ expect(result).not.toBeNull();
485
+ expect(result?.version).toBe(2);
486
+ expect(result?.status).toBe("completed");
487
+ });
488
+
489
+ it("returns null for empty input", () => {
490
+ expect(extractJson("")).toBeNull();
491
+ expect(extractJson(" ")).toBeNull();
492
+ });
493
+
494
+ it("returns null when no JSON block exists", () => {
495
+ expect(extractJson("Some text without JSON.\nMore text.")).toBeNull();
496
+ });
497
+
498
+ it("returns null for invalid JSON", () => {
499
+ const output = `
500
+ \`\`\`json
501
+ {invalid json: not valid}
502
+ \`\`\`
503
+ `;
504
+ expect(extractJson(output)).toBeNull();
505
+ });
506
+ });