rubocop-sorbet 0.12.0 → 0.13.0

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: 6435562ddd6c7da9c69d97032407e065c2258099a118a71b7d6ac5a0e1164fd0
4
- data.tar.gz: 22267c08a28a1e491546a310be05de578ba7693aba4c597ba0b508c048669777
3
+ metadata.gz: b5dcbac6e0a2af2d4bed5b33ef9bd2d9cfad4608a400dc2f81e0c8c338543615
4
+ data.tar.gz: 9960e81847d7cda7ffb4b94d9dff22ac65f3c2b9c74664c931684b492d7da14e
5
5
  SHA512:
6
- metadata.gz: 85a52ba95de0151045c93321e38ec24e0859067166084b3bf1d96e3e66e91cb27997224340a26cb39a1684341e4ce1d8e1e596643216b8761d3db0960e5e361a
7
- data.tar.gz: d5897dc561a7fe22cbd099c3b34355bf474f7bbcf139c3c7a1c3fed24cf3295cc02e9349a46e75a6d75da0db547f21cd971dd557c0177b88dba6842ca661602e
6
+ metadata.gz: 7a0e44a9cc6fd67dd8fb564015f9dfee1e76c510d2b5e3dd853cffb72ee4f6b30948593f8d96f008749fdcbb5a70b02b11342396787a8e48f1fd9b11a6f308cd
7
+ data.tar.gz: 647911f5d12c0a8459e92ee71b0cd3bc01b7d4e8b15195555e82194499f4b57423d2307854f080f00132bf64f86d2356cefc6b22c545cda00b24427cbbb4fd9c
@@ -8,7 +8,7 @@ jobs:
8
8
  check-placeholders:
9
9
  runs-on: ubuntu-latest
10
10
  steps:
11
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
11
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
12
12
  - uses: ruby/setup-ruby@v1
13
13
  with:
14
14
  bundler-cache: true
@@ -14,7 +14,7 @@ jobs:
14
14
  ruby: ["3.1", "3.2", "3.3", "3.4"]
15
15
  name: Test Ruby ${{ matrix.ruby }}
16
16
  steps:
17
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
18
18
  - name: Set up Ruby
19
19
  uses: ruby/setup-ruby@a4effe49ee8ee5b8b5091268c473a4628afb5651 # v1.245.0
20
20
  with:
@@ -29,7 +29,7 @@ jobs:
29
29
  fail-fast: false
30
30
  name: Lint & Docs
31
31
  steps:
32
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
32
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
33
33
  - name: Set up Ruby
34
34
  uses: ruby/setup-ruby@a4effe49ee8ee5b8b5091268c473a4628afb5651 # v1.245.0
35
35
  with:
@@ -12,12 +12,12 @@ jobs:
12
12
  steps:
13
13
  - name: Dependabot metadata
14
14
  id: metadata
15
- uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a # v2.5.0
15
+ uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
16
16
  with:
17
17
  github-token: "${{ secrets.GITHUB_TOKEN }}"
18
18
  - name: Enable auto-merge for Dependabot PRs
19
19
  if: ${{ steps.metadata.outputs.update-type != 'version-update:semver-major' }}
20
- uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
20
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
21
21
  with:
22
22
  github-token: "${{ secrets.GITHUB_TOKEN }}"
23
23
  script: |
@@ -0,0 +1,52 @@
1
+ name: Release gem on RubyGems.org and create GitHub release
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - 'v[0-9]+.[0-9]+.[0-9]+'
7
+
8
+ jobs:
9
+ release_gem:
10
+ name: Release gem on RubyGems.org
11
+ if: github.repository == 'Shopify/rubocop-sorbet'
12
+ runs-on: ubuntu-latest
13
+
14
+ permissions:
15
+ contents: write
16
+ id-token: write
17
+
18
+ environment: release
19
+
20
+ steps:
21
+ - name: Checkout repository
22
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
23
+ with:
24
+ persist-credentials: false
25
+
26
+ - name: Set up Ruby
27
+ uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
28
+ with:
29
+ bundler-cache: true
30
+
31
+ - name: Release gem on RubyGems.org
32
+ uses: rubygems/release-gem@052cc82692552de3ef2b81fd670e41d13cba8092 # v1.4.0
33
+
34
+ release_github:
35
+ name: Create GitHub release
36
+ if: github.repository == 'Shopify/rubocop-sorbet'
37
+ needs: release_gem
38
+ runs-on: ubuntu-latest
39
+
40
+ permissions:
41
+ contents: write
42
+
43
+ steps:
44
+ - name: Checkout repository
45
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
46
+
47
+ - name: Create GitHub release
48
+ run: |
49
+ tag_name="$(git describe --tags --abbrev=0)"
50
+ gh release create "${tag_name}" --verify-tag --generate-notes
51
+ env:
52
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -12,7 +12,7 @@ jobs:
12
12
  stale:
13
13
  runs-on: ubuntu-latest
14
14
  steps:
15
- - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
15
+ - uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
16
16
  with:
17
17
  stale-pr-message: >
18
18
  This PR has been automatically marked as stale because it has not had
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- rubocop-sorbet (0.12.0)
4
+ rubocop-sorbet (0.13.0)
5
5
  lint_roller
6
6
  rubocop (>= 1.75.2)
7
7
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.12.0
1
+ 0.13.0
data/config/default.yml CHANGED
@@ -8,6 +8,15 @@ Sorbet/ForbidTSig:
8
8
  Enabled: false
9
9
  VersionAdded: '0.11.0'
10
10
 
