├── LICENSE ├── README.md ├── code.js ├── dom.go ├── go.mod ├── go.sum ├── index.html ├── screenshot.png ├── spaghetti.go └── style.css /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2021, Alan Donovan 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Spaghetti: a dependency analysis tool for Go packages 2 | 3 | Spaghetti is an interactive web-based tool to help you understand the 4 | dependencies of a Go program, and to explore and evaluate various 5 | possible efforts to eliminate dependencies. 6 | 7 | It displays the complete dependencies of the initial packages, 8 | organized into a tree based on the directory structure of the 9 | package/module namespace. 10 | 11 | Clicking on a package node displays information about it, 12 | including an arbitrary path to it from one of the initial packages. 13 | Each edge in the path may be "broken", causing it to be removed 14 | from the graph and the view recomputed. 15 | The broken edges are tabulated, and each can be restored if you change 16 | your mind or try another approach. 17 | In this manner, you can explore how your overall dependencies would 18 | change as you work to remove specific imports. 19 | Once you are happy with the overall dependencies, the set of broken 20 | edges becomes your task list for a clean-up project. 21 | 22 | Run it like so: 23 | 24 | ```shell 25 | $ go install github.com/adonovan/spaghetti@latest # install in $HOME/go/bin 26 | $ ~/go/bin/spaghetti [package] & # run the server 27 | $ open http://localhost:8080 # open in chrome, firefox, etc 28 | ``` 29 | 30 | where _package_ is or more Go packages, or a pattern understood by `go 31 | list`. Then point your browser at the insecure single-user web server 32 | at `localhost:18080`. 33 | 34 | This tool is a rewrite from scratch of a tool I wrote while at Google 35 | for exploring the dependency graphs used by the Blaze build system. 36 | The basic design could be easily be generalized to support any kind of 37 | dependency graph, independent of language or domain, or turned into a 38 | secure multi-user web service that operates on graph data uploaded from 39 | the client program that generates it. 40 | 41 | You can probably tell that web UIs are not my expertise. 42 | PRs that provide cosmetic improvements are most welcome! 43 | 44 | ![Screenshot](screenshot.png) 45 | -------------------------------------------------------------------------------- /code.js: -------------------------------------------------------------------------------- 1 | 2 | var packages = null // array of packages.Package JSON objects 3 | var path = null // path from root to selected package (elements are indices in 'packages') 4 | var broken = null // array of 2-arrays of int, the node ids of broken edges. 5 | 6 | function onLoad() { 7 | // Grab data from server: package graph, "directory" tree, broken edges. 8 | jQuery.ajax({url: "/data", success: onData}) 9 | } 10 | 11 | // onData is called shortly after page load with the result of the /data request. 12 | function onData(data) { 13 | // Save array of Package objects. 14 | packages = data.Packages 15 | 16 | // Show initial packages. 17 | $('#initial').text(data.Initial.map(i => packages[i].PkgPath).join("\n")) 18 | 19 | // Show broken edges. 20 | broken = data.Broken 21 | var html = "" 22 | for (var i in broken) { 23 | edge = broken[i] 24 | html += " " 25 | + "" + packages[edge[0]].PkgPath + " ⟶ " 26 | + "" + packages[edge[1]].PkgPath + "
" 27 | } 28 | $('#broken').html(html) 29 | 30 | // Populate package/module "directory" tree. 31 | $('#tree').jstree({ 32 | "core": { 33 | "animation": 0, 34 | "check_callback": true, 35 | 'data': data.Tree, 36 | }, 37 | "types": { 38 | "#": { 39 | }, 40 | "root": { 41 | "icon": "/static/3.3.11/assets/images/tree_icon.png" 42 | }, 43 | "module": { 44 | "icon": "https://jstree.com/static/3.3.11/assets/images/tree_icon.png" 45 | }, 46 | "default": { 47 | }, 48 | "pkg": { 49 | "icon": "https://old.jstree.com//static/v.1.0pre/_demo/file.png" 50 | } 51 | }, 52 | "plugins": ["search", "state", "types", "wholerow"], 53 | "search": { 54 | "case_sensitive": false, 55 | "show_only_matches": true, 56 | } 57 | }) 58 | 59 | // Show package info when a node is clicked. 60 | $('#tree').on("changed.jstree", function (e, data) { 61 | if (data.node) { 62 | selectPkg(data.node.original) 63 | } 64 | }) 65 | 66 | // Search the tree when the user types in the search box. 67 | $("#search").keyup(function () { 68 | var searchString = $(this).val(); 69 | $('#tree').jstree('search', searchString); 70 | }); 71 | } 72 | 73 | // selectPkg shows package info (if any) about the clicked node. 74 | function selectPkg(json) { 75 | if (json.Package < 0) { 76 | // Non-package "directory" node: clear the fields. 77 | $('#pkgname').text("none") 78 | $('#doc').text("") 79 | $('#imports').html("") 80 | $('#importedBy').html("") 81 | $('#path').text("") 82 | return 83 | } 84 | 85 | // A package node was selected. 86 | var pkg = packages[json.Package] 87 | 88 | // Show selected package. 89 | $('#pkgname').text(pkg.PkgPath) 90 | 91 | // Set link to Go package documentation. 92 | $('#doc').html("") 93 | 94 | // Show imports in a drop-down menu. 95 | // Selecting an import acts like clicking on that package in the tree. 96 | addImports($('#imports'), json.Imports) 97 | addImports($('#importedBy'), json.ImportedBy) 98 | 99 | // Show "break edges" buttons. 100 | var html = "" 101 | var path = [].concat(json.Path).reverse() // from root to selected package 102 | for (var i in path) { 103 | var p = packages[path[i]] 104 | if (i > 0) { 105 | html += " " 106 | + " " 107 | + "⟶ " 108 | } 109 | html += "" + p.PkgPath + "
" 110 | } 111 | $('#path').html(html) 112 | } 113 | 114 | function breakedge(i, j, all) { 115 | // Must reload the page since the graph has changed. 116 | document.location = "/break?from=" + i + "&to=" + j + "&all=" + all 117 | } 118 | 119 | function unbreak(i, j) { 120 | // Must reload the page since the graph has changed. 121 | document.location = "/unbreak?from=" + i + "&to=" + j 122 | } 123 | 124 | // addImports adds option elements to the select element, 125 | // one per package index in the packageIDs array. 126 | function addImports(select, packageIDs) { 127 | select.html("") 128 | var option = document.createElement("option") 129 | option.textContent = "..." 130 | option.value = "-1" 131 | select.append(option) 132 | for (var i in packageIDs) { 133 | var imp = packageIDs[i] 134 | option = document.createElement("option") 135 | option.textContent = packages[imp].PkgPath 136 | option.value = "" + imp // package index, used by onSelectImport 137 | select.append(option) 138 | } 139 | } 140 | 141 | // onSelectImport is called by the imports dropdown. 142 | function onSelectImport(sel) { 143 | if (sel.value >= 0) { 144 | // Simulate a click on a tree node corresponding to the selected import. 145 | $('#tree').jstree('select_node', 'node' + sel.value); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /dom.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Dominator tree construction 4 | // 5 | // This file was plundered from golang.org/x/tools/go/ssa/dom.go and 6 | // modified to support a different graph representation, multiple 7 | // roots, and unreachable nodes. 8 | // 9 | // TODO(adonovan): turn it into a generic dominance package abstracted 10 | // from representation. 11 | 12 | // LICENCE 13 | // 14 | // Copyright (c) 2009 The Go Authors. All rights reserved. 15 | // 16 | // Redistribution and use in source and binary forms, with or without 17 | // modification, are permitted provided that the following conditions are 18 | // met: 19 | // 20 | // * Redistributions of source code must retain the above copyright 21 | // notice, this list of conditions and the following disclaimer. 22 | // * Redistributions in binary form must reproduce the above 23 | // copyright notice, this list of conditions and the following disclaimer 24 | // in the documentation and/or other materials provided with the 25 | // distribution. 26 | // * Neither the name of Google Inc. nor the names of its 27 | // contributors may be used to endorse or promote products derived from 28 | // this software without specific prior written permission. 29 | // 30 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 31 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 32 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 33 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 34 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 35 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 36 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 37 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 38 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 39 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 40 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 41 | 42 | // Dominator tree construction ---------------------------------------- 43 | // 44 | // We use the algorithm described in Lengauer & Tarjan. 1979. A fast 45 | // algorithm for finding dominators in a flowgraph. 46 | // http://doi.acm.org/10.1145/357062.357071 47 | // 48 | // We also apply the optimizations to SLT described in Georgiadis et 49 | // al, Finding Dominators in Practice, JGAA 2006, 50 | // http://jgaa.info/accepted/2006/GeorgiadisTarjanWerneck2006.10.1.pdf 51 | // to avoid the need for buckets of size > 1. 52 | 53 | // Idom returns the block that immediately dominates b: 54 | // its parent in the dominator tree, if any. The root node has no parent. 55 | func (b *node) Idom() *node { return b.dom.idom } 56 | 57 | // Dominees returns the list of blocks that b immediately dominates: 58 | // its children in the dominator tree. 59 | func (b *node) Dominees() []*node { return b.dom.children } 60 | 61 | // Dominates reports whether b dominates c. 62 | func (b *node) Dominates(c *node) bool { 63 | return b.dom.pre <= c.dom.pre && c.dom.post <= b.dom.post 64 | } 65 | 66 | // domInfo contains a node's dominance information. 67 | type domInfo struct { 68 | idom *node // immediate dominator (parent in domtree) 69 | children []*node // nodes immediately dominated by this one 70 | pre, post int32 // pre- and post-order numbering within domtree 71 | index int32 // preorder index within reachable nodes; see "reachable hack" 72 | } 73 | 74 | // ltState holds the working state for Lengauer-Tarjan algorithm 75 | // (during which domInfo.pre is repurposed for CFG DFS preorder number). 76 | type ltState struct { 77 | // Each slice is indexed by domInfo.index. 78 | sdom []*node // b's semidominator 79 | parent []*node // b's parent in DFS traversal of CFG 80 | ancestor []*node // b's ancestor with least sdom 81 | } 82 | 83 | // dfs implements the depth-first search part of the LT algorithm. 84 | func (lt *ltState) dfs(v *node, i int32, preorder []*node) int32 { 85 | preorder[i] = v 86 | v.dom.pre = i // For now: DFS preorder of spanning tree of CFG 87 | i++ 88 | lt.sdom[v.dom.index] = v 89 | lt.link(nil, v) 90 | for _, w := range v.imports { 91 | if lt.sdom[w.dom.index] == nil { 92 | lt.parent[w.dom.index] = v 93 | i = lt.dfs(w, i, preorder) 94 | } 95 | } 96 | return i 97 | } 98 | 99 | // eval implements the EVAL part of the LT algorithm. 100 | func (lt *ltState) eval(v *node) *node { 101 | u := v 102 | for ; lt.ancestor[v.dom.index] != nil; v = lt.ancestor[v.dom.index] { 103 | if lt.sdom[v.dom.index].dom.pre < lt.sdom[u.dom.index].dom.pre { 104 | u = v 105 | } 106 | } 107 | return u 108 | } 109 | 110 | // link implements the LINK part of the LT algorithm. 111 | func (lt *ltState) link(v, w *node) { 112 | lt.ancestor[w.dom.index] = v 113 | } 114 | 115 | // buildDomTree computes the dominator tree of f using the LT algorithm. 116 | // The first node is the distinguished root node. 117 | func buildDomTree(nodes []*node) { 118 | // The step numbers refer to the original LT paper; the 119 | // reordering is due to Georgiadis. 120 | 121 | // Clear any previous domInfo. 122 | for _, b := range nodes { 123 | b.dom = domInfo{index: -1} 124 | } 125 | 126 | root := nodes[0] 127 | 128 | // The original (ssa) implementation had the precondition 129 | // that all nodes are reachable, but because of Spaghetti's 130 | // "broken edges", some nodes may be unreachable. 131 | // We filter them out now with another graph traversal. 132 | // The domInfo.index numbering is relative to this ordering. 133 | // See other "reachable hack" comments for related parts. 134 | // We should combine this into step 1. 135 | var reachable []*node 136 | var visit func(n *node) 137 | visit = func(n *node) { 138 | if n.dom.index < 0 { 139 | n.dom.index = int32(len(reachable)) 140 | reachable = append(reachable, n) 141 | for _, imp := range n.imports { 142 | visit(imp) 143 | } 144 | } 145 | } 146 | visit(root) 147 | nodes = reachable 148 | 149 | n := len(nodes) 150 | // Allocate space for 5 contiguous [n]*node arrays: 151 | // sdom, parent, ancestor, preorder, buckets. 152 | space := make([]*node, 5*n) 153 | lt := ltState{ 154 | sdom: space[0:n], 155 | parent: space[n : 2*n], 156 | ancestor: space[2*n : 3*n], 157 | } 158 | 159 | // Step 1. Number vertices by depth-first preorder. 160 | preorder := space[3*n : 4*n] 161 | lt.dfs(root, 0, preorder) 162 | 163 | buckets := space[4*n : 5*n] 164 | copy(buckets, preorder) 165 | 166 | // In reverse preorder... 167 | for i := int32(n) - 1; i > 0; i-- { 168 | w := preorder[i] 169 | 170 | // Step 3. Implicitly define the immediate dominator of each node. 171 | for v := buckets[i]; v != w; v = buckets[v.dom.pre] { 172 | u := lt.eval(v) 173 | if lt.sdom[u.dom.index].dom.pre < i { 174 | v.dom.idom = u 175 | } else { 176 | v.dom.idom = w 177 | } 178 | } 179 | 180 | // Step 2. Compute the semidominators of all nodes. 181 | lt.sdom[w.dom.index] = lt.parent[w.dom.index] 182 | for _, v := range w.importedBy { 183 | if v.dom.index < 0 { 184 | continue // see "reachable hack" 185 | } 186 | u := lt.eval(v) 187 | if lt.sdom[u.dom.index].dom.pre < lt.sdom[w.dom.index].dom.pre { 188 | lt.sdom[w.dom.index] = lt.sdom[u.dom.index] 189 | } 190 | } 191 | 192 | lt.link(lt.parent[w.dom.index], w) 193 | 194 | if lt.parent[w.dom.index] == lt.sdom[w.dom.index] { 195 | w.dom.idom = lt.parent[w.dom.index] 196 | } else { 197 | buckets[i] = buckets[lt.sdom[w.dom.index].dom.pre] 198 | buckets[lt.sdom[w.dom.index].dom.pre] = w 199 | } 200 | } 201 | 202 | // The final 'Step 3' is now outside the loop. 203 | for v := buckets[0]; v != preorder[0]; v = buckets[v.dom.pre] { 204 | v.dom.idom = preorder[0] 205 | } 206 | 207 | // Step 4. Explicitly define the immediate dominator of each 208 | // node, in preorder. 209 | for _, w := range preorder[1:] { 210 | if w == root { 211 | w.dom.idom = nil 212 | } else { 213 | if w.dom.idom != lt.sdom[w.dom.index] { 214 | w.dom.idom = w.dom.idom.dom.idom 215 | } 216 | // Calculate Children relation as inverse of Idom. 217 | w.dom.idom.dom.children = append(w.dom.idom.dom.children, w) 218 | } 219 | } 220 | 221 | // Number all nodes to enable O(1) dominance queries. 222 | numberDomTree(root, 0, 0) 223 | } 224 | 225 | // numberDomTree sets the pre- and post-order numbers of a depth-first 226 | // traversal of the dominator tree rooted at v. These are used to 227 | // answer dominance queries in constant time. 228 | // 229 | func numberDomTree(v *node, pre, post int32) (int32, int32) { 230 | v.dom.pre = pre 231 | pre++ 232 | for _, child := range v.dom.children { 233 | pre, post = numberDomTree(child, pre, post) 234 | } 235 | v.dom.post = post 236 | post++ 237 | return pre, post 238 | } 239 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/adonovan/spaghetti 2 | 3 | go 1.16 4 | 5 | require golang.org/x/tools v0.1.1-0.20210319172145-bda8f5cee399 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 2 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 3 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 4 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 5 | golang.org/x/mod v0.4.1 h1:Kvvh58BN8Y9/lBi7hTekvtMpm07eUZ0ck5pRHpsMWrY= 6 | golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 7 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 8 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 9 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 10 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 11 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 12 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 13 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 14 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 15 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk= 16 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 18 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 19 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 20 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 21 | golang.org/x/tools v0.1.1-0.20210319172145-bda8f5cee399 h1:O5bm8buX/OaamnfcBrkjn0SPUIU30jFmaS8lP+ikkxs= 22 | golang.org/x/tools v0.1.1-0.20210319172145-bda8f5cee399/go.mod h1:9bzcO0MWcOuT0tm1iBGzDVPshzfwoVvREIui8C+MHqU= 23 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 24 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 25 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 26 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 27 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Spaghetti: dependency analysis for Go packages 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |

Spaghetti: dependency analysis for Go packages

14 |

15 | This tool displays the complete dependencies of these initial packages: 16 |

17 |
...
18 |

19 | Click on a package in the tree view to display information about it, 20 | including a path by which it is reached from one of the initial packages. 21 | 22 | Use the break button to remove an edge from the graph, so 23 | that you can assess what edges need to be broken to eliminate an 24 | unwanted dependency. 25 |

26 | 27 |

Packages

28 |

29 |

30 | 31 | This tree shows all dependencies of the initial packages, 32 | grouped hierarchically by import path and containing 33 | module.

34 | 35 | Each package has a numeric weight, computed using network 36 | flow: this is the size of the graph rooted at the node, 37 | divided by the node's in-degree.

38 | 39 | Click a package to show more information about it. 40 |
41 | 43 | 44 |
45 |

46 |
47 |
tree
48 |
49 | 50 |

Selected package: none   

51 |

52 | This list shows the packages 53 | directly imported by the selected package 54 | 55 | 56 |

57 |

58 | This list shows the packages 59 | that directly import the selected package 60 | 61 | 62 |

63 |

64 | 65 | This section displays an arbitrary path from one of the 66 | initial packages to the selected package. Click 67 | the break button so see how your dependencies would 68 | change if you were to remove a single edge.

