rubocop-dev_doc 0.10.4 → 0.11.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: b11d7c80b04d4ef7ecbff606f3920260773ae1767ffcacab78e99663920ca9d7
4
- data.tar.gz: eaae1ebc845ca76bc72a878916d1f096c5495ce16b9238734c823875b0ba2323
3
+ metadata.gz: 83da1f7ff50b778721408a7f3713a0708a4749bf0af5e5df9e96810aa7f2be1c
4
+ data.tar.gz: 6b69c63a8bd53bd6a012acaa46ae7134581dbf2760f5e7627948dcd8b137558f
5
5
  SHA512:
6
- metadata.gz: 0c227078e2a29302744d3b14cd3058f5912b50831221b82d7ab8cb13885ffaf4bca0eb7e8784745bd470533190e6c522d3f69b28f7e64cc18f183d2a39d6dd19
7
- data.tar.gz: b7f474e88689ce7bd672e735458b5b8e45c5c47448b44461b673a46b8ac4b74c997ccebf548b02b33460095214c2d6920211ffb621a82eca3e231d8f65898f4d
6
+ metadata.gz: 8b79ae456b8c5518023e9b89ba0232dfeb9fc64cb9a53d2e3e0ac18854a2d8b6f96b02e5a2ff3fd0928c19a32e9f5ae5204e3a5c3aa24af566dea1d8c7b24186
7
+ data.tar.gz: 7ffae58fc78c00ca6abdfab21e3c4517528183c3130e765b2aac198a44b91c2e8b2c9969fc9c3e7bd7c42243d78406f659ea4be94575df28fde2753bb4f22dfa
data/config/default.yml CHANGED
@@ -640,15 +640,60 @@ DevDoc/Auth/LoadResourceCurrentUserGuard:
640
640
  CurrentUserAssertionMethodNames:
641
641
  - assert_current_user_present
642
642
 
643
+ # AllowedMethods classifies the FIRST method called on current_user (or a
644
+ # same-file alias — `@user = current_user` is taint-tracked) in a condition:
645
+ # listed = data read, passes; unlisted = role predicate or unclassified,
646
+ # flags. A closed set on purpose — each addition is a reviewed "data or
647
+ # permission?" decision. The universally-generic baseline (errors, id, email,
648
+ # persisted?, new_record?) is built into the cop
649
+ # (DEFAULT_ALLOWED_METHODS) and CANNOT be dropped by configuration — the
650
+ # list below is purely ADDITIVE, so projects never repeat the baseline (a
651
+ # project AllowedMethods would otherwise REPLACE it: RuboCop does not merge
652
+ # array config). Add your app's own data reads (MFA flags, verification
653
+ # progress, ...) in .rubocop.yml.
643
654
  DevDoc/Auth/CurrentUserBranching:
644
655
  Description: "Avoid branching on `current_user` / `user_signed_in?` in page-specific code. Use shared layouts or an inline disable with a reason for genuinely dual-state pages."
645
656
  Enabled: true
657
+ AllowedMethods: []
646
658
  Exclude:
647
659
  - "app/policies/**/*.rb"
648
660
  - "app/helpers/**/*.rb"
649
661
  - "app/controllers/concerns/**/*.rb"
650
662
  - "app/views/layouts/**/*"
651
663
 
