rigortype 0.1.8 → 0.1.10

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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +186 -513
  3. data/lib/rigor/analysis/check_rules.rb +20 -0
  4. data/lib/rigor/analysis/runner.rb +67 -9
  5. data/lib/rigor/analysis/worker_session.rb +13 -4
  6. data/lib/rigor/cache/rbs_descriptor.rb +21 -2
  7. data/lib/rigor/cache/rbs_environment.rb +2 -1
  8. data/lib/rigor/cli/annotate_command.rb +274 -0
  9. data/lib/rigor/cli/baseline_command.rb +36 -16
  10. data/lib/rigor/cli/coverage_command.rb +126 -0
  11. data/lib/rigor/cli/coverage_renderer.rb +162 -0
  12. data/lib/rigor/cli/coverage_report.rb +75 -0
  13. data/lib/rigor/cli/mcp_command.rb +70 -0
  14. data/lib/rigor/cli/prism_colorizer.rb +111 -0
  15. data/lib/rigor/cli.rb +134 -6
  16. data/lib/rigor/environment/rbs_loader.rb +46 -5
  17. data/lib/rigor/environment/reporters.rb +3 -2
  18. data/lib/rigor/environment.rb +168 -5
  19. data/lib/rigor/inference/builtins/method_catalog.rb +17 -1
  20. data/lib/rigor/inference/builtins/time_catalog.rb +10 -1
  21. data/lib/rigor/inference/def_return_typer.rb +98 -0
  22. data/lib/rigor/inference/expression_typer.rb +308 -18
  23. data/lib/rigor/inference/method_dispatcher/cgi_folding.rb +109 -0
  24. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +178 -10
  25. data/lib/rigor/inference/method_dispatcher/kernel_dispatch.rb +53 -1
  26. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +62 -15
  27. data/lib/rigor/inference/method_dispatcher/math_folding.rb +149 -0
  28. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +20 -1
  29. data/lib/rigor/inference/method_dispatcher/regexp_folding.rb +81 -0
  30. data/lib/rigor/inference/method_dispatcher/set_folding.rb +81 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +431 -9
  32. data/lib/rigor/inference/method_dispatcher/shellwords_folding.rb +126 -0
  33. data/lib/rigor/inference/method_dispatcher/time_folding.rb +56 -0
  34. data/lib/rigor/inference/method_dispatcher/uri_folding.rb +67 -0
  35. data/lib/rigor/inference/method_dispatcher.rb +148 -1
  36. data/lib/rigor/inference/method_parameter_binder.rb +67 -10
  37. data/lib/rigor/inference/narrowing.rb +29 -10
  38. data/lib/rigor/inference/precision_scanner.rb +131 -0
  39. data/lib/rigor/inference/statement_evaluator.rb +29 -3
  40. data/lib/rigor/mcp/loop.rb +43 -0
  41. data/lib/rigor/mcp/server.rb +263 -0
  42. data/lib/rigor/mcp.rb +16 -0
  43. data/lib/rigor/plugin/base.rb +67 -5
  44. data/lib/rigor/plugin/loader.rb +22 -1
  45. data/lib/rigor/plugin/manifest.rb +101 -10
  46. data/lib/rigor/plugin/protocol_contract.rb +185 -0
  47. data/lib/rigor/plugin/registry.rb +87 -0
  48. data/lib/rigor/plugin/source_rbs_synthesis_reporter.rb +51 -0
  49. data/lib/rigor/sig_gen/generator.rb +150 -75
  50. data/lib/rigor/triage/catalogue.rb +2 -2
  51. data/lib/rigor/type/combinator.rb +57 -0
  52. data/lib/rigor/type/constant.rb +29 -2
  53. data/lib/rigor/version.rb +1 -1
  54. data/sig/rigor/analysis/baseline.rbs +39 -0
  55. data/sig/rigor/environment.rbs +3 -2
  56. data/sig/rigor/type.rbs +4 -0
  57. data/sig/rigor.rbs +2 -0
  58. metadata +42 -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
 
@@ -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,6 +658,21 @@ module Rigor
651
658
  )
652
659
  end
653
660
 
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
+
654
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
 
@@ -771,6 +793,18 @@ module Rigor
771
793
  array_lift = array_new_lift(receiver_type.class_name, arg_types)
