rigortype 0.1.19 → 0.2.0

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 (166) hide show
  1. checksums.yaml +4 -4
  2. data/lib/rigor/analysis/check_rules/ivar_write_collector.rb +3 -23
  3. data/lib/rigor/analysis/check_rules/rule_walk.rb +3 -21
  4. data/lib/rigor/analysis/check_rules/self_closedness_scanner.rb +24 -15
  5. data/lib/rigor/analysis/check_rules.rb +492 -71
  6. data/lib/rigor/analysis/dependency_source_inference/index.rb +4 -7
  7. data/lib/rigor/analysis/dependency_source_inference/walker.rb +2 -18
  8. data/lib/rigor/analysis/dependency_source_inference.rb +3 -12
  9. data/lib/rigor/analysis/fact_store.rb +5 -4
  10. data/lib/rigor/analysis/rule_catalog.rb +153 -6
  11. data/lib/rigor/analysis/runner/diagnostic_aggregator.rb +17 -17
  12. data/lib/rigor/analysis/runner/project_pre_passes.rb +9 -8
  13. data/lib/rigor/analysis/runner.rb +17 -6
  14. data/lib/rigor/analysis/self_call_resolution_recorder.rb +3 -4
  15. data/lib/rigor/analysis/worker_session.rb +10 -14
  16. data/lib/rigor/builtins/predefined_constant_refinements.rb +151 -0
  17. data/lib/rigor/cache/store.rb +5 -3
  18. data/lib/rigor/cli/annotate_command.rb +28 -7
  19. data/lib/rigor/cli/baseline_command.rb +4 -3
  20. data/lib/rigor/cli/check_command.rb +115 -16
  21. data/lib/rigor/cli/coverage_command.rb +148 -16
  22. data/lib/rigor/cli/coverage_scan.rb +57 -0
  23. data/lib/rigor/cli/explain_command.rb +2 -0
  24. data/lib/rigor/cli/lsp_command.rb +3 -7
  25. data/lib/rigor/cli/mutation_protection_renderer.rb +63 -0
  26. data/lib/rigor/cli/mutation_protection_report.rb +73 -0
  27. data/lib/rigor/cli/options.rb +9 -0
  28. data/lib/rigor/cli/plugins_command.rb +2 -1
  29. data/lib/rigor/cli/protection_renderer.rb +63 -0
  30. data/lib/rigor/cli/protection_report.rb +68 -0
  31. data/lib/rigor/cli/sig_gen_command.rb +2 -1
  32. data/lib/rigor/cli/trace_command.rb +2 -1
  33. data/lib/rigor/cli/triage_command.rb +2 -1
  34. data/lib/rigor/cli/type_of_command.rb +1 -1
  35. data/lib/rigor/cli/type_scan_command.rb +2 -1
  36. data/lib/rigor/cli.rb +3 -2
  37. data/lib/rigor/configuration/dependencies.rb +2 -4
  38. data/lib/rigor/configuration.rb +45 -7
  39. data/lib/rigor/environment/bundle_sig_discovery.rb +61 -13
  40. data/lib/rigor/environment/class_registry.rb +4 -3
  41. data/lib/rigor/environment/constant_type_cache_holder.rb +43 -0
  42. data/lib/rigor/environment/lockfile_resolver.rb +1 -1
  43. data/lib/rigor/environment/rbs_collection_discovery.rb +1 -2
  44. data/lib/rigor/environment/rbs_coverage_report.rb +2 -1
  45. data/lib/rigor/environment/rbs_loader.rb +49 -5
  46. data/lib/rigor/environment.rb +17 -7
  47. data/lib/rigor/flow_contribution/fact.rb +1 -1
  48. data/lib/rigor/flow_contribution.rb +3 -5
  49. data/lib/rigor/inference/acceptance.rb +17 -9
  50. data/lib/rigor/inference/block_parameter_binder.rb +2 -3
  51. data/lib/rigor/inference/builtins/comparable_catalog.rb +2 -2
  52. data/lib/rigor/inference/builtins/enumerable_catalog.rb +2 -2
  53. data/lib/rigor/inference/builtins/method_catalog.rb +19 -0
  54. data/lib/rigor/inference/builtins/string_catalog.rb +9 -1
  55. data/lib/rigor/inference/expression_typer.rb +20 -28
  56. data/lib/rigor/inference/hkt_body.rb +8 -11
  57. data/lib/rigor/inference/hkt_body_parser.rb +10 -12
  58. data/lib/rigor/inference/hkt_registry.rb +10 -11
  59. data/lib/rigor/inference/method_dispatcher/call_context.rb +1 -4
  60. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +156 -21
  61. data/lib/rigor/inference/method_dispatcher/data_folding.rb +9 -73
  62. data/lib/rigor/inference/method_dispatcher/file_folding.rb +6 -7
  63. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +10 -16
  64. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +25 -13
  65. data/lib/rigor/inference/method_dispatcher/member_shape_projection.rb +93 -0
  66. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +1 -3
  67. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +24 -22
  68. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +90 -15
  69. data/lib/rigor/inference/method_dispatcher/struct_folding.rb +303 -0
  70. data/lib/rigor/inference/method_dispatcher.rb +40 -48
  71. data/lib/rigor/inference/mutation_widening.rb +5 -11
  72. data/lib/rigor/inference/narrowing.rb +14 -16
  73. data/lib/rigor/inference/parameter_inference_collector.rb +367 -0
  74. data/lib/rigor/inference/project_patched_methods.rb +4 -7
  75. data/lib/rigor/inference/project_patched_scanner.rb +2 -13
  76. data/lib/rigor/inference/protection_scanner.rb +86 -0
  77. data/lib/rigor/inference/scope_indexer.rb +129 -55
  78. data/lib/rigor/inference/statement_evaluator.rb +244 -114
  79. data/lib/rigor/inference/struct_fold_safety.rb +181 -0
  80. data/lib/rigor/inference/synthetic_method.rb +7 -7
  81. data/lib/rigor/language_server/completion_provider.rb +6 -12
  82. data/lib/rigor/language_server/diagnostic_publisher.rb +4 -4
  83. data/lib/rigor/language_server/document_symbol_provider.rb +3 -3
  84. data/lib/rigor/language_server/hover_provider.rb +2 -3
  85. data/lib/rigor/language_server/hover_renderer.rb +2 -11
  86. data/lib/rigor/language_server/server.rb +9 -17
  87. data/lib/rigor/language_server.rb +4 -5
  88. data/lib/rigor/plugin/base.rb +10 -8
  89. data/lib/rigor/plugin/macro/block_as_method.rb +3 -4
  90. data/lib/rigor/plugin/macro/heredoc_template.rb +4 -7
  91. data/lib/rigor/plugin/macro/trait_registry.rb +3 -6
  92. data/lib/rigor/plugin/macro.rb +4 -5
  93. data/lib/rigor/plugin/manifest.rb +45 -66
  94. data/lib/rigor/plugin/registry.rb +6 -7
  95. data/lib/rigor/plugin/type_node_resolver.rb +6 -8
  96. data/lib/rigor/protection/mutation_scanner.rb +120 -0
  97. data/lib/rigor/protection/mutator.rb +246 -0
  98. data/lib/rigor/rbs_extended.rb +24 -36
  99. data/lib/rigor/reflection.rb +4 -7
  100. data/lib/rigor/scope/discovery_index.rb +14 -2
  101. data/lib/rigor/scope.rb +54 -11
  102. data/lib/rigor/sig_gen/observed_call.rb +3 -3
  103. data/lib/rigor/sig_gen/writer.rb +40 -2
  104. data/lib/rigor/source/constant_path.rb +62 -0
  105. data/lib/rigor/source.rb +1 -0
  106. data/lib/rigor/type/bound_method.rb +2 -11
  107. data/lib/rigor/type/combinator.rb +16 -3
  108. data/lib/rigor/type/constant.rb +2 -11
  109. data/lib/rigor/type/data_class.rb +2 -11
  110. data/lib/rigor/type/data_instance.rb +2 -11
  111. data/lib/rigor/type/hash_shape.rb +2 -11
  112. data/lib/rigor/type/integer_range.rb +2 -11
  113. data/lib/rigor/type/intersection.rb +2 -11
  114. data/lib/rigor/type/nominal.rb +2 -11
  115. data/lib/rigor/type/plain_lattice.rb +37 -0
  116. data/lib/rigor/type/refined.rb +72 -13
  117. data/lib/rigor/type/singleton.rb +2 -11
  118. data/lib/rigor/type/struct_class.rb +75 -0
  119. data/lib/rigor/type/struct_instance.rb +93 -0
  120. data/lib/rigor/type/tuple.rb +5 -15
  121. data/lib/rigor/type.rb +2 -0
  122. data/lib/rigor/version.rb +1 -1
  123. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_discoverer.rb +1 -1
  124. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable/channel_index.rb +3 -3
  125. data/plugins/rigor-actioncable/lib/rigor/plugin/actioncable.rb +3 -3
  126. data/plugins/rigor-actionmailer/lib/rigor/plugin/actionmailer/mailer_discoverer.rb +5 -13
  127. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack/analyzer.rb +11 -17
  128. data/plugins/rigor-actionpack/lib/rigor/plugin/actionpack.rb +7 -10
  129. data/plugins/rigor-activejob/lib/rigor/plugin/activejob/job_index.rb +3 -2
  130. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord/model_discoverer.rb +4 -4
  131. data/plugins/rigor-activerecord/lib/rigor/plugin/activerecord.rb +6 -8
  132. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage/analyzer.rb +5 -7
  133. data/plugins/rigor-activestorage/lib/rigor/plugin/activestorage.rb +1 -2
  134. data/plugins/rigor-devise/lib/rigor/plugin/devise.rb +9 -11
  135. data/plugins/rigor-dry-struct/lib/rigor/plugin/dry_struct.rb +8 -9
  136. data/plugins/rigor-dry-types/lib/rigor/plugin/dry_types.rb +13 -12
  137. data/plugins/rigor-dry-validation/lib/rigor/plugin/dry_validation.rb +3 -4
  138. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/analyzer.rb +8 -8
  139. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_discoverer.rb +9 -11
  140. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot/factory_index.rb +7 -8
  141. data/plugins/rigor-factorybot/lib/rigor/plugin/factorybot.rb +7 -9
  142. data/plugins/rigor-graphql/lib/rigor/plugin/graphql/type_scanner.rb +12 -13
  143. data/plugins/rigor-graphql/lib/rigor/plugin/graphql.rb +15 -23
  144. data/plugins/rigor-mangrove/lib/rigor/plugin/mangrove.rb +3 -3
  145. data/plugins/rigor-minitest/lib/rigor/plugin/minitest.rb +3 -3
  146. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/analyzer.rb +2 -4
  147. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n/locale_loader.rb +27 -11
  148. data/plugins/rigor-rails-i18n/lib/rigor/plugin/rails_i18n.rb +1 -1
  149. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/devise_routes.rb +4 -6
  150. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes/routes_parser.rb +12 -18
  151. data/plugins/rigor-rails-routes/lib/rigor/plugin/rails_routes.rb +5 -5
  152. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_scope_index.rb +3 -4
  153. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/let_type_resolver.rb +1 -1
  154. data/plugins/rigor-rspec/lib/rigor/plugin/rspec/matcher_analyzer.rb +1 -1
  155. data/plugins/rigor-rspec/lib/rigor/plugin/rspec.rb +19 -14
  156. data/plugins/rigor-shoulda-matchers/lib/rigor/plugin/shoulda_matchers/analyzer.rb +0 -1
  157. data/plugins/rigor-sidekiq/lib/rigor/plugin/sidekiq/worker_index.rb +5 -4
  158. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/assertion_recognizer.rb +2 -3
  159. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/method_signature.rb +7 -11
  160. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sig_parser.rb +4 -5
  161. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/sigil_detector.rb +6 -9
  162. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet/type_translator.rb +5 -15
  163. data/plugins/rigor-sorbet/lib/rigor/plugin/sorbet.rb +28 -41
  164. data/sig/rigor/scope.rbs +9 -1
  165. data/sig/rigor/type.rbs +36 -1
  166. metadata +19 -1
