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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 76a1b0c57c7a373182f7b9f88f765fa8e0167a492e28d028998335bc246c10d9
4
- data.tar.gz: 2c5ecb49ed0a63ebd7a58cb3db9e8b5e8f9893953198d1c3e9f0cc5ca74cfb3a
3
+ metadata.gz: 28a9914f63873fd6bc4ec2e86154aa69e4e3dbfb08c3d9ee6218fc521e31836b
4
+ data.tar.gz: e1033a810ea825b21980fb3cb406bfd7bda1bf69e970e82c988fb0d149c0d172
5
5
  SHA512:
6
- metadata.gz: 633d141cdc23a3e2e24df8b983b9112ffa9a759194669c4f9a6f62f0bf07219b507bd72cea8235de3c7193ab934c60ca8c98ee2095eabe21fad2e80f682ea8e2
7
- data.tar.gz: bfd863839b4d5fbea291d22282487de79d261fd55bba5662d82d285e013827290257ca61d5b637000581453ec8f3a0510b1571bf6492d1b9f6f9e7de9f3af225
6
+ metadata.gz: 07b6250748bf1d3e854157b6ac81f8a2574259f77d2e28746d241e880763cb446410b27736740c29f803e4936fed3d5ff0f6ca1b95d73cc6cb664896630c17fe
7
+ data.tar.gz: ab564046d55d28d6e94af9e045991574322e14074e4121e788c6b59f8ce3628f4499351d5f9cac7e670564bc9ea7472b3615056d9b91beaeb64dc3e131babc04
data/README.md CHANGED
@@ -11,6 +11,10 @@ graph. Some of the checks and fixes were trained on how Rails
11
11
  core itself is being ractorized; see the
12
12
  [pattern study](docs/rails_core_best_practices.md).
13
13
 
