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
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # IO / File support — the pure-path-manipulation tier.
9
+ #
10
+ # File and IO carry a lot of side-effecting surface (filesystem
11
+ # reads, descriptor mutations, line iteration) the analyzer
12
+ # cannot fold. Several `File` class methods, however, are
13
+ # functions over their path-string arguments — they do NOT
14
+ # touch the filesystem and do NOT depend on the current
15
+ # working directory.
16
+ #
17
+ # Folding them is platform-sensitive: every recognised method
18
+ # ([:basename, :dirname, :extname, :join, :split,
19
+ # :absolute_path?]) reads `File::SEPARATOR` /
20
+ # `File::ALT_SEPARATOR` and produces different answers on
21
+ # Windows vs POSIX hosts. The Ruby process running the
22
+ # analyzer hosts ONE platform; folding to a `Constant<String>`
23
+ # would silently bake that platform's answer into the
24
+ # analyzer's result and mis-report it on a host with a
25
+ # different separator policy.
26
+ #
27
+ # Default policy (`fold_platform_specific_paths == false`):
28
+ # decline the fold so the RBS tier answers with `Nominal[String]`
29
+ # / `Tuple[Nominal[String], Nominal[String]]` / `bool`. That is
30
+ # the platform-agnostic envelope — every concrete answer the
31
+ # method could legally return on any platform fits inside it.
32
+ # The future `non-empty-string` refinement carrier (see
33
+ # [imported-built-in-types.md](../../../../../docs/type-specification/imported-built-in-types.md))
34
+ # will tighten the basename/dirname/join cases further without
35
+ # leaking platform specifics; today we leave them at the
36
+ # nominal envelope.
37
+ #
38
+ # Opt-in policy (`fold_platform_specific_paths == true`):
39
+ # the analyzer trusts that its host platform matches the
40
+ # callers' deployment target and folds to a precise
41
+ # `Constant<String>`. Single-platform projects (most internal
42
+ # tooling, Rails apps deployed to Linux containers) can
43
+ # enable this in `.rigor.yml`:
44
+ #
45
+ # fold_platform_specific_paths: true
46
+ #
47
+ # The runner reads this on startup (`Rigor::Analysis::Runner`)
48
+ # and writes the flag here. Tests toggle the flag explicitly.
49
+ #
50
+ # See [ADR-5 — robustness principle](../../../../../docs/adr/5-robustness-principle.md):
51
+ # the platform-agnostic default is clause-1 of the principle
52
+ # applied with the constraint that "as strict as can be
53
+ # *correctness-preservingly* proved" excludes Constants whose
54
+ # value is host-specific.
55
+ module FileFolding
56
+ # File class methods that the analyzer can fold *when the
57
+ # fold is platform-safe to perform*. Today every entry is
58
+ # platform-sensitive (every one observes `File::SEPARATOR`
59
+ # or `File::ALT_SEPARATOR`); the gate below requires the
60
+ # opt-in flag for any of them to fire.
61
+ FILE_PURE_CLASS_METHODS = Set[
62
+ :basename,
63
+ :dirname,
64
+ :extname,
65
+ :join,
66
+ :split,
67
+ :absolute_path?
68
+ ].freeze
69
+ private_constant :FILE_PURE_CLASS_METHODS
70
+
71
+ # Methods whose result depends on host directory-separator
72
+ # semantics (`/` on POSIX, `/` AND `\` on Windows, drive
73
+ # letters, UNC paths). Folding these would bake the
74
+ # analyzer-host's platform into the inferred type. The opt-
75
+ # in flag below controls whether to do it anyway.
76
+ PLATFORM_DEPENDENT_METHODS = Set[
77
+ :basename, :dirname, :extname, :join, :split, :absolute_path?
78
+ ].freeze
79
+ private_constant :PLATFORM_DEPENDENT_METHODS
80
+
81
+ class << self
82
+ # Module-global flag. The runner sets it from
83
+ # `Rigor::Configuration#fold_platform_specific_paths`.
84
+ # Tests toggle it directly.
85
+ attr_accessor :fold_platform_specific_paths
86
+ end
87
+ self.fold_platform_specific_paths = false
88
+
89
+ module_function
90
+
91
+ # @return [Rigor::Type, nil] folded result, or nil to defer
92
+ # to the next dispatcher tier.
93
+ def try_dispatch(receiver:, method_name:, args:)
94
+ return nil unless dispatch_target?(receiver)
95
+ return nil unless FILE_PURE_CLASS_METHODS.include?(method_name)
96
+ return nil if platform_specific_skip?(method_name)
97
+
98
+ string_args = constant_string_args(args)
99
+ return nil if string_args.nil?
100
+
101
+ fold_class_method(method_name, string_args)
102
+ end
103
+
104
+ def platform_specific_skip?(method_name)
105
+ PLATFORM_DEPENDENT_METHODS.include?(method_name) &&
106
+ !FileFolding.fold_platform_specific_paths
107
+ end
108
+
109
+ def dispatch_target?(receiver)
110
+ receiver.is_a?(Type::Singleton) && receiver.class_name == "File"
111
+ end
112
+
113
+ def constant_string_args(args)
114
+ return [] if args.empty?
115
+ return nil unless args.all? { |arg| constant_string_arg?(arg) }
116
+
117
+ args.map { |arg| arg.value.to_s }
118
+ end
119
+
120
+ def constant_string_arg?(arg)
121
+ arg.is_a?(Type::Constant) && (arg.value.is_a?(String) || arg.value.is_a?(Symbol))
122
+ end
123
+
124
+ def fold_class_method(method_name, string_args)
125
+ result = File.public_send(method_name, *string_args)
126
+ wrap_result(result)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ def wrap_result(result)
132
+ case result
133
+ when String, true, false
134
+ Type::Combinator.constant_of(result)
135
+ when Array
136
+ return nil unless result.all?(String)
137
+
138
+ Type::Combinator.tuple_of(*result.map { |s| Type::Combinator.constant_of(s) })
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../type"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module MethodDispatcher
8
+ # Iterator-style block-parameter typing.
9
+ #
10
+ # Sits ahead of `RbsDispatch.block_param_types` so the precise
11
+ # integer bounds for `Integer#times` / `Integer#upto` /
12
+ # `Integer#downto` reach the block body's parameter binder.
13
+ # Without this tier the RBS signature for `Integer#times`
14
+ # widens the index to `Nominal[Integer]`, dropping every
15
+ # bound the receiver carries.
16
+ #
17
+ # Each rule mirrors Ruby's actual iteration semantics:
18
+ #
19
+ # - `n.times { |i| … }` yields `i ∈ [0, n-1]` when `n > 0`,
20
+ # nothing otherwise. The block-param type is therefore
21
+ # `int<0, n-1>` for a `Constant<Integer>` receiver,
22
+ # `int<0, upper-1>` for a finite `IntegerRange`, and
23
+ # `non_negative_int` for any unbounded-above shape.
24
+ # - `a.upto(b) { |i| … }` yields `i ∈ [a, b]` when `a <= b`.
25
+ # Lower bound from the receiver, upper bound from the
26
+ # argument.
27
+ # - `a.downto(b) { |i| … }` yields the same domain `[b, a]`,
28
+ # just iterated in reverse. Lower bound from the
29
+ # argument, upper bound from the receiver.
30
+ module IteratorDispatch
31
+ module_function
32
+
33
+ # @return [Array<Rigor::Type>, nil] block-param types, or
34
+ # nil to fall through to the next tier.
35
+ def block_param_types(receiver:, method_name:, args:)
36
+ case method_name
37
+ when :times then times_block_params(receiver)
38
+ when :upto then upto_block_params(receiver, args.first)
39
+ when :downto then downto_block_params(receiver, args.first)
40
+ end
41
+ end
42
+
43
+ def times_block_params(receiver)
44
+ return nil unless integer_rooted?(receiver)
45
+
46
+ upper = upper_bound_of(receiver)
47
+ return [Type::Combinator.non_negative_int] unless upper.is_a?(Integer)
48
+ return [Type::Combinator.non_negative_int] unless upper.positive?
49
+
50
+ [build_index_range(0, upper - 1)]
51
+ end
52
+
53
+ def upto_block_params(receiver, end_arg)
54
+ return nil unless integer_rooted?(receiver) && integer_rooted?(end_arg)
55
+
56
+ [build_index_range(lower_bound_of(receiver), upper_bound_of(end_arg))]
57
+ end
58
+
59
+ def downto_block_params(receiver, end_arg)
60
+ return nil unless integer_rooted?(receiver) && integer_rooted?(end_arg)
61
+
62
+ [build_index_range(lower_bound_of(end_arg), upper_bound_of(receiver))]
63
+ end
64
+
65
+ # `Constant<Integer>`, `IntegerRange`, and `Nominal[Integer]`
66
+ # all participate. Non-integer types (Float, String, …) and
67
+ # `Top`/`Dynamic` decline so the RBS tier answers.
68
+ def integer_rooted?(type)
69
+ case type
70
+ when Type::Constant then type.value.is_a?(Integer)
71
+ when Type::IntegerRange then true
72
+ when Type::Nominal then type.class_name == "Integer" && type.type_args.empty?
73
+ else false
74
+ end
75
+ end
76
+
77
+ def lower_bound_of(type)
78
+ case type
79
+ when Type::Constant then type.value
80
+ when Type::IntegerRange then type.min
81
+ when Type::Nominal then Type::IntegerRange::NEG_INFINITY
82
+ end
83
+ end
84
+
85
+ def upper_bound_of(type)
86
+ case type
87
+ when Type::Constant then type.value
88
+ when Type::IntegerRange then type.max
89
+ when Type::Nominal then Type::IntegerRange::POS_INFINITY
90
+ end
91
+ end
92
+
93
+ # Builds a `Constant`/`IntegerRange` from possibly-symbolic
94
+ # bounds. Vacuous ranges (lower > upper, indicating the
95
+ # iterator does not fire) collapse to `non_negative_int` so
96
+ # the body still type-checks against a sensible binding.
97
+ def build_index_range(lower, upper)
98
+ return Type::Combinator.non_negative_int if vacuous_range?(lower, upper)
99
+ return Type::Combinator.constant_of(lower) if lower.is_a?(Integer) && lower == upper
100
+
101
+ Type::Combinator.integer_range(lower, upper)
102
+ end
103
+
104
+ def vacuous_range?(lower, upper)
105
+ return false if lower == Type::IntegerRange::NEG_INFINITY
106
+ return false if upper == Type::IntegerRange::POS_INFINITY
107
+
108
+ lower > upper
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../type"
4
+ require_relative "../../rbs_extended"
4
5
  require_relative "../rbs_type_translator"
