* Get merged graph for display from cached files
(filePaths?: string[])
| 744 | * Get merged graph for display from cached files |
| 745 | */ |
| 746 | async getMergedGraph(filePaths?: string[]): Promise<WorkflowGraph | null> { |
| 747 | await this.initPromise; |
| 748 | |
| 749 | // Normalize input paths to relative paths (cache keys are always relative) |
| 750 | const targetFiles = filePaths |
| 751 | ? filePaths.map(fp => this.toRelativePath(fp)) |
| 752 | : Object.keys(this.files); |
| 753 | const allNodes: WorkflowNode[] = []; |
| 754 | const allEdges: WorkflowEdge[] = []; |
| 755 | const nodeIds = new Set<string>(); |
| 756 | const llmsDetected = new Set<string>(); |
| 757 | |
| 758 | // Collect from cached files (dedupe nodes by ID, keep most complete) |
| 759 | const nodeById = new Map<string, WorkflowNode>(); |
| 760 | for (const fp of targetFiles) { |
| 761 | const cached = this.files[fp]; |
| 762 | if (cached) { |
| 763 | for (const node of cached.nodes) { |
| 764 | const existing = nodeById.get(node.id); |
| 765 | // Keep node with more complete info (has source.line vs doesn't) |
| 766 | if (!existing || (node.source?.line && !existing.source?.line)) { |
| 767 | nodeById.set(node.id, node); |
| 768 | } |
| 769 | nodeIds.add(node.id); |
| 770 | if (node.model) llmsDetected.add(node.model); |
| 771 | } |
| 772 | allEdges.push(...cached.internalEdges); |
| 773 | } |
| 774 | } |
| 775 | allNodes.push(...nodeById.values()); |
| 776 | |
| 777 | // Sanitize purely-numeric labels (LLM sometimes returns sequence numbers instead of names). |
| 778 | // Replace with the function name from source metadata. |
| 779 | for (const node of allNodes) { |
| 780 | if (/^\d+$/.test(node.label) && node.source?.function) { |
| 781 | node.label = node.source.function.replace(/\(\)$/, ''); |
| 782 | } |
| 783 | } |
| 784 | |
| 785 | if (allNodes.length === 0) return null; |
| 786 | |
| 787 | // Add valid cross-file edges with fuzzy ID resolution |
| 788 | // This handles cases where LLM uses shortened paths (e.g., "file.py::func") |
| 789 | // but actual node IDs have full paths (e.g., "dir/file.py::func") |
| 790 | const lookup = buildNodeLookup(allNodes); |
| 791 | for (const edge of this.crossFileEdges) { |
| 792 | let resolvedSource = findMatchingNodeId(edge.sourceNodeId, lookup); |
| 793 | let resolvedTarget = findMatchingNodeId(edge.targetNodeId, lookup); |
| 794 | |
| 795 | // Create stub node for cross-file edge targets that don't exist as cached nodes. |
| 796 | if (resolvedSource && !resolvedTarget) { |
| 797 | const targetId = edge.targetNodeId; |
| 798 | const parts = targetId.split('::'); |
| 799 | const isRealFile = parts.length >= 2 && /\.\w+$/.test(parts[0]); |
| 800 | |
| 801 | let stubNode: WorkflowNode | null = null; |
| 802 | |
| 803 | if (isRealFile) { |
no test coverage detected