@synergenius/flow-weaver 0.10.2 → 0.10.4

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.
@@ -8,6 +8,14 @@
8
8
  export interface HtmlViewerOptions {
9
9
  title?: string;
10
10
  theme?: 'dark' | 'light';
11
+ nodeSources?: Record<string, {
12
+ description?: string;
13
+ source?: string;
14
+ ports?: Record<string, {
15
+ type: string;
16
+ tsType?: string;
17
+ }>;
18
+ }>;
11
19
  }
12
20
  export declare function wrapSVGInHTML(svgContent: string, options?: HtmlViewerOptions): string;
13
21
  //# sourceMappingURL=html-viewer.d.ts.map
@@ -122,24 +122,46 @@ path[data-source].port-hover { opacity: 1; }
122
122
  /* Info panel */
123
123
  #info-panel {
124
124
  position: fixed; bottom: 52px; left: 16px;
125
- max-width: 320px; min-width: 200px;
125
+ max-width: 480px; min-width: 260px;
126
126
  background: ${surfaceMain}; border: 1px solid ${borderSubtle};
127
127
  border-radius: 8px; padding: 12px 16px;
128
128
  font-size: 13px; line-height: 1.5;
129
129
  box-shadow: 0 2px 8px rgba(0,0,0,0.2);
130
130
  z-index: 10; display: none;
131
+ max-height: calc(100vh - 120px); overflow-y: auto;
131
132
  }
132
133
  #info-panel.visible { display: block; }
133
134
  #info-panel h3 {
134
135
  font-size: 14px; font-weight: 700; margin-bottom: 6px;
135
136
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
136
137
  }
138
+ #info-panel .node-desc { color: ${textMed}; font-size: 12px; margin-bottom: 8px; font-style: italic; }
137
139
  #info-panel .info-section { margin-bottom: 6px; }
138
140
  #info-panel .info-label { font-size: 11px; font-weight: 600; color: ${textLow}; text-transform: uppercase; letter-spacing: 0.5px; }
139
141
  #info-panel .info-value { color: ${textMed}; }
140
142
  #info-panel .port-list { list-style: none; padding: 0; }
141
143
  #info-panel .port-list li { padding: 1px 0; }
142
144
  #info-panel .port-list li::before { content: '\\2022'; margin-right: 6px; color: ${textLow}; }
