@synergenius/flow-weaver 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/validator.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { RESERVED_NODE_NAMES, isStartNode, isExitNode, isExecutePort, isReservedNodeName, } from './constants.js';
2
2
  import { findClosestMatches } from './utils/string-distance.js';
3
3
  import { parseFunctionSignature } from './jsdoc-port-sync/signature-parser.js';
4
+ import { checkTypeCompatibilityFromStrings } from './type-checker.js';
4
5
  export class WorkflowValidator {
5
6
  errors = [];
6
7
  warnings = [];
@@ -383,12 +384,14 @@ export class WorkflowValidator {
383
384
  }
384
385
  const sourceType = sourcePortDef.dataType;
385
386
  const targetType = targetPortDef.dataType;
387
+ const sourceTsType = sourcePortDef.tsType;
388
+ const targetTsType = targetPortDef.tsType;
386
389
  // Validate STEP port connections - STEP must connect to STEP only
387
390
  if (sourceType === 'STEP' && targetType !== 'STEP') {
388
391
  this.errors.push({
389
392
  type: 'error',
390
393
  code: 'STEP_PORT_TYPE_MISMATCH',
391
- message: `STEP port "${fromPort}" on node "${fromNode}" cannot connect to non-STEP port "${toPort}" (${targetType}) on node "${toNode}"`,
394
+ message: `STEP port "${fromPort}" on node "${fromNode}" cannot connect to non-STEP port "${toPort}" (${this.formatType(targetType, targetTsType)}) on node "${toNode}"`,
392
395
  connection: conn,
393
396
  location: connLocation,
394
397
  });
@@ -398,7 +401,7 @@ export class WorkflowValidator {
398
401
  this.errors.push({
399
402
  type: 'error',
400
403
  code: 'STEP_PORT_TYPE_MISMATCH',
401
- message: `Non-STEP port "${fromPort}" (${sourceType}) on node "${fromNode}" cannot connect to STEP port "${toPort}" on node "${toNode}"`,
404
+ message: `Non-STEP port "${fromPort}" (${this.formatType(sourceType, sourceTsType)}) on node "${fromNode}" cannot connect to STEP port "${toPort}" on node "${toNode}"`,
402
405
  connection: conn,
403
406
  location: connLocation,
404
407
  });
@@ -412,15 +415,20 @@ export class WorkflowValidator {
412
415
  if (sourceType === targetType) {
413
416
  // For OBJECT types, check if tsType differs (structural mismatch)
414
417
  if (sourceType === 'OBJECT' && sourcePortDef.tsType && targetPortDef.tsType) {
415
- // If both have tsType and they differ (after normalization), warn about potential mismatch
416
- if (this.normalizeTypeString(sourcePortDef.tsType) !== this.normalizeTypeString(targetPortDef.tsType)) {
417
- this.warnings.push({
418
- type: 'warning',
419
- code: 'OBJECT_TYPE_MISMATCH',
420
- message: `Structural type mismatch: ${fromNode}.${fromPort} outputs "${sourcePortDef.tsType}" but ${toNode}.${toPort} expects "${targetPortDef.tsType}". Verify the object shapes are compatible.`,
421
- connection: conn,
422
- location: connLocation,
423
- });
418
+ const normalizedSource = this.normalizeTypeString(sourcePortDef.tsType);
419
+ const normalizedTarget = this.normalizeTypeString(targetPortDef.tsType);
420
+ if (normalizedSource !== normalizedTarget) {
421
+ // Use string-based compatibility check to suppress false positives (e.g. when one side is 'any')
422
+ const compat = checkTypeCompatibilityFromStrings(sourcePortDef.tsType, targetPortDef.tsType);
423
+ if (!compat.isCompatible) {
424
+ this.warnings.push({
425
+ type: 'warning',
426
+ code: 'OBJECT_TYPE_MISMATCH',
427
+ message: `Structural type mismatch: ${fromNode}.${fromPort} outputs "${sourcePortDef.tsType}" but ${toNode}.${toPort} expects "${targetPortDef.tsType}". Verify the object shapes are compatible.`,
428
+ connection: conn,
429
+ location: connLocation,
430
+ });
431
+ }
424
432
  }
425
433
  }
426
434
  return;
@@ -451,7 +459,7 @@ export class WorkflowValidator {
451
459
  pushTypeIssue({
452
460
  type: 'warning',
453
461
  code: 'LOSSY_TYPE_COERCION',
454
- message: `Lossy type coercion from ${sourceType} to ${targetType} in connection ${fromNode}.${fromPort} → ${toNode}.${toPort}. ${reason}. Add @strictTypes to your workflow annotation to enforce type safety.`,
462
+ message: `Lossy type coercion from ${this.formatType(sourceType, sourceTsType)} to ${this.formatType(targetType, targetTsType)} in connection ${fromNode}.${fromPort} → ${toNode}.${toPort}. ${reason}. Add @strictTypes to your workflow annotation to enforce type safety.`,
455
463
  connection: conn,
456
464
  location: connLocation,
457
465
  }, true);
@@ -474,7 +482,7 @@ export class WorkflowValidator {
474
482
  pushTypeIssue({
475
483
  type: 'warning',
476
484
  code: 'UNUSUAL_TYPE_COERCION',
477
- message: `Unusual type coercion from ${sourceType} to ${targetType} in connection ${fromNode}.${fromPort} → ${toNode}.${toPort}. ${reason}.`,
485
+ message: `Unusual type coercion from ${this.formatType(sourceType, sourceTsType)} to ${this.formatType(targetType, targetTsType)} in connection ${fromNode}.${fromPort} → ${toNode}.${toPort}. ${reason}.`,
478
486
  connection: conn,
479
487
  location: connLocation,
480
488
  }, true);
@@ -485,7 +493,7 @@ export class WorkflowValidator {
485
493
  pushTypeIssue({
486
494
  type: 'warning',
487
495
  code: 'TYPE_MISMATCH',
488
- message: `Type mismatch in connection ${fromNode}.${fromPort} (${sourceType}) → ${toNode}.${toPort} (${targetType}). Runtime coercion will be attempted.`,
496
+ message: `Type mismatch in connection ${fromNode}.${fromPort} (${this.formatType(sourceType, sourceTsType)}) → ${toNode}.${toPort} (${this.formatType(targetType, targetTsType)}). Runtime coercion will be attempted.`,
489
497
  connection: conn,
490
498
  location: connLocation,
491
499
  }, true);
@@ -979,6 +987,31 @@ export class WorkflowValidator {
979
987
  }
980
988
  if (scopeNames.size === 0)
981
989
  continue;
990
+ // Check: connections with scope qualifiers must reference valid scope names on this instance
991
+ for (const conn of workflow.connections) {
992
+ if (conn.from.scope && conn.from.node === instance.id) {
993
+ if (!scopeNames.has(conn.from.scope)) {
994
+ this.errors.push({
995
+ type: 'error',
996
+ code: 'SCOPE_WRONG_SCOPE_NAME',
997
+ message: `Connection from "${instance.id}.${conn.from.port}" uses scope qualifier ":${conn.from.scope}" but node "${instance.id}" does not define scope "${conn.from.scope}". Available scopes: ${[...scopeNames].join(', ')}.`,
998
+ connection: conn,
999
+ location: this.getConnectionLocation(conn),
1000
+ });
1001
+ }
1002
+ }
1003
+ if (conn.to.scope && conn.to.node === instance.id) {
1004
+ if (!scopeNames.has(conn.to.scope)) {
1005
+ this.errors.push({
1006
+ type: 'error',
1007
+ code: 'SCOPE_WRONG_SCOPE_NAME',
1008
+ message: `Connection to "${instance.id}.${conn.to.port}" uses scope qualifier ":${conn.to.scope}" but node "${instance.id}" does not define scope "${conn.to.scope}". Available scopes: ${[...scopeNames].join(', ')}.`,
1009
+ connection: conn,
1010
+ location: this.getConnectionLocation(conn),
1011
+ });
1012
+ }
1013
+ }
1014
+ }
982
1015
  for (const scopeName of scopeNames) {
983
1016
  // Find children in this scope
984
1017
  const childIds = [];
@@ -996,6 +1029,119 @@ export class WorkflowValidator {
996
1029
  (conn.to.scope === scopeName && conn.to.node === instance.id) ||
997
1030
  (conn.from.scope === scopeName && childIds.includes(conn.from.node)) ||
998
1031
  (conn.to.scope === scopeName && childIds.includes(conn.to.node)));
1032
+ // Check: scoped connections reference actual scoped ports on the parent
1033
+ for (const conn of scopedConnections) {
1034
+ if (conn.from.node === instance.id && conn.from.scope === scopeName) {
1035
+ const portDef = nodeType.outputs[conn.from.port];
1036
+ if (!portDef) {
1037
+ const availablePorts = Object.entries(nodeType.outputs)
1038
+ .filter(([, p]) => p.scope === scopeName)
1039
+ .map(([n]) => n);
1040
+ this.errors.push({
1041
+ type: 'error',
1042
+ code: 'SCOPE_UNKNOWN_PORT',
1043
+ message: `Scoped connection references non-existent output port "${conn.from.port}" on "${instance.id}" in scope "${scopeName}". Available scoped outputs: ${availablePorts.join(', ') || 'none'}.`,
1044
+ connection: conn,
1045
+ location: this.getConnectionLocation(conn),
1046
+ });
1047
+ }
1048
+ else if (portDef.scope !== scopeName) {
1049
+ this.errors.push({
1050
+ type: 'error',
1051
+ code: 'SCOPE_UNKNOWN_PORT',
1052
+ message: `Output port "${conn.from.port}" on "${instance.id}" is not a scoped port of scope "${scopeName}"${portDef.scope ? ` (it belongs to scope "${portDef.scope}")` : ' (it is an unscoped port)'}.`,
1053
+ connection: conn,
1054
+ location: this.getConnectionLocation(conn),
1055
+ });
1056
+ }
1057
+ }
1058
+ if (conn.to.node === instance.id && conn.to.scope === scopeName) {
1059
+ const portDef = nodeType.inputs[conn.to.port];
1060
+ if (!portDef) {
1061
+ const availablePorts = Object.entries(nodeType.inputs)
1062
+ .filter(([, p]) => p.scope === scopeName)
1063
+ .map(([n]) => n);
1064
+ this.errors.push({
1065
+ type: 'error',
1066
+ code: 'SCOPE_UNKNOWN_PORT',
1067
+ message: `Scoped connection references non-existent input port "${conn.to.port}" on "${instance.id}" in scope "${scopeName}". Available scoped inputs: ${availablePorts.join(', ') || 'none'}.`,
1068
+ connection: conn,
1069
+ location: this.getConnectionLocation(conn),
1070
+ });
1071
+ }
1072
+ else if (portDef.scope !== scopeName) {
1073
+ this.errors.push({
1074
+ type: 'error',
1075
+ code: 'SCOPE_UNKNOWN_PORT',
1076
+ message: `Input port "${conn.to.port}" on "${instance.id}" is not a scoped port of scope "${scopeName}"${portDef.scope ? ` (it belongs to scope "${portDef.scope}")` : ' (it is an unscoped port)'}.`,
1077
+ connection: conn,
1078
+ location: this.getConnectionLocation(conn),
1079
+ });
1080
+ }
1081
+ }
1082
+ }
1083
+ // Check: scoped connections must stay within the scope boundary
1084
+ for (const conn of scopedConnections) {
1085
+ if (conn.from.scope === scopeName && childIds.includes(conn.from.node)) {
1086
+ if (conn.to.node !== instance.id && !childIds.includes(conn.to.node)) {
1087
+ this.errors.push({
1088
+ type: 'error',
1089
+ code: 'SCOPE_CONNECTION_OUTSIDE',
1090
+ message: `Scoped connection from "${conn.from.node}.${conn.from.port}" targets "${conn.to.node}" which is not inside scope "${scopeName}" of "${instance.id}".`,
1091
+ connection: conn,
1092
+ location: this.getConnectionLocation(conn),
1093
+ });
1094
+ }
1095
+ }
1096
+ if (conn.to.scope === scopeName && childIds.includes(conn.to.node)) {
1097
+ if (conn.from.node !== instance.id && !childIds.includes(conn.from.node)) {
1098
+ this.errors.push({
1099
+ type: 'error',
1100
+ code: 'SCOPE_CONNECTION_OUTSIDE',
1101
+ message: `Scoped connection to "${conn.to.node}.${conn.to.port}" sources from "${conn.from.node}" which is not inside scope "${scopeName}" of "${instance.id}".`,
1102
+ connection: conn,
1103
+ location: this.getConnectionLocation(conn),
1104
+ });
1105
+ }
1106
+ }
1107
+ }
1108
+ // Check: type compatibility for scoped connections between parent and children
1109
+ for (const conn of scopedConnections) {
1110
+ // Parent scoped output -> child input
1111
+ if (conn.from.node === instance.id && conn.from.scope === scopeName) {
1112
+ const parentPort = nodeType.outputs[conn.from.port];
1113
+ const childType = instanceMap.get(conn.to.node);
1114
+ const childPort = childType?.inputs[conn.to.port];
1115
+ if (parentPort && childPort && parentPort.dataType !== 'STEP' && childPort.dataType !== 'STEP') {
1116
+ if (parentPort.dataType !== childPort.dataType && parentPort.dataType !== 'ANY' && childPort.dataType !== 'ANY') {
1117
+ this.warnings.push({
1118
+ type: 'warning',
1119
+ code: 'SCOPE_PORT_TYPE_MISMATCH',
1120
+ message: `Type mismatch in scope "${scopeName}": "${instance.id}.${conn.from.port}" outputs ${this.formatType(parentPort.dataType, parentPort.tsType)} but "${conn.to.node}.${conn.to.port}" expects ${this.formatType(childPort.dataType, childPort.tsType)}.`,
1121
+ connection: conn,
1122
+ location: this.getConnectionLocation(conn),
1123
+ });
1124
+ }
1125
+ }
1126
+ }
1127
+ // Child output -> parent scoped input
1128
+ if (conn.to.node === instance.id && conn.to.scope === scopeName) {
1129
+ const parentPort = nodeType.inputs[conn.to.port];
1130
+ const childType = instanceMap.get(conn.from.node);
1131
+ const childPort = childType?.outputs[conn.from.port];
1132
+ if (parentPort && childPort && parentPort.dataType !== 'STEP' && childPort.dataType !== 'STEP') {
1133
+ if (parentPort.dataType !== childPort.dataType && parentPort.dataType !== 'ANY' && childPort.dataType !== 'ANY') {
1134
+ this.warnings.push({
1135
+ type: 'warning',
1136
+ code: 'SCOPE_PORT_TYPE_MISMATCH',
1137
+ message: `Type mismatch in scope "${scopeName}": "${conn.from.node}.${conn.from.port}" outputs ${this.formatType(childPort.dataType, childPort.tsType)} but "${instance.id}.${conn.to.port}" expects ${this.formatType(parentPort.dataType, parentPort.tsType)}.`,
1138
+ connection: conn,
1139
+ location: this.getConnectionLocation(conn),
1140
+ });
1141
+ }
1142
+ }
1143
+ }
1144
+ }
999
1145
  // Check: each child's required inputs must be satisfied within the scope
1000
1146
  for (const childId of childIds) {
1001
1147
  const childType = instanceMap.get(childId);
@@ -1042,9 +1188,37 @@ export class WorkflowValidator {
1042
1188
  });
1043
1189
  }
1044
1190
  }
1191
+ // Check: each child should have at least one scoped connection to/from the parent
1192
+ for (const childId of childIds) {
1193
+ const hasConnectionFromParent = scopedConnections.some((conn) => conn.from.node === instance.id &&
1194
+ conn.from.scope === scopeName &&
1195
+ conn.to.node === childId);
1196
+ const hasConnectionToParent = scopedConnections.some((conn) => conn.to.node === instance.id &&
1197
+ conn.to.scope === scopeName &&
1198
+ conn.from.node === childId);
1199
+ if (!hasConnectionFromParent && !hasConnectionToParent) {
1200
+ this.warnings.push({
1201
+ type: 'warning',
1202
+ code: 'SCOPE_ORPHANED_CHILD',
1203
+ message: `Child node "${childId}" is declared inside scope "${scopeName}" of "${instance.id}" but has no scoped connections to or from the parent. It is disconnected from the scope's data flow.`,
1204
+ node: childId,
1205
+ location: this.getInstanceLocation(workflow, childId),
1206
+ });
1207
+ }
1208
+ }
1045
1209
  }
1046
1210
  }
1047
1211
  }
1212
+ /**
1213
+ * Format a type for display in error messages.
1214
+ * Prefers the structural TypeScript type when available, falling back to the enum name.
1215
+ */
1216
+ formatType(dataType, tsType) {
1217
+ if (tsType) {
1218
+ return `${tsType} (${dataType})`;
1219
+ }
1220
+ return dataType;
1221
+ }
1048
1222
  normalizeTypeString(type) {
1049
1223
  let n = type;
1050
1224
  // Remove all whitespace
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: Advanced Annotations
3
- description: Pull execution, execution strategies, merge strategies, auto-connect, strict types, path and map sugar, node attributes, and multi-workflow files
4
- keywords: [pullExecution, executeWhen, mergeStrategy, autoConnect, strictTypes, path, map, sugar, attributes, expr, portOrder, portLabel, minimized, multi-workflow, CONJUNCTION, DISJUNCTION, FIRST, LAST, COLLECT, MERGE, CONCAT]
3
+ description: Pull execution, execution strategies, merge strategies, auto-connect, strict types, path, map, fan-out, fan-in, node attributes, and multi-workflow files
4
+ keywords: [pullExecution, executeWhen, mergeStrategy, autoConnect, strictTypes, path, map, fanOut, fanIn, sugar, attributes, expr, portOrder, portLabel, minimized, multi-workflow, CONJUNCTION, DISJUNCTION, FIRST, LAST, COLLECT, MERGE, CONCAT]
5
5
  ---
6
6
 
7
7
  # Advanced Annotations
@@ -178,6 +178,75 @@ This is equivalent to manually writing:
178
178
 
179
179
  ---
180
180
 
181
+ ## Fan-Out / Fan-In (`@fanOut`, `@fanIn`)
182
+
183
+ Fan macros reduce boilerplate when broadcasting a single output to many targets, or merging many sources into a single input. Both expand to individual `@connect` lines during compilation.
184
+
185
+ ### `@fanOut` — One to Many
186
+
187
+ Broadcasts a single output port to multiple targets:
188
+
189
+ ```typescript
190
+ /**
191
+ * @flowWeaver workflow
192
+ * @node a processA
193
+ * @node b processB
194
+ * @node c processC
195
+ *
196
+ * @fanOut Start.data -> a, b, c
197
+ * @connect a.result -> Exit.resultA
198
+ * @connect b.result -> Exit.resultB
199
+ * @connect c.result -> Exit.resultC
200
+ */
201
+ ```
202
+
203
+ This expands to:
204
+ ```
205
+ @connect Start.data -> a.data
206
+ @connect Start.data -> b.data
207
+ @connect Start.data -> c.data
208
+ ```
209
+
210
+ You can specify explicit target ports when the names don't match the source:
211
+
212
+ ```
213
+ @fanOut Start.data -> a.input1, b.input2, c.rawData
214
+ ```
215
+
216
+ Without an explicit port, the target port defaults to the source port name.
217
+
218
+ ### `@fanIn` — Many to One
219
+
220
+ Merges multiple output ports into a single target:
221
+
222
+ ```typescript
223
+ /**
224
+ * @flowWeaver workflow
225
+ * @node a processA
226
+ * @node b processB
227
+ * @node c processC
228
+ * @node agg aggregate
229
+ *
230
+ * @fanIn a.result, b.result, c.result -> agg.items
231
+ * @connect agg.merged -> Exit.result
232
+ */
233
+ ```
234
+
235
+ This expands to:
236
+ ```
237
+ @connect a.result -> agg.items
238
+ @connect b.result -> agg.items
239
+ @connect c.result -> agg.items
240
+ ```
241
+
242
+ The target port should have a `[mergeStrategy:COLLECT]` (or another merge strategy) to combine multiple inputs — otherwise the validator will flag `MULTIPLE_CONNECTIONS_TO_INPUT`.
243
+
244
+ ### Round-Trip Preservation
245
+
246
+ Both macros are preserved through parse-regenerate round-trips. The compiler stores the original macro and regenerates the annotation rather than expanding to individual `@connect` lines.
247
+
248
+ ---
249
+
181
250
  ## Strict Types (`@strictTypes`)
182
251
 
183
252
  By default, type mismatches between connected ports produce warnings. With `@strictTypes`, they become errors.
@@ -657,6 +657,10 @@ These codes apply to AI agent workflows that use LLM, tool-executor, and memory
657
657
  | MISSING_REQUIRED_INPUT | Required input has no connection/default/expression |
658
658
  | CYCLE_DETECTED | Graph contains a loop |
659
659
  | INVALID_EXIT_PORT_TYPE | Exit onSuccess/onFailure is not STEP type |
660
+ | SCOPE_MISSING_REQUIRED_INPUT | Required input port on a scoped child has no connection |
661
+ | SCOPE_WRONG_SCOPE_NAME | Connection uses a scope name not defined on the node |
662
+ | SCOPE_CONNECTION_OUTSIDE | Scoped connection references a node outside the scope |
663
+ | SCOPE_UNKNOWN_PORT | Connection references a port that is not a scoped port of the specified scope |
660
664
  | AGENT_LLM_MISSING_ERROR_HANDLER | LLM node's onFailure port is unconnected |
661
665
  <!-- AUTO:END error_summary_table -->
662
666
 
@@ -679,6 +683,9 @@ These codes apply to AI agent workflows that use LLM, tool-executor, and memory
679
683
  | UNUSED_OUTPUT_PORT | Output port data is discarded |
680
684
  | UNREACHABLE_EXIT_PORT | Exit port has no incoming connection |
681
685
  | MULTIPLE_EXIT_CONNECTIONS | Exit port has multiple sources |
686
+ | SCOPE_UNUSED_INPUT | Scoped input port has no connection from inner nodes |
687
+ | SCOPE_PORT_TYPE_MISMATCH | Type mismatch between scoped port and connected child port |
688
+ | SCOPE_ORPHANED_CHILD | Child node in scope has no scoped connections to parent |
682
689
  | AGENT_UNGUARDED_TOOL_EXECUTOR | Tool executor has no upstream human-approval gate |
683
690
  | AGENT_MISSING_MEMORY_IN_LOOP | Loop has LLM but no conversation memory node |
684
691
  | AGENT_LLM_NO_FALLBACK | LLM onFailure routes directly to Exit |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synergenius/flow-weaver",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Deterministic workflow compiler for AI agents. Compiles to standalone TypeScript, no runtime dependencies.",
5
5
  "private": false,
6
6
  "type": "module",