@compilr-dev/sdk 0.10.14 → 0.10.15
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.
|
@@ -184,7 +184,7 @@ export interface InteractiveFlowResult {
|
|
|
184
184
|
/** UI handler — the consumer provides this; the SDK calls it once validation passes */
|
|
185
185
|
export type InteractiveFlowHandler = (input: InteractiveFlowInput) => Promise<InteractiveFlowResult>;
|
|
186
186
|
/** Error codes — see spec §4 */
|
|
187
|
-
export type FlowErrorCode = 'MISSING_START_NODE' | 'DUPLICATE_NODE_ID' | 'UNRESOLVED_NEXT' | 'UNRESOLVED_BRANCH' | 'INVALID_ICON' | 'INVALID_RENDERER' | 'INVALID_NODE_TYPE' | 'INVALID_CONDITION';
|
|
187
|
+
export type FlowErrorCode = 'MISSING_START_NODE' | 'DUPLICATE_NODE_ID' | 'UNRESOLVED_NEXT' | 'UNRESOLVED_BRANCH' | 'INVALID_ICON' | 'INVALID_RENDERER' | 'INVALID_NODE_TYPE' | 'INVALID_CONDITION' | 'CYCLE_DETECTED';
|
|
188
188
|
export interface FlowValidationError {
|
|
189
189
|
code: FlowErrorCode;
|
|
190
190
|
message: string;
|
|
@@ -108,7 +108,20 @@ export function validateFlow(flowInput) {
|
|
|
108
108
|
continue; // already flagged above
|
|
109
109
|
validateNode(node, flow.nodes, errors);
|
|
110
110
|
}
|
|
111
|
-
// 4.
|
|
111
|
+
// 4. Cycle detection — any cycle is an error, not a warning. A cycle means
|
|
112
|
+
// the user can be trapped in an infinite loop ("Yes → q1 → Yes → q1 ..."),
|
|
113
|
+
// which is fundamentally broken UX even though the user CAN escape via Cancel.
|
|
114
|
+
if (errors.length === 0) {
|
|
115
|
+
const cycles = detectCycles(flow);
|
|
116
|
+
for (const cycle of cycles) {
|
|
117
|
+
errors.push({
|
|
118
|
+
code: 'CYCLE_DETECTED',
|
|
119
|
+
message: `Cycle detected: ${cycle.join(' → ')}. Flows must be acyclic — a user navigating the cycle would loop forever (with Cancel as the only escape). If you need a "retry" pattern, model it with explicit nodes that ask "Try again?" before re-entering.`,
|
|
120
|
+
nodeId: cycle[0],
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
// 5. Reachability — warn on orphans (only when no errors)
|
|
112
125
|
if (errors.length === 0 && flow.startNode in flow.nodes) {
|
|
113
126
|
const reachable = computeReachable(flow);
|
|
114
127
|
for (const id of nodeIds) {
|
|
@@ -437,6 +450,120 @@ function checkIcon(icon, nodeId, errors) {
|
|
|
437
450
|
});
|
|
438
451
|
}
|
|
439
452
|
}
|
|
453
|
+
/**
|
|
454
|
+
* Detect cycles in the flow graph. Returns an array of cycles, where each
|
|
455
|
+
* cycle is an array of node IDs representing the loop (first node repeated
|
|
456
|
+
* implicitly — `[a, b, c]` means `a → b → c → a`).
|
|
457
|
+
*
|
|
458
|
+
* Uses iterative DFS with explicit recursion-stack tracking ("color marking"):
|
|
459
|
+
* - WHITE = not visited yet
|
|
460
|
+
* - GRAY = currently in the recursion stack (on the active path)
|
|
461
|
+
* - BLACK = fully explored
|
|
462
|
+
* If we encounter a GRAY node, that's a back-edge → cycle.
|
|
463
|
+
*
|
|
464
|
+
* Walks every outgoing edge (next, byAnswer, branch routes, branch default)
|
|
465
|
+
* so cycles through any path are caught.
|
|
466
|
+
*/
|
|
467
|
+
function detectCycles(flow) {
|
|
468
|
+
const cycles = [];
|
|
469
|
+
const seenCycles = new Set(); // dedup by sorted member set
|
|
470
|
+
const color = new Map();
|
|
471
|
+
const allIds = Object.keys(flow.nodes);
|
|
472
|
+
function getOutgoing(node) {
|
|
473
|
+
if (node.type === 'summary')
|
|
474
|
+
return [];
|
|
475
|
+
if (node.type === 'branch') {
|
|
476
|
+
const t = [];
|
|
477
|
+
const routes = node.routes;
|
|
478
|
+
if (Array.isArray(routes)) {
|
|
479
|
+
for (const r of routes) {
|
|
480
|
+
if (r && typeof r.goto === 'string')
|
|
481
|
+
t.push(r.goto);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (typeof node.default === 'string')
|
|
485
|
+
t.push(node.default);
|
|
486
|
+
return t;
|
|
487
|
+
}
|
|
488
|
+
// question | info — extract from next
|
|
489
|
+
const next = node.next;
|
|
490
|
+
if (typeof next === 'string')
|
|
491
|
+
return [next];
|
|
492
|
+
if (!next || typeof next !== 'object')
|
|
493
|
+
return [];
|
|
494
|
+
const t = [];
|
|
495
|
+
if (next.byAnswer && typeof next.byAnswer === 'object') {
|
|
496
|
+
for (const v of Object.values(next.byAnswer)) {
|
|
497
|
+
if (typeof v === 'string')
|
|
498
|
+
t.push(v);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
if (typeof next.default === 'string')
|
|
502
|
+
t.push(next.default);
|
|
503
|
+
return t;
|
|
504
|
+
}
|
|
505
|
+
function recordCycle(path, backEdgeTarget) {
|
|
506
|
+
const startIdx = path.indexOf(backEdgeTarget);
|
|
507
|
+
if (startIdx === -1)
|
|
508
|
+
return;
|
|
509
|
+
const cycle = path.slice(startIdx);
|
|
510
|
+
// Dedup by canonicalizing — rotate to smallest member first, then stringify
|
|
511
|
+
const canonical = canonicalCycle(cycle);
|
|
512
|
+
if (seenCycles.has(canonical))
|
|
513
|
+
return;
|
|
514
|
+
seenCycles.add(canonical);
|
|
515
|
+
cycles.push(cycle);
|
|
516
|
+
}
|
|
517
|
+
for (const startId of allIds) {
|
|
518
|
+
if (color.get(startId))
|
|
519
|
+
continue;
|
|
520
|
+
const stack = [
|
|
521
|
+
{ nodeId: startId, targets: getOutgoing(flow.nodes[startId]), cursor: 0 },
|
|
522
|
+
];
|
|
523
|
+
color.set(startId, 'gray');
|
|
524
|
+
while (stack.length > 0) {
|
|
525
|
+
const frame = stack[stack.length - 1];
|
|
526
|
+
if (frame.cursor >= frame.targets.length) {
|
|
527
|
+
// Done with this node — mark black, pop
|
|
528
|
+
color.set(frame.nodeId, 'black');
|
|
529
|
+
stack.pop();
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const target = frame.targets[frame.cursor++];
|
|
533
|
+
if (!(target in flow.nodes))
|
|
534
|
+
continue; // missing node (UNRESOLVED_NEXT already flagged)
|
|
535
|
+
const status = color.get(target);
|
|
536
|
+
if (status === 'gray') {
|
|
537
|
+
// Back-edge → cycle
|
|
538
|
+
const activePath = stack.map((f) => f.nodeId);
|
|
539
|
+
recordCycle(activePath, target);
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
if (status === 'black')
|
|
543
|
+
continue; // already fully explored
|
|
544
|
+
// WHITE — recurse
|
|
545
|
+
color.set(target, 'gray');
|
|
546
|
+
stack.push({ nodeId: target, targets: getOutgoing(flow.nodes[target]), cursor: 0 });
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
return cycles;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* Canonical-form a cycle so `[a,b,c]`, `[b,c,a]`, `[c,a,b]` all collapse to
|
|
553
|
+
* the same string. Used for dedup.
|
|
554
|
+
*/
|
|
555
|
+
function canonicalCycle(cycle) {
|
|
556
|
+
if (cycle.length === 0)
|
|
557
|
+
return '';
|
|
558
|
+
// Find rotation starting with lexicographically-smallest node
|
|
559
|
+
let minIdx = 0;
|
|
560
|
+
for (let i = 1; i < cycle.length; i++) {
|
|
561
|
+
if (cycle[i] < cycle[minIdx])
|
|
562
|
+
minIdx = i;
|
|
563
|
+
}
|
|
564
|
+
const rotated = [...cycle.slice(minIdx), ...cycle.slice(0, minIdx)];
|
|
565
|
+
return rotated.join('→');
|
|
566
|
+
}
|
|
440
567
|
/** Compute set of node IDs reachable from startNode via any path */
|
|
441
568
|
function computeReachable(flow) {
|
|
442
569
|
const reachable = new Set();
|