rubocop-dev_doc 0.10.4 → 0.12.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +73 -0
  3. data/lib/dev_doc/i18n/pseudo_locale.rb +23 -19
  4. data/lib/dev_doc/test/lints/cop_drift_check.rb +43 -0
  5. data/lib/dev_doc/test/lints/cop_drift_test.rb +40 -0
  6. data/lib/dev_doc/test/lints/cron_schedule.rb +137 -115
  7. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +5 -2
  8. data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +5 -0
  9. data/lib/dev_doc/test/lints/no_file_excludes.rb +32 -21
  10. data/lib/dev_doc/test/pseudo_i18n_crawler.rb +96 -60
  11. data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +81 -58
  12. data/lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb +176 -0
  13. data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +3 -3
  14. data/lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb +102 -0
  15. data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +12 -11
  16. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -0
  17. data/lib/rubocop/cop/dev_doc/migration/date_column_naming.rb +3 -0
  18. data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +3 -2
  19. data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +2 -2
  20. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +3 -3
  21. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +3 -1
  22. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +4 -1
  23. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +5 -4
  24. data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +3 -4
  25. data/lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb +116 -0
  26. data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +2 -1
  27. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +23 -13
  28. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +21 -0
  29. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +13 -9
  30. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +13 -10
  31. data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +15 -7
  32. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +2 -2
  33. data/lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb +134 -0
  34. data/lib/rubocop/dev_doc/plugin.rb +2 -0
  35. data/lib/rubocop/dev_doc/version.rb +1 -1
  36. metadata +8 -2
@@ -16,7 +16,7 @@ module DevDoc
16
16
  # this via the per-project override on the `NoFileExcludes` module.
17
17
  DEFAULT_ALLOWED_FILES = %w[db/schema.rb].freeze
18
18
 
19
- GLOB_CHARACTERS = /[*?\[\]{}]/.freeze
19
+ GLOB_CHARACTERS = /[*?\[\]{}]/
20
20
 
21
21
  # Sentinel returned by `#offenders` when the `.rubocop.yml` doesn't
22
22
  # exist. Distinguishable from `[]` (file present, all good) so the
@@ -37,18 +37,24 @@ module DevDoc
37
37
 
38
38
  # YAML.safe_load returns nil for an empty file. Rubocop config
39
39
  # is a flat top-level hash; `Exclude:` keys live one level down.
40
- (YAML.safe_load(@rubocop_yml_path.read) || {}).each_with_object([]) do |(section, value), acc|
41
- next unless value.is_a?(Hash)
40
+ (YAML.safe_load(@rubocop_yml_path.read) || {}).flat_map do |section, value|
41
+ next [] unless value.is_a?(Hash)
42
42
 
43
- Array(value['Exclude']).each do |entry|
44
- next unless entry.is_a?(String)
45
- next if entry.match?(GLOB_CHARACTERS)
46
- next if @allowed_files.include?(entry)
47
-
48
- acc << " #{section}: Exclude includes literal file #{entry.inspect}"
43
+ literal_excludes(value).map do |entry|
44
+ " #{section}: Exclude includes literal file #{entry.inspect}"
49
45
  end
50
46
  end
51
47
  end
48
+
49
+ private
50
+
51
+ # Literal-path (non-glob, non-allowlisted) entries of one section's
52
+ # `Exclude:` list.
53
+ def literal_excludes(section_config)
54
+ Array(section_config['Exclude']).select do |entry|
55
+ entry.is_a?(String) && !entry.match?(GLOB_CHARACTERS) && !@allowed_files.include?(entry)
56
+ end
57
+ end
52
58
  end
53
59
 
54
60
  # Reject literal-file entries in any `Exclude:` list under
@@ -61,7 +67,7 @@ module DevDoc
61
67
  # pattern is acceptable for new code in the same file. That sets the
62
68
  # wrong expectation and lets violations multiply.
63
69
  #
64
- # Inline `# rubocop:disable Cop/Name` at the violation line(s) keeps
70
+ # An inline `rubocop:disable Cop/Name` directive at the violation line(s) keeps
65
71
  # the suppression visible to anyone reading the code AND scopes it to
66
72
  # the specific lines — a fresh violation in the same file still gets
67
73
  # flagged. The rationale for the suppression lives next to the code it
@@ -80,7 +86,8 @@ module DevDoc
80
86
  # ✔ Visible inline, scoped to the specific line(s)
81
87
  #