11
+ Sorbet/RedundantTLet:
12
+ Description: >-
13
+ Prevents redundant use of `T.let` where Sorbet infers types automatically:
14
+ instance variables assigned from signature parameters in `initialize`, and
15
+ constants assigned constructor calls.
16
+ Enabled: true
17
+ SafeAutoCorrect: false
18
+ VersionAdded: '0.13.0'
19
+
11
20
  inherit_mode:
12
21
  merge:
13
22
  - Exclude
@@ -97,7 +106,9 @@ Sorbet/EnforceSignatures:
97
106
  Description: 'Ensures all methods have a valid signature.'
98
107
  Enabled: false
99
108
  Style: sig
109
+ AutocorrectStyle: sig
100
110
  VersionAdded: 0.3.4
111
+ VersionChanged: '0.13.0'
101
112
 
102
113
  Sorbet/EnforceSingleSigil:
103
114
  Description: 'Ensures that there is only one Sorbet sigil in a file.'
@@ -316,6 +327,15 @@ Sorbet/RedundantExtendTSig:
316
327
  Safe: false
317
328
  VersionAdded: 0.7.0
318
329
 
330
+ Sorbet/RedundantTLetForLiteral:
331
+ Description: >-
332
+ Checks for redundant `T.let` declarations on constants where the value
333
+ is a simple literal whose type Sorbet can infer automatically.
334
+ Enabled: pending
335
+ Safe: true
336
+ SafeAutoCorrect: false
337
+ VersionAdded: '0.13.0'
338
+
319
339
  Sorbet/Refinement:
320
340
  Description: >-
