rigortype 0.3.6 → 0.3.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 (223) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/builtins/ruby_core/date.yml +6 -3
  4. data/data/builtins/ruby_core/enumerable.yml +1 -0
  5. data/data/builtins/ruby_core/exception.yml +2 -1
  6. data/data/builtins/ruby_core/file.yml +28 -14
  7. data/data/builtins/ruby_core/hash.yml +5 -2
  8. data/data/builtins/ruby_core/io.yml +33 -16
  9. data/data/builtins/ruby_core/random.yml +2 -1
  10. data/data/builtins/ruby_core/re.yml +6 -3
  11. data/data/builtins/ruby_core/struct.yml +2 -0
  12. data/data/builtins/ruby_core/time.yml +2 -1
  13. data/data/gem_overlay/activesupport/core_ext.rbs +409 -11
  14. data/docs/handbook/11-sig-gen.md +24 -14
  15. data/docs/manual/02-cli-reference.md +23 -7
  16. data/docs/manual/04-diagnostics.md +1 -1
  17. data/docs/manual/05-inspecting-types.md +20 -8
  18. data/docs/manual/07-plugins.md +4 -0
  19. data/docs/manual/plugins/rigor-actionpack.md +61 -0
  20. data/docs/manual/plugins/rigor-activerecord.md +74 -1
  21. data/docs/manual/plugins/rigor-activesupport-core-ext.md +114 -12
  22. data/docs/manual/plugins/rigor-sidekiq.md +22 -0
  23. data/lib/rigor/analysis/check_rules/always_truthy_condition_collector.rb +12 -0
  24. data/lib/rigor/analysis/check_rules/dead_version_guard_arms.rb +98 -0
  25. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -3
  26. data/lib/rigor/analysis/check_rules/published_constant_guard.rb +199 -0
  27. data/lib/rigor/analysis/check_rules/rule_walk.rb +1 -2
  28. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +2 -2
  29. data/lib/rigor/analysis/check_rules/shadowed_rescue_collector.rb +6 -12
  30. data/lib/rigor/analysis/check_rules.rb +383 -48
  31. data/lib/rigor/analysis/crash_signature.rb +188 -0
  32. data/lib/rigor/analysis/dependency_recorder.rb +18 -3
  33. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  34. data/lib/rigor/analysis/incremental.rb +28 -0
  35. data/lib/rigor/analysis/incremental_session.rb +63 -16
  36. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +1 -2
  37. data/lib/rigor/analysis/reachability/graph.rb +14 -5
  38. data/lib/rigor/analysis/reachability/scan.rb +7 -3
  39. data/lib/rigor/analysis/result.rb +32 -0
  40. data/lib/rigor/analysis/run_cache_key.rb +41 -1
  41. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +233 -16
  42. data/lib/rigor/analysis/runner/pool_coordinator.rb +266 -47
  43. data/lib/rigor/analysis/runner/project_pre_passes.rb +15 -11
  44. data/lib/rigor/analysis/runner/run_snapshots.rb +12 -2
  45. data/lib/rigor/analysis/runner.rb +212 -16
  46. data/lib/rigor/analysis/worker_session.rb +37 -3
  47. data/lib/rigor/bleeding_edge.rb +7 -6
  48. data/lib/rigor/builtins/hkt_builtins.rb +45 -6
  49. data/lib/rigor/builtins/imported_refinements.rb +11 -9
  50. data/lib/rigor/builtins/predefined_constant_refinements.rb +108 -59
  51. data/lib/rigor/builtins/static_return_refinements.rb +20 -1
  52. data/lib/rigor/cache/annotation_location.rb +72 -0
  53. data/lib/rigor/cache/descriptor.rb +42 -2
  54. data/lib/rigor/cache/incremental_snapshot.rb +40 -3
  55. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +70 -8
  56. data/lib/rigor/cache/store.rb +17 -1
  57. data/lib/rigor/cli/coverage_command.rb +23 -18
  58. data/lib/rigor/cli/coverage_mutation.rb +13 -17
  59. data/lib/rigor/cli/coverage_scan.rb +47 -6
  60. data/lib/rigor/cli/fused_protection_renderer.rb +19 -2
  61. data/lib/rigor/cli/fused_protection_report.rb +23 -2
  62. data/lib/rigor/cli/measurement_integrity_warning.rb +57 -0
  63. data/lib/rigor/cli/mutation_protection_renderer.rb +22 -2
  64. data/lib/rigor/cli/mutation_protection_report.rb +31 -2
  65. data/lib/rigor/cli/sig_gen_command.rb +47 -0
  66. data/lib/rigor/cli/type_of_command.rb +188 -41
  67. data/lib/rigor/cli/type_of_renderer.rb +72 -3
  68. data/lib/rigor/cli/type_scan_command.rb +10 -1
  69. data/lib/rigor/cli.rb +1 -1
  70. data/lib/rigor/configuration.rb +7 -4
  71. data/lib/rigor/effects/plugin_facts.rb +1 -1
  72. data/lib/rigor/effects/scanner.rb +2 -3
  73. data/lib/rigor/environment/bundle_sig_discovery.rb +64 -13
  74. data/lib/rigor/environment/failure_slot.rb +28 -0
  75. data/lib/rigor/environment/lockfile_resolver.rb +15 -5
  76. data/lib/rigor/environment/missing_gem_constant_index.rb +45 -4
  77. data/lib/rigor/environment/rbs_hierarchy.rb +16 -22
  78. data/lib/rigor/environment/rbs_loader.rb +743 -93
  79. data/lib/rigor/environment.rb +125 -25
  80. data/lib/rigor/inference/acceptance.rb +172 -20
  81. data/lib/rigor/inference/captured_locals.rb +62 -0
  82. data/lib/rigor/inference/content_join.rb +347 -0
  83. data/lib/rigor/inference/def_handle.rb +15 -3
  84. data/lib/rigor/inference/def_node_resolver.rb +62 -3
  85. data/lib/rigor/inference/expression_typer.rb +1020 -141
  86. data/lib/rigor/inference/fork_map.rb +6 -1
  87. data/lib/rigor/inference/hkt_reducer.rb +12 -1
  88. data/lib/rigor/inference/hkt_registry.rb +61 -3
  89. data/lib/rigor/inference/hkt_sugar_translator.rb +87 -0
  90. data/lib/rigor/inference/index_write_widening.rb +48 -0
  91. data/lib/rigor/inference/indexed_narrowing.rb +14 -0
  92. data/lib/rigor/inference/method_dispatcher/block_folding.rb +7 -5
  93. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +10 -0
  94. data/lib/rigor/inference/method_dispatcher/json_folding.rb +58 -0
  95. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +81 -38
  96. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +54 -17
  97. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +1 -2
  98. data/lib/rigor/inference/method_dispatcher/singleton_mixin_dispatch.rb +63 -0
  99. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +63 -10
  100. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +104 -0
  101. data/lib/rigor/inference/method_dispatcher/universal_object_dispatch.rb +81 -0
  102. data/lib/rigor/inference/method_dispatcher.rb +155 -14
  103. data/lib/rigor/inference/method_parameter_binder.rb +3 -3
  104. data/lib/rigor/inference/mutation_widening.rb +244 -174
  105. data/lib/rigor/inference/narrowing.rb +331 -27
  106. data/lib/rigor/inference/parameter_inference_collector.rb +1 -0
  107. data/lib/rigor/inference/pre_eval_constants.rb +15 -0
  108. data/lib/rigor/inference/precision_scanner.rb +5 -2
  109. data/lib/rigor/inference/project_patched_scanner.rb +2 -1
  110. data/lib/rigor/inference/rbs_type_translator.rb +89 -39
  111. data/lib/rigor/inference/scope_indexer.rb +1493 -140
  112. data/lib/rigor/inference/statement_evaluator.rb +276 -91
  113. data/lib/rigor/inference/struct_fold_safety.rb +186 -27
  114. data/lib/rigor/inference/version_guard.rb +229 -0
  115. data/lib/rigor/language_server/selection_range_provider.rb +1 -1
  116. data/lib/rigor/plugin/base.rb +3 -2
  117. data/lib/rigor/plugin/inflector.rb +14 -5
  118. data/lib/rigor/plugin/io_boundary.rb +107 -4
  119. data/lib/rigor/plugin/loader.rb +14 -0
  120. data/lib/rigor/plugin/registry.rb +25 -2
  121. data/lib/rigor/protection/analysis_guard.rb +60 -0
  122. data/lib/rigor/protection/closure_kill_oracle.rb +43 -38
  123. data/lib/rigor/protection/diagnostic_oracle.rb +12 -4
  124. data/lib/rigor/protection/discovery_seed.rb +1 -1
  125. data/lib/rigor/protection/measurement_integrity.rb +36 -0
  126. data/lib/rigor/protection/mutation_scanner.rb +61 -6
  127. data/lib/rigor/protection/mutator.rb +121 -18
  128. data/lib/rigor/rbs_extended/envelope_scanner.rb +8 -7
  129. data/lib/rigor/rbs_extended/hkt_directives.rb +16 -1
  130. data/lib/rigor/rbs_extended/reporter.rb +93 -13
  131. data/lib/rigor/rbs_extended.rb +6 -1
  132. data/lib/rigor/reflection.rb +182 -30
  133. data/lib/rigor/scope/discovery_index.rb +39 -1
  134. data/lib/rigor/scope.rb +385 -4
  135. data/lib/rigor/sig_gen/classification.rb +12 -1
  136. data/lib/rigor/sig_gen/generator.rb +320 -85
  137. data/lib/rigor/sig_gen/renderer.rb +14 -12
  138. data/lib/rigor/sig_gen/writer.rb +3 -0
  139. data/lib/rigor/source/constant_path.rb +79 -2
  140. data/lib/rigor/triage/catalogue.rb +1 -1
  141. data/lib/rigor/type/combinator.rb +10 -0
  142. data/lib/rigor/type/maybe.rb +47 -0
  143. data/lib/rigor/type/refined.rb +1 -2
  144. data/lib/rigor/type/result.rb +53 -0
  145. data/lib/rigor/type.rb +2 -0
  146. data/lib/rigor/version.rb +1 -1
  147. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/analyzer.rb +3 -1
  148. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +58 -8
  149. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +15 -2
  150. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +4 -1
  151. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/analyzer.rb +3 -1
  152. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +39 -14
  153. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_index.rb +36 -3
  154. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +6 -2
  155. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +21 -5
  156. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +47 -8
  157. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_index.rb +13 -3
  158. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +242 -4
  159. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/analyzer.rb +3 -1
  160. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +7 -3
  161. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +62 -11
  162. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +15 -2
  163. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/recurring_scan.rb +3 -1
  164. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +5 -1
  165. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/analyzer.rb +2 -2
  166. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +798 -22
  167. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_index.rb +156 -13
  168. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +140 -40
  169. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +2 -2
  170. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +20 -6
  171. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_index.rb +14 -4
  172. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +6 -3
  173. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext/effects.rb +23 -6
  174. data/plugins/rigor-activesupport-core-ext/lib/rigor/plugin/activesupport_core_ext.rb +264 -9
  175. data/plugins/rigor-activesupport-core-ext/sig/active_support/core_ext.rbs +876 -23
  176. data/plugins/rigor-dry-monads/lib/rigor/plugin/dry_monads.rb +99 -0
  177. data/plugins/rigor-dry-monads/lib/rigor-dry-monads.rb +5 -0
  178. data/plugins/rigor-ethon/lib/rigor/plugin/ethon.rb +34 -0
  179. data/plugins/rigor-ethon/lib/rigor-ethon.rb +3 -0
  180. data/plugins/rigor-ethon/sig/ethon.rbs +27 -0
  181. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +3 -2
  182. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/analyzer.rb +198 -0
  183. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/binding_recognizer.rb +75 -0
  184. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/catalog.rb +64 -0
  185. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +173 -0
  186. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +46 -0
  187. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/types.rb +161 -0
  188. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +121 -0
  189. data/plugins/rigor-ffi/lib/rigor-ffi.rb +3 -0
  190. data/plugins/rigor-ffi/sig/ffi.rbs +67 -0
  191. data/plugins/rigor-ffi-rzmq/lib/rigor/plugin/ffi_rzmq.rb +19 -0
  192. data/plugins/rigor-ffi-rzmq/lib/rigor-ffi-rzmq.rb +3 -0
  193. data/plugins/rigor-ffi-rzmq/sig/ffi_rzmq.rbs +29 -0
  194. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +2 -4
  195. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +2 -1
  196. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +2 -1
  197. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +2 -1
  198. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +2 -1
  199. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +2 -1
  200. data/plugins/rigor-railties/lib/rigor/plugin/railties.rb +156 -11
  201. data/plugins/rigor-rbnacl/lib/rigor/plugin/rbnacl.rb +30 -0
  202. data/plugins/rigor-rbnacl/lib/rigor-rbnacl.rb +3 -0
  203. data/plugins/rigor-rbnacl/sig/rbnacl.rbs +22 -0
  204. data/plugins/rigor-sassc/lib/rigor/plugin/sassc.rb +55 -0
  205. data/plugins/rigor-sassc/lib/rigor-sassc.rb +3 -0
  206. data/plugins/rigor-sassc/sig/sassc.rbs +24 -0
  207. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +32 -11
  208. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/analyzer.rb +3 -1
  209. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/schedule_scan.rb +4 -1
  210. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +63 -10
  211. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +15 -2
  212. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +61 -2
  213. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/catalog_walker.rb +2 -2
  214. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +4 -2
  215. data/sig/rigor/analysis/baseline.rbs +1 -1
  216. data/sig/rigor/environment.rbs +9 -0
  217. data/sig/rigor/inference.rbs +4 -1
  218. data/sig/rigor/plugin/io_boundary.rbs +2 -0
  219. data/sig/rigor/reflection.rbs +3 -1
  220. data/sig/rigor/scope.rbs +25 -1
  221. data/sig/rigor/type.rbs +95 -1
  222. data/sig/rigor.rbs +16 -0
  223. metadata +71 -3
