rigortype 0.4.7 → 0.4.8

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 (268) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +3 -1
  3. data/data/core_overlay/bigdecimal.rbs +33 -0
  4. data/data/core_overlay/resolv.rbs +30 -0
  5. data/data/gem_overlay/activesupport/core_ext.rbs +29 -0
  6. data/data/gem_overlay/addressable/addressable.rbs +40 -0
  7. data/docs/handbook/01-getting-started.md +1 -1
  8. data/docs/handbook/11-sig-gen.md +35 -0
  9. data/docs/install.md +20 -0
  10. data/docs/manual/01-installation.md +67 -26
  11. data/docs/manual/02-cli-reference.md +307 -91
  12. data/docs/manual/03-configuration.md +41 -3
  13. data/docs/manual/04-diagnostics.md +5 -4
  14. data/docs/manual/08-skills.md +11 -0
  15. data/docs/manual/09-editor-integration.md +8 -0
  16. data/docs/manual/14-rails-quickstart.md +1 -1
  17. data/docs/manual/19-effect-labels.md +3 -4
  18. data/docs/manual/plugins/rigor-actionpack.md +15 -6
  19. data/docs/manual/plugins/rigor-activesupport-core-ext.md +3 -0
  20. data/docs/manual/plugins/rigor-sorbet.md +17 -3
  21. data/lib/rigor/analysis/check_rules/always_raises_shield.rb +147 -0
  22. data/lib/rigor/analysis/check_rules/call_facts.rb +58 -0
  23. data/lib/rigor/analysis/check_rules/declaration_sourced_guard.rb +8 -0
  24. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +13 -2
  25. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +6 -0
  26. data/lib/rigor/analysis/check_rules/plugin_api_surface.rb +195 -0
  27. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +40 -11
  28. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +27 -1
  29. data/lib/rigor/analysis/check_rules/replaced_singleton_defs.rb +96 -0
  30. data/lib/rigor/analysis/check_rules/rule_ids.rb +8 -1
  31. data/lib/rigor/analysis/check_rules/void_value_use_collector.rb +1 -1
  32. data/lib/rigor/analysis/check_rules.rb +598 -99
  33. data/lib/rigor/analysis/crash_signature.rb +43 -1
  34. data/lib/rigor/analysis/dependency_recorder.rb +233 -76
  35. data/lib/rigor/analysis/incremental.rb +11 -1
  36. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +2 -1
  37. data/lib/rigor/analysis/incremental_session.rb +279 -37
  38. data/lib/rigor/analysis/reachability/plugin_roots.rb +6 -3
  39. data/lib/rigor/analysis/rule_catalog.rb +37 -1
  40. data/lib/rigor/analysis/run_cache_key.rb +27 -3
  41. data/lib/rigor/analysis/run_disclosure_credit.rb +26 -6
  42. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +4 -0
  43. data/lib/rigor/analysis/runner/declaration_position.rb +7 -7
  44. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +72 -20
  45. data/lib/rigor/analysis/runner/effect_envelope_pass.rb +3 -8
  46. data/lib/rigor/analysis/runner/pool_coordinator.rb +26 -5
  47. data/lib/rigor/analysis/runner/project_pre_passes.rb +4 -37
  48. data/lib/rigor/analysis/runner.rb +222 -39
  49. data/lib/rigor/analysis/self_call_resolution_recorder.rb +61 -22
  50. data/lib/rigor/analysis/worker_session.rb +42 -41
  51. data/lib/rigor/bleeding_edge.rb +19 -4
  52. data/lib/rigor/cache/file_digest.rb +39 -31
  53. data/lib/rigor/cache/incremental_snapshot.rb +66 -7
  54. data/lib/rigor/cache/rbs_descriptor.rb +37 -2
  55. data/lib/rigor/cache/rbs_environment.rb +5 -1
  56. data/lib/rigor/cli/baseline_command.rb +15 -2
  57. data/lib/rigor/cli/check_command.rb +4 -2
  58. data/lib/rigor/cli/effects_snapshot_command.rb +8 -1
  59. data/lib/rigor/cli/lens_command.rb +17 -2
  60. data/lib/rigor/cli/sig_gen_command.rb +8 -0
  61. data/lib/rigor/cli/skill_describe.rb +1 -0
  62. data/lib/rigor/cli.rb +5 -2
  63. data/lib/rigor/config_audit.rb +32 -18
  64. data/lib/rigor/configuration/severity_profile.rb +9 -0
  65. data/lib/rigor/configuration.rb +32 -5
  66. data/lib/rigor/effects/collector.rb +53 -21
  67. data/lib/rigor/environment/bundle_sig_discovery.rb +63 -28
  68. data/lib/rigor/environment/constant_type_cache_holder.rb +5 -0
  69. data/lib/rigor/environment/default_libraries.rb +17 -0
  70. data/lib/rigor/environment/deferred_signature_plan.rb +104 -6
  71. data/lib/rigor/environment/hkt_registry_holder.rb +6 -0
  72. data/lib/rigor/environment/rbs_collection_discovery.rb +123 -8
  73. data/lib/rigor/environment/rbs_coverage_report.rb +12 -11
  74. data/lib/rigor/environment/rbs_hierarchy.rb +6 -0
  75. data/lib/rigor/environment/rbs_loader.rb +312 -63
  76. data/lib/rigor/environment.rb +131 -35
  77. data/lib/rigor/inference/acceptance.rb +50 -15
  78. data/lib/rigor/inference/alias_mutation_floor/foreign_locals.rb +117 -0
  79. data/lib/rigor/inference/alias_mutation_floor/memos.rb +85 -0
  80. data/lib/rigor/inference/alias_mutation_floor/reach.rb +119 -0
  81. data/lib/rigor/inference/alias_mutation_floor/site_collector.rb +252 -0
  82. data/lib/rigor/inference/alias_mutation_floor.rb +339 -0
  83. data/lib/rigor/inference/anonymous_meta_class.rb +18 -0
  84. data/lib/rigor/inference/block_auto_splat.rb +1 -1
  85. data/lib/rigor/inference/block_run_count.rb +9 -2
  86. data/lib/rigor/inference/builtins/array_catalog.rb +2 -2
  87. data/lib/rigor/inference/callee_mutation_chain.rb +1 -1
  88. data/lib/rigor/inference/captured_locals.rb +2 -0
  89. data/lib/rigor/inference/census_foreign_block.rb +354 -0
  90. data/lib/rigor/inference/clause_widening.rb +1 -1
  91. data/lib/rigor/inference/closure_escape_analyzer.rb +11 -4
  92. data/lib/rigor/inference/construction_escape.rb +56 -15
  93. data/lib/rigor/inference/declaration_walk.rb +4 -0
  94. data/lib/rigor/inference/def_node_resolver.rb +32 -14
  95. data/lib/rigor/inference/def_return_typer.rb +1 -1
  96. data/lib/rigor/inference/definer_resolution.rb +15 -7
  97. data/lib/rigor/inference/definers_index.rb +72 -16
  98. data/lib/rigor/inference/error_info.rb +16 -0
  99. data/lib/rigor/inference/expression_typer.rb +1459 -287
  100. data/lib/rigor/inference/flow_tracer.rb +31 -14
  101. data/lib/rigor/inference/fork_map.rb +3 -0
  102. data/lib/rigor/inference/fresh_frame_blocks.rb +52 -1
  103. data/lib/rigor/inference/global_write_census.rb +8 -6
  104. data/lib/rigor/inference/in_effect_refinements.rb +7 -0
  105. data/lib/rigor/inference/indexed_narrowing.rb +57 -8
  106. data/lib/rigor/inference/ivar_writer_census.rb +106 -11
  107. data/lib/rigor/inference/key_presence_guard.rb +51 -25
  108. data/lib/rigor/inference/last_line/self_evidence.rb +30 -0
  109. data/lib/rigor/inference/last_line.rb +5 -2
  110. data/lib/rigor/inference/loop_value_record.rb +63 -15
  111. data/lib/rigor/inference/match_rebinding/frame.rb +11 -0
  112. data/lib/rigor/inference/match_rebinding/yielding_match.rb +74 -18
  113. data/lib/rigor/inference/match_rebinding.rb +21 -8
  114. data/lib/rigor/inference/method_dispatcher/block_folding.rb +43 -29
  115. data/lib/rigor/inference/method_dispatcher/class_keyed_block_params.rb +65 -0
  116. data/lib/rigor/inference/method_dispatcher/core_enumerable_receiver.rb +44 -0
  117. data/lib/rigor/inference/method_dispatcher/declared_constructor.rb +151 -0
  118. data/lib/rigor/inference/method_dispatcher/facet_distribution.rb +1 -1
  119. data/lib/rigor/inference/method_dispatcher/imprecise_argument.rb +2 -2
  120. data/lib/rigor/inference/method_dispatcher/interface_shape.rb +181 -0
  121. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +12 -0
  122. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +409 -67
  123. data/lib/rigor/inference/method_dispatcher/self_substitute.rb +1 -1
  124. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +384 -64
  125. data/lib/rigor/inference/method_dispatcher/singleton_type_params.rb +104 -0
  126. data/lib/rigor/inference/method_dispatcher.rb +66 -18
  127. data/lib/rigor/inference/method_parameter_binder.rb +136 -56
  128. data/lib/rigor/inference/module_function_state/spellings.rb +2 -2
  129. data/lib/rigor/inference/module_function_state.rb +13 -2
  130. data/lib/rigor/inference/multi_target_binder.rb +1 -1
  131. data/lib/rigor/inference/mutation_widening.rb +44 -4
  132. data/lib/rigor/inference/narrowing.rb +201 -27
  133. data/lib/rigor/inference/operand_effects.rb +43 -2
  134. data/lib/rigor/inference/optimistic_origin.rb +133 -8
  135. data/lib/rigor/inference/origin_lookup.rb +1 -1
  136. data/lib/rigor/inference/precision_scanner.rb +1 -1
  137. data/lib/rigor/inference/project_method_ownership.rb +28 -10
  138. data/lib/rigor/inference/project_patched_methods.rb +65 -13
  139. data/lib/rigor/inference/project_patched_scanner.rb +120 -25
  140. data/lib/rigor/inference/project_patched_visibility.rb +39 -3
  141. data/lib/rigor/inference/rbs_type_translator.rb +68 -15
  142. data/lib/rigor/inference/receiver_writer_call.rb +67 -0
  143. data/lib/rigor/inference/recursion_rebinding.rb +157 -0
  144. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +56 -18
  145. data/lib/rigor/inference/refined_dispatch/own_method.rb +51 -17
  146. data/lib/rigor/inference/refined_dispatch.rb +13 -0
  147. data/lib/rigor/inference/reflective_ivar_write.rb +19 -11
  148. data/lib/rigor/inference/remover_holder_scan.rb +76 -22
  149. data/lib/rigor/inference/scope_indexer/class_cvars_collector.rb +13 -6
  150. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +12 -11
  151. data/lib/rigor/inference/scope_indexer/ivar_contributions.rb +188 -28
  152. data/lib/rigor/inference/scope_indexer/mixin_accumulator.rb +136 -9
  153. data/lib/rigor/inference/scope_indexer.rb +2079 -183
  154. data/lib/rigor/inference/self_call_ivar_writes.rb +26 -7
  155. data/lib/rigor/inference/self_preserving_block.rb +87 -0
  156. data/lib/rigor/inference/singleton_hook_decline.rb +41 -6
  157. data/lib/rigor/inference/singleton_object_constant.rb +32 -2
  158. data/lib/rigor/inference/statement_evaluator.rb +970 -250
  159. data/lib/rigor/inference/string_mutation.rb +14 -0
  160. data/lib/rigor/inference/synthetic_method_scanner.rb +35 -11
  161. data/lib/rigor/inference/toplevel_def_census.rb +14 -6
  162. data/lib/rigor/inference/unknown_store_widening.rb +1 -1
  163. data/lib/rigor/inference/untyped_rejoin.rb +1 -1
  164. data/lib/rigor/inference/void_tail_summary.rb +29 -10
  165. data/lib/rigor/lens/claims/comparison.rb +78 -0
  166. data/lib/rigor/lens/claims/request.rb +131 -0
  167. data/lib/rigor/lens/claims/type_reader.rb +216 -0
  168. data/lib/rigor/lens/claims.rb +208 -0
  169. data/lib/rigor/lens/contract_reader.rb +5 -0
  170. data/lib/rigor/lens/document.rb +1 -1
  171. data/lib/rigor/lens/file_reader.rb +21 -8
  172. data/lib/rigor/lens/run.rb +8 -0
  173. data/lib/rigor/lens.rb +1 -1
  174. data/lib/rigor/library_probe.rb +68 -0
  175. data/lib/rigor/memo_audit.rb +282 -0
  176. data/lib/rigor/plugin/base.rb +15 -2
  177. data/lib/rigor/plugin/io_boundary.rb +25 -3
  178. data/lib/rigor/plugin/manifest.rb +7 -3
  179. data/lib/rigor/plugin/registry.rb +13 -2
  180. data/lib/rigor/plugin/trust_policy.rb +46 -0
  181. data/lib/rigor/protection/mutation_cache.rb +4 -1
  182. data/lib/rigor/reflection/constant_ancestors.rb +79 -22
  183. data/lib/rigor/reflection/declaration_collectors.rb +55 -0
  184. data/lib/rigor/reflection/declarations.rb +58 -0
  185. data/lib/rigor/reflection/project_method.rb +177 -0
  186. data/lib/rigor/reflection.rb +2 -0
  187. data/lib/rigor/rooted_glob.rb +18 -1
  188. data/lib/rigor/run_scoped_slots.rb +20 -20
  189. data/lib/rigor/scope/discovery_index.rb +149 -2
  190. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -1
  191. data/lib/rigor/scope/resolution_chain.rb +900 -55
  192. data/lib/rigor/scope.rb +307 -34
  193. data/lib/rigor/sig_gen/bot_proposal_guard.rb +306 -0
  194. data/lib/rigor/sig_gen/classification.rb +14 -1
  195. data/lib/rigor/sig_gen/generator.rb +222 -13
  196. data/lib/rigor/sig_gen/method_candidate.rb +14 -0
  197. data/lib/rigor/sig_gen/observation_collector.rb +11 -2
  198. data/lib/rigor/sig_gen/platform_guarded_files.rb +184 -0
  199. data/lib/rigor/sig_gen/skip_reason_catalog.rb +47 -0
  200. data/lib/rigor/sig_gen/writer.rb +21 -0
  201. data/lib/rigor/sig_gen.rb +1 -0
  202. data/lib/rigor/source/forwardable_delegators.rb +122 -0
  203. data/lib/rigor/source/node_children.rb +13 -0
  204. data/lib/rigor/type/combinator.rb +164 -8
  205. data/lib/rigor/type/interface.rb +60 -0
  206. data/lib/rigor/type.rb +1 -0
  207. data/lib/rigor/type_of_memo/serving.rb +99 -0
  208. data/lib/rigor/type_of_memo/stats.rb +43 -0
  209. data/lib/rigor/type_of_memo.rb +337 -0
  210. data/lib/rigor/typing_state.rb +1094 -0
  211. data/lib/rigor/version.rb +1 -1
  212. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +2 -2
  213. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +57 -25
  214. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +3 -6
  215. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +25 -1
  216. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +16 -6
  217. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +4 -2
  218. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -1
  219. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_parser.rb +130 -30
  220. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/schema_table.rb +20 -5
  221. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/structure_sql_parser.rb +7 -4
  222. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +49 -13
  223. data/plugins/rigor-activerecord/sig/active_record/relation.rbs +19 -5
  224. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +4 -2
  225. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +13 -5
  226. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +29 -0
  227. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_collector.rb +15 -21
  228. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -6
  229. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +4 -2
  230. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +12 -2
  231. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +6 -0
  232. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/analyzer.rb +199 -1
  233. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +3 -2
  234. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +4 -4
  235. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog.rb +209 -2
  236. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +146 -7
  237. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +52 -34
  238. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +80 -3
  239. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_collector.rb +16 -16
  240. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +1 -6
  241. data/sig/rigor/analysis/check_rules/rule_walk.rbs +6 -0
  242. data/sig/rigor/analysis/fact_store.rbs +2 -3
  243. data/sig/rigor/cache/descriptor.rbs +14 -0
  244. data/sig/rigor/environment.rbs +4 -1
  245. data/sig/rigor/plugin/base.rbs +53 -19
  246. data/sig/rigor/plugin/interfaces.rbs +51 -0
  247. data/sig/rigor/plugin/io_boundary.rbs +2 -3
  248. data/sig/rigor/plugin/node_context.rbs +21 -0
  249. data/sig/rigor/reflection.rbs +37 -0
  250. data/sig/rigor/scope.rbs +22 -3
  251. data/sig/rigor/trinary.rbs +1 -0
  252. data/sig/rigor/type.rbs +9 -3
  253. data/sig/rigor.rbs +3 -2
  254. data/skills/rigor-ask/SKILL.md +1 -0
  255. data/skills/rigor-project-init/references/01-detect.md +1 -0
  256. data/skills/rigor-project-init/references/03-baseline-and-bugs.md +5 -1
  257. data/skills/rigor-project-init/references/06-agent-contract.md +4 -4
  258. data/skills/rigor-rbs-contract/SKILL.md +147 -0
  259. data/skills/rigor-rbs-contract/references/01-finding-candidates.md +212 -0
  260. data/skills/rigor-rbs-contract/references/02-contract-rules.md +157 -0
  261. data/skills/rigor-rbs-contract/references/03-target-own-code.md +90 -0
  262. data/skills/rigor-rbs-contract/references/04-target-gem-rbs-collection.md +210 -0
  263. data/skills/rigor-rbs-contract/references/05-declaration-mutants.md +182 -0
  264. data/skills/rigor-rbs-setup/SKILL.md +3 -1
  265. data/skills/rigor-type-oracle/SKILL.md +26 -1
  266. data/skills/rigor-type-oracle/references/02-agents-md-fragment.md +4 -3
  267. metadata +48 -2
  268. data/lib/rigor/inference/memo_audit.rb +0 -55
@@ -11,6 +11,8 @@ require_relative "../source/node_children"
11
11
  require_relative "../source/node_walker"
12
12
  require_relative "../analysis/self_call_resolution_recorder"
13
13
  require_relative "../run_scoped_slots"
14
+ require_relative "../typing_state"
15
+ require_relative "../type_of_memo"
14
16
  require_relative "block_call_timing"
15
17
  require_relative "block_repetition"
16
18
  require_relative "project_yielder"
@@ -20,6 +22,7 @@ require_relative "body_fixpoint"
20
22
  require_relative "budget_trace"
21
23
  require_relative "captured_locals"
22
24
  require_relative "closure_escape_analyzer"
25
+ require_relative "self_preserving_block"
23
26
  require_relative "receiver_blind_block"
24
27
  require_relative "def_node_resolver"
25
28
  require_relative "dynamic_origin"
@@ -41,6 +44,7 @@ require_relative "mutation_widening"
41
44
  require_relative "narrowing"
42
45
  require_relative "optimistic_origin"
43
46
  require_relative "receiver_alias"
47
+ require_relative "recursion_rebinding"
44
48
  require_relative "repeated_or_writes"
45
49
  require_relative "singleton_object_constant"
46
50
  require_relative "refined_dispatch"
@@ -260,17 +264,36 @@ module Rigor
260
264
  @typing_node = typing_node
261
265
  end
262
266
 
267
+ # ADR-129 WD2 — the calling thread's {TypingState::State}, cached on the typer the first time a slot is read, so
268
+ # a later read is an ivar and an array index rather than a `Thread.current` lookup. A typer lives for one query on
269
+ # one thread; an object kept across threads must not keep one (see {Analysis::IncrementalSession}'s effects
270
+ # evaluator), or it would write into a state whose thread's {RunScopedSlots.release} has already run. A frame
271
+ # install whose block's value is the answer calls {TypingState.current} instead: Rigor's own analysis does not
272
+ # adopt this memoised reader's type at a self-call (#525's self-purity), so the install's answer would read as
273
+ # `Dynamic` through it.
274
+ def typing_state = @typing_state ||= TypingState.current
275
+
263
276
  def type_of(node)
264
277
  previous = @typing_node
265
278
  @typing_node = node
279
+ # ADR-129 G1′: under the audit, the Frame declarations are asserted around every `type_of`.
280
+ audited = MemoAudit.enabled?
281
+ MemoAudit.enter_type_of if audited
266
282
  result = if FlowTracer.active?
267
283
  FlowTracer.trace_node(node) { untraced_type_of(node) }
284
+ elsif TypeOfMemo.enabled? && @tracer.nil? && @operand_types.nil?
285
+ # ADR-129 stage 3 — the shadow memo (G1); shut under a tracer and for a typer with instance state.
286
+ state = typing_state
287
+ TypeOfMemo.shadow(state, scope, node, callee: !state.stack(INFERENCE_GUARD).empty?) do
288
+ untraced_type_of(node)
289
+ end
268
290
  else
269
291
  untraced_type_of(node)
270
292
  end
271
293
  result
272
294
  ensure
273
295
  @typing_node = previous
296
+ MemoAudit.leave_type_of if audited
274
297
  end
275
298
 
276
299
  def untraced_type_of(node)
@@ -299,8 +322,10 @@ module Rigor
299
322
  # unchanged before skipping the callee's symbol dependents. nil for an abstract / bodyless def, or when
300
323
  # the memo refuses a transient result — the caller treats either as "not provably stable" (keeps the
301
324
  # dependents), the conservative direction.
302
- def return_type_for(def_node, receiver, arg_types)
303
- infer_user_method_return(def_node, receiver, arg_types)
325
+ #
326
+ # Issue #1237 — `positionals_unknown:` re-drives a key a splat call observed, binding it the way that call did.
327
+ def return_type_for(def_node, receiver, arg_types, positionals_unknown: false)
328
+ infer_user_method_return(def_node, receiver, arg_types, positionals_unknown: positionals_unknown)
304
329
  end
305
330
 
306
331
  # The `receiver[args]` read a compound index write performs before it stores (`c[k] += v` reads `c[k]`),
@@ -317,7 +342,7 @@ module Rigor
317
342
  # `name` anyway. Minting a `Prism::CallNode` for the read would hand plugins a node that is not in the
318
343
  # tree, through a constructor whose field list changed inside the `prism` range the gemspec accepts.
319
344
  def implicit_index_read_type(node, receiver, arg_types)
320
- return splat_index_read_type(node, receiver, arg_types) if node.arguments&.arguments&.any?(Prism::SplatNode)
345
+ return splat_index_read_type(node, receiver, arg_types) if splat_arguments?(node)
321
346
 
