rubocop-dev_doc 0.12.2 → 0.13.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4840aa1ca5c8a4ee2f5dfcb5b2e6880dbe7966d451b8502e0983e8eb63e9b04c
4
- data.tar.gz: 4cc9d0f5a9e352f4b361b831250e5683242a0b35578b8abb43eb3535a18c9a1c
3
+ metadata.gz: c36d2d77bcdffd7f74968817e83dbdb75134e3e3d63b953127e4475f7c1841f9
4
+ data.tar.gz: db6ef7a22d41f10f30e6e6ffedfdb1c7605bc849907290930f9d1eae4f0c49b4
5
5
  SHA512:
6
- metadata.gz: 8c66389a73be82ba697f12708c89023851dc246caa0b3439a389948c8490b0b903a8dd14b90e7f54fa33698bc5e441fe50db7465d5d808e46db72086da0a5305
7
- data.tar.gz: 69fd78aefb3b759863f7a45e3de18bb84bdccf52e1485a4dac0ea9151e0790fcbfa13c1a45d6dffd79bd6fbfff478020231b8b5aafc1d3e9f7a21b2668437fbf
6
+ metadata.gz: a874f22bc4773c0abaeb6de19587b5c1934061d40fb608831b2c725d060acdc39c8817689f38cab35041d1a67d99cfc12daf995a4bc3780fcc91a4f99901dfcb
7
+ data.tar.gz: 9966867628667e9fee6dd869345d1943e936db024a426b6dbd7433f14ae93396c5d8edeffba3a97b1259aac8f831ada7f288d93fdfceb14c34b4893d03fe613d
data/config/default.yml CHANGED
@@ -169,6 +169,25 @@ DevDoc/Rails/NoPerformLaterInModel:
169
169
  Include:
170
170
  - "app/models/**/*.rb"
171
171
 
172
+ DevDoc/Rails/NoPersistenceInService:
173
+ Description: "app/services is for external-boundary adapters; domain persistence and transactions belong in a domain PORO or model method under app/models, enqueues at the calling controller/job."
174
+ Enabled: true
175
+ Include:
176
+ - "app/services/**/*.rb"
177
+ KnownWriteWrappers: []
178
+
179
+ # Data-saving orchestration placement (see
180
+ # best_practices/backend/en/12_orchestration.md): a transaction boundary
181
+ # encodes which records must land together — a domain invariant owned by the
182
+ # model layer, not the request layer. Jobs and rake tasks are deliberately
183
+ # outside Include: they are asynchronous entry points and may transact
184
+ # (best_practices/backend/en/08_job.md assumes idempotent writes).
185
+ DevDoc/Rails/NoTransactionInController:
186
+ Description: "Controllers must not open transactions (`transaction`/`with_lock`); move the write sequence into a model method or domain PORO under app/models."
187
+ Enabled: true
188
+ Include:
189
+ - "app/controllers/**/*.rb"
190
+
172
191
  DevDoc/Rails/EnumMustBeSymbolized:
173
192
  Description: "Declare enums with `enum_symbolize :foo, { … }` instead of a bare `enum`, so the attribute type is set before the enum and the reader returns a symbol."
174
193
  Enabled: true
@@ -1,5 +1,7 @@
1
1
  require_relative 'lints/cron_schedule'
2
2
  require_relative 'lints/duplicate_snapshot'
3
+ require_relative 'lints/enqueue_disable_naming'
4
+ require_relative 'lints/external_io_boundary'
3
5
  require_relative 'lints/no_file_excludes'
4
6
 
5
7
  module DevDoc
@@ -24,6 +26,8 @@ module DevDoc
24
26
  def self.included(base)
25
27
  base.include Lints::CronSchedule
26
28
  base.include Lints::DuplicateSnapshot
29
+ base.include Lints::EnqueueDisableNaming
30
+ base.include Lints::ExternalIoBoundary
27
31
  base.include Lints::NoFileExcludes
28
32
  end
29
33
  end