@@ -7,10 +7,14 @@ require_relative "../reflection"
7
7
  require_relative "../ast"
8
8
  require_relative "../source/constant_path"
9
9
  require_relative "../source/node_children"
10
+ require_relative "../source/node_walker"
10
11
  require_relative "../analysis/self_call_resolution_recorder"
11
12
  require_relative "block_parameter_binder"
13
+ require_relative "method_parameter_binder"
12
14
  require_relative "body_fixpoint"
13
15
  require_relative "budget_trace"
16
+ require_relative "captured_locals"
17
+ require_relative "def_node_resolver"
14
18
  require_relative "dynamic_origin"
15
19
  require_relative "origin_lookup"
16
20
  require_relative "../effects/collector"
@@ -19,10 +23,13 @@ require_relative "flow_tracer"
19
23
  require_relative "indexed_narrowing"
20
24
  require_relative "macro_block_self_type"
21
25
  require_relative "method_dispatcher"
26
+ require_relative "mutation_widening"
22
27
  require_relative "narrowing"
28
+ require_relative "receiver_alias"
23
29
  require_relative "singleton_object_constant"
24
30
  require_relative "optimistic_origin"
25
31
  require_relative "struct_fold_safety"
32
+ require_relative "version_guard"
26
33
 
27
34
  module Rigor
28
35
  module Inference
@@ -51,7 +58,7 @@ module Rigor
51
58
  # docs/internal-spec/inference-engine.md. The optional tracer is a Rigor::Inference::FallbackTracer (or
52
59
  # any object answering #record_fallback) that receives a Fallback event for each fallback; the tracer
53
60
  # MUST NOT change the return value of type_of.
54
- # rubocop:disable Metrics/ClassLength
61
+ # rubocop:disable-next Metrics/ClassLength
55
62
  class ExpressionTyper
56
63
  # Hash-based dispatch keeps `type_of` linear and lets future slices add node kinds without growing a
57
64
  # single case statement past RuboCop's cyclomatic budget. Anonymous Prism subclasses are not expected.
@@ -83,6 +90,9 @@ module Rigor
83
90
  Prism::ProgramNode => :type_of_program,
84
91
  # Calls
85
92
  Prism::CallNode => :call_type_for,
93
+ Prism::CallOrWriteNode => :call_or_write_type_for,
94
+ Prism::CallAndWriteNode => :call_and_write_type_for,
95
+ Prism::CallOperatorWriteNode => :call_operator_write_type_for,
86
96
  Prism::ArgumentsNode => :type_of_non_value,
87
97
  # Constants
88
98
  Prism::ConstantReadNode => :type_of_constant_read,
@@ -421,11 +431,16 @@ module Rigor
421
431
  resolve_constant_name(node.name.to_s) || unresolved_constant_fallback(node, node.name.to_s)
422
432
  end
423
433
 
434
+ # A leading `::` (`::Rails`, `::Rails::Application`) is Ruby's escape hatch out of the lexical ladder:
435
+ # it names the top-level constant whatever the enclosing nesting defines. The rendered name is
436
+ # deliberately un-rooted (the discovery tables are keyed that way), so the marker rides alongside it
437
+ # into the resolver (#614).
424
438
  def type_of_constant_path(node)
425
439
  full_name = Source::ConstantPath.qualified_name_or_nil(node)
426
440
  return fallback_for(node, family: :prism) if full_name.nil?
427
441
 
428
- resolve_constant_name(full_name) || unresolved_constant_fallback(node, full_name)
442
+ resolve_constant_name(full_name, rooted: Source::ConstantPath.rooted?(node)) ||
443
+ unresolved_constant_fallback(node, full_name)
429
444
  end
430
445
 
431
446
  # ADR-82 WD9 — an unresolved constant whose root name a locked, RBS-less gem declares carries the
@@ -436,6 +451,7 @@ module Rigor
436
451
  # `Dynamic[top]`, and an unindexed constant (project typo, unanalyzed project path) keeps the generic
437
452
  # cause: the fail-open direction is a missing label, never a wrong one.
438
453
  def unresolved_constant_fallback(node, full_name)
454
+ record_missing_constant(full_name) if Analysis::DependencyRecorder.active?
439
455
  root = full_name.delete_prefix("::").split("::").first
440
456
  owner = root && scope.environment.missing_rbs_gem_owner(root)
441
457
  return fallback_for(node, family: :prism) unless owner
@@ -446,13 +462,50 @@ module Rigor
446
462
  inner
447
463
  end
448
464
 
465
+ # ADR-46 slice 3 / issue #622 — a constant reference that resolved to NOTHING is a negative cross-file
466
+ # dependency: a file declaring that constant later must re-check every file that read it as missing.
467
+ # Without this edge the structural tier had no record of the read at all — `read_missing(:method, …)`
468
+ # fires only once the receiver constant has RESOLVED and the method lookup misses, so a missing
469
+ # constant short-circuited before any edge existed, and a warm `--incremental` run kept
470
+ # `Rails.logger`'s `Dynamic[top]` (and missed the `call.undefined-method` a full run fires) after a
471
+ # later edit added `module Rails`.
472
+ #
473
+ # Reuses the existing `class:Name` negative kind (`CheckRules`'s override-ancestor miss records the
474
+ # same one) rather than adding a `constant:` kind, because the producer that has to invert it already
475
+ # exists: `Incremental.appeared_classes` reports a newly declared class / module by its QUALIFIED name
476
+ # and `negative_affected` matches it by simple (last-segment) name. The snapshot row grammar is
477
+ # therefore unchanged — no `IncrementalSnapshot::SCHEMA` bump — and a snapshot recorded by an engine
478
+ # without this edge is already dropped by the fingerprint's engine-source part.
479
+ #
480
+ # The LAST segment is the whole key, and one row per reference is the whole cost: a qualified read
481
+ # resolves only once its final segment is declared, and every constant form that resolves cross-file —
482
+ # a `class` / `module`, and the constant-assigned `Data.define` / `Struct.new` forms — registers in the
483
+ # discovery pre-pass's class sources under its qualified name, so its final segment always appears.
484
+ # (A plain value constant, `FOO = 1` or a `VERSION` nested in a module, resolves in NO cross-file read
485
+ # today, so there is nothing for a root-segment or a `constant:` key to salvage; if that changes, the
486
+ # answer is a producer that reports appeared value constants, not a wider key here.) Simple-name
487
+ # matching over-invalidates — a nested `MyApp::Rails` also re-checks a reader of the top-level `Rails`
488
+ # — which is the sound direction and the grammar the class negatives already use. Consumers hold
489
+ # `missing` as a Set, so a name repeated across a file still costs one row.
490
+ def record_missing_constant(full_name)
491
+ segments = full_name.delete_prefix("::").split("::")
492
+ return if segments.empty?
493
+
494
+ Analysis::DependencyRecorder.read_missing(:class, segments.last)
495
+ # Issue #644's `constant:<last segment>` edge is NOT recorded here. It is recorded once per
496
+ # reference by `Reflection.resolve_constant_type`, before the resolver ladder runs, so it covers a
497
+ # reference that resolves through RBS or the class registry as well as one that misses — all three
498
+ # answers move when the project's constant write set moves. Recording it again on this path would be
499
+ # a second source of truth for the same key.
500
+ end
501
+
449
502
  # Resolves a constant reference through Ruby's lexical constant lookup. Delegates to the shared
450
503
  # `Reflection.resolve_constant_type` owner so the same walk (registry singleton, discovered class,
451
504
  # in-source value, RBS constant, across the peeled `::` prefix candidates) is reused by
452
505
  # `Inference::Narrowing`'s `Constant[Regexp]` match-operand recognition. Returns the matched
453
506
  # `Rigor::Type` or nil; the caller decides whether to fall back.
454
- def resolve_constant_name(name)
455
- Reflection.resolve_constant_type(name, scope: scope)
507
+ def resolve_constant_name(name, rooted: false)
508
+ Reflection.resolve_constant_type(name, scope: scope, rooted: rooted)
456
509
  end
457
510
 
458
511
  # Slice 5 phase 1 upgrades hash literals to `HashShape{...}` when every entry is a static `AssocNode`
@@ -642,6 +695,14 @@ module Rigor
642
695
  # `Constant[false]` fold one branch; `Union[true, false]`, `Dynamic[T]`, and `Top` keep both branches live.
643
696
  def constant_predicate_polarity(predicate)
644
697
  return nil if predicate.nil?
698
+
699
+ # ADR-47 WD5 — a decidable version guard (#627) answers first, exactly as it does on the scope side
700
+ # in `StatementEvaluator#branch_certainty`. Both readers ask the same pure function of the AST, so
701
+ # the expression form (`RUBY_VERSION >= "3.1" ? a : b`) and the statement form cannot disagree about
702
+ # which arm survives. The verdict rests on literals, so the ADR-101 optimistic-carrier decline below
703
+ # — which guards an RBS-derived judgment — does not apply to it.
704
+ guard = VersionGuard.verdict(predicate)
705
+ return guard if guard
645
706
  # ADR-101 — decline on an optimistically nil-free carrier; see
646
707
  # `StatementEvaluator#optimistic_carrier?` for why the gate is here and not in `Narrowing`.
647
708
  return nil unless optimistic_origin_for(predicate).nil?
@@ -957,10 +1018,10 @@ module Rigor
957
1018
  end
958
1019
  end
959
1020
 
960
- def fallback_for(node, family:)
1021
+ def fallback_for(node, family:, origin: DynamicOrigin::UNSUPPORTED_SYNTAX)
961
1022
  inner = dynamic_top
962
- record_fallback(node, family: family, inner_type: inner, origin: DynamicOrigin::UNSUPPORTED_SYNTAX)
963
- scope.record_dynamic_origin(node, DynamicOrigin::UNSUPPORTED_SYNTAX)
1023
+ record_fallback(node, family: family, inner_type: inner, origin: origin)
1024
+ scope.record_dynamic_origin(node, origin)
964
1025
  inner
965
1026
  end
966
1027
 
@@ -1107,9 +1168,19 @@ module Rigor
1107
1168
  # Issue #316 — the lookup goes through the confidence-gated `Scope#bindable_top_level_def_for`, not the
1108
1169
  # raw table: inside a block whose `self` is unmodelled, a top-level `def` from ANOTHER file is not
1109
1170
  # evidence about which method the call reaches, so the bind is declined and the call widens.
1171
+ #
1172
+ # Issue #618 — the binding is a fallback for names the enclosing `self` does NOT answer, not a
1173
+ # first-choice tier. A top-level `def` is a private method on `Object`, the last link of every MRO, so
1174
+ # whenever the call's own `self` carries the name the class wins at runtime and the top-level body is
1175
+ # never reached. Binding regardless of `self_type` inverted that: a top-level `def text` typed the
1176
+ # member read inside `class Line < Struct.new(:text); def shout = text.upcase; end` as the def's `nil`
1177
+ # and fired `undefined method 'upcase' for nil` on correct code. The candidate is still looked up
1178
+ # first — that lookup is a hash probe and owns the ADR-46 cross-file dependency edge — and
1179
+ # {#self_type_answers?} then vetoes the bind for a name the enclosing class answers itself.
1110
1180
  def try_local_def_dispatch(node, receiver, arg_types)
1111
1181
  local_def = node.receiver.nil? ? scope.bindable_top_level_def_for(node.name) : nil
1112
1182
  return nil unless local_def
1183
+ return nil if self_type_answers?(node.name)
1113
1184
 
1114
1185
  local_inference = infer_top_level_user_method(local_def, receiver, arg_types)
1115
1186
  return local_inference if local_inference
@@ -1123,15 +1194,193 @@ module Rigor
1123
1194
  dynamic_top
1124
1195
  end
1125
1196
 