321
341
  Checks for the use of Ruby Refinements library. Refinements add
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sorbet
6
+ # Shared helper for cops that unwrap a `T.let` on a constant assignment.
7
+ module ConstantScope
8
+ private
9
+
10
+ # True when the constant is assigned directly in a class, module, or
11
+ # top-level body. In `typed: strict` files Sorbet requires a `T.let` on
12
+ # constants assigned elsewhere (inside a conditional, loop, block, or
13
+ # method — error 7027), so removing the annotation there would break
14
+ # typechecking.
15
+ def statically_scoped?(node)
16
+ ancestor = node.parent
17
+ ancestor = ancestor.parent if ancestor&.begin_type?
18
+ ancestor.nil? || ancestor.class_type? || ancestor.module_type? || ancestor.sclass_type?
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sorbet
6
+ # Shared autocorrection helper for cops that unwrap a redundant
7
+ # `T.let(value, Type)` down to `value`, preserving heredoc bodies that a
8
+ # naive `corrector.replace(t_let_node, value_node.source)` would drop
9
+ # (`value_node.source` stops at the marker line, so the body and its
10
+ # terminator sit outside it).
11
+ module TLetCorrection
12
+ private
13
+
14
+ # Replaces `T.let(value, Type)` with `value`.
15
+ def replace_t_let(corrector, t_let_node, value_node)
16
+ heredocs = value_node.each_node(:any_str).select(&:heredoc?)
17
+ return corrector.replace(t_let_node, value_node.source) if heredocs.empty?
18
+
19
+ replace_t_let_preserving_heredocs(corrector, t_let_node, value_node, heredocs)
20
+ end
21
+
22
+ # Replaces `T.let(value, Type)` with `value` without losing any heredocs
23
+ # contained in the value. For example, `T.let(<<~SQL, String)` becomes
24
+ # `<<~SQL` followed by its original body and terminator.
25
+ def replace_t_let_preserving_heredocs(corrector, t_let_node, value_node, heredocs)
26
+ corrector.replace(
27
+ range_including_heredocs(t_let_node, heredocs),
28
+ source_including_heredocs(value_node, heredocs),
29
+ )
30
+ end
31
+
32
+ # Expands the correction range to include every heredoc body and terminator.
33
+ # For example, the range for `T.let(<<~SQL, String)` extends through the
34
+ # final `SQL` terminator rather than ending at the closing parenthesis.
35
+ def range_including_heredocs(t_let_node, heredocs)
36
+ end_positions = [t_let_node.source_range.end_pos]
37
+ end_positions.concat(heredocs.map { |node| node.loc.heredoc_end.end_pos })
38
+ t_let_node.source_range.with(end_pos: end_positions.max)
39
+ end
40
+
41
+ # Reconstructs the value with its marker-line comment and heredoc bodies.
42
+ # For example, `T.let(<<~SQL, String) # query` becomes a source string
43
+ # containing `<<~SQL # query`, followed by its body and `SQL` terminator.
44
+ def source_including_heredocs(value_node, heredocs)
45
+ marker_line = "#{value_node.source}#{marker_line_comment(value_node)}"
46
+ heredoc_bodies = heredocs
47
+ .sort_by { |node| node.loc.heredoc_body.begin_pos }
48
+ .map { |node| "#{node.loc.heredoc_body.source}#{node.loc.heredoc_end.source}" }
49
+
50
+ ([marker_line] + heredoc_bodies).join("\n")
51
+ end
52
+
53
+ # Returns the comment trailing the heredoc marker, including its leading space.
54
+ # For example, the marker line `<<~SQL, String) # query` returns ` # query`.
55
+ def marker_line_comment(value_node)
56
+ buffer = value_node.source_range.source_buffer.source
57
+ value_end = value_node.source_range.end_pos
58
+ line_end = buffer.index("\n", value_end) || buffer.length
59
+ comment = buffer[value_end...line_end][/#.*/]
60
+ comment ? " #{comment}" : ""
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -12,10 +12,25 @@ module RuboCop
12
12
  base.def_node_matcher(:t_let, <<~PATTERN)
13
13
  (send (const nil? :T) :let $_constant _type)
14
14
  PATTERN
15
+
16
+ # @!method sorbet_type_declaration?(node)
17
+ base.def_node_matcher(:sorbet_type_declaration?, <<~PATTERN)
18
+ {
19
+ (send nil? {:type_member :type_template} ...)
20
+ (block (send nil? {:type_member :type_template} ...) ...)
21
+ (send (const nil? :T) :type_alias ...)
22
+ (block (send (const nil? :T) :type_alias ...) ...)
23
+ }
24
+ PATTERN
15
25
  end
16
26
  end
17
27
 
18
28
  def on_assignment(value)
29
+ # Sorbet type declarations such as `type_member`, `type_template`,
30
+ # and `T.type_alias` produce type objects that cannot be frozen
31
+ # (calling `.freeze` on them breaks Sorbet), so they are exempt.
32
+ return if sorbet_type_declaration?(value)
33
+
19
34
  t_let(value) do |constant|
20
35
  value = constant
21
36
  end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module Sorbet
6
+ # Prevents unnecessary `T.let` where Sorbet infers the type automatically.
7
+ #
8
+ # When a signature parameter is assigned to an instance variable in
9
+ # `initialize`, the type is inferred from the signature.
10
+ #
11
+ # When a constant is assigned a constructor call (`.new`), optionally
12
+ # followed by `.freeze` (Sorbet 0.6.13304+), the type is inferred from
13
+ # the class being instantiated. Generic classes (e.g. `Set`) are
14
+ # excluded: Sorbet infers their constructor calls as applied types like
15
+ # `T::Set[T.untyped]`, so an annotation is still required.
16
+ #
17
+ # @example
18
+ #
19
+ # # bad
20
+ # sig { params(a: Integer) }
21
+ # def initialize(a)
22
+ # @a = T.let(a, Integer)
23
+ # end
24
+ #
25
+ # # good
26
+ # sig { params(a: Integer) }
27
+ # def initialize(a)
28
+ # @a = a
29
+ # end
30
+ #
31
+ # # good
32
+ # sig { params(a: Integer) }
33
+ # def initialize(a)
34
+ # @a = T.let(a, T.any(Integer, String))
35
+ # end
36
+ #
37
+ # # bad
38
+ # DEFAULT_PATH = T.let(Pathname.new("/usr/local").freeze, Pathname)
39
+ #
40
+ # # good
41
+ # DEFAULT_PATH = Pathname.new("/usr/local").freeze
42
+ #
43
+ # # good — generic classes are not inferred, so T.let is required
44
+ # LICENSES = T.let(Set.new(["mit"]).freeze, T::Set[String])
45
+ #
46
+ # # good — instance variables are only inferred from signature parameters
47
+ # @path = T.let(Pathname.new("/usr/local"), Pathname)
48
+ class RedundantTLet < RuboCop::Cop::Base
49
+ include SignatureHelp
50
+ include ConstantScope
51
+ include TargetSorbetVersion
52
+ include TLetCorrection
53
+ extend AutoCorrector
54
+
55
+ # The constructor-constant inference these cops rely on (`X = A.new`,
56
+ # `X = A.new.freeze`) was finalized by Sorbet's freeze-transparent
57
+ # inference in 0.6.13304. Flagging it against an older Sorbet could
58
+ # remove a `T.let` that Sorbet still requires, so the constant path
59
+ # (`on_casgn`) disables itself below that version. The signature-based
60
+ # instance-variable path (`on_def`) predates this and is not gated.
61
+ minimum_target_sorbet_static_version "0.6.13304"
62
+
63
+ MSG = "Unnecessary T.let. The instance variable type is inferred from the signature."
64
+ MSG_CONSTRUCTOR = "Unnecessary T.let. The constant type is inferred from the constructor."
65
+
66
+ # Classes whose constructor calls Sorbet infers as applied generic
67
+ # types (e.g. `T::Set[T.untyped]`) rather than the bare class, so
68
+ # constants assigned them still need an explicit annotation.
69
+ GENERIC_CLASSES = ["Array", "Class", "Enumerator", "Hash", "Module", "Range", "Set"].freeze
70
+
71
+ # @!method t_let(node)
72
+ def_node_matcher :t_let, "(ivasgn _ $(send (const {nil? cbase} :T) :let (lvar $_) $_))"
73
+
74
+ # @!method t_let_casgn(node)
75
+ def_node_matcher :t_let_casgn, "(casgn _ _ $(send (const {nil? cbase} :T) :let $_ $_))"
76
+
77
+ # @!method sig_params(node)
78
+ def_node_matcher :sig_params, "`(send nil? :params (hash $...))"
79
+
80
+ def on_def(node)
81
+ return unless node.method?(:initialize)
82
+
83
+ method_args = named_argument_types(node)
84
+ return if method_args.none?
85
+
86
+ sig_node = find_sig_node(node)
87
+ return unless sig_node
88
+
89
+ sig_params = sig_params(sig_node)&.to_h { |pair| [pair.key.value, pair.value] }
90
+ return unless sig_params&.any?
91
+
92
+ ivar_assignments(node).each do |ivasgn_node|
93
+ t_let(ivasgn_node) do |tlet_node, tlet_key, tlet_value|
94
+ find_redundant_t_let(tlet_node, tlet_key, tlet_value, sig_params, method_args)
95
+ end
96
+ end
97
+ end
98
+
99
+ def on_casgn(node)
100
+ return unless enabled_for_sorbet_static_version?
101
+ return unless statically_scoped?(node)
102
+
103
+ t_let_casgn(node) do |tlet_node, value_node, type_node|
104
+ next unless type_node.const_type?
105
+
106
+ constructor = constructor_call(value_node)
107
+ next unless constructor
108
+
109
+ class_path = constructor.receiver.source.delete_prefix("::")
110
+ next if GENERIC_CLASSES.include?(class_path)
111
+ next unless class_path == type_node.source.delete_prefix("::")
112
+
113
+ add_offense(tlet_node, message: MSG_CONSTRUCTOR) do |corrector|
114
+ replace_t_let(corrector, tlet_node, value_node)
115
+ end
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ # Maps named parameters to their AST types, omitting destructured
122
+ # parameters because they have no name and cannot appear in a signature.
123
+ # For example, `def initialize(a, (left, right), *rest)` returns
124
+ # `{ a: :arg, rest: :restarg }`, omitting `(left, right)`.
125
+ def named_argument_types(method_node)
126
+ method_node.arguments.filter_map do |argument|
127
+ [argument.name, argument.type] if argument.respond_to?(:name)
128
+ end.to_h
129
+ end
130
+
131
+ # Returns the `.new` call for a constant constructor, looking through
132
+ # an optional block and trailing `.freeze`. For example,
133
+ # `Foo.new { _1.enabled = true }.freeze` returns the `Foo.new` call,
134
+ # while `foo.new` and `Foo.build` return `nil`.
135
+ def constructor_call(node)
136
+ node = node.receiver if node.send_type? && node.method?(:freeze)
137
+ node = unwrap_block(node)
138
+ return unless node&.send_type? && node.method?(:new)
139
+ return unless node.receiver&.const_type?
140
+
141
+ node
142
+ end
143
+
144
+ # Returns the method send wrapped by a block, or the original node when
145
+ # no block is present. For example, `Foo.new { _1.enabled = true }`
146
+ # returns the `Foo.new` send node.
147
+ def unwrap_block(node)
148
+ node&.any_block_type? ? node.send_node : node
149
+ end
150
+
151
+ def find_sig_node(method_node)
152
+ # When the def is wrapped by a method modifier (`private def initialize`),
153
+ # Sorbet's initializer rewriter does not process the ivar assignments,
154
+ # so T.let annotations remain required. Skip by returning nil.
155
+ return if method_node.parent&.send_type?
156
+
157
+ method_node.left_sibling.then { |s| signature?(s) ? s : nil }
158
+ end
159
+
160
+ def normalize_whitespace(source)
161
+ source
162
+ .gsub(/\s+/, " ") # collapse all whitespace to single spaces
163
+ .gsub(/,\s*([)\]\}])/, "\\1") # remove trailing commas before closing delimiters
164
+ .gsub(/,\s*/, ", ") # normalize to a single space after commas
165
+ .gsub(/\(\s*/, "(") # remove space after (
166
+ .gsub(/\s*\)/, ")") # remove space before )
167
+ .gsub(/\[\s*/, "[") # remove space after [
168
+ .gsub(/\s*\]/, "]") # remove space before ]
169
+ .strip
170
+ end
171
+
172
+ def ivar_assignments(node)
173
+ return [] unless node.body
174
+ return node.body.each_child_node(:ivasgn) if node.body.begin_type?
175
+ return [node.body] if node.body.ivasgn_type?
176
+
177
+ []
178
+ end
179
+
180
+ def find_redundant_t_let(node, tlet_key, tlet_value, sig_params, method_args)
181
+ sig_type = sig_params[tlet_key]
182
+ return unless sig_type
183
+
184
+ method_arg_kind = method_args[tlet_key]
185
+ return unless method_arg_kind
186
+
187
+ arg_type = expected_type(normalize_whitespace(sig_type.source), method_arg_kind)
188
+ return unless normalize_whitespace(tlet_value.source) == arg_type
189
+
190
+ add_offense(node) do |corrector|
191
+ corrector.replace(node, tlet_key.to_s)
192
+ end
193
+ end
194
+
195
+ def expected_type(sig_type, arg_kind)
196
+ case arg_kind
197
+ when :restarg then "T::Array[#{sig_type}]"
198
+ when :kwrestarg then "T::Hash[Symbol, #{sig_type}]"
199
+ else sig_type
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,218 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rubocop"
4
+
5
+ module RuboCop
6
+ module Cop
7
+ module Sorbet
8
+ # Checks for redundant `T.let` declarations where the first argument
9
+ # is a literal whose type Sorbet can infer automatically, so wrapping
10
+ # it in `T.let` is redundant.
11
+ #
12
+ # Simple literals (strings, symbols, integers, floats, regexps) infer as
13
+ # their own class. Regexp literals are the only simple literals whose
14
+ # inference survives a `.freeze` call (Sorbet 0.6.13304+), so
15
+ # `T.let(/foo/.freeze, Regexp)` is also redundant; other frozen simple
16
+ # literals (e.g. `"hello".freeze`) are not inferred and still need `T.let`.
17
+ #
18
+ # Array literals of simple literals are also inferred:
19
+ #
20
+ # * A frozen array (`[...].freeze`) infers as a fixed-size tuple, which is
21
+ # a subtype of the annotated `T::Array`, so the annotation is redundant.
22
+ # * An unfrozen array infers as `T::Array[<element type>]`. It is only
23
+ # flagged when that inferred type matches the annotation exactly, to
24
+ # avoid silently widening (e.g. `["a", nil]` infers a nilable element).
25
+ #
26
+ # Hashes are excluded: Sorbet infers hash literals as `T.untyped`, so the
27
+ # annotation is required.
28
+ #
29
+ # @example
30
+ # # bad
31
+ # MAX_RETRIES = T.let(3, Integer)
32
+ # GREETING = T.let("hello", String)
33
+ # RATE = T.let(1.5, Float)
34
+ # PATTERN = T.let(/foo/, Regexp)
35
+ # FROZEN_PATTERN = T.let(/foo/.freeze, Regexp)
36
+ # STATUS = T.let(:active, Symbol)
37
+ # SHELLS = T.let([:bash, :zsh].freeze, T::Array[Symbol])
38
+ # NAMES = T.let(["alice", "bob"], T::Array[String])
39
+ #
40
+ # # good
41
+ # MAX_RETRIES = 3
42
+ # GREETING = "hello"
43
+ # RATE = 1.5
44
+ # PATTERN = /foo/
45
+ # FROZEN_PATTERN = /foo/.freeze
46
+ # STATUS = :active
47
+ # SHELLS = [:bash, :zsh].freeze
48
+ # NAMES = ["alice", "bob"]
49
+ #
50
+ # # good — non-regexp frozen simple literals are not inferred
51
+ # GREETING = T.let("hello".freeze, String)
52
+ #
53
+ # # good — hash literals are inferred as T.untyped
54
+ # OPTIONS = T.let({ verbose: true }, T::Hash[Symbol, T::Boolean])
55
+ #
56
+ # # good — unfrozen array whose annotation is wider than the inferred type
57
+ # NAMES = T.let(["alice", "bob"], T::Array[T.nilable(String)])
58
+ #
59
+ # # good — type is not the literal's own class
60
+ # value = T.let("hello", T.nilable(String))
61
+ #
62
+ # # good — instance variables need T.let for Sorbet to track their type
63
+ # @max_retries = T.let(3, Integer)
64
+ #
65
+ # # good — local variables may need T.let so Sorbet allows reassignment
66
+ # count = T.let(0, Integer)
67
+ class RedundantTLetForLiteral < Base
68
+ include ConstantScope
69
+ include TargetSorbetVersion
70
+ include TLetCorrection
71
+ extend AutoCorrector
72
+
73
+ # The frozen-literal and literal-array inference the newer cases rely on
74
+ # was added up to Sorbet 0.6.13304 (frozen regexp via freeze-transparent
75
+ # inference; literal arrays via constant tuple inference). Flagging them
76
+ # against an older Sorbet could remove a `T.let` it still requires, so
77
+ # those paths disable themselves below that version. Bare simple
78
+ # literals (`T.let(3, Integer)`) predate this and are not gated.
79
+ minimum_target_sorbet_static_version "0.6.13304"
80
+
81
+ MSG = "Redundant `T.let` for %{type} literal. Sorbet can infer this type automatically."
82
+
83
+ # Simple literal node types Sorbet infers, mapped to the class name.
84
+ # Interpolated strings/symbols (`dstr`/`dsym`) infer as `String`/`Symbol`.
85
+ LITERAL_TYPE_TO_CLASS = {
86
+ dstr: :String,
87
+ dsym: :Symbol,
88
+ float: :Float,
89
+ int: :Integer,
90
+ regexp: :Regexp,
91
+ str: :String,
92
+ sym: :Symbol,
93
+ }.freeze
94
+
95
+ # Element node types allowed inside an inferable array literal: the
96
+ # literals whose class Sorbet reflects into the array's element type.
97
+ # Regexps and ranges, by contrast, degrade the array to `T.untyped`, so
98
+ # they are excluded. Interpolated strings/symbols (`dstr`/`dsym`) infer
99
+ # as `String`/`Symbol`, the same as their plain literal forms.
100
+ ARRAY_ELEMENT_TYPES = [:dstr, :dsym, :str, :sym, :int, :float, :true, :false, :nil].freeze
101
+
102
+ # Element node types whose inferred class is unambiguous, used to derive
103
+ # the element type of an unfrozen array literal.
104
+ ELEMENT_TYPE_TO_CLASS = {
105
+ dstr: "String",
106
+ dsym: "Symbol",
107
+ float: "Float",
108
+ int: "Integer",
109
+ str: "String",
110
+ sym: "Symbol",
111
+ }.freeze
112
+
113
+ # @!method t_let_with_literal_and_class?(node)
114
+ def_node_matcher :t_let_with_literal_and_class?, <<~PATTERN
115
+ (casgn _ _ (send (const {nil? cbase} :T) :let ${literal? (send (regexp ...) :freeze)} (const nil? $_)))
116
+ PATTERN
117
+
118
+ # @!method t_let_with_array?(node)
119
+ def_node_matcher :t_let_with_array?, <<~PATTERN
120
+ (casgn _ _ (send (const {nil? cbase} :T) :let ${array (send array :freeze)} $_))
121
+ PATTERN
122
+
123
+ def on_casgn(node)
124
+ return unless statically_scoped?(node)
125
+
126
+ t_let_with_literal_and_class?(node) do |value_node, class_name|
127
+ literal_node = inferable_literal_node(value_node)
128
+ next unless literal_node
129
+ next unless LITERAL_TYPE_TO_CLASS[literal_node.type] == class_name
130
+
131
+ register_offense(node, value_node, class_name)
132
+ end
133
+
134
+ return unless enabled_for_sorbet_static_version?
135
+
136
+ t_let_with_array?(node) do |value_node, type_node|
137
+ frozen = value_node.send_type?
138
+ array_node = frozen ? value_node.receiver : value_node
139
+ next unless inferable_array?(array_node)
140
+ next unless redundant_array_annotation?(array_node, type_node, frozen: frozen)
141
+
142
+ register_offense(node, value_node, :Array)
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ # Returns the underlying literal when its inference is supported by the
149
+ # target Sorbet version. Bare literals are always supported; for example,
150
+ # `3` returns its integer node. Frozen literals are version-gated, so
151
+ # `/foo/.freeze` returns its regexp node only for supported targets.
152
+ def inferable_literal_node(value_node)
153
+ return value_node unless value_node.send_type?
154
+ return unless enabled_for_sorbet_static_version?
155
+
156
+ value_node.receiver
157
+ end
158
+
159
+ def register_offense(node, value_node, type)
160
+ t_let_node = node.children[2]
161
+ add_offense(t_let_node, message: format(MSG, type: type)) do |corrector|
162
+ replace_t_let(corrector, t_let_node, value_node)
163
+ end
164
+ end
165
+
166
+ # An array literal is inferable only when every element is one of the
167
+ # simple literals Sorbet reflects into the element type (or a nested
168
+ # inferable array). Empty arrays are excluded: they infer as
169
+ # `T::Array[T.untyped]` and so still need the annotation.
170
+ def inferable_array?(node)
171
+ return false unless node.array_type?
172
+ return false if node.children.empty?
173
+
174
+ node.children.all? do |child|
175
+ if child.array_type?
176
+ inferable_array?(child)
177
+ else
178
+ ARRAY_ELEMENT_TYPES.include?(child.type)
179
+ end
180
+ end
181
+ end
182
+
183
+ # Returns whether Sorbet's inferred array type makes the annotation
184
+ # redundant. A frozen `[:a].freeze` infers as a tuple compatible with
185
+ # any `T::Array[...]`; an unfrozen `[:a]` must infer exactly the
186
+ # annotated type, such as `T::Array[Symbol]`.
187
+ def redundant_array_annotation?(array_node, type_node, frozen:)
188
+ return t_array_type?(type_node) if frozen
189
+
190
+ inferred_array_type(array_node) == normalize(type_node.source).delete_prefix("::")
191
+ end
192
+
193
+ # `T::Array[...]` (with or without a leading `::`)
194
+ def t_array_type?(node)
195
+ node.send_type? && node.method?(:[]) &&
196
+ node.receiver&.source&.delete_prefix("::") == "T::Array"
197
+ end
198
+
199
+ # The type Sorbet infers for an unfrozen array literal, or nil when the
200
+ # element types are not uniform enough to render deterministically
201
+ # (mixed classes, booleans, nils and nested arrays are left alone).
202
+ def inferred_array_type(node)
203
+ classes = node.children.map { |child| ELEMENT_TYPE_TO_CLASS[child.type] }
204
+ return unless classes.all? && classes.uniq.size == 1
205
+
206
+ "T::Array[#{classes.first}]"
207
+ end
208
+
209
+ # Strips whitespace and any trailing comma before a closing delimiter,
210
+ # so a multi-line annotation (`T::Array[\n String,\n]`) still compares
211
+ # equal to the rendered inferred type.
212
+ def normalize(source)
213
+ source.gsub(/\s+/, "").gsub(/,([)\]}])/, '\1')
214
+ end
215
+ end
216
+ end
217
+ end
218
+ end
@@ -23,11 +23,13 @@ module RuboCop
23
23
  # * `ParameterTypePlaceholder`: placeholders used for parameter types (default: 'T.untyped')
