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.
@@ -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 or Regexp returned by
12
- # a call (`.tr`, `format`, `Regexp.new`)
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
- # Rails hit both shapes (`.tr` and `Regexp.new`) in
30
- # constants during its ractorization. These names belong
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
- sync = node.elements.any? do |element|
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
@@ -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, "**", "*.rb")].reject do |path|
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| EXCLUDED_DIRS.include?(p) || p.start_with?(".") }
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Audition
4
- VERSION = "0.2.4"
4
+ VERSION = "0.3.0"
5
5
  end
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.2.4
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