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
@@ -53,6 +53,7 @@ module Rigor
53
53
  RULE_NIL_RECEIVER = "possible-nil-receiver"
54
54
  RULE_DUMP_TYPE = "dump-type"
55
55
  RULE_ASSERT_TYPE = "assert-type"
56
+ RULE_ALWAYS_RAISES = "always-raises"
56
57
 
57
58
  ALL_RULES = [
58
59
  RULE_UNDEFINED_METHOD,
@@ -60,7 +61,8 @@ module Rigor
60
61
  RULE_ARGUMENT_TYPE,
61
62
  RULE_NIL_RECEIVER,
62
63
  RULE_DUMP_TYPE,
63
- RULE_ASSERT_TYPE
64
+ RULE_ASSERT_TYPE,
65
+ RULE_ALWAYS_RAISES
64
66
  ].freeze
65
67
 
66
68
  module_function
@@ -97,6 +99,9 @@ module Rigor
97
99
 
98
100
  assert_diagnostic = assert_type_diagnostic(path, node, scope_index)
99
101
  diagnostics << assert_diagnostic if assert_diagnostic
102
+
103
+ raises_diagnostic = always_raises_diagnostic(path, node, scope_index)
104
+ diagnostics << raises_diagnostic if raises_diagnostic
100
105
  end
101
106
  filter_suppressed(diagnostics, comments: comments, disabled_rules: disabled_rules)
102
107
  end
@@ -333,6 +338,13 @@ module Rigor
333
338
  end
334
339
 
335
340
  def arity_eligible?(function)
341
+ # `RBS::Types::UntypedFunction` (used for `(?) ->`
342
+ # untyped sigs) does not expose the per-arity
343
+ # accessors. Treating it as ineligible is the
344
+ # correct conservative move: an untyped function
345
+ # has no static arity to enforce.
346
+ return false unless function.respond_to?(:required_keywords)
347
+
336
348
  function.required_keywords.empty? && function.trailing_positionals.empty?
337
349
  end
338
350
 
@@ -562,6 +574,74 @@ module Rigor
562
574
  )
563
575
  end
564
576
 
577
+ # Diagnoses calls that the analyzer can prove will always
578
+ # raise. Today the only triggering shape is integer
579
+ # division/modulo by a literal zero divisor:
580
+ #
581
+ # 5 / 0 # => ZeroDivisionError
582
+ # x.modulo(0) # => ZeroDivisionError when x: Integer
583
+ # xs.size % 0 # same — non_negative_int / Constant[0]
584
+ #
585
+ # Float divmod by zero returns Infinity/NaN at runtime, so
586
+ # the rule restricts to Integer-rooted receivers (`Constant`,
587
+ # `IntegerRange`, `Nominal[Integer]`). The argument MUST be a
588
+ # `Constant<Integer>` whose value is exactly zero — a
589
+ # `Union[Constant[0], Constant[2]]` divisor "may" raise,
590
+ # which we surface separately (future slice).
591
+ INTEGER_RAISING_OPERATORS = %i[/ % div modulo divmod].freeze
592
+ private_constant :INTEGER_RAISING_OPERATORS
593
+
594
+ def always_raises_diagnostic(path, call_node, scope_index)
595
+ return nil unless integer_zero_division?(call_node, scope_index)
596
+
597
+ build_always_raises_diagnostic(path, call_node)
598
+ end
599
+
600
+ def integer_zero_division?(call_node, scope_index)
601
+ return false unless raising_call_shape?(call_node)
602
+
603
+ scope = scope_index[call_node]
604
+ return false if scope.nil?
605
+ return false unless integer_rooted_for_diagnostic?(scope.type_of(call_node.receiver))
606
+
607
+ arg = single_argument(call_node)
608
+ arg && integer_zero_constant?(scope.type_of(arg))
609
+ end
610
+
611
+ def raising_call_shape?(call_node)
612
+ !call_node.receiver.nil? && INTEGER_RAISING_OPERATORS.include?(call_node.name)
613
+ end
614
+
615
+ def single_argument(call_node)
616
+ args = call_node.arguments&.arguments || []
617
+ args.size == 1 ? args.first : nil
618
+ end
619
+
620
+ def integer_rooted_for_diagnostic?(type)
621
+ case type
622
+ when Type::Constant then type.value.is_a?(Integer)
623
+ when Type::IntegerRange then true
624
+ when Type::Nominal then type.class_name == "Integer" && type.type_args.empty?
625
+ else false
626
+ end
627
+ end
628
+
629
+ def integer_zero_constant?(type)
630
+ type.is_a?(Type::Constant) && type.value.is_a?(Integer) && type.value.zero?
631
+ end
632
+
633
+ def build_always_raises_diagnostic(path, call_node)
634
+ location = call_node.message_loc || call_node.location
635
+ Diagnostic.new(
636
+ rule: RULE_ALWAYS_RAISES,
637
+ path: path,
638
+ line: location.start_line,
639
+ column: location.start_column + 1,
640
+ message: "always raises ZeroDivisionError: `#{call_node.name}' by zero on Integer receiver",
641
+ severity: :error
642
+ )
643
+ end
644
+
565
645
  # v0.0.2 #4 — argument-type-mismatch diagnostic.
566
646
  # Walks a call's positional arguments and checks each
567
647
  # against the matching parameter's RBS type via
@@ -643,6 +723,11 @@ module Rigor
643
723
  end
644
724
 
645
725
  def argument_check_eligible?(function)
726
+ # See `arity_eligible?`: `UntypedFunction` lacks
727
+ # the per-arity accessors. Treat it as ineligible
728
+ # for argument-type-mismatch diagnostics.
729
+ return false unless function.respond_to?(:required_keywords)
730
+
646
731
  function.rest_positionals.nil? &&
647
732
  function.required_keywords.empty? &&
648
733
  function.optional_keywords.empty? &&
@@ -6,6 +6,7 @@ require_relative "../environment"
6
6
  require_relative "../scope"
7
7
  require_relative "../inference/coverage_scanner"
8
8
  require_relative "../inference/scope_indexer"
9
+ require_relative "../inference/method_dispatcher/file_folding"
9
10
  require_relative "check_rules"
10
11
  require_relative "diagnostic"
11
12
  require_relative "result"
@@ -29,6 +30,9 @@ module Rigor
29
30
  # is built once at run start through `Environment.for_project`
30
31
  # so all files share the same RBS load.
31
32
  def run(paths = @configuration.paths)
33
+ Inference::MethodDispatcher::FileFolding.fold_platform_specific_paths =
34
+ @configuration.fold_platform_specific_paths
35
+
32
36
  environment = Environment.for_project(
33
37
  libraries: @configuration.libraries,
34
38
  signature_paths: @configuration.signature_paths
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../type"
4
+
5
+ module Rigor
6
+ module Builtins
7
+ # Canonical-name registry for the imported-built-in
8
+ # refinement catalogue. See `imported-built-in-types.md`
9
+ # in `docs/type-specification/` for the full catalogue
10
+ # rationale and the kebab-case naming rule.
11
+ #
12
+ # Maps kebab-case names (`non-empty-string`, `positive-int`,
13
+ # `non-empty-array`, …) to the Rigor type each name denotes.
14
+ # The registry is the single integration point for:
15
+ #
16
+ # - The new `rigor:v1:return:` RBS::Extended directive
17
+ # ([`Rigor::RbsExtended.read_return_type_override`](../rbs_extended.rb)),
18
+ # which overrides a method's RBS-declared return type
19
+ # with a refinement carrier.
20
+ # - Future `RBS::Extended` directives that accept a
21
+ # refinement name in any type position (`param:`,
22
+ # `assert: x is non-empty-string`, …).
23
+ # - The display side: `Type::Difference#describe` already
24
+ # recognises the same shapes and prints the kebab-case
25
+ # spelling without consulting the registry.
26
+ #
27
+ # Names not in the registry resolve to `nil`; callers
28
+ # decide whether to fall back to the RBS-declared type or
29
+ # raise a parse error.
30
+ #
31
+ # The current registry covers no-argument refinement
32
+ # names. Parameterised refinements like
33
+ # `non-empty-array[Integer]` will be parsed by a future
34
+ # tokeniser; today the no-arg form `non-empty-array` lands
35
+ # at `non_empty_array(top)` and downstream code projects
36
+ # to the underlying base nominal.
37
+ module ImportedRefinements
38
+ REGISTRY = {
39
+ "non-empty-string" => -> { Type::Combinator.non_empty_string },
40
+ "non-zero-int" => -> { Type::Combinator.non_zero_int },
41
+ "non-empty-array" => -> { Type::Combinator.non_empty_array },
42
+ "non-empty-hash" => -> { Type::Combinator.non_empty_hash },
43
+ "positive-int" => -> { Type::Combinator.positive_int },
44
+ "non-negative-int" => -> { Type::Combinator.non_negative_int },
45
+ "negative-int" => -> { Type::Combinator.negative_int },
46
+ "non-positive-int" => -> { Type::Combinator.non_positive_int }
47
+ }.freeze
48
+ private_constant :REGISTRY
49
+
50
+ module_function
51
+
52
+ # @param name [String] kebab-case refinement name.
53
+ # @return [Rigor::Type, nil] the matching refinement
54
+ # carrier, or `nil` if the name is not registered.
55
+ def lookup(name)
56
+ builder = REGISTRY[name.to_s]
57
+ builder&.call
58
+ end
59
+
60
+ def known?(name)
61
+ REGISTRY.key?(name.to_s)
62
+ end
63
+
64
+ def known_names
65
+ REGISTRY.keys
66
+ end
67
+ end
68
+ end
69
+ end
@@ -12,13 +12,14 @@ module Rigor
12
12
  "disable" => [],
13
13
  "libraries" => [],
14
14
  "signature_paths" => nil,
15
+ "fold_platform_specific_paths" => false,
15
16
  "cache" => {
16
17
  "path" => ".rigor/cache"
17
18
  }
18
19
  }.freeze
19
20
 
20
21
  attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
21
- :libraries, :signature_paths
22
+ :libraries, :signature_paths, :fold_platform_specific_paths
22
23
 
23
24
  def self.load(path = DEFAULT_PATH)
24
25
  data = if File.exist?(path)
@@ -40,6 +41,9 @@ module Rigor
40
41
  @libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
41
42
  sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
42
43
  @signature_paths = sig_paths.nil? ? nil : Array(sig_paths).map(&:to_s).freeze
44
+ @fold_platform_specific_paths = data.fetch(
45
+ "fold_platform_specific_paths", DEFAULTS.fetch("fold_platform_specific_paths")
46
+ ) == true
43
47
  @cache_path = cache.fetch("path").to_s
44
48
  end
45
49
 
@@ -51,6 +55,7 @@ module Rigor
51
55
  "disable" => disabled_rules,
52
56
  "libraries" => libraries,
53
57
  "signature_paths" => signature_paths,
58
+ "fold_platform_specific_paths" => fold_platform_specific_paths,
54
59
  "cache" => {
55
60
  "path" => cache_path
56
61
  }
@@ -64,6 +64,8 @@ module Rigor
64
64
  Type::Singleton => :accepts_singleton,
65
65
  Type::Nominal => :accepts_nominal,
66
66
  Type::Constant => :accepts_constant,
67
+ Type::IntegerRange => :accepts_integer_range,
68
+ Type::Difference => :accepts_difference,
67
69
  Type::Tuple => :accepts_tuple,
68
70
  Type::HashShape => :accepts_hash_shape
69
71
  }.freeze
@@ -190,6 +192,8 @@ module Rigor
190
192
  accepts_nominal_from_constant(self_type, other_type, mode)
191
193
  when Type::Singleton
192
194
  accepts_nominal_from_singleton(self_type, other_type, mode)
195
+ when Type::IntegerRange
196
+ accepts_nominal_from_integer_range(self_type, other_type, mode)
193
197
  when Type::Tuple
194
198
  accepts(self_type, project_tuple_to_nominal(other_type), mode: mode)
195
199
  .with_reason("projected Tuple to Nominal[Array]")
@@ -204,6 +208,33 @@ module Rigor
204
208
  end
205
209
  end
206
210
 
211
+ # `Nominal[Integer]` (and anything Integer is-a, like Numeric) accepts
212
+ # any `IntegerRange`; nothing else does. Argument-bearing `Nominal`s
213
+ # never accept `IntegerRange` because IntegerRange has no type args.
214
+ INTEGER_NOMINAL_ANCESTORS = %w[Integer Numeric Comparable Object BasicObject].freeze
215
+ private_constant :INTEGER_NOMINAL_ANCESTORS
216
+
217
+ def accepts_nominal_from_integer_range(self_type, _other_type, mode)
218
+ unless self_type.type_args.empty?
219
+ return Type::AcceptsResult.no(
220
+ mode: mode,
221
+ reasons: "Nominal[#{self_type.class_name}] with type args rejects IntegerRange"
222
+ )
223
+ end
224
+
225
+ if INTEGER_NOMINAL_ANCESTORS.include?(self_type.class_name)
226
+ Type::AcceptsResult.yes(
227
+ mode: mode,
228
+ reasons: "IntegerRange is-a #{self_type.class_name}"
229
+ )
230
+ else
231
+ Type::AcceptsResult.no(
232
+ mode: mode,
233
+ reasons: "Nominal[#{self_type.class_name}] rejects IntegerRange"
234
+ )
235
+ end
236
+ end
237
+
207
238
  # v0.0.2 — meta-type rule. A `Singleton[T]` is the
208
239
  # class object for `T`, so it is an instance of
209
240
  # `Class` (when `T` is a class) and always an instance
@@ -344,6 +375,124 @@ module Rigor
344
375
  end
345
376
  end
346
377
 
378
+ # IntegerRange[a..b] accepts:
379
+ # - Constant[n] where n is an Integer covered by [a..b];
380
+ # - IntegerRange[c..d] where [c..d] ⊆ [a..b];
381
+ # - Nominal[Integer] only when self is the universal range
382
+ # (`int<min, max>`), since otherwise an arbitrary Integer
383
+ # could fall outside the bound.
384
+ # Anything else is rejected.
385
+ def accepts_integer_range(self_type, other_type, mode)
386
+ case other_type
387
+ when Type::Constant
388
+ accepts_integer_range_from_constant(self_type, other_type, mode)
389
+ when Type::IntegerRange
390
+ accepts_integer_range_from_integer_range(self_type, other_type, mode)
391
+ when Type::Nominal
392
+ accepts_integer_range_from_nominal(self_type, other_type, mode)
393
+ else
394
+ Type::AcceptsResult.no(
395
+ mode: mode,
396
+ reasons: "IntegerRange rejects #{other_type.class}"
397
+ )
398
+ end
399
+ end
400
+
401
+ def accepts_integer_range_from_constant(self_type, constant, mode)
402
+ unless constant.value.is_a?(Integer)
403
+ return Type::AcceptsResult.no(
404
+ mode: mode,
405
+ reasons: "IntegerRange rejects non-Integer Constant"
406
+ )
407
+ end
408
+
409
+ if self_type.covers?(constant.value)
410
+ Type::AcceptsResult.yes(
411
+ mode: mode,
412
+ reasons: "Constant[#{constant.value}] is in #{self_type.describe}"
413
+ )
414
+ else
415
+ Type::AcceptsResult.no(
416
+ mode: mode,
417
+ reasons: "Constant[#{constant.value}] outside #{self_type.describe}"
418
+ )
419
+ end
420
+ end
421
+
422
+ def accepts_integer_range_from_integer_range(self_type, other_range, mode)
423
+ if self_type.lower <= other_range.lower && other_range.upper <= self_type.upper
424
+ Type::AcceptsResult.yes(
425
+ mode: mode,
426
+ reasons: "#{other_range.describe} ⊆ #{self_type.describe}"
427
+ )
428
+ else
429
+ Type::AcceptsResult.no(
430
+ mode: mode,
431
+ reasons: "#{other_range.describe} not contained in #{self_type.describe}"
432
+ )
433
+ end
434
+ end
435
+
436
+ def accepts_integer_range_from_nominal(self_type, nominal, mode)
437
+ unless nominal.class_name == "Integer"
438
+ return Type::AcceptsResult.no(
439
+ mode: mode,
440
+ reasons: "IntegerRange rejects Nominal[#{nominal.class_name}]"
441
+ )
442
+ end
443
+
444
+ if self_type.universal?
445
+ Type::AcceptsResult.yes(
446
+ mode: mode,
447
+ reasons: "universal IntegerRange accepts Nominal[Integer]"
448
+ )
449
+ else
450
+ Type::AcceptsResult.no(
451
+ mode: mode,
452
+ reasons: "non-universal IntegerRange rejects Nominal[Integer] (could fall outside #{self_type.describe})"
453
+ )
454
+ end
455
+ end
456
+
457
+ # `Difference[base, removed]` accepts another type X when
458
+ # the base accepts X *and* X's value set is provably
459
+ # disjoint from `removed`. The disjointness test is the
460
+ # subtle part — it is NOT the same as `removed.accepts(X)`,
461
+ # because `Nominal[String]` includes `""` even though
462
+ # `Constant[""]` does not "accept" `Nominal[String]`.
463
+ # The conservative rule here: we can prove disjointness
464
+ # only when X is itself a `Constant` carrier (compare
465
+ # values directly) or another `Difference` with the same
466
+ # removed value (already exhibits the disjointness). Any
467
+ # other shape — Nominal, Union, IntegerRange — could
468
+ # overlap the removed value, so the difference rejects
469
+ # it under gradual mode.
470
+ def accepts_difference(self_type, other_type, mode)
471
+ base_result = accepts(self_type.base, other_type, mode: mode)
472
+ return base_result if base_result.no?
473
+
474
+ unless provably_disjoint_from_removed?(other_type, self_type.removed)
475
+ return Type::AcceptsResult.no(
476
+ mode: mode,
477
+ reasons: "#{self_type.describe} cannot prove #{other_type.class} excludes the removed value"
478
+ )
479
+ end
480
+
481
+ base_result.with_reason("#{self_type.describe}: base accepts and removed is disjoint")
482
+ end
483
+
484
+ def provably_disjoint_from_removed?(other_type, removed)
485
+ case other_type
486
+ when Type::Constant
487
+ !(removed.is_a?(Type::Constant) && removed.value == other_type.value)
488
+ when Type::Difference
489
+ # `Difference[A, removed_R].accepts(Difference[B, R])` —
490
+ # the inner difference exhibits the same disjointness;
491
+ # forward to the base.
492
+ other_type.removed == removed && provably_disjoint_from_removed?(other_type.base, removed)
493
+ end
494
+ end
495
+
347
496
  # Constant[v] accepts only Constant[v'] with structurally equal
348
497
  # value. Any other type is rejected (modulo the universal
349
498
  # 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,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