rigortype 0.0.2 → 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 (36) 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 +86 -1
  8. data/lib/rigor/analysis/runner.rb +4 -0
  9. data/lib/rigor/builtins/imported_refinements.rb +69 -0
  10. data/lib/rigor/configuration.rb +6 -1
  11. data/lib/rigor/inference/acceptance.rb +149 -0
  12. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  13. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  14. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  15. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  16. data/lib/rigor/inference/expression_typer.rb +48 -1
  17. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
  18. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  19. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
  20. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  21. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
  22. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  23. data/lib/rigor/inference/narrowing.rb +374 -4
  24. data/lib/rigor/inference/scope_indexer.rb +10 -2
  25. data/lib/rigor/inference/statement_evaluator.rb +211 -2
  26. data/lib/rigor/rbs_extended.rb +65 -1
  27. data/lib/rigor/scope.rb +14 -0
  28. data/lib/rigor/type/combinator.rb +69 -1
  29. data/lib/rigor/type/difference.rb +155 -0
  30. data/lib/rigor/type/integer_range.rb +137 -0
  31. data/lib/rigor/type.rb +2 -0
  32. data/lib/rigor/version.rb +1 -1
  33. data/sig/rigor/rbs_extended.rbs +3 -0
  34. data/sig/rigor/scope.rbs +1 -0
  35. data/sig/rigor/type.rbs +51 -1
  36. 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
@@ -452,7 +507,21 @@ module Rigor
452
507
  end
453
508
  end
454
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
+
455
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)
456
525
  case name
457
526
  when :nil?, :! then dispatch_unary_predicate(node, scope, name)
458
527
  when :is_a?, :kind_of? then analyse_class_predicate(node, scope, exact: false)
@@ -462,6 +531,92 @@ module Rigor
462
531
  end
463
532
  end
464
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
+
465
620
  def dispatch_unary_predicate(node, scope, name)
466
621
  return nil unless argument_free?(node)
467
622
 
@@ -491,6 +646,126 @@ module Rigor
491
646
  equality == :== ? [positive, negative] : [negative, positive]
492
647
  end
493
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
+
494
769
  def equality_local_literal(left, right, scope)
495
770
  if left.is_a?(Prism::LocalVariableReadNode)
496
771
  literal = static_literal_type(right, scope)
@@ -850,10 +1125,11 @@ module Rigor
850
1125
  fully_narrowable = true
851
1126
 
852
1127
  conditions.each do |condition|
853
- target = static_class_name(condition) || case_equality_target_class(condition)
854
- if target
855
- truthy_members << narrow_class(current, target, exact: false, environment: scope.environment)
856
- 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]
857
1133
  else
858
1134
  fully_narrowable = false
859
1135
  end
@@ -866,6 +1142,100 @@ module Rigor
866
1142
  ]
867
1143
  end
868
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
+
869
1239
  def range_target_class(range_node)
870
1240
  left = range_node.left
871
1241
  right = range_node.right
@@ -418,11 +418,19 @@ module Rigor
418
418
  end
419
419
  end
420
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
+
421
430
  def record_def_node(def_node, qualified_prefix, in_singleton_class, accumulator)
422
- return if qualified_prefix.empty?
423
431
  return if def_node.receiver.is_a?(Prism::SelfNode) || in_singleton_class
424
432
 
425
- class_name = qualified_prefix.join("::")
433
+ class_name = qualified_prefix.empty? ? TOP_LEVEL_DEF_KEY : qualified_prefix.join("::")
426
434
  accumulator[class_name] ||= {}
427
435
  accumulator[class_name][def_node.name] = def_node
428
436
  end
@@ -335,7 +335,17 @@ module Rigor
335
335
  # nil-injection on half-bound names so a name set in one branch
336
336
  # but not the other is observable as `T | nil` after the if.
337
337
  def eval_if(node)
338
- _pred_type, post_pred = sub_eval(node.predicate, scope)
338
+ pred_type, post_pred = sub_eval(node.predicate, scope)
339
+
340
+ # When the predicate is a known-truthy / known-falsey type
341
+ # (notably `Constant[true]` / `Constant[false]` after the
342
+ # constant-fold tier), only the live branch contributes a
343
+ # type and a post-scope. The dead branch is skipped so the
344
+ # result type is precise (`Constant[:even]` instead of the
345
+ # joined `Constant[:even] | Constant[:odd]`).
346
+ live = live_branch_for_if(node, pred_type, post_pred)
347
+ return live if live
348
+
339
349
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
340
350
  then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
341
351
  else_type, else_scope = eval_branch_or_nil(node.subsequent, falsey_scope)
@@ -360,7 +370,11 @@ module Rigor
360
370
  # narrower's truthy/falsey edges are routed in swapped form
361
371
  # because `unless` runs its body when the predicate is falsey.
362
372
  def eval_unless(node)
