spoom 1.2.1 → 1.2.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b116c81229f1eb09a9e8676643fd54bb3436f7fa2534b06275d657bcd995f0c4
4
- data.tar.gz: 93431ed795ccdcb7aa3afcec9cd3bc468c5b1893bfac4f269c33df9f52e32055
3
+ metadata.gz: 8e7ef8ebef70bc8827cdcb4b2c0e49ed6c53686e0db409908354f3762ad07c10
4
+ data.tar.gz: a6adb405f0379cbe040e743f177e758e2bdaadcdea13ee9c7f75d84f764434b1
5
5
  SHA512:
6
- metadata.gz: 826328b46d178109253dc01197f1cf80102c2174d7dc5b3ac1d52c142fee9785e7100e4b444c4bba71e5303fa711d15f8368cd173177b3614b54ce14c60c5b09
7
- data.tar.gz: 60a28d2220e2507d9deaf12d7e3f51e5ea08418f79685c40aa469da5bc317dbddfded2c8d49cd0102035e5a990d9fa60693ae65a7d1e7538118768bea1140f7f
6
+ metadata.gz: 86d74bc7d9554d67c1421908116bbab7615d77e9cb096d461f7885429d495f2b02f19d6024117f7083423cfe7c48165b18918c143f29574896bfbb76a4fb651f
7
+ data.tar.gz: 754d41e8f26425a42288388879fe1658214adea03002421495297486343a827d6dc79a395e895553ed15f67cdc766a16f4a174aab2a3671bd2455e305a8c6f13
data/Gemfile CHANGED
@@ -7,7 +7,6 @@ gemspec
7
7
 
8
8
  group :development do
9
9
  gem "debug"
10
- gem "ruby-lsp"
11
10
  gem "rubocop-shopify", require: false
12
11
  gem "rubocop-sorbet", require: false
13
12
  gem "tapioca", require: false
@@ -187,7 +187,7 @@ module Spoom
187
187
 
188
188
  no_commands do
189
189
  def parse_time(string, option)
190
- return nil unless string
190
+ return unless string
191
191
 
192
192
  Time.parse(string)
193
193
  rescue ArgumentError
@@ -10,12 +10,18 @@ module Spoom
10
10
 
11
11
  requires_ancestor { Context }
12
12
 
13
- # Read the `contents` of the Gemfile in this context directory
13
+ # Read the contents of the Gemfile in this context directory
14
14
  sig { returns(T.nilable(String)) }
15
15
  def read_gemfile
16
16
  read("Gemfile")
17
17
  end
18
18
 
19
+ # Read the contents of the Gemfile.lock in this context directory
20
+ sig { returns(T.nilable(String)) }
21
+ def read_gemfile_lock
22
+ read("Gemfile.lock")
23
+ end
24
+
19
25
  # Set the `contents` of the Gemfile in this context directory
20
26
  sig { params(contents: String, append: T::Boolean).void }
21
27
  def write_gemfile!(contents, append: false)
@@ -41,17 +47,20 @@ module Spoom
41
47
  bundle("exec #{command}", version: version, capture_err: capture_err)
42
48
  end
43
49
 
50
+ sig { returns(T::Hash[String, Bundler::LazySpecification]) }
51
+ def gemfile_lock_specs
52
+ return {} unless file?("Gemfile.lock")
53
+
54
+ parser = Bundler::LockfileParser.new(read_gemfile_lock)
55
+ parser.specs.map { |spec| [spec.name, spec] }.to_h
56
+ end
57
+
44
58
  # Get `gem` version from the `Gemfile.lock` content
45
59
  #
46
60
  # Returns `nil` if `gem` cannot be found in the Gemfile.
47
61
  sig { params(gem: String).returns(T.nilable(String)) }
48
62
  def gem_version_from_gemfile_lock(gem)
