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.
- checksums.yaml +4 -4
- data/data/builtins/ruby_core/array.yml +1470 -0
- data/data/builtins/ruby_core/file.yml +501 -0
- data/data/builtins/ruby_core/io.yml +1594 -0
- data/data/builtins/ruby_core/numeric.yml +1809 -0
- data/data/builtins/ruby_core/string.yml +1850 -0
- data/lib/rigor/analysis/check_rules.rb +86 -1
- data/lib/rigor/analysis/runner.rb +4 -0
- data/lib/rigor/builtins/imported_refinements.rb +69 -0
- data/lib/rigor/configuration.rb +6 -1
- data/lib/rigor/inference/acceptance.rb +149 -0
- data/lib/rigor/inference/builtins/array_catalog.rb +46 -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/string_catalog.rb +39 -0
- data/lib/rigor/inference/expression_typer.rb +48 -1
- data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
- data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
- data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
- data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
- data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
- data/lib/rigor/inference/method_dispatcher.rb +28 -21
- data/lib/rigor/inference/narrowing.rb +374 -4
- data/lib/rigor/inference/scope_indexer.rb +10 -2
- data/lib/rigor/inference/statement_evaluator.rb +211 -2
- data/lib/rigor/rbs_extended.rb +65 -1
- data/lib/rigor/scope.rb +14 -0
- data/lib/rigor/type/combinator.rb +69 -1
- data/lib/rigor/type/difference.rb +155 -0
- data/lib/rigor/type/integer_range.rb +137 -0
- data/lib/rigor/type.rb +2 -0
- data/lib/rigor/version.rb +1 -1
- data/sig/rigor/rbs_extended.rbs +3 -0
- data/sig/rigor/scope.rbs +1 -0
- data/sig/rigor/type.rbs +51 -1
- 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
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -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
|