@synergenius/flow-weaver 0.9.1 → 0.9.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.
@@ -97,4 +97,10 @@ export declare function isScopedPort(portDef: {
97
97
  dataType: string;
98
98
  scope?: string;
99
99
  }): boolean;
100
+ export declare const VALID_NODE_COLORS: readonly ["blue", "purple", "cyan", "orange", "pink", "green", "red", "yellow", "teal"];
101
+ export type ValidNodeColor = (typeof VALID_NODE_COLORS)[number];
102
+ export declare const KNOWN_NODETYPE_TAGS: Set<string>;
103
+ export declare const KNOWN_WORKFLOW_TAGS: Set<string>;
104
+ export declare const KNOWN_PATTERN_TAGS: Set<string>;
105
+ export declare const STANDARD_JSDOC_TAGS: Set<string>;
100
106
  //# sourceMappingURL=constants.d.ts.map
package/dist/constants.js CHANGED
@@ -122,4 +122,26 @@ export function isControlFlowPort(portName) {
122
122
  export function isScopedPort(portDef) {
123
123
  return portDef.dataType === "FUNCTION" && portDef.scope !== undefined;
124
124
  }
125
+ // ── Valid annotation values ───────────────────────────────────────────
126
+ export const VALID_NODE_COLORS = [
127
+ 'blue', 'purple', 'cyan', 'orange', 'pink', 'green', 'red', 'yellow', 'teal',
128
+ ];
129
+ // ── Known annotation tags per block type ──────────────────────────────
130
+ export const KNOWN_NODETYPE_TAGS = new Set([
131
+ 'flowWeaver', 'name', 'label', 'description', 'color', 'icon', 'tag',
132
+ 'executeWhen', 'scope', 'expression', 'pullExecution', 'input', 'output', 'step',
133
+ ]);
134
+ export const KNOWN_WORKFLOW_TAGS = new Set([
135
+ 'flowWeaver', 'name', 'fwImport', 'description', 'strictTypes', 'autoConnect',
136
+ 'node', 'position', 'connect', 'scope', 'map', 'path', 'fanOut', 'fanIn',
137
+ 'coerce', 'trigger', 'cancelOn', 'retries', 'timeout', 'throttle', 'param',
138
+ 'return', 'returns',
139
+ ]);
140
+ export const KNOWN_PATTERN_TAGS = new Set([
141
+ 'flowWeaver', 'name', 'description', 'node', 'position', 'connect', 'port',
142
+ ]);
143
+ export const STANDARD_JSDOC_TAGS = new Set([
144
+ 'example', 'see', 'deprecated', 'type', 'typedef', 'template',
145
+ 'link', 'since', 'version', 'author',
146
+ ]);
125
147
  //# sourceMappingURL=constants.js.map
@@ -17,4 +17,6 @@ export declare const TYPE_ABBREVIATIONS: Record<string, string>;
17
17
  * Icon names match the original React editor's TIconNameType keys.
18
18
  */
19
19
  export declare const NODE_ICON_PATHS: Record<string, string>;
20
+ /** All valid icon names (keys of NODE_ICON_PATHS) */
21
+ export declare const VALID_NODE_ICONS: ReadonlyArray<string>;
20
22
  //# sourceMappingURL=theme.d.ts.map
@@ -33,6 +33,9 @@ export const NODE_VARIANT_COLORS = {
33
33
  orange: { border: '#e3732d', darkBorder: '#ff8133' }, // orange-shade-2 / orange-dark-shade-1
34
34
  pink: { border: '#e349c2', darkBorder: '#ff52da' }, // pink-shade-2 / pink-dark-shade-1
35
35
  green: { border: '#0ec850', darkBorder: '#10e15a' }, // green-shade-2 / green-dark-shade-1
36
+ red: { border: '#e34646', darkBorder: '#ff4f4f' }, // red-shade-2 / red-dark-shade-1
37
+ yellow: { border: '#e3a82b', darkBorder: '#ffbd30' }, // yellow-shade-2 / yellow-dark-shade-1
38
+ teal: { border: '#3db0a8', darkBorder: '#4dc7be' }, // teal-shade-2 / teal-dark-shade-1
36
39
  };
37
40
  // ---- Theme palettes (exact values from token system) ----
38
41
  const DARK_PALETTE = {
@@ -176,6 +179,8 @@ export const NODE_ICON_PATHS = {
176
179
  description: 'M319-249.52h322v-62.63H319v62.63Zm0-170h322v-62.63H319v62.63Zm-96.85 345.5q-27.6 0-47.86-20.27-20.27-20.26-20.27-47.86v-675.7q0-27.7 20.27-48.03 20.26-20.34 47.86-20.34h361.48l222.59 222.59v521.48q0 27.6-20.34 47.86-20.33 20.27-48.03 20.27h-515.7Zm326.7-557.83v-186h-326.7v675.7h515.7v-489.7h-189Zm-326.7-186v186-186 675.7-675.7Z',
177
180
  attachFile: 'M737.33-324.39q0 105.46-74.69 177.91-74.69 72.46-180.26 72.46-105.58 0-180.35-72.46-74.77-72.45-74.77-177.85v-383.82q0-74.63 53.41-126.35 53.42-51.72 127.75-51.72 74.34 0 127.69 51.72 53.35 51.72 53.35 126.35v363.82q0 43.66-31.56 74.62-31.55 30.97-75.81 30.97-44.26 0-75.61-30.64t-31.35-74.95v-370h66.46v370q0 16.05 11.97 27.59t29.2 11.54q17.24 0 28.74-11.5 11.5-11.51 11.5-27.63v-363.58q.24-47.35-33.38-79.6-33.62-32.25-81.35-32.25-47.74 0-81.14 32.19-33.41 32.19-33.41 79.42v383.82q.24 77.83 55.57 130.96 55.33 53.13 133.62 53.13 77.86 0 133.03-53.16 55.17-53.17 54.93-130.93v-396.93h66.46v396.87Z',
178
181
  };
182
+ /** All valid icon names (keys of NODE_ICON_PATHS) */
183
+ export const VALID_NODE_ICONS = Object.keys(NODE_ICON_PATHS);
179
184
  // ---- Helpers ----
180
185
  function darkenHex(hex, amount) {
181
186
  const r = parseInt(hex.slice(1, 3), 16);
@@ -122,6 +122,14 @@ const ANNOTATION_VALUES = {
122
122
  kind: 'value',
123
123
  sortOrder: 7,
124
124
  },
125
+ {
126
+ label: 'cyan',
127
+ detail: 'Cyan node color',
128
+ insertText: 'cyan',
129
+ insertTextFormat: 'plain',
130
+ kind: 'value',
131
+ sortOrder: 8,
132
+ },
125
133
  ],
126
134
  };
127
135
  /**
@@ -530,6 +530,98 @@ const errorMappers = {
530
530
  code: error.code,
531
531
  };
532
532
  },
533
+ // ── Annotation validation rules ──────────────────────────────────────
534
+ DUPLICATE_INSTANCE_ID(error) {
535
+ const quoted = extractQuoted(error.message);
536
+ const instanceId = quoted[0] || error.node || 'unknown';
537
+ return {
538
+ title: 'Duplicate Instance ID',
539
+ explanation: `Two @node declarations use the same ID '${instanceId}'. Each node instance needs a unique ID within its workflow.`,
540
+ fix: `Rename one of the '${instanceId}' instances to give it a unique ID.`,
541
+ code: error.code,
542
+ };
543
+ },
544
+ DUPLICATE_CONNECTION(error) {
545
+ return {
546
+ title: 'Duplicate Connection',
547
+ explanation: `The same connection is declared twice. ${error.message}`,
548
+ fix: `Remove the duplicate @connect annotation.`,
549
+ code: error.code,
550
+ };
551
+ },
552
+ INVALID_COLOR(error) {
553
+ const quoted = extractQuoted(error.message);
554
+ const color = quoted[1] || quoted[0] || 'unknown';
555
+ return {
556
+ title: 'Invalid Color',
557
+ explanation: `Color '${color}' is not a recognized node color. Check the spelling or use a valid color name.`,
558
+ fix: `Use one of the valid colors: blue, purple, cyan, orange, pink, green, red, yellow, teal.`,
559
+ code: error.code,
560
+ };
561
+ },
562
+ INVALID_ICON(error) {
563
+ const quoted = extractQuoted(error.message);
564
+ const icon = quoted[1] || quoted[0] || 'unknown';
565
+ return {
566
+ title: 'Invalid Icon',
567
+ explanation: `Icon '${icon}' is not a recognized node icon. Check the spelling.`,
568
+ fix: `Use a valid icon name from the icon set (e.g., database, code, flow, psychology, send).`,
569
+ code: error.code,
570
+ };
571
+ },
572
+ INVALID_PORT_TYPE(error) {
573
+ const quoted = extractQuoted(error.message);
574
+ const portName = quoted[0] || 'unknown';
575
+ const typeName = quoted[2] || quoted[1] || 'unknown';
576
+ return {
577
+ title: 'Invalid Port Type',
578
+ explanation: `Port '${portName}' has an unrecognized type '${typeName}'.`,
579
+ fix: `Use a valid port type: STRING, NUMBER, BOOLEAN, ARRAY, OBJECT, FUNCTION, ANY, or STEP.`,
580
+ code: error.code,
581
+ };
582
+ },
583
+ INVALID_PORT_CONFIG_REF(error) {
584
+ const quoted = extractQuoted(error.message);
585
+ const instanceId = quoted[0] || error.node || 'unknown';
586
+ const portName = quoted[1] || 'unknown';
587
+ return {
588
+ title: 'Invalid Port Config Reference',
589
+ explanation: `Instance '${instanceId}' references port '${portName}' in a portOrder or portLabel annotation, but this port doesn't exist on the node type.`,
590
+ fix: `Check the port name spelling, or remove the port configuration if the port was renamed or removed.`,
591
+ code: error.code,
592
+ };
593
+ },
594
+ INVALID_EXECUTE_WHEN(error) {
595
+ const quoted = extractQuoted(error.message);
596
+ const value = quoted[1] || quoted[0] || 'unknown';
597
+ return {
598
+ title: 'Invalid Execution Strategy',
599
+ explanation: `@executeWhen value '${value}' is not recognized.`,
600
+ fix: `Use one of: CONJUNCTION (all inputs), DISJUNCTION (any input), or CUSTOM (custom logic).`,
601
+ code: error.code,
602
+ };
603
+ },
604
+ SCOPE_EMPTY(error) {
605
+ const quoted = extractQuoted(error.message);
606
+ const scopeName = quoted[0] || 'unknown';
607
+ const nodeName = quoted[1] || error.node || 'unknown';
608
+ return {
609
+ title: 'Empty Scope',
610
+ explanation: `Scope '${scopeName}' on node '${nodeName}' has no child nodes declared inside it. The scope won't iterate over anything.`,
611
+ fix: `Add child nodes to the scope with @scope ${scopeName} [childId1, childId2], or remove the scope if it's not needed.`,
612
+ code: error.code,
613
+ };
614
+ },
615
+ SCOPE_INCONSISTENT(error) {
616
+ const quoted = extractQuoted(error.message);
617
+ const instanceId = quoted[0] || error.node || 'unknown';
618
+ return {
619
+ title: 'Scope Conflict',
620
+ explanation: `Instance '${instanceId}' is assigned to multiple scopes. A node can only belong to one scope at a time.`,
621
+ fix: `Remove '${instanceId}' from one of the conflicting @scope declarations.`,
622
+ code: error.code,
623
+ };
624
+ },
533
625
  };
534
626
  // ── Public API ─────────────────────────────────────────────────────────
535
627
  /**
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * Parses @flowWeaver annotations from JSDoc comments.
5
5
  */
6
- import { isExecutePort, isSuccessPort, isFailurePort, isScopedMandatoryPort } from './constants.js';
6
+ import { isExecutePort, isSuccessPort, isFailurePort, isScopedMandatoryPort, KNOWN_NODETYPE_TAGS, KNOWN_WORKFLOW_TAGS, KNOWN_PATTERN_TAGS, STANDARD_JSDOC_TAGS, } from './constants.js';
7
7
  import { inferDataTypeFromTS } from './type-mappings.js';
8
+ import { findClosestMatches } from './utils/string-distance.js';
8
9
  import { parsePortLine, parseNodeLine, parseConnectLine, parsePositionLine, parseScopeLine, parseMapLine, parsePathLine, parseFanOutLine, parseFanInLine, parseCoerceLine, parseTriggerLine, parseCancelOnLine, parseThrottleLine, } from './chevrotain-parser/index.js';
9
10
  /**
10
11
  * Extract the type of a field from a callback's return type using ts-morph Type API.
@@ -154,10 +155,10 @@ export class JSDocParser {
154
155
  config.description = comment.trim();
155
156
  break;
156
157
  case 'color':
157
- config.color = comment.trim();
158
+ config.color = comment.trim().replace(/^["']|["']$/g, '');
158
159
  break;
159
160
  case 'icon':
160
- config.icon = comment.trim();
161
+ config.icon = comment.trim().replace(/^["']|["']$/g, '');
161
162
  break;
162
163
  case 'tag':
163
164
  config.tags = config.tags || [];
@@ -194,6 +195,18 @@ export class JSDocParser {
194
195
  case 'step':
195
196
  this.parseStepTag(tag, config, func, warnings);
196
197
  break;
198
+ default:
199
+ // D: Context validation - tags that belong to other block types
200
+ if (tagName === 'param' || tagName === 'returns' || tagName === 'return') {
201
+ warnings.push(`@${tagName} is for workflows, not node types. Use @input/@output instead.`);
202
+ }
203
+ else if (!KNOWN_NODETYPE_TAGS.has(tagName) && !STANDARD_JSDOC_TAGS.has(tagName)) {
204
+ // C: Unknown tag detection with suggestions
205
+ const suggestions = findClosestMatches(tagName, [...KNOWN_NODETYPE_TAGS]);
206
+ const hint = suggestions.length > 0 ? ` Did you mean @${suggestions[0]}?` : '';
207
+ warnings.push(`Unknown annotation @${tagName} in nodeType block.${hint}`);
208
+ }
209
+ break;
197
210
  }
198
211
  });
199
212
  return config;
@@ -310,6 +323,21 @@ export class JSDocParser {
310
323
  case 'returns':
311
324
  this.parseReturnTag(tag, config, func, warnings);
312
325
  break;
326
+ default:
327
+ // D: Context validation - tags that belong to other block types
328
+ if (tagName === 'color' || tagName === 'icon' || tagName === 'tag') {
329
+ warnings.push(`@${tagName} is for node types, not workflows. Use it on @flowWeaver nodeType instead.`);
330
+ }
331
+ else if (tagName === 'input' || tagName === 'output' || tagName === 'step') {
332
+ warnings.push(`@${tagName} is for node types, not workflows. Use @param/@returns for workflows.`);
333
+ }
334
+ else if (!KNOWN_WORKFLOW_TAGS.has(tagName) && !STANDARD_JSDOC_TAGS.has(tagName)) {
335
+ // C: Unknown tag detection with suggestions
336
+ const suggestions = findClosestMatches(tagName, [...KNOWN_WORKFLOW_TAGS]);
337
+ const hint = suggestions.length > 0 ? ` Did you mean @${suggestions[0]}?` : '';
338
+ warnings.push(`Unknown annotation @${tagName} in workflow block.${hint}`);
339
+ }
340
+ break;
313
341
  }
314
342
  });
315
343
  return config;
@@ -365,6 +393,13 @@ export class JSDocParser {
365
393
  case 'port':
366
394
  this.parsePatternPortTag(tag, config, warnings);
367
395
  break;
396
+ default:
397
+ if (!KNOWN_PATTERN_TAGS.has(tagName) && !STANDARD_JSDOC_TAGS.has(tagName)) {
398
+ const suggestions = findClosestMatches(tagName, [...KNOWN_PATTERN_TAGS]);
399
+ const hint = suggestions.length > 0 ? ` Did you mean @${suggestions[0]}?` : '';
400
+ warnings.push(`Unknown annotation @${tagName} in pattern block.${hint}`);
401
+ }
402
+ break;
368
403
  }
369
404
  });
370
405
  // Apply positions to instances
@@ -465,6 +500,10 @@ export class JSDocParser {
465
500
  // Check for STEP ports: execute OR scoped mandatory ports (success, failure with scope)
466
501
  const isScopedStepInput = scope && isScopedMandatoryPort(name);
467
502
  if (isExecutePort(name) || isScopedStepInput) {
503
+ // E: Warn if user explicitly specified a non-STEP type on a reserved port
504
+ if (result.dataType && result.dataType !== 'STEP') {
505
+ warnings.push(`Port "${name}" is a reserved control port; type will always be STEP.`);
506
+ }
468
507
  type = 'STEP';
469
508
  }
470
509
  else if (scope) {
@@ -518,6 +557,10 @@ export class JSDocParser {
518
557
  expression = label.substring('Expression:'.length).trim();
519
558
  label = undefined;
520
559
  }
560
+ // B: Duplicate port detection
561
+ if (config.inputs.hasOwnProperty(name)) {
562
+ warnings.push(`Duplicate @input "${name}". The second declaration will overwrite the first.`);
563
+ }
521
564
  config.inputs[name] = {
522
565
  type,
523
566
  defaultValue: defaultValue ? this.parseDefaultValue(defaultValue) : undefined,
@@ -547,6 +590,10 @@ export class JSDocParser {
547
590
  // Check for STEP ports: onSuccess/onFailure OR scoped mandatory ports (start with scope)
548
591
  const isScopedStepOutput = scope && isScopedMandatoryPort(name);
549
592
  if (isSuccessPort(name) || isFailurePort(name) || isScopedStepOutput) {
593
+ // E: Warn if user explicitly specified a non-STEP type on a reserved port
594
+ if (result.dataType && result.dataType !== 'STEP') {
595
+ warnings.push(`Port "${name}" is a reserved control port; type will always be STEP.`);
596
+ }
550
597
  type = 'STEP';
551
598
  }
552
599
  else if (scope) {
@@ -597,6 +644,10 @@ export class JSDocParser {
597
644
  type = 'ANY';
598
645
  }
599
646
  }
647
+ // B: Duplicate port detection
648
+ if (config.outputs.hasOwnProperty(name)) {
649
+ warnings.push(`Duplicate @output "${name}". The second declaration will overwrite the first.`);
650
+ }
600
651
  config.outputs[name] = {
601
652
  type,
602
653
  label: description?.trim(),
@@ -658,8 +709,16 @@ export class JSDocParser {
658
709
  if (fieldMatch) {
659
710
  type = inferDataTypeFromTS(fieldMatch[1].trim());
660
711
  }
712
+ else {
713
+ // G: Type inference fallback to ANY
714
+ warnings.push(`Could not infer type for @returns "${name}", defaulting to ANY.`);
715
+ }
661
716
  }
662
717
  config.returnPorts = config.returnPorts || {};
718
+ // B: Duplicate port detection
719
+ if (config.returnPorts.hasOwnProperty(name)) {
720
+ warnings.push(`Duplicate @returns "${name}". The second declaration will overwrite the first.`);
721
+ }
663
722
  config.returnPorts[name] = {
664
723
  dataType: type,
665
724
  label: description?.trim(),
@@ -698,9 +757,18 @@ export class JSDocParser {
698
757
  if (fieldMatch) {
699
758
  type = inferDataTypeFromTS(fieldMatch[1].trim());
700
759
  }
760
+ else {
761
+ // F: @param doesn't match any field in the params object
762
+ // G: Type inference fallback to ANY
763
+ warnings.push(`@param "${name}" does not match any field in the params object. Type defaults to ANY.`);
764
+ }
701
765
  }
702
766
  }
703
767
  config.startPorts = config.startPorts || {};
768
+ // B: Duplicate port detection
769
+ if (config.startPorts.hasOwnProperty(name)) {
770
+ warnings.push(`Duplicate @param "${name}". The second declaration will overwrite the first.`);
771
+ }
704
772
  config.startPorts[name] = {
705
773
  dataType: type,
706
774
  label: description?.trim(),
@@ -96,6 +96,12 @@ export declare class WorkflowValidator {
96
96
  * 2. Scoped input ports (callback returns) have connections from inner nodes
97
97
  */
98
98
  private validateScopeTopology;
99
+ private validateDuplicateInstanceIds;
100
+ private validateDuplicateConnections;
101
+ private validateVisualAnnotations;
102
+ private validatePortTypes;
103
+ private validatePortConfigReferences;
104
+ private validateExecuteWhen;
99
105
  /**
100
106
  * Format a type for display in error messages.
101
107
  * Prefers the structural TypeScript type when available, falling back to the enum name.
package/dist/validator.js CHANGED
@@ -1,7 +1,9 @@
1
- import { RESERVED_NODE_NAMES, isStartNode, isExitNode, isExecutePort, isReservedNodeName, } from './constants.js';
1
+ import { RESERVED_NODE_NAMES, isStartNode, isExitNode, isExecutePort, isReservedNodeName, VALID_NODE_COLORS, EXECUTION_STRATEGIES, } from './constants.js';
2
2
  import { findClosestMatches } from './utils/string-distance.js';
3
3
  import { parseFunctionSignature } from './jsdoc-port-sync/signature-parser.js';
4
4
  import { checkTypeCompatibilityFromStrings } from './type-checker.js';
5
+ import { isValidPortType } from './type-mappings.js';
6
+ import { VALID_NODE_ICONS } from './diagram/theme.js';
5
7
  const DOCS_BASE = 'https://docs.flowweaver.dev/reference';
6
8
  /** Map error codes to the documentation page that explains how to fix them. */
7
9
  const ERROR_DOC_URLS = {
@@ -135,10 +137,12 @@ export class WorkflowValidator {
135
137
  // Structural validation
136
138
  this.validateStructure(workflow);
137
139
  this.validateDuplicateNodeNames(workflow);
140
+ this.validateDuplicateInstanceIds(workflow);
138
141
  this.validateMutableBindings(workflow);
139
142
  // Connection and node validation
140
143
  this.validateReservedNames(workflow, nodeTypeMap);
141
144
  this.validateConnections(workflow, instanceMap);
145
+ this.validateDuplicateConnections(workflow);
142
146
  this.validateNodeReferences(workflow, instanceMap);
143
147
  this.validateTypeCompatibility(workflow, instanceMap);
144
148
  this.validateRequiredInputs(workflow, instanceMap);
@@ -148,6 +152,10 @@ export class WorkflowValidator {
148
152
  this.validateCycles(workflow);
149
153
  this.validateMultipleInputConnections(workflow, instanceMap);
150
154
  this.validateAnnotationSignatureConsistency(workflow);
155
+ this.validateVisualAnnotations(workflow, instanceMap);
156
+ this.validatePortTypes(workflow);
157
+ this.validatePortConfigReferences(workflow, instanceMap);
158
+ this.validateExecuteWhen(workflow);
151
159
  this.validateScopeTopology(workflow, instanceMap);
152
160
  // Deduplicate cascading errors: if a node has UNKNOWN_NODE_TYPE,
153
161
  // suppress UNKNOWN_SOURCE_NODE, UNKNOWN_TARGET_NODE, and UNDEFINED_NODE
@@ -1029,6 +1037,25 @@ export class WorkflowValidator {
1029
1037
  * 2. Scoped input ports (callback returns) have connections from inner nodes
1030
1038
  */
1031
1039
  validateScopeTopology(workflow, instanceMap) {
1040
+ // P: Scope consistency - check if any instance appears in multiple scope arrays
1041
+ if (workflow.scopes) {
1042
+ const instanceToScope = new Map();
1043
+ for (const [scopeKey, childIds] of Object.entries(workflow.scopes)) {
1044
+ for (const childId of childIds) {
1045
+ const existing = instanceToScope.get(childId);
1046
+ if (existing && existing !== scopeKey) {
1047
+ this.errors.push({
1048
+ type: 'error',
1049
+ code: 'SCOPE_INCONSISTENT',
1050
+ message: `Instance "${childId}" appears in multiple scopes: "${existing}" and "${scopeKey}". A node can only belong to one scope.`,
1051
+ node: childId,
1052
+ location: this.getInstanceLocation(workflow, childId),
1053
+ });
1054
+ }
1055
+ instanceToScope.set(childId, scopeKey);
1056
+ }
1057
+ }
1058
+ }
1032
1059
  // Find all instances that have scoped ports
1033
1060
  for (const instance of workflow.instances) {
1034
1061
  const nodeType = instanceMap.get(instance.id);
@@ -1081,8 +1108,17 @@ export class WorkflowValidator {
1081
1108
  childIds.push(child.id);
1082
1109
  }
1083
1110
  }
1084
- if (childIds.length === 0)
1111
+ // O: Empty scope warning
1112
+ if (childIds.length === 0) {
1113
+ this.warnings.push({
1114
+ type: 'warning',
1115
+ code: 'SCOPE_EMPTY',
1116
+ message: `Scope "${scopeName}" on node "${instance.id}" has no child nodes.`,
1117
+ node: instance.id,
1118
+ location: this.getInstanceLocation(workflow, instance.id),
1119
+ });
1085
1120
  continue;
1121
+ }
1086
1122
  // Collect scoped connections (connections with scope tags)
1087
1123
  const scopedConnections = workflow.connections.filter((conn) => (conn.from.scope === scopeName && conn.from.node === instance.id) ||
1088
1124
  (conn.to.scope === scopeName && conn.to.node === instance.id) ||
@@ -1268,6 +1304,168 @@ export class WorkflowValidator {
1268
1304
  }
1269
1305
  }
1270
1306
  }
1307
+ // ── H: Duplicate instance IDs ──────────────────────────────────────────
1308
+ validateDuplicateInstanceIds(workflow) {
1309
+ const seen = new Set();
1310
+ for (const instance of workflow.instances) {
1311
+ if (seen.has(instance.id)) {
1312
+ this.errors.push({
1313
+ type: 'error',
1314
+ code: 'DUPLICATE_INSTANCE_ID',
1315
+ message: `Duplicate instance ID "${instance.id}" in workflow. Each @node must have a unique ID.`,
1316
+ node: instance.id,
1317
+ location: instance.sourceLocation,
1318
+ });
1319
+ }
1320
+ seen.add(instance.id);
1321
+ }
1322
+ }
1323
+ // ── I: Duplicate connections ──────────────────────────────────────────
1324
+ validateDuplicateConnections(workflow) {
1325
+ const seen = new Set();
1326
+ for (const conn of workflow.connections) {
1327
+ const key = `${conn.from.node}.${conn.from.port}->${conn.to.node}.${conn.to.port}`;
1328
+ if (seen.has(key)) {
1329
+ this.errors.push({
1330
+ type: 'error',
1331
+ code: 'DUPLICATE_CONNECTION',
1332
+ message: `Duplicate connection: ${key}`,
1333
+ connection: conn,
1334
+ location: this.getConnectionLocation(conn),
1335
+ });
1336
+ }
1337
+ seen.add(key);
1338
+ }
1339
+ }
1340
+ // ── J+K: Visual annotation validation ────────────────────────────────
1341
+ validateVisualAnnotations(workflow, instanceMap) {
1342
+ const validColors = VALID_NODE_COLORS;
1343
+ const validIcons = VALID_NODE_ICONS;
1344
+ // Check node type colors and icons (stored in visuals)
1345
+ for (const nodeType of workflow.nodeTypes) {
1346
+ const color = nodeType.visuals?.color;
1347
+ const icon = nodeType.visuals?.icon;
1348
+ if (color && !validColors.includes(color)) {
1349
+ const suggestions = findClosestMatches(color, [...validColors]);
1350
+ const hint = suggestions.length > 0 ? ` Did you mean "${suggestions[0]}"?` : '';
1351
+ this.warnings.push({
1352
+ type: 'warning',
1353
+ code: 'INVALID_COLOR',
1354
+ message: `Node type "${nodeType.functionName}" has invalid color "${color}".${hint} Valid colors: ${validColors.join(', ')}.`,
1355
+ node: nodeType.functionName,
1356
+ location: nodeType.sourceLocation,
1357
+ });
1358
+ }
1359
+ if (icon && !validIcons.includes(icon)) {
1360
+ const suggestions = findClosestMatches(icon, [...validIcons]);
1361
+ const hint = suggestions.length > 0 ? ` Did you mean "${suggestions[0]}"?` : '';
1362
+ this.warnings.push({
1363
+ type: 'warning',
1364
+ code: 'INVALID_ICON',
1365
+ message: `Node type "${nodeType.functionName}" has invalid icon "${icon}".${hint}`,
1366
+ node: nodeType.functionName,
1367
+ location: nodeType.sourceLocation,
1368
+ });
1369
+ }
1370
+ }
1371
+ // Check instance-level color and icon overrides
1372
+ for (const instance of workflow.instances) {
1373
+ if (instance.config?.color && !validColors.includes(instance.config.color)) {
1374
+ const suggestions = findClosestMatches(instance.config.color, [...validColors]);
1375
+ const hint = suggestions.length > 0 ? ` Did you mean "${suggestions[0]}"?` : '';
1376
+ this.warnings.push({
1377
+ type: 'warning',
1378
+ code: 'INVALID_COLOR',
1379
+ message: `Instance "${instance.id}" has invalid color "${instance.config.color}".${hint} Valid colors: ${validColors.join(', ')}.`,
1380
+ node: instance.id,
1381
+ location: instance.sourceLocation,
1382
+ });
1383
+ }
1384
+ if (instance.config?.icon && !validIcons.includes(instance.config.icon)) {
1385
+ const suggestions = findClosestMatches(instance.config.icon, [...validIcons]);
1386
+ const hint = suggestions.length > 0 ? ` Did you mean "${suggestions[0]}"?` : '';
1387
+ this.warnings.push({
1388
+ type: 'warning',
1389
+ code: 'INVALID_ICON',
1390
+ message: `Instance "${instance.id}" has invalid icon "${instance.config.icon}".${hint}`,
1391
+ node: instance.id,
1392
+ location: instance.sourceLocation,
1393
+ });
1394
+ }
1395
+ }
1396
+ }
1397
+ // ── L: Port type validation ──────────────────────────────────────────
1398
+ validatePortTypes(workflow) {
1399
+ for (const nodeType of workflow.nodeTypes) {
1400
+ for (const [portName, portDef] of Object.entries(nodeType.inputs)) {
1401
+ if (!isValidPortType(portDef.dataType)) {
1402
+ this.warnings.push({
1403
+ type: 'warning',
1404
+ code: 'INVALID_PORT_TYPE',
1405
+ message: `Port "${portName}" on node type "${nodeType.functionName}" has invalid type "${portDef.dataType}".`,
1406
+ node: nodeType.functionName,
1407
+ location: nodeType.sourceLocation,
1408
+ });
1409
+ }
1410
+ }
1411
+ for (const [portName, portDef] of Object.entries(nodeType.outputs)) {
1412
+ if (!isValidPortType(portDef.dataType)) {
1413
+ this.warnings.push({
1414
+ type: 'warning',
1415
+ code: 'INVALID_PORT_TYPE',
1416
+ message: `Port "${portName}" on node type "${nodeType.functionName}" has invalid type "${portDef.dataType}".`,
1417
+ node: nodeType.functionName,
1418
+ location: nodeType.sourceLocation,
1419
+ });
1420
+ }
1421
+ }
1422
+ }
1423
+ }
1424
+ // ── M: portOrder/portLabel reference validation ──────────────────────
1425
+ validatePortConfigReferences(workflow, instanceMap) {
1426
+ for (const instance of workflow.instances) {
1427
+ const portConfigs = instance.config?.portConfigs;
1428
+ if (!portConfigs)
1429
+ continue;
1430
+ const nodeType = instanceMap.get(instance.id);
1431
+ if (!nodeType)
1432
+ continue;
1433
+ const allPorts = new Set([
1434
+ ...Object.keys(nodeType.inputs),
1435
+ ...Object.keys(nodeType.outputs),
1436
+ ]);
1437
+ for (const pc of portConfigs) {
1438
+ if (!allPorts.has(pc.portName)) {
1439
+ const suggestions = findClosestMatches(pc.portName, [...allPorts]);
1440
+ const hint = suggestions.length > 0 ? ` Did you mean "${suggestions[0]}"?` : '';
1441
+ this.warnings.push({
1442
+ type: 'warning',
1443
+ code: 'INVALID_PORT_CONFIG_REF',
1444
+ message: `Instance "${instance.id}" references port "${pc.portName}" in portConfig, but this port does not exist on node type "${instance.nodeType}".${hint}`,
1445
+ node: instance.id,
1446
+ location: instance.sourceLocation,
1447
+ });
1448
+ }
1449
+ }
1450
+ }
1451
+ }
1452
+ // ── N: @executeWhen value validation ─────────────────────────────────
1453
+ validateExecuteWhen(workflow) {
1454
+ const validStrategies = Object.values(EXECUTION_STRATEGIES);
1455
+ for (const nodeType of workflow.nodeTypes) {
1456
+ if (nodeType.executeWhen && !validStrategies.includes(nodeType.executeWhen)) {
1457
+ const suggestions = findClosestMatches(nodeType.executeWhen, validStrategies);
1458
+ const hint = suggestions.length > 0 ? ` Did you mean "${suggestions[0]}"?` : '';
1459
+ this.warnings.push({
1460
+ type: 'warning',
1461
+ code: 'INVALID_EXECUTE_WHEN',
1462
+ message: `Node type "${nodeType.functionName}" has invalid @executeWhen value "${nodeType.executeWhen}".${hint} Valid values: ${validStrategies.join(', ')}.`,
1463
+ node: nodeType.functionName,
1464
+ location: nodeType.sourceLocation,
1465
+ });
1466
+ }
1467
+ }
1468
+ }
1271
1469
  /**
1272
1470
  * Format a type for display in error messages.
1273
1471
  * Prefers the structural TypeScript type when available, falling back to the enum name.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synergenius/flow-weaver",
3
- "version": "0.9.1",
3
+ "version": "0.9.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",
@@ -66,6 +66,10 @@
66
66
  "./marketplace": {
67
67
  "types": "./dist/marketplace/index.d.ts",
68
68
  "default": "./dist/marketplace/index.js"
69
+ },
70
+ "./testing": {
71
+ "types": "./dist/testing/index.d.ts",
72
+ "default": "./dist/testing/index.js"
69
73
  }
70
74
  },
71
75
  "bin": {