rigortype 0.0.9 → 0.1.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +45 -2
  3. data/data/builtins/ruby_core/array.yml +6 -6
  4. data/data/builtins/ruby_core/hash.yml +1 -1
  5. data/data/builtins/ruby_core/io.yml +3 -3
  6. data/data/builtins/ruby_core/numeric.yml +1 -1
  7. data/data/builtins/ruby_core/pathname.yml +100 -100
  8. data/data/builtins/ruby_core/proc.yml +1 -1
  9. data/data/builtins/ruby_core/time.yml +3 -3
  10. data/lib/rigor/analysis/check_rules.rb +228 -40
  11. data/lib/rigor/analysis/diagnostic.rb +15 -1
  12. data/lib/rigor/analysis/runner.rb +269 -7
  13. data/lib/rigor/builtins/regex_refinement.rb +104 -0
  14. data/lib/rigor/cache/rbs_class_ancestor_table.rb +1 -1
  15. data/lib/rigor/cache/rbs_class_type_param_names.rb +1 -1
  16. data/lib/rigor/cache/rbs_constant_table.rb +2 -2
  17. data/lib/rigor/cache/rbs_descriptor.rb +2 -0
  18. data/lib/rigor/cache/rbs_instance_definitions.rb +79 -0
  19. data/lib/rigor/cache/store.rb +2 -0
  20. data/lib/rigor/cli/type_of_command.rb +3 -3
  21. data/lib/rigor/cli/type_scan_command.rb +4 -4
  22. data/lib/rigor/cli.rb +20 -7
  23. data/lib/rigor/configuration/severity_profile.rb +109 -0
  24. data/lib/rigor/configuration.rb +286 -15
  25. data/lib/rigor/environment/rbs_loader.rb +89 -13
  26. data/lib/rigor/environment.rb +12 -4
  27. data/lib/rigor/flow_contribution/conflict.rb +81 -0
  28. data/lib/rigor/flow_contribution/element.rb +53 -0
  29. data/lib/rigor/flow_contribution/fact.rb +88 -0
  30. data/lib/rigor/flow_contribution/merge_result.rb +67 -0
  31. data/lib/rigor/flow_contribution/merger.rb +275 -0
  32. data/lib/rigor/flow_contribution.rb +51 -0
  33. data/lib/rigor/inference/block_parameter_binder.rb +15 -0
  34. data/lib/rigor/inference/expression_typer.rb +87 -6
  35. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
  36. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +136 -9
  37. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
  38. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
  39. data/lib/rigor/inference/method_dispatcher.rb +50 -1
  40. data/lib/rigor/inference/multi_target_binder.rb +2 -0
  41. data/lib/rigor/inference/narrowing.rb +246 -127
  42. data/lib/rigor/inference/scope_indexer.rb +124 -16
  43. data/lib/rigor/inference/statement_evaluator.rb +406 -37
  44. data/lib/rigor/plugin/access_denied_error.rb +24 -0
  45. data/lib/rigor/plugin/base.rb +284 -0
  46. data/lib/rigor/plugin/fact_store.rb +92 -0
  47. data/lib/rigor/plugin/io_boundary.rb +102 -0
  48. data/lib/rigor/plugin/load_error.rb +35 -0
  49. data/lib/rigor/plugin/loader.rb +307 -0
  50. data/lib/rigor/plugin/manifest.rb +203 -0
  51. data/lib/rigor/plugin/registry.rb +50 -0
  52. data/lib/rigor/plugin/services.rb +77 -0
  53. data/lib/rigor/plugin/trust_policy.rb +99 -0
  54. data/lib/rigor/plugin.rb +62 -0
  55. data/lib/rigor/rbs_extended.rb +57 -9
  56. data/lib/rigor/reflection.rb +2 -2
  57. data/lib/rigor/trinary.rb +1 -1
  58. data/lib/rigor/type/integer_range.rb +6 -2
  59. data/lib/rigor/version.rb +1 -1
  60. data/lib/rigor.rb +7 -0
  61. data/sig/rigor/environment.rbs +10 -3
  62. data/sig/rigor/inference.rbs +1 -0
  63. data/sig/rigor/rbs_extended.rbs +2 -0
  64. data/sig/rigor/scope.rbs +1 -0
  65. data/sig/rigor/type.rbs +7 -0
  66. data/sig/rigor.rbs +8 -2
  67. metadata +20 -1
