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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/data/builtins/ruby_core/array.yml +1470 -0
  3. data/data/builtins/ruby_core/file.yml +501 -0
  4. data/data/builtins/ruby_core/io.yml +1594 -0
  5. data/data/builtins/ruby_core/numeric.yml +1809 -0
  6. data/data/builtins/ruby_core/string.yml +1850 -0
  7. data/lib/rigor/analysis/check_rules.rb +297 -5
  8. data/lib/rigor/analysis/diagnostic.rb +13 -2
  9. data/lib/rigor/analysis/runner.rb +52 -5
  10. data/lib/rigor/builtins/imported_refinements.rb +69 -0
  11. data/lib/rigor/cli/type_of_command.rb +11 -5
  12. data/lib/rigor/cli/type_scan_command.rb +13 -8
  13. data/lib/rigor/cli.rb +26 -6
  14. data/lib/rigor/configuration.rb +18 -2
  15. data/lib/rigor/environment.rb +3 -1
  16. data/lib/rigor/inference/acceptance.rb +180 -0
  17. data/lib/rigor/inference/builtins/array_catalog.rb +46 -0
  18. data/lib/rigor/inference/builtins/method_catalog.rb +90 -0
  19. data/lib/rigor/inference/builtins/numeric_catalog.rb +93 -0
  20. data/lib/rigor/inference/builtins/string_catalog.rb +39 -0
  21. data/lib/rigor/inference/expression_typer.rb +151 -0
  22. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +650 -16
  23. data/lib/rigor/inference/method_dispatcher/file_folding.rb +144 -0
  24. data/lib/rigor/inference/method_dispatcher/iterator_dispatch.rb +113 -0
  25. data/lib/rigor/inference/method_dispatcher/rbs_dispatch.rb +4 -0
  26. data/lib/rigor/inference/method_dispatcher/shape_dispatch.rb +107 -0
  27. data/lib/rigor/inference/method_dispatcher.rb +28 -21
  28. data/lib/rigor/inference/narrowing.rb +471 -10
  29. data/lib/rigor/inference/scope_indexer.rb +66 -0
  30. data/lib/rigor/inference/statement_evaluator.rb +305 -2
  31. data/lib/rigor/rbs_extended.rb +174 -14
  32. data/lib/rigor/scope.rb +44 -5
  33. data/lib/rigor/type/combinator.rb +69 -1
  34. data/lib/rigor/type/difference.rb +155 -0
  35. data/lib/rigor/type/integer_range.rb +137 -0
  36. data/lib/rigor/type.rb +2 -0
  37. data/lib/rigor/version.rb +1 -1
  38. data/sig/rigor/inference.rbs +5 -2
  39. data/sig/rigor/rbs_extended.rbs +25 -1
  40. data/sig/rigor/scope.rbs +4 -0
  41. data/sig/rigor/type.rbs +51 -1
  42. 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
- scope = Scope.empty(environment: project_environment)
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
- # so calls scoped to the current project resolve through the
98
- # local RBS tree. Phase 2a does not yet wire stdlib opt-in here;
99
- # that lands when the configuration layer (`.rigor.yml`) gains an
100
- # `rbs:` section.
101
- def project_environment
102
- Environment.for_project
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.each { |diagnostic| @out.puts(diagnostic) }
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 #{file_count} file(s)")
198
+ @out.puts("#{result.error_count} error(s) in #{error_files} file(s)")
179
199
  end
180
200
 
181
201
  def help
@@ -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
  }
@@ -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 uri logger date
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