322
347
  try_overriding_def_dispatch(node, receiver, arg_types, method_name: :[]) ||
323
348
  index_read_dispatch(node, receiver, arg_types) ||
@@ -342,36 +367,59 @@ module Rigor
342
367
  recorded = self.class.recorded_inject_accumulator(block, scope)
343
368
  return recorded if recorded
344
369
 
345
- try_block_inject_fold(call_node, receiver, arg_types)
370
+ # ADR-129 WD1 — a read-through Table's miss: writing another Table from it touches.
371
+ typing_state.memo_miss(INJECT_ACCUMULATOR) { try_block_inject_fold(call_node, receiver, arg_types) }
346
372
  self.class.recorded_inject_accumulator(block, scope)
347
373
  end
348
374
 
349
- INJECT_ACCUMULATOR_KEY = RunScopedSlots.register(:__rigor_inject_accumulator__)
350
- private_constant :INJECT_ACCUMULATOR_KEY
375
+ # ADR-129 WD1 — a Table, and a read-through cache of the computation's own value: a record read back answers
376
+ # what typing the call again from the same scope would compute.
377
+ INJECT_ACCUMULATOR = TypingState.table(
378
+ :inject_accumulator,
379
+ run_scoped: true, read_through: true, entries: TypingState::WeakKeyMapEntries,
380
+ reason: "keyed by block node; a read-through cache of the fold that the call's own typing computes"
381
+ )
382
+ private_constant :INJECT_ACCUMULATOR
351
383
 
352
384
  # The accumulator type {#record_inject_accumulator} stored for `block` when the call was typed from `scope`
353
- # itself (by identity), else nil.
385
+ # itself (by identity) in the current ADR-55 summary generation (#2767, {LoopValueRecord.generation}), else nil.
354
386
  def self.recorded_inject_accumulator(block, scope)
355
- slot = Thread.current[INJECT_ACCUMULATOR_KEY]&.[](block)
356
- slot && slot[0].equal?(scope) ? slot[1] : nil
387
+ state = TypingState.current
388
+ slot = state[INJECT_ACCUMULATOR]&.[](block)
389
+ state.log_read(INJECT_ACCUMULATOR, block, slot || TypingState::ABSENT)
390
+ slot && slot[0].equal?(scope) && slot[2] == LoopValueRecord.generation ? slot[1] : nil
357
391
  end
358
392
 
359
393
  # ADR-89 WD2 — the current run's return memo bucket as `{ def_node => [MemoEntry, …] }` (only entries
360
394
  # that carry a call descriptor, i.e. every stored entry). Read by the incremental session right after a
361
395
  # recording run to harvest each analyzed callee's observed call keys → return descriptors. Returns an
362
- # empty hash when no bucket exists (a run that memoised nothing). Class method: the bucket lives on
363
- # `Thread.current`, independent of any one `ExpressionTyper` instance.
396
+ # empty hash when no bucket exists (a run that memoised nothing). Class method: the bucket lives in the thread's
397
+ # {TypingState}, independent of any one `ExpressionTyper` instance.
364
398
  #
365
399
  # `bucket` defaults to the calling thread's current one; `Runner#run` keeps the bucket its run filled
366
400
  # ({.current_return_memo}) and passes it, since the run releases the thread's slot when it ends.
401
+ #
402
+ # Issue #3160 — the entries are what the memo STORED. A def one of whose calls it refused to store was observed
403
+ # under a key no entry lists; {.return_memo_refused?} says which.
367
404
  def self.harvest_return_memo(bucket = current_return_memo)
368
405
  return {} if bucket.nil?
369
406
 
370
- bucket.transform_values(&:values)
407
+ bucket.transform_values { |per_def| per_def.except(RETURN_MEMO_REFUSED).values }
408
+ end
409
+
410
+ # Issue #3160 — whether `bucket` served a result of `def_node` that it refused to store (ADR-84 WD3). That
411
+ # result reached its caller, so the def's stored entries are not every key it was observed under.
412
+ def self.return_memo_refused?(bucket, def_node)
413
+ bucket&.[](def_node)&.key?(RETURN_MEMO_REFUSED) || false
371
414
  end
372
415
 
416
+ # Issue #3160 — the per-def key under which {#consult_and_store_return_memo} notes that it served a result it
417
+ # did not store. No call descriptor is a Symbol, so it never meets a real key.
418
+ RETURN_MEMO_REFUSED = :refused
419
+ private_constant :RETURN_MEMO_REFUSED
420
+
373
421
  # The calling thread's return memo bucket (`{ def_node => { descriptor => MemoEntry } }`), or nil.
374
- def self.current_return_memo = Thread.current[RETURN_MEMO_KEY]&.last
422
+ def self.current_return_memo = TypingState.current[RETURN_MEMO]&.last
375
423
 
376
424
  # Issue #720 — the value the block passed by the call site currently being re-typed evaluates to, or
377
425
  # `nil` outside such a frame. Read by {#type_of_yield}.
@@ -381,22 +429,19 @@ module Rigor
381
429
  # inference rebuilds freely. It is installed unconditionally — with `nil` — at every user-method
382
430
  # inference frame, so a callee reached WITHOUT a block from inside a yielding body cannot read its
383
431
  # caller's block.
384
- YIELD_VALUE_KEY = :__rigor_yield_value_type__
385
- private_constant :YIELD_VALUE_KEY
432
+ YIELD_VALUE = TypingState.frame(
433
+ :yield_value_type, reason: "the caller's block value, installed per frame",
434
+ witness: "spec/fixtures/typing_state_witnesses/yield_value_type.rb"
435
+ )
436
+ private_constant :YIELD_VALUE
386
437
 
387
438
  def self.current_yield_value_type
388
- Thread.current[YIELD_VALUE_KEY]
439
+ TypingState.current[YIELD_VALUE]
389
440
  end
390
441
 
391
442
  # Runs `block` with `type` installed as the frame's yield value, restoring the previous frame on exit.
392
- def self.with_yield_value_type(type)
393
- previous = Thread.current[YIELD_VALUE_KEY]
394
- Thread.current[YIELD_VALUE_KEY] = type
395
- begin
396
- yield
397
- ensure
398
- Thread.current[YIELD_VALUE_KEY] = previous
399
- end
443
+ def self.with_yield_value_type(type, &)
444
+ TypingState.current.with(YIELD_VALUE, type, &)
400
445
  end
401
446
 
402
447
  # Issue #1125 — the argument types of the call currently being re-typed into the body being walked,
@@ -404,37 +449,48 @@ module Rigor
404
449
  # `m` itself was called with, so {#call_arg_types} expands the forwarding node to THIS list instead of
405
450
  # the `Dynamic[top]` a bare `Prism::ForwardingArgumentsNode` types as.
406
451
  #
407
- # A thread-local for the same reason the yield value is (see {YIELD_VALUE_KEY}): `...` names the
452
+ # A thread-local for the same reason the yield value is (see {YIELD_VALUE}): `...` names the
408
453
  # frame's caller, not a binding, and the body is walked through scopes the inference rebuilds freely.
409
454
  # It is installed unconditionally — with nil — at every user-method inference frame, so a body reached
410
455
  # without `...` cannot read its caller's argument list. Because each frame installs its OWN list, a
411
456
  # chain `a(...) -> b(...) -> c(...)` threads through: `b`'s frame holds the types `a` expanded and
412
457
  # `c`'s holds `b`'s. The list is the callee's call-site `arg_types`, which is what keeps the ADR-84
413
458
  # return memo's `(def_node, receiver, arg_types)` key complete for a forwarding def.
414
- FORWARDED_ARGS_KEY = :__rigor_forwarded_call_arg_types__
415
- private_constant :FORWARDED_ARGS_KEY
459
+ FORWARDED_ARGS = TypingState.frame(:forwarded_call_arg_types,
460
+ reason: "the caller's frozen argument list, installed per frame",
461
+ witness: "spec/fixtures/typing_state_witnesses/forwarded_call_arg_types.rb")
462
+ private_constant :FORWARDED_ARGS
416
463
 
417
464
  def self.current_forwarded_arg_types
418
- Thread.current[FORWARDED_ARGS_KEY]
465
+ TypingState.current[FORWARDED_ARGS]
419
466
  end
420
467
 
421
- # Runs `block` with `types` installed as the frame's forwarded argument list, restoring the previous
468
+ # Runs `block` with `types` (frozen) installed as the frame's forwarded argument list, restoring the previous
422
469
  # frame on exit.
423
- def self.with_forwarded_arg_types(types, &block)
424
- previous = Thread.current[FORWARDED_ARGS_KEY]
425
- Thread.current[FORWARDED_ARGS_KEY] = types
426
- begin
427
- block.call
428
- ensure
429
- Thread.current[FORWARDED_ARGS_KEY] = previous
430
- end
470
+ def self.with_forwarded_arg_types(types, &)
471
+ TypingState.current.with(FORWARDED_ARGS, types, &)
431
472
  end
432
473
 
433
474
  # The two call-site channels a user-method body may read from its caller — the block a `yield`
434
475
  # reaches ({#with_yield_value_type}) and the argument list `...` re-supplies
435
476
  # ({#with_forwarded_arg_types}) — installed together, both even when nil, for exactly one frame.
477
+ #
478
+ # The frame also clears the reflective-ivar repeat depth ({ReflectiveIvarWrite.in_repeating_body}): it says
479
+ # the CALLER's loop body is walked once for many runs, while the callee's body starts from its own scope and
480
+ # reads its own write. The depth only widens and the return memo's key has no term for it, so a body first
481
+ # reached from a `for` body stored a wider return that every later caller was served. It clears the caller's
482
+ # jump sinks and retry frames too ({StatementEvaluator.outside_caller_sinks}): a `next`, `break` or raise point
483
+ # in the callee's body is never the caller's, and a recursive re-walk of the caller's own block otherwise
484
+ # recorded the callee's arms into it. The block-body
485
+ # threading suppression is the same kind of caller flag and is NOT cleared here (issue #2782): it is what
486
+ # keeps a fold from nesting through callee frames, and clearing it re-walked unstorable frames
487
+ # exponentially, while keying the memo on it cost a fifth of `lib`'s allocations.
436
488
  def self.with_call_site_frame(yield_type, forwarded_arg_types, &)
437
- with_yield_value_type(yield_type) { with_forwarded_arg_types(forwarded_arg_types, &) }
489
+ ReflectiveIvarWrite.outside_repeating_body do
490
+ StatementEvaluator.outside_caller_sinks do
491
+ TypingState.current.with_pair(YIELD_VALUE, yield_type, FORWARDED_ARGS, forwarded_arg_types, &)
492
+ end
493
+ end
438
494
  end
439
495
 
440
496
  private
@@ -1058,7 +1114,7 @@ module Rigor
1058
1114
  # optimistic-carrier decline) and one terminating-arm rule, and cannot drift apart again. Only the
1059
1115
  # value is read; the post-scope belongs to whichever walk owns the statement.
1060
1116
  def type_of_conditional(node)
1061
- LoopValueRecord.fetch(node, scope) || scope.evaluate(node, tracer: tracer).first
1117
+ LoopValueRecord.fetch(node, scope) || LoopValueRecord.miss { scope.evaluate(node, tracer: tracer).first }
1062
1118
  end
1063
1119
 
1064
1120
  def type_of_else(node)
@@ -1289,7 +1345,7 @@ module Rigor
1289
1345
  def type_of_loop(node)
1290
1346
  return Type::Combinator.constant_of(nil) if break_free_condition_loop?(node)
1291
1347
 
1292
- LoopValueRecord.fetch(node, scope) || scope.evaluate(node, tracer: tracer).first
1348
+ LoopValueRecord.fetch(node, scope) || LoopValueRecord.miss { scope.evaluate(node, tracer: tracer).first }
1293
1349
  end
1294
1350
 
1295
1351
  def break_free_condition_loop?(node)
@@ -1326,7 +1382,7 @@ module Rigor
1326
1382
  # to bare `Nominal[Range]` when no endpoint contributes a typable shape.
1327
1383
  def nominal_range_for_endpoints(left_node, right_node)
1328
1384
  endpoints = [left_node, right_node].compact.map { |n| range_endpoint_element_type(n) }
1329
- endpoints.reject! { |t| t.equal?(Type::Combinator.untyped) }
1385
+ endpoints.reject! { |t| Type::Combinator.untyped?(t) }
1330
1386
  return Type::Combinator.nominal_of("Range") if endpoints.empty?
1331
1387
 
1332
1388
  Type::Combinator.nominal_of("Range", type_args: [Type::Combinator.union(*endpoints)])
@@ -1562,13 +1618,20 @@ module Rigor
1562
1618
  # engine walks can enumerate: a plugin's `dynamic_return` answer and the ADR-16 synthetic-method
1563
1619
  # index. They are members of `self` exactly as an `attr_reader` is, so a top-level `def` must not
1564
1620
  # bind ahead of them either.
1621
+ #
1622
+ # Issue #2861 — a bind the #316 gate declines answers `Dynamic[Top]`, never the RBS method of the same name: the
1623
+ # block's real `self` reaches either a same-named method of its own or the top-level `def`, which shadows
1624
+ # `Kernel#format` and every other `Object` / `Kernel` method on the way. Falling through typed
1625
+ # `format(r).bit_length` against `Kernel#format`'s `String` on code that runs the project's `def format`.
1565
1626
  def try_local_def_dispatch(node, receiver, arg_types, block_type = nil)
1566
- local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
1567
- return nil unless local_def
1627
+ return nil unless node.receiver.nil?
1628
+
1629
+ local_def = scope.bindable_top_level_def_for(node.name)
1630
+ return declined_top_level_def_answer(node) if local_def.nil?
1568
1631
  return nil if self_type_answers?(node.name)
1569
1632
  return nil if plugin_member_answers?(node, receiver)
1570
1633
 
1571
- local_inference = infer_top_level_user_method(local_def, receiver, arg_types, block_type)
1634
+ local_inference = infer_top_level_user_method(local_def, receiver, node, arg_types, block_type)
1572
1635
  return local_inference if local_inference
1573
1636
 
1574
1637
  # The local def matches by name but the inference was disqualified — the parameter shape is too
@@ -1580,6 +1643,16 @@ module Rigor
1580
1643
  dynamic_top
1581
1644
  end
1582
1645
 
1646
+ # Issue #2861 — the answer for an implicit-self call whose top-level `def` the #316 gate declined: nil (dispatch
1647
+ # as before) when the project defines no top-level `def` of the name, else `Dynamic[Top]`. The gate declines only
1648
+ # where `self_type` is nil, so no member of `self` ({#self_type_answers?}, {#plugin_member_answers?}) can answer.
1649
+ def declined_top_level_def_answer(node)
1650
+ return nil unless scope.opaque_block_self? && scope.self_type.nil?
1651
+ return nil if scope.top_level_def_for(node.name).nil?
1652
+
1653
+ dynamic_top
1654
+ end
1655
+
1583
1656
  # ADR-110 WD1 / #856 — the receiver's own `def` outranks a declaration that only an ANCESTOR carries.
1584
1657
  #
1585
1658
  # RBS resolves through the fully built method table, so `RbsDispatch` answers a subclass call with a
@@ -1712,8 +1785,15 @@ module Rigor
1712
1785
  return true if scope.discovered_method_through_ancestors?(class_name, method_name, :instance)
1713
1786
  return true if meta_member?(class_name, method_name)
1714
1787
  return true if resolve_user_def_through_ancestors(class_name, method_name)
1788
+ return true if rbs_ancestor_answers?(class_name, method_name)
1715
1789
 
1716
- rbs_ancestor_answers?(class_name, method_name)
1790
+ # Issue #2968 — a class on the chain defines methods under names no table holds (`attr_accessor(*FLAGS)`, a
1791
+ # splatted `Struct.new(*FIELDS)`), so the name may be one of them, ahead of the top-level `def`.
1792
+ return true if Scope::DiscoveryIndex.open_names_on_chain?(scope, class_name, :instance)
1793
+
1794
+ # Issue #2994 — a class on the chain mixes in a module no table names (`include(*MODS)`, `include helper`), so
1795
+ # the name may be that module's, ahead of the top-level `def`.
1796
+ Scope::DiscoveryIndex.unnamed_mixin_on_chain?(scope, class_name)
1717
1797
  end
1718
1798
 
1719
1799
  # The RBS arm of {#instance_self_answers?}. A project class is usually absent from the RBS
@@ -1740,7 +1820,13 @@ module Rigor
1740
1820
  return true if scope.discovered_method_through_ancestors?(class_name, method_name, :singleton)
1741
1821
  return true unless scope.singleton_def_through_ancestors(class_name, method_name).first.nil?
1742
1822
 
1743
- rbs_declared_on_class?(safe_rbs_method_definition(class_name, method_name, :singleton), class_name)
1823
+ declared = safe_rbs_method_definition(class_name, method_name, :singleton)
1824
+ return true if rbs_declared_on_class?(declared, class_name)
1825
+
1826
+ # Issue #2994 — a class object on the chain that a module no table names may extend through a hook (the
1827
+ # `:include_hook` open mark of a class with `include(*MODS)`), a class that `extend(*EMODS)`s, or an extended
1828
+ # module whose own module set is unknown, may answer any name ahead of the top-level `def`.
1829
+ Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods).unnamed_mixin?
1744
1830
  end
1745
1831
 
1746
1832
  # A `Struct.new(:a, :b)` / `Data.define(:a, :b)` member accessor. The layouts are a discovery table of
@@ -1775,7 +1861,148 @@ module Rigor
1775
1861
  def call_type_for(node)
1776
1862
  return safe_navigation_call_type(node) if node.safe_navigation?
1777
1863
 
1778
- memo_object_value(node, attribute_write_value(node, call_dispatch_type_for(node)))
1864
+ memo_object_value(node, class_test_find_value(node, attribute_write_value(node, call_dispatch_type_for(node))))
1865
+ end
1866
+
1867
+ CLASS_TEST_FINDERS = Set[:find, :detect, :rfind].freeze
1868
+ CLASS_TEST_PREDICATES = Set[:is_a?, :kind_of?, :instance_of?].freeze
1869
+ private_constant :CLASS_TEST_FINDERS, :CLASS_TEST_PREDICATES
1870
+
1871
+ # Issue #2719 — `find` / `detect` / `rfind` whose block is one class test on its one parameter
1872
+ # (`ancestors.rfind { |n| n.is_a?(Prism::DefNode) }`) returns an element that passed the test, or `nil`. The
1873
+ # dispatched `E?` is narrowed by that test, through the same {Narrowing} rule an `if n.is_a?(C)` edge takes, and
1874
+ # keeps its `nil`. Only an answer that already carries `nil` is narrowed, so an untyped receiver's `Dynamic`
1875
+ # stays as it was; a test that leaves no element (`Bot`, a class the element type is disjoint from) keeps the
1876
+ # dispatched answer too, since `nil` alone would be a verdict that the guard never matches. Any other block
1877
+ # shape, an `ifnone` argument, and a block with more than one parameter or statement keep today's answer.
1878
+ def class_test_find_value(node, result)
1879
+ param = class_test_find_parameter(node)
1880
+ return result if param.nil?
1881
+ return result unless result.is_a?(Type::Union) && result.members.any? { |m| nil_constant?(m) }
1882
+ return result unless element_or_nil?(node, result)
1883
+ # A refinement in effect may replace the finder with a body whose answer only happens to read as an element
1884
+ # or nil (`refine(Array) { def find = [2.5, 1, nil].sample }`), so the call keeps its answer.
1885
+ return result unless scope.in_effect_refinements(node).empty?
1886
+
1887
+ truthy, = Narrowing.predicate_scopes(node.block.body.body.first, scope.with_local(param, result))
1888
+ narrowed = truthy.local(param)
1889
+ return result if narrowed.nil?
1890
+
1891
+ non_nil = Narrowing.narrow_non_nil(narrowed)
1892
+ return result if non_nil.is_a?(Type::Bot) || introduces_dynamic?(non_nil, result)
1893
+
1894
+ Type::Combinator.union(non_nil, Type::Combinator.constant_of(nil))
1895
+ end
1896
+
1897
+ def nil_constant?(type) = type.is_a?(Type::Constant) && type.value.nil?
1898
+
1899
+ # True when the dispatched answer is exactly the receiver's element type or nil: the call really answers "an
1900
+ # element the block accepted, or nil". Whatever else wins the dispatch (a refinement, a reopened `Array` or
1901
+ # `Enumerable`, a gem's override) and answers anything else keeps its answer, and so does a receiver whose
1902
+ # element type cannot be read.
1903
+ def element_or_nil?(node, result)
1904
+ type = type_of(node.receiver)
1905
+ members = type.is_a?(Type::Union) ? type.members : [type]
1906
+ elements = members.map { |member| MethodDispatcher::IteratorDispatch.element_type_of(member) }
1907
+ return false if elements.include?(nil)
1908
+
1909
+ result == Type::Combinator.union(*elements, Type::Combinator.constant_of(nil))
1910
+ end
1911
+
1912
+ # A narrowing that cannot order an element's class against the tested one answers `Dynamic[top]` for it (a
1913
+ # project class with no RBS, typically); that is the test's "cannot tell", not a narrower element, so the
1914
+ # dispatched answer stands.
1915
+ def introduces_dynamic?(narrowed, result)
1916
+ members = narrowed.is_a?(Type::Union) ? narrowed.members : [narrowed]
1917
+ members.any? { |member| member.is_a?(Type::Dynamic) && !result.members.include?(member) }
1918
+ end
1919
+
1920
+ # The block parameter's name when `node` is a block-only `find` / `detect` / `rfind` whose block takes exactly
1921
+ # one plain parameter and whose body is one `is_a?` / `kind_of?` / `instance_of?` call on that parameter with a
1922
+ # single constant argument; nil otherwise.
1923
+ def class_test_find_parameter(node)
1924
+ # `detect(ifnone)` answers `ifnone.call` when nothing matches, which is not an element. A guard: today the
1925
+ # dispatched answer of an argument-bearing `detect` never carries the `nil` the narrowing needs, so the
1926
+ # call would already keep its answer.
1927
+ return nil unless CLASS_TEST_FINDERS.include?(node.name) && node.arguments.nil?
1928
+
1929
+ block = node.block
1930
+ return nil unless block.is_a?(Prism::BlockNode)
1931
+
1932
+ name = single_block_parameter(block)
1933
+ return nil if name.nil?
1934
+
1935
+ body = block.body
1936
+ return nil unless body.is_a?(Prism::StatementsNode) && body.body.size == 1
1937
+
1938
+ return nil unless class_test_on?(body.body.first, name)
1939
+
1940
+ core_finder_call?(node) ? name : nil
1941
+ end
1942
+
1943
+ # The owners whose `find` / `detect` / `rfind` return an element the block accepted: core `Array` and
1944
+ # `Enumerable` (`Hash`, `Set` and `Range` take `Enumerable`'s).
1945
+ CORE_FINDER_OWNERS = %w[Array Enumerable].freeze
1946
+ private_constant :CORE_FINDER_OWNERS
1947
+
1948
+ # True when every member of the call's receiver dispatches `node.name` to core `Array` or `Enumerable`: the
1949
+ # method's RBS owner is one of {CORE_FINDER_OWNERS} and the project defines no method by that name on the
1950
+ # class or an ancestor. A project or gem `find` may return anything its block did not test
1951
+ # (`def find = yield(1) ? 2.0 : nil`), so the name alone is not enough. {#element_or_nil?} closes the spellings
1952
+ # this cannot see (a refinement, a reopened module the discovery walk does not reach).
1953
+ def core_finder_call?(node)
1954
+ receiver = node.receiver
1955
+ return false if receiver.nil?
1956
+
1957
+ type = type_of(receiver)
1958
+ members = type.is_a?(Type::Union) ? type.members : [type]
1959
+ members.all? { |member| core_finder_member?(member, node.name) }
1960
+ end
1961
+
1962
+ def core_finder_member?(member, method_name)
1963
+ class_name =
1964
+ case member
1965
+ when Type::Nominal then member.class_name
1966
+ when Type::Tuple then "Array"
1967
+ when Type::HashShape then "Hash"
1968
+ end
1969
+ return false if class_name.nil?
1970
+ return false if scope.discovered_method_through_ancestors?(class_name, method_name, :instance)
1971
+
1972
+ defined_in = safe_rbs_method_definition(class_name, method_name, :instance)&.defined_in
1973
+ return false if defined_in.nil?
1974
+
1975
+ owner = defined_in.to_s.delete_prefix("::")
1976
+ return false unless CORE_FINDER_OWNERS.include?(owner)
1977
+
1978
+ # A reopened core owner (`module Enumerable; def find = 2.5; end`) replaces the method for every includer,
1979
+ # which the ancestor walk above does not reach for a class whose ancestry RBS alone records (`Set`).
1980
+ !scope.discovered_method?(owner, method_name, :instance)
1981
+ end
1982
+
1983
+ def single_block_parameter(block)
1984
+ params = block.parameters
1985
+ return nil unless params.is_a?(Prism::BlockParametersNode) && params.locals.empty?
1986
+
1987
+ list = params.parameters
1988
+ return nil if list.nil? || list.requireds.size != 1
1989
+ return nil unless list.optionals.empty? && list.rest.nil? && list.posts.empty? &&
1990
+ list.keywords.empty? && list.keyword_rest.nil? && list.block.nil?
1991
+
1992
+ required = list.requireds.first
1993
+ required.is_a?(Prism::RequiredParameterNode) ? required.name : nil
1994
+ end
1995
+
1996
+ def class_test_on?(test, name)
1997
+ return false unless test.is_a?(Prism::CallNode) && CLASS_TEST_PREDICATES.include?(test.name)
1998
+ return false if test.block || test.safe_navigation?
1999
+
2000
+ receiver = test.receiver
2001
+ return false unless receiver.is_a?(Prism::LocalVariableReadNode) && receiver.name == name
2002
+
2003
+ args = test.arguments&.arguments
2004
+ !args.nil? && args.size == 1 &&
2005
+ (args.first.is_a?(Prism::ConstantReadNode) || args.first.is_a?(Prism::ConstantPathNode))
1779
2006
  end
