conformance-kit 0.3.0 → 0.4.1
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 +9 -0
- data/README.md +25 -10
- data/lib/conformance_kit/audit/allowlist.rb +72 -10
- data/lib/conformance_kit/audit/ancestor_rule.rb +73 -0
- data/lib/conformance_kit/audit/cli.rb +2 -2
- data/lib/conformance_kit/audit.rb +23 -8
- data/lib/conformance_kit/config.rb +1 -1
- data/lib/conformance_kit/store.rb +15 -4
- data/lib/conformance_kit/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 18bb6e4722bbdc745b4efcbd1f9b3c3106b6193f235f9657d33e94f41c93b38a
|
|
4
|
+
data.tar.gz: 6b023ed04a3167ab8a43f0689691a4416b7ab5fcb2714d71bb1864d62162c3df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 16280d264476d1b02f60039e2c03dc821fc3f063e861b90a5b3fceae5e0fd53620d51c538cdd290169a5c348ac178b565b03c1867acdf24d37992ee5152ce061
|
|
7
|
+
data.tar.gz: 46cbb7fcfdf0ed59f99aa8f11b837b5aff4ad502062b66f0a964feb6f9e7529059f61c86451b276529c78b18f3f933e3438f8b916385a05350a510bb7f7d2ae8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- `ConformanceKit::Store.fetch` on a non-main Ractor below Ruby 3.4: threads that race on the first fetch now get the first value stored instead of each keeping its own (the race shrinks from the whole initializer to the publish; a thread switch inside the publish itself remains possible, as no Ractor-local lock exists before 3.4) (found by zoolutions/docs-kit#103, whose copy of the Store had the same `||= [yield]`).
|
|
6
|
+
|
|
7
|
+
## 0.4.0
|
|
8
|
+
|
|
9
|
+
- `main_only:` takes `ancestor:` rules (with optional `ivars:`): framework class state on the gem's own subclasses (ActiveRecord, ActionController, Rails::Engine, ActiveSupport::Concern, ...) in one entry per framework instead of one per ivar per class. Constants are never covered; unused rules are listed as stale; an entry with an unknown key, or an ancestor every module has (`Object`, `Module`, ...), is a config error. `Audit::Finding` gains a `rule` member (the covering rule, or nil).
|
|
10
|
+
- `conformance-audit` no longer crashes on a `BasicObject` value (ActiveRecord keeps one in an unresolved model's `@primary_key`): the finding names its class through `Kernel#class`.
|
|
11
|
+
|
|
3
12
|
## 0.3.0
|
|
4
13
|
|
|
5
14
|
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.
|
data/README.md
CHANGED
|
@@ -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", "~> 0.
|
|
19
|
+
gem "conformance-kit", "~> 0.4", 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
|
|
@@ -152,11 +152,18 @@ main_only:
|
|
|
152
152
|
reason: guards boot-time registration; no non-main Ractor ever registers
|
|
153
153
|
- name: MyGem::LOCK # constant: Owner::NAME
|
|
154
154
|
reason: ...
|
|
155
|
+
- ancestor: Rails::Engine # framework state on the gem's own subclasses
|
|
156
|
+
ivars: ["@called_from", "@initializers", "@instance"] # only the framework's ivars
|
|
157
|
+
reason: Rails::Engine's own class state, set by Rails at boot on the main Ractor
|
|
158
|
+
- ancestor: ActionController::Base # without ivars: every ivar on its subclasses
|
|
159
|
+
reason: ActionController's own class state, set by Rails on the main Ractor
|
|
155
160
|
```
|
|
156
161
|
|
|
157
162
|
`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
163
|
|
|
159
|
-
`
|
|
164
|
+
An `ancestor:` entry covers the class state a framework keeps on the gem's own classes, which would otherwise take one `name:` entry per ivar per class (a Rails engine easily needs a hundred): every class/module ivar on a module that inherits from, includes or is extended with that constant. It never covers a constant. Prefer `ivars:`: it narrows the rule to the framework's own ivars, so state the gem keeps on the same classes is still audited. Without `ivars:`, that state is allowlisted too, so use the bare form only for classes the gem adds no state to. A `name:` entry for an ivar a rule also covers keeps its own reason. A rule that covered nothing, or whose constant isn't loaded (never loaded by the audit, even if it is a declared autoload), is listed as stale. `BasicObject`, `Object`, `Kernel`, `Module` and `Class` are rejected as ancestors (every module has them), and so is any unknown key in an entry, so a typo cannot widen a rule. `audit.skip_autoloads` takes `name:` entries only.
|
|
165
|
+
|
|
166
|
+
`strict_autoloads: true` needs `audit.require` to load everything, e.g. a file that calls your Zeitwerk loader's `eager_load!`. In a Rails engine, `MyGem::Engine.eager_load!` is a no-op (Rails 7+): call `Zeitwerk::Loader.eager_load_namespace(MyGem)` after booting the dummy app instead, and reference constants in `autoload_once_paths` explicitly.
|
|
160
167
|
|
|
161
168
|
## RuboCop
|
|
162
169
|
|
|
@@ -175,7 +182,7 @@ Turns on the six `rubocop-thread_safety` cops (`ClassAndModuleAttributes`, `Clas
|
|
|
175
182
|
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
183
|
|
|
177
184
|
1. `.conformance.yml` (above),
|
|
178
|
-
2. the Gemfile lines (`conformance-kit ~> 0.
|
|
185
|
+
2. the Gemfile lines (`conformance-kit ~> 0.4`, `rubocop-thread_safety >= 0.8`),
|
|
179
186
|
3. `inherit_gem: { conformance-kit: config/rubocop.yml }` in `.rubocop.yml`,
|
|
180
187
|
4. a caller, pinned to a release tag:
|
|
181
188
|
|
|
@@ -192,7 +199,7 @@ permissions:
|
|
|
192
199
|
|
|
193
200
|
jobs:
|
|
194
201
|
conformance:
|
|
195
|
-
uses: zoolutions/conformance-kit/.github/workflows/conformance.yml@v0.
|
|
202
|
+
uses: zoolutions/conformance-kit/.github/workflows/conformance.yml@v0.4.0
|
|
196
203
|
```
|
|
197
204
|
|
|
198
205
|
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@*`.
|
|
@@ -205,22 +212,30 @@ The workflow has three jobs:
|
|
|
205
212
|
|
|
206
213
|
### Staying current
|
|
207
214
|
|
|
208
|
-
Dependabot bumps both pins
|
|
215
|
+
Dependabot bumps both pins, the `uses:` tag (`github-actions`) and the gem (`bundler`), in **one** pull request through a multi-ecosystem group. It can still open one with a single pin when only one ecosystem has an update so far (the tag is out but RubyGems hasn't published the gem yet): the version handshake then fails that pull request until the other pin catches up, so check both before merging. The `allow` lists keep it to the kit; drop them to have Dependabot maintain everything else too.
|
|
209
216
|
|
|
210
217
|
```yaml
|
|
211
218
|
# .github/dependabot.yml
|
|
212
219
|
version: 2
|
|
220
|
+
multi-ecosystem-groups:
|
|
221
|
+
conformance-kit:
|
|
222
|
+
schedule: { interval: weekly }
|
|
223
|
+
labels: ["dependencies", "devops"] # your repo's own type + area labels
|
|
213
224
|
updates:
|
|
214
225
|
- package-ecosystem: github-actions
|
|
215
226
|
directory: /
|
|
216
|
-
|
|
227
|
+
multi-ecosystem-group: conformance-kit
|
|
228
|
+
patterns: ["zoolutions/conformance-kit*"]
|
|
229
|
+
allow:
|
|
230
|
+
- dependency-name: "zoolutions/conformance-kit*"
|
|
217
231
|
- package-ecosystem: bundler
|
|
218
232
|
directory: /
|
|
219
|
-
|
|
233
|
+
multi-ecosystem-group: conformance-kit
|
|
234
|
+
patterns: ["conformance-kit"]
|
|
235
|
+
allow:
|
|
236
|
+
- dependency-name: conformance-kit
|
|
220
237
|
```
|
|
221
238
|
|
|
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.
|
|
223
|
-
|
|
224
239
|
### `conformance-kit check`, the local equivalent
|
|
225
240
|
|
|
226
241
|
```bash
|
|
@@ -243,7 +258,7 @@ bundle exec conformance-kit config probes.gem_name
|
|
|
243
258
|
|
|
244
259
|
### The version handshake
|
|
245
260
|
|
|
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`.
|
|
261
|
+
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`.
|
|
247
262
|
|
|
248
263
|
## Development
|
|
249
264
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
+
require_relative "ancestor_rule"
|
|
4
5
|
|
|
5
6
|
module ConformanceKit
|
|
6
7
|
class Audit
|
|
@@ -14,8 +15,10 @@ module ConformanceKit
|
|
|
14
15
|
# reason: guards boot-time registration; never touched by a non-main Ractor
|
|
15
16
|
# - name: MyGem::LOCK # a constant: Owner::NAME
|
|
16
17
|
# reason: ...
|
|
18
|
+
# - ancestor: Rails::Engine # framework state on the gem's subclasses
|
|
19
|
+
# reason: ... # (see AncestorRule)
|
|
17
20
|
#
|
|
18
|
-
# `audit.skip_autoloads:` entries have the
|
|
21
|
+
# `audit.skip_autoloads:` entries have the name:/reason: shape (`key:` names the list).
|
|
19
22
|
class Allowlist
|
|
20
23
|
def self.empty = new([])
|
|
21
24
|
|
|
@@ -28,37 +31,96 @@ module ConformanceKit
|
|
|
28
31
|
raise AllowlistError, "#{path}: #{e.message}"
|
|
29
32
|
end
|
|
30
33
|
|
|
31
|
-
attr_reader :entries
|
|
34
|
+
attr_reader :entries, :ancestor_rules
|
|
32
35
|
|
|
33
36
|
def initialize(entries, source: "the allowlist", key: "main_only")
|
|
34
37
|
@key = key
|
|
35
38
|
raise AllowlistError, "#{source}: #{key}: must be a list" unless entries.is_a?(Array)
|
|
36
39
|
|
|
37
40
|
@entries = entries.map { validate(_1, source) }
|
|
41
|
+
@ancestor_rules = @entries.select { _1.key?("ancestor") }.map { AncestorRule.new(_1) }.freeze
|
|
38
42
|
end
|
|
39
43
|
|
|
40
|
-
def names = entries.
|
|
44
|
+
def names = entries.filter_map { _1["name"] }
|
|
41
45
|
def include?(name) = names.include?(name)
|
|
42
46
|
def reason_for(name) = entries.find { _1["name"] == name }&.fetch("reason")
|
|
43
47
|
|
|
48
|
+
# Ancestors every module has: a rule naming one would cover every ivar.
|
|
49
|
+
EVERY_MODULE = %w[BasicObject Object Kernel Module Class].freeze
|
|
50
|
+
|
|
44
51
|
private
|
|
45
52
|
|
|
46
53
|
def validate(entry, source)
|
|
47
54
|
unless entry.is_a?(Hash)
|
|
48
55
|
raise AllowlistError,
|
|
49
|
-
"#{source}: each #{@key} entry needs a name: and a reason:,
|
|
56
|
+
"#{source}: each #{@key} entry needs a name: (or, in main_only, an ancestor:) and a reason:, " \
|
|
57
|
+
"got #{entry.inspect}"
|
|
50
58
|
end
|
|
51
59
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if blank?(entry["reason"])
|
|
55
|
-
raise AllowlistError,
|
|
56
|
-
"#{source}: #{name} has no reason: (say why it is safe)"
|
|
57
|
-
end
|
|
60
|
+
validate_keys(entry, source)
|
|
61
|
+
label = validate_target(entry, source)
|
|
62
|
+
raise AllowlistError, "#{source}: #{label} has no reason: (say why it is safe)" if blank?(entry["reason"])
|
|
58
63
|
|
|
59
64
|
entry
|
|
60
65
|
end
|
|
61
66
|
|
|
67
|
+
# Every key is known, so a typo (ivar: for ivars:) cannot quietly widen a rule.
|
|
68
|
+
def validate_keys(entry, source)
|
|
69
|
+
if @key != "main_only" && entry.key?("ancestor")
|
|
70
|
+
raise AllowlistError, "#{source}: #{@key} entries take a name:, not an ancestor: (#{entry['ancestor']})"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
known = @key == "main_only" ? %w[name ancestor ivars reason] : %w[name reason]
|
|
74
|
+
unknown = entry.keys - known
|
|
75
|
+
return if unknown.empty?
|
|
76
|
+
|
|
77
|
+
raise AllowlistError, "#{source}: a #{@key} entry has an unknown key #{unknown.first} " \
|
|
78
|
+
"(one of #{known.join(', ')}): #{entry.inspect}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# An entry names one constant or ivar (name:), or, in main_only, a
|
|
82
|
+
# framework ancestor (ancestor:, optional ivars:). Returns its label.
|
|
83
|
+
def validate_target(entry, source)
|
|
84
|
+
targets = %w[name ancestor].select { entry.key?(_1) }
|
|
85
|
+
unless targets.size == 1
|
|
86
|
+
raise AllowlistError, "#{source}: each #{@key} entry needs a name: or an ancestor:, not both " \
|
|
87
|
+
"(#{entry.inspect})"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
key = targets.first
|
|
91
|
+
raise AllowlistError, "#{source}: a #{@key} entry has a blank #{key}: (#{entry.inspect})" if blank?(entry[key])
|
|
92
|
+
return validate_name(entry, source) if key == "name"
|
|
93
|
+
|
|
94
|
+
validate_ancestor(entry, entry["ancestor"], source)
|
|
95
|
+
"ancestor #{entry['ancestor']}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def validate_name(entry, source)
|
|
99
|
+
return entry["name"] unless entry.key?("ivars")
|
|
100
|
+
|
|
101
|
+
raise AllowlistError, "#{source}: #{entry['name']}: ivars: only goes with an ancestor:"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Ruby's constant rule, Unicode included: each segment starts with an uppercase letter.
|
|
105
|
+
CONSTANT_PATH = /\A(?:::)?\p{Upper}\p{Word}*(?:::\p{Upper}\p{Word}*)*\z/
|
|
106
|
+
private_constant :CONSTANT_PATH
|
|
107
|
+
|
|
108
|
+
def validate_ancestor(entry, ancestor, source)
|
|
109
|
+
unless ancestor.match?(CONSTANT_PATH)
|
|
110
|
+
raise AllowlistError, "#{source}: ancestor #{ancestor.inspect} is not a constant path (e.g. Rails::Engine)"
|
|
111
|
+
end
|
|
112
|
+
if EVERY_MODULE.include?(ancestor.delete_prefix("::"))
|
|
113
|
+
raise AllowlistError, "#{source}: ancestor #{ancestor} matches every module: name the framework's own class"
|
|
114
|
+
end
|
|
115
|
+
return unless entry.key?("ivars")
|
|
116
|
+
|
|
117
|
+
ivars = entry["ivars"]
|
|
118
|
+
return if ivars.is_a?(Array) && !ivars.empty? && ivars.all? { _1.is_a?(String) && _1.match?(/\A@\w+\z/) }
|
|
119
|
+
|
|
120
|
+
raise AllowlistError,
|
|
121
|
+
"#{source}: ancestor #{ancestor}: ivars: must be a non-empty list of @names, got #{ivars.inspect}"
|
|
122
|
+
end
|
|
123
|
+
|
|
62
124
|
def blank?(value) = !value.is_a?(String) || value.strip.empty?
|
|
63
125
|
end
|
|
64
126
|
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ConformanceKit
|
|
4
|
+
class Audit
|
|
5
|
+
# A `main_only:` entry that names a framework class or module instead of
|
|
6
|
+
# one ivar: every class/module ivar (or only the listed `ivars:`) on a
|
|
7
|
+
# module that inherits from, includes or is extended with `ancestor` is
|
|
8
|
+
# main-only, for one reason. Constants are never covered: they are the
|
|
9
|
+
# gem's own.
|
|
10
|
+
#
|
|
11
|
+
# main_only:
|
|
12
|
+
# - ancestor: ActiveRecord::Base
|
|
13
|
+
# ivars: ["@primary_key"] # optional; omit to cover every ivar
|
|
14
|
+
# reason: ActiveRecord's own class state, set by Rails at boot on the main Ractor
|
|
15
|
+
class AncestorRule
|
|
16
|
+
attr_reader :ancestor, :ivars, :reason
|
|
17
|
+
|
|
18
|
+
def initialize(entry)
|
|
19
|
+
@ancestor = entry.fetch("ancestor").delete_prefix("::")
|
|
20
|
+
@ivars = entry["ivars"]&.dup&.freeze
|
|
21
|
+
@reason = entry.fetch("reason")
|
|
22
|
+
freeze
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def label = ivars ? "ancestor #{ancestor} (#{ivars.join(', ')})" : "ancestor #{ancestor}"
|
|
26
|
+
|
|
27
|
+
# The loaded module +ancestor+ names, or nil when it isn't loaded: not
|
|
28
|
+
# defined, or only a declared autoload (never loaded here: loading it
|
|
29
|
+
# mid-audit would change what is being audited). A nil rule covers
|
|
30
|
+
# nothing and is listed as stale. Called once per audit, after audit.require.
|
|
31
|
+
#
|
|
32
|
+
# Raises AllowlistError when the path resolves to a module every module
|
|
33
|
+
# has (Object::Kernel is Kernel): the name check alone can't see aliases.
|
|
34
|
+
def resolve
|
|
35
|
+
target = walk
|
|
36
|
+
return target unless target && EVERY_MODULE.any? { target.equal?(_1) }
|
|
37
|
+
|
|
38
|
+
raise AllowlistError, "ancestor #{ancestor} resolves to #{target}, which every module has: " \
|
|
39
|
+
"name the framework's own class"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Whether this rule covers +ivar+ on +mod+, given its resolved +target+.
|
|
43
|
+
def covers?(mod, ivar, target)
|
|
44
|
+
return false if ivars && !ivars.include?(ivar.to_s)
|
|
45
|
+
return false if target.equal?(mod)
|
|
46
|
+
|
|
47
|
+
[mod, core(::Kernel, :singleton_class, mod)].any? do |owner|
|
|
48
|
+
core(::Module, :ancestors, owner).any? { _1.equal?(target) }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
EVERY_MODULE = [::BasicObject, ::Object, ::Kernel, ::Module, ::Class].freeze
|
|
53
|
+
private_constant :EVERY_MODULE
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def walk
|
|
58
|
+
ancestor.split("::").reduce(Object) do |scope, segment|
|
|
59
|
+
return nil unless core(::Module, :const_defined?, scope, segment, false)
|
|
60
|
+
return nil if core(::Module, :autoload?, scope, segment, false)
|
|
61
|
+
|
|
62
|
+
value = core(::Module, :const_get, scope, segment, false)
|
|
63
|
+
return nil unless ::Module === value # rubocop:disable Style/CaseEquality -- a BasicObject has no is_a?
|
|
64
|
+
|
|
65
|
+
value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The core reflection methods, bound directly, as in Audit.
|
|
70
|
+
def core(owner, method, receiver, *) = owner.instance_method(method).bind_call(receiver, *)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -112,9 +112,9 @@ module ConformanceKit
|
|
|
112
112
|
end
|
|
113
113
|
|
|
114
114
|
def details(audit, strict_autoloads)
|
|
115
|
-
allowlisted = audit.allowlisted.map { "#{_1.name} — #{audit.
|
|
115
|
+
allowlisted = audit.allowlisted.map { "#{_1.name} — #{audit.reason_for(_1)}" }
|
|
116
116
|
section("allowlisted main-only:", allowlisted)
|
|
117
|
-
section("stale allowlist entries (no longer found):", audit.stale)
|
|
117
|
+
section("stale allowlist entries (no longer found, or rules that covered nothing):", audit.stale)
|
|
118
118
|
skipped = audit.skipped_autoloads - audit.unexpected_autoloads
|
|
119
119
|
section("unloaded autoloads skipped by audit.skip_autoloads:",
|
|
120
120
|
skipped.map { "#{_1} — #{audit.skip_autoloads.reason_for(_1)}" })
|
|
@@ -12,7 +12,8 @@ module ConformanceKit
|
|
|
12
12
|
# eager-load) everything the audit should see, and list deliberate exceptions
|
|
13
13
|
# in `skip_autoloads` (`audit.skip_autoloads:` in .conformance.yml).
|
|
14
14
|
class Audit
|
|
15
|
-
|
|
15
|
+
# +rule+: the main_only ancestor: rule that covers it, or nil.
|
|
16
|
+
Finding = Data.define(:name, :kind, :value_class, :path, :reason, :rule) do
|
|
16
17
|
def to_s
|
|
17
18
|
where = path.empty? ? "" : " #{path} is"
|
|
18
19
|
"#{name} (#{kind}) #{value_class}:#{where} #{reason}"
|
|
@@ -42,10 +43,16 @@ module ConformanceKit
|
|
|
42
43
|
# Skipped autoloads not listed in skip_autoloads: what strict_autoloads fails on.
|
|
43
44
|
def unexpected_autoloads = skipped_autoloads - @skip_autoloads.names
|
|
44
45
|
|
|
45
|
-
#
|
|
46
|
+
# Why an allowlisted finding is main-only: its own name: entry's reason, else its ancestor: rule's.
|
|
47
|
+
def reason_for(finding) = @allowlist.reason_for(finding.name) || finding.rule&.reason
|
|
48
|
+
|
|
49
|
+
# Allowlisted names inside the audited namespaces that no longer turn up,
|
|
50
|
+
# and ancestor: rules that covered nothing.
|
|
46
51
|
def stale
|
|
47
52
|
found = scan.map(&:name)
|
|
48
|
-
|
|
53
|
+
used = scan.filter_map(&:rule)
|
|
54
|
+
@allowlist.names.select { in_namespace?(_1) && !found.include?(_1) } +
|
|
55
|
+
@allowlist.ancestor_rules.reject { |rule| used.any? { _1.equal?(rule) } }.map(&:label)
|
|
49
56
|
end
|
|
50
57
|
|
|
51
58
|
# skip_autoloads names inside the audited namespaces that are no longer unloaded autoloads.
|
|
@@ -57,7 +64,7 @@ module ConformanceKit
|
|
|
57
64
|
private
|
|
58
65
|
|
|
59
66
|
def partitioned
|
|
60
|
-
@partitioned ||= scan.partition { !@allowlist.include?(_1.name) }
|
|
67
|
+
@partitioned ||= scan.partition { !_1.rule && !@allowlist.include?(_1.name) }
|
|
61
68
|
end
|
|
62
69
|
|
|
63
70
|
def scan
|
|
@@ -82,14 +89,14 @@ module ConformanceKit
|
|
|
82
89
|
constants_of(mod).filter_map do |constant|
|
|
83
90
|
next if autoload?(mod, constant)
|
|
84
91
|
|
|
85
|
-
finding("#{name}::#{constant}", "constant", core(::Module, :const_get, mod, constant, false))
|
|
92
|
+
finding("#{name}::#{constant}", "constant", core(::Module, :const_get, mod, constant, false), nil)
|
|
86
93
|
end
|
|
87
94
|
end
|
|
88
95
|
|
|
89
96
|
def ivar_findings(mod, name)
|
|
90
97
|
core(::Kernel, :instance_variables, mod).filter_map do |ivar|
|
|
91
98
|
kind = mod.is_a?(Class) ? "class ivar" : "module ivar"
|
|
92
|
-
finding("#{name}.#{ivar}", kind, core(::Kernel, :instance_variable_get, mod, ivar))
|
|
99
|
+
finding("#{name}.#{ivar}", kind, core(::Kernel, :instance_variable_get, mod, ivar), mod, ivar)
|
|
93
100
|
end
|
|
94
101
|
end
|
|
95
102
|
|
|
@@ -99,11 +106,19 @@ module ConformanceKit
|
|
|
99
106
|
def constants_of(mod) = core(::Module, :constants, mod, false)
|
|
100
107
|
def autoload?(mod, constant) = core(::Module, :autoload?, mod, constant)
|
|
101
108
|
|
|
102
|
-
|
|
109
|
+
# +mod+ and +ivar+ (an ivar finding only) are what an ancestor: rule matches on.
|
|
110
|
+
def finding(name, kind, value, mod, ivar = nil)
|
|
103
111
|
culprit = Shareability.culprit(value)
|
|
104
112
|
return nil unless culprit
|
|
105
113
|
|
|
106
|
-
Finding.new(name: name, kind: kind, value_class: value.
|
|
114
|
+
Finding.new(name: name, kind: kind, value_class: core(::Kernel, :class, value).name, path: culprit.path,
|
|
115
|
+
reason: culprit.reason, rule: ivar && rule_for(mod, ivar))
|
|
107
116
|
end
|
|
117
|
+
|
|
118
|
+
# The first ancestor: rule covering +ivar+ on +mod+, or nil.
|
|
119
|
+
def rule_for(mod, ivar) = rule_targets.find { |rule, target| target && rule.covers?(mod, ivar, target) }&.first
|
|
120
|
+
|
|
121
|
+
# Each ancestor: rule with the module it names, resolved once, after audit.require.
|
|
122
|
+
def rule_targets = @rule_targets ||= @allowlist.ancestor_rules.map { [_1, _1.resolve] }
|
|
108
123
|
end
|
|
109
124
|
end
|
|
@@ -117,7 +117,7 @@ module ConformanceKit
|
|
|
117
117
|
string: "a string (quote it)",
|
|
118
118
|
boolean: "true or false",
|
|
119
119
|
string_map: "a mapping of names to string values (quote numbers)",
|
|
120
|
-
entries: "a list of name:/reason: entries"
|
|
120
|
+
entries: "a list of name:/reason: (or, in main_only, ancestor:/reason:) entries"
|
|
121
121
|
}.freeze
|
|
122
122
|
private_constant :EXPECTED
|
|
123
123
|
|
|
@@ -8,8 +8,11 @@ module ConformanceKit
|
|
|
8
8
|
# a Mutex, created at load (never lazily, so there is no check-then-set race);
|
|
9
9
|
# it is the kit's one allowlisted main-only state (.conformance.yml). A
|
|
10
10
|
# non-main Ractor on 3.2/3.3 cannot read it, so it keeps its own values in
|
|
11
|
-
# Ractor-local storage instead
|
|
12
|
-
# build a value
|
|
11
|
+
# Ractor-local storage instead. No lock can be shared there, so threads of
|
|
12
|
+
# one Ractor may each build a value; they all return the first one
|
|
13
|
+
# published, except in the rare case of a thread switch inside the publish
|
|
14
|
+
# itself (`Ractor.current[slot] ||= built` is a read and a write), which no
|
|
15
|
+
# Ractor-local primitive before 3.4 can close.
|
|
13
16
|
module Store
|
|
14
17
|
# rubocop:disable ThreadSafety/MutableClassInstanceVariable -- main-only fallback, guarded by @fallback_lock
|
|
15
18
|
@fallback_lock = Mutex.new
|
|
@@ -31,9 +34,17 @@ module ConformanceKit
|
|
|
31
34
|
|
|
32
35
|
def main_ractor? = !defined?(::Ractor) || ::Ractor.current == ::Ractor.main
|
|
33
36
|
|
|
34
|
-
# Boxed, so a nil or false value is cached rather than rebuilt on every
|
|
37
|
+
# Boxed, so a nil or false value is cached rather than rebuilt on every
|
|
38
|
+
# fetch. Built before the check-and-publish, never between them: threads
|
|
39
|
+
# that raced on the build return whichever value was stored first (see
|
|
40
|
+
# the module comment for the switch-inside-the-publish limit).
|
|
35
41
|
def ractor_local(key)
|
|
36
|
-
|
|
42
|
+
slot = :"conformance_kit_store/#{key}"
|
|
43
|
+
stored = ::Ractor.current[slot]
|
|
44
|
+
return stored.first if stored
|
|
45
|
+
|
|
46
|
+
built = [yield]
|
|
47
|
+
(::Ractor.current[slot] ||= built).first
|
|
37
48
|
end
|
|
38
49
|
|
|
39
50
|
public
|
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.4.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mhenrixon
|
|
@@ -30,6 +30,7 @@ files:
|
|
|
30
30
|
- lib/conformance_kit.rb
|
|
31
31
|
- lib/conformance_kit/audit.rb
|
|
32
32
|
- lib/conformance_kit/audit/allowlist.rb
|
|
33
|
+
- lib/conformance_kit/audit/ancestor_rule.rb
|
|
33
34
|
- lib/conformance_kit/audit/cli.rb
|
|
34
35
|
- lib/conformance_kit/checks/boot_finalized.rb
|
|
35
36
|
- lib/conformance_kit/checks/consistency.rb
|