spoom 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,6 @@ module Spoom
6
6
  SORBET_CONFIG = "sorbet/config"
7
7
  SORBET_GEM_PATH = Gem::Specification.find_by_name("sorbet-static").full_gem_path
8
8
  SORBET_PATH = (Pathname.new(SORBET_GEM_PATH) / "libexec" / "sorbet").to_s
9
- WORKSPACE_PATH = (Pathname.new(ENV['BUNDLE_GEMFILE']) / "..").to_s
9
+ SPOOM_PATH = (Pathname.new(__FILE__) / ".." / ".." / "..").to_s
10
10
  end
11
11
  end
@@ -0,0 +1,73 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "coverage/snapshot"
5
+ require_relative "coverage/report"
6
+ require_relative "file_tree"
7
+
8
+ require "date"
9
+
10
+ module Spoom
11
+ module Coverage
12
+ extend T::Sig
13
+
14
+ sig { params(path: String).returns(Snapshot) }
15
+ def self.snapshot(path: '.')
16
+ snapshot = Snapshot.new
17
+ metrics = Spoom::Sorbet.srb_metrics(path: path, capture_err: true)
18
+ return snapshot unless metrics
19
+
20
+ sha = Spoom::Git.last_commit(path: path)
21
+ snapshot.commit_sha = sha
22
+ snapshot.commit_timestamp = Spoom::Git.commit_timestamp(sha, path: path).to_i if sha
23
+
24
+ snapshot.files = metrics.fetch("types.input.files", 0)
25
+ snapshot.modules = metrics.fetch("types.input.modules.total", 0)
26
+ snapshot.classes = metrics.fetch("types.input.classes.total", 0)
27
+ snapshot.singleton_classes = metrics.fetch("types.input.singleton_classes.total", 0)
28
+ snapshot.methods_with_sig = metrics.fetch("types.sig.count", 0)
29
+ snapshot.methods_without_sig = metrics.fetch("types.input.methods.total", 0) - snapshot.methods_with_sig
30
+ snapshot.calls_typed = metrics.fetch("types.input.sends.typed", 0)
31
+ snapshot.calls_untyped = metrics.fetch("types.input.sends.total", 0) - snapshot.calls_typed
32
+
33
+ snapshot.duration += metrics.fetch("run.utilization.system_time.us", 0)
34
+ snapshot.duration += metrics.fetch("run.utilization.user_time.us", 0)
35
+
36
+ Snapshot::STRICTNESSES.each do |strictness|
37
+ next unless metrics.key?("types.input.files.sigil.#{strictness}")
38
+ snapshot.sigils[strictness] = T.must(metrics["types.input.files.sigil.#{strictness}"])
39
+ end
40
+
41
+ snapshot.version_static = Spoom::Sorbet.version_from_gemfile_lock(gem: "sorbet-static", path: path)
42
+ snapshot.version_runtime = Spoom::Sorbet.version_from_gemfile_lock(gem: "sorbet-runtime", path: path)
43
+
44
+ snapshot
45
+ end
46
+
47
+ sig { params(snapshots: T::Array[Snapshot], palette: D3::ColorPalette, path: String).returns(Report) }
48
+ def self.report(snapshots, palette:, path: ".")
49
+ intro_commit = Git.sorbet_intro_commit(path: path)
50
+ intro_date = intro_commit ? Git.commit_time(intro_commit, path: path) : nil
51
+
52
+ Report.new(
53
+ project_name: File.basename(File.expand_path(path)),
54
+ palette: palette,
55
+ snapshots: snapshots,
56
+ sigils_tree: sigils_tree(path: path),
57
+ sorbet_intro_commit: intro_commit,
58
+ sorbet_intro_date: intro_date,
59
+ )
60
+ end
61
+
62
+ sig { params(path: String).returns(FileTree) }
63
+ def self.sigils_tree(path: ".")
64
+ config_file = "#{path}/#{Spoom::Config::SORBET_CONFIG}"
65
+ return FileTree.new unless File.exist?(config_file)
66
+ config = Sorbet::Config.parse_file(config_file)
67
+ files = Sorbet.srb_files(config, path: path)
68
+ files.select! { |file| file =~ /\.rb$/ }
69
+ files.reject! { |file| file =~ %r{/test/} }
70
+ FileTree.new(files, strip_prefix: path)
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,110 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "d3/circle_map"
5
+ require_relative "d3/pie"
6
+ require_relative "d3/timeline"
7
+
8
+ module Spoom
9
+ module Coverage
10
+ module D3
11
+ extend T::Sig
12
+
13
+ COLOR_IGNORE = "#999"
14
+ COLOR_FALSE = "#db4437"
15
+ COLOR_TRUE = "#0f9d58"
16
+ COLOR_STRICT = "#0a7340"
17
+ COLOR_STRONG = "#064828"
18
+
19
+ sig { returns(String) }
20
+ def self.header_style
21
+ <<~CSS
22
+ svg {
23
+ width: 100%;
24
+ height: 100%;
25
+ }
26
+
27
+ .tooltip {
28
+ font: 12px Arial, sans-serif;
29
+ color: #fff;
30
+ text-align: center;
31
+ background: rgba(0, 0, 0, 0.6);
32
+ padding: 5px;
33
+ border: 0px;
34
+ border-radius: 4px;
35
+ position: absolute;
36
+ top: 0;
37
+ left: 0;
38
+ opacity: 0;
39
+ }
40
+
41
+ .label {
42
+ font: 14px Arial, sans-serif;
43
+ font-weight: bold;
44
+ fill: #fff;
45
+ text-anchor: middle;
46
+ pointer-events: none;
47
+ }
48
+
49
+ .label .small {
50
+ font-size: 10px;
51
+ }
52
+
53
+ #{Pie.header_style}
54
+ #{CircleMap.header_style}
55
+ #{Timeline.header_style}
56
+ CSS
57
+ end
58
+
59
+ sig { params(palette: ColorPalette).returns(String) }
60
+ def self.header_script(palette)
61
+ <<~JS
62
+ var parseDate = d3.timeParse("%s");
63
+
64
+ function strictnessColor(strictness) {
65
+ switch(strictness) {
66
+ case "ignore":
67
+ return "#{palette.ignore}";
68
+ case "false":
69
+ return "#{palette.false}";
70
+ case "true":
71
+ return "#{palette.true}";
72
+ case "strict":
73
+ return "#{palette.strict}";
74
+ case "strong":
75
+ return "#{palette.strong}";
76
+ }
77
+ return "#{palette.false}";
78
+ }
79
+
80
+ function toPercent(value, sum) {
81
+ return value ? Math.round(value * 100 / sum) : 0;
82
+ }
83
+
84
+ var tooltip = d3.select("body")
85
+ .append("div")
86
+ .append("div")
87
+ .attr("class", "tooltip");
88
+
89
+ function moveTooltip(d) {
90
+ return tooltip
91
+ .style("left", (d3.event.pageX + 20) + "px")
92
+ .style("top", (d3.event.pageY) + "px")
93
+ }
94
+
95
+ #{Pie.header_script}
96
+ #{CircleMap.header_script}
97
+ #{Timeline.header_script}
98
+ JS
99
+ end
100
+
101
+ class ColorPalette < T::Struct
102
+ prop :ignore, String
103
+ prop :false, String
104
+ prop :true, String
105
+ prop :strict, String
106
+ prop :strong, String
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,50 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Coverage
6
+ module D3
7
+ class Base
8
+ extend T::Sig
9
+ extend T::Helpers
10
+
11
+ abstract!
12
+
13
+ sig { returns(String) }
14
+ attr_reader :id
15
+
16
+ sig { params(id: String, data: T.untyped).void }
17
+ def initialize(id, data)
18
+ @id = id
19
+ @data = data
20
+ end
21
+
22
+ sig { returns(String) }
23
+ def self.header_style
24
+ ""
25
+ end
26
+
27
+ sig { returns(String) }
28
+ def self.header_script
29
+ ""
30
+ end
31
+
32
+ sig { returns(String) }
33
+ def html
34
+ <<~HTML
35
+ <svg id="#{id}"></svg>
36
+ <script>#{script}</script>
37
+ HTML
38
+ end
39
+
40
+ sig { returns(String) }
41
+ def tooltip
42
+ ""
43
+ end
44
+
45
+ sig { abstract.returns(String) }
46
+ def script; end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,195 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "base"
5
+
6
+ module Spoom
7
+ module Coverage
8
+ module D3
9
+ class CircleMap < Base
10
+ extend T::Sig
11
+
12
+ sig { returns(String) }
13
+ def self.header_style
14
+ <<~CSS
15
+ .node {
16
+ cursor: pointer;
17
+ }
18
+
19
+ .node:hover {
20
+ stroke: #333;
21
+ stroke-width: 1px;
22
+ }
23
+
24
+ .label.dir {
25
+ fill: #333;
26
+ }
27
+
28
+ .label.file {
29
+ font: 12px Arial, sans-serif;
30
+ }
31
+
32
+ .node.root, .node.file {
33
+ pointer-events: none;
34
+ }
35
+ CSS
36
+ end
37
+
38
+ sig { returns(String) }
39
+ def self.header_script
40
+ <<~JS
41
+ function treeHeight(root, height = 0) {
42
+ height += 1;
43
+ if (root.children && root.children.length > 0)
44
+ return Math.max(...root.children.map(child => treeHeight(child, height)));
45
+ else
46
+ return height;
47
+ }
48
+
49
+ function tooltipMap(d) {
50
+ moveTooltip(d)
51
+ .html("<b>" + d.data.name + "</b>")
52
+ }
53
+ JS
54
+ end
55
+
56
+ sig { override.returns(String) }
57
+ def script
58
+ <<~JS
59
+ var root = {children: #{@data.to_json}}
60
+ var dataHeight = treeHeight(root)
61
+
62
+ var opacity = d3.scaleLinear()
63
+ .domain([0, dataHeight])
64
+ .range([0, 0.2])
65
+
66
+ root = d3.hierarchy(root)
67
+ .sum((d) => d.children ? d.children.length : 1)
68
+ .sort((a, b) => b.value - a.value);
69
+
70
+ var dirColor = d3.scaleLinear()
71
+ .domain([1, 0])
72
+ .range([strictnessColor("true"), strictnessColor("false")])
73
+ .interpolate(d3.interpolateRgb);
74
+
75
+ function redraw() {
76
+ var diameter = document.getElementById("#{id}").clientWidth - 20;
77
+ d3.select("##{id}").selectAll("*").remove()
78
+
79
+ var svg_#{id} = d3.select("##{id}")
80
+ .attr("width", diameter)
81
+ .attr("height", diameter)
82
+ .append("g")
83
+ .attr("transform", "translate(" + diameter / 2 + "," + diameter / 2 + ")");
84
+
85
+ var pack = d3.pack()
86
+ .size([diameter, diameter])
87
+ .padding(2);
88
+
89
+ var focus = root,
90
+ nodes = pack(root).descendants(),
91
+ view;
92
+
93
+ var circle = svg_#{id}.selectAll("circle")
94
+ .data(nodes)
95
+ .enter().append("circle")
96
+ .attr("class", (d) => d.parent ? d.children ? "node" : "node file" : "node root")
97
+ .attr("fill", (d) => d.children ? dirColor(d.data.score) : strictnessColor(d.data.strictness))
98
+ .attr("fill-opacity", (d) => d.children ? opacity(d.depth) : 1)
99
+ .on("click", function(d) { if (focus !== d) zoom(d), d3.event.stopPropagation(); })
100
+ .on("mouseover", (d) => tooltip.style("opacity", 1))
101
+ .on("mousemove", tooltipMap)
102
+ .on("mouseleave", (d) => tooltip.style("opacity", 0));
103
+
104
+ var text = svg_#{id}.selectAll("text")
105
+ .data(nodes)
106
+ .enter().append("text")
107
+ .attr("class", (d) => d.children ? "label dir" : "label file")
108
+ .attr("fill-opacity", (d) => d.depth <= 1 ? 1 : 0)
109
+ .attr("display", (d) => d.depth <= 1 ? "inline" : "none")
110
+ .text((d) => d.data.name);
111
+
112
+ var node = svg_#{id}.selectAll("circle,text");
113
+
114
+ function zoom(d) {
115
+ var focus0 = focus; focus = d;
116
+
117
+ var transition = d3.transition()
118
+ .duration(d3.event.altKey ? 7500 : 750)
119
+ .tween("zoom", function(d) {
120
+ var i = d3.interpolateZoom(view, [focus.x, focus.y, focus.r * 2]);
121
+ return (t) => zoomTo(i(t));
122
+ });
123
+
124
+ transition.selectAll("text")
125
+ .filter(function(d) { return d && d.parent === focus || this.style.display === "inline"; })
126
+ .attr("fill-opacity", function(d) { return d.parent === focus ? 1 : 0; })
127
+ .on("start", function(d) { if (d.parent === focus) this.style.display = "inline"; })
128
+ .on("end", function(d) { if (d.parent !== focus) this.style.display = "none"; });
129
+ }
130
+
131
+ function zoomTo(v) {
132
+ var k = diameter / v[2]; view = v;
133
+ node.attr("transform", (d) => "translate(" + (d.x - v[0]) * k + "," + (d.y - v[1]) * k + ")");
134
+ circle.attr("r", (d) => d.r * k);
135
+ }
136
+
137
+ zoomTo([root.x, root.y, root.r * 2]);
138
+ d3.select("##{id}").on("click", () => zoom(root));
139
+ }
140
+
141
+ redraw();
142
+ window.addEventListener("resize", redraw);
143
+ JS
144
+ end
145
+
146
+ class Sigils < CircleMap
147
+ extend T::Sig
148
+
149
+ sig { params(id: String, sigils_tree: FileTree).void }
150
+ def initialize(id, sigils_tree)
151
+ @scores = T.let({}, T::Hash[FileTree::Node, Float])
152
+ @strictnesses = T.let({}, T::Hash[FileTree::Node, T.nilable(String)])
153
+ @sigils_tree = sigils_tree
154
+ super(id, sigils_tree.roots.map { |r| tree_node_to_json(r) })
155
+ end
156
+
157
+ sig { params(node: FileTree::Node).returns(T::Hash[Symbol, T.untyped]) }
158
+ def tree_node_to_json(node)
159
+ if node.children.empty?
160
+ return { name: node.name, strictness: tree_node_strictness(node) }
161
+ end
162
+ {
163
+ name: node.name,
164
+ children: node.children.values.map { |n| tree_node_to_json(n) },
165
+ score: tree_node_score(node),
166
+ }
167
+ end
168
+
169
+ sig { params(node: FileTree::Node).returns(T.nilable(String)) }
170
+ def tree_node_strictness(node)
171
+ prefix = @sigils_tree.strip_prefix
172
+ path = node.path
173
+ path = "#{prefix}/#{path}" if prefix
174
+ @strictnesses[node] ||= Spoom::Sorbet::Sigils.file_strictness(path)
175
+ end
176
+
177
+ sig { params(node: FileTree::Node).returns(Float) }
178
+ def tree_node_score(node)
179
+ unless @scores.key?(node)
180
+ if node.name =~ /\.rbi?$/
181
+ case tree_node_strictness(node)
182
+ when "true", "strict", "strong"
183
+ @scores[node] = 1.0
184
+ end
185
+ elsif !node.children.empty?
186
+ @scores[node] = node.children.values.sum { |n| tree_node_score(n) } / node.children.size.to_f
187
+ end
188
+ end
189
+ @scores[node] || 0.0
190
+ end
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
@@ -0,0 +1,175 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "base"
5
+
6
+ module Spoom
7
+ module Coverage
8
+ module D3
9
+ class Pie < Base
10
+ extend T::Sig
11
+ extend T::Helpers
12
+
13
+ abstract!
14
+
15
+ sig { params(id: String, title: String, data: T.untyped).void }
16
+ def initialize(id, title, data)
17
+ super(id, data)
18
+ @title = title
19
+ end
20
+
21
+ sig { returns(String) }
22
+ def self.header_style
23
+ <<~CSS
24
+ .pie .title {
25
+ font: 18px Arial, sans-serif;
26
+ font-weight: bold;
27
+ fill: #212529;
28
+ text-anchor: middle;
29
+ pointer-events: none;
30
+ }
31
+
32
+ .pie .arc {
33
+ stroke: #fff;
34
+ stroke-width: 2px;
35
+ }
36
+ CSS
37
+ end
38
+
39
+ sig { returns(String) }
40
+ def self.header_script
41
+ <<~JS
42
+ function tooltipPie(d, title, kind, sum) {
43
+ moveTooltip(d)
44
+ .html("<b>" + title + "</b><br><br>"
45
+ + "<b>" + d.data.value + "</b> " + kind + "<br>"
46
+ + "<b>" + toPercent(d.data.value, sum) + "</b>%")
47
+ }
48
+ JS
49
+ end
50
+
51
+ sig { override.returns(String) }
52
+ def script
53
+ <<~JS
54
+ #{tooltip}
55
+
56
+ var json_#{id} = #{@data.to_json};
57
+ var pie_#{id} = d3.pie().value((d) => d.value);
58
+ var data_#{id} = pie_#{id}(d3.entries(json_#{id}));
59
+ var sum_#{id} = d3.sum(data_#{id}, (d) => d.data.value);
60
+ var title_#{id} = #{@title.to_json};
61
+
62
+ function draw_#{id}() {
63
+ var pieSize_#{id} = document.getElementById("#{id}").clientWidth - 10;
64
+
65
+ var arcGenerator_#{id} = d3.arc()
66
+ .innerRadius(pieSize_#{id} / 4)
67
+ .outerRadius(pieSize_#{id} / 2);
68
+
69
+ d3.select("##{id}").selectAll("*").remove()
70
+
71
+ var svg_#{id} = d3.select("##{id}")
72
+ .attr("width", pieSize_#{id})
73
+ .attr("height", pieSize_#{id})
74
+ .attr("class", "pie")
75
+ .append("g")
76
+ .attr("transform", "translate(" + pieSize_#{id} / 2 + "," + pieSize_#{id} / 2 + ")");
77
+
78
+ svg_#{id}.selectAll("arcs")
79
+ .data(data_#{id})
80
+ .enter()
81
+ .append('path')
82
+ .attr("class", "arc")
83
+ .attr('fill', (d) => strictnessColor(d.data.key))
84
+ .attr('d', arcGenerator_#{id})
85
+ .on("mouseover", (d) => tooltip.style("opacity", 1))
86
+ .on("mousemove", tooltip_#{id})
87
+ .on("mouseleave", (d) => tooltip.style("opacity", 0));
88
+
89
+ svg_#{id}.selectAll("labels")
90
+ .data(data_#{id})
91
+ .enter()
92
+ .append('text')
93
+ .attr("class", "label")
94
+ .attr("transform", (d) => "translate(" + arcGenerator_#{id}.centroid(d) + ")")
95
+ .filter(d => (d.endAngle - d.startAngle) > 0.25)
96
+ .append("tspan")
97
+ .attr("x", 0)
98
+ .attr("y", -3)
99
+ .text((d) => d.data.value)
100
+ .append("tspan")
101
+ .attr("class", "small")
102
+ .attr("x", 0)
103
+ .attr("y", 13)
104
+ .text((d) => toPercent(d.data.value, sum_#{id}) + "%");
105
+
106
+ svg_#{id}
107
+ .append("text")
108
+ .attr("class", "title")
109
+ .append("tspan")
110
+ .attr("y", 7)
111
+ .text(title_#{id});
112
+ }
113
+
114
+ draw_#{id}();
115
+ window.addEventListener("resize", draw_#{id});
116
+ JS
117
+ end
118
+
119
+ class Sigils < Pie
120
+ extend T::Sig
121
+
122
+ sig { params(id: String, title: String, snapshot: Snapshot).void }
123
+ def initialize(id, title, snapshot)
124
+ super(id, title, snapshot.sigils.select { |_k, v| v })
125
+ end
126
+
127
+ sig { override.returns(String) }
128
+ def tooltip
129
+ <<~JS
130
+ function tooltip_#{id}(d) {
131
+ tooltipPie(d, "typed: " + d.data.key, "files", sum_#{id});
132
+ }
133
+ JS
134
+ end
135
+ end
136
+
137
+ class Calls < Pie
138
+ extend T::Sig
139
+
140
+ sig { params(id: String, title: String, snapshot: Snapshot).void }
141
+ def initialize(id, title, snapshot)
142
+ super(id, title, { true: snapshot.calls_typed, false: snapshot.calls_untyped })
143
+ end
144
+
145
+ sig { override.returns(String) }
146
+ def tooltip
147
+ <<~JS
148
+ function tooltip_#{id}(d) {
149
+ tooltipPie(d, d.data.key == "true" ? " checked" : " unchecked", "calls", sum_#{id})
150
+ }
151
+ JS
152
+ end
153
+ end
154
+
155
+ class Sigs < Pie
156
+ extend T::Sig
157
+
158
+ sig { params(id: String, title: String, snapshot: Snapshot).void }
159
+ def initialize(id, title, snapshot)
160
+ super(id, title, { true: snapshot.methods_with_sig, false: snapshot.methods_without_sig })
161
+ end
162
+
163
+ sig { override.returns(String) }
164
+ def tooltip
165
+ <<~JS
166
+ function tooltip_#{id}(d) {
167
+ tooltipPie(d, (d.data.key == "true" ? " with" : " without") + " a signature", "methods", sum_#{id})
168
+ }
169
+ JS
170
+ end
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end