@@ -6,8 +6,10 @@ require_relative "../reflection"
6
6
  require_relative "../type"
7
7
  require_relative "../analysis/fact_store"
8
8
  require_relative "../source/node_walker"
9
+ require_relative "../source/constant_path"
9
10
  require_relative "block_parameter_binder"
10
11
  require_relative "body_fixpoint"
12
+ require_relative "struct_fold_safety"
11
13
  require_relative "closure_escape_analyzer"
12
14
  require_relative "indexed_narrowing"
13
15
  require_relative "method_dispatcher"
@@ -100,6 +102,7 @@ module Rigor
100
102
  Prism::CallNode => :eval_call,
101
103
  Prism::BlockNode => :eval_block,
102
104
  Prism::ReturnNode => :eval_return,
105
+ Prism::BreakNode => :eval_break,
103
106
  Prism::MatchWriteNode => :eval_match_write
104
107
  }.freeze
105
108
  private_constant :HANDLERS
@@ -115,6 +118,19 @@ module Rigor
115
118
  RETURN_SINK_KEY = :rigor_return_sink
116
119
  private_constant :RETURN_SINK_KEY
117
120
 
121
+ # Thread-local sink (an Array of `[BreakNode, Scope]`) collecting the
122
+ # scope at each `break` reached while evaluating a loop body, so
123
+ # `eval_loop` / `eval_for` can join a `break`-path binding (`flag = true;
124
+ # break`) into the loop continuation that the fall-through would
125
+ # otherwise drop. Stacks like the return sink: a nested loop installs its
126
+ # own sink, restored on exit, so an inner loop's break does not leak to
127
+ # the outer one. A `break` inside a block / nested loop targets that
128
+ # inner construct, not the lexical loop — filtered out by the
129
+ # directly-targeting break set, see {#directly_targeting_breaks}.
130
+ # See docs/notes/20260615-loop-break-binding-propagation-design.md.
131
+ BREAK_SINK_KEY = :rigor_break_sink
132
+ private_constant :BREAK_SINK_KEY
133
+
118
134
  # Lexical class frame: the `name:` field is the qualified class
