@synergenius/flow-weaver 0.20.5 → 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.
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { validator } from "../validator.js";
8
8
  import { getAgentValidationRules } from "../validation/agent-rules.js";
9
+ import { getDesignValidationRules } from "../validation/design-rules.js";
9
10
  import { validationRuleRegistry } from "./validation-registry.js";
10
11
  /**
11
12
  * Validates a workflow AST
@@ -24,6 +25,7 @@ export function validateWorkflow(ast, options) {
24
25
  // including CI/CD when applicable), and custom rules
25
26
  const allRules = [
26
27
  ...getAgentValidationRules(),
28
+ ...getDesignValidationRules(),
27
29
  ...validationRuleRegistry.getApplicableRules(ast),
28
30
  ...(options?.customRules || []),
29
31
  ];
@@ -9671,7 +9671,7 @@ var VERSION;
9671
9671
  var init_generated_version = __esm({
9672
9672
  "src/generated-version.ts"() {
9673
9673
  "use strict";
9674
- VERSION = "0.20.5";
9674
+ VERSION = "0.20.6";
9675
9675
  }
9676
9676
  });
9677
9677
 
@@ -10456,9 +10456,9 @@ function generateScopeFunctionClosure(scopeName, parentNodeId, parentNodeType, w
10456
10456
  const childInstance = childInstances.find((c) => c.id === sourceNode);
10457
10457
  const sourceNodeTypeName = childInstance?.nodeType ?? "";
10458
10458
  const varAddr = `{ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${toValidIdentifier(sourceNode)}Idx, nodeTypeName: '${sourceNodeTypeName}' }`;
10459
- const isStepPort = portName === "success" || portName === "failure";
10459
+ const isStepPort2 = portName === "success" || portName === "failure";
10460
10460
  const defaultValue = portName === "success" ? "true" : portName === "failure" ? "false" : "undefined";
10461
- if (isStepPort) {
10461
+ if (isStepPort2) {
10462
10462
  lines.push(
10463
10463
  ` const ${varName} = ctx.hasVariable(${varAddr}) ? ${getCallAfterMerge}(${varAddr}) as ${portType} : ${defaultValue};`
10464
10464
  );
@@ -39987,6 +39987,277 @@ var init_agent_rules = __esm({
39987
39987
  }
39988
39988
  });
39989
39989
 
39990
+ // src/validation/design-rules.ts
39991
+ function resolveNodeType2(ast, instance) {
39992
+ return ast.nodeTypes.find(
39993
+ (nt) => nt.name === instance.nodeType || nt.functionName === instance.nodeType
39994
+ );
39995
+ }
39996
+ function getOutgoing2(ast, nodeId, portName) {
39997
+ return ast.connections.filter((c) => {
39998
+ if (c.from.node !== nodeId) return false;
39999
+ if (portName && c.from.port !== portName) return false;
40000
+ return true;
40001
+ });
40002
+ }
40003
+ function getIncoming(ast, nodeId, portName) {
40004
+ return ast.connections.filter((c) => {
40005
+ if (c.to.node !== nodeId) return false;
40006
+ if (portName && c.to.port !== portName) return false;
40007
+ return true;
40008
+ });
40009
+ }
40010
+ function isStepPort(portName, portDef) {
40011
+ if (portDef?.dataType === "STEP") return true;
40012
+ return STEP_PORTS.has(portName);
40013
+ }
40014
+ function getReachableNodes(ast, startNode) {
40015
+ const visited = /* @__PURE__ */ new Set();
40016
+ const queue = [startNode];
40017
+ while (queue.length > 0) {
40018
+ const current2 = queue.shift();
40019
+ if (visited.has(current2)) continue;
40020
+ visited.add(current2);
40021
+ for (const conn of ast.connections) {
40022
+ if (conn.from.node === current2 && !visited.has(conn.to.node)) {
40023
+ queue.push(conn.to.node);
40024
+ }
40025
+ }
40026
+ }
40027
+ return visited;
40028
+ }
40029
+ function getDesignValidationRules() {
40030
+ return designValidationRules;
40031
+ }
40032
+ var STEP_PORTS, asyncNoErrorPathRule, scopeNoFailureExitRule, unboundedRetryRule, fanoutNoFaninRule, exitDataUnreachableRule, pullCandidateRule, pullUnusedRule, designValidationRules;
40033
+ var init_design_rules = __esm({
40034
+ "src/validation/design-rules.ts"() {
40035
+ "use strict";
40036
+ STEP_PORTS = /* @__PURE__ */ new Set(["execute", "onSuccess", "onFailure", "start", "success", "failure"]);
40037
+ asyncNoErrorPathRule = {
40038
+ name: "DESIGN_ASYNC_NO_ERROR_PATH",
40039
+ validate(ast) {
40040
+ const errors2 = [];
40041
+ for (const instance of ast.instances) {
40042
+ const nt = resolveNodeType2(ast, instance);
40043
+ if (!nt) continue;
40044
+ if (!nt.isAsync) continue;
40045
+ if (!nt.hasFailurePort) continue;
40046
+ const failureConns = getOutgoing2(ast, instance.id, "onFailure");
40047
+ if (failureConns.length === 0) {
40048
+ errors2.push({
40049
+ type: "warning",
40050
+ code: "DESIGN_ASYNC_NO_ERROR_PATH",
40051
+ message: `Async node '${instance.id}' has no onFailure connection. Async operations (network, disk, AI) can fail, and errors will be silently lost.`,
40052
+ node: instance.id
40053
+ });
40054
+ }
40055
+ }
40056
+ return errors2;
40057
+ }
40058
+ };
40059
+ scopeNoFailureExitRule = {
40060
+ name: "DESIGN_SCOPE_NO_FAILURE_EXIT",
40061
+ validate(ast) {
40062
+ const errors2 = [];
40063
+ for (const instance of ast.instances) {
40064
+ const nt = resolveNodeType2(ast, instance);
40065
+ if (!nt) continue;
40066
+ const scopeNames = nt.scopes ?? (nt.scope ? [nt.scope] : []);
40067
+ if (scopeNames.length === 0) continue;
40068
+ if (!nt.hasFailurePort) continue;
40069
+ const failureConns = getOutgoing2(ast, instance.id, "onFailure");
40070
+ const failureConns2 = getOutgoing2(ast, instance.id, "failure");
40071
+ if (failureConns.length === 0 && failureConns2.length === 0) {
40072
+ errors2.push({
40073
+ type: "warning",
40074
+ code: "DESIGN_SCOPE_NO_FAILURE_EXIT",
40075
+ message: `Scope node '${instance.id}' has no failure path out. If all iterations fail, execution stalls with no error surfaced.`,
40076
+ node: instance.id
40077
+ });
40078
+ }
40079
+ }
40080
+ return errors2;
40081
+ }
40082
+ };
40083
+ unboundedRetryRule = {
40084
+ name: "DESIGN_UNBOUNDED_RETRY",
40085
+ validate(ast) {
40086
+ const errors2 = [];
40087
+ const retryPatterns = /retry|repeat|loop|poll|backoff/i;
40088
+ for (const instance of ast.instances) {
40089
+ const nt = resolveNodeType2(ast, instance);
40090
+ if (!nt) continue;
40091
+ const scopeNames = nt.scopes ?? (nt.scope ? [nt.scope] : []);
40092
+ if (scopeNames.length === 0) continue;
40093
+ const nameHint = `${nt.name} ${nt.functionName} ${nt.label ?? ""}`;
40094
+ if (!retryPatterns.test(nameHint)) continue;
40095
+ const limitInputs = Object.keys(nt.inputs).filter(
40096
+ (p) => /max|limit|attempts|retries|count/i.test(p)
40097
+ );
40098
+ if (limitInputs.length === 0) {
40099
+ errors2.push({
40100
+ type: "warning",
40101
+ code: "DESIGN_UNBOUNDED_RETRY",
40102
+ message: `Scope node '${instance.id}' appears to be a retry loop but has no visible attempt limit input. This could loop indefinitely.`,
40103
+ node: instance.id
40104
+ });
40105
+ }
40106
+ }
40107
+ return errors2;
40108
+ }
40109
+ };
40110
+ fanoutNoFaninRule = {
40111
+ name: "DESIGN_FANOUT_NO_FANIN",
40112
+ validate(ast) {
40113
+ const errors2 = [];
40114
+ for (const instance of ast.instances) {
40115
+ const nt = resolveNodeType2(ast, instance);
40116
+ const stepOutConns = getOutgoing2(ast, instance.id).filter((c) => {
40117
+ if (nt) {
40118
+ const portDef = nt.outputs[c.from.port];
40119
+ return isStepPort(c.from.port, portDef);
40120
+ }
40121
+ return isStepPort(c.from.port);
40122
+ });
40123
+ const stepTargets = [...new Set(stepOutConns.map((c) => c.to.node))].filter(
40124
+ (n) => n !== "Exit"
40125
+ );
40126
+ if (stepTargets.length < 3) continue;
40127
+ const reachableSets = stepTargets.map((target) => getReachableNodes(ast, target));
40128
+ const allNodes = /* @__PURE__ */ new Set();
40129
+ let hasMerge = false;
40130
+ for (const reachable of reachableSets) {
40131
+ for (const node of reachable) {
40132
+ if (allNodes.has(node)) {
40133
+ hasMerge = true;
40134
+ break;
40135
+ }
40136
+ }
40137
+ if (hasMerge) break;
40138
+ for (const node of reachable) {
40139
+ allNodes.add(node);
40140
+ }
40141
+ }
40142
+ if (!hasMerge) {
40143
+ for (const target of stepTargets) {
40144
+ const targetInst = ast.instances.find((i) => i.id === target);
40145
+ if (!targetInst) continue;
40146
+ const targetNt = resolveNodeType2(ast, targetInst);
40147
+ if (!targetNt) continue;
40148
+ const hasMergePort = Object.values(targetNt.inputs).some((p) => p.mergeStrategy);
40149
+ if (hasMergePort) {
40150
+ hasMerge = true;
40151
+ break;
40152
+ }
40153
+ }
40154
+ }
40155
+ if (!hasMerge) {
40156
+ errors2.push({
40157
+ type: "warning",
40158
+ code: "DESIGN_FANOUT_NO_FANIN",
40159
+ 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.`,
40160
+ node: instance.id
40161
+ });
40162
+ }
40163
+ }
40164
+ return errors2;
40165
+ }
40166
+ };
40167
+ exitDataUnreachableRule = {
40168
+ name: "DESIGN_EXIT_DATA_UNREACHABLE",
40169
+ validate(ast) {
40170
+ const errors2 = [];
40171
+ const stepPorts = /* @__PURE__ */ new Set(["onSuccess", "onFailure"]);
40172
+ for (const [portName, portDef] of Object.entries(ast.exitPorts)) {
40173
+ if (portDef.dataType === "STEP") continue;
40174
+ const incoming = getIncoming(ast, "Exit", portName);
40175
+ if (incoming.length > 0) continue;
40176
+ const hasPullProvider = ast.instances.some((inst) => {
40177
+ const isPull = inst.config?.pullExecution || resolveNodeType2(ast, inst)?.defaultConfig?.pullExecution;
40178
+ if (!isPull) return false;
40179
+ return getOutgoing2(ast, inst.id).some(
40180
+ (c) => c.to.node === "Exit" && c.to.port === portName
40181
+ );
40182
+ });
40183
+ if (!hasPullProvider) {
40184
+ errors2.push({
40185
+ type: "warning",
40186
+ code: "DESIGN_EXIT_DATA_UNREACHABLE",
40187
+ message: `Exit port '${portName}' has no incoming connection and no pull-execution node provides it. The output will be undefined.`
40188
+ });
40189
+ }
40190
+ }
40191
+ return errors2;
40192
+ }
40193
+ };
40194
+ pullCandidateRule = {
40195
+ name: "DESIGN_PULL_CANDIDATE",
40196
+ validate(ast) {
40197
+ const errors2 = [];
40198
+ for (const instance of ast.instances) {
40199
+ const nt = resolveNodeType2(ast, instance);
40200
+ if (!nt) continue;
40201
+ if (instance.config?.pullExecution || nt.defaultConfig?.pullExecution) continue;
40202
+ if (nt.expression) continue;
40203
+ const incomingStep = getIncoming(ast, instance.id).filter((c) => {
40204
+ const portDef = nt.inputs[c.to.port];
40205
+ return isStepPort(c.to.port, portDef);
40206
+ });
40207
+ if (incomingStep.length > 0) continue;
40208
+ const dataOutputs = Object.keys(nt.outputs).filter((p) => !STEP_PORTS.has(p));
40209
+ const hasConsumedOutput = dataOutputs.some(
40210
+ (port) => getOutgoing2(ast, instance.id, port).length > 0
40211
+ );
40212
+ if (hasConsumedOutput) {
40213
+ errors2.push({
40214
+ type: "warning",
40215
+ code: "DESIGN_PULL_CANDIDATE",
40216
+ 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.`,
40217
+ node: instance.id
40218
+ });
40219
+ }
40220
+ }
40221
+ return errors2;
40222
+ }
40223
+ };
40224
+ pullUnusedRule = {
40225
+ name: "DESIGN_PULL_UNUSED",
40226
+ validate(ast) {
40227
+ const errors2 = [];
40228
+ for (const instance of ast.instances) {
40229
+ const nt = resolveNodeType2(ast, instance);
40230
+ if (!nt) continue;
40231
+ const isPull = instance.config?.pullExecution || nt.defaultConfig?.pullExecution;
40232
+ if (!isPull) continue;
40233
+ const dataOutputs = Object.keys(nt.outputs).filter((p) => !STEP_PORTS.has(p));
40234
+ const hasConnectedOutput = dataOutputs.some(
40235
+ (port) => getOutgoing2(ast, instance.id, port).length > 0
40236
+ );
40237
+ if (!hasConnectedOutput) {
40238
+ errors2.push({
40239
+ type: "warning",
40240
+ code: "DESIGN_PULL_UNUSED",
40241
+ message: `Node '${instance.id}' is marked with pullExecution but no downstream node reads its data output. It will never execute.`,
40242
+ node: instance.id
40243
+ });
40244
+ }
40245
+ }
40246
+ return errors2;
40247
+ }
40248
+ };
40249
+ designValidationRules = [
40250
+ asyncNoErrorPathRule,
40251
+ scopeNoFailureExitRule,
40252
+ unboundedRetryRule,
40253
+ fanoutNoFaninRule,
40254
+ exitDataUnreachableRule,
40255
+ pullCandidateRule,
40256
+ pullUnusedRule
40257
+ ];
40258
+ }
40259
+ });
40260
+
39990
40261
  // src/api/validate.ts
