rigortype 0.4.2 → 0.4.4

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 (128) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/data/core_overlay/ostruct.rbs +40 -0
  4. data/docs/handbook/11-sig-gen.md +28 -5
  5. data/docs/manual/02-cli-reference.md +23 -4
  6. data/docs/manual/03-configuration.md +21 -8
  7. data/docs/manual/04-diagnostics.md +44 -2
  8. data/docs/manual/05-inspecting-types.md +50 -0
  9. data/docs/manual/07-plugins.md +1 -1
  10. data/docs/manual/10-mcp-server.md +32 -3
  11. data/docs/manual/12-caching.md +18 -1
  12. data/docs/manual/14-rails-quickstart.md +2 -2
  13. data/docs/manual/18-removing-dead-code.md +26 -9
  14. data/docs/manual/plugins/rigor-rails-routes.md +2 -2
  15. data/docs/manual/plugins/rigor-shoulda-matchers.md +4 -1
  16. data/exe/rigor +12 -17
  17. data/lib/rigor/analysis/check_rules/rule_ids.rb +64 -0
  18. data/lib/rigor/analysis/check_rules.rb +68 -57
  19. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  20. data/lib/rigor/analysis/incremental.rb +12 -0
  21. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  22. data/lib/rigor/analysis/incremental_session.rb +207 -40
  23. data/lib/rigor/analysis/path_expansion.rb +62 -6
  24. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  25. data/lib/rigor/analysis/reachability/graph.rb +139 -15
  26. data/lib/rigor/analysis/reachability/scan.rb +374 -173
  27. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  28. data/lib/rigor/analysis/rule_catalog.rb +6 -1
  29. data/lib/rigor/analysis/run_cache_key.rb +31 -7
  30. data/lib/rigor/analysis/run_cache_probe.rb +6 -4
  31. data/lib/rigor/analysis/run_stats.rb +28 -6
  32. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +74 -13
  33. data/lib/rigor/analysis/runner/pool_coordinator.rb +133 -19
  34. data/lib/rigor/analysis/runner/project_pre_passes.rb +46 -1
  35. data/lib/rigor/analysis/runner.rb +62 -18
  36. data/lib/rigor/analysis/severity_stamp.rb +34 -1
  37. data/lib/rigor/analysis/worker_session.rb +58 -2
  38. data/lib/rigor/cache/descriptor.rb +40 -6
  39. data/lib/rigor/cache/engine_source.rb +21 -7
  40. data/lib/rigor/cache/incremental_snapshot.rb +56 -12
  41. data/lib/rigor/cli/check_command.rb +138 -39
  42. data/lib/rigor/cli/docs_command.rb +5 -4
  43. data/lib/rigor/cli/lens_command.rb +144 -0
  44. data/lib/rigor/cli/lsp_command.rb +5 -2
  45. data/lib/rigor/cli/mcp_command.rb +1 -1
  46. data/lib/rigor/cli/skill_command.rb +3 -3
  47. data/lib/rigor/cli/skill_describe.rb +1 -1
  48. data/lib/rigor/cli/type_of_command.rb +2 -1
  49. data/lib/rigor/cli/unused_command.rb +157 -24
  50. data/lib/rigor/cli.rb +30 -4
  51. data/lib/rigor/config_audit.rb +111 -15
  52. data/lib/rigor/configuration/dependencies.rb +19 -11
  53. data/lib/rigor/configuration.rb +15 -0
  54. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  55. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  56. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  57. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  58. data/lib/rigor/environment/rbs_loader.rb +79 -10
  59. data/lib/rigor/environment/required_features.rb +4 -2
  60. data/lib/rigor/environment.rb +12 -5
  61. data/lib/rigor/inference/block_call_timing.rb +5 -0
  62. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  63. data/lib/rigor/inference/captured_locals.rb +69 -6
  64. data/lib/rigor/inference/definer_resolution.rb +16 -7
  65. data/lib/rigor/inference/expression_typer.rb +117 -29
  66. data/lib/rigor/inference/guard_rebinding.rb +7 -2
  67. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  68. data/lib/rigor/inference/last_line/self_evidence.rb +1 -1
  69. data/lib/rigor/inference/match_rebinding.rb +1 -1
  70. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  71. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  72. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  73. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  74. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  75. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  76. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  77. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  78. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  79. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  80. data/lib/rigor/inference/module_function_state.rb +80 -13
  81. data/lib/rigor/inference/narrowing.rb +184 -68
  82. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  83. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  84. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  85. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  86. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  87. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  88. data/lib/rigor/inference/reflective_ivar_write.rb +116 -0
  89. data/lib/rigor/inference/repeated_or_writes.rb +416 -1
  90. data/lib/rigor/inference/scope_indexer.rb +565 -61
  91. data/lib/rigor/inference/self_call_ivar_writes.rb +341 -0
  92. data/lib/rigor/inference/statement_evaluator.rb +307 -60
  93. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  94. data/lib/rigor/language_server/project_context.rb +56 -5
  95. data/lib/rigor/lens/anchor.rb +115 -0
  96. data/lib/rigor/lens/contract_reader.rb +133 -0
  97. data/lib/rigor/lens/document.rb +113 -0
  98. data/lib/rigor/lens/file_reader.rb +802 -0
  99. data/lib/rigor/lens/members.rb +203 -0
  100. data/lib/rigor/lens/parameters.rb +91 -0
  101. data/lib/rigor/lens/plugins.rb +40 -0
  102. data/lib/rigor/lens/run.rb +322 -0
  103. data/lib/rigor/lens/slot.rb +79 -0
  104. data/lib/rigor/lens.rb +41 -0
  105. data/lib/rigor/mcp/server.rb +61 -20
  106. data/lib/rigor/plugin/box_probe.rb +154 -7
  107. data/lib/rigor/project_environment.rb +23 -1
  108. data/lib/rigor/protection/mutation_cache.rb +2 -1
  109. data/lib/rigor/scope.rb +33 -17
  110. data/lib/rigor/sig_gen/generator.rb +45 -5
  111. data/lib/rigor/sig_gen/layout_index.rb +36 -0
  112. data/lib/rigor/sig_gen/method_candidate.rb +8 -0
  113. data/lib/rigor/sig_gen/path_mapper.rb +18 -2
  114. data/lib/rigor/sig_gen/writer.rb +47 -34
  115. data/lib/rigor/version.rb +1 -1
  116. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  117. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  118. data/plugins/rigor-dry-schema/lib/rigor/plugin/dry_schema.rb +5 -1
  119. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +5 -1
  120. data/plugins/rigor-ffi/lib/rigor/plugin/ffi/discoverer.rb +28 -6
  121. data/plugins/rigor-ffi/lib/rigor/plugin/ffi.rb +1 -1
  122. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +5 -1
  123. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +155 -12
  124. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +10 -8
  125. data/sig/rigor/scope.rbs +3 -0
  126. data/sig/rigor.rbs +6 -3
  127. data/skills/rigor-project-init/references/02-configure.md +2 -2
  128. metadata +21 -1
