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