rubocop-gusto 10.9.4 → 11.0.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 +4 -4
- data/CHANGELOG.md +19 -0
- data/config/default.yml +17 -0
- data/lib/rubocop/cop/gusto/described_class_constant_reference.rb +139 -0
- data/lib/rubocop/cop/gusto/discouraged_gem.rb +1 -1
- data/lib/rubocop/cop/gusto/unreferenced_let.rb +283 -0
- data/lib/rubocop/cop/rack/lowercase_header_keys.rb +2 -2
- data/lib/rubocop/gusto/version.rb +1 -1
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b642214b2077302ad9ab6e076c66534fe49cfcda33a411b87b9c4b34df8f8b43
|
|
4
|
+
data.tar.gz: d9a653b4e27f33467921f5a2ffb520e2cc8b3a65f58e7cb4bb888611cea3e69d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 876ba0b9f58929c28d77c71a370833670b27e0d55ecf8ab937f2882824db8b0da4ba2294a0649f2e54fdf7687b393d6c79be86da874a38d555b4190291c8a36b
|
|
7
|
+
data.tar.gz: fe18184e9f77db188cbbdc77a5274fbe624d664e73ec97a2b5eec130b3753dd6323e5192e98441fe7832d166e2c8980e0d6231494f4d54b0317a9cb18aae9ee5
|
data/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
- Remove redundant `Rails: Enabled: true` from `config/rails.yml` (already set by rubocop-rails' own defaults)
|
|
4
4
|
- Enable `Rails/DefaultScope` cop (disabled by default in rubocop-rails)
|
|
5
5
|
|
|
6
|
+
## [11.0.0](https://github.com/Gusto/rubocop-gusto/compare/v10.10.0...v11.0.0) (2026-06-08)
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### ⚠ BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
* rubocop-gusto now requires Ruby >= 3.4.
|
|
12
|
+
|
|
13
|
+
### Features
|
|
14
|
+
|
|
15
|
+
* add Gusto/DescribedClassConstantReference cop ([#127](https://github.com/Gusto/rubocop-gusto/issues/127)) ([f7cf636](https://github.com/Gusto/rubocop-gusto/commit/f7cf6362f3f522f322251da0fdf5c8affa35bc0f))
|
|
16
|
+
* add Gusto/UnreferencedLet cop (requires Ruby >= 3.4) ([#128](https://github.com/Gusto/rubocop-gusto/issues/128)) ([99a2df7](https://github.com/Gusto/rubocop-gusto/commit/99a2df761b52ce11f4f6bf65a5c8e414153efa53))
|
|
17
|
+
|
|
18
|
+
## [10.10.0](https://github.com/Gusto/rubocop-gusto/compare/v10.9.4...v10.10.0) (2026-06-01)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
### Features
|
|
22
|
+
|
|
23
|
+
* Set EnforcedShorthandSyntax to 'always' ([#116](https://github.com/Gusto/rubocop-gusto/issues/116)) ([650a8aa](https://github.com/Gusto/rubocop-gusto/commit/650a8aa26ee5af6d7558976dce6df18f053e1925))
|
|
24
|
+
|
|
6
25
|
## [10.9.4](https://github.com/Gusto/rubocop-gusto/compare/v10.9.3...v10.9.4) (2026-06-01)
|
|
7
26
|
|
|
8
27
|
|
data/config/default.yml
CHANGED
|
@@ -42,6 +42,13 @@ Gusto/DatadogConstant:
|
|
|
42
42
|
- '**/spec/**/*'
|
|
43
43
|
Description: 'Do not call Datadog directly, use an appropriate wrapper library.'
|
|
44
44
|
|
|
45
|
+
Gusto/DescribedClassConstantReference:
|
|
46
|
+
Description: 'Flags constants scoped through `described_class` (e.g. `described_class::Foo`), which Sorbet cannot resolve statically.'
|
|
47
|
+
Enabled: true
|
|
48
|
+
SafeAutoCorrect: false
|
|
49
|
+
Include:
|
|
50
|
+
- '**/spec/**/*'
|
|
51
|
+
|
|
45
52
|
Gusto/DiscouragedGem:
|
|
46
53
|
Description: 'Flags installation of discouraged gems in Gemfiles and gemspecs.'
|
|
47
54
|
Enabled: false
|
|
@@ -136,6 +143,15 @@ Gusto/ToplevelConstants:
|
|
|
136
143
|
- '**/*/spec_helper.rb'
|
|
137
144
|
- 'spec/support/**/*.rb'
|
|
138
145
|
|
|
146
|
+
Gusto/UnreferencedLet:
|
|
147
|
+
Description: 'Removes a lazy let whose name is never referenced (its block never runs).'
|
|
148
|
+
Enabled: true
|
|
149
|
+
Include:
|
|
150
|
+
- '**/spec/**/*_spec.rb'
|
|
151
|
+
# Deletion is unsafe (explicit -A required, never applied on a plain run): the cop is heuristic
|
|
152
|
+
# and cannot see references made across files via shared examples or included harnesses.
|
|
153
|
+
SafeAutoCorrect: false
|
|
154
|
+
|
|
139
155
|
Gusto/UsePaintNotColorize:
|
|
140
156
|
Description: 'Use Paint instead of colorize for terminal colors.'
|
|
141
157
|
SafeAutoCorrect: false
|
|
@@ -462,6 +478,7 @@ Style/GuardClause:
|
|
|
462
478
|
|
|
463
479
|
Style/HashSyntax:
|
|
464
480
|
EnforcedStyle: ruby19_no_mixed_keys
|
|
481
|
+
EnforcedShorthandSyntax: always
|
|
465
482
|
|
|
466
483
|
Style/HashTransformKeys:
|
|
467
484
|
Enabled: false
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module Gusto
|
|
6
|
+
# Flags constants that are scoped through `described_class`, e.g.
|
|
7
|
+
# `described_class::Worker`.
|
|
8
|
+
#
|
|
9
|
+
# `described_class` is an RSpec helper method resolved at runtime, so
|
|
10
|
+
# Sorbet's static analysis treats `described_class::Worker` as a dynamic
|
|
11
|
+
# constant reference and cannot resolve it (`Dynamic constant references
|
|
12
|
+
# are unsupported`, https://srb.help/5001). Reference the constant by its
|
|
13
|
+
# fully-qualified name instead. A bare `described_class` (with no `::`
|
|
14
|
+
# constant lookup) is an ordinary method call and is left alone.
|
|
15
|
+
#
|
|
16
|
+
# Autocorrection replaces `described_class` with the constant that the
|
|
17
|
+
# enclosing example group describes. It is marked unsafe
|
|
18
|
+
# (`SafeAutoCorrect: false`) because the rewrite relies on the described
|
|
19
|
+
# constant being a statically-written name; review the result before
|
|
20
|
+
# committing. In particular, a constant defined on an *ancestor* of the
|
|
21
|
+
# described class is qualified against the described class itself, which
|
|
22
|
+
# is correct at runtime but which Sorbet cannot resolve through the
|
|
23
|
+
# inheritance chain -- re-point those to the defining ancestor by hand.
|
|
24
|
+
#
|
|
25
|
+
# @example
|
|
26
|
+
# # bad
|
|
27
|
+
# RSpec.describe Payments::Processor do
|
|
28
|
+
# describe described_class::Worker do
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# # good
|
|
33
|
+
# RSpec.describe Payments::Processor do
|
|
34
|
+
# describe Payments::Processor::Worker do
|
|
35
|
+
# end
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# # good - `RSpec.describe self` resolves to the enclosing namespace
|
|
39
|
+
# module Payments
|
|
40
|
+
# RSpec.describe self do
|
|
41
|
+
# it { expect(Payments::TIMEOUT).to eq(5) }
|
|
42
|
+
# end
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# # good - a bare `described_class` is not a constant reference
|
|
46
|
+
# RSpec.describe Payments::Processor do
|
|
47
|
+
# subject { described_class.new }
|
|
48
|
+
# end
|
|
49
|
+
class DescribedClassConstantReference < Base
|
|
50
|
+
extend AutoCorrector
|
|
51
|
+
|
|
52
|
+
MSG = "Use the fully-qualified constant name instead of scoping it through " \
|
|
53
|
+
"`described_class`, which Sorbet cannot resolve statically."
|
|
54
|
+
|
|
55
|
+
# A constant whose scope is a no-receiver `described_class`, e.g.
|
|
56
|
+
# `described_class::Worker`.
|
|
57
|
+
# @!method const_scoped_on_described_class?(node)
|
|
58
|
+
def_node_matcher :const_scoped_on_described_class?, <<~PATTERN
|
|
59
|
+
(const (send nil? :described_class) _)
|
|
60
|
+
PATTERN
|
|
61
|
+
|
|
62
|
+
# An example group, capturing its first argument: a constant
|
|
63
|
+
# (`RSpec.describe Foo do`, `context Foo do`), `self`
|
|
64
|
+
# (`RSpec.describe self do`), and so on.
|
|
65
|
+
# @!method example_group_described_argument(node)
|
|
66
|
+
def_node_matcher :example_group_described_argument, <<~PATTERN
|
|
67
|
+
(block
|
|
68
|
+
(send {(const nil? :RSpec) nil?}
|
|
69
|
+
{:describe :xdescribe :fdescribe :context :xcontext :fcontext :feature :example_group}
|
|
70
|
+
$_ ...)
|
|
71
|
+
...)
|
|
72
|
+
PATTERN
|
|
73
|
+
|
|
74
|
+
# Whether a node routes through a no-receiver `described_class`.
|
|
75
|
+
# @!method scoped_through_described_class?(node)
|
|
76
|
+
def_node_search :scoped_through_described_class?, <<~PATTERN
|
|
77
|
+
(send nil? :described_class)
|
|
78
|
+
PATTERN
|
|
79
|
+
|
|
80
|
+
def on_const(node)
|
|
81
|
+
return unless const_scoped_on_described_class?(node)
|
|
82
|
+
|
|
83
|
+
scope = node.children[0]
|
|
84
|
+
add_offense(scope) do |corrector|
|
|
85
|
+
replacement = described_class_replacement(node)
|
|
86
|
+
corrector.replace(scope, replacement) if replacement
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
# The fully-qualified name (as a String) that `described_class` resolves
|
|
93
|
+
# to lexically, from the nearest enclosing example group, or nil if it
|
|
94
|
+
# cannot be determined statically.
|
|
95
|
+
#
|
|
96
|
+
# - `describe SomeClass` resolves to that constant's written name.
|
|
97
|
+
# - `describe self` resolves to the enclosing module/class namespace.
|
|
98
|
+
# - `describe described_class::X` qualifies the describe argument itself
|
|
99
|
+
# against the outer group; a reference in such a group's *body* resolves
|
|
100
|
+
# at runtime to the scoped (statically unknown) class, so we decline to
|
|
101
|
+
# autocorrect it. Once the enclosing `described_class::X` is rewritten,
|
|
102
|
+
# a later pass resolves the body reference correctly.
|
|
103
|
+
# - Any other describe argument (e.g. a string) is skipped, and the
|
|
104
|
+
# search continues at the next enclosing example group.
|
|
105
|
+
def described_class_replacement(node)
|
|
106
|
+
node.each_ancestor(:block) do |block_node|
|
|
107
|
+
described_argument = example_group_described_argument(block_node)
|
|
108
|
+
next if described_argument.nil?
|
|
109
|
+
|
|
110
|
+
if described_argument.self_type?
|
|
111
|
+
namespace = enclosing_namespace(block_node)
|
|
112
|
+
return namespace if namespace
|
|
113
|
+
elsif described_argument.const_type?
|
|
114
|
+
return described_argument.source unless scoped_through_described_class?(described_argument)
|
|
115
|
+
return nil unless reference_within_described_constant?(described_argument, node)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The fully-qualified name of the module/class lexically enclosing the
|
|
122
|
+
# example group, which is what `self` refers to in `RSpec.describe self`.
|
|
123
|
+
def enclosing_namespace(block_node)
|
|
124
|
+
names = block_node.each_ancestor(:class, :module).map { |mod| mod.children.first.source }
|
|
125
|
+
return if names.empty?
|
|
126
|
+
|
|
127
|
+
names.reverse.join("::")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Whether the offending constant is the described constant itself (the
|
|
131
|
+
# describe argument) rather than a reference inside the group's body.
|
|
132
|
+
def reference_within_described_constant?(described_constant, node)
|
|
133
|
+
node.equal?(described_constant) ||
|
|
134
|
+
described_constant.each_descendant(:const).any? { |const_node| const_node.equal?(node) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module RuboCop
|
|
6
|
+
module Cop
|
|
7
|
+
module Gusto
|
|
8
|
+
# Flags lazy `let` declarations whose name is never referenced. A lazy `let(:name) { ... }`
|
|
9
|
+
# is only evaluated when `name` is called, so an unreferenced one is dead code -- its block
|
|
10
|
+
# never runs -- and is deleted.
|
|
11
|
+
#
|
|
12
|
+
# Eager `let!` is intentionally out of scope: it runs its block before every example for its
|
|
13
|
+
# side effect even when unreferenced, so it cannot simply be deleted. Only plain `let` is
|
|
14
|
+
# handled here.
|
|
15
|
+
#
|
|
16
|
+
# Detection is file-scoped: a `let` referenced only from another file (through a shared
|
|
17
|
+
# example or an included test harness) cannot be seen, so the cop stays conservative and
|
|
18
|
+
# prefers false negatives over false positives:
|
|
19
|
+
# - a name defined more than once in the file by `let`/`let!`/`subject` (an override /
|
|
20
|
+
# `super` chain, including a `subject` that overrides a `let` of the same name) is never
|
|
21
|
+
# flagged;
|
|
22
|
+
# - a `let` declared lexically inside a `shared_examples` / `shared_examples_for` /
|
|
23
|
+
# `shared_context` block is skipped (its consumers live in other files);
|
|
24
|
+
# - every `let` in a file that uses `it_behaves_like` / `it_should_behave_like` /
|
|
25
|
+
# `include_examples` / `include_context` is skipped, because an included shared block may
|
|
26
|
+
# reference the binding by a name we cannot follow statically;
|
|
27
|
+
# - any `let` whose name is also defined as a `let`/`subject` in a `spec/support/**` helper is
|
|
28
|
+
# skipped, because it is almost certainly overriding a contract an included harness consumes;
|
|
29
|
+
# - `let(:cop_config)` is skipped: it is a rubocop-rspec contract consumed by the `:config`
|
|
30
|
+
# shared context, not by a reference in the spec file; and
|
|
31
|
+
# - every `let` in a file that reflectively dispatches through a name we cannot resolve
|
|
32
|
+
# statically (e.g. `send("expected_#{type}")`) is skipped, since any `let` could be the
|
|
33
|
+
# target.
|
|
34
|
+
# A name counts as referenced if it is called bare (`foo`), appears as a symbol (`:foo`)
|
|
35
|
+
# anywhere but the let's own name argument, or appears as an identifier-shaped token inside
|
|
36
|
+
# any string/heredoc literal -- covering dynamic dispatch, `:foo` entries in data tables the
|
|
37
|
+
# spec later dispatches on, and bindings named only inside raw SQL/GraphQL text.
|
|
38
|
+
#
|
|
39
|
+
# Because a bare `:foo` symbol anywhere counts as a reference, commonly-named lets
|
|
40
|
+
# (`let(:user)`, `let(:company)`, `let(:id)`) are essentially never flagged -- `create(:user)`,
|
|
41
|
+
# `:name` hash keys, and the like saturate the file. This conservative bias means the cop
|
|
42
|
+
# realistically only deletes distinctively-named dead lets; it is not a complete dead-`let`
|
|
43
|
+
# finder.
|
|
44
|
+
#
|
|
45
|
+
# @example
|
|
46
|
+
# # bad (name never referenced -- deleted, the block never runs)
|
|
47
|
+
# let(:unused) { create(:thing) }
|
|
48
|
+
#
|
|
49
|
+
# # good
|
|
50
|
+
# let(:thing) { create(:thing) }
|
|
51
|
+
# it { expect(thing).to be_present }
|
|
52
|
+
#
|
|
53
|
+
class UnreferencedLet < ::RuboCop::Cop::RSpec::Base
|
|
54
|
+
extend AutoCorrector
|
|
55
|
+
include RangeHelp
|
|
56
|
+
|
|
57
|
+
DEFINITION_METHODS = %i(let let! subject).freeze
|
|
58
|
+
# `let`s consumed by a test framework rather than by a reference in the spec file. The
|
|
59
|
+
# rubocop-rspec `:config` shared context reads `cop_config`, so it is live even though the
|
|
60
|
+
# spec never names it.
|
|
61
|
+
FRAMEWORK_RESERVED_NAMES = %i(cop_config).freeze
|
|
62
|
+
# Reflective dispatch methods whose target is the first argument. When that argument is not
|
|
63
|
+
# a statically-resolvable name (a `sym` or plain `str`) -- e.g. `send("expected_#{type}")` --
|
|
64
|
+
# the called name cannot be known, so the whole file is left untouched.
|
|
65
|
+
DYNAMIC_DISPATCH_METHODS = %i(send public_send __send__ try try! method public_method respond_to?).freeze
|
|
66
|
+
FRAMEWORK_LET_PATTERN = /\b(?:let!?|subject)\s*\(?\s*:([A-Za-z_]\w*[!?]?)/
|
|
67
|
+
# Identifier-shaped tokens inside a string/heredoc literal. A `let` whose name appears only
|
|
68
|
+
# inside string text -- e.g. a binding or column referenced in raw SQL/GraphQL the spec
|
|
69
|
+
# later executes -- counts as referenced, so it is not deleted.
|
|
70
|
+
IDENTIFIER_IN_STRING = /[A-Za-z_]\w*[!?]?/
|
|
71
|
+
MSG = "Remove unreferenced `let(:%{name})` -- its name is never used, so the block never runs."
|
|
72
|
+
RESTRICT_ON_SEND = %i(let).freeze
|
|
73
|
+
# The glob and the pathspec encode the SAME set of files two ways: `Dir.glob` (fallback) and
|
|
74
|
+
# a regexp filter over `git ls-files` output. Keep them in sync if either changes.
|
|
75
|
+
SUPPORT_FILES_GLOB = "**/spec/support/**/*.rb"
|
|
76
|
+
SUPPORT_FILES_PATHSPEC = %r{(?:\A|/)spec/support/.+\.rb\z}
|
|
77
|
+
|
|
78
|
+
# The name symbol of any definition (`let`/`let!`/`subject`) in any block form -- used to
|
|
79
|
+
# count how many times a name is defined, so override / `super` chains (including a
|
|
80
|
+
# `subject` that overrides a `let` of the same name) are never flagged.
|
|
81
|
+
# @!method definition_name(node)
|
|
82
|
+
def_node_matcher :definition_name, <<~PATTERN
|
|
83
|
+
(any_block (send nil? {#{DEFINITION_METHODS.map { ":#{it}" }.join(' ')}} (sym $_) ...) ...)
|
|
84
|
+
PATTERN
|
|
85
|
+
|
|
86
|
+
class << self
|
|
87
|
+
# Names defined as `let`/`subject` anywhere under `spec/support/**`. Computed once per
|
|
88
|
+
# process (lazily, after boot) and shared across every file the cop inspects.
|
|
89
|
+
def framework_let_names
|
|
90
|
+
@framework_let_names ||= scan_framework_let_names(support_file_paths)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Enumerate `spec/support/**/*.rb`. Prefer `git ls-files` (reads the git index, skipping
|
|
94
|
+
# untracked trees like `node_modules`): a leading-`**` `Dir.glob` walks the entire
|
|
95
|
+
# repository and costs seconds, while reading the index costs tens of milliseconds. Fall
|
|
96
|
+
# back to `Dir.glob` when not in a git work tree or `git` is unavailable.
|
|
97
|
+
#
|
|
98
|
+
# Tradeoff: an untracked (brand-new, uncommitted) `spec/support/*.rb` override is invisible
|
|
99
|
+
# to `git ls-files`. In that narrow window its contract names are not exempted; once
|
|
100
|
+
# committed it is seen like any other support file.
|
|
101
|
+
def support_file_paths
|
|
102
|
+
git_tracked_support_files || ::Dir.glob(SUPPORT_FILES_GLOB)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def git_tracked_support_files
|
|
106
|
+
output, status = ::Open3.capture2("git", "ls-files", "-z")
|
|
107
|
+
return nil unless status.success?
|
|
108
|
+
|
|
109
|
+
output.split("\x0").grep(SUPPORT_FILES_PATHSPEC)
|
|
110
|
+
rescue ::SystemCallError
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def scan_framework_let_names(paths)
|
|
115
|
+
paths.each_with_object(Set.new) do |path, names|
|
|
116
|
+
extract_let_names(read_source(path), names)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def extract_let_names(source, names)
|
|
121
|
+
source.scan(FRAMEWORK_LET_PATTERN) { |(captured)| names << captured.to_sym }
|
|
122
|
+
names
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def read_source(path)
|
|
126
|
+
return "" unless ::File.file?(path)
|
|
127
|
+
|
|
128
|
+
::File.read(path)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def on_send(node)
|
|
133
|
+
return unless node.receiver.nil?
|
|
134
|
+
|
|
135
|
+
name_argument = node.first_argument
|
|
136
|
+
return unless name_argument&.sym_type?
|
|
137
|
+
|
|
138
|
+
block = node.block_node
|
|
139
|
+
return unless block
|
|
140
|
+
|
|
141
|
+
name = name_argument.value
|
|
142
|
+
return if exempt_from_deletion?(name, block)
|
|
143
|
+
|
|
144
|
+
add_offense(node.loc.selector, message: format(MSG, name:)) do |corrector|
|
|
145
|
+
corrector.remove(removal_range(block))
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
# A lazy `let` is exempt from deletion whenever file-scoped analysis cannot prove its name
|
|
152
|
+
# is dead: its name is a framework-reserved contract (e.g. `cop_config`), the file
|
|
153
|
+
# dispatches through a name we cannot resolve statically, it consumes shared examples, the
|
|
154
|
+
# `let` is lexically inside a shared-example definition, its name is a `spec/support/**`
|
|
155
|
+
# framework contract, it is overridden by another definition of the same name, or it is
|
|
156
|
+
# referenced somewhere in the file.
|
|
157
|
+
def exempt_from_deletion?(name, block)
|
|
158
|
+
FRAMEWORK_RESERVED_NAMES.include?(name) ||
|
|
159
|
+
dynamic_dispatch? ||
|
|
160
|
+
consumes_shared_examples? ||
|
|
161
|
+
within_shared_definition?(block) ||
|
|
162
|
+
self.class.framework_let_names.include?(name) ||
|
|
163
|
+
overridden?(name) ||
|
|
164
|
+
referenced?(name)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Delete the `let` block, plus:
|
|
168
|
+
# - an immediately-preceding `sig { ... }` (so a Sorbet signature is not left dangling),
|
|
169
|
+
# - explanatory comment lines attached directly above it (so they are not orphaned), and
|
|
170
|
+
# - a single trailing blank line where removal would otherwise leave a stray/duplicate
|
|
171
|
+
# blank -- unless the line above is a `let`/`subject`, where that blank is the required
|
|
172
|
+
# separator after the now-final let and must stay.
|
|
173
|
+
def removal_range(node)
|
|
174
|
+
lines = processed_source.lines
|
|
175
|
+
start_line = node.source_range.first_line
|
|
176
|
+
end_line = node.source_range.last_line
|
|
177
|
+
|
|
178
|
+
sig = preceding_sig(node)
|
|
179
|
+
start_line = sig.source_range.first_line if sig
|
|
180
|
+
|
|
181
|
+
start_line -= 1 while start_line > 1 && absorbable_comment?(lines[start_line - 2])
|
|
182
|
+
|
|
183
|
+
if end_line < lines.size && blank_line?(lines[end_line]) &&
|
|
184
|
+
!(start_line > 1 && let_or_subject_line?(lines[start_line - 2]))
|
|
185
|
+
end_line += 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
buffer = processed_source.buffer
|
|
189
|
+
range_by_whole_lines(buffer.line_range(start_line).join(buffer.line_range(end_line)), include_final_newline: true)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def absorbable_comment?(source_line)
|
|
193
|
+
stripped = source_line.strip
|
|
194
|
+
stripped.start_with?("#") && !stripped.start_with?("# rubocop:")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def blank_line?(source_line)
|
|
198
|
+
source_line.strip.empty?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def let_or_subject_line?(source_line)
|
|
202
|
+
source_line.match?(/\A\s*(?:let!?|subject)\b/)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def preceding_sig(node)
|
|
206
|
+
sibling = node.left_sibling
|
|
207
|
+
return unless sibling.is_a?(::RuboCop::AST::BlockNode)
|
|
208
|
+
return unless sibling.method?(:sig)
|
|
209
|
+
|
|
210
|
+
sibling
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def within_shared_definition?(node)
|
|
214
|
+
node.each_ancestor(:any_block).any? { |ancestor| shared_group?(ancestor) }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def consumes_shared_examples?
|
|
218
|
+
return @consumes_shared_examples unless @consumes_shared_examples.nil?
|
|
219
|
+
|
|
220
|
+
@consumes_shared_examples = processed_source.ast.each_node(:call).any? { |send_node| include?(send_node) }
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# True when the file reflectively dispatches through a name we cannot resolve statically --
|
|
224
|
+
# `send`/`public_send`/`method`/etc. called with anything other than a `sym` or plain `str`
|
|
225
|
+
# first argument (most commonly an interpolated string, `send("expected_#{type}")`). In
|
|
226
|
+
# that case any `let` in the file could be the dispatch target, so none are deleted.
|
|
227
|
+
def dynamic_dispatch?
|
|
228
|
+
return @dynamic_dispatch unless @dynamic_dispatch.nil?
|
|
229
|
+
|
|
230
|
+
@dynamic_dispatch = processed_source.ast.each_node(:call).any? do |send_node|
|
|
231
|
+
next false unless DYNAMIC_DISPATCH_METHODS.include?(send_node.method_name)
|
|
232
|
+
|
|
233
|
+
target = send_node.first_argument
|
|
234
|
+
target && !target.sym_type? && !target.str_type?
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def overridden?(name)
|
|
239
|
+
definitions_by_name.fetch(name, 0) > 1
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def definitions_by_name
|
|
243
|
+
@definitions_by_name ||= processed_source.ast.each_node(:any_block).each_with_object(Hash.new(0)) do |node, counts|
|
|
244
|
+
name = definition_name(node)
|
|
245
|
+
counts[name] += 1 if name
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def referenced?(name)
|
|
250
|
+
referenced_names.include?(name)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# A name is "referenced" if it is called as a bare method (`foo`), appears as a symbol
|
|
254
|
+
# literal (`:foo`) other than the let/subject's own name argument, or appears as an
|
|
255
|
+
# identifier-shaped token inside any string/heredoc literal. The symbol and string cases
|
|
256
|
+
# cover indirect invocation -- `send(:foo)` / `send("foo")`, a `:foo`/`"foo"` listed in a
|
|
257
|
+
# data table the spec later dispatches on, or a binding named only inside raw SQL/GraphQL
|
|
258
|
+
# text the spec executes -- which file-scoped analysis cannot otherwise follow. (Tokenizing
|
|
259
|
+
# string bodies, rather than matching the whole string, keeps a `let` referenced only from
|
|
260
|
+
# inside a multi-word heredoc from being deleted.) Interpolated-string *dispatch* is handled
|
|
261
|
+
# separately by `dynamic_dispatch?`, which exempts the whole file.
|
|
262
|
+
def referenced_names
|
|
263
|
+
@referenced_names ||= processed_source.ast.each_node(:sym, :str, :call).each_with_object(Set.new) do |node, names|
|
|
264
|
+
if node.sym_type?
|
|
265
|
+
names << node.value unless definition_name_argument?(node)
|
|
266
|
+
elsif node.str_type?
|
|
267
|
+
# A string with invalid encoding (e.g. a deliberate bad-UTF-8 test fixture) cannot
|
|
268
|
+
# contain an identifier-shaped reference and would raise on `scan`, so skip it.
|
|
269
|
+
node.value.scan(IDENTIFIER_IN_STRING) { |token| names << token.to_sym } if node.value.valid_encoding?
|
|
270
|
+
elsif node.receiver.nil? && node.arguments.empty?
|
|
271
|
+
names << node.method_name
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def definition_name_argument?(sym_node)
|
|
277
|
+
parent = sym_node.parent
|
|
278
|
+
parent.send_type? && parent.receiver.nil? && DEFINITION_METHODS.include?(parent.method_name)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
end
|
|
@@ -122,9 +122,9 @@ module RuboCop
|
|
|
122
122
|
|
|
123
123
|
def add_offense_for_header(node, key_value)
|
|
124
124
|
downcased = key_value.downcase
|
|
125
|
-
message = format(MSG, downcased
|
|
125
|
+
message = format(MSG, downcased:, original: key_value)
|
|
126
126
|
|
|
127
|
-
add_offense(node, message:
|
|
127
|
+
add_offense(node, message:) do |corrector|
|
|
128
128
|
corrector.replace(node, "'#{downcased}'")
|
|
129
129
|
end
|
|
130
130
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-gusto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 11.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gusto Engineering
|
|
@@ -125,6 +125,7 @@ files:
|
|
|
125
125
|
- lib/rubocop-gusto.rb
|
|
126
126
|
- lib/rubocop/cop/gusto/bootsnap_load_file.rb
|
|
127
127
|
- lib/rubocop/cop/gusto/datadog_constant.rb
|
|
128
|
+
- lib/rubocop/cop/gusto/described_class_constant_reference.rb
|
|
128
129
|
- lib/rubocop/cop/gusto/discouraged_gem.rb
|
|
129
130
|
- lib/rubocop/cop/gusto/execute_migration.rb
|
|
130
131
|
- lib/rubocop/cop/gusto/factory_classes_or_modules.rb
|
|
@@ -143,6 +144,7 @@ files:
|
|
|
143
144
|
- lib/rubocop/cop/gusto/rspec_date_time_mock.rb
|
|
144
145
|
- lib/rubocop/cop/gusto/sidekiq_params.rb
|
|
145
146
|
- lib/rubocop/cop/gusto/toplevel_constants.rb
|
|
147
|
+
- lib/rubocop/cop/gusto/unreferenced_let.rb
|
|
146
148
|
- lib/rubocop/cop/gusto/use_paint_not_colorize.rb
|
|
147
149
|
- lib/rubocop/cop/gusto/vcr_recordings.rb
|
|
148
150
|
- lib/rubocop/cop/internal_affairs/assignment_first.rb
|
|
@@ -169,7 +171,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
169
171
|
requirements:
|
|
170
172
|
- - ">="
|
|
171
173
|
- !ruby/object:Gem::Version
|
|
172
|
-
version: '3.
|
|
174
|
+
version: '3.4'
|
|
173
175
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
174
176
|
requirements:
|
|
175
177
|
- - ">="
|