x-openapi-flow 1.4.3 → 1.5.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/lib/validator.js CHANGED
@@ -4,6 +4,7 @@ const fs = require("fs");
4
4
  const path = require("path");
5
5
  const yaml = require("js-yaml");
6
6
  const Ajv = require("ajv");
7
+ const { CODES, buildIssue } = require("./error-codes");
7
8
 
8
9
  // ---------------------------------------------------------------------------
9
10
  // Paths
@@ -150,6 +151,7 @@ function defaultResult(pathValue, ok = true) {
150
151
  non_terminating_states: [],
151
152
  invalid_operation_references: [],
152
153
  invalid_field_references: [],
154
+ semantic_warnings: [],
153
155
  warnings: [],
154
156
  },
155
157
  };
@@ -701,6 +703,88 @@ function detectTerminalCoverage(graph) {
701
703
  };
702
704
  }
703
705
 
706
+ function detectStateNamingStyle(state) {
707
+ if (/^[A-Z][A-Z0-9_]*$/.test(state)) {
708
+ return "upper_snake";
709
+ }
710
+ if (/^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/.test(state)) {
711
+ return "kebab_case";
712
+ }
713
+ if (/^[a-z][a-zA-Z0-9]*$/.test(state)) {
714
+ return "camelCase";
715
+ }
716
+ if (/^[A-Z][a-zA-Z0-9]*$/.test(state)) {
717
+ return "PascalCase";
718
+ }
719
+ return "other";
720
+ }
721
+
722
+ function canonicalizeStateName(state) {
723
+ return String(state || "")
724
+ .toLowerCase()
725
+ .replace(/[_\-\s]+/g, "");
726
+ }
727
+
728
+ function detectSemanticModelingWarnings(flows) {
729
+ const warnings = [];
730
+ const states = [...new Set(flows.map(({ flow }) => String(flow.current_state)).filter(Boolean))];
731
+
732
+ if (states.length > 0) {
733
+ const styleSet = new Set(states.map((state) => detectStateNamingStyle(state)));
734
+ if (styleSet.size > 1) {
735
+ warnings.push(
736
+ `Semantic: inconsistent state naming styles detected (${[...styleSet].sort().join(", ")}).`
737
+ );
738
+ }
739
+
740
+ const byCanonical = new Map();
741
+ for (const state of states) {
742
+ const key = canonicalizeStateName(state);
743
+ if (!byCanonical.has(key)) {
744
+ byCanonical.set(key, new Set());
745
+ }
746
+ byCanonical.get(key).add(state);
747
+ }
748
+
749
+ for (const variants of byCanonical.values()) {
750
+ if (variants.size > 1) {
751
+ warnings.push(
752
+ `Semantic: ambiguous state variants found (${[...variants].sort().join(", ")}).`
753
+ );
754
+ }
755
+ }
756
+ }
757
+
758
+ const transitionSignature = new Map();
759
+ for (const { flow } of flows) {
760
+ const from = String(flow.current_state || "");
761
+ const transitions = Array.isArray(flow.transitions) ? flow.transitions : [];
762
+
763
+ for (const transition of transitions) {
764
+ if (!transition || !transition.next_operation_id) {
765
+ continue;
766
+ }
767
+
768
+ const key = `${from}::${transition.next_operation_id}`;
769
+ if (!transitionSignature.has(key)) {
770
+ transitionSignature.set(key, new Set());
771
+ }
772
+ transitionSignature.get(key).add(String(transition.target_state || ""));
773
+ }
774
+ }
775
+
776
+ for (const [signature, targetStates] of transitionSignature.entries()) {
777
+ if (targetStates.size > 1) {
778
+ const [signatureFrom, signatureOperation] = signature.split("::");
779
+ warnings.push(
780
+ `Semantic: ambiguous transition mapping from state '${signatureFrom}' to next_operation_id '${signatureOperation}' with multiple target states (${[...targetStates].sort().join(", ")}).`
781
+ );
782
+ }
783
+ }
784
+
785
+ return [...new Set(warnings)];
786
+ }
787
+
704
788
  // ---------------------------------------------------------------------------
705
789
  // Main runner
706
790
  // ---------------------------------------------------------------------------
