rigortype 0.1.7 → 0.1.9

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 (47) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +23 -1
  4. data/lib/rigor/analysis/diagnostic.rb +17 -3
  5. data/lib/rigor/analysis/runner.rb +178 -3
  6. data/lib/rigor/analysis/worker_session.rb +14 -3
  7. data/lib/rigor/cli/annotate_command.rb +224 -0
  8. data/lib/rigor/cli/baseline_command.rb +36 -16
  9. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  10. data/lib/rigor/cli/triage_command.rb +83 -0
  11. data/lib/rigor/cli/triage_renderer.rb +77 -0
  12. data/lib/rigor/cli.rb +71 -5
  13. data/lib/rigor/environment.rb +9 -1
  14. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  15. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  16. data/lib/rigor/inference/expression_typer.rb +300 -18
  17. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  18. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +173 -10
  19. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  20. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  21. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  22. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +33 -8
  23. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  24. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  25. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +316 -2
  26. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  27. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  28. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  29. data/lib/rigor/inference/method_dispatcher.rb +179 -4
  30. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  31. data/lib/rigor/inference/narrowing.rb +29 -10
  32. data/lib/rigor/inference/scope_indexer.rb +156 -6
  33. data/lib/rigor/inference/statement_evaluator.rb +43 -21
  34. data/lib/rigor/plugin/base.rb +39 -0
  35. data/lib/rigor/plugin/loader.rb +22 -1
  36. data/lib/rigor/plugin/manifest.rb +73 -10
  37. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  38. data/lib/rigor/plugin/registry.rb +66 -0
  39. data/lib/rigor/scope.rb +46 -0
  40. data/lib/rigor/triage/catalogue.rb +296 -0
  41. data/lib/rigor/triage/hint.rb +27 -0
  42. data/lib/rigor/triage.rb +89 -0
  43. data/lib/rigor/type/constant.rb +29 -2
  44. data/lib/rigor/version.rb +1 -1
  45. data/sig/rigor/inference.rbs +1 -0
  46. data/sig/rigor/scope.rbs +6 -0
  47. metadata +16 -1
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Folds the zone-pinned `Time` class constructors on statically
9
+ # known arguments.
10
+ #
11
+ # Only `Time.utc` / `Time.gm` are folded. They pin the result
12
+ # to UTC, so the literal is machine-independent — every reader
13
+ # (`year`, `hour`, `utc_offset`, `strftime`, …) yields the same
14
+ # answer on any analysis host.
15
+ #
16
+ # `Time.now` (non-deterministic), `Time.at` / `Time.local` /
17
+ # `Time.mktime` / `Time.new` (local-zone — the result's wall
18
+ # clock and `utc_offset` depend on the analysis machine's
19
+ # timezone) are deliberately NOT folded; they keep their
20
+ # `Nominal[Time]` RBS answer. For the same reason `Time#getlocal`
21
+ # is blocklisted in `TIME_CATALOG` so a folded `Constant[Time]`
22
+ # cannot produce a machine-dependent local-zone copy.
23
+ module TimeFolding
24
+ TIME_UTC_METHODS = Set[:utc, :gm].freeze
25
+ private_constant :TIME_UTC_METHODS
26
+
27
+ # `Time.utc` accepts the 1–7 positional (year-first) form and
28
+ # a 10-arg (sec-first) form; cap the arity so a malformed
29
+ # call declines cheaply rather than reaching the constructor.
30
+ MAX_TIME_ARITY = 10
31
+ private_constant :MAX_TIME_ARITY
32
+
33
+ module_function
34
+
35
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
36
+ def try_dispatch(receiver:, method_name:, args:)
37
+ return nil unless dispatch_target?(receiver)
38
+ return nil unless TIME_UTC_METHODS.include?(method_name)
39
+ return nil unless args.size.between?(1, MAX_TIME_ARITY)
40
+ return nil unless args.all?(Type::Constant)
41
+
42
+ values = args.map(&:value)
43
+ return nil unless values.all? { |v| v.is_a?(Integer) || v.is_a?(String) }
44
+
45
+ Type::Combinator.constant_of(Time.utc(*values))
46
+ rescue StandardError
47
+ nil
48
+ end
49
+
50
+ def dispatch_target?(receiver)
51
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "Time"
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../../type"
5
+
6
+ module Rigor
7
+ module Inference
8
+ module MethodDispatcher
9
+ # Folds `URI` module-function calls on statically known
10
+ # string constants.
11
+ #
12
+ # `URI.encode_www_form_component` / `decode_www_form_component`
13
+ # and the newer `encode_uri_component` / `decode_uri_component`
14
+ # are pure, deterministic functions over their string inputs.
15
+ # When the argument is a `Constant[String]`, the analyzer can
16
+ # evaluate the call at inference time and return the concrete
17
+ # `Constant[String]` result.
18
+ #
19
+ # === Supported methods
20
+ #
21
+ # * `encode_www_form_component(str)` / `decode_www_form_component(str)` —
22
+ # RFC 3986 percent-encode / decode. Returns `Constant[String]`.
23
+ # * `encode_uri_component(str)` / `decode_uri_component(str)` —
24
+ # Same encoding but may preserve additional reserved chars
25
+ # (Ruby 3.2+). Returns `Constant[String]`.
26
+ #
27
+ # === Non-constant / unsupported cases
28
+ #
29
+ # Returns `nil` (deferring to the next dispatcher tier) when:
30
+ # - the receiver is not `Singleton[URI]`,
31
+ # - the first argument is not a `Constant[String]`,
32
+ # - the method is not in the supported set.
33
+ module URIFolding
34
+ URI_COMPONENT_METHODS = Set[
35
+ :encode_www_form_component, :decode_www_form_component,
36
+ :encode_uri_component, :decode_uri_component
37
+ ].freeze
38
+ private_constant :URI_COMPONENT_METHODS
39
+
40
+ module_function
41
+
42
+ # @return [Rigor::Type, nil] folded result, or nil to defer.
43
+ def try_dispatch(receiver:, method_name:, args:)
44
+ return nil unless dispatch_target?(receiver)
45
+ return nil unless URI_COMPONENT_METHODS.include?(method_name)
46
+
47
+ fold_uri_call(method_name, args)
48
+ end
49
+
50
+ def dispatch_target?(receiver)
51
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "URI"
52
+ end
53
+
54
+ def fold_uri_call(method_name, args)
55
+ return nil unless args.size == 1
56
+
57
+ arg = args.first
58
+ return nil unless arg.is_a?(Type::Constant) && arg.value.is_a?(String)
59
+
60
+ Type::Combinator.constant_of(URI.public_send(method_name, arg.value))
61
+ rescue StandardError
62
+ nil
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -13,6 +13,13 @@ require_relative "method_dispatcher/rbs_dispatch"
13
13
  require_relative "method_dispatcher/iterator_dispatch"
