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,284 @@
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-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
115
+ # slice 7 — per-call return-type contribution hook. When
116
+ # the inference engine dispatches a `Prism::CallNode` and
117
+ # neither the precision tiers nor RBS resolve a result,
118
+ # `MethodDispatcher` consults each loaded plugin via this
119
+ # hook ahead of `RbsDispatch`. Plugins that override the
120
+ # default return a {Rigor::FlowContribution} bundle whose
121
+ # `return_type` slot pins the call site's result type.
122
+ #
123
+ # Default returns nil — plugins that don't refine return
124
+ # types skip the override. Failures are isolated: a hook
125
+ # that raises gets its contribution dropped silently for
126
+ # this call so the rest of the dispatch chain continues.
127
+ def flow_contribution_for(call_node:, scope:) # rubocop:disable Lint/UnusedMethodArgument
128
+ nil
129
+ end
130
+
131
+ # ADR-9 slice 3 — per-run preparation hook. The runner
132
+ # invokes `#prepare(services)` on every loaded plugin once
133
+ # per `Analysis::Runner.run`, after `#init` has run on every
134
+ # plugin and before any `#diagnostics_for_file` call.
135
+ # Plugins use this hook to compute and publish facts other
136
+ # plugins consume:
137
+ #
138
+ # def prepare(services)
139
+ # services.fact_store.publish(
140
+ # plugin_id: manifest.id, name: :model_index, value: model_index
141
+ # )
142
+ # end
143
+ #
144
+ # Default no-op so plugins without facts to publish leave
145
+ # `#prepare` unimplemented. Failures isolate as
146
+ # `:plugin_loader runtime-error` diagnostics; a plugin that
147
+ # raises in `#prepare` has its facts considered un-published
148
+ # and downstream consumers see `nil` from `fact_store.read`.
149
+ #
150
+ # Slice 3 calls plugins in registration order. ADR-9 slice 5
151
+ # introduces topological ordering by `consumes:` so producers
152
+ # always run before consumers.
153
+ def prepare(services) # rubocop:disable Lint/UnusedMethodArgument
154
+ nil
155
+ end
156
+
157
+ # ADR-7 § "Slice 5-A" — per-file diagnostic emission hook.
158
+ # Override in plugin subclasses to return an array of
159
+ # `Rigor::Analysis::Diagnostic` rows for the analysed file.
160
+ # The runner stamps each returned diagnostic with
161
+ # `source_family: "plugin.<manifest.id>"` automatically per
162
+ # ADR-7 § "Slice 5-B"; plugin authors should construct
163
+ # diagnostics without setting `source_family` (any value
164
+ # they pass is overwritten).
165
+ #
166
+ # `path` is the analysed file path; `scope` is the entry
167
+ # `Rigor::Scope` after `ScopeIndexer` ran; `root` is the
168
+ # parsed `Prism::Node` root. Plugin authors traverse `root`
169
+ # themselves if they need node-scoped rules — the
170
+ # `Rule<TNode>` API ADR-2 § "Custom rules" mentions stays
171
+ # deferred to v0.1.x.
172
+ #
173
+ # Default returns `[]` so plugins that contribute through
174
+ # other channels (e.g. slice-4 narrowing contributions,
175
+ # slice-6 cache producers) do not have to override.
176
+ def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument
177
+ []
178
+ end
179
+
180
+ # Convenience accessor — `manifest` on the instance returns
181
+ # the class-level manifest declaration.
182
+ def manifest
183
+ self.class.manifest
184
+ end
185
+
186
+ # ADR-7 § "Slice 6-A/6-B" — per-plugin {IoBoundary}.
187
+ # Memoised so the boundary's accumulated `FileEntry`
188
+ # rows persist across producer invocations within the
189
+ # same plugin instance and feed cache invalidation
190
+ # via `cache_for`.
191
+ def io_boundary
192
+ @io_boundary ||= services.io_boundary_for(manifest.id)
193
+ end
194
+
195
+ # ADR-7 § "Slice 6-A" — returns a callable that performs
196
+ # a `Cache::Store#fetch_or_compute` round-trip for the
197
+ # named producer. The descriptor (per ADR-7 § "Slice
198
+ # 6-B") is auto-assembled from the plugin's
199
+ # `PluginEntry` template (id, version, config_hash) and
200
+ # the {IoBoundary} read history. The producer id is
201
+ # auto-prefixed `plugin.<manifest.id>.` per ADR-7 §
202
+ # "Slice 6-C" so plugin caches stay sandboxed from
203
+ # built-in producers.
204
+ #
205
+ # When `services.cache_store` is `nil` (e.g. CLI
206
+ # `--no-cache`), the callable bypasses the cache and
207
+ # runs the producer block every time — same semantics
208
+ # as the v0.0.9 cache surface for built-in producers.
209
+ #
210
+ # `descriptor:` (optional, ADR-7 § "Slice 6" follow-up)
211
+ # supplies extra `Cache::Descriptor` rows the plugin
212
+ # author wants to compose into the auto-built descriptor
213
+ # — typically gem-version `GemEntry`, configuration-file
214
+ # `FileEntry` digests, or `ConfigEntry` rows for external
215
+ # state the {IoBoundary} cannot capture itself. The
216
+ # passed descriptor composes via `Cache::Descriptor.compose`
217
+ # with the auto-built one (PluginEntry template + boundary
218
+ # reads); per-slot conflicts raise
219
+ # `Cache::Descriptor::Conflict` to make divergent inputs
220
+ # visible rather than silently shadowing.
221
+ def cache_for(producer_id, params: {}, descriptor: nil)
222
+ producer = self.class.producers[producer_id.to_sym]
223
+ unless producer
224
+ raise ArgumentError,
225
+ "plugin #{manifest.id.inspect} did not declare producer #{producer_id.inspect}"
226
+ end
227
+
228
+ compute = -> { instance_exec(params, &producer[:block]) }
229
+ store = services.cache_store
230
+ return compute unless store
231
+
232
+ prefixed_id = "plugin.#{manifest.id}.#{producer_id}"
233
+ composed_descriptor = compose_cache_descriptor(descriptor)
234
+ lambda do
235
+ store.fetch_or_compute(
236
+ producer_id: prefixed_id,
237
+ params: params,
238
+ descriptor: composed_descriptor,
239
+ serialize: producer[:serialize],
240
+ deserialize: producer[:deserialize],
241
+ &compute
242
+ )
243
+ end
244
+ end
245
+
246
+ private
247
+
248
+ # ADR-7 § "Slice 6-B" — composes the per-call cache
249
+ # descriptor from (1) the plugin's PluginEntry template
250
+ # and (2) the IoBoundary's accumulated FileEntry rows.
251
+ def build_plugin_cache_descriptor
252
+ plugin_entry = Cache::Descriptor::PluginEntry.new(
253
+ id: manifest.id,
254
+ version: manifest.version,
255
+ config_hash: digest_config(config)
256
+ )
257
+ boundary_descriptor = io_boundary.cache_descriptor
258
+ Cache::Descriptor.new(
259
+ plugins: [plugin_entry],
260
+ files: boundary_descriptor.files
261
+ )
262
+ end
263
+
264
+ # ADR-7 § "Slice 6" follow-up — composes the auto-built
265
+ # cache descriptor with an optional plugin-author-supplied
266
+ # extension. Extra `GemEntry` / `FileEntry` / `ConfigEntry`
267
+ # rows the plugin needs (gem-version pins, external
268
+ # configuration files, sibling-plugin state) flow through
269
+ # `Cache::Descriptor.compose`; the union behaviour matches
270
+ # built-in producers (`RbsConstantTable`, `RbsEnvironment`).
271
+ def compose_cache_descriptor(extra)
272
+ auto_built = build_plugin_cache_descriptor
273
+ return auto_built if extra.nil?
274
+
275
+ Cache::Descriptor.compose(auto_built, extra)
276
+ end
277
+
278
+ def digest_config(config)
279
+ canonical = Cache::Descriptor.canonicalize_value(config || {})
280
+ Digest::SHA256.hexdigest(JSON.generate(canonical))
281
+ end
282
+ end
283
+ end
284
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ module Plugin
5
+ # Per-run cross-plugin fact store. ADR-9 § "Plugin::FactStore".
6
+ #
7
+ # A plugin publishes typed `(plugin_id, name) -> value` tuples
8
+ # in its `Plugin::Base#prepare(services)` hook (slice 3); other
9
+ # plugins read them in `#diagnostics_for_file` via
10
+ # `services.fact_store.read(plugin_id:, name:)`. The store is
11
+ # constructed fresh at the start of every `Analysis::Runner.run`
12
+ # and discarded at the end — caching the underlying expensive
13
+ # computation is the producer's job (`Plugin::Base.producer`);
14
+ # the FactStore just publishes a *reference* to that
15
+ # already-cached result.
16
+ #
17
+ # `(plugin_id, name)` is a unique key. A second `publish` with
18
+ # the same value is a no-op (`==` comparison); a second
19
+ # `publish` with a different value raises {Conflict}. Since
20
+ # `plugin_id` namespaces the key, a real conflict only happens
21
+ # when a single plugin publishes twice with differing values —
22
+ # the conflict signals a plugin-author bug, never a load-time
23
+ # interaction between unrelated plugins.
24
+ class FactStore
25
+ Fact = Data.define(:plugin_id, :name, :value)
26
+
27
+ class Conflict < StandardError
28
+ attr_reader :plugin_id, :name, :existing, :incoming
29
+
30
+ def initialize(plugin_id:, name:, existing:, incoming:)
31
+ @plugin_id = plugin_id
32
+ @name = name
33
+ @existing = existing
34
+ @incoming = incoming
35
+ super(
36
+ "fact store conflict: plugin #{plugin_id.inspect} published " \
37
+ "two different values for #{name.inspect} " \
38
+ "(existing: #{existing.inspect}, incoming: #{incoming.inspect})"
39
+ )
40
+ end
41
+ end
42
+
43
+ def initialize
44
+ @facts = {}
45
+ @mutex = Mutex.new
46
+ end
47
+
48
+ # Writes a `(plugin_id, name) -> value` triple. Idempotent if
49
+ # the same value is published twice (`==`); raises
50
+ # {Conflict} if the values differ.
51
+ #
52
+ # @param plugin_id [String] producing plugin's manifest id.
53
+ # @param name [Symbol, String] fact name (canonicalised to
54
+ # Symbol for lookup).
55
+ # @param value [Object] frozen-shape value object the
56
+ # producer chose to publish. The value is stored as-is.
57
+ def publish(plugin_id:, name:, value:)
58
+ plugin_id = plugin_id.to_s
59
+ name = name.to_sym
60
+ @mutex.synchronize do
61
+ existing = @facts[[plugin_id, name]]
62
+ if existing && existing.value != value
63
+ raise Conflict.new(plugin_id: plugin_id, name: name, existing: existing.value, incoming: value)
64
+ end
65
+
66
+ @facts[[plugin_id, name]] = Fact.new(plugin_id: plugin_id, name: name, value: value)
67
+ end
68
+ nil
69
+ end
70
+
71
+ # @return [Object, nil] the published value, or `nil` when no
72
+ # fact is registered. Reads do NOT establish a dependency —
73
+ # `manifest(consumes:)` (slice 4) is the dependency
74
+ # declaration mechanism.
75
+ def read(plugin_id:, name:)
76
+ fact = @mutex.synchronize { @facts[[plugin_id.to_s, name.to_sym]] }
77
+ fact&.value
78
+ end
79
+
80
+ # @return [Boolean] whether a fact is registered.
81
+ def published?(plugin_id:, name:)
82
+ @mutex.synchronize { @facts.key?([plugin_id.to_s, name.to_sym]) }
83
+ end
84
+
85
+ # @yield [Fact] every published fact in publication order.
86
+ def each_fact(&)
87
+ snapshot = @mutex.synchronize { @facts.values }
88
+ snapshot.each(&)
89
+ end
90
+ end
91
+ end
92
+ 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,35 @@
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, :reason
15
+
16
+ # ADR-9 slice 5 introduces two new reason codes alongside the
17
+ # implicit "load failure" used for require / configuration /
18
+ # init failures:
19
+ #
20
+ # - `:missing-producer` — a non-optional `manifest(consumes:)`
21
+ # entry names a `(plugin_id, name)` no loaded plugin
22
+ # produces.
23
+ # - `:dependency-cycle` — the consumes graph forms a cycle.
24
+ #
25
+ # Older callers omit `reason:` and the field defaults to nil
26
+ # (the legacy "load failure" envelope).
27
+ def initialize(message, plugin_ref:, cause: nil, reason: nil)
28
+ super(message)
29
+ @plugin_ref = plugin_ref
30
+ @cause_class = cause&.class
31
+ @reason = reason&.to_sym
32
+ end
33
+ end
34
+ end
35
+ end