rigortype 0.1.16 → 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 (180) 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/self_closedness_scanner.rb +100 -0
  6. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +226 -0
  7. data/lib/rigor/analysis/check_rules.rb +180 -73
  8. data/lib/rigor/analysis/dependency_recorder.rb +122 -0
  9. data/lib/rigor/analysis/diagnostic.rb +18 -0
  10. data/lib/rigor/analysis/incremental.rb +162 -0
  11. data/lib/rigor/analysis/incremental_session.rb +337 -0
  12. data/lib/rigor/analysis/rule_catalog.rb +48 -0
  13. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +580 -0
  14. data/lib/rigor/analysis/runner/pool_coordinator.rb +569 -0
  15. data/lib/rigor/analysis/runner/project_pre_passes.rb +318 -0
  16. data/lib/rigor/analysis/runner/run_snapshots.rb +46 -0
  17. data/lib/rigor/analysis/runner.rb +477 -1110
  18. data/lib/rigor/analysis/self_call_resolution_recorder.rb +121 -0
  19. data/lib/rigor/analysis/worker_session.rb +47 -8
  20. data/lib/rigor/builtins/static_return_refinements.rb +7 -1
  21. data/lib/rigor/cache/descriptor.rb +50 -49
  22. data/lib/rigor/cache/incremental_snapshot.rb +153 -0
  23. data/lib/rigor/cache/rbs_cache_producer.rb +34 -0
  24. data/lib/rigor/cache/rbs_class_ancestor_table.rb +2 -8
  25. data/lib/rigor/cache/rbs_class_type_param_names.rb +2 -8
  26. data/lib/rigor/cache/rbs_constant_table.rb +2 -8
  27. data/lib/rigor/cache/rbs_environment.rb +2 -8
  28. data/lib/rigor/cache/rbs_known_class_names.rb +2 -8
  29. data/lib/rigor/cache/store.rb +145 -14
  30. data/lib/rigor/cli/annotate_command.rb +2 -7
  31. data/lib/rigor/cli/baseline_command.rb +2 -7
  32. data/lib/rigor/cli/check_command.rb +705 -0
  33. data/lib/rigor/cli/ci_detector.rb +94 -0
  34. data/lib/rigor/cli/command.rb +47 -0
  35. data/lib/rigor/cli/coverage_command.rb +3 -23
  36. data/lib/rigor/cli/coverage_renderer.rb +3 -8
  37. data/lib/rigor/cli/diagnostic_formats.rb +345 -0
  38. data/lib/rigor/cli/diff_command.rb +3 -7
  39. data/lib/rigor/cli/explain_command.rb +2 -7
  40. data/lib/rigor/cli/lsp_command.rb +3 -7
  41. data/lib/rigor/cli/mcp_command.rb +3 -7
  42. data/lib/rigor/cli/options.rb +57 -0
  43. data/lib/rigor/cli/plugin_command.rb +3 -7
  44. data/lib/rigor/cli/plugins_command.rb +2 -7
  45. data/lib/rigor/cli/prism_colorizer.rb +10 -3
  46. data/lib/rigor/cli/renderable.rb +26 -0
  47. data/lib/rigor/cli/sig_gen_command.rb +2 -7
  48. data/lib/rigor/cli/skill_command.rb +3 -7
  49. data/lib/rigor/cli/trace_command.rb +143 -0
  50. data/lib/rigor/cli/trace_renderer.rb +310 -0
  51. data/lib/rigor/cli/triage_command.rb +2 -7
  52. data/lib/rigor/cli/type_of_command.rb +5 -38
  53. data/lib/rigor/cli/type_of_renderer.rb +4 -9
  54. data/lib/rigor/cli/type_scan_command.rb +3 -23
  55. data/lib/rigor/cli/type_scan_renderer.rb +4 -9
  56. data/lib/rigor/cli.rb +15 -532
  57. data/lib/rigor/configuration/dependencies.rb +18 -1
  58. data/lib/rigor/configuration/severity_profile.rb +22 -3
  59. data/lib/rigor/configuration.rb +16 -3
  60. data/lib/rigor/environment/rbs_loader.rb +129 -71
  61. data/lib/rigor/environment.rb +1 -1
  62. data/lib/rigor/inference/acceptance.rb +10 -0
  63. data/lib/rigor/inference/block_parameter_binder.rb +1 -2
  64. data/lib/rigor/inference/builtins/array_catalog.rb +2 -5
  65. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -5
  66. data/lib/rigor/inference/builtins/complex_catalog.rb +2 -5
  67. data/lib/rigor/inference/builtins/date_catalog.rb +2 -5
  68. data/lib/rigor/inference/builtins/encoding_catalog.rb +2 -5
  69. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -5
  70. data/lib/rigor/inference/builtins/exception_catalog.rb +2 -5
  71. data/lib/rigor/inference/builtins/hash_catalog.rb +2 -5
  72. data/lib/rigor/inference/builtins/method_catalog.rb +15 -0
  73. data/lib/rigor/inference/builtins/numeric_catalog.rb +21 -93
  74. data/lib/rigor/inference/builtins/pathname_catalog.rb +2 -5
  75. data/lib/rigor/inference/builtins/proc_catalog.rb +2 -5
  76. data/lib/rigor/inference/builtins/random_catalog.rb +2 -5
  77. data/lib/rigor/inference/builtins/range_catalog.rb +2 -5
  78. data/lib/rigor/inference/builtins/rational_catalog.rb +2 -5
  79. data/lib/rigor/inference/builtins/re_catalog.rb +2 -5
  80. data/lib/rigor/inference/builtins/set_catalog.rb +2 -5
  81. data/lib/rigor/inference/builtins/string_catalog.rb +2 -5
  82. data/lib/rigor/inference/builtins/struct_catalog.rb +2 -5
  83. data/lib/rigor/inference/builtins/time_catalog.rb +2 -5
  84. data/lib/rigor/inference/expression_typer.rb +149 -63
  85. data/lib/rigor/inference/flow_tracer.rb +180 -0
  86. data/lib/rigor/inference/macro_block_self_type.rb +10 -11
  87. data/lib/rigor/inference/method_dispatcher/block_folding.rb +5 -1
  88. data/lib/rigor/inference/method_dispatcher/call_context.rb +65 -0
  89. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +11 -10
  90. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +12 -6
  91. data/lib/rigor/inference/method_dispatcher/data_folding.rb +246 -0
  92. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -2
  93. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +6 -2
  94. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +4 -1
  95. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +4 -1
  96. data/lib/rigor/inference/method_dispatcher/math_folding.rb +6 -6
  97. data/lib/rigor/inference/method_dispatcher/method_folding.rb +12 -7
  98. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +33 -1
  99. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +23 -13
  100. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +9 -9
  101. data/lib/rigor/inference/method_dispatcher/set_folding.rb +6 -6
  102. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +120 -9
  103. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +12 -12
  104. data/lib/rigor/inference/method_dispatcher/singleton_folding.rb +49 -0
  105. data/lib/rigor/inference/method_dispatcher/time_folding.rb +6 -6
  106. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +9 -9
  107. data/lib/rigor/inference/method_dispatcher.rb +185 -84
  108. data/lib/rigor/inference/narrowing.rb +262 -5
  109. data/lib/rigor/inference/scope_indexer.rb +208 -21
  110. data/lib/rigor/inference/statement_evaluator.rb +110 -48
  111. data/lib/rigor/language_server/buffer_resolution.rb +33 -0
  112. data/lib/rigor/language_server/completion_provider.rb +4 -4
  113. data/lib/rigor/language_server/document_symbol_provider.rb +4 -4
  114. data/lib/rigor/language_server/folding_range_provider.rb +4 -4
  115. data/lib/rigor/language_server/hover_provider.rb +4 -4
  116. data/lib/rigor/language_server/selection_range_provider.rb +4 -4
  117. data/lib/rigor/language_server/signature_help_provider.rb +4 -4
  118. data/lib/rigor/plugin/additional_initializer.rb +61 -38
  119. data/lib/rigor/plugin/base.rb +302 -45
  120. data/lib/rigor/plugin/node_rule_walk.rb +147 -0
  121. data/lib/rigor/plugin/registry.rb +281 -15
  122. data/lib/rigor/plugin.rb +1 -0
  123. data/lib/rigor/rbs_extended/conformance_checker.rb +293 -0
  124. data/lib/rigor/rbs_extended.rb +39 -0
  125. data/lib/rigor/scope/discovery_index.rb +58 -0
  126. data/lib/rigor/scope.rb +150 -167
  127. data/lib/rigor/sig_gen/observation_collector.rb +6 -6
  128. data/lib/rigor/source/literals.rb +14 -0
  129. data/lib/rigor/type/acceptance_router.rb +19 -0
  130. data/lib/rigor/type/accepts_result.rb +3 -10
  131. data/lib/rigor/type/app.rb +3 -7
  132. data/lib/rigor/type/bot.rb +2 -3
  133. data/lib/rigor/type/bound_method.rb +5 -12
  134. data/lib/rigor/type/combinator.rb +22 -0
  135. data/lib/rigor/type/constant.rb +2 -3
  136. data/lib/rigor/type/data_class.rb +80 -0
  137. data/lib/rigor/type/data_instance.rb +100 -0
  138. data/lib/rigor/type/difference.rb +5 -10
  139. data/lib/rigor/type/dynamic.rb +5 -10
  140. data/lib/rigor/type/hash_shape.rb +5 -15
  141. data/lib/rigor/type/integer_range.rb +5 -10
  142. data/lib/rigor/type/intersection.rb +5 -10
  143. data/lib/rigor/type/nominal.rb +5 -10
  144. data/lib/rigor/type/refined.rb +5 -10
  145. data/lib/rigor/type/singleton.rb +5 -10
  146. data/lib/rigor/type/top.rb +2 -3
  147. data/lib/rigor/type/tuple.rb +5 -10
  148. data/lib/rigor/type/union.rb +5 -10
  149. data/lib/rigor/type.rb +2 -0
  150. data/lib/rigor/value_semantics.rb +77 -0
  151. data/lib/rigor/version.rb +1 -1
  152. data/lib/rigor.rb +1 -1
  153. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +1 -2
  154. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +2 -4
  155. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +70 -32
  156. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +3 -3
  157. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +15 -21
  158. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +1 -1
  159. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -2
  160. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -2
  161. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +12 -2
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +12 -2
  163. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  164. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +35 -18
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/absurd_recognizer.rb +8 -29
  166. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +17 -1
  167. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +2 -2
  168. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +83 -36
  169. data/sig/rigor/cache.rbs +19 -0
  170. data/sig/rigor/environment.rbs +0 -2
  171. data/sig/rigor/inference.rbs +27 -0
  172. data/sig/rigor/plugin/base.rbs +1 -2
  173. data/sig/rigor/rbs_extended.rbs +2 -0
  174. data/sig/rigor/scope.rbs +42 -25
  175. data/sig/rigor/source.rbs +1 -0
  176. data/sig/rigor/type.rbs +58 -1
  177. data/sig/rigor.rbs +6 -1
  178. data/skills/rigor-ci-setup/SKILL.md +319 -0
  179. metadata +36 -2
  180. data/lib/rigor/cache/rbs_instance_definitions.rb +0 -79
