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,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
|