24
24
  # * `ReturnTypePlaceholder`: placeholders used for return types (default: 'T.untyped')
25
25
  # * `Style`: signature style to enforce - 'sig' for sig blocks, 'rbs' for RBS comments, 'both' to allow either (default: 'sig')
26
+ # * `AutocorrectStyle`: signature style to use when autocorrecting - 'sig' for sig blocks, 'rbs' for RBS comments (default: 'sig'). Only used when `Style` is 'both'.
26
27
  class EnforceSignatures < ::RuboCop::Cop::Base
27
28
  extend AutoCorrector
28
29
  include SignatureHelp
29
30
 
30
31
  VALID_STYLES = ["sig", "rbs", "both"].freeze
32
+ VALID_AUTOCORRECT_STYLES = ["sig", "rbs"].freeze
31
33
 
32
34
  # @!method accessor?(node)
33
35
  def_node_matcher(:accessor?, <<-PATTERN)
@@ -87,7 +89,7 @@ module RuboCop
87
89
  # Both styles allowed - require at least one
88
90
  unless sig_node || rbs_node
89
91
  add_offense(node, message: "Each method is required to have a signature.") do |corrector|
90
- autocorrect_with_signature_type(corrector, node, "sig")
92
+ autocorrect_with_signature_type(corrector, node, autocorrect_style)
91
93
  end
