Skip to content

getImpactRecursive: inconsistent visited vs nodes gate causes incoming edges to be silently dropped #1089

Description

@inth3shadows

File: src/graph/traversal.ts
Lines: 506–509 (depth guard), 522 (container-children gate), 543 (incoming-edges gate)

Defect: getImpactRecursive uses two different sets to gate whether a node is re-processed:

  • Container-children loop (L522): if (childNode && !visited.has(childNode.id))
  • Incoming-edges loop (L543): if (sourceNode && !nodes.has(sourceNode.id))

The depth guard at L506–508 (if (currentDepth >= maxDepth || visited.has(nodeId)) return;) returns before visited.add(nodeId) at L509 when the depth limit fires. This means a node can be added to nodes (by its parent's loop) but absent from visited. When that node later appears as an incoming-edge source for a different node, the !nodes.has() gate at L543 skips it — and the corresponding edge is silently dropped from the result.

Failure trace:

  • Graph: container P has child method M. Both M and P have an incoming calls edge from Q.
  • getImpactRadius("Z", 2) where Z → P → M in the dependency chain; currentDepth=1 when reaching P.
  • getImpactRecursive(P, 2, 1, …): visited.add(P). Container loop: !visited.has(M) = true → nodes.set(M); recurse (M, 2, 1, …).
    • Inside M's recursion: visited.add(M). Incoming from Q: !nodes.has(Q) = true → nodes.set(Q); recurse (Q, 2, 2, …).
      • currentDepth=2 ≥ maxDepth=2return without visited.add(Q). Q is in nodes, not in visited.
  • Back in P's incoming-edges loop (L541): P has incoming from Q (Q→P).
    • !nodes.has(Q) = false (Q was added via M's processing). Gate fails → Q→P edge silently dropped.
  • Impact result is missing edge Q→P; Q's path to P via its direct edge is invisible.

Expected fix:
Unify the two gates to use the same set. Since visited tracks "fully processed" nodes while nodes tracks "collected" nodes, the correct guard for both loops should be !nodes.has() (skip if already in result) OR !visited.has() (re-process if not yet traversed). Given the depth-guard asymmetry, the simplest fix is to ensure visited.add(nodeId) always runs before any early-return from the depth arm:

if (visited.has(nodeId)) return;
visited.add(nodeId);                   // mark before depth check
if (currentDepth >= maxDepth) return;

Then both loops can safely use !visited.has() for consistency, or keep the nodes check (since a visited node will also be in nodes).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions