rigortype 0.0.1 → 0.0.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/data/builtins/ruby_core/array.yml +1470 -0
  3. data/data/builtins/ruby_core/file.yml +501 -0
  4. data/data/builtins/ruby_core/io.yml +1594 -0
  5. data/data/builtins/ruby_core/numeric.yml +1809 -0
  6. data/data/builtins/ruby_core/string.yml +1850 -0
  7. data/lib/rigor/analysis/check_rules.rb +297 -5
  8. data/lib/rigor/analysis/diagnostic.rb +13 -2
  9. data/lib/rigor/analysis/runner.rb +52 -5
  10. data/lib/rigor/builtins/imported_refinements.rb +69 -0
  11. data/lib/rigor/cli/type_of_command.rb +11 -5
  12. data/lib/rigor/cli/type_scan_command.rb +13 -8
  13. data/lib/rigor/cli.rb +26 -6
  14. data/lib/rigor/configuration.rb +18 -2
  15. data/lib/rigor/environment.rb +3 -1
  16. data/lib/rigor/inference/acceptance.rb +180 -0
  17. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  18. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  19. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  20. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  21. data/lib/rigor/inference/expression_typer.rb +151 -0
  22. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
  23. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  24. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  26. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
  27. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  28. data/lib/rigor/inference/narrowing.rb +471 -10
  29. data/lib/rigor/inference/scope_indexer.rb +66 -0
  30. data/lib/rigor/inference/statement_evaluator.rb +305 -2
  31. data/lib/rigor/rbs_extended.rb +174 -14
  32. data/lib/rigor/scope.rb +44 -5
  33. data/lib/rigor/type/combinator.rb +69 -1
  34. data/lib/rigor/type/difference.rb +155 -0
  35. data/lib/rigor/type/integer_range.rb +137 -0
  36. data/lib/rigor/type.rb +2 -0
  37. data/lib/rigor/version.rb +1 -1
  38. data/sig/rigor/inference.rbs +5 -2
  39. data/sig/rigor/rbs_extended.rbs +25 -1
  40. data/sig/rigor/scope.rbs +4 -0
  41. data/sig/rigor/type.rbs +51 -1
  42. metadata +15 -1
@@ -155,6 +155,61 @@ module Rigor
155
155
  end
156
156
  end
157
157
 
158
+ # Integer-comparison fragment of `type` against an Integer
159
+ # literal `bound`. Narrows the receiver of `x < n`, `x <= n`,
160
+ # `x > n`, `x >= n` (and the reversed forms) to the subset of
161
+ # the existing domain that satisfies the comparison. Hooks in:
162
+ # - `Constant<Integer>` is preserved when it satisfies the
163
+ # comparison, otherwise collapsed to `Bot`.
164
+ # - `IntegerRange[a..b]` becomes the intersection with the
165
+ # half-line implied by the comparison; an empty intersection
166
+ # collapses to `Bot`, a single-point intersection collapses
167
+ # to `Constant<Integer>`.
168
+ # - `Nominal[Integer]` becomes the half-line itself (e.g.
169
+ # `x > 0` on `Nominal[Integer]` is `positive_int`).
170
+ # - `Union` narrows each member independently.
171
+ # - Other carriers (Float, String, Top, Dynamic) flow through
172
+ # unchanged: the analyzer does not have a Float-range carrier
173
+ # today, and no other carrier participates in numeric ordering.
174
+ def narrow_integer_comparison(type, comparator, bound)
175
+ return type unless bound.is_a?(Integer) && %i[< <= > >=].include?(comparator)
176
+
177
+ narrow_integer_comparison_dispatch(type, comparator, bound)
178
+ end
179
+
180
+ # Equality fragment of `type` against an Integer `value`.
181
+ # `Constant<Integer>` is preserved when it equals `value`,
182
+ # otherwise collapses to `Bot`. `IntegerRange` covers? `value`
183
+ # narrows to `Constant[value]`; an out-of-range comparison
184
+ # collapses to `Bot`. `Nominal[Integer]` narrows to
185
+ # `Constant[value]`. `Union` narrows each member.
186
+ def narrow_integer_equal(type, value)
187
+ return type unless value.is_a?(Integer)
188
+
189
+ narrow_integer_equal_dispatch(type, value)
190
+ end
191
+
192
+ # Complement of {.narrow_integer_equal}. Removes a single
193
+ # integer value from the domain when one endpoint of an
194
+ # `IntegerRange` is exactly that value (so the result stays a
195
+ # contiguous range). Domains where the value sits strictly
196
+ # between the endpoints stay unchanged: punching a hole would
197
+ # require a two-piece carrier the lattice does not yet model.
198
+ def narrow_integer_not_equal(type, value)
199
+ return type unless value.is_a?(Integer)
200
+
201
+ case type
202
+ when Type::Constant
203
+ type.value == value ? Type::Combinator.bot : type
204
+ when Type::IntegerRange
205
+ narrow_integer_range_not_equal(type, value)
206
+ when Type::Union
207
+ Type::Combinator.union(*type.members.map { |m| narrow_integer_not_equal(m, value) })
208
+ else
209
+ type
210
+ end
211
+ end
212
+
158
213
  # Class-membership fragment of `type`: the subset whose
159
214
  # inhabitants are instances of `class_name` (or its subclasses
160
215
  # when `exact: false`). `class_name` is the qualified name of
@@ -424,10 +479,49 @@ module Rigor
424
479
  # `rigor:v1:predicate-if-true` / `predicate-if-false`
425
480
  # annotations, apply them to narrow the corresponding
426
481
  # local-variable arguments on each edge.
427
- analyse_rbs_extended_predicate(node, scope)
482
+ predicate_result = analyse_rbs_extended_predicate(node, scope)
483
+ assert_result = analyse_rbs_extended_assert_if(node, scope)
484
+ merge_extended_results(predicate_result, assert_result, scope)
485
+ end
486
+
487
+ # Combines two `[truthy_scope, falsey_scope]` pair
488
+ # results from sibling RBS::Extended analysers
489
+ # (`predicate-if-*` and `assert-if-*`). When only one
490
+ # side fires, return it directly; when both fire the
491
+ # right side's per-local deltas are applied on top of
492
+ # the left side's edges so the rules compose.
493
+ def merge_extended_results(left, right, base_scope)
494
+ return left if right.nil?
495
+ return right if left.nil?
496
+
497
+ [
498
+ merge_scope_pair(left[0], right[0], base_scope),
499
+ merge_scope_pair(left[1], right[1], base_scope)
500
+ ]
501
+ end
502
+
503
+ def merge_scope_pair(left_scope, right_scope, base_scope)
504
+ right_scope.locals.reduce(left_scope) do |acc, (name, type)|
505
+ base_type = base_scope.local(name)
506
+ type.equal?(base_type) ? acc : acc.with_local(name, type)
507
+ end
428
508
  end
429
509
 
510
+ ZERO_CLASS_PREDICATES = %i[positive? negative? zero? nonzero?].freeze
511
+ COMPARISON_OPERATORS = %i[< <= > >=].freeze
512
+ private_constant :ZERO_CLASS_PREDICATES, :COMPARISON_OPERATORS
513
+
430
514
  def dispatch_call(node, scope, name)
515
+ return dispatch_call_simple(node, scope, name) if simple_dispatch_name?(name)
516
+
517
+ dispatch_call_numeric(node, scope, name)
518
+ end
519
+
520
+ def simple_dispatch_name?(name)
521
+ %i[nil? ! is_a? kind_of? instance_of? == != ===].include?(name)
522
+ end
523
+
524
+ def dispatch_call_simple(node, scope, name)
431
525
  case name
432
526
  when :nil?, :! then dispatch_unary_predicate(node, scope, name)
433
527
  when :is_a?, :kind_of? then analyse_class_predicate(node, scope, exact: false)
@@ -437,6 +531,92 @@ module Rigor
437
531
  end
438
532
  end
439
533
 
