@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,927 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { sequence, sequence_, traverse } from "../combinators.js";
3
+ import {
4
+ assertEffects,
5
+ assertFileWrites,
6
+ collectEffects,
7
+ countEffects,
8
+ dryRun,
9
+ dryRunWith,
10
+ expectTask,
11
+ filterEffects,
12
+ getAffectedFiles,
13
+ getFileWrites,
14
+ mockEffect,
15
+ } from "../dry-run.js";
16
+ import {
17
+ copyFile,
18
+ exec,
19
+ exists,
20
+ getContext,
21
+ glob,
22
+ info,
23
+ mkdir,
24
+ promptConfirm,
25
+ promptSelect,
26
+ promptText,
27
+ readFile,
28
+ setContext,
29
+ writeFile,
30
+ } from "../primitives.js";
31
+ import { fail, flatMap, map, pure } from "../task.js";
32
+ import type { Effect, TaskError } from "../types.js";
33
+
34
+ // =============================================================================
35
+ // Core dryRun Function
36
+ // =============================================================================
37
+
38
+ describe("Dry-Run - Core dryRun Function", () => {
39
+ describe("basic functionality", () => {
40
+ it("collects effects from a task", () => {
41
+ const task = sequence_([
42
+ mkdir("/tmp/test"),
43
+ writeFile("/tmp/test/file.txt", "hello"),
44
+ info("Done"),
45
+ ]);
46
+
47
+ const result = dryRun(task);
48
+
49
+ expect(result.effects.length).toBe(3);
50
+ expect(result.effects[0]._tag).toBe("MakeDir");
51
+ expect(result.effects[1]._tag).toBe("WriteFile");
52
+ expect(result.effects[2]._tag).toBe("Log");
53
+ });
54
+
55
+ it("returns the final value", () => {
56
+ const task = flatMap(pure(10), (x) => pure(x * 2));
57
+ const result = dryRun(task);
58
+
59
+ expect(result.value).toBe(20);
60
+ });
61
+
62
+ it("handles empty tasks (pure values)", () => {
63
+ const task = pure(42);
64
+ const result = dryRun(task);
65
+
66
+ expect(result.value).toBe(42);
67
+ expect(result.effects.length).toBe(0);
68
+ });
69
+
70
+ it("throws TaskExecutionError for failed tasks", () => {
71
+ const error: TaskError = { code: "ERR", message: "test error" };
72
+ const task = fail<number>(error);
73
+
74
+ expect(() => dryRun(task)).toThrow("test error");
75
+ });
76
+
77
+ it("throws on failure in chains", () => {
78
+ const error: TaskError = { code: "ERR", message: "chain error" };
79
+ const task = flatMap(fail<number>(error), (x) => pure(x * 2));
80
+
81
+ expect(() => dryRun(task)).toThrow("chain error");
82
+ });
83
+ });
84
+
85
+ describe("effect handling", () => {
86
+ it("collects ReadFile effects", () => {
87
+ const task = readFile("/test.txt");
88
+ const { effects } = dryRun(task);
89
+
90
+ expect(effects.length).toBe(1);
91
+ expect(effects[0]._tag).toBe("ReadFile");
92
+ });
93
+
94
+ it("collects WriteFile effects", () => {
95
+ const task = writeFile("/output.txt", "content");
96
+ const { effects } = dryRun(task);
97
+
98
+ expect(effects.length).toBe(1);
99
+ expect(effects[0]._tag).toBe("WriteFile");
100
+ expect((effects[0] as { content: string }).content).toBe("content");
101
+ });
102
+
103
+ it("collects Exec effects", () => {
104
+ const task = exec("npm", ["install"], "/project");
105
+ const { effects } = dryRun(task);
106
+
107
+ expect(effects.length).toBe(1);
108
+ expect(effects[0]._tag).toBe("Exec");
109
+ });
110
+
111
+ it("collects Prompt effects", () => {
112
+ const task = promptText("name", "Enter name:");
113
+ const { effects } = dryRun(task);
114
+
115
+ expect(effects.length).toBe(1);
116
+ expect(effects[0]._tag).toBe("Prompt");
117
+ });
118
+
119
+ it("collects Log effects", () => {
120
+ const task = info("test message");
121
+ const { effects } = dryRun(task);
122
+
123
+ expect(effects.length).toBe(1);
124
+ expect(effects[0]._tag).toBe("Log");
125
+ });
126
+
127
+ it("collects Context effects", () => {
128
+ const task = sequence_([setContext("key", "value"), getContext("key")]);
129
+ const { effects } = dryRun(task);
130
+
131
+ expect(effects.length).toBe(2);
132
+ expect(effects[0]._tag).toBe("WriteContext");
133
+ expect(effects[1]._tag).toBe("ReadContext");
134
+ });
135
+ });
136
+ });
137
+
138
+ // =============================================================================
139
+ // mockEffect
140
+ // =============================================================================
141
+
142
+ describe("Dry-Run - mockEffect", () => {
143
+ it("returns mock content for ReadFile", () => {
144
+ const effect: Effect = { _tag: "ReadFile", path: "/test.txt" };
145
+ const result = mockEffect(effect);
146
+
147
+ expect(result).toBe("[mock content of /test.txt]");
148
+ });
149
+
150
+ it("returns true for Exists", () => {
151
+ const effect: Effect = { _tag: "Exists", path: "/any/path" };
152
+ const result = mockEffect(effect);
153
+
154
+ expect(result).toBe(true);
155
+ });
156
+
157
+ it("returns empty array for Glob", () => {
158
+ const effect: Effect = { _tag: "Glob", pattern: "**/*.ts", cwd: "/src" };
159
+ const result = mockEffect(effect);
160
+
161
+ expect(result).toEqual([]);
162
+ });
163
+
164
+ it("returns undefined for WriteFile", () => {
165
+ const effect: Effect = {
166
+ _tag: "WriteFile",
167
+ path: "/out.txt",
168
+ content: "x",
169
+ };
170
+ const result = mockEffect(effect);
171
+
172
+ expect(result).toBeUndefined();
173
+ });
174
+
175
+ it("returns undefined for MakeDir", () => {
176
+ const effect: Effect = { _tag: "MakeDir", path: "/dir", recursive: true };
177
+ const result = mockEffect(effect);
178
+
179
+ expect(result).toBeUndefined();
180
+ });
181
+
182
+ it("returns mock ExecResult for Exec", () => {
183
+ const effect: Effect = { _tag: "Exec", command: "npm", args: ["install"] };
184
+ const result = mockEffect(effect);
185
+
186
+ expect(result).toEqual({ stdout: "", stderr: "", exitCode: 0 });
187
+ });
188
+
189
+ it("returns default for text Prompt", () => {
190
+ const effect: Effect = {
191
+ _tag: "Prompt",
192
+ question: { type: "text", name: "x", message: "?", default: "default" },
193
+ };
194
+ const result = mockEffect(effect);
195
+
196
+ expect(result).toBe("default");
197
+ });
198
+
199
+ it("returns default for confirm Prompt", () => {
200
+ const effect: Effect = {
201
+ _tag: "Prompt",
202
+ question: { type: "confirm", name: "x", message: "?", default: true },
203
+ };
204
+ const result = mockEffect(effect);
205
+
206
+ expect(result).toBe(true);
207
+ });
208
+
209
+ it("returns first choice for select Prompt without default", () => {
210
+ const effect: Effect = {
211
+ _tag: "Prompt",
212
+ question: {
213
+ type: "select",
214
+ name: "x",
215
+ message: "?",
216
+ choices: [
217
+ { label: "A", value: "a" },
218
+ { label: "B", value: "b" },
219
+ ],
220
+ },
221
+ };
222
+ const result = mockEffect(effect);
223
+
224
+ expect(result).toBe("a");
225
+ });
226
+
227
+ it("returns undefined for Log", () => {
228
+ const effect: Effect = { _tag: "Log", level: "info", message: "test" };
229
+ const result = mockEffect(effect);
230
+
231
+ expect(result).toBeUndefined();
232
+ });
233
+
234
+ it("returns undefined for WriteContext", () => {
235
+ const effect: Effect = {
236
+ _tag: "WriteContext",
237
+ key: "k",
238
+ value: "v",
239
+ };
240
+ const result = mockEffect(effect);
241
+
242
+ expect(result).toBeUndefined();
243
+ });
244
+
245
+ it("returns undefined for ReadContext", () => {
246
+ const effect: Effect = { _tag: "ReadContext", key: "k" };
247
+ const result = mockEffect(effect);
248
+
249
+ expect(result).toBeUndefined();
250
+ });
251
+ });
252
+
253
+ // =============================================================================
254
+ // dryRun mock values in tasks
255
+ // =============================================================================
256
+
257
+ describe("Dry-Run - mock values in tasks", () => {
258
+ it("returns mock content for ReadFile task", () => {
259
+ const task = readFile("/test.txt");
260
+ const { value } = dryRun(task);
261
+
262
+ expect(value).toBe("[mock content of /test.txt]");
263
+ });
264
+
265
+ it("returns false for Exists task when file not created during dry-run", () => {
266
+ const task = exists("/any/path");
267
+ const { value } = dryRun(task);
268
+
269
+ expect(value).toBe(false);
270
+ });
271
+
272
+ it("returns true for Exists task when file was created during dry-run", () => {
273
+ const task = flatMap(writeFile("/any/path", "content"), () =>
274
+ exists("/any/path"),
275
+ );
276
+ const { value } = dryRun(task);
277
+
278
+ expect(value).toBe(true);
279
+ });
280
+
281
+ it("returns empty array for Glob task", () => {
282
+ const task = glob("**/*.ts", "/src");
283
+ const { value } = dryRun(task);
284
+
285
+ expect(value).toEqual([]);
286
+ });
287
+
288
+ it("returns mock ExecResult for Exec task", () => {
289
+ const task = exec("npm", ["install"]);
290
+ const { value } = dryRun(task);
291
+
292
+ expect(value).toEqual({ stdout: "", stderr: "", exitCode: 0 });
293
+ });
294
+
295
+ it("returns default for promptText task", () => {
296
+ const task = promptText("name", "Name?", "default");
297
+ const { value } = dryRun(task);
298
+
299
+ expect(value).toBe("default");
300
+ });
301
+
302
+ it("returns default for promptConfirm task", () => {
303
+ const task = promptConfirm("proceed", "Continue?", true);
304
+ const { value } = dryRun(task);
305
+
306
+ expect(value).toBe(true);
307
+ });
308
+
309
+ it("returns default for promptSelect task", () => {
310
+ const choices = [
311
+ { label: "A", value: "a" },
312
+ { label: "B", value: "b" },
313
+ ];
314
+ const task = promptSelect("choice", "Pick:", choices, "b");
315
+ const { value } = dryRun(task);
316
+
317
+ expect(value).toBe("b");
318
+ });
319
+
320
+ it("returns first choice when no default for promptSelect task", () => {
321
+ const choices = [
322
+ { label: "First", value: "first" },
323
+ { label: "Second", value: "second" },
324
+ ];
325
+ const task = promptSelect("choice", "Pick:", choices);
326
+ const { value } = dryRun(task);
327
+
328
+ expect(value).toBe("first");
329
+ });
330
+ });
331
+
332
+ // =============================================================================
333
+ // dryRunWith - custom mocks
334
+ // =============================================================================
335
+
336
+ describe("Dry-Run - dryRunWith", () => {
337
+ it("uses custom mock for specified effect type", () => {
338
+ const customMocks = new Map<string, (e: Effect) => unknown>();
339
+ customMocks.set("ReadFile", () => "custom content");
340
+
341
+ const task = readFile("/test.txt");
342
+ const { value } = dryRunWith(task, customMocks);
343
+
344
+ expect(value).toBe("custom content");
345
+ });
346
+
347
+ it("falls back to default mock for unspecified types", () => {
348
+ const customMocks = new Map<string, (e: Effect) => unknown>();
349
+ customMocks.set("ReadFile", () => "custom content");
350
+
351
+ const task = sequence([readFile("/test.txt"), exists("/other.txt")]);
352
+ const { value } = dryRunWith(task, customMocks);
353
+
354
+ expect(value[0]).toBe("custom content");
355
+ expect(value[1]).toBe(true); // default mock for Exists
356
+ });
357
+
358
+ it("receives the effect in custom mock function", () => {
359
+ const customMocks = new Map<string, (e: Effect) => unknown>();
360
+ customMocks.set("ReadFile", (e) => {
361
+ if (e._tag === "ReadFile") {
362
+ return `content of ${e.path}`;
363
+ }
364
+ return "";
365
+ });
366
+
367
+ const task = readFile("/my-file.txt");
368
+ const { value } = dryRunWith(task, customMocks);
369
+
370
+ expect(value).toBe("content of /my-file.txt");
371
+ });
372
+
373
+ it("collects effects same as dryRun", () => {
374
+ const customMocks = new Map<string, (e: Effect) => unknown>();
375
+
376
+ const task = sequence_([
377
+ writeFile("/a.txt", "a"),
378
+ writeFile("/b.txt", "b"),
379
+ ]);
380
+ const { effects } = dryRunWith(task, customMocks);
381
+
382
+ expect(effects.length).toBe(2);
383
+ expect(effects[0]._tag).toBe("WriteFile");
384
+ expect(effects[1]._tag).toBe("WriteFile");
385
+ });
386
+ });
387
+
388
+ // =============================================================================
389
+ // collectEffects
390
+ // =============================================================================
391
+
392
+ describe("Dry-Run - collectEffects", () => {
393
+ it("collects all effects from a task", () => {
394
+ const task = sequence_([
395
+ writeFile("/a.txt", "a"),
396
+ writeFile("/b.txt", "b"),
397
+ ]);
398
+
399
+ const effects = collectEffects(task);
400
+
401
+ expect(effects.length).toBe(2);
402
+ expect(effects.every((e) => e._tag === "WriteFile")).toBe(true);
403
+ });
404
+
405
+ it("returns empty array for pure tasks", () => {
406
+ const effects = collectEffects(pure(42));
407
+ expect(effects.length).toBe(0);
408
+ });
409
+
410
+ it("handles complex nested tasks", () => {
411
+ const task = traverse([1, 2, 3], (n) =>
412
+ sequence_([info(`Processing ${n}`), writeFile(`/${n}.txt`, String(n))]),
413
+ );
414
+
415
+ const effects = collectEffects(task);
416
+
417
+ expect(effects.filter((e) => e._tag === "Log").length).toBe(3);
418
+ expect(effects.filter((e) => e._tag === "WriteFile").length).toBe(3);
419
+ });
420
+
421
+ it("does not throw on failed tasks - stops at failure", () => {
422
+ const error: TaskError = { code: "ERR", message: "error" };
423
+ const task = sequence_([writeFile("/a.txt", "a"), fail(error)]);
424
+
425
+ // collectEffects stops at Fail node, does not throw
426
+ const effects = collectEffects(task);
427
+ expect(effects.length).toBe(1);
428
+ });
429
+ });
430
+
431
+ // =============================================================================
432
+ // countEffects
433
+ // =============================================================================
434
+
435
+ describe("Dry-Run - countEffects", () => {
436
+ it("counts effects by type", () => {
437
+ const task = sequence_([
438
+ mkdir("/tmp/a"),
439
+ mkdir("/tmp/b"),
440
+ writeFile("/tmp/a/file.txt", "content"),
441
+ info("Log 1"),
442
+ info("Log 2"),
443
+ info("Log 3"),
444
+ ]);
445
+
446
+ const { effects } = dryRun(task);
447
+ const counts = countEffects(effects);
448
+
449
+ expect(counts.MakeDir).toBe(2);
450
+ expect(counts.WriteFile).toBe(1);
451
+ expect(counts.Log).toBe(3);
452
+ });
453
+
454
+ it("returns undefined for missing types (not 0)", () => {
455
+ const task = writeFile("/test.txt", "content");
456
+ const { effects } = dryRun(task);
457
+ const counts = countEffects(effects);
458
+
459
+ expect(counts.WriteFile).toBe(1);
460
+ expect(counts.ReadFile).toBeUndefined();
461
+ expect(counts.MakeDir).toBeUndefined();
462
+ });
463
+
464
+ it("handles empty effects array", () => {
465
+ const counts = countEffects([]);
466
+
467
+ expect(counts.WriteFile).toBeUndefined();
468
+ expect(counts.ReadFile).toBeUndefined();
469
+ expect(Object.keys(counts).length).toBe(0);
470
+ });
471
+ });
472
+
473
+ // =============================================================================
474
+ // filterEffects
475
+ // =============================================================================
476
+
477
+ describe("Dry-Run - filterEffects", () => {
478
+ it("filters effects by tag", () => {
479
+ const task = sequence_([
480
+ mkdir("/tmp/test"),
481
+ writeFile("/tmp/test/a.txt", "a"),
482
+ writeFile("/tmp/test/b.txt", "b"),
483
+ info("Done"),
484
+ ]);
485
+
486
+ const { effects } = dryRun(task);
487
+ const writes = filterEffects(effects, "WriteFile");
488
+
489
+ expect(writes.length).toBe(2);
490
+ expect(writes[0].path).toBe("/tmp/test/a.txt");
491
+ expect(writes[1].path).toBe("/tmp/test/b.txt");
492
+ });
493
+
494
+ it("returns empty array when no matches", () => {
495
+ const task = writeFile("/test.txt", "content");
496
+ const { effects } = dryRun(task);
497
+ const logs = filterEffects(effects, "Log");
498
+
499
+ expect(logs.length).toBe(0);
500
+ });
501
+
502
+ it("works with all effect types", () => {
503
+ const task = sequence_([
504
+ readFile("/input.txt"),
505
+ mkdir("/output"),
506
+ writeFile("/output/file.txt", "content"),
507
+ exec("npm", ["install"]),
508
+ info("Done"),
509
+ ]);
510
+
511
+ const { effects } = dryRun(task);
512
+
513
+ expect(filterEffects(effects, "ReadFile").length).toBe(1);
514
+ expect(filterEffects(effects, "MakeDir").length).toBe(1);
515
+ expect(filterEffects(effects, "WriteFile").length).toBe(1);
516
+ expect(filterEffects(effects, "Exec").length).toBe(1);
517
+ expect(filterEffects(effects, "Log").length).toBe(1);
518
+ });
519
+
520
+ it("returns typed results", () => {
521
+ const task = writeFile("/test.txt", "content");
522
+ const { effects } = dryRun(task);
523
+ const writes = filterEffects(effects, "WriteFile");
524
+
525
+ // Type should allow accessing WriteFile-specific properties
526
+ expect(writes[0].path).toBe("/test.txt");
527
+ expect(writes[0].content).toBe("content");
528
+ });
529
+ });
530
+
531
+ // =============================================================================
532
+ // getFileWrites
533
+ // =============================================================================
534
+
535
+ describe("Dry-Run - getFileWrites", () => {
536
+ it("extracts file write paths and contents", () => {
537
+ const task = sequence_([
538
+ writeFile("/a.txt", "content a"),
539
+ writeFile("/b.txt", "content b"),
540
+ ]);
541
+
542
+ const { effects } = dryRun(task);
543
+ const writes = getFileWrites(effects);
544
+
545
+ expect(writes).toEqual([
546
+ { path: "/a.txt", content: "content a" },
547
+ { path: "/b.txt", content: "content b" },
548
+ ]);
549
+ });
550
+
551
+ it("returns empty array when no writes", () => {
552
+ const task = readFile("/test.txt");
553
+ const { effects } = dryRun(task);
554
+ const writes = getFileWrites(effects);
555
+
556
+ expect(writes).toEqual([]);
557
+ });
558
+
559
+ it("preserves order", () => {
560
+ const task = sequence_([
561
+ writeFile("/1.txt", "1"),
562
+ writeFile("/2.txt", "2"),
563
+ writeFile("/3.txt", "3"),
564
+ ]);
565
+
566
+ const { effects } = dryRun(task);
567
+ const writes = getFileWrites(effects);
568
+
569
+ expect(writes.map((w) => w.path)).toEqual(["/1.txt", "/2.txt", "/3.txt"]);
570
+ });
571
+
572
+ it("only includes WriteFile effects", () => {
573
+ const task = sequence_([
574
+ mkdir("/dir"),
575
+ writeFile("/dir/file.txt", "content"),
576
+ info("done"),
577
+ ]);
578
+
579
+ const { effects } = dryRun(task);
580
+ const writes = getFileWrites(effects);
581
+
582
+ expect(writes.length).toBe(1);
583
+ expect(writes[0].path).toBe("/dir/file.txt");
584
+ });
585
+ });
586
+
587
+ // =============================================================================
588
+ // getAffectedFiles
589
+ // =============================================================================
590
+
591
+ describe("Dry-Run - getAffectedFiles", () => {
592
+ it("returns unique sorted list of affected files", () => {
593
+ const task = sequence_([
594
+ mkdir("/tmp/dir"),
595
+ writeFile("/tmp/dir/a.txt", "a"),
596
+ writeFile("/tmp/dir/b.txt", "b"),
597
+ ]);
598
+
599
+ const { effects } = dryRun(task);
600
+ const files = getAffectedFiles(effects);
601
+
602
+ expect(files).toEqual(["/tmp/dir", "/tmp/dir/a.txt", "/tmp/dir/b.txt"]);
603
+ });
604
+
605
+ it("deduplicates paths", () => {
606
+ const task = sequence_([
607
+ writeFile("/same.txt", "first"),
608
+ readFile("/same.txt"),
609
+ writeFile("/same.txt", "second"),
610
+ ]);
611
+
612
+ const { effects } = dryRun(task);
613
+ const files = getAffectedFiles(effects);
614
+
615
+ // Should only appear once
616
+ expect(files.filter((f) => f === "/same.txt").length).toBe(1);
617
+ });
618
+
619
+ it("includes dest (not source) for CopyFile", () => {
620
+ const task = copyFile("/source.txt", "/dest.txt");
621
+ const { effects } = dryRun(task);
622
+ const files = getAffectedFiles(effects);
623
+
624
+ // Only dest is considered "affected"
625
+ expect(files).toContain("/dest.txt");
626
+ expect(files).not.toContain("/source.txt");
627
+ });
628
+
629
+ it("returns empty array for non-file effects", () => {
630
+ const task = sequence_([info("message"), exec("ls", [])]);
631
+ const { effects } = dryRun(task);
632
+ const files = getAffectedFiles(effects);
633
+
634
+ expect(files).toEqual([]);
635
+ });
636
+
637
+ it("sorts paths alphabetically", () => {
638
+ const task = sequence_([
639
+ writeFile("/z.txt", "z"),
640
+ writeFile("/a.txt", "a"),
641
+ writeFile("/m.txt", "m"),
642
+ ]);
643
+
644
+ const { effects } = dryRun(task);
645
+ const files = getAffectedFiles(effects);
646
+
647
+ expect(files).toEqual(["/a.txt", "/m.txt", "/z.txt"]);
648
+ });
649
+ });
650
+
651
+ // =============================================================================
652
+ // assertEffects
653
+ // =============================================================================
654
+
655
+ describe("Dry-Run - assertEffects", () => {
656
+ it("passes when effects match", () => {
657
+ const task = sequence_([writeFile("/a.txt", "a"), info("done")]);
658
+
659
+ expect(() =>
660
+ assertEffects(task, [
661
+ { _tag: "WriteFile", path: "/a.txt" },
662
+ { _tag: "Log", message: "done" },
663
+ ]),
664
+ ).not.toThrow();
665
+ });
666
+
667
+ it("throws when effect count differs", () => {
668
+ const task = writeFile("/a.txt", "a");
669
+
670
+ expect(() =>
671
+ assertEffects(task, [{ _tag: "WriteFile" }, { _tag: "WriteFile" }]),
672
+ ).toThrow("Expected 2 effects, got 1");
673
+ });
674
+
675
+ it("throws when effect property differs", () => {
676
+ const task = writeFile("/a.txt", "a");
677
+
678
+ expect(() =>
679
+ assertEffects(task, [{ _tag: "WriteFile", path: "/b.txt" }]),
680
+ ).toThrow();
681
+ });
682
+
683
+ it("only checks specified properties", () => {
684
+ const task = writeFile("/a.txt", "content");
685
+
686
+ // Should pass - only checking _tag
687
+ expect(() => assertEffects(task, [{ _tag: "WriteFile" }])).not.toThrow();
688
+ });
689
+ });
690
+
691
+ // =============================================================================
692
+ // assertFileWrites
693
+ // =============================================================================
694
+
695
+ describe("Dry-Run - assertFileWrites", () => {
696
+ it("passes when file writes match", () => {
697
+ const task = sequence_([
698
+ mkdir("/dir"),
699
+ writeFile("/a.txt", "a"),
700
+ writeFile("/b.txt", "b"),
701
+ ]);
702
+
703
+ // assertFileWrites checks getAffectedFiles which includes mkdir paths
704
+ expect(() =>
705
+ assertFileWrites(task, ["/a.txt", "/b.txt", "/dir"]),
706
+ ).not.toThrow();
707
+ });
708
+
709
+ it("throws when file count differs", () => {
710
+ const task = writeFile("/a.txt", "a");
711
+
712
+ expect(() => assertFileWrites(task, ["/a.txt", "/b.txt"])).toThrow();
713
+ });
714
+
715
+ it("throws when file path differs", () => {
716
+ const task = writeFile("/a.txt", "a");
717
+
718
+ expect(() => assertFileWrites(task, ["/b.txt"])).toThrow();
719
+ });
720
+
721
+ it("sorts both actual and expected for comparison", () => {
722
+ const task = sequence_([
723
+ writeFile("/z.txt", "z"),
724
+ writeFile("/a.txt", "a"),
725
+ ]);
726
+
727
+ // Order in expected doesn't matter since both are sorted
728
+ expect(() => assertFileWrites(task, ["/a.txt", "/z.txt"])).not.toThrow();
729
+ });
730
+ });
731
+
732
+ // =============================================================================
733
+ // expectTask
734
+ // =============================================================================
735
+
736
+ describe("Dry-Run - expectTask", () => {
737
+ describe("toHaveValue", () => {
738
+ it("passes when value matches", () => {
739
+ const task = pure(42);
740
+ const matcher = expectTask(task);
741
+
742
+ expect(() => matcher.toHaveValue(42)).not.toThrow();
743
+ });
744
+
745
+ it("throws when value does not match", () => {
746
+ const task = pure(42);
747
+ const matcher = expectTask(task);
748
+
749
+ expect(() => matcher.toHaveValue(99)).toThrow();
750
+ });
751
+
752
+ it("uses strict equality for primitive values", () => {
753
+ const task = pure("hello");
754
+ const matcher = expectTask(task);
755
+
756
+ expect(() => matcher.toHaveValue("hello")).not.toThrow();
757
+ expect(() => matcher.toHaveValue("world")).toThrow();
758
+ });
759
+ });
760
+
761
+ describe("toHaveEffectCount", () => {
762
+ it("passes when count matches", () => {
763
+ const task = sequence_([
764
+ writeFile("/a.txt", "a"),
765
+ writeFile("/b.txt", "b"),
766
+ ]);
767
+
768
+ const matcher = expectTask(task);
769
+ expect(() => matcher.toHaveEffectCount(2)).not.toThrow();
770
+ });
771
+
772
+ it("throws when count does not match", () => {
773
+ const task = sequence_([
774
+ writeFile("/a.txt", "a"),
775
+ writeFile("/b.txt", "b"),
776
+ ]);
777
+
778
+ const matcher = expectTask(task);
779
+ expect(() => matcher.toHaveEffectCount(3)).toThrow();
780
+ });
781
+
782
+ it("works with zero effects", () => {
783
+ const task = pure(42);
784
+ const matcher = expectTask(task);
785
+
786
+ expect(() => matcher.toHaveEffectCount(0)).not.toThrow();
787
+ });
788
+ });
789
+
790
+ describe("toWriteFile", () => {
791
+ it("passes when file is written", () => {
792
+ const task = writeFile("/test.txt", "content");
793
+ const matcher = expectTask(task);
794
+
795
+ expect(() => matcher.toWriteFile("/test.txt")).not.toThrow();
796
+ });
797
+
798
+ it("throws when file is not written", () => {
799
+ const task = writeFile("/test.txt", "content");
800
+ const matcher = expectTask(task);
801
+
802
+ expect(() => matcher.toWriteFile("/other.txt")).toThrow();
803
+ });
804
+
805
+ it("works with multiple writes", () => {
806
+ const task = sequence_([
807
+ writeFile("/a.txt", "a"),
808
+ writeFile("/b.txt", "b"),
809
+ writeFile("/c.txt", "c"),
810
+ ]);
811
+
812
+ const matcher = expectTask(task);
813
+ expect(() => matcher.toWriteFile("/b.txt")).not.toThrow();
814
+ });
815
+ });
816
+
817
+ describe("toNotWriteFile", () => {
818
+ it("passes when file is not written", () => {
819
+ const task = writeFile("/test.txt", "content");
820
+ const matcher = expectTask(task);
821
+
822
+ expect(() => matcher.toNotWriteFile("/other.txt")).not.toThrow();
823
+ });
824
+
825
+ it("throws when file is written", () => {
826
+ const task = writeFile("/test.txt", "content");
827
+ const matcher = expectTask(task);
828
+
829
+ expect(() => matcher.toNotWriteFile("/test.txt")).toThrow();
830
+ });
831
+ });
832
+
833
+ describe("exposed properties", () => {
834
+ it("exposes effects array", () => {
835
+ const task = sequence_([
836
+ writeFile("/a.txt", "a"),
837
+ writeFile("/b.txt", "b"),
838
+ ]);
839
+
840
+ const matcher = expectTask(task);
841
+
842
+ expect(matcher.effects.length).toBe(2);
843
+ expect(matcher.effects[0]._tag).toBe("WriteFile");
844
+ });
845
+
846
+ it("exposes value", () => {
847
+ const task = pure(42);
848
+ const matcher = expectTask(task);
849
+
850
+ expect(matcher.value).toBe(42);
851
+ });
852
+
853
+ it("exposes value for effect tasks", () => {
854
+ const task = map(writeFile("/test.txt", "content"), () => "done");
855
+ const matcher = expectTask(task);
856
+
857
+ expect(matcher.value).toBe("done");
858
+ });
859
+ });
860
+ });
861
+
862
+ // =============================================================================
863
+ // Integration Tests
864
+ // =============================================================================
865
+
866
+ describe("Dry-Run - Integration", () => {
867
+ it("can test complex workflows", () => {
868
+ const generateFiles = traverse(["a", "b", "c"], (name) =>
869
+ sequence_([
870
+ mkdir(`/output/${name}`),
871
+ writeFile(`/output/${name}/index.ts`, `export const ${name} = true;`),
872
+ info(`Created ${name}`),
873
+ ]),
874
+ );
875
+
876
+ const matcher = expectTask(generateFiles);
877
+
878
+ expect(matcher.effects.length).toBe(9); // 3 * (mkdir + writeFile + log)
879
+ matcher.toWriteFile("/output/b/index.ts");
880
+ });
881
+
882
+ it("can verify effect order", () => {
883
+ const task = sequence_([
884
+ info("Step 1"),
885
+ mkdir("/output"),
886
+ info("Step 2"),
887
+ writeFile("/output/file.txt", "content"),
888
+ info("Step 3"),
889
+ ]);
890
+
891
+ const { effects } = dryRun(task);
892
+
893
+ expect(effects[0]._tag).toBe("Log");
894
+ expect((effects[0] as { message: string }).message).toBe("Step 1");
895
+
896
+ expect(effects[1]._tag).toBe("MakeDir");
897
+
898
+ expect(effects[2]._tag).toBe("Log");
899
+ expect((effects[2] as { message: string }).message).toBe("Step 2");
900
+
901
+ expect(effects[3]._tag).toBe("WriteFile");
902
+
903
+ expect(effects[4]._tag).toBe("Log");
904
+ expect((effects[4] as { message: string }).message).toBe("Step 3");
905
+ });
906
+
907
+ it("can use filterEffects for detailed assertions", () => {
908
+ const task = sequence_([
909
+ writeFile("/a.txt", "content a"),
910
+ info("log 1"),
911
+ writeFile("/b.txt", "content b"),
912
+ info("log 2"),
913
+ ]);
914
+
915
+ const { effects } = dryRun(task);
916
+ const writes = filterEffects(effects, "WriteFile");
917
+ const logs = filterEffects(effects, "Log");
918
+
919
+ expect(writes.length).toBe(2);
920
+ expect(writes[0].content).toBe("content a");
921
+ expect(writes[1].content).toBe("content b");
922
+
923
+ expect(logs.length).toBe(2);
924
+ expect(logs[0].message).toBe("log 1");
925
+ expect(logs[1].message).toBe("log 2");
926
+ });
927
+ });