rigortype 0.4.3 → 0.4.5

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +104 -15
  5. data/docs/manual/02-cli-reference.md +43 -12
  6. data/docs/manual/03-configuration.md +20 -7
  7. data/docs/manual/04-diagnostics.md +56 -3
  8. data/docs/manual/05-inspecting-types.md +2 -2
  9. data/docs/manual/06-baseline.md +1 -1
  10. data/docs/manual/07-plugins.md +3 -2
  11. data/docs/manual/08-skills.md +7 -0
  12. data/docs/manual/10-mcp-server.md +40 -4
  13. data/docs/manual/18-removing-dead-code.md +23 -2
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-rspec.md +101 -1
  16. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  17. data/exe/rigor +12 -17
  18. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  19. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  20. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  21. data/lib/rigor/analysis/check_rules/rule_ids.rb +60 -1
  22. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  23. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  24. data/lib/rigor/analysis/check_rules.rb +274 -51
  25. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  26. data/lib/rigor/analysis/incremental.rb +61 -0
  27. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  28. data/lib/rigor/analysis/incremental_session.rb +270 -28
  29. data/lib/rigor/analysis/path_expansion.rb +3 -1
  30. data/lib/rigor/analysis/reachability/graph.rb +111 -14
  31. data/lib/rigor/analysis/reachability/scan.rb +305 -23
  32. data/lib/rigor/analysis/rule_catalog.rb +22 -4
  33. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  34. data/lib/rigor/analysis/run_cache_probe.rb +5 -3
  35. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +48 -12
  36. data/lib/rigor/analysis/runner/pool_coordinator.rb +179 -31
  37. data/lib/rigor/analysis/runner/project_pre_passes.rb +29 -4
  38. data/lib/rigor/analysis/runner.rb +102 -72
  39. data/lib/rigor/analysis/severity_stamp.rb +45 -1
  40. data/lib/rigor/analysis/worker_session.rb +69 -2
  41. data/lib/rigor/cache/descriptor.rb +79 -12
  42. data/lib/rigor/cache/engine_source.rb +3 -2
  43. data/lib/rigor/cache/incremental_snapshot.rb +39 -5
  44. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  45. data/lib/rigor/cache/store.rb +4 -3
  46. data/lib/rigor/cli/command.rb +3 -1
  47. data/lib/rigor/cli/docs_command.rb +3 -2
  48. data/lib/rigor/cli/doctor_command.rb +1 -1
  49. data/lib/rigor/cli/explain_command.rb +3 -2
  50. data/lib/rigor/cli/mcp_command.rb +1 -1
  51. data/lib/rigor/cli/plugin_command.rb +2 -1
  52. data/lib/rigor/cli/plugins_command.rb +2 -1
  53. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  54. data/lib/rigor/cli/skill_command.rb +2 -1
  55. data/lib/rigor/cli/skill_describe.rb +3 -2
  56. data/lib/rigor/cli/unused_command.rb +10 -7
  57. data/lib/rigor/cli.rb +3 -2
  58. data/lib/rigor/config_audit.rb +163 -22
  59. data/lib/rigor/configuration/dependencies.rb +19 -11
  60. data/lib/rigor/configuration/severity_profile.rb +20 -2
  61. data/lib/rigor/configuration.rb +29 -3
  62. data/lib/rigor/effects/signature_sources.rb +3 -1
  63. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  64. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  65. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  66. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  67. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  68. data/lib/rigor/environment/rbs_loader.rb +67 -11
  69. data/lib/rigor/inference/block_call_timing.rb +12 -1
  70. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  71. data/lib/rigor/inference/captured_locals.rb +67 -13
  72. data/lib/rigor/inference/clause_widening.rb +90 -0
  73. data/lib/rigor/inference/definer_resolution.rb +101 -25
  74. data/lib/rigor/inference/expression_typer.rb +94 -9
  75. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  76. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  77. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  78. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  79. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  80. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  81. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  82. data/lib/rigor/inference/module_function_state.rb +213 -51
  83. data/lib/rigor/inference/narrowing.rb +235 -75
  84. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  85. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  86. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  87. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  88. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  89. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  90. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  91. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  92. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  93. data/lib/rigor/inference/repeated_or_writes.rb +414 -1
  94. data/lib/rigor/inference/return_barrier.rb +16 -0
  95. data/lib/rigor/inference/scope_indexer.rb +1061 -122
  96. data/lib/rigor/inference/self_call_ivar_writes.rb +346 -0
  97. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  98. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  99. data/lib/rigor/inference/statement_evaluator.rb +512 -150
  100. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  101. data/lib/rigor/lens/anchor.rb +3 -3
  102. data/lib/rigor/lens/contract_reader.rb +60 -2
  103. data/lib/rigor/lens/document.rb +4 -3
  104. data/lib/rigor/lens/file_reader.rb +452 -73
  105. data/lib/rigor/lens/members.rb +81 -17
  106. data/lib/rigor/lens/run.rb +73 -4
  107. data/lib/rigor/lens.rb +2 -1
  108. data/lib/rigor/mcp/server.rb +63 -22
  109. data/lib/rigor/plugin/base.rb +3 -2
  110. data/lib/rigor/plugin/box_probe.rb +154 -7
  111. data/lib/rigor/plugin/io_boundary.rb +42 -6
  112. data/lib/rigor/plugin/isolation.rb +4 -3
  113. data/lib/rigor/project_environment.rb +6 -3
  114. data/lib/rigor/protection/discovery_seed.rb +9 -36
  115. data/lib/rigor/protection/mutation_cache.rb +8 -2
  116. data/lib/rigor/rooted_glob.rb +27 -0
  117. data/lib/rigor/scope/discovery_index.rb +5 -0
  118. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  119. data/lib/rigor/scope/resolution_chain.rb +40 -5
  120. data/lib/rigor/scope.rb +56 -8
  121. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  122. data/lib/rigor/sig_gen/classification.rb +4 -1
  123. data/lib/rigor/sig_gen/generator.rb +167 -22
  124. data/lib/rigor/sig_gen/layout_index.rb +40 -1
  125. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  126. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  127. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  128. data/lib/rigor/sig_gen/renderer.rb +13 -155
  129. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  130. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  131. data/lib/rigor/sig_gen/write_result.rb +3 -1
  132. data/lib/rigor/sig_gen/writer.rb +50 -35
  133. data/lib/rigor/signature_path_audit.rb +4 -2
  134. data/lib/rigor/type/constant.rb +10 -1
  135. data/lib/rigor/version.rb +1 -1
  136. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  137. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  138. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  139. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  140. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  141. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  142. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  143. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  144. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  145. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  146. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  147. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  148. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  149. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  150. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  151. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +57 -6
  152. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  153. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  154. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  155. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  156. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  157. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  158. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  159. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  160. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  161. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  162. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  163. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  164. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  165. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  166. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  167. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  168. data/sig/rigor/rooted_glob.rbs +7 -0
  169. data/sig/rigor/scope.rbs +4 -3
  170. data/skills/rigor-doctor/references/01-checks.md +23 -11
  171. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  172. metadata +16 -1
@@ -10,6 +10,7 @@ require_relative "../source/node_children"
10
10
  require_relative "../source/constant_path"
11
11
  require_relative "anonymous_meta_class"
12
12
  require_relative "definer_resolution"
13
+ require_relative "block_call_timing"
13
14
  require_relative "block_repetition"
14
15
  require_relative "block_parameter_binder"
15
16
  require_relative "body_fixpoint"
@@ -27,7 +28,10 @@ require_relative "content_join"
27
28
  require_relative "define_method_block_self"
28
29
  require_relative "macro_block_self_type"
29
30
  require_relative "guard_rebinding"
31
+ require_relative "self_call_ivar_writes"
32
+ require_relative "intervening_ivar_reset"
30
33
  require_relative "match_rebinding"
34
+ require_relative "clause_widening"
31
35
  require_relative "element_read_widening"
32
36
  require_relative "hash_lookup_mutation"
33
37
  require_relative "indexed_narrowing"
@@ -125,7 +129,7 @@ module Rigor
125
129
  Prism::EnsureNode => :eval_ensure,
126
130
  Prism::WhileNode => :eval_loop,
127
131
  Prism::UntilNode => :eval_loop,
128
- Prism::ForNode => :eval_for,
132
+ Prism::ForNode => :eval_repeating_for,
129
133
  Prism::AndNode => :eval_and_or,
130
134
  Prism::OrNode => :eval_and_or,
131
135
  Prism::ParenthesesNode => :eval_parentheses,
@@ -364,7 +368,18 @@ module Rigor
364
368
  # Default: the node is treated as a pure expression. Type it through the existing expression typer (which
365
369
  # observes the current scope's locals) and leave the scope unchanged, but for the match globals a call in it
366
370
  # may rebind (`super(line.sub(re, ""))`, issue #1365).