145
+ #info-panel .port-type { color: ${textLow}; font-size: 11px; margin-left: 4px; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
146
+ #info-panel pre {
147
+ background: ${isDark ? '#161625' : '#f0f1fa'}; border: 1px solid ${borderSubtle};
148
+ border-radius: 6px; padding: 10px; overflow-x: auto;
149
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
150
+ font-size: 12px; line-height: 1.6; white-space: pre;
151
+ max-height: 300px; overflow-y: auto; margin: 6px 0 0;
152
+ color: ${isDark ? '#e6edf3' : '#1a2340'};
153
+ }
154
+ .hl-kw { color: ${isDark ? '#8e9eff' : '#4040bf'}; }
155
+ .hl-str { color: ${isDark ? '#ff7b72' : '#c4432b'}; }
156
+ .hl-num { color: ${isDark ? '#f0a050' : '#b35e14'}; }
157
+ .hl-cm { color: #6a737d; font-style: italic; }
158
+ .hl-fn { color: ${isDark ? '#d2a8ff' : '#7c3aed'}; }
159
+ .hl-ty { color: ${isDark ? '#d2a8ff' : '#7c3aed'}; }
160
+ .hl-pn { color: ${isDark ? '#b8bdd0' : '#4a5578'}; }
161
+ .hl-ann { color: ${isDark ? '#8e9eff' : '#4040bf'}; font-weight: 600; font-style: normal; }
162
+ .hl-arr { color: ${isDark ? '#79c0ff' : '#0969da'}; font-weight: 600; font-style: normal; }
163
+ .hl-id { color: ${isDark ? '#e6edf3' : '#1a2340'}; font-style: normal; }
164
+ .hl-sc { color: ${isDark ? '#d2a8ff' : '#7c3aed'}; font-style: italic; }
143
165
 
144
166
  /* Branding badge */
145
167
  #branding {
@@ -194,8 +216,8 @@ path[data-source].port-hover { opacity: 1; }
194
216
  <circle cx="10" cy="10" r="1.5" fill="${dotColor}" opacity="0.6"/>
195
217
  </pattern>
196
218
  </defs>
197
- <rect x="-100000" y="-100000" width="200000" height="200000" fill="${bg}"/>
198
- <rect x="-100000" y="-100000" width="200000" height="200000" fill="url(#viewer-dots)"/>
219
+ <rect x="-100000" y="-100000" width="200000" height="200000" fill="${bg}" pointer-events="none"/>
220
+ <rect x="-100000" y="-100000" width="200000" height="200000" fill="url(#viewer-dots)" pointer-events="none"/>
199
221
  <g id="diagram">${inner}</g>
200
222
  </svg>
201
223
  <div id="controls">
@@ -225,6 +247,7 @@ path[data-source].port-hover { opacity: 1; }
225
247
  </a>
226
248
  <div id="scroll-hint">Use <kbd id="mod-key">Ctrl</kbd> + scroll to zoom</div>
227
249
  <div id="studio-hint">Like rearranging? <a href="https://flowweaver.ai" target="_blank" rel="noopener">Flow Weaver Studio</a> saves your layouts.</div>
250
+ <script>var nodeSources = ${JSON.stringify(options.nodeSources ?? {})};</script>
228
251
  <script>
229
252
  (function() {
230
253
  'use strict';
@@ -309,6 +332,7 @@ path[data-source].port-hover { opacity: 1; }
309
332
 
310
333
  // ---- Pan (drag) + Node drag ----
311
334
  var draggedNodeId = null, dragNodeStart = null, didDragNode = false;
335
+ var clickTarget = null; // stash the real target before setPointerCapture steals it
312
336
  var dragCount = 0, nudgeIndex = 0, nudgeTimer = null;
313
337
  var nudgeMessages = [
314
338
  'Like rearranging? <a href="https://flowweaver.ai" target="_blank" rel="noopener">Flow Weaver Studio</a> saves your layouts.',
@@ -323,6 +347,8 @@ path[data-source].port-hover { opacity: 1; }
323
347
 
324
348
  canvas.addEventListener('pointerdown', function(e) {
325
349
  if (e.button !== 0) return;
350
+ clickTarget = e.target; // stash before setPointerCapture redirects events
351
+ didDrag = false;
326
352
  // Check if clicking on a node body (walk up to detect data-node-id)
327
353
  var t = e.target;
328
354
  while (t && t !== canvas) {
@@ -339,7 +365,6 @@ path[data-source].port-hover { opacity: 1; }
339
365
  }
340
366
  // Canvas pan
341
367
  pointerDown = true;
342
- didDrag = false;
343
368
  dragLast = { x: e.clientX, y: e.clientY };
344
369
  canvas.setPointerCapture(e.pointerId);
345
370
  });
@@ -792,14 +817,28 @@ path[data-source].port-hover { opacity: 1; }
792
817
  // Build info panel
793
818
  infoTitle.textContent = labelText;
794
819
  var html = '';
820
+ var src = nodeSources[nodeId];
821
+ if (src && src.description) {
822
+ html += '<div class="node-desc">' + escapeH(src.description) + '</div>';
823
+ }
824
+ var portInfo = (src && src.ports) ? src.ports : {};
825
+ function portLabel(name) {
826
+ var p = portInfo[name];
827
+ var label = escapeH(name);
828
+ if (p) {
829
+ var typeStr = p.tsType || p.type;
830
+ if (typeStr) label += ' <span class="port-type">' + escapeH(typeStr) + '</span>';
831
+ }
832
+ return label;
833
+ }
795
834
  if (inputs.length) {
796
835
  html += '<div class="info-section"><div class="info-label">Inputs</div><ul class="port-list">';
797
- inputs.forEach(function(n) { html += '<li>' + escapeH(n) + '</li>'; });
836
+ inputs.forEach(function(n) { html += '<li>' + portLabel(n) + '</li>'; });
798
837
  html += '</ul></div>';
799
838
  }
800
839
  if (outputs.length) {
801
840
  html += '<div class="info-section"><div class="info-label">Outputs</div><ul class="port-list">';
802
- outputs.forEach(function(n) { html += '<li>' + escapeH(n) + '</li>'; });
841
+ outputs.forEach(function(n) { html += '<li>' + portLabel(n) + '</li>'; });
803
842
  html += '</ul></div>';
804
843
  }
805
844
  if (connectedNodes.size) {
@@ -807,6 +846,10 @@ path[data-source].port-hover { opacity: 1; }
807
846
  html += Array.from(connectedNodes).map(escapeH).join(', ');
808
847
  html += '</div></div>';
809
848
  }
849
+ if (src && src.source) {
850
+ html += '<div class="info-section"><div class="info-label">Source</div>';
851
+ html += '<pre><code>' + highlightTS(src.source) + '</code></pre></div>';
852
+ }
810
853
  infoBody.innerHTML = html;
811
854
  infoPanel.classList.add('visible');
812
855
  }
@@ -815,10 +858,130 @@ path[data-source].port-hover { opacity: 1; }
815
858
  return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
816
859
  }
817
860
 
861
+ var fwAnnotations = 'flowWeaver,input,output,step,node,connect,param,returns,fwImport,label,scope,position,color,icon,tag,map,path,name,description,expression,executeWhen,pullExecution,strictTypes,autoConnect,port,trigger,cancelOn,retries,timeout,throttle';
862
+
863
+ function highlightJSDoc(block) {
864
+ var annSet = fwAnnotations.split(',');
865
+ var out = '';
866
+ var re = /(@[a-zA-Z]+)|(-&gt;)|(\\.[a-zA-Z_][a-zA-Z0-9_]*)|("[^"]*")|('\\''[^'\\'']*'\\'')|(-?[0-9]+(?:\\.[0-9]+)?)|([a-zA-Z_][a-zA-Z0-9_]*)|([^@a-zA-Z0-9"'\\-.]+)/g;
867
+ var m;
868
+ while ((m = re.exec(block)) !== null) {
869
+ if (m[1]) {
870
+ var tag = m[1].slice(1);
871
+ if (annSet.indexOf(tag) >= 0) {
872
+ out += '<span class="hl-ann">' + m[0] + '</span>';
873
+ } else {
874
+ out += '<span class="hl-ann">' + m[0] + '</span>';
875
+ }
876
+ } else if (m[2]) {
877
+ out += '<span class="hl-arr">' + m[2] + '</span>';
878
+ } else if (m[3]) {
879
+ // .portName scope reference
880
+ out += '<span class="hl-sc">' + m[3] + '</span>';
881
+ } else if (m[4] || m[5]) {
882
+ out += '<span class="hl-str">' + (m[4] || m[5]) + '</span>';
883
+ } else if (m[6]) {
884
+ out += '<span class="hl-num">' + m[6] + '</span>';
885
+ } else if (m[7]) {
886
+ var tys = 'string,number,boolean,any,void,never,unknown,STEP,STRING,NUMBER,BOOLEAN,ARRAY,OBJECT,FUNCTION,ANY';
887
+ if (tys.split(',').indexOf(m[7]) >= 0) {
888
+ out += '<span class="hl-ty">' + m[7] + '</span>';
889
+ } else {
890
+ out += '<span class="hl-id">' + m[7] + '</span>';
891
+ }
892
+ } else {
893
+ out += m[0];
894
+ }
895
+ }
896
+ return out;
897
+ }
898
+
899
+ function highlightTS(code) {
900
+ var tokens = [];
901
+ var i = 0;
902
+ while (i < code.length) {
903
+ // Line comments
904
+ if (code[i] === '/' && code[i+1] === '/') {
905
+ var end = code.indexOf('\\n', i);
906
+ if (end === -1) end = code.length;
907
+ tokens.push({ t: 'cm', v: code.slice(i, end) });
908
+ i = end;
909
+ continue;
910
+ }
911
+ // Block comments (detect JSDoc for annotation highlighting)
912
+ if (code[i] === '/' && code[i+1] === '*') {
913
+ var end = code.indexOf('*/', i + 2);
914
+ if (end === -1) end = code.length; else end += 2;
915
+ var block = code.slice(i, end);
916
+ var hasFW = /@(flowWeaver|input|output|step|node|connect|param|returns)/.test(block);
917
+ if (hasFW) {
918
+ tokens.push({ t: 'jsdoc', v: block });
919
+ } else {
920
+ tokens.push({ t: 'cm', v: block });
921
+ }
922
+ i = end;
923
+ continue;
924
+ }
925
+ // Strings
926
+ if (code[i] === "'" || code[i] === '"' || code[i] === '\`') {
927
+ var q = code[i], j = i + 1;
928
+ while (j < code.length && code[j] !== q) { if (code[j] === '\\\\') j++; j++; }
929
+ tokens.push({ t: 'str', v: code.slice(i, j + 1) });
930
+ i = j + 1;
931
+ continue;
932
+ }
933
+ // Numbers
934
+ if (/[0-9]/.test(code[i]) && (i === 0 || /[^a-zA-Z_$]/.test(code[i-1]))) {
935
+ var j = i;
936
+ while (j < code.length && /[0-9a-fA-FxX._]/.test(code[j])) j++;
937
+ tokens.push({ t: 'num', v: code.slice(i, j) });
938
+ i = j;
939
+ continue;
940
+ }
941
+ // Words
942
+ if (/[a-zA-Z_$]/.test(code[i])) {
943
+ var j = i;
944
+ while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
945
+ var w = code.slice(i, j);
946
+ var kws = 'async,await,break,case,catch,class,const,continue,default,delete,do,else,export,extends,finally,for,from,function,if,import,in,instanceof,let,new,of,return,switch,throw,try,typeof,var,void,while,yield';
947
+ var tys = 'string,number,boolean,any,void,never,unknown,null,undefined,true,false,Promise,Record,Map,Set,Array,Partial,Required,Omit,Pick';
948
+ if (kws.split(',').indexOf(w) >= 0) {
949
+ tokens.push({ t: 'kw', v: w });
950
+ } else if (tys.split(',').indexOf(w) >= 0) {
951
+ tokens.push({ t: 'ty', v: w });
952
+ } else if (j < code.length && code[j] === '(') {
953
+ tokens.push({ t: 'fn', v: w });
954
+ } else {
955
+ tokens.push({ t: '', v: w });
956
+ }
957
+ i = j;
958
+ continue;
959
+ }
960
+ // Punctuation
961
+ if (/[{}()\\[\\];:.,<>=!&|?+\\-*/%^~@]/.test(code[i])) {
962
+ tokens.push({ t: 'pn', v: code[i] });
963
+ i++;
964
+ continue;
965
+ }
966
+ // Whitespace and other
967
+ tokens.push({ t: '', v: code[i] });
968
+ i++;
969
+ }
970
+ return tokens.map(function(tk) {
971
+ if (tk.t === 'jsdoc') {
972
+ return '<span class="hl-cm">' + highlightJSDoc(escapeH(tk.v)) + '</span>';
973
+ }
974
+ var v = escapeH(tk.v);
975
+ return tk.t ? '<span class="hl-' + tk.t + '">' + v + '</span>' : v;
976
+ }).join('');
977
+ }
978
+
818
979
  // Delegate click: port click > node click > background
980
+ // Use clickTarget (stashed from pointerdown) because setPointerCapture redirects click to canvas
819
981
  canvas.addEventListener('click', function(e) {
820
982
  if (didDrag || didDragNode) { didDragNode = false; return; }
821
- var target = e.target;
983
+ var target = clickTarget || e.target;
984
+ clickTarget = null;
822
985
  while (target && target !== canvas) {
823
986
  if (target.hasAttribute && target.hasAttribute('data-port-id')) {
824
987
  e.stopPropagation();
@@ -28,37 +28,73 @@ export function fileToSVG(filePath, options = {}) {
28
28
  */
29
29
  export function workflowToHTML(ast, options = {}) {
30
30
  const svg = workflowToSVG(ast, options);
31
- return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme });
31
+ return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme, nodeSources: buildNodeSourceMap(ast) });
32
32
  }
33
33
  /**
34
34
  * Parse TypeScript source code and render the first (or named) workflow to interactive HTML.
35
35
  */
36
36
  export function sourceToHTML(code, options = {}) {
37
- const svg = sourceToSVG(code, options);
38
- return wrapSVGInHTML(svg, { title: options.workflowName, theme: options.theme });
37
+ const result = parser.parseFromString(code);
38
+ const ast = pickWorkflow(result.workflows, options);
39
+ const svg = workflowToSVG(ast, options);
40
+ return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme, nodeSources: buildNodeSourceMap(ast) });
39
41
  }
40
42
  /**
41
43
  * Parse a workflow file and render the first (or named) workflow to interactive HTML.
42
44
  */
43
45
  export function fileToHTML(filePath, options = {}) {
44
- const svg = fileToSVG(filePath, options);
45
- return wrapSVGInHTML(svg, { title: options.workflowName, theme: options.theme });
46
+ const result = parser.parse(filePath);
47
+ const ast = pickWorkflow(result.workflows, options);
48
+ const svg = workflowToSVG(ast, options);
49
+ return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme, nodeSources: buildNodeSourceMap(ast) });
46
50
  }
