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
@@ -27,11 +27,12 @@ module RuboCop
27
27
  # OrganizationMailer.with(organization: organization).deliver_later
28
28
  # end
29
29
  #
30
- # ## Watch out for indirect calls
30
+ # ## Configurable blocklist for library wrappers
31
31
  # Some libraries call `perform_later` / `deliver_later` behind the
32
- # scenes e.g. `@user.send_verification_email!` from the Devise gem.
33
- # This cop cannot detect those wrappers; reviewers should still flag
34
- # them when they appear inside a `transaction` block.
32
+ # scenes. Configure `KnownAsyncWrappers` to flag those methods too.
33
+ # Common Devise methods are included by default. This is a partial
34
+ # mitigation reviewers must still catch unknown wrappers not in the
35
+ # list.
35
36
  #
36
37
  # @example
37
38
  # # bad
@@ -40,6 +41,12 @@ module RuboCop
40
41
  # OrganizationMailer.with(organization: organization).deliver_later
41
42
  # end
42
43
  #
44
+ # # bad (Devise wrapper, caught via KnownAsyncWrappers)
45
+ # User.transaction do
46
+ # @user.save!
47
+ # @user.send_verification_email!
48
+ # end
49
+ #
43
50
  # # good
44
51
  # organization.transaction do
45
52
  # organization.save!
@@ -47,16 +54,26 @@ module RuboCop
47
54
  # OrganizationMailer.with(organization: organization).deliver_later
48
55
  class NoDeliverLaterInTransaction < Base
49
56
  MSG = '`%<method>s` inside a `transaction` block may use stale data. Move it outside the transaction.'.freeze
50
- RESTRICT_ON_SEND = %i[deliver_later perform_later].freeze
57
+
58
+ CORE_METHODS = %i[deliver_later perform_later].freeze
51
59
 
52
60
  def on_send(node)
53
61
  return unless inside_transaction?(node)
62
+ return unless tracked_method?(node.method_name)
54
63
 
55
64
  add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
56
65
  end
57
66
 
58
67
  private
59
68
 
69
+ def tracked_method?(name)
70
+ CORE_METHODS.include?(name) || known_async_wrappers.include?(name.to_s)
71
+ end
72
+
73
+ def known_async_wrappers
74
+ cop_config.fetch('KnownAsyncWrappers', [])
75
+ end
76
+
60
77
  def inside_transaction?(node)
61
78
  node.each_ancestor(:block).any? do |ancestor|
62
79
  ancestor.method_name == :transaction
