@doeixd/machine 0.0.8 ā 0.0.10
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.
- package/dist/cjs/development/index.js +120 -0
- package/dist/cjs/development/index.js.map +2 -2
- package/dist/cjs/production/index.js +4 -4
- package/dist/esm/development/index.js +120 -0
- package/dist/esm/development/index.js.map +2 -2
- package/dist/esm/production/index.js +5 -5
- package/dist/types/extract.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/primitives.d.ts +117 -17
- package/dist/types/primitives.d.ts.map +1 -1
- package/package.json +9 -6
- package/scripts/extract-statechart.ts +351 -0
- package/src/extract.ts +59 -19
- package/src/index.ts +4 -0
- package/src/primitives.ts +287 -27
|
@@ -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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
// =============================================================================
|