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.
@@ -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. Base classes are allowlisted here
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
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.12.1".freeze
3
+ VERSION = "0.13.0".freeze
4
4
  end
5
5
  end
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.12.1
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-13 00:00:00.000000000 Z
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