internal/graph: add AllPaths, use in cmd/digraph

Change-Id: Icd748dae9383580341f899cbf7242f4b4a5ee129
Reviewed-on: https://go-review.googlesource.com/c/tools/+/747367
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Alan Donovan <adonovan@google.com>
diff --git a/cmd/digraph/digraph.go b/cmd/digraph/digraph.go
index 4da43b5..2a9ac84 100644
--- a/cmd/digraph/digraph.go
+++ b/cmd/digraph/digraph.go
@@ -156,17 +156,7 @@
 }
 
 func (g digraph) allpaths(from, to string) error {
-	// We intersect the forward closure of 'from' with
-	// the reverse closure of 'to'. This is not the most
-	// efficient implementation, but it's the clearest,
-	// and the previous one had bugs.
-	seen := nodeset(graph.Reachable(g, from))
-	rev := nodeset(graph.Reachable(g.transpose(), to))
-	for n := range seen {
-		if !rev[n] {
-			delete(seen, n)
-		}
-	}
+	seen := graph.AllPaths(g, from, to)
 
 	// For each marked node, collect its marked successors.
 	var edges []string
diff --git a/internal/graph/allpaths.go b/internal/graph/allpaths.go
new file mode 100644
index 0000000..4473255
--- /dev/null
+++ b/internal/graph/allpaths.go
@@ -0,0 +1,24 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package graph
+
+// AllPaths returns the set of nodes that are part of at least one path from src to dst.
+func AllPaths[NodeID comparable](g Graph[NodeID], src, dst NodeID) map[NodeID]bool {
+	// We intersect the forward closure of 'src' with
+	// the reverse closure of 'dst'. This is not the most
+	// efficient implementation, but it's the clearest,
+	// and the previous one had bugs.
+
+	fwd := Reachable(g, src)
+	rev := Reachable(Transpose(g), dst)
+
+	// Intersection
+	for n := range fwd {
+		if !rev[n] {
+			delete(fwd, n)
+		}
+	}
+	return fwd
+}
diff --git a/internal/graph/allpaths_test.go b/internal/graph/allpaths_test.go
new file mode 100644
index 0000000..acab0be
--- /dev/null
+++ b/internal/graph/allpaths_test.go
@@ -0,0 +1,106 @@
+// Copyright 2026 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package graph
+
+import (
+	"maps"
+	"slices"
+	"testing"
+)
+
+func TestAllPaths(t *testing.T) {
+	g := stringGraph{
+		"A": {"B", "C"},
+		"B": {"D"},
+		"C": {"D", "E"},
+		"D": {"F"},
+		"E": {},
+		"F": {},
+	}
+
+	// Paths in g:
+	//
+	// A->B->D->F
+	// A->C->D->F
+	// A->C->E
+
+	// AllPaths(A, F) should include A, B, C, D, F. E is not on path to F.
+	got := slices.Sorted(maps.Keys(AllPaths(g, "A", "F")))
+	want := []string{"A", "B", "C", "D", "F"}
+	if !slices.Equal(got, want) {
+		t.Errorf("AllPaths(A, F) = %v, want %v", got, want)
+	}
+
+	// AllPaths(A, E) -> A, C, E. B is not on path.
+	got = slices.Sorted(maps.Keys(AllPaths(g, "A", "E")))
+	want = []string{"A", "C", "E"}
+	if !slices.Equal(got, want) {
+		t.Errorf("AllPaths(A, E) = %v, want %v", got, want)
+	}
+}
+
+func TestAllPaths_74842(t *testing.T) {
+	tests := []struct {
+		name     string
+		g        stringGraph
+		src, dst string
+		want     []string
+	}{
+		{
+			// C <--> B --> A --> D <--> E
+			//        ⋃
+			name: "non-regression test for #74842",
+			g: stringGraph{
+				"A": {"D"},
+				"B": {"A", "B", "C"},
+				"C": {"B"},
+				"D": {"E"},
+				"E": {"D"},
+			},
+			src: "A", dst: "D",
+			want: []string{"A", "D", "E"},
+		},
+		{
+			// A --> B --> D
+			//       ^
+			//       v
+			//       C[123]
+			name: "regression test for #74842",
+			g: stringGraph{
+				"A":  {"B"},
+				"B":  {"C1", "C2", "C3", "D"},
+				"C1": {"B"},
+				"C2": {"B"},
+				"C3": {"B"},
+			},
+			src: "A", dst: "D",
+			want: []string{"A", "B", "C1", "C2", "C3", "D"},
+		},
+		{
+			// A -------> B --> D
+			//  \--> C ---^     |
+			//       ^----------+
+			name: "another regression test for #74842",
+			g: stringGraph{
+				"A": {"B", "C"},
+				"B": {"D"},
+				"C": {"B"},
+				"D": {"C"},
+			},
+			src: "A", dst: "D",
+			want: []string{"A", "B", "C", "D"},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			allPaths := AllPaths(tt.g, tt.src, tt.dst)
+			got := slices.Sorted(maps.Keys(allPaths))
+			if !slices.Equal(got, tt.want) {
+				t.Errorf("got %v, want %v", got, tt.want)
+			}
+		})
+	}
+}