rigortype 0.0.1 → 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 +297 -5
- data/lib/rigor/analysis/diagnostic.rb +13 -2
- data/lib/rigor/analysis/runner.rb +52 -5
- data/lib/rigor/builtins/imported_refinements.rb +69 -0
- data/lib/rigor/cli/type_of_command.rb +11 -5
- data/lib/rigor/cli/type_scan_command.rb +13 -8
- data/lib/rigor/cli.rb +26 -6
- data/lib/rigor/configuration.rb +18 -2
- data/lib/rigor/environment.rb +3 -1
- data/lib/rigor/inference/acceptance.rb +180 -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 +151 -0
- 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 +471 -10
- data/lib/rigor/inference/scope_indexer.rb +66 -0
- data/lib/rigor/inference/statement_evaluator.rb +305 -2
- data/lib/rigor/rbs_extended.rb +174 -14
- data/lib/rigor/scope.rb +44 -5
- 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/inference.rbs +5 -2
- data/sig/rigor/rbs_extended.rbs +25 -1
- data/sig/rigor/scope.rbs +4 -0
- data/sig/rigor/type.rbs +51 -1
- metadata +15 -1
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "optionparser"
|
|
4
4
|
require "prism"
|
|
5
5
|
|
|
6
|
+
require_relative "../configuration"
|
|
6
7
|
require_relative "../environment"
|
|
7
8
|
require_relative "../inference/coverage_scanner"
|
|
8
9
|
require_relative "../scope"
|
|
@@ -44,11 +45,13 @@ module Rigor
|
|
|
44
45
|
private
|
|
45
46
|
|
|
46
47
|
def parse_options
|
|
47
|
-
options = { format: "text", limit: 10, show_recognized: false, threshold: nil
|
|
48
|
+
options = { format: "text", limit: 10, show_recognized: false, threshold: nil,
|
|
49
|
+
config: Configuration::DEFAULT_PATH }
|
|
48
50
|
|
|
49
51
|
parser = OptionParser.new do |opts|
|
|
50
52
|
opts.banner = USAGE
|
|
51
53
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
54
|
+
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
52
55
|
opts.on("--limit=N", Integer, "Max example events to print (text only)") do |value|
|
|
53
56
|
options[:limit] = value
|
|
54
57
|
end
|
|
@@ -86,7 +89,8 @@ module Rigor
|
|
|
86
89
|
end
|
|
87
90
|
|
|
88
91
|
def scan_paths(paths, options)
|
|
89
|
-
|
|
92
|
+
configuration = Configuration.load(options.fetch(:config))
|
|
93
|
+
scope = Scope.empty(environment: project_environment(configuration))
|
|
90
94
|
scanner = Inference::CoverageScanner.new(scope: scope)
|
|
91
95
|
accumulator = ScanAccumulator.new
|
|
92
96
|
paths.each { |path| scan_one(path, scanner, accumulator) }
|
|
@@ -94,12 +98,13 @@ module Rigor
|
|
|
94
98
|
end
|
|
95
99
|
|
|
96
100
|
# Builds a project-aware environment that auto-detects `<cwd>/sig`
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
# by default and honours the configuration's `libraries:` /
|
|
102
|
+
# `signature_paths:` keys when present.
|
|
103
|
+
def project_environment(configuration)
|
|
104
|
+
Environment.for_project(
|
|
105
|
+
libraries: configuration.libraries,
|
|
106
|
+
signature_paths: configuration.signature_paths
|
|
107
|
+
)
|
|
103
108
|
end
|
|
104
109
|
|
|
105
110
|
def scan_one(path, scanner, accumulator)
|
data/lib/rigor/cli.rb
CHANGED
|
@@ -68,19 +68,21 @@ module Rigor
|
|
|
68
68
|
|
|
69
69
|
options = {
|
|
70
70
|
config: Configuration::DEFAULT_PATH,
|
|
71
|
-
format: "text"
|
|
71
|
+
format: "text",
|
|
72
|
+
explain: false
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
parser = OptionParser.new do |opts|
|
|
75
76
|
opts.banner = "Usage: rigor check [options] [paths]"
|
|
76
77
|
opts.on("--config=PATH", "Path to the Rigor configuration file") { |value| options[:config] = value }
|
|
77
78
|
opts.on("--format=FORMAT", "Output format: text or json") { |value| options[:format] = value }
|
|
79
|
+
opts.on("--explain", "Surface fail-soft fallback events as :info diagnostics") { options[:explain] = true }
|
|
78
80
|
end
|
|
79
81
|
parser.parse!(@argv)
|
|
80
82
|
|
|
81
83
|
configuration = Configuration.load(options.fetch(:config))
|
|
82
84
|
paths = @argv.empty? ? configuration.paths : @argv
|
|
83
|
-
result = Analysis::Runner.new(configuration: configuration).run(paths)
|
|
85
|
+
result = Analysis::Runner.new(configuration: configuration, explain: options.fetch(:explain)).run(paths)
|
|
84
86
|
|
|
85
87
|
write_result(result, options.fetch(:format))
|
|
86
88
|
result.success? ? 0 : 1
|
|
@@ -126,6 +128,23 @@ module Rigor
|
|
|
126
128
|
# `rigor type-scan` when no path is given.
|
|
127
129
|
# - plugins: reserved for future plugin contributions
|
|
128
130
|
# (no plugins are loaded today).
|
|
131
|
+
# - disable: list of `rigor check` rule identifiers to
|
|
132
|
+
# silence project-wide. The shipped rules are
|
|
133
|
+
# undefined-method, wrong-arity,
|
|
134
|
+
# argument-type-mismatch, possible-nil-receiver,
|
|
135
|
+
# dump-type, assert-type. In-source
|
|
136
|
+
# `# rigor:disable <rule>` comments at the end
|
|
137
|
+
# of an offending line silence per-line; use
|
|
138
|
+
# `# rigor:disable all` to suppress every rule.
|
|
139
|
+
# - libraries: stdlib libraries to load on top of the
|
|
140
|
+
# bundled defaults (e.g. ["csv", "set"]).
|
|
141
|
+
# Each entry must be a name accepted by
|
|
142
|
+
# `RBS::EnvironmentLoader#has_library?`.
|
|
143
|
+
# - signature_paths:
|
|
144
|
+
# explicit list of `sig/`-style directories.
|
|
145
|
+
# Leave unset (or `null`) to auto-detect
|
|
146
|
+
# `<root>/sig`. Use `[]` to disable
|
|
147
|
+
# project-RBS loading entirely.
|
|
129
148
|
# - cache.path: where Rigor will eventually persist
|
|
130
149
|
# analysis results across runs.
|
|
131
150
|
#
|
|
@@ -167,15 +186,16 @@ module Rigor
|
|
|
167
186
|
# the success and failure cases and reports the affected
|
|
168
187
|
# file count for failures.
|
|
169
188
|
def write_text_result(result)
|
|
189
|
+
result.diagnostics.each { |diagnostic| @out.puts(diagnostic) }
|
|
190
|
+
|
|
170
191
|
if result.success?
|
|
171
|
-
@out.puts("No diagnostics")
|
|
192
|
+
@out.puts("No diagnostics") if result.diagnostics.empty?
|
|
172
193
|
return
|
|
173
194
|
end
|
|
174
195
|
|
|
175
|
-
result.diagnostics.
|
|
176
|
-
file_count = result.diagnostics.map(&:path).uniq.size
|
|
196
|
+
error_files = result.diagnostics.select(&:error?).map(&:path).uniq.size
|
|
177
197
|
@out.puts("")
|
|
178
|
-
@out.puts("#{result.error_count} error(s) in #{
|
|
198
|
+
@out.puts("#{result.error_count} error(s) in #{error_files} file(s)")
|
|
179
199
|
end
|
|
180
200
|
|
|
181
201
|
def help
|
data/lib/rigor/configuration.rb
CHANGED
|
@@ -9,12 +9,17 @@ module Rigor
|
|
|
9
9
|
"target_ruby" => "4.0",
|
|
10
10
|
"paths" => ["lib"],
|
|
11
11
|
"plugins" => [],
|
|
12
|
+
"disable" => [],
|
|
13
|
+
"libraries" => [],
|
|
14
|
+
"signature_paths" => nil,
|
|
15
|
+
"fold_platform_specific_paths" => false,
|
|
12
16
|
"cache" => {
|
|
13
17
|
"path" => ".rigor/cache"
|
|
14
18
|
}
|
|
15
19
|
}.freeze
|
|
16
20
|
|
|
17
|
-
attr_reader :target_ruby, :paths, :plugins, :cache_path
|
|
21
|
+
attr_reader :target_ruby, :paths, :plugins, :cache_path, :disabled_rules,
|
|
22
|
+
:libraries, :signature_paths, :fold_platform_specific_paths
|
|
18
23
|
|
|
19
24
|
def self.load(path = DEFAULT_PATH)
|
|
20
25
|
data = if File.exist?(path)
|
|
@@ -26,12 +31,19 @@ module Rigor
|
|
|
26
31
|
new(DEFAULTS.merge(data))
|
|
27
32
|
end
|
|
28
33
|
|
|
29
|
-
def initialize(data = DEFAULTS)
|
|
34
|
+
def initialize(data = DEFAULTS) # rubocop:disable Metrics/AbcSize
|
|
30
35
|
cache = DEFAULTS.fetch("cache").merge(data.fetch("cache", {}))
|
|
31
36
|
|
|
32
37
|
@target_ruby = data.fetch("target_ruby", DEFAULTS.fetch("target_ruby")).to_s
|
|
33
38
|
@paths = Array(data.fetch("paths", DEFAULTS.fetch("paths"))).map(&:to_s)
|
|
34
39
|
@plugins = Array(data.fetch("plugins", DEFAULTS.fetch("plugins"))).map(&:to_s)
|
|
40
|
+
@disabled_rules = Array(data.fetch("disable", DEFAULTS.fetch("disable"))).map(&:to_s).freeze
|
|
41
|
+
@libraries = Array(data.fetch("libraries", DEFAULTS.fetch("libraries"))).map(&:to_s).freeze
|
|
42
|
+
sig_paths = data.fetch("signature_paths", DEFAULTS.fetch("signature_paths"))
|
|
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
|
|
35
47
|
@cache_path = cache.fetch("path").to_s
|
|
36
48
|
end
|
|
37
49
|
|
|
@@ -40,6 +52,10 @@ module Rigor
|
|
|
40
52
|
"target_ruby" => target_ruby,
|
|
41
53
|
"paths" => paths,
|
|
42
54
|
"plugins" => plugins,
|
|
55
|
+
"disable" => disabled_rules,
|
|
56
|
+
"libraries" => libraries,
|
|
57
|
+
"signature_paths" => signature_paths,
|
|
58
|
+
"fold_platform_specific_paths" => fold_platform_specific_paths,
|
|
43
59
|
"cache" => {
|
|
44
60
|
"path" => cache_path
|
|
45
61
|
}
|
data/lib/rigor/environment.rb
CHANGED
|
@@ -35,7 +35,9 @@ module Rigor
|
|
|
35
35
|
# a strictly RBS-core view MUST construct an `RbsLoader`
|
|
36
36
|
# directly instead of going through `for_project`.
|
|
37
37
|
DEFAULT_LIBRARIES = %w[
|
|
38
|
-
pathname optparse json yaml fileutils tempfile
|
|
38
|
+
pathname optparse json yaml fileutils tempfile tmpdir
|
|
39
|
+
stringio forwardable digest securerandom
|
|
40
|
+
uri logger date
|
|
39
41
|
prism rbs
|
|
40
42
|
].freeze
|
|
41
43
|
|
|
@@ -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
|
|
@@ -188,6 +190,10 @@ module Rigor
|
|
|
188
190
|
accepts_nominal_from_nominal(self_type, other_type, mode)
|
|
189
191
|
when Type::Constant
|
|
190
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)
|
|
191
197
|
when Type::Tuple
|
|
192
198
|
accepts(self_type, project_tuple_to_nominal(other_type), mode: mode)
|
|
193
199
|
.with_reason("projected Tuple to Nominal[Array]")
|
|
@@ -202,6 +208,62 @@ module Rigor
|
|
|
202
208
|
end
|
|
203
209
|
end
|
|
204
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
|
+
|
|
238
|
+
# v0.0.2 — meta-type rule. A `Singleton[T]` is the
|
|
239
|
+
# class object for `T`, so it is an instance of
|
|
240
|
+
# `Class` (when `T` is a class) and always an instance
|
|
241
|
+
# of `Module`. Without this rule a method whose
|
|
242
|
+
# parameter is typed `Class | Module` would reject
|
|
243
|
+
# every `is_a?(SomeClass)` call and similar
|
|
244
|
+
# introspection patterns. The rule conservatively
|
|
245
|
+
# answers `:yes` for `Module` (every singleton is at
|
|
246
|
+
# least a Module) and for `Class` / `Object` /
|
|
247
|
+
# `BasicObject` (the class object inherits from
|
|
248
|
+
# those). Other Nominals fall through to the default
|
|
249
|
+
# `:no`.
|
|
250
|
+
META_NOMINALS_FROM_SINGLETON = %w[Module Class Object BasicObject].freeze
|
|
251
|
+
private_constant :META_NOMINALS_FROM_SINGLETON
|
|
252
|
+
|
|
253
|
+
def accepts_nominal_from_singleton(self_type, other_type, mode)
|
|
254
|
+
if META_NOMINALS_FROM_SINGLETON.include?(self_type.class_name)
|
|
255
|
+
return Type::AcceptsResult.yes(
|
|
256
|
+
mode: mode,
|
|
257
|
+
reasons: "Singleton[#{other_type.class_name}] is-a #{self_type.class_name}"
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
Type::AcceptsResult.no(
|
|
262
|
+
mode: mode,
|
|
263
|
+
reasons: "Nominal[#{self_type.class_name}] rejects Singleton[#{other_type.class_name}]"
|
|
264
|
+
)
|
|
265
|
+
end
|
|
266
|
+
|
|
205
267
|
def accepts_nominal_from_nominal(self_type, other_type, mode)
|
|
206
268
|
class_result = class_subtype_result(
|
|
207
269
|
target_name: self_type.class_name,
|
|
@@ -313,6 +375,124 @@ module Rigor
|
|
|
313
375
|
end
|
|
314
376
|
end
|
|
315
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
|
+
|
|
316
496
|
# Constant[v] accepts only Constant[v'] with structurally equal
|
|
317
497
|
# value. Any other type is rejected (modulo the universal
|
|
318
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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "method_catalog"
|
|
4
|
+
|
|
5
|
+
module Rigor
|
|
6
|
+
module Inference
|
|
7
|
+
module Builtins
|
|
8
|
+
# `String` and `Symbol` catalog. Singleton — load once,
|
|
9
|
+
# consult during dispatch.
|
|
10
|
+
#
|
|
11
|
+
# The blocklist below is the curated set of catalog `:leaf`
|
|
12
|
+
# entries the C-body classifier mis-attributes (the body of
|
|
13
|
+
# `rb_str_replace` calls `str_modifiable` / `str_discard`
|
|
14
|
+
# which the regex-based classifier does not recognise as
|
|
15
|
+
# mutation primitives). Adding to the blocklist is the
|
|
16
|
+
# corrective surface for false positives until the
|
|
17
|
+
# classifier learns the helper functions.
|
|
18
|
+
STRING_CATALOG = MethodCatalog.new(
|
|
19
|
+
path: File.expand_path(
|
|
20
|
+
"../../../../data/builtins/ruby_core/string.yml",
|
|
21
|
+
__dir__
|
|
22
|
+
),
|
|
23
|
+
mutating_selectors: {
|
|
24
|
+
"String" => Set[
|
|
25
|
+
:replace, :initialize, :initialize_copy, :clear, :<<, :concat, :insert,
|
|
26
|
+
:prepend, :force_encoding, :encode, :scrub, :unicode_normalize, :"[]=",
|
|
27
|
+
:upto, :each_byte, :each_char, :each_codepoint,
|
|
28
|
+
:each_grapheme_cluster, :each_line, :bytesplice
|
|
29
|
+
],
|
|
30
|
+
"Symbol" => Set[
|
|
31
|
+
# Symbol is immutable in Ruby; the classifier mis-flags
|
|
32
|
+
# `inspect` because `rb_sym_inspect` builds a temporary
|
|
33
|
+
# mutable buffer. Allow it.
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|