rigortype 0.4.4 → 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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +5 -5
  3. data/docs/handbook/11-sig-gen.md +90 -17
  4. data/docs/manual/02-cli-reference.md +39 -9
  5. data/docs/manual/03-configuration.md +10 -8
  6. data/docs/manual/04-diagnostics.md +16 -5
  7. data/docs/manual/05-inspecting-types.md +2 -2
  8. data/docs/manual/06-baseline.md +1 -1
  9. data/docs/manual/07-plugins.md +2 -1
  10. data/docs/manual/08-skills.md +7 -0
  11. data/docs/manual/10-mcp-server.md +8 -1
  12. data/docs/manual/18-removing-dead-code.md +18 -7
  13. data/docs/manual/plugins/rigor-rspec.md +101 -1
  14. data/lib/rigor/analysis/check_rules/clause_coverage.rb +134 -0
  15. data/lib/rigor/analysis/check_rules/lexical_method_sites.rb +111 -7
  16. data/lib/rigor/analysis/check_rules/rbs_receiver_surface.rb +190 -0
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +3 -1
  18. data/lib/rigor/analysis/check_rules/source_arity.rb +32 -3
  19. data/lib/rigor/analysis/check_rules/unreachable_clause_collector.rb +13 -4
  20. data/lib/rigor/analysis/check_rules.rb +228 -15
  21. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -1
  22. data/lib/rigor/analysis/incremental.rb +49 -0
  23. data/lib/rigor/analysis/incremental_run_slot/write_guard.rb +36 -18
  24. data/lib/rigor/analysis/incremental_session.rb +166 -24
  25. data/lib/rigor/analysis/path_expansion.rb +3 -1
  26. data/lib/rigor/analysis/reachability/graph.rb +90 -13
  27. data/lib/rigor/analysis/reachability/scan.rb +279 -39
  28. data/lib/rigor/analysis/rule_catalog.rb +17 -4
  29. data/lib/rigor/analysis/runner/pool_coordinator.rb +69 -16
  30. data/lib/rigor/analysis/runner.rb +69 -65
  31. data/lib/rigor/analysis/severity_stamp.rb +11 -0
  32. data/lib/rigor/analysis/worker_session.rb +12 -1
  33. data/lib/rigor/cache/descriptor.rb +40 -7
  34. data/lib/rigor/cache/engine_source.rb +3 -2
  35. data/lib/rigor/cache/incremental_snapshot.rb +26 -8
  36. data/lib/rigor/cache/rbs_descriptor.rb +2 -1
  37. data/lib/rigor/cache/store.rb +4 -3
  38. data/lib/rigor/cli/command.rb +3 -1
  39. data/lib/rigor/cli/docs_command.rb +3 -2
  40. data/lib/rigor/cli/doctor_command.rb +1 -1
  41. data/lib/rigor/cli/explain_command.rb +3 -2
  42. data/lib/rigor/cli/plugin_command.rb +2 -1
  43. data/lib/rigor/cli/plugins_command.rb +2 -1
  44. data/lib/rigor/cli/sig_gen_command.rb +32 -11
  45. data/lib/rigor/cli/skill_command.rb +2 -1
  46. data/lib/rigor/cli/skill_describe.rb +3 -2
  47. data/lib/rigor/cli/unused_command.rb +10 -7
  48. data/lib/rigor/cli.rb +3 -2
  49. data/lib/rigor/config_audit.rb +60 -15
  50. data/lib/rigor/configuration/severity_profile.rb +20 -2
  51. data/lib/rigor/configuration.rb +29 -3
  52. data/lib/rigor/effects/signature_sources.rb +3 -1
  53. data/lib/rigor/environment/bundle_sig_discovery.rb +3 -2
  54. data/lib/rigor/environment/gated_signature_guard.rb +2 -1
  55. data/lib/rigor/environment/gated_signature_plan.rb +2 -1
  56. data/lib/rigor/environment/installed_gem_set.rb +2 -1
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +4 -3
  58. data/lib/rigor/environment/rbs_loader.rb +21 -1
  59. data/lib/rigor/inference/block_call_timing.rb +7 -1
  60. data/lib/rigor/inference/builtins/method_catalog.rb +2 -1
  61. data/lib/rigor/inference/captured_locals.rb +44 -13
  62. data/lib/rigor/inference/clause_widening.rb +90 -0
  63. data/lib/rigor/inference/definer_resolution.rb +87 -20
  64. data/lib/rigor/inference/expression_typer.rb +35 -5
  65. data/lib/rigor/inference/intervening_ivar_reset.rb +303 -0
  66. data/lib/rigor/inference/macro_block_self_type.rb +20 -6
  67. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +170 -35
  68. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +50 -2
  69. data/lib/rigor/inference/method_dispatcher.rb +67 -3
  70. data/lib/rigor/inference/module_function_state.rb +137 -42
  71. data/lib/rigor/inference/narrowing.rb +54 -10
  72. data/lib/rigor/inference/parameter_inference_collector.rb +4 -26
  73. data/lib/rigor/inference/project_patched_methods.rb +168 -4
  74. data/lib/rigor/inference/project_patched_mixins.rb +162 -0
  75. data/lib/rigor/inference/project_patched_scanner.rb +51 -12
  76. data/lib/rigor/inference/project_patched_visibility.rb +214 -0
  77. data/lib/rigor/inference/project_seed_tables.rb +120 -0
  78. data/lib/rigor/inference/refined_dispatch/constructed_class.rb +113 -0
  79. data/lib/rigor/inference/refined_dispatch/own_method.rb +41 -12
  80. data/lib/rigor/inference/reflective_ivar_write.rb +6 -6
  81. data/lib/rigor/inference/return_barrier.rb +16 -0
  82. data/lib/rigor/inference/scope_indexer.rb +675 -105
  83. data/lib/rigor/inference/self_call_ivar_writes.rb +11 -6
  84. data/lib/rigor/inference/singleton_external_ahead.rb +64 -0
  85. data/lib/rigor/inference/singleton_hook_decline.rb +7 -0
  86. data/lib/rigor/inference/statement_evaluator.rb +446 -139
  87. data/lib/rigor/inference/unthreaded_rebinds.rb +3 -1
  88. data/lib/rigor/lens/contract_reader.rb +45 -2
  89. data/lib/rigor/lens/document.rb +2 -1
  90. data/lib/rigor/lens/file_reader.rb +12 -2
  91. data/lib/rigor/lens/members.rb +16 -2
  92. data/lib/rigor/lens/run.rb +34 -7
  93. data/lib/rigor/mcp/server.rb +2 -2
  94. data/lib/rigor/plugin/base.rb +3 -2
  95. data/lib/rigor/plugin/io_boundary.rb +42 -6
  96. data/lib/rigor/plugin/isolation.rb +4 -3
  97. data/lib/rigor/protection/discovery_seed.rb +9 -36
  98. data/lib/rigor/protection/mutation_cache.rb +8 -2
  99. data/lib/rigor/rooted_glob.rb +27 -0
  100. data/lib/rigor/scope/discovery_index.rb +5 -0
  101. data/lib/rigor/scope/resolution_chain/relevance.rb +14 -0
  102. data/lib/rigor/scope/resolution_chain.rb +40 -5
  103. data/lib/rigor/scope.rb +45 -2
  104. data/lib/rigor/sig_gen/alias_index.rb +2 -1
  105. data/lib/rigor/sig_gen/classification.rb +4 -1
  106. data/lib/rigor/sig_gen/generator.rb +167 -22
  107. data/lib/rigor/sig_gen/layout_index.rb +5 -2
  108. data/lib/rigor/sig_gen/observation_collector.rb +137 -11
  109. data/lib/rigor/sig_gen/renderer.rb +13 -155
  110. data/lib/rigor/sig_gen/skip_reason_catalog.rb +11 -0
  111. data/lib/rigor/sig_gen/write_reporter.rb +203 -0
  112. data/lib/rigor/sig_gen/write_result.rb +3 -1
  113. data/lib/rigor/sig_gen/writer.rb +5 -3
  114. data/lib/rigor/signature_path_audit.rb +4 -2
  115. data/lib/rigor/type/constant.rb +10 -1
  116. data/lib/rigor/version.rb +1 -1
  117. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  118. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +1 -1
  119. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/controller_discoverer.rb +1 -1
  120. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/render_locals.rb +6 -1
  121. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/view_assigns.rb +1 -1
  122. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +1 -1
  123. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/effects.rb +4 -3
  124. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_discoverer.rb +1 -1
  125. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +1 -1
  126. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/attachment_discoverer.rb +1 -1
  127. data/plugins/rigor-alba/lib/rigor/plugin/alba/resource_discoverer.rb +1 -1
  128. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +1 -1
  129. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +1 -1
  130. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +38 -9
  131. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/target_detector.rb +1 -1
  132. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/authorization_scan.rb +1 -1
  133. data/plugins/rigor-pundit/lib/rigor/plugin/pundit/policy_discoverer.rb +1 -1
  134. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_index.rb +7 -2
  135. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +5 -5
  136. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +18 -17
  137. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +1 -1
  138. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_discoverer.rb +1 -1
  139. data/plugins/rigor-sinatra/lib/rigor/plugin/sinatra.rb +4 -2
  140. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +2 -2
  141. data/plugins/rigor-typelizer/lib/rigor/plugin/typelizer/serializer_discoverer.rb +2 -2
  142. data/sig/rigor/plugin/io_boundary.rbs +5 -2
  143. data/sig/rigor/rooted_glob.rbs +7 -0
  144. data/sig/rigor/scope.rbs +4 -3
  145. data/skills/rigor-doctor/references/01-checks.md +23 -11
  146. data/skills/rigor-type-oracle/references/01-oracle-commands.md +2 -1
  147. metadata +13 -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"