@@ -0,0 +1,137 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Rails
5
+ # Flag `params.require(:foo).permit(...)` and the reverse form
6
+ # `params.permit(foo: ...).require(:foo)` — use `params.expect(foo: [...])`
7
+ # instead.
8
+ #
9
+ # ## Rationale
10
+ # The upstream `Rails/StrongParametersExpect` autocorrects two distinct
11
+ # patterns: the hash-form rewrite (`require.permit` → `expect`) and the
12
+ # scalar form (`params[:id]` inside find-method chains). The scalar form
13
+ # fires false positives on optional query params (e.g.
14
+ # `params[:status]&.to_sym || :draft`) and forces scattered per-line
15
+ # disables — the typical workaround is to disable the upstream cop
16
+ # entirely, losing the hash-form benefit too.
17
+ #
18
+ # This cop targets **only** the hash-form rewrite, so projects can keep
19
+ # `Rails/StrongParametersExpect: Enabled: false` and still enforce the
20
+ # safe `params.expect` pattern.
21
+ #
22
+ # `params.expect` raises `ActionController::ParameterMissing` for scalar
23
+ # values where `permit` would silently return `nil`, and it makes the
24
+ # permitted-attribute shape explicit in one call.
25
+ #
26
+ # ## Patterns detected
27
+ #
28
+ # ❌ require → permit chain
29
+ # params.require(:user).permit(:name, :email)
30
+ #
31
+ # ❌ permit → require chain (less common)
32
+ # params.permit(user: %i[name email]).require(:user)
33
+ #
34
+ # ✔️
35
+ # params.expect(user: [:name, :email])
36
+ #
37
+ # ## Not flagged
38
+ # Scalar `params[:foo]` in any context — leave that to per-project
39
+ # decision or the upstream cop.
40
+ #
41
+ # @example
42
+ # # bad
43
+ # params.require(:user).permit(:name, :email)
44
+ #
45
+ # # bad
46
+ # params.require(:user).permit(:name, profile_attributes: [:bio])
47
+ #
48
+ # # bad
49
+ # params.permit(user: %i[name email]).require(:user)
50
+ #
51
+ # # good
52
+ # params.expect(user: [:name, :email])
53
+ #
54
+ # # good
55
+ # params.expect(user: [:name, { profile_attributes: [:bio] }])
56
+ class StrongParametersExpect < Base
57
+ extend AutoCorrector
58
+
59
+ MSG_REQUIRE_PERMIT = 'Use `params.expect(%<key>s: [...])` instead of ' \
60
+ '`params.require(:%<key>s).permit(...)`.'
61
+ MSG_PERMIT_REQUIRE = 'Use `params.expect(%<key>s: ...)` instead of ' \
62
+ '`params.permit(%<key>s: ...).require(:%<key>s)`.'
63
+
64
+ RESTRICT_ON_SEND = %i[permit require].freeze
65
+
66
+ def on_send(node)
67
+ check_require_permit(node) if node.method_name == :permit
68
+ check_permit_require(node) if node.method_name == :require
69
+ end
70
+
71
+ private
72
+
73
+ # Match: params.require(:foo).permit(...)
74
+ def check_require_permit(permit_node)
75
+ require_node = permit_node.receiver
76
+ return unless require_node&.send_type? && require_node.method_name == :require
77
+ return unless params_receiver?(require_node.receiver)
78
+ return unless require_node.arguments.one? && require_node.first_argument.sym_type?
79
+
80
+ key = require_node.first_argument.value
81
+ add_offense(permit_node, message: format(MSG_REQUIRE_PERMIT, key: key)) do |corrector|
82
+ replacement = build_require_permit_replacement(
83
+ require_node.receiver, key, permit_node.arguments
84
+ )
85
+ corrector.replace(permit_node, replacement)
86
+ end
87
+ end
88
+
89
+ # Match: params.permit(foo: ...).require(:foo)
90
+ def check_permit_require(require_node)
91
+ permit_node = require_node.receiver
92
+ return unless permit_node&.send_type? && permit_node.method_name == :permit
93
+ return unless params_receiver?(permit_node.receiver)
94
+ return unless require_node.arguments.one? && require_node.first_argument.sym_type?
95
+
96
+ key = require_node.first_argument.value
97
+ pair = permit_hash_pair_for_key(permit_node, key)
98
+ return unless pair
99
+
100
+ add_offense(require_node, message: format(MSG_PERMIT_REQUIRE, key: key)) do |corrector|
101
+ params_src = permit_node.receiver.source
102
+ corrector.replace(require_node, "#{params_src}.expect(#{key}: #{pair.value.source})")
103
+ end
104
+ end
105
+
106
+ def params_receiver?(node)
107
+ node&.send_type? && node.method_name == :params && node.receiver.nil?
108
+ end
109
+
110
+ # Find the first hash pair whose key matches `key` in permit's arguments.
111
+ def permit_hash_pair_for_key(permit_node, key)
112
+ permit_node.arguments.each do |arg|
113
+ next unless arg.hash_type?
114
+
115
+ arg.pairs.each do |pair|
116
+ return pair if pair.key.sym_type? && pair.key.value == key
117
+ end
118
+ end
119
+ nil
120
+ end
121
+
122
+ # Build the replacement for `params.require(:key).permit(*args)`.
123
+ # Symbol args stay as-is; hash args are wrapped in `{ }` since they
124
+ # need explicit braces when placed inside an array literal.
125
+ def build_require_permit_replacement(params_node, key, permit_args)
126
+ inner = permit_args.map { |arg| permit_arg_source(arg) }.join(', ')
127
+ "#{params_node.source}.expect(#{key}: [#{inner}])"
128
+ end
129
+
130
+ def permit_arg_source(arg)
131
+ arg.hash_type? ? "{ #{arg.source} }" : arg.source
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -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
@@ -2,7 +2,7 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Route
5
- # Always use `only:` (or `except:`) for `resources` / `resource` in routes.rb.
5
+ # Always use `only:` for `resources` / `resource` in routes.rb.
6
6
  #
