rubocop-dev_doc 0.12.1 → 0.13.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 +79 -0
- data/lib/dev_doc/test/best_practice_lints.rb +4 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_check.rb +111 -0
- data/lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb +103 -0
- data/lib/dev_doc/test/lints/enqueue_disable_naming.rb +138 -0
- data/lib/dev_doc/test/lints/external_io_boundary.rb +132 -0
- data/lib/rubocop/cop/dev_doc/auth/adjacent_justification.rb +55 -0
- data/lib/rubocop/cop/dev_doc/auth/login_only_policy_justification.rb +165 -0
- data/lib/rubocop/cop/dev_doc/auth/no_record_presence_in_policy.rb +104 -0
- data/lib/rubocop/cop/dev_doc/auth/unscoped_find_justification.rb +194 -0
- data/lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb +30 -14
- data/lib/rubocop/cop/dev_doc/rails/no_persistence_in_service.rb +168 -0
- data/lib/rubocop/cop/dev_doc/rails/no_transaction_in_controller.rb +92 -0
- data/lib/rubocop/cop/dev_doc/test/avoid_unit_test.rb +12 -0
- data/lib/rubocop/cop/dev_doc/test/require_unit_test_justification.rb +6 -1
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +12 -2
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Classes under `app/services/` must not persist domain records, open
|
|
6
|
+
# transactions, or enqueue jobs/mailers.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# `app/services/` is reserved for external-boundary adapters: clients for
|
|
10
|
+
# third-party APIs, storage backends, and their test doubles. An adapter
|
|
11
|
+
# talks to an external system and returns data. Anything more is domain
|
|
12
|
+
# orchestration, and its home routes by what is being orchestrated (see
|
|
13
|
+
# the orchestration best-practice doc):
|
|
14
|
+
#
|
|
15
|
+
# - Transactions and record writes are data-saving orchestration. Home:
|
|
16
|
+
# a domain-named `ActiveModel::Model` PORO under `app/models/` (see
|
|
17
|
+
# the model best-practice doc) or a method on the model that owns the
|
|
18
|
+
# data.
|
|
19
|
+
# - Enqueues (jobs, mailer sends) are execution-flow control. Home: the
|
|
20
|
+
# calling controller or job entry point — ordering is then enforced by
|
|
21
|
+
# data flow, because the trigger runs only after the domain call
|
|
22
|
+
# returns post-commit. The one exception: an enqueue inseparable from
|
|
23
|
+
# a record state change lives in the model behind an explicitly named
|
|
24
|
+
# `notify_*`/`send_*` method with a justified per-line disable of
|
|
25
|
+
# `DevDoc/Rails/NoPerformLaterInModel`.
|
|
26
|
+
#
|
|
27
|
+
# Keeping domain writes out of `app/services/` prevents the "fake-generic
|
|
28
|
+
# service" failure mode: a `*Service` class with one caller whose generic
|
|
29
|
+
# name and location advertise reusability it does not have, and whose
|
|
30
|
+
# existence bypasses the conventions (form-object POROs, model methods,
|
|
31
|
+
# concerns) the project actually uses for domain logic.
|
|
32
|
+
#
|
|
33
|
+
# ❌
|
|
34
|
+
# # app/services/orders/publish_service.rb
|
|
35
|
+
# class Orders::PublishService
|
|
36
|
+
# def call
|
|
37
|
+
# Order.transaction do
|
|
38
|
+
# @order.save!
|
|
39
|
+
# OrderMailer.with(order: @order).published.deliver_later
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
# end
|
|
43
|
+
#
|
|
44
|
+
# ✔️
|
|
45
|
+
# # app/models/orders/publish.rb — domain PORO owns the transaction and writes,
|
|
46
|
+
# # and RETURNS; it does not enqueue (that would trip NoPerformLaterInModel)
|
|
47
|
+
# class Orders::Publish
|
|
48
|
+
# include ActiveModel::Model
|
|
49
|
+
#
|
|
50
|
+
# def save
|
|
51
|
+
# Order.transaction { @order.save! }
|
|
52
|
+
# end
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# # app/controllers/orders_controller.rb — the caller triggers the send;
|
|
56
|
+
# # it can only run after the PORO's transaction has committed
|
|
57
|
+
# OrderMailer.with(order: publish.order).published.deliver_later if publish.save
|
|
58
|
+
#
|
|
59
|
+
# ✔️
|
|
60
|
+
# # app/services/payment_gateway.rb — adapter; external calls only
|
|
61
|
+
# class PaymentGateway
|
|
62
|
+
# def charge(amount_cents)
|
|
63
|
+
# @client.post('/charges', amount: amount_cents)
|
|
64
|
+
# end
|
|
65
|
+
# end
|
|
66
|
+
#
|
|
67
|
+
# ## Configurable blocklist for write wrappers
|
|
68
|
+
# A service can hide its writes behind a model method that persists
|
|
69
|
+
# internally (e.g. an `add_member_if_missing`-style helper). The cop
|
|
70
|
+
# cannot see through arbitrary methods; register the known ones via
|
|
71
|
+
# `KnownWriteWrappers` so those sends flag too. This is a partial
|
|
72
|
+
# mitigation — reviewers must still catch unregistered wrappers.
|
|
73
|
+
#
|
|
74
|
+
# NOTE: Two residuals the cop cannot catch — reviewers own both:
|
|
75
|
+
# - A service whose writes all go through wrapper methods not listed in
|
|
76
|
+
# `KnownWriteWrappers`.
|
|
77
|
+
# - Read-only domain logic parked in `app/services/` (queries, catalogs,
|
|
78
|
+
# pure utilities). Nothing persists, so nothing flags — but its home is
|
|
79
|
+
# still `app/models/` or `lib/`.
|
|
80
|
+
# `Hash#delete` / `Array#insert` collisions are why bare `delete` /
|
|
81
|
+
# `insert` are not in the tracked list; `delete_all` / `insert_all` are.
|
|
82
|
+
# Bare `update` IS tracked despite `Hash#update` (a `merge!` alias):
|
|
83
|
+
# ActiveRecord `update` is a far more common sight in a service than a
|
|
84
|
+
# Hash mutation, so the rare Hash collision is accepted (disable inline
|
|
85
|
+
# with a reason) rather than losing detection of `record.update`.
|
|
86
|
+
#
|
|
87
|
+
# @example
|
|
88
|
+
# # bad
|
|
89
|
+
# ApplicationRecord.transaction { order.save! }
|
|
90
|
+
#
|
|
91
|
+
# # bad
|
|
92
|
+
# user.folders.find_or_create_by!(name: 'Default')
|
|
93
|
+
#
|
|
94
|
+
# # bad
|
|
95
|
+
# OrderMailer.with(order: order).published.deliver_later
|
|
96
|
+
#
|
|
97
|
+
# # good — adapter work: external API call, no domain persistence
|
|
98
|
+
# @client.post('/charges', amount: amount_cents)
|
|
99
|
+
class NoPersistenceInService < Base
|
|
100
|
+
# Two messages, routed per the orchestration taxonomy: persistence and
|
|
101
|
+
# transactions move to the model layer; enqueues move to the caller.
|
|
102
|
+
MSG = '`%<method>s` in app/services — services are external-boundary ' \
|
|
103
|
+
'adapters and must not persist domain records or enqueue jobs. ' \
|
|
104
|
+
'Move this into a domain object under app/models.'.freeze
|
|
105
|
+
ENQUEUE_MSG = '`%<method>s` in app/services — services are external-boundary ' \
|
|
106
|
+
'adapters and must not enqueue jobs or send mail. Return data and ' \
|
|
107
|
+
'let the calling controller or job trigger the send.'.freeze
|
|
108
|
+
|
|
109
|
+
# `with_lock` opens a transaction too (see NoDeliverLaterInTransaction);
|
|
110
|
+
# an enqueue/write inside it is the same violation in a service.
|
|
111
|
+
TRANSACTION_METHODS = %i[transaction with_lock].freeze
|
|
112
|
+
|
|
113
|
+
# ActiveRecord persistence. Non-bang forms and the find-or-create
|
|
114
|
+
# family are included (an observed violation used find_or_create_by!).
|
|
115
|
+
# `delete` and `insert` are deliberately ABSENT: Hash#delete /
|
|
116
|
+
# Array#insert are common enough to flood false positives;
|
|
117
|
+
# `delete_all` / `insert_all` remain.
|
|
118
|
+
PERSISTENCE_METHODS = %i[
|
|
119
|
+
save save! create create! update update! update_all
|
|
120
|
+
update_column update_columns update_attribute
|
|
121
|
+
destroy destroy! destroy_all delete_all
|
|
122
|
+
insert_all insert_all! upsert upsert_all
|
|
123
|
+
find_or_create_by find_or_create_by! create_or_find_by create_or_find_by!
|
|
124
|
+
first_or_create first_or_create! touch increment! decrement! toggle!
|
|
125
|
+
].freeze
|
|
126
|
+
|
|
127
|
+
# Enqueues/sends — mirror NoDeliverLaterInTransaction::CORE_METHODS,
|
|
128
|
+
# plus deliver_now (an adapter must not send mail synchronously either).
|
|
129
|
+
ENQUEUE_METHODS = %i[
|
|
130
|
+
deliver_later deliver_later! deliver_now perform_later perform_all_later enqueue
|
|
131
|
+
].freeze
|
|
132
|
+
|
|
133
|
+
# Union of the three fixed lists, pre-computed once for O(1) membership.
|
|
134
|
+
# KnownWriteWrappers (config) are matched separately as strings — see
|
|
135
|
+
# tracked_method?.
|
|
136
|
+
TRACKED_METHODS = (TRANSACTION_METHODS + PERSISTENCE_METHODS + ENQUEUE_METHODS).freeze
|
|
137
|
+
|
|
138
|
+
# Config-driven (KnownWriteWrappers), so do NOT use RESTRICT_ON_SEND
|
|
139
|
+
# (CONTRIBUTING documents this exact exception). Filter inside on_send,
|
|
140
|
+
# mirroring NoDeliverLaterInTransaction#tracked_method?.
|
|
141
|
+
def on_send(node)
|
|
142
|
+
name = node.method_name
|
|
143
|
+
return unless tracked_method?(name)
|
|
144
|
+
|
|
145
|
+
msg = ENQUEUE_METHODS.include?(name) ? ENQUEUE_MSG : MSG
|
|
146
|
+
add_offense(node.loc.selector, message: format(msg, method: name))
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Safe navigation (`a&.save!`) parses as a `csend` node that on_send
|
|
150
|
+
# does not receive — alias it so `&.` persistence/enqueue/transaction
|
|
151
|
+
# is caught too. `node.method_name` / `node.loc.selector` are valid on
|
|
152
|
+
# csend, so the on_send body needs no change.
|
|
153
|
+
alias on_csend on_send
|
|
154
|
+
|
|
155
|
+
private
|
|
156
|
+
|
|
157
|
+
def tracked_method?(name)
|
|
158
|
+
TRACKED_METHODS.include?(name) || known_write_wrappers.include?(name.to_s)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def known_write_wrappers
|
|
162
|
+
cop_config.fetch('KnownWriteWrappers', [])
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Rails
|
|
5
|
+
# Controllers must not open transactions (`transaction` / `with_lock`);
|
|
6
|
+
# a transaction boundary belongs in the model layer.
|
|
7
|
+
#
|
|
8
|
+
# ## Rationale
|
|
9
|
+
# A transaction boundary encodes a domain invariant: which records must
|
|
10
|
+
# land together for the data to stay consistent. That knowledge belongs
|
|
11
|
+
# to the layer that owns the data — an ActiveRecord model method or a
|
|
12
|
+
# domain `ActiveModel::Model` PORO under `app/models/` (see the model
|
|
13
|
+
# and orchestration best-practice docs). The controller owns request
|
|
14
|
+
# concerns: param binding, authorization, and deciding *when* to
|
|
15
|
+
# trigger the operation — not *what* must commit atomically.
|
|
16
|
+
#
|
|
17
|
+
# `with_lock` opens a transaction too (see
|
|
18
|
+
# `DevDoc/Rails/NoDeliverLaterInTransaction`), so it is tracked as well.
|
|
19
|
+
#
|
|
20
|
+
# ❌
|
|
21
|
+
# # app/controllers/orders_controller.rb
|
|
22
|
+
# def create
|
|
23
|
+
# ApplicationRecord.transaction do
|
|
24
|
+
# @order.save!
|
|
25
|
+
# @order.line_items.create!(line_item_rows)
|
|
26
|
+
# end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# ✔️
|
|
30
|
+
# # app/controllers/orders_controller.rb — one call; the model owns the boundary
|
|
31
|
+
# def create
|
|
32
|
+
# @order.assign_attributes(create_params)
|
|
33
|
+
# @order.save_with_line_items!(line_item_rows)
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# ## Relationship with DevDoc/Rails/ApplicationRecordTransaction
|
|
37
|
+
# That cop governs the *spelling* of transactions in the places they
|
|
38
|
+
# remain legitimate outside `app/models/` (jobs, rake tasks); this cop
|
|
39
|
+
# removes controllers from that set entirely. Once the write sequence
|
|
40
|
+
# moves under `app/models/`, that cop's `app/models` exclusion applies
|
|
41
|
+
# and `SomeModel.transaction` is the sanctioned spelling again.
|
|
42
|
+
#
|
|
43
|
+
# ## Exception
|
|
44
|
+
# Jobs and rake tasks are asynchronous entry points and may open
|
|
45
|
+
# transactions (the job best-practice doc assumes idempotent writes);
|
|
46
|
+
# they are out of scope via `Include`. When adopting this cop on an
|
|
47
|
+
# existing codebase with a backlog, pin it off by name with a measured
|
|
48
|
+
# backlog comment and schedule the migration as its own wave.
|
|
49
|
+
#
|
|
50
|
+
# NOTE: Limitations — reviewers own both:
|
|
51
|
+
# - A transaction opened inside a non-model helper the controller calls
|
|
52
|
+
# (e.g. a `lib/` utility wrapping a block in a transaction) is
|
|
53
|
+
# invisible to this cop.
|
|
54
|
+
# - Receiver-agnostic matching means a domain reader genuinely named
|
|
55
|
+
# `transaction` (e.g. `payment.transaction` returning an associated
|
|
56
|
+
# record) false-positives — disable inline with a reason.
|
|
57
|
+
#
|
|
58
|
+
# @example
|
|
59
|
+
# # bad
|
|
60
|
+
# ApplicationRecord.transaction do
|
|
61
|
+
# order.save!
|
|
62
|
+
# order.line_items.create!(rows)
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# # bad
|
|
66
|
+
# member.with_lock { member.upgrade! }
|
|
67
|
+
#
|
|
68
|
+
# # good — the model or domain PORO owns the transaction
|
|
69
|
+
# order.save_with_line_items!(rows)
|
|
70
|
+
class NoTransactionInController < Base
|
|
71
|
+
MSG = '`%<method>s` in a controller — a transaction boundary is data-saving ' \
|
|
72
|
+
'orchestration and belongs to the layer that owns the domain invariant. Move the ' \
|
|
73
|
+
'write sequence into a model method or domain PORO under app/models.'.freeze
|
|
74
|
+
|
|
75
|
+
# Fixed two-method set, so RESTRICT_ON_SEND applies (unlike the
|
|
76
|
+
# config-driven NoPersistenceInService). Receiver-agnostic: any
|
|
77
|
+
# receiver spelling (`ApplicationRecord.`, `SomeModel.`, bare) opens
|
|
78
|
+
# the same boundary.
|
|
79
|
+
RESTRICT_ON_SEND = %i[transaction with_lock].freeze
|
|
80
|
+
|
|
81
|
+
def on_send(node)
|
|
82
|
+
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Safe navigation (`record&.with_lock`) parses as a csend node that
|
|
86
|
+
# on_send does not receive — alias it so `&.` is caught too.
|
|
87
|
+
alias on_csend on_send
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -25,6 +25,18 @@ module RuboCop
|
|
|
25
25
|
# one only when you are sure a controller test genuinely cannot reach the
|
|
26
26
|
# path, not because it is quicker to write.
|
|
27
27
|
#
|
|
28
|
+
# And even when a controller genuinely can't reach a path, a unit test is
|
|
29
|
+
# warranted only if SOME entry point — a request or a job — can. A defect no
|
|
30
|
+
# legitimate path can trigger is not a live bug; it is theoretical. Countless
|
|
31
|
+
# variables could be nil in isolation yet never are, because the caller already
|
|
32
|
+
# guaranteed otherwise — chase one such unreachable case with a test and a
|
|
33
|
+
# nil-guard and you owe the same for a million more. If nothing reaches the
|
|
34
|
+
# path, the "bug" is dead code: work out how to REMOVE it, don't just
|
|
35
|
+
# record it as "known safe". Removal is usually surgical — the dead part
|
|
36
|
+
# (a redundant branch, an unreachable warn), not the whole method; reach
|
|
37
|
+
# for a "why it is safe" comment only when removal isn't clean. Accepting
|
|
38
|
+
# dead code is how this class of bug hides in the first place.
|
|
39
|
+
#
|
|
28
40
|
# This cop flags only the literal `< ActiveSupport::TestCase`
|
|
29
41
|
# superclass. The blessed blackbox bases —
|
|
30
42
|
# `ActionDispatch::IntegrationTest`, `Glib::IntegrationTest`,
|
|
@@ -43,7 +43,12 @@ module RuboCop
|
|
|
43
43
|
# comes from); stock phrases ("the controller merely surfaces the
|
|
44
44
|
# model's errors") do not count, and a header that misstates the
|
|
45
45
|
# mechanism is worse than none — verify the claim by tracing the
|
|
46
|
-
# write path before writing it.
|
|
46
|
+
# write path before writing it. The entry point named must be REAL:
|
|
47
|
+
# trace that a request or job actually reaches the path — if nothing
|
|
48
|
+
# does, there is nothing to wire (the "defect" is dead code, not a
|
|
49
|
+
# live bug — remove it, usually just the dead part rather than the
|
|
50
|
+
# whole method; comment only if removal isn't clean). Base classes
|
|
51
|
+
# are allowlisted here
|
|
47
52
|
# (`RequireUnitBase`, default true): a *Test class must subclass a
|
|
48
53
|
# `UnitBaseClasses` member — plain ActiveSupport::TestCase is
|
|
49
54
|
# rejected as an unexamined default, so the confession base is
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubocop-dev_doc
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.13.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- dev-doc contributors
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-07-
|
|
11
|
+
date: 2026-07-25 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activesupport
|
|
@@ -93,15 +93,23 @@ files:
|
|
|
93
93
|
- lib/dev_doc/test/lints/cop_drift_check.rb
|
|
94
94
|
- lib/dev_doc/test/lints/cop_drift_test.rb
|
|
95
95
|
- lib/dev_doc/test/lints/cron_schedule.rb
|
|
96
|
+
- lib/dev_doc/test/lints/cross_tenant_canary_check.rb
|
|
97
|
+
- lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb
|
|
96
98
|
- lib/dev_doc/test/lints/duplicate_snapshot.rb
|
|
99
|
+
- lib/dev_doc/test/lints/enqueue_disable_naming.rb
|
|
100
|
+
- lib/dev_doc/test/lints/external_io_boundary.rb
|
|
97
101
|
- lib/dev_doc/test/lints/http_driven_controller_tests.rb
|
|
98
102
|
- lib/dev_doc/test/lints/no_file_excludes.rb
|
|
99
103
|
- lib/dev_doc/test/pseudo_i18n_crawler.rb
|
|
100
104
|
- lib/rubocop-dev_doc.rb
|
|
105
|
+
- lib/rubocop/cop/dev_doc/auth/adjacent_justification.rb
|
|
101
106
|
- lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
|
|
102
107
|
- lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb
|
|
103
108
|
- lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
|
|
109
|
+
- lib/rubocop/cop/dev_doc/auth/login_only_policy_justification.rb
|
|
110
|
+
- lib/rubocop/cop/dev_doc/auth/no_record_presence_in_policy.rb
|
|
104
111
|
- lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb
|
|
112
|
+
- lib/rubocop/cop/dev_doc/auth/unscoped_find_justification.rb
|
|
105
113
|
- lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb
|
|
106
114
|
- lib/rubocop/cop/dev_doc/i18n/localizable_props.rb
|
|
107
115
|
- lib/rubocop/cop/dev_doc/i18n/report_text.rb
|
|
@@ -132,6 +140,8 @@ files:
|
|
|
132
140
|
- lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb
|
|
133
141
|
- lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb
|
|
134
142
|
- lib/rubocop/cop/dev_doc/rails/no_perform_later_in_model.rb
|
|
143
|
+
- lib/rubocop/cop/dev_doc/rails/no_persistence_in_service.rb
|
|
144
|
+
- lib/rubocop/cop/dev_doc/rails/no_transaction_in_controller.rb
|
|
135
145
|
- lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb
|
|
136
146
|
- lib/rubocop/cop/dev_doc/route/no_custom_actions.rb
|
|
137
147
|
- lib/rubocop/cop/dev_doc/route/resource_name_number.rb
|