spoom 1.0.1 → 1.0.6

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,308 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "d3"
5
+
6
+ require "erb"
7
+
8
+ module Spoom
9
+ module Coverage
10
+ class Template
11
+ extend T::Sig
12
+ extend T::Helpers
13
+
14
+ abstract!
15
+
16
+ # Create a new template from an Erb file path
17
+ sig { params(template: String).void }
18
+ def initialize(template:)
19
+ @template = template
20
+ end
21
+
22
+ sig { returns(String) }
23
+ def erb
24
+ File.read(@template)
25
+ end
26
+
27
+ sig { returns(String) }
28
+ def html
29
+ ERB.new(erb).result(get_binding)
30
+ end
31
+
32
+ sig { returns(Binding) }
33
+ def get_binding # rubocop:disable Naming/AccessorMethodName
34
+ binding
35
+ end
36
+ end
37
+
38
+ class Page < Template
39
+ extend T::Sig
40
+ extend T::Helpers
41
+
42
+ abstract!
43
+
44
+ TEMPLATE = T.let("#{Spoom::Config::SPOOM_PATH}/templates/page.erb", String)
45
+
46
+ sig { returns(String) }
47
+ attr_reader :title
48
+
49
+ sig { returns(D3::ColorPalette) }
50
+ attr_reader :palette
51
+
52
+ sig { params(title: String, palette: D3::ColorPalette, template: String).void }
53
+ def initialize(title:, palette:, template: TEMPLATE)
54
+ super(template: template)
55
+ @title = title
56
+ @palette = palette
57
+ end
58
+
59
+ sig { returns(String) }
60
+ def header_style
61
+ D3.header_style
62
+ end
63
+
64
+ sig { returns(String) }
65
+ def header_script
66
+ D3.header_script(palette)
67
+ end
68
+
69
+ sig { returns(String) }
70
+ def header_html
71
+ "<h1 class='display-3'>#{title}</h1>"
72
+ end
73
+
74
+ sig { returns(String) }
75
+ def body_html
76
+ cards.map(&:html).join("\n")
77
+ end
78
+
79
+ sig { abstract.returns(T::Array[Cards::Card]) }
80
+ def cards; end
81
+
82
+ sig { returns(String) }
83
+ def footer_html
84
+ "Generated by <a href='https://github.com/Shopify/spoom'>spoom</a> on #{Time.now.utc}."
85
+ end
86
+ end
87
+
88
+ module Cards
89
+ class Card < Template
90
+ extend T::Sig
91
+
92
+ TEMPLATE = T.let("#{Spoom::Config::SPOOM_PATH}/templates/card.erb", String)
93
+
94
+ sig { returns(T.nilable(String)) }
95
+ attr_reader :title, :body
96
+
97
+ sig { params(template: String, title: T.nilable(String), body: T.nilable(String)).void }
98
+ def initialize(template: TEMPLATE, title: nil, body: nil)
99
+ super(template: template)
100
+ @title = title
101
+ @body = body
102
+ end
103
+ end
104
+
105
+ class Erb < Card
106
+ extend T::Sig
107
+ extend T::Helpers
108
+
109
+ abstract!
110
+
111
+ sig { void }
112
+ def initialize; end
113
+
114
+ sig { override.returns(String) }
115
+ def html
116
+ ERB.new(erb).result(get_binding)
117
+ end
118
+
119
+ sig { abstract.returns(String) }
120
+ def erb; end
121
+ end
122
+
123
+ class Snapshot < Card
124
+ extend T::Sig
125
+
126
+ TEMPLATE = T.let("#{Spoom::Config::SPOOM_PATH}/templates/card_snapshot.erb", String)
127
+
128
+ sig { returns(Coverage::Snapshot) }
129
+ attr_reader :snapshot
130
+
131
+ sig { params(snapshot: Coverage::Snapshot, title: String).void }
132
+ def initialize(snapshot:, title: "Snapshot")
133
+ super(template: TEMPLATE, title: title)
134
+ @snapshot = snapshot
135
+ end
136
+
137
+ sig { returns(D3::Pie::Sigils) }
138
+ def pie_sigils
139
+ D3::Pie::Sigils.new('pie_sigils', 'Sigils', snapshot)
140
+ end
141
+
142
+ sig { returns(D3::Pie::Calls) }
143
+ def pie_calls
144
+ D3::Pie::Calls.new('pie_calls', 'Calls', snapshot)
145
+ end
146
+
147
+ sig { returns(D3::Pie::Sigs) }
148
+ def pie_sigs
149
+ D3::Pie::Sigs.new('pie_sigs', 'Sigs', snapshot)
150
+ end
151
+ end
152
+
153
+ class Map < Card
154
+ extend T::Sig
155
+
156
+ sig { params(sigils_tree: FileTree, title: String).void }
157
+ def initialize(sigils_tree:, title: "Strictness Map")
158
+ super(title: title, body: D3::CircleMap::Sigils.new("map_sigils", sigils_tree).html)
159
+ end
160
+ end
161
+
162
+ class Timeline < Card
163
+ extend T::Sig
164
+
165
+ sig { params(title: String, timeline: D3::Timeline).void }
166
+ def initialize(title:, timeline:)
167
+ super(title: title, body: timeline.html)
168
+ end
169
+
170
+ class Sigils < Timeline
171
+ extend T::Sig
172
+
173
+ sig { params(snapshots: T::Array[Coverage::Snapshot], title: String).void }
174
+ def initialize(snapshots:, title: "Sigils Timeline")
175
+ super(title: title, timeline: D3::Timeline::Sigils.new("timeline_sigils", snapshots))
176
+ end
177
+ end
178
+
179
+ class Calls < Timeline
180
+ extend T::Sig
181
+
182
+ sig { params(snapshots: T::Array[Coverage::Snapshot], title: String).void }
183
+ def initialize(snapshots:, title: "Calls Timeline")
184
+ super(title: title, timeline: D3::Timeline::Calls.new("timeline_calls", snapshots))
185
+ end
186
+ end
187
+
188
+ class Sigs < Timeline
189
+ extend T::Sig
190
+
191
+ sig { params(snapshots: T::Array[Coverage::Snapshot], title: String).void }
192
+ def initialize(snapshots:, title: "Signatures Timeline")
193
+ super(title: title, timeline: D3::Timeline::Sigs.new("timeline_sigs", snapshots))
194
+ end
195
+ end
196
+
197
+ class Versions < Timeline
198
+ extend T::Sig
199
+
200
+ sig { params(snapshots: T::Array[Coverage::Snapshot], title: String).void }
201
+ def initialize(snapshots:, title: "Sorbet Versions Timeline")
202
+ super(title: title, timeline: D3::Timeline::Versions.new("timeline_versions", snapshots))
203
+ end
204
+ end
205
+
206
+ class Runtimes < Timeline
207
+ extend T::Sig
208
+
209
+ sig { params(snapshots: T::Array[Coverage::Snapshot], title: String).void }
210
+ def initialize(snapshots:, title: "Sorbet Typechecking Time")
211
+ super(title: title, timeline: D3::Timeline::Runtimes.new("timeline_runtimes", snapshots))
212
+ end
213
+ end
214
+ end
215
+
216
+ class SorbetIntro < Erb
217
+ extend T::Sig
218
+
219
+ sig { params(sorbet_intro_commit: T.nilable(String), sorbet_intro_date: T.nilable(Time)).void }
220
+ def initialize(sorbet_intro_commit: nil, sorbet_intro_date: nil)
221
+ @sorbet_intro_commit = sorbet_intro_commit
222
+ @sorbet_intro_date = sorbet_intro_date
223
+ end
224
+
225
+ sig { override.returns(String) }
226
+ def erb
227
+ <<~ERB
228
+ <div class="text-center" style="margin-top: 30px">
229
+ Typchecked by Sorbet since <b>#{@sorbet_intro_date&.strftime('%F')}</b>
230
+ (commit <b>#{@sorbet_intro_commit}</b>).
231
+ </div>
232
+ ERB
233
+ end
234
+ end
235
+ end
236
+
237
+ class Report < Page
238
+ extend T::Sig
239
+
240
+ sig { returns(String) }
241
+ attr_reader :project_name
242
+
243
+ sig { returns(T.nilable(String)) }
244
+ attr_reader :sorbet_intro_commit
245
+
246
+ sig { returns(T.nilable(Time)) }
247
+ attr_reader :sorbet_intro_date
248
+
249
+ sig { returns(T::Array[Snapshot]) }
250
+ attr_reader :snapshots
251
+
252
+ sig { returns(FileTree) }
253
+ attr_reader :sigils_tree
254
+
255
+ sig do
256
+ params(
257
+ project_name: String,
258
+ palette: D3::ColorPalette,
259
+ snapshots: T::Array[Snapshot],
260
+ sigils_tree: FileTree,
261
+ sorbet_intro_commit: T.nilable(String),
262
+ sorbet_intro_date: T.nilable(Time),
263
+ ).void
264
+ end
265
+ def initialize(
266
+ project_name:,
267
+ palette:,
268
+ snapshots:,
269
+ sigils_tree:,
270
+ sorbet_intro_commit: nil,
271
+ sorbet_intro_date: nil
272
+ )
273
+ super(title: project_name, palette: palette)
274
+ @project_name = project_name
275
+ @snapshots = snapshots
276
+ @sigils_tree = sigils_tree
277
+ @sorbet_intro_commit = sorbet_intro_commit
278
+ @sorbet_intro_date = sorbet_intro_date
279
+ end
280
+
281
+ sig { override.returns(String) }
282
+ def header_html
283
+ last = T.must(snapshots.last)
284
+ <<~ERB
285
+ <h1 class="display-3">
286
+ #{project_name}
287
+ <span class="badge badge-pill badge-dark" style="font-size: 20%;">#{last.commit_sha}</span>
288
+ </h1>
289
+ ERB
290
+ end
291
+
292
+ sig { override.returns(T::Array[Cards::Card]) }
293
+ def cards
294
+ last = T.must(snapshots.last)
295
+ cards = []
296
+ cards << Cards::Snapshot.new(snapshot: last)
297
+ cards << Cards::Map.new(sigils_tree: sigils_tree)
298
+ cards << Cards::Timeline::Sigils.new(snapshots: snapshots)
299
+ cards << Cards::Timeline::Calls.new(snapshots: snapshots)
300
+ cards << Cards::Timeline::Sigs.new(snapshots: snapshots)
301
+ cards << Cards::Timeline::Versions.new(snapshots: snapshots)
302
+ cards << Cards::Timeline::Runtimes.new(snapshots: snapshots)
303
+ cards << Cards::SorbetIntro.new(sorbet_intro_commit: sorbet_intro_commit, sorbet_intro_date: sorbet_intro_date)
304
+ cards
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,132 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Coverage
6
+ class Snapshot < T::Struct
7
+ extend T::Sig
8
+
9
+ prop :timestamp, Integer, default: Time.new.getutc.to_i
10
+ prop :version_static, T.nilable(String), default: nil
11
+ prop :version_runtime, T.nilable(String), default: nil
12
+ prop :duration, Integer, default: 0
13
+ prop :commit_sha, T.nilable(String), default: nil
14
+ prop :commit_timestamp, T.nilable(Integer), default: nil
15
+ prop :files, Integer, default: 0
16
+ prop :modules, Integer, default: 0
17
+ prop :classes, Integer, default: 0
18
+ prop :singleton_classes, Integer, default: 0
19
+ prop :methods_without_sig, Integer, default: 0
20
+ prop :methods_with_sig, Integer, default: 0
21
+ prop :calls_untyped, Integer, default: 0
22
+ prop :calls_typed, Integer, default: 0
23
+ prop :sigils, T::Hash[String, Integer], default: Hash.new(0)
24
+
25
+ # The strictness name as found in the Sorbet metrics file
26
+ STRICTNESSES = T.let(["ignore", "false", "true", "strict", "strong", "stdlib"].freeze, T::Array[String])
27
+
28
+ sig { params(out: T.any(IO, StringIO), colors: T::Boolean, indent_level: Integer).void }
29
+ def print(out: $stdout, colors: true, indent_level: 0)
30
+ printer = SnapshotPrinter.new(out: out, colors: colors, indent_level: indent_level)
31
+ printer.print_snapshot(self)
32
+ end
33
+
34
+ sig { params(json: String).returns(Snapshot) }
35
+ def self.from_json(json)
36
+ from_obj(JSON.parse(json))
37
+ end
38
+
39
+ sig { params(obj: T::Hash[String, T.untyped]).returns(Snapshot) }
40
+ def self.from_obj(obj)
41
+ snapshot = Snapshot.new
42
+ snapshot.timestamp = obj.fetch("timestamp", 0)
43
+ snapshot.version_static = obj.fetch("version_static", nil)
44
+ snapshot.version_runtime = obj.fetch("version_runtime", nil)
45
+ snapshot.duration = obj.fetch("duration", 0)
46
+ snapshot.commit_sha = obj.fetch("commit_sha", nil)
47
+ snapshot.commit_timestamp = obj.fetch("commit_timestamp", nil)
48
+ snapshot.files = obj.fetch("files", 0)
49
+ snapshot.modules = obj.fetch("modules", 0)
50
+ snapshot.classes = obj.fetch("classes", 0)
51
+ snapshot.singleton_classes = obj.fetch("singleton_classes", 0)
52
+ snapshot.methods_with_sig = obj.fetch("methods_with_sig", 0)
53
+ snapshot.methods_without_sig = obj.fetch("methods_without_sig", 0)
54
+ snapshot.calls_typed = obj.fetch("calls_typed", 0)
55
+ snapshot.calls_untyped = obj.fetch("calls_untyped", 0)
56
+
57
+ sigils = obj.fetch("sigils", {})
58
+ if sigils
59
+ Snapshot::STRICTNESSES.each do |strictness|
60
+ next unless sigils.key?(strictness)
61
+ snapshot.sigils[strictness] = sigils[strictness]
62
+ end
63
+ end
64
+
65
+ snapshot
66
+ end
67
+
68
+ sig { params(arg: T.untyped).returns(String) }
69
+ def to_json(*arg)
70
+ serialize.to_json(*arg)
71
+ end
72
+ end
73
+
74
+ class SnapshotPrinter < Spoom::Printer
75
+ extend T::Sig
76
+
77
+ sig { params(snapshot: Snapshot).void }
78
+ def print_snapshot(snapshot)
79
+ methods = snapshot.methods_with_sig + snapshot.methods_without_sig
80
+ calls = snapshot.calls_typed + snapshot.calls_untyped
81
+
82
+ if snapshot.version_static || snapshot.version_runtime
83
+ printl("Sorbet static: #{snapshot.version_static}") if snapshot.version_static
84
+ printl("Sorbet runtime: #{snapshot.version_runtime}") if snapshot.version_runtime
85
+ printn
86
+ end
87
+ printl("Content:")
88
+ indent
89
+ printl("files: #{snapshot.files}")
90
+ printl("modules: #{snapshot.modules}")
91
+ printl("classes: #{snapshot.classes - snapshot.singleton_classes}")
92
+ printl("methods: #{methods}")
93
+ dedent
94
+ printn
95
+ printl("Sigils:")
96
+ print_map(snapshot.sigils, snapshot.files)
97
+ printn
98
+ printl("Methods:")
99
+ methods_map = {
100
+ "with signature" => snapshot.methods_with_sig,
101
+ "without signature" => snapshot.methods_without_sig,
102
+ }
103
+ print_map(methods_map, methods)
104
+ printn
105
+ printl("Calls:")
106
+ calls_map = {
107
+ "typed" => snapshot.calls_typed,
108
+ "untyped" => snapshot.calls_untyped,
109
+ }
110
+ print_map(calls_map, calls)
111
+ end
112
+
113
+ private
114
+
115
+ sig { params(hash: T::Hash[String, Integer], total: Integer).void }
116
+ def print_map(hash, total)
117
+ indent
118
+ hash.each do |key, value|
119
+ next unless value > 0
120
+ printl("#{key}: #{value}#{percent(value, total)}")
121
+ end
122
+ dedent
123
+ end
124
+
125
+ sig { params(value: T.nilable(Integer), total: T.nilable(Integer)).returns(String) }
126
+ def percent(value, total)
127
+ return "" if value.nil? || total.nil? || total == 0
128
+ " (#{(value.to_f * 100.0 / total.to_f).round}%)"
129
+ end
130
+ end
131
+ end
132
+ end
@@ -0,0 +1,196 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ # Build a file hierarchy from a set of file paths.
6
+ class FileTree
7
+ extend T::Sig
8
+
9
+ sig { returns(T.nilable(String)) }
10
+ attr_reader :strip_prefix
11
+
12
+ sig { params(paths: T::Enumerable[String], strip_prefix: T.nilable(String)).void }
13
+ def initialize(paths = [], strip_prefix: nil)
14
+ @roots = T.let({}, T::Hash[String, Node])
15
+ @strip_prefix = strip_prefix
16
+ add_paths(paths)
17
+ end
18
+
19
+ # Add all `paths` to the tree
20
+ sig { params(paths: T::Enumerable[String]).void }
21
+ def add_paths(paths)
22
+ paths.each { |path| add_path(path) }
23
+ end
24
+
25
+ # Add a `path` to the tree
26
+ #
27
+ # This will create all nodes until the root of `path`.
28
+ sig { params(path: String).returns(Node) }
29
+ def add_path(path)
30
+ prefix = @strip_prefix
31
+ path = path.delete_prefix("#{prefix}/") if prefix
32
+ parts = path.split("/")
33
+ if path.empty? || parts.size == 1
34
+ return @roots[path] ||= Node.new(parent: nil, name: path)
35
+ end
36
+ parent_path = T.must(parts[0...-1]).join("/")
37
+ parent = add_path(parent_path)
38
+ name = T.must(parts.last)
39
+ parent.children[name] ||= Node.new(parent: parent, name: name)
40
+ end
41
+
42
+ # All root nodes
43
+ sig { returns(T::Array[Node]) }
44
+ def roots
45
+ @roots.values
46
+ end
47
+
48
+ # All the nodes in this tree
49
+ sig { returns(T::Array[Node]) }
50
+ def nodes
51
+ all_nodes = []
52
+ @roots.values.each { |root| collect_nodes(root, all_nodes) }
53
+ all_nodes
54
+ end
55
+
56
+ # All the paths in this tree
57
+ sig { returns(T::Array[String]) }
58
+ def paths
59
+ nodes.collect(&:path)
60
+ end
61
+
62
+ sig do
63
+ params(
64
+ out: T.any(IO, StringIO),
65
+ show_strictness: T::Boolean,
66
+ colors: T::Boolean,
67
+ indent_level: Integer
68
+ ).void
69
+ end
70
+ def print(out: $stdout, show_strictness: true, colors: true, indent_level: 0)
71
+ printer = TreePrinter.new(
72
+ tree: self,
73
+ out: out,
74
+ show_strictness: show_strictness,
75
+ colors: colors,
76
+ indent_level: indent_level
77
+ )
78
+ printer.print_tree
79
+ end
80
+
81
+ private
82
+
83
+ sig { params(node: FileTree::Node, collected_nodes: T::Array[Node]).returns(T::Array[String]) }
84
+ def collect_nodes(node, collected_nodes = [])
85
+ collected_nodes << node
86
+ node.children.values.each { |child| collect_nodes(child, collected_nodes) }
87
+ collected_nodes
88
+ end
89
+
90
+ # A node representing either a file or a directory inside a FileTree
91
+ class Node < T::Struct
92
+ extend T::Sig
93
+
94
+ # Node parent or `nil` if the node is a root one
95
+ const :parent, T.nilable(Node)
96
+
97
+ # File or dir name
98
+ const :name, String
99
+
100
+ # Children of this node (if not empty, it means it's a dir)
101
+ const :children, T::Hash[String, Node], default: {}
102
+
103
+ # Full path to this node from root
104
+ sig { returns(String) }
105
+ def path
106
+ parent = self.parent
107
+ return name unless parent
108
+ "#{parent.path}/#{name}"
109
+ end
110
+ end
111
+
112
+ # An internal class used to print a FileTree
113
+ #
114
+ # See `FileTree#print`
115
+ class TreePrinter < Spoom::Printer
116
+ extend T::Sig
117
+
118
+ sig { returns(FileTree) }
119
+ attr_reader :tree
120
+
121
+ sig do
122
+ params(
123
+ tree: FileTree,
124
+ out: T.any(IO, StringIO),
125
+ show_strictness: T::Boolean,
126
+ colors: T::Boolean,
127
+ indent_level: Integer
128
+ ).void
129
+ end
130
+ def initialize(tree:, out: $stdout, show_strictness: true, colors: true, indent_level: 0)
131
+ super(out: out, colors: colors, indent_level: indent_level)
132
+ @tree = tree
133
+ @show_strictness = show_strictness
134
+ end
135
+
136
+ sig { void }
137
+ def print_tree
138
+ print_nodes(tree.roots)
139
+ end
140
+
141
+ sig { params(node: FileTree::Node).void }
142
+ def print_node(node)
143
+ printt
144
+ if node.children.empty?
145
+ if @show_strictness
146
+ strictness = node_strictness(node)
147
+ if @colors
148
+ print_colored(node.name, strictness_color(strictness))
149
+ elsif strictness
150
+ print("#{node.name} (#{strictness})")
151
+ else
152
+ print(node.name.to_s)
153
+ end
154
+ else
155
+ print(node.name.to_s)
156
+ end
157
+ print("\n")
158
+ else
159
+ print_colored(node.name, :blue)
160
+ print("/")
161
+ printn
162
+ indent
163
+ print_nodes(node.children.values)
164
+ dedent
165
+ end
166
+ end
167
+
168
+ sig { params(nodes: T::Array[FileTree::Node]).void }
169
+ def print_nodes(nodes)
170
+ nodes.each { |node| print_node(node) }
171
+ end
172
+
173
+ private
174
+
175
+ sig { params(node: FileTree::Node).returns(T.nilable(String)) }
176
+ def node_strictness(node)
177
+ path = node.path
178
+ prefix = tree.strip_prefix
179
+ path = "#{prefix}/#{path}" if prefix
180
+ Spoom::Sorbet::Sigils.file_strictness(path)
181
+ end
182
+
183
+ sig { params(strictness: T.nilable(String)).returns(Symbol) }
184
+ def strictness_color(strictness)
185
+ case strictness
186
+ when "false"
187
+ :red
188
+ when "true", "strict", "strong"
189
+ :green
190
+ else
191
+ :uncolored
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end