363
- _pred_type, post_pred = sub_eval(node.predicate, scope)
373
+ pred_type, post_pred = sub_eval(node.predicate, scope)
374
+
375
+ live = live_branch_for_unless(node, pred_type, post_pred)
376
+ return live if live
377
+
364
378
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
365
379
  then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
366
380
  else_type, else_scope = eval_branch_or_nil(node.else_clause, truthy_scope)
@@ -378,6 +392,38 @@ module Rigor
378
392
  ]
379
393
  end
380
394
 
395
+ # Returns the `[type, post_scope]` of the live branch when the
396
+ # predicate is provably truthy / falsey, else nil so the
397
+ # caller falls through to the standard both-branch evaluation.
398
+ # Constant `true`/`false` is the obvious trigger; non-falsey
399
+ # carriers like `Nominal[Integer]` (Integer is always truthy
400
+ # in Ruby — including 0) also collapse the dead else.
401
+ def live_branch_for_if(node, pred_type, post_pred)
402
+ case predicate_certainty(pred_type)
403
+ when :always_truthy then eval_branch_or_nil(node.statements, post_pred)
404
+ when :always_falsey then eval_branch_or_nil(node.subsequent, post_pred)
405
+ end
406
+ end
407
+
408
+ def live_branch_for_unless(node, pred_type, post_pred)
409
+ case predicate_certainty(pred_type)
410
+ when :always_truthy then eval_branch_or_nil(node.else_clause, post_pred)
411
+ when :always_falsey then eval_branch_or_nil(node.statements, post_pred)
412
+ end
413
+ end
414
+
415
+ def predicate_certainty(pred_type)
416
+ return nil if pred_type.nil? || pred_type.is_a?(Type::Bot)
417
+
418
+ truthy_bot = Narrowing.narrow_truthy(pred_type).is_a?(Type::Bot)
419
+ falsey_bot = Narrowing.narrow_falsey(pred_type).is_a?(Type::Bot)
420
+
421
+ return :always_falsey if truthy_bot && !falsey_bot
422
+ return :always_truthy if !truthy_bot && falsey_bot
423
+
424
+ nil
425
+ end
426
+
381
427
  def eval_else(node)
382
428
  return [Type::Combinator.constant_of(nil), scope] if node.statements.nil?
383
429
 
@@ -606,9 +652,172 @@ module Rigor
606
652
  evaluate_block_if_present(node)
607
653
  post_scope = record_closure_escape_if_any(node)
608
654
  post_scope = apply_rbs_extended_assertions(node, post_scope)
655
+ post_scope = apply_rspec_matcher_narrowing(node, post_scope)
609
656
  [call_type, post_scope]
610
657
  end
611
658
 