@@ -27,6 +27,7 @@ require_relative "content_join"
27
27
  require_relative "define_method_block_self"
28
28
  require_relative "macro_block_self_type"
29
29
  require_relative "guard_rebinding"
30
+ require_relative "self_call_ivar_writes"
30
31
  require_relative "match_rebinding"
31
32
  require_relative "element_read_widening"
32
33
  require_relative "hash_lookup_mutation"
@@ -42,8 +43,10 @@ require_relative "operand_effects"
42
43
  require_relative "operand_walk"
43
44
  require_relative "optimistic_origin"
44
45
  require_relative "return_barrier"
46
+ require_relative "repeated_or_writes"
45
47
  require_relative "rewrite_mutation"
46
48
  require_relative "unknown_store_widening"
49
+ require_relative "untyped_rejoin"
47
50
  require_relative "version_guard"
48
51
 
49
52
  module Rigor
@@ -121,9 +124,9 @@ module Rigor
121
124
  Prism::BeginNode => :eval_begin_node,
122
125
  Prism::RescueNode => :eval_rescue,
123
126
  Prism::EnsureNode => :eval_ensure,
124
- Prism::WhileNode => :eval_loop,
125
- Prism::UntilNode => :eval_loop,
126
- Prism::ForNode => :eval_for,
127
+ Prism::WhileNode => :eval_repeating_loop,
128
+ Prism::UntilNode => :eval_repeating_loop,
129
+ Prism::ForNode => :eval_repeating_for,
127
130
  Prism::AndNode => :eval_and_or,
128
131
  Prism::OrNode => :eval_and_or,
129
132
  Prism::ParenthesesNode => :eval_parentheses,
@@ -278,11 +281,15 @@ module Rigor
278
281
  # a declined `class self::X`, a parse-error header), where `class_context` is empty without the code
279
282
  # being at the top level. A `def` (or `class << self`) there runs under an untyped `self` instead of
280
283
  # the top level's nil one, so its implicit-self calls answer `Dynamic` and no top-level rule fires.
284
+ # @param widen_clauses — issue #1231: when true (and a recorder is installed), a rescue or `ensure` clause
285
+ # records its per-node entry scopes under {#widen_clause_entry}'s widening. The check path sets it; every
286
+ # other reader of the index (parameter inference, sig-gen, the scanners) keeps the clause's plain entry.
281
287
  def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze, # rubocop:disable Metrics/ParameterLists
282
288
  lexical_nesting: EMPTY_NESTING, converged_loop_recording: false, next_scope_sink: nil,
283
289
  operand_scope: nil, in_operand: false, operand_recorder: nil, operand_types: nil,
284
- opaque_class: false)
290
+ opaque_class: false, widen_clauses: false)
285
291
  @scope = scope
292
+ @widen_clauses = widen_clauses
286
293
  @opaque_class = opaque_class
287
294
  @tracer = tracer
288
295
  @on_enter = on_enter
@@ -484,9 +491,10 @@ module Rigor
484
491
  # `opts[k] ||= raise KeyError` is a guard, never `bot`, and `@flags[n] ||= false` is `true` after an
485
492
  # `@flags[n] = true` elsewhere, never provably `false`.
486
493
  #
487
- # Nor is a site a block-return pass marked (`Scope#repeated_or_write?`, {RepeatedOrWrites}): the pass types
488
- # every run of a repeating body from one entry scope, so its slot's gradual type may be what an EARLIER run
489
- # stored rather than an absence of evidence.
494
+ # Nor is a marked site (`Scope#repeated_or_write?`, {RepeatedOrWrites}): a block-return pass, and this
495
+ # evaluator's statement and write-back passes ({#with_repeated_or_write_marks}), type every run of a repeating
496
+ # body from one entry scope, so its slot's gradual type may be what an EARLIER run stored rather than an
497
+ # absence of evidence.
490
498
  def index_compound_write_value(node)
491
499
  return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
492
500
 
@@ -518,10 +526,13 @@ module Rigor
518
526
  [result_type, current]
519
527
  end