534
+ def dispatch_call_numeric(node, scope, name)
535
+ if COMPARISON_OPERATORS.include?(name)
536
+ analyse_comparison_predicate(node, scope, comparator: name)
537
+ elsif ZERO_CLASS_PREDICATES.include?(name)
538
+ analyse_zero_class_predicate(node, scope, predicate: name)
539
+ elsif name == :between?
540
+ analyse_between_predicate(node, scope)
541
+ end
542
+ end
543
+
544
+ # `:positive?` / `:negative?` / `:zero?` / `:nonzero?` are
545
+ # zero-arg predicates on `Numeric`. We model them as
546
+ # comparisons against the literal 0 so the existing range
547
+ # narrowing handles them uniformly.
548
+ ZERO_CLASS_PREDICATE_RULES = {
549
+ positive?: { truthy: [:>, 0], falsey: [:<=, 0] },
550
+ negative?: { truthy: [:<, 0], falsey: [:>=, 0] },
551
+ zero?: { truthy: [:eq, 0], falsey: [:ne, 0] },
552
+ nonzero?: { truthy: [:ne, 0], falsey: [:eq, 0] }
553
+ }.freeze
554
+ private_constant :ZERO_CLASS_PREDICATE_RULES
555
+
556
+ def analyse_zero_class_predicate(node, scope, predicate:)
557
+ return nil unless argument_free?(node)
558
+ return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
559
+
560
+ local_name = node.receiver.name
561
+ current = scope.local(local_name)
562
+ return nil if current.nil?
563
+
564
+ rules = ZERO_CLASS_PREDICATE_RULES[predicate]
565
+ truthy = apply_zero_rule(current, rules[:truthy])
566
+ falsey = apply_zero_rule(current, rules[:falsey])
567
+ [scope.with_local(local_name, truthy), scope.with_local(local_name, falsey)]
568
+ end
569
+
570
+ def apply_zero_rule(type, rule)
571
+ case rule[0]
572
+ when :eq then narrow_integer_equal(type, rule[1])
573
+ when :ne then narrow_integer_not_equal(type, rule[1])
574
+ else
575
+ narrow_integer_comparison(type, rule[0], rule[1])
576
+ end
577
+ end
578
+
579
+ # `x.between?(a, b)` truthy edge narrows to
580
+ # `narrow_integer_comparison(>=, a)` ∩
581
+ # `narrow_integer_comparison(<=, b)`. The falsey edge is left
582
+ # unchanged because the complement is a two-piece domain
583
+ # (`x < a || x > b`) that the lattice cannot express
584
+ # precisely. `a` and `b` MUST both be integer literals.
585
+ def analyse_between_predicate(node, scope)
586
+ return nil unless node.receiver.is_a?(Prism::LocalVariableReadNode)
587
+ return nil if node.arguments.nil?
588
+ return nil unless node.arguments.arguments.size == 2
589
+
590
+ low, high = node.arguments.arguments
591
+ return nil unless low.is_a?(Prism::IntegerNode) && high.is_a?(Prism::IntegerNode)
592
+
593
+ local_name = node.receiver.name
594
+ current = scope.local(local_name)
595
+ return nil if current.nil?
596
+
597
+ truthy = narrow_integer_comparison(
598
+ narrow_integer_comparison(current, :>=, low.value),
599
+ :<=, high.value
600
+ )
601
+ [scope.with_local(local_name, truthy), scope]
602
+ end
603
+
604
+ # Helper for {.narrow_integer_not_equal}. Only adjusts when the
605
+ # value sits exactly on one endpoint, so the result stays
606
+ # contiguous; otherwise the input range is preserved.
607
+ def narrow_integer_range_not_equal(range, value)
608
+ return range if range.lower > value || range.upper < value
609
+ return Type::Combinator.bot if single_point_range_equal?(range, value)
610
+ return build_narrowing_integer_range(value + 1, range.upper) if range.lower == value
611
+ return build_narrowing_integer_range(range.lower, value - 1) if range.upper == value
612
+
613
+ range
614
+ end
615
+
616
+ def single_point_range_equal?(range, value)
617
+ range.finite? && range.min == value && range.max == value
618
+ end
619
+
440
620
  def dispatch_unary_predicate(node, scope, name)