@@ -190,44 +190,218 @@ 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
 
223
- # Frozen snapshot of the declared dynamic-return rules.
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
+
327
+ # Frozen snapshot of the declared dynamic-return rules. Memoised:
328
+ # `@dynamic_returns` is built once at class-definition time (via
329
+ # `dynamic_return`) and never mutated during analysis, and every
330
+ # element is already frozen, so a fresh `dup.freeze` per call was
331
+ # pure waste — the engine calls this for every plugin on every
332
+ # dispatch (`collect_plugin_contributions`), making it a top
333
+ # allocation site on plugin-heavy projects. The cached frozen
334
+ # array is immutable, so sharing one instance across callers is
335
+ # safe.
336
+ # rubocop:disable Naming/MemoizedInstanceVariableName -- the
337
+ # natural name `@dynamic_returns` is the canonical (mutable-at-
338
+ # definition) store this snapshots; the memo must be distinct.
224
339
  def dynamic_returns
225
- (@dynamic_returns || []).dup.freeze
340
+ @dynamic_returns_snapshot ||= (@dynamic_returns || []).dup.freeze
226
341
  end
342
+ # rubocop:enable Naming/MemoizedInstanceVariableName
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!
227
400
 
228
401
  # ADR-37 slice 2 — declares a predicate/assertion narrowing