367
- [@scope.type_of(node, tracer: @tracer), forget_rebound_specials(@scope, node)]
371
+ after = reset_ivars_past_unknown_code(node, forget_rebound_specials(@scope, node))
372
+ [@scope.type_of(node, tracer: @tracer), after]
373
+ end
374
+
375
+ # Issue #1402 — `after`, past a statement `yield` or `super`, with each narrowed instance variable widened over
376
+ # its class-ivar seed ({InterveningIvarReset.after_operand}): the block `yield` runs, and the superclass method
377
+ # `super` runs on `self`, may write any of them, as an implicit-self call may. Inside an operand the statement
378
+ # that holds it answers instead.
379
+ def reset_ivars_past_unknown_code(node, after)
380
+ return after if @in_operand || !InterveningIvarReset.candidate?(node)
381
+
382
+ InterveningIvarReset.after_operand(node, after)
368
383
  end
369
384
 
370
385
  # Issue #1429 — a compound write or a `for` loop calls a method its syntax does not spell (`r += r` calls `r.+`,
@@ -525,10 +540,13 @@ module Rigor
525
540
  [result_type, current]
526
541
  end
527
542
 
543
+ # The top level is a straight-line body: an index `||=` there whose slot an earlier store may have filled is
544
+ # marked ({RepeatedOrWrites.straight_line_sites}).
528
545
  def eval_program(node)
529
546
  return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
530
547
 
531
- sub_eval(node.statements, scope)
548
+ marks = RepeatedOrWrites.straight_line_sites(node.statements, scope)
549
+ sub_eval(node.statements, scope.with_repeated_or_writes(marks))
532
550
  end
533
551
 
534
552
  # `name = rvalue` evaluates the rvalue under the entry scope (so earlier assignments in a chained `a = b = expr`
@@ -1345,6 +1363,9 @@ module Rigor
1345
1363
  def eval_case_when_branches(subject_type, subject, conditions, entry_scope)
1346
1364
  results = []
1347
1365
  falsey_scope = entry_scope
1366
+ # Issue #1963 — set once a `when` test may have rebound the subject variable, which from there on no longer
1367
+ # holds the value the later tests match ({#subject_when_scopes}).
1368
+ rebound = false
1348
1369
  conditions.each do |branch|
1349
1370
  # Issue #1359 — a clause's conditions, or its pattern's pins and guard, run before its body and before
1350
1371
  # every later clause, and the walk types them without evaluating them, so a reader there forgets `$_` here.
@@ -1355,7 +1376,11 @@ module Rigor
1355
1376
  # recursion) so no condition sub-expression is newly typed; `propagate` preserves the entry because it already
1356
1377
  # keys the node.
1357
1378
  record_clause_entry_scope(branch, falsey_scope)
1358
- body_scope, falsey_scope = branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1379
+ if subject && branch.is_a?(Prism::WhenNode)
1380
+ body_scope, falsey_scope, rebound = subject_when_scopes(subject, branch.conditions, falsey_scope, rebound)
1381
+ else
1382
+ body_scope, falsey_scope = branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1383
+ end
1359
1384
  results << sub_eval(branch, body_scope)
1360
1385
  end
1361
1386
  [results, falsey_scope]
@@ -1380,46 +1405,154 @@ module Rigor
1380
1405
  end
1381
1406
 
1382
1407
  # Returns `[body_scope, updated_falsey_scope]` for a single branch. `WhenNode` branches narrow through
1383
- # `Narrowing.case_when_scopes`. `InNode` branches narrow soundly only for a bare class pattern (`in C` / `in C =>
1384
- # x`, pure `is_a?`); every other pattern keeps the conservative "body = entry + bindings, falsey unchanged" shape.
1385
- # `subject_type` is the predicate's type, which an `in` branch's pattern decomposes to type the names it binds.
1408
+ # `Narrowing.case_when_scopes`, or through {#subjectless_when_scopes} in a `case` without a subject. `InNode`
1409
+ # branches narrow soundly only for a bare class pattern (`in C` / `in C => x`, pure `is_a?`); every other pattern
1410
+ # keeps the conservative "body = entry + bindings, falsey unchanged" shape. `subject_type` is the predicate's
1411
+ # type, which an `in` branch's pattern decomposes to type the names it binds.
1386
1412
  def branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1387
1413
  if branch.is_a?(Prism::InNode)
1388
1414
  in_branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1415
+ elsif subject.nil?
1416
+ subjectless_when_scopes(branch.conditions, falsey_scope)
1389
1417
  else
1390
1418
  when_conditions = branch.respond_to?(:conditions) ? branch.conditions : []
1391
1419
  Narrowing.case_when_scopes(subject, when_conditions, falsey_scope)
1392
1420
  end
1393
1421
  end
1394
1422
 
1423
+ # Issue #1963 — a `when` clause of a `case` with a subject. Ruby runs each test in order (`test === subject`)
1424
+ # until one matches, so a test that runs code — a call, a write, a lambda literal, whose `===` runs its body —
1425
+ # runs before the clause's body and every later clause. Such a test is evaluated ({#run_when_test}), so a call
1426
+ # in it resets the instance variables and the guard narrowings it may rebind, as a statement call does, and a
1427
+ # write in it binds. The body is reached after any prefix of the tests ran, so it enters from the join of the
1428
+ # scopes each test leaves, the next clause from the scope the last one leaves.
1429
+ #
1430
+ # The subject is evaluated once, before every test, so a test that may rebind the subject variable
1431
+ # ({#when_test_rebinds_subject?}) leaves the variable holding something other than the value the later tests
1432
+ # match: from that test on, no clause narrows it (`case @x when String then 1 when reset! then @x.upcase`, with
1433
+ # `reset!` assigning `@x`, reads `@x` as `reset!` may leave it, not as `nil`). `rebound` carries that across
1434
+ # the clauses; the answer is `[body_scope, falsey_scope, rebound]`. A clause whose tests run no code, before
1435
+ # any test rebound the subject, keeps {Narrowing.case_when_scopes}' narrowing unchanged.
1436
+ def subject_when_scopes(subject, conditions, entry, rebound)
1437
+ unless rebound || conditions.any? { |condition| when_test_runs_code?(condition) }
1438
+ return [*Narrowing.case_when_scopes(subject, conditions, entry), false]
1439
+ end
1440
+
1441
+ after = entry
1442
+ test_exits = conditions.map do |condition|
1443
+ before = after
1444
+ after = run_when_test(condition, before)
1445
+ rebound ||= when_test_rebinds_subject?(subject, condition, before, after)
1446
+ after
1447
+ end
1448
+ body_entry = reduce_scopes_with_nil_injection(test_exits)
1449
+ return [Narrowing.apply_when_regex_globals(conditions, body_entry), after, true] if rebound
1450
+
1451
+ [Narrowing.case_when_scopes(subject, conditions, body_entry).first,
1452
+ Narrowing.case_when_scopes(subject, conditions, after).last, false]
1453
+ end
1454
+
1455
+ # Whether a `when` test runs code beyond reading a value: a call, `super`, `yield`, a lambda literal or a write.
1456
+ WHEN_TEST_CODE_NODES = [
1457
+ Prism::CallNode, Prism::SuperNode, Prism::ForwardingSuperNode, Prism::YieldNode, Prism::LambdaNode
1458
+ ].freeze
1459
+ private_constant :WHEN_TEST_CODE_NODES
1460
+
1461
+ def when_test_runs_code?(condition)
1462
+ return true if OperandEffects.any?(condition)
1463
+
1464
+ found = false
1465
+ Source::NodeWalker.each(condition) do |descendant|
1466
+ found ||= WHEN_TEST_CODE_NODES.any? { |klass| descendant.is_a?(klass) }
1467
+ end
1468
+ found
1469
+ end
1470
+
1471
+ # The scope a `when` test leaves. The instance variables its implicit-self calls may write are widened first,
1472
+ # a lambda literal's body included, since its `===` runs it ({InterveningIvarReset.after_operand}, which
1473
+ # marks a binding whose narrowing had ruled `nil` out). The test is then evaluated, which binds its writes
1474
+ # and drops the narrowing of a variable a lambda literal captures and writes ({#eval_lambda}). A guard's
1475
+ # narrowing that a lambda body may rebind is restored as a call's would be, since its `===` runs the body.
1476
+ def run_when_test(condition, before)
1477
+ before = InterveningIvarReset.after_operand(condition, before, run_lambdas: true)
1478
+ _type, after = sub_eval(condition, before)
1479
+ body = when_lambda_body(condition)
1480
+ return after unless body && after.guard_narrowed? && GuardRebinding.may_rebind?(body, after)
1481
+
1482
+ after.forget_guard_narrowings
1483
+ end
1484
+
1485
+ def when_lambda_body(condition)
1486
+ condition = condition.body.body.first while condition.is_a?(Prism::ParenthesesNode) &&
1487
+ condition.body.is_a?(Prism::StatementsNode) &&
1488
+ condition.body.body.size == 1
1489
+ condition.body if condition.is_a?(Prism::LambdaNode)
1490
+ end
1491
+
1492
+ # Whether the `when` test `condition`, which took `before` to `after`, may have rebound the variable `subject`
1493
+ # reads: its binding moved, the test writes it, or — for an instance variable — a call in the test may write
1494
+ # it ({InterveningIvarReset.may_write?}), or — for a global or constant — the test may run code that rebinds
1495
+ # one ({GuardRebinding.may_rebind?}).
1496
+ def when_test_rebinds_subject?(subject, condition, before, after)
1497
+ name = case_subject_name(subject)
1498
+ return false if name.nil?
1499
+ return true unless case_subject_binding(subject, before) == case_subject_binding(subject, after)
1500
+ return true if OperandEffects.written_variables(condition).include?(name)
1501
+
1502
+ case subject
1503
+ when Prism::InstanceVariableReadNode
1504
+ InterveningIvarReset.may_write?(condition, name, before, run_lambdas: true)
1505
+ when Prism::LocalVariableReadNode
1506
+ false
1507
+ else
1508
+ body = when_lambda_body(condition)
1509
+ GuardRebinding.may_rebind?(condition, before) || (!body.nil? && GuardRebinding.may_rebind?(body, before))
1510
+ end
1511
+ end
1512
+
1513
+ def case_subject_name(subject)
1514
+ case subject
1515
+ when Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, Prism::GlobalVariableReadNode
1516
+ subject.name
1517
+ when Prism::ConstantReadNode, Prism::ConstantPathNode
1518
+ Narrowing.constant_key(subject)
1519
+ end
1520
+ end
1521
+
1522
+ def case_subject_binding(subject, scope)
1523
+ case subject
1524
+ when Prism::LocalVariableReadNode then scope.local(subject.name)
1525
+ when Prism::InstanceVariableReadNode then scope.ivar(subject.name)
1526
+ when Prism::GlobalVariableReadNode then scope.global(subject.name)
1527
+ else scope.constant_narrowing(Narrowing.constant_key(subject))
1528
+ end
1529
+ end
1530
+
1531
+ # Issue #1495 — a `case` without a subject tests each `when` condition for truthiness, in order, and stops at the
1532
+ # first truthy one, so a clause is the `||` of its conditions in an `if` / `elsif` chain. Each condition is
1533
+ # evaluated under the falsey edges of the conditions before it, in this clause and every earlier one; the body
1534
+ # joins the conditions' truthy edges, and the next clause starts from the last falsey edge. A condition
1535
+ # `Narrowing.predicate_scopes` does not recognise narrows nothing: a splat (`when *conds`), and a regexp literal,
1536
+ # which is tested for its truthiness and matches nothing, so it binds no match global (#1486).
1537
+ def subjectless_when_scopes(conditions, entry)
1538
+ falsey = entry
1539
+ truthy_edges = conditions.map do |condition|
1540
+ _type, _post, truthy, falsey = eval_with_edges(condition, falsey)
1541
+ truthy
1542
+ end
1543
+ [reduce_scopes_with_nil_injection(truthy_edges), falsey]
1544
+ end
1545
+
1395
1546
  # ADR-47 WD3a — a bare class pattern matches on `C === subject`, i.e. exactly `subject.is_a?(C)` with no