47
- function pickAndRender(workflows, options) {
51
+ function buildNodeSourceMap(ast) {
52
+ const typeMap = new Map(ast.nodeTypes.map(nt => [nt.functionName, nt]));
53
+ const map = {};
54
+ for (const inst of ast.instances) {
55
+ const nt = typeMap.get(inst.nodeType);
56
+ if (!nt)
57
+ continue;
58
+ const ports = {};
59
+ for (const [name, def] of Object.entries(nt.inputs ?? {})) {
60
+ ports[name] = { type: def.dataType, tsType: def.tsType };
61
+ }
62
+ for (const [name, def] of Object.entries(nt.outputs ?? {})) {
63
+ ports[name] = { type: def.dataType, tsType: def.tsType };
64
+ }
65
+ map[inst.id] = { description: nt.description, source: nt.functionText, ports };
66
+ }
67
+ // Virtual Start/Exit nodes get their port types from the workflow definition
68
+ const startPorts = {};
69
+ for (const [name, def] of Object.entries(ast.startPorts ?? {})) {
70
+ startPorts[name] = { type: def.dataType, tsType: def.tsType };
71
+ }
72
+ if (Object.keys(startPorts).length) {
73
+ map['Start'] = { description: ast.description, ports: startPorts };
74
+ }
75
+ const exitPorts = {};
76
+ for (const [name, def] of Object.entries(ast.exitPorts ?? {})) {
77
+ exitPorts[name] = { type: def.dataType, tsType: def.tsType };
78
+ }
79
+ if (Object.keys(exitPorts).length) {
80
+ map['Exit'] = { ports: exitPorts };
81
+ }
82
+ return map;
83
+ }
84
+ function pickWorkflow(workflows, options) {
48
85
  if (workflows.length === 0) {
49
86
  throw new Error('No workflows found in source code');
50
87
  }
51
- let workflow;
52
88
  if (options.workflowName) {
53
89
  const found = workflows.find(w => w.name === options.workflowName);
54
90
  if (!found) {
55
91
  throw new Error(`Workflow "${options.workflowName}" not found. Available: ${workflows.map(w => w.name).join(', ')}`);
56
92
  }
57
- workflow = found;
58
- }
59
- else {
60
- workflow = workflows[0];
93
+ return found;
61
94
  }
62
- return workflowToSVG(workflow, options);
95
+ return workflows[0];
96
+ }
97
+ function pickAndRender(workflows, options) {
98
+ return workflowToSVG(pickWorkflow(workflows, options), options);
63
99
  }
64
100
  //# sourceMappingURL=index.js.map
@@ -35,7 +35,7 @@ const COERCE_TARGET_TYPES = {
35
35
  OBJECT: 'object',
36
36
  };
37
37
  /**
38
- * Build a concrete @coerce suggestion from error message context.
38
+ * Build a concrete `@connect ... as <type>` coercion suggestion from error context.
39
39
  * Returns null if not enough info is available.
40
40
  */
41
41
  function buildCoerceSuggestion(quoted, targetType) {
@@ -51,7 +51,7 @@ function buildCoerceSuggestion(quoted, targetType) {
51
51
  const coerceType = COERCE_TARGET_TYPES[targetType.toUpperCase()] || targetType.toLowerCase();
52
52
  if (!['string', 'number', 'boolean', 'json', 'object'].includes(coerceType))
53
53
  return null;
54
- return `@coerce c1 ${portRefs[0]} -> ${portRefs[1]} as ${coerceType}`;
54
+ return `@connect ${portRefs[0]} -> ${portRefs[1]} as ${coerceType}`;
55
55
  }
56
56
  function extractCyclePath(message) {
57
57
  const match = message.match(/:\s*(.+ -> .+)/);
@@ -163,8 +163,8 @@ const errorMappers = {
163
163
  title: 'Type Mismatch',
164
164
  explanation: `Type mismatch: you're connecting a ${source} to a ${target}. The value will be automatically converted, but this might cause unexpected behavior.`,
165
165
  fix: coerceSuggestion
166
- ? `Add an explicit coercion: \`${coerceSuggestion}\`, change one of the port types, or use @strictTypes to turn this into an error.`
167
- : `Add a @coerce annotation between the two ports, change one of the port types, or use @strictTypes to turn this into an error.`,
166
+ ? `Use \`${coerceSuggestion}\` for explicit coercion, change one of the port types, or use @strictTypes to turn this into an error.`
167
+ : `Add \`as <type>\` to the @connect annotation (e.g. \`as string\`), change one of the port types, or use @strictTypes to turn this into an error.`,
168
168
  code: error.code,
169
169
  };
170
170
  },
@@ -264,8 +264,8 @@ const errorMappers = {
264
264
  title: 'Type Incompatible',
265
265
  explanation: `Type mismatch: ${source} to ${target}. With @strictTypes enabled, this is an error instead of a warning.`,
266
266
  fix: coerceSuggestion
267
- ? `Add an explicit coercion: \`${coerceSuggestion}\`, change one of the port types, or remove @strictTypes to allow implicit coercions.`
268
- : `Add a @coerce annotation between the ports, change one of the port types, or remove @strictTypes to allow implicit coercions.`,
267
+ ? `Use \`${coerceSuggestion}\` for explicit coercion, change one of the port types, or remove @strictTypes to allow implicit coercions.`
268
+ : `Add \`as <type>\` to the @connect annotation (e.g. \`as number\`), change one of the port types, or remove @strictTypes to allow implicit coercions.`,
269
269
  code: error.code,
270
270
  };
271
271
  },
@@ -279,8 +279,8 @@ const errorMappers = {
279
279
  title: 'Unusual Type Coercion',
280
280
  explanation: `Converting ${source} to ${target} is technically valid but semantically unusual and may produce unexpected behavior.`,
281
281
  fix: coerceSuggestion
282
- ? `If intentional, add an explicit coercion: \`${coerceSuggestion}\`, or use @strictTypes to enforce type safety.`
283
- : `If intentional, add an explicit @coerce annotation, or use @strictTypes to enforce type safety.`,
282
+ ? `If intentional, use \`${coerceSuggestion}\` for explicit coercion, or use @strictTypes to enforce type safety.`
283
+ : `If intentional, add \`as <type>\` to the @connect annotation, or use @strictTypes to enforce type safety.`,
284
284
  code: error.code,
285
285
  };
286
286
  },
@@ -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';
@@ -515,8 +550,8 @@ const errorMappers = {
515
550
  title: 'Lossy Type Conversion',
516
551
  explanation: `Converting ${source} to ${target} may lose data or produce unexpected results (e.g., NaN, truncation).`,
517
552
  fix: coerceSuggestion
518
- ? `Add an explicit coercion: \`${coerceSuggestion}\`, or use @strictTypes to enforce type safety.`
519
- : `Add an explicit conversion with @coerce, or use @strictTypes to enforce type safety.`,
553
+ ? `Use \`${coerceSuggestion}\` for explicit coercion, or use @strictTypes to enforce type safety.`
554
+ : `Add \`as <type>\` to the @connect annotation for explicit conversion, or use @strictTypes to enforce type safety.`,
520
555
  code: error.code,
521
556
  };
522
557
  },
@@ -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
  /**