119
135
  # name as it would render in Ruby (e.g., `"Foo::Bar"`); the
120
136
  # `singleton:` field is `true` for `class << self` frames so
@@ -365,9 +381,7 @@ module Rigor
365
381
  end
366
382
 
367
383
  # `receiver[key] ||= default` — the Redmine `Query#as_params`
368
- # idiom (ROADMAP § Future cycles / Type-language / engine
369
- # "Indexed-collection narrowing through `Hash[k] ||= default`").
370
- # After the `||=`, the next read at `receiver[key]` is known
384
+ # idiom. After the `||=`, the next read at `receiver[key]` is known
371
385
  # non-nil; the next `<<` / `[]=` / other mutator runs against
372
386
  # a Tuple / Hash carrier instead of the `Constant[nil]` an
373
387
  # empty `HashShape{}` lookup would otherwise fold to.
@@ -480,10 +494,24 @@ module Rigor
480
494
  # then-branch unconditionally exits (return / next /
481
495
  # break / raise) and there is no else, the post-scope
482
496
  # is the falsey edge of the predicate (subsequent
483
- # statements observe the predicate-was-false world).
497
+ # statements observe the predicate-was-false world). The
498
+ # then-body is the *skipped* path, so the bare narrowing
499
+ # (no body assignments) is the correct continuation.
484
500
  return [Type::Combinator.union(then_type, else_type), falsey_scope] \
485
501
  if branch_terminates?(node.statements, then_type) && node.subsequent.nil?
