Repository: Effect-TS/effect (TypeScript)

Issue:
`Graph.neighbors()` returns self-loops in undirected graphs
### What version of Effect is running?

3.18.4

### What steps can reproduce the bug?

**Problem**: `Graph.neighbors()` traversal always returns `edge.target` without checking which endpoint is being queried.

**Simplest failing test**:

```typescript
import { Graph } from "effect";

// Single undirected edge: 0 -- 1
const graph = Graph.undirected<number, void>((mutable) => {
  Graph.addNode(mutable, 0);
  Graph.addNode(mutable, 1);
  Graph.addEdge(mutable, 0, 1, undefined);
});

console.log(Graph.neighbors(graph, 0)); // Output: [1] ✅ Correct
console.log(Graph.neighbors(graph, 1)); // Output: [1] ❌ Returns itself!
```

**Full test case**:

```typescript
import { describe, it, expect } from "vitest";
import { Graph } from "effect";

describe("Graph.neighbors() returns self-loops", () => {
  it("minimal case: single edge returns node itself", () => {
    const graph = Graph.undirected<number, void>((mutable) => {
      Graph.addNode(mutable, 0);
      Graph.addNode(mutable, 1);
      Graph.addEdge(mutable, 0, 1, undefined);
    });

    expect(Graph.neighbors(graph, 0)).toEqual([1]); // ✅ PASSES
    expect(Graph.neighbors(graph, 1)).toEqual([0]); // ❌ FAILS: returns [1]
  });

  it("linear graph: middle node includes itself", () => {
    const graph = Graph.undirected<number, void>((mutable) => {
      Graph.addNode(mutable, 0);
      Graph.addNode(mutable, 1);
      Graph.addNode(mutable, 2);
      Graph.addEdge(mutable, 0, 1, undefined);
      Graph.addEdge(mutable, 1, 2, undefined);
    });

    const neighbors1 = Graph.neighbors(graph, 1);
    expect(neighbors1.sort()).toEqual([0, 2]); // ❌ FAILS: returns [1, 2]
  });
});
```

**Environment details**:

- **Effect version**: 3.18.4
- **Node version**: v22.21.0
- **TypeScript version**: 5.9.3
- **Package manager**: pnpm 10.13.1
- **OS**: macOS 15.6.1 

### What is the expected behavior?

For an undirected graph, `Graph.neighbors(graph, nodeIndex)` should return all nodes connected to `nodeIndex` (i.e., the "other endpoint" of each edge), never the node itself.

**Minimal example**:

```typescript
const graph = Graph.undirected<number, void>((mutable) => {
  Graph.addNode(mutable, 0);
  Graph.addNode(mutable, 1);
  Graph.addEdge(mutable, 0, 1, undefined); // Single edge: 0 -- 1
});

// Expected for undirected graph
Graph.neighbors(graph, 0); // [1] - node 0's neighbor is 1
Graph.neighbors(graph, 1); // [0] - node 1's neighbor is 0
```

### What do you see instead?

**From minimal example above**: 

```typescript
Graph.neighbors(graph, 0); // [1] ✅ Correct
Graph.neighbors(graph, 1); // [1] ❌ Returns itself!
```

**With more nodes** (0 -- 1 -- 2):

```typescript
Graph.neighbors(graph, 0); // [1]    ✅ Correct
Graph.neighbors(graph, 1); // [1, 2] ❌ Expected [0, 2], includes itself
Graph.neighbors(graph, 2); // [2]    ❌ Expected [1], returns itself
```

### Additional information

**Discovery**: Found while implementing network topology generation for scientific simulation research using Effect.Graph for small-world and scale-free networks. Consumers of `Graph.neighbors()` like `dfs` and `bfs` may produce incorrect traversals for undirected graphs.

**Current workaround**: Build adjacency lists directly from edges instead of using `Graph.neighbors()`:

```typescript
// Our workaround for undirected graphs
const buildAdjacency = (graph: Graph.UndirectedGraph<N, E>) => {
  const adj = new Map<number, number[]>();

  for (const [idx, edge] of graph.edges) {
    adj.get(edge.source)?.push(edge.target);
    adj.get(edge.target)?.push(edge.source); // Add both directions
  }

  return adj;
};
```

