rigortype 0.4.2 → 0.4.3

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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/docs/handbook/11-sig-gen.md +12 -5
  4. data/docs/manual/02-cli-reference.md +19 -1
  5. data/docs/manual/03-configuration.md +6 -4
  6. data/docs/manual/05-inspecting-types.md +50 -0
  7. data/docs/manual/12-caching.md +18 -1
  8. data/docs/manual/14-rails-quickstart.md +2 -2
  9. data/docs/manual/18-removing-dead-code.md +16 -9
  10. data/lib/rigor/analysis/check_rules/rule_ids.rb +7 -0
  11. data/lib/rigor/analysis/check_rules.rb +23 -22
  12. data/lib/rigor/analysis/effects_cache_probe.rb +1 -1
  13. data/lib/rigor/analysis/incremental_run_slot.rb +3 -3
  14. data/lib/rigor/analysis/incremental_session.rb +89 -22
  15. data/lib/rigor/analysis/path_expansion.rb +62 -6
  16. data/lib/rigor/analysis/plugin_fact_fingerprint.rb +124 -37
  17. data/lib/rigor/analysis/reachability/graph.rb +119 -15
  18. data/lib/rigor/analysis/reachability/scan.rb +332 -173
  19. data/lib/rigor/analysis/reachability/scan_cache.rb +4 -2
  20. data/lib/rigor/analysis/run_cache_probe.rb +1 -1
  21. data/lib/rigor/analysis/run_stats.rb +28 -6
  22. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +26 -1
  23. data/lib/rigor/analysis/runner/pool_coordinator.rb +28 -9
  24. data/lib/rigor/analysis/runner/project_pre_passes.rb +21 -1
  25. data/lib/rigor/analysis/runner.rb +29 -11
  26. data/lib/rigor/analysis/worker_session.rb +1 -1
  27. data/lib/rigor/cache/engine_source.rb +21 -7
  28. data/lib/rigor/cache/incremental_snapshot.rb +36 -8
  29. data/lib/rigor/cli/check_command.rb +138 -39
  30. data/lib/rigor/cli/docs_command.rb +5 -4
  31. data/lib/rigor/cli/lens_command.rb +144 -0
  32. data/lib/rigor/cli/lsp_command.rb +5 -2
  33. data/lib/rigor/cli/skill_command.rb +3 -3
  34. data/lib/rigor/cli/skill_describe.rb +1 -1
  35. data/lib/rigor/cli/type_of_command.rb +2 -1
  36. data/lib/rigor/cli/unused_command.rb +157 -24
  37. data/lib/rigor/cli.rb +30 -4
  38. data/lib/rigor/configuration.rb +15 -0
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +11 -7
  40. data/lib/rigor/environment/installed_gem_set.rb +38 -11
  41. data/lib/rigor/environment/lockfile_resolver.rb +37 -6
  42. data/lib/rigor/environment/missing_gem_constant_index.rb +22 -7
  43. data/lib/rigor/environment/rbs_loader.rb +33 -0
  44. data/lib/rigor/environment/required_features.rb +4 -2
  45. data/lib/rigor/environment.rb +12 -5
  46. data/lib/rigor/inference/builtins/cruby_definers.rb +22 -5
  47. data/lib/rigor/inference/captured_locals.rb +43 -3
  48. data/lib/rigor/inference/expression_typer.rb +58 -25
  49. data/lib/rigor/inference/in_effect_refinements/scope_reads.rb +21 -17
  50. data/lib/rigor/inference/match_rebinding.rb +1 -1
  51. data/lib/rigor/inference/method_dispatcher/argument_nodes.rb +25 -0
  52. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +3 -2
  53. data/lib/rigor/inference/method_dispatcher/match_data_folding.rb +2 -2
  54. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +3 -3
  55. data/lib/rigor/inference/method_dispatcher/optional_count.rb +63 -0
  56. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -16
  57. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +4 -7
  58. data/lib/rigor/inference/method_dispatcher/struct_materialization.rb +8 -26
  59. data/lib/rigor/inference/method_dispatcher/unit_denominator_rational.rb +67 -0
  60. data/lib/rigor/inference/method_dispatcher.rb +52 -3
  61. data/lib/rigor/inference/project_patched_definedness_guard.rb +86 -0
  62. data/lib/rigor/inference/project_patched_methods.rb +2 -2
  63. data/lib/rigor/inference/project_patched_scanner.rb +38 -24
  64. data/lib/rigor/inference/refined_dispatch/class_object.rb +350 -0
  65. data/lib/rigor/inference/refined_dispatch/own_method.rb +112 -40
  66. data/lib/rigor/inference/refined_dispatch.rb +62 -14
  67. data/lib/rigor/inference/repeated_or_writes.rb +2 -0
  68. data/lib/rigor/inference/scope_indexer.rb +140 -5
  69. data/lib/rigor/inference/statement_evaluator.rb +223 -31
  70. data/lib/rigor/inference/untyped_rejoin.rb +75 -0
  71. data/lib/rigor/language_server/project_context.rb +56 -5
  72. data/lib/rigor/lens/anchor.rb +115 -0
  73. data/lib/rigor/lens/contract_reader.rb +118 -0
  74. data/lib/rigor/lens/document.rb +113 -0
  75. data/lib/rigor/lens/file_reader.rb +433 -0
  76. data/lib/rigor/lens/members.rb +153 -0
  77. data/lib/rigor/lens/parameters.rb +91 -0
  78. data/lib/rigor/lens/plugins.rb +40 -0
  79. data/lib/rigor/lens/run.rb +280 -0
  80. data/lib/rigor/lens/slot.rb +79 -0
  81. data/lib/rigor/lens.rb +40 -0
  82. data/lib/rigor/project_environment.rb +20 -1
  83. data/lib/rigor/protection/mutation_cache.rb +2 -1
  84. data/lib/rigor/scope.rb +22 -11
  85. data/lib/rigor/sig_gen/generator.rb +45 -5
  86. data/lib/rigor/version.rb +1 -1
  87. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers/serializer_discoverer.rb +29 -3
  88. data/plugins/rigor-active-model-serializers/lib/rigor/plugin/active_model_serializers.rb +5 -1
  89. data/sig/rigor/scope.rbs +3 -0
  90. data/sig/rigor.rbs +6 -3
  91. data/skills/rigor-project-init/references/02-configure.md +2 -2
  92. metadata +18 -1