441
621
  return nil unless argument_free?(node)
442
622
 
@@ -466,6 +646,126 @@ module Rigor
466
646
  equality == :== ? [positive, negative] : [negative, positive]
467
647
  end
468
648
 
649
+ # Comparison predicate analyser. Recognised shapes:
650
+ # x < Int x <= Int x > Int x >= Int
651
+ # Int < x Int <= x Int > x Int >= x
652
+ # The reversed (literal-on-left) form is normalised by
653
+ # transposing the operator so the receiver-local always
654
+ # appears on the left of the rule.
655
+ INVERT_COMPARISON_OP = { :< => :>=, :<= => :>, :> => :<=, :>= => :< }.freeze
656
+ REVERSE_COMPARISON_OP = { :< => :>, :<= => :>=, :> => :<, :>= => :<= }.freeze
657
+ private_constant :INVERT_COMPARISON_OP, :REVERSE_COMPARISON_OP
658
+
659
+ def analyse_comparison_predicate(node, scope, comparator:)
660
+ return nil if node.arguments.nil?
661
+ return nil unless node.arguments.arguments.size == 1
662
+
663
+ match = comparison_local_literal(node.receiver, node.arguments.arguments.first, comparator)
664
+ return nil if match.nil?
665
+
666
+ local_name, normalised_op, bound = match
667
+ current = scope.local(local_name)
668
+ return nil if current.nil?
669
+
670
+ truthy = narrow_integer_comparison(current, normalised_op, bound)
671
+ falsey = narrow_integer_comparison(current, INVERT_COMPARISON_OP[normalised_op], bound)
672
+ [scope.with_local(local_name, truthy), scope.with_local(local_name, falsey)]
673
+ end
674
+
675
+ def comparison_local_literal(left, right, comparator)
676
+ if left.is_a?(Prism::LocalVariableReadNode) && right.is_a?(Prism::IntegerNode)
677
+ return [left.name, comparator, right.value]
678
+ end
679
+ return nil unless right.is_a?(Prism::LocalVariableReadNode) && left.is_a?(Prism::IntegerNode)
680
+
681
+ [right.name, REVERSE_COMPARISON_OP[comparator], left.value]
682
+ end
683
+
684
+ def narrow_integer_equal_dispatch(type, value)
685
+ case type
686
+ when Type::Constant then narrow_integer_equal_constant(type, value)
687
+ when Type::IntegerRange then narrow_integer_equal_range(type, value)
688
+ when Type::Nominal then narrow_integer_equal_nominal(type, value)
689
+ when Type::Union
690
+ Type::Combinator.union(*type.members.map { |m| narrow_integer_equal(m, value) })
691
+ else type
692
+ end
693
+ end
694
+
695
+ def narrow_integer_equal_constant(constant, value)
696
+ constant.value == value ? constant : Type::Combinator.bot
697
+ end
698
+
699
+ def narrow_integer_equal_range(range, value)
700
+ range.covers?(value) ? Type::Combinator.constant_of(value) : Type::Combinator.bot
701
+ end
702
+
703
+ def narrow_integer_equal_nominal(nominal, value)
704
+ return nominal unless nominal.class_name == "Integer" && nominal.type_args.empty?
705
+
706
+ Type::Combinator.constant_of(value)
707
+ end
708
+
709
+ def narrow_integer_comparison_dispatch(type, comparator, bound)
710
+ case type
711
+ when Type::Constant
712
+ integer_constant_satisfies?(type.value, comparator, bound) ? type : Type::Combinator.bot
713
+ when Type::IntegerRange
714
+ intersect_integer_range(type, comparator, bound)
715
+ when Type::Nominal
716
+ narrow_integer_comparison_nominal(type, comparator, bound)
717
+ when Type::Union
718
+ Type::Combinator.union(
719
+ *type.members.map { |m| narrow_integer_comparison(m, comparator, bound) }
720
+ )
721
+ else
722
+ type
723
+ end
724
+ end
725
+
726
+ def narrow_integer_comparison_nominal(nominal, comparator, bound)
727
+ return nominal unless nominal.class_name == "Integer" && nominal.type_args.empty?
728
+
729
+ intersect_integer_range(Type::Combinator.universal_int, comparator, bound)
730
+ end
731
+
732
+ def integer_constant_satisfies?(value, comparator, bound)
733
+ return false unless value.is_a?(Integer)
734
+
735
+ case comparator
736
+ when :< then value < bound
737
+ when :<= then value <= bound
738
+ when :> then value > bound
739
+ when :>= then value >= bound
740
+ end
741
+ end
742
+
743
+ def intersect_integer_range(range, comparator, bound)
744
+ new_lower, new_upper = comparison_endpoints(range, comparator, bound)
745
+ return Type::Combinator.bot if new_lower > new_upper
746
+
747
+ build_narrowing_integer_range(new_lower, new_upper)
748
+ end
749
+
750
+ def comparison_endpoints(range, comparator, bound)
751
+ case comparator
752
+ when :< then [range.lower, [range.upper, bound - 1].min]
753
+ when :<= then [range.lower, [range.upper, bound].min]
754
+ when :> then [[range.lower, bound + 1].max, range.upper]
755
+ when :>= then [[range.lower, bound].max, range.upper]
756
+ end
757
+ end
758
+
759
+ def build_narrowing_integer_range(lower, upper)
760
+ min = lower == -Float::INFINITY ? Type::IntegerRange::NEG_INFINITY : Integer(lower)
761
+ max = upper == Float::INFINITY ? Type::IntegerRange::POS_INFINITY : Integer(upper)
762
+ if min.is_a?(Integer) && max.is_a?(Integer) && min == max
763
+ Type::Combinator.constant_of(min)
764
+ else
765
+ Type::Combinator.integer_range(min, max)
766
+ end
767
+ end
768
+
469
769
  def equality_local_literal(left, right, scope)
