audition 0.2.4 → 0.3.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 +101 -9
- data/lib/audition/bundle_sweep.rb +11 -3
- data/lib/audition/cli.rb +110 -16
- data/lib/audition/config.rb +3 -3
- data/lib/audition/dynamic/harness.rb +113 -20
- data/lib/audition/dynamic/prober.rb +44 -3
- data/lib/audition/report/github.rb +68 -0
- data/lib/audition/report/json.rb +64 -0
- data/lib/audition/report/style.rb +74 -0
- data/lib/audition/report/text.rb +149 -0
- data/lib/audition/report.rb +8 -294
- data/lib/audition/rewriters.rb +4 -4
- data/lib/audition/static/checks/mutable_constants.rb +91 -28
- data/lib/audition/static/checks/runtime_require.rb +4 -1
- data/lib/audition/static/checks/unsafe_calls.rb +7 -6
- data/lib/audition/static/graph_audit.rb +3 -5
- data/lib/audition/static/literal_classifier.rb +78 -7
- data/lib/audition/static/native_extensions.rb +163 -0
- data/lib/audition/static/source_file.rb +70 -0
- data/lib/audition/target.rb +74 -10
- data/lib/audition/version.rb +1 -1
- data/lib/audition.rb +1 -0
- metadata +6 -1
|
@@ -7,9 +7,10 @@ module Audition
|
|
|
7
7
|
# Classifies a Prism expression node by Ractor shareability:
|
|
8
8
|
# :shareable proven deeply shareable
|
|
9
9
|
# :mutable_string unfrozen String literal
|
|
10
|
-
# :mutable_container Array/Hash literal or constructor
|
|
11
|
-
# :mutable_call unfrozen String
|
|
12
|
-
# a call (`.tr`,
|
|
10
|
+
# :mutable_container Array/Hash/Set literal or constructor
|
|
11
|
+
# :mutable_call unfrozen String, Regexp, or sentinel
|
|
12
|
+
# Object returned by a call (`.tr`,
|
|
13
|
+
# `format`, `Regexp.new`, `Object.new`)
|
|
13
14
|
# :shallow_freeze frozen container with mutable elements
|
|
14
15
|
# :sync_primitive Mutex/Queue/... constructor
|
|
15
16
|
# :proc lambda or proc
|
|
@@ -23,11 +24,20 @@ module Audition
|
|
|
23
24
|
Thread::ConditionVariable
|
|
24
25
|
].freeze
|
|
25
26
|
SHAREABLE_FACTORIES = %w[Struct Class Module].freeze
|
|
27
|
+
# Sentinels: `NOT_GIVEN = Object.new` raises when read from
|
|
28
|
+
# a worker until frozen, and a frozen bare Object is
|
|
29
|
+
# shareable (verified on Ruby 4.0.6). BasicObject has no
|
|
30
|
+
# #freeze.
|
|
31
|
+
SENTINEL_FACTORIES = %w[Object BasicObject].freeze
|
|
32
|
+
# Concurrent::Map defines no #freeze, so make_shareable
|
|
33
|
+
# raises NoMethodError on it; a constant holding one can
|
|
34
|
+
# never be shared (verified on 4.0.6 with concurrent-ruby).
|
|
35
|
+
UNFREEZABLE_COLLECTIONS = %w[Concurrent::Map].freeze
|
|
26
36
|
|
|
27
37
|
# Calls returning a fresh, unfrozen String or Regexp;
|
|
28
38
|
# `# frozen_string_literal: true` covers literals only.
|
|
29
|
-
#
|
|
30
|
-
# constants
|
|
39
|
+
# Both shapes (`.tr` and `Regexp.new`) turn up in
|
|
40
|
+
# constants in the wild. These names belong
|
|
31
41
|
# to String alone in core, so any receiver qualifies.
|
|
32
42
|
STRING_ONLY_METHODS = %i[
|
|
33
43
|
tr tr_s gsub sub squeeze strip lstrip rstrip chomp chop
|
|
@@ -95,6 +105,23 @@ module Audition
|
|
|
95
105
|
end
|
|
96
106
|
end
|
|
97
107
|
|
|
108
|
+
# What `value.freeze` would classify as, for the check to
|
|
109
|
+
# choose a plain `.freeze` over a deep wrap: :shareable
|
|
110
|
+
# when every element is provably shareable, :shallow_freeze
|
|
111
|
+
# when one is provably mutable, :unknown otherwise.
|
|
112
|
+
def frozen_kind(value)
|
|
113
|
+
case value
|
|
114
|
+
when Prism::ArrayNode, Prism::HashNode,
|
|
115
|
+
Prism::KeywordHashNode
|
|
116
|
+
deep_classify(value.elements)
|
|
117
|
+
when Prism::CallNode
|
|
118
|
+
elements = set_elements(value)
|
|
119
|
+
elements ? deep_classify(elements) : :unknown
|
|
120
|
+
else
|
|
121
|
+
:unknown
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
98
125
|
private
|
|
99
126
|
|
|
100
127
|
# Adjacent literals ("a" "b") parse as interpolation but
|
|
@@ -121,18 +148,26 @@ module Audition
|
|
|
121
148
|
classify_freeze(node, receiver)
|
|
122
149
|
when :new
|
|
123
150
|
name = const_name(receiver)
|
|
124
|
-
return :sync_primitive if SYNC_PRIMITIVES.include?(name)
|
|
151
|
+
return :sync_primitive if SYNC_PRIMITIVES.include?(name) ||
|
|
152
|
+
UNFREEZABLE_COLLECTIONS.include?(name)
|
|
125
153
|
return :shareable if SHAREABLE_FACTORIES.include?(name)
|
|
126
154
|
return :proc if name == "Proc" && node.block
|
|
127
155
|
# Hash.new retains its block as the default proc;
|
|
128
156
|
# Array.new only uses its block to build elements.
|
|
129
157
|
return :default_proc if name == "Hash" && node.block
|
|
158
|
+
return set_kind(node) if name == "Set"
|
|
159
|
+
if SENTINEL_FACTORIES.include?(name)
|
|
160
|
+
bare = node.arguments.nil? && node.block.nil?
|
|
161
|
+
return bare ? :mutable_call : :unknown
|
|
162
|
+
end
|
|
130
163
|
|
|
131
164
|
if %w[Hash Array].include?(name)
|
|
132
165
|
return :mutable_container
|
|
133
166
|
end
|
|
134
167
|
|
|
135
168
|
:unknown
|
|
169
|
+
when :[], :to_set
|
|
170
|
+
set_kind(node)
|
|
136
171
|
when :define
|
|
137
172
|
(const_name(receiver) == "Data") ? :shareable : :unknown
|
|
138
173
|
when :make_shareable
|
|
@@ -158,6 +193,7 @@ module Audition
|
|
|
158
193
|
case classify(receiver)
|
|
159
194
|
when :default_proc then :default_proc
|
|
160
195
|
when :mutable_call then :shareable
|
|
196
|
+
when :mutable_container then frozen_kind(receiver)
|
|
161
197
|
else :unknown
|
|
162
198
|
end
|
|
163
199
|
else
|
|
@@ -194,7 +230,11 @@ module Audition
|
|
|
194
230
|
# keeps a frozen Hash of Mutexes). The classification
|
|
195
231
|
# propagates so no freeze or wrap is ever suggested.
|
|
196
232
|
def container_kind(node)
|
|
197
|
-
|
|
233
|
+
elements_kind(node.elements)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def elements_kind(elements)
|
|
237
|
+
sync = elements.any? do |element|
|
|
198
238
|
element_children(element).any? do |child|
|
|
199
239
|
classify(child) == :sync_primitive
|
|
200
240
|
end
|
|
@@ -202,6 +242,37 @@ module Audition
|
|
|
202
242
|
sync ? :sync_primitive : :mutable_container
|
|
203
243
|
end
|
|
204
244
|
|
|
245
|
+
# A Set built from literals is a container of them:
|
|
246
|
+
# `Set.new([...])`, `Set[...]`, `%w[...].to_set`. Anything
|
|
247
|
+
# else, such as `Set.new(compute)` or a mapping block,
|
|
248
|
+
# stays unknown.
|
|
249
|
+
def set_kind(node)
|
|
250
|
+
elements = set_elements(node)
|
|
251
|
+
elements ? elements_kind(elements) : :unknown
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
def set_elements(node)
|
|
255
|
+
return nil if node.block
|
|
256
|
+
|
|
257
|
+
case node.name
|
|
258
|
+
when :new, :[]
|
|
259
|
+
return nil unless const_name(node.receiver) == "Set"
|
|
260
|
+
|
|
261
|
+
args = node.arguments&.arguments || []
|
|
262
|
+
return args if node.name == :[]
|
|
263
|
+
return [] if args.empty?
|
|
264
|
+
return nil unless args.size == 1
|
|
265
|
+
|
|
266
|
+
args[0].is_a?(Prism::ArrayNode) ? args[0].elements : nil
|
|
267
|
+
when :to_set
|
|
268
|
+
receiver = node.receiver
|
|
269
|
+
return nil unless node.arguments.nil? &&
|
|
270
|
+
receiver.is_a?(Prism::ArrayNode)
|
|
271
|
+
|
|
272
|
+
receiver.elements
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
205
276
|
def element_children(element)
|
|
206
277
|
case element
|
|
207
278
|
when Prism::AssocNode then [element.key, element.value]
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Audition
|
|
4
|
+
module Static
|
|
5
|
+
# Compiled extensions are invisible to the Ruby scanner, but the
|
|
6
|
+
# one fact that decides their Ractor behavior is visible from
|
|
7
|
+
# outside. Ruby marks every method an extension defines while
|
|
8
|
+
# Init_* runs without rb_ext_ractor_safe(true), and calling any
|
|
9
|
+
# of them from a non-main Ractor raises Ractor::UnsafeError. The
|
|
10
|
+
# declaration is a libruby import, so a compiled file names the
|
|
11
|
+
# symbol or does not, whatever language produced it; sources
|
|
12
|
+
# spell it as rb_ext_ractor_safe(true) or the
|
|
13
|
+
# RB_EXT_RACTOR_SAFE(true) macro (C, Rust via rb-sys, Zig).
|
|
14
|
+
#
|
|
15
|
+
# Compiled files win when present: they are what `require`
|
|
16
|
+
# loads. Sources are consulted only for a checkout that has not
|
|
17
|
+
# been built yet.
|
|
18
|
+
class NativeExtensions
|
|
19
|
+
CHECK = "native-extension"
|
|
20
|
+
SYMBOL = "rb_ext_ractor_safe"
|
|
21
|
+
DECLARATION = /rb_ext_ractor_safe\s*\(\s*true\s*\)/i
|
|
22
|
+
INIT = /\bInit_\w+\s*\(/
|
|
23
|
+
SOURCES = "*.{c,cc,cpp,cxx,m,mm,h,hpp,rs,zig}"
|
|
24
|
+
BUILD_FILES = %w[Cargo.toml build.zig extconf.rb].freeze
|
|
25
|
+
# Harness crates and test trees under ext/ are not compiled
|
|
26
|
+
# into the extension; a fuzz target that links the VM itself
|
|
27
|
+
# may call rb_ext_ractor_safe without the extension doing so.
|
|
28
|
+
HARNESS_DIRS = %w[fuzz benches tests examples test spec].freeze
|
|
29
|
+
|
|
30
|
+
SILENT_WHY =
|
|
31
|
+
"Ruby marks every method an extension defines while " \
|
|
32
|
+
"Init_* runs without rb_ext_ractor_safe(true), and calling " \
|
|
33
|
+
"any of them from a non-main Ractor raises " \
|
|
34
|
+
"Ractor::UnsafeError (\"ractor unsafe method called from " \
|
|
35
|
+
"not main ractor\"), whether the extension is C, Rust, or " \
|
|
36
|
+
"Zig (verified on Ruby 4.0.6). "
|
|
37
|
+
# Tails appended to SILENT_WHY at finding time; a constant
|
|
38
|
+
# built with + would hold an unfrozen String.
|
|
39
|
+
COMPILED_TAIL =
|
|
40
|
+
"The declaration is a libruby import, so a compiled file " \
|
|
41
|
+
"that never names the symbol cannot have made it."
|
|
42
|
+
SOURCE_TAIL =
|
|
43
|
+
"These sources never call it, so the compiled extension " \
|
|
44
|
+
"will raise the same way."
|
|
45
|
+
SILENT_FIX =
|
|
46
|
+
"Audit the native code for process-global mutable state " \
|
|
47
|
+
"(statics, caches, VALUEs held outside Ruby objects), then " \
|
|
48
|
+
"call RB_EXT_RACTOR_SAFE(true) first thing in Init_* " \
|
|
49
|
+
"(rb_sys::rb_ext_ractor_safe(true) from Rust). Until then, " \
|
|
50
|
+
"keep every call into this extension on the main Ractor."
|
|
51
|
+
DECLARED_WHY =
|
|
52
|
+
"rb_ext_ractor_safe(true) is the maintainer's assertion " \
|
|
53
|
+
"that the extension keeps no process-global mutable state; " \
|
|
54
|
+
"Ruby does not verify it and neither can audition. Its " \
|
|
55
|
+
"methods run from any Ractor, in parallel, on the strength " \
|
|
56
|
+
"of that assertion alone."
|
|
57
|
+
DECLARED_FIX =
|
|
58
|
+
"Exercise real calls from a non-main Ractor (a script " \
|
|
59
|
+
"probe, or the gem's test suite under Ractor.new) before " \
|
|
60
|
+
"relying on it."
|
|
61
|
+
|
|
62
|
+
# @param target [Target]
|
|
63
|
+
# @param compiled_files [Array<String>] compiled extension
|
|
64
|
+
# files to inspect (defaults to the target's own list; the
|
|
65
|
+
# CLI passes the config-filtered subset)
|
|
66
|
+
# @return [Array<Finding>]
|
|
67
|
+
def analyze(target, compiled_files: target.compiled_files)
|
|
68
|
+
if compiled_files.any?
|
|
69
|
+
return compiled_files.filter_map { |p| compiled_finding(p) }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
extension_dirs(target.root).map do |dir|
|
|
73
|
+
source_finding(dir, target.root)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def compiled_finding(path)
|
|
80
|
+
name = File.basename(path)
|
|
81
|
+
if File.binread(path).include?(SYMBOL)
|
|
82
|
+
finding(:info, path, nil,
|
|
83
|
+
"compiled extension #{name} declares Ractor safety " \
|
|
84
|
+
"(imports #{SYMBOL})", DECLARED_WHY, DECLARED_FIX)
|
|
85
|
+
else
|
|
86
|
+
finding(:warning, path, nil,
|
|
87
|
+
"compiled extension #{name} does not declare Ractor " \
|
|
88
|
+
"safety", SILENT_WHY + COMPILED_TAIL, SILENT_FIX)
|
|
89
|
+
end
|
|
90
|
+
rescue SystemCallError
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# One finding per extension directory (ext/<name>), anchored
|
|
95
|
+
# at the declaration when there is one, else at Init_* or the
|
|
96
|
+
# build file, where the declaration belongs.
|
|
97
|
+
def source_finding(dir, root)
|
|
98
|
+
sources = sources_under(dir)
|
|
99
|
+
label = dir.delete_prefix("#{root}/")
|
|
100
|
+
path, line = locate(sources, DECLARATION)
|
|
101
|
+
if path
|
|
102
|
+
return finding(:info, path, line,
|
|
103
|
+
"extension sources under #{label} declare Ractor " \
|
|
104
|
+
"safety (#{SYMBOL})", DECLARED_WHY, DECLARED_FIX)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
path, line = locate(sources, INIT)
|
|
108
|
+
path ||= build_file(dir)
|
|
109
|
+
finding(:warning, path, line,
|
|
110
|
+
"extension sources under #{label} do not declare Ractor " \
|
|
111
|
+
"safety", SILENT_WHY + SOURCE_TAIL, SILENT_FIX)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ext/<name> directories holding native sources; ext/ itself
|
|
115
|
+
# when the sources sit directly in it.
|
|
116
|
+
def extension_dirs(root)
|
|
117
|
+
ext = File.join(root, "ext")
|
|
118
|
+
return [] unless File.directory?(ext)
|
|
119
|
+
|
|
120
|
+
dirs = Dir[File.join(ext, "*")].sort.select do |dir|
|
|
121
|
+
File.directory?(dir) && !skipped?(File.basename(dir)) &&
|
|
122
|
+
sources_under(dir).any?
|
|
123
|
+
end
|
|
124
|
+
dirs << ext if Dir[File.join(ext, SOURCES)].any?
|
|
125
|
+
dirs
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def sources_under(dir)
|
|
129
|
+
Dir[File.join(dir, "**", SOURCES)].sort.reject do |path|
|
|
130
|
+
path.delete_prefix("#{dir}/").split("/")[0..-2]
|
|
131
|
+
.any? { |part| skipped?(part) }
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def skipped?(part)
|
|
136
|
+
Target::EXCLUDED_DIRS.include?(part) ||
|
|
137
|
+
Target::BUILD_DIRS.include?(part) ||
|
|
138
|
+
HARNESS_DIRS.include?(part) || part.start_with?(".")
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def locate(sources, pattern)
|
|
142
|
+
sources.each do |path|
|
|
143
|
+
File.foreach(path, mode: "rb").with_index(1) do |text, n|
|
|
144
|
+
return [path, n] if text.match?(pattern)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def build_file(dir)
|
|
151
|
+
BUILD_FILES.map { |name| File.join(dir, name) }
|
|
152
|
+
.find { |path| File.file?(path) } || dir
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def finding(severity, path, line, message, why, fix)
|
|
156
|
+
Finding.new(
|
|
157
|
+
check: CHECK, severity: severity, message: message,
|
|
158
|
+
why: why, fix: fix, path: path, line: line
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -152,6 +152,76 @@ module Audition
|
|
|
152
152
|
# shaped like `# key: value`, which sweeps up documentation
|
|
153
153
|
# (`# I18n.t: 'date.formats.short'`); inserting after those
|
|
154
154
|
# would land a magic comment mid-file.
|
|
155
|
+
# Constants frozen by a bare `NAME.freeze` statement at the
|
|
156
|
+
# same lexical level as a file, class, or module body: the
|
|
157
|
+
# build-then-freeze shape. A freeze inside a method does
|
|
158
|
+
# not count; nothing guarantees it runs before a Ractor
|
|
159
|
+
# reads the constant.
|
|
160
|
+
# @return [Array<String>]
|
|
161
|
+
def frozen_constants
|
|
162
|
+
@frozen_constants ||= begin
|
|
163
|
+
names = []
|
|
164
|
+
bodies = [root.statements]
|
|
165
|
+
until bodies.empty?
|
|
166
|
+
statements = bodies.shift
|
|
167
|
+
next unless statements.is_a?(Prism::StatementsNode)
|
|
168
|
+
|
|
169
|
+
statements.body.each do |node|
|
|
170
|
+
case node
|
|
171
|
+
when Prism::ModuleNode, Prism::ClassNode,
|
|
172
|
+
Prism::SingletonClassNode
|
|
173
|
+
bodies << node.body
|
|
174
|
+
when Prism::CallNode
|
|
175
|
+
next unless node.name == :freeze &&
|
|
176
|
+
node.arguments.nil? && node.block.nil?
|
|
177
|
+
|
|
178
|
+
name = constant_receiver(node.receiver)
|
|
179
|
+
names << name if name
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
names.uniq
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Calls that give a constant's object singleton behavior;
|
|
188
|
+
# freezing the object first makes them raise FrozenError
|
|
189
|
+
# (`NULL = Object.new; def NULL.to_s = "null"`).
|
|
190
|
+
CONST_CUSTOMIZERS = %i[
|
|
191
|
+
extend define_singleton_method instance_eval instance_exec
|
|
192
|
+
singleton_class instance_variable_set
|
|
193
|
+
].freeze
|
|
194
|
+
|
|
195
|
+
# @return [Array<String>] names of constants that receive a
|
|
196
|
+
# singleton method definition or a customizing call
|
|
197
|
+
def customized_constants
|
|
198
|
+
@customized_constants ||= begin
|
|
199
|
+
names = []
|
|
200
|
+
queue = [root]
|
|
201
|
+
until queue.empty?
|
|
202
|
+
node = queue.shift
|
|
203
|
+
queue.concat(node.child_nodes.compact)
|
|
204
|
+
receiver =
|
|
205
|
+
if node.is_a?(Prism::DefNode)
|
|
206
|
+
node.receiver
|
|
207
|
+
elsif node.is_a?(Prism::CallNode) &&
|
|
208
|
+
CONST_CUSTOMIZERS.include?(node.name)
|
|
209
|
+
node.receiver
|
|
210
|
+
end
|
|
211
|
+
name = constant_receiver(receiver)
|
|
212
|
+
names << name if name
|
|
213
|
+
end
|
|
214
|
+
names.uniq
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def constant_receiver(node)
|
|
219
|
+
case node
|
|
220
|
+
when Prism::ConstantReadNode then node.name.to_s
|
|
221
|
+
when Prism::ConstantPathNode then node.location.slice
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
155
225
|
MAGIC_KEYS = %w[
|
|
156
226
|
encoding coding frozen_string_literal
|
|
157
227
|
shareable_constant_value warn_indent
|
data/lib/audition/target.rb
CHANGED
|
@@ -12,6 +12,14 @@ module Audition
|
|
|
12
12
|
vendor node_modules tmp log coverage pkg .git .bundle
|
|
13
13
|
].freeze
|
|
14
14
|
|
|
15
|
+
# Cargo and Zig build output: full of .so/.dylib artifacts that
|
|
16
|
+
# are not the extension `require` loads.
|
|
17
|
+
BUILD_DIRS = %w[target zig-out].freeze
|
|
18
|
+
|
|
19
|
+
# What Ruby loads as a native extension (RbConfig DLEXT is
|
|
20
|
+
# "bundle" on macOS, "so" everywhere else, Windows included).
|
|
21
|
+
COMPILED = "*.{bundle,so}"
|
|
22
|
+
|
|
15
23
|
# @return [Symbol] one of `:script`, `:gem`, `:rack`, `:rails`,
|
|
16
24
|
# `:directory`, `:bundle`
|
|
17
25
|
attr_reader :type
|
|
@@ -22,6 +30,10 @@ module Audition
|
|
|
22
30
|
# @return [Array<String>] Ruby files to scan statically
|
|
23
31
|
attr_reader :ruby_files
|
|
24
32
|
|
|
33
|
+
# @return [Array<String>] compiled extension files (.bundle/.so)
|
|
34
|
+
# shipped with the target; empty for scripts and file lists
|
|
35
|
+
attr_reader :compiled_files
|
|
36
|
+
|
|
25
37
|
# @return [Hash, nil] dynamic probe entry (`:mode` plus
|
|
26
38
|
# mode-specific keys), nil for static-only targets
|
|
27
39
|
attr_reader :entry
|
|
@@ -43,6 +55,30 @@ module Audition
|
|
|
43
55
|
end
|
|
44
56
|
end
|
|
45
57
|
|
|
58
|
+
# Builds a static-only target from an explicit file list, the
|
|
59
|
+
# shape git hooks hand over (lefthook's {staged_files},
|
|
60
|
+
# pre-commit's filename arguments). Config, pragmas, and the
|
|
61
|
+
# baseline resolve against the working directory, which is the
|
|
62
|
+
# repository root when a hook manager runs the command.
|
|
63
|
+
#
|
|
64
|
+
# @param paths [Array<String>] `.rb`/`.ru` files
|
|
65
|
+
# @return [Target] type `:files`, no dynamic entry
|
|
66
|
+
# @raise [Audition::Error] when a path is not a Ruby file
|
|
67
|
+
def self.for_files(paths)
|
|
68
|
+
paths.each do |path|
|
|
69
|
+
unless File.file?(path) && path.end_with?(".rb", ".ru")
|
|
70
|
+
raise Error, "#{path} is not a Ruby file"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
new(
|
|
75
|
+
type: :files,
|
|
76
|
+
root: Dir.pwd,
|
|
77
|
+
ruby_files: paths,
|
|
78
|
+
entry: nil
|
|
79
|
+
)
|
|
80
|
+
end
|
|
81
|
+
|
|
46
82
|
def self.from_file(path)
|
|
47
83
|
if File.basename(path) == "Gemfile.lock"
|
|
48
84
|
return new(
|
|
@@ -80,7 +116,8 @@ module Audition
|
|
|
80
116
|
type: :directory,
|
|
81
117
|
root: dir,
|
|
82
118
|
ruby_files: glob(dir),
|
|
83
|
-
entry: nil
|
|
119
|
+
entry: nil,
|
|
120
|
+
compiled_files: compiled(dir)
|
|
84
121
|
)
|
|
85
122
|
end
|
|
86
123
|
end
|
|
@@ -94,7 +131,8 @@ module Audition
|
|
|
94
131
|
glob(File.join(spec.full_gem_path, rp))
|
|
95
132
|
end,
|
|
96
133
|
entry: {mode: :require, feature: name,
|
|
97
|
-
root: spec.full_gem_path}
|
|
134
|
+
root: spec.full_gem_path},
|
|
135
|
+
compiled_files: compiled_for(spec)
|
|
98
136
|
)
|
|
99
137
|
rescue Gem::MissingSpecError
|
|
100
138
|
raise Error,
|
|
@@ -112,7 +150,8 @@ module Audition
|
|
|
112
150
|
mode: :rails,
|
|
113
151
|
environment: File.join(dir, "config", "environment.rb"),
|
|
114
152
|
root: dir
|
|
115
|
-
}
|
|
153
|
+
},
|
|
154
|
+
compiled_files: compiled(dir)
|
|
116
155
|
)
|
|
117
156
|
end
|
|
118
157
|
|
|
@@ -121,7 +160,8 @@ module Audition
|
|
|
121
160
|
type: :rack,
|
|
122
161
|
root: dir,
|
|
123
162
|
ruby_files: [config_ru] + glob(dir),
|
|
124
|
-
entry: {mode: :rack, config_ru: config_ru}
|
|
163
|
+
entry: {mode: :rack, config_ru: config_ru},
|
|
164
|
+
compiled_files: compiled(dir)
|
|
125
165
|
)
|
|
126
166
|
end
|
|
127
167
|
|
|
@@ -136,7 +176,8 @@ module Audition
|
|
|
136
176
|
feature: File.basename(gemspec, ".gemspec"),
|
|
137
177
|
load_paths: [lib],
|
|
138
178
|
root: dir
|
|
139
|
-
}
|
|
179
|
+
},
|
|
180
|
+
compiled_files: compiled(dir)
|
|
140
181
|
)
|
|
141
182
|
end
|
|
142
183
|
|
|
@@ -149,24 +190,47 @@ module Audition
|
|
|
149
190
|
raw.sub(%r{/+\z}, "")
|
|
150
191
|
end
|
|
151
192
|
|
|
152
|
-
def self.glob(dir)
|
|
193
|
+
def self.glob(dir, pattern = "*.rb", skip: EXCLUDED_DIRS)
|
|
153
194
|
dir = normalize(dir)
|
|
154
|
-
Dir[File.join(dir, "**",
|
|
195
|
+
Dir[File.join(dir, "**", pattern)].reject do |path|
|
|
155
196
|
relative = path.delete_prefix("#{dir}/")
|
|
156
197
|
parts = relative.split("/")
|
|
157
|
-
parts.any? { |p|
|
|
198
|
+
parts.any? { |p| skip.include?(p) || p.start_with?(".") }
|
|
158
199
|
end.sort
|
|
159
200
|
end
|
|
160
201
|
|
|
202
|
+
# macOS debug-symbol bundles (x.bundle.dSYM/...) carry a file
|
|
203
|
+
# with the extension's name that nothing ever loads.
|
|
204
|
+
def self.compiled(dir)
|
|
205
|
+
glob(dir, COMPILED, skip: EXCLUDED_DIRS + BUILD_DIRS).reject do |p|
|
|
206
|
+
p.split("/").any? { |part| part.end_with?(".dSYM") }
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Compiled extension files of an installed gem. RubyGems builds
|
|
211
|
+
# a source gem into its extension dir and also copies the result
|
|
212
|
+
# under lib/, so the same file shows up on two require paths;
|
|
213
|
+
# keep one per require-relative name.
|
|
214
|
+
#
|
|
215
|
+
# @param spec [Gem::Specification]
|
|
216
|
+
# @return [Array<String>]
|
|
217
|
+
def self.compiled_for(spec)
|
|
218
|
+
spec.full_require_paths.flat_map do |rp|
|
|
219
|
+
compiled(rp).map { |path| [path.delete_prefix("#{rp}/"), path] }
|
|
220
|
+
end.uniq(&:first).map(&:last)
|
|
221
|
+
end
|
|
222
|
+
|
|
161
223
|
private_class_method :from_file, :from_directory, :from_gem_name,
|
|
162
224
|
:rails_target, :rack_target, :gem_dir_target,
|
|
163
|
-
:glob, :normalize
|
|
225
|
+
:glob, :compiled, :normalize
|
|
164
226
|
|
|
165
|
-
def initialize(type:, root:, ruby_files:, entry
|
|
227
|
+
def initialize(type:, root:, ruby_files:, entry:,
|
|
228
|
+
compiled_files: [])
|
|
166
229
|
@type = type
|
|
167
230
|
@root = root
|
|
168
231
|
@ruby_files = ruby_files
|
|
169
232
|
@entry = entry
|
|
233
|
+
@compiled_files = compiled_files
|
|
170
234
|
end
|
|
171
235
|
end
|
|
172
236
|
end
|
data/lib/audition/version.rb
CHANGED
data/lib/audition.rb
CHANGED
|
@@ -7,6 +7,7 @@ require_relative "audition/static/source_file"
|
|
|
7
7
|
require_relative "audition/static/literal_classifier"
|
|
8
8
|
require_relative "audition/static/checks"
|
|
9
9
|
require_relative "audition/static/graph_audit"
|
|
10
|
+
require_relative "audition/static/native_extensions"
|
|
10
11
|
require_relative "audition/static/analyzer"
|
|
11
12
|
require_relative "audition/dynamic/prober"
|
|
12
13
|
require_relative "audition/report"
|
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.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Markin
|
|
@@ -103,6 +103,10 @@ files:
|
|
|
103
103
|
- lib/audition/finding.rb
|
|
104
104
|
- lib/audition/fixer.rb
|
|
105
105
|
- lib/audition/report.rb
|
|
106
|
+
- lib/audition/report/github.rb
|
|
107
|
+
- lib/audition/report/json.rb
|
|
108
|
+
- lib/audition/report/style.rb
|
|
109
|
+
- lib/audition/report/text.rb
|
|
106
110
|
- lib/audition/rewriters.rb
|
|
107
111
|
- lib/audition/static/analyzer.rb
|
|
108
112
|
- lib/audition/static/checks.rb
|
|
@@ -114,6 +118,7 @@ files:
|
|
|
114
118
|
- lib/audition/static/checks/unsafe_calls.rb
|
|
115
119
|
- lib/audition/static/graph_audit.rb
|
|
116
120
|
- lib/audition/static/literal_classifier.rb
|
|
121
|
+
- lib/audition/static/native_extensions.rb
|
|
117
122
|
- lib/audition/static/source_file.rb
|
|
118
123
|
- lib/audition/target.rb
|
|
119
124
|
- lib/audition/version.rb
|