├── screenshot.png
├── go.mod
├── style.css
├── LICENSE
├── README.md
├── go.sum
├── index.html
├── code.js
├── dom.go
└── spaghetti.go
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/adonovan/spaghetti/HEAD/screenshot.png
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | 
45 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------