5
6
  require_relative "overload_selector"
6
7
 
@@ -247,6 +248,9 @@ module Rigor
247
248
 
248
249
  # rubocop:disable Metrics/ParameterLists
249
250
  def translate_return_type(method_definition, class_name:, kind:, args:, type_vars:, block_type:)
251
+ override = RbsExtended.read_return_type_override(method_definition)
252
+ return override if override
253
+
250
254
  instance_type = Type::Combinator.nominal_of(class_name)
251
255
  self_type =
252
256
  case kind
@@ -93,9 +93,35 @@ module Rigor
93
93
  case receiver
94
94
  when Type::Tuple then dispatch_tuple(receiver, method_name, args)
95
95
  when Type::HashShape then dispatch_hash_shape(receiver, method_name, args)
96
+ when Type::Nominal then dispatch_nominal_size(receiver, method_name, args)
97
+ when Type::Difference then dispatch_difference(receiver, method_name, args)
96
98
  end
97
99
  end
98
100
 
101
+ # Tightens `Array#size` / `Array#length` / `String#length` /
102
+ # `String#bytesize` / `Hash#size` etc. on a `Nominal` receiver
103
+ # from the RBS-declared `Integer` to `non_negative_int`. The
104
+ # tier ahead of RBS sees the more precise carrier so
105
+ # downstream narrowing (`if size > 0; …`) actually has a
106
+ # range to intersect with.
107
+ SIZE_RETURNING_NOMINALS = {
108
+ "Array" => %i[size length count],
109
+ "String" => %i[length size bytesize],
110
+ "Hash" => %i[size length count],
111
+ "Set" => %i[size length count],
112
+ "Range" => %i[size length count]
113
+ }.freeze
114
+ private_constant :SIZE_RETURNING_NOMINALS
115
+
116
+ # When the difference removes the empty value of the
117
+ # base type (`Constant[""]`, `Constant[0]`, an empty
118
+ # Tuple, an empty HashShape), `size` / `length` /
119
+ # `count` MUST be `positive-int` (the base's
120
+ # non-negative range minus the removed point's `0`),
121
+ # and `empty?` / `zero?` MUST be `Constant[false]`.
122
+ EMPTY_REMOVAL_BASES = %w[String Array Hash Set].freeze
123
+ private_constant :EMPTY_REMOVAL_BASES
124
+
99
125
  class << self
