rubocop-dev_doc 0.1.0 → 0.3.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +318 -33
  3. data/lib/dev_doc/test/best_practice_lints.rb +31 -0
  4. data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
  5. data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
  6. data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
  7. data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
  8. data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
  9. data/lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb +92 -0
  10. data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +86 -0
  11. data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +68 -13
  12. data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
  13. data/lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb +18 -3
  14. data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
  15. data/lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb +53 -0
  16. data/lib/rubocop/cop/dev_doc/migration/require_primary_key.rb +55 -0
  17. data/lib/rubocop/cop/dev_doc/migration/require_timestamps.rb +4 -13
  18. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +56 -0
  19. data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +135 -0
  20. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
  21. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
  22. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +83 -0
  23. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
  24. data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
  25. data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
  26. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
  27. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
  28. data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +29 -15
  29. data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +56 -22
  30. data/lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb +102 -0
  31. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +42 -10
  32. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
  33. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
  34. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
  35. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
  36. data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
  37. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
  38. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
  39. data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
  40. data/lib/rubocop/dev_doc/version.rb +1 -1
  41. data/lib/rubocop-dev_doc.rb +1 -0
  42. metadata +73 -10
  43. data/lib/rubocop/cop/dev_doc/migration/avoid_update_column.rb +0 -53
@@ -0,0 +1,102 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Avoid `**options`-style kwargs in method signatures; use explicit keyword args.
6
+ #
7
+ # ## Rationale
8
+ # Keyword args raise `ArgumentError` on typos, are self-labeled at the
9
+ # call site, and get IDE autocomplete. Options hashes (via `**opts`)
10
+ # silently swallow misspelled keys, hide what's accepted, and depend on
11
+ # doc/source-reading to use correctly.
12
+ #
13
+ # ❌ Options hash — typos pass silently
14
+ # def configure(name:, **options)
15
+ # title = options[:title]
16
+ # color = options[:color]
17
+ # end
18
+ # configure(name: 'x', titel: 'wrong') # silently ignored — title is nil
19
+ #
20
+ # ✔️ Keyword args — typo raises immediately
21
+ # def configure(name:, title: nil, color: nil)
22
+ # end
23
+ # configure(name: 'x', titel: 'wrong') # ArgumentError: unknown keyword: :titel
24
+ #
25
+ # Pure-forwarding kwargs are exempt — when the only use of the kwrestarg
26
+ # is to splat it into another call, there is no options-hash behaviour:
27
+ #
28
+ # ✔️ Pure forwarding — exempt
29
+ # def foo(**args)
30
+ # other(**args)
31
+ # end
32
+ #
33
+ # Anonymous double-splat (`**`) is also always exempt.
34
+ #
35
+ # @example
36
+ # # bad
37
+ # def configure(name:, **options)
38
+ # options[:title]
39
+ # end
40
+ #
41
+ # # good
42
+ # def configure(name:, title: nil, color: nil)
43
+ # end
44
+ #
45
+ # # good (pure forwarding)
46
+ # def foo(**args)
47
+ # other(**args)
48
+ # end
49
+ class AvoidOptionsHash < Base
50
+ MSG = 'Use keyword arguments instead of `**%<name>s` — ' \
51
+ 'typos in keyword args raise `ArgumentError`; options hashes swallow them silently.'.freeze
52
+
53
+ def on_def(node)
54
+ check_method(node)
55
+ end
56
+ alias on_defs on_def
57
+
58
+ private
59
+
60
+ def check_method(node)
61
+ kwrestarg = find_kwrestarg(node)
62
+ return unless kwrestarg
63
+
64
+ kwrest_name = kwrestarg.node_parts[0]
65
+ return if kwrest_name.nil?
66
+
67
+ body = node.body
68
+ return if pure_forwarding?(body, kwrest_name)
69
+
70
+ add_offense(kwrestarg, message: format(MSG, name: kwrest_name))
71
+ end
72
+
73
+ def find_kwrestarg(node)
74
+ args_node = node.arguments
75
+ args_node.each_child_node(:kwrestarg).first
76
+ end
77
+
78
+ def pure_forwarding?(body, kwrest_name)
79
+ return true if body.nil?
80
+
81
+ lvar_refs = collect_lvar_refs(body, kwrest_name)
82
+ return true if lvar_refs.empty?
83
+
84
+ lvar_refs.all? { |lvar| under_kwsplat?(lvar) }
85
+ end
86
+
87
+ def collect_lvar_refs(node, name)
88
+ refs = []
89
+ node.each_descendant(:lvar) do |lvar|
90
+ refs << lvar if lvar.node_parts[0] == name
91
+ end
92
+ refs
93
+ end
94
+
95
+ def under_kwsplat?(node)
96
+ node.parent&.kwsplat_type?
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -2,14 +2,16 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Style
5
- # Avoid `send` and `public_send` with an explicit receiver.
5
+ # Avoid dynamic `send` and `public_send` with an explicit receiver.
6
6
  #