@@ -28,7 +29,9 @@ require_relative "define_method_block_self"
28
29
  require_relative "macro_block_self_type"
29
30
  require_relative "guard_rebinding"
30
31
  require_relative "self_call_ivar_writes"
32
+ require_relative "intervening_ivar_reset"
31
33
  require_relative "match_rebinding"
34
+ require_relative "clause_widening"
32
35
  require_relative "element_read_widening"
33
36
  require_relative "hash_lookup_mutation"
34
37
  require_relative "indexed_narrowing"
@@ -124,8 +127,8 @@ module Rigor
124
127
  Prism::BeginNode => :eval_begin_node,
125
128
  Prism::RescueNode => :eval_rescue,
126
129
  Prism::EnsureNode => :eval_ensure,
127
- Prism::WhileNode => :eval_repeating_loop,
128
- Prism::UntilNode => :eval_repeating_loop,
130
+ Prism::WhileNode => :eval_loop,
131
+ Prism::UntilNode => :eval_loop,
129
132
  Prism::ForNode => :eval_repeating_for,
130
133
  Prism::AndNode => :eval_and_or,
131
134
  Prism::OrNode => :eval_and_or,
@@ -365,7 +368,18 @@ module Rigor
365
368
  # Default: the node is treated as a pure expression. Type it through the existing expression typer (which
