@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,970 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { dryRun, dryRunWith } from "../dry-run.js";
3
+ import {
4
+ copyDirectory,
5
+ copyFile,
6
+ debug,
7
+ deleteDirectory,
8
+ deleteFile,
9
+ error,
10
+ exec,
11
+ execSimple,
12
+ exists,
13
+ getContext,
14
+ glob,
15
+ info,
16
+ log,
17
+ mkdir,
18
+ noop,
19
+ prompt,
20
+ promptConfirm,
21
+ promptMultiselect,
22
+ promptSelect,
23
+ promptText,
24
+ readFile,
25
+ setContext,
26
+ sortFileLines,
27
+ succeed,
28
+ warn,
29
+ withContext,
30
+ writeFile,
31
+ } from "../primitives.js";
32
+ import { flatMap } from "../task.js";
33
+
34
+ // =============================================================================
35
+ // File System Primitives
36
+ // =============================================================================
37
+
38
+ describe("Primitives - File System", () => {
39
+ describe("readFile", () => {
40
+ it("creates a ReadFile effect", () => {
41
+ const task = readFile("/path/to/file.txt");
42
+ const { effects } = dryRun(task);
43
+
44
+ expect(effects.length).toBe(1);
45
+ expect(effects[0]._tag).toBe("ReadFile");
46
+ expect((effects[0] as { path: string }).path).toBe("/path/to/file.txt");
47
+ });
48
+
49
+ it("returns mock content in dry run", () => {
50
+ const task = readFile("/test.txt");
51
+ const { value } = dryRun(task);
52
+
53
+ expect(value).toBe("[mock content of /test.txt]");
54
+ });
55
+
56
+ it("handles absolute paths", () => {
57
+ const task = readFile("/absolute/path/file.ts");
58
+ const { effects } = dryRun(task);
59
+ expect((effects[0] as { path: string }).path).toBe(
60
+ "/absolute/path/file.ts",
61
+ );
62
+ });
63
+
64
+ it("handles relative paths", () => {
65
+ const task = readFile("./relative/file.ts");
66
+ const { effects } = dryRun(task);
67
+ expect((effects[0] as { path: string }).path).toBe("./relative/file.ts");
68
+ });
69
+
70
+ it("handles paths with spaces", () => {
71
+ const task = readFile("/path/with spaces/file.txt");
72
+ const { effects } = dryRun(task);
73
+ expect((effects[0] as { path: string }).path).toBe(
74
+ "/path/with spaces/file.txt",
75
+ );
76
+ });
77
+ });
78
+
79
+ describe("writeFile", () => {
80
+ it("creates a WriteFile effect", () => {
81
+ const task = writeFile("/path/to/file.txt", "Hello, World!");
82
+ const { effects } = dryRun(task);
83
+
84
+ expect(effects.length).toBe(1);
85
+ expect(effects[0]._tag).toBe("WriteFile");
86
+ expect((effects[0] as { path: string }).path).toBe("/path/to/file.txt");
87
+ expect((effects[0] as { content: string }).content).toBe("Hello, World!");
88
+ });
89
+
90
+ it("handles empty content", () => {
91
+ const task = writeFile("/empty.txt", "");
92
+ const { effects } = dryRun(task);
93
+ expect((effects[0] as { content: string }).content).toBe("");
94
+ });
95
+
96
+ it("handles multiline content", () => {
97
+ const content = "line1\nline2\nline3";
98
+ const task = writeFile("/multiline.txt", content);
99
+ const { effects } = dryRun(task);
100
+ expect((effects[0] as { content: string }).content).toBe(content);
101
+ });
102
+
103
+ it("handles content with special characters", () => {
104
+ const content = 'const x = "hello"; // comment\n\t@decorator';
105
+ const task = writeFile("/code.ts", content);
106
+ const { effects } = dryRun(task);
107
+ expect((effects[0] as { content: string }).content).toBe(content);
108
+ });
109
+
110
+ it("handles JSON content", () => {
111
+ const json = JSON.stringify({ key: "value", arr: [1, 2, 3] }, null, 2);
112
+ const task = writeFile("/data.json", json);
113
+ const { effects } = dryRun(task);
114
+ expect((effects[0] as { content: string }).content).toBe(json);
115
+ });
116
+ });
117
+
118
+ describe("copyFile", () => {
119
+ it("creates a CopyFile effect", () => {
120
+ const task = copyFile("/source.txt", "/dest.txt");
121
+ const { effects } = dryRun(task);
122
+
123
+ expect(effects.length).toBe(1);
124
+ expect(effects[0]._tag).toBe("CopyFile");
125
+ expect((effects[0] as { source: string }).source).toBe("/source.txt");
126
+ expect((effects[0] as { dest: string }).dest).toBe("/dest.txt");
127
+ });
128
+ });
129
+
130
+ describe("copyDirectory", () => {
131
+ it("creates a CopyDirectory effect", () => {
132
+ const task = copyDirectory("/source/dir", "/dest/dir");
133
+ const { effects } = dryRun(task);
134
+
135
+ expect(effects.length).toBe(1);
136
+ expect(effects[0]._tag).toBe("CopyDirectory");
137
+ expect((effects[0] as { source: string }).source).toBe("/source/dir");
138
+ expect((effects[0] as { dest: string }).dest).toBe("/dest/dir");
139
+ });
140
+ });
141
+
142
+ describe("deleteFile", () => {
143
+ it("creates a DeleteFile effect", () => {
144
+ const task = deleteFile("/path/to/delete.txt");
145
+ const { effects } = dryRun(task);
146
+
147
+ expect(effects.length).toBe(1);
148
+ expect(effects[0]._tag).toBe("DeleteFile");
149
+ expect((effects[0] as { path: string }).path).toBe("/path/to/delete.txt");
150
+ });
151
+ });
152
+
153
+ describe("deleteDirectory", () => {
154
+ it("creates a DeleteDirectory effect", () => {
155
+ const task = deleteDirectory("/path/to/delete/dir");
156
+ const { effects } = dryRun(task);
157
+
158
+ expect(effects.length).toBe(1);
159
+ expect(effects[0]._tag).toBe("DeleteDirectory");
160
+ expect((effects[0] as { path: string }).path).toBe("/path/to/delete/dir");
161
+ });
162
+ });
163
+
164
+ describe("mkdir", () => {
165
+ it("creates a MakeDir effect with recursive true by default", () => {
166
+ const task = mkdir("/path/to/dir");
167
+ const { effects } = dryRun(task);
168
+
169
+ expect(effects.length).toBe(1);
170
+ expect(effects[0]._tag).toBe("MakeDir");
171
+ expect((effects[0] as { path: string }).path).toBe("/path/to/dir");
172
+ expect((effects[0] as { recursive: boolean }).recursive).toBe(true);
173
+ });
174
+
175
+ it("allows setting recursive to false", () => {
176
+ const task = mkdir("/path/to/dir", false);
177
+ const { effects } = dryRun(task);
178
+ expect((effects[0] as { recursive: boolean }).recursive).toBe(false);
179
+ });
180
+
181
+ it("allows explicit recursive true", () => {
182
+ const task = mkdir("/deep/nested/path", true);
183
+ const { effects } = dryRun(task);
184
+ expect((effects[0] as { recursive: boolean }).recursive).toBe(true);
185
+ });
186
+ });
187
+
188
+ describe("exists", () => {
189
+ it("creates an Exists effect", () => {
190
+ const task = exists("/path/to/file");
191
+ const { effects } = dryRun(task);
192
+
193
+ expect(effects.length).toBe(1);
194
+ expect(effects[0]._tag).toBe("Exists");
195
+ expect((effects[0] as { path: string }).path).toBe("/path/to/file");
196
+ });
197
+
198
+ it("returns false by default in dry run when file not created", () => {
199
+ const task = exists("/any/path");
200
+ const { value } = dryRun(task);
201
+ expect(value).toBe(false);
202
+ });
203
+
204
+ it("returns true in dry run when file was created during run", () => {
205
+ const task = flatMap(writeFile("/any/path", "content"), () =>
206
+ exists("/any/path"),
207
+ );
208
+ const { value } = dryRun(task);
209
+ expect(value).toBe(true);
210
+ });
211
+ });
212
+
213
+ describe("glob", () => {
214
+ it("creates a Glob effect", () => {
215
+ const task = glob("**/*.ts", "/src");
216
+ const { effects } = dryRun(task);
217
+
218
+ expect(effects.length).toBe(1);
219
+ expect(effects[0]._tag).toBe("Glob");
220
+ expect((effects[0] as { pattern: string }).pattern).toBe("**/*.ts");
221
+ expect((effects[0] as { cwd: string }).cwd).toBe("/src");
222
+ });
223
+
224
+ it("handles complex glob patterns", () => {
225
+ const task = glob("**/*.{ts,tsx,js,jsx}", "/project");
226
+ const { effects } = dryRun(task);
227
+ expect((effects[0] as { pattern: string }).pattern).toBe(
228
+ "**/*.{ts,tsx,js,jsx}",
229
+ );
230
+ });
231
+
232
+ it("returns empty array by default in dry run", () => {
233
+ const task = glob("**/*", "/src");
234
+ const { value } = dryRun(task);
235
+ expect(value).toEqual([]);
236
+ });
237
+ });
238
+ });
239
+
240
+ // =============================================================================
241
+ // Process Primitives
242
+ // =============================================================================
243
+
244
+ describe("Primitives - Process", () => {
245
+ describe("exec", () => {
246
+ it("creates an Exec effect", () => {
247
+ const task = exec("npm", ["install"], "/project");
248
+ const { effects } = dryRun(task);
249
+
250
+ expect(effects.length).toBe(1);
251
+ expect(effects[0]._tag).toBe("Exec");
252
+ expect((effects[0] as { command: string }).command).toBe("npm");
253
+ expect((effects[0] as { args: string[] }).args).toEqual(["install"]);
254
+ expect((effects[0] as { cwd?: string }).cwd).toBe("/project");
255
+ });
256
+
257
+ it("works without cwd", () => {
258
+ const task = exec("ls", ["-la"]);
259
+ const { effects } = dryRun(task);
260
+ expect((effects[0] as { cwd?: string }).cwd).toBeUndefined();
261
+ });
262
+
263
+ it("handles empty args", () => {
264
+ const task = exec("pwd", []);
265
+ const { effects } = dryRun(task);
266
+ expect((effects[0] as { args: string[] }).args).toEqual([]);
267
+ });
268
+
269
+ it("handles multiple args", () => {
270
+ const task = exec("git", [
271
+ "commit",
272
+ "-m",
273
+ "feat: add feature",
274
+ "--no-verify",
275
+ ]);
276
+ const { effects } = dryRun(task);
277
+ expect((effects[0] as { args: string[] }).args).toEqual([
278
+ "commit",
279
+ "-m",
280
+ "feat: add feature",
281
+ "--no-verify",
282
+ ]);
283
+ });
284
+
285
+ it("returns mock ExecResult in dry run", () => {
286
+ const task = exec("echo", ["hello"]);
287
+ const { value } = dryRun(task);
288
+ expect(value).toEqual({ stdout: "", stderr: "", exitCode: 0 });
289
+ });
290
+ });
291
+
292
+ describe("execSimple", () => {
293
+ it("creates an Exec effect from a command string", () => {
294
+ const task = execSimple("npm install");
295
+ const { effects } = dryRun(task);
296
+
297
+ expect(effects.length).toBe(1);
298
+ expect(effects[0]._tag).toBe("Exec");
299
+ expect((effects[0] as { command: string }).command).toBe("npm");
300
+ expect((effects[0] as { args: string[] }).args).toEqual(["install"]);
301
+ });
302
+
303
+ it("handles single word command", () => {
304
+ const task = execSimple("pwd");
305
+ const { effects } = dryRun(task);
306
+ expect((effects[0] as { command: string }).command).toBe("pwd");
307
+ expect((effects[0] as { args: string[] }).args).toEqual([]);
308
+ });
309
+
310
+ it("handles command with multiple arguments", () => {
311
+ const task = execSimple("git commit -m message");
312
+ const { effects } = dryRun(task);
313
+ expect((effects[0] as { command: string }).command).toBe("git");
314
+ expect((effects[0] as { args: string[] }).args).toEqual([
315
+ "commit",
316
+ "-m",
317
+ "message",
318
+ ]);
319
+ });
320
+
321
+ it("works with cwd parameter", () => {
322
+ const task = execSimple("npm install", "/project");
323
+ const { effects } = dryRun(task);
324
+ expect((effects[0] as { cwd?: string }).cwd).toBe("/project");
325
+ });
326
+ });
327
+ });
328
+
329
+ // =============================================================================
330
+ // Prompt Primitives
331
+ // =============================================================================
332
+
333
+ describe("Primitives - Prompt", () => {
334
+ describe("prompt (generic)", () => {
335
+ it("creates a Prompt effect with text question", () => {
336
+ const task = prompt({
337
+ type: "text",
338
+ name: "username",
339
+ message: "Enter username:",
340
+ default: "guest",
341
+ });
342
+ const { effects } = dryRun(task);
343
+
344
+ expect(effects.length).toBe(1);
345
+ expect(effects[0]._tag).toBe("Prompt");
346
+ const question = (effects[0] as { question: { type: string } }).question;
347
+ expect(question.type).toBe("text");
348
+ });
349
+ });
350
+
351
+ describe("promptText", () => {
352
+ it("creates a text Prompt effect", () => {
353
+ const task = promptText("name", "What is your name?", "John");
354
+ const { effects } = dryRun(task);
355
+
356
+ expect(effects.length).toBe(1);
357
+ expect(effects[0]._tag).toBe("Prompt");
358
+ const question = (
359
+ effects[0] as {
360
+ question: {
361
+ type: string;
362
+ name: string;
363
+ message: string;
364
+ default?: string;
365
+ };
366
+ }
367
+ ).question;
368
+ expect(question.type).toBe("text");
369
+ expect(question.name).toBe("name");
370
+ expect(question.message).toBe("What is your name?");
371
+ expect(question.default).toBe("John");
372
+ });
373
+
374
+ it("works without default value", () => {
375
+ const task = promptText("name", "What is your name?");
376
+ const { effects } = dryRun(task);
377
+ const question = (effects[0] as { question: { default?: string } })
378
+ .question;
379
+ expect(question.default).toBeUndefined();
380
+ });
381
+
382
+ it("returns default value in dry run", () => {
383
+ const task = promptText("name", "Name?", "default_name");
384
+ const { value } = dryRun(task);
385
+ expect(value).toBe("default_name");
386
+ });
387
+
388
+ it("returns empty string when no default in dry run", () => {
389
+ const task = promptText("name", "Name?");
390
+ const { value } = dryRun(task);
391
+ expect(value).toBe("");
392
+ });
393
+ });
394
+
395
+ describe("promptConfirm", () => {
396
+ it("creates a confirm Prompt effect", () => {
397
+ const task = promptConfirm("proceed", "Continue?", true);
398
+ const { effects } = dryRun(task);
399
+
400
+ expect(effects.length).toBe(1);
401
+ expect(effects[0]._tag).toBe("Prompt");
402
+ const question = (
403
+ effects[0] as {
404
+ question: { type: string; name: string; default?: boolean };
405
+ }
406
+ ).question;
407
+ expect(question.type).toBe("confirm");
408
+ expect(question.name).toBe("proceed");
409
+ expect(question.default).toBe(true);
410
+ });
411
+
412
+ it("defaults to false", () => {
413
+ const task = promptConfirm("proceed", "Continue?");
414
+ const { effects } = dryRun(task);
415
+ const question = (effects[0] as { question: { default?: boolean } })
416
+ .question;
417
+ expect(question.default).toBe(false);
418
+ });
419
+
420
+ it("returns default value in dry run", () => {
421
+ const task = promptConfirm("proceed", "Continue?", true);
422
+ const { value } = dryRun(task);
423
+ expect(value).toBe(true);
424
+ });
425
+
426
+ it("returns false when no default in dry run", () => {
427
+ const task = promptConfirm("proceed", "Continue?");
428
+ const { value } = dryRun(task);
429
+ expect(value).toBe(false);
430
+ });
431
+ });
432
+
433
+ describe("promptSelect", () => {
434
+ it("creates a select Prompt effect", () => {
435
+ const choices = [
436
+ { label: "Option A", value: "a" },
437
+ { label: "Option B", value: "b" },
438
+ { label: "Option C", value: "c" },
439
+ ];
440
+ const task = promptSelect("choice", "Pick one:", choices, "b");
441
+ const { effects } = dryRun(task);
442
+
443
+ expect(effects.length).toBe(1);
444
+ expect(effects[0]._tag).toBe("Prompt");
445
+ const question = (
446
+ effects[0] as {
447
+ question: { type: string; choices: typeof choices; default?: string };
448
+ }
449
+ ).question;
450
+ expect(question.type).toBe("select");
451
+ expect(question.choices).toEqual(choices);
452
+ expect(question.default).toBe("b");
453
+ });
454
+
455
+ it("returns default value in dry run", () => {
456
+ const choices = [
457
+ { label: "A", value: "a" },
458
+ { label: "B", value: "b" },
459
+ ];
460
+ const task = promptSelect("choice", "Pick:", choices, "b");
461
+ const { value } = dryRun(task);
462
+ expect(value).toBe("b");
463
+ });
464
+
465
+ it("returns first choice when no default in dry run", () => {
466
+ const choices = [
467
+ { label: "First", value: "first" },
468
+ { label: "Second", value: "second" },
469
+ ];
470
+ const task = promptSelect("choice", "Pick:", choices);
471
+ const { value } = dryRun(task);
472
+ expect(value).toBe("first");
473
+ });
474
+ });
475
+
476
+ describe("promptMultiselect", () => {
477
+ it("creates a multiselect Prompt effect", () => {
478
+ const choices = [
479
+ { label: "TypeScript", value: "ts" },
480
+ { label: "ESLint", value: "eslint" },
481
+ { label: "Prettier", value: "prettier" },
482
+ ];
483
+ const task = promptMultiselect("features", "Select features:", choices, [
484
+ "ts",
485
+ "prettier",
486
+ ]);
487
+ const { effects } = dryRun(task);
488
+
489
+ expect(effects.length).toBe(1);
490
+ expect(effects[0]._tag).toBe("Prompt");
491
+ const question = (
492
+ effects[0] as {
493
+ question: {
494
+ type: string;
495
+ choices: typeof choices;
496
+ default?: string[];
497
+ };
498
+ }
499
+ ).question;
500
+ expect(question.type).toBe("multiselect");
501
+ expect(question.choices).toEqual(choices);
502
+ expect(question.default).toEqual(["ts", "prettier"]);
503
+ });
504
+
505
+ it("returns default values in dry run", () => {
506
+ const choices = [
507
+ { label: "A", value: "a" },
508
+ { label: "B", value: "b" },
509
+ ];
510
+ const task = promptMultiselect("choices", "Pick:", choices, ["a", "b"]);
511
+ const { value } = dryRun(task);
512
+ expect(value).toEqual(["a", "b"]);
513
+ });
514
+
515
+ it("returns empty array when no default in dry run", () => {
516
+ const choices = [
517
+ { label: "A", value: "a" },
518
+ { label: "B", value: "b" },
519
+ ];
520
+ const task = promptMultiselect("choices", "Pick:", choices);
521
+ const { value } = dryRun(task);
522
+ expect(value).toEqual([]);
523
+ });
524
+ });
525
+ });
526
+
527
+ // =============================================================================
528
+ // Logging Primitives
529
+ // =============================================================================
530
+
531
+ describe("Primitives - Logging", () => {
532
+ describe("log", () => {
533
+ it("creates a Log effect with the given level", () => {
534
+ const task = log("warn", "Warning message");
535
+ const { effects } = dryRun(task);
536
+
537
+ expect(effects.length).toBe(1);
538
+ expect(effects[0]._tag).toBe("Log");
539
+ expect((effects[0] as { level: string }).level).toBe("warn");
540
+ expect((effects[0] as { message: string }).message).toBe(
541
+ "Warning message",
542
+ );
543
+ });
544
+ });
545
+
546
+ describe("debug", () => {
547
+ it("creates a debug level log", () => {
548
+ const task = debug("Debug message");
549
+ const { effects } = dryRun(task);
550
+
551
+ expect(effects.length).toBe(1);
552
+ expect((effects[0] as { level: string }).level).toBe("debug");
553
+ expect((effects[0] as { message: string }).message).toBe("Debug message");
554
+ });
555
+ });
556
+
557
+ describe("info", () => {
558
+ it("creates an info level log", () => {
559
+ const task = info("Info message");
560
+ const { effects } = dryRun(task);
561
+
562
+ expect(effects.length).toBe(1);
563
+ expect((effects[0] as { level: string }).level).toBe("info");
564
+ expect((effects[0] as { message: string }).message).toBe("Info message");
565
+ });
566
+ });
567
+
568
+ describe("warn", () => {
569
+ it("creates a warn level log", () => {
570
+ const task = warn("Warning message");
571
+ const { effects } = dryRun(task);
572
+
573
+ expect(effects.length).toBe(1);
574
+ expect((effects[0] as { level: string }).level).toBe("warn");
575
+ expect((effects[0] as { message: string }).message).toBe(
576
+ "Warning message",
577
+ );
578
+ });
579
+ });
580
+
581
+ describe("error", () => {
582
+ it("creates an error level log", () => {
583
+ const task = error("Error message");
584
+ const { effects } = dryRun(task);
585
+
586
+ expect(effects.length).toBe(1);
587
+ expect((effects[0] as { level: string }).level).toBe("error");
588
+ expect((effects[0] as { message: string }).message).toBe("Error message");
589
+ });
590
+ });
591
+
592
+ describe("log message handling", () => {
593
+ it("handles empty message", () => {
594
+ const task = info("");
595
+ const { effects } = dryRun(task);
596
+ expect((effects[0] as { message: string }).message).toBe("");
597
+ });
598
+
599
+ it("handles multiline message", () => {
600
+ const message = "Line 1\nLine 2\nLine 3";
601
+ const task = info(message);
602
+ const { effects } = dryRun(task);
603
+ expect((effects[0] as { message: string }).message).toBe(message);
604
+ });
605
+
606
+ it("handles message with special characters", () => {
607
+ const message = 'Special: @#$%^&*()[]{}|\\;"<>';
608
+ const task = info(message);
609
+ const { effects } = dryRun(task);
610
+ expect((effects[0] as { message: string }).message).toBe(message);
611
+ });
612
+
613
+ it("handles message with unicode", () => {
614
+ const message = "Unicode: \u{1F600} \u{1F4A5} \u{2705}";
615
+ const task = info(message);
616
+ const { effects } = dryRun(task);
617
+ expect((effects[0] as { message: string }).message).toBe(message);
618
+ });
619
+ });
620
+ });
621
+
622
+ // =============================================================================
623
+ // Context Primitives
624
+ // =============================================================================
625
+
626
+ describe("Primitives - Context", () => {
627
+ describe("getContext", () => {
628
+ it("creates a ReadContext effect", () => {
629
+ const task = getContext("myKey");
630
+ const { effects } = dryRun(task);
631
+
632
+ expect(effects.length).toBe(1);
633
+ expect(effects[0]._tag).toBe("ReadContext");
634
+ expect((effects[0] as { key: string }).key).toBe("myKey");
635
+ });
636
+
637
+ it("returns undefined by default in dry run", () => {
638
+ const task = getContext("anyKey");
639
+ const { value } = dryRun(task);
640
+ expect(value).toBeUndefined();
641
+ });
642
+
643
+ it("handles dot notation keys", () => {
644
+ const task = getContext("user.settings.theme");
645
+ const { effects } = dryRun(task);
646
+ expect((effects[0] as { key: string }).key).toBe("user.settings.theme");
647
+ });
648
+ });
649
+
650
+ describe("setContext", () => {
651
+ it("creates a WriteContext effect", () => {
652
+ const task = setContext("myKey", { data: 123 });
653
+ const { effects } = dryRun(task);
654
+
655
+ expect(effects.length).toBe(1);
656
+ expect(effects[0]._tag).toBe("WriteContext");
657
+ expect((effects[0] as { key: string }).key).toBe("myKey");
658
+ expect((effects[0] as { value: unknown }).value).toEqual({ data: 123 });
659
+ });
660
+
661
+ it("handles various value types", () => {
662
+ const tests = [
663
+ { key: "string", value: "hello" },
664
+ { key: "number", value: 42 },
665
+ { key: "boolean", value: true },
666
+ { key: "null", value: null },
667
+ { key: "array", value: [1, 2, 3] },
668
+ { key: "object", value: { nested: { deep: true } } },
669
+ ];
670
+
671
+ for (const test of tests) {
672
+ const task = setContext(test.key, test.value);
673
+ const { effects } = dryRun(task);
674
+ expect((effects[0] as { value: unknown }).value).toEqual(test.value);
675
+ }
676
+ });
677
+ });
678
+
679
+ describe("withContext", () => {
680
+ it("creates a WriteContext effect with continuation", () => {
681
+ const innerTask = readFile("/file.txt");
682
+ const task = withContext("tempKey", "tempValue", innerTask);
683
+ const { effects } = dryRun(task);
684
+
685
+ expect(effects.length).toBe(2);
686
+ expect(effects[0]._tag).toBe("WriteContext");
687
+ expect((effects[0] as { key: string }).key).toBe("tempKey");
688
+ expect((effects[0] as { value: unknown }).value).toBe("tempValue");
689
+ expect(effects[1]._tag).toBe("ReadFile");
690
+ });
691
+ });
692
+ });
693
+
694
+ // =============================================================================
695
+ // Pure Primitives
696
+ // =============================================================================
697
+
698
+ describe("Primitives - Pure", () => {
699
+ describe("noop", () => {
700
+ it("creates no effects and returns undefined", () => {
701
+ const { effects, value } = dryRun(noop);
702
+
703
+ expect(effects.length).toBe(0);
704
+ expect(value).toBeUndefined();
705
+ });
706
+
707
+ it("can be used in sequences", () => {
708
+ // noop is useful as a placeholder in conditional logic
709
+ expect(noop._tag).toBe("Pure");
710
+ });
711
+ });
712
+
713
+ describe("succeed", () => {
714
+ it("creates no effects and returns the given value", () => {
715
+ const { effects, value } = dryRun(succeed(42));
716
+
717
+ expect(effects.length).toBe(0);
718
+ expect(value).toBe(42);
719
+ });
720
+
721
+ it("works with various types", () => {
722
+ expect(dryRun(succeed("hello")).value).toBe("hello");
723
+ expect(dryRun(succeed([1, 2, 3])).value).toEqual([1, 2, 3]);
724
+ expect(dryRun(succeed({ key: "value" })).value).toEqual({ key: "value" });
725
+ expect(dryRun(succeed(null)).value).toBeNull();
726
+ expect(dryRun(succeed(undefined)).value).toBeUndefined();
727
+ });
728
+
729
+ it("preserves referential equality", () => {
730
+ const obj = { a: 1 };
731
+ const { value } = dryRun(succeed(obj));
732
+ expect(value).toBe(obj);
733
+ });
734
+ });
735
+ });
736
+
737
+ // =============================================================================
738
+ // Integration Tests
739
+ // =============================================================================
740
+
741
+ describe("Primitives - Integration", () => {
742
+ it("can chain multiple file operations", () => {
743
+ const task = readFile("/input.txt");
744
+ const { effects } = dryRun(task);
745
+
746
+ expect(effects[0]._tag).toBe("ReadFile");
747
+ });
748
+
749
+ it("logging primitives produce correct effect sequence", () => {
750
+ const { effects } = dryRun(debug("step 1"));
751
+ expect(effects.length).toBe(1);
752
+ expect((effects[0] as { level: string }).level).toBe("debug");
753
+ });
754
+ });
755
+
756
+ // =============================================================================
757
+ // Edge Cases
758
+ // =============================================================================
759
+
760
+ describe("Primitives - Edge Cases", () => {
761
+ describe("Path handling", () => {
762
+ it("handles Windows-style paths", () => {
763
+ const task = readFile("C:\\Users\\name\\file.txt");
764
+ const { effects } = dryRun(task);
765
+ expect((effects[0] as { path: string }).path).toBe(
766
+ "C:\\Users\\name\\file.txt",
767
+ );
768
+ });
769
+
770
+ it("handles paths with trailing slash", () => {
771
+ const task = mkdir("/path/to/dir/");
772
+ const { effects } = dryRun(task);
773
+ expect((effects[0] as { path: string }).path).toBe("/path/to/dir/");
774
+ });
775
+
776
+ it("handles empty path", () => {
777
+ const task = readFile("");
778
+ const { effects } = dryRun(task);
779
+ expect((effects[0] as { path: string }).path).toBe("");
780
+ });
781
+ });
782
+
783
+ describe("Content handling", () => {
784
+ it("handles very large content", () => {
785
+ const largeContent = "x".repeat(1_000_000);
786
+ const task = writeFile("/large.txt", largeContent);
787
+ const { effects } = dryRun(task);
788
+ expect((effects[0] as { content: string }).content.length).toBe(
789
+ 1_000_000,
790
+ );
791
+ });
792
+
793
+ it("handles content with null characters", () => {
794
+ const content = "before\x00after";
795
+ const task = writeFile("/binary.dat", content);
796
+ const { effects } = dryRun(task);
797
+ expect((effects[0] as { content: string }).content).toBe(content);
798
+ });
799
+ });
800
+
801
+ describe("Command handling", () => {
802
+ it("handles command with absolute path", () => {
803
+ const task = exec("/usr/bin/node", ["--version"]);
804
+ const { effects } = dryRun(task);
805
+ expect((effects[0] as { command: string }).command).toBe("/usr/bin/node");
806
+ });
807
+
808
+ it("handles arguments with spaces", () => {
809
+ const task = exec("echo", ["hello world", "foo bar"]);
810
+ const { effects } = dryRun(task);
811
+ expect((effects[0] as { args: string[] }).args).toEqual([
812
+ "hello world",
813
+ "foo bar",
814
+ ]);
815
+ });
816
+
817
+ it("handles empty command in execSimple", () => {
818
+ const task = execSimple("");
819
+ const { effects } = dryRun(task);
820
+ expect((effects[0] as { command: string }).command).toBe("");
821
+ });
822
+ });
823
+ });
824
+
825
+ // =============================================================================
826
+ // File Transformation Primitives
827
+ // =============================================================================
828
+
829
+ describe("Primitives - File Transformation", () => {
830
+ describe("sortFileLines", () => {
831
+ const createMocks = (content: string) =>
832
+ new Map([["ReadFile", () => content]]);
833
+
834
+ it("sorts lines alphabetically by default", () => {
835
+ const mockContent = "zebra\napple\nmango\nbanana";
836
+ const task = sortFileLines("/exports.ts");
837
+
838
+ const { effects } = dryRunWith(task, createMocks(mockContent));
839
+
840
+ expect(effects.length).toBe(2);
841
+ expect(effects[0]._tag).toBe("ReadFile");
842
+ expect(effects[1]._tag).toBe("WriteFile");
843
+
844
+ const writeEffect = effects[1] as { content: string };
845
+ expect(writeEffect.content).toBe("apple\nbanana\nmango\nzebra");
846
+ });
847
+
848
+ it("removes duplicates when unique option is true", () => {
849
+ const mockContent = "apple\nbanana\napple\nmango\nbanana";
850
+ const task = sortFileLines("/exports.ts", { unique: true });
851
+
852
+ const { effects } = dryRunWith(task, createMocks(mockContent));
853
+
854
+ const writeEffect = effects[1] as { content: string };
855
+ expect(writeEffect.content).toBe("apple\nbanana\nmango");
856
+ });
857
+
858
+ it("preserves header lines matching pattern", () => {
859
+ const mockContent =
860
+ "// Header comment\n// Another header\nexport { z } from './z';\nexport { a } from './a';\nexport { m } from './m';";
861
+ const task = sortFileLines("/exports.ts", {
862
+ headerPattern: /^\/\//,
863
+ });
864
+
865
+ const { effects } = dryRunWith(task, createMocks(mockContent));
866
+
867
+ const writeEffect = effects[1] as { content: string };
868
+ const lines = writeEffect.content.split("\n");
869
+
870
+ // Header lines should be preserved at top
871
+ expect(lines[0]).toBe("// Header comment");
872
+ expect(lines[1]).toBe("// Another header");
873
+ // Body should be sorted
874
+ expect(lines[2]).toBe("export { a } from './a';");
875
+ expect(lines[3]).toBe("export { m } from './m';");
876
+ expect(lines[4]).toBe("export { z } from './z';");
877
+ });
878
+
879
+ it("preserves footer lines matching pattern", () => {
880
+ const mockContent =
881
+ "export { z } from './z';\nexport { a } from './a';\n// Footer\n// End";
882
+ const task = sortFileLines("/exports.ts", {
883
+ footerPattern: /^\/\//,
884
+ });
885
+
886
+ const { effects } = dryRunWith(task, createMocks(mockContent));
887
+
888
+ const writeEffect = effects[1] as { content: string };
889
+ const lines = writeEffect.content.split("\n");
890
+
891
+ // Body should be sorted
892
+ expect(lines[0]).toBe("export { a } from './a';");
893
+ expect(lines[1]).toBe("export { z } from './z';");
894
+ // Footer lines should be preserved at bottom
895
+ expect(lines[2]).toBe("// Footer");
896
+ expect(lines[3]).toBe("// End");
897
+ });
898
+
899
+ it("accepts custom comparator", () => {
900
+ const mockContent = "Apple\nbanana\nCherry";
901
+ const task = sortFileLines("/exports.ts", {
902
+ compare: (a, b) => a.toLowerCase().localeCompare(b.toLowerCase()),
903
+ });
904
+
905
+ const { effects } = dryRunWith(task, createMocks(mockContent));
906
+
907
+ const writeEffect = effects[1] as { content: string };
908
+ expect(writeEffect.content).toBe("Apple\nbanana\nCherry");
909
+ });
910
+
911
+ it("handles empty file", () => {
912
+ const task = sortFileLines("/empty.ts");
913
+
914
+ const { effects } = dryRunWith(task, createMocks(""));
915
+
916
+ const writeEffect = effects[1] as { content: string };
917
+ expect(writeEffect.content).toBe("");
918
+ });
919
+
920
+ it("handles single line file", () => {
921
+ const task = sortFileLines("/single.ts");
922
+
923
+ const { effects } = dryRunWith(task, createMocks("only line"));
924
+
925
+ const writeEffect = effects[1] as { content: string };
926
+ expect(writeEffect.content).toBe("only line");
927
+ });
928
+
929
+ it("handles barrel file with exports", () => {
930
+ const mockContent = `// Auto-generated barrel
931
+ export { zebra } from "./zebra.js";
932
+ export { apple } from "./apple.js";
933
+ export { mango } from "./mango.js";`;
934
+
935
+ const task = sortFileLines("/index.ts", {
936
+ headerPattern: /^\/\//,
937
+ });
938
+
939
+ const { effects } = dryRunWith(task, createMocks(mockContent));
940
+
941
+ const writeEffect = effects[1] as { content: string };
942
+ const lines = writeEffect.content.split("\n");
943
+
944
+ expect(lines[0]).toBe("// Auto-generated barrel");
945
+ expect(lines[1]).toBe('export { apple } from "./apple.js";');
946
+ expect(lines[2]).toBe('export { mango } from "./mango.js";');
947
+ expect(lines[3]).toBe('export { zebra } from "./zebra.js";');
948
+ });
949
+
950
+ it("combines header, unique, and custom comparator", () => {
951
+ const mockContent = `// Header
952
+ B
953
+ a
954
+ B
955
+ A
956
+ c`;
957
+
958
+ const task = sortFileLines("/test.ts", {
959
+ headerPattern: /^\/\//,
960
+ unique: true,
961
+ compare: (a, b) => a.toLowerCase().localeCompare(b.toLowerCase()),
962
+ });
963
+
964
+ const { effects } = dryRunWith(task, createMocks(mockContent));
965
+
966
+ const writeEffect = effects[1] as { content: string };
967
+ expect(writeEffect.content).toBe("// Header\na\nA\nB\nc");
968
+ });
969
+ });
970
+ });