* Traverse the graph using breadth-first search * * @param startId - Starting node ID * @param options - Traversal options * @returns Subgraph containing traversed nodes and edges
(startId: string, options: TraversalOptions = {})
| 46 | * @returns Subgraph containing traversed nodes and edges |
| 47 | */ |
| 48 | traverseBFS(startId: string, options: TraversalOptions = {}): Subgraph { |
| 49 | const opts = { ...DEFAULT_OPTIONS, ...options }; |
| 50 | const startNode = this.queries.getNodeById(startId); |
| 51 | |
| 52 | if (!startNode) { |
| 53 | return { nodes: new Map(), edges: [], roots: [] }; |
| 54 | } |
| 55 | |
| 56 | const nodes = new Map<string, Node>(); |
| 57 | const edges: Edge[] = []; |
| 58 | const visited = new Set<string>(); |
| 59 | // Enqueue-once guard, tracked separately from `visited` (which is only set |
| 60 | // on dequeue). Guarding the enqueue on `visited` alone let a target |
| 61 | // reachable via two edges get queued twice; the second dequeue then hit |
| 62 | // `visited.has → continue` and its edge was never recorded, so parallel |
| 63 | // edges (A calls AND references B, or two `calls` on different lines — edges |
| 64 | // are unique on source+target+kind+line+col) went missing from the result |
| 65 | // (#1090). `enqueued` makes each node queued exactly once. |
| 66 | const enqueued = new Set<string>([startNode.id]); |
| 67 | // Edge-identity dedup so a `direction:'both'` scan — which encounters A→B |
| 68 | // from both endpoints — records each edge once. |
| 69 | const seenEdges = new Set<string>(); |
| 70 | const edgeKey = (e: Edge) => |
| 71 | `${e.source}|${e.target}|${e.kind}|${e.line ?? -1}|${e.column ?? -1}`; |
| 72 | const queue: TraversalStep[] = [{ node: startNode, edge: null, depth: 0 }]; |
| 73 | |
| 74 | if (opts.includeStart) { |
| 75 | nodes.set(startNode.id, startNode); |
| 76 | } |
| 77 | |
| 78 | while (queue.length > 0 && nodes.size < opts.limit) { |
| 79 | const step = queue.shift()!; |
| 80 | const { node, depth } = step; |
| 81 | |
| 82 | if (visited.has(node.id)) { |
| 83 | continue; |
| 84 | } |
| 85 | visited.add(node.id); |
| 86 | |
| 87 | // Check depth limit |
| 88 | if (depth >= opts.maxDepth) { |
| 89 | continue; |
| 90 | } |
| 91 | |
| 92 | // Get adjacent edges, prioritizing structural edges (contains, calls) |
| 93 | // over reference edges so BFS discovers internal structure before |
| 94 | // fanning out to external references (e.g., component usages in templates). |
| 95 | const adjacentEdges = this.getAdjacentEdges(node.id, opts.direction, opts.edgeKinds); |
| 96 | adjacentEdges.sort((a, b) => { |
| 97 | const priority = (e: Edge) => e.kind === 'contains' ? 0 : e.kind === 'calls' ? 1 : 2; |
| 98 | return priority(a) - priority(b); |
| 99 | }); |
| 100 | |
| 101 | // Batch-fetch neighbors we might newly enqueue in one query (was N+1 per |
| 102 | // BFS step). Already-queued/visited neighbors are already in `nodes`, so |
| 103 | // they don't need re-fetching to record an edge back to them. |
| 104 | const wantIds = adjacentEdges |
| 105 | .map((e) => (e.source === node.id ? e.target : e.source)) |
no test coverage detected