rigortype 0.4.5 → 0.4.6

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 (136) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +11 -2
  4. data/docs/manual/02-cli-reference.md +46 -7
  5. data/docs/manual/03-configuration.md +2 -1
  6. data/docs/manual/04-diagnostics.md +1 -1
  7. data/docs/manual/10-mcp-server.md +9 -1
  8. data/docs/manual/12-caching.md +8 -1
  9. data/docs/manual/plugins/rigor-actionmailer.md +30 -0
  10. data/docs/manual/plugins/rigor-actionpack.md +30 -0
  11. data/docs/manual/plugins/rigor-devise.md +15 -0
  12. data/docs/manual/plugins/rigor-rspec.md +2 -1
  13. data/lib/rigor/analysis/buffer_binding.rb +14 -0
  14. data/lib/rigor/analysis/check_rules/inferred_param_guard.rb +30 -9
  15. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +142 -15
  16. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +4 -0
  17. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +66 -3
  18. data/lib/rigor/analysis/check_rules/rebinding_block_callees/dispatch.rb +112 -0
  19. data/lib/rigor/analysis/check_rules/rebinding_block_callees.rb +394 -0
  20. data/lib/rigor/analysis/check_rules/ruby_deprecations.rb +7 -0
  21. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +51 -0
  22. data/lib/rigor/analysis/check_rules.rb +93 -8
  23. data/lib/rigor/analysis/dependency_recorder.rb +11 -0
  24. data/lib/rigor/analysis/edit_replay.rb +67 -0
  25. data/lib/rigor/analysis/incremental.rb +37 -0
  26. data/lib/rigor/analysis/incremental_session.rb +175 -27
  27. data/lib/rigor/analysis/path_expansion.rb +41 -0
  28. data/lib/rigor/analysis/require_relative_closure.rb +100 -0
  29. data/lib/rigor/analysis/rule_catalog.rb +5 -1
  30. data/lib/rigor/analysis/run_disclosure_credit.rb +80 -0
  31. data/lib/rigor/analysis/run_disclosure_ledger.rb +155 -0
  32. data/lib/rigor/analysis/runner/buffer_pool_dispatcher.rb +6 -2
  33. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +11 -3
  34. data/lib/rigor/analysis/runner/pool_coordinator.rb +17 -4
  35. data/lib/rigor/analysis/runner/project_pre_passes.rb +45 -3
  36. data/lib/rigor/analysis/runner/scan_environment.rb +51 -0
  37. data/lib/rigor/analysis/runner.rb +156 -49
  38. data/lib/rigor/analysis/worker_session.rb +14 -2
  39. data/lib/rigor/cache/incremental_snapshot.rb +30 -7
  40. data/lib/rigor/cli/check_command.rb +93 -4
  41. data/lib/rigor/cli/diff_command.rb +2 -1
  42. data/lib/rigor/cli/lens_command.rb +5 -5
  43. data/lib/rigor/cli/mcp_command.rb +2 -1
  44. data/lib/rigor/cli/options.rb +20 -3
  45. data/lib/rigor/cli/plugins_command.rb +5 -0
  46. data/lib/rigor/cli/plugins_renderer.rb +24 -2
  47. data/lib/rigor/cli/show_bleedingedge_command.rb +2 -1
  48. data/lib/rigor/cli/sig_gen_command.rb +46 -7
  49. data/lib/rigor/cli/type_of_command.rb +2 -1
  50. data/lib/rigor/cli/type_of_project_scope.rb +27 -0
  51. data/lib/rigor/config_audit.rb +16 -5
  52. data/lib/rigor/configuration.rb +14 -4
  53. data/lib/rigor/environment/rbs_loader.rb +115 -6
  54. data/lib/rigor/environment/required_features.rb +136 -29
  55. data/lib/rigor/environment/ruby_library_roots.rb +122 -0
  56. data/lib/rigor/environment.rb +4 -2
  57. data/lib/rigor/inference/callee_mutation_chain.rb +115 -0
  58. data/lib/rigor/inference/deep_erasure.rb +138 -0
  59. data/lib/rigor/inference/def_return_typer.rb +159 -9
  60. data/lib/rigor/inference/definer_resolution.rb +17 -3
  61. data/lib/rigor/inference/error_info.rb +17 -3
  62. data/lib/rigor/inference/expression_typer.rb +62 -13
  63. data/lib/rigor/inference/global_write_census.rb +36 -1
  64. data/lib/rigor/inference/ivar_writer_census.rb +195 -0
  65. data/lib/rigor/inference/loop_value_record.rb +53 -0
  66. data/lib/rigor/inference/match_rebinding/frame.rb +19 -0
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +110 -9
  68. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +6 -2
  69. data/lib/rigor/inference/method_dispatcher/set_folding.rb +53 -20
  70. data/lib/rigor/inference/method_dispatcher.rb +46 -16
  71. data/lib/rigor/inference/module_function_state/spellings.rb +64 -0
  72. data/lib/rigor/inference/module_function_state.rb +12 -0
  73. data/lib/rigor/inference/mutation_widening.rb +5 -2
  74. data/lib/rigor/inference/narrowing.rb +66 -5
  75. data/lib/rigor/inference/object_mixins.rb +13 -1
  76. data/lib/rigor/inference/project_seed_tables.rb +2 -1
  77. data/lib/rigor/inference/project_yielder.rb +128 -0
  78. data/lib/rigor/inference/scope_indexer/certainty.rb +9 -0
  79. data/lib/rigor/inference/scope_indexer/declared_ivar_types.rb +64 -0
  80. data/lib/rigor/inference/scope_indexer/declared_param_writes.rb +162 -0
  81. data/lib/rigor/inference/scope_indexer/unseen_ivar_writers.rb +133 -0
  82. data/lib/rigor/inference/scope_indexer.rb +480 -103
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +18 -6
  84. data/lib/rigor/inference/statement_evaluator.rb +658 -90
  85. data/lib/rigor/inference/synthetic_method_index.rb +23 -0
  86. data/lib/rigor/inference/synthetic_method_scanner/project_members.rb +130 -0
  87. data/lib/rigor/inference/synthetic_method_scanner.rb +72 -12
  88. data/lib/rigor/language_server/buffer_table.rb +6 -0
  89. data/lib/rigor/language_server/diagnostic_publisher.rb +20 -6
  90. data/lib/rigor/language_server/project_context.rb +59 -3
  91. data/lib/rigor/language_server/server.rb +5 -3
  92. data/lib/rigor/mcp/server.rb +9 -1
  93. data/lib/rigor/plugin/base.rb +26 -11
  94. data/lib/rigor/plugin/manifest.rb +29 -3
  95. data/lib/rigor/plugin/registry.rb +38 -0
  96. data/lib/rigor/protection/discovery_seed.rb +57 -2
  97. data/lib/rigor/rooted_glob.rb +10 -1
  98. data/lib/rigor/scope.rb +14 -5
  99. data/lib/rigor/sig_gen/generator.rb +308 -37
  100. data/lib/rigor/sig_gen/observation_collector.rb +4 -1
  101. data/lib/rigor/sig_gen/rbs_validity.rb +1 -1
  102. data/lib/rigor/sig_gen/renderer.rb +6 -3
  103. data/lib/rigor/sig_gen/type_elaborator.rb +21 -5
  104. data/lib/rigor/type/combinator.rb +4 -3
  105. data/lib/rigor/type/constant.rb +45 -3
  106. data/lib/rigor/type/hash_shape.rb +14 -9
  107. data/lib/rigor/type/struct_instance.rb +3 -2
  108. data/lib/rigor/version.rb +1 -1
  109. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +60 -43
  110. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer.rb +20 -1
  111. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +13 -0
  112. data/plugins/rigor-activejob/lib/rigor/plugin/activejob.rb +4 -2
  113. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +5 -3
  114. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +6 -5
  115. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +102 -11
  116. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq.rb +4 -2
  117. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer.rb +1 -1
  118. data/sig/rigor/analysis/baseline.rbs +3 -2
  119. data/sig/rigor/effects/file_collection.rbs +5 -6
  120. data/sig/rigor/effects/registry.rbs +8 -6
  121. data/sig/rigor/effects/summary.rbs +6 -5
  122. data/sig/rigor/environment.rbs +5 -3
  123. data/sig/rigor/inference/optimistic_origin.rbs +11 -0
  124. data/sig/rigor/inference.rbs +6 -0
  125. data/sig/rigor/plugin/additional_initializer.rbs +5 -5
  126. data/sig/rigor/plugin/effect_ancestry.rbs +6 -4
  127. data/sig/rigor/plugin/effect_attribution.rbs +25 -10
  128. data/sig/rigor/plugin/effect_edge.rbs +12 -5
  129. data/sig/rigor/plugin/effect_entry_points.rbs +8 -3
  130. data/sig/rigor/plugin/io_boundary.rbs +4 -4
  131. data/sig/rigor/plugin/manifest.rbs +16 -18
  132. data/sig/rigor/plugin/protocol_contract.rbs +10 -9
  133. data/sig/rigor/plugin/registry.rbs +1 -1
  134. data/sig/rigor/scope.rbs +31 -50
  135. data/sig/rigor/type.rbs +0 -6
  136. metadata +20 -1
@@ -14,6 +14,7 @@ require_relative "block_call_timing"
14
14
  require_relative "block_repetition"
15
15
  require_relative "block_parameter_binder"
16
16
  require_relative "body_fixpoint"
17
+ require_relative "callee_mutation_chain"
17
18
  require_relative "captured_locals"
18
19
  require_relative "dynamic_origin"
19
20
  require_relative "error_info"
@@ -45,6 +46,8 @@ require_relative "narrowing"
45
46
  require_relative "operand_effects"
46
47
  require_relative "operand_walk"
