Class: Rigor::Plugin::Base
- Inherits:
-
Object
- Object
- Rigor::Plugin::Base
- Defined in:
- lib/rigor/plugin/base.rb
Overview
Base class every Rigor plugin subclasses. The plugin gem subclasses Base, declares its identity through Base.manifest, registers the subclass with register, and overrides #init to wire up any state it needs from the injected service container.
Slice 1 ships only the registration / loading plumbing. The protocol hooks (dynamic-return contributions, type-specifying contributions, dynamic reflection) land in subsequent v0.1.0 slices and arrive as additional methods on this class.
Example plugin:
class MyRailsPlugin < Rigor::Plugin::Base
manifest(
id: "rails",
version: "0.1.0",
description: "Rails framework support for Rigor"
)
def init(services)
@reflection = services.reflection
@type = services.type
end
end
Rigor::Plugin.register(MyRailsPlugin)
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, &block) ⇒ Object
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 ⇒ Object
Frozen snapshot of the declared dynamic-return rules.
-
.manifest(**fields) ⇒ Object
Declares the plugin’s manifest.
-
.node_file_context(&block) ⇒ 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, &block) ⇒ Object
ADR-37 slice 1 — declares a node-scoped diagnostic rule.
-
.node_rules ⇒ Object
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, &block) ⇒ Object
ADR-7 § “Slice 6-A” — DSL declaration of a cached producer.
-
.producers ⇒ Object
Frozen snapshot of the producer table.
-
.suggest(name, candidates) ⇒ Object
Boilerplate-reduction helper (review §1.3): the “did you mean …?” suggestion every diagnostic-emitting plugin otherwise hand-rolls.
-
.type_specifier(methods:, &block) ⇒ Object
ADR-37 slice 2 — declares a predicate/assertion narrowing contribution, method-gated.
-
.type_specifiers ⇒ Object
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 with `methods:` (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 per `cache_for` call.
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_validate` round-trip for the named producer (the ADR-45 record-and-validate path).
-
#diagnostic(node, path:, message:, severity: :error, rule: nil, location: nil) ⇒ Object
Builds a ‘Rigor::Analysis::Diagnostic` positioned at a Prism `node` for return from `#diagnostics_for_file`.
-
#diagnostics_for(violations, path:, node: nil) ⇒ Object
ADR-60 WD4 — maps a plugin’s own violation objects to ‘Rigor::Analysis::Diagnostic`s through #diagnostic, absorbing the `violations.map { |v| diagnostic(node, …) }` block the node-rule plugins otherwise repeat.
-
#diagnostics_for_file(path:, scope:, root:) ⇒ Object
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) ⇒ Object
Override in subclasses to wire any state the plugin needs from the injected service container.
-
#initialize(services:, config: {}) ⇒ Base
constructor
A new instance of Base.
-
#io_boundary ⇒ Object
ADR-7 § “Slice 6-A/6-B” — per-plugin IoBoundary.
-
#manifest ⇒ Object
Convenience accessor — ‘manifest` on the instance returns the class-level manifest declaration.
-
#node_rule_diagnostics(path:, scope:, root:) ⇒ Object
ADR-37 slice 1 — runs the plugin’s declared Base.node_rules over one file and returns their diagnostics.
-
#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.
-
#prepare(services) ⇒ Object
ADR-9 slice 3 — per-run preparation hook.
-
#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.
-
#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 ‘#prepare` hook, memoised per `(plugin_id, name)` on this instance INCLUDING a nil result.
-
#signature_paths ⇒ Object
ADR-25 — absolute RBS signature directories this plugin contributes.
-
#type_specifier_facts(call_node:, scope:) ⇒ Object
ADR-37 slice 2 — the post-return narrowing facts contributed by this plugin’s Base.type_specifier rules for a call.
Constructor Details
#initialize(services:, config: {}) ⇒ Base
Returns a new instance of Base.
475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 |
# File 'lib/rigor/plugin/base.rb', line 475 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.
473 474 475 |
# File 'lib/rigor/plugin/base.rb', line 473 def config @config end |
#services ⇒ Object (readonly)
Returns the value of attribute services.
473 474 475 |
# File 'lib/rigor/plugin/base.rb', line 473 def services @services end |
Class Method Details
.dynamic_return(receivers: nil, methods: nil, file_methods: nil, &block) ⇒ Object
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: -> { .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:`.
324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 |
# File 'lib/rigor/plugin/base.rb', line 324 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 ⇒ Object
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.
375 376 377 |
# File 'lib/rigor/plugin/base.rb', line 375 def dynamic_returns @dynamic_returns_snapshot ||= (@dynamic_returns || []).dup.freeze end |
.manifest(**fields) ⇒ Object
48 49 50 51 52 53 54 55 56 |
# File 'lib/rigor/plugin/base.rb', line 48 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 |
.node_file_context(&block) ⇒ 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.
218 219 220 221 222 |
# File 'lib/rigor/plugin/base.rb', line 218 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.
225 226 227 |
# File 'lib/rigor/plugin/base.rb', line 225 def node_file_context_block defined?(@node_file_context_block) ? @node_file_context_block : nil end |
.node_rule(node_type, &block) ⇒ 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.
173 174 175 176 177 178 179 180 181 182 183 |
# File 'lib/rigor/plugin/base.rb', line 173 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 ⇒ Object
Frozen snapshot of the declared node rules, in declaration order. Not inherited from a superclass — like producers, the loader instantiates one subclass per registration.
188 189 190 |
# File 'lib/rigor/plugin/base.rb', line 188 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.
355 356 357 358 359 360 |
# File 'lib/rigor/plugin/base.rb', line 355 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.
344 345 346 347 348 349 |
# File 'lib/rigor/plugin/base.rb', line 344 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, &block) ⇒ Object
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 (`roots` a 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_exec` on the plugin instance at `cache_for` invocation time (NEVER at class-definition time — search roots are typically computed in `#init` from 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.
108 109 110 111 112 113 114 115 116 117 |
# File 'lib/rigor/plugin/base.rb', line 108 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 ⇒ Object
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.
135 136 137 |
# File 'lib/rigor/plugin/base.rb', line 135 def producers (@producers || {}).dup.freeze end |
.suggest(name, candidates) ⇒ Object
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.
743 744 745 746 747 748 |
# File 'lib/rigor/plugin/base.rb', line 743 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:, &block) ⇒ Object
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):
type_specifier 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.
449 450 451 452 453 454 455 456 457 458 459 460 461 |
# File 'lib/rigor/plugin/base.rb', line 449 def type_specifier(methods:, &block) raise ArgumentError, "Plugin::Base.type_specifier 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.type_specifier 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 |
.type_specifiers ⇒ Object
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
467 468 469 |
# File 'lib/rigor/plugin/base.rb', line 467 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).
395 396 397 398 399 400 401 402 403 404 405 406 407 408 |
# File 'lib/rigor/plugin/base.rb', line 395 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).
383 384 385 386 387 388 389 390 |
# File 'lib/rigor/plugin/base.rb', line 383 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
421 422 423 424 425 426 427 428 429 430 431 432 |
# File 'lib/rigor/plugin/base.rb', line 421 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
410 411 412 413 414 415 416 417 418 419 |
# File 'lib/rigor/plugin/base.rb', line 410 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.
121 122 123 124 125 126 127 |
# File 'lib/rigor/plugin/base.rb', line 121 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.
836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 |
# File 'lib/rigor/plugin/base.rb', line 836 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
Builds a ‘Rigor::Analysis::Diagnostic` positioned at a Prism `node` for return from `#diagnostics_for_file`. Internalises the 1-based `line` / `start_column + 1` convention every plugin otherwise re-derives by hand, so authors pass the node and the message/severity/rule rather than unpacking `node.location`.
‘source_family` is intentionally NOT accepted — the runner stamps `plugin.<manifest.id>` on every returned diagnostic (ADR-7 § “Slice 5-B”), so any value set here would be overwritten. Pass `location:` (a Prism location) to point the diagnostic at a sub-location of `node` rather than `node.location` — typically `node.message_loc` so a matcher / method-name diagnostic points at the name, not the receiver-spanning whole call. A `nil` `location:` falls back to `node.location`, so `location: node.message_loc` reproduces the common `message_loc || location` idiom.
664 665 666 667 668 669 |
# File 'lib/rigor/plugin/base.rb', line 664 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) ⇒ Object
ADR-60 WD4 — maps a plugin’s own violation objects to ‘Rigor::Analysis::Diagnostic`s 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.
681 682 683 684 685 686 687 688 689 690 691 692 693 |
# File 'lib/rigor/plugin/base.rb', line 681 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:) ⇒ Object
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 traverse `root` themselves if they need node-scoped rules — the `Rule<TNode>` API ADR-2 § “Custom rules” mentions stays deferred to v0.1.x.
Default returns ‘[]` so plugins that contribute through other channels (e.g. slice-4 narrowing contributions, slice-6 cache producers) do not have to override.
554 555 556 |
# File 'lib/rigor/plugin/base.rb', line 554 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.
603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 |
# File 'lib/rigor/plugin/base.rb', line 603 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) ⇒ Object
Override in subclasses to wire any state the plugin needs from the injected service container. Default is a no-op so plugins that only contribute through later-slice protocol hooks do not have to define an explicit body.
497 498 499 |
# File 'lib/rigor/plugin/base.rb', line 497 def init(services) # rubocop:disable Lint/UnusedMethodArgument nil end |
#io_boundary ⇒ Object
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`.
800 801 802 |
# File 'lib/rigor/plugin/base.rb', line 800 def io_boundary @io_boundary ||= services.io_boundary_for(manifest.id) end |
#manifest ⇒ Object
Convenience accessor — ‘manifest` on the instance returns the class-level manifest declaration.
752 753 754 |
# File 'lib/rigor/plugin/base.rb', line 752 def manifest self.class.manifest end |
#node_rule_diagnostics(path:, scope:, root:) ⇒ Object
ADR-37 slice 1 — runs the plugin’s declared node_rules over one file and returns their diagnostics. The engine owns the single AST walk here so plugin authors never hand-roll a traversal: every node reachable from ‘root` is offered to each rule whose `node_type` it satisfies (`node.is_a?`), the rule’s block is ‘instance_exec`’d on this plugin instance with ‘(node, scope, path)`, and the returned diagnostics are concatenated in (node, declaration) order.
Returns ‘[]` immediately when the plugin declares no node rules, so it is a zero-cost no-op for every plugin that does not use the DSL. The runner calls it alongside `#diagnostics_for_file` and stamps `plugin.<id>` provenance on the result; a raise propagates to the runner’s per-plugin isolation boundary.
572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 |
# File 'lib/rigor/plugin/base.rb', line 572 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.
1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 |
# File 'lib/rigor/plugin/base.rb', line 1033 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) ⇒ Object
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.
531 532 533 |
# File 'lib/rigor/plugin/base.rb', line 531 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`.
730 731 732 |
# File 'lib/rigor/plugin/base.rb', line 730 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.
716 717 718 719 720 721 722 723 724 |
# File 'lib/rigor/plugin/base.rb', line 716 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.
791 792 793 |
# File 'lib/rigor/plugin/base.rb', line 791 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.
702 703 704 705 706 707 |
# File 'lib/rigor/plugin/base.rb', line 702 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 ⇒ Object
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`.
767 768 769 770 771 772 773 774 775 776 777 778 779 780 |
# File 'lib/rigor/plugin/base.rb', line 767 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:) ⇒ Object
ADR-37 slice 2 — the post-return narrowing facts contributed by this plugin’s type_specifier 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 [].
630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 |
# File 'lib/rigor/plugin/base.rb', line 630 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 |