rigortype 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +4 -2
  3. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +18 -1
  4. data/lib/rigor/analysis/check_rules/rule_walk.rb +67 -0
  5. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +18 -1
  6. data/lib/rigor/analysis/check_rules.rb +34 -6
  7. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  8. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  9. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  10. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  11. data/lib/rigor/analysis/runner.rb +160 -1190
  12. data/lib/rigor/analysis/worker_session.rb +47 -8
  13. data/lib/rigor/cache/incremental_snapshot.rb +10 -4
  14. data/lib/rigor/cache/rbs_cache_producer.rb +5 -1
  15. data/lib/rigor/cache/store.rb +46 -13
  16. data/lib/rigor/cli/check_command.rb +705 -0
  17. data/lib/rigor/cli/ci_detector.rb +94 -0
  18. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  19. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  20. data/lib/rigor/cli/trace_command.rb +143 -0
  21. data/lib/rigor/cli/trace_renderer.rb +310 -0
  22. data/lib/rigor/cli.rb +15 -614
  23. data/lib/rigor/configuration.rb +9 -6
  24. data/lib/rigor/environment/rbs_loader.rb +53 -68
  25. data/lib/rigor/environment.rb +1 -1
  26. data/lib/rigor/inference/acceptance.rb +10 -0
  27. data/lib/rigor/inference/expression_typer.rb +28 -62
  28. data/lib/rigor/inference/flow_tracer.rb +180 -0
  29. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  30. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  31. data/lib/rigor/inference/method_dispatcher.rb +115 -54
  32. data/lib/rigor/inference/narrowing.rb +60 -0
  33. data/lib/rigor/inference/scope_indexer.rb +75 -15
  34. data/lib/rigor/inference/statement_evaluator.rb +35 -52
  35. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  36. data/lib/rigor/plugin/base.rb +282 -41
  37. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  38. data/lib/rigor/plugin/registry.rb +263 -35
  39. data/lib/rigor/plugin.rb +1 -0
  40. data/lib/rigor/rbs_extended/conformance_checker.rb +86 -1
  41. data/lib/rigor/scope/discovery_index.rb +58 -0
  42. data/lib/rigor/scope.rb +67 -198
  43. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  44. data/lib/rigor/source/literals.rb +14 -0
  45. data/lib/rigor/type/combinator.rb +5 -0
  46. data/lib/rigor/version.rb +1 -1
  47. data/lib/rigor.rb +0 -1
  48. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  49. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  50. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  51. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  52. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  53. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  54. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  55. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  56. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  57. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  58. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  59. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  60. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  61. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  62. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  63. data/sig/rigor/environment.rbs +0 -2
  64. data/sig/rigor/inference.rbs +5 -0
  65. data/sig/rigor/plugin/base.rbs +1 -2
  66. data/sig/rigor/scope.rbs +41 -29
  67. data/sig/rigor/source.rbs +1 -0
  68. data/skills/rigor-ci-setup/SKILL.md +319 -0
  69. metadata +15 -2
  70. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -66
@@ -3,32 +3,40 @@
3
3
  module Rigor
4
4
  module Plugin
5
5
  # ADR-38 declaration: "on `receiver_constraint` (and its
6
- # subclasses), every method named in `methods` also establishes
7
- # instance-variable state treat it like `initialize` for the
8
- # read-before-write nil soundness gate."
6
+ # subclasses), every method named in `methods` (def-form) or
7
+ # `block_methods` (block-form) also establishes instance-variable
8
+ # state — treat it like `initialize` for the read-before-write nil
9
+ # soundness gate."
10
+ #
11
+ # **Def-form** (`methods:`) — applies when the ivar write lives in a
12
+ # named `def` body. Example: Minitest `def setup; @conn = …; end`.
13
+ #
14
+ # **Block-form** (`block_methods:`) — applies when the ivar write
15
+ # lives in a block passed to a method call. Example: RSpec
16
+ # `before { @user = create(:user) }` / `let(:x) { @y = … }`.
17
+ # `ScopeIndexer` descends the block body of any `CallNode` whose
18
+ # method name is in `block_methods`, collecting ivar writes exactly
19
+ # as it would for a def-form initializer.
20
+ #
21
+ # At least one of `methods:` or `block_methods:` must be non-empty.
9
22
  #
