spoom 1.1.16 → 1.2.1

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,154 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ class Context
6
+ # Sorbet features for a context
7
+ module Sorbet
8
+ extend T::Sig
9
+ extend T::Helpers
10
+
11
+ requires_ancestor { Context }
12
+
13
+ # Run `bundle exec srb` in this context directory
14
+ sig { params(arg: String, sorbet_bin: T.nilable(String), capture_err: T::Boolean).returns(ExecResult) }
15
+ def srb(*arg, sorbet_bin: nil, capture_err: true)
16
+ res = if sorbet_bin
17
+ exec("#{sorbet_bin} #{arg.join(" ")}", capture_err: capture_err)
18
+ else
19
+ bundle_exec("srb #{arg.join(" ")}", capture_err: capture_err)
20
+ end
21
+
22
+ case res.exit_code
23
+ when Spoom::Sorbet::KILLED_CODE
24
+ raise Spoom::Sorbet::Error::Killed.new("Sorbet was killed.", res)
25
+ when Spoom::Sorbet::SEGFAULT_CODE
26
+ raise Spoom::Sorbet::Error::Segfault.new("Sorbet segfaulted.", res)
27
+ end
28
+
29
+ res
30
+ end
31
+
32
+ sig { params(arg: String, sorbet_bin: T.nilable(String), capture_err: T::Boolean).returns(ExecResult) }
33
+ def srb_tc(*arg, sorbet_bin: nil, capture_err: true)
34
+ arg.prepend("tc") unless sorbet_bin
35
+ T.unsafe(self).srb(*arg, sorbet_bin: sorbet_bin, capture_err: capture_err)
36
+ end
37
+
38
+ sig do
39
+ params(
40
+ arg: String,
41
+ sorbet_bin: T.nilable(String),
42
+ capture_err: T::Boolean,
43
+ ).returns(T.nilable(T::Hash[String, Integer]))
44
+ end
45
+ def srb_metrics(*arg, sorbet_bin: nil, capture_err: true)
46
+ metrics_file = "metrics.tmp"
47
+
48
+ T.unsafe(self).srb_tc(
49
+ "--metrics-file",
50
+ metrics_file,
51
+ *arg,
52
+ sorbet_bin: sorbet_bin,
53
+ capture_err: capture_err,
54
+ )
55
+ return nil unless file?(metrics_file)
56
+
57
+ metrics_path = absolute_path_to(metrics_file)
58
+ metrics = Spoom::Sorbet::MetricsParser.parse_file(metrics_path)
59
+ remove!(metrics_file)
60
+ metrics
61
+ end
62
+
63
+ # List all files typechecked by Sorbet from its `config`
64
+ sig { params(with_config: T.nilable(Spoom::Sorbet::Config), include_rbis: T::Boolean).returns(T::Array[String]) }
65
+ def srb_files(with_config: nil, include_rbis: true)
66
+ config = with_config || sorbet_config
67
+
68
+ allowed_extensions = config.allowed_extensions
69
+ allowed_extensions = Spoom::Sorbet::Config::DEFAULT_ALLOWED_EXTENSIONS if allowed_extensions.empty?
70
+ allowed_extensions -= [".rbi"] unless include_rbis
71
+
72
+ excluded_patterns = config.ignore.map { |string| File.join("**", string, "**") }
73
+
74
+ collector = FileCollector.new(allow_extensions: allowed_extensions, exclude_patterns: excluded_patterns)
75
+ collector.visit_paths(config.paths.map { |path| absolute_path_to(path) })
76
+ collector.files.map { |file| file.delete_prefix("#{absolute_path}/") }.sort
77
+ end
78
+
79
+ # List all files typechecked by Sorbet from its `config` that matches `strictness`
80
+ sig do
81
+ params(
82
+ strictness: String,
83
+ with_config: T.nilable(Spoom::Sorbet::Config),
84
+ include_rbis: T::Boolean,
85
+ ).returns(T::Array[String])
86
+ end
87
+ def srb_files_with_strictness(strictness, with_config: nil, include_rbis: true)
88
+ srb_files(with_config: with_config, include_rbis: include_rbis)
89
+ .select { |file| read_file_strictness(file) == strictness }
90
+ end
91
+
92
+ sig { params(arg: String, sorbet_bin: T.nilable(String), capture_err: T::Boolean).returns(T.nilable(String)) }
93
+ def srb_version(*arg, sorbet_bin: nil, capture_err: true)
94
+ res = T.unsafe(self).srb_tc("--no-config", "--version", *arg, sorbet_bin: sorbet_bin, capture_err: capture_err)
95
+ return nil unless res.status
96
+
97
+ res.out.split(" ")[2]
98
+ end
99
+
100
+ # Does this context has a `sorbet/config` file?
101
+ sig { returns(T::Boolean) }
102
+ def has_sorbet_config?
103
+ file?(Spoom::Sorbet::CONFIG_PATH)
104
+ end
105
+
106
+ sig { returns(Spoom::Sorbet::Config) }
107
+ def sorbet_config
108
+ Spoom::Sorbet::Config.parse_string(read_sorbet_config)
109
+ end
110
+
111
+ # Read the contents of `sorbet/config` in this context directory
112
+ sig { returns(String) }
113
+ def read_sorbet_config
114
+ read(Spoom::Sorbet::CONFIG_PATH)
115
+ end
116
+
117
+ # Set the `contents` of `sorbet/config` in this context directory
118
+ sig { params(contents: String, append: T::Boolean).void }
119
+ def write_sorbet_config!(contents, append: false)
120
+ write!(Spoom::Sorbet::CONFIG_PATH, contents, append: append)
121
+ end
122
+
123
+ # Read the strictness sigil from the file at `relative_path` (returns `nil` if no sigil)
124
+ sig { params(relative_path: String).returns(T.nilable(String)) }
125
+ def read_file_strictness(relative_path)
126
+ Spoom::Sorbet::Sigils.file_strictness(absolute_path_to(relative_path))
127
+ end
128
+
129
+ # Get the commit introducing the `sorbet/config` file
130
+ sig { returns(T.nilable(Spoom::Git::Commit)) }
131
+ def sorbet_intro_commit
132
+ res = git_log("--diff-filter=A --format='%h %at' -1 -- sorbet/config")
133
+ return nil unless res.status
134
+
135
+ out = res.out.strip
136
+ return nil if out.empty?
137
+
138
+ Spoom::Git::Commit.parse_line(out)
139
+ end
140
+
141
+ # Get the commit removing the `sorbet/config` file
142
+ sig { returns(T.nilable(Spoom::Git::Commit)) }
143
+ def sorbet_removal_commit
144
+ res = git_log("--diff-filter=D --format='%h %at' -1 -- sorbet/config")
145
+ return nil unless res.status
146
+
147
+ out = res.out.strip
148
+ return nil if out.empty?
149
+
150
+ Spoom::Git::Commit.parse_line(out)
151
+ end
152
+ end
153
+ end
154
+ end
data/lib/spoom/context.rb CHANGED
@@ -3,8 +3,15 @@
3
3
 
