@canonical/summon 0.1.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 (45) hide show
  1. package/README.md +439 -0
  2. package/generators/example/hello/index.ts +132 -0
  3. package/generators/example/hello/templates/README.md.ejs +20 -0
  4. package/generators/example/hello/templates/index.ts.ejs +9 -0
  5. package/generators/example/webapp/index.ts +509 -0
  6. package/generators/example/webapp/templates/ARCHITECTURE.md.ejs +180 -0
  7. package/generators/example/webapp/templates/App.tsx.ejs +86 -0
  8. package/generators/example/webapp/templates/README.md.ejs +154 -0
  9. package/generators/example/webapp/templates/app.test.ts.ejs +63 -0
  10. package/generators/example/webapp/templates/app.ts.ejs +132 -0
  11. package/generators/example/webapp/templates/feature.ts.ejs +264 -0
  12. package/generators/example/webapp/templates/index.html.ejs +20 -0
  13. package/generators/example/webapp/templates/main.tsx.ejs +43 -0
  14. package/generators/example/webapp/templates/styles.css.ejs +135 -0
  15. package/generators/init/index.ts +124 -0
  16. package/generators/init/templates/generator.ts.ejs +85 -0
  17. package/generators/init/templates/template-index.ts.ejs +9 -0
  18. package/generators/init/templates/template-test.ts.ejs +8 -0
  19. package/package.json +64 -0
  20. package/src/__tests__/combinators.test.ts +895 -0
  21. package/src/__tests__/dry-run.test.ts +927 -0
  22. package/src/__tests__/effect.test.ts +816 -0
  23. package/src/__tests__/interpreter.test.ts +673 -0
  24. package/src/__tests__/primitives.test.ts +970 -0
  25. package/src/__tests__/task.test.ts +929 -0
  26. package/src/__tests__/template.test.ts +666 -0
  27. package/src/cli-format.ts +165 -0
  28. package/src/cli-types.ts +53 -0
  29. package/src/cli.tsx +1322 -0
  30. package/src/combinators.ts +294 -0
  31. package/src/completion.ts +488 -0
  32. package/src/components/App.tsx +960 -0
  33. package/src/components/ExecutionProgress.tsx +205 -0
  34. package/src/components/FileTreePreview.tsx +97 -0
  35. package/src/components/PromptSequence.tsx +483 -0
  36. package/src/components/Spinner.tsx +36 -0
  37. package/src/components/index.ts +16 -0
  38. package/src/dry-run.ts +434 -0
  39. package/src/effect.ts +224 -0
  40. package/src/index.ts +266 -0
  41. package/src/interpreter.ts +463 -0
  42. package/src/primitives.ts +442 -0
  43. package/src/task.ts +245 -0
  44. package/src/template.ts +537 -0
  45. package/src/types.ts +453 -0
