rubocop-dev_doc 0.11.0 → 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 +28 -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/load_resource_current_user_guard.rb +3 -3
- 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 +6 -2
|
@@ -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
|
|
@@ -274,11 +274,11 @@ module RuboCop
|
|
|
274
274
|
# `raise`/`fail`. A multi-statement branch (a `begin`) counts when its
|
|
275
275
|
# LAST statement does.
|
|
276
276
|
def branch_exits?(branch)
|
|
277
|
+
branch = branch.children.last if branch&.begin_type?
|
|
277
278
|
return false unless branch
|
|
278
279
|
|
|
279
|
-
branch
|
|
280
|
-
|
|
281
|
-
(branch&.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
|
|
280
|
+
branch.return_type? ||
|
|
281
|
+
(branch.send_type? && branch.receiver.nil? && %i[raise fail].include?(branch.method_name))
|
|
282
282
|
end
|
|
283
283
|
end
|
|
284
284
|
end
|
|
@@ -40,9 +40,7 @@ module RuboCop
|
|
|
40
40
|
TRANSLATION_METHODS = %i[t translate].freeze
|
|
41
41
|
|
|
42
42
|
def on_send(node)
|
|
43
|
-
if watched_methods.include?(node.method_name.to_s)
|
|
44
|
-
node.arguments.each { |arg| each_localizable(arg) }
|
|
45
|
-
end
|
|
43
|
+
node.arguments.each { |arg| each_localizable(arg) } if watched_methods.include?(node.method_name.to_s)
|
|
46
44
|
|
|
47
45
|
check_jbuilder_positional(node)
|
|
48
46
|
end
|
|
@@ -56,19 +54,22 @@ module RuboCop
|
|
|
56
54
|
def each_localizable(node)
|
|
57
55
|
case node.type
|
|
58
56
|
when :hash
|
|
59
|
-
node.pairs.each
|
|
60
|
-
key = pair.key
|
|
61
|
-
if key.sym_type? && localizable_keys.include?(key.value.to_s)
|
|
62
|
-
inspect_localizable(key.value, pair.value)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
each_localizable(pair.value)
|
|
66
|
-
end
|
|
57
|
+
node.pairs.each { |pair| walk_pair(pair) }
|
|
67
58
|
when :array
|
|
68
59
|
node.children.each { |child| each_localizable(child) }
|
|
60
|
+
else
|
|
61
|
+
# Any other node type (literal, send, variable…) can't contain
|
|
62
|
+
# localizable key-value pairs.
|
|
69
63
|
end
|
|
70
64
|
end
|
|
71
65
|
|
|
66
|
+
def walk_pair(pair)
|
|
67
|
+
key = pair.key
|
|
68
|
+
inspect_localizable(key.value, pair.value) if key.sym_type? && localizable_keys.include?(key.value.to_s)
|
|
69
|
+
|
|
70
|
+
each_localizable(pair.value)
|
|
71
|
+
end
|
|
72
|
+
|
|
72
73
|
# jbuilder positional form: `json.title 'Forms'`. The method name is
|
|
73
74
|
# the key and the first argument is the value. Restricted to the
|
|
74
75
|
# jbuilder root `json` so ordinary `obj.text('...')` calls aren't
|
|
@@ -125,8 +125,9 @@ module RuboCop
|
|
|
125
125
|
private
|
|
126
126
|
|
|
127
127
|
def receiver_shape_matches?(node)
|
|
128
|
-
|
|
129
|
-
return
|
|
128
|
+
const_receiver = node.receiver&.const_type?
|
|
129
|
+
return const_receiver if CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
130
|
+
return !const_receiver if NON_CONST_RECEIVER_ONLY.include?(node.method_name)
|
|
130
131
|
|
|
131
132
|
true
|
|
132
133
|
end
|
|
@@ -44,8 +44,8 @@ module RuboCop
|
|
|
44
44
|
# run — something `validate` can't express), disable this cop inline
|
|
45
45
|
# with a written justification:
|
|
46
46
|
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
47
|
+
# # rubocop:disable DevDoc/Rails/AvoidLifecycleMethodOverride -- <explanation>
|
|
48
|
+
# def run_validations!
|
|
49
49
|
# with_access { super }
|
|
50
50
|
# end
|
|
51
51
|
#
|
|
@@ -86,8 +86,8 @@ module RuboCop
|
|
|
86
86
|
# genuine exception, disable this cop inline with a written
|
|
87
87
|
# justification — expect pushback in review:
|
|
88
88
|
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
89
|
+
# # rubocop:disable DevDoc/Rails/AvoidRailsCallbacks -- <explanation>
|
|
90
|
+
# after_create :some_callback
|
|
91
91
|
#
|
|
92
92
|
# Both the symbol form and the block form are flagged:
|
|
93
93
|
#
|
|
@@ -136,7 +136,7 @@ module RuboCop
|
|
|
136
136
|
add_offense(node.loc.selector, message: format(MSG, method: node.method_name))
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
-
def on_block(node)
|
|
139
|
+
def on_block(node)
|
|
140
140
|
send_node = node.send_node
|
|
141
141
|
return unless CALLBACKS.include?(send_node.method_name)
|
|
142
142
|
|
|
@@ -86,7 +86,9 @@ module RuboCop
|
|
|
86
86
|
|
|
87
87
|
def enum_column(klass, name)
|
|
88
88
|
table = schema.table_by(name: table_name(klass))
|
|
89
|
-
|
|
89
|
+
return nil unless table
|
|
90
|
+
|
|
91
|
+
table.columns.find { |c| c.name == name.to_s }
|
|
90
92
|
end
|
|
91
93
|
|
|
92
94
|
def class_node(node)
|
|
@@ -121,7 +121,10 @@ module RuboCop
|
|
|
121
121
|
return explicit.method_name.to_s if explicit
|
|
122
122
|
|
|
123
123
|
# Otherwise the nearest block that isn't a transparent wrapper decides.
|
|
124
|
-
|
|
124
|
+
decider_context(sends.find { |s| !TRANSPARENT_WRAPPERS.include?(s.method_name) })
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def decider_context(decider)
|
|
125
128
|
return unless decider
|
|
126
129
|
|
|
127
130
|
return 'collection' if RESOURCEFUL.include?(decider.method_name)
|
|
@@ -48,13 +48,14 @@ module RuboCop
|
|
|
48
48
|
expected = expected_name(node.method_name, name)
|
|
49
49
|
next if expected == name
|
|
50
50
|
|
|
51
|
-
add_offense(
|
|
52
|
-
arg,
|
|
53
|
-
message: format(MSG, method: node.method_name, number: number_word(node.method_name), expected: expected)
|
|
54
|
-
)
|
|
51
|
+
add_offense(arg, message: offense_message(node.method_name, expected))
|
|
55
52
|
end
|
|
56
53
|
end
|
|
57
54
|
|
|
55
|
+
def offense_message(method_name, expected)
|
|
56
|
+
format(MSG, method: method_name, number: number_word(method_name), expected: expected)
|
|
57
|
+
end
|
|
58
|
+
|
|
58
59
|
private
|
|
59
60
|
|
|
60
61
|
# The resource name as a string, for symbol or string arguments only
|
|
@@ -43,14 +43,13 @@ module RuboCop
|
|
|
43
43
|
RESTRICT_ON_SEND = %i[resources resource].freeze
|
|
44
44
|
|
|
45
45
|
def on_send(node)
|
|
46
|
-
|
|
47
|
-
has_except = key_present?(node, :except)
|
|
46
|
+
return if key_present?(node, :only)
|
|
48
47
|
|
|
49
|
-
|
|
48
|
+
has_except = key_present?(node, :except)
|
|
50
49
|
return if has_except && !require_only?
|
|
51
50
|
|
|
52
51
|
name = node.first_argument&.value || '?'
|
|
53
|
-
msg = has_except
|
|
52
|
+
msg = has_except ? MSG_REQUIRE_ONLY : MSG
|
|
54
53
|
add_offense(node.loc.selector, message: format(msg, method: node.method_name, name: name))
|
|
55
54
|
end
|
|
56
55
|
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
module RuboCop
|
|
2
|
+
module Cop
|
|
3
|
+
module DevDoc
|
|
4
|
+
module Style
|
|
5
|
+
# Flag `.to_sym` (including `&.to_sym`, and through laundering hops
|
|
6
|
+
# like `.presence`/`.to_s`/`.downcase`) chained directly onto an
|
|
7
|
+
# attacker-controlled boundary source: `params[...]` (also `.dig`,
|
|
8
|
+
# `.fetch`, `.require`) or `request.headers[...]`.
|
|
9
|
+
#
|
|
10
|
+
# ## Rationale
|
|
11
|
+
# A bare `.to_sym` lets ANY client-supplied value cross into the symbol
|
|
12
|
+
# domain, where downstream `case`/`==` dispatch treats it as known
|
|
13
|
+
# vocabulary. The allow-list is the missing DECISION: which values do
|
|
14
|
+
# we recognise, and what happens to the rest? Skipping it also mints a
|
|
15
|
+
# symbol per novel value (the classic symbol-DoS vector; GC reclaims
|
|
16
|
+
# dynamic symbols since Ruby 2.2, so today this is churn rather than a
|
|
17
|
+
# hard leak — the vocabulary laundering is the real bug).
|
|
18
|
+
#
|
|
19
|
+
# Resolve the value through the glib-web helper, which only ever
|
|
20
|
+
# returns a symbol already in the allow-list:
|
|
21
|
+
#
|
|
22
|
+
# ❌
|
|
23
|
+
# @mode = params[:mode]&.to_sym || :active
|
|
24
|
+
#
|
|
25
|
+
# ✔️ (glib-web >= 5.1.2)
|
|
26
|
+
# @mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
|
|
27
|
+
#
|
|
28
|
+
# When recognised inputs must map to differently-spelled symbols
|
|
29
|
+
# (e.g. `'spring-promo'` → `:spring_promo`), use an explicit
|
|
30
|
+
# `case`-when at the boundary instead — same allow-list property.
|
|
31
|
+
#
|
|
32
|
+
# ## Sources detected
|
|
33
|
+
# - `params[…]`, `params.dig(…)`, `params.fetch(…)`, `params.require(…)`
|
|
34
|
+
# - `request.headers[…]`
|
|
35
|
+
#
|
|
36
|
+
# `ENV[…]` is deliberately NOT a source here (unlike the sibling
|
|
37
|
+
# `DevDoc/Style/StringSymbolComparison`): ENV is operator-controlled,
|
|
38
|
+
# not attacker-reachable, and `.to_sym` on boot-time config is
|
|
39
|
+
# legitimate where the controller helper doesn't even exist.
|
|
40
|
+
#
|
|
41
|
+
# ## Relationship with DevDoc/Style/StringSymbolComparison
|
|
42
|
+
# Complementary pair over the same boundary doctrine: that cop flags
|
|
43
|
+
# comparing an UNCONVERTED string source against a symbol (always
|
|
44
|
+
# false); this cop flags CONVERTING without validating. The sanctioned
|
|
45
|
+
# exit from both is the allow-list helper.
|
|
46
|
+
#
|
|
47
|
+
# ## Limitations
|
|
48
|
+
# Only the direct form is caught. Once the value flows through a
|
|
49
|
+
# helper method (`report_params[:x]`) or a local/instance variable,
|
|
50
|
+
# static analysis loses the boundary and review has to catch it.
|
|
51
|
+
# Laundering transforms (`.presence`, `.to_s`, `.strip`,
|
|
52
|
+
# `.downcase`, `.upcase`, `.squish`) do NOT reset the trail — they
|
|
53
|
+
# transform, they don't validate.
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# # bad
|
|
57
|
+
# @mode = params[:mode]&.to_sym || :active
|
|
58
|
+
# @theme = request.headers['X-Theme']&.to_sym
|
|
59
|
+
# @segment = params[:segment].presence&.to_sym
|
|
60
|
+
# @sort = params[:sort].to_s.downcase.to_sym
|
|
61
|
+
#
|
|
62
|
+
# # good
|
|
63
|
+
# @mode = glib_allowlist_symbol_param(params[:mode], MODES, default: :active)
|
|
64
|
+
class AvoidSymbolizingBoundaryInput < Base
|
|
65
|
+
MSG = 'Unvalidated `.to_sym` on `%<source>s` lets any client value into the symbol ' \
|
|
66
|
+
'domain (and mints a symbol per novel value). Allow-list it instead: ' \
|
|
67
|
+
'`glib_allowlist_symbol_param(value, ALLOWED, default:)`. Laundering through ' \
|
|
68
|
+
'`.to_s`/`.presence`/`.downcase` is not validation.'.freeze
|
|
69
|
+
|
|
70
|
+
RESTRICT_ON_SEND = %i[to_sym].freeze
|
|
71
|
+
|
|
72
|
+
# String-preserving transforms that commonly sit between the boundary
|
|
73
|
+
# read and the `.to_sym` — they launder the value, not validate it,
|
|
74
|
+
# so the chain is still an offense.
|
|
75
|
+
TRANSFORM_HOPS = %i[presence to_s strip downcase upcase squish].freeze
|
|
76
|
+
|
|
77
|
+
def_node_matcher :params_access?, <<~PATTERN
|
|
78
|
+
(send (send nil? :params) {:[] :dig :fetch :require} ...)
|
|
79
|
+
PATTERN
|
|
80
|
+
|
|
81
|
+
def_node_matcher :request_headers_access?, <<~PATTERN
|
|
82
|
+
(send (send _ :headers) :[] _)
|
|
83
|
+
PATTERN
|
|
84
|
+
|
|
85
|
+
def on_send(node)
|
|
86
|
+
source = boundary_source_in(node.receiver)
|
|
87
|
+
return unless source
|
|
88
|
+
|
|
89
|
+
add_offense(node, message: format(MSG, source: source.source))
|
|
90
|
+
end
|
|
91
|
+
alias on_csend on_send
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
# The boundary source at the bottom of the receiver chain, walking
|
|
96
|
+
# through any number of laundering transforms. Returns nil for
|
|
97
|
+
# anything else (locals, `action_name`, helper-method results) so
|
|
98
|
+
# those don't false-positive.
|
|
99
|
+
def boundary_source_in(node)
|
|
100
|
+
while node
|
|
101
|
+
return node if boundary_source?(node)
|
|
102
|
+
return nil unless node.send_type? || node.csend_type?
|
|
103
|
+
return nil unless TRANSFORM_HOPS.include?(node.method_name)
|
|
104
|
+
|
|
105
|
+
node = node.receiver
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def boundary_source?(node)
|
|
110
|
+
params_access?(node) || request_headers_access?(node)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -15,7 +15,8 @@ module RuboCop
|
|
|
15
15
|
#
|
|
16
16
|
# 1. **Fall-through genuinely happens in normal operation** — add
|
|
17
17
|
# `else` with a comment stating WHY it is correct (requires
|
|
18
|
-
# `Style/EmptyElse`
|
|
18
|
+
# `Style/EmptyElse` with `AllowComments: true`, which keeps that
|
|
19
|
+
# cop's guard against comment-less empty elses).
|
|
19
20
|
# 2. **A non-match is a bug and continuing is unsafe** — `else raise`
|
|
20
21
|
# (fail fast; unexpected enum/mode values must not proceed).
|
|
21
22
|
# 3. **A non-match is a bug but users can safely continue** — report
|
|
@@ -83,21 +83,28 @@ module RuboCop
|
|
|
83
83
|
|
|
84
84
|
siblings = parent.children
|
|
85
85
|
nxt = siblings[siblings.index(asgn) + 1]
|
|
86
|
-
nxt if
|
|
86
|
+
nxt if plain_if?(nxt)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# A statement-form `if` (not a ternary, modifier, or unless).
|
|
90
|
+
def plain_if?(node)
|
|
91
|
+
node&.if_type? && !node.ternary? && !node.modifier_form? && node.if?
|
|
87
92
|
end
|
|
88
93
|
|
|
89
94
|
# `if x` or `if x.present?` — a truthiness check on the assigned var.
|
|
90
95
|
def truthiness_check?(condition, name)
|
|
91
96
|
return false unless condition
|
|
97
|
+
return condition.children.first == name if condition.lvar_type?
|
|
92
98
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
truthy_predicate_on?(condition, name)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# `x.present?`-style: a truthy predicate whose receiver is the var.
|
|
103
|
+
def truthy_predicate_on?(condition, name)
|
|
104
|
+
return false unless condition.send_type? && TRUTHY_PREDICATES.include?(condition.method_name)
|
|
105
|
+
|
|
106
|
+
recv = condition.receiver
|
|
107
|
+
recv&.lvar_type? && recv.children.first == name
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
# Every read of `name` in the enclosing scope sits inside the if's
|
|
@@ -109,12 +116,15 @@ module RuboCop
|
|
|
109
116
|
return false unless single_plain_assignment?(scope, name, asgn)
|
|
110
117
|
return false if rebinds_name?(scope, name)
|
|
111
118
|
|
|
119
|
+
reads_confined_to?(if_node, reads(scope, name))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Read at least once in the true branch, and every read sits inside
|
|
123
|
+
# the if's condition or that branch.
|
|
124
|
+
def reads_confined_to?(if_node, refs)
|
|
112
125
|
true_branch = if_node.if_branch
|
|
113
126
|
return false unless true_branch
|
|
114
|
-
|
|
115
|
-
refs = reads(scope, name)
|
|
116
|
-
in_branch = refs.select { |r| within?(r, true_branch) }
|
|
117
|
-
return false if in_branch.empty?
|
|
127
|
+
return false if refs.none? { |r| within?(r, true_branch) }
|
|
118
128
|
|
|
119
129
|
refs.all? { |r| within?(r, if_node.condition) || within?(r, true_branch) }
|
|
120
130
|
end
|
|
@@ -69,6 +69,13 @@ module RuboCop
|
|
|
69
69
|
# def distance
|
|
70
70
|
# end
|
|
71
71
|
# end
|
|
72
|
+
#
|
|
73
|
+
# # good — RSpec example group (class_exec'd onto a generated
|
|
74
|
+
# # example-group class, so the def does NOT land on Object)
|
|
75
|
+
# RSpec.describe Thing do
|
|
76
|
+
# def build_thing
|
|
77
|
+
# end
|
|
78
|
+
# end
|
|
72
79
|
class NoUnscopedMethodDefinitions < Base
|
|
73
80
|
MSG = 'Define methods inside an explicit `module` or `class`, not at the top level ' \
|
|
74
81
|
'or inside a DSL block (e.g. Rake `namespace`). ' \
|
|
@@ -78,6 +85,14 @@ module RuboCop
|
|
|
78
85
|
# new class exactly like Struct.
|
|
79
86
|
DEFAULT_SAFE_DSL_RECEIVERS = %w[Struct Data Class Module].freeze
|
|
80
87
|
|
|
88
|
+
# RSpec example-group blocks are class_exec'd onto a generated
|
|
89
|
+
# example-group class, so a `def` inside them lands on that class —
|
|
90
|
+
# not Object. Method-specific (unlike the receiver allowlist above)
|
|
91
|
+
# because a def inside e.g. `RSpec.configure` DOES land on Object.
|
|
92
|
+
RSPEC_EXAMPLE_GROUP_METHODS = %i[
|
|
93
|
+
describe shared_examples shared_examples_for shared_context
|
|
94
|
+
].freeze
|
|
95
|
+
|
|
81
96
|
def on_def(node)
|
|
82
97
|
add_offense(node.loc.keyword) unless enclosed_in_class_or_module?(node)
|
|
83
98
|
end
|
|
@@ -110,12 +125,18 @@ module RuboCop
|
|
|
110
125
|
receiver = send_node.receiver
|
|
111
126
|
|
|
112
127
|
return false if receiver.nil?
|
|
128
|
+
return true if rspec_example_group_block?(send_node, receiver)
|
|
113
129
|
|
|
114
130
|
safe_receivers.any? do |safe|
|
|
115
131
|
receiver_matches?(receiver, safe)
|
|
116
132
|
end
|
|
117
133
|
end
|
|
118
134
|
|
|
135
|
+
def rspec_example_group_block?(send_node, receiver)
|
|
136
|
+
receiver_matches?(receiver, 'RSpec') &&
|
|
137
|
+
RSPEC_EXAMPLE_GROUP_METHODS.include?(send_node.method_name)
|
|
138
|
+
end
|
|
139
|
+
|
|
119
140
|
def receiver_matches?(receiver, safe_name)
|
|
120
141
|
# Handles `Struct`, `Class`, `Module` (const nodes)
|
|
121
142
|
receiver.const_type? && receiver.short_name.to_s == safe_name
|