49
- return nil unless file?("Gemfile.lock")
50
-
51
- content = read("Gemfile.lock").match(/^ #{gem} \(.*(\d+\.\d+\.\d+).*\)/)
52
- return nil unless content
53
-
54
- content[1]
63
+ gemfile_lock_specs[gem]&.version&.to_s
55
64
  end
56
65
  end
57
66
  end
@@ -43,6 +43,23 @@ module Spoom
43
43
  glob("*")
44
44
  end
45
45
 
46
+ sig do
47
+ params(
48
+ allow_extensions: T::Array[String],
49
+ allow_mime_types: T::Array[String],
50
+ exclude_patterns: T::Array[String],
51
+ ).returns(T::Array[String])
52
+ end
53
+ def collect_files(allow_extensions: [], allow_mime_types: [], exclude_patterns: [])
54
+ collector = FileCollector.new(
55
+ allow_extensions: allow_extensions,
56
+ allow_mime_types: allow_mime_types,
57
+ exclude_patterns: exclude_patterns,
58
+ )
59
+ collector.visit_path(absolute_path)
60
+ collector.files.map { |file| file.delete_prefix("#{absolute_path}/") }
61
+ end
62
+
46
63
  # Does `relative_path` point to an existing file in this context directory?
47
64
  sig { params(relative_path: String).returns(T::Boolean) }
48
65
  def file?(relative_path)
@@ -13,7 +13,7 @@ module Spoom
13
13
  sig { params(string: String).returns(T.nilable(Commit)) }
14
14
  def parse_line(string)
15
15
  sha, epoch = string.split(" ", 2)
16
- return nil unless sha && epoch
16
+ return unless sha && epoch
17
17
 
18
18
  time = Time.strptime(epoch, "%s")
19
19
  Commit.new(sha: sha, time: time)
@@ -63,8 +63,18 @@ module Spoom
63
63
  git("checkout #{ref}")
64
64
  end
65
65
 
66
+ # Run `git checkout -b <branch-name> <ref>` in this context directory
67
+ sig { params(branch_name: String, ref: T.nilable(String)).returns(ExecResult) }
68
+ def git_checkout_new_branch!(branch_name, ref: nil)
69
+ if ref
70
+ git("checkout -b #{branch_name} #{ref}")
71
+ else
72
+ git("checkout -b #{branch_name}")
73
+ end
74
+ end
75
+
66
76
  # Run `git add . && git commit` in this context directory
67
- sig { params(message: String, time: Time, allow_empty: T::Boolean).void }
77
+ sig { params(message: String, time: Time, allow_empty: T::Boolean).returns(ExecResult) }
68
78
  def git_commit!(message: "message", time: Time.now.utc, allow_empty: false)
69
79
  git("add --all")
70
80
 
@@ -78,7 +88,7 @@ module Spoom
78
88
  sig { returns(T.nilable(String)) }
79
89
  def git_current_branch
80
90
  res = git("branch --show-current")
81
- return nil unless res.status
91
+ return unless res.status
82
92
 
83
93
  res.out.strip
84
94
  end
@@ -93,10 +103,10 @@ module Spoom
93
103
  sig { params(short_sha: T::Boolean).returns(T.nilable(Spoom::Git::Commit)) }
94
104
  def git_last_commit(short_sha: true)
95
105
  res = git_log("HEAD --format='%#{short_sha ? "h" : "H"} %at' -1")
96
- return nil unless res.status
106
+ return unless res.status
97
107
 
98
108
  out = res.out.strip
99
- return nil if out.empty?
109
+ return if out.empty?
100
110
 
101
111
  Spoom::Git::Commit.parse_line(out)
102
112
  end
@@ -106,6 +116,12 @@ module Spoom
106
116
  git("log #{arg.join(" ")}")
107
117
  end
108
118
 
119
+ # Run `git push <remote> <ref>` in this context directory
120
+ sig { params(remote: String, ref: String, force: T::Boolean).returns(ExecResult) }
121
+ def git_push!(remote, ref, force: false)
122
+ git("push #{force ? "-f" : ""} #{remote} #{ref}")
123
+ end
124
+
109
125
  sig { params(arg: String).returns(ExecResult) }
110
126
  def git_show(*arg)
111
127
  git("show #{arg.join(" ")}")
@@ -52,7 +52,7 @@ module Spoom
52
52
  sorbet_bin: sorbet_bin,
53
53
  capture_err: capture_err,
54
54
  )