@@ -42,8 +42,10 @@ require_relative "operand_effects"
42
42
  require_relative "operand_walk"
43
43
  require_relative "optimistic_origin"
44
44
  require_relative "return_barrier"
45
+ require_relative "repeated_or_writes"
45
46
  require_relative "rewrite_mutation"
46
47
  require_relative "unknown_store_widening"
48
+ require_relative "untyped_rejoin"
47
49
  require_relative "version_guard"
48
50
 
49
51
  module Rigor
@@ -278,11 +280,15 @@ module Rigor
278
280
  # a declined `class self::X`, a parse-error header), where `class_context` is empty without the code
279
281
  # being at the top level. A `def` (or `class << self`) there runs under an untyped `self` instead of
280
282
  # the top level's nil one, so its implicit-self calls answer `Dynamic` and no top-level rule fires.
283
+ # @param widen_clauses — issue #1231: when true (and a recorder is installed), a rescue or `ensure` clause
284
+ # records its per-node entry scopes under {#widen_clause_entry}'s widening. The check path sets it; every
285
+ # other reader of the index (parameter inference, sig-gen, the scanners) keeps the clause's plain entry.
281
286
  def initialize(scope:, tracer: nil, on_enter: nil, class_context: [].freeze, # rubocop:disable Metrics/ParameterLists
282
287
  lexical_nesting: EMPTY_NESTING, converged_loop_recording: false, next_scope_sink: nil,
283
288
  operand_scope: nil, in_operand: false, operand_recorder: nil, operand_types: nil,
284
- opaque_class: false)
289
+ opaque_class: false, widen_clauses: false)
285
290
  @scope = scope
291
+ @widen_clauses = widen_clauses
286
292
  @opaque_class = opaque_class
287
293
  @tracer = tracer
288
294
  @on_enter = on_enter
@@ -484,9 +490,10 @@ module Rigor
484
490
  # `opts[k] ||= raise KeyError` is a guard, never `bot`, and `@flags[n] ||= false` is `true` after an
485
491
  # `@flags[n] = true` elsewhere, never provably `false`.
486
492
  #
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.
493
+ # Nor is a marked site (`Scope#repeated_or_write?`, {RepeatedOrWrites}): a block-return pass, and this
494
+ # evaluator's statement and write-back passes ({#with_repeated_or_write_marks}), type every run of a repeating
495
+ # body from one entry scope, so its slot's gradual type may be what an EARLIER run stored rather than an
496
+ # absence of evidence.
490
497
  def index_compound_write_value(node)
491
498
  return index_write_stored_type(node, scope) unless node.is_a?(Prism::IndexOrWriteNode)
492
499
 
@@ -1180,7 +1187,8 @@ module Rigor
1180
1187
 
1181
1188
  [
1182
1189
  Type::Combinator.union(then_type, else_type),
1183
- join_with_nil_injection(then_scope, else_scope)
1190
+ UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred,
1191
+ [node.statements, node.subsequent])
1184
1192
  ]
1185
1193
  end
1186
1194
 
@@ -1215,7 +1223,8 @@ module Rigor
1215
1223
 
1216
1224
  [
1217
1225
  Type::Combinator.union(then_type, else_type),
1218
- join_with_nil_injection(then_scope, else_scope)
1226
+ UntypedRejoin.apply(join_with_nil_injection(then_scope, else_scope), post_pred,
1227
+ [node.statements, node.else_clause])
1219
1228
  ]
1220
1229
  end
1221
1230
 
@@ -1286,7 +1295,7 @@ module Rigor
1286
1295
  subject_type, post_pred = node.predicate ? sub_eval(node.predicate, scope) : [nil, scope]
1287
1296
  branch_results, falsey_scope = eval_case_when_branches(subject_type, node.predicate, node.conditions, post_pred)
1288
1297
  if pattern_case_matches_every_path?(node, branch_results)
1289
- return unmatched_pattern_result(branch_results, node.conditions)
1298
+ return unmatched_pattern_result(branch_results, node.conditions, post_pred)
1290
1299
  end
1291
1300
 
1292
1301
  else_result = eval_case_else(node.else_clause, falsey_scope)
@@ -1295,7 +1304,7 @@ module Rigor
1295
1304
  branch_nodes = [*node.conditions, node.else_clause]
1296
1305
  [
1297
1306
  Type::Combinator.union(*all_results.map(&:first)),
1298
- join_case_branch_scopes(all_results, branch_nodes)
1307
+ join_case_branch_scopes(all_results, branch_nodes, post_pred)
1299
1308
  ]
1300
1309
  end
1301
1310
 
@@ -1310,8 +1319,9 @@ module Rigor
1310
1319
  node.is_a?(Prism::CaseMatchNode) && node.else_clause.nil? && !branch_results.empty?
1311
1320
  end
