audition 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +125 -49
- data/lib/audition/bundle_sweep.rb +25 -15
- data/lib/audition/cli.rb +128 -140
- data/lib/audition/config.rb +30 -4
- data/lib/audition/dynamic/harness.rb +415 -41
- data/lib/audition/dynamic/prober.rb +271 -54
- data/lib/audition/finding.rb +14 -2
- data/lib/audition/progress.rb +418 -0
- data/lib/audition/reconciliation.rb +86 -0
- data/lib/audition/report/github.rb +4 -3
- data/lib/audition/report/json.rb +5 -1
- data/lib/audition/report/sweep.rb +182 -0
- data/lib/audition/report/text.rb +13 -2
- data/lib/audition/report.rb +8 -1
- data/lib/audition/rewriters.rb +29 -2
- data/lib/audition/static/analyzer.rb +86 -17
- data/lib/audition/static/checks/dependency_class_state.rb +160 -0
- data/lib/audition/static/checks/instance_memoization.rb +226 -0
- data/lib/audition/static/checks/mutable_constants.rb +172 -9
- data/lib/audition/static/checks/ractor_isolation.rb +214 -15
- data/lib/audition/static/checks/unsafe_calls.rb +7 -4
- data/lib/audition/static/checks/unshareable_reads.rb +259 -0
- data/lib/audition/static/checks.rb +6 -2
- data/lib/audition/static/gem_calls.rb +1770 -0
- data/lib/audition/static/graph_audit.rb +1113 -15
- data/lib/audition/static/literal_classifier.rb +385 -20
- data/lib/audition/static/native_extensions.rb +28 -16
- data/lib/audition/static/work_split.rb +54 -0
- data/lib/audition/target.rb +82 -13
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +3 -0
- metadata +12 -4
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "rubydex"
|
|
4
4
|
require_relative "../rewriters"
|
|
5
|
+
require_relative "work_split"
|
|
5
6
|
|
|
6
7
|
module Audition
|
|
7
8
|
module Static
|
|
@@ -34,11 +35,25 @@ module Audition
|
|
|
34
35
|
"per-subclass values compute in the inherited hook " \
|
|
35
36
|
"(guard on subclass.name for anonymous classes). For " \
|
|
36
37
|
"collections, rebuild and refreeze on write, " \
|
|
37
|
-
"copy-on-write: self.list = " \
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
38
|
+
"copy-on-write: self.list = (list + [item]).freeze " \
|
|
39
|
+
"when every element is shareable, self.list = " \
|
|
40
|
+
"Ractor.make_shareable(list + [item]) when the " \
|
|
41
|
+
"additions may be unfrozen, since a plain freeze is " \
|
|
42
|
+
"shallow; never mutate in place. As a last resort use " \
|
|
43
|
+
"Ractor.store_if_absent for per-Ractor state, or read " \
|
|
44
|
+
"the ivar first and proxy the write to the main Ractor."
|
|
45
|
+
PROXIED_WHY =
|
|
46
|
+
"The read is the lock-free fast path and the write runs " \
|
|
47
|
+
"on the main Ractor through on_main, so no non-main " \
|
|
48
|
+
"Ractor ever writes class state. Every worker waiting " \
|
|
49
|
+
"on main serializes there, and a worker's next read " \
|
|
50
|
+
"raises Ractor::IsolationError unless the memoized " \
|
|
51
|
+
"value is shareable."
|
|
52
|
+
PROXIED_FIX =
|
|
53
|
+
"Keep the hatch rare: delete the memo or warm it at " \
|
|
54
|
+
"boot where possible, and make the computed value " \
|
|
55
|
+
"deeply frozen inside the block (.freeze or " \
|
|
56
|
+
"Ractor.make_shareable) so workers can read it."
|
|
42
57
|
FROZEN_MEMO_WHY =
|
|
43
58
|
"Every write memoizes a shareable (frozen) value, so " \
|
|
44
59
|
"non-main Ractors can read it once it has been " \
|
|
@@ -65,40 +80,172 @@ module Audition
|
|
|
65
80
|
"containers) before spawning Ractors. Configuration " \
|
|
66
81
|
"that cannot be shareable needs per-Ractor state or a " \
|
|
67
82
|
"main-Ractor proxy instead."
|
|
83
|
+
DERIVED_WHY =
|
|
84
|
+
"The value comes from another constant whose own " \
|
|
85
|
+
"definition is already flagged: freezing this one is " \
|
|
86
|
+
"shallow and does not change what the referent holds, " \
|
|
87
|
+
"so a non-main Ractor reading it can raise the same " \
|
|
88
|
+
"Ractor::IsolationError."
|
|
89
|
+
DERIVED_FIX =
|
|
90
|
+
"Make the referenced constant deeply shareable first; " \
|
|
91
|
+
"this finding follows its verdict."
|
|
92
|
+
CONTAINED_WHY =
|
|
93
|
+
"The assigned expression is flagged on this same line: " \
|
|
94
|
+
"the constant holds whatever unshareable value that " \
|
|
95
|
+
"expression produces, so a non-main Ractor reading the " \
|
|
96
|
+
"constant hits the same problem."
|
|
97
|
+
SINGLETON_SCAN_WHY =
|
|
98
|
+
"The receiver is a runtime value, so the graph cannot " \
|
|
99
|
+
"attribute the state this body writes to any class. If " \
|
|
100
|
+
"the receiver is one, these are class-level instance " \
|
|
101
|
+
"variables and a non-main Ractor raises " \
|
|
102
|
+
"Ractor::IsolationError writing them."
|
|
103
|
+
SINGLETON_SCAN_FIX =
|
|
104
|
+
"Open the singleton on the constant itself so the state " \
|
|
105
|
+
"resolves, or confirm the receiver with the dynamic " \
|
|
106
|
+
"probe, which reads the live object graph."
|
|
107
|
+
ANCESTOR_SCAN_WHY =
|
|
108
|
+
"The ancestor is a runtime value, so whatever it " \
|
|
109
|
+
"contributes—class-level state, class variables, " \
|
|
110
|
+
"hostile APIs—is invisible here. A clean report for " \
|
|
111
|
+
"this class covers only what the class itself declares."
|
|
112
|
+
ANCESTOR_SCAN_FIX =
|
|
113
|
+
"Name the superclass or module directly where the " \
|
|
114
|
+
"hierarchy is static. Where it cannot be, audit the " \
|
|
115
|
+
"candidates separately; the dynamic probe sweeps the " \
|
|
116
|
+
"ancestors that are actually in play."
|
|
117
|
+
CONSTANT_SCAN_WHY =
|
|
118
|
+
"The path starts from a runtime value, so the constant " \
|
|
119
|
+
"this assignment defines holds something the shareability " \
|
|
120
|
+
"checks never saw."
|
|
121
|
+
CONSTANT_SCAN_FIX =
|
|
122
|
+
"Reference the constant by its static path, or let the " \
|
|
123
|
+
"dynamic probe check the value with Ractor.shareable?."
|
|
124
|
+
|
|
125
|
+
# Findings from these per-file checks seed the derived-
|
|
126
|
+
# constant propagation.
|
|
127
|
+
PROPAGATED_CHECKS = [
|
|
128
|
+
"mutable-constants", "unshareable-reads", "native-gem-calls"
|
|
129
|
+
].freeze
|
|
130
|
+
|
|
131
|
+
# Checks that flag an expression rather than the constant
|
|
132
|
+
# it may be assigned to; when such a finding sits inside a
|
|
133
|
+
# constant assignment, the constant inherits it by name.
|
|
134
|
+
EXPRESSION_CHECKS =
|
|
135
|
+
["unshareable-reads", "native-gem-calls"].freeze
|
|
136
|
+
|
|
137
|
+
# The expressions rubydex reports as unresolvable. Each is a
|
|
138
|
+
# hole in the walks above, so where the shape could hide
|
|
139
|
+
# something they look for, the hole is reported. rubydex's
|
|
140
|
+
# parse rules are left out—the per-file syntax check already
|
|
141
|
+
# reports those—as are its visibility rules, which say
|
|
142
|
+
# nothing about Ractors.
|
|
143
|
+
SCAN_RULES = {
|
|
144
|
+
"DynamicSingletonDefinition" => :singleton,
|
|
145
|
+
"DynamicAncestor" => :ancestor,
|
|
146
|
+
"DynamicConstantReference" => :constant
|
|
147
|
+
}.freeze
|
|
148
|
+
|
|
149
|
+
MIXINS = ["include", "extend", "prepend"].freeze
|
|
150
|
+
|
|
151
|
+
STATE_WRITES = [
|
|
152
|
+
Prism::InstanceVariableWriteNode,
|
|
153
|
+
Prism::InstanceVariableOrWriteNode,
|
|
154
|
+
Prism::InstanceVariableAndWriteNode,
|
|
155
|
+
Prism::InstanceVariableOperatorWriteNode,
|
|
156
|
+
Prism::ClassVariableWriteNode,
|
|
157
|
+
Prism::ClassVariableOrWriteNode,
|
|
158
|
+
Prism::ClassVariableAndWriteNode,
|
|
159
|
+
Prism::ClassVariableOperatorWriteNode
|
|
160
|
+
].freeze
|
|
68
161
|
|
|
69
162
|
# @param sources [Hash{String => String}] path => source
|
|
163
|
+
# @param constant_findings [Array<Finding>] per-file findings
|
|
164
|
+
# whose definition sites seed derived-constant propagation
|
|
165
|
+
# @param progress [Progress]
|
|
70
166
|
# @return [Array<Finding>]
|
|
71
|
-
def analyze_sources(sources
|
|
167
|
+
def analyze_sources(sources, constant_findings: [],
|
|
168
|
+
workers: nil, progress: Progress::SILENT)
|
|
72
169
|
graph = Rubydex::Graph.new
|
|
170
|
+
progress.stage("indexing", total: sources.size)
|
|
73
171
|
sources.each do |path, code|
|
|
172
|
+
progress.tick
|
|
74
173
|
graph.index_source(path, code, "ruby")
|
|
75
174
|
end
|
|
76
175
|
@sources = sources
|
|
176
|
+
@workers = workers
|
|
77
177
|
@frozen_memos = frozen_memo_map(sources)
|
|
78
|
-
|
|
178
|
+
@reported_lines = constant_findings
|
|
179
|
+
.map { |f| [f.path, f.line] }.to_set
|
|
180
|
+
@constant_findings = constant_findings.select do |f|
|
|
181
|
+
PROPAGATED_CHECKS.include?(f.check)
|
|
182
|
+
end
|
|
183
|
+
audit(graph, progress)
|
|
79
184
|
end
|
|
80
185
|
|
|
81
186
|
# rubydex's index_all descends directories but skips bare file
|
|
82
187
|
# lists, so files are fed through index_source individually.
|
|
83
188
|
#
|
|
84
189
|
# @param paths [Array<String>] files to index and audit
|
|
190
|
+
# @param constant_findings [Array<Finding>] see
|
|
191
|
+
# {#analyze_sources}
|
|
192
|
+
# @param progress [Progress]
|
|
85
193
|
# @return [Array<Finding>]
|
|
86
|
-
def analyze_paths(paths
|
|
194
|
+
def analyze_paths(paths, constant_findings: [], workers: nil,
|
|
195
|
+
progress: Progress::SILENT)
|
|
87
196
|
sources = {}
|
|
197
|
+
progress.stage("reading", total: paths.size)
|
|
88
198
|
paths.each do |path|
|
|
199
|
+
progress.tick
|
|
89
200
|
sources[path] = File.read(path)
|
|
90
201
|
rescue SystemCallError
|
|
91
202
|
next
|
|
92
203
|
end
|
|
93
|
-
analyze_sources(sources
|
|
204
|
+
analyze_sources(sources, constant_findings: constant_findings,
|
|
205
|
+
workers: workers, progress: progress)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# Names one slice of a scan declares, for a parallel audit to
|
|
209
|
+
# merge before any walk emits.
|
|
210
|
+
#
|
|
211
|
+
# @api private
|
|
212
|
+
# @param sources [Hash{String => String}] path => source
|
|
213
|
+
# @return [Hash{Symbol => Object}]
|
|
214
|
+
def gathered_names(sources)
|
|
215
|
+
@sources = sources
|
|
216
|
+
{writers: declared_writers, declared: declared_names,
|
|
217
|
+
extended: extended_names}
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# The class-state walks over one slice, against names
|
|
221
|
+
# gathered from the whole scan.
|
|
222
|
+
#
|
|
223
|
+
# @api private
|
|
224
|
+
# @param sources [Hash{String => String}] path => source
|
|
225
|
+
# @param seen [Set] path/line pairs a declaration claimed
|
|
226
|
+
# @param names [Hash] from {#gathered_names}, merged
|
|
227
|
+
# @return [Array<Array<Finding>>] one batch per walk
|
|
228
|
+
def walk_batches(sources, seen, names)
|
|
229
|
+
@sources = sources
|
|
230
|
+
[singleton_attr_findings,
|
|
231
|
+
extended_module_findings(seen, names[:extended]),
|
|
232
|
+
dynamic_ivar_findings(seen, names[:declared]),
|
|
233
|
+
attribute_write_findings(seen, names[:writers])]
|
|
94
234
|
end
|
|
95
235
|
|
|
96
236
|
private
|
|
97
237
|
|
|
98
|
-
|
|
238
|
+
# Resolution is one opaque call inside rubydex; everything
|
|
239
|
+
# after it walks declarations rather than files, so the stage
|
|
240
|
+
# names say which pass a waiting reader is watching.
|
|
241
|
+
def audit(graph, progress = Progress::SILENT)
|
|
242
|
+
progress.stage("resolving")
|
|
99
243
|
graph.resolve
|
|
100
244
|
findings = []
|
|
101
|
-
graph.declarations
|
|
245
|
+
declarations = graph.declarations
|
|
246
|
+
progress.stage("declarations", total: declarations.size)
|
|
247
|
+
declarations.each do |decl|
|
|
248
|
+
progress.tick
|
|
102
249
|
case decl
|
|
103
250
|
when Rubydex::ClassVariable
|
|
104
251
|
findings.concat(class_variable_findings(decl))
|
|
@@ -106,9 +253,501 @@ module Audition
|
|
|
106
253
|
findings.concat(class_state_findings(decl))
|
|
107
254
|
end
|
|
108
255
|
end
|
|
256
|
+
seen = findings.map { |f| [f.path, f.line] }.to_set
|
|
257
|
+
class_state_batches(seen, progress).each do |batch|
|
|
258
|
+
findings.concat(batch)
|
|
259
|
+
batch.each { |f| seen << [f.path, f.line] }
|
|
260
|
+
end
|
|
261
|
+
progress.stage("constants")
|
|
262
|
+
findings.concat(derived_constant_findings(graph))
|
|
263
|
+
progress.stage("diagnostics")
|
|
264
|
+
findings.concat(static_scan_findings(graph, findings))
|
|
109
265
|
findings.sort_by { |f| [f.path, f.line] }
|
|
110
266
|
end
|
|
111
267
|
|
|
268
|
+
# rubydex says which expressions it could not resolve; this
|
|
269
|
+
# turns the ones that matter into findings, so a blind spot
|
|
270
|
+
# reads as a blind spot rather than as a clean line. A
|
|
271
|
+
# diagnostic is dropped where something else already reported
|
|
272
|
+
# that line, and where the shape cannot hide what the walks
|
|
273
|
+
# look for.
|
|
274
|
+
def static_scan_findings(graph, found)
|
|
275
|
+
reported = @reported_lines +
|
|
276
|
+
found.map { |f| [f.path, f.line] }
|
|
277
|
+
seen = Set.new
|
|
278
|
+
graph.diagnostics.filter_map do |diagnostic|
|
|
279
|
+
kind = SCAN_RULES[diagnostic.rule.rule_name]
|
|
280
|
+
next unless kind
|
|
281
|
+
|
|
282
|
+
path = path_from_uri(diagnostic.location.uri)
|
|
283
|
+
line = diagnostic.location.start_line + 1
|
|
284
|
+
next unless seen.add?([path, line, kind])
|
|
285
|
+
next if reported.include?([path, line])
|
|
286
|
+
|
|
287
|
+
scan_finding(kind, path, line)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def scan_finding(kind, path, line)
|
|
292
|
+
root = diagnostic_root(path)
|
|
293
|
+
return nil unless root
|
|
294
|
+
|
|
295
|
+
case kind
|
|
296
|
+
when :singleton then singleton_scan(root, path, line)
|
|
297
|
+
when :ancestor then ancestor_scan(root, path, line)
|
|
298
|
+
else constant_scan(root, path, line)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Only the files a diagnostic points at are parsed here. The
|
|
303
|
+
# walks may have run in Ractors, whose trees never reach this
|
|
304
|
+
# one, and the shapes are rare enough that re-reading the
|
|
305
|
+
# whole tree would cost more than the checks on it.
|
|
306
|
+
def diagnostic_root(path)
|
|
307
|
+
@diagnostic_roots ||= {}
|
|
308
|
+
return @diagnostic_roots[path] if @diagnostic_roots.key?(path)
|
|
309
|
+
|
|
310
|
+
code = @sources[path]
|
|
311
|
+
file = code && SourceFile.new(source: code, path: path)
|
|
312
|
+
@diagnostic_roots[path] = file&.valid_syntax? ? file.root : nil
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# A runtime singleton target only matters when the body it
|
|
316
|
+
# opens writes state: that is what the graph could not
|
|
317
|
+
# attribute to a class.
|
|
318
|
+
def singleton_scan(root, path, line)
|
|
319
|
+
node = node_at(root, line) do |candidate|
|
|
320
|
+
candidate.is_a?(Prism::SingletonClassNode) ||
|
|
321
|
+
(candidate.is_a?(Prism::DefNode) && candidate.receiver)
|
|
322
|
+
end
|
|
323
|
+
return nil unless node && state_writes?(node)
|
|
324
|
+
|
|
325
|
+
scan(path, line, severity: :warning,
|
|
326
|
+
message: "class-level state behind an unresolved " \
|
|
327
|
+
"singleton receiver",
|
|
328
|
+
why: SINGLETON_SCAN_WHY, fix: SINGLETON_SCAN_FIX)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# A runtime superclass always defines a class. A runtime
|
|
332
|
+
# mixin argument only names an ancestor inside a class or
|
|
333
|
+
# module body: a matcher called `include` in a test reads the
|
|
334
|
+
# same way to the graph and has to stay silent.
|
|
335
|
+
def ancestor_scan(root, path, line)
|
|
336
|
+
klass = node_at(root, line) do |candidate|
|
|
337
|
+
candidate.is_a?(Prism::ClassNode) && candidate.superclass &&
|
|
338
|
+
constant_slice(candidate.superclass).nil?
|
|
339
|
+
end
|
|
340
|
+
mixin = klass ? nil : body_mixins(path, root)[line]
|
|
341
|
+
return nil unless klass || mixin
|
|
342
|
+
|
|
343
|
+
subject =
|
|
344
|
+
if klass
|
|
345
|
+
"superclass of #{klass.constant_path.slice}"
|
|
346
|
+
else
|
|
347
|
+
"#{mixin[0]} argument in #{mixin[1]}"
|
|
348
|
+
end
|
|
349
|
+
scan(path, line, severity: :warning,
|
|
350
|
+
message: "unresolved #{subject}",
|
|
351
|
+
why: ANCESTOR_SCAN_WHY, fix: ANCESTOR_SCAN_FIX)
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# A runtime constant path only matters where it defines a
|
|
355
|
+
# constant: elsewhere there is no name whose shareability
|
|
356
|
+
# anything would have checked.
|
|
357
|
+
def constant_scan(root, path, line)
|
|
358
|
+
name = constant_assigned_at(root, line)
|
|
359
|
+
return nil unless name
|
|
360
|
+
|
|
361
|
+
scan(path, line, severity: :info,
|
|
362
|
+
message: "unresolved constant path in #{name}",
|
|
363
|
+
why: CONSTANT_SCAN_WHY, fix: CONSTANT_SCAN_FIX)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def scan(path, line, severity:, message:, why:, fix:)
|
|
367
|
+
Finding.new(
|
|
368
|
+
check: "static-scan",
|
|
369
|
+
severity: severity,
|
|
370
|
+
message: message,
|
|
371
|
+
why: why,
|
|
372
|
+
fix: fix,
|
|
373
|
+
path: path,
|
|
374
|
+
line: line,
|
|
375
|
+
source: source_line(path, line)
|
|
376
|
+
)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def node_at(root, line)
|
|
380
|
+
queue = [root]
|
|
381
|
+
until queue.empty?
|
|
382
|
+
node = queue.shift
|
|
383
|
+
if node.location.start_line == line && yield(node)
|
|
384
|
+
return node
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
queue.concat(node.compact_child_nodes)
|
|
388
|
+
end
|
|
389
|
+
nil
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Nested classes and modules are skipped: their state is
|
|
393
|
+
# their own, and the graph resolves it.
|
|
394
|
+
def state_writes?(node)
|
|
395
|
+
queue = node.compact_child_nodes
|
|
396
|
+
until queue.empty?
|
|
397
|
+
child = queue.shift
|
|
398
|
+
return true if STATE_WRITES.include?(child.class)
|
|
399
|
+
next if child.is_a?(Prism::ClassNode) ||
|
|
400
|
+
child.is_a?(Prism::ModuleNode)
|
|
401
|
+
|
|
402
|
+
queue.concat(child.compact_child_nodes)
|
|
403
|
+
end
|
|
404
|
+
false
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def body_mixins(path, root)
|
|
408
|
+
@body_mixins ||= {}
|
|
409
|
+
@body_mixins[path] ||= collect_body_mixins(root)
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
# Method and block bodies are skipped: a receiverless call in
|
|
413
|
+
# one is not a mixin into the enclosing class.
|
|
414
|
+
def collect_body_mixins(root)
|
|
415
|
+
found = {}
|
|
416
|
+
queue = root.compact_child_nodes.map { |node| [node, nil] }
|
|
417
|
+
until queue.empty?
|
|
418
|
+
node, owner = queue.shift
|
|
419
|
+
case node
|
|
420
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
421
|
+
owner = node.constant_path.slice
|
|
422
|
+
when Prism::DefNode, Prism::BlockNode, Prism::LambdaNode
|
|
423
|
+
next
|
|
424
|
+
when Prism::CallNode
|
|
425
|
+
record_mixin(found, node, owner)
|
|
426
|
+
end
|
|
427
|
+
queue.concat(
|
|
428
|
+
node.compact_child_nodes.map { |child| [child, owner] }
|
|
429
|
+
)
|
|
430
|
+
end
|
|
431
|
+
found
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
# Keyed by every line the call spans, since the diagnostic
|
|
435
|
+
# points at the argument rather than the call.
|
|
436
|
+
def record_mixin(found, node, owner)
|
|
437
|
+
return unless owner && node.receiver.nil? &&
|
|
438
|
+
MIXINS.include?(node.name.to_s)
|
|
439
|
+
return if resolved_mixin?(node)
|
|
440
|
+
|
|
441
|
+
location = node.location
|
|
442
|
+
(location.start_line..location.end_line).each do |line|
|
|
443
|
+
found[line] ||= [node.name.to_s, owner]
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# The graph gave up on the line, but if every argument
|
|
448
|
+
# names a constant the walks read it anyway, and saying
|
|
449
|
+
# otherwise would contradict the finding they emit.
|
|
450
|
+
def resolved_mixin?(node)
|
|
451
|
+
arguments = Array(node.arguments&.arguments)
|
|
452
|
+
arguments.any? &&
|
|
453
|
+
arguments.all? { |arg| constant_slice(arg) }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def constant_assigned_at(root, line)
|
|
457
|
+
queue = [root]
|
|
458
|
+
until queue.empty?
|
|
459
|
+
node = queue.shift
|
|
460
|
+
location = node.location
|
|
461
|
+
next unless line
|
|
462
|
+
.between?(location.start_line, location.end_line)
|
|
463
|
+
|
|
464
|
+
name = constant_target_name(node)
|
|
465
|
+
return name if name
|
|
466
|
+
|
|
467
|
+
queue.concat(node.compact_child_nodes)
|
|
468
|
+
end
|
|
469
|
+
nil
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# The constant a node assigns, nil for anything else.
|
|
473
|
+
def constant_target_name(node)
|
|
474
|
+
case node
|
|
475
|
+
when Prism::ConstantWriteNode, Prism::ConstantOrWriteNode
|
|
476
|
+
node.name.to_s
|
|
477
|
+
when Prism::ConstantPathWriteNode,
|
|
478
|
+
Prism::ConstantPathOrWriteNode
|
|
479
|
+
node.target.location.slice
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Below this many files the Ractor spawn and the copy of the
|
|
484
|
+
# sources cost more than the walks they divide.
|
|
485
|
+
PARALLEL_THRESHOLD = 100
|
|
486
|
+
|
|
487
|
+
# Every batch is computed against the declaration findings
|
|
488
|
+
# alone, so the dedup set only suppresses lines a declaration
|
|
489
|
+
# already claimed. Each walks every parsed source once, which
|
|
490
|
+
# is why they are named separately.
|
|
491
|
+
def class_state_batches(seen, progress)
|
|
492
|
+
workers = @workers || WorkSplit.workers
|
|
493
|
+
if @sources.size < PARALLEL_THRESHOLD || workers <= 1
|
|
494
|
+
serial_batches(seen, progress)
|
|
495
|
+
else
|
|
496
|
+
parallel_batches(seen, progress, workers)
|
|
497
|
+
end
|
|
498
|
+
rescue Ractor::Error, Ractor::ClosedError => e
|
|
499
|
+
# The silent fallback would otherwise mask a walk that is
|
|
500
|
+
# itself Ractor-hostile; surface it under -w. ClosedError
|
|
501
|
+
# is not a Ractor::Error: it is what sending to a worker
|
|
502
|
+
# that already died raises.
|
|
503
|
+
if $VERBOSE
|
|
504
|
+
warn "Audition: parallel graph audit fell back to " \
|
|
505
|
+
"serial: #{e.class}: #{e.message}"
|
|
506
|
+
end
|
|
507
|
+
progress.ractors = nil
|
|
508
|
+
serial_batches(seen, progress)
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def serial_batches(seen, progress)
|
|
512
|
+
progress.stage("parsing", total: @sources.size)
|
|
513
|
+
source_roots(progress)
|
|
514
|
+
progress.stage("singletons")
|
|
515
|
+
singletons = singleton_attr_findings
|
|
516
|
+
progress.stage("extends")
|
|
517
|
+
extended = extended_module_findings(seen)
|
|
518
|
+
progress.stage("ivars")
|
|
519
|
+
ivars = dynamic_ivar_findings(seen)
|
|
520
|
+
progress.stage("writers")
|
|
521
|
+
[singletons, extended, ivars,
|
|
522
|
+
attribute_write_findings(seen)]
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
# A walk may only emit once every name the whole scan
|
|
526
|
+
# declares is known, so the workers run in two rounds: gather
|
|
527
|
+
# the names of a slice, then walk it against the merged set.
|
|
528
|
+
# Parsed trees cannot cross a Ractor boundary, so the workers
|
|
529
|
+
# stay alive between the rounds and keep theirs.
|
|
530
|
+
def parallel_batches(seen, progress, workers)
|
|
531
|
+
experimental = Warning[:experimental]
|
|
532
|
+
Warning[:experimental] = false
|
|
533
|
+
port = Ractor::Port.new
|
|
534
|
+
chunks = chunks_for(workers)
|
|
535
|
+
progress.ractors = chunks.size
|
|
536
|
+
progress.stage("parsing")
|
|
537
|
+
ractors = chunks.map { |paths| worker(paths, port) }
|
|
538
|
+
names = merge_names(gather(port, ractors))
|
|
539
|
+
progress.stage("state")
|
|
540
|
+
claimed = seen.to_a
|
|
541
|
+
ractors.each { |ractor| ractor.send([claimed, names]) }
|
|
542
|
+
collect_batches(ractors)
|
|
543
|
+
ensure
|
|
544
|
+
Warning[:experimental] = experimental
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def chunks_for(workers)
|
|
548
|
+
WorkSplit.chunks(
|
|
549
|
+
@sources.map { |path, code| [path, code.bytesize] }, workers
|
|
550
|
+
)
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def worker(paths, port)
|
|
554
|
+
Ractor.new(slice(paths), port) do |sources, out|
|
|
555
|
+
audit = GraphAudit.new
|
|
556
|
+
begin
|
|
557
|
+
names = audit.gathered_names(sources)
|
|
558
|
+
ensure
|
|
559
|
+
# Sent from `ensure` so a gather that raises still
|
|
560
|
+
# releases the main Ractor; `Ractor#value` reports why.
|
|
561
|
+
out.send(names)
|
|
562
|
+
end
|
|
563
|
+
# Ruby cannot copy a Set whose elements are compound, so
|
|
564
|
+
# the claimed lines travel as pairs.
|
|
565
|
+
claimed, gathered = Ractor.receive
|
|
566
|
+
audit.walk_batches(sources, claimed.to_set, gathered)
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
def gather(port, ractors)
|
|
571
|
+
parts = ractors.map { port.receive }
|
|
572
|
+
# nil stands in for a worker that died gathering; asking
|
|
573
|
+
# for its value raises what killed it.
|
|
574
|
+
ractors.each(&:value) if parts.any?(&:nil?)
|
|
575
|
+
parts
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Each worker returns one batch per walk, in the order
|
|
579
|
+
# {#serial_batches} runs them; the batches concatenate walk
|
|
580
|
+
# by walk so the dedup a batch carries still holds.
|
|
581
|
+
def collect_batches(ractors)
|
|
582
|
+
ractors.map(&:value).transpose.map { |batch| batch.flatten(1) }
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def slice(paths)
|
|
586
|
+
paths.to_h { |path| [path, @sources[path]] }
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def merge_names(parts)
|
|
590
|
+
merged = {writers: {}, declared: Set.new, extended: Set.new}
|
|
591
|
+
parts.each do |part|
|
|
592
|
+
part[:writers].each do |owner, ivars|
|
|
593
|
+
(merged[:writers][owner] ||= Set.new).merge(ivars)
|
|
594
|
+
end
|
|
595
|
+
merged[:declared].merge(part[:declared])
|
|
596
|
+
merged[:extended].merge(part[:extended])
|
|
597
|
+
end
|
|
598
|
+
merged
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# A constant defined from another constant—an alias
|
|
602
|
+
# (`DEFAULT = PRIMARY`) or a frozen container of references
|
|
603
|
+
# (`ALL = [A, B].freeze`)—inherits the referent's problem,
|
|
604
|
+
# which the per-file classifier cannot see. The graph knows
|
|
605
|
+
# every reference, so flagged definitions propagate to any
|
|
606
|
+
# constant assignment that references them, transitively.
|
|
607
|
+
def derived_constant_findings(graph)
|
|
608
|
+
flagged = {}
|
|
609
|
+
@constant_findings.each do |f|
|
|
610
|
+
flagged[[f.path, f.line]] ||= f
|
|
611
|
+
end
|
|
612
|
+
return [] if flagged.empty?
|
|
613
|
+
|
|
614
|
+
spans = assignment_spans
|
|
615
|
+
by_site = constant_declarations_by_site(graph)
|
|
616
|
+
results = []
|
|
617
|
+
queue = flagged.keys.flat_map { |site| by_site[site] }.uniq
|
|
618
|
+
contained_findings(spans, flagged, results) do |site|
|
|
619
|
+
queue.concat(by_site[site])
|
|
620
|
+
end
|
|
621
|
+
until queue.empty?
|
|
622
|
+
decl = queue.shift
|
|
623
|
+
source = flagged_source(decl, flagged)
|
|
624
|
+
next unless source
|
|
625
|
+
|
|
626
|
+
decl.references.each do |ref|
|
|
627
|
+
path = path_from_uri(ref.location.uri)
|
|
628
|
+
line = ref.location.start_line + 1
|
|
629
|
+
span = spans[path].find { |s| s[:lines].cover?(line) }
|
|
630
|
+
next unless span
|
|
631
|
+
|
|
632
|
+
site = [path, span[:line]]
|
|
633
|
+
next if flagged.key?(site)
|
|
634
|
+
|
|
635
|
+
finding = Finding.new(
|
|
636
|
+
check: "derived-constants",
|
|
637
|
+
severity: source.severity,
|
|
638
|
+
message: "constant #{span[:name]} references " \
|
|
639
|
+
"#{decl.name}, itself flagged",
|
|
640
|
+
why: DERIVED_WHY,
|
|
641
|
+
fix: DERIVED_FIX,
|
|
642
|
+
path: path,
|
|
643
|
+
line: span[:line],
|
|
644
|
+
source: source_line(path, span[:line])
|
|
645
|
+
)
|
|
646
|
+
flagged[site] = finding
|
|
647
|
+
results << finding
|
|
648
|
+
queue.concat(by_site[site])
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
results
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
# An expression-check finding inside a constant assignment
|
|
655
|
+
# marks the constant itself: the assignment captures the
|
|
656
|
+
# flagged value under a name the graph can then follow.
|
|
657
|
+
def contained_findings(spans, flagged, results)
|
|
658
|
+
emitted = Set.new
|
|
659
|
+
flagged.to_a.each do |(path, line), seed|
|
|
660
|
+
next unless EXPRESSION_CHECKS.include?(seed.check)
|
|
661
|
+
|
|
662
|
+
span = spans[path].find { |s| s[:lines].cover?(line) }
|
|
663
|
+
next unless span
|
|
664
|
+
|
|
665
|
+
site = [path, span[:line]]
|
|
666
|
+
next unless emitted.add?(site)
|
|
667
|
+
|
|
668
|
+
already = flagged[site]
|
|
669
|
+
next if already && !EXPRESSION_CHECKS.include?(already.check)
|
|
670
|
+
|
|
671
|
+
finding = Finding.new(
|
|
672
|
+
check: "derived-constants",
|
|
673
|
+
severity: seed.severity,
|
|
674
|
+
message: "constant #{span[:name]} is assigned a " \
|
|
675
|
+
"value flagged on this line",
|
|
676
|
+
why: CONTAINED_WHY,
|
|
677
|
+
fix: DERIVED_FIX,
|
|
678
|
+
path: path,
|
|
679
|
+
line: span[:line],
|
|
680
|
+
source: source_line(path, span[:line])
|
|
681
|
+
)
|
|
682
|
+
flagged[site] ||= finding
|
|
683
|
+
results << finding
|
|
684
|
+
yield site
|
|
685
|
+
end
|
|
686
|
+
end
|
|
687
|
+
|
|
688
|
+
def constant_declarations_by_site(graph)
|
|
689
|
+
by_site = Hash.new { |h, k| h[k] = [] }
|
|
690
|
+
graph.declarations.each do |decl|
|
|
691
|
+
next unless constant_declaration?(decl)
|
|
692
|
+
|
|
693
|
+
each_local_definition(decl).each do |defn|
|
|
694
|
+
site = [path_from_uri(defn.location.uri),
|
|
695
|
+
defn.location.start_line + 1]
|
|
696
|
+
by_site[site] << decl
|
|
697
|
+
end
|
|
698
|
+
end
|
|
699
|
+
by_site
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
# rubydex leaves a declaration as a Todo when some reference
|
|
703
|
+
# keeps it from settling on a type. One with a constant name
|
|
704
|
+
# still carries definitions and references, and propagation
|
|
705
|
+
# already requires a flagged definition site plus references
|
|
706
|
+
# inside constant assignments, so it qualifies.
|
|
707
|
+
def constant_declaration?(decl)
|
|
708
|
+
case decl
|
|
709
|
+
when Rubydex::Constant, Rubydex::ConstantAlias
|
|
710
|
+
true
|
|
711
|
+
when Rubydex::Todo
|
|
712
|
+
decl.name.split("::").last&.match?(/\A[A-Z]/) || false
|
|
713
|
+
else
|
|
714
|
+
false
|
|
715
|
+
end
|
|
716
|
+
end
|
|
717
|
+
|
|
718
|
+
def flagged_source(decl, flagged)
|
|
719
|
+
each_local_definition(decl).filter_map do |defn|
|
|
720
|
+
flagged[[path_from_uri(defn.location.uri),
|
|
721
|
+
defn.location.start_line + 1]]
|
|
722
|
+
end.first
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# Line spans of every constant assignment, so a reference
|
|
726
|
+
# landing inside one attributes to the constant it defines.
|
|
727
|
+
def assignment_spans
|
|
728
|
+
spans = Hash.new { |h, k| h[k] = [] }
|
|
729
|
+
@sources.each do |path, code|
|
|
730
|
+
file = SourceFile.new(source: code, path: path)
|
|
731
|
+
next unless file.valid_syntax?
|
|
732
|
+
|
|
733
|
+
queue = [file.root]
|
|
734
|
+
until queue.empty?
|
|
735
|
+
node = queue.shift
|
|
736
|
+
queue.concat(node.child_nodes.compact)
|
|
737
|
+
name = constant_target_name(node)
|
|
738
|
+
next unless name
|
|
739
|
+
|
|
740
|
+
location = node.location
|
|
741
|
+
spans[path] << {
|
|
742
|
+
lines: (location.start_line..location.end_line),
|
|
743
|
+
line: location.start_line,
|
|
744
|
+
name: name
|
|
745
|
+
}
|
|
746
|
+
end
|
|
747
|
+
end
|
|
748
|
+
spans
|
|
749
|
+
end
|
|
750
|
+
|
|
112
751
|
def class_variable_findings(decl)
|
|
113
752
|
variable = decl.name.split("#").last
|
|
114
753
|
owner = display_owner(decl.owner)
|
|
@@ -135,6 +774,7 @@ module Audition
|
|
|
135
774
|
finding_at(
|
|
136
775
|
defn,
|
|
137
776
|
check: "class-level-state",
|
|
777
|
+
subject: "#{owner}/#{variable}",
|
|
138
778
|
severity: :info,
|
|
139
779
|
message: "frozen memoization #{variable} on " \
|
|
140
780
|
"#{owner}; warm it on the main Ractor",
|
|
@@ -145,16 +785,29 @@ module Audition
|
|
|
145
785
|
finding_at(
|
|
146
786
|
defn,
|
|
147
787
|
check: "class-level-state",
|
|
788
|
+
subject: "#{owner}/#{variable}",
|
|
148
789
|
severity: :warning,
|
|
149
790
|
message: "best-effort frozen state #{variable} " \
|
|
150
791
|
"on #{owner}",
|
|
151
792
|
why: BEST_EFFORT_WHY,
|
|
152
793
|
fix: BEST_EFFORT_FIX
|
|
153
794
|
)
|
|
795
|
+
when :proxied, :proxied_frozen
|
|
796
|
+
finding_at(
|
|
797
|
+
defn,
|
|
798
|
+
check: "class-level-state",
|
|
799
|
+
subject: "#{owner}/#{variable}",
|
|
800
|
+
severity: (verdict == :proxied) ? :warning : :info,
|
|
801
|
+
message: "memoization #{variable} on #{owner} " \
|
|
802
|
+
"proxied to the main Ractor",
|
|
803
|
+
why: PROXIED_WHY,
|
|
804
|
+
fix: PROXIED_FIX
|
|
805
|
+
)
|
|
154
806
|
else
|
|
155
807
|
finding_at(
|
|
156
808
|
defn,
|
|
157
809
|
check: "class-level-state",
|
|
810
|
+
subject: "#{owner}/#{variable}",
|
|
158
811
|
message: "class-level instance variable " \
|
|
159
812
|
"#{variable} on #{owner}",
|
|
160
813
|
why: STATE_WHY,
|
|
@@ -164,6 +817,428 @@ module Audition
|
|
|
164
817
|
end
|
|
165
818
|
end
|
|
166
819
|
|
|
820
|
+
# Parsed once: the passes below all walk every file.
|
|
821
|
+
def source_roots(progress = Progress::SILENT)
|
|
822
|
+
@source_roots ||= @sources.filter_map do |path, code|
|
|
823
|
+
progress.tick
|
|
824
|
+
file = SourceFile.new(source: code, path: path)
|
|
825
|
+
[path, file.root] if file.valid_syntax?
|
|
826
|
+
end.to_h
|
|
827
|
+
end
|
|
828
|
+
|
|
829
|
+
# An attribute writer on a singleton class declares
|
|
830
|
+
# class-level state the same way an assignment does, but
|
|
831
|
+
# no line assigns the ivar, so the graph never sees it.
|
|
832
|
+
# A reader alone is left to the graph: whatever writes
|
|
833
|
+
# the ivar is already a declaration.
|
|
834
|
+
SINGLETON_ATTRS = ["attr_accessor", "attr_writer"].freeze
|
|
835
|
+
|
|
836
|
+
def singleton_attr_findings
|
|
837
|
+
singleton_attrs.flat_map do |path, attrs|
|
|
838
|
+
attrs.map do |owner, ivar, line|
|
|
839
|
+
Finding.new(
|
|
840
|
+
check: "class-level-state",
|
|
841
|
+
severity: :error,
|
|
842
|
+
message: "class-level instance variable " \
|
|
843
|
+
"@#{ivar} on #{owner}",
|
|
844
|
+
why: STATE_WHY,
|
|
845
|
+
fix: STATE_FIX,
|
|
846
|
+
path: path,
|
|
847
|
+
line: line,
|
|
848
|
+
source: source_line(path, line),
|
|
849
|
+
subject: "#{owner}/@#{ivar}"
|
|
850
|
+
)
|
|
851
|
+
end
|
|
852
|
+
end
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
def singleton_attrs
|
|
856
|
+
@singleton_attrs ||= source_roots.to_h do |path, root|
|
|
857
|
+
attrs = []
|
|
858
|
+
walk_singletons(root, [], attrs)
|
|
859
|
+
[path, attrs]
|
|
860
|
+
end
|
|
861
|
+
end
|
|
862
|
+
|
|
863
|
+
# Assigning a declared singleton attribute writes the
|
|
864
|
+
# class-level instance variable behind it, which a non-main
|
|
865
|
+
# Ractor cannot do at all. The declaration is flagged where
|
|
866
|
+
# it sits; this is the assignment, which is a line of its
|
|
867
|
+
# own. Readers are left out: a read raises only on an
|
|
868
|
+
# unshareable value, and the declaration already says so.
|
|
869
|
+
def attribute_write_findings(seen, writers = declared_writers)
|
|
870
|
+
return [] if writers.empty?
|
|
871
|
+
|
|
872
|
+
source_roots.flat_map do |path, root|
|
|
873
|
+
calls = []
|
|
874
|
+
collect_writes(root, writers, calls)
|
|
875
|
+
calls.filter_map do |owner, name, line|
|
|
876
|
+
next if seen.include?([path, line])
|
|
877
|
+
|
|
878
|
+
Finding.new(
|
|
879
|
+
check: "class-level-state",
|
|
880
|
+
severity: :error,
|
|
881
|
+
message: "class-level instance variable " \
|
|
882
|
+
"@#{name} on #{owner}",
|
|
883
|
+
why: STATE_WHY,
|
|
884
|
+
fix: STATE_FIX,
|
|
885
|
+
path: path,
|
|
886
|
+
line: line,
|
|
887
|
+
source: source_line(path, line),
|
|
888
|
+
subject: "#{owner}/@#{name}"
|
|
889
|
+
)
|
|
890
|
+
end
|
|
891
|
+
end
|
|
892
|
+
end
|
|
893
|
+
|
|
894
|
+
# Attribute names by their owner's last segment: a write
|
|
895
|
+
# site and the declaration rarely spell the path the same
|
|
896
|
+
# way.
|
|
897
|
+
def declared_writers
|
|
898
|
+
writers = {}
|
|
899
|
+
singleton_attrs.each_value do |attrs|
|
|
900
|
+
attrs.each do |owner, ivar, _|
|
|
901
|
+
key = owner.split("::").last
|
|
902
|
+
(writers[key] ||= Set.new) << ivar
|
|
903
|
+
end
|
|
904
|
+
end
|
|
905
|
+
writers
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def collect_writes(node, writers, out)
|
|
909
|
+
if node.is_a?(Prism::CallNode)
|
|
910
|
+
name = node.name.to_s
|
|
911
|
+
owner = name.end_with?("=") &&
|
|
912
|
+
constant_slice(node.receiver)
|
|
913
|
+
if owner && writers[owner.split("::").last]
|
|
914
|
+
&.include?(name.chomp("="))
|
|
915
|
+
out << [owner, name.chomp("="),
|
|
916
|
+
node.location.start_line]
|
|
917
|
+
end
|
|
918
|
+
end
|
|
919
|
+
node.compact_child_nodes.each do |child|
|
|
920
|
+
collect_writes(child, writers, out)
|
|
921
|
+
end
|
|
922
|
+
end
|
|
923
|
+
|
|
924
|
+
# Extending a module makes its instance methods run with
|
|
925
|
+
# a class as self, so the ivars they assign live on that
|
|
926
|
+
# class. Which class is not knowable from the module, so
|
|
927
|
+
# the finding lands on the assignment.
|
|
928
|
+
IVAR_WRITES = [
|
|
929
|
+
Prism::InstanceVariableWriteNode,
|
|
930
|
+
Prism::InstanceVariableOrWriteNode,
|
|
931
|
+
Prism::InstanceVariableAndWriteNode,
|
|
932
|
+
Prism::InstanceVariableOperatorWriteNode
|
|
933
|
+
].freeze
|
|
934
|
+
|
|
935
|
+
def extended_module_findings(seen, names = extended_names)
|
|
936
|
+
return [] if names.empty?
|
|
937
|
+
|
|
938
|
+
source_roots.flat_map do |path, root|
|
|
939
|
+
writes = []
|
|
940
|
+
walk_extended(root, [], names, nil, writes)
|
|
941
|
+
writes.filter_map do |owner, ivar, line|
|
|
942
|
+
next if seen.include?([path, line])
|
|
943
|
+
|
|
944
|
+
Finding.new(
|
|
945
|
+
check: "class-level-state",
|
|
946
|
+
severity: :error,
|
|
947
|
+
message: "class-level instance variable " \
|
|
948
|
+
"#{ivar} on #{owner}",
|
|
949
|
+
why: STATE_WHY,
|
|
950
|
+
fix: STATE_FIX,
|
|
951
|
+
path: path,
|
|
952
|
+
line: line,
|
|
953
|
+
source: source_line(path, line),
|
|
954
|
+
subject: "#{owner}/#{ivar}"
|
|
955
|
+
)
|
|
956
|
+
end
|
|
957
|
+
end
|
|
958
|
+
end
|
|
959
|
+
|
|
960
|
+
# Writing an instance variable through its name reaches
|
|
961
|
+
# the same class-level state an assignment does, and the
|
|
962
|
+
# graph indexes assignments only. Verified on Ruby 4.0:
|
|
963
|
+
# set and remove always raise in a non-main Ractor, while
|
|
964
|
+
# get raises only on an unshareable value, so it rides on
|
|
965
|
+
# the declaration the way a plain read does.
|
|
966
|
+
DYNAMIC_WRITES = Ractor.make_shareable(
|
|
967
|
+
Set.new(%i[instance_variable_set remove_instance_variable])
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
# Ruby hands these hooks the class that triggered them.
|
|
971
|
+
CLASS_HOOKS = Ractor.make_shareable(
|
|
972
|
+
Set.new(%i[inherited included extended prepended])
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
def dynamic_ivar_findings(seen, declared = declared_names)
|
|
976
|
+
source_roots.flat_map do |path, root|
|
|
977
|
+
writes = []
|
|
978
|
+
walk_dynamic(root, Context.new([], false, false, {}),
|
|
979
|
+
declared, writes)
|
|
980
|
+
writes.filter_map do |owner, ivar, line|
|
|
981
|
+
next if seen.include?([path, line])
|
|
982
|
+
|
|
983
|
+
Finding.new(
|
|
984
|
+
check: "class-level-state",
|
|
985
|
+
severity: :error,
|
|
986
|
+
message: "class-level instance variable " \
|
|
987
|
+
"#{ivar} on #{owner}",
|
|
988
|
+
why: STATE_WHY,
|
|
989
|
+
fix: STATE_FIX,
|
|
990
|
+
path: path,
|
|
991
|
+
line: line,
|
|
992
|
+
source: source_line(path, line),
|
|
993
|
+
subject: "#{owner}/#{ivar}"
|
|
994
|
+
)
|
|
995
|
+
end
|
|
996
|
+
end
|
|
997
|
+
end
|
|
998
|
+
|
|
999
|
+
# Every class and module the target declares, by last name
|
|
1000
|
+
# segment: a receiver spelled one way at the call site and
|
|
1001
|
+
# another at the definition still names the same thing.
|
|
1002
|
+
def declared_names
|
|
1003
|
+
names = Set.new
|
|
1004
|
+
source_roots.each_value do |root|
|
|
1005
|
+
collect_declared(root, names)
|
|
1006
|
+
end
|
|
1007
|
+
names
|
|
1008
|
+
end
|
|
1009
|
+
|
|
1010
|
+
def collect_declared(node, names)
|
|
1011
|
+
case node
|
|
1012
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
1013
|
+
names << node.constant_path.slice.split("::").last
|
|
1014
|
+
end
|
|
1015
|
+
node.compact_child_nodes.each do |child|
|
|
1016
|
+
collect_declared(child, names)
|
|
1017
|
+
end
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
# nesting: enclosing class and module names.
|
|
1021
|
+
# singleton: whether self is a class or module here.
|
|
1022
|
+
# sclass: whether a bare def here defines a class method.
|
|
1023
|
+
# hooks: locals a class hook bound to a class.
|
|
1024
|
+
Context = Struct.new(:nesting, :singleton, :sclass, :hooks)
|
|
1025
|
+
|
|
1026
|
+
def walk_dynamic(node, context, declared, out)
|
|
1027
|
+
context = descend_dynamic(node, context)
|
|
1028
|
+
if node.is_a?(Prism::CallNode)
|
|
1029
|
+
record_dynamic(node, context, declared, out)
|
|
1030
|
+
end
|
|
1031
|
+
node.compact_child_nodes.each do |child|
|
|
1032
|
+
walk_dynamic(child, context, declared, out)
|
|
1033
|
+
end
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
# A class body has the class as self, but a bare def in
|
|
1037
|
+
# one defines an instance method, where self is not. Only
|
|
1038
|
+
# a receiver or an enclosing singleton class makes it a
|
|
1039
|
+
# class method again.
|
|
1040
|
+
def descend_dynamic(node, context)
|
|
1041
|
+
case node
|
|
1042
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
1043
|
+
Context.new(
|
|
1044
|
+
context.nesting + [node.constant_path.slice],
|
|
1045
|
+
true, false, {}
|
|
1046
|
+
)
|
|
1047
|
+
when Prism::SingletonClassNode
|
|
1048
|
+
Context.new(context.nesting, true, true, context.hooks)
|
|
1049
|
+
when Prism::DefNode
|
|
1050
|
+
singleton = !node.receiver.nil? || context.sclass
|
|
1051
|
+
Context.new(context.nesting, singleton, false,
|
|
1052
|
+
singleton ? hook_locals(node) : {})
|
|
1053
|
+
else
|
|
1054
|
+
context
|
|
1055
|
+
end
|
|
1056
|
+
end
|
|
1057
|
+
|
|
1058
|
+
def hook_locals(node)
|
|
1059
|
+
return {} unless CLASS_HOOKS.include?(node.name)
|
|
1060
|
+
|
|
1061
|
+
first = node.parameters&.requireds&.first
|
|
1062
|
+
return {} unless first.is_a?(Prism::RequiredParameterNode)
|
|
1063
|
+
|
|
1064
|
+
{first.name => true}
|
|
1065
|
+
end
|
|
1066
|
+
|
|
1067
|
+
def record_dynamic(node, context, declared, out)
|
|
1068
|
+
return unless DYNAMIC_WRITES.include?(node.name)
|
|
1069
|
+
|
|
1070
|
+
name = node.arguments&.arguments&.first
|
|
1071
|
+
return unless name.is_a?(Prism::SymbolNode) &&
|
|
1072
|
+
name.unescaped.start_with?("@")
|
|
1073
|
+
|
|
1074
|
+
owner = dynamic_owner(node.receiver, context, declared)
|
|
1075
|
+
return unless owner
|
|
1076
|
+
|
|
1077
|
+
out << [owner, name.unescaped, node.location.start_line]
|
|
1078
|
+
end
|
|
1079
|
+
|
|
1080
|
+
# Only receivers that are a class or module for certain:
|
|
1081
|
+
# self in a singleton, a constant the target declares, a
|
|
1082
|
+
# hook's class argument, or anything's own class.
|
|
1083
|
+
def dynamic_owner(receiver, context, declared)
|
|
1084
|
+
case receiver
|
|
1085
|
+
when nil, Prism::SelfNode
|
|
1086
|
+
context.nesting.last if context.singleton
|
|
1087
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1088
|
+
name = constant_slice(receiver)
|
|
1089
|
+
name if declared.include?(name.split("::").last)
|
|
1090
|
+
when Prism::LocalVariableReadNode
|
|
1091
|
+
receiver.slice if context.hooks[receiver.name]
|
|
1092
|
+
when Prism::CallNode
|
|
1093
|
+
receiver.slice if receiver.name == :class
|
|
1094
|
+
end
|
|
1095
|
+
end
|
|
1096
|
+
|
|
1097
|
+
# The name a concern gives the module it puts on the class.
|
|
1098
|
+
# Nothing in the concern's own source extends it: the mixin
|
|
1099
|
+
# that does lives in whatever library defines the pattern,
|
|
1100
|
+
# so within the target the name is the only evidence.
|
|
1101
|
+
CLASS_METHODS = "ClassMethods"
|
|
1102
|
+
|
|
1103
|
+
# The same methods declared as a block, with no module in
|
|
1104
|
+
# the source to hang the name on: the concern synthesizes
|
|
1105
|
+
# one under the conventional name at load time.
|
|
1106
|
+
CLASS_METHODS_BLOCK = :class_methods
|
|
1107
|
+
|
|
1108
|
+
# Mixing into a singleton class lands a module's instance
|
|
1109
|
+
# methods on the class, the way extend does.
|
|
1110
|
+
SINGLETON_MIXINS = Ractor.make_shareable(
|
|
1111
|
+
Set.new(%i[prepend include])
|
|
1112
|
+
)
|
|
1113
|
+
|
|
1114
|
+
# A module reached through its last name segment: the
|
|
1115
|
+
# extend site and the definition rarely spell the path
|
|
1116
|
+
# the same way.
|
|
1117
|
+
def extended_names
|
|
1118
|
+
names = Set.new([CLASS_METHODS])
|
|
1119
|
+
source_roots.each_value { |root| collect_extends(root, names) }
|
|
1120
|
+
names
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
def collect_extends(node, names)
|
|
1124
|
+
if node.is_a?(Prism::CallNode) && extend_call?(node)
|
|
1125
|
+
Array(node.arguments&.arguments).each do |arg|
|
|
1126
|
+
name = constant_slice(arg)
|
|
1127
|
+
names << name.split("::").last if name
|
|
1128
|
+
end
|
|
1129
|
+
end
|
|
1130
|
+
node.compact_child_nodes.each { |c| collect_extends(c, names) }
|
|
1131
|
+
end
|
|
1132
|
+
|
|
1133
|
+
def extend_call?(node)
|
|
1134
|
+
return true if node.name == :extend
|
|
1135
|
+
return false unless SINGLETON_MIXINS.include?(node.name)
|
|
1136
|
+
|
|
1137
|
+
node.receiver.is_a?(Prism::CallNode) &&
|
|
1138
|
+
node.receiver.name == :singleton_class
|
|
1139
|
+
end
|
|
1140
|
+
|
|
1141
|
+
def constant_slice(node)
|
|
1142
|
+
case node
|
|
1143
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1144
|
+
node.slice.delete_prefix("::")
|
|
1145
|
+
when Prism::CallNode
|
|
1146
|
+
const_get_slice(node)
|
|
1147
|
+
end
|
|
1148
|
+
end
|
|
1149
|
+
|
|
1150
|
+
# A constant fetched through `const_get` names it as
|
|
1151
|
+
# plainly as the constant does, and a concern resolving
|
|
1152
|
+
# its own companion module writes the extend that way.
|
|
1153
|
+
# Only on self: an explicit receiver picks the scope at
|
|
1154
|
+
# runtime, which is the blind spot the scan reports.
|
|
1155
|
+
def const_get_slice(node)
|
|
1156
|
+
return unless node.name == :const_get
|
|
1157
|
+
return unless node.receiver.nil? ||
|
|
1158
|
+
node.receiver.is_a?(Prism::SelfNode)
|
|
1159
|
+
|
|
1160
|
+
argument = Array(node.arguments&.arguments).first
|
|
1161
|
+
case argument
|
|
1162
|
+
when Prism::SymbolNode, Prism::StringNode
|
|
1163
|
+
argument.unescaped
|
|
1164
|
+
end
|
|
1165
|
+
end
|
|
1166
|
+
|
|
1167
|
+
# A def with a receiver writes the module's own state,
|
|
1168
|
+
# which the graph already owns; a nested class starts
|
|
1169
|
+
# its own instance side.
|
|
1170
|
+
def walk_extended(node, nesting, names, owner, out)
|
|
1171
|
+
case node
|
|
1172
|
+
when Prism::ModuleNode
|
|
1173
|
+
name = node.constant_path.slice
|
|
1174
|
+
nesting += [name]
|
|
1175
|
+
owner = nesting.join("::") if
|
|
1176
|
+
names.include?(name.split("::").last)
|
|
1177
|
+
when Prism::ClassNode
|
|
1178
|
+
nesting += [node.constant_path.slice]
|
|
1179
|
+
owner = nil
|
|
1180
|
+
when Prism::CallNode
|
|
1181
|
+
if node.block && node.name == CLASS_METHODS_BLOCK
|
|
1182
|
+
owner = (nesting + [CLASS_METHODS]).join("::")
|
|
1183
|
+
end
|
|
1184
|
+
when Prism::DefNode
|
|
1185
|
+
return if node.receiver
|
|
1186
|
+
else
|
|
1187
|
+
if owner && IVAR_WRITES.any? { |k| node.is_a?(k) }
|
|
1188
|
+
out << [owner, node.name.to_s, node.location.start_line]
|
|
1189
|
+
end
|
|
1190
|
+
end
|
|
1191
|
+
node.compact_child_nodes.each do |child|
|
|
1192
|
+
walk_extended(child, nesting, names, owner, out)
|
|
1193
|
+
end
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
# Tracks the lexical nesting so `class << self` resolves
|
|
1197
|
+
# to the class it sits in, and `class << Name` to that name.
|
|
1198
|
+
def walk_singletons(node, nesting, out)
|
|
1199
|
+
case node
|
|
1200
|
+
when Prism::ClassNode, Prism::ModuleNode
|
|
1201
|
+
nesting += [node.constant_path.slice]
|
|
1202
|
+
when Prism::SingletonClassNode
|
|
1203
|
+
owner = singleton_owner(node, nesting)
|
|
1204
|
+
collect_attrs(node.body, owner, out) if owner
|
|
1205
|
+
end
|
|
1206
|
+
node.compact_child_nodes.each do |child|
|
|
1207
|
+
walk_singletons(child, nesting, out)
|
|
1208
|
+
end
|
|
1209
|
+
end
|
|
1210
|
+
|
|
1211
|
+
def singleton_owner(node, nesting)
|
|
1212
|
+
case node.expression
|
|
1213
|
+
when Prism::SelfNode then nesting.last
|
|
1214
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode
|
|
1215
|
+
node.expression.slice
|
|
1216
|
+
end
|
|
1217
|
+
end
|
|
1218
|
+
|
|
1219
|
+
# Only the body's own statements count: a nested def or
|
|
1220
|
+
# block calling attr_accessor defines something else.
|
|
1221
|
+
def collect_attrs(body, owner, out)
|
|
1222
|
+
return unless body.is_a?(Prism::StatementsNode)
|
|
1223
|
+
|
|
1224
|
+
body.body.each do |stmt|
|
|
1225
|
+
next unless stmt.is_a?(Prism::CallNode) &&
|
|
1226
|
+
stmt.receiver.nil? &&
|
|
1227
|
+
SINGLETON_ATTRS.include?(stmt.name.to_s)
|
|
1228
|
+
|
|
1229
|
+
Array(stmt.arguments&.arguments).each do |arg|
|
|
1230
|
+
name = attr_name(arg)
|
|
1231
|
+
out << [owner, name, stmt.location.start_line] if name
|
|
1232
|
+
end
|
|
1233
|
+
end
|
|
1234
|
+
end
|
|
1235
|
+
|
|
1236
|
+
def attr_name(node)
|
|
1237
|
+
case node
|
|
1238
|
+
when Prism::SymbolNode, Prism::StringNode then node.unescaped
|
|
1239
|
+
end
|
|
1240
|
+
end
|
|
1241
|
+
|
|
167
1242
|
def each_local_definition(decl)
|
|
168
1243
|
decl.definitions.reject do |defn|
|
|
169
1244
|
defn.location.uri.start_with?("rubydex:")
|
|
@@ -171,7 +1246,7 @@ module Audition
|
|
|
171
1246
|
end
|
|
172
1247
|
|
|
173
1248
|
def finding_at(defn, check:, message:, why:, fix:,
|
|
174
|
-
severity: :error)
|
|
1249
|
+
severity: :error, subject: nil)
|
|
175
1250
|
path = path_from_uri(defn.location.uri)
|
|
176
1251
|
line = defn.location.start_line + 1
|
|
177
1252
|
Finding.new(
|
|
@@ -182,7 +1257,8 @@ module Audition
|
|
|
182
1257
|
fix: fix,
|
|
183
1258
|
path: path,
|
|
184
1259
|
line: line,
|
|
185
|
-
source: source_line(path, line)
|
|
1260
|
+
source: source_line(path, line),
|
|
1261
|
+
subject: subject
|
|
186
1262
|
)
|
|
187
1263
|
end
|
|
188
1264
|
|
|
@@ -265,7 +1341,10 @@ module Audition
|
|
|
265
1341
|
|
|
266
1342
|
# Cross-file merge keeps the weakest promise: any dirty file
|
|
267
1343
|
# taints the group, and best-effort beats fully frozen.
|
|
268
|
-
VERDICT_RANK = {
|
|
1344
|
+
VERDICT_RANK = {
|
|
1345
|
+
dirty: 0, best_effort: 1, proxied: 2, proxied_frozen: 3,
|
|
1346
|
+
frozen: 4
|
|
1347
|
+
}.freeze
|
|
269
1348
|
|
|
270
1349
|
def weaker_verdict(existing, verdict)
|
|
271
1350
|
return verdict unless existing
|
|
@@ -278,6 +1357,9 @@ module Audition
|
|
|
278
1357
|
return :dirty if ops.any? { |op| op[:kind] == :other }
|
|
279
1358
|
|
|
280
1359
|
memos = Rewriters::Memoization.memo_sites(ops)
|
|
1360
|
+
if memos.any? && memos.all? { |m| m[:op][:proxied] }
|
|
1361
|
+
return proxied_verdict(ops, memos, classifier)
|
|
1362
|
+
end
|
|
281
1363
|
if memos.any?
|
|
282
1364
|
return group_frozen?(ops, classifier) ? :frozen : :dirty
|
|
283
1365
|
end
|
|
@@ -297,6 +1379,22 @@ module Audition
|
|
|
297
1379
|
(all_safe && wrapped) ? :best_effort : :dirty
|
|
298
1380
|
end
|
|
299
1381
|
|
|
1382
|
+
# The read-then-proxy hatch, `@x || on_main(self) { @x ||= v }`:
|
|
1383
|
+
# every write is a memo site inside the block, so the main
|
|
1384
|
+
# Ractor performs it by construction, and the value decides
|
|
1385
|
+
# between a note and a warning. A plain write elsewhere
|
|
1386
|
+
# (a reset) is a worker-side write again.
|
|
1387
|
+
def proxied_verdict(ops, memos, classifier)
|
|
1388
|
+
memo_ops = memos.map { |memo| memo[:op] }
|
|
1389
|
+
writes = ops.select { |op| op[:kind] == :write }
|
|
1390
|
+
return :dirty unless (writes - memo_ops).empty?
|
|
1391
|
+
|
|
1392
|
+
frozen = memos.all? do |memo|
|
|
1393
|
+
frozen_memo_value?(memo[:op][:node].value, classifier)
|
|
1394
|
+
end
|
|
1395
|
+
frozen ? :proxied_frozen : :proxied
|
|
1396
|
+
end
|
|
1397
|
+
|
|
300
1398
|
# Matches the emitted setter recipe:
|
|
301
1399
|
# (Ractor.make_shareable(value) rescue value)
|
|
302
1400
|
def best_effort_value?(node)
|
|
@@ -353,7 +1451,7 @@ module Audition
|
|
|
353
1451
|
end
|
|
354
1452
|
end
|
|
355
1453
|
|
|
356
|
-
# "
|
|
1454
|
+
# "Widget::<Widget>" reads as noise; show "Widget".
|
|
357
1455
|
# Nested singleton owners produce nested angle brackets, so
|
|
358
1456
|
# the strip repeats until the tail is gone.
|
|
359
1457
|
def display_owner(owner)
|