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.
@@ -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-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
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 fileName = `${parsed.name}-openapi-flow${extension}`;
392
- 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;
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: `${safeOperationId}_FLOW_ID`,
485
- current_state: `${safeOperationId}_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
- 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
+ }
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.outPath || targetOpenApiFile;
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
- for (const state of nodes) {
765
- 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}` : ""}`);
766
1439
  }
767
1440
 
768
1441
  return {
1442
+ format_version: "1.0",
769
1443
  flowCount: flows.length,
770
- nodes: [...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}`);