audition 0.2.0 → 0.2.2
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 +23 -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 +56 -12
- data/lib/audition/fixer.rb +10 -4
- data/lib/audition/report.rb +37 -8
- data/lib/audition/rewriters.rb +373 -49
- 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 +84 -13
- data/lib/audition/static/checks/ractor_isolation.rb +42 -12
- data/lib/audition/static/checks/unsafe_calls.rb +1 -1
- data/lib/audition/static/graph_audit.rb +153 -9
- data/lib/audition/static/literal_classifier.rb +7 -2
- data/lib/audition/static/source_file.rb +67 -4
- data/lib/audition/version.rb +1 -1
- metadata +3 -3
|
@@ -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,
|
|
@@ -104,7 +104,12 @@ module Audition
|
|
|
104
104
|
end
|
|
105
105
|
|
|
106
106
|
on :call_node do |node|
|
|
107
|
-
flag_constant_mutation(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, "[]=")
|
|
108
113
|
end
|
|
109
114
|
|
|
110
115
|
on :constant_path_write_node,
|
|
@@ -117,50 +122,108 @@ module Audition
|
|
|
117
122
|
def examine(name, node, value)
|
|
118
123
|
return if file.shareable_constants?
|
|
119
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)
|
|
120
130
|
case classifier.classify(value)
|
|
121
131
|
when :mutable_string
|
|
122
132
|
flag(node, :mutable_string, name: name,
|
|
123
|
-
autofix: append_freeze(value))
|
|
133
|
+
autofix: fix_ok ? append_freeze(value) : nil)
|
|
124
134
|
when :mutable_container
|
|
125
135
|
flag(node, :mutable_container, name: name,
|
|
126
136
|
type: container_type(value),
|
|
127
|
-
autofix: wrap_make_shareable(value))
|
|
137
|
+
autofix: fix_ok ? wrap_make_shareable(value) : nil)
|
|
128
138
|
when :shallow_freeze
|
|
129
139
|
flag(node, :shallow_freeze, name: name,
|
|
130
|
-
autofix:
|
|
140
|
+
autofix:
|
|
141
|
+
fix_ok ? replace_with_make_shareable(value) : nil)
|
|
131
142
|
when :sync_primitive
|
|
132
143
|
klass = value.is_a?(Prism::CallNode) &&
|
|
133
144
|
classifier.const_name(value.receiver)
|
|
134
145
|
flag(node, :sync_primitive, name: name,
|
|
135
146
|
klass: klass || "sync primitive")
|
|
136
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?)
|
|
137
157
|
flag(node, :proc_constant, name: name,
|
|
138
|
-
autofix: wrap_make_shareable(value))
|
|
158
|
+
autofix: wrappable ? wrap_make_shareable(value) : nil)
|
|
139
159
|
when :default_proc
|
|
140
160
|
flag(node, :hash_default_proc, name: name)
|
|
141
161
|
end
|
|
142
162
|
end
|
|
143
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
|
+
|
|
144
206
|
# Mutators that read as data mutation, not as class-level
|
|
145
207
|
# method names (delete/update/store would collide with
|
|
146
208
|
# Active Record class methods). Screaming-case receivers
|
|
147
209
|
# only: `RENDERERS << x` is a registry mutation, while
|
|
148
210
|
# `User << x` is a class method call.
|
|
149
211
|
MUTATORS = Ractor.make_shareable(
|
|
150
|
-
|
|
212
|
+
SourceFile::CONST_MUTATORS
|
|
151
213
|
)
|
|
152
214
|
|
|
153
|
-
def flag_constant_mutation(node)
|
|
154
|
-
|
|
215
|
+
def flag_constant_mutation(node, receiver, method)
|
|
216
|
+
if node.is_a?(Prism::CallNode)
|
|
217
|
+
return unless MUTATORS.include?(node.name)
|
|
218
|
+
end
|
|
155
219
|
|
|
156
|
-
name = classifier.const_name(
|
|
220
|
+
name = classifier.const_name(receiver)
|
|
157
221
|
return unless name
|
|
158
222
|
return if name == "ENV"
|
|
159
223
|
return unless name.split("::").last
|
|
160
224
|
.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
161
225
|
|
|
162
|
-
flag(node, :constant_mutation, name: name,
|
|
163
|
-
method: node.name.to_s)
|
|
226
|
+
flag(node, :constant_mutation, name: name, method: method)
|
|
164
227
|
end
|
|
165
228
|
|
|
166
229
|
def container_type(value)
|
|
@@ -202,12 +265,19 @@ module Audition
|
|
|
202
265
|
end
|
|
203
266
|
end
|
|
204
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).
|
|
205
271
|
def wrap_make_shareable(value)
|
|
206
272
|
source = value.location.slice
|
|
273
|
+
if value.is_a?(Prism::ArrayNode) && value.opening_loc.nil?
|
|
274
|
+
source = "[#{source}]"
|
|
275
|
+
end
|
|
207
276
|
Autofix.new(
|
|
208
277
|
start_offset: value.location.start_offset,
|
|
209
278
|
end_offset: value.location.end_offset,
|
|
210
|
-
replacement: "Ractor.make_shareable(#{source})"
|
|
279
|
+
replacement: "Ractor.make_shareable(#{source})",
|
|
280
|
+
safety: :unsafe
|
|
211
281
|
)
|
|
212
282
|
end
|
|
213
283
|
|
|
@@ -218,7 +288,8 @@ module Audition
|
|
|
218
288
|
Autofix.new(
|
|
219
289
|
start_offset: call.location.start_offset,
|
|
220
290
|
end_offset: call.location.end_offset,
|
|
221
|
-
replacement: "Ractor.make_shareable(#{source})"
|
|
291
|
+
replacement: "Ractor.make_shareable(#{source})",
|
|
292
|
+
safety: :unsafe
|
|
222
293
|
)
|
|
223
294
|
end
|
|
224
295
|
end
|
|
@@ -82,21 +82,51 @@ 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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
# Plain defs, not a define_method loop: the parallel
|
|
93
|
+
# scan calls these from worker Ractors, and a method
|
|
94
|
+
# born from define_method carries an un-shareable Proc
|
|
95
|
+
# that raises when dispatched from another Ractor.
|
|
96
|
+
def visit_local_variable_read_node(node)
|
|
97
|
+
note(node)
|
|
98
|
+
super
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def visit_local_variable_write_node(node)
|
|
102
|
+
note(node)
|
|
103
|
+
super
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def visit_local_variable_operator_write_node(node)
|
|
107
|
+
note(node)
|
|
108
|
+
super
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def visit_local_variable_or_write_node(node)
|
|
112
|
+
note(node)
|
|
113
|
+
super
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def visit_local_variable_and_write_node(node)
|
|
117
|
+
note(node)
|
|
118
|
+
super
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def visit_local_variable_target_node(node)
|
|
122
|
+
note(node)
|
|
123
|
+
super
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
private
|
|
127
|
+
|
|
128
|
+
def note(node)
|
|
129
|
+
@names << node.name.to_s if node.depth > @level
|
|
100
130
|
end
|
|
101
131
|
end
|
|
102
132
|
end
|
|
@@ -48,6 +48,18 @@ module Audition
|
|
|
48
48
|
"hook. If the value genuinely must be computed at " \
|
|
49
49
|
"runtime, proxy the write to the main Ractor or use " \
|
|
50
50
|
"Ractor.store_if_absent."
|
|
51
|
+
BEST_EFFORT_WHY =
|
|
52
|
+
"Writes wrap their value in Ractor.make_shareable with " \
|
|
53
|
+
"a rescue fallback: shareable values are deeply frozen " \
|
|
54
|
+
"and readable from any Ractor, while unshareable values " \
|
|
55
|
+
"keep their old (Ractor-hostile) behavior. Whether this " \
|
|
56
|
+
"state is actually safe depends on what the application " \
|
|
57
|
+
"assigns; the dynamic probe reports ground truth."
|
|
58
|
+
BEST_EFFORT_FIX =
|
|
59
|
+
"Assign only shareable values (strings, symbols, frozen " \
|
|
60
|
+
"containers) before spawning Ractors. Configuration " \
|
|
61
|
+
"that cannot be shareable needs per-Ractor state or a " \
|
|
62
|
+
"main-Ractor proxy instead."
|
|
51
63
|
|
|
52
64
|
# @param sources [Hash{String => String}] path => source
|
|
53
65
|
# @return [Array<Finding>]
|
|
@@ -111,9 +123,10 @@ module Audition
|
|
|
111
123
|
|
|
112
124
|
variable = decl.name.split("#").last
|
|
113
125
|
owner = display_owner(decl.owner)
|
|
114
|
-
|
|
126
|
+
verdict = @frozen_memos["#{owner}/#{variable}"]
|
|
115
127
|
each_local_definition(decl).map do |defn|
|
|
116
|
-
|
|
128
|
+
case verdict
|
|
129
|
+
when :frozen
|
|
117
130
|
finding_at(
|
|
118
131
|
defn,
|
|
119
132
|
check: "class-level-state",
|
|
@@ -123,6 +136,16 @@ module Audition
|
|
|
123
136
|
why: FROZEN_MEMO_WHY,
|
|
124
137
|
fix: FROZEN_MEMO_FIX
|
|
125
138
|
)
|
|
139
|
+
when :best_effort
|
|
140
|
+
finding_at(
|
|
141
|
+
defn,
|
|
142
|
+
check: "class-level-state",
|
|
143
|
+
severity: :warning,
|
|
144
|
+
message: "best-effort frozen state #{variable} " \
|
|
145
|
+
"on #{owner}",
|
|
146
|
+
why: BEST_EFFORT_WHY,
|
|
147
|
+
fix: BEST_EFFORT_FIX
|
|
148
|
+
)
|
|
126
149
|
else
|
|
127
150
|
finding_at(
|
|
128
151
|
defn,
|
|
@@ -179,15 +202,115 @@ module Audition
|
|
|
179
202
|
)
|
|
180
203
|
collector.groups.each do |(namespace, name), ops|
|
|
181
204
|
key = "#{namespace}/#{name}"
|
|
182
|
-
verdict =
|
|
183
|
-
|
|
184
|
-
map[key] =
|
|
185
|
-
(map[key] == :dirty) ? :dirty : verdict
|
|
205
|
+
verdict = group_verdict(ops, classifier)
|
|
206
|
+
map[key] = weaker_verdict(map[key], verdict)
|
|
186
207
|
end
|
|
208
|
+
mark_singleton_reopenings(file, map)
|
|
187
209
|
end
|
|
188
210
|
map
|
|
189
211
|
end
|
|
190
212
|
|
|
213
|
+
# `class << Foo` bodies write ivars on Foo's singleton
|
|
214
|
+
# class outside the collector's `class << self` tracking;
|
|
215
|
+
# any such write taints the group so a reopening in one
|
|
216
|
+
# file can never be shadowed by a clean memo in another.
|
|
217
|
+
def mark_singleton_reopenings(file, map)
|
|
218
|
+
queue = [file.root]
|
|
219
|
+
until queue.empty?
|
|
220
|
+
node = queue.shift
|
|
221
|
+
queue.concat(node.child_nodes.compact)
|
|
222
|
+
next unless node.is_a?(Prism::SingletonClassNode)
|
|
223
|
+
|
|
224
|
+
owner =
|
|
225
|
+
case node.expression
|
|
226
|
+
when Prism::ConstantReadNode
|
|
227
|
+
node.expression.name.to_s
|
|
228
|
+
when Prism::ConstantPathNode
|
|
229
|
+
node.expression.location.slice
|
|
230
|
+
end
|
|
231
|
+
next unless owner
|
|
232
|
+
|
|
233
|
+
ivar_names_in(node).each do |ivar|
|
|
234
|
+
map["#{owner}/#{ivar}"] = :dirty
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
IVAR_NODES = [
|
|
240
|
+
Prism::InstanceVariableReadNode,
|
|
241
|
+
Prism::InstanceVariableWriteNode,
|
|
242
|
+
Prism::InstanceVariableOrWriteNode,
|
|
243
|
+
Prism::InstanceVariableOperatorWriteNode,
|
|
244
|
+
Prism::InstanceVariableAndWriteNode,
|
|
245
|
+
Prism::InstanceVariableTargetNode
|
|
246
|
+
].freeze
|
|
247
|
+
|
|
248
|
+
def ivar_names_in(node)
|
|
249
|
+
names = []
|
|
250
|
+
queue = [node]
|
|
251
|
+
until queue.empty?
|
|
252
|
+
current = queue.shift
|
|
253
|
+
queue.concat(current.child_nodes.compact)
|
|
254
|
+
if IVAR_NODES.any? { |type| current.is_a?(type) }
|
|
255
|
+
names << current.name.to_s
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
names.uniq
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Cross-file merge keeps the weakest promise: any dirty file
|
|
262
|
+
# taints the group, and best-effort beats fully frozen.
|
|
263
|
+
VERDICT_RANK = {dirty: 0, best_effort: 1, frozen: 2}.freeze
|
|
264
|
+
|
|
265
|
+
def weaker_verdict(existing, verdict)
|
|
266
|
+
return verdict unless existing
|
|
267
|
+
|
|
268
|
+
[existing, verdict].min_by { |v| VERDICT_RANK[v] }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def group_verdict(ops, classifier)
|
|
272
|
+
return :dirty if ops.any? { |op| op[:body] }
|
|
273
|
+
return :dirty if ops.any? { |op| op[:kind] == :other }
|
|
274
|
+
|
|
275
|
+
memos = Rewriters::Memoization.memo_sites(ops)
|
|
276
|
+
if memos.any?
|
|
277
|
+
return group_frozen?(ops, classifier) ? :frozen : :dirty
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
281
|
+
return :dirty if writes.empty?
|
|
282
|
+
|
|
283
|
+
all_safe = writes.all? do |w|
|
|
284
|
+
value = w[:node].value
|
|
285
|
+
best_effort_value?(value) ||
|
|
286
|
+
Rewriters::Memoization.frozen_call?(value) ||
|
|
287
|
+
classifier.classify(value) == :shareable
|
|
288
|
+
end
|
|
289
|
+
wrapped = writes.any? do |w|
|
|
290
|
+
best_effort_value?(w[:node].value)
|
|
291
|
+
end
|
|
292
|
+
(all_safe && wrapped) ? :best_effort : :dirty
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Matches the emitted setter recipe:
|
|
296
|
+
# (Ractor.make_shareable(value) rescue value)
|
|
297
|
+
def best_effort_value?(node)
|
|
298
|
+
inner = node
|
|
299
|
+
if inner.is_a?(Prism::ParenthesesNode)
|
|
300
|
+
body = inner.body&.body
|
|
301
|
+
return false unless body && body.size == 1
|
|
302
|
+
|
|
303
|
+
inner = body[0]
|
|
304
|
+
end
|
|
305
|
+
return false unless inner.is_a?(Prism::RescueModifierNode)
|
|
306
|
+
|
|
307
|
+
call = inner.expression
|
|
308
|
+
call.is_a?(Prism::CallNode) &&
|
|
309
|
+
call.name == :make_shareable &&
|
|
310
|
+
call.receiver.is_a?(Prism::ConstantReadNode) &&
|
|
311
|
+
call.receiver.name == :Ractor
|
|
312
|
+
end
|
|
313
|
+
|
|
191
314
|
def group_frozen?(ops, classifier)
|
|
192
315
|
return false if ops.any? { |op| op[:body] }
|
|
193
316
|
return false if ops.any? { |op| op[:kind] == :other }
|
|
@@ -203,14 +326,35 @@ module Audition
|
|
|
203
326
|
|
|
204
327
|
memos.all? do |memo|
|
|
205
328
|
value = memo[:op][:node].value
|
|
206
|
-
|
|
207
|
-
|
|
329
|
+
frozen_memo_value?(value, classifier)
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# A bare `.freeze` on a container literal is shallow: the
|
|
334
|
+
# elements stay mutable and the cross-Ractor read still
|
|
335
|
+
# raises, so it must not count as frozen. A `.freeze` on a
|
|
336
|
+
# call result is accepted as the memo recipe (the dynamic
|
|
337
|
+
# probe verifies the value); provably shareable values pass.
|
|
338
|
+
def frozen_memo_value?(value, classifier)
|
|
339
|
+
return true if classifier.classify(value) == :shareable
|
|
340
|
+
return false unless Rewriters::Memoization.frozen_call?(value)
|
|
341
|
+
|
|
342
|
+
case value.receiver
|
|
343
|
+
when Prism::ArrayNode, Prism::HashNode,
|
|
344
|
+
Prism::KeywordHashNode
|
|
345
|
+
false
|
|
346
|
+
else
|
|
347
|
+
true
|
|
208
348
|
end
|
|
209
349
|
end
|
|
210
350
|
|
|
211
351
|
# "Payments::<Payments>" reads as noise; show "Payments".
|
|
352
|
+
# Nested singleton owners produce nested angle brackets, so
|
|
353
|
+
# the strip repeats until the tail is gone.
|
|
212
354
|
def display_owner(owner)
|
|
213
|
-
|
|
355
|
+
name = owner&.name || "?"
|
|
356
|
+
name = name.sub(/::<.*>\z/m, "") while name.match?(/::<.*>\z/m)
|
|
357
|
+
name
|
|
214
358
|
end
|
|
215
359
|
|
|
216
360
|
def path_from_uri(uri)
|
|
@@ -64,10 +64,14 @@ module Audition
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# `::Mutex` and `Mutex` are the same constant for matching
|
|
68
|
+
# purposes; the leading colons are stripped.
|
|
67
69
|
def const_name(node)
|
|
68
70
|
case node
|
|
69
|
-
when Prism::ConstantReadNode
|
|
70
|
-
|
|
71
|
+
when Prism::ConstantReadNode
|
|
72
|
+
node.name.to_s
|
|
73
|
+
when Prism::ConstantPathNode
|
|
74
|
+
node.location.slice.delete_prefix("::")
|
|
71
75
|
end
|
|
72
76
|
end
|
|
73
77
|
|
|
@@ -96,6 +100,7 @@ module Audition
|
|
|
96
100
|
name = const_name(receiver)
|
|
97
101
|
return :sync_primitive if SYNC_PRIMITIVES.include?(name)
|
|
98
102
|
return :shareable if SHAREABLE_FACTORIES.include?(name)
|
|
103
|
+
return :proc if name == "Proc" && node.block
|
|
99
104
|
# Hash.new retains its block as the default proc;
|
|
100
105
|
# Array.new only uses its block to build elements.
|
|
101
106
|
return :default_proc if name == "Hash" && node.block
|
|
@@ -25,11 +25,22 @@ module Audition
|
|
|
25
25
|
|
|
26
26
|
def root = parse_result.value
|
|
27
27
|
|
|
28
|
+
# Ruby matches magic-comment keys and values
|
|
29
|
+
# case-insensitively and ignores magic comments that appear
|
|
30
|
+
# after the first statement (verified on 4.0); Prism reports
|
|
31
|
+
# them all, so both rules are applied here.
|
|
28
32
|
def magic_comment(key)
|
|
33
|
+
limit = first_statement_offset
|
|
29
34
|
comment = parse_result.magic_comments.find do |mc|
|
|
30
|
-
mc.key_loc.slice == key
|
|
35
|
+
mc.key_loc.slice.downcase == key &&
|
|
36
|
+
mc.key_loc.start_offset < limit
|
|
31
37
|
end
|
|
32
|
-
comment&.value_loc&.slice
|
|
38
|
+
comment&.value_loc&.slice&.downcase
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def first_statement_offset
|
|
42
|
+
first = root.statements.body.first
|
|
43
|
+
first ? first.location.start_offset : source.bytesize
|
|
33
44
|
end
|
|
34
45
|
|
|
35
46
|
# String literals in this file are frozen (and therefore
|
|
@@ -48,7 +59,9 @@ module Audition
|
|
|
48
59
|
end
|
|
49
60
|
|
|
50
61
|
def line_at(number)
|
|
51
|
-
@lines ||= source.lines
|
|
62
|
+
@lines ||= source.lines.map do |line|
|
|
63
|
+
line.dup.force_encoding(Encoding::UTF_8).scrub
|
|
64
|
+
end
|
|
52
65
|
@lines[number - 1]&.strip
|
|
53
66
|
end
|
|
54
67
|
|
|
@@ -87,6 +100,54 @@ module Audition
|
|
|
87
100
|
@raw ||= source.dup.force_encoding(Encoding::BINARY)
|
|
88
101
|
end
|
|
89
102
|
|
|
103
|
+
# Method names that read as in-place data mutation when
|
|
104
|
+
# sent to a constant. Shared by the mutable-constants check
|
|
105
|
+
# and the fixers: a constant this file mutates is a
|
|
106
|
+
# deliberate accumulator (sinatra's PARAMS_CONFIG) and must
|
|
107
|
+
# never be frozen by magic comment or wrap.
|
|
108
|
+
CONST_MUTATORS = %i[
|
|
109
|
+
[]= << push unshift concat merge! replace
|
|
110
|
+
].freeze
|
|
111
|
+
|
|
112
|
+
# Index writes are their own node types, not calls:
|
|
113
|
+
# `COUNTS[k] += 1` is IndexOperatorWriteNode and
|
|
114
|
+
# `CACHE[k] ||= v` is IndexOrWriteNode; missing them let a
|
|
115
|
+
# `.freeze` autofix produce FrozenError at runtime.
|
|
116
|
+
INDEX_WRITES = [
|
|
117
|
+
Prism::IndexOperatorWriteNode,
|
|
118
|
+
Prism::IndexOrWriteNode,
|
|
119
|
+
Prism::IndexAndWriteNode,
|
|
120
|
+
Prism::IndexTargetNode
|
|
121
|
+
].freeze
|
|
122
|
+
|
|
123
|
+
# @return [Array<String>] names of constants that receive a
|
|
124
|
+
# mutator call somewhere in this file
|
|
125
|
+
def mutated_constants
|
|
126
|
+
@mutated_constants ||= begin
|
|
127
|
+
names = []
|
|
128
|
+
queue = [root]
|
|
129
|
+
until queue.empty?
|
|
130
|
+
node = queue.shift
|
|
131
|
+
queue.concat(node.child_nodes.compact)
|
|
132
|
+
receiver =
|
|
133
|
+
if node.is_a?(Prism::CallNode) &&
|
|
134
|
+
CONST_MUTATORS.include?(node.name)
|
|
135
|
+
node.receiver
|
|
136
|
+
elsif INDEX_WRITES.any? { |type| node.is_a?(type) }
|
|
137
|
+
node.receiver
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
case receiver
|
|
141
|
+
when Prism::ConstantReadNode
|
|
142
|
+
names << receiver.name.to_s
|
|
143
|
+
when Prism::ConstantPathNode
|
|
144
|
+
names << receiver.location.slice
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
names.uniq
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
90
151
|
# Keys Ruby actually honors. Prism reports every comment
|
|
91
152
|
# shaped like `# key: value`, which sweeps up documentation
|
|
92
153
|
# (`# I18n.t: 'date.formats.short'`); inserting after those
|
|
@@ -104,8 +165,10 @@ module Audition
|
|
|
104
165
|
newline = raw.index("\n")
|
|
105
166
|
offset = newline ? newline + 1 : raw.bytesize
|
|
106
167
|
end
|
|
168
|
+
limit = first_statement_offset
|
|
107
169
|
after_magic = parse_result.magic_comments.filter_map do |mc|
|
|
108
|
-
next unless MAGIC_KEYS.include?(mc.key_loc.slice)
|
|
170
|
+
next unless MAGIC_KEYS.include?(mc.key_loc.slice.downcase)
|
|
171
|
+
next unless mc.key_loc.start_offset < limit
|
|
109
172
|
|
|
110
173
|
newline = raw.index("\n", mc.value_loc.end_offset)
|
|
111
174
|
newline ? newline + 1 : raw.bytesize
|
data/lib/audition/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: audition
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
@@ -57,14 +57,14 @@ dependencies:
|
|
|
57
57
|
requirements:
|
|
58
58
|
- - ">="
|
|
59
59
|
- !ruby/object:Gem::Version
|
|
60
|
-
version: '0
|
|
60
|
+
version: '1.0'
|
|
61
61
|
type: :runtime
|
|
62
62
|
prerelease: false
|
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
|
64
64
|
requirements:
|
|
65
65
|
- - ">="
|
|
66
66
|
- !ruby/object:Gem::Version
|
|
67
|
-
version: '0
|
|
67
|
+
version: '1.0'
|
|
68
68
|
- !ruby/object:Gem::Dependency
|
|
69
69
|
name: tty-link
|
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|