rigortype 0.0.9 → 0.1.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/README.md +40 -2
- data/lib/rigor/analysis/check_rules.rb +228 -40
- data/lib/rigor/analysis/diagnostic.rb +15 -1
- data/lib/rigor/analysis/runner.rb +183 -4
- data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
- data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
- data/lib/rigor/cache/rbs_constant_table.rb +2 -2
- data/lib/rigor/cache/rbs_descriptor.rb +2 -0
- data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
- data/lib/rigor/cache/store.rb +2 -0
- data/lib/rigor/cli.rb +9 -3
- data/lib/rigor/configuration/severity_profile.rb +109 -0
- data/lib/rigor/configuration.rb +110 -6
- data/lib/rigor/environment/rbs_loader.rb +89 -13
- data/lib/rigor/flow_contribution/conflict.rb +81 -0
- data/lib/rigor/flow_contribution/element.rb +53 -0
- data/lib/rigor/flow_contribution/fact.rb +88 -0
- data/lib/rigor/flow_contribution/merge_result.rb +67 -0
- data/lib/rigor/flow_contribution/merger.rb +275 -0
- data/lib/rigor/flow_contribution.rb +51 -0
- data/lib/rigor/inference/block_parameter_binder.rb +15 -0
- data/lib/rigor/inference/expression_typer.rb +84 -5
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +95 -9
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
- data/lib/rigor/inference/multi_target_binder.rb +2 -0
- data/lib/rigor/inference/narrowing.rb +105 -130
- data/lib/rigor/inference/scope_indexer.rb +75 -1
- data/lib/rigor/inference/statement_evaluator.rb +380 -40
- data/lib/rigor/plugin/access_denied_error.rb +24 -0
- data/lib/rigor/plugin/base.rb +241 -0
- data/lib/rigor/plugin/io_boundary.rb +102 -0
- data/lib/rigor/plugin/load_error.rb +23 -0
- data/lib/rigor/plugin/loader.rb +191 -0
- data/lib/rigor/plugin/manifest.rb +134 -0
- data/lib/rigor/plugin/registry.rb +50 -0
- data/lib/rigor/plugin/services.rb +65 -0
- data/lib/rigor/plugin/trust_policy.rb +99 -0
- data/lib/rigor/plugin.rb +61 -0
- data/lib/rigor/rbs_extended.rb +57 -9
- data/lib/rigor/reflection.rb +2 -2
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +7 -0
- data/sig/rigor/environment.rbs +7 -1
- data/sig/rigor/inference.rbs +1 -0
- data/sig/rigor/rbs_extended.rbs +2 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +7 -0
- metadata +18 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "manifest"
|
|
7
|
+
|
|
8
|
+
module Rigor
|
|
9
|
+
module Plugin
|
|
10
|
+
# Base class every Rigor plugin subclasses. The plugin gem
|
|
11
|
+
# subclasses {Base}, declares its identity through {.manifest},
|
|
12
|
+
# registers the subclass with {Rigor::Plugin.register}, and
|
|
13
|
+
# overrides {#init} to wire up any state it needs from the
|
|
14
|
+
# injected service container.
|
|
15
|
+
#
|
|
16
|
+
# Slice 1 ships only the registration / loading plumbing. The
|
|
17
|
+
# protocol hooks (dynamic-return contributions, type-specifying
|
|
18
|
+
# contributions, dynamic reflection) land in subsequent v0.1.0
|
|
19
|
+
# slices and arrive as additional methods on this class.
|
|
20
|
+
#
|
|
21
|
+
# Example plugin:
|
|
22
|
+
#
|
|
23
|
+
# class MyRailsPlugin < Rigor::Plugin::Base
|
|
24
|
+
# manifest(
|
|
25
|
+
# id: "rails",
|
|
26
|
+
# version: "0.1.0",
|
|
27
|
+
# description: "Rails framework support for Rigor"
|
|
28
|
+
# )
|
|
29
|
+
#
|
|
30
|
+
# def init(services)
|
|
31
|
+
# @reflection = services.reflection
|
|
32
|
+
# @type = services.type
|
|
33
|
+
# end
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# Rigor::Plugin.register(MyRailsPlugin)
|
|
37
|
+
class Base
|
|
38
|
+
class << self
|
|
39
|
+
# Declares the plugin's manifest. Called once at class
|
|
40
|
+
# definition time — the resulting {Manifest} is cached on
|
|
41
|
+
# the class so {Rigor::Plugin::Loader} reads it without
|
|
42
|
+
# constructing the plugin.
|
|
43
|
+
def manifest(**fields)
|
|
44
|
+
if fields.empty?
|
|
45
|
+
raise ArgumentError, "plugin #{self} did not declare a manifest" unless defined?(@manifest) && @manifest
|
|
46
|
+
|
|
47
|
+
return @manifest
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
@manifest = Manifest.new(**fields)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# ADR-7 § "Slice 6-A" — DSL declaration of a cached
|
|
54
|
+
# producer. Plugin authors write
|
|
55
|
+
#
|
|
56
|
+
# class MyPlugin < Rigor::Plugin::Base
|
|
57
|
+
# manifest(id: "rails", version: "0.1.0")
|
|
58
|
+
#
|
|
59
|
+
# producer :schema_table do |params|
|
|
60
|
+
# schema = io_boundary.read_file("db/schema.rb")
|
|
61
|
+
# parse(schema, params)
|
|
62
|
+
# end
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
# The block runs through `instance_exec` so `self` inside
|
|
66
|
+
# the body is the plugin instance — `io_boundary`,
|
|
67
|
+
# `services`, `manifest`, `config` are all in scope. The
|
|
68
|
+
# block receives the call-site `params` Hash as its sole
|
|
69
|
+
# argument; the same params Hash mixes into the cache
|
|
70
|
+
# key per `Cache::Descriptor#cache_key_for`.
|
|
71
|
+
#
|
|
72
|
+
# `serialize:` / `deserialize:` are forwarded verbatim to
|
|
73
|
+
# `Cache::Store#fetch_or_compute`. Default round-trip is
|
|
74
|
+
# `Marshal.dump` / `Marshal.load` per the v0.0.9 callable
|
|
75
|
+
# surface; producers whose return values are not Marshal-
|
|
76
|
+
# clean must supply their own pair.
|
|
77
|
+
#
|
|
78
|
+
# Producer ids are auto-prefixed `plugin.<manifest.id>.`
|
|
79
|
+
# at the cache layer (slice 6-C) so plugin-side ids cannot
|
|
80
|
+
# collide with built-in producers.
|
|
81
|
+
def producer(id, serialize: nil, deserialize: nil, &block)
|
|
82
|
+
raise ArgumentError, "Plugin::Base.producer requires a block body" if block.nil?
|
|
83
|
+
|
|
84
|
+
@producers ||= {}
|
|
85
|
+
@producers[id.to_sym] = { block: block, serialize: serialize, deserialize: deserialize }.freeze
|
|
86
|
+
id.to_sym
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Frozen snapshot of the producer table. Inherited
|
|
90
|
+
# producers from a superclass are intentionally NOT
|
|
91
|
+
# surfaced — Plugin::Base subclasses do not chain
|
|
92
|
+
# producers, and the loader instantiates one
|
|
93
|
+
# subclass per registration.
|
|
94
|
+
def producers
|
|
95
|
+
(@producers || {}).dup.freeze
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
attr_reader :services, :config
|
|
100
|
+
|
|
101
|
+
def initialize(services:, config: {})
|
|
102
|
+
@services = services
|
|
103
|
+
@config = config.freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Override in subclasses to wire any state the plugin needs
|
|
107
|
+
# from the injected service container. Default is a no-op so
|
|
108
|
+
# plugins that only contribute through later-slice protocol
|
|
109
|
+
# hooks do not have to define an explicit body.
|
|
110
|
+
def init(services) # rubocop:disable Lint/UnusedMethodArgument
|
|
111
|
+
nil
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# ADR-7 § "Slice 5-A" — per-file diagnostic emission hook.
|
|
115
|
+
# Override in plugin subclasses to return an array of
|
|
116
|
+
# `Rigor::Analysis::Diagnostic` rows for the analysed file.
|
|
117
|
+
# The runner stamps each returned diagnostic with
|
|
118
|
+
# `source_family: "plugin.<manifest.id>"` automatically per
|
|
119
|
+
# ADR-7 § "Slice 5-B"; plugin authors should construct
|
|
120
|
+
# diagnostics without setting `source_family` (any value
|
|
121
|
+
# they pass is overwritten).
|
|
122
|
+
#
|
|
123
|
+
# `path` is the analysed file path; `scope` is the entry
|
|
124
|
+
# `Rigor::Scope` after `ScopeIndexer` ran; `root` is the
|
|
125
|
+
# parsed `Prism::Node` root. Plugin authors traverse `root`
|
|
126
|
+
# themselves if they need node-scoped rules — the
|
|
127
|
+
# `Rule<TNode>` API ADR-2 § "Custom rules" mentions stays
|
|
128
|
+
# deferred to v0.1.x.
|
|
129
|
+
#
|
|
130
|
+
# Default returns `[]` so plugins that contribute through
|
|
131
|
+
# other channels (e.g. slice-4 narrowing contributions,
|
|
132
|
+
# slice-6 cache producers) do not have to override.
|
|
133
|
+
def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
|
|
134
|
+
[]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Convenience accessor — `manifest` on the instance returns
|
|
138
|
+
# the class-level manifest declaration.
|
|
139
|
+
def manifest
|
|
140
|
+
self.class.manifest
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}.
|
|
144
|
+
# Memoised so the boundary's accumulated `FileEntry`
|
|
145
|
+
# rows persist across producer invocations within the
|
|
146
|
+
# same plugin instance and feed cache invalidation
|
|
147
|
+
# via `cache_for`.
|
|
148
|
+
def io_boundary
|
|
149
|
+
@io_boundary ||= services.io_boundary_for(manifest.id)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# ADR-7 § "Slice 6-A" — returns a callable that performs
|
|
153
|
+
# a `Cache::Store#fetch_or_compute` round-trip for the
|
|
154
|
+
# named producer. The descriptor (per ADR-7 § "Slice
|
|
155
|
+
# 6-B") is auto-assembled from the plugin's
|
|
156
|
+
# `PluginEntry` template (id, version, config_hash) and
|
|
157
|
+
# the {IoBoundary} read history. The producer id is
|
|
158
|
+
# auto-prefixed `plugin.<manifest.id>.` per ADR-7 §
|
|
159
|
+
# "Slice 6-C" so plugin caches stay sandboxed from
|
|
160
|
+
# built-in producers.
|
|
161
|
+
#
|
|
162
|
+
# When `services.cache_store` is `nil` (e.g. CLI
|
|
163
|
+
# `--no-cache`), the callable bypasses the cache and
|
|
164
|
+
# runs the producer block every time — same semantics
|
|
165
|
+
# as the v0.0.9 cache surface for built-in producers.
|
|
166
|
+
#
|
|
167
|
+
# `descriptor:` (optional, ADR-7 § "Slice 6" follow-up)
|
|
168
|
+
# supplies extra `Cache::Descriptor` rows the plugin
|
|
169
|
+
# author wants to compose into the auto-built descriptor
|
|
170
|
+
# — typically gem-version `GemEntry`, configuration-file
|
|
171
|
+
# `FileEntry` digests, or `ConfigEntry` rows for external
|
|
172
|
+
# state the {IoBoundary} cannot capture itself. The
|
|
173
|
+
# passed descriptor composes via `Cache::Descriptor.compose`
|
|
174
|
+
# with the auto-built one (PluginEntry template + boundary
|
|
175
|
+
# reads); per-slot conflicts raise
|
|
176
|
+
# `Cache::Descriptor::Conflict` to make divergent inputs
|
|
177
|
+
# visible rather than silently shadowing.
|
|
178
|
+
def cache_for(producer_id, params: {}, descriptor: nil)
|
|
179
|
+
producer = self.class.producers[producer_id.to_sym]
|
|
180
|
+
unless producer
|
|
181
|
+
raise ArgumentError,
|
|
182
|
+
"plugin #{manifest.id.inspect} did not declare producer #{producer_id.inspect}"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
compute = -> { instance_exec(params, &producer[:block]) }
|
|
186
|
+
store = services.cache_store
|
|
187
|
+
return compute unless store
|
|
188
|
+
|
|
189
|
+
prefixed_id = "plugin.#{manifest.id}.#{producer_id}"
|
|
190
|
+
composed_descriptor = compose_cache_descriptor(descriptor)
|
|
191
|
+
lambda do
|
|
192
|
+
store.fetch_or_compute(
|
|
193
|
+
producer_id: prefixed_id,
|
|
194
|
+
params: params,
|
|
195
|
+
descriptor: composed_descriptor,
|
|
196
|
+
serialize: producer[:serialize],
|
|
197
|
+
deserialize: producer[:deserialize],
|
|
198
|
+
&compute
|
|
199
|
+
)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
private
|
|
204
|
+
|
|
205
|
+
# ADR-7 § "Slice 6-B" — composes the per-call cache
|
|
206
|
+
# descriptor from (1) the plugin's PluginEntry template
|
|
207
|
+
# and (2) the IoBoundary's accumulated FileEntry rows.
|
|
208
|
+
def build_plugin_cache_descriptor
|
|
209
|
+
plugin_entry = Cache::Descriptor::PluginEntry.new(
|
|
210
|
+
id: manifest.id,
|
|
211
|
+
version: manifest.version,
|
|
212
|
+
config_hash: digest_config(config)
|
|
213
|
+
)
|
|
214
|
+
boundary_descriptor = io_boundary.cache_descriptor
|
|
215
|
+
Cache::Descriptor.new(
|
|
216
|
+
plugins: [plugin_entry],
|
|
217
|
+
files: boundary_descriptor.files
|
|
218
|
+
)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# ADR-7 § "Slice 6" follow-up — composes the auto-built
|
|
222
|
+
# cache descriptor with an optional plugin-author-supplied
|
|
223
|
+
# extension. Extra `GemEntry` / `FileEntry` / `ConfigEntry`
|
|
224
|
+
# rows the plugin needs (gem-version pins, external
|
|
225
|
+
# configuration files, sibling-plugin state) flow through
|
|
226
|
+
# `Cache::Descriptor.compose`; the union behaviour matches
|
|
227
|
+
# built-in producers (`RbsConstantTable`, `RbsEnvironment`).
|
|
228
|
+
def compose_cache_descriptor(extra)
|
|
229
|
+
auto_built = build_plugin_cache_descriptor
|
|
230
|
+
return auto_built if extra.nil?
|
|
231
|
+
|
|
232
|
+
Cache::Descriptor.compose(auto_built, extra)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def digest_config(config)
|
|
236
|
+
canonical = Cache::Descriptor.canonicalize_value(config || {})
|
|
237
|
+
Digest::SHA256.hexdigest(JSON.generate(canonical))
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "access_denied_error"
|
|
6
|
+
|
|
7
|
+
module Rigor
|
|
8
|
+
module Plugin
|
|
9
|
+
# Analyzer-side helper plugins go through to read files and
|
|
10
|
+
# (eventually) reach the network. The boundary enforces the
|
|
11
|
+
# active {TrustPolicy} and accumulates a {Cache::Descriptor}
|
|
12
|
+
# of every read so plugin contributions stay invalidatable
|
|
13
|
+
# alongside their inputs.
|
|
14
|
+
#
|
|
15
|
+
# ADR-2 § "Plugin Trust and I/O Policy" is the binding
|
|
16
|
+
# contract. The boundary is **not** a sandbox: a plugin that
|
|
17
|
+
# uses `File.read` directly bypasses everything here, and the
|
|
18
|
+
# ADR explicitly accepts that trade-off. The discipline is:
|
|
19
|
+
# when plugin code goes through this surface, reads stay
|
|
20
|
+
# within the trust scope and feed the cache descriptor;
|
|
21
|
+
# contributions built on top of out-of-scope reads will not
|
|
22
|
+
# invalidate correctly.
|
|
23
|
+
#
|
|
24
|
+
# Slice 2 ships a minimal surface:
|
|
25
|
+
#
|
|
26
|
+
# - `#read_file(path)` — validates against the policy, returns
|
|
27
|
+
# the file's contents, and adds a digest-keyed
|
|
28
|
+
# {Cache::Descriptor::FileEntry} to the boundary's
|
|
29
|
+
# accumulated descriptor.
|
|
30
|
+
# - `#open_url(url)` — always raises {AccessDeniedError} while
|
|
31
|
+
# `network_policy` is `:disabled` (the only setting in slice
|
|
32
|
+
# 2). The hook exists so slices 3-6 can layer richer access
|
|
33
|
+
# policy without re-defining the API.
|
|
34
|
+
# - `#cache_descriptor` — flushes the accumulated entries into
|
|
35
|
+
# a fresh {Cache::Descriptor} for the contribution that
|
|
36
|
+
# built it.
|
|
37
|
+
class IoBoundary
|
|
38
|
+
attr_reader :policy, :plugin_id
|
|
39
|
+
|
|
40
|
+
def initialize(policy:, plugin_id:)
|
|
41
|
+
@policy = policy
|
|
42
|
+
@plugin_id = plugin_id.to_s.dup.freeze
|
|
43
|
+
@file_entries = {}
|
|
44
|
+
@mutex = Mutex.new
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Reads the file at `path` after validating it against the
|
|
48
|
+
# policy. Raises {AccessDeniedError} when the path is outside
|
|
49
|
+
# every allowed read root. Records a `:digest` {FileEntry}
|
|
50
|
+
# so the resulting cache slice invalidates on content change.
|
|
51
|
+
def read_file(path)
|
|
52
|
+
absolute = File.expand_path(path.to_s)
|
|
53
|
+
unless @policy.allow_read?(absolute)
|
|
54
|
+
raise AccessDeniedError.new(
|
|
55
|
+
"plugin #{@plugin_id.inspect} cannot read #{absolute.inspect}: " \
|
|
56
|
+
"path is outside the trusted-read scope",
|
|
57
|
+
reason: :read_outside_scope,
|
|
58
|
+
resource: absolute
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
contents = File.binread(absolute)
|
|
63
|
+
record_file_entry(absolute, contents)
|
|
64
|
+
contents
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Slice 2 stub: every URL access is denied while
|
|
68
|
+
# `network_policy` is `:disabled`. Slices that need to relax
|
|
69
|
+
# the rule (e.g. for opt-in offline-replay caches) will lift
|
|
70
|
+
# the policy gate; the API does not change.
|
|
71
|
+
def open_url(url)
|
|
72
|
+
unless @policy.network_allowed?
|
|
73
|
+
raise AccessDeniedError.new(
|
|
74
|
+
"plugin #{@plugin_id.inspect} cannot open URL #{url.inspect}: " \
|
|
75
|
+
"network access is disabled during analysis",
|
|
76
|
+
reason: :network_disabled,
|
|
77
|
+
resource: url.to_s
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
raise NotImplementedError, "URL fetch surface is reserved; slice 2 only ships the deny path"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# @return [Rigor::Cache::Descriptor] frozen snapshot of every
|
|
85
|
+
# file the boundary has read so far. Calling this multiple
|
|
86
|
+
# times yields equal descriptors; subsequent reads expand
|
|
87
|
+
# the underlying record table.
|
|
88
|
+
def cache_descriptor
|
|
89
|
+
entries = @mutex.synchronize { @file_entries.values.dup }
|
|
90
|
+
Cache::Descriptor.new(files: entries)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def record_file_entry(path, contents)
|
|
96
|
+
digest = Digest::SHA256.hexdigest(contents)
|
|
97
|
+
entry = Cache::Descriptor::FileEntry.new(path: path, comparator: :digest, value: digest)
|
|
98
|
+
@mutex.synchronize { @file_entries[path] = entry }
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# Raised inside the loader (and surfaced as a diagnostic by the
|
|
6
|
+
# analyzer) when a plugin entry cannot be resolved or
|
|
7
|
+
# instantiated. Carries the failing plugin reference plus the
|
|
8
|
+
# underlying cause so the diagnostic message stays precise.
|
|
9
|
+
#
|
|
10
|
+
# ADR-2 § "Plugin Trust and I/O Policy" requires plugin failures
|
|
11
|
+
# to be isolated at the analyzer boundary; this class is the
|
|
12
|
+
# carrier for that contract on the loading side.
|
|
13
|
+
class LoadError < StandardError
|
|
14
|
+
attr_reader :plugin_ref, :cause_class
|
|
15
|
+
|
|
16
|
+
def initialize(message, plugin_ref:, cause: nil)
|
|
17
|
+
super(message)
|
|
18
|
+
@plugin_ref = plugin_ref
|
|
19
|
+
@cause_class = cause&.class
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "registry"
|
|
4
|
+
require_relative "load_error"
|
|
5
|
+
|
|
6
|
+
module Rigor
|
|
7
|
+
module Plugin
|
|
8
|
+
# Resolves the project's `.rigor.yml` `plugins:` entries into
|
|
9
|
+
# instantiated plugin instances, paired with a service container.
|
|
10
|
+
# Internal slice-1 implementation; the public surface is
|
|
11
|
+
# {Loader.load} returning a {Registry}.
|
|
12
|
+
#
|
|
13
|
+
# Steps per entry (in order):
|
|
14
|
+
#
|
|
15
|
+
# 1. Normalise the entry into `{ gem:, id:, config: }`.
|
|
16
|
+
# 2. `require` the gem (failures surface as a {LoadError}).
|
|
17
|
+
# 3. Look up the registered plugin class by id (or by gem
|
|
18
|
+
# name if the entry omitted an explicit id).
|
|
19
|
+
# 4. Validate the user's config against the manifest's
|
|
20
|
+
# `config_schema`.
|
|
21
|
+
# 5. Instantiate the plugin and call `init(services)`.
|
|
22
|
+
#
|
|
23
|
+
# Loading is deterministic: configuration order, with plugin
|
|
24
|
+
# id alphabetical as the tie-breaker for entries that resolve
|
|
25
|
+
# to the same gem. Failures do not abort the run; the loader
|
|
26
|
+
# collects them on the {Registry} so the runner can convert
|
|
27
|
+
# each one into a `:plugin_loader` diagnostic.
|
|
28
|
+
class Loader # rubocop:disable Metrics/ClassLength
|
|
29
|
+
attr_reader :services, :requirer
|
|
30
|
+
|
|
31
|
+
# @param services [Rigor::Plugin::Services]
|
|
32
|
+
# @param requirer [#call] takes a gem name and returns truthy
|
|
33
|
+
# on successful require. Defaulted to `Kernel.require` via
|
|
34
|
+
# a lambda; the spec injects a fake to avoid touching the
|
|
35
|
+
# real load path.
|
|
36
|
+
def initialize(services:, requirer: ->(name) { require name })
|
|
37
|
+
@services = services
|
|
38
|
+
@requirer = requirer
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.load(configuration:, services:, requirer: ->(name) { require name })
|
|
42
|
+
new(services: services, requirer: requirer).load(configuration.plugins)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @param entries [Array<String, Hash>] the raw `plugins:`
|
|
46
|
+
# list from the configuration.
|
|
47
|
+
# @return [Registry]
|
|
48
|
+
def load(entries)
|
|
49
|
+
plugins = []
|
|
50
|
+
load_errors = []
|
|
51
|
+
seen_ids = {}
|
|
52
|
+
|
|
53
|
+
Array(entries).each_with_index do |raw, index|
|
|
54
|
+
entry = normalise_entry(raw, index)
|
|
55
|
+
rescue LoadError => e
|
|
56
|
+
load_errors << e
|
|
57
|
+
else
|
|
58
|
+
begin
|
|
59
|
+
plugin = resolve_and_instantiate(entry, seen_ids)
|
|
60
|
+
plugins << plugin if plugin
|
|
61
|
+
rescue LoadError => e
|
|
62
|
+
load_errors << e
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
Registry.new(plugins: plugins, load_errors: load_errors)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
# Accepts:
|
|
72
|
+
# "rigor-rails"
|
|
73
|
+
# { "gem" => "rigor-rails", "id" => "rails", "config" => {...} }
|
|
74
|
+
# { gem: "rigor-rails", id: "rails", config: {...} }
|
|
75
|
+
def normalise_entry(raw, index) # rubocop:disable Metrics/CyclomaticComplexity
|
|
76
|
+
case raw
|
|
77
|
+
when String
|
|
78
|
+
{ gem: raw, id: nil, config: {} }
|
|
79
|
+
when Hash
|
|
80
|
+
string_keyed = raw.to_h { |k, v| [k.to_s, v] }
|
|
81
|
+
gem_name = string_keyed["gem"] || string_keyed["id"]
|
|
82
|
+
unless gem_name.is_a?(String) && !gem_name.empty?
|
|
83
|
+
raise LoadError.new(
|
|
84
|
+
"plugin entry ##{index} must declare a non-empty `gem:` (or `id:`), got #{raw.inspect}",
|
|
85
|
+
plugin_ref: raw
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
{ gem: gem_name, id: string_keyed["id"], config: string_keyed["config"] || {} }
|
|
90
|
+
else
|
|
91
|
+
raise LoadError.new(
|
|
92
|
+
"plugin entry ##{index} must be a String or Hash, got #{raw.class}",
|
|
93
|
+
plugin_ref: raw
|
|
94
|
+
)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def resolve_and_instantiate(entry, seen_ids) # rubocop:disable Metrics/AbcSize
|
|
99
|
+
before = Plugin.registered.keys.to_set
|
|
100
|
+
require_gem!(entry)
|
|
101
|
+
after = Plugin.registered.keys.to_set
|
|
102
|
+
newly_registered = (after - before).to_a
|
|
103
|
+
|
|
104
|
+
plugin_class = lookup_plugin_class!(entry, newly_registered)
|
|
105
|
+
manifest = plugin_class.manifest
|
|
106
|
+
|
|
107
|
+
if seen_ids.key?(manifest.id)
|
|
108
|
+
raise LoadError.new(
|
|
109
|
+
"plugin id #{manifest.id.inspect} appeared twice in configuration " \
|
|
110
|
+
"(first via #{seen_ids[manifest.id].inspect}, again via #{entry[:gem].inspect})",
|
|
111
|
+
plugin_ref: manifest.id
|
|
112
|
+
)
|
|
113
|
+
end
|
|
114
|
+
seen_ids[manifest.id] = entry[:gem]
|
|
115
|
+
|
|
116
|
+
validate_config!(manifest, entry[:config])
|
|
117
|
+
instantiate(plugin_class, entry[:config])
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def require_gem!(entry)
|
|
121
|
+
@requirer.call(entry[:gem])
|
|
122
|
+
rescue ::LoadError => e
|
|
123
|
+
raise LoadError.new(
|
|
124
|
+
"could not load plugin gem #{entry[:gem].inspect}: #{e.message}",
|
|
125
|
+
plugin_ref: entry[:gem],
|
|
126
|
+
cause: e
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def lookup_plugin_class!(entry, newly_registered) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
|
|
131
|
+
if entry[:id]
|
|
132
|
+
plugin_class = Plugin.registered_for(entry[:id])
|
|
133
|
+
unless plugin_class
|
|
134
|
+
raise LoadError.new(
|
|
135
|
+
"plugin id #{entry[:id].inspect} (gem #{entry[:gem].inspect}) " \
|
|
136
|
+
"did not register itself with Rigor::Plugin.register",
|
|
137
|
+
plugin_ref: entry[:id]
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
return plugin_class
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
case newly_registered.size
|
|
145
|
+
when 0
|
|
146
|
+
raise LoadError.new(
|
|
147
|
+
"plugin gem #{entry[:gem].inspect} did not register any plugin via Rigor::Plugin.register",
|
|
148
|
+
plugin_ref: entry[:gem]
|
|
149
|
+
)
|
|
150
|
+
when 1
|
|
151
|
+
Plugin.registered_for(newly_registered.first)
|
|
152
|
+
else
|
|
153
|
+
raise LoadError.new(
|
|
154
|
+
"plugin gem #{entry[:gem].inspect} registered multiple plugins " \
|
|
155
|
+
"(#{newly_registered.sort.inspect}); disambiguate with an explicit `id:` field",
|
|
156
|
+
plugin_ref: entry[:gem]
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def validate_config!(manifest, config)
|
|
162
|
+
errors = manifest.validate_config(config)
|
|
163
|
+
return if errors.empty?
|
|
164
|
+
|
|
165
|
+
raise LoadError.new(
|
|
166
|
+
"plugin #{manifest.id.inspect} config invalid: #{errors.join('; ')}",
|
|
167
|
+
plugin_ref: manifest.id
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def instantiate(plugin_class, config)
|
|
172
|
+
plugin = plugin_class.new(services: @services, config: config)
|
|
173
|
+
plugin.init(@services)
|
|
174
|
+
plugin
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
manifest_id = safe_manifest_id(plugin_class)
|
|
177
|
+
raise LoadError.new(
|
|
178
|
+
"plugin #{manifest_id.inspect} raised during init: #{e.class}: #{e.message}",
|
|
179
|
+
plugin_ref: manifest_id,
|
|
180
|
+
cause: e
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def safe_manifest_id(plugin_class)
|
|
185
|
+
plugin_class.manifest.id
|
|
186
|
+
rescue StandardError
|
|
187
|
+
plugin_class.to_s
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rigor
|
|
4
|
+
module Plugin
|
|
5
|
+
# Value object describing one plugin's identity and metadata.
|
|
6
|
+
# Constructed once per plugin class through {Rigor::Plugin::Base.manifest};
|
|
7
|
+
# consumed by {Rigor::Plugin::Loader} when matching project
|
|
8
|
+
# configuration entries to registered plugins and by
|
|
9
|
+
# {Rigor::Cache::Descriptor::PluginEntry} when deriving cache keys.
|
|
10
|
+
#
|
|
11
|
+
# The fields are pinned by ADR-2 § "Registration, Configuration,
|
|
12
|
+
# and Caching"; the v0.1.0 plugin contract surface treats this
|
|
13
|
+
# struct as the public manifest shape.
|
|
14
|
+
class Manifest
|
|
15
|
+
# Same regex {Rigor::Cache::Store::VALID_PRODUCER_ID} uses,
|
|
16
|
+
# so plugin ids round-trip through cache producer ids and
|
|
17
|
+
# `plugin.<id>.<rule>` diagnostic identifiers without escape.
|
|
18
|
+
VALID_ID = /\A[a-z][a-z0-9._-]*\z/
|
|
19
|
+
|
|
20
|
+
# The first-implementation `config_schema` accepts these value
|
|
21
|
+
# kinds. Slice 1 only checks key presence and shallow value
|
|
22
|
+
# kind; richer schemas (nested maps, enums) land later when
|
|
23
|
+
# the v0.1.0 protocol slices need them.
|
|
24
|
+
VALID_VALUE_KINDS = %i[string boolean integer array hash any].freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :id, :version, :description, :protocols, :config_schema
|
|
27
|
+
|
|
28
|
+
def initialize(id:, version:, description: nil, protocols: [], config_schema: {})
|
|
29
|
+
validate_id!(id)
|
|
30
|
+
validate_version!(version)
|
|
31
|
+
validate_protocols!(protocols)
|
|
32
|
+
validate_config_schema!(config_schema)
|
|
33
|
+
|
|
34
|
+
@id = id.dup.freeze
|
|
35
|
+
@version = version.dup.freeze
|
|
36
|
+
@description = description.nil? ? nil : description.to_s.dup.freeze
|
|
37
|
+
@protocols = protocols.map(&:to_sym).freeze
|
|
38
|
+
@config_schema = config_schema.to_h { |k, v| [k.to_s.dup.freeze, v.to_sym] }.freeze
|
|
39
|
+
|
|
40
|
+
freeze
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Validates the user-supplied plugin config block against this
|
|
44
|
+
# manifest's `config_schema`. Returns an array of human-readable
|
|
45
|
+
# error strings (empty when the config is valid). Slice 1 checks
|
|
46
|
+
# only unknown keys and shallow value kind; nested schemas come
|
|
47
|
+
# with later slices.
|
|
48
|
+
def validate_config(config)
|
|
49
|
+
return ["plugin config must be a Hash, got #{config.class}"] unless config.is_a?(Hash)
|
|
50
|
+
|
|
51
|
+
errors = []
|
|
52
|
+
config.each do |key, value|
|
|
53
|
+
key_s = key.to_s
|
|
54
|
+
unless config_schema.key?(key_s)
|
|
55
|
+
errors << "unknown config key #{key_s.inspect} for plugin #{id.inspect}"
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
kind = config_schema.fetch(key_s)
|
|
60
|
+
errors << "config key #{key_s.inspect} expected #{kind}, got #{value.class}" unless value_matches?(value,
|
|
61
|
+
kind)
|
|
62
|
+
end
|
|
63
|
+
errors
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def to_h
|
|
67
|
+
{
|
|
68
|
+
"id" => id,
|
|
69
|
+
"version" => version,
|
|
70
|
+
"description" => description,
|
|
71
|
+
"protocols" => protocols.map(&:to_s),
|
|
72
|
+
"config_schema" => config_schema.to_h { |k, v| [k, v.to_s] }
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def ==(other)
|
|
77
|
+
other.is_a?(Manifest) && to_h == other.to_h
|
|
78
|
+
end
|
|
79
|
+
alias eql? ==
|
|
80
|
+
|
|
81
|
+
def hash
|
|
82
|
+
to_h.hash
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def validate_id!(id)
|
|
88
|
+
return if id.is_a?(String) && id.match?(VALID_ID)
|
|
89
|
+
|
|
90
|
+
raise ArgumentError,
|
|
91
|
+
"plugin manifest id must match #{VALID_ID.inspect}, got #{id.inspect}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_version!(version)
|
|
95
|
+
return if version.is_a?(String) && !version.empty?
|
|
96
|
+
|
|
97
|
+
raise ArgumentError, "plugin manifest version must be a non-empty String, got #{version.inspect}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def validate_protocols!(protocols)
|
|
101
|
+
return if protocols.is_a?(Array) && protocols.all? { |p| p.is_a?(Symbol) || p.is_a?(String) }
|
|
102
|
+
|
|
103
|
+
raise ArgumentError, "plugin manifest protocols must be an Array of Symbol/String, got #{protocols.inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_config_schema!(schema)
|
|
107
|
+
unless schema.is_a?(Hash)
|
|
108
|
+
raise ArgumentError,
|
|
109
|
+
"plugin manifest config_schema must be a Hash, got #{schema.inspect}"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
schema.each_value do |kind|
|
|
113
|
+
next if VALID_VALUE_KINDS.include?(kind.to_sym)
|
|
114
|
+
|
|
115
|
+
raise ArgumentError,
|
|
116
|
+
"plugin manifest config_schema value kind must be one of " \
|
|
117
|
+
"#{VALID_VALUE_KINDS.inspect}, got #{kind.inspect}"
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def value_matches?(value, kind)
|
|
122
|
+
case kind
|
|
123
|
+
when :string then value.is_a?(String)
|
|
124
|
+
when :boolean then [true, false].include?(value)
|
|
125
|
+
when :integer then value.is_a?(Integer)
|
|
126
|
+
when :array then value.is_a?(Array)
|
|
127
|
+
when :hash then value.is_a?(Hash)
|
|
128
|
+
when :any then true
|
|
129
|
+
else false
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|