1396
1547
  # deconstruction, so it narrows like `when C`: the body sees the subject narrowed to `C` and the next clause's
1397
1548
  # falsey scope has `C` removed. Other patterns can fail to match even when a class test would pass (deconstruction
1398
1549
  # arity, hash keys, ...), so removing anything from the falsey scope would be unsound — they keep the conservative
1399
1550
  # shape.
1400
1551
  def in_branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1401
- class_node = bare_class_pattern_node(branch.pattern)
1402
- unless class_node
1403
- bound = apply_in_pattern_bindings(subject_type, subject, branch.pattern, falsey_scope)
1404
- return [bound, falsey_scope]
1405
- end
1406
-
1407
- truthy_scope, narrowed_falsey = Narrowing.case_when_scopes(subject, [class_node], falsey_scope)
1552
+ truthy_scope, narrowed_falsey = Narrowing.case_clause_scopes(subject, branch, falsey_scope)
1408
1553
  [apply_in_pattern_bindings(subject_type, subject, branch.pattern, truthy_scope), narrowed_falsey]
1409
1554
  end
1410
1555
 
1411
- # The class-constant node of a `in C` / `in C => x` pattern (the only `in` shapes whose match is pure `is_a?`), or
1412
- # nil for any pattern that deconstructs, binds, or matches a value.
1413
- def bare_class_pattern_node(pattern)
1414
- case pattern
1415
- when Prism::ConstantReadNode, Prism::ConstantPathNode
1416
- pattern
1417
- when Prism::CapturePatternNode
1418
- value = pattern.value
1419
- value if value.is_a?(Prism::ConstantReadNode) || value.is_a?(Prism::ConstantPathNode)
1420
- end
1421
- end
1422
-
1423
1556
  def eval_case_else(else_clause, falsey_scope)
1424
1557
  return sub_eval(else_clause, falsey_scope) if else_clause
1425
1558
 
@@ -1513,9 +1646,32 @@ module Rigor
1513
1646
 
1514
1647
  # The scope past `node`'s `ensure` clause, entered with `exit_scope`, the `next` and `break` scopes recorded past
1515
1648
  # `jump_marks` carried through it.
1649
+ #
1650
+ # Issue #1839 — the code after the `begin` runs only when the clause finished from a `begin` that did, so the
1651
+ # scope it continues from is the clause's under `exit_scope`. A `return`, `next` or `break` in the clause can
1652
+ # also leave from one that raised, after any prefix of the body: those jumps are taken from the pass under the
1653
+ # widened entry ({#widen_clause_entry}), when a jump sink is installed to collect them ({#jump_sinks_installed?}).
1516
1654
  def eval_begin_ensure(node, exit_scope, jump_marks)
1517
- carry_jumps_through_ensure(node.ensure_clause, jump_marks)
1518
- eval_widened_clause(node.ensure_clause, node.ensure_clause.statements, exit_scope, ensure_writes(node)).last
1655
+ clause = node.ensure_clause
1656
+ carry_jumps_through_ensure(clause, jump_marks)
1657
+ jumps = jump_sinks_installed? && clause_jumps?(clause.statements)
1658
+ record_widened = widening_recorded?
1659
+ widened = clause.statements && (jumps || record_widened) &&
1660
+ widen_clause_entry(exit_scope, ensure_writes(node), clause.statements)
1661
+ return sub_eval(clause, exit_scope).last unless widened
1662
+
1663
+ record_operand_clause(clause.statements, widened) if record_widened
1664
+
1665
+ widened_options = record_widened ? {} : UNRECORDED
1666
+ if jumps
1667
+ sub_eval(clause, widened, **widened_options)
1668
+ else
1669
+ without_jump_sinks { sub_eval(clause, widened, next_scope_sink: nil, **widened_options) }
1670
+ end
1671
+ plain_options = record_widened ? UNRECORDED : {}
1672
+ return sub_eval(clause, exit_scope, **plain_options).last unless jumps
1673
+
1674
+ without_jump_sinks { sub_eval(clause, exit_scope, next_scope_sink: nil, **plain_options) }.last
1519
1675
  end
1520
1676
 
1521
1677
  # Issue #1231 — a rescue clause runs after the body raised from any point in it, and an `ensure` clause after the
@@ -1523,22 +1679,27 @@ module Rigor
1523
1679
  # instance variable those wrote at a value the scope it enters with does not hold: `conn = nil; begin; conn =
1524
1680
  # open_conn; Integer(s); rescue; conn.size; end` reads the connection. The engine does not track where the body
1525
1681
  # raises (the precise recorder of #1397 put new false positives on correct code); it widens instead, and
1526
- # deliberately imprecisely. Each name the clause mentions ({#clause_mentions}) that its entry binds and those
1527
- # clauses write, outside a nested `def` or class body, reads as `Dynamic[top]`, which reports nothing. A plain
1528
- # join would read `x` as `1?` in `x = 1; begin; foo; x = nil; rescue; x.succ; end`, where it cannot be nil, and a
1529
- # `Dynamic` that keeps the join as its facet still narrows: `x.nil?` on `Dynamic[1 | 2]` leaves a `nil` member
1530
- # for the next call to report. A name written inside a nested `ensure` clause, which also runs on the way out of
1531
- # a raise, is covered by the same floor.
1682
+ # deliberately imprecisely. Each name that the clause's entry binds and those clauses write, outside a nested
1683
+ # `def` or class body, and that the clause mentions or they write more than once ({ClauseWidening}),
1684
+ # reads as `Dynamic[top]`, which reports nothing. A plain join would read `x` as `1?` in `x
1685
+ # = 1; begin; foo; x = nil; rescue; x.succ; end`, where it cannot be nil, and a `Dynamic` that keeps the join as
1686
+ # its facet still narrows: `x.nil?` on `Dynamic[1 | 2]` leaves a `nil` member for the next call to report. A name
1687
+ # written inside a nested `ensure` clause, which also runs on the way out of a raise, is covered by the same
1688
+ # floor.
1689
+ #
1690
+ # Issue #1839 — the widening is not for the clause's own reads only: a rescue clause's value, the scope it falls
1691
+ # through with and a `return`, `next` or `break` it takes all come from its widened entry, on every path (an
1692
+ # inferred return included), so `got = begin; conn = make; work; rescue; conn; end` leaves `got` untyped rather
1693
+ # than the entry's `nil`, and a name the clause never mentions but the body writes twice joins the code after the
1694
+ # `begin` untyped rather than at the entry's binding, which misses every write the body made before the last.
1532
1695
  #