82
88
  # # db/migrate/20260505035728_create_pr_reviews.rb
83
- # t.jsonb :diff_files, default: [] # rubocop:disable DevDoc/Migration/AvoidColumnDefault
89
+ # # rubocop:disable DevDoc/Migration/AvoidColumnDefault -- <reason>
90
+ # t.jsonb :diff_files, default: []
84
91
  #
85
92
  # ✔ Glob — still acceptable, targets a category not a specific file
86
93
  #
@@ -109,19 +116,23 @@ module DevDoc
109
116
 
110
117
  skip "no #{rubocop_yml_path} found" if result == NoFileExcludesChecker::MissingFile
111
118
 
112
- assert result.empty?,
113
- "`.rubocop.yml` Exclude lists must contain only globs " \
114
- "(category patterns), not literal file paths. A literal-path " \
115
- "exclude hides the suppression from readers of the file. " \
116
- "Use `# rubocop:disable Cop/Name` at the violation line(s) " \
117
- "instead — visible to readers, scoped to the specific lines.\n\n" \
118
- "Offenders:\n#{result.join("\n")}"
119
+ assert result.empty?, no_file_excludes_message(result)
119
120
  end
120
121
 
121
122
  private
122
- def rubocop_yml_path
123
- Rails.root.join('.rubocop.yml')
124
- end
123
+
124
+ def rubocop_yml_path
125
+ Rails.root.join('.rubocop.yml')
126
+ end
127
+
128
+ def no_file_excludes_message(offenders)
129
+ "`.rubocop.yml` Exclude lists must contain only globs " \
130
+ "(category patterns), not literal file paths. A literal-path " \
131
+ "exclude hides the suppression from readers of the file. " \
132
+ "Use `# rubocop:disable Cop/Name` at the violation line(s) " \
133
+ "instead — visible to readers, scoped to the specific lines.\n\n" \
134
+ "Offenders:\n#{offenders.join("\n")}"
135
+ end
125
136
  end
126
137
  end
127
138
  end
@@ -1,5 +1,3 @@
1
- require 'set'
2
-
3
1
  module DevDoc
4
2
  module Test
5
3
  # Pseudo-localization hardcoded-text crawler.
@@ -96,20 +94,11 @@ module DevDoc
96
94
 
97
95
  # Prepend the Scanner onto the crawler HTTP client exactly once, no
98
96
  # matter how many test classes include the module.
99
- unless Glib::JsonCrawler::Http.ancestors.include?(Scanner)
100
- Glib::JsonCrawler::Http.prepend(Scanner)
101
- end
97
+ Glib::JsonCrawler::Http.prepend(Scanner) unless Glib::JsonCrawler::Http.ancestors.include?(Scanner)
102
98
 
103
99
  base.setup do
104
- skip 'Set PSEUDO_I18N=1 to run the pseudo-localization scan.' unless ENV['PSEUDO_I18N'] == '1'
105
- skip 'en-PSEUDO locale not loaded — is config/initializers/pseudo_locale.rb active?' unless ::I18n.available_locales.include?(:'en-PSEUDO')
106
-
107
- self.class.hits = {}
108
- @previous_default_locale = ::I18n.default_locale
109
- # The app's set_locale falls back to I18n.default_locale when no
110
- # `_lang` param is present (crawler URLs carry none), so this renders
111
- # every page in pseudo without touching the crawler's URLs.
112
- ::I18n.default_locale = :'en-PSEUDO'
100
+ skip_unless_pseudo_enabled!
101
+ enter_pseudo_locale
113
102
  end
114
103
 
115
104
  base.teardown do
@@ -117,6 +106,7 @@ module DevDoc
117
106
  end
118
107
  end
119
108
 
109
+ # Per-run accumulator surface for the including test class.
120
110
  module ClassMethods
121
111
  attr_accessor :hits # { url => [ "[key] value", ... ] }
122
112
 
@@ -130,31 +120,44 @@ module DevDoc
130
120
  @report_dir ||= ::Rails.root.join('test/integration/pseudo_i18n_crawler_test_results').to_s
131
121
  end
132
122
 
123
+ def parse_json(body)
124
+ JSON.parse(body)
125
+ rescue JSON::ParserError
126
+ nil
127
+ end
128
+
133
129
  def scan_body(url, body)
134
130
  return unless body.is_a?(String) && body.lstrip.start_with?('{', '[')
135
131
 
