@synergenius/flow-weaver 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -29,6 +29,7 @@ import { openapiCommand } from './commands/openapi.js';
29
29
  import { pluginInitCommand } from './commands/plugin.js';
30
30
  import { migrateCommand } from './commands/migrate.js';
31
31
  import { changelogCommand } from './commands/changelog.js';
32
+ import { docsListCommand, docsReadCommand, docsSearchCommand } from './commands/docs.js';
32
33
  import { marketInitCommand, marketPackCommand, marketPublishCommand, marketInstallCommand, marketSearchCommand, marketListCommand, } from './commands/market.js';
33
34
  import { logger } from './utils/logger.js';
34
35
  import { getErrorMessage } from '../utils/error-utils.js';
@@ -585,6 +586,49 @@ program
585
586
  process.exit(1);
586
587
  }
587
588
  });
589
+ // Docs command group
590
+ const docsCmd = program.command('docs').description('Browse reference documentation');
591
+ docsCmd
592
+ .command('list', { isDefault: true })
593
+ .description('List available documentation topics')
594
+ .option('--json', 'Output as JSON', false)
595
+ .option('--compact', 'Compact output', false)
596
+ .action(async (options) => {
597
+ try {
598
+ await docsListCommand(options);
599
+ }
600
+ catch (error) {
601
+ logger.error(`Command failed: ${getErrorMessage(error)}`);
602
+ process.exit(1);
603
+ }
604
+ });
605
+ docsCmd
606
+ .command('read <topic>')
607
+ .description('Read a documentation topic')
608
+ .option('--json', 'Output as JSON', false)
609
+ .option('--compact', 'Return compact LLM-friendly version', false)
610
+ .action(async (topic, options) => {
611
+ try {
612
+ await docsReadCommand(topic, options);
613
+ }
614
+ catch (error) {
615
+ logger.error(`Command failed: ${getErrorMessage(error)}`);
616
+ process.exit(1);
617
+ }
618
+ });
619
+ docsCmd
620
+ .command('search <query>')
621
+ .description('Search across all documentation')
622
+ .option('--json', 'Output as JSON', false)
623
+ .action(async (query, options) => {
624
+ try {
625
+ await docsSearchCommand(query, options);
626
+ }
627
+ catch (error) {
628
+ logger.error(`Command failed: ${getErrorMessage(error)}`);
629
+ process.exit(1);
630
+ }
631
+ });
588
632
  // Marketplace command group
589
633
  const marketCmd = program.command('market').description('Discover, install, and publish marketplace packages');
590
634
  marketCmd