100
126
  private
101
127
 
@@ -113,6 +139,87 @@ module Rigor
113
139
  send(handler, shape, method_name, args)
114
140
  end
115
141
 
142
+ def dispatch_nominal_size(nominal, method_name, args)
143
+ return nil unless args.empty?
144
+
145
+ selectors = SIZE_RETURNING_NOMINALS[nominal.class_name]
146
+ return nil unless selectors&.include?(method_name)
147
+
148
+ Type::Combinator.non_negative_int
149
+ end
150
+
151
+ # Refinement-aware projections over a `Difference[base,
152
+ # removed]` receiver. When the removed value is the
153
+ # empty witness of the base (`Constant[""]` for
154
+ # String, `Tuple[]` for Array, `HashShape{}` for Hash,
155
+ # `Constant[0]` for Integer), the catalog tier knows:
156
+ #
157
+ # ns.size # positive-int
158
+ # ns.size == 0 # Constant[false] (via narrowing tier)
159
+ # ns.empty? # Constant[false]
160
+ # nzi.zero? # Constant[false]
161
+ #
162
+ # For any other base method, the difference is opaque
163
+ # to ShapeDispatch — we delegate to the base nominal
164
+ # so the size/length tier still answers the broader
165
+ # `non_negative_int` envelope where applicable.
166
+ def dispatch_difference(difference, method_name, args)
167
+ base = difference.base
168
+ return nil unless base.is_a?(Type::Nominal)
169
+
170
+ if removes_empty_witness?(difference)
171
+ precise = empty_removal_projection(base, method_name, args)
172
+ return precise if precise
173
+ end
174
+
175
+ dispatch_nominal_size(base, method_name, args)
176
+ end
177
+
178
+ EMPTY_WITNESS_PREDICATES = {
179
+ "String" => ->(removed) { removed.is_a?(Type::Constant) && removed.value == "" },
180
+ "Integer" => lambda { |removed|
181
+ removed.is_a?(Type::Constant) && removed.value.is_a?(Integer) && removed.value.zero?
182
+ },
183
+ "Array" => ->(removed) { removed.is_a?(Type::Tuple) && removed.elements.empty? },
184
+ "Hash" => ->(removed) { removed.is_a?(Type::HashShape) && removed.pairs.empty? }
185
+ }.freeze
186
+ private_constant :EMPTY_WITNESS_PREDICATES
187
+
188
+ def removes_empty_witness?(difference)
189
+ return false unless difference.base.is_a?(Type::Nominal)
190
+
191
+ predicate = EMPTY_WITNESS_PREDICATES[difference.base.class_name]
192
+ !!(predicate && predicate.call(difference.removed))
193
+ end
194
+
195
+ def empty_removal_projection(base, method_name, args)
196
+ return nil unless args.empty?
197
+
198
+ if %i[size length count bytesize].include?(method_name)
199
+ return size_returning_for_empty_removal(base, method_name)
200
+ end
201
+
202
+ empty_predicate_projection(base, method_name)
203
+ end
204
+
205
+ def empty_predicate_projection(base, method_name)
206
+ case method_name
207
+ when :empty?
208
+ base.class_name == "Integer" ? nil : Type::Combinator.constant_of(false)
209
+ when :zero?
210
+ base.class_name == "Integer" ? Type::Combinator.constant_of(false) : nil
211
+ end
212
+ end
213
+
214
+ def size_returning_for_empty_removal(base, method_name)
215
+ return nil if base.class_name == "Integer" # Integer has no size method on Difference
216
+
217
+ selectors = SIZE_RETURNING_NOMINALS[base.class_name]
218
+ return nil unless selectors&.include?(method_name)
219
+
220
+ Type::Combinator.positive_int
221
+ end
222
+
116
223
  def tuple_first(tuple, _method_name, args)