1312
1321
 
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)]
1322
+ def unmatched_pattern_result(branch_results, branch_nodes, entry)
1323
+ [Type::Combinator.union(*branch_results.map(&:first)),
1324
+ join_case_branch_scopes(branch_results, branch_nodes, entry)]
1315
1325
  end
1316
1326
 
1317
1327
  # Joins the post-scopes of every `when`/`in`/`else` branch, dropping the scope of any branch that terminates
@@ -1320,15 +1330,16 @@ module Rigor
1320
1330
  # `branch_terminates?` rule `eval_if`/`eval_unless` already apply to the if/else merge: e.g. `case x; when 1 then
1321
1331
  # v="a"; when 2 then v="b"; else raise; end` keeps `v: "a" | "b"` instead of `... | nil`. When every branch
1322
1332
  # 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)
1333
+ # well-formed. A local the clauses only narrowed from `Dynamic[top]` re-joins to it ({UntypedRejoin}, issue
1334
+ # #1840), read against the post-predicate `entry`.
1335
+ def join_case_branch_scopes(results, nodes, entry)
1325
1336
  live = []
1326
1337
  results.each_with_index do |(type, branch_scope), i|
1327
1338
  live << branch_scope unless branch_terminates?(nodes[i], type)
1328
1339
  end
1329
1340
 
1330
1341
  live = results.map(&:last) if live.empty?
1331
- reduce_scopes_with_nil_injection(live)
1342
+ UntypedRejoin.apply(reduce_scopes_with_nil_injection(live), entry, nodes)
1332
1343
  end
1333
1344
 
1334
1345
  def eval_case_when_branches(subject_type, subject, conditions, entry_scope)
@@ -1477,7 +1488,8 @@ module Rigor
1477
1488
  # prefix of the body, and so reads it forgotten whenever the body may set it.
1478
1489
  entry = edge ? forget_guard_if_rebinds(LastLine.forget_if_set(scope, node), node) : scope
1479
1490
  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)
1491
+ rescue_chain = collect_rescue_chain_results(node.rescue_clause, rescue_entry_scope(node, entry),
1492
+ edge: edge, writes: rescue_writes(node, edge))
1481
1493
 
1482
1494
  # B2.1 — retry-edge widening. When a `retry` in the rescue chain targets this `begin`, control re-enters the
1483
1495
  # primary body carrying every rebind made before the retry: the arm's (`rescue; tries += 1; retry; end`), and
@@ -1496,13 +1508,132 @@ module Rigor
1496
1508
  exit_scope = reduce_scopes_with_nil_injection([primary_scope, *live_rescues.map(&:last)])
1497
1509
  end
1498
1510
 
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
1511
+ [exit_type, node.ensure_clause ? eval_begin_ensure(node, exit_scope, jump_marks) : exit_scope]
1512
+ end
1513
+
1514
+ # The scope past `node`'s `ensure` clause, entered with `exit_scope`, the `next` and `break` scopes recorded past
1515
+ # `jump_marks` carried through it.
1516
+ def eval_begin_ensure(node, exit_scope, jump_marks)
1517
+ carry_jumps_through_ensure(node.ensure_clause, jump_marks)
1518
+ eval_widened_clause(node.ensure_clause, node.ensure_clause.statements, exit_scope, ensure_writes(node)).last
1519
+ end
1520
+
1521
+ # Issue #1231 — a rescue clause runs after the body raised from any point in it, and an `ensure` clause after the
1522
+ # body, its `else` or a rescue clause raised as well as after they finished, so a clause reads each local or
1523
+ # instance variable those wrote at a value the scope it enters with does not hold: `conn = nil; begin; conn =
1524
+ # open_conn; Integer(s); rescue; conn.size; end` reads the connection. The engine does not track where the body
1525
+ # raises (the precise recorder of #1397 put new false positives on correct code); it widens instead, and
1526
+ # deliberately imprecisely. Each name the clause mentions ({#clause_mentions}) that its entry binds and those
1527
+ # clauses write, outside a nested `def` or class body, reads as `Dynamic[top]`, which reports nothing. A plain
1528
+ # join would read `x` as `1?` in `x = 1; begin; foo; x = nil; rescue; x.succ; end`, where it cannot be nil, and a
1529
+ # `Dynamic` that keeps the join as its facet still narrows: `x.nil?` on `Dynamic[1 | 2]` leaves a `nil` member
1530
+ # for the next call to report. A name written inside a nested `ensure` clause, which also runs on the way out of
1531
+ # a raise, is covered by the same floor.
1532
+ #
1533
+ # The names the clauses of `node` read from: the primary body's for a rescue clause, none under a retry `edge`.
1534
+ def rescue_writes(node, edge)
1535
+ clause_writes([node.statements]) if node.rescue_clause && edge.nil? && widening_recorded?
1536
+ end
1537
+
1538
+ # For the `ensure` clause: the writes of the body, its `else` and every rescue clause.
1539
+ def ensure_writes(node)
1540
+ return nil unless widening_recorded?
1541
+
1542
+ sources = [node.statements, node.else_clause]
1543
+ current = node.rescue_clause
1544
+ while current
1545
+ sources << current
1546
+ current = current.subsequent
1503
1547
  end
