x-openapi-flow 1.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.
@@ -0,0 +1,712 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const yaml = require("js-yaml");
6
+ const Ajv = require("ajv");
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // Paths
10
+ // ---------------------------------------------------------------------------
11
+ const SCHEMA_PATH = path.join(__dirname, "..", "schema", "flow-schema.json");
12
+ const DEFAULT_API_PATH = path.join(
13
+ __dirname,
14
+ "..",
15
+ "examples",
16
+ "payment-api.yaml"
17
+ );
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Load & compile schema
21
+ // ---------------------------------------------------------------------------
22
+ const schema = JSON.parse(fs.readFileSync(SCHEMA_PATH, "utf8"));
23
+ const ajv = new Ajv({ allErrors: true });
24
+ const validate = ajv.compile(schema);
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Helpers
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Load an OAS document from a YAML file.
32
+ * @param {string} filePath - Absolute or relative path to the YAML file.
33
+ * @returns {object} Parsed OAS document.
34
+ */
35
+ function loadApi(filePath) {
36
+ const content = fs.readFileSync(filePath, "utf8");
37
+ return yaml.load(content);
38
+ }
39
+
40
+ /**
41
+ * Extract every x-flow object found in the `paths` section of an OAS document.
42
+ * @param {object} api - Parsed OAS document.
43
+ * @returns {{ endpoint: string, flow: object }[]}
44
+ */
45
+ function extractFlows(api) {
46
+ const entries = [];
47
+ const paths = (api && api.paths) || {};
48
+
49
+ for (const [pathKey, pathItem] of Object.entries(paths)) {
50
+ const HTTP_METHODS = [
51
+ "get",
52
+ "put",
53
+ "post",
54
+ "delete",
55
+ "options",
56
+ "head",
57
+ "patch",
58
+ "trace",
59
+ ];
60
+ for (const method of HTTP_METHODS) {
61
+ const operation = pathItem[method];
62
+ if (operation && operation["x-flow"]) {
63
+ entries.push({
64
+ endpoint: `${method.toUpperCase()} ${pathKey}`,
65
+ flow: operation["x-flow"],
66
+ });
67
+ }
68
+ }
69
+ }
70
+
71
+ return entries;
72
+ }
73
+
74
+ // ---------------------------------------------------------------------------
75
+ // Validation
76
+ // ---------------------------------------------------------------------------
77
+
78
+ /**
79
+ * Validate all x-flow objects against the JSON Schema.
80
+ * @param {{ endpoint: string, flow: object }[]} flows
81
+ * @returns {{ endpoint: string, errors: object[] }[]} Array of validation failures.
82
+ */
83
+ function validateFlows(flows) {
84
+ const failures = [];
85
+
86
+ for (const { endpoint, flow } of flows) {
87
+ const valid = validate(flow);
88
+ if (!valid) {
89
+ failures.push({ endpoint, errors: validate.errors });
90
+ }
91
+ }
92
+
93
+ return failures;
94
+ }
95
+
96
+ /**
97
+ * Build human-friendly fix suggestions from AJV errors.
98
+ * @param {object[]} errors
99
+ * @returns {string[]}
100
+ */
101
+ function suggestFixes(errors = []) {
102
+ const suggestions = new Set();
103
+
104
+ for (const err of errors) {
105
+ if (err.keyword === "required" && err.params && err.params.missingProperty) {
106
+ const missing = err.params.missingProperty;
107
+ if (missing === "version") {
108
+ suggestions.add("Add `version: \"1.0\"` to the x-flow object.");
109
+ } else if (missing === "id") {
110
+ suggestions.add("Add a unique `id` to the x-flow object.");
111
+ } else if (missing === "current_state") {
112
+ suggestions.add("Add `current_state` to describe the operation state.");
113
+ } else {
114
+ suggestions.add(`Add required property \`${missing}\` in x-flow.`);
115
+ }
116
+ }
117
+
118
+ if (err.keyword === "enum" && err.instancePath.endsWith("/version")) {
119
+ suggestions.add("Use supported x-flow version: `\"1.0\"`.");
120
+ }
121
+
122
+ if (err.keyword === "additionalProperties" && err.params && err.params.additionalProperty) {
123
+ suggestions.add(
124
+ `Remove unsupported property \`${err.params.additionalProperty}\` from x-flow payload.`
125
+ );
126
+ }
127
+ }
128
+
129
+ return [...suggestions];
130
+ }
131
+
132
+ function defaultResult(pathValue, ok = true) {
133
+ return {
134
+ ok,
135
+ path: pathValue,
136
+ profile: "strict",
137
+ flowCount: 0,
138
+ schemaFailures: [],
139
+ orphans: [],
140
+ graphChecks: {
141
+ initial_states: [],
142
+ terminal_states: [],
143
+ unreachable_states: [],
144
+ cycle: { has_cycle: false },
145
+ },
146
+ qualityChecks: {
147
+ multiple_initial_states: [],
148
+ duplicate_transitions: [],
149
+ non_terminating_states: [],
150
+ warnings: [],
151
+ },
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Verify that every target_state referenced in transitions corresponds to a
157
+ * current_state defined in at least one endpoint of the same API.
158
+ *
159
+ * An "orphan state" is a target_state that has no matching current_state
160
+ * anywhere in the API, meaning the lifecycle graph has a dangling edge.
161
+ *
162
+ * @param {{ endpoint: string, flow: object }[]} flows
163
+ * @returns {{ target_state: string, declared_in: string }[]} Orphan states.
164
+ */
165
+ function detectOrphanStates(flows) {
166
+ // Collect every current_state declared across all endpoints.
167
+ const knownStates = new Set(flows.map(({ flow }) => flow.current_state));
168
+
169
+ const orphans = [];
170
+
171
+ for (const { endpoint, flow } of flows) {
172
+ const transitions = flow.transitions || [];
173
+ for (const transition of transitions) {
174
+ if (
175
+ transition.target_state &&
176
+ !knownStates.has(transition.target_state)
177
+ ) {
178
+ orphans.push({
179
+ target_state: transition.target_state,
180
+ declared_in: endpoint,
181
+ });
182
+ }
183
+ }
184
+ }
185
+
186
+ return orphans;
187
+ }
188
+
189
+ /**
190
+ * Build a directed graph from current_state -> target_state transitions.
191
+ * @param {{ endpoint: string, flow: object }[]} flows
192
+ * @returns {{ nodes: Set<string>, adjacency: Map<string, Set<string>>, indegree: Map<string, number>, outdegree: Map<string, number> }}
193
+ */
194
+ function buildStateGraph(flows) {
195
+ const nodes = new Set();
196
+ const adjacency = new Map();
197
+ const indegree = new Map();
198
+ const outdegree = new Map();
199
+
200
+ function ensureNode(state) {
201
+ if (!nodes.has(state)) {
202
+ nodes.add(state);
203
+ adjacency.set(state, new Set());
204
+ indegree.set(state, 0);
205
+ outdegree.set(state, 0);
206
+ }
207
+ }
208
+
209
+ for (const { flow } of flows) {
210
+ ensureNode(flow.current_state);
211
+
212
+ const transitions = flow.transitions || [];
213
+ for (const transition of transitions) {
214
+ if (!transition.target_state) {
215
+ continue;
216
+ }
217
+
218
+ ensureNode(transition.target_state);
219
+
220
+ const neighbors = adjacency.get(flow.current_state);
221
+ if (!neighbors.has(transition.target_state)) {
222
+ neighbors.add(transition.target_state);
223
+ outdegree.set(flow.current_state, outdegree.get(flow.current_state) + 1);
224
+ indegree.set(transition.target_state, indegree.get(transition.target_state) + 1);
225
+ }
226
+ }
227
+ }
228
+
229
+ return { nodes, adjacency, indegree, outdegree };
230
+ }
231
+
232
+ /**
233
+ * Detect states that are unreachable from any initial state.
234
+ * Initial states are those with indegree 0.
235
+ * @param {{ nodes: Set<string>, adjacency: Map<string, Set<string>>, indegree: Map<string, number> }} graph
236
+ * @returns {{ initial_states: string[], unreachable_states: string[] }}
237
+ */
238
+ function detectUnreachableStates(graph) {
239
+ const initialStates = [...graph.nodes].filter(
240
+ (state) => graph.indegree.get(state) === 0
241
+ );
242
+
243
+ if (initialStates.length === 0) {
244
+ return { initial_states: [], unreachable_states: [...graph.nodes] };
245
+ }
246
+
247
+ const visited = new Set();
248
+ const stack = [...initialStates];
249
+
250
+ while (stack.length > 0) {
251
+ const current = stack.pop();
252
+ if (visited.has(current)) {
253
+ continue;
254
+ }
255
+
256
+ visited.add(current);
257
+
258
+ const neighbors = graph.adjacency.get(current) || new Set();
259
+ for (const next of neighbors) {
260
+ if (!visited.has(next)) {
261
+ stack.push(next);
262
+ }
263
+ }
264
+ }
265
+
266
+ const unreachableStates = [...graph.nodes].filter((state) => !visited.has(state));
267
+
268
+ return {
269
+ initial_states: initialStates,
270
+ unreachable_states: unreachableStates,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Detect at least one directed cycle in the state graph.
276
+ * @param {{ nodes: Set<string>, adjacency: Map<string, Set<string>> }} graph
277
+ * @returns {{ has_cycle: boolean, cycle_path?: string[] }}
278
+ */
279
+ function detectCycle(graph) {
280
+ const visited = new Set();
281
+ const inStack = new Set();
282
+ const pathStack = [];
283
+
284
+ function dfs(state) {
285
+ visited.add(state);
286
+ inStack.add(state);
287
+ pathStack.push(state);
288
+
289
+ const neighbors = graph.adjacency.get(state) || new Set();
290
+ for (const next of neighbors) {
291
+ if (!visited.has(next)) {
292
+ const cycle = dfs(next);
293
+ if (cycle) {
294
+ return cycle;
295
+ }
296
+ } else if (inStack.has(next)) {
297
+ const cycleStartIndex = pathStack.lastIndexOf(next);
298
+ const cyclePath = pathStack.slice(cycleStartIndex).concat(next);
299
+ return cyclePath;
300
+ }
301
+ }
302
+
303
+ pathStack.pop();
304
+ inStack.delete(state);
305
+ return null;
306
+ }
307
+
308
+ for (const state of graph.nodes) {
309
+ if (!visited.has(state)) {
310
+ const cyclePath = dfs(state);
311
+ if (cyclePath) {
312
+ return { has_cycle: true, cycle_path: cyclePath };
313
+ }
314
+ }
315
+ }
316
+
317
+ return { has_cycle: false };
318
+ }
319
+
320
+ /**
321
+ * Detect duplicate transitions with same source, target and trigger_type.
322
+ * @param {{ endpoint: string, flow: object }[]} flows
323
+ * @returns {{ from: string, to: string, trigger_type: string, count: number, declared_in: string[] }[]}
324
+ */
325
+ function detectDuplicateTransitions(flows) {
326
+ const transitionMap = new Map();
327
+
328
+ for (const { endpoint, flow } of flows) {
329
+ const source = flow.current_state;
330
+ const transitions = flow.transitions || [];
331
+
332
+ for (const transition of transitions) {
333
+ const target = transition.target_state;
334
+ const triggerType = transition.trigger_type;
335
+
336
+ if (!target || !triggerType) {
337
+ continue;
338
+ }
339
+
340
+ const key = `${source}::${target}::${triggerType}`;
341
+ if (!transitionMap.has(key)) {
342
+ transitionMap.set(key, {
343
+ from: source,
344
+ to: target,
345
+ trigger_type: triggerType,
346
+ count: 0,
347
+ declared_in: [],
348
+ });
349
+ }
350
+
351
+ const entry = transitionMap.get(key);
352
+ entry.count += 1;
353
+ entry.declared_in.push(endpoint);
354
+ }
355
+ }
356
+
357
+ return [...transitionMap.values()].filter((entry) => entry.count > 1);
358
+ }
359
+
360
+ /**
361
+ * Detect states that cannot reach any terminal state.
362
+ * @param {{ nodes: Set<string>, adjacency: Map<string, Set<string>>, outdegree: Map<string, number> }} graph
363
+ * @returns {{ terminal_states: string[], non_terminating_states: string[] }}
364
+ */
365
+ function detectTerminalCoverage(graph) {
366
+ const terminalStates = [...graph.nodes].filter(
367
+ (state) => graph.outdegree.get(state) === 0
368
+ );
369
+
370
+ if (terminalStates.length === 0) {
371
+ return {
372
+ terminal_states: [],
373
+ non_terminating_states: [...graph.nodes],
374
+ };
375
+ }
376
+
377
+ const reverseAdjacency = new Map();
378
+ for (const state of graph.nodes) {
379
+ reverseAdjacency.set(state, new Set());
380
+ }
381
+
382
+ for (const [from, targets] of graph.adjacency.entries()) {
383
+ for (const to of targets) {
384
+ reverseAdjacency.get(to).add(from);
385
+ }
386
+ }
387
+
388
+ const canReachTerminal = new Set();
389
+ const stack = [...terminalStates];
390
+
391
+ while (stack.length > 0) {
392
+ const current = stack.pop();
393
+ if (canReachTerminal.has(current)) {
394
+ continue;
395
+ }
396
+
397
+ canReachTerminal.add(current);
398
+
399
+ const previousStates = reverseAdjacency.get(current) || new Set();
400
+ for (const previous of previousStates) {
401
+ if (!canReachTerminal.has(previous)) {
402
+ stack.push(previous);
403
+ }
404
+ }
405
+ }
406
+
407
+ const nonTerminatingStates = [...graph.nodes].filter(
408
+ (state) => !canReachTerminal.has(state)
409
+ );
410
+
411
+ return {
412
+ terminal_states: terminalStates,
413
+ non_terminating_states: nonTerminatingStates,
414
+ };
415
+ }
416
+
417
+ // ---------------------------------------------------------------------------
418
+ // Main runner
419
+ // ---------------------------------------------------------------------------
420
+
421
+ /**
422
+ * Run all validations against an OAS file and print results.
423
+ * @param {string} [apiPath] - Path to the OAS YAML file (defaults to payment-api.yaml).
424
+ * @param {{ output?: "pretty" | "json", strictQuality?: boolean, profile?: "core" | "relaxed" | "strict" }} [options]
425
+ * @returns {{ ok: boolean, path: string, flowCount: number, schemaFailures: object[], orphans: object[], graphChecks: object }}
426
+ */
427
+ function run(apiPath, options = {}) {
428
+ const output = options.output || "pretty";
429
+ const strictQuality = options.strictQuality === true;
430
+ const profile = options.profile || "strict";
431
+ const profiles = {
432
+ core: { runAdvanced: false, failAdvanced: false, runQuality: false },
433
+ relaxed: { runAdvanced: true, failAdvanced: false, runQuality: true },
434
+ strict: { runAdvanced: true, failAdvanced: true, runQuality: true },
435
+ };
436
+ const profileConfig = profiles[profile];
437
+ const resolvedPath = apiPath
438
+ ? path.resolve(apiPath)
439
+ : DEFAULT_API_PATH;
440
+
441
+ if (!profileConfig) {
442
+ const invalidProfileResult = defaultResult(resolvedPath, false);
443
+ invalidProfileResult.error = `Invalid profile '${profile}'. Use core, relaxed, or strict.`;
444
+ if (output === "json") {
445
+ console.log(JSON.stringify(invalidProfileResult, null, 2));
446
+ } else {
447
+ console.error(`ERROR: ${invalidProfileResult.error}`);
448
+ }
449
+ return invalidProfileResult;
450
+ }
451
+
452
+ if (output === "pretty") {
453
+ console.log(`\nValidating: ${resolvedPath}`);
454
+ console.log(`Profile: ${profile}\n`);
455
+ }
456
+
457
+ // 1. Load API
458
+ let api;
459
+ try {
460
+ api = loadApi(resolvedPath);
461
+ } catch (err) {
462
+ if (output === "json") {
463
+ const result = defaultResult(resolvedPath, false);
464
+ result.profile = profile;
465
+ result.error = `Could not load API file — ${err.message}`;
466
+ console.log(JSON.stringify(result, null, 2));
467
+ return result;
468
+ }
469
+
470
+ console.error(`ERROR: Could not load API file — ${err.message}`);
471
+ const result = defaultResult(resolvedPath, false);
472
+ result.profile = profile;
473
+ result.error = `Could not load API file — ${err.message}`;
474
+ return result;
475
+ }
476
+
477
+ // 2. Extract x-flow objects
478
+ const flows = extractFlows(api);
479
+
480
+ if (flows.length === 0) {
481
+ const result = defaultResult(resolvedPath, true);
482
+ result.profile = profile;
483
+
484
+ if (output === "json") {
485
+ console.log(JSON.stringify(result, null, 2));
486
+ } else {
487
+ console.warn("WARNING: No x-flow extensions found in the API paths.");
488
+ }
489
+
490
+ return result;
491
+ }
492
+
493
+ if (output === "pretty") {
494
+ console.log(`Found ${flows.length} x-flow definition(s).\n`);
495
+ }
496
+
497
+ let hasErrors = false;
498
+
499
+ // 3. Schema validation
500
+ const schemaFailures = validateFlows(flows);
501
+ if (schemaFailures.length > 0) {
502
+ hasErrors = true;
503
+ }
504
+
505
+ if (output === "pretty") {
506
+ if (schemaFailures.length === 0) {
507
+ console.log("✔ Schema validation passed for all x-flow definitions.");
508
+ } else {
509
+ console.error("✘ Schema validation FAILED:");
510
+ for (const { endpoint, errors } of schemaFailures) {
511
+ console.error(` [${endpoint}]`);
512
+ for (const err of errors) {
513
+ console.error(` - ${err.instancePath || "(root)"}: ${err.message}`);
514
+ }
515
+ const fixes = suggestFixes(errors);
516
+ if (fixes.length > 0) {
517
+ console.error(" Suggested fixes:");
518
+ for (const fix of fixes) {
519
+ console.error(` * ${fix}`);
520
+ }
521
+ }
522
+ }
523
+ }
524
+ }
525
+
526
+ // 4. Orphan state detection
527
+ const orphans = detectOrphanStates(flows);
528
+ if (orphans.length > 0) {
529
+ hasErrors = true;
530
+ }
531
+
532
+ if (output === "pretty") {
533
+ if (orphans.length === 0) {
534
+ console.log("✔ Graph validation passed — no orphan states detected.");
535
+ } else {
536
+ console.error("✘ Graph validation FAILED — orphan state(s) detected:");
537
+ for (const { target_state, declared_in } of orphans) {
538
+ console.error(
539
+ ` target_state "${target_state}" (declared in ${declared_in}) has no matching current_state in any endpoint.`
540
+ );
541
+ }
542
+ }
543
+ }
544
+
545
+ // 5. Advanced graph checks
546
+ const graph = buildStateGraph(flows);
547
+ const initialStates = [...graph.nodes].filter(
548
+ (state) => graph.indegree.get(state) === 0
549
+ );
550
+ const terminalStates = [...graph.nodes].filter(
551
+ (state) => graph.outdegree.get(state) === 0
552
+ );
553
+ const reachability = detectUnreachableStates(graph);
554
+ const cycle = detectCycle(graph);
555
+ const duplicateTransitions = detectDuplicateTransitions(flows);
556
+ const terminalCoverage = detectTerminalCoverage(graph);
557
+ const multipleInitialStates = initialStates.length > 1 ? initialStates : [];
558
+
559
+ if (profileConfig.runAdvanced) {
560
+ if (profileConfig.failAdvanced && (initialStates.length === 0 || terminalStates.length === 0)) {
561
+ hasErrors = true;
562
+ }
563
+
564
+ if (profileConfig.failAdvanced && reachability.unreachable_states.length > 0) {
565
+ hasErrors = true;
566
+ }
567
+
568
+ if (profileConfig.failAdvanced && cycle.has_cycle) {
569
+ hasErrors = true;
570
+ }
571
+ }
572
+
573
+ const qualityWarnings = [];
574
+
575
+ if (profileConfig.runQuality && multipleInitialStates.length > 0) {
576
+ qualityWarnings.push(
577
+ `Multiple initial states detected: ${multipleInitialStates.join(", ")}`
578
+ );
579
+ }
580
+
581
+ if (profileConfig.runQuality && duplicateTransitions.length > 0) {
582
+ qualityWarnings.push(
583
+ `Duplicate transitions detected: ${duplicateTransitions.length}`
584
+ );
585
+ }
586
+
587
+ if (profileConfig.runQuality && terminalCoverage.non_terminating_states.length > 0) {
588
+ qualityWarnings.push(
589
+ `States without path to terminal: ${terminalCoverage.non_terminating_states.join(", ")}`
590
+ );
591
+ }
592
+
593
+ if (strictQuality && qualityWarnings.length > 0) {
594
+ hasErrors = true;
595
+ }
596
+
597
+ if (output === "pretty") {
598
+ if (profileConfig.runAdvanced) {
599
+ if (initialStates.length === 0) {
600
+ if (profileConfig.failAdvanced) {
601
+ console.error("✘ Graph validation FAILED — no initial state detected (indegree = 0).");
602
+ } else {
603
+ console.warn("⚠ Graph warning — no initial state detected (indegree = 0).");
604
+ }
605
+ }
606
+
607
+ if (terminalStates.length === 0) {
608
+ if (profileConfig.failAdvanced) {
609
+ console.error("✘ Graph validation FAILED — no terminal state detected (outdegree = 0).");
610
+ } else {
611
+ console.warn("⚠ Graph warning — no terminal state detected (outdegree = 0).");
612
+ }
613
+ }
614
+
615
+ if (reachability.unreachable_states.length > 0) {
616
+ if (profileConfig.failAdvanced) {
617
+ console.error(
618
+ `✘ Graph validation FAILED — unreachable state(s): ${reachability.unreachable_states.join(", ")}`
619
+ );
620
+ } else {
621
+ console.warn(
622
+ `⚠ Graph warning — unreachable state(s): ${reachability.unreachable_states.join(", ")}`
623
+ );
624
+ }
625
+ }
626
+
627
+ if (cycle.has_cycle) {
628
+ if (profileConfig.failAdvanced) {
629
+ console.error(
630
+ `✘ Graph validation FAILED — cycle detected: ${cycle.cycle_path.join(" -> ")}`
631
+ );
632
+ } else {
633
+ console.warn(
634
+ `⚠ Graph warning — cycle detected: ${cycle.cycle_path.join(" -> ")}`
635
+ );
636
+ }
637
+ }
638
+
639
+ if (
640
+ initialStates.length > 0 &&
641
+ terminalStates.length > 0 &&
642
+ reachability.unreachable_states.length === 0 &&
643
+ !cycle.has_cycle
644
+ ) {
645
+ console.log("✔ Advanced graph checks passed (initial, terminal, reachability, acyclic).");
646
+ }
647
+ }
648
+
649
+ if (profileConfig.runQuality) {
650
+ if (qualityWarnings.length === 0) {
651
+ console.log("✔ Quality checks passed (single initial, no duplicate transitions, terminal coverage).");
652
+ } else {
653
+ for (const warning of qualityWarnings) {
654
+ if (strictQuality) {
655
+ console.error(`✘ Quality check FAILED (strict): ${warning}`);
656
+ } else {
657
+ console.warn(`⚠ Quality warning: ${warning}`);
658
+ }
659
+ }
660
+ }
661
+ }
662
+ }
663
+
664
+ const result = {
665
+ ok: !hasErrors,
666
+ path: resolvedPath,
667
+ profile,
668
+ flowCount: flows.length,
669
+ schemaFailures,
670
+ orphans,
671
+ graphChecks: {
672
+ initial_states: initialStates,
673
+ terminal_states: terminalStates,
674
+ unreachable_states: reachability.unreachable_states,
675
+ cycle,
676
+ },
677
+ qualityChecks: {
678
+ multiple_initial_states: multipleInitialStates,
679
+ duplicate_transitions: duplicateTransitions,
680
+ non_terminating_states: terminalCoverage.non_terminating_states,
681
+ warnings: qualityWarnings,
682
+ },
683
+ };
684
+
685
+ if (output === "json") {
686
+ console.log(JSON.stringify(result, null, 2));
687
+ } else {
688
+ console.log("");
689
+ if (hasErrors) {
690
+ console.error("Validation finished with errors.");
691
+ } else {
692
+ console.log("All validations passed ✔");
693
+ }
694
+ }
695
+
696
+ return result;
697
+ }
698
+
699
+ // Allow the module to be required by tests without side-effects.
700
+ if (require.main === module) {
701
+ const result = run(process.argv[2]);
702
+ process.exit(result.ok ? 0 : 1);
703
+ }
704
+
705
+ module.exports = {
706
+ loadApi,
707
+ extractFlows,
708
+ validateFlows,
709
+ detectOrphanStates,
710
+ buildStateGraph,
711
+ run,
712
+ };