rigortype 0.0.2 → 0.0.4

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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +24 -7
  3. data/data/builtins/ruby_core/array.yml +1470 -0
  4. data/data/builtins/ruby_core/file.yml +501 -0
  5. data/data/builtins/ruby_core/hash.yml +936 -0
  6. data/data/builtins/ruby_core/io.yml +1594 -0
  7. data/data/builtins/ruby_core/numeric.yml +1809 -0
  8. data/data/builtins/ruby_core/range.yml +389 -0
  9. data/data/builtins/ruby_core/set.yml +594 -0
  10. data/data/builtins/ruby_core/string.yml +1850 -0
  11. data/data/builtins/ruby_core/time.yml +750 -0
  12. data/lib/rigor/analysis/check_rules.rb +97 -4
  13. data/lib/rigor/analysis/runner.rb +4 -0
  14. data/lib/rigor/builtins/imported_refinements.rb +251 -0
  15. data/lib/rigor/configuration.rb +6 -1
  16. data/lib/rigor/inference/acceptance.rb +324 -6
  17. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  18. data/lib/rigor/inference/builtins/hash_catalog.rb +40 -0
  19. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  20. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  21. data/lib/rigor/inference/builtins/range_catalog.rb +46 -0
  22. data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
  23. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  24. data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
  25. data/lib/rigor/inference/expression_typer.rb +48 -1
  26. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +670 -16
  27. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  28. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +215 -0
  29. data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
  30. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  31. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +240 -4
  32. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  33. data/lib/rigor/inference/method_parameter_binder.rb +29 -4
  34. data/lib/rigor/inference/narrowing.rb +376 -4
  35. data/lib/rigor/inference/scope_indexer.rb +10 -2
  36. data/lib/rigor/inference/statement_evaluator.rb +213 -2
  37. data/lib/rigor/rbs_extended.rb +230 -15
  38. data/lib/rigor/scope.rb +14 -0
  39. data/lib/rigor/type/combinator.rb +159 -1
  40. data/lib/rigor/type/difference.rb +155 -0
  41. data/lib/rigor/type/integer_range.rb +137 -0
  42. data/lib/rigor/type/intersection.rb +135 -0
  43. data/lib/rigor/type/refined.rb +174 -0
  44. data/lib/rigor/type.rb +4 -0
  45. data/lib/rigor/version.rb +1 -1
  46. data/sig/rigor/rbs_extended.rbs +14 -0
  47. data/sig/rigor/scope.rbs +1 -0
  48. data/sig/rigor/type.rbs +91 -1
  49. metadata +25 -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,215 @@
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 # rubocop:disable Metrics/ModuleLength
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
+ when :each_with_index then each_with_index_block_params(receiver)
41
+ end
42
+ end
43
+
44
+ def times_block_params(receiver)
45
+ return nil unless integer_rooted?(receiver)
46
+
47
+ upper = upper_bound_of(receiver)
48
+ return [Type::Combinator.non_negative_int] unless upper.is_a?(Integer)
49
+ return [Type::Combinator.non_negative_int] unless upper.positive?
50
+
51
+ [build_index_range(0, upper - 1)]
52
+ end
53
+
54
+ def upto_block_params(receiver, end_arg)
55
+ return nil unless integer_rooted?(receiver) && integer_rooted?(end_arg)
56
+
57
+ [build_index_range(lower_bound_of(receiver), upper_bound_of(end_arg))]
58
+ end
59
+
60
+ def downto_block_params(receiver, end_arg)
61
+ return nil unless integer_rooted?(receiver) && integer_rooted?(end_arg)
62
+
63
+ [build_index_range(lower_bound_of(end_arg), upper_bound_of(receiver))]
64
+ end
65
+
66
+ # Generalised iterator: every Enumerable-shaped collection
67
+ # in v0.0.4 yields `(element, index)` where the index is
68
+ # always `non-negative-int`. The element comes from the
69
+ # receiver's shape:
70
+ #
71
+ # - `Array[T]` / `Set[T]` / `Range[T]` → T
72
+ # - `Tuple[A, B, C]` → A | B | C
73
+ # (empty tuple cannot iterate, but we conservatively
74
+ # fall through to RBS so a missing rule never throws)
75
+ # - `Hash[K, V]` / `HashShape{...}` → Tuple[K, V]
76
+ # (Ruby yields `[key, value]` pairs as the element)
77
+ # - `Constant<Array>` / `Constant<Range>` / `Constant<Set>`
78
+ # → corresponding Constant element
79
+ #
80
+ # Receivers we cannot project (Top, Dynamic, unknown
81
+ # nominals, IO, …) decline so the RBS tier still answers
82
+ # — its element type is correct, only the index would
83
+ # widen to plain Integer.
84
+ def each_with_index_block_params(receiver)
85
+ element = element_type_of(receiver)
86
+ return nil if element.nil?
87
+
88
+ [element, Type::Combinator.non_negative_int]
89
+ end
90
+
91
+ ELEMENT_BY_NOMINAL = {
92
+ "Array" => :nominal_unary_element,
93
+ "Set" => :nominal_unary_element,
94
+ "Range" => :nominal_unary_element,
95
+ "Hash" => :nominal_hash_pair_element
96
+ }.freeze
97
+ private_constant :ELEMENT_BY_NOMINAL
98
+
99
+ def element_type_of(receiver)
100
+ case receiver
101
+ when Type::Tuple then tuple_element(receiver)
102
+ when Type::HashShape then hash_shape_pair_element(receiver)
103
+ when Type::Nominal then nominal_element(receiver)
104
+ when Type::Constant then constant_element(receiver)
105
+ end
106
+ end
107
+
108
+ def tuple_element(tuple)
109
+ return nil if tuple.elements.empty?
110
+ return tuple.elements.first if tuple.elements.size == 1
111
+
112
+ Type::Combinator.union(*tuple.elements)
113
+ end
114
+
115
+ def hash_shape_pair_element(shape)
116
+ return nil if shape.pairs.empty?
117
+
118
+ key = Type::Combinator.union(*shape.pairs.keys.map { |k| Type::Combinator.constant_of(k) })
119
+ value = Type::Combinator.union(*shape.pairs.values)
120
+ Type::Combinator.tuple_of(key, value)
121
+ end
122
+
123
+ def nominal_element(nominal)
124
+ handler = ELEMENT_BY_NOMINAL[nominal.class_name]
125
+ return nil unless handler
126
+
127
+ send(handler, nominal)
128
+ end
129
+
130
+ def nominal_unary_element(nominal)
131
+ nominal.type_args.first
132
+ end
133
+
134
+ def nominal_hash_pair_element(nominal)
135
+ key, value = nominal.type_args
136
+ return nil if key.nil? || value.nil?
137
+
138
+ Type::Combinator.tuple_of(key, value)
139
+ end
140
+
141
+ def constant_element(constant)
142
+ case constant.value
143
+ when Array
144
+ return nil if constant.value.empty?
145
+
146
+ Type::Combinator.union(*constant.value.map { |v| Type::Combinator.constant_of(v) })
147
+ when Range
148
+ range_constant_element(constant.value)
149
+ end
150
+ end
151
+
152
+ def range_constant_element(range)
153
+ beg = range.begin
154
+ en = range.end
155
+ return Type::Combinator.constant_of(beg) if beg.is_a?(Integer) && beg == en
156
+
157
+ if beg.is_a?(Integer) && en.is_a?(Integer)
158
+ upper = range.exclude_end? ? en - 1 : en
159
+ return build_index_range(beg, upper)
160
+ end
161
+
162
+ # Mixed / non-integer ranges decline: the dispatcher
163
+ # falls through to RBS's element-type answer.
164
+ nil
165
+ end
166
+
167
+ # `Constant<Integer>`, `IntegerRange`, and `Nominal[Integer]`
168
+ # all participate. Non-integer types (Float, String, …) and
169
+ # `Top`/`Dynamic` decline so the RBS tier answers.
170
+ def integer_rooted?(type)
171
+ case type
172
+ when Type::Constant then type.value.is_a?(Integer)
173
+ when Type::IntegerRange then true
174
+ when Type::Nominal then type.class_name == "Integer" && type.type_args.empty?
175
+ else false
176
+ end
177
+ end
178
+
179
+ def lower_bound_of(type)
180
+ case type
181
+ when Type::Constant then type.value
182
+ when Type::IntegerRange then type.min
183
+ when Type::Nominal then Type::IntegerRange::NEG_INFINITY
184
+ end
185
+ end
186
+
187
+ def upper_bound_of(type)
188
+ case type
189
+ when Type::Constant then type.value
190
+ when Type::IntegerRange then type.max
191
+ when Type::Nominal then Type::IntegerRange::POS_INFINITY
192
+ end
193
+ end
194
+
195
+ # Builds a `Constant`/`IntegerRange` from possibly-symbolic
196
+ # bounds. Vacuous ranges (lower > upper, indicating the
197
+ # iterator does not fire) collapse to `non_negative_int` so
198
+ # the body still type-checks against a sensible binding.
199
+ def build_index_range(lower, upper)
200
+ return Type::Combinator.non_negative_int if vacuous_range?(lower, upper)
201
+ return Type::Combinator.constant_of(lower) if lower.is_a?(Integer) && lower == upper
202
+
203
+ Type::Combinator.integer_range(lower, upper)
204
+ end
205
+
206
+ def vacuous_range?(lower, upper)
207
+ return false if lower == Type::IntegerRange::NEG_INFINITY
208
+ return false if upper == Type::IntegerRange::POS_INFINITY
209
+
210
+ lower > upper
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end
@@ -53,13 +53,22 @@ module Rigor
53
53
  overloads = method_definition.method_types
