audition 0.1.0 → 0.2.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.
@@ -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
@@ -149,6 +166,16 @@ module Audition
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
 
@@ -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,27 @@ 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, use " \
30
- "Ractor.store_if_absent for lazy initialization, or keep " \
31
- "per-Ractor state in Ractor.current[:key]."
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."
32
51
 
33
52
  # @param sources [Hash{String => String}] path => source
34
53
  # @return [Array<Finding>]
@@ -38,6 +57,7 @@ module Audition
38
57
  graph.index_source(path, code, "ruby")
39
58
  end
40
59
  @sources = sources
60
+ @frozen_memos = frozen_memo_map(sources)
41
61
  audit(graph)
42
62
  end
43
63
 
@@ -91,15 +111,28 @@ module Audition
91
111
 
92
112
  variable = decl.name.split("#").last
93
113
  owner = display_owner(decl.owner)
114
+ frozen = @frozen_memos["#{owner}/#{variable}"] == :frozen
94
115
  each_local_definition(decl).map do |defn|
95
- finding_at(
96
- defn,
97
- check: "class-level-state",
98
- message: "class-level instance variable #{variable} " \
99
- "on #{owner}",
100
- why: STATE_WHY,
101
- fix: STATE_FIX
102
- )
116
+ if frozen
117
+ finding_at(
118
+ defn,
119
+ check: "class-level-state",
120
+ severity: :info,
121
+ message: "frozen memoization #{variable} on " \
122
+ "#{owner}; warm it on the main Ractor",
123
+ why: FROZEN_MEMO_WHY,
124
+ fix: FROZEN_MEMO_FIX
125
+ )
126
+ else
127
+ finding_at(
128
+ defn,
129
+ check: "class-level-state",
130
+ message: "class-level instance variable " \
131
+ "#{variable} on #{owner}",
132
+ why: STATE_WHY,
133
+ fix: STATE_FIX
134
+ )
135
+ end
103
136
  end
104
137
  end
105
138
 
@@ -109,12 +142,13 @@ module Audition
109
142
  end
110
143
  end
111
144
 
112
- def finding_at(defn, check:, message:, why:, fix:)
145
+ def finding_at(defn, check:, message:, why:, fix:,
146
+ severity: :error)
113
147
  path = path_from_uri(defn.location.uri)
114
148
  line = defn.location.start_line + 1
115
149
  Finding.new(
116
150
  check: check,
117
- severity: :error,
151
+ severity: severity,
118
152
  message: message,
119
153
  why: why,
120
154
  fix: fix,
@@ -124,6 +158,56 @@ module Audition
124
158
  )
125
159
  end
126
160
 
161
+ # Frozen memoization, the shape Rails core ships: every
162
+ # write to the ivar is a memo site (`@x ||=` or a defined?
163
+ # guard) whose value is provably shareable, either a frozen
164
+ # literal, an explicit `.freeze` or make_shareable call.
165
+ # Such state is read-safe from any Ractor once warmed, so
166
+ # the finding downgrades to an info note. Any stray write
167
+ # or unproven value keeps the error. Keys are
168
+ # "Owner::Path/@name"; a dirty verdict in any file wins.
169
+ def frozen_memo_map(sources)
170
+ map = {}
171
+ sources.each do |path, code|
172
+ file = SourceFile.new(source: code, path: path)
173
+ next unless file.valid_syntax?
174
+
175
+ collector = Rewriters::Memoization::SingletonIvars.new
176
+ collector.visit(file.root)
177
+ classifier = LiteralClassifier.new(
178
+ frozen_string_literal: file.frozen_string_literal?
179
+ )
180
+ collector.groups.each do |(namespace, name), ops|
181
+ key = "#{namespace}/#{name}"
182
+ verdict =
183
+ group_frozen?(ops, classifier) ? :frozen : :dirty
184
+ map[key] =
185
+ (map[key] == :dirty) ? :dirty : verdict
186
+ end
187
+ end
188
+ map
189
+ end
190
+
191
+ def group_frozen?(ops, classifier)
192
+ return false if ops.any? { |op| op[:body] }
193
+ return false if ops.any? { |op| op[:kind] == :other }
194
+
195
+ memos = Rewriters::Memoization.memo_sites(ops)
196
+ return false if memos.empty?
197
+ return false if
198
+ Rewriters::Memoization.orphan_guards?(ops, memos)
199
+
200
+ memo_ops = memos.map { |memo| memo[:op] }
201
+ writes = ops.select { |op| op[:kind] == :write }
202
+ return false unless (writes - memo_ops).empty?
203
+
204
+ memos.all? do |memo|
205
+ value = memo[:op][:node].value
206
+ Rewriters::Memoization.frozen_call?(value) ||
207
+ classifier.classify(value) == :shareable
208
+ end
209
+ end
210
+
127
211
  # "Payments::<Payments>" reads as noise; show "Payments".
128
212
  def display_owner(owner)
129
213
  (owner&.name || "?").sub(/::<[^>]+>\z/, "")
@@ -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
- :mutable_container
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,6 +57,8 @@ 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
@@ -92,6 +96,13 @@ module Audition
92
96
  name = const_name(receiver)
93
97
  return :sync_primitive if SYNC_PRIMITIVES.include?(name)
94
98
  return :shareable if SHAREABLE_FACTORIES.include?(name)
99
+ # Hash.new retains its block as the default proc;
100
+ # Array.new only uses its block to build elements.
101
+ return :default_proc if name == "Hash" && node.block
102
+
103
+ if %w[Hash Array].include?(name)
104
+ return :mutable_container
105
+ end
95
106
 
