rigortype 0.0.3 → 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.
@@ -693,14 +693,16 @@ module Rigor
693
693
  return nil if method_def.nil? || method_def == true
694
694
  return nil unless method_def.method_types.size == 1
695
695
 
696
- mismatch = first_argument_mismatch(method_def.method_types.first, call_node, scope)
696
+ param_overrides = Rigor::RbsExtended.param_type_override_map(method_def)
697
+ mismatch = first_argument_mismatch(method_def.method_types.first, call_node, scope, param_overrides)
697
698
  return nil if mismatch.nil?
698
699
 
699
700
  build_argument_type_diagnostic(path, call_node, class_name, mismatch)
700
701
  end
701
702
  # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
702
703
 
703
- def first_argument_mismatch(method_type, call_node, scope) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
704
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
705
+ def first_argument_mismatch(method_type, call_node, scope, param_overrides)
704
706
  function = method_type.type
705
707
  return nil unless argument_check_eligible?(function)
706
708
 
@@ -710,7 +712,12 @@ module Rigor
710
712
  param = params[index]
711
713
  next if param.nil? # arity mismatch is the wrong-arity rule's concern.
712
714
 
713
- param_type = translate_param_type(param.type, scope.environment)
715
+ # `rigor:v1:param: <name> <refinement>` annotations
716
+ # tighten the RBS-declared parameter type. The
717
+ # override is the authoritative contract when
718
+ # present; otherwise we translate the RBS type as
719
+ # before.
720
+ param_type = param_overrides[param.name] || translate_param_type(param.type, scope.environment)
714
721
  next if param_type.is_a?(Type::Dynamic) || param_type.is_a?(Type::Top)
715
722
 
716
723
  arg_type = scope.type_of(arg)
@@ -721,6 +728,7 @@ module Rigor
721
728
  end
722
729
  nil
723
730
  end
731
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity, Metrics/AbcSize
724
732
 
725
733
  def argument_check_eligible?(function)
726
734
  # See `arity_eligible?`: `UntypedFunction` lacks
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "strscan"
4
+
3
5
  require_relative "../type"
4
6
 
5
7
  module Rigor
@@ -13,7 +15,7 @@ module Rigor
13
15
  # `non-empty-array`, …) to the Rigor type each name denotes.
14
16
  # The registry is the single integration point for:
15
17
  #
16
- # - The new `rigor:v1:return:` RBS::Extended directive
18
+ # - The `rigor:v1:return:` RBS::Extended directive
17
19
  # ([`Rigor::RbsExtended.read_return_type_override`](../rbs_extended.rb)),
18
20
  # which overrides a method's RBS-declared return type
19
21
  # with a refinement carrier.
@@ -28,12 +30,19 @@ module Rigor
28
30
  # decide whether to fall back to the RBS-declared type or
29
31
  # raise a parse error.
30
32
  #
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.
33
+ # The registry covers two surfaces:
34
+ #
35
+ # - **No-argument refinement names** (`non-empty-string`,
36
+ # `non-zero-int`, `lowercase-string`, …) live in `REGISTRY`
37
+ # and resolve through `lookup(name)`.
38
+ # - **Parameterised refinement payloads** (`non-empty-array[Integer]`,
39
+ # `non-empty-hash[Symbol, Integer]`, `int<5, 10>`) are
40
+ # accepted by `parse(payload)`. The full grammar is documented
41
+ # on `Parser`. The two surfaces share `REGISTRY` for the
42
+ # no-arg head names; the parameterised head names live in
43
+ # `PARAMETERISED_TYPE_BUILDERS` (square-bracket form, type
44
+ # args) and `PARAMETERISED_INT_BUILDERS` (angle-bracket form,
45
+ # integer bounds).
37
46
  module ImportedRefinements
38
47
  REGISTRY = {
39
48
  "non-empty-string" => -> { Type::Combinator.non_empty_string },
@@ -43,10 +52,51 @@ module Rigor
43
52
  "positive-int" => -> { Type::Combinator.positive_int },
44
53
  "non-negative-int" => -> { Type::Combinator.non_negative_int },
45
54
  "negative-int" => -> { Type::Combinator.negative_int },
46
- "non-positive-int" => -> { Type::Combinator.non_positive_int }
55
+ "non-positive-int" => -> { Type::Combinator.non_positive_int },
56
+ "lowercase-string" => -> { Type::Combinator.lowercase_string },
57
+ "uppercase-string" => -> { Type::Combinator.uppercase_string },
58
+ "numeric-string" => -> { Type::Combinator.numeric_string },
59
+ "decimal-int-string" => -> { Type::Combinator.decimal_int_string },
60
+ "octal-int-string" => -> { Type::Combinator.octal_int_string },
61
+ "hex-int-string" => -> { Type::Combinator.hex_int_string },
62
+ "non-empty-lowercase-string" => -> { Type::Combinator.non_empty_lowercase_string },
63
+ "non-empty-uppercase-string" => -> { Type::Combinator.non_empty_uppercase_string }
47
64
  }.freeze
