spoom 1.0.1 → 1.0.6

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