1548
+ clause_writes(sources)
1549
+ end
1550
+
1551
+ # The local and instance variable names `sources` write, a nested block's included (it may write an outer local),
1552
+ # or nil for none. A `def` or class body writes nothing here.
1553
+ def clause_writes(sources)
1554
+ writes = nil
1555
+ sources.each { |source| writes = collect_clause_names(source, CLAUSE_WRITE_NODES, nil, writes) if source }
1556
+ writes
1557
+ end
1558
+
1559
+ CLAUSE_WRITE_NODES = Set[
1560
+ *CapturedLocals::LOCAL_WRITE_NODES,
1561
+ Prism::InstanceVariableWriteNode,
1562
+ Prism::InstanceVariableOperatorWriteNode,
1563
+ Prism::InstanceVariableOrWriteNode,
1564
+ Prism::InstanceVariableAndWriteNode,
1565
+ Prism::InstanceVariableTargetNode
1566
+ ].freeze
1567
+ private_constant :CLAUSE_WRITE_NODES
1568
+
1569
+ CLAUSE_MENTION_NODES = Set[
1570
+ Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode, *CLAUSE_WRITE_NODES
1571
+ ].freeze
1572
+ private_constant :CLAUSE_MENTION_NODES
1573
+
1574
+ # The names of the `node_classes` nodes under `node` (restricted to `among` when given) added to `found`, outside
1575
+ # a nested `def` or class body.
1576
+ def collect_clause_names(node, node_classes, among, found)
1577
+ return found if SCOPE_BODY_NODES.any? { |klass| node.is_a?(klass) }
1578
+
1579
+ if node_classes.include?(node.class) && (among.nil? || among.include?(node.name))
1580
+ (found ||= Set.new) << node.name
1581
+ end
1582
+ node.rigor_each_child { |child| found = collect_clause_names(child, node_classes, among, found) }
1583
+ found
1584
+ end
1585
+
1586
+ # `clause_scope` with each name of `writes` that `statements` mentions, and `clause_scope` binds, rebound to
1587
+ # `Dynamic[top]`, or nil when none changes.
1588
+ def widen_clause_entry(clause_scope, statements, writes)
1589
+ return nil if writes.nil? || statements.nil? || !widening_recorded?
1590
+
1591
+ names = collect_clause_names(statements, CLAUSE_MENTION_NODES, writes, nil)
1592
+ return nil if names.nil?
1593
+
1594
+ untyped = Type::Combinator.untyped
1595
+ widened = names.reduce(clause_scope) do |acc, name|
1596
+ kind = name.start_with?("@") ? :ivar : :local
1597
+ current = clause_scope.public_send(VAR_KIND_GETTERS.fetch(kind), name)
1598
+ current.nil? || current == untyped ? acc : rebind_variable(acc, kind, name, untyped)
1599
+ end
1600
+ widened.equal?(clause_scope) ? nil : widened
1601
+ end
1504
1602
 
1505
- [exit_type, exit_scope]
1603
+ # Whether a widened pass would record anything: only the check path asks for one ({#initialize}'s
1604
+ # `widen_clauses`), and a pass nothing records is skipped, so a nested clause inside an unrecorded pass is
1605
+ # evaluated once.
1606
+ def widening_recorded?
1607
+ @widen_clauses && !(@on_enter.nil? && @operand_recorder.nil?)
1608
+ end
1609
+
1610
+ # `[type, scope]` of a rescue or `ensure` clause entered with `clause_scope`. When {#widen_clause_entry} widens
1611
+ # it, the clause is evaluated twice: under the widened entry for the per-node index, which is what the check
1612
+ # rules read, and under `clause_scope` without recording, for the value and the scope the code after the
1613
+ # `begin` continues from. The widened pass collects no `return`, `next`, `break` or retry-edge scope, so the
1614
+ # widening reaches neither an inferred return nor a loop: it can only take diagnostics away inside the clause.
1615
+ def eval_widened_clause(node, statements, clause_scope, writes)
1616
+ widened = widen_clause_entry(clause_scope, statements, writes)
1617
+ return sub_eval(node, clause_scope) unless widened
1618
+
1619
+ eval_widened_pass(node, widened)
1620
+ sub_eval(node, clause_scope, **UNRECORDED)
1621
+ end
1622
+
1623
+ # The recorded pass over a widened clause: it reaches no `return`, `next`, `break` or retry-frame sink.
1624
+ def eval_widened_pass(node, widened)
1625
+ return if node.nil?
1626
+
1627
+ without_jump_sinks { sub_eval(node, widened, next_scope_sink: nil) }
1628
+ end
1629
+
1630
+ # Runs `block` with no `return`, `next`, `break` or retry-frame sink installed.
1631
+ def without_jump_sinks
1632
+ previous = CLAUSE_SINK_KEYS.map { |key| Thread.current[key] }
1633
+ CLAUSE_SINK_KEYS.each { |key| Thread.current[key] = nil }
1634
+ yield
1635
+ ensure
1636
+ CLAUSE_SINK_KEYS.zip(previous).each { |key, value| Thread.current[key] = value } if previous
1506
1637
  end
1507
1638
 
1508
1639
  # The scope a rescue clause of `node` enters with: it runs after any prefix of the body, so neither a `$_`
@@ -1600,6 +1731,10 @@ module Rigor
1600
1731
  RETRY_FRAMES_KEY = :rigor_retry_frames
1601
1732
  private_constant :RETRY_FRAMES_KEY
1602
1733
 
1734
+ # The thread-local sinks a widened clause pass ({#without_jump_sinks}) leaves uninstalled.
1735
+ CLAUSE_SINK_KEYS = [RETURN_SINK_KEY, NEXT_SINK_KEY, BREAK_SINK_KEY, BREAK_VALUE_SINK_KEY, RETRY_FRAMES_KEY].freeze
1736
+ private_constant :CLAUSE_SINK_KEYS
1737
+
1603
1738
  # The retry edge of `node`, or nil when no `retry` in its rescue chain targets it. Allocation-free for a `begin`
