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
|
@@ -16,6 +16,10 @@ module Audition
|
|
|
16
16
|
# :proc lambda or proc
|
|
17
17
|
# :default_proc Hash.new with a block; the block
|
|
18
18
|
# survives .freeze and stays unshareable
|
|
19
|
+
# :instance_new unfrozen instance of an arbitrary class
|
|
20
|
+
# :opaque_call method call with an unprovable return
|
|
21
|
+
# :shallow_opaque frozen at the top, but what it holds
|
|
22
|
+
# is an unprovable call result
|
|
19
23
|
# :unknown cannot tell statically
|
|
20
24
|
class LiteralClassifier
|
|
21
25
|
SYNC_PRIMITIVES = %w[
|
|
@@ -51,6 +55,18 @@ module Audition
|
|
|
51
55
|
].freeze
|
|
52
56
|
FORMATTERS = %i[format sprintf].freeze
|
|
53
57
|
REGEXP_FACTORIES = %i[new union compile].freeze
|
|
58
|
+
# File methods returning a fresh path String.
|
|
59
|
+
FILE_PATH_METHODS = %i[
|
|
60
|
+
expand_path join dirname basename absolute_path realpath
|
|
61
|
+
].freeze
|
|
62
|
+
|
|
63
|
+
# Calls returning a shareable primitive on any receiver.
|
|
64
|
+
SHAREABLE_RETURNS = %i[
|
|
65
|
+
to_i to_int to_f to_r to_c to_sym size length count
|
|
66
|
+
bytesize ord hash
|
|
67
|
+
].freeze
|
|
68
|
+
# Sorbet's inline casts, which return their value argument.
|
|
69
|
+
SORBET_CASTS = %i[let cast must].freeze
|
|
54
70
|
|
|
55
71
|
# @param frozen_string_literal [Boolean] whether the file has
|
|
56
72
|
# the frozen_string_literal magic comment
|
|
@@ -61,6 +77,7 @@ module Audition
|
|
|
61
77
|
# @param node [Prism::Node] an expression node
|
|
62
78
|
# @return [Symbol] classification, see class docs
|
|
63
79
|
def classify(node)
|
|
80
|
+
node = begin_value(node)
|
|
64
81
|
case node
|
|
65
82
|
when Prism::IntegerNode, Prism::FloatNode,
|
|
66
83
|
Prism::RationalNode, Prism::ImaginaryNode,
|
|
@@ -94,6 +111,29 @@ module Audition
|
|
|
94
111
|
end
|
|
95
112
|
end
|
|
96
113
|
|
|
114
|
+
# The value an expression hands back once begin blocks
|
|
115
|
+
# and inline casts are peeled off.
|
|
116
|
+
def unwrap(node)
|
|
117
|
+
loop do
|
|
118
|
+
inner = begin_value(node)
|
|
119
|
+
inner = cast_value(inner) || inner
|
|
120
|
+
break node if inner.equal?(node)
|
|
121
|
+
|
|
122
|
+
node = inner
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# A begin block's value is its last statement. A rescue,
|
|
127
|
+
# else, or ensure clause can supply a different one, so
|
|
128
|
+
# only the plain form resolves.
|
|
129
|
+
def begin_value(node)
|
|
130
|
+
return node unless node.is_a?(Prism::BeginNode) &&
|
|
131
|
+
node.rescue_clause.nil? && node.else_clause.nil? &&
|
|
132
|
+
node.ensure_clause.nil?
|
|
133
|
+
|
|
134
|
+
node.statements&.body&.last || node
|
|
135
|
+
end
|
|
136
|
+
|
|
97
137
|
# `::Mutex` and `Mutex` are the same constant for matching
|
|
98
138
|
# purposes; the leading colons are stripped.
|
|
99
139
|
def const_name(node)
|
|
@@ -105,6 +145,17 @@ module Audition
|
|
|
105
145
|
end
|
|
106
146
|
end
|
|
107
147
|
|
|
148
|
+
# The value inside a Sorbet inline cast, or nil. The cast
|
|
149
|
+
# returns its argument, so fixes and type names belong on
|
|
150
|
+
# the value, not the cast.
|
|
151
|
+
def cast_value(node)
|
|
152
|
+
return nil unless node.is_a?(Prism::CallNode)
|
|
153
|
+
return nil unless const_name(node.receiver) == "T" &&
|
|
154
|
+
SORBET_CASTS.include?(node.name)
|
|
155
|
+
|
|
156
|
+
node.arguments&.arguments&.first
|
|
157
|
+
end
|
|
158
|
+
|
|
108
159
|
# What `value.freeze` would classify as, for the check to
|
|
109
160
|
# choose a plain `.freeze` over a deep wrap: :shareable
|
|
110
161
|
# when every element is provably shareable, :shallow_freeze
|
|
@@ -165,9 +216,11 @@ module Audition
|
|
|
165
216
|
return :mutable_container
|
|
166
217
|
end
|
|
167
218
|
|
|
168
|
-
:
|
|
169
|
-
when :
|
|
219
|
+
name ? :instance_new : :opaque_call
|
|
220
|
+
when :to_set
|
|
170
221
|
set_kind(node)
|
|
222
|
+
when :[]
|
|
223
|
+
index_kind(node)
|
|
171
224
|
when :define
|
|
172
225
|
(const_name(receiver) == "Data") ? :shareable : :unknown
|
|
173
226
|
when :make_shareable
|
|
@@ -175,14 +228,46 @@ module Audition
|
|
|
175
228
|
when :lambda, :proc
|
|
176
229
|
(receiver.nil? && node.block) ? :proc : :unknown
|
|
177
230
|
else
|
|
178
|
-
|
|
231
|
+
opaque_kind(node)
|
|
179
232
|
end
|
|
180
233
|
end
|
|
181
234
|
|
|
235
|
+
# Catch-all for unrecognized calls: shareable returns pass,
|
|
236
|
+
# predicates stay silent, everything else is opaque.
|
|
237
|
+
def opaque_kind(node)
|
|
238
|
+
return :shareable if SHAREABLE_RETURNS.include?(node.name)
|
|
239
|
+
return :unknown if node.name.end_with?("?")
|
|
240
|
+
|
|
241
|
+
if (value = cast_value(node))
|
|
242
|
+
return classify(value)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
:opaque_call
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Indexing into a constant, another call's result, or a
|
|
249
|
+
# fresh instance returns a value of unprovable shareability.
|
|
250
|
+
# Sorbet type constructors and ENV, whose values are frozen
|
|
251
|
+
# strings, stay silent.
|
|
252
|
+
def index_kind(node)
|
|
253
|
+
owner = const_name(node.receiver)
|
|
254
|
+
return set_kind(node) if owner == "Set"
|
|
255
|
+
if owner.nil?
|
|
256
|
+
receiver_kind = classify(node.receiver)
|
|
257
|
+
return :opaque_call if %i[opaque_call instance_new]
|
|
258
|
+
.include?(receiver_kind)
|
|
259
|
+
return :unknown
|
|
260
|
+
end
|
|
261
|
+
return :unknown if owner == "ENV" ||
|
|
262
|
+
owner == "T" || owner.start_with?("T::")
|
|
263
|
+
|
|
264
|
+
:opaque_call
|
|
265
|
+
end
|
|
266
|
+
|
|
182
267
|
def classify_freeze(node, receiver)
|
|
183
268
|
return :unknown unless node.arguments.nil? && receiver
|
|
184
269
|
|
|
185
|
-
case receiver
|
|
270
|
+
case (receiver = begin_value(receiver))
|
|
186
271
|
when Prism::StringNode
|
|
187
272
|
:shareable
|
|
188
273
|
when Prism::ArrayNode, Prism::HashNode
|
|
@@ -194,6 +279,7 @@ module Audition
|
|
|
194
279
|
when :default_proc then :default_proc
|
|
195
280
|
when :mutable_call then :shareable
|
|
196
281
|
when :mutable_container then frozen_kind(receiver)
|
|
282
|
+
when :instance_new, :opaque_call then :shallow_opaque
|
|
197
283
|
else :unknown
|
|
198
284
|
end
|
|
199
285
|
else
|
|
@@ -214,6 +300,7 @@ module Audition
|
|
|
214
300
|
owner = const_name(receiver)
|
|
215
301
|
(name == :new && owner == "String") ||
|
|
216
302
|
(owner == "Kernel" && FORMATTERS.include?(name)) ||
|
|
303
|
+
(owner == "File" && FILE_PATH_METHODS.include?(name)) ||
|
|
217
304
|
STRING_ONLY_METHODS.include?(name)
|
|
218
305
|
else
|
|
219
306
|
false
|
|
@@ -243,12 +330,12 @@ module Audition
|
|
|
243
330
|
end
|
|
244
331
|
|
|
245
332
|
# A Set built from literals is a container of them:
|
|
246
|
-
# `Set.new([...])`, `Set[...]`, `%w[...].to_set`.
|
|
247
|
-
#
|
|
248
|
-
#
|
|
333
|
+
# `Set.new([...])`, `Set[...]`, `%w[...].to_set`. A Set
|
|
334
|
+
# built from an opaque source or a mapping block is still
|
|
335
|
+
# a fresh unfrozen Set, mutable no matter its contents.
|
|
249
336
|
def set_kind(node)
|
|
250
337
|
elements = set_elements(node)
|
|
251
|
-
elements ? elements_kind(elements) : :
|
|
338
|
+
elements ? elements_kind(elements) : :mutable_container
|
|
252
339
|
end
|
|
253
340
|
|
|
254
341
|
def set_elements(node)
|
|
@@ -308,20 +395,33 @@ module Audition
|
|
|
308
395
|
body && body.size == 1 && body[0]
|
|
309
396
|
end
|
|
310
397
|
|
|
311
|
-
# Fold element classifications
|
|
312
|
-
#
|
|
313
|
-
# :shallow_freeze;
|
|
314
|
-
#
|
|
315
|
-
#
|
|
398
|
+
# Fold element classifications by the strongest evidence:
|
|
399
|
+
# a sync primitive poisons the whole container; a provably
|
|
400
|
+
# mutable element makes it :shallow_freeze; an opaque call
|
|
401
|
+
# result makes it :shallow_opaque. A bare constant read is
|
|
402
|
+
# commonly a class or another frozen constant, so on its
|
|
403
|
+
# own it keeps the container silent (:unknown), but it
|
|
404
|
+
# cannot excuse a bad element elsewhere.
|
|
405
|
+
VERDICT_RANK = {
|
|
406
|
+
shareable: 0, unknown: 1, shallow_opaque: 2,
|
|
407
|
+
shallow_freeze: 3
|
|
408
|
+
}.freeze
|
|
409
|
+
|
|
316
410
|
def deep_classify(elements)
|
|
317
411
|
verdict = :shareable
|
|
318
412
|
elements.each do |element|
|
|
319
413
|
element_children(element).each do |child|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
414
|
+
kind =
|
|
415
|
+
case classify(child)
|
|
416
|
+
when :shareable then :shareable
|
|
417
|
+
when :sync_primitive then return :sync_primitive
|
|
418
|
+
when :unknown then :unknown
|
|
419
|
+
when :instance_new, :opaque_call, :shallow_opaque
|
|
420
|
+
:shallow_opaque
|
|
421
|
+
else :shallow_freeze
|
|
422
|
+
end
|
|
423
|
+
if VERDICT_RANK[kind] > VERDICT_RANK[verdict]
|
|
424
|
+
verdict = kind
|
|
325
425
|
end
|
|
326
426
|
end
|
|
327
427
|
end
|
|
@@ -22,9 +22,9 @@ module Audition
|
|
|
22
22
|
INIT = /\bInit_\w+\s*\(/
|
|
23
23
|
SOURCES = "*.{c,cc,cpp,cxx,m,mm,h,hpp,rs,zig}"
|
|
24
24
|
BUILD_FILES = %w[Cargo.toml build.zig extconf.rb].freeze
|
|
25
|
-
# Harness crates and test trees
|
|
26
|
-
#
|
|
27
|
-
#
|
|
25
|
+
# Harness crates and test trees are not compiled into the
|
|
26
|
+
# extension; a fuzz target that links the VM itself may call
|
|
27
|
+
# rb_ext_ractor_safe without the extension doing so.
|
|
28
28
|
HARNESS_DIRS = %w[fuzz benches tests examples test spec].freeze
|
|
29
29
|
|
|
30
30
|
SILENT_WHY =
|
|
@@ -51,7 +51,7 @@ module Audition
|
|
|
51
51
|
DECLARED_WHY =
|
|
52
52
|
"rb_ext_ractor_safe(true) is the maintainer's assertion " \
|
|
53
53
|
"that the extension keeps no process-global mutable state; " \
|
|
54
|
-
"Ruby does not verify it and neither can
|
|
54
|
+
"Ruby does not verify it and neither can Audition. Its " \
|
|
55
55
|
"methods run from any Ractor, in parallel, on the strength " \
|
|
56
56
|
"of that assertion alone."
|
|
57
57
|
DECLARED_FIX =
|
|
@@ -91,12 +91,16 @@ module Audition
|
|
|
91
91
|
nil
|
|
92
92
|
end
|
|
93
93
|
|
|
94
|
-
# One finding per extension directory
|
|
94
|
+
# One finding per extension directory, anchored
|
|
95
95
|
# at the declaration when there is one, else at Init_* or the
|
|
96
96
|
# build file, where the declaration belongs.
|
|
97
97
|
def source_finding(dir, root)
|
|
98
98
|
sources = sources_under(dir)
|
|
99
|
-
label = dir
|
|
99
|
+
label = if dir == root
|
|
100
|
+
File.basename(root)
|
|
101
|
+
else
|
|
102
|
+
dir.delete_prefix("#{root}/")
|
|
103
|
+
end
|
|
100
104
|
path, line = locate(sources, DECLARATION)
|
|
101
105
|
if path
|
|
102
106
|
return finding(:info, path, line,
|
|
@@ -111,18 +115,26 @@ module Audition
|
|
|
111
115
|
"safety", SILENT_WHY + SOURCE_TAIL, SILENT_FIX)
|
|
112
116
|
end
|
|
113
117
|
|
|
114
|
-
#
|
|
115
|
-
#
|
|
118
|
+
# Directories holding native sources, found by the build file
|
|
119
|
+
# that compiles them rather than by convention: a gem is free
|
|
120
|
+
# to put its extension anywhere. A manifest above another one
|
|
121
|
+
# is dropped, so a workspace does not report the sources of
|
|
122
|
+
# the crates under it a second time.
|
|
116
123
|
def extension_dirs(root)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
dirs = Dir[File.join(ext, "*")].sort.select do |dir|
|
|
121
|
-
File.directory?(dir) && !skipped?(File.basename(dir)) &&
|
|
122
|
-
sources_under(dir).any?
|
|
124
|
+
dirs = build_dirs(root).select { |dir| sources_under(dir).any? }
|
|
125
|
+
dirs.reject do |dir|
|
|
126
|
+
dirs.any? { |other| other.start_with?("#{dir}/") }
|
|
123
127
|
end
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def build_dirs(root)
|
|
131
|
+
pattern = "{#{BUILD_FILES.join(",")}}"
|
|
132
|
+
Dir[File.join(root, "**", pattern)].filter_map do |path|
|
|
133
|
+
relative = path.delete_prefix("#{root}/").split("/")
|
|
134
|
+
next if relative[0..-2].any? { |part| skipped?(part) }
|
|
135
|
+
|
|
136
|
+
File.dirname(path)
|
|
137
|
+
end.uniq.sort
|
|
126
138
|
end
|
|
127
139
|
|
|
128
140
|
def sources_under(dir)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "etc"
|
|
4
|
+
|
|
5
|
+
module Audition
|
|
6
|
+
module Static
|
|
7
|
+
# How a scan is divided between Ractor workers.
|
|
8
|
+
module WorkSplit
|
|
9
|
+
# Ractors run on a fixed pool of native threads sized by
|
|
10
|
+
# RUBY_MAX_CPU, which only the environment can set. Spawning
|
|
11
|
+
# past it buys no parallelism and costs a Ractor each.
|
|
12
|
+
RACTOR_CPU_DEFAULT = 8
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# One worker per core the Ractor pool can actually run. The
|
|
17
|
+
# main Ractor only waits while workers scan, so no core is
|
|
18
|
+
# held back for it.
|
|
19
|
+
#
|
|
20
|
+
# @return [Integer]
|
|
21
|
+
def workers
|
|
22
|
+
Etc.nprocessors.clamp(1, ractor_cpu_limit)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def ractor_cpu_limit
|
|
26
|
+
limit = ENV["RUBY_MAX_CPU"].to_i
|
|
27
|
+
limit.positive? ? limit : RACTOR_CPU_DEFAULT
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Longest-processing-time-first: deal the heaviest item onto
|
|
31
|
+
# the lightest worker. Consecutive files are neighbors in the
|
|
32
|
+
# tree and so alike in size, so contiguous slices come out
|
|
33
|
+
# lopsided: one worker can draw a slice weighing several times
|
|
34
|
+
# the mean and still be running once the rest have finished.
|
|
35
|
+
# Greedy is enough here: LPT finishes within 4/3 of an
|
|
36
|
+
# optimal split.
|
|
37
|
+
#
|
|
38
|
+
# @param weighted [Array<Array>] `[item, weight]` pairs
|
|
39
|
+
# @param count [Integer] worker count
|
|
40
|
+
# @return [Array<Array>] one chunk of items per busy worker
|
|
41
|
+
def chunks(weighted, count)
|
|
42
|
+
chunks = Array.new(count) { [] }
|
|
43
|
+
loads = Array.new(count, 0)
|
|
44
|
+
weighted.sort_by { |item, weight| [-weight, item] }
|
|
45
|
+
.each do |item, weight|
|
|
46
|
+
lightest = loads.index(loads.min)
|
|
47
|
+
chunks[lightest] << item
|
|
48
|
+
loads[lightest] += weight
|
|
49
|
+
end
|
|
50
|
+
chunks.reject(&:empty?)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/audition/target.rb
CHANGED
|
@@ -12,6 +12,12 @@ module Audition
|
|
|
12
12
|
vendor node_modules tmp log coverage pkg .git .bundle
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
|
+
# Directories holding the target's tests rather than the code
|
|
16
|
+
# a production boot loads. The default; `test_dirs` in
|
|
17
|
+
# .audition.yml replaces it for a project that files them
|
|
18
|
+
# elsewhere.
|
|
19
|
+
TEST_DIRS = %w[test spec features].freeze
|
|
20
|
+
|
|
15
21
|
# Cargo and Zig build output: full of .so/.dylib artifacts that
|
|
16
22
|
# are not the extension `require` loads.
|
|
17
23
|
BUILD_DIRS = %w[target zig-out].freeze
|
|
@@ -20,6 +26,11 @@ module Audition
|
|
|
20
26
|
# "bundle" on macOS, "so" everywhere else, Windows included).
|
|
21
27
|
COMPILED = "*.{bundle,so}"
|
|
22
28
|
|
|
29
|
+
# Generated type stubs. The extension is the convention; where
|
|
30
|
+
# the generator writes them is not, so they are found by
|
|
31
|
+
# extension anywhere in the tree.
|
|
32
|
+
STUBS = "*.rbi"
|
|
33
|
+
|
|
23
34
|
# @return [Symbol] one of `:script`, `:gem`, `:rack`, `:rails`,
|
|
24
35
|
# `:directory`, `:bundle`
|
|
25
36
|
attr_reader :type
|
|
@@ -34,6 +45,10 @@ module Audition
|
|
|
34
45
|
# shipped with the target; empty for scripts and file lists
|
|
35
46
|
attr_reader :compiled_files
|
|
36
47
|
|
|
48
|
+
# @return [Array<String>] generated type stubs (.rbi) found
|
|
49
|
+
# anywhere in the target's tree
|
|
50
|
+
attr_reader :stub_files
|
|
51
|
+
|
|
37
52
|
# @return [Hash, nil] dynamic probe entry (`:mode` plus
|
|
38
53
|
# mode-specific keys), nil for static-only targets
|
|
39
54
|
attr_reader :entry
|
|
@@ -75,7 +90,8 @@ module Audition
|
|
|
75
90
|
type: :files,
|
|
76
91
|
root: Dir.pwd,
|
|
77
92
|
ruby_files: paths,
|
|
78
|
-
entry: nil
|
|
93
|
+
entry: nil,
|
|
94
|
+
stub_files: stubs(Dir.pwd)
|
|
79
95
|
)
|
|
80
96
|
end
|
|
81
97
|
|
|
@@ -96,10 +112,26 @@ module Audition
|
|
|
96
112
|
type: :script,
|
|
97
113
|
root: File.dirname(path),
|
|
98
114
|
ruby_files: [path],
|
|
99
|
-
|
|
115
|
+
# A file inside a Rails root cannot run standalone; the
|
|
116
|
+
# script probe would report a misleading boot failure, so
|
|
117
|
+
# such files stay static-only.
|
|
118
|
+
entry: rails_member?(path) ? nil : {mode: :script, path: path},
|
|
119
|
+
stub_files: stubs(File.dirname(path))
|
|
100
120
|
)
|
|
101
121
|
end
|
|
102
122
|
|
|
123
|
+
def self.rails_member?(path)
|
|
124
|
+
dir = File.dirname(File.expand_path(path))
|
|
125
|
+
until dir == (parent = File.dirname(dir))
|
|
126
|
+
return true if File.file?(
|
|
127
|
+
File.join(dir, "config", "application.rb")
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
dir = parent
|
|
131
|
+
end
|
|
132
|
+
false
|
|
133
|
+
end
|
|
134
|
+
|
|
103
135
|
def self.from_directory(dir)
|
|
104
136
|
application_rb = File.join(dir, "config", "application.rb")
|
|
105
137
|
config_ru = File.join(dir, "config.ru")
|
|
@@ -117,7 +149,8 @@ module Audition
|
|
|
117
149
|
root: dir,
|
|
118
150
|
ruby_files: glob(dir),
|
|
119
151
|
entry: nil,
|
|
120
|
-
compiled_files: compiled(dir)
|
|
152
|
+
compiled_files: compiled(dir),
|
|
153
|
+
stub_files: stubs(dir)
|
|
121
154
|
)
|
|
122
155
|
end
|
|
123
156
|
end
|
|
@@ -131,16 +164,21 @@ module Audition
|
|
|
131
164
|
glob(File.join(spec.full_gem_path, rp))
|
|
132
165
|
end,
|
|
133
166
|
entry: {mode: :require, feature: name,
|
|
167
|
+
load_paths: spec.full_require_paths,
|
|
134
168
|
root: spec.full_gem_path},
|
|
135
|
-
compiled_files: compiled_for(spec)
|
|
169
|
+
compiled_files: compiled_for(spec),
|
|
170
|
+
stub_files: stubs(spec.full_gem_path)
|
|
136
171
|
)
|
|
137
172
|
rescue Gem::MissingSpecError
|
|
138
173
|
raise Error,
|
|
139
174
|
"#{name} is not a file, directory, or installed gem"
|
|
140
175
|
end
|
|
141
176
|
|
|
177
|
+
# An app's own Ruby is not confined to app/lib/config: local
|
|
178
|
+
# gems, engines and tests boot into the same process, so the
|
|
179
|
+
# whole root is scanned.
|
|
142
180
|
def self.rails_target(dir)
|
|
143
|
-
files =
|
|
181
|
+
files = glob(dir)
|
|
144
182
|
files << File.join(dir, "config.ru")
|
|
145
183
|
new(
|
|
146
184
|
type: :rails,
|
|
@@ -151,7 +189,8 @@ module Audition
|
|
|
151
189
|
environment: File.join(dir, "config", "environment.rb"),
|
|
152
190
|
root: dir
|
|
153
191
|
},
|
|
154
|
-
compiled_files: compiled(dir)
|
|
192
|
+
compiled_files: compiled(dir),
|
|
193
|
+
stub_files: stubs(dir)
|
|
155
194
|
)
|
|
156
195
|
end
|
|
157
196
|
|
|
@@ -160,24 +199,31 @@ module Audition
|
|
|
160
199
|
type: :rack,
|
|
161
200
|
root: dir,
|
|
162
201
|
ruby_files: [config_ru] + glob(dir),
|
|
163
|
-
entry: {mode: :rack, config_ru: config_ru},
|
|
164
|
-
compiled_files: compiled(dir)
|
|
202
|
+
entry: {mode: :rack, config_ru: config_ru, root: dir},
|
|
203
|
+
compiled_files: compiled(dir),
|
|
204
|
+
stub_files: stubs(dir)
|
|
165
205
|
)
|
|
166
206
|
end
|
|
167
207
|
|
|
208
|
+
# Reading require_paths off the gemspec would mean evaluating
|
|
209
|
+
# it, which the static pass never does; `lib` is the packaging
|
|
210
|
+
# default, and a gem that keeps its code elsewhere falls back to
|
|
211
|
+
# the whole checkout rather than scanning nothing.
|
|
168
212
|
def self.gem_dir_target(dir, gemspec)
|
|
169
213
|
lib = File.join(dir, "lib")
|
|
214
|
+
paths = File.directory?(lib) ? [lib] : [dir]
|
|
170
215
|
new(
|
|
171
216
|
type: :gem,
|
|
172
217
|
root: dir,
|
|
173
|
-
ruby_files: glob(
|
|
218
|
+
ruby_files: paths.flat_map { |path| glob(path) },
|
|
174
219
|
entry: {
|
|
175
220
|
mode: :require,
|
|
176
221
|
feature: File.basename(gemspec, ".gemspec"),
|
|
177
|
-
load_paths:
|
|
222
|
+
load_paths: paths,
|
|
178
223
|
root: dir
|
|
179
224
|
},
|
|
180
|
-
compiled_files: compiled(dir)
|
|
225
|
+
compiled_files: compiled(dir),
|
|
226
|
+
stub_files: stubs(dir)
|
|
181
227
|
)
|
|
182
228
|
end
|
|
183
229
|
|
|
@@ -199,6 +245,14 @@ module Audition
|
|
|
199
245
|
end.sort
|
|
200
246
|
end
|
|
201
247
|
|
|
248
|
+
# Generated type stubs anywhere under a directory.
|
|
249
|
+
#
|
|
250
|
+
# @param dir [String]
|
|
251
|
+
# @return [Array<String>]
|
|
252
|
+
def self.stubs(dir)
|
|
253
|
+
glob(dir, STUBS)
|
|
254
|
+
end
|
|
255
|
+
|
|
202
256
|
# macOS debug-symbol bundles (x.bundle.dSYM/...) carry a file
|
|
203
257
|
# with the extension's name that nothing ever loads.
|
|
204
258
|
def self.compiled(dir)
|
|
@@ -222,15 +276,30 @@ module Audition
|
|
|
222
276
|
|
|
223
277
|
private_class_method :from_file, :from_directory, :from_gem_name,
|
|
224
278
|
:rails_target, :rack_target, :gem_dir_target,
|
|
225
|
-
:glob, :compiled, :normalize
|
|
279
|
+
:rails_member?, :glob, :compiled, :normalize
|
|
226
280
|
|
|
227
281
|
def initialize(type:, root:, ruby_files:, entry:,
|
|
228
|
-
compiled_files: [])
|
|
282
|
+
compiled_files: [], stub_files: [])
|
|
229
283
|
@type = type
|
|
230
284
|
@root = root
|
|
231
285
|
@ruby_files = ruby_files
|
|
232
286
|
@entry = entry
|
|
233
287
|
@compiled_files = compiled_files
|
|
288
|
+
@stub_files = stub_files
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Whether a path is test/spec code rather than code the
|
|
292
|
+
# production boot loads.
|
|
293
|
+
#
|
|
294
|
+
# @param path [String] absolute or root-relative
|
|
295
|
+
# @param dirs [Array<String>] directory names that hold tests
|
|
296
|
+
# @return [Boolean]
|
|
297
|
+
def test_file?(path, dirs: TEST_DIRS)
|
|
298
|
+
relative = File.expand_path(path, root)
|
|
299
|
+
.delete_prefix("#{root}/")
|
|
300
|
+
parts = relative.split("/")
|
|
301
|
+
parts[0..-2].any? { |p| dirs.include?(p) } ||
|
|
302
|
+
parts.last.to_s.end_with?("_test.rb", "_spec.rb")
|
|
234
303
|
end
|
|
235
304
|
end
|
|
236
305
|
end
|
data/lib/audition/version.rb
CHANGED
data/lib/audition.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "audition/version"
|
|
4
|
+
require_relative "audition/progress"
|
|
4
5
|
require_relative "audition/finding"
|
|
5
6
|
require_relative "audition/target"
|
|
6
7
|
require_relative "audition/static/source_file"
|
|
7
8
|
require_relative "audition/static/literal_classifier"
|
|
8
9
|
require_relative "audition/static/checks"
|
|
9
10
|
require_relative "audition/static/graph_audit"
|
|
11
|
+
require_relative "audition/static/gem_calls"
|
|
10
12
|
require_relative "audition/static/native_extensions"
|
|
11
13
|
require_relative "audition/static/analyzer"
|
|
12
14
|
require_relative "audition/dynamic/prober"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: audition
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
@@ -43,14 +43,14 @@ dependencies:
|
|
|
43
43
|
requirements:
|
|
44
44
|
- - ">="
|
|
45
45
|
- !ruby/object:Gem::Version
|
|
46
|
-
version: '0.
|
|
46
|
+
version: '0.4'
|
|
47
47
|
type: :runtime
|
|
48
48
|
prerelease: false
|
|
49
49
|
version_requirements: !ruby/object:Gem::Requirement
|
|
50
50
|
requirements:
|
|
51
51
|
- - ">="
|
|
52
52
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: '0.
|
|
53
|
+
version: '0.4'
|
|
54
54
|
- !ruby/object:Gem::Dependency
|
|
55
55
|
name: table_tennis
|
|
56
56
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -102,24 +102,30 @@ files:
|
|
|
102
102
|
- lib/audition/dynamic/prober.rb
|
|
103
103
|
- lib/audition/finding.rb
|
|
104
104
|
- lib/audition/fixer.rb
|
|
105
|
+
- lib/audition/progress.rb
|
|
105
106
|
- lib/audition/report.rb
|
|
106
107
|
- lib/audition/report/github.rb
|
|
107
108
|
- lib/audition/report/json.rb
|
|
108
109
|
- lib/audition/report/style.rb
|
|
110
|
+
- lib/audition/report/sweep.rb
|
|
109
111
|
- lib/audition/report/text.rb
|
|
110
112
|
- lib/audition/rewriters.rb
|
|
111
113
|
- lib/audition/static/analyzer.rb
|
|
112
114
|
- lib/audition/static/checks.rb
|
|
113
115
|
- lib/audition/static/checks/base.rb
|
|
116
|
+
- lib/audition/static/checks/dependency_class_state.rb
|
|
114
117
|
- lib/audition/static/checks/global_variables.rb
|
|
115
118
|
- lib/audition/static/checks/mutable_constants.rb
|
|
116
119
|
- lib/audition/static/checks/ractor_isolation.rb
|
|
117
120
|
- lib/audition/static/checks/runtime_require.rb
|
|
118
121
|
- lib/audition/static/checks/unsafe_calls.rb
|
|
122
|
+
- lib/audition/static/checks/unshareable_reads.rb
|
|
123
|
+
- lib/audition/static/gem_calls.rb
|
|
119
124
|
- lib/audition/static/graph_audit.rb
|
|
120
125
|
- lib/audition/static/literal_classifier.rb
|
|
121
126
|
- lib/audition/static/native_extensions.rb
|
|
122
127
|
- lib/audition/static/source_file.rb
|
|
128
|
+
- lib/audition/static/work_split.rb
|
|
123
129
|
- lib/audition/target.rb
|
|
124
130
|
- lib/audition/version.rb
|
|
125
131
|
homepage: https://github.com/yaroslav/audition
|
|
@@ -142,7 +148,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
142
148
|
- !ruby/object:Gem::Version
|
|
143
149
|
version: '0'
|
|
144
150
|
requirements: []
|
|
145
|
-
rubygems_version: 4.0.
|
|
151
|
+
rubygems_version: 4.0.20
|
|
146
152
|
specification_version: 4
|
|
147
153
|
summary: Probe Ruby code for Ractor-readiness
|
|
148
154
|
test_files: []
|