664
+ # The receiver-agnostic companion to CurrentUserBranching: taint can't follow
665
+ # an ivar from controller to view, and wrapper methods break the chain — but
666
+ # the role-predicate NAME is visible on any receiver (`@user.admin?`,
667
+ # `viewer.admin?`). Populate RolePredicates with every role predicate your
668
+ # User/Member models define.
669
+ #
670
+ # The two cops COMPOSE as a pair of nets: CurrentUserBranching is default-deny
671
+ # at the auth root (a brand-new role predicate called on current_user flags as
672
+ # unclassified with zero config), while this cop makes the KNOWN predicates
673
+ # undodgeable on any receiver. The escape requires a new name AND a laundered
674
+ # receiver simultaneously — and each name triaged into RolePredicates closes
675
+ # that hole permanently (the ratchet). RATCHET, step two: once page code
676
+ # queries policies by purpose (`policy(x).auto_approve?`), add any generic
677
+ # role getters your policies export (`user_is_admin?`, `user_is_super_admin?`)
678
+ # to RolePredicates too — policies are excluded paths, so policy-internal use
679
+ # stays free while new loose call sites in page code are blocked mechanically.
680
+ DevDoc/Auth/RolePredicateOutsidePolicy:
681
+ Description: "Role predicates (`admin?`, ...) answer authorization questions and belong in Pundit policies; query via `policy(record).x?` instead of calling them in page code."
682
+ Enabled: true
683
+ RolePredicates:
684
+ - admin?
685
+ - super_admin?
686
+ Exclude:
687
+ - "app/policies/**/*.rb"
688
+ - "app/models/**/*.rb"
689
+ - "app/helpers/**/*.rb"
690
+ - "app/views/layouts/**/*"
691
+ # Tests assert role facts as fixture preconditions (`assert_not
692
+ # stranger.effective_admin_of_organization?(org)`) — they verify the
693
+ # permission model rather than fragment it.
694
+ - "test/**/*"
695
+ - "spec/**/*"
696
+
652
697
  DevDoc/I18n/AvoidTitleizeHumanize:
653
698
  Description: "Avoid `.titleize`/`.humanize` for display text; it's English-only and bypasses I18n. Use `t(...)`."
654
699
  # A review aid, not a clean lint: it can't tell a display string from one
@@ -1,3 +1,5 @@
1
+ require_relative "current_user_branching_helpers"
2
+
1
3
  module RuboCop
2
4
  module Cop
3
5
  module DevDoc
@@ -43,16 +45,63 @@ module RuboCop
43
45
  # ## Patterns flagged
44
46
  # - `if current_user` / `unless current_user` (block or modifier) where
45
47
  # the body is non-empty and is not a bare `return` (bare returns are
46
- # the nil-guard pattern covered by LoadResourceCurrentUserGuard).
48
+ # the nil-guard pattern covered by LoadResourceCurrentUserGuard; the
49
+ # exemption covers every nil-test spelling of the guard —
50
+ # `return if current_user.nil?`, `return unless
51
+ # current_user.present?`, `return if !current_user`).
47
52
  # - `if user_signed_in?` / `unless user_signed_in?` (any form).
48
- # - `if current_user&.foo` and other safe-nav uses of `current_user`
49
- # as a condition. The `&.` is itself a confession that the dev
50
- # expects `current_user` to be nil sometimes — i.e., it's the same
51
- # anti-pattern as `if current_user`, just in disguise: when
52
- # `current_user` is nil the csend returns nil → branch is dead for
53
- # anonymous visitors, exactly what the cop prevents.
53
+ # - The evasion spellings all equivalent to the bare form and all
54
+ # measured in the wild once the bare form was policed:
55
+ # `current_user.present?` / `.blank?` / `.nil?`, `!current_user`,
56
+ # `!user_signed_in?`, and boolean combinations
57
+ # (`current_user && x`, `user_signed_in? || y`).
58
+ # - ANY method chain rooted at `current_user` used as a condition —
59
+ # both `current_user&.admin?` and plain `current_user.admin?`. The
60
+ # `&.` is a confession that the dev expects nil; the plain chain is
61
+ # a role/ownership check that belongs in the policy (see below).
54
62
  # - Ternaries: `current_user ? a : b`, `user_signed_in? ? a : b`.
55
63
  # - Hash/argument values: `authenticated: user_signed_in?`.