47
48
  require_relative "optimistic_origin"
49
+ require_relative "loop_value_record"
50
+ require_relative "project_yielder"
48
51
  require_relative "return_barrier"
49
52
  require_relative "repeated_or_writes"
50
53
  require_relative "rewrite_mutation"
@@ -117,8 +120,8 @@ module Rigor
117
120
  # the default expression pair, so routing the or-writes here adds the body entry and nothing else.
118
121
  Prism::ConstantOrWriteNode => :eval_constant_write,
119
122
  Prism::ConstantPathOrWriteNode => :eval_constant_write,
120
- Prism::IfNode => :eval_if,
121
- Prism::UnlessNode => :eval_unless,
123
+ Prism::IfNode => :eval_recorded_if,
124
+ Prism::UnlessNode => :eval_recorded_unless,
122
125
  Prism::ElseNode => :eval_else,
123
126
  Prism::CaseNode => :eval_case,
124
127
  Prism::CaseMatchNode => :eval_case,
@@ -127,9 +130,9 @@ module Rigor
127
130
  Prism::BeginNode => :eval_begin_node,
128
131
  Prism::RescueNode => :eval_rescue,
129
132
  Prism::EnsureNode => :eval_ensure,
130
- Prism::WhileNode => :eval_loop,
131
- Prism::UntilNode => :eval_loop,
132
- Prism::ForNode => :eval_repeating_for,
133
+ Prism::WhileNode => :eval_recorded_loop,
134
+ Prism::UntilNode => :eval_recorded_loop,
135
+ Prism::ForNode => :eval_recorded_for,
133
136
  Prism::AndNode => :eval_and_or,
134
137
  Prism::OrNode => :eval_and_or,
135
138
  Prism::ParenthesesNode => :eval_parentheses,
@@ -186,6 +189,40 @@ module Rigor
186
189
  RETURN_SINK_KEY = :rigor_return_sink
187
190
  private_constant :RETURN_SINK_KEY
188
191
 
192
+ # Thread-local flag: set while a side pass ({.provisional_returns}) re-types part of a body from a scope other
193
+ # than the one the statement walk holds, so the `return`s it reaches are filed as provisional ({ReturnSink}).
194
+ PROVISIONAL_RETURNS_KEY = :rigor_provisional_returns
195
+ private_constant :PROVISIONAL_RETURNS_KEY
196
+
197
+ # The `return` sink {.with_return_sink} installs. Issue #2299 — a `return` the statement walk records is typed
198
+ # with its real bindings; one a side pass records ({.provisional_returns}) may read a name the pass skipped the
199
+ # binding of as `Dynamic[top]`. A provisional record of a `ReturnNode` therefore yields to the walk's record of
200
+ # the SAME node, and stands only where the walk never reaches that node: a block in expression position (a call
201
+ # argument, an array element, a chain receiver, a `return` value, an endless-def body), whose tail the side pass
202
+ # is the only walk to type.
203
+ class ReturnSink
204
+ def initialize
205
+ @types = []
206
+ @recorded = Set.new.compare_by_identity
207
+ @provisional = []
208
+ end
209
+
210
+ def record(node, type)
211
+ if Thread.current[PROVISIONAL_RETURNS_KEY]
212
+ @provisional << [node, type]
213
+ else
214
+ @types << type
215
+ @recorded << node
216
+ end
217
+ end
218
+
219
+ # The walk's records, then each provisional record whose node the walk did not record.
220
+ def types
221
+ @types + @provisional.filter_map { |node, type| type unless @recorded.include?(node) }
222
+ end
223
+ end
224
+ private_constant :ReturnSink
225
+
189
226
  # Thread-local sink (an Array of `[NextNode, Type]`) collecting the value each `next` carries out of the block it
190
227
  # leaves, so `ExpressionTyper#type_block_body` can join those arms into the block's value type. Issue #841: the
191
228
  # block-return pass modelled only the fall-through tail, so `ops.all? { |o| next false unless o; true }` read as
@@ -313,14 +350,31 @@ module Rigor
313
350
  # method-return type.
314
351
  def self.with_return_sink
315
352
  previous = Thread.current[RETURN_SINK_KEY]
316
- sink = []
353
+ previous_provisional = Thread.current[PROVISIONAL_RETURNS_KEY]
354
+ sink = ReturnSink.new
317
355
  Thread.current[RETURN_SINK_KEY] = sink
356
+ Thread.current[PROVISIONAL_RETURNS_KEY] = nil
318
357
  begin
319
358
  result = yield
320
359
  ensure
321
360
  Thread.current[RETURN_SINK_KEY] = previous
361
+ Thread.current[PROVISIONAL_RETURNS_KEY] = previous_provisional
362
+ end
363
+ [result, sink.types]
364
+ end
365
+
366
+ # Runs `block` with the `return`s it reaches filed as provisional ({ReturnSink}), restoring the previous state
367
+ # on exit. Issue #2299 — {ExpressionTyper#type_block_body}'s tail-only answer types `return r if r` without the
368
+ # `r = yield e` before it, so `r` read `Dynamic[top]` and that went into the enclosing method's return type
369
+ # although the statement walk of the same block recorded `r` with its real binding.
370
+ def self.provisional_returns
371
+ previous = Thread.current[PROVISIONAL_RETURNS_KEY]
372
+ Thread.current[PROVISIONAL_RETURNS_KEY] = true
373
+ begin
374
+ yield
375
+ ensure
376
+ Thread.current[PROVISIONAL_RETURNS_KEY] = previous
322
377
  end
323
- [result, sink]
324
378
  end
325
379
 
326
380
  # Runs `block` with a fresh `next` sink installed, then yields the collected `[NextNode, Type]` pairs to the
@@ -452,19 +506,28 @@ module Rigor
452
506
  [type, fall_through, join_jump_scopes(fall_through, sink, JumpTargets.of(body, Prism::NextNode))]
453
507
  end
454
508
 
455
- # ADR-89 WD2 — the sorted positions of the positional parameters whose CONTENT `def_node` mutates
509
+ # ADR-89 WD2 — the sorted slots of the parameters (positional, then keyword) whose CONTENT `def_node` mutates
456
510
  # (`callee_content_mutated_parameters`, the ADR-56 arg-flooring surface a caller consumes). A per-def
457
511
  # static property of the AST — this is a pure exposure of the existing private computation (it never
458
512
  # reads the scope), so the incremental session can compute it on any `StatementEvaluator` (an empty
459
513
  # scope suffices), persist it in a return summary, and re-check a callee's symbol dependents when it
460
514
  # moves (a caller's arg flooring changes even if the callee's return does not).
515
+ #
516
+ # Issue #2104 — a mutated keyword parameter reports its keyword symbol, after the positions. A parameter the
517
+ # def hands on to a self-call counts whether or not the callee resolves: the session's empty scope resolves no
518
+ # callee, and without it an edit that starts or stops forwarding a parameter to a mutating callee would leave
519
+ # the effects equal and the callers' floors stale.
461
520
  def content_mutated_parameter_positions(def_node)
462
- callee_content_mutated_parameters(def_node).values.uniq.sort
521
+ slots = content_parameter_slots(def_node)
522
+ census = callee_content_mutated_parameters(def_node).values
523
+ each_forwarding_call(def_node, slots) { |_, forwarded| census += forwarded.map { |name, _| slots[name] } }
524
+ positions, keywords = census.uniq.partition { |slot| slot.is_a?(Integer) }
525
+ positions.sort + keywords.sort
463
526
  end
464
527
 
465
- # The local-variable reads among `call_node`'s positional arguments whose matching parameter the callee
466
- # content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's scope
467
- # (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
528
+ # The local-variable reads among `call_node`'s arguments (positional, or a keyword's value) whose parameter the
529
+ # callee content-mutates, when `call_node` is a self-dispatch call resolving to a user def in this evaluator's
530
+ # scope (`callee_content_mutated_parameters`); empty for any other call. These are the locals the straight-line
468
531
  # callee floor ({#widen_callee_escaped_argument_captures}) floors after the call, and the ones
469
532
  # {CapturedLocals.content_mutations} reports as a block's callee-mutated captures.
470
533
  def content_mutated_arguments(call_node)
@@ -480,12 +543,25 @@ module Rigor
480
543
  return NO_ARGUMENT_READS if mutated.empty?
481
544
 
482
545
  argument_nodes = call_node.arguments.arguments
483
- mutated.values.uniq.filter_map do |index|
484
- argument = argument_nodes[index]
546
+ mutated.values.uniq.filter_map do |slot|
547
+ argument = slot.is_a?(Symbol) ? keyword_argument(argument_nodes, slot) : argument_nodes[slot]
548
+ argument = argument.value if argument.is_a?(Prism::ImplicitNode)
485
549
  argument if argument.is_a?(Prism::LocalVariableReadNode)
486
550
  end
487
551
  end
488
552
 
553
+ # The value `argument_nodes` pass for the keyword `name` (`poke(acc: a)`), or nil.
554
+ def keyword_argument(argument_nodes, name)
555
+ hash = argument_nodes.last
556
+ return unless hash.is_a?(Prism::KeywordHashNode)
557
+
558
+ pair = hash.elements.find do |element|
559
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) &&
560
+ element.key.unescaped.to_sym == name
561
+ end
562
+ pair&.value
563
+ end
564
+
489
565
  NO_ARGUMENT_READS = [].freeze
490
566
  private_constant :NO_ARGUMENT_READS
491
567
 
@@ -678,7 +754,19 @@ module Rigor
678
754
  # stamped by {#seed_instance_ivars}.
679
755
  bound = bound.with_published_constant_mark(:ivar, node.name) if
680
756
  published_constant_copy?(node.value, rhs_type, post_rhs)