1197
+ # Issue #618 — whether the call's enclosing `self` already answers `method_name`. Only a `self` whose
1198
+ # class is KNOWN participates: at genuine top level, and inside a block whose `self` is unmodelled,
1199
+ # `scope.self_type` is nil, the predicate is false, and the historical top-level binding stands — that
1200
+ # is #316's / #319's territory and this veto stays out of it.
1201
+ #
1202
+ # The ADR-48 member carriers answer from their own map first: a `StructInstance` / `DataInstance`
1203
+ # `self` carries the member set the body reads through, and an anonymous `Struct.new(...)` value has no
1204
+ # class name to look anything else up under.
1205
+ def self_type_answers?(method_name)
1206
+ case (self_type = scope.self_type)
1207
+ when Type::Singleton then singleton_self_answers?(self_type.class_name, method_name)
1208
+ when Type::Nominal then instance_self_answers?(self_type.class_name, method_name)
1209
+ when Type::StructInstance, Type::DataInstance
1210
+ self_type.members.key?(method_name.to_sym) || instance_self_answers?(self_type.class_name, method_name)
1211
+ else false
1212
+ end
1213
+ end
1214
+
1215
+ # The instance side of {#self_type_answers?}: the class's own discovered methods (`def`, `attr_*`,
1216
+ # `define_method`, `alias`), its `Struct.new` / `Data.define` member accessors, a `def` reached through
1217
+ # its project ancestors (superclass chain and included modules), and an RBS method declared on the
1218
+ # class ITSELF.
1219
+ #
1220
+ # The RBS arm is own-class only, deliberately. An inherited-declaration test would match every
1221
+ # `Object` / `Kernel` / `Enumerable` name and retract the binding v0.0.3 A exists for — a `def
1222
+ # select(...)` collocated with its DSL-block call site would route straight back through
1223
+ # `Enumerable#select`.
1224
+ def instance_self_answers?(class_name, method_name)
1225
+ return false if class_name.nil?
1226
+ return true if scope.discovered_method?(class_name, method_name, :instance)
1227
+ return true if meta_member?(class_name, method_name)
1228
+ return true if resolve_user_def_through_ancestors(class_name, method_name)
1229
+
1230
+ rbs_declared_on_class?(safe_rbs_method_definition(class_name, method_name, :instance), class_name)
1231
+ end
1232
+
1233
+ # The singleton side: a class-body `self` is `Singleton[Foo]`, where an implicit-self call reaches
1234
+ # `Foo`'s own class methods before `Object`'s private top-level `def`.
1235
+ def singleton_self_answers?(class_name, method_name)
1236
+ return false if class_name.nil?
1237
+ return true if scope.discovered_method?(class_name, method_name, :singleton)
1238
+ return true unless scope.singleton_def_for(class_name, method_name).nil?
1239
+
1240
+ rbs_declared_on_class?(safe_rbs_method_definition(class_name, method_name, :singleton), class_name)
1241
+ end
1242
+
1243
+ # A `Struct.new(:a, :b)` / `Data.define(:a, :b)` member accessor. The layouts are a discovery table of
1244
+ # their own, separate from `discovered_methods`, so the accessor names they imply have to be asked for
1245
+ # explicitly. Only the reader name is tested: a writer is unreachable as an implicit-self call, since
1246
+ # bare `a = v` is a local assignment.
1247
+ def meta_member?(class_name, method_name)
1248
+ layout = scope.struct_member_layout(class_name)
1249
+ members = layout ? layout[:members] : scope.data_member_layout(class_name)
1250
+ !members.nil? && members.include?(method_name.to_sym)
1251
+ end
1252
+
1253
+ def safe_rbs_method_definition(class_name, method_name, kind)
1254
+ if kind == :singleton
1255
+ Rigor::Reflection.singleton_method_definition(class_name, method_name, scope: scope)
1256
+ else
1257
+ Rigor::Reflection.instance_method_definition(class_name, method_name, scope: scope)
1258
+ end
1259
+ rescue StandardError
1260
+ nil
1261
+ end
1262
+
1263
+ # True when the RBS declaration found for the name sits on `class_name` itself rather than on an
1264
+ # ancestor; mirrors `CheckRules#defined_on?` and `SigGen::Generator#declared_on_class_itself?`.
1265
+ def rbs_declared_on_class?(definition, class_name)
1266
+ return false if definition.nil?
1267
+ return false unless definition.respond_to?(:defined_in)
1268
+
1269
+ defined_in = definition.defined_in
1270
+ return false if defined_in.nil?
1271
+
1272
+ defined_in.to_s.delete_prefix("::") == class_name.to_s.delete_prefix("::")
1273
+ end
1274
+
1275
+ # Issue #520 — Ruby defines the value of an attribute / index assignment (`x.attr = v`, `h[k] = v`)
1276
+ # as the RHS object itself, whatever the writer method returns. The dispatch pipeline still runs
1277
+ # first for everything it observes on the side (effect collection, provenance, recorders, and the
1278
+ # rules' own view of the writer), but its RESULT is discarded in favor of the last argument's type —
1279
+ # `Hash#[]=`'s declared V made `h[k] = true` read Dynamic on an untyped hash, ~300 sites across the
1280
+ # 2026-09-01 corpus sweep. Safe-navigation writes stay on the dispatch result: `x&.attr = v` is
1281
+ # `v | nil`, which is #518's (safe-navigation) territory, not plain value semantics.
1282
+ def call_type_for(node)
1283
+ return safe_navigation_call_type(node) if node.safe_navigation?
1284
+
1285
+ attribute_write_value(node, call_dispatch_type_for(node))
1286
+ end
1287
+
1288
+ # The RHS-value override for plain attribute / index writes (#520); a non-write call keeps the
1289
+ # dispatch result.
1290
+ def attribute_write_value(node, result)
1291
+ return result unless node.attribute_write?
1292
+
1293
+ rhs = node.arguments&.arguments&.last
1294
+ return result if rhs.nil? || rhs.is_a?(Prism::SplatNode)
1295
+
1296
+ type_of(rhs)
1297
+ end
1298
+
1299
+ # Issue #518 — `x&.m` is NOT a plain call: when `x` is nil the method never runs and the expression
1300
+ # is nil. Typed as a plain call it inherited both defects of union dispatch — the nil arm's method
1301
+ # was folded as if `&.` called it (`s&.to_s` on `String?` read `String`, missing the nil the runtime
1302
+ # produces), and a method absent from NilClass declined the whole union to `Dynamic[top]` even when
1303
+ # the non-nil arm is fully typed (herb's own sig declares `Token#value: String`; the `?` alone
1304
+ # discarded it). The call is dispatched on the nil-stripped receiver and the nil the skip produces is
1305
+ # unioned back in; a receiver that IS nil skips the call statically.
1306
+ def safe_navigation_call_type(node)
1307
+ # A literal `nil&.m` is the statically-skipped call and folds to nil. An INFERRED exactly-nil
1308
+ # receiver deliberately does NOT fold: every corpus site with that shape traced to a wrong
1309
+ # upstream nil (an `attr_writer`-backed ivar whose only static write is nil — #541 — or a mutated
1310
+ # literal-shape constant — #540), and folding it turned those latent wrong types into
1311
+ # `flow.always-truthy-condition` firings on working programs. Until those uplinks are honest,
1312
+ # the inferred-nil receiver keeps the plain pipeline's Dynamic.
1313
+ return Type::Combinator.constant_of(nil) if node.receiver.is_a?(Prism::NilNode)
1314
+
1315
+ receiver = type_of(node.receiver)
1316
+ non_nil = Narrowing.narrow_non_nil(receiver)
1317
+ # A Bot or Dynamic fragment cannot improve on the plain pipeline (Bot: the inferred-exactly-nil
1318
+ # receivers trace to the #540 / #541 uplinks; Dynamic: the stripped dispatch re-answers Dynamic),
1319
+ # so both keep the historical path and its flow bookkeeping.
1320
+ return call_dispatch_type_for(node) if non_nil.is_a?(Type::Bot) || non_nil.is_a?(Type::Dynamic)
1321
+
1322
+ result = attribute_write_value(node, call_dispatch_type_for(node, receiver_override: non_nil))
1323
+ # Structural equality: `narrow_non_nil` rebuilds a Union even when it removed nothing, and a
1324
+ # receiver that cannot be nil must not have a phantom nil unioned into its result.
1325
+ return result if non_nil == receiver
1326
+
1327
+ Type::Combinator.union(result, Type::Combinator.constant_of(nil))
1328
+ end
1329
+
1330
+ # Issue #532 — the attribute compound-write family (`x.attr ||= v`, `&&=`, `+=`), the last
1331
+ # genuinely unmodeled value-position constructs the 2026-09-01 corpus census found (they fell to the
1332
+ # `unsupported_syntax` fallback). Value semantics mirror the local / ivar / index siblings:
1333
+ # `||=` is `truthy(read) | rhs`, `&&=` is `falsey(read) | rhs`, and an operator write is the
1334
+ # operator dispatched on the read result. A `&.`-form compound write unions the skipped-call nil in
1335
+ # (#518's rule). Scope effects stay as before (none) — the struct member writeback and shape
1336
+ # widening for these forms are follow-up work recorded on #532.
1337
+ def call_or_write_type_for(node)
1338
+ current = attribute_compound_read_type(node)
1339
+ value = Type::Combinator.union(Narrowing.narrow_truthy(current), type_of(node.value))
1340
+ with_compound_write_safe_nav(node, value)
1341
+ end
1342
+
1343
+ def call_and_write_type_for(node)
1344
+ current = attribute_compound_read_type(node)
1345
+ value = Type::Combinator.union(Narrowing.narrow_falsey(current), type_of(node.value))
1346
+ with_compound_write_safe_nav(node, value)
1347
+ end
1348
+
1349
+ def call_operator_write_type_for(node)
1350
+ current = attribute_compound_read_type(node)
1351
+ result = MethodDispatcher.dispatch(
1352
+ receiver_type: current, method_name: node.binary_operator, arg_types: [type_of(node.value)],
1353
+ environment: scope.environment, call_node: node, scope: scope
1354
+ ) || dynamic_top
1355
+ with_compound_write_safe_nav(node, result)
1356
+ end
1357
+
1358
+ def attribute_compound_read_type(node)
1359
+ receiver = type_of(node.receiver)
1360
+ MethodDispatcher.dispatch(
1361
+ receiver_type: receiver, method_name: node.read_name, arg_types: [],
1362
+ environment: scope.environment, call_node: node, scope: scope
1363
+ ) || dynamic_top
1364
+ end
1365
+
1366
+ def with_compound_write_safe_nav(node, value)
1367
+ return value unless node.call_operator_loc&.slice == "&."
1368
+
1369
+ Type::Combinator.union(value, Type::Combinator.constant_of(nil))
1370
+ end
1371
+
1126
1372
  # Slice 2 routes call expressions through `MethodDispatcher`. The receiver and every argument are typed
1127
1373
  # first, then the dispatcher is asked for a result type. A nil result triggers the fail-soft fallback
1128
1374
  # for the CallNode itself (the inner type_of calls already record their own fallbacks for unrecognised
1129
1375
  # receivers/args, so the tracer captures both the immediate dispatch miss and the deeper cause).
1130
- def call_type_for(node)
1376
+ # `receiver_override` substitutes the receiver type for the whole pipeline (folds, dispatch, the
1377
+ # inference tiers) without re-reading the receiver node — the safe-navigation path (#518) dispatches
1378
+ # on the nil-stripped fragment, and the optional-receiver retry (#519) re-runs the pipeline on it.
1379
+ def call_dispatch_type_for(node, receiver_override: nil)
1131
1380
  narrowed = indexed_narrowing_for(node)
1132
1381
  return narrowed if narrowed
1133
1382
 
1134
- receiver = call_receiver_type_for(node)
1383
+ receiver = receiver_override || call_receiver_type_for(node)
1135
1384
  arg_types = call_arg_types(node)
1136
1385
  block_type = block_return_type_for(node, receiver, arg_types)
1137
1386
 
@@ -1140,6 +1389,9 @@ module Rigor
1140
1389
  # asks nothing further of dispatch. Off (the default) this is one integer read.
1141
1390
  Effects::Collector.record_call(node, receiver, scope) if Effects::Collector.active?
1142
1391
 
1392
+ literal_send = try_literal_send(node, receiver)
1393
+ return literal_send if literal_send
1394
+
1143
1395
  local_def_result = try_local_def_dispatch(node, receiver, arg_types)
1144
1396
  return local_def_result if local_def_result
1145
1397
 
@@ -1148,14 +1400,8 @@ module Rigor
1148
1400
  # the corresponding element bound to the block parameter and assemble the results into a
1149
1401
  # `Tuple[U_1..U_n]`. This sits ahead of `MethodDispatcher.dispatch` so the RBS tier does not re-widen
1150
1402
  # the answer back to `Array[union]`.
1151
- per_element = try_per_element_block_fold(node, receiver)
1152
- return per_element if per_element
1153
-
1154
- inject_fold = try_block_inject_fold(node, receiver, arg_types)
1155
- return inject_fold if inject_fold
1156
-
1157
- hash_transform = try_hash_shape_block_fold(node, receiver)
1158
- return hash_transform if hash_transform
1403
+ block_fold = try_receiver_block_folds(node, receiver, arg_types)
1404
+ return block_fold if block_fold
1159
1405
 
1160
1406
  result = MethodDispatcher.dispatch(
1161
1407
  receiver_type: receiver,
@@ -1168,6 +1414,12 @@ module Rigor
1168
1414
  )
1169
1415
  return result if result
1170
1416
 
1417
+ dispatch_miss_result(node, receiver, arg_types)
1418
+ end
1419
+
1420
+ # The post-dispatch tiers for a call `MethodDispatcher` could not answer, in their historical
1421
+ # order; extracted from {#call_dispatch_type_for} whole.
1422
+ def dispatch_miss_result(node, receiver, arg_types)
1171
1423
  # v0.0.2 #5 — inter-procedural inference for user-defined methods. When dispatch misses but the
1172
1424
  # receiver is a user class with a `def` body, re-type the body with the call's argument types bound
1173
1425
  # and return the body's last-expression type.
@@ -1187,9 +1439,46 @@ module Rigor
1187
1439
  # semantic outcome, not a fail-soft compromise, so it MUST NOT record a tracer event.
1188
1440
  return inherit_receiver_origin(node) if receiver.is_a?(Type::Dynamic)
1189
1441
 
1442
+ # Issue #519 — a `T | nil` receiver whose dispatch exhausted every tier: the nil arm vetoed the
1443
+ # union (dispatch_union declines when ANY member declines, and the later tiers refuse unions), so
1444
+ # `StringScanner?#scan` lost a type its RBS fully declares. Retry the whole pipeline on the
1445
+ # non-nil fragment and answer its result: the nil path raises, which is `possible-nil-receiver`'s
1446
+ # job (that rule reads the RECEIVER and is untouched); the value describes the path that returns
1447
+ # (ADR-5 optimism, same polarity as the accessor-read nil-drop ADR-58 records). Unions whose
1448
+ # non-nil members still decline fall through unchanged.
1449
+ optional_retry = try_non_nil_receiver_retry(node, receiver)
1450
+ return optional_retry if optional_retry
1451
+
1190
1452
  unresolved_call_result(node, receiver)