1780
2007
 
1781
2008
  # Issue #2411 — `recv.each_with_object(memo) { |x, acc| acc[k] = v }` returns the memo as the block's
@@ -1938,7 +2165,7 @@ module Rigor
1938
2165
  return type unless type.is_a?(Type::Constant) && [true, false].include?(type.value)
1939
2166
  # The carrier read itself keeps its declared answer even when that is a literal `true` /
1940
2167
  # `false` — the mark says its nil-freeness is a bet, not that the value's class widened.
1941
- return type if scope.optimistic_origins[node]
2168
+ return type if scope.optimistic_origin(node)
1942
2169
  return type if OptimisticOrigin.resolve(node, scope).nil?
1943
2170
 
1944
2171
  # Widen only toward what a miss really answers: a safe-navigation call answers `nil`, and
@@ -2068,7 +2295,7 @@ module Rigor
2068
2295
  return dynamic_top if name && refined_name_for?(receiver, name, list)
2069
2296
 
2070
2297
  block_name = symbol_block_argument(node)
2071
- dynamic_top if block_name && refined_anywhere?(block_name, list)
2298
+ dynamic_top if block_name && RefinedDispatch.refined_anywhere?(scope, block_name, list)
2072
2299
  end
2073
2300
 
2074
2301
  REFINED_NAMING_CALLS = Set[:send, :__send__, :public_send, :respond_to?, :method, :public_method].freeze
@@ -2104,17 +2331,6 @@ module Rigor
2104
2331
  end
2105
2332
  end
2106
2333
 
2107
- # ADR-121 WD7 — a names-wildcard row counts as a refinement of every name.
2108
- def refined_anywhere?(name, list)
2109
- refinements = scope.discovered_refinements
2110
- wildcard = Scope::DiscoveryIndex::REFINEMENT_WILDCARD
2111
- return true if list.include?(InEffectRefinements::UNKNOWN) &&
2112
- refinements.any? { |_refined, methods| methods.key?(name) || methods.key?(wildcard) }
2113
-
2114
- !RefinedDispatch.targets(refinements, name, list).nil? ||
2115
- !RefinedDispatch.targets(refinements, wildcard, list).nil?
2116
- end
2117
-
2118
2334
  # ADR-121 WD7 — does the call's lexical list (a plugin-declared module is the plugin's declaration and never
2119
2335
  # opaque) hold an opaque module? `list` is the full list, which is the lexical one when nothing is declared.
2120
2336
  def opaque_refinement_span?(node, list)
@@ -2152,7 +2368,7 @@ module Rigor
2152
2368
  return nil if winner.nil?
2153
2369
  return dynamic_top if winner == RefinedDispatch::UNKNOWN
2154
2370
 
2155
- refined_body_return(winner, node.name, Type::Combinator.nominal_of(class_name), arg_types, block_type) ||
2371
+ refined_body_return(winner, node, Type::Combinator.nominal_of(class_name), arg_types, block_type) ||
2156
2372
  dynamic_top
2157
2373
  end
2158
2374
 
@@ -2171,15 +2387,17 @@ module Rigor
2171
2387
  #
2172
2388
  # A body another file wrote is re-typed with that file's in-effect refinements at the `def` (its own module
2173
2389
  # among them), so a sibling override it calls answers from its refine body too.
2174
- def refined_body_return(winner, method_name, self_type, arg_types, block_type)
2390
+ def refined_body_return(winner, call_node, self_type, arg_types, block_type)
2175
2391
  def_node, foreign =
2176
- InEffectRefinements.refinement_def_with_query(scope, winner.module_name, winner.refined_class, method_name)
2392
+ InEffectRefinements.refinement_def_with_query(scope, winner.module_name, winner.refined_class, call_node.name)
2177
2393
  return nil if def_node.nil?
2178
2394
 
2179
2395
  refinements = foreign&.at(def_node.location.start_offset) do |name|
2180
2396
  InEffectRefinements.activated_modules(scope, name)
2181
2397
  end
2182
- infer_user_method_return(def_node, self_type, arg_types, yield_type: block_type, refinements: refinements)
2398
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
2399
+ infer_user_method_return(def_node, self_type, arg_types, yield_type: block_type, refinements: refinements,
2400
+ positionals_unknown: positionals_unknown)
2183
2401
  rescue StandardError
2184
2402
  nil
2185
2403
  end
@@ -2199,6 +2417,151 @@ module Rigor
2199
2417
  end
2200
2418
  end
2201
2419
 
2420
+ # Issue #1237 — whether the call's argument list leaves the positional correspondence unknown: it holds a
2421
+ # `*splat`, or a `...` with no forwarding frame to expand it from. `call_arg_types` carries either as ONE
2422
+ # argument, so a body re-typed from that list bound it to one parameter: `def count(*keys) = keys.size`
2423
+ # called as `r.count(*[0, 1])` folded to `1` and drew an always-falsey `== 2`. A Tuple splat of known
2424
+ # arity is not expanded either.
2425
+ def splat_arguments?(node)
2426
+ arguments = node.arguments&.arguments
2427
+ return false if arguments.nil?
2428
+
2429
+ arguments.any?(Prism::SplatNode) ||
2430
+ (forwards_arguments?(arguments) && ExpressionTyper.current_forwarded_arg_types.nil?)
2431
+ end
2432
+
2433
+ # The argument list and the `positionals_unknown:` flag a body tier re-types a user `def` with. Under
2434
+ # {#splat_arguments?} the body is still re-typed, so a body that always raises still answers `bot` and a
2435
+ # return that does not read its arguments is kept, but with no positional correspondence
2436
+ # ({#bind_params_without_correspondence}): the list is the call's keyword shape alone. Only a trailing
2437
+ # keyword-hash argument the call writes and {#call_arg_type} types as a `HashShape` supplies one. Any
2438
+ # other call leaves the keywords unknown (an open empty shape), because a splatted array's last element
2439
+ # is passed as keywords when it is a `ruby2_keywords` hash.
2440
+ #
2441
+ # Issue #3157 — a call whose every splat {#expand_tuple_splats} can expand is bound as the equivalent
2442
+ # positional call. Issue #3099 — a literal `**{}` passes no argument and is dropped. Issue #3191 — any other
2443
+ # trailing keyword hash holding a double splat is bound with no positional correspondence unless it is typed
2444
+ # as a non-empty closed shape ({#unknown_double_splat?}).
2445
+ def positional_correspondence(call_node, arg_types)
2446
+ arguments = call_node.arguments&.arguments
2447
+ unless splat_arguments?(call_node)
2448
+ return [arg_types[0...-1], false] if literal_empty_double_splat?(arguments&.last)
2449
+ return [[UNKNOWN_KEYWORDS], true] if unknown_double_splat?(arguments&.last, arg_types.last)
2450
+
2451
+ return [arg_types, false]
2452
+ end
2453
+
2454
+ expanded = expand_tuple_splats(arguments, arg_types)
2455
+ return [expanded, false] if expanded
2456
+
2457
+ last = arguments.last
2458
+ keywords = arg_types.last if last.is_a?(Prism::KeywordHashNode) && keyword_shape?(arg_types.last)
2459
+ [[keywords || UNKNOWN_KEYWORDS], true]
2460
+ end
2461
+
2462
+ UNKNOWN_KEYWORDS = Type::Combinator.hash_shape_of({}, extra_keys: :open)
2463
+ private_constant :UNKNOWN_KEYWORDS
2464
+
2465
+ # Issue #3099 — whether `argument` is a keyword hash built only of literal empty double splats (`**{}`),
2466
+ # which Ruby passes as no argument at all.
2467
+ def literal_empty_double_splat?(argument)
2468
+ argument.is_a?(Prism::KeywordHashNode) &&
2469
+ argument.elements.all? do |element|
2470
+ element.is_a?(Prism::AssocSplatNode) && element.value.is_a?(Prism::HashNode) &&
2471
+ element.value.elements.empty?
2472
+ end
2473
+ end
2474
+
2475
+ # Issues #3099 / #3191 — whether `argument` is a keyword hash holding a double splat whose type (`type`) is
2476
+ # not a non-empty closed shape. Ruby passes such a hash as a trailing positional `Hash` to a method that
2477
+ # declares no keywords when it is non-empty, and passes nothing when it is empty, so the call does not say
2478
+ # how many positionals the callee receives. An empty closed shape (`**h`, `h = {}`) may be a stale read of
2479
+ # a hash a callee filled; an open or shapeless one (`**opts`), a mixed `k: 1, **h`, two splats `**h, **h`
2480
+ # and `**nil` are not typed as a shape at all ({#double_splat_hash_shape}). Bound as given, each left a
2481
+ # positional `Hash`: `def count(*keys) = keys.size` called `r.count(1, 2, **opts)` folded to `3`.
2482
+ def unknown_double_splat?(argument, type)
2483
+ argument.is_a?(Prism::KeywordHashNode) && argument.elements.any?(Prism::AssocSplatNode) &&
2484
+ !keyword_shape?(type)
2485
+ end
2486
+
2487
+ # Issue #3157 — the argument list with each `*splat` replaced by its elements, or nil to keep the
2488
+ # no-correspondence binding. It expands only when every splat's operand is an array literal, so its arity is
2489
+ # syntactic, and every element is {#provably_not_hash?}: a splatted array's last element is passed as
2490
+ # keywords when it is a `ruby2_keywords` hash. A splat of a local or any other expression is not expanded,
2491
+ # because a `Tuple` read there may be a stale fold of an array that has since grown or shrunk. A trailing
2492
+ # keyword hash must be a literal `**{}` (dropped) or a non-empty shape; an open, mixed or empty non-literal
2493
+ # one leaves the keywords unknown. A list holding `...` is not expanded: its argument types do not line up
2494
+ # with its argument nodes. The splat's own entry in `arg_types` is `Dynamic[top]` ({#type_of_non_value}),
2495
+ # so the literal's elements are typed here.
2496
+ #
2497
+ # Only a call outside every re-typed body frame expands (the main walk's call sites). Inside a frame a rest
2498
+ # parameter holds the caller's arguments, so a chain `m1(*a) = m2(*[a, 1]) + m2(*[a, a])` handed each level
2499
+ # a deeper element type and walked every combination of every level
2500
+ # (spec/rigor/inference/splat_chain_growth_spec.rb).
2501
+ def expand_tuple_splats(arguments, arg_types)
2502
+ return nil if forwards_arguments?(arguments)
2503
+ return nil unless typing_state.stack(INFERENCE_GUARD).empty?
2504
+
2505
+ last = arguments.last
2506
+ trailing_keywords = last.is_a?(Prism::KeywordHashNode)
2507
+ return nil if trailing_keywords && !literal_empty_double_splat?(last) && !keyword_shape?(arg_types.last)
2508
+
2509
+ expanded = []
2510
+ arguments.each_with_index do |argument, index|
2511
+ next if argument.equal?(last) && trailing_keywords && literal_empty_double_splat?(last)
2512
+ next expanded << arg_types[index] unless argument.is_a?(Prism::SplatNode)
2513
+
2514
+ elements = literal_splat_elements(argument)
2515
+ return nil if elements.nil?
2516
+
2517
+ expanded.concat(elements)
2518
+ end
2519
+ expanded
2520
+ end
2521
+
2522
+ # Whether a trailing keyword-hash argument's type is a shape with at least one pair. {#call_arg_type} types a
2523
+ # keyword hash as a shape only when it is closed.
2524
+ def keyword_shape?(type)
2525
+ type.is_a?(Type::HashShape) && !type.pairs.empty?
2526
+ end
2527
+
2528
+ # The element types of `*[a, b]`, each typed on its own, or nil unless the operand is an array literal whose
2529
+ # every element is {#provably_not_hash?}. A splat element (`*[*t]`) types `Dynamic[top]`
2530
+ # ({#type_of_non_value}), so it declines too.
2531
+ def literal_splat_elements(splat)
2532
+ literal = unparenthesized(splat.expression)
2533
+ return nil unless literal.is_a?(Prism::ArrayNode)
2534
+
2535
+ types = literal.elements.map { |element| type_of(element) }
2536
+ types if types.all? { |type| provably_not_hash?(type) }
2537
+ end
2538
+
2539
+ # Classes no instance of which is a `Hash`. A `Nominal` of any other class may be a `Hash` subclass.
2540
+ NON_HASH_CLASSES = %w[
2541
+ Integer Float Rational Complex String Symbol Array Range Regexp NilClass TrueClass FalseClass Proc
2542
+ ].to_set.freeze
2543
+ private_constant :NON_HASH_CLASSES
2544
+
2545
+ # `*([1, 2])`'s array literal, or the node itself when it is not a parenthesised single expression.
2546
+ def unparenthesized(node)
2547
+ return node unless node.is_a?(Prism::ParenthesesNode) && node.body.is_a?(Prism::StatementsNode)
2548
+
2549
+ statements = node.body.body
2550
+ statements.size == 1 ? statements.first : node
2551
+ end
2552
+
2553
+ # Whether no value of `type` is a `Hash`. Anything this does not recognise answers false. A `Constant` never
2554
+ # carries a `Hash` (it holds scalars only).
2555
+ def provably_not_hash?(type)
2556
+ case type
2557
+ when Type::Constant, Type::Tuple, Type::IntegerRange then true
2558
+ when Type::Nominal then NON_HASH_CLASSES.include?(type.class_name)
2559
+ when Type::Union then type.members.all? { |member| provably_not_hash?(member) }
2560
+ when Type::Refined, Type::Difference then provably_not_hash?(type.base)
2561
+ else false
2562
+ end
2563
+ end
2564
+
2202
2565
  # The post-dispatch tiers for a call `MethodDispatcher` could not answer, in their historical
2203
2566
  # order; extracted from {#call_dispatch_type_for} whole.
2204
2567
  def dispatch_miss_result(node, receiver, arg_types, block_type = nil)
@@ -2414,8 +2777,10 @@ module Rigor
2414
2777
  # `scope.self_type` (or implicit `Object`) as the receiver carrier so the body's own self is
2415
2778
  # consistent with the call site's. Returns nil when the parameter shape disqualifies the def, when the
2416
2779
  # body is empty, or when the inference raises.
2417
- def infer_top_level_user_method(def_node, receiver, arg_types, block_type = nil)
2418
- infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
2780
+ def infer_top_level_user_method(def_node, receiver, call_node, arg_types, block_type = nil)
2781
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
2782
+ infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type,
2783
+ positionals_unknown: positionals_unknown)
2419
2784
  rescue StandardError
2420
2785
  nil
2421
2786
  end
@@ -2467,6 +2832,9 @@ module Rigor
2467
2832
  case type
2468
2833
  when Type::Constant then true
2469
2834
  when Type::Tuple then type.elements.all? { |element| fully_value_pinned?(element) }
2835
+ when Type::HashShape
2836
+ # Issue #3115 — the shape {#pinned_shape_descriptor} keys an unroll on is as pinned as a result.
2837
+ type.closed? && type.optional_keys.empty? && type.pairs.each_value.all? { |value| fully_value_pinned?(value) }
2470
2838
  else false
2471
2839
  end
2472
2840
  end
@@ -2553,9 +2921,10 @@ module Rigor
2553
2921
  def_node, owner, raising = resolve_typing_def_with_owner(receiver.class_name, method_name)
2554
2922
  return every_definer_raises_type(raising, receiver, call_node, arg_types, block_type) if def_node.nil?
2555
2923
 
2924
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
2556
2925
  result = infer_user_method_return(def_node, receiver, arg_types,
2557
2926
  self_fold_safe: fold_safe_call_receiver?(call_node),
2558
- yield_type: block_type)
2927
+ yield_type: block_type, positionals_unknown: positionals_unknown)
2559
2928
  return result if result.nil?
2560
2929
 
2561
2930
  degrade_if_overridable(result, owner, method_name, :instance)
@@ -2637,7 +3006,9 @@ module Rigor
2637
3006
  # proven to be the method Ruby calls, so the call falls through to `Dynamic`.
2638
3007
  return nil if scope.discovery.contested?(:discovered_singleton_def_nodes, [owner, method_name.to_sym])
2639
3008
 
2640
- result = infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
3009
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
3010
+ result = infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type,
3011
+ positionals_unknown: positionals_unknown)
2641
3012
  return result if result.nil?
2642
3013
 
2643
3014
  degrade_if_overridable(result, owner, method_name, :singleton)
@@ -2659,13 +3030,185 @@ module Rigor
2659
3030
  cache = (by_singleton[scope.discovered_singleton_def_nodes] ||= {})
2660
3031
  table = (cache[class_name.to_s] ||= {})
2661
3032
  key = method_name.to_sym
2662
- return table[key] if table.key?(key)
3033
+ if table.key?(key)
3034
+ return MemoAudit.check("class graph singleton def", table[key], class_name, key) do
3035
+ compute_singleton_def_with_owner(class_name, method_name)
3036
+ end
3037
+ end
3038
+
3039
+ table[key] = MemoAudit.key_audit("class graph singleton def") do
3040
+ compute_singleton_def_with_owner(class_name, method_name)
3041
+ end
3042
+ end
2663
3043
 
3044
+ def compute_singleton_def_with_owner(class_name, method_name)
2664
3045
  found = scope.singleton_def_through_ancestors(class_name, method_name)
2665
3046
  owner = found.last
2666
3047
  found = [nil, nil] if owner && SingletonExternalAhead.withhold?(scope, class_name, method_name, owner)
2667
3048
  found = [nil, nil] if found.last && dynamic_singleton_definer_ahead?(class_name, method_name, found.last)