366
369
  # observes the current scope's locals) and leave the scope unchanged, but for the match globals a call in it
367
370
  # may rebind (`super(line.sub(re, ""))`, issue #1365).
368
- [@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)
369
383
  end
370
384
 
371
385
  # Issue #1429 — a compound write or a `for` loop calls a method its syntax does not spell (`r += r` calls `r.+`,
@@ -1349,6 +1363,9 @@ module Rigor
1349
1363
  def eval_case_when_branches(subject_type, subject, conditions, entry_scope)
1350
1364
  results = []
1351
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
1352
1369
  conditions.each do |branch|
1353
1370
  # Issue #1359 — a clause's conditions, or its pattern's pins and guard, run before its body and before
1354
1371
  # every later clause, and the walk types them without evaluating them, so a reader there forgets `$_` here.
@@ -1359,7 +1376,11 @@ module Rigor
1359
1376
  # recursion) so no condition sub-expression is newly typed; `propagate` preserves the entry because it already
1360
1377
  # keys the node.
1361
1378
  record_clause_entry_scope(branch, falsey_scope)
1362
- 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
1363
1384
  results << sub_eval(branch, body_scope)
1364
1385
  end
1365
1386
  [results, falsey_scope]
@@ -1399,6 +1420,114 @@ module Rigor
1399
1420
  end
1400
1421
  end
1401
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
+
1402
1531
  # Issue #1495 — a `case` without a subject tests each `when` condition for truthiness, in order, and stops at the
1403
1532
  # first truthy one, so a clause is the `||` of its conditions in an `if` / `elsif` chain. Each condition is
1404
1533
  # evaluated under the falsey edges of the conditions before it, in this clause and every earlier one; the body
@@ -1517,9 +1646,32 @@ module Rigor
1517
1646
 
1518
1647
  # The scope past `node`'s `ensure` clause, entered with `exit_scope`, the `next` and `break` scopes recorded past
1519
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?}).
1520
1654
  def eval_begin_ensure(node, exit_scope, jump_marks)
1521
- carry_jumps_through_ensure(node.ensure_clause, jump_marks)
1522
- 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
1523
1675
  end
1524
1676
 
1525
1677
  # Issue #1231 — a rescue clause runs after the body raised from any point in it, and an `ensure` clause after the
@@ -1527,22 +1679,27 @@ module Rigor
1527
1679
  # instance variable those wrote at a value the scope it enters with does not hold: `conn = nil; begin; conn =
1528
1680
  # open_conn; Integer(s); rescue; conn.size; end` reads the connection. The engine does not track where the body
1529
1681
  # raises (the precise recorder of #1397 put new false positives on correct code); it widens instead, and
