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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "rubydex"
|
|
4
|
+
require_relative "../rewriters"
|
|
4
5
|
|
|
5
6
|
module Audition
|
|
6
7
|
module Static
|
|
@@ -26,9 +27,39 @@ module Audition
|
|
|
26
27
|
"it while it holds a non-shareable value (verified on " \
|
|
27
28
|
"Ruby 4.0)."
|
|
28
29
|
STATE_FIX =
|
|
29
|
-
"Precompute and freeze the value at load time
|
|
30
|
-
"
|
|
31
|
-
"
|
|
30
|
+
"Precompute and freeze the value at load time (for " \
|
|
31
|
+
"per-subclass values, in the inherited hook). For " \
|
|
32
|
+
"collections, rebuild and refreeze on write, " \
|
|
33
|
+
"Rails-style copy-on-write: self.list = " \
|
|
34
|
+
"(list + [item]).freeze; never mutate in place. As a " \
|
|
35
|
+
"last resort use Ractor.store_if_absent for lazy " \
|
|
36
|
+
"initialization or per-Ractor state in " \
|
|
37
|
+
"Ractor.current[:key]."
|
|
38
|
+
FROZEN_MEMO_WHY =
|
|
39
|
+
"Every write memoizes a shareable (frozen) value, so " \
|
|
40
|
+
"non-main Ractors can read it once it has been " \
|
|
41
|
+
"computed; only the first write must happen on the " \
|
|
42
|
+
"main Ractor, or it raises Ractor::IsolationError. " \
|
|
43
|
+
"This is the pattern Rails core uses for its own " \
|
|
44
|
+
"memoized class state."
|
|
45
|
+
FROZEN_MEMO_FIX =
|
|
46
|
+
"Warm the cache at boot, before spawning Ractors: call " \
|
|
47
|
+
"the memoizing method from an initializer or on_load " \
|
|
48
|
+
"hook. If the value genuinely must be computed at " \
|
|
49
|
+
"runtime, proxy the write to the main Ractor or use " \
|
|
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."
|
|
32
63
|
|
|
33
64
|
# @param sources [Hash{String => String}] path => source
|
|
34
65
|
# @return [Array<Finding>]
|
|
@@ -38,6 +69,7 @@ module Audition
|
|
|
38
69
|
graph.index_source(path, code, "ruby")
|
|
39
70
|
end
|
|
40
71
|
@sources = sources
|
|
72
|
+
@frozen_memos = frozen_memo_map(sources)
|
|
41
73
|
audit(graph)
|
|
42
74
|
end
|
|
43
75
|
|
|
@@ -91,15 +123,39 @@ module Audition
|
|
|
91
123
|
|
|
92
124
|
variable = decl.name.split("#").last
|
|
93
125
|
owner = display_owner(decl.owner)
|
|
126
|
+
verdict = @frozen_memos["#{owner}/#{variable}"]
|
|
94
127
|
each_local_definition(decl).map do |defn|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
128
|
+
case verdict
|
|
129
|
+
when :frozen
|
|
130
|
+
finding_at(
|
|
131
|
+
defn,
|
|
132
|
+
check: "class-level-state",
|
|
133
|
+
severity: :info,
|
|
134
|
+
message: "frozen memoization #{variable} on " \
|
|
135
|
+
"#{owner}; warm it on the main Ractor",
|
|
136
|
+
why: FROZEN_MEMO_WHY,
|
|
137
|
+
fix: FROZEN_MEMO_FIX
|
|
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
|
+
)
|
|
149
|
+
else
|
|
150
|
+
finding_at(
|
|
151
|
+
defn,
|
|
152
|
+
check: "class-level-state",
|
|
153
|
+
message: "class-level instance variable " \
|
|
154
|
+
"#{variable} on #{owner}",
|
|
155
|
+
why: STATE_WHY,
|
|
156
|
+
fix: STATE_FIX
|
|
157
|
+
)
|
|
158
|
+
end
|
|
103
159
|
end
|
|
104
160
|
end
|
|
105
161
|
|
|
@@ -109,12 +165,13 @@ module Audition
|
|
|
109
165
|
end
|
|
110
166
|
end
|
|
111
167
|
|
|
112
|
-
def finding_at(defn, check:, message:, why:, fix
|
|
168
|
+
def finding_at(defn, check:, message:, why:, fix:,
|
|
169
|
+
severity: :error)
|
|
113
170
|
path = path_from_uri(defn.location.uri)
|
|
114
171
|
line = defn.location.start_line + 1
|
|
115
172
|
Finding.new(
|
|
116
173
|
check: check,
|
|
117
|
-
severity:
|
|
174
|
+
severity: severity,
|
|
118
175
|
message: message,
|
|
119
176
|
why: why,
|
|
120
177
|
fix: fix,
|
|
@@ -124,9 +181,180 @@ module Audition
|
|
|
124
181
|
)
|
|
125
182
|
end
|
|
126
183
|
|
|
184
|
+
# Frozen memoization, the shape Rails core ships: every
|
|
185
|
+
# write to the ivar is a memo site (`@x ||=` or a defined?
|
|
186
|
+
# guard) whose value is provably shareable, either a frozen
|
|
187
|
+
# literal, an explicit `.freeze` or make_shareable call.
|
|
188
|
+
# Such state is read-safe from any Ractor once warmed, so
|
|
189
|
+
# the finding downgrades to an info note. Any stray write
|
|
190
|
+
# or unproven value keeps the error. Keys are
|
|
191
|
+
# "Owner::Path/@name"; a dirty verdict in any file wins.
|
|
192
|
+
def frozen_memo_map(sources)
|
|
193
|
+
map = {}
|
|
194
|
+
sources.each do |path, code|
|
|
195
|
+
file = SourceFile.new(source: code, path: path)
|
|
196
|
+
next unless file.valid_syntax?
|
|
197
|
+
|
|
198
|
+
collector = Rewriters::Memoization::SingletonIvars.new
|
|
199
|
+
collector.visit(file.root)
|
|
200
|
+
classifier = LiteralClassifier.new(
|
|
201
|
+
frozen_string_literal: file.frozen_string_literal?
|
|
202
|
+
)
|
|
203
|
+
collector.groups.each do |(namespace, name), ops|
|
|
204
|
+
key = "#{namespace}/#{name}"
|
|
205
|
+
verdict = group_verdict(ops, classifier)
|
|
206
|
+
map[key] = weaker_verdict(map[key], verdict)
|
|
207
|
+
end
|
|
208
|
+
mark_singleton_reopenings(file, map)
|
|
209
|
+
end
|
|
210
|
+
map
|
|
211
|
+
end
|
|
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
|
+
|
|
314
|
+
def group_frozen?(ops, classifier)
|
|
315
|
+
return false if ops.any? { |op| op[:body] }
|
|
316
|
+
return false if ops.any? { |op| op[:kind] == :other }
|
|
317
|
+
|
|
318
|
+
memos = Rewriters::Memoization.memo_sites(ops)
|
|
319
|
+
return false if memos.empty?
|
|
320
|
+
return false if
|
|
321
|
+
Rewriters::Memoization.orphan_guards?(ops, memos)
|
|
322
|
+
|
|
323
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
324
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
325
|
+
return false unless (writes - memo_ops).empty?
|
|
326
|
+
|
|
327
|
+
memos.all? do |memo|
|
|
328
|
+
value = memo[:op][:node].value
|
|
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
|
|
348
|
+
end
|
|
349
|
+
end
|
|
350
|
+
|
|
127
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.
|
|
128
354
|
def display_owner(owner)
|
|
129
|
-
|
|
355
|
+
name = owner&.name || "?"
|
|
356
|
+
name = name.sub(/::<.*>\z/m, "") while name.match?(/::<.*>\z/m)
|
|
357
|
+
name
|
|
130
358
|
end
|
|
131
359
|
|
|
132
360
|
def path_from_uri(uri)
|
|
@@ -7,10 +7,12 @@ module Audition
|
|
|
7
7
|
# Classifies a Prism expression node by Ractor shareability:
|
|
8
8
|
# :shareable proven deeply shareable
|
|
9
9
|
# :mutable_string unfrozen String literal
|
|
10
|
-
# :mutable_container Array/Hash literal
|
|
10
|
+
# :mutable_container Array/Hash literal or constructor
|
|
11
11
|
# :shallow_freeze frozen container with mutable elements
|
|
12
12
|
# :sync_primitive Mutex/Queue/... constructor
|
|
13
13
|
# :proc lambda or proc
|
|
14
|
+
# :default_proc Hash.new with a block; the block
|
|
15
|
+
# survives .freeze and stays unshareable
|
|
14
16
|
# :unknown cannot tell statically
|
|
15
17
|
class LiteralClassifier
|
|
16
18
|
SYNC_PRIMITIVES = %w[
|
|
@@ -43,7 +45,7 @@ module Audition
|
|
|
43
45
|
classify_interpolated_string(node)
|
|
44
46
|
when Prism::ArrayNode, Prism::HashNode,
|
|
45
47
|
Prism::KeywordHashNode
|
|
46
|
-
|
|
48
|
+
container_kind(node)
|
|
47
49
|
when Prism::RangeNode
|
|
48
50
|
ends = [node.left, node.right].compact
|
|
49
51
|
if ends.all? { |n| classify(n) == :shareable }
|
|
@@ -55,15 +57,21 @@ module Audition
|
|
|
55
57
|
:proc
|
|
56
58
|
when Prism::CallNode
|
|
57
59
|
classify_call(node)
|
|
60
|
+
when Prism::IfNode
|
|
61
|
+
ternary_kind(node)
|
|
58
62
|
else
|
|
59
63
|
:unknown
|
|
60
64
|
end
|
|
61
65
|
end
|
|
62
66
|
|
|
67
|
+
# `::Mutex` and `Mutex` are the same constant for matching
|
|
68
|
+
# purposes; the leading colons are stripped.
|
|
63
69
|
def const_name(node)
|
|
64
70
|
case node
|
|
65
|
-
when Prism::ConstantReadNode
|
|
66
|
-
|
|
71
|
+
when Prism::ConstantReadNode
|
|
72
|
+
node.name.to_s
|
|
73
|
+
when Prism::ConstantPathNode
|
|
74
|
+
node.location.slice.delete_prefix("::")
|
|
67
75
|
end
|
|
68
76
|
end
|
|
69
77
|
|
|
@@ -92,6 +100,14 @@ module Audition
|
|
|
92
100
|
name = const_name(receiver)
|
|
93
101
|
return :sync_primitive if SYNC_PRIMITIVES.include?(name)
|
|
94
102
|
return :shareable if SHAREABLE_FACTORIES.include?(name)
|
|
103
|
+
return :proc if name == "Proc" && node.block
|
|
104
|
+
# Hash.new retains its block as the default proc;
|
|
105
|
+
# Array.new only uses its block to build elements.
|
|
106
|
+
return :default_proc if name == "Hash" && node.block
|
|
107
|
+
|
|
108
|
+
if %w[Hash Array].include?(name)
|
|
109
|
+
return :mutable_container
|
|
110
|
+
end
|
|
95
111
|
|
|
96
112
|
:unknown
|
|
97
113
|
when :define
|
|
@@ -113,26 +129,74 @@ module Audition
|
|
|
113
129
|
:shareable
|
|
114
130
|
when Prism::ArrayNode, Prism::HashNode
|
|
115
131
|
deep_classify(receiver.elements)
|
|
132
|
+
when Prism::CallNode
|
|
133
|
+
# A default proc survives freezing the Hash.
|
|
134
|
+
(classify(receiver) == :default_proc) ? :default_proc : :unknown
|
|
135
|
+
else
|
|
136
|
+
:unknown
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A container holding a sync primitive can never become
|
|
141
|
+
# shareable; Ractor.make_shareable raises on it (multi_json
|
|
142
|
+
# keeps a frozen Hash of Mutexes). The classification
|
|
143
|
+
# propagates so no freeze or wrap is ever suggested.
|
|
144
|
+
def container_kind(node)
|
|
145
|
+
sync = node.elements.any? do |element|
|
|
146
|
+
element_children(element).any? do |child|
|
|
147
|
+
classify(child) == :sync_primitive
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
sync ? :sync_primitive : :mutable_container
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def element_children(element)
|
|
154
|
+
case element
|
|
155
|
+
when Prism::AssocNode then [element.key, element.value]
|
|
156
|
+
else [element]
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# A ternary of provable branches classifies as the worst
|
|
161
|
+
# branch: two string literals make a string, so a plain
|
|
162
|
+
# `.freeze` stays available for `cond ? ";" : ":"`.
|
|
163
|
+
def ternary_kind(node)
|
|
164
|
+
return :unknown unless node.subsequent
|
|
165
|
+
.is_a?(Prism::ElseNode)
|
|
166
|
+
|
|
167
|
+
branches = [
|
|
168
|
+
single_statement(node.statements),
|
|
169
|
+
single_statement(node.subsequent.statements)
|
|
170
|
+
]
|
|
171
|
+
return :unknown unless branches.all?
|
|
172
|
+
|
|
173
|
+
kinds = branches.map { |branch| classify(branch) }
|
|
174
|
+
return :shareable if kinds.all?(:shareable)
|
|
175
|
+
|
|
176
|
+
if kinds.all? { |k| %i[shareable mutable_string].include?(k) }
|
|
177
|
+
:mutable_string
|
|
116
178
|
else
|
|
117
179
|
:unknown
|
|
118
180
|
end
|
|
119
181
|
end
|
|
120
182
|
|
|
183
|
+
def single_statement(statements)
|
|
184
|
+
body = statements&.body
|
|
185
|
+
body && body.size == 1 && body[0]
|
|
186
|
+
end
|
|
187
|
+
|
|
121
188
|
# Fold element classifications: everything provably shareable
|
|
122
189
|
# gives :shareable; anything provably mutable gives
|
|
123
|
-
# :shallow_freeze;
|
|
124
|
-
#
|
|
190
|
+
# :shallow_freeze; a sync primitive poisons the whole
|
|
191
|
+
# container; anything unknowable gives :unknown (stay silent
|
|
192
|
+
# rather than guess).
|
|
125
193
|
def deep_classify(elements)
|
|
126
194
|
verdict = :shareable
|
|
127
195
|
elements.each do |element|
|
|
128
|
-
|
|
129
|
-
case element
|
|
130
|
-
when Prism::AssocNode then [element.key, element.value]
|
|
131
|
-
else [element]
|
|
132
|
-
end
|
|
133
|
-
children.each do |child|
|
|
196
|
+
element_children(element).each do |child|
|
|
134
197
|
case classify(child)
|
|
135
198
|
when :shareable then nil
|
|
199
|
+
when :sync_primitive then return :sync_primitive
|
|
136
200
|
when :unknown then return :unknown
|
|
137
201
|
else verdict = :shallow_freeze
|
|
138
202
|
end
|
|
@@ -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
|
|
|
@@ -72,25 +85,93 @@ module Audition
|
|
|
72
85
|
%i[require require_relative].include?(s.name)
|
|
73
86
|
end
|
|
74
87
|
if last_require
|
|
75
|
-
newline =
|
|
76
|
-
offset = newline ? newline + 1 :
|
|
88
|
+
newline = raw.index("\n", last_require.location.end_offset)
|
|
89
|
+
offset = newline ? newline + 1 : raw.bytesize
|
|
77
90
|
{offset: offset, after_require: true}
|
|
78
91
|
else
|
|
79
92
|
{offset: leading_comments_end, after_require: false}
|
|
80
93
|
end
|
|
81
94
|
end
|
|
82
95
|
|
|
96
|
+
# Prism reports byte offsets; index math against the source
|
|
97
|
+
# must run on a binary copy or multibyte content shifts
|
|
98
|
+
# every computed position.
|
|
99
|
+
def raw
|
|
100
|
+
@raw ||= source.dup.force_encoding(Encoding::BINARY)
|
|
101
|
+
end
|
|
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
|
+
|
|
151
|
+
# Keys Ruby actually honors. Prism reports every comment
|
|
152
|
+
# shaped like `# key: value`, which sweeps up documentation
|
|
153
|
+
# (`# I18n.t: 'date.formats.short'`); inserting after those
|
|
154
|
+
# would land a magic comment mid-file.
|
|
155
|
+
MAGIC_KEYS = %w[
|
|
156
|
+
encoding coding frozen_string_literal
|
|
157
|
+
shareable_constant_value warn_indent
|
|
158
|
+
].freeze
|
|
159
|
+
|
|
83
160
|
# Where a new magic comment can go: after the shebang and any
|
|
84
161
|
# existing magic comments, before code.
|
|
85
162
|
def magic_insertion_offset
|
|
86
163
|
offset = 0
|
|
87
|
-
if
|
|
88
|
-
newline =
|
|
89
|
-
offset = newline ? newline + 1 :
|
|
164
|
+
if raw.start_with?("#!")
|
|
165
|
+
newline = raw.index("\n")
|
|
166
|
+
offset = newline ? newline + 1 : raw.bytesize
|
|
90
167
|
end
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
168
|
+
limit = first_statement_offset
|
|
169
|
+
after_magic = parse_result.magic_comments.filter_map do |mc|
|
|
170
|
+
next unless MAGIC_KEYS.include?(mc.key_loc.slice.downcase)
|
|
171
|
+
next unless mc.key_loc.start_offset < limit
|
|
172
|
+
|
|
173
|
+
newline = raw.index("\n", mc.value_loc.end_offset)
|
|
174
|
+
newline ? newline + 1 : raw.bytesize
|
|
94
175
|
end.max
|
|
95
176
|
[offset, after_magic || 0].max
|
|
96
177
|
end
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
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
|