64
+ # - ALIASES: a local/instance variable assigned from `current_user`
65
+ # in the same file inherits every rule above — `@user =
66
+ # current_user` followed by `if @user.admin?` is the same branch
67
+ # laundered, and it flags identically. Only PURE aliases are
68
+ # tracked: every assignment to the name must be an unconditional
69
+ # `= current_user` (case dispatch counts as unconditional). A
70
+ # conditional assignment or a second source makes the variable a
71
+ # genuinely nilable/general resource holder — branching on it is
72
+ # resource-presence logic, not auth state. Taint is per-file only:
73
+ # an ivar assigned in the controller and read in a view is not
74
+ # tracked — pair this cop with the receiver-agnostic
75
+ # `DevDoc/Auth/RolePredicateOutsidePolicy`, which closes that gap
76
+ # for the known role predicates.
77
+ #
78
+ # ## AllowedMethods — classifying data reads
79
+ # The first method called on `current_user` (or a tainted alias) in a
80
+ # condition is CLASSIFIED: entries in `AllowedMethods` are data reads
81
+ # (`current_user.errors.any?`, an MFA-configured flag) and pass;
82
+ # everything else — role predicates and methods nobody has classified
83
+ # yet — flags. The list is a closed set on purpose: every new reader
84
+ # used in a condition forces a reviewed "data or permission?" decision
85
+ # in `.rubocop.yml` rather than slipping through. The
86
+ # universally-generic baseline (errors, id, email, persisted?,
87
+ # new_record? — `DEFAULT_ALLOWED_METHODS`) is built into the cop and
88
+ # cannot be un-configured; the yml list is purely ADDITIVE, so
89
+ # projects list only their own readers:
90
+ #
91
+ # DevDoc/Auth/CurrentUserBranching:
92
+ # AllowedMethods:
93
+ # - otp_required_for_login
94
+ #
95
+ # ## Moving a decision to the policy — query by PURPOSE
96
+ # The policy move is only complete when the policy owns the
97
+ # role->behavior rule, not just the role's spelling. Export a
98
+ # decision-shaped query (`auto_approve?`, `initiation_blocked_for_admin?`,
99
+ # or an `authorize` block) and call THAT from page code. A generic role
100
+ # getter (`def user_is_admin? = current_user&.admin?`) re-opens the
101
+ # scattering: every call site invents its own rule again, one policy
102
+ # door down. If your policies must expose such getters as internal
103
+ # building blocks, list their names in the companion cop's
104
+ # RolePredicates so page code cannot call them.
56
105
  #
57
106
  # ## Allowed paths (Exclude:)
58
107
  # By default the cop is silent in:
@@ -139,9 +188,21 @@ module RuboCop
139
188
  # ...
140
189
  # end
141
190
  class CurrentUserBranching < Base
142
- MSG = 'Avoid branching on auth state in page code. ' \
143
- 'If this page serves both anonymous and signed-in users, ' \
144
- 'add an inline disable with a reason.'.freeze
191
+ include CurrentUserBranchingHelpers
192
+
193
+ MSG = 'Branching on auth state in page code — decide what this branch really is: ' \
194
+ 'auth is required here -> the anonymous branch is dead code, delete it; ' \
195
+ 'a role/ownership check -> move the DECISION into the Pundit policy and ' \
196
+ 'query it by purpose (`policy(record).auto_approve?`, `can?(:update, x)`) ' \
197
+ '— do NOT export a generic role getter (`user_is_admin?`) from the policy: ' \
198
+ 'that centralizes only the spelling while every call site keeps its own ' \
199
+ 'role->behavior rule; a plain data read (not permission-related) -> ' \
200
+ 'classify the method in AllowedMethods; genuinely dual-state -> move the ' \
201
+ 'branching to a layout/helper (excluded paths — PRESENTATION branching ' \
202
+ 'only, keyed on entity-scoped facts, never smuggled permission logic) or ' \
203
+ 'split anonymous/signed-in partials. Aliasing (`user = current_user`) ' \
204
+ 'does not exempt any of this. Only a small, local branch on a genuinely ' \
205
+ 'dual-state PAGE warrants an inline disable with a reason.'.freeze
145
206
 
146
207
  AUTH_METHODS = %i[current_user user_signed_in?].freeze
147
208
 
@@ -152,9 +213,17 @@ module RuboCop
152
213
  add_offense(if_offense_location(node))
153
214
  end
154
215
 
155
- # `value: user_signed_in?` — condition passed as a value.
216
+ # `value: user_signed_in?` — the BOOLEAN auth state passed as data,
217
+ # which smuggles the branch to the consumer/client. Deliberately
218
+ # limited to `user_signed_in?`: bare `current_user` passed as data
219
+ # (`requested_by: current_user`, `model: current_user`,
220
+ # `x.user == current_user`) is actorship recording or identity
221
+ # comparison, not branching — flagging it measured 44 noise
222
+ # offenses / 0 finds on a mature codebase.
223
+ VALUE_FLAGGED_METHODS = %i[user_signed_in?].freeze
224
+
156
225
  def on_send(node)