136
- json = begin
137
- JSON.parse(body)
138
- rescue JSON::ParserError
139
- return
140
- end
132
+ json = parse_json(body)
133
+ return unless json
141
134
 
142
- walk(json) do |key, value|
143
- if value.include?(MARKER)
144
- @wrapped = (@wrapped || 0) + 1
145
- elsif trivial?(value)
146
- next
147
- elsif fixture_strings.include?(value.strip)
148
- # ActiveRecord display value (record title/name/user copy) that
149
- # came straight from the DB — data, not a hardcoded UI string.
150
- @data_sourced = (@data_sourced || 0) + 1
151
- else
152
- @unwrapped = (@unwrapped || 0) + 1
153
- (hits[url] ||= []) << "[#{key}] #{value.inspect}"
154
- end
135
+ walk(json) { |key, value| tally(url, key, value) }
136
+ end
137
+
138
+ # Buckets one text-prop value: wrapped (went through t()), trivial
139
+ # (not copy), data-sourced (verbatim fixture string), or a hardcoded
140
+ # candidate recorded in `hits`.
141
+ def tally(url, key, value)
142
+ if value.include?(MARKER)
143
+ counters[:wrapped] += 1
144
+ elsif trivial?(value)
145
+ nil
146
+ elsif fixture_strings.include?(value.strip)
147
+ # ActiveRecord display value (record title/name/user copy) that
148
+ # came straight from the DB — data, not a hardcoded UI string.
149
+ counters[:data_sourced] += 1
150
+ else
151
+ counters[:unwrapped] += 1
152
+ (hits[url] ||= []) << "[#{key}] #{value.inspect}"
155
153
  end
156
154
  end
157
155
 
156
+ # Per-run bucket counts for the DIAG line.
157
+ def counters
158
+ @counters ||= Hash.new(0)
159
+ end
160
+
158
161
  # Verbatim string values from the fixtures. A candidate equal to one of
159
162
  # these is an ActiveRecord display value (loaded from the DB, so it
160
163
  # never went through t() by design) rather than hardcoded copy, and is
@@ -164,7 +167,7 @@ module DevDoc
164
167
  # fixtures contain no ERB so static YAML parsing is exact.
165
168
  def fixture_strings
166
169
  @fixture_strings ||= Dir[File.join(fixture_dir, '**', '*.yml')].each_with_object(Set.new) do |file, set|
167
- data = YAML.safe_load(File.read(file), permitted_classes: [Date, Time], aliases: true)
170
+ data = YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true)
168
171
  collect_strings(data, set)
169
172
  rescue Psych::Exception
170
173
  next
@@ -178,12 +181,15 @@ module DevDoc
178
181
  when String
179
182
  s = node.strip
180
183
  set << s unless s.empty?
184
+ else
185
+ # Non-string scalars (numbers, booleans, nil, dates) carry no copy.
181
186
  end
182
187
  end
183
188
 
184
189
  def diag
185
- "wrapped(localized via t)=#{@wrapped || 0} data-sourced(fixtures, skipped)=#{@data_sourced || 0} " \
186
- "unwrapped(candidates)=#{@unwrapped || 0}"
190
+ "wrapped(localized via t)=#{counters[:wrapped]} " \
191
+ "data-sourced(fixtures, skipped)=#{counters[:data_sourced]} " \
192
+ "unwrapped(candidates)=#{counters[:unwrapped]}"
187
193
  end
188
194
 
189
195
  def walk(node, &blk)
@@ -195,6 +201,9 @@ module DevDoc
195
201
  end
196
202
  when Array
197
203
  node.each { |v| walk(v, &blk) }
204
+ else
205
+ # Scalars have no children to walk; string values are handed to
206
+ # the block by their parent hash above.
198
207
  end
199
208
  end
200
209
 
@@ -208,47 +217,74 @@ module DevDoc
208
217
 
209
218
  private
210
219
 
220
+ def skip_unless_pseudo_enabled!
221
+ skip 'Set PSEUDO_I18N=1 to run the pseudo-localization scan.' unless ENV['PSEUDO_I18N'] == '1'
222
+ return if ::I18n.available_locales.include?(:'en-PSEUDO')
223
+
224
+ skip 'en-PSEUDO locale not loaded — is config/initializers/pseudo_locale.rb active?'
225
+ end
226
+
227
+ def enter_pseudo_locale
228
+ self.class.hits = {}
229
+ self.class.counters.clear
230
+ @previous_default_locale = ::I18n.default_locale
231
+ # The app's set_locale falls back to I18n.default_locale when no
232
+ # `_lang` param is present (crawler URLs carry none), so this renders
233
+ # every page in pseudo without touching the crawler's URLs.
234
+ ::I18n.default_locale = :'en-PSEUDO'
235
+ end
236
+
211
237
  def crawl_pseudo(user)