520
528
 
529
+ # The top level is a straight-line body: an index `||=` there whose slot an earlier store may have filled is
530
+ # marked ({RepeatedOrWrites.straight_line_sites}).
521
531
  def eval_program(node)
522
532
  return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
523
533
 
524
- sub_eval(node.statements, scope)
534
+ marks = RepeatedOrWrites.straight_line_sites(node.statements, scope)
535
+ sub_eval(node.statements, scope.with_repeated_or_writes(marks))
525
536
  end
526
537
 
527
538
  # `name = rvalue` evaluates the rvalue under the entry scope (so earlier assignments in a chained `a = b = expr`
@@ -1180,7 +1191,8 @@ module Rigor
1180
1191
 
1181
1192
  [
1182
1193
  Type::Combinator.union(then_type, else_type),
1183
- join_with_nil_injection(then_scope, else_scope)
1194
+ UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred,
1195
+ [node.statements, node.subsequent])
1184
1196
  ]
1185
1197
  end
1186
1198
 
@@ -1215,7 +1227,8 @@ module Rigor
1215
1227
 
1216
1228
  [
1217
1229
  Type::Combinator.union(then_type, else_type),
1218
- join_with_nil_injection(then_scope, else_scope)
1230
+ UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred,
1231
+ [node.statements, node.else_clause])
1219
1232
  ]
1220
1233
  end
1221
1234
 
@@ -1286,7 +1299,7 @@ module Rigor
1286
1299
  subject_type, post_pred = node.predicate ? sub_eval(node.predicate, scope) : [nil, scope]
1287
1300
  branch_results, falsey_scope = eval_case_when_branches(subject_type, node.predicate, node.conditions, post_pred)
1288
1301
  if pattern_case_matches_every_path?(node, branch_results)
1289
- return unmatched_pattern_result(branch_results, node.conditions)
1302
+ return unmatched_pattern_result(branch_results, node.conditions, post_pred)
1290
1303
  end
1291
1304
 
1292
1305
  else_result = eval_case_else(node.else_clause, falsey_scope)
@@ -1295,7 +1308,7 @@ module Rigor
1295
1308
  branch_nodes = [*node.conditions, node.else_clause]
1296
1309
  [
1297
1310
  Type::Combinator.union(*all_results.map(&:first)),
1298
- join_case_branch_scopes(all_results, branch_nodes)
1311
+ join_case_branch_scopes(all_results, branch_nodes, post_pred)
1299
1312
  ]
1300
1313
  end
1301
1314
 
@@ -1310,8 +1323,9 @@ module Rigor
1310
1323
  node.is_a?(Prism::CaseMatchNode) && node.else_clause.nil? && !branch_results.empty?
1311
1324
  end
1312
1325
 
1313
- def unmatched_pattern_result(branch_results, branch_nodes)
1314
- [Type::Combinator.union(*branch_results.map(&:first)), join_case_branch_scopes(branch_results, branch_nodes)]
1326
+ def unmatched_pattern_result(branch_results, branch_nodes, entry)
1327
+ [Type::Combinator.union(*branch_results.map(&:first)),
1328
+ join_case_branch_scopes(branch_results, branch_nodes, entry)]
1315
1329
  end
1316
1330
 
1317
1331
  # Joins the post-scopes of every `when`/`in`/`else` branch, dropping the scope of any branch that terminates
@@ -1320,15 +1334,16 @@ module Rigor
1320
1334
  # `branch_terminates?` rule `eval_if`/`eval_unless` already apply to the if/else merge: e.g. `case x; when 1 then
1321
1335
  # v="a"; when 2 then v="b"; else raise; end` keeps `v: "a" | "b"` instead of `... | nil`. When every branch
1322
1336
  # terminates the merge is itself unreachable; fall back to the full join so the continuation scope stays
1323
- # well-formed.
1324
- def join_case_branch_scopes(results, nodes)
1337
+ # well-formed. A local the clauses only narrowed from `Dynamic[top]` re-joins to it ({UntypedRejoin}, issue
1338
+ # #1840), read against the post-predicate `entry`.
1339
+ def join_case_branch_scopes(results, nodes, entry)
1325
1340
  live = []
1326
1341
  results.each_with_index do |(type, branch_scope), i|
1327
1342
  live << branch_scope unless branch_terminates?(nodes[i], type)
1328
1343
  end
1329
1344
 
1330
1345
  live = results.map(&:last) if live.empty?
1331
- reduce_scopes_with_nil_injection(live)
1346
+ UntypedRejoin.apply(reduce_scopes_with_nil_injection(live), entry, nodes)
1332
1347
  end
1333
1348
 
1334
1349
  def eval_case_when_branches(subject_type, subject, conditions, entry_scope)
@@ -1369,46 +1384,46 @@ module Rigor
1369
1384
  end
1370
1385
 
1371
1386
  # Returns `[body_scope, updated_falsey_scope]` for a single branch. `WhenNode` branches narrow through
1372
- # `Narrowing.case_when_scopes`. `InNode` branches narrow soundly only for a bare class pattern (`in C` / `in C =>
1373
- # x`, pure `is_a?`); every other pattern keeps the conservative "body = entry + bindings, falsey unchanged" shape.
1374
- # `subject_type` is the predicate's type, which an `in` branch's pattern decomposes to type the names it binds.
1387
+ # `Narrowing.case_when_scopes`, or through {#subjectless_when_scopes} in a `case` without a subject. `InNode`
1388
+ # branches narrow soundly only for a bare class pattern (`in C` / `in C => x`, pure `is_a?`); every other pattern
1389
+ # keeps the conservative "body = entry + bindings, falsey unchanged" shape. `subject_type` is the predicate's
1390
+ # type, which an `in` branch's pattern decomposes to type the names it binds.
1375
1391
  def branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1376