1191
1453
  end
1192
1454
 
1455
+ def try_non_nil_receiver_retry(node, receiver)
1456
+ return nil unless receiver.is_a?(Type::Union)
1457
+ # When NilClass DEFINES the method (`nil?`, `to_s`, `==`, `inspect`, …), the nil arm is a live
1458
+ # returner, not the veto — the union declined on some other member, and answering the stripped
1459
+ # receiver's result would drop the nil arm's contribution (`(Journal | nil).nil?` must never
1460
+ # answer the non-nil arm's constant-folded `false`). Retry only when the nil path raises.
1461
+ return nil if nil_class_defines?(node.name)
1462
+
1463
+ non_nil = Narrowing.narrow_non_nil(receiver)
1464
+ # Structural equality, not identity: `narrow_non_nil` rebuilds a Union even when it removed
1465
+ # nothing, and an identity check would send a nil-free union that exhausts dispatch through the
1466
+ # retry forever. A Dynamic fragment is excluded too: its retry can only re-answer Dynamic, and
1467
+ # running the pipeline twice for that non-answer perturbs the flow bookkeeping for nothing.
1468
+ return nil if non_nil.is_a?(Type::Bot) || non_nil.is_a?(Type::Dynamic) || non_nil == receiver
1469
+
1470
+ call_dispatch_type_for(node, receiver_override: non_nil)
1471
+ end
1472
+
1473
+ # Conservative when NilClass's definition is unavailable: without the proof that the nil path
1474
+ # raises, the retry stays off.
1475
+ def nil_class_defines?(method_name)
1476
+ definition = Rigor::Reflection.instance_definition("NilClass", scope: scope)
1477
+ return true if definition.nil?
1478
+
1479
+ !definition.methods[method_name.to_sym].nil?
1480
+ end
1481
+
1193
1482
  # The engine choke-point where a call has exhausted every resolution tier (RBS dispatch + user-class
1194
1483
  # ancestor walk) and falls through to `Dynamic[top]`. Two observational recorders read it, both a
1195
1484
  # plain integer read when inactive, and neither changes the answer:
@@ -1201,9 +1490,85 @@ module Rigor
1201
1490
  record_unresolved_self_call(node, receiver) if Analysis::SelfCallResolutionRecorder.active?
1202
1491
  Effects::Collector.record_unresolved(node, scope.source_path) if Effects::Collector.active?
1203
1492
 
1204
- fallback_for(node, family: :prism)
1493
+ fallback_for(node, family: :prism, origin: unresolved_call_origin(receiver, node.name))
1494
+ end
1495
+
1496
+ # Issue #522 — the honest cause for a call the tiers exhausted. When the receiver's class HAS a
1497
+ # discovered project def for the method, the miss is a return the engine could not infer (the
1498
+ # discovered-method tier deliberately declined in favor of body inference, which then declined too —
1499
+ # `MethodDispatcher#try_discovered_method`'s decline arms), which is ADR-82's
1500
+ # `INFERRED_RETURN_UNTYPED`, not "unsupported syntax". Without this, every service-object `#call`
1501
+ # whose body defeats inference reports to `coverage --protection` as a syntax gap. The generic cause
1502
+ # stays for genuinely unresolved names (framework DSL sends, methods no scanned file defines).
1503
+ def unresolved_call_origin(receiver, method_name)
1504
+ class_name, kind = case receiver
1505
+ when Type::Nominal then [receiver.class_name, :instance]
1506
+ when Type::Singleton then [receiver.class_name, :singleton]
1507
+ else [nil, nil]
1508
+ end
1509
+ return DynamicOrigin::UNSUPPORTED_SYNTAX if class_name.nil?
1510
+ return DynamicOrigin::INFERRED_RETURN_UNTYPED if scope.discovered_method?(class_name, method_name, kind)
1511
+ # Issue #530 item 1 — the receiver's own class declares nothing, but its ancestry LEAVES the project
1512
+ # into a locked gem that ships no RBS. That gem is the honest cause: the method is inherited from a
1513
+ # class Rigor cannot see, and the user's action is `add_rbs`, not "report an engine gap".
1514
+ return DynamicOrigin::EXTERNAL_GEM_WITHOUT_RBS if external_gem_reached_through_ancestry?(class_name)
1515
+
1516
+ DynamicOrigin::UNSUPPORTED_SYNTAX
1517
+ end
1518
+
1519
+ # Issue #530 item 1 — whether `class_name`'s ancestry crosses out of the project into a locked, RBS-less
1520
+ # gem. WD9's tagging keys on CONSTANT READS, which catches `Parser::AST::Node` where it is WRITTEN (the
1521
+ # superclass position) and nothing after: every implicit-self call inherited into the subclass body —
1522
+ # `node_parts` on rubocop-ast, and the 125-site `#[]` chain hanging off it — recorded the generic cause,
1523
+ # so the target reported 13 add-rbs against 687 engine-gap where the honest story is a gem with no RBS.
1524
+ #
1525
+ # The boundary is exactly the ancestor name that resolves to NO project class. Inside the project the
1526
+ # walk continues; the first name that leaves is the one whose root segment the missing-gem index can
1527
+ # own. A name that leaves into a gem the index does NOT claim (RBS present, not locked, entry file
1528
+ # unreadable) yields nothing and the generic cause stands — the fail-open direction ADR-82 requires,
1529
+ # where the failure mode is a missing label and never a wrong one.
1530
+ #
1531
+ # Bounded by {Scope::ANCESTOR_WALK_LIMIT}'s spirit rather than its constant: this is a provenance
1532
+ # side-channel on an ALREADY-unresolved call, so a deep hierarchy must cost a bounded amount and then
1533
+ # give up rather than pay for a perfect answer nothing type-checks against.
1534
+ def external_gem_reached_through_ancestry?(class_name)
1535
+ environment = scope.environment
1536
+ return false if environment.nil? || !environment.respond_to?(:missing_rbs_gem_owner)
1537
+
1538
+ seen = {}
1539
+ queue = [class_name.to_s]
1540
+ visited = 0
1541
+ until queue.empty?
1542
+ current = queue.shift
1543
+ next if current.nil? || seen[current]
1544
+
1545
+ seen[current] = true
1546
+ visited += 1
1547
+ return false if visited > EXTERNAL_GEM_ANCESTRY_LIMIT
1548
+
1549
+ return true if ancestry_step_leaves_project?(current, queue, environment)
1550
+ end
1551
+ false
1552
+ end
1553
+
1554
+ # One hop of {#external_gem_reached_through_ancestry?}: pushes `current`'s ancestors that stay INSIDE
1555
+ # the project onto `queue`, and answers whether any that leaves lands in a gem the index claims.
1556
+ def ancestry_step_leaves_project?(current, queue, environment)
1557
+ raw_ancestors = scope.includes_of(current) + [scope.superclass_of(current)].compact
1558
+ raw_ancestors.any? do |raw|
1559
+ resolved = scope.ancestor_name_candidates(current, raw).find { |c| scope.known_user_class?(c) }
1560
+ next queue.push(resolved) && false if resolved
1561
+
1562
+ root = raw.to_s.delete_prefix("::").split("::").first
1563
+ !(root.nil? || environment.missing_rbs_gem_owner(root).nil?)
1564
+ end
1205
1565
  end
1206
1566
 
1567
+ # Deliberately smaller than the dispatch walk's cap: this answers a REPORTING question, and a hierarchy
1568
+ # deeper than this has already told us the receiver is not a simple project class.
1569
+ EXTERNAL_GEM_ANCESTRY_LIMIT = 16
1570
+ private_constant :EXTERNAL_GEM_ANCESTRY_LIMIT
1571
+
1207
1572
  # ADR-82 WD6 — carry the receiver's provenance onto the call it produces (returning the unchanged
1208
1573
  # `dynamic_top` result), so a specific cause survives a method chain (`x.foo.bar`): without this,
1209
1574
  # `.foo` on a Dynamic `x` records nothing and `.bar`'s receiver looks causeless. Side-channel only
@@ -1326,20 +1691,121 @@ module Rigor
1326
1691
  end
1327
1692
  end
1328
1693
 
1329
- def try_user_method_inference(receiver, call_node, arg_types)
1330
- return nil unless receiver.is_a?(Type::Nominal)
1694
+ # #525 instance-side user-method inference accepts the named ADR-48 member carriers alongside
1695
+ # plain nominals: a `Line = Struct.new(...) do ... end` value is a StructInstance whose block defs
1696
+ # live under "Line", and the carrier itself is the right `self` for the body (member reads inside
1697
+ # the def project through it).
1698
+ def user_inference_receiver?(receiver)
1699
+ case receiver
1700
+ when Type::Nominal then true
1701
+ when Type::StructInstance, Type::DataInstance then !receiver.class_name.nil?
1702
+ else false
1703
+ end
1704
+ end
1705
+
1706
+ # The three receiver-shaped block folds, in their historical order (extracted whole from
1707
+ # `call_dispatch_type_for` for method-length budget).
1708
+ def try_receiver_block_folds(node, receiver, arg_types)
1709
+ per_element = try_per_element_block_fold(node, receiver)
1710
+ return per_element if per_element
1711
+
1712
+ inject_fold = try_block_inject_fold(node, receiver, arg_types)
1713
+ return inject_fold if inject_fold
1714
+
1715
+ try_hash_shape_block_fold(node, receiver)
1716
+ end
1717
+
1718
+ # Issue #533 — `x.send(:selector, args)` with a LITERAL symbol is statically `x.selector(args)`:
1719
+ # the private-boundary idiom (protobuf's `send(:get_file_descriptor)` at 84 sites) resolves through
1720
+ # the same dispatch + project-inference tiers as the direct call would. `send` legitimately crosses
1721
+ # visibility, so no public-only gate applies (`public_send` is treated identically — typing the
1722
+ # success path of a call that would raise on a private method is ADR-5 optimism). Declines: a
1723
+ # non-literal selector, a splat / forwarded tail (positional correspondence unknown), or a block at
1724
+ # the call site (block forwarding is out of this slice).
1725
+ LITERAL_SEND_SELECTORS = %i[send __send__ public_send].freeze
1726
+ private_constant :LITERAL_SEND_SELECTORS
1331
1727
 
1332
- def_node, owner = resolve_user_def_with_owner(receiver.class_name, call_node.name)
1728
+ def try_literal_send(node, receiver)
1729
+ return nil unless LITERAL_SEND_SELECTORS.include?(node.name)
1730
+ return nil unless node.block.nil?
1731
+
1732
+ args = node.arguments&.arguments
1733
+ return nil unless args && args.first.is_a?(Prism::SymbolNode)
1734
+
1735
+ rest = args[1..]
1736
+ return nil if rest.any? { |a| a.is_a?(Prism::SplatNode) || a.is_a?(Prism::ForwardingArgumentsNode) }
1737
+
1738
+ inner_name = args.first.unescaped.to_sym
1739
+ inner_args = rest.map { |a| type_of(a) }
1740
+ MethodDispatcher.dispatch(
1741
+ receiver_type: receiver, method_name: inner_name, arg_types: inner_args,
1742
+ block_type: nil, environment: scope.environment, call_node: node, scope: scope
1743
+ ) ||
1744
+ try_user_method_inference(receiver, node, inner_args, method_name: inner_name) ||
1745
+ try_project_singleton_inference(receiver, node, inner_args, method_name: inner_name)
1746
+ end
1747
+
1748
+ def try_user_method_inference(receiver, call_node, arg_types, method_name: call_node.name)
1749
+ return nil unless user_inference_receiver?(receiver)
1750
+
1751
+ def_node, owner = resolve_user_def_with_owner(receiver.class_name, method_name)
1333
1752
  return nil if def_node.nil?
1334
1753
 
1335
- result = infer_user_method_return(def_node, receiver, arg_types)
1754
+ result = infer_user_method_return(def_node, receiver, arg_types,
1755
+ self_fold_safe: fold_safe_call_receiver?(call_node, receiver))
1336
1756
  return result if result.nil?
1337
1757
 
1338
- degrade_if_overridable(result, owner, call_node.name, :instance)
1758
+ degrade_if_overridable(result, owner, method_name, :instance)
1339
1759
  rescue StandardError
1340
1760
  nil
1341
1761
  end
1342
1762
 