14
14
  require_relative "method_dispatcher/block_folding"
15
15
  require_relative "method_dispatcher/file_folding"
16
+ require_relative "method_dispatcher/shellwords_folding"
17
+ require_relative "method_dispatcher/math_folding"
18
+ require_relative "method_dispatcher/time_folding"
19
+ require_relative "method_dispatcher/regexp_folding"
20
+ require_relative "method_dispatcher/cgi_folding"
21
+ require_relative "method_dispatcher/uri_folding"
22
+ require_relative "method_dispatcher/set_folding"
16
23
  require_relative "method_dispatcher/kernel_dispatch"
17
24
  require_relative "method_dispatcher/method_folding"
18
25
 
@@ -191,7 +198,7 @@ module Rigor
191
198
  # introspection (`attr_reader`, `private`, ...) on
192
199
  # user classes without requiring the user to author
193
200
  # their own RBS.
194
- try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
201
+ try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node)
195
202
  end
196
203
 
197
204
  # v0.1.3 — discovered-method dispatch tier. `scope` carries
@@ -643,7 +650,7 @@ module Rigor
643
650
  ConstantFolding.try_fold(receiver: receiver_type, method_name: method_name, args: arg_types) ||
644
651
  LiteralStringFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
645
652
  ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
646
- FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
653
+ dispatch_stdlib_module_tiers(receiver_type, method_name, arg_types) ||
647
654
  KernelDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
648
655
  MethodFolding.try_forward(receiver: receiver_type, method_name: method_name, args: arg_types) ||
649
656
  BlockFolding.try_fold(
@@ -651,7 +658,22 @@ module Rigor
651
658
  )
652
659
  end
653
660
 