229
402
  # contribution, method-gated. The narrow successor to the
230
- # `post_return_facts` slot of `flow_contribution_for`:
403
+ # `post_return_facts` slot of the deleted `flow_contribution_for`
404
+ # hook (ADR-52 WD3):
231
405
  #
232
406
  # type_specifier methods: [:assert_kind_of] do |call_node, scope|
233
407
  # # return an Array of post-return facts, or nil
@@ -250,10 +424,14 @@ module Rigor
250
424
  nil
251
425
  end
252
426
 
253
- # Frozen snapshot of the declared type-specifier rules.
427
+ # Frozen snapshot of the declared type-specifier rules. Memoised
428
+ # for the same reason as {dynamic_returns} — consulted per plugin
429
+ # per dispatch, over an array fixed at class-definition time.
430
+ # rubocop:disable Naming/MemoizedInstanceVariableName -- see dynamic_returns
254
431
  def type_specifiers
255
- (@type_specifiers || []).dup.freeze
432
+ @type_specifiers_snapshot ||= (@type_specifiers || []).dup.freeze
256
433
  end
434
+ # rubocop:enable Naming/MemoizedInstanceVariableName
257
435
  end
258
436
 
259
437
  attr_reader :services, :config
@@ -261,6 +439,12 @@ module Rigor
261
439
  def initialize(services:, config: {})