1604
1739
  # no `retry` targets.
1605
1740
  def retry_edge_for(node)
@@ -1876,13 +2011,23 @@ module Rigor
1876
2011
  # each arm holding one. The latter still carries a rebind the `retry` itself does not see: a write an `ensure`
1877
2012
  # runs on the way out (`begin; retry; ensure; tries += 1; end`), or one made before a `retry` the evaluator only
1878
2013
  # types (`log(tries < 5 ? retry : :gave_up)`).
1879
- def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil)
2014
+ #
2015
+ # Issue #1231 — `writes` widens the scope each clause enters with, as {#eval_widened_clause} does, before its
2016
+ # reference is bound, so `rescue => e` still binds `e` to the exception. It is nil under a retry edge, which
2017
+ # already carries every scope the primary body raises from ({#widen_entry_for_retry}).
2018
+ def collect_rescue_chain_results(rescue_node, entry_scope, edge: nil, writes: nil)
1880
2019
  on_enter = edge ? retry_scope_recorder(edge) : @on_enter
1881
2020
  results = []
1882
2021
  current = rescue_node
1883
2022
  while current
2023
+ widened = widen_clause_entry(entry_scope, current.statements, writes)
2024
+ eval_widened_pass(current.statements, bind_rescue_reference(current, widened)) if widened
1884
2025
  rescue_scope = bind_rescue_reference(current, entry_scope)
1885
- arm = eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
2026
+ arm = if widened
2027
+ sub_eval(current.statements, rescue_scope, **UNRECORDED)
2028
+ else
2029
+ eval_branch_or_nil(current.statements, rescue_scope, on_enter: on_enter)
2030
+ end
1886
2031
  edge.retry_scopes << arm.last if edge&.retrying_arms&.include?(current)
1887
2032
  results << [arm, current]
1888
2033
  current = current.subsequent
@@ -1952,7 +2097,7 @@ module Rigor
1952
2097
  # the single-pass join before returning.
1953
2098
  if names.empty?
1954
2099
  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)]
2100
+ return [Type::Combinator.constant_of(nil), loop_exit(node, post_pred, fast)]
1956
2101
  end
1957
2102
 
1958
2103
  post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first, jumps)
@@ -1961,7 +2106,7 @@ module Rigor
1961
2106
  # :idle` under `end while state != :idle`) is dead in every converged pass. The single pass runs from the
1962
2107
  # un-narrowed post-predicate scope, so its arms stand in for that first iteration.
1963
2108
  post_loop = join_break_scopes(post_loop, first_breaks, names) if node.begin_modifier?
1964
- post_loop = narrow_loop_exit_edge(node, post_loop)
2109
+ post_loop = loop_exit(node, post_pred, post_loop)
1965
2110
  [Type::Combinator.constant_of(nil), post_loop]
1966
2111
  end
1967
2112
 
@@ -2048,6 +2193,12 @@ module Rigor
2048
2193
  join_break_scopes(post_loop, arms, names)
2049
2194
  end
2050
2195
 
2196
+ # A loop's continuation: a local its body and predicate only narrowed from `Dynamic[top]` re-joins to it
2197
+ # ({UntypedRejoin}, issue #1840), then the exit edge narrows.
2198
+ def loop_exit(node, post_pred, post_loop)
2199
+ narrow_loop_exit_edge(node, UntypedRejoin.apply(post_loop, post_pred, [node.predicate, node.statements]))
2200
+ end
2201
+
2051
2202
  # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred` loop exits PRECISELY on the predicate's
2052
2203
  # exit edge: `while` exits when `pred` is falsey, `until` when `pred` is truthy. So after the loop the
2053
2204
  # predicate-assignment target carries the exit polarity — `until line = io.gets; …; end; line.foo` reads `line`
@@ -2379,6 +2530,12 @@ module Rigor
2379
2530
  stores
2380
2531
  end
2381
2532
 
2533
+ # A `for` loop's continuation, with a local the body only narrowed from `Dynamic[top]` re-joined to it
2534
+ # ({UntypedRejoin}, issue #1840). The index is written on every iteration.
2535
+ def for_exit(node, post_coll, continuation)
2536
+ UntypedRejoin.apply(continuation, post_coll, [node.index, node.statements])
2537
+ end
2538
+
2382
2539
  # `for index in collection; body; end`. Unlike `each {}` blocks, `for` does NOT create a new variable scope: the
2383
2540
  # index variable AND every local written in the body leak to the surrounding scope. The collection is evaluated
2384
2541
  # once; the body runs zero or more times, so the post-loop scope is the join of the no-iteration scope (just
@@ -2405,7 +2562,7 @@ module Rigor
2405
2562
  continuation = join_with_nil_injection(post_coll, body_scope)
2406
2563
  pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
2407
2564
  continuation = join_break_scopes(continuation, breaks, pre_existing + body_first)
2408
- [Type::Combinator.constant_of(nil), continuation]
2565
+ [Type::Combinator.constant_of(nil), for_exit(node, post_coll, continuation)]
2409
2566
  end
2410
2567
 
2411
2568
  # Issue #1429 — a `for` body runs after the collection's `each`, which may be a project method, and after itself.
@@ -2565,7 +2722,7 @@ module Rigor
2565
2722
 
2566
2723
  # A dead RHS is still evaluated and its scope still joins, so a write inside it nil-injects exactly as
2567
2724
  # 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)
2725
+ joined_scope = UntypedRejoin.apply(join_with_nil_injection(left_scope, right_scope), left_scope, [node.right])
2569
2726
  type = skipped_type