1533
1696
  # The names the clauses of `node` read from: the primary body's for a rescue clause, none under a retry `edge`.
1534
1697
  def rescue_writes(node, edge)
1535
- clause_writes([node.statements]) if node.rescue_clause && edge.nil? && widening_recorded?
1698
+ clause_writes([node.statements]) if node.rescue_clause && edge.nil?
1536
1699
  end
1537
1700
 
1538
1701
  # For the `ensure` clause: the writes of the body, its `else` and every rescue clause.
1539
1702
  def ensure_writes(node)
1540
- return nil unless widening_recorded?
1541
-
1542
1703
  sources = [node.statements, node.else_clause]
1543
1704
  current = node.rescue_clause
1544
1705
  while current
@@ -1548,83 +1709,68 @@ module Rigor
1548
1709
  clause_writes(sources)
1549
1710
  end
1550
1711
 
1551
- # The local and instance variable names `sources` write, a nested block's included (it may write an outer local),
1552
- # or nil for none. A `def` or class body writes nothing here.
1553
- def clause_writes(sources)
1554
- writes = nil
1555
- sources.each { |source| writes = collect_clause_names(source, CLAUSE_WRITE_NODES, nil, writes) if source }
1556
- writes
1557
- end
1712
+ def clause_writes(sources) = ClauseWidening.writes(sources)
1558
1713
 
1559
- CLAUSE_WRITE_NODES = Set[
1560
- *CapturedLocals::LOCAL_WRITE_NODES,
1561
- Prism::InstanceVariableWriteNode,
1562
- Prism::InstanceVariableOperatorWriteNode,
1563
- Prism::InstanceVariableOrWriteNode,
1564
- Prism::InstanceVariableAndWriteNode,
1565
- Prism::InstanceVariableTargetNode
1566
- ].freeze
1567
- private_constant :CLAUSE_WRITE_NODES
1714
+ CLAUSE_JUMP_NODES = Set[Prism::ReturnNode, Prism::NextNode, Prism::BreakNode].freeze
1715
+ private_constant :CLAUSE_JUMP_NODES
1568
1716
 
1569
- CLAUSE_MENTION_NODES = Set[
1570
- Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, *CLAUSE_WRITE_NODES
1571
- ].freeze
1572
- private_constant :CLAUSE_MENTION_NODES
1717
+ # Whether a `return`, `next` or `break` reached here is collected: a pass {#without_jump_sinks} runs collects
1718
+ # none, so an `ensure` clause inside it, nested at any depth, is evaluated once rather than once per pass of
1719
+ # every enclosing clause holding a jump.
1720
+ def jump_sinks_installed?
1721
+ !@next_scope_sink.nil? || JUMP_SINK_KEYS.any? { |key| Thread.current[key] }
1722
+ end
1573
1723
 
1574
- # The names of the `node_classes` nodes under `node` (restricted to `among` when given) added to `found`, outside
1575
- # a nested `def` or class body.
1576
- def collect_clause_names(node, node_classes, among, found)
1577
- return found if SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
1724
+ # Whether `node` holds a `return`, `next` or `break` outside a nested `def`.
1725
+ def clause_jumps?(node)
1726
+ return false if node.nil? || node.is_a?(Prism::DefNode)
1727
+ return true if CLAUSE_JUMP_NODES.include?(node.class)
1578
1728
 
1579
- if node_classes.include?(node.class) && (among.nil? || among.include?(node.name))
1580
- (found ||= Set.new) << node.name
1581
- end
1582
- node.rigor_each_child { |child| found = collect_clause_names(child, node_classes, among, found) }
1583
- found
1729
+ node.rigor_each_child { |child| return true if clause_jumps?(child) }
1730
+ false
1584
1731
  end
1585
1732
 
1586
- # `clause_scope` with each name of `writes` that `statements` mentions, and `clause_scope` binds, rebound to
1587
- # `Dynamic[top]`, or nil when none changes.
1588
- def widen_clause_entry(clause_scope, statements, writes)
1589
- return nil if writes.nil? || statements.nil? || !widening_recorded?
1733
+ # `clause_scope` entering `clause` with the names of `writes` {ClauseWidening.widen} picks rebound to
1734
+ # `Dynamic[top]`, or nil when none changes. An empty rescue clause is widened too: the scope it falls through
1735
+ # with is its entry.
1736
+ def widen_clause_entry(clause_scope, writes, clause) = ClauseWidening.widen(clause_scope, writes, clause)
1590
1737
 
1591
- names = collect_clause_names(statements, CLAUSE_MENTION_NODES, writes, nil)
1592
- return nil if names.nil?
1738
+ # Issue #1839 — an operand evaluator ({#thread_operand}) records only the later operands its walk takes, so the
1739
+ # index's walk hands a clause it evaluated the scope of the `begin`'s nearest recorded ancestor, which binds
1740
+ # each name the body writes as the `begin` found it: `puts(begin; conn = make; work; rescue; conn.size; end)`
1741
+ # read `conn` as `nil`. On the check path it records the clause's `statements` under the `widened` entry, with
1742
+ # `$!`, `$@` and `$?` unbound as the walk leaves them for a clause the evaluator did not enter; the
1743
+ # walk fills the clause's nodes from there. The clause's later operands are recorded by its own pass. An
1744
+ # evaluator that records its nodes itself skips this: its pass over the clause records `statements` right
1745
+ # after, and the index keeps the last visit, so the `@on_enter` test is a guard no example can fail.
1746
+ def record_operand_clause(statements, widened)
1747
+ return unless statements && widened && @on_enter.nil? && @operand_recorder && widening_recorded?
1593
1748
 
1594
- untyped = Type::Combinator.untyped
1595
- widened = names.reduce(clause_scope) do |acc, name|
1596
- kind = name.start_with?("@") ? :ivar : :local
1597
- current = clause_scope.public_send(VAR_KIND_GETTERS.fetch(kind), name)
1598
- current.nil? || current == untyped ? acc : rebind_variable(acc, kind, name, untyped)
1599
- end
1600
- widened.equal?(clause_scope) ? nil : widened
1749
+ @operand_recorder.call(statements, widened.forget_error_info.forget_last_status)
1601
1750
  end
1602
1751
 
1603
- # Whether a widened pass would record anything: only the check path asks for one ({#initialize}'s
1604
- # `widen_clauses`), and a pass nothing records is skipped, so a nested clause inside an unrecorded pass is
1605
- # evaluated once.
1752
+ # Whether this pass records the per-node index under a clause's widened entry: only the check path asks for that
1753
+ # ({#initialize}'s `widen_clauses`). Every other recorder keeps the clause's plain entry ({#recording?}).
1606
1754
  def widening_recorded?
1607
- @widen_clauses && !(@on_enter.nil? && @operand_recorder.nil?)
1755
+ @widen_clauses && recording?
1608
1756
  end
1609
1757
 
1610
- # `[type, scope]` of a rescue or `ensure` clause entered with `clause_scope`. When {#widen_clause_entry} widens
1611
- # it, the clause is evaluated twice: under the widened entry for the per-node index, which is what the check
1612
- # rules read, and under `clause_scope` without recording, for the value and the scope the code after the
1613
- # `begin` continues from. The widened pass collects no `return`, `next`, `break` or retry-edge scope, so the
1614
- # widening reaches neither an inferred return nor a loop: it can only take diagnostics away inside the clause.
1615
- def eval_widened_clause(node, statements, clause_scope, writes)
1616
- widened = widen_clause_entry(clause_scope, statements, writes)
1617
- return sub_eval(node, clause_scope) unless widened
1618
-
1619
- eval_widened_pass(node, widened)
1620
- sub_eval(node, clause_scope, **UNRECORDED)
1758
+ # Whether this pass records the per-node index at all.
1759
+ def recording?
1760
+ !(@on_enter.nil? && @operand_recorder.nil?)
1621
1761
  end
1622
1762
 
1623
- # The recorded pass over a widened clause: it reaches no `return`, `next`, `break` or retry-frame sink.
1624
- def eval_widened_pass(node, widened)
1625
- return if node.nil?
1763
+ # `[type, scope]` of a rescue clause's `statements` entered with `widened` ({#widen_clause_entry}), the value,
1764
+ # the scope and the jumps the code after the `begin` sees. A recorder that keeps the clause's plain entry
1765
+ # ({#widening_recorded?}) records a second pass under `plain` that reaches no jump sink, so nothing records twice.
1766
+ # The widened pass then records nothing, and a clause nested inside it is evaluated once, so `d` nested clauses
1767
+ # cost `d * (d + 1) / 2` evaluations; every other pass evaluates the clause once.
1768
+ def eval_widened_clause(statements, plain, widened, on_enter:)
1769
+ return [Type::Combinator.constant_of(nil), widened] if statements.nil?
1770
+ return sub_eval(statements, widened, on_enter: on_enter) if widening_recorded? || !recording?
1626
1771
 
1627
- without_jump_sinks { sub_eval(node, widened, next_scope_sink: nil) }
1772
+ without_jump_sinks { sub_eval(statements, plain, next_scope_sink: nil, on_enter: on_enter) }
1773
+ sub_eval(statements, widened, **UNRECORDED)
1628
1774
  end
1629
1775
 
1630
1776
  # Runs `block` with no `return`, `next`, `break` or retry-frame sink installed.
@@ -1735,6 +1881,10 @@ module Rigor
1735
1881
  CLAUSE_SINK_KEYS = [RETURN_SINK_KEY, NEXT_SINK_KEY, BREAK_SINK_KEY, BREAK_VALUE_SINK_KEY, RETRY_FRAMES_KEY].freeze
1736
1882
  private_constant :CLAUSE_SINK_KEYS
1737
1883
 
1884
+ # The thread-local sinks a `return`, `next` or `break` is collected into.
1885
+ JUMP_SINK_KEYS = (CLAUSE_SINK_KEYS - [RETRY_FRAMES_KEY]).freeze
1886
+ private_constant :JUMP_SINK_KEYS
1887
+
1738
1888
  # The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
1739
1889
  # no `retry` targets.
1740
1890
  def retry_edge_for(node)
@@ -2012,7 +2162,7 @@ module Rigor
2012
2162
  # runs on the way out (`begin; retry; ensure; tries += 1; end`), or one made before a `retry` the evaluator only
2013
2163
  # types (`log(tries < 5 ? retry : :gave_up)`).
2014
2164
  #
2015
- # Issue #1231 — `writes` widens the scope each clause enters with, as {#eval_widened_clause} does, before its
2165
+ # Issue #1231 — `writes` widens the scope each clause enters with ({#widen_clause_entry}) before its
2016
2166
  # reference is bound, so `rescue => e` still binds `e` to the exception. It is nil under a retry edge, which
2017
2167
  # already carries every scope the primary body raises from ({#widen_entry_for_retry}).
2018
2168
  def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil)
@@ -2020,11 +2170,12 @@ module Rigor
2020
2170
  results = []
2021
2171
  current = rescue_node
2022
2172
  while current
2023
- widened = widen_clause_entry(entry_scope, current.statements, writes)
2024
- eval_widened_pass(current.statements, bind_rescue_reference(current, widened)) if widened
2173
+ widened = widen_clause_entry(entry_scope, writes, current.statements)
2174
+ record_operand_clause(current.statements, widened)
2025
2175
  rescue_scope = bind_rescue_reference(current, entry_scope)
2026
2176
  arm = if widened
2027
- sub_eval(current.statements, rescue_scope, **UNRECORDED)
2177
+ eval_widened_clause(current.statements, rescue_scope, bind_rescue_reference(current, widened),
2178
+ on_enter: on_enter)
2028
2179
  else
2029
2180
  eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
2030
2181
  end
@@ -2065,18 +2216,29 @@ module Rigor
2065
2216
  after.global(:$_) == Type::Combinator.untyped && !LastLine.may_set?(node.statements, scope)
2066
2217
  end
2067
2218
 
2219
+ # Issue #1889 — {#eval_for} with a literal-name `instance_variable_set` in the body joining its value into the
2220
+ # ivar's binding rather than replacing it ({ReflectiveIvarWrite.in_repeating_body}): a `for` body has no rebind
2221
+ # fixpoint. A `while` / `until` body needs no such join since issue #1930: its fixpoint widens the ivar
2222
+ # ({#loop_body_ivar_writes}).
2223
+ def eval_repeating_for(node) = ReflectiveIvarWrite.in_repeating_body { eval_for(node) }
2224
+
2068
2225
  # `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
2069
2226
  # degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
2070
2227
  # 1), reflecting the common case where no `break VALUE` is observed.
2071
2228
  def eval_loop(node)
2072
- _pred_type, post_pred = sub_eval(node.predicate, loop_entry_scope(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) }
2073
2234
  post_pred = widen_predicate_pins(node, post_pred)
2074
2235
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
2075
2236
 
2076
2237
  # The historical single body pass joined with the pre-loop scope. This continues to carry everything the
2077
2238
  # fixpoint does NOT track: receiver-mutation widening of non-rebound locals (`buf.push(i)` widens `buf`'s
2078
- # Tuple), body-introduced locals' nil-injection, an instance variable's rebind, and the loop value itself. The
2079
- # fixpoint then OVERLAYS only the rebound-local bindings it corrects.
2239
+ # Tuple), body-introduced locals' nil-injection, a rebind of an instance variable the fixpoint does not track
2240
+ # ({#loop_body_ivar_writes}), and the loop value itself. The fixpoint then OVERLAYS only the rebound bindings it
2241
+ # corrects.
2080
2242
  #
2081
2243
  # Like every fixpoint pass, it enters the body on the predicate's loop-entry edge ({#loop_pass_entry}): the
2082
2244
  # body of `while (line = gets)` reads `line` as `String`, and the body of `while gets` reads `$_` as one
@@ -2089,8 +2251,7 @@ module Rigor
2089
2251
  body_scope, first_breaks = single_pass(node, post_pred, jumps)
2090
2252
  base_scope = join_with_nil_injection(post_pred, body_scope)
2091
2253
 
2092
- rebound, body_first = loop_body_local_writes(node.statements, post_pred)
2093
- names = rebound + body_first
2254
+ names, body_first = loop_rebound_names(node.statements, post_pred)
2094
2255
 
2095
2256
  # Fast path: a loop whose body rebinds no local skips the rebind fixpoint, but still needs the slice-C content
2096
2257
  # writeback (a loop may content-mutate a collection without rebinding any local — `acc << x`), so apply it to
@@ -2183,7 +2344,7 @@ module Rigor
2183
2344
  # assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
2184
2345
  # constants. One extra pass from the converged bindings (result discarded) re-records the body's entry scopes.
2185
2346
  record_converged_loop_body(node, post_pred, result, names, body_first, jumps, break_pass)
2186
- post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
2347
+ post_loop = result.reduce(base_scope) { |acc, (name, type)| bind_capture(acc, name, type) }
2187
2348
  # ADR-56 slice C — loop-body receiver-content element-type join. A loop that content-mutates a collection (`acc
2188
2349
  # << n`) keeps only the seed's element types after the single-pass widen; join the appended/stored types into
2189
2350
  # the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
@@ -2251,6 +2412,13 @@ module Rigor
2251
2412
  NO_LOOP_NAMES = [].freeze
2252
2413
  private_constant :NO_LOOP_BINDINGS, :NO_LOOP_NAMES
2253
2414
 
2415
+ # The instance-variable write forms {#loop_body_ivar_writes} counts.
2416
+ IVAR_WRITE_NODES = Set[
2417
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableOrWriteNode,
2418
+ Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableTargetNode
2419
+ ].freeze
2420
+ private_constant :IVAR_WRITE_NODES
2421
+
2254
2422
  # A body with no targeting jump pays two allocation-free scans.
2255
2423
  def loop_jumps(statements)
2256
2424
  kinds = JumpTargets.kinds(statements, LOOP_JUMP_CLASSES)
@@ -2346,11 +2514,11 @@ module Rigor
2346
2514
  floor = Type::Combinator.untyped
2347
2515
  breaks.reduce(continuation) do |cont, break_scope|
2348
2516
  names.reduce(cont) do |acc, name|
2349
- break_value = break_scope.local(name)
2350
- current = acc.local(name)
2517
+ break_value = CapturedLocals.bound_type(break_scope, name)
2518
+ current = CapturedLocals.bound_type(acc, name)
2351
2519
  next acc if break_value.nil? || current == floor
2352
2520
 
2353
- acc.with_local(name, current ? Type::Combinator.union(current, break_value) : break_value)
2521
+ bind_capture(acc, name, current ? Type::Combinator.union(current, break_value) : break_value)
2354
2522
  end
2355
2523
  end
2356
2524
  end
@@ -2423,7 +2591,7 @@ module Rigor
2423
2591
  # treatment. `break_pass` rides along so every pass leaves its `break` arms for {#loop_break_arms}.
2424
2592
  def loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
2425
2593
  nil_const = Type::Combinator.constant_of(nil)
2426
- seed = names.to_h { |name| [name, post_pred.local(name) || nil_const] }
2594
+ seed = names.to_h { |name| [name, CapturedLocals.bound_type(post_pred, name) || nil_const] }
2427
2595
  evaluate_body = lambda do |bindings|
2428
2596
  loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass)
2429
2597
  end
@@ -2455,6 +2623,34 @@ module Rigor
2455
2623
  [pre_existing.uniq, body_first.uniq - pre_existing.uniq]
2456
2624
  end
2457
2625
 