48
65
  private_constant :REGISTRY
49
66
 
67
+ # `name[T]` / `name[K, V]` — type-arg parameterised
68
+ # refinements. Each builder takes an `Array<Rigor::Type>`
69
+ # and returns a `Rigor::Type` (or `nil` on arity / shape
70
+ # mismatch so the caller surfaces a parse failure).
71
+ PARAMETERISED_TYPE_BUILDERS = {
72
+ "non-empty-array" => lambda { |args|
73
+ return nil unless args.size == 1
74
+
75
+ Type::Combinator.non_empty_array(args.first)
76
+ },
77
+ "non-empty-hash" => lambda { |args|
78
+ return nil unless args.size == 2
79
+
80
+ Type::Combinator.non_empty_hash(args[0], args[1])
81
+ }
82
+ }.freeze
83
+ private_constant :PARAMETERISED_TYPE_BUILDERS
84
+
85
+ # `name<min, max>` — integer-bound parameterised
86
+ # refinements. Each builder takes an `Array<Integer>` and
87
+ # returns a `Rigor::Type` (or `nil`). Bounds are signed
88
+ # integer literals; `min` MUST be ≤ `max` for the carrier
89
+ # to construct successfully (`Type::IntegerRange` enforces
90
+ # the invariant).
91
+ PARAMETERISED_INT_BUILDERS = {
92
+ "int" => lambda { |bounds|
93
+ return nil unless bounds.size == 2
94
+
95
+ Type::Combinator.integer_range(bounds[0], bounds[1])
96
+ }
97
+ }.freeze
98
+ private_constant :PARAMETERISED_INT_BUILDERS
99
+
50
100
  module_function
51
101
 
52
102
  # @param name [String] kebab-case refinement name.
@@ -57,13 +107,145 @@ module Rigor
57
107
  builder&.call
58
108
  end
59
109
 
110
+ # @param payload [String] the trailing payload of a
111
+ # `rigor:v1:return:` (or sibling) directive. Accepts
112
+ # the bare-name forms `lookup` already handles plus the
113
+ # parameterised forms documented on {Parser}.
114
+ # @return [Rigor::Type, nil] the resolved refinement
115
+ # carrier, or `nil` when the payload is unparseable or
116
+ # names a refinement / class not in the registry.
117
+ def parse(payload)
118
+ Parser.new(payload.to_s).parse
119
+ end
120
+
60
121
  def known?(name)
61
- REGISTRY.key?(name.to_s)
122
+ REGISTRY.key?(name.to_s) ||
123
+ PARAMETERISED_TYPE_BUILDERS.key?(name.to_s) ||
124
+ PARAMETERISED_INT_BUILDERS.key?(name.to_s)
62
125
  end
63
126
 
64
127
  def known_names
65
- REGISTRY.keys
128
+ REGISTRY.keys + PARAMETERISED_TYPE_BUILDERS.keys + PARAMETERISED_INT_BUILDERS.keys
129
+ end
130
+
131
+ # Recursive-descent parser for the refinement-payload
132
+ # grammar:
133
+ #
134
+ # type := simple_name | parametric
135
+ # simple_name := /[a-z][a-z0-9-]*/
136
+ # parametric := simple_name '[' type_arg_list ']'
137
+ # | simple_name '<' int_bound_list '>'
138
+ # type_arg_list := type_arg (',' type_arg)*
139
+ # type_arg := type | class_name
140
+ # class_name := /[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*/
141
+ # int_bound_list := signed_int (',' signed_int)*
142
+ # signed_int := /-?\d+/
143
+ #
144
+ # Whitespace between tokens is ignored. The parser fails
145
+ # soft (returns `nil` from `parse`) on any deviation so the
146
+ # `RBS::Extended` directive site can fall back to the
147
+ # RBS-declared type rather than crash on a typo.
148
+ class Parser
149
+ def initialize(input)
150
+ @scanner = StringScanner.new(input.strip)
151
+ end
152
+
153
+ def parse
154
+ type = parse_type
155
+ return nil if type.nil?
156
+ return nil unless @scanner.eos?
157
+
158
+ type
159
+ end
160
+
161
+ private
162
+
163
+ SIMPLE_NAME = /[a-z][a-z0-9-]*/
164
+ CLASS_NAME = /[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*/
165
+ SIGNED_INT = /-?\d+/
166
+ private_constant :SIMPLE_NAME, :CLASS_NAME, :SIGNED_INT
167
+
168
+ def parse_type
169
+ name = @scanner.scan(SIMPLE_NAME)
170
+ return nil if name.nil?
171
+
172
+ case @scanner.peek(1)
173
+ when "[" then parse_parametric_type_args(name)
174
+ when "<" then parse_parametric_int_bounds(name)
175
+ else ImportedRefinements.lookup(name)
176
+ end
177
+ end
178
+
179
+ def parse_parametric_type_args(name)
180
+ builder = PARAMETERISED_TYPE_BUILDERS[name]
181
+ return nil if builder.nil?
182
+
183
+ @scanner.getch # consume '['
184
+ args = parse_type_arg_list
185
+ return nil if args.nil?
186
+ return nil unless @scanner.getch == "]"
187
+
188
+ builder.call(args)
189
+ end
190
+
191
+ def parse_parametric_int_bounds(name)
192
+ builder = PARAMETERISED_INT_BUILDERS[name]
193
+ return nil if builder.nil?
194
+
195
+ @scanner.getch # consume '<'
196
+ bounds = parse_int_bound_list
197
+ return nil if bounds.nil?
198
+ return nil unless @scanner.getch == ">"
199
+
200
+ builder.call(bounds)
201
+ end
202
+
203
+ def parse_type_arg_list
204
+ collect_separated_list { parse_type_arg }
205
+ end
206
+
207
+ def parse_int_bound_list
208
+ collect_separated_list { parse_int_bound }
209
+ end
210
+
211
+ def collect_separated_list
212
+ items = []
213
+ loop do
214
+ skip_ws
215
+ item = yield
216
+ return nil if item.nil?
217
+
218
+ items << item
219
+ skip_ws
220
+ break unless @scanner.peek(1) == ","
221
+
222
+ @scanner.getch # consume ','
223
+ end
224
+ items
225
+ end
226
+
227
+ def parse_type_arg
228
+ skip_ws
229
+ if (class_name = @scanner.scan(CLASS_NAME))
230
+ Type::Combinator.nominal_of(class_name)
231
+ else
232
+ parse_type
233
+ end
234
+ end
235
+
236
+ def parse_int_bound
237
+ skip_ws
238
+ literal = @scanner.scan(SIGNED_INT)
239
+ return nil if literal.nil?
240
+
241
+ Integer(literal)
242
+ end
243
+
244
+ def skip_ws
245
+ @scanner.skip(/\s+/)
246
+ end
66
247
  end
248
+ private_constant :Parser
67
249
  end
68
250
  end
69
251
  end
@@ -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
@@ -66,6 +77,8 @@ module Rigor
66
77
  Type::Constant => :accepts_constant,
67
78
  Type::IntegerRange => :accepts_integer_range,
68
79
  Type::Difference => :accepts_difference,
80
+ Type::Refined => :accepts_refined,
81
+ Type::Intersection => :accepts_intersection,
69
82
  Type::Tuple => :accepts_tuple,
70
83
  Type::HashShape => :accepts_hash_shape
71
84
  }.freeze
@@ -128,6 +141,27 @@ module Rigor
128
141
  end
129
142
  end
130
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
+
131
165
  # self.accepts(Union[Y, Z]) iff self accepts every Y_i. Strict
132
166
  # AND across members: any "no" turns the whole result no, any
133
167
  # "maybe" without a "no" gives maybe, all "yes" gives yes.
@@ -186,20 +220,40 @@ module Rigor
186
220
  # - Singleton: never (wrong value kind).
187
221
  def accepts_nominal(self_type, other_type, mode)
188
222
  case other_type
189
- when Type::Nominal
190
- accepts_nominal_from_nominal(self_type, other_type, mode)
191
- when Type::Constant
192
- accepts_nominal_from_constant(self_type, other_type, mode)
193
- when Type::Singleton
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)
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
197
240
  when Type::Tuple
198
241
  accepts(self_type, project_tuple_to_nominal(other_type), mode: mode)
199
242
  .with_reason("projected Tuple to Nominal[Array]")
200
243
  when Type::HashShape
201
244
  accepts(self_type, project_hash_shape_to_nominal(other_type), mode: mode)
202
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")
203
257
  else
204
258
  Type::AcceptsResult.no(
205
259
  mode: mode,
@@ -486,10 +540,125 @@ module Rigor
486
540
  when Type::Constant
487
541
  !(removed.is_a?(Type::Constant) && removed.value == other_type.value)
488
542
  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)
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
+ )
493
662
  end
494
663
  end
495
664
 
@@ -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,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Range` catalog. Singleton — load once, consult during
9
+ # dispatch.
10
+ #
11
+ # Range is largely immutable: `begin`, `end`, and `excl` are
12
+ # set at construction by `range_initialize` and never mutated
13
+ # afterwards. The blocklist below therefore stays small. The
14
+ # entries we DO need are the iteration methods whose C body
15
+ # routes through a helper the block/yield regex does not
16
+ # recognise, so the classifier mis-flags them as `:leaf`
17
+ # despite yielding to a block.
18
+ RANGE_CATALOG = MethodCatalog.new(
19
+ path: File.expand_path(
20
+ "../../../../data/builtins/ruby_core/range.yml",
21
+ __dir__
22
+ ),
23
+ mutating_selectors: {
24
+ "Range" => Set[
25
+ # `range_initialize` / `range_initialize_copy` write
26
+ # `begin`/`end`/`excl` slots on the receiver; classed
27
+ # `:leaf` because the writes go through the struct
28
+ # accessor not `rb_check_frozen`. Blocked for symmetry
29
+ # with String / Array.
30
+ :initialize, :initialize_copy,
31
+ # `range_reverse_each` yields to its block via
32
+ # `range_each_func` -> caller's block; the regex
33
+ # classifier follows direct `rb_yield*` calls only.
34
+ :reverse_each,
35
+ # `range_percent_step` returns an Enumerator unless a
36
+ # block is supplied, in which case it yields. Treated
37
+ # as block-dependent so the fold tier never invokes it
38
+ # against a literal Range and tries to materialise an
39
+ # Enumerator into a Constant.
40
+ :%
41
+ ]
42
+ }
43
+ )
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Set` catalog. Singleton — load once, consult during dispatch.
9
+ #
10
+ # Set was rewritten in C and folded into CRuby for Ruby 3.2+;
11
+ # the reference branch (`ruby_4_0`) ships the implementation in
12
+ # `references/ruby/set.c` with `Init_Set` registering every
13
+ # method directly. There is no `set.rb` prelude — the trailing
14
+ # `rb_provide("set.rb")` makes `require "set"` a no-op against
15
+ # the built-in.
16
+ #
17
+ # The blocklist below catches the catalog `:leaf` entries the
18
+ # C-body classifier mis-attributes. Set's iteration helpers
19
+ # (`set_iter`, `RETURN_SIZED_ENUMERATOR`) and its identity-
20
+ # mode and reset paths drive into helpers the regex classifier
21
+ # does not yet recognise as block-yielding or mutating.
22
+ SET_CATALOG = MethodCatalog.new(
23
+ path: File.expand_path(
24
+ "../../../../data/builtins/ruby_core/set.yml",
25
+ __dir__
26
+ ),
27
+ mutating_selectors: {
28
+ "Set" => Set[
29
+ # Indirect mutators classified `:leaf` because the C
30
+ # classifier did not follow the helper functions:
31
+ #
32
+ # - `initialize_copy` calls `set_copy` to overwrite the
33
+ # receiver's table.
34
+ # - `compare_by_identity` swaps the internal hash type
35
+ # via `set_reset_table_with_type`.
36
+ # - `reset` rebuilds the internal table to dedup after
37
+ # element mutation.
38
+ :initialize_copy, :compare_by_identity, :reset,
39
+ # Block-dependent methods classified `:leaf` because the
40
+ # C body uses `set_iter` / `RETURN_SIZED_ENUMERATOR`
41
+ # rather than calling `rb_yield` directly:
42
+ :each, :classify, :divide,
43
+ # `disjoint?` delegates into `set_i_intersect`, which
44
+ # for non-Set enumerables uses `rb_funcall(other,
45
+ # :any?, ...)` — that is user-redefinable dispatch the
46
+ # classifier missed because the call site is in a
47
+ # sibling function.
48
+ :disjoint?
49
+ ]
50
+ }
51
+ )
52
+ end
53
+ end
54
+ end