spoom 1.0.2 → 1.0.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,79 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Cli
6
+ class Run < Thor
7
+ include Helper
8
+
9
+ default_task :tc
10
+
11
+ desc "tc", "run srb tc"
12
+ option :limit, type: :numeric, aliases: :l
13
+ option :code, type: :numeric, aliases: :c
14
+ option :sort, type: :string, aliases: :s
15
+ def tc
16
+ in_sorbet_project!
17
+
18
+ path = exec_path
19
+ limit = options[:limit]
20
+ sort = options[:sort]
21
+ code = options[:code]
22
+ colors = options[:color]
23
+
24
+ unless limit || code || sort
25
+ return Spoom::Sorbet.srb_tc(path: path, capture_err: false).last
26
+ end
27
+
28
+ output, status = Spoom::Sorbet.srb_tc(path: path, capture_err: true)
29
+ if status
30
+ $stderr.print(output)
31
+ return 0
32
+ end
33
+
34
+ errors = Spoom::Sorbet::Errors::Parser.parse_string(output)
35
+ errors_count = errors.size
36
+
37
+ errors = sort == "code" ? errors.sort_by { |e| [e.code, e.file, e.line, e.message] } : errors.sort
38
+ errors = errors.select { |e| e.code == code } if code
39
+ errors = T.must(errors.slice(0, limit)) if limit
40
+
41
+ errors.each do |e|
42
+ code = colorize_code(e.code, colors)
43
+ message = colorize_message(e.message, colors)
44
+ $stderr.puts "#{code} - #{e.file}:#{e.line}: #{message}"
45
+ end
46
+
47
+ if errors_count == errors.size
48
+ $stderr.puts "Errors: #{errors_count}"
49
+ else
50
+ $stderr.puts "Errors: #{errors.size} shown, #{errors_count} total"
51
+ end
52
+
53
+ 1
54
+ end
55
+
56
+ no_commands do
57
+ def colorize_code(code, colors = true)
58
+ return code.to_s unless colors
59
+ code.to_s.light_black
60
+ end
61
+
62
+ def colorize_message(message, colors = true)
63
+ return message unless colors
64
+
65
+ cyan = T.let(false, T::Boolean)
66
+ word = StringIO.new
67
+ message.chars.each do |c|
68
+ if c == '`'
69
+ cyan = !cyan
70
+ next
71
+ end
72
+ word << (cyan ? c.cyan : c.red)
73
+ end
74
+ word.string
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,13 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ require 'pathname'
5
+
6
+ module Spoom
7
+ module Config
8
+ SORBET_CONFIG = "sorbet/config"
9
+ SORBET_GEM_PATH = Gem::Specification.find_by_name("sorbet-static").full_gem_path
10
+ SORBET_PATH = (Pathname.new(SORBET_GEM_PATH) / "libexec" / "sorbet").to_s
11
+ SPOOM_PATH = (Pathname.new(__FILE__) / ".." / ".." / "..").to_s
12
+ end
13
+ 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