2626
+ # Every name {#eval_loop}'s rebind fixpoint tracks, and the body-first locals among them: the locals the body
2627
+ # rebinds ({#loop_body_local_writes}) and the instance variables ({#loop_body_ivar_writes}).
2628
+ def loop_rebound_names(statements, base_scope)
2629
+ rebound, body_first = loop_body_local_writes(statements, base_scope)
2630
+ [rebound + loop_body_ivar_writes(statements, base_scope) + body_first, body_first]
2631
+ end
2632
+
2633
+ # Issue #1930 — the instance variables the loop body can rebind, which the rebind fixpoint widens as it widens a
2634
+ # local: a write node (`@n = i + 1`, any depth, a nested block's included) or a literal-name
2635
+ # `instance_variable_set` on `self` ({ReflectiveIvarWrite.literal_name}). Only an ivar `base_scope` binds off its
2636
+ # ADR-58 declaration seed counts ({CapturedLocals.rebindable_ivar?}), as for a block's write-back: one the method
2637
+ # has not bound reads `Dynamic[top]` before the loop, and the join drops it after; one on its declaration seed
2638
+ # already holds the union of every write in the class. A call that writes the ivar inside the callee
2639
+ # (`self.bump`) is not a write here; the intervening-call reset handles it.
2640
+ def loop_body_ivar_writes(statements, base_scope)
2641
+ return NO_LOOP_NAMES if base_scope.ivars.empty?
2642
+
2643
+ names = nil
2644
+ Source::NodeWalker.each(statements) do |descendant|
2645
+ name = ReflectiveIvarWrite.literal_name(descendant) ||
2646
+ (descendant.name if IVAR_WRITE_NODES.include?(descendant.class))
2647
+ next if name.nil? || names&.include?(name) || !CapturedLocals.rebindable_ivar?(base_scope, name)
2648
+
2649
+ (names ||= []) << name
2650
+ end
2651
+ names || NO_LOOP_NAMES
2652
+ end
2653
+
2458
2654
  # Evaluates the loop body once with each fixpoint-tracked local bound to the supplied running assumption and
2459
2655
  # returns the per-name exit binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
2460
2656
  #
@@ -2483,14 +2679,14 @@ module Rigor
2483
2679
  break_pass[:entry] = bindings.except(*body_first)
2484
2680
  break_pass[:arms] = breaks
2485
2681
  end
2486
- names.to_h { |name| [name, exit_scope.local(name)] }
2682
+ names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
2487
2683
  end
2488
2684
 
2489
2685
  # The scope one fixpoint pass enters the body with: `post_pred` overlaid with the pre-existing names' running
2490
2686
  # assumption, then narrowed by the predicate's loop-entry edge ({#loop_body_exit_bindings} carries the why).
2491
2687
  def loop_pass_entry(node, post_pred, bindings, body_first, jumps)
2492
2688
  overlaid = bindings.except(*body_first)
2493
- entry = overlaid.reduce(post_pred) { |acc, (name, type)| acc.with_local(name, type) }
2689
+ entry = overlaid.reduce(post_pred) { |acc, (name, type)| bind_capture(acc, name, type) }
2494
2690
  entry = loop_content_entry(node.statements, post_pred, entry)
2495
2691
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
2496
2692
  edge = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
@@ -2856,15 +3052,47 @@ module Rigor
2856
3052
  # u.strip`, where the raise almost always comes from `Float` after it. Neither the arm nor anything in it
2857
3053
  # is recorded or typed from there, which keeps it where it was before #1256: an ADR-5 trade of the rare
2858
3054
  # raise-before-write path for no false positive on the common one.
2859
- arm_entry = ErrorInfo.modifier_entry(join_with_nil_injection(scope, after_expression), node.rescue_expression)
2860
- after_rescue = thread_operand(node.rescue_expression, arm_entry, OperandWalk.new(nil), arm_entry)
2861
- type = OperandWalk.type_of(scope, node, tracer, walk.types(tracer))
3055
+ #
3056
+ # Issue #1839 — the arm enters with each name `expr` writes that it mentions, or that `expr` writes more
3057
+ # than once, read as `Dynamic[top]` ({#widen_clause_entry}), as a `begin`'s rescue clause does: the raise
3058
+ # may come after any of those writes. The scope past the modifier joins that entry through the arm.
3059
+ fallback = node.rescue_expression
3060
+ writes = clause_writes([node.expression])
3061
+ record_modifier_fallback(fallback, writes, walk)
3062
+ joined = join_with_nil_injection(scope, after_expression)
3063
+ widened = widen_clause_entry(joined, writes, fallback)
3064
+ arm_entry = ErrorInfo.modifier_entry(widened || joined, fallback)
3065
+ after_rescue = thread_operand(fallback, arm_entry, OperandWalk.new(nil), arm_entry)
2862
3066
  after = if branch_unconditionally_exits?(node.rescue_expression)
2863
3067
  after_expression
2864
3068
  else
2865
3069
  join_with_nil_injection(after_expression, after_rescue)
2866
3070
  end
2867
- [type, after]
3071
+ [rescue_modifier_type(node, walk, widened && arm_entry), after]
3072
+ end
3073
+
3074
+ # Issue #1839 — the walk records nothing in the fallback, so the index's walk hands it the modifier's own entry,
3075
+ # which binds each name `expr` writes as the modifier found it: `conn = nil; (conn = make; work) rescue
3076
+ # conn.size` read `conn` as `nil`. The fallback is recorded under that entry with the names `writes`
3077
+ # {ClauseWidening.widen} picks read as `Dynamic[top]`, and `$!`, `$@` and `$?` unbound as the walk leaves them,
3078
+ # when the widening changes it and the check path asks for it ({#widening_recorded?}). A name `expr` first
3079
+ # binds keeps the entry's reading, for the trade above.
3080
+ def record_modifier_fallback(fallback, writes, walk)
3081
+ return unless walk.recorder && @widen_clauses
3082
+
3083
+ widened = widen_clause_entry(scope, writes, fallback)
3084
+ walk.recorder.call(fallback, widened.forget_error_info.forget_last_status) if widened
3085
+ end
3086
+
3087
+ # Issue #1839 — the value of `expr rescue alt`: the fallback is typed from `widened_entry`, its entry with each
3088
+ # name `expr` writes read as `Dynamic[top]` ({#widen_clause_entry}), as a `begin`'s rescue clause reads it, not
3089
+ # at the entry's binding: `got = (conn = make; work) rescue conn` leaves `got` untyped, not `nil`. Nil when the
3090
+ # widening changes nothing, and the modifier is typed whole.
3091
+ def rescue_modifier_type(node, walk, widened_entry)
3092
+ return OperandWalk.type_of(scope, node, tracer, walk.types(tracer)) unless widened_entry
3093
+
3094
+ Type::Combinator.union(OperandWalk.type_of(scope, node.expression, tracer, walk.types(tracer)),
3095
+ widened_entry.type_of(node.rescue_expression, tracer: tracer))
2868
3096
  end
2869
3097
 
2870
3098
  # `class Foo; body; end` and `module Foo; body; end`. The class body runs in a fresh scope (Ruby's class scope
@@ -2929,6 +3157,7 @@ module Rigor
2929
3157
  # evaluates `def` to the method's name as a Symbol, so the produced type is `Constant[:name]`.
2930
3158
  def eval_def(node)
2931
3159
  body_scope = build_method_entry_scope(node)
3160
+ body_scope = body_scope.with_repeated_or_writes(RepeatedOrWrites.straight_line_sites(node.body, body_scope))
2932
3161
  # Parameter default value expressions (e.g. `self.x` in `def copy(x: self.x)`) execute when the method is
2933
3162
  # *invoked*, not when the `def` is read; their `self` is the instance receiver, not the surrounding class body.
2934
3163
  # Walk the parameters subtree under `body_scope` so the scope-index records the instance `self_type` for every
@@ -3119,7 +3348,7 @@ module Rigor
3119
3348
  # map …) that rebinds an outer local must not leave that local's pre-call binding unmodified in the continuation
3120
3349
  # scope; the spec MUST in § "Fact stability and mutation" names captured locals a first-class invalidation
3121
3350
  # category. (The escaping / unknown path already widened to Dynamic[top] via `record_closure_escape_if_any`.)
3122
- post_scope = write_back_block_captures(node, post_scope)
3351
+ post_scope = block_capture_continuation(node, post_scope)
3123
3352
  statement_call = !call_type.nil? && !@in_operand
3124
3353
  post_scope = apply_post_return_narrowing(node, post_scope) if statement_call
3125
3354
  # Flow-folding G1 / G2 — widen a local- or instance-variable binding when the call is an in-place mutator on it
@@ -3170,6 +3399,9 @@ module Rigor
3170
3399
  # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
3171
3400
  # [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
3172
3401
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
3402
+ # Issue #1889 — `instance_variable_set` on `self` writes an ivar no write node shows. It runs after the reset
3403
+ # above, which would join a literal-name write back into the class seed.
3404
+ post_scope = ReflectiveIvarWrite.apply(node, post_scope) { type_operand(ReflectiveIvarWrite.value_node(node)) }
3173
3405
  # C1 — regex match-data globals (`$~`, `$1..$N`, `$&`, …) are narrowed to non-nil on a successful-match edge; a