470
770
  if left.is_a?(Prism::LocalVariableReadNode)
471
771
  literal = static_literal_type(right, scope)
@@ -669,6 +969,73 @@ module Rigor
669
969
  [truthy_scope, falsey_scope]
670
970
  end
671
971
 
972
+ # v0.0.2 — `assert-if-true` / `assert-if-false`. Reads
973
+ # the conditional assertion effects off the called
974
+ # method and narrows the matching argument on the
975
+ # corresponding edge. The unconditional `assert`
976
+ # variant is NOT applied here; `StatementEvaluator`
977
+ # applies it directly to the post-call scope.
978
+ def analyse_rbs_extended_assert_if(node, scope)
979
+ method_def = resolve_rbs_extended_method(node, scope)
980
+ return nil if method_def.nil?
981
+
982
+ effects = RbsExtended.read_assert_effects(method_def).reject(&:always?)
983
+ return nil if effects.empty?
984
+
985
+ truthy_scope = scope
986
+ falsey_scope = scope
987
+ effects.each do |effect|
988
+ truthy_scope, falsey_scope =
989
+ apply_assert_if_effect(effect, node, scope, truthy_scope, falsey_scope, method_def)
990
+ end
991
+ [truthy_scope, falsey_scope]
992
+ end
993
+
994
+ # rubocop:disable Metrics/ParameterLists
995
+ def apply_assert_if_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
996
+ target_node = effect_target_node(effect, call_node, method_def)
997
+ return [truthy_scope, falsey_scope] unless target_node.is_a?(Prism::LocalVariableReadNode)
998
+
999
+ local_name = target_node.name
1000
+ current = entry_scope.local(local_name)
1001
+ return [truthy_scope, falsey_scope] if current.nil?
1002
+
1003
+ narrowed = narrow_for_effect(current, effect, entry_scope.environment)
1004
+ if effect.if_truthy_return?
1005
+ [truthy_scope.with_local(local_name, narrowed), falsey_scope]
1006
+ else
1007
+ [truthy_scope, falsey_scope.with_local(local_name, narrowed)]
1008
+ end
1009
+ end
1010
+ # rubocop:enable Metrics/ParameterLists
1011
+
1012
+ # v0.0.2 #3 — resolves an effect's target node. For
1013
+ # `target: <param>` we look up the matching positional
1014
+ # argument; for `target: self` we use the call's
1015
+ # receiver. In both cases the caller still requires a
1016
+ # `Prism::LocalVariableReadNode` for narrowing to
1017
+ # actually fire (the engine's narrowing surface only
1018
+ # rebinds locals).
1019
+ def effect_target_node(effect, call_node, method_def)
1020
+ if effect.target_kind == :self
1021
+ call_node.receiver
1022
+ else
1023
+ lookup_positional_arg(call_node, method_def, effect.target_name)
1024
+ end
1025
+ end
1026
+
1027
+ # v0.0.2 — selects `narrow_class` (positive) or
1028
+ # `narrow_not_class` (negative `~T` form) based on
1029
+ # the effect's `negative?` flag. Shared between
1030
+ # predicate-if-* and assert-if-* application paths.
1031
+ def narrow_for_effect(current, effect, environment)
1032
+ if effect.negative?
1033
+ narrow_not_class(current, effect.class_name, exact: false, environment: environment)
1034
+ else
1035
+ narrow_class(current, effect.class_name, exact: false, environment: environment)
1036
+ end
1037
+ end
1038
+
672
1039
  def resolve_rbs_extended_method(node, scope)