486
- return [Type::Combinator.union(then_type, else_type), truthy_scope] \
502
+ # Symmetric case: the else / elsif-chain (`node.subsequent`)
503
+ # unconditionally exits, so the only surviving path is the
504
+ # then-branch that RAN. The continuation must therefore carry
505
+ # `then_scope` — the predicate-truthy narrowing PLUS the
506
+ # then-body's assignments — not the bare `truthy_scope`.
507
+ # Returning `truthy_scope` drops every local the then-body
508
+ # bound, leaving it unbound for any enclosing merge to
509
+ # spuriously nil-inject: e.g. the inner `elsif … else raise`
510
+ # of `if a then x=… elsif b then x=… else raise end` would
511
+ # return with `x` unbound, and the outer if's join would then
512
+ # read `x` as `… | nil` and fire a false `possible-nil-receiver`
513
+ # (liquid v5.x sweep, Event 3).
514
+ return [Type::Combinator.union(then_type, else_type), then_scope] \
487
515
  if branch_terminates?(node.subsequent, else_type) && node.statements
488
516
 
489
517
  [
@@ -518,10 +546,17 @@ module Rigor
518
546
  else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
519
547
  # Slice 7 phase 14 — same early-return narrowing as
520
548
  # `if`: when the body unconditionally exits and there
521
- # is no else, the post-scope is the truthy edge.
549
+ # is no else, the post-scope is the truthy edge (the body
550
+ # is the skipped path, so the bare narrowing is correct).
522
551
  return [Type::Combinator.union(then_type, else_type), truthy_scope] \
523
552
  if branch_terminates?(node.statements, then_type) && node.else_clause.nil?
524
- return [Type::Combinator.union(then_type, else_type), falsey_scope] \
553
+ # Symmetric to the `if` else-exits fix: when the else-clause
554
+ # exits, the surviving path is the unless-body that RAN, so the
555
+ # continuation carries `then_scope` (the predicate-falsey
556
+ # narrowing PLUS the body's assignments), not the bare
557
+ # `falsey_scope` — otherwise body-bound locals are dropped and
558
+ # an enclosing merge nil-injects them.
559
+ return [Type::Combinator.union(then_type, else_type), then_scope] \
525
560
  if branch_terminates?(node.else_clause, else_type) && node.statements
526
561
 
527
562
  [
@@ -900,7 +935,11 @@ module Rigor
900
935
  # widens `buf`'s Tuple), body-introduced locals' nil-injection, and
901
936
  # the loop value itself. The fixpoint then OVERLAYS only the
902
937
  # rebound-local bindings it corrects.
903
- _body_type, body_scope = sub_eval(node.statements, post_pred)
938
+ #
939
+ # The pass runs under a break sink so a `break`-path binding
940
+ # (`flag = true; break`) the fall-through `body_scope` drops is
941
+ # collected for the continuation join below.
942
+ break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, post_pred)
904
943
  base_scope = join_with_nil_injection(post_pred, body_scope)
905
944
 
906
945
  rebound, body_first = loop_body_local_writes(node.statements, post_pred)
@@ -915,36 +954,41 @@ module Rigor
915
954
  return [Type::Combinator.constant_of(nil), narrow_loop_exit_edge(node, fast)]
916
955
  end
917
956
 
957
+ post_loop = converged_loop_scope(node, post_pred, base_scope, names, body_first)
958
+ # Recover `break`-path bindings the fall-through dropped (`flag = true;
959
+ # break` -> `flag` is `false | true`, not the stale `false`).
960
+ post_loop = join_break_scopes(post_loop, break_sink, break_targets, names)
961
+ post_loop = narrow_loop_exit_edge(node, post_loop)
962
+ [Type::Combinator.constant_of(nil), post_loop]
963
+ end
964
+
965
+ # The continuation scope for a loop whose body rebinds locals: the
966
+ # ADR-56 slice-B rebind fixpoint overlaid on `base_scope`, then the
967
+ # slice-C receiver-content writeback.
968
+ def converged_loop_scope(node, post_pred, base_scope, names, body_first)
918
969
  # ADR-56 slice B — loop-body fixpoint. The body runs 0..N times and
919
970
  # may compound (`d *= 2`), so the historical single body pass joined
920
971
  # with the pre-loop scope kept stale folded constants
921
972
  # (`d = 1; while …; d *= 2; end` → `1 | 2`, never reaching `4, 8`).
922
- # Fold each body-written local's continuation binding through the
923
- # same capped fixpoint slice A uses for non-escaping block captures.
924
- #
925
- # Seed: a pre-existing local seeds with its post-predicate binding;
926
- # a local FIRST assigned inside the body seeds with `nil` so the
927
- # 0-iteration path (the body may never run) degrades it to
928
- # `T | nil`, matching the historical nil-injection treatment.
973
+ # Fold each body-written local's continuation binding through the same
974
+ # capped fixpoint slice A uses for non-escaping block captures. Seed:
975
+ # a pre-existing local seeds with its post-predicate binding; a local
976
+ # FIRST assigned inside the body seeds with `nil` so the 0-iteration
977
+ # path degrades it to `T | nil`, matching the nil-injection treatment.
929
978
  result = loop_rebind_fixpoint(node, post_pred, names, body_first)
930
979
  # Display-path re-record: the fixpoint's body re-evaluations fire
931
980
  # `on_enter` with the cap-N INTERMEDIATE assumptions, so the
932
981
  # last-visit-wins scope index would annotate loop-body lines with
933
- # stale pre-convergence constants. One extra pass from the
934
- # converged bindings (result discarded) re-records the body's
935
- # entry scopes post-writeback.
982
+ # stale pre-convergence constants. One extra pass from the converged
983
+ # bindings (result discarded) re-records the body's entry scopes.
936
984
  record_converged_loop_body(node, post_pred, result, names, body_first)
937
985
  post_loop = result.reduce(base_scope) { |acc, (name, type)| acc.with_local(name, type) }
938
- # ADR-56 slice C — loop-body receiver-content element-type join. A
939
- # loop that content-mutates a collection (`acc << n`) keeps only the
940
- # seed's element types after the single-pass widen (B1 unsoundness:
941
- # `acc = [0]; while …; acc << n; end` → `Array[0]`, runtime
942
- # `[0, n, …]`). Join the appended/stored types into the continuation
943
- # collection. Pre-state is read from `post_loop` so a local both
944
- # rebound and content-mutated composes.
945
- post_loop = loop_content_writeback(node.statements, post_loop)
946
- post_loop = narrow_loop_exit_edge(node, post_loop)
947
- [Type::Combinator.constant_of(nil), post_loop]
986
+ # ADR-56 slice C — loop-body receiver-content element-type join. A loop
987
+ # that content-mutates a collection (`acc << n`) keeps only the seed's
988
+ # element types after the single-pass widen; join the appended/stored
989
+ # types into the continuation collection (pre-state read from
990
+ # `post_loop` so a local both rebound and content-mutated composes).
991
+ loop_content_writeback(node.statements, post_loop)
948
992
  end
949
993
 
950
994
  # Item 4 — loop-exit predicate narrowing. A `while pred` / `until pred`
@@ -983,6 +1027,87 @@ module Rigor
983
1027
  found
984
1028
  end
985
1029
 
1030
+ # A `break` inside one of these nested constructs targets the inner
1031
+ # construct (an inner loop, a block's method, a nested def), NOT the
1032
+ # lexical loop — so the directly-targeting break scan does not descend
1033
+ # into them.
1034
+ BREAK_BOUNDARY_NODES = [
1035
+ Prism::ForNode, Prism::WhileNode, Prism::UntilNode,
1036
+ Prism::BlockNode, Prism::LambdaNode, Prism::DefNode,
1037
+ Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode
1038
+ ].freeze
1039
+ private_constant :BREAK_BOUNDARY_NODES
1040
+
1041
+ # The `BreakNode`s that lexically target THIS loop — reachable from the
1042
+ # body without crossing a nested loop / block / def boundary. An
1043
+ # identity-keyed Hash used as a membership set to filter the collected
1044
+ # break scopes (the thread-local sink also collects breaks from nested
1045
+ # blocks that did not install their own sink).
1046
+ def directly_targeting_breaks(statements)
1047
+ found = {}.compare_by_identity
1048
+ collect_direct_breaks(statements, found)
1049
+ found
1050
+ end
1051
+
1052
+ def collect_direct_breaks(node, found)
1053
+ return if node.nil?
1054
+
1055
+ found[node] = true if node.is_a?(Prism::BreakNode)
1056
+ node.compact_child_nodes.each do |child|
1057
+ next if BREAK_BOUNDARY_NODES.any? { |klass| child.is_a?(klass) }
1058
+
1059
+ collect_direct_breaks(child, found)
1060
+ end
1061
+ end
1062
+
1063
+ # Installs a fresh thread-local break sink around `yield` (a loop-body
1064
+ # evaluation), returning `[collected, yield_result]`. Stacks: the
1065
+ # previous sink is restored on exit so a nested loop's breaks do not
1066
+ # leak to the enclosing loop.
1067
+ def collect_break_scopes
1068
+ previous = Thread.current[BREAK_SINK_KEY]
1069
+ sink = []
1070
+ Thread.current[BREAK_SINK_KEY] = sink
1071
+ begin
1072
+ result = yield
1073
+ ensure
1074
+ Thread.current[BREAK_SINK_KEY] = previous
1075
+ end
1076
+ [sink, result]
1077
+ end
1078
+
1079
+ # Runs a loop body's single pass under a break sink. Returns the
1080
+ # directly-targeting break set, the collected break scopes, and the
1081
+ # fall-through body scope — the three inputs the continuation's
1082
+ # {#join_break_scopes} needs. Shared by `eval_loop` and `eval_for`.
1083
+ def capture_loop_body_breaks(statements, entry)
1084
+ targets = directly_targeting_breaks(statements)
1085
+ sink, (_type, body_scope) = collect_break_scopes { sub_eval(statements, entry) }
1086
+ [targets, sink, body_scope]
1087
+ end
1088
+
1089
+ # Joins each directly-targeting break's body-written local bindings into
1090
+ # the loop continuation, so a `break`-path binding the fall-through
1091
+ # dropped is recovered (`flag = true; break` -> `flag` becomes `false |
1092
+ # true`). Only loop-body-written names are joined — an unchanged local
1093
+ # unions to itself; a break-only-written local is already present via the
1094
+ # fixpoint / nil-injection seed, so the union reflects its break value.
1095
+ def join_break_scopes(continuation, sink, targeting, names)
1096
+ return continuation if sink.empty? || names.empty?
1097
+
1098
+ breaks = sink.select { |(node, _scope)| targeting.key?(node) }
1099
+ breaks.reduce(continuation) do |cont, (_node, break_scope)|
1100
+ names.reduce(cont) do |acc, name|
1101
+ break_value = break_scope.local(name)
1102
+ next acc if break_value.nil?
1103
+
1104
+ current = acc.local(name)
1105
+ joined = current ? Type::Combinator.union(current, break_value) : break_value
1106
+ acc.with_local(name, joined)
1107
+ end
1108
+ end
1109
+ end
1110
+
986
1111
  # Joins loop-body content mutations into the continuation collection
987
1112
  # bindings. The mutator arguments are typed against `post_loop`, whose
988
1113
  # locals already carry the loop-body fixpoint widening (so an
@@ -1107,11 +1232,19 @@ module Rigor
1107
1232
  element_type = for_iteration_element_type(coll_type)
1108
1233
  body_entry = bind_for_index(node.index, element_type, post_coll)
1109
1234
 
1110
- body_scope = node.statements ? sub_eval(node.statements, body_entry).last : body_entry
1111
- [
1112
- Type::Combinator.constant_of(nil),
1113
- join_with_nil_injection(post_coll, body_scope)
1114
- ]
1235
+ if node.statements.nil?
1236
+ return [Type::Combinator.constant_of(nil), join_with_nil_injection(post_coll, body_entry)]
1237
+ end
1238
+
1239
+ # Run the body pass under a break sink so a `break`-path binding the
1240
+ # fall-through drops is recovered into the continuation (the `for`
1241
+ # sibling of `eval_loop`'s break join; `for` has no fixpoint, so the
1242
+ # single-pass join is the only continuation).
1243
+ break_targets, break_sink, body_scope = capture_loop_body_breaks(node.statements, body_entry)
1244
+ continuation = join_with_nil_injection(post_coll, body_scope)
1245
+ pre_existing, body_first = loop_body_local_writes(node.statements, post_coll)
1246
+ continuation = join_break_scopes(continuation, break_sink, break_targets, pre_existing + body_first)
1247
+ [Type::Combinator.constant_of(nil), continuation]
1115
1248
  end
1116
1249
 
1117
1250
  # `for x in coll` is semantically `coll.each { |x| ... }`. We
@@ -1278,7 +1411,7 @@ module Rigor
1278
1411
  # (`Constant[nil]` for an empty body); we discard the body's
1279
1412
  # post-scope.
1280
1413
  def eval_class_or_module(node)
1281
- name = qualified_name_for(node.constant_path)
1414
+ name = Source::ConstantPath.qualified_name(node.constant_path)
1282
1415
  new_context = @class_context + [ClassFrame.new(name: name, singleton: false)]
1283
1416
  body_type, _body_scope = eval_class_body(node, new_context)
1284
1417
  [body_type, scope]
@@ -1404,21 +1537,15 @@ module Rigor
1404
1537
  # parameter (not for every self-call), and sound — only loses
1405
1538
  # precision on the floored argument.
1406
1539
  post_scope = widen_callee_escaped_argument_captures(node, post_scope)
1407
- # And the same widening for outer-scope locals / ivars
1408
- # mutated inside the block body (`items.each { |x| arr << x }`):
1409
- # the block lives in a child scope so without an explicit
1410
- # propagation step the outer `arr` keeps its pre-mutation
1411
- # binding. Sound for the same reason — only ever LOSES
1412
- # precision — so blindly applying is safe regardless of
1413
- # whether the block actually runs.
1540
+ # Same always-safe rationale as `widen_after_call` above
1541
+ # propagates outer-scope local / ivar widening from block body
1542
+ # mutations (`items.each { |x| arr << x }`).
1414
1543
  post_scope = MutationWidening.widen_after_block(call_node: node, outer_scope: post_scope)
1415
- # ADR-56 slice C — receiver-content element-type join. The widening
1416
- # above forgets a content-mutated collection's literal arity but
1417
- # keeps only the seed's element types (the B1 unsound under-
1418
- # approximation for a non-empty seed). Join the appended / stored
1419
- # element / key / value types into the continuation collection's
1420
- # parameter so `out = [0]; arr.each { |x| out << x }` types
1421
- # `Array[0 | Integer]`, not `Array[0]`. Always sound — only widens.
1544
+ # ADR-56 slice C — receiver-content element-type join. Joins
1545
+ # appended / stored element / key / value types into the
1546
+ # continuation collection so `out = [0]; arr.each { |x| out << x }`
1547
+ # types `Array[0 | Integer]`, not `Array[0]`. Same always-safe
1548
+ # rationale (only widens).
1422
1549
  post_scope = content_writeback_block_captures(node, post_scope)
1423
1550
  # Indexed-collection narrowing — drop any
1424
1551
  # `receiver[key] ||= default` narrowing the analyzer
@@ -1609,7 +1736,7 @@ module Rigor
1609
1736
  args = matcher.arguments&.arguments || []
1610
1737
  return nil unless args.size == 1
1611
1738
 
1612
- class_name = constant_node_name(args.first)
1739
+ class_name = Source::ConstantPath.qualified_name_or_nil(args.first)
1613
1740
  return nil if class_name.nil?
1614
1741
 
1615
1742
  { local: local_name, kind: :class, class_name: class_name, exact: exact }
@@ -1665,35 +1792,6 @@ module Rigor
1665
1792
  matcher.arguments.nil? || matcher.arguments.arguments.empty?
1666
1793
  end
1667
1794
 
1668
- # Decodes a `Prism::ConstantReadNode` /
1669
- # `Prism::ConstantPathNode` into a colon-joined class
1670
- # name string, or returns nil for any other node
1671
- # shape. Mirrors the conservative envelope used by the
1672
- # `is_a?` / `kind_of?` predicate narrower.
1673
- def constant_node_name(node)
1674
- case node
1675
- when Prism::ConstantReadNode
1676
- node.name.to_s
1677
- when Prism::ConstantPathNode
1678
- flatten_constant_path(node)
1679
- end
1680
- end
1681
-
1682
- def flatten_constant_path(node)
1683
- parts = []
1684
- cursor = node
1685
- while cursor.is_a?(Prism::ConstantPathNode)
1686
- parts.unshift(cursor.name.to_s)
1687
- cursor = cursor.parent
1688
- end
1689
- case cursor
1690
- when Prism::ConstantReadNode then parts.unshift(cursor.name.to_s)
1691
- when nil then nil # ::Foo absolute root — preserve as-is
1692
- else return nil
1693
- end
1694
- parts.join("::")
1695
- end
1696
-
1697
1795
  # Slice 4b-2 (ADR-7 § "Slice 4-A/4-B") — applies the
1698
1796
  # post-return facts the merger produces for an
1699
1797
  # `RBS::Extended`-annotated call. Reads through
@@ -1702,9 +1800,8 @@ module Rigor
1702
1800
  # rows for `:always` assert directives (the slice-4a
1703
1801
  # routing places conditional asserts on `truthy_facts` /
1704
1802
  # `falsey_facts`, which `Narrowing.predicate_scopes`
1705
- # consumes). Future plugin contributions that add
1706
- # `:always` assertions at the same call site flow through
1707
- # the same merger and land here.
1803
+ # consumes). Plugin `:always` assertions are handled by
1804
+ # the sibling `apply_plugin_assertions`, not this path.
1708
1805
  def apply_rbs_extended_assertions(call_node, current_scope)
1709
1806
  method_def = resolve_call_method(call_node, current_scope)
1710
1807
  return current_scope if method_def.nil?
@@ -1770,16 +1867,9 @@ module Rigor
1770
1867
  EMPTY_CONTRIBUTIONS = [].freeze
1771
1868
  private_constant :EMPTY_CONTRIBUTIONS
1772
1869
 
1773
- # Per-dispatch collection of plugin narrowing contributions. Mirrors
1774
- # `MethodDispatcher#collect_plugin_contributions`: visit only the
1775
- # registry-ordered subset of plugins that implement a per-call path
1776
- # (`for_statement` = declares a `type_specifier`), gate each path
1777
- # by membership AND by the ADR-52 WD1 method-name gates (every
1778
- # `type_specifier` rule is `methods:`-gated, so the common
1779
- # no-candidate case is a single Set probe; a pruned
1780
- # consultation could only have returned `[]`), and accumulate
1781
- # lazily (shared frozen empty array otherwise). Same contributions in
1782
- # the same order as visiting every plugin; the caller is read-only.
1870
+ # Fast-exit guard: skip if no plugin declares a `type_specifier`, or if
1871
+ # no registered method-name gate matches the call. See
1872
+ # `collect_gated_statement_contributions` for the full consultation.
1783
1873
  def collect_plugin_contributions(registry, call_node, current_scope)
1784
1874
  index = registry.contribution_index
1785
1875
  relevant = index.for_statement
@@ -1791,8 +1881,10 @@ module Rigor
1791
1881
  collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1792
1882
  end
1793
1883
 
1794
- # The post-gate walk, in registry order — the same order the
1795
- # ungated walk used.
1884
+ # ADR-37 slice 2 / ADR-52 WD1 — post-gate walk in registry order.
1885
+ # Visits only plugins in `for_statement` (declare a `type_specifier`),
1886
+ # further gated by the method-name Set probe so the common no-candidate
1887
+ # case is a single lookup. Accumulates lazily; caller is read-only.
1796
1888
  def collect_gated_statement_contributions(index, relevant, name, call_node, current_scope)
1797
1889
  result = nil
1798
1890
  relevant.each do |plugin|
@@ -2677,6 +2769,12 @@ module Rigor
2677
2769
  source_path: scope.source_path
2678
2770
  )