212
238
  user[:token] = login(user)
213
239
 
214
- router = Glib::JsonCrawler::Router.new
215
- http = Glib::JsonCrawler::Http.new(self, user, router, inspect_result: true)
216
- router.host = HOST
217
- router.skip_similar_page = true
218
-
219
240
  ENV['CRAWLER_MODE'] = 'true'
220
- router.step(
221
- http,
222
- 'onClick' => {
223
- 'action' => 'initiate_navigation',
224
- 'url' => "http://#{HOST}/users/me?format=json&redirect=default"
225
- }
226
- )
241
+ start_crawl(user)
227
242
  ensure
228
243
  ENV['CRAWLER_MODE'] = nil
229
244
  logout
230
245
  report!(user)
231
246
  end
232
247
 
248
+ # Enters the crawl at the logged-in user's landing page; the Router
249
+ # walks every reachable page from there.
250
+ def start_crawl(user)
251
+ router = Glib::JsonCrawler::Router.new
252
+ http = Glib::JsonCrawler::Http.new(self, user, router, inspect_result: true)
253
+ router.host = HOST
254
+ router.skip_similar_page = true
255
+
256
+ entry = { 'action' => 'initiate_navigation',
257
+ 'url' => "http://#{HOST}/users/me?format=json&redirect=default" }
258
+ router.step(http, 'onClick' => entry)
259
+ end
260
+
233
261
  def report!(user)
234
262
  hits = self.class.hits
235
- report_dir = self.class.report_dir
236
- FileUtils.mkdir_p(report_dir)
237
- path = File.join(report_dir, "#{user[:email]}.txt")
238
-
239
- lines = []
240
- hits.sort.each do |url, values|
241
- lines << url
242
- values.uniq.each { |v| lines << " #{v}" }
243
- lines << ''
244
- end
245
- total = hits.values.sum { |v| v.uniq.size }
246
- summary = "#{total} hardcoded candidate(s) across #{hits.size} page(s) for #{user[:email]}"
263
+ summary = report_summary(hits, user)
264
+ path = write_report(summary, hits, user)
247
265
 
248
- File.write(path, ([summary, ''] + lines).join("\n"))
249
266
  puts "\n[pseudo_i18n] #{summary}\n report: #{path}"
250
267
  puts "[pseudo_i18n] DIAG: #{self.class.diag}"
251
268
  end
269
+
270
+ # Writes the per-role report file and returns its path.
271
+ def write_report(summary, hits, user)
272
+ FileUtils.mkdir_p(self.class.report_dir)
273
+ path = File.join(self.class.report_dir, "#{user[:email]}.txt")
274
+ File.write(path, ([summary, ''] + report_lines(hits)).join("\n"))
275
+ path
276
+ end
277
+
278
+ def report_summary(hits, user)
279
+ total = hits.values.sum { |v| v.uniq.size }
280
+ "#{total} hardcoded candidate(s) across #{hits.size} page(s) for #{user[:email]}"
281
+ end
282
+
283
+ def report_lines(hits)
284
+ hits.sort.flat_map do |url, values|
285
+ [url] + values.uniq.map { |v| " #{v}" } + ['']
286
+ end
287
+ end
252
288
  end
253
289
  end
254
290
  end
@@ -1,3 +1,5 @@
1
+ require_relative "current_user_branching_helpers"
2
+
1
3
  module RuboCop
2
4
  module Cop
3
5
  module DevDoc
@@ -43,16 +45,63 @@ module RuboCop
43
45
  # ## Patterns flagged
44
46
  # - `if current_user` / `unless current_user` (block or modifier) where
45
47
  # the body is non-empty and is not a bare `return` (bare returns are
46
- # the nil-guard pattern covered by LoadResourceCurrentUserGuard).
48
+ # the nil-guard pattern covered by LoadResourceCurrentUserGuard; the
49
+ # exemption covers every nil-test spelling of the guard —
50
+ # `return if current_user.nil?`, `return unless
51
+ # current_user.present?`, `return if !current_user`).
47
52
  # - `if user_signed_in?` / `unless user_signed_in?` (any form).
