x-openapi-flow 1.2.3 → 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.
@@ -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]
50
+ x-openapi-flow init [openapi-file] [--flows path] [--force] [--dry-run]
47
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,11 +59,17 @@ 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-openapi-flow.yaml
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
59
67
  x-openapi-flow apply openapi.yaml --in-place
60
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
61
73
  x-openapi-flow graph examples/order-api.yaml
62
74
  x-openapi-flow doctor
63
75
  `);
@@ -69,6 +81,73 @@ function deriveFlowOutputPath(openApiFile) {
69
81
  return path.join(parsed.dir, `${parsed.name}.flow${extension}`);
70
82
  }
71
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
+
72
151
  function getOptionValue(args, optionName) {
73
152
  const index = args.indexOf(optionName);
74
153
  if (index === -1) {
@@ -175,7 +254,7 @@ function parseValidateArgs(args) {
175
254
  }
176
255
 
177
256
  function parseInitArgs(args) {
178
- const unknown = findUnknownOptions(args, ["--flows"], []);
257
+ const unknown = findUnknownOptions(args, ["--flows"], ["--force", "--dry-run"]);
179
258
  if (unknown) {
180
259
  return { error: `Unknown option: ${unknown}` };
181
260
  }
@@ -189,6 +268,12 @@ function parseInitArgs(args) {
189
268
  if (token === "--flows") {
190
269
  return false;
191
270
  }
271
+ if (token === "--force") {
272
+ return false;
273
+ }
274
+ if (token === "--dry-run") {
275
+ return false;
276
+ }
192
277
  if (index > 0 && args[index - 1] === "--flows") {
193
278
  return false;
194
279
  }
@@ -202,6 +287,203 @@ function parseInitArgs(args) {
202
287
  return {
203
288
  openApiFile: positional[0] ? path.resolve(positional[0]) : undefined,
204
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,
205
487
  };
206
488
  }
207
489
 
@@ -334,6 +616,16 @@ function parseArgs(argv) {
334
616
  return parsed.error ? parsed : { command, ...parsed };
335
617
  }
336
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
+
337
629
  if (command === "doctor") {
338
630
  const parsed = parseDoctorArgs(commandArgs);
339
631
  return parsed.error ? parsed : { command, ...parsed };
@@ -401,13 +693,40 @@ function resolveFlowsPath(openApiFile, customFlowsPath) {
401
693
  if (openApiFile) {
402
694
  const parsed = path.parse(openApiFile);
403
695
  const extension = parsed.ext.toLowerCase() === ".json" ? ".json" : ".yaml";
404
- const fileName = `${parsed.name}-openapi-flow${extension}`;
405
- return path.join(path.dirname(openApiFile), fileName);
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;
406
711
  }
407
712
 
408
713
  return path.resolve(process.cwd(), DEFAULT_FLOWS_FILE);
409
714
  }
410
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
+
411
730
  function getOpenApiFormat(filePath) {
412
731
  return filePath.endsWith(".json") ? "json" : "yaml";
413
732
  }
@@ -494,8 +813,8 @@ function buildFlowTemplate(operationId) {
494
813
  const safeOperationId = operationId || "operation";
495
814
  return {
496
815
  version: "1.0",
497
- id: `${safeOperationId}_FLOW_ID`,
498
- current_state: `${safeOperationId}_STATE`,
816
+ id: safeOperationId,
817
+ current_state: safeOperationId,
499
818
  transitions: [],
500
819
  };
501
820
  }
@@ -606,6 +925,7 @@ function runInit(parsed) {
606
925
  }
607
926
 
608
927
  const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath);
928
+ const flowOutputPath = deriveFlowOutputPath(targetOpenApiFile);
609
929
 
610
930
  let api;
611
931
  try {
@@ -624,12 +944,91 @@ function runInit(parsed) {
624
944
  }
625
945
 
626
946
  const mergedFlows = mergeFlowsWithOpenApi(api, flowsDoc);
627
- writeFlowsFile(flowsPath, mergedFlows);
628
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
+ }
629
1027
 
630
1028
  console.log(`Using existing OpenAPI file: ${targetOpenApiFile}`);
631
1029
  console.log(`Flows sidecar synced: ${flowsPath}`);
632
1030
  console.log(`Tracked operations: ${trackedCount}`);
1031
+ console.log(applyMessage);
633
1032
  console.log("OpenAPI source unchanged. Edit the sidecar and run apply to generate the full spec.");
634
1033
 
635
1034
  console.log(`Validate now: x-openapi-flow validate ${targetOpenApiFile}`);
@@ -637,18 +1036,28 @@ function runInit(parsed) {
637
1036
  }
638
1037
 
639
1038
  function runApply(parsed) {
640
- const targetOpenApiFile = parsed.openApiFile || findOpenApiFile(process.cwd());
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
+ }
641
1046
 
642
1047
  if (!targetOpenApiFile) {
643
1048
  console.error("ERROR: Could not find an existing OpenAPI file in this repository.");
644
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
+ }
645
1054
  return 1;
646
1055
  }
647
1056
 
648
- const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath);
1057
+ const flowsPath = resolveFlowsPath(targetOpenApiFile, parsed.flowsPath || flowsPathFromPositional);
649
1058
  if (!fs.existsSync(flowsPath)) {
650
1059
  console.error(`ERROR: Flows sidecar not found: ${flowsPath}`);
651
- 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>.");
652
1061
  return 1;
653
1062
  }
654
1063
 
@@ -681,6 +1090,68 @@ function runApply(parsed) {
681
1090
  return 0;
682
1091
  }
683
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
+
684
1155
  function runDoctor(parsed) {
685
1156
  const config = loadConfig(parsed.configPath);
686
1157
  let hasErrors = false;
@@ -723,13 +1194,173 @@ function runDoctor(parsed) {
723
1194
  return hasErrors ? 1 : 0;
724
1195
  }
725
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
+
726
1358
  function buildMermaidGraph(filePath) {
727
1359
  const flows = extractFlowsForGraph(filePath);
728
1360
  if (flows.length === 0) {
729
1361
  throw new Error("No x-openapi-flow definitions found in OpenAPI or sidecar file");
730
1362
  }
731
1363
 
732
- const lines = ["stateDiagram-v2"];
733
1364
  const nodes = new Set();
734
1365
  const edges = [];
735
1366
  const edgeSeen = new Set();
@@ -771,19 +1402,47 @@ function buildMermaidGraph(filePath) {
771
1402
  next_operation_id: transition.next_operation_id,
772
1403
  prerequisite_operation_ids: transition.prerequisite_operation_ids || [],
773
1404
  });
774
-
775
- lines.push(` ${from} --> ${to}${label ? `: ${label}` : ""}`);
776
1405
  }
777
1406
  }
778
1407
 
779
- for (const state of nodes) {
780
- lines.splice(1, 0, ` state ${state}`);
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}` : ""}`);
781
1439
  }
782
1440
 
783
1441
  return {
1442
+ format_version: "1.0",
784
1443
  flowCount: flows.length,
785
- nodes: [...nodes],
786
- edges,
1444
+ nodes: sortedNodes,
1445
+ edges: sortedEdges,
787
1446
  mermaid: lines.join("\n"),
788
1447
  };
789
1448
  }
@@ -879,6 +1538,19 @@ function main() {
879
1538
  process.exit(runApply(parsed));
880
1539
  }
881
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
+
882
1554
  const config = loadConfig(parsed.configPath);
883
1555
  if (config.error) {
884
1556
  console.error(`ERROR: ${config.error}`);