681
- [rhs_type, bound]
757
+ [rhs_type, mark_inferred_param_ivar(bound, node)]
758
+ end
759
+
760
+ # Issue #2189 — the ivar twin of {#bind_local_write}'s ADR-67 WD6b stamp: `@x = param; @x.upcase` reads the
761
+ # parameter's open-call-site lower bound back, so the ivar carries the `:inferred_param` mark under its
762
+ # `@`-prefixed name and {Analysis::CheckRules::InferredParamGuard} declines on the read. A write from any
763
+ # other RHS clears it. Method-local only: the class-ivar census seeding a sibling method types the RHS
764
+ # without the inference seed, so no lower bound crosses the method boundary to guard.
765
+ def mark_inferred_param_ivar(bound, node)
766
+ return bound.with_inferred_param_mark(node.name) if
767
+ Analysis::CheckRules::InferredParamGuard.rooted?(node.value, scope)
768
+
769
+ bound.without_inferred_param_mark(node.name)
682
770
  end
683
771
 
684
772
  def eval_cvar_write(node)
@@ -832,11 +920,24 @@ module Rigor
832
920
 
833
921
  def compound_eval(node, kind:, op:) # rubocop:disable Naming/MethodParameterName
834
922
  current_type = current_type_for(kind, node.name)
835
- rhs_type, post_rhs = sub_eval(node.value, scope)
923
+ rhs_type, post_rhs = sub_eval(node.value, compound_value_scope(kind, node.name, op))
836
924
  result_type = compound_result_type(current_type, rhs_type, op)
837
925
  [result_type, rebind_variable(post_rhs, kind, node.name, result_type)]
838
926
  end
839
927
 
928
+ # Issue #2214 — the scope a compound write's value operand runs in. `x &&= v` runs `v` only when `x` is truthy,
929
+ # as `x && (x = v)` does, so `v` reads `x` narrowed to its truthy part: `c &&= c + 1` on an `Integer?` `c` reads
930
+ # `Integer`, not the `nil` that drew `call.possible-nil-receiver`. `x ||= v` and an operator write run `v` from
931
+ # the scope they start in; a variable the scope does not bind is left alone.
932
+ def compound_value_scope(kind, name, operator)
933
+ return scope unless operator == :and
934
+
935
+ current = scope.public_send(VAR_KIND_GETTERS.fetch(kind), name)
936
+ return scope if current.nil?
937
+
938
+ scope.public_send(VAR_KIND_BUILDERS.fetch(kind), name, Narrowing.narrow_truthy(current))
939
+ end
940
+
840
941
  VAR_KIND_GETTERS = {
841
942
  local: :local, ivar: :ivar, cvar: :cvar, global: :global
842
943
  }.freeze
@@ -906,7 +1007,40 @@ module Rigor
906
1007
  arg_types: index_write_arg_types(node, result_type))
907
1008
  post = post.with_indexed_narrowing(*address, result_type) if address
908
1009
 
909
- [result_type, forget_rebound_specials(post, node)]
1010
+ # The value the expression yields takes the memoizing reading ({#index_compound_write_value}, #1155) for a
1011
+ # fresh container or construction rvalue, with a container's literal precision erased
1012
+ # ({#memo_bound_value}).
1013
+ [memo_bound_value(node, result_type), forget_rebound_specials(post, node)]
1014
+ end
1015
+
1016
+ # What `x = (tbl[k] ||= v)` binds `x` to. Where {#index_compound_write_value} reads the memoizing idiom, the
1017
+ # slot may hold ANY earlier value of the table, so `x` is the rvalue only as far as its CLASS is known: a
1018
+ # `{}` / `[]` rvalue (a shape or tuple) erases to `Hash[untyped, untyped]` / `Array[untyped]`, because the
1019
+ # stored table is not empty after the first store (`entry[:name].upcase` is no call on nil), and a
1020
+ # construction (`new(...)`, a nominal) is kept. Every other rvalue (a Constant, a union) keeps the stored
1021
+ # `Dynamic | v`, since a literal would read the slot as freshly created at every later read.
1022
+ def memo_bound_value(node, stored)
1023
+ return stored unless node.is_a?(Prism::IndexOrWriteNode)
1024
+
1025
+ value = index_compound_write_value(node)
1026
+ return stored if value == stored
1027
+
1028
+ erased = erase_memo_container(value)
1029
+ erased || stored
1030
+ end
1031
+
1032
+ def erase_memo_container(type)
1033
+ any = Type::Combinator.untyped
1034
+ case type
1035
+ when Type::HashShape then Type::Combinator.nominal_of("Hash", type_args: [any, any])
1036
+ when Type::Tuple then Type::Combinator.nominal_of("Array", type_args: [any])
1037
+ when Type::Nominal
1038
+ case type.class_name
1039
+ when "Hash" then Type::Combinator.nominal_of("Hash", type_args: [any, any])
1040
+ when "Array" then Type::Combinator.nominal_of("Array", type_args: [any])
1041
+ else type
1042
+ end
1043
+ end
910
1044
  end
911
1045
 
912
1046
  # `h[k] &&= v` / `h[k] += v`. Neither had a handler, so both fell to `evaluate`'s default — typed as a pure
@@ -1446,7 +1580,10 @@ module Rigor
1446
1580
  after
1447
1581
  end
1448
1582
  body_entry = reduce_scopes_with_nil_injection(test_exits)
1449
- return [Narrowing.apply_when_regex_globals(conditions, body_entry), after, true] if rebound
1583
+ if rebound
1584
+ body_entry, after = [body_entry, after].map { |s| forget_called_global_subject(subject, s) }
1585
+ return [Narrowing.apply_when_regex_globals(conditions, body_entry), after, true]
1586
+ end
1450
1587
 
1451
1588
  [Narrowing.case_when_scopes(subject, conditions, body_entry).first,
1452
1589
  Narrowing.case_when_scopes(subject, conditions, after).last, false]
@@ -1493,16 +1630,21 @@ module Rigor
1493
1630
  # reads: its binding moved, the test writes it, or — for an instance variable — a call in the test may write
1494
1631
  # it ({InterveningIvarReset.may_write?}), or — for a global or constant — the test may run code that rebinds
1495
1632
  # one ({GuardRebinding.may_rebind?}).
1633
+ #
1634
+ # A subject that writes a local or instance variable (`case (@x = v)`) is read as that variable, as
1635
+ # {Narrowing.case_when_scopes} narrows it (issue #2211). For such an instance variable the binding test above
1636
+ # answers first (the test's call widens it in {#run_when_test}), so its `may_write?` arm is a guard.
1496
1637
  def when_test_rebinds_subject?(subject, condition, before, after)
1638
+ subject = case_subject_variable(subject)
1497
1639
  name = case_subject_name(subject)
1498
1640
  return false if name.nil?
1499
1641
  return true unless case_subject_binding(subject, before) == case_subject_binding(subject, after)
1500
1642
  return true if OperandEffects.written_variables(condition).include?(name)
1501
1643
 
1502
1644
  case subject
1503
- when Prism::InstanceVariableReadNode
1645
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
1504
1646
  InterveningIvarReset.may_write?(condition, name, before, run_lambdas: true)
1505
- when Prism::LocalVariableReadNode
1647
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode
1506
1648
  false
1507
1649
  else
1508
1650
  body = when_lambda_body(condition)
@@ -1510,9 +1652,29 @@ module Rigor
1510
1652
  end
1511
1653
  end
1512
1654
 
1655
+ # Issue #2211 — a global subject from a `when` test that may rebind it on ({#when_test_rebinds_subject?}): a
1656
+ # method may assign a global, and the statement walk keeps a global's binding past a call, so the clause
1657
+ # bodies and the later clauses read it as `Dynamic[top]`, as the scope index's walk of a value-position `case`
1658
+ # reads it
1659
+ # (`ScopeIndexer#forget_called_subject`). `$g = nil; case $g when String then 1 when reset! then 2 else $g + 1
1660
+ # end`, with `reset!` assigning `$g`, is `6` in Ruby. An instance variable a test's call may write is widened by
1661
+ # {#run_when_test} instead; any other subject is left as is.
1662
+ def forget_called_global_subject(subject, scope)
1663
+ return scope unless subject.is_a?(Prism::GlobalVariableReadNode)
1664
+
1665
+ scope.with_global(subject.name, Type::Combinator.untyped)
1666
+ end
1667
+
1668
+ # The subject node the rebinding test reads: a parenthesised one unwrapped, and a local or instance variable
1669
+ # write kept as the variable it writes.
1670
+ def case_subject_variable(subject)
1671
+ Narrowing.unwrap_case_subject(subject)
1672
+ end
1673
+
1513
1674
  def case_subject_name(subject)
1514
1675
  case subject
1515
- when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode
1676
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode,
1677
+ Prism::LocalVariableWriteNode, Prism::InstanceVariableWriteNode
1516
1678
  subject.name
1517
1679
  when Prism::ConstantReadNode, Prism::ConstantPathNode
1518
1680
  Narrowing.constant_key(subject)
@@ -1521,8 +1683,8 @@ module Rigor
1521
1683
 
1522
1684
  def case_subject_binding(subject, scope)
1523
1685
  case subject
1524
- when Prism::LocalVariableReadNode then scope.local(subject.name)
1525
- when Prism::InstanceVariableReadNode then scope.ivar(subject.name)
1686
+ when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode then scope.local(subject.name)
1687
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode then scope.ivar(subject.name)
1526
1688
  when Prism::GlobalVariableReadNode then scope.global(subject.name)
1527
1689
  else scope.constant_narrowing(Narrowing.constant_key(subject))
1528
1690
  end
@@ -2222,16 +2384,37 @@ module Rigor
2222
2384
  # ({#loop_body_ivar_writes}).