4
4
  require "fileutils"
5
5
  require "open3"
6
+ require "time"
6
7
  require "tmpdir"
7
8
 
9
+ require_relative "context/bundle"
10
+ require_relative "context/exec"
11
+ require_relative "context/file_system"
12
+ require_relative "context/git"
13
+ require_relative "context/sorbet"
14
+
8
15
  module Spoom
9
16
  # An abstraction to a Ruby project context
10
17
  #
@@ -13,9 +20,11 @@ module Spoom
13
20
  class Context
14
21
  extend T::Sig
15
22
 
16
- # The absolute path to the directory this context is about
17
- sig { returns(String) }
18
- attr_reader :absolute_path
23
+ include Bundle
24
+ include Exec
25
+ include FileSystem
26
+ include Git
27
+ include Sorbet
19
28
 
20
29
  class << self
21
30
  extend T::Sig
@@ -30,6 +39,10 @@ module Spoom
30
39
  end
31
40
  end
32
41
 
42
+ # The absolute path to the directory this context is about
43
+ sig { returns(String) }
44
+ attr_reader :absolute_path
45
+
33
46
  # Create a new context about `absolute_path`
34
47
  #
35
48
  # The directory will not be created if it doesn't exist.
@@ -38,202 +51,5 @@ module Spoom
38
51
  def initialize(absolute_path)