654
- def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
661
+ # Stdlib module singleton-folding tiers: File, Shellwords, Math,
662
+ # Time, Regexp, CGI, URI, Set. Extracted from
663
+ # `dispatch_precise_tiers` to keep the parent method within the
664
+ # cyclomatic-complexity limit.
665
+ def dispatch_stdlib_module_tiers(receiver_type, method_name, arg_types)
666
+ FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
667
+ ShellwordsFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
668
+ MathFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
669
+ TimeFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
670
+ RegexpFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
671
+ CGIFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
672
+ URIFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
673
+ SetFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types)
674
+ end
675
+
676
+ def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type, call_node = nil)
655
677
  return nil if environment.nil?
656
678
 
657
679
  fallback_receiver = user_class_fallback_receiver(receiver_type, environment)
@@ -665,16 +687,44 @@ module Rigor
665
687
  # `Bundler::URI::Generic` instance method types `base`
666
688
  # as `Object` because `Bundler::URI::Generic` is not in
667
689
  # RBS and the fallback's `self` resolves to Object.
690
+ #
691
+ # `public_only:` — when the call has an EXPLICIT, non-`self`
692
+ # receiver (`Favourite.select(...)`), suppress the private
693
+ # `Object`/`Kernel`/`Class` methods the fallback would
694
+ # otherwise resolve. Ruby raises `NoMethodError` for a
695
+ # private method called with an explicit receiver, so
696
+ # resolving `Favourite.select` to the private `Kernel#select`
697
+ # (`-> Array[String]`) is a confidently-wrong type. Implicit-
698
+ # self / `self.`-receiver calls (`puts`, `raise`, `require`)
699
+ # keep resolving — those are the fallback's intended targets.
668
700
  RbsDispatch.try_dispatch(
669
701
  receiver: fallback_receiver,
670
702
  method_name: method_name,
671
703
  args: arg_types,
672
704
  environment: environment,
673
705
  block_type: block_type,
674
- self_type_override: receiver_type
706
+ self_type_override: receiver_type,
707
+ public_only: explicit_non_self_receiver?(call_node)
675
708
  )
676
709
  end
677
710
 
711
+ # True when the call node carries an explicit receiver that is
712
+ # not the literal `self`. Such a call cannot legally dispatch to
713
+ # a private method, so the user-class fallback must skip private
714
+ # signatures rather than return a confidently-wrong type. Returns
715
+ # false for implicit-self calls and `self.`-receiver calls (both
716
+ # may legally reach a private method in modern Ruby), and false
717
+ # when no `call_node` is supplied (internal dispatcher callers).
718
+ def explicit_non_self_receiver?(call_node)
719
+ return false if call_node.nil?
720
+ return false unless call_node.respond_to?(:receiver)
721
+
722
+ receiver = call_node.receiver
723
+ return false if receiver.nil?
724
+
725
+ !receiver.is_a?(Prism::SelfNode)
726
+ end
727
+
678
728
  def user_class_fallback_receiver(receiver_type, environment)
679
729
  case receiver_type
680
730
  when Type::Nominal
@@ -743,6 +793,18 @@ module Rigor
743
793
  array_lift = array_new_lift(receiver_type.class_name, arg_types)
744
794
  return array_lift if array_lift
745
795
 
796
+ range_lift = range_new_lift(receiver_type.class_name, arg_types)
797
+ return range_lift if range_lift
798
+
799
+ set_lift = set_new_lift(receiver_type.class_name, arg_types)
800
+ return set_lift if set_lift
801
+
802
+ regexp_lift = regexp_new_lift(receiver_type.class_name, arg_types)
803
+ return regexp_lift if regexp_lift
804
+
805
+ date_lift = date_new_lift(receiver_type.class_name, arg_types)
806
+ return date_lift if date_lift
807
+
746
808
  Type::Combinator.nominal_of(receiver_type.class_name)
747
809
  end
748
810
 
@@ -812,6 +874,119 @@ module Rigor
812
874
  type
813
875
  end
814
876
 