673
1040
  loader = scope.environment.rbs_loader
674
1041
  return nil if loader.nil?
@@ -709,15 +1076,14 @@ module Rigor
709
1076
 
710
1077
  # rubocop:disable Metrics/ParameterLists
711
1078
  def apply_predicate_effect(effect, call_node, entry_scope, truthy_scope, falsey_scope, method_def)
712
- arg_node = lookup_positional_arg(call_node, method_def, effect.target_name)
713
- return [truthy_scope, falsey_scope] if effect.target_kind != :parameter
714
- return [truthy_scope, falsey_scope] unless arg_node.is_a?(Prism::LocalVariableReadNode)
1079
+ target_node = effect_target_node(effect, call_node, method_def)
1080
+ return [truthy_scope, falsey_scope] unless target_node.is_a?(Prism::LocalVariableReadNode)
715
1081
 
716
- local_name = arg_node.name
1082
+ local_name = target_node.name
717
1083
  current = entry_scope.local(local_name)
718
1084
  return [truthy_scope, falsey_scope] if current.nil?
719
1085
 
720
- narrowed = narrow_class(current, effect.class_name, exact: false, environment: entry_scope.environment)
1086
+ narrowed = narrow_for_effect(current, effect, entry_scope.environment)
721
1087
  if effect.truthy_only?
722
1088
  [truthy_scope.with_local(local_name, narrowed), falsey_scope]
723
1089
  else
@@ -759,10 +1125,11 @@ module Rigor
759
1125
  fully_narrowable = true
760
1126
 
761
1127
  conditions.each do |condition|
762
- target = static_class_name(condition) || case_equality_target_class(condition)
763
- if target
764
- truthy_members << narrow_class(current, target, exact: false, environment: scope.environment)
765
- falsey_type = narrow_not_class(falsey_type, target, exact: false, environment: scope.environment)
1128
+ applied = apply_case_when_condition(scope, current, condition, falsey_type)
1129
+ if applied
1130
+ truthy_members << applied[:truthy]
1131
+ falsey_type = applied[:falsey]
1132
+ fully_narrowable &&= applied[:fully_narrowable]
766
1133
  else
767
1134
  fully_narrowable = false
768
1135
  end
@@ -775,6 +1142,100 @@ module Rigor
775
1142
  ]
776
1143
  end
777
1144
 