54
54
  return nil if overloads.empty?
55
55
 
56
+ # `rigor:v1:param: <name> <refinement>` annotations on
57
+ # this method override the RBS-declared parameter type
58
+ # at the matching name. The map is consumed inside
59
+ # `accepts_param?` so overload selection sees the
60
+ # tighter type when filtering candidates by argument
61
+ # compatibility.
62
+ param_overrides = RbsExtended.param_type_override_map(method_definition)
63
+
56
64
  match = find_matching_overload(
57
65
  overloads,
58
66
  arg_types: arg_types,
59
67
  self_type: self_type,
60
68
  instance_type: instance_type,
61
69
  type_vars: type_vars,
62
- block_required: block_required
70
+ block_required: block_required,
71
+ param_overrides: param_overrides
63
72
  )
64
73
  return match if match
65
74
  return overloads.find { |mt| overload_has_block?(mt) } if block_required
@@ -76,7 +85,8 @@ module Rigor
76
85
  private
77
86
 
78
87
  # rubocop:disable Metrics/ParameterLists
79
- def find_matching_overload(overloads, arg_types:, self_type:, instance_type:, type_vars:, block_required:)
88
+ def find_matching_overload(overloads, arg_types:, self_type:, instance_type:, type_vars:, block_required:,
89
+ param_overrides:)
80
90
  overloads.find do |method_type|