877
+ # `Range.new(b, e)` / `Range.new(b, e, excl)` — folds to
878
+ # `Constant[Range]` when both endpoints are `Constant[Integer]`
879
+ # or both are `Constant[String]`, and the optional third argument
880
+ # is a `Constant[true/false]`. Nil endpoints (beginless /
881
+ # endless ranges) are not folded because the useful instance
882
+ # methods (`to_a`, `first`, `last`) are not defined for them;
883
+ # the RBS tier answers `Nominal[Range]` for those forms.
884
+ def range_new_lift(class_name, arg_types)
885
+ return nil unless class_name == "Range"
886
+ return nil if arg_types.size < 2 || arg_types.size > 3
887
+
888
+ b_type = arg_types[0]
889
+ e_type = arg_types[1]
890
+
891
+ return nil unless b_type.is_a?(Type::Constant) && e_type.is_a?(Type::Constant)
892
+
893
+ b_val = b_type.value
894
+ e_val = e_type.value
895
+
896
+ # Only fold homogeneous Integer or String endpoint pairs.
897
+ return nil unless b_val.instance_of?(e_val.class)
898
+ return nil unless b_val.is_a?(Integer) || b_val.is_a?(String)
899
+
900
+ excl = range_new_excl(arg_types[2])
901
+ return nil if excl.nil?
902
+
903
+ Type::Combinator.constant_of(Range.new(b_val, e_val, excl))
904
+ rescue StandardError
905
+ nil
906
+ end
907
+
908
+ # Resolves the optional `exclude_end` argument for `range_new_lift`.
909
+ # Returns `false` (no arg), `true`/`false` (Constant[bool] arg),
910
+ # or `nil` to signal "decline" (wrong type / wrong value class).
911
+ def range_new_excl(excl_type)
912
+ case excl_type
913
+ when nil then false
914
+ when Type::Constant
915
+ excl_type.value if [true, false].include?(excl_type.value)
916
+ end
917
+ end
918
+
919
+ # `Set.new` / `Set.new(tuple_of_constants)` — folds to `Constant[Set]`
920
+ # when zero arguments are given or the single argument is a `Tuple`
921
+ # whose every element is a `Constant[T]`. Mirrors `SetFolding#fold_new`
922
+ # but lives here so the `:new` path in `try_meta_introspection` can
923
+ # reach it before the RBS tier answers `Nominal[Set]`.
924
+ def set_new_lift(class_name, arg_types)
925
+ return nil unless class_name == "Set"
926
+ return Type::Combinator.constant_of(::Set.new) if arg_types.empty?
927
+ return nil if arg_types.size > 1
928
+
929
+ arg = arg_types.first
930
+ return nil unless arg.is_a?(Type::Tuple)
931
+ return nil unless arg.elements.all?(Type::Constant)
932
+
933
+ values = arg.elements.map(&:value)
934
+ Type::Combinator.constant_of(::Set.new(values))
935
+ rescue StandardError
936
+ nil
937
+ end
938
+
939
+ # `Regexp.new(pattern)` / `Regexp.new(pattern, opts)` — folds to
940
+ # `Constant[Regexp]` when the pattern is a `Constant[String]`.
941
+ # Mirrors `RegexpFolding#fold_new` but lives here so the `:new` path
942
+ # in `try_meta_introspection` can reach it.
943
+ def regexp_new_lift(class_name, arg_types)
944
+ return nil unless class_name == "Regexp"
945
+ return nil if arg_types.empty? || arg_types.size > 2
946
+
947
+ pattern_arg = arg_types.first
948
+ return nil unless pattern_arg.is_a?(Type::Constant) && pattern_arg.value.is_a?(String)
949
+
950
+ opts = if arg_types.size == 2
951
+ opt_type = arg_types[1]
952
+ return nil unless opt_type.is_a?(Type::Constant)
953
+
954
+ opt_type.value
955
+ else
956
+ 0
957
+ end
958
+
959
+ Type::Combinator.constant_of(Regexp.new(pattern_arg.value, opts))
960
+ rescue StandardError
961
+ nil
962
+ end
963
+
964
+ # `Date.new(y, m, d)` / `DateTime.new(y, m, d, h, min, s, off)`
965
+ # — folds to `Constant[Date]` / `Constant[DateTime]` when every
966
+ # argument is a `Constant` carrying an Integer (or, for the
967
+ # `DateTime` offset / `start` slots, a Rational or String).
968
+ # `Date.new` carries no timezone, and `DateTime.new`'s offset
969
+ # defaults to UTC, so the literal is machine-independent.
970
+ # `Date.today` / `Date.parse` are not constructors here — they
971
+ # are non-deterministic / string-dependent and stay
972
+ # `Nominal[Date]`.
973
+ DATE_NEW_CLASSES = %w[Date DateTime].freeze
974
+ private_constant :DATE_NEW_CLASSES
975
+
976
+ def date_new_lift(class_name, arg_types)
977
+ return nil unless DATE_NEW_CLASSES.include?(class_name)
978
+ return nil unless arg_types.size.between?(1, 8)
979
+ return nil unless arg_types.all?(Type::Constant)
980
+
981
+ values = arg_types.map(&:value)
982
+ return nil unless values.all? { |v| v.is_a?(Integer) || v.is_a?(Rational) || v.is_a?(String) }
983
+
984
+ klass = class_name == "Date" ? Date : DateTime
985
+ Type::Combinator.constant_of(klass.new(*values))
986
+ rescue StandardError
987
+ nil
988
+ end
989
+
815
990
  CONSTANT_METACLASSES = {
816
991
  Integer => "Integer", Float => "Float", String => "String",
817
992
  Symbol => "Symbol", Range => "Range",
@@ -43,10 +43,15 @@ module Rigor
43
43
  # method (either `def self.foo` or a `def foo` inside
44
44
  # `class << self`); routes the lookup through
45
45
  # `RbsLoader#singleton_method`.
46
- def initialize(environment:, class_path:, singleton:)
46
+ # @param source_path [String, nil] the project-relative path of
47
+ # the file the method is defined in. Used to match ADR-28
48
+ # path-scoped protocol contracts; `nil` (the default for
49
+ # synthetic / probe scopes) disables the contract tier.
50
+ def initialize(environment:, class_path:, singleton:, source_path: nil)
47
51
  @environment = environment
48
52
  @class_path = class_path
49
53
  @singleton = singleton
54
+ @source_path = source_path
50
55
  end
51
56
 
52
57
  # @param def_node [Prism::DefNode]
@@ -58,15 +63,22 @@ module Rigor
58
63
  types = default_types_for(slots)
59
64
 
60
65
  rbs_method = lookup_rbs_method(def_node)
61
- return types unless rbs_method
62
-
63
- apply_rbs_overloads(types, slots, rbs_method.method_types) unless rbs_method.method_types.empty?
64
- # `rigor:v1:param: <name> <refinement>` annotations
65
- # tighten the bound type for matching slots. Applied
66
- # after the RBS-overload pass so the override is the
67
- # authoritative answer regardless of what the RBS
68
- # signature declared.
69
- apply_param_overrides(types, slots, rbs_method)
66
+ if rbs_method
67
+ apply_rbs_overloads(types, slots, rbs_method.method_types) unless rbs_method.method_types.empty?
68
+ # `rigor:v1:param: <name> <refinement>` annotations
69
+ # tighten the bound type for matching slots. Applied
70
+ # after the RBS-overload pass so the override is the
71
+ # authoritative answer regardless of what the RBS
72
+ # signature declared.
73
+ apply_param_overrides(types, slots, rbs_method)
74
+ end
75
+ # ADR-28 — a path-scoped protocol contract supplies the
76
+ # parameter type for a matching `def`. Applied last (most
77
+ # authoritative) and regardless of RBS presence: the
78
+ # methods a contract targets — controller actions and the
79
+ # like — typically have no RBS signature at all, so this
80
+ # tier must run even when `rbs_method` is nil.
81
+ apply_protocol_contract(types, slots, def_node)
70
82
  types
71
83
  end
72
84
 
@@ -75,6 +87,10 @@ module Rigor
75
87
  ParamSlot = Data.define(:kind, :name, :index)
76
88
  private_constant :ParamSlot
77
89
 
90
+ # Slot kinds a contract's positional `index` can address.
91
+ POSITIONAL_KINDS = %i[required_positional optional_positional].freeze
92
+ private_constant :POSITIONAL_KINDS
93
+
78
94
  # Walk the Prism `ParametersNode` and emit one slot per named
79
95
  # parameter, in declaration order. Anonymous slots (rest /
80
96
  # keyword-rest with no name) are skipped because we have no
@@ -187,6 +203,47 @@ module Rigor
187
203
  end
188
204
  end
189
205
 
206
+ # ADR-28 — when a path-scoped protocol contract targets this
207
+ # `def` (file path matches the contract's `path_glob`, method
208
+ # name + singleton-ness match), replace each contracted
209
+ # positional slot's binding with the contract's declared type.
210
+ # The type name resolves against the environment lazily here;
211
+ # an unresolvable name (the protocol's RBS not loaded) falls
212
+ # through to whatever the prior tiers bound, fail-soft.
213
+ def apply_protocol_contract(types, slots, def_node)
214
+ return if @source_path.nil?
215
+
216
+ registry = @environment.respond_to?(:plugin_registry) ? @environment.plugin_registry : nil
217
+ return if registry.nil?
218
+
219
+ contract = matching_contract(registry, def_node)
220
+ return if contract.nil?
221
+
222
+ contract.param_types.each do |param_type|
223
+ slot = positional_slot_at(slots, param_type.index)
224
+ next if slot.nil? || slot.name.nil?
225
+
226
+ resolved = @environment.nominal_for_name(param_type.type_name)
227
+ next if resolved.nil?
228
+
229
+ types[slot.name] = resolved
230
+ end
231
+ end
232
+
233
+ def matching_contract(registry, def_node)
234
+ contracts = registry.contracts_for_path(@source_path)
235
+ return nil if contracts.empty?
236
+
237
+ singleton = def_node.receiver.is_a?(Prism::SelfNode) || @singleton
238
+ contracts.find do |contract|
239
+ contract.method_name == def_node.name && contract.singleton == singleton
240
+ end
241
+ end
242
+
243
+ def positional_slot_at(slots, index)
244
+ slots.find { |slot| POSITIONAL_KINDS.include?(slot.kind) && slot.index == index }
245
+ end
246
+
190
247
  def collect_translated_types(method_types, slot)
191
248
  rbs_types = method_types.flat_map do |mt|
192
249
  t = rbs_type_for_slot(mt.type, slot)
@@ -386,8 +386,8 @@ module Rigor
386
386
  analyse_local_read(node, scope)
387
387
  when Prism::LocalVariableWriteNode
388
388
  analyse_local_write(node, scope)
389
- when Prism::InstanceVariableWriteNode
390
- analyse_ivar_write(node, scope)
389
+ when Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode
390
+ analyse_ivar(node, scope)
391
391
  when Prism::ClassVariableWriteNode
392
392
  analyse_cvar_write(node, scope)
393
393
  when Prism::GlobalVariableWriteNode
@@ -767,7 +767,17 @@ module Rigor
767
767
  ]