@@ -0,0 +1,141 @@
1
+ require 'pathname'
2
+
3
+ module DevDoc
4
+ module Test
5
+ module Lints
6
+ # Framework-agnostic check: finds every inline disable of
7
+ # `DevDoc/Rails/NoPerformLaterInModel` under `app/models/` and returns
8
+ # offender descriptions for directives whose enclosing method name does
9
+ # not signal the enqueue (`notify_*` / `send_*` / `deliver_*`).
10
+ #
11
+ # Wrapped by the Minitest module `EnqueueDisableNaming` below — see that
12
+ # module for the rationale.
13
+ class EnqueueDisableNamingChecker
14
+ DIRECTIVE = %r{rubocop:(?:disable|todo)[^#]*DevDoc/Rails/NoPerformLaterInModel}
15
+ # Handles the inline-visibility form (`private def foo`) too — without
16
+ # it, a disable inside such a method would false-positive as
17
+ # "no enclosing method".
18
+ DEF_PATTERN = /\A(\s*)(?:private\s+|protected\s+|public\s+)?def\s+(?:self\.)?([a-zA-Z_][a-zA-Z0-9_]*[!?=]?)/
19
+
20
+ # Bare `notify`/`send`/`deliver` (with optional !/?) count too — the
21
+ # prefix convention is about the verb being first, not about
22
+ # requiring a suffix.
23
+ DEFAULT_NAME_PATTERN = /\A(?:notify|send|deliver)(?:_|[!?]?\z)/
24
+
25
+ def initialize(project_root, name_pattern: DEFAULT_NAME_PATTERN)
26
+ @project_root = Pathname(project_root)
27
+ @name_pattern = name_pattern
28
+ end
29
+
30
+ # Returns an Array<String> of offender descriptions, or `[]` when
31
+ # every disable sits inside a conforming method.
32
+ def offenders
33
+ Dir.glob(@project_root.join('app/models/**/*.rb')).flat_map do |path|
34
+ file_offenders(path)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ def file_offenders(path)
41
+ lines = File.readlines(path)
42
+ lines.each_index.filter_map do |index|
43
+ next unless lines[index].match?(DIRECTIVE)
44
+
45
+ method_name = enclosing_method_name(lines, index)
46
+ next if method_name&.match?(@name_pattern)
47
+
48
+ described = method_name ? "method `#{method_name}`" : 'no enclosing method'
49
+ " #{relative(path)}:#{index + 1}: #{described}"
50
+ end
51
+ end
52
+
53
+ # Nearest `def` above the directive that is still open at the
54
+ # directive's line — a def is closed by an `end` at (or left of) its
55
+ # own indentation. Indentation-based, which the standard Layout cops
56
+ # make reliable; an `end` inside a heredoc is the known blind spot.
57
+ def enclosing_method_name(lines, directive_index)
58
+ same_line = DEF_PATTERN.match(lines[directive_index])
59
+ return same_line[2] if same_line
60
+
61
+ (directive_index - 1).downto(0) do |i|
62
+ def_match = DEF_PATTERN.match(lines[i])
63
+ next unless def_match
64
+
65
+ return def_match[2] unless closed_before?(lines, i, directive_index, def_match[1].size)
66
+ end
67
+ nil
68
+ end
69
+
70
+ def closed_before?(lines, def_index, directive_index, def_indent)
71
+ ((def_index + 1)...directive_index).any? do |i|
72
+ end_match = /\A(\s*)end\b/.match(lines[i])
73
+ end_match && end_match[1].size <= def_indent
74
+ end
75
+ end
76
+
77
+ def relative(path)
78
+ Pathname(path).relative_path_from(@project_root).to_s
79
+ end
80
+ end
81
+
82
+ # Naming tripwire for the sanctioned `NoPerformLaterInModel` escape:
83
+ # every inline disable of that cop in `app/models/` must sit inside an
84
+ # explicitly named `notify_*` / `send_*` / `deliver_*` method.
85
+ #
86
+ # ## Rationale
87
+ # A model-level enqueue is legitimate only when it is inseparable from a
88
+ # record state change (see the orchestration best-practice doc,
89
+ # execution-flow category) — and the convention that makes that
90
+ # legitimate is TWO-part: a justified per-line disable AND an explicit
91
+ # method name that announces the side effect at every call site. The
92
+ # meta-guard (`Style/DisableCopsWithinSourceCodeDirective`) reviews who
93
+ # may disable; nothing machine-checks the naming half. This lint closes
94
+ # that: a disable inside `finalize` or at class level fails the suite.
95
+ #
96
+ # NOTE: Limitations:
97
+ # - Enclosing-method detection is indentation-based (reliable under the
98
+ # standard Layout cops); an `end` keyword inside a heredoc between the
99
+ # `def` and the directive can misattribute the method.
100
+ # - The lint validates the NAME only. Whether the enqueue is genuinely
101
+ # state-coupled — the justification for the disable — stays with the
102
+ # reviewer.
103
+ #
104
+ # ## Usage
105
+ # Include this module in a Minitest test class (Rails test env). To
106
+ # change the accepted verb prefixes, redefine the constant on the test
107
+ # class:
108
+ #
109
+ # class EnqueueDisableNamingTest < ActiveSupport::TestCase
110
+ # include DevDoc::Test::Lints::EnqueueDisableNaming
111
+ # # ENQUEUE_METHOD_NAME_PATTERN = /\A(?:notify|send|deliver|dispatch)_/
112
+ # end
113
+ module EnqueueDisableNaming
114
+ # Default verb prefixes. Per-project override: redefine the constant
115
+ # on the test class that includes this module.
116
+ ENQUEUE_METHOD_NAME_PATTERN = EnqueueDisableNamingChecker::DEFAULT_NAME_PATTERN
117
+
118
+ def test_model_enqueue_disables_sit_in_explicitly_named_methods
119
+ offenders = EnqueueDisableNamingChecker.new(
120
+ Rails.root,
121
+ name_pattern: self.class::ENQUEUE_METHOD_NAME_PATTERN
122
+ ).offenders
123
+
124
+ assert offenders.empty?, enqueue_disable_naming_message(offenders)
125
+ end
126
+
127
+ private
128
+
129
+ def enqueue_disable_naming_message(offenders)
130
+ "Inline disables of DevDoc/Rails/NoPerformLaterInModel are sanctioned " \
131
+ "only inside an explicitly named notify_*/send_*/deliver_* method — " \
132
+ "the name announces the side effect at every call site " \
133
+ "(backend/12_orchestration.md). Rename the enclosing method, or move " \
134
+ "the enqueue to the calling controller if it is not coupled to a " \
135
+ "record state change.\n\n" \
136
+ "Offenders:\n#{offenders.join("\n")}"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,132 @@
1
+ require 'pathname'
2
+
3
+ module DevDoc
4
+ module Test
5
+ module Lints
6
+ # Framework-agnostic check: scans Ruby sources under `app/` (excluding
7
+ # `app/services/`) for HTTP-client tokens and returns offender
8
+ # descriptions for every hit.
9
+ #
10
+ # Wrapped by the Minitest module `ExternalIoBoundary` below — see that
11
+ # module for the rationale.
12
+ class ExternalIoBoundaryChecker
13
+ # The generic Ruby HTTP-client surface. Provider SDKs (Stripe, AWS,
14
+ # etc.) are project vocabulary and belong in the per-project token
15
+ # override on the `ExternalIoBoundary` module, not here.
16
+ DEFAULT_HTTP_CLIENT_TOKENS = %w[
17
+ Net::HTTP Faraday HTTParty RestClient OpenURI URI.open Typhoeus Excon Curl
18
+ ].freeze
19
+
20
+ def initialize(project_root, tokens: DEFAULT_HTTP_CLIENT_TOKENS, allowed_paths: [])
21
+ @project_root = Pathname(project_root)
22
+ @tokens = tokens
23
+ @allowed_paths = allowed_paths
24
+ end
25
+
26
+ # Returns an Array<String> of offender descriptions
27
+ # (" app/models/foo.rb:12: Net::HTTP"), or `[]` when the boundary
28
+ # holds. Token matching is boundary-aware, so `Net::HTTP` does not
29
+ # fire on `Net::HTTPSuccess` and `Curl` does not fire on `Curly`.
30
+ def offenders
31
+ ruby_files.flat_map { |path| file_offenders(path) }
32
+ end
33
+
34
+ private
35
+
36
+ def ruby_files
37
+ Dir.glob(@project_root.join('app/**/*.rb')).reject do |path|
38
+ relative = relative(path)
39
+ relative.start_with?('app/services/') ||
40
+ @allowed_paths.any? { |allowed| relative.start_with?(allowed) }
41
+ end
42
+ end
43
+
44
+ def file_offenders(path)
45
+ [].tap do |offenders|
46
+ File.foreach(path).with_index(1) do |line, lineno|
47
+ token = @tokens.find { |candidate| code_portion(line).match?(token_pattern(candidate)) }
48
+ offenders << " #{relative(path)}:#{lineno}: #{token}" if token
49
+ end
50
+ end
51
+ end
52
+
53
+ # Comments never count: full-line comments are dropped, and trailing
54
+ # ` # ...` comments are stripped (string interpolation survives —
55
+ # `#{` has no space after the hash).
56
+ def code_portion(line)
57
+ line.sub(/\A\s*#.*/, '').sub(/\s#\s.*/, '')
58
+ end
59
+
60
+ def token_pattern(token)
61
+ /(?<![A-Za-z0-9_])#{Regexp.escape(token)}(?![A-Za-z0-9_])/
62
+ end
63
+
64
+ def relative(path)
65
+ Pathname(path).relative_path_from(@project_root).to_s
66
+ end
67
+ end
68
+
69
+ # External-I/O boundary tripwire: HTTP-client code must live only in
70
+ # `app/services/` adapters.
71
+ #
72
+ # ## Rationale
73
+ # External-I/O sequencing (requests, retries, provider fallback,
74
+ # pagination) is the one kind of orchestration whose home is
75
+ # `app/services/` — see `best_practices/backend/en/12_orchestration.md`.
76
+ # `DevDoc/Rails/NoPersistenceInService` enforces the inward half of that
77
+ # boundary (adapters cannot persist or enqueue); this lint enforces the
78
+ # outward half: no HTTP client leaks into models, controllers, jobs, or
79
+ # views, where it would be unmockable, untracked, and invisible to the
80
+ # adapter conventions. A tripwire rather than a cop because it guards an
81
+ # invariant that currently holds — there is nothing for a cop's
82
+ # observed-violation justification gate to measure.
83
+ #
84
+ # NOTE: Limitations:
85
+ # - Matching is textual (per line, comments stripped) over `app/**/*.rb`
86
+ # only — an HTTP call assembled via `Object.const_get` or made from
87
+ # `lib/` is invisible.
88
+ # - Only the generic Ruby HTTP clients are in the default token list;
89
+ # add each provider SDK your project uses (e.g. `Stripe`) via the
90
+ # per-project override.
91
+ #
92
+ # ## Usage
93
+ # Include this module in a Minitest test class (Rails test env). To
94
+ # extend the token list or exempt a sanctioned path, redefine the
95
+ # constants on the test class:
96
+ #
97
+ # class ExternalIoBoundaryTest < ActiveSupport::TestCase
98
+ # include DevDoc::Test::Lints::ExternalIoBoundary
99
+ # # HTTP_CLIENT_TOKENS =
100
+ # # (ExternalIoBoundaryChecker::DEFAULT_HTTP_CLIENT_TOKENS + %w[Stripe]).freeze
101
+ # # ALLOWED_EXTERNAL_IO_PATHS = %w[app/middleware/proxy.rb].freeze
102
+ # end
103
+ module ExternalIoBoundary
104
+ # Defaults. Per-project override: redefine the constants on the test
105
+ # class that includes this module.
106
+ HTTP_CLIENT_TOKENS = ExternalIoBoundaryChecker::DEFAULT_HTTP_CLIENT_TOKENS
107
+ ALLOWED_EXTERNAL_IO_PATHS = [].freeze
108
+
109
+ def test_no_http_clients_outside_app_services
110
+ offenders = ExternalIoBoundaryChecker.new(
111
+ Rails.root,
112
+ tokens: self.class::HTTP_CLIENT_TOKENS,
113
+ allowed_paths: self.class::ALLOWED_EXTERNAL_IO_PATHS
114
+ ).offenders
115
+
116
+ assert offenders.empty?, external_io_boundary_message(offenders)
117
+ end
118
+
119
+ private
120
+
121
+ def external_io_boundary_message(offenders)
122
+ "HTTP-client code outside app/services — external-I/O sequencing " \
123
+ "belongs in an app/services adapter (backend/12_orchestration.md). " \
124
+ "Move the call behind an adapter that returns data; if this site is " \
125
+ "a sanctioned exception, add it to ALLOWED_EXTERNAL_IO_PATHS on the " \
126
+ "including test class with a comment.\n\n" \
127
+ "Offenders:\n#{offenders.join("\n")}"
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -2,18 +2,32 @@ module RuboCop
2
2
  module Cop
3
3
  module DevDoc
4
4
  module Rails
5
- # Avoid `perform_later` calls inside model files.
5
+ # Avoid `perform_later`/`deliver_later` calls inside model files.
6
6
  #
7
7
  # ## Rationale
8
- # Avoid using ActiveJob inside Models, because:
8
+ # Enqueuing a job or mailer is execution-flow control — deciding *when*
9
+ # a side effect fires — and its home is the calling controller or job
10
+ # entry point (see the orchestration best-practice doc), because:
9
11
  #
10
12
  # - It is prone to conflicts between `transaction` and `perform_later`
11
13
  # (the job may run before the transaction commits and read stale data
12
14
  # — see `DevDoc/Rails/NoDeliverLaterInTransaction`).
13
- # - Execution flow control should be done in the Controller, not in the
14
- # Model.
15
- # - If it really must be done in the Model, name the method explicitly
16
- # so the side effect is obvious at the call site.
15
+ # - Flow control belongs to the Controller, not the Model. This governs
16
+ # the *trigger* only: the mail itself stays composed in the Mailer
17
+ # (parameters, templates see the controller best-practice doc); the
18
+ # controller merely calls `deliver_later`. Reuse across controllers
19
+ # goes in a controller concern — DRY alone never justifies pushing a
20
+ # trigger into a model.
21
+ # - The exception: an enqueue inseparable from a record state change
22
+ # (e.g. a throttle stamp and its send that must never be split across
23
+ # a boundary) stays in the model behind an explicitly named
24
+ # `notify_*`/`send_*` method, so the side effect is obvious at the
25
+ # call site.
26
+ #
27
+ # The boundary test: is the enqueue bound to a state change on this
28
+ # record, or is ordering already enforced by data flow (the caller can
29
+ # only enqueue with values that exist after the domain call returns)?
30
+ # The latter is flow control — trigger it from the controller.
17
31
  #
18
32
  # ❌ (in app/models/order.rb)
19
33
  # def finalize
@@ -21,18 +35,20 @@ module RuboCop
21
35
  # OrderJob.perform_later(self)
22
36
  # end
23
37
  #
24
- # ✔️ Explicit method name signals the side effect; reviewers
25
- # immediately see that this should not be called inside a
38
+ # ✔️ State-coupled enqueue behind an explicit `notify_*` name; the
39
+ # side effect is obvious at the call site, and reviewers
40
+ # immediately see that this must not be called inside a
26
41
  # `transaction` block.
27
- # def save_with_email_sending
28
- # save!
42
+ # def notify_finalized
43
+ # update!(finalize_notified_at: Time.current)
29
44
  # OrderJob.perform_later(self)
30
45
  # end
31
46
  #
32
47
  # NOTE: This cop flags *any* `perform_later` in `app/models/**/*.rb`,
33
48
  # including the legitimate "explicitly named method" case above. The
34
49
  # cop is intentionally conservative — disable per-line with a rubocop
35
- # comment when you have deliberately followed the naming convention.
50
+ # comment stating the justification when you have deliberately followed
51
+ # the naming convention.
36
52
  #
37
53
  # @example
38
54
  # # bad (in app/models/order.rb)
@@ -41,9 +57,9 @@ module RuboCop
41
57
  # OrderJob.perform_later(self)
42
58
  # end
43
59
  #
44
- # # good — explicit method communicates the side effect
45
- # def finalize_with_job
46
- # save!
60
+ # # good — state-coupled enqueue behind an explicitly named method
61
+ # def notify_finalized
62
+ # update!(finalize_notified_at: Time.current)
47
63
  # OrderJob.perform_later(self)
48
64
  # end
49
65
  class NoPerformLaterInModel < Base
@@ -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.2".freeze
3
+ VERSION = "0.13.1".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.2
4
+ version: 0.13.1
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-14 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
@@ -96,6 +96,8 @@ files:
96
96
  - lib/dev_doc/test/lints/cross_tenant_canary_check.rb
97
97
  - lib/dev_doc/test/lints/cross_tenant_canary_sweep.rb
98
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
99
101
  - lib/dev_doc/test/lints/http_driven_controller_tests.rb
100
102
  - lib/dev_doc/test/lints/no_file_excludes.rb
101
103
  - lib/dev_doc/test/pseudo_i18n_crawler.rb
@@ -138,6 +140,8 @@ files:
138
140
  - lib/rubocop/cop/dev_doc/rails/no_block_predicate_on_relation.rb
139
141
  - lib/rubocop/cop/dev_doc/rails/no_deliver_later_in_transaction.rb
140
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
141
145
  - lib/rubocop/cop/dev_doc/rails/strong_parameters_expect.rb
142
146
  - lib/rubocop/cop/dev_doc/route/no_custom_actions.rb
143
147
  - lib/rubocop/cop/dev_doc/route/resource_name_number.rb