10
23
  # Authored on a plugin manifest:
11
24
  #
12
- # manifest(
13
- # id: "minitest",
14
- # version: "0.1.0",
15
- # additional_initializers: [
16
- # Rigor::Plugin::AdditionalInitializer.new(
17
- # receiver_constraint: "Minitest::Test",
18
- # methods: [:setup]
19
- # )
20
- # ]
25
+ # # def-form (Minitest):
26
+ # AdditionalInitializer.new(
27
+ # receiver_constraint: "Minitest::Test",
28
+ # methods: [:setup]
29
+ # )
30
+ #
31
+ # # block-form (RSpec):
32
+ # AdditionalInitializer.new(
33
+ # receiver_constraint: "RSpec::ExampleGroup",
34
+ # block_methods: [:before, :let, :subject]
21
35
  # )
22
36
  #
23
37
  # The Ruby analogue of PHPStan's `AdditionalConstructorsExtension`.
24
38
  # `Rigor::Inference::ScopeIndexer` consults the aggregated set at
25
- # its single read-before-write gate: for a `def` whose name is in
26
- # `methods` on a class that equals or inherits from
27
- # `receiver_constraint` (matched via `Environment#class_ordering`,
28
- # the same mechanism ADR-16 Tier A uses), the method's ivar writes
29
- # are folded into the class's `init_writes` set, so a sibling
30
- # method reading those ivars no longer gets a `Constant[nil]`
31
- # widening.
39
+ # its read-before-write gate.
32
40
  #
33
41
  # The contribution can only ever *suppress* a nil widening — it
34
42
  # never makes the analyzer stricter — so a missed or over-broad
@@ -39,38 +47,47 @@ module Rigor
39
47
  #
40
48
  # - `receiver_constraint` — fully-qualified class name (String).
41
49
  # The entry applies to that class and its subclasses.
42
- # - `methods` — Array of Symbol method names treated as
43
- # initializers on a matching class.
50
+ # - `methods` — Array of Symbol `def`-form method names (may be
51
+ # empty when only block_methods is used).
52
+ # - `block_methods` — Array of Symbol call-with-block method names
53
+ # (may be empty when only methods is used).
44
54
  #
45
55
  # ## Ractor-shareability
46
56
  #
47
- # Both fields are frozen at construction (ADR-15 Phase 1);
48
- # `Ractor.shareable?` returns true after `#initialize`, so the
49
- # value object survives `Plugin::Registry.materialize` into a
50
- # worker Ractor.
57
+ # All fields are frozen at construction (ADR-15 Phase 1);
58
+ # `Ractor.shareable?` returns true after `#initialize`.
51
59
  class AdditionalInitializer
52
- attr_reader :receiver_constraint, :methods
60
+ attr_reader :receiver_constraint, :methods, :block_methods
53
61
 
54
- def initialize(receiver_constraint:, methods:)
62
+ def initialize(receiver_constraint:, methods: [], block_methods: [])
55
63
  validate_receiver_constraint!(receiver_constraint)
56
- validate_methods!(methods)
64
+ validate_method_list!(methods, :methods)
65
+ validate_method_list!(block_methods, :block_methods)
66
+ validate_at_least_one!(methods, block_methods)
57
67
 
58
68
  @receiver_constraint = receiver_constraint.dup.freeze
59
69
  @methods = methods.map(&:to_sym).freeze
70
+ @block_methods = block_methods.map(&:to_sym).freeze
60
71
  freeze
61
72
  end
62
73
 
63
- # True when `method_name` (a Symbol) is declared an initializer
64
- # by this entry. The class-constraint match is the caller's
65
- # responsibility (it needs the environment's class graph).
74
+ # True when `method_name` (a Symbol) is declared a def-form
75
+ # initializer by this entry.
66
76
  def covers_method?(method_name)
