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.
@@ -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