7
7
  # ## Rationale
8
8
  # When defining routes in routes.rb, it is important to explicitly
@@ -12,45 +12,59 @@ module RuboCop
12
12
  # exposes routes the application has no controller action for, or
13
13
  # routes that probably should be locked down.
14
14
  #
15
+ # `only:` is preferred over `except:` because it is explicit about
16
+ # what is exposed. `except:` exposes everything *not* in the list,
17
+ # which is easier to misread when the action set changes.
18
+ #
19
+ # Set `RequireOnly: false` to accept both `only:` and `except:`.
20
+ #
15
21
  # ✔️
16
22
  # resources :job_applications, only: [:index, :new, :create]
17
23
  #
18
- # In this example, only three actions are exposed for
19
- # `job_applications`: index, new, and create. This is safer because
20
- # only the needed actions are declared and accessible.
24
+ # @example EnforcedStyle: RequireOnly (default)
25
+ # # bad
26
+ # resources :users
27
+ # resources :users, except: [:destroy]
21
28
  #
22
- # `except:` is also acceptable, but `only:` is preferred because it
23
- # is more explicit about what is being exposed.
29
+ # # good
30
+ # resources :users, only: %i[index show]
24
31
  #
25
- # @example
32
+ # @example EnforcedStyle: RequireOnly: false
26
33
  # # bad
27
34
  # resources :users
28
- # resource :profile
29
35
  #
30
36
  # # good
31
37
  # resources :users, only: %i[index show]
32
- # resource :profile, only: %i[show edit update]
33
38
  # resources :users, except: [:destroy]
34
39
  class ResourcesRequireOnly < Base
35
40
  MSG = 'Specify `only:` or `except:` for `%<method>s :%<name>s` to avoid exposing unintended actions.'.freeze
41
+ MSG_REQUIRE_ONLY = 'Specify `only:` for `%<method>s :%<name>s` ' \
42
+ '(`except:` is allowed only with `RequireOnly: false`).'.freeze
36
43
  RESTRICT_ON_SEND = %i[resources resource].freeze
37
44
 
38
45
  def on_send(node)
39
- return if only_or_except?(node)
46
+ has_only = key_present?(node, :only)
47
+ has_except = key_present?(node, :except)
48
+
49
+ return if has_only
50
+ return if has_except && !require_only?
40
51
 
41
52
  name = node.first_argument&.value || '?'
42
- add_offense(node.loc.selector, message: format(MSG, method: node.method_name, name: name))
53
+ msg = has_except && require_only? ? MSG_REQUIRE_ONLY : MSG
54
+ add_offense(node.loc.selector, message: format(msg, method: node.method_name, name: name))
43
55
  end
44
56
 
45
57
  private
46
58
 
47
- def only_or_except?(node)
59
+ def require_only?
60
+ cop_config.fetch('RequireOnly', true)
61
+ end
62
+
63
+ def key_present?(node, key)
48
64
  options = node.arguments.find(&:hash_type?)
49
65
  return false unless options
50
66
 
51
- options.pairs.any? do |pair|
52
- pair.key.sym_type? && %i[only except].include?(pair.key.value)
53
- end
67
+ options.pairs.any? { |pair| pair.key.sym_type? && pair.key.value == key }
54
68
  end
55
69
  end
56
70
  end
@@ -2,17 +2,18 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Style
5
- # Avoid `head()` responses in controllers.
5
+ # Avoid `head()` with error status codes in controllers.
6
6
  #
7
7
  # ## Rationale