2668
- table[key] = found
3049
+ found = [nil, nil] if found.last && singleton_hook_order_split?(class_name, method_name)
3050
+ found = [nil, nil] if found.last && singleton_mixin_split?(class_name, method_name, found)
3051
+ found = [nil, nil] if found.last && unseen_definer_split?(class_name, method_name, found)
3052
+ found
3053
+ end
3054
+
3055
+ # A definer no def table holds that Ruby may call instead of `found`: a hook-placed one, or a foreign `def C.x`.
3056
+ def unseen_definer_split?(class_name, method_name, found)
3057
+ hidden_hook_split?(class_name, method_name, found.last) || foreign_definer_behind_copy?(method_name, found)
3058
+ end
3059
+
3060
+ # Issue #3155 — a `def C.x` outside C's body (in the extended module, another class or the file) defines C's
3061
+ # own `x`, which no def table holds, only its object-singleton mark. A fold copy found on C may then be a
3062
+ # body Ruby does not call, so the typing read withholds.
3063
+ def foreign_definer_behind_copy?(method_name, found)
3064
+ node, owner = found
3065
+ return false unless singleton_copy_head(owner, node) != owner
3066
+
3067
+ SingletonHookDecline.foreign_singleton_definer?(scope, owner, method_name)
3068
+ end
3069
+
3070
+ # The hook-placed definers a found singleton `def` may not be the one Ruby calls through.
3071
+ def hidden_hook_split?(class_name, method_name, owner)
3072
+ hidden_hook_definer?(class_name, method_name, owner) || hook_block_rival?(class_name, method_name, owner)
3073
+ end
3074
+
3075
+ # Issue #3005 — a class object's own `def self.x` and a `def self.x` a concern's `included` block defines on it
3076
+ # replace each other in the order the `def` and the `include` run, which the tables do not hold, so the typing
3077
+ # read withholds when the answer is a class object's own `def` and its level's hook-block module defines the
3078
+ # name too.
3079
+ def hook_block_rival?(class_name, method_name, owner)
3080
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3081
+ index = chain.entries.index { |entry| !entry.external? && entry.side == :singleton && entry.name == owner }
3082
+ !index.nil? && chain.hook_block_rival?(scope, index, method_name)
3083
+ end
3084
+
3085
+ # Issue #1592 — a module a hook extends the class with may define the name private (`class_methods do private;
3086
+ # def x`), which Ruby refuses on an explicit receiver. The visibility rule does not reach that module, so the
3087
+ # typing read withholds rather than answer a call Ruby raises on. A module the class `extend`s itself is left to
3088
+ # that rule.
3089
+ def hidden_hook_definer?(class_name, method_name, owner)
3090
+ visibility = scope.discovered_method_visibility(owner, method_name)
3091
+ return false if visibility.nil? || visibility == :public
3092
+
3093
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3094
+ return false unless chain.entries.any? { |entry| entry.name == owner && entry.side == :instance }
3095
+
3096
+ scope.singleton_extends_of(class_name).none? do |raw|
3097
+ written = raw.delete_prefix("::")
3098
+ owner == written || owner.end_with?("::#{written}")
3099
+ end
3100
+ end
3101
+
3102
+ # Issue #1592 — whether to withhold `method_name`'s class-side answer: a class object on the chain has
3103
+ # hook-extended modules whose order against its own `extend`s is not a fact, and the orders give different
3104
+ # definers (the retro world's against the chain's, or, with no retro world, the class objects' alone against
3105
+ # the chain's). A `def` the extends fold copied onto a class object is read where Ruby finds it, at the extended
3106
+ # module's own entry, since the copy sits ahead of every module in both orders.
3107
+ def singleton_hook_order_split?(class_name, method_name)
3108
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3109
+ return false unless chain.hook_order_marked?
3110
+
3111
+ answer = positioned_singleton_definer(chain, method_name)
3112
+ retro = chain.retro_world
3113
+ return answer != positioned_singleton_definer(retro, method_name) if retro
3114
+
3115
+ answer != positioned_singleton_definer(chain, method_name, copies: true)
3116
+ end
3117
+
3118
+ # Issues #2998 and #3020 — whether a singleton-side mixin of a class object on the chain may answer ahead of
3119
+ # `found`: a named prepend master's order does not place, or a module no table names.
3120
+ def singleton_mixin_split?(class_name, method_name, found)
3121
+ singleton_prepend_split?(class_name, method_name, found) ||
3122
+ unnamed_singleton_mixin_split?(class_name, method_name)
3123
+ end
3124
+
3125
+ # Issue #2998 — whether to withhold the class-side answer `found` on a chain whose class objects have
3126
+ # singleton-side prepends of their own: master's order, which a chain that does not stand answers, places no
3127
+ # such prepend, so its definer may not be the one Ruby's order reaches first. A `def` the extends fold copied
3128
+ # onto a class object is compared as its module's. A definer from a prepend region whose order is not a fact
3129
+ # is withheld whichever order answers.
3130
+ def singleton_prepend_split?(class_name, method_name, found)
3131
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3132
+ return false unless chain.singleton_prepend_marked?
3133
+
3134
+ answer = positioned_singleton_definer(chain, method_name)
3135
+ return true if unordered_prepend_definer?(chain, answer)
3136
+
3137
+ node, owner = found
3138
+ head = scope.singleton_def_for(owner, method_name).equal?(node) ? singleton_copy_head(owner, node) : owner
3139
+ head != answer
3140
+ end
3141
+
3142
+ # Whether `answer` is a module in the prepend region of a class object on the chain whose prepends have no known
3143
+ # order: the entries between the start of that class's level and its own entry.
3144
+ def unordered_prepend_definer?(chain, answer)
3145
+ chain.prepend_order_nodes.any? do |node|
3146
+ level = chain.level_classes.index(node)
3147
+ next true if level.nil?
3148
+
3149
+ start = chain.level_starts[level]
3150
+ own = chain.entries.index { |entry| entry.side == :singleton && entry.name == node }
3151
+ next true if own.nil?
3152
+
3153
+ chain.entries[start...own].any? { |entry| entry.name == answer }
3154
+ end
3155
+ end
3156
+
3157
+ # Issue #3020 — whether to withhold the class-side answer on a chain whose class objects mix a module no table
3158
+ # names into their singleton side, which may define the name. A prepend (`class << self; prepend(*MODS)`) sits
3159
+ # ahead of the class object's own `def self.`, so a definer at or behind that class object's level is withheld;
3160
+ # an `extend(*MODS)` or a `class << self` `include(*MODS)` sits right behind it, so a definer behind the class
3161
+ # object's own entry is withheld and its own `def self.` still answers.
3162
+ def unnamed_singleton_mixin_split?(class_name, method_name)
3163
+ chain = Scope::ResolutionChain.for(scope, class_name.to_s, :singleton, :methods)
3164
+ prepends = chain.unnamed_prepend_nodes
3165
+ extends = chain.unnamed_extend_nodes
3166
+ return false if prepends.empty? && extends.empty?
3167
+
3168
+ answer = positioned_singleton_definer(chain, method_name)
3169
+ prepends.any? { |node| !answered_ahead?(chain, answer, level_start_of(chain, node)) } ||
3170
+ extends.any? { |node| !answered_ahead?(chain, answer, own_singleton_index(chain, node)&.succ) }
3171
+ end
3172
+
3173
+ # Whether `answer` owns an entry before `stop` (nil when the position is unknown: never). A nil `answer` (no class
3174
+ # object's own def) owns none.
3175
+ def answered_ahead?(chain, answer, stop)
3176
+ !stop.nil? && chain.entries[0...stop].any? { |entry| entry.name == answer }
3177
+ end
3178
+
3179
+ def level_start_of(chain, node)
3180
+ level = chain.level_classes.index(node)
3181
+ level && chain.level_starts[level]
3182
+ end
3183
+
3184
+ def own_singleton_index(chain, node)
3185
+ chain.entries.index { |entry| entry.side == :singleton && entry.name == node }
3186
+ end
3187
+
3188
+ # The owner of the first `def` of the name along the chain: a fold copy is skipped, or with `copies:` read as
3189
+ # its module's, on the class objects alone (the answer master gives).
3190
+ def positioned_singleton_definer(chain, method_name, copies: false)
3191
+ chain.search(scope, side: copies ? :singleton : nil) do |entry|
3192
+ next if entry.external?
3193
+
3194
+ name = entry.name
3195
+ if entry.side == :singleton
3196
+ node = scope.singleton_def_for(name, method_name)
3197
+ next nil if node.nil?
3198
+
3199
+ head = singleton_copy_head(name, node)
3200
+ copies ? head : (name if head == name)
3201
+ elsif scope.user_def_for(name, method_name)
3202
+ name
3203
+ end
3204
+ end
3205
+ end
3206
+
3207
+ # The module a receiverless singleton `def` was written in when the extends fold copied it onto `owner`, else
3208
+ # `owner`.
3209
+ def singleton_copy_head(owner, node)
3210
+ head = node.receiver.nil? ? scope.discovery.recorded_def_nesting(node)&.first : nil
3211
+ head.nil? ? owner : head
2669
3212
  end
2670
3213
 
2671
3214
  SINGLETON_WILDCARD_SLOT = %i[singleton *].freeze
@@ -2693,6 +3236,8 @@ module Rigor
2693
3236
  next if entry.external?
2694
3237
  return false if entry.name == owner.to_s
2695
3238
  return true if entry_defines_without_def?(entry, name)
3239
+ # Issue #1592 — a hook of a module the class includes may have extended it with a module no table names.
3240
+ return true if entry.side == :singleton && chain.hook_open_nodes.include?(entry.name)
2696
3241
  end
2697
3242
  false
2698
3243
  end
@@ -2744,7 +3289,9 @@ module Rigor
2744
3289
  def_node = SingletonObjectConstant.def_node_for(call_node, receiver, method_name, scope)
2745
3290
  return nil if def_node.nil?
2746
3291
 
2747
- infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type)
3292
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
3293
+ infer_user_method_return(def_node, receiver, arg_types, yield_type: block_type,
3294
+ positionals_unknown: positionals_unknown)
2748
3295
  rescue StandardError
2749
3296
  nil
2750
3297
  end
@@ -2756,8 +3303,14 @@ module Rigor
2756
3303
  # external entries this lookup passes over. Cross-file: the chain is built from the tables the runner
2757
3304
  # seeds from the project-wide pre-pass, cycle-guarded and capped at `Scope::ANCESTOR_WALK_LIMIT`.
2758
3305
 
2759
- CLASS_GRAPH_CACHE_KEY = RunScopedSlots.register(:__rigor_class_graph_cache__)
2760
- private_constant :CLASS_GRAPH_CACHE_KEY
3306
+ CLASS_GRAPH_CACHE = TypingState.memo(
3307
+ :class_graph_cache,
3308
+ run_scoped: true,
3309
+ audit: ["class graph user def", "class graph typing def", "class graph singleton def", "class graph yields",
3310
+ "class graph self pure"],
3311
+ reason: "pure functions of the frozen discovery index it keys on"
3312
+ )
3313
+ private_constant :CLASS_GRAPH_CACHE
2761
3314
 
2762
3315
  # Memo for the static class-graph resolvers below. They are pure functions of the scope's *frozen*
2763
3316
  # discovery tables — `user_def_for` / `superclass_of` / `includes_of` and the ancestor-name resolver
@@ -2775,8 +3328,8 @@ module Rigor
2775
3328
  #
2776
3329
  # ONE slot, replaced rather than accumulated — the same shape, and now the same key, as
2777
3330
  # {MethodDispatcher::RbsDispatch}'s `core_stdlib_memo`. `ExpressionTyper` is rebuilt per
2778
- # `Scope#type_of`, so the slot lives on `Thread.current` rather than on `self`. A `Scope` merges its
2779
- # file's discovery with the project pre-pass, so every analysed file arrives with a fresh index: an
3331
+ # `Scope#type_of`, so the slot lives in the thread's {TypingState} rather than on `self`. A `Scope` merges
3332
+ # its file's discovery with the project pre-pass, so every analysed file arrives with a fresh index: an
2780
3333
  # identity-keyed *store* grew one bucket per file and, because the keys ARE the tables, pinned every
2781
3334
  # file's whole discovery index for the length of the run. What that bought, measured over
2782
3335
  # `lib`+`plugins`: 552 buckets holding 1,834 entries between them, and 58 of 80,106 calls answered
@@ -2786,23 +3339,32 @@ module Rigor
2786
3339
  # because one `equal?` is cheaper than three identity-hash lookups.
2787
3340
  #
2788
3341
  # Steady-state cost is one `equal?` check and zero allocation; the array and the buckets are
2789
- # allocated once per index. (Pool mode forks per worker, so the `Thread.current` slot is
3342
+ # allocated once per index. (Pool mode forks per worker, so the per-thread slot is
2790
3343
  # process-local and never crosses a project boundary — and note that the parent's reported
2791
3344
  # `Memory peak` therefore does not see a worker's share of this at all.)
3345
+ #
3346
+ # ADR-129 WD1 — every bucket's hit goes through `MemoAudit.check`; an audit recomputation gets fresh buckets that
3347
+ # nothing stores, so it reads no entry and leaves none.
2792
3348
  def class_graph_buckets
3349
+ return fresh_class_graph_buckets if MemoAudit.enabled? && MemoAudit.suspended?
3350
+
2793
3351
  discovery = scope.discovery
2794
- slot = Thread.current[CLASS_GRAPH_CACHE_KEY]
3352
+ state = typing_state
3353
+ slot = state[CLASS_GRAPH_CACHE]
2795
3354
  unless slot && slot[0].equal?(discovery)
2796
3355
  # `self_pure` is issue #525's grant scan (identity-keyed by def node); it belongs here because it
2797
3356
  # is a pure function of the same frozen index — the sibling resolver it walks reads nothing else.
2798
3357
  # `singleton_def` is added lazily by {#singleton_def_through_ancestors}'s caller.
2799
- slot = [discovery,
2800
- { user_def: {}, typing_def: {}, self_pure: {}.compare_by_identity, yields: {}.compare_by_identity }]
2801
- Thread.current[CLASS_GRAPH_CACHE_KEY] = slot
3358
+ slot = [discovery, fresh_class_graph_buckets]
3359
+ state[CLASS_GRAPH_CACHE] = slot
2802
3360
  end
2803
3361
  slot[1]
2804
3362
  end
2805
3363
 
3364
+ def fresh_class_graph_buckets
3365
+ { user_def: {}, typing_def: {}, self_pure: {}.compare_by_identity, yields: {}.compare_by_identity }
3366
+ end
3367
+
2806
3368
  # ADR-119 C2-b1 — the TYPING read of {#resolve_user_def_with_owner}: the same `[def_node, owner]` pair, but
2807
3369
  # answered through {DefinerResolution} so a chain that does not stand for the name (a conditional definer,
2808
3370
  # a conditional include, a fork, an external ancestor that may answer first) is `[nil, nil]` and the call
@@ -2817,17 +3379,26 @@ module Rigor
2817
3379
  cache = class_graph_buckets[:typing_def]
2818
3380
  table = (cache[class_name.to_s] ||= {})
2819
3381
  key = method_name.to_sym
2820
- return table[key] if table.key?(key)
2821
-
2822
- table[key] =
2823
- case DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :definer)
2824
- in DefinerResolution::Known(answer: [node, owner])
2825
- [node, owner]
2826
- in DefinerResolution::UNKNOWN
2827
- [nil, nil, every_project_definer(class_name, method_name)]
2828
- in DefinerResolution::ABSENT
2829
- [nil, nil]
3382
+ if table.key?(key)
3383
+ return MemoAudit.check("class graph typing def", table[key], class_name, key) do
3384
+ compute_typing_def_with_owner(class_name, method_name)
2830
3385
  end
3386
+ end
3387
+
3388
+ table[key] = MemoAudit.key_audit("class graph typing def") do
3389
+ compute_typing_def_with_owner(class_name, method_name)
3390
+ end
3391
+ end
3392
+
3393
+ def compute_typing_def_with_owner(class_name, method_name)
3394
+ case DefinerResolution.resolve(scope, class_name, method_name, :instance, question: :definer)
3395
+ in DefinerResolution::Known(answer: [node, owner])
3396
+ [node, owner]
3397
+ in DefinerResolution::UNKNOWN
3398
+ [nil, nil, every_project_definer(class_name, method_name)]
3399
+ in DefinerResolution::ABSENT
3400
+ [nil, nil]
3401
+ end
2831
3402
  end
2832
3403
 
2833
3404
  # ADR-119 C2-b1 errata (the `bot` exception) — an UNKNOWN chain does not say WHICH definer Ruby calls, but
@@ -2841,9 +3412,11 @@ module Rigor
2841
3412
  return nil if defs.nil?
2842
3413
 
2843
3414
  self_fold_safe = fold_safe_call_receiver?(call_node)
3415
+ arg_types, positionals_unknown = positional_correspondence(call_node, arg_types)
2844
3416
  defs.each do |def_node|
2845
3417
  result = infer_user_method_return(def_node, receiver, arg_types,
2846
- self_fold_safe: self_fold_safe, yield_type: block_type)
3418
+ self_fold_safe: self_fold_safe, yield_type: block_type,
3419
+ positionals_unknown: positionals_unknown)
2847
3420
  return nil unless result.is_a?(Type::Bot)
2848
3421
  end
2849
3422
  Type::Combinator.bot
@@ -2918,9 +3491,15 @@ module Rigor
2918
3491
  cache = class_graph_buckets[:user_def]
2919
3492
  table = (cache[class_name.to_s] ||= {})
2920
3493
  key = method_name.to_sym
2921
- return table[key] if table.key?(key)
3494
+ if table.key?(key)
3495
+ return MemoAudit.check("class graph user def", table[key], class_name, key) do
3496
+ compute_user_def_with_owner(class_name, method_name)
3497
+ end
3498
+ end
2922
3499
 
2923
- table[key] = compute_user_def_with_owner(class_name, method_name)
3500
+ table[key] = MemoAudit.key_audit("class graph user def") do
3501
+ compute_user_def_with_owner(class_name, method_name)
3502
+ end
2924
3503
  end
2925
3504
 
2926
3505
  # ADR-24 slice 2 — the walk itself lives on `Scope` ({Scope#user_def_through_ancestors}); it reads
@@ -2957,8 +3536,12 @@ module Rigor
2957
3536
  dynamic_top
2958
3537
  end
2959
3538
 
2960
- OVERRIDE_GATE_CACHE_KEY = RunScopedSlots.register(:__rigor_overridable_method_gate__)
2961
- private_constant :OVERRIDE_GATE_CACHE_KEY
3539
+ OVERRIDE_GATE_CACHE = TypingState.memo(
3540
+ :overridable_method_gate,
3541
+ run_scoped: true, audit: "overridable method gate",
3542
+ reason: "pure functions of the frozen discovery index it keys on"
3543
+ )
3544
+ private_constant :OVERRIDE_GATE_CACHE
2962
3545
 
2963
3546
  # Memo for {#overridden_in_project?}, nested `kind → owner → method_name`. The predicate is a pure
2964
3547
  # function of the scope's frozen discovery tables, so that index's *identity* is what says whether a
@@ -2988,10 +3571,11 @@ module Rigor
2988
3571
  # keyed and bounded differently for exactly that reason.
2989
3572
  def override_gate_buckets
2990
3573
  discovery = scope.discovery
2991
- slot = Thread.current[OVERRIDE_GATE_CACHE_KEY]
3574
+ state = typing_state
3575
+ slot = state[OVERRIDE_GATE_CACHE]
2992
3576
  unless slot && slot[0].equal?(discovery)
2993
3577
  slot = [discovery, { instance: {}, singleton: {} }]
2994
- Thread.current[OVERRIDE_GATE_CACHE_KEY] = slot
3578
+ state[OVERRIDE_GATE_CACHE] = slot
2995
3579
  end
2996
3580
  slot[1]
2997
3581
  end
@@ -3002,10 +3586,18 @@ module Rigor
3002
3586
  # reopen of `owner` itself is NOT an override (monkey-patch reopen shares the owner identity). Memoized
3003
3587
  # per `(owner, method_name, kind)`.
3004
3588
  def overridden_in_project?(owner, method_name, kind)
3589
+ return compute_overridden_in_project?(owner, method_name, kind) if MemoAudit.enabled? && MemoAudit.suspended?
3590
+
3005
3591
  by_owner = (override_gate_buckets[kind][owner] ||= {})
3006
- return by_owner[method_name] if by_owner.key?(method_name)
3592
+ if by_owner.key?(method_name)
3593
+ return MemoAudit.check("overridable method gate", by_owner[method_name], owner, method_name) do
3594
+ compute_overridden_in_project?(owner, method_name, kind)
3595
+ end
3596
+ end
3007
3597
 
3008
- by_owner[method_name] = compute_overridden_in_project?(owner, method_name, kind)
3598
+ by_owner[method_name] = MemoAudit.key_audit("overridable method gate") do
3599
+ compute_overridden_in_project?(owner, method_name, kind)
3600
+ end
3009
3601
  end
3010
3602
 
3011
3603
  def compute_overridden_in_project?(owner, method_name, kind)
@@ -3030,16 +3622,23 @@ module Rigor
3030
3622
  EMPTY_REDEFINERS = [].freeze
3031
3623
  private_constant :EMPTY_REDEFINERS
3032
3624
 
3033
- METHOD_DEFINERS_INDEX_KEY = RunScopedSlots.register(:__rigor_method_definers_index__)
3034
- private_constant :METHOD_DEFINERS_INDEX_KEY
3035
-
3036
- # The second way. Two flat thread slots rather than one slot holding an array of ways: a
3037
- # `Thread.current[KEY] ||= […]` seeds the store with an array LITERAL, which folds to a tuple whose
3038
- # elements are pinned, and the engine then reads the `way[0].equal?(…)` guards below as always-falsey
3039
- # (`make check` fires `flow.always-truthy-condition` on this very file). Two keys read as two plain
3040
- # `Thread.current` reads and keep the guards analysable.
3041
- METHOD_DEFINERS_INDEX_ALT_KEY = RunScopedSlots.register(:__rigor_method_definers_index_alt__)
3042
- private_constant :METHOD_DEFINERS_INDEX_ALT_KEY
3625
+ METHOD_DEFINERS_INDEX = TypingState.memo(
3626
+ :method_definers_index,
3627
+ run_scoped: true, audit: "method definers index",
3628
+ reason: "pure functions of the def tables it keys on"
3629
+ )
3630
+ private_constant :METHOD_DEFINERS_INDEX
3631
+
3632
+ # The second way. Two flat slots rather than one slot holding an array of ways: a `slot ||= […]` seeds the
3633
+ # store with an array LITERAL, which folds to a tuple whose elements are pinned, and the engine then reads the
3634
+ # `way[0].equal?(…)` guards below as always-falsey (`make check` fires `flow.always-truthy-condition` on this
3635
+ # very file). Two slots read as two plain reads and keep the guards analysable.
3636
+ METHOD_DEFINERS_INDEX_ALT = TypingState.memo(
3637
+ :method_definers_index_alt,
3638
+ run_scoped: true, audit: "method definers index alt",
3639
+ reason: "pure functions of the def tables it keys on"
3640
+ )
3641
+ private_constant :METHOD_DEFINERS_INDEX_ALT
3043
3642
 
3044
3643
  # `method_name (Symbol) → [owner names]` inverted index over the instance / singleton def tables,
3045
3644
  # valid for exactly as long as the table it inverts: a new analysis generation, or a `Scope` that swaps
@@ -3080,19 +3679,52 @@ module Rigor
3080
3679
  # def-table-keyed one misses 201 — it would rebuild the seed's 934-entry table once per file and hand
3081
3680
  # back the +22.6 % this bound exists to avoid. Check that number before widening this key to match
3082
3681
  # its siblings.
3682
+ #
3683
+ # ADR-129 WD1 — a hit in either way is audited against an index built with every memo bypassed, compared in
3684
+ # {DefinersIndex.canonical} form (a `Layered` index and a whole one hold the same owners, in any order).
3083
3685
  def method_definers_index(kind)