69 | 70 | Click break all to remove all inbound edges to a 71 | package, removing it from the graph. This may be useful for 72 | removing distracting packages that you don't plan to 73 | eliminate.

74 | 75 | The bold nodes are dominators: nodes that are found on 76 | every path to the selected node. One way to break a dependency 77 | on a package is to break all dependencies on any of its dominators. 78 |
79 | 80 |

81 |

82 | 83 |

Broken edges

84 | This section lists 85 | the edges you have broken so far. Click unbreak to 86 | restore an edge and update the graph. Once you are happy with 87 | the dependencies, you can use this as your to-do list for 88 | refactoring. 89 |
...
90 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adonovan/spaghetti/ba438b8fd619693ac78051a46dc13b721be7e888/screenshot.png -------------------------------------------------------------------------------- /spaghetti.go: -------------------------------------------------------------------------------- 1 | // The Spaghetti command runs a local web server that provides an 2 | // interactive single-user tool for visualizing the package 3 | // dependencies of a Go program with a view to refactoring. 4 | // 5 | // Usage: spaghetti [package...] 6 | package main 7 | 8 | import ( 9 | "embed" 10 | "encoding/json" 11 | "flag" 12 | "fmt" 13 | "log" 14 | "net/http" 15 | "os" 16 | "path" 17 | "sort" 18 | "strconv" 19 | "strings" 20 | 21 | "golang.org/x/tools/go/packages" 22 | ) 23 | 24 | // TODO: 25 | // - select the initial nodes initially in the dir tree. 26 | // - need more rigor with IDs. Test on a project with multiple versioned modules. 27 | // - support gopackages -test option. 28 | // - prettier dir tree labels (it's HTML) 29 | // - document that server is not concurrency-safe. 30 | 31 | func main() { 32 | log.SetPrefix("spaghetti: ") 33 | log.SetFlags(0) 34 | flag.Parse() 35 | if len(flag.Args()) == 0 { 36 | log.Fatalf("need package arguments") 37 | } 38 | 39 | // Load packages and their dependencies. 40 | config := &packages.Config{ 41 | Mode: packages.NeedName | packages.NeedImports | packages.NeedDeps | packages.NeedModule | packages.NeedFiles, 42 | // TODO(adonovan): support "Test: true," 43 | } 44 | initial, err := packages.Load(config, flag.Args()...) 45 | if err != nil { 46 | log.Fatal(err) 47 | } 48 | nerrs := 0 49 | for _, p := range initial { 50 | for _, err := range p.Errors { 51 | fmt.Fprintln(os.Stderr, err) 52 | nerrs++ 53 | } 54 | } 55 | if nerrs > 0 { 56 | log.Fatalf("failed to load initial packages. Ensure this command works first:\n\t$ go list %s", strings.Join(flag.Args(), " ")) 57 | } 58 | 59 | // The dominator computation algorithm needs a single root. 60 | // Synthesize one as needed that imports the initial packages; 61 | // the UI does not expose its existence. 62 | rootpkg := initial[0] 63 | if len(initial) > 1 { 64 | imports := make(map[string]*packages.Package) 65 | for i, pkg := range initial { 66 | imports[fmt.Sprintf("%03d", i)] = pkg 67 | } 68 | rootpkg = &packages.Package{ 69 | ID: "(root)", 70 | Name: "synthetic root package", 71 | PkgPath: "(root)", 72 | Imports: imports, 73 | } 74 | } 75 | 76 | // Create nodes in deterministic preorder, distinguished root first. 77 | // Node numbering determines search results, and we want stability. 78 | nodes := make(map[string]*node) // map from Package.ID 79 | packages.Visit([]*packages.Package{rootpkg}, func(pkg *packages.Package) bool { 80 | n := &node{Package: pkg, index: len(allnodes)} 81 | if pkg.Module != nil { 82 | n.modpath = pkg.Module.Path 83 | n.modversion = pkg.Module.Version 84 | } else { 85 | n.modpath = "std" 86 | n.modversion = "" // TODO: use Go version? 87 | } 88 | allnodes = append(allnodes, n) 89 | nodes[pkg.ID] = n 90 | return true 91 | }, nil) 92 | for _, pkg := range initial { 93 | nodes[pkg.ID].initial = true 94 | } 95 | 96 | // Create edges, in arbitrary order. 97 | var makeEdges func(n *node) 98 | makeEdges = func(n *node) { 99 | for _, imp := range n.Imports { 100 | n2 := nodes[imp.ID] 101 | n2.importedBy = append(n2.importedBy, n) 102 | n.imports = append(n.imports, n2) 103 | } 104 | } 105 | for _, n := range allnodes { 106 | makeEdges(n) 107 | } 108 | 109 | recompute() 110 | 111 | http.Handle("/data", http.HandlerFunc(onData)) 112 | http.Handle("/break", http.HandlerFunc(onBreak)) 113 | http.Handle("/unbreak", http.HandlerFunc(onUnbreak)) 114 | http.Handle("/", http.FileServer(http.FS(content))) 115 | 116 | const addr = "localhost:18080" 117 | log.Printf("listening on http://%s", addr) 118 | if err := http.ListenAndServe(addr, nil); err != nil { 119 | log.Fatal(err) 120 | } 121 | } 122 | 123 | // Global server state, modified by HTTP handlers. 124 | var ( 125 | allnodes []*node // all package nodes, in packages.Visit order (root first) 126 | rootdir *dirent // root of module/package "directory" tree 127 | broken [][2]*node // broken edges 128 | ) 129 | 130 | // recompute redoes all the graph algorithms each time the graph is updated. 131 | func recompute() { 132 | // Sort edges in numeric order of the adjacent node. 133 | for _, n := range allnodes { 134 | sortNodes(n.imports) 135 | sortNodes(n.importedBy) 136 | n.from = nil 137 | } 138 | 139 | // Record the path to every node from the root. 140 | // The path is arbitrary but determined by edge sort order. 141 | var setPath func(n, from *node) 142 | setPath = func(n, from *node) { 143 | if n.from == nil { 144 | n.from = from 145 | for _, imp := range n.imports { 146 | setPath(imp, n) 147 | } 148 | } 149 | } 150 | setPath(allnodes[0], nil) 151 | 152 | // Compute dominator tree. 153 | buildDomTree(allnodes) 154 | 155 | // Compute node weights, using network flow. 156 | var weight func(*node) int 157 | weight = func(n *node) int { 158 | if n.weight == 0 { 159 | w := 1 + len(n.GoFiles) 160 | for _, n2 := range n.imports { 161 | w += weight(n2) / len(n2.importedBy) 162 | } 163 | n.weight = w 164 | } 165 | return n.weight 166 | } 167 | weight(allnodes[0]) 168 | 169 | // Create tree of reachable modules/packages. Excludes synthetic root, if any. 170 | rootdir = new(dirent) 171 | for _, n := range allnodes { 172 | if n.initial || n.from != nil { // reachable? 173 | // FIXME Use of n.ID here is fishy. 174 | getDirent(n.ID, n.modpath, n.modversion).node = n 175 | } 176 | } 177 | } 178 | 179 | //go:embed index.html style.css code.js 180 | var content embed.FS 181 | 182 | // A node is a vertex in the package dependency graph (a DAG). 183 | type node struct { 184 | // These fields are immutable. 185 | *packages.Package // information about the package 186 | index int // in allnodes numbering 187 | initial bool // package was among set of initial roots 188 | modpath, modversion string // module, or ("std", "") for standard packages 189 | 190 | // These fields are recomputed after a graph change. 191 | imports, importedBy []*node // graph edges 192 | weight int // weight computed by network flow 193 | from *node // next link in path from a root node (nil if root) 194 | dom domInfo // dominator information 195 | } 196 | 197 | func sortNodes(nodes []*node) { 198 | sort.Slice(nodes, func(i, j int) bool { return nodes[i].index < nodes[j].index }) 199 | } 200 | 201 | // A dirent is an entry in the package directory tree. 202 | type dirent struct { 203 | name string // slash-separated path name (displayed in tree for non-package dirs) 204 | node *node // may be nil 205 | parent *dirent // nil for rootdir 206 | children map[string]*dirent 207 | } 208 | 209 | // id returns the entry's DOM element ID in the jsTree. 210 | func (e *dirent) id() string { 211 | if e.node != nil { 212 | // package "directory" 213 | return fmt.Sprintf("node%d", e.node.index) 214 | } else if e.parent == nil { 215 | // top-level "directory" 216 | return "#" 217 | } else { 218 | // non-package "directory" 219 | return fmt.Sprintf("dir%p", e) 220 | } 221 | } 222 | 223 | // getDirent returns the dirent for a given slash-separated path. 224 | // TODO explain module behavior. 225 | func getDirent(name, modpath, modversion string) *dirent { 226 | var s string 227 | var parent *dirent 228 | if name == modpath { 229 | // modules are top-level "directories" (child of root) 230 | parent = rootdir 231 | s = modpath 232 | if modversion != "" { 233 | s += "@" + modversion 234 | } 235 | name = s 236 | } else { 237 | dir, base := path.Dir(name), path.Base(name) 238 | if dir == "." { 239 | dir, base = modpath, name // e.g. "std" 240 | } 241 | parent = getDirent(dir, modpath, modversion) 242 | s = base 243 | } 244 | 245 | e := parent.children[s] 246 | if e == nil { 247 | e = &dirent{name: name, parent: parent} 248 | if parent.children == nil { 249 | parent.children = make(map[string]*dirent) 250 | } 251 | parent.children[s] = e 252 | } 253 | return e 254 | } 255 | 256 | // onData handles the /data endpoint. It emits all the server's state as JSON: 257 | // the list of root packages, the directory tree of packages in jsTree form, 258 | // the canonical array of reachable packages, and the list of broken edges. 259 | func onData(w http.ResponseWriter, req *http.Request) { 260 | 261 | // All ints in the JSON are indices into the packages array. 262 | type treeitem struct { 263 | // These three fields are used by jsTree 264 | ID string `json:"id"` // id of DOM element 265 | Parent string `json:"parent"` 266 | Text string `json:"text"` // actually HTML 267 | Type string `json:"type"` 268 | 269 | // Any additional fields will be accessible 270 | // in the jstree node's .original field. 271 | Package int // -1 for non-package nodes 272 | Imports []int 273 | ImportedBy []int 274 | Dominators []int // path through dom tree, from package to root inclusive 275 | Path []int // path through package graph, from package to root inclusive 276 | } 277 | var payload struct { 278 | Initial []int 279 | Tree []treeitem 280 | Packages []*packages.Package 281 | Broken [][2]int // (from, to) node indices 282 | } 283 | 284 | // roots and graph nodes (packages) 285 | for _, n := range allnodes { 286 | if n.initial { 287 | payload.Initial = append(payload.Initial, n.index) 288 | } 289 | 290 | payload.Packages = append(payload.Packages, n.Package) 291 | } 292 | 293 | // broken edges 294 | payload.Broken = [][2]int{} // avoid JSON null 295 | for _, edge := range broken { 296 | payload.Broken = append(payload.Broken, [2]int{edge[0].index, edge[1].index}) 297 | } 298 | 299 | // tree nodes (packages, modules, and directories) 300 | var visit func(children map[string]*dirent) 301 | visit = func(children map[string]*dirent) { 302 | var names []string 303 | for name := range children { 304 | names = append(names, name) 305 | } 306 | sort.Strings(names) 307 | for _, name := range names { 308 | e := children[name] 309 | 310 | item := treeitem{ID: e.id(), Text: e.name, Package: -1} 311 | if e.node != nil { 312 | // package node: show flow weight 313 | // TODO(adonovan): improve node label. This is HTML, not text. 314 | item.Text = fmt.Sprintf("%s (%d)", e.name, e.node.weight) 315 | 316 | // TODO(adonovan): use "module", "dir" node types too. 317 | item.Type = "pkg" 318 | 319 | // TODO(adonovan): pre-open the tree to the first root node 320 | // item.State = { 'opened' : true, 'selected' : true } 321 | 322 | item.Package = e.node.index 323 | item.Imports = []int{} // avoid JSON null 324 | for _, imp := range e.node.imports { 325 | item.Imports = append(item.Imports, imp.index) 326 | } 327 | item.ImportedBy = []int{} // avoid JSON null 328 | for _, imp := range e.node.importedBy { 329 | item.ImportedBy = append(item.ImportedBy, imp.index) 330 | } 331 | for n := e.node.Idom(); n != nil; n = n.Idom() { 332 | item.Dominators = append(item.Dominators, n.index) 333 | } 334 | // Don't show the synthetic root node (if any) in the path. 335 | for n := e.node; n != nil && n.ID != "(root)"; n = n.from { 336 | item.Path = append(item.Path, n.index) 337 | } 338 | } 339 | 340 | if e.parent != nil { 341 | item.Parent = e.parent.id() 342 | } 343 | payload.Tree = append(payload.Tree, item) 344 | 345 | visit(e.children) 346 | } 347 | } 348 | visit(rootdir.children) 349 | 350 | data, err := json.Marshal(payload) 351 | if err != nil { 352 | log.Fatal(err) 353 | } 354 | 355 | w.Header().Set("Content-Type", "application/json") 356 | w.Write(data) 357 | } 358 | 359 | // onBreak handles the /break (from, to int, all bool) endpoint. 360 | func onBreak(w http.ResponseWriter, req *http.Request) { 361 | if err := req.ParseForm(); err != nil { 362 | log.Println(err) 363 | return 364 | } 365 | 366 | to, _ := strconv.Atoi(req.Form.Get("to")) 367 | toNode := allnodes[to] 368 | 369 | all, _ := strconv.ParseBool(req.Form.Get("all")) 370 | if all { 371 | // break all edges '* --> to'. 372 | for _, fromNode := range toNode.importedBy { 373 | broken = append(broken, [2]*node{fromNode, toNode}) 374 | fromNode.imports = remove(fromNode.imports, toNode) 375 | } 376 | toNode.importedBy = nil 377 | 378 | } else { 379 | // break edge 'from --> to' 380 | from, _ := strconv.Atoi(req.Form.Get("from")) 381 | fromNode := allnodes[from] 382 | broken = append(broken, [2]*node{fromNode, toNode}) 383 | fromNode.imports = remove(fromNode.imports, toNode) 384 | toNode.importedBy = remove(toNode.importedBy, fromNode) 385 | } 386 | 387 | recompute() 388 | 389 | http.Redirect(w, req, "/index.html", http.StatusTemporaryRedirect) 390 | } 391 | 392 | // remove destructively removes all occurrences of x from slice, sorts it, and returns it. 393 | func remove(slice []*node, x *node) []*node { 394 | for i := 0; i < len(slice); i++ { 395 | if slice[i] == x { 396 | last := len(slice) - 1 397 | slice[i] = slice[last] 398 | slice = slice[:last] 399 | i-- 400 | } 401 | } 402 | sortNodes(slice) 403 | return slice 404 | } 405 | 406 | // onUnbreak handles the /unbreak (from, to int) endpoint. 407 | func onUnbreak(w http.ResponseWriter, req *http.Request) { 408 | if err := req.ParseForm(); err != nil { 409 | log.Println(err) 410 | return 411 | } 412 | 413 | from, _ := strconv.Atoi(req.Form.Get("from")) 414 | fromNode := allnodes[from] 415 | 416 | to, _ := strconv.Atoi(req.Form.Get("to")) 417 | toNode := allnodes[to] 418 | 419 | // Remove from broken edge list. 420 | out := broken[:0] 421 | for _, edge := range broken { 422 | if edge != [2]*node{fromNode, toNode} { 423 | out = append(out, edge) 424 | } 425 | } 426 | broken = out 427 | 428 | fromNode.imports = append(fromNode.imports, toNode) 429 | toNode.importedBy = append(toNode.importedBy, fromNode) 430 | 431 | recompute() 432 | 433 | http.Redirect(w, req, "/index.html", http.StatusTemporaryRedirect) 434 | } 435 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | body { margin: 1em; font-family: "Minion Pro", serif; max-width: 1024px; } 2 | h1, h2, h3, h4, .jstree-default { font-family: Lato; } 3 | code, pre { font-family: Consolas, monospace; } 4 | code.dom { font-weight: bold; } 5 | button { font-size: 90%; } 6 | pre#initial { margin-left: 1em; } 7 | 8 | /* the ⓘ symbol */ 9 | .tooltip { 10 | position: relative; 11 | display: inline-block; 12 | border-bottom: 1px dotted blue; 13 | } 14 | 15 | .tooltip .tooltiptext { 16 | visibility: hidden; 17 | width: 300px; 18 | background-color: #eff; 19 | color: #333; 20 | border-radius: 6px; 21 | border: thin solid blue; 22 | padding: 1em; 23 | 24 | /* Position the tooltip */ 25 | position: absolute; 26 | z-index: 1; 27 | } 28 | 29 | .tooltip:hover .tooltiptext { 30 | visibility: visible; 31 | } 32 | 33 | 34 | --------------------------------------------------------------------------------