From 26e88121f078fcd93fb8c52ebdf01acf5bd1b1eb Mon Sep 17 00:00:00 2001 From: skudasov Date: Wed, 13 May 2026 19:38:40 +0200 Subject: [PATCH] break cycles, add flag for detecting cycles --- README.md | 11 +++- main.go | 84 +++++++++++++++++++++++++++- testdata/cycle.txtar | 20 +++++++ testdata/detect-cycle-acyclic.txtar | 15 +++++ testdata/detect-cycle-shadowed.txtar | 14 +++++ testdata/detect-cycle.txtar | 8 +++ 6 files changed, 147 insertions(+), 5 deletions(-) create mode 100644 testdata/cycle.txtar create mode 100644 testdata/detect-cycle-acyclic.txtar create mode 100644 testdata/detect-cycle-shadowed.txtar create mode 100644 testdata/detect-cycle.txtar diff --git a/README.md b/README.md index da9d7fb..7507989 100644 --- a/README.md +++ b/README.md @@ -5,16 +5,23 @@ ```shell $ modgraph --help Usage of modgraph: + -v verbose mode -prefix string - prefix to filter - -v verbose mode + prefix to filter + -detect-cycles + fail if the module-name graph (versions collapsed) contains cycles ``` ```shell go mod graph | modgraph -prefix github.com/smartcontractkit/ ``` +```shell +go mod graph | modgraph -prefix github.com/smartcontractkit/ -detect-cycles # fail if there any import cycles between modules +``` + ## Example + ```mermaid flowchart bar --> baz diff --git a/main.go b/main.go index 45df17c..4183968 100644 --- a/main.go +++ b/main.go @@ -14,8 +14,10 @@ import ( ) var ( - prefix = flag.String("prefix", "", "prefix to filter") - verbose = flag.Bool("v", false, "verbose mode") + prefix = flag.String("prefix", "", "prefix to filter") + verbose = flag.Bool("v", false, "verbose mode") + detectCycles = flag.Bool("detect-cycles", false, + "fail if the module-name graph (versions collapsed) contains cycles") ) func main() { @@ -42,6 +44,16 @@ func Main() int { deps.add(modPath, depPath) } + if *detectCycles { + cycles := deps.findCycles() + for _, c := range cycles { + slog.Error("Cycle detected", "path", strings.Join(c, " -> ")) + } + if len(cycles) > 0 { + return 2 + } + } + deps.transitiveReduction() for m, ds := range deps.depsSorted() { @@ -157,6 +169,65 @@ func (s *state) sawMod(path string) { } } +// findCycles returns cycles between deps +// deploy@v2.0.0 -> common@v2.0.0 +// common@v2.0.0 -> deploy@v0.5.0 +// deploy@v0.5.0 -> common@v1.0.0 +// +// back-edge DFS algorithm +// edge (u, v), v is visited and an ancestor of u = we have a cycle +// +// Caveat: reports one cycle per back-edge, not every simple cycle. A cycle is +// hidden when its target has already been visited and popped via another path. +// Example: edges {a->b, a->c, b->c, c->a} — DFS at a descends a->b->c, sees +// c->a as a back-edge and reports [a,b,c]. The direct a->c->a cycle is missed +// because c is no longer on the stack when the loop at a reaches it. Fixing +// the reported cycle and re-running surfaces the hidden one, eventually, we'll find +// all the cycles. +// // Alternatively, Tarjan algorithm can be used to detect all the cycles in one run +// but it's slightly more complex to understand. +func (s *state) findCycles() [][]string { + var cycles [][]string + visited := make(map[string]struct{}) + // current stack of paths, ex.: a@v2.0 -> b@v1.0 -> c@v0.5 + stack := make([]string, 0) + // positions on stack of paths, ex: {a:0, b:1, c:2} + onStack := make(map[string]int) + + var dfs func(string) + dfs = func(n string) { + visited[n] = struct{}{} + stack = append(stack, n) + onStack[n] = len(stack) - 1 + + children := slices.Clone(s.deps[n]) + slices.Sort(children) + for _, d := range children { + if _, ok := visited[d]; !ok { + dfs(d) + } else if idx, ok := onStack[d]; ok { + // cycle detected, clone [first_ancenstor:current] which has back-edge + cycles = append(cycles, slices.Clone(stack[idx:])) + } + } + + delete(onStack, n) + stack = stack[:len(stack)-1] + } + + deps := slices.Sorted(maps.Keys(s.deps)) + for _, dep := range deps { + if _, ok := visited[dep]; !ok { + dfs(dep) + } + } + // append the ancestor for printing, ex.: a->b->c->a + for ci := range cycles { + cycles[ci] = append(cycles[ci], cycles[ci][0]) + } + return cycles +} + func (s *state) transitiveReduction() { noPath := make(map[string]map[string]struct{}) // [path][path] @@ -167,11 +238,18 @@ func (s *state) transitiveReduction() { s.deps[m] = slices.DeleteFunc(deps, func(d string) bool { // BFS for indirect paths to d, tracking nodes we touch along the way var touched []string + // visited guards against cycles in the graph + visited := make(map[string]struct{}) children := slices.DeleteFunc(slices.Clone(deps), func(s string) bool { return s == d }) // exclude direct for len(children) > 0 { - touched = append(touched, children...) var next []string for _, child := range children { + if _, ok := visited[child]; ok { + continue + } + visited[child] = struct{}{} + touched = append(touched, child) + if child == d { if *verbose { slog.Info("Excluding transitive edge", "mod", m, "dep", d) diff --git a/testdata/cycle.txtar b/testdata/cycle.txtar new file mode 100644 index 0000000..01b4365 --- /dev/null +++ b/testdata/cycle.txtar @@ -0,0 +1,20 @@ +stdin go-mod-graph.txt +exec modgraph -prefix github.com/example/ +cmp stdout go.md + +-- go-mod-graph.txt -- +github.com/example/a github.com/example/b +github.com/example/a github.com/example/d +github.com/example/b github.com/example/c +github.com/example/c github.com/example/b + +-- go.md -- + a --> b + a --> d + click a href "https://github.com/example/a" + b --> c + click b href "https://github.com/example/b" + c --> b + click c href "https://github.com/example/c" + d + click d href "https://github.com/example/d" diff --git a/testdata/detect-cycle-acyclic.txtar b/testdata/detect-cycle-acyclic.txtar new file mode 100644 index 0000000..8a27ccb --- /dev/null +++ b/testdata/detect-cycle-acyclic.txtar @@ -0,0 +1,15 @@ +stdin go-mod-graph.txt +exec modgraph -detect-cycles -prefix github.com/example/ +cmp stdout go.md + +-- go-mod-graph.txt -- +github.com/example/foo github.com/example/bar +github.com/example/bar github.com/example/baz + +-- go.md -- + bar --> baz + click bar href "https://github.com/example/bar" + baz + click baz href "https://github.com/example/baz" + foo --> bar + click foo href "https://github.com/example/foo" diff --git a/testdata/detect-cycle-shadowed.txtar b/testdata/detect-cycle-shadowed.txtar new file mode 100644 index 0000000..c1603b9 --- /dev/null +++ b/testdata/detect-cycle-shadowed.txtar @@ -0,0 +1,14 @@ +# {a->b, a->c, b->c, c->a} the only cycle reported is a->b->c->a. The +# direct a->c->a cycle is hidden because c is no longer on the stack +# when the loop at a reaches it. Re-running after the longer cycle is +# fixed surfaces the shadowed one. +stdin go-mod-graph.txt +! exec modgraph -detect-cycles -prefix github.com/example/ +stderr 'Cycle detected.*a -> b -> c -> a' +! stderr 'Cycle detected.*a -> c -> a' + +-- go-mod-graph.txt -- +github.com/example/a github.com/example/b +github.com/example/a github.com/example/c +github.com/example/b github.com/example/c +github.com/example/c github.com/example/a diff --git a/testdata/detect-cycle.txtar b/testdata/detect-cycle.txtar new file mode 100644 index 0000000..7e07205 --- /dev/null +++ b/testdata/detect-cycle.txtar @@ -0,0 +1,8 @@ +stdin go-mod-graph.txt +! exec modgraph -detect-cycles -prefix github.com/example/ +stderr 'Cycle detected.*a -> b -> c -> a' + +-- go-mod-graph.txt -- +github.com/example/a github.com/example/b +github.com/example/b github.com/example/c +github.com/example/c github.com/example/a