audition 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +290 -0
- data/exe/audition +6 -0
- data/lib/audition/baseline.rb +66 -0
- data/lib/audition/bundle_sweep.rb +110 -0
- data/lib/audition/cli.rb +415 -0
- data/lib/audition/config.rb +67 -0
- data/lib/audition/directives.rb +56 -0
- data/lib/audition/dynamic/harness.rb +423 -0
- data/lib/audition/dynamic/prober.rb +362 -0
- data/lib/audition/finding.rb +94 -0
- data/lib/audition/fixer.rb +141 -0
- data/lib/audition/report.rb +344 -0
- data/lib/audition/rewriters.rb +459 -0
- data/lib/audition/static/analyzer.rb +96 -0
- data/lib/audition/static/checks/base.rb +160 -0
- data/lib/audition/static/checks/global_variables.rb +67 -0
- data/lib/audition/static/checks/mutable_constants.rb +145 -0
- data/lib/audition/static/checks/ractor_isolation.rb +105 -0
- data/lib/audition/static/checks/runtime_require.rb +126 -0
- data/lib/audition/static/checks/unsafe_calls.rb +166 -0
- data/lib/audition/static/checks.rb +46 -0
- data/lib/audition/static/graph_audit.rb +143 -0
- data/lib/audition/static/literal_classifier.rb +145 -0
- data/lib/audition/static/source_file.rb +109 -0
- data/lib/audition/target.rb +172 -0
- data/lib/audition/version.rb +5 -0
- data/lib/audition.rb +37 -0
- metadata +143 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
# Unsafe-tier, multi-site rewrites planned at fix time from a
|
|
7
|
+
# parsed file plus its findings. Each .plan returns Autofix edits
|
|
8
|
+
# (safety :unsafe) or, for MagicComments, a file-level edit plus
|
|
9
|
+
# the list of checks it makes redundant.
|
|
10
|
+
module Rewriters
|
|
11
|
+
# -- magic comments ----------------------------------------------
|
|
12
|
+
|
|
13
|
+
module MagicComments
|
|
14
|
+
SCV_LINE = "# shareable_constant_value: literal\n"
|
|
15
|
+
FSL_LINE = "# frozen_string_literal: true\n"
|
|
16
|
+
|
|
17
|
+
# `shareable_constant_value: literal` raises at load for
|
|
18
|
+
# non-literal constant values (verified on 4.0), so it is only
|
|
19
|
+
# planned when every constant assignment in the file
|
|
20
|
+
# classifies as a literal. Otherwise, if the flagged values
|
|
21
|
+
# are all strings, frozen_string_literal covers them.
|
|
22
|
+
def self.plan(file, findings)
|
|
23
|
+
return nil if file.shareable_constants?
|
|
24
|
+
return nil if findings.none? { |f| f.check == "mutable-constants" }
|
|
25
|
+
|
|
26
|
+
classifier = Static::LiteralClassifier.new(
|
|
27
|
+
frozen_string_literal: file.frozen_string_literal?
|
|
28
|
+
)
|
|
29
|
+
kinds = constant_values(file).map { |v| classifier.classify(v) }
|
|
30
|
+
flagged = kinds.reject do |k|
|
|
31
|
+
%i[shareable unknown].include?(k)
|
|
32
|
+
end
|
|
33
|
+
scv_ok = kinds.all? do |k|
|
|
34
|
+
%i[shareable mutable_string mutable_container
|
|
35
|
+
shallow_freeze].include?(k)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if scv_ok
|
|
39
|
+
comment_plan(file, SCV_LINE)
|
|
40
|
+
elsif !file.frozen_string_literal? &&
|
|
41
|
+
flagged.all? { |k| k == :mutable_string }
|
|
42
|
+
comment_plan(file, FSL_LINE)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.comment_plan(file, line)
|
|
47
|
+
offset = file.magic_insertion_offset
|
|
48
|
+
{
|
|
49
|
+
edit: Autofix.new(start_offset: offset,
|
|
50
|
+
end_offset: offset,
|
|
51
|
+
replacement: line,
|
|
52
|
+
safety: :unsafe),
|
|
53
|
+
covers: ["mutable-constants"]
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.constant_values(file)
|
|
58
|
+
collector = ConstantValues.new
|
|
59
|
+
collector.visit(file.root)
|
|
60
|
+
collector.values
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
class ConstantValues < Prism::Visitor
|
|
64
|
+
attr_reader :values
|
|
65
|
+
|
|
66
|
+
def initialize
|
|
67
|
+
@values = []
|
|
68
|
+
super
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
%i[
|
|
72
|
+
visit_constant_write_node
|
|
73
|
+
visit_constant_or_write_node
|
|
74
|
+
visit_constant_path_write_node
|
|
75
|
+
visit_constant_path_or_write_node
|
|
76
|
+
].each do |method|
|
|
77
|
+
define_method(method) do |node|
|
|
78
|
+
@values << node.value
|
|
79
|
+
super(node)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# -- class-level memoization -------------------------------------
|
|
86
|
+
|
|
87
|
+
# Rewrites singleton-scope ivar state to Ractor-local storage:
|
|
88
|
+
# @x ||= expr -> Ractor.store_if_absent(:"Klass/@x") { expr }
|
|
89
|
+
# @x = expr -> Ractor.current[:"Klass/@x"] = expr
|
|
90
|
+
# @x -> Ractor.current[:"Klass/@x"]
|
|
91
|
+
# Only when every reference in singleton scope is visible in
|
|
92
|
+
# this file, none sit directly in the class body, and no
|
|
93
|
+
# compound writes exist. Caveat (why this is unsafe): each
|
|
94
|
+
# Ractor computes its own copy, and store_if_absent treats a
|
|
95
|
+
# stored nil as present where ||= would recompute.
|
|
96
|
+
module Memoization
|
|
97
|
+
def self.plan(file, findings)
|
|
98
|
+
return [] if findings.none? { |f| f.check == "class-level-state" }
|
|
99
|
+
|
|
100
|
+
collector = SingletonIvars.new
|
|
101
|
+
collector.visit(file.root)
|
|
102
|
+
edits = []
|
|
103
|
+
collector.groups.each do |(namespace, name), ops|
|
|
104
|
+
next if namespace.empty?
|
|
105
|
+
|
|
106
|
+
kinds = ops.map { |op| op[:kind] }
|
|
107
|
+
next unless kinds.include?(:or_write)
|
|
108
|
+
next if kinds.include?(:other)
|
|
109
|
+
next if ops.any? { |op| op[:body] }
|
|
110
|
+
|
|
111
|
+
key = %(:"#{namespace}/#{name}")
|
|
112
|
+
ops.each { |op| edits << edit_for(op, key) }
|
|
113
|
+
end
|
|
114
|
+
edits
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.edit_for(op, key)
|
|
118
|
+
node = op[:node]
|
|
119
|
+
case op[:kind]
|
|
120
|
+
when :or_write
|
|
121
|
+
value = node.value.location.slice
|
|
122
|
+
Autofix.new(
|
|
123
|
+
start_offset: node.location.start_offset,
|
|
124
|
+
end_offset: node.location.end_offset,
|
|
125
|
+
replacement:
|
|
126
|
+
"Ractor.store_if_absent(#{key}) { #{value} }",
|
|
127
|
+
safety: :unsafe
|
|
128
|
+
)
|
|
129
|
+
when :write
|
|
130
|
+
Autofix.new(
|
|
131
|
+
start_offset: node.name_loc.start_offset,
|
|
132
|
+
end_offset: node.name_loc.end_offset,
|
|
133
|
+
replacement: "Ractor.current[#{key}]",
|
|
134
|
+
safety: :unsafe
|
|
135
|
+
)
|
|
136
|
+
when :read
|
|
137
|
+
Autofix.new(
|
|
138
|
+
start_offset: node.location.start_offset,
|
|
139
|
+
end_offset: node.location.end_offset,
|
|
140
|
+
replacement: "Ractor.current[#{key}]",
|
|
141
|
+
safety: :unsafe
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Collects ivar operations that touch the class object:
|
|
147
|
+
# inside `def self.x`, inside `class << self` methods, or
|
|
148
|
+
# directly in the class body (recorded with body: true, which
|
|
149
|
+
# disqualifies the group). Instance-method ivars are ignored.
|
|
150
|
+
class SingletonIvars < Prism::Visitor
|
|
151
|
+
attr_reader :groups
|
|
152
|
+
|
|
153
|
+
def initialize
|
|
154
|
+
@groups = Hash.new { |h, k| h[k] = [] }
|
|
155
|
+
@namespace = []
|
|
156
|
+
@sclass_depth = 0
|
|
157
|
+
@def_stack = []
|
|
158
|
+
super
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def visit_class_node(node)
|
|
162
|
+
@namespace.push(node.constant_path.location.slice)
|
|
163
|
+
super
|
|
164
|
+
ensure
|
|
165
|
+
@namespace.pop
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def visit_module_node(node)
|
|
169
|
+
@namespace.push(node.constant_path.location.slice)
|
|
170
|
+
super
|
|
171
|
+
ensure
|
|
172
|
+
@namespace.pop
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def visit_singleton_class_node(node)
|
|
176
|
+
if node.expression.is_a?(Prism::SelfNode)
|
|
177
|
+
@sclass_depth += 1
|
|
178
|
+
begin
|
|
179
|
+
super
|
|
180
|
+
ensure
|
|
181
|
+
@sclass_depth -= 1
|
|
182
|
+
end
|
|
183
|
+
else
|
|
184
|
+
super
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def visit_def_node(node)
|
|
189
|
+
kind =
|
|
190
|
+
node.receiver.is_a?(Prism::SelfNode) ? :self : :plain
|
|
191
|
+
@def_stack.push(kind)
|
|
192
|
+
super
|
|
193
|
+
ensure
|
|
194
|
+
@def_stack.pop
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
{
|
|
198
|
+
visit_instance_variable_read_node: :read,
|
|
199
|
+
visit_instance_variable_write_node: :write,
|
|
200
|
+
visit_instance_variable_or_write_node: :or_write,
|
|
201
|
+
visit_instance_variable_operator_write_node: :other,
|
|
202
|
+
visit_instance_variable_and_write_node: :other,
|
|
203
|
+
visit_instance_variable_target_node: :other
|
|
204
|
+
}.each do |method, kind|
|
|
205
|
+
define_method(method) do |node|
|
|
206
|
+
record(node, kind)
|
|
207
|
+
super(node)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private
|
|
212
|
+
|
|
213
|
+
def record(node, kind)
|
|
214
|
+
return if @namespace.empty?
|
|
215
|
+
|
|
216
|
+
in_def = !@def_stack.empty?
|
|
217
|
+
singleton_method =
|
|
218
|
+
@def_stack.last == :self ||
|
|
219
|
+
(@def_stack.last == :plain && @sclass_depth.positive?)
|
|
220
|
+
if in_def
|
|
221
|
+
return unless singleton_method
|
|
222
|
+
|
|
223
|
+
body = false
|
|
224
|
+
else
|
|
225
|
+
body = true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
key = [@namespace.join("::"), node.name.to_s]
|
|
229
|
+
@groups[key] << {node: node, kind: kind, body: body}
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# -- write-once globals and class variables ----------------------
|
|
235
|
+
|
|
236
|
+
# A variable written exactly once, at a scope where constant
|
|
237
|
+
# assignment is legal, with every other reference a plain read
|
|
238
|
+
# in the same file, converts mechanically to a frozen constant.
|
|
239
|
+
# Unsafe because cross-file readers are invisible.
|
|
240
|
+
module WriteOnce
|
|
241
|
+
def self.plan(file, findings)
|
|
242
|
+
edits = []
|
|
243
|
+
if findings.any? { |f| f.check == "global-variables" }
|
|
244
|
+
edits += globals(file)
|
|
245
|
+
end
|
|
246
|
+
if findings.any? { |f| f.check == "class-variables" }
|
|
247
|
+
edits += class_variables(file)
|
|
248
|
+
end
|
|
249
|
+
edits
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def self.globals(file)
|
|
253
|
+
collector = Variables.new(:gvar)
|
|
254
|
+
collector.visit(file.root)
|
|
255
|
+
convert(collector, file) do |name|
|
|
256
|
+
name.delete_prefix("$").upcase
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def self.class_variables(file)
|
|
261
|
+
collector = Variables.new(:cvar)
|
|
262
|
+
collector.visit(file.root)
|
|
263
|
+
convert(collector, file) do |name|
|
|
264
|
+
name.delete_prefix("@@").upcase
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def self.convert(collector, file)
|
|
269
|
+
classifier = Static::LiteralClassifier.new(
|
|
270
|
+
frozen_string_literal: file.frozen_string_literal?
|
|
271
|
+
)
|
|
272
|
+
edits = []
|
|
273
|
+
collector.groups.each do |name, ops|
|
|
274
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
275
|
+
next unless writes.size == 1 && writes[0][:assignable]
|
|
276
|
+
next unless (ops - writes).all? { |op| op[:kind] == :read }
|
|
277
|
+
|
|
278
|
+
write = writes[0][:node]
|
|
279
|
+
shareable = classifier.classify(write.value) == :shareable
|
|
280
|
+
# A mutable value that would get deep-frozen must never be
|
|
281
|
+
# the receiver of a call afterwards: `X[k] = v`, `X << v`
|
|
282
|
+
# and friends would raise FrozenError at runtime. Reads of
|
|
283
|
+
# immutable values are safe anywhere.
|
|
284
|
+
unless shareable
|
|
285
|
+
mutated = (ops - writes).any? do |op|
|
|
286
|
+
collector.receiver_reads.include?(op[:node].object_id)
|
|
287
|
+
end
|
|
288
|
+
next if mutated
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
constant = yield(name.split("/").last)
|
|
292
|
+
next unless constant.match?(/\A[A-Z][A-Z0-9_]*\z/)
|
|
293
|
+
next if collector.taken_constants.include?(constant)
|
|
294
|
+
|
|
295
|
+
edits << Autofix.new(
|
|
296
|
+
start_offset: write.name_loc.start_offset,
|
|
297
|
+
end_offset: write.name_loc.end_offset,
|
|
298
|
+
replacement: constant,
|
|
299
|
+
safety: :unsafe
|
|
300
|
+
)
|
|
301
|
+
unless shareable
|
|
302
|
+
value = write.value
|
|
303
|
+
source = value.location.slice
|
|
304
|
+
edits << Autofix.new(
|
|
305
|
+
start_offset: value.location.start_offset,
|
|
306
|
+
end_offset: value.location.end_offset,
|
|
307
|
+
replacement: "Ractor.make_shareable(#{source})",
|
|
308
|
+
safety: :unsafe
|
|
309
|
+
)
|
|
310
|
+
end
|
|
311
|
+
(ops - writes).each do |op|
|
|
312
|
+
node = op[:node]
|
|
313
|
+
edits << Autofix.new(
|
|
314
|
+
start_offset: node.location.start_offset,
|
|
315
|
+
end_offset: node.location.end_offset,
|
|
316
|
+
replacement: constant,
|
|
317
|
+
safety: :unsafe
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
edits
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Collects either global or class variable operations.
|
|
325
|
+
# Globals group process-wide; class variables group per
|
|
326
|
+
# namespace so a name reused in two classes never merges.
|
|
327
|
+
# `assignable` marks writes at a scope where a constant
|
|
328
|
+
# assignment would be legal (no surrounding def or block).
|
|
329
|
+
class Variables < Prism::Visitor
|
|
330
|
+
SKIP_GLOBALS = (
|
|
331
|
+
Static::Checks::GlobalVariables::SAFE_READS +
|
|
332
|
+
Static::Checks::GlobalVariables::SAFE_WRITES +
|
|
333
|
+
Static::Checks::GlobalVariables::LOAD_PATH_GLOBALS
|
|
334
|
+
).freeze
|
|
335
|
+
|
|
336
|
+
attr_reader :groups, :taken_constants, :receiver_reads
|
|
337
|
+
|
|
338
|
+
def initialize(mode)
|
|
339
|
+
@mode = mode
|
|
340
|
+
@groups = Hash.new { |h, k| h[k] = [] }
|
|
341
|
+
@taken_constants = []
|
|
342
|
+
@receiver_reads = {}
|
|
343
|
+
@namespace = []
|
|
344
|
+
@def_depth = 0
|
|
345
|
+
@block_depth = 0
|
|
346
|
+
super()
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
def visit_call_node(node)
|
|
350
|
+
receiver = node.receiver
|
|
351
|
+
if receiver.is_a?(Prism::GlobalVariableReadNode) ||
|
|
352
|
+
receiver.is_a?(Prism::ClassVariableReadNode)
|
|
353
|
+
@receiver_reads[receiver.object_id] = true
|
|
354
|
+
end
|
|
355
|
+
super
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def visit_class_node(node)
|
|
359
|
+
@namespace.push(node.constant_path.location.slice)
|
|
360
|
+
super
|
|
361
|
+
ensure
|
|
362
|
+
@namespace.pop
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def visit_module_node(node)
|
|
366
|
+
@namespace.push(node.constant_path.location.slice)
|
|
367
|
+
super
|
|
368
|
+
ensure
|
|
369
|
+
@namespace.pop
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def visit_def_node(node)
|
|
373
|
+
@def_depth += 1
|
|
374
|
+
super
|
|
375
|
+
ensure
|
|
376
|
+
@def_depth -= 1
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def visit_block_node(node)
|
|
380
|
+
@block_depth += 1
|
|
381
|
+
super
|
|
382
|
+
ensure
|
|
383
|
+
@block_depth -= 1
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def visit_lambda_node(node)
|
|
387
|
+
@block_depth += 1
|
|
388
|
+
super
|
|
389
|
+
ensure
|
|
390
|
+
@block_depth -= 1
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def visit_constant_write_node(node)
|
|
394
|
+
@taken_constants << node.name.to_s
|
|
395
|
+
super
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def visit_constant_read_node(node)
|
|
399
|
+
@taken_constants << node.name.to_s
|
|
400
|
+
super
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
GVAR_NODES = {
|
|
404
|
+
visit_global_variable_read_node: :read,
|
|
405
|
+
visit_global_variable_write_node: :write,
|
|
406
|
+
visit_global_variable_operator_write_node: :other,
|
|
407
|
+
visit_global_variable_or_write_node: :other,
|
|
408
|
+
visit_global_variable_and_write_node: :other,
|
|
409
|
+
visit_global_variable_target_node: :other
|
|
410
|
+
}.freeze
|
|
411
|
+
CVAR_NODES = {
|
|
412
|
+
visit_class_variable_read_node: :read,
|
|
413
|
+
visit_class_variable_write_node: :write,
|
|
414
|
+
visit_class_variable_operator_write_node: :other,
|
|
415
|
+
visit_class_variable_or_write_node: :other,
|
|
416
|
+
visit_class_variable_and_write_node: :other,
|
|
417
|
+
visit_class_variable_target_node: :other
|
|
418
|
+
}.freeze
|
|
419
|
+
|
|
420
|
+
GVAR_NODES.each do |method, kind|
|
|
421
|
+
define_method(method) do |node|
|
|
422
|
+
record_gvar(node, kind) if @mode == :gvar
|
|
423
|
+
super(node)
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
CVAR_NODES.each do |method, kind|
|
|
428
|
+
define_method(method) do |node|
|
|
429
|
+
record_cvar(node, kind) if @mode == :cvar
|
|
430
|
+
super(node)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
private
|
|
435
|
+
|
|
436
|
+
def record_gvar(node, kind)
|
|
437
|
+
name = node.name.to_s
|
|
438
|
+
return if SKIP_GLOBALS.include?(name)
|
|
439
|
+
|
|
440
|
+
@groups[name] << {
|
|
441
|
+
node: node, kind: kind,
|
|
442
|
+
assignable: @def_depth.zero? && @block_depth.zero? &&
|
|
443
|
+
@namespace.empty?
|
|
444
|
+
}
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def record_cvar(node, kind)
|
|
448
|
+
return if @namespace.empty?
|
|
449
|
+
|
|
450
|
+
key = "#{@namespace.join("::")}/#{node.name}"
|
|
451
|
+
@groups[key] << {
|
|
452
|
+
node: node, kind: kind,
|
|
453
|
+
assignable: @def_depth.zero? && @block_depth.zero?
|
|
454
|
+
}
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
end
|
|
459
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
module Static
|
|
7
|
+
# Runs the per-file Prism checks over sources or paths.
|
|
8
|
+
class Analyzer
|
|
9
|
+
# @param checks [Array<Class>] check classes to run
|
|
10
|
+
# (defaults to {Checks.all})
|
|
11
|
+
def initialize(checks: Checks.all)
|
|
12
|
+
@checks = checks
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @param source [String] Ruby source text
|
|
16
|
+
# @param path [String] path used in findings
|
|
17
|
+
# @return [Array<Finding>]
|
|
18
|
+
def analyze_source(source, path:)
|
|
19
|
+
analyze_file(SourceFile.new(source: source, path: path))
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param path [String] file to read and analyze
|
|
23
|
+
# @return [Array<Finding>]
|
|
24
|
+
def analyze_path(path)
|
|
25
|
+
analyze_file(SourceFile.read(path))
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
PARALLEL_THRESHOLD = 16
|
|
29
|
+
|
|
30
|
+
# Scans files across Ractors when there are enough of them to
|
|
31
|
+
# be worth the spawn cost. Checks are plain shareable classes
|
|
32
|
+
# with deeply frozen catalogs, findings copy back through
|
|
33
|
+
# `Ractor#value`, and anything unexpected falls back to the
|
|
34
|
+
# serial path.
|
|
35
|
+
#
|
|
36
|
+
# @param paths [Array<String>] files to analyze
|
|
37
|
+
# @param workers [Integer] Ractor count (defaults to
|
|
38
|
+
# processor count minus one)
|
|
39
|
+
# @param threshold [Integer] minimum file count before
|
|
40
|
+
# Ractors are used at all
|
|
41
|
+
# @return [Array<Finding>]
|
|
42
|
+
def analyze_paths(paths, workers: default_workers,
|
|
43
|
+
threshold: PARALLEL_THRESHOLD)
|
|
44
|
+
if paths.size < threshold || workers <= 1
|
|
45
|
+
return paths.flat_map { |path| analyze_path(path) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
parallel_analyze(paths, workers)
|
|
49
|
+
rescue Ractor::Error
|
|
50
|
+
paths.flat_map { |path| analyze_path(path) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def parallel_analyze(paths, workers)
|
|
56
|
+
experimental = Warning[:experimental]
|
|
57
|
+
Warning[:experimental] = false
|
|
58
|
+
slice = (paths.size / workers.to_f).ceil
|
|
59
|
+
checks = @checks
|
|
60
|
+
paths.each_slice(slice).map do |chunk|
|
|
61
|
+
Ractor.new(chunk, checks) do |files, active_checks|
|
|
62
|
+
analyzer = Analyzer.new(checks: active_checks)
|
|
63
|
+
files.flat_map { |file| analyzer.analyze_path(file) }
|
|
64
|
+
end
|
|
65
|
+
end.flat_map(&:value)
|
|
66
|
+
ensure
|
|
67
|
+
Warning[:experimental] = experimental
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def default_workers
|
|
71
|
+
[Etc.nprocessors - 1, 1].max
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def analyze_file(file)
|
|
75
|
+
return [syntax_finding(file)] unless file.valid_syntax?
|
|
76
|
+
|
|
77
|
+
@checks.flat_map { |check| check.call(file) }.sort_by(&:line)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def syntax_finding(file)
|
|
83
|
+
error = file.syntax_errors.first
|
|
84
|
+
Finding.new(
|
|
85
|
+
check: "syntax",
|
|
86
|
+
severity: :error,
|
|
87
|
+
message: "file does not parse: #{error&.message}",
|
|
88
|
+
why: "Audition can only analyze valid Ruby.",
|
|
89
|
+
fix: "Fix the syntax error first.",
|
|
90
|
+
path: file.path,
|
|
91
|
+
line: error&.location&.start_line
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
module Static
|
|
7
|
+
module Checks
|
|
8
|
+
# A check is a Prism visitor plus a message catalog, written in
|
|
9
|
+
# a small declarative DSL:
|
|
10
|
+
#
|
|
11
|
+
# class MyCheck < Base
|
|
12
|
+
# check_name "my-check" # optional; derived
|
|
13
|
+
#
|
|
14
|
+
# explain :bad_thing,
|
|
15
|
+
# severity: :error,
|
|
16
|
+
# message: "found %{name}",
|
|
17
|
+
# why: "it breaks because ...",
|
|
18
|
+
# fix: "do this instead ..."
|
|
19
|
+
#
|
|
20
|
+
# on :call_node do |node|
|
|
21
|
+
# flag(node, :bad_thing, name: node.name)
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# `on` generates the visit method and always continues into
|
|
26
|
+
# child nodes, so handlers cannot accidentally stop traversal.
|
|
27
|
+
# Third-party checks plug in via Checks.register(MyCheck).
|
|
28
|
+
class Base < Prism::Visitor
|
|
29
|
+
EMPTY_CATALOG = {}.freeze
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
# No lazy memoization here: checks must be readable from
|
|
33
|
+
# non-main Ractors (parallel scanning), so class-level
|
|
34
|
+
# state is written eagerly on the main Ractor at class
|
|
35
|
+
# definition time and kept deeply shareable.
|
|
36
|
+
#
|
|
37
|
+
# @param value [String, nil] sets the identifier when
|
|
38
|
+
# given; otherwise derived from the class name
|
|
39
|
+
# @return [String] kebab-case check identifier
|
|
40
|
+
def check_name(value = nil)
|
|
41
|
+
@check_name = -value if value # audition:disable class-level-state
|
|
42
|
+
@check_name || name.split("::").last
|
|
43
|
+
.gsub(/([a-z0-9])([A-Z])/, '\1-\2').downcase
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def explanations
|
|
47
|
+
@explanations || EMPTY_CATALOG
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Registers a message catalog entry. Strings may contain
|
|
51
|
+
# `%{placeholders}` filled by {#flag}.
|
|
52
|
+
#
|
|
53
|
+
# @param key [Symbol] catalog key used by {#flag}
|
|
54
|
+
# @param severity [Symbol] `:error`, `:warning`, `:info`
|
|
55
|
+
# @param message [String] one-line problem statement
|
|
56
|
+
# @param why [String] the Ractor rule behind it
|
|
57
|
+
# @param fix [String] suggested remediation
|
|
58
|
+
# @return [void]
|
|
59
|
+
def explain(key, severity:, message:, why:, fix:)
|
|
60
|
+
entry = {
|
|
61
|
+
severity: severity, message: message,
|
|
62
|
+
why: why, fix: fix
|
|
63
|
+
}
|
|
64
|
+
@explanations = Ractor.make_shareable( # audition:disable
|
|
65
|
+
explanations.merge(key => entry)
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def handlers
|
|
70
|
+
@handlers || EMPTY_CATALOG
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Methods born from define_method carry their block as a
|
|
74
|
+
# Proc, and Ruby refuses to call them from another Ractor
|
|
75
|
+
# unless that Proc is shareable. Both the handler and the
|
|
76
|
+
# generated wrapper are therefore isolated via
|
|
77
|
+
# Ractor.make_shareable; the wrapper looks its handler up
|
|
78
|
+
# through __method__ and continues traversal explicitly
|
|
79
|
+
# (super is not available inside an isolated proc, and
|
|
80
|
+
# this reproduces Prism::Visitor's default body).
|
|
81
|
+
WRAPPER = Ractor.make_shareable(
|
|
82
|
+
proc do |node|
|
|
83
|
+
handler_for(__method__, node)
|
|
84
|
+
node.each_child_node { |child| child.accept(self) }
|
|
85
|
+
end
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Generates visit methods for the given Prism node types.
|
|
89
|
+
# The handler runs via instance_exec and traversal always
|
|
90
|
+
# continues into child nodes afterwards. Handlers must not
|
|
91
|
+
# capture outer locals (they are isolated for cross-Ractor
|
|
92
|
+
# use and Ractor.make_shareable would raise).
|
|
93
|
+
#
|
|
94
|
+
# @param node_types [Array<Symbol>] e.g. `:call_node`
|
|
95
|
+
# @yieldparam node [Prism::Node] the visited node
|
|
96
|
+
# @return [void]
|
|
97
|
+
def on(*node_types, &handler)
|
|
98
|
+
isolated = Ractor.make_shareable(handler)
|
|
99
|
+
node_types.each do |type|
|
|
100
|
+
method_name = :"visit_#{type}"
|
|
101
|
+
@handlers = Ractor.make_shareable( # audition:disable
|
|
102
|
+
handlers.merge(method_name => isolated)
|
|
103
|
+
)
|
|
104
|
+
define_method(method_name, &WRAPPER)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Runs this check over one parsed file.
|
|
109
|
+
#
|
|
110
|
+
# @param file [SourceFile]
|
|
111
|
+
# @return [Array<Finding>]
|
|
112
|
+
def call(file)
|
|
113
|
+
visitor = new(file)
|
|
114
|
+
visitor.visit(file.root)
|
|
115
|
+
visitor.findings
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
attr_reader :file, :findings
|
|
120
|
+
|
|
121
|
+
def initialize(file)
|
|
122
|
+
@file = file
|
|
123
|
+
@findings = []
|
|
124
|
+
super()
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
private
|
|
128
|
+
|
|
129
|
+
def handler_for(method_name, node)
|
|
130
|
+
instance_exec(node, &self.class.handlers.fetch(method_name))
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def flag(node, key, autofix: nil, **interp)
|
|
134
|
+
spec = self.class.explanations.fetch(key)
|
|
135
|
+
add(node,
|
|
136
|
+
severity: spec[:severity],
|
|
137
|
+
message: format(spec[:message], **interp),
|
|
138
|
+
why: format(spec[:why], **interp),
|
|
139
|
+
fix: format(spec[:fix], **interp),
|
|
140
|
+
autofix: autofix)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def add(node, severity:, message:, why:, fix:, autofix: nil)
|
|
144
|
+
line = node.location.start_line
|
|
145
|
+
@findings << Finding.new(
|
|
146
|
+
check: self.class.check_name,
|
|
147
|
+
severity: severity,
|
|
148
|
+
message: message,
|
|
149
|
+
why: why,
|
|
150
|
+
fix: fix,
|
|
151
|
+
path: file.path,
|
|
152
|
+
line: line,
|
|
153
|
+
source: file.line_at(line),
|
|
154
|
+
autofix: autofix
|
|
155
|
+
)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|