spoom 1.0.3 → 1.0.8

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,109 @@
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
+ SORT_CODE = "code"
12
+ SORT_LOC = "loc"
13
+ SORT_ENUM = [SORT_CODE, SORT_LOC]
14
+
15
+ DEFAULT_FORMAT = "%C - %F:%L: %M"
16
+
17
+ desc "tc", "Run `srb tc`"
18
+ option :limit, type: :numeric, aliases: :l, desc: "Limit displayed errors"
19
+ option :code, type: :numeric, aliases: :c, desc: "Filter displayed errors by code"
20
+ option :sort, type: :string, aliases: :s, desc: "Sort errors", enum: SORT_ENUM, lazy_default: SORT_LOC
21
+ option :format, type: :string, aliases: :f, desc: "Format line output"
22
+ option :uniq, type: :boolean, aliases: :u, desc: "Remove duplicated lines"
23
+ option :count, type: :boolean, default: true, desc: "Show errors count"
24
+ option :sorbet, type: :string, desc: "Path to custom Sorbet bin"
25
+ def tc(*arg)
26
+ in_sorbet_project!
27
+
28
+ path = exec_path
29
+ limit = options[:limit]
30
+ sort = options[:sort]
31
+ code = options[:code]
32
+ uniq = options[:uniq]
33
+ format = options[:format]
34
+ count = options[:count]
35
+ sorbet = options[:sorbet]
36
+
37
+ unless limit || code || sort
38
+ output, status = T.unsafe(Spoom::Sorbet).srb_tc(*arg, path: path, capture_err: false, sorbet_bin: sorbet)
39
+ $stderr.print(output)
40
+ exit(status)
41
+ end
42
+
43
+ output, status = T.unsafe(Spoom::Sorbet).srb_tc(*arg, path: path, capture_err: true, sorbet_bin: sorbet)
44
+ if status
45
+ $stderr.print(output)
46
+ exit(0)
47
+ end
48
+
49
+ errors = Spoom::Sorbet::Errors::Parser.parse_string(output)
50
+ errors_count = errors.size
51
+
52
+ errors = case sort
53
+ when SORT_CODE
54
+ Spoom::Sorbet::Errors.sort_errors_by_code(errors)
55
+ when SORT_LOC
56
+ errors.sort
57
+ else
58
+ errors # preserve natural sort
59
+ end
60
+
61
+ errors = errors.select { |e| e.code == code } if code
62
+ errors = T.must(errors.slice(0, limit)) if limit
63
+
64
+ lines = errors.map { |e| format_error(e, format || DEFAULT_FORMAT) }
65
+ lines = lines.uniq if uniq
66
+
67
+ lines.each do |line|
68
+ $stderr.puts line
69
+ end
70
+
71
+ if count
72
+ if errors_count == errors.size
73
+ $stderr.puts "Errors: #{errors_count}"
74
+ else
75
+ $stderr.puts "Errors: #{errors.size} shown, #{errors_count} total"
76
+ end
77
+ end
78
+
79
+ exit(1)
80
+ end
81
+
82
+ no_commands do
83
+ def format_error(error, format)
84
+ line = format
85
+ line = line.gsub(/%C/, colorize(error.code.to_s, :yellow))
86
+ line = line.gsub(/%F/, error.file)
87
+ line = line.gsub(/%L/, error.line.to_s)
88
+ line = line.gsub(/%M/, colorize_message(error.message))
89
+ line
90
+ end
91
+
92
+ def colorize_message(message)
93
+ return message unless color?
94
+
95
+ cyan = T.let(false, T::Boolean)
96
+ word = StringIO.new
97
+ message.chars.each do |c|
98
+ if c == '`'
99
+ cyan = !cyan
100
+ next
101
+ end
102
+ word << (cyan ? c.cyan : c.red)
103
+ end
104
+ word.string
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,89 @@
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, rbi: T::Boolean, sorbet_bin: T.nilable(String)).returns(Snapshot) }
15
+ def self.snapshot(path: '.', rbi: true, sorbet_bin: nil)
16
+ config = sorbet_config(path: path)
17
+ config.allowed_extensions.push(".rb", ".rbi") if config.allowed_extensions.empty?
18
+
19
+ new_config = config.copy
20
+ new_config.allowed_extensions.reject! { |ext| !rbi && ext == ".rbi" }
21
+
22
+ metrics = Spoom::Sorbet.srb_metrics(
23
+ "--no-config",
24
+ new_config.options_string,
25
+ path: path,
26
+ capture_err: true,
27
+ sorbet_bin: sorbet_bin
28
+ )
29
+
30
+ snapshot = Snapshot.new
31
+ return snapshot unless metrics
32
+
33
+ sha = Spoom::Git.last_commit(path: path)
34
+ snapshot.commit_sha = sha
35
+ snapshot.commit_timestamp = Spoom::Git.commit_timestamp(sha, path: path).to_i if sha
36
+
37
+ snapshot.files = metrics.fetch("types.input.files", 0)
38
+ snapshot.modules = metrics.fetch("types.input.modules.total", 0)
39
+ snapshot.classes = metrics.fetch("types.input.classes.total", 0)
40
+ snapshot.singleton_classes = metrics.fetch("types.input.singleton_classes.total", 0)
41
+ snapshot.methods_with_sig = metrics.fetch("types.sig.count", 0)
42
+ snapshot.methods_without_sig = metrics.fetch("types.input.methods.total", 0) - snapshot.methods_with_sig
43
+ snapshot.calls_typed = metrics.fetch("types.input.sends.typed", 0)
44
+ snapshot.calls_untyped = metrics.fetch("types.input.sends.total", 0) - snapshot.calls_typed
45
+
46
+ snapshot.duration += metrics.fetch("run.utilization.system_time.us", 0)
47
+ snapshot.duration += metrics.fetch("run.utilization.user_time.us", 0)
48
+
49
+ Snapshot::STRICTNESSES.each do |strictness|
50
+ next unless metrics.key?("types.input.files.sigil.#{strictness}")
51
+ snapshot.sigils[strictness] = T.must(metrics["types.input.files.sigil.#{strictness}"])
52
+ end
53
+
54
+ snapshot.version_static = Spoom::Sorbet.version_from_gemfile_lock(gem: "sorbet-static", path: path)
55
+ snapshot.version_runtime = Spoom::Sorbet.version_from_gemfile_lock(gem: "sorbet-runtime", path: path)
56
+
57
+ snapshot
58
+ end
59
+
60
+ sig { params(snapshots: T::Array[Snapshot], palette: D3::ColorPalette, path: String).returns(Report) }
61
+ def self.report(snapshots, palette:, path: ".")
62
+ intro_commit = Git.sorbet_intro_commit(path: path)
63
+ intro_date = intro_commit ? Git.commit_time(intro_commit, path: path) : nil
64
+
65
+ Report.new(
66
+ project_name: File.basename(File.expand_path(path)),
67
+ palette: palette,
68
+ snapshots: snapshots,
69
+ sigils_tree: sigils_tree(path: path),
70
+ sorbet_intro_commit: intro_commit,
71
+ sorbet_intro_date: intro_date,
72
+ )
73
+ end
74
+
75
+ sig { params(path: String).returns(Sorbet::Config) }
76
+ def self.sorbet_config(path: ".")
77
+ Sorbet::Config.parse_file("#{path}/#{Spoom::Sorbet::CONFIG_PATH}")
78
+ end
79
+
80
+ sig { params(path: String).returns(FileTree) }
81
+ def self.sigils_tree(path: ".")
82
+ config = sorbet_config(path: path)
83
+ files = Sorbet.srb_files(config, path: path)
84
+ files.select! { |file| file =~ /\.rb$/ }
85
+ files.reject! { |file| file =~ %r{/test/} }
86
+ FileTree.new(files, strip_prefix: path)
87
+ end
88
+ end
89
+ 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