conformance-kit 0.3.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: 6e74bc1ed523498fd8aa7cd04191a6b13b690568c21a5a16627c0d2d147afb15
4
- data.tar.gz: 9138ad7259e5f81442f50c9fab3e6a9b3fd08a3569a4861f27e62bc945f446b5
3
+ metadata.gz: 6f0701a2aba3683c8dff95e66fb8721c6a5dc51211e3afdb10ab30eaf8dae480
4
+ data.tar.gz: def47390a131f01068f3da132c1054ba763ceff96a64a103d7b39064e4d60f7b
5
5
  SHA512:
6
- metadata.gz: acb89af04013c0b5e8efc69da423c53f794c9305359c0f7dfe131066cfa9f47f6436e586c6de74d80272a25a3f8f29deab16831045c37971d30815968b6286e0
7
- data.tar.gz: 4ac36fc7dbef17b06b1c2fd7bca8c38ee6f915a910324f1173602af05d51ed571207c2f02536cd8d926adebe2d9e54a3c484dc606976d1bf199424860cedb209
6
+ metadata.gz: b875f57d615bb14cfe2bbb42f0250409c3dcf91e30c344d44a59f3dc3a71a8ed4c7ddb64dc56972db6bf86292a4ee049483c9ee89fb49ad568fc0f3a6cd1e3d1
7
+ data.tar.gz: 6b52bcfc88febff716fa8554ab6b4c44c3729b8762d94151f48c535a0af6d5207fa27db63e5e64a10d879fda0bf96653f3b7f2e10626df770342abb502c99eed
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
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
+
3
8
  ## 0.3.0
4
9
 
5
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.
data/README.md CHANGED
@@ -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
 
@@ -205,22 +212,29 @@ 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 }
213
223
  updates:
214
224
  - package-ecosystem: github-actions
215
225
  directory: /
216
- schedule: { interval: weekly }
226
+ multi-ecosystem-group: conformance-kit
227
+ patterns: ["zoolutions/conformance-kit*"]
228
+ allow:
229
+ - dependency-name: "zoolutions/conformance-kit*"
217
230
  - package-ecosystem: bundler
218
231
  directory: /
219
- schedule: { interval: weekly }
232
+ multi-ecosystem-group: conformance-kit
233
+ patterns: ["conformance-kit"]
234
+ allow:
235
+ - dependency-name: conformance-kit
220
236
  ```
221
237
 
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
238
  ### `conformance-kit check`, the local equivalent
225
239
 
226
240
  ```bash
@@ -243,7 +257,7 @@ bundle exec conformance-kit config probes.gem_name
243
257
 
244
258
  ### The version handshake
245
259
 
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.
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`.
247
261
 
248
262
  ## Development
249
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.3.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.3.0
4
+ version: 0.4.0
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