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
@@ -47,7 +47,18 @@ module Rigor
47
47
  if other_type.is_a?(Type::Dynamic)
48
48
  return Type::AcceptsResult.yes(mode: mode, reasons: "gradual: Dynamic[T] passes any boundary")
49
49
  end
50
+
51
+ # Structural equality short-circuit. Two identical carriers
52
+ # describe the same value set, so they always accept each
53
+ # other. This is sound for any mode and covers cases where
54
+ # neither side has a per-class rule for the other's exact
55
+ # carrier kind (the canonical example is
56
+ # `Intersection.accepts(Intersection)`, where the disjunction
57
+ # rule below would otherwise reject equal-but-narrow LHSes).
58
+ return Type::AcceptsResult.yes(mode: mode, reasons: "structural equality") if self_type == other_type
59
+
50
60
  return accepts_union_other(self_type, other_type, mode) if other_type.is_a?(Type::Union)
61
+ return accepts_intersection_other(self_type, other_type, mode) if other_type.is_a?(Type::Intersection)
51
62
 
52
63
  accepts_one(self_type, other_type, mode)
53
64
  end
@@ -64,6 +75,10 @@ module Rigor
64
75
  Type::Singleton => :accepts_singleton,
65
76
  Type::Nominal => :accepts_nominal,
66
77
  Type::Constant => :accepts_constant,
78
+ Type::IntegerRange => :accepts_integer_range,
79
+ Type::Difference => :accepts_difference,
80
+ Type::Refined => :accepts_refined,
81
+ Type::Intersection => :accepts_intersection,
67
82
  Type::Tuple => :accepts_tuple,
68
83
  Type::HashShape => :accepts_hash_shape
69
84
  }.freeze
@@ -126,6 +141,27 @@ module Rigor
126
141
  end
127
142
  end
128
143
 
144
+ # self.accepts(Intersection[Y, Z]) iff self accepts at least
145
+ # one Y_i. Disjunction across members because the intersection
146
+ # is the meet of its members' value sets, so containment in
147
+ # any one member implies containment of the whole
148
+ # intersection. Symmetric counterpart to
149
+ # `accepts_union_other`.
150
+ def accepts_intersection_other(self_type, intersection, mode)
151
+ results = intersection.members.map { |m| accepts(self_type, m, mode: mode) }
152
+
153
+ if results.any?(&:yes?)
154
+ Type::AcceptsResult.yes(mode: mode, reasons: "self accepts an intersection member")
155
+ elsif results.any?(&:maybe?)
156
+ Type::AcceptsResult.maybe(
157
+ mode: mode,
158
+ reasons: "self could not be proven to accept any intersection member"
159
+ )
160
+ else
161
+ Type::AcceptsResult.no(mode: mode, reasons: "self rejects every intersection member")
162
+ end
163
+ end
164
+
129
165
  # self.accepts(Union[Y, Z]) iff self accepts every Y_i. Strict
130
166
  # AND across members: any "no" turns the whole result no, any
131
167
  # "maybe" without a "no" gives maybe, all "yes" gives yes.
@@ -184,18 +220,40 @@ module Rigor
184
220
  # - Singleton: never (wrong value kind).
185
221
  def accepts_nominal(self_type, other_type, mode)
186
222
  case other_type
187
- when Type::Nominal
188
- accepts_nominal_from_nominal(self_type, other_type, mode)
189
- when Type::Constant
190
- accepts_nominal_from_constant(self_type, other_type, mode)
191
- when Type::Singleton
192
- accepts_nominal_from_singleton(self_type, other_type, mode)
223
+ when Type::Nominal then accepts_nominal_from_nominal(self_type, other_type, mode)
224
+ when Type::Constant then accepts_nominal_from_constant(self_type, other_type, mode)
225
+ when Type::Singleton then accepts_nominal_from_singleton(self_type, other_type, mode)
226
+ when Type::IntegerRange then accepts_nominal_from_integer_range(self_type, other_type, mode)
227
+ else accepts_nominal_from_shape(self_type, other_type, mode)
228
+ end
229
+ end
230
+
231
+ # Tail of `accepts_nominal` that handles structural shape
232
+ # carriers (`Tuple` / `HashShape`) and refinement carriers
233
+ # (`Difference` / `Refined`). Each branch projects the
234
+ # other-side carrier to the nominal layer it sits above
235
+ # and re-runs acceptance — soundness follows because the
236
+ # carrier's value set is contained in the projected
237
+ # nominal's value set.
238
+ def accepts_nominal_from_shape(self_type, other_type, mode)
239
+ case other_type
193
240
  when Type::Tuple