262
440
  @services = services
263
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 = {}
264
448
  end
265
449
 
266
450
  # Override in subclasses to wire any state the plugin needs
@@ -271,22 +455,13 @@ module Rigor
271
455
  nil
272
456
  end
273
457
 
274
- # ADR-2 § "Flow Contribution Bundle" / v0.1.1 Track 2
275
- # slice 7 per-call return-type contribution hook. When
276
- # the inference engine dispatches a `Prism::CallNode` and
277
- # neither the precision tiers nor RBS resolve a result,
278
- # `MethodDispatcher` consults each loaded plugin via this
279
- # hook ahead of `RbsDispatch`. Plugins that override the
280
- # default return a {Rigor::FlowContribution} bundle whose
281
- # `return_type` slot pins the call site's result type.
282
- #
283
- # Default returns nil — plugins that don't refine return
284
- # types skip the override. Failures are isolated: a hook
285
- # that raises gets its contribution dropped silently for
286
- # this call so the rest of the dispatch chain continues.
287
- def flow_contribution_for(call_node:, scope:) # rubocop:disable Lint/UnusedMethodArgument
288
- nil
289
- 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.
290
465
 
291
466
  # ADR-9 slice 3 — per-run preparation hook. The runner
292
467
  # invokes `#prepare(services)` on every loaded plugin once
@@ -363,10 +538,14 @@ module Rigor
363
538
 
364
539
  diagnostics = []
365
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
366
545
  rules.each do |rule|
367
546
  next unless node.is_a?(rule[:node_type])
368
547
 
369
- context = NodeContext.new(ancestors)
548
+ context ||= NodeContext.new(ancestors)
370
549
  diagnostics.concat(Array(instance_exec(node, scope, path, file_context, context, &rule[:block])))
371
550
  end
372
551
  end
@@ -375,20 +554,22 @@ module Rigor
375
554
 