2223
2385
  def eval_repeating_for(node) = ReflectiveIvarWrite.in_repeating_body { eval_for(node) }
2224
2386
 
2387
+ # A loop's evaluation, with its value recorded for `ExpressionTyper#type_of_loop` ({LoopValueRecord}).
2388
+ # A conditional's evaluation, recorded when it evaluated a loop ({LoopValueRecord.conditional}): the typer
2389
+ # types a conditional that holds a loop again from the scope it was evaluated from, and the loop in its branch
2390
+ # is evaluated from a branch scope no record matches.
2391
+ def eval_recorded_if(node) = record_conditional_value(node) { eval_if(node) }
2392
+
2393
+ def eval_recorded_unless(node) = record_conditional_value(node) { eval_unless(node) }
2394
+
2395
+ # The "evaluated a loop" gate is a memory-only guard: recording every conditional would give the same answers, at
2396
+ # the cost of a retained scope per `if` node, so no behavioural example pins it.
2397
+ def record_conditional_value(node)
2398
+ before = LoopValueRecord.loops_evaluated
2399
+ result = yield
2400
+ LoopValueRecord.conditional(node, scope, result.first) unless LoopValueRecord.loops_evaluated == before
2401
+ result
2402
+ end
2403
+
2404
+ def eval_recorded_loop(node) = record_loop_value(node, eval_loop(node))
2405
+
2406
+ def eval_recorded_for(node) = record_loop_value(node, eval_repeating_for(node))
2407
+
2408
+ def record_loop_value(node, result)
2409
+ LoopValueRecord.record(node, scope, result.first)
2410
+ result
2411
+ end
2412
+
2225
2413
  # `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
2226
- # degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
2227
- # 1), reflecting the common case where no `break VALUE` is observed.
2414
+ # degrade to `T | nil` in the post-loop scope. The loop expression is `nil` joined with the value of every `break`
2415
+ # that leaves this loop ({#loop_value}, issue #2031).
2228
2416
  def eval_loop(node)
2229
- # Issue #1930 — the predicate runs before every iteration but is walked once, so a literal-name
2230
- # `instance_variable_set` in it joins its value and `Dynamic[top]` ({ReflectiveIvarWrite.in_repeating_body}):
2231
- # the rebind fixpoint tracks the body's writes only.
2232
- entry = loop_entry_scope(node)
2233
- _pred_type, post_pred = ReflectiveIvarWrite.in_repeating_body { sub_eval(node.predicate, entry) }
2234
- post_pred = widen_predicate_pins(node, post_pred)
2417
+ post_pred = loop_predicate_scope(node)
2235
2418
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
2236
2419
 
2237
2420
  # The historical single body pass joined with the pre-loop scope. This continues to carry everything the
@@ -2248,7 +2431,7 @@ module Rigor
2248
2431
  # The pass ends with its `next` exits as well as its fall-through ({#loop_iteration}). Its `break` arms are
2249
2432
  # superseded by the fixpoint's converged pass ({#loop_break_arms}) except in a `begin … end while` loop, below.
2250
2433
  jumps = loop_jumps(node.statements)
2251
- body_scope, first_breaks = single_pass(node, post_pred, jumps)
2434
+ body_scope, first_breaks, first_values = single_pass(node, post_pred, jumps)
2252
2435
  base_scope = join_with_nil_injection(post_pred, body_scope)
2253
2436
 
2254
2437
  names, body_first = loop_rebound_names(node.statements, post_pred)
@@ -2258,17 +2441,50 @@ module Rigor
2258
2441
  # the single-pass join before returning.
2259
2442
  if names.empty?
2260
2443
  fast = loop_content_writeback(node.statements, base_scope, pre_body: post_pred)
2261
- return [Type::Combinator.constant_of(nil), loop_exit(node, post_pred, fast)]
2444
+ return [loop_value(node, first_values), loop_exit(node, post_pred, fast)]
2262
2445
  end
2263
2446
 
2264
- post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
2447
+ post_loop, values = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
2265
2448
  # A `begin … end while` / `until` body runs once before the predicate is first tested, so that iteration's
2266
2449
  # entry lies outside every fixpoint pass's predicate-narrowed one, and a `break` only it can take (`if state ==
2267
2450
  # :idle` under `end while state != :idle`) is dead in every converged pass. The single pass runs from the
2268
2451
  # un-narrowed post-predicate scope, so its arms stand in for that first iteration.
2269
2452
  post_loop = join_break_scopes(post_loop, first_breaks, names) if node.begin_modifier?
2270
2453
  post_loop = loop_exit(node, post_pred, post_loop)
2271
- [Type::Combinator.constant_of(nil), post_loop]
2454
+ [loop_value(node, first_values + values), post_loop]
2455
+ end
2456
+
2457
+ # The scope a loop's predicate leaves, which the body and the exit edge start from. Issue #1930 — the predicate
2458
+ # runs before every iteration but is walked once, so a literal-name `instance_variable_set` in it joins its value
2459
+ # and `Dynamic[top]` ({ReflectiveIvarWrite.in_repeating_body}): the rebind fixpoint tracks the body's writes only.
2460
+ def loop_predicate_scope(node)
2461
+ entry = loop_entry_scope(node)
2462
+ _pred_type, post_pred = ReflectiveIvarWrite.in_repeating_body { sub_eval(node.predicate, entry) }
2463
+ widen_predicate_pins(node, post_pred)
2464
+ end
2465
+
2466
+ # Issue #2031 — a `while` / `until` expression is `nil` when its predicate ends it and the value of the `break`
2467
+ # that ends it otherwise: `x = while i < k; break @r if i == 4; end` is `"four"` or `nil`. `break_values` holds
2468
+ # what each `break` targeting this loop carried, from the single body pass (the first iteration) and the pass
2469
+ # from the converged bindings ({#loop_break_arms}), which together cover the entry of every iteration. A literal
2470
+ # `while true` / `until false` / `until nil` is never ended by its predicate, so only its `break`s give it a
2471
+ # value; any other predicate is assumed to end it, since its first evaluation says nothing about later ones. A
2472
+ # loop with no reachable `break` keeps its `nil`.
2473
+ def loop_value(node, break_values)
2474
+ nil_type = Type::Combinator.constant_of(nil)
2475
+ return nil_type if break_values.empty?
2476
+ return Type::Combinator.union(*break_values) if endless_loop_predicate?(node)
2477
+
2478
+ Type::Combinator.union(nil_type, *break_values)
2479
+ end
2480
+
2481
+ def endless_loop_predicate?(node)
2482
+ predicate = node.predicate
2483
+ if node.is_a?(Prism::UntilNode)
2484
+ predicate.is_a?(Prism::FalseNode) || predicate.is_a?(Prism::NilNode)
2485
+ else
2486
+ predicate.is_a?(Prism::TrueNode)
2487
+ end
2272
2488
  end
2273
2489
 
2274
2490
  # Issue #1359 — the scope a loop's predicate first runs from: a body that may set `$_` runs again after it ran,
@@ -2330,7 +2546,8 @@ module Rigor
2330
2546
 
2331
2547
  # The continuation scope for a loop whose body rebinds locals: the ADR-56 slice-B rebind fixpoint overlaid on
2332
2548
  # `base_scope`, then the slice-C receiver-content writeback, then the `break`-path bindings the fall-through
2333
- # dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`).
2549
+ # dropped (`flag = true; break` -> `flag` is `false | true`, not the stale `false`). Returns `[scope, values]`,
2550
+ # `values` being what the converged pass's `break`s carry ({#loop_value}).
2334
2551
  def converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
2335
2552
  # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and may compound (`d *= 2`), so the historical
2336
2553
  # single body pass joined with the pre-loop scope kept stale folded constants (`d = 1; while …; d *= 2; end` →
@@ -2338,7 +2555,7 @@ module Rigor
2338
2555
  # fixpoint slice A uses for non-escaping block captures. Seed: a pre-existing local seeds with its
2339
2556
  # post-predicate binding; a local FIRST assigned inside the body seeds with `nil` so the 0-iteration path
2340
2557
  # degrades it to `T | nil`, matching the nil-injection treatment.
2341
- break_pass = jumps.breaks && { entry: nil, arms: [] }
2558
+ break_pass = { entry: nil, arms: [], values: [] } if jumps.breaks || later_pass_returns?(node.statements)
2342
2559
  result = loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
2343
2560
  # Display-path re-record: the fixpoint's body re-evaluations fire `on_enter` with the cap-N INTERMEDIATE
2344
2561
  # assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
@@ -2350,8 +2567,8 @@ module Rigor
2350
2567
  # the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
2351
2568
  # from `post_loop` for one it also rebinds, so composition still works — see {#loop_content_writeback}.
2352
2569
  post_loop = loop_content_writeback(node.statements, post_loop, pre_body: post_pred, rebound: names)
2353
- arms = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
2354
- join_break_scopes(post_loop, arms, names)
2570
+ arms, values = loop_break_arms(node, post_pred, result, body_first, jumps, break_pass)
2571
+ [join_break_scopes(post_loop, arms, names), values]
2355
2572
  end
2356
2573
 
2357
2574
  # A loop's continuation: a local its body and predicate only narrowed from `Dynamic[top]` re-joins to it
@@ -2444,8 +2661,11 @@ module Rigor
2444
2661
  [sink, result]
2445
2662
  end
2446
2663
 