1530
- # deliberately imprecisely. Each name the clause mentions ({#clause_mentions}) that its entry binds and those
1531
- # clauses write, outside a nested `def` or class body, reads as `Dynamic[top]`, which reports nothing. A plain
1532
- # join would read `x` as `1?` in `x = 1; begin; foo; x = nil; rescue; x.succ; end`, where it cannot be nil, and a
1533
- # `Dynamic` that keeps the join as its facet still narrows: `x.nil?` on `Dynamic[1 | 2]` leaves a `nil` member
1534
- # for the next call to report. A name written inside a nested `ensure` clause, which also runs on the way out of
1535
- # 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.
1536
1695
  #
1537
1696
  # The names the clauses of `node` read from: the primary body's for a rescue clause, none under a retry `edge`.
1538
1697
  def rescue_writes(node, edge)
1539
- clause_writes([node.statements]) if node.rescue_clause && edge.nil? && widening_recorded?
1698
+ clause_writes([node.statements]) if node.rescue_clause && edge.nil?
1540
1699
  end
1541
1700
 
1542
1701
  # For the `ensure` clause: the writes of the body, its `else` and every rescue clause.
1543
1702
  def ensure_writes(node)
1544
- return nil unless widening_recorded?
1545
-
1546
1703
  sources = [node.statements, node.else_clause]
1547
1704
  current = node.rescue_clause
1548
1705
  while current
@@ -1552,83 +1709,68 @@ module Rigor
1552
1709
  clause_writes(sources)
1553
1710
  end
1554
1711
 
1555
- # The local and instance variable names `sources` write, a nested block's included (it may write an outer local),
1556
- # or nil for none. A `def` or class body writes nothing here.
1557
- def clause_writes(sources)
1558
- writes = nil
1559
- sources.each { |source| writes = collect_clause_names(source, CLAUSE_WRITE_NODES, nil, writes) if source }
1560
- writes
1561
- end
1712
+ def clause_writes(sources) = ClauseWidening.writes(sources)
1562
1713
 
1563
- CLAUSE_WRITE_NODES = Set[
1564
- *CapturedLocals::LOCAL_WRITE_NODES,
1565
- Prism::InstanceVariableWriteNode,
1566
- Prism::InstanceVariableOperatorWriteNode,
1567
- Prism::InstanceVariableOrWriteNode,
1568
- Prism::InstanceVariableAndWriteNode,
1569
- Prism::InstanceVariableTargetNode
1570
- ].freeze
1571
- private_constant :CLAUSE_WRITE_NODES
1714
+ CLAUSE_JUMP_NODES = Set[Prism::ReturnNode, Prism::NextNode, Prism::BreakNode].freeze
1715
+ private_constant :CLAUSE_JUMP_NODES
1572
1716
 
1573
- CLAUSE_MENTION_NODES = Set[
1574
- Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, *CLAUSE_WRITE_NODES
1575
- ].freeze
1576
- 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
1577
1723
 
1578
- # The names of the `node_classes` nodes under `node` (restricted to `among` when given) added to `found`, outside
1579
- # a nested `def` or class body.
1580
- def collect_clause_names(node, node_classes, among, found)
1581
- 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)
1582
1728
 
1583
- if node_classes.include?(node.class) && (among.nil? || among.include?(node.name))
1584
- (found ||= Set.new) << node.name
1585
- end
1586
- node.rigor_each_child { |child| found = collect_clause_names(child, node_classes, among, found) }
1587
- found
1729
+ node.rigor_each_child { |child| return true if clause_jumps?(child) }
1730
+ false
1588
1731
  end
1589
1732
 
1590
- # `clause_scope` with each name of `writes` that `statements` mentions, and `clause_scope` binds, rebound to
1591
- # `Dynamic[top]`, or nil when none changes.
1592
- def widen_clause_entry(clause_scope, statements, writes)
1593
- 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)
1594
1737
 
1595
- names = collect_clause_names(statements, CLAUSE_MENTION_NODES, writes, nil)
1596
- 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?
1597
1748
 
1598
- untyped = Type::Combinator.untyped
1599
- widened = names.reduce(clause_scope) do |acc, name|
1600
- kind = name.start_with?("@") ? :ivar : :local
1601
- current = clause_scope.public_send(VAR_KIND_GETTERS.fetch(kind), name)
1602
- current.nil? || current == untyped ? acc : rebind_variable(acc, kind, name, untyped)
1603
- end
1604
- widened.equal?(clause_scope) ? nil : widened
1749
+ @operand_recorder.call(statements, widened.forget_error_info.forget_last_status)
1605
1750
  end
1606
1751
 