@@ -0,0 +1,307 @@
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
+ # ADR-9 slice 5 — topological sort by `manifest(consumes:)`
67
+ # so producers run before consumers, plus early
68
+ # `missing-producer` validation. Cycles surface as
69
+ # `dependency-cycle` LoadErrors. When validation fails, the
70
+ # offending plugin(s) drop from the returned plugins list
71
+ # and the LoadError surfaces alongside any earlier failure.
72
+ plugins, sort_errors = topo_sort_plugins(plugins)
73
+ load_errors.concat(sort_errors)
74
+
75
+ Registry.new(plugins: plugins, load_errors: load_errors)
76
+ end
77
+
78
+ private
79
+
80
+ # Accepts:
81
+ # "rigor-rails"
82
+ # { "gem" => "rigor-rails", "id" => "rails", "config" => {...} }
83
+ # { gem: "rigor-rails", id: "rails", config: {...} }
84
+ def normalise_entry(raw, index) # rubocop:disable Metrics/CyclomaticComplexity
85
+ case raw
86
+ when String
87
+ { gem: raw, id: nil, config: {} }
88
+ when Hash
89
+ string_keyed = raw.to_h { |k, v| [k.to_s, v] }
90
+ gem_name = string_keyed["gem"] || string_keyed["id"]
91
+ unless gem_name.is_a?(String) && !gem_name.empty?
92
+ raise LoadError.new(
93
+ "plugin entry ##{index} must declare a non-empty `gem:` (or `id:`), got #{raw.inspect}",
94
+ plugin_ref: raw
95
+ )
96
+ end
97
+
98
+ { gem: gem_name, id: string_keyed["id"], config: string_keyed["config"] || {} }
99
+ else
100
+ raise LoadError.new(
101
+ "plugin entry ##{index} must be a String or Hash, got #{raw.class}",
102
+ plugin_ref: raw
103
+ )
104
+ end
105
+ end
106
+
107
+ def resolve_and_instantiate(entry, seen_ids) # rubocop:disable Metrics/AbcSize
108
+ before = Plugin.registered.keys.to_set
109
+ require_gem!(entry)
110
+ after = Plugin.registered.keys.to_set
111
+ newly_registered = (after - before).to_a
112
+
113
+ plugin_class = lookup_plugin_class!(entry, newly_registered)
114
+ manifest = plugin_class.manifest
115
+
116
+ if seen_ids.key?(manifest.id)
117
+ raise LoadError.new(
118
+ "plugin id #{manifest.id.inspect} appeared twice in configuration " \
119
+ "(first via #{seen_ids[manifest.id].inspect}, again via #{entry[:gem].inspect})",
120
+ plugin_ref: manifest.id
121
+ )
122
+ end
123
+ seen_ids[manifest.id] = entry[:gem]
124
+
125
+ validate_config!(manifest, entry[:config])
126
+ instantiate(plugin_class, entry[:config])
127
+ end
128
+
129
+ def require_gem!(entry)
130
+ @requirer.call(entry[:gem])
131
+ rescue ::LoadError => e
132
+ raise LoadError.new(
133
+ "could not load plugin gem #{entry[:gem].inspect}: #{e.message}",
134
+ plugin_ref: entry[:gem],
135
+ cause: e
136
+ )
137
+ end
138
+
139
+ def lookup_plugin_class!(entry, newly_registered) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
140
+ if entry[:id]
141
+ plugin_class = Plugin.registered_for(entry[:id])
142
+ unless plugin_class
143
+ raise LoadError.new(
144
+ "plugin id #{entry[:id].inspect} (gem #{entry[:gem].inspect}) " \
145
+ "did not register itself with Rigor::Plugin.register",
146
+ plugin_ref: entry[:id]
147
+ )
148
+ end
149
+
150
+ return plugin_class
151
+ end
152
+
153
+ case newly_registered.size
154
+ when 0
155
+ raise LoadError.new(
156
+ "plugin gem #{entry[:gem].inspect} did not register any plugin via Rigor::Plugin.register",
157
+ plugin_ref: entry[:gem]
158
+ )
159
+ when 1
160
+ Plugin.registered_for(newly_registered.first)
161
+ else
162
+ raise LoadError.new(
163
+ "plugin gem #{entry[:gem].inspect} registered multiple plugins " \
164
+ "(#{newly_registered.sort.inspect}); disambiguate with an explicit `id:` field",
165
+ plugin_ref: entry[:gem]
166
+ )
167
+ end
168
+ end
169
+
170
+ def validate_config!(manifest, config)
171
+ errors = manifest.validate_config(config)
172
+ return if errors.empty?
173
+
174
+ raise LoadError.new(
175
+ "plugin #{manifest.id.inspect} config invalid: #{errors.join('; ')}",
176
+ plugin_ref: manifest.id
177
+ )
178
+ end
179
+
180
+ def instantiate(plugin_class, config)
181
+ plugin = plugin_class.new(services: @services, config: config)
182
+ plugin.init(@services)
183
+ plugin
184
+ rescue StandardError => e
185
+ manifest_id = safe_manifest_id(plugin_class)
186
+ raise LoadError.new(
187
+ "plugin #{manifest_id.inspect} raised during init: #{e.class}: #{e.message}",
188
+ plugin_ref: manifest_id,
189
+ cause: e
190
+ )
191
+ end
192
+
193
+ def safe_manifest_id(plugin_class)
194
+ plugin_class.manifest.id
195
+ rescue StandardError
196
+ plugin_class.to_s
197
+ end
198
+
199
+ # ADR-9 slice 5 — topological sort of plugins by their
200
+ # `manifest(consumes:)` declarations. Returns `[sorted_plugins,
201
+ # load_errors]`. Determinism: when no dependency relation
202
+ # forces an order, plugins are visited alphabetically by
203
+ # manifest id. A non-optional consume of a `(plugin_id, name)`
204
+ # whose producer is missing emits a `:missing-producer`
205
+ # LoadError and drops the consumer; cycles emit a
206
+ # `:dependency-cycle` LoadError naming the offending chain.
207
+ def topo_sort_plugins(plugins)
208
+ # If no plugin opts into the cross-plugin API the loader's
209
+ # legacy configuration-order contract is preserved
210
+ # unchanged. Topo sort and missing-producer validation only
211
+ # run when at least one plugin declares `consumes:`.
212
+ return [plugins, []] unless plugins.any? { |p| p.manifest.consumes.any? }
213
+
214
+ index = plugins.to_h { |plugin| [plugin.manifest.id, plugin] }
215
+ errors = validate_missing_producers(plugins, index)
216
+ sortable = plugins.reject { |p| errors.any? { |e| e.plugin_ref == p.manifest.id } }
217
+ config_order = plugins.each_with_index.to_h { |plugin, i| [plugin.manifest.id, i] }
218
+
219
+ sort_in_topo_order(sortable, index, errors, config_order)
220
+ end
221
+
222
+ def validate_missing_producers(plugins, index)
223
+ errors = []
224
+ plugins.each do |plugin|
225
+ plugin.manifest.consumes.each do |consume|
226
+ next if consume.optional
227
+ next if index.key?(consume.plugin_id) && producer_provides?(index[consume.plugin_id], consume.name)
228
+
229
+ errors << LoadError.new(
230
+ "plugin #{plugin.manifest.id.inspect} consumes " \
231
+ "#{consume.plugin_id.inspect}/#{consume.name} but no loaded plugin " \
232
+ "with that id declares `produces: [#{consume.name.inspect}]`",
233
+ plugin_ref: plugin.manifest.id,
234
+ reason: :"missing-producer"
235
+ )
236
+ end
237
+ end
238
+ errors
239
+ end
240
+
241
+ def producer_provides?(producer, name)
242
+ producer.manifest.produces.include?(name)
243
+ end
244
+
245
+ # Kahn's algorithm with `Configuration#plugins`-order
246
+ # tie-break. Edges go from producer -> consumer (producer
247
+ # must visit first). When two plugins are simultaneously
248
+ # ready, the configuration-order index decides the visit
249
+ # order — preserves the v0.1.0 legacy contract for plugins
250
+ # without dependencies.
251
+ def sort_in_topo_order(plugins, index, errors, config_order)
252
+ in_degree, forward = build_consumes_graph(plugins, index, errors)
253
+ ordered, cycle_errors = kahn_walk(plugins, in_degree, forward, config_order)
254
+ [ordered, errors + cycle_errors]
255
+ end
256
+
257
+ def build_consumes_graph(plugins, index, errors)
258
+ in_degree = Hash.new(0)
259
+ forward = Hash.new { |h, k| h[k] = [] }
260
+ plugins.each do |consumer|
261
+ consumer.manifest.consumes.each do |consume|
262
+ next unless index.key?(consume.plugin_id)
263
+ next if errors.any? { |e| e.plugin_ref == consume.plugin_id }
264
+
265
+ forward[consume.plugin_id] << consumer.manifest.id
266
+ in_degree[consumer.manifest.id] += 1
267
+ end
268
+ end
269
+ [in_degree, forward]
270
+ end
271
+
272
+ def kahn_walk(plugins, in_degree, forward, config_order)
273
+ order = ->(plugin) { config_order.fetch(plugin.manifest.id, Float::INFINITY) }
274
+ ready = plugins.select { |p| in_degree[p.manifest.id].zero? }.sort_by(&order)
275
+ result = kahn_collect(plugins, in_degree, forward, ready, order)
276
+
277
+ return [result, []] if result.size == plugins.size
278
+
279
+ cycled = plugins - result
280
+ [result, [dependency_cycle_error(cycled)]]
281
+ end
282
+
283
+ def kahn_collect(plugins, in_degree, forward, ready, order)
284
+ result = []
285
+ until ready.empty?
286
+ plugin = ready.shift
287
+ result << plugin
288
+ forward[plugin.manifest.id].each do |consumer_id|
289
+ in_degree[consumer_id] -= 1
290
+ ready << plugins.find { |p| p.manifest.id == consumer_id } if in_degree[consumer_id].zero?
291
+ end
292
+ ready.sort_by!(&order)
293
+ end
294
+ result
295
+ end
296
+
297
+ def dependency_cycle_error(cycled)
298
+ ids = cycled.map { |p| p.manifest.id }.sort
299
+ LoadError.new(
300
+ "plugin dependency cycle through `manifest(consumes:)`: #{ids.inspect}",
301
+ plugin_ref: ids.first,
302
+ reason: :"dependency-cycle"
303
+ )
304
+ end
305
+ end
306
+ end
307
+ end
@@ -0,0 +1,203 @@
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 # rubocop:disable Metrics/ClassLength
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
+ # ADR-9 slice 4 — declared cross-plugin fact dependencies.
27
+ # `produces:` lists the names this plugin publishes through
28
+ # its `#prepare(services)` hook. `consumes:` lists the
29
+ # `(plugin_id, name)` pairs this plugin reads from
30
+ # `services.fact_store`. The loader uses both for
31
+ # topological sort + missing-producer detection (slice 5);
32
+ # slice 4 carries the declarations on the manifest but the
33
+ # loader does not yet enforce them.
34
+ Consumption = Data.define(:plugin_id, :name, :optional) do
35
+ def initialize(plugin_id:, name:, optional: false)
36
+ super(plugin_id: plugin_id.to_s, name: name.to_sym, optional: optional ? true : false)
37
+ end
38
+ end
39
+
40
+ attr_reader :id, :version, :description, :protocols, :config_schema, :produces, :consumes
41
+
42
+ def initialize( # rubocop:disable Metrics/ParameterLists
43
+ id:, version:,
44
+ description: nil, protocols: [], config_schema: {},
45
+ produces: [], consumes: []
46
+ )
47
+ validate_id!(id)
48
+ validate_version!(version)
49
+ validate_protocols!(protocols)
50
+ validate_config_schema!(config_schema)
51
+ validate_produces!(produces)
52
+
53
+ assign_fields(id, version, description, protocols, config_schema, produces, consumes)
54
+ freeze
55
+ end
56
+
57
+ private
58
+
59
+ def assign_fields(id, version, description, protocols, config_schema, produces, consumes) # rubocop:disable Metrics/ParameterLists
60
+ @id = id.dup.freeze
61
+ @version = version.dup.freeze
62
+ @description = description.nil? ? nil : description.to_s.dup.freeze
63
+ @protocols = protocols.map(&:to_sym).freeze
64
+ @config_schema = config_schema.to_h { |k, v| [k.to_s.dup.freeze, v.to_sym] }.freeze
65
+ @produces = produces.map(&:to_sym).freeze
66
+ @consumes = coerce_consumes(consumes)
67
+ end
68
+
69
+ public
70
+
71
+ # Validates the user-supplied plugin config block against this
72
+ # manifest's `config_schema`. Returns an array of human-readable
73
+ # error strings (empty when the config is valid). Slice 1 checks
74
+ # only unknown keys and shallow value kind; nested schemas come
75
+ # with later slices.
76
+ def validate_config(config)
77
+ return ["plugin config must be a Hash, got #{config.class}"] unless config.is_a?(Hash)
78
+
79
+ errors = []
80
+ config.each do |key, value|
81
+ key_s = key.to_s
82
+ unless config_schema.key?(key_s)
83
+ errors << "unknown config key #{key_s.inspect} for plugin #{id.inspect}"
84
+ next
85
+ end
86
+
87
+ kind = config_schema.fetch(key_s)
88
+ errors << "config key #{key_s.inspect} expected #{kind}, got #{value.class}" unless value_matches?(value,
89
+ kind)
90
+ end
91
+ errors
92
+ end
93
+
94
+ def to_h
95
+ {
96
+ "id" => id,
97
+ "version" => version,
98
+ "description" => description,
99
+ "protocols" => protocols.map(&:to_s),
100
+ "config_schema" => config_schema.to_h { |k, v| [k, v.to_s] },
101
+ "produces" => produces.map(&:to_s),
102
+ "consumes" => consumes.map { |c| consumption_hash(c) }
103
+ }
104
+ end
105
+
106
+ def ==(other)
107
+ other.is_a?(Manifest) && to_h == other.to_h
108
+ end
109
+ alias eql? ==
110
+
111
+ def hash
112
+ to_h.hash
113
+ end
114
+
115
+ private
116
+
117
+ def validate_id!(id)
118
+ return if id.is_a?(String) && id.match?(VALID_ID)
119
+
120
+ raise ArgumentError,
121
+ "plugin manifest id must match #{VALID_ID.inspect}, got #{id.inspect}"
122
+ end
123
+
124
+ def validate_version!(version)
125
+ return if version.is_a?(String) && !version.empty?
126
+
127
+ raise ArgumentError, "plugin manifest version must be a non-empty String, got #{version.inspect}"
128
+ end
129
+
130
+ def validate_protocols!(protocols)
131
+ return if protocols.is_a?(Array) && protocols.all? { |p| p.is_a?(Symbol) || p.is_a?(String) }
132
+
133
+ raise ArgumentError, "plugin manifest protocols must be an Array of Symbol/String, got #{protocols.inspect}"
134
+ end
135
+
136
+ def validate_config_schema!(schema)
137
+ unless schema.is_a?(Hash)
138
+ raise ArgumentError,
139
+ "plugin manifest config_schema must be a Hash, got #{schema.inspect}"
140
+ end
141
+
142
+ schema.each_value do |kind|
143
+ next if VALID_VALUE_KINDS.include?(kind.to_sym)
144
+
145
+ raise ArgumentError,
146
+ "plugin manifest config_schema value kind must be one of " \
147
+ "#{VALID_VALUE_KINDS.inspect}, got #{kind.inspect}"
148
+ end
149
+ end
150
+
151
+ def value_matches?(value, kind)
152
+ case kind
153
+ when :string then value.is_a?(String)
154
+ when :boolean then [true, false].include?(value)
155
+ when :integer then value.is_a?(Integer)
156
+ when :array then value.is_a?(Array)
157
+ when :hash then value.is_a?(Hash)
158
+ when :any then true
159
+ else false
160
+ end
161
+ end
162
+
163
+ def validate_produces!(produces)
164
+ return if produces.is_a?(Array) && produces.all? { |p| p.is_a?(Symbol) || p.is_a?(String) }
165
+
166
+ raise ArgumentError, "plugin manifest produces must be an Array of Symbol/String, got #{produces.inspect}"
167
+ end
168
+
169
+ def coerce_consumes(consumes)
170
+ unless consumes.is_a?(Array)
171
+ raise ArgumentError, "plugin manifest consumes must be an Array, got #{consumes.inspect}"
172
+ end
173
+
174
+ consumes.map { |entry| coerce_consumption(entry) }.freeze
175
+ end
176
+
177
+ def coerce_consumption(entry)
178
+ case entry
179
+ when Consumption then entry
180
+ when Hash then build_consumption_from_hash(entry)
181
+ else raise ArgumentError,
182
+ "plugin manifest consumes entry must be a Hash or Consumption, got #{entry.inspect}"
183
+ end
184
+ end
185
+
186
+ def consumption_hash(consumption)
187
+ { "plugin_id" => consumption.plugin_id, "name" => consumption.name.to_s, "optional" => consumption.optional }
188
+ end
189
+
190
+ def build_consumption_from_hash(entry)
191
+ plugin_id = entry[:plugin_id] || entry["plugin_id"]
192
+ name = entry[:name] || entry["name"]
193
+ optional = entry.key?(:optional) ? entry[:optional] : entry["optional"]
194
+ if plugin_id.nil? || name.nil?
195
+ raise ArgumentError,
196
+ "plugin manifest consumes entry missing plugin_id/name: #{entry.inspect}"
197
+ end
198
+
199
+ Consumption.new(plugin_id: plugin_id, name: name, optional: optional || false)
200
+ end
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Read-side query API over the plugins loaded for a single
6
+ # `Analysis::Runner.run`. Constructed by
7
+ # {Rigor::Plugin::Loader.load} and exposed downstream so the
8
+ # contribution merger (slice 3) and diagnostic provenance
9
+ # (slice 5) can iterate over loaded plugin instances in
10
+ # deterministic order.
11
+ #
12
+ # The registry is read-only after construction; ordering is
13
+ # the order in which {Rigor::Plugin::Loader} resolved
14
+ # configuration entries, which is project-config order with
15
+ # plugin-id alphabetical as the tie-breaker.
16
+ class Registry
17
+ attr_reader :plugins, :load_errors
18
+
19
+ # @param plugins [Array<Rigor::Plugin::Base>] instantiated
20
+ # plugin instances in deterministic order.
21
+ # @param load_errors [Array<Rigor::Plugin::LoadError>] failures
22
+ # surfaced during loading. Each error is also turned into a
23
+ # diagnostic by the runner.
24
+ def initialize(plugins: [], load_errors: [])
25
+ @plugins = plugins.dup.freeze
26
+ @load_errors = load_errors.dup.freeze
27
+ freeze
28
+ end
29
+
30
+ def find(id)
31
+ id_s = id.to_s
32
+ plugins.find { |plugin| plugin.manifest.id == id_s }
33
+ end
34
+
35
+ def ids
36
+ plugins.map { |plugin| plugin.manifest.id }
37
+ end
38
+
39
+ def empty?
40
+ plugins.empty?
41
+ end
42
+
43
+ def any_load_errors?
44
+ !load_errors.empty?
45
+ end
46
+
47
+ EMPTY = new.freeze
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Dependency-injection container handed to every plugin's
6
+ # {Rigor::Plugin::Base#init} method. Plugins read from the
7
+ # container; they MUST NOT mutate it. The container is
8
+ # constructed once per `Analysis::Runner.run` and destroyed
9
+ # at the end of the run.
10
+ #
11
+ # ADR-2 § "Registration, Configuration, and Caching" reserves
12
+ # this surface for "constructor injection for analyzer
13
+ # services such as reflection providers, type factories,
14
+ # loggers, and configuration readers". Slice 1 wires four
15
+ # of those:
16
+ #
17
+ # - `reflection`: the {Rigor::Reflection} read-side facade.
18
+ # - `type`: the {Rigor::Type::Combinator} factory module.
19
+ # - `configuration`: the project's {Rigor::Configuration}.
20
+ # - `cache_store`: the {Rigor::Cache::Store} the run is using
21
+ # (or `nil` when caching is disabled). Slice 6 wires
22
+ # plugin-side cache producers through this entry.
23
+ #
24
+ # Loggers are not yet a public surface in the core analyzer;
25
+ # they will be added when the diagnostics formatter grows a
26
+ # progress channel.
27
+ #
28
+ # Slice 2 (Plugin trust / I/O policy) extends the container
29
+ # with `trust_policy` and a per-plugin `io_boundary_for(plugin_id)`
30
+ # factory. Plugins should reach for the boundary rather than
31
+ # raw `File.read` so reads stay within the trusted scope and
32
+ # feed cache invalidation; ADR-2 § "Plugin Trust and I/O
33
+ # Policy" documents the trust model the boundary enforces.
34
+ #
35
+ # ADR-9 slice 2 adds `fact_store`: the per-run cross-plugin
36
+ # `Plugin::FactStore`. Producer plugins publish their facts
37
+ # in `#prepare(services)` (slice 3); consumer plugins read in
38
+ # `#diagnostics_for_file` via `services.fact_store.read(...)`.
39
+ # A fresh `FactStore` instance is constructed per Services
40
+ # when none is supplied — the runner threads its own instance
41
+ # in once slice 3 wires `#prepare` invocation.
42
+ class Services
43
+ attr_reader :reflection, :type, :configuration, :cache_store, :trust_policy, :fact_store
44
+
45
+ def initialize( # rubocop:disable Metrics/ParameterLists
46
+ reflection:, type:, configuration:,
47
+ cache_store: nil, trust_policy: nil, fact_store: nil
48
+ )
49
+ @reflection = reflection
50
+ @type = type
51
+ @configuration = configuration
52
+ @cache_store = cache_store
53
+ @trust_policy = trust_policy || default_trust_policy
54
+ @fact_store = fact_store || FactStore.new
55
+ freeze
56
+ end
57
+
58
+ # Returns a fresh {IoBoundary} bound to `plugin_id` and the
59
+ # current `trust_policy`. The boundary accumulates per-plugin
60
+ # cache descriptor entries; the loader / contribution merger
61
+ # constructs one boundary per plugin per run.
62
+ def io_boundary_for(plugin_id)
63
+ IoBoundary.new(policy: @trust_policy, plugin_id: plugin_id)
64
+ end
65
+
66
+ private
67
+
68
+ def default_trust_policy
69
+ TrustPolicy.new(
70
+ trusted_gems: [],
71
+ allowed_read_roots: [Dir.pwd],
72
+ network_policy: :disabled
73
+ )
74
+ end
75
+ end
76
+ end
77
+ end