39
52
  @absolute_path = T.let(::File.expand_path(absolute_path), String)
40
53
  end
41
-
42
- # Returns the absolute path to `relative_path` in the context's directory
43
- sig { params(relative_path: String).returns(String) }
44
- def absolute_path_to(relative_path)
45
- File.join(@absolute_path, relative_path)
46
- end
47
-
48
- # File System
49
-
50
- # Does the context directory at `absolute_path` exist and is a directory?
51
- sig { returns(T::Boolean) }
52
- def exist?
53
- File.directory?(@absolute_path)
54
- end
55
-
56
- # Create the context directory at `absolute_path`
57
- sig { void }
58
- def mkdir!
59
- FileUtils.rm_rf(@absolute_path)
60
- FileUtils.mkdir_p(@absolute_path)
61
- end
62
-
63
- # List all files in this context matching `pattern`
64
- sig { params(pattern: String).returns(T::Array[String]) }
65
- def glob(pattern = "**/*")
66
- Dir.glob(absolute_path_to(pattern)).map do |path|
67
- Pathname.new(path).relative_path_from(@absolute_path).to_s
68
- end.sort
69
- end
70
-
71
- # List all files at the top level of this context directory
72
- sig { returns(T::Array[String]) }
73
- def list
74
- glob("*")
75
- end
76
-
77
- # Does `relative_path` point to an existing file in this context directory?
78
- sig { params(relative_path: String).returns(T::Boolean) }
79
- def file?(relative_path)
80
- File.file?(absolute_path_to(relative_path))
81
- end
82
-
83
- # Return the contents of the file at `relative_path` in this context directory
84
- #
85
- # Will raise if the file doesn't exist.
86
- sig { params(relative_path: String).returns(String) }
87
- def read(relative_path)
88
- File.read(absolute_path_to(relative_path))
89
- end
90
-
91
- # Write `contents` in the file at `relative_path` in this context directory
92
- #
93
- # Append to the file if `append` is true.
94
- sig { params(relative_path: String, contents: String, append: T::Boolean).void }
95
- def write!(relative_path, contents = "", append: false)
96
- absolute_path = absolute_path_to(relative_path)
97
- FileUtils.mkdir_p(File.dirname(absolute_path))
98
- File.write(absolute_path, contents, mode: append ? "a" : "w")
99
- end
100
-
101
- # Remove the path at `relative_path` (recursive + force) in this context directory
102
- sig { params(relative_path: String).void }
103
- def remove!(relative_path)
104
- FileUtils.rm_rf(absolute_path_to(relative_path))
105
- end
106
-
107
- # Move the file or directory from `from_relative_path` to `to_relative_path`
108
- sig { params(from_relative_path: String, to_relative_path: String).void }
109
- def move!(from_relative_path, to_relative_path)
110
- destination_path = absolute_path_to(to_relative_path)
111
- FileUtils.mkdir_p(File.dirname(destination_path))
112
- FileUtils.mv(absolute_path_to(from_relative_path), destination_path)
113
- end
114
-
115
- # Delete this context and its content
116
- #
117
- # Warning: it will `rm -rf` the context directory on the file system.
118
- sig { void }
119
- def destroy!
120
- FileUtils.rm_rf(@absolute_path)
121
- end
122
-
123
- # Execution
124
-
125
- # Run a command in this context directory
126
- sig { params(command: String, capture_err: T::Boolean).returns(ExecResult) }
127
- def exec(command, capture_err: true)
128
- Bundler.with_unbundled_env do
129
- opts = T.let({ chdir: @absolute_path }, T::Hash[Symbol, T.untyped])
130
-
131
- if capture_err
132
- out, err, status = Open3.capture3(command, opts)
133
- ExecResult.new(out: out, err: err, status: T.must(status.success?), exit_code: T.must(status.exitstatus))
134
- else
135
- out, status = Open3.capture2(command, opts)
136
- ExecResult.new(out: out, err: nil, status: T.must(status.success?), exit_code: T.must(status.exitstatus))
137
- end
138
- end
139
- end
140
-
141
- # Bundle
142
-
143
- # Read the `contents` of the Gemfile in this context directory
144
- sig { returns(T.nilable(String)) }
145
- def read_gemfile
146
- read("Gemfile")
147
- end
148
-
149
- # Set the `contents` of the Gemfile in this context directory
150
- sig { params(contents: String, append: T::Boolean).void }
151
- def write_gemfile!(contents, append: false)
152
- write!("Gemfile", contents, append: append)
153
- end
154
-
155
- # Run a command with `bundle` in this context directory
156
- sig { params(command: String, version: T.nilable(String)).returns(ExecResult) }
157
- def bundle(command, version: nil)
158
- command = "_#{version}_ #{command}" if version
159
- exec("bundle #{command}")
160
- end
161
-
162
- # Run `bundle install` in this context directory
163
- sig { params(version: T.nilable(String)).returns(ExecResult) }
164
- def bundle_install!(version: nil)
165
- bundle("install", version: version)
166
- end
167
-
168
- # Run a command `bundle exec` in this context directory
169
- sig { params(command: String, version: T.nilable(String)).returns(ExecResult) }
170
- def bundle_exec(command, version: nil)
171
- bundle("exec #{command}", version: version)
172
- end
173
-
174
- # Git
175
-
176
- # Run a command prefixed by `git` in this context directory
177
- sig { params(command: String).returns(ExecResult) }
178
- def git(command)
179
- exec("git #{command}")
180
- end
181
-
182
- # Run `git init` in this context directory
183
- #
184
- # Warning: passing a branch will run `git init -b <branch>` which is only available in git 2.28+.
185
- # In older versions, use `git_init!` followed by `git("checkout -b <branch>")`.
186
- sig { params(branch: T.nilable(String)).returns(ExecResult) }
187
- def git_init!(branch: nil)
188
- if branch
189
- git("init -b #{branch}")
190
- else
191
- git("init")
192
- end
193
- end
194
-
195
- # Run `git checkout` in this context directory
196
- sig { params(ref: String).returns(ExecResult) }
197
- def git_checkout!(ref: "main")
198
- git("checkout #{ref}")
199
- end
200
-
201
- # Get the current git branch in this context directory
202
- sig { returns(T.nilable(String)) }
203
- def git_current_branch
204
- Spoom::Git.current_branch(path: @absolute_path)
205
- end
206
-
207
- # Get the last commit in the currently checked out branch
208
- sig { params(short_sha: T::Boolean).returns(T.nilable(Git::Commit)) }
209
- def git_last_commit(short_sha: true)
210
- Spoom::Git.last_commit(path: @absolute_path, short_sha: short_sha)
211
- end
212
-
213
- # Sorbet
214
-
215
- # Run `bundle exec srb` in this context directory
216
- sig { params(command: String).returns(ExecResult) }
217
- def srb(command)
218
- bundle_exec("srb #{command}")
219
- end
220
-
221
- # Read the contents of `sorbet/config` in this context directory
222
- sig { returns(String) }
223
- def read_sorbet_config
224
- read(Spoom::Sorbet::CONFIG_PATH)
225
- end
226
-
227
- # Set the `contents` of `sorbet/config` in this context directory
228
- sig { params(contents: String, append: T::Boolean).void }
229
- def write_sorbet_config!(contents, append: false)
230
- write!(Spoom::Sorbet::CONFIG_PATH, contents, append: append)
231
- end
232
-
233
- # Read the strictness sigil from the file at `relative_path` (returns `nil` if no sigil)
234
- sig { params(relative_path: String).returns(T.nilable(String)) }
235
- def read_file_strictness(relative_path)
236
- Spoom::Sorbet::Sigils.file_strictness(absolute_path_to(relative_path))
237
- end
238
54
  end