1392
  if branch.is_a?(Prism::InNode)
1377
1393
  in_branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1394
+ elsif subject.nil?
1395
+ subjectless_when_scopes(branch.conditions, falsey_scope)
1378
1396
  else
1379
1397
  when_conditions = branch.respond_to?(:conditions) ? branch.conditions : []
1380
1398
  Narrowing.case_when_scopes(subject, when_conditions, falsey_scope)
1381
1399
  end
1382
1400
  end
1383
1401
 
1402
+ # Issue #1495 — a `case` without a subject tests each `when` condition for truthiness, in order, and stops at the
1403
+ # first truthy one, so a clause is the `||` of its conditions in an `if` / `elsif` chain. Each condition is
1404
+ # evaluated under the falsey edges of the conditions before it, in this clause and every earlier one; the body
1405
+ # joins the conditions' truthy edges, and the next clause starts from the last falsey edge. A condition
1406
+ # `Narrowing.predicate_scopes` does not recognise narrows nothing: a splat (`when *conds`), and a regexp literal,
1407
+ # which is tested for its truthiness and matches nothing, so it binds no match global (#1486).
1408
+ def subjectless_when_scopes(conditions, entry)
1409
+ falsey = entry
1410
+ truthy_edges = conditions.map do |condition|
1411
+ _type, _post, truthy, falsey = eval_with_edges(condition, falsey)
1412
+ truthy
1413
+ end
1414
+ [reduce_scopes_with_nil_injection(truthy_edges), falsey]
1415
+ end
1416
+
1384
1417
  # ADR-47 WD3a — a bare class pattern matches on `C === subject`, i.e. exactly `subject.is_a?(C)` with no
1385
1418
  # deconstruction, so it narrows like `when C`: the body sees the subject narrowed to `C` and the next clause's
1386
1419
  # falsey scope has `C` removed. Other patterns can fail to match even when a class test would pass (deconstruction
1387
1420
  # arity, hash keys, ...), so removing anything from the falsey scope would be unsound — they keep the conservative
1388
1421
  # shape.
1389
1422
  def in_branch_body_and_falsey_scopes(subject_type, subject, branch, falsey_scope)
1390
- class_node = bare_class_pattern_node(branch.pattern)
1391
- unless class_node
1392
- bound = apply_in_pattern_bindings(subject_type, subject, branch.pattern, falsey_scope)
1393
- return [bound, falsey_scope]
1394
- end
1395
-
1396
- truthy_scope, narrowed_falsey = Narrowing.case_when_scopes(subject, [class_node], falsey_scope)
1423
+ truthy_scope, narrowed_falsey = Narrowing.case_clause_scopes(subject, branch, falsey_scope)
1397
1424
  [apply_in_pattern_bindings(subject_type, subject, branch.pattern, truthy_scope), narrowed_falsey]
1398
1425
  end
1399
1426
 
1400
- # The class-constant node of a `in C` / `in C => x` pattern (the only `in` shapes whose match is pure `is_a?`), or
1401
- # nil for any pattern that deconstructs, binds, or matches a value.
1402
- def bare_class_pattern_node(pattern)
1403
- case pattern
1404
- when Prism::ConstantReadNode, Prism::ConstantPathNode
1405
- pattern
1406
- when Prism::CapturePatternNode
1407
- value = pattern.value
1408
- value if value.is_a?(Prism::ConstantReadNode) || value.is_a?(Prism::ConstantPathNode)
1409
- end
1410
- end
1411
-
1412
1427
  def eval_case_else(else_clause, falsey_scope)
1413
1428
  return sub_eval(else_clause, falsey_scope) if else_clause
1414
1429
 
@@ -1477,7 +1492,8 @@ module Rigor
1477
1492
  # prefix of the body, and so reads it forgotten whenever the body may set it.
1478
1493
  entry = edge ? forget_guard_if_rebinds(LastLine.forget_if_set(scope, node), node) : scope
1479
1494
  primary_type, primary_scope = eval_begin_primary_under(node, entry, edge: edge)
1480
- rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry_scope(node, entry), edge: edge)
1495
+ rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry_scope(node, entry),
1496
+ edge: edge, writes: rescue_writes(node, edge))
1481
1497
 
1482
1498
  # B2.1 — retry-edge widening. When a `retry` in the rescue chain targets this `begin`, control re-enters the
1483
1499
  # primary body carrying every rebind made before the retry: the arm's (`rescue; tries += 1; retry; end`), and
@@ -1496,13 +1512,132 @@ module Rigor
1496
1512
  exit_scope = reduce_scopes_with_nil_injection([primary_scope, *live_rescues.map(&:last)])
1497
1513
  end
1498
1514
 
