spoom 1.0.4 → 1.0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -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