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.
- checksums.yaml +4 -4
- data/README.md +37 -29
- data/exe/audition +8 -0
- data/lib/audition/dynamic/prober.rb +16 -4
- data/lib/audition/fixer.rb +49 -21
- data/lib/audition/report.rb +4 -3
- data/lib/audition/rewriters.rb +371 -55
- data/lib/audition/static/checks/mutable_constants.rb +91 -9
- data/lib/audition/static/checks/ractor_isolation.rb +1 -1
- data/lib/audition/static/checks/runtime_require.rb +98 -15
- data/lib/audition/static/checks/unsafe_calls.rb +27 -0
- data/lib/audition/static/graph_audit.rb +97 -13
- data/lib/audition/static/literal_classifier.rb +69 -10
- data/lib/audition/static/source_file.rb +26 -8
- data/lib/audition/version.rb +1 -1
- metadata +1 -1
|
@@ -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:
|
|
44
|
+
autofix: autofix)
|
|
44
45
|
end
|
|
45
46
|
elsif node.name == :autoload
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
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
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
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:
|
|
118
|
-
end_offset:
|
|
119
|
-
replacement:
|
|
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
|
|
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."
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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:
|
|
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
|
-
|
|
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;
|
|
124
|
-
#
|
|
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
|
-
|
|
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 =
|
|
76
|
-
offset = newline ? newline + 1 :
|
|
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
|
|
88
|
-
newline =
|
|
89
|
-
offset = newline ? newline + 1 :
|
|
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.
|
|
92
|
-
|
|
93
|
-
|
|
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
|
data/lib/audition/version.rb
CHANGED