Class: Rigor::Plugin::Base

Inherits:
Object
  • Object
show all
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

Class Method Summary collapse

Instance Method Summary collapse

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

#configObject (readonly)

Returns the value of attribute config.



473
474
475
# File 'lib/rigor/plugin/base.rb', line 473

def config
  @config
end

#servicesObject (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: -> { 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:`.

Raises:

  • (ArgumentError)


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_returnsObject

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

Declares the plugin’s manifest. Called once at class definition time — the resulting Manifest is cached on the class so Loader reads it without constructing the plugin.



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.

Raises:

  • (ArgumentError)


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_blockObject

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.

Raises:

  • (ArgumentError)


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_rulesObject

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.

Raises:

  • (ArgumentError)


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

.producersObject

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.

Raises:

  • (ArgumentError)


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_specifiersObject

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).

Raises:

  • (ArgumentError)


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).

Raises:

  • (ArgumentError)


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

Raises:

  • (ArgumentError)


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

Raises:

  • (ArgumentError)


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.

Raises:

  • (ArgumentError)


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: 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.message,
      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_boundaryObject

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

#manifestObject

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_entryObject

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_contractsObject

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_pathsObject

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.expand_path(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