194
241
  accepts(self_type, project_tuple_to_nominal(other_type), mode: mode)
195
242
  .with_reason("projected Tuple to Nominal[Array]")
196
243
  when Type::HashShape
197
244
  accepts(self_type, project_hash_shape_to_nominal(other_type), mode: mode)
198
245
  .with_reason("projected HashShape to Nominal[Hash]")
246
+ when Type::Difference, Type::Refined
247
+ # A refinement carrier's value set is a subset of its
248
+ # base. So if `self` (Nominal) accepts the base, it
249
+ # also accepts the refinement; if it rejects the
250
+ # base, it cannot accept any subset of it. Forward
251
+ # through to the base nominal so the standard subtype
252
+ # check applies. The recursion is bounded because
253
+ # every refinement carrier's `base` is closer to the
254
+ # nominal layer.
255
+ accepts(self_type, other_type.base, mode: mode)
256
+ .with_reason("projected #{other_type.class.name.split('::').last} to its base")
199
257
  else
200
258
  Type::AcceptsResult.no(
201
259
  mode: mode,
@@ -204,6 +262,33 @@ module Rigor
204
262
  end
205
263
  end
206
264
 
265
+ # `Nominal[Integer]` (and anything Integer is-a, like Numeric) accepts
266
+ # any `IntegerRange`; nothing else does. Argument-bearing `Nominal`s
267
+ # never accept `IntegerRange` because IntegerRange has no type args.
268
+ INTEGER_NOMINAL_ANCESTORS = %w[Integer Numeric Comparable Object BasicObject].freeze
269
+ private_constant :INTEGER_NOMINAL_ANCESTORS
270
+
271
+ def accepts_nominal_from_integer_range(self_type, _other_type, mode)
272
+ unless self_type.type_args.empty?
273
+ return Type::AcceptsResult.no(
274
+ mode: mode,
275
+ reasons: "Nominal[#{self_type.class_name}] with type args rejects IntegerRange"
276
+ )
277
+ end
278
+
279
+ if INTEGER_NOMINAL_ANCESTORS.include?(self_type.class_name)
280
+ Type::AcceptsResult.yes(
281
+ mode: mode,
282
+ reasons: "IntegerRange is-a #{self_type.class_name}"
283
+ )
284
+ else
285
+ Type::AcceptsResult.no(
286
+ mode: mode,
287
+ reasons: "Nominal[#{self_type.class_name}] rejects IntegerRange"
288
+ )
289
+ end
290
+ end
291
+
207
292
  # v0.0.2 — meta-type rule. A `Singleton[T]` is the
208
293
  # class object for `T`, so it is an instance of
209
294
  # `Class` (when `T` is a class) and always an instance
@@ -344,6 +429,239 @@ module Rigor
344
429
  end
345
430
  end
346
431
 
432
+ # IntegerRange[a..b] accepts:
433
+ # - Constant[n] where n is an Integer covered by [a..b];
434
+ # - IntegerRange[c..d] where [c..d] ⊆ [a..b];
435
+ # - Nominal[Integer] only when self is the universal range
436
+ # (`int<min, max>`), since otherwise an arbitrary Integer
437
+ # could fall outside the bound.
438
+ # Anything else is rejected.
439
+ def accepts_integer_range(self_type, other_type, mode)
440
+ case other_type
441
+ when Type::Constant
442
+ accepts_integer_range_from_constant(self_type, other_type, mode)
443
+ when Type::IntegerRange
444
+ accepts_integer_range_from_integer_range(self_type, other_type, mode)
445
+ when Type::Nominal
446
+ accepts_integer_range_from_nominal(self_type, other_type, mode)
447
+ else
448
+ Type::AcceptsResult.no(
449
+ mode: mode,
450
+ reasons: "IntegerRange rejects #{other_type.class}"
451
+ )
452
+ end
453
+ end
454
+
455
+ def accepts_integer_range_from_constant(self_type, constant, mode)
456
+ unless constant.value.is_a?(Integer)
457
+ return Type::AcceptsResult.no(
458
+ mode: mode,
459
+ reasons: "IntegerRange rejects non-Integer Constant"
460
+ )
461
+ end
462
+
463
+ if self_type.covers?(constant.value)
464
+ Type::AcceptsResult.yes(
465
+ mode: mode,
466
+ reasons: "Constant[#{constant.value}] is in #{self_type.describe}"
467
+ )
468
+ else
469
+ Type::AcceptsResult.no(
470
+ mode: mode,
471
+ reasons: "Constant[#{constant.value}] outside #{self_type.describe}"
472
+ )
473
+ end
474
+ end
475
+
476
+ def accepts_integer_range_from_integer_range(self_type, other_range, mode)
477
+ if self_type.lower <= other_range.lower && other_range.upper <= self_type.upper
478
+ Type::AcceptsResult.yes(
479
+ mode: mode,
480
+ reasons: "#{other_range.describe} ⊆ #{self_type.describe}"
481
+ )
482
+ else
483
+ Type::AcceptsResult.no(
484
+ mode: mode,
485
+ reasons: "#{other_range.describe} not contained in #{self_type.describe}"
486
+ )
487
+ end
488
+ end
489
+
490
+ def accepts_integer_range_from_nominal(self_type, nominal, mode)
491
+ unless nominal.class_name == "Integer"
492
+ return Type::AcceptsResult.no(
493
+ mode: mode,
494
+ reasons: "IntegerRange rejects Nominal[#{nominal.class_name}]"
495
+ )
496
+ end
497
+
498
+ if self_type.universal?
499
+ Type::AcceptsResult.yes(
500
+ mode: mode,
501
+ reasons: "universal IntegerRange accepts Nominal[Integer]"
502
+ )
503
+ else
504
+ Type::AcceptsResult.no(
505
+ mode: mode,
506
+ reasons: "non-universal IntegerRange rejects Nominal[Integer] (could fall outside #{self_type.describe})"
507
+ )
508
+ end
509
+ end
510
+
511
+ # `Difference[base, removed]` accepts another type X when
512
+ # the base accepts X *and* X's value set is provably
513
+ # disjoint from `removed`. The disjointness test is the
514
+ # subtle part — it is NOT the same as `removed.accepts(X)`,
515
+ # because `Nominal[String]` includes `""` even though
516
+ # `Constant[""]` does not "accept" `Nominal[String]`.
517
+ # The conservative rule here: we can prove disjointness
518
+ # only when X is itself a `Constant` carrier (compare
519
+ # values directly) or another `Difference` with the same
520
+ # removed value (already exhibits the disjointness). Any
521
+ # other shape — Nominal, Union, IntegerRange — could
522
+ # overlap the removed value, so the difference rejects
523
+ # it under gradual mode.
524
+ def accepts_difference(self_type, other_type, mode)
525
+ base_result = accepts(self_type.base, other_type, mode: mode)
526
+ return base_result if base_result.no?
527
+
528
+ unless provably_disjoint_from_removed?(other_type, self_type.removed)
529
+ return Type::AcceptsResult.no(
530
+ mode: mode,
531
+ reasons: "#{self_type.describe} cannot prove #{other_type.class} excludes the removed value"
532
+ )
533
+ end
534
+
535
+ base_result.with_reason("#{self_type.describe}: base accepts and removed is disjoint")
536
+ end
537
+
538
+ def provably_disjoint_from_removed?(other_type, removed)
539
+ case other_type
540
+ when Type::Constant
541
+ !(removed.is_a?(Type::Constant) && removed.value == other_type.value)
542
+ when Type::Difference
543
+ # `Difference[A, R].accepts(Difference[B, R])`: the
544
+ # other carrier already excludes `R` at its difference
545
+ # layer, so the disjointness is exhibited regardless of
546
+ # how `B` (its base) relates to `R`. We do NOT recurse
547
+ # into `other_type.base` because that would always fail
548
+ # (a Nominal base contains the removed value).
549
+ other_type.removed == removed
550
+ when Type::Intersection
551
+ # Disjointness is monotonic over Intersection: if any
552
+ # member is provably disjoint from `removed`, the meet
553
+ # is too.
554
+ other_type.members.any? { |m| provably_disjoint_from_removed?(m, removed) }
555
+ end
556
+ end
557
+
558
+ # `Refined[base, predicate]` accepts another type X when
559
+ # the base accepts the *base* of X *and* X is provably
560
+ # contained in the predicate's value set. The base
561
+ # check is delegated to `accepts(self.base, X.base)`
562
+ # so handlers like `accepts_nominal` see Nominal-vs-
563
+ # Nominal and return their normal answer (the inner
564
+ # `accepts_nominal` does not register `Refined` /
565
+ # `Difference` as direct other-shapes — projecting to
566
+ # the base is what makes the comparison meaningful).
567
+ #
568
+ # Provability rules in gradual mode (the conservative
569
+ # analogue of `accepts_difference`):
570
+ #
571
+ # - X is a `Refined` with the *same* predicate_id —
572
+ # exact predicate match, accept.
573
+ # - X is a `Constant` whose value the predicate's
574
+ # recogniser accepts — the value is statically
575
+ # contained, accept. A recognised non-match is `:no`.
576
+ # - Anything else (Nominal, Union, IntegerRange,
577
+ # Difference) — predicate-subset cannot be proven
578
+ # without a runtime test, so reject under gradual
579
+ # mode rather than degrade to `:maybe`. Mirrors the
580
+ # `accepts_difference` policy.
581
+ def accepts_refined(self_type, other_type, mode)
582
+ case other_type
583
+ when Type::Refined then accepts_refined_from_refined(self_type, other_type, mode)
584
+ when Type::Constant then accepts_refined_from_constant(self_type, other_type, mode)
585
+ else accepts_refined_other_shape(self_type, other_type, mode)
586
+ end
587
+ end
588
+
589
+ def accepts_refined_from_refined(self_type, other_type, mode)
590
+ base_result = accepts(self_type.base, other_type.base, mode: mode)
591
+ return base_result if base_result.no?
592
+
593
+ if other_type.predicate_id == self_type.predicate_id
594
+ base_result.with_reason("matching predicate :#{self_type.predicate_id}")
595
+ else
596
+ Type::AcceptsResult.no(
597
+ mode: mode,
598
+ reasons: "predicate mismatch: :#{self_type.predicate_id} vs :#{other_type.predicate_id}"
599
+ )
600
+ end
601
+ end
602
+
603
+ def accepts_refined_from_constant(self_type, constant, mode)
604
+ base_result = accepts(self_type.base, constant, mode: mode)
605
+ return base_result if base_result.no?
606
+
607
+ case self_type.matches?(constant.value)
608
+ when true
609
+ base_result.with_reason("Constant value satisfies :#{self_type.predicate_id}")
610
+ when false
611
+ Type::AcceptsResult.no(
612
+ mode: mode,
613
+ reasons: "Constant value fails :#{self_type.predicate_id}"
614
+ )
615
+ else
616
+ Type::AcceptsResult.maybe(
617
+ mode: mode,
618
+ reasons: "predicate :#{self_type.predicate_id} not in registry"
619
+ )
620
+ end
621
+ end
622
+
623
+ def accepts_refined_other_shape(self_type, other_type, mode)
624
+ base_result = accepts(self_type.base, other_type, mode: mode)
625
+ return base_result if base_result.no?
626
+
627
+ Type::AcceptsResult.no(
628
+ mode: mode,
629
+ reasons: "#{self_type.describe} cannot prove #{other_type.class} satisfies " \
630
+ ":#{self_type.predicate_id}"
631
+ )
632
+ end
633
+
634
+ # `Intersection[M1, M2, …]` accepts X iff *every* member
635
+ # accepts X — the meet of value sets is contained iff the
636
+ # candidate is contained in each. Conjunctive combine: any
637
+ # `:no` makes the result `:no`, any `:maybe` without a
638
+ # `:no` makes the result `:maybe`, all `:yes` makes the
639
+ # result `:yes`. The 0-member case is unreachable because
640
+ # `Combinator.intersection` collapses empty intersections
641
+ # to `Top`.
642
+ def accepts_intersection(self_type, other_type, mode)
643
+ per_member = self_type.members.map { |m| accepts(m, other_type, mode: mode) }
644
+
645
+ if per_member.any?(&:no?)
646
+ return Type::AcceptsResult.no(
647
+ mode: mode,
648
+ reasons: "an intersection member rejected #{other_type.class}"
649
+ )
650
+ end
651
+
652
+ if per_member.any?(&:maybe?)
653
+ Type::AcceptsResult.maybe(
654
+ mode: mode,
655
+ reasons: "an intersection member could not be proven accepted"
656
+ )
657
+ else
658
+ Type::AcceptsResult.yes(
659
+ mode: mode,
660
+ reasons: "every intersection member accepted #{other_type.class}"
661
+ )
662
+ end
663
+ end
664
+
347
665
  # Constant[v] accepts only Constant[v'] with structurally equal
348
666
  # value. Any other type is rejected (modulo the universal
349
667
  # Bot/Dynamic short-circuits already applied upstream).
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Array` catalog. Singleton — load once, consult during dispatch.
9
+ #
10
+ # Array has more mutation surface than String: every method that
11
+ # logically reshapes the array tends to call `rb_ary_modify` or
12
+ # an internal helper (`ary_replace`, `ary_resize`, `ary_pop`,
13
+ # `ary_push_internal`, …) that the classifier does not yet
14
+ # recognise. The blocklist captures the methods we have
15
+ # specifically observed flowing as `:leaf` despite mutating.
16
+ ARRAY_CATALOG = MethodCatalog.new(
17
+ path: File.expand_path(
18
+ "../../../../data/builtins/ruby_core/array.yml",
19
+ __dir__
20
+ ),
21
+ mutating_selectors: {
22
+ "Array" => Set[
23
+ # Mutators classified `:leaf` by the C-body heuristic
24
+ :<<, :push, :replace, :clear, :concat, :insert, :"[]=",
25
+ :unshift, :prepend, :pop, :shift, :delete_at, :slice!,
26
+ :compact!, :flatten!, :uniq!, :sort!, :reverse!,
27
+ :rotate!, :keep_if, :delete_if, :select!, :filter!,
28
+ :reject!, :collect!, :map!, :assoc, :rassoc,
29
+ :fill, :delete, :transpose,
30
+ # Methods that yield (block-dependent) — classifier
31
+ # may mark them leaf when the block call is gated:
32
+ :each, :each_with_index, :each_index, :each_slice,
33
+ :each_cons, :each_with_object,
34
+ # Identity/comparison methods that take a block too
35
+ :max, :min, :max_by, :min_by, :minmax, :minmax_by,
36
+ :sort_by, :group_by, :partition, :all?, :any?, :none?,
37
+ :one?, :find, :detect, :find_all, :find_index,
38
+ :reduce, :inject, :flat_map, :collect_concat,
39
+ :zip, :product, :combination, :permutation,
40
+ :chunk_while, :slice_when, :tally
41
+ ]
42
+ }
43
+ )
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Hash` catalog. Singleton — load once, consult during dispatch.
9
+ #
10
+ # Hash mirrors Array's mutation pattern: nearly every iteration
11
+ # method yields through `rb_hash_foreach` plus a per-pair static
12
+ # callback (`each_value_i`, `keep_if_i`, …), and the C-body
13
+ # classifier does not follow into the callback so it lands as
14
+ # `:leaf` despite being block-dependent. The blocklist below
15
+ # captures every false-positive `:leaf` we have spotted in the
16
+ # generated YAML — bias toward conservatism so a missed fold is
17
+ # acceptable but a folded mutator/yielder is not.
18
+ HASH_CATALOG = MethodCatalog.new(
19
+ path: File.expand_path(
20
+ "../../../../data/builtins/ruby_core/hash.yml",
21
+ __dir__
22
+ ),
23
+ mutating_selectors: {
24
+ "Hash" => Set[
25
+ # Block-dependent iteration — yields via `rb_hash_foreach`
26
+ # plus a per-pair callback that the regex classifier does
27
+ # not follow:
28
+ :each, :each_pair, :each_key, :each_value,
29
+ :select, :filter, :reject,
30
+ :transform_values,
31
+ # Block-dependent merge — `rb_hash_merge` delegates into
32
+ # `rb_hash_update`, which yields per conflict when a block
33
+ # is given:
34
+ :merge
35
+ ]
36
+ }
37
+ )
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # Generic loader for offline-generated catalogs under
9
+ # `data/builtins/ruby_core/<topic>.yml`. One instance per topic
10
+ # (numeric, string, array, …); each owns the path to its own
11
+ # YAML and the per-class blocklist of selectors the static
12
+ # classifier marked `:leaf` but that actually mutate the
13
+ # receiver (false positives the C-body heuristic does not
14
+ # catch).
15
+ #
16
+ # `safe_for_folding?(class_name, selector, kind:)` returns true
17
+ # when:
18
+ # 1. The catalog has an entry for `(class_name, selector, kind)`,
19
+ # 2. The entry's `purity` is one of `leaf` / `trivial` /
20
+ # `leaf_when_numeric`,
21
+ # 3. The selector is NOT in the per-class mutation blocklist.
22
+ #
23
+ # Missing catalog files (e.g. in a bare gem install where data
24
+ # was opted out) degrade to `false` so the dispatcher falls
25
+ # back to its hand-rolled allow lists.
26
+ class MethodCatalog
27
+ FOLDABLE_PURITIES = Set["leaf", "trivial", "leaf_when_numeric"].freeze
28
+ EMPTY_CATALOG = { "classes" => {} }.freeze
29
+
30
+ def initialize(path:, mutating_selectors: {})
31
+ @path = path
32
+ @mutating_selectors = mutating_selectors.transform_values(&:freeze).freeze
33
+ @catalog = nil
34
+ end
35
+
36
+ def safe_for_folding?(class_name, selector, kind: :instance)
37
+ class_name_str = class_name.to_s
38
+ return false if blocked?(class_name_str, selector)
39
+
40
+ entry = method_entry(class_name_str, selector, kind: kind)
41
+ return false unless entry
42
+
43
+ FOLDABLE_PURITIES.include?(entry["purity"])
44
+ end
45
+
46
+ def method_entry(class_name, selector, kind: :instance)
47
+ klass = catalog.dig("classes", class_name.to_s)
48
+ return nil unless klass
49
+
50
+ bucket_key = kind == :singleton ? "singleton_methods" : "instance_methods"
51
+ klass.dig(bucket_key, selector.to_s)
52
+ end
53
+
54
+ def reset!
55
+ @catalog = nil
56
+ end
57
+
58
+ private
59
+
60
+ def blocked?(class_name, selector)
61
+ # Bang-suffixed selectors are mutating by Ruby convention
62
+ # (`upcase!`, `concat`, etc. are listed explicitly below;
63
+ # this catches the rest). We bias toward false negatives:
64
+ # losing a fold opportunity is acceptable; folding a
65
+ # mutator is not.
66
+ selector_str = selector.to_s
67
+ return true if selector_str.end_with?("!")
68
+
69
+ per_class = @mutating_selectors[class_name]
70
+ return false if per_class.nil?
71
+
72
+ per_class.include?(selector.to_sym) || per_class.include?(selector_str.to_sym)
73
+ end
74
+
75
+ def catalog
76
+ @catalog ||= load_catalog
77
+ end
78
+
79
+ def load_catalog
80
+ return EMPTY_CATALOG unless File.exist?(@path)
81
+
82
+ data = YAML.safe_load_file(@path, permitted_classes: [Symbol])
83
+ data.is_a?(Hash) ? data : EMPTY_CATALOG
84
+ rescue Psych::SyntaxError
85
+ EMPTY_CATALOG
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # Read-only loader for the Numeric/Integer/Float built-in method
9
+ # catalog at `data/builtins/ruby_core/numeric.yml`. The catalog is
10
+ # produced offline by `tool/extract_numeric_catalog.rb` from the
11
+ # CRuby reference checkout under `references/ruby` plus the RBS
12
+ # core signatures under `references/rbs`.
13
+ #
14
+ # The loader is the runtime bridge: callers ask "is `Integer#+`
15
+ # safe to invoke during constant folding?" and the answer comes
16
+ # straight from the offline classification (`leaf`, `trivial`,
17
+ # `leaf_when_numeric` are foldable; everything else is not).
18
+ #
19
+ # The catalog is loaded lazily on first access and memoised for
20
+ # the lifetime of the process. If the file is missing (e.g. in a
21
+ # bare gem install where the consumer opted out of shipping data
22
+ # files, or in a development checkout that has not yet generated
23
+ # the catalog) the loader degrades to an empty catalog so calls
24
+ # uniformly return `false` and the rest of the dispatcher
25
+ # continues with its hand-rolled allow lists.
26
+ module NumericCatalog
27
+ # Purity tags from the catalog that are safe for the analyzer
28
+ # to invoke against concrete literal receivers/arguments.
29
+ # `leaf_when_numeric` is included because `ConstantFolding`
30
+ # only lets it through when every argument is itself a
31
+ # `Constant<Numeric>` or `IntegerRange` — exactly the gate
32
+ # the catalog tag is named for.
33
+ FOLDABLE_PURITIES = Set["leaf", "trivial", "leaf_when_numeric"].freeze
34
+
35
+ EMPTY_CATALOG = { "classes" => {} }.freeze
36
+ private_constant :EMPTY_CATALOG
37
+
38
+ # Path resolved relative to this file. The catalog ships under
39
+ # `data/builtins/ruby_core/numeric.yml` at the gem root.
40
+ CATALOG_PATH = File.expand_path(
41
+ "../../../../data/builtins/ruby_core/numeric.yml",
42
+ __dir__
43
+ )
44
+ private_constant :CATALOG_PATH
45
+
46
+ class << self
47
+ # @param class_name [String] e.g. "Integer", "Float"
48
+ # @param selector [Symbol, String]
49
+ # @param kind [Symbol] :instance (default) or :singleton
50
+ # @return [Boolean]
51
+ def safe_for_folding?(class_name, selector, kind: :instance)
52
+ entry = method_entry(class_name, selector, kind: kind)
53
+ return false unless entry
54
+
55
+ FOLDABLE_PURITIES.include?(entry["purity"])
56
+ end
57
+
58
+ # @return [Hash, nil] catalog entry for the given method, or
59
+ # nil when the method is not registered.
60
+ def method_entry(class_name, selector, kind: :instance)
61
+ klass = catalog.dig("classes", class_name.to_s)
62
+ return nil unless klass
63
+
64
+ bucket_key = kind == :singleton ? "singleton_methods" : "instance_methods"
65
+ klass.dig(bucket_key, selector.to_s)
66
+ end
67
+
68
+ # Used by tests to drop the cached catalog so a different
69
+ # path or content can be exercised. Production code MUST
70
+ # NOT call this during normal operation.
71
+ def reset!
72
+ @catalog = nil
73
+ end
74
+
75
+ private
76
+
77
+ def catalog
78
+ @catalog ||= load_catalog
79
+ end
80
+
81
+ def load_catalog
82
+ return EMPTY_CATALOG unless File.exist?(CATALOG_PATH)
83
+
84
+ data = YAML.safe_load_file(CATALOG_PATH, permitted_classes: [Symbol])
85
+ data.is_a?(Hash) ? data : EMPTY_CATALOG
86
+ rescue Psych::SyntaxError
87
+ EMPTY_CATALOG
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end