rubocop-dev_doc 0.10.4 → 0.12.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 +73 -0
- data/lib/dev_doc/i18n/pseudo_locale.rb +23 -19
- data/lib/dev_doc/test/lints/cop_drift_check.rb +43 -0
- data/lib/dev_doc/test/lints/cop_drift_test.rb +40 -0
- data/lib/dev_doc/test/lints/cron_schedule.rb +137 -115
- data/lib/dev_doc/test/lints/duplicate_snapshot.rb +5 -2
- data/lib/dev_doc/test/lints/http_driven_controller_tests.rb +5 -0
- data/lib/dev_doc/test/lints/no_file_excludes.rb +32 -21
- data/lib/dev_doc/test/pseudo_i18n_crawler.rb +96 -60
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching.rb +81 -58
- data/lib/rubocop/cop/dev_doc/auth/current_user_branching_helpers.rb +176 -0
- data/lib/rubocop/cop/dev_doc/auth/load_resource_current_user_guard.rb +3 -3
- data/lib/rubocop/cop/dev_doc/auth/role_predicate_outside_policy.rb +102 -0
- data/lib/rubocop/cop/dev_doc/i18n/localizable_props.rb +12 -11
- data/lib/rubocop/cop/dev_doc/i18n/translation_key_prefix.rb +3 -0
- data/lib/rubocop/cop/dev_doc/migration/date_column_naming.rb +3 -0
- data/lib/rubocop/cop/dev_doc/rails/avoid_bypassing_validation.rb +3 -2
- data/lib/rubocop/cop/dev_doc/rails/avoid_lifecycle_method_override.rb +2 -2
- data/lib/rubocop/cop/dev_doc/rails/avoid_rails_callbacks.rb +3 -3
- data/lib/rubocop/cop/dev_doc/rails/enum_column_not_null.rb +3 -1
- data/lib/rubocop/cop/dev_doc/route/no_custom_actions.rb +4 -1
- data/lib/rubocop/cop/dev_doc/route/resource_name_number.rb +5 -4
- data/lib/rubocop/cop/dev_doc/route/resources_require_only.rb +3 -4
- data/lib/rubocop/cop/dev_doc/style/avoid_symbolizing_boundary_input.rb +116 -0
- data/lib/rubocop/cop/dev_doc/style/case_else_decision.rb +2 -1
- data/lib/rubocop/cop/dev_doc/style/minimize_variable_scope.rb +23 -13
- data/lib/rubocop/cop/dev_doc/style/no_unscoped_method_definitions.rb +21 -0
- data/lib/rubocop/cop/dev_doc/style/repeated_bracket_read.rb +13 -9
- data/lib/rubocop/cop/dev_doc/style/repeated_safe_navigation_receiver.rb +13 -10
- data/lib/rubocop/cop/dev_doc/style/string_symbol_comparison.rb +15 -7
- data/lib/rubocop/cop/dev_doc/test/avoid_glib_travel_freeze.rb +2 -2
- data/lib/rubocop/cop/dev_doc/test/no_unit_idioms_in_integration_tests.rb +134 -0
- data/lib/rubocop/dev_doc/plugin.rb +2 -0
- data/lib/rubocop/dev_doc/version.rb +1 -1
- metadata +8 -2
|
@@ -16,7 +16,7 @@ module DevDoc
|
|
|
16
16
|
# this via the per-project override on the `NoFileExcludes` module.
|
|
17
17
|
DEFAULT_ALLOWED_FILES = %w[db/schema.rb].freeze
|
|
18
18
|
|
|
19
|
-
GLOB_CHARACTERS = /[*?\[\]{}]
|
|
19
|
+
GLOB_CHARACTERS = /[*?\[\]{}]/
|
|
20
20
|
|
|
21
21
|
# Sentinel returned by `#offenders` when the `.rubocop.yml` doesn't
|
|
22
22
|
# exist. Distinguishable from `[]` (file present, all good) so the
|
|
@@ -37,18 +37,24 @@ module DevDoc
|
|
|
37
37
|
|
|
38
38
|
# YAML.safe_load returns nil for an empty file. Rubocop config
|
|
39
39
|
# is a flat top-level hash; `Exclude:` keys live one level down.
|
|
40
|
-
(YAML.safe_load(@rubocop_yml_path.read) || {}).
|
|
41
|
-
next unless value.is_a?(Hash)
|
|
40
|
+
(YAML.safe_load(@rubocop_yml_path.read) || {}).flat_map do |section, value|
|
|
41
|
+
next [] unless value.is_a?(Hash)
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
next if entry.match?(GLOB_CHARACTERS)
|
|
46
|
-
next if @allowed_files.include?(entry)
|
|
47
|
-
|
|
48
|
-
acc << " #{section}: Exclude includes literal file #{entry.inspect}"
|
|
43
|
+
literal_excludes(value).map do |entry|
|
|
44
|
+
" #{section}: Exclude includes literal file #{entry.inspect}"
|
|
49
45
|
end
|
|
50
46
|
end
|
|
51
47
|
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Literal-path (non-glob, non-allowlisted) entries of one section's
|
|
52
|
+
# `Exclude:` list.
|
|
53
|
+
def literal_excludes(section_config)
|
|
54
|
+
Array(section_config['Exclude']).select do |entry|
|
|
55
|
+
entry.is_a?(String) && !entry.match?(GLOB_CHARACTERS) && !@allowed_files.include?(entry)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
# Reject literal-file entries in any `Exclude:` list under
|
|
@@ -61,7 +67,7 @@ module DevDoc
|
|
|
61
67
|
# pattern is acceptable for new code in the same file. That sets the
|
|
62
68
|
# wrong expectation and lets violations multiply.
|
|
63
69
|
#
|
|
64
|
-
#
|
|
70
|
+
# An inline `rubocop:disable Cop/Name` directive at the violation line(s) keeps
|
|
65
71
|
# the suppression visible to anyone reading the code AND scopes it to
|
|
66
72
|
# the specific lines — a fresh violation in the same file still gets
|
|
67
73
|
# flagged. The rationale for the suppression lives next to the code it
|
|
@@ -80,7 +86,8 @@ module DevDoc
|
|
|
80
86
|
# ✔ Visible inline, scoped to the specific line(s)
|
|
81
87
|
#
|
|
82
88
|
# # db/migrate/20260505035728_create_pr_reviews.rb
|
|
83
|
-
#
|
|
89
|
+
# # rubocop:disable DevDoc/Migration/AvoidColumnDefault -- <reason>
|
|
90
|
+
# t.jsonb :diff_files, default: []
|
|
84
91
|
#
|
|
85
92
|
# ✔ Glob — still acceptable, targets a category not a specific file
|
|
86
93
|
#
|
|
@@ -109,19 +116,23 @@ module DevDoc
|
|
|
109
116
|
|
|
110
117
|
skip "no #{rubocop_yml_path} found" if result == NoFileExcludesChecker::MissingFile
|
|
111
118
|
|
|
112
|
-
assert result.empty?,
|
|
113
|
-
"`.rubocop.yml` Exclude lists must contain only globs " \
|
|
114
|
-
"(category patterns), not literal file paths. A literal-path " \
|
|
115
|
-
"exclude hides the suppression from readers of the file. " \
|
|
116
|
-
"Use `# rubocop:disable Cop/Name` at the violation line(s) " \
|
|
117
|
-
"instead — visible to readers, scoped to the specific lines.\n\n" \
|
|
118
|
-
"Offenders:\n#{result.join("\n")}"
|
|
119
|
+
assert result.empty?, no_file_excludes_message(result)
|
|
119
120
|
end
|
|
120
121
|
|
|
121
122
|
private
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
|
|
124
|
+
def rubocop_yml_path
|
|
125
|
+
Rails.root.join('.rubocop.yml')
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def no_file_excludes_message(offenders)
|
|
129
|
+
"`.rubocop.yml` Exclude lists must contain only globs " \
|
|
130
|
+
"(category patterns), not literal file paths. A literal-path " \
|
|
131
|
+
"exclude hides the suppression from readers of the file. " \
|
|
132
|
+
"Use `# rubocop:disable Cop/Name` at the violation line(s) " \
|
|
133
|
+
"instead — visible to readers, scoped to the specific lines.\n\n" \
|
|
134
|
+
"Offenders:\n#{offenders.join("\n")}"
|
|
135
|
+
end
|
|
125
136
|
end
|
|
126
137
|
end
|
|
127
138
|
end
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
require 'set'
|
|
2
|
-
|
|
3
1
|
module DevDoc
|
|
4
2
|
module Test
|
|
5
3
|
# Pseudo-localization hardcoded-text crawler.
|
|
@@ -96,20 +94,11 @@ module DevDoc
|
|
|
96
94
|
|
|
97
95
|
# Prepend the Scanner onto the crawler HTTP client exactly once, no
|
|
98
96
|
# matter how many test classes include the module.
|
|
99
|
-
unless Glib::JsonCrawler::Http.ancestors.include?(Scanner)
|
|
100
|
-
Glib::JsonCrawler::Http.prepend(Scanner)
|
|
101
|
-
end
|
|
97
|
+
Glib::JsonCrawler::Http.prepend(Scanner) unless Glib::JsonCrawler::Http.ancestors.include?(Scanner)
|
|
102
98
|
|
|
103
99
|
base.setup do
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
self.class.hits = {}
|
|
108
|
-
@previous_default_locale = ::I18n.default_locale
|
|
109
|
-
# The app's set_locale falls back to I18n.default_locale when no
|
|
110
|
-
# `_lang` param is present (crawler URLs carry none), so this renders
|
|
111
|
-
# every page in pseudo without touching the crawler's URLs.
|
|
112
|
-
::I18n.default_locale = :'en-PSEUDO'
|
|
100
|
+
skip_unless_pseudo_enabled!
|
|
101
|
+
enter_pseudo_locale
|
|
113
102
|
end
|
|
114
103
|
|
|
115
104
|
base.teardown do
|
|
@@ -117,6 +106,7 @@ module DevDoc
|
|
|
117
106
|
end
|
|
118
107
|
end
|
|
119
108
|
|
|
109
|
+
# Per-run accumulator surface for the including test class.
|
|
120
110
|
module ClassMethods
|
|
121
111
|
attr_accessor :hits # { url => [ "[key] value", ... ] }
|
|
122
112
|
|
|
@@ -130,31 +120,44 @@ module DevDoc
|
|
|
130
120
|
@report_dir ||= ::Rails.root.join('test/integration/pseudo_i18n_crawler_test_results').to_s
|
|
131
121
|
end
|
|
132
122
|
|
|
123
|
+
def parse_json(body)
|
|
124
|
+
JSON.parse(body)
|
|
125
|
+
rescue JSON::ParserError
|
|
126
|
+
nil
|
|
127
|
+
end
|
|
128
|
+
|
|
133
129
|
def scan_body(url, body)
|
|
134
130
|
return unless body.is_a?(String) && body.lstrip.start_with?('{', '[')
|
|
135
131
|
|
|
136
|
-
json =
|
|
137
|
-
|
|
138
|
-
rescue JSON::ParserError
|
|
139
|
-
return
|
|
140
|
-
end
|
|
132
|
+
json = parse_json(body)
|
|
133
|
+
return unless json
|
|
141
134
|
|
|
142
|
-
walk(json)
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
135
|
+
walk(json) { |key, value| tally(url, key, value) }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Buckets one text-prop value: wrapped (went through t()), trivial
|
|
139
|
+
# (not copy), data-sourced (verbatim fixture string), or a hardcoded
|
|
140
|
+
# candidate recorded in `hits`.
|
|
141
|
+
def tally(url, key, value)
|
|
142
|
+
if value.include?(MARKER)
|
|
143
|
+
counters[:wrapped] += 1
|
|
144
|
+
elsif trivial?(value)
|
|
145
|
+
nil
|
|
146
|
+
elsif fixture_strings.include?(value.strip)
|
|
147
|
+
# ActiveRecord display value (record title/name/user copy) that
|
|
148
|
+
# came straight from the DB — data, not a hardcoded UI string.
|
|
149
|
+
counters[:data_sourced] += 1
|
|
150
|
+
else
|
|
151
|
+
counters[:unwrapped] += 1
|
|
152
|
+
(hits[url] ||= []) << "[#{key}] #{value.inspect}"
|
|
155
153
|
end
|
|
156
154
|
end
|
|
157
155
|
|
|
156
|
+
# Per-run bucket counts for the DIAG line.
|
|
157
|
+
def counters
|
|
158
|
+
@counters ||= Hash.new(0)
|
|
159
|
+
end
|
|
160
|
+
|
|
158
161
|
# Verbatim string values from the fixtures. A candidate equal to one of
|
|
159
162
|
# these is an ActiveRecord display value (loaded from the DB, so it
|
|
160
163
|
# never went through t() by design) rather than hardcoded copy, and is
|
|
@@ -164,7 +167,7 @@ module DevDoc
|
|
|
164
167
|
# fixtures contain no ERB so static YAML parsing is exact.
|
|
165
168
|
def fixture_strings
|
|
166
169
|
@fixture_strings ||= Dir[File.join(fixture_dir, '**', '*.yml')].each_with_object(Set.new) do |file, set|
|
|
167
|
-
data = YAML.
|
|
170
|
+
data = YAML.safe_load_file(file, permitted_classes: [Date, Time], aliases: true)
|
|
168
171
|
collect_strings(data, set)
|
|
169
172
|
rescue Psych::Exception
|
|
170
173
|
next
|
|
@@ -178,12 +181,15 @@ module DevDoc
|
|
|
178
181
|
when String
|
|
179
182
|
s = node.strip
|
|
180
183
|
set << s unless s.empty?
|
|
184
|
+
else
|
|
185
|
+
# Non-string scalars (numbers, booleans, nil, dates) carry no copy.
|
|
181
186
|
end
|
|
182
187
|
end
|
|
183
188
|
|
|
184
189
|
def diag
|
|
185
|
-
"wrapped(localized via t)=#{
|
|
186
|
-
"
|
|
190
|
+
"wrapped(localized via t)=#{counters[:wrapped]} " \
|
|
191
|
+
"data-sourced(fixtures, skipped)=#{counters[:data_sourced]} " \
|
|
192
|
+
"unwrapped(candidates)=#{counters[:unwrapped]}"
|
|
187
193
|
end
|
|
188
194
|
|
|
189
195
|
def walk(node, &blk)
|
|
@@ -195,6 +201,9 @@ module DevDoc
|
|
|
195
201
|
end
|
|
196
202
|
when Array
|
|
197
203
|
node.each { |v| walk(v, &blk) }
|
|
204
|
+
else
|
|
205
|
+
# Scalars have no children to walk; string values are handed to
|
|
206
|
+
# the block by their parent hash above.
|
|
198
207
|
end
|
|
199
208
|
end
|
|
200
209
|
|
|
@@ -208,47 +217,74 @@ module DevDoc
|
|
|
208
217
|
|
|
209
218
|
private
|
|
210
219
|
|
|
220
|
+
def skip_unless_pseudo_enabled!
|
|
221
|
+
skip 'Set PSEUDO_I18N=1 to run the pseudo-localization scan.' unless ENV['PSEUDO_I18N'] == '1'
|
|
222
|
+
return if ::I18n.available_locales.include?(:'en-PSEUDO')
|
|
223
|
+
|
|
224
|
+
skip 'en-PSEUDO locale not loaded — is config/initializers/pseudo_locale.rb active?'
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def enter_pseudo_locale
|
|
228
|
+
self.class.hits = {}
|
|
229
|
+
self.class.counters.clear
|
|
230
|
+
@previous_default_locale = ::I18n.default_locale
|
|
231
|
+
# The app's set_locale falls back to I18n.default_locale when no
|
|
232
|
+
# `_lang` param is present (crawler URLs carry none), so this renders
|
|
233
|
+
# every page in pseudo without touching the crawler's URLs.
|
|
234
|
+
::I18n.default_locale = :'en-PSEUDO'
|
|
235
|
+
end
|
|
236
|
+
|
|
211
237
|
def crawl_pseudo(user)
|
|
212
238
|
user[:token] = login(user)
|
|
213
239
|
|
|
214
|
-
router = Glib::JsonCrawler::Router.new
|
|
215
|
-
http = Glib::JsonCrawler::Http.new(self, user, router, inspect_result: true)
|
|
216
|
-
router.host = HOST
|
|
217
|
-
router.skip_similar_page = true
|
|
218
|
-
|
|
219
240
|
ENV['CRAWLER_MODE'] = 'true'
|
|
220
|
-
|
|
221
|
-
http,
|
|
222
|
-
'onClick' => {
|
|
223
|
-
'action' => 'initiate_navigation',
|
|
224
|
-
'url' => "http://#{HOST}/users/me?format=json&redirect=default"
|
|
225
|
-
}
|
|
226
|
-
)
|
|
241
|
+
start_crawl(user)
|
|
227
242
|
ensure
|
|
228
243
|
ENV['CRAWLER_MODE'] = nil
|
|
229
244
|
logout
|
|
230
245
|
report!(user)
|
|
231
246
|
end
|
|
232
247
|
|
|
248
|
+
# Enters the crawl at the logged-in user's landing page; the Router
|
|
249
|
+
# walks every reachable page from there.
|
|
250
|
+
def start_crawl(user)
|
|
251
|
+
router = Glib::JsonCrawler::Router.new
|
|
252
|
+
http = Glib::JsonCrawler::Http.new(self, user, router, inspect_result: true)
|
|
253
|
+
router.host = HOST
|
|
254
|
+
router.skip_similar_page = true
|
|
255
|
+
|
|
256
|
+
entry = { 'action' => 'initiate_navigation',
|
|
257
|
+
'url' => "http://#{HOST}/users/me?format=json&redirect=default" }
|
|
258
|
+
router.step(http, 'onClick' => entry)
|
|
259
|
+
end
|
|
260
|
+
|
|
233
261
|
def report!(user)
|
|
234
262
|
hits = self.class.hits
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
path = File.join(report_dir, "#{user[:email]}.txt")
|
|
238
|
-
|
|
239
|
-
lines = []
|
|
240
|
-
hits.sort.each do |url, values|
|
|
241
|
-
lines << url
|
|
242
|
-
values.uniq.each { |v| lines << " #{v}" }
|
|
243
|
-
lines << ''
|
|
244
|
-
end
|
|
245
|
-
total = hits.values.sum { |v| v.uniq.size }
|
|
246
|
-
summary = "#{total} hardcoded candidate(s) across #{hits.size} page(s) for #{user[:email]}"
|
|
263
|
+
summary = report_summary(hits, user)
|
|
264
|
+
path = write_report(summary, hits, user)
|
|
247
265
|
|
|
248
|
-
File.write(path, ([summary, ''] + lines).join("\n"))
|
|
249
266
|
puts "\n[pseudo_i18n] #{summary}\n report: #{path}"
|
|
250
267
|
puts "[pseudo_i18n] DIAG: #{self.class.diag}"
|
|
251
268
|
end
|
|
269
|
+
|
|
270
|
+
# Writes the per-role report file and returns its path.
|
|
271
|
+
def write_report(summary, hits, user)
|
|
272
|
+
FileUtils.mkdir_p(self.class.report_dir)
|
|
273
|
+
path = File.join(self.class.report_dir, "#{user[:email]}.txt")
|
|
274
|
+
File.write(path, ([summary, ''] + report_lines(hits)).join("\n"))
|
|
275
|
+
path
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def report_summary(hits, user)
|
|
279
|
+
total = hits.values.sum { |v| v.uniq.size }
|
|
280
|
+
"#{total} hardcoded candidate(s) across #{hits.size} page(s) for #{user[:email]}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def report_lines(hits)
|
|
284
|
+
hits.sort.flat_map do |url, values|
|
|
285
|
+
[url] + values.uniq.map { |v| " #{v}" } + ['']
|
|
286
|
+
end
|
|
287
|
+
end
|
|
252
288
|
end
|
|
253
289
|
end
|
|
254
290
|
end
|
|
@@ -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
|
-
# -
|
|
49
|
-
#
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
# `current_user
|
|
53
|
-
#
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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?` —
|
|
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
|
|
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)
|