@doeixd/machine 0.0.8 → 0.0.9

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.
@@ -0,0 +1,351 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * @file CLI tool for extracting statecharts from machine definitions
4
+ * @description
5
+ * Command-line interface for the @doeixd/machine statechart extraction system.
6
+ * Supports:
7
+ * - Single or multiple machine extraction
8
+ * - Config file support (.statechart.config.ts)
9
+ * - Watch mode for development
10
+ * - JSON validation against XState schema
11
+ * - Multiple output formats (JSON, Mermaid)
12
+ */
13
+
14
+ import { Command } from 'commander';
15
+ import * as fs from 'fs';
16
+ import * as path from 'path';
17
+ import chalk from 'chalk';
18
+ import chokidar from 'chokidar';
19
+ import {
20
+ extractMachine,
21
+ extractMachines,
22
+ type MachineConfig,
23
+ type ExtractionConfig,
24
+ } from '../src/extract';
25
+ import { Project } from 'ts-morph';
26
+
27
+ // =============================================================================
28
+ // CLI PROGRAM SETUP
29
+ // =============================================================================
30
+
31
+ const program = new Command();
32
+
33
+ program
34
+ .name('extract-statechart')
35
+ .description('Extract statechart definitions from TypeScript state machines')
36
+ .version('1.0.0');
37
+
38
+ program
39
+ .option('-i, --input <file>', 'Input file containing machine definitions')
40
+ .option('-o, --output <file>', 'Output file for the generated statechart')
41
+ .option('-c, --config <file>', 'Configuration file path', '.statechart.config.ts')
42
+ .option('-w, --watch', 'Watch mode - regenerate on file changes')
43
+ .option('-f, --format <type>', 'Output format: json, mermaid, or both', 'json')
44
+ .option('--validate', 'Validate output against XState JSON schema')
45
+ .option('-v, --verbose', 'Verbose logging')
46
+ .option('--id <id>', 'Machine ID (required with --input)')
47
+ .option('--classes <classes>', 'Comma-separated list of class names (required with --input)')
48
+ .option('--initial <state>', 'Initial state class name (required with --input)');
49
+
50
+ // =============================================================================
51
+ // HELPER FUNCTIONS
52
+ // =============================================================================
53
+
54
+ /**
55
+ * Converts a file path to a file:// URL compatible with dynamic import
56
+ */
57
+ function pathToFileURL(filePath: string): string {
58
+ // On Windows, convert backslashes and add file:// protocol
59
+ const normalized = path.resolve(filePath).replace(/\\/g, '/');
60
+ return `file:///${normalized}`;
61
+ }
62
+
63
+ /**
64
+ * Loads configuration from a TypeScript or JSON file
65
+ */
66
+ async function loadConfig(configPath: string): Promise<ExtractionConfig | null> {
67
+ const resolvedPath = path.resolve(process.cwd(), configPath);
68
+
69
+ // Check if file exists
70
+ if (!fs.existsSync(resolvedPath)) {
71
+ return null;
72
+ }
73
+
74
+ console.error(chalk.blue(`šŸ“„ Loading config from: ${resolvedPath}`));
75
+
76
+ // For TypeScript files, use dynamic import
77
+ if (resolvedPath.endsWith('.ts')) {
78
+ try {
79
+ // Convert to file:// URL for proper import on Windows
80
+ const fileUrl = pathToFileURL(resolvedPath);
81
+ const config = await import(fileUrl);
82
+ return config.default || config;
83
+ } catch (error) {
84
+ console.error(chalk.red(`āŒ Error loading config file:`), error);
85
+ return null;
86
+ }
87
+ }
88
+
89
+ // For JSON files, use fs
90
+ if (resolvedPath.endsWith('.json')) {
91
+ try {
92
+ const content = fs.readFileSync(resolvedPath, 'utf-8');
93
+ return JSON.parse(content);
94
+ } catch (error) {
95
+ console.error(chalk.red(`āŒ Error parsing JSON config:`), error);
96
+ return null;
97
+ }
98
+ }
99
+
100
+ console.error(chalk.yellow(`āš ļø Unsupported config file format: ${resolvedPath}`));
101
+ return null;
102
+ }
103
+
104
+ /**
105
+ * Writes output to file or stdout
106
+ */
107
+ function writeOutput(data: any, outputPath?: string, format: string = 'json'): void {
108
+ let output: string;
109
+
110
+ switch (format) {
111
+ case 'json':
112
+ output = JSON.stringify(data, null, 2);
113
+ break;
114
+ case 'mermaid':
115
+ output = generateMermaid(data);
116
+ break;
117
+ default:
118
+ output = JSON.stringify(data, null, 2);
119
+ }
120
+
121
+ if (outputPath) {
122
+ const resolvedPath = path.resolve(process.cwd(), outputPath);
123
+ const dir = path.dirname(resolvedPath);
124
+
125
+ // Create directory if it doesn't exist
126
+ if (!fs.existsSync(dir)) {
127
+ fs.mkdirSync(dir, { recursive: true });
128
+ }
129
+
130
+ fs.writeFileSync(resolvedPath, output, 'utf-8');
131
+ console.error(chalk.green(`āœ… Statechart written to: ${resolvedPath}`));
132
+ } else {
133
+ // Write to stdout
134
+ console.log(output);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Generates Mermaid diagram from statechart
140
+ * (Basic implementation - can be enhanced)
141
+ */
142
+ function generateMermaid(chart: any): string {
143
+ const lines: string[] = [
144
+ 'stateDiagram-v2',
145
+ ` [*] --> ${chart.initial}`,
146
+ ];
147
+
148
+ for (const [stateName, stateNode] of Object.entries(chart.states as any)) {
149
+ const node = stateNode as any;
150
+
151
+ // Add transitions
152
+ for (const [event, transition] of Object.entries(node.on || {})) {
153
+ const trans = transition as any;
154
+ const label = trans.description ? `${event}: ${trans.description}` : event;
155
+ lines.push(` ${stateName} --> ${trans.target} : ${label}`);
156
+ }
157
+ }
158
+
159
+ return lines.join('\n');
160
+ }
161
+
162
+ /**
163
+ * Validates statechart against XState JSON schema
164
+ * (Placeholder - needs actual schema and ajv integration)
165
+ */
166
+ function validateStatechart(chart: any): boolean {
167
+ // TODO: Implement actual validation with ajv and XState schema
168
+ console.error(chalk.yellow('āš ļø Validation not yet implemented'));
169
+
170
+ // Basic structure validation
171
+ if (!chart.id || !chart.initial || !chart.states) {
172
+ console.error(chalk.red('āŒ Invalid statechart structure'));
173
+ return false;
174
+ }
175
+
176
+ return true;
177
+ }
178
+
179
+ /**
180
+ * Extracts machines based on CLI options or config
181
+ */
182
+ async function extract(options: any): Promise<void> {
183
+ const verbose = options.verbose || false;
184
+
185
+ // Try loading config file first
186
+ let config: ExtractionConfig | null = null;
187
+
188
+ if (options.config) {
189
+ config = await loadConfig(options.config);
190
+ }
191
+
192
+ // If no config and no input, error
193
+ if (!config && !options.input) {
194
+ console.error(chalk.red('āŒ Error: Either --config or --input must be provided'));
195
+ console.error(chalk.gray(' Use --config to specify a config file'));
196
+ console.error(chalk.gray(' Or use --input, --id, --classes, and --initial for a single machine'));
197
+ process.exit(1);
198
+ }
199
+
200
+ // If input is provided via CLI, create a single-machine config
201
+ if (options.input) {
202
+ if (!options.id || !options.classes || !options.initial) {
203
+ console.error(chalk.red('āŒ Error: --input requires --id, --classes, and --initial'));
204
+ process.exit(1);
205
+ }
206
+
207
+ const machineConfig: MachineConfig = {
208
+ input: options.input,
209
+ classes: options.classes.split(',').map((s: string) => s.trim()),
210
+ id: options.id,
211
+ initialState: options.initial,
212
+ output: options.output,
213
+ };
214
+
215
+ config = {
216
+ machines: [machineConfig],
217
+ verbose,
218
+ format: options.format,
219
+ validate: options.validate,
220
+ };
221
+ }
222
+
223
+ if (!config) {
224
+ console.error(chalk.red('āŒ Error: Failed to load configuration'));
225
+ process.exit(1);
226
+ }
227
+
228
+ // Update config with CLI options (CLI overrides config file)
229
+ if (options.verbose !== undefined) config.verbose = options.verbose;
230
+ if (options.format) config.format = options.format;
231
+ if (options.validate !== undefined) config.validate = options.validate;
232
+
233
+ // Extract machines
234
+ try {
235
+ if (verbose) {
236
+ console.error(chalk.blue('\nšŸš€ Starting extraction...\n'));
237
+ }
238
+
239
+ const results = extractMachines(config);
240
+
241
+ // Validate if requested
242
+ if (config.validate) {
243
+ for (const chart of results) {
244
+ if (!validateStatechart(chart)) {
245
+ console.error(chalk.red(`āŒ Validation failed for machine: ${chart.id}`));
246
+ process.exit(1);
247
+ }
248
+ }
249
+ }
250
+
251
+ // Write outputs
252
+ if (config.machines.length === 1 && config.machines[0].output) {
253
+ // Single machine with specified output
254
+ writeOutput(results[0], config.machines[0].output, config.format || 'json');
255
+ } else if (config.machines.length === 1 && options.output) {
256
+ // Single machine with CLI output option
257
+ writeOutput(results[0], options.output, config.format || 'json');
258
+ } else {
259
+ // Multiple machines - write each to its own file or stdout
260
+ for (let i = 0; i < results.length; i++) {
261
+ const chart = results[i];
262
+ const machineConfig = config.machines[i];
263
+
264
+ if (machineConfig.output) {
265
+ writeOutput(chart, machineConfig.output, config.format || 'json');
266
+ } else {
267
+ // If no output specified, write to stdout (only for single machine)
268
+ if (results.length === 1) {
269
+ writeOutput(chart, undefined, config.format || 'json');
270
+ } else {
271
+ // For multiple machines without output paths, generate default names
272
+ const defaultOutput = `statecharts/${chart.id}.json`;
273
+ writeOutput(chart, defaultOutput, config.format || 'json');
274
+ }
275
+ }
276
+ }
277
+ }
278
+
279
+ if (verbose) {
280
+ console.error(chalk.green(`\nāœ… Extraction complete!`));
281
+ }
282
+ } catch (error) {
283
+ console.error(chalk.red('āŒ Extraction failed:'), error);
284
+ process.exit(1);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Watch mode - regenerate on file changes
290
+ */
291
+ async function watch(options: any): Promise<void> {
292
+ console.error(chalk.blue('šŸ‘€ Watch mode enabled - watching for file changes...\n'));
293
+
294
+ // Initial extraction
295
+ await extract(options);
296
+
297
+ // Load config to determine which files to watch
298
+ const config = options.config ? await loadConfig(options.config) : null;
299
+
300
+ const filesToWatch: string[] = [];
301
+
302
+ if (config) {
303
+ for (const machine of config.machines) {
304
+ filesToWatch.push(path.resolve(process.cwd(), machine.input));
305
+ }
306
+ // Also watch the config file itself
307
+ filesToWatch.push(path.resolve(process.cwd(), options.config));
308
+ } else if (options.input) {
309
+ filesToWatch.push(path.resolve(process.cwd(), options.input));
310
+ }
311
+
312
+ // Set up watcher
313
+ const watcher = chokidar.watch(filesToWatch, {
314
+ persistent: true,
315
+ ignoreInitial: true,
316
+ });
317
+
318
+ watcher.on('change', async (filePath) => {
319
+ console.error(chalk.yellow(`\nšŸ”„ File changed: ${filePath}`));
320
+ console.error(chalk.blue(' Re-extracting...\n'));
321
+
322
+ try {
323
+ await extract(options);
324
+ console.error(chalk.green(' āœ… Re-extraction complete\n'));
325
+ } catch (error) {
326
+ console.error(chalk.red(' āŒ Re-extraction failed:'), error);
327
+ }
328
+ });
329
+
330
+ console.error(chalk.gray(' Press Ctrl+C to stop watching\n'));
331
+ }
332
+
333
+ // =============================================================================
334
+ // MAIN EXECUTION
335
+ // =============================================================================
336
+
337
+ program.action(async (options) => {
338
+ try {
339
+ if (options.watch) {
340
+ await watch(options);
341
+ } else {
342
+ await extract(options);
343
+ }
344
+ } catch (error) {
345
+ console.error(chalk.red('āŒ Fatal error:'), error);
346
+ process.exit(1);
347
+ }
348
+ });
349
+
350
+ // Parse arguments and run
351
+ program.parse();
package/src/extract.ts CHANGED
@@ -324,25 +324,65 @@ function extractFromCallExpression(call: Node, verbose = false): any | null {
324
324
  break;
325
325
 
326
326
  case 'action':
327
- // Args: (action, transition)
328
- if (args[0]) {
329
- const actionMeta = parseObjectLiteral(args[0]);
330
- if (Object.keys(actionMeta).length > 0) {
331
- metadata.actions = [actionMeta];
332
- }
333
- }
334
- // Recurse into wrapped transition
335
- if (args[1] && Node.isCallExpression(args[1])) {
336
- const nested = extractFromCallExpression(args[1], verbose);
337
- if (nested) {
338
- Object.assign(metadata, nested);
339
- }
340
- }
341
- break;
342
-
343
- default:
344
- // Not a DSL primitive we recognize
345
- return null;
327
+ // Args: (action, transition)
328
+ if (args[0]) {
329
+ const actionMeta = parseObjectLiteral(args[0]);
330
+ if (Object.keys(actionMeta).length > 0) {
331
+ metadata.actions = [actionMeta];
332
+ }
333
+ }
334
+ // Recurse into wrapped transition
335
+ if (args[1] && Node.isCallExpression(args[1])) {
336
+ const nested = extractFromCallExpression(args[1], verbose);
337
+ if (nested) {
338
+ Object.assign(metadata, nested);
339
+ }
340
+ }
341
+ break;
342
+
343
+ case 'guard':
344
+ // Args: (condition, transition, options?)
345
+ // Extract description from options object (third argument)
346
+ if (args[2]) {
347
+ const options = parseObjectLiteral(args[2]);
348
+ if (options.description) {
349
+ metadata.description = options.description;
350
+ }
351
+ }
352
+ // Add a generic guard condition for static analysis
353
+ metadata.guards = [{ name: 'runtime_guard', description: metadata.description || 'Synchronous condition check' }];
354
+ // Recurse into the transition (second argument)
355
+ if (args[1] && Node.isCallExpression(args[1])) {
356
+ const nested = extractFromCallExpression(args[1], verbose);
357
+ if (nested) {
358
+ Object.assign(metadata, nested);
359
+ }
360
+ }
361
+ break;
362
+
363
+ case 'guardAsync':
364
+ // Args: (condition, transition, options?)
365
+ // Extract description from options object (third argument)
366
+ if (args[2]) {
367
+ const options = parseObjectLiteral(args[2]);
368
+ if (options.description) {
369
+ metadata.description = options.description;
370
+ }
371
+ }
372
+ // Add a generic guard condition for static analysis
373
+ metadata.guards = [{ name: 'runtime_guard_async', description: metadata.description || 'Asynchronous condition check' }];
374
+ // Recurse into the transition (second argument)
375
+ if (args[1] && Node.isCallExpression(args[1])) {
376
+ const nested = extractFromCallExpression(args[1], verbose);
377
+ if (nested) {
378
+ Object.assign(metadata, nested);
379
+ }
380
+ }
381
+ break;
382
+
383
+ default:
384
+ // Not a DSL primitive we recognize
385
+ return null;
346
386
  }
347
387
 
348
388
  return Object.keys(metadata).length > 0 ? metadata : null;
package/src/index.ts CHANGED
@@ -675,7 +675,9 @@ export {
675
675
  describe,
676
676
  guarded,
677
677
  guard,
678
+ guardAsync,
678
679
  whenGuard,
680
+ whenGuardAsync,
679
681
  invoke,
680
682
  action,
681
683
  metadata,
@@ -722,6 +724,8 @@ export * from './multi'
722
724
 
723
725
  export * from './higher-order'
724
726
 
727
+ export * from './extract'
728
+
725
729
  // =============================================================================
726
730
  // SECTION: MIDDLEWARE & INTERCEPTION
727
731
  // =============================================================================