67
77
  methods.include?(method_name)
68
78
  end
69
79
 
80
+ # True when `method_name` (a Symbol) is declared a block-form
81
+ # initializer by this entry.
82
+ def covers_block_method?(method_name)
83
+ block_methods.include?(method_name)
84
+ end
85
+
70
86
  def to_h
71
87
  {
72
88
  "receiver_constraint" => receiver_constraint,
73
- "methods" => methods.map(&:to_s)
89
+ "methods" => methods.map(&:to_s),
90
+ "block_methods" => block_methods.map(&:to_s)
74
91
  }
75
92
  end
76
93
 
@@ -93,16 +110,22 @@ module Rigor
93
110
  "got #{value.inspect}"
94
111
  end
95
112
 
96
- def validate_methods!(value)
97
- if value.is_a?(Array) && !value.empty? &&
98
- value.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
99
- return
100
- end
113
+ def validate_method_list!(value, field)
114
+ return if value.is_a?(Array) &&
115
+ value.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
101
116
 
102
117
  raise ArgumentError,
103
- "Plugin::AdditionalInitializer#methods must be a non-empty Array of " \
118
+ "Plugin::AdditionalInitializer##{field} must be an Array of " \
104
119
  "Symbol/non-empty String, got #{value.inspect}"
105
120
  end
121
+
122
+ def validate_at_least_one!(methods, block_methods)
123
+ return unless methods.empty? && block_methods.empty?
124
+
125
+ raise ArgumentError,
126
+ "Plugin::AdditionalInitializer requires at least one of methods: or block_methods: " \
127
+ "to be non-empty"
128
+ end
106
129
  end
107
130
  end
108
131
  end
@@ -190,36 +190,140 @@ module Rigor
190
190
  defined?(@node_file_context_block) ? @node_file_context_block : nil
191
191
  end
192
192
 
193
- # ADR-37 slice 2 — declares a per-call-site return-type
194
- # contribution, receiver-gated. The narrow successor to the
195
- # `return_type` slot of `flow_contribution_for`:
193
+ # ADR-37 slice 2 / ADR-52 WD2 — declares a per-call-site
194
+ # return-type contribution, gated by receiver class, method name,
195
+ # or both. The narrow successor to the `return_type` slot of the
196
+ # deleted `flow_contribution_for` hook (ADR-52 WD3):
196
197
  #
198
+ # # receiver-gated only:
197
199
  # dynamic_return receivers: ["ActiveRecord::Base"] do |call_node, scope|
198
200
  # # self = plugin instance; return a Rigor::Type or nil
199
201
  # end
200
202
  #
203
+ # # receiver + method gated (preferred for focused rules):
204
+ # dynamic_return receivers: ["Result"], methods: [:unwrap, :unwrap!] do |call_node, scope|
205
+ # # fires only for Result#unwrap / Result#unwrap!
206
+ # end
207
+ #
208
+ # # method-gated only (ADR-52 WD2 — receiver-independent rules,
209
+ # # e.g. a unit-dimension DSL whose receiver carrier is a
210
+ # # refinement, not a nominal class):
211
+ # dynamic_return methods: [:kilometers, :per_hour, :in_meters] do |call_node, scope|
212
+ # # fires for any receiver when the method name matches;
213
+ # # the block reads the receiver's shape itself
214
+ # end
215
+ #
201
216
  # `receivers:` is a non-empty Array of class names; the engine
202
217
  # calls the block only when the call's receiver type's class
203
218
  # equals or inherits from one of them (via