1763
+ # Issue #525 — whether the RECEIVER EXPRESSION of this call is one whose struct member map is still
1764
+ # current. Three arms:
1765
+ #
1766
+ # - a call that MATERIALISES the struct (`Point.new(…)`, `Point[…]`, `Struct.new(:x).new(…)`, a
1767
+ # `.with(…)` copy) — the object was created by this very expression, so nothing has run against it;
1768
+ # - a local the fold-safe scan proved is never mutated / aliased / escaped;
1769
+ # - `nil` / `self`, inheriting the CURRENT body's own grant, which is what lets a chain of
1770
+ # implicit-self readers propagate: `Line.new(…).outer` grants `outer`'s body `:self`, and an
1771
+ # `inner` call inside it carries the grant into `inner`'s body.
1772
+ #
1773
+ # The first arm shares `StructFolding`'s materialisation test with the direct member-read gate. Neither
1774
+ # accepts a bare chained call: "chained" is not "fresh" once a method can hand back its own receiver,
1775
+ # and a self-returning fluent builder is ordinary Ruby:
1776
+ #
1777
+ # Line = Struct.new(:text) do
1778
+ # def with_text(v) = (self.text = v; self)
1779
+ # def shout = text.upcase
1780
+ # end
1781
+ # Line.new("a").with_text("z").shout # runtime "Z"
1782
+ #
1783
+ # Accepting the `with_text(…)` receiver as fresh would grant `shout` a member map two statements
1784
+ # stale and fold `"A"`. Only the shapes the folding layer itself materialises qualify here.
1785
+ #
1786
+ # This is only a property of the receiver expression; whether the grant is actually issued also needs
1787
+ # a `StructInstance` carrier and a body that survives the self-use scan, both decided in
1788
+ # {#build_user_method_body_scope}.
1789
+ def fold_safe_call_receiver?(call_node, receiver)
1790
+ return false if call_node.nil?
1791
+
1792
+ case call_node.receiver
1793
+ when Prism::CallNode then materialization_call?(call_node.receiver, receiver)
1794
+ when Prism::LocalVariableReadNode then scope.struct_fold_safe?(call_node.receiver.name)
1795
+ when nil, Prism::SelfNode then scope.struct_fold_safe?(:self)
1796
+ else false
1797
+ end
1798
+ end
1799
+
1800
+ # Issue #595 — the materialisation test lives ONCE, in
1801
+ # [`struct_materialization.rb`](method_dispatcher/struct_materialization.rb), because the direct
1802
+ # member-read gate ({MethodDispatcher::StructFolding}`#fresh_receiver?`) needs the identical answer;
1803
+ # see {MethodDispatcher::StructMaterialization.materialization_call?}. It resolves its own `.with`
1804
+ # guard through `Scope`, so neither consumer supplies a lookup and neither can answer differently.
1805
+ def materialization_call?(node, receiver)
1806
+ MethodDispatcher::StructMaterialization.materialization_call?(node, receiver, scope)
1807
+ end
1808
+
1343
1809
  # Module-singleton call resolution (ADR-57 follow-up) — resolves `Foo.<name>` on a `Singleton[Foo]`
1344
1810
  # receiver against `Foo`'s user-side singleton defs (`def self.x`, `def Foo.x`, a `class << self`
1345
1811
  # body, or a `module_function` method) and re-types the body with the call's argument types bound.
@@ -1347,30 +1813,53 @@ module Rigor
1347
1813
  # (`def self.via; helper(x); end`) re-enters this tier and resolves against the same singleton table
1348
1814
  # — the symmetric counterpart of the instance-side ancestor walk.
1349
1815
  #
1350
- # Resolution is OWN-class only: the singleton-ancestry chain (`extend`ed modules, inherited
1351
- # class-method dispatch) is not walked at this slice. A miss degrades to today's `Dynamic[top]`, never
1352
- # a false resolution (ADR-57 follow-up § module-singleton).
1353
- def try_singleton_method_inference(receiver, call_node, arg_types)
1816
+ # Issue #731 resolution walks the SUPERCLASS chain, so a class method a base class defines answers
1817
+ # on its subclasses (`class Plain < Base` with `def self.build` on `Base`: `Plain.build` was
1818
+ # `Dynamic[top]` while `Plain.new.inst` resolved on the same fixture). Superclasses only, which is the
1819
+ # whole shape of class-method inheritance — an `include`d module's `def self.x` is not callable on the
1820
+ # includer, and `extend M` is already folded into the extender's own singleton entries by
1821
+ # `ScopeIndexer`. A miss still degrades to `Dynamic[top]`, never a false resolution.
1822
+ #
1823
+ # The OWNER — not the receiver class — is what the overridable gate keys on, exactly as the instance
1824
+ # side does: adopting `Base`'s literal return is unsound when a subclass redefines the method, and
1825
+ # after this change the resolved body is routinely not the receiver's own.
1826
+ def try_singleton_method_inference(receiver, call_node, arg_types, method_name: call_node.name)
1354
1827
  return nil unless receiver.is_a?(Type::Singleton)
1355
1828
 
1356
- def_node = scope.singleton_def_for(receiver.class_name, call_node.name)
1829
+ def_node, owner = resolve_singleton_def_with_owner(receiver.class_name, method_name)
1357
1830
  return nil if def_node.nil?
1358
1831
 
1359
1832
  result = infer_user_method_return(def_node, receiver, arg_types)
1360
1833
  return result if result.nil?
1361
1834
 
1362
- degrade_if_overridable(result, receiver.class_name, call_node.name, :singleton)
1835
+ degrade_if_overridable(result, owner, method_name, :singleton)
1363
1836
  rescue StandardError
1364
1837
  nil
1365
1838
  end
1366
1839
 
1840
+ # The singleton twin of {#resolve_user_def_with_owner}. Memoised in a bucket nested under the
1841
+ # SINGLETON def table's identity as well as the class-graph trio: `class_graph_buckets` keys on
1842
+ # `discovered_def_nodes` / `discovered_superclasses` / `discovered_includes`, and this walk reads a
1843
+ # fourth table those three do not imply. Keying on what the answer actually depends on is the #682
1844
+ # lesson — a memo that outlives its inputs serves one scope's answer to another.
1845
+ def resolve_singleton_def_with_owner(class_name, method_name)
1846
+ by_singleton = (class_graph_buckets[:singleton_def] ||= {}.compare_by_identity)
1847
+ cache = (by_singleton[scope.discovered_singleton_def_nodes] ||= {})
1848
+ table = (cache[class_name.to_s] ||= {})
1849
+ key = method_name.to_sym
1850
+ return table[key] if table.key?(key)
1851
+
1852
+ table[key] = scope.singleton_def_through_ancestors(class_name, method_name,
1853
+ name_memo: class_graph_buckets[:name])
1854
+ end
1855
+
1367
1856
  # The project-side singleton-method band: a `Foo.bar` call resolved against a `class << …` / `def self.…`
1368
1857
  # body the project itself wrote. Which of the two tiers applies is decided by the receiver carrier —
1369
1858
  # `Singleton[Foo]` when the constant names a class or module, `Nominal[…]` when it holds an ordinary
1370
1859
  # object (#320) — so the two are mutually exclusive and consulting both is one resolution attempt.
1371
- def try_project_singleton_inference(receiver, call_node, arg_types)
1372
- try_singleton_method_inference(receiver, call_node, arg_types) ||
1373
- try_singleton_object_constant_inference(receiver, call_node, arg_types)
1860
+ def try_project_singleton_inference(receiver, call_node, arg_types, method_name: call_node.name)
1861
+ try_singleton_method_inference(receiver, call_node, arg_types, method_name: method_name) ||
1862
+ try_singleton_object_constant_inference(receiver, call_node, arg_types, method_name: method_name)
1374
1863
  end
1375
1864
 
1376
1865
  # #320 — resolves a call whose receiver is a constant holding an ordinary object with a `class << Const`
@@ -1378,10 +1867,10 @@ module Rigor
1378
1867
  # that object, so the receiver carrier is passed through unchanged. Own-constant only, and only for a
1379
1868
  # name the project actually recorded — a miss degrades to today's `Dynamic[top]`, never a false
1380
1869
  # resolution. `Singleton` receivers never reach here: {#try_singleton_method_inference} owns them.
1381
- def try_singleton_object_constant_inference(receiver, call_node, arg_types)
1870
+ def try_singleton_object_constant_inference(receiver, call_node, arg_types, method_name: call_node.name)
1382
1871
  return nil unless receiver.is_a?(Type::Nominal)
1383
1872
 
1384
- def_node = SingletonObjectConstant.def_node_for(call_node, receiver, call_node.name, scope)
1873
+ def_node = SingletonObjectConstant.def_node_for(call_node, receiver, method_name, scope)
1385
1874
  return nil if def_node.nil?
1386
1875
 
1387
1876
  infer_user_method_return(def_node, receiver, arg_types)
@@ -1396,8 +1885,6 @@ module Rigor
1396
1885
  # project-discovered class/module ends that branch. Cross-file: the chain is followed through
1397
1886
  # `Scope#discovered_superclasses` / `#discovered_includes` / `#discovered_def_nodes`, which the runner
1398
1887
  # seeds from the project-wide pre-pass. The walk is breadth-first, cycle-guarded, and node-count-capped.
1399
- ANCESTOR_WALK_LIMIT = 100
1400
- private_constant :ANCESTOR_WALK_LIMIT
1401
1888
 
1402
1889
  CLASS_GRAPH_CACHE_KEY = :__rigor_class_graph_cache__
1403
1890
  private_constant :CLASS_GRAPH_CACHE_KEY
@@ -1417,7 +1904,10 @@ module Rigor
1417
1904
  store = (Thread.current[CLASS_GRAPH_CACHE_KEY] ||= {}.compare_by_identity)
1418
1905
  by_def = (store[scope.discovered_def_nodes] ||= {}.compare_by_identity)
1419
1906
  by_super = (by_def[scope.discovered_superclasses] ||= {}.compare_by_identity)
1420
- by_super[scope.discovered_includes] ||= { name: {}, user_def: {} }
1907
+ # `self_pure` is issue #525's grant scan (identity-keyed by def node); it belongs here because it
1908
+ # is a pure function of the same frozen index trio — the sibling resolver it walks reads nothing
1909
+ # else.
1910
+ by_super[scope.discovered_includes] ||= { name: {}, user_def: {}, self_pure: {}.compare_by_identity }
1421
1911
  end
1422
1912
 
1423
1913
  def resolve_user_def_through_ancestors(class_name, method_name)
@@ -1438,70 +1928,15 @@ module Rigor
1438
1928
  table[key] = compute_user_def_with_owner(class_name, method_name)
1439
1929
  end
1440
1930
 
1931
+ # ADR-24 slice 2 — the walk itself lives on `Scope` ({Scope#user_def_through_ancestors}); it reads
1932
+ # nothing but the frozen discovery index, and the `.with` guard in
1933
+ # {MethodDispatcher::StructMaterialization} needs the SAME answer without threading dispatcher state
1934
+ # through the dispatcher (#598 review). What stays here is the caching: the run-scoped
1935
+ # `(class_name, method_name)` memo above, plus the per-edge name bucket handed to the walk so a class
1936
+ # whose many methods are resolved pays each ancestor edge once.
1441
1937
  def compute_user_def_with_owner(class_name, method_name)
1442
- queue = [class_name.to_s]
1443
- seen = {}
1444
- visited = 0
1445
- until queue.empty?
1446
- current = queue.shift
1447
- next if current.nil? || seen[current]
1448
-
1449
- seen[current] = true
1450
- visited += 1
1451
- if visited > ANCESTOR_WALK_LIMIT
1452
- BudgetTrace.hit(BudgetTrace::ANCESTOR_WALK_LIMIT)
1453
- return [nil, nil]
1454
- end
1455
-
1456
- found = scope.user_def_for(current, method_name)
1457
- return [found, current] if found
1458
-
1459
- enqueue_ancestors(current, queue)
1460
- end
1461
- [nil, nil]
1462
- end
1463
-
1464
- # Pushes `current`'s direct ancestors onto the BFS queue: included / prepended modules first (Ruby
1465
- # places mixins nearer than the superclass), then the superclass. Each as-written name is resolved
1466
- # against `current`'s lexical nesting; names that resolve to no project class/module are dropped
1467
- # (RBS-known / third-party ancestors).
1468
- def enqueue_ancestors(current, queue)
1469
- scope.includes_of(current).each do |raw|
1470
- resolved = resolve_ancestor_class_name(current, raw)
1471
- queue.push(resolved) if resolved
1472
- end
1473
- raw_super = scope.superclass_of(current)
1474
- return if raw_super.nil?
1475
-
1476
- resolved_super = resolve_ancestor_class_name(current, raw_super)
1477
- queue.push(resolved_super) if resolved_super
1478
- end
1479
-
1480
- # Resolves a superclass name AS WRITTEN (`"Base"`, or a qualified `"A::B"`) to a project-discovered
1481
- # class, following Ruby's `Module.nesting` constant lookup: try the raw name under each enclosing
1482
- # namespace of the subclass, innermost first, then bare. Returns nil when no candidate names a
1483
- # discovered user class (e.g. the superclass is an RBS-known or third-party class).
1484
- def resolve_ancestor_class_name(subclass_qualified, raw_superclass)
1485
- by_subclass = (class_graph_buckets[:name][subclass_qualified] ||= {})
1486
- return by_subclass[raw_superclass] if by_subclass.key?(raw_superclass)
1487
-
1488
- by_subclass[raw_superclass] =
1489
- compute_ancestor_class_name(subclass_qualified, raw_superclass)
1490
- end
1491
-
1492
- def compute_ancestor_class_name(subclass_qualified, raw_superclass)
1493
- segments = subclass_qualified.split("::")
1494
- (segments.length - 1).downto(0) do |i|
1495
- candidate = (segments[0, i] + [raw_superclass]).join("::")
1496
- return candidate if known_user_class?(candidate)
1497
- end
1498
- nil
1499
- end
1500
-
1501
- def known_user_class?(name)
1502
- scope.discovered_superclasses.key?(name) ||
1503
- scope.discovered_def_nodes.key?(name) ||
1504
- scope.discovered_includes.key?(name)
1938
+ scope.user_def_through_ancestors(class_name, method_name,
1939
+ name_memo: class_graph_buckets[:name])
1505
1940
  end
1506
1941
 
1507
1942
  # ADR-57 N5 — overridable-method adoption gate. A self-call resolved to a project `def` whose owner has
@@ -1604,6 +2039,12 @@ module Rigor
1604
2039
  # `owner` — i.e. `candidate` is a subclass of an owner class or an includer of an owner module. Reuses
1605
2040
  # the same BFS resolver the method-resolution ancestor walk uses, so name resolution (lexical nesting,
1606
2041
  # RBS-known-ancestor pruning) is identical.
