@@ -14,8 +14,10 @@ import (
1414)
1515
1616var (
17- prefix = flag .String ("prefix" , "" , "prefix to filter" )
18- verbose = flag .Bool ("v" , false , "verbose mode" )
17+ prefix = flag .String ("prefix" , "" , "prefix to filter" )
18+ verbose = flag .Bool ("v" , false , "verbose mode" )
19+ detectCycles = flag .Bool ("detect-cycles" , false ,
20+ "fail if the module-name graph (versions collapsed) contains cycles" )
1921)
2022
2123func main () {
@@ -42,6 +44,16 @@ func Main() int {
4244 deps .add (modPath , depPath )
4345 }
4446
47+ if * detectCycles {
48+ cycles := deps .findCycles ()
49+ for _ , c := range cycles {
50+ slog .Error ("Cycle detected" , "path" , strings .Join (c , " -> " ))
51+ }
52+ if len (cycles ) > 0 {
53+ return 2
54+ }
55+ }
56+
4557 deps .transitiveReduction ()
4658
4759 for m , ds := range deps .depsSorted () {
@@ -157,6 +169,65 @@ func (s *state) sawMod(path string) {
157169 }
158170}
159171
172+ // findCycles returns cycles between deps
173+ // deploy@v2.0.0 -> common@v2.0.0
174+ // common@v2.0.0 -> deploy@v0.5.0
175+ // deploy@v0.5.0 -> common@v1.0.0
176+ //
177+ // back-edge DFS algorithm
178+ // edge (u, v), v is visited and an ancestor of u = we have a cycle
179+ //
180+ // Caveat: reports one cycle per back-edge, not every simple cycle. A cycle is
181+ // hidden when its target has already been visited and popped via another path.
182+ // Example: edges {a->b, a->c, b->c, c->a} — DFS at a descends a->b->c, sees
183+ // c->a as a back-edge and reports [a,b,c]. The direct a->c->a cycle is missed
184+ // because c is no longer on the stack when the loop at a reaches it. Fixing
185+ // the reported cycle and re-running surfaces the hidden one, eventually, we'll find
186+ // all the cycles.
187+ // // Alternatively, Tarjan algorithm can be used to detect all the cycles in one run
188+ // but it's slightly more complex to understand.
189+ func (s * state ) findCycles () [][]string {
190+ var cycles [][]string
191+ visited := make (map [string ]struct {})
192+ // current stack of paths, ex.: a@v2.0 -> b@v1.0 -> c@v0.5
193+ stack := make ([]string , 0 )
194+ // positions on stack of paths, ex: {a:0, b:1, c:2}
195+ onStack := make (map [string ]int )
196+
197+ var dfs func (string )
198+ dfs = func (n string ) {
199+ visited [n ] = struct {}{}
200+ stack = append (stack , n )
201+ onStack [n ] = len (stack ) - 1
202+
203+ children := slices .Clone (s .deps [n ])
204+ slices .Sort (children )
205+ for _ , d := range children {
206+ if _ , ok := visited [d ]; ! ok {
207+ dfs (d )
208+ } else if idx , ok := onStack [d ]; ok {
209+ // cycle detected, clone [first_ancenstor:current] which has back-edge
210+ cycles = append (cycles , slices .Clone (stack [idx :]))
211+ }
212+ }
213+
214+ delete (onStack , n )
215+ stack = stack [:len (stack )- 1 ]
216+ }
217+
218+ deps := slices .Sorted (maps .Keys (s .deps ))
219+ for _ , dep := range deps {
220+ if _ , ok := visited [dep ]; ! ok {
221+ dfs (dep )
222+ }
223+ }
224+ // append the ancestor for printing, ex.: a->b->c->a
225+ for ci := range cycles {
226+ cycles [ci ] = append (cycles [ci ], cycles [ci ][0 ])
227+ }
228+ return cycles
229+ }
230+
160231func (s * state ) transitiveReduction () {
161232 noPath := make (map [string ]map [string ]struct {}) // [path][path]
162233
@@ -167,11 +238,18 @@ func (s *state) transitiveReduction() {
167238 s .deps [m ] = slices .DeleteFunc (deps , func (d string ) bool {
168239 // BFS for indirect paths to d, tracking nodes we touch along the way
169240 var touched []string
241+ // visited guards against cycles in the graph
242+ visited := make (map [string ]struct {})
170243 children := slices .DeleteFunc (slices .Clone (deps ), func (s string ) bool { return s == d }) // exclude direct
171244 for len (children ) > 0 {
172- touched = append (touched , children ... )
173245 var next []string
174246 for _ , child := range children {
247+ if _ , ok := visited [child ]; ok {
248+ continue
249+ }
250+ visited [child ] = struct {}{}
251+ touched = append (touched , child )
252+
175253 if child == d {
176254 if * verbose {
177255 slog .Info ("Excluding transitive edge" , "mod" , m , "dep" , d )
0 commit comments