2447
- # One evaluation of a loop body from `entry`. Returns `[exit, breaks]`: the scope the iteration ends with, and the
2448
- # scopes at the `break`s that target the loop ({LoopJumps}). Every reader of a loop body goes through here — the
2664
+ # One evaluation of a loop body from `entry`. Returns `[exit, breaks, values]`: the scope the iteration ends with,
2665
+ # the scopes at the `break`s that target the loop ({LoopJumps}), and the values those `break`s carry, read from a
2666
+ # `break`-value sink of the loop's own ({#loop_value}); filtering it by the loop's `break`s is defensive, since a
2667
+ # nested loop installs its own sink and a nested block's call evaluates its body under another, so no example
2668
+ # tells the filter apart today. Every reader of a loop body goes through here — the
2449
2669
  # single pass `eval_loop` joins with the pre-loop scope, each pass of its rebind fixpoint, and `eval_for`'s only
2450
2670
  # pass.
2451
2671
  #
@@ -2463,18 +2683,21 @@ module Rigor
2463
2683
  recording = recorded ? {} : UNRECORDED
2464
2684
  evaluate = -> { sub_eval(statements, entry, next_scope_sink: next_sink, **recording).last }
2465
2685
  if jumps.breaks
2466
- break_sink, fall_through = collect_break_scopes(&evaluate)
2686
+ break_sink, (fall_through, value_sink) = collect_break_scopes do
2687
+ StatementEvaluator.with_break_value_sink(&evaluate)
2688
+ end
2467
2689
  breaks = targeted_scopes(break_sink, jumps.breaks)
2690
+ values = targeted_scopes(value_sink, jumps.breaks)
2468
2691
  else
2469
2692
  fall_through = evaluate.call
2470
- breaks = NO_BREAK_ARMS
2693
+ breaks = values = NO_BREAK_ARMS
2471
2694
  end
2472
- return [fall_through, breaks] if next_sink.nil?
2695
+ return [fall_through, breaks, values] if next_sink.nil?
2473
2696
 
2474
2697
  exit_scope = targeted_scopes(next_sink, jumps.nexts).reduce(fall_through) do |acc, next_scope|
2475
2698
  join_with_nil_injection(acc, next_scope)
2476
2699
  end
2477
- [exit_scope, breaks]
2700
+ [exit_scope, breaks, values]
2478
2701
  end
2479
2702
 
2480
2703
  # The `break` scopes the continuation joins. A `break` leaves the loop, so its binding starts no further iteration
@@ -2494,12 +2717,24 @@ module Rigor
2494
2717
  # The unrecorded pass is otherwise an ordinary evaluation: a `return` it reaches joins the enclosing method's
2495
2718
  # inferred return type, as one any other pass reaches does. That only widens the return, toward values a capped
2496
2719
  # fixpoint's own passes never evaluated.
2720
+ #
2721
+ # Returns `[arms, values]`, `values` being what the same pass's `break`s carry ({#loop_value}).
2497
2722
  def loop_break_arms(node, post_pred, converged, body_first, jumps, break_pass)
2498
- return NO_BREAK_ARMS if break_pass.nil?
2499
- return break_pass[:arms] if break_pass[:entry] == converged.except(*body_first)
2723
+ return [NO_BREAK_ARMS, NO_BREAK_ARMS] if break_pass.nil?
2724
+ return break_pass.values_at(:arms, :values) if break_pass[:entry] == converged.except(*body_first)
2500
2725
 
2501
2726
  entry = loop_pass_entry(node, post_pred, converged, body_first, jumps)
2502
- loop_iteration(node.statements, entry, jumps, recorded: false).last
2727
+ loop_iteration(node.statements, entry, jumps, recorded: false).drop(1)
2728
+ end
2729
+
2730
+ # Issue #2128 — whether a pass from the converged bindings must run for the `return`s in a loop body: a method's
2731
+ # return sink is active and the body holds a `return` that exits the method ({ReturnBarrier.exits_method?}). A
2732
+ # `return` whose condition reads a loop-carried local (`return false if seen > 3` after `seen += 1`) is dead in
2733
+ # every capped fixpoint pass, which runs from a value-pinned assumption (`seen` at most `3`), and only the pass
2734
+ # from the widened binding reaches it ({#loop_break_arms}); without it the method inferred `true` and a
2735
+ # caller's test on it folded always-truthy on correct code.
2736
+ def later_pass_returns?(statements)
2737
+ !Thread.current[RETURN_SINK_KEY].nil? && ReturnBarrier.exits_method?(statements)
2503
2738
  end
2504
2739
 
2505
2740
  # Joins each `break` arm's body-written local bindings into the loop continuation, so a `break`-path binding the
@@ -2674,10 +2909,11 @@ module Rigor
2674
2909
  # it.
2675
2910
  def loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass = nil)
2676
2911
  entry = loop_pass_entry(node, post_pred, bindings, body_first, jumps)
2677
- exit_scope, breaks = loop_iteration(node.statements, entry, jumps)
2912
+ exit_scope, breaks, values = loop_iteration(node.statements, entry, jumps)
2678
2913
  if break_pass
2679
2914
  break_pass[:entry] = bindings.except(*body_first)
2680
2915
  break_pass[:arms] = breaks
2916
+ break_pass[:values] = values
2681
2917
  end
2682
2918
  names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
2683
2919
  end
@@ -2736,29 +2972,157 @@ module Rigor
2736
2972
  # index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
2737
2973
  # once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
2738
2974
  # `post_collection`) and the body scope, with half-bound names degraded to `T | nil` via nil-injection. The loop
2739
- # expression itself types as `Constant[nil]`, the policy `eval_loop` uses for `while` / `until` — a known gap for
2740
- # `for`, whose value in Ruby is the collection it iterated (issue #1216).
2975
+ # expression is the collection's `each` value joined with every `break` value ({#for_value}, issue #1216).
2741
2976
  def eval_for(node)
2742
2977
  coll_type, post_coll = sub_eval(node.collection, scope)
2743
- element_type = for_iteration_element_type(coll_type)
2744
- # Issue #1359 — a body that may set `$_` runs again after it ran, as a `while` body does ({#eval_loop}).
2745
- body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
2746
- body_entry = forget_guard_for_body(node, post_coll, body_entry)
2747
- body_entry = loop_content_entry(node.statements, post_coll, body_entry)
2978
+ body_entry = for_body_entry(node, for_iteration_element_type(coll_type), post_coll)
2748
2979
 
2749
2980
  if node.statements.nil?
2750
- return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
2981
+ return [for_value(node, coll_type, post_coll, NO_BREAK_ARMS),
2982
+ join_with_nil_injection(post_coll, body_entry)]
2751
2983
  end
2752
2984
 
2753
2985
  # The body pass ends with its `next` exits as well as its fall-through, and its `break` arms are recovered into
2754
2986
  # the continuation (the `for` sibling of `eval_loop`'s break join; `for` has no fixpoint, so the single pass is
2755
2987
  # the only continuation and the only source of `break` arms).
2756
2988
  jumps = loop_jumps(node.statements)
2757
- body_scope, breaks = loop_iteration(node.statements, body_entry, jumps)
2989
+ body_scope, breaks, values = loop_iteration(node.statements, body_entry, jumps)
2990
+ values += later_for_pass_values(node.statements, body_entry, body_scope, jumps)
2758
2991
  continuation = join_with_nil_injection(post_coll, body_scope)
2759
2992
  pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
