@synergenius/flow-weaver 0.20.4 → 0.20.6
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/dist/api/validate.js +2 -0
- package/dist/cli/flow-weaver.mjs +396 -23
- package/dist/cli/templates/workflows/ai-agent.js +4 -1
- package/dist/cli/templates/workflows/ai-rag.js +1 -1
- package/dist/cli/templates/workflows/ai-react.js +3 -1
- package/dist/cli/templates/workflows/foreach.js +1 -0
- package/dist/doc-metadata/extractors/error-codes.d.ts +1 -1
- package/dist/doc-metadata/extractors/error-codes.js +50 -0
- package/dist/friendly-errors.js +65 -0
- package/dist/generated-version.d.ts +1 -1
- package/dist/generated-version.js +1 -1
- package/dist/generator/code-utils.js +24 -7
- package/dist/mcp/response-utils.js +8 -0
- package/dist/validation/design-rules.d.ts +61 -0
- package/dist/validation/design-rules.js +377 -0
- package/docs/reference/error-codes.md +74 -0
- package/package.json +5 -1
|
@@ -318,5 +318,55 @@ export const VALIDATION_CODES = [
|
|
|
318
318
|
description: 'Tool executor data outputs all unconnected',
|
|
319
319
|
category: 'agent',
|
|
320
320
|
},
|
|
321
|
+
// ── Design ──────────────────────────────────────────────────────────
|
|
322
|
+
{
|
|
323
|
+
code: 'DESIGN_ASYNC_NO_ERROR_PATH',
|
|
324
|
+
severity: 'warning',
|
|
325
|
+
title: 'Async Node Missing Error Path',
|
|
326
|
+
description: 'Async node has no onFailure connection',
|
|
327
|
+
category: 'design',
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
code: 'DESIGN_SCOPE_NO_FAILURE_EXIT',
|
|
331
|
+
severity: 'warning',
|
|
332
|
+
title: 'Scope Missing Failure Exit',
|
|
333
|
+
description: 'Scope node has no failure path out',
|
|
334
|
+
category: 'design',
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
code: 'DESIGN_UNBOUNDED_RETRY',
|
|
338
|
+
severity: 'warning',
|
|
339
|
+
title: 'Unbounded Retry Loop',
|
|
340
|
+
description: 'Retry scope has no visible attempt limit',
|
|
341
|
+
category: 'design',
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
code: 'DESIGN_FANOUT_NO_FANIN',
|
|
345
|
+
severity: 'warning',
|
|
346
|
+
title: 'Fan-Out Without Fan-In',
|
|
347
|
+
description: 'Fan-out to multiple step targets with no merge back',
|
|
348
|
+
category: 'design',
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
code: 'DESIGN_EXIT_DATA_UNREACHABLE',
|
|
352
|
+
severity: 'warning',
|
|
353
|
+
title: 'Exit Data Unreachable',
|
|
354
|
+
description: 'Exit data port has no connection and no pull-execution provider',
|
|
355
|
+
category: 'design',
|
|
356
|
+
},
|
|
357
|
+
{
|
|
358
|
+
code: 'DESIGN_PULL_CANDIDATE',
|
|
359
|
+
severity: 'warning',
|
|
360
|
+
title: 'Pull Execution Candidate',
|
|
361
|
+
description: 'Node has no step trigger but its outputs are consumed downstream',
|
|
362
|
+
category: 'design',
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
code: 'DESIGN_PULL_UNUSED',
|
|
366
|
+
severity: 'warning',
|
|
367
|
+
title: 'Unused Pull Execution',
|
|
368
|
+
description: 'Pull-execution node has no downstream consumers',
|
|
369
|
+
category: 'design',
|
|
370
|
+
},
|
|
321
371
|
];
|
|
322
372
|
//# sourceMappingURL=error-codes.js.map
|
package/dist/friendly-errors.js
CHANGED
|
@@ -505,6 +505,71 @@ const errorMappers = {
|
|
|
505
505
|
code: error.code,
|
|
506
506
|
};
|
|
507
507
|
},
|
|
508
|
+
// ── Design quality rules ─────────────────────────────────────────────
|
|
509
|
+
DESIGN_ASYNC_NO_ERROR_PATH(error) {
|
|
510
|
+
const nodeName = error.node || 'unknown';
|
|
511
|
+
return {
|
|
512
|
+
title: 'Async Node Missing Error Path',
|
|
513
|
+
explanation: `Async node '${nodeName}' has no onFailure connection. Async operations (network calls, file I/O, AI calls) can fail, and errors will be silently lost.`,
|
|
514
|
+
fix: `Connect ${nodeName}.onFailure to an error handler, retry node, or Exit.onFailure.`,
|
|
515
|
+
code: error.code,
|
|
516
|
+
};
|
|
517
|
+
},
|
|
518
|
+
DESIGN_SCOPE_NO_FAILURE_EXIT(error) {
|
|
519
|
+
const nodeName = error.node || 'unknown';
|
|
520
|
+
return {
|
|
521
|
+
title: 'Scope Missing Failure Exit',
|
|
522
|
+
explanation: `Scope node '${nodeName}' has no failure path out. If all iterations fail, execution stalls with no error surfaced upstream.`,
|
|
523
|
+
fix: `Connect ${nodeName}.onFailure to an error handler or Exit.onFailure so scope failures propagate.`,
|
|
524
|
+
code: error.code,
|
|
525
|
+
};
|
|
526
|
+
},
|
|
527
|
+
DESIGN_UNBOUNDED_RETRY(error) {
|
|
528
|
+
const nodeName = error.node || 'unknown';
|
|
529
|
+
return {
|
|
530
|
+
title: 'Unbounded Retry Loop',
|
|
531
|
+
explanation: `Scope node '${nodeName}' looks like a retry loop but has no visible attempt limit input. This could loop indefinitely on persistent failures.`,
|
|
532
|
+
fix: `Add a maxAttempts or retries input port to the node type, or use a counter to break out of the loop after N attempts.`,
|
|
533
|
+
code: error.code,
|
|
534
|
+
};
|
|
535
|
+
},
|
|
536
|
+
DESIGN_FANOUT_NO_FANIN(error) {
|
|
537
|
+
const nodeName = error.node || 'unknown';
|
|
538
|
+
return {
|
|
539
|
+
title: 'Fan-Out Without Fan-In',
|
|
540
|
+
explanation: `Node '${nodeName}' fans out to multiple step targets, but those paths never merge back to a shared downstream node. Data from parallel branches may be lost.`,
|
|
541
|
+
fix: `Add a merge node downstream where the parallel branches converge, or wire them to a shared node before Exit.`,
|
|
542
|
+
code: error.code,
|
|
543
|
+
};
|
|
544
|
+
},
|
|
545
|
+
DESIGN_EXIT_DATA_UNREACHABLE(error) {
|
|
546
|
+
const quoted = extractQuoted(error.message);
|
|
547
|
+
const portName = quoted[0] || 'unknown';
|
|
548
|
+
return {
|
|
549
|
+
title: 'Exit Data Unreachable',
|
|
550
|
+
explanation: `Exit port '${portName}' has no incoming data connection and no pull-execution node provides it. The workflow will return undefined for this output.`,
|
|
551
|
+
fix: `Connect a node's data output to Exit.${portName}, or add a pull-execution node that computes this value on demand.`,
|
|
552
|
+
code: error.code,
|
|
553
|
+
};
|
|
554
|
+
},
|
|
555
|
+
DESIGN_PULL_CANDIDATE(error) {
|
|
556
|
+
const nodeName = error.node || 'unknown';
|
|
557
|
+
return {
|
|
558
|
+
title: 'Pull Execution Candidate',
|
|
559
|
+
explanation: `Node '${nodeName}' has no incoming step connection but its data outputs are consumed downstream. Without a step trigger or pullExecution, this node may never execute.`,
|
|
560
|
+
fix: `Add [pullExecution: execute] to the @node annotation so the node runs on demand when downstream nodes read its output.`,
|
|
561
|
+
code: error.code,
|
|
562
|
+
};
|
|
563
|
+
},
|
|
564
|
+
DESIGN_PULL_UNUSED(error) {
|
|
565
|
+
const nodeName = error.node || 'unknown';
|
|
566
|
+
return {
|
|
567
|
+
title: 'Unused Pull Execution',
|
|
568
|
+
explanation: `Node '${nodeName}' is marked with pullExecution but no downstream node reads its data output. The node will never execute since pull execution requires a consumer.`,
|
|
569
|
+
fix: `Connect a data output from '${nodeName}' to a downstream node, or remove the pullExecution config if the node isn't needed.`,
|
|
570
|
+
code: error.code,
|
|
571
|
+
};
|
|
572
|
+
},
|
|
508
573
|
COERCE_TYPE_MISMATCH(error) {
|
|
509
574
|
const coerceMatch = error.message.match(/`as (\w+)`/);
|
|
510
575
|
const coerceType = coerceMatch?.[1] || 'unknown';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
export declare const VERSION = "0.20.
|
|
1
|
+
export declare const VERSION = "0.20.6";
|
|
2
2
|
//# sourceMappingURL=generated-version.d.ts.map
|
|
@@ -255,23 +255,40 @@ export function buildNodeArgumentsWithContext(opts) {
|
|
|
255
255
|
const isConstSource = isStartNode(sourceNode) || sourceNode === instanceParent;
|
|
256
256
|
const nonNullAssert = isConstSource ? '' : '!';
|
|
257
257
|
const portType = mapToTypeScript(portConfig.dataType, portConfig.tsType);
|
|
258
|
+
// For optional ports on non-const sources, guard against undefined execution index.
|
|
259
|
+
// This is critical for DISJUNCTION nodes where the source may not have executed.
|
|
260
|
+
const needsGuard = portConfig.optional && !isConstSource;
|
|
258
261
|
// For FUNCTION type ports, add resolution step to handle registry IDs
|
|
259
262
|
if (portConfig.dataType === 'FUNCTION') {
|
|
260
263
|
const rawVarName = `${varName}_raw`;
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
+
if (needsGuard) {
|
|
265
|
+
lines.push(`${indent}const ${rawVarName} = ${sourceIdx} !== undefined ? ${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx} }) : undefined;`);
|
|
266
|
+
lines.push(`${indent}const ${varName}_resolved = ${rawVarName} !== undefined ? resolveFunction(${rawVarName}) : undefined;`);
|
|
267
|
+
lines.push(`${indent}const ${varName} = ${varName}_resolved?.fn as ${portType};`);
|
|
268
|
+
}
|
|
269
|
+
else {
|
|
270
|
+
lines.push(`${indent}const ${rawVarName} = ${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx}${nonNullAssert} });`);
|
|
271
|
+
lines.push(`${indent}const ${varName}_resolved = resolveFunction(${rawVarName});`);
|
|
272
|
+
lines.push(`${indent}const ${varName} = ${varName}_resolved.fn as ${portType};`);
|
|
273
|
+
}
|
|
264
274
|
}
|
|
265
275
|
else {
|
|
266
276
|
// Check for coercion (explicit or auto)
|
|
267
277
|
const sourceDataType = resolveSourcePortDataType(workflow, sourceNode, sourcePort);
|
|
268
278
|
const coerceExpr = getCoercionWrapper(connection, sourceDataType, portConfig.dataType);
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
279
|
+
if (needsGuard) {
|
|
280
|
+
const getExpr = `${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx} })`;
|
|
281
|
+
const wrappedExpr = coerceExpr ? `${coerceExpr}(${getExpr})` : getExpr;
|
|
282
|
+
lines.push(`${indent}const ${varName} = ${sourceIdx} !== undefined ? ${wrappedExpr} as ${portType} : undefined;`);
|
|
272
283
|
}
|
|
273
284
|
else {
|
|
274
|
-
|
|
285
|
+
const getExpr = `${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx}${nonNullAssert} })`;
|
|
286
|
+
if (coerceExpr) {
|
|
287
|
+
lines.push(`${indent}const ${varName} = ${coerceExpr}(${getExpr}) as ${portType};`);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
lines.push(`${indent}const ${varName} = ${getExpr} as ${portType};`);
|
|
291
|
+
}
|
|
275
292
|
}
|
|
276
293
|
}
|
|
277
294
|
}
|
|
@@ -55,6 +55,14 @@ export const ERROR_HINTS = {
|
|
|
55
55
|
AGENT_MISSING_MEMORY_IN_LOOP: 'Add a conversation-memory node inside the loop scope. Use fw_scaffold(template="conversation-memory") to create one',
|
|
56
56
|
AGENT_LLM_NO_FALLBACK: 'Add a retry or fallback node between the LLM onFailure and Exit. Consider a second LLM provider as fallback',
|
|
57
57
|
AGENT_TOOL_NO_OUTPUT_HANDLING: 'Use fw_modify(operation="addConnection") to wire tool output ports to downstream nodes',
|
|
58
|
+
// Design quality rules
|
|
59
|
+
DESIGN_ASYNC_NO_ERROR_PATH: 'Use fw_modify(operation="addConnection", params={from:"<nodeId>.onFailure", to:"Exit.onFailure"}) or add a retry/error handler',
|
|
60
|
+
DESIGN_SCOPE_NO_FAILURE_EXIT: 'Use fw_modify(operation="addConnection", params={from:"<nodeId>.onFailure", to:"Exit.onFailure"}) to surface scope failures',
|
|
61
|
+
DESIGN_UNBOUNDED_RETRY: 'Add a maxAttempts or retries input port to the retry node type to bound the loop',
|
|
62
|
+
DESIGN_FANOUT_NO_FANIN: 'Add a merge node downstream where parallel branches converge before Exit',
|
|
63
|
+
DESIGN_EXIT_DATA_UNREACHABLE: 'Use fw_modify(operation="addConnection") to connect a node output to this Exit port, or add a pull-execution node',
|
|
64
|
+
DESIGN_PULL_CANDIDATE: 'Add [pullExecution: execute] to the @node annotation so the node runs on demand',
|
|
65
|
+
DESIGN_PULL_UNUSED: 'Connect a data output from this node to a downstream consumer, or remove pullExecution',
|
|
58
66
|
};
|
|
59
67
|
/**
|
|
60
68
|
* Enriches validation items with actionable hints from {@link ERROR_HINTS} and optional
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design Quality Validation Rules
|
|
3
|
+
*
|
|
4
|
+
* Deterministic, AST-inspectable rules that catch common workflow design problems.
|
|
5
|
+
* A workflow can compile fine and still be poorly designed: missing error paths,
|
|
6
|
+
* unbounded retries, fan-out without fan-in, dead-end branches.
|
|
7
|
+
*
|
|
8
|
+
* All rules are warnings or info. None block compilation.
|
|
9
|
+
* Users can suppress any with @suppress.
|
|
10
|
+
*
|
|
11
|
+
* Rules:
|
|
12
|
+
* 1. DESIGN_ASYNC_NO_ERROR_PATH - Async node has no onFailure connection
|
|
13
|
+
* 2. DESIGN_SCOPE_NO_FAILURE_EXIT - Scope has no failure path out
|
|
14
|
+
* 3. DESIGN_UNBOUNDED_RETRY - Retry scope has no visible attempt limit
|
|
15
|
+
* 4. DESIGN_FANOUT_NO_FANIN - Fan-out to step targets with no merge back
|
|
16
|
+
* 5. DESIGN_EXIT_DATA_UNREACHABLE - Exit data port has no connection and no pull-execution provider
|
|
17
|
+
* 6. DESIGN_PULL_CANDIDATE - Node with no incoming step but consumed data outputs
|
|
18
|
+
* 7. DESIGN_PULL_UNUSED - Node marked pullExecution but no downstream reads its output
|
|
19
|
+
*/
|
|
20
|
+
import type { TValidationRule } from '../ast/types.js';
|
|
21
|
+
/**
|
|
22
|
+
* Async nodes (network, disk, AI) can fail. If onFailure is unconnected,
|
|
23
|
+
* failures may be silently swallowed or crash the workflow.
|
|
24
|
+
*/
|
|
25
|
+
export declare const asyncNoErrorPathRule: TValidationRule;
|
|
26
|
+
/**
|
|
27
|
+
* A scope (retry/forEach) with no failure path out means if every iteration
|
|
28
|
+
* fails, execution stalls with no way to surface the error.
|
|
29
|
+
*/
|
|
30
|
+
export declare const scopeNoFailureExitRule: TValidationRule;
|
|
31
|
+
/**
|
|
32
|
+
* A scope used as a retry loop without a visible attempt limit could loop
|
|
33
|
+
* indefinitely. Check if the parent node type name/function suggests retry
|
|
34
|
+
* semantics but lacks a maxAttempts-like input or config.
|
|
35
|
+
*/
|
|
36
|
+
export declare const unboundedRetryRule: TValidationRule;
|
|
37
|
+
/**
|
|
38
|
+
* A node that fans out via step connections to multiple targets, but none of
|
|
39
|
+
* those paths merge back to a shared downstream node. Data from parallel
|
|
40
|
+
* branches may be lost.
|
|
41
|
+
*/
|
|
42
|
+
export declare const fanoutNoFaninRule: TValidationRule;
|
|
43
|
+
/**
|
|
44
|
+
* Extends UNREACHABLE_EXIT_PORT to consider pull execution. An exit data port
|
|
45
|
+
* is fine if a pull-executed node is wired to provide it, even without a
|
|
46
|
+
* step path.
|
|
47
|
+
*/
|
|
48
|
+
export declare const exitDataUnreachableRule: TValidationRule;
|
|
49
|
+
/**
|
|
50
|
+
* A node with no incoming step connections but data outputs consumed downstream
|
|
51
|
+
* is a candidate for pullExecution. Without it, the node may never execute.
|
|
52
|
+
*/
|
|
53
|
+
export declare const pullCandidateRule: TValidationRule;
|
|
54
|
+
/**
|
|
55
|
+
* A node marked with pullExecution but no downstream node reads its data output.
|
|
56
|
+
* The node will never execute since pull execution requires a consumer.
|
|
57
|
+
*/
|
|
58
|
+
export declare const pullUnusedRule: TValidationRule;
|
|
59
|
+
export declare const designValidationRules: TValidationRule[];
|
|
60
|
+
export declare function getDesignValidationRules(): TValidationRule[];
|
|
61
|
+
//# sourceMappingURL=design-rules.d.ts.map
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design Quality Validation Rules
|
|
3
|
+
*
|
|
4
|
+
* Deterministic, AST-inspectable rules that catch common workflow design problems.
|
|
5
|
+
* A workflow can compile fine and still be poorly designed: missing error paths,
|
|
6
|
+
* unbounded retries, fan-out without fan-in, dead-end branches.
|
|
7
|
+
*
|
|
8
|
+
* All rules are warnings or info. None block compilation.
|
|
9
|
+
* Users can suppress any with @suppress.
|
|
10
|
+
*
|
|
11
|
+
* Rules:
|
|
12
|
+
* 1. DESIGN_ASYNC_NO_ERROR_PATH - Async node has no onFailure connection
|
|
13
|
+
* 2. DESIGN_SCOPE_NO_FAILURE_EXIT - Scope has no failure path out
|
|
14
|
+
* 3. DESIGN_UNBOUNDED_RETRY - Retry scope has no visible attempt limit
|
|
15
|
+
* 4. DESIGN_FANOUT_NO_FANIN - Fan-out to step targets with no merge back
|
|
16
|
+
* 5. DESIGN_EXIT_DATA_UNREACHABLE - Exit data port has no connection and no pull-execution provider
|
|
17
|
+
* 6. DESIGN_PULL_CANDIDATE - Node with no incoming step but consumed data outputs
|
|
18
|
+
* 7. DESIGN_PULL_UNUSED - Node marked pullExecution but no downstream reads its output
|
|
19
|
+
*/
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Helpers
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
function resolveNodeType(ast, instance) {
|
|
24
|
+
return ast.nodeTypes.find((nt) => nt.name === instance.nodeType || nt.functionName === instance.nodeType);
|
|
25
|
+
}
|
|
26
|
+
function getOutgoing(ast, nodeId, portName) {
|
|
27
|
+
return ast.connections.filter((c) => {
|
|
28
|
+
if (c.from.node !== nodeId)
|
|
29
|
+
return false;
|
|
30
|
+
if (portName && c.from.port !== portName)
|
|
31
|
+
return false;
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function getIncoming(ast, nodeId, portName) {
|
|
36
|
+
return ast.connections.filter((c) => {
|
|
37
|
+
if (c.to.node !== nodeId)
|
|
38
|
+
return false;
|
|
39
|
+
if (portName && c.to.port !== portName)
|
|
40
|
+
return false;
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
const STEP_PORTS = new Set(['execute', 'onSuccess', 'onFailure', 'start', 'success', 'failure']);
|
|
45
|
+
function isStepPort(portName, portDef) {
|
|
46
|
+
if (portDef?.dataType === 'STEP')
|
|
47
|
+
return true;
|
|
48
|
+
return STEP_PORTS.has(portName);
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Rule 1: Async Node Missing Error Path
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* Async nodes (network, disk, AI) can fail. If onFailure is unconnected,
|
|
55
|
+
* failures may be silently swallowed or crash the workflow.
|
|
56
|
+
*/
|
|
57
|
+
export const asyncNoErrorPathRule = {
|
|
58
|
+
name: 'DESIGN_ASYNC_NO_ERROR_PATH',
|
|
59
|
+
validate(ast) {
|
|
60
|
+
const errors = [];
|
|
61
|
+
for (const instance of ast.instances) {
|
|
62
|
+
const nt = resolveNodeType(ast, instance);
|
|
63
|
+
if (!nt)
|
|
64
|
+
continue;
|
|
65
|
+
if (!nt.isAsync)
|
|
66
|
+
continue;
|
|
67
|
+
if (!nt.hasFailurePort)
|
|
68
|
+
continue;
|
|
69
|
+
const failureConns = getOutgoing(ast, instance.id, 'onFailure');
|
|
70
|
+
if (failureConns.length === 0) {
|
|
71
|
+
errors.push({
|
|
72
|
+
type: 'warning',
|
|
73
|
+
code: 'DESIGN_ASYNC_NO_ERROR_PATH',
|
|
74
|
+
message: `Async node '${instance.id}' has no onFailure connection. Async operations (network, disk, AI) can fail, and errors will be silently lost.`,
|
|
75
|
+
node: instance.id,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return errors;
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Rule 2: Scope With No Failure Exit
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
/**
|
|
86
|
+
* A scope (retry/forEach) with no failure path out means if every iteration
|
|
87
|
+
* fails, execution stalls with no way to surface the error.
|
|
88
|
+
*/
|
|
89
|
+
export const scopeNoFailureExitRule = {
|
|
90
|
+
name: 'DESIGN_SCOPE_NO_FAILURE_EXIT',
|
|
91
|
+
validate(ast) {
|
|
92
|
+
const errors = [];
|
|
93
|
+
for (const instance of ast.instances) {
|
|
94
|
+
const nt = resolveNodeType(ast, instance);
|
|
95
|
+
if (!nt)
|
|
96
|
+
continue;
|
|
97
|
+
// Only check nodes that define scopes
|
|
98
|
+
const scopeNames = nt.scopes ?? (nt.scope ? [nt.scope] : []);
|
|
99
|
+
if (scopeNames.length === 0)
|
|
100
|
+
continue;
|
|
101
|
+
// A scope parent needs an onFailure or failure port connected
|
|
102
|
+
if (!nt.hasFailurePort)
|
|
103
|
+
continue;
|
|
104
|
+
const failureConns = getOutgoing(ast, instance.id, 'onFailure');
|
|
105
|
+
// Also check 'failure' port used in some scope patterns
|
|
106
|
+
const failureConns2 = getOutgoing(ast, instance.id, 'failure');
|
|
107
|
+
if (failureConns.length === 0 && failureConns2.length === 0) {
|
|
108
|
+
errors.push({
|
|
109
|
+
type: 'warning',
|
|
110
|
+
code: 'DESIGN_SCOPE_NO_FAILURE_EXIT',
|
|
111
|
+
message: `Scope node '${instance.id}' has no failure path out. If all iterations fail, execution stalls with no error surfaced.`,
|
|
112
|
+
node: instance.id,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return errors;
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Rule 3: Unbounded Retry
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
/**
|
|
123
|
+
* A scope used as a retry loop without a visible attempt limit could loop
|
|
124
|
+
* indefinitely. Check if the parent node type name/function suggests retry
|
|
125
|
+
* semantics but lacks a maxAttempts-like input or config.
|
|
126
|
+
*/
|
|
127
|
+
export const unboundedRetryRule = {
|
|
128
|
+
name: 'DESIGN_UNBOUNDED_RETRY',
|
|
129
|
+
validate(ast) {
|
|
130
|
+
const errors = [];
|
|
131
|
+
const retryPatterns = /retry|repeat|loop|poll|backoff/i;
|
|
132
|
+
for (const instance of ast.instances) {
|
|
133
|
+
const nt = resolveNodeType(ast, instance);
|
|
134
|
+
if (!nt)
|
|
135
|
+
continue;
|
|
136
|
+
const scopeNames = nt.scopes ?? (nt.scope ? [nt.scope] : []);
|
|
137
|
+
if (scopeNames.length === 0)
|
|
138
|
+
continue;
|
|
139
|
+
// Only flag nodes that look like retry patterns
|
|
140
|
+
const nameHint = `${nt.name} ${nt.functionName} ${nt.label ?? ''}`;
|
|
141
|
+
if (!retryPatterns.test(nameHint))
|
|
142
|
+
continue;
|
|
143
|
+
// Check for a maxAttempts/retries/limit input port
|
|
144
|
+
const limitInputs = Object.keys(nt.inputs).filter((p) => /max|limit|attempts|retries|count/i.test(p));
|
|
145
|
+
if (limitInputs.length === 0) {
|
|
146
|
+
errors.push({
|
|
147
|
+
type: 'warning',
|
|
148
|
+
code: 'DESIGN_UNBOUNDED_RETRY',
|
|
149
|
+
message: `Scope node '${instance.id}' appears to be a retry loop but has no visible attempt limit input. This could loop indefinitely.`,
|
|
150
|
+
node: instance.id,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return errors;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// Rule 4: Fan-Out Without Fan-In
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
/**
|
|
161
|
+
* A node that fans out via step connections to multiple targets, but none of
|
|
162
|
+
* those paths merge back to a shared downstream node. Data from parallel
|
|
163
|
+
* branches may be lost.
|
|
164
|
+
*/
|
|
165
|
+
export const fanoutNoFaninRule = {
|
|
166
|
+
name: 'DESIGN_FANOUT_NO_FANIN',
|
|
167
|
+
validate(ast) {
|
|
168
|
+
const errors = [];
|
|
169
|
+
for (const instance of ast.instances) {
|
|
170
|
+
const nt = resolveNodeType(ast, instance);
|
|
171
|
+
// Find step output connections (excluding data ports)
|
|
172
|
+
const stepOutConns = getOutgoing(ast, instance.id).filter((c) => {
|
|
173
|
+
if (nt) {
|
|
174
|
+
const portDef = nt.outputs[c.from.port];
|
|
175
|
+
return isStepPort(c.from.port, portDef);
|
|
176
|
+
}
|
|
177
|
+
return isStepPort(c.from.port);
|
|
178
|
+
});
|
|
179
|
+
// Get unique step targets (excluding Exit, which is a natural terminus)
|
|
180
|
+
const stepTargets = [...new Set(stepOutConns.map((c) => c.to.node))].filter((n) => n !== 'Exit');
|
|
181
|
+
// Need at least 3 targets to be a meaningful fan-out (2 is just success/failure branching)
|
|
182
|
+
if (stepTargets.length < 3)
|
|
183
|
+
continue;
|
|
184
|
+
// For each target, walk forward to find all reachable nodes
|
|
185
|
+
const reachableSets = stepTargets.map((target) => getReachableNodes(ast, target));
|
|
186
|
+
// Check if any node appears in multiple reachable sets (fan-in point)
|
|
187
|
+
const allNodes = new Set();
|
|
188
|
+
let hasMerge = false;
|
|
189
|
+
for (const reachable of reachableSets) {
|
|
190
|
+
for (const node of reachable) {
|
|
191
|
+
if (allNodes.has(node)) {
|
|
192
|
+
hasMerge = true;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (hasMerge)
|
|
197
|
+
break;
|
|
198
|
+
for (const node of reachable) {
|
|
199
|
+
allNodes.add(node);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// Also check if any target has a merge strategy on its input ports
|
|
203
|
+
if (!hasMerge) {
|
|
204
|
+
for (const target of stepTargets) {
|
|
205
|
+
const targetInst = ast.instances.find((i) => i.id === target);
|
|
206
|
+
if (!targetInst)
|
|
207
|
+
continue;
|
|
208
|
+
const targetNt = resolveNodeType(ast, targetInst);
|
|
209
|
+
if (!targetNt)
|
|
210
|
+
continue;
|
|
211
|
+
const hasMergePort = Object.values(targetNt.inputs).some((p) => p.mergeStrategy);
|
|
212
|
+
if (hasMergePort) {
|
|
213
|
+
hasMerge = true;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if (!hasMerge) {
|
|
219
|
+
errors.push({
|
|
220
|
+
type: 'warning',
|
|
221
|
+
code: 'DESIGN_FANOUT_NO_FANIN',
|
|
222
|
+
message: `Node '${instance.id}' fans out to ${stepTargets.length} step targets (${stepTargets.join(', ')}) but those paths never merge back. Data from parallel branches may be lost.`,
|
|
223
|
+
node: instance.id,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return errors;
|
|
228
|
+
},
|
|
229
|
+
};
|
|
230
|
+
/** Walk forward from a node to find all reachable nodes (BFS via step connections). */
|
|
231
|
+
function getReachableNodes(ast, startNode) {
|
|
232
|
+
const visited = new Set();
|
|
233
|
+
const queue = [startNode];
|
|
234
|
+
while (queue.length > 0) {
|
|
235
|
+
const current = queue.shift();
|
|
236
|
+
if (visited.has(current))
|
|
237
|
+
continue;
|
|
238
|
+
visited.add(current);
|
|
239
|
+
for (const conn of ast.connections) {
|
|
240
|
+
if (conn.from.node === current && !visited.has(conn.to.node)) {
|
|
241
|
+
queue.push(conn.to.node);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return visited;
|
|
246
|
+
}
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
// Rule 5: Exit Data Port Unreachable (Pull-Execution Aware)
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
/**
|
|
251
|
+
* Extends UNREACHABLE_EXIT_PORT to consider pull execution. An exit data port
|
|
252
|
+
* is fine if a pull-executed node is wired to provide it, even without a
|
|
253
|
+
* step path.
|
|
254
|
+
*/
|
|
255
|
+
export const exitDataUnreachableRule = {
|
|
256
|
+
name: 'DESIGN_EXIT_DATA_UNREACHABLE',
|
|
257
|
+
validate(ast) {
|
|
258
|
+
const errors = [];
|
|
259
|
+
const stepPorts = new Set(['onSuccess', 'onFailure']);
|
|
260
|
+
for (const [portName, portDef] of Object.entries(ast.exitPorts)) {
|
|
261
|
+
if (portDef.dataType === 'STEP')
|
|
262
|
+
continue; // Step ports handled by core validator
|
|
263
|
+
const incoming = getIncoming(ast, 'Exit', portName);
|
|
264
|
+
if (incoming.length > 0)
|
|
265
|
+
continue; // Has a direct connection, fine
|
|
266
|
+
// Check if a pull-executed node provides this port
|
|
267
|
+
const hasPullProvider = ast.instances.some((inst) => {
|
|
268
|
+
const isPull = inst.config?.pullExecution ||
|
|
269
|
+
resolveNodeType(ast, inst)?.defaultConfig?.pullExecution;
|
|
270
|
+
if (!isPull)
|
|
271
|
+
return false;
|
|
272
|
+
// Check if this pull node has a data output connected to Exit.<portName>
|
|
273
|
+
return getOutgoing(ast, inst.id).some((c) => c.to.node === 'Exit' && c.to.port === portName);
|
|
274
|
+
});
|
|
275
|
+
if (!hasPullProvider) {
|
|
276
|
+
errors.push({
|
|
277
|
+
type: 'warning',
|
|
278
|
+
code: 'DESIGN_EXIT_DATA_UNREACHABLE',
|
|
279
|
+
message: `Exit port '${portName}' has no incoming connection and no pull-execution node provides it. The output will be undefined.`,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return errors;
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Rule 6: Pull Execution Candidate
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
/**
|
|
290
|
+
* A node with no incoming step connections but data outputs consumed downstream
|
|
291
|
+
* is a candidate for pullExecution. Without it, the node may never execute.
|
|
292
|
+
*/
|
|
293
|
+
export const pullCandidateRule = {
|
|
294
|
+
name: 'DESIGN_PULL_CANDIDATE',
|
|
295
|
+
validate(ast) {
|
|
296
|
+
const errors = [];
|
|
297
|
+
for (const instance of ast.instances) {
|
|
298
|
+
const nt = resolveNodeType(ast, instance);
|
|
299
|
+
if (!nt)
|
|
300
|
+
continue;
|
|
301
|
+
// Skip if already has pullExecution configured
|
|
302
|
+
if (instance.config?.pullExecution || nt.defaultConfig?.pullExecution)
|
|
303
|
+
continue;
|
|
304
|
+
// Skip expression nodes (they are already pull-executed by nature)
|
|
305
|
+
if (nt.expression)
|
|
306
|
+
continue;
|
|
307
|
+
// Check for incoming step connections
|
|
308
|
+
const incomingStep = getIncoming(ast, instance.id).filter((c) => {
|
|
309
|
+
const portDef = nt.inputs[c.to.port];
|
|
310
|
+
return isStepPort(c.to.port, portDef);
|
|
311
|
+
});
|
|
312
|
+
if (incomingStep.length > 0)
|
|
313
|
+
continue; // Has step trigger, not a candidate
|
|
314
|
+
// Check if any data outputs are consumed downstream
|
|
315
|
+
const dataOutputs = Object.keys(nt.outputs).filter((p) => !STEP_PORTS.has(p));
|
|
316
|
+
const hasConsumedOutput = dataOutputs.some((port) => getOutgoing(ast, instance.id, port).length > 0);
|
|
317
|
+
if (hasConsumedOutput) {
|
|
318
|
+
errors.push({
|
|
319
|
+
type: 'warning',
|
|
320
|
+
code: 'DESIGN_PULL_CANDIDATE',
|
|
321
|
+
message: `Node '${instance.id}' has no incoming step connection but its data outputs are consumed downstream. Consider adding [pullExecution: execute] so it executes on demand.`,
|
|
322
|
+
node: instance.id,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return errors;
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// Rule 7: Unused Pull Execution
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
/**
|
|
333
|
+
* A node marked with pullExecution but no downstream node reads its data output.
|
|
334
|
+
* The node will never execute since pull execution requires a consumer.
|
|
335
|
+
*/
|
|
336
|
+
export const pullUnusedRule = {
|
|
337
|
+
name: 'DESIGN_PULL_UNUSED',
|
|
338
|
+
validate(ast) {
|
|
339
|
+
const errors = [];
|
|
340
|
+
for (const instance of ast.instances) {
|
|
341
|
+
const nt = resolveNodeType(ast, instance);
|
|
342
|
+
if (!nt)
|
|
343
|
+
continue;
|
|
344
|
+
const isPull = instance.config?.pullExecution || nt.defaultConfig?.pullExecution;
|
|
345
|
+
if (!isPull)
|
|
346
|
+
continue;
|
|
347
|
+
// Check if any data output ports are connected
|
|
348
|
+
const dataOutputs = Object.keys(nt.outputs).filter((p) => !STEP_PORTS.has(p));
|
|
349
|
+
const hasConnectedOutput = dataOutputs.some((port) => getOutgoing(ast, instance.id, port).length > 0);
|
|
350
|
+
if (!hasConnectedOutput) {
|
|
351
|
+
errors.push({
|
|
352
|
+
type: 'warning',
|
|
353
|
+
code: 'DESIGN_PULL_UNUSED',
|
|
354
|
+
message: `Node '${instance.id}' is marked with pullExecution but no downstream node reads its data output. It will never execute.`,
|
|
355
|
+
node: instance.id,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return errors;
|
|
360
|
+
},
|
|
361
|
+
};
|
|
362
|
+
// ---------------------------------------------------------------------------
|
|
363
|
+
// Public API
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
export const designValidationRules = [
|
|
366
|
+
asyncNoErrorPathRule,
|
|
367
|
+
scopeNoFailureExitRule,
|
|
368
|
+
unboundedRetryRule,
|
|
369
|
+
fanoutNoFaninRule,
|
|
370
|
+
exitDataUnreachableRule,
|
|
371
|
+
pullCandidateRule,
|
|
372
|
+
pullUnusedRule,
|
|
373
|
+
];
|
|
374
|
+
export function getDesignValidationRules() {
|
|
375
|
+
return designValidationRules;
|
|
376
|
+
}
|
|
377
|
+
//# sourceMappingURL=design-rules.js.map
|