1145
+ # Per-condition rule. Returns `nil` when the condition shape
1146
+ # is not recognised (caller marks `fully_narrowable = false`),
1147
+ # or `{truthy:, falsey:, fully_narrowable:}` when it is.
1148
+ def apply_case_when_condition(scope, current, condition, falsey_acc)
1149
+ int_range = case_equality_integer_range(condition)
1150
+ return integer_range_when_result(current, int_range, falsey_acc) if int_range && integer_rooted_type?(current)
1151
+
1152
+ int_literal = case_equality_integer_literal(condition)
1153
+ if int_literal && integer_rooted_type?(current)
1154
+ return integer_literal_when_result(current, int_literal, falsey_acc)
1155
+ end
1156
+
1157
+ target = static_class_name(condition) || case_equality_target_class(condition)
1158
+ return class_when_result(scope, current, target, falsey_acc) if target
1159
+
1160
+ nil
1161
+ end
1162
+
1163
+ def case_equality_integer_literal(condition)
1164
+ condition = unwrap_parens(condition)
1165
+ condition.is_a?(Prism::IntegerNode) ? condition.value : nil
1166
+ end
1167
+
1168
+ def integer_literal_when_result(current, value, falsey_acc)
1169
+ # `case n when k` is `k === n` which for Integer is value
1170
+ # equality. The truthy edge collapses the local to
1171
+ # `Constant[k]`; the falsey edge tightens via
1172
+ # `narrow_integer_not_equal` (only effective when k sits
1173
+ # at one endpoint of the current range).
1174
+ {
1175
+ truthy: narrow_integer_equal(current, value),
1176
+ falsey: narrow_integer_not_equal(falsey_acc, value),
1177
+ fully_narrowable: true
1178
+ }
1179
+ end
1180
+
1181
+ def integer_range_when_result(current, range_pair, falsey_acc)
1182
+ low, high = range_pair
1183
+ truthy = narrow_integer_comparison(
1184
+ narrow_integer_comparison(current, :>=, low),
1185
+ :<=, high
1186
+ )
1187
+ # The falsey edge of `n in [a, b]` is two-piece; we cannot
1188
+ # express the complement precisely with a single carrier,
1189
+ # so keep the accumulator unchanged. `fully_narrowable: false`
1190
+ # forces the else-branch to see `current` (the unmodified
1191
+ # entry type), which mirrors `between?` falsey behaviour.
1192
+ { truthy: truthy, falsey: falsey_acc, fully_narrowable: false }
1193
+ end
1194
+
1195
+ def class_when_result(scope, current, target, falsey_acc)
1196
+ {
1197
+ truthy: narrow_class(current, target, exact: false, environment: scope.environment),
1198
+ falsey: narrow_not_class(falsey_acc, target, exact: false, environment: scope.environment),
1199
+ fully_narrowable: true
1200
+ }
1201
+ end
1202
+
1203
+ # Returns `[low, high]` for a `Prism::RangeNode` whose
1204
+ # endpoints are both `Prism::IntegerNode` literals, with
1205
+ # `..`/`...` exclusivity respected. Open-ended ranges use
1206
+ # the symbolic infinities so the existing comparison
1207
+ # narrowing tier handles them. Returns `nil` for any other
1208
+ # shape (Float endpoints, String endpoints, dynamic
1209
+ # expressions).
1210
+ def case_equality_integer_range(condition)
1211
+ condition = unwrap_parens(condition)
1212
+ return nil unless condition.is_a?(Prism::RangeNode)
1213
+
1214
+ low = integer_range_endpoint(condition.left, default: Type::IntegerRange::NEG_INFINITY)
1215
+ high = integer_range_endpoint(condition.right, default: Type::IntegerRange::POS_INFINITY)
1216
+ return nil if low.nil? || high.nil?
1217
+
1218
+ high -= 1 if condition.exclude_end? && high.is_a?(Integer)
1219
+ [low, high]
1220
+ end
1221
+
1222
+ def integer_range_endpoint(node, default:)
1223
+ return default if node.nil?
1224
+ return node.value if node.is_a?(Prism::IntegerNode)
1225
+
1226
+ nil
1227
+ end
1228
+
1229
+ def integer_rooted_type?(type)
1230
+ case type
1231
+ when Type::Constant then type.value.is_a?(Integer)
1232
+ when Type::IntegerRange then true
1233
+ when Type::Nominal then type.class_name == "Integer" && type.type_args.empty?
1234
+ when Type::Union then type.members.all? { |m| integer_rooted_type?(m) }
1235
+ else false
1236
+ end
1237
+ end
1238
+
778
1239
  def range_target_class(range_node)