3084
- def_nodes = scope.discovered_def_nodes
3085
- singleton_def_nodes = scope.discovered_singleton_def_nodes
3086
- way = Thread.current[METHOD_DEFINERS_INDEX_KEY]
3087
- unless way && way[0].equal?(def_nodes) && way[1].equal?(singleton_def_nodes)
3088
- alt = Thread.current[METHOD_DEFINERS_INDEX_ALT_KEY]
3089
- alt = nil unless alt && alt[0].equal?(def_nodes) && alt[1].equal?(singleton_def_nodes)
3090
- Thread.current[METHOD_DEFINERS_INDEX_ALT_KEY] = way
3091
- way = alt || [def_nodes, singleton_def_nodes, nil, nil]
3092
- Thread.current[METHOD_DEFINERS_INDEX_KEY] = way
3093
- end
3094
3686
  singleton = kind == :singleton
3095
- way[singleton ? 3 : 2] ||= DefinersIndex.for(singleton ? singleton_def_nodes : def_nodes)
3687
+ table = singleton ? scope.discovered_singleton_def_nodes : scope.discovered_def_nodes
3688
+ return DefinersIndex.for(table) if MemoAudit.enabled? && MemoAudit.suspended?
3689
+
3690
+ state = typing_state
3691
+ primary = current_definers_way?(state[METHOD_DEFINERS_INDEX])
3692
+ way = primary ? state[METHOD_DEFINERS_INDEX] : rotate_definers_ways(state)
3693
+ position = singleton ? 3 : 2
3694
+ index = way[position]
3695
+ return way[position] = MemoAudit.key_audit("method definers index") { DefinersIndex.for(table) } if index.nil?
3696
+
3697
+ audit_definers_index(primary, index, kind, table) if MemoAudit.enabled?
3698
+ index
3699
+ end
3700
+
3701
+ # Whether `way` was built over this scope's two def tables.
3702
+ def current_definers_way?(way)
3703
+ !way.nil? && way[0].equal?(scope.discovered_def_nodes) && way[1].equal?(scope.discovered_singleton_def_nodes)
3704
+ end
3705
+
3706
+ # Makes the alternate way current when it was built over this scope's def tables, or a fresh way otherwise; the
3707
+ # way it replaces becomes the alternate.
3708
+ def rotate_definers_ways(state)
3709
+ alt = state[METHOD_DEFINERS_INDEX_ALT]
3710
+ alt = nil unless current_definers_way?(alt)
3711
+ state[METHOD_DEFINERS_INDEX_ALT] = state[METHOD_DEFINERS_INDEX]
3712
+ state[METHOD_DEFINERS_INDEX] =
3713
+ alt || [scope.discovered_def_nodes, scope.discovered_singleton_def_nodes, nil, nil]
3714
+ end
3715
+
3716
+ # A hit in the current way (`primary`) or the alternate one, checked in {DefinersIndex.canonical} form.
3717
+ def audit_definers_index(primary, index, kind, table)
3718
+ canonical = DefinersIndex.canonical(index)
3719
+ if primary
3720
+ MemoAudit.check("method definers index", canonical, kind) do
3721
+ DefinersIndex.canonical(DefinersIndex.for(table))
3722
+ end
3723
+ else
3724
+ MemoAudit.check("method definers index alt", canonical, kind) do
3725
+ DefinersIndex.canonical(DefinersIndex.for(table))
3726
+ end
3727
+ end
3096
3728
  end
3097
3729
 
3098
3730
  # True when `candidate`'s ancestor chain (superclasses + included/prepended modules) reaches `owner` —
@@ -3105,18 +3737,30 @@ module Rigor
3105
3737
  !chain.search(scope) { |entry| entry.name == owner }.nil?
3106
3738
  end
3107
3739
 
3108
- INFERENCE_GUARD_KEY = :__rigor_user_method_inference_stack__
3109
- private_constant :INFERENCE_GUARD_KEY
3740
+ # ADR-129 WD3 — an id-determined Resource: {TypingState::State#push} and {TypingState::State#pop} each take a
3741
+ # fresh id, so the stack's contents are fixed while the id is and a read of it refuses nothing.
3742
+ INFERENCE_GUARD = TypingState.resource(
3743
+ :user_method_inference_stack,
3744
+ read_exemption: :id_determined,
3745
+ reason: "the ADR-55 recursion guard, pushed and popped in place by each inference frame"
3746
+ )
3747
+ private_constant :INFERENCE_GUARD
3110
3748
 
3111
- INFERENCE_UNROLL_FUEL_KEY = :__rigor_user_method_unroll_fuel__
3112
- private_constant :INFERENCE_UNROLL_FUEL_KEY
3749
+ INFERENCE_UNROLL_FUEL = TypingState.resource(
3750
+ :user_method_unroll_fuel,
3751
+ reason: "ADR-55 unroll fuel, consumed in place per outermost entry"
3752
+ )
3753
+ private_constant :INFERENCE_UNROLL_FUEL
3113
3754
 
3114
3755
  # ADR-55 slice 2 — thread-local fixpoint return-summary table, keyed by the plain `(receiver, method)`
3115
3756
  # signature (NOT the value-extended signature: extended frames from slice 1 share the same summary).
3116
3757
  # Each entry is `{ assumption:, consulted: }` where `assumption` is the current Kleene iterate (seeded
3117
3758
  # `bot`) and `consulted` flips true when an in-cycle re-entry returns it.
3118
- INFERENCE_SUMMARY_KEY = :__rigor_user_method_return_summary__
3119
- private_constant :INFERENCE_SUMMARY_KEY
3759
+ INFERENCE_SUMMARY = TypingState.resource(
3760
+ :user_method_return_summary,
3761
+ reason: "ADR-55 Kleene summaries and their `:consulted` flag, rewritten in place"
3762
+ )
3763
+ private_constant :INFERENCE_SUMMARY
3120
3764
 
3121
3765
  # Per-thread append-only log of the seed depths of every in-flight summary `consult_summary` read
3122
3766
  # (ADR-55 slice 2 mutual-recursion soundness fix, 2026-06-12). Each fixpoint owner records the guard
@@ -3127,8 +3771,11 @@ module Rigor
3127
3771
  # `untyped` rather than fold one branch's seed into a "final" constant. Own-signature consults log
3128
3772
  # depth == own depth, and a nested fixpoint that completes within the evaluation logs depths > own
3129
3773
  # depth; neither is foreign. Cleared with the summary table when the guard stack drains to empty.
3130
- SUMMARY_CONSULT_DEPTHS_KEY = :__rigor_user_method_summary_consult_depths__
3131
- private_constant :SUMMARY_CONSULT_DEPTHS_KEY
3774
+ SUMMARY_CONSULT_DEPTHS = TypingState.resource(
3775
+ :user_method_summary_consult_depths,
3776
+ reason: "ADR-55 consult log, appended in place and read as a delta"
3777
+ )
3778
+ private_constant :SUMMARY_CONSULT_DEPTHS
3132
3779
 
3133
3780
  # ADR-57 follow-up — run-scoped memo for resolved user-method return types. The ADR-57 gate-open made
3134
3781
  # every resolved in-body self-call adopt the callee's inferred return, which re-types the callee body
@@ -3151,7 +3798,7 @@ module Rigor
3151
3798
  # post-hoc (ADR-84 WD3): candidacy only requires that the plain signature not already be on the
3152
3799
  # recursion guard stack (a frame inside its own cycle returns a Kleene iterate by construction), and
3153
3800
  # the store gate brackets the compute over the transient-event depth log
3154
- # (TRANSIENT_EVENT_DEPTHS_KEY), refusing only when an event referenced a frame BELOW the bracket's
3801
+ # (TRANSIENT_EVENT_DEPTHS), refusing only when an event referenced a frame BELOW the bracket's
3155
3802
  # entry depth — proof the ancestor context (an in-flight ancestor summary, the shared unroll fuel, a
3156
3803
  # possibly-ancestor-caused clamp) influenced the result. Events at-or-above the entry depth are the
3157
3804
  # compute's own deterministic machinery — a recursive method's own converged fixpoint stores from a
@@ -3175,10 +3822,68 @@ module Rigor
3175
3822
  # parse). Only the current generation's bucket is retained (single slot) — a rolled-over run's entries
3176
3823
  # become garbage instead of accumulating across LSP runs, and the run that filled the slot releases it
3177
3824
  # when it ends ({RunScopedSlots}; `Runner#run` keeps its bucket for `#return_summaries`).
3178
- # `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store lives on `Thread.current`; fork-pool
3825
+ # `ExpressionTyper` is rebuilt per `Scope#type_of`, so the store lives in the thread's {TypingState}; fork-pool
3179
3826
  # workers are separate processes, so it never crosses a project boundary (ADR-84 WD5).
3180
- RETURN_MEMO_KEY = RunScopedSlots.register(:__rigor_user_method_return_memo__)
3181
- private_constant :RETURN_MEMO_KEY
3827
+ #
3828
+ # ADR-129 WD1 — a Table, not a Memo: its WD3 serves a standalone result where a fresh compute inside a cycle
3829
+ # would be tainted, and its WD6 serves a stored `untyped`, so it is not extensional. Its reads and stores are
3830
+ # journaled ({#log_return_memo}).
3831
+ # ADR-129 WD3 — the return memo's entry protocol. The slot holds `[generation, bucket]` ({#return_memo_bucket}),
3832
+ # and a journal record keys an entry on `[def_node, memo_key]`. A replay writes into the bucket the slot holds: a
3833
+ # `type_of` memo entry lives no longer than its file's analysis, inside one run generation, so a slot with no
3834
+ # bucket means the entry outlived its run, which is a defect to surface rather than a bucket to invent.
3835
+ module ReturnMemoEntries
3836
+ module_function
3837
+
3838
+ def entry(slot_value, key)
3839
+ per_def = slot_value&.[](1)&.[](key[0])
3840
+ per_def.nil? ? TypingState::ABSENT : per_def.fetch(key[1], TypingState::ABSENT)
3841
+ end
3842
+
3843
+ def put(slot_value, key, value)
3844
+ (slot_value[1][key[0]] ||= {})[key[1]] = value
3845
+ end
3846
+
3847
+ def delete(slot_value, key)
3848
+ per_def = slot_value && slot_value[1][key[0]]
3849
+ per_def&.delete(key[1])
3850
+ end
3851
+
3852
+ def container(state, slot)
3853
+ state[slot] || raise(ArgumentError, "no return memo bucket to replay into: its entry outlived its run")
3854
+ end
3855
+ end
3856
+
3857
+ RETURN_MEMO = TypingState.table(
3858
+ :user_method_return_memo,
3859
+ run_scoped: true, computed: "return memo", entries: ReturnMemoEntries,
3860
+ reason: "ADR-84 return memo; not extensional (its WD3 and WD6), so its reads and stores are journaled"
3861
+ )
3862
+ private_constant :RETURN_MEMO
3863
+
3864
+ # ADR-129 WD1's key audit of a return-memo miss. The key carries the yield value ({YIELD_VALUE}, read through
3865
+ # `key_frame`). {.with_call_site_frame} installs the forwarded-argument list, derived from `arg_types`, repeat
3866
+ # depth zero and none of the caller's jump sinks or retry frames before the key is built, and
3867
+ # {#infer_user_method_return} installs the key-presence walk off, so the computation reads those forms. The
3868
+ # bucket keys on the run generation ({#return_memo_bucket}), within which ADR-130's run-level flags are fixed, so
3869
+ # the key carries them. Two Frames are read without a key term, each a known gap: the block-body threading
3870
+ # suppression (#2782) and Acceptance's unread-shape refusal (#2866).
3871
+ RETURN_MEMO_KEY_AUDIT = MemoAudit.key_declaration(
3872
+ forced: %i[forwarded_call_arg_types reflective_ivar_write_repeating key_presence_walk next_sink break_sink
3873
+ break_value_sink retry_frames],
3874
+ carried: {
3875
+ interface_erasure: "installed around a whole run; the bucket keys on the run generation",
3876
+ interface_dispatch_off: "installed around a whole run; the bucket keys on the run generation"
3877
+ },
3878
+ gaps: {
3879
+ threaded_block_body: "#2782: the return memo reads the block-body threading suppression without keying on " \
3880
+ "it; keying on it cost a fifth of lib's allocations, and clearing it per callee " \
3881
+ "frame re-walked unstorable frames exponentially",
3882
+ acceptance_unread_shape_refused: "#2866: a callee body typed inside Acceptance.refusing_unread " \
3883
+ "reads the caller's refusal, and the return memo does not key on it"
3884
+ }
3885
+ ).freeze
3886
+ private_constant :RETURN_MEMO_KEY_AUDIT
3182
3887
 
3183
3888
  # ADR-84 WD2 — one memo entry: the final inferred return plus, when the entry was computed under
3184
3889
  # ADR-46 dependency recording, the frozen `Analysis::DependencyRecorder::ReadSet` its body walk
@@ -3190,7 +3895,10 @@ module Rigor
3190
3895
  # when every return is unchanged, skips its symbol dependents. Carrying two frozen type refs is
3191
3896
  # negligible on the memo-store path (already in scope) and never enters memo equality (entries are
3192
3897
  # keyed by `memo_key`, retrieved by `.result` / `.read_set`).
3193
- MemoEntry = Data.define(:result, :read_set, :receiver, :arg_types)
3898
+ #
3899
+ # Issue #1237 — `positionals_unknown` marks an entry a splat call stored ({#positional_correspondence}): the
3900
+ # session re-drives its key the same way ({#return_type_for}).
3901
+ MemoEntry = Data.define(:result, :read_set, :receiver, :arg_types, :positionals_unknown)
3194
3902
  private_constant :MemoEntry
3195
3903
 
3196
3904
  # ADR-84 WD3 — thread-local transient-machinery event log, appended by `note_transient_fallback` at
@@ -3246,8 +3954,11 @@ module Rigor
3246
3954
  # sense above — position 0 is always a sound (conservative) choice; spec/rigor/inference/
3247
3955
  # return_memo_taint_spec.rb pins this (no raw `BudgetTrace.hit` on the recursion categories outside
3248
3956
  # the helper).
3249
- TRANSIENT_EVENT_DEPTHS_KEY = :__rigor_user_method_transient_event_depths__
3250
- private_constant :TRANSIENT_EVENT_DEPTHS_KEY
3957
+ TRANSIENT_EVENT_DEPTHS = TypingState.resource(
3958
+ :user_method_transient_event_depths,
3959
+ reason: "ADR-84 WD3 event log, appended in place and read as a delta"
3960
+ )
3961
+ private_constant :TRANSIENT_EVENT_DEPTHS
3251
3962
 
3252
3963
  # Per-inference recursion context threaded through the guard / fixpoint helpers (ADR-55 slice 2).
3253
3964
  # Bundles the call descriptor (`receiver`, `arg_types`, `plain_signature`), the thread-local summary
@@ -3255,7 +3966,7 @@ module Rigor
3255
3966
  # carried separately (it is the body owner, not call context).
3256
3967
  RecursionContext = Data.define(
3257
3968
  :receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded,
3258
- :self_fold_safe
3969
+ :self_fold_safe, :positionals_unknown
3259
3970
  )
3260
3971
  private_constant :RecursionContext
3261
3972
 
@@ -3295,24 +4006,27 @@ module Rigor
3295
4006
  # The return memo and the recursion guard do not key on them, which holds because such a node comes only from
3296
4007
  # `DefNodeResolver.refinement_query`'s own parse and always carries that file's one list; a caller that types
3297
4008
  # the same node under another list must add it to the key.
4009
+ #
4010
+ # Issue #1237 — `positionals_unknown:` re-types the body with no positional correspondence
4011
+ # ({#positional_correspondence}): `arg_types` is then the call's keyword shape alone. Such a frame reads and
4012
+ # stores the return memo under a key of its own ({#return_memo_key}), which the incremental session re-drives
4013
+ # with the same flag ({#return_type_for}), and installs no `...` forwarding list.
3298
4014
  def infer_user_method_return(def_node, receiver, arg_types, self_fold_safe: false, yield_type: nil,
3299
- refinements: nil)
4015
+ refinements: nil, positionals_unknown: false)
3300
4016
  return nil if def_node.body.nil?
3301
- unless KeyPresenceGuard.active?
3302
- return infer_user_method_return_unguarded(def_node, receiver, arg_types, self_fold_safe, yield_type,
3303
- refinements)
3304
- end
3305
4017
 
3306
- KeyPresenceGuard.without_guards do
3307
- infer_user_method_return_unguarded(def_node, receiver, arg_types, self_fold_safe, yield_type, refinements)
3308
- end
4018
+ call = [def_node, receiver, arg_types, self_fold_safe, yield_type, refinements, positionals_unknown]
4019
+ return infer_user_method_return_unguarded(*call) unless KeyPresenceGuard.forcing_off?
4020
+
4021
+ KeyPresenceGuard.without_guards { infer_user_method_return_unguarded(*call) }
3309
4022
  end
3310
4023
 
3311
4024
  def infer_user_method_return_unguarded(def_node, receiver, arg_types, self_fold_safe, yield_type,
3312
- refinements = nil)
4025
+ refinements, positionals_unknown)
3313
4026
  yield_type = nil unless yield_type && body_yields?(def_node)
3314
4027
  body_scope = build_user_method_body_scope(def_node, receiver, arg_types,
3315
- self_fold_safe: self_fold_safe)
4028
+ self_fold_safe: self_fold_safe,
4029
+ positionals_unknown: positionals_unknown)
3316
4030
  return nil if body_scope.nil?
3317
4031
 
3318
4032
  body_scope = body_scope.with_declared_refinements(refinements) if refinements
@@ -3325,8 +4039,7 @@ module Rigor
3325
4039
  # resolving during the main walk. `describe(:short)` keeps non-Nominal receivers (the implicit
3326
4040
  # `Object` carrier for top-level / DSL-block defs) printable.
3327
4041
  plain_signature = [receiver.describe(:short), def_node.name]
3328
- stack = (Thread.current[INFERENCE_GUARD_KEY] ||= [])
3329
- summaries = (Thread.current[INFERENCE_SUMMARY_KEY] ||= {})
4042
+ stack = typing_state.stack(INFERENCE_GUARD)
3330
4043
 
3331
4044
  # WD0 (RIGOR_BUDGET_TRACE) — one entry into user-method return inference. No-op when disabled.
3332
4045
  BudgetTrace.hit(BudgetTrace::MEMO_ENTRIES)
@@ -3349,11 +4062,12 @@ module Rigor
3349
4062
  # below reads it back from here rather than taking a second parameter, so key and frame cannot
3350
4063
  # disagree). It is installed even when nil, which is what stops a blockless callee reached from
3351
4064
  # inside a yielding body from inheriting the outer caller's block.
3352
- ExpressionTyper.with_call_site_frame(yield_type, forwarded_frame_types(def_node, arg_types)) do
4065
+ forwarded = positionals_unknown ? nil : forwarded_frame_types(def_node, arg_types)
4066
+ ExpressionTyper.with_call_site_frame(yield_type, forwarded) do
3353
4067
  unless memo_candidate?(stack, plain_signature)
3354
4068
  trace_memo_refusal(stack, plain_signature)
3355
- next compute_user_method_return(def_node, body_scope, stack, summaries,
3356
- receiver, arg_types, plain_signature)
4069
+ next compute_user_method_return(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4070
+ positionals_unknown: positionals_unknown)
3357
4071
  end
3358
4072
 
3359
4073
  # INVARIANT (ADR-46 recording soundness, ADR-84 WD2) — the deep cross-file dependency edges (the
@@ -3370,8 +4084,8 @@ module Rigor
3370
4084
  # (ActiveStorage video_analyzer.rb's subtree, 0.43s -> >90s; PR #79) and stays rejected. Pinned by
3371
4085
  # spec/rigor/inference/return_memo_recording_spec.rb (cross-file replay completeness) and
3372
4086
  # dependency_recorder_spec.rb's transitive deep-edge example.
3373
- consult_and_store_return_memo(def_node, body_scope, stack, summaries,
3374
- receiver, arg_types, plain_signature)
4087
+ consult_and_store_return_memo(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4088
+ positionals_unknown: positionals_unknown)
3375
4089
  end
3376
4090
  end
3377
4091
 
@@ -3392,9 +4106,15 @@ module Rigor
3392
4106
  # replaced with the analysis generation cannot outlive the nodes it holds.
3393
4107
  def body_yields?(def_node)
3394
4108
  cache = class_graph_buckets[:yields]
3395
- return cache[def_node] if cache.key?(def_node)
4109
+ if cache.key?(def_node)
4110
+ return MemoAudit.check("class graph yields", cache[def_node], def_node.name) do
4111
+ yield_reachable?(def_node.body, def_node.parameters&.block&.name)
4112
+ end
4113
+ end
3396
4114
 
3397
- cache[def_node] = yield_reachable?(def_node.body, def_node.parameters&.block&.name)
4115
+ cache[def_node] = MemoAudit.key_audit("class graph yields") do
4116
+ yield_reachable?(def_node.body, def_node.parameters&.block&.name)
4117
+ end
3398
4118
  end
3399
4119
 
3400
4120
  def yield_reachable?(node, block_name)
@@ -3425,7 +4145,7 @@ module Rigor
3425
4145
  # The candidate-frame memo path: consult the current run generation's bucket, and on a miss compute
3426
4146
  # and store a FINAL result. Reached only when `memo_candidate?` held (see `infer_user_method_return`).
3427
4147
  # The store gate (ADR-84 WD3): a result is stored when no transient-machinery event during the
3428
- # bracket referenced a stack frame BELOW the bracket's entry depth (see TRANSIENT_EVENT_DEPTHS_KEY).
4148
+ # bracket referenced a stack frame BELOW the bracket's entry depth (see TRANSIENT_EVENT_DEPTHS).
3429
4149
  # A separate ADR-55 fixpoint-consult bracket counter used to sit in front of this test; it is gone
3430
4150
  # (issue #875) because the event log already subsumes it — the sole `consult_summary` call site is
3431
4151
  # the in-cycle guard hit, which logs its event at the consulted owner's position first, so a consult
@@ -3439,8 +4159,11 @@ module Rigor
3439
4159
  # is already `Dynamic[top]` is stored anyway — see `top_result?`. A hit under ADR-46 recording
3440
4160
  # replays the entry's captured read-set into the current consumer (see the INVARIANT comment at the
3441
4161
  # call site).
3442
- def consult_and_store_return_memo(def_node, body_scope, stack, summaries,
3443
- receiver, arg_types, plain_signature)
4162
+ #
4163
+ # ADR-129 WD3 — the ADR-55 summaries are not read here: a hit neither reads nor writes them, and a miss's
4164
+ # compute takes them ({#return_summaries}).
4165
+ def consult_and_store_return_memo(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4166
+ positionals_unknown: false)
3444
4167
  per_def = (return_memo_bucket[def_node] ||= {})