2679
2771
  bindings = binder.bind(def_node)
2772
+ # ADR-67 WD3 — override an undeclared parameter with its call-site
2773
+ # inferred type (precision-additive; an RBS-declared parameter wins,
2774
+ # the table is empty on a normal `check` run). The inferred type lives
2775
+ # only as a body local, never as an RBS contract, so it cannot fire a
2776
+ # parameter-boundary diagnostic (WD1, satisfied by construction).
2777
+ bindings = seed_inferred_param_types(bindings, def_node, singleton)
2680
2778
 
2681
2779
  # Method bodies do NOT see the outer scope's locals. They start
2682
2780
  # from a fresh scope with the same environment, then receive
@@ -2690,9 +2788,47 @@ module Rigor
2690
2788
  fresh = seed_instance_ivars(fresh, singleton: singleton)
2691
2789
  fresh = seed_class_cvars(fresh)
2692
2790
  fresh = seed_program_globals(fresh)
2791
+ # ADR-48 Struct slice 3 — install the method body's fold-safe-local set
2792
+ # so a member read off a mutation-free local folds during the in-body
2793
+ # walk (the call-return inference path is seeded separately).
2794
+ fresh = fresh.with_struct_fold_safe(
2795
+ StructFoldSafety.fold_safe_locals(
2796
+ def_node.body, ->(name) { scope.struct_member_layout(name)&.[](:members) }
2797
+ )
2798
+ )
2693
2799
  bindings.reduce(fresh) { |acc, (name, type)| acc.with_local(name, type) }