**Proposed fix**: Effect.Graph already has correct logic in the private `getUndirectedNeighbors()` helper ([`Graph.ts#L1869-L1889`](https://github.com/Effect-TS/effect/blob/main/packages/effect/src/Graph.ts#L1869-L1889)). `Graph.neighbors()` could be updated to dispatch to/use this logic for undirected graphs.

**Affects effect-smol v4 preview**: The same bug exists in [`effect-smol/packages/effect/src/collections/Graph.ts#L1478-L1496`](https://github.com/Effect-TS/effect-smol/blob/main/packages/effect/src/collections/Graph.ts#L1478-L1496). Both repositories should be updated with the same fix.


Discussion:
Hey there, fellow Graph enjoyor. Good find. Do you want to provide a fix as your first contribution to Effect? I'm happy to open a PR if not. Thanks for reporting!

EDIT: Nvm. I just went ahead and opened a PR
Ah beat me to it! Thanks for the putting together the quick PR on this.
Sorry, you sniped me right when I had a few minutes of spare time between meetings :-)



Patch:
diff --git a/.changeset/fix-graph-neighbors-undirected.md b/.changeset/fix-graph-neighbors-undirected.md
new file mode 100644
index 00000000000..c06f85e526b
--- /dev/null
+++ b/.changeset/fix-graph-neighbors-undirected.md
@@ -0,0 +1,7 @@
+---
+"effect": patch
+---
+
+Fix Graph.neighbors() returning self-loops in undirected graphs.
+
+Graph.neighbors() now correctly returns the other endpoint for undirected graphs instead of always returning edge.target, which caused nodes to appear as their own neighbors when queried from the target side of an edge.
diff --git a/packages/effect/src/Graph.ts b/packages/effect/src/Graph.ts
index 4babd77d569..fdf5c69eca0 100644
--- a/packages/effect/src/Graph.ts
+++ b/packages/effect/src/Graph.ts
@@ -1488,6 +1488,11 @@ export const neighbors = <N, E, T extends Kind = "directed">(
   graph: Graph<N, E, T> | MutableGraph<N, E, T>,
   nodeIndex: NodeIndex
 ): Array<NodeIndex> => {
+  // For undirected graphs, use the specialized helper that returns the other endpoint
+  if (graph.type === "undirected") {
+    return getUndirectedNeighbors(graph as any, nodeIndex)
+  }
+
   const adjacencyList = getMapSafe(graph.adjacency, nodeIndex)
   if (Option.isNone(adjacencyList)) {
     return []


Test patch:
diff --git a/packages/effect/test/Graph.test.ts b/packages/effect/test/Graph.test.ts
index 47a1ce79766..7bc96468842 100644
--- a/packages/effect/test/Graph.test.ts
+++ b/packages/effect/test/Graph.test.ts
@@ -1497,6 +1497,65 @@ describe("Graph", () => {
       })
     })
 
+    describe("neighbors with undirected graphs", () => {
+      it("should return correct neighbors for single edge", () => {
+        const graph = Graph.undirected<number, void>((mutable) => {
+          Graph.addNode(mutable, 0)
+          Graph.addNode(mutable, 1)
+          Graph.addEdge(mutable, 0, 1, undefined)
+        })
+
+        expect(Graph.neighbors(graph, 0)).toEqual([1])
+        expect(Graph.neighbors(graph, 1)).toEqual([0])
+      })
+
+      it("should return correct neighbors for linear graph", () => {
+        const graph = Graph.undirected<number, void>((mutable) => {
+          Graph.addNode(mutable, 0)
+          Graph.addNode(mutable, 1)
+          Graph.addNode(mutable, 2)
+          Graph.addEdge(mutable, 0, 1, undefined)
+          Graph.addEdge(mutable, 1, 2, undefined)
+        })
+
+        expect(Graph.neighbors(graph, 0)).toEqual([1])
+        expect(Graph.neighbors(graph, 1).sort()).toEqual([0, 2])
+        expect(Graph.neighbors(graph, 2)).toEqual([1])
+      })
+
+      it("should handle multiple edges between same nodes", () => {
+        const graph = Graph.undirected<number, void>((mutable) => {
+          Graph.addNode(mutable, 0)
+          Graph.addNode(mutable, 1)
+          Graph.addEdge(mutable, 0, 1, undefined)
+          Graph.addEdge(mutable, 0, 1, undefined)
+        })
+
+        // Should deduplicate neighbors
+        expect(Graph.neighbors(graph, 0)).toEqual([1])
+        expect(Graph.neighbors(graph, 1)).toEqual([0])
+      })
+
+      it("should handle self-loops", () => {
+        const graph = Graph.undirected<number, void>((mutable) => {
+          Graph.addNode(mutable, 0)
+          Graph.addEdge(mutable, 0, 0, undefined)
+        })
+
+        expect(Graph.neighbors(graph, 0)).toEqual([0])
+      })
+
+      it("should handle node with no neighbors", () => {
+        const graph = Graph.undirected<number, void>((mutable) => {
+          Graph.addNode(mutable, 0)
+          Graph.addNode(mutable, 1)
+        })
+
+        expect(Graph.neighbors(graph, 0)).toEqual([])
+        expect(Graph.neighbors(graph, 1)).toEqual([])
+      })
+    })
+
     describe("neighborsDirected", () => {
       it("should return incoming neighbors", () => {
         let nodeA: Graph.NodeIndex