rubocop-dev_doc 0.11.0 → 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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +28 -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/load_resource_current_user_guard.rb +3 -3
  12. data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +12 -11
  13. data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -0
  14. data/lib/rubocop/cop/dev_doc/migration/date_column_naming.rb +3 -0
  15. data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +3 -2
  16. data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +2 -2
  17. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +3 -3
  18. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +3 -1
  19. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +4 -1
  20. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +5 -4
  21. data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +3 -4
  22. data/lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb +116 -0
  23. data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +2 -1
  24. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +23 -13
  25. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +21 -0
  26. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +13 -9
  27. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +13 -10
  28. data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +15 -7
  29. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +2 -2
  30. data/lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb +134 -0
  31. data/lib/rubocop/dev_doc/plugin.rb +2 -0
  32. data/lib/rubocop/dev_doc/version.rb +1 -1
  33. metadata +6 -2
@@ -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
@@ -274,11 +274,11 @@ module RuboCop
274
274
  # `raise`/`fail`. A multi-statement branch (a `begin`) counts when its
275
275
  # LAST statement does.
276
276
  def branch_exits?(branch)
277
+ branch = branch.children.last if branch&.begin_type?
277
278
  return false unless branch
278
279
 
279
- branch = branch.children.last if branch.begin_type?
280
- branch&.return_type? ||
281
- (branch&.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
280
+ branch.return_type? ||
281
+ (branch.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
282
282
  end
283
283
  end
284
284
  end
@@ -40,9 +40,7 @@ module RuboCop
40
40
  TRANSLATION_METHODS = %i[t translate].freeze
41
41
 
42
42
  def on_send(node)
43
- if watched_methods.include?(node.method_name.to_s)
44
- node.arguments.each { |arg| each_localizable(arg) }
45
- end
43
+ node.arguments.each { |arg| each_localizable(arg) } if watched_methods.include?(node.method_name.to_s)
46
44
 
47
45
  check_jbuilder_positional(node)
48
46
  end
@@ -56,19 +54,22 @@ module RuboCop
56
54
  def each_localizable(node)
57
55
  case node.type
58
56
  when :hash
59
- node.pairs.each do |pair|
60
- key = pair.key
61
- if key.sym_type? && localizable_keys.include?(key.value.to_s)
62
- inspect_localizable(key.value, pair.value)
63
- end
64
-
65
- each_localizable(pair.value)
66
- end
57
+ node.pairs.each { |pair| walk_pair(pair) }
67
58
  when :array
68
59
  node.children.each { |child| each_localizable(child) }
60
+ else
61
+ # Any other node type (literal, send, variable…) can't contain
62
+ # localizable key-value pairs.
69
63
  end
70
64
  end
71
65
 
66
+ def walk_pair(pair)
67
+ key = pair.key
68
+ inspect_localizable(key.value, pair.value) if key.sym_type? && localizable_keys.include?(key.value.to_s)
69
+
70
+ each_localizable(pair.value)
71
+ end
72
+
72
73
  # jbuilder positional form: `json.title 'Forms'`. The method name is
73
74
  # the key and the first argument is the value. Restricted to the
74
75
  # jbuilder root `json` so ordinary `obj.text('...')` calls aren't
@@ -74,6 +74,9 @@ module RuboCop
74
74
  when :dstr
75
75
  first = node.children.first
76
76
  first&.str_type? ? first.value : nil
77
+ else
78
+ # Fully dynamic key (variable, send…) — nil is this method's
79
+ # documented "statically unknown" return.
77
80
  end
78
81
  end
79
82
 
@@ -62,6 +62,9 @@ module RuboCop
62
62
  return if name.end_with?('_at')
63
63
 
64
64
  add_offense(col_name_node, message: format(DATETIME_MSG, name: name))
65
+ else
66
+ # add_column funnels every column type here; only date/time
67
+ # types carry a suffix naming rule.
65
68
  end
66
69
  end
67
70
  end
@@ -125,8 +125,9 @@ module RuboCop
125
125
  private
126
126
 
127
127
  def receiver_shape_matches?(node)
128
- return node.receiver&.const_type? if CONST_RECEIVER_ONLY.include?(node.method_name)
129
- return !node.receiver&.const_type? if NON_CONST_RECEIVER_ONLY.include?(node.method_name)
128
+ const_receiver = node.receiver&.const_type?
129
+ return const_receiver if CONST_RECEIVER_ONLY.include?(node.method_name)
130
+ return !const_receiver if NON_CONST_RECEIVER_ONLY.include?(node.method_name)
130
131
 
131
132
  true
132
133
  end
@@ -44,8 +44,8 @@ module RuboCop
44
44
  # run — something `validate` can't express), disable this cop inline
45
45
  # with a written justification:
46
46
  #
47
- # def run_validations! # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride
48
- # # Reason: <explanation>
47
+ # # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride -- <explanation>
48
+ # def run_validations!
49
49
  # with_access { super }
50
50
  # end
51
51
  #
@@ -86,8 +86,8 @@ module RuboCop
86
86
  # genuine exception, disable this cop inline with a written
87
87
  # justification — expect pushback in review:
88
88
  #
89
- # after_create :some_callback # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks
90
- # # Reason: <explanation>
89
+ # # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks -- <explanation>
90
+ # after_create :some_callback
91
91
  #
92
92
  # Both the symbol form and the block form are flagged:
93
93
  #
@@ -136,7 +136,7 @@ module RuboCop
136
136
  add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
137
137
  end
138
138
 
139
- def on_block(node) # rubocop:disable InternalAffairs/NumblockHandler
139
+ def on_block(node)
140
140
  send_node = node.send_node
141
141
  return unless CALLBACKS.include?(send_node.method_name)
142
142
 
@@ -86,7 +86,9 @@ module RuboCop
86
86
 
87
87
  def enum_column(klass, name)
88
88
  table = schema.table_by(name: table_name(klass))
89
- table&.columns&.find { |c| c.name == name.to_s }
89
+ return nil unless table
90
+
91
+ table.columns.find { |c| c.name == name.to_s }
90
92
  end
91
93
 
92
94
  def class_node(node)
@@ -121,7 +121,10 @@ module RuboCop
121
121
  return explicit.method_name.to_s if explicit
122
122
 
123
123
  # Otherwise the nearest block that isn't a transparent wrapper decides.
124
- decider = sends.find { |s| !TRANSPARENT_WRAPPERS.include?(s.method_name) }
124
+ decider_context(sends.find { |s| !TRANSPARENT_WRAPPERS.include?(s.method_name) })
125
+ end
126
+
127
+ def decider_context(decider)
125
128
  return unless decider
126
129
 
127
130
  return 'collection' if RESOURCEFUL.include?(decider.method_name)
@@ -48,13 +48,14 @@ module RuboCop
48
48
  expected = expected_name(node.method_name, name)
49
49
  next if expected == name
50
50
 
51
- add_offense(
52
- arg,
53
- message: format(MSG, method: node.method_name, number: number_word(node.method_name), expected: expected)
54
- )
51
+ add_offense(arg, message: offense_message(node.method_name, expected))
55
52
  end
56
53
  end
57
54
 
55
+ def offense_message(method_name, expected)
56
+ format(MSG, method: method_name, number: number_word(method_name), expected: expected)
57
+ end
58
+
58
59
  private
59
60
 
60
61
  # The resource name as a string, for symbol or string arguments only
@@ -43,14 +43,13 @@ module RuboCop
43
43
  RESTRICT_ON_SEND = %i[resources resource].freeze
44
44
 
45
45
  def on_send(node)
46
- has_only = key_present?(node, :only)
47
- has_except = key_present?(node, :except)
46
+ return if key_present?(node, :only)
48
47
 
49
- return if has_only
48
+ has_except = key_present?(node, :except)
50
49
  return if has_except && !require_only?
51
50
 
52
51
  name = node.first_argument&.value || '?'
53
- msg = has_except && require_only? ? MSG_REQUIRE_ONLY : MSG
52
+ msg = has_except ? MSG_REQUIRE_ONLY : MSG
54
53
  add_offense(node.loc.selector, message: format(msg, method: node.method_name, name: name))
55
54
  end
56
55
 
@@ -0,0 +1,116 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag `.to_sym` (including `&.to_sym`, and through laundering hops
6
+ # like `.presence`/`.to_s`/`.downcase`) chained directly onto an
7
+ # attacker-controlled boundary source: `params[...]` (also `.dig`,
8
+ # `.fetch`, `.require`) or `request.headers[...]`.
9
+ #
10
+ # ## Rationale
11
+ # A bare `.to_sym` lets ANY client-supplied value cross into the symbol
12
+ # domain, where downstream `case`/`==` dispatch treats it as known
13
+ # vocabulary. The allow-list is the missing DECISION: which values do
14
+ # we recognise, and what happens to the rest? Skipping it also mints a
15
+ # symbol per novel value (the classic symbol-DoS vector; GC reclaims
16
+ # dynamic symbols since Ruby 2.2, so today this is churn rather than a
17
+ # hard leak — the vocabulary laundering is the real bug).
18
+ #
19
+ # Resolve the value through the glib-web helper, which only ever
20
+ # returns a symbol already in the allow-list:
21
+ #
22
+ # ❌
23
+ # @mode = params[:mode]&.to_sym || :active
24
+ #
25
+ # ✔️ (glib-web >= 5.1.2)
26
+ # @mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
27
+ #
28
+ # When recognised inputs must map to differently-spelled symbols
29
+ # (e.g. `'spring-promo'` → `:spring_promo`), use an explicit
30
+ # `case`-when at the boundary instead — same allow-list property.
31
+ #
32
+ # ## Sources detected
33
+ # - `params[…]`, `params.dig(…)`, `params.fetch(…)`, `params.require(…)`
34
+ # - `request.headers[…]`
35
+ #
36
+ # `ENV[…]` is deliberately NOT a source here (unlike the sibling
37
+ # `DevDoc/Style/StringSymbolComparison`): ENV is operator-controlled,
38
+ # not attacker-reachable, and `.to_sym` on boot-time config is
39
+ # legitimate where the controller helper doesn't even exist.
40
+ #
41
+ # ## Relationship with DevDoc/Style/StringSymbolComparison
42
+ # Complementary pair over the same boundary doctrine: that cop flags
43
+ # comparing an UNCONVERTED string source against a symbol (always
44
+ # false); this cop flags CONVERTING without validating. The sanctioned
45
+ # exit from both is the allow-list helper.
46
+ #
47
+ # ## Limitations
48
+ # Only the direct form is caught. Once the value flows through a
49
+ # helper method (`report_params[:x]`) or a local/instance variable,
50
+ # static analysis loses the boundary and review has to catch it.
51
+ # Laundering transforms (`.presence`, `.to_s`, `.strip`,
52
+ # `.downcase`, `.upcase`, `.squish`) do NOT reset the trail — they
53
+ # transform, they don't validate.
54
+ #
55
+ # @example
56
+ # # bad
57
+ # @mode = params[:mode]&.to_sym || :active
58
+ # @theme = request.headers['X-Theme']&.to_sym
59
+ # @segment = params[:segment].presence&.to_sym
60
+ # @sort = params[:sort].to_s.downcase.to_sym
61
+ #
62
+ # # good
63
+ # @mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
64
+ class AvoidSymbolizingBoundaryInput < Base
65
+ MSG = 'Unvalidated `.to_sym` on `%<source>s` lets any client value into the symbol ' \
66
+ 'domain (and mints a symbol per novel value). Allow-list it instead: ' \
67
+ '`glib_allowlist_symbol_param(value, ALLOWED, default:)`. Laundering through ' \
68
+ '`.to_s`/`.presence`/`.downcase` is not validation.'.freeze
69
+
70
+ RESTRICT_ON_SEND = %i[to_sym].freeze
71
+
72
+ # String-preserving transforms that commonly sit between the boundary
73
+ # read and the `.to_sym` — they launder the value, not validate it,
74
+ # so the chain is still an offense.
75
+ TRANSFORM_HOPS = %i[presence to_s strip downcase upcase squish].freeze
76
+
77
+ def_node_matcher :params_access?, <<~PATTERN
78
+ (send (send nil? :params) {:[] :dig :fetch :require} ...)
79
+ PATTERN
80
+
81
+ def_node_matcher :request_headers_access?, <<~PATTERN
82
+ (send (send _ :headers) :[] _)
83
+ PATTERN
84
+
85
+ def on_send(node)
86
+ source = boundary_source_in(node.receiver)
87
+ return unless source
88
+
89
+ add_offense(node, message: format(MSG, source: source.source))
90
+ end
91
+ alias on_csend on_send
92
+
93
+ private
94
+
95
+ # The boundary source at the bottom of the receiver chain, walking
96
+ # through any number of laundering transforms. Returns nil for
97
+ # anything else (locals, `action_name`, helper-method results) so
98
+ # those don't false-positive.
99
+ def boundary_source_in(node)
100
+ while node
101
+ return node if boundary_source?(node)
102
+ return nil unless node.send_type? || node.csend_type?
103
+ return nil unless TRANSFORM_HOPS.include?(node.method_name)
104
+
105
+ node = node.receiver
106
+ end
107
+ end
108
+
109
+ def boundary_source?(node)
110
+ params_access?(node) || request_headers_access?(node)
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -15,7 +15,8 @@ module RuboCop
15
15
  #
16
16
  # 1. **Fall-through genuinely happens in normal operation** — add
17
17
  # `else` with a comment stating WHY it is correct (requires
18
- # `Style/EmptyElse` to be disabled).
18
+ # `Style/EmptyElse` with `AllowComments: true`, which keeps that
19
+ # cop's guard against comment-less empty elses).
19
20
  # 2. **A non-match is a bug and continuing is unsafe** — `else raise`
20
21
  # (fail fast; unexpected enum/mode values must not proceed).
21
22
  # 3. **A non-match is a bug but users can safely continue** — report
@@ -83,21 +83,28 @@ module RuboCop
83
83
 
84
84
  siblings = parent.children
85
85
  nxt = siblings[siblings.index(asgn) + 1]
86
- nxt if nxt&.if_type? && !nxt.ternary? && !nxt.modifier_form? && nxt.if?
86
+ nxt if plain_if?(nxt)
87
+ end
88
+
89
+ # A statement-form `if` (not a ternary, modifier, or unless).
90
+ def plain_if?(node)
91
+ node&.if_type? && !node.ternary? && !node.modifier_form? && node.if?
87
92
  end
88
93
 
89
94
  # `if x` or `if x.present?` — a truthiness check on the assigned var.
90
95
  def truthiness_check?(condition, name)
91
96
  return false unless condition
97
+ return condition.children.first == name if condition.lvar_type?
92
98
 
93
- if condition.lvar_type?
94
- condition.children.first == name
95
- elsif condition.send_type? && TRUTHY_PREDICATES.include?(condition.method_name)
96
- recv = condition.receiver
97
- recv&.lvar_type? && recv.children.first == name
98
- else
99
- false
100
- end
99
+ truthy_predicate_on?(condition, name)
100
+ end
101
+
102
+ # `x.present?`-style: a truthy predicate whose receiver is the var.
103
+ def truthy_predicate_on?(condition, name)
104
+ return false unless condition.send_type? && TRUTHY_PREDICATES.include?(condition.method_name)
105
+
106
+ recv = condition.receiver
107
+ recv&.lvar_type? && recv.children.first == name
101
108
  end
102
109
 
103
110
  # Every read of `name` in the enclosing scope sits inside the if's
@@ -109,12 +116,15 @@ module RuboCop
109
116
  return false unless single_plain_assignment?(scope, name, asgn)
110
117
  return false if rebinds_name?(scope, name)
111
118
 
119
+ reads_confined_to?(if_node, reads(scope, name))
120
+ end
121
+
122
+ # Read at least once in the true branch, and every read sits inside
123
+ # the if's condition or that branch.
124
+ def reads_confined_to?(if_node, refs)
112
125
  true_branch = if_node.if_branch
113
126
  return false unless true_branch
114
-
115
- refs = reads(scope, name)
116
- in_branch = refs.select { |r| within?(r, true_branch) }
117
- return false if in_branch.empty?
127
+ return false if refs.none? { |r| within?(r, true_branch) }
118
128
 
119
129
  refs.all? { |r| within?(r, if_node.condition) || within?(r, true_branch) }
120
130
  end
@@ -69,6 +69,13 @@ module RuboCop
69
69
  # def distance
70
70
  # end
71
71
  # end
72
+ #
73
+ # # good — RSpec example group (class_exec'd onto a generated
74
+ # # example-group class, so the def does NOT land on Object)
75
+ # RSpec.describe Thing do
76
+ # def build_thing
77
+ # end
78
+ # end
72
79
  class NoUnscopedMethodDefinitions < Base
73
80
  MSG = 'Define methods inside an explicit `module` or `class`, not at the top level ' \
74
81
  'or inside a DSL block (e.g. Rake `namespace`). ' \
@@ -78,6 +85,14 @@ module RuboCop
78
85
  # new class exactly like Struct.
79
86
  DEFAULT_SAFE_DSL_RECEIVERS = %w[Struct Data Class Module].freeze
80
87
 
88
+ # RSpec example-group blocks are class_exec'd onto a generated
89
+ # example-group class, so a `def` inside them lands on that class —
90
+ # not Object. Method-specific (unlike the receiver allowlist above)
91
+ # because a def inside e.g. `RSpec.configure` DOES land on Object.
92
+ RSPEC_EXAMPLE_GROUP_METHODS = %i[
93
+ describe shared_examples shared_examples_for shared_context
94
+ ].freeze
95
+
81
96
  def on_def(node)
82
97
  add_offense(node.loc.keyword) unless enclosed_in_class_or_module?(node)
83
98
  end
@@ -110,12 +125,18 @@ module RuboCop
110
125
  receiver = send_node.receiver
111
126
 
112
127
  return false if receiver.nil?
128
+ return true if rspec_example_group_block?(send_node, receiver)
113
129
 
114
130
  safe_receivers.any? do |safe|
115
131
  receiver_matches?(receiver, safe)
116
132
  end
117
133
  end
118
134
 
135
+ def rspec_example_group_block?(send_node, receiver)
136
+ receiver_matches?(receiver, 'RSpec') &&
137
+ RSPEC_EXAMPLE_GROUP_METHODS.include?(send_node.method_name)
138
+ end
139
+
119
140
  def receiver_matches?(receiver, safe_name)
120
141
  # Handles `Struct`, `Class`, `Module` (const nodes)
121
142
  receiver.const_type? && receiver.short_name.to_s == safe_name