1499
- if node.ensure_clause
1500
- carry_jumps_through_ensure(node.ensure_clause, jump_marks)
1501
- _ensure_type, ensure_scope = sub_eval(node.ensure_clause, exit_scope)
1502
- exit_scope = ensure_scope
1515
+ [exit_type, node.ensure_clause ? eval_begin_ensure(node, exit_scope, jump_marks) : exit_scope]
1516
+ end
1517
+
1518
+ # The scope past `node`'s `ensure` clause, entered with `exit_scope`, the `next` and `break` scopes recorded past
1519
+ # `jump_marks` carried through it.
1520
+ 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
1523
+ end
1524
+
1525
+ # Issue #1231 — a rescue clause runs after the body raised from any point in it, and an `ensure` clause after the
1526
+ # body, its `else` or a rescue clause raised as well as after they finished, so a clause reads each local or
1527
+ # instance variable those wrote at a value the scope it enters with does not hold: `conn = nil; begin; conn =
1528
+ # open_conn; Integer(s); rescue; conn.size; end` reads the connection. The engine does not track where the body
1529
+ # 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.
1536
+ #
1537
+ # The names the clauses of `node` read from: the primary body's for a rescue clause, none under a retry `edge`.
1538
+ def rescue_writes(node, edge)
1539
+ clause_writes([node.statements]) if node.rescue_clause && edge.nil? && widening_recorded?
1540
+ end
1541
+
1542
+ # For the `ensure` clause: the writes of the body, its `else` and every rescue clause.
1543
+ def ensure_writes(node)
1544
+ return nil unless widening_recorded?
1545
+
1546
+ sources = [node.statements, node.else_clause]
1547
+ current = node.rescue_clause
1548
+ while current
1549
+ sources << current
1550
+ current = current.subsequent
1551
+ end
1552
+ clause_writes(sources)
1553
+ end
1554
+
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
1562
+
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
1572
+
1573
+ CLAUSE_MENTION_NODES = Set[
1574
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, *CLAUSE_WRITE_NODES
1575
+ ].freeze
1576
+ private_constant :CLAUSE_MENTION_NODES
1577
+
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) }
1582
+
1583
+ if node_classes.include?(node.class) && (among.nil? || among.include?(node.name))
1584
+ (found ||= Set.new) << node.name
1503
1585
  end
1586
+ node.rigor_each_child { |child| found = collect_clause_names(child, node_classes, among, found) }
1587
+ found
1588
+ end
1504
1589
 
1505
- [exit_type, exit_scope]
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?
1594
+
1595
+ names = collect_clause_names(statements, CLAUSE_MENTION_NODES, writes, nil)
1596
+ return nil if names.nil?
1597
+
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
1605
+ end
1606
+
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.
1610
+ def widening_recorded?
1611
+ @widen_clauses && !(@on_enter.nil? && @operand_recorder.nil?)
1612
+ end
1613
+
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)
1625
+ end
1626
+
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?
1630
+
1631
+ without_jump_sinks { sub_eval(node, widened, next_scope_sink: nil) }
1632
+ end
1633
+
1634
+ # Runs `block` with no `return`, `next`, `break` or retry-frame sink installed.
1635
+ def without_jump_sinks
1636
+ previous = CLAUSE_SINK_KEYS.map { |key| Thread.current[key] }
1637
+ CLAUSE_SINK_KEYS.each { |key| Thread.current[key] = nil }
1638
+ yield
1639
+ ensure
1640
+ CLAUSE_SINK_KEYS.zip(previous).each { |key, value| Thread.current[key] = value } if previous
1506
1641
  end
1507
1642
 
1508
1643
  # The scope a rescue clause of `node` enters with: it runs after any prefix of the body, so neither a `$_`
@@ -1600,6 +1735,10 @@ module Rigor
1600
1735
  RETRY_FRAMES_KEY = :rigor_retry_frames
1601
1736
  private_constant :RETRY_FRAMES_KEY
1602
1737
 
1738
+ # The thread-local sinks a widened clause pass ({#without_jump_sinks}) leaves uninstalled.
1739
+ CLAUSE_SINK_KEYS = [RETURN_SINK_KEY, NEXT_SINK_KEY, BREAK_SINK_KEY, BREAK_VALUE_SINK_KEY, RETRY_FRAMES_KEY].freeze
1740
+ private_constant :CLAUSE_SINK_KEYS
1741
+
1603
1742
  # The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
1604
1743
  # no `retry` targets.
1605
1744
  def retry_edge_for(node)
@@ -1876,13 +2015,23 @@ module Rigor
1876
2015
  # each arm holding one. The latter still carries a rebind the `retry` itself does not see: a write an `ensure`
1877
2016
  # runs on the way out (`begin; retry; ensure; tries += 1; end`), or one made before a `retry` the evaluator only
1878
2017
  # types (`log(tries < 5 ? retry : :gave_up)`).
1879
- def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil)
2018
+ #
2019
+ # Issue #1231 — `writes` widens the scope each clause enters with, as {#eval_widened_clause} does, before its
2020
+ # reference is bound, so `rescue => e` still binds `e` to the exception. It is nil under a retry edge, which
2021
+ # already carries every scope the primary body raises from ({#widen_entry_for_retry}).
2022
+ def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil)
1880
2023
  on_enter = edge ? retry_scope_recorder(edge) : @on_enter
1881
2024
  results = []
1882
2025
  current = rescue_node
1883
2026
  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
1884
2029
  rescue_scope = bind_rescue_reference(current, entry_scope)
1885
- arm = eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
2030
+ arm = if widened
2031
+ sub_eval(current.statements, rescue_scope, **UNRECORDED)
2032
+ else
2033
+ eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
2034
+ end
1886
2035
  edge.retry_scopes << arm.last if edge&.retrying_arms&.include?(current)
1887
2036
  results << [arm, current]
1888
2037
  current = current.subsequent
@@ -1920,6 +2069,12 @@ module Rigor
1920
2069
  after.global(:$_) == Type::Combinator.untyped && !LastLine.may_set?(node.statements, scope)
1921
2070
  end