659
+ # v0.0.3 — recognises a small catalogue of RSpec
660
+ # matcher patterns as assert-shaped narrows on the
661
+ # local passed to `expect(...)`. The pattern is
662
+ # matched purely on AST shape; no RBS for RSpec is
663
+ # required (and none is shipped today).
664
+ #
665
+ # Recognised today:
666
+ #
667
+ # expect(x).not_to(be_nil)
668
+ # expect(x).to_not(be_nil)
669
+ # → narrow `x` AWAY from `NilClass`.
670
+ #
671
+ # expect(x).to(be_a(C))
672
+ # expect(x).to(be_kind_of(C))
673
+ # expect(x).to(be_an_instance_of(C))
674
+ # → narrow `x` to `C` (exact for
675
+ # `be_an_instance_of`, subtype-permitting
676
+ # otherwise).
677
+ #
678
+ # Anything else is silently passed through. Symmetric
679
+ # negative class assertions (`not_to be_a(C)`) and
680
+ # narrowing TO `NilClass` are intentionally NOT
681
+ # modelled: they are rarely useful in practice and
682
+ # risk masking bugs if the assertion later fails.
683
+ def apply_rspec_matcher_narrowing(call_node, current_scope)
684
+ narrow = rspec_matcher_narrowing_request(call_node)
685
+ return current_scope if narrow.nil?
686
+
687
+ local_name = narrow.fetch(:local)
688
+ current_type = current_scope.local(local_name)
689
+ return current_scope if current_type.nil?
690
+
691
+ narrowed = apply_rspec_narrow(current_type, narrow, current_scope.environment)
692
+ current_scope.with_local(local_name, narrowed)
693
+ end
694
+
695
+ # Decodes an `expect(x).<chain>` outer call into a
696
+ # narrowing request hash, or `nil` when the shape is
697
+ # not recognised. The hash carries `:local` (the local
698
+ # name being narrowed) plus the narrowing parameters.
699
+ def rspec_matcher_narrowing_request(call_node)
700
+ local_name = rspec_expectation_target(call_node)
701
+ return nil if local_name.nil?
702
+
703
+ case call_node.name
704
+ when :not_to, :to_not
705
+ rspec_negative_narrow(call_node, local_name)
706
+ when :to
707
+ rspec_positive_narrow(call_node, local_name)
708
+ end
709
+ end
710
+
711
+ def rspec_negative_narrow(call_node, local_name)
712
+ return nil unless rspec_matcher_argument?(call_node, :be_nil)
713
+
714
+ { local: local_name, kind: :not_class, class_name: "NilClass", exact: false }
715
+ end
716
+
717
+ def rspec_positive_narrow(call_node, local_name)
718
+ matcher = rspec_matcher_node(call_node)
719
+ return nil if matcher.nil?
720
+
721
+ case matcher.name
722
+ when :be_a, :be_kind_of
723
+ rspec_be_a_narrow(matcher, local_name, exact: false)
724
+ when :be_an_instance_of, :be_instance_of
725
+ rspec_be_a_narrow(matcher, local_name, exact: true)
726
+ end
727
+ end
728
+
729
+ # `be_a` / `be_kind_of` / `be_an_instance_of` accept a
730
+ # single class argument — either a `ConstantReadNode`
731
+ # (`Integer`) or a `ConstantPathNode` (`Rigor::Type::Nominal`).
732
+ def rspec_be_a_narrow(matcher, local_name, exact:)
733
+ args = matcher.arguments&.arguments || []
734
+ return nil unless args.size == 1
735
+
736
+ class_name = constant_node_name(args.first)
737
+ return nil if class_name.nil?
738
+
739
+ { local: local_name, kind: :class, class_name: class_name, exact: exact }
740
+ end
741
+
742
+ def apply_rspec_narrow(current_type, narrow, environment)
743
+ case narrow.fetch(:kind)
744
+ when :not_class
745
+ Narrowing.narrow_not_class(current_type, narrow.fetch(:class_name),
746
+ exact: narrow.fetch(:exact), environment: environment)
747
+ when :class
748
+ Narrowing.narrow_class(current_type, narrow.fetch(:class_name),
749
+ exact: narrow.fetch(:exact), environment: environment)
750
+ end
751
+ end
752
+
753
+ # Returns the local name passed to `expect(...)` when
754
+ # the receiver chain matches `expect(<local>)` exactly,
755
+ # or nil otherwise. Centralised so each per-matcher
756
+ # decoder can short-circuit on a non-matching outer
757
+ # call.
758
+ def rspec_expectation_target(call_node) # rubocop:disable Metrics/CyclomaticComplexity
759
+ receiver = call_node.receiver
760
+ return nil unless receiver.is_a?(Prism::CallNode) && receiver.name == :expect
761
+ return nil unless receiver.receiver.nil?
762
+
763
+ args = receiver.arguments&.arguments || []
764
+ return nil unless args.size == 1
765
+
766
+ target = args.first
767
+ target.is_a?(Prism::LocalVariableReadNode) ? target.name : nil
768
+ end
769
+
770
+ def rspec_matcher_node(call_node)
771
+ args = call_node.arguments&.arguments || []
772
+ return nil unless args.size == 1
773
+
774
+ matcher = args.first
775
+ return nil unless matcher.is_a?(Prism::CallNode) && matcher.receiver.nil? && matcher.block.nil?
776
+
777
+ matcher
778
+ end
779
+
780
+ # True when `call_node`'s sole argument is an
781
+ # implicit-self matcher call with the given name and
782
+ # no positional arguments — used by the no-arg
783
+ # matchers (`be_nil`).
784
+ def rspec_matcher_argument?(call_node, matcher_name)
785
+ matcher = rspec_matcher_node(call_node)
786
+ return false if matcher.nil?
787
+ return false unless matcher.name == matcher_name
788
+
789
+ matcher.arguments.nil? || matcher.arguments.arguments.empty?
790
+ end
791
+
792
+ # Decodes a `Prism::ConstantReadNode` /
793
+ # `Prism::ConstantPathNode` into a colon-joined class
794
+ # name string, or returns nil for any other node
795
+ # shape. Mirrors the conservative envelope used by the
796
+ # `is_a?` / `kind_of?` predicate narrower.
797
+ def constant_node_name(node)
798
+ case node
799
+ when Prism::ConstantReadNode
800
+ node.name.to_s
801
+ when Prism::ConstantPathNode
802
+ flatten_constant_path(node)
803
+ end
804
+ end
805
+
806
+ def flatten_constant_path(node)
807
+ parts = []
808
+ cursor = node
809
+ while cursor.is_a?(Prism::ConstantPathNode)
810
+ parts.unshift(cursor.name.to_s)
811
+ cursor = cursor.parent
812
+ end
813
+ case cursor
814
+ when Prism::ConstantReadNode then parts.unshift(cursor.name.to_s)
815
+ when nil then nil # ::Foo absolute root — preserve as-is
816
+ else return nil
817
+ end
818
+ parts.join("::")
819
+ end
820
+
612
821
  # v0.0.2 — applies `RBS::Extended` `assert <target> is T`
613
822
  # directives to the post-call scope. The conditional
614
823
  # variants (`assert-if-true` / `assert-if-false`) are