@@ -0,0 +1,895 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ attempt,
4
+ bracket,
5
+ delay,
6
+ ensure,
7
+ fold,
8
+ ifElse,
9
+ ifElseM,
10
+ optional,
11
+ orElse,
12
+ parallel,
13
+ parallelN,
14
+ race,
15
+ retry,
16
+ retryWithBackoff,
17
+ sequence,
18
+ sequence_,
19
+ tap,
20
+ tapError,
21
+ timeout,
22
+ traverse,
23
+ traverse_,
24
+ unless,
25
+ when,
26
+ whenM,
27
+ zip,
28
+ zip3,
29
+ } from "../combinators.js";
30
+ import { dryRun } from "../dry-run.js";
31
+ import { info, mkdir, writeFile } from "../primitives.js";
32
+ import { effect, fail, flatMap, map, pure } from "../task.js";
33
+ import type { Effect, Task, TaskError } from "../types.js";
34
+
35
+ // =============================================================================
36
+ // Sequencing Combinators
37
+ // =============================================================================
38
+
39
+ describe("Combinators - Sequencing", () => {
40
+ describe("sequence", () => {
41
+ it("sequences an empty array to an empty result", () => {
42
+ const t = sequence([]);
43
+ expect(t._tag).toBe("Pure");
44
+ expect((t as { value: unknown[] }).value).toEqual([]);
45
+ });
46
+
47
+ it("sequences pure tasks and collects results", () => {
48
+ const tasks = [pure(1), pure(2), pure(3)];
49
+ const t = sequence(tasks);
50
+
51
+ expect(t._tag).toBe("Pure");
52
+ expect((t as { value: number[] }).value).toEqual([1, 2, 3]);
53
+ });
54
+
55
+ it("short-circuits on failure", () => {
56
+ const error: TaskError = { code: "ERR", message: "failed" };
57
+ const tasks = [pure(1), fail<number>(error), pure(3)];
58
+ const t = sequence(tasks);
59
+
60
+ expect(t._tag).toBe("Fail");
61
+ expect((t as { error: TaskError }).error.code).toBe("ERR");
62
+ });
63
+
64
+ it("preserves order", () => {
65
+ const tasks = [pure("a"), pure("b"), pure("c"), pure("d"), pure("e")];
66
+ const t = sequence(tasks);
67
+ expect((t as { value: string[] }).value).toEqual([
68
+ "a",
69
+ "b",
70
+ "c",
71
+ "d",
72
+ "e",
73
+ ]);
74
+ });
75
+
76
+ it("handles tasks with effects", () => {
77
+ const tasks = [writeFile("/a.txt", "a"), writeFile("/b.txt", "b")];
78
+ const { effects } = dryRun(sequence(tasks));
79
+
80
+ expect(effects.length).toBe(2);
81
+ expect(effects[0]._tag).toBe("WriteFile");
82
+ expect(effects[1]._tag).toBe("WriteFile");
83
+ });
84
+
85
+ it("handles mixed pure and effect tasks", () => {
86
+ const eff: Effect = { _tag: "ReadFile", path: "/test.txt" };
87
+ const tasks = [pure(1), effect<string>(eff), pure(3)];
88
+ const t = sequence(tasks);
89
+
90
+ expect(t._tag).toBe("Effect");
91
+ });
92
+
93
+ it("handles single task", () => {
94
+ const t = sequence([pure(42)]);
95
+ expect((t as { value: number[] }).value).toEqual([42]);
96
+ });
97
+
98
+ it("handles large arrays", () => {
99
+ const tasks = Array.from({ length: 100 }, (_, i) => pure(i));
100
+ const t = sequence(tasks);
101
+ expect((t as { value: number[] }).value).toHaveLength(100);
102
+ expect((t as { value: number[] }).value[99]).toBe(99);
103
+ });
104
+ });
105
+
106
+ describe("sequence_", () => {
107
+ it("sequences tasks and discards results", () => {
108
+ const tasks = [pure(1), pure(2), pure(3)];
109
+ const t = sequence_(tasks);
110
+
111
+ expect(t._tag).toBe("Pure");
112
+ expect((t as { value: unknown }).value).toBeUndefined();
113
+ });
114
+
115
+ it("still runs all effects", () => {
116
+ const tasks = [
117
+ writeFile("/a.txt", "a"),
118
+ writeFile("/b.txt", "b"),
119
+ writeFile("/c.txt", "c"),
120
+ ];
121
+ const { effects, value } = dryRun(sequence_(tasks));
122
+
123
+ expect(effects.length).toBe(3);
124
+ expect(value).toBeUndefined();
125
+ });
126
+
127
+ it("short-circuits on failure", () => {
128
+ const error: TaskError = { code: "ERR", message: "failed" };
129
+ const tasks = [pure(1), fail<number>(error), pure(3)];
130
+ const t = sequence_(tasks);
131
+
132
+ expect(t._tag).toBe("Fail");
133
+ });
134
+
135
+ it("handles empty array", () => {
136
+ const t = sequence_([]);
137
+ expect(t._tag).toBe("Pure");
138
+ expect((t as { value: unknown }).value).toBeUndefined();
139
+ });
140
+ });
141
+
142
+ describe("traverse", () => {
143
+ it("applies function to each element and sequences results", () => {
144
+ const items = [1, 2, 3];
145
+ const t = traverse(items, (x) => pure(x * 2));
146
+
147
+ expect(t._tag).toBe("Pure");
148
+ expect((t as { value: number[] }).value).toEqual([2, 4, 6]);
149
+ });
150
+
151
+ it("provides index to the function", () => {
152
+ const items = ["a", "b", "c"];
153
+ const t = traverse(items, (item, index) => pure(`${item}${index}`));
154
+
155
+ expect((t as { value: string[] }).value).toEqual(["a0", "b1", "c2"]);
156
+ });
157
+
158
+ it("handles empty array", () => {
159
+ const t = traverse([], (x: number) => pure(x));
160
+ expect((t as { value: unknown[] }).value).toEqual([]);
161
+ });
162
+
163
+ it("short-circuits on failure", () => {
164
+ const error: TaskError = { code: "ERR", message: "failed" };
165
+ const items = [1, 2, 3];
166
+ const t = traverse(items, (x) =>
167
+ x === 2 ? fail<number>(error) : pure(x),
168
+ );
169
+
170
+ expect(t._tag).toBe("Fail");
171
+ });
172
+
173
+ it("can create effects from items", () => {
174
+ const files = ["a.txt", "b.txt"];
175
+ const t = traverse(files, (file) => writeFile(`/${file}`, "content"));
176
+ const { effects } = dryRun(t);
177
+
178
+ expect(effects.length).toBe(2);
179
+ });
180
+ });
181
+
182
+ describe("traverse_", () => {
183
+ it("applies function to each element and discards results", () => {
184
+ const items = ["a", "b", "c"];
185
+ let _count = 0;
186
+ const t = traverse_(items, (_item) => {
187
+ _count++;
188
+ return pure(undefined);
189
+ });
190
+
191
+ expect(t._tag).toBe("Pure");
192
+ expect((t as { value: unknown }).value).toBeUndefined();
193
+ });
194
+
195
+ it("provides index to the function", () => {
196
+ const items = [1, 2, 3];
197
+ const indices: number[] = [];
198
+ traverse_(items, (_item, index) => {
199
+ indices.push(index);
200
+ return pure(undefined);
201
+ });
202
+ // Note: indices won't be populated until dry-run/execution
203
+ });
204
+
205
+ it("handles effects", () => {
206
+ const files = ["/a.txt", "/b.txt"];
207
+ const t = traverse_(files, (path) => writeFile(path, "x"));
208
+ const { effects } = dryRun(t);
209
+
210
+ expect(effects.length).toBe(2);
211
+ });
212
+ });
213
+ });
214
+
215
+ // =============================================================================
216
+ // Parallel Combinators
217
+ // =============================================================================
218
+
219
+ describe("Combinators - Parallel", () => {
220
+ describe("parallel", () => {
221
+ it("returns empty array for empty tasks", () => {
222
+ const t = parallel([]);
223
+ expect(t._tag).toBe("Pure");
224
+ expect((t as { value: unknown[] }).value).toEqual([]);
225
+ });
226
+
227
+ it("creates a Parallel effect for non-empty tasks", () => {
228
+ const tasks = [pure(1), pure(2), pure(3)];
229
+ const t = parallel(tasks);
230
+
231
+ expect(t._tag).toBe("Effect");
232
+ const effectTask = t as { effect: Effect };
233
+ expect(effectTask.effect._tag).toBe("Parallel");
234
+ });
235
+
236
+ it("collects results in dry run", () => {
237
+ const tasks = [pure(1), pure(2), pure(3)];
238
+ const { value } = dryRun(parallel(tasks));
239
+
240
+ expect(value).toEqual([1, 2, 3]);
241
+ });
242
+ });
243
+
244
+ describe("parallelN", () => {
245
+ it("returns empty array for empty tasks", () => {
246
+ const t = parallelN(2, []);
247
+ expect(t._tag).toBe("Pure");
248
+ expect((t as { value: unknown[] }).value).toEqual([]);
249
+ });
250
+
251
+ it("batches tasks by concurrency limit", () => {
252
+ const tasks = [pure(1), pure(2), pure(3), pure(4), pure(5)];
253
+ const { value } = dryRun(parallelN(2, tasks));
254
+
255
+ expect(value).toEqual([1, 2, 3, 4, 5]);
256
+ });
257
+
258
+ it("handles single batch", () => {
259
+ const tasks = [pure(1), pure(2)];
260
+ const { value } = dryRun(parallelN(5, tasks));
261
+
262
+ expect(value).toEqual([1, 2]);
263
+ });
264
+
265
+ it("handles exact batch size", () => {
266
+ const tasks = [pure(1), pure(2), pure(3), pure(4)];
267
+ const { value } = dryRun(parallelN(2, tasks));
268
+
269
+ expect(value).toEqual([1, 2, 3, 4]);
270
+ });
271
+
272
+ it("handles batch size of 1 (sequential)", () => {
273
+ const tasks = [pure(1), pure(2), pure(3)];
274
+ const { value } = dryRun(parallelN(1, tasks));
275
+
276
+ expect(value).toEqual([1, 2, 3]);
277
+ });
278
+ });
279
+
280
+ describe("race", () => {
281
+ it("fails for empty tasks", () => {
282
+ const t = race([]);
283
+ expect(t._tag).toBe("Fail");
284
+ expect((t as { error: TaskError }).error.code).toBe("RACE_EMPTY");
285
+ });
286
+
287
+ it("creates a Race effect for non-empty tasks", () => {
288
+ const tasks = [pure(1), pure(2)];
289
+ const t = race(tasks);
290
+
291
+ expect(t._tag).toBe("Effect");
292
+ const effectTask = t as { effect: Effect };
293
+ expect(effectTask.effect._tag).toBe("Race");
294
+ });
295
+
296
+ it("returns first task result in dry run", () => {
297
+ const tasks = [pure(1), pure(2), pure(3)];
298
+ const { value } = dryRun(race(tasks));
299
+
300
+ expect(value).toBe(1);
301
+ });
302
+ });
303
+ });
304
+
305
+ // =============================================================================
306
+ // Conditional Combinators
307
+ // =============================================================================
308
+
309
+ describe("Combinators - Conditional", () => {
310
+ describe("when", () => {
311
+ it("runs task when condition is true", () => {
312
+ const { effects } = dryRun(when(true, info("running")));
313
+ expect(effects.length).toBe(1);
314
+ expect(effects[0]._tag).toBe("Log");
315
+ });
316
+
317
+ it("returns pure undefined when condition is false", () => {
318
+ const task = when(false, info("not running"));
319
+ expect(task._tag).toBe("Pure");
320
+ expect((task as { value: unknown }).value).toBeUndefined();
321
+
322
+ const { effects } = dryRun(task);
323
+ expect(effects.length).toBe(0);
324
+ });
325
+
326
+ it("handles pure tasks", () => {
327
+ const task = when(true, pure(undefined));
328
+ expect(task._tag).toBe("Pure");
329
+ });
330
+ });
331
+
332
+ describe("unless", () => {
333
+ it("runs task when condition is false", () => {
334
+ const { effects } = dryRun(unless(false, info("running")));
335
+ expect(effects.length).toBe(1);
336
+ });
337
+
338
+ it("returns pure undefined when condition is true", () => {
339
+ const task = unless(true, info("not running"));
340
+ expect(task._tag).toBe("Pure");
341
+
342
+ const { effects } = dryRun(task);
343
+ expect(effects.length).toBe(0);
344
+ });
345
+ });
346
+
347
+ describe("ifElse", () => {
348
+ it("returns onTrue task when condition is true", () => {
349
+ const t = ifElse(true, pure("yes"), pure("no"));
350
+ expect((t as { value: string }).value).toBe("yes");
351
+ });
352
+
353
+ it("returns onFalse task when condition is false", () => {
354
+ const t = ifElse(false, pure("yes"), pure("no"));
355
+ expect((t as { value: string }).value).toBe("no");
356
+ });
357
+
358
+ it("can have different types", () => {
359
+ const t = ifElse(true, pure(42), pure("forty-two"));
360
+ expect((t as { value: number | string }).value).toBe(42);
361
+ });
362
+
363
+ it("works with effects", () => {
364
+ const t = ifElse(true, info("true branch"), info("false branch"));
365
+ const { effects } = dryRun(t);
366
+
367
+ expect(effects.length).toBe(1);
368
+ expect((effects[0] as { message: string }).message).toBe("true branch");
369
+ });
370
+ });
371
+
372
+ describe("whenM", () => {
373
+ it("runs task when condition task returns true", () => {
374
+ const t = whenM(pure(true), info("running"));
375
+ const { effects } = dryRun(t);
376
+
377
+ expect(effects.length).toBe(1);
378
+ });
379
+
380
+ it("does not run task when condition task returns false", () => {
381
+ const t = whenM(pure(false), info("not running"));
382
+ const { effects } = dryRun(t);
383
+
384
+ expect(effects.length).toBe(0);
385
+ });
386
+ });
387
+
388
+ describe("ifElseM", () => {
389
+ it("chooses based on condition task", () => {
390
+ const t = ifElseM(pure(true), pure("yes"), pure("no"));
391
+ const { value } = dryRun(t);
392
+
393
+ expect(value).toBe("yes");
394
+ });
395
+
396
+ it("propagates failure from condition task", () => {
397
+ const error: TaskError = { code: "ERR", message: "condition failed" };
398
+ const t = ifElseM(fail<boolean>(error), pure("yes"), pure("no"));
399
+
400
+ expect(t._tag).toBe("Fail");
401
+ });
402
+ });
403
+ });
404
+
405
+ // =============================================================================
406
+ // Error Handling Combinators
407
+ // =============================================================================
408
+
409
+ describe("Combinators - Error Handling", () => {
410
+ describe("retry", () => {
411
+ it("returns the task immediately if maxAttempts is 1", () => {
412
+ const t = retry(pure(42), 1);
413
+ expect((t as { value: number }).value).toBe(42);
414
+ });
415
+
416
+ it("returns the task immediately if maxAttempts is 0", () => {
417
+ const t = retry(pure(42), 0);
418
+ expect((t as { value: number }).value).toBe(42);
419
+ });
420
+
421
+ it("propagates success without retrying", () => {
422
+ const t = retry(pure(42), 3);
423
+ expect((t as { value: number }).value).toBe(42);
424
+ });
425
+
426
+ it("wraps failed task in recover for retry", () => {
427
+ const error: TaskError = { code: "ERR", message: "error" };
428
+ const t = retry(fail<number>(error), 2);
429
+
430
+ // After all retries exhausted, it should still fail
431
+ expect(t._tag).toBe("Fail");
432
+ });
433
+ });
434
+
435
+ describe("retryWithBackoff", () => {
436
+ it("behaves like retry (backoff is handled by interpreter)", () => {
437
+ const t = retryWithBackoff(pure(42), 3, 100);
438
+ expect((t as { value: number }).value).toBe(42);
439
+ });
440
+ });
441
+
442
+ describe("orElse", () => {
443
+ it("returns primary if it succeeds", () => {
444
+ const t = orElse(pure(42), pure(0));
445
+ expect((t as { value: number }).value).toBe(42);
446
+ });
447
+
448
+ it("returns fallback if primary fails", () => {
449
+ const error: TaskError = { code: "ERR", message: "error" };
450
+ const t = orElse(fail<number>(error), pure(99));
451
+
452
+ expect((t as { value: number }).value).toBe(99);
453
+ });
454
+
455
+ it("propagates fallback failure", () => {
456
+ const error1: TaskError = { code: "ERR1", message: "first" };
457
+ const error2: TaskError = { code: "ERR2", message: "second" };
458
+ const t = orElse(fail<number>(error1), fail<number>(error2));
459
+
460
+ expect(t._tag).toBe("Fail");
461
+ expect((t as { error: TaskError }).error.code).toBe("ERR2");
462
+ });
463
+
464
+ it("can chain multiple fallbacks", () => {
465
+ const error: TaskError = { code: "ERR", message: "error" };
466
+ const t = orElse(
467
+ orElse(fail<number>(error), fail<number>(error)),
468
+ pure(42),
469
+ );
470
+
471
+ expect((t as { value: number }).value).toBe(42);
472
+ });
473
+ });
474
+
475
+ describe("optional", () => {
476
+ it("returns the value if task succeeds", () => {
477
+ const t = optional(pure(42));
478
+ expect((t as { value: number | undefined }).value).toBe(42);
479
+ });
480
+
481
+ it("returns undefined if task fails", () => {
482
+ const error: TaskError = { code: "ERR", message: "error" };
483
+ const t = optional(fail<number>(error));
484
+
485
+ expect((t as { value: number | undefined }).value).toBeUndefined();
486
+ });
487
+
488
+ it("works with effects", () => {
489
+ const eff: Effect = { _tag: "ReadFile", path: "/test.txt" };
490
+ const t = optional(effect<string>(eff));
491
+
492
+ expect(t._tag).toBe("Effect");
493
+ });
494
+ });
495
+
496
+ describe("attempt", () => {
497
+ it("returns ok result on success", () => {
498
+ const t = attempt(pure(42));
499
+ const result = (t as { value: { ok: boolean; value?: number } }).value;
500
+
501
+ expect(result.ok).toBe(true);
502
+ expect(result.value).toBe(42);
503
+ });
504
+
505
+ it("returns error result on failure", () => {
506
+ const error: TaskError = { code: "ERR", message: "error" };
507
+ const t = attempt(fail<number>(error));
508
+ const result = (t as { value: { ok: boolean; error?: TaskError } }).value;
509
+
510
+ expect(result.ok).toBe(false);
511
+ expect(result.error?.code).toBe("ERR");
512
+ });
513
+
514
+ it("never throws - always returns Pure", () => {
515
+ const error: TaskError = { code: "ERR", message: "error" };
516
+ const t = attempt(fail<number>(error));
517
+
518
+ expect(t._tag).toBe("Pure");
519
+ });
520
+ });
521
+ });
522
+
523
+ // =============================================================================
524
+ // Resource Management Combinators
525
+ // =============================================================================
526
+
527
+ describe("Combinators - Resource Management", () => {
528
+ describe("bracket", () => {
529
+ it("runs acquire, use, and release in order for pure tasks", () => {
530
+ const acquire = pure("resource");
531
+ const use = (r: string) => pure(r.length);
532
+ const release = (_r: string) => pure(undefined);
533
+
534
+ const t = bracket(acquire, use, release);
535
+
536
+ expect((t as { value: number }).value).toBe(8);
537
+ });
538
+
539
+ it("runs release on failure", () => {
540
+ const acquire = pure("resource");
541
+ const error: TaskError = { code: "USE_ERR", message: "use failed" };
542
+ const use = (_r: string) => fail<number>(error);
543
+ const release = (_r: string) => pure(undefined);
544
+
545
+ const t = bracket(acquire, use, release);
546
+
547
+ expect(t._tag).toBe("Fail");
548
+ expect((t as { error: TaskError }).error.code).toBe("USE_ERR");
549
+ });
550
+
551
+ it("propagates acquire failure without running use or release", () => {
552
+ const error: TaskError = {
553
+ code: "ACQUIRE_ERR",
554
+ message: "acquire failed",
555
+ };
556
+ const acquire = fail<string>(error);
557
+ const use = (_r: string) => pure(42);
558
+ const release = (_r: string) => pure(undefined);
559
+
560
+ const t = bracket(acquire, use, release);
561
+
562
+ expect(t._tag).toBe("Fail");
563
+ expect((t as { error: TaskError }).error.code).toBe("ACQUIRE_ERR");
564
+ });
565
+
566
+ it("works with effects", () => {
567
+ const acquire = writeFile("/lock", "locked");
568
+ const use = () => info("using resource");
569
+ const release = () => writeFile("/lock", "unlocked");
570
+
571
+ const { effects } = dryRun(bracket(acquire, use, release));
572
+
573
+ expect(effects.length).toBe(3);
574
+ });
575
+ });
576
+
577
+ describe("ensure", () => {
578
+ it("runs cleanup after successful task", () => {
579
+ const t = ensure(pure(42), info("cleanup"));
580
+ const { value, effects } = dryRun(t);
581
+
582
+ expect(value).toBe(42);
583
+ expect(effects.length).toBe(1);
584
+ expect(effects[0]._tag).toBe("Log");
585
+ });
586
+
587
+ it("runs cleanup and propagates failure", () => {
588
+ const error: TaskError = { code: "ERR", message: "error" };
589
+ const t = ensure(fail<number>(error), info("cleanup"));
590
+
591
+ // dryRun throws TaskExecutionError on failure
592
+ expect(() => dryRun(t)).toThrow("error");
593
+ });
594
+
595
+ it("cleanup failure does not override main task failure", () => {
596
+ const mainError: TaskError = { code: "MAIN_ERR", message: "main" };
597
+ const cleanupError: TaskError = {
598
+ code: "CLEANUP_ERR",
599
+ message: "cleanup",
600
+ };
601
+ const t = ensure(fail<number>(mainError), fail<void>(cleanupError));
602
+
603
+ // When both main and cleanup fail, one of them propagates
604
+ expect(() => dryRun(t)).toThrow();
605
+ });
606
+ });
607
+ });
608
+
609
+ // =============================================================================
610
+ // Utility Combinators
611
+ // =============================================================================
612
+
613
+ describe("Combinators - Utility", () => {
614
+ describe("tap", () => {
615
+ it("executes side effect but returns original value", () => {
616
+ const t = tap(pure(42), (x) => info(`Value is ${x}`));
617
+ const { value, effects } = dryRun(t);
618
+
619
+ expect(value).toBe(42);
620
+ expect(effects.length).toBe(1);
621
+ });
622
+
623
+ it("propagates failure from main task", () => {
624
+ const error: TaskError = { code: "ERR", message: "error" };
625
+ const t = tap(fail<number>(error), () => info("side effect"));
626
+
627
+ expect(t._tag).toBe("Fail");
628
+ });
629
+
630
+ it("propagates failure from side effect", () => {
631
+ const error: TaskError = {
632
+ code: "SIDE_ERR",
633
+ message: "side effect failed",
634
+ };
635
+ const t = tap(pure(42), () => fail<void>(error));
636
+
637
+ expect(t._tag).toBe("Fail");
638
+ });
639
+ });
640
+
641
+ describe("tapError", () => {
642
+ it("does not affect successful tasks", () => {
643
+ const t = tapError(pure(42), () => info("error occurred"));
644
+ const { value, effects } = dryRun(t);
645
+
646
+ expect(value).toBe(42);
647
+ expect(effects.length).toBe(0);
648
+ });
649
+
650
+ it("executes side effect on failure and re-throws", () => {
651
+ const error: TaskError = { code: "ERR", message: "error" };
652
+ const t = tapError(fail<number>(error), (e) => info(`Error: ${e.code}`));
653
+
654
+ // dryRun throws TaskExecutionError on failure
655
+ expect(() => dryRun(t)).toThrow("error");
656
+ });
657
+ });
658
+
659
+ describe("delay", () => {
660
+ it("returns the task unchanged (delay handled by interpreter)", () => {
661
+ const t = delay(pure(42), 1000);
662
+ expect((t as { value: number }).value).toBe(42);
663
+ });
664
+ });
665
+
666
+ describe("timeout", () => {
667
+ it("returns the task unchanged (timeout handled by interpreter)", () => {
668
+ const t = timeout(pure(42), 5000);
669
+ expect((t as { value: number }).value).toBe(42);
670
+ });
671
+ });
672
+
673
+ describe("fold", () => {
674
+ it("applies onSuccess for successful task", () => {
675
+ const t = fold(
676
+ pure(42),
677
+ (x) => `success: ${x}`,
678
+ (e) => `error: ${e.code}`,
679
+ );
680
+
681
+ expect((t as { value: string }).value).toBe("success: 42");
682
+ });
683
+
684
+ it("applies onFailure for failed task", () => {
685
+ const error: TaskError = { code: "ERR", message: "error" };
686
+ const t = fold(
687
+ fail<number>(error),
688
+ (x) => `success: ${x}`,
689
+ (e) => `error: ${e.code}`,
690
+ );
691
+
692
+ expect((t as { value: string }).value).toBe("error: ERR");
693
+ });
694
+
695
+ it("never fails - always returns Pure", () => {
696
+ const error: TaskError = { code: "ERR", message: "error" };
697
+ const t = fold(
698
+ fail<number>(error),
699
+ () => "success",
700
+ () => "failure",
701
+ );
702
+
703
+ expect(t._tag).toBe("Pure");
704
+ });
705
+ });
706
+
707
+ describe("zip", () => {
708
+ it("combines two tasks into a tuple", () => {
709
+ const t = zip(pure(1), pure("a"));
710
+ expect((t as { value: [number, string] }).value).toEqual([1, "a"]);
711
+ });
712
+
713
+ it("propagates first failure", () => {
714
+ const error: TaskError = { code: "ERR1", message: "first" };
715
+ const t = zip(fail<number>(error), pure("a"));
716
+
717
+ expect(t._tag).toBe("Fail");
718
+ expect((t as { error: TaskError }).error.code).toBe("ERR1");
719
+ });
720
+
721
+ it("propagates second failure", () => {
722
+ const error: TaskError = { code: "ERR2", message: "second" };
723
+ const t = zip(pure(1), fail<string>(error));
724
+
725
+ expect(t._tag).toBe("Fail");
726
+ expect((t as { error: TaskError }).error.code).toBe("ERR2");
727
+ });
728
+
729
+ it("works with effects", () => {
730
+ const t = zip(writeFile("/a.txt", "a"), writeFile("/b.txt", "b"));
731
+ const { effects } = dryRun(t);
732
+
733
+ expect(effects.length).toBe(2);
734
+ });
735
+ });
736
+
737
+ describe("zip3", () => {
738
+ it("combines three tasks into a tuple", () => {
739
+ const t = zip3(pure(1), pure("a"), pure(true));
740
+ expect((t as { value: [number, string, boolean] }).value).toEqual([
741
+ 1,
742
+ "a",
743
+ true,
744
+ ]);
745
+ });
746
+
747
+ it("propagates any failure", () => {
748
+ const error: TaskError = { code: "ERR", message: "error" };
749
+ const t = zip3(pure(1), fail<string>(error), pure(true));
750
+
751
+ expect(t._tag).toBe("Fail");
752
+ });
753
+
754
+ it("works with effects", () => {
755
+ const t = zip3(
756
+ writeFile("/a.txt", "a"),
757
+ writeFile("/b.txt", "b"),
758
+ writeFile("/c.txt", "c"),
759
+ );
760
+ const { effects } = dryRun(t);
761
+
762
+ expect(effects.length).toBe(3);
763
+ });
764
+ });
765
+ });
766
+
767
+ // =============================================================================
768
+ // Integration Tests
769
+ // =============================================================================
770
+
771
+ describe("Combinators - Integration", () => {
772
+ it("can compose multiple combinators", () => {
773
+ const items = [1, 2, 3, 4, 5];
774
+
775
+ const t = traverse(items, (n) =>
776
+ when(n % 2 === 0, writeFile(`/${n}.txt`, String(n))),
777
+ );
778
+
779
+ const { effects } = dryRun(t);
780
+
781
+ // Only even numbers should produce write effects
782
+ expect(effects.filter((e) => e._tag === "WriteFile").length).toBe(2);
783
+ });
784
+
785
+ it("can build complex workflows", () => {
786
+ const workflow = sequence_([
787
+ info("Starting workflow"),
788
+ mkdir("/output"),
789
+ traverse_(["a", "b", "c"], (name) =>
790
+ writeFile(`/output/${name}.txt`, `Content for ${name}`),
791
+ ),
792
+ info("Workflow complete"),
793
+ ]);
794
+
795
+ const { effects } = dryRun(workflow);
796
+
797
+ expect(effects.filter((e) => e._tag === "Log").length).toBe(2);
798
+ expect(effects.filter((e) => e._tag === "MakeDir").length).toBe(1);
799
+ expect(effects.filter((e) => e._tag === "WriteFile").length).toBe(3);
800
+ });
801
+
802
+ it("handles error recovery in workflows", () => {
803
+ const riskyTask = fail<number>({ code: "RISKY", message: "risky" });
804
+ const safeTask = pure(42);
805
+
806
+ const t = orElse(riskyTask, safeTask);
807
+ const { value } = dryRun(t);
808
+
809
+ expect(value).toBe(42);
810
+ });
811
+
812
+ it("can use bracket for resource management", () => {
813
+ const createTempFile = writeFile("/tmp/lock", "locked");
814
+ const useTempFile = () =>
815
+ sequence_([info("Using temp file"), writeFile("/output.txt", "data")]);
816
+ const cleanupTempFile = () => writeFile("/tmp/lock", "");
817
+
818
+ const t = bracket(createTempFile, useTempFile, cleanupTempFile);
819
+ const { effects } = dryRun(t);
820
+
821
+ // Should have: create lock, log, write output, cleanup lock
822
+ expect(effects.length).toBe(4);
823
+ });
824
+ });
825
+
826
+ // =============================================================================
827
+ // Edge Cases
828
+ // =============================================================================
829
+
830
+ describe("Combinators - Edge Cases", () => {
831
+ describe("Deep nesting", () => {
832
+ it("handles deeply nested sequences", () => {
833
+ let t: Task<number[]> = sequence([pure(0)]);
834
+ for (let i = 1; i < 50; i++) {
835
+ t = flatMap(t, (arr) => map(pure(i), (n) => [...arr, n]));
836
+ }
837
+
838
+ const { value } = dryRun(t);
839
+ expect(value).toHaveLength(50);
840
+ });
841
+
842
+ it("handles deeply nested optionals", () => {
843
+ const error: TaskError = { code: "ERR", message: "deep error" };
844
+ let t: Task<number | undefined> = optional(fail<number>(error));
845
+
846
+ for (let i = 0; i < 10; i++) {
847
+ t = optional(t);
848
+ }
849
+
850
+ const { value } = dryRun(t);
851
+ expect(value).toBeUndefined();
852
+ });
853
+ });
854
+
855
+ describe("Empty inputs", () => {
856
+ it("traverse handles empty array", () => {
857
+ const t = traverse([], (x: number) => pure(x * 2));
858
+ expect((t as { value: number[] }).value).toEqual([]);
859
+ });
860
+
861
+ it("sequence handles empty array", () => {
862
+ const t = sequence([]);
863
+ expect((t as { value: unknown[] }).value).toEqual([]);
864
+ });
865
+
866
+ it("parallel handles empty array", () => {
867
+ const t = parallel([]);
868
+ expect((t as { value: unknown[] }).value).toEqual([]);
869
+ });
870
+
871
+ it("parallelN handles empty array", () => {
872
+ const t = parallelN(5, []);
873
+ expect((t as { value: unknown[] }).value).toEqual([]);
874
+ });
875
+ });
876
+
877
+ describe("Type preservation", () => {
878
+ it("zip preserves tuple types", () => {
879
+ const t = zip(pure(42 as const), pure("hello" as const));
880
+ const { value } = dryRun(t);
881
+ expect(value).toEqual([42, "hello"]);
882
+ });
883
+
884
+ it("fold can change types", () => {
885
+ const t = fold(
886
+ pure(42),
887
+ (n) => ({ type: "success", value: n }),
888
+ (e) => ({ type: "error", code: e.code }),
889
+ );
890
+
891
+ const { value } = dryRun(t);
892
+ expect(value).toEqual({ type: "success", value: 42 });
893
+ });
894
+ });
895
+ });