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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e74bc1ed523498fd8aa7cd04191a6b13b690568c21a5a16627c0d2d147afb15
4
- data.tar.gz: 9138ad7259e5f81442f50c9fab3e6a9b3fd08a3569a4861f27e62bc945f446b5
3
+ metadata.gz: 18bb6e4722bbdc745b4efcbd1f9b3c3106b6193f235f9657d33e94f41c93b38a
4
+ data.tar.gz: 6b023ed04a3167ab8a43f0689691a4416b7ab5fcb2714d71bb1864d62162c3df
5
5
  SHA512:
6
- metadata.gz: acb89af04013c0b5e8efc69da423c53f794c9305359c0f7dfe131066cfa9f47f6436e586c6de74d80272a25a3f8f29deab16831045c37971d30815968b6286e0
7
- data.tar.gz: 4ac36fc7dbef17b06b1c2fd7bca8c38ee6f915a910324f1173602af05d51ed571207c2f02536cd8d926adebe2d9e54a3c484dc606976d1bf199424860cedb209
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.3", require: false
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
- `strict_autoloads: true` needs `audit.require` to load everything, e.g. a file that calls your Zeitwerk loader's `eager_load!`.
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.3`, `rubocop-thread_safety >= 0.8`),
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.3.0
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: the `uses:` tag (`github-actions`) and the gem (`bundler`), in separate pull requests.
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
- schedule: { interval: weekly }
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
- schedule: { interval: weekly }
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`. 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.
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 same shape (`key:` names the list).
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.map { _1["name"] }
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:, got #{entry.inspect}"
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
- name = entry["name"]
53
- raise AllowlistError, "#{source}: a #{@key} entry has no name: (#{entry.inspect})" if blank?(name)
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.allowlist.reason_for(_1.name)}" }
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
- Finding = Data.define(:name, :kind, :value_class, :path, :reason) do
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
- # Allowlisted names inside the audited namespaces that no longer turn up.
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
- @allowlist.names.select { in_namespace?(_1) && !found.include?(_1) }
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
- def finding(name, kind, value)
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.class.name, path: culprit.path, reason: culprit.reason)
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 (not locked: one Ractor's threads may race to
12
- # build a value, as with any `||=`).
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 fetch.
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
- (::Ractor.current[:"conformance_kit_store/#{key}"] ||= [yield]).first
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConformanceKit
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.1"
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.3.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