772
794
  return array_lift if array_lift
773
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
+
774
808
  Type::Combinator.nominal_of(receiver_type.class_name)
775
809
  end
776
810
 
@@ -840,6 +874,119 @@ module Rigor
840
874
  type
841
875
  end
842
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
+
843
990
  CONSTANT_METACLASSES = {
844
991
  Integer => "Integer", Float => "Float", String => "String",
845
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
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../source/node_walker"
4
+ require_relative "../scope"
5
+ require_relative "scope_indexer"
6
+
7
+ module Rigor
8
+ module Inference
9
+ # Measures the *type quality* of inferred expressions — not whether the
10
+ # engine recognises an AST node class (that is `CoverageScanner`'s job),
11
+ # but whether the type it produces carries useful static information.
12
+ #
13
+ # Each visited node is classified into one of eight precision tiers:
14
+ #
15
+ # :constant — Constant[T]: literal value known exactly
16
+ # :nominal — Nominal/Singleton: class identity known
17
+ # :shaped — Tuple/HashShape/IntegerRange/App: structure known
18
+ # :refined — Refined: narrowed by a predicate/assertion
19
+ # :bot — Bot: unreachable branch (definitively precise)
20
+ # :dynamic_specific — Dynamic[X] where X is not Top: origin partial
21
+ # :dynamic_top — Dynamic[Top]: completely opaque (the "untyped" hole)
22
+ # :top — Top: universal supertype (no information)
23
+ #
24
+ # The summary exposes `precision_ratio` (constant+nominal+shaped+refined+bot
25
+ # over total) and `opaque_ratio` (dynamic_top+top over total).
26
+ #
27
+ # For Union types the *worst* member tier is used, since the union is only
28
+ # as precise as its least-precise constituent. Intersection uses the *best*
29
+ # member (the most specific side wins). Difference follows its base type.
30
+ class PrecisionScanner
31
+ TIERS = %i[
32
+ constant nominal shaped refined bot
33
+ dynamic_specific dynamic_top top
34
+ ].freeze
35
+
36
+ TIER_RANK = TIERS.each_with_index.to_h.freeze
37
+ private_constant :TIER_RANK
38
+
39
+ PRECISE_TIERS = %i[constant nominal shaped refined bot].to_set.freeze
40
+ private_constant :PRECISE_TIERS
41
+
42
+ # Per-file result. Immutable value object.
43
+ class FileResult < Data.define(:total, :tier_counts)
44
+ def precise_count
45
+ PRECISE_TIERS.sum { |t| tier_counts.fetch(t, 0) }
46
+ end
47
+
48
+ def dynamic_top_count
49
+ tier_counts.fetch(:dynamic_top, 0)
50
+ end
51
+
52
+ def dynamic_specific_count
53
+ tier_counts.fetch(:dynamic_specific, 0)
54
+ end
55
+
56
+ def dynamic_count
57
+ dynamic_top_count + dynamic_specific_count
58
+ end
59
+
60
+ def opaque_count
61
+ tier_counts.fetch(:dynamic_top, 0) + tier_counts.fetch(:top, 0)
62
+ end
63
+
64
+ def precision_ratio
65
+ return 1.0 if total.zero?
66
+
67
+ precise_count.fdiv(total)
68
+ end
69
+
70
+ def opaque_ratio
71
+ return 0.0 if total.zero?
72
+
73
+ opaque_count.fdiv(total)
74
+ end
75
+ end
76
+
77
+ # @param scope [Rigor::Scope] base scope for type inference.
78
+ def initialize(scope: nil)
79
+ @scope = scope || Scope.empty
80
+ end
81
+
82
+ # @param root [Prism::Node] the parsed AST
83
+ # @return [FileResult]
84
+ def scan(root)
85
+ scope_index = ScopeIndexer.index(root, default_scope: @scope)
86
+ tier_counts = TIERS.to_h { |t| [t, 0] }
87
+ total = 0
88
+
89
+ Source::NodeWalker.each(root) do |node|
90
+ type = scope_index[node].type_of(node)
91
+ tier = classify(type)
92
+ tier_counts[tier] += 1
93
+ total += 1
94
+ end
95
+
96
+ FileResult.new(total: total, tier_counts: tier_counts)
97
+ end
98
+
99
+ private
100
+
101
+ def classify(type)
102
+ case type
103
+ when Type::Bot then :bot
104
+ when Type::Top then :top
105
+ when Type::Constant then :constant
106
+ when Type::Nominal, Type::Singleton then :nominal
107
+ when Type::Tuple, Type::HashShape,
108
+ Type::IntegerRange, Type::App then :shaped
109
+ when Type::Refined then :refined
110
+ when Type::Dynamic then classify_dynamic(type)
111
+ when Type::Union then worst_of(type.members)
112
+ when Type::Intersection then best_of(type.members)
113
+ when Type::Difference then classify(type.base)
114
+ else :dynamic_top
115
+ end
116
+ end
117
+
118
+ def classify_dynamic(type)
119
+ type.static_facet.is_a?(Type::Top) ? :dynamic_top : :dynamic_specific
120
+ end
121
+
122
+ def worst_of(members)
123
+ members.map { |m| classify(m) }.max_by { |t| TIER_RANK[t] } || :dynamic_top
124
+ end
125
+
126
+ def best_of(members)
127
+ members.map { |m| classify(m) }.min_by { |t| TIER_RANK[t] } || :dynamic_top
128
+ end
129
+ end
130
+ end
131
+ end
@@ -347,7 +347,20 @@ module Rigor
347
347
  # result type is precise (`Constant[:even]` instead of the
348
348
  # joined `Constant[:even] | Constant[:odd]`).
349
349
  live = live_branch_for_if(node, pred_type, post_pred)
350
- return live if live
350
+ if live
351
+ live_type, _live_scope = live
352
+ # When the provably-live then-branch terminates and there is no
353
+ # else, apply the same falsey-scope narrowing as the standard
354
+ # early-return path below. Without this, `return if @ivar.nil?`
355
+ # with an ivar seeded as Constant[nil] (making nil? = Constant[true]
356
+ # and the then-branch "provably live") propagates the un-narrowed
357
+ # nil scope past the guard instead of Bot.
358
+ if branch_terminates?(node.statements, live_type) && node.subsequent.nil?
359
+ _, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
360
+ return [live_type, falsey_scope]
361
+ end
362
+ return live
363
+ end
351
364
 
352
365
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
353
366
  then_type, then_scope = eval_branch_or_nil(node.statements, truthy_scope)
@@ -376,7 +389,18 @@ module Rigor
376
389
  pred_type, post_pred = sub_eval(node.predicate, scope)
377
390
 
378
391
  live = live_branch_for_unless(node, pred_type, post_pred)
379
- return live if live
392
+ if live
393
+ live_type, _live_scope = live
394
+ # Mirror of the eval_if fix: when the provably-live unless-body
395
+ # terminates and there is no else, apply the truthy-scope narrowing
396
+ # so `return unless @ivar` with a nil-seeded ivar doesn't propagate
397
+ # the nil scope past the guard.
398
+ if branch_terminates?(node.statements, live_type) && node.else_clause.nil?
399
+ truthy_scope, = Narrowing.predicate_scopes(node.predicate, post_pred)
400
+ return [live_type, truthy_scope]
401
+ end
402
+ return live
403
+ end
380
404
 
381
405
  truthy_scope, falsey_scope = Narrowing.predicate_scopes(node.predicate, post_pred)
382
406
  then_type, then_scope = eval_branch_or_nil(node.statements, falsey_scope)
@@ -1421,7 +1445,8 @@ module Rigor
1421
1445
  binder = MethodParameterBinder.new(
1422
1446
  environment: scope.environment,
1423
1447
  class_path: current_class_path,
1424
- singleton: singleton
1448
+ singleton: singleton,
1449
+ source_path: scope.source_path
1425
1450
  )
1426
1451
  bindings = binder.bind(def_node)
1427
1452
 
@@ -1486,6 +1511,7 @@ module Rigor
1486
1511
  # remain reachable from inside nested bodies.
1487
1512
  def build_fresh_body_scope # rubocop:disable Metrics/AbcSize
1488
1513
  Scope.empty(environment: scope.environment)
1514
+ .with_source_path(scope.source_path)
1489
1515
  .with_declared_types(scope.declared_types)
1490
1516
  .with_discovered_classes(scope.discovered_classes)
1491
1517
  .with_in_source_constants(scope.in_source_constants)