Skip to content

Commit cb7a774

Browse files
authored
Merge pull request #103 from dims/fix-why-svg-otel-timeout
Fix why SVG/DOT subgraph pruning
2 parents b1177e7 + 45eb8b4 commit cb7a774

File tree

3 files changed

+354
-57
lines changed

3 files changed

+354
-57
lines changed

cmd/why.go

Lines changed: 160 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ type WhyResult struct {
4040
MainModules []string `json:"mainModules"`
4141
Truncated bool `json:"truncated,omitempty"`
4242
TotalPaths int `json:"totalPaths,omitempty"`
43+
// Pre-computed graph data for SVG/DOT output (avoids expensive path enumeration)
44+
NodeSet map[string]bool `json:"-"`
45+
EdgeSet map[svgEdge]bool `json:"-"`
4346
}
4447

4548
const (
@@ -48,6 +51,7 @@ const (
4851
)
4952

5053
var whyMaxPaths int
54+
var whyMaxDepth int
5155
var whySplitTestOnly bool
5256

5357
var whyCmd = &cobra.Command{
@@ -132,17 +136,46 @@ func runWhy(cmd *cobra.Command, args []string) error {
132136
}
133137
sort.Strings(result.DirectDeps)
134138

135-
// Pre-compute which nodes can reach the target to avoid full-graph DFS.
139+
// Pre-compute which nodes can reach the target to prune DFS.
136140
reachable := computeReachableToTarget(target, depGraph.Graph)
137-
if !reachable[target] {
141+
anyMainReachable := false
142+
for _, mainMod := range depGraph.MainModules {
143+
if reachable[mainMod] {
144+
anyMainReachable = true
145+
break
146+
}
147+
}
148+
if !anyMainReachable {
138149
if jsonOutput {
139150
return outputWhyJSON(result)
140151
}
141152
fmt.Printf("Dependency %q not reachable from any main module.\n", target)
142153
return nil
143154
}
144155

145-
// Find all paths from main modules to target.
156+
// For SVG/DOT output, compute the path subgraph directly in O(V+E)
157+
// instead of enumerating individual paths (which can be exponentially slow).
158+
if svgOutput || dotOutput {
159+
nodeSet, edgeSet := computePathSubgraph(depGraph.MainModules, depGraph.Graph, reachable)
160+
if len(nodeSet) == 0 {
161+
if svgOutput {
162+
return outputWhySVG(result)
163+
}
164+
fmt.Printf("Dependency %q found in graph, but no paths were discovered.\n", target)
165+
return nil
166+
}
167+
result.NodeSet = nodeSet
168+
result.EdgeSet = edgeSet
169+
result.TotalPaths = len(edgeSet) // edge count as proxy for header
170+
fmt.Fprintf(cmd.ErrOrStderr(), "[depstat why] subgraph nodes=%d edges=%d\n", len(nodeSet), len(edgeSet))
171+
172+
if dotOutput {
173+
return outputWhyDOT(result, depGraph)
174+
}
175+
return outputWhySVG(result)
176+
}
177+
178+
// For text/JSON output, enumerate individual paths using DFS.
146179
var allPaths [][]string
147180
if len(depGraph.MainModules) == 0 {
148181
return fmt.Errorf("no main modules available to search")
@@ -193,19 +226,116 @@ func runWhy(cmd *cobra.Command, args []string) error {
193226
})
194227
result.TotalPaths = len(result.Paths)
195228

196-
outputFn := outputWhyText
197229
if jsonOutput {
198-
outputFn = outputWhyJSON
199-
} else if dotOutput {
200-
outputFn = func(res WhyResult) error { return outputWhyDOT(res, depGraph) }
201-
} else if svgOutput {
202-
outputFn = outputWhySVG
230+
return outputWhyJSON(result)
231+
}
232+
return outputWhyText(result)
233+
}
234+
235+
// computeReachableToTarget does a reverse BFS from target to find all nodes
236+
// that can reach it, allowing DFS to prune dead-end branches early.
237+
func computeReachableToTarget(target string, graph map[string][]string) map[string]bool {
238+
// Build reverse adjacency list
239+
reverse := make(map[string][]string)
240+
for from, tos := range graph {
241+
for _, to := range tos {
242+
reverse[to] = append(reverse[to], from)
243+
}
203244
}
204-
return outputFn(result)
245+
// BFS backward from target
246+
reachable := map[string]bool{target: true}
247+
queue := []string{target}
248+
for len(queue) > 0 {
249+
cur := queue[0]
250+
queue = queue[1:]
251+
for _, prev := range reverse[cur] {
252+
if !reachable[prev] {
253+
reachable[prev] = true
254+
queue = append(queue, prev)
255+
}
256+
}
257+
}
258+
return reachable
259+
}
260+
261+
// computePathSubgraph computes the set of nodes and edges that lie on any path
262+
// from a main module to the target, using bidirectional reachability in O(V+E).
263+
// The 'reachable' map (backward BFS from target) must already be computed.
264+
func computePathSubgraph(mainModules []string, graph map[string][]string, reachable map[string]bool) (map[string]bool, map[svgEdge]bool) {
265+
// Forward BFS from main modules, constrained to nodes that can reach the target.
266+
forward := make(map[string]bool)
267+
queue := make([]string, 0)
268+
for _, m := range mainModules {
269+
if reachable[m] {
270+
forward[m] = true
271+
queue = append(queue, m)
272+
}
273+
}
274+
for len(queue) > 0 {
275+
cur := queue[0]
276+
queue = queue[1:]
277+
for _, next := range graph[cur] {
278+
if reachable[next] && !forward[next] {
279+
forward[next] = true
280+
queue = append(queue, next)
281+
}
282+
}
283+
}
284+
285+
// Collect edges between path nodes while removing back-edges from cycles.
286+
nodeSet := forward // forward ⊆ reachable by construction
287+
adj := make(map[string][]string)
288+
nodes := make([]string, 0, len(nodeSet))
289+
for node := range nodeSet {
290+
nodes = append(nodes, node)
291+
}
292+
sort.Strings(nodes)
293+
for _, from := range nodes {
294+
for _, to := range graph[from] {
295+
if nodeSet[to] {
296+
adj[from] = append(adj[from], to)
297+
}
298+
}
299+
sort.Strings(adj[from])
300+
}
301+
302+
const (
303+
visitUnseen = 0
304+
visitActive = 1
305+
visitDone = 2
306+
)
307+
state := make(map[string]int)
308+
edgeSet := make(map[svgEdge]bool)
309+
310+
var dfs func(string)
311+
dfs = func(cur string) {
312+
state[cur] = visitActive
313+
for _, next := range adj[cur] {
314+
switch state[next] {
315+
case visitUnseen:
316+
edgeSet[svgEdge{From: cur, To: next}] = true
317+
dfs(next)
318+
case visitActive:
319+
// Skip back-edge to avoid cycles in the rendered graph.
320+
default:
321+
edgeSet[svgEdge{From: cur, To: next}] = true
322+
}
323+
}
324+
state[cur] = visitDone
325+
}
326+
327+
for _, node := range nodes {
328+
if state[node] == visitUnseen {
329+
dfs(node)
330+
}
331+
}
332+
333+
return nodeSet, edgeSet
205334
}
206335

207336
// findAllPaths finds paths from start to target using DFS and appends to out.
208337
// If maxPaths > 0, search stops once out reaches maxPaths.
338+
// If whyMaxDepth > 0, paths longer than whyMaxDepth hops are pruned.
209339
func findAllPaths(start, target string, graph map[string][]string, reachable map[string]bool, currentPath []string, visited map[string]bool, out *[][]string, maxPaths int) {
210340
if start == target {
211341
pathCopy := make([]string, len(currentPath)+1)
@@ -219,6 +349,9 @@ func findAllPaths(start, target string, graph map[string][]string, reachable map
219349
if !reachable[start] {
220350
return
221351
}
352+
if whyMaxDepth > 0 && len(currentPath) >= whyMaxDepth {
353+
return
354+
}
222355

223356
currentPath = append(currentPath, start)
224357

@@ -239,31 +372,6 @@ func findAllPaths(start, target string, graph map[string][]string, reachable map
239372
}
240373
}
241374

242-
func computeReachableToTarget(target string, graph map[string][]string) map[string]bool {
243-
rev := make(map[string][]string)
244-
for from, tos := range graph {
245-
for _, to := range tos {
246-
rev[to] = append(rev[to], from)
247-
}
248-
}
249-
reachable := map[string]bool{}
250-
queue := []string{target}
251-
for len(queue) > 0 {
252-
current := queue[0]
253-
queue = queue[1:]
254-
if reachable[current] {
255-
continue
256-
}
257-
reachable[current] = true
258-
for _, prev := range rev[current] {
259-
if !reachable[prev] {
260-
queue = append(queue, prev)
261-
}
262-
}
263-
}
264-
return reachable
265-
}
266-
267375
func outputWhyJSON(result WhyResult) error {
268376
out, err := json.MarshalIndent(result, "", "\t")
269377
if err != nil {
@@ -329,16 +437,25 @@ func outputWhyDOT(result WhyResult, depGraph *DependencyOverview) error {
329437
fmt.Println("node [shape=box, style=filled, fillcolor=white];")
330438
fmt.Println()
331439

332-
// Collect all nodes and edges from paths
440+
// Use pre-computed subgraph if available, otherwise extract from paths.
333441
nodes := make(map[string]bool)
334442
edges := make(map[string]bool)
335443

336-
for _, wp := range result.Paths {
337-
for i, node := range wp.Path {
338-
nodes[node] = true
339-
if i > 0 {
340-
edge := fmt.Sprintf("%s -> %s", wp.Path[i-1], node)
341-
edges[edge] = true
444+
if result.NodeSet != nil {
445+
for n := range result.NodeSet {
446+
nodes[n] = true
447+
}
448+
for e := range result.EdgeSet {
449+
edges[fmt.Sprintf("%s -> %s", e.From, e.To)] = true
450+
}
451+
} else {
452+
for _, wp := range result.Paths {
453+
for i, node := range wp.Path {
454+
nodes[node] = true
455+
if i > 0 {
456+
edge := fmt.Sprintf("%s -> %s", wp.Path[i-1], node)
457+
edges[edge] = true
458+
}
342459
}
343460
}
344461
}
@@ -386,6 +503,7 @@ func init() {
386503
whyCmd.Flags().BoolVarP(&dotOutput, "dot", "", false, "Output in DOT format for Graphviz")
387504
whyCmd.Flags().BoolVarP(&svgOutput, "svg", "s", false, "Output as self-contained SVG diagram")
388505
whyCmd.Flags().IntVar(&whyMaxPaths, "max-paths", whyDefaultMaxPaths, "Maximum dependency paths to search. Set 0 for no limit")
506+
whyCmd.Flags().IntVar(&whyMaxDepth, "max-depth", 0, "Maximum path depth in hops (0 = unlimited). Useful for limiting DFS on deep graphs")
389507
whyCmd.Flags().BoolVar(&whySplitTestOnly, "split-test-only", false, "Exclude test-only dependencies when finding paths (uses go mod why -m)")
390508
whyCmd.Flags().StringSliceVar(&excludeModules, "exclude-modules", []string{}, "Exclude module path patterns (repeatable, supports * wildcard)")
391509
whyCmd.Flags().StringSliceVarP(&mainModules, "mainModules", "m", []string{}, "Specify main modules")

cmd/why_svg.go

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -50,26 +50,35 @@ const (
5050
)
5151

5252
func outputWhySVG(result WhyResult) error {
53-
if !result.Found || len(result.Paths) == 0 {
53+
// Use pre-computed subgraph if available (O(V+E) fast path),
54+
// otherwise extract from enumerated paths (backward compat).
55+
var nodeSet map[string]bool
56+
var edgeSet map[svgEdge]bool
57+
58+
if result.NodeSet != nil {
59+
nodeSet = result.NodeSet
60+
edgeSet = result.EdgeSet
61+
} else if len(result.Paths) > 0 {
62+
nodeSet = make(map[string]bool)
63+
edgeSet = make(map[svgEdge]bool)
64+
for _, wp := range result.Paths {
65+
for i, node := range wp.Path {
66+
nodeSet[node] = true
67+
if i > 0 {
68+
edgeSet[svgEdge{From: wp.Path[i-1], To: node}] = true
69+
}
70+
}
71+
}
72+
}
73+
74+
if len(nodeSet) == 0 {
5475
fmt.Printf(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="80">
5576
<text x="200" y="40" text-anchor="middle" font-family="sans-serif" font-size="14">No dependency paths found for %s</text>
5677
</svg>
5778
`, xmlEscape(result.Target))
5879
return nil
5980
}
6081

61-
// Extract unique nodes and edges from paths
62-
nodeSet := make(map[string]bool)
63-
edgeSet := make(map[svgEdge]bool)
64-
for _, wp := range result.Paths {
65-
for i, node := range wp.Path {
66-
nodeSet[node] = true
67-
if i > 0 {
68-
edgeSet[svgEdge{From: wp.Path[i-1], To: node}] = true
69-
}
70-
}
71-
}
72-
7382
// Assign layers via BFS using longest path from root
7483
layerOf := assignLayers(nodeSet, edgeSet, result)
7584

@@ -147,7 +156,11 @@ func outputWhySVG(result WhyResult) error {
147156
// Title
148157
fmt.Fprintf(&b, `<text x="%.1f" y="28" text-anchor="middle" font-size="14" font-weight="600" fill="#333">Why is %s included?</text>`, svgWidth/2, xmlEscape(result.Target))
149158
fmt.Fprintln(&b)
150-
fmt.Fprintf(&b, `<text x="%.1f" y="46" text-anchor="middle" font-size="11" fill="#888">%d paths, %d direct dependent(s)</text>`, svgWidth/2, len(result.Paths), len(result.DirectDeps))
159+
pathCount := len(result.Paths)
160+
if pathCount == 0 {
161+
pathCount = result.TotalPaths
162+
}
163+
fmt.Fprintf(&b, `<text x="%.1f" y="46" text-anchor="middle" font-size="11" fill="#888">%d paths, %d direct dependent(s)</text>`, svgWidth/2, pathCount, len(result.DirectDeps))
151164
fmt.Fprintln(&b)
152165

153166
// Legend
@@ -236,8 +249,15 @@ func assignLayers(nodeSet map[string]bool, edgeSet map[svgEdge]bool, result WhyR
236249
}
237250
}
238251

239-
// BFS assigning max depth
252+
// BFS assigning max depth, with safety cap to prevent infinite loops
253+
// in case the edge set contains cycles.
254+
maxIter := len(nodeSet) * len(nodeSet) * 2
255+
iter := 0
240256
for len(queue) > 0 {
257+
if iter >= maxIter {
258+
break
259+
}
260+
iter++
241261
cur := queue[0]
242262
queue = queue[1:]
243263
for _, next := range adj[cur] {

0 commit comments

Comments
 (0)