768
768
  end
769
769
 
770
- def analyse_ivar_write(node, scope)
770
+ # Truthy guard on an instance variable. Covers both the bare
771
+ # read — `if @ivar`, the left arm of `@ivar && @ivar.foo`,
772
+ # the receiver of `unless @ivar.nil?` once `!` reverses it —
773
+ # and the assignment-in-condition write `if @ivar = expr`.
774
+ # Mirrors `analyse_local_read` / `analyse_local_write`: the
775
+ # truthy edge narrows the ivar by `narrow_truthy` (dropping
776
+ # `nil` / `false`), the falsey edge by `narrow_falsey`. The
777
+ # narrowing is scoped to the guarded branch only, so it is
778
+ # purely additive — it never widens or re-narrows the ivar
779
+ # binding seen elsewhere.
780
+ def analyse_ivar(node, scope)
771
781
  current = scope.ivar(node.name)
772
782
  return nil if current.nil?
773
783
 
@@ -2024,15 +2034,24 @@ module Rigor
2024
2034
  end
2025
2035
 
2026
2036
  def analyse_nil_predicate(receiver, scope)
2027
- return nil unless receiver.is_a?(Prism::LocalVariableReadNode)
2037
+ case receiver
2038
+ when Prism::LocalVariableReadNode
2039
+ current = scope.local(receiver.name)
2040
+ return nil if current.nil?
2028
2041
 
2029
- current = scope.local(receiver.name)
2030
- return nil if current.nil?
2042
+ [
2043
+ scope.with_local(receiver.name, narrow_nil(current)),
2044
+ scope.with_local(receiver.name, narrow_non_nil(current))
2045
+ ]
2046
+ when Prism::InstanceVariableReadNode
2047
+ current = scope.ivar(receiver.name)
2048
+ return nil if current.nil?
2031
2049
 
2032
- [
2033
- scope.with_local(receiver.name, narrow_nil(current)),
2034
- scope.with_local(receiver.name, narrow_non_nil(current))
2035
- ]
2050
+ [
2051
+ scope.with_ivar(receiver.name, narrow_nil(current)),
2052
+ scope.with_ivar(receiver.name, narrow_non_nil(current))
2053
+ ]
2054
+ end
2036
2055
  end
2037
2056
 
2038
2057
  # `a && b` short-circuits: the truthy edge is the truthy edge