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.
- checksums.yaml +4 -4
- data/README.md +45 -2
- data/data/builtins/ruby_core/array.yml +6 -6
- data/data/builtins/ruby_core/hash.yml +1 -1
- data/data/builtins/ruby_core/io.yml +3 -3
- data/data/builtins/ruby_core/numeric.yml +1 -1
- data/data/builtins/ruby_core/pathname.yml +100 -100
- data/data/builtins/ruby_core/proc.yml +1 -1
- data/data/builtins/ruby_core/time.yml +3 -3
- data/lib/rigor/analysis/check_rules.rb +228 -40
- data/lib/rigor/analysis/diagnostic.rb +15 -1
- data/lib/rigor/analysis/runner.rb +269 -7
- data/lib/rigor/builtins/regex_refinement.rb +104 -0
- 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/type_of_command.rb +3 -3
- data/lib/rigor/cli/type_scan_command.rb +4 -4
- data/lib/rigor/cli.rb +20 -7
- data/lib/rigor/configuration/severity_profile.rb +109 -0
- data/lib/rigor/configuration.rb +286 -15
- data/lib/rigor/environment/rbs_loader.rb +89 -13
- data/lib/rigor/environment.rb +12 -4
- 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 +87 -6
- data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +31 -0
- data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +136 -9
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +21 -1
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +68 -2
- data/lib/rigor/inference/method_dispatcher.rb +50 -1
- data/lib/rigor/inference/multi_target_binder.rb +2 -0
- data/lib/rigor/inference/narrowing.rb +246 -127
- data/lib/rigor/inference/scope_indexer.rb +124 -16
- data/lib/rigor/inference/statement_evaluator.rb +406 -37
- data/lib/rigor/plugin/access_denied_error.rb +24 -0
- data/lib/rigor/plugin/base.rb +284 -0
- data/lib/rigor/plugin/fact_store.rb +92 -0
- data/lib/rigor/plugin/io_boundary.rb +102 -0
- data/lib/rigor/plugin/load_error.rb +35 -0
- data/lib/rigor/plugin/loader.rb +307 -0
- data/lib/rigor/plugin/manifest.rb +203 -0
- data/lib/rigor/plugin/registry.rb +50 -0
- data/lib/rigor/plugin/services.rb +77 -0
- data/lib/rigor/plugin/trust_policy.rb +99 -0
- data/lib/rigor/plugin.rb +62 -0
- data/lib/rigor/rbs_extended.rb +57 -9
- data/lib/rigor/reflection.rb +2 -2
- data/lib/rigor/trinary.rb +1 -1
- data/lib/rigor/type/integer_range.rb +6 -2
- data/lib/rigor/version.rb +1 -1
- data/lib/rigor.rb +7 -0
- data/sig/rigor/environment.rbs +10 -3
- 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
- data/sig/rigor.rbs +8 -2
- 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
|