rubocop-dev_doc 0.2.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 (28) hide show
  1. checksums.yaml +4 -4
  2. data/config/default.yml +230 -61
  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/avoid_conditional_schema_changes.rb +89 -0
  10. data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
  11. data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +1 -1
  12. data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
  13. data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
  14. data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +2 -2
  15. data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
  16. data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
  17. data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
  18. data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
  19. data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +31 -4
  20. data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
  21. data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
  22. data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
  23. data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
  24. data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
  25. data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
  26. data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
  27. data/lib/rubocop/dev_doc/version.rb +1 -1
  28. metadata +58 -3
@@ -0,0 +1,171 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Route
5
+ # Avoid custom `member` / `collection` actions; model them as RESTful
6
+ # sub-resources instead.
7
+ #
8
+ # ## Rationale
9
+ # Follow Rails' standard REST principles as much as possible. A resource
10
+ # exposes seven standard actions (`index`, `show`, `new`, `create`,
11
+ # `edit`, `update`, `destroy`); anything declared through a `member` or
12
+ # `collection` block is a custom verb bolted onto the resource. Each one
13
+ # is better expressed as its own RESTful sub-resource — the action then
14
+ # reads as a noun being created/destroyed, scopes cleanly under Pundit
15
+ # policies, and keeps every controller a thin CRUD controller.
16
+ #
17
+ # ❌ POST /products/3/activate
18
+ # resources :products, only: [:show] do
19
+ # member { post :activate }
20
+ # end
21
+ #
22
+ # ✔️ POST /products/3/activations (create an activation)
23
+ # resources :products, only: [:show] do
24
+ # resources :activations, only: [:create]
25
+ # end
26
+ #
27
+ # ✔️ a mode on the standard edit/update, when it's really one resource
28
+ # PATCH /products/3?mode=activate
29
+ #
30
+ # A reversible pair (`activate` / `deactivate`, `lock` / `unlock`,
31
+ # `archive` / `restore`) maps naturally onto `create` / `destroy` of one
32
+ # sub-resource.
33
+ #
34
+ # ## Exception
35
+ # The doc says "as much as possible" — some custom actions are genuinely
36
+ # awkward to model as a sub-resource (a multi-step wizard step, a
37
+ # non-CRUD report endpoint). For those, disable the cop on the line with
38
+ # a written reason, e.g.:
39
+ #
40
+ # member do
41
+ # # rubocop:disable DevDoc/Route/NoCustomActions
42
+ # get :balance # multi-step finalize wizard; not a persisted resource
43
+ # # rubocop:enable DevDoc/Route/NoCustomActions
44
+ # end
45
+ #
46
+ # This cop flags every form a custom resource action takes:
47
+ # - inside a `member` / `collection` block
48
+ # - the inline `on: :member` / `on: :collection` option
49
+ # (`get :activate, on: :member`)
50
+ # - a bare verb directly inside a `resources` / `resource` block,
51
+ # which Rails treats as a collection route (`resources :x do
52
+ # get :search end`)
53
+ # - a bare verb inside a `concern` block (its routes are mixed into
54
+ # resources, so a custom verb there is a custom resource action)
55
+ #
56
+ # `constraints` / `defaults` wrappers between the verb and its resource
57
+ # are transparent — a custom action nested inside them is still caught.
58
+ #
59
+ # NOTE: Stand-alone non-resource routes (`get 'sitemap.xml'`,
60
+ # `get 'proxy'`) and verbs scoped by a `namespace` / `scope` block are
61
+ # not resourceful, and are intentionally left alone. A custom action
62
+ # written as a flat top-level route (`get 'photos/search', to:
63
+ # 'photos#search'`) is indistinguishable from such a route and cannot be
64
+ # flagged without false positives — that case is left to review.
65
+ #
66
+ # @example
67
+ # # bad
68
+ # resources :products, only: [:show] do
69
+ # member do
70
+ # post :activate
71
+ # post :deactivate
72
+ # end
73
+ # end
74
+ #
75
+ # # good
76
+ # resources :products, only: [:show] do
77
+ # resources :activations, only: %i[create destroy]
78
+ # end
79
+ class NoCustomActions < Base
80
+ MSG = 'Custom `%<context>s` action `%<verb>s %<name>s`. Model it as a RESTful ' \
81
+ 'sub-resource (e.g. `resources :activations, only: [:create]`) instead. ' \
82
+ 'Disable with a reason if a custom action is genuinely unavoidable.'.freeze
83
+
84
+ RESTRICT_ON_SEND = %i[get post patch put delete match].freeze
85
+
86
+ MEMBER_OR_COLLECTION = %i[member collection].freeze
87
+ RESOURCEFUL = %i[resources resource].freeze
88
+ # Wrappers that add conditions but do not change whether the verb is a
89
+ # resource action — walk through them to find the real context.
90
+ TRANSPARENT_WRAPPERS = %i[constraints defaults].freeze
91
+
92
+ def on_send(node)
93
+ context = routing_context(node)
94
+ return unless context
95
+
96
+ add_offense(
97
+ node.loc.selector,
98
+ message: format(MSG, context: context, verb: node.method_name, name: action_name(node))
99
+ )
100
+ end
101
+
102
+ private
103
+
104
+ # The routing context that makes this verb a custom resource action,
105
+ # or nil if it is a stand-alone route. Shapes that count:
106
+ # - inside a `member` / `collection` block (at any depth)
107
+ # - the inline `on: :member` / `on: :collection` / `on: :new` option
108
+ # - a bare verb whose nearest non-transparent enclosing block is
109
+ # `resources` / `resource` (a collection route) or `concern`
110
+ # `constraints` / `defaults` wrappers are transparent (walked through);
111
+ # a `namespace` / `scope` block turns the verb into a stand-alone route
112
+ # and stops the search.
113
+ def routing_context(node)
114
+ inline = inline_on_context(node)
115
+ return inline if inline
116
+
117
+ sends = enclosing_routing_sends(node)
118
+
119
+ # `member` / `collection` mark a custom action at any nesting depth.
120
+ explicit = sends.find { |s| MEMBER_OR_COLLECTION.include?(s.method_name) }
121
+ return explicit.method_name.to_s if explicit
122
+
123
+ # Otherwise the nearest block that isn't a transparent wrapper decides.
124
+ decider = sends.find { |s| !TRANSPARENT_WRAPPERS.include?(s.method_name) }
125
+ return unless decider
126
+
127
+ return 'collection' if RESOURCEFUL.include?(decider.method_name)
128
+
129
+ 'concern' if decider.method_name == :concern
130
+ end
131
+
132
+ # The block call-sends enclosing this node, innermost first, limited to
133
+ # DSL blocks with no explicit receiver. Covers `do...end`, `{}`, and
134
+ # numbered (`_1`) / `it` block forms.
135
+ def enclosing_routing_sends(node)
136
+ node.each_ancestor(:block, :numblock, :itblock).filter_map do |b|
137
+ send = b.children.first
138
+ send if send.send_type? && send.receiver.nil?
139
+ end
140
+ end
141
+
142
+ # The value of an inline `on:` option (`:member` / `:collection` /
143
+ # `:new`) as a string, or nil when absent. `on:` is only ever used to
144
+ # attach a custom action to a resource.
145
+ def inline_on_context(node)
146
+ options = node.arguments.find(&:hash_type?)
147
+ return unless options
148
+
149
+ pair = options.pairs.find { |p| p.key.sym_type? && p.key.value == :on }
150
+ return unless pair
151
+
152
+ pair.value.sym_type? ? pair.value.value.to_s : 'member'
153
+ end
154
+
155
+ # Display name for the action: the leading symbol/string argument
156
+ # (`:activate`, `'weeks/:start_date'`), or `?` when it can't be read.
157
+ def action_name(node)
158
+ arg = node.first_argument
159
+ return '?' unless arg
160
+
161
+ case arg.type
162
+ when :sym then ":#{arg.value}"
163
+ when :str then "'#{arg.value}'"
164
+ else '?'
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,77 @@
1
+ require 'active_support/inflector'
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module DevDoc
6
+ module Route
7
+ # Use a plural name for `resources` and a singular name for `resource`.
8
+ #
9
+ # ## Rationale
10
+ # Follow Rails' standard REST conventions. A plural `resources` maps to a
11
+ # collection (`/products`), so its name should be plural; a singular
12
+ # `resource` maps to a single implicit-id resource (`/profile`), so its
13
+ # name should be singular. Mismatched number reads against every Rails
14
+ # convention and produces awkward path/helper names.
15
+ #
16
+ # ❌ resources :product # => /product
17
+ # ❌ resource :sessions # singular resource, plural name
18
+ #
19
+ # ✔️ resources :products # => /products
20
+ # ✔️ resource :session # => /session
21
+ #
22
+ # The check uses ActiveSupport's inflector, so irregular and uncountable
23
+ # nouns are handled (`resources :people`, `resources :fish` are fine).
24
+ #
25
+ # ## Exception
26
+ # A project with custom inflections (`config/initializers/inflections.rb`)
27
+ # the bundled inflector doesn't know about may be misjudged. Disable the
28
+ # cop on that line with a reason.
29
+ #
30
+ # @example
31
+ # # bad
32
+ # resources :product
33
+ # resource :sessions
34
+ #
35
+ # # good
36
+ # resources :products
37
+ # resource :session
38
+ class ResourceNameNumber < Base
39
+ MSG = '`%<method>s` should name a %<number>s resource — use `:%<expected>s`.'.freeze
40
+
41
+ RESTRICT_ON_SEND = %i[resources resource].freeze
42
+
43
+ def on_send(node)
44
+ node.arguments.each do |arg|
45
+ name = name_of(arg)
46
+ next unless name
47
+
48
+ expected = expected_name(node.method_name, name)
49
+ next if expected == name
50
+
51
+ add_offense(
52
+ arg,
53
+ message: format(MSG, method: node.method_name, number: number_word(node.method_name), expected: expected)
54
+ )
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ # The resource name as a string, for symbol or string arguments only
61
+ # (skips the options hash, blocks, etc.).
62
+ def name_of(arg)
63
+ arg.value.to_s if arg.sym_type? || arg.str_type?
64
+ end
65
+
66
+ def expected_name(method, name)
67
+ method == :resources ? ActiveSupport::Inflector.pluralize(name) : ActiveSupport::Inflector.singularize(name)
68
+ end
69
+
70
+ def number_word(method)
71
+ method == :resources ? 'plural' : 'singular'
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -36,17 +36,28 @@ module RuboCop
36
36
  # ✔️ Restricted — only methods with the prefix can be called
37
37
  # obj.send("export_#{method_name}")
38
38
  #
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.
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
- # obj.send("export_#{x}")
49
+ #
50
+ # # bad — interpolation with no static prefix restricts nothing
51
+ # obj.send("#{x}")
52
+ # obj.send("#{x}_run")
44
53
  #
45
54
  # # good — literal symbol: method name is statically visible
46
55
  # instance.send(:private_helper, arg)
47
56
  #
48
- # # good
57
+ # # good — bracket notation for model attributes
49
58
  # @user[attribute_name]
59
+ #
60
+ # # good — static prefix restricts the callable methods
50
61
  # obj.send("export_#{method_name}")
51
62
  class AvoidSend < Base
52
63
  MSG = "Avoid dynamic `%<method>s` — use bracket notation for model attributes, " \
@@ -55,10 +66,26 @@ module RuboCop
55
66
 
56
67
  def on_send(node)
57
68
  return if node.receiver.nil?
58
- return if node.first_argument&.sym_type?
69
+
70
+ arg = node.first_argument
71
+ return if arg&.sym_type?
72
+ return if prefixed_dynamic_method?(arg)
59
73
 
60
74
  add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
61
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
62
89
  end
63
90
  end
64
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