@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
package/src/dry-run.ts ADDED
@@ -0,0 +1,434 @@
1
+ /**
2
+ * Dry-Run Interpreter
3
+ *
4
+ * This module provides interpreters for testing and previewing tasks
5
+ * without actually executing their effects.
6
+ */
7
+
8
+ import { TaskExecutionError } from "./interpreter.js";
9
+ import type { DryRunResult, Effect, Task } from "./types.js";
10
+
11
+ // =============================================================================
12
+ // Mock Effect Results
13
+ // =============================================================================
14
+
15
+ /**
16
+ * Generate a mock result for an effect.
17
+ * This allows tasks to continue executing in dry-run mode.
18
+ */
19
+ export const mockEffect = (effect: Effect): unknown => {
20
+ switch (effect._tag) {
21
+ case "ReadFile":
22
+ return `[mock content of ${effect.path}]`;
23
+
24
+ case "WriteFile":
25
+ case "AppendFile":
26
+ case "CopyFile":
27
+ case "CopyDirectory":
28
+ case "DeleteFile":
29
+ case "DeleteDirectory":
30
+ case "MakeDir":
31
+ case "Log":
32
+ case "WriteContext":
33
+ return undefined;
34
+
35
+ case "Exists":
36
+ return true;
37
+
38
+ case "Glob":
39
+ return [];
40
+
41
+ case "Exec":
42
+ return { stdout: "", stderr: "", exitCode: 0 };
43
+
44
+ case "Prompt":
45
+ // Return default or first choice
46
+ switch (effect.question.type) {
47
+ case "text":
48
+ return effect.question.default ?? "";
49
+ case "confirm":
50
+ return effect.question.default ?? false;
51
+ case "select":
52
+ return (
53
+ effect.question.default ?? effect.question.choices[0]?.value ?? ""
54
+ );
55
+ case "multiselect":
56
+ return effect.question.default ?? [];
57
+ }
58
+ break;
59
+
60
+ case "ReadContext":
61
+ return undefined;
62
+
63
+ case "Parallel":
64
+ return effect.tasks.map((t) => {
65
+ const result = dryRun(t);
66
+ return result.value;
67
+ });
68
+
69
+ case "Race":
70
+ if (effect.tasks.length > 0) {
71
+ return dryRun(effect.tasks[0]).value;
72
+ }
73
+ return undefined;
74
+ }
75
+ };
76
+
77
+ // =============================================================================
78
+ // Dry-Run Interpreter
79
+ // =============================================================================
80
+
81
+ /**
82
+ * Run a task in dry-run mode, collecting effects without executing them.
83
+ * Tracks virtual filesystem state to properly handle exists() checks.
84
+ */
85
+ export const dryRun = <A>(task: Task<A>): DryRunResult<A> => {
86
+ const effects: Effect[] = [];
87
+ // Track files/dirs that would be created during dry-run
88
+ const virtualFs = new Set<string>();
89
+
90
+ const mockEffectWithFs = (effect: Effect): unknown => {
91
+ switch (effect._tag) {
92
+ case "WriteFile":
93
+ case "AppendFile":
94
+ virtualFs.add(effect.path);
95
+ return undefined;
96
+ case "MakeDir":
97
+ virtualFs.add(effect.path);
98
+ return undefined;
99
+ case "Exists":
100
+ // Check if file was "created" during this dry-run
101
+ return virtualFs.has(effect.path);
102
+ default:
103
+ return mockEffect(effect);
104
+ }
105
+ };
106
+
107
+ const run = <T>(t: Task<T>): T => {
108
+ switch (t._tag) {
109
+ case "Pure":
110
+ return t.value;
111
+
112
+ case "Fail":
113
+ throw new TaskExecutionError(t.error);
114
+
115
+ case "Effect": {
116
+ const effect = t.effect;
117
+
118
+ // Handle Parallel specially - collect effects from all child tasks
119
+ if (effect._tag === "Parallel") {
120
+ const results = effect.tasks.map((childTask) => {
121
+ const childResult = dryRunWithVirtualFs(childTask, virtualFs);
122
+ effects.push(...childResult.effects);
123
+ return childResult.value;
124
+ });
125
+ return run(t.cont(results) as Task<T>);
126
+ }
127
+
128
+ // Handle Race specially - collect effects from first child task
129
+ if (effect._tag === "Race") {
130
+ if (effect.tasks.length > 0) {
131
+ const childResult = dryRunWithVirtualFs(effect.tasks[0], virtualFs);
132
+ effects.push(...childResult.effects);
133
+ return run(t.cont(childResult.value) as Task<T>);
134
+ }
135
+ return run(t.cont(undefined) as Task<T>);
136
+ }
137
+
138
+ // Regular effects - add to list and mock
139
+ effects.push(effect);
140
+ const mockResult = mockEffectWithFs(effect);
141
+ return run(t.cont(mockResult) as Task<T>);
142
+ }
143
+ }
144
+ };
145
+
146
+ const value = run(task);
147
+ return { value, effects };
148
+ };
149
+
150
+ /**
151
+ * Internal helper: Run dry-run with shared virtual filesystem state.
152
+ */
153
+ const dryRunWithVirtualFs = <A>(
154
+ task: Task<A>,
155
+ virtualFs: Set<string>,
156
+ ): DryRunResult<A> => {
157
+ const effects: Effect[] = [];
158
+
159
+ const mockEffectWithFs = (effect: Effect): unknown => {
160
+ switch (effect._tag) {
161
+ case "WriteFile":
162
+ case "AppendFile":
163
+ virtualFs.add(effect.path);
164
+ return undefined;
165
+ case "MakeDir":
166
+ virtualFs.add(effect.path);
167
+ return undefined;
168
+ case "Exists":
169
+ return virtualFs.has(effect.path);
170
+ default:
171
+ return mockEffect(effect);
172
+ }
173
+ };
174
+
175
+ const run = <T>(t: Task<T>): T => {
176
+ switch (t._tag) {
177
+ case "Pure":
178
+ return t.value;
179
+
180
+ case "Fail":
181
+ throw new TaskExecutionError(t.error);
182
+
183
+ case "Effect": {
184
+ const effect = t.effect;
185
+
186
+ if (effect._tag === "Parallel") {
187
+ const results = effect.tasks.map((childTask) => {
188
+ const childResult = dryRunWithVirtualFs(childTask, virtualFs);
189
+ effects.push(...childResult.effects);
190
+ return childResult.value;
191
+ });
192
+ return run(t.cont(results) as Task<T>);
193
+ }
194
+
195
+ if (effect._tag === "Race") {
196
+ if (effect.tasks.length > 0) {
197
+ const childResult = dryRunWithVirtualFs(effect.tasks[0], virtualFs);
198
+ effects.push(...childResult.effects);
199
+ return run(t.cont(childResult.value) as Task<T>);
200
+ }
201
+ return run(t.cont(undefined) as Task<T>);
202
+ }
203
+
204
+ effects.push(effect);
205
+ const mockResult = mockEffectWithFs(effect);
206
+ return run(t.cont(mockResult) as Task<T>);
207
+ }
208
+ }
209
+ };
210
+
211
+ const value = run(task);
212
+ return { value, effects };
213
+ };
214
+
215
+ /**
216
+ * Run a task in dry-run mode with custom mock values.
217
+ */
218
+ export const dryRunWith = <A>(
219
+ task: Task<A>,
220
+ mocks: Map<string, (effect: Effect) => unknown>,
221
+ ): DryRunResult<A> => {
222
+ const effects: Effect[] = [];
223
+
224
+ const getMock = (effect: Effect): unknown => {
225
+ const customMock = mocks.get(effect._tag);
226
+ if (customMock) {
227
+ return customMock(effect);
228
+ }
229
+ return mockEffect(effect);
230
+ };
231
+
232
+ const run = (t: Task<A>): A => {
233
+ switch (t._tag) {
234
+ case "Pure":
235
+ return t.value;
236
+
237
+ case "Fail":
238
+ throw new TaskExecutionError(t.error);
239
+
240
+ case "Effect": {
241
+ effects.push(t.effect);
242
+ const mockResult = getMock(t.effect);
243
+ return run(t.cont(mockResult));
244
+ }
245
+ }
246
+ };
247
+
248
+ const value = run(task);
249
+ return { value, effects };
250
+ };
251
+
252
+ // =============================================================================
253
+ // Effect Collection
254
+ // =============================================================================
255
+
256
+ /**
257
+ * Collect all effects from a task without running to completion.
258
+ * Useful for testing what effects a task would produce.
259
+ */
260
+ export const collectEffects = <A>(task: Task<A>): Effect[] => {
261
+ const effects: Effect[] = [];
262
+
263
+ const collect = (t: Task<unknown>): void => {
264
+ switch (t._tag) {
265
+ case "Pure":
266
+ case "Fail":
267
+ return;
268
+
269
+ case "Effect":
270
+ effects.push(t.effect);
271
+ collect(t.cont(mockEffect(t.effect)));
272
+ }
273
+ };
274
+
275
+ collect(task);
276
+ return effects;
277
+ };
278
+
279
+ /**
280
+ * Count the number of effects of each type.
281
+ */
282
+ export const countEffects = (effects: Effect[]): Record<string, number> => {
283
+ const counts: Record<string, number> = {};
284
+ for (const effect of effects) {
285
+ counts[effect._tag] = (counts[effect._tag] ?? 0) + 1;
286
+ }
287
+ return counts;
288
+ };
289
+
290
+ /**
291
+ * Filter effects by type.
292
+ */
293
+ export const filterEffects = <T extends Effect["_tag"]>(
294
+ effects: Effect[],
295
+ tag: T,
296
+ ): Array<Extract<Effect, { _tag: T }>> =>
297
+ effects.filter((e): e is Extract<Effect, { _tag: T }> => e._tag === tag);
298
+
299
+ /**
300
+ * Get all file write operations from effects.
301
+ */
302
+ export const getFileWrites = (
303
+ effects: Effect[],
304
+ ): Array<{ path: string; content: string }> =>
305
+ filterEffects(effects, "WriteFile").map((e) => ({
306
+ path: e.path,
307
+ content: e.content,
308
+ }));
309
+
310
+ /**
311
+ * Get all file paths that would be created/modified.
312
+ */
313
+ export const getAffectedFiles = (effects: Effect[]): string[] => {
314
+ const files = new Set<string>();
315
+
316
+ for (const effect of effects) {
317
+ switch (effect._tag) {
318
+ case "WriteFile":
319
+ case "AppendFile":
320
+ case "DeleteFile":
321
+ files.add(effect.path);
322
+ break;
323
+ case "CopyFile":
324
+ files.add(effect.dest);
325
+ break;
326
+ case "MakeDir":
327
+ files.add(effect.path);
328
+ break;
329
+ }
330
+ }
331
+
332
+ return Array.from(files).sort();
333
+ };
334
+
335
+ // =============================================================================
336
+ // Test Utilities
337
+ // =============================================================================
338
+
339
+ /**
340
+ * Assert that a task produces specific effects.
341
+ */
342
+ export const assertEffects = <A>(
343
+ task: Task<A>,
344
+ expectedEffects: Partial<Effect>[],
345
+ ): void => {
346
+ const { effects } = dryRun(task);
347
+
348
+ if (effects.length !== expectedEffects.length) {
349
+ throw new Error(
350
+ `Expected ${expectedEffects.length} effects, got ${effects.length}`,
351
+ );
352
+ }
353
+
354
+ for (let i = 0; i < effects.length; i++) {
355
+ const actual = effects[i];
356
+ const expected = expectedEffects[i];
357
+
358
+ for (const [key, value] of Object.entries(expected)) {
359
+ if ((actual as Record<string, unknown>)[key] !== value) {
360
+ throw new Error(
361
+ `Effect ${i}: expected ${key} to be ${JSON.stringify(value)}, got ${JSON.stringify((actual as Record<string, unknown>)[key])}`,
362
+ );
363
+ }
364
+ }
365
+ }
366
+ };
367
+
368
+ /**
369
+ * Assert that a task would write specific files.
370
+ */
371
+ export const assertFileWrites = <A>(
372
+ task: Task<A>,
373
+ expectedFiles: string[],
374
+ ): void => {
375
+ const { effects } = dryRun(task);
376
+ const actualFiles = getAffectedFiles(effects);
377
+ const expected = expectedFiles.sort();
378
+
379
+ if (actualFiles.length !== expected.length) {
380
+ throw new Error(
381
+ `Expected ${expected.length} file writes, got ${actualFiles.length}\n` +
382
+ `Expected: ${expected.join(", ")}\n` +
383
+ `Actual: ${actualFiles.join(", ")}`,
384
+ );
385
+ }
386
+
387
+ for (let i = 0; i < expected.length; i++) {
388
+ if (actualFiles[i] !== expected[i]) {
389
+ throw new Error(`Expected file ${expected[i]}, got ${actualFiles[i]}`);
390
+ }
391
+ }
392
+ };
393
+
394
+ /**
395
+ * Create a task result matcher for testing.
396
+ */
397
+ export const expectTask = <A>(task: Task<A>) => {
398
+ const result = dryRun(task);
399
+
400
+ return {
401
+ toHaveValue: (expected: A) => {
402
+ if (result.value !== expected) {
403
+ throw new Error(
404
+ `Expected value ${JSON.stringify(expected)}, got ${JSON.stringify(result.value)}`,
405
+ );
406
+ }
407
+ },
408
+
409
+ toHaveEffectCount: (count: number) => {
410
+ if (result.effects.length !== count) {
411
+ throw new Error(
412
+ `Expected ${count} effects, got ${result.effects.length}`,
413
+ );
414
+ }
415
+ },
416
+
417
+ toWriteFile: (path: string) => {
418
+ const writes = filterEffects(result.effects, "WriteFile");
419
+ if (!writes.some((w) => w.path === path)) {
420
+ throw new Error(`Expected task to write file ${path}`);
421
+ }
422
+ },
423
+
424
+ toNotWriteFile: (path: string) => {
425
+ const writes = filterEffects(result.effects, "WriteFile");
426
+ if (writes.some((w) => w.path === path)) {
427
+ throw new Error(`Expected task to not write file ${path}`);
428
+ }
429
+ },
430
+
431
+ effects: result.effects,
432
+ value: result.value,
433
+ };
434
+ };
package/src/effect.ts ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Effect Constructors
3
+ *
4
+ * This module provides constructor functions for creating Effect values.
5
+ * Effects are pure data that describe operations without performing them.
6
+ */
7
+
8
+ import type { Effect, LogLevel, PromptQuestion, Task } from "./types.js";
9
+
10
+ // =============================================================================
11
+ // File System Effect Constructors
12
+ // =============================================================================
13
+
14
+ export const readFileEffect = (path: string): Effect => ({
15
+ _tag: "ReadFile",
16
+ path,
17
+ });
18
+
19
+ export const writeFileEffect = (path: string, content: string): Effect => ({
20
+ _tag: "WriteFile",
21
+ path,
22
+ content,
23
+ });
24
+
25
+ export const appendFileEffect = (
26
+ path: string,
27
+ content: string,
28
+ createIfMissing = true,
29
+ ): Effect => ({
30
+ _tag: "AppendFile",
31
+ path,
32
+ content,
33
+ createIfMissing,
34
+ });
35
+
36
+ export const copyFileEffect = (source: string, dest: string): Effect => ({
37
+ _tag: "CopyFile",
38
+ source,
39
+ dest,
40
+ });
41
+
42
+ export const copyDirectoryEffect = (source: string, dest: string): Effect => ({
43
+ _tag: "CopyDirectory",
44
+ source,
45
+ dest,
46
+ });
47
+
48
+ export const deleteFileEffect = (path: string): Effect => ({
49
+ _tag: "DeleteFile",
50
+ path,
51
+ });
52
+
53
+ export const deleteDirectoryEffect = (path: string): Effect => ({
54
+ _tag: "DeleteDirectory",
55
+ path,
56
+ });
57
+
58
+ export const makeDirEffect = (path: string, recursive = true): Effect => ({
59
+ _tag: "MakeDir",
60
+ path,
61
+ recursive,
62
+ });
63
+
64
+ export const existsEffect = (path: string): Effect => ({
65
+ _tag: "Exists",
66
+ path,
67
+ });
68
+
69
+ export const globEffect = (pattern: string, cwd: string): Effect => ({
70
+ _tag: "Glob",
71
+ pattern,
72
+ cwd,
73
+ });
74
+
75
+ // =============================================================================
76
+ // Process Effect Constructors
77
+ // =============================================================================
78
+
79
+ export const execEffect = (
80
+ command: string,
81
+ args: string[],
82
+ cwd?: string,
83
+ ): Effect => ({
84
+ _tag: "Exec",
85
+ command,
86
+ args,
87
+ cwd,
88
+ });
89
+
90
+ // =============================================================================
91
+ // Prompt Effect Constructors
92
+ // =============================================================================
93
+
94
+ export const promptEffect = (question: PromptQuestion): Effect => ({
95
+ _tag: "Prompt",
96
+ question,
97
+ });
98
+
99
+ // =============================================================================
100
+ // Logging Effect Constructors
101
+ // =============================================================================
102
+
103
+ export const logEffect = (level: LogLevel, message: string): Effect => ({
104
+ _tag: "Log",
105
+ level,
106
+ message,
107
+ });
108
+
109
+ // =============================================================================
110
+ // Context Effect Constructors
111
+ // =============================================================================
112
+
113
+ export const readContextEffect = (key: string): Effect => ({
114
+ _tag: "ReadContext",
115
+ key,
116
+ });
117
+
118
+ export const writeContextEffect = (key: string, value: unknown): Effect => ({
119
+ _tag: "WriteContext",
120
+ key,
121
+ value,
122
+ });
123
+
124
+ // =============================================================================
125
+ // Concurrency Effect Constructors
126
+ // =============================================================================
127
+
128
+ export const parallelEffect = (tasks: Task<unknown>[]): Effect => ({
129
+ _tag: "Parallel",
130
+ tasks,
131
+ });
132
+
133
+ export const raceEffect = (tasks: Task<unknown>[]): Effect => ({
134
+ _tag: "Race",
135
+ tasks,
136
+ });
137
+
138
+ // =============================================================================
139
+ // Effect Utilities
140
+ // =============================================================================
141
+
142
+ /**
143
+ * Get a human-readable description of an effect.
144
+ */
145
+ export const describeEffect = (effect: Effect): string => {
146
+ switch (effect._tag) {
147
+ case "ReadFile":
148
+ return `Read file: ${effect.path}`;
149
+ case "WriteFile":
150
+ return `Write file: ${effect.path} (${effect.content.length} bytes)`;
151
+ case "AppendFile":
152
+ return `Append to file: ${effect.path} (${effect.content.length} bytes)${effect.createIfMissing ? " [create if missing]" : ""}`;
153
+ case "CopyFile":
154
+ return `Copy file: ${effect.source} → ${effect.dest}`;
155
+ case "CopyDirectory":
156
+ return `Copy directory: ${effect.source} → ${effect.dest}`;
157
+ case "DeleteFile":
158
+ return `Delete file: ${effect.path}`;
159
+ case "DeleteDirectory":
160
+ return `Delete directory: ${effect.path}`;
161
+ case "MakeDir":
162
+ return `Created ${effect.path}/`;
163
+ case "Exists":
164
+ return `Check exists: ${effect.path}`;
165
+ case "Glob":
166
+ return `Glob: ${effect.pattern} in ${effect.cwd}`;
167
+ case "Exec":
168
+ return `Execute: ${effect.command} ${effect.args.join(" ")}`;
169
+ case "Prompt":
170
+ return `Prompt: ${effect.question.message}`;
171
+ case "Log":
172
+ return `Log [${effect.level}]: ${effect.message}`;
173
+ case "ReadContext":
174
+ return `Read context: ${effect.key}`;
175
+ case "WriteContext":
176
+ return `Write context: ${effect.key}`;
177
+ case "Parallel":
178
+ return `Parallel: ${effect.tasks.length} tasks`;
179
+ case "Race":
180
+ return `Race: ${effect.tasks.length} tasks`;
181
+ }
182
+ };
183
+
184
+ /**
185
+ * Check if an effect modifies the file system.
186
+ */
187
+ export const isWriteEffect = (effect: Effect): boolean => {
188
+ switch (effect._tag) {
189
+ case "WriteFile":
190
+ case "AppendFile":
191
+ case "CopyFile":
192
+ case "CopyDirectory":
193
+ case "DeleteFile":
194
+ case "DeleteDirectory":
195
+ case "MakeDir":
196
+ return true;
197
+ default:
198
+ return false;
199
+ }
200
+ };
201
+
202
+ /**
203
+ * Get the file paths affected by an effect.
204
+ */
205
+ export const getAffectedPaths = (effect: Effect): string[] => {
206
+ switch (effect._tag) {
207
+ case "ReadFile":
208
+ case "WriteFile":
209
+ case "AppendFile":
210
+ case "DeleteFile":
211
+ case "MakeDir":
212
+ case "Exists":
213
+ return [effect.path];
214
+ case "CopyFile":
215
+ case "CopyDirectory":
216
+ return [effect.source, effect.dest];
217
+ case "DeleteDirectory":
218
+ return [effect.path];
219
+ case "Glob":
220
+ return [effect.cwd];
221
+ default:
222
+ return [];
223
+ }
224
+ };