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
@@ -1,19 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "../../type"
4
+ require_relative "../builtins/numeric_catalog"
5
+ require_relative "../builtins/string_catalog"
6
+ require_relative "../builtins/array_catalog"
7
+ require_relative "../builtins/hash_catalog"
8
+ require_relative "../builtins/range_catalog"
9
+ require_relative "../builtins/set_catalog"
10
+ require_relative "../builtins/time_catalog"
4
11
 
5
12
  module Rigor
6
13
  module Inference
7
14
  module MethodDispatcher
8
- # Slice 2 rule book that folds binary operations on `Rigor::Type::Constant`
9
- # receivers into another `Constant` whenever:
15
+ # Slice 2 rule book that folds method calls on `Rigor::Type::Constant`
16
+ # receivers (and unions of them) into another `Constant` (or a small
17
+ # `Union[Constant, …]`) whenever:
10
18
  #
11
- # * the receiver is a recognised scalar literal,
12
- # * exactly one argument is supplied and it is also a `Constant`,
19
+ # * the receiver is a recognised scalar literal, OR a `Union` whose
20
+ # members are all `Constant`,
21
+ # * arguments (zero or one) are likewise `Constant` or `Union[Constant…]`,
13
22
  # * the method name is in the curated whitelist for the receiver's class,
14
23
  # * the operation cannot accidentally explode the analyzer (we cap
15
- # string-fold output at `STRING_FOLD_BYTE_LIMIT` bytes), and
16
- # * the actual Ruby invocation does not raise.
24
+ # string-fold output at `STRING_FOLD_BYTE_LIMIT` bytes, the input
25
+ # cartesian product at `UNION_FOLD_INPUT_LIMIT`, and the deduped
26
+ # output union at `UNION_FOLD_OUTPUT_LIMIT`), and
27
+ # * the actual Ruby invocation does not raise on at least one
28
+ # receiver/argument combination.
17
29
  #
18
30
  # Anything else returns `nil`, signalling "no rule matched" so the
19
31
  # caller (`ExpressionTyper`) falls back to `Dynamic[Top]` and records a
@@ -21,7 +33,7 @@ module Rigor
21
33
  # behind this rule book, but the constant-folding semantics defined
22
34
  # here MUST NOT regress: any value reachable by literal arithmetic at
23
35
  # parse time is meant to be foldable independent of RBS data.
24
- module ConstantFolding
36
+ module ConstantFolding # rubocop:disable Metrics/ModuleLength
25
37
  module_function
26
38
 
27
39
  NUMERIC_BINARY = Set[:+, :-, :*, :/, :%, :<, :<=, :>, :>=, :==, :!=, :<=>].freeze
@@ -30,25 +42,667 @@ module Rigor
30
42
  BOOL_BINARY = Set[:&, :|, :^, :==, :!=].freeze
31
43
  NIL_BINARY = Set[:==, :!=].freeze
32
44
 
