conformance-kit 0.4.2 → 0.5.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: 7194b9197bb0f74d0984120097dcb41a80e518b9de1ba8818f0b0007b4614c09
4
- data.tar.gz: d830a25c1493c79747f91312287f2b856f4450bd2f4636f6fd6bff9435c0ffcb
3
+ metadata.gz: 5a02143b58331881bf17f8f3fa4af58ed4a2e31293ecf91ec36c75dc6b88cf76
4
+ data.tar.gz: ccf2d81afca57e37ac74347deb18baba5a08a1ee592d2ec10580cdee0aecba6b
5
5
  SHA512:
6
- metadata.gz: 387a7061b29d5f30d6bbc0316ca9d68601b981bce2b37a0afc5d03fa462e1bb3803a5ad3362bf822c106f154ab5b6df614a69e26eff5df0791294da79dc01353
7
- data.tar.gz: 2b47a5f0f484b3b71382ae21630255b821d4bb420512b5cc6e2e5be69a3d44f2a9b79072c937d0644c8209e16320b5099bf4ceee450560f70ae7d94a06acb4cd
6
+ metadata.gz: 379a96c84f23be361de24dc24e55f66c313e00290b3fbe47b9b54fd4848f0ea02051aa3dc4c9186cf1f363d3584711e12c10a90f43e9ac0a4d37a9646d55bc6c
7
+ data.tar.gz: 8d4e996635a3e7b4b1d9b8d086d058c57fa6c16207b8f0d2a989aed19c9ed6ea9e22cdb0af000ebd178b99e3537704f5c30eb9aa0f929f9b24c1fa42ceb585d4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0 - 2026-10-18
4
+
5
+ - `conformance-audit --require` and `audit.require` take a gem or feature name as well as a path: an entry that is not written as a path (`./`, `../`, `~`, absolute) and names no existing file is required from the load path. The kit now audits any installed gem with no Gemfile change and no `.conformance.yml`: `gem install conformance-kit` then `conformance-audit --require loofah --namespace Loofah`. Existing paths resolve as before.
6
+ - README: how to try the audit on any gem, and a Minitest and an RSpec conformance case (the entry point in a non-main Ractor; every constant shareable) for gems that won't take the dependency.
7
+ - The ractorize job (report-only, #22): `conformance-kit check --ractorize` boots `ractorize.app` in production with `ActiveSupport::Ractors.unshareable_proc_action = :raise`, calls `Rails.application.ractorize!`, serves each `ractorize.requests` path inside a non-main Ractor (a failure names the root cause and its first frame, also when an error page swallowed it) and audits `ractorize.audit_namespaces` (default `audit.namespace`) in the same process. It skips with the reason below Ruby 4.0 or on a Rails without `ractorize!`. New `.conformance.yml` keys: `ractorize.app`, `ractorize.requests`, `ractorize.audit_namespaces`, `ractorize.setup`.
8
+ - `conformance.yml` gains a Ractorize job that runs when `ractorize.app` is set. It is `continue-on-error` until Rails 8.2 ships `ractorize!`, and bundles the repo with `config/ractorize.gemfile`: Rails main, Rack main and Shopify's i18n with Shopify/i18n#5, replacing the repo's own lines for them.
9
+
3
10
  ## 0.4.2 - 2026-10-17
4
11
 
5
12
  - A Proc or lock found inside another value now reads as one sentence: `[:save] is a Proc that is never shareable: ...` and `.@lock is a Thread::Mutex that is main-only: ...` instead of `is a Proc is never shareable` (in `conformance-audit` findings and `"a boot-finalized configuration"` failures; a top-level one reads `a Proc that is never shareable`).
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # conformance-kit
2
2
 
3
- Concurrency conformance for the zoolutions gems. A gem that adopts it is held to explicit tiers, each with a mechanical gate:
3
+ Concurrency conformance for Ruby gems. Built for the zoolutions gems and usable by any gem: [audit a gem without adding anything to it](#try-it-on-any-gem), [copy the checks into your own tests](#a-conformance-case-without-the-kit), or adopt the kit. A gem that adopts it is held to explicit tiers, each with a mechanical gate:
4
4
 
5
5
  | Tier | Meaning | Gate |
6
6
  |---|---|---|
@@ -11,6 +11,102 @@ Concurrency conformance for the zoolutions gems. A gem that adopts it is held to
11
11
 
12
12
  Background, the finalize-at-boot pattern and the settled decisions: [zoolutions/phlex-reactive#363](https://github.com/zoolutions/phlex-reactive/issues/363).
13
13
 
14
+ ## Try it on any gem
15
+
16
+ No Gemfile change and no `.conformance.yml`: install the kit, then name the gem to load and the namespace to audit.
17
+
18
+ ```bash
19
+ gem install conformance-kit
20
+ conformance-audit --require loofah --namespace Loofah
21
+ ```
22
+
23
+ ```
24
+ conformance-audit: 42 findings in Loofah (0 allowlisted main-only; 38 modules audited, 0 unloaded autoloads skipped)
25
+ Loofah::Elements::BLOCK_LEVEL (constant) Set: a Set that is not frozen: freeze it at boot (Ractor.make_shareable)
26
+ ...
27
+ Loofah::Scrubbers::MAP (constant) Hash: a Hash that is not frozen: freeze it at boot (Ractor.make_shareable)
28
+ report only: --strict fails on these
29
+ ```
30
+
31
+ That is loofah 2.25.2 on Ruby 4.0.7: every finding is state a non-main Ractor cannot read. `--require` takes a gem or feature name (required from the load path, so any installed gem) or a file path, and repeats (`--require rack --require rack/mock_request`). Without `--strict` it only reports. What it can and can't see, and its exit codes: [`conformance-audit`](#conformance-audit).
32
+
33
+ ## A conformance case without the kit
34
+
35
+ When a new development dependency is not an option (a framework, a gem that keeps its bundle small), the two checks that matter most fit in the gem's own tests. This is the shape of the case to attach to a compatibility pull request: it fails before the fix, passes after it, and fails again on the next constant that stops being shareable.
36
+
37
+ ```ruby
38
+ # Minitest
39
+ require "minitest/autorun" # or your test_helper
40
+
41
+ class RactorConformanceTest < Minitest::Test
42
+ # State that is main-only on purpose, each with why.
43
+ MAIN_ONLY = { "MyGem::REGISTRY" => "apps add to it at boot" }.freeze
44
+
45
+ def setup
46
+ skip "needs Ruby 4.0 (Ractor#value)" unless defined?(Ractor) && Ractor.method_defined?(:value)
47
+ end
48
+
49
+ # The entry point runs inside a non-main Ractor and gives the main Ractor's answer.
50
+ def test_runs_inside_a_non_main_ractor
51
+ assert_equal MyGem.render("<p>hi</p>"), Ractor.new { MyGem.render("<p>hi</p>") }.value
52
+ end
53
+
54
+ # Every constant under MyGem is shareable, so the next mutable one fails here.
55
+ def test_constants_are_shareable
56
+ unshareable = constants_under(MyGem).reject { |name, value| MAIN_ONLY.key?(name) || Ractor.shareable?(value) }
57
+ assert_empty unshareable.map { |name, value| "#{name} (#{value.class})" }
58
+ end
59
+
60
+ private
61
+
62
+ def constants_under(mod)
63
+ mod.constants(false).reject { mod.autoload?(_1) }.flat_map do |name|
64
+ value = mod.const_get(name, false)
65
+ own = value.is_a?(Module) && value.name == "#{mod.name}::#{name}"
66
+ [["#{mod.name}::#{name}", value], *(own ? constants_under(value) : [])]
67
+ end
68
+ end
69
+ end
70
+ ```
71
+
72
+ ```ruby
73
+ # RSpec
74
+ RSpec.describe "MyGem in a Ractor" do
75
+ main_only = { "MyGem::REGISTRY" => "apps add to it at boot" }.freeze
76
+
77
+ before { skip "needs Ruby 4.0 (Ractor#value)" unless defined?(Ractor) && Ractor.method_defined?(:value) }
78
+
79
+ it "runs the entry point inside a non-main Ractor" do
80
+ expect(Ractor.new { MyGem.render("<p>hi</p>") }.value).to eq(MyGem.render("<p>hi</p>"))
81
+ end
82
+
83
+ it "keeps every constant under MyGem shareable" do
84
+ walk = lambda do |mod|
85
+ mod.constants(false).reject { mod.autoload?(_1) }.flat_map do |name|
86
+ value = mod.const_get(name, false)
87
+ own = value.is_a?(Module) && value.name == "#{mod.name}::#{name}"
88
+ [["#{mod.name}::#{name}", value], *(own ? walk.call(value) : [])]
89
+ end
90
+ end
91
+ unshareable = walk.call(MyGem).reject { |name, value| main_only.key?(name) || Ractor.shareable?(value) }
92
+ expect(unshareable.map { |name, value| "#{name} (#{value.class})" }).to be_empty
93
+ end
94
+ end
95
+ ```
96
+
97
+ A failure names the constant and its class, e.g. `Expected ["MyGem::Html::ESCAPES (Hash)"] to be empty.` The Ractor block must not use outer local variables: write its input inline, or reach it through a constant.
98
+
99
+ What these two leave out, and the kit covers:
100
+
101
+ - class and module instance variables (a `@cache ||= {}` memo);
102
+ - which value inside a constant is the problem: they name `MyGem::SETTINGS (Hash)`, the audit names `[:hooks] is a Proc that is never shareable`;
103
+ - unloaded autoloads, which they skip without a word (the audit lists them);
104
+ - races between threads and fibers.
105
+
106
+ Neither sees a constant the gem defines under another namespace, such as an Active Job adapter in `ActiveJob::QueueAdapters`: walk that namespace too, or pass it to `--namespace`.
107
+
108
+ ## Adding it to a gem
109
+
14
110
  **A development dependency only.** Nothing here becomes a runtime dependency of your gem, and the kit adds none to your bundle: `async`, `minitest`, `rubocop-thread_safety` and `rubocop_spinel` are used only when you already have them. Ruby 3.2+; Ractor-only behaviour is gated on the running Ruby, never on a version floor.
15
111
 
16
112
  ```ruby
@@ -105,7 +201,7 @@ bundle exec conformance-audit --strict #
105
201
  bundle exec conformance-audit --require ./lib/my_gem --namespace MyGem --strict # flags override them
106
202
  ```
107
203
 
108
- Requires the files, walks every loaded module under the namespaces (`--require` and `--namespace` repeat; without them it reads `audit.require` and `audit.namespace`), and reports each public constant and class/module instance variable whose value is not `Ractor.shareable?`, with the path to the offending object and the fix:
204
+ Requires the files (an entry starting with `./`, `../`, `~` or `/` is a path; any other is required by name from the load path, as a gem, unless it names an existing file), walks every loaded module under the namespaces (`--require` and `--namespace` repeat; without them it reads `audit.require` and `audit.namespace`), and reports each public constant and class/module instance variable whose value is not `Ractor.shareable?`, with the path to the offending object and the fix:
109
205
 
110
206
  ```
111
207
  conformance-audit: 2 findings in MyGem (1 allowlisted main-only; 14 modules audited, 0 unloaded autoloads skipped)
@@ -147,6 +243,11 @@ probes:
147
243
  gem_name: my_gem # required for the probes job
148
244
  paths: [lib] # default [lib]
149
245
  roundhouse_app: spec/dummy # optional
246
+ ractorize: # optional: the report-only ractorize job (below)
247
+ app: spec/dummy # the Rails app to boot, relative to this file
248
+ requests: ["/", "/widgets/1"] # GET paths served inside a non-main Ractor
249
+ audit_namespaces: [MyGem] # default audit.namespace
250
+ setup: bin/rails db:prepare # optional, run in the app
150
251
  main_only:
151
252
  - name: MyGem::Registry.@lock # class/module ivar: Owner.@ivar
152
253
  reason: guards boot-time registration; no non-main Ractor ever registers
@@ -204,11 +305,12 @@ jobs:
204
305
 
205
306
  To make it a merge requirement, add `conformance / Conformance settings` and `conformance / Conformance (Ruby 4.0)` to the required status checks in the repo's branch ruleset. Require both: if the settings job fails, the gate is skipped, and a skipped check does not block a merge. The prefix is the caller's job id (`conformance` above) and the Ruby is `ractor.ruby`, so the names change with either. Leave the probes out: they never fail. A repo or org that only allows selected actions must allow `zoolutions/conformance-kit/.github/workflows/conformance.yml@*`.
206
307
 
207
- The workflow has three jobs:
308
+ The workflow has four jobs:
208
309
 
209
- - **Conformance settings** reads `ractor.ruby` and `ractor.working_directory`.
310
+ - **Conformance settings** reads `ractor.ruby`, `ractor.working_directory` and `ractorize.app`.
210
311
  - **Conformance (Ruby `ractor.ruby`)**, the gate: the version handshake, then `bundle exec conformance-kit check`. A `postgres:18` service (user and password `postgres`, port 5432) is always up; the workflow exports nothing, so `ractor.env` and `ractor.setup` wire it.
211
312
  - **Compiler probes (report-only)**: `rubocop_spinel` (gem-installed in the probe, never in your Gemfile), `spinel-compat probe <probes.gem_name> --dir .` and, when `probes.roundhouse_app` is set, `roundhouse check` (the release tarball, checked against a pinned SHA-256). Every probe continues on error; the reports go to the run summary and a `conformance-probes` artifact. It never fails a build, and it skips with a notice when `probes.gem_name` is unset.
313
+ - **Ractorize (report-only)**, only when `ractorize.app` is set: [the ractorize job](#the-ractorize-job-report-only) on Rails main. It never fails the workflow; leave it out of the required checks.
212
314
 
213
315
  ### Staying current
214
316
 
@@ -240,6 +342,7 @@ updates:
240
342
 
241
343
  ```bash
242
344
  bundle exec conformance-kit check # doctor, ractor.setup, ractor.test_command, then conformance-audit --strict
345
+ bundle exec conformance-kit check --ractorize # the ractorize job (below), with BUNDLE_GEMFILE=Gemfile.ractorize
243
346
  bundle exec conformance-kit doctor # the static checks only
244
347
  bundle exec conformance-kit config probes.gem_name
245
348
  ```
@@ -256,6 +359,63 @@ bundle exec conformance-kit config probes.gem_name
256
359
  | 1 | a check failed (`check` exits with the failing stage's status) |
257
360
  | 2 | usage error or an invalid `.conformance.yml` |
258
361
 
362
+ ### The ractorize job (report-only)
363
+
364
+ Rails 8.2 can serve an app's requests from several Ractors: `Rails.application.ractorize!` makes the booted app shareable, and every gem on the request path has to cope. The Ruby 4.0 canary runs your gem's own code in a Ractor. This job runs it inside a real app that has been ractorized, where Rails' framework hooks, the app's boot order and other engines meet it.
365
+
366
+ ```yaml
367
+ # .conformance.yml
368
+ ractorize:
369
+ app: spec/dummy # the Rails app to boot (its config/environment.rb), relative to this file
370
+ requests: ["/", "/widgets/1"] # GET paths, each served inside a non-main Ractor after ractorize!
371
+ audit_namespaces: [MyGem] # audited after ractorize!; default audit.namespace
372
+ setup: bin/rails db:prepare db:schema:cache:dump assets:precompile # optional
373
+ ```
374
+
375
+ `conformance-kit check --ractorize` runs it locally, and the workflow's Ractorize job runs the same when `ractorize.app` is set:
376
+
377
+ 1. `ractorize.setup` runs in the app, with `ractor.env`, `RAILS_ENV=production` and `SECRET_KEY_BASE_DUMMY=1`.
378
+ 2. A new process sets `ActiveSupport::Ractors.unshareable_proc_action = :raise` and boots the app in production. The test environment can't serve a ractorized request yet ([rails/rails#59168](https://github.com/rails/rails/issues/59168)).
379
+ 3. It calls `Rails.application.ractorize!`, then `require "i18n/ractorize"`.
380
+ 4. Each request runs inside `Ractor.new`. An exception fails it, and so does a 5xx; either way the report names the root cause and its first frame, including an exception the app's error page swallowed.
381
+ 5. The audit runs on the namespaces in the same process, with `main_only:` applied, so it sees what a request Ractor reads.
382
+
383
+ ```
384
+ ractorize: boot: ok
385
+ ractorize: Rails.application.ractorize!: ok
386
+ GET /widget -> 500 (0 bytes): Ractor::IsolationError: can not get unshareable values from instance variables of classes/modules from non-main Ractors (@registry from MyGem::Widget)
387
+ at /home/runner/work/my_gem/my_gem/spec/dummy/app/controllers/widgets_controller.rb:5:in 'WidgetsController#show'
388
+ ractorize: audit MyGem after ractorize!: 1 findings
389
+ MyGem::Widget.@registry (class ivar) Hash: [:names] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
390
+ ractorize failed: 2 failures (failed requests and audit findings)
391
+ ```
392
+
393
+ A block Rails can't make shareable stops the boot, and Ruby's message names the block's file and line (`Proc's self is not shareable: #<Proc:... lib/my_gem/engine.rb:8>`). Below Ruby 4.0, or on a Rails without `ractorize!`, the job skips with the reason and exits 0.
394
+
395
+ **What the app needs.** Production settings that can be made shareable, as in Rails' own `railties/test/application/ractors_test.rb`:
396
+
397
+ - `config.enable_reloading = false` and `config.eager_load = true` (a generated `production.rb` sets both);
398
+ - `config.logger = ActiveSupport::Ractors::Logger.new`;
399
+ - `config.cache_store = :null_store`, `config.public_file_server.enabled = false`, `config.action_cable.mount_path = nil` and `config.active_job.queue_adapter = :inline`.
400
+
401
+ With Active Record, it also needs a dumped schema cache (`db:schema:cache:dump`) and `config.active_record.check_schema_cache_dump_version = false`: without them, a model's first query inside a Ractor fails, and nothing warns. Precompile assets in `setup`. Framework state on your engine's classes is main-only, so list it once in `main_only:` (e.g. `ancestor: Rails::Engine` with `ivars: ["@called_from", "@initializers"]`).
402
+
403
+ **The bundle.** Until they ship, `ractorize!` needs:
404
+
405
+ - Rails main;
406
+ - Rack main, for `rack/ractorize`;
407
+ - Shopify's i18n branch, pinned to [Shopify/i18n#5](https://github.com/Shopify/i18n/pull/5) until that merges: without it, every render fails on `I18n.fallbacks`.
408
+
409
+ `config/ractorize.gemfile` swaps them into your Gemfile. It drops your own lines for those gems and Rails' component gems (Bundler refuses a gem declared twice) and keeps everything else. Copy it next to your Gemfile and bundle with it, as the workflow does; keep `Gemfile.ractorize` and its lock out of git.
410
+
411
+ ```bash
412
+ cp "$(bundle exec ruby -e 'print Gem.loaded_specs["conformance-kit"].full_gem_path')/config/ractorize.gemfile" Gemfile.ractorize
413
+ BUNDLE_GEMFILE=Gemfile.ractorize bundle install
414
+ BUNDLE_GEMFILE=Gemfile.ractorize bundle exec conformance-kit check --ractorize
415
+ ```
416
+
417
+ **Report-only.** Rails main moves under it, so the job is `continue-on-error` and never fails the workflow until Rails 8.2 ships `ractorize!`. This repo proves it the same way, with a "Ractorize lane" in its own CI: a failing and a passing fixture app on that bundle.
418
+
259
419
  ### The version handshake
260
420
 
261
421
  The behaviour a repo is held to comes from the `conformance-kit` in its lockfile; only the plumbing comes from the workflow tag. So that a stale lockfile cannot pass a newer workflow vacuously, the workflow reads its own tag from `job.workflow_ref` (the called workflow's ref; `github.workflow_ref` is the caller's) and fails with "bump conformance-kit to ~> X.Y" when the repo resolves anything older than `X.Y`. The version is derived, never stored. When the caller pins anything other than a `vX.Y.Z` tag (a branch, a commit SHA, or this repo's own `./` caller) the handshake is skipped with a notice, and so it is on GitHub Enterprise Server, which has no `job.workflow_ref`.
@@ -264,6 +424,11 @@ The behaviour a repo is held to comes from the `conformance-kit` in its lockfile
264
424
 
265
425
  ```bash
266
426
  bundle exec rake # rspec + rubocop; CI runs Ruby 3.2, 3.4 and 4.0
427
+
428
+ # The ractorize lane (Ruby 4.0; CI's report-only "Ractorize lane" job)
429
+ cp config/ractorize.gemfile Gemfile.ractorize
430
+ BUNDLE_GEMFILE=Gemfile.ractorize bundle install
431
+ BUNDLE_GEMFILE=Gemfile.ractorize bundle exec rspec --tag ractorize
267
432
  ```
268
433
 
269
434
  Every check is proven both ways against `spec/fixtures/`: a violating fixture it must catch and a conforming one it must pass. The kit audits itself (`.conformance.yml`, strict, nothing skipped), and `conformance.yml` runs on its own pull requests.
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The ractorize job's bundle: the repo's own Gemfile, with Rails, Rack and i18n
4
+ # swapped for the branches `Rails.application.ractorize!` needs until they ship
5
+ # (Rails 8.2, Rack's rack/ractorize, ruby-i18n/i18n#741). Copy it next to the
6
+ # repo's Gemfile as Gemfile.ractorize and run with BUNDLE_GEMFILE=Gemfile.ractorize.
7
+ ractorize_sources = {
8
+ "rails" => { github: "rails/rails", branch: "main" },
9
+ "rack" => { github: "rack/rack", branch: "main" },
10
+ "i18n" => { github: "Shopify/i18n", ref: "refs/pull/5/head" }
11
+ }.freeze
12
+ # Rails' own gems come from the same checkout as rails.
13
+ replaced = %w[railties actionpack actionview activesupport activemodel activerecord activejob actionmailer
14
+ actioncable activestorage actiontext actionmailbox] + ractorize_sources.keys
15
+
16
+ # The repo's own lines for these gems are dropped: Bundler refuses a gem declared twice.
17
+ reading_repo_gemfile = true
18
+ define_singleton_method(:gem) do |name, *args, **options, &block|
19
+ super(name, *args, **options, &block) unless reading_repo_gemfile && replaced.include?(name)
20
+ end
21
+
22
+ eval_gemfile File.expand_path("Gemfile", __dir__)
23
+ reading_repo_gemfile = false
24
+
25
+ ractorize_sources.each { |name, source| gem name, **source }
@@ -56,7 +56,7 @@ module ConformanceKit
56
56
  def parser(options)
57
57
  OptionParser.new do |o|
58
58
  o.banner = "Usage: conformance-audit [--require FILE] [--namespace CONST] [--strict] [--config PATH]"
59
- o.on("--require FILE", "Require FILE before auditing (repeatable; default audit.require)") do |file|
59
+ o.on("--require FILE", "Require FILE or a gem by name first (repeatable; default audit.require)") do |file|
60
60
  options[:requires] << file
61
61
  end
62
62
  o.on("--namespace CONST", "Audit CONST and all under it (repeatable; default audit.namespace)") do |name|
@@ -78,7 +78,7 @@ module ConformanceKit
78
78
  def requires(options, config)
79
79
  return config.audit_requires if options[:requires].empty?
80
80
 
81
- options[:requires].map { File.expand_path(_1) }
81
+ options[:requires].map { Config.require_target(_1, Dir.pwd) }
82
82
  end
83
83
 
84
84
  def check_namespace(name)
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "conformance_kit"
3
+ require_relative "../conformance_kit"
4
4
  require_relative "config"
5
5
  require_relative "audit/allowlist"
6
6
  require_relative "audit/cli"
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+
6
+ module ConformanceKit
7
+ class CLI
8
+ # `conformance-kit check --ractorize`: what the workflow's ractorize job
9
+ # runs. ractorize.setup in the app (RAILS_ENV=production), then the runner
10
+ # in its own process, both with ractor.env. Stops at the first failing stage.
11
+ class RactorizeCheck
12
+ BOOT = File.expand_path("../ractorize/boot.rb", __dir__).freeze
13
+ PRODUCTION = { "RAILS_ENV" => "production", "SECRET_KEY_BASE_DUMMY" => "1" }.freeze
14
+
15
+ def initialize(config, out:)
16
+ @config = config
17
+ @out = out
18
+ @app = config.ractorize_app
19
+ raise ConfigError, "#{config.source}: ractorize.app is not set (the Rails app to boot)" unless @app
20
+
21
+ environment = File.join(@app, "config/environment.rb")
22
+ return if File.file?(environment)
23
+
24
+ raise ConfigError, "#{config.source}: ractorize.app: #{environment} does not exist"
25
+ end
26
+
27
+ def run
28
+ setup = @config["ractorize.setup"]
29
+ code = setup ? shell(setup) : 0
30
+ return failed("ractorize.setup", code) unless code.zero?
31
+
32
+ code = runner
33
+ code.zero? ? code : failed("ractorize", code)
34
+ end
35
+
36
+ private
37
+
38
+ def env = @config["ractor.env"].merge(PRODUCTION)
39
+
40
+ def shell(command)
41
+ @out.puts "==> #{command}"
42
+ @out.flush
43
+ # system returns nil when the command could not be spawned at all.
44
+ return 127 if system(env, command, chdir: @app).nil?
45
+
46
+ Process.last_status.exitstatus || 1
47
+ end
48
+
49
+ def runner
50
+ @out.puts "==> ractorize #{@app}"
51
+ @out.flush
52
+ output, status = Open3.capture2e(env, RbConfig.ruby, BOOT, File.expand_path(@config.source), chdir: @app)
53
+ @out.print output
54
+ status.exitstatus || 1
55
+ end
56
+
57
+ def failed(stage, code)
58
+ @out.puts "conformance-kit check failed at #{stage} (exit #{code})"
59
+ code
60
+ end
61
+ end
62
+ end
63
+ end
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
- require "conformance_kit"
4
+ require_relative "../conformance_kit"
5
5
  require_relative "config"
6
6
  require_relative "cli/doctor"
7
7
  require_relative "cli/check"
8
+ require_relative "cli/ractorize_check"
8
9
 
9
10
  module ConformanceKit
10
- # `conformance-kit check|doctor|config KEY [--config PATH] [--expect X.Y]`
11
+ # `conformance-kit check|doctor|config KEY [--config PATH] [--expect X.Y] [--ractorize]`
11
12
  #
12
13
  # Exit codes: 0 ok, 1 a check failed (`check` passes on its failing stage's
13
14
  # status), 2 usage or config error.
@@ -15,6 +16,8 @@ module ConformanceKit
15
16
  USAGE = <<~TEXT
16
17
  Usage: conformance-kit COMMAND [--config PATH]
17
18
  check [--expect X.Y] doctor, ractor.setup, ractor.test_command, then conformance-audit --strict
19
+ check --ractorize ractorize.setup, then boot ractorize.app with Rails.application.ractorize!,
20
+ serve ractorize.requests inside non-main Ractors and audit (report-only job)
18
21
  doctor [--expect X.Y] .conformance.yml, .rubocop.yml and the bundle are set up for the conformance workflow
19
22
  config KEY print a .conformance.yml value (dotted key, e.g. probes.gem_name)
20
23
  TEXT
@@ -47,16 +50,17 @@ module ConformanceKit
47
50
  case command
48
51
  when "config" then print_value(options)
49
52
  when "doctor" then doctor(options).run
50
- when "check" then Check.new(config(options), doctor(options), out: @out).run
53
+ when "check" then check(options)
51
54
  else raise ArgumentError, command ? "unknown command #{command}" : "no command"
52
55
  end
53
56
  end
54
57
 
55
58
  def parse(argv)
56
- options = { config: Config::DEFAULT_PATH, expect: nil }
59
+ options = { config: Config::DEFAULT_PATH, expect: nil, ractorize: false }
57
60
  options[:args] = OptionParser.new do |o|
58
61
  o.on("--config PATH") { options[:config] = _1 }
59
62
  o.on("--expect X.Y") { options[:expect] = expected_version(_1) }
63
+ o.on("--ractorize") { options[:ractorize] = true }
60
64
  end.parse(argv)
61
65
  options
62
66
  end
@@ -69,6 +73,12 @@ module ConformanceKit
69
73
 
70
74
  def config(options) = Config.load(options[:config])
71
75
 
76
+ def check(options)
77
+ return RactorizeCheck.new(config(options), out: @out).run if options[:ractorize]
78
+
79
+ Check.new(config(options), doctor(options), out: @out).run
80
+ end
81
+
72
82
  def doctor(options)
73
83
  Doctor.new(config(options), out: @out, version: @version, specs: @specs, expect: options[:expect])
74
84
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rbconfig"
3
4
  require "yaml"
4
5
  require_relative "audit/allowlist"
5
6
 
@@ -13,6 +14,7 @@ module ConformanceKit
13
14
  # audit: { require: [], namespace: [], strict_autoloads: false, skip_autoloads: [] }
14
15
  # ractor: { ruby: "4.0", test_command: "...", env: {}, setup: nil, working_directory: "." }
15
16
  # probes: { gem_name: nil, paths: [lib], roundhouse_app: nil }
17
+ # ractorize: { app: nil, requests: [], audit_namespaces: [audit.namespace], setup: nil }
16
18
  # main_only: []
17
19
  class Config
18
20
  DEFAULT_PATH = ".conformance.yml"
@@ -30,6 +32,10 @@ module ConformanceKit
30
32
  "probes.gem_name" => :string,
31
33
  "probes.paths" => :list,
32
34
  "probes.roundhouse_app" => :string,
35
+ "ractorize.app" => :string,
36
+ "ractorize.requests" => :list,
37
+ "ractorize.audit_namespaces" => :list,
38
+ "ractorize.setup" => :string,
33
39
  "main_only" => :entries
34
40
  }.freeze
35
41
 
@@ -43,10 +49,11 @@ module ConformanceKit
43
49
  "ractor.env" => {}.freeze,
44
50
  "ractor.working_directory" => ".",
45
51
  "probes.paths" => ["lib"].freeze,
52
+ "ractorize.requests" => [].freeze,
46
53
  "main_only" => [].freeze
47
54
  }.freeze
48
55
 
49
- SECTIONS = %w[audit ractor probes].freeze
56
+ SECTIONS = %w[audit ractor probes ractorize].freeze
50
57
 
51
58
  def self.load(path = DEFAULT_PATH)
52
59
  root = File.dirname(File.expand_path(path))
@@ -64,7 +71,9 @@ module ConformanceKit
64
71
  @source = source
65
72
  @root = root
66
73
  @exists = exists
67
- raise ConfigError, "#{source}: expected a mapping (audit:, ractor:, probes:, main_only:)" unless data.is_a?(Hash)
74
+ unless data.is_a?(Hash)
75
+ raise ConfigError, "#{source}: expected a mapping (audit:, ractor:, probes:, ractorize:, main_only:)"
76
+ end
68
77
 
69
78
  pairs = flatten(data).each { |key, _| unknown_key!(key, TYPES.keys) unless TYPES.key?(key) }
70
79
  # A key left blank is unset: its default applies, as in the workflow's yq read.
@@ -82,10 +91,34 @@ module ConformanceKit
82
91
  @values.fetch(key) { DEFAULTS[key] }
83
92
  end
84
93
 
85
- def audit_requires = self["audit.require"].map { File.expand_path(_1, root) }
94
+ # An `audit.require` or `--require` entry: a path relative to +base+ when
95
+ # written as one (`./lib/my_gem`, `/abs/path`) or when it names a Ruby file
96
+ # or native extension there, otherwise a feature for the load path, such as
97
+ # an installed gem.
98
+ def self.require_target(entry, base)
99
+ path = File.expand_path(entry, base)
100
+ explicit = entry.start_with?("./", "../", "~") || File.absolute_path?(entry)
101
+ explicit || requirable_file?(path) ? path : entry
102
+ end
103
+
104
+ def self.requirable_file?(path)
105
+ ["", ".rb", ".#{RbConfig::CONFIG['DLEXT']}"].any? { File.file?("#{path}#{_1}") }
106
+ end
107
+ private_class_method :requirable_file?
108
+
109
+ def audit_requires = self["audit.require"].map { self.class.require_target(_1, root) }
86
110
  def strict_autoloads? = self["audit.strict_autoloads"]
87
111
  def working_directory = File.expand_path(self["ractor.working_directory"], root)
88
112
 
113
+ # The Rails app the ractorize job boots, or nil when `ractorize.app` is unset.
114
+ def ractorize_app = self["ractorize.app"]&.then { File.expand_path(_1, root) }
115
+
116
+ # What the ractorize job audits after `ractorize!`: its own list, or audit.namespace.
117
+ def ractorize_namespaces
118
+ namespaces = self["ractorize.audit_namespaces"]
119
+ namespaces.nil? || namespaces.empty? ? self["audit.namespace"] : namespaces
120
+ end
121
+
89
122
  private
90
123
 
91
124
  def flatten(data)
@@ -100,9 +133,12 @@ module ConformanceKit
100
133
  end
101
134
  end
102
135
 
136
+ NAMESPACE_KEYS = %w[audit.namespace ractorize.audit_namespaces].freeze
137
+ private_constant :NAMESPACE_KEYS
138
+
103
139
  # A namespace is matched by name: `::MyGem` is `MyGem`, as with --namespace.
104
140
  def normalize(key, value)
105
- key == "audit.namespace" ? value.map { _1.delete_prefix("::") } : value
141
+ NAMESPACE_KEYS.include?(key) ? value.map { _1.delete_prefix("::") } : value
106
142
  end
107
143
 
108
144
  def validate(key, value)
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The ractorize job's process: `ruby boot.rb .conformance.yml`, under the
4
+ # bundle that has Rails (BUNDLE_GEMFILE=Gemfile.ractorize). conformance-kit
5
+ # check --ractorize starts it; it never runs inside the kit's own process.
6
+ require_relative "runner"
7
+
8
+ $stdout.sync = true
9
+ exit ConformanceKit::Ractorize::Runner.new(ConformanceKit::Config.load(ARGV.fetch(0)), out: $stdout).run
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+
5
+ module ConformanceKit
6
+ module Ractorize
7
+ # One request the ractorize job serves. Its env is built on the main Ractor
8
+ # from Strings only, so it crosses into the request Ractor as a copy; the
9
+ # IO objects Rack wants are made inside that Ractor.
10
+ module Request
11
+ SERVER = {
12
+ "SCRIPT_NAME" => "",
13
+ "SERVER_NAME" => "localhost",
14
+ "SERVER_PORT" => "80",
15
+ "SERVER_PROTOCOL" => "HTTP/1.1",
16
+ "HTTP_HOST" => "localhost",
17
+ "HTTP_ACCEPT" => "text/html",
18
+ "rack.url_scheme" => "http"
19
+ }.freeze
20
+
21
+ def self.env_for(path)
22
+ path_info, query = path.split("?", 2)
23
+ { "REQUEST_METHOD" => "GET", "PATH_INFO" => path_info, "QUERY_STRING" => query.to_s }.merge(SERVER)
24
+ end
25
+
26
+ # `{ status:, body:, error: }`, where error is describe_error's triple:
27
+ # the exception the app raised, or the one its error page swallowed.
28
+ def self.serve(app, path)
29
+ Ractor.new(app, env_for(path)) { |rack_app, env| Request.call(rack_app, env) }.value
30
+ end
31
+
32
+ # Runs inside the request Ractor.
33
+ def self.call(app, env)
34
+ env = env.merge("rack.input" => StringIO.new(+""), "rack.errors" => StringIO.new)
35
+ status, _headers, body = app.call(env)
36
+ text = read(body)
37
+ swallowed = env["action_dispatch.exception"]
38
+ { status: status, body: text, error: swallowed && describe_error(swallowed) }
39
+ rescue Exception => e # rubocop:disable Lint/RescueException -- every failure is the report
40
+ { status: nil, body: nil, error: describe_error(e) }
41
+ end
42
+
43
+ def self.read(body)
44
+ text = +""
45
+ body.each { text << _1 }
46
+ text
47
+ ensure
48
+ body.close if body.respond_to?(:close)
49
+ end
50
+
51
+ # The root cause's class, message and first frame, unfiltered (no
52
+ # backtrace cleaner), so the frame names the file and line to fix.
53
+ def self.describe_error(error)
54
+ root = error
55
+ root = root.cause while root.cause
56
+ [root.class.name, root.message, root.backtrace&.first]
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ractorize"
4
+ require_relative "../audit"
5
+ require_relative "request"
6
+
7
+ module ConformanceKit
8
+ module Ractorize
9
+ # Runs in its own process (boot.rb): it boots the app, freezes it for good
10
+ # and leaves global state behind. Boots in production with
11
+ # `unshareable_proc_action = :raise`, calls `Rails.application.ractorize!`,
12
+ # serves each `ractorize.requests` path inside a non-main Ractor, then audits
13
+ # `ractorize.audit_namespaces` in the same process. Exits 0 when all of it
14
+ # passes or the run is skipped (with the reason), 1 otherwise.
15
+ class Runner
16
+ def initialize(config, out:)
17
+ @config = config
18
+ @out = out
19
+ @failures = 0
20
+ end
21
+
22
+ def run
23
+ reason = unavailable_reason
24
+ return skip(reason) if reason
25
+
26
+ ActiveSupport::Ractors.unshareable_proc_action = :raise
27
+ return 1 unless stage("boot") { boot }
28
+ return 1 unless stage("Rails.application.ractorize!") { ractorize }
29
+
30
+ serve_requests
31
+ audit
32
+ summary
33
+ end
34
+
35
+ private
36
+
37
+ def unavailable_reason
38
+ return rails_reason if Ractorize.available?
39
+
40
+ "Ractor#value is unavailable on #{RUBY_ENGINE} #{RUBY_VERSION} (needs Ruby 4.0)"
41
+ end
42
+
43
+ # Checked before :raise is armed, so a Rails without ractorize! skips instead of failing its boot.
44
+ def rails_reason
45
+ require "rails"
46
+ require "active_support/ractors"
47
+ return if Rails::Application.method_defined?(:ractorize!)
48
+
49
+ "Rails #{Rails.version} has no Rails.application.ractorize!: run it with BUNDLE_GEMFILE=Gemfile.ractorize"
50
+ rescue LoadError => e
51
+ return "Rails is not in the bundle: run it with BUNDLE_GEMFILE=Gemfile.ractorize" if e.path == "rails"
52
+
53
+ "ActiveSupport::Ractors is unavailable (Rails before 8.2): run it with BUNDLE_GEMFILE=Gemfile.ractorize"
54
+ end
55
+
56
+ def boot
57
+ ENV["RAILS_ENV"] = "production"
58
+ ENV["SECRET_KEY_BASE_DUMMY"] ||= "1"
59
+ environment = File.join(@config.ractorize_app, "config/environment.rb")
60
+ require environment
61
+ raise "#{environment} defined no Rails.application" unless defined?(::Rails) && ::Rails.application
62
+ end
63
+
64
+ def ractorize
65
+ Warning[:experimental] = false
66
+ Rails.application.ractorize!
67
+ require "i18n/ractorize"
68
+ rescue LoadError => e
69
+ raise unless e.path == "i18n/ractorize"
70
+
71
+ @out.puts "ractorize: note: i18n/ractorize is unavailable (released i18n): I18n reads fail in a Ractor"
72
+ end
73
+
74
+ def stage(name)
75
+ yield
76
+ @out.puts "ractorize: #{name}: ok"
77
+ true
78
+ rescue Exception => e # rubocop:disable Lint/RescueException -- the failure is the report
79
+ @out.puts "ractorize: #{name} failed: #{format(Request.describe_error(e))}"
80
+ false
81
+ end
82
+
83
+ def serve_requests
84
+ @config["ractorize.requests"].each do |path|
85
+ result = Request.serve(Rails.application, path)
86
+ failed = result[:error] || result[:status].nil? || result[:status] >= 500
87
+ @failures += 1 if failed
88
+ @out.puts " GET #{path} -> #{describe(result)}"
89
+ end
90
+ end
91
+
92
+ def describe(result)
93
+ status = result[:status] ? "#{result[:status]} (#{result[:body].bytesize} bytes)" : "raised"
94
+ result[:error] ? "#{status}: #{format(result[:error])}" : status
95
+ end
96
+
97
+ def format((klass, message, frame)) = "#{klass}: #{message}\n at #{frame}"
98
+
99
+ def audit
100
+ namespaces = @config.ractorize_namespaces
101
+ return @out.puts("ractorize: audit: nothing to audit (set ractorize.audit_namespaces)") if namespaces.empty?
102
+
103
+ missing = namespaces.reject { Object.const_defined?(_1) }
104
+ return audit_failed("namespace #{missing.join(', ')} is not defined after boot") if missing.any?
105
+
106
+ findings = Audit.new(namespaces: namespaces, allowlist: @config.allowlist,
107
+ skip_autoloads: @config.skip_autoloads).findings
108
+ @failures += findings.size
109
+ @out.puts "ractorize: audit #{namespaces.join(', ')} after ractorize!: #{findings.size} findings"
110
+ findings.each { @out.puts " #{_1}" }
111
+ end
112
+
113
+ def audit_failed(message)
114
+ @failures += 1
115
+ @out.puts "ractorize: audit failed: #{message}"
116
+ end
117
+
118
+ def summary
119
+ return 0.tap { @out.puts "ractorize passed" } if @failures.zero?
120
+
121
+ @out.puts "ractorize failed: #{@failures} failures (failed requests and audit findings)"
122
+ 1
123
+ end
124
+
125
+ def skip(reason)
126
+ @out.puts "ractorize: skipped: #{reason}"
127
+ 0
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ # The ractorize job: boot a gem's test app the way Rails 8.2 runs it across
5
+ # Ractors (`Rails.application.ractorize!`), serve requests inside non-main
6
+ # Ractors and audit what they read. See README "The ractorize job".
7
+ module Ractorize
8
+ # Ractor#value is Ruby 4.0's; before it, results came back through #take.
9
+ def self.available? = defined?(::Ractor) && ::Ractor.method_defined?(:value)
10
+ end
11
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConformanceKit
4
- VERSION = "0.4.2"
4
+ VERSION = "0.5.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: conformance-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.2
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mhenrixon
@@ -23,6 +23,7 @@ files:
23
23
  - CHANGELOG.md
24
24
  - LICENSE.txt
25
25
  - README.md
26
+ - config/ractorize.gemfile
26
27
  - config/rubocop.yml
27
28
  - config/rubocop_spinel.yml
28
29
  - exe/conformance-audit
@@ -40,8 +41,13 @@ files:
40
41
  - lib/conformance_kit/cli.rb
41
42
  - lib/conformance_kit/cli/check.rb
42
43
  - lib/conformance_kit/cli/doctor.rb
44
+ - lib/conformance_kit/cli/ractorize_check.rb
43
45
  - lib/conformance_kit/config.rb
44
46
  - lib/conformance_kit/minitest.rb
47
+ - lib/conformance_kit/ractorize.rb
48
+ - lib/conformance_kit/ractorize/boot.rb
49
+ - lib/conformance_kit/ractorize/request.rb
50
+ - lib/conformance_kit/ractorize/runner.rb
45
51
  - lib/conformance_kit/result.rb
46
52
  - lib/conformance_kit/rspec.rb
47
53
  - lib/conformance_kit/shareability.rb