1607
- # Whether a widened pass would record anything: only the check path asks for one ({#initialize}'s
1608
- # `widen_clauses`), and a pass nothing records is skipped, so a nested clause inside an unrecorded pass is
1609
- # 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?}).
1610
1754
  def widening_recorded?
1611
- @widen_clauses && !(@on_enter.nil? && @operand_recorder.nil?)
1755
+ @widen_clauses && recording?
1612
1756
  end
1613
1757
 
1614
- # `[type, scope]` of a rescue or `ensure` clause entered with `clause_scope`. When {#widen_clause_entry} widens
1615
- # it, the clause is evaluated twice: under the widened entry for the per-node index, which is what the check
1616
- # rules read, and under `clause_scope` without recording, for the value and the scope the code after the
1617
- # `begin` continues from. The widened pass collects no `return`, `next`, `break` or retry-edge scope, so the
1618
- # widening reaches neither an inferred return nor a loop: it can only take diagnostics away inside the clause.
1619
- def eval_widened_clause(node, statements, clause_scope, writes)
1620
- widened = widen_clause_entry(clause_scope, statements, writes)
1621
- return sub_eval(node, clause_scope) unless widened
1622
-
1623
- eval_widened_pass(node, widened)
1624
- 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?)
1625
1761
  end
1626
1762
 
1627
- # The recorded pass over a widened clause: it reaches no `return`, `next`, `break` or retry-frame sink.
1628
- def eval_widened_pass(node, widened)
1629
- 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?
1630
1771
 
1631
- 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)
1632
1774
  end
1633
1775
 
1634
1776
  # Runs `block` with no `return`, `next`, `break` or retry-frame sink installed.
@@ -1739,6 +1881,10 @@ module Rigor
1739
1881
  CLAUSE_SINK_KEYS = [RETURN_SINK_KEY, NEXT_SINK_KEY, BREAK_SINK_KEY, BREAK_VALUE_SINK_KEY, RETRY_FRAMES_KEY].freeze
1740
1882
  private_constant :CLAUSE_SINK_KEYS
1741
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
+
1742
1888
  # The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
1743
1889
  # no `retry` targets.
1744
1890
  def retry_edge_for(node)
@@ -2016,7 +2162,7 @@ module Rigor
2016
2162
  # runs on the way out (`begin; retry; ensure; tries += 1; end`), or one made before a `retry` the evaluator only
2017
2163
  # types (`log(tries < 5 ? retry : :gave_up)`).
2018
2164
  #
2019
- # 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
2020
2166
  # reference is bound, so `rescue => e` still binds `e` to the exception. It is nil under a retry edge, which
2021
2167
  # already carries every scope the primary body raises from ({#widen_entry_for_retry}).
2022
2168
  def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil)
@@ -2024,11 +2170,12 @@ module Rigor
2024
2170
  results = []
2025
2171
  current = rescue_node
2026
2172
  while current
2027
- widened = widen_clause_entry(entry_scope, current.statements, writes)
2028
- 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)
2029
2175
  rescue_scope = bind_rescue_reference(current, entry_scope)
2030
2176
  arm = if widened
2031
- 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)
2032
2179
  else
2033
2180
  eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
2034
2181
  end
@@ -2069,24 +2216,29 @@ module Rigor
2069
2216
  after.global(:$_) == Type::Combinator.untyped && !LastLine.may_set?(node.statements, scope)
2070
2217
  end
2071
2218
 
2072
- # Issue #1889 — {#eval_loop} / {#eval_for} with a literal-name `instance_variable_set` in the body joining its
2073
- # value into the ivar's binding rather than replacing it ({ReflectiveIvarWrite.in_repeating_body}).
2074
- def eval_repeating_loop(node) = ReflectiveIvarWrite.in_repeating_body { eval_loop(node) }
2075
-
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}).
2076
2223
  def eval_repeating_for(node) = ReflectiveIvarWrite.in_repeating_body { eval_for(node) }
2077
2224
 
2078
2225
  # `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
2079
2226
  # degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
2080
2227
  # 1), reflecting the common case where no `break VALUE` is observed.
2081
2228
  def eval_loop(node)
2082
- _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) }
2083
2234
  post_pred = widen_predicate_pins(node, post_pred)
2084
2235
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, post_pred)] if node.statements.nil?
2085
2236
 
2086
2237
  # The historical single body pass joined with the pre-loop scope. This continues to carry everything the
2087
2238
  # fixpoint does NOT track: receiver-mutation widening of non-rebound locals (`buf.push(i)` widens `buf`'s
2088
- # Tuple), body-introduced locals' nil-injection, an instance variable's rebind, and the loop value itself. The
2089
- # 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.
2090
2242
  #