204
- # `Environment#class_ordering`). Method-name and type-shape
205
- # refinement stays in the block, which returns a `Rigor::Type`
206
- # (or `nil` to decline). The block runs through `instance_exec`,
207
- # so `config` / `services` are in scope. This is the
208
- # {.producer}-style class DSL (it carries logic needing the
209
- # instance, not pure data).
210
- def dynamic_return(receivers:, &block)
219
+ # `Environment#class_ordering`). It MAY be omitted — then the rule
220
+ # is receiver-independent and fires on `methods:` alone.
221
+ #
222
+ # `methods:` is an Array of Symbol method names. When provided, the
223
+ # block is skipped unless `call_node.name` is in the list —
224
+ # declarative and cheaper than an in-block guard (the engine
225
+ # compiles it into the registry's contribution table, ADR-52 WD1).
226
+ # It is REQUIRED when `receivers:` is omitted: a rule gated on
227
+ # neither would fire on every dispatch, which is exactly the
228
+ # ungated cost the `flow_contribution_for` escape valve carries —
229
+ # `dynamic_return` declines to reintroduce it.
230
+ #
231
+ # Method-name and type-shape refinement can still be done inside
232
+ # the block. The block runs through `instance_exec`, so `config`
233
+ # / `services` are in scope.
234
+ # ADR-52 slice 3 — `receivers:` may also be a **callable**
235
+ # (a `-> { ... }` resolved once per run, lazily, the first time
236
+ # the rule is consulted — always after `#prepare`) for a receiver
237
+ # set the plugin only knows at run time:
238
+ #
239
+ # dynamic_return receivers: -> { attachment_index.model_names } do |call_node, scope|
240
+ # # fires when the receiver class is one a `prepare`-time scan
241
+ # # found; the block does the precise per-call lookup
242
+ # end
243
+ #
244
+ # The callable runs through `instance_exec`, so it reads the
245
+ # plugin's own `#prepare`-built indexes. It MUST be idempotent and
246
+ # post-`#prepare`-safe — reference a lazily-built / memoised index
247
+ # (as activestorage's `attachment_index` and activerecord's
248
+ # `model_index` are), never a value captured at class-definition
249
+ # time. The resolved set is a safe over-approximation of the
250
+ # block's own filter (it admits subclasses too), so the block
251
+ # stays the precise gate and diagnostics are unchanged.
252
+ #
253
+ # ADR-52 slice 4 — `methods:` may ALSO be a callable, for a
254
+ # method-name set the plugin only knows at run time (a Sorbet
255
+ # catalog's keys, a config-derived DSL method name):
256
+ #
257
+ # dynamic_return methods: -> { catalog.method_names } do |call_node, scope|
258
+ # ...
259
+ # end
260
+ #
261
+ # Same contract as a callable `receivers:` — `instance_exec`'d,
262
+ # resolved lazily after `#prepare`, memoised, idempotent. A
263
+ # callable method set cannot be compiled into the registry's
264
+ # name gate (it is unknown at registry-build time), so the
265
+ # plugin is consulted on every dispatch and the name filter runs
266
+ # in this instance path instead — the block still only fires for
267
+ # a listed name, so diagnostics are unchanged.
268
+ # ADR-52 slice 5a — `file_methods:` is the per-file
269
+ # specialisation of the run-time `methods:` callable, for a name
270
+ # set that varies per analysed file (rigor-rspec's `let` names —
271
+ # the names depend on each file's `describe`/`let` structure, so
272
+ # one run-wide set cannot exist). The callable receives the file
273
+ # path, runs through `instance_exec`, and is memoised per
274
+ # `(rule, path)`:
275
+ #
276
+ # dynamic_return file_methods: ->(path) { let_names_for(path) } do |call_node, scope|
277
+ # ...
278
+ # end
279
+ #
280
+ # Same idempotence contract as the other callables, plus: it MUST
281
+ # tolerate any path the engine analyses (return `[]` / nil for a
282
+ # file it has no names for — never raise). Like a callable
283
+ # `methods:`, it cannot compile into the registry name gate, so
284
+ # the plugin is consulted on every dispatch and filtered here.
285
+ # `file_methods:` replaces `methods:` (declaring both is
286
+ # rejected — they are the same gate at two scopes); it MAY
287
+ # combine with `receivers:`.
288
+ def dynamic_return(receivers: nil, methods: nil, file_methods: nil, &block)
211
289
  raise ArgumentError, "Plugin::Base.dynamic_return requires a block body" if block.nil?
