conformance-kit 0.1.0 → 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: 45e9d40d8ce04df0a8aae9f4f0bd8da57e2f0f649c2549f1649f7d9d5ed7adc2
4
- data.tar.gz: df75fb6611c183f7d93d52f95755022f6154afd63cd60a541346e779107e317b
3
+ metadata.gz: 6e74bc1ed523498fd8aa7cd04191a6b13b690568c21a5a16627c0d2d147afb15
4
+ data.tar.gz: 9138ad7259e5f81442f50c9fab3e6a9b3fd08a3569a4861f27e62bc945f446b5
5
5
  SHA512:
6
- metadata.gz: ea8d19b3ca7a5dfdb2a4cd5d5a9e12c350c105ffa7af381319f7d31efc5a10ae8e6e8c35b5ef2f8cb0e70f23f2dcdaa417e8bf96617c9ac51280d176d106272d
7
- data.tar.gz: aea92dfbc96c976dd0a4a31d556d8d111764c31ddfa14dab32f7f5123aad84272ca59f4251302d160f635af1289de41a357fdae78f474db6ca16a3dc13d2f1e8
6
+ metadata.gz: acb89af04013c0b5e8efc69da423c53f794c9305359c0f7dfe131066cfa9f47f6436e586c6de74d80272a25a3f8f29deab16831045c37971d30815968b6286e0
7
+ data.tar.gz: 4ac36fc7dbef17b06b1c2fd7bca8c38ee6f915a910324f1173602af05d51ed571207c2f02536cd8d926adebe2d9e54a3c484dc606976d1bf199424860cedb209
data/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.3.0
4
+
5
+ Breaking (pre-1.0): `conformance.yml` is a reusable workflow that each repo calls, instead of one an org ruleset requires. It runs on `workflow_call` only. A repo adds a caller pinned to a release tag (`uses: zoolutions/conformance-kit/.github/workflows/conformance.yml@vX.Y.Z`, see the README) and makes `conformance / Conformance settings` and `conformance / Conformance (Ruby 4.0)` required checks in its own branch ruleset. Repos outside zoolutions can use it the same way.
6
+
7
+ - The version handshake reads `job.workflow_ref` (the called workflow's own tag) instead of `github.workflow_ref` (the caller's).
8
+ - This repo calls its own workflow through `.github/workflows/conformance-self.yml`.
9
+
10
+ ## 0.2.0
11
+
12
+ Breaking (pre-1.0): the reusable workflows `ractor.yml` and `probes.yml` are replaced by `conformance.yml`, which an org ruleset requires at a release tag. A repo opts in with `.conformance.yml`, `gem "conformance-kit", "~> 0.2"` and `gem "rubocop-thread_safety", ">= 0.8"` in its Gemfile, and `inherit_gem: { conformance-kit: config/rubocop.yml }` in `.rubocop.yml` (`conformance-kit doctor` checks all three); it needs no workflow edits.
13
+
14
+ - `.conformance.yml` 0.2 schema: `audit:` (`require`, `namespace`, `strict_autoloads`, `skip_autoloads`), `ractor:` (`ruby`, `test_command`, `env`, `setup`, `working_directory`), `probes:` (`gem_name`, `paths`, `roundhouse_app`) and the unchanged `main_only:`. Every key is validated; an unknown key or a wrong type exits 2.
15
+ - `conformance-kit` executable: `check` (doctor, `ractor.setup`, `ractor.test_command`, then `conformance-audit --strict`), `doctor [--expect X.Y]` and `config KEY`.
16
+ - `conformance-audit` reads `audit.require` and `audit.namespace` when no flags are given (flags still override); `--config` is the new name for `--allowlist`.
17
+ - `audit.strict_autoloads: true` makes `--strict` fail on unloaded autoloads not listed in `audit.skip_autoloads` (default `false`, 0.1's behaviour).
18
+ - `.github/workflows/conformance.yml`: the Ruby 4.0 conformance job (with a `postgres:18` service and the version handshake from `github.workflow_ref`) and the report-only compiler probes.
19
+
20
+ ## 0.1.0
4
21
 
5
22
  - 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
23
  - The same checks as Minitest assertions (`require "conformance_kit/minitest"`): `assert_thread_safe`, `assert_fiber_safe`, `assert_boot_finalized`, `assert_runs_in_ractor`.
data/README.md CHANGED
@@ -6,8 +6,8 @@ Concurrency conformance for the zoolutions gems. A gem that adopts it is held to
6
6
  |---|---|---|
7
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
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) |
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 (`conformance.yml`) |
10
+ | 4 compiler-probed | Spinel/Roundhouse readiness is measured, never gated | the probes job of `conformance.yml` (report-only) |
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
 