92
94
  end
93
95
  else # "sig" (default)
@@ -192,6 +194,15 @@ module RuboCop
192
194
  "sig"
193
195
  end
194
196
 
197
+ def autocorrect_style
198
+ config_value = cop_config["AutocorrectStyle"] || "sig"
199
+ unless VALID_AUTOCORRECT_STYLES.include?(config_value)
200
+ raise ArgumentError, "Invalid AutocorrectStyle option: '#{config_value}'. Valid options are: #{VALID_AUTOCORRECT_STYLES.join(", ")}"
201
+ end
202
+
203
+ config_value
204
+ end
205
+
195
206
  class SignatureChecker
196
207
  def initialize(processed_source)
197
208
  @processed_source = processed_source
@@ -3,6 +3,8 @@
3
3
  require_relative "sorbet/mixin/target_sorbet_version"
4
4
  require_relative "sorbet/mixin/t_enum"
5
5
  require_relative "sorbet/mixin/signature_help"
6
+ require_relative "sorbet/mixin/constant_scope"
7
+ require_relative "sorbet/mixin/t_let_correction"
6
8
 
7
9
  require_relative "sorbet/binding_constant_without_type_alias"
8
10
  require_relative "sorbet/block_method_definition"
@@ -27,7 +29,9 @@ require_relative "sorbet/forbid_t_struct"
27
29
  require_relative "sorbet/forbid_t_type_alias"
