audition 0.1.0 → 0.2.1
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/README.md +57 -26
- data/exe/audition +8 -0
- data/lib/audition/bundle_sweep.rb +60 -13
- data/lib/audition/cli.rb +82 -29
- data/lib/audition/config.rb +28 -3
- data/lib/audition/directives.rb +26 -8
- data/lib/audition/dynamic/harness.rb +31 -5
- data/lib/audition/dynamic/prober.rb +72 -16
- data/lib/audition/fixer.rb +59 -25
- data/lib/audition/report.rb +41 -11
- data/lib/audition/rewriters.rb +725 -85
- data/lib/audition/static/analyzer.rb +7 -1
- data/lib/audition/static/checks/base.rb +19 -4
- data/lib/audition/static/checks/global_variables.rb +2 -1
- data/lib/audition/static/checks/mutable_constants.rb +168 -15
- data/lib/audition/static/checks/ractor_isolation.rb +5 -1
- data/lib/audition/static/checks/runtime_require.rb +98 -15
- data/lib/audition/static/checks/unsafe_calls.rb +28 -1
- data/lib/audition/static/graph_audit.rb +242 -14
- data/lib/audition/static/literal_classifier.rb +76 -12
- data/lib/audition/static/source_file.rb +92 -11
- data/lib/audition/version.rb +1 -1
- metadata +3 -3
|
@@ -46,7 +46,13 @@ module Audition
|
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
parallel_analyze(paths, workers)
|
|
49
|
-
rescue Ractor::Error
|
|
49
|
+
rescue Ractor::Error => e
|
|
50
|
+
# The silent fallback would otherwise mask a check that is
|
|
51
|
+
# itself Ractor-hostile; surface it under -w.
|
|
52
|
+
if $VERBOSE
|
|
53
|
+
warn "audition: parallel scan fell back to serial: " \
|
|
54
|
+
"#{e.class}: #{e.message}"
|
|
55
|
+
end
|
|
50
56
|
paths.flat_map { |path| analyze_path(path) }
|
|
51
57
|
end
|
|
52
58
|
|
|
@@ -39,12 +39,21 @@ module Audition
|
|
|
39
39
|
# @return [String] kebab-case check identifier
|
|
40
40
|
def check_name(value = nil)
|
|
41
41
|
@check_name = -value if value # audition:disable class-level-state
|
|
42
|
-
@check_name || name.split("::")
|
|
43
|
-
.gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
|
|
42
|
+
@check_name || (name || "anonymous-check").split("::")
|
|
43
|
+
.last.gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
+
# Catalogs merge down the ancestry so a subclass of a
|
|
47
|
+
# concrete check inherits its handlers and messages
|
|
48
|
+
# instead of raising KeyError at visit time.
|
|
46
49
|
def explanations
|
|
47
|
-
@explanations || EMPTY_CATALOG
|
|
50
|
+
own = @explanations || EMPTY_CATALOG
|
|
51
|
+
parent = superclass
|
|
52
|
+
if parent.respond_to?(:explanations)
|
|
53
|
+
parent.explanations.merge(own)
|
|
54
|
+
else
|
|
55
|
+
own
|
|
56
|
+
end
|
|
48
57
|
end
|
|
49
58
|
|
|
50
59
|
# Registers a message catalog entry. Strings may contain
|
|
@@ -67,7 +76,13 @@ module Audition
|
|
|
67
76
|
end
|
|
68
77
|
|
|
69
78
|
def handlers
|
|
70
|
-
@handlers || EMPTY_CATALOG
|
|
79
|
+
own = @handlers || EMPTY_CATALOG
|
|
80
|
+
parent = superclass
|
|
81
|
+
if parent.respond_to?(:handlers)
|
|
82
|
+
parent.handlers.merge(own)
|
|
83
|
+
else
|
|
84
|
+
own
|
|
85
|
+
end
|
|
71
86
|
end
|
|
72
87
|
|
|
73
88
|
# Methods born from define_method carry their block as a
|
|
@@ -11,8 +11,9 @@ module Audition
|
|
|
11
11
|
class GlobalVariables < Base
|
|
12
12
|
SAFE_READS = %w[
|
|
13
13
|
$stdin $stdout $stderr $! $? $~ $_ $DEBUG $VERBOSE $/ $$
|
|
14
|
+
$-v $-d
|
|
14
15
|
].freeze
|
|
15
|
-
SAFE_WRITES = %w[$DEBUG $VERBOSE].freeze
|
|
16
|
+
SAFE_WRITES = %w[$DEBUG $VERBOSE $-v $-d].freeze
|
|
16
17
|
LOAD_PATH_GLOBALS = %w[$LOAD_PATH $: $LOADED_FEATURES $"].freeze
|
|
17
18
|
|
|
18
19
|
explain :access,
|
|
@@ -71,10 +71,47 @@ module Audition
|
|
|
71
71
|
"it isolates self-contained lambdas; or " \
|
|
72
72
|
"promote the logic to a method."
|
|
73
73
|
|
|
74
|
+
explain :hash_default_proc,
|
|
75
|
+
severity: :error,
|
|
76
|
+
message: "constant %{name} holds a Hash with a " \
|
|
77
|
+
"default proc",
|
|
78
|
+
why: "The default block is a Proc baked into the " \
|
|
79
|
+
"Hash, and freezing the Hash does not make " \
|
|
80
|
+
"the block shareable; a non-main Ractor " \
|
|
81
|
+
"reading this constant raises " \
|
|
82
|
+
"Ractor::IsolationError. Rails removed this " \
|
|
83
|
+
"pattern twice during its ractorization.",
|
|
84
|
+
fix: "Use a plain frozen Hash with explicit keys, " \
|
|
85
|
+
"or drop the default proc and fetch with a " \
|
|
86
|
+
"literal default: hash.fetch(key, [])."
|
|
87
|
+
|
|
88
|
+
explain :constant_mutation,
|
|
89
|
+
severity: :warning,
|
|
90
|
+
message: "in-place %{method} on constant %{name}",
|
|
91
|
+
why: "A constant mutated after load is shared " \
|
|
92
|
+
"mutable state: non-main Ractors raise " \
|
|
93
|
+
"Ractor::IsolationError reading it while " \
|
|
94
|
+
"unfrozen, and freezing it turns this call " \
|
|
95
|
+
"into a FrozenError.",
|
|
96
|
+
fix: "Build the complete value at load time and " \
|
|
97
|
+
"freeze it (each_with_object then .freeze), " \
|
|
98
|
+
"or move the registry behind a writer that " \
|
|
99
|
+
"rebuilds and refreezes on each change, the " \
|
|
100
|
+
"copy-on-write style Rails registries use."
|
|
101
|
+
|
|
74
102
|
on :constant_write_node, :constant_or_write_node do |node|
|
|
75
103
|
examine(node.name.to_s, node, node.value)
|
|
76
104
|
end
|
|
77
105
|
|
|
106
|
+
on :call_node do |node|
|
|
107
|
+
flag_constant_mutation(node, node.receiver, node.name.to_s)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
on :index_operator_write_node, :index_or_write_node,
|
|
111
|
+
:index_and_write_node, :index_target_node do |node|
|
|
112
|
+
flag_constant_mutation(node, node.receiver, "[]=")
|
|
113
|
+
end
|
|
114
|
+
|
|
78
115
|
on :constant_path_write_node,
|
|
79
116
|
:constant_path_or_write_node do |node|
|
|
80
117
|
examine(node.target.location.slice, node, node.value)
|
|
@@ -85,23 +122,118 @@ module Audition
|
|
|
85
122
|
def examine(name, node, value)
|
|
86
123
|
return if file.shareable_constants?
|
|
87
124
|
|
|
125
|
+
# A constant this file itself mutates in place is a
|
|
126
|
+
# deliberate accumulator; freezing it would raise at
|
|
127
|
+
# the mutation site (sinatra's PARAMS_CONFIG). The
|
|
128
|
+
# finding stays, the autofix goes.
|
|
129
|
+
fix_ok = !mutated?(name)
|
|
88
130
|
case classifier.classify(value)
|
|
89
131
|
when :mutable_string
|
|
90
132
|
flag(node, :mutable_string, name: name,
|
|
91
|
-
autofix: append_freeze(value))
|
|
133
|
+
autofix: fix_ok ? append_freeze(value) : nil)
|
|
92
134
|
when :mutable_container
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
autofix: wrap_make_shareable(value))
|
|
135
|
+
flag(node, :mutable_container, name: name,
|
|
136
|
+
type: container_type(value),
|
|
137
|
+
autofix: fix_ok ? wrap_make_shareable(value) : nil)
|
|
96
138
|
when :shallow_freeze
|
|
97
139
|
flag(node, :shallow_freeze, name: name,
|
|
98
|
-
autofix:
|
|
140
|
+
autofix:
|
|
141
|
+
fix_ok ? replace_with_make_shareable(value) : nil)
|
|
99
142
|
when :sync_primitive
|
|
143
|
+
klass = value.is_a?(Prism::CallNode) &&
|
|
144
|
+
classifier.const_name(value.receiver)
|
|
100
145
|
flag(node, :sync_primitive, name: name,
|
|
101
|
-
klass:
|
|
146
|
+
klass: klass || "sync primitive")
|
|
102
147
|
when :proc
|
|
148
|
+
# make_shareable on a proc raises when the proc
|
|
149
|
+
# captures locals or its self is unshareable (any
|
|
150
|
+
# top-level lambda); only capture-free lambdas inside
|
|
151
|
+
# a class or module body get the wrap.
|
|
152
|
+
body = proc_body(value)
|
|
153
|
+
wrappable = fix_ok && namespaced?(node) &&
|
|
154
|
+
(body.nil? ||
|
|
155
|
+
RactorIsolation::CaptureScanner
|
|
156
|
+
.scan(body).empty?)
|
|
103
157
|
flag(node, :proc_constant, name: name,
|
|
104
|
-
autofix: wrap_make_shareable(value))
|
|
158
|
+
autofix: wrappable ? wrap_make_shareable(value) : nil)
|
|
159
|
+
when :default_proc
|
|
160
|
+
flag(node, :hash_default_proc, name: name)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def mutated?(name)
|
|
165
|
+
bare = name.split("::").last
|
|
166
|
+
file.mutated_constants.any? do |mutated|
|
|
167
|
+
mutated == name || mutated.split("::").last == bare
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# A proc value is a LambdaNode (`->`) or a CallNode
|
|
172
|
+
# (`proc { }`, `lambda { }`, `Proc.new { }`); the body
|
|
173
|
+
# lives in different places (rspec-expectations crashed
|
|
174
|
+
# the CallNode path).
|
|
175
|
+
def proc_body(value)
|
|
176
|
+
case value
|
|
177
|
+
when Prism::LambdaNode then value.body
|
|
178
|
+
when Prism::CallNode then value.block&.body
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def namespaced?(node)
|
|
183
|
+
offset = node.location.start_offset
|
|
184
|
+
module_ranges.any? { |range| range.cover?(offset) }
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def module_ranges
|
|
188
|
+
@module_ranges ||= begin
|
|
189
|
+
ranges = []
|
|
190
|
+
queue = [file.root]
|
|
191
|
+
until queue.empty?
|
|
192
|
+
current = queue.shift
|
|
193
|
+
queue.concat(current.child_nodes.compact)
|
|
194
|
+
case current
|
|
195
|
+
when Prism::ClassNode, Prism::ModuleNode,
|
|
196
|
+
Prism::SingletonClassNode
|
|
197
|
+
location = current.location
|
|
198
|
+
ranges <<
|
|
199
|
+
(location.start_offset..location.end_offset)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
ranges
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Mutators that read as data mutation, not as class-level
|
|
207
|
+
# method names (delete/update/store would collide with
|
|
208
|
+
# Active Record class methods). Screaming-case receivers
|
|
209
|
+
# only: `RENDERERS << x` is a registry mutation, while
|
|
210
|
+
# `User << x` is a class method call.
|
|
211
|
+
MUTATORS = Ractor.make_shareable(
|
|
212
|
+
SourceFile::CONST_MUTATORS
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
def flag_constant_mutation(node, receiver, method)
|
|
216
|
+
if node.is_a?(Prism::CallNode)
|
|
217
|
+
return unless MUTATORS.include?(node.name)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
name = classifier.const_name(receiver)
|
|
221
|
+
return unless name
|
|
222
|
+
return if name == "ENV"
|
|
223
|
+
return unless name.split("::").last
|
|
224
|
+
.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
225
|
+
|
|
226
|
+
flag(node, :constant_mutation, name: name, method: method)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def container_type(value)
|
|
230
|
+
case value
|
|
231
|
+
when Prism::HashNode, Prism::KeywordHashNode then "Hash"
|
|
232
|
+
when Prism::ArrayNode then "Array"
|
|
233
|
+
when Prism::CallNode
|
|
234
|
+
classifier.const_name(value.receiver) || "container"
|
|
235
|
+
else
|
|
236
|
+
"container"
|
|
105
237
|
end
|
|
106
238
|
end
|
|
107
239
|
|
|
@@ -111,21 +243,41 @@ module Audition
|
|
|
111
243
|
)
|
|
112
244
|
end
|
|
113
245
|
|
|
246
|
+
# Ternaries classify as strings when both branches are;
|
|
247
|
+
# `.freeze` binds tighter than `?:`, so they get parens.
|
|
114
248
|
def append_freeze(value)
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
end_offset
|
|
119
|
-
|
|
120
|
-
|
|
249
|
+
string = value.is_a?(Prism::StringNode) ||
|
|
250
|
+
value.is_a?(Prism::InterpolatedStringNode)
|
|
251
|
+
if string
|
|
252
|
+
offset = value.location.end_offset
|
|
253
|
+
Autofix.new(
|
|
254
|
+
start_offset: offset,
|
|
255
|
+
end_offset: offset,
|
|
256
|
+
replacement: ".freeze"
|
|
257
|
+
)
|
|
258
|
+
else
|
|
259
|
+
source = value.location.slice
|
|
260
|
+
Autofix.new(
|
|
261
|
+
start_offset: value.location.start_offset,
|
|
262
|
+
end_offset: value.location.end_offset,
|
|
263
|
+
replacement: "(#{source}).freeze"
|
|
264
|
+
)
|
|
265
|
+
end
|
|
121
266
|
end
|
|
122
267
|
|
|
268
|
+
# `X = :a, :b` is an array literal without brackets; the
|
|
269
|
+
# slice must gain them or the wrap becomes a multi-arg
|
|
270
|
+
# call (mail's ATTRIBUTES).
|
|
123
271
|
def wrap_make_shareable(value)
|
|
124
272
|
source = value.location.slice
|
|
273
|
+
if value.is_a?(Prism::ArrayNode) && value.opening_loc.nil?
|
|
274
|
+
source = "[#{source}]"
|
|
275
|
+
end
|
|
125
276
|
Autofix.new(
|
|
126
277
|
start_offset: value.location.start_offset,
|
|
127
278
|
end_offset: value.location.end_offset,
|
|
128
|
-
replacement: "Ractor.make_shareable(#{source})"
|
|
279
|
+
replacement: "Ractor.make_shareable(#{source})",
|
|
280
|
+
safety: :unsafe
|
|
129
281
|
)
|
|
130
282
|
end
|
|
131
283
|
|
|
@@ -136,7 +288,8 @@ module Audition
|
|
|
136
288
|
Autofix.new(
|
|
137
289
|
start_offset: call.location.start_offset,
|
|
138
290
|
end_offset: call.location.end_offset,
|
|
139
|
-
replacement: "Ractor.make_shareable(#{source})"
|
|
291
|
+
replacement: "Ractor.make_shareable(#{source})",
|
|
292
|
+
safety: :unsafe
|
|
140
293
|
)
|
|
141
294
|
end
|
|
142
295
|
end
|
|
@@ -82,7 +82,11 @@ module Audition
|
|
|
82
82
|
|
|
83
83
|
# Method definitions open fresh scopes; nothing inside
|
|
84
84
|
# them can capture the surrounding locals.
|
|
85
|
+
# A def opens a fresh scope, but its receiver
|
|
86
|
+
# expression (`def x.foo`) evaluates in the enclosing
|
|
87
|
+
# one and can capture an outer local.
|
|
85
88
|
def visit_def_node(node)
|
|
89
|
+
visit(node.receiver) if node.receiver
|
|
86
90
|
end
|
|
87
91
|
|
|
88
92
|
%i[
|
|
@@ -93,7 +97,7 @@ module Audition
|
|
|
93
97
|
visit_local_variable_and_write_node
|
|
94
98
|
visit_local_variable_target_node
|
|
95
99
|
].each do |method|
|
|
96
|
-
define_method(method) do |node|
|
|
100
|
+
define_method(method) do |node| # audition:disable unsafe-calls
|
|
97
101
|
@names << node.name.to_s if node.depth > @level
|
|
98
102
|
super(node)
|
|
99
103
|
end
|
|
@@ -39,12 +39,15 @@ module Audition
|
|
|
39
39
|
if node.receiver.nil?
|
|
40
40
|
if REQUIRE_METHODS.include?(node.name) && inside_def?
|
|
41
41
|
unless hoisted_already?(node)
|
|
42
|
+
autofix = rescued? ? nil : hoist_autofix(node)
|
|
42
43
|
flag(node, :runtime_require, method: node.name,
|
|
43
|
-
autofix:
|
|
44
|
+
autofix: autofix)
|
|
44
45
|
end
|
|
45
46
|
elsif node.name == :autoload
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
unless eagerly_loaded?(node)
|
|
48
|
+
flag(node, :autoload,
|
|
49
|
+
autofix: require_conversion_autofix(node))
|
|
50
|
+
end
|
|
48
51
|
end
|
|
49
52
|
end
|
|
50
53
|
end
|
|
@@ -56,12 +59,40 @@ module Audition
|
|
|
56
59
|
@def_depth -= 1
|
|
57
60
|
end
|
|
58
61
|
|
|
62
|
+
# A require under a rescue is conditional by construction
|
|
63
|
+
# (optional dependencies, tzinfo's tzinfo-data); hoisting
|
|
64
|
+
# it to an unguarded top-level require breaks every
|
|
65
|
+
# installation without the feature.
|
|
66
|
+
def visit_begin_node(node)
|
|
67
|
+
if node.rescue_clause
|
|
68
|
+
@rescue_depth = rescue_depth + 1
|
|
69
|
+
begin
|
|
70
|
+
super
|
|
71
|
+
ensure
|
|
72
|
+
@rescue_depth -= 1
|
|
73
|
+
end
|
|
74
|
+
else
|
|
75
|
+
super
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def visit_rescue_modifier_node(node)
|
|
80
|
+
@rescue_depth = rescue_depth + 1
|
|
81
|
+
super
|
|
82
|
+
ensure
|
|
83
|
+
@rescue_depth -= 1
|
|
84
|
+
end
|
|
85
|
+
|
|
59
86
|
private
|
|
60
87
|
|
|
61
88
|
def def_depth = @def_depth ||= 0
|
|
62
89
|
|
|
63
90
|
def inside_def? = def_depth.positive?
|
|
64
91
|
|
|
92
|
+
def rescue_depth = @rescue_depth ||= 0
|
|
93
|
+
|
|
94
|
+
def rescued? = rescue_depth.positive?
|
|
95
|
+
|
|
65
96
|
def literal_feature(node)
|
|
66
97
|
return nil unless node.name == :require ||
|
|
67
98
|
node.name == :require_relative
|
|
@@ -80,10 +111,12 @@ module Audition
|
|
|
80
111
|
file.top_level_requires.include?(feature.unescaped)
|
|
81
112
|
end
|
|
82
113
|
|
|
83
|
-
# Requiring is idempotent, so
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
114
|
+
# Requiring is idempotent, so a boot-time duplicate leaves
|
|
115
|
+
# the method's behavior alone; but it does load the feature
|
|
116
|
+
# in every context, and files can be deliberately lazy
|
|
117
|
+
# (optional dependencies, i18n's test-DSL mixins), so this
|
|
118
|
+
# is unsafe tier. `load` is excluded: it re-executes on
|
|
119
|
+
# every call.
|
|
87
120
|
def hoist_autofix(node)
|
|
88
121
|
feature = literal_feature(node)
|
|
89
122
|
return nil unless feature
|
|
@@ -93,11 +126,11 @@ module Audition
|
|
|
93
126
|
statement = "#{node.name} #{feature.location.slice}"
|
|
94
127
|
if insertion[:after_require]
|
|
95
128
|
Autofix.new(start_offset: offset, end_offset: offset,
|
|
96
|
-
replacement: "#{statement}\n")
|
|
129
|
+
replacement: "#{statement}\n", safety: :unsafe)
|
|
97
130
|
else
|
|
98
131
|
offset += 1 if newline_at?(offset)
|
|
99
132
|
Autofix.new(start_offset: offset, end_offset: offset,
|
|
100
|
-
replacement: "#{statement}\n\n")
|
|
133
|
+
replacement: "#{statement}\n\n", safety: :unsafe)
|
|
101
134
|
end
|
|
102
135
|
end
|
|
103
136
|
|
|
@@ -105,18 +138,68 @@ module Audition
|
|
|
105
138
|
file.source.byteslice(offset, 1) == "\n"
|
|
106
139
|
end
|
|
107
140
|
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
141
|
+
# The eager conversion is only offered when the autoloaded
|
|
142
|
+
# feature resolves to a file inside the target itself and
|
|
143
|
+
# that file carries no optional-dependency guard: a
|
|
144
|
+
# `rescue LoadError` means the file is deliberately lazy
|
|
145
|
+
# (i18n's key_value backend), and a feature that does not
|
|
146
|
+
# resolve locally may not even be installed.
|
|
147
|
+
def convertible_feature?(feature)
|
|
148
|
+
candidate = resolve_locally(feature)
|
|
149
|
+
!candidate.nil? &&
|
|
150
|
+
!File.read(candidate).include?("rescue LoadError")
|
|
151
|
+
rescue SystemCallError
|
|
152
|
+
false
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def resolve_locally(feature)
|
|
156
|
+
roots = [File.dirname(file.path)]
|
|
157
|
+
if (index = file.path.rindex("/lib/"))
|
|
158
|
+
roots << file.path[0, index + 4]
|
|
159
|
+
end
|
|
160
|
+
roots.filter_map { |root|
|
|
161
|
+
candidate = File.join(root, "#{feature}.rb")
|
|
162
|
+
candidate if File.file?(candidate)
|
|
163
|
+
}.first
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def autoload_feature(node)
|
|
111
167
|
args = node.arguments&.arguments
|
|
112
168
|
return nil unless args&.size == 2 &&
|
|
113
169
|
args[0].is_a?(Prism::SymbolNode) &&
|
|
114
170
|
args[1].is_a?(Prism::StringNode)
|
|
115
171
|
|
|
172
|
+
args[1]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# An autoload whose feature is also required at the top
|
|
176
|
+
# level of the same file is eagerly loaded; the remaining
|
|
177
|
+
# registration is a harmless safety net.
|
|
178
|
+
def eagerly_loaded?(node)
|
|
179
|
+
feature = autoload_feature(node)
|
|
180
|
+
!feature.nil? &&
|
|
181
|
+
file.top_level_requires.include?(feature.unescaped)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Eager require is exactly the trade a Ractor deployment
|
|
185
|
+
# wants, but it changes load timing: unsafe tier. The
|
|
186
|
+
# autoload line stays and the require lands at the end of
|
|
187
|
+
# the file, after every registration in it: requiring in
|
|
188
|
+
# registration order breaks mutual references (file A
|
|
189
|
+
# loads first, references B, whose registration was
|
|
190
|
+
# converted away), while a kept registration resolves any
|
|
191
|
+
# constant the eager load path touches.
|
|
192
|
+
def require_conversion_autofix(node)
|
|
193
|
+
feature = autoload_feature(node)
|
|
194
|
+
return nil unless feature
|
|
195
|
+
return nil unless convertible_feature?(feature.unescaped)
|
|
196
|
+
|
|
197
|
+
eof = file.source.bytesize
|
|
198
|
+
statement = "require #{feature.location.slice}\n"
|
|
116
199
|
Autofix.new(
|
|
117
|
-
start_offset:
|
|
118
|
-
end_offset:
|
|
119
|
-
replacement:
|
|
200
|
+
start_offset: eof,
|
|
201
|
+
end_offset: eof,
|
|
202
|
+
replacement: statement,
|
|
120
203
|
safety: :unsafe
|
|
121
204
|
)
|
|
122
205
|
end
|
|
@@ -83,6 +83,22 @@ module Audition
|
|
|
83
83
|
fix: "Fork before spawning Ractors, or use " \
|
|
84
84
|
"spawn/exec."
|
|
85
85
|
|
|
86
|
+
explain :define_method_closure,
|
|
87
|
+
severity: :warning,
|
|
88
|
+
message: "define_method carries its block as a Proc",
|
|
89
|
+
why: "Methods born from define_method keep the " \
|
|
90
|
+
"defining block; calling one from a non-main " \
|
|
91
|
+
"Ractor raises Ractor::IsolationError " \
|
|
92
|
+
"(\"defined with an un-shareable Proc\") " \
|
|
93
|
+
"unless the block was made shareable first.",
|
|
94
|
+
fix: "Generate the method with class_eval and a " \
|
|
95
|
+
"source string (how Rails fixed its autosave " \
|
|
96
|
+
"callbacks), or pass an isolated block: " \
|
|
97
|
+
"define_method(:x, " \
|
|
98
|
+
"&Ractor.make_shareable(proc { ... })). " \
|
|
99
|
+
"Isolated blocks cannot capture outer locals " \
|
|
100
|
+
"or use super."
|
|
101
|
+
|
|
86
102
|
explain :singleton_include,
|
|
87
103
|
severity: :warning,
|
|
88
104
|
message: "include Singleton memoizes the instance " \
|
|
@@ -119,6 +135,7 @@ module Audition
|
|
|
119
135
|
on :call_node do |node|
|
|
120
136
|
apply_rules(node)
|
|
121
137
|
flag_singleton_include(node)
|
|
138
|
+
flag_define_method(node)
|
|
122
139
|
end
|
|
123
140
|
|
|
124
141
|
private
|
|
@@ -143,12 +160,22 @@ module Audition
|
|
|
143
160
|
when Prism::ConstantReadNode
|
|
144
161
|
receiver.name.to_s == expected
|
|
145
162
|
when Prism::ConstantPathNode
|
|
146
|
-
receiver.location.slice == expected
|
|
163
|
+
receiver.location.slice.delete_prefix("::") == expected
|
|
147
164
|
else
|
|
148
165
|
false
|
|
149
166
|
end
|
|
150
167
|
end
|
|
151
168
|
|
|
169
|
+
# Only literal blocks are flagged: `&SOMETHING` block
|
|
170
|
+
# pass-throughs are how isolated, pre-shared procs get
|
|
171
|
+
# attached, which is the sanctioned form.
|
|
172
|
+
def flag_define_method(node)
|
|
173
|
+
return unless node.name == :define_method
|
|
174
|
+
return unless node.block.is_a?(Prism::BlockNode)
|
|
175
|
+
|
|
176
|
+
flag(node, :define_method_closure)
|
|
177
|
+
end
|
|
178
|
+
|
|
152
179
|
def flag_singleton_include(node)
|
|
153
180
|
return unless node.name == :include && node.receiver.nil?
|
|
154
181
|
|