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.
- checksums.yaml +4 -4
- data/config/default.yml +318 -33
- data/lib/dev_doc/test/best_practice_lints.rb +31 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +345 -0
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +197 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +128 -0
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +203 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +230 -0
- data/lib/rubocop/cop/dev_doc/migration/amount_column_in_cents.rb +92 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_bypassing_validation.rb +86 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_column_default.rb +68 -13
- data/lib/rubocop/cop/dev_doc/migration/avoid_conditional_schema_changes.rb +89 -0
- data/lib/rubocop/cop/dev_doc/migration/avoid_json_column.rb +18 -3
- data/lib/rubocop/cop/dev_doc/migration/avoid_non_null.rb +121 -0
- data/lib/rubocop/cop/dev_doc/migration/no_create_join_table.rb +53 -0
- data/lib/rubocop/cop/dev_doc/migration/require_primary_key.rb +55 -0
- data/lib/rubocop/cop/dev_doc/migration/require_timestamps.rb +4 -13
- data/lib/rubocop/cop/dev_doc/rails/application_record_transaction.rb +56 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +135 -0
- data/lib/rubocop/cop/dev_doc/rails/bang_save_in_transaction.rb +127 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +99 -0
- data/lib/rubocop/cop/dev_doc/rails/enum_must_be_symbolized.rb +83 -0
- data/lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb +236 -0
- data/lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb +22 -5
- data/lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb +137 -0
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +171 -0
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +77 -0
- data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +29 -15
- data/lib/rubocop/cop/dev_doc/style/avoid_head_response.rb +56 -22
- data/lib/rubocop/cop/dev_doc/style/avoid_options_hash.rb +102 -0
- data/lib/rubocop/cop/dev_doc/style/avoid_send.rb +42 -10
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +158 -0
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +129 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +150 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +118 -0
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +91 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +53 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +66 -0
- data/lib/rubocop/cop/dev_doc/test/response_assert_equal.rb +179 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- data/lib/rubocop-dev_doc.rb +1 -0
- metadata +73 -10
- 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
|
-
# ##
|
|
30
|
+
# ## Configurable blocklist for library wrappers
|
|
31
31
|
# Some libraries call `perform_later` / `deliver_later` behind the
|
|
32
|
-
# scenes
|
|
33
|
-
#
|
|
34
|
-
#
|
|
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
|
-
|
|
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:`
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
24
|
+
# @example EnforcedStyle: RequireOnly (default)
|
|
25
|
+
# # bad
|
|
26
|
+
# resources :users
|
|
27
|
+
# resources :users, except: [:destroy]
|
|
21
28
|
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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?
|
|
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()`
|
|
5
|
+
# Avoid `head()` with error status codes in controllers.
|
|
6
6
|
#
|
|
7
7
|
# ## Rationale
|
|
8
|
-
# `head()` returns an empty body with no
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
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
|
-
#
|
|
35
|
-
# @user = User.find_by(id: params[:id])
|
|
36
|
-
# head(:not_found) unless @user
|
|
37
|
-
# end
|
|
37
|
+
# head(:not_found)
|
|
38
38
|
#
|
|
39
|
-
# #
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
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()
|
|
45
|
-
'
|
|
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
|
-
|
|
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
|