376
555
  # ADR-37 slice 2 — the return type contributed by this plugin's
377
556
  # {.dynamic_return} rules for a call, or nil. The engine calls this
378
- # from `MethodDispatcher` alongside (and ahead of) the legacy
379
- # `flow_contribution_for`; a rule fires only when `receiver_type`'s
557
+ # from `MethodDispatcher`; a rule fires only when `receiver_type`'s
380
558
  # class equals or inherits from one of its declared `receivers:`.
381
559
  # First non-nil wins (declaration order). Failures isolate to nil.
382
560
  def dynamic_return_type(call_node:, scope:, receiver_type:)
383
561
  rules = self.class.dynamic_returns
384
562
  return nil if rules.empty? || receiver_type.nil?
385
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.
386
569
  class_name = dynamic_return_receiver_class_name(receiver_type)
387
- return nil if class_name.nil?
388
-
389
570
  environment = scope&.environment
390
571
  rules.each do |rule|
391
- 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)
392
573
 
393
574
  result = instance_exec(call_node, scope, &rule[:block])
394
575
  return result if result
@@ -400,8 +581,8 @@ module Rigor
400
581
 
401
582
  # ADR-37 slice 2 — the post-return narrowing facts contributed by
402
583
  # this plugin's {.type_specifier} rules for a call. The engine
403
- # calls this from `StatementEvaluator` alongside the legacy
404
- # `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`
405
586
  # is one of its declared `methods:`. Failures isolate to [].
406
587
  def type_specifier_facts(call_node:, scope:)
407
588
  rules = self.class.type_specifiers
@@ -643,6 +824,82 @@ module Rigor
643
824
  end
644
825
  end
645
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
+
646
903
  # True when `class_name` equals or inherits from `constraint`,
647
904
  # matched through `Environment#class_ordering` (the mechanism
