conformance-kit 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45e9d40d8ce04df0a8aae9f4f0bd8da57e2f0f649c2549f1649f7d9d5ed7adc2
4
+ data.tar.gz: df75fb6611c183f7d93d52f95755022f6154afd63cd60a541346e779107e317b
5
+ SHA512:
6
+ metadata.gz: ea8d19b3ca7a5dfdb2a4cd5d5a9e12c350c105ffa7af381319f7d31efc5a10ae8e6e8c35b5ef2f8cb0e70f23f2dcdaa417e8bf96617c9ac51280d176d106272d
7
+ data.tar.gz: aea92dfbc96c976dd0a4a31d556d8d111764c31ddfa14dab32f7f5123aad84272ca59f4251302d160f635af1289de41a357fdae78f474db6ca16a3dc13d2f1e8
data/CHANGELOG.md ADDED
@@ -0,0 +1,11 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Shared examples (`require "conformance_kit/rspec"`): `"thread-safe under contention"`, `"fiber-safe under an Async reactor"`, `"a boot-finalized configuration"` and `"runs inside a non-main Ractor"` (Ruby 4.0+, tagged `:ractor`).
6
+ - The same checks as Minitest assertions (`require "conformance_kit/minitest"`): `assert_thread_safe`, `assert_fiber_safe`, `assert_boot_finalized`, `assert_runs_in_ractor`.
7
+ - `ConformanceKit::Store.fetch(key) { init }`: per-Ractor lazy storage (`Ractor.store_if_absent` on 3.4+, a Mutex-guarded fallback below).
8
+ - `conformance-audit`: reports unshareable constants and class/module ivars under a namespace; `.conformance.yml` `main_only:` allowlist with a required `reason:`; `--strict` exits 1.
9
+ - `config/rubocop.yml` (rubocop-thread_safety) and `config/rubocop_spinel.yml` (report-only Spinel probe).
10
+ - Reusable workflows: `ractor.yml` (Ruby 4.0 Ractor examples + `conformance-audit --strict`) and `probes.yml` (report-only rubocop_spinel, spinel-compat, roundhouse).
11
+ - Gem skeleton: release kit, labels kit, CI on Ruby 3.2, 3.4 and 4.0.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 mhenrixon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,200 @@
1
+ # conformance-kit
2
+
3
+ Concurrency conformance for the zoolutions gems. A gem that adopts it is held to explicit tiers, each with a mechanical gate:
4
+
5
+ | Tier | Meaning | Gate |
6
+ |---|---|---|
7
+ | 1 thread-safe | no unguarded shared mutable state on the request/broadcast path | `config/rubocop.yml` (rubocop-thread_safety) + `"thread-safe under contention"` |
8
+ | 2 fiber-safe | correct under a fiber scheduler (Falcon/Async): request state fiber-local, no `Thread.main[]` misuse | `"fiber-safe under an Async reactor"` |
9
+ | 3 Ractor-clean | the gem adds no Ractor blocker beyond its dependencies': config shareable after boot, per-Ractor caches, no locks in constants | `"a boot-finalized configuration"`, `conformance-audit --strict`, the Ruby 4.0 `"runs inside a non-main Ractor"` canary (`ractor.yml`) |
10
+ | 4 compiler-probed | Spinel/Roundhouse readiness is measured, never gated | `probes.yml` (report-only) |
11
+
12
+ Background, the finalize-at-boot pattern and the settled decisions: [zoolutions/phlex-reactive#363](https://github.com/zoolutions/phlex-reactive/issues/363).
13
+
14
+ **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
+
16
+ ```ruby
17
+ # Gemfile
18
+ group :development, :test do
19
+ gem "conformance-kit", github: "zoolutions/conformance-kit"
20
+ gem "rubocop-thread_safety", ">= 0.8", require: false # for config/rubocop.yml
21
+ gem "async", require: false # optional: runs the fiber example
22
+ end
23
+ ```
24
+
25
+ ## Shared examples (RSpec)
26
+
27
+ ```ruby
28
+ # spec/spec_helper.rb
29
+ require "conformance_kit/rspec"
30
+ ```
31
+
32
+ Each example calls `subject_call`, a callable for the operation under test:
33
+
34
+ ```ruby
35
+ RSpec.describe MyGem::Cache do
36
+ let(:subject_call) { -> { MyGem::Cache.for(:key) } }
37
+
38
+ it_behaves_like "thread-safe under contention", threads: 8, iterations: 50, consistent: :identical
39
+ it_behaves_like "fiber-safe under an Async reactor", fibers: 8, iterations: 50
40
+ end
41
+
42
+ RSpec.describe MyGem do
43
+ it_behaves_like "a boot-finalized configuration",
44
+ readers: [MyGem.method(:colors), MyGem.method(:adapters)],
45
+ finalize: -> { MyGem.finalize! }
46
+ end
47
+
48
+ RSpec.describe MyGem::Token do
49
+ let(:subject_call) { ->(payload) { MyGem::Token.verify(MyGem::Token.sign(payload)) } }
50
+
51
+ it_behaves_like "runs inside a non-main Ractor", inputs: [{ id: 1 }]
52
+ end
53
+ ```
54
+
55
+ | Example | Options | Fails when | Skipped when |
56
+ |---|---|---|---|
57
+ | `"thread-safe under contention"` | `threads:` (8), `iterations:` (50), `consistent:` | a call raises, or the results disagree | — |
58
+ | `"fiber-safe under an Async reactor"` | `fibers:` (8), `iterations:` (50), `consistent:` | a call raises inside an `Async` task, or the results disagree | `async` is not loadable |
59
+ | `"a boot-finalized configuration"` | `readers:`, `finalize:` | a reader returns a value that is not `Ractor.shareable?` (or raises) | no `Ractor` (TruffleRuby, JRuby) |
60
+ | `"runs inside a non-main Ractor"` (tagged `:ractor`) | `inputs:` ([]) | the block raises inside `Ractor.new`, captures outer variables, or returns something else than on the main Ractor | below Ruby 4.0 (`Ractor#value`) |
61
+
62
+ - `consistent:` is opt-in: `:identical` (every result is the same object, e.g. a memoized cache), `:equal` (all `==`), or a callable that receives every result and returns truthy.
63
+ - `readers:` takes `Method` objects (reported as `Owner.reader`), a `{ "name" => callable }` Hash, or bare callables (reported by source location).
64
+ - The Ractor block must be capture-free: pass values through `inputs:`, reach everything else through constants. Before 4.0 a non-main Ractor cannot autoload, so load what it touches first.
65
+
66
+ Failures name the culprit, e.g.
67
+
68
+ ```
69
+ a boot-finalized configuration: 1 of 2 readers returned a value that is not Ractor-shareable:
70
+ MyGem.colors returned Hash: [:primary] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
71
+ ```
72
+
73
+ ## Assertions (Minitest)
74
+
75
+ ```ruby
76
+ require "minitest/autorun" # the kit never loads Minitest itself
77
+ require "conformance_kit/minitest"
78
+
79
+ class CacheTest < Minitest::Test
80
+ include ConformanceKit::Minitest
81
+
82
+ def test_thread_safe = assert_thread_safe(threads: 8, iterations: 50, consistent: :identical) { MyGem::Cache.for(:key) }
83
+ def test_fiber_safe = assert_fiber_safe(fibers: 8) { MyGem::Cache.for(:key) }
84
+ def test_boot_finalized = assert_boot_finalized(readers: [MyGem.method(:colors)], finalize: -> { MyGem.finalize! })
85
+ def test_ractor = assert_runs_in_ractor(inputs: [2]) { |n| MyGem.double(n) }
86
+ end
87
+ ```
88
+
89
+ Same checks, same options and messages; skips use `skip` with the reason. Works on Minitest 5 and 6.
90
+
91
+ ## `ConformanceKit::Store`
92
+
93
+ Per-Ractor lazy storage, the home for caches that must not live in a class ivar or a constant:
94
+
95
+ ```ruby
96
+ def self.cache = ConformanceKit::Store.fetch(:my_gem_cache) { {} }
97
+ ```
98
+
99
+ On Ruby 3.4+ this is `Ractor.store_if_absent` (atomic, one value per Ractor). Below 3.4 the main Ractor (and an engine without `Ractor`) uses one Mutex-guarded Hash, and a non-main Ractor keeps its own values in Ractor-local storage, unlocked: two threads of that one Ractor can race to build a value, as with `||=`. Keys are Symbols; prefix them with your gem's name.
100
+
101
+ ## `conformance-audit`
102
+
103
+ ```bash
104
+ bundle exec conformance-audit --require ./lib/my_gem --namespace MyGem --strict
105
+ ```
106
+
107
+ Requires the files, walks every loaded module under the namespaces (`--require` and `--namespace` repeat), 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:
108
+
109
+ ```
110
+ conformance-audit: 2 findings in MyGem (1 allowlisted main-only; 14 modules audited, 0 unloaded autoloads skipped)
111
+ MyGem::LOCK (constant) Thread::Mutex: a Thread::Mutex is main-only: keep per-Ractor state in ConformanceKit::Store, or allowlist it main_only
112
+ MyGem::Config.@colors (module ivar) Hash: [:primary] is an Array that is not frozen: freeze it at boot (Ractor.make_shareable)
113
+ ```
114
+
115
+ Limits, so a green run means what it says:
116
+
117
+ - Unloaded autoload constants are skipped and listed, never loaded mid-walk: require (or eager-load) everything the audit should see. The header counts the modules audited and the autoloads skipped.
118
+ - `private_constant`s are invisible to it (Ruby has no public way to enumerate them). `config/rubocop.yml` and the boot-finalized example are the gates for those.
119
+
120
+ | Exit | Meaning |
121
+ |---|---|
122
+ | 0 | no unallowlisted findings; or findings without `--strict` (report only); or no `Ractor` on this engine |
123
+ | 1 | unallowlisted findings under `--strict` |
124
+ | 2 | usage error, a namespace that is not defined or not a module, a file that cannot be required, or an invalid allowlist |
125
+
126
+ ### `.conformance.yml`
127
+
128
+ State that is deliberately main-only, read from `.conformance.yml` in the working directory (or `--allowlist PATH`). Every entry needs a `reason:`; an entry without one is an error (exit 2).
129
+
130
+ ```yaml
131
+ main_only:
132
+ - name: MyGem::Registry.@lock # class/module ivar: Owner.@ivar
133
+ reason: guards boot-time registration; no non-main Ractor ever registers
134
+ - name: MyGem::LOCK # constant: Owner::NAME
135
+ reason: ...
136
+ ```
137
+
138
+ Entries inside the audited namespaces that no longer turn up are listed as stale.
139
+
140
+ ## RuboCop
141
+
142
+ ```yaml
143
+ # .rubocop.yml
144
+ inherit_gem:
145
+ conformance-kit: config/rubocop.yml
146
+ ```
147
+
148
+ Turns on the six `rubocop-thread_safety` cops (`ClassAndModuleAttributes`, `ClassInstanceVariable`, `MutableClassInstanceVariable` with `EnforcedStyle: literals`, `NewThread`, `DirChdir`, `RackMiddlewareInstanceVariable`) plus `LazySynchronizationPrimitive` (which needs rubocop-thread_safety 0.8+). Exclude a file or line only with a comment saying why it is safe.
149
+
150
+ `config/rubocop_spinel.yml` runs only `Spinel/Unsupported` (rubocop_spinel). It is for the report-only probe, never your lint: it flags `class << self`.
151
+
152
+ ## Reusable workflows
153
+
154
+ ```yaml
155
+ # .github/workflows/main.yml
156
+ jobs:
157
+ ractor:
158
+ uses: zoolutions/conformance-kit/.github/workflows/ractor.yml@main
159
+ with:
160
+ require: ./lib/my_gem
161
+ namespace: MyGem
162
+
163
+ probes:
164
+ uses: zoolutions/conformance-kit/.github/workflows/probes.yml@main
165
+ with:
166
+ gem-name: my_gem
167
+ ```
168
+
169
+ **`ractor.yml`** (the Tier 3 gate): the Ractor examples, then `conformance-audit --strict`.
170
+
171
+ | Input | Default | |
172
+ |---|---|---|
173
+ | `require` | required | files the audit requires (space-separated) |
174
+ | `namespace` | required | constants to audit (space-separated) |
175
+ | `ruby-version` | `4.0` | |
176
+ | `allowlist` | `.conformance.yml` | skipped when the default file is absent |
177
+ | `test-command` | `bundle exec rspec --tag ractor` | Minitest gems pass their own |
178
+ | `audit` | `true` | `false` runs only the examples |
179
+ | `working-directory` | `.` | |
180
+
181
+ **`probes.yml`** (Tier 4, report-only): `rubocop_spinel`, `spinel-compat probe <gem-name> --dir .` and, when `roundhouse-app` is set, `roundhouse check`. Every probe step continues on error; the reports go to the run summary and an artifact. It never fails a build.
182
+
183
+ | Input | Default | |
184
+ |---|---|---|
185
+ | `gem-name` | required | the gem spinel-compat probes from the checkout |
186
+ | `ruby-version` | `4.0` | |
187
+ | `paths` | `lib` | what rubocop_spinel inspects (space-separated) |
188
+ | `roundhouse-app` | `""` | a Rails app directory (e.g. `spec/dummy`); empty skips it |
189
+ | `roundhouse-version` | `v2026.9.18` | the rubys/roundhouse release to install |
190
+ | `roundhouse-sha256` | SHA-256 of that release's `roundhouse-x86_64-unknown-linux-musl.tar.xz` | checked before the binary runs; change it with `roundhouse-version` |
191
+ | `artifact-name` | `conformance-probes` | |
192
+ | `working-directory` | `.` | |
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ bundle exec rake # rspec + rubocop; CI runs Ruby 3.2, 3.4 and 4.0
198
+ ```
199
+
200
+ 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`) and calls its own workflows in CI.
@@ -0,0 +1,35 @@
1
+ # conformance-kit's Tier 1 RuboCop gate. In a consuming gem's .rubocop.yml:
2
+ #
3
+ # inherit_gem:
4
+ # conformance-kit: config/rubocop.yml
5
+ #
6
+ # and add `gem "rubocop-thread_safety", require: false` to its Gemfile (the kit
7
+ # never adds dependencies for you). Exclude a file or line only with a comment
8
+ # saying why it is safe, e.g. a Mutex created at load and only used on the main
9
+ # Ractor, or the specs that start threads on purpose.
10
+ plugins:
11
+ - rubocop-thread_safety
12
+
13
+ # The six cops of the epic's Tier 1, on whatever the consumer's NewCops is.
14
+ ThreadSafety/ClassAndModuleAttributes:
15
+ Enabled: true
16
+
17
+ ThreadSafety/ClassInstanceVariable:
18
+ Enabled: true
19
+
20
+ ThreadSafety/MutableClassInstanceVariable:
21
+ Enabled: true
22
+ EnforcedStyle: literals
23
+
24
+ ThreadSafety/NewThread:
25
+ Enabled: true
26
+
27
+ ThreadSafety/DirChdir:
28
+ Enabled: true
29
+
30
+ ThreadSafety/RackMiddlewareInstanceVariable:
31
+ Enabled: true
32
+
33
+ # `@mutex ||= Mutex.new` is itself a race: two threads can build two locks.
34
+ ThreadSafety/LazySynchronizationPrimitive:
35
+ Enabled: true
@@ -0,0 +1,16 @@
1
+ # conformance-kit's report-only Spinel probe (Tier 4): only the Spinel cops,
2
+ # never a gate. Run by .github/workflows/probes.yml as
3
+ #
4
+ # rubocop --config <conformance-kit>/config/rubocop_spinel.yml lib
5
+ #
6
+ # It flags `class << self`, which every zoolutions gem uses: read the report,
7
+ # don't chase it.
8
+ plugins:
9
+ - rubocop_spinel
10
+
11
+ AllCops:
12
+ DisabledByDefault: true
13
+ SuggestExtensions: false
14
+
15
+ Spinel/Unsupported:
16
+ Enabled: true
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/conformance_kit/audit"
5
+
6
+ exit ConformanceKit::Audit::CLI.run(ARGV)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module ConformanceKit
6
+ class Audit
7
+ class AllowlistError < StandardError; end
8
+
9
+ # `.conformance.yml`: state that is deliberately main-only, each entry with
10
+ # the reason it is safe.
11
+ #
12
+ # main_only:
13
+ # - name: MyGem::Registry.@lock # a class/module ivar: Owner.@ivar
14
+ # reason: guards boot-time registration; never touched by a non-main Ractor
15
+ # - name: MyGem::LOCK # a constant: Owner::NAME
16
+ # reason: ...
17
+ class Allowlist
18
+ def self.empty = new([])
19
+
20
+ def self.load(path)
21
+ data = YAML.safe_load_file(path) || {}
22
+ raise AllowlistError, "#{path}: expected a mapping with a main_only: list" unless data.is_a?(Hash)
23
+
24
+ new(data.fetch("main_only", []) || [], source: path)
25
+ rescue Psych::Exception => e
26
+ raise AllowlistError, "#{path}: #{e.message}"
27
+ end
28
+
29
+ attr_reader :entries
30
+
31
+ def initialize(entries, source: "the allowlist")
32
+ raise AllowlistError, "#{source}: main_only: must be a list" unless entries.is_a?(Array)
33
+
34
+ @entries = entries.map { validate(_1, source) }
35
+ end
36
+
37
+ def names = entries.map { _1["name"] }
38
+ def include?(name) = names.include?(name)
39
+ def reason_for(name) = entries.find { _1["name"] == name }&.fetch("reason")
40
+
41
+ private
42
+
43
+ def validate(entry, source)
44
+ unless entry.is_a?(Hash)
45
+ raise AllowlistError,
46
+ "#{source}: each main_only entry needs a name: and a reason:, got #{entry.inspect}"
47
+ end
48
+
49
+ name = entry["name"]
50
+ raise AllowlistError, "#{source}: a main_only entry has no name: (#{entry.inspect})" if blank?(name)
51
+ if blank?(entry["reason"])
52
+ raise AllowlistError,
53
+ "#{source}: #{name} has no reason: (say why it is safe main-only)"
54
+ end
55
+
56
+ entry
57
+ end
58
+
59
+ def blank?(value) = !value.is_a?(String) || value.strip.empty?
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module ConformanceKit
6
+ class Audit
7
+ # `conformance-audit --require FILE --namespace CONST [--strict] [--allowlist PATH]`
8
+ #
9
+ # Exit codes: 0 clean (or report-only, or no Ractor on this engine),
10
+ # 1 unallowlisted findings under --strict, 2 usage or allowlist error.
11
+ class CLI
12
+ DEFAULT_ALLOWLIST = ".conformance.yml"
13
+ USAGE_ERROR = 2
14
+
15
+ def self.run(argv, out: $stdout, err: $stderr) = new(out, err).run(argv)
16
+
17
+ def initialize(out, err)
18
+ @out = out
19
+ @err = err
20
+ end
21
+
22
+ def run(argv)
23
+ options = parse(argv)
24
+ return unavailable unless defined?(::Ractor)
25
+
26
+ options[:requires].each { require File.expand_path(_1) }
27
+ options[:namespaces].each { check_namespace(_1) }
28
+ audit = Audit.new(namespaces: options[:namespaces], allowlist: allowlist(options[:allowlist]))
29
+ report(audit, options[:strict])
30
+ rescue OptionParser::ParseError, AllowlistError, LoadError, NameError, ArgumentError => e
31
+ @err.puts "conformance-audit: #{e.message}"
32
+ USAGE_ERROR
33
+ end
34
+
35
+ private
36
+
37
+ def parse(argv)
38
+ options = { requires: [], namespaces: [], strict: false, allowlist: nil }
39
+ parser(options).parse!(argv.dup)
40
+ raise OptionParser::MissingArgument, "--namespace (the constant to audit)" if options[:namespaces].empty?
41
+
42
+ options
43
+ end
44
+
45
+ def parser(options)
46
+ OptionParser.new do |o|
47
+ o.banner = "Usage: conformance-audit --require FILE --namespace CONST [--strict] [--allowlist PATH]"
48
+ o.on("--require FILE", "Require FILE before auditing (repeatable)") { options[:requires] << _1 }
49
+ o.on("--namespace CONST", "Audit CONST and everything under it (repeatable)") do |name|
50
+ options[:namespaces] << name.delete_prefix("::")
51
+ end
52
+ o.on("--strict", "Exit 1 on any unallowlisted finding") { options[:strict] = true }
53
+ o.on("--allowlist PATH", "main_only: allowlist (default: #{DEFAULT_ALLOWLIST} if present)") do |path|
54
+ options[:allowlist] = path
55
+ end
56
+ end
57
+ end
58
+
59
+ def check_namespace(name)
60
+ value = Object.const_get(name)
61
+ raise ArgumentError, "--namespace #{name} is a #{value.class}, not a module" unless value.is_a?(Module)
62
+ end
63
+
64
+ def allowlist(path)
65
+ return Allowlist.load(path) if path
66
+ return Allowlist.load(DEFAULT_ALLOWLIST) if File.exist?(DEFAULT_ALLOWLIST)
67
+
68
+ Allowlist.empty
69
+ rescue Errno::ENOENT
70
+ raise AllowlistError, "allowlist #{path} does not exist"
71
+ end
72
+
73
+ def unavailable
74
+ @out.puts "conformance-audit: Ractor is unavailable on #{RUBY_ENGINE} #{RUBY_VERSION}; nothing to audit."
75
+ 0
76
+ end
77
+
78
+ def report(audit, strict)
79
+ findings = audit.findings
80
+ allowlisted = audit.allowlisted
81
+ @out.puts "conformance-audit: #{findings.size} findings in #{audit.namespaces.join(', ')} " \
82
+ "(#{allowlisted.size} allowlisted main-only; #{audit.modules_audited} modules audited, " \
83
+ "#{audit.skipped_autoloads.size} unloaded autoloads skipped)"
84
+ findings.each { @out.puts " #{_1}" }
85
+ section("allowlisted main-only:", allowlisted.map { "#{_1.name} — #{audit.allowlist.reason_for(_1.name)}" })
86
+ section("stale allowlist entries (no longer found):", audit.stale)
87
+ section("unloaded autoloads, not audited (require or eager-load them):", audit.skipped_autoloads)
88
+ return 0 if findings.empty?
89
+ return 1 if strict
90
+
91
+ @out.puts "report only: --strict fails on these"
92
+ 0
93
+ end
94
+
95
+ def section(title, lines)
96
+ return if lines.empty?
97
+
98
+ @out.puts title
99
+ lines.each { @out.puts " #{_1}" }
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "conformance_kit"
4
+ require_relative "audit/allowlist"
5
+ require_relative "audit/cli"
6
+
7
+ module ConformanceKit
8
+ # Walks every loaded module under the given namespaces and reports each
9
+ # constant and class/module instance variable whose value is not
10
+ # Ractor-shareable. Unloaded autoload constants are skipped: require (or
11
+ # eager-load) everything the audit should see.
12
+ class Audit
13
+ Finding = Data.define(:name, :kind, :value_class, :path, :reason) do
14
+ def to_s
15
+ where = path.empty? ? "" : " #{path} is"
16
+ "#{name} (#{kind}) #{value_class}:#{where} #{reason}"
17
+ end
18
+ end
19
+
20
+ attr_reader :namespaces, :allowlist
21
+
22
+ def initialize(namespaces:, allowlist: Allowlist.empty)
23
+ @namespaces = namespaces
24
+ @allowlist = allowlist
25
+ end
26
+
27
+ def findings = partitioned.first
28
+ def allowlisted = partitioned.last
29
+
30
+ def modules_audited = audited_modules.size
31
+
32
+ # Constants declared with `autoload` but never loaded: not audited.
33
+ def skipped_autoloads
34
+ audited_modules.flat_map do |mod, name|
35
+ constants_of(mod).select { autoload?(mod, _1) }.map { "#{name}::#{_1}" }
36
+ end
37
+ end
38
+
39
+ # Allowlisted names inside the audited namespaces that no longer turn up.
40
+ def stale
41
+ found = scan.map(&:name)
42
+ @allowlist.names.select { in_namespace?(_1) && !found.include?(_1) }
43
+ end
44
+
45
+ private
46
+
47
+ def partitioned
48
+ @partitioned ||= scan.partition { !@allowlist.include?(_1.name) }
49
+ end
50
+
51
+ def scan
52
+ @scan ||= audited_modules.flat_map { |mod, name| constant_findings(mod, name) + ivar_findings(mod, name) }
53
+ .sort_by(&:name)
54
+ end
55
+
56
+ def audited_modules
57
+ @audited_modules ||= ObjectSpace.each_object(Module).filter_map do |mod|
58
+ next if mod.singleton_class?
59
+
60
+ name = ::Module.instance_method(:name).bind_call(mod) # a class may override .name
61
+ [mod, name] if name && in_namespace?(name)
62
+ end
63
+ end
64
+
65
+ def in_namespace?(name)
66
+ namespaces.any? { |namespace| name == namespace || name.start_with?("#{namespace}::", "#{namespace}.") }
67
+ end
68
+
69
+ def constant_findings(mod, name)
70
+ constants_of(mod).filter_map do |constant|
71
+ next if autoload?(mod, constant)
72
+
73
+ finding("#{name}::#{constant}", "constant", core(::Module, :const_get, mod, constant, false))
74
+ end
75
+ end
76
+
77
+ def ivar_findings(mod, name)
78
+ core(::Kernel, :instance_variables, mod).filter_map do |ivar|
79
+ kind = mod.is_a?(Class) ? "class ivar" : "module ivar"
80
+ finding("#{name}.#{ivar}", kind, core(::Kernel, :instance_variable_get, mod, ivar))
81
+ end
82
+ end
83
+
84
+ # The core reflection methods, bound directly: a module may override its own
85
+ # `.constants` or `.instance_variables` and hide state from the audit.
86
+ def core(owner, method, receiver, *) = owner.instance_method(method).bind_call(receiver, *)
87
+ def constants_of(mod) = core(::Module, :constants, mod, false)
88
+ def autoload?(mod, constant) = core(::Module, :autoload?, mod, constant)
89
+
90
+ def finding(name, kind, value)
91
+ culprit = Shareability.culprit(value)
92
+ return nil unless culprit
93
+
94
+ Finding.new(name: name, kind: kind, value_class: value.class.name, path: culprit.path, reason: culprit.reason)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ module Checks
5
+ # "a boot-finalized configuration": after `finalize`, every reader returns a
6
+ # Ractor-shareable value. Readers are `Method` objects (named
7
+ # `Owner.reader`), a `{ "name" => callable }` Hash, or bare callables (named
8
+ # by source location).
9
+ module BootFinalized
10
+ NAME = "a boot-finalized configuration"
11
+
12
+ class << self
13
+ def call(readers:, finalize: nil)
14
+ raise ArgumentError, "readers: is empty, so #{NAME} would check nothing" if readers.nil? || readers.empty?
15
+
16
+ unless Shareability.available?
17
+ return Result.skip("#{NAME} needs Ractor.shareable?, which #{RUBY_ENGINE} #{RUBY_VERSION} lacks")
18
+ end
19
+
20
+ finalize&.call
21
+ named = name_readers(readers)
22
+ offenses = named.filter_map { |name, reader| offense(name, reader) }
23
+ return Result.pass if offenses.empty?
24
+
25
+ Result.fail("#{NAME}: #{offenses.size} of #{named.size} readers returned a value " \
26
+ "that is not Ractor-shareable:\n#{offenses.map { " #{_1}" }.join("\n")}")
27
+ end
28
+
29
+ private
30
+
31
+ def offense(name, reader)
32
+ value = reader.call
33
+ culprit = Shareability.culprit(value)
34
+ return nil unless culprit
35
+
36
+ where = culprit.path.empty? ? "it is" : "#{culprit.path} is"
37
+ "#{name} returned #{value.class}: #{where} #{culprit.reason}"
38
+ rescue StandardError => e
39
+ "#{name} raised #{e.class}: #{e.message}"
40
+ end
41
+
42
+ def name_readers(readers)
43
+ return readers.map { |name, reader| [name.to_s, reader] } if readers.is_a?(::Hash)
44
+
45
+ Array(readers).map { |reader| [reader_name(reader), reader] }
46
+ end
47
+
48
+ def reader_name(reader)
49
+ case reader
50
+ when ::Method
51
+ receiver = reader.receiver
52
+ receiver.is_a?(::Module) ? "#{receiver.name}.#{reader.name}" : "#{receiver.class.name}##{reader.name}"
53
+ when ::Proc
54
+ file, line = reader.source_location
55
+ "the reader at #{file}:#{line}"
56
+ else reader.inspect
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ module Checks
5
+ # The outcome reporting shared by the thread and fiber checks: exceptions,
6
+ # and the `consistent:` option.
7
+ module Consistency
8
+ MODES = %i[identical equal].freeze
9
+ # How many distinct results a failure report tells apart before it stops counting.
10
+ DIAGNOSED = 10
11
+
12
+ class << self
13
+ def validate_counts!(**counts)
14
+ counts.each do |name, count|
15
+ next if count.is_a?(Integer) && count.positive?
16
+
17
+ raise ArgumentError, "#{name}: must be a positive Integer (got #{count.inspect}); " \
18
+ "zero would pass without a single call"
19
+ end
20
+ end
21
+
22
+ def validate!(consistent)
23
+ return if consistent.nil? || consistent.respond_to?(:call) || MODES.include?(consistent)
24
+
25
+ raise ArgumentError, "consistent: must be :identical, :equal or a callable taking every result, " \
26
+ "got #{consistent.inspect}"
27
+ end
28
+
29
+ # nil when consistent, a failure description otherwise.
30
+ def problem(results, consistent)
31
+ case consistent
32
+ when nil then nil
33
+ when :identical then distinct(results.uniq(&:__id__), consistent)
34
+ when :equal then distinct(uniq_by_equality(results), consistent)
35
+ else "the `consistent:` check returned false for #{results.size} results" unless consistent.call(results)
36
+ end
37
+ end
38
+
39
+ def raised(errors, total, unit)
40
+ first = errors.first
41
+ where = first.backtrace ? " (#{first.backtrace.first})" : ""
42
+ "#{errors.size} of #{total} #{unit} raised; first: #{first.class}: #{first.message}#{where}"
43
+ end
44
+
45
+ private
46
+
47
+ # Array#uniq compares with eql?/hash (1 and 1.0 differ); :equal promises ==. Stops past
48
+ # DIAGNOSED distinct values, so N different results cost O(N) == calls, not O(N²).
49
+ def uniq_by_equality(results)
50
+ results.each_with_object([]) do |result, seen|
51
+ next if seen.any? { |s| s == result }
52
+
53
+ seen << result
54
+ break seen if seen.size > DIAGNOSED
55
+ end
56
+ end
57
+
58
+ def distinct(unique, mode)
59
+ return nil if unique.size <= 1
60
+
61
+ count = unique.size > DIAGNOSED ? "more than #{DIAGNOSED}" : unique.size
62
+ classes = unique.group_by { _1.class.name }.map { |name, all| "#{name} ×#{all.size}" }.join(", ")
63
+ "returned #{count} distinct results (#{classes}), so the `consistent: #{mode.inspect}` check failed"
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ module Checks
5
+ # "fiber-safe under an Async reactor": `fibers` Async tasks, each making
6
+ # `iterations` calls. Needs the `async` gem, which is never a dependency of
7
+ # the kit: skipped with a reason when it cannot be loaded.
8
+ module FiberReactor
9
+ NAME = "fiber-safe under an Async reactor"
10
+
11
+ class << self
12
+ def call(fibers: 8, iterations: 50, consistent: nil, loader: -> { require "async" }, &subject)
13
+ Consistency.validate_counts!(fibers: fibers, iterations: iterations)
14
+ Consistency.validate!(consistent)
15
+ begin
16
+ loader.call
17
+ rescue LoadError => e
18
+ return Result.skip("#{NAME} needs the async gem, which is not loadable (#{e.message}): " \
19
+ "add `gem \"async\"` to the Gemfile's test group to run it")
20
+ end
21
+
22
+ verdict(run(fibers, iterations, subject), fibers, iterations, consistent)
23
+ end
24
+
25
+ private
26
+
27
+ def run(fibers, iterations, subject)
28
+ Async do |task|
29
+ tasks = Array.new(fibers) do
30
+ task.async do
31
+ Array.new(iterations) { subject.call }
32
+ rescue StandardError => e
33
+ e
34
+ end
35
+ end
36
+ tasks.map(&:wait)
37
+ end.wait
38
+ end
39
+
40
+ def verdict(outcomes, fibers, iterations, consistent)
41
+ errors = outcomes.grep(Exception)
42
+ return Result.fail("#{NAME}: #{Consistency.raised(errors, fibers, 'fibers')}") if errors.any?
43
+
44
+ problem = Consistency.problem(outcomes.flatten(1), consistent)
45
+ problem ? Result.fail("#{NAME}: #{fibers} fibers × #{iterations} calls #{problem}") : Result.pass
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ module Checks
5
+ # "runs inside a non-main Ractor": runs a capture-free block on the main
6
+ # Ractor and inside `Ractor.new(*inputs)`, and compares the two results.
7
+ # Ruby 4.0+ only (`Ractor#value`); skipped with a reason below.
8
+ module RactorCanary
9
+ NAME = "runs inside a non-main Ractor"
10
+
11
+ class << self
12
+ def call(inputs: [], &block)
13
+ return Result.skip(unavailable) unless available?
14
+
15
+ ractor = ::Ractor.new(*inputs, &block) # copies the inputs before the main run can mutate them
16
+ expected = block.call(*inputs)
17
+ actual = ractor.value
18
+ return Result.pass if actual == expected
19
+
20
+ Result.fail("#{NAME}: returned #{actual.inspect} inside the Ractor " \
21
+ "but #{expected.inspect} on the main Ractor")
22
+ rescue ::Ractor::RemoteError => e
23
+ cause = e.cause || e
24
+ Result.fail("#{NAME}: raised #{cause.class} inside the Ractor: #{cause.message}")
25
+ rescue ::Ractor::IsolationError, ::ArgumentError => e
26
+ raise unless e.message.include?("isolate")
27
+
28
+ Result.fail("#{NAME}: the block must be capture-free (pass values through inputs:): #{e.message}")
29
+ end
30
+
31
+ private
32
+
33
+ def available? = defined?(::Ractor) && ::Ractor.method_defined?(:value)
34
+
35
+ def unavailable
36
+ "#{NAME} needs Ruby 4.0+ (Ractor#value); running #{RUBY_ENGINE} #{RUBY_VERSION}"
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ module Checks
5
+ # "thread-safe under contention": `threads` threads released together, each
6
+ # making `iterations` calls. Fails on any exception, or when the results
7
+ # disagree under `consistent:`.
8
+ module ThreadContention
9
+ NAME = "thread-safe under contention"
10
+
11
+ class << self
12
+ def call(threads: 8, iterations: 50, consistent: nil, &subject)
13
+ Consistency.validate_counts!(threads: threads, iterations: iterations)
14
+ Consistency.validate!(consistent)
15
+ outcomes = run(threads, iterations, subject)
16
+ errors = outcomes.grep(Exception)
17
+ return Result.fail("#{NAME}: #{Consistency.raised(errors, threads, 'threads')}") if errors.any?
18
+
19
+ problem = Consistency.problem(outcomes.flatten(1), consistent)
20
+ problem ? Result.fail("#{NAME}: #{threads} threads × #{iterations} calls #{problem}") : Result.pass
21
+ end
22
+
23
+ private
24
+
25
+ def run(threads, iterations, subject)
26
+ start = Queue.new
27
+ workers = Array.new(threads) do
28
+ Thread.new do # rubocop:disable ThreadSafety/NewThread -- contention is the point of the check
29
+ start.pop
30
+ Array.new(iterations) { subject.call }
31
+ rescue StandardError => e
32
+ e
33
+ end
34
+ end
35
+ threads.times { start << true }
36
+ workers.map(&:value)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "conformance_kit"
4
+
5
+ module ConformanceKit
6
+ # The Minitest front end: the same four checks as assertions.
7
+ #
8
+ # class CacheTest < Minitest::Test
9
+ # include ConformanceKit::Minitest
10
+ #
11
+ # def test_cache_is_thread_safe
12
+ # assert_thread_safe(threads: 8, iterations: 50, consistent: :identical) { MyGem.cache_for(:key) }
13
+ # end
14
+ # end
15
+ #
16
+ # Uses only `assert` and `skip`, so it works on Minitest 5 and 6.
17
+ module Minitest
18
+ def assert_thread_safe(threads: 8, iterations: 50, consistent: nil, &)
19
+ conformance_enforce(Checks::ThreadContention.call(
20
+ threads: threads, iterations: iterations, consistent: consistent, &
21
+ ))
22
+ end
23
+
24
+ def assert_fiber_safe(fibers: 8, iterations: 50, consistent: nil, &)
25
+ conformance_enforce(Checks::FiberReactor.call(fibers: fibers, iterations: iterations, consistent: consistent, &))
26
+ end
27
+
28
+ def assert_boot_finalized(readers:, finalize: nil)
29
+ conformance_enforce(Checks::BootFinalized.call(readers: readers, finalize: finalize))
30
+ end
31
+
32
+ def assert_runs_in_ractor(inputs: [], &)
33
+ conformance_enforce(Checks::RactorCanary.call(inputs: inputs, &))
34
+ end
35
+
36
+ private
37
+
38
+ def conformance_enforce(result)
39
+ skip(result.message) if result.skipped?
40
+ assert(result.passed?, result.message)
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ # What a check returns; the RSpec and Minitest front ends turn it into a
5
+ # pass, a failure or a skip.
6
+ Result = Data.define(:status, :message) do
7
+ def self.pass(message = "ok") = new(status: :passed, message: message)
8
+ def self.fail(message) = new(status: :failed, message: message)
9
+ def self.skip(message) = new(status: :skipped, message: message)
10
+
11
+ def passed? = status == :passed
12
+ def failed? = status == :failed
13
+ def skipped? = status == :skipped
14
+ end
15
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require "conformance_kit"
5
+
6
+ module ConformanceKit
7
+ # The RSpec front end: `require "conformance_kit/rspec"` registers four shared
8
+ # examples. Each example group defines `subject_call`, a callable for the
9
+ # operation under test (not needed by "a boot-finalized configuration").
10
+ #
11
+ # let(:subject_call) { -> { MyGem.cache_for(:key) } }
12
+ # it_behaves_like "thread-safe under contention", threads: 8, iterations: 50, consistent: :identical
13
+ module RSpec
14
+ class << self
15
+ def register!
16
+ register_thread_example
17
+ register_fiber_example
18
+ register_boot_example
19
+ register_ractor_example
20
+ end
21
+
22
+ def enforce(example, result)
23
+ return if result.passed?
24
+ return example.skip(result.message) if result.skipped?
25
+
26
+ raise failure_class, result.message
27
+ end
28
+
29
+ private
30
+
31
+ def failure_class
32
+ return RuntimeError unless defined?(::RSpec::Expectations::ExpectationNotMetError)
33
+
34
+ ::RSpec::Expectations::ExpectationNotMetError
35
+ end
36
+
37
+ def register_thread_example
38
+ ::RSpec.shared_examples "thread-safe under contention" do |threads: 8, iterations: 50, consistent: nil|
39
+ it "survives #{threads} threads × #{iterations} calls" do
40
+ options = { threads: threads, iterations: iterations, consistent: consistent }
41
+ ConformanceKit::RSpec.enforce(self, Checks::ThreadContention.call(**options, &subject_call))
42
+ end
43
+ end
44
+ end
45
+
46
+ def register_fiber_example
47
+ ::RSpec.shared_examples "fiber-safe under an Async reactor" do |fibers: 8, iterations: 50, consistent: nil|
48
+ it "survives #{fibers} fibers × #{iterations} calls in one reactor" do
49
+ options = { fibers: fibers, iterations: iterations, consistent: consistent }
50
+ ConformanceKit::RSpec.enforce(self, Checks::FiberReactor.call(**options, &subject_call))
51
+ end
52
+ end
53
+ end
54
+
55
+ def register_boot_example
56
+ ::RSpec.shared_examples "a boot-finalized configuration" do |readers:, finalize: nil|
57
+ it "returns a Ractor-shareable value from every reader after finalize" do
58
+ ConformanceKit::RSpec.enforce(self, Checks::BootFinalized.call(readers: readers, finalize: finalize))
59
+ end
60
+ end
61
+ end
62
+
63
+ def register_ractor_example
64
+ ::RSpec.shared_examples "runs inside a non-main Ractor" do |inputs: []|
65
+ it "returns the main Ractor's result from inside Ractor.new", :ractor do
66
+ ConformanceKit::RSpec.enforce(self, Checks::RactorCanary.call(inputs: inputs, &subject_call))
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ ConformanceKit::RSpec.register!
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ # Finds WHY a value is not Ractor-shareable: the first offending object on a
5
+ # depth-first walk (followed as deep as it goes), the path to it, and the fix.
6
+ # Works on any value, BasicObject included: it only calls core methods.
7
+ module Shareability
8
+ Culprit = Data.define(:path, :object_class, :reason)
9
+
10
+ LOCKS = %w[Thread::Mutex Monitor Thread::Queue Thread::SizedQueue Thread::ConditionVariable].freeze
11
+ MAX_DEPTH = 32
12
+
13
+ class << self
14
+ def available? = defined?(::Ractor) ? ::Ractor.respond_to?(:shareable?) : false
15
+
16
+ # nil when the value is shareable, a Culprit otherwise.
17
+ def culprit(value)
18
+ return nil if ::Ractor.shareable?(value)
19
+
20
+ find(value, "", {}, 0)
21
+ end
22
+
23
+ private
24
+
25
+ def find(value, path, seen, depth)
26
+ return nil if ::Ractor.shareable?(value) || seen.key?(value.__id__)
27
+ return blame(value, path) if intrinsic?(value) || depth >= MAX_DEPTH
28
+
29
+ seen[value.__id__] = true
30
+ # Freezing an unfrozen container would not fix a lock or a Proc inside it.
31
+ return intrinsic_below(value, path, seen, depth) || blame(value, path) unless frozen?(value)
32
+
33
+ children(value).each do |child_path, child|
34
+ found = find(child, path + child_path, seen, depth + 1)
35
+ return found if found
36
+ end
37
+ blame(value, path)
38
+ end
39
+
40
+ def intrinsic?(value) = callable?(value) || lock?(value) || concurrent?(value)
41
+
42
+ # The first never-shareable object (lock, Proc, Concurrent::*) under value.
43
+ def intrinsic_below(value, path, seen, depth)
44
+ return nil if depth >= MAX_DEPTH
45
+
46
+ children(value).each do |child_path, child|
47
+ next if ::Ractor.shareable?(child) || seen.key?(child.__id__)
48
+ return blame(child, path + child_path) if intrinsic?(child)
49
+
50
+ seen[child.__id__] = true
51
+ found = intrinsic_below(child, path + child_path, seen, depth + 1)
52
+ return found if found
53
+ end
54
+ nil
55
+ end
56
+
57
+ def children(value)
58
+ pairs = core(:instance_variables, value).map { |ivar| [".#{ivar}", core(:instance_variable_get, value, ivar)] }
59
+ case value
60
+ when ::Hash then pairs + hash_children(value)
61
+ when ::Array then pairs + value.each_with_index.map { |v, i| ["[#{i}]", v] }
62
+ when ::Struct, ::Data then pairs + value.to_h.map { |k, v| [".#{k}", v] }
63
+ when ::Range then pairs + [[".begin", value.begin], [".end", value.end]]
64
+ else pairs
65
+ end
66
+ end
67
+
68
+ def hash_children(hash)
69
+ pairs = hash.flat_map { |k, v| [["{key #{label(k)}}", k], ["[#{label(k)}]", v]] }
70
+ return pairs + [[".default_proc", hash.default_proc]] if hash.default_proc
71
+
72
+ hash.default.nil? ? pairs : pairs + [[".default", hash.default]]
73
+ end
74
+
75
+ def blame(value, path)
76
+ Culprit.new(path: path, object_class: class_name(value), reason: reason(value))
77
+ end
78
+
79
+ def reason(value)
80
+ name = class_name(value)
81
+ if callable?(value)
82
+ "#{article(name)} #{name} is never shareable: use Ractor.shareable_proc (Ruby 4.0) " \
83
+ "or Ractor.make_shareable on a capture-free proc"
84
+ elsif lock?(value) || concurrent?(value)
85
+ "#{article(name)} #{name} is main-only: keep per-Ractor state in ConformanceKit::Store, " \
86
+ "or allowlist it main_only"
87
+ elsif !frozen?(value)
88
+ "#{article(name)} #{name} that is not frozen: freeze it at boot (Ractor.make_shareable)"
89
+ else
90
+ "#{article(name)} #{name} that is frozen but not shareable (an extension object, or unshareable internals)"
91
+ end
92
+ end
93
+
94
+ def article(name) = name.match?(/\A[AEIOU]/) ? "an" : "a"
95
+
96
+ # Module#=== rather than value.is_a?, which a BasicObject lacks.
97
+ def callable?(value)
98
+ [::Proc, ::Method, ::UnboundMethod].any? { _1 === value } # rubocop:disable Style/CaseEquality
99
+ end
100
+
101
+ def frozen?(value) = core(:frozen?, value)
102
+ def core(method, value, *) = ::Kernel.instance_method(method).bind_call(value, *)
103
+
104
+ # A key's #inspect is consumer code: never let it abort the walk.
105
+ def label(key)
106
+ key.inspect
107
+ rescue StandardError
108
+ "#<#{class_name(key)}>"
109
+ end
110
+
111
+ def lock?(value) = LOCKS.include?(class_name(value))
112
+ def concurrent?(value) = class_name(value).start_with?("Concurrent::")
113
+ def class_name(value) = ::Kernel.instance_method(:class).bind_call(value).name || "anonymous class"
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ # Per-Ractor lazy storage: `Store.fetch(:my_gem_cache) { {} }`.
5
+ #
6
+ # On Ruby 3.4+ this is `Ractor.store_if_absent`, atomic and per-Ractor. Below
7
+ # 3.4 (and on engines without Ractor) the main Ractor uses one Hash guarded by
8
+ # a Mutex, created at load (never lazily, so there is no check-then-set race);
9
+ # it is the kit's one allowlisted main-only state (.conformance.yml). A
10
+ # non-main Ractor on 3.2/3.3 cannot read it, so it keeps its own values in
11
+ # Ractor-local storage instead (not locked: one Ractor's threads may race to
12
+ # build a value, as with any `||=`).
13
+ module Store
14
+ # rubocop:disable ThreadSafety/MutableClassInstanceVariable -- main-only fallback, guarded by @fallback_lock
15
+ @fallback_lock = Mutex.new
16
+ @fallback_values = {}
17
+ # rubocop:enable ThreadSafety/MutableClassInstanceVariable
18
+
19
+ class << self
20
+ def fetch(key, &init)
21
+ raise ArgumentError, "ConformanceKit::Store.fetch(#{key.inspect}) needs an initializer block" unless init
22
+ return ::Ractor.store_if_absent(key, &init) if native?
23
+ return ractor_local(key, &init) unless main_ractor?
24
+
25
+ @fallback_lock.synchronize do
26
+ @fallback_values.fetch(key) { @fallback_values[key] = init.call }
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def main_ractor? = !defined?(::Ractor) || ::Ractor.current == ::Ractor.main
33
+
34
+ # Boxed, so a nil or false value is cached rather than rebuilt on every fetch.
35
+ def ractor_local(key)
36
+ (::Ractor.current[:"conformance_kit_store/#{key}"] ||= [yield]).first
37
+ end
38
+
39
+ public
40
+
41
+ def native?
42
+ defined?(::Ractor) ? ::Ractor.respond_to?(:store_if_absent) : false
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ConformanceKit
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "conformance_kit/version"
4
+ require_relative "conformance_kit/result"
5
+ require_relative "conformance_kit/store"
6
+ require_relative "conformance_kit/shareability"
7
+ require_relative "conformance_kit/checks/consistency"
8
+ require_relative "conformance_kit/checks/thread_contention"
9
+ require_relative "conformance_kit/checks/fiber_reactor"
10
+ require_relative "conformance_kit/checks/boot_finalized"
11
+ require_relative "conformance_kit/checks/ractor_canary"
12
+
13
+ # Concurrency conformance for the zoolutions gems. See README.md for the tiers.
14
+ module ConformanceKit
15
+ end
metadata ADDED
@@ -0,0 +1,68 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: conformance-kit
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - mhenrixon
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: Shared RSpec examples, a per-Ractor storage shim, a class-state audit,
13
+ RuboCop configs and reusable CI workflows that hold a gem to explicit thread-safe,
14
+ fiber-safe and Ractor-clean tiers.
15
+ email:
16
+ - mikael@mhenrixon.com
17
+ executables:
18
+ - conformance-audit
19
+ extensions: []
20
+ extra_rdoc_files: []
21
+ files:
22
+ - CHANGELOG.md
23
+ - LICENSE.txt
24
+ - README.md
25
+ - config/rubocop.yml
26
+ - config/rubocop_spinel.yml
27
+ - exe/conformance-audit
28
+ - lib/conformance_kit.rb
29
+ - lib/conformance_kit/audit.rb
30
+ - lib/conformance_kit/audit/allowlist.rb
31
+ - lib/conformance_kit/audit/cli.rb
32
+ - lib/conformance_kit/checks/boot_finalized.rb
33
+ - lib/conformance_kit/checks/consistency.rb
34
+ - lib/conformance_kit/checks/fiber_reactor.rb
35
+ - lib/conformance_kit/checks/ractor_canary.rb
36
+ - lib/conformance_kit/checks/thread_contention.rb
37
+ - lib/conformance_kit/minitest.rb
38
+ - lib/conformance_kit/result.rb
39
+ - lib/conformance_kit/rspec.rb
40
+ - lib/conformance_kit/shareability.rb
41
+ - lib/conformance_kit/store.rb
42
+ - lib/conformance_kit/version.rb
43
+ homepage: https://github.com/zoolutions/conformance-kit
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ source_code_uri: https://github.com/zoolutions/conformance-kit
48
+ changelog_uri: https://github.com/zoolutions/conformance-kit/blob/main/CHANGELOG.md
49
+ rubygems_mfa_required: 'true'
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: '3.2'
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 4.0.19
65
+ specification_version: 4
66
+ summary: 'Concurrency conformance gates for Ruby gems: threads, fibers, Ractors and
67
+ compiler probes.'
68
+ test_files: []