117
224
  return nil unless args.empty?
118
225
  return Type::Combinator.constant_of(nil) if tuple.elements.empty?
@@ -4,6 +4,8 @@ require_relative "../type"
4
4
  require_relative "method_dispatcher/constant_folding"
5
5
  require_relative "method_dispatcher/shape_dispatch"
6
6
  require_relative "method_dispatcher/rbs_dispatch"
7
+ require_relative "method_dispatcher/iterator_dispatch"
8
+ require_relative "method_dispatcher/file_folding"
7
9
 
8
10
  module Rigor
9
11
  module Inference
@@ -56,29 +58,12 @@ module Rigor
56
58
  def dispatch(receiver_type:, method_name:, arg_types:, block_type: nil, environment: nil)
57
59
  return nil if receiver_type.nil?
58
60
 
59
- meta_result = try_meta_introspection(receiver_type, method_name)
60
- return meta_result if meta_result
61
-
62
- constant_result = ConstantFolding.try_fold(
63
- receiver: receiver_type,
64
- method_name: method_name,
65
- args: arg_types
66
- )
67
- return constant_result if constant_result
68
-
69
- shape_result = ShapeDispatch.try_dispatch(
70
- receiver: receiver_type,
71
- method_name: method_name,
72
- args: arg_types
73
- )
74
- return shape_result if shape_result
61
+ precise = dispatch_precise_tiers(receiver_type, method_name, arg_types)
62
+ return precise if precise
75
63
 
