rubocop-fourshark 0.8.3 → 0.8.5
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/README.md +4 -6
- data/config/default.yml +2 -2
- data/lib/rubocop/cop/rails/bidirectional_association.rb +1 -1
- data/lib/rubocop/cop/rspec/inverse_of_matcher.rb +1 -1
- data/lib/rubocop/cop/style/disallow_delegate.rb +101 -32
- data/lib/rubocop/fourshark/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0b6d31dcd7f72bdcf6f8cc44ca8315d8fd4388c54352be392fcf3b13160ed690
|
|
4
|
+
data.tar.gz: d399855a3ce34a564f243a741d34fc76c368cfeb2922f0c4f41f7d8020d0b89e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cb034ef690e144cbb51853a88e987059f697a4c434317e66da75cc6aee3002074aa7501ed2642382582feb1f4de3906bf05fd1b005cf02fd4df36255bfa7ce65
|
|
7
|
+
data.tar.gz: ece127b0287704f962886699502445783f2ad3eac3354aa9628bd62dd30f3a97710ea37a98db39e173d16c202088747af1a8ca0acfa1d6e992221d7639d09864
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
## [0.8.5] - 2026-08-07
|
|
2
|
+
|
|
3
|
+
### Changed
|
|
4
|
+
|
|
5
|
+
- Convention rationale in the README
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `Style/DisallowDelegate` — a macro aimed at the object's own class is no longer flagged
|
|
10
|
+
|
|
11
|
+
## [0.8.4] - 2026-08-07
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- `Style/DisallowDelegate` — own state reached through a call on it is no longer flagged
|
|
16
|
+
- `Style/DisallowDelegate` — an argument rooted at the collaborator being forwarded to is flagged again
|
|
17
|
+
- `Style/DisallowDelegate` — own state read through an explicit `self` is no longer flagged
|
|
18
|
+
- `Style/DisallowDelegate` — a call reached through safe navigation counts as the call it is, on both the receiver and the argument side
|
|
19
|
+
|
|
1
20
|
## [0.8.3] - 2026-08-06
|
|
2
21
|
|
|
3
22
|
### Fixed
|
data/README.md
CHANGED
|
@@ -118,20 +118,18 @@ Where a 4Shark cop supersedes or contradicts a stock cop, `config/default.yml` t
|
|
|
118
118
|
|---|---|
|
|
119
119
|
| `FactoryBot/AssociationInFactory` | No associations declared inside a factory — they trigger cascading object creation and callbacks. Set the association manually in the spec. Scoped to `spec/factories`. |
|
|
120
120
|
|
|
121
|
-
|
|
121
|
+
This README states the intent each cop enforces. One convention deviates from a safe Rails default and is worth spelling out — see below.
|
|
122
122
|
|
|
123
123
|
### Why `belongs_to` is `optional: true` by default
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
By the time an association is assigned, existence and ownership have already been verified — more strictly than Rails would. Rails' default `belongs_to` (`optional: false`) then adds an existence `SELECT` per record on top of that: redundant work, and at high API throughput a measurable per-request cost.
|
|
125
|
+
Rails' default `belongs_to` (`optional: false`) validates that the associated record exists, which costs an existence `SELECT` per record. That validation is redundant in an application whose own request handling already establishes, before the association is assigned, that the record exists and is reachable by the caller — and under high throughput the redundant query is a measurable per-request cost.
|
|
128
126
|
|
|
129
127
|
So the convention is:
|
|
130
128
|
|
|
131
129
|
- `belongs_to` is always declared `optional: true` (enforced by `Rails/OptionalBelongsTo`), turning off Rails' automatic existence validation.
|
|
132
130
|
- Presence is validated manually with `validates :x_id, presence: true` **where the business rule requires it** — case by case, not globally (which is why no cop enforces the presence side).
|
|
133
131
|
|
|
134
|
-
A deliberate performance trade-off
|
|
132
|
+
A deliberate performance trade-off — not an omission.
|
|
135
133
|
|
|
136
134
|
## Development
|
|
137
135
|
|
|
@@ -147,7 +145,7 @@ Each new cop ships with a spec (`expect_offense` / `expect_no_offenses`) and a `
|
|
|
147
145
|
|
|
148
146
|
## Releasing
|
|
149
147
|
|
|
150
|
-
|
|
148
|
+
Releases are cut from `main`: feature branches merge into `main` via PR, the version is bumped, a `vX.Y.Z` tag is created from `main`, and the gem is published to RubyGems.
|
|
151
149
|
|
|
152
150
|
## License
|
|
153
151
|
|
data/config/default.yml
CHANGED
|
@@ -16,8 +16,8 @@ Layout/MultilineMethodCallIndentation:
|
|
|
16
16
|
|
|
17
17
|
# Stock cop configured (not a 4Shark cop): name the rescued exception variable
|
|
18
18
|
# `exception`. The cop's default is `e`, and a single-letter variable is banned by
|
|
19
|
-
# the 4Shark naming convention — so the two disagree
|
|
20
|
-
#
|
|
19
|
+
# the 4Shark naming convention — so the two disagree wherever a rescue binds its
|
|
20
|
+
# exception. `PreferredName` is the option the cop ships to settle exactly that,
|
|
21
21
|
# and the cop autocorrects, so the rename is mechanical.
|
|
22
22
|
Naming/RescuedExceptionsVariableName:
|
|
23
23
|
PreferredName: exception
|
|
@@ -127,7 +127,7 @@ module RuboCop
|
|
|
127
127
|
match && match[1]
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
-
# "UserAccount" → "user_account"; "
|
|
130
|
+
# "UserAccount" → "user_account"; "Order::LineItem" → "order/line_item"
|
|
131
131
|
def camel_to_snake(name)
|
|
132
132
|
name.gsub('::', '/')
|
|
133
133
|
.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2')
|
|
@@ -22,6 +22,13 @@ module RuboCop
|
|
|
22
22
|
# method answers for a domain it owns, so it is not delegation and is not
|
|
23
23
|
# flagged.
|
|
24
24
|
#
|
|
25
|
+
# A macro aimed at `:class` reaches the same place and is exempt for the
|
|
26
|
+
# same reason. Remove Middle Man has nothing to prescribe here: there is no
|
|
27
|
+
# third object for the caller to navigate to, so the only alternatives are
|
|
28
|
+
# the macro and a hand-written body that says exactly what the macro says.
|
|
29
|
+
# Between those two the macro is the better spelling, and flagging it while
|
|
30
|
+
# exempting the body would push every author toward the longer one.
|
|
31
|
+
#
|
|
25
32
|
# `each` in a class that includes or prepends `Enumerable` is the module's
|
|
26
33
|
# required contract, not delegation. The class has no interface without it
|
|
27
34
|
# — every method `Enumerable` provides is built on `each` — so the class is
|
|
@@ -37,36 +44,52 @@ module RuboCop
|
|
|
37
44
|
# contributes something the caller would otherwise have to reach in and
|
|
38
45
|
# take, and the result is a simpler API on the object that owns the data.
|
|
39
46
|
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
+
# Own state is an instance variable, `self`, or a receiverless call,
|
|
48
|
+
# reached directly, through a wrapper, or through a call on it — `record`,
|
|
49
|
+
# `record.owner_id` and `[record.owner_id]` all carry the object's own
|
|
50
|
+
# data. A method PARAMETER does not: it arrives as an `lvar`, so a setter
|
|
51
|
+
# handing its argument to a collaborator stays flagged.
|
|
52
|
+
#
|
|
53
|
+
# Two limits keep the exemption from swallowing the rule.
|
|
54
|
+
#
|
|
55
|
+
# The receiver must not itself be a chain. Remove Middle Man on a message
|
|
56
|
+
# chain IS the caller navigating, so composing own state does not excuse
|
|
57
|
+
# it.
|
|
58
|
+
#
|
|
59
|
+
# An argument rooted at the collaborator being forwarded to does not earn
|
|
60
|
+
# the exemption. `author.name(author.locale)` hands the collaborator back
|
|
61
|
+
# its own data, which is the echo this rule forbids, not composition, so
|
|
62
|
+
# with no other argument to carry it the forward stays flagged. Rooted at
|
|
63
|
+
# the object's OWN state the argument may be a chain of any depth — the
|
|
64
|
+
# object still supplied what the collaborator needed, and how far it
|
|
65
|
+
# reached inside itself to build the value is its own business.
|
|
47
66
|
#
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
67
|
+
# A forward to a collaborator that passes NO argument is the one that
|
|
68
|
+
# republishes, and it is always flagged. That is the line — supplying data
|
|
69
|
+
# the collaborator needs is composition, echoing back what the collaborator
|
|
70
|
+
# already knows is delegation.
|
|
52
71
|
#
|
|
53
72
|
# @example
|
|
54
73
|
# # bad
|
|
55
|
-
# delegate :name, to: :
|
|
74
|
+
# delegate :name, to: :author
|
|
56
75
|
#
|
|
57
76
|
# # bad — same promise, written by hand
|
|
58
77
|
# def name
|
|
59
|
-
#
|
|
78
|
+
# author.name
|
|
60
79
|
# end
|
|
61
80
|
#
|
|
62
81
|
# # good — the caller navigates
|
|
63
|
-
#
|
|
82
|
+
# post.author.name
|
|
64
83
|
#
|
|
65
84
|
# # good — the object answers about itself, not for a collaborator
|
|
66
85
|
# def lock_key
|
|
67
|
-
# self.class.lock_key(
|
|
86
|
+
# self.class.lock_key(owner_id: owner_id)
|
|
68
87
|
# end
|
|
69
88
|
#
|
|
89
|
+
# # good — aimed at the object's own class, where there is no third
|
|
90
|
+
# # object to navigate to
|
|
91
|
+
# delegate :model, to: :class
|
|
92
|
+
#
|
|
70
93
|
# # good — `each` is the Enumerable contract this class implements
|
|
71
94
|
# class SearchResult
|
|
72
95
|
# include Enumerable
|
|
@@ -78,12 +101,22 @@ module RuboCop
|
|
|
78
101
|
#
|
|
79
102
|
# # good — composes its own attribute, so the caller cannot just navigate
|
|
80
103
|
# def output
|
|
81
|
-
#
|
|
104
|
+
# formatter.output(value)
|
|
105
|
+
# end
|
|
106
|
+
#
|
|
107
|
+
# # good — names an expression built from its own record; not a forward
|
|
108
|
+
# def lock_key
|
|
109
|
+
# Registry.lock_key(owner_id: record.owner_id)
|
|
82
110
|
# end
|
|
83
111
|
#
|
|
84
112
|
# # bad — a chain does not stop being a chain because an argument rode along
|
|
85
113
|
# def starts_at
|
|
86
|
-
#
|
|
114
|
+
# schedule.window.period.starts_at(calendar)
|
|
115
|
+
# end
|
|
116
|
+
#
|
|
117
|
+
# # bad — the argument is the collaborator's own data handed back to it
|
|
118
|
+
# def name
|
|
119
|
+
# author.name(author.locale)
|
|
87
120
|
# end
|
|
88
121
|
#
|
|
89
122
|
class DisallowDelegate < ::RuboCop::Cop::Base
|
|
@@ -102,8 +135,23 @@ module RuboCop
|
|
|
102
135
|
(send nil? {:include :prepend} (const {nil? cbase} :Enumerable))
|
|
103
136
|
PATTERN
|
|
104
137
|
|
|
138
|
+
# `delegate` names its target in a `to:` option, wherever that option
|
|
139
|
+
# sits among the forwarded method names.
|
|
140
|
+
# @!method to_option(node)
|
|
141
|
+
def_node_matcher :to_option, <<~PATTERN
|
|
142
|
+
(send nil? :delegate ... (hash <(pair (sym :to) $_) ...>))
|
|
143
|
+
PATTERN
|
|
144
|
+
|
|
145
|
+
# The Forwardable macros and `delegate_missing_to` take their target as
|
|
146
|
+
# the first argument. `DelegateClass` names none and never matches.
|
|
147
|
+
# @!method leading_target(node)
|
|
148
|
+
def_node_matcher :leading_target, <<~PATTERN
|
|
149
|
+
(send nil? {:delegate_missing_to :def_delegator :def_delegators} $_ ...)
|
|
150
|
+
PATTERN
|
|
151
|
+
|
|
105
152
|
def on_send(node)
|
|
106
153
|
return unless node.receiver.nil?
|
|
154
|
+
return if own_class_target?(node)
|
|
107
155
|
|
|
108
156
|
add_offense(node, message: MACRO_MSG)
|
|
109
157
|
end
|
|
@@ -118,6 +166,16 @@ module RuboCop
|
|
|
118
166
|
|
|
119
167
|
private
|
|
120
168
|
|
|
169
|
+
# The target is written as a bare name, so a symbol and a string say the
|
|
170
|
+
# same thing and both are read as one.
|
|
171
|
+
def own_class_target?(node)
|
|
172
|
+
target = to_option(node) || leading_target(node)
|
|
173
|
+
return false if target.nil?
|
|
174
|
+
return false unless target.type?(:sym, :str)
|
|
175
|
+
|
|
176
|
+
target.value.to_sym == :class
|
|
177
|
+
end
|
|
178
|
+
|
|
121
179
|
def forwards_own_message?(body, method_name)
|
|
122
180
|
return false if body.nil?
|
|
123
181
|
return false unless body.send_type?
|
|
@@ -154,32 +212,43 @@ module RuboCop
|
|
|
154
212
|
mixes_in_enumerable?(body)
|
|
155
213
|
end
|
|
156
214
|
|
|
157
|
-
def composes_own_state?(body)
|
|
158
|
-
return false if chained_receiver?(body.receiver)
|
|
159
|
-
|
|
160
|
-
body.arguments.any? { |argument| own_state?(argument) }
|
|
161
|
-
end
|
|
162
|
-
|
|
163
215
|
# Remove Middle Man on a message chain IS the caller navigating, so an
|
|
164
216
|
# argument riding along does not earn the exemption.
|
|
165
|
-
def
|
|
166
|
-
return false
|
|
217
|
+
def composes_own_state?(body)
|
|
218
|
+
return false if call_with_receiver?(body.receiver)
|
|
167
219
|
|
|
168
|
-
|
|
220
|
+
body.arguments.any? { |argument| own_state?(argument, body.receiver) }
|
|
169
221
|
end
|
|
170
222
|
|
|
171
223
|
# A method parameter reaches the body as an `lvar`, so only an instance
|
|
172
|
-
# variable or a receiverless call counts as the object's own
|
|
173
|
-
#
|
|
174
|
-
|
|
175
|
-
|
|
224
|
+
# variable, `self`, or a receiverless call counts as the object's own
|
|
225
|
+
# state — read directly, through a wrapper, or through a call on it.
|
|
226
|
+
# Anything rooted at the collaborator is that collaborator's own data
|
|
227
|
+
# coming back, which is the echo the rule forbids. The first guard
|
|
228
|
+
# refuses anything that is not a node, which covers both the nil child
|
|
229
|
+
# an anonymous block pass carries and a wrapper whose children are raw
|
|
230
|
+
# Ruby values rather than nodes.
|
|
231
|
+
def own_state?(argument, collaborator)
|
|
232
|
+
return false unless argument.is_a?(::RuboCop::AST::Node)
|
|
233
|
+
return false if argument == collaborator
|
|
176
234
|
return true if argument.ivar_type?
|
|
177
|
-
return true if argument.
|
|
178
|
-
return argument.
|
|
235
|
+
return true if argument.self_type?
|
|
236
|
+
return own_state?(argument.receiver, collaborator) if call_with_receiver?(argument)
|
|
237
|
+
return true if argument.send_type?
|
|
238
|
+
return argument.children.any? { |child| own_state?(child, collaborator) } if wrapper?(argument)
|
|
179
239
|
|
|
180
240
|
false
|
|
181
241
|
end
|
|
182
242
|
|
|
243
|
+
# `call_type?` is the `send`/`csend` union, so safe navigation counts as
|
|
244
|
+
# the call it is — a `&.` link neither escapes the chain limit nor stops
|
|
245
|
+
# own state from being recognized.
|
|
246
|
+
def call_with_receiver?(node)
|
|
247
|
+
return false unless node.call_type?
|
|
248
|
+
|
|
249
|
+
!node.receiver.nil?
|
|
250
|
+
end
|
|
251
|
+
|
|
183
252
|
def wrapper?(argument)
|
|
184
253
|
ARGUMENT_WRAPPERS.include?(argument.type)
|
|
185
254
|
end
|