14
+ <div align="center">
15
+ <img src="https://raw.githubusercontent.com/yaroslav/audition/refs/heads/main/assets/images/audition.png" width="512" height="512" alt="gem audition" title="gem audition">
16
+ </div>
17
+
14
18
  [![GitHub Release](https://img.shields.io/github/v/release/yaroslav/audition)](https://github.com/yaroslav/audition/releases)
15
19
  [![Docs](https://img.shields.io/badge/yard-docs-blue.svg)](https://rubydoc.info/gems/audition)
16
20
 
@@ -24,8 +28,10 @@ core itself is being ractorized; see the
24
28
  (which rule of the Ractor model it violates) and a `fix`
25
29
  (what to write instead).
26
30
  - **`--fix` like RuboCop, in two tiers.** Safe corrections:
27
- `.freeze` on string constants, `Ractor.make_shareable(...)` for
28
- mutable and shallow-frozen containers and Proc constants, and
31
+ `.freeze` on string constants, sentinels, and containers whose
32
+ elements are all shareable, `Ractor.make_shareable(...)` for
33
+ the remaining mutable and shallow-frozen containers and for
34
+ Proc constants, and
29
35
  boot-time hoisting of method-body requires. `--fix-unsafe` adds
30
36
  semantics-affecting rewrites: magic-comment insertion,
31
37
  freeze-on-memoize for class-level memoization (both `@x ||=`
@@ -41,13 +47,15 @@ core itself is being ractorized; see the
41
47
  state, so `globalid` is not blamed for ActiveSupport's state.
42
48
  - **Dogfooding.** The scanner for Ractor compatibility is built
43
49
  using Ractors: static analysis fans out across CPU cores on
44
- Ractor workers, and audition passes its own audit.
50
+ Ractor workers, and audition passes its own audit. It runs on
51
+ itself on every commit (a lefthook pre-commit over the staged
52
+ files) and on every push (a full self-audit in CI).
45
53
  - **Trained on Rails core.** Several checks and fix suggestions
46
54
  come straight from studying the Rails ractorization effort
47
- (about 75 substantive commits): `Hash.new` default procs,
48
- in-place mutation of registry constants, closure-carrying
49
- `define_method`, copy-on-write rewrites. The findings are
50
- documented in
55
+ (some two hundred commits and pull requests): `Hash.new`
56
+ default procs, in-place mutation of registry constants,
57
+ closure-carrying `define_method`, copy-on-write rewrites. The
58
+ findings are documented in
51
59
  [docs/rails_core_best_practices.md](docs/rails_core_best_practices.md).
52
60
  - **Terminal-native output.** Colors, glyphs, and OSC 8 hyperlinks;
53
61
  `path:line` is clickable in supporting terminals. JSON output for
@@ -112,6 +120,7 @@ proxying) and its verified semantics.
112
120
  - [Installation](#installation)
113
121
  - [Usage](#usage)
114
122
  - [Adopting incrementally](#adopting-incrementally)
123
+ - [CI and git hooks](#ci-and-git-hooks)
115
124
  - [What it catches](#what-it-catches)
116
125
  - [Agent skill](#agent-skill)
117
126
  - [Extending](#extending)
@@ -140,6 +149,7 @@ audition path/to/gem-checkout # a gem working copy (*.gemspec)
140
149
  audition path/to/rack-app # a config.ru directory
141
150
  audition path/to/rails-root # a Rails application
142
151
  audition lib # any directory, static-only
152
+ audition a.rb b.rb c.rb # several files at once, statically
143
153
  audition Gemfile.lock # sweep every gem in the bundle
144
154
  audition path/to/app --deps # same, from the app root
145
155
  ```
@@ -154,16 +164,19 @@ Useful flags:
154
164
  | `--fix-unsafe` | also apply semantics-affecting corrections |
155
165
  | `--dry-run` | with a fix flag: preview edits, change nothing |
156
166
  | `--format json` | machine-readable report for CI |
157
- | `--format github` | GitHub Actions annotations on PR diffs |
167
+ | `--format github` | GitHub Actions annotations + job summary |
158
168
  | `--compare old.json` | delta vs a previous report: fixed/introduced |
159
169
  | `--static-only` / `--dynamic-only` | pick one probe layer |
160
170
  | `--fail-on warning` | stricter CI gate (default: error) |
171
+ | `--exit-zero` | report findings but never fail the build |
161
172
  | `--capabilities` | table of what this Ruby allows in Ractors |
162
173
  | `--timeout 60` | dynamic probe budget in seconds |
163
174
  | `--plain` | no colors or hyperlinks (also via NO_COLOR, pipes) |
164
175
 
165
176
  Exit codes: `0` clean, `1` findings at or above the `--fail-on`
166
177
  threshold (or a failed dynamic probe), `2` usage error.
178
+ `--exit-zero` (alias for `--fail-on never`) always exits `0`
179
+ unless the invocation itself is broken.
167
180
 
168
181
  ## Adopting incrementally
169
182
 
@@ -202,6 +215,71 @@ checks:
202
215
  - at-exit
203
216
  ```
204
217
 
218
+ ## CI and git hooks
219
+
220
+ **GitHub Actions.** `--format github` turns findings into
221
+ workflow-command annotations that land right on the PR diff, and
222
+ appends a verdict-plus-counts markdown table to the job summary
223
+ page. A blocking gate:
224
+
225
+ ```yaml
226
+ - uses: ruby/setup-ruby@v1
227
+ with:
228
+ ruby-version: "4.0"
229
+ - run: gem install audition
230
+ - run: audition --format github .
231
+ ```
232
+
233
+ To surface findings without failing the build while you adopt
234
+ (the flag other linters call `--exit-zero` too, so it keeps its
235
+ name here):
236
+
237
+ ```yaml
238
+ - run: audition --format github --exit-zero .
239
+ ```
240
+
241
+ `--fail-on never` is the long form, and works from
242
+ `.audition.yml` as well; GitHub's own `continue-on-error: true`
243
+ on the step is the workflow-level equivalent.
244
+
245
+ **Git hooks.** Passing several `.rb` files audits exactly those
246
+ files statically, which is the shape hook managers hand over.
247
+ With [lefthook](https://github.com/evilmartians/lefthook):
248
+
249
+ ```yaml
250
+ pre-commit:
251
+ commands:
252
+ audition:
253
+ glob: "*.rb"
254
+ run: audition --static-only --plain {staged_files}
255
+ ```
256
+
257
+ With [pre-commit](https://pre-commit.com):
258
+
259
+ ```yaml
260
+ - repo: local
261
+ hooks:
262
+ - id: audition
263
+ name: audition
264
+ language: system
265
+ entry: audition --static-only --plain
266
+ types: [ruby]
267
+ ```
268
+
269
+ Config, pragmas, and the baseline resolve against the working
270
+ directory, so a hook run from the repository root honors the
271
+ same `.audition.yml` as a full audit.
272
+
273
+ **This repository eats its own dog food.** Every commit runs
274
+ `audition --static-only` over the staged files through lefthook
275
+ (next to standardrb), and every push and pull request gets a
276
+ full self-audit in CI with annotations and a job summary,
277
+ non-blocking via `--exit-zero`
278
+ ([audit.yml](.github/workflows/audit.yml)). Current state: own
279
+ code audits `ready`; the full dynamic probe reports the terminal
280
+ dev-dependencies as `blocked`, which is exactly the distinction
281
+ the verdict system exists to make.
282
+
205
283
  ## What it catches
206
284
 
207
285
  Static, with file:line precision:
@@ -235,6 +313,15 @@ Static, with file:line precision:
235
313
  `class_attribute` migration Rails itself made) and
236
314
  `class_attribute` without copy-on-write writes,
237
315
  `include Singleton`, `fork`, `ObjectSpace._id2ref`, ENV mutation.
316
+ - **Native extensions that never declare Ractor safety**: a byte
317
+ scan of every compiled `.bundle`/`.so` for the
318
+ `rb_ext_ractor_safe` import, which also covers precompiled
319
+ platform gems that ship no sources; an unbuilt checkout is
320
+ scanned at the source level (`ext/**`, C, Rust, or Zig) instead.
321
+ A silent extension raises `Ractor::UnsafeError` on every call
322
+ from a non-main Ractor, so it rates a warning; a declared one
323
+ gets an info note, because the declaration is the maintainer's
324
+ assertion, not a proof.
238
325
 
239
326
  Dynamic, on the live object graph:
240
327
 
@@ -244,6 +331,11 @@ Dynamic, on the live object graph:
244
331
  `Ractor.shareable?`, and inspects every class and module for
245
332
  class-level ivars and class variables, with
246
333
  `const_source_location` attribution.
334
+ - Records every compiled extension the load pulled in,
335
+ dependencies included, and byte-scans each for the
336
+ `rb_ext_ractor_safe` import; silent ones are reported against
337
+ the dependency that ships them. Ruby's own extensions are left
338
+ to Ruby.
247
339
  - Boots `config.ru` and serves one GET / entirely inside a Ractor,
248
340
  the per-worker model of Ractor web servers; then hammers it from
249
341
  4 Ractors x 25 requests to surface failures that only appear
@@ -305,7 +397,7 @@ Audition::Static::Checks.register(NoSleep)
305
397
  bundle install
306
398
  bundle exec rake spec # RSpec suite
307
399
  bundle exec rake standard # standardrb lint
308
- lefthook install # pre-commit lint hook
400
+ lefthook install # pre-commit standardrb + audition
309
401
  bundle exec exe/audition --capabilities
310
402
  ```
311
403
 
@@ -115,7 +115,8 @@ module Audition
115
115
  ruby_files: spec.require_paths.flat_map do |rp|
116
116
  ruby_files_under(File.join(root, rp))
117
117
  end,
118
- entry: {mode: :require, feature: spec.name, root: root}
118
+ entry: {mode: :require, feature: spec.name, root: root},
119
+ compiled_files: Target.compiled_for(spec)
119
120
  )
120
121
  end
121
122
 
@@ -142,16 +143,23 @@ module Audition
142
143
  files = target.ruby_files.reject do |file|
143
144
  config.excluded?(file.delete_prefix("#{target.root}/"))
144
145
  end
146
+ compiled = target.compiled_files.reject do |file|
147
+ config.excluded?(file.delete_prefix("#{target.root}/"))
148
+ end
145
149
  per_file = Static::Analyzer.new
146
150
  .analyze_paths(files, workers: 1)
147
- per_file + Static::GraphAudit.new.analyze_paths(files)
151
+ per_file + Static::GraphAudit.new.analyze_paths(files) +
152
+ Static::NativeExtensions.new.analyze(target,
153
+ compiled_files: compiled)
148
154
  end
149
155
 
150
156
  def dynamic_results(target)
151
157
  return [] if @static_only || target.entry.nil?
152
158
 
153
159
  prober = Dynamic::Prober.new(timeout: @timeout)
154
- [prober.probe(target.entry)]
160
+ [prober.probe(
161
+ target.entry.merge(compiled_files: target.compiled_files)
162
+ )]
155
163
  end
156
164
  end
157
165
  end
data/lib/audition/cli.rb CHANGED
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "optparse"
4
5
  require "table_tennis"
5
6
 
6
7
  module Audition
7
8
  class CLI
8
- USAGE = "usage: audition [options] TARGET " \
9
+ USAGE = "usage: audition [options] TARGET [FILE...] " \
9
10
  "(a .rb script, directory, config.ru dir, Rails root, " \
10
- "gem dir, or installed gem name)"
11
+ "gem dir, installed gem name, or several .rb files)"
11
12
 
12
13
  # Entry point used by `exe/audition`.
13
14
  #
@@ -31,13 +32,20 @@ module Audition
31
32
 
32
33
  return print_capabilities(options) if options[:capabilities]
33
34
 
34
- target_arg = options[:args].first
35
- unless target_arg
35
+ args = options[:args]
36
+ if args.empty?
36
37
  @stderr.puts(USAGE)
37
38
  return 2
38
39
  end
39
40
 
40
- target = Target.detect(target_arg)
41
+ # A single argument keeps full detection (including the
42
+ # dynamic probe); several arguments are the git-hook shape,
43
+ # a plain list of staged .rb files audited statically.
44
+ target = if args.size == 1
45
+ Target.detect(args.first)
46
+ else
47
+ Target.for_files(args)
48
+ end
41
49
  target = deps_target(target) if options[:deps]
42
50
  if target.type == :bundle
43
51
  sweep(target, options)
@@ -89,11 +97,18 @@ module Audition
89
97
  "with --fix: show planned edits, change nothing") do
90
98
  options[:dry_run] = true
91
99
  end
92
- o.on("--fail-on LEVEL", %w[error warning info],
93
- "exit 1 threshold (default: error)") do |v|
100
+ o.on("--fail-on LEVEL", %w[error warning info never],
101
+ "exit 1 threshold (default: error); never always " \
102
+ "exits 0") do |v|
94
103
  options[:fail_on] = v.to_sym
95
104
  options[:explicit] << :fail_on
96
105
  end
106
+ o.on("--exit-zero",
107
+ "report findings but exit 0 (same as " \
108
+ "--fail-on never)") do
109
+ options[:fail_on] = :never
110
+ options[:explicit] << :fail_on
111
+ end
97
112
  o.on("--write-baseline",
98
113
  "record current findings as the baseline") do
99
114
  options[:write_baseline] = true
@@ -150,7 +165,9 @@ module Audition
150
165
 
151
166
  results = []
152
167
  if target.entry && !options[:static_only]
153
- results << prober(options).probe(target.entry)
168
+ results << prober(options).probe(
169
+ target.entry.merge(compiled_files: target.compiled_files)
170
+ )
154
171
  end
155
172
 
156
173
  all = findings +
@@ -262,8 +279,13 @@ module Audition
262
279
  files = target.ruby_files.reject do |file|
263
280
  config.excluded?(file.delete_prefix("#{target.root}/"))
264
281
  end
282
+ compiled = target.compiled_files.reject do |file|
283
+ config.excluded?(file.delete_prefix("#{target.root}/"))
284
+ end
265
285
  per_file = Static::Analyzer.new.analyze_paths(files)
266
- per_file + Static::GraphAudit.new.analyze_paths(files)
286
+ per_file + Static::GraphAudit.new.analyze_paths(files) +
287
+ Static::NativeExtensions.new.analyze(target,
288
+ compiled_files: compiled)
267
289
  end
268
290
 
269
291
  # Fix chatter goes to stderr: stdout carries the report, which
@@ -312,12 +334,33 @@ module Audition
312
334
 
313
335
  def emit(report, options)
314
336
  case options[:format]
315
- when :json then @stdout.puts(report.to_json)
316
- when :github then @stdout.puts(report.to_github)
317
- else @stdout.puts(report.to_text(style: style(options)))
337
+ when :json
338
+ @stdout.puts(Report::Json.new(report).render)
339
+ when :github
340
+ github = Report::Github.new(report)
341
+ @stdout.puts(github.render)
342
+ append_step_summary(github.summary)
343
+ else
344
+ @stdout.puts(
345
+ Report::Text.new(report, style(options)).render
346
+ )
318
347
  end
319
348
  end
320
349
 
350
+ # GitHub Actions exposes the job summary as an appendable
351
+ # file; outside Actions the variable is absent and this is a
352
+ # no-op. A broken path must not fail the audit itself.
353
+ def append_step_summary(markdown)
354
+ path = ENV["GITHUB_STEP_SUMMARY"]
355
+ return if path.nil? || path.empty?
356
+
357
+ File.open(path, "a") { |f| f.puts(markdown) }
358
+ rescue SystemCallError => e
359
+ @stderr.puts(
360
+ "audition: cannot write step summary: #{e.message}"
361
+ )
362
+ end
363
+
321
364
  def style(options, io: @stdout)
322
365
  if options[:plain]
323
366
  Report::Style.new(color: false, hyperlinks: false)
@@ -327,6 +370,8 @@ module Audition
327
370
  end
328
371
 
329
372
  def exit_code(report, options)
373
+ return 0 if options[:fail_on] == :never
374
+
330
375
  threshold = SEVERITIES.fetch(options[:fail_on])
331
376
  failed =
332
377
  report.findings.any? do |f|
@@ -359,11 +404,13 @@ module Audition
359
404
  )
360
405
  rows = sweeper.rows(progress: sweep_progress)
361
406
 
362
- if options[:format] == :json
363
- emit_sweep_json(rows)
364
- else
365
- emit_sweep_table(rows, options)
407
+ case options[:format]
408
+ when :json then emit_sweep_json(rows)
409
+ when :github then emit_sweep_github(rows)
410
+ else emit_sweep_table(rows, options)
366
411
  end
412
+ return 0 if options[:fail_on] == :never
413
+
367
414
  threshold = SEVERITIES.fetch(options[:fail_on])
368
415
  (rows.any? { |r| row_failed?(r, threshold) }) ? 1 : 0
369
416
  end
@@ -421,6 +468,53 @@ module Audition
421
468
  )
422
469
  end
423
470
 
471
+ # Sweep rows carry no file or line, so the annotations land on
472
+ # the run summary rather than a diff; the markdown table goes
473
+ # to the job summary page when Actions provides one.
474
+ def emit_sweep_github(rows)
475
+ ready = rows.count { |r| r.verdict == :ready }
476
+ rows.each do |row|
477
+ level = sweep_annotation_level(row)
478
+ next unless level
479
+
480
+ @stdout.puts(
481
+ "::#{level} title=audition::gem #{row.name} " \
482
+ "#{row.version}: #{row.errors} errors, " \
483
+ "#{row.dep_errors} dependency errors, " \
484
+ "#{row.warnings} warnings " \
485
+ "(#{VERDICT_CELLS.fetch(row.verdict)})"
486
+ )
487
+ end
488
+ @stdout.puts("#{ready} of #{rows.size} gems ractor-ready")
489
+ append_step_summary(sweep_summary_markdown(rows, ready))
490
+ end
491
+
492
+ def sweep_annotation_level(row)
493
+ if row.verdict == :not_ready ||
494
+ (row.errors + row.dep_errors).positive?
495
+ "error"
496
+ elsif row.warnings.positive?
497
+ "warning"
498
+ end
499
+ end
500
+
501
+ def sweep_summary_markdown(rows, ready)
502
+ lines = [
503
+ "## audition bundle sweep", "",
504
+ "| gem | version | verdict | errors | dep errors " \
505
+ "| warnings | fixable |",
506
+ "| --- | --- | --- | --- | --- | --- | --- |"
507
+ ]
508
+ rows.each do |r|
509
+ lines << "| #{r.name} | #{r.version} | " \
510
+ "#{VERDICT_CELLS.fetch(r.verdict)} | #{r.errors} | " \
511
+ "#{r.dep_errors} | #{r.warnings} | #{r.fixable} |"
512
+ end
513
+ lines << ""
514
+ lines << "#{ready} of #{rows.size} gems ractor-ready"
515
+ lines.join("\n")
516
+ end
517
+
424
518
  def emit_sweep_json(rows)
425
519
  @stdout.puts(JSON.pretty_generate(
426
520
  "audition" => VERSION,
@@ -23,7 +23,7 @@ module Audition
23
23
  disabled_checks: []}
24
24
  )
25
25
 
26
- FAIL_ON_LEVELS = %w[error warning info].freeze
26
+ FAIL_ON_LEVELS = %w[error warning info never].freeze
27
27
 
28
28
  attr_reader :fail_on, :timeout, :exclude, :disabled_checks
29
29
 
@@ -59,8 +59,8 @@ module Audition
59
59
  FAIL_ON_LEVELS.include?(fail_on.to_s)
60
60
 
61
61
  raise Error,
62
- "#{path}: fail_on must be one of error, warning, or " \
63
- "info (got #{fail_on.inspect})"
62
+ "#{path}: fail_on must be one of error, warning, " \
63
+ "info, or never (got #{fail_on.inspect})"
64
64
  end
65
65
  private_class_method :validate!
66
66
 
@@ -12,10 +12,23 @@ Warning[:experimental] = false
12
12
  Thread.report_on_exception = false
13
13
 
14
14
  require "json"
15
+ require "rbconfig"
15
16
 
16
17
  module AuditionHarness
17
18
  MAX_CONSTS = 5000
18
19
 
20
+ # Directories under the target root that are not the target's
21
+ # own surface. Bundler's deployment mode (and bundler-cache in
22
+ # GitHub Actions) vendors every gem into <root>/vendor/bundle,
23
+ # and attributing those constants to the target would flip its
24
+ # verdict from blocked to not_ready. Must mirror
25
+ # Audition::Target::EXCLUDED_DIRS (this file is a standalone
26
+ # subprocess script and cannot require the gem); a spec keeps
27
+ # the two lists in sync.
28
+ EXCLUDED_DIRS = %w[
29
+ vendor node_modules tmp log coverage pkg .git .bundle
30
+ ].freeze
31
+
19
32
  # Fixtures for capability probes.
20
33
  CAP_CONST = [1, 2] # audition:disable mutable-constants
21
34
 
@@ -125,18 +138,45 @@ module AuditionHarness
125
138
  $LOAD_PATH.unshift(lp) # audition:disable global-variables
126
139
  end
127
140
  before = Object.constants
128
- feature = payload.fetch("feature")
129
- begin
130
- require feature # audition:disable runtime-require
131
- rescue LoadError
132
- # Dashed gem names conventionally ship slashed entry files
133
- # (rspec-mocks provides rspec/mocks).
134
- slashed = feature.tr("-", "/")
135
- raise if slashed == feature
141
+ features = $LOADED_FEATURES.dup # audition:disable global-variables
142
+ require_target(payload.fetch("feature"), payload["root"])
143
+ scan(Object.constants - before, root: payload["root"]).merge(
144
+ "native_extensions" => native_extensions(
145
+ features, payload["root"], payload["known_compiled"]
146
+ )
147
+ )
148
+ end
136
149
 
137
- require slashed # audition:disable runtime-require
150
+ # Gem names and entry files diverge in two conventional ways:
151
+ # dashed names ship slashed files (rspec-mocks provides
152
+ # rspec/mocks), and squashed names ship snake_case files
153
+ # (activesupport provides active_support). The second has no
154
+ # rule to invert, so when the target ships exactly one top-level
155
+ # file under lib/, that file is the entry, required by absolute
156
+ # path. An absolute require keeps the path as given, and scan
157
+ # compares constant origins against the realpathed root, so the
158
+ # candidate is built from the realpath too (a symlinked tmpdir,
159
+ # macOS /var, otherwise turns own findings into dependency ones).
160
+ # The error reported is the last one seen: a candidate that loads
161
+ # but fails inside says more than "cannot load such file".
162
+ def require_target(feature, root)
163
+ require feature # audition:disable runtime-require
164
+ rescue LoadError => error
165
+ entry_candidates(feature, root).each do |candidate|
166
+ return require candidate # audition:disable runtime-require
167
+ rescue LoadError => e
168
+ error = e
138
169
  end
139
- scan(Object.constants - before, root: payload["root"])
170
+ raise error
171
+ end
172
+
173
+ def entry_candidates(feature, root)
174
+ candidates = []
175
+ slashed = feature.tr("-", "/")
176
+ candidates << slashed if slashed != feature
177
+ files = root ? Dir[File.join(realpath(root), "lib", "*.rb")] : []
178
+ candidates << files.first.delete_suffix(".rb") if files.size == 1
179
+ candidates
140
180
  end
141
181
 
142
182
  # Breadth-first walk of every constant the require introduced:
@@ -148,13 +188,7 @@ module AuditionHarness
148
188
  # Loaded features are realpathed by require; the target root
149
189
  # must be too, or symlinked paths (macOS /var vs /private/var)
150
190
  # break the own-vs-dependency comparison.
151
- if root
152
- root = begin
153
- File.realpath(root)
154
- rescue
155
- root
156
- end
157
- end
191
+ root = realpath(root)
158
192
  unshareable = []
159
193
  class_state = []
160
194
  class_vars = []
@@ -218,10 +252,64 @@ module AuditionHarness
218
252
  end
219
253
  # The separator matters: /x/app must not claim /x/app-helpers.
220
254
  own = root.nil? || path.nil? || path == root ||
221
- path.start_with?(root + File::SEPARATOR)
255
+ (path.start_with?(root + File::SEPARATOR) &&
256
+ !excluded?(path, root))
222
257
  {"path" => path, "line" => line, "own" => own}
223
258
  end
224
259
 
260
+ # Matches the static scanner's exclusion rule: any excluded or
261
+ # dot-prefixed component in the root-relative path means the
262
+ # file is not the target's own code.
263
+ def excluded?(path, root)
264
+ relative = path.delete_prefix(root + File::SEPARATOR)
265
+ relative.split(File::SEPARATOR).any? do |part|
266
+ EXCLUDED_DIRS.include?(part) || part.start_with?(".")
267
+ end
268
+ end
269
+
270
+ NATIVE = /\.(bundle|so)\z/
271
+ DECLARATION = "rb_ext_ractor_safe"
272
+
273
+ # Compiled extensions the require pulled in, with the one fact
274
+ # that decides their Ractor behavior: whether the file imports
275
+ # rb_ext_ractor_safe. Ruby's own extensions (archdir) are flagged
276
+ # so the prober can leave them to Ruby, and files the static check
277
+ # already covers are flagged as known.
278
+ def native_extensions(before, root, known)
279
+ root = realpath(root)
280
+ known = Array(known).map { |path| realpath(path) }
281
+ archdir = RbConfig::CONFIG["archdir"] + File::SEPARATOR
282
+ loaded = $LOADED_FEATURES - before # audition:disable global-variables
283
+ loaded.grep(NATIVE).map do |path|
284
+ {"path" => path,
285
+ "declares" => declares?(path),
286
+ "ruby" => path.start_with?(archdir),
287
+ "known" => known.include?(path),
288
+ "own" => known.include?(path) || own_path?(path, root)}
289
+ end
290
+ end
291
+
292
+ def declares?(path)
293
+ File.binread(path).include?(DECLARATION)
294
+ rescue SystemCallError
295
+ false
296
+ end
297
+
298
+ def own_path?(path, root)
299
+ return false unless root
300
+
301
+ path == root ||
302
+ (path.start_with?(root + File::SEPARATOR) && !excluded?(path, root))
303
+ end
304
+
305
+ def realpath(path)
306
+ return path if path.nil?
307
+
308
+ File.realpath(path)
309
+ rescue SystemCallError
310
+ path
311
+ end
312
+
225
313
  def inspect_module(full, mod, origin, class_state, class_vars)
226
314
  ivars = mod.instance_variables
227
315
  if ivars.any?
@@ -360,6 +448,7 @@ module AuditionHarness
360
448
  def rails(payload)
361
449
  environment = payload.fetch("environment")
362
450
  before = Object.constants
451
+ features = $LOADED_FEATURES.dup # audition:disable global-variables
363
452
  started = Time.now
364
453
  require environment # audition:disable runtime-require
365
454
  begin
@@ -369,8 +458,12 @@ module AuditionHarness
369
458
  end
370
459
  boot = {"ok" => true,
371
460
  "seconds" => (Time.now - started).round(1)}
372
- scan(Object.constants - before, root: payload["root"])
373
- .merge("boot" => boot)
461
+ scan(Object.constants - before, root: payload["root"]).merge(
462
+ "boot" => boot,
463
+ "native_extensions" => native_extensions(
464
+ features, payload["root"], payload["known_compiled"]
465
+ )
466
+ )
374
467
  rescue Exception => e
375
468
  {"boot" => {"ok" => false, "error" => describe_error(e)}}
376
469
  end