2042
+ # Delegates to the shared walk's edge step so this BFS and method resolution resolve ancestor names
2043
+ # identically, sharing the per-edge memo bucket.
2044
+ def enqueue_ancestors(current, queue)
2045
+ scope.enqueue_ancestors(current, queue, class_graph_buckets[:name])
2046
+ end
2047
+
1607
2048
  def related_to_owner?(candidate, owner)
1608
2049
  queue = []
1609
2050
  enqueue_ancestors(candidate, queue)
@@ -1617,7 +2058,7 @@ module Rigor
1617
2058
 
1618
2059
  seen[current] = true
1619
2060
  visited += 1
1620
- return false if visited > ANCESTOR_WALK_LIMIT
2061
+ return false if visited > Scope::ANCESTOR_WALK_LIMIT
1621
2062
 
1622
2063
  enqueue_ancestors(current, queue)
1623
2064
  end
@@ -1780,7 +2221,8 @@ module Rigor
1780
2221
  # table, and the WD1 clamp flag so the helpers stay within the parameter-list budget. `def_node` is
1781
2222
  # carried separately (it is the body owner, not call context).
1782
2223
  RecursionContext = Data.define(
1783
- :receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded
2224
+ :receiver, :arg_types, :plain_signature, :summaries, :would_have_been_guarded,
2225
+ :self_fold_safe
1784
2226
  )
1785
2227
  private_constant :RecursionContext
1786
2228
 
@@ -1801,10 +2243,16 @@ module Rigor
1801
2243
  RECURSION_VALUE_SIZE_CAP = 64
1802
2244
  private_constant :RECURSION_VALUE_SIZE_CAP
1803
2245
 
1804
- def infer_user_method_return(def_node, receiver, arg_types)
2246
+ # `self_fold_safe` (issue #525) is the caller's statement that its RECEIVER EXPRESSION was foldable;
2247
+ # {#build_user_method_body_scope} turns it into the body scope's `:self` sentinel when the carrier and
2248
+ # the body both qualify. It does NOT need its own memo-key slot here — the bit is observable on the
2249
+ # built `body_scope`, which is where every downstream consumer (the memo key, the recursion context)
2250
+ # reads it from, so the two can never disagree.
2251
+ def infer_user_method_return(def_node, receiver, arg_types, self_fold_safe: false)
1805
2252
  return nil if def_node.body.nil?
1806
2253
 
1807
- body_scope = build_user_method_body_scope(def_node, receiver, arg_types)
2254
+ body_scope = build_user_method_body_scope(def_node, receiver, arg_types,
2255
+ self_fold_safe: self_fold_safe)
1808
2256
  return nil if body_scope.nil?
1809
2257
 
1810
2258
  # Recursion-guard signature. Keyed on `(receiver, method)` only — NOT the argument types. ADR-24 WD5:
@@ -1874,8 +2322,14 @@ module Rigor
1874
2322
  def consult_and_store_return_memo(def_node, body_scope, stack, summaries,
1875
2323
  receiver, arg_types, plain_signature)
1876
2324
  per_def = (return_memo_bucket[def_node] ||= {})
2325
+ # Issue #525 — the `:self` fold-safety grant is a THIRD call-site-varying dimension: the same
2326
+ # `(receiver, arg_types)` body returns a folded member type when the caller's receiver expression
2327
+ # was foldable and `Dynamic[top]` when it was not. Without it in the key the first call site to
2328
+ # reach a def would poison every later one with the other polarity. It is read off the body scope
2329
+ # rather than passed in, so the key cannot drift from the scope that produced the result.
1877
2330
  memo_key = [receiver.describe(:short),
1878
- arg_types.map { |type| type.describe(:short) }]
2331
+ arg_types.map { |type| type.describe(:short) },
2332
+ body_scope.struct_fold_safe?(:self)]
1879
2333
  if (entry = per_def[memo_key])
1880
2334
  BudgetTrace.hit(BudgetTrace::MEMO_HITS)
1881
2335
  Analysis::DependencyRecorder.replay(entry.read_set) if Analysis::DependencyRecorder.active?
@@ -1977,7 +2431,11 @@ module Rigor
1977
2431
 
1978
2432
  context = RecursionContext.new(
1979
2433
  receiver: receiver, arg_types: arg_types, plain_signature: plain_signature,
1980
- summaries: summaries, would_have_been_guarded: would_have_been_guarded
2434
+ summaries: summaries, would_have_been_guarded: would_have_been_guarded,
2435
+ # Issue #525 — read off the built scope so the bot-collapse retry rebuilds a widened scope with
2436
+ # the SAME `:self` polarity the first attempt used; a retry that silently dropped the grant would
2437
+ # return a different type for the same call.
2438
+ self_fold_safe: body_scope.struct_fold_safe?(:self)
1981
2439
  )
1982
2440
  evaluate_guarded_user_method_body(def_node, body_scope, stack, signature, context)
1983
2441
  end
@@ -2222,9 +2680,9 @@ module Rigor
2222
2680
  # `fixpoint_user_method_return`: call-site argument narrowing can prune a recursive method's base
2223
2681
  # case, and widening restores the declared-type view under which the base case is reachable. Returns
2224
2682
  # `nil` when the parameter shape is not inferable (mirrors `build_user_method_body_scope`).
2225
- def widened_user_method_body_scope(def_node, receiver, arg_types)
2683
+ def widened_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
2226
2684
  widened_args = arg_types.map { |arg_type| widen_value_pinned(arg_type) }
2227
- build_user_method_body_scope(def_node, receiver, widened_args)
2685
+ build_user_method_body_scope(def_node, receiver, widened_args, self_fold_safe: self_fold_safe)
2228
2686
  end
2229
2687
 
2230
2688
  # ADR-55 slice 2 bot-collapse resolution (2026-06-11). Called when a fixpoint iteration computed `bot`
@@ -2244,7 +2702,8 @@ module Rigor
2244
2702
  # divergence — `spin`).
2245
2703
  def resolve_bot_collapse(def_node, context, widened:)
2246
2704
  unless widened
2247
- widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types)
2705
+ widened_scope = widened_user_method_body_scope(def_node, context.receiver, context.arg_types,
2706
+ self_fold_safe: context.self_fold_safe)
2248
2707
  return fixpoint_user_method_return(def_node, widened_scope, context, widened: true) unless widened_scope.nil?
2249
2708
  end
2250
2709
 
@@ -2382,20 +2841,23 @@ module Rigor
2382
2841
 
2383
2842
  # Builds the body scope for a user-defined instance method call: a fresh `Scope` with `self_type` set
2384
2843
  # to the receiver's nominal type, the project-wide accumulators inherited (so the body sees the same
2385
- # `discovered_classes` / `class_ivars` / etc. the caller does), and required positional parameters
2386
- # bound from the call's `arg_types` by index. Returns nil when the parameter shape is too complex for
2387
- # the first-iteration binder (rest args, keyword args, block params, etc.).
2388
- def build_user_method_body_scope(def_node, receiver, arg_types)
2844
+ # `discovered_classes` / `class_ivars` / etc. the caller does), and the call's `arg_types` bound to
2845
+ # the parameters. Returns nil when the binding is truly ambiguous (arity mismatch, trailing `post`
2846
+ # params, `...` forwarding).
2847
+ #
2848
+ # Issue #524 — the binder is per-PARAMETER, not per-signature. The first iteration declined any def
2849
+ # with an optional / rest / keyword / block parameter outright, so ONE `options = {}` default zeroed
2850
+ # out an otherwise-inferable return at every call site (the widest engine lever the 2026-09-01 sweep
2851
+ # measured, verified on ten targets). Now: requireds bind by index; supplied optionals by index and
2852
+ # unsupplied ones from a LITERAL default (else `Dynamic`); `*rest` collects the leftover positionals
2853
+ # as a Tuple; keywords bind by name from a trailing keyword-hash shape, falling back to their literal
2854
+ # default — or `Dynamic` when the shape is open or absent; `**kwrest`, `&block`, and destructured
2855
+ # positionals bind `Dynamic`. Binding `Dynamic` is monotone-safe (wider in → wider out), so the only
2856
+ # declines left are the shapes where positional CORRESPONDENCE itself is unknowable.
2857
+ def build_user_method_body_scope(def_node, receiver, arg_types, self_fold_safe: false)
2389
2858
  params = def_node.parameters
2390
- required = params&.requireds || []
2391
- return nil unless params.nil? || user_method_param_shape_simple?(params)
2392
- return nil unless required.size == arg_types.size
2393
-
2394
- # Bind required positionals by index. The body scope starts from an empty fact store and narrowing
2395
- # set, so `with_local`'s fact / narrowing invalidations would be no-ops here — build the locals
2396
- # table directly (matching `with_local`'s `name.to_sym` key).
2397
- locals = {}
2398
- required.each_with_index { |param, index| locals[param.name.to_sym] = arg_types[index] }
2859
+ locals = bind_params_from_call_types(params, arg_types)
2860
+ return nil if locals.nil?
2399
2861
 
2400
2862
  # Construct the body scope in a SINGLE allocation — the previous `Scope.empty.with_*.with_*…` chain
2401
2863
  # allocated a fresh frozen Scope per field, run per user-method-call inference (ADR-44). The
@@ -2405,12 +2867,179 @@ module Rigor
2405
2867
  environment: scope.environment,
2406
2868
  locals: locals.freeze,
2407
2869
  self_type: receiver,
2870
+ # Issue #681 — the chain the walk recorded as it entered `def_node`'s own declaration. Without it
2871
+ # this scope carried a self type alone, and `Reflection.lexical_nesting_chain` fell back to peeling
2872
+ # `receiver`'s qualified name — which cannot tell a compact `class Admin::Maker` from the nested
2873
+ # spelling, and for an INHERITED body peels the subclass rather than the declaration that owns it.
2874
+ # The same `Post.new` then typed one way on the line that writes it and another through this
2875
+ # re-walk. Issue #716 — `[]` for a top-level def: that IS Ruby's `Module.nesting` there, and it
2876
+ # sends the body's constants to the top level instead of to the caller's namespace.
2877
+ lexical_nesting: recorded_def_nesting(def_node),
2408
2878
  discovery: scope.discovery,
2409
- struct_fold_safe_locals: struct_fold_safe_locals_for(def_node.body),
2879
+ struct_fold_safe_locals: body_fold_safe_locals(def_node, receiver, self_fold_safe),
2410
2880
  dynamic_origins: scope.dynamic_origins
2411
2881
  )
2412
2882
  end
2413
2883
 