2570
2727
  type = Type::Combinator.union(skipped_type, right_type) unless right_operand_dead?(node, left_type, left_scope)
2571
2728
  return [type, joined_scope] unless edges
@@ -3013,7 +3170,7 @@ module Rigor
3013
3170
  # observes the seed's union (not the pre-call narrowed value). Always-safe (only widens; no new facts). See
3014
3171
  # [`docs/CURRENT_WORK.md`](../../../docs/CURRENT_WORK.md) § "Flow-folding" — G2 intervening-call case.
3015
3172
  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
3173
+ # C1 — regex match-data globals (`$~`, `$1..$N`, `$&`, …) are narrowed to non-nil on a successful-match edge; a
3017
3174
  # later call that itself runs a regex match rebinds them, so the narrowed facts must be dropped. We forget them
3018
3175
  # only when the call may run a match in this frame ({#rebinds_match_globals?}). A call provably match-free on a
3019
3176
  # known receiver — `$3.to_i`, `year < 50` — does NOT clobber, so the multi-statement `m = /…/ =~ s; …; use($2)`
@@ -3542,7 +3699,9 @@ module Rigor
3542
3699
  end
3543
3700
 
3544
3701
  def enter_call_block(node, block)
3545
- block_entry = narrow_define_method_block_self(node, repeating_block_entry(node, block))
3702
+ block_entry = narrow_define_method_block_self(
3703
+ node, with_repeated_or_write_marks(node, block, repeating_block_entry(node, block))
3704
+ )
3546
3705
  # #319 — `Class.new do ... end` and friends evaluate their block as a CLASS BODY (`class_eval`
3547
3706
  # semantics): `self` is the freshly created class, so a `def` inside defines an instance method on it
3548
3707
  # and `attr_reader` runs as a class-level macro. Enter the block under the same `self_type` /
@@ -4142,7 +4301,7 @@ module Rigor
4142
4301
  # The `break` scopes of one unrecorded pass from `converged` — for a capped fixpoint, whose widened binding no
4143
4302
  # pass has run from.
4144
4303
  def converged_break_arms(call_node, block, converged, targets)
4145
- entry = block_pass_entry(call_node, block, converged)
4304
+ entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, converged))
4146
4305
  sink, = collect_break_scopes { sub_eval(block, entry, **UNRECORDED) }
4147
4306
  targeted_scopes(sink, targets)
4148
4307
  end
@@ -4942,7 +5101,8 @@ module Rigor
4942
5101
  # params / `;`-locals re-bound as usual) and returns the per-name exit binding for `names`. Used as the
4943
5102
  # `BodyFixpoint` body-evaluator.
4944
5103
  def block_exit_bindings(call_node, block, bindings, names)
4945
- _type, exit_scope = sub_eval(block, block_pass_entry(call_node, block, bindings))
5104
+ entry = with_repeated_or_write_marks(call_node, block, block_pass_entry(call_node, block, bindings))
5105
+ _type, exit_scope = sub_eval(block, entry)
4946
5106
  names.to_h { |name| [name, CapturedLocals.bound_type(exit_scope, name)] }
4947
5107
  end
4948
5108
 
@@ -4958,6 +5118,38 @@ module Rigor
4958
5118
  end
4959
5119
  end
4960
5120
 
5121
+ # Issue #1336 — `entry` with the index `||=` sites of a repeating block marked as the generic block-return pass
5122
+ # marks them ({RepeatedOrWrites}, `Scope#with_repeated_or_writes`). The statement pass and every ADR-56
5123
+ # write-back pass type the body from one entry scope just as that pass does, so a slot an earlier run stored
5124
+ # into reads as a lone `Dynamic` there too, and the memoizing reading ({#index_compound_write_value}) answered
5125
+ # one site's rvalue: `last = (cache[:k] ||= :f).itself` beside a `cache[:k] ||= :e` wrote `:f` alone back
5126
+ # into `last`, and `last == :e` folded always-falsey. A call that runs its block at most once marks nothing.
5127
+ def with_repeated_or_write_marks(call_node, block, entry)
5128
+ sites = repeated_or_write_sites(call_node, block)
5129
+ sites.empty? ? entry : entry.with_repeated_or_writes(sites)
5130
+ end
5131
+
5132
+ # The sites {#with_repeated_or_write_marks} lays, once per block: a body that spells no `||=` — nearly every
5133
+ # one — answers none without the repetition question.
5134
+ def repeated_or_write_sites(call_node, block)
5135
+ memo = (@repeated_or_write_sites ||= {}.compare_by_identity)
5136
+ return memo[block] if memo.key?(block)
5137
+
5138
+ memo[block] = compute_repeated_or_write_sites(call_node, block)
5139
+ end
5140
+
5141
+ def compute_repeated_or_write_sites(call_node, block)
5142
+ body = block.body
5143
+ return NO_REPEATED_OR_WRITES if body.nil? || !RepeatedOrWrites.may_hold_or_write?(body)
5144
+ return NO_REPEATED_OR_WRITES if repeating_block_class(call_node).nil?
5145
+
5146
+ stores = CapturedLocals.content_mutations(block, scope, non_locals: true)
5147
+ RepeatedOrWrites.sites(block, stores, scope).at(nil)
5148
+ end
5149
+
5150
+ NO_REPEATED_OR_WRITES = [].freeze
5151
+ private_constant :NO_REPEATED_OR_WRITES
5152
+
4961
5153
  NO_CAPTURE_BINDINGS = {}.freeze
4962
5154
  private_constant :NO_CAPTURE_BINDINGS
4963
5155
 
