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.
- checksums.yaml +4 -4
- data/README.md +24 -7
- data/data/builtins/ruby_core/array.yml +1470 -0
- data/data/builtins/ruby_core/file.yml +501 -0
- data/data/builtins/ruby_core/hash.yml +936 -0
- data/data/builtins/ruby_core/io.yml +1594 -0
- data/data/builtins/ruby_core/numeric.yml +1809 -0
- data/data/builtins/ruby_core/range.yml +389 -0
- data/data/builtins/ruby_core/set.yml +594 -0
- data/data/builtins/ruby_core/string.yml +1850 -0
- data/data/builtins/ruby_core/time.yml +750 -0
- data/lib/rigor/analysis/check_rules.rb +97 -4
- data/lib/rigor/analysis/runner.rb +4 -0
- data/lib/rigor/builtins/imported_refinements.rb +251 -0
- data/lib/rigor/configuration.rb +6 -1
- data/lib/rigor/inference/acceptance.rb +324 -6
- data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/hash_catalog.rb +40 -0
- data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
- data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
- data/lib/rigor/inference/builtins/range_catalog.rb +46 -0
- data/lib/rigor/inference/builtins/set_catalog.rb +54 -0
- data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
- data/lib/rigor/inference/builtins/time_catalog.rb +64 -0
- data/lib/rigor/inference/expression_typer.rb +48 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +670 -16
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +215 -0
- data/lib/rigor/inference/method_dispatcher/overload_selector.rb +23 -7
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +240 -4
- data/lib/rigor/inference/method_dispatcher.rb +28 -21
- data/lib/rigor/inference/method_parameter_binder.rb +29 -4
- data/lib/rigor/inference/narrowing.rb +376 -4
- data/lib/rigor/inference/scope_indexer.rb +10 -2
- data/lib/rigor/inference/statement_evaluator.rb +213 -2
- data/lib/rigor/rbs_extended.rb +230 -15
- data/lib/rigor/scope.rb +14 -0
- data/lib/rigor/type/combinator.rb +159 -1
- data/lib/rigor/type/difference.rb +155 -0
- data/lib/rigor/type/integer_range.rb +137 -0
- data/lib/rigor/type/intersection.rb +135 -0
- data/lib/rigor/type/refined.rb +174 -0
- data/lib/rigor/type.rb +4 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/rbs_extended.rbs +14 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +91 -1
- 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
|
-
|
|
189
|
-
when Type::
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|