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.
-
.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) {|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.
-
.type_specifier(methods:) {|call_node, scope| ... } ⇒ nil
Deprecating alias for
narrowing_facts(ADR-80); removed in 0.3.0. -
.type_specifiers ⇒ Array[untyped]
Frozen snapshot of the declared type-specifier rules.
-
.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_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. -
#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.
-
#type_specifier_facts(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.
Constructor Details
#initialize(services:, config: {}) ⇒ Base
Returns a new instance of Base.
434 435 436 437 438 439 440 441 442 443 444 445 446 447 |
# File 'lib/rigor/plugin/base.rb', line 434 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.
432 433 434 |
# File 'lib/rigor/plugin/base.rb', line 432 def config @config end |
#services ⇒ Object (readonly)
Returns the value of attribute services.
432 433 434 |
# File 'lib/rigor/plugin/base.rb', line 432 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:.
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 |
# File 'lib/rigor/plugin/base.rb', line 270 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.
316 317 318 |
# File 'lib/rigor/plugin/base.rb', line 316 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.
43 44 45 46 47 48 49 50 51 |
# File 'lib/rigor/plugin/base.rb', line 43 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. type_specifier survives as a deprecating alias through
0.2.x and is removed in 0.3.0.
395 396 397 398 399 400 401 402 403 404 405 406 407 |
# File 'lib/rigor/plugin/base.rb', line 395 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 @type_specifiers ||= [] @type_specifiers << { methods: methods.map(&:to_sym).freeze, block: block }.freeze nil 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.
184 185 186 187 188 |
# File 'lib/rigor/plugin/base.rb', line 184 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.
191 192 193 |
# File 'lib/rigor/plugin/base.rb', line 191 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.
144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/rigor/plugin/base.rb', line 144 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.
158 159 160 |
# File 'lib/rigor/plugin/base.rb', line 158 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.
300 301 302 303 304 305 |
# File 'lib/rigor/plugin/base.rb', line 300 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.
290 291 292 293 294 295 |
# File 'lib/rigor/plugin/base.rb', line 290 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) {|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.
89 90 91 92 93 94 95 96 97 98 |
# File 'lib/rigor/plugin/base.rb', line 89 def producer(id, watch: nil, serialize: nil, deserialize: nil, &block) raise ArgumentError, "Plugin::Base.producer requires a block body" if block.nil? validate_producer_watch!(watch) @producers ||= {} @producers[id.to_sym] = { block: block, watch: watch, serialize: serialize, deserialize: deserialize }.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.
114 115 116 |
# File 'lib/rigor/plugin/base.rb', line 114 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.
657 658 659 660 661 662 |
# File 'lib/rigor/plugin/base.rb', line 657 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 |
.type_specifier(methods:) {|call_node, scope| ... } ⇒ nil
Deprecating alias for narrowing_facts (ADR-80); removed in 0.3.0.
413 414 415 416 417 418 419 420 |
# File 'lib/rigor/plugin/base.rb', line 413 def type_specifier(methods:, &) unless @type_specifier_deprecation_warned @type_specifier_deprecation_warned = true warn("[rigor] Plugin::Base.type_specifier is deprecated (ADR-80) and will be " \ "removed in Rigor 0.3.0; rename it to `narrowing_facts`. (#{name})") end narrowing_facts(methods:, &) end |
.type_specifiers ⇒ Array[untyped]
Frozen snapshot of the declared type-specifier rules. Memoised for the same reason as dynamic_returns — consulted per plugin per dispatch, over an array fixed at class-definition time. rubocop:disable Naming/MemoizedInstanceVariableName -- see dynamic_returns
426 427 428 |
# File 'lib/rigor/plugin/base.rb', line 426 def type_specifiers @type_specifiers_snapshot ||= (@type_specifiers || []).dup.freeze 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).
334 335 336 337 338 339 340 341 342 343 344 345 346 347 |
# File 'lib/rigor/plugin/base.rb', line 334 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).
323 324 325 326 327 328 329 330 |
# File 'lib/rigor/plugin/base.rb', line 323 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
360 361 362 363 364 365 366 367 368 369 370 371 |
# File 'lib/rigor/plugin/base.rb', line 360 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
349 350 351 352 353 354 355 356 357 358 |
# File 'lib/rigor/plugin/base.rb', line 349 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_watch!(watch) ⇒ Object
ADR-60 WD3 — watch: is nil (no glob coverage), a static tuple Array, or a Proc evaluated per
cache_for call.
102 103 104 105 106 107 108 |
# File 'lib/rigor/plugin/base.rb', line 102 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.
727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 |
# File 'lib/rigor/plugin/base.rb', line 727 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, 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.
589 590 591 592 593 594 |
# File 'lib/rigor/plugin/base.rb', line 589 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.
603 604 605 606 607 608 609 610 611 612 613 614 615 |
# File 'lib/rigor/plugin/base.rb', line 603 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.
496 497 498 |
# File 'lib/rigor/plugin/base.rb', line 496 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.
538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 |
# File 'lib/rigor/plugin/base.rb', line 538 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).
452 453 454 |
# File 'lib/rigor/plugin/base.rb', line 452 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.
703 704 705 |
# File 'lib/rigor/plugin/base.rb', line 703 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.
665 666 667 |
# File 'lib/rigor/plugin/base.rb', line 665 def manifest self.class.manifest end |
#node_rule_diagnostics(path:, scope:, root:) ⇒ Array[untyped]
Engine-executed dispatch over the declared DSLs.
510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 |
# File 'lib/rigor/plugin/base.rb', line 510 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.
899 900 901 902 903 904 905 906 907 908 909 |
# File 'lib/rigor/plugin/base.rb', line 899 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.
479 480 481 |
# File 'lib/rigor/plugin/base.rb', line 479 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.
647 648 649 |
# File 'lib/rigor/plugin/base.rb', line 647 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.
634 635 636 637 638 639 640 641 642 |
# File 'lib/rigor/plugin/base.rb', line 634 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.
696 697 698 |
# File 'lib/rigor/plugin/base.rb', line 696 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.
622 623 624 625 626 627 |
# File 'lib/rigor/plugin/base.rb', line 622 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.
676 677 678 679 680 681 682 683 684 685 686 687 688 689 |
# File 'lib/rigor/plugin/base.rb', line 676 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 |
#type_specifier_facts(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 [].
561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 |
# File 'lib/rigor/plugin/base.rb', line 561 def type_specifier_facts(call_node:, scope:) rules = self.class.type_specifiers 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 |