648
905
  # `MacroBlockSelfType` / `additional_initializers` use). Degrades to
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "node_context"
4
+ require_relative "../source/node_walker"
5
+
6
+ module Rigor
7
+ module Plugin
8
+ # ADR-52 WD4 — one engine-owned AST walk per file for node rules.
9
+ #
10
+ # Before this, every plugin that declared a {Base.node_rule} walked
11
+ # the file's AST itself (`Base#node_rule_diagnostics` →
12
+ # `Source::NodeWalker.each_with_ancestors`), so a project with N
13
+ # node-rule plugins paid N walks per file. This folds them into a
14
+ # single walk that dispatches each visited node to every matching
15
+ # `(plugin, rule)` pair.
16
+ #
17
+ # Behaviour is preserved exactly so the diagnostics stay
18
+ # byte-identical (the WD6 gate):
19
+ #
20
+ # * Each plugin's `node_file_context` block runs once per file,
21
+ # before any of its rules fire, `instance_exec`'d on that plugin —
22
+ # same as the per-plugin walk.
23
+ # * One frozen {NodeContext} is built per node, lazily, only when at
24
+ # least one rule matches it. Because it wraps only the ancestors it
25
+ # is safe to share across plugins for the same node.
26
+ # * Each rule block is `instance_exec`'d on its own plugin instance
27
+ # with the same five arguments `(node, scope, path, file_context,
28
+ # context)`.
29
+ # * A plugin whose context block or any rule block raises has its
30
+ # whole node-rule contribution isolated — the walk records the
31
+ # error against that plugin and continues, matching the runner's
32
+ # per-plugin rescue around the old `#node_rule_diagnostics` call.
33
+ # * Diagnostics are bucketed per plugin and returned in the
34
+ # registry order the runner already iterates, so emission order is
35
+ # unchanged (plugin-major, not node-major) — order preservation is
36
+ # what keeps the gate byte-identical in this slice.
37
+ #
38
+ # The result is an ordered Array of {Result}, one per node-rule
39
+ # plugin (registry order). `Result#error` is non-nil iff that
40
+ # plugin's context or a rule block raised, in which case
41
+ # `#diagnostics` is empty; the runner turns the error into the same
42
+ # per-plugin `runtime-error` envelope it produced before.
43
+ class NodeRuleWalk
44
+ # One plugin's node-rule outcome for a single file. `error` is the
45
+ # exception raised by the plugin's context block or a rule block
46
+ # (nil on success); when set, `diagnostics` is empty.
47
+ Result = Struct.new(:plugin, :diagnostics, :error)
48
+
49
+ # Plugins that declare at least one `node_rule`, paired with their
50
+ # frozen rule list, in registry order. Built once per run and
51
+ # reused for every file.
52
+ def initialize(plugins)
53
+ @entries = plugins.filter_map do |plugin|
54
+ rules = plugin.class.node_rules
55
+ rules.empty? ? nil : [plugin, rules]
56
+ end.freeze
57
+ freeze
58
+ end
59
+
60
+ def empty?
61
+ @entries.empty?
62
+ end
63
+
64
+ # Walk `root` once, dispatching every node to each matching
65
+ # `(plugin, rule)`. Returns an Array of {Result} in plugin
66
+ # (registry) order. `root` nil yields one empty Result per plugin.
67
+ def diagnostics_for_file(path:, scope:, root:)
68
+ return @entries.map { |plugin, _| Result.new(plugin, [], nil) } if root.nil?
69
+
70
+ states = @entries.map { |plugin, rules| State.new(plugin, rules, scope, root) }
71
+ walk(path, scope, root, states)
72
+ states.map(&:result)
73
+ end
74
+
75
+ private
76
+
77
+ def walk(path, scope, root, states)
78
+ Source::NodeWalker.each_with_ancestors(root) do |node, ancestors|
79
+ context = nil
80
+ states.each do |state|
81
+ next if state.failed?
82
+
83
+ matched = state.rules_for(node)
84
+ next if matched.empty?
85
+
86
+ # One frozen NodeContext per node, built lazily and shared
87
+ # across every plugin that matches this node.
88
+ context ||= NodeContext.new(ancestors)
89
+ state.run_rules(matched, node, scope, path, context)
90
+ end
91
+ end
92
+ end
93
+
94
+ # Mutable per-(plugin, file) walk state. Kept private to the walk —
95
+ # holds the diagnostics bucket, the file context, the per-concrete-
96
+ # class match memo, and the isolation flag.
97
+ class State
98
+ def initialize(plugin, rules, scope, root)
99
+ @plugin = plugin
100
+ @rules = rules
101
+ @diagnostics = []
102
+ @match_cache = {}.compare_by_identity
103
+ @error = nil
104
+ build_file_context(scope, root)
105
+ end
106
+
107
+ def failed?
108
+ !@error.nil?
109
+ end
110
+
111
+ # Rules whose `node_type` this concrete node satisfies, memoised
112
+ # by the node's class so the `is_a?` scan runs once per class.
113
+ # Preserves `is_a?` semantics when a rule's `node_type` is a
114
+ # superclass of the concrete node.
115
+ def rules_for(node)
116
+ @match_cache[node.class] ||=
117
+ @rules.select { |rule| node.is_a?(rule[:node_type]) }
118
+ end
119
+
120
+ def run_rules(matched, node, scope, path, context)
121
+ matched.each do |rule|
122
+ result = @plugin.instance_exec(node, scope, path, @file_context, context, &rule[:block])
123
+ @diagnostics.concat(Array(result))
124
+ end
125
+ rescue StandardError => e
126
+ @error = e
127
+ @diagnostics = []
128
+ end
129
+
130
+ def result
131
+ NodeRuleWalk::Result.new(@plugin, @diagnostics, @error)
132
+ end
133
+
134
+ private
135
+
136
+ def build_file_context(scope, root)
137
+ block = @plugin.class.node_file_context_block
138
+ @file_context = block ? @plugin.instance_exec(root, scope, &block) : nil
139
+ rescue StandardError => e
140
+ @error = e
141
+ @file_context = nil
142
+ end
143
+ end
144
+ private_constant :State
145
+ end
146
+ end
147
+ end