@@ -16,7 +16,7 @@ Background, the finalize-at-boot pattern and the settled decisions: [zoolutions/
16
16
  ```ruby
17
17
  # Gemfile
18
18
  group :development, :test do
19
- gem "conformance-kit", github: "zoolutions/conformance-kit"
19
+ gem "conformance-kit", "~> 0.3", require: false
20
20
  gem "rubocop-thread_safety", ">= 0.8", require: false # for config/rubocop.yml
21
21
  gem "async", require: false # optional: runs the fiber example
22
22
  end
@@ -101,10 +101,11 @@ On Ruby 3.4+ this is `Ractor.store_if_absent` (atomic, one value per Ractor). Be
101
101
  ## `conformance-audit`
102
102
 
103
103
  ```bash
104
- bundle exec conformance-audit --require ./lib/my_gem --namespace MyGem --strict
104
+ bundle exec conformance-audit --strict # audit.require + audit.namespace from .conformance.yml
105
+ bundle exec conformance-audit --require ./lib/my_gem --namespace MyGem --strict # flags override them
105
106
  ```
106
107
 
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
+ 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:
108
109
 
109
110
  ```
110
111
  conformance-audit: 2 findings in MyGem (1 allowlisted main-only; 14 modules audited, 0 unloaded autoloads skipped)
@@ -114,28 +115,48 @@ conformance-audit: 2 findings in MyGem (1 allowlisted main-only; 14 modules audi
114
115
 
115
116
  Limits, so a green run means what it says:
116
117
 
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
+ - 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. With `audit.strict_autoloads: true`, `--strict` also fails on every skipped autoload not listed in `audit.skip_autoloads`, so a green run means every autoload not deliberately skipped was audited.
118
119
  - `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
 
120
121
  | Exit | Meaning |
121
122
  |---|---|
122
123
  | 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 |
124
+ | 1 | unallowlisted findings under `--strict` (or, with `audit.strict_autoloads`, unlisted unloaded autoloads) |
125
+ | 2 | usage error, no namespace from either the flags or `audit.namespace`, a namespace that is not defined or not a module, a file that cannot be required, or an invalid `.conformance.yml` |
125
126
 
126
127
  ### `.conformance.yml`
127
128
 
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
+ One file per repo says what it is held to. It is read from the working directory (or `--config PATH`; `--allowlist PATH` still works). `audit.require` and `ractor.working_directory` are relative to the file; `ractor.setup`, `ractor.test_command`, `probes.paths` and `probes.roundhouse_app` run in (or are relative to) `ractor.working_directory`. Every key is validated: an unknown key or a wrong type is an error (exit 2) naming the file and the key.
129
130
 
130
131
  ```yaml
132
+ audit:
133
+ require: [./lib/my_gem, ./spec/support/eager_load.rb] # files to require (list)
134
+ namespace: [MyGem] # constants to audit (list)
135
+ strict_autoloads: true # optional, default false
136
+ skip_autoloads: # optional; each needs a reason
137
+ - name: MyGem::Optional
138
+ reason: only loads with pgbus present
139
+ ractor:
140
+ ruby: "4.0" # default "4.0" (quote it)
141
+ test_command: bundle exec rspec --tag ractor # default; Minitest gems set their own
142
+ env: # optional string map, applied before setup/test_command
143
+ PGBUS_DATABASE_URL: postgres://postgres:postgres@localhost:5432/pgbus_test
144
+ setup: bin/conformance-db-setup # optional command run once before test_command
145
+ working_directory: . # default "."
146
+ probes:
147
+ gem_name: my_gem # required for the probes job
148
+ paths: [lib] # default [lib]
149
+ roundhouse_app: spec/dummy # optional
131
150
  main_only:
132
- - name: MyGem::Registry.@lock # class/module ivar: Owner.@ivar
151
+ - name: MyGem::Registry.@lock # class/module ivar: Owner.@ivar
133
152
  reason: guards boot-time registration; no non-main Ractor ever registers
134
- - name: MyGem::LOCK # constant: Owner::NAME
153
+ - name: MyGem::LOCK # constant: Owner::NAME
135
154
  reason: ...
136
155
  ```
137
156
 
138
- Entries inside the audited namespaces that no longer turn up are listed as stale.
157
+ `main_only:` is state that is deliberately main-only: every entry needs a `reason:`. Entries inside the audited namespaces that no longer turn up are listed as stale, and so are `audit.skip_autoloads` entries that are no longer unloaded autoloads.
158
+
159
+ `strict_autoloads: true` needs `audit.require` to load everything, e.g. a file that calls your Zeitwerk loader's `eager_load!`.
139
160
 
140
161
  ## RuboCop
141
162
 
@@ -149,47 +170,80 @@ Turns on the six `rubocop-thread_safety` cops (`ClassAndModuleAttributes`, `Clas
149
170
 
150
171
  `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
172
 
152
- ## Reusable workflows
173
+ ## Enforcement
174
+
175
+ Conformance runs as **one reusable workflow in this repo**, `.github/workflows/conformance.yml`. Any repo can call it, inside zoolutions or not. A repo opts in with
176
+
177
+ 1. `.conformance.yml` (above),
178
+ 2. the Gemfile lines (`conformance-kit ~> 0.3`, `rubocop-thread_safety >= 0.8`),
179
+ 3. `inherit_gem: { conformance-kit: config/rubocop.yml }` in `.rubocop.yml`,
180
+ 4. a caller, pinned to a release tag:
153
181
 
154
182
  ```yaml
155
- # .github/workflows/main.yml
183
+ # .github/workflows/conformance.yml
184
+ name: Conformance
185
+
186
+ on:
187
+ pull_request:
188
+ merge_group:
189
+
190
+ permissions:
191
+ contents: read
192
+
156
193
  jobs:
157
- ractor:
158
- uses: zoolutions/conformance-kit/.github/workflows/ractor.yml@main
159
- with:
160
- require: ./lib/my_gem
161
- namespace: MyGem
194
+ conformance:
195
+ uses: zoolutions/conformance-kit/.github/workflows/conformance.yml@v0.3.0
196
+ ```
197
+
198
+ 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@*`.
199
+
200
+ The workflow has three jobs:
201
+
202
+ - **Conformance settings** reads `ractor.ruby` and `ractor.working_directory`.
203
+ - **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.
204
+ - **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.
205
+
206
+ ### Staying current
207
+
208
+ Dependabot bumps both pins: the `uses:` tag (`github-actions`) and the gem (`bundler`), in separate pull requests.
162
209
 
163
- probes:
164
- uses: zoolutions/conformance-kit/.github/workflows/probes.yml@main
165
- with:
166
- gem-name: my_gem
210
+ ```yaml
211
+ # .github/dependabot.yml
212
+ version: 2
213
+ updates:
214
+ - package-ecosystem: github-actions
215
+ directory: /
216
+ schedule: { interval: weekly }
217
+ - package-ecosystem: bundler
218
+ directory: /
219
+ schedule: { interval: weekly }
167
220
  ```
168
221
 
169
- **`ractor.yml`** (the Tier 3 gate): the Ractor examples, then `conformance-audit --strict`.
222
+ Merge the gem bump first: after a minor or major release the workflow bump fails the version handshake until the lockfile has caught up.
170
223
 
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` | `.` | |
224
+ ### `conformance-kit check`, the local equivalent
225
+
226
+ ```bash
227
+ bundle exec conformance-kit check # doctor, ractor.setup, ractor.test_command, then conformance-audit --strict
228
+ bundle exec conformance-kit doctor # the static checks only
229
+ bundle exec conformance-kit config probes.gem_name
230
+ ```
180
231
 
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.
232
+ `check` stops at the first failing stage, names it, and exits with its status. Run it before adding the caller: what passes here passes the workflow (on the same Ruby).
182
233
 
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` | `.` | |
234
+ `doctor` checks that `.conformance.yml` exists, is valid and sets `audit.namespace`; that `.rubocop.yml` inherits `conformance-kit: config/rubocop.yml`; and that `rubocop-thread_safety` resolves at `>= 0.8`. Each problem names its fix. `--expect X.Y` also checks the resolved `conformance-kit` is at least `X.Y`.
235
+
236
+ `config KEY` prints a dotted key's value (a list joined with spaces, `ractor.env` as `NAME=value` lines, an unset key as an empty line); an unknown key exits 2.
237
+
238
+ | Exit | Meaning |
239
+ |---|---|
240
+ | 0 | ok |
241
+ | 1 | a check failed (`check` exits with the failing stage's status) |
242
+ | 2 | usage error or an invalid `.conformance.yml` |
243
+
244
+ ### The version handshake
245
+
246
+ 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`. That a tag-pinned call carries the tag is GitHub's documented behaviour; it has not been seen on a run yet, since v0.3.0 is the first tag that can be called. Until it has, treat the handshake as pending validation.
193
247
 
194
248
  ## Development
195
249
 
@@ -197,4 +251,4 @@ jobs:
197
251
  bundle exec rake # rspec + rubocop; CI runs Ruby 3.2, 3.4 and 4.0
198
252
  ```
199
253
 
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.
254
+ 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.
@@ -1,5 +1,5 @@
1
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
2
+ # never a gate. Run by the probes job of .github/workflows/conformance.yml as
3
3
  #
4
4
  # rubocop --config <conformance-kit>/config/rubocop_spinel.yml lib
5
5
  #
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/conformance_kit/cli"
5
+
6
+ exit ConformanceKit::CLI.run(ARGV)
@@ -14,6 +14,8 @@ module ConformanceKit
14
14
  # reason: guards boot-time registration; never touched by a non-main Ractor
15
15
  # - name: MyGem::LOCK # a constant: Owner::NAME
16
16
  # reason: ...
17
+ #
18
+ # `audit.skip_autoloads:` entries have the same shape (`key:` names the list).
17
19
  class Allowlist
18
20
  def self.empty = new([])
19
21
 
@@ -28,8 +30,9 @@ module ConformanceKit
28
30
 
29
31
  attr_reader :entries
30
32
 
31
- def initialize(entries, source: "the allowlist")
32
- raise AllowlistError, "#{source}: main_only: must be a list" unless entries.is_a?(Array)
33
+ def initialize(entries, source: "the allowlist", key: "main_only")
34
+ @key = key
35
+ raise AllowlistError, "#{source}: #{key}: must be a list" unless entries.is_a?(Array)
33
36
 
34
37
  @entries = entries.map { validate(_1, source) }
35
38
  end
@@ -43,14 +46,14 @@ module ConformanceKit
43
46
  def validate(entry, source)
44
47
  unless entry.is_a?(Hash)
45
48
  raise AllowlistError,
46
- "#{source}: each main_only entry needs a name: and a reason:, got #{entry.inspect}"
49
+ "#{source}: each #{@key} entry needs a name: and a reason:, got #{entry.inspect}"
47
50
  end
48
51
 
49
52
  name = entry["name"]
50
- raise AllowlistError, "#{source}: a main_only entry has no name: (#{entry.inspect})" if blank?(name)
53
+ raise AllowlistError, "#{source}: a #{@key} entry has no name: (#{entry.inspect})" if blank?(name)
51
54
  if blank?(entry["reason"])
52
55
  raise AllowlistError,
53
- "#{source}: #{name} has no reason: (say why it is safe main-only)"
56
+ "#{source}: #{name} has no reason: (say why it is safe)"
54
57
  end
55
58
 
56
59
  entry
@@ -4,12 +4,18 @@ require "optparse"
4
4
 
5
5
  module ConformanceKit
6
6
  class Audit
7
- # `conformance-audit --require FILE --namespace CONST [--strict] [--allowlist PATH]`
7
+ # `conformance-audit [--require FILE] [--namespace CONST] [--strict] [--config PATH]`
8
+ #
9
+ # Reads `.conformance.yml` (or `--config PATH`, alias `--allowlist`): its
10
+ # `audit.require`/`audit.namespace` when no flags are given, its `main_only:`
11
+ # allowlist, and `audit.strict_autoloads`/`audit.skip_autoloads`.
8
12
  #
9
13
  # Exit codes: 0 clean (or report-only, or no Ractor on this engine),
10
- # 1 unallowlisted findings under --strict, 2 usage or allowlist error.
14
+ # 1 unallowlisted findings (or, with strict_autoloads, unlisted unloaded
15
+ # autoloads) under --strict, 2 usage or config error.
11
16
  class CLI
12
- DEFAULT_ALLOWLIST = ".conformance.yml"
17
+ DEFAULT_CONFIG = Config::DEFAULT_PATH
18
+ DEFAULT_ALLOWLIST = DEFAULT_CONFIG
13
19
  USAGE_ERROR = 2
14
20
 
15
21
  def self.run(argv, out: $stdout, err: $stderr) = new(out, err).run(argv)
@@ -23,51 +29,62 @@ module ConformanceKit
23
29
  options = parse(argv)
24
30
  return unavailable unless defined?(::Ractor)
25
31
 
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
32
+ config = load_config(options[:config])
33
+ namespaces = options[:namespaces].empty? ? config["audit.namespace"] : options[:namespaces]
34
+ raise OptionParser::MissingArgument, NO_NAMESPACE if namespaces.empty?
35
+
36
+ requires(options, config).each { require _1 }
37
+ namespaces.each { check_namespace(_1) }
38
+ audit = Audit.new(namespaces: namespaces, allowlist: config.allowlist, skip_autoloads: config.skip_autoloads)
39
+ report(audit, options[:strict], config.strict_autoloads?)
40
+ rescue OptionParser::ParseError, ConfigError, AllowlistError, LoadError, NameError, ArgumentError => e
31
41
  @err.puts "conformance-audit: #{e.message}"
32
42
  USAGE_ERROR
33
43
  end
34
44
 
45
+ NO_NAMESPACE = "--namespace: nothing to audit (pass --namespace or set .conformance.yml audit.namespace)"
46
+ private_constant :NO_NAMESPACE
47
+
35
48
  private
36
49
 
37
50
  def parse(argv)
38
- options = { requires: [], namespaces: [], strict: false, allowlist: nil }
51
+ options = { requires: [], namespaces: [], strict: false, config: nil }
39
52
  parser(options).parse!(argv.dup)
40
- raise OptionParser::MissingArgument, "--namespace (the constant to audit)" if options[:namespaces].empty?
41
-
42
53
  options
43
54
  end
44
55
 
45
56
  def parser(options)
46
57
  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|
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|
60
+ options[:requires] << file
61
+ end
62
+ o.on("--namespace CONST", "Audit CONST and all under it (repeatable; default audit.namespace)") do |name|
50
63
  options[:namespaces] << name.delete_prefix("::")
51
64
  end
52
65
  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
66
+ o.on("--config PATH", "--allowlist PATH", "The config to read (default: #{DEFAULT_CONFIG})") do |path|
67
+ options[:config] = path
55
68
  end
56
69
  end
57
70
  end
58
71
 
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)
72
+ def load_config(path)
73
+ raise ConfigError, "config #{path} does not exist" if path && !File.exist?(path)
74
+
75
+ Config.load(path || DEFAULT_CONFIG)
62
76
  end
63
77
 
64
- def allowlist(path)
65
- return Allowlist.load(path) if path
66
- return Allowlist.load(DEFAULT_ALLOWLIST) if File.exist?(DEFAULT_ALLOWLIST)
78
+ def requires(options, config)
79
+ return config.audit_requires if options[:requires].empty?
80
+
81
+ options[:requires].map { File.expand_path(_1) }
82
+ end
67
83
 
68
- Allowlist.empty
69
- rescue Errno::ENOENT
70
- raise AllowlistError, "allowlist #{path} does not exist"
84
+ def check_namespace(name)
85
+ name = name.delete_prefix("::")
86
+ value = Object.const_get(name)
87
+ raise ArgumentError, "namespace #{name} is a #{value.class}, not a module" unless value.is_a?(Module)
71
88
  end
72
89
 
73
90
  def unavailable
@@ -75,21 +92,40 @@ module ConformanceKit
75
92
  0
76
93
  end
77
94
 
78
- def report(audit, strict)
95
+ def report(audit, strict, strict_autoloads)
79
96
  findings = audit.findings
80
- allowlisted = audit.allowlisted
97
+ autoloads = strict_autoloads ? audit.unexpected_autoloads : []
98
+ header(audit, findings)
99
+ details(audit, strict_autoloads)
100
+ return 0 if findings.empty? && autoloads.empty?
101
+ return 1 if strict
102
+
103
+ @out.puts "report only: --strict fails on these"
104
+ 0
105
+ end
106
+
107
+ def header(audit, findings)
81
108
  @out.puts "conformance-audit: #{findings.size} findings in #{audit.namespaces.join(', ')} " \
82
- "(#{allowlisted.size} allowlisted main-only; #{audit.modules_audited} modules audited, " \
109
+ "(#{audit.allowlisted.size} allowlisted main-only; #{audit.modules_audited} modules audited, " \
83
110
  "#{audit.skipped_autoloads.size} unloaded autoloads skipped)"
84
111
  findings.each { @out.puts " #{_1}" }
85
- section("allowlisted main-only:", allowlisted.map { "#{_1.name} — #{audit.allowlist.reason_for(_1.name)}" })
112
+ end
113
+
114
+ def details(audit, strict_autoloads)
115
+ allowlisted = audit.allowlisted.map { "#{_1.name} — #{audit.allowlist.reason_for(_1.name)}" }
116
+ section("allowlisted main-only:", allowlisted)
86
117
  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
118
+ skipped = audit.skipped_autoloads - audit.unexpected_autoloads
119
+ section("unloaded autoloads skipped by audit.skip_autoloads:",
120
+ skipped.map { "#{_1} — #{audit.skip_autoloads.reason_for(_1)}" })
121
+ section("stale audit.skip_autoloads entries (no longer unloaded autoloads):", audit.stale_skip_autoloads)
122
+ section(autoload_title(strict_autoloads), audit.unexpected_autoloads)
123
+ end
90
124
 
91
- @out.puts "report only: --strict fails on these"
92
- 0
125
+ def autoload_title(strict_autoloads)
126
+ return "unloaded autoloads, not audited (require or eager-load them):" unless strict_autoloads
127
+
128
+ "unloaded autoloads, not audited (strict_autoloads: eager-load them or list them in audit.skip_autoloads):"
93
129
  end
94
130
 
95
131
  def section(title, lines)
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "conformance_kit"
4
+ require_relative "config"
4
5
  require_relative "audit/allowlist"
5
6
  require_relative "audit/cli"
6
7
 
@@ -8,7 +9,8 @@ module ConformanceKit
8
9
  # Walks every loaded module under the given namespaces and reports each
9
10
  # constant and class/module instance variable whose value is not
10
11
  # Ractor-shareable. Unloaded autoload constants are skipped: require (or
11
- # eager-load) everything the audit should see.
12
+ # eager-load) everything the audit should see, and list deliberate exceptions
13
+ # in `skip_autoloads` (`audit.skip_autoloads:` in .conformance.yml).
12
14
  class Audit
13
15
  Finding = Data.define(:name, :kind, :value_class, :path, :reason) do
14
16
  def to_s
@@ -17,11 +19,12 @@ module ConformanceKit
17
19
  end
18
20
  end
19
21
 
20
- attr_reader :namespaces, :allowlist
22
+ attr_reader :namespaces, :allowlist, :skip_autoloads
21
23
 
22
- def initialize(namespaces:, allowlist: Allowlist.empty)
24
+ def initialize(namespaces:, allowlist: Allowlist.empty, skip_autoloads: Allowlist.empty)
23
25
  @namespaces = namespaces
24
26
  @allowlist = allowlist
27
+ @skip_autoloads = skip_autoloads
25
28
  end
26
29
 
27
30
  def findings = partitioned.first
@@ -31,17 +34,26 @@ module ConformanceKit
31
34
 
32
35
  # Constants declared with `autoload` but never loaded: not audited.
33
36
  def skipped_autoloads
34
- audited_modules.flat_map do |mod, name|
37
+ @skipped_autoloads ||= audited_modules.flat_map do |mod, name|
35
38
  constants_of(mod).select { autoload?(mod, _1) }.map { "#{name}::#{_1}" }
36
39
  end
37
40
  end
38
41
 
42
+ # Skipped autoloads not listed in skip_autoloads: what strict_autoloads fails on.
43
+ def unexpected_autoloads = skipped_autoloads - @skip_autoloads.names
44
+
39
45
  # Allowlisted names inside the audited namespaces that no longer turn up.
40
46
  def stale
41
47
  found = scan.map(&:name)
42
48
  @allowlist.names.select { in_namespace?(_1) && !found.include?(_1) }
43
49
  end
44
50
 
51
+ # skip_autoloads names inside the audited namespaces that are no longer unloaded autoloads.
52
+ def stale_skip_autoloads
53
+ skipped = skipped_autoloads
54
+ @skip_autoloads.names.select { in_namespace?(_1) && !skipped.include?(_1) }
55
+ end
56
+
45
57
  private
46
58
 
47
59
  def partitioned
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "rbconfig"
5
+
6
+ module ConformanceKit
7
+ class CLI
8
+ # What the conformance workflow runs, and what a developer runs locally
9
+ # before flipping a repo: doctor, then ractor.setup and ractor.test_command
10
+ # (with ractor.env, in ractor.working_directory), then the strict audit.
11
+ # Stops at the first failing stage and exits with its status.
12
+ class Check
13
+ AUDIT = File.expand_path("../../../exe/conformance-audit", __dir__).freeze
14
+
15
+ def initialize(config, doctor, out:)
16
+ @config = config
17
+ @doctor = doctor
18
+ @out = out
19
+ end
20
+
21
+ def run
22
+ status = stages.each do |stage, command|
23
+ code = command.call
24
+ break failed(stage, code) unless code.zero?
25
+ end
26
+ return status if status.is_a?(Integer)
27
+
28
+ @out.puts "conformance-kit check passed"
29
+ 0
30
+ end
31
+
32
+ private
33
+
34
+ def stages
35
+ list = [["doctor", -> { @doctor.run }]]
36
+ setup = @config["ractor.setup"]
37
+ list << ["ractor.setup", -> { shell(setup) }] if setup
38
+ list << ["ractor.test_command", -> { shell(@config["ractor.test_command"]) }]
39
+ list << ["audit", -> { audit }]
40
+ end
41
+
42
+ def shell(command)
43
+ @out.puts "==> #{command}"
44
+ @out.flush
45
+ exit_code(system(@config["ractor.env"], command, chdir: @config.working_directory))
46
+ end
47
+
48
+ def audit
49
+ @out.puts "==> conformance-audit --strict --config #{@config.source}"
50
+ output, status = Open3.capture2e(@config["ractor.env"], RbConfig.ruby, AUDIT, "--strict",
51
+ "--config", File.expand_path(@config.source), chdir: @config.root)
52
+ @out.print output
53
+ status.exitstatus || 1
54
+ end
55
+
56
+ # system returns nil when the command could not be spawned at all.
57
+ def exit_code(result)
58
+ return 127 if result.nil?
59
+
60
+ Process.last_status.exitstatus || 1
61
+ end
62
+
63
+ def failed(stage, code)
64
+ @out.puts "conformance-kit check failed at #{stage} (exit #{code})"
65
+ code
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module ConformanceKit
6
+ class CLI
7
+ # The static checks the conformance workflow relies on. Every problem is
8
+ # listed with its fix; exit 1 when there is any.
9
+ class Doctor
10
+ THREAD_SAFETY = "rubocop-thread_safety"
11
+ THREAD_SAFETY_FLOOR = "0.8"
12
+ RUBOCOP_CONFIG = "config/rubocop.yml"
13
+
14
+ def initialize(config, out:, version:, specs:, expect: nil)
15
+ @config = config
16
+ @out = out
17
+ @version = Gem::Version.new(version)
18
+ @specs = specs
19
+ @expect = expect
20
+ end
21
+
22
+ def problems
23
+ [conformance_problem, rubocop_problem, thread_safety_problem, version_problem].compact
24
+ end
25
+
26
+ def run
27
+ found = problems
28
+ return report_ok if found.empty?
29
+
30
+ @out.puts "conformance-kit doctor: #{found.size} problem#{'s' unless found.size == 1}"
31
+ found.each { @out.puts " - #{_1}" }
32
+ 1
33
+ end
34
+
35
+ private
36
+
37
+ def report_ok
38
+ @out.puts "conformance-kit doctor: ok (conformance-kit #{@version}, #{@config.source})"
39
+ 0
40
+ end
41
+
42
+ def conformance_problem
43
+ unless @config.exists?
44
+ return "no #{@config.source}: add one with audit.require and audit.namespace (see the conformance-kit README)"
45
+ end
46
+ return nil unless @config["audit.namespace"].empty?
47
+
48
+ "#{@config.source} sets no audit.namespace: list the constants to audit (e.g. audit.namespace: [MyGem])"
49
+ end
50
+
51
+ def rubocop_problem
52
+ path = File.join(@config.root, ".rubocop.yml")
53
+ fix = "add `inherit_gem: { conformance-kit: #{RUBOCOP_CONFIG} }`"
54
+ return "no #{path}: #{fix}" unless File.exist?(path)
55
+ return nil if inherits_kit?(YAML.safe_load_file(path, aliases: true))
56
+
57
+ "#{path} does not inherit conformance-kit's RuboCop config: #{fix}"
58
+ rescue Psych::Exception => e
59
+ "#{path} is not valid YAML (#{e.message})"
60
+ end
61
+
62
+ def inherits_kit?(rubocop)
63
+ gems = rubocop.is_a?(Hash) && rubocop["inherit_gem"]
64
+ gems.is_a?(Hash) && Array(gems["conformance-kit"]).include?(RUBOCOP_CONFIG)
65
+ end
66
+
67
+ def thread_safety_problem
68
+ spec = @specs[THREAD_SAFETY]
69
+ line = %(gem "#{THREAD_SAFETY}", ">= #{THREAD_SAFETY_FLOOR}", require: false)
70
+ return "#{THREAD_SAFETY} is not in the bundle: add `#{line}` to the Gemfile" unless spec
71
+ return nil if spec.version >= Gem::Version.new(THREAD_SAFETY_FLOOR)
72
+
73
+ "#{THREAD_SAFETY} #{spec.version} is resolved: bump it to >= #{THREAD_SAFETY_FLOOR} (`#{line}`)"
74
+ end
75
+
76
+ def version_problem
77
+ return nil if @expect.nil? || @version >= @expect
78
+
79
+ segments = @expect.segments.first(2).join(".")
80
+ "conformance-kit #{@version} is resolved, the workflow expects >= #{@expect}: " \
81
+ "bump conformance-kit to ~> #{segments} in the Gemfile and run bundle update conformance-kit"
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require "conformance_kit"
5
+ require_relative "config"
6
+ require_relative "cli/doctor"
7
+ require_relative "cli/check"
8
+
9
+ module ConformanceKit
10
+ # `conformance-kit check|doctor|config KEY [--config PATH] [--expect X.Y]`
11
+ #
12
+ # Exit codes: 0 ok, 1 a check failed (`check` passes on its failing stage's
13
+ # status), 2 usage or config error.
14
+ class CLI
15
+ USAGE = <<~TEXT
16
+ Usage: conformance-kit COMMAND [--config PATH]
17
+ check [--expect X.Y] doctor, ractor.setup, ractor.test_command, then conformance-audit --strict
18
+ doctor [--expect X.Y] .conformance.yml, .rubocop.yml and the bundle are set up for the conformance workflow
19
+ config KEY print a .conformance.yml value (dotted key, e.g. probes.gem_name)
20
+ TEXT
21
+ USAGE_ERROR = 2
22
+
23
+ def self.run(argv, out: $stdout, err: $stderr, version: VERSION, specs: Gem.loaded_specs)
24
+ new(out: out, err: err, version: version, specs: specs).run(argv)
25
+ end
26
+
27
+ def initialize(out:, err:, version:, specs:)
28
+ @out = out
29
+ @err = err
30
+ @version = version
31
+ @specs = specs
32
+ end
33
+
34
+ def run(argv)
35
+ command, *rest = argv
36
+ options = parse(rest)
37
+ dispatch(command, options)
38
+ rescue OptionParser::ParseError, ConfigError, ArgumentError => e
39
+ @err.puts "conformance-kit: #{e.message}"
40
+ @err.puts USAGE
41
+ USAGE_ERROR
42
+ end
43
+
44
+ private
45
+
46
+ def dispatch(command, options)
47
+ case command
48
+ when "config" then print_value(options)
49
+ when "doctor" then doctor(options).run
50
+ when "check" then Check.new(config(options), doctor(options), out: @out).run
51
+ else raise ArgumentError, command ? "unknown command #{command}" : "no command"
52
+ end
53
+ end
54
+
55
+ def parse(argv)
56
+ options = { config: Config::DEFAULT_PATH, expect: nil }
57
+ options[:args] = OptionParser.new do |o|
58
+ o.on("--config PATH") { options[:config] = _1 }
59
+ o.on("--expect X.Y") { options[:expect] = expected_version(_1) }
60
+ end.parse(argv)
61
+ options
62
+ end
63
+
64
+ def expected_version(value)
65
+ raise ArgumentError, "--expect #{value}: expected a version like 0.2" unless Gem::Version.correct?(value)
66
+
67
+ Gem::Version.new(value)
68
+ end
69
+
70
+ def config(options) = Config.load(options[:config])
71
+
72
+ def doctor(options)
73
+ Doctor.new(config(options), out: @out, version: @version, specs: @specs, expect: options[:expect])
74
+ end
75
+
76
+ def print_value(options)
77
+ key, *extra = options[:args]
78
+ raise ArgumentError, "config needs a KEY (e.g. probes.gem_name)" unless key
79
+ raise ArgumentError, "config takes one KEY, got also: #{extra.join(' ')}" if extra.any?
80
+
81
+ @out.puts format_value(config(options)[key])
82
+ 0
83
+ end
84
+
85
+ def format_value(value)
86
+ case value
87
+ when Hash then value.map { |name, env_value| "#{name}=#{env_value}" }.join("\n")
88
+ when Array then value.map { _1.is_a?(Hash) ? _1["name"] : _1 }.join(" ")
89
+ else value.to_s
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "audit/allowlist"
5
+
6
+ module ConformanceKit
7
+ class ConfigError < StandardError; end
8
+
9
+ # A repo's `.conformance.yml`: what `conformance-kit check` and
10
+ # `conformance-audit` hold it to. Every key is validated; a missing file is
11
+ # an empty config. Paths in it are relative to the file.
12
+ #
13
+ # audit: { require: [], namespace: [], strict_autoloads: false, skip_autoloads: [] }
14
+ # ractor: { ruby: "4.0", test_command: "...", env: {}, setup: nil, working_directory: "." }
15
+ # probes: { gem_name: nil, paths: [lib], roundhouse_app: nil }
16
+ # main_only: []
17
+ class Config
18
+ DEFAULT_PATH = ".conformance.yml"
19
+
20
+ TYPES = {
21
+ "audit.require" => :list,
22
+ "audit.namespace" => :list,
23
+ "audit.strict_autoloads" => :boolean,
24
+ "audit.skip_autoloads" => :entries,
25
+ "ractor.ruby" => :string,
26
+ "ractor.test_command" => :string,
27
+ "ractor.env" => :string_map,
28
+ "ractor.setup" => :string,
29
+ "ractor.working_directory" => :string,
30
+ "probes.gem_name" => :string,
31
+ "probes.paths" => :list,
32
+ "probes.roundhouse_app" => :string,
33
+ "main_only" => :entries
34
+ }.freeze
35
+
36
+ DEFAULTS = {
37
+ "audit.require" => [].freeze,
38
+ "audit.namespace" => [].freeze,
39
+ "audit.strict_autoloads" => false,
40
+ "audit.skip_autoloads" => [].freeze,
41
+ "ractor.ruby" => "4.0",
42
+ "ractor.test_command" => "bundle exec rspec --tag ractor",
43
+ "ractor.env" => {}.freeze,
44
+ "ractor.working_directory" => ".",
45
+ "probes.paths" => ["lib"].freeze,
46
+ "main_only" => [].freeze
47
+ }.freeze
48
+
49
+ SECTIONS = %w[audit ractor probes].freeze
50
+
51
+ def self.load(path = DEFAULT_PATH)
52
+ root = File.dirname(File.expand_path(path))
53
+ return new({}, source: path, root: root, exists: false) unless File.exist?(path)
54
+
55
+ data = YAML.safe_load_file(path)
56
+ new(data.nil? ? {} : data, source: path, root: root, exists: true)
57
+ rescue Psych::Exception, SystemCallError => e
58
+ raise ConfigError, "#{path}: #{e.message}"
59
+ end
60
+
61
+ attr_reader :source, :root, :allowlist, :skip_autoloads
62
+
63
+ def initialize(data, source:, root:, exists: true)
64
+ @source = source
65
+ @root = root
66
+ @exists = exists
67
+ raise ConfigError, "#{source}: expected a mapping (audit:, ractor:, probes:, main_only:)" unless data.is_a?(Hash)
68
+
69
+ pairs = flatten(data).each { |key, _| unknown_key!(key, TYPES.keys) unless TYPES.key?(key) }
70
+ # A key left blank is unset: its default applies, as in the workflow's yq read.
71
+ @values = pairs.to_h.compact.to_h { |key, value| [key, normalize(key, validate(key, value))] }
72
+ @allowlist = entries("main_only")
73
+ @skip_autoloads = entries("audit.skip_autoloads")
74
+ end
75
+
76
+ def exists? = @exists
77
+
78
+ # A dotted key (`ractor.ruby`): its value, or its default (nil when it has none).
79
+ def [](key)
80
+ unknown_key!(key, TYPES.keys) unless TYPES.key?(key)
81
+
82
+ @values.fetch(key) { DEFAULTS[key] }
83
+ end
84
+
85
+ def audit_requires = self["audit.require"].map { File.expand_path(_1, root) }
86
+ def strict_autoloads? = self["audit.strict_autoloads"]
87
+ def working_directory = File.expand_path(self["ractor.working_directory"], root)
88
+
89
+ private
90
+
91
+ def flatten(data)
92
+ data.flat_map do |key, value|
93
+ next [[key, value]] if key == "main_only"
94
+
95
+ unknown_key!(key, [*SECTIONS, "main_only"]) unless SECTIONS.include?(key)
96
+ next [] if value.nil?
97
+ raise ConfigError, "#{source}: #{key}: must be a mapping, got #{value.class}" unless value.is_a?(Hash)
98
+
99
+ value.map { |sub, sub_value| ["#{key}.#{sub}", sub_value] }
100
+ end
101
+ end
102
+
103
+ # A namespace is matched by name: `::MyGem` is `MyGem`, as with --namespace.
104
+ def normalize(key, value)
105
+ key == "audit.namespace" ? value.map { _1.delete_prefix("::") } : value
106
+ end
107
+
108
+ def validate(key, value)
109
+ type = TYPES.fetch(key)
110
+ return value if valid?(type, value)
111
+
112
+ raise ConfigError, "#{source}: #{key} must be #{EXPECTED.fetch(type)}, got #{value.class} #{value.inspect}"
113
+ end
114
+
115
+ EXPECTED = {
116
+ list: "a list of strings",
117
+ string: "a string (quote it)",
118
+ boolean: "true or false",
119
+ string_map: "a mapping of names to string values (quote numbers)",
120
+ entries: "a list of name:/reason: entries"
121
+ }.freeze
122
+ private_constant :EXPECTED
123
+
124
+ def valid?(type, value)
125
+ case type
126
+ when :list then value.is_a?(Array) && value.all?(String)
127
+ when :string then value.is_a?(String)
128
+ when :boolean then [true, false].include?(value)
129
+ when :string_map then value.is_a?(Hash) && value.all? { |k, v| k.is_a?(String) && v.is_a?(String) }
130
+ when :entries then value.is_a?(Array)
131
+ end
132
+ end
133
+
134
+ def unknown_key!(key, known)
135
+ raise ConfigError, "#{source}: unknown key #{key} (one of #{known.join(', ')})"
136
+ end
137
+
138
+ def entries(key)
139
+ Audit::Allowlist.new(self[key], source: source, key: key)
140
+ rescue Audit::AllowlistError => e
141
+ raise ConfigError, e.message
142
+ end
143
+ end
144
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConformanceKit
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.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.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mhenrixon
@@ -10,12 +10,13 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
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,
13
+ RuboCop configs and a reusable CI workflow that hold a gem to explicit thread-safe,
14
14
  fiber-safe and Ractor-clean tiers.
15
15
  email:
16
16
  - mikael@mhenrixon.com
17
17
  executables:
18
18
  - conformance-audit
19
+ - conformance-kit
19
20
  extensions: []
20
21
  extra_rdoc_files: []
21
22
  files:
@@ -25,6 +26,7 @@ files:
25
26
  - config/rubocop.yml
26
27
  - config/rubocop_spinel.yml
27
28
  - exe/conformance-audit
29
+ - exe/conformance-kit
28
30
  - lib/conformance_kit.rb
29
31
  - lib/conformance_kit/audit.rb
30
32
  - lib/conformance_kit/audit/allowlist.rb
@@ -34,6 +36,10 @@ files:
34
36
  - lib/conformance_kit/checks/fiber_reactor.rb
35
37
  - lib/conformance_kit/checks/ractor_canary.rb
36
38
  - lib/conformance_kit/checks/thread_contention.rb
39
+ - lib/conformance_kit/cli.rb
40
+ - lib/conformance_kit/cli/check.rb
41
+ - lib/conformance_kit/cli/doctor.rb
42
+ - lib/conformance_kit/config.rb
37
43
  - lib/conformance_kit/minitest.rb
38
44
  - lib/conformance_kit/result.rb
39
45
  - lib/conformance_kit/rspec.rb
@@ -61,7 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
61
67
  - !ruby/object:Gem::Version
62
68
  version: '0'
63
69
  requirements: []
64
- rubygems_version: 4.0.19
70
+ rubygems_version: 3.6.9
65
71
  specification_version: 4
66
72
  summary: 'Concurrency conformance gates for Ruby gems: threads, fibers, Ractors and
67
73
  compiler probes.'