2091
2243
  # Like every fixpoint pass, it enters the body on the predicate's loop-entry edge ({#loop_pass_entry}): the
2092
2244
  # body of `while (line = gets)` reads `line` as `String`, and the body of `while gets` reads `$_` as one
@@ -2099,8 +2251,7 @@ module Rigor
2099
2251
  body_scope, first_breaks = single_pass(node, post_pred, jumps)
2100
2252
  base_scope = join_with_nil_injection(post_pred, body_scope)
2101
2253
 
2102
- rebound, body_first = loop_body_local_writes(node.statements, post_pred)
2103
- names = rebound + body_first
2254
+ names, body_first = loop_rebound_names(node.statements, post_pred)
2104
2255
 
2105
2256
  # Fast path: a loop whose body rebinds no local skips the rebind fixpoint, but still needs the slice-C content
2106
2257
  # writeback (a loop may content-mutate a collection without rebinding any local — `acc << x`), so apply it to
@@ -2193,7 +2344,7 @@ module Rigor
2193
2344
  # assumptions, so the last-visit-wins scope index would annotate loop-body lines with stale pre-convergence
2194
2345
  # constants. One extra pass from the converged bindings (result discarded) re-records the body's entry scopes.
2195
2346
  record_converged_loop_body(node, post_pred, result, names, body_first, jumps, break_pass)
2196
- 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) }
2197
2348
  # ADR-56 slice C — loop-body receiver-content element-type join. A loop that content-mutates a collection (`acc
2198
2349
  # << n`) keeps only the seed's element types after the single-pass widen; join the appended/stored types into
2199
2350
  # the continuation collection. Pre-state comes from `post_pred` for a name the loop only content-mutates and
@@ -2261,6 +2412,13 @@ module Rigor
2261
2412
  NO_LOOP_NAMES = [].freeze
2262
2413
  private_constant :NO_LOOP_BINDINGS, :NO_LOOP_NAMES
2263
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
+
2264
2422
  # A body with no targeting jump pays two allocation-free scans.
2265
2423
  def loop_jumps(statements)
2266
2424
  kinds = JumpTargets.kinds(statements, LOOP_JUMP_CLASSES)
@@ -2356,11 +2514,11 @@ module Rigor
2356
2514
  floor = Type::Combinator.untyped
2357
2515
  breaks.reduce(continuation) do |cont, break_scope|
2358
2516
  names.reduce(cont) do |acc, name|
2359
- break_value = break_scope.local(name)
2360
- current = acc.local(name)
2517
+ break_value = CapturedLocals.bound_type(break_scope, name)
2518
+ current = CapturedLocals.bound_type(acc, name)
2361
2519
  next acc if break_value.nil? || current == floor
2362
2520
 
2363
- 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)
2364
2522
  end
2365
2523
  end
2366
2524
  end
@@ -2433,7 +2591,7 @@ module Rigor
2433
2591
  # treatment. `break_pass` rides along so every pass leaves its `break` arms for {#loop_break_arms}.
2434
2592
  def loop_rebind_fixpoint(node, post_pred, names, body_first, jumps, break_pass)
2435
2593
  nil_const = Type::Combinator.constant_of(nil)
2436
- 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] }
2437
2595
  evaluate_body = lambda do |bindings|
2438
2596
  loop_body_exit_bindings(node, post_pred, bindings, names, body_first, jumps, break_pass)
2439
2597
  end
@@ -2465,6 +2623,34 @@ module Rigor
2465
2623
  [pre_existing.uniq, body_first.uniq - pre_existing.uniq]
2466
2624
  end
2467
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
+
2468
2654
  # Evaluates the loop body once with each fixpoint-tracked local bound to the supplied running assumption and
2469
2655
  # returns the per-name exit binding. Used as the {BodyFixpoint} body-evaluator for `eval_loop`.
2470
2656
  #
@@ -2493,14 +2679,14 @@ module Rigor
2493
2679
  break_pass[:entry] = bindings.except(*body_first)
2494
2680
  break_pass[:arms] = breaks
2495
2681
  end
2496
- names.to_h { |name| [name, exit_scope.local(name)] }
2682
+ names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
2497
2683
  end
2498
2684
 
2499
2685
  # The scope one fixpoint pass enters the body with: `post_pred` overlaid with the pre-existing names' running
2500
2686
  # assumption, then narrowed by the predicate's loop-entry edge ({#loop_body_exit_bindings} carries the why).
2501
2687
  def loop_pass_entry(node, post_pred, bindings, body_first, jumps)
2502
2688
  overlaid = bindings.except(*body_first)
