Class: Rigor::Plugin::Base
- Inherits:
-
Object
- Object
- Rigor::Plugin::Base
- Defined in:
- lib/rigor/plugin/base.rb,
sig/rigor/plugin/base.rbs
Overview
The plugin contract every Rigor plugin subclasses. The signatures
below are the RBS face of lib/rigor/plugin/base.rb; they let the
lib Steep target check Base's own implementation against the
declared surface and give plugin subclasses (once they enter a check
target) method resolution for the DSL + hook surface.
Collaborators that have no RBS yet (Diagnostic, Cache::Descriptor,
FlowContribution, NodeContext, Prism nodes) are typed untyped; the
precision ceiling is the sparse sig/ tree, not this file.
Instance Attribute Summary collapse
-
#config ⇒ Object
readonly
Returns the value of attribute config.
-
#services ⇒ Object
readonly
Returns the value of attribute services.
Class Method Summary collapse
-
.dynamic_return(receivers: nil, methods: nil, file_methods: nil) {|call_node, scope| ... } ⇒ nil
ADR-37 slice 2 / ADR-52 WD2 — declares a per-call-site return-type contribution, gated by receiver class, method name, or both.
-
.dynamic_returns ⇒ Array[untyped]
Frozen snapshot of the declared dynamic-return rules.
-
.manifest(**fields) ⇒ Rigor::Plugin::Manifest
Dual-purpose:
manifest(id:, version:, ...)declares + caches, the no-argmanifestreads the cached value back. -
.narrowing_facts(methods:) {|call_node, scope| ... } ⇒ nil
ADR-37 slice 2 — declares a predicate/assertion narrowing contribution, method-gated.
-
.narrowing_facts_rules ⇒ Array[untyped]
The engine-facing reader for the declared
narrowing_factsrules. -
.node_file_context {|root, scope| ... } ⇒ Object
ADR-37 slice 1c — declares a per-file context builder for a two-pass (collect-then-validate) plugin.
-
.node_file_context_block ⇒ Object
The declared per-file context builder block, or nil.
-
.node_rule(node_type) {|arg0| ... } ⇒ Object
ADR-37 slice 1 — declares a node-scoped diagnostic rule.
-
.node_rules ⇒ Array[untyped]
Frozen snapshot of the declared node rules, in declaration order.
-
.normalize_dynamic_return_methods(methods) ⇒ Object
A method-name Array is symbol-normalised + frozen; a run-time callable (ADR-52 slice 4) is stored verbatim and resolved per instance.
-
.normalize_dynamic_return_receivers(receivers) ⇒ Object
A class-name Array is frozen element-wise; a run-time callable (ADR-52 slice 3) is stored verbatim and resolved per instance.
-
.producer(id, watch: nil, serialize: nil, deserialize: nil, generation_cap: Cache::Store::UNBOUNDED_GENERATIONS) {|params| ... } ⇒ Symbol
ADR-7 § "Slice 6-A" — DSL declaration of a cached producer.
-
.producers ⇒ Hash[Symbol, untyped]
Frozen snapshot of the producer table.
-
.suggest(name, candidates) ⇒ String?
Boilerplate-reduction helper (review §1.3): the "did you mean …?" suggestion every diagnostic-emitting plugin otherwise hand-rolls.
-
.validate_dynamic_return_file_methods!(file_methods, methods) ⇒ Object
ADR-52 slice 5a —
file_methods:must be a callable, and is mutually exclusive withmethods:(one name gate, two scopes — declaring both is a contradiction, not a composition). -
.validate_dynamic_return_gate!(receivers, methods, file_methods) ⇒ Object
ADR-52 WD2 — a rule must gate on something.
- .validate_dynamic_return_methods!(methods) ⇒ Object
- .validate_dynamic_return_receivers!(receivers) ⇒ Object
-
.validate_producer_generation_cap!(id, generation_cap) ⇒ Object
A bad
generation_cap:is caught at class-definition time (plugin load) rather than at the firstcache_forround-trip, so the plugin author sees it before any caching happens. -
.validate_producer_watch!(watch) ⇒ Object
ADR-60 WD3 —
watch:is nil (no glob coverage), a static tuple Array, or a Proc evaluated percache_forcall.
Instance Method Summary collapse
-
#cache_for(producer_id, params: {}, descriptor: nil) ⇒ Object
ADR-7 § "Slice 6-A" / ADR-60 WD3 — returns a callable that performs a
Cache::Store#fetch_or_validateround-trip for the named producer (the ADR-45 record-and-validate path). -
#diagnostic(node, path:, message:, severity: :error, rule: nil, location: nil) ⇒ Object
Authoring helpers.
-
#diagnostics_for(violations, path:, node: nil) ⇒ Array[untyped]
ADR-60 WD4 — maps a plugin's own violation objects to
Rigor::Analysis::Diagnostics through #diagnostic, absorbing theviolations.map { |v| diagnostic(node, …) }block the node-rule plugins otherwise repeat. -
#diagnostics_for_file(path:, scope:, root:) ⇒ Array[untyped]
ADR-7 § "Slice 5-A" — per-file diagnostic emission hook.
-
#dynamic_return_type(call_node:, scope:, receiver_type:) ⇒ Object
ADR-37 slice 2 — the return type contributed by this plugin's Base.dynamic_return rules for a call, or nil.
-
#init(services) ⇒ nil
Overridable lifecycle / extension hooks (default no-ops).
-
#initialize(services:, config: {}) ⇒ Base
constructor
A new instance of Base.
-
#io_boundary ⇒ Rigor::Plugin::IoBoundary
ADR-7 § "Slice 6-A/6-B" — per-plugin IoBoundary.
-
#manifest ⇒ Rigor::Plugin::Manifest
Convenience accessor —
manifeston the instance returns the class-level manifest declaration. -
#narrowing_facts_for(call_node:, scope:) ⇒ Array[untyped]
ADR-37 slice 2 — the post-return narrowing facts contributed by this plugin's Base.narrowing_facts rules for a call.
-
#node_rule_diagnostics(path:, scope:, root:) ⇒ Array[untyped]
Engine-executed dispatch over the declared DSLs.
-
#plugin_entry ⇒ Object
ADR-32 WD5 — the
Cache::Descriptor::PluginEntrytemplate carrying this plugin's id, version, and a SHA-256 digest of its (canonicalised) config hash. -
#prepare(services) ⇒ nil
ADR-9 slice 3 — per-run preparation hook.
-
#producer_error(id) ⇒ Object
ADR-60 WD4 — the
StandardErrora prior #producer_value call rescued forid, or nil when it succeeded or was never called. -
#producer_value(id, params: {}) ⇒ Object
ADR-60 WD4 — runs a declared Base.producer through #cache_for and returns its value, memoised per
(id, params)INCLUDING nil. -
#protocol_contracts ⇒ Object
ADR-28 — the path-scoped method-protocol contracts this plugin contributes.
-
#read_fact(plugin_id:, name:) ⇒ Object
ADR-60 WD4 — reads a cross-plugin fact (ADR-9) published by another plugin's
#preparehook, memoised per(plugin_id, name)on this instance INCLUDING a nil result. -
#signature_paths ⇒ Array[String]
ADR-25 — absolute RBS signature directories this plugin contributes.
Constructor Details
#initialize(services:, config: {}) ⇒ Base
Returns a new instance of Base.
447 448 449 450 451 452 453 454 455 456 457 458 459 460 |
# File 'lib/rigor/plugin/base.rb', line 447 def initialize(services:, config: {}) @services = services @config = merge_config_defaults(config).freeze # ADR-52 slice 3 — per-rule cache of resolved run-time `dynamic_return receivers:` callables. Created # here (before any subclass `initialize` freezes the instance) so the lazy memo-on-first-dispatch is # a Hash-content mutation, sound even on a self-freezing plugin. @dynamic_return_runtime_cache = {} # ADR-60 WD4 — nil-inclusive memo tables for the authoring helpers ({#read_fact} / {#producer_value} # / {#producer_error}). Allocated here, before any subclass `initialize` self-freeze, for the same # reason: a populate is a Hash-content mutation. @fact_cache = {} @producer_value_cache = {} @producer_errors = {} end |
Instance Attribute Details
#config ⇒ Object (readonly)
Returns the value of attribute config.
445 446 447 |
# File 'lib/rigor/plugin/base.rb', line 445 def config @config end |
#services ⇒ Object (readonly)
Returns the value of attribute services.
445 446 447 |
# File 'lib/rigor/plugin/base.rb', line 445 def services @services end |
Class Method Details
.dynamic_return(receivers: nil, methods: nil, file_methods: nil) {|call_node, scope| ... } ⇒ nil
ADR-37 slice 2 / ADR-52 WD2 — declares a per-call-site return-type contribution, gated by receiver
class, method name, or both. The narrow successor to the return_type slot of the deleted
flow_contribution_for hook (ADR-52 WD3):
# receiver-gated only:
dynamic_return receivers: ["ActiveRecord::Base"] do |call_node, scope|
# self = plugin instance; return a Rigor::Type or nil
end
# receiver + method gated (preferred for focused rules):
dynamic_return receivers: ["Result"], methods: [:unwrap, :unwrap!] do |call_node, scope|
# fires only for Result#unwrap / Result#unwrap!
end
# method-gated only (ADR-52 WD2 — receiver-independent rules,
# e.g. a unit-dimension DSL whose receiver carrier is a
# refinement, not a nominal class):
dynamic_return methods: [:kilometers, :per_hour, :in_meters] do |call_node, scope|
# fires for any receiver when the method name matches;
# the block reads the receiver's shape itself
end
receivers: is a non-empty Array of class names; the engine calls the block only when the call's
receiver type's class equals or inherits from one of them (via Environment#class_ordering). It
MAY be omitted — then the rule is receiver-independent and fires on methods: alone.
methods: is an Array of Symbol method names. When provided, the block is skipped unless
call_node.name is in the list — declarative and cheaper than an in-block guard (the engine
compiles it into the registry's contribution table, ADR-52 WD1). It is REQUIRED when receivers:
is omitted: a rule gated on neither would fire on every dispatch, which is exactly the ungated cost
the flow_contribution_for escape valve carries — dynamic_return declines to reintroduce it.
Method-name and type-shape refinement can still be done inside the block. The block runs through
instance_exec, so config / services are in scope.
ADR-52 slice 3 — receivers: may also be a callable (a -> { ... } resolved once per run,
lazily, the first time the rule is consulted — always after #prepare) for a receiver set the
plugin only knows at run time:
dynamic_return receivers: -> { attachment_index.model_names } do |call_node, scope|
# fires when the receiver class is one a `prepare`-time scan
# found; the block does the precise per-call lookup
end
The callable runs through instance_exec, so it reads the plugin's own #prepare-built indexes.
It MUST be idempotent and post-#prepare-safe — reference a lazily-built / memoised index (as
activestorage's attachment_index and activerecord's model_index are), never a value captured at
class-definition time. The resolved set is a safe over-approximation of the block's own filter (it
admits subclasses too), so the block stays the precise gate and diagnostics are unchanged.
ADR-52 slice 4 — methods: may ALSO be a callable, for a method-name set the plugin only knows at
run time (a Sorbet catalog's keys, a config-derived DSL method name):
dynamic_return methods: -> { catalog.method_names } do |call_node, scope|
...
end
Same contract as a callable receivers: — instance_exec'd, resolved lazily after #prepare,
memoised, idempotent. A callable method set cannot be compiled into the registry's name gate (it is
unknown at registry-build time), so the plugin is consulted on every dispatch and the name filter
runs in this instance path instead — the block still only fires for a listed name, so diagnostics
are unchanged.
ADR-52 slice 5a — file_methods: is the per-file specialisation of the run-time methods:
callable, for a name set that varies per analysed file (rigor-rspec's let names — the names
depend on each file's describe/let structure, so one run-wide set cannot exist). The callable
receives the file path, runs through instance_exec, and is memoised per (rule, path):
dynamic_return file_methods: ->(path) { let_names_for(path) } do |call_node, scope|
...
end
Same idempotence contract as the other callables, plus: it MUST tolerate any path the engine
analyses (return [] / nil for a file it has no names for — never raise). Like a callable
methods:, it cannot compile into the registry name gate, so the plugin is consulted on every
dispatch and filtered here. file_methods: replaces methods: (declaring both is rejected — they
are the same gate at two scopes); it MAY combine with receivers:.
296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 |
# File 'lib/rigor/plugin/base.rb', line 296 def dynamic_return(receivers: nil, methods: nil, file_methods: nil, &block) raise ArgumentError, "Plugin::Base.dynamic_return requires a block body" if block.nil? validate_dynamic_return_gate!(receivers, methods, file_methods) validate_dynamic_return_receivers!(receivers) unless receivers.nil? validate_dynamic_return_methods!(methods) validate_dynamic_return_file_methods!(file_methods, methods) @dynamic_returns ||= [] @dynamic_returns << { receivers: normalize_dynamic_return_receivers(receivers), methods: normalize_dynamic_return_methods(methods), file_methods: file_methods, block: block }.freeze nil end |
.dynamic_returns ⇒ Array[untyped]
Frozen snapshot of the declared dynamic-return rules. Memoised: @dynamic_returns is built once at
class-definition time (via dynamic_return) and never mutated during analysis, and every element
is already frozen, so a fresh dup.freeze per call was pure waste — the engine calls this for
every plugin on every dispatch (collect_plugin_contributions), making it a top allocation site on
plugin-heavy projects. The cached frozen array is immutable, so sharing one instance across callers
is safe.
rubocop:disable Naming/MemoizedInstanceVariableName -- the natural name @dynamic_returns is the
canonical (mutable-at-definition) store this snapshots; the memo must be distinct.
342 343 344 |
# File 'lib/rigor/plugin/base.rb', line 342 def dynamic_returns @dynamic_returns_snapshot ||= (@dynamic_returns || []).dup.freeze end |
.manifest(**fields) ⇒ Rigor::Plugin::Manifest
Dual-purpose: manifest(id:, version:, ...) declares + caches,
the no-arg manifest reads the cached value back.
46 47 48 49 50 51 52 53 54 |
# File 'lib/rigor/plugin/base.rb', line 46 def manifest(**fields) if fields.empty? raise ArgumentError, "plugin #{self} did not declare a manifest" unless defined?(@manifest) && @manifest return @manifest end @manifest = Manifest.new(**fields) end |
.narrowing_facts(methods:) {|call_node, scope| ... } ⇒ nil
ADR-37 slice 2 — declares a predicate/assertion narrowing contribution, method-gated. The narrow
successor to the post_return_facts slot of the deleted flow_contribution_for hook (ADR-52 WD3):
narrowing_facts methods: [:assert_kind_of] do |call_node, scope|
# return an Array of post-return facts, or nil
end
methods: is a non-empty Array of method names; the engine calls the block only when
call_node.name is one of them. The block returns the same post_return_facts the merger applies.
This hook supplies post-return narrowing facts — the predicate/assertion edges a call establishes
about its arguments or receiver, e.g. assert_kind_of(String, x) ⇒ x is narrowed to String on
the continuation. It does NOT give a call a type; for that use dynamic_return (per-call-site)
or contribute RBS via the manifest signature_paths:. dynamic_return is the type slot, this is
the fact slot.
Renamed from type_specifier (ADR-80): the old name read as a parallel to dynamic_return (a
type) when it actually returns facts. The old verb was a deprecating alias through 0.2.x and is
gone in 0.3.0 — narrowing_facts is the only spelling.
421 422 423 424 425 426 427 428 429 430 431 432 433 |
# File 'lib/rigor/plugin/base.rb', line 421 def narrowing_facts(methods:, &block) raise ArgumentError, "Plugin::Base.narrowing_facts requires a block body" if block.nil? unless methods.is_a?(Array) && !methods.empty? && methods.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) } raise ArgumentError, "Plugin::Base.narrowing_facts methods: must be a non-empty Array of Symbol/String, " \ "got #{methods.inspect}" end @narrowing_facts_rules ||= [] @narrowing_facts_rules << { methods: methods.map(&:to_sym).freeze, block: block }.freeze nil end |
.narrowing_facts_rules ⇒ Array[untyped]
The engine-facing reader for the declared narrowing_facts rules. Named
type_specifiers before ADR-80; that spelling was removed in 0.3.0.
439 440 441 |
# File 'lib/rigor/plugin/base.rb', line 439 def narrowing_facts_rules @narrowing_facts_rules_snapshot ||= (@narrowing_facts_rules || []).dup.freeze end |
.node_file_context {|root, scope| ... } ⇒ Object
ADR-37 slice 1c — declares a per-file context builder for a two-pass (collect-then-validate)
plugin. The block runs once per analysed file (via instance_exec, so the plugin instance is
self) BEFORE any node rule fires, receives (root, scope), and returns an arbitrary file-local
value that is threaded to every node_rule block as its fourth argument:
node_file_context do |root, _scope|
collect_declared_states(root) # the "collect" pass
end
node_rule Prism::CallNode do |node, _scope, path, states|
next [] unless transition_call?(node)
validate(node, path, states) # the "validate" pass
end
This is what lets a same-file two-pass plugin drop its hand-rolled validate walk: the collect pass
computes the closed namespace once (it MUST complete before validation because a reference may
precede its declaration), and the engine owns the validate walk. A cross-file collect belongs in
#prepare + services.fact_store instead — a node rule reads the fact directly and needs no
per-file context.
Only one builder per plugin; a second declaration replaces the first. The block result is nil
when none is declared.
210 211 212 213 214 |
# File 'lib/rigor/plugin/base.rb', line 210 def node_file_context(&block) raise ArgumentError, "Plugin::Base.node_file_context requires a block body" if block.nil? @node_file_context_block = block end |
.node_file_context_block ⇒ Object
The declared per-file context builder block, or nil.
217 218 219 |
# File 'lib/rigor/plugin/base.rb', line 217 def node_file_context_block defined?(@node_file_context_block) ? @node_file_context_block : nil end |
.node_rule(node_type) {|arg0| ... } ⇒ Object
ADR-37 slice 1 — declares a node-scoped diagnostic rule. The engine owns a single AST walk per file (see #node_rule_diagnostics) and dispatches each node to the rules registered for its type, so a plugin author writes the check, never the traversal:
class MyPlugin < Rigor::Plugin::Base
manifest(id: "demo", version: "0.1.0")
node_rule Prism::CallNode do |node, scope, path|
next [] unless node.name == :transition_to
[diagnostic(node, path: path, message: "…", rule: "x")]
end
end
node_type is a Prism::Node subclass; the rule fires for every node where
node.is_a?(node_type). The block runs through instance_exec so self is the plugin instance —
config, services, io_boundary, diagnostic, and the cross-plugin services.fact_store are
all in scope. It receives (node, scope, path, file_context, context) — the fourth argument is the
value built by node_file_context for a two-pass plugin (nil otherwise); the fifth is a
NodeContext carrying the node's lexical ancestors (enclosing class / method / block
DSL). Trailing arguments may be omitted from the block's parameter list. The block MUST return an
Array of Rigor::Analysis::Diagnostic (an empty array to fire nothing); the runner stamps
plugin.<id> provenance.
Multiple rules for the same node_type are allowed and run in declaration order. This is the
producer-style class DSL rather than a manifest field because a rule carries logic that needs the
plugin instance, not pure data.
170 171 172 173 174 175 176 177 178 179 180 |
# File 'lib/rigor/plugin/base.rb', line 170 def node_rule(node_type, &block) raise ArgumentError, "Plugin::Base.node_rule requires a block body" if block.nil? unless node_type.is_a?(Class) && node_type <= Prism::Node raise ArgumentError, "Plugin::Base.node_rule node_type must be a Prism::Node subclass, got #{node_type.inspect}" end @node_rules ||= [] @node_rules << { node_type: node_type, block: block }.freeze node_type end |
.node_rules ⇒ Array[untyped]
Frozen snapshot of the declared node rules, in declaration order. Not inherited from a superclass — like producers, the loader instantiates one subclass per registration.
184 185 186 |
# File 'lib/rigor/plugin/base.rb', line 184 def node_rules (@node_rules || []).dup.freeze end |
.normalize_dynamic_return_methods(methods) ⇒ Object
A method-name Array is symbol-normalised + frozen; a run-time callable (ADR-52 slice 4) is stored verbatim and resolved per instance.
326 327 328 329 330 331 |
# File 'lib/rigor/plugin/base.rb', line 326 def normalize_dynamic_return_methods(methods) return nil if methods.nil? return methods if methods.respond_to?(:call) methods.map(&:to_sym).freeze end |
.normalize_dynamic_return_receivers(receivers) ⇒ Object
A class-name Array is frozen element-wise; a run-time callable (ADR-52 slice 3) is stored verbatim and resolved per instance.
316 317 318 319 320 321 |
# File 'lib/rigor/plugin/base.rb', line 316 def normalize_dynamic_return_receivers(receivers) return nil if receivers.nil? return receivers if receivers.respond_to?(:call) receivers.map { |r| r.dup.freeze }.freeze end |
.producer(id, watch: nil, serialize: nil, deserialize: nil, generation_cap: Cache::Store::UNBOUNDED_GENERATIONS) {|params| ... } ⇒ Symbol
ADR-7 § "Slice 6-A" — DSL declaration of a cached producer. Plugin authors write
class MyPlugin < Rigor::Plugin::Base
manifest(id: "rails", version: "0.1.0")
producer :schema_table do |params|
schema = io_boundary.read_file("db/schema.rb")
parse(schema, params)
end
end
The block runs through instance_exec so self inside the body is the plugin instance —
io_boundary, services, manifest, config are all in scope. The block receives the call-site
params Hash as its sole argument; the same params Hash mixes into the cache key per
Cache::Descriptor#cache_key_for.
serialize: / deserialize: apply to the producer's return VALUE (the cache layer wraps them
around the record-and-validate entry pair itself). Default round-trip is Marshal.dump /
Marshal.load per the v0.0.9 callable surface; producers whose return values are not Marshal-clean
must supply their own pair.
watch: (ADR-60 WD3) declares the glob coverage of a discovery-style producer — the files whose
addition / removal / edit must invalidate the cached value even when the producer block never read
them individually (e.g. it globbed a directory itself). It is either
- a static Array of
[roots, pattern, ...]tuples (rootsa String or Array of Strings; one or more glob-pattern suffixes per tuple — the same shape #glob_descriptor takes), or - a Proc, run through
instance_execon the plugin instance atcache_forinvocation time (NEVER at class-definition time — search roots are typically computed in#initfrom config), returning the same tuple Array.
The evaluated tuples become Cache::Descriptor::GlobEntry rows in the dependency descriptor
recorded after the block runs; Descriptor#fresh? re-globs + re-digests on the next run.
Producer ids are auto-prefixed plugin.<manifest.id>. at the cache layer (slice 6-C) so plugin-side
ids cannot collide with built-in producers.
generation_cap: declares how many generations of this producer's entries survive
Cache::Store#evict!'s compaction pass. The default —
Cache::Store::UNBOUNDED_GENERATIONS — suits the usual plugin producer, which keys per file or per
discovered unit and keeps many entries live at once; only the size-based LRU pass bounds it. A
producer whose entries are WHOLE-PROJECT and content-keyed (each run's inputs produce a fresh key
and orphan the previous one) should declare a small positive Integer instead, so the orphans are
reclaimed rather than accumulating under the global byte cap.
100 101 102 103 104 105 106 107 108 109 110 111 112 |
# File 'lib/rigor/plugin/base.rb', line 100 def producer(id, watch: nil, serialize: nil, deserialize: nil, generation_cap: Cache::Store::UNBOUNDED_GENERATIONS, &block) raise ArgumentError, "Plugin::Base.producer requires a block body" if block.nil? validate_producer_watch!(watch) validate_producer_generation_cap!(id, generation_cap) @producers ||= {} @producers[id.to_sym] = { block: block, watch: watch, serialize: serialize, deserialize: deserialize, generation_cap: generation_cap }.freeze id.to_sym end |
.producers ⇒ Hash[Symbol, untyped]
Frozen snapshot of the producer table. Inherited producers from a superclass are intentionally NOT surfaced — Plugin::Base subclasses do not chain producers, and the loader instantiates one subclass per registration.
140 141 142 |
# File 'lib/rigor/plugin/base.rb', line 140 def producers (@producers || {}).dup.freeze end |
.suggest(name, candidates) ⇒ String?
Boilerplate-reduction helper (review §1.3): the "did you mean …?" suggestion every
diagnostic-emitting plugin otherwise hand-rolls. Returns the closest of candidates to name via
DidYouMean::SpellChecker (the same engine Ruby's own NoMethodError hints use), or nil when
there is no good match / no candidates — replacing the per-plugin Levenshtein copies. A class
method so it is callable both from a plugin instance (Rigor::Plugin::Base.suggest(...)) and from an
Analyzer module function that has no instance.
670 671 672 673 674 675 |
# File 'lib/rigor/plugin/base.rb', line 670 def self.suggest(name, candidates) dictionary = Array(candidates).map(&:to_s) return nil if dictionary.empty? DidYouMean::SpellChecker.new(dictionary: dictionary).correct(name.to_s).first end |
.validate_dynamic_return_file_methods!(file_methods, methods) ⇒ Object
ADR-52 slice 5a — file_methods: must be a callable, and is mutually exclusive with methods:
(one name gate, two scopes — declaring both is a contradiction, not a composition).
360 361 362 363 364 365 366 367 368 369 370 371 372 373 |
# File 'lib/rigor/plugin/base.rb', line 360 def validate_dynamic_return_file_methods!(file_methods, methods) return if file_methods.nil? unless file_methods.respond_to?(:call) raise ArgumentError, "Plugin::Base.dynamic_return file_methods: must be a callable receiving the file path, " \ "got #{file_methods.inspect}" end return if methods.nil? raise ArgumentError, "Plugin::Base.dynamic_return file_methods: replaces methods: — declare one name gate, " \ "not both" end |
.validate_dynamic_return_gate!(receivers, methods, file_methods) ⇒ Object
ADR-52 WD2 — a rule must gate on something. receivers: alone, methods: alone, or both are
valid; neither is not (it would fire on every dispatch).
349 350 351 352 353 354 355 356 |
# File 'lib/rigor/plugin/base.rb', line 349 def validate_dynamic_return_gate!(receivers, methods, file_methods) return unless receivers.nil? && file_methods.nil? return if (methods.is_a?(Array) && !methods.empty?) || methods.respond_to?(:call) raise ArgumentError, "Plugin::Base.dynamic_return requires receivers:, methods:, or file_methods: — a rule " \ "gated on none would fire on every dispatch (that is what flow_contribution_for is for)" end |
.validate_dynamic_return_methods!(methods) ⇒ Object
386 387 388 389 390 391 392 393 394 395 396 397 |
# File 'lib/rigor/plugin/base.rb', line 386 def validate_dynamic_return_methods!(methods) return if methods.nil? # ADR-52 slice 4 — a run-time callable resolves to the name set per instance after `#prepare`; its # shape is checked then. return if methods.respond_to?(:call) return if methods.is_a?(Array) && !methods.empty? && methods.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) } raise ArgumentError, "Plugin::Base.dynamic_return methods: must be a non-empty Array of Symbol/String, a callable, " \ "or nil, got #{methods.inspect}" end |
.validate_dynamic_return_receivers!(receivers) ⇒ Object
375 376 377 378 379 380 381 382 383 384 |
# File 'lib/rigor/plugin/base.rb', line 375 def validate_dynamic_return_receivers!(receivers) # ADR-52 slice 3 — a run-time callable is resolved per instance after `#prepare`; its shape is # checked at resolution time. return if receivers.respond_to?(:call) return if receivers.is_a?(Array) && !receivers.empty? && receivers.all? { |r| r.is_a?(String) && !r.empty? } raise ArgumentError, "Plugin::Base.dynamic_return receivers: must be a non-empty Array of class-name Strings " \ "or a callable, got #{receivers.inspect}" end |
.validate_producer_generation_cap!(id, generation_cap) ⇒ Object
A bad generation_cap: is caught at class-definition time (plugin load) rather than at the first
cache_for round-trip, so the plugin author sees it before any caching happens.
116 117 118 119 120 121 122 123 |
# File 'lib/rigor/plugin/base.rb', line 116 def validate_producer_generation_cap!(id, generation_cap) return if generation_cap == Cache::Store::UNBOUNDED_GENERATIONS return if generation_cap.is_a?(Integer) && generation_cap.positive? raise ArgumentError, "Plugin::Base.producer #{id.inspect} generation_cap: must be a positive Integer or " \ "#{Cache::Store::UNBOUNDED_GENERATIONS.inspect}, got #{generation_cap.inspect}" end |
.validate_producer_watch!(watch) ⇒ Object
ADR-60 WD3 — watch: is nil (no glob coverage), a static tuple Array, or a Proc evaluated per
cache_for call.
128 129 130 131 132 133 134 |
# File 'lib/rigor/plugin/base.rb', line 128 def validate_producer_watch!(watch) return if watch.nil? || watch.is_a?(Array) || watch.respond_to?(:call) raise ArgumentError, "Plugin::Base.producer watch: must be nil, an Array of [roots, pattern, ...] tuples, " \ "or a Proc returning one, got #{watch.inspect}" end |
Instance Method Details
#cache_for(producer_id, params: {}, descriptor: nil) ⇒ Object
ADR-7 § "Slice 6-A" / ADR-60 WD3 — returns a callable that performs a Cache::Store#fetch_or_validate
round-trip for the named producer (the ADR-45 record-and-validate path). The entry is KEYED on the
stable identity inputs — the plugin's PluginEntry template (id, version, config_hash) composed with
the optional descriptor: extras — and stores, beside the value, a DEPENDENCY descriptor recorded
AFTER the producer block ran: the IoBoundary's post-compute read history plus the evaluated
watch: Cache::Descriptor::GlobEntry rows. In-block reads are therefore always captured (the
structural stale-cache hazard fetch_or_compute's call-time snapshot carried); the next run
re-validates the recorded dependencies by re-digest (Descriptor#fresh?) and recomputes when any
changed. The producer id is auto-prefixed plugin.<manifest.id>. per ADR-7 § "Slice 6-C" so plugin
caches stay sandboxed from built-in producers.
When services.cache_store is nil (e.g. CLI --no-cache), the callable bypasses the cache and
runs the producer block every time — same semantics as the v0.0.9 cache surface for built-in
producers.
descriptor: (optional) supplies extra Cache::Descriptor rows for IDENTITY inputs — gem-version
GemEntry pins, ConfigEntry rows for external state — that compose into the cache KEY via
Cache::Descriptor.compose; per-slot conflicts raise Cache::Descriptor::Conflict to make divergent
inputs visible rather than silently shadowing. A key change is a miss, so the invalidation effect of
the legacy glob_descriptor-as-descriptor: idiom is preserved unchanged.
740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 |
# File 'lib/rigor/plugin/base.rb', line 740 def cache_for(producer_id, params: {}, descriptor: nil) producer = self.class.producers[producer_id.to_sym] unless producer raise ArgumentError, "plugin #{manifest.id.inspect} did not declare producer #{producer_id.inspect}" end compute = -> { instance_exec(params, &producer[:block]) } store = services.cache_store return compute unless store prefixed_id = "plugin.#{manifest.id}.#{producer_id}" key_descriptor = compose_key_descriptor(descriptor) lambda do store.fetch_or_validate( producer_id: prefixed_id, key_descriptor: key_descriptor, generation_cap: producer[:generation_cap], params: params, serialize: pair_serializer(producer[:serialize]), deserialize: pair_deserializer(producer[:deserialize]) ) do value = compute.call [value, producer_dependency_descriptor(producer)] end end end |
#diagnostic(node, path:, message:, severity: :error, rule: nil, location: nil) ⇒ Object
Authoring helpers.
602 603 604 605 606 607 |
# File 'lib/rigor/plugin/base.rb', line 602 def diagnostic(node, path:, message:, severity: :error, rule: nil, location: nil) Analysis::Diagnostic.from_location( location || node.location, path: path, message: , severity: severity, rule: rule ) end |
#diagnostics_for(violations, path:, node: nil) ⇒ Array[untyped]
ADR-60 WD4 — maps a plugin's own violation objects to Rigor::Analysis::Diagnostics through
#diagnostic, absorbing the violations.map { |v| diagnostic(node, …) } block the node-rule plugins
otherwise repeat. Each violation duck-types: #message (required); optional #node (the Prism node
to position at — falls back to the node: argument, the common "all violations point at the same
call" case), #location (a sub-location such as node.message_loc), #severity (defaults
:error), and #rule. Returns an Array suitable for direct return from #diagnostics_for_file / a
node_rule block.
616 617 618 619 620 621 622 623 624 625 626 627 628 |
# File 'lib/rigor/plugin/base.rb', line 616 def diagnostics_for(violations, path:, node: nil) Array(violations).map do |violation| target = (violation.node if violation.respond_to?(:node)) || node diagnostic( target, path: path, message: violation., severity: (violation.respond_to?(:severity) && violation.severity) || :error, rule: (violation.rule if violation.respond_to?(:rule)), location: (violation.location if violation.respond_to?(:location)) ) end end |
#diagnostics_for_file(path:, scope:, root:) ⇒ Array[untyped]
ADR-7 § "Slice 5-A" — per-file diagnostic emission hook. Override in plugin subclasses to return an
array of Rigor::Analysis::Diagnostic rows for the analysed file. The runner stamps each returned
diagnostic with source_family: "plugin.<manifest.id>" automatically per ADR-7 § "Slice 5-B"; plugin
authors should construct diagnostics without setting source_family (any value they pass is
overwritten).
path is the analysed file path; scope is the entry Rigor::Scope after ScopeIndexer ran; root
is the parsed Prism::Node root. Plugin authors can traverse root themselves or declare rules via
the .node_rule DSL (ADR-37, shipped). The PHPStan-style Rule<TNode> base class mentioned in ADR-2
was superseded by the block-based .node_rule DSL.
Default returns [] so plugins that contribute through other channels (e.g. slice-4 narrowing
contributions, slice-6 cache producers) do not have to override.
509 510 511 |
# File 'lib/rigor/plugin/base.rb', line 509 def diagnostics_for_file(path:, scope:, root:) # rubocop:disable Lint/UnusedMethodArgument [] end |
#dynamic_return_type(call_node:, scope:, receiver_type:) ⇒ Object
ADR-37 slice 2 — the return type contributed by this plugin's dynamic_return rules for a call, or
nil. The engine calls this from MethodDispatcher; a rule fires only when receiver_type's class
equals or inherits from one of its declared receivers:. First non-nil wins (declaration order).
Failures isolate to nil.
551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 |
# File 'lib/rigor/plugin/base.rb', line 551 def dynamic_return_type(call_node:, scope:, receiver_type:) rules = self.class.dynamic_returns return nil if rules.empty? || receiver_type.nil? # `class_name` is nil for a receiver carrier with no nominal class (a refinement dimension, an # inferred shape) — fine for a receiver-less (methods-only) rule (ADR-52 WD2), which gates on the # method name alone and reads the receiver shape inside its own block. class_name = dynamic_return_receiver_class_name(receiver_type) environment = scope&.environment rules.each do |rule| next unless dynamic_return_rule_applies?(rule, call_node, class_name, environment, scope) result = instance_exec(call_node, scope, &rule[:block]) return result if result end nil rescue StandardError nil end |
#init(services) ⇒ nil
Overridable lifecycle / extension hooks (default no-ops).
465 466 467 |
# File 'lib/rigor/plugin/base.rb', line 465 def init(services) # rubocop:disable Lint/UnusedMethodArgument nil end |
#io_boundary ⇒ Rigor::Plugin::IoBoundary
ADR-7 § "Slice 6-A/6-B" — per-plugin IoBoundary. Memoised so the boundary's accumulated FileEntry
rows persist across producer invocations within the same plugin instance and feed cache invalidation
via cache_for.
716 717 718 |
# File 'lib/rigor/plugin/base.rb', line 716 def io_boundary @io_boundary ||= services.io_boundary_for(manifest.id) end |
#manifest ⇒ Rigor::Plugin::Manifest
Convenience accessor — manifest on the instance returns the class-level manifest declaration.
678 679 680 |
# File 'lib/rigor/plugin/base.rb', line 678 def manifest self.class.manifest end |
#narrowing_facts_for(call_node:, scope:) ⇒ Array[untyped]
ADR-37 slice 2 — the post-return narrowing facts contributed by this plugin's narrowing_facts
rules for a call. The engine calls this from StatementEvaluator; a rule fires only when
call_node.name is one of its declared methods:. Failures isolate to [].
574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 |
# File 'lib/rigor/plugin/base.rb', line 574 def narrowing_facts_for(call_node:, scope:) rules = self.class.narrowing_facts_rules return [] if rules.empty? || !call_node.respond_to?(:name) name = call_node.name facts = [] rules.each do |rule| next unless rule[:methods].include?(name) result = instance_exec(call_node, scope, &rule[:block]) facts.concat(Array(result)) if result end facts rescue StandardError [] end |
#node_rule_diagnostics(path:, scope:, root:) ⇒ Array[untyped]
Engine-executed dispatch over the declared DSLs.
523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 |
# File 'lib/rigor/plugin/base.rb', line 523 def node_rule_diagnostics(path:, scope:, root:) rules = self.class.node_rules return [] if rules.empty? || root.nil? # ADR-37 slice 1c — build the per-file context once (the "collect" pass) before the engine-owned # validate walk, so a two-pass plugin sees the closed namespace at every node. context_block = self.class.node_file_context_block file_context = context_block ? instance_exec(root, scope, &context_block) : nil diagnostics = [] Source::NodeWalker.each_with_ancestors(root) do |node, ancestors| # One frozen NodeContext per node, shared across the rules that match it (ADR-52 WD1) — built # lazily so non-matching nodes (the vast majority) allocate nothing. context = nil rules.each do |rule| next unless node.is_a?(rule[:node_type]) context ||= NodeContext.new(ancestors) diagnostics.concat(Array(instance_exec(node, scope, path, file_context, context, &rule[:block]))) end end diagnostics end |
#plugin_entry ⇒ Object
ADR-32 WD5 — the Cache::Descriptor::PluginEntry template carrying this plugin's id, version, and a
SHA-256 digest of its (canonicalised) config hash. Callers outside the plugin (e.g.
Environment.for_project caching per-file synthesizer output) compose this entry into their own
cache descriptor so a config change to the plugin (e.g. flipping require_magic_comment:)
invalidates the dependent cache.
913 914 915 916 917 918 919 920 921 922 923 |
# File 'lib/rigor/plugin/base.rb', line 913 def plugin_entry # Built fresh on each call rather than memoised so a plugin subclass that freezes itself in # `initialize` (e.g. `Rigor::Plugin::RbsInline` per ADR-32) doesn't trip a FrozenError on first read. # The construction cost is a single `Data.define`-backed value-object build; the cache key derivation # downstream is the expensive step, and it's already memoised inside `Cache::Store`. Cache::Descriptor::PluginEntry.new( id: manifest.id, version: manifest.version, config_hash: digest_config(config) ) end |
#prepare(services) ⇒ nil
ADR-9 slice 3 — per-run preparation hook. The runner invokes #prepare(services) on every loaded
plugin once per Analysis::Runner.run, after #init has run on every plugin and before any
#diagnostics_for_file call. Plugins use this hook to compute and publish facts other plugins
consume:
def prepare(services)
services.fact_store.publish(
plugin_id: manifest.id, name: :model_index, value: model_index
)
end
Default no-op so plugins without facts to publish leave #prepare unimplemented. Failures isolate as
:plugin_loader runtime-error diagnostics; a plugin that raises in #prepare has its facts
considered un-published and downstream consumers see nil from fact_store.read.
Slice 3 calls plugins in registration order. ADR-9 slice 5 introduces topological ordering by
consumes: so producers always run before consumers.
492 493 494 |
# File 'lib/rigor/plugin/base.rb', line 492 def prepare(services) # rubocop:disable Lint/UnusedMethodArgument nil end |
#producer_error(id) ⇒ Object
ADR-60 WD4 — the StandardError a prior #producer_value call rescued for id, or nil when it
succeeded or was never called. Plugins surface it as a load-error diagnostic from
#diagnostics_for_file.
660 661 662 |
# File 'lib/rigor/plugin/base.rb', line 660 def producer_error(id) @producer_errors[id.to_sym] end |
#producer_value(id, params: {}) ⇒ Object
ADR-60 WD4 — runs a declared producer through #cache_for and returns its value, memoised per
(id, params) INCLUDING nil. A StandardError the producer raises (a malformed project file, an I/O
failure) is rescued, recorded for #producer_error, and yields nil — so one bad project file
degrades a plugin to silence rather than aborting the whole run. This is the *_index_or_nil shape
the discovery plugins hand-rolled, named once.
647 648 649 650 651 652 653 654 655 |
# File 'lib/rigor/plugin/base.rb', line 647 def producer_value(id, params: {}) key = [id.to_sym, params].freeze return @producer_value_cache[key] if @producer_value_cache.key?(key) @producer_value_cache[key] = cache_for(id, params: params).call rescue StandardError => e @producer_errors[id.to_sym] = e @producer_value_cache[key] = nil end |
#protocol_contracts ⇒ Object
ADR-28 — the path-scoped method-protocol contracts this plugin contributes. Defaults to the
manifest-declared protocol_contracts:; the same indirection #signature_paths uses, so a plugin
MAY override this to fold per-project config into the contract set (e.g. substituting the convention
path_glob with a user-supplied one) without the manifest having to be config-aware.
Plugin::Registry#protocol_contracts aggregates the result across loaded plugins.
709 710 711 |
# File 'lib/rigor/plugin/base.rb', line 709 def protocol_contracts manifest.protocol_contracts end |
#read_fact(plugin_id:, name:) ⇒ Object
ADR-60 WD4 — reads a cross-plugin fact (ADR-9) published by another plugin's #prepare hook,
memoised per (plugin_id, name) on this instance INCLUDING a nil result. The nil-inclusive memo
retires the hand-rolled @x_resolved flag the discovery plugins carried to distinguish "fact not
published" from "not yet read". services.fact_store is the only sanctioned cross-plugin channel; a
fact no loaded producer published reads as nil.
635 636 637 638 639 640 |
# File 'lib/rigor/plugin/base.rb', line 635 def read_fact(plugin_id:, name:) key = [plugin_id.to_s, name.to_sym].freeze return @fact_cache[key] if @fact_cache.key?(key) @fact_cache[key] = services.fact_store.read(plugin_id: plugin_id.to_s, name: name.to_sym) end |
#signature_paths ⇒ Array[String]
ADR-25 — absolute RBS signature directories this plugin contributes. Resolves each
manifest.signature_paths entry (declared relative to the plugin gem root) against that root. The
gem root is the directory above lib/ in the file that defined the plugin class (falling back to
that file's directory for a non-conventional layout). Returns [] when the manifest declares no
signature_paths: or the class is anonymous (an anonymous class cannot ship a gem).
Plugin::Loader validates the resolved dirs exist at load time; Environment.for_project merges
them into the signature-path set fed to RbsLoader.
689 690 691 692 693 694 695 696 697 698 699 700 701 702 |
# File 'lib/rigor/plugin/base.rb', line 689 def signature_paths relative = manifest.signature_paths return [] if relative.empty? class_name = self.class.name return [] if class_name.nil? file, = Object.const_source_location(class_name) return [] if file.nil? before, separator, = file.rpartition("/lib/") root = separator.empty? ? File.dirname(file) : before relative.map { |rel| File.(rel, root) } end |