1922
2071
 
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
+
2076
+ def eval_repeating_for(node) = ReflectiveIvarWrite.in_repeating_body { eval_for(node) }
2077
+
1923
2078
  # `while pred; body; end` / `until pred; body; end`. The body might run zero or more times, so half-bound names
1924
2079
  # degrade to `T | nil` in the post-loop scope. The loop expression itself types as `Constant[nil]` (Slice 3 phase
1925
2080
  # 1), reflecting the common case where no `break VALUE` is observed.
@@ -1952,7 +2107,7 @@ module Rigor
1952
2107
  # the single-pass join before returning.
1953
2108
  if names.empty?
1954
2109
  fast = loop_content_writeback(node.statements, base_scope, pre_body: post_pred)
1955
- return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
2110
+ return [Type::Combinator.constant_of(nil), loop_exit(node, post_pred, fast)]
1956
2111
  end
1957
2112
 
1958
2113
  post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
@@ -1961,7 +2116,7 @@ module Rigor
1961
2116
  # :idle` under `end while state != :idle`) is dead in every converged pass. The single pass runs from the
1962
2117
  # un-narrowed post-predicate scope, so its arms stand in for that first iteration.
1963
2118
  post_loop = join_break_scopes(post_loop, first_breaks, names) if node.begin_modifier?
1964
- post_loop = narrow_loop_exit_edge(node, post_loop)
2119
+ post_loop = loop_exit(node, post_pred, post_loop)
1965
2120
  [Type::Combinator.constant_of(nil), post_loop]
1966
2121
  end
1967
2122
 
@@ -2048,6 +2203,12 @@ module Rigor
2048
2203
  join_break_scopes(post_loop, arms, names)
2049
2204
  end
2050
2205
 
2206
+ # A loop's continuation: a local its body and predicate only narrowed from `Dynamic[top]` re-joins to it
2207
+ # ({UntypedRejoin}, issue #1840), then the exit edge narrows.
2208
+ def loop_exit(node, post_pred, post_loop)
2209
+ narrow_loop_exit_edge(node, UntypedRejoin.apply(post_loop, post_pred, [node.predicate, node.statements]))
2210
+ end
2211
+
2051
2212
  # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred` loop exits PRECISELY on the predicate's
2052
2213
  # exit edge: `while` exits when `pred` is falsey, `until` when `pred` is truthy. So after the loop the
2053
2214
  # predicate-assignment target carries the exit polarity — `until line = io.gets; …; end; line.foo` reads `line`
@@ -2379,6 +2540,12 @@ module Rigor
2379
2540
  stores
2380
2541
  end
2381
2542
 
2543
+ # A `for` loop's continuation, with a local the body only narrowed from `Dynamic[top]` re-joined to it
2544
+ # ({UntypedRejoin}, issue #1840). The index is written on every iteration.
2545
+ def for_exit(node, post_coll, continuation)
2546
+ UntypedRejoin.apply(continuation, post_coll, [node.index, node.statements])
2547
+ end
2548
+
2382
2549
  # `for index in collection; body; end`. Unlike `each {}` blocks, `for` does NOT create a new variable scope: the
2383
2550
  # index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
2384
2551
  # once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
@@ -2405,7 +2572,7 @@ module Rigor
2405
2572
  continuation = join_with_nil_injection(post_coll, body_scope)
2406
2573
  pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
2407
2574
  continuation = join_break_scopes(continuation, breaks, pre_existing + body_first)
2408
- [Type::Combinator.constant_of(nil), continuation]
2575
+ [Type::Combinator.constant_of(nil), for_exit(node, post_coll, continuation)]
2409
2576
  end
2410
2577
 
2411
2578
  # Issue #1429 — a `for` body runs after the collection's `each`, which may be a project method, and after itself.
@@ -2565,7 +2732,7 @@ module Rigor
2565
2732
 
2566
2733
  # A dead RHS is still evaluated and its scope still joins, so a write inside it nil-injects exactly as
2567
2734
  # before; only its value is dropped, because it cannot be the value of the expression.
2568
- joined_scope = join_with_nil_injection(left_scope, right_scope)
2735
+ joined_scope = UntypedRejoin.apply(join_with_nil_injection(left_scope, right_scope), left_scope, [node.right])
2569
2736
  type = skipped_type
2570
2737
  type = Type::Combinator.union(skipped_type, right_type) unless right_operand_dead?(node, left_type, left_scope)
2571
2738
  return [type, joined_scope] unless edges
@@ -2772,6 +2939,7 @@ module Rigor
2772
2939
  # evaluates `def` to the method's name as a Symbol, so the produced type is `Constant[:name]`.
2773
2940
  def eval_def(node)
2774
2941
  body_scope = build_method_entry_scope(node)
2942
+ body_scope = body_scope.with_repeated_or_writes(RepeatedOrWrites.straight_line_sites(node.body, body_scope))
2775
2943
  # Parameter default value expressions (e.g. `self.x` in `def copy(x: self.x)`) execute when the method is
2776
2944
  # *invoked*, not when the `def` is read; their `self` is the instance receiver, not the surrounding class body.
2777
2945
  # Walk the parameters subtree under `body_scope` so the scope-index records the instance `self_type` for every
@@ -3013,7 +3181,10 @@ module Rigor
3013
3181
  # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
3014
3182
  # [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
3015
3183
  post_scope = invalidate_ivars_for_intervening_call(node, post_scope) if statement_call
