conformance-kit 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f6e770de2e65a548378c2950a8f74f1a6c56b16609ac26664ec6296bd17f2bbd
4
- data.tar.gz: 2d97f05248db1e51dd036b286ace5bad7c75ab2a9de2443eec7589165a4bd700
3
+ metadata.gz: 6f0701a2aba3683c8dff95e66fb8721c6a5dc51211e3afdb10ab30eaf8dae480
4
+ data.tar.gz: def47390a131f01068f3da132c1054ba763ceff96a64a103d7b39064e4d60f7b
5
5
  SHA512:
6
- metadata.gz: f008a9934b689d327cef7f99483618e389da37ee375462bd2ab1097fba758106578cdd3f2b36265ba000487383473e35613c8dfbc1da413baed8216e517f005d
7
- data.tar.gz: 3f70b113493211202cf08ee4e66fc251417ff998d481a2944d465f4cf605cdf93235541f8cc152d3ac87e1eb8af1c88c268ed0a6b4e54d1f1a0ccc7ea7f197ab
6
+ metadata.gz: b875f57d615bb14cfe2bbb42f0250409c3dcf91e30c344d44a59f3dc3a71a8ed4c7ddb64dc56972db6bf86292a4ee049483c9ee89fb49ad568fc0f3a6cd1e3d1
7
+ data.tar.gz: 6b52bcfc88febff716fa8554ab6b4c44c3729b8762d94151f48c535a0af6d5207fa27db63e5e64a10d879fda0bf96653f3b7f2e10626df770342abb502c99eed
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - `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).
6
+ - `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`.
7
+
8
+ ## 0.3.0
9
+
10
+ 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.
11
+
12
+ - The version handshake reads `job.workflow_ref` (the called workflow's own tag) instead of `github.workflow_ref` (the caller's).
13
+ - This repo calls its own workflow through `.github/workflows/conformance-self.yml`.
14
+
3
15
  ## 0.2.0
4
16
 
5
17
  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.
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.2", require: false
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
@@ -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
 
@@ -172,17 +179,62 @@ Turns on the six `rubocop-thread_safety` cops (`ClassAndModuleAttributes`, `Clas
172
179
 
173
180
  ## Enforcement
174
181
 
175
- Conformance is enforced by **one workflow in this repo**, `.github/workflows/conformance.yml`, which a zoolutions org ruleset requires at a release tag (`conformance.yml@refs/tags/vX.Y.Z`). A repo needs no workflow of its own: it opts in with
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.2`, `rubocop-thread_safety >= 0.8`),
179
- 3. `inherit_gem: { conformance-kit: config/rubocop.yml }` in `.rubocop.yml`.
185
+ 2. the Gemfile lines (`conformance-kit ~> 0.3`, `rubocop-thread_safety >= 0.8`),
186
+ 3. `inherit_gem: { conformance-kit: config/rubocop.yml }` in `.rubocop.yml`,
187
+ 4. a caller, pinned to a release tag:
188
+
189
+ ```yaml
190
+ # .github/workflows/conformance.yml
191
+ name: Conformance
192
+
193
+ on:
194
+ pull_request:
195
+ merge_group:
196
+
197
+ permissions:
198
+ contents: read
199
+
200
+ jobs:
201
+ conformance:
202
+ uses: zoolutions/conformance-kit/.github/workflows/conformance.yml@v0.3.0
203
+ ```
180
204
 
181
- The workflow runs on `pull_request` and `merge_group`, with two jobs:
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@*`.
182
206
 
207
+ The workflow has three jobs:
208
+
209
+ - **Conformance settings** reads `ractor.ruby` and `ractor.working_directory`.
183
210
  - **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.
184
211
  - **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.
185
212
 
213
+ ### Staying current
214
+
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.
216
+
217
+ ```yaml
218
+ # .github/dependabot.yml
219
+ version: 2
220
+ multi-ecosystem-groups:
221
+ conformance-kit:
222
+ schedule: { interval: weekly }
223
+ updates:
224
+ - package-ecosystem: github-actions
225
+ directory: /
226
+ multi-ecosystem-group: conformance-kit
227
+ patterns: ["zoolutions/conformance-kit*"]
228
+ allow:
229
+ - dependency-name: "zoolutions/conformance-kit*"
230
+ - package-ecosystem: bundler
231
+ directory: /
232
+ multi-ecosystem-group: conformance-kit
233
+ patterns: ["conformance-kit"]
234
+ allow:
235
+ - dependency-name: conformance-kit
236
+ ```
237
+
186
238
  ### `conformance-kit check`, the local equivalent
187
239
 
188
240
  ```bash
@@ -191,7 +243,7 @@ bundle exec conformance-kit doctor # the static checks only
191
243
  bundle exec conformance-kit config probes.gem_name
192
244
  ```
193
245
 
194
- `check` stops at the first failing stage, names it, and exits with its status. Run it before flipping a repo: what passes here passes the workflow (on the same Ruby).
246
+ `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).
195
247
 
196
248
  `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`.
197
249
 
@@ -205,7 +257,7 @@ bundle exec conformance-kit config probes.gem_name
205
257
 
206
258
  ### The version handshake
207
259
 
208
- The behaviour a repo is held to comes from the `conformance-kit` in its lockfile; only the plumbing comes from the workflow tag. So a stale lockfile cannot pass a newer workflow vacuously, the workflow reads its own tag from `github.workflow_ref` and fails with "bump conformance-kit to ~> X.Y" when the repo resolves anything older than `X.Y`. The version is derived, never stored: a release bumps only the ruleset's `ref`. On a ref that is not a `refs/tags/v*` tag (this repo's own pull requests) the handshake is skipped with a notice. That `github.workflow_ref` carries the tag for a ruleset-required workflow is verified in the rollout pilot; until then, treat the handshake as pending validation.
260
+ 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`.
209
261
 
210
262
  ## Development
211
263
 
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ConformanceKit
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: conformance-kit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mhenrixon
@@ -10,7 +10,7 @@ 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 an org-required CI workflow that hold a gem to explicit thread-safe,
13
+ RuboCop configs and a reusable CI workflow that hold a gem to explicit thread-safe,
14
14
  fiber-safe and Ractor-clean tiers.
15
15
  email:
16
16
  - mikael@mhenrixon.com
@@ -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