@synergenius/flow-weaver 0.10.1 → 0.10.3

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/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # @synergenius/flow-weaver
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@synergenius/flow-weaver.svg)](https://www.npmjs.com/package/@synergenius/flow-weaver)
4
- [![License: Flow Weaver Library License](https://img.shields.io/badge/License-Flow%20Weaver%20Library-blue.svg)](./LICENSE)
5
- [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green.svg)](https://nodejs.org)
3
+ [![npm version](https://img.shields.io/npm/v/@synergenius/flow-weaver?style=flat)](https://www.npmjs.com/package/@synergenius/flow-weaver)
4
+ [![npm downloads](https://img.shields.io/npm/dw/@synergenius/flow-weaver?style=flat)](https://www.npmjs.com/package/@synergenius/flow-weaver)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/synergenius-fw/flow-weaver/ci.yml?branch=main&style=flat)](https://github.com/synergenius-fw/flow-weaver/actions/workflows/ci.yml)
6
+ [![Tests](https://img.shields.io/endpoint?url=https://gist.githubusercontent.com/moraispgsi/305430ef59a51d0a58eb61fefdfbe634/raw/flow-weaver-test-count.json&style=flat)](https://github.com/synergenius-fw/flow-weaver/actions/workflows/ci.yml)
7
+ [![Coverage](https://img.shields.io/codecov/c/github/synergenius-fw/flow-weaver?style=flat)](https://codecov.io/gh/synergenius-fw/flow-weaver)
8
+ [![License: Flow Weaver Library License](https://img.shields.io/badge/License-Flow%20Weaver%20Library-blue?style=flat)](./LICENSE)
9
+ [![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green?style=flat)](https://nodejs.org)
6
10
 
7
11
  **Build AI agent workflows visually. Ship them as your own code.**
8
12
 
@@ -459,6 +459,8 @@ export type TConnectionAST = {
459
459
  metadata?: TConnectionMetadata;
460
460
  /** Pre-computed type compatibility (set during parsing when ts-morph Types are available) */
461
461
  typeCompatibility?: TTypeCompatibility;
462
+ /** Explicit type coercion for this connection (e.g., 'number' wraps value with Number()) */
463
+ coerce?: TCoerceTargetType;
462
464
  };
463
465
  /**
464
466
  * Port Reference - Identifies a specific port on a node.
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Parser for @connect declarations using Chevrotain.
5
5
  */
6
+ import type { TCoerceTargetType } from '../ast/types.js';
6
7
  export interface PortReference {
7
8
  nodeId: string;
8
9
  portName: string;
@@ -11,6 +12,10 @@ export interface PortReference {
11
12
  export interface ConnectParseResult {
12
13
  source: PortReference;
13
14
  target: PortReference;
15
+ /** Explicit type coercion (from `as <type>` suffix) */
16
+ coerce?: TCoerceTargetType;
17
+ /** Set when `as <type>` uses an unrecognized type (cleared after warning is emitted) */
18
+ invalidCoerceType?: string;
14
19
  }
15
20
  /**
16
21
  * Parse a @connect line and return structured result.
@@ -4,7 +4,7 @@
4
4
  * Parser for @connect declarations using Chevrotain.
5
5
  */
6
6
  import { CstParser } from 'chevrotain';
7
- import { JSDocLexer, ConnectTag, Identifier, Arrow, Dot, Colon, allTokens } from './tokens.js';
7
+ import { JSDocLexer, ConnectTag, Identifier, Arrow, Dot, Colon, AsKeyword, allTokens } from './tokens.js';
8
8
  // =============================================================================
9
9
  // Parser Definition
10
10
  // =============================================================================
@@ -13,12 +13,16 @@ class ConnectParser extends CstParser {
13
13
  super(allTokens);
14
14
  this.performSelfAnalysis();
15
15
  }
16
- // Entry rule for connect line
16
+ // Entry rule for connect line: @connect A.port -> B.port [as type]
17
17
  connectLine = this.RULE('connectLine', () => {
18
18
  this.CONSUME(ConnectTag);
19
19
  this.SUBRULE(this.portRef, { LABEL: 'sourceRef' });
20
20
  this.CONSUME(Arrow);
21
21
  this.SUBRULE2(this.portRef, { LABEL: 'targetRef' });
22
+ this.OPTION(() => {
23
+ this.CONSUME(AsKeyword);
24
+ this.CONSUME(Identifier, { LABEL: 'coerceType' });
25
+ });
22
26
  });
23
27
  // node.port or node.port:scope
24
28
  portRef = this.RULE('portRef', () => {
@@ -47,7 +51,18 @@ class ConnectVisitor extends BaseVisitor {
47
51
  connectLine(ctx) {
48
52
  const source = this.visit(ctx.sourceRef);
49
53
  const target = this.visit(ctx.targetRef);
50
- return { source, target };
54
+ const result = { source, target };
55
+ if (ctx.coerceType?.[0]) {
56
+ const validTypes = new Set(['string', 'number', 'boolean', 'json', 'object']);
57
+ const raw = ctx.coerceType[0].image;
58
+ if (validTypes.has(raw)) {
59
+ result.coerce = raw;
60
+ }
61
+ else {
62
+ result.invalidCoerceType = raw;
63
+ }
64
+ }
65
+ return result;
51
66
  }
52
67
  portRef(ctx) {
53
68
  const nodeId = ctx.nodeId[0].image;
@@ -87,7 +102,12 @@ export function parseConnectLine(input, warnings) {
87
102
  ` Expected format: @connect sourceNode.port -> targetNode.port`);
88
103
  return null;
89
104
  }
90
- return visitorInstance.visit(cst);
105
+ const result = visitorInstance.visit(cst);
106
+ if (result.invalidCoerceType) {
107
+ warnings.push(`Invalid coerce type "${result.invalidCoerceType}" in @connect. Valid types: string, number, boolean, json, object`);
108
+ delete result.invalidCoerceType;
109
+ }
110
+ return result;
91
111
  }
92
112
  /**
93
113
  * Get serialized grammar for documentation/diagram generation.
@@ -12263,7 +12263,21 @@ var init_theme = __esm({
12263
12263
  });
12264
12264
 
12265
12265
  // src/validator.ts
12266
- var DOCS_BASE, ERROR_DOC_URLS, WorkflowValidator, validator;
12266
+ function suggestCoerceType(targetType) {
12267
+ switch (targetType) {
12268
+ case "STRING":
12269
+ return "string";
12270
+ case "NUMBER":
12271
+ return "number";
12272
+ case "BOOLEAN":
12273
+ return "boolean";
12274
+ case "OBJECT":
12275
+ return "object";
12276
+ default:
12277
+ return "<type>";
12278
+ }
12279
+ }
12280
+ var DOCS_BASE, ERROR_DOC_URLS, COERCE_OUTPUT_TYPE, WorkflowValidator, validator;
12267
12281
  var init_validator = __esm({
12268
12282
  "src/validator.ts"() {
12269
12283
  "use strict";
@@ -12284,7 +12298,17 @@ var init_validator = __esm({
12284
12298
  MISSING_EXIT_CONNECTION: `${DOCS_BASE}/concepts#start-and-exit`,
12285
12299
  INFERRED_NODE_TYPE: `${DOCS_BASE}/node-conversion`,
12286
12300
  DUPLICATE_CONNECTION: `${DOCS_BASE}/concepts#connections`,
12287
- STUB_NODE: `${DOCS_BASE}/model-driven#stub-nodes`
12301
+ STUB_NODE: `${DOCS_BASE}/model-driven#stub-nodes`,
12302
+ COERCE_TYPE_MISMATCH: `${DOCS_BASE}/compilation#type-coercion`,
12303
+ REDUNDANT_COERCE: `${DOCS_BASE}/compilation#type-coercion`,
12304
+ COERCE_ON_FUNCTION_PORT: `${DOCS_BASE}/compilation#type-coercion`
12305
+ };
12306
+ COERCE_OUTPUT_TYPE = {
12307
+ string: "STRING",
12308
+ number: "NUMBER",
12309
+ boolean: "BOOLEAN",
12310
+ json: "STRING",
12311
+ object: "OBJECT"
12288
12312
  };
12289
12313
  WorkflowValidator = class {
12290
12314
  errors = [];
@@ -12710,7 +12734,27 @@ Add '@param ${fromPort}' to the workflow JSDoc and include it in the params obje
12710
12734
  if (sourceType === "STEP" && targetType === "STEP") {
12711
12735
  return;
12712
12736
  }
12737
+ if (conn.coerce && (sourceType === "FUNCTION" || targetType === "FUNCTION")) {
12738
+ this.errors.push({
12739
+ type: "error",
12740
+ code: "COERCE_ON_FUNCTION_PORT",
12741
+ message: `Coercion \`as ${conn.coerce}\` cannot be used on FUNCTION ports in connection "${fromNode}.${fromPort}" \u2192 "${toNode}.${toPort}". FUNCTION values cannot be meaningfully coerced.`,
12742
+ connection: conn,
12743
+ location: connLocation
12744
+ });
12745
+ return;
12746
+ }
12713
12747
  if (sourceType === targetType) {
12748
+ if (conn.coerce) {
12749
+ this.warnings.push({
12750
+ type: "warning",
12751
+ code: "REDUNDANT_COERCE",
12752
+ message: `Coercion \`as ${conn.coerce}\` on connection "${fromNode}.${fromPort}" \u2192 "${toNode}.${toPort}" is redundant \u2014 source and target are both ${sourceType}.`,
12753
+ connection: conn,
12754
+ location: connLocation
12755
+ });
12756
+ return;
12757
+ }
12714
12758
  if (sourceType === "OBJECT" && sourcePortDef.tsType && targetPortDef.tsType) {
12715
12759
  const normalizedSource = this.normalizeTypeString(sourcePortDef.tsType);
12716
12760
  const normalizedTarget = this.normalizeTypeString(targetPortDef.tsType);
@@ -12732,6 +12776,23 @@ Add '@param ${fromPort}' to the workflow JSDoc and include it in the params obje
12732
12776
  if (sourceType === "ANY" || targetType === "ANY") {
12733
12777
  return;
12734
12778
  }
12779
+ if (conn.coerce) {
12780
+ const producedType = COERCE_OUTPUT_TYPE[conn.coerce];
12781
+ if (producedType === targetType) {
12782
+ return;
12783
+ }
12784
+ pushTypeIssue(
12785
+ {
12786
+ type: "warning",
12787
+ code: "COERCE_TYPE_MISMATCH",
12788
+ message: `Coercion \`as ${conn.coerce}\` produces ${producedType} but target port "${toPort}" on "${toNode}" expects ${targetType}. Use \`as ${suggestCoerceType(targetType)}\` instead.`,
12789
+ connection: conn,
12790
+ location: connLocation
12791
+ },
12792
+ true
12793
+ );
12794
+ return;
12795
+ }
12735
12796
  const safeCoercions = [
12736
12797
  ["NUMBER", "STRING"],
12737
12798
  ["BOOLEAN", "STRING"]
@@ -16881,6 +16942,121 @@ var require_browser = __commonJS({
16881
16942
  }
16882
16943
  });
16883
16944
 
16945
+ // node_modules/has-flag/index.js
16946
+ var require_has_flag = __commonJS({
16947
+ "node_modules/has-flag/index.js"(exports2, module2) {
16948
+ "use strict";
16949
+ module2.exports = (flag, argv = process.argv) => {
16950
+ const prefix = flag.startsWith("-") ? "" : flag.length === 1 ? "-" : "--";
16951
+ const position = argv.indexOf(prefix + flag);
16952
+ const terminatorPosition = argv.indexOf("--");
16953
+ return position !== -1 && (terminatorPosition === -1 || position < terminatorPosition);
16954
+ };
16955
+ }
16956
+ });
16957
+
16958
+ // node_modules/supports-color/index.js
16959
+ var require_supports_color = __commonJS({
16960
+ "node_modules/supports-color/index.js"(exports2, module2) {
16961
+ "use strict";
16962
+ var os3 = __require("os");
16963
+ var tty = __require("tty");
16964
+ var hasFlag = require_has_flag();
16965
+ var { env } = process;
16966
+ var forceColor;
16967
+ if (hasFlag("no-color") || hasFlag("no-colors") || hasFlag("color=false") || hasFlag("color=never")) {
16968
+ forceColor = 0;
16969
+ } else if (hasFlag("color") || hasFlag("colors") || hasFlag("color=true") || hasFlag("color=always")) {
16970
+ forceColor = 1;
16971
+ }
16972
+ if ("FORCE_COLOR" in env) {
16973
+ if (env.FORCE_COLOR === "true") {
16974
+ forceColor = 1;
16975
+ } else if (env.FORCE_COLOR === "false") {
16976
+ forceColor = 0;
16977
+ } else {
16978
+ forceColor = env.FORCE_COLOR.length === 0 ? 1 : Math.min(parseInt(env.FORCE_COLOR, 10), 3);
16979
+ }
16980
+ }
16981
+ function translateLevel(level) {
16982
+ if (level === 0) {
16983
+ return false;
16984
+ }
16985
+ return {
16986
+ level,
16987
+ hasBasic: true,
16988
+ has256: level >= 2,
16989
+ has16m: level >= 3
16990
+ };
16991
+ }
16992
+ function supportsColor(haveStream, streamIsTTY) {
16993
+ if (forceColor === 0) {
16994
+ return 0;
16995
+ }
16996
+ if (hasFlag("color=16m") || hasFlag("color=full") || hasFlag("color=truecolor")) {
16997
+ return 3;
16998
+ }
16999
+ if (hasFlag("color=256")) {
17000
+ return 2;
17001
+ }
17002
+ if (haveStream && !streamIsTTY && forceColor === void 0) {
17003
+ return 0;
17004
+ }
17005
+ const min = forceColor || 0;
17006
+ if (env.TERM === "dumb") {
17007
+ return min;
17008
+ }
17009
+ if (process.platform === "win32") {
17010
+ const osRelease = os3.release().split(".");
17011
+ if (Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
17012
+ return Number(osRelease[2]) >= 14931 ? 3 : 2;
17013
+ }
17014
+ return 1;
17015
+ }
17016
+ if ("CI" in env) {
17017
+ if (["TRAVIS", "CIRCLECI", "APPVEYOR", "GITLAB_CI", "GITHUB_ACTIONS", "BUILDKITE"].some((sign) => sign in env) || env.CI_NAME === "codeship") {
17018
+ return 1;
17019
+ }
17020
+ return min;
17021
+ }
17022
+ if ("TEAMCITY_VERSION" in env) {
17023
+ return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(env.TEAMCITY_VERSION) ? 1 : 0;
17024
+ }
17025
+ if (env.COLORTERM === "truecolor") {
17026
+ return 3;
17027
+ }
17028
+ if ("TERM_PROGRAM" in env) {
17029
+ const version3 = parseInt((env.TERM_PROGRAM_VERSION || "").split(".")[0], 10);
17030
+ switch (env.TERM_PROGRAM) {
17031
+ case "iTerm.app":
17032
+ return version3 >= 3 ? 3 : 2;
17033
+ case "Apple_Terminal":
17034
+ return 2;
17035
+ }
17036
+ }
17037
+ if (/-256(color)?$/i.test(env.TERM)) {
17038
+ return 2;
17039
+ }
17040
+ if (/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(env.TERM)) {
17041
+ return 1;
17042
+ }
17043
+ if ("COLORTERM" in env) {
17044
+ return 1;
17045
+ }
17046
+ return min;
17047
+ }
17048
+ function getSupportLevel(stream2) {
17049
+ const level = supportsColor(stream2, stream2 && stream2.isTTY);
17050
+ return translateLevel(level);
17051
+ }
17052
+ module2.exports = {
17053
+ supportsColor: getSupportLevel,
17054
+ stdout: translateLevel(supportsColor(true, tty.isatty(1))),
17055
+ stderr: translateLevel(supportsColor(true, tty.isatty(2)))
17056
+ };
17057
+ }
17058
+ });
17059
+
16884
17060
  // node_modules/debug/src/node.js
16885
17061
  var require_node = __commonJS({
16886
17062
  "node_modules/debug/src/node.js"(exports2, module2) {
@@ -16899,7 +17075,7 @@ var require_node = __commonJS({
16899
17075
  );
16900
17076
  exports2.colors = [6, 2, 3, 4, 5, 1];
16901
17077
  try {
16902
- const supportsColor = __require("supports-color");
17078
+ const supportsColor = require_supports_color();
16903
17079
  if (supportsColor && (supportsColor.stderr || supportsColor).level >= 2) {
16904
17080
  exports2.colors = [
16905
17081
  20,
@@ -28049,6 +28225,43 @@ function generateScopeFunctionClosure(scopeName, parentNodeId, parentNodeType, w
28049
28225
 
28050
28226
  // src/generator/code-utils.ts
28051
28227
  init_type_mappings();
28228
+ var COERCION_EXPRESSIONS = {
28229
+ string: "String",
28230
+ number: "Number",
28231
+ boolean: "Boolean",
28232
+ json: "JSON.stringify",
28233
+ object: "JSON.parse"
28234
+ };
28235
+ function resolveSourcePortDataType(workflow, sourceNodeId, sourcePort) {
28236
+ if (isStartNode(sourceNodeId)) {
28237
+ return workflow.startPorts?.[sourcePort]?.dataType;
28238
+ }
28239
+ if (isExitNode(sourceNodeId)) {
28240
+ return workflow.exitPorts?.[sourcePort]?.dataType;
28241
+ }
28242
+ const instance = workflow.instances.find((i) => i.id === sourceNodeId);
28243
+ if (!instance) return void 0;
28244
+ const nodeType = workflow.nodeTypes.find(
28245
+ (nt) => nt.name === instance.nodeType || nt.functionName === instance.nodeType
28246
+ );
28247
+ if (!nodeType) return void 0;
28248
+ return nodeType.outputs?.[sourcePort]?.dataType;
28249
+ }
28250
+ function getCoercionWrapper(connection, sourceDataType, targetDataType) {
28251
+ if (connection.coerce) {
28252
+ return COERCION_EXPRESSIONS[connection.coerce];
28253
+ }
28254
+ if (!sourceDataType || !targetDataType || sourceDataType === targetDataType) return null;
28255
+ if (sourceDataType === "STEP" || targetDataType === "STEP") return null;
28256
+ if (sourceDataType === "ANY" || targetDataType === "ANY") return null;
28257
+ if (targetDataType === "STRING" && sourceDataType !== "STRING") {
28258
+ return "String";
28259
+ }
28260
+ if (sourceDataType === "BOOLEAN" && targetDataType === "NUMBER") {
28261
+ return "Number";
28262
+ }
28263
+ return null;
28264
+ }
28052
28265
  function toValidIdentifier(nodeId) {
28053
28266
  let sanitized = nodeId.replace(/[^a-zA-Z0-9_$]/g, "_");
28054
28267
  if (/^[0-9]/.test(sanitized)) {
@@ -28178,9 +28391,18 @@ function buildNodeArgumentsWithContext(opts) {
28178
28391
  `${indent}const ${varName} = ${varName}_resolved.fn as ${portType};`
28179
28392
  );
28180
28393
  } else {
28181
- lines.push(
28182
- `${indent}const ${varName} = ${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx}${nonNullAssert} }) as ${portType};`
28183
- );
28394
+ const sourceDataType = resolveSourcePortDataType(workflow, sourceNode, sourcePort);
28395
+ const coerceExpr = getCoercionWrapper(connection, sourceDataType, portConfig.dataType);
28396
+ const getExpr = `${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx}${nonNullAssert} })`;
28397
+ if (coerceExpr) {
28398
+ lines.push(
28399
+ `${indent}const ${varName} = ${coerceExpr}(${getExpr}) as ${portType};`
28400
+ );
28401
+ } else {
28402
+ lines.push(
28403
+ `${indent}const ${varName} = ${getExpr} as ${portType};`
28404
+ );
28405
+ }
28184
28406
  }
28185
28407
  } else {
28186
28408
  const validConnections = connections.filter((conn) => {
@@ -28194,13 +28416,19 @@ function buildNodeArgumentsWithContext(opts) {
28194
28416
  return;
28195
28417
  }
28196
28418
  const attempts = [];
28197
- validConnections.forEach((conn, _idx) => {
28419
+ validConnections.forEach((conn) => {
28198
28420
  const sourceNode = conn.from.node;
28199
28421
  const sourcePort = conn.from.port;
28200
28422
  const sourceIdx = isStartNode(sourceNode) ? "startIdx" : `${toValidIdentifier(sourceNode)}Idx`;
28201
- attempts.push(
28202
- `(${sourceIdx} !== undefined ? ${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx} }) : undefined)`
28203
- );
28423
+ const getExpr = `${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx} })`;
28424
+ if (portConfig.dataType !== "FUNCTION") {
28425
+ const sourceDataType = resolveSourcePortDataType(workflow, sourceNode, sourcePort);
28426
+ const coerceExpr = getCoercionWrapper(conn, sourceDataType, portConfig.dataType);
28427
+ const wrapped = coerceExpr ? `${coerceExpr}(${getExpr})` : getExpr;
28428
+ attempts.push(`(${sourceIdx} !== undefined ? ${wrapped} : undefined)`);
28429
+ } else {
28430
+ attempts.push(`(${sourceIdx} !== undefined ? ${getExpr} : undefined)`);
28431
+ }
28204
28432
  });
28205
28433
  const ternary = attempts.join(" ?? ");
28206
28434
  const portType = mapToTypeScript(portConfig.dataType, portConfig.tsType);
@@ -43430,12 +43658,16 @@ var ConnectParser = class extends CstParser {
43430
43658
  super(allTokens);
43431
43659
  this.performSelfAnalysis();
43432
43660
  }
43433
- // Entry rule for connect line
43661
+ // Entry rule for connect line: @connect A.port -> B.port [as type]
43434
43662
  connectLine = this.RULE("connectLine", () => {
43435
43663
  this.CONSUME(ConnectTag);
43436
43664
  this.SUBRULE(this.portRef, { LABEL: "sourceRef" });
43437
43665
  this.CONSUME(Arrow);
43438
43666
  this.SUBRULE2(this.portRef, { LABEL: "targetRef" });
43667
+ this.OPTION(() => {
43668
+ this.CONSUME(AsKeyword);
43669
+ this.CONSUME(Identifier, { LABEL: "coerceType" });
43670
+ });
43439
43671
  });
43440
43672
  // node.port or node.port:scope
43441
43673
  portRef = this.RULE("portRef", () => {
@@ -43458,7 +43690,17 @@ var ConnectVisitor = class extends BaseVisitor3 {
43458
43690
  connectLine(ctx) {
43459
43691
  const source = this.visit(ctx.sourceRef);
43460
43692
  const target = this.visit(ctx.targetRef);
43461
- return { source, target };
43693
+ const result = { source, target };
43694
+ if (ctx.coerceType?.[0]) {
43695
+ const validTypes = /* @__PURE__ */ new Set(["string", "number", "boolean", "json", "object"]);
43696
+ const raw = ctx.coerceType[0].image;
43697
+ if (validTypes.has(raw)) {
43698
+ result.coerce = raw;
43699
+ } else {
43700
+ result.invalidCoerceType = raw;
43701
+ }
43702
+ }
43703
+ return result;
43462
43704
  }
43463
43705
  portRef(ctx) {
43464
43706
  const nodeId = ctx.nodeId[0].image;
@@ -43492,7 +43734,14 @@ function parseConnectLine(input, warnings) {
43492
43734
  );
43493
43735
  return null;
43494
43736
  }
43495
- return visitorInstance3.visit(cst);
43737
+ const result = visitorInstance3.visit(cst);
43738
+ if (result.invalidCoerceType) {
43739
+ warnings.push(
43740
+ `Invalid coerce type "${result.invalidCoerceType}" in @connect. Valid types: string, number, boolean, json, object`
43741
+ );
43742
+ delete result.invalidCoerceType;
43743
+ }
43744
+ return result;
43496
43745
  }
43497
43746
  function getConnectGrammar() {
43498
43747
  return parserInstance3.getSerializedGastProductions();
@@ -45192,7 +45441,7 @@ var JSDocParser = class {
45192
45441
  warnings.push(`Invalid @connect tag format: @connect ${comment}`);
45193
45442
  return;
45194
45443
  }
45195
- const { source, target } = result;
45444
+ const { source, target, coerce: coerce2 } = result;
45196
45445
  const line = tag.getStartLineNumber();
45197
45446
  config2.connections.push({
45198
45447
  from: {
@@ -45205,7 +45454,8 @@ var JSDocParser = class {
45205
45454
  port: target.portName,
45206
45455
  ...target.scope && { scope: target.scope }
45207
45456
  },
45208
- sourceLocation: { line, column: 0 }
45457
+ sourceLocation: { line, column: 0 },
45458
+ ...coerce2 && { coerce: coerce2 }
45209
45459
  });
45210
45460
  }
45211
45461
  /**
@@ -54170,6 +54420,41 @@ var errorMappers = {
54170
54420
  code: error2.code
54171
54421
  };
54172
54422
  },
54423
+ COERCE_TYPE_MISMATCH(error2) {
54424
+ const coerceMatch = error2.message.match(/`as (\w+)`/);
54425
+ const coerceType = coerceMatch?.[1] || "unknown";
54426
+ const expectsMatch = error2.message.match(/expects (\w+)/);
54427
+ const expectedType = expectsMatch?.[1] || "unknown";
54428
+ const suggestedType = COERCE_TARGET_TYPES[expectedType.toUpperCase()] || expectedType.toLowerCase();
54429
+ return {
54430
+ title: "Wrong Coercion Type",
54431
+ explanation: `The \`as ${coerceType}\` coercion produces the wrong type for the target port. The target expects ${expectedType}.`,
54432
+ fix: `Change \`as ${coerceType}\` to \`as ${suggestedType}\` in the @connect annotation.`,
54433
+ code: error2.code
54434
+ };
54435
+ },
54436
+ REDUNDANT_COERCE(error2) {
54437
+ const coerceMatch = error2.message.match(/`as (\w+)`/);
54438
+ const coerceType = coerceMatch?.[1] || "unknown";
54439
+ const bothMatch = error2.message.match(/both (\w+)/);
54440
+ const dataType = bothMatch?.[1] || "the same type";
54441
+ return {
54442
+ title: "Redundant Coercion",
54443
+ explanation: `The \`as ${coerceType}\` coercion is unnecessary because both the source and target ports are already ${dataType}.`,
54444
+ fix: `Remove \`as ${coerceType}\` from the @connect annotation \u2014 no coercion is needed.`,
54445
+ code: error2.code
54446
+ };
54447
+ },
54448
+ COERCE_ON_FUNCTION_PORT(error2) {
54449
+ const coerceMatch = error2.message.match(/`as (\w+)`/);
54450
+ const coerceType = coerceMatch?.[1] || "unknown";
54451
+ return {
54452
+ title: "Coercion on Function Port",
54453
+ explanation: `The \`as ${coerceType}\` coercion cannot be applied to FUNCTION ports. Function values are callable references and cannot be meaningfully converted to other types.`,
54454
+ fix: `Remove \`as ${coerceType}\` from the @connect annotation. If you need to convert the function's return value, add a transformation node.`,
54455
+ code: error2.code
54456
+ };
54457
+ },
54173
54458
  LOSSY_TYPE_COERCION(error2) {
54174
54459
  const types2 = extractTypes(error2.message);
54175
54460
  const source = types2?.source || "unknown";
@@ -61065,12 +61350,12 @@ function renderConnection(parts2, conn, gradIndex) {
61065
61350
  ` <path d="${conn.path}" fill="none" stroke="url(#conn-grad-${gradIndex})" stroke-width="3"${dashAttr} stroke-linecap="round" data-source="${escapeXml(conn.fromNode)}.${escapeXml(conn.fromPort)}:output" data-target="${escapeXml(conn.toNode)}.${escapeXml(conn.toPort)}:input"/>`
61066
61351
  );
61067
61352
  }
61068
- function renderScopeConnection(parts2, conn, allConnections) {
61353
+ function renderScopeConnection(parts2, conn, allConnections, parentNodeId) {
61069
61354
  const gradIndex = allConnections.indexOf(conn);
61070
61355
  if (gradIndex < 0) return;
61071
61356
  const dashAttr = conn.isStepConnection ? "" : ' stroke-dasharray="8 4"';
61072
61357
  parts2.push(
61073
- ` <path d="${conn.path}" fill="none" stroke="url(#conn-grad-${gradIndex})" stroke-width="2.5"${dashAttr} stroke-linecap="round" data-source="${escapeXml(conn.fromNode)}.${escapeXml(conn.fromPort)}:output" data-target="${escapeXml(conn.toNode)}.${escapeXml(conn.toPort)}:input"/>`
61358
+ ` <path d="${conn.path}" fill="none" stroke="url(#conn-grad-${gradIndex})" stroke-width="2.5"${dashAttr} stroke-linecap="round" data-source="${escapeXml(conn.fromNode)}.${escapeXml(conn.fromPort)}:output" data-target="${escapeXml(conn.toNode)}.${escapeXml(conn.toPort)}:input" data-scope="${escapeXml(parentNodeId)}"/>`
61074
61359
  );
61075
61360
  }
61076
61361
  function renderNodeBody(parts2, node, theme, indent) {
@@ -61114,7 +61399,7 @@ function renderScopedContent(parts2, node, theme, themeName, allConnections) {
61114
61399
  ` <rect x="${scopeX}" y="${scopeY}" width="${scopeW}" height="${scopeH}" rx="4" fill="none" stroke="${theme.scopeAreaStroke}" stroke-width="1" stroke-dasharray="4 2" opacity="0.5"/>`
61115
61400
  );
61116
61401
  for (const conn of node.scopeConnections ?? []) {
61117
- renderScopeConnection(parts2, conn, allConnections);
61402
+ renderScopeConnection(parts2, conn, allConnections, node.id);
61118
61403
  }
61119
61404
  if (scopePorts) {
61120
61405
  renderPortDots(parts2, node.id, scopePorts.inputs, scopePorts.outputs, themeName);
@@ -61662,7 +61947,7 @@ path[data-source].port-hover { opacity: 1; }
61662
61947
  var connIndex = [];
61663
61948
  content.querySelectorAll('path[data-source]').forEach(function(p) {
61664
61949
  var src = p.getAttribute('data-source'), tgt = p.getAttribute('data-target');
61665
- connIndex.push({ el: p, src: src, tgt: tgt, srcNode: src.split('.')[0], tgtNode: tgt.split('.')[0] });
61950
+ connIndex.push({ el: p, src: src, tgt: tgt, srcNode: src.split('.')[0], tgtNode: tgt.split('.')[0], scopeOf: p.getAttribute('data-scope') || null });
61666
61951
  });
61667
61952
 
61668
61953
  // Snapshot of original port positions for reset
@@ -61704,9 +61989,18 @@ path[data-source].port-hover { opacity: 1; }
61704
61989
  off.dx += dx; off.dy += dy;
61705
61990
  var tr = 'translate(' + off.dx + ',' + off.dy + ')';
61706
61991
 
61707
- // Move node group
61992
+ // Move node group (if nested inside a scoped parent, subtract parent offset)
61708
61993
  var nodeG = content.querySelector('.nodes [data-node-id="' + CSS.escape(nodeId) + '"]');
61709
- if (nodeG) nodeG.setAttribute('transform', tr);
61994
+ if (nodeG) {
61995
+ var parentNodeG = nodeG.parentElement ? nodeG.parentElement.closest('[data-node-id]') : null;
61996
+ if (parentNodeG) {
61997
+ var parentId = parentNodeG.getAttribute('data-node-id');
61998
+ var parentOff = nodeOffsets[parentId] || { dx: 0, dy: 0 };
61999
+ nodeG.setAttribute('transform', 'translate(' + (off.dx - parentOff.dx) + ',' + (off.dy - parentOff.dy) + ')');
62000
+ } else {
62001
+ nodeG.setAttribute('transform', tr);
62002
+ }
62003
+ }
61710
62004
 
61711
62005
  // Move label
61712
62006
  var labelG = content.querySelector('[data-label-for="' + CSS.escape(nodeId) + '"]');
@@ -61753,11 +62047,20 @@ path[data-source].port-hover { opacity: 1; }
61753
62047
  });
61754
62048
  }
61755
62049
 
61756
- // Recalculate affected connection paths
62050
+ // Recalculate affected connection paths (skip scope connections when parent is dragged \u2014 they move with the group transform)
61757
62051
  connIndex.forEach(function(c) {
62052
+ if (c.scopeOf === nodeId) return;
61758
62053
  if (c.srcNode === nodeId || c.tgtNode === nodeId) {
61759
62054
  var sp = portPositions[c.src], tp = portPositions[c.tgt];
61760
- if (sp && tp) c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
62055
+ if (sp && tp) {
62056
+ if (c.scopeOf) {
62057
+ // Scope connection paths live inside the parent group; use parent-local coords
62058
+ var pOff = nodeOffsets[c.scopeOf] || { dx: 0, dy: 0 };
62059
+ c.el.setAttribute('d', computeConnectionPath(sp.cx - pOff.dx, sp.cy - pOff.dy, tp.cx - pOff.dx, tp.cy - pOff.dy));
62060
+ } else {
62061
+ c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
62062
+ }
62063
+ }
61761
62064
  }
61762
62065
  if (nodeG) {
61763
62066
  var children = nodeG.querySelectorAll(':scope > g[data-node-id]');
@@ -61765,7 +62068,14 @@ path[data-source].port-hover { opacity: 1; }
61765
62068
  var childId = childG.getAttribute('data-node-id');
61766
62069
  if (c.srcNode === childId || c.tgtNode === childId) {
61767
62070
  var sp = portPositions[c.src], tp = portPositions[c.tgt];
61768
- if (sp && tp) c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
62071
+ if (sp && tp) {
62072
+ if (c.scopeOf) {
62073
+ var pOff = nodeOffsets[c.scopeOf] || { dx: 0, dy: 0 };
62074
+ c.el.setAttribute('d', computeConnectionPath(sp.cx - pOff.dx, sp.cy - pOff.dy, tp.cx - pOff.dx, tp.cy - pOff.dy));
62075
+ } else {
62076
+ c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
62077
+ }
62078
+ }
61769
62079
  }
61770
62080
  });
61771
62081
  }
@@ -96590,7 +96900,7 @@ function displayInstalledPackage(pkg) {
96590
96900
  }
96591
96901
 
96592
96902
  // src/cli/index.ts
96593
- var version2 = true ? "0.10.1" : "0.0.0-dev";
96903
+ var version2 = true ? "0.10.3" : "0.0.0-dev";
96594
96904
  var program2 = new Command();
96595
96905
  program2.name("flow-weaver").description("Flow Weaver Annotations - Compile and validate workflow files").version(version2, "-v, --version", "Output the current version");
96596
96906
  program2.configureOutput({
@@ -481,7 +481,7 @@ path[data-source].port-hover { opacity: 1; }
481
481
  var connIndex = [];
482
482
  content.querySelectorAll('path[data-source]').forEach(function(p) {
483
483
  var src = p.getAttribute('data-source'), tgt = p.getAttribute('data-target');
484
- connIndex.push({ el: p, src: src, tgt: tgt, srcNode: src.split('.')[0], tgtNode: tgt.split('.')[0] });
484
+ connIndex.push({ el: p, src: src, tgt: tgt, srcNode: src.split('.')[0], tgtNode: tgt.split('.')[0], scopeOf: p.getAttribute('data-scope') || null });
485
485
  });
486
486
 
487
487
  // Snapshot of original port positions for reset
@@ -523,9 +523,18 @@ path[data-source].port-hover { opacity: 1; }
523
523
  off.dx += dx; off.dy += dy;
524
524
  var tr = 'translate(' + off.dx + ',' + off.dy + ')';
525
525
 
526
- // Move node group
526
+ // Move node group (if nested inside a scoped parent, subtract parent offset)
527
527
  var nodeG = content.querySelector('.nodes [data-node-id="' + CSS.escape(nodeId) + '"]');
528
- if (nodeG) nodeG.setAttribute('transform', tr);
528
+ if (nodeG) {
529
+ var parentNodeG = nodeG.parentElement ? nodeG.parentElement.closest('[data-node-id]') : null;
530
+ if (parentNodeG) {
531
+ var parentId = parentNodeG.getAttribute('data-node-id');
532
+ var parentOff = nodeOffsets[parentId] || { dx: 0, dy: 0 };
533
+ nodeG.setAttribute('transform', 'translate(' + (off.dx - parentOff.dx) + ',' + (off.dy - parentOff.dy) + ')');
534
+ } else {
535
+ nodeG.setAttribute('transform', tr);
536
+ }
537
+ }
529
538
 
530
539
  // Move label
531
540
  var labelG = content.querySelector('[data-label-for="' + CSS.escape(nodeId) + '"]');
@@ -572,11 +581,20 @@ path[data-source].port-hover { opacity: 1; }
572
581
  });
573
582
  }
574
583
 
575
- // Recalculate affected connection paths
584
+ // Recalculate affected connection paths (skip scope connections when parent is dragged — they move with the group transform)
576
585
  connIndex.forEach(function(c) {
586
+ if (c.scopeOf === nodeId) return;
577
587
  if (c.srcNode === nodeId || c.tgtNode === nodeId) {
578
588
  var sp = portPositions[c.src], tp = portPositions[c.tgt];
579
- if (sp && tp) c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
589
+ if (sp && tp) {
590
+ if (c.scopeOf) {
591
+ // Scope connection paths live inside the parent group; use parent-local coords
592
+ var pOff = nodeOffsets[c.scopeOf] || { dx: 0, dy: 0 };
593
+ c.el.setAttribute('d', computeConnectionPath(sp.cx - pOff.dx, sp.cy - pOff.dy, tp.cx - pOff.dx, tp.cy - pOff.dy));
594
+ } else {
595
+ c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
596
+ }
597
+ }
580
598
  }
581
599
  if (nodeG) {
582
600
  var children = nodeG.querySelectorAll(':scope > g[data-node-id]');
@@ -584,7 +602,14 @@ path[data-source].port-hover { opacity: 1; }
584
602
  var childId = childG.getAttribute('data-node-id');
585
603
  if (c.srcNode === childId || c.tgtNode === childId) {
586
604
  var sp = portPositions[c.src], tp = portPositions[c.tgt];
587
- if (sp && tp) c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
605
+ if (sp && tp) {
606
+ if (c.scopeOf) {
607
+ var pOff = nodeOffsets[c.scopeOf] || { dx: 0, dy: 0 };
608
+ c.el.setAttribute('d', computeConnectionPath(sp.cx - pOff.dx, sp.cy - pOff.dy, tp.cx - pOff.dx, tp.cy - pOff.dy));
609
+ } else {
610
+ c.el.setAttribute('d', computeConnectionPath(sp.cx, sp.cy, tp.cx, tp.cy));
611
+ }
612
+ }
588
613
  }
589
614
  });
590
615
  }
@@ -100,12 +100,12 @@ function renderConnection(parts, conn, gradIndex) {
100
100
  const dashAttr = conn.isStepConnection ? '' : ' stroke-dasharray="8 4"';
101
101
  parts.push(` <path d="${conn.path}" fill="none" stroke="url(#conn-grad-${gradIndex})" stroke-width="3"${dashAttr} stroke-linecap="round" data-source="${escapeXml(conn.fromNode)}.${escapeXml(conn.fromPort)}:output" data-target="${escapeXml(conn.toNode)}.${escapeXml(conn.toPort)}:input"/>`);
102
102
  }
103
- function renderScopeConnection(parts, conn, allConnections) {
103
+ function renderScopeConnection(parts, conn, allConnections, parentNodeId) {
104
104
  const gradIndex = allConnections.indexOf(conn);
105
105
  if (gradIndex < 0)
106
106
  return;
107
107
  const dashAttr = conn.isStepConnection ? '' : ' stroke-dasharray="8 4"';
108
- parts.push(` <path d="${conn.path}" fill="none" stroke="url(#conn-grad-${gradIndex})" stroke-width="2.5"${dashAttr} stroke-linecap="round" data-source="${escapeXml(conn.fromNode)}.${escapeXml(conn.fromPort)}:output" data-target="${escapeXml(conn.toNode)}.${escapeXml(conn.toPort)}:input"/>`);
108
+ parts.push(` <path d="${conn.path}" fill="none" stroke="url(#conn-grad-${gradIndex})" stroke-width="2.5"${dashAttr} stroke-linecap="round" data-source="${escapeXml(conn.fromNode)}.${escapeXml(conn.fromPort)}:output" data-target="${escapeXml(conn.toNode)}.${escapeXml(conn.toPort)}:input" data-scope="${escapeXml(parentNodeId)}"/>`);
109
109
  }
110
110
  // ---- Node rendering ----
111
111
  /** Render node body rect + icon */
@@ -147,7 +147,7 @@ function renderScopedContent(parts, node, theme, themeName, allConnections) {
147
147
  parts.push(` <rect x="${scopeX}" y="${scopeY}" width="${scopeW}" height="${scopeH}" rx="4" fill="none" stroke="${theme.scopeAreaStroke}" stroke-width="1" stroke-dasharray="4 2" opacity="0.5"/>`);
148
148
  // Scope connections (before ports so ports appear on top)
149
149
  for (const conn of node.scopeConnections ?? []) {
150
- renderScopeConnection(parts, conn, allConnections);
150
+ renderScopeConnection(parts, conn, allConnections, node.id);
151
151
  }
152
152
  // Scope port dots (before children so dots sit on top of connections)
153
153
  if (scopePorts) {
@@ -505,6 +505,41 @@ const errorMappers = {
505
505
  code: error.code,
506
506
  };
507
507
  },
508
+ COERCE_TYPE_MISMATCH(error) {
509
+ const coerceMatch = error.message.match(/`as (\w+)`/);
510
+ const coerceType = coerceMatch?.[1] || 'unknown';
511
+ const expectsMatch = error.message.match(/expects (\w+)/);
512
+ const expectedType = expectsMatch?.[1] || 'unknown';
513
+ const suggestedType = COERCE_TARGET_TYPES[expectedType.toUpperCase()] || expectedType.toLowerCase();
514
+ return {
515
+ title: 'Wrong Coercion Type',
516
+ explanation: `The \`as ${coerceType}\` coercion produces the wrong type for the target port. The target expects ${expectedType}.`,
517
+ fix: `Change \`as ${coerceType}\` to \`as ${suggestedType}\` in the @connect annotation.`,
518
+ code: error.code,
519
+ };
520
+ },
521
+ REDUNDANT_COERCE(error) {
522
+ const coerceMatch = error.message.match(/`as (\w+)`/);
523
+ const coerceType = coerceMatch?.[1] || 'unknown';
524
+ const bothMatch = error.message.match(/both (\w+)/);
525
+ const dataType = bothMatch?.[1] || 'the same type';
526
+ return {
527
+ title: 'Redundant Coercion',
528
+ explanation: `The \`as ${coerceType}\` coercion is unnecessary because both the source and target ports are already ${dataType}.`,
529
+ fix: `Remove \`as ${coerceType}\` from the @connect annotation — no coercion is needed.`,
530
+ code: error.code,
531
+ };
532
+ },
533
+ COERCE_ON_FUNCTION_PORT(error) {
534
+ const coerceMatch = error.message.match(/`as (\w+)`/);
535
+ const coerceType = coerceMatch?.[1] || 'unknown';
536
+ return {
537
+ title: 'Coercion on Function Port',
538
+ explanation: `The \`as ${coerceType}\` coercion cannot be applied to FUNCTION ports. Function values are callable references and cannot be meaningfully converted to other types.`,
539
+ fix: `Remove \`as ${coerceType}\` from the @connect annotation. If you need to convert the function's return value, add a transformation node.`,
540
+ code: error.code,
541
+ };
542
+ },
508
543
  LOSSY_TYPE_COERCION(error) {
509
544
  const types = extractTypes(error.message);
510
545
  const source = types?.source || 'unknown';
@@ -1,4 +1,15 @@
1
- import type { TNodeTypeAST, TWorkflowAST, TMergeStrategy } from '../ast/index.js';
1
+ import type { TNodeTypeAST, TWorkflowAST, TMergeStrategy, TConnectionAST, TDataType } from '../ast/index.js';
2
+ /**
3
+ * Get the coercion expression to wrap a value, if coercion is needed.
4
+ * Returns null if no coercion needed.
5
+ *
6
+ * Priority:
7
+ * 1. Explicit coerce on the connection (from `as <type>` annotation)
8
+ * 2. Auto-coercion for safe pairs:
9
+ * - anything -> STRING (String() never fails)
10
+ * - BOOLEAN -> NUMBER (well-defined: false->0, true->1)
11
+ */
12
+ export declare function getCoercionWrapper(connection: TConnectionAST, sourceDataType: TDataType | undefined, targetDataType: TDataType | undefined): string | null;
2
13
  /**
3
14
  * Sanitize a node ID to be a valid JavaScript identifier.
4
15
  * Replaces non-alphanumeric characters (except _ and $) with underscores.
@@ -1,6 +1,72 @@
1
1
  import { RESERVED_PORT_NAMES, isStartNode, isExitNode, isExecutePort, isSuccessPort, isFailurePort, } from '../constants.js';
2
2
  import { generateScopeFunctionClosure } from './scope-function-generator.js';
3
3
  import { mapToTypeScript } from '../type-mappings.js';
4
+ /** Map coercion target type to inline JS expression */
5
+ const COERCION_EXPRESSIONS = {
6
+ string: 'String',
7
+ number: 'Number',
8
+ boolean: 'Boolean',
9
+ json: 'JSON.stringify',
10
+ object: 'JSON.parse',
11
+ };
12
+ /** Map TDataType to TCoerceTargetType for auto-coercion */
13
+ const DATATYPE_TO_COERCE = {
14
+ STRING: 'string',
15
+ NUMBER: 'number',
16
+ BOOLEAN: 'boolean',
17
+ OBJECT: 'object',
18
+ };
19
+ /**
20
+ * Resolve the dataType of a source port by looking up the node instance -> node type -> outputs.
21
+ */
22
+ function resolveSourcePortDataType(workflow, sourceNodeId, sourcePort) {
23
+ if (isStartNode(sourceNodeId)) {
24
+ return workflow.startPorts?.[sourcePort]?.dataType;
25
+ }
26
+ if (isExitNode(sourceNodeId)) {
27
+ return workflow.exitPorts?.[sourcePort]?.dataType;
28
+ }
29
+ const instance = workflow.instances.find((i) => i.id === sourceNodeId);
30
+ if (!instance)
31
+ return undefined;
32
+ const nodeType = workflow.nodeTypes.find((nt) => nt.name === instance.nodeType || nt.functionName === instance.nodeType);
33
+ if (!nodeType)
34
+ return undefined;
35
+ return nodeType.outputs?.[sourcePort]?.dataType;
36
+ }
37
+ /**
38
+ * Get the coercion expression to wrap a value, if coercion is needed.
39
+ * Returns null if no coercion needed.
40
+ *
41
+ * Priority:
42
+ * 1. Explicit coerce on the connection (from `as <type>` annotation)
43
+ * 2. Auto-coercion for safe pairs:
44
+ * - anything -> STRING (String() never fails)
45
+ * - BOOLEAN -> NUMBER (well-defined: false->0, true->1)
46
+ */
47
+ export function getCoercionWrapper(connection, sourceDataType, targetDataType) {
48
+ // Explicit coerce on connection
49
+ if (connection.coerce) {
50
+ return COERCION_EXPRESSIONS[connection.coerce];
51
+ }
52
+ // No auto-coercion if types are unknown or same
53
+ if (!sourceDataType || !targetDataType || sourceDataType === targetDataType)
54
+ return null;
55
+ // Skip STEP and ANY ports — no coercion needed
56
+ if (sourceDataType === 'STEP' || targetDataType === 'STEP')
57
+ return null;
58
+ if (sourceDataType === 'ANY' || targetDataType === 'ANY')
59
+ return null;
60
+ // Auto-coerce: anything -> STRING
61
+ if (targetDataType === 'STRING' && sourceDataType !== 'STRING') {
62
+ return 'String';
63
+ }
64
+ // Auto-coerce: BOOLEAN -> NUMBER
65
+ if (sourceDataType === 'BOOLEAN' && targetDataType === 'NUMBER') {
66
+ return 'Number';
67
+ }
68
+ return null;
69
+ }
4
70
  /**
5
71
  * Sanitize a node ID to be a valid JavaScript identifier.
6
72
  * Replaces non-alphanumeric characters (except _ and $) with underscores.
@@ -197,7 +263,16 @@ export function buildNodeArgumentsWithContext(opts) {
197
263
  lines.push(`${indent}const ${varName} = ${varName}_resolved.fn as ${portType};`);
198
264
  }
199
265
  else {
200
- lines.push(`${indent}const ${varName} = ${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx}${nonNullAssert} }) as ${portType};`);
266
+ // Check for coercion (explicit or auto)
267
+ const sourceDataType = resolveSourcePortDataType(workflow, sourceNode, sourcePort);
268
+ const coerceExpr = getCoercionWrapper(connection, sourceDataType, portConfig.dataType);
269
+ const getExpr = `${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx}${nonNullAssert} })`;
270
+ if (coerceExpr) {
271
+ lines.push(`${indent}const ${varName} = ${coerceExpr}(${getExpr}) as ${portType};`);
272
+ }
273
+ else {
274
+ lines.push(`${indent}const ${varName} = ${getExpr} as ${portType};`);
275
+ }
201
276
  }
202
277
  }
203
278
  else {
@@ -214,11 +289,21 @@ export function buildNodeArgumentsWithContext(opts) {
214
289
  return;
215
290
  }
216
291
  const attempts = [];
217
- validConnections.forEach((conn, _idx) => {
292
+ validConnections.forEach((conn) => {
218
293
  const sourceNode = conn.from.node;
219
294
  const sourcePort = conn.from.port;
220
295
  const sourceIdx = isStartNode(sourceNode) ? 'startIdx' : `${toValidIdentifier(sourceNode)}Idx`;
221
- attempts.push(`(${sourceIdx} !== undefined ? ${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx} }) : undefined)`);
296
+ const getExpr = `${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx} })`;
297
+ // Per-connection coercion: each source gets its own coercion wrapper
298
+ if (portConfig.dataType !== 'FUNCTION') {
299
+ const sourceDataType = resolveSourcePortDataType(workflow, sourceNode, sourcePort);
300
+ const coerceExpr = getCoercionWrapper(conn, sourceDataType, portConfig.dataType);
301
+ const wrapped = coerceExpr ? `${coerceExpr}(${getExpr})` : getExpr;
302
+ attempts.push(`(${sourceIdx} !== undefined ? ${wrapped} : undefined)`);
303
+ }
304
+ else {
305
+ attempts.push(`(${sourceIdx} !== undefined ? ${getExpr} : undefined)`);
306
+ }
222
307
  });
223
308
  const ternary = attempts.join(' ?? ');
224
309
  const portType = mapToTypeScript(portConfig.dataType, portConfig.tsType);
@@ -889,7 +889,7 @@ export class JSDocParser {
889
889
  warnings.push(`Invalid @connect tag format: @connect ${comment}`);
890
890
  return;
891
891
  }
892
- const { source, target } = result;
892
+ const { source, target, coerce } = result;
893
893
  // Capture source location from tag
894
894
  const line = tag.getStartLineNumber();
895
895
  config.connections.push({
@@ -904,6 +904,7 @@ export class JSDocParser {
904
904
  ...(target.scope && { scope: target.scope }),
905
905
  },
906
906
  sourceLocation: { line, column: 0 },
907
+ ...(coerce && { coerce }),
907
908
  });
908
909
  }
909
910
  /**
package/dist/validator.js CHANGED
@@ -17,7 +17,25 @@ const ERROR_DOC_URLS = {
17
17
  INFERRED_NODE_TYPE: `${DOCS_BASE}/node-conversion`,
18
18
  DUPLICATE_CONNECTION: `${DOCS_BASE}/concepts#connections`,
19
19
  STUB_NODE: `${DOCS_BASE}/model-driven#stub-nodes`,
20
+ COERCE_TYPE_MISMATCH: `${DOCS_BASE}/compilation#type-coercion`,
21
+ REDUNDANT_COERCE: `${DOCS_BASE}/compilation#type-coercion`,
22
+ COERCE_ON_FUNCTION_PORT: `${DOCS_BASE}/compilation#type-coercion`,
20
23
  };
24
+ /** Map coerce type to the dataType it produces */
25
+ const COERCE_OUTPUT_TYPE = {
26
+ string: 'STRING', number: 'NUMBER', boolean: 'BOOLEAN',
27
+ json: 'STRING', object: 'OBJECT',
28
+ };
29
+ /** Suggest the correct `as <type>` for a given target dataType */
30
+ function suggestCoerceType(targetType) {
31
+ switch (targetType) {
32
+ case 'STRING': return 'string';
33
+ case 'NUMBER': return 'number';
34
+ case 'BOOLEAN': return 'boolean';
35
+ case 'OBJECT': return 'object';
36
+ default: return '<type>';
37
+ }
38
+ }
21
39
  export class WorkflowValidator {
22
40
  errors = [];
23
41
  warnings = [];
@@ -478,8 +496,30 @@ export class WorkflowValidator {
478
496
  if (sourceType === 'STEP' && targetType === 'STEP') {
479
497
  return;
480
498
  }
499
+ // Block coercion on FUNCTION ports — coercing a function value is nonsensical
500
+ if (conn.coerce && (sourceType === 'FUNCTION' || targetType === 'FUNCTION')) {
501
+ this.errors.push({
502
+ type: 'error',
503
+ code: 'COERCE_ON_FUNCTION_PORT',
504
+ message: `Coercion \`as ${conn.coerce}\` cannot be used on FUNCTION ports in connection "${fromNode}.${fromPort}" → "${toNode}.${toPort}". FUNCTION values cannot be meaningfully coerced.`,
505
+ connection: conn,
506
+ location: connLocation,
507
+ });
508
+ return;
509
+ }
481
510
  // Same type - check for structural compatibility if both are OBJECT
482
511
  if (sourceType === targetType) {
512
+ // Redundant coerce: same types don't need coercion
513
+ if (conn.coerce) {
514
+ this.warnings.push({
515
+ type: 'warning',
516
+ code: 'REDUNDANT_COERCE',
517
+ message: `Coercion \`as ${conn.coerce}\` on connection "${fromNode}.${fromPort}" → "${toNode}.${toPort}" is redundant — source and target are both ${sourceType}.`,
518
+ connection: conn,
519
+ location: connLocation,
520
+ });
521
+ return;
522
+ }
483
523
  // For OBJECT types, check if tsType differs (structural mismatch)
484
524
  if (sourceType === 'OBJECT' && sourcePortDef.tsType && targetPortDef.tsType) {
485
525
  const normalizedSource = this.normalizeTypeString(sourcePortDef.tsType);
@@ -504,6 +544,22 @@ export class WorkflowValidator {
504
544
  if (sourceType === 'ANY' || targetType === 'ANY') {
505
545
  return;
506
546
  }
547
+ // Validate explicit coerce: check that produced type matches target
548
+ if (conn.coerce) {
549
+ const producedType = COERCE_OUTPUT_TYPE[conn.coerce];
550
+ if (producedType === targetType) {
551
+ return; // Coerce correctly resolves the mismatch
552
+ }
553
+ // Wrong coerce type — produced type doesn't match target
554
+ pushTypeIssue({
555
+ type: 'warning',
556
+ code: 'COERCE_TYPE_MISMATCH',
557
+ message: `Coercion \`as ${conn.coerce}\` produces ${producedType} but target port "${toPort}" on "${toNode}" expects ${targetType}. Use \`as ${suggestCoerceType(targetType)}\` instead.`,
558
+ connection: conn,
559
+ location: connLocation,
560
+ }, true);
561
+ return;
562
+ }
507
563
  // Safe coercions (no warning)
508
564
  const safeCoercions = [
509
565
  ['NUMBER', 'STRING'],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synergenius/flow-weaver",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "Deterministic workflow compiler for AI agents. Compiles to standalone TypeScript, no runtime dependencies.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -145,6 +145,7 @@
145
145
  "@types/js-yaml": "^4.0.9",
146
146
  "@types/node": "^20.11.0",
147
147
  "@types/react": "^19.0.0",
148
+ "@vitest/coverage-v8": "^4.0.18",
148
149
  "esbuild": "^0.27.2",
149
150
  "prettier": "^3.1.1",
150
151
  "rimraf": "6.1.2",