239
55
  end
@@ -148,48 +148,34 @@ module Spoom
148
148
  class Sigils < CircleMap
149
149
  extend T::Sig
150
150
 
151
- sig { params(id: String, sigils_tree: FileTree).void }
152
- def initialize(id, sigils_tree)
153
- @scores = T.let({}, T::Hash[FileTree::Node, Float])
154
- @strictnesses = T.let({}, T::Hash[FileTree::Node, T.nilable(String)])
155
- @sigils_tree = sigils_tree
156
- super(id, sigils_tree.roots.map { |r| tree_node_to_json(r) })
151
+ sig do
152
+ params(
153
+ id: String,
154
+ file_tree: FileTree,
155
+ nodes_strictnesses: T::Hash[FileTree::Node, T.nilable(String)],
156
+ nodes_scores: T::Hash[FileTree::Node, Float],
157
+ ).void
158
+ end
159
+ def initialize(id, file_tree, nodes_strictnesses, nodes_scores)
160
+ @nodes_strictnesses = nodes_strictnesses
161
+ @nodes_scores = nodes_scores
162
+ super(id, file_tree.roots.map { |r| tree_node_to_json(r) })
157
163
  end
158
164
 
159
165
  sig { params(node: FileTree::Node).returns(T::Hash[Symbol, T.untyped]) }