157
- return unless auth_method_call?(node)
226
+ return unless VALUE_FLAGGED_METHODS.include?(node.method_name) && node.receiver.nil?
158
227
  return unless used_as_value?(node)
159
228
 
160
229
  add_offense(node.loc.selector)
@@ -166,46 +235,6 @@ module RuboCop
166
235
  node.if_type? && auth_condition?(node.condition)
167
236
  end
168
237
 
169
- def auth_condition?(condition)
170
- return false unless condition
171
-
172
- if condition.send_type?
173
- AUTH_METHODS.include?(condition.method_name) && condition.receiver.nil?
174
- elsif condition.csend_type?
175
- # `current_user&.foo` — the safe-nav is itself the auth check;
176
- # `bare_current_user_condition?` won't match (it requires send),
177
- # so guard-return forms like `return unless current_user&.admin?`
178
- # correctly stay flagged.
179
- receiver = condition.receiver
180
- receiver&.send_type? &&
181
- receiver.method_name == :current_user &&
182
- receiver.receiver.nil?
183
- else
184
- false
185
- end
186
- end
187
-
188
- # `return unless current_user` (and similar bare guards) should not
189
- # be flagged — they are the correct pattern handled by
190
- # LoadResourceCurrentUserGuard. Detect: an if whose condition is bare
191
- # `current_user` and one branch is a bare `return` (no arguments).
192
- def bare_return_guard?(node)
193
- condition = node.condition
194
- return false unless bare_current_user_condition?(condition)
195
-
196
- bare_return_branch?(node.else_branch) || bare_return_branch?(node.if_branch)
197
- end
198
-
199
- def bare_current_user_condition?(condition)
200
- condition&.send_type? &&
201
- condition.method_name == :current_user &&
202
- condition.receiver.nil?
203
- end
204
-
205
- def bare_return_branch?(branch)
206
- branch&.return_type? && branch.children.empty?
207
- end
208
-
209
238
  # `if`/`unless` keyword forms have `loc.keyword`; ternary `?:` nodes
210
239
  # have `loc.question` instead. Fall back to the condition source range.
211
240
  def if_offense_location(node)
@@ -219,12 +248,6 @@ module RuboCop
219
248
  end
220
249
  end
221
250
 
222
- # Is this send node an auth method call (`current_user`, `user_signed_in?`)
223
- # on no receiver?
224
- def auth_method_call?(node)
225
- AUTH_METHODS.include?(node.method_name) && node.receiver.nil?
226
- end
227
-
228
251
  # Is the send node used as a value (argument to another send, pair
229
252
  # value in a hash, etc.) rather than already caught as a branch condition?
230
253
  def used_as_value?(node)
