Skip to content

Commit 26e8812

Browse files
committed
break cycles, add flag for detecting cycles
1 parent f3c6f98 commit 26e8812

6 files changed

Lines changed: 147 additions & 5 deletions

File tree

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,23 @@
55
```shell
66
$ modgraph --help
77
Usage of modgraph:
8+
-v verbose mode
89
-prefix string
9-
prefix to filter
10-
-v verbose mode
10+
prefix to filter
11+
-detect-cycles
12+
fail if the module-name graph (versions collapsed) contains cycles
1113
```
1214
1315
```shell
1416
go mod graph | modgraph -prefix github.com/smartcontractkit/
1517
```
1618
19+
```shell
20+
go mod graph | modgraph -prefix github.com/smartcontractkit/ -detect-cycles # fail if there any import cycles between modules
21+
```
22+
1723
## Example
24+
1825
```mermaid
1926
flowchart
2027
bar --> baz

main.go

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@ import (
1414
)
1515

1616
var (
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

2123
func 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+
160231
func (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)

testdata/cycle.txtar

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
stdin go-mod-graph.txt
2+
exec modgraph -prefix github.com/example/
3+
cmp stdout go.md
4+
5+
-- go-mod-graph.txt --
6+
github.com/example/a github.com/example/b
7+
github.com/example/a github.com/example/d
8+
github.com/example/b github.com/example/c
9+
github.com/example/c github.com/example/b
10+
11+
-- go.md --
12+
a --> b
13+
a --> d
14+
click a href "https://github.com/example/a"
15+
b --> c
16+
click b href "https://github.com/example/b"
17+
c --> b
18+
click c href "https://github.com/example/c"
19+
d
20+
click d href "https://github.com/example/d"
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
stdin go-mod-graph.txt
2+
exec modgraph -detect-cycles -prefix github.com/example/
3+
cmp stdout go.md
4+
5+
-- go-mod-graph.txt --
6+
github.com/example/foo github.com/example/bar
7+
github.com/example/bar github.com/example/baz
8+
9+
-- go.md --
10+
bar --> baz
11+
click bar href "https://github.com/example/bar"
12+
baz
13+
click baz href "https://github.com/example/baz"
14+
foo --> bar
15+
click foo href "https://github.com/example/foo"
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# {a->b, a->c, b->c, c->a} the only cycle reported is a->b->c->a. The
2+
# direct a->c->a cycle is hidden because c is no longer on the stack
3+
# when the loop at a reaches it. Re-running after the longer cycle is
4+
# fixed surfaces the shadowed one.
5+
stdin go-mod-graph.txt
6+
! exec modgraph -detect-cycles -prefix github.com/example/
7+
stderr 'Cycle detected.*a -> b -> c -> a'
8+
! stderr 'Cycle detected.*a -> c -> a'
9+
10+
-- go-mod-graph.txt --
11+
github.com/example/a github.com/example/b
12+
github.com/example/a github.com/example/c
13+
github.com/example/b github.com/example/c
14+
github.com/example/c github.com/example/a

testdata/detect-cycle.txtar

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
stdin go-mod-graph.txt
2+
! exec modgraph -detect-cycles -prefix github.com/example/
3+
stderr 'Cycle detected.*a -> b -> c -> a'
4+
5+
-- go-mod-graph.txt --
6+
github.com/example/a github.com/example/b
7+
github.com/example/b github.com/example/c
8+
github.com/example/c github.com/example/a

0 commit comments

Comments
 (0)