81
91
  next false if block_required && !OverloadSelector.overload_has_block?(method_type)
82
92
 
@@ -85,13 +95,15 @@ module Rigor
85
95
  arg_types,
86
96
  self_type: self_type,
87
97
  instance_type: instance_type,
88
- type_vars: type_vars
98
+ type_vars: type_vars,
99
+ param_overrides: param_overrides
89
100
  )
90
101
  end
91
102
  end
92
103
  # rubocop:enable Metrics/ParameterLists
93
104
 
94
- def matches?(method_type, arg_types, self_type:, instance_type:, type_vars:)
105
+ # rubocop:disable Metrics/ParameterLists
106
+ def matches?(method_type, arg_types, self_type:, instance_type:, type_vars:, param_overrides:)
95
107
  return false if method_type.respond_to?(:type_params) && rejects_keyword_required?(method_type)
96
108
 
97
109
  fun = method_type.type
@@ -104,10 +116,12 @@ module Rigor
104
116
  arg,
105
117
  self_type: self_type,
106
118
  instance_type: instance_type,
107
- type_vars: type_vars
119
+ type_vars: type_vars,
120
+ param_overrides: param_overrides
108
121
  )
109
122
  end
110
123
  end
124
+ # rubocop:enable Metrics/ParameterLists
111
125
 
112
126
  # Slice 4 phase 2c does not pass keyword arguments through the
113
127
  # call site (caller passes only positional `arg_types`). An
@@ -152,8 +166,9 @@ module Rigor
152
166
  head
153
167
  end
154
168
 
155
- def accepts_param?(param, arg, self_type:, instance_type:, type_vars:)
156
- param_type = RbsTypeTranslator.translate(
169
+ # rubocop:disable Metrics/ParameterLists
170
+ def accepts_param?(param, arg, self_type:, instance_type:, type_vars:, param_overrides:)
171
+ param_type = param_overrides[param.name] || RbsTypeTranslator.translate(
157
172
  param.type,
158
173
  self_type: self_type,
159
174
  instance_type: instance_type,
@@ -162,6 +177,7 @@ module Rigor
162
177
  result = param_type.accepts(arg, mode: :gradual)
163
178
  result.yes? || result.maybe?
164
179
  end
180
+ # rubocop:enable Metrics/ParameterLists
165
181
  end
166
182
  end
167
183
  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