audition 0.2.4 → 0.4.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 +175 -39
- data/lib/audition/bundle_sweep.rb +32 -15
- data/lib/audition/cli.rb +181 -100
- data/lib/audition/config.rb +33 -7
- data/lib/audition/dynamic/harness.rb +323 -31
- data/lib/audition/dynamic/prober.rb +190 -43
- data/lib/audition/finding.rb +10 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/report/github.rb +69 -0
- data/lib/audition/report/json.rb +68 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +160 -0
- data/lib/audition/report.rb +16 -295
- data/lib/audition/rewriters.rb +8 -5
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/mutable_constants.rb +162 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +5 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1053 -12
- data/lib/audition/static/literal_classifier.rb +191 -20
- data/lib/audition/static/native_extensions.rb +175 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +147 -14
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +15 -4
data/lib/audition/rewriters.rb
CHANGED
|
@@ -81,7 +81,10 @@ module Audition
|
|
|
81
81
|
values = collector.values
|
|
82
82
|
kinds = values.map { |v| classifier.classify(v) }
|
|
83
83
|
flagged = kinds.reject do |k|
|
|
84
|
-
|
|
84
|
+
# A magic comment cannot fix opaque values, so they
|
|
85
|
+
# must not veto one either.
|
|
86
|
+
%i[shareable unknown instance_new opaque_call
|
|
87
|
+
shallow_opaque].include?(k)
|
|
85
88
|
end
|
|
86
89
|
scv_ok = values.any? &&
|
|
87
90
|
values.all? { |value| deep_literal?(value, classifier) }
|
|
@@ -190,9 +193,9 @@ module Audition
|
|
|
190
193
|
# @x ||= expr
|
|
191
194
|
# return @x if defined?(@x); @x = expr
|
|
192
195
|
#
|
|
193
|
-
# Preferred strategy is freeze-on-memoize
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
+
# Preferred strategy is freeze-on-memoize: the memoization
|
|
197
|
+
# stays exactly as written and only the memoized value
|
|
198
|
+
# becomes shareable
|
|
196
199
|
# (`.freeze` appended; Ractor.make_shareable for containers).
|
|
197
200
|
# Non-main Ractors may then read the ivar once it has been
|
|
198
201
|
# computed; the first write must still happen on the main
|
|
@@ -325,7 +328,7 @@ module Audition
|
|
|
325
328
|
end
|
|
326
329
|
|
|
327
330
|
# Config setters (`def self.backend=(value); @backend =
|
|
328
|
-
# value; end`) get
|
|
331
|
+
# value; end`) get a try-make-shareable recipe in
|
|
329
332
|
# plain Ruby: shareable values are deeply frozen so reads
|
|
330
333
|
# from any Ractor become legal, unshareable values keep
|
|
331
334
|
# today's behavior through the rescue. Only bare local reads
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "work_split"
|
|
4
4
|
|
|
5
5
|
module Audition
|
|
6
6
|
module Static
|
|
@@ -27,6 +27,10 @@ module Audition
|
|
|
27
27
|
|
|
28
28
|
PARALLEL_THRESHOLD = 16
|
|
29
29
|
|
|
30
|
+
# Workers report in batches: a port message per file would
|
|
31
|
+
# cost more than the redraw it feeds.
|
|
32
|
+
TICK_STRIDE = 25
|
|
33
|
+
|
|
30
34
|
# Scans files across Ractors when there are enough of them to
|
|
31
35
|
# be worth the spawn cost. Checks are plain shareable classes
|
|
32
36
|
# with deeply frozen catalogs, findings copy back through
|
|
@@ -34,47 +38,112 @@ module Audition
|
|
|
34
38
|
# serial path.
|
|
35
39
|
#
|
|
36
40
|
# @param paths [Array<String>] files to analyze
|
|
37
|
-
# @param workers [Integer] Ractor count (defaults to
|
|
38
|
-
#
|
|
41
|
+
# @param workers [Integer, nil] Ractor count (defaults to
|
|
42
|
+
# {#default_workers})
|
|
39
43
|
# @param threshold [Integer] minimum file count before
|
|
40
44
|
# Ractors are used at all
|
|
45
|
+
# @param progress [Progress] ticked per file
|
|
41
46
|
# @return [Array<Finding>]
|
|
42
|
-
def analyze_paths(paths, workers:
|
|
43
|
-
threshold: PARALLEL_THRESHOLD)
|
|
47
|
+
def analyze_paths(paths, workers: nil,
|
|
48
|
+
threshold: PARALLEL_THRESHOLD, progress: Progress::SILENT)
|
|
49
|
+
workers ||= default_workers
|
|
44
50
|
if paths.size < threshold || workers <= 1
|
|
45
|
-
return paths
|
|
51
|
+
return serial_analyze(paths, progress)
|
|
46
52
|
end
|
|
47
53
|
|
|
48
|
-
parallel_analyze(paths, workers)
|
|
54
|
+
parallel_analyze(paths, workers, progress)
|
|
49
55
|
rescue Ractor::Error => e
|
|
50
56
|
# The silent fallback would otherwise mask a check that is
|
|
51
57
|
# itself Ractor-hostile; surface it under -w.
|
|
52
58
|
if $VERBOSE
|
|
53
|
-
warn "
|
|
59
|
+
warn "Audition: parallel scan fell back to serial: " \
|
|
54
60
|
"#{e.class}: #{e.message}"
|
|
55
61
|
end
|
|
56
|
-
|
|
62
|
+
progress.ractors = nil
|
|
63
|
+
serial_analyze(paths, progress)
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
private
|
|
60
67
|
|
|
61
|
-
def
|
|
68
|
+
def serial_analyze(paths, progress)
|
|
69
|
+
paths.flat_map do |path|
|
|
70
|
+
findings = analyze_path(path)
|
|
71
|
+
progress.tick
|
|
72
|
+
findings
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A port carries counts out of the workers while they run:
|
|
77
|
+
# the alternative is a status line frozen for the whole
|
|
78
|
+
# parallel phase, which is most of a large scan.
|
|
79
|
+
def parallel_analyze(paths, workers, progress)
|
|
62
80
|
experimental = Warning[:experimental]
|
|
63
81
|
Warning[:experimental] = false
|
|
64
|
-
slice = (paths.size / workers.to_f).ceil
|
|
65
82
|
checks = @checks
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
83
|
+
port = progress.enabled? ? Ractor::Port.new : nil
|
|
84
|
+
chunks = balanced_chunks(paths, workers)
|
|
85
|
+
progress.ractors = chunks.size
|
|
86
|
+
ractors = chunks.map do |chunk|
|
|
87
|
+
# The sentinel goes out through `ensure` so a worker that
|
|
88
|
+
# raises still releases the drain loop; the exception
|
|
89
|
+
# itself still surfaces from `Ractor#value`.
|
|
90
|
+
Ractor.new(chunk, checks, port) do |files, active, tap|
|
|
91
|
+
analyzer = Analyzer.new(checks: active)
|
|
92
|
+
pending = 0
|
|
93
|
+
begin
|
|
94
|
+
files.flat_map do |file|
|
|
95
|
+
findings = analyzer.analyze_path(file)
|
|
96
|
+
pending += 1
|
|
97
|
+
if tap && pending >= TICK_STRIDE
|
|
98
|
+
tap.send(pending)
|
|
99
|
+
pending = 0
|
|
100
|
+
end
|
|
101
|
+
findings
|
|
102
|
+
end
|
|
103
|
+
ensure
|
|
104
|
+
tap&.send(pending)
|
|
105
|
+
tap&.send(:done)
|
|
106
|
+
end
|
|
70
107
|
end
|
|
71
|
-
end
|
|
108
|
+
end
|
|
109
|
+
drain(port, ractors.size, progress) if port
|
|
110
|
+
ractors.flat_map(&:value)
|
|
72
111
|
ensure
|
|
73
112
|
Warning[:experimental] = experimental
|
|
74
113
|
end
|
|
75
114
|
|
|
115
|
+
def drain(port, workers, progress)
|
|
116
|
+
done = 0
|
|
117
|
+
while done < workers
|
|
118
|
+
message = port.receive
|
|
119
|
+
if message == :done
|
|
120
|
+
done += 1
|
|
121
|
+
else
|
|
122
|
+
progress.tick(message)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
rescue
|
|
126
|
+
# Narration is cosmetic; a closed port ends it quietly and
|
|
127
|
+
# `Ractor#value` still reports what went wrong.
|
|
128
|
+
nil
|
|
129
|
+
end
|
|
130
|
+
|
|
76
131
|
def default_workers
|
|
77
|
-
|
|
132
|
+
WorkSplit.workers
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Byte size stands in for parse cost, and the stat it costs
|
|
136
|
+
# is nothing beside the parse it schedules.
|
|
137
|
+
def balanced_chunks(paths, workers)
|
|
138
|
+
WorkSplit.chunks(
|
|
139
|
+
paths.map { |path| [path, file_size(path)] }, workers
|
|
140
|
+
)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def file_size(path)
|
|
144
|
+
File.size(path)
|
|
145
|
+
rescue SystemCallError
|
|
146
|
+
0
|
|
78
147
|
end
|
|
79
148
|
|
|
80
149
|
def analyze_file(file)
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
module Checks
|
|
6
|
+
# Class-level state owned by a dependency. A gem's own source
|
|
7
|
+
# is outside the scanned tree, so the graph audit never sees
|
|
8
|
+
# the ivar and the call site is the only place left to flag.
|
|
9
|
+
# Generated type stubs in the target's tree record it: an
|
|
10
|
+
# attribute reader on a singleton class is a class-level ivar
|
|
11
|
+
# read by definition, and the generator marks those methods
|
|
12
|
+
# apart from ones that compute their answer.
|
|
13
|
+
class DependencyClassState < Base
|
|
14
|
+
explain :read,
|
|
15
|
+
severity: :warning,
|
|
16
|
+
message: "%{owner}.%{name} reads class-level state " \
|
|
17
|
+
"owned by a dependency",
|
|
18
|
+
why: "The stub for %{owner} declares %{name} as an " \
|
|
19
|
+
"attribute on its singleton class, so the call " \
|
|
20
|
+
"returns a class-level instance variable. A " \
|
|
21
|
+
"non-main Ractor raises Ractor::IsolationError " \
|
|
22
|
+
"(\"can not get unshareable values from instance " \
|
|
23
|
+
"variables of classes/modules\") unless whatever " \
|
|
24
|
+
"the value happens to be is shareable.",
|
|
25
|
+
fix: "Read it once on the main Ractor and pass the " \
|
|
26
|
+
"value in, or make the dependency's assignment " \
|
|
27
|
+
"deeply shareable before any Ractor starts."
|
|
28
|
+
|
|
29
|
+
explain :write,
|
|
30
|
+
severity: :error,
|
|
31
|
+
message: "%{owner}.%{name}= writes class-level state " \
|
|
32
|
+
"owned by a dependency",
|
|
33
|
+
why: "Assigning an attribute on %{owner}'s singleton " \
|
|
34
|
+
"class writes a class-level instance variable, " \
|
|
35
|
+
"which a non-main Ractor cannot do at all: it " \
|
|
36
|
+
"raises Ractor::IsolationError regardless of what " \
|
|
37
|
+
"the value is.",
|
|
38
|
+
fix: "Configure the dependency at boot on the main " \
|
|
39
|
+
"Ractor and leave it alone afterwards."
|
|
40
|
+
|
|
41
|
+
# The generator documents a reader it generated from an
|
|
42
|
+
# attribute; a hand-written method of the same shape gets
|
|
43
|
+
# its own prose and stays quiet.
|
|
44
|
+
STUB_READER = /Returns the value of attribute (\w+)/
|
|
45
|
+
STUB_SINGLETON = /\A\s*class\s+<<\s+self\b/
|
|
46
|
+
STUB_SCOPE = /\A\s*(?:class|module)\s+([A-Za-z0-9_:]+)/
|
|
47
|
+
STUB_DEF = /\A\s*def ([a-z_][A-Za-z0-9_]*)[(;]/
|
|
48
|
+
STUB_COMMENT = /\A\s*#/
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
attr_reader :attributes
|
|
52
|
+
|
|
53
|
+
# Owner name => set of attribute names, kept shareable
|
|
54
|
+
# for parallel scanning.
|
|
55
|
+
def attributes=(pairs)
|
|
56
|
+
grouped = pairs.group_by(&:first)
|
|
57
|
+
.transform_values { |v| v.map(&:last).uniq }
|
|
58
|
+
@attributes = Ractor.make_shareable( # audition:disable
|
|
59
|
+
grouped
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @param paths [Array<String>] scanned files; only
|
|
64
|
+
# generated type stubs carry this evidence
|
|
65
|
+
# @return [void]
|
|
66
|
+
def learn(paths)
|
|
67
|
+
pairs = []
|
|
68
|
+
paths.each do |path|
|
|
69
|
+
next unless path.end_with?(".rbi")
|
|
70
|
+
|
|
71
|
+
read_stub(path, pairs)
|
|
72
|
+
end
|
|
73
|
+
self.attributes = pairs
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Stub indentation tracks nesting exactly, so the scope
|
|
79
|
+
# stack follows it rather than parsing the file.
|
|
80
|
+
def read_stub(path, pairs)
|
|
81
|
+
scope = []
|
|
82
|
+
singleton = []
|
|
83
|
+
indents = []
|
|
84
|
+
documented = nil
|
|
85
|
+
File.foreach(path) do |raw|
|
|
86
|
+
line = raw.rstrip
|
|
87
|
+
next if line.empty?
|
|
88
|
+
|
|
89
|
+
indent = line[/\A */].size
|
|
90
|
+
while indents.any? && indent <= indents.last
|
|
91
|
+
indents.pop
|
|
92
|
+
scope.pop
|
|
93
|
+
singleton.pop
|
|
94
|
+
end
|
|
95
|
+
documented =
|
|
96
|
+
step_stub(line, scope, singleton, indents,
|
|
97
|
+
documented, pairs)
|
|
98
|
+
end
|
|
99
|
+
rescue SystemCallError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def step_stub(line, scope, singleton, indents,
|
|
104
|
+
documented, pairs)
|
|
105
|
+
case line
|
|
106
|
+
when STUB_COMMENT
|
|
107
|
+
return STUB_READER.match(line)&.[](1) || documented
|
|
108
|
+
when STUB_SINGLETON
|
|
109
|
+
scope.push(scope.last)
|
|
110
|
+
singleton.push(true)
|
|
111
|
+
indents.push(line[/\A */].size)
|
|
112
|
+
when STUB_SCOPE
|
|
113
|
+
name = Regexp.last_match(1)
|
|
114
|
+
scope.push(scope.empty? ? name : "#{scope.last}::#{name}")
|
|
115
|
+
singleton.push(false)
|
|
116
|
+
indents.push(line[/\A */].size)
|
|
117
|
+
when STUB_DEF
|
|
118
|
+
name = Regexp.last_match(1)
|
|
119
|
+
if singleton.last && scope.last && documented == name
|
|
120
|
+
pairs << [scope.last, name]
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
self.attributes = []
|
|
128
|
+
|
|
129
|
+
on :call_node do |node|
|
|
130
|
+
examine(node)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def examine(node)
|
|
136
|
+
owner = constant_name(node.receiver)
|
|
137
|
+
return unless owner
|
|
138
|
+
|
|
139
|
+
name = node.name.to_s
|
|
140
|
+
writer = name.end_with?("=")
|
|
141
|
+
attribute = writer ? name.chomp("=") : name
|
|
142
|
+
names = self.class.attributes[owner]
|
|
143
|
+
return unless names&.include?(attribute)
|
|
144
|
+
|
|
145
|
+
flag(node, writer ? :write : :read,
|
|
146
|
+
owner: owner, name: attribute)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def constant_name(node)
|
|
150
|
+
case node
|
|
151
|
+
when Prism::ConstantReadNode
|
|
152
|
+
node.name.to_s
|
|
153
|
+
when Prism::ConstantPathNode
|
|
154
|
+
node.location.slice.delete_prefix("::")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -30,21 +30,23 @@ module Audition
|
|
|
30
30
|
"literals only; a method call returns a fresh " \
|
|
31
31
|
"unfrozen object, so a non-main Ractor reading " \
|
|
32
32
|
"this constant raises Ractor::IsolationError. " \
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
fix: "Append `.freeze` to the call; a frozen String " \
|
|
36
|
-
"or
|
|
33
|
+
"Typical shapes: `.tr`, `Regexp.new`, and " \
|
|
34
|
+
"`Object.new` sentinels.",
|
|
35
|
+
fix: "Append `.freeze` to the call; a frozen String, " \
|
|
36
|
+
"Regexp, or bare Object is deeply shareable. " \
|
|
37
|
+
"BasicObject has no #freeze: use " \
|
|
38
|
+
"Object.new.freeze for such a sentinel."
|
|
37
39
|
|
|
38
40
|
explain :mutable_container,
|
|
39
41
|
severity: :error,
|
|
40
42
|
message: "constant %{name} holds a mutable %{type} " \
|
|
41
43
|
"literal",
|
|
42
44
|
why: CONSTANT_WHY,
|
|
43
|
-
fix: "
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
45
|
+
fix: "Append `.freeze` when every element is itself " \
|
|
46
|
+
"shareable; otherwise make it deeply shareable with " \
|
|
47
|
+
"`# shareable_constant_value: literal` or " \
|
|
48
|
+
"Ractor.make_shareable(...), since a bare " \
|
|
49
|
+
"`.freeze` is shallow."
|
|
48
50
|
|
|
49
51
|
explain :shallow_freeze,
|
|
50
52
|
severity: :error,
|
|
@@ -62,17 +64,19 @@ module Audition
|
|
|
62
64
|
explain :sync_primitive,
|
|
63
65
|
severity: :error,
|
|
64
66
|
message: "constant %{name} holds a %{klass}; sync " \
|
|
65
|
-
"primitives are
|
|
67
|
+
"primitives and concurrent collections are " \
|
|
68
|
+
"deliberately unshareable",
|
|
66
69
|
why: "Mutex/Queue/ConditionVariable coordinate " \
|
|
67
70
|
"threads inside one Ractor and can never be " \
|
|
68
71
|
"shared across Ractors; any non-main Ractor " \
|
|
69
72
|
"touching this constant raises " \
|
|
70
|
-
"Ractor::IsolationError."
|
|
73
|
+
"Ractor::IsolationError. A concurrent-ruby Map " \
|
|
74
|
+
"defines no #freeze at all, so make_shareable " \
|
|
75
|
+
"raises NoMethodError on it.",
|
|
71
76
|
fix: "Use Ractor::Port for cross-Ractor " \
|
|
72
77
|
"coordination; keep a per-Ractor primitive " \
|
|
73
78
|
"via Ractor.store_if_absent when the state it " \
|
|
74
|
-
"guards is per-Ractor too
|
|
75
|
-
"template digest mutex this way); or use " \
|
|
79
|
+
"guards is per-Ractor too; or use " \
|
|
76
80
|
"Ractor-safe structures (ractor_safe, ratomic " \
|
|
77
81
|
"gems)."
|
|
78
82
|
|
|
@@ -94,12 +98,49 @@ module Audition
|
|
|
94
98
|
"Hash, and freezing the Hash does not make " \
|
|
95
99
|
"the block shareable; a non-main Ractor " \
|
|
96
100
|
"reading this constant raises " \
|
|
97
|
-
"Ractor::IsolationError.
|
|
98
|
-
"pattern twice during its ractorization.",
|
|
101
|
+
"Ractor::IsolationError.",
|
|
99
102
|
fix: "Use a plain frozen Hash with explicit keys, " \
|
|
100
103
|
"or drop the default proc and fetch with a " \
|
|
101
104
|
"literal default: hash.fetch(key, [])."
|
|
102
105
|
|
|
106
|
+
explain :unshareable_instance,
|
|
107
|
+
severity: :warning,
|
|
108
|
+
message: "constant %{name} holds an unfrozen %{klass} " \
|
|
109
|
+
"instance",
|
|
110
|
+
why: "A fresh instance is unfrozen, so non-main " \
|
|
111
|
+
"Ractors raise Ractor::IsolationError reading " \
|
|
112
|
+
"it. A warning, not an error: .new can be " \
|
|
113
|
+
"overridden to return a shareable value.",
|
|
114
|
+
fix: "Freeze it when deeply immutable, wrap in " \
|
|
115
|
+
"Ractor.make_shareable, or keep a per-Ractor " \
|
|
116
|
+
"copy via Ractor.store_if_absent."
|
|
117
|
+
|
|
118
|
+
explain :shallow_opaque,
|
|
119
|
+
severity: :warning,
|
|
120
|
+
message: "constant %{name} is frozen at the top level " \
|
|
121
|
+
"only; what it holds is unproven",
|
|
122
|
+
why: "Freezing is shallow: elements and instance " \
|
|
123
|
+
"variables stay as their calls returned them, and " \
|
|
124
|
+
"unless those are deeply frozen a non-main Ractor " \
|
|
125
|
+
"reading the constant raises " \
|
|
126
|
+
"Ractor::IsolationError. The dynamic probe settles " \
|
|
127
|
+
"it when the target boots.",
|
|
128
|
+
fix: "Build the value with Ractor.make_shareable for a " \
|
|
129
|
+
"deep freeze, or silence a known-shareable value " \
|
|
130
|
+
"with a disable comment."
|
|
131
|
+
|
|
132
|
+
explain :opaque_constant,
|
|
133
|
+
severity: :warning,
|
|
134
|
+
message: "constant %{name} holds the result of " \
|
|
135
|
+
"%{method}; shareability unproven",
|
|
136
|
+
why: "Unless the call returns a deeply frozen value, " \
|
|
137
|
+
"non-main Ractors raise Ractor::IsolationError " \
|
|
138
|
+
"reading the constant. The dynamic probe settles " \
|
|
139
|
+
"it when the target boots.",
|
|
140
|
+
fix: "Freeze the result at definition time, or " \
|
|
141
|
+
"silence a known-shareable value with a disable " \
|
|
142
|
+
"comment."
|
|
143
|
+
|
|
103
144
|
explain :constant_mutation,
|
|
104
145
|
severity: :warning,
|
|
105
146
|
message: "in-place %{method} on constant %{name}",
|
|
@@ -111,8 +152,8 @@ module Audition
|
|
|
111
152
|
fix: "Build the complete value at load time and " \
|
|
112
153
|
"freeze it (each_with_object then .freeze), " \
|
|
113
154
|
"or move the registry behind a writer that " \
|
|
114
|
-
"rebuilds and refreezes on each change
|
|
115
|
-
"copy-on-write
|
|
155
|
+
"rebuilds and refreezes on each change " \
|
|
156
|
+
"(copy-on-write). A " \
|
|
116
157
|
"registry that plugins extend during boot is " \
|
|
117
158
|
"frozen in the last boot hook (after_initialize) " \
|
|
118
159
|
"rather than at definition, and writes after the " \
|
|
@@ -144,21 +185,34 @@ module Audition
|
|
|
144
185
|
|
|
145
186
|
# A constant this file itself mutates in place is a
|
|
146
187
|
# deliberate accumulator; freezing it would raise at
|
|
147
|
-
# the mutation site (sinatra's PARAMS_CONFIG).
|
|
188
|
+
# the mutation site (sinatra's PARAMS_CONFIG). One it
|
|
189
|
+
# gives singleton methods raises the same way. The
|
|
148
190
|
# finding stays, the autofix goes.
|
|
149
|
-
fix_ok = !mutated?(name)
|
|
150
|
-
|
|
191
|
+
fix_ok = !mutated?(name) && !customized?(name)
|
|
192
|
+
kind = classifier.classify(value)
|
|
193
|
+
# A Sorbet cast returns its argument and a begin
|
|
194
|
+
# block its last statement: fixes, type names and
|
|
195
|
+
# depth checks target the value inside.
|
|
196
|
+
value = classifier.unwrap(value)
|
|
197
|
+
# Build-then-freeze: a bare `NAME.freeze` later in the
|
|
198
|
+
# same body makes the literal as good as frozen, so
|
|
199
|
+
# only provably mutable elements remain to report.
|
|
200
|
+
if kind == :mutable_container && frozen_later?(name)
|
|
201
|
+
kind = classifier.frozen_kind(value)
|
|
202
|
+
fix_ok = false
|
|
203
|
+
end
|
|
204
|
+
case kind
|
|
151
205
|
when :mutable_string
|
|
152
206
|
flag(node, :mutable_string, name: name,
|
|
153
207
|
autofix: fix_ok ? append_freeze(value) : nil)
|
|
154
208
|
when :mutable_call
|
|
155
209
|
flag(node, :mutable_call, name: name,
|
|
156
210
|
type: call_type(value), method: call_display(value),
|
|
157
|
-
autofix: fix_ok ?
|
|
211
|
+
autofix: fix_ok ? freeze_call(value) : nil)
|
|
158
212
|
when :mutable_container
|
|
159
213
|
flag(node, :mutable_container, name: name,
|
|
160
214
|
type: container_type(value),
|
|
161
|
-
autofix: fix_ok ?
|
|
215
|
+
autofix: fix_ok ? freeze_container(value) : nil)
|
|
162
216
|
when :shallow_freeze
|
|
163
217
|
flag(node, :shallow_freeze, name: name,
|
|
164
218
|
autofix:
|
|
@@ -183,13 +237,35 @@ module Audition
|
|
|
183
237
|
autofix: wrappable ? wrap_make_shareable(value) : nil)
|
|
184
238
|
when :default_proc
|
|
185
239
|
flag(node, :hash_default_proc, name: name)
|
|
240
|
+
when :shallow_opaque
|
|
241
|
+
flag(node, :shallow_opaque, name: name)
|
|
242
|
+
# Build-then-freeze leaves depth unknown: stay silent.
|
|
243
|
+
when :instance_new
|
|
244
|
+
unless frozen_later?(name)
|
|
245
|
+
flag(node, :unshareable_instance, name: name,
|
|
246
|
+
klass:
|
|
247
|
+
classifier.const_name(value.receiver) || "new")
|
|
248
|
+
end
|
|
249
|
+
when :opaque_call
|
|
250
|
+
unless frozen_later?(name)
|
|
251
|
+
flag(node, :opaque_constant, name: name,
|
|
252
|
+
method: opaque_display(value))
|
|
253
|
+
end
|
|
186
254
|
end
|
|
187
255
|
end
|
|
188
256
|
|
|
189
|
-
def mutated?(name)
|
|
257
|
+
def mutated?(name) = named_in?(file.mutated_constants, name)
|
|
258
|
+
|
|
259
|
+
def customized?(name)
|
|
260
|
+
named_in?(file.customized_constants, name)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def frozen_later?(name) = named_in?(file.frozen_constants, name)
|
|
264
|
+
|
|
265
|
+
def named_in?(names, name)
|
|
190
266
|
bare = name.split("::").last
|
|
191
|
-
|
|
192
|
-
|
|
267
|
+
names.any? do |other|
|
|
268
|
+
other == name || other.split("::").last == bare
|
|
193
269
|
end
|
|
194
270
|
end
|
|
195
271
|
|
|
@@ -262,6 +338,8 @@ module Audition
|
|
|
262
338
|
when Prism::HashNode, Prism::KeywordHashNode then "Hash"
|
|
263
339
|
when Prism::ArrayNode then "Array"
|
|
264
340
|
when Prism::CallNode
|
|
341
|
+
return "Set" if value.name == :to_set
|
|
342
|
+
|
|
265
343
|
classifier.const_name(value.receiver) || "container"
|
|
266
344
|
else
|
|
267
345
|
"container"
|
|
@@ -277,8 +355,11 @@ module Audition
|
|
|
277
355
|
# Ternaries classify as strings when both branches are;
|
|
278
356
|
# `.freeze` binds tighter than `?:`, so they get parens.
|
|
279
357
|
def call_type(call)
|
|
280
|
-
|
|
281
|
-
|
|
358
|
+
case classifier.const_name(call.receiver)
|
|
359
|
+
when "Regexp" then "Regexp"
|
|
360
|
+
when "Object", "BasicObject" then "Object"
|
|
361
|
+
else "String"
|
|
362
|
+
end
|
|
282
363
|
end
|
|
283
364
|
|
|
284
365
|
def call_display(call)
|
|
@@ -288,14 +369,32 @@ module Audition
|
|
|
288
369
|
owner ? "#{owner}.#{call.name}" : "String##{call.name}"
|
|
289
370
|
end
|
|
290
371
|
|
|
372
|
+
# The receiver is arbitrary, often a chain, so the
|
|
373
|
+
# fallback names only the method.
|
|
374
|
+
def opaque_display(call)
|
|
375
|
+
owner = classifier.const_name(call.receiver)
|
|
376
|
+
method =
|
|
377
|
+
if owner
|
|
378
|
+
"#{owner}.#{call.name}"
|
|
379
|
+
elsif call.receiver
|
|
380
|
+
".#{call.name}"
|
|
381
|
+
else
|
|
382
|
+
call.name.to_s
|
|
383
|
+
end
|
|
384
|
+
"a #{method} call"
|
|
385
|
+
end
|
|
386
|
+
|
|
291
387
|
# `.freeze` binds tighter than an operator: `"a" + "b".freeze`
|
|
292
388
|
# freezes only "b", so operator calls get parentheses while
|
|
293
389
|
# literals and parenthesized or argument-free calls take
|
|
294
390
|
# the bare suffix.
|
|
295
391
|
def bare_freezable?(value)
|
|
296
392
|
case value
|
|
297
|
-
when Prism::StringNode, Prism::InterpolatedStringNode
|
|
393
|
+
when Prism::StringNode, Prism::InterpolatedStringNode,
|
|
394
|
+
Prism::HashNode
|
|
298
395
|
true
|
|
396
|
+
when Prism::ArrayNode
|
|
397
|
+
!value.opening_loc.nil?
|
|
299
398
|
when Prism::CallNode
|
|
300
399
|
!value.opening_loc.nil? ||
|
|
301
400
|
(!value.receiver.nil? && value.arguments.nil?)
|
|
@@ -305,6 +404,16 @@ module Audition
|
|
|
305
404
|
end
|
|
306
405
|
|
|
307
406
|
def append_freeze(value)
|
|
407
|
+
# `X = :a, :b` has no brackets; it gains them so the
|
|
408
|
+
# suffix freezes the whole array.
|
|
409
|
+
if value.is_a?(Prism::ArrayNode) && value.opening_loc.nil?
|
|
410
|
+
return Autofix.new(
|
|
411
|
+
start_offset: value.location.start_offset,
|
|
412
|
+
end_offset: value.location.end_offset,
|
|
413
|
+
replacement: "[#{value.location.slice}].freeze"
|
|
414
|
+
)
|
|
415
|
+
end
|
|
416
|
+
|
|
308
417
|
if bare_freezable?(value)
|
|
309
418
|
offset = value.location.end_offset
|
|
310
419
|
Autofix.new(
|
|
@@ -322,6 +431,31 @@ module Audition
|
|
|
322
431
|
end
|
|
323
432
|
end
|
|
324
433
|
|
|
434
|
+
# A frozen bare Object is shareable; BasicObject has no
|
|
435
|
+
# #freeze, so its sentinel becomes a frozen Object.
|
|
436
|
+
def freeze_call(value)
|
|
437
|
+
return append_freeze(value) unless
|
|
438
|
+
classifier.const_name(value.receiver) == "BasicObject"
|
|
439
|
+
|
|
440
|
+
Autofix.new(
|
|
441
|
+
start_offset: value.location.start_offset,
|
|
442
|
+
end_offset: value.location.end_offset,
|
|
443
|
+
replacement: "Object.new.freeze",
|
|
444
|
+
safety: :unsafe
|
|
445
|
+
)
|
|
446
|
+
end
|
|
447
|
+
|
|
448
|
+
# Plain `.freeze` where every element is provably
|
|
449
|
+
# shareable, the plain-Ruby shape; the deep wrap only
|
|
450
|
+
# where a shallow freeze would not be enough.
|
|
451
|
+
def freeze_container(value)
|
|
452
|
+
if classifier.frozen_kind(value) == :shareable
|
|
453
|
+
append_freeze(value)
|
|
454
|
+
else
|
|
455
|
+
wrap_make_shareable(value)
|
|
456
|
+
end
|
|
457
|
+
end
|
|
458
|
+
|
|
325
459
|
# `X = :a, :b` is an array literal without brackets; the
|
|
326
460
|
# slice must gain them or the wrap becomes a multi-arg
|
|
327
461
|
# call (mail's ATTRIBUTES).
|
|
@@ -22,7 +22,10 @@ module Audition
|
|
|
22
22
|
"load-time side effects run at an arbitrary " \
|
|
23
23
|
"point.",
|
|
24
24
|
fix: "Require eagerly at boot, before Ractors are " \
|
|
25
|
-
"spawned."
|
|
25
|
+
"spawned. For an optional dependency, the " \
|
|
26
|
+
"class-level macro that enables the feature is " \
|
|
27
|
+
"a boot-time scope; the request-time method is " \
|
|
28
|
+
"not."
|
|
26
29
|
|
|
27
30
|
explain :autoload,
|
|
28
31
|
severity: :warning,
|