@@ -708,12 +792,13 @@ function detectTerminalCoverage(graph) {
708
792
  /**
709
793
  * Run all validations against an OAS file and print results.
710
794
  * @param {string} [apiPath] - Path to the OAS YAML file (defaults to payment-api.yaml).
711
- * @param {{ output?: "pretty" | "json", strictQuality?: boolean, profile?: "core" | "relaxed" | "strict" }} [options]
795
+ * @param {{ output?: "pretty" | "json", strictQuality?: boolean, profile?: "core" | "relaxed" | "strict", semantic?: boolean }} [options]
712
796
  * @returns {{ ok: boolean, path: string, flowCount: number, schemaFailures: object[], orphans: object[], graphChecks: object }}
713
797
  */
714
798
  function run(apiPath, options = {}) {
715
799
  const output = options.output || "pretty";
716
800
  const strictQuality = options.strictQuality === true;
801
+ const semantic = options.semantic === true;
717
802
  const profile = options.profile || "strict";
718
803
  const profiles = {
719
804
  core: { runAdvanced: false, failAdvanced: false, runQuality: false },
@@ -861,6 +946,7 @@ function run(apiPath, options = {}) {
861
946
  }
862
947
 
863
948
  const qualityWarnings = [];
949
+ const semanticWarnings = semantic ? detectSemanticModelingWarnings(flows) : [];
864
950
 
865
951
  if (profileConfig.runQuality && multipleInitialStates.length > 0) {
866
952
  qualityWarnings.push(
@@ -895,6 +981,10 @@ function run(apiPath, options = {}) {
895
981
  );
896
982
  }
897
983
 
984
+ if (profileConfig.runQuality && semanticWarnings.length > 0) {
985
+ qualityWarnings.push(...semanticWarnings);
986
+ }
987
+
898
988
  if (strictQuality && qualityWarnings.length > 0) {
899
989
  hasErrors = true;
900
990
  }
@@ -985,10 +1075,14 @@ function run(apiPath, options = {}) {
985
1075
  non_terminating_states: terminalCoverage.non_terminating_states,
986
1076
  invalid_operation_references: invalidOperationReferences,
987
1077
  invalid_field_references: invalidFieldReferences,
1078
+ semantic_warnings: semanticWarnings,
988
1079
  warnings: qualityWarnings,
989
1080
  },
990
1081
  };
991
1082
 
1083
+ // Attach structured issues list to JSON output
1084
+ result.issues = buildStructuredIssues(result);
1085
+
992
1086
  if (output === "json") {
993
1087
  console.log(JSON.stringify(result, null, 2));
994
1088
  } else {
@@ -1003,6 +1097,286 @@ function run(apiPath, options = {}) {
1003
1097
  return result;
1004
1098
  }
1005
1099
 
1100
+ // ---------------------------------------------------------------------------
1101
+ // Structured issue builder (maps raw results → standard issue objects)
1102
+ // ---------------------------------------------------------------------------
1103
+
1104
+ /**
1105
+ * Convert raw validation result into a flat array of structured issue objects,
1106
+ * each with a stable XFLOW error code.
1107
+ */
1108
+ function buildStructuredIssues(result) {
1109
+ const issues = [];
1110
+
1111
+ // Schema failures
1112
+ for (const failure of (result.schemaFailures || [])) {
1113
+ for (const err of (failure.errors || [])) {
1114
+ const isAdditional = err.keyword === "additionalProperties" && err.params && err.params.additionalProperty;
1115
+ const isMissing = err.keyword === "required" && err.params && err.params.missingProperty;
1116
+ const isEnum = err.keyword === "enum";
1117
+
1118
+ let def;
1119
+ if (isAdditional) {
1120
+ def = CODES.SCHEMA_ADDITIONAL_PROPERTY;
1121
+ } else if (isMissing) {
1122
+ def = CODES.SCHEMA_MISSING_REQUIRED;
1123
+ } else if (isEnum) {
1124
+ def = CODES.SCHEMA_INVALID_ENUM;
1125
+ } else {
1126
+ def = CODES.SCHEMA_VALIDATION_FAILED;
1127
+ }
1128
+
1129
+ const msg = isMissing
1130
+ ? `Missing required property '${err.params.missingProperty}'.`
1131
+ : isAdditional
1132
+ ? `Property '${err.params.additionalProperty}' is not allowed.`
1133
+ : err.message || def.title;
1134
+
1135
+ const suggestion = isMissing && err.params.missingProperty === "version"
1136
+ ? "Add `version: \"1.0\"` to the x-openapi-flow object."
1137
+ : isMissing && err.params.missingProperty === "current_state"
1138
+ ? "Add `current_state` to describe the operation state."
1139
+ : isAdditional
1140
+ ? `Remove unsupported property \`${err.params.additionalProperty}\` from x-openapi-flow payload.`
1141
+ : undefined;
1142
+
1143
+ issues.push(buildIssue(def, msg, { location: failure.endpoint, suggestion }));
1144
+ }
1145
+ }
1146
+
1147
+ // Orphan states
1148
+ for (const orphan of (result.orphans || [])) {
1149
+ issues.push(buildIssue(
1150
+ CODES.GRAPH_ORPHAN_STATES,
1151
+ `Orphan state: '${orphan}'.`,
1152
+ {
1153
+ location: orphan,
1154
+ suggestion: "Connect this state to the flow graph using transitions.",
1155
+ }
1156
+ ));
1157
+ }
1158
+
1159
+ const gc = result.graphChecks || {};
1160
+
1161
+ // Graph: no initial state
1162
+ if (Array.isArray(gc.initial_states) && gc.initial_states.length === 0 && (result.flowCount || 0) > 0) {
1163
+ issues.push(buildIssue(
1164
+ CODES.GRAPH_NO_INITIAL_STATE,
1165
+ "No initial state detected (every state has incoming transitions).",
1166
+ { suggestion: "Ensure at least one state has no incoming transitions (flow entry point)." }
1167
+ ));
1168
+ }
1169
+
1170
+ // Graph: no terminal state
1171
+ if (Array.isArray(gc.terminal_states) && gc.terminal_states.length === 0 && (result.flowCount || 0) > 0) {
1172
+ issues.push(buildIssue(
1173
+ CODES.GRAPH_NO_TERMINAL_STATE,
1174
+ "No terminal state detected (every state has outgoing transitions).",
1175
+ { suggestion: "Ensure at least one state has no outgoing transitions (flow end point)." }
1176
+ ));
1177
+ }
1178
+
1179
+ // Graph: unreachable
1180
+ for (const state of (gc.unreachable_states || [])) {
1181
+ issues.push(buildIssue(
1182
+ CODES.GRAPH_UNREACHABLE_STATES,
1183
+ `State '${state}' is unreachable from any initial state.`,
1184
+ { location: state, suggestion: `Add a transition leading to '${state}' or remove it.` }
1185
+ ));
1186
+ }
1187
+
1188
+ // Graph: cycle
1189
+ if (gc.cycle && gc.cycle.has_cycle) {
1190
+ const cyclePath = Array.isArray(gc.cycle.cycle_path) ? gc.cycle.cycle_path.join(" → ") : "";
1191
+ issues.push(buildIssue(
1192
+ CODES.GRAPH_CYCLE_DETECTED,
1193
+ `Cycle detected in flow graph: ${cyclePath}.`,
1194
+ {
1195
+ suggestion: "Remove the back-edge creating the cycle or use profile 'relaxed' to allow cycles.",
1196
+ details: { cycle_path: gc.cycle.cycle_path },
1197
+ }
1198
+ ));
1199
+ }
1200
+
1201
+ const qc = result.qualityChecks || {};
1202
+
1203
+ // Quality: multiple initial states
1204
+ for (const state of (qc.multiple_initial_states || [])) {
1205
+ issues.push(buildIssue(
1206
+ CODES.QUALITY_MULTIPLE_INITIAL_STATES,
1207
+ `State '${state}' is an additional initial state — the flow has multiple entry points.`,
1208
+ { location: state, suggestion: "Consolidate into a single initial state if possible." }
1209
+ ));
1210
+ }
1211
+
1212
+ // Quality: duplicate transitions
1213
+ for (const dup of (qc.duplicate_transitions || [])) {
1214
+ issues.push(buildIssue(
1215
+ CODES.QUALITY_DUPLICATE_TRANSITIONS,
1216
+ `Duplicate transition from '${dup.from}' to '${dup.to}' (count: ${dup.count}).`,
1217
+ { location: `${dup.from} → ${dup.to}`, suggestion: "Remove redundant transition entries." }
1218
+ ));
1219
+ }
1220
+
1221
+ // Quality: non-terminating states
1222
+ for (const state of (qc.non_terminating_states || [])) {
1223
+ issues.push(buildIssue(
1224
+ CODES.QUALITY_NON_TERMINATING_STATES,
1225
+ `State '${state}' has no path to a terminal state.`,
1226
+ { location: state, suggestion: "Add a transition from this state to a terminal state." }
1227
+ ));
1228
+ }
1229
+
1230
+ // Quality: invalid operation references
1231
+ for (const ref of (qc.invalid_operation_references || [])) {
1232
+ issues.push(buildIssue(
1233
+ CODES.QUALITY_INVALID_OPERATION_REF,
1234
+ `Transition in '${ref.declared_in}' references unknown operationId '${ref.operation_id}' (type: ${ref.type}).`,
1235
+ {
1236
+ location: ref.declared_in,
1237
+ suggestion: `Check that '${ref.operation_id}' is defined in the OpenAPI spec with an operationId.`,
1238
+ details: { operation_id: ref.operation_id, ref_type: ref.type },
1239
+ }
1240
+ ));
1241
+ }
1242
+
1243
+ // Quality: invalid field references
1244
+ for (const ref of (qc.invalid_field_refs || qc.invalid_field_references || [])) {
1245
+ if (!ref) continue;
1246
+ issues.push(buildIssue(
1247
+ CODES.QUALITY_INVALID_FIELD_REF,
1248
+ typeof ref === "string"
1249
+ ? `Invalid field reference: ${ref}`
1250
+ : `Invalid field reference '${ref.ref || ref.message}' in '${ref.endpoint || ref.declared_in || "unknown"}'.`,
1251
+ { suggestion: "Verify the field path and operationId in the field reference." }
1252
+ ));
1253
+ }
1254
+
1255
+ // Semantic warnings
1256
+ for (const warning of (qc.semantic_warnings || [])) {
1257
+ const isAmbiguous = warning.includes("ambiguous state variants") || warning.includes("ambiguous transition");
1258
+ const def = isAmbiguous ? CODES.QUALITY_SEMANTIC_AMBIGUOUS_VARIANTS : CODES.QUALITY_SEMANTIC_INCONSISTENT_NAMING;
1259
+ issues.push(buildIssue(
1260
+ def,
1261
+ warning,
1262
+ { suggestion: "Adopt a single consistent naming convention for all state names (e.g. UPPER_SNAKE_CASE)." }
1263
+ ));
1264
+ }
1265
+
1266
+ return issues;
1267
+ }
1268
+
1269
+ // ---------------------------------------------------------------------------
1270
+ // Quality report
1271
+ // ---------------------------------------------------------------------------
1272
+
1273
+ /**
1274
+ * Compute a quality score and structured report from a validation result.
1275
+ *
1276
+ * @param {object} result - Result returned by `run()`.
1277
+ * @param {{ semantic?: boolean }} [options]
1278
+ * @returns {object} Quality report with score, grade, issues, suggestions, breakdown.
1279
+ */
1280
+ function computeQualityReport(result, options = {}) {
1281
+ const semantic = options.semantic === true;
1282
+ const issues = result.issues && result.issues.length > 0 ? result.issues : buildStructuredIssues(result);
1283
+ const flowCount = result.flowCount || 0;
1284
+
1285
+ // ── Schema score (weight 40 pts) ────────────────────────────────────────
1286
+ const schemaErrors = (result.schemaFailures || []).length;
1287
+ const schemaScore = flowCount === 0 ? 100 : Math.max(0, Math.round((1 - schemaErrors / flowCount) * 100));
1288
+
1289
+ // ── Graph score (weight 30 pts) ──────────────────────────────────────────
1290
+ const gc = result.graphChecks || {};
1291
+ const graphChecksTotal = 4;
1292
+ let graphPassed = 0;
1293
+ if (!Array.isArray(gc.initial_states) || gc.initial_states.length > 0 || flowCount === 0) graphPassed += 1;
1294
+ if (!Array.isArray(gc.terminal_states) || gc.terminal_states.length > 0 || flowCount === 0) graphPassed += 1;
1295
+ if (!Array.isArray(gc.unreachable_states) || gc.unreachable_states.length === 0) graphPassed += 1;
1296
+ if (!gc.cycle || !gc.cycle.has_cycle) graphPassed += 1;
1297
+ const graphScore = Math.round((graphPassed / graphChecksTotal) * 100);
1298
+
1299
+ // ── Quality score (weight 20 pts) ────────────────────────────────────────
1300
+ const qc = result.qualityChecks || {};
1301
+ const qualityIssueCount =
1302
+ (qc.multiple_initial_states || []).length +
1303
+ (qc.duplicate_transitions || []).length +
1304
+ (qc.non_terminating_states || []).length +
1305
+ (qc.invalid_operation_references || []).length +
1306
+ (qc.invalid_field_references || []).length;
1307
+ const qualityScore = flowCount === 0 ? 100 : Math.max(0, Math.round((1 - Math.min(1, qualityIssueCount / Math.max(1, flowCount))) * 100));
1308
+
1309
+ // ── Semantic score (weight 10 pts) ───────────────────────────────────────
1310
+ const semanticIssueCount = semantic ? (qc.semantic_warnings || []).length : 0;
1311
+ const semanticScore = flowCount === 0 ? 100 : Math.max(0, Math.round((1 - Math.min(1, semanticIssueCount / Math.max(1, flowCount))) * 100));
1312
+
1313
+ // ── Overall weighted score ───────────────────────────────────────────────
1314
+ const semanticWeight = semantic ? 0.10 : 0;
1315
+ const qualityWeight = semantic ? 0.20 : 0.25;
1316
+ const graphWeight = semantic ? 0.30 : 0.35;
1317
+ const schemaWeight = semantic ? 0.40 : 0.40;
1318
+
1319
+ const weightedScore =
1320
+ schemaScore * schemaWeight +
1321
+ graphScore * graphWeight +
1322
+ qualityScore * qualityWeight +
1323
+ (semantic ? semanticScore * semanticWeight : 0);
1324
+
1325
+ const score = Math.round(weightedScore);
1326
+
1327
+ let grade;
1328
+ if (score >= 90) grade = "A";
1329
+ else if (score >= 80) grade = "B";
1330
+ else if (score >= 70) grade = "C";
1331
+ else if (score >= 60) grade = "D";
1332
+ else grade = "F";
1333
+
1334
+ const suggestions = [...new Set(
1335
+ issues.map((issue) => issue.suggestion).filter(Boolean)
1336
+ )];
1337
+
1338
+ const breakdown = {
1339
+ schema: {
1340
+ score: schemaScore,
1341
+ weight: Math.round(schemaWeight * 100),
1342
+ failed: schemaErrors,
1343
+ passed: flowCount - schemaErrors,
1344
+ },
1345
+ graph: {
1346
+ score: graphScore,
1347
+ weight: Math.round(graphWeight * 100),
1348
+ checks_passed: graphPassed,
1349
+ checks_total: graphChecksTotal,
1350
+ },
1351
+ quality: {
1352
+ score: qualityScore,
1353
+ weight: Math.round(qualityWeight * 100),
1354
+ issues: qualityIssueCount,
1355
+ },
1356
+ };
1357
+
1358
+ if (semantic) {
1359
+ breakdown.semantic = {
1360
+ score: semanticScore,
1361
+ weight: Math.round(semanticWeight * 100),
1362
+ issues: semanticIssueCount,
1363
+ };
1364
+ }
1365
+
1366
+ return {
1367
+ generated_at: new Date().toISOString(),
1368
+ path: result.path,
1369
+ profile: result.profile,
1370
+ score,
1371
+ grade,
1372
+ flow_count: flowCount,
1373
+ ok: result.ok,
1374
+ issues: issues.filter((issue) => semantic || issue.category !== "quality" || !issue.code.startsWith("XFLOW_W20")),
1375
+ suggestions,
1376
+ breakdown,
1377
+ };
1378
+ }
1379
+
1006
1380
  // Allow the module to be required by tests without side-effects.
1007
1381
  if (require.main === module) {
1008
1382
  const result = run(process.argv[2]);
@@ -1018,5 +1392,8 @@ module.exports = {
1018
1392
  detectDuplicateTransitions,
1019
1393
  detectInvalidOperationReferences,
1020
1394
  detectTerminalCoverage,
1395
+ detectSemanticModelingWarnings,
1396
+ buildStructuredIssues,
1397
+ computeQualityReport,
1021
1398
  run,
1022
1399
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "x-openapi-flow",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "OpenAPI extension for resource workflow and lifecycle management",
5
5
  "main": "lib/validator.js",
6
6
  "repository": {
@@ -33,10 +33,12 @@
33
33
  "scripts": {
34
34
  "sync:readme": "node ../scripts/sync-package-readme.js",
35
35
  "prepack": "npm run sync:readme",
36
- "test": "npm run test:cli && npm run test:ui && npm run test:integration && npm run test:smoke",
36
+ "test": "npm run test:cli && npm run test:ui && npm run test:integration && npm run test:runtime && npm run test:engine && npm run test:smoke",
37
37
  "test:cli": "node --test tests/cli/cli.test.js",
38
38
  "test:ui": "node --test tests/plugins/*.test.js",
39
39
  "test:integration": "node --test tests/integration/*.test.js",
40
+ "test:runtime": "node --test tests/runtime/*.test.js",
41
+ "test:engine": "node --test tests/runtime/state-machine-engine.test.js tests/runtime/openapi-state-machine-adapter.test.js",
40
42
  "test:examples": "node --test tests/integration/examples.test.js",
41
43
  "test:smoke": "node bin/x-openapi-flow.js validate examples/payment-api.yaml --profile strict"
42
44
  },