3174
3406
  # later call that itself runs a regex match rebinds them, so the narrowed facts must be dropped. We forget them
3175
3407
  # only when the call may run a match in this frame ({#rebinds_match_globals?}). A call provably match-free on a
@@ -3191,7 +3423,13 @@ module Rigor
3191
3423
  post_scope = post_scope.forget_match_globals if rebinds_match_globals?(node, post_scope)
3192
3424
  post_scope = post_scope.forget_last_line if rebinds_last_line?(node, post_scope)
3193
3425
  if post_scope.guard_narrowed? && GuardRebinding.call_may_rebind?(node, operand_scope)
3194
- post_scope = post_scope.forget_guard_narrowings
3426
+ # Issue #1596 — a self-call proven to write only some instance variables restores only theirs.
3427
+ writes = intervening_call_candidate?(node) ? self_call_ivar_writes(node) : nil
3428
+ post_scope = if writes
3429
+ writes.reduce(post_scope) { |acc, name| acc.forget_guard_narrowing_of(:ivar, name) }
3430
+ else
3431
+ post_scope.forget_guard_narrowings
3432
+ end
3195
3433
  end
3196
3434
  forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
3197
3435
  end
@@ -3316,30 +3554,35 @@ module Rigor
3316
3554
  # Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
3317
3555
  # is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
3318
3556
  # External- receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged.
3557
+ # Issue #1596 — where the call is proven to write only some ivars ({#self_call_ivar_writes}), only those widen.
3558
+ #
3559
+ # Issue #1402 — a proc's call (`blk.call`) is a candidate too, and so is a call on another receiver whose literal
3560
+ # block makes one ({InterveningIvarReset.after_block}: `[1].each { reset }`). Both widen as a condition's
3561
+ # calls do, marking a binding whose narrowing had ruled `nil` out, since the code they run is far more often
3562
+ # code that leaves the variable alone. A `yield` or proc call in the call's receiver or arguments
3563
+ # (`@items << yield`, `puts(yield)`) widens every narrowed ivar ({InterveningIvarReset.after_operands}).
3319
3564
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
3320
- return current_scope unless intervening_call_candidate?(call_node)
3321
-
3322
- class_name = enclosing_class_name_for(current_scope.self_type)
3323
- return current_scope if class_name.nil?
3324
-
3325
- seed = current_scope.class_ivars_for(class_name)
3326
- return current_scope if seed.empty?
3565
+ widened =
3566
+ if intervening_call_candidate?(call_node)
3567
+ mark = !InterveningIvarReset.self_call?(call_node)
3568
+ InterveningIvarReset.widen(current_scope, mark: mark) { self_call_ivar_writes(call_node) }
3569
+ else
3570
+ InterveningIvarReset.after_block(call_node, current_scope)
3571
+ end
3572
+ InterveningIvarReset.after_operands(call_node, widened)
3573
+ end
3327
3574
 
3328
- widened = current_scope
3329
- seed.each do |ivar_name, seed_type|
3330
- local_type = current_scope.ivar(ivar_name)
3331
- next if local_type.nil? || local_type == seed_type
3575
+ # Issue #1596 — the ivars the implicit-self / `self.` call `call_node` may write, or nil when that is not proven
3576
+ # ({SelfCallIvarWrites.of}). Memoised per call node: the reset and the guard restore ask it of the same call.
3577
+ def self_call_ivar_writes(call_node)
3578
+ memo = (@self_call_ivar_writes ||= {}.compare_by_identity)
3579
+ return memo[call_node] if memo.key?(call_node)
3332
3580
 
3333
- widened = widened.with_ivar(ivar_name, Type::Combinator.union(local_type, seed_type))
3334
- end
3335
- widened
3581
+ memo[call_node] = SelfCallIvarWrites.of(call_node, operand_scope)
3336
3582
  end
3337
3583
 
3338
3584
  def intervening_call_candidate?(call_node)
3339
- return false unless call_node.is_a?(Prism::CallNode)
3340
-
3341
- receiver = call_node.receiver
3342
- receiver.nil? || receiver.is_a?(Prism::SelfNode)
3585
+ InterveningIvarReset.candidate?(call_node)
3343
3586
  end
3344
3587
 
3345
3588
  def enclosing_class_name_for(self_type)
@@ -3839,7 +4082,7 @@ module Rigor
3839
4082
  classification = classify_closure_escape(node)
3840
4083
  return post_scope if classification == :non_escaping
3841
4084
 
3842
- post_scope = escaping_closure_captures(node.block, post_scope)
4085
+ post_scope = escaping_closure_captures(node.block, post_scope, foreign_self: foreign_self_block?(node))
3843
4086
  post_scope.with_fact(
3844
4087
  Analysis::FactStore::Fact.new(
3845
4088
  bucket: :dynamic_origin,
@@ -4143,13 +4386,72 @@ module Rigor
4143
4386
  # callback that runs later writes the very ivar the continuation reads (`@clicked = false; button.on_click {
4144
4387
  # @clicked = true }` left `if @clicked` folding always-falsey). One still on its ADR-58 declaration seed is
4145
4388
  # left alone ({CapturedLocals.writes}): that seed already holds whatever the callback stores.
4146
- def drop_captured_narrowing(block_node, base_scope)
4147
- names = CapturedLocals.writes(block_node, base_scope, ivars: true)
4389
+ def drop_captured_narrowing(block_node, base_scope, foreign_self: false)
4390
+ names = CapturedLocals.writes(block_node, base_scope, ivars: true, foreign_self: foreign_self)
4148
4391
  return base_scope if names.empty?
4149
4392
 
4150
4393
  names.reduce(base_scope) { |acc, name| bind_capture(acc, name, Type::Combinator.untyped) }
4151
4394
  end
4152
4395
 
4396
+ # The continuation {#write_back_block_captures} binds, after which the block's later-run `return`s are collected
4397
+ # ({#collect_repeated_block_returns}).
4398
+ def block_capture_continuation(call_node, post_scope)
4399
+ post_scope = write_back_block_captures(call_node, post_scope)
4400
+ collect_repeated_block_returns(call_node, post_scope)
4401
+ post_scope
4402
+ end
4403
+
4404
+ # Issue #2123 — the `return`s a repeating block reaches only on a later run. Every statement pass over a block
4405
+ # enters with a captured local or ivar the body rebinds at a binding some run can hold, but not at every such
4406
+ # binding: the statement pass enters at the call-site binding, and the ADR-56 write-back's capped fixpoint widens
4407
+ # its last assumption without running a pass from it. So
4408
+ # `seen = 0; items.each { return false if (seen += 1) > 3 }; true` read `seen` as at most `3` on every pass, the
4409
+ # `return` was never reached, the method inferred `true`, and a caller's `if` on it folded always-falsey on
4410
+ # correct code.
4411
+ #
4412
+ # While a method's return sink is active, a block that may repeat ({#repeating_block_class}), rebinds a capture,
4413
+ # and holds a `return` that exits the method ({ReturnBarrier.exits_method?}) gets one more unrecorded pass, with
4414
+ # each rebound name bound to its pre-call binding joined with its continuation `post_scope` holds. The
4415
+ # continuation covers every run count, so it covers the binding any run enters with. The pass records no
4416
+ # scope, and the method's fall-through and earlier `return`s are kept, so the inferred return type only widens.
4417
+ # The fresh `next`, `break`-value and `break`-scope sinks are defensive: every consumer of those sinks filters by
4418
+ # jump-node identity, so no example tells them apart from the outer sinks today. So is the `rescue`: a raise here
4419
+ # keeps the arms the earlier passes collected rather than taking out the method.
4420
+ def collect_repeated_block_returns(call_node, post_scope)
4421
+ return if Thread.current[RETURN_SINK_KEY].nil?
4422
+
4423
+ block = call_node.block
4424
+ return unless repeated_block_return_candidate?(call_node, block)
4425
+
4426
+ names = CapturedLocals.writes(block, scope, ivars: true, foreign_self: foreign_self_block?(call_node))
4427
+ return if names.empty?
4428
+
4429
+ bindings = names.to_h do |name|
4430
+ [name, Type::Combinator.union(*[scope, post_scope].filter_map { |s| CapturedLocals.bound_type(s, name) })]
4431
+ end
4432
+ entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, bindings))
4433
+ StatementEvaluator.with_next_sink do
4434
+ StatementEvaluator.with_break_value_sink do
4435
+ collect_break_scopes { sub_eval(block, entry, **UNRECORDED, next_scope_sink: nil) }
4436
+ end
4437
+ end
4438
+ rescue StandardError
4439
+ nil
4440
+ end
4441
+
4442
+ # 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
4445
+ # anonymous-class checks are defensive guards: no such call repeats under that rule today.
4446
+ def repeated_block_return_candidate?(call_node, block)
4447
+ return false unless block.is_a?(Prism::BlockNode)
4448
+ return false unless ReturnBarrier.exits_method?(block.body)
4449
+ return false if return_barrier_block?(call_node) || refined_class_context(call_node)
4450
+ return false if AnonymousMetaClass.name_for(call_node, scope.source_path)
4451
+
4452
+ BlockCallTiming.kernel_loop?(call_node) || !repeating_block_class(call_node).nil?
4453
+ end
4454
+
4153
4455
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
4154
4456
  # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