7
7
  # ## Rationale
8
8
  # `send()` can call *any* method, including destructive ones like
9
- # `destroy`. When the method name is dynamic, this is a real risk a
10
- # crafted parameter can invoke methods the developer never intended to
11
- # expose. If `send()` is unavoidable, add safeguards to restrict which
12
- # methods can be called.
9
+ # `destroy`. The risk is specifically with **dynamic** method names
10
+ # when the argument is a variable or interpolated string, a crafted
11
+ # value could invoke methods the developer never intended to expose.
12
+ #
13
+ # **Literal symbol arguments are exempt** — the method name is fixed at
14
+ # code-write time and visible to reviewers, equivalent to a direct call.
13
15
  #
14
16
  # ## Safer alternatives
15
17
  #
@@ -34,26 +36,56 @@ module RuboCop
34
36
  # ✔️ Restricted — only methods with the prefix can be called
35
37
  # obj.send("export_#{method_name}")
36
38
  #
37
- # **c) For known methods call directly instead of via `send`.**
39
+ # NOTE: A prefix narrows the callable surface but does not eliminate it —
40
+ # an attacker-controlled suffix can still reach any method sharing the
41
+ # prefix (e.g. `"export_#{x}"` could hit `export_and_destroy`). Use the
42
+ # narrowest prefix that fits, and prefer an explicit whitelist when the
43
+ # set of targets is small.
38
44
  #
39
45
  # @example
40
- # # bad
46
+ # # bad — dynamic method name from a variable
41
47
  # @user.send(method_name)
42
48
  # obj.public_send(action)
43
49
  #
44
- # # good
50
+ # # bad — interpolation with no static prefix restricts nothing
51
+ # obj.send("#{x}")
52
+ # obj.send("#{x}_run")
53
+ #
54
+ # # good — literal symbol: method name is statically visible
55
+ # instance.send(:private_helper, arg)
56
+ #
57
+ # # good — bracket notation for model attributes
45
58
  # @user[attribute_name]
59
+ #
60
+ # # good — static prefix restricts the callable methods
46
61
  # obj.send("export_#{method_name}")
47
62
  class AvoidSend < Base
48
- MSG = 'Avoid `%<method>s` with an explicit receiver. ' \
49
- 'Use bracket notation for model attributes, or restrict callable methods with a prefix.'.freeze
63
+ MSG = "Avoid dynamic `%<method>s` use bracket notation for model attributes, " \
64
+ "or a prefix (`obj.send(\"export_\#{x}\")`) to restrict callable methods.".freeze
50
65
  RESTRICT_ON_SEND = %i[send public_send].freeze
51
66
 
52
67
  def on_send(node)
53
68
  return if node.receiver.nil?
54
69
 
70
+ arg = node.first_argument
71
+ return if arg&.sym_type?
72
+ return if prefixed_dynamic_method?(arg)
73
+
55
74
  add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
56
75
  end
76
+
77
+ private
78
+
79
+ # A dynamic string/symbol that begins with a static prefix (e.g.
80
+ # `"export_#{x}"`) restricts the callable surface to methods sharing
81
+ # that prefix, so it is exempt. Pure interpolation (`"#{x}"`) or a
82
+ # trailing prefix (`"#{x}_run"`) restricts nothing and is still flagged.
83
+ def prefixed_dynamic_method?(arg)
84
+ return false unless arg&.type?(:dstr, :dsym)
85
+
86
+ first = arg.children.first
87
+ first&.str_type? && !first.value.empty?
88
+ end
57
89
  end