55
- return nil unless file?(metrics_file)
55
+ return unless file?(metrics_file)
56
56
 
57
57
  metrics_path = absolute_path_to(metrics_file)
58
58
  metrics = Spoom::Sorbet::MetricsParser.parse_file(metrics_path)
@@ -69,7 +69,24 @@ module Spoom
69
69
  allowed_extensions = Spoom::Sorbet::Config::DEFAULT_ALLOWED_EXTENSIONS if allowed_extensions.empty?
70
70
  allowed_extensions -= [".rbi"] unless include_rbis
71
71
 
72
- excluded_patterns = config.ignore.map { |string| File.join("**", string, "**") }
72
+ excluded_patterns = config.ignore.map do |string|
73
+ # We need to simulate the behavior of Sorbet's `--ignore` flag.
74
+ #
75
+ # From Sorbet docs on `--ignore`:
76
+ # > Ignores input files that contain the given string in their paths (relative to the input path passed to
77
+ # > Sorbet). Strings beginning with / match against the prefix of these relative paths; others are substring
78
+ # > matchs. Matches must be against whole folder and file names, so `foo` matches `/foo/bar.rb` and
79
+ # > `/bar/foo/baz.rb` but not `/foo.rb` or `/foo2/bar.rb`.
80
+ string = if string.start_with?("/")
81
+ # Strings beginning with / match against the prefix of these relative paths
82
+ File.join(absolute_path, string)
83
+ else
84
+ # Others are substring matchs
85
+ File.join(absolute_path, "**", string)
86
+ end
87
+ # Matches must be against whole folder and file names
88
+ "#{string.delete_suffix("/")}{,/**}"
89
+ end
73
90
 
74
91
  collector = FileCollector.new(allow_extensions: allowed_extensions, exclude_patterns: excluded_patterns)
75
92
  collector.visit_paths(config.paths.map { |path| absolute_path_to(path) })
@@ -92,7 +109,7 @@ module Spoom
92
109
  sig { params(arg: String, sorbet_bin: T.nilable(String), capture_err: T::Boolean).returns(T.nilable(String)) }
93
110
  def srb_version(*arg, sorbet_bin: nil, capture_err: true)
94
111
  res = T.unsafe(self).srb_tc("--no-config", "--version", *arg, sorbet_bin: sorbet_bin, capture_err: capture_err)
95
- return nil unless res.status
112
+ return unless res.status
96
113
 
97
114
  res.out.split(" ")[2]
98
115
  end
@@ -130,10 +147,10 @@ module Spoom
130
147
  sig { returns(T.nilable(Spoom::Git::Commit)) }
131
148
  def sorbet_intro_commit
132
149
  res = git_log("--diff-filter=A --format='%h %at' -1 -- sorbet/config")
133
- return nil unless res.status
150
+ return unless res.status
134
151
 
135
152
  out = res.out.strip
136
- return nil if out.empty?
153
+ return if out.empty?
137
154
 
138
155
  Spoom::Git::Commit.parse_line(out)
139
156
  end
@@ -142,10 +159,10 @@ module Spoom
142
159
  sig { returns(T.nilable(Spoom::Git::Commit)) }
143
160
  def sorbet_removal_commit
144
161
  res = git_log("--diff-filter=D --format='%h %at' -1 -- sorbet/config")
145
- return nil unless res.status
162
+ return unless res.status
146
163
 
147
164
  out = res.out.strip
148
- return nil if out.empty?
165
+ return if out.empty?
149
166
 
150
167
  Spoom::Git::Commit.parse_line(out)