48
- # - `if current_user&.foo` and other safe-nav uses of `current_user`
49
- # as a condition. The `&.` is itself a confession that the dev
50
- # expects `current_user` to be nil sometimes — i.e., it's the same
51
- # anti-pattern as `if current_user`, just in disguise: when
52
- # `current_user` is nil the csend returns nil → branch is dead for
53
- # anonymous visitors, exactly what the cop prevents.
53
+ # - The evasion spellings all equivalent to the bare form and all
54
+ # measured in the wild once the bare form was policed:
55
+ # `current_user.present?` / `.blank?` / `.nil?`, `!current_user`,
56
+ # `!user_signed_in?`, and boolean combinations
57
+ # (`current_user && x`, `user_signed_in? || y`).
58
+ # - ANY method chain rooted at `current_user` used as a condition —
59
+ # both `current_user&.admin?` and plain `current_user.admin?`. The
60
+ # `&.` is a confession that the dev expects nil; the plain chain is
61
+ # a role/ownership check that belongs in the policy (see below).
54
62
  # - Ternaries: `current_user ? a : b`, `user_signed_in? ? a : b`.
55
63
  # - Hash/argument values: `authenticated: user_signed_in?`.
64
+ # - ALIASES: a local/instance variable assigned from `current_user`
65
+ # in the same file inherits every rule above — `@user =
66
+ # current_user` followed by `if @user.admin?` is the same branch
67
+ # laundered, and it flags identically. Only PURE aliases are
68
+ # tracked: every assignment to the name must be an unconditional
69
+ # `= current_user` (case dispatch counts as unconditional). A
70
+ # conditional assignment or a second source makes the variable a
71
+ # genuinely nilable/general resource holder — branching on it is
72
+ # resource-presence logic, not auth state. Taint is per-file only:
73
+ # an ivar assigned in the controller and read in a view is not
74
+ # tracked — pair this cop with the receiver-agnostic
75
+ # `DevDoc/Auth/RolePredicateOutsidePolicy`, which closes that gap
76
+ # for the known role predicates.
77
+ #
78
+ # ## AllowedMethods — classifying data reads
79
+ # The first method called on `current_user` (or a tainted alias) in a
80
+ # condition is CLASSIFIED: entries in `AllowedMethods` are data reads
81
+ # (`current_user.errors.any?`, an MFA-configured flag) and pass;
82
+ # everything else — role predicates and methods nobody has classified
83
+ # yet — flags. The list is a closed set on purpose: every new reader
84
+ # used in a condition forces a reviewed "data or permission?" decision
85
+ # in `.rubocop.yml` rather than slipping through. The
86
+ # universally-generic baseline (errors, id, email, persisted?,
87
+ # new_record? — `DEFAULT_ALLOWED_METHODS`) is built into the cop and
88
+ # cannot be un-configured; the yml list is purely ADDITIVE, so
89
+ # projects list only their own readers:
90
+ #
91
+ # DevDoc/Auth/CurrentUserBranching:
92
+ # AllowedMethods:
93
+ # - otp_required_for_login
94
+ #
95
+ # ## Moving a decision to the policy — query by PURPOSE
96
+ # The policy move is only complete when the policy owns the
97
+ # role->behavior rule, not just the role's spelling. Export a
98
+ # decision-shaped query (`auto_approve?`, `initiation_blocked_for_admin?`,
99
+ # or an `authorize` block) and call THAT from page code. A generic role
100
+ # getter (`def user_is_admin? = current_user&.admin?`) re-opens the
101
+ # scattering: every call site invents its own rule again, one policy
102
+ # door down. If your policies must expose such getters as internal
103
+ # building blocks, list their names in the companion cop's
104
+ # RolePredicates so page code cannot call them.
56
105
  #
57
106
  # ## Allowed paths (Exclude:)
58
107
  # By default the cop is silent in:
@@ -139,9 +188,21 @@ module RuboCop
139
188
  # ...
140
189
  # end
141
190
  class CurrentUserBranching < Base