2884
+ # Issue #681 / #707 — the ONE place in `lib/` that answers "what `Module.nesting` was recorded for this
2885
+ # def node". `Scope::DiscoveryIndex#discovered_def_nestings` is the owner; the resolver memo is that
2886
+ # table's REHYDRATION for the one population it cannot hold — a def served from an ADR-85 seed bundle,
2887
+ # whose node {Inference::DefNodeResolver} mints from its own parse, so no table keyed by the identities
2888
+ # the analyzer's own walks produce can contain it.
2889
+ #
2890
+ # The order is safe on OBJECT PROVENANCE, not on any file-level split: the resolver parses separately
2891
+ # and both tables are `compare_by_identity`, so no node is a key in both, and the content-digest gate
2892
+ # makes the two agree where they answer for the same def through different objects. A file can be
2893
+ # bundle-served and walked live in the SAME run (an unchanged file re-analysed as a dependent), which is
2894
+ # exactly why the guarantee has to rest on the parse rather than on the file — see
2895
+ # {Inference::DefNodeResolver.rehydrated_nesting}. Routing both through one reader is what keeps a cold
2896
+ # run and an `--incremental` recheck answering the same constant for the same body; the rehydration half
2897
+ # is populated only inside `DefNodeResolver.with_run`, and outside it this falls back to the peel.
2898
+ def recorded_def_nesting(def_node)
2899
+ scope.discovery.discovered_def_nestings[def_node] || DefNodeResolver.rehydrated_nesting(def_node)
2900
+ end
2901
+
2902
+ # The body scope's fold-safe set: the body's own struct locals, plus issue #525's `:self` sentinel
2903
+ # when the caller's receiver expression was foldable AND the carrier is a `StructInstance` AND the
2904
+ # body's every use of `self` is a pure read (no member setter, no escape, no unrecognised self-call —
2905
+ # see {Inference::StructFoldSafety.self_fold_safe_body?}). All three are required: the first is the
2906
+ # caller's evidence that the map is current on entry, the last two that the body keeps it current.
2907
+ def body_fold_safe_locals(def_node, receiver, self_fold_safe)
2908
+ locals = struct_fold_safe_locals_for(def_node.body)
2909
+ return locals unless self_fold_safe && receiver.is_a?(Type::StructInstance)
2910
+ return locals unless self_fold_safe_grant?(def_node, receiver)
2911
+
2912
+ locals + [:self]
2913
+ end
2914
+
2915
+ # The memoised entry point to the grant scan. Without the memo the walk re-runs on every
2916
+ # granted-CANDIDATE call, return-memo HITS included — the body scope is built before the memo is
2917
+ # consulted, so a hot call site pays the scan every time. Only the OUTERMOST scan is memoised: an
2918
+ # inner answer can rest on the cycle guard's `seen` set, which is not part of the key.
2919
+ def self_fold_safe_grant?(def_node, receiver)
2920
+ per_def = (class_graph_buckets[:self_pure][def_node] ||= {})
2921
+ key = [receiver.class_name, receiver.member_names]
2922
+ return per_def[key] if per_def.key?(key)
2923
+
2924
+ per_def[key] = self_fold_safe_body?(def_node.body, receiver, [def_node])
2925
+ end
2926
+
2927
+ # Whether `body`'s every use of `self` is a pure read, resolving each unrecognised self-call against
2928
+ # the receiver's own class so a body that DELEGATES can still hold the grant: `def outer; shout; end`
2929
+ # keeps it because `shout` reads a member, while `def go; reset!; text; end` loses it because `reset!`
2930
+ # writes one. `seen` breaks a mutual-call cycle by refusing (the conservative direction) and
2931
+ # `SELF_PURE_DEPTH` bounds the walk — an unresolvable name (`puts`, `raise`, a method from an RBS-only
2932
+ # ancestor) refuses too, so every answer this returns is backed by a body actually examined.
2933
+ SELF_PURE_DEPTH = 4
2934
+ private_constant :SELF_PURE_DEPTH
2935
+
2936
+ def self_fold_safe_body?(body, receiver, seen)
2937
+ StructFoldSafety.self_fold_safe_body?(body, receiver.member_names) do |name|
2938
+ next false if seen.size > SELF_PURE_DEPTH
2939
+
2940
+ sibling, = resolve_user_def_with_owner(receiver.class_name, name)
2941
+ next false if sibling.nil? || sibling.body.nil? || seen.include?(sibling)
2942
+
2943
+ self_fold_safe_body?(sibling.body, receiver, seen + [sibling])
2944
+ end
2945
+ end
2946
+
2947
+ # The locals table for the body scope, or nil to decline. Keys match `with_local`'s `name.to_sym`.
2948
+ # The body scope starts from an empty fact store and narrowing set, so `with_local`'s fact /
2949
+ # narrowing invalidations would be no-ops here — the table is built directly.
2950
+ def bind_params_from_call_types(params, arg_types)
2951
+ return arg_types.empty? ? {} : nil if params.nil?
2952
+ return nil unless bindable_param_shape?(params)
2953
+
2954
+ positional = arg_types.dup
2955
+ kw_shape = positional.pop if takes_keywords?(params) && positional.last.is_a?(Type::HashShape)
2956
+ locals = bind_positional_params(params, positional)
2957
+ return nil if locals.nil?
2958
+
2959
+ bind_keyword_params(params, kw_shape, locals)
2960
+ locals[params.keyword_rest.name.to_sym] = dynamic_top if params.keyword_rest&.name
2961
+ locals[params.block.name.to_sym] = dynamic_top if params.block&.name
2962
+ locals
2963
+ end
2964
+
2965
+ # Trailing required positionals after a rest (`def f(a, *m, z)`) shift the correspondence; `...`
2966
+ # arrives as the keyword_rest slot. Both stay declined — correspondence, not width, is the issue.
2967
+ def bindable_param_shape?(params)
2968
+ params.is_a?(Prism::ParametersNode) &&
2969
+ params.posts.empty? &&
2970
+ !params.keyword_rest.is_a?(Prism::ForwardingParameterNode)
2971
+ end
2972
+
2973
+ def takes_keywords?(params)
2974
+ params.keywords.any? || !params.keyword_rest.nil?
2975
+ end
2976
+
2977
+ def bind_positional_params(params, positional)
2978
+ requireds = params.requireds
2979
+ optionals = params.optionals
2980
+ return nil if positional.size < requireds.size
2981
+ return nil if params.rest.nil? && positional.size > requireds.size + optionals.size
2982
+
2983
+ locals = {}
2984
+ requireds.each_with_index { |param, index| bind_positional_param(locals, param, positional[index]) }
2985
+ optionals.each_with_index do |param, index|
2986
+ supplied = positional[requireds.size + index]
2987
+ locals[param.name.to_sym] = supplied || literal_default_type(param.value)
2988
+ end
2989
+ bind_rest_param(params, positional, locals)
2990
+ locals
2991
+ end
2992
+
2993
+ def bind_rest_param(params, positional, locals)
2994
+ rest_name = params.rest&.name
2995
+ return if rest_name.nil?
2996
+
2997
+ leftover = positional[(params.requireds.size + params.optionals.size)..] || []
2998
+ locals[rest_name.to_sym] = Type::Combinator.tuple_of(*leftover)
2999
+ end
3000
+
3001
+ # A destructured positional (`def f((a, b))`) consumes one argument slot but binds its leaf names
3002
+ # `Dynamic` — element correspondence through the destructure is a later slice.
3003
+ def bind_positional_param(locals, param, arg_type)
3004
+ if param.is_a?(Prism::MultiTargetNode)
3005
+ Destructure.target_names(param).each { |name| locals[name.to_sym] = dynamic_top }
3006
+ else
3007
+ locals[param.name.to_sym] = arg_type
3008
+ end
3009
+ end
3010
+
3011
+ def bind_keyword_params(params, kw_shape, locals)
3012
+ open_shape = kw_shape && kw_shape.extra_keys == :open
3013
+ params.keywords.each do |param|
3014
+ name = param.name.to_s.delete_suffix(":").to_sym
3015
+ supplied = kw_shape&.pairs&.[](name)
3016
+ locals[name] = supplied ||
3017
+ (open_shape ? dynamic_top : keyword_default_type(param))
3018
+ end
3019
+ end
3020
+
3021
+ def keyword_default_type(param)
3022
+ param.respond_to?(:value) && param.value ? literal_default_type(param.value) : dynamic_top
3023
+ end
3024
+
3025
+ # A default expression contributes its type only when it is lexically scope-free — a scalar literal
3026
+ # or an EMPTY collection literal (`options = {}` is the dominant Rails idiom). Anything that could
3027
+ # read the def's own lexical scope binds `Dynamic` instead of being mis-typed in the caller's scope.
3028
+ LITERAL_DEFAULT_NODES = [
3029
+ Prism::IntegerNode, Prism::FloatNode, Prism::StringNode, Prism::SymbolNode,
3030
+ Prism::TrueNode, Prism::FalseNode, Prism::NilNode
3031
+ ].freeze
3032
+ private_constant :LITERAL_DEFAULT_NODES
3033
+
3034
+ def literal_default_type(value_node)
3035
+ return dynamic_top if value_node.nil?
3036
+ return type_of(value_node) if LITERAL_DEFAULT_NODES.any? { |klass| value_node.is_a?(klass) }
3037
+ return type_of(value_node) if value_node.is_a?(Prism::ArrayNode) && value_node.elements.empty?
3038
+ return type_of(value_node) if value_node.is_a?(Prism::HashNode) && value_node.elements.empty?
3039
+
3040
+ dynamic_top
3041
+ end
3042
+
2414
3043
  # ADR-48 Struct slice 3 — the fold-safe-local set for a method body (runs only on a return-memo miss,
2415
3044
  # so the per-call cost is bounded — measured perf-neutral). Struct member layouts of constant
2416
3045
  # receivers are resolved through the discovery side-table the body scope inherits.
@@ -2421,19 +3050,6 @@ module Rigor
2421
3050
  )
2422
3051
  end
2423
3052
 
2424
- # First iteration accepts only required positional parameters: `def foo(a, b, c)`. Optionals, rest,
2425
- # keyword params, and block params disqualify the method from inference (the caller observes
2426
- # `Dynamic[Top]` instead).
2427
- def user_method_param_shape_simple?(params)
2428
- return false unless params.is_a?(Prism::ParametersNode)
2429
-
2430
- params.optionals.empty? &&
2431
- params.rest.nil? &&
2432
- params.keywords.empty? &&
2433
- params.keyword_rest.nil? &&
2434
- params.block.nil?
2435
- end
2436
-
2437
3053
  # Slice A-engine. Implicit-self calls (no `node.receiver`) adopt the surrounding scope's `self_type`
2438
3054
  # as their receiver so calls like `attr_reader_method_name` or `private_helper(...)` inside an
2439
3055
  # instance method dispatch against the enclosing class. Slice 7 phase 10 — when `self_type` is nil
@@ -2533,11 +3149,187 @@ module Rigor
2533
3149
  result || dynamic_top
2534
3150
  end
2535
3151
 
3152
+ # The block body's value type — what the dispatcher binds a generic block-return variable to.
3153
+ #
3154
+ # Issue #533 item 9: this pass used to type only the body's LAST statement, in the block's ENTRY scope.
3155
+ # A tail reading a name an earlier statement of the same body binds (`m.synchronize do v = 42; v end`)
3156
+ # therefore never saw the binding and fell through `local_read` to `Dynamic[top]`, while the main pass
3157
+ # — which threads scope statement by statement through `StatementEvaluator` — held `42` for the very
3158
+ # same node. {#threaded_block_body_type} closes that gap by reusing the main pass's evaluator; a decline
3159
+ # keeps the tail-only answer verbatim.
2536
3160
  def type_block_body(block_node, block_scope)
2537
3161
  body = block_node.body
2538
3162
  return Type::Combinator.constant_of(nil) if body.nil?
2539
3163
 