45
+ # v0.0.3 C — pure unary catalogue. Each method must:
46
+ # - take zero arguments,
47
+ # - have no side effects,
48
+ # - never raise on the type's full domain (or be
49
+ # guarded by `safe?` below),
50
+ # - return a value safe to materialise as a
51
+ # `Constant` (no large strings, no host objects).
52
+ #
53
+ # The catalogue is the prerequisite for aggressive
54
+ # constant folding through user methods: once
55
+ # `Constant[3].odd?` folds to `Constant[true]`, the
56
+ # inter-procedural inference path landed in v0.0.2
57
+ # #5 carries the constant through the body of a
58
+ # user-defined `def is_odd(n) = n.odd?` so
59
+ # `Parity.new.is_odd(3)` types as `Constant[true]`
60
+ # rather than the RBS-widened `bool`.
61
+ INTEGER_UNARY = Set[
62
+ :odd?, :even?, :zero?, :positive?, :negative?,
63
+ :succ, :pred, :next, :abs, :magnitude,
64
+ :bit_length, :to_s, :to_i, :to_int, :to_f,
65
+ :inspect, :hash, :-@, :+@, :~
66
+ ].freeze
67
+ FLOAT_UNARY = Set[
68
+ :zero?, :positive?, :negative?,
69
+ :nan?, :finite?, :infinite?,
70
+ :abs, :magnitude, :floor, :ceil, :round, :truncate,
71
+ :to_s, :to_i, :to_int, :to_f,
72
+ :inspect, :hash, :-@, :+@
73
+ ].freeze
74
+ STRING_UNARY = Set[
75
+ :upcase, :downcase, :capitalize, :swapcase,
76
+ :reverse, :length, :size, :bytesize,
77
+ :empty?, :strip, :lstrip, :rstrip, :chomp,
78
+ :to_s, :to_str, :to_sym, :intern,
79
+ :inspect, :hash
80
+ ].freeze
81
+ SYMBOL_UNARY = Set[
82
+ :to_s, :to_sym, :to_proc, :length, :size,
83
+ :empty?, :upcase, :downcase, :capitalize,
84
+ :swapcase, :inspect, :hash
85
+ ].freeze
86
+ BOOL_UNARY = Set[:!, :to_s, :inspect, :hash, :&, :|, :^].freeze
87
+ NIL_UNARY = Set[:nil?, :!, :to_s, :to_a, :to_h, :inspect, :hash].freeze
88
+
33
89
  STRING_FOLD_BYTE_LIMIT = 4096
34
90
 
35
- # @return [Rigor::Type::Constant, nil]
91
+ # Input cartesian product hard cap. Keeps fold cost bounded even
92
+ # when the receiver and argument are both `Union[Constant…]`.
93
+ # 5 × 5 = 25 inputs is permitted; 6 × 6 = 36 is not. The user-
94
+ # facing payoff (a precise small enum) drops off fast past this
95
+ # range and CRuby method invocation cost adds up.
96
+ UNION_FOLD_INPUT_LIMIT = 32
97
+
98
+ # Output cardinality cap on the deduped result union. A single
99
+ # binary op on a small range can collapse: `[1,2,3] + [2,4,6]`
100
+ # produces 9 raw pairs but only 7 distinct sums. The output cap
101
+ # is what ultimately limits how wide an inferred type gets.
102
+ UNION_FOLD_OUTPUT_LIMIT = 8
103
+
104
+ # @return [Rigor::Type::Constant, Rigor::Type::Union, Rigor::Type::IntegerRange, nil]
36
105
  def try_fold(receiver:, method_name:, args:)
37
- return nil unless receiver.is_a?(Type::Constant)
38
- return nil if args.size != 1
106
+ receiver_set = numeric_set_of(receiver)
107
+ return nil unless receiver_set
108
+
109
+ arg_sets = args.map { |a| numeric_set_of(a) }
110
+ return nil if arg_sets.any?(&:nil?)
111
+
112
+ case args.size
113
+ when 0 then try_fold_unary(receiver_set, method_name)
114
+ when 1 then try_fold_binary(receiver_set, method_name, arg_sets.first)
115
+ end
116
+ end
117
+
118
+ # Normalises an input type into one of:
119
+ # - `Array<Object>` for a `Constant` (1-element) or
120
+ # `Union[Constant…]` (n-element) — concrete values to enumerate.
121
+ # - `Type::IntegerRange` — bounded interval.
122
+ # - `nil` — the input shape is not foldable.
123
+ def numeric_set_of(type)
124
+ case type
125
+ when Type::Constant then [type.value]
126
+ when Type::Union
127
+ return nil unless type.members.all?(Type::Constant)
128
+
129
+ type.members.map(&:value)
130
+ when Type::IntegerRange then type
131
+ end
132
+ end
133
+
134
+ def try_fold_unary(set, method_name)
135
+ case set
136
+ when Array then try_fold_unary_set(set, method_name)
137
+ when Type::IntegerRange then try_fold_unary_range(set, method_name)
138
+ end
139
+ end
140
+
141
+ def try_fold_binary(left, method_name, right)
142
+ return try_fold_divmod(left, right) if method_name == :divmod
143
+
144
+ if left.is_a?(Type::IntegerRange) || right.is_a?(Type::IntegerRange)
145
+ try_fold_binary_range(left, method_name, right)
146
+ else
147
+ try_fold_binary_set(left, method_name, right)
148
+ end
149
+ end
150
+
151
+ # `Integer#divmod` and `Float#divmod` return a 2-element array
152
+ # `[quotient, remainder]`. We project that into
153
+ # `Tuple[Constant[q], Constant[r]]` so downstream rules see
154
+ # the precise element types. Union/range receivers are
155
+ # widened per-position: each tuple slot carries the union of
156
+ # quotients (resp. remainders) over every safe input pair.
157
+ # Range inputs are not yet folded — they bail to nil.
158
+ def try_fold_divmod(left, right)
159
+ pairs = collect_divmod_pairs(left, right)
160
+ return nil unless pairs && !pairs.empty?
161
+
162
+ q_type = build_constant_type(pairs.map(&:first))
163
+ r_type = build_constant_type(pairs.map(&:last))
164
+ return nil unless q_type && r_type
165
+
166
+ Type::Combinator.tuple_of(q_type, r_type)
167
+ end
39
168
 