2760
- continuation = join_break_scopes(continuation, breaks, pre_existing + body_first)
2761
- [Type::Combinator.constant_of(nil), for_exit(node, post_coll, continuation)]
2993
+ continuation = for_exit(node, post_coll, join_break_scopes(continuation, breaks, pre_existing + body_first))
2994
+ continuation = widen_for_elements(node, continuation)
2995
+ [for_value(node, coll_type, continuation, values), continuation]
2996
+ end
2997
+
2998
+ # Issue #2262 — the variables the collection reads, past a body that mutates an element in place: `for x in a;
2999
+ # x << 2; end` leaves `a[0]` holding what the literal's shape did not.
3000
+ def widen_for_elements(node, scope)
3001
+ DeepErasure.widen_element_aliases(
3002
+ node.collection, node.statements, DeepErasure.parameter_names(node.index), scope
3003
+ )
3004
+ end
3005
+
3006
+ # Issue #2128 — a `for` body has no rebind fixpoint, so its only pass enters with every loop-carried local at its
3007
+ # pre-loop binding, and a `return` or `break` whose condition needs a later iteration (`n = 0; for x in xs;
3008
+ # return :f if (n += 1) > 2; end`) was never reached. When the body holds such a jump (a `break`, or a `return`
3009
+ # that exits the method while a return sink is active, {#later_pass_returns?}), one more unrecorded pass runs
3010
+ # with each local and instance variable the body rebinds at its value-pinned widening
3011
+ # (`Type::Combinator.widen_value_pinned`) of its entry binding joined with the first pass's exit, a binding that
3012
+ # covers what any iteration enters with, as the `while` fixpoint's widened binding does. The `return`s it
3013
+ # reaches join the method's return type as any pass's do, and the `break` values it collects are returned for
3014
+ # the loop's value ({#for_value}). The pass records no scope, and the continuation keeps the first pass's
3015
+ # answer.
3016
+ def later_for_pass_values(statements, body_entry, body_scope, jumps)
3017
+ return NO_BREAK_ARMS unless jumps.breaks || later_pass_returns?(statements)
3018
+
3019
+ names, body_first = loop_rebound_names(statements, body_entry)
3020
+ bindings = (names - body_first).filter_map do |name|
3021
+ joined = [body_entry, body_scope].filter_map { |s| CapturedLocals.bound_type(s, name) }
3022
+ [name, Type::Combinator.widen_value_pinned(Type::Combinator.union(*joined))] unless joined.empty?
3023
+ end
3024
+ return NO_BREAK_ARMS if bindings.empty?
3025
+
3026
+ entry = bindings.reduce(body_entry) { |acc, (name, type)| bind_capture(acc, name, type) }
3027
+ loop_iteration(statements, entry, jumps, recorded: false)[2]
3028
+ end
3029
+
3030
+ # The core collections whose `each` with a block answers its receiver.
3031
+ FOR_SELF_EACH_CLASSES = Set["Array", "Hash", "Range"].freeze
3032
+ private_constant :FOR_SELF_EACH_CLASSES
3033
+
3034
+ # The nodes through which a `for` body or index may change the iterated collection's contents: a call (an
3035
+ # in-place mutator on the collection or on an alias of it, or a method that reaches it), `yield` / `super`, an
3036
+ # index store, and an operator write, which calls its operator on the variable's value (`b <<= 9` appends to
3037
+ # the array `b` holds).
3038
+ FOR_MUTATING_NODES = Set[
3039
+ Prism::CallNode, Prism::YieldNode, Prism::SuperNode, Prism::ForwardingSuperNode, Prism::IndexTargetNode,
3040
+ Prism::IndexOperatorWriteNode, Prism::IndexOrWriteNode, Prism::IndexAndWriteNode,
3041
+ Prism::CallOperatorWriteNode, Prism::CallOrWriteNode, Prism::CallAndWriteNode,
3042
+ Prism::LocalVariableOperatorWriteNode, Prism::InstanceVariableOperatorWriteNode,
3043
+ Prism::ClassVariableOperatorWriteNode, Prism::GlobalVariableOperatorWriteNode
3044
+ ].freeze
3045
+ FOR_VARIABLE_READS = Set[Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode].freeze
3046
+ private_constant :FOR_MUTATING_NODES, :FOR_VARIABLE_READS
3047
+
3048
+ # Issue #1216 — `for x in coll` is `coll.each { |x| … }`, so the loop's value is what that `each` answers, or
3049
+ # the value of the `break` that ends it. An Array, a Hash and a Range answer themselves, including when
3050
+ # they are empty and the body never runs, so the value is the collection ({#for_collection_after}). Any other
3051
+ # collection (`Dynamic`, a project class, an `Enumerator`, a union with `nil`) answers `Dynamic[top]`: its
3052
+ # `each` is not known to answer its receiver, and a guessed type would be a claim the analysis cannot back.
3053
+ def for_value(node, coll_type, continuation, break_values)
3054
+ each_value = each_answers_self?(coll_type) ? for_collection_after(node, coll_type, continuation) : Type::Combinator.untyped
3055
+ break_values.empty? ? each_value : Type::Combinator.union(each_value, *break_values)
3056
+ end
3057
+
3058
+ # The collection as the loop leaves it. `each` answers the object it iterated, after the body ran, so a body that
3059
+ # may change its contents ({FOR_MUTATING_NODES}: `a.pop`, `h.delete(k)`, `b << 3` on an alias `b`) leaves
3060
+ # neither its shape nor its size: `a = [1, 2]; for i in a; a.pop; end` is `[1]`. The value is then the
3061
+ # collection's type with every shape and refinement dropped to its nominal ({#shape_erased_collection}), joined,
3062
+ # when the collection is a local or instance variable the loop does not rebind, with that variable's binding
3063
+ # after the loop, which holds the contents the body's own mutator calls add. A body that runs no such code
3064
+ # leaves the collection as it was.
3065
+ def for_collection_after(node, coll_type, continuation)
3066
+ return coll_type unless [node.index, node.statements].any? { |part| for_may_mutate?(part) }
3067
+
3068
+ types = [coll_type]
3069
+ name = for_collection_variable(node)
3070
+ types << CapturedLocals.bound_type(continuation, name) if name
3071
+ deep = DeepErasure.element_mutation?(node.statements, DeepErasure.parameter_names(node.index))
3072
+ Type::Combinator.union(*types.compact.map { |type| shape_erased_collection(type, deep: deep) })
3073
+ end
3074
+
3075
+ def for_may_mutate?(part)
3076
+ return false if part.nil?
3077
+
3078
+ found = false
3079
+ Source::NodeWalker.each(part) { |descendant| found ||= FOR_MUTATING_NODES.include?(descendant.class) }
3080
+ found || FOR_MUTATING_NODES.include?(part.class)
3081
+ end
3082
+
3083
+ # The local or instance variable `for` iterates, when neither its index nor its body rebinds it.
3084
+ def for_collection_variable(node)
3085
+ collection = node.collection
3086
+ return nil unless FOR_VARIABLE_READS.include?(collection.class)
3087
+
3088
+ written = OperandEffects.written_variables(node.index) + OperandEffects.written_variables(node.statements)
3089
+ written.include?(collection.name) ? nil : collection.name
3090
+ end
3091
+
3092
+ # `type` with each `Tuple` / `HashShape` / literal Array or Hash dropped to its nominal, each refinement to its
3093
+ # base. A Range is immutable and keeps its value.
3094
+ def shape_erased_collection(type, deep: false)
3095
+ # A body that mutates an element in place changes the shapes one level down too, which `each` answers
3096
+ # as the elements' own objects ({DeepErasure.widen}).
3097
+ return DeepErasure.widen(type) if deep && !(type.is_a?(Type::Constant) && type.value.is_a?(Range))
3098
+
3099
+ case type
3100
+ when Type::Tuple then MutationWidening.widen_tuple(type)
3101
+ when Type::HashShape then MutationWidening.widen_hash_shape(type)
3102
+ when Type::Constant then type.value.is_a?(Range) ? type : Type::Combinator.widen_value_pinned(type)
3103
+ when Type::Refined, Type::Difference then shape_erased_collection(type.base)
3104
+ when Type::Union then Type::Combinator.union(*type.members.map { |member| shape_erased_collection(member) })
3105
+ else type
3106
+ end
3107
+ end
3108
+
3109
+ def each_answers_self?(type)
3110
+ case type
3111
+ when Type::Tuple, Type::HashShape then true
3112
+ when Type::Nominal then FOR_SELF_EACH_CLASSES.include?(type.class_name)
3113
+ when Type::Constant then type.value.is_a?(Array) || type.value.is_a?(Hash) || type.value.is_a?(Range)
3114
+ when Type::Refined, Type::Difference then each_answers_self?(type.base)
3115
+ when Type::Union then type.members.all? { |member| each_answers_self?(member) }
3116
+ else false
3117
+ end
3118
+ end
3119
+
3120
+ # The scope a `for` body enters with: the index bound to the element. Issue #1359 — a body that may set `$_` runs
3121
+ # again after it ran, as a `while` body does ({#eval_loop}).
3122
+ def for_body_entry(node, element_type, post_coll)
3123
+ body_entry = LastLine.forget_if_set(bind_for_index(node.index, element_type, post_coll), node.statements)
3124
+ body_entry = forget_guard_for_body(node, post_coll, body_entry)
3125
+ loop_content_entry(node.statements, post_coll, body_entry)
2762
3126
  end
2763
3127
 
2764
3128
  # Issue #1429 — a `for` body runs after the collection's `each`, which may be a project method, and after itself.
@@ -4124,13 +4488,163 @@ module Rigor
4124
4488
  end
4125
4489
  end
4126
4490
 
4127
- # The `{ name => position }` positional parameters whose content the callee mutates, from either channel: those
4128
- # escape-mutated inside a block (which may run later / repeatedly) AND those mutated directly in the method body
4129
- # during the call itself (`declaration[:prefix] = v`). Both leave the caller's argument binding stale after the
4130
- # call, so both floor it. Memoised per def node (the merge is otherwise recomputed at every call site).
4491
+ # The `{ name => slot }` parameters whose content the callee mutates, a slot being a positional index or a
4492
+ # keyword symbol: those it mutates itself ({#own_content_parameters}) and, issue #2104, those it passes on to a
4493
+ # self-call whose callee mutates them, through any number of such hand-offs ({CalleeMutationChain}). Each leaves
4494
+ # the caller's argument binding stale after the call, so each floors it. Memoised per def node (the closure is
4495
+ # otherwise recomputed at every call site), and across evaluators by {#resolved_census}.
4131
4496
  def callee_content_mutated_parameters(def_node)
4132
4497
  cache = (@callee_mutated_param_cache ||= {}.compare_by_identity)
