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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +40 -2
  3. data/lib/rigor/analysis/check_rules.rb +228 -40
  4. data/lib/rigor/analysis/diagnostic.rb +15 -1
  5. data/lib/rigor/analysis/runner.rb +183 -4
  6. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  7. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  8. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  9. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  10. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  11. data/lib/rigor/cache/store.rb +2 -0
  12. data/lib/rigor/cli.rb +9 -3
  13. data/lib/rigor/configuration/severity_profile.rb +109 -0
  14. data/lib/rigor/configuration.rb +110 -6
  15. data/lib/rigor/environment/rbs_loader.rb +89 -13
  16. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  17. data/lib/rigor/flow_contribution/element.rb +53 -0
  18. data/lib/rigor/flow_contribution/fact.rb +88 -0
  19. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  20. data/lib/rigor/flow_contribution/merger.rb +275 -0
  21. data/lib/rigor/flow_contribution.rb +51 -0
  22. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  23. data/lib/rigor/inference/expression_typer.rb +84 -5
  24. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +95 -9
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  26. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  27. data/lib/rigor/inference/narrowing.rb +105 -130
  28. data/lib/rigor/inference/scope_indexer.rb +75 -1
  29. data/lib/rigor/inference/statement_evaluator.rb +380 -40
  30. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  31. data/lib/rigor/plugin/base.rb +241 -0
  32. data/lib/rigor/plugin/io_boundary.rb +102 -0
  33. data/lib/rigor/plugin/load_error.rb +23 -0
  34. data/lib/rigor/plugin/loader.rb +191 -0
  35. data/lib/rigor/plugin/manifest.rb +134 -0
  36. data/lib/rigor/plugin/registry.rb +50 -0
  37. data/lib/rigor/plugin/services.rb +65 -0
  38. data/lib/rigor/plugin/trust_policy.rb +99 -0
  39. data/lib/rigor/plugin.rb +61 -0
  40. data/lib/rigor/rbs_extended.rb +57 -9
  41. data/lib/rigor/reflection.rb +2 -2
  42. data/lib/rigor/version.rb +1 -1
  43. data/lib/rigor.rb +7 -0
  44. data/sig/rigor/environment.rbs +7 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/rbs_extended.rbs +2 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +7 -0
  49. 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