@@ -0,0 +1,176 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Auth
5
+ # Condition analysis for CurrentUserBranching, extracted so the cop
6
+ # class stays within size limits. Everything here answers one
7
+ # question: is this AST node auth-state/authorization branching?
8
+ #
9
+ # Three mechanisms cooperate (see the cop docstring for doctrine):
10
+ # - AUTH ROOTS: bare `current_user` / `user_signed_in?` calls, PLUS
11
+ # any local/instance variable assigned from `current_user` in the
12
+ # same file (taint tracking — `@user = current_user` must not be a
13
+ # way to launder the branch). Taint is per-file: an ivar assigned in
14
+ # a controller and read in a view is NOT tracked — the companion
15
+ # role-predicate cop covers that gap receiver-agnostically.
16
+ # - ALLOWED METHODS: the first method called on an auth root is
17
+ # classified. Data reads (`errors`, project-configured readers like
18
+ # `otp_required_for_login`) pass; everything else — role predicates,
19
+ # unknown methods — flags until someone classifies it in
20
+ # `AllowedMethods` (a deliberate, reviewed decision).
21
+ # - GUARD EXEMPTION: bare-return nil guards in every spelling stay
22
+ # allowed (the correct pre-auth pattern).
23
+ module CurrentUserBranchingHelpers
24
+ # Universal non-permission data reads, allowed WITHOUT configuration.
25
+ # Lives in code (not default.yml) so a project setting its own
26
+ # AllowedMethods cannot accidentally drop the baseline — RuboCop
27
+ # REPLACES array config wholesale, it does not merge. The yml key is
28
+ # therefore purely additive.
29
+ DEFAULT_ALLOWED_METHODS = %i[errors id email persisted? new_record?].freeze
30
+
31
+ # Nil-test spellings that keep the guard-return exemption; role/data
32
+ # chains never qualify.
33
+ NIL_TEST_METHODS = %i[present? blank? nil? !].freeze
34
+
35
+ # Identity comparisons are display logic, not access decisions.
36
+ IDENTITY_COMPARISON_METHODS = %i[== !=].freeze
37
+
38
+ def on_new_investigation
39
+ super
40
+ @tainted_names = collect_tainted_names(processed_source.ast)
41
+ end
42
+
43
+ private
44
+
45
+ # Names that are PURE aliases of current_user: every assignment to
46
+ # the name, file-wide, is an unconditional `= current_user`. An
47
+ # assignment under an `if` is semantic narrowing (`@user =
48
+ # current_user` only when a token matches — the variable is
49
+ # genuinely nilable, and branching on it is resource-presence
50
+ # logic), and an assignment from any other source makes it a
51
+ # general holder; either disqualifies the name. `case` dispatch
52
+ # (glib_load_resource's per-action assignment) does NOT disqualify —
53
+ # per-action aliasing is still pure aliasing.
54
+ def collect_tainted_names(ast)
55
+ names = Set.new
56
+ disqualified = Set.new
57
+ ast&.each_node(:lvasgn, :ivasgn) do |assign|
58
+ target = pure_alias_assignment?(assign) ? names : disqualified
59
+ target << assign.children[0]
60
+ end
61
+ names - disqualified
62
+ end
63
+
64
+ def pure_alias_assignment?(assign)
65
+ value = assign.children[1]
66
+ return false unless value&.send_type? && value.method_name == :current_user && value.receiver.nil?
67
+
68
+ assign.each_ancestor(:if).none?
69
+ end
70
+
71
+ def tainted_ref?(node)
72
+ node&.type?(:lvar, :ivar) && @tainted_names.include?(node.children.first)
73
+ end
74
+
75
+ def auth_condition?(condition)
76
+ return false unless condition
77
+ return tainted_ref?(condition) if condition.type?(:lvar, :ivar)
78
+ return auth_condition?(condition.children.last) if condition.begin_type?
79
+ return condition.children.any? { |operand| auth_condition?(operand) } if condition.type?(:and, :or)
80
+
81
+ condition.type?(:send, :csend) && auth_send_condition?(condition)
82
+ end
83
+
84
+ def auth_send_condition?(node)
85
+ return true if bare_auth_call?(node)
86
+ # Identity comparisons are exempt in BOTH directions
87
+ # (`x.user == current_user` and `current_user.id == x`), so test
88
+ # before the chain-root rule.
89
+ return false if IDENTITY_COMPARISON_METHODS.include?(node.method_name)
90
+ # `!current_user` / `!user_signed_in?` (or a negated chain)
91
+ return auth_send_condition?(node.receiver) if node.method_name == :! && node.receiver&.type?(:send, :csend)
92
+ return flagged_chain_off_auth_root?(node) if chain_first_hop(node)
93
+
94
+ current_user_as_access_argument?(node)
95
+ end
96
+
97
+ # The first method called on an auth root is the classified one:
98
+ # `current_user.oauth_tokens.any?` classifies `oauth_tokens` (the
99
+ # rest operates on the returned data). Allowed data reads pass;
100
+ # role predicates and unknown methods flag.
101
+ def flagged_chain_off_auth_root?(node)
102
+ !allowed_method?(chain_first_hop(node).method_name)
103
+ end
104
+
105
+ # The send whose receiver IS an auth root (bare current_user or a
106
+ # tainted alias) — nil when the chain isn't rooted at auth state.
107
+ # `user_signed_in?` accepts no chain (it returns a bool).
108
+ def chain_first_hop(node)
109
+ current = node
110
+ while current.respond_to?(:receiver) && current.receiver
111
+ return current if auth_root?(current.receiver)
112
+
113
+ current = current.receiver
114
+ end
115
+ nil
116
+ end
117
+
118
+ def auth_root?(node)
119
+ (node.send_type? && node.method_name == :current_user && node.receiver.nil?) || tainted_ref?(node)
120
+ end
121
+
122
+ def bare_auth_call?(node)
123
+ node.send_type? && self.class::AUTH_METHODS.include?(node.method_name) && node.receiver.nil?
124
+ end
125
+
126
+ def allowed_method?(method_name)
127
+ DEFAULT_ALLOWED_METHODS.include?(method_name) ||
128
+ (cop_config['AllowedMethods'] || []).map(&:to_sym).include?(method_name)
129
+ end
130
+
131
+ # `admins.include?(current_user)` / `accessible_by?(current_user)`
132
+ # in a condition — an access decision parameterised by the current
133
+ # user (or a tainted alias); belongs in the policy.
134
+ def current_user_as_access_argument?(node)
135
+ return false if IDENTITY_COMPARISON_METHODS.include?(node.method_name)
136
+
137
+ node.arguments.any? do |arg|
138
+ (arg.send_type? && arg.method_name == :current_user && arg.receiver.nil?) || tainted_ref?(arg)
139
+ end
140
+ end
141
+
142
+ # `return unless current_user` (and every nil-test SPELLING of it,
143
+ # on current_user or a tainted alias) is the correct guard pattern —
144
+ # the cop polices branching, not guard orthography. Role/data chains
145
+ # (`return unless current_user.admin?`) do NOT qualify.
146
+ def bare_return_guard?(node)
147
+ return false unless pure_auth_state_test?(node.condition)
148
+
149
+ bare_return_branch?(node.else_branch) || bare_return_branch?(node.if_branch)
150
+ end
151
+
152
+ def pure_auth_state_test?(condition)
153
+ return false if condition.nil?
154
+ return true if tainted_ref?(condition)
155
+ return false unless condition.type?(:send, :csend)
156
+ return true if bare_current_user?(condition)
157
+
158
+ nil_test_of_auth_state?(condition)
159
+ end
160
+
161
+ def nil_test_of_auth_state?(node)
162
+ NIL_TEST_METHODS.include?(node.method_name) && node.receiver && pure_auth_state_test?(node.receiver)
163
+ end
164
+
165
+ def bare_current_user?(node)
166
+ node.method_name == :current_user && node.receiver.nil?
167
+ end
168
+
169
+ def bare_return_branch?(branch)
170
+ branch&.return_type? && branch.children.empty?
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,102 @@
1
+ module RuboCop
2
+ module Cop
3
+ module DevDoc
4
+ module Auth
5
+ # Role predicates must not be called outside the policy layer.
6
+ #
7
+ # ## Rationale
8
+ # A role predicate (`admin?`, `super_admin?`,
9
+ # `effective_admin_of_organization?`, ...) answers "what may this
10
+ # person do?" — an authorization question. Scattering those calls
11
+ # through controllers and views fragments the permission model: the
12
+ # Pundit policy says one thing, page code quietly decides another.
13
+ #
14
+ # This cop is the receiver-agnostic companion to
15
+ # `DevDoc/Auth/CurrentUserBranching`: that cop tracks `current_user`
16
+ # and its same-file aliases, but taint cannot follow an ivar from a
17
+ # controller into a view, and any wrapper method breaks the chain.
18
+ # Keying on the PREDICATE NAME instead means `current_user.admin?`,
19
+ # `@user.admin?`, `user.admin?`, and `viewer.admin?` are all equally
20
+ # visible — no alias or wrapper hides the question being asked.
21
+ #
22
+ # ## Remedy
23
+ # Move the decision into the Pundit policy and query it where needed:
24
+ # `policy(record).manage?` — no role predicate at the call site.
25
+ # Name the policy query after the DECISION it guards (`auto_approve?`,
26
+ # `initiation_blocked_for_admin?`), never after the role: a generic
27
+ # getter (`user_is_admin?`) re-exports the scattering through the
28
+ # policy door — every call site still maps role to behavior itself.
29
+ # Genuine display logic keyed on a role (badges, admin-only hints)
30
+ # belongs in a helper (an excluded path), where the branching is
31
+ # openly presentation-side.
32
+ #
33
+ # ## The ratchet
34
+ # Once page code queries policies by purpose, add any role-shaped
35
+ # getters your policies DO export (`user_is_super_admin?`, ...) to
36
+ # RolePredicates. Policies are excluded paths, so policy-internal use
37
+ # stays free while new loose call sites in page code flag mechanically.
38
+ #
39
+ # ## Configuration
40
+ # `RolePredicates` is a closed, per-project list — add every role
41
+ # predicate your User/Member models define:
42
+ #
43
+ # DevDoc/Auth/RolePredicateOutsidePolicy:
44
+ # RolePredicates:
45
+ # - admin?
46
+ # - super_admin?
47
+ # - effective_admin_of_organization?
48
+ # - explicit_admin_of_organization?
49
+ #
50
+ # ## Allowed paths (Exclude:)
51
+ # By default the cop is silent in:
52
+ # app/policies/**/*.rb ← where the predicates belong
53
+ # app/models/**/*.rb ← where they are defined/composed
54
+ # app/helpers/**/*.rb ← sanctioned display branching
55
+ # app/views/layouts/**/* ← sanctioned display branching
56
+ # test/, spec/ ← tests assert role facts as fixture
57
+ # preconditions; they verify the permission
58
+ # model rather than fragment it
59
+ #
60
+ # @example
61
+ # # bad — controller decides a permission by role
62
+ # if current_user.admin?
63
+ # auto_approve
64
+ # end
65
+ #
66
+ # # bad — the alias dodge is equally visible
67
+ # if @user.admin?
68
+ # auto_approve
69
+ # end
70
+ #
71
+ # # good — the policy owns the decision; page code queries it
72
+ # if policy(@organization).auto_approve?
73
+ # auto_approve
74
+ # end
75
+ class RolePredicateOutsidePolicy < Base
76
+ MSG = '`%<method>s` is a role predicate — an authorization question that belongs in ' \
77
+ 'the Pundit policy. Move the DECISION there and query it by purpose ' \
78
+ '(`policy(record).auto_approve?`) — name the query after the decision it ' \
79
+ 'guards, never after the role (`user_is_admin?` just re-exports the ' \
80
+ 'scattering through the policy door, and belongs in this cop\'s ' \
81
+ 'RolePredicates list). For pure display branching (badges, admin hints), ' \
82
+ 'use a helper. Renaming the receiver never exempts it: the flagged thing ' \
83
+ 'is the question, not who is asked.'.freeze
84
+
85
+ def on_send(node)
86
+ method_name = node.method_name
87
+ return unless role_predicates.include?(method_name)
88
+
89
+ add_offense(node.loc.selector, message: format(MSG, method: method_name))
90
+ end
91
+ alias on_csend on_send
92
+
93
+ private
94
+
95
+ def role_predicates
96
+ @role_predicates ||= (cop_config['RolePredicates'] || []).map(&:to_sym)
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -1,5 +1,5 @@
1
1
  module RuboCop
2
2
  module DevDoc
3
- VERSION = "0.10.4".freeze
3
+ VERSION = "0.11.0".freeze
4
4
  end
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubocop-dev_doc
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.10.4
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - dev-doc contributors
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-09 00:00:00.000000000 Z
11
+ date: 2026-07-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -97,7 +97,9 @@ files:
97
97
  - lib/dev_doc/test/pseudo_i18n_crawler.rb
98
98
  - lib/rubocop-dev_doc.rb
99
99
  - lib/rubocop/cop/dev_doc/auth/current_user_branching.rb
100
+ - lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb
100
101
  - lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb
102
+ - lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb
101
103
  - lib/rubocop/cop/dev_doc/i18n/avoid_titleize_humanize.rb
102
104
  - lib/rubocop/cop/dev_doc/i18n/localizable_props.rb
103
105
  - lib/rubocop/cop/dev_doc/i18n/report_text.rb