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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dbfa3d2a442ba57bc74ba0171563f0bcadb6f4460b769654963066462ca0b61f
4
- data.tar.gz: '097de5b926870d292e63aa39f79aacc8342fe1909e34809bf9bb2f066c309a22'
3
+ metadata.gz: 0b6d31dcd7f72bdcf6f8cc44ca8315d8fd4388c54352be392fcf3b13160ed690
4
+ data.tar.gz: d399855a3ce34a564f243a741d34fc76c368cfeb2922f0c4f41f7d8020d0b89e
5
5
  SHA512:
6
- metadata.gz: 36791e28a8f0c14fa70a4990d953207412390095deda3386d9aa4edb233447ef4576d645b4ea499414c5e3ecac44fe9e4d2845033d83d607453a303655d6e32a
7
- data.tar.gz: 17bbb761062d3b28d03238fcf94df33bc29cc2f85ba839038469e7cf39d5c7bd0a6ced5402fac62d6660c7eb5d7c57e8971aa118f3ce1283412f7ddd366f223a
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
- The full rationale behind each convention (the "why this is good 4Shark code") lives in the team's engineering docs; this README states the intent each cop enforces. One convention deviates from a safe Rails default and is worth spelling out — see below.
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
- 4Shark never exposes internal database IDs across its API and upload boundaries. Clients send their **own** identifiers; each external identifier is mapped to its internal record (a surrogate-key cross-reference), and that lookup is scoped to the client's account so it confirms both that the record **exists** and that it **belongs to the caller**, in a single step.
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 backed by the external-identifier mapping — not an omission.
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
- This project does **not** use HubFlow. 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. Consuming repos depend on the published version in their `Gemfile`.
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 on every rescue in the
20
- # codebase. `PreferredName` is the option the cop ships to settle exactly that,
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
@@ -141,7 +141,7 @@ module RuboCop
141
141
  #
142
142
  # Example:
143
143
  # "UserAccount" → "user_account"
144
- # "PlanStatementAudit::Row" → "plan_statement_audit/row"
144
+ # "InvoiceLineItem::Row" → "invoice_line_item/row"
145
145
  def camel_to_snake(name)
146
146
  return '' if name.nil?
147
147
 
@@ -127,7 +127,7 @@ module RuboCop
127
127
  match && match[1]
128
128
  end
129
129
 
130
- # "UserAccount" → "user_account"; "Plan::Statement" → "plan/statement"
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
- # Three limits keep that exemption from swallowing the rule. The receiver
41
- # must not itself be a chain Remove Middle Man on a message chain IS the
42
- # caller navigating, so composing own state does not excuse it. Own state
43
- # is an instance variable or a receiverless call, so a method parameter
44
- # forwarded through does not qualify and a setter stays flagged. And own
45
- # state is read through a splat, a double splat, an array or a block pass,
46
- # because the wrapper does not change whose state the argument carries.
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
- # An argument derived from own state through another call
49
- # `variable.label(value.to_s)` is still flagged. Recursing into an
50
- # argument's receiver would also exempt `Lock.lock_key(company_id:
51
- # user.company_id)`, which is delegation, so the conservative reading wins.
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: :commission
74
+ # delegate :name, to: :author
56
75
  #
57
76
  # # bad — same promise, written by hand
58
77
  # def name
59
- # commission.name
78
+ # author.name
60
79
  # end
61
80
  #
62
81
  # # good — the caller navigates
63
- # statement.commission.name
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(company_id: company_id)
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
- # variable.output(value)
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
- # commission.plan.period.starts_at(calendar)
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 chained_receiver?(receiver)
166
- return false unless receiver.send_type?
217
+ def composes_own_state?(body)
218
+ return false if call_with_receiver?(body.receiver)
167
219
 
168
- !receiver.receiver.nil?
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 state. An
173
- # anonymous block pass carries a nil child, hence the first guard.
174
- def own_state?(argument)
175
- return false if argument.nil?
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.send_type? && argument.receiver.nil?
178
- return argument.children.any? { |child| own_state?(child) } if wrapper?(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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module RuboCop
4
4
  module Fourshark
5
- VERSION = '0.8.3'
5
+ VERSION = '0.8.5'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-fourshark
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.3
4
+ version: 0.8.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Paulo Ribeiro