142
- MSG = 'Avoid branching on auth state in page code. ' \
143
- 'If this page serves both anonymous and signed-in users, ' \
144
- 'add an inline disable with a reason.'.freeze
191
+ include CurrentUserBranchingHelpers
192
+
193
+ MSG = 'Branching on auth state in page code — decide what this branch really is: ' \
194
+ 'auth is required here -> the anonymous branch is dead code, delete it; ' \
195
+ 'a role/ownership check -> move the DECISION into the Pundit policy and ' \
196
+ 'query it by purpose (`policy(record).auto_approve?`, `can?(:update, x)`) ' \
197
+ '— do NOT export a generic role getter (`user_is_admin?`) from the policy: ' \
198
+ 'that centralizes only the spelling while every call site keeps its own ' \
199
+ 'role->behavior rule; a plain data read (not permission-related) -> ' \
200
+ 'classify the method in AllowedMethods; genuinely dual-state -> move the ' \
201
+ 'branching to a layout/helper (excluded paths — PRESENTATION branching ' \
202
+ 'only, keyed on entity-scoped facts, never smuggled permission logic) or ' \
203
+ 'split anonymous/signed-in partials. Aliasing (`user = current_user`) ' \
204
+ 'does not exempt any of this. Only a small, local branch on a genuinely ' \
205
+ 'dual-state PAGE warrants an inline disable with a reason.'.freeze
145
206
 
146
207
  AUTH_METHODS = %i[current_user user_signed_in?].freeze
147
208
 
@@ -152,9 +213,17 @@ module RuboCop
152
213
  add_offense(if_offense_location(node))
153
214
  end
154
215
 
155
- # `value: user_signed_in?` — condition passed as a value.
216
+ # `value: user_signed_in?` — the BOOLEAN auth state passed as data,
217
+ # which smuggles the branch to the consumer/client. Deliberately
218
+ # limited to `user_signed_in?`: bare `current_user` passed as data
219
+ # (`requested_by: current_user`, `model: current_user`,
220
+ # `x.user == current_user`) is actorship recording or identity
221
+ # comparison, not branching — flagging it measured 44 noise
222
+ # offenses / 0 finds on a mature codebase.
223
+ VALUE_FLAGGED_METHODS = %i[user_signed_in?].freeze
224
+
156
225
  def on_send(node)
157
- return unless auth_method_call?(node)
226
+ return unless VALUE_FLAGGED_METHODS.include?(node.method_name) && node.receiver.nil?
158
227
  return unless used_as_value?(node)
159
228
 
160
229
  add_offense(node.loc.selector)
@@ -166,46 +235,6 @@ module RuboCop
166
235
  node.if_type? && auth_condition?(node.condition)
167
236
  end
168
237
 
169
- def auth_condition?(condition)
170
- return false unless condition
171
-
172
- if condition.send_type?
173
- AUTH_METHODS.include?(condition.method_name) && condition.receiver.nil?
174
- elsif condition.csend_type?
175
- # `current_user&.foo` — the safe-nav is itself the auth check;
176
- # `bare_current_user_condition?` won't match (it requires send),
177
- # so guard-return forms like `return unless current_user&.admin?`
178
- # correctly stay flagged.
179
- receiver = condition.receiver
180
- receiver&.send_type? &&
181
- receiver.method_name == :current_user &&
182
- receiver.receiver.nil?
183
- else
184
- false
185
- end
186
- end
187
-
188
- # `return unless current_user` (and similar bare guards) should not
189
- # be flagged — they are the correct pattern handled by
190
- # LoadResourceCurrentUserGuard. Detect: an if whose condition is bare
191
- # `current_user` and one branch is a bare `return` (no arguments).
192
- def bare_return_guard?(node)
193
- condition = node.condition
194
- return false unless bare_current_user_condition?(condition)
195
-
196
- bare_return_branch?(node.else_branch) || bare_return_branch?(node.if_branch)
197
- end
198
-
199
- def bare_current_user_condition?(condition)
200
- condition&.send_type? &&
201
- condition.method_name == :current_user &&
202
- condition.receiver.nil?
203
- end
204
-
205
- def bare_return_branch?(branch)
206
- branch&.return_type? && branch.children.empty?
207
- end
208
-
209
238
  # `if`/`unless` keyword forms have `loc.keyword`; ternary `?:` nodes
210
239
  # have `loc.question` instead. Fall back to the condition source range.
211
240
  def if_offense_location(node)
@@ -219,12 +248,6 @@ module RuboCop
219
248
  end
220
249
  end
221
250
 
222
- # Is this send node an auth method call (`current_user`, `user_signed_in?`)
223
- # on no receiver?
224
- def auth_method_call?(node)
225
- AUTH_METHODS.include?(node.method_name) && node.receiver.nil?
226
- end
227
-
228
251
  # Is the send node used as a value (argument to another send, pair
229
252
  # value in a hash, etc.) rather than already caught as a branch condition?
230
253
  def used_as_value?(node)