39991
40262
  var validate_exports = {};
39992
40263
  __export(validate_exports, {
@@ -39996,6 +40267,7 @@ function validateWorkflow(ast, options) {
39996
40267
  const result = validator.validate(ast, { mode: options?.mode });
39997
40268
  const allRules = [
39998
40269
  ...getAgentValidationRules(),
40270
+ ...getDesignValidationRules(),
39999
40271
  ...validationRuleRegistry.getApplicableRules(ast),
40000
40272
  ...options?.customRules || []
40001
40273
  ];
@@ -40030,6 +40302,7 @@ var init_validate = __esm({
40030
40302
  "use strict";
40031
40303
  init_validator();
40032
40304
  init_agent_rules();
40305
+ init_design_rules();
40033
40306
  init_validation_registry();
40034
40307
  }
40035
40308
  });
@@ -46512,6 +46785,7 @@ function aggregateResults(
46512
46785
  * @connect iterator.results -> Exit.results
46513
46786
  * @connect iterator.results -> aggregator.results
46514
46787
  * @connect iterator.onSuccess -> aggregator.execute
46788
+ * @connect iterator.onFailure -> aggregator.execute
46515
46789
  * @connect aggregator.successCount -> Exit.successCount
46516
46790
  * @connect aggregator.failedCount -> Exit.failedCount
46517
46791
  * @connect aggregator.onSuccess -> Exit.onSuccess
@@ -47149,6 +47423,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, ToolFn> = {
47149
47423
  * @flowWeaver nodeType
47150
47424
  * @label Agent Loop
47151
47425
  * @input userMessage [order:1] - User's input message
47426
+ * @input [maxIterations] [order:2] - Maximum loop iterations (default: 10)
47152
47427
  * @input success scope:iteration [order:0] - From LLM onSuccess
47153
47428
  * @input failure scope:iteration [order:1] - From LLM onFailure
47154
47429
  * @input llmResponse scope:iteration [order:2] - LLM response
@@ -47163,6 +47438,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, ToolFn> = {
47163
47438
  async function agentLoop(
47164
47439
  execute: boolean,
47165
47440
  userMessage: string,
47441
+ maxIterations: number = MAX_ITERATIONS,
47166
47442
  iteration: (start: boolean, state: AgentState) => Promise<{
47167
47443
  success: boolean;
47168
47444
  failure: boolean;
@@ -47181,7 +47457,7 @@ async function agentLoop(
47181
47457
  terminated: false,
47182
47458
  };
47183
47459
 
47184
- while (state.iteration < MAX_ITERATIONS) {
47460
+ while (state.iteration < maxIterations) {
47185
47461
  const result = await iteration(true, state);
47186
47462
 
47187
47463
  state.iteration++;
@@ -47319,6 +47595,7 @@ async function executeTools(
47319
47595
  * @connect llm.onSuccess -> loop.success:iteration
47320
47596
  * @connect llm.onFailure -> loop.failure:iteration
47321
47597
  * @connect tools.messages -> loop.toolMessages:iteration
47598
+ * @connect tools.onFailure -> loop.failure:iteration
47322
47599
  * @connect loop.response -> Exit.response
47323
47600
  * @connect loop.onSuccess -> Exit.onSuccess
47324
47601
  * @connect loop.onFailure -> Exit.onFailure
@@ -47448,6 +47725,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, (input: string) => Promise<string>> =
47448
47725
  * @label ReAct Loop
47449
47726
  * @input execute [order:0] - Execute
47450
47727
  * @input task [order:1] - Task for the agent
47728
+ * @input [maxSteps] [order:2] - Maximum reasoning steps (default: 10)
47451
47729
  * @input success scope:step [order:0] - Iteration succeeded
47452
47730
  * @input failure scope:step [order:1] - Iteration failed
47453
47731
  * @input thought scope:step [order:2] - Agent's reasoning
@@ -47463,6 +47741,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, (input: string) => Promise<string>> =
47463
47741
  async function reactLoop(
47464
47742
  execute: boolean,
47465
47743
  task: string,
47744
+ maxSteps: number = MAX_STEPS,
47466
47745
  step: (start: boolean, messages: LLMMessage[]) => Promise<{
47467
47746
  success: boolean;
47468
47747
  failure: boolean;
@@ -47478,7 +47757,7 @@ async function reactLoop(
47478
47757
 
47479
47758
  const messages: LLMMessage[] = [{ role: 'user', content: task }];
47480
47759
 
47481
- for (let i = 0; i < MAX_STEPS; i++) {
47760
+ for (let i = 0; i < maxSteps; i++) {
47482
47761
  const result = await step(true, messages);
47483
47762
 
47484
47763
  if (result.failure) {
@@ -47791,7 +48070,7 @@ Answer:\`;
47791
48070
  * RAG Pipeline for knowledge-based Q&A
47792
48071
  *
47793
48072
  * @flowWeaver workflow
47794
- * @node retriever retrieve [position: -50 0] [color: "teal"] [icon: "search"] [suppress: "UNUSED_OUTPUT_PORT"]
48073
+ * @node retriever retrieve [position: -50 0] [color: "teal"] [icon: "search"] [suppress: "UNUSED_OUTPUT_PORT", "DESIGN_ASYNC_NO_ERROR_PATH"]
47795
48074
  * @node generator generate [position: 200 0] [color: "purple"] [icon: "autoAwesome"]
47796
48075
  * @position Start -300 0
47797
48076
  * @position Exit 400 0
@@ -78203,6 +78482,71 @@ var errorMappers = {
78203
78482
  code: error2.code
78204
78483
  };
78205
78484
  },
78485
+ // ── Design quality rules ─────────────────────────────────────────────
78486
+ DESIGN_ASYNC_NO_ERROR_PATH(error2) {
78487
+ const nodeName = error2.node || "unknown";
78488
+ return {
78489
+ title: "Async Node Missing Error Path",
78490
+ 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.`,
78491
+ fix: `Connect ${nodeName}.onFailure to an error handler, retry node, or Exit.onFailure.`,
78492
+ code: error2.code
78493
+ };
78494
+ },
78495
+ DESIGN_SCOPE_NO_FAILURE_EXIT(error2) {
78496
+ const nodeName = error2.node || "unknown";
78497
+ return {
78498
+ title: "Scope Missing Failure Exit",
78499
+ explanation: `Scope node '${nodeName}' has no failure path out. If all iterations fail, execution stalls with no error surfaced upstream.`,
78500
+ fix: `Connect ${nodeName}.onFailure to an error handler or Exit.onFailure so scope failures propagate.`,
78501
+ code: error2.code
78502
+ };
78503
+ },
78504
+ DESIGN_UNBOUNDED_RETRY(error2) {
78505
+ const nodeName = error2.node || "unknown";
78506
+ return {
78507
+ title: "Unbounded Retry Loop",
78508
+ explanation: `Scope node '${nodeName}' looks like a retry loop but has no visible attempt limit input. This could loop indefinitely on persistent failures.`,
78509
+ 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.`,
78510
+ code: error2.code
78511
+ };
78512
+ },
78513
+ DESIGN_FANOUT_NO_FANIN(error2) {
78514
+ const nodeName = error2.node || "unknown";
78515
+ return {
78516
+ title: "Fan-Out Without Fan-In",
78517
+ 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.`,
78518
+ fix: `Add a merge node downstream where the parallel branches converge, or wire them to a shared node before Exit.`,
78519
+ code: error2.code
78520
+ };
78521
+ },
78522
+ DESIGN_EXIT_DATA_UNREACHABLE(error2) {
78523
+ const quoted = extractQuoted(error2.message);
78524
+ const portName = quoted[0] || "unknown";
78525
+ return {
78526
+ title: "Exit Data Unreachable",
78527
+ explanation: `Exit port '${portName}' has no incoming data connection and no pull-execution node provides it. The workflow will return undefined for this output.`,
78528
+ fix: `Connect a node's data output to Exit.${portName}, or add a pull-execution node that computes this value on demand.`,
78529
+ code: error2.code
78530
+ };
78531
+ },
78532
+ DESIGN_PULL_CANDIDATE(error2) {
78533
+ const nodeName = error2.node || "unknown";
78534
+ return {
78535
+ title: "Pull Execution Candidate",
78536
+ 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.`,
78537
+ fix: `Add [pullExecution: execute] to the @node annotation so the node runs on demand when downstream nodes read its output.`,
78538
+ code: error2.code
78539
+ };
78540
+ },
78541
+ DESIGN_PULL_UNUSED(error2) {
78542
+ const nodeName = error2.node || "unknown";
78543
+ return {
78544
+ title: "Unused Pull Execution",
78545
+ 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.`,
78546
+ fix: `Connect a data output from '${nodeName}' to a downstream node, or remove the pullExecution config if the node isn't needed.`,
78547
+ code: error2.code
78548
+ };
78549
+ },
78206
78550
  COERCE_TYPE_MISMATCH(error2) {
78207
78551
  const coerceMatch = error2.message.match(/`as (\w+)`/);
78208
78552
  const coerceType = coerceMatch?.[1] || "unknown";
@@ -100073,7 +100417,15 @@ var ERROR_HINTS = {
100073
100417
  AGENT_UNGUARDED_TOOL_EXECUTOR: 'Add a human-approval node before the tool executor. Use fw_scaffold(template="human-approval") to create one',
100074
100418
  AGENT_MISSING_MEMORY_IN_LOOP: 'Add a conversation-memory node inside the loop scope. Use fw_scaffold(template="conversation-memory") to create one',
100075
100419
  AGENT_LLM_NO_FALLBACK: "Add a retry or fallback node between the LLM onFailure and Exit. Consider a second LLM provider as fallback",
100076
- AGENT_TOOL_NO_OUTPUT_HANDLING: 'Use fw_modify(operation="addConnection") to wire tool output ports to downstream nodes'
100420
+ AGENT_TOOL_NO_OUTPUT_HANDLING: 'Use fw_modify(operation="addConnection") to wire tool output ports to downstream nodes',
100421
+ // Design quality rules
100422
+ DESIGN_ASYNC_NO_ERROR_PATH: 'Use fw_modify(operation="addConnection", params={from:"<nodeId>.onFailure", to:"Exit.onFailure"}) or add a retry/error handler',
100423
+ DESIGN_SCOPE_NO_FAILURE_EXIT: 'Use fw_modify(operation="addConnection", params={from:"<nodeId>.onFailure", to:"Exit.onFailure"}) to surface scope failures',
100424
+ DESIGN_UNBOUNDED_RETRY: "Add a maxAttempts or retries input port to the retry node type to bound the loop",
100425
+ DESIGN_FANOUT_NO_FANIN: "Add a merge node downstream where parallel branches converge before Exit",
100426
+ DESIGN_EXIT_DATA_UNREACHABLE: 'Use fw_modify(operation="addConnection") to connect a node output to this Exit port, or add a pull-execution node',
100427
+ DESIGN_PULL_CANDIDATE: "Add [pullExecution: execute] to the @node annotation so the node runs on demand",
100428
+ DESIGN_PULL_UNUSED: "Connect a data output from this node to a downstream consumer, or remove pullExecution"
100077
100429
  };
100078
100430
  function addHintsToItems(items, friendlyErrorFn) {
100079
100431
  return items.map((item) => {
@@ -106731,7 +107083,7 @@ function displayInstalledPackage(pkg) {
106731
107083
  // src/cli/index.ts
106732
107084
  init_logger();
106733
107085
  init_error_utils();
106734
- var version2 = true ? "0.20.5" : "0.0.0-dev";
107086
+ var version2 = true ? "0.20.6" : "0.0.0-dev";
106735
107087
  var program2 = new Command();
106736
107088
  program2.name("flow-weaver").description("Flow Weaver Annotations - Compile and validate workflow files").option("-v, --version", "Output the current version").option("--no-color", "Disable colors").option("--color", "Force colors").on("option:version", () => {
106737
107089
  logger.banner(version2);
@@ -128,6 +128,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, ToolFn> = {
128
128
  * @flowWeaver nodeType
129
129
  * @label Agent Loop
130
130
  * @input userMessage [order:1] - User's input message
131
+ * @input [maxIterations] [order:2] - Maximum loop iterations (default: 10)
131
132
  * @input success scope:iteration [order:0] - From LLM onSuccess
132
133
  * @input failure scope:iteration [order:1] - From LLM onFailure
133
134
  * @input llmResponse scope:iteration [order:2] - LLM response
@@ -142,6 +143,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, ToolFn> = {
142
143
  async function agentLoop(
143
144
  execute: boolean,
144
145
  userMessage: string,
146
+ maxIterations: number = MAX_ITERATIONS,
145
147
  iteration: (start: boolean, state: AgentState) => Promise<{
146
148
  success: boolean;
147
149
  failure: boolean;
@@ -160,7 +162,7 @@ async function agentLoop(
160
162
  terminated: false,
161
163
  };
162
164
 
163
- while (state.iteration < MAX_ITERATIONS) {
165
+ while (state.iteration < maxIterations) {
164
166
  const result = await iteration(true, state);
165
167
 
166
168
  state.iteration++;
@@ -298,6 +300,7 @@ async function executeTools(
298
300
  * @connect llm.onSuccess -> loop.success:iteration
299
301
  * @connect llm.onFailure -> loop.failure:iteration
300
302
  * @connect tools.messages -> loop.toolMessages:iteration
303
+ * @connect tools.onFailure -> loop.failure:iteration
301
304
  * @connect loop.response -> Exit.response
302
305
  * @connect loop.onSuccess -> Exit.onSuccess
303
306
  * @connect loop.onFailure -> Exit.onFailure
@@ -145,7 +145,7 @@ Answer:\`;
145
145
  * RAG Pipeline for knowledge-based Q&A
146
146
  *
147
147
  * @flowWeaver workflow
148
- * @node retriever retrieve [position: -50 0] [color: "teal"] [icon: "search"] [suppress: "UNUSED_OUTPUT_PORT"]
148
+ * @node retriever retrieve [position: -50 0] [color: "teal"] [icon: "search"] [suppress: "UNUSED_OUTPUT_PORT", "DESIGN_ASYNC_NO_ERROR_PATH"]
149
149
  * @node generator generate [position: 200 0] [color: "purple"] [icon: "autoAwesome"]
150
150
  * @position Start -300 0
151
151
  * @position Exit 400 0
@@ -97,6 +97,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, (input: string) => Promise<string>> =
97
97
  * @label ReAct Loop
98
98
  * @input execute [order:0] - Execute
99
99
  * @input task [order:1] - Task for the agent
100
+ * @input [maxSteps] [order:2] - Maximum reasoning steps (default: 10)
100
101
  * @input success scope:step [order:0] - Iteration succeeded
101
102
  * @input failure scope:step [order:1] - Iteration failed
102
103
  * @input thought scope:step [order:2] - Agent's reasoning
@@ -112,6 +113,7 @@ const TOOL_IMPLEMENTATIONS: Record<string, (input: string) => Promise<string>> =
112
113
  async function reactLoop(
113
114
  execute: boolean,
114
115
  task: string,
116
+ maxSteps: number = MAX_STEPS,
115
117
  step: (start: boolean, messages: LLMMessage[]) => Promise<{
116
118
  success: boolean;
117
119
  failure: boolean;
@@ -127,7 +129,7 @@ async function reactLoop(
127
129
 
128
130
  const messages: LLMMessage[] = [{ role: 'user', content: task }];
129
131
 
130
- for (let i = 0; i < MAX_STEPS; i++) {
132
+ for (let i = 0; i < maxSteps; i++) {
131
133
  const result = await step(true, messages);
132
134
 
133
135
  if (result.failure) {
@@ -116,6 +116,7 @@ function aggregateResults(
116
116
  * @connect iterator.results -> Exit.results
117
117
  * @connect iterator.results -> aggregator.results
118
118
  * @connect iterator.onSuccess -> aggregator.execute
119
+ * @connect iterator.onFailure -> aggregator.execute
119
120
  * @connect aggregator.successCount -> Exit.successCount
120
121
  * @connect aggregator.failedCount -> Exit.failedCount
121
122
  * @connect aggregator.onSuccess -> Exit.onSuccess
@@ -12,7 +12,7 @@ export interface TValidationCodeDoc {
12
12
  severity: 'error' | 'warning';
13
13
  title: string;
14
14
  description: string;
15
- category: 'structural' | 'naming' | 'connection' | 'type' | 'node-ref' | 'graph' | 'data-flow' | 'agent';
15
+ category: 'structural' | 'naming' | 'connection' | 'type' | 'node-ref' | 'graph' | 'data-flow' | 'agent' | 'design';
16
16
  }
17
17
  export declare const VALIDATION_CODES: TValidationCodeDoc[];
18
18
  //# sourceMappingURL=error-codes.d.ts.map
@@ -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
@@ -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.5";
1
+ export declare const VERSION = "0.20.6";
2
2
  //# sourceMappingURL=generated-version.d.ts.map
@@ -1,3 +1,3 @@
1
1
  // Auto-generated by scripts/generate-version.ts — do not edit manually
2
- export const VERSION = '0.20.5';
2
+ export const VERSION = '0.20.6';
3
3
  //# sourceMappingURL=generated-version.js.map
@@ -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
@@ -631,6 +631,73 @@ These codes apply to AI agent workflows that use LLM, tool-executor, and memory
631
631
  >
632
632
  > **What to do:** Connect the tool executor's data output ports (e.g., `result`) to downstream nodes that consume the data.
633
633
 
634
+ ### Design Quality Rules
635
+
636
+ These rules detect common workflow design problems that compile fine but indicate poor structure. All are warnings or info-level, suppressible with `@suppress`.
637
+
638
+ #### DESIGN_ASYNC_NO_ERROR_PATH (warning)
639
+
640
+ | Field | Value |
641
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
642
+ | Severity | Warning |
643
+ | Meaning | An async node has no onFailure connection. Async operations (network calls, file I/O, AI calls) can fail, and errors will be silently lost. |
644
+ | Common Causes | Adding an async node and connecting onSuccess but forgetting to wire onFailure. |
645
+ | Fix | Connect the node's `onFailure` port to an error handler, retry node, or `Exit.onFailure`. |
646
+
647
+ #### DESIGN_SCOPE_NO_FAILURE_EXIT (warning)
648
+
649
+ | Field | Value |
650
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
651
+ | Severity | Warning |
652
+ | Meaning | A scope node (retry/forEach) has no failure path out. If all iterations fail, execution stalls with no error surfaced upstream. |
653
+ | Common Causes | Creating a forEach or retry scope and only wiring the success path. |
654
+ | Fix | Connect the scope node's `onFailure` port to propagate errors. |
655
+
656
+ #### DESIGN_UNBOUNDED_RETRY (warning)
657
+
658
+ | Field | Value |
659
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
660
+ | Severity | Warning |
661
+ | Meaning | A scope node with retry/loop semantics (detected by name) has no visible attempt limit input. This could loop indefinitely on persistent failures. |
662
+ | Common Causes | Creating a custom retry loop without a maxAttempts or retries parameter. |
663
+ | Fix | Add a `maxAttempts` or `retries` input port to the node type, or use a counter to break out. |
664
+
665
+ #### DESIGN_FANOUT_NO_FANIN (warning)
666
+
667
+ | Field | Value |
668
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
669
+ | Severity | Warning |
670
+ | Meaning | A node fans out to 3+ step targets, but those paths never converge to a shared downstream node. Data from parallel branches may be lost. |
671
+ | Common Causes | Dispatching work to multiple parallel branches without a merge point before Exit. |
672
+ | Fix | Add a merge node downstream where parallel branches converge, or use a merge strategy on the target port. |
673
+
674
+ #### DESIGN_EXIT_DATA_UNREACHABLE (warning)
675
+
676
+ | Field | Value |
677
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
678
+ | Severity | Warning |
679
+ | Meaning | An Exit data port has no incoming connection and no pull-execution node provides it. Extends UNREACHABLE_EXIT_PORT with pull-execution awareness. |
680
+ | Common Causes | Declaring an exit port but never connecting any node output to it, and no pull-execution node wires to it. |
681
+ | Fix | Connect a node's data output to the Exit port, or add a pull-execution node that computes the value on demand. |
682
+
683
+ #### DESIGN_PULL_CANDIDATE (warning)
684
+
685
+ | Field | Value |
686
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
687
+ | Severity | Warning |
688
+ | Meaning | A node has no incoming step connection but its data outputs are consumed downstream. Without pullExecution, the node may never execute. |
689
+ | Common Causes | Adding a node and connecting its data outputs to downstream nodes but forgetting a step trigger or pullExecution config. |
690
+ | Fix | Add `[pullExecution: execute]` to the `@node` annotation so the node runs on demand. |
691
+
692
+ #### DESIGN_PULL_UNUSED (warning)
693
+
694
+ | Field | Value |
695
+ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
696
+ | Severity | Warning |
697
+ | Meaning | A node is marked with pullExecution but no downstream node reads its data output. Pull execution requires a consumer to trigger. |
698
+ | Common Causes | Setting pullExecution on a node but then not connecting any of its data outputs. |
699
+ | Fix | Connect a data output to a downstream node, or remove the pullExecution config if the node isn't needed. |
700
+
634
701
  ---
635
702
 
636
703
  ## Quick Reference: Error Severity Summary
@@ -690,4 +757,11 @@ These codes apply to AI agent workflows that use LLM, tool-executor, and memory
690
757
  | AGENT_MISSING_MEMORY_IN_LOOP | Loop has LLM but no conversation memory node |
691
758
  | AGENT_LLM_NO_FALLBACK | LLM onFailure routes directly to Exit |
692
759
  | AGENT_TOOL_NO_OUTPUT_HANDLING | Tool executor data outputs all unconnected |
760
+ | DESIGN_ASYNC_NO_ERROR_PATH | Async node has no onFailure connection |
761
+ | DESIGN_SCOPE_NO_FAILURE_EXIT | Scope node has no failure path out |
762
+ | DESIGN_UNBOUNDED_RETRY | Retry scope has no visible attempt limit |
763
+ | DESIGN_FANOUT_NO_FANIN | Fan-out to multiple step targets with no merge back |
764
+ | DESIGN_EXIT_DATA_UNREACHABLE | Exit data port has no connection and no pull-execution provider |
765
+ | DESIGN_PULL_CANDIDATE | Node has no step trigger but its outputs are consumed downstream |
766
+ | DESIGN_PULL_UNUSED | Pull-execution node has no downstream consumers |
693
767
  <!-- AUTO:END warning_summary_table -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synergenius/flow-weaver",
3
- "version": "0.20.5",
3
+ "version": "0.20.6",
4
4
  "description": "Deterministic workflow compiler for AI agents. Compiles to standalone TypeScript, no runtime dependencies.",
5
5
  "private": false,
6
6
  "type": "module",