3445
4168
  # Issue #525 — the `:self` fold-safety grant is a THIRD call-site-varying dimension: the same
3446
4169
  # `(receiver, arg_types)` body returns a folded member type when the caller's receiver expression
@@ -3451,11 +4174,8 @@ module Rigor
3451
4174
  # passed in for the same reason: a yielding callee's return depends on the block THIS call site
3452
4175
  # hands it, so two call sites passing differently-typed blocks must not share an entry. It is nil
3453
4176
  # for every def that cannot reach a `yield`, which is where the memo's mass is.
3454
- memo_key = [receiver.describe(:short),
3455
- arg_types.map { |type| type.describe(:short) },
3456
- body_scope.struct_fold_safe?(:self),
3457
- ExpressionTyper.current_yield_value_type&.describe(:short)]
3458
- if (entry = per_def[memo_key])
4177
+ memo_key = return_memo_key(body_scope, receiver, arg_types, positionals_unknown: positionals_unknown)
4178
+ if (entry = recall_return_memo(per_def, def_node, memo_key))
3459
4179
  BudgetTrace.hit(BudgetTrace::MEMO_HITS)
3460
4180
  Analysis::DependencyRecorder.replay(entry.read_set) if Analysis::DependencyRecorder.active?
3461
4181
  return entry.result
@@ -3465,20 +4185,65 @@ module Rigor
3465
4185
 
3466
4186
  entry_depth = stack.size
3467
4187
  event_mark = transient_event_mark
3468
- result, read_set = compute_with_read_capture(def_node, body_scope, stack, summaries,
3469
- receiver, arg_types, plain_signature)
4188
+ result, read_set = MemoAudit.key_audit("return memo", RETURN_MEMO_KEY_AUDIT, memo_key) do
4189
+ compute_with_read_capture(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4190
+ positionals_unknown)
4191
+ end
3470
4192
 
3471
4193
  if context_tainted?(event_mark, entry_depth) && !top_result?(result)
3472
4194
  BudgetTrace.hit(BudgetTrace::MEMO_REFUSE_TRANSIENT)
4195
+ per_def[RETURN_MEMO_REFUSED] = true
3473
4196
  else
3474
- per_def[memo_key] = MemoEntry.new(result: result, read_set: read_set,
3475
- receiver: receiver, arg_types: arg_types)
4197
+ store_return_memo(per_def, def_node, memo_key,
4198
+ MemoEntry.new(result: result, read_set: read_set, receiver: receiver, arg_types: arg_types,
4199
+ positionals_unknown: positionals_unknown))
3476
4200
  end
3477
4201
  result
3478
4202
  end
3479
4203
 
4204
+ # The return memo's key: the call descriptor, the body scope's `:self` grant, and the yield-value Frame, read as a
4205
+ # key term (ADR-129 WD1's key audit). Issue #1237 — a frame with no positional correspondence keys one term more,
4206
+ # {POSITIONALS_UNKNOWN_KEY}, so it never shares an entry with a positional call whose list reads the same; every
4207
+ # other key is as it was.
4208
+ def return_memo_key(body_scope, receiver, arg_types, positionals_unknown: false)
4209
+ MemoAudit.key do
4210
+ key = [receiver.describe(:short),
4211
+ arg_types.map { |type| type.describe(:short) },
4212
+ body_scope.struct_fold_safe?(:self),
4213
+ typing_state.key_frame(YIELD_VALUE)&.describe(:short)]
4214
+ positionals_unknown ? key << POSITIONALS_UNKNOWN_KEY : key
4215
+ end
4216
+ end
4217
+
4218
+ POSITIONALS_UNKNOWN_KEY = :positionals_unknown
4219
+ private_constant :POSITIONALS_UNKNOWN_KEY
4220
+
4221
+ # The entry `per_def` holds for `memo_key`, or nil, journaled as a Table read (ADR-129 WD3).
4222
+ def recall_return_memo(per_def, def_node, memo_key)
4223
+ entry = per_def[memo_key]
4224
+ log_return_memo(:read, def_node, memo_key, entry || TypingState::ABSENT)
4225
+ entry
4226
+ end
4227
+
4228
+ # Stores `entry` for `memo_key`, journaled as a Table write (ADR-129 WD3), logged before the store so a priors
4229
+ # window notes the entry it replaces.
4230
+ def store_return_memo(per_def, def_node, memo_key, entry)
4231
+ log_return_memo(:write, def_node, memo_key, entry)
4232
+ per_def[memo_key] = entry
4233
+ end
4234
+
4235
+ # ADR-129 WD3 — journals a return-memo read or store under `[def_node, memo_key]`. The composite key is built
4236
+ # only while a capture is open, so a run with the `type_of` memo off allocates nothing here.
4237
+ def log_return_memo(kind, def_node, memo_key, value)
4238
+ state = typing_state
4239
+ return unless state.capturing?
4240
+
4241
+ key = [def_node, memo_key].freeze
4242
+ kind == :read ? state.log_read(RETURN_MEMO, key, value) : state.log_write(RETURN_MEMO, key, value)
4243
+ end
4244
+
3480
4245
  def transient_event_mark
3481
- Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]&.size || 0
4246
+ typing_state.read(TRANSIENT_EVENT_DEPTHS)&.size || 0
3482
4247
  end
3483
4248
 
3484
4249
  # ADR-84 WD3 — true when a transient-machinery event logged during the bracket (entries past
@@ -3490,7 +4255,7 @@ module Rigor
3490
4255
  # outermost entry, so the suffix slice allocated an array per candidate compute — a term quadratic in
3491
4256
  # the event count, and the `ary_ensure_room_for_push` / GC-sweep profile issue #870 sampled.
3492
4257
  def context_tainted?(event_mark, entry_depth)
3493
- log = Thread.current[TRANSIENT_EVENT_DEPTHS_KEY]
4258
+ log = typing_state.read(TRANSIENT_EVENT_DEPTHS)
3494
4259
  return false if log.nil? || log.size <= event_mark
3495
4260
 
3496
4261
  index = event_mark
@@ -3522,31 +4287,29 @@ module Rigor
3522
4287
  # happen in practice is the byte-identical `--format json` runs over `lib`, `plugins/*/lib`,
3523
4288
  # `examples/*/lib` and the survey projects recorded on issue #872.
3524
4289
  def top_result?(result)
3525
- result.equal?(Type::Combinator.untyped)
4290
+ Type::Combinator.untyped?(result)
3526
4291
  end
3527
4292
 
3528
4293
  # ADR-84 WD2 — the memo-miss compute, wrapped in a `DependencyRecorder.capture` window when ADR-46
3529
4294
  # recording is active so the entry can carry its replayable read-set. Returns `[result, read_set]`
3530
4295
  # (`read_set` nil when not recording).
3531
- def compute_with_read_capture(def_node, body_scope, stack, summaries,
3532
- receiver, arg_types, plain_signature)
4296
+ def compute_with_read_capture(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4297
+ positionals_unknown)
4298
+ call = [def_node, body_scope, stack, receiver, arg_types, plain_signature]
3533
4299
  unless Analysis::DependencyRecorder.active?
3534
- return [compute_user_method_return(def_node, body_scope, stack, summaries,
3535
- receiver, arg_types, plain_signature), nil]
4300
+ return [compute_user_method_return(*call, positionals_unknown: positionals_unknown), nil]
3536
4301
  end
3537
4302
 
3538
- Analysis::DependencyRecorder.capture do
3539
- compute_user_method_return(def_node, body_scope, stack, summaries,
3540
- receiver, arg_types, plain_signature)
3541
- end
4303
+ Analysis::DependencyRecorder.capture { compute_user_method_return(*call, positionals_unknown: positionals_unknown) }
3542
4304
  end
3543
4305
 
3544
4306
  # The ADR-55 recursion-guard + value-unroll + fixpoint body of user-method return inference, factored
3545
4307
  # out so `infer_user_method_return` is a thin memo wrapper (the memo is the ADR-57 follow-up; this is
3546
4308
  # unchanged from pre-memo behaviour).
3547
- def compute_user_method_return(def_node, body_scope, stack, summaries,
3548
- receiver, arg_types, plain_signature)
4309
+ def compute_user_method_return(def_node, body_scope, stack, receiver, arg_types, plain_signature,
4310
+ positionals_unknown: false)
3549
4311
  trace_body_eval(plain_signature)
4312
+ summaries = return_summaries
3550
4313
  # ADR-55 slice 1: when every bound argument is value-pinned, extend the guard key with a stable
3551
4314
  # descriptor of the argument *values* so distinct constant frames may recurse (e.g. `factorial(5)`
3552
4315
  # folds to `Constant[120]`). Distinct constant frames are bounded by `RECURSION_UNROLL_FUEL` per
@@ -3566,7 +4329,11 @@ module Rigor
3566
4329
  # `bot`) instead of bare `untyped`. The fixpoint loop below seeds the entry on the outermost frame;
3567
4330
  # if a re-entry beats it here the entry already exists. The WD4 composition: slice 1's clamp/fuel
3568
4331
  # fallbacks also route here when a summary is active.
3569
- return consult_summary(summaries, plain_signature)
4332
+ # Issue #3162 — the summary is read only where its frame covers the re-entry's own parameter
4333
+ # bindings and block. A value-keyed re-entry is tested like any other: the unroll frame it
4334
+ # re-enters holds its constants, but the summary it reads is the outermost frame's.
4335
+ floor = uncovered_reentry_floor(summaries[plain_signature], def_node, body_scope, positionals_unknown)
4336
+ return floor || consult_summary(summaries, plain_signature)
3570
4337
  end
3571
4338
 
3572
4339
  # ADR-55 WD1 clamp (governing rule): the constant-arg unroll may only ever surface a fully
@@ -3586,11 +4353,16 @@ module Rigor
3586
4353
  # Issue #525 — read off the built scope so the bot-collapse retry rebuilds a widened scope with
3587
4354
  # the SAME `:self` polarity the first attempt used; a retry that silently dropped the grant would
3588
4355
  # return a different type for the same call.
3589
- self_fold_safe: body_scope.struct_fold_safe?(:self)
4356
+ self_fold_safe: body_scope.struct_fold_safe?(:self),
4357
+ positionals_unknown: positionals_unknown
3590
4358
  )
3591
4359
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
3592
4360
  end
3593
4361
 
4362
+ # ADR-55 — the thread's in-flight fixpoint summaries, created on first use. Taken only by a compute, never on a
4363
+ # return-memo hit (ADR-129 WD3), and cleared when the guard stack drains.
4364
+ def return_summaries = typing_state.acquire(INFERENCE_SUMMARY) { {} }
4365
+
3594
4366
  # Issue #1125 — the argument list a `def m(...)` body's `...` re-supplies, or nil for a def without the
3595
4367
  # forwarding parameter (whose body cannot contain a `...` call at all) and for a call that does not even
3596
4368
  # satisfy `m`'s own required positionals (that call raises at runtime; the body is not worth re-typing).
@@ -3609,8 +4381,9 @@ module Rigor
3609
4381
  kw_shape = positional.pop if takes_keywords?(params) && positional.last.is_a?(Type::HashShape)
3610
4382
  return nil if positional.size < params.requireds.size
3611
4383
 
4384
+ # Frozen: a Frame value does not change while it is installed (ADR-129 WD1).
3612
4385
  tail = positional[(params.requireds.size + params.optionals.size)..] || []
3613
- kw_shape ? tail + [kw_shape] : tail
4386
+ (kw_shape ? tail + [kw_shape] : tail).freeze
3614
4387
  end
3615
4388
 
3616
4389
  # True when this frame's result is a candidate for the return memo: the one structural precondition,
@@ -3631,13 +4404,14 @@ module Rigor
3631
4404
  # `def_nodes` table identity for runner-less scopes (single-file probes) — the pre-WD2 per-file
3632
4405
  # scope. A generation mismatch drops the previous bucket whole, so a re-run in one process (LSP,
3633
4406
  # ADR-62 warm loop) can neither hit stale entries nor accumulate them. The bucket maps `def_node`
3634
- # (object identity, held strongly — see RETURN_MEMO_KEY) to its per-descriptor entries.
4407
+ # (object identity, held strongly — see RETURN_MEMO) to its per-descriptor entries.
3635
4408
  def return_memo_bucket
3636
4409
  generation = scope.run_generation || scope.discovered_def_nodes
3637
- slot = Thread.current[RETURN_MEMO_KEY]
4410
+ state = typing_state
4411
+ slot = state[RETURN_MEMO]
3638
4412
  unless slot && slot[0].equal?(generation)
3639
4413
  slot = [generation, {}.compare_by_identity]
3640
- Thread.current[RETURN_MEMO_KEY] = slot
4414
+ state[RETURN_MEMO] = slot
3641
4415
  end
3642
4416
  slot[1]
3643
4417
  end
@@ -3687,10 +4461,10 @@ module Rigor
3687
4461
  # referenced frame's stack position to the load-bearing thread-local event log (regardless of
3688
4462
  # RIGOR_BUDGET_TRACE) and forwards the category to `BudgetTrace`. The taint spec pins that no
3689
4463
  # recursion-machinery `BudgetTrace.hit` exists outside this helper, so a future fallback cannot
3690
- # silently join unlogged (see the audit table at TRANSIENT_EVENT_DEPTHS_KEY; `context_depth` 0 is
4464
+ # silently join unlogged (see the audit table at TRANSIENT_EVENT_DEPTHS; `context_depth` 0 is
3691
4465
  # always a sound conservative choice).
3692
4466
  def note_transient_fallback(category, context_depth)
3693
- (Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] ||= []) << context_depth
4467
+ typing_state.append(TRANSIENT_EVENT_DEPTHS, context_depth)
3694
4468
  BudgetTrace.hit(category)
3695
4469
  end
3696
4470
 
@@ -3702,25 +4476,53 @@ module Rigor
3702
4476
  # The outermost frame for this plain signature owns the summary entry and runs the fixpoint loop.
3703
4477
  # ADR-55 WD2.
3704
4478
  outermost = stack.none? { |frame| plain_part(frame) == context.plain_signature }
3705
- stack.push(signature)
4479
+ first_unpinned = first_unpinned_frame?(stack, signature, context)
4480
+ state = typing_state
4481
+ state.push(INFERENCE_GUARD, signature)
3706
4482
  begin
3707
4483
  if outermost
3708
4484
  fixpoint_user_method_return(def_node, body_scope, context)
4485
+ elsif first_unpinned
4486
+ nested_fixpoint_user_method_return(def_node, body_scope, context)
3709
4487
  else
3710
4488
  type, = evaluate_body_with_returns(body_scope, def_node.body)
3711
4489
  clamp_unroll_result(type, context.would_have_been_guarded)
3712
4490
  end
3713
4491
  ensure
3714
- stack.pop
4492
+ state.pop(INFERENCE_GUARD)
3715
4493
  if stack.empty?
3716
- Thread.current[INFERENCE_UNROLL_FUEL_KEY] = nil
3717
- Thread.current[INFERENCE_SUMMARY_KEY] = nil
3718
- Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] = nil
3719
- Thread.current[TRANSIENT_EVENT_DEPTHS_KEY] = nil
4494
+ state.write(INFERENCE_UNROLL_FUEL, nil)
4495
+ state.write(INFERENCE_SUMMARY, nil)
4496
+ state.write(SUMMARY_CONSULT_DEPTHS, nil)
4497
+ state.write(TRANSIENT_EVENT_DEPTHS, nil)
3720
4498
  end
3721
4499
  end
3722
4500
  end
3723
4501
 
4502
+ # Issue #3162 — whether this frame is the first of its signature with an unpinned argument: every
4503
+ # frame of the signature below it is a constant-argument unroll frame, and the recursion has now
4504
+ # passed something that is no constant (`rec(k - 1, *rest)` out of `rec(3, 1)`). The outermost
4505
+ # frame's summary is the return of a body typed for its constants, which does not cover this
4506
+ # frame's re-entries, so this frame owns a summary of its own. A frame that is all constants but
4507
+ # out of unroll fuel is not one: its re-entries keep reading the outermost frame's summary, whose
4508
+ # `bot` sends the outermost frame to the widened retry. Asked before the frame is pushed.
4509
+ def first_unpinned_frame?(stack, signature, context)
4510
+ plain_signature = context.plain_signature
4511
+ signature.equal?(plain_signature) && stack.none?(plain_signature) &&
4512
+ constant_argument_value_key(context.arg_types).nil?
4513
+ end
4514
+
4515
+ # The fixpoint of a {#first_unpinned_frame?} frame, run over its own summary entry; the outermost
4516
+ # frame's entry is put back afterwards.
4517
+ def nested_fixpoint_user_method_return(def_node, body_scope, context)
4518
+ summaries = context.summaries
4519
+ outer = summaries[context.plain_signature]
4520
+ fixpoint_user_method_return(def_node, body_scope, context)
4521
+ ensure
4522
+ summaries[context.plain_signature] = outer
4523
+ LoopValueRecord.advance_generation
4524
+ end
4525
+
3724
4526
  # Evaluates a method body and joins the value types of every explicit `return value` reached during
3725
4527
  # the walk with the body's tail type.
3726
4528
  #
@@ -3749,14 +4551,11 @@ module Rigor
3749
4551
  def fixpoint_user_method_return(def_node, body_scope, context, widened: false)
3750
4552
  plain_signature = context.plain_signature
3751
4553
  summaries = context.summaries
3752
- depth = seed_fixpoint_summary(summaries, plain_signature)
3753
- consult_depths = (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= [])
4554
+ depth = seed_fixpoint_summary(summaries, plain_signature, body_scope, context.receiver)
3754
4555
 
3755
- RECURSION_FIXPOINT_CAP.times do |iteration|
3756
- summaries[plain_signature][:consulted] = false
3757
- consult_mark = consult_depths.size
3758
- type, = evaluate_body_with_returns(body_scope, def_node.body)
3759
- computed = clamp_unroll_result(type, context.would_have_been_guarded)
4556
+ iteration = 0
4557
+ while iteration < RECURSION_FIXPOINT_CAP
4558
+ computed, entangled = evaluate_fixpoint_iterate(def_node, body_scope, context, depth)
3760
4559
 
3761
4560
  # Cross-signature mutual recursion (ADR-55 soundness fix, 2026-06-12): the evaluation consulted an
3762
4561
  # ANCESTOR signature's in-flight summary (seed depth shallower than this frame's), so `computed`
@@ -3764,9 +4563,7 @@ module Rigor
3764
4563
  # `Constant[false]`. The per-signature iteration below cannot converge such an entangled pair (each
3765
4564
  # side's iterate is conditioned on the other's unfinished assumption), so degrade this frame to the
3766
4565
  # sound `untyped` floor instead of surfacing a one-sided value.
3767
- if consult_depths[consult_mark..].any? { |d| d < depth }
3768
- return degrade_entangled_fixpoint(summaries, plain_signature)
3769
- end
4566
+ return degrade_entangled_fixpoint(summaries, plain_signature) if entangled
3770
4567
 
3771
4568
  # The summary was never consulted — the method did not recurse on this evaluation, so there is no
3772
4569
  # fixpoint to chase. Return the computed type directly (pre-fixpoint behaviour for non-recursive
@@ -3784,26 +4581,153 @@ module Rigor
3784
4581
  return resolved unless resolved.nil?
3785
4582
  end
3786
4583
 
4584
+ # Issue #3162 — a re-entry read the summary for arguments this frame's parameters do not cover
4585
+ # (`acc(n - 1, k + 1)` against `k = 1`), so `computed` holds this frame's base case as that
4586
+ # call's answer. The evaluation is discarded and the body typed again with such a re-entry
4587
+ # answering `Dynamic[top]`; it is not a Kleene step.
4588
+ next if floored_uncovered_reentries?(summaries[plain_signature])
4589
+
3787
4590
  converged = fixpoint_step(summaries, plain_signature, computed, iteration)
3788
4591
  return converged if converged
4592
+
4593
+ iteration += 1
3789
4594
  end
3790
4595
 
3791
4596
  # Out of iterations and still unstable. The collapse is the loop's own tail rather than a branch
3792
- # inside `fixpoint_step`: `Integer#times` yields its receiver when no iteration returns, so a
3793
- # tail-less loop reads as a path that returns an Integer summary (the `def.return-type-mismatch`
3794
- # on `return_type_for` this shape retired), and the loop, not the step, owns its cap.
4597
+ # inside `fixpoint_step`: the loop, not the step, owns its cap.
3795
4598
  collapse_fixpoint_cap(summaries, plain_signature)
3796
4599
  end
3797
4600
 