8
- # `head()` returns an empty body with no useful information for the
9
- # client. Its presence is usually a sign that error handling should be
10
- # delegated to Rails exceptions (e.g. `ActiveRecord::RecordNotFound`)
11
- # or model validations instead.
8
+ # Using `head()` for error responses returns an empty body with no
9
+ # useful information for the client. Error handling should be delegated
10
+ # to Rails exceptions (e.g. `ActiveRecord::RecordNotFound`) or model
11
+ # validations instead, which give the client more context.
12
12
  #
13
- # If you find yourself reaching for `head()`, consider whether a
14
- # well-known Rails exception or a model validation can handle the
15
- # case more cleanly:
13
+ # Success statuses like `:ok`, `:no_content`, and `:accepted` are
14
+ # legitimate uses of `head()` and are not flagged.
15
+ #
16
+ # The set of flagged statuses is configurable via `FlaggedStatuses:`.
16
17
  #
17
18
  # ❌ Manually returns 404 with no body
18
19
  # def show
@@ -25,30 +26,63 @@ module RuboCop
25
26
  # @user = User.find(params[:id])
26
27
  # end
27
28
  #
28
- # NOTE: The cop flags every bare `head(...)` call. Some legitimate uses
29
- # (e.g. `head :no_content` for a successful DELETE, or simple webhook
30
- # acknowledgements) still get flagged — disable per-line in those cases.
29
+ # ✔️ Success response empty body is correct here
30
+ # def destroy
31
+ # @resource.destroy!
32
+ # head :no_content
33
+ # end
31
34
  #
32
35
  # @example
33
36
  # # bad
34
- # def glib_load_resource
35
- # @user = User.find_by(id: params[:id])
36
- # head(:not_found) unless @user
37
- # end
37
+ # head(:not_found)
38
38
  #
39
- # # good
40
- # def glib_load_resource
41
- # @user = User.find(params[:id])
42
- # end
39
+ # # bad
40
+ # head(:unprocessable_entity)
41
+ #
42
+ # # good (success status — not flagged)
43
+ # head(:no_content)
44
+ #
45
+ # # good (success status — not flagged)
46
+ # head(:ok)
47
+ #
48
+ # # good (dynamic status — not flagged to avoid false positives)
49
+ # head(status_code)
43
50
  class AvoidHeadResponse < Base
44
- MSG = 'Avoid `head()`. Delegate error handling to Rails exceptions ' \
45
- '(e.g. use `find` instead of `find_by` + `head(:not_found)`) or model validations.'.freeze
51
+ MSG = 'Avoid `head(%<status>s)` for error handling. ' \
52
+ 'Delegate to Rails exceptions or model validations instead.'.freeze
53
+
46
54
  RESTRICT_ON_SEND = %i[head].freeze
47
55
 
56
+ DEFAULT_FLAGGED_STATUSES = %w[
57
+ not_found unprocessable_entity forbidden unauthorized
58
+ bad_request conflict gone method_not_allowed
59
+ 404 422 403 401 400 409 410 405
60
+ ].freeze
61
+
48
62
  def on_send(node)
49
63
  return unless node.receiver.nil?
50
64
 
51
- add_offense(node.loc.selector)
65
+ status_node = node.arguments.first
66
+ return unless status_node
67
+ return unless flagged_literal?(status_node)
68
+
69
+ add_offense(node.loc.selector, message: format(MSG, status: status_display(status_node)))
70
+ end
71
+
72
+ private
73
+
74
+ def flagged_literal?(node)
75
+ return false unless node.sym_type? || node.int_type?
76
+
77
+ flagged_statuses.include?(node.value.to_s)
78
+ end
79
+
80
+ def status_display(node)
81
+ node.sym_type? ? ":#{node.value}" : node.value.to_s
82
+ end
83
+
84
+ def flagged_statuses
85
+ cop_config.fetch('FlaggedStatuses', DEFAULT_FLAGGED_STATUSES).map(&:to_s)
52
86
  end
53
87
  end
54
88
  end