151
168
  end
@@ -0,0 +1,98 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ # A definition is a class, module, method, constant, etc. being defined in the code
7
+ class Definition < T::Struct
8
+ extend T::Sig
9
+
10
+ class Kind < T::Enum
11
+ enums do
12
+ AttrReader = new("attr_reader")
13
+ AttrWriter = new("attr_writer")
14
+ Class = new("class")
15
+ Constant = new("constant")
16
+ Method = new("method")
17
+ Module = new("module")
18
+ end
19
+ end
20
+
21
+ class Status < T::Enum
22
+ enums do
23
+ # A definition is marked as `ALIVE` if it has at least one reference with the same name
24
+ ALIVE = new
25
+ # A definition is marked as `DEAD` if it has no reference with the same name
26
+ DEAD = new
27
+ # A definition can be marked as `IGNORED` if it is not relevant for the analysis
28
+ IGNORED = new
29
+ end
30
+ end
31
+
32
+ const :kind, Kind
33
+ const :name, String
34
+ const :full_name, String
35
+ const :location, Location
36
+ const :status, Status, default: Status::DEAD
37
+
38
+ # Kind
39
+
40
+ sig { returns(T::Boolean) }
41
+ def attr_reader?
42
+ kind == Kind::AttrReader
43
+ end
44
+
45
+ sig { returns(T::Boolean) }
46
+ def attr_writer?
47
+ kind == Kind::AttrWriter
48
+ end
49
+
50
+ sig { returns(T::Boolean) }
51
+ def class?
52
+ kind == Kind::Class
53
+ end
54
+
55
+ sig { returns(T::Boolean) }
56
+ def constant?
57
+ kind == Kind::Constant
58
+ end
59
+
60
+ sig { returns(T::Boolean) }
61
+ def method?
62
+ kind == Kind::Method
63
+ end
64
+
65
+ sig { returns(T::Boolean) }
66
+ def module?
67
+ kind == Kind::Module
68
+ end
69
+
70
+ # Status
71
+
72
+ sig { returns(T::Boolean) }
73
+ def alive?
74
+ status == Status::ALIVE
75
+ end
76
+
77
+ sig { void }
78
+ def alive!
79
+ @status = Status::ALIVE
80
+ end
81
+
82
+ sig { returns(T::Boolean) }
83
+ def dead?
84
+ status == Status::DEAD
85
+ end
86
+
87
+ sig { returns(T::Boolean) }
88
+ def ignored?
89
+ status == Status::IGNORED
90
+ end
91
+
92
+ sig { void }
93
+ def ignored!
94
+ @status = Status::IGNORED
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,103 @@
1
+ # typed: true
2
+ # frozen_string_literal: true
3
+
4
+ # Copied from https://github.com/rails/rails/blob/main/actionview/lib/action_view/template/handlers/erb/erubi.rb.
5
+ #
6
+ # Copyright (c) David Heinemeier Hansson
7
+ #
8
+ # Permission is hereby granted, free of charge, to any person obtaining
9
+ # a copy of this software and associated documentation files (the
10
+ # "Software"), to deal in the Software without restriction, including
11
+ # without limitation the rights to use, copy, modify, merge, publish,
12
+ # distribute, sublicense, and/or sell copies of the Software, and to
13
+ # permit persons to whom the Software is furnished to do so, subject to
14
+ # the following conditions:
15
+ #
16
+ # The above copyright notice and this permission notice shall be
17
+ # included in all copies or substantial portions of the Software.
18
+ #
19
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
20
+ # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
21
+ # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
22
+ # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
23
+ # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
24
+ # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
25
+ # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
26
+ module Spoom
27
+ module Deadcode
28
+ # Custom engine to handle ERB templates as used by Rails
29
+ class ERB < ::Erubi::Engine
30
+ extend T::Sig
31
+
32
+ sig { params(input: T.untyped, properties: T.untyped).void }
33
+ def initialize(input, properties = {})
34
+ @newline_pending = 0
35
+
36
+ properties = Hash[properties]
37
+ properties[:bufvar] ||= "@output_buffer"
38
+ properties[:preamble] ||= ""
39
+ properties[:postamble] ||= "#{properties[:bufvar]}.to_s"
40
+ properties[:escapefunc] = ""
41
+
42
+ super
43
+ end
44
+
45
+ private
46
+
47
+ sig { params(text: T.untyped).void }
48
+ def add_text(text)
49
+ return if text.empty?
50
+
51
+ if text == "\n"
52
+ @newline_pending += 1
53
+ else
54
+ src << bufvar << ".safe_append='"
55
+ src << "\n" * @newline_pending if @newline_pending > 0
56
+ src << text.gsub(/['\\]/, '\\\\\&')
57
+ src << "'.freeze;"
58
+
59
+ @newline_pending = 0
60
+ end
61
+ end
62
+
63
+ BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/
64
+
65
+ sig { params(indicator: T.untyped, code: T.untyped).void }
66
+ def add_expression(indicator, code)
67
+ flush_newline_if_pending(src)
68
+
69
+ src << bufvar << if (indicator == "==") || @escape
70
+ ".safe_expr_append="
71
+ else
72
+ ".append="
73
+ end
74
+
75
+ if BLOCK_EXPR.match?(code)
76
+ src << " " << code
77
+ else
78
+ src << "(" << code << ");"
79
+ end
80
+ end
81
+
82
+ sig { params(code: T.untyped).void }
83
+ def add_code(code)
84
+ flush_newline_if_pending(src)
85
+ super
86
+ end
87
+
88
+ sig { params(_: T.untyped).void }
89
+ def add_postamble(_)
90
+ flush_newline_if_pending(src)
91
+ super
92
+ end
93
+
94
+ sig { params(src: T.untyped).void }
95
+ def flush_newline_if_pending(src)
96
+ if @newline_pending > 0
97
+ src << bufvar << ".safe_append='#{"\n" * @newline_pending}'.freeze;"
98
+ @newline_pending = 0
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,61 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Spoom
5
+ module Deadcode
6
+ class Index
7
+ extend T::Sig
8
+
9
+ sig { returns(T::Hash[String, T::Array[Definition]]) }
10
+ attr_reader :definitions
11
+
12
+ sig { returns(T::Hash[String, T::Array[Reference]]) }
13
+ attr_reader :references
14
+
15
+ sig { void }
16
+ def initialize
17
+ @definitions = T.let({}, T::Hash[String, T::Array[Definition]])
18
+ @references = T.let({}, T::Hash[String, T::Array[Reference]])
19
+ end
20
+
21
+ # Indexing
22
+
23
+ sig { params(definition: Definition).void }
24
+ def define(definition)
25
+ (@definitions[definition.name] ||= []) << definition
26
+ end
27
+
28
+ sig { params(reference: Reference).void }
29
+ def reference(reference)
30
+ (@references[reference.name] ||= []) << reference
31
+ end
32
+
33
+ # Mark all definitions having a reference of the same name as `alive`
34
+ #
35
+ # To be called once all the files have been indexed and all the definitions and references discovered.
36
+ sig { void }
37
+ def finalize!
38
+ @references.keys.each do |name|
39
+ definitions_for_name(name).each(&:alive!)
40
+ end
41
+ end
42
+
43
+ # Utils
44
+
45
+ sig { params(name: String).returns(T::Array[Definition]) }
46
+ def definitions_for_name(name)
47
+ @definitions[name] || []
48
+ end
49
+
50
+ sig { returns(T::Array[Definition]) }
51
+ def all_definitions
52
+ @definitions.values.flatten
53
+ end
54
+
55
+ sig { returns(T::Array[Reference]) }
56
+ def all_references
57
+ @references.values.flatten
58
+ end
59
+ end
60
+ end
61
+ end