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