58
90
  end
59
91
  end
@@ -0,0 +1,158 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Assign a variable inside the `if` condition that guards it, so the
6
+ # variable's scope is the branch that actually uses it.
7
+ #
8
+ # ## Rationale
9
+ # When a local is assigned and then immediately gated by a truthiness
10
+ # check, hoisting the assignment to its own line widens its scope to the
11
+ # whole method and separates the binding from the guard. Folding the
12
+ # assignment into the condition (with parentheses) keeps the variable
13
+ # local to the branch that uses it and reads as one thought.
14
+ #
15
+ # ❌
16
+ # token = params[:token]
17
+ # if token
18
+ # authenticate(token)
19
+ # end
20
+ #
21
+ # ✔️
22
+ # if (token = params[:token])
23
+ # authenticate(token)
24
+ # end
25
+ #
26
+ # Parentheses around the assignment silence Ruby's "assignment in
27
+ # condition" warning and signal the assignment is intentional.
28
+ #
29
+ # ## When it fires
30
+ # Only when the assigned variable is used **only** inside the guarding
31
+ # `if` — its condition plus the true branch — and is read at least once
32
+ # in that branch. If the variable is read in the `else` branch or after
33
+ # the block, folding wouldn't narrow its scope, so the cop leaves it.
34
+ #
35
+ # ## Exception
36
+ # When the assigned expression is long, inlining it into the condition
37
+ # hurts readability more than the scope-narrowing helps. Keep the
38
+ # two-line form and inline-`disable` with a reason.
39
+ #
40
+ # NOTE: Conservative by design — it skips reassigned variables, `op_asgn`
41
+ # (`+=`, `||=`), compound/comparison conditions, and scopes containing a
42
+ # nested `def` or a block that rebinds the same name. Those are left
43
+ # un-flagged rather than risk a wrong rewrite.
44
+ #
45
+ # @example
46
+ # # bad
47
+ # user = account.users.find_by(id: params[:id])
48
+ # if user
49
+ # redirect_to user
50
+ # end
51
+ #
52
+ # # good
53
+ # if (user = account.users.find_by(id: params[:id]))
54
+ # redirect_to user
55
+ # end
56
+ class MinimizeVariableScope < Base
57
+ MSG = "Assign `%<name>s` inside the `if` condition " \
58
+ "(`if (%<name>s = ...)`) so its scope is the branch that uses it.".freeze
59
+
60
+ # Truthiness-shaped predicates we fold. Comparisons (`x == 1`) are
61
+ # deliberately excluded — folding those reads as assignment-in-condition.
62
+ TRUTHY_PREDICATES = %i[present? any? presence].freeze
63
+
64
+ def on_lvasgn(node)
65
+ name, value = *node
66
+ return unless value
67
+
68
+ if_node = guarding_if(node)
69
+ return unless if_node
70
+ return unless truthiness_check?(if_node.condition, name)
71
+ return unless confined_to_if?(node, if_node, name)
72
+
73
+ add_offense(node.loc.name, message: format(MSG, name: name))
74
+ end
75
+
76
+ private
77
+
78
+ # The `if` immediately following this assignment as the next statement
79
+ # in the same body (not a ternary or modifier-if).
80
+ def guarding_if(asgn)
81
+ parent = asgn.parent
82
+ return unless parent&.begin_type?
83
+
84
+ siblings = parent.children
85
+ nxt = siblings[siblings.index(asgn) + 1]
86
+ nxt if nxt&.if_type? && !nxt.ternary? && !nxt.modifier_form? && nxt.if?
87
+ end
88
+
89
+ # `if x` or `if x.present?` — a truthiness check on the assigned var.
90
+ def truthiness_check?(condition, name)
91
+ return false unless condition
92
+
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
101
+ end
102
+
103
+ # Every read of `name` in the enclosing scope sits inside the if's
104
+ # condition or true branch, it's read at least once in that branch,
105
+ # and nothing reassigns or rebinds the name.
106
+ def confined_to_if?(asgn, if_node, name)
107
+ scope = enclosing_scope(asgn)
108
+ return false unless scope
109
+ return false unless single_plain_assignment?(scope, name, asgn)
110
+ return false if rebinds_name?(scope, name)
111
+
112
+ true_branch = if_node.if_branch
113
+ 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?
118
+
119
+ refs.all? { |r| within?(r, if_node.condition) || within?(r, true_branch) }
120
+ end
121
+
122
+ def enclosing_scope(node)
123
+ node.each_ancestor(:def, :defs, :block, :numblock, :itblock).first
124
+ end
125
+
126
+ def reads(scope, name)
127
+ scope.each_descendant(:lvar).select { |n| n.children.first == name }
128
+ end
129
+
130
+ # Exactly one `name = ...` (our node) and no `op_asgn`/`or_asgn`/
131
+ # `and_asgn` touching it.
132
+ def single_plain_assignment?(scope, name, asgn)
133
+ lvasgns = scope.each_descendant(:lvasgn).select { |n| n.children.first == name }
134
+ return false unless lvasgns.size == 1 && lvasgns.first.equal?(asgn)
135
+
136
+ scope.each_descendant(:op_asgn, :or_asgn, :and_asgn).none? do |n|
137
+ target = n.children.first
138
+ target.respond_to?(:children) && target.children.first == name
139
+ end
140
+ end
141
+
142
+ # A nested `def`/`defs` (separate scope) or a block re-binding `name`
143
+ # would make the read analysis unreliable — bail.
144
+ def rebinds_name?(scope, name)
145
+ scope.each_descendant(:def, :defs).any? ||
146
+ scope.each_descendant(:block).any? do |blk|
147
+ blk.arguments.any? { |a| a.respond_to?(:name) && a.name == name }
148
+ end
149
+ end
150
+
151
+ def within?(node, container)
152
+ node.equal?(container) || node.each_ancestor.any? { |a| a.equal?(container) }
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,129 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Flag `def` / `define_method` whose enclosing scope chain does not
6
+ # include a `class` or `module` body — the method lands on `Object`.
7
+ #
8
+ # ## Rationale
9
+ # A `def` not inside an explicit `class` or `module` body defines a
10
+ # method on `Object`, even when it visually looks scoped. The most common
11
+ # failure mode is inside Rake's `namespace` block:
12
+ #
13
+ # ❌ Two rake files both define `build_load_plan` — whichever file
14
+ # loads second silently wins. Tests for one task call the other
15
+ # task's helper logic without warning.
16
+ # namespace :faqs do
17
+ # def build_load_plan(fixtures, by_slug)
18
+ # ...
19
+ # end
20
+ # end
21
+ #
22
+ # ✔️ Wrapped in a real Ruby scope — no collision risk.
23
+ # module FaqsLoader
24
+ # extend self
25
+ #
26
+ # def build_load_plan(fixtures, by_slug)
27
+ # ...
28
+ # end
29
+ # end
30
+ #
31
+ # Core's `Style/TopLevelMethodDefinition` catches literal top-level
32
+ # `def` (not inside any block) but misses the rake `namespace` pattern
33
+ # because the `def` is technically inside a `block` node. This cop
34
+ # subsumes that case — disable `Style/TopLevelMethodDefinition` when
35
+ # this cop is enabled to avoid double-flagging.
36
+ #
37
+ # ## Allowlist
38
+ # Some DSLs legitimately define methods inside blocks where the block's
39
+ # receiver is not `Object` (`Struct.new`, `Class.new`, `Module.new`).
40
+ # Configure `SafeDSLReceivers` to extend the allowlist.
41
+ #
42
+ # @example
43
+ # # bad — literal top-level def
44
+ # def helper_method
45
+ # end
46
+ #
47
+ # # bad — inside a rake namespace (lands on Object)
48
+ # namespace :faqs do
49
+ # def build_load_plan(fixtures, by_slug)
50
+ # end
51
+ # end
52
+ #
53
+ # # good — inside an explicit module
54
+ # module FaqsLoader
55
+ # extend self
56
+ #
57
+ # def build_load_plan(fixtures, by_slug)
58
+ # end
59
+ # end
60
+ #
61
+ # # good — inside an explicit class
62
+ # class FaqsImporter
63
+ # def import
64
+ # end
65
+ # end
66
+ #
67
+ # # good — Struct.new block (allowlisted by default)
68
+ # Point = Struct.new(:x, :y) do
69
+ # def distance
70
+ # end
71
+ # end
72
+ class NoUnscopedMethodDefinitions < Base
73
+ MSG = 'Define methods inside an explicit `module` or `class`, not at the top level ' \
74
+ 'or inside a DSL block (e.g. Rake `namespace`). ' \
75
+ 'Methods defined here land on `Object` and can silently collide across files.'.freeze
76
+
77
+ DEFAULT_SAFE_DSL_RECEIVERS = %w[Struct Class Module].freeze
78
+
79
+ def on_def(node)
80
+ add_offense(node.loc.keyword) unless enclosed_in_class_or_module?(node)
81
+ end
82
+ alias on_defs on_def
83
+
84
+ def on_send(node)
85
+ return unless node.method_name == :define_method
86
+ return if enclosed_in_class_or_module?(node)
87
+
88
+ add_offense(node.loc.selector)
89
+ end
90
+
91
+ private
92
+
93
+ def enclosed_in_class_or_module?(node)
94
+ node.each_ancestor.any? do |ancestor|
95
+ next true if ancestor.class_type? || ancestor.module_type?
96
+ next true if safe_dsl_block?(ancestor)
97
+
98
+ false
99
+ end
100
+ end
101
+
102
+ # A `block` node is safe when its method call receiver is an
103
+ # allowlisted DSL (Struct.new, Class.new, Module.new, etc.)
104
+ def safe_dsl_block?(node)
105
+ return false unless node.block_type?
106
+
107
+ send_node = node.send_node
108
+ receiver = send_node.receiver
109
+
110
+ return false if receiver.nil?
111
+
112
+ safe_receivers.any? do |safe|
113
+ receiver_matches?(receiver, safe)
114
+ end
115
+ end
116
+
117
+ def receiver_matches?(receiver, safe_name)
118
+ # Handles `Struct`, `Class`, `Module` (const nodes)
119
+ receiver.const_type? && receiver.short_name.to_s == safe_name
120
+ end
121
+
122
+ def safe_receivers
123
+ DEFAULT_SAFE_DSL_RECEIVERS + Array(cop_config.fetch('SafeDSLReceivers', []))
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,150 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Style
5
+ # Avoid reading `obj[key]` more than once with the same receiver and
6
+ # same key in a single method body.
7
+ #
8
+ # ## Rationale
9
+ # When the same bracket read appears in multiple places, two distinct
10
+ # failure modes open up:
11
+ #
12
+ # 1. **Silent typos.** Bracket access returns nil for missing keys;
13
+ # nothing raises. Two occurrences of `params[:status]` keep the
14
+ # spelling in sync, but if one of them silently becomes
15
+ # `params[:stutus]`, the line returns nil and the bug ships. A
16
+ # single assignment fixes the spelling in exactly one place — a
17
+ # typo there becomes a `NameError`, not a silent nil.
18
+ #
19
+ # 2. **Reader ambiguity and mutation risk.** The reader has to verify
20
+ # every occurrence really is the same key and that nothing in
21
+ # between mutates the hash. Assigning once makes the value a
22
+ # stable named thing — and on receivers like `session` or shared
23
+ # hashes, it also avoids a real TOCTOU shape where the value can
24
+ # change between the guard and the use.
25
+ #
26
+ # ❌
27
+ # def show
28
+ # @item = Item.lookup_by_slug(params[:slug])
29
+ # redirect_to canonical_url(@item) if @item.slug != params[:slug]
30
+ # end
31
+ #
32
+ # ✔️
33
+ # def show
34
+ # slug = params[:slug]
35
+ # @item = Item.lookup_by_slug(slug)
36
+ # redirect_to canonical_url(@item) if @item.slug != slug
37
+ # end
38
+ #
39
+ # The cop only counts reads. `obj[k] = v` is a write (`:[]=`) and is
40
+ # not compared against bracket reads of the same key.
41
+ #
42
+ # ## Exception
43
+ # Genuine intentional re-reads (e.g. a deliberate second check after
44
+ # a write that may have mutated the receiver) go through inline
45
+ # `# rubocop:disable` with a reason.
46
+ #
47
+ # NOTE: Receiver and key are compared by source text — `hash['foo']`
48
+ # and `hash[:foo]` look different to the cop even though they're the
49
+ # same value on a `HashWithIndifferentAccess` like `params`. That is
50
+ # a known false negative; the cop will miss that shape rather than
51
+ # over-fire.
52
+ #
53
+ # NOTE: Scope is the enclosing `def`/`defs` body, including nested
54
+ # blocks. Block parameters are scoped to their block, so the same name
55
+ # in two sibling blocks (`arr.each { |item| item[:k] }; other.each {
56
+ # |item| item[:k] }`) is correctly treated as two different bindings,
57
+ # not a repeat. Reads of the same block param *within one block* are
58
+ # still flagged. (Numbered/`it` block params are matched textually — a
59
+ # rare residual false positive; inline-disable if it surfaces.)
60
+ #
61
+ # NOTE: Multi-argument bracket calls (`arr[i, len]`, slicing) are
62
+ # ignored. Only single-argument reads participate.
63
+ #
64
+ # @example
65
+ # # bad — same key read twice
66
+ # def show
67
+ # @item = Item.lookup_by_slug(params[:slug])
68
+ # redirect_to canonical_url(@item) if @item.slug != params[:slug]
69
+ # end
70
+ #
71
+ # # bad — guard-then-use re-reads the receiver
72
+ # def session_get(key)
73
+ # return nil unless session[key]
74
+ # session[key].symbolize_keys
75
+ # end
76
+ #
77
+ # # good — assign once
78
+ # def session_get(key)
79
+ # payload = session[key]
80
+ # return nil unless payload
81
+ # payload.symbolize_keys
82
+ # end
83
+ #
84
+ # # good — different keys, no offense
85
+ # def filters
86
+ # @search = params[:search]
87
+ # @status = params[:status]
88
+ # end
89
+ class RepeatedBracketRead < Base
90
+ MSG = "Receiver `%<receiver>s[%<key>s]` already read earlier in this method — assign once and reuse.".freeze
91
+
92
+ def on_def(node)
93
+ check_method(node)
94
+ end
95
+ alias on_defs on_def
96
+
97
+ private
98
+
99
+ def check_method(def_node)
100
+ body = def_node.body
101
+ return unless body
102
+
103
+ seen = {}
104
+ body.each_descendant(:send) do |send_node|
105
+ next unless bracket_read?(send_node)
106
+
107
+ receiver = send_node.receiver
108
+ key = send_node.first_argument
109
+ composite = composite_key(receiver, key)
110
+
111
+ if seen[composite]
112
+ add_offense(send_node.loc.selector, message: format(MSG, receiver: receiver.source, key: key.source))
113
+ else
114
+ seen[composite] = true
115
+ end
116
+ end
117
+ end
118
+
119
+ # Block parameters are scoped to their block: the same name read in two
120
+ # sibling blocks is a *different* binding, not a repeat. Key those reads
121
+ # by their binding block so they don't collide. Method-call / ivar /
122
+ # method-local receivers keep a purely textual key.
123
+ def composite_key(receiver, key_node)
124
+ key = key_node.source
125
+ block = binding_block(receiver)
126
+ block ? "#{block.object_id}|#{receiver.source}|#{key}" : "#{receiver.source}|#{key}"
127
+ end
128
+
129
+ # The nearest enclosing block that binds the receiver as a block
130
+ # argument, or nil when the receiver is not a block-local variable.
131
+ def binding_block(receiver)
132
+ return nil unless receiver.lvar_type?
133
+
134
+ name = receiver.children.first
135
+ receiver.each_ancestor(:block) do |block|
136
+ return block if block.arguments.any? { |arg| arg.respond_to?(:name) && arg.name == name }
137
+ end
138
+ nil
139
+ end
140
+
141
+ def bracket_read?(node)
142
+ node.method_name == :[] &&
143
+ node.receiver &&
144
+ node.arguments.size == 1
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end