spoom 1.2.1 → 1.2.3

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