2503
- 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) }
2504
2690
  entry = loop_content_entry(node.statements, post_pred, entry)
2505
2691
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, entry)
2506
2692
  edge = node.is_a?(Prism::UntilNode) ? falsey_scope : truthy_scope
@@ -2866,15 +3052,47 @@ module Rigor
2866
3052
  # u.strip`, where the raise almost always comes from `Float` after it. Neither the arm nor anything in it
2867
3053
  # is recorded or typed from there, which keeps it where it was before #1256: an ADR-5 trade of the rare
2868
3054
  # raise-before-write path for no false positive on the common one.
2869
- arm_entry = ErrorInfo.modifier_entry(join_with_nil_injection(scope, after_expression), node.rescue_expression)
2870
- after_rescue = thread_operand(node.rescue_expression, arm_entry, OperandWalk.new(nil), arm_entry)
2871
- 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)
2872
3066
  after = if branch_unconditionally_exits?(node.rescue_expression)
2873
3067
  after_expression
2874
3068
  else
2875
3069
  join_with_nil_injection(after_expression, after_rescue)
2876
3070
  end
2877
- [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))
2878
3096
  end
2879
3097
 
2880
3098
  # `class Foo; body; end` and `module Foo; body; end`. The class body runs in a fresh scope (Ruby's class scope
@@ -3130,7 +3348,7 @@ module Rigor
3130
3348
  # map …) that rebinds an outer local must not leave that local's pre-call binding unmodified in the continuation
3131
3349
  # scope; the spec MUST in § "Fact stability and mutation" names captured locals a first-class invalidation
3132
3350
  # category. (The escaping / unknown path already widened to Dynamic[top] via `record_closure_escape_if_any`.)
3133
- post_scope = write_back_block_captures(node, post_scope)
3351
+ post_scope = block_capture_continuation(node, post_scope)
3134
3352
  statement_call = !call_type.nil? && !@in_operand
3135
3353
  post_scope = apply_post_return_narrowing(node, post_scope) if statement_call
3136
3354
  # Flow-folding G1 / G2 — widen a local- or instance-variable binding when the call is an in-place mutator on it
@@ -3337,30 +3555,21 @@ module Rigor
3337
3555
  # is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
3338
3556
  # External- receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged.
3339
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}).
3340
3564
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
3341
- return current_scope unless intervening_call_candidate?(call_node)
3342
-
3343
- class_name = enclosing_class_name_for(current_scope.self_type)
3344
- return current_scope if class_name.nil?
3345
-
3346
- seed = current_scope.class_ivars_for(class_name)
3347
- return current_scope if seed.empty?
3348
-
3349
- widened = current_scope
3350
- seed.each do |ivar_name, seed_type|
3351
- local_type = current_scope.ivar(ivar_name)
3352
- next if local_type.nil? || local_type == seed_type
3353
-
3354
- writes = self_call_ivar_writes(call_node)
3355
- next if writes && !writes.include?(ivar_name)
3356
-
3357
- joined = Type::Combinator.union(local_type, seed_type)
3358
- widened = widened.with_ivar(ivar_name, joined)
3359
- # Issue #1889 — an ivar a reflective write widened keeps its declaration mark, so the reset must too.
3360
- widened = widened.seed_declaration_sourced_ivar(ivar_name, joined) if
3361
- current_scope.declaration_sourced?(:ivar, ivar_name)
3362
- end
3363
- widened
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)
3364
3573
  end
3365
3574
 
3366
3575
  # Issue #1596 — the ivars the implicit-self / `self.` call `call_node` may write, or nil when that is not proven
@@ -3373,10 +3582,7 @@ module Rigor
3373
3582
  end
3374
3583
 
3375
3584
  def intervening_call_candidate?(call_node)
3376
- return false unless call_node.is_a?(Prism::CallNode)
3377
-
3378
- receiver = call_node.receiver
3379
- receiver.nil? || receiver.is_a?(Prism::SelfNode)
3585
+ InterveningIvarReset.candidate?(call_node)
3380
3586
  end
3381
3587
 
3382
3588
  def enclosing_class_name_for(self_type)
@@ -3876,7 +4082,7 @@ module Rigor
3876
4082
  classification = classify_closure_escape(node)
3877
4083
  return post_scope if classification == :non_escaping
3878
4084
 