@@ -5662,6 +5854,7 @@ module Rigor
5662
5854
  class_context: class_context,
5663
5855
  lexical_nesting: lexical_nesting,
5664
5856
  converged_loop_recording: @converged_loop_recording,
5857
+ widen_clauses: @widen_clauses,
5665
5858
  next_scope_sink: next_scope_sink,
5666
5859
  operand_scope: operand_scope,
5667
5860
  in_operand: in_operand,
@@ -5956,8 +6149,7 @@ module Rigor
5956
6149
  # it is frozen, and the dispatcher already projects it (see `DataFolding`).
5957
6150
  def struct_instance_projection(subject_type, method_name, scope, subject_node)
5958
6151
  return nil unless subject_type.is_a?(Type::StructInstance)
5959
- return nil unless MethodDispatcher::StructMaterialization.materialization_call?(subject_node, subject_type,
5960
- scope)
6152
+ return nil unless MethodDispatcher::StructMaterialization.materialization_call?(subject_node, scope)
5961
6153
 
5962
6154
  case method_name
5963
6155
  when :deconstruct then Type::Combinator.tuple_of(*subject_type.members.values)
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+ require_relative "../source/node_walker"
5
+ require_relative "captured_locals"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Issue #1840 — a branch merge that re-joins the narrowed edges of an untyped local gives it back its
10
+ # untyped binding.
11
+ #
12
+ # Flow narrowing concretizes a `Dynamic[top]` receiver (ADR-83): `x.nil?` splits it into `nil` and
13
+ # `Dynamic[top]`, and `when String` into `String` and `Dynamic[top]`. `Scope#join` unions the edges, so
14
+ # after both merges the local reads `Dynamic[top] | String | nil`. That union is not wrong as a set, but
15
+ # every member is a narrowing of the one untyped value, so it says nothing the pre-branch binding did
16
+ # not. It does, however, hold `nil` beside a nameable class, and `call.possible-nil-receiver` reports
17
+ # `x + "a"` on correct code although neither narrowing alone did.
18
+ #
19
+ # The rule: at a merge, a local whose pre-branch binding is exactly `Dynamic[top]`, that no merged arm
20
+ # writes, and whose joined binding is a union still carrying a `Dynamic` member, is bound to
21
+ # `Dynamic[top]` again.
22
+ #
23
+ # - The write test keeps a nil the program puts there: `x = nil if c` writes `x`, so the join stays
24
+ # `Dynamic[top] | nil` and still reports. It is syntactic, over every local-write form under the arms
25
+ # (a pattern capture and a block's write to the outer local included), so a doubtful arm keeps today's
26
+ # join. It does not see a written nil that a `nil?` edge stripped before the merge (`x = nil if c; if
27
+ # x.nil? … elsif x.is_a?(String) … end`): that merge's entry is exactly `Dynamic[top]`, and the outer
28
+ # join's `Dynamic[top] | nil` is one the possible-nil gate declines anyway (#1876).
29
+ # - The `Dynamic`-member test keeps a narrowing whose surviving arms no longer cover the untyped value:
30
+ # `case x when String then … when Integer then … else raise end` leaves `Integer | String`, which is
31
+ # what `x` is past it.
32
+ # - Only the `Dynamic[top]` facet: a faceted `Dynamic[T]` carries static knowledge of its own (a
33
+ # plugin's or a signature's `T`), so a `nil` its narrowing leaves may be one the facet promised. Those
34
+ # keep today's join.
35
+ #
36
+ # Restoring `Dynamic[top]` is always sound, since every narrowed edge is a subset of it; what it drops
37
+ # is only the nameable arms, which an untyped union never needed to dispatch.
38
+ module UntypedRejoin
39
+ module_function
40
+
41
+ # `joined` with each qualifying local rebound to its `entry` binding. `arms` are the nodes whose
42
+ # evaluation produced the merged scopes (`nil` entries are skipped).
43
+ def apply(joined, entry, arms)
44
+ candidates = nil
45
+ joined.locals.each do |name, type|
46
+ next unless candidate?(entry.locals[name], type)
47
+
48
+ (candidates ||= []) << name
49
+ end
50
+ return joined if candidates.nil?
51
+
52
+ written = written_locals(arms)
53
+ kept = candidates.reject { |name| written.include?(name) }
54
+ return joined if kept.empty?
55
+
56
+ joined.with_rejoined_locals(kept.to_h { |name| [name, entry.locals[name]] })
57
+ end
58
+
59
+ def candidate?(entry_type, joined_type)
60
+ entry_type.is_a?(Type::Dynamic) && entry_type.static_facet.is_a?(Type::Top) &&
61
+ joined_type.is_a?(Type::Union) && joined_type.members.any?(Type::Dynamic)
62
+ end
63
+
64
+ def written_locals(arms)
65
+ names = Set.new
66
+ arms.each do |arm|
67
+ Source::NodeWalker.each(arm) do |node|
68
+ names << node.name if CapturedLocals::LOCAL_WRITE_NODES.include?(node.class)
69
+ end
70
+ end
71
+ names
72
+ end
73
+ end
74
+ end
75
+ end
@@ -6,6 +6,7 @@ require_relative "../cache/store"
6
6
  require_relative "../analysis/runner"
7
7
  require_relative "../analysis/incremental_session"
8
8
  require_relative "../cache/incremental_snapshot"
9
+ require_relative "../effects/identity"
9
10
 
10
11
  module Rigor
11
12
  module LanguageServer
@@ -24,6 +25,10 @@ module Rigor
24
25
  # are small (the per-publish path is already ≈2ms once Environment is warm).
25
26
  #
26
27
  # Invalidation:
