x-openapi-flow 1.2.2 → 1.3.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.
- package/README.md +212 -18
- package/bin/x-openapi-flow.js +709 -22
- package/examples/swagger-ui/index.html +1 -1
- package/lib/swagger-ui/x-openapi-flow-plugin.js +856 -0
- package/lib/validator.js +36 -3
- package/package.json +3 -2
- package/schema/flow-schema.json +2 -2
- package/examples/swagger-ui/x-openapi-flow-plugin.js +0 -446
package/bin/x-openapi-flow.js
CHANGED
|
@@ -8,6 +8,10 @@ const {
|
|
|
8
8
|
run,
|
|
9
9
|
loadApi,
|
|
10
10
|
extractFlows,
|
|
11
|
+
buildStateGraph,
|
|
12
|
+
detectDuplicateTransitions,
|
|
13
|
+
detectInvalidOperationReferences,
|
|
14
|
+
detectTerminalCoverage,
|
|
11
15
|
} = require("../lib/validator");
|
|
12
16
|
|
|
13
17
|
const DEFAULT_CONFIG_NAME = "x-openapi-flow.config.json";
|
|
@@ -43,8 +47,10 @@ function printHelp() {
|
|
|
43
47
|
|
|
44
48
|
Usage:
|
|
45
49
|
x-openapi-flow validate <openapi-file> [--format pretty|json] [--profile core|relaxed|strict] [--strict-quality] [--config path]
|
|
46
|
-
x-openapi-flow init [openapi-file] [--flows path]
|
|
47
|
-
x-openapi-flow apply [openapi-file] [--flows path] [--out path]
|
|
50
|
+
x-openapi-flow init [openapi-file] [--flows path] [--force] [--dry-run]
|
|
51
|
+
x-openapi-flow apply [openapi-file] [--flows path] [--out path] [--in-place]
|
|
52
|
+
x-openapi-flow diff [openapi-file] [--flows path] [--format pretty|json]
|
|
53
|
+
x-openapi-flow lint [openapi-file] [--format pretty|json] [--config path]
|
|
48
54
|
x-openapi-flow graph <openapi-file> [--format mermaid|json]
|
|
49
55
|
x-openapi-flow doctor [--config path]
|
|
50
56
|
x-openapi-flow --help
|
|
@@ -53,15 +59,95 @@ Examples:
|
|
|
53
59
|
x-openapi-flow validate examples/order-api.yaml
|
|
54
60
|
x-openapi-flow validate examples/order-api.yaml --profile relaxed
|
|
55
61
|
x-openapi-flow validate examples/order-api.yaml --strict-quality
|
|
56
|
-
x-openapi-flow init openapi.yaml --flows openapi
|
|
62
|
+
x-openapi-flow init openapi.yaml --flows openapi.x.yaml
|
|
63
|
+
x-openapi-flow init openapi.yaml --force
|
|
64
|
+
x-openapi-flow init openapi.yaml --dry-run
|
|
57
65
|
x-openapi-flow init
|
|
58
66
|
x-openapi-flow apply openapi.yaml
|
|
67
|
+
x-openapi-flow apply openapi.yaml --in-place
|
|
59
68
|
x-openapi-flow apply openapi.yaml --out openapi.flow.yaml
|
|
69
|
+
x-openapi-flow diff openapi.yaml
|
|
70
|
+
x-openapi-flow diff openapi.yaml --format json
|
|
71
|
+
x-openapi-flow lint openapi.yaml
|
|
72
|
+
x-openapi-flow lint openapi.yaml --format json
|
|
60
73
|
x-openapi-flow graph examples/order-api.yaml
|
|
61
74
|
x-openapi-flow doctor
|
|
62
75
|
`);
|
|
63
76
|
}
|
|
64
77
|
|
|
78
|
+
function deriveFlowOutputPath(openApiFile) {
|
|
79
|
+
const parsed = path.parse(openApiFile);
|
|
80
|
+
const extension = parsed.ext || ".yaml";
|
|
81
|
+
return path.join(parsed.dir, `${parsed.name}.flow${extension}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readSingleLineFromStdin() {
|
|
85
|
+
const sleepBuffer = new SharedArrayBuffer(4);
|
|
86
|
+
const sleepView = new Int32Array(sleepBuffer);
|
|
87
|
+
const chunks = [];
|
|
88
|
+
const buffer = Buffer.alloc(256);
|
|
89
|
+
const maxEagainRetries = 200;
|
|
90
|
+
let eagainRetries = 0;
|
|
91
|
+
|
|
92
|
+
while (true) {
|
|
93
|
+
let bytesRead;
|
|
94
|
+
try {
|
|
95
|
+
bytesRead = fs.readSync(0, buffer, 0, buffer.length, null);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err && (err.code === "EAGAIN" || err.code === "EWOULDBLOCK")) {
|
|
98
|
+
eagainRetries += 1;
|
|
99
|
+
if (eagainRetries >= maxEagainRetries) {
|
|
100
|
+
const retryError = new Error("Could not read interactive input from stdin.");
|
|
101
|
+
retryError.code = "EAGAIN";
|
|
102
|
+
throw retryError;
|
|
103
|
+
}
|
|
104
|
+
Atomics.wait(sleepView, 0, 0, 25);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
eagainRetries = 0;
|
|
111
|
+
if (bytesRead === 0) {
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const chunk = buffer.toString("utf8", 0, bytesRead);
|
|
116
|
+
chunks.push(chunk);
|
|
117
|
+
if (chunk.includes("\n")) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const input = chunks.join("");
|
|
123
|
+
const firstLine = input.split(/\r?\n/)[0] || "";
|
|
124
|
+
return firstLine.trim();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function askForConfirmation(question) {
|
|
128
|
+
process.stdout.write(`${question} [y/N]: `);
|
|
129
|
+
const answer = readSingleLineFromStdin().toLowerCase();
|
|
130
|
+
return answer === "y" || answer === "yes";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function getNextBackupPath(filePath) {
|
|
134
|
+
let index = 1;
|
|
135
|
+
while (true) {
|
|
136
|
+
const candidate = `${filePath}.backup-${index}`;
|
|
137
|
+
if (!fs.existsSync(candidate)) {
|
|
138
|
+
return candidate;
|
|
139
|
+
}
|
|
140
|
+
index += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function applyFlowsAndWrite(openApiFile, flowsDoc, outputPath) {
|
|
145
|
+
const api = loadApi(openApiFile);
|
|
146
|
+
const appliedCount = applyFlowsToOpenApi(api, flowsDoc);
|
|
147
|
+
saveOpenApi(outputPath, api);
|
|
148
|
+
return appliedCount;
|
|
149
|
+
}
|
|
150
|
+
|
|
65
151
|
function getOptionValue(args, optionName) {
|
|
66
152
|
const index = args.indexOf(optionName);
|
|
67
153
|
if (index === -1) {
|
|
@@ -168,7 +254,7 @@ function parseValidateArgs(args) {
|
|
|
168
254
|
}
|
|
169
255
|
|
|
170
256
|
function parseInitArgs(args) {
|
|
171
|
-
const unknown = findUnknownOptions(args, ["--flows"], []);
|
|
257
|
+
const unknown = findUnknownOptions(args, ["--flows"], ["--force", "--dry-run"]);
|
|
172
258
|
if (unknown) {
|
|
173
259
|
return { error: `Unknown option: ${unknown}` };
|
|
174
260
|
}
|
|
@@ -182,6 +268,12 @@ function parseInitArgs(args) {
|
|
|
182
268
|
if (token === "--flows") {
|
|
183
269
|
return false;
|
|
184
270
|
}
|
|
271
|
+
if (token === "--force") {
|
|
272
|
+
return false;
|
|
273
|
+
}
|
|
274
|
+
if (token === "--dry-run") {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
185
277
|
if (index > 0 && args[index - 1] === "--flows") {
|
|
186
278
|
return false;
|
|
187
279
|
}
|
|
@@ -195,11 +287,208 @@ function parseInitArgs(args) {
|
|
|
195
287
|
return {
|
|
196
288
|
openApiFile: positional[0] ? path.resolve(positional[0]) : undefined,
|
|
197
289
|
flowsPath: flowsOpt.found ? path.resolve(flowsOpt.value) : undefined,
|
|
290
|
+
force: args.includes("--force"),
|
|
291
|
+
dryRun: args.includes("--dry-run"),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function summarizeSidecarDiff(existingFlowsDoc, mergedFlowsDoc) {
|
|
296
|
+
const existingOps = new Map();
|
|
297
|
+
for (const entry of (existingFlowsDoc && existingFlowsDoc.operations) || []) {
|
|
298
|
+
if (!entry || !entry.operationId) continue;
|
|
299
|
+
existingOps.set(entry.operationId, entry["x-openapi-flow"] || null);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const mergedOps = new Map();
|
|
303
|
+
for (const entry of (mergedFlowsDoc && mergedFlowsDoc.operations) || []) {
|
|
304
|
+
if (!entry || !entry.operationId) continue;
|
|
305
|
+
mergedOps.set(entry.operationId, entry["x-openapi-flow"] || null);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let added = 0;
|
|
309
|
+
let removed = 0;
|
|
310
|
+
let changed = 0;
|
|
311
|
+
const addedOperationIds = [];
|
|
312
|
+
const removedOperationIds = [];
|
|
313
|
+
const changedOperationIds = [];
|
|
314
|
+
const changedOperationDetails = [];
|
|
315
|
+
|
|
316
|
+
function collectLeafPaths(value, prefix = "") {
|
|
317
|
+
if (value === null || value === undefined) {
|
|
318
|
+
return [prefix || "(root)"];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (Array.isArray(value)) {
|
|
322
|
+
return [prefix || "(root)"];
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (typeof value !== "object") {
|
|
326
|
+
return [prefix || "(root)"];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const keys = Object.keys(value);
|
|
330
|
+
if (keys.length === 0) {
|
|
331
|
+
return [prefix || "(root)"];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return keys.flatMap((key) => {
|
|
335
|
+
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
|
336
|
+
return collectLeafPaths(value[key], nextPrefix);
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function diffPaths(left, right, prefix = "") {
|
|
341
|
+
if (JSON.stringify(left) === JSON.stringify(right)) {
|
|
342
|
+
return [];
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const leftIsObject = left && typeof left === "object" && !Array.isArray(left);
|
|
346
|
+
const rightIsObject = right && typeof right === "object" && !Array.isArray(right);
|
|
347
|
+
|
|
348
|
+
if (leftIsObject && rightIsObject) {
|
|
349
|
+
const keys = Array.from(new Set([...Object.keys(left), ...Object.keys(right)])).sort();
|
|
350
|
+
return keys.flatMap((key) => {
|
|
351
|
+
const nextPrefix = prefix ? `${prefix}.${key}` : key;
|
|
352
|
+
return diffPaths(left[key], right[key], nextPrefix);
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (rightIsObject && !leftIsObject) {
|
|
357
|
+
return collectLeafPaths(right, prefix);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (leftIsObject && !rightIsObject) {
|
|
361
|
+
return collectLeafPaths(left, prefix);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return [prefix || "(root)"];
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
for (const [operationId, mergedFlow] of mergedOps.entries()) {
|
|
368
|
+
if (!existingOps.has(operationId)) {
|
|
369
|
+
added += 1;
|
|
370
|
+
addedOperationIds.push(operationId);
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const existingFlow = existingOps.get(operationId);
|
|
375
|
+
if (JSON.stringify(existingFlow) !== JSON.stringify(mergedFlow)) {
|
|
376
|
+
changed += 1;
|
|
377
|
+
changedOperationIds.push(operationId);
|
|
378
|
+
changedOperationDetails.push({
|
|
379
|
+
operationId,
|
|
380
|
+
changedPaths: Array.from(new Set(diffPaths(existingFlow, mergedFlow))).sort(),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
for (const operationId of existingOps.keys()) {
|
|
386
|
+
if (!mergedOps.has(operationId)) {
|
|
387
|
+
removed += 1;
|
|
388
|
+
removedOperationIds.push(operationId);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
added,
|
|
394
|
+
removed,
|
|
395
|
+
changed,
|
|
396
|
+
addedOperationIds: addedOperationIds.sort(),
|
|
397
|
+
removedOperationIds: removedOperationIds.sort(),
|
|
398
|
+
changedOperationIds: changedOperationIds.sort(),
|
|
399
|
+
changedOperationDetails: changedOperationDetails.sort((a, b) => a.operationId.localeCompare(b.operationId)),
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function parseDiffArgs(args) {
|
|
404
|
+
const unknown = findUnknownOptions(args, ["--flows", "--format"], []);
|
|
405
|
+
if (unknown) {
|
|
406
|
+
return { error: `Unknown option: ${unknown}` };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const flowsOpt = getOptionValue(args, "--flows");
|
|
410
|
+
if (flowsOpt.error) {
|
|
411
|
+
return { error: flowsOpt.error };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const formatOpt = getOptionValue(args, "--format");
|
|
415
|
+
if (formatOpt.error) {
|
|
416
|
+
return { error: `${formatOpt.error} Use 'pretty' or 'json'.` };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const format = formatOpt.found ? formatOpt.value : "pretty";
|
|
420
|
+
if (!["pretty", "json"].includes(format)) {
|
|
421
|
+
return { error: `Invalid --format '${format}'. Use 'pretty' or 'json'.` };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const positional = args.filter((token, index) => {
|
|
425
|
+
if (token === "--flows" || token === "--format") {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (index > 0 && (args[index - 1] === "--flows" || args[index - 1] === "--format")) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
return !token.startsWith("--");
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
if (positional.length > 1) {
|
|
435
|
+
return { error: `Unexpected argument: ${positional[1]}` };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
openApiFile: positional[0] ? path.resolve(positional[0]) : undefined,
|
|
440
|
+
flowsPath: flowsOpt.found ? path.resolve(flowsOpt.value) : undefined,
|
|
441
|
+
format,
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseLintArgs(args) {
|
|
446
|
+
const unknown = findUnknownOptions(args, ["--format", "--config"], []);
|
|
447
|
+
if (unknown) {
|
|
448
|
+
return { error: `Unknown option: ${unknown}` };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const formatOpt = getOptionValue(args, "--format");
|
|
452
|
+
if (formatOpt.error) {
|
|
453
|
+
return { error: `${formatOpt.error} Use 'pretty' or 'json'.` };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const configOpt = getOptionValue(args, "--config");
|
|
457
|
+
if (configOpt.error) {
|
|
458
|
+
return { error: configOpt.error };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const format = formatOpt.found ? formatOpt.value : "pretty";
|
|
462
|
+
if (![
|
|
463
|
+
"pretty",
|
|
464
|
+
"json",
|
|
465
|
+
].includes(format)) {
|
|
466
|
+
return { error: `Invalid --format '${format}'. Use 'pretty' or 'json'.` };
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const positional = args.filter((token, index) => {
|
|
470
|
+
if (token === "--format" || token === "--config") {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
if (index > 0 && (args[index - 1] === "--format" || args[index - 1] === "--config")) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
return !token.startsWith("--");
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
if (positional.length > 1) {
|
|
480
|
+
return { error: `Unexpected argument: ${positional[1]}` };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return {
|
|
484
|
+
openApiFile: positional[0] ? path.resolve(positional[0]) : undefined,
|
|
485
|
+
format,
|
|
486
|
+
configPath: configOpt.found ? configOpt.value : undefined,
|
|
198
487
|
};
|
|
199
488
|
}
|
|
200
489
|
|
|
201
490
|
function parseApplyArgs(args) {
|
|
202
|
-
const unknown = findUnknownOptions(args, ["--flows", "--out"], []);
|
|
491
|
+
const unknown = findUnknownOptions(args, ["--flows", "--out"], ["--in-place"]);
|
|
203
492
|
if (unknown) {
|
|
204
493
|
return { error: `Unknown option: ${unknown}` };
|
|
205
494
|
}
|
|
@@ -214,8 +503,13 @@ function parseApplyArgs(args) {
|
|
|
214
503
|
return { error: outOpt.error };
|
|
215
504
|
}
|
|
216
505
|
|
|
506
|
+
const inPlace = args.includes("--in-place");
|
|
507
|
+
if (inPlace && outOpt.found) {
|
|
508
|
+
return { error: "Options --in-place and --out cannot be used together." };
|
|
509
|
+
}
|
|
510
|
+
|
|
217
511
|
const positional = args.filter((token, index) => {
|
|
218
|
-
if (token === "--flows" || token === "--out") {
|
|
512
|
+
if (token === "--flows" || token === "--out" || token === "--in-place") {
|
|
219
513
|
return false;
|
|
220
514
|
}
|
|
221
515
|
if (index > 0 && (args[index - 1] === "--flows" || args[index - 1] === "--out")) {
|
|
@@ -232,6 +526,7 @@ function parseApplyArgs(args) {
|
|
|
232
526
|
openApiFile: positional[0] ? path.resolve(positional[0]) : undefined,
|
|
233
527
|
flowsPath: flowsOpt.found ? path.resolve(flowsOpt.value) : undefined,
|
|
234
528
|
outPath: outOpt.found ? path.resolve(outOpt.value) : undefined,
|
|
529
|
+
inPlace,
|
|
235
530
|
};
|
|
236
531
|
}
|
|
237
532
|
|
|
@@ -321,6 +616,16 @@ function parseArgs(argv) {
|
|
|
321
616
|
return parsed.error ? parsed : { command, ...parsed };
|
|
322
617
|
}
|
|
323
618
|
|
|
619
|
+
if (command === "diff") {
|
|
620
|
+
const parsed = parseDiffArgs(commandArgs);
|
|
621
|
+
return parsed.error ? parsed : { command, ...parsed };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (command === "lint") {
|
|
625
|
+
const parsed = parseLintArgs(commandArgs);
|
|
626
|
+
return parsed.error ? parsed : { command, ...parsed };
|
|
627
|
+
}
|
|
628
|
+
|
|
324
629
|
if (command === "doctor") {
|
|
325
630
|
const parsed = parseDoctorArgs(commandArgs);
|
|
326
631
|
return parsed.error ? parsed : { command, ...parsed };
|
|
@@ -388,13 +693,40 @@ function resolveFlowsPath(openApiFile, customFlowsPath) {
|
|
|
388
693
|
if (openApiFile) {
|
|
389
694
|
const parsed = path.parse(openApiFile);
|
|
390
695
|
const extension = parsed.ext.toLowerCase() === ".json" ? ".json" : ".yaml";
|
|
391
|
-
const
|
|
392
|
-
|
|
696
|
+
const baseDir = path.dirname(openApiFile);
|
|
697
|
+
const newFileName = `${parsed.name}.x${extension}`;
|
|
698
|
+
const legacyFileName = `${parsed.name}-openapi-flow${extension}`;
|
|
699
|
+
const newPath = path.join(baseDir, newFileName);
|
|
700
|
+
const legacyPath = path.join(baseDir, legacyFileName);
|
|
701
|
+
|
|
702
|
+
if (fs.existsSync(newPath)) {
|
|
703
|
+
return newPath;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (fs.existsSync(legacyPath)) {
|
|
707
|
+
return legacyPath;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
return newPath;
|
|
393
711
|
}
|
|
394
712
|
|
|
395
713
|
return path.resolve(process.cwd(), DEFAULT_FLOWS_FILE);
|
|
396
714
|
}
|
|
397
715
|
|
|
716
|
+
function looksLikeFlowsSidecar(filePath) {
|
|
717
|
+
if (!filePath) return false;
|
|
718
|
+
const normalized = filePath.toLowerCase();
|
|
719
|
+
return normalized.endsWith(".x.yaml")
|
|
720
|
+
|| normalized.endsWith(".x.yml")
|
|
721
|
+
|| normalized.endsWith(".x.json")
|
|
722
|
+
|| normalized.endsWith("-openapi-flow.yaml")
|
|
723
|
+
|| normalized.endsWith("-openapi-flow.yml")
|
|
724
|
+
|| normalized.endsWith("-openapi-flow.json")
|
|
725
|
+
|| normalized.endsWith("x-openapi-flow.flows.yaml")
|
|
726
|
+
|| normalized.endsWith("x-openapi-flow.flows.yml")
|
|
727
|
+
|| normalized.endsWith("x-openapi-flow.flows.json");
|
|
728
|
+
}
|
|
729
|
+
|
|
398
730
|
function getOpenApiFormat(filePath) {
|
|
399
731
|
return filePath.endsWith(".json") ? "json" : "yaml";
|
|
400
732
|
}
|
|
@@ -481,8 +813,8 @@ function buildFlowTemplate(operationId) {
|
|
|
481
813
|
const safeOperationId = operationId || "operation";
|
|
482
814
|
return {
|
|
483
815
|
version: "1.0",
|
|
484
|
-
id:
|
|
485
|
-
current_state:
|
|
816
|
+
id: safeOperationId,
|
|
817
|
+
current_state: safeOperationId,
|
|
486
818
|
transitions: [],
|
|
487
819
|
};
|
|
488
820
|
}
|
|
@@ -593,6 +925,7 @@ function runInit(parsed) {
|
|
|
593
925
|
}
|
|
594
926
|
|
|
595
927
|
const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath);
|
|
928
|
+
const flowOutputPath = deriveFlowOutputPath(targetOpenApiFile);
|
|
596
929
|
|
|
597
930
|
let api;
|
|
598
931
|
try {
|
|
@@ -611,12 +944,91 @@ function runInit(parsed) {
|
|
|
611
944
|
}
|
|
612
945
|
|
|
613
946
|
const mergedFlows = mergeFlowsWithOpenApi(api, flowsDoc);
|
|
614
|
-
writeFlowsFile(flowsPath, mergedFlows);
|
|
615
947
|
const trackedCount = mergedFlows.operations.length;
|
|
948
|
+
const sidecarDiff = summarizeSidecarDiff(flowsDoc, mergedFlows);
|
|
949
|
+
|
|
950
|
+
let applyMessage = "Init completed without regenerating flow output.";
|
|
951
|
+
const flowOutputExists = fs.existsSync(flowOutputPath);
|
|
952
|
+
let shouldRecreateFlowOutput = !flowOutputExists;
|
|
953
|
+
let sidecarBackupPath = null;
|
|
954
|
+
|
|
955
|
+
if (parsed.dryRun) {
|
|
956
|
+
let dryRunFlowPlan;
|
|
957
|
+
if (!flowOutputExists) {
|
|
958
|
+
dryRunFlowPlan = `Would generate flow output: ${flowOutputPath}`;
|
|
959
|
+
} else if (parsed.force) {
|
|
960
|
+
dryRunFlowPlan = `Would recreate flow output: ${flowOutputPath} (with sidecar backup).`;
|
|
961
|
+
} else {
|
|
962
|
+
dryRunFlowPlan = `Flow output exists at ${flowOutputPath}; would require interactive confirmation to recreate (or use --force).`;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
console.log(`[dry-run] Using existing OpenAPI file: ${targetOpenApiFile}`);
|
|
966
|
+
console.log(`[dry-run] Flows sidecar target: ${flowsPath}`);
|
|
967
|
+
console.log(`[dry-run] Tracked operations: ${trackedCount}`);
|
|
968
|
+
console.log(`[dry-run] Sidecar changes -> added: ${sidecarDiff.added}, changed: ${sidecarDiff.changed}, removed: ${sidecarDiff.removed}`);
|
|
969
|
+
console.log(`[dry-run] ${dryRunFlowPlan}`);
|
|
970
|
+
console.log("[dry-run] No files were written.");
|
|
971
|
+
return 0;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
if (flowOutputExists) {
|
|
975
|
+
if (parsed.force) {
|
|
976
|
+
shouldRecreateFlowOutput = true;
|
|
977
|
+
if (fs.existsSync(flowsPath)) {
|
|
978
|
+
sidecarBackupPath = getNextBackupPath(flowsPath);
|
|
979
|
+
fs.copyFileSync(flowsPath, sidecarBackupPath);
|
|
980
|
+
}
|
|
981
|
+
} else {
|
|
982
|
+
const isInteractive = process.stdin.isTTY && process.stdout.isTTY;
|
|
983
|
+
if (isInteractive) {
|
|
984
|
+
let shouldRecreate;
|
|
985
|
+
try {
|
|
986
|
+
shouldRecreate = askForConfirmation(
|
|
987
|
+
`Flow output already exists at ${flowOutputPath}. Recreate it from current OpenAPI + sidecar?`
|
|
988
|
+
);
|
|
989
|
+
} catch (err) {
|
|
990
|
+
if (err && (err.code === "EAGAIN" || err.code === "EWOULDBLOCK")) {
|
|
991
|
+
console.error("ERROR: Could not read interactive confirmation from stdin (EAGAIN).");
|
|
992
|
+
console.error("Run `x-openapi-flow apply` to update the existing flow output in this environment.");
|
|
993
|
+
return 1;
|
|
994
|
+
}
|
|
995
|
+
throw err;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (shouldRecreate) {
|
|
999
|
+
shouldRecreateFlowOutput = true;
|
|
1000
|
+
if (fs.existsSync(flowsPath)) {
|
|
1001
|
+
sidecarBackupPath = getNextBackupPath(flowsPath);
|
|
1002
|
+
fs.copyFileSync(flowsPath, sidecarBackupPath);
|
|
1003
|
+
}
|
|
1004
|
+
} else {
|
|
1005
|
+
applyMessage = "Flow output kept as-is (recreate cancelled by user).";
|
|
1006
|
+
}
|
|
1007
|
+
} else {
|
|
1008
|
+
console.error(`ERROR: Flow output already exists at ${flowOutputPath}.`);
|
|
1009
|
+
console.error("Use `x-openapi-flow init --force` to recreate, or `x-openapi-flow apply` to update in non-interactive mode.");
|
|
1010
|
+
return 1;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
writeFlowsFile(flowsPath, mergedFlows);
|
|
1016
|
+
|
|
1017
|
+
if (shouldRecreateFlowOutput) {
|
|
1018
|
+
const appliedCount = applyFlowsAndWrite(targetOpenApiFile, mergedFlows, flowOutputPath);
|
|
1019
|
+
if (flowOutputExists) {
|
|
1020
|
+
applyMessage = sidecarBackupPath
|
|
1021
|
+
? `Flow output recreated: ${flowOutputPath} (applied entries: ${appliedCount}). Sidecar backup: ${sidecarBackupPath}.`
|
|
1022
|
+
: `Flow output recreated: ${flowOutputPath} (applied entries: ${appliedCount}).`;
|
|
1023
|
+
} else {
|
|
1024
|
+
applyMessage = `Flow output generated: ${flowOutputPath} (applied entries: ${appliedCount}).`;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
616
1027
|
|
|
617
1028
|
console.log(`Using existing OpenAPI file: ${targetOpenApiFile}`);
|
|
618
1029
|
console.log(`Flows sidecar synced: ${flowsPath}`);
|
|
619
1030
|
console.log(`Tracked operations: ${trackedCount}`);
|
|
1031
|
+
console.log(applyMessage);
|
|
620
1032
|
console.log("OpenAPI source unchanged. Edit the sidecar and run apply to generate the full spec.");
|
|
621
1033
|
|
|
622
1034
|
console.log(`Validate now: x-openapi-flow validate ${targetOpenApiFile}`);
|
|
@@ -624,18 +1036,28 @@ function runInit(parsed) {
|
|
|
624
1036
|
}
|
|
625
1037
|
|
|
626
1038
|
function runApply(parsed) {
|
|
627
|
-
|
|
1039
|
+
let targetOpenApiFile = parsed.openApiFile || findOpenApiFile(process.cwd());
|
|
1040
|
+
let flowsPathFromPositional = null;
|
|
1041
|
+
|
|
1042
|
+
if (!parsed.flowsPath && parsed.openApiFile && looksLikeFlowsSidecar(parsed.openApiFile)) {
|
|
1043
|
+
flowsPathFromPositional = parsed.openApiFile;
|
|
1044
|
+
targetOpenApiFile = findOpenApiFile(process.cwd());
|
|
1045
|
+
}
|
|
628
1046
|
|
|
629
1047
|
if (!targetOpenApiFile) {
|
|
630
1048
|
console.error("ERROR: Could not find an existing OpenAPI file in this repository.");
|
|
631
1049
|
console.error("Expected one of: openapi.yaml|yml|json, swagger.yaml|yml|json");
|
|
1050
|
+
if (flowsPathFromPositional) {
|
|
1051
|
+
console.error(`Detected sidecar argument: ${flowsPathFromPositional}`);
|
|
1052
|
+
console.error("Provide an OpenAPI file explicitly or run the command from the OpenAPI project root.");
|
|
1053
|
+
}
|
|
632
1054
|
return 1;
|
|
633
1055
|
}
|
|
634
1056
|
|
|
635
|
-
const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath);
|
|
1057
|
+
const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath || flowsPathFromPositional);
|
|
636
1058
|
if (!fs.existsSync(flowsPath)) {
|
|
637
1059
|
console.error(`ERROR: Flows sidecar not found: ${flowsPath}`);
|
|
638
|
-
console.error("Run `x-openapi-flow init` first to create and sync the sidecar
|
|
1060
|
+
console.error("Run `x-openapi-flow init` first to create and sync the sidecar, or use --flows <sidecar-file>.");
|
|
639
1061
|
return 1;
|
|
640
1062
|
}
|
|
641
1063
|
|
|
@@ -656,7 +1078,9 @@ function runApply(parsed) {
|
|
|
656
1078
|
}
|
|
657
1079
|
|
|
658
1080
|
const appliedCount = applyFlowsToOpenApi(api, flowsDoc);
|
|
659
|
-
const outputPath = parsed.
|
|
1081
|
+
const outputPath = parsed.inPlace
|
|
1082
|
+
? targetOpenApiFile
|
|
1083
|
+
: (parsed.outPath || deriveFlowOutputPath(targetOpenApiFile));
|
|
660
1084
|
saveOpenApi(outputPath, api);
|
|
661
1085
|
|
|
662
1086
|
console.log(`OpenAPI source: ${targetOpenApiFile}`);
|
|
@@ -666,6 +1090,68 @@ function runApply(parsed) {
|
|
|
666
1090
|
return 0;
|
|
667
1091
|
}
|
|
668
1092
|
|
|
1093
|
+
function runDiff(parsed) {
|
|
1094
|
+
const targetOpenApiFile = parsed.openApiFile || findOpenApiFile(process.cwd());
|
|
1095
|
+
|
|
1096
|
+
if (!targetOpenApiFile) {
|
|
1097
|
+
console.error("ERROR: Could not find an existing OpenAPI file in this repository.");
|
|
1098
|
+
console.error("Expected one of: openapi.yaml|yml|json, swagger.yaml|yml|json");
|
|
1099
|
+
return 1;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath);
|
|
1103
|
+
|
|
1104
|
+
let api;
|
|
1105
|
+
let existingFlows;
|
|
1106
|
+
try {
|
|
1107
|
+
api = loadApi(targetOpenApiFile);
|
|
1108
|
+
} catch (err) {
|
|
1109
|
+
console.error(`ERROR: Could not parse OpenAPI file — ${err.message}`);
|
|
1110
|
+
return 1;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
try {
|
|
1114
|
+
existingFlows = readFlowsFile(flowsPath);
|
|
1115
|
+
} catch (err) {
|
|
1116
|
+
console.error(`ERROR: Could not parse flows file — ${err.message}`);
|
|
1117
|
+
return 1;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const mergedFlows = mergeFlowsWithOpenApi(api, existingFlows);
|
|
1121
|
+
const diff = summarizeSidecarDiff(existingFlows, mergedFlows);
|
|
1122
|
+
|
|
1123
|
+
if (parsed.format === "json") {
|
|
1124
|
+
console.log(JSON.stringify({
|
|
1125
|
+
openApiFile: targetOpenApiFile,
|
|
1126
|
+
flowsPath,
|
|
1127
|
+
trackedOperations: mergedFlows.operations.length,
|
|
1128
|
+
exists: fs.existsSync(flowsPath),
|
|
1129
|
+
diff,
|
|
1130
|
+
}, null, 2));
|
|
1131
|
+
return 0;
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const addedText = diff.addedOperationIds.length ? diff.addedOperationIds.join(", ") : "-";
|
|
1135
|
+
const changedText = diff.changedOperationIds.length ? diff.changedOperationIds.join(", ") : "-";
|
|
1136
|
+
const removedText = diff.removedOperationIds.length ? diff.removedOperationIds.join(", ") : "-";
|
|
1137
|
+
|
|
1138
|
+
console.log(`OpenAPI source: ${targetOpenApiFile}`);
|
|
1139
|
+
console.log(`Flows sidecar: ${flowsPath}${fs.existsSync(flowsPath) ? "" : " (not found; treated as empty)"}`);
|
|
1140
|
+
console.log(`Tracked operations: ${mergedFlows.operations.length}`);
|
|
1141
|
+
console.log(`Sidecar diff -> added: ${diff.added}, changed: ${diff.changed}, removed: ${diff.removed}`);
|
|
1142
|
+
console.log(`Added operationIds: ${addedText}`);
|
|
1143
|
+
console.log(`Changed operationIds: ${changedText}`);
|
|
1144
|
+
if (diff.changedOperationDetails.length > 0) {
|
|
1145
|
+
console.log("Changed details:");
|
|
1146
|
+
diff.changedOperationDetails.forEach((detail) => {
|
|
1147
|
+
const paths = detail.changedPaths.length ? detail.changedPaths.join(", ") : "(root)";
|
|
1148
|
+
console.log(`- ${detail.operationId}: ${paths}`);
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
console.log(`Removed operationIds: ${removedText}`);
|
|
1152
|
+
return 0;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
669
1155
|
function runDoctor(parsed) {
|
|
670
1156
|
const config = loadConfig(parsed.configPath);
|
|
671
1157
|
let hasErrors = false;
|
|
@@ -708,13 +1194,173 @@ function runDoctor(parsed) {
|
|
|
708
1194
|
return hasErrors ? 1 : 0;
|
|
709
1195
|
}
|
|
710
1196
|
|
|
1197
|
+
function collectOperationIds(api) {
|
|
1198
|
+
const operationsById = new Map();
|
|
1199
|
+
const paths = (api && api.paths) || {};
|
|
1200
|
+
const methods = ["get", "put", "post", "delete", "options", "head", "patch", "trace"];
|
|
1201
|
+
|
|
1202
|
+
for (const [pathKey, pathItem] of Object.entries(paths)) {
|
|
1203
|
+
for (const method of methods) {
|
|
1204
|
+
const operation = pathItem[method];
|
|
1205
|
+
if (!operation || !operation.operationId) {
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
operationsById.set(operation.operationId, {
|
|
1209
|
+
endpoint: `${method.toUpperCase()} ${pathKey}`,
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
return operationsById;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function runLint(parsed, configData = {}) {
|
|
1218
|
+
const targetOpenApiFile = parsed.openApiFile || findOpenApiFile(process.cwd());
|
|
1219
|
+
if (!targetOpenApiFile) {
|
|
1220
|
+
console.error("ERROR: Could not find an existing OpenAPI file in this repository.");
|
|
1221
|
+
console.error("Expected one of: openapi.yaml|yml|json, swagger.yaml|yml|json");
|
|
1222
|
+
return 1;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
let api;
|
|
1226
|
+
try {
|
|
1227
|
+
api = loadApi(targetOpenApiFile);
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
console.error(`ERROR: Could not parse OpenAPI file — ${err.message}`);
|
|
1230
|
+
return 1;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
const flows = extractFlows(api);
|
|
1234
|
+
const lintConfig = (configData && configData.lint && configData.lint.rules) || {};
|
|
1235
|
+
const ruleConfig = {
|
|
1236
|
+
next_operation_id_exists: lintConfig.next_operation_id_exists !== false,
|
|
1237
|
+
prerequisite_operation_ids_exist: lintConfig.prerequisite_operation_ids_exist !== false,
|
|
1238
|
+
duplicate_transitions: lintConfig.duplicate_transitions !== false,
|
|
1239
|
+
terminal_path: lintConfig.terminal_path !== false,
|
|
1240
|
+
};
|
|
1241
|
+
|
|
1242
|
+
const operationsById = collectOperationIds(api);
|
|
1243
|
+
const graph = buildStateGraph(flows);
|
|
1244
|
+
const invalidOperationReferences = detectInvalidOperationReferences(operationsById, flows);
|
|
1245
|
+
const duplicateTransitions = detectDuplicateTransitions(flows);
|
|
1246
|
+
const terminalCoverage = detectTerminalCoverage(graph);
|
|
1247
|
+
|
|
1248
|
+
const nextOperationIssues = invalidOperationReferences
|
|
1249
|
+
.filter((entry) => entry.type === "next_operation_id")
|
|
1250
|
+
.map((entry) => ({
|
|
1251
|
+
operation_id: entry.operation_id,
|
|
1252
|
+
declared_in: entry.declared_in,
|
|
1253
|
+
}));
|
|
1254
|
+
|
|
1255
|
+
const prerequisiteIssues = invalidOperationReferences
|
|
1256
|
+
.filter((entry) => entry.type === "prerequisite_operation_ids")
|
|
1257
|
+
.map((entry) => ({
|
|
1258
|
+
operation_id: entry.operation_id,
|
|
1259
|
+
declared_in: entry.declared_in,
|
|
1260
|
+
}));
|
|
1261
|
+
|
|
1262
|
+
const issues = {
|
|
1263
|
+
next_operation_id_exists: ruleConfig.next_operation_id_exists ? nextOperationIssues : [],
|
|
1264
|
+
prerequisite_operation_ids_exist: ruleConfig.prerequisite_operation_ids_exist ? prerequisiteIssues : [],
|
|
1265
|
+
duplicate_transitions: ruleConfig.duplicate_transitions ? duplicateTransitions : [],
|
|
1266
|
+
terminal_path: {
|
|
1267
|
+
terminal_states: ruleConfig.terminal_path ? terminalCoverage.terminal_states : [],
|
|
1268
|
+
non_terminating_states: ruleConfig.terminal_path ? terminalCoverage.non_terminating_states : [],
|
|
1269
|
+
},
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
const errorCount =
|
|
1273
|
+
issues.next_operation_id_exists.length +
|
|
1274
|
+
issues.prerequisite_operation_ids_exist.length +
|
|
1275
|
+
issues.duplicate_transitions.length +
|
|
1276
|
+
issues.terminal_path.non_terminating_states.length;
|
|
1277
|
+
|
|
1278
|
+
const result = {
|
|
1279
|
+
ok: errorCount === 0,
|
|
1280
|
+
path: targetOpenApiFile,
|
|
1281
|
+
flowCount: flows.length,
|
|
1282
|
+
ruleConfig,
|
|
1283
|
+
issues,
|
|
1284
|
+
summary: {
|
|
1285
|
+
errors: errorCount,
|
|
1286
|
+
violated_rules: Object.entries({
|
|
1287
|
+
next_operation_id_exists: issues.next_operation_id_exists.length,
|
|
1288
|
+
prerequisite_operation_ids_exist: issues.prerequisite_operation_ids_exist.length,
|
|
1289
|
+
duplicate_transitions: issues.duplicate_transitions.length,
|
|
1290
|
+
terminal_path: issues.terminal_path.non_terminating_states.length,
|
|
1291
|
+
})
|
|
1292
|
+
.filter(([, count]) => count > 0)
|
|
1293
|
+
.map(([rule]) => rule),
|
|
1294
|
+
},
|
|
1295
|
+
};
|
|
1296
|
+
|
|
1297
|
+
if (parsed.format === "json") {
|
|
1298
|
+
console.log(JSON.stringify(result, null, 2));
|
|
1299
|
+
return result.ok ? 0 : 1;
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
console.log(`Linting: ${targetOpenApiFile}`);
|
|
1303
|
+
console.log(`Found ${flows.length} x-openapi-flow definition(s).`);
|
|
1304
|
+
console.log("Rules:");
|
|
1305
|
+
Object.entries(ruleConfig).forEach(([ruleName, enabled]) => {
|
|
1306
|
+
console.log(`- ${ruleName}: ${enabled ? "enabled" : "disabled"}`);
|
|
1307
|
+
});
|
|
1308
|
+
|
|
1309
|
+
if (flows.length === 0) {
|
|
1310
|
+
console.log("No x-openapi-flow definitions found.");
|
|
1311
|
+
return 0;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
if (issues.next_operation_id_exists.length === 0) {
|
|
1315
|
+
console.log("✔ next_operation_id_exists: no invalid references.");
|
|
1316
|
+
} else {
|
|
1317
|
+
console.error(`✘ next_operation_id_exists: ${issues.next_operation_id_exists.length} invalid reference(s).`);
|
|
1318
|
+
issues.next_operation_id_exists.forEach((entry) => {
|
|
1319
|
+
console.error(` - ${entry.operation_id} (declared in ${entry.declared_in})`);
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (issues.prerequisite_operation_ids_exist.length === 0) {
|
|
1324
|
+
console.log("✔ prerequisite_operation_ids_exist: no invalid references.");
|
|
1325
|
+
} else {
|
|
1326
|
+
console.error(`✘ prerequisite_operation_ids_exist: ${issues.prerequisite_operation_ids_exist.length} invalid reference(s).`);
|
|
1327
|
+
issues.prerequisite_operation_ids_exist.forEach((entry) => {
|
|
1328
|
+
console.error(` - ${entry.operation_id} (declared in ${entry.declared_in})`);
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (issues.duplicate_transitions.length === 0) {
|
|
1333
|
+
console.log("✔ duplicate_transitions: none found.");
|
|
1334
|
+
} else {
|
|
1335
|
+
console.error(`✘ duplicate_transitions: ${issues.duplicate_transitions.length} duplicate transition group(s).`);
|
|
1336
|
+
issues.duplicate_transitions.forEach((entry) => {
|
|
1337
|
+
console.error(` - ${entry.from} -> ${entry.to} (${entry.trigger_type}), count=${entry.count}`);
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
if (issues.terminal_path.non_terminating_states.length === 0) {
|
|
1342
|
+
console.log("✔ terminal_path: all states can reach a terminal state.");
|
|
1343
|
+
} else {
|
|
1344
|
+
console.error(
|
|
1345
|
+
`✘ terminal_path: states without path to terminal -> ${issues.terminal_path.non_terminating_states.join(", ")}`
|
|
1346
|
+
);
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
if (result.ok) {
|
|
1350
|
+
console.log("Lint checks passed ✔");
|
|
1351
|
+
} else {
|
|
1352
|
+
console.error("Lint checks finished with errors.");
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
return result.ok ? 0 : 1;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
711
1358
|
function buildMermaidGraph(filePath) {
|
|
712
1359
|
const flows = extractFlowsForGraph(filePath);
|
|
713
1360
|
if (flows.length === 0) {
|
|
714
1361
|
throw new Error("No x-openapi-flow definitions found in OpenAPI or sidecar file");
|
|
715
1362
|
}
|
|
716
1363
|
|
|
717
|
-
const lines = ["stateDiagram-v2"];
|
|
718
1364
|
const nodes = new Set();
|
|
719
1365
|
const edges = [];
|
|
720
1366
|
const edgeSeen = new Set();
|
|
@@ -756,19 +1402,47 @@ function buildMermaidGraph(filePath) {
|
|
|
756
1402
|
next_operation_id: transition.next_operation_id,
|
|
757
1403
|
prerequisite_operation_ids: transition.prerequisite_operation_ids || [],
|
|
758
1404
|
});
|
|
759
|
-
|
|
760
|
-
lines.push(` ${from} --> ${to}${label ? `: ${label}` : ""}`);
|
|
761
1405
|
}
|
|
762
1406
|
}
|
|
763
1407
|
|
|
764
|
-
|
|
765
|
-
|
|
1408
|
+
const sortedNodes = [...nodes].sort((a, b) => a.localeCompare(b));
|
|
1409
|
+
const sortedEdges = [...edges].sort((left, right) => {
|
|
1410
|
+
const leftKey = [
|
|
1411
|
+
left.from,
|
|
1412
|
+
left.to,
|
|
1413
|
+
left.next_operation_id || "",
|
|
1414
|
+
(left.prerequisite_operation_ids || []).join(","),
|
|
1415
|
+
].join("::");
|
|
1416
|
+
const rightKey = [
|
|
1417
|
+
right.from,
|
|
1418
|
+
right.to,
|
|
1419
|
+
right.next_operation_id || "",
|
|
1420
|
+
(right.prerequisite_operation_ids || []).join(","),
|
|
1421
|
+
].join("::");
|
|
1422
|
+
return leftKey.localeCompare(rightKey);
|
|
1423
|
+
});
|
|
1424
|
+
|
|
1425
|
+
const lines = ["stateDiagram-v2"];
|
|
1426
|
+
for (const state of sortedNodes) {
|
|
1427
|
+
lines.push(` state ${state}`);
|
|
1428
|
+
}
|
|
1429
|
+
for (const edge of sortedEdges) {
|
|
1430
|
+
const labelParts = [];
|
|
1431
|
+
if (edge.next_operation_id) {
|
|
1432
|
+
labelParts.push(`next:${edge.next_operation_id}`);
|
|
1433
|
+
}
|
|
1434
|
+
if (Array.isArray(edge.prerequisite_operation_ids) && edge.prerequisite_operation_ids.length > 0) {
|
|
1435
|
+
labelParts.push(`requires:${edge.prerequisite_operation_ids.join(",")}`);
|
|
1436
|
+
}
|
|
1437
|
+
const label = labelParts.join(" | ");
|
|
1438
|
+
lines.push(` ${edge.from} --> ${edge.to}${label ? `: ${label}` : ""}`);
|
|
766
1439
|
}
|
|
767
1440
|
|
|
768
1441
|
return {
|
|
1442
|
+
format_version: "1.0",
|
|
769
1443
|
flowCount: flows.length,
|
|
770
|
-
nodes:
|
|
771
|
-
edges,
|
|
1444
|
+
nodes: sortedNodes,
|
|
1445
|
+
edges: sortedEdges,
|
|
772
1446
|
mermaid: lines.join("\n"),
|
|
773
1447
|
};
|
|
774
1448
|
}
|
|
@@ -864,6 +1538,19 @@ function main() {
|
|
|
864
1538
|
process.exit(runApply(parsed));
|
|
865
1539
|
}
|
|
866
1540
|
|
|
1541
|
+
if (parsed.command === "diff") {
|
|
1542
|
+
process.exit(runDiff(parsed));
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
if (parsed.command === "lint") {
|
|
1546
|
+
const config = loadConfig(parsed.configPath);
|
|
1547
|
+
if (config.error) {
|
|
1548
|
+
console.error(`ERROR: ${config.error}`);
|
|
1549
|
+
process.exit(1);
|
|
1550
|
+
}
|
|
1551
|
+
process.exit(runLint(parsed, config.data));
|
|
1552
|
+
}
|
|
1553
|
+
|
|
867
1554
|
const config = loadConfig(parsed.configPath);
|
|
868
1555
|
if (config.error) {
|
|
869
1556
|
console.error(`ERROR: ${config.error}`);
|