212
- unless receivers.is_a?(Array) && !receivers.empty? && receivers.all? { |r| r.is_a?(String) && !r.empty? }
213
- raise ArgumentError,
214
- "Plugin::Base.dynamic_return receivers: must be a non-empty Array of class-name Strings, " \
215
- "got #{receivers.inspect}"
216
- end
290
+
291
+ validate_dynamic_return_gate!(receivers, methods, file_methods)
292
+ validate_dynamic_return_receivers!(receivers) unless receivers.nil?
293
+ validate_dynamic_return_methods!(methods)
294
+ validate_dynamic_return_file_methods!(file_methods, methods)
217
295
 
218
296
  @dynamic_returns ||= []
219
- @dynamic_returns << { receivers: receivers.map { |r| r.dup.freeze }.freeze, block: block }.freeze
297
+ @dynamic_returns << {
298
+ receivers: normalize_dynamic_return_receivers(receivers),
299
+ methods: normalize_dynamic_return_methods(methods),
300
+ file_methods: file_methods,
301
+ block: block
302
+ }.freeze
220
303
  nil
221
304
  end
222
305
 
306
+ # A class-name Array is frozen element-wise; a run-time callable
307
+ # (ADR-52 slice 3) is stored verbatim and resolved per instance.
308
+ def normalize_dynamic_return_receivers(receivers)
309
+ return nil if receivers.nil?
310
+ return receivers if receivers.respond_to?(:call)
311
+
312
+ receivers.map { |r| r.dup.freeze }.freeze
313
+ end
314
+ private :normalize_dynamic_return_receivers
315
+
316
+ # A method-name Array is symbol-normalised + frozen; a run-time
317
+ # callable (ADR-52 slice 4) is stored verbatim and resolved per
318
+ # instance.
319
+ def normalize_dynamic_return_methods(methods)
320
+ return nil if methods.nil?
321
+ return methods if methods.respond_to?(:call)
322
+
323
+ methods.map(&:to_sym).freeze
324
+ end
325
+ private :normalize_dynamic_return_methods
326
+
223
327
  # Frozen snapshot of the declared dynamic-return rules. Memoised:
224
328
  # `@dynamic_returns` is built once at class-definition time (via
225
329
  # `dynamic_return`) and never mutated during analysis, and every
@@ -237,9 +341,67 @@ module Rigor
237
341
  end
238
342
  # rubocop:enable Naming/MemoizedInstanceVariableName
239
343
 
