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 +4 -4
- data/CHANGELOG.md +5 -0
- data/README.md +21 -7
- 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/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: 6f0701a2aba3683c8dff95e66fb8721c6a5dc51211e3afdb10ab30eaf8dae480
|
|
4
|
+
data.tar.gz: def47390a131f01068f3da132c1054ba763ceff96a64a103d7b39064e4d60f7b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
`
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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`.
|
|
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
|
|
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
|
|
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.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
|