2540
- block_scope.type_of(body)
3164
+ threaded_block_body_type(body, block_scope) || block_scope.type_of(body)
3165
+ end
3166
+
3167
+ # Re-typing the whole body would be wrong to do unconditionally: this path runs for EVERY block-bearing
3168
+ # call, and the statements ahead of the tail are pure cost whenever the tail does not depend on them.
3169
+ # Three declines keep that cost where the defect actually is, each falling back to the tail-only path:
3170
+ #
3171
+ # - a `nil` or non-`StatementsNode` body (`do … rescue … end` parses as a `BeginNode`) and a
3172
+ # single-statement body — the overwhelming majority of blocks — pay nothing at all;
3173
+ # - a multi-statement body pays one DFS over its non-tail statements, and threads only when the tail
3174
+ # READS a variable name one of them WRITES or MUTATES IN PLACE ({#tail_depends_on_body_binding?}).
3175
+ # That is exactly the diagnosed shape, so a block whose tail does not consume the body's own
3176
+ # bindings keeps today's type;
3177
+ # - the fold is not re-entrant. `StatementEvaluator#eval_call` already evaluates each nested block body
3178
+ # once, plus up to three more times under the ADR-56 `BodyFixpoint` when the block rebinds a captured
3179
+ # local, so a fold nested inside a fold would multiply that work per block-nesting level. Inside a
3180
+ # threaded body a nested block-bearing call reverts to the tail-only path — a wider answer in a rare
3181
+ # shape, never a new false positive.
3182
+ #
3183
+ # ADR-56 interaction: the fold cannot double-apply or fight the captured-local write-back. That
3184
+ # write-back is `StatementEvaluator#write_back_block_captures`, computed from the CALLER's scope into
3185
+ # the caller's continuation; this pass only derives a value type from a throwaway block scope and
3186
+ # discards the exit scope, exactly as the tail-only path did.
3187
+ #
3188
+ # Any failure inside the fold falls back to the tail-only answer rather than propagating: the enclosing
3189
+ # `block_return_type_for` rescue would otherwise report "no block" to the dispatcher, which is a much
3190
+ # larger regression than a wide block return.
3191
+ def threaded_block_body_type(body, block_scope)
3192
+ return nil unless body.is_a?(Prism::StatementsNode)
3193
+
3194
+ statements = body.body
3195
+ return nil if statements.size < 2
3196
+ return nil if block_body_threading_suppressed?
3197
+ return nil unless tail_depends_on_body_binding?(statements)
3198
+
3199
+ without_block_body_threading { block_scope.evaluate(body).first }
3200
+ rescue StandardError
3201
+ nil
3202
+ end
3203
+
3204
+ # Suppression flag for {#threaded_block_body_type} — set while a fold is running (so a fold never nests)
3205
+ # and while the per-element Tuple fold is over its arity cap. Thread-local because block typing is
3206
+ # re-entrant within one thread and the fork-pool workers each own their own.
3207
+ THREADED_BLOCK_BODY_KEY = :__rigor_threaded_block_body__
3208
+ private_constant :THREADED_BLOCK_BODY_KEY
3209
+
3210
+ def block_body_threading_suppressed?
3211
+ Thread.current[THREADED_BLOCK_BODY_KEY] ? true : false
3212
+ end
3213
+
3214
+ # Runs the block with the scope-threading fold suppressed, restoring the previous state (not clearing
3215
+ # it) on the way out, so nesting a suppressed region inside another cannot re-enable threading.
3216
+ def without_block_body_threading
3217
+ previous = Thread.current[THREADED_BLOCK_BODY_KEY]
3218
+ Thread.current[THREADED_BLOCK_BODY_KEY] = true
3219
+ begin
3220
+ yield
3221
+ ensure
3222
+ Thread.current[THREADED_BLOCK_BODY_KEY] = previous
3223
+ end
3224
+ end
3225
+
3226
+ # Every variable-write form whose name `StatementEvaluator` threads into the following statement's
3227
+ # scope, including the multi-assign / pattern targets that appear under a `MultiWriteNode`.
3228
+ VARIABLE_WRITE_NODES = Set[
3229
+ Prism::LocalVariableWriteNode, Prism::LocalVariableOperatorWriteNode,
3230
+ Prism::LocalVariableOrWriteNode, Prism::LocalVariableAndWriteNode,
3231
+ Prism::LocalVariableTargetNode,
3232
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode,
3233
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
3234
+ Prism::InstanceVariableTargetNode,
3235
+ Prism::ClassVariableWriteNode, Prism::ClassVariableOperatorWriteNode,
3236
+ Prism::ClassVariableOrWriteNode, Prism::ClassVariableAndWriteNode,
3237
+ Prism::ClassVariableTargetNode,
3238
+ Prism::GlobalVariableWriteNode, Prism::GlobalVariableOperatorWriteNode,
3239
+ Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableAndWriteNode,
3240
+ Prism::GlobalVariableTargetNode
3241
+ ].freeze
3242
+ private_constant :VARIABLE_WRITE_NODES
3243
+
3244
+ # Every node that OBSERVES a variable binding: the plain reads plus the compound writes, which read
3245
+ # their target before rebinding it (`v += 1` in the tail depends on an earlier `v = 0`).
3246
+ VARIABLE_READ_NODES = (
3247
+ VARIABLE_WRITE_NODES | [
3248
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
3249
+ Prism::ClassVariableReadNode, Prism::GlobalVariableReadNode
3250
+ ]
3251
+ ).freeze
3252
+ private_constant :VARIABLE_READ_NODES
3253
+
3254
+ # A `next` / `break` that leaves THIS block carries a value the fold cannot see: `evaluate(body).first`
3255
+ # is the fall-through value only, and no next-value join into the block return exists (`type_of_jump`
3256
+ # types both as `Bot`). So `m.synchronize do next 5 if flag; v = 42; v end` really can answer 5 at
3257
+ # runtime, and threading would type it `42`. Both forms escape with a value — `next v` is the block's
3258
+ # value for that yield, `break v` is the yielding CALL's value — so both must decline.
3259
+ JUMP_NODES = Set[Prism::NextNode, Prism::BreakNode].freeze
3260
+ private_constant :JUMP_NODES
3261
+
3262
+ # Constructs that RETARGET a `next` / `break` nested inside them, so a jump below one of these says
3263
+ # nothing about our block's value and must not trigger the decline. A nested `BlockNode` / `LambdaNode`
3264
+ # is the jump's own block (`do xs.each { next 1 }; v = 42; v end` threads soundly — the inner `next`
3265
+ # ends the inner iteration); a loop consumes both forms (`while … next 5 … end` continues the loop);
3266
+ # a `DefNode` body is a different method entirely.
3267
+ JUMP_BOUNDARY_NODES = Set[
3268
+ Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
3269
+ Prism::WhileNode, Prism::UntilNode, Prism::ForNode
3270
+ ].freeze
3271
+ private_constant :JUMP_BOUNDARY_NODES
3272
+
3273
+ # True when the tail statement observes a variable name one of the earlier statements binds OR mutates
3274
+ # in place — the two ways threading the scope through the body can change the tail's type — AND no
3275
+ # earlier statement can jump out of the block with a value. The name sets are compared sigil-and-all
3276
+ # across kinds, so the answer over-approximates (an `@x` write plus an `x` read threads needlessly);
3277
+ # over-approximating only spends the fold, it never changes an answer.
3278
+ #
3279
+ # The in-place half is issue #587. `outer = []; m.synchronize do outer.push(1); outer end` binds no
3280
+ # variable in its prefix — `push` is a call, not a write node — so a write-only scan declined and the
3281
+ # tail kept the entry scope's empty `Tuple[]`, a wrong-precise answer (the runtime value is `[1]`) that
3282
+ # hands downstream rules a provably-empty array. Threading is the fix, not a cost: `StatementEvaluator`
3283
+ # runs `MutationWidening.widen_after_call` on the `push`, so the threaded tail reads the widened
3284
+ # `Array[…]`. A call therefore contributes every variable its receiver can evaluate to
3285
+ # ({ReceiverAlias.candidates} — the ternary-selected receiver of issue #277 included) whenever its name
3286
+ # is one the widening responds to ({MutationWidening::SHAPE_MUTATORS}); keying on the widening's own
3287
+ # tables is what keeps "the scan says thread" and "threading changes something" the same predicate.
3288
+ #
3289
+ # Cost is two walks of the body, the second only when the first found a write and no jump — the same
3290
+ # order of cost `StatementEvaluator`'s own per-call captured-write scan already pays, and far below
3291
+ # re-typing. The prefix walk is hand-rolled rather than `Source::NodeWalker.each` because the two
3292
+ # questions it answers have different depths: a write is collected at ANY depth (a block is a closure,
3293
+ # so `[1].each { v = 5 }` really does bind the outer `v`, and `[1].each { outer << 1 }` really does
3294
+ # mutate the outer `outer`), while a jump counts only above the nearest {JUMP_BOUNDARY_NODES} boundary.
3295
+ def tail_depends_on_body_binding?(statements)
3296
+ written = Set.new
3297
+ statements[0...-1].each do |statement|
3298
+ return false unless prefix_statement_jump_free?(statement, written, false)
3299
+ end
3300
+ return false if written.empty?
3301
+
3302
+ Source::NodeWalker.each(statements.last) do |node|
3303
+ return true if VARIABLE_READ_NODES.include?(node.class) && written.include?(node.name)
3304
+ end
3305
+ false
3306
+ end
3307
+
3308
+ # True when `node` cannot jump out of the block with a value, collecting into `written` the names it
3309
+ # binds (a variable-write node) or mutates in place (a {MutationWidening::SHAPE_MUTATORS} call, through
3310
+ # every variable its receiver can evaluate to) on the way down. `retargeted` is true once the descent
3311
+ # has passed a boundary.
3312
+ #
3313
+ # A `Prism::DefinedNode`'s operand is never evaluated, so it is not descended into — the same rule
3314
+ # {Source::NodeWalker} applies, for the same reason: neither a write nor a jump under `defined?` runs.
3315
+ def prefix_statement_jump_free?(node, written, retargeted)
3316
+ return false if !retargeted && JUMP_NODES.include?(node.class)
3317
+
3318
+ written << node.name if VARIABLE_WRITE_NODES.include?(node.class)
3319
+ collect_mutated_receivers(node, written) if node.is_a?(Prism::CallNode)
3320
+ return true if node.is_a?(Prism::DefinedNode)
3321
+
3322
+ child_retargeted = retargeted || JUMP_BOUNDARY_NODES.include?(node.class)
3323
+ node.rigor_each_child do |child|
3324
+ return false unless prefix_statement_jump_free?(child, written, child_retargeted)
3325
+ end
3326
+ true
3327
+ end
3328
+
3329
+ def collect_mutated_receivers(call_node, written)
3330
+ return unless MutationWidening::SHAPE_MUTATORS.include?(call_node.name)
3331
+
3332
+ ReceiverAlias.candidates(call_node.receiver).each { |read| written << read.name }
2541
3333
  end
2542
3334
 
2543
3335
  # v0.0.6 phase 2 — per-element block fold for Tuple receivers under `:map` / `:collect`. Walks every
@@ -2589,15 +3381,100 @@ module Rigor
2589
3381
  # dispatched as a zero-arg method on each element type.
2590
3382
  #
2591
3383
  # Any other shape (`&proc_local`, `&method(:foo)`, no block) returns `nil` so the fold declines.
3384
+ # Arity cap on the scope-threading fold under this per-element walk. The `Constant<Range>` receivers are
3385
+ # already capped at {PER_ELEMENT_RANGE_LIMIT} positions, but a Tuple's elements come through
3386
+ # {#per_element_elements_of} uncapped — a 40-element array literal is 40 positions, and each one that
3387
+ # threads pays a FULL body evaluation rather than the tail-only typing this walk used to cost. Above the
3388
+ # cap the walk still runs (the fold's own per-position precision is unchanged); only the threading is
3389
+ # suppressed, so a body whose tail reads a body-local answers `Dynamic[top]` at every position instead
3390
+ # of its value. That is the cliff: `[1, …, 8].map do v = e; v end` folds to the values and a ninth
3391
+ # element drops the whole result to `Dynamic[top]` positions. It is set at the Range path's limit so one
3392
+ # number governs both receivers.
3393
+ PER_ELEMENT_THREADING_LIMIT = PER_ELEMENT_RANGE_LIMIT
3394
+ private_constant :PER_ELEMENT_THREADING_LIMIT
3395
+
2592
3396
  def per_element_block_results(block, element_types)
2593
3397
  case block
2594
3398
  when Prism::BlockNode
2595
- element_types.map { |element_type| type_block_body_with_param(block, [element_type]) }
3399
+ per_element_body_results(block, element_types)
2596
3400
  when Prism::BlockArgumentNode
2597
3401
  per_element_symbol_results(block, element_types)
2598
3402
  end
2599
3403
  end
2600
3404
 
3405
+ def per_element_body_results(block, element_types)
3406
+ captured = per_element_captured_bindings(block, element_types)
3407
+ results = lambda do
3408
+ element_types.map { |element_type| type_block_body_with_param(block, [element_type], captured: captured) }
3409
+ end
3410
+ return results.call if element_types.size <= PER_ELEMENT_THREADING_LIMIT
3411
+
3412
+ without_block_body_threading(&results)
3413
+ end
3414
+
3415
+ # Issue #587 (b) — first-iteration pinning. Every position of this fold is typed from the SAME entry
3416
+ # scope, so a body that rebinds a captured outer local answers the FIRST iteration's value at every
3417
+ # position: `total = 0; [1, 2].map do total += 1; total end` folded to `[1, 1]` (runtime `[1, 2]`), and
3418
+ # `r.first == 1` then folded to `true` — a live always-truthy on correct code. The ADR-56 fixpoint
3419
+ # (`StatementEvaluator#write_back_block_captures`) already computes the honest binding of such a local
3420
+ # — the join over the pre-call binding and every permitted iteration, value-pin widened, floored to
3421
+ # `Dynamic[top]` on structural compounding (`x = [x]`) — but it runs AFTER the call is typed and feeds
3422
+ # only the continuation. This fold runs the same `BodyFixpoint` over the same name set
3423
+ # ({CapturedLocals.writes}) up front and binds each such local to its converged type in every
3424
+ # position's entry scope, so a position answers what the local can be in ANY iteration
3425
+ # (`[Integer, Integer]`), never what it was in the first.
3426
+ #
3427
+ # Only the rebound names move. A position whose tail reads an untouched captured local or a block-local
3428
+ # keeps its exact fold (`[5, 5]`, `[42, 42]`), and a predicate that ignores the rebound counter still
3429
+ # decides (`select do seen += 1; e > 1 end` still folds to `[2]`); a blanket decline would have lost all
3430
+ # three for nothing. The fixpoint binds the block parameter to the union of the elements, so its cost
3431
+ # is independent of the arity — which is why the per-element threading cap is NOT a reason to floor: a
3432
+ # ninth element keeps `Integer` where it would otherwise keep the stale `0`.
3433
+ #
3434
+ # Under threading suppression — this fold nested inside another threaded body — the fixpoint's body
3435
+ # evaluations are exactly the re-entrant cost the suppression exists to refuse, so the names take the
3436
+ # escaping-block floor (`Dynamic[top]`) instead: cheaper, wider, still sound. A failure inside the
3437
+ # fixpoint takes the same floor rather than the seed — a seed that reaches a position is the pin this
3438
+ # exists to remove.
3439
+ #
3440
+ # Returns `nil` (no binding to apply) for the overwhelmingly common body that rebinds nothing captured.
3441
+ def per_element_captured_bindings(block, element_types)
3442
+ names = CapturedLocals.writes(block, scope)
3443
+ return nil if names.empty?
3444
+ return captured_floor(names) if block_body_threading_suppressed?
3445
+
3446
+ begin
3447
+ converged_captured_bindings(block, names, element_types)
3448
+ rescue StandardError
3449
+ captured_floor(names)
3450
+ end
3451
+ end
3452
+
3453
+ def captured_floor(names)
3454
+ names.to_h { |name| [name, Type::Combinator.untyped] }
3455
+ end
3456
+
3457
+ def converged_captured_bindings(block, names, element_types)
3458
+ param_types = [Type::Combinator.union(*element_types)]
3459
+ BodyFixpoint.converge(
3460
+ names: names,
3461
+ seed_bindings: names.to_h { |name| [name, scope.local(name)] },
3462
+ widen: Type::Combinator.method(:widen_value_pinned),
3463
+ evaluate_body: ->(bindings) { captured_exit_bindings(block, param_types, bindings, names) }
3464
+ )
3465
+ end
3466
+
3467
+ # One fixpoint pass: the body evaluated from `bindings` with the block parameters bound over them (the
3468
+ # same layering as {#type_block_body_with_param}), returning the per-name exit binding. Threading is
3469
+ # suppressed for the pass, as it is for every full body evaluation the block-return pass runs.
3470
+ def captured_exit_bindings(block, param_types, bindings, names)
3471
+ params = BlockParameterBinder.new(expected_param_types: param_types).bind(block)
3472
+ entry = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
3473
+ entry = params.reduce(entry) { |acc, (name, type)| acc.with_local(name, type) }
3474
+ _type, exit_scope = without_block_body_threading { entry.evaluate(block.body) }
3475
+ names.to_h { |name| [name, exit_scope.local(name)] }
3476
+ end
3477
+
2601
3478
  def per_element_symbol_results(block_arg, element_types)
2602
3479
  expression = block_arg.expression
2603
3480
  return nil unless expression.is_a?(Prism::SymbolNode)
@@ -3006,14 +3883,16 @@ module Rigor
3006
3883
  end
3007
3884
  end
3008
3885
 
3009
- def type_block_body_with_param(block_node, expected_param_types)
3886
+ # `captured:` — issue #587 (b): the per-name entry binding of every captured outer local the body rebinds
3887
+ # ({#per_element_captured_bindings}), laid under the parameter bindings so a parameter still shadows.
3888
+ def type_block_body_with_param(block_node, expected_param_types, captured: nil)
3010
3889
  bindings = BlockParameterBinder.new(expected_param_types: expected_param_types).bind(block_node)
3011
- block_scope = bindings.reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
3890
+ block_scope = (captured || {}).reduce(scope) { |acc, (name, type)| acc.with_local(name, type) }
3891
+ block_scope = bindings.reduce(block_scope) { |acc, (name, type)| acc.with_local(name, type) }
3012
3892
  type_block_body(block_node, block_scope)
3013
3893
  rescue StandardError
3014
3894
  nil
3015
3895
  end
3016
3896
  end
3017
- # rubocop:enable Metrics/ClassLength
3018
3897
  end
3019
3898
  end