344
+ # ADR-52 WD2 — a rule must gate on something. `receivers:` alone,
345
+ # `methods:` alone, or both are valid; neither is not (it would
346
+ # fire on every dispatch).
347
+ def validate_dynamic_return_gate!(receivers, methods, file_methods)
348
+ return unless receivers.nil? && file_methods.nil?
349
+ return if (methods.is_a?(Array) && !methods.empty?) || methods.respond_to?(:call)
350
+
351
+ raise ArgumentError,
352
+ "Plugin::Base.dynamic_return requires receivers:, methods:, or file_methods: — a rule " \
353
+ "gated on none would fire on every dispatch (that is what flow_contribution_for is for)"
354
+ end
355
+
356
+ # ADR-52 slice 5a — `file_methods:` must be a callable, and is
357
+ # mutually exclusive with `methods:` (one name gate, two scopes —
358
+ # declaring both is a contradiction, not a composition).
359
+ def validate_dynamic_return_file_methods!(file_methods, methods)
360
+ return if file_methods.nil?
361
+
362
+ unless file_methods.respond_to?(:call)
363
+ raise ArgumentError,
364
+ "Plugin::Base.dynamic_return file_methods: must be a callable receiving the file path, " \
365
+ "got #{file_methods.inspect}"
366
+ end
367
+ return if methods.nil?
368
+
369
+ raise ArgumentError,
370
+ "Plugin::Base.dynamic_return file_methods: replaces methods: — declare one name gate, " \
371
+ "not both"
372
+ end
373
+
374
+ def validate_dynamic_return_receivers!(receivers)
375
+ # ADR-52 slice 3 — a run-time callable is resolved per instance
376
+ # after `#prepare`; its shape is checked at resolution time.
377
+ return if receivers.respond_to?(:call)
378
+ return if receivers.is_a?(Array) && !receivers.empty? && receivers.all? { |r| r.is_a?(String) && !r.empty? }
379
+
380
+ raise ArgumentError,
381
+ "Plugin::Base.dynamic_return receivers: must be a non-empty Array of class-name Strings " \
382
+ "or a callable, got #{receivers.inspect}"
383
+ end
384
+
385
+ def validate_dynamic_return_methods!(methods)
386
+ return if methods.nil?
387
+ # ADR-52 slice 4 — a run-time callable resolves to the name set
388
+ # per instance after `#prepare`; its shape is checked then.
389
+ return if methods.respond_to?(:call)
390
+ return if methods.is_a?(Array) && !methods.empty? &&
391
+ methods.all? { |m| m.is_a?(Symbol) || (m.is_a?(String) && !m.empty?) }
392
+
393
+ raise ArgumentError,
394
+ "Plugin::Base.dynamic_return methods: must be a non-empty Array of Symbol/String, a callable, " \
395
+ "or nil, got #{methods.inspect}"
396
+ end
397
+
398
+ private :validate_dynamic_return_gate!, :validate_dynamic_return_receivers!,
399
+ :validate_dynamic_return_methods!, :validate_dynamic_return_file_methods!
400
+
240
401
  # ADR-37 slice 2 — declares a predicate/assertion narrowing
241
402
  # contribution, method-gated. The narrow successor to the
242
- # `post_return_facts` slot of `flow_contribution_for`:
403
+ # `post_return_facts` slot of the deleted `flow_contribution_for`
404
+ # hook (ADR-52 WD3):
243
405
  #
244
406
  # type_specifier methods: [:assert_kind_of] do |call_node, scope|
245
407
  # # return an Array of post-return facts, or nil
@@ -277,6 +439,12 @@ module Rigor
277
439
  def initialize(services:, config: {})
278
440
  @services = services
279
441
  @config = merge_config_defaults(config).freeze
442
+ # ADR-52 slice 3 — per-rule cache of resolved run-time
443
+ # `dynamic_return receivers:` callables. Created here (before any
444
+ # subclass `initialize` freezes the instance) so the lazy
445
+ # memo-on-first-dispatch is a Hash-content mutation, sound even on
446
+ # a self-freezing plugin.
447
+ @dynamic_return_runtime_cache = {}
280
448
  end
281
449
 
282
450
  # Override in subclasses to wire any state the plugin needs
@@ -287,22 +455,13 @@ module Rigor
287
455
  nil
288
456
  end
289
457
 
290
- # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
291
- # slice 7 per-call return-type contribution hook. When
292
- # the inference engine dispatches a `Prism::CallNode` and
293
- # neither the precision tiers nor RBS resolve a result,
294
- # `MethodDispatcher` consults each loaded plugin via this
295
- # hook ahead of `RbsDispatch`. Plugins that override the
296
- # default return a {Rigor::FlowContribution} bundle whose
297
- # `return_type` slot pins the call site's result type.
298
- #
299
- # Default returns nil — plugins that don't refine return
300
- # types skip the override. Failures are isolated: a hook
301
- # that raises gets its contribution dropped silently for
302
- # this call so the rest of the dispatch chain continues.
303
- def flow_contribution_for(call_node:, scope:) # rubocop:disable Lint/UnusedMethodArgument
304
- nil
305
- end
458
+ # NOTE: (ADR-52 WD3): the legacy ungated per-call hook
459
+ # `flow_contribution_for` was DELETED here pre-1.0 after its five
460
+ # production users migrated. Per-call return types are declared via
461
+ # the gated {.dynamic_return} DSL (static / run-time / per-file
462
+ # name sets, static / run-time receiver sets); post-return
463
+ # narrowing facts via {.type_specifier}. See the CHANGELOG
464
+ # migration note for the idiom-by-idiom mapping.
306
465
 
