@@ -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
4548const (
@@ -48,6 +51,7 @@ const (
4851)
4952
5053var whyMaxPaths int
54+ var whyMaxDepth int
5155var whySplitTestOnly bool
5256
5357var 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.
209339func 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-
267375func 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" )
0 commit comments