@synergenius/flow-weaver 0.9.2 → 0.9.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.
- package/dist/cli/flow-weaver.mjs +770 -308
- package/dist/constants.d.ts +6 -0
- package/dist/constants.js +22 -0
- package/dist/diagram/geometry.js +4 -1
- package/dist/diagram/html-viewer.js +6 -6
- package/dist/diagram/theme.d.ts +2 -0
- package/dist/diagram/theme.js +5 -0
- package/dist/editor-completions/annotationValues.js +8 -0
- package/dist/friendly-errors.js +92 -0
- package/dist/jsdoc-parser.js +71 -3
- package/dist/validator.d.ts +6 -0
- package/dist/validator.js +200 -2
- package/package.json +3 -2
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
package/dist/diagram/geometry.js
CHANGED
|
@@ -744,7 +744,8 @@ export function buildDiagramGraph(ast, options = {}) {
|
|
|
744
744
|
const allPositioned = [...diagramNodes.keys()].every(id => explicitPositions.has(id));
|
|
745
745
|
const nonePositioned = ![...diagramNodes.keys()].some(id => explicitPositions.has(id));
|
|
746
746
|
if (allPositioned) {
|
|
747
|
-
// All nodes have explicit positions — apply them
|
|
747
|
+
// All nodes have explicit positions — apply them, then fix overlaps
|
|
748
|
+
// caused by scope expansion making nodes wider than the author anticipated.
|
|
748
749
|
for (const [id, pos] of explicitPositions) {
|
|
749
750
|
const node = diagramNodes.get(id);
|
|
750
751
|
if (node) {
|
|
@@ -752,6 +753,7 @@ export function buildDiagramGraph(ast, options = {}) {
|
|
|
752
753
|
node.y = pos.y;
|
|
753
754
|
}
|
|
754
755
|
}
|
|
756
|
+
resolveHorizontalOverlaps(diagramNodes);
|
|
755
757
|
}
|
|
756
758
|
else if (nonePositioned) {
|
|
757
759
|
// No positions — full auto-layout (original behavior)
|
|
@@ -762,6 +764,7 @@ export function buildDiagramGraph(ast, options = {}) {
|
|
|
762
764
|
// Mixed — explicit positions + auto-layout for remaining nodes
|
|
763
765
|
const { layers } = layoutWorkflow(ast);
|
|
764
766
|
assignUnpositionedNodes(layers, diagramNodes, explicitPositions);
|
|
767
|
+
resolveHorizontalOverlaps(diagramNodes);
|
|
765
768
|
}
|
|
766
769
|
// Compute external port positions
|
|
767
770
|
for (const node of diagramNodes.values()) {
|
|
@@ -67,10 +67,10 @@ body {
|
|
|
67
67
|
.nodes > g:hover ~ .show-port-labels .port-label,
|
|
68
68
|
.nodes > g:hover ~ .show-port-labels .port-type-label { opacity: 1; }
|
|
69
69
|
|
|
70
|
-
/* Connection hover & dimming */
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
body.node-active
|
|
70
|
+
/* Connection hover & dimming (attribute selector covers both main and scope connections) */
|
|
71
|
+
path[data-source] { transition: opacity 0.2s ease, stroke-width 0.15s ease; }
|
|
72
|
+
path[data-source]:hover { stroke-width: 4; cursor: pointer; }
|
|
73
|
+
body.node-active path[data-source].dimmed { opacity: 0.15; }
|
|
74
74
|
|
|
75
75
|
/* Node hover glow */
|
|
76
76
|
.nodes g[data-node-id]:hover > rect:first-of-type { filter: brightness(1.08); }
|
|
@@ -274,7 +274,7 @@ body.node-active .connections path.dimmed { opacity: 0.15; }
|
|
|
274
274
|
|
|
275
275
|
// Build adjacency: portId → array of connected portIds
|
|
276
276
|
var portConnections = {};
|
|
277
|
-
content.querySelectorAll('
|
|
277
|
+
content.querySelectorAll('path[data-source]').forEach(function(p) {
|
|
278
278
|
var src = p.getAttribute('data-source');
|
|
279
279
|
var tgt = p.getAttribute('data-target');
|
|
280
280
|
if (!src || !tgt) return;
|
|
@@ -371,7 +371,7 @@ body.node-active .connections path.dimmed { opacity: 0.15; }
|
|
|
371
371
|
});
|
|
372
372
|
|
|
373
373
|
// Connected paths
|
|
374
|
-
var allPaths = content.querySelectorAll('
|
|
374
|
+
var allPaths = content.querySelectorAll('path[data-source]');
|
|
375
375
|
var connectedPaths = [];
|
|
376
376
|
var connectedNodes = new Set();
|
|
377
377
|
allPaths.forEach(function(p) {
|
package/dist/diagram/theme.d.ts
CHANGED
|
@@ -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
|
package/dist/diagram/theme.js
CHANGED
|
@@ -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
|
/**
|
package/dist/friendly-errors.js
CHANGED
|
@@ -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
|
/**
|
package/dist/jsdoc-parser.js
CHANGED
|
@@ -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(),
|
package/dist/validator.d.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|