160
166
  def tree_node_to_json(node)
161
167
  if node.children.empty?
162
- return { name: node.name, strictness: tree_node_strictness(node) }
163
- end
164
-
165
- {
166
- name: node.name,
167
- children: node.children.values.map { |n| tree_node_to_json(n) },
168
- score: tree_node_score(node),
169
- }
170
- end
171
-
172
- sig { params(node: FileTree::Node).returns(T.nilable(String)) }
173
- def tree_node_strictness(node)
174
- prefix = @sigils_tree.strip_prefix
175
- path = node.path
176
- path = "#{prefix}/#{path}" if prefix
177
- @strictnesses[node] ||= Spoom::Sorbet::Sigils.file_strictness(path)
178
- end
179
-
180
- sig { params(node: FileTree::Node).returns(Float) }
181
- def tree_node_score(node)
182
- unless @scores.key?(node)
183
- if node.name =~ /\.rbi?$/
184
- case tree_node_strictness(node)
185
- when "true", "strict", "strong"
186
- @scores[node] = 1.0
187
- end
188
- elsif !node.children.empty?
189
- @scores[node] = node.children.values.sum { |n| tree_node_score(n) } / node.children.size.to_f
190
- end
168
+ {
169
+ name: node.name,
170
+ strictness: @nodes_strictnesses.fetch(node, "false"),
171
+ }
172
+ else
173
+ {
174
+ name: node.name,
175
+ children: node.children.values.map { |n| tree_node_to_json(n) },
176
+ score: @nodes_scores.fetch(node, 0.0),
177
+ }
191
178
  end
192
- @scores[node] || 0.0
193
179
  end
194
180
  end
195
181
  end
@@ -153,9 +153,24 @@ module Spoom
153
153
  class Map < Card
154
154
  extend T::Sig
155
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)
156
+ sig do
157
+ params(
158
+ file_tree: FileTree,
159
+ nodes_strictnesses: T::Hash[FileTree::Node, T.nilable(String)],
160
+ nodes_strictness_scores: T::Hash[FileTree::Node, Float],
161
+ title: String,
162
+ ).void
163
+ end
164
+ def initialize(file_tree:, nodes_strictnesses:, nodes_strictness_scores:, title: "Strictness Map")
165
+ super(
166
+ title: title,
167
+ body: D3::CircleMap::Sigils.new(
168
+ "map_sigils",
169
+ file_tree,
170
+ nodes_strictnesses,
171
+ nodes_strictness_scores,
172
+ ).html
173
+ )
159
174
  end
160
175
  end
161
176
 
@@ -246,27 +261,14 @@ module Spoom
246
261
  class Report < Page
247
262
  extend T::Sig
248
263
 
249
- sig { returns(String) }
250
- attr_reader :project_name
251
-
252
- sig { returns(T.nilable(String)) }
253
- attr_reader :sorbet_intro_commit
254
-
255
- sig { returns(T.nilable(Time)) }
256
- attr_reader :sorbet_intro_date
257
-
258
- sig { returns(T::Array[Snapshot]) }
259
- attr_reader :snapshots
260
-
261
- sig { returns(FileTree) }
262
- attr_reader :sigils_tree
263
-
264
264
  sig do