76
64
  rbs_result = RbsDispatch.try_dispatch(
77
- receiver: receiver_type,
78
- method_name: method_name,
79
- args: arg_types,
80
- environment: environment,
81
- block_type: block_type
65
+ receiver: receiver_type, method_name: method_name, args: arg_types,
66
+ environment: environment, block_type: block_type
82
67
  )
83
68
  return rbs_result if rbs_result
84
69
 
@@ -96,6 +81,21 @@ module Rigor
96
81
  try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
97
82
  end
98
83
 
84
+ # Runs the precision tiers (constant fold, shape dispatch,
85
+ # file-path fold) in order and returns the first non-nil
86
+ # answer. Each tier owns its own receiver/argument shape
87
+ # checks; a tier that does not recognise the receiver returns
88
+ # nil so the next tier can try. The RBS tier sits below this
89
+ # chain and is invoked by the outer `dispatch` method.
90
+ def dispatch_precise_tiers(receiver_type, method_name, arg_types)
91
+ meta_result = try_meta_introspection(receiver_type, method_name)
92
+ return meta_result if meta_result
93
+
94
+ ConstantFolding.try_fold(receiver: receiver_type, method_name: method_name, args: arg_types) ||
95
+ ShapeDispatch.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) ||
96
+ FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types)
97
+ end
98
+
99
99
  def try_user_class_fallback(receiver_type, method_name, arg_types, environment, block_type)
100
100
  return nil if environment.nil?
101
101
 
@@ -201,6 +201,13 @@ module Rigor
201
201
  def expected_block_param_types(receiver_type:, method_name:, arg_types:, environment: nil)
202
202
  return [] if receiver_type.nil?
203
203
 
204
+ iterator_result = IteratorDispatch.block_param_types(
205
+ receiver: receiver_type,
206
+ method_name: method_name,
207
+ args: arg_types
208
+ )
209
+ return iterator_result if iterator_result
210
+
204
211
  RbsDispatch.block_param_types(
205
212
  receiver: receiver_type,
206
213
  method_name: method_name,