2694
2800
  end
2695
2801
 
2802
+ # ADR-67 WD3 — consult the call-site parameter-inference table for this
2803
+ # `def` and replace each undeclared (untyped) parameter binding with its
2804
+ # inferred type. Keyed by `[class_name, method_name, kind]`, reconstructed
2805
+ # from the lexical class path — the same triple
2806
+ # {Inference::ParameterInferenceCollector} records. An RBS-declared
2807
+ # parameter (a non-untyped binding) always wins. No-op when the table is
2808
+ # empty (the normal `check` path), so the seed is byte-identical there.
2809
+ def seed_inferred_param_types(bindings, def_node, singleton)
2810
+ inferred = scope.param_inferred_types
2811
+ return bindings if inferred.empty?
2812
+
2813
+ path = current_class_path
2814
+ return bindings if path.nil?
2815
+
2816
+ table = inferred[[path, def_node.name, singleton ? :singleton : :instance]]
2817
+ return bindings if table.nil? || table.empty?
2818
+
2819
+ merged = bindings.dup
2820
+ table.each do |name, type|
2821
+ merged[name] = type if merged.key?(name) && untyped_binding?(merged[name])
2822
+ end
2823
+ merged
2824
+ end
2825
+
2826
+ # True for the `Dynamic[Top]` carrier `MethodParameterBinder` leaves on an
2827
+ # undeclared parameter — the only bindings ADR-67 WD3 overrides.
2828
+ def untyped_binding?(type)
2829
+ type.is_a?(Type::Dynamic) && type.static_facet.is_a?(Type::Top)
2830
+ end
2831
+
2696
2832
  def seed_instance_ivars(body_scope, singleton:)
