spoom 1.1.16 → 1.2.1

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