40
- arg = args.first
41
- return nil unless arg.is_a?(Type::Constant)
42
- return nil unless safe?(receiver.value, method_name, arg.value)
169
+ def collect_divmod_pairs(left, right)
170
+ return nil unless left.is_a?(Array) && right.is_a?(Array)
171
+ return nil if left.size * right.size > UNION_FOLD_INPUT_LIMIT
43
172
 
44
- Type::Combinator.constant_of(receiver.value.public_send(method_name, arg.value))
173
+ left.flat_map do |lv|
174
+ right.flat_map { |rv| invoke_divmod(lv, rv) || [] }
175
+ end
176
+ end
177
+
178
+ # Returns `[[quotient, remainder]]` (single-element array
179
+ # wrapping the tuple) on success; `nil` to signal "skip this
180
+ # pair". The wrapping mirrors `invoke_binary` so we can
181
+ # use `flat_map` and not lose legitimate 0/false elements.
182
+ def invoke_divmod(receiver_value, arg_value)
183
+ return nil unless receiver_value.is_a?(Numeric) && arg_value.is_a?(Numeric)
184
+
185
+ result = receiver_value.divmod(arg_value)
186
+ divmod_result_to_pair(result)
45
187
  rescue StandardError
46
188
  nil
47
189
  end
48
190
 