779
1240
  left = range_node.left
780
1241
  right = range_node.right
@@ -98,6 +98,13 @@ module Rigor
98
98
  discovered_methods = build_discovered_methods(root)
99
99
  seeded_scope = seeded_scope.with_discovered_methods(discovered_methods)
100
100
 
101
+ # v0.0.2 #5 — also record the def node itself for
102
+ # instance methods so the engine can re-type the body
103
+ # when a call site dispatches against a user-defined
104
+ # method without an RBS sig.
105
+ discovered_def_nodes = build_discovered_def_nodes(root)
106
+ seeded_scope = seeded_scope.with_discovered_def_nodes(discovered_def_nodes)
107
+
101
108
  table = {}.compare_by_identity
102
109
  table.default = seeded_scope
103
110
 
@@ -369,6 +376,65 @@ module Rigor
369
376
  accumulator[class_name][def_node.name] = kind
370
377
  end
371
378
 
379
+ # v0.0.2 #5 — instance-side def-node recording. Walks
380
+ # class bodies the same way as `build_discovered_methods`
381
+ # but records the actual `Prism::DefNode` for each
382
+ # **instance** method so `ExpressionTyper` can re-type
383
+ # the body at the call site for inter-procedural return
384
+ # inference. Singleton methods and `define_method` calls
385
+ # are intentionally skipped: the inference path needs a
386
+ # statically introspectable body, and singleton dispatch
387
+ # has its own complications (Class / Module ancestry)
388
+ # the first-iteration rule does not yet model.
389
+ def build_discovered_def_nodes(root)
390
+ accumulator = {}
391
+ walk_def_nodes(root, [], false, accumulator)
392
+ accumulator.transform_values(&:freeze).freeze
393
+ end
394
+
395
+ def walk_def_nodes(node, qualified_prefix, in_singleton_class, accumulator) # rubocop:disable Metrics/CyclomaticComplexity
396
+ return unless node.is_a?(Prism::Node)
397
+
398
+ case node
399
+ when Prism::ClassNode, Prism::ModuleNode
400
+ name = qualified_name_for(node.constant_path)
401
+ if name
402
+ child_prefix = qualified_prefix + [name]
403
+ walk_def_nodes(node.body, child_prefix, false, accumulator) if node.body
404
+ return
405
+ end
406
+ when Prism::SingletonClassNode
407
+ if node.expression.is_a?(Prism::SelfNode) && node.body
408
+ walk_def_nodes(node.body, qualified_prefix, true, accumulator)
409
+ return
410
+ end
411
+ when Prism::DefNode
412
+ record_def_node(node, qualified_prefix, in_singleton_class, accumulator)
413
+ return
414
+ end
415
+
416
+ node.compact_child_nodes.each do |child|
417
+ walk_def_nodes(child, qualified_prefix, in_singleton_class, accumulator)
418
+ end
419
+ end
420
+
421
+ # v0.0.3 A — sentinel key under which `record_def_node`
422
+ # files DefNodes that live outside any class / module
423
+ # body (top-level helpers, `def`s nested inside DSL
424
+ # blocks like `RSpec.describe ... do; def helper; end`).
425
+ # Looked up by `Scope#top_level_def_for` to give
426
+ # implicit-self calls priority over RBS dispatch when
427
+ # the file defines a same-named local method.
428
+ TOP_LEVEL_DEF_KEY = "<toplevel>"
429
+
430
+ def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
431
+ return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
432
+
433
+ class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
434
+ accumulator[class_name] ||= {}
435
+ accumulator[class_name][def_node.name] = def_node
436
+ end
437
+
372
438
  def record_define_method(call_node, qualified_prefix, in_singleton_class, accumulator)
373
439
  return if qualified_prefix.empty?
374
440
  return if call_node.arguments.nil? || call_node.arguments.arguments.empty?