265
265
  params(
266
266
  project_name: String,
267
267
  palette: D3::ColorPalette,
268
268
  snapshots: T::Array[Snapshot],
269
- sigils_tree: FileTree,
269
+ file_tree: FileTree,
270
+ nodes_strictnesses: T::Hash[FileTree::Node, T.nilable(String)],
271
+ nodes_strictness_scores: T::Hash[FileTree::Node, Float],
270
272
  sorbet_intro_commit: T.nilable(String),
271
273
  sorbet_intro_date: T.nilable(Time),
272
274
  ).void
@@ -275,24 +277,28 @@ module Spoom
275
277
  project_name:,
276
278
  palette:,
277
279
  snapshots:,
278
- sigils_tree:,
280
+ file_tree:,
281
+ nodes_strictnesses:,
282
+ nodes_strictness_scores:,
279
283
  sorbet_intro_commit: nil,
280
284
  sorbet_intro_date: nil
281
285
  )
282
286
  super(title: project_name, palette: palette)
283
287
  @project_name = project_name
284
288
  @snapshots = snapshots
285
- @sigils_tree = sigils_tree
289
+ @file_tree = file_tree
290
+ @nodes_strictnesses = nodes_strictnesses
291
+ @nodes_strictness_scores = nodes_strictness_scores
286
292
  @sorbet_intro_commit = sorbet_intro_commit
287
293
  @sorbet_intro_date = sorbet_intro_date
288
294
  end
289
295
 
290
296
  sig { override.returns(String) }
291
297
  def header_html
292
- last = T.must(snapshots.last)
298
+ last = T.must(@snapshots.last)
293
299
  <<~ERB
294
300
  <h1 class="display-3">
295
- #{project_name}
301
+ #{@project_name}
296
302
  <span class="badge badge-pill badge-dark" style="font-size: 20%;">#{last.commit_sha}</span>
297
303
  </h1>
298
304
  ERB
@@ -300,17 +306,24 @@ module Spoom
300
306
 
301
307
  sig { override.returns(T::Array[Cards::Card]) }
302
308
  def cards
303
- last = T.must(snapshots.last)
309
+ last = T.must(@snapshots.last)
304
310
  cards = []
305
311
  cards << Cards::Snapshot.new(snapshot: last)
306
- cards << Cards::Map.new(sigils_tree: sigils_tree)
307
- cards << Cards::Timeline::Sigils.new(snapshots: snapshots)
308
- cards << Cards::Timeline::Calls.new(snapshots: snapshots)
309
- cards << Cards::Timeline::Sigs.new(snapshots: snapshots)
310
- cards << Cards::Timeline::RBIs.new(snapshots: snapshots)
311
- cards << Cards::Timeline::Versions.new(snapshots: snapshots)
312
- cards << Cards::Timeline::Runtimes.new(snapshots: snapshots)
313
- cards << Cards::SorbetIntro.new(sorbet_intro_commit: sorbet_intro_commit, sorbet_intro_date: sorbet_intro_date)
312
+ cards << Cards::Map.new(
313
+ file_tree: @file_tree,
314
+ nodes_strictnesses: @nodes_strictnesses,
315
+ nodes_strictness_scores: @nodes_strictness_scores,
316
+ )
317
+ cards << Cards::Timeline::Sigils.new(snapshots: @snapshots)
318
+ cards << Cards::Timeline::Calls.new(snapshots: @snapshots)
319
+ cards << Cards::Timeline::Sigs.new(snapshots: @snapshots)
320
+ cards << Cards::Timeline::RBIs.new(snapshots: @snapshots)
321
+ cards << Cards::Timeline::Versions.new(snapshots: @snapshots)
322
+ cards << Cards::Timeline::Runtimes.new(snapshots: @snapshots)
323
+ cards << Cards::SorbetIntro.new(
324
+ sorbet_intro_commit: @sorbet_intro_commit,
325
+ sorbet_intro_date: @sorbet_intro_date,
326
+ )
314
327
  cards
315
328
  end
316
329
  end