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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 07e4fd6ab3ecb0208fb61db9e55757d6eb99cae95da51feb2623017c8339b0e8
|
|
4
|
+
data.tar.gz: c53126a7268ad824fa5da9cbb11aef8230ba8feaac0e57eb14ddabe1df0cabe0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 303f94f3547ea900364756abceee715b72681a31aab1f9b92c7a817eca79957b11f2c93ab838ddc96109c2356a1741184f82fa9530acaab7fc57eccff72f77b3
|
|
7
|
+
data.tar.gz: e3ea10adeaceee99f587ff6af4879da1601b6f57fd2e1e1b9bba4cde530cd3736f5316b8f65e02d73bc62dafa8f8e8ef7f6aafe9282ff20548635b62689b87b8
|
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
|
|
@@ -722,6 +741,66 @@ DevDoc/Auth/RolePredicateOutsidePolicy:
|
|
|
722
741
|
- "test/**/*"
|
|
723
742
|
- "spec/**/*"
|
|
724
743
|
|
|
744
|
+
# Presence of the policy record is a LOADING fact, not an authorization fact.
|
|
745
|
+
# Headless-policy stacks can hand `record` a truthy placeholder (the policy-name
|
|
746
|
+
# Symbol) when a lookup resolves nothing, turning `record.present?` fail-open for
|
|
747
|
+
# exactly the request it was written to refuse. Observed in production code as a
|
|
748
|
+
# cross-tenant guard reduced to `record.present?`. The remedy — a type check plus
|
|
749
|
+
# ownership/attribute conditions — is strictly stronger in every case, so there
|
|
750
|
+
# is no legitimate hit to tolerate.
|
|
751
|
+
DevDoc/Auth/NoRecordPresenceInPolicy:
|
|
752
|
+
Description: "`record.present?`/`record.blank?` is a loading fact, not an authorization fact; use a type check (`record.is_a?(SomeModel)`) plus ownership/attribute conditions."
|
|
753
|
+
Enabled: true
|
|
754
|
+
Include:
|
|
755
|
+
- "app/policies/**/*.rb"
|
|
756
|
+
|
|
757
|
+
# A login-only gate authorizes every signed-in user, including other tenants'.
|
|
758
|
+
# That is safe only when something else scopes what the action reaches — and the
|
|
759
|
+
# cop cannot see the controller from the policy file, so it makes the developer
|
|
760
|
+
# state that scoping in an adjacent "login-only:" comment. Expect a one-time
|
|
761
|
+
# justification sweep on adoption; writing each sentence honestly IS the audit
|
|
762
|
+
# ("what stops a stranger with someone else's id?"). Observed shipped IDORs had
|
|
763
|
+
# exactly this shape: params-resolved records behind user_signed_in?-only gates.
|
|
764
|
+
# A policy that is login-only throughout (current-user pages, own-resource CRUD)
|
|
765
|
+
# can carry one marker above its class instead of one per gate.
|
|
766
|
+
DevDoc/Auth/LoginOnlyPolicyJustification:
|
|
767
|
+
Description: "A gate that is only `user_signed_in?`/`everyone`/`true` needs an adjacent `login-only:` comment stating where per-record scoping happens — or bind the decision to the record."
|
|
768
|
+
Enabled: true
|
|
769
|
+
JustificationMarker: "login-only:"
|
|
770
|
+
LoginOnlyPredicates:
|
|
771
|
+
- user_signed_in?
|
|
772
|
+
- everyone
|
|
773
|
+
Include:
|
|
774
|
+
- "app/policies/**/*.rb"
|
|
775
|
+
|
|
776
|
+
# `Post.find(params[:id])` reaches every row from a client-controlled, usually
|
|
777
|
+
# enumerable id. Unless the policy binds the resolved record (or its tenant) to
|
|
778
|
+
# the requester, that is a cross-tenant IDOR — the observed shipped shape. The
|
|
779
|
+
# fix the cop pushes toward is scoping the lookup itself (current_user.posts,
|
|
780
|
+
# parent association); a genuinely necessary root find carries an adjacent
|
|
781
|
+
# "unscoped-find:" comment naming the binding. Pairs with
|
|
782
|
+
# LoginOnlyPolicyJustification: shipping a new IDOR past both requires writing
|
|
783
|
+
# two dishonest justifications in two files.
|
|
784
|
+
DevDoc/Auth/UnscopedFindJustification:
|
|
785
|
+
Description: "A `Model.find(params[...])` with a constant receiver is unscoped; scope it through an association/current_user or add an adjacent `unscoped-find:` comment naming the binding."
|
|
786
|
+
Enabled: true
|
|
787
|
+
JustificationMarker: "unscoped-find:"
|
|
788
|
+
FinderMethods:
|
|
789
|
+
- find
|
|
790
|
+
- find_by
|
|
791
|
+
- find_by!
|
|
792
|
+
# Lookup keys treated as non-enumerable capability ids: a keyword lookup
|
|
793
|
+
# whose params-derived values are all keyed by one of these
|
|
794
|
+
# (`find_by!(uuid: params[:id])`) is skipped — the unguessable key is the
|
|
795
|
+
# binding. Key NAMES only; the cop cannot verify entropy. `slug` is
|
|
796
|
+
# deliberately absent (public, guessable). Set to [] to disable.
|
|
797
|
+
NonEnumerableKeys:
|
|
798
|
+
- uuid
|
|
799
|
+
- token
|
|
800
|
+
- secret_key
|
|
801
|
+
Include:
|
|
802
|
+
- "app/controllers/**/*.rb"
|
|
803
|
+
|
|
725
804
|
DevDoc/I18n/AvoidTitleizeHumanize:
|
|
726
805
|
Description: "Avoid `.titleize`/`.humanize` for display text; it's English-only and bypasses I18n. Use `t(...)`."
|
|
727
806
|
# A review aid, not a clean lint: it can't tell a display string from one
|
|
@@ -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,111 @@
|
|
|
1
|
+
module DevDoc
|
|
2
|
+
module Test
|
|
3
|
+
module Lints
|
|
4
|
+
# Cross-tenant canary sweep: catches a tenant-isolation leak by
|
|
5
|
+
# *observing responses/side-effects*, not by inspecting source. The
|
|
6
|
+
# static auth cops (DevDoc/Auth/*) flag the leak-shaped code patterns
|
|
7
|
+
# we have seen before; this lint fails when another tenant's data
|
|
8
|
+
# actually APPEARS somewhere a persona must not see it — regardless of
|
|
9
|
+
# which novel controller shape caused it.
|
|
10
|
+
#
|
|
11
|
+
# ## The canary convention (consumer-side)
|
|
12
|
+
# Fixtures give every tenant globally-unique, greppable markers on
|
|
13
|
+
# fields that render into responses (e.g. `CANARY-<tenant>-<field>`),
|
|
14
|
+
# seeded for at least two tenants so "foreign" is well-defined. Markers
|
|
15
|
+
# must be STATIC fixture strings — never minted at runtime — so a leak
|
|
16
|
+
# is a pure substring test with zero false positives.
|
|
17
|
+
#
|
|
18
|
+
# ## What it covers, honestly
|
|
19
|
+
# - A passive scan of every crawled response catches leaks on routes a
|
|
20
|
+
# persona can reach. It reads CONTENT, not status: an endpoint that
|
|
21
|
+
# returns 200 by design but embeds foreign data (a metadata preview)
|
|
22
|
+
# is caught where a status-only probe passes it.
|
|
23
|
+
# - An adversarial replay (id-like params substituted with a foreign
|
|
24
|
+
# tenant's ids) reaches leaks no page ever links the persona to.
|
|
25
|
+
# Only 2xx bodies are asserted on — a substituted id that 404s has
|
|
26
|
+
# no body to leak, and status is not the signal.
|
|
27
|
+
# - An outbound-recipient scan catches side-effect leaks (mail sent to
|
|
28
|
+
# a foreign tenant's address) that leave no trace in any response.
|
|
29
|
+
#
|
|
30
|
+
# NOT covered: anything a persona can neither be linked to nor have
|
|
31
|
+
# its params mutated into, and any field carrying no marker. The lint
|
|
32
|
+
# reduces the cross-tenant surface; it does not eliminate it.
|
|
33
|
+
#
|
|
34
|
+
# ## Usage
|
|
35
|
+
# This file holds only the pure, Rails-free core (so the gem's own
|
|
36
|
+
# spec suite can load it). Projects include the runtime wrapper into
|
|
37
|
+
# their base integration test instead — see
|
|
38
|
+
# DevDoc::Test::Lints::CrossTenantCanarySweep.
|
|
39
|
+
module CrossTenantCanaryCheck
|
|
40
|
+
# The marker convention: `CANARY-<tenant>-<field>`. Consumers seed these
|
|
41
|
+
# STATIC strings onto tenant-owned fields. The tenant id is embedded, so
|
|
42
|
+
# collection needs no per-model knowledge — grouping is by the marker
|
|
43
|
+
# itself.
|
|
44
|
+
MARKER = /CANARY-\d+-[a-z0-9-]+/
|
|
45
|
+
TENANT_FROM_MARKER = /\ACANARY-(\d+)-/
|
|
46
|
+
|
|
47
|
+
module_function
|
|
48
|
+
|
|
49
|
+
# Build `{ tenant_key => [markers...] }` from arbitrary text sources —
|
|
50
|
+
# fixture-file contents, DB column values, rendered snapshots — grouping
|
|
51
|
+
# each marker by the tenant id embedded in it. Pure string logic: the
|
|
52
|
+
# consumer owns WHERE the text comes from (fixtures path, models), the
|
|
53
|
+
# gem owns the marker format and the grouping. Deduped per tenant.
|
|
54
|
+
def tenant_registry(texts)
|
|
55
|
+
registry = Hash.new { |hash, key| hash[key] = [] }
|
|
56
|
+
texts.each do |text|
|
|
57
|
+
text.to_s.scan(MARKER).each do |marker|
|
|
58
|
+
registry[marker[TENANT_FROM_MARKER, 1]] << marker
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
registry.transform_values(&:uniq)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Markers belonging to every tenant OUTSIDE the persona's allowed
|
|
65
|
+
# set. Takes a SET of allowed tenant keys, not a single tenant — a
|
|
66
|
+
# global admin legitimately sees every tenant (pass all keys and the
|
|
67
|
+
# check is vacuous for it), and hierarchical tenants can grant a
|
|
68
|
+
# parent visibility into its children. Keys compare as strings so
|
|
69
|
+
# fixture-derived integers and configured symbols can't miss.
|
|
70
|
+
def foreign_markers(allowed_tenant_keys, registry)
|
|
71
|
+
allowed = allowed_tenant_keys.map(&:to_s)
|
|
72
|
+
registry.reject { |key, _| allowed.include?(key.to_s) }
|
|
73
|
+
.values.flatten.uniq
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# The subset of markers present in the body. Boundary-aware, like
|
|
77
|
+
# CopDriftCheck.unmentioned_cops: a marker that PREFIXES a longer
|
|
78
|
+
# marker must not match on the longer one's occurrence, or the
|
|
79
|
+
# failure names the wrong leaked field.
|
|
80
|
+
def leaked_markers(body, markers)
|
|
81
|
+
return [] if body.nil? || body.empty?
|
|
82
|
+
|
|
83
|
+
markers.select { |marker| body.match?(/#{Regexp.escape(marker)}(?![A-Za-z0-9-])/) }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Outbound recipients that belong to a foreign tenant. Emails are
|
|
87
|
+
# case-insensitive per RFC domain rules and common practice, so the
|
|
88
|
+
# intersection normalizes case on both sides.
|
|
89
|
+
def leaked_recipients(recipients, foreign_emails)
|
|
90
|
+
normalize(recipients) & normalize(foreign_emails)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Param keys worth substituting with a foreign tenant's values in an
|
|
94
|
+
# adversarial replay: `id`, anything ending `_id`, plus an explicit
|
|
95
|
+
# project-configured allowlist (e.g. a display-mode toggle that
|
|
96
|
+
# switches a form into a data-bearing preview).
|
|
97
|
+
def id_like_param_keys(keys, extra: [])
|
|
98
|
+
extras = extra.map(&:to_s)
|
|
99
|
+
keys.map(&:to_s)
|
|
100
|
+
.select { |key| key == 'id' || key.end_with?('_id') || extras.include?(key) }
|
|
101
|
+
.uniq
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize(emails)
|
|
105
|
+
Array(emails).compact.map { |email| email.to_s.downcase }
|
|
106
|
+
end
|
|
107
|
+
private_class_method :normalize
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require 'dev_doc/test/lints/cross_tenant_canary_check'
|
|
2
|
+
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Test
|
|
5
|
+
module Lints
|
|
6
|
+
# L1 of the cross-tenant canary sweep (see CrossTenantCanaryCheck for the
|
|
7
|
+
# doctrine and the honest coverage/miss statement). Passive: it asserts
|
|
8
|
+
# that no response a persona receives contains another tenant's canary
|
|
9
|
+
# marker. It adds no requests of its own — it rides whatever crawl/replay
|
|
10
|
+
# the host test already drives.
|
|
11
|
+
#
|
|
12
|
+
# Include into the project's base integration test:
|
|
13
|
+
#
|
|
14
|
+
# Glib::IntegrationTest.include DevDoc::Test::Lints::CrossTenantCanarySweep
|
|
15
|
+
#
|
|
16
|
+
# ## The choke point
|
|
17
|
+
# Every request helper (`get`/`post`/...) funnels through the verb methods
|
|
18
|
+
# this module overrides (the module sits ahead of the framework Runner in
|
|
19
|
+
# the MRO). A JSON crawler that drives requests via `send(:get, ...)` on
|
|
20
|
+
# the test instance is caught the same way as a direct call — no crawler
|
|
21
|
+
# changes needed. After `super` returns, `response.body` holds the body to
|
|
22
|
+
# scan.
|
|
23
|
+
#
|
|
24
|
+
# ## Where this has signal, and where it deliberately doesn't
|
|
25
|
+
# The scan only sees what the response actually rendered:
|
|
26
|
+
# - Full-render harnesses (a crawler that renders pages to follow their
|
|
27
|
+
# links; a permission replay run in FULL mode) → real signal: a foreign
|
|
28
|
+
# marker in a persona's own reachable page is a data-bleed leak.
|
|
29
|
+
# - A permission replay in its default SKIP mode short-circuits the
|
|
30
|
+
# controller action to a status-only body, so there is no rendered
|
|
31
|
+
# content to scan. That harness catches cross-tenant access at the
|
|
32
|
+
# STATUS level (its response-matrix snapshot); this lint contributes no
|
|
33
|
+
# content signal there, and that is fine — the two are complementary,
|
|
34
|
+
# not redundant. Passing this lint in skip mode is not evidence of
|
|
35
|
+
# isolation; the matrix is.
|
|
36
|
+
# This layer is passive, so it never reaches a leak a persona is not
|
|
37
|
+
# already linked to — that is the adversarial replay's job (L2/L3), which
|
|
38
|
+
# the host app owns because param-mutation and mail-capture are too
|
|
39
|
+
# app-shaped to generalize. Keep this core pure.
|
|
40
|
+
#
|
|
41
|
+
# ## Consumer contract
|
|
42
|
+
# The including test must provide two things, refreshed per persona:
|
|
43
|
+
# - `canary_registry` → `{ tenant_key => [markers...] }`, scanned from
|
|
44
|
+
# fixtures once (static strings only — never runtime-minted, per the
|
|
45
|
+
# determinism doctrine).
|
|
46
|
+
# - `canary_allowed_tenant_keys` → the tenant keys the CURRENT persona may
|
|
47
|
+
# legitimately see. A single-tenant member returns its one key; a global
|
|
48
|
+
# admin returns every key (making the scan vacuous for it — correct, it
|
|
49
|
+
# sees everything); a parent in a tenant hierarchy returns itself plus
|
|
50
|
+
# its descendants. Returning nil disables the scan for that request
|
|
51
|
+
# (e.g. before any persona has logged in).
|
|
52
|
+
module CrossTenantCanarySweep
|
|
53
|
+
%i[get post patch put delete head options].each do |verb|
|
|
54
|
+
define_method(verb) do |*args, **kwargs, &block|
|
|
55
|
+
result = super(*args, **kwargs, &block)
|
|
56
|
+
__dev_doc_scan_for_foreign_canary
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def __dev_doc_scan_for_foreign_canary
|
|
64
|
+
allowed = canary_allowed_tenant_keys
|
|
65
|
+
return if allowed.nil?
|
|
66
|
+
|
|
67
|
+
registry = canary_registry
|
|
68
|
+
return if registry.nil? || registry.empty?
|
|
69
|
+
|
|
70
|
+
foreign = CrossTenantCanaryCheck.foreign_markers(allowed, registry)
|
|
71
|
+
leaked = CrossTenantCanaryCheck.leaked_markers(__dev_doc_response_body, foreign)
|
|
72
|
+
return if leaked.empty?
|
|
73
|
+
|
|
74
|
+
raise Minitest::Assertion, __dev_doc_canary_message(allowed, leaked)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def __dev_doc_response_body
|
|
78
|
+
response&.body.to_s
|
|
79
|
+
rescue StandardError
|
|
80
|
+
''
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def __dev_doc_canary_message(allowed, leaked)
|
|
84
|
+
<<~MSG
|
|
85
|
+
Cross-tenant leak: this persona (allowed tenants: #{Array(allowed).join(', ')}) received a
|
|
86
|
+
response containing #{leaked.join(', ')}, which belongs to a tenant it must not see.
|
|
87
|
+
Route: #{__dev_doc_route_label}
|
|
88
|
+
Another tenant's data rendered into this persona's response — a cross-tenant read leak.
|
|
89
|
+
Bind the record to the requester: scope the lookup through the persona's tenant
|
|
90
|
+
(current_user / current organization), or gate the policy on the record's tenant.
|
|
91
|
+
See DevDoc/Auth/UnscopedFindJustification and DevDoc/Auth/LoginOnlyPolicyJustification
|
|
92
|
+
for the static companions.
|
|
93
|
+
MSG
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def __dev_doc_route_label
|
|
97
|
+
req = request
|
|
98
|
+
req ? "#{req.request_method} #{req.fullpath}" : '(unknown)'
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
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
|
+
DEF_PATTERN = /\A(\s*)def\s+(?:self\.)?([a-zA-Z_][a-zA-Z0-9_]*[!?=]?)/
|
|
16
|
+
|
|
17
|
+
# Bare `notify`/`send`/`deliver` (with optional !/?) count too — the
|
|
18
|
+
# prefix convention is about the verb being first, not about
|
|
19
|
+
# requiring a suffix.
|
|
20
|
+
DEFAULT_NAME_PATTERN = /\A(?:notify|send|deliver)(?:_|[!?]?\z)/
|
|
21
|
+
|
|
22
|
+
def initialize(project_root, name_pattern: DEFAULT_NAME_PATTERN)
|
|
23
|
+
@project_root = Pathname(project_root)
|
|
24
|
+
@name_pattern = name_pattern
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns an Array<String> of offender descriptions, or `[]` when
|
|
28
|
+
# every disable sits inside a conforming method.
|
|
29
|
+
def offenders
|
|
30
|
+
Dir.glob(@project_root.join('app/models/**/*.rb')).flat_map do |path|
|
|
31
|
+
file_offenders(path)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def file_offenders(path)
|
|
38
|
+
lines = File.readlines(path)
|
|
39
|
+
lines.each_index.filter_map do |index|
|
|
40
|
+
next unless lines[index].match?(DIRECTIVE)
|
|
41
|
+
|
|
42
|
+
method_name = enclosing_method_name(lines, index)
|
|
43
|
+
next if method_name&.match?(@name_pattern)
|
|
44
|
+
|
|
45
|
+
described = method_name ? "method `#{method_name}`" : 'no enclosing method'
|
|
46
|
+
" #{relative(path)}:#{index + 1}: #{described}"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Nearest `def` above the directive that is still open at the
|
|
51
|
+
# directive's line — a def is closed by an `end` at (or left of) its
|
|
52
|
+
# own indentation. Indentation-based, which the standard Layout cops
|
|
53
|
+
# make reliable; an `end` inside a heredoc is the known blind spot.
|
|
54
|
+
def enclosing_method_name(lines, directive_index)
|
|
55
|
+
same_line = DEF_PATTERN.match(lines[directive_index])
|
|
56
|
+
return same_line[2] if same_line
|
|
57
|
+
|
|
58
|
+
(directive_index - 1).downto(0) do |i|
|
|
59
|
+
def_match = DEF_PATTERN.match(lines[i])
|
|
60
|
+
next unless def_match
|
|
61
|
+
|
|
62
|
+
return def_match[2] unless closed_before?(lines, i, directive_index, def_match[1].size)
|
|
63
|
+
end
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def closed_before?(lines, def_index, directive_index, def_indent)
|
|
68
|
+
((def_index + 1)...directive_index).any? do |i|
|
|
69
|
+
end_match = /\A(\s*)end\b/.match(lines[i])
|
|
70
|
+
end_match && end_match[1].size <= def_indent
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def relative(path)
|
|
75
|
+
Pathname(path).relative_path_from(@project_root).to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Naming tripwire for the sanctioned `NoPerformLaterInModel` escape:
|
|
80
|
+
# every inline disable of that cop in `app/models/` must sit inside an
|
|
81
|
+
# explicitly named `notify_*` / `send_*` / `deliver_*` method.
|
|
82
|
+
#
|
|
83
|
+
# ## Rationale
|
|
84
|
+
# A model-level enqueue is legitimate only when it is inseparable from a
|
|
85
|
+
# record state change (see the orchestration best-practice doc,
|
|
86
|
+
# execution-flow category) — and the convention that makes that
|
|
87
|
+
# legitimate is TWO-part: a justified per-line disable AND an explicit
|
|
88
|
+
# method name that announces the side effect at every call site. The
|
|
89
|
+
# meta-guard (`Style/DisableCopsWithinSourceCodeDirective`) reviews who
|
|
90
|
+
# may disable; nothing machine-checks the naming half. This lint closes
|
|
91
|
+
# that: a disable inside `finalize` or at class level fails the suite.
|
|
92
|
+
#
|
|
93
|
+
# NOTE: Limitations:
|
|
94
|
+
# - Enclosing-method detection is indentation-based (reliable under the
|
|
95
|
+
# standard Layout cops); an `end` keyword inside a heredoc between the
|
|
96
|
+
# `def` and the directive can misattribute the method.
|
|
97
|
+
# - The lint validates the NAME only. Whether the enqueue is genuinely
|
|
98
|
+
# state-coupled — the justification for the disable — stays with the
|
|
99
|
+
# reviewer.
|
|
100
|
+
#
|
|
101
|
+
# ## Usage
|
|
102
|
+
# Include this module in a Minitest test class (Rails test env). To
|
|
103
|
+
# change the accepted verb prefixes, redefine the constant on the test
|
|
104
|
+
# class:
|
|
105
|
+
#
|
|
106
|
+
# class EnqueueDisableNamingTest < ActiveSupport::TestCase
|
|
107
|
+
# include DevDoc::Test::Lints::EnqueueDisableNaming
|
|
108
|
+
# # ENQUEUE_METHOD_NAME_PATTERN = /\A(?:notify|send|deliver|dispatch)_/
|
|
109
|
+
# end
|
|
110
|
+
module EnqueueDisableNaming
|
|
111
|
+
# Default verb prefixes. Per-project override: redefine the constant
|
|
112
|
+
# on the test class that includes this module.
|
|
113
|
+
ENQUEUE_METHOD_NAME_PATTERN = EnqueueDisableNamingChecker::DEFAULT_NAME_PATTERN
|
|
114
|
+
|
|
115
|
+
def test_model_enqueue_disables_sit_in_explicitly_named_methods
|
|
116
|
+
offenders = EnqueueDisableNamingChecker.new(
|
|
117
|
+
Rails.root,
|
|
118
|
+
name_pattern: self.class::ENQUEUE_METHOD_NAME_PATTERN
|
|
119
|
+
).offenders
|
|
120
|
+
|
|
121
|
+
assert offenders.empty?, enqueue_disable_naming_message(offenders)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def enqueue_disable_naming_message(offenders)
|
|
127
|
+
"Inline disables of DevDoc/Rails/NoPerformLaterInModel are sanctioned " \
|
|
128
|
+
"only inside an explicitly named notify_*/send_*/deliver_* method — " \
|
|
129
|
+
"the name announces the side effect at every call site " \
|
|
130
|
+
"(backend/12_orchestration.md). Rename the enclosing method, or move " \
|
|
131
|
+
"the enqueue to the calling controller if it is not coupled to a " \
|
|
132
|
+
"record state change.\n\n" \
|
|
133
|
+
"Offenders:\n#{offenders.join("\n")}"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
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
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Auth
|
|
5
|
+
# The adjacent-justification contract shared by the auth "flag and make
|
|
6
|
+
# the developer state the mechanism" cops: an offense is silenced by a
|
|
7
|
+
# comment containing the cop's marker phrase either on the flagged
|
|
8
|
+
# node's own line (trailing) or in the contiguous comment block ending
|
|
9
|
+
# on the line directly above it. Adjacency is the point — a marker
|
|
10
|
+
# elsewhere in the file justifies nothing. Host cops must provide
|
|
11
|
+
# `justification_marker`.
|
|
12
|
+
#
|
|
13
|
+
# With `standalone_only: true` the upward walk only crosses comment-ONLY
|
|
14
|
+
# lines. Use this when the checked node is a class/module declaration:
|
|
15
|
+
# there the "trailing comment on a preceding code line" rule would let a
|
|
16
|
+
# marker trailing the PREVIOUS class's `end` silence the next class.
|
|
17
|
+
module AdjacentJustification
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def adjacent_justification?(node, standalone_only: false)
|
|
21
|
+
marker = justification_marker.downcase
|
|
22
|
+
comments_by_line = processed_source.comments.group_by { |comment| comment.location.line }
|
|
23
|
+
|
|
24
|
+
return true if contains_marker?(comments_by_line[node.first_line], marker)
|
|
25
|
+
|
|
26
|
+
marker_in_block_above?(node.first_line - 1, comments_by_line, marker, standalone_only)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Walk the contiguous comment block upward from the line directly
|
|
30
|
+
# above. A trailing comment on a preceding code line also counts —
|
|
31
|
+
# still adjacent, still a deliberate statement — except in
|
|
32
|
+
# standalone_only mode.
|
|
33
|
+
def marker_in_block_above?(line, comments_by_line, marker, standalone_only)
|
|
34
|
+
while (line_comments = comments_by_line[line])
|
|
35
|
+
break if standalone_only && !comment_only_line?(line)
|
|
36
|
+
return true if contains_marker?(line_comments, marker)
|
|
37
|
+
|
|
38
|
+
line -= 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def comment_only_line?(line)
|
|
45
|
+
processed_source.lines[line - 1].lstrip.start_with?('#')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def contains_marker?(comments, marker)
|
|
49
|
+
Array(comments).any? { |comment| comment.text.downcase.include?(marker) }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|