4155
4457
  # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
@@ -4174,13 +4476,27 @@ module Rigor
4174
4476
  return post_scope unless block.is_a?(Prism::BlockNode)
4175
4477
  return post_scope unless classify_closure_escape(call_node) == :non_escaping
4176
4478
 
4177
- names = CapturedLocals.writes(block, scope, ivars: true)
4479
+ names = CapturedLocals.writes(block, scope, ivars: true, foreign_self: foreign_self_block?(call_node))
4178
4480
  return post_scope if names.empty?
4179
4481
 
4180
4482
  break_pass = block_break_pass(block)
4181
- result = converge_captures_by_kind(call_node, block, names, break_pass)
4483
+ marks = {}
4484
+ result = with_capture_exit_marks(marks) { converge_captures_by_kind(call_node, block, names, break_pass) }
4182
4485
  result = join_block_break_bindings(call_node, block, result, break_pass)
4183
- result.reduce(post_scope) { |acc, (name, type)| bind_capture(acc, name, type) }
4486
+ result.reduce(post_scope) { |acc, (name, type)| bind_capture(acc, name, type, optimistic: marks[name]) }
4487
+ end
4488
+
4489
+ # Issue #1402 — runs the write-back's passes with `marks` collecting the ADR-101 optimistic mark a pass's exit
4490
+ # holds for each rebound name ({#block_exit_bindings}), as the per-element fold collects them
4491
+ # (`ExpressionTyper#captured_exit_bindings`). A later pass enters on that mark, and the continuation binds it:
4492
+ # `x = false; pairs.each { x = pairs.first&.empty? }` stored the miss's `nil` at run time, and without the mark
4493
+ # `x.nil?` folded to `false` where the inline `pairs.first&.empty?.nil?` answers `bool`.
4494
+ def with_capture_exit_marks(marks)
4495
+ outer = @capture_exit_marks
4496
+ @capture_exit_marks = marks
4497
+ yield
4498
+ ensure
4499
+ @capture_exit_marks = outer
4184
4500
  end
4185
4501
 
4186
4502
  # The {BodyFixpoint} continuation of `names`, seeded from their pre-call bindings.
@@ -4239,7 +4555,12 @@ module Rigor
4239
4555
  # Binds a name from `CapturedLocals.writes`. An ivar goes through {CapturedLocals.bind}, which keeps its issue
4240
4556
  # #286 optimistic mark. A local keeps the plain `with_local` these seams have always used, so a block that
4241
4557
  # rebinds no ivar leaves the locals exactly as before.
4242
- def bind_capture(scope, name, type)
4558
+ #
4559
+ # Issue #1402 — `optimistic` is a mark a write-back pass's exit held ({#with_capture_exit_marks}); a name that has
4560
+ # one binds through {CapturedLocals.bind} whatever its kind, which adds it.
4561
+ def bind_capture(scope, name, type, optimistic: nil)
4562
+ return CapturedLocals.bind(scope, name, type, optimistic: optimistic) if optimistic
4563
+
4243
4564
  CapturedLocals.ivar_name?(name) ? CapturedLocals.bind(scope, name, type) : scope.with_local(name, type)
4244
4565
  end
4245
4566
 
@@ -5103,9 +5424,22 @@ module Rigor
5103
5424
  def block_exit_bindings(call_node, block, bindings, names)
5104
5425
  entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, bindings))
5105
5426
  _type, exit_scope = sub_eval(block, entry)
5427
+ record_capture_exit_marks(exit_scope, names)
5106
5428
  names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
5107
5429
  end
5108
5430
 
5431
+ # Issue #1402 — records into {#with_capture_exit_marks}' table the mark `exit_scope` holds for each of `names`;
5432
+ # the first mark a name takes stays.
5433
+ def record_capture_exit_marks(exit_scope, names)
5434
+ marks = @capture_exit_marks
5435
+ return if marks.nil?
5436
+
5437
+ names.each do |name|
5438
+ mark = CapturedLocals.optimistic_mark(exit_scope, name)
5439
+ marks[name] ||= mark if mark
5440
+ end
5441
+ end
5442
+
5109
5443
  # The entry scope of one write-back pass: the block's entry with each written outer local or ivar bound to
5110
5444
  # `bindings`, and each captured local the body only mutates in place at its widening ({#capture_pass_bindings}).
5111
5445
  # That widening describes the same object, so it keeps the marks a source-level write drops (issue #1287,
@@ -5114,7 +5448,9 @@ module Rigor
5114
5448
  def block_pass_entry(call_node, block, bindings)
5115
5449
  entry = build_block_entry_scope(call_node, block)
5116
5450
  capture_pass_bindings(block, bindings).reduce(entry) do |acc, (name, type)|
5117
- bindings.key?(name) ? bind_capture(acc, name, type) : acc.with_mutated_local(name, type)
5451
+ next acc.with_mutated_local(name, type) unless bindings.key?(name)
5452
+
5453
+ bind_capture(acc, name, type, optimistic: @capture_exit_marks&.[](name))
5118
5454
  end
5119
5455
  end
5120
5456
 
@@ -5218,7 +5554,7 @@ module Rigor
5218
5554
  def unproven_rebind_bindings(call_node, block, classification)
5219
5555
  return NO_CAPTURE_BINDINGS if call_node.receiver && classification == :non_escaping
5220
5556
 
5221
- names = CapturedLocals.writes(block, scope, ivars: true)
5557
+ names = CapturedLocals.writes(block, scope, ivars: true, foreign_self: foreign_self_block?(call_node))
5222
5558
  return NO_CAPTURE_BINDINGS if names.empty?
5223
5559
 
5224
5560
  untyped = Type::Combinator.untyped
@@ -5333,8 +5669,16 @@ module Rigor
5333
5669
  # The continuation effects of an escaping closure body: the outer locals it can rebind drop their narrowing, and
5334
5670
  # the ones it content-mutates are floored. Shared by the two spellings — a block on an escaping call
5335
5671
  # ({#record_closure_escape_if_any}) and a lambda literal ({#eval_lambda}) — so they cannot drift.
5336
- def escaping_closure_captures(closure_node, post_scope)
5337
- widen_escaping_content_captures(closure_node, drop_captured_narrowing(closure_node, post_scope))
5672
+ def escaping_closure_captures(closure_node, post_scope, foreign_self: false)
5673
+ widen_escaping_content_captures(closure_node,
5674
+ drop_captured_narrowing(closure_node, post_scope, foreign_self: foreign_self))
5675
+ end
5676
+
5677
+ # Issue #1402 — whether `call_node`'s block runs on an object that is provably not `self`
5678
+ # ({InterveningIvarReset.foreign_self_block?}, its receiver typed where the operands were), so that no instance
5679
+ # variable it writes is this scope's ({CapturedLocals.writes}' `foreign_self:`).
5680
+ def foreign_self_block?(call_node)
5681
+ InterveningIvarReset.foreign_self_block?(call_node, operand_scope)
5338
5682
  end
5339
5683
 
5340
5684
  # Builds the entry scope for a block body. The block sees the outer scope's locals (Ruby's lexical scoping rule)
@@ -5412,7 +5756,7 @@ module Rigor
5412
5756
  return [] if receiver_type.nil?
5413
5757
 
5414
5758
  arg_types = call_arg_types_for(call_node)
5415
- MethodDispatcher.expected_block_param_types(
5759
+ expected = MethodDispatcher.expected_block_param_types(
5416
5760
  receiver_type: receiver_type,
5417
5761
  method_name: call_node.name,
5418
5762
  arg_types: arg_types,
@@ -5420,10 +5764,28 @@ module Rigor
5420
5764
  scope: scope,
5421
5765
  call_node: call_node
5422
5766
  )
5767
+ with_inject_accumulator(call_node, receiver_type, arg_types, expected)
5423
5768
  rescue StandardError
5424
5769
  []
5425
5770
  end
5426
5771
 
5772
+ # Issue #1889 — a block-form `inject` / `reduce` call's accumulator parameter at the type it holds across
5773
+ # every run ({ExpressionTyper#inject_accumulator_type}) rather than the seed the signature binds it to, once
5774
+ # per call: every write-back pass builds the entry again.
5775
+ def with_inject_accumulator(call_node, receiver_type, arg_types, expected)
5776
+ memo = @inject_accumulator
5777
+ accumulator =
5778
+ if memo && memo[0].equal?(call_node)
5779
+ memo[1]
5780
+ else
5781
+ typed = ExpressionTyper.new(scope: scope, tracer: tracer)
5782
+ .inject_accumulator_type(call_node, receiver_type, arg_types)
5783
+ @inject_accumulator = [call_node, typed]
5784
+ typed
5785
+ end
5786
+ accumulator ? [accumulator, *expected.drop(1)] : expected
5787
+ end
5788
+
5427
5789
  def call_arg_types_for(call_node)
5428
5790
  arguments = call_node.arguments
5429
5791
  return [] if arguments.nil?