191
+ def divmod_result_to_pair(result)
192
+ return nil unless result.is_a?(Array) && result.size == 2
193
+ return nil unless result.all? { |v| foldable_constant_value?(v) }
194
+
195
+ [result]
196
+ end
197
+
198
+ def try_fold_unary_set(receiver_values, method_name)
199
+ # Type-level allow check on every receiver. If one member's
200
+ # type does not have the method in its allow list (e.g.
201
+ # `Union[String, nil].nil?` — `:nil?` is not in
202
+ # `STRING_UNARY`), bail the fold so the RBS tier answers.
203
+ # Silently dropping the unsafe member would lie about the
204
+ # remaining receivers' behaviour.
205
+ return nil unless receiver_values.all? { |rv| unary_method_allowed?(rv, method_name) }
206
+
207
+ results = receiver_values.flat_map do |rv|
208
+ invoke_unary(rv, method_name) || []
209
+ end
210
+ build_constant_type(results, source: receiver_values)
211
+ end
212
+
213
+ def try_fold_binary_set(receiver_values, method_name, arg_values)
214
+ return nil if receiver_values.size * arg_values.size > UNION_FOLD_INPUT_LIMIT
215
+ return nil unless receiver_values.all? { |rv| binary_method_allowed?(rv, method_name) }
216
+
217
+ results = receiver_values.flat_map do |rv|
218
+ arg_values.flat_map { |av| invoke_binary(rv, method_name, av) || [] }
219
+ end
220
+ build_constant_type(results, source: receiver_values + arg_values)
221
+ end
222
+
223
+ def unary_method_allowed?(receiver_value, method_name)
224
+ unary_ops_for(receiver_value).include?(method_name) ||
225
+ catalog_allows?(receiver_value, method_name)
226
+ end
227
+
228
+ def binary_method_allowed?(receiver_value, method_name)
229
+ ops_for(receiver_value).include?(method_name) ||
230
+ catalog_allows?(receiver_value, method_name)
231
+ end
232
+
233
+ # Builds a Constant or Union[Constant…] from a flat list of
234
+ # Ruby values. When the deduped set exceeds
235
+ # `UNION_FOLD_OUTPUT_LIMIT` and every result is an Integer,
236
+ # widens to the bounding `IntegerRange` instead of returning
237
+ # nil — that is the graceful escape valve for additions over
238
+ # disjoint integer ranges. The `source` array is used only as
239
+ # a hint that the result set's "Integer-ness" was already
240
+ # implied by the inputs (so the widening fallback only fires
241
+ # for arithmetic over integers).
242
+ def build_constant_type(values, source: nil)
243
+ return nil if values.empty?
244
+
245
+ unique = values.uniq
246
+ return collapse_constants(unique) if unique.size <= UNION_FOLD_OUTPUT_LIMIT
247
+
248
+ widen_to_integer_range(unique, source) ||
249
+ (raise_if_strict || nil)
250
+ end
251
+
252
+ def collapse_constants(values)
253
+ constants = values.map { |v| Type::Combinator.constant_of(v) }
254
+ constants.size == 1 ? constants.first : Type::Combinator.union(*constants)
255
+ end
256
+
257
+ # Widening fallback: when every successful result is an
258
+ # Integer, return the bounding `IntegerRange` rather than
259
+ # losing the answer entirely. The fallback is also gated on
260
+ # the input set being all-integers, so a fold whose results
261
+ # happen to land on integers but whose receivers were Floats
262
+ # does not silently change shape.
263
+ def widen_to_integer_range(values, source)
264
+ return nil unless values.all?(Integer)
265
+ return nil if source && !source.all? { |v| v.is_a?(Integer) || v.is_a?(Type::IntegerRange) }
266
+
267
+ Type::Combinator.integer_range(values.min, values.max)
268
+ end
269
+
270
+ # Reserved hook: present so future `:strict` modes can raise
271
+ # rather than silently returning nil. Today it always returns
272
+ # nil so behaviour is unchanged.
273
+ def raise_if_strict
274
+ nil
275
+ end
276
+
277
+ # ----------------------------------------------------------------
278
+ # IntegerRange arithmetic and comparison.
279
+ # ----------------------------------------------------------------
280
+
281
+ RANGE_ADDITIVE = Set[:+, :-].freeze
282
+ RANGE_COMPARISON = Set[:<, :<=, :>, :>=, :==, :!=].freeze
283
+
284
+ # Per-operator dispatch table for binary range ops. Each
285
+ # value is a method symbol on `ConstantFolding` taking
286
+ # `(left, right)` and returning a `Type` or `nil`.
287
+ BINARY_RANGE_HANDLERS = {
288
+ :* => :range_multiply,
289
+ :/ => :range_divide,
290
+ :% => :range_modulo
291
+ }.freeze
292
+ private_constant :BINARY_RANGE_HANDLERS
293
+
294
+ def try_fold_binary_range(left, method_name, right)
295
+ l = ensure_integer_range(left)
296
+ r = ensure_integer_range(right)
297
+ return nil unless l && r
298
+ return range_additive(l, method_name, r) if RANGE_ADDITIVE.include?(method_name)
299
+ return range_comparison(l, method_name, r) if RANGE_COMPARISON.include?(method_name)
300
+ return send(BINARY_RANGE_HANDLERS[method_name], l, r) if BINARY_RANGE_HANDLERS.key?(method_name)
301
+
302
+ nil
303
+ end
304
+
305
+ # Promotes an array-of-values input to an `IntegerRange` when
306
+ # every value is an `Integer`. Used so a mixed `Constant +
307
+ # IntegerRange` call can be reduced to range × range
308
+ # arithmetic. Returns `nil` for non-Integer arrays so a
309
+ # `Constant[Float]` does not silently degrade.
310
+ def ensure_integer_range(operand)
311
+ case operand
312
+ when Type::IntegerRange then operand
313
+ when Array
314
+ return nil unless operand.all?(Integer)
315
+
316
+ Type::Combinator.integer_range(operand.min, operand.max)
317
+ end
318
+ end
319
+
320
+ def range_additive(left, method_name, right)
321
+ lower, upper =
322
+ case method_name
323
+ when :+ then [left.lower + right.lower, left.upper + right.upper]
324
+ when :- then [left.lower - right.upper, left.upper - right.lower]
325
+ end
326
+ build_integer_range(lower, upper)
327
+ end
328
+
329
+ # Range × Range. Computes the four corner products with
330
+ # `safe_mul` so that `0 × ±∞` is treated as 0 rather than
331
+ # NaN — that captures the algebraic truth that the actual
332
+ # range elements are integers, never literal infinity.
333
+ def range_multiply(left, right)
334
+ corners = [
335
+ safe_mul(left.lower, right.lower),
336
+ safe_mul(left.lower, right.upper),
337
+ safe_mul(left.upper, right.lower),
338
+ safe_mul(left.upper, right.upper)
339
+ ]
340
+ build_integer_range(corners.min, corners.max)
341
+ end
342
+
343
+ # 0 dominates: 0 × anything (including ±∞) is 0. Without this
344
+ # special case Ruby's `0 * Float::INFINITY` is `NaN`, which
345
+ # would corrupt `min`/`max`.
346
+ def safe_mul(left, right)
347
+ return 0 if left.zero? || right.zero?
348
+
349
+ left * right
350
+ end
351
+
352
+ # Range ÷ Range using Ruby's integer floor division. If the
353
+ # right range covers 0 the operation may raise
354
+ # `ZeroDivisionError`, so the fold bails (caller falls back
355
+ # to RBS-widened `Integer`). When both inputs are finite we
356
+ # compute the four corner quotients; the universal-on-one-side
357
+ # case is handled by treating ±∞ ÷ n as ±∞ and n ÷ ±∞ as 0.
358
+ def range_divide(left, right)
359
+ return nil if right.covers?(0)
360
+
361
+ corners = [
362
+ safe_div(left.lower, right.lower),
363
+ safe_div(left.lower, right.upper),
364
+ safe_div(left.upper, right.lower),
365
+ safe_div(left.upper, right.upper)
366
+ ]
367
+ build_integer_range(corners.min, corners.max)
368
+ end
369
+
370
+ def safe_div(numer, denom)
371
+ return 0 if numer.zero?
372
+ return numer.positive? ^ denom.negative? ? Float::INFINITY : -Float::INFINITY if denom.zero?
373
+ return 0 if denom.infinite?
374
+
375
+ if numer.infinite?
376
+ return numer.positive? ^ denom.negative? ? Float::INFINITY : -Float::INFINITY
377
+ end
378
+
379
+ numer.to_i.div(denom.to_i).to_f
380
+ end
381
+
382
+ # Range % Range. Only the `(any range) % (positive constant n)`
383
+ # and `(any range) % (negative constant n)` cases are folded
384
+ # precisely — the former narrows to `int<0, n-1>`, the latter
385
+ # to `int<n+1, 0>`. Other shapes fall back to nil.
386
+ def range_modulo(_left, right)
387
+ return nil unless right.finite? && right.min == right.max
388
+
389
+ divisor = right.min
390
+ return nil if divisor.zero?
391
+
392
+ if divisor.positive?
393
+ build_integer_range(0, divisor - 1)
394
+ else
395
+ build_integer_range(divisor + 1, 0)
396
+ end
397
+ end
398
+
399
+ # Builds an `IntegerRange` from numeric `lower`/`upper`
400
+ # endpoints. Collapses single-point finite ranges to a
401
+ # `Constant` so downstream rules (which prefer the more
402
+ # specific carrier) see the most precise result.
403
+ def build_integer_range(lower, upper)
404
+ min = lower == -Float::INFINITY ? Type::IntegerRange::NEG_INFINITY : Integer(lower)
405
+ max = upper == Float::INFINITY ? Type::IntegerRange::POS_INFINITY : Integer(upper)
406
+ if min.is_a?(Integer) && max.is_a?(Integer) && min == max
407
+ Type::Combinator.constant_of(min)
408
+ else
409
+ Type::Combinator.integer_range(min, max)
410
+ end
411
+ end
412
+
413
+ def range_comparison(left, method_name, right)
414
+ decision = decide_range_comparison(left, method_name, right)
415
+ case decision
416
+ when :always_true then Type::Combinator.constant_of(true)
417
+ when :always_false then Type::Combinator.constant_of(false)
418
+ when :both then bool_union
419
+ end
420
+ end
421
+
422
+ def bool_union
423
+ Type::Combinator.union(
424
+ Type::Combinator.constant_of(true),
425
+ Type::Combinator.constant_of(false)
426
+ )
427
+ end
428
+
429
+ BOOL_INVERSE = {
430
+ always_true: :always_false,
431
+ always_false: :always_true,
432
+ both: :both
433
+ }.freeze
434
+ private_constant :BOOL_INVERSE
435
+
436
+ def decide_range_comparison(left, method_name, right)
437
+ case method_name
438
+ when :< then decide_lt(left, right)
439
+ when :<= then decide_le(left, right)
440
+ when :> then decide_lt(right, left)
441
+ when :>= then decide_le(right, left)
442
+ when :== then decide_eq(left, right)
443
+ when :!= then BOOL_INVERSE[decide_eq(left, right)]
444
+ end
445
+ end
446
+
447
+ def decide_lt(left, right)
448
+ return :always_true if left.upper < right.lower
449
+ return :always_false if left.lower >= right.upper
450
+
451
+ :both
452
+ end
453
+
454
+ def decide_le(left, right)
455
+ return :always_true if left.upper <= right.lower
456
+ return :always_false if left.lower > right.upper
457
+
458
+ :both
459
+ end
460
+
461
+ def decide_eq(left, right)
462
+ return :always_true if left.finite? && right.finite? && left.min == left.max && left == right
463
+ return :always_false if left.upper < right.lower || right.upper < left.lower
464
+
465
+ :both
466
+ end
467
+
468
+ # ----------------------------------------------------------------
469
+ # IntegerRange unary folds.
470
+ # ----------------------------------------------------------------
471
+
472
+ RANGE_UNARY_PREDICATES = Set[:zero?, :positive?, :negative?].freeze
473
+ RANGE_UNARY_SHIFTS = Set[:succ, :next, :pred].freeze
474
+ RANGE_UNARY_PARITY = Set[:even?, :odd?].freeze
475
+
476
+ # `(method_name) -> handler symbol` for the unary range
477
+ # surface that does not need extra context. The grouped
478
+ # categories (predicates / shifts / parity) stay separate
479
+ # because they share dispatch logic.
480
+ UNARY_RANGE_DIRECT = {
481
+ abs: :range_unary_abs,
482
+ magnitude: :range_unary_abs,
483
+ "-@": :range_unary_negate,
484
+ "+@": :range_unary_identity,
485
+ bit_length: :range_unary_bit_length
486
+ }.freeze
487
+ private_constant :UNARY_RANGE_DIRECT
488
+
489
+ def try_fold_unary_range(range, method_name)
490
+ return range_unary_predicate(range, method_name) if RANGE_UNARY_PREDICATES.include?(method_name)
491
+ return range_unary_shift(range, method_name) if RANGE_UNARY_SHIFTS.include?(method_name)
492
+ return range_unary_parity(range, method_name) if RANGE_UNARY_PARITY.include?(method_name)
493
+ return send(UNARY_RANGE_DIRECT[method_name], range) if UNARY_RANGE_DIRECT.key?(method_name)
494
+
495
+ nil
496
+ end
497
+
498
+ def range_unary_negate(range)
499
+ build_integer_range(-range.upper, -range.lower)
500
+ end
501
+
502
+ def range_unary_identity(range)
503
+ range
504
+ end
505
+
506
+ # `even?`/`odd?` on a single-point range collapses to an
507
+ # exact `Constant[bool]`. Any range spanning ≥ 2 integers
508
+ # contains both an even and an odd value, so the result is
509
+ # `Union[true, false]`.
510
+ def range_unary_parity(range, method_name)
511
+ if range.finite? && range.min == range.max
512
+ value = range.min.public_send(method_name)
513
+ Type::Combinator.constant_of(value)
514
+ else
515
+ bool_union
516
+ end
517
+ end
518
+
519
+ # Integer#bit_length is non-negative and bounded by the
520
+ # bit_length of the wider endpoint. For half-open ranges the
521
+ # upper bound is unknown (any large integer is reachable), so
522
+ # we widen to non_negative_int. Negative endpoints map via
523
+ # `~n` semantics; using the magnitude is a safe upper bound.
524
+ def range_unary_bit_length(range)
525
+ return Type::Combinator.non_negative_int unless range.finite?
526
+
527
+ width = [range.min.bit_length, range.max.bit_length].max
528
+ build_integer_range(0, width)
529
+ end
530
+
531
+ def range_unary_predicate(range, method_name)
532
+ decision =
533
+ case method_name
534
+ when :zero? then decide_zero(range)
535
+ when :positive? then decide_positive(range)
536
+ when :negative? then decide_negative(range)
537
+ end
538
+ range_comparison_result(decision)
539
+ end
540
+
541
+ def range_comparison_result(decision)
542
+ case decision
543
+ when :always_true then Type::Combinator.constant_of(true)
544
+ when :always_false then Type::Combinator.constant_of(false)
545
+ when :both then bool_union
546
+ end
547
+ end
548
+
549
+ def decide_zero(range)
550
+ return :always_true if range.finite? && range.min.zero? && range.max.zero?
551
+ return :always_false unless range.covers?(0)
552
+
553
+ :both
554
+ end
555
+
556
+ def decide_positive(range)
557
+ return :always_true if range.lower.positive?
558
+ return :always_false if range.upper <= 0
559
+
560
+ :both
561
+ end
562
+
563
+ def decide_negative(range)
564
+ return :always_true if range.upper.negative?
565
+ return :always_false if range.lower >= 0
566
+
567
+ :both
568
+ end
569
+
570
+ def range_unary_shift(range, method_name)
571
+ delta = method_name == :pred ? -1 : 1
572
+ build_integer_range(range.lower + delta, range.upper + delta)
573
+ end
574
+
575
+ def range_unary_abs(range)
576
+ if range.lower >= 0
577
+ range
578
+ elsif range.upper <= 0
579
+ build_integer_range(-range.upper, -range.lower)
580
+ else
581
+ magnitude = [range.lower.abs, range.upper.abs].max
582
+ build_integer_range(0, magnitude)
583
+ end
584
+ end
585
+
586
+ # ----------------------------------------------------------------
587
+
588
+ # Returns `[value]` on success, `nil` to signal "skip this pair".
589
+ # The 1-element-array shape lets callers distinguish a successful
590
+ # `false`/`nil` fold from a skipped pair when chaining via
591
+ # `flat_map`.
592
+ def invoke_binary(receiver_value, method_name, arg_value)
593
+ return nil unless safe?(receiver_value, method_name, arg_value)
594
+
595
+ result = receiver_value.public_send(method_name, arg_value)
596
+ foldable_constant_value?(result) ? [result] : nil
597
+ rescue StandardError
598
+ nil
599
+ end
600
+
601
+ # Returns `[value]` on success, `nil` to signal "skip". See
602
+ # `invoke_binary` for why we wrap.
603
+ def invoke_unary(receiver_value, method_name)
604
+ return nil unless unary_safe?(receiver_value, method_name)
605
+ return nil if string_unary_blow_up?(receiver_value, method_name)
606
+
607
+ result = receiver_value.public_send(method_name)
608
+ foldable_constant_value?(result) ? [result] : nil
609
+ rescue StandardError
610
+ nil
611
+ end
612
+
613
+ def unary_safe?(receiver_value, method_name)
614
+ return true if unary_ops_for(receiver_value).include?(method_name)
615
+
616
+ catalog_allows?(receiver_value, method_name)
617
+ end
618
+
619
+ # Consults the offline numeric catalog (data/builtins/ruby_core/
620
+ # numeric.yml) as a superset of the hand-rolled unary/binary
621
+ # allow lists. The catalog's `leaf` / `trivial` /
622
+ # `leaf_when_numeric` entries promise the underlying CRuby
623
+ # implementation does not call back into user-redefinable
624
+ # Ruby methods, so executing them on a literal Integer/Float
625
+ # is safe regardless of monkey-patching.
626
+ def catalog_allows?(receiver_value, method_name)
627
+ catalog, class_name = catalog_for(receiver_value)
628
+ return false unless catalog
629
+
630
+ catalog.safe_for_folding?(class_name, method_name)
631
+ end
632
+
633
+ # `(catalog, class_name)` per receiver class. The class_name
634
+ # is what each catalog's RBS-rooted entries are keyed by.
635
+ # `catalog_for` walks this table in declaration order so
636
+ # subclasses (Symbol < String) hit their dedicated entry
637
+ # before any base-class fallback would, and adding a new
638
+ # class is a one-line addition rather than another `when`
639
+ # arm on a growing case statement.
640
+ CATALOG_BY_CLASS = [
641
+ [Integer, [Builtins::NumericCatalog, "Integer"]],
642
+ [Float, [Builtins::NumericCatalog, "Float"]],
643
+ [String, [Builtins::STRING_CATALOG, "String"]],
644
+ [Symbol, [Builtins::STRING_CATALOG, "Symbol"]],
645
+ [Array, [Builtins::ARRAY_CATALOG, "Array"]],
646
+ [Hash, [Builtins::HASH_CATALOG, "Hash"]],
647
+ [Range, [Builtins::RANGE_CATALOG, "Range"]],
648
+ [::Set, [Builtins::SET_CATALOG, "Set"]],
649
+ [Time, [Builtins::TIME_CATALOG, "Time"]]
650
+ ].freeze
651
+ private_constant :CATALOG_BY_CLASS
652
+
653
+ # Returns `[catalog, class_name]` for receivers we have a
654
+ # catalog for; nil otherwise.
655
+ def catalog_for(receiver_value)
656
+ CATALOG_BY_CLASS.each do |klass, entry|
657
+ return entry if receiver_value.is_a?(klass)
658
+ end
659
+ nil
660
+ end
661
+
662
+ def unary_ops_for(receiver_value)
663
+ case receiver_value
664
+ when Integer then INTEGER_UNARY
665
+ when Float then FLOAT_UNARY
666
+ when String then STRING_UNARY
667
+ when Symbol then SYMBOL_UNARY
668
+ when true, false then BOOL_UNARY
669
+ when nil then NIL_UNARY
670
+ else Set.new
671
+ end
672
+ end
673
+
674
+ # `String#reverse` / `#swapcase` etc. produce a
675
+ # string the same size as the receiver; only the
676
+ # already-handled binary `:+` / `:*` paths can
677
+ # explode the output. No unary string method
678
+ # currently in the catalogue grows beyond the input
679
+ # size, so this hook is a no-op today — kept as a
680
+ # placeholder so future additions (e.g. `:succ` on
681
+ # very long strings) can be guarded without
682
+ # restructuring.
683
+ def string_unary_blow_up?(_receiver_value, _method_name)
684
+ false
685
+ end
686
+
687
+ # Scalar / String / Symbol values fold; everything
688
+ # else (Array, Hash, Proc, Range, ...) is held back
689
+ # because `Type::Constant` does not model those
690
+ # carriers and surfacing one would mis-type
691
+ # downstream calls. `Range`, `Array`, and friends
692
+ # have their own shape carriers; this method picks
693
+ # the conservative envelope of "values that already
694
+ # round-trip through `Type::Combinator.constant_of`".
695
+ def foldable_constant_value?(value)
696
+ case value
697
+ when Integer, Float, String, Symbol, true, false, nil then true
698
+ else false
699
+ end
700
+ end
701
+
49
702
  def safe?(receiver_value, method_name, arg_value)
50
- ops = ops_for(receiver_value)
51
- return false unless ops.include?(method_name)
703
+ allowed = ops_for(receiver_value).include?(method_name) ||
704
+ catalog_allows?(receiver_value, method_name)
705
+ return false unless allowed
52
706
  return false if integer_division_by_zero?(receiver_value, method_name, arg_value)
53
707
  return false if string_blow_up?(receiver_value, method_name, arg_value)
54
708