4601
+ # One body evaluation of the fixpoint loop, as `[computed, entangled]`: the clamped return, and
4602
+ # whether the evaluation consulted an ancestor signature's in-flight summary (one seeded shallower
4603
+ # than `depth`). Clears the summary's per-evaluation record first.
4604
+ def evaluate_fixpoint_iterate(def_node, body_scope, context, depth)
4605
+ entry = context.summaries[context.plain_signature]
4606
+ entry[:consulted] = false
4607
+ entry[:uncovered] = false
4608
+ consult_depths = typing_state.acquire(SUMMARY_CONSULT_DEPTHS) { [] }
4609
+ consult_mark = consult_depths.size
4610
+ type, = evaluate_body_with_returns(body_scope, def_node.body)
4611
+ computed = clamp_unroll_result(type, context.would_have_been_guarded)
4612
+ [computed, consult_depths[consult_mark..].any? { |consulted| consulted < depth }]
4613
+ end
4614
+
4615
+ # Switches the summary to its floor after an evaluation in which an uncovered re-entry read it, and
4616
+ # says whether it did. Once on the floor such a re-entry never reads the summary again, so the
4617
+ # switch happens at most once per fixpoint.
4618
+ #
4619
+ # A `bot` iterate has been to the widened retry first. When that retry found no exit either
4620
+ # (`spin`), the entry here is the retry's own, which noted nothing, and the answer stays `bot`.
4621
+ def floored_uncovered_reentries?(entry)
4622
+ return false unless entry[:uncovered] && !entry[:floor]
4623
+
4624
+ entry[:floor] = true
4625
+ LoopValueRecord.advance_generation
4626
+ true
4627
+ end
4628
+
4629
+ # Issue #3162 — the answer of a re-entry the summary's frame does not cover, or nil for one that
4630
+ # reads the summary. The uncovered re-entry is noted on the entry; until the fixpoint owner has
4631
+ # switched the summary to its floor ({#floored_uncovered_reentries?}) it still reads the summary,
4632
+ # so a body whose iterate is `bot` reaches the widened retry as before. On the floor it answers
4633
+ # `untyped`, the pre-slice-2 observable for an in-cycle call.
4634
+ def uncovered_reentry_floor(entry, def_node, body_scope, positionals_unknown)
4635
+ return nil if entry.nil?
4636
+ return nil if summary_covers_reentry?(entry, def_node, body_scope.locals, positionals_unknown)
4637
+
4638
+ entry[:uncovered] = true
4639
+ return nil unless entry[:floor]
4640
+
4641
+ scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
4642
+ Type::Combinator.untyped
4643
+ end
4644
+
4645
+ # Issue #3162 — whether the summary's frame covers a re-entry's parameter bindings (`locals`, as the
4646
+ # guard site built them): every parameter of the frame holds a type that includes what the re-entry
4647
+ # bound it to. The summary is the return of the frame's body under the frame's bindings, so it is
4648
+ # the re-entry's return only then; a parameter passed through unchanged is the same type object.
4649
+ #
4650
+ # A re-entry through a call-site `*splat` has no positional correspondence, and its binder holds
4651
+ # `Dynamic[top]` for every positional. There the def's own calls are read instead
4652
+ # ({RecursionRebinding}): a positional every one of them passes through unchanged (`*rest` as
4653
+ # `*rest`) is covered, and any other is not.
4654
+ #
4655
+ # The block is part of the frame too: `yield` answers the frame's block ({#reentry_block_covered?}).
4656
+ def summary_covers_reentry?(entry, def_node, locals, positionals_unknown)
4657
+ return false unless reentry_block_covered?(entry, def_node)
4658
+
4659
+ kept = (entry[:kept] ||= kept_positional_parameters(def_node, entry[:receiver])) if positionals_unknown
4660
+ frame = entry[:locals]
4661
+ locals.all? do |name, argument|
4662
+ bound = frame[name]
4663
+ bound.nil? || kept&.include?(name) || binding_covers?(bound, argument)
4664
+ end
4665
+ end
4666
+
4667
+ # Whether the re-entry runs with a block the summary's frame covers. A re-entry with no block, or
4668
+ # one that passes the def's own block parameter on (`&blk`, or the anonymous `&`), does; `&blk` is
4669
+ # the frame's block only while the body never assigns `blk` and no block or lambda in it declares
4670
+ # the name ({RecursionRebinding}'s write and block-local scan, as for a positional). One that
4671
+ # passes another `&expression` does not. A block literal does when the value it yields is covered
4672
+ # by the value the frame's block yields, which is nil on both sides for a def that never `yield`s.
4673
+ # The re-entry's call is the node being typed; with none to read, only the yield values are compared.
4674
+ def reentry_block_covered?(entry, def_node)
4675
+ block = @typing_node.block if @typing_node.is_a?(Prism::CallNode)
4676
+ return true if @typing_node.is_a?(Prism::CallNode) && block.nil?
4677
+ return own_block_passed?(def_node, block) if block.is_a?(Prism::BlockArgumentNode)
4678
+
4679
+ yielded = ExpressionTyper.current_yield_value_type
4680
+ return true if yielded.nil?
4681
+
4682
+ frame_yield = entry[:yield]
4683
+ !frame_yield.nil? && binding_covers?(frame_yield, yielded)
4684
+ end
4685
+
4686
+ def own_block_passed?(def_node, block)
4687
+ params = def_node.parameters
4688
+ own = params.block if params.is_a?(Prism::ParametersNode)
4689
+ return false if own.nil?
4690
+
4691
+ expression = block.expression
4692
+ return own.name.nil? if expression.nil?
4693
+ return false unless expression.is_a?(Prism::LocalVariableReadNode) && expression.name == own.name
4694
+
4695
+ written = RecursionRebinding.rebound_parameters(def_node, Set.new)
4696
+ written != RecursionRebinding::ALL && !written.include?(own.name)
4697
+ end
4698
+
4699
+ # `2` is covered by `Integer` though their union is spelled `2 | Integer`, so the argument is also
4700
+ # tried with its value pins widened.
4701
+ def binding_covers?(bound, argument)
4702
+ return true if argument.equal?(bound) || Type::Combinator.untyped?(bound) || argument == bound
4703
+ return false if Type::Combinator.untyped?(argument)
4704
+ return true if Type::Combinator.union(bound, argument) == bound
4705
+
4706
+ Type::Combinator.union(bound, widen_bound_argument(argument)) == bound
4707
+ end
4708
+
4709
+ def kept_positional_parameters(def_node, receiver)
4710
+ params = def_node.parameters
4711
+ rebound = recursion_rebound_parameters(def_node, receiver)
4712
+ return [] if rebound == RecursionRebinding::ALL || !params.is_a?(Prism::ParametersNode)
4713
+
4714
+ positionals = params.requireds + params.optionals + params.posts + [params.rest]
4715
+ positionals.filter_map { |param| param.name if param.respond_to?(:name) && param.name } - rebound.to_a
4716
+ end
4717
+
3798
4718
  # Seeds the thread-local summary entry for a fixpoint owner: the `bot` Kleene seed plus the
3799
4719
  # guard-stack depth at seed time (the frame for this signature is already pushed), which
3800
4720
  # `consult_summary` logs so nested fixpoints can detect a foreign in-flight (ancestor) consult.
3801
4721
  # Returns the seed depth. ADR-55 slice 2.
3802
- def seed_fixpoint_summary(summaries, plain_signature)
3803
- depth = (Thread.current[INFERENCE_GUARD_KEY] || []).size
4722
+ def seed_fixpoint_summary(summaries, plain_signature, body_scope, receiver)
4723
+ depth = typing_state[INFERENCE_GUARD]&.size || 0
3804
4724
  summaries[plain_signature] = {
3805
- assumption: Type::Combinator.bot, consulted: false, depth: depth
4725
+ assumption: Type::Combinator.bot, consulted: false, depth: depth,
4726
+ # Issue #3162 — the frame the summary is the return of, for {#summary_covers_reentry?}.
4727
+ locals: body_scope.locals, yield: ExpressionTyper.current_yield_value_type, receiver: receiver,
4728
+ uncovered: false, floor: false
3806
4729
  }
4730
+ LoopValueRecord.advance_generation
3807
4731
  depth
3808
4732
  end
3809
4733
 
@@ -3816,13 +4740,14 @@ module Rigor
3816
4740
  note_transient_fallback(BudgetTrace::RECURSION_GUARD, own_guard_frame_position)
3817
4741
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
3818
4742
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
4743
+ LoopValueRecord.advance_generation
3819
4744
  Type::Combinator.untyped
3820
4745
  end
3821
4746
 
3822
4747
  # The stack position of the currently-evaluating owner frame (the guard stack's top) — the
3823
4748
  # self-referential `context_depth` for events that are deterministic per owner (ADR-84 WD3).
3824
4749
  def own_guard_frame_position
3825
- size = Thread.current[INFERENCE_GUARD_KEY]&.size || 0
4750
+ size = typing_state[INFERENCE_GUARD]&.size || 0
3826
4751
  size.positive? ? size - 1 : 0
3827
4752
  end
3828
4753
 
@@ -3843,6 +4768,7 @@ module Rigor
3843
4768
  return candidate if joined == assumption
3844
4769
 
3845
4770
  summaries[plain_signature][:assumption] = joined
4771
+ LoopValueRecord.advance_generation
3846
4772
  nil
3847
4773
  end
3848
4774
 
@@ -3854,17 +4780,67 @@ module Rigor
3854
4780
  note_transient_fallback(BudgetTrace::RECURSION_FIXPOINT_CAP, own_guard_frame_position)
3855
4781
  scope.record_dynamic_origin(@typing_node, DynamicOrigin::ANALYZER_BUDGET_CUTOFF) if @typing_node
3856
4782
  summaries[plain_signature][:assumption] = Type::Combinator.untyped
4783
+ LoopValueRecord.advance_generation
3857
4784
  Type::Combinator.untyped
3858
4785
  end
3859
4786
 
3860
4787
  # Rebuilds the user-method body scope with every bound positional parameter widened to its nominal
3861
4788
  # base (`1 | 2 | 3` → `Integer`, `Constant[:x]` → `Symbol`). Used by the bot-collapse retry in
3862
4789
  # `fixpoint_user_method_return`: call-site argument narrowing can prune a recursive method's base
3863
- # case, and widening restores the declared-type view under which the base case is reachable. Returns
3864
- # `nil` when the parameter shape is not inferable (mirrors `build_user_method_body_scope`).
3865
- def widened_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
3866
- widened_args = arg_types.map { |arg_type| widen_value_pinned(arg_type) }
3867
- build_user_method_body_scope(def_node, receiver, widened_args, self_fold_safe: self_fold_safe)
4790
+ # case, and widening restores the declared-type view under which the base case is reachable. A
4791
+ # parameter the def's own recursive calls rebind is bound `Dynamic[top]` instead ({RecursionRebinding}).
4792
+ # Returns `nil` when the parameter shape is not inferable (mirrors `build_user_method_body_scope`).
4793
+ def widened_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false,
4794
+ positionals_unknown: false)
4795
+ widened_args = arg_types.map { |arg_type| widen_bound_argument(arg_type) }
4796
+ build_user_method_body_scope(def_node, receiver, widened_args,
4797
+ self_fold_safe: self_fold_safe, positionals_unknown: positionals_unknown,
4798
+ rebound: recursion_rebound_parameters(def_node, receiver))
4799
+ end
4800
+
4801
+ # Issue #3115 — {RecursionRebinding.rebound_parameters} for `def_node`, told the def's other names: a name
4802
+ # the body calls is an alias when `receiver`'s class resolves it to this same def (`alias al2 al1` makes
4803
+ # `al2(n - 1, !f)` a recursive call). A receiver whose class cannot be asked rebinds every parameter.
4804
+ def recursion_rebound_parameters(def_node, receiver)
4805
+ names = Set[def_node.name]
4806
+ RecursionRebinding.called_names(def_node.body).each do |name|
4807
+ next if name == def_node.name
4808
+
4809
+ resolved = recursion_def_for(receiver, name)
4810
+ return RecursionRebinding::ALL if resolved == :unknown
4811
+
4812
+ names << name if resolved.equal?(def_node)
4813
+ end
4814
+ RecursionRebinding.rebound_parameters(def_node, names)
4815
+ end
4816
+
4817
+ def recursion_def_for(receiver, method_name)
4818
+ return scope.singleton_def_for(receiver.class_name, method_name) if receiver.is_a?(Type::Singleton)
4819
+ return :unknown unless user_inference_receiver?(receiver)
4820
+
4821
+ resolve_user_def_through_ancestors(receiver.class_name, method_name)
4822
+ end
4823
+
4824
+ # Issue #3115 — one argument of the widened retry. A keyword argument reaches the binder inside the call's
4825
+ # trailing `HashShape`, which {#widen_value_pinned} leaves as it is (a shape is not a value pin), so a
4826
+ # keyword parameter kept its call-site pin through the retry and the base case stayed pruned: the body
4827
+ # then converged on the `bot` seed. A shape carrier keeps its keys, policy and arity, and the values it
4828
+ # holds widen at every depth: a `HashShape`'s values, a `Tuple`'s elements and a `Union`'s members.
4829
+ def widen_bound_argument(arg_type)
4830
+ case arg_type
4831
+ when Type::HashShape
4832
+ Type::HashShape.new(
4833
+ arg_type.pairs.transform_values { |value| widen_bound_argument(value) },
4834
+ required_keys: arg_type.required_keys, optional_keys: arg_type.optional_keys,
4835
+ read_only_keys: arg_type.read_only_keys, extra_keys: arg_type.extra_keys
4836
+ )
4837
+ when Type::Tuple
4838
+ Type::Combinator.tuple_of(*arg_type.elements.map { |element| widen_bound_argument(element) })
4839
+ when Type::Union
4840
+ Type::Combinator.union(*arg_type.members.map { |member| widen_bound_argument(member) })
4841
+ else
4842
+ widen_value_pinned(arg_type)
4843
+ end
3868
4844
  end
3869
4845
 
3870
4846
  # ADR-55 slice 2 bot-collapse resolution (2026-06-11). Called when a fixpoint iteration computed `bot`
@@ -3885,7 +4861,8 @@ module Rigor
3885
4861
  def resolve_bot_collapse(def_node, context, widened:)
3886
4862
  unless widened
3887
4863
  widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types,
3888
- self_fold_safe: context.self_fold_safe)
4864
+ self_fold_safe: context.self_fold_safe,
4865
+ positionals_unknown: context.positionals_unknown)
3889
4866
  return fixpoint_user_method_return(def_node, widened_scope, context, widened: true) unless widened_scope.nil?
3890
4867
  end
3891
4868
 
@@ -3927,7 +4904,7 @@ module Rigor
3927
4904
  return Type::Combinator.untyped if entry.nil?
3928
4905
 
3929
4906
  entry[:consulted] = true
3930
- (Thread.current[SUMMARY_CONSULT_DEPTHS_KEY] ||= []) << entry[:depth]
4907
+ typing_state.append(SUMMARY_CONSULT_DEPTHS, entry[:depth])
3931
4908
  entry[:assumption]
3932
4909
  end
3933
4910
 
@@ -3966,10 +4943,11 @@ module Rigor
3966
4943
  # drains back to empty. On exhaustion (return 0) it records a `RECURSION_UNROLL_FUEL` hit so the caller
3967
4944
  # keeps the plain `(receiver, method)` signature — today's behaviour.
3968
4945
  def unroll_fuel_remaining(stack)
3969
- remaining = Thread.current[INFERENCE_UNROLL_FUEL_KEY]
4946
+ state = typing_state
4947
+ remaining = state.read(INFERENCE_UNROLL_FUEL)
3970
4948
  remaining = RECURSION_UNROLL_FUEL if remaining.nil? || stack.empty?
3971
4949
  if remaining.positive?
3972
- Thread.current[INFERENCE_UNROLL_FUEL_KEY] = remaining - 1
4950
+ state.write(INFERENCE_UNROLL_FUEL, remaining - 1)
3973
4951
  else
3974
4952
  # ADR-84 WD3 — position 0: fuel is one shared resource seeded at the stack bottom, so its
3975
4953
  # exhaustion is context-dependent for every nested bracket.
@@ -4018,9 +4996,28 @@ module Rigor
4018
4996
  total += descriptor.last
4019
4997
  end
4020
4998
  [["t", parts], total]
4999
+ when Type::HashShape then pinned_shape_descriptor(arg)
4021
5000
  end
4022
5001
  end
4023
5002
 
5003
+ # Issue #3115 — a call's keyword arguments arrive as one closed `HashShape`, so `m(n: 2)` is as
5004
+ # value-pinned as `m(2)` when every entry is. An open shape, or one with an optional key, does not name
5005
+ # one argument list and stays on the type-keyed path.
5006
+ def pinned_shape_descriptor(shape)
5007
+ return nil unless shape.closed? && shape.optional_keys.empty?
5008
+
5009
+ parts = []
5010
+ total = 1
5011
+ shape.pairs.each do |key, value|
5012
+ descriptor = pinned_value_descriptor(value)
5013
+ return nil if descriptor.nil?
5014
+
5015
+ parts << [key.inspect, descriptor.first]
5016
+ total += descriptor.last
5017
+ end
5018
+ [["h", parts], total]
5019
+ end
5020
+
4024
5021
  # Builds the body scope for a user-defined instance method call: a fresh `Scope` with `self_type` set
4025
5022
  # to the receiver's nominal type, the project-wide accumulators inherited (so the body sees the same
4026
5023
  # `discovered_classes` / `class_ivars` / etc. the caller does), and the call's `arg_types` bound to
@@ -4042,11 +5039,25 @@ module Rigor
4042
5039
  # through it. Record a per-file write kind in the census instead, as {ScopeIndexer::CONSTANT_MEMO} does
4043
5040
  # (#1276). Setting `source_path` here was rejected: plugins read it, and the return memo would become
4044
5041
  # caller-dependent.
4045
- def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
5042
+ #
5043
+ # Issue #3115 — `rebound:` is the widened retry's set ({#widened_user_method_body_scope}): the parameters
5044
+ # the def's own recursive calls rebind ({RecursionRebinding}), each bound `Dynamic[top]` whether the call
5045
+ # wrote the slot or a default filled it. The retry types the body for every call the recursion makes,
5046
+ # and such a slot holds whatever a recursive call passes (`tog(n: n - 1, f: !f)` against `f: false`).
5047
+ def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false,
5048
+ positionals_unknown: false, rebound: nil)
4046
5049
  params = def_node.parameters
4047
- locals = bind_params_from_call_types(params, arg_types)
5050
+ locals =
5051
+ if positionals_unknown
5052
+ bind_params_without_correspondence(params, arg_types.last)
5053
+ else
5054
+ bind_params_from_call_types(params, arg_types)
5055
+ end
4048
5056
  return nil if locals.nil?
4049
5057
 
5058
+ rebound = locals.keys if rebound == RecursionRebinding::ALL
5059
+ rebound&.each { |name| locals[name] = dynamic_top if locals.key?(name) }
5060
+
4050
5061
  # Construct the body scope in a SINGLE Scope allocation (plus the issue #1358 frame it carries, whose
4051
5062
  # scans run only on demand) — the previous `Scope.empty.with_*.with_*…` chain
4052
5063
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
@@ -4111,9 +5122,15 @@ module Rigor
4111
5122
  def self_fold_safe_grant?(def_node, receiver)
4112
5123
  per_def = (class_graph_buckets[:self_pure][def_node] ||= {})
4113
5124
  key = [receiver.class_name, receiver.member_names]
4114
- return per_def[key] if per_def.key?(key)
5125
+ if per_def.key?(key)
5126
+ return MemoAudit.check("class graph self pure", per_def[key], def_node.name, key) do
5127
+ self_fold_safe_body?(def_node.body, receiver, [def_node])
5128
+ end
5129
+ end
4115
5130
 
4116
- per_def[key] = self_fold_safe_body?(def_node.body, receiver, [def_node])
5131
+ per_def[key] = MemoAudit.key_audit("class graph self pure") do
5132
+ self_fold_safe_body?(def_node.body, receiver, [def_node])
5133
+ end
4117
5134
  end
4118
5135
 
4119
5136
  # Whether `body`'s every use of `self` is a pure read, resolving each unrecognised self-call against
@@ -4148,6 +5165,11 @@ module Rigor
4148
5165
  locals = bind_positional_params(params, positional)
4149
5166
  return nil if locals.nil?
4150
5167
 
5168
+ bind_keyword_and_block_params(params, kw_shape, locals)
5169
+ end
5170
+
5171
+ # The keyword, `**rest` and block parameters' bindings, added to `locals`, which it returns.
5172
+ def bind_keyword_and_block_params(params, kw_shape, locals)
4151
5173
  bind_keyword_params(params, kw_shape, locals)
4152
5174
  rest_name = keyword_rest_name(params)
4153
5175
  locals[rest_name.to_sym] = keyword_rest_type(params, kw_shape) if rest_name
@@ -4155,6 +5177,25 @@ module Rigor
4155
5177
  locals
4156
5178
  end
4157
5179
 
5180
+ # Issue #1237 — the locals table for a call whose positional correspondence is unknown (a `*splat` at the
5181
+ # call site). Every positional parameter (required, optional, trailing) binds `Dynamic[top]` and the rest
5182
+ # parameter an `Array[untyped]`; the keyword parameters bind from `keywords` as {#bind_keyword_params} does
5183
+ # for any call, so an open shape leaves them `Dynamic[top]`. An optional's default is not used: the splat
5184
+ # may supply the slot.
5185
+ def bind_params_without_correspondence(params, keywords)
5186
+ return {} if params.nil?
5187
+ return nil unless params.is_a?(Prism::ParametersNode)
5188
+
5189
+ locals = {}
5190
+ (params.requireds + params.posts).each { |param| bind_positional_param(locals, param, dynamic_top) }
5191
+ params.optionals.each { |param| locals[param.name.to_sym] = dynamic_top }
5192
+ rest = params.rest
5193
+ if rest.respond_to?(:name) && rest.name
5194
+ locals[rest.name.to_sym] = Type::Combinator.nominal_of("Array", type_args: [dynamic_top])
5195
+ end
5196
+ bind_keyword_and_block_params(params, keywords, locals)
5197
+ end
5198
+
4158
5199
  # Trailing required positionals after a rest (`def f(a, *m, z)`) shift the correspondence and stay
4159
5200
  # declined — correspondence, not width, is the issue. Issue #1125: `def m(...)`'s forwarding slot is no
4160
5201
  # longer a decline. Its NAMED parameters (a leading `def m(a, ...)` required) bind exactly as before;
@@ -4300,7 +5341,7 @@ module Rigor
4300
5341
  # now carry what the callee needs:
4301
5342
  #
4302
5343
  # - `f(...)` inside `def m(...)` re-supplies the arguments `m` was called with, read from the frame
4303
- # {#infer_user_method_return} installed ({FORWARDED_ARGS_KEY}) — so it expands to a whole LIST, not
5344
+ # {#infer_user_method_return} installed ({FORWARDED_ARGS}) — so it expands to a whole LIST, not
4304
5345
  # one type, which is why the map became a flat_map.
4305
5346
  # - a keyword hash built ENTIRELY from a double splat (`f(**h)`) is the shape of `h`, so `f(**h)`
4306
5347
  # binds the same parameters the literal form `f(a: 1, b: 2)` does.
@@ -4396,7 +5437,7 @@ module Rigor
4396
5437
  )
4397
5438
  block_return_for(
4398
5439
  block_arg, expected,
4399
- call_node: call_node,
5440
+ call_node: call_node, receiver_type: receiver_type,
4400
5441
  narrowing: block_body_self_narrowing(call_node, receiver_type),
4401
5442
  repeats: !BLOCK_VALUE_DISCARDING.include?(call_node.name) && block_may_repeat?(call_node, receiver_type)
4402
5443
  )
@@ -4469,7 +5510,7 @@ module Rigor
4469
5510
  param_types = break_arm_param_types(call_node, receiver)
4470
5511
  block_scope = block_entry_scope(
4471
5512
  block_node, param_types,
4472
- call_node: call_node,
5513
+ call_node: call_node, receiver_type: receiver,
4473
5514
  narrowing: block_body_self_narrowing(call_node, receiver),
4474
5515
  captured: repeating_captured_bindings(block_node, param_types, block_may_repeat?(call_node, receiver))
4475
5516
  )
@@ -4512,12 +5553,13 @@ module Rigor
4512
5553
  )
4513
5554
  end
4514
5555
 
4515
- def block_return_for(block_arg, expected, call_node: nil, narrowing: nil, repeats: false)
5556
+ def block_return_for(block_arg, expected, call_node: nil, narrowing: nil, repeats: false, receiver_type: nil)
4516
5557
  case block_arg
