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 +4 -4
- data/CHANGELOG.md +18 -1
- data/README.md +99 -45
- data/config/rubocop_spinel.yml +1 -1
- data/exe/conformance-kit +6 -0
- data/lib/conformance_kit/audit/allowlist.rb +8 -5
- data/lib/conformance_kit/audit/cli.rb +70 -34
- data/lib/conformance_kit/audit.rb +16 -4
- data/lib/conformance_kit/cli/check.rb +69 -0
- data/lib/conformance_kit/cli/doctor.rb +85 -0
- data/lib/conformance_kit/cli.rb +93 -0
- data/lib/conformance_kit/config.rb +144 -0
- data/lib/conformance_kit/version.rb +1 -1
- metadata +9 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6e74bc1ed523498fd8aa7cd04191a6b13b690568c21a5a16627c0d2d147afb15
|
|
4
|
+
data.tar.gz: 9138ad7259e5f81442f50c9fab3e6a9b3fd08a3569a4861f27e62bc945f446b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: acb89af04013c0b5e8efc69da423c53f794c9305359c0f7dfe131066cfa9f47f6436e586c6de74d80272a25a3f8f29deab16831045c37971d30815968b6286e0
|
|
7
|
+
data.tar.gz: 4ac36fc7dbef17b06b1c2fd7bca8c38ee6f915a910324f1173602af05d51ed571207c2f02536cd8d926adebe2d9e54a3c484dc606976d1bf199424860cedb209
|
data/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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 (`
|
|
10
|
-
| 4 compiler-probed | Spinel/Roundhouse readiness is measured, never gated | `
|
|
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",
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
##
|
|
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/
|
|
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
|
-
|
|
158
|
-
uses: zoolutions/conformance-kit/.github/workflows/
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
|
188
|
-
|
|
189
|
-
|
|
|
190
|
-
|
|
|
191
|
-
|
|
|
192
|
-
|
|
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
|
|
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.
|
data/config/rubocop_spinel.yml
CHANGED
|
@@ -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/
|
|
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
|
#
|
data/exe/conformance-kit
ADDED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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] [--
|
|
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
|
|
14
|
+
# 1 unallowlisted findings (or, with strict_autoloads, unlisted unloaded
|
|
15
|
+
# autoloads) under --strict, 2 usage or config error.
|
|
11
16
|
class CLI
|
|
12
|
-
|
|
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[:
|
|
27
|
-
options[:namespaces].
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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,
|
|
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] [--
|
|
48
|
-
o.on("--require FILE", "Require FILE before auditing (repeatable)")
|
|
49
|
-
|
|
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", "
|
|
54
|
-
options[:
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
65
|
-
return
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
92
|
-
|
|
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
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: conformance-kit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
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
|
|
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:
|
|
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.'
|