2697
2833
  return body_scope if singleton
2698
2834
 
@@ -2783,7 +2919,7 @@ module Rigor
2783
2919
  when Prism::ConstantReadNode
2784
2920
  receiver.name.to_s == prefix.last
2785
2921
  when Prism::ConstantPathNode
2786
- rendered = render_constant_path(receiver)
2922
+ rendered = Source::ConstantPath.render(receiver)
2787
2923
  return false unless rendered
2788
2924
 
2789
2925
  path = rendered.split("::")
@@ -2823,25 +2959,6 @@ module Rigor
2823
2959
  end
2824
2960
  end
2825
2961
 
2826
- def qualified_name_for(constant_path_node)
2827
- case constant_path_node
2828
- when Prism::ConstantReadNode
2829
- constant_path_node.name.to_s
2830
- when Prism::ConstantPathNode
2831
- render_constant_path(constant_path_node)
2832
- end
2833
- end
2834
-
2835
- def render_constant_path(node)
2836
- prefix =
2837
- case node.parent
2838
- when Prism::ConstantReadNode then "#{node.parent.name}::"
2839
- when Prism::ConstantPathNode then "#{render_constant_path(node.parent)}::"
2840
- else ""
2841
- end
2842
- "#{prefix}#{node.name}"
2843
- end
2844
-
2845
2962
  def singleton_context_for(node)