4133
- cache[def_node] ||= escaped_content_parameters(def_node).merge(direct_content_parameters(def_node))
4498
+ cache[def_node] ||= resolved_census(:close, def_node) do
4499
+ CalleeMutationChain.close(def_node, own: method(:own_content_parameters), edges: method(:forwarding_edges))
4500
+ end
4501
+ end
4502
+
4503
+ # Issue #2104 — the per-thread memo of a census that also resolves self-calls ({#resolve_self_callee_def}), so it
4504
+ # reads, besides `def_node`'s AST, the class self-calls resolve in and the discovery index whose def table they
4505
+ # resolve through. Those two key it: the index by identity, as it is immutable (one slot, as a file's analysis
4506
+ # reads one index; a scope indexed for another file replaces it), the class by name. A run builds hundreds of
4507
+ # thousands of evaluators, and a per-evaluator memo alone recomputed each closure and each def's edges about
4508
+ # thirty times over (#2295's cost on the release gate).
4509
+ #
4510
+ # While a dependency recorder is active the memo is bypassed: a resolution records an ADR-46 read in the file
4511
+ # and in each capture window open at the time, and a hit would leave that read out of them.
4512
+ def resolved_census(kind, def_node)
4513
+ return yield if Analysis::DependencyRecorder.active?
4514
+
4515
+ discovery = scope.discovery
4516
+ slot = Thread.current[RESOLVED_CENSUS_KEY]
4517
+ slot = Thread.current[RESOLVED_CENSUS_KEY] = [discovery, {}] unless slot&.first.equal?(discovery)
4518
+ by_class = (slot.last[kind] ||= {})
4519
+ class_name = enclosing_class_name_for(scope.self_type)
4520
+ by_def = (by_class[class_name] ||= {}.compare_by_identity)
4521
+ by_def.fetch(def_node) { by_def[def_node] = yield }
4522
+ end
4523
+
4524
+ RESOLVED_CENSUS_KEY = :rigor_callee_resolved_census
4525
+ private_constant :RESOLVED_CENSUS_KEY
4526
+
4527
+ # The parameters `def_node` mutates itself, from either channel: those escape-mutated inside a block (which may
4528
+ # run later / repeatedly) AND those mutated directly in the method body during the call itself
4529
+ # (`declaration[:prefix] = v`). A property of the def's AST alone, so it is memoised per def node for the thread
4530
+ # ({#def_census}) rather than per evaluator: a chain reads its callees' census from every evaluator that floors a
4531
+ # call into it.
4532
+ def own_content_parameters(def_node)
4533
+ def_census(:own, def_node) { escaped_content_parameters(def_node).merge(direct_content_parameters(def_node)) }
4534
+ end
4535
+
4536
+ # Issue #2104 — the per-thread memo of a census that reads nothing but `def_node`'s AST, under `kind`.
4537
+ def def_census(kind, def_node)
4538
+ table = (Thread.current[DEF_CENSUS_KEY] ||= ObjectSpace::WeakKeyMap.new)
4539
+ entry = (table[def_node] ||= {})
4540
+ entry.fetch(kind) { entry[kind] = yield }
4541
+ end
4542
+
4543
+ DEF_CENSUS_KEY = :rigor_callee_def_census
4544
+ private_constant :DEF_CENSUS_KEY
4545
+
4546
+ # Issue #2104 — the {CalleeMutationChain::Edge}s along which `def_node` hands one of its parameters, unchanged, to
4547
+ # a self-call resolving to a user def: a positional argument or a keyword value that reads the parameter itself
4548
+ # (not a block-local shadowing it), in the method body or a block in it. The callee resolves in this evaluator's
4549
+ # class, the receiver's class at every hop of a self-call chain. Memoised per def node, and across evaluators by
4550
+ # {#resolved_census}.
4551
+ def forwarding_edges(def_node)
4552
+ cache = (@forwarding_edge_cache ||= {}.compare_by_identity)
4553
+ cache[def_node] ||= resolved_census(:edges, def_node) { compute_forwarding_edges(def_node) }
4554
+ end
4555
+
4556
+ def compute_forwarding_edges(def_node)
4557
+ slots = content_parameter_slots(def_node)
4558
+ edges = []
4559
+ each_forwarding_call(def_node, slots) do |call_node, forwarded|
4560
+ callee = resolve_self_callee_def(call_node)
4561
+ next if callee.nil?
4562
+
4563
+ callee_slots = content_parameter_slots(callee)
4564
+ forwarded.each do |name, callee_slot|
4565
+ names = bound_parameter_names(callee_slots, callee_slot)
4566
+ next if names.empty?
4567
+
4568
+ edges << CalleeMutationChain::Edge.new(name: name, slot: slots[name], callee: callee, callee_names: names)
4569
+ end
4570
+ end
4571
+ edges.empty? ? NO_FORWARDS : edges
4572
+ end
4573
+
4574
+ # Yields each self-call in `def_node`'s body that hands on one of `slots`' parameters, with
4575
+ # {#forwarded_parameters}' answer for it. The calls are an AST property, memoised per def node ({#def_census}).
4576
+ def each_forwarding_call(def_node, slots, &)
4577
+ return if slots.empty?
4578
+
4579
+ def_census(:forwards, def_node) { forwarding_calls(def_node, slots) }.each(&)
4580
+ end
4581
+
4582
+ def forwarding_calls(def_node, slots)
4583
+ calls = []
4584
+ each_node_in_method_scope(def_node.body, 0) do |descendant, depth|
4585
+ next unless self_dispatch_call?(descendant)
4586
+
4587
+ forwarded = forwarded_parameters(descendant, slots, depth)
4588
+ calls << [descendant, forwarded] unless forwarded.empty?
4589
+ end
4590
+ calls.empty? ? NO_FORWARDS : calls
4591
+ end
4592
+
4593
+ NO_FORWARDS = [].freeze
4594
+ private_constant :NO_FORWARDS
4595
+
4596
+ # `[[parameter_name, callee_slot], ...]` for the arguments of `call_node` that read one of `slots`' parameters at
4597
+ # `depth`, the block nesting the call sits at: a read of the method's own parameter there has that depth. An
4598
+ # argument after a splat or `...` has an index that says nothing about the parameter it binds, so it binds
4599
+ # {ANY_SLOT}, any positional parameter of the callee; one before the first splat keeps its index.
4600
+ def forwarded_parameters(call_node, slots, depth)
4601
+ args = call_node.arguments
4602
+ return NO_FORWARDS unless args.is_a?(Prism::ArgumentsNode)
4603
+
4604
+ spread = false
4605
+ forwarded = []
4606
+ args.arguments.each_with_index do |argument, index|
4607
+ if argument.is_a?(Prism::KeywordHashNode)
4608
+ forwarded.concat(forwarded_keywords(argument, slots, depth))
4609
+ elsif argument.is_a?(Prism::SplatNode) || argument.is_a?(Prism::ForwardingArgumentsNode)
4610
+ spread = true
4611
+ elsif (name = parameter_read_name(argument, slots, depth))
4612
+ forwarded << [name, spread ? ANY_SLOT : index]
4613
+ end
4614
+ end
4615
+ forwarded
4616
+ end
4617
+
4618
+ # The `callee_slot` of an argument whose parameter the call does not show (one after a splat).
4619
+ ANY_SLOT = :__any_positional__
4620
+ private_constant :ANY_SLOT
4621
+
4622
+ # The names of the callee's parameters (`callee_slots`, {#content_parameter_slots}) an argument bound to `slot`
4623
+ # may reach: the one at that slot, or every positional one for {ANY_SLOT}.
4624
+ def bound_parameter_names(callee_slots, slot)
4625
+ if slot == ANY_SLOT
4626
+ callee_slots.filter_map { |name, at| name if at.is_a?(Integer) }
4627
+ else
4628
+ callee_slots.filter_map { |name, at| name if at == slot }
4629
+ end
4630
+ end
4631
+
4632
+ def forwarded_keywords(hash_node, slots, depth)
4633
+ hash_node.elements.filter_map do |pair|
4634
+ next unless pair.is_a?(Prism::AssocNode) && pair.key.is_a?(Prism::SymbolNode)
4635
+
4636
+ name = parameter_read_name(pair.value, slots, depth)
4637
+ [name, pair.key.unescaped.to_sym] if name
4638
+ end
4639
+ end
4640
+
4641
+ # The parameter `node` reads when it is a read of one of `slots`' names at `depth` (a keyword shorthand
4642
+ # `poke(acc:)` wraps the read in an `ImplicitNode`), else nil.
4643
+ def parameter_read_name(node, slots, depth)
4644
+ node = node.value if node.is_a?(Prism::ImplicitNode)
4645
+ return unless node.is_a?(Prism::LocalVariableReadNode) && node.depth == depth
4646
+
4647
+ node.name if slots.key?(node.name)
4134
4648
  end
4135
4649
 
4136
4650
  # The user def a self-dispatch `node` resolves to in the enclosing class, or nil. Reuses the discovery index
@@ -4149,7 +4663,7 @@ module Rigor
4149
4663
  node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
4150
4664
  end
4151
4665
 
4152
- # The set of `[name, position]` parameters of `def_node` whose content a block in the body escape-mutates.
4666
+ # The set of `[name, slot]` parameters of `def_node` whose content a block in the body escape-mutates.
4153
4667
  # Memoised per def node (the body walk is otherwise repeated at every call site). A parameter is "escape- mutated"
4154
4668
  # when a `param[k] = v` / `param << x` mutation on it appears inside a block whose receiving call is not proven
4155
4669
  # non-escaping.
@@ -4159,7 +4673,7 @@ module Rigor
4159
4673
  end
4160
4674
 
4161
4675
  def compute_escaped_content_parameters(def_node)
4162
- positions = positional_parameter_positions(def_node)
4676
+ positions = content_parameter_slots(def_node)
4163
4677
  return {} if positions.empty?
4164
4678
 
4165
4679
  mutated = Set.new
@@ -4174,18 +4688,22 @@ module Rigor
4174
4688
  positions.slice(*mutated)
4175
4689
  end
4176
4690
 
4177
- # The `{ name => position }` positional parameters whose CONTENT the callee mutates directly in its method body —
4178
- # a top-level `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case
4179
- # is `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
4691
+ # The `{ name => slot }` parameters whose CONTENT the callee mutates directly in its method body — a top-level
4692
+ # `param[k] = v` / `param << x`, outside a nested block. Memoised per def node. The block-nested case is
4693
+ # `escaped_content_parameters`'s job; walking only outside nested blocks / defs / lambdas here means a matching
4180
4694
  # parameter-name read is genuinely at method scope (depth 0) rather than a block-local of the same name that
4181
4695
  # merely shadows the parameter — so we never floor a caller argument the callee did not actually mutate.
4696
+ #
4697
+ # A mutation inside a NON-escaping block (`n.times { |i| acc[i] = v }`) is not counted: the bare-collection floor
4698
+ # it would apply turned haml's `AttributeCompiler#compile` (a `temple` array a helper fills in a `times` block,
4699
+ # returned against a declared tuple) into a `def.return-type-mismatch` (af3efef33, and again under #2104).
4182
4700
  def direct_content_parameters(def_node)
4183
4701
  cache = (@direct_param_cache ||= {}.compare_by_identity)
4184
4702
  cache[def_node] ||= compute_direct_content_parameters(def_node)
4185
4703
  end
4186
4704
 
4187
4705
  def compute_direct_content_parameters(def_node)