4517
5558
  when Prism::BlockNode
4518
5559
  captured = repeating_captured_bindings(block_arg, expected, repeats)
4519
5560
  entry = block_entry_scope(
4520
- block_arg, expected, call_node: call_node, narrowing: narrowing, captured: captured
5561
+ block_arg, expected,
5562
+ call_node: call_node, narrowing: narrowing, captured: captured, receiver_type: receiver_type
4521
5563
  )
4522
5564
  type_block_body(block_arg, entry, captured: captured)
4523
5565
  when Prism::BlockArgumentNode
@@ -4545,8 +5587,16 @@ module Rigor
4545
5587
  # Issue #316 — mirrors `StatementEvaluator#build_block_entry_scope`: the block body's `self` is the
4546
5588
  # yielding method's business, so the return-typing pass must see the same unmodelled-self mark. Issue #1358
4547
5589
  # — and the same match-global view ({MatchRebinding.block_entry}), which reads the owning `call_node`.
4548
- def block_entry_scope(block_node, expected, call_node: nil, narrowing: nil, captured: nil)
4549
- entry = MatchRebinding.block_entry(scope.entering_opaque_block, block_node, call_node)
5590
+ #
5591
+ # Issue #2861 — and the same self-preserving entry ({SelfPreservingBlock}), read off `receiver_type`.
5592
+ def block_entry_scope(block_node, expected, call_node: nil, narrowing: nil, captured: nil, receiver_type: nil)
5593
+ self_entry = if SelfPreservingBlock.call?(call_node, receiver_type, scope)
5594
+ scope.entering_self_preserving_block
5595
+ else
5596
+ scope.entering_opaque_block
5597
+ end
5598
+ entry = MatchRebinding.block_entry(self_entry, block_node, call_node)
5599
+ entry = entry.entering_block_without_gradual_marks(block_node)
4550
5600
  entry = captured.lay(entry) if captured
4551
5601
  block_scope = BlockParameterBinder.new(expected_param_types: expected).bind_onto(block_node, entry)
4552
5602
  return block_scope unless narrowing
@@ -4816,23 +5866,20 @@ module Rigor
4816
5866
  # Suppression flag for {#threaded_block_body_type} — set while a fold is running (so a fold never nests)
4817
5867
  # and while the per-element Tuple fold is over its arity cap. Thread-local because block typing is
4818
5868
  # re-entrant within one thread and the fork-pool workers each own their own.
4819
- THREADED_BLOCK_BODY_KEY = :__rigor_threaded_block_body__
4820
- private_constant :THREADED_BLOCK_BODY_KEY
5869
+ THREADED_BLOCK_BODY = TypingState.frame(
5870
+ :threaded_block_body, reason: "a suppression flag installed per fold",
5871
+ witness: "spec/fixtures/typing_state_witnesses/threaded_block_body.rb"
5872
+ )
5873
+ private_constant :THREADED_BLOCK_BODY
4821
5874
 
4822
5875
  def block_body_threading_suppressed?
4823
- Thread.current[THREADED_BLOCK_BODY_KEY] ? true : false
5876
+ typing_state[THREADED_BLOCK_BODY] ? true : false
4824
5877
  end
4825
5878
 
4826
5879
  # Runs the block with the scope-threading fold suppressed, restoring the previous state (not clearing
4827
5880
  # it) on the way out, so nesting a suppressed region inside another cannot re-enable threading.
4828
- def without_block_body_threading
4829
- previous = Thread.current[THREADED_BLOCK_BODY_KEY]
4830
- Thread.current[THREADED_BLOCK_BODY_KEY] = true
4831
- begin
4832
- yield
4833
- ensure
4834
- Thread.current[THREADED_BLOCK_BODY_KEY] = previous
4835
- end
5881
+ def without_block_body_threading(&)
5882
+ TypingState.current.with(THREADED_BLOCK_BODY, true, &)
4836
5883
  end
4837
5884
 
4838
5885
  # Every variable-write form whose name `StatementEvaluator` threads into the following statement's
@@ -5027,11 +6074,13 @@ module Rigor
5027
6074
  # position matches. `r = [1, 2].find(-> { 0 }) { |e| e > 5 }` is `0` at runtime; the walk answered `nil`,
5028
6075
  # and `r + 1` then reported a nil receiver on correct code. The other supported methods take no argument.
5029
6076
  PER_ELEMENT_TUPLE_METHODS = Set[
5030
- :map, :collect, :filter_map, :flat_map,
5031
- :select, :filter, :reject,
5032
- :find, :detect, :find_index, :index
6077
+ :map, :collect, :filter_map, :flat_map, :collect_concat,
6078
+ :select, :filter, :find_all, :reject, :take_while, :drop_while,
6079
+ :partition, :count, :find, :detect, :rfind, :find_index, :index, :rindex
5033
6080
  ].freeze
5034
- private_constant :PER_ELEMENT_TUPLE_METHODS
6081
+ ARRAY_ONLY_PER_ELEMENT_METHODS = Set[:index, :rindex, :rfind].freeze
6082
+ REVERSE_PER_ELEMENT_METHODS = Set[:rfind, :rindex].freeze
6083
+ private_constant :PER_ELEMENT_TUPLE_METHODS, :ARRAY_ONLY_PER_ELEMENT_METHODS, :REVERSE_PER_ELEMENT_METHODS
5035
6084
 
5036
6085
  HASH_SHAPE_TRANSFORM_METHODS = Set[
5037
6086
  :transform_keys, :transform_keys!,
@@ -5050,23 +6099,35 @@ module Rigor
5050
6099
  private_constant :PER_ELEMENT_RANGE_LIMIT
5051
6100
 
5052
6101
  def try_per_element_block_fold(call_node, receiver_type)
5053
- return nil unless PER_ELEMENT_TUPLE_METHODS.include?(call_node.name)
6102
+ name = call_node.name
6103
+ return nil unless PER_ELEMENT_TUPLE_METHODS.include?(name)
6104
+ return nil if ARRAY_ONLY_PER_ELEMENT_METHODS.include?(name) && !receiver_type.is_a?(Type::Tuple)
5054
6105
  return nil unless call_node.arguments.nil?
5055
6106
 
5056
6107
  element_types = per_element_elements_of(receiver_type)
5057
6108
  return nil if element_types.nil? || element_types.empty?
5058
6109
 
5059
- per_position = per_element_block_results(call_node.block, element_types)
6110
+ per_position = evaluate_per_element_positions(call_node.block, element_types, name)
6111
+ return nil if per_position.nil?
6112
+
6113
+ assemble_per_element_result(name, per_position, element_types) ||
6114
+ undecided_fold_floor(name, element_types, receiver_type)
6115
+ end
6116
+
6117
+ def evaluate_per_element_positions(block, element_types, method_name)
6118
+ reverse = REVERSE_PER_ELEMENT_METHODS.include?(method_name)
6119
+ eval_elements = reverse ? element_types.reverse : element_types
6120
+ per_position = per_element_block_results(block, eval_elements)
5060
6121
  return nil if per_position.nil? || per_position.any?(&:nil?)
5061
6122
 
5062
- assemble_per_element_result(call_node.name, per_position, element_types) ||
5063
- undecided_fold_floor(call_node.name, element_types, receiver_type)
6123
+ reverse ? per_position.reverse : per_position
5064
6124
  end
5065
6125
 
5066
- # The honest answer for the find family (`find` / `detect` / `find_index` / `index`) and the filter family
5067
- # (`select` / `filter` / `reject`) when this fold walked every position and the assembler still could not
5068
- # decide — which happens for exactly one reason: some position's predicate is not a `Constant`, so "the
5069
- # first matching one" or "the ones kept" is not a static fact.
6126
+ # The honest answer for the find family (`find` / `detect` / `rfind` / `find_index` / `index` / `rindex`),
6127
+ # the filter family (`select` / `filter` / `find_all` / `reject` / `take_while` / `drop_while`), and `count`
6128
+ # when this fold walked every position and the assembler still could not decide — which happens for
6129
+ # exactly one reason: some position's predicate is not a `Constant`, so "the matching one" or "the ones
6130
+ # kept" is not a static fact.
5070
6131
  #
5071
6132
  # Falling through to the dispatcher was WRONG for this family, and issue #617 residue (1) is the bill:
5072
6133
  # `seen = 0; [1, 2].find do |e| seen += 1; seen == 2 end` answered `nil` where the runtime answers `2`.
@@ -5076,30 +6137,34 @@ module Rigor
5076
6137
  # typed the predicate per position and saw that it does not fold. Answering here is what keeps that
5077
6138
  # knowledge from being thrown away in favour of a worse-informed tier.
5078
6139
  #
5079
- # The floor is what `find` can return and no tighter: one of the receiver's own elements, or `nil` when
5080
- # no element matches. Value pinning survives because it is still true of every candidate — `find` hands
5081
- # back an element, it does not compute one. `find_index` / `index` answer a position in the receiver, so
5082
- # `Integer?` is their floor.
5083
- #
5084
- # `select` / `filter` / `reject` take a floor for the same reason. Their fall-through is not merely
5085
- # wider: `BlockFolding`'s filter folds DO answer on a `Constant` block, and the entry-scope pin hands
5086
- # them one, so `seen = 0; [1, 2].select do |e| seen += 1; seen == 2 end` answered a provably-empty `[]`
5087
- # where Ruby answers `[2]`. Nested inside a threaded body, where a position can be typed tail-only
5088
- # ({#tail_only_body_floor}), `[[], []].select do |a| a << w; a.any? end` did the same. Their floor is an
5089
- # Array of the receiver's own elements: which of them survive is undecided, and what they are is not.
5090
- # A range receiver's elements are widened first ({#filter_family_floor}).
6140
+ # The floor is what `find` / `rfind` can return and no tighter: one of the receiver's own elements, or
6141
+ # `nil` when no element matches. Value pinning survives because it is still true of every candidate —
6142
+ # `find` hands back an element, it does not compute one. `find_index` / `index` / `rindex` answer a
6143
+ # position in the receiver, so `Integer?` is their floor.
6144
+ #
6145
+ # `select` / `filter` / `find_all` / `reject` / `take_while` / `drop_while` take a floor for the same
6146
+ # reason. Their fall-through is not merely wider: `BlockFolding`'s filter folds DO answer on a `Constant`
6147
+ # block, and the entry-scope pin hands them one, so `seen = 0; [1, 2].select do |e| seen += 1; seen == 2
6148
+ # end` answered a provably-empty `[]` where Ruby answers `[2]`. Nested inside a threaded body, where a
6149
+ # position can be typed tail-only ({#tail_only_body_floor}), `[[], []].select do |a| a << w; a.any? end`
6150
+ # did the same. Their floor is an Array of the receiver's own elements: which of them survive is
6151
+ # undecided, and what they are is not. A range receiver's elements are widened first
6152
+ # ({#filter_family_floor}). `count` likewise floors to `Integer` so an undecided block never falls through
6153
+ # to `BlockFolding`'s entry-scope `0` or `size`.
5091
6154
  #
5092
6155
  # Nothing else in {PER_ELEMENT_TUPLE_METHODS} takes a floor. `map`'s assembler cannot decline, and
5093
- # `BlockFolding` never folds `filter_map` / `flat_map`, so their fall-through is the RBS `Array[U]`
6156
+ # `BlockFolding` never folds `filter_map` / `flat_map` / `partition`, so their fall-through is the RBS
5094
6157
  # projection.
5095
6158
  def undecided_fold_floor(method_name, element_types, receiver_type)
5096
6159
  case method_name
5097
- when :find, :detect
6160
+ when :find, :detect, :rfind
5098
6161
  Type::Combinator.union(*element_types, Type::Combinator.constant_of(nil))
5099
- when :select, :filter, :reject
6162
+ when :select, :filter, :find_all, :reject, :take_while, :drop_while
5100
6163
  filter_family_floor(element_types, receiver_type)
5101
- when :find_index, :index
6164
+ when :find_index, :index, :rindex
5102
6165
  Type::Combinator.union(Type::Combinator.nominal_of("Integer"), Type::Combinator.constant_of(nil))
6166
+ when :count
6167
+ Type::Combinator.nominal_of("Integer")
5103
6168
  end
5104
6169
  end
5105
6170
 
@@ -5637,9 +6702,13 @@ module Rigor
5637
6702
  # Records `type` as the accumulator `block` receives when typed from this typer's scope
5638
6703
  # ({.recorded_inject_accumulator}). Prism nodes compare by identity. The keys are weak but the values strong,
5639
6704
  # and a recorded scope reaches the parse tree that holds the key, so a record never goes on its own (issue
5640
- # #2412): the map is a run-scoped slot ({RunScopedSlots}), emptied when the run that filled it ends.
6705
+ # #2412): the map is a run-scoped slot ({TypingState.release_run_scoped}), emptied when the run that filled it
6706
+ # ends.
5641
6707
  def record_inject_accumulator(block, type)
5642
- (Thread.current[INJECT_ACCUMULATOR_KEY] ||= ObjectSpace::WeakKeyMap.new)[block] = [scope, type].freeze
6708
+ record = [scope, type, LoopValueRecord.generation].freeze
6709
+ state = typing_state
6710
+ state.log_write(INJECT_ACCUMULATOR, block, record)
6711
+ state.weak_key_map(INJECT_ACCUMULATOR)[block] = record
5643
6712
  end
5644
6713
 
5645
6714
  # Issue #1332 — the #587 (b) captured-block binding ({#captured_block_bindings}) both inject arms lay under
@@ -5754,18 +6823,44 @@ module Rigor
5754
6823
  result
5755
6824
  end
5756
6825
 
6826
+ PER_ELEMENT_ASSEMBLERS = {
6827
+ map: :assemble_map_result,
6828
+ collect: :assemble_map_result,
6829
+ filter_map: :assemble_filter_map_result,
6830
+ flat_map: :assemble_flat_map_result,
6831
+ collect_concat: :assemble_flat_map_result,
6832
+ select: :assemble_select_result,
6833
+ filter: :assemble_select_result,
6834
+ find_all: :assemble_select_result,
6835
+ reject: :assemble_reject_result,
6836
+ take_while: :assemble_take_while_result,
6837
+ drop_while: :assemble_drop_while_result,
6838
+ partition: :assemble_partition_result,
6839
+ count: :assemble_count_result,
6840
+ find: :assemble_find_result,
6841
+ detect: :assemble_find_result,
6842
+ rfind: :assemble_rfind_result,
6843
+ find_index: :assemble_find_index_result,
6844
+ index: :assemble_find_index_result,
6845
+ rindex: :assemble_rindex_result
6846
+ }.freeze
6847
+ private_constant :PER_ELEMENT_ASSEMBLERS
6848
+
5757
6849
  def assemble_per_element_result(method_name, per_position, element_types)
5758
- case method_name
5759
- when :map, :collect then Type::Combinator.tuple_of(*per_position)
5760
- when :filter_map then assemble_filter_map_result(per_position)
5761
- when :flat_map then assemble_flat_map_result(per_position)
5762
- when :select, :filter
5763
- assemble_filter_result(per_position, element_types, keep_on_truthy: true)
5764
- when :reject
5765
- assemble_filter_result(per_position, element_types, keep_on_truthy: false)
5766
- when :find, :detect then assemble_find_result(per_position, element_types)
5767
- when :find_index, :index then assemble_find_index_result(per_position)
5768
- end
6850
+ handler = PER_ELEMENT_ASSEMBLERS[method_name]
6851
+ send(handler, per_position, element_types) if handler
6852
+ end
6853
+
6854
+ def assemble_map_result(per_position, _element_types)
6855
+ Type::Combinator.tuple_of(*per_position)
6856
+ end
6857
+
6858
+ def assemble_select_result(per_position, element_types)
6859
+ assemble_filter_result(per_position, element_types, keep_on_truthy: true)
6860
+ end
6861
+
6862
+ def assemble_reject_result(per_position, element_types)
6863
+ assemble_filter_result(per_position, element_types, keep_on_truthy: false)
5769
6864
  end
5770
6865
 
5771
6866
  # `select` / `filter` / `reject`: keeps each receiver element whose per-position predicate result
@@ -5785,10 +6880,51 @@ module Rigor
5785
6880
  Type::Combinator.tuple_of(*kept)
5786
6881
  end
5787
6882
 
6883
+ # `take_while` / `drop_while`: stops at the first falsey `Constant` position when every preceding
6884
+ # position is a truthy `Constant` (or takes/drops all elements when every position is a truthy
6885
+ # `Constant`). Declines when an undecided position precedes the stopping point.
6886
+ def assemble_take_while_result(per_position, element_types)
6887
+ cutoff = leading_truthy_cutoff(per_position)
6888
+ return nil if cutoff.nil?
6889
+
6890
+ Type::Combinator.tuple_of(*element_types[0...cutoff])
6891
+ end
6892
+
6893
+ def assemble_drop_while_result(per_position, element_types)
6894
+ cutoff = leading_truthy_cutoff(per_position)
6895
+ return nil if cutoff.nil?
6896
+
6897
+ Type::Combinator.tuple_of(*element_types[cutoff..])
6898
+ end
6899
+
6900
+ def leading_truthy_cutoff(per_position)
6901
+ first_falsey_index = per_position.index { |type| falsey_constant?(type) }
6902
+ return (per_position.all?(Type::Constant) ? per_position.size : nil) if first_falsey_index.nil?
6903
+ return nil unless per_position[0...first_falsey_index].all?(Type::Constant)
6904
+
6905
+ first_falsey_index
6906
+ end
6907
+
6908
+ # `partition`: returns `Tuple[truthy_tuple, falsey_tuple]` when every position folds to a `Constant`.
6909
+ def assemble_partition_result(per_position, element_types)
6910
+ return nil unless per_position.all?(Type::Constant)
6911
+
6912
+ truthy = assemble_filter_result(per_position, element_types, keep_on_truthy: true)
6913
+ falsey = assemble_filter_result(per_position, element_types, keep_on_truthy: false)
6914
+ Type::Combinator.tuple_of(truthy, falsey)
6915
+ end
6916
+
6917
+ # `count`: returns `Constant[Integer]` of the truthy positions when every position folds to a `Constant`.
6918
+ def assemble_count_result(per_position, _element_types)
6919
+ return nil unless per_position.all?(Type::Constant)
6920
+
6921
+ Type::Combinator.constant_of(per_position.count { |type| truthy_constant?(type) })
6922
+ end
6923
+
5788
6924
  # `filter_map` folds tightly only when every per-position result is a `Constant`: positions whose
5789
6925
  # value is `nil` or `false` drop, the rest survive in declaration order. When any position is
5790
6926
  # non-Constant the dispatcher declines (returns nil) so the RBS tier widens to `Array[U]`.
5791
- def assemble_filter_map_result(per_position)
6927
+ def assemble_filter_map_result(per_position, _element_types = nil)
5792
6928
  return nil unless per_position.all?(Type::Constant)
5793
6929
 
5794
6930
  kept = per_position.reject { |type| type.value.nil? || type.value == false }
@@ -5803,7 +6939,7 @@ module Rigor
5803
6939
  #
5804
6940
  # `Type::Constant` only ever holds non-Array scalars (the carrier rejects Array literals), so a single
5805
6941
  # `Constant` safely contributes itself as a single Tuple element.
5806
- def assemble_flat_map_result(per_position)
6942
+ def assemble_flat_map_result(per_position, _element_types = nil)
5807
6943
  flattened = per_position.flat_map { |type| flat_map_contribution(type) }
5808
6944
  return nil if flattened.nil? || flattened.any?(&:nil?)
5809
6945
 
@@ -5833,15 +6969,32 @@ module Rigor
5833
6969
  Type::Combinator.union(*candidates.map { |index| element_types[index] })
5834
6970
  end
5835
6971
 
6972
+ # `rfind`: reverse of `find` — returns the last receiver element whose block result is Ruby-truthy,
6973
+ # joined with any undecided positions to its right.
6974
+ def assemble_rfind_result(per_position, element_types)
6975
+ candidates = last_match_candidates(per_position)
6976
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
6977
+
6978
+ Type::Combinator.union(*candidates.map { |index| element_types[index] })
6979
+ end
6980
+
5836
6981
  # `find_index` / `index`: returns the index of the first truthy position, or `Constant[nil]` when
5837
6982
  # nothing matches, on {#assemble_find_result}'s terms.
5838
- def assemble_find_index_result(per_position)
6983
+ def assemble_find_index_result(per_position, _element_types = nil)
5839
6984
  candidates = first_match_candidates(per_position)
5840
6985
  return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
5841
6986
 
5842
6987
  Type::Combinator.union(*candidates.map { |index| Type::Combinator.constant_of(index) })
5843
6988
  end
5844
6989
 
6990
+ # `rindex`: returns the index of the last truthy position, or `Constant[nil]` when nothing matches.
6991
+ def assemble_rindex_result(per_position, _element_types = nil)
6992
+ candidates = last_match_candidates(per_position)
6993
+ return candidates if candidates.nil? || candidates.equal?(NO_MATCH)
6994
+
6995
+ Type::Combinator.union(*candidates.map { |index| Type::Combinator.constant_of(index) })
6996
+ end
6997
+
5845
6998
  NO_MATCH = Type::Combinator.constant_of(nil)
5846
6999
  private_constant :NO_MATCH
5847
7000
 
@@ -5857,8 +7010,27 @@ module Rigor
5857
7010
  undecided + [first_truthy_index]
5858
7011
  end
5859
7012
 
7013
+ # The positions `rfind` / `rindex` may stop at — the last decisive truthy one and every undecided one
7014
+ # after it — `NO_MATCH` when every position folds falsey, or nil when an undecided position has no
7015
+ # truthy one before it.
7016
+ def last_match_candidates(per_position)
7017
+ last_truthy_index = per_position.rindex { |type| truthy_constant?(type) }
7018
+ if last_truthy_index.nil?
7019
+ return per_position.all?(Type::Constant) ? NO_MATCH : nil
7020
+ end
7021
+
7022
+ undecided = ((last_truthy_index + 1)...per_position.size).reject do |index|
7023
+ per_position[index].is_a?(Type::Constant)
7024
+ end
7025
+ [last_truthy_index] + undecided
7026
+ end
7027
+
5860
7028
  def truthy_constant?(type)
5861
- type.is_a?(Type::Constant) && type.value && type.value != false
7029
+ type.is_a?(Type::Constant) && !type.value.nil? && type.value != false
7030
+ end
7031
+
7032
+ def falsey_constant?(type)
7033
+ type.is_a?(Type::Constant) && (type.value.nil? || type.value == false)
5862
7034
  end
5863
7035
 
5864
7036
  # Per-pair block fold for `HashShape#transform_keys` and `HashShape#transform_values` (and their bang