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