4188
- positions = positional_parameter_positions(def_node)
4706
+ positions = content_parameter_slots(def_node)
4189
4707
  return {} if positions.empty?
4190
4708
 
4191
4709
  mutated = Set.new
@@ -4210,6 +4728,43 @@ module Rigor
4210
4728
  end
4211
4729
  end
4212
4730
 
4731
+ # Yields every node reachable from `node` with the number of blocks it sits in: a read of the method's own local
4732
+ # there has that depth. A nested def or lambda is not entered: a def has no access to the method's locals, and a
4733
+ # lambda body is not run by the call. Nor is the block of a {#self_rebinding_block_call?}: a self-call there
4734
+ # either runs on another `self`, or runs later as a method body, so the walk leaves it out.
4735
+ def each_node_in_method_scope(node, depth, &)
4736
+ return if node.nil?
4737
+
4738
+ yield node, depth
4739
+ rebinding = self_rebinding_block_call?(node)
4740
+ node.rigor_each_child do |child|
4741
+ next if child.is_a?(Prism::DefNode) || child.is_a?(Prism::LambdaNode)
4742
+
4743
+ if child.is_a?(Prism::BlockNode)
4744
+ each_node_in_method_scope(child, depth + 1, &) unless rebinding
4745
+ else
4746
+ each_node_in_method_scope(child, depth, &)
4747
+ end
4748
+ end
4749
+ end
4750
+
4751
+ # The calls whose block the hand-off walk leaves out. In an `instance_eval` / `class_exec` / … block, and in a
4752
+ # `Class.new` / `Module.new` / `Struct.new` / `Data.define` block ({AnonymousMetaClass.block_form_receiver}),
4753
+ # `self` is another object, so a self-call there is not a call on the method's receiver. A `define_method` or
4754
+ # `define_singleton_method` block keeps an instance of the class as `self`, but it is a method body that runs
4755
+ # later, if ever, so leaving it out is the conservative reading: a hand-off it holds is not one the call makes.
4756
+ SELF_REBINDING_BLOCK_CALLS = Set[
4757
+ :instance_eval, :instance_exec, :class_eval, :class_exec, :module_eval, :module_exec, :define_method,
4758
+ :define_singleton_method
4759
+ ].freeze
4760
+ private_constant :SELF_REBINDING_BLOCK_CALLS
4761
+
4762
+ def self_rebinding_block_call?(node)
4763
+ return false unless node.is_a?(Prism::CallNode) && node.block.is_a?(Prism::BlockNode)
4764
+
4765
+ SELF_REBINDING_BLOCK_CALLS.include?(node.name) || !AnonymousMetaClass.block_form_receiver(node).nil?
4766
+ end
4767
+
4213
4768
  # A receiver-independent over-approximation of `ClosureEscapeAnalyzer`'s non-escaping verdict, used when scanning
4214
4769
  # a callee body where the block- owning call's receiver TYPE is not available. A call whose method name is a known
4215
4770
  # structural iterator (`each` / `map` / `tap` / …) runs its block synchronously and does not retain it, so its
@@ -4230,18 +4785,20 @@ module Rigor
4230
4785
  SYNTACTIC_NON_ESCAPING_BLOCK_METHODS.include?(call_node.name)
4231
4786
  end
4232
4787
 
4233
- # `{ name => position }` for the required / optional positional parameters of a def. Keyword / rest / block
4234
- # parameters are skipped — the boundary-escape idiom passes a plain positional collection.
4235
- def positional_parameter_positions(def_node)
4788
+ # `{ name => slot }` for the parameters a caller's argument binds by name or position: the required / optional
4789
+ # positional ones (slot: their index) and, issue #2104, the keyword ones (slot: the keyword symbol). Rest, post
4790
+ # and block parameters are skipped.
4791
+ def content_parameter_slots(def_node)
4236
4792
  params = def_node.parameters
4237
4793
  return {} if params.nil?
4238
4794
 
4239
4795
  ordered = (params.requireds || []) + (params.optionals || [])
4240
- positions = {}
4796
+ slots = {}
4241
4797
  ordered.each_with_index do |param, index|
4242
- positions[param.name] = index if param.respond_to?(:name)
4798
+ slots[param.name] = index if param.respond_to?(:name)
4243
4799
  end
4244
- positions
4800
+ (params.keywords || []).each { |param| slots[param.name] = param.name }
4801
+ slots
4245
4802
  end
4246
4803
 
4247
4804
  # True when at least one argument of `node` is a bare local-variable read (positional or keyword value) bound in
@@ -4257,9 +4814,10 @@ module Rigor
4257
4814
  scope.locals.key?(arg.name)
4258
4815
  when Prism::KeywordHashNode
4259
4816
  arg.elements.any? do |pair|
4260
- pair.is_a?(Prism::AssocNode) &&
4261
- pair.value.is_a?(Prism::LocalVariableReadNode) &&
4262
- scope.locals.key?(pair.value.name)
4817
+ next false unless pair.is_a?(Prism::AssocNode)
4818
+
4819
+ value = pair.value.is_a?(Prism::ImplicitNode) ? pair.value.value : pair.value
4820
+ value.is_a?(Prism::LocalVariableReadNode) && scope.locals.key?(value.name)
4263
4821
  end
4264
4822
  else
4265
4823
  false
@@ -4417,6 +4975,11 @@ module Rigor
4417
4975
  # The fresh `next`, `break`-value and `break`-scope sinks are defensive: every consumer of those sinks filters by
4418
4976
  # jump-node identity, so no example tells them apart from the outer sinks today. So is the `rescue`: a raise here
4419
4977
  # keeps the arms the earlier passes collected rather than taking out the method.
4978
+ #
4979
+ # Issue #2127 — a block passed to a project method whose body may run it more than once
4980
+ # ({ProjectYielder.may_run_block_repeatedly?}) gets the same pass. No write-back runs for such a call, so its
4981
+ # continuation holds each rebound capture at `Dynamic[top]` ({#drop_captured_narrowing}), and the pass reads it
4982
+ # so: `seen = 0; yielder { return :ret if (seen += 1) > 1 }` reaches the `return`.
4420
4983
  def collect_repeated_block_returns(call_node, post_scope)
4421
4984
  return if Thread.current[RETURN_SINK_KEY].nil?
4422
4985
 
@@ -4440,16 +5003,19 @@ module Rigor
4440
5003
  end
4441
5004
 
4442
5005
  # Whether `block` is an ordinary block (not a return barrier or a class-body block) whose body may `return` from
4443
- # the method and which `call_node` may run more than once: {#repeating_block_class}'s rule, or `Kernel#loop`,
4444
- # which that rule leaves out and which runs its block until it breaks or raises. The barrier, refinement and
5006
+ # the method and which `call_node` may run more than once: {#repeating_block_class}'s rule, `Kernel#loop`, which
5007
+ # that rule leaves out and which runs its block until it breaks or raises, or a project method whose body may
5008
+ # run it more than once ({ProjectYielder.may_run_block_repeatedly?}, issue #2127). The barrier, refinement and
4445
5009
  # anonymous-class checks are defensive guards: no such call repeats under that rule today.
4446
5010
  def repeated_block_return_candidate?(call_node, block)
4447
5011
  return false unless block.is_a?(Prism::BlockNode)
4448
5012
  return false unless ReturnBarrier.exits_method?(block.body)
4449
5013
  return false if return_barrier_block?(call_node) || refined_class_context(call_node)
4450
5014
  return false if AnonymousMetaClass.name_for(call_node, scope.source_path)
5015
+ return true if BlockCallTiming.kernel_loop?(call_node) || !repeating_block_class(call_node).nil?
4451
5016
 
4452
- BlockCallTiming.kernel_loop?(call_node) || !repeating_block_class(call_node).nil?
5017
+ receiver_type = block_call_receiver_type(call_node)
5018
+ !receiver_type.nil? && ProjectYielder.may_run_block_repeatedly?(call_node, receiver_type, scope)
4453
5019
  end
4454
5020
 
4455
5021
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
@@ -5523,12 +6089,7 @@ module Rigor
5523
6089
  # receiver is the one the block-return pass asks about: the explicit receiver, or the implicit `self`
5524
6090
  # (`Object` at the top level).
5525
6091
  def repeating_block_class(call_node)
5526
- receiver_type =
5527
- if call_node.receiver
5528
- explicit_receiver_type(call_node)
5529
- else
5530
- operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
5531
- end
6092
+ receiver_type = block_call_receiver_type(call_node)
5532
6093
  return nil if receiver_type.nil?
5533
6094
 
5534
6095
  method_name = call_node.name
@@ -5542,6 +6103,13 @@ module Rigor
5542
6103
  nil
5543
6104
  end
5544
6105
 
6106
+ # The receiver a block-carrying call dispatches on: its explicit receiver's type, else `self`, else `Object`.
6107
+ def block_call_receiver_type(call_node)
6108
+ return explicit_receiver_type(call_node) if call_node.receiver
6109
+
6110
+ operand_scope.self_type || operand_scope.environment.nominal_for_name("Object")
6111
+ end
6112
+
5545
6113
  # The binding each outer local or ivar the body rebinds ({CapturedLocals.writes}) from a sentinel seed — `nil` or
5546
6114
  # `false`, alone or as a union of the two — enters a repeating call's body with when the write-back will not run
5547
6115
  # its passes for it ({#write_back_block_captures} reaches only an explicit receiver classified
@@ -6129,7 +6697,7 @@ module Rigor
6129
6697
  # inferred.
6130
6698
  def eval_return(node)
6131
6699
  sink = Thread.current[RETURN_SINK_KEY]
6132
- sink << jump_value_type(node) if sink
6700
+ sink&.record(node, jump_value_type(node))
6133
6701
  [Type::Combinator.bot, scope]
6134
6702
  end
6135
6703