3016
- # C1 — regex match-data globals (`$~`, `$1..$9`, `$&`, …) are narrowed to non-nil on a successful-match edge; a
3184
+ # Issue #1889 — `instance_variable_set` on `self` writes an ivar no write node shows. It runs after the reset
3185
+ # above, which would join a literal-name write back into the class seed.
3186
+ post_scope = ReflectiveIvarWrite.apply(node, post_scope) { type_operand(ReflectiveIvarWrite.value_node(node)) }
3187
+ # C1 — regex match-data globals (`$~`, `$1..$N`, `$&`, …) are narrowed to non-nil on a successful-match edge; a
3017
3188
  # later call that itself runs a regex match rebinds them, so the narrowed facts must be dropped. We forget them
3018
3189
  # only when the call may run a match in this frame ({#rebinds_match_globals?}). A call provably match-free on a
3019
3190
  # known receiver — `$3.to_i`, `year < 50` — does NOT clobber, so the multi-statement `m = /…/ =~ s; …; use($2)`
@@ -3034,7 +3205,13 @@ module Rigor
3034
3205
  post_scope = post_scope.forget_match_globals if rebinds_match_globals?(node, post_scope)
3035
3206
  post_scope = post_scope.forget_last_line if rebinds_last_line?(node, post_scope)
3036
3207
  if post_scope.guard_narrowed? && GuardRebinding.call_may_rebind?(node, operand_scope)
3037
- post_scope = post_scope.forget_guard_narrowings
3208
+ # Issue #1596 — a self-call proven to write only some instance variables restores only theirs.
3209
+ writes = intervening_call_candidate?(node) ? self_call_ivar_writes(node) : nil
3210
+ post_scope = if writes
3211
+ writes.reduce(post_scope) { |acc, name| acc.forget_guard_narrowing_of(:ivar, name) }
3212
+ else
3213
+ post_scope.forget_guard_narrowings
3214
+ end
3038
3215
  end
3039
3216
  forget_rescued_status(LastStatus.after(node, post_scope, scope), node)
3040
3217
  end
@@ -3159,6 +3336,7 @@ module Rigor
3159
3336
  # Returns a scope with each ivar's narrowed local binding widened back to its class-ivar seed value when the call
3160
3337
  # is one that could plausibly mutate ivars on the enclosing class (implicit-self or explicit `self.foo`).
3161
3338
  # External- receiver calls (`obj.method`) cannot reach the caller's ivars; they pass through unchanged.
3339
+ # Issue #1596 — where the call is proven to write only some ivars ({#self_call_ivar_writes}), only those widen.
3162
3340
  def invalidate_ivars_for_intervening_call(call_node, current_scope)
3163
3341
  return current_scope unless intervening_call_candidate?(call_node)
3164
3342
 
@@ -3173,11 +3351,27 @@ module Rigor
3173
3351
  local_type = current_scope.ivar(ivar_name)
3174
3352
  next if local_type.nil? || local_type == seed_type
3175
3353
 
3176
- widened = widened.with_ivar(ivar_name, Type::Combinator.union(local_type, seed_type))
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)
3177
3362
  end
3178
3363
  widened
3179
3364
  end
3180
3365
 
3366
+ # Issue #1596 — the ivars the implicit-self / `self.` call `call_node` may write, or nil when that is not proven
3367
+ # ({SelfCallIvarWrites.of}). Memoised per call node: the reset and the guard restore ask it of the same call.
3368
+ def self_call_ivar_writes(call_node)
3369
+ memo = (@self_call_ivar_writes ||= {}.compare_by_identity)
3370
+ return memo[call_node] if memo.key?(call_node)
3371
+
3372
+ memo[call_node] = SelfCallIvarWrites.of(call_node, operand_scope)
3373
+ end
3374
+
3181
3375
  def intervening_call_candidate?(call_node)
3182
3376
  return false unless call_node.is_a?(Prism::CallNode)
3183
3377
 
@@ -3542,7 +3736,9 @@ module Rigor
3542
3736
  end
3543
3737
 
3544
3738
  def enter_call_block(node, block)
3545
- block_entry = narrow_define_method_block_self(node, repeating_block_entry(node, block))
3739
+ block_entry = narrow_define_method_block_self(
3740
+ node, with_repeated_or_write_marks(node, block, repeating_block_entry(node, block))
3741
+ )
3546
3742
  # #319 — `Class.new do ... end` and friends evaluate their block as a CLASS BODY (`class_eval`
3547
3743
  # semantics): `self` is the freshly created class, so a `def` inside defines an instance method on it
3548
3744
  # and `attr_reader` runs as a class-level macro. Enter the block under the same `self_type` /
@@ -4142,7 +4338,7 @@ module Rigor
4142
4338
  # The `break` scopes of one unrecorded pass from `converged` — for a capped fixpoint, whose widened binding no
4143
4339
  # pass has run from.
4144
4340
  def converged_break_arms(call_node, block, converged, targets)
4145
- entry = block_pass_entry(call_node, block, converged)
4341
+ entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, converged))
4146
4342
  sink, = collect_break_scopes { sub_eval(block, entry, **UNRECORDED) }
4147
4343
  targeted_scopes(sink, targets)
4148
4344
  end
@@ -4942,7 +5138,8 @@ module Rigor
4942
5138
  # params / `;`-locals re-bound as usual) and returns the per-name exit binding for `names`. Used as the
4943
5139
  # `BodyFixpoint` body-evaluator.
4944
5140
  def block_exit_bindings(call_node, block, bindings, names)
4945
- _type, exit_scope = sub_eval(block, block_pass_entry(call_node, block, bindings))
5141
+ entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, bindings))
5142
+ _type, exit_scope = sub_eval(block, entry)
4946
5143
  names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