307
466
  # ADR-9 slice 3 — per-run preparation hook. The runner
308
467
  # invokes `#prepare(services)` on every loaded plugin once
@@ -379,10 +538,14 @@ module Rigor
379
538
 
380
539
  diagnostics = []
381
540
  Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
541
+ # One frozen NodeContext per node, shared across the rules
542
+ # that match it (ADR-52 WD1) — built lazily so non-matching
543
+ # nodes (the vast majority) allocate nothing.
544
+ context = nil
382
545
  rules.each do |rule|
383
546
  next unless node.is_a?(rule[:node_type])
384
547
 
385
- context = NodeContext.new(ancestors)
548
+ context ||= NodeContext.new(ancestors)
386
549
  diagnostics.concat(Array(instance_exec(node, scope, path, file_context, context, &rule[:block])))
387
550
  end
388
551
  end
@@ -391,20 +554,22 @@ module Rigor
391
554
 
392
555
  # ADR-37 slice 2 — the return type contributed by this plugin's
393
556
  # {.dynamic_return} rules for a call, or nil. The engine calls this
394
- # from `MethodDispatcher` alongside (and ahead of) the legacy
395
- # `flow_contribution_for`; a rule fires only when `receiver_type`'s
557
+ # from `MethodDispatcher`; a rule fires only when `receiver_type`'s
396
558
  # class equals or inherits from one of its declared `receivers:`.
397
559
  # First non-nil wins (declaration order). Failures isolate to nil.
398
560
  def dynamic_return_type(call_node:, scope:, receiver_type:)
399
561
  rules = self.class.dynamic_returns
400
562
  return nil if rules.empty? || receiver_type.nil?
401
563
 
564
+ # `class_name` is nil for a receiver carrier with no nominal
565
+ # class (a refinement dimension, an inferred shape) — fine for a
566
+ # receiver-less (methods-only) rule (ADR-52 WD2), which gates on
567
+ # the method name alone and reads the receiver shape inside its
568
+ # own block.
402
569
  class_name = dynamic_return_receiver_class_name(receiver_type)
403
- return nil if class_name.nil?
404
-
405
570
  environment = scope&.environment
406
571
  rules.each do |rule|
407
- next unless rule[:receivers].any? { |c| class_matches_receiver?(class_name, c, environment) }
572
+ next unless dynamic_return_rule_applies?(rule, call_node, class_name, environment, scope)
408
573
 
409
574
  result = instance_exec(call_node, scope, &rule[:block])
410
575
  return result if result
@@ -416,8 +581,8 @@ module Rigor
416
581
 
417
582
  # ADR-37 slice 2 — the post-return narrowing facts contributed by
418
583
  # this plugin's {.type_specifier} rules for a call. The engine
419
- # calls this from `StatementEvaluator` alongside the legacy
420
- # `flow_contribution_for`; a rule fires only when `call_node.name`
584
+ # calls this from `StatementEvaluator`; a rule fires only when
585
+ # `call_node.name`
421
586
  # is one of its declared `methods:`. Failures isolate to [].
422
587
  def type_specifier_facts(call_node:, scope:)
423
588
  rules = self.class.type_specifiers
@@ -659,6 +824,82 @@ module Rigor
659
824
  end
660
825
  end
661
826
 
