spoom 1.0.2 → 1.0.7

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.
@@ -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