28
30
  require_relative "sorbet/forbid_t_unsafe"
29
31
  require_relative "sorbet/forbid_t_untyped"
32
+ require_relative "sorbet/redundant_t_let"
30
33
  require_relative "sorbet/redundant_extend_t_sig"
34
+ require_relative "sorbet/redundant_t_let_for_literal"
31
35
  require_relative "sorbet/refinement"
32
36
  require_relative "sorbet/select_by_is_a"
33
37
  require_relative "sorbet/type_alias_name"
data/manual/cops.md CHANGED
@@ -49,6 +49,8 @@ In the following section you find all available cops:
49
49
  * [Sorbet/MultipleTEnumValues](cops_sorbet.md#sorbetmultipletenumvalues)
50
50
  * [Sorbet/ObsoleteStrictMemoization](cops_sorbet.md#sorbetobsoletestrictmemoization)
51
51
  * [Sorbet/RedundantExtendTSig](cops_sorbet.md#sorbetredundantextendtsig)
52
+ * [Sorbet/RedundantTLet](cops_sorbet.md#sorbetredundanttlet)
53
+ * [Sorbet/RedundantTLetForLiteral](cops_sorbet.md#sorbetredundanttletforliteral)
52
54
  * [Sorbet/Refinement](cops_sorbet.md#sorbetrefinement)
53
55
  * [Sorbet/RuntimeOnFailureDependsOnChecked](cops_sorbet.md#sorbetruntimeonfailuredependsonchecked)
54
56
  * [Sorbet/SelectByIsA](cops_sorbet.md#sorbetselectbyisa)
@@ -310,7 +310,7 @@ other comments or magic comments are left in the same place.
310
310
 
311
311
  Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged
312
312
  --- | --- | --- | --- | ---
313
- Disabled | Yes | Yes | 0.3.4 | -
313
+ Disabled | Yes | Yes | 0.3.4 | 0.13.0
314
314
 
315
315
  Checks that every method definition and attribute accessor has a Sorbet signature.
316
316
 
@@ -332,12 +332,14 @@ You can configure the placeholders used by changing the following options:
332
332
  * `ParameterTypePlaceholder`: placeholders used for parameter types (default: 'T.untyped')
333
333
  * `ReturnTypePlaceholder`: placeholders used for return types (default: 'T.untyped')
334
334
  * `Style`: signature style to enforce - 'sig' for sig blocks, 'rbs' for RBS comments, 'both' to allow either (default: 'sig')
335
+ * `AutocorrectStyle`: signature style to use when autocorrecting - 'sig' for sig blocks, 'rbs' for RBS comments (default: 'sig'). Only used when `Style` is 'both'.
335
336
 
336
337
  ### Configurable attributes
337
338
 
338
339
  Name | Default value | Configurable values
339
340
  --- | --- | ---
340
341
  Style | `sig` | String
342
+ AutocorrectStyle | `sig` | String
341
343
 
342
344
  ## Sorbet/EnforceSingleSigil
343
345
 
@@ -1160,6 +1162,126 @@ class Example
1160
1162
  end
1161
1163
  ```
1162
1164
 
1165
+ ## Sorbet/RedundantTLet
1166
+
1167
+ Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged
1168
+ --- | --- | --- | --- | ---
1169
+ Enabled | Yes | Yes (Unsafe) | 0.13.0 | -
1170
+
1171
+ Prevents unnecessary `T.let` where Sorbet infers the type automatically.
1172
+
1173
+ When a signature parameter is assigned to an instance variable in
1174
+ `initialize`, the type is inferred from the signature.
1175
+
1176
+ When a constant is assigned a constructor call (`.new`), optionally
1177
+ followed by `.freeze` (Sorbet 0.6.13304+), the type is inferred from
1178
+ the class being instantiated. Generic classes (e.g. `Set`) are
1179
+ excluded: Sorbet infers their constructor calls as applied types like
1180
+ `T::Set[T.untyped]`, so an annotation is still required.
1181
+
1182
+ ### Examples
1183
+
1184
+ ```ruby
1185
+ # bad
1186
+ sig { params(a: Integer) }
1187
+ def initialize(a)
1188
+ @a = T.let(a, Integer)
1189
+ end
1190
+
1191
+ # good
1192
+ sig { params(a: Integer) }
1193
+ def initialize(a)
1194
+ @a = a
1195
+ end
1196
+
1197
+ # good
1198
+ sig { params(a: Integer) }
1199
+ def initialize(a)
1200
+ @a = T.let(a, T.any(Integer, String))
1201
+ end
1202
+
1203
+ # bad
1204
+ DEFAULT_PATH = T.let(Pathname.new("/usr/local").freeze, Pathname)
1205
+
1206
+ # good
1207
+ DEFAULT_PATH = Pathname.new("/usr/local").freeze
1208
+
1209
+ # good — generic classes are not inferred, so T.let is required
1210
+ LICENSES = T.let(Set.new(["mit"]).freeze, T::Set[String])
1211
+
1212
+ # good — instance variables are only inferred from signature parameters
1213
+ @path = T.let(Pathname.new("/usr/local"), Pathname)
1214
+ ```
1215
+
1216
+ ## Sorbet/RedundantTLetForLiteral
1217
+
1218
+ Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged
1219
+ --- | --- | --- | --- | ---
1220
+ Enabled | Yes | Yes (Unsafe) | 0.13.0 | -
1221
+
1222
+ Checks for redundant `T.let` declarations where the first argument
1223
+ is a literal whose type Sorbet can infer automatically, so wrapping
1224
+ it in `T.let` is redundant.
1225
+
1226
+ Simple literals (strings, symbols, integers, floats, regexps) infer as
1227
+ their own class. Regexp literals are the only simple literals whose
1228
+ inference survives a `.freeze` call (Sorbet 0.6.13304+), so
1229
+ `T.let(/foo/.freeze, Regexp)` is also redundant; other frozen simple
1230
+ literals (e.g. `"hello".freeze`) are not inferred and still need `T.let`.
1231
+
1232
+ Array literals of simple literals are also inferred:
1233
+
1234
+ * A frozen array (`[...].freeze`) infers as a fixed-size tuple, which is
1235
+ a subtype of the annotated `T::Array`, so the annotation is redundant.
1236
+ * An unfrozen array infers as `T::Array[<element type>]`. It is only
1237
+ flagged when that inferred type matches the annotation exactly, to
1238
+ avoid silently widening (e.g. `["a", nil]` infers a nilable element).
1239
+
1240
+ Hashes are excluded: Sorbet infers hash literals as `T.untyped`, so the
1241
+ annotation is required.
1242
+
1243
+ ### Examples
1244
+
1245
+ ```ruby
1246
+ # bad
1247
+ MAX_RETRIES = T.let(3, Integer)
1248
+ GREETING = T.let("hello", String)
1249
+ RATE = T.let(1.5, Float)
1250
+ PATTERN = T.let(/foo/, Regexp)
1251
+ FROZEN_PATTERN = T.let(/foo/.freeze, Regexp)
1252
+ STATUS = T.let(:active, Symbol)
1253
+ SHELLS = T.let([:bash, :zsh].freeze, T::Array[Symbol])
1254
+ NAMES = T.let(["alice", "bob"], T::Array[String])
1255
+
1256
+ # good
1257
+ MAX_RETRIES = 3
1258
+ GREETING = "hello"
1259
+ RATE = 1.5
1260
+ PATTERN = /foo/
1261
+ FROZEN_PATTERN = /foo/.freeze
1262
+ STATUS = :active
1263
+ SHELLS = [:bash, :zsh].freeze
1264
+ NAMES = ["alice", "bob"]
1265
+
1266
+ # good — non-regexp frozen simple literals are not inferred
1267
+ GREETING = T.let("hello".freeze, String)
1268
+
1269
+ # good — hash literals are inferred as T.untyped
1270
+ OPTIONS = T.let({ verbose: true }, T::Hash[Symbol, T::Boolean])
1271
+
1272
+ # good — unfrozen array whose annotation is wider than the inferred type
1273
+ NAMES = T.let(["alice", "bob"], T::Array[T.nilable(String)])
1274
+
1275
+ # good — type is not the literal's own class
1276
+ value = T.let("hello", T.nilable(String))
1277
+
1278
+ # good — instance variables need T.let for Sorbet to track their type
1279
+ @max_retries = T.let(3, Integer)
1280
+
1281
+ # good — local variables may need T.let so Sorbet allows reassignment
1282
+ count = T.let(0, Integer)
1283
+ ```
1284
+
1163
1285
  ## Sorbet/Refinement
1164
1286
 
1165
1287
  Enabled by default | Safe | Supports autocorrection | VersionAdded | VersionChanged
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-sorbet
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ufuk Kayserilioglu
@@ -54,6 +54,7 @@ files:
54
54
  - ".github/workflows/ci.yml"
55
55
  - ".github/workflows/cla.yml"
56
56
  - ".github/workflows/dependabot_automerge.yml"
57
+ - ".github/workflows/release.yml"
57
58
  - ".github/workflows/stale.yml"
58
59
  - ".gitignore"
59
60
  - ".rspec"
@@ -101,8 +102,10 @@ files:
101
102
  - lib/rubocop/cop/sorbet/forbid_type_aliased_shapes.rb
102
103
  - lib/rubocop/cop/sorbet/forbid_untyped_struct_props.rb
103
104
  - lib/rubocop/cop/sorbet/implicit_conversion_method.rb
105
+ - lib/rubocop/cop/sorbet/mixin/constant_scope.rb
104
106
  - lib/rubocop/cop/sorbet/mixin/signature_help.rb
105
107
  - lib/rubocop/cop/sorbet/mixin/t_enum.rb
108
+ - lib/rubocop/cop/sorbet/mixin/t_let_correction.rb
106
109
  - lib/rubocop/cop/sorbet/mixin/target_sorbet_version.rb
107
110
  - lib/rubocop/cop/sorbet/mutable_constant_sorbet_aware_behaviour.rb
108
111
  - lib/rubocop/cop/sorbet/obsolete_strict_memoization.rb
@@ -112,6 +115,8 @@ files:
112
115
  - lib/rubocop/cop/sorbet/rbi_versioning/gem_version_annotation_helper.rb
113
116
  - lib/rubocop/cop/sorbet/rbi_versioning/valid_gem_version_annotations.rb
114
117
  - lib/rubocop/cop/sorbet/redundant_extend_t_sig.rb
118
+ - lib/rubocop/cop/sorbet/redundant_t_let.rb
119
+ - lib/rubocop/cop/sorbet/redundant_t_let_for_literal.rb
115
120
  - lib/rubocop/cop/sorbet/refinement.rb
116
121
  - lib/rubocop/cop/sorbet/select_by_is_a.rb
117
122
  - lib/rubocop/cop/sorbet/sigils/enforce_sigil_order.rb
@@ -170,7 +175,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
170
175
  - !ruby/object:Gem::Version
171
176
  version: '0'
172
177
  requirements: []
173
- rubygems_version: 4.0.6
178
+ rubygems_version: 3.6.7
174
179
  specification_version: 4
175
180
  summary: Automatic Sorbet code style checking tool.
176
181
  test_files: []