827
+ # The gate for one `dynamic_return` rule. Method-name gate first —
828
+ # a Symbol-array probe vs the receiver ancestry resolution below
829
+ # (ADR-52 WD1); both are pure predicates, so order only affects
830
+ # cost. A receiver-less rule (ADR-52 WD2) skips the ancestry check
831
+ # entirely and fires on the method name alone.
832
+ def dynamic_return_rule_applies?(rule, call_node, class_name, environment, scope)
833
+ return false if rule[:methods] && !resolved_dynamic_return_methods(rule).include?(call_node.name)
834
+
835
+ if rule[:file_methods]
836
+ # The path is read here, not in `dynamic_return_type`, so a
837
+ # spec-double scope without `source_path` only affects
838
+ # `file_methods:` rules (other gate forms never touch it).
839
+ path = scope.respond_to?(:source_path) ? scope.source_path : nil
840
+ return false unless resolved_dynamic_return_file_methods(rule, path).include?(call_node.name)
841
+ end
842
+
843
+ receivers = resolved_dynamic_return_receivers(rule)
844
+ return true if receivers.nil?
845
+ return false if class_name.nil?
846
+
847
+ receivers.any? { |c| class_matches_receiver?(class_name, c, environment) }
848
+ end
849
+
850
+ # ADR-52 slice 4 — the rule's method-name set. A static Array is
851
+ # returned as-is (`#include?` over Symbols); a run-time callable is
852
+ # `instance_exec`'d against this plugin and memoised as a Symbol Set,
853
+ # same lazy/idempotent contract as a callable `receivers:`. The
854
+ # cache key is namespaced so a rule that makes both `methods:` and
855
+ # `receivers:` callable keeps two distinct memo slots.
856
+ def resolved_dynamic_return_methods(rule)
857
+ methods = rule[:methods]
858
+ return methods unless methods.respond_to?(:call)
859
+
860
+ (@dynamic_return_runtime_cache ||= {})[[:methods, rule]] ||=
861
+ Array(instance_exec(&methods)).to_set(&:to_sym).freeze
862
+ end
863
+
864
+ # ADR-52 slice 5a — the rule's per-file method-name set. The
865
+ # `file_methods:` callable is `instance_exec`'d with the file path
866
+ # and memoised per `(rule, path)` — one resolution per analysed
867
+ # file, the per-file analogue of the run-wide `methods:` memo. A
868
+ # nil path (synthetic call sites with no file context) resolves to
869
+ # the empty set: the gate has nothing to key on, so the rule
870
+ # declines — fail-closed, consistent with the gate's purpose. A
871
+ # raising callable degrades to "declines this dispatch" via
872
+ # `dynamic_return_type`'s surrounding rescue.
873
+ EMPTY_NAME_SET = Set.new.freeze
874
+ private_constant :EMPTY_NAME_SET
875
+
876
+ def resolved_dynamic_return_file_methods(rule, path)
877
+ return EMPTY_NAME_SET if path.nil?
878
+
879
+ (@dynamic_return_runtime_cache ||= {})[[:file_methods, rule, path]] ||=
880
+ Array(instance_exec(path, &rule[:file_methods])).to_set(&:to_sym).freeze
881
+ end
882
+
883
+ # ADR-52 slice 3 — the rule's receiver class-name Array. A static
884
+ # Array is returned as-is; a run-time callable is `instance_exec`'d
885
+ # against this plugin (so it reads the `#prepare`-built indexes) and
886
+ # memoised per rule for the run. Resolution is lazy — first reached
887
+ # during file analysis, always after `#prepare` — and the callable
888
+ # is required to be idempotent, so the memoised set is stable. A
889
+ # callable that raises degrades to "no receivers match" (the rule
890
+ # declines), never a crash, consistent with the surrounding rescue.
891
+ def resolved_dynamic_return_receivers(rule)
892
+ receivers = rule[:receivers]
893
+ return receivers unless receivers.respond_to?(:call)
894
+
895
+ # `||= {}` keeps the path correct even when a caller bypassed
896
+ # `initialize` (`allocate` in unit specs that inject a fake
897
+ # index); a self-freezing plugin already has the Hash from
898
+ # `initialize`, so the `||=` is a no-op there (never a FrozenError).
899
+ (@dynamic_return_runtime_cache ||= {})[rule] ||=
900
+ Array(instance_exec(&receivers)).map { |c| c.to_s.dup.freeze }.freeze
901
+ end
902
+
662
903
  # True when `class_name` equals or inherits from `constraint`,
663
904
  # matched through `Environment#class_ordering` (the mechanism
664
905
  # `MacroBlockSelfType` / `additional_initializers` use). Degrades to