3879
- 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))
3880
4086
  post_scope.with_fact(
3881
4087
  Analysis::FactStore::Fact.new(
3882
4088
  bucket: :dynamic_origin,
@@ -4180,13 +4386,72 @@ module Rigor
4180
4386
  # callback that runs later writes the very ivar the continuation reads (`@clicked = false; button.on_click {
4181
4387
  # @clicked = true }` left `if @clicked` folding always-falsey). One still on its ADR-58 declaration seed is
4182
4388
  # left alone ({CapturedLocals.writes}): that seed already holds whatever the callback stores.
4183
- def drop_captured_narrowing(block_node, base_scope)
4184
- 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)
4185
4391
  return base_scope if names.empty?
4186
4392
 
4187
4393
  names.reduce(base_scope) { |acc, name| bind_capture(acc, name, Type::Combinator.untyped) }
4188
4394
  end
4189
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
+
4190
4455
  # ADR-56 slice A. For a `:non_escaping` block, fold the continuation binding of every outer local the body can
4191
4456
  # rebind back into `post_scope`. The binding is a capped fixpoint (cap 3) over the block body re-evaluated under
4192
4457
  # the running per-name assumption, joined with the pre-call binding (kept as a constituent so the 0-iteration path
@@ -4211,13 +4476,27 @@ module Rigor
4211
4476
  return post_scope unless block.is_a?(Prism::BlockNode)
4212
4477
  return post_scope unless classify_closure_escape(call_node) == :non_escaping
4213
4478
 
4214
- names = CapturedLocals.writes(block, scope, ivars: true)
4479
+ names = CapturedLocals.writes(block, scope, ivars: true, foreign_self: foreign_self_block?(call_node))
4215
4480
  return post_scope if names.empty?
4216
4481
 
4217
4482
  break_pass = block_break_pass(block)
4218
- 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) }
4219
4485
  result = join_block_break_bindings(call_node, block, result, break_pass)
4220
- 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
4221
4500
  end
4222
4501
 
4223
4502
  # The {BodyFixpoint} continuation of `names`, seeded from their pre-call bindings.
@@ -4276,7 +4555,12 @@ module Rigor
4276
4555
  # Binds a name from `CapturedLocals.writes`. An ivar goes through {CapturedLocals.bind}, which keeps its issue
4277
4556
  # #286 optimistic mark. A local keeps the plain `with_local` these seams have always used, so a block that
4278
4557
  # rebinds no ivar leaves the locals exactly as before.
4279
- 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
+
4280
4564
  CapturedLocals.ivar_name?(name) ? CapturedLocals.bind(scope, name, type) : scope.with_local(name, type)
4281
4565
  end
4282
4566
 
@@ -5140,9 +5424,22 @@ module Rigor
5140
5424
  def block_exit_bindings(call_node, block, bindings, names)
5141
5425
  entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, bindings))
5142
5426
  _type, exit_scope = sub_eval(block, entry)
5427
+ record_capture_exit_marks(exit_scope, names)
5143
5428
  names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
5144
5429
  end
5145
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
+
5146
5443
  # The entry scope of one write-back pass: the block's entry with each written outer local or ivar bound to
5147
5444
  # `bindings`, and each captured local the body only mutates in place at its widening ({#capture_pass_bindings}).
5148
5445
  # That widening describes the same object, so it keeps the marks a source-level write drops (issue #1287,
@@ -5151,7 +5448,9 @@ module Rigor
5151
5448
  def block_pass_entry(call_node, block, bindings)
5152
5449
  entry = build_block_entry_scope(call_node, block)
5153
5450
  capture_pass_bindings(block, bindings).reduce(entry) do |acc, (name, type)|
5154
- 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))
5155
5454
  end
5156
5455
  end
5157
5456
 
@@ -5255,7 +5554,7 @@ module Rigor
5255
5554
  def unproven_rebind_bindings(call_node, block, classification)
5256
5555
  return NO_CAPTURE_BINDINGS if call_node.receiver && classification == :non_escaping
5257
5556
 
5258
- names = CapturedLocals.writes(block, scope, ivars: true)
5557
+ names = CapturedLocals.writes(block, scope, ivars: true, foreign_self: foreign_self_block?(call_node))
5259
5558
  return NO_CAPTURE_BINDINGS if names.empty?
5260
5559
 
5261
5560
  untyped = Type::Combinator.untyped
@@ -5370,8 +5669,16 @@ module Rigor
5370
5669
  # The continuation effects of an escaping closure body: the outer locals it can rebind drop their narrowing, and
5371
5670
  # the ones it content-mutates are floored. Shared by the two spellings — a block on an escaping call
5372
5671
  # ({#record_closure_escape_if_any}) and a lambda literal ({#eval_lambda}) — so they cannot drift.
5373
- def escaping_closure_captures(closure_node, post_scope)
5374
- 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)
5375
5682
  end
5376
5683
 
5377
5684
  # Builds the entry scope for a block body. The block sees the outer scope's locals (Ruby's lexical scoping rule)