96
107
  :unknown
97
108
  when :define
@@ -113,26 +124,74 @@ module Audition
113
124
  :shareable
114
125
  when Prism::ArrayNode, Prism::HashNode
115
126
  deep_classify(receiver.elements)
127
+ when Prism::CallNode
128
+ # A default proc survives freezing the Hash.
129
+ (classify(receiver) == :default_proc) ? :default_proc : :unknown
130
+ else
131
+ :unknown
132
+ end
133
+ end
134
+
135
+ # A container holding a sync primitive can never become
136
+ # shareable; Ractor.make_shareable raises on it (multi_json
137
+ # keeps a frozen Hash of Mutexes). The classification
138
+ # propagates so no freeze or wrap is ever suggested.
139
+ def container_kind(node)
140
+ sync = node.elements.any? do |element|
141
+ element_children(element).any? do |child|
142
+ classify(child) == :sync_primitive
143
+ end
144
+ end
145
+ sync ? :sync_primitive : :mutable_container
146
+ end
147
+
148
+ def element_children(element)
149
+ case element
150
+ when Prism::AssocNode then [element.key, element.value]
151
+ else [element]
152
+ end
153
+ end
154
+
155
+ # A ternary of provable branches classifies as the worst
156
+ # branch: two string literals make a string, so a plain
157
+ # `.freeze` stays available for `cond ? ";" : ":"`.
158
+ def ternary_kind(node)
159
+ return :unknown unless node.subsequent
160
+ .is_a?(Prism::ElseNode)
161
+
162
+ branches = [
163
+ single_statement(node.statements),
164
+ single_statement(node.subsequent.statements)
165
+ ]
166
+ return :unknown unless branches.all?
167
+
168
+ kinds = branches.map { |branch| classify(branch) }
169
+ return :shareable if kinds.all?(:shareable)
170
+
171
+ if kinds.all? { |k| %i[shareable mutable_string].include?(k) }
172
+ :mutable_string
116
173
  else
117
174
  :unknown
118
175
  end
119
176
  end
120
177
 
178
+ def single_statement(statements)
179
+ body = statements&.body
180
+ body && body.size == 1 && body[0]
181
+ end
182
+
121
183
  # Fold element classifications: everything provably shareable
122
184
  # gives :shareable; anything provably mutable gives
123
- # :shallow_freeze; anything unknowable gives :unknown (stay
124
- # silent rather than guess).
185
+ # :shallow_freeze; a sync primitive poisons the whole
186
+ # container; anything unknowable gives :unknown (stay silent
187
+ # rather than guess).
125
188
  def deep_classify(elements)
126
189
  verdict = :shareable
127
190
  elements.each do |element|
128
- children =
129
- case element
130
- when Prism::AssocNode then [element.key, element.value]
131
- else [element]
132
- end
133
- children.each do |child|
191
+ element_children(element).each do |child|
134
192
  case classify(child)
135
193
  when :shareable then nil
194
+ when :sync_primitive then return :sync_primitive
136
195
  when :unknown then return :unknown
137
196
  else verdict = :shallow_freeze
138
197
  end
@@ -72,25 +72,43 @@ module Audition
72
72
  %i[require require_relative].include?(s.name)
73
73
  end
74
74
  if last_require
75
- newline = source.index("\n", last_require.location.end_offset)
76
- offset = newline ? newline + 1 : source.bytesize
75
+ newline = raw.index("\n", last_require.location.end_offset)
76
+ offset = newline ? newline + 1 : raw.bytesize
77
77
  {offset: offset, after_require: true}
78
78
  else
79
79
  {offset: leading_comments_end, after_require: false}
80
80
  end
81
81
  end
82
82
 
83
+ # Prism reports byte offsets; index math against the source
84
+ # must run on a binary copy or multibyte content shifts
85
+ # every computed position.
86
+ def raw
87
+ @raw ||= source.dup.force_encoding(Encoding::BINARY)
88
+ end
89
+
90
+ # Keys Ruby actually honors. Prism reports every comment
91
+ # shaped like `# key: value`, which sweeps up documentation
92
+ # (`# I18n.t: 'date.formats.short'`); inserting after those
93
+ # would land a magic comment mid-file.
94
+ MAGIC_KEYS = %w[
95
+ encoding coding frozen_string_literal
96
+ shareable_constant_value warn_indent
97
+ ].freeze
98
+
83
99
  # Where a new magic comment can go: after the shebang and any
84
100
  # existing magic comments, before code.
85
101
  def magic_insertion_offset
86
102
  offset = 0
87
- if source.start_with?("#!")
88
- newline = source.index("\n")
89
- offset = newline ? newline + 1 : source.bytesize
103
+ if raw.start_with?("#!")
104
+ newline = raw.index("\n")
105
+ offset = newline ? newline + 1 : raw.bytesize
90
106
  end
91
- after_magic = parse_result.magic_comments.map do |mc|
92
- newline = source.index("\n", mc.value_loc.end_offset)
93
- newline ? newline + 1 : source.bytesize
107
+ after_magic = parse_result.magic_comments.filter_map do |mc|
108
+ next unless MAGIC_KEYS.include?(mc.key_loc.slice)
109
+
110
+ newline = raw.index("\n", mc.value_loc.end_offset)
111
+ newline ? newline + 1 : raw.bytesize
94
112
  end.max
95
113
  [offset, after_magic || 0].max
96
114
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Audition
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin