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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a70fbdce97064f296200b1a77f663eb5c7f928973ea7be693d0ad36f1ff015bb
4
- data.tar.gz: 3e285799f1deffcb5bd30c90d61b2f3bd5438cb7a6a30b358dd60eba61a31072
3
+ metadata.gz: 07e4fd6ab3ecb0208fb61db9e55757d6eb99cae95da51feb2623017c8339b0e8
4
+ data.tar.gz: c53126a7268ad824fa5da9cbb11aef8230ba8feaac0e57eb14ddabe1df0cabe0
5
5
  SHA512:
6
- metadata.gz: 7eb23bbe57954d976ce2975b1eb35a616a306b56bfacacbbc18c03c40bfce86564efe48990649b4137ebdbe7f3773f77f4c3649df27a3bc3d33cc763afc10717
7
- data.tar.gz: 2593c0b78fcc7d82755f1ae7ae7ee81e70a362f3f1e1eff2636ebd2b221c496304c3816cca489fa50d8f67711159ade8ecc7bd88dd2d705f3c820105579ae49c
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