28
+ # - Every whole-project round (`#project_diagnostics`) re-reads the configuration and compares the
29
+ # incremental snapshot fingerprint and the `effects:` identity with the ones its session was primed with,
30
+ # re-baselining on any change — so no file watcher or `didChangeConfiguration` is needed for
31
+ # `.rigor.yml`, `sig/`, `pre_eval:` or lockfile edits (#1613).
27
32
  # - `#invalidate!` drops the cached environment AND project scan + bumps the generation counter; the next
28
33
  # reader rebuilds. Watched-file changes (`workspace/didChangeWatchedFiles`) and configuration refreshes
29
34
  # (`workspace/didChangeConfiguration`) both trigger this — the next publish observes the new project
@@ -44,8 +49,13 @@ module Rigor
44
49
  # invalidated never reach the editor.
45
50
  attr_reader :configuration, :generation
46
51
 
47
- def initialize(configuration:)
52
+ # `configuration_loader` re-reads the configuration (e.g. `-> { Configuration.load(path) }`); a save round
53
+ # calls it so an edit to `.rigor.yml` (or a file it `includes:`) reaches the server without a restart. Nil
54
+ # keeps the configuration the context was built with.
55
+ def initialize(configuration:, configuration_loader: nil)
48
56
  @configuration = configuration
57
+ @configuration_loader = configuration_loader
58
+ @primed_key = nil
49
59
  @buffer_sources = {}
50
60
  @disk_feature_files = nil
51
61
  @environment_features = nil
@@ -110,16 +120,31 @@ module Rigor
110
120
  # The session is never persisted. Its state lives as long as this context, which is what a long-running
111
121
  # server needs, and writing it would race exactly the way the read-only {#cache_store} exists to avoid.
112
122
  # Seeding is therefore one-directional: the terminal warms the server, not the reverse.
123
+ #
124
+ # Every round recomputes {Cache::IncrementalSnapshot.fingerprint} (the function the CLI's `--incremental`
125
+ # keys its snapshot on — one input list) and compares it with the one the session was primed with. A
126
+ # session's recheck only sees source-file digests, so an edit to a `pre_eval:` file, an auto-detected
127
+ # `sig/*.rbs`, a lockfile or `.rigor.yml` would otherwise keep serving answers computed from the old
128
+ # inputs until the server restarts (#1613). A change — including a fingerprint that newly cannot be
129
+ # computed — drops the session and the world built from the old inputs and re-baselines, so the round equals
130
+ # a cold run. The `effects:` identity rides along because `Configuration#to_h` omits `effects:`, so the
131
+ # fingerprint alone does not move on an `effects:` edit. A fingerprint that was ALREADY uncomputable at
132
+ # priming is not a change: that session keeps rechecking.
133
+ # {#generation} is deliberately NOT bumped: the round that notices the change is itself computed from the
134
+ # new inputs, and the publisher discards a round whose generation moved.
113
135
  def project_diagnostics
136
+ reload_configuration
137
+ fingerprint = current_fingerprint
138
+ key = [fingerprint, Effects::Identity.digest(configuration: @configuration)]
139
+ rebaseline if @session_primed && key != @primed_key
114
140
  session = (@incremental_session ||= build_incremental_session)
115
141
  return session.recheck.diagnostics if @session_primed
116
142
 
117
143
  @session_primed = true
144
+ @primed_key = key
118
145
  diagnostics, = session.run_incremental(
119
146
  snapshot: Cache::IncrementalSnapshot.new(root: @configuration.cache_path),
120
- fingerprint: Cache::IncrementalSnapshot.fingerprint(
121
- configuration: @configuration, roots: @configuration.paths
122
- ),
147
+ fingerprint: fingerprint,
123
148
  persist: false
124
149
  )
125
150
  diagnostics
@@ -153,6 +178,13 @@ module Rigor
153
178
  # `workspace/didChangeConfiguration`.
154
179
  def invalidate!
155
180
  @generation += 1
181
+ reset_world
182
+ end
183
+
184
+ private
185
+
186
+ # Drops what was built from the previous inputs, without touching {#generation}.
187
+ def reset_world
156
188
  @environment = nil
157
189
  @environment_features = nil
158
190
  @disk_feature_files = nil
@@ -161,12 +193,31 @@ module Rigor
161
193
  # outlive them. The next save round primes a fresh one.
162
194
  @incremental_session = nil
163
195
  @session_primed = false
196
+ @primed_key = nil
164
197
  # Cache store stays — it's content-addressed; a stale env build won't be served because the file
165
198
  # digest mixed into the cache key has changed.
166
199
  nil
167
200
  end
168
201
 
169
- private
202
+ # A changed configuration may also move `cache_path`, so the store goes too (unlike {#invalidate!}).
203
+ def rebaseline
204
+ reset_world
205
+ @cache_store = nil
206
+ end
207
+
208
+ def current_fingerprint
209
+ Cache::IncrementalSnapshot.fingerprint(configuration: @configuration, roots: @configuration.paths)
210
+ end
211
+
212
+ # A load that raises (a half-typed `.rigor.yml`) keeps the previous configuration: the editor must not
213
+ # lose its diagnostics to a transient parse error, and the next round retries.
214
+ def reload_configuration
215
+ return unless @configuration_loader
216
+
217
+ @configuration = @configuration_loader.call
218
+ rescue StandardError
219
+ nil
220
+ end
170
221
 
171
222
  # The session shares this context's warm Environment, so a round does not rebuild the RBS universe. It
172
223
  # deliberately does NOT share the prebuilt ProjectScan: a round runs after a save, i.e. after the file