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 +4 -4
- data/config/default.yml +19 -0
- data/lib/dev_doc/test/best_practice_lints.rb +4 -0
- data/lib/dev_doc/test/lints/enqueue_disable_naming.rb +141 -0
- data/lib/dev_doc/test/lints/external_io_boundary.rb +132 -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 +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c36d2d77bcdffd7f74968817e83dbdb75134e3e3d63b953127e4475f7c1841f9
|
|
4
|
+
data.tar.gz: db6ef7a22d41f10f30e6e6ffedfdb1c7605bc849907290930f9d1eae4f0c49b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
# -
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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
|
-
# ✔️
|
|
25
|
-
#
|
|
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
|
|
28
|
-
#
|
|
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
|
|
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 —
|
|
45
|
-
# def
|
|
46
|
-
#
|
|
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.
|
|
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.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-
|
|
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
|