2846
2963
  case node.expression
2847
2964
  when Prism::SelfNode
@@ -2880,7 +2997,7 @@ module Rigor
2880
2997
  when Prism::ConstantReadNode
2881
2998
  expression.name.to_s
2882
2999
  when Prism::ConstantPathNode
2883
- render_constant_path(expression)
3000
+ Source::ConstantPath.render(expression)
2884
3001
  end
2885
3002
  end
2886
3003
 
@@ -2914,6 +3031,19 @@ module Rigor
2914
3031
  [Type::Combinator.bot, scope]
2915
3032
  end
2916
3033
 
3034
+ # A `break` transfers control to the loop exit (its flow value is `Bot`,
3035
+ # like `return`). It records the current scope into the active loop's
3036
+ # break sink so the loop join can recover a `break`-path binding the
3037
+ # fall-through would drop (`flag = true; break` -> `flag` is `false |
3038
+ # true` after the loop). nil sink = a `break` not inside an inferred
3039
+ # loop body (a block targeting a method, or top-level) — left to the
3040
+ # existing escaping-block / no-op handling.
3041
+ def eval_break(node)
3042
+ sink = Thread.current[BREAK_SINK_KEY]
3043
+ sink << [node, scope] if sink
3044
+ [Type::Combinator.bot, scope]
3045
+ end
3046
+
2917
3047
  def record_return_value(node, sink)
2918
3048
  args = node.arguments&.arguments || []
2919
3049
  # `return` with no argument returns nil; `return a` records the