4947
5144
  end
4948
5145
 
@@ -4958,6 +5155,38 @@ module Rigor
4958
5155
  end
4959
5156
  end
4960
5157
 
5158
+ # Issue #1336 — `entry` with the index `||=` sites of a repeating block marked as the generic block-return pass
5159
+ # marks them ({RepeatedOrWrites}, `Scope#with_repeated_or_writes`). The statement pass and every ADR-56
5160
+ # write-back pass type the body from one entry scope just as that pass does, so a slot an earlier run stored
5161
+ # into reads as a lone `Dynamic` there too, and the memoizing reading ({#index_compound_write_value}) answered
5162
+ # one site's rvalue: `last = (cache[:k] ||= :f).itself` beside a `cache[:k] ||= :e` wrote `:f` alone back
5163
+ # into `last`, and `last == :e` folded always-falsey. A call that runs its block at most once marks nothing.
5164
+ def with_repeated_or_write_marks(call_node, block, entry)
5165
+ sites = repeated_or_write_sites(call_node, block)
5166
+ sites.empty? ? entry : entry.with_repeated_or_writes(sites)
5167
+ end
5168
+
5169
+ # The sites {#with_repeated_or_write_marks} lays, once per block: a body that spells no `||=` — nearly every
5170
+ # one — answers none without the repetition question.
5171
+ def repeated_or_write_sites(call_node, block)
5172
+ memo = (@repeated_or_write_sites ||= {}.compare_by_identity)
5173
+ return memo[block] if memo.key?(block)
5174
+
5175
+ memo[block] = compute_repeated_or_write_sites(call_node, block)
5176
+ end
5177
+
5178
+ def compute_repeated_or_write_sites(call_node, block)
5179
+ body = block.body
5180
+ return NO_REPEATED_OR_WRITES if body.nil? || !RepeatedOrWrites.may_hold_or_write?(body)
5181
+ return NO_REPEATED_OR_WRITES if repeating_block_class(call_node).nil?
5182
+
5183
+ stores = CapturedLocals.content_mutations(block, scope, non_locals: true)
5184
+ RepeatedOrWrites.sites(block, stores, scope).at(nil)
5185
+ end
5186
+
5187
+ NO_REPEATED_OR_WRITES = [].freeze
5188
+ private_constant :NO_REPEATED_OR_WRITES
5189
+
4961
5190
  NO_CAPTURE_BINDINGS = {}.freeze
4962
5191
  private_constant :NO_CAPTURE_BINDINGS
4963
5192
 
@@ -5220,7 +5449,7 @@ module Rigor
5220
5449
  return [] if receiver_type.nil?
5221
5450
 
5222
5451
  arg_types = call_arg_types_for(call_node)
5223
- MethodDispatcher.expected_block_param_types(
5452
+ expected = MethodDispatcher.expected_block_param_types(
5224
5453
  receiver_type: receiver_type,
5225
5454
  method_name: call_node.name,
5226
5455
  arg_types: arg_types,
@@ -5228,10 +5457,28 @@ module Rigor
5228
5457
  scope: scope,
5229
5458
  call_node: call_node
5230
5459
  )
5460
+ with_inject_accumulator(call_node, receiver_type, arg_types, expected)
5231
5461
  rescue StandardError
5232
5462
  []
5233
5463
  end
5234
5464
 
5465
+ # Issue #1889 — a block-form `inject` / `reduce` call's accumulator parameter at the type it holds across
5466
+ # every run ({ExpressionTyper#inject_accumulator_type}) rather than the seed the signature binds it to, once
5467
+ # per call: every write-back pass builds the entry again.
5468
+ def with_inject_accumulator(call_node, receiver_type, arg_types, expected)
5469
+ memo = @inject_accumulator
5470
+ accumulator =
5471
+ if memo && memo[0].equal?(call_node)
5472
+ memo[1]
5473
+ else
5474
+ typed = ExpressionTyper.new(scope: scope, tracer: tracer)
5475
+ .inject_accumulator_type(call_node, receiver_type, arg_types)
5476
+ @inject_accumulator = [call_node, typed]
5477
+ typed
5478
+ end
5479
+ accumulator ? [accumulator, *expected.drop(1)] : expected
5480
+ end
5481
+
5235
5482
  def call_arg_types_for(call_node)
5236
5483
  arguments = call_node.arguments
5237
5484
  return [] if arguments.nil?
@@ -5662,6 +5909,7 @@ module Rigor
5662
5909
  class_context: class_context,
5663
5910
  lexical_nesting: lexical_nesting,
5664
5911
  converged_loop_recording: @converged_loop_recording,
5912
+ widen_clauses: @widen_clauses,
5665
5913
  next_scope_sink: next_scope_sink,
5666
5914
  operand_scope: operand_scope,
5667
5915
  in_operand: in_operand,
@@ -5956,8 +6204,7 @@ module Rigor
5956
6204
  # it is frozen, and the dispatcher already projects it (see `DataFolding`).
5957
6205
  def struct_instance_projection(subject_type, method_name, scope, subject_node)
5958
6206
  return nil unless subject_type.is_a?(Type::StructInstance)
5959
- return nil unless MethodDispatcher::StructMaterialization.materialization_call?(subject_node, subject_type,
5960
- scope)
6207
+ return nil unless MethodDispatcher::StructMaterialization.materialization_call?(subject_node, scope)
5961
6208
 
5962
6209
  case method_name
5963
6210
  when :deconstruct then Type::Combinator.tuple_of(*subject_type.members.values)