@@ -724,6 +768,13 @@ program.on('--help', () => {
724
768
  logger.log(' $ flow-weaver market search openai');
725
769
  logger.log(' $ flow-weaver market list');
726
770
  logger.newline();
771
+ logger.section('Documentation');
772
+ logger.log(' $ flow-weaver docs');
773
+ logger.log(' $ flow-weaver docs read error-codes');
774
+ logger.log(' $ flow-weaver docs read scaffold --compact');
775
+ logger.log(' $ flow-weaver docs search "missing workflow"');
776
+ logger.log(' $ flow-weaver docs read error-codes --json');
777
+ logger.newline();
727
778
  logger.section('Migration & Changelog');
728
779
  logger.log(" $ flow-weaver migrate '**/*.ts'");
729
780
  logger.log(" $ flow-weaver migrate '**/*.ts' --dry-run");
@@ -1,5 +1,5 @@
1
1
  import type { TWorkflowAST } from '../ast/types.js';
2
- import type { DiagramNode, DiagramGraph, DiagramOptions } from './types.js';
2
+ import type { DiagramNode, DiagramPort, DiagramGraph, DiagramOptions } from './types.js';
3
3
  export declare const PORT_RADIUS = 7;
4
4
  export declare const PORT_SIZE: number;
5
5
  export declare const PORT_GAP = 8;
@@ -7,16 +7,21 @@ export declare const PORT_PADDING_Y = 18;
7
7
  export declare const NODE_MIN_WIDTH = 90;
8
8
  export declare const NODE_MIN_HEIGHT = 90;
9
9
  export declare const BORDER_RADIUS = 6;
10
- export declare const LAYER_GAP_X = 220;
10
+ export declare const LAYER_GAP_X = 300;
11
+ export declare const LABEL_CLEARANCE = 42;
12
+ export declare const MIN_EDGE_GAP = 112;
11
13
  export declare const NODE_GAP_Y = 60;
12
14
  export declare const LABEL_HEIGHT = 20;
13
15
  export declare const LABEL_GAP = 12;
14
- export declare const SCOPE_PADDING = 40;
16
+ export declare const SCOPE_PADDING_X = 140;
17
+ export declare const SCOPE_PADDING_Y = 40;
15
18
  export declare const SCOPE_PORT_COLUMN = 50;
16
- export declare const SCOPE_INNER_GAP_X = 160;
19
+ export declare const SCOPE_INNER_GAP_X = 240;
17
20
  export declare const ORTHOGONAL_DISTANCE_THRESHOLD = 300;
18
21
  /** Measure text width using pre-computed Montserrat 600/10px SVG character widths */
19
22
  export declare function measureText(text: string): number;
23
+ /** Compute the full badge width for a port label (matches renderer badge layout) */
24
+ export declare function portBadgeWidth(port: DiagramPort): number;
20
25
  export declare function computeNodeDimensions(node: DiagramNode): void;
21
26
  export declare function computePortPositions(node: DiagramNode): void;
22
27
  export declare function computeConnectionPath(sx: number, sy: number, tx: number, ty: number): string;
@@ -11,14 +11,17 @@ export const PORT_PADDING_Y = 18; // matches inputsStyle paddingTop/Bottom
11
11
  export const NODE_MIN_WIDTH = 90; // matches NODE_MIN_WIDTH in styles.ts
12
12
  export const NODE_MIN_HEIGHT = 90; // matches NODE_MIN_HEIGHT in styles.ts
13
13
  export const BORDER_RADIUS = 6; // matches wrapperStyle borderRadius
14
- export const LAYER_GAP_X = 220;
14
+ export const LAYER_GAP_X = 300; // target center-to-center; actual gap adapts to port labels
15
+ export const LABEL_CLEARANCE = 42; // breathing room between opposing port label badges
16
+ export const MIN_EDGE_GAP = 112; // minimum edge-to-edge gap between node boxes
15
17
  export const NODE_GAP_Y = 60;
16
18
  export const LABEL_HEIGHT = 20; // 13px font + padding
17
19
  export const LABEL_GAP = 12; // matches labelRootStyle bottom: calc(100% + 12px)
18
20
  // Scope rendering constants
19
- export const SCOPE_PADDING = 40; // padding around scope area inside parent
21
+ export const SCOPE_PADDING_X = 140; // horizontal padding inside scope (between port columns and children)
22
+ export const SCOPE_PADDING_Y = 40; // vertical padding inside scope (top/bottom)
20
23
  export const SCOPE_PORT_COLUMN = 50; // width for scoped port column on inner edges
21
- export const SCOPE_INNER_GAP_X = 160; // horizontal gap between children inside scope
24
+ export const SCOPE_INNER_GAP_X = 240; // horizontal gap between children inside scope
22
25
  // Routing mode threshold — connections longer than this use orthogonal routing
23
26
  // (midpoint of original 250–350 hysteresis thresholds)
24
27
  export const ORTHOGONAL_DISTANCE_THRESHOLD = 300;
@@ -47,6 +50,20 @@ export function measureText(text) {
47
50
  }
48
51
  return width;
49
52
  }
53
+ /** Compute the full badge width for a port label (matches renderer badge layout) */
54
+ export function portBadgeWidth(port) {
55
+ const abbrev = TYPE_ABBREVIATIONS[port.dataType] ?? port.dataType;
56
+ const typeWidth = measureText(abbrev);
57
+ const labelWidth = measureText(port.label);
58
+ const pad = 7;
59
+ const divGap = 4;
60
+ return pad + typeWidth + divGap + 1 + divGap + labelWidth + pad;
61
+ }
62
+ /** Total extent of a port label from the port dot center outward (badge + gap + dot radius) */
63
+ function portLabelExtent(port) {
64
+ const badgeGap = 5;
65
+ return PORT_RADIUS + badgeGap + portBadgeWidth(port);
66
+ }
50
67
  // ---- Dimension computation ----
51
68
  export function computeNodeDimensions(node) {
52
69
  const maxPorts = Math.max(node.inputs.length, node.outputs.length);
@@ -303,8 +320,17 @@ function buildScopeSubGraph(parentNode, parentNt, scopeName, childIds, ast, node
303
320
  const parentUI = ast.ui?.instances?.find(u => u.name === parentNode.id);
304
321
  const uiWidth = parentUI?.expandedWidth ?? parentUI?.width;
305
322
  const uiHeight = parentUI?.expandedHeight ?? parentUI?.height;
306
- const computedWidth = SCOPE_PORT_COLUMN + SCOPE_PADDING + childrenWidth + SCOPE_PADDING + SCOPE_PORT_COLUMN;
307
- const computedHeight = SCOPE_PADDING * 2 + Math.max(childrenHeight, scopeOutPortsHeight, scopeInPortsHeight);
323
+ // Minimum inner width so opposing scope port labels don't collide
324
+ const maxLeftLabelExtent = scopeOutputPorts.length > 0
325
+ ? Math.max(...scopeOutputPorts.map(portLabelExtent))
326
+ : 0;
327
+ const maxRightLabelExtent = scopeInputPorts.length > 0
328
+ ? Math.max(...scopeInputPorts.map(portLabelExtent))
329
+ : 0;
330
+ const minInnerWidth = maxLeftLabelExtent + LABEL_CLEARANCE + maxRightLabelExtent;
331
+ const contentWidth = Math.max(childrenWidth, minInnerWidth);
332
+ const computedWidth = SCOPE_PORT_COLUMN + SCOPE_PADDING_X + contentWidth + SCOPE_PADDING_X + SCOPE_PORT_COLUMN;
333
+ const computedHeight = SCOPE_PADDING_Y * 2 + Math.max(childrenHeight, scopeOutPortsHeight, scopeInPortsHeight);
308
334
  parentNode.width = Math.max(parentNode.width, uiWidth ?? computedWidth);
309
335
  parentNode.height = Math.max(parentNode.height, uiHeight ?? computedHeight);
310
336
  // Store scope data (children positions are in local coordinates, will be offset later)
@@ -319,15 +345,15 @@ function finalizeScopePositions(parentNode, ast, theme = 'dark') {
319
345
  if (!children || children.length === 0 || !scopePorts)
320
346
  return;
321
347
  // Scope inner area (between the two port columns)
322
- const innerLeft = parentNode.x + SCOPE_PORT_COLUMN + SCOPE_PADDING;
323
- const innerRight = parentNode.x + parentNode.width - SCOPE_PORT_COLUMN - SCOPE_PADDING;
348
+ const innerLeft = parentNode.x + SCOPE_PORT_COLUMN + SCOPE_PADDING_X;
349
+ const innerRight = parentNode.x + parentNode.width - SCOPE_PORT_COLUMN - SCOPE_PADDING_X;
324
350
  const innerWidth = innerRight - innerLeft;
325
351
  // Compute children block width from local coordinates
326
352
  const lastChild = children[children.length - 1];
327
353
  const childrenBlockWidth = lastChild.x + lastChild.width;
328
354
  // Center children horizontally within the inner area
329
355
  const centerOffsetX = innerLeft + (innerWidth - childrenBlockWidth) / 2;
330
- const scopeOriginY = parentNode.y + SCOPE_PADDING;
356
+ const scopeOriginY = parentNode.y + SCOPE_PADDING_Y;
331
357
  // Offset children to absolute positions
332
358
  for (const child of children) {
333
359
  child.x += centerOffsetX;
@@ -432,6 +458,167 @@ function portsColumnHeight(count) {
432
458
  return 0;
433
459
  return PORT_PADDING_Y + count * PORT_SIZE + (count - 1) * PORT_GAP + PORT_PADDING_Y;
434
460
  }
461
+ // ---- Position extraction ----
462
+ /**
463
+ * Extract explicit positions from AST metadata.
464
+ * Returns a map of node ID → {x, y} for nodes that have both x and y defined.
465
+ * Sources: ast.ui.startNode, ast.ui.exitNode, instance.config.x/y
466
+ */
467
+ function extractExplicitPositions(ast) {
468
+ const positions = new Map();
469
+ if (ast.ui?.startNode?.x != null && ast.ui.startNode.y != null) {
470
+ positions.set('Start', { x: ast.ui.startNode.x, y: ast.ui.startNode.y });
471
+ }
472
+ if (ast.ui?.exitNode?.x != null && ast.ui.exitNode.y != null) {
473
+ positions.set('Exit', { x: ast.ui.exitNode.x, y: ast.ui.exitNode.y });
474
+ }
475
+ for (const inst of ast.instances) {
476
+ if (inst.config?.x != null && inst.config.y != null) {
477
+ positions.set(inst.id, { x: inst.config.x, y: inst.config.y });
478
+ }
479
+ }
480
+ return positions;
481
+ }
482
+ /**
483
+ * Compute the maximum right-side overhang for a layer (external output port labels only).
484
+ * Scope inner-edge port labels face inward and don't extend past the node boundary.
485
+ */
486
+ function layerOutputExtent(layerNodes, diagramNodes) {
487
+ let max = 0;
488
+ for (const id of layerNodes) {
489
+ const node = diagramNodes.get(id);
490
+ max = Math.max(max, maxPortLabelExtent(node.outputs));
491
+ }
492
+ return max;
493
+ }
494
+ /**
495
+ * Compute the maximum left-side overhang for a layer (external input port labels only).
496
+ * Scope inner-edge port labels face inward and don't extend past the node boundary.
497
+ */
498
+ function layerInputExtent(layerNodes, diagramNodes) {
499
+ let max = 0;
500
+ for (const id of layerNodes) {
501
+ const node = diagramNodes.get(id);
502
+ max = Math.max(max, maxPortLabelExtent(node.inputs));
503
+ }
504
+ return max;
505
+ }
506
+ /**
507
+ * Assign coordinates to nodes using auto-layout layer assignments.
508
+ * Gap between layers adapts to port label extents so labels never overlap,
509
+ * while using LAYER_GAP_X as the target center-to-center distance.
510
+ */
511
+ function assignLayerCoordinates(layers, diagramNodes) {
512
+ // Pre-compute filtered layers
513
+ const filtered = layers.map(l => l.filter(id => diagramNodes.has(id)));
514
+ let currentX = 0;
515
+ for (let i = 0; i < filtered.length; i++) {
516
+ const layerNodes = filtered[i];
517
+ if (layerNodes.length === 0) {
518
+ currentX += LAYER_GAP_X;
519
+ continue;
520
+ }
521
+ const maxWidth = Math.max(...layerNodes.map(id => diagramNodes.get(id).width));
522
+ const totalHeight = layerNodes.reduce((sum, id) => {
523
+ const n = diagramNodes.get(id);
524
+ return sum + n.height + LABEL_HEIGHT + LABEL_GAP;
525
+ }, 0) + (layerNodes.length - 1) * NODE_GAP_Y;
526
+ let currentY = -totalHeight / 2;
527
+ for (const id of layerNodes) {
528
+ const node = diagramNodes.get(id);
529
+ currentY += LABEL_HEIGHT + LABEL_GAP;
530
+ node.x = currentX + (maxWidth - node.width) / 2;
531
+ node.y = currentY;
532
+ currentY += node.height + NODE_GAP_Y;
533
+ }
534
+ // Compute label-aware edge gap to the next layer
535
+ const nextLayerNodes = filtered[i + 1];
536
+ if (nextLayerNodes && nextLayerNodes.length > 0) {
537
+ const outputOverhang = layerOutputExtent(layerNodes, diagramNodes);
538
+ const inputOverhang = layerInputExtent(nextLayerNodes, diagramNodes);
539
+ const labelMinGap = outputOverhang + LABEL_CLEARANCE + inputOverhang;
540
+ const edgeGap = Math.max(labelMinGap, LAYER_GAP_X - maxWidth, MIN_EDGE_GAP);
541
+ currentX += maxWidth + edgeGap;
542
+ }
543
+ else {
544
+ currentX += maxWidth + LAYER_GAP_X;
545
+ }
546
+ }
547
+ }
548
+ /**
549
+ * Hybrid positioning: apply explicit positions to positioned nodes,
550
+ * then auto-layout only the remaining unpositioned nodes.
551
+ */
552
+ function assignUnpositionedNodes(layers, diagramNodes, explicitPositions) {
553
+ // Apply explicit positions first
554
+ for (const [id, pos] of explicitPositions) {
555
+ const node = diagramNodes.get(id);
556
+ if (node) {
557
+ node.x = pos.x;
558
+ node.y = pos.y;
559
+ }
560
+ }
561
+ // Auto-layout only unpositioned nodes using the same layer-based algorithm
562
+ const filtered = layers.map(l => l.filter(id => diagramNodes.has(id)));
563
+ let currentX = 0;
564
+ for (let i = 0; i < filtered.length; i++) {
565
+ const layerNodes = filtered[i];
566
+ if (layerNodes.length === 0) {
567
+ currentX += LAYER_GAP_X;
568
+ continue;
569
+ }
570
+ const unpositioned = layerNodes.filter(id => !explicitPositions.has(id));
571
+ const maxWidth = Math.max(...layerNodes.map(id => diagramNodes.get(id).width));
572
+ if (unpositioned.length > 0) {
573
+ const totalHeight = unpositioned.reduce((sum, id) => {
574
+ const n = diagramNodes.get(id);
575
+ return sum + n.height + LABEL_HEIGHT + LABEL_GAP;
576
+ }, 0) + (unpositioned.length - 1) * NODE_GAP_Y;
577
+ let currentY = -totalHeight / 2;
578
+ for (const id of unpositioned) {
579
+ const node = diagramNodes.get(id);
580
+ currentY += LABEL_HEIGHT + LABEL_GAP;
581
+ node.x = currentX + (maxWidth - node.width) / 2;
582
+ node.y = currentY;
583
+ currentY += node.height + NODE_GAP_Y;
584
+ }
585
+ }
586
+ // Label-aware edge gap
587
+ const nextLayerNodes = filtered[i + 1];
588
+ if (nextLayerNodes && nextLayerNodes.length > 0) {
589
+ const outputOverhang = layerOutputExtent(layerNodes, diagramNodes);
590
+ const inputOverhang = layerInputExtent(nextLayerNodes, diagramNodes);
591
+ const labelMinGap = outputOverhang + LABEL_CLEARANCE + inputOverhang;
592
+ const edgeGap = Math.max(labelMinGap, LAYER_GAP_X - maxWidth, MIN_EDGE_GAP);
593
+ currentX += maxWidth + edgeGap;
594
+ }
595
+ else {
596
+ currentX += maxWidth + LAYER_GAP_X;
597
+ }
598
+ }
599
+ }
600
+ /**
601
+ * After applying explicit positions, ensure no nodes overlap horizontally.
602
+ * Sorts nodes by x, then pushes any node whose left edge (minus its input
603
+ * label extent) intrudes into the previous node's right edge (plus its
604
+ * output label extent and clearance).
605
+ */
606
+ function resolveHorizontalOverlaps(diagramNodes) {
607
+ const nodes = [...diagramNodes.values()].sort((a, b) => a.x - b.x);
608
+ for (let i = 1; i < nodes.length; i++) {
609
+ const prev = nodes[i - 1];
610
+ const curr = nodes[i];
611
+ // Only external port labels overhang the node boundary.
612
+ // Scope inner-edge port labels face inward and don't extend past the node box.
613
+ const prevRightExtent = maxPortLabelExtent(prev.outputs);
614
+ const currLeftExtent = maxPortLabelExtent(curr.inputs);
615
+ const minGap = Math.max(prevRightExtent + LABEL_CLEARANCE + currLeftExtent, MIN_EDGE_GAP);
616
+ const actualGap = curr.x - (prev.x + prev.width);
617
+ if (actualGap < minGap) {
618
+ curr.x = prev.x + prev.width + minGap;
619
+ }
620
+ }
621
+ }
435
622
  // ---- Main orchestrator ----
436
623
  export function buildDiagramGraph(ast, options = {}) {
437
624
  const themeName = options.theme ?? 'dark';
@@ -545,30 +732,32 @@ export function buildDiagramGraph(ast, options = {}) {
545
732
  continue;
546
733
  buildScopeSubGraph(parentNode, parentNt, scopeName, childIds, ast, nodeTypeMap, themeName);
547
734
  }
548
- // Layout
549
- const { layers } = layoutWorkflow(ast);
550
- // Assign coordinates
551
- let currentX = 0;
552
- for (const layer of layers) {
553
- const layerNodes = layer.filter(id => diagramNodes.has(id));
554
- if (layerNodes.length === 0) {
555
- currentX += LAYER_GAP_X;
556
- continue;
557
- }
558
- const maxWidth = Math.max(...layerNodes.map(id => diagramNodes.get(id).width));
559
- const totalHeight = layerNodes.reduce((sum, id) => {
560
- const n = diagramNodes.get(id);
561
- return sum + n.height + LABEL_HEIGHT + LABEL_GAP;
562
- }, 0) + (layerNodes.length - 1) * NODE_GAP_Y;
563
- let currentY = -totalHeight / 2;
564
- for (const id of layerNodes) {
735
+ // Layout — use explicit positions when available, auto-layout otherwise
736
+ const explicitPositions = extractExplicitPositions(ast);
737
+ const allPositioned = [...diagramNodes.keys()].every(id => explicitPositions.has(id));
738
+ const nonePositioned = ![...diagramNodes.keys()].some(id => explicitPositions.has(id));
739
+ if (allPositioned) {
740
+ // All nodes have explicit positions — apply them
741
+ for (const [id, pos] of explicitPositions) {
565
742
  const node = diagramNodes.get(id);
566
- currentY += LABEL_HEIGHT + LABEL_GAP;
567
- node.x = currentX + (maxWidth - node.width) / 2;
568
- node.y = currentY;
569
- currentY += node.height + NODE_GAP_Y;
743
+ if (node) {
744
+ node.x = pos.x;
745
+ node.y = pos.y;
746
+ }
570
747
  }
571
- currentX += maxWidth + LAYER_GAP_X;
748
+ // Resolve horizontal overlaps: scope nodes may be wider than the
749
+ // original positions anticipated, so push downstream nodes apart.
750
+ resolveHorizontalOverlaps(diagramNodes);
751
+ }
752
+ else if (nonePositioned) {
753
+ // No positions — full auto-layout (original behavior)
754
+ const { layers } = layoutWorkflow(ast);
755
+ assignLayerCoordinates(layers, diagramNodes);
756
+ }
757
+ else {
758
+ // Mixed — explicit positions + auto-layout for remaining nodes
759
+ const { layers } = layoutWorkflow(ast);
760
+ assignUnpositionedNodes(layers, diagramNodes, explicitPositions);
572
761
  }
573
762
  // Compute external port positions
574
763
  for (const node of diagramNodes.values()) {