rigortype 0.0.8 → 0.0.9

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/README.md +195 -21
  3. data/data/builtins/ruby_core/encoding.yml +210 -0
  4. data/data/builtins/ruby_core/exception.yml +641 -0
  5. data/data/builtins/ruby_core/numeric.yml +3 -2
  6. data/data/builtins/ruby_core/proc.yml +731 -0
  7. data/data/builtins/ruby_core/random.yml +166 -0
  8. data/data/builtins/ruby_core/re.yml +689 -0
  9. data/data/builtins/ruby_core/struct.yml +449 -0
  10. data/lib/rigor/analysis/runner.rb +19 -3
  11. data/lib/rigor/builtins/imported_refinements.rb +6 -1
  12. data/lib/rigor/cache/rbs_class_ancestor_table.rb +63 -0
  13. data/lib/rigor/cache/rbs_class_type_param_names.rb +60 -0
  14. data/lib/rigor/cache/rbs_constant_table.rb +15 -51
  15. data/lib/rigor/cache/rbs_descriptor.rb +53 -0
  16. data/lib/rigor/cache/rbs_environment.rb +52 -0
  17. data/lib/rigor/cache/rbs_environment_marshal_patch.rb +40 -0
  18. data/lib/rigor/cache/rbs_known_class_names.rb +43 -0
  19. data/lib/rigor/cache/store.rb +79 -15
  20. data/lib/rigor/cli.rb +36 -4
  21. data/lib/rigor/environment/rbs_hierarchy.rb +18 -5
  22. data/lib/rigor/environment/rbs_loader.rb +137 -25
  23. data/lib/rigor/environment.rb +11 -2
  24. data/lib/rigor/flow_contribution.rb +128 -0
  25. data/lib/rigor/inference/builtins/encoding_catalog.rb +67 -0
  26. data/lib/rigor/inference/builtins/exception_catalog.rb +92 -0
  27. data/lib/rigor/inference/builtins/proc_catalog.rb +122 -0
  28. data/lib/rigor/inference/builtins/random_catalog.rb +58 -0
  29. data/lib/rigor/inference/builtins/re_catalog.rb +81 -0
  30. data/lib/rigor/inference/builtins/struct_catalog.rb +55 -0
  31. data/lib/rigor/inference/expression_typer.rb +26 -1
  32. data/lib/rigor/inference/method_dispatcher/constant_folding.rb +16 -1
  33. data/lib/rigor/inference/method_dispatcher/literal_string_folding.rb +87 -0
  34. data/lib/rigor/inference/method_dispatcher.rb +2 -0
  35. data/lib/rigor/inference/narrowing.rb +29 -14
  36. data/lib/rigor/rbs_extended.rb +55 -0
  37. data/lib/rigor/type/combinator.rb +72 -0
  38. data/lib/rigor/type/refined.rb +50 -2
  39. data/lib/rigor/version.rb +1 -1
  40. data/lib/rigor.rb +6 -0
  41. data/sig/rigor.rbs +3 -1
  42. metadata +21 -1
@@ -42,9 +42,29 @@ module Rigor
42
42
  def reset_default!
43
43
  @default = nil
44
44
  end
45
+
46
+ # Builds an `RBS::Environment` from explicit `libraries` and
47
+ # `signature_paths`. Stateless surface so the v0.0.9
48
+ # {Cache::RbsEnvironment} producer can build an env on cache
49
+ # miss without holding a loader instance, and the
50
+ # instance-side {#build_env} delegates here so the
51
+ # implementation stays single-rooted.
52
+ def build_env_for(libraries:, signature_paths:)
53
+ rbs_loader = RBS::EnvironmentLoader.new
54
+ libraries.each do |library|
55
+ next unless rbs_loader.has_library?(library: library, version: nil)
56
+
57
+ rbs_loader.add(library: library, version: nil)
58
+ end
59
+ signature_paths.each do |path|
60
+ path = Pathname(path) unless path.is_a?(Pathname)
61
+ rbs_loader.add(path: path) if path.directory?
62
+ end
63
+ RBS::Environment.from_loader(rbs_loader).resolve_type_names
64
+ end
45
65
  end
46
66
 
47
- attr_reader :libraries, :signature_paths
67
+ attr_reader :libraries, :signature_paths, :cache_store
48
68
 
49
69
  # @param libraries [Array<String, Symbol>] stdlib library names to
50
70
  # load on top of core (e.g., `["pathname", "json"]`). Empty by
@@ -57,9 +77,16 @@ module Rigor
57
77
  # `sig/` tree). Non-existent or non-directory paths are filtered
58
78
  # out at build time so the loader stays robust to fixtures and
59
79
  # bare repositories.
60
- def initialize(libraries: [], signature_paths: [])
80
+ # @param cache_store [Rigor::Cache::Store, nil] the persistent
81
+ # cache the loader consults for translated constant lookups
82
+ # (and, in later v0.0.9 slices, other Marshal-clean
83
+ # reflection artefacts). Pass `nil` (the default) to skip
84
+ # the cache entirely; the runner threads its own Store
85
+ # through here when caching is enabled.
86
+ def initialize(libraries: [], signature_paths: [], cache_store: nil)
61
87
  @libraries = libraries.map(&:to_s).freeze
62
88
  @signature_paths = signature_paths.map { |p| Pathname(p) }.freeze
89
+ @cache_store = cache_store
63
90
  @state = { env: nil, builder: nil }
64
91
  @instance_definition_cache = {}
65
92
  @singleton_definition_cache = {}
@@ -71,11 +98,35 @@ module Rigor
71
98
  # name is loaded. Accepts unprefixed or top-level-prefixed names
72
99
  # ("Integer" or "::Integer"). Memoized per-name (positive and
73
100
  # negative results both cache).
101
+ #
102
+ # When `cache_store` is set, the loader fetches the entire set of
103
+ # known class / module / alias names once (per process) through
104
+ # {Cache::RbsKnownClassNames.fetch} and answers `class_known?`
105
+ # from the in-memory Set. Cold runs pay a single env walk and
106
+ # persist the result; warm runs (and a separate loader sharing
107
+ # the same Store) skip the env walk entirely.
74
108
  def class_known?(name)
75
109
  key = name.to_s
76
110
  return @class_known_cache[key] if @class_known_cache.key?(key)
77
111
 
78
- @class_known_cache[key] = compute_class_known(name)
112
+ @class_known_cache[key] = if cache_store
113
+ cached_class_known(name)
114
+ else
115
+ compute_class_known(name)
116
+ end
117
+ end
118
+
119
+ # Yields every known class / module / alias name (top-level
120
+ # prefixed) currently loaded into the environment. The cache
121
+ # producer that materialises the known-name set uses this so
122
+ # it never recurses back through {#class_known?}.
123
+ def each_known_class_name
124
+ return enum_for(:each_known_class_name) unless block_given?
125
+
126
+ env.class_decls.each_key { |rbs_name| yield rbs_name.to_s }
127
+ env.class_alias_decls.each_key { |rbs_name| yield rbs_name.to_s }
128
+ rescue StandardError
129
+ # fail-soft: a broken environment yields no names.
79
130
  end
80
131
 
81
132
  # @return [RBS::Definition, nil] the resolved instance definition
@@ -133,7 +184,19 @@ module Rigor
133
184
  # names (the loader stays fail-soft). NOTE: in the `rbs` gem,
134
185
  # `RBS::Definition#type_params` returns `Array<Symbol>` directly,
135
186
  # not the AST `TypeParam` object (those live on the AST level).
187
+ #
188
+ # When `cache_store` is set, the loader fetches the entire
189
+ # type-parameter-name table once (per process) through
190
+ # {Cache::RbsClassTypeParamNames.fetch} and answers point
191
+ # lookups from it. Cold runs build the table once and persist
192
+ # it; warm runs (and a separate loader sharing the same Store)
193
+ # skip the env walk entirely.
136
194
  def class_type_param_names(class_name)
195
+ if cache_store
196
+ key = class_name.to_s.delete_prefix("::")
197
+ return type_param_names_table.fetch(key, []).dup
198
+ end
199
+
137
200
  definition = instance_definition(class_name)
138
201
  return [] unless definition
139
202
 
@@ -155,6 +218,21 @@ module Rigor
155
218
  []
156
219
  end
157
220
 
221
+ # Yields `(name, entry)` for every RBS constant declaration
222
+ # currently loaded into the environment. The cache producer
223
+ # uses this to materialise the constant-type table without
224
+ # going back through {#constant_type} (which would recurse
225
+ # back into the cache when `cache_store` is set).
226
+ def each_constant_decl
227
+ return enum_for(:each_constant_decl) unless block_given?
228
+
229
+ env.constant_decls.each do |rbs_name, entry|
230
+ yield rbs_name.to_s, entry
231
+ end
232
+ rescue StandardError
233
+ # fail-soft: a broken environment yields no entries.
234
+ end
235
+
158
236
  # Slice A constant-value lookup. Returns the translated
159
237
  # `Rigor::Type` for a non-class constant declaration
160
238
  # (`BUCKETS: Array[Symbol]`, `DEFAULT_PATH: String`, ...) or
@@ -164,23 +242,73 @@ module Rigor
164
242
  # nil; the loader does NOT consult the class declarations
165
243
  # here — class objects are still resolved through
166
244
  # {#class_known?} and `Environment#singleton_for_name`.
245
+ #
246
+ # When `cache_store` is set, the loader fetches the entire
247
+ # translated constant table once (per process) through
248
+ # {Cache::RbsConstantTable.fetch} and answers point lookups
249
+ # from it. Cold runs pay the translation cost up-front and
250
+ # write the result to disk; warm runs skip the translation
251
+ # entirely and pay only a `Marshal.load` of the table.
167
252
  def constant_type(name)
168
253
  rbs_name = parse_type_name(name)
169
254
  return nil unless rbs_name
170
255
 
256
+ if cache_store
257
+ constant_type_table[rbs_name.to_s]
258
+ else
259
+ translate_constant_decl(rbs_name)
260
+ end
261
+ rescue StandardError
262
+ nil
263
+ end
264
+
265
+ private
266
+
267
+ def constant_type_table
268
+ @constant_type_table ||= begin
269
+ require_relative "../cache/rbs_constant_table"
270
+ Cache::RbsConstantTable.fetch(loader: self, store: cache_store)
271
+ end
272
+ end
273
+
274
+ def known_class_names_set
275
+ @known_class_names_set ||= begin
276
+ require_relative "../cache/rbs_known_class_names"
277
+ Cache::RbsKnownClassNames.fetch(loader: self, store: cache_store)
278
+ end
279
+ end
280
+
281
+ def type_param_names_table
282
+ @type_param_names_table ||= begin
283
+ require_relative "../cache/rbs_class_type_param_names"
284
+ Cache::RbsClassTypeParamNames.fetch(loader: self, store: cache_store)
285
+ end
286
+ end
287
+
288
+ def cached_class_known(name)
289
+ rbs_name = parse_type_name(name)
290
+ return false unless rbs_name
291
+
292
+ known_class_names_set.include?(rbs_name.to_s)
293
+ rescue StandardError
294
+ false
295
+ end
296
+
297
+ def translate_constant_decl(rbs_name)
171
298
  entry = env.constant_decls[rbs_name]
172
299
  return nil unless entry
173
300
 
174
301
  translated = Inference::RbsTypeTranslator.translate(entry.decl.type)
175
302
  translated unless translated.is_a?(Type::Bot)
176
- rescue StandardError
177
- nil
178
303
  end
179
304
 
180
- private
181
-
182
305
  def env
183
- @state[:env] ||= build_env
306
+ @state[:env] ||= cache_store ? cached_env : build_env
307
+ end
308
+
309
+ def cached_env
310
+ require_relative "../cache/rbs_environment"
311
+ Cache::RbsEnvironment.fetch(loader: self, store: cache_store)
184
312
  end
185
313
 
186
314
  def builder
@@ -188,23 +316,7 @@ module Rigor
188
316
  end
189
317
 
190
318
  def build_env
191
- rbs_loader = RBS::EnvironmentLoader.new
192
- @libraries.each do |library|
193
- # Phase 2a deliberately fails-soft on unknown stdlib libraries
194
- # so a stale `.rigor.yml` (or future config plumbing) does not
195
- # take down the whole analyzer. Phase 2b will surface this
196
- # through diagnostics once the configuration layer can name
197
- # the offending source. The unknown-library check happens at
198
- # `from_loader` time, not at `add` time, so we have to gate
199
- # ahead of `add`.
200
- next unless rbs_loader.has_library?(library: library, version: nil)
201
-
202
- rbs_loader.add(library: library, version: nil)
203
- end
204
- @signature_paths.each do |path|
205
- rbs_loader.add(path: path) if path.directory?
206
- end
207
- RBS::Environment.from_loader(rbs_loader).resolve_type_names
319
+ self.class.build_env_for(libraries: @libraries, signature_paths: @signature_paths)
208
320
  end
209
321
 
210
322
  def build_instance_definition(class_name)
@@ -76,11 +76,20 @@ module Rigor
76
76
  # list of `sig/`-style directories. When `nil` (the default),
77
77
  # the canonical project layout `<root>/sig` is used if it
78
78
  # exists, otherwise no signature path is loaded.
79
+ # @param cache_store [Rigor::Cache::Store, nil] persistent cache
80
+ # threaded into the underlying {Environment::RbsLoader} so
81
+ # constant lookups (and, in later v0.0.9 slices, other
82
+ # reflection artefacts) consult the cache. Pass `nil` (the
83
+ # default) to skip caching for this environment.
79
84
  # @return [Rigor::Environment]
80
- def for_project(root: Dir.pwd, libraries: [], signature_paths: nil)
85
+ def for_project(root: Dir.pwd, libraries: [], signature_paths: nil, cache_store: nil)
81
86
  resolved_paths = signature_paths || default_signature_paths(root)
82
87
  merged_libraries = (DEFAULT_LIBRARIES + libraries.map(&:to_s)).uniq
83
- loader = RbsLoader.new(libraries: merged_libraries, signature_paths: resolved_paths)
88
+ loader = RbsLoader.new(
89
+ libraries: merged_libraries,
90
+ signature_paths: resolved_paths,
91
+ cache_store: cache_store
92
+ )
84
93
  new(rbs_loader: loader)
85
94
  end
86
95
 
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rigor
4
+ # The public packaging of a flow contribution at a single call edge.
5
+ # Plugins, `RBS::Extended` annotations, and built-in narrowing rules
6
+ # all hand the analyzer this same bundle shape; the inference engine
7
+ # merges contributions through the policy described in
8
+ # [ADR-2 § "Plugin Contribution Merging"](../../docs/adr/2-extension-api.md)
9
+ # rather than letting any one source override another silently.
10
+ #
11
+ # Eight content slots plus a {Provenance} block. A slot left as `nil`
12
+ # (or, for collection-shaped slots, an empty collection) means the
13
+ # contribution does not assert anything in that dimension; the merge
14
+ # policy treats it as absent.
15
+ #
16
+ # The struct is the only shape plugin authors need to learn. Richer
17
+ # or more permissive shapes are not part of the first public
18
+ # contract — see ADR-2 § "Flow Contribution Bundle" for the binding
19
+ # definition.
20
+ #
21
+ # The element-list flattening (`to_element_list`) ADR-2 mentions is
22
+ # intentionally not implemented yet: it is the analyzer-internal
23
+ # bookkeeping behind the merge policy and will land alongside the
24
+ # plugin contribution merger in v0.1.0. Plugin authors should not
25
+ # rely on it.
26
+ class FlowContribution
27
+ # Provenance carries the metadata every contribution needs for
28
+ # diagnostic attribution and cache invalidation. `source_family`
29
+ # mirrors {Rigor::Analysis::Diagnostic::DEFAULT_SOURCE_FAMILY};
30
+ # `descriptor` is the {Rigor::Cache::Descriptor} this
31
+ # contribution attaches to (or `nil` when the contribution does
32
+ # not need its own cache slice).
33
+ Provenance = Data.define(:source_family, :plugin_id, :node, :descriptor) do
34
+ def self.builtin
35
+ new(source_family: :builtin, plugin_id: nil, node: nil, descriptor: nil)
36
+ end
37
+ end
38
+
39
+ SLOT_NAMES = %i[
40
+ return_type
41
+ truthy_facts
42
+ falsey_facts
43
+ post_return_facts
44
+ mutations
45
+ invalidations
46
+ exceptional
47
+ role_conformance
48
+ ].freeze
49
+
50
+ attr_reader(*SLOT_NAMES, :provenance)
51
+
52
+ # @param return_type [Object, nil] normal-edge return type. Use
53
+ # `nil` when the contribution does not refine the return type
54
+ # selected from the RBS contract.
55
+ # @param truthy_facts [Array, nil] facts that hold only on the
56
+ # truthy control-flow edge. Edge-local: a truthy-edge fact does
57
+ # NOT imply its falsey-edge complement (ADR-2 § "Plugin
58
+ # Contribution Merging").
59
+ # @param falsey_facts [Array, nil] dual of `truthy_facts`.
60
+ # @param post_return_facts [Array, nil] facts that hold after the
61
+ # call returns normally on every edge — the carrier for
62
+ # assertion-style contributions.
63
+ # @param mutations [Array, nil] receiver and argument mutation
64
+ # effects.
65
+ # @param invalidations [Array, nil] targeted fact invalidations
66
+ # beyond what mutation effects already imply.
67
+ # @param exceptional [Object, nil] non-returning, raising, or
68
+ # unreachable effect.
69
+ # @param role_conformance [Array, nil] capability-role conformance
70
+ # facts the contribution provides.
71
+ # @param provenance [Provenance] source-family, plugin-id, node,
72
+ # and cache-descriptor metadata. Defaults to `Provenance.builtin`.
73
+ # rubocop:disable Metrics/ParameterLists
74
+ def initialize(return_type: nil, truthy_facts: nil, falsey_facts: nil,
75
+ post_return_facts: nil, mutations: nil, invalidations: nil,
76
+ exceptional: nil, role_conformance: nil,
77
+ provenance: Provenance.builtin)
78
+ # rubocop:enable Metrics/ParameterLists
79
+ @return_type = return_type
80
+ @truthy_facts = freeze_collection(truthy_facts)
81
+ @falsey_facts = freeze_collection(falsey_facts)
82
+ @post_return_facts = freeze_collection(post_return_facts)
83
+ @mutations = freeze_collection(mutations)
84
+ @invalidations = freeze_collection(invalidations)
85
+ @exceptional = exceptional
86
+ @role_conformance = freeze_collection(role_conformance)
87
+ @provenance = provenance
88
+ freeze
89
+ end
90
+
91
+ # @return [Boolean] true when every content slot is unset (nil or
92
+ # an empty collection). Provenance does not count toward
93
+ # emptiness — an empty bundle still carries source attribution.
94
+ def empty?
95
+ SLOT_NAMES.all? { |slot| slot_empty?(public_send(slot)) }
96
+ end
97
+
98
+ def to_h
99
+ SLOT_NAMES.each_with_object(provenance: provenance.to_h) do |slot, acc|
100
+ acc[slot] = public_send(slot)
101
+ end
102
+ end
103
+
104
+ def ==(other)
105
+ other.is_a?(FlowContribution) && to_h == other.to_h
106
+ end
107
+ alias eql? ==
108
+
109
+ def hash
110
+ to_h.hash
111
+ end
112
+
113
+ private
114
+
115
+ def freeze_collection(value)
116
+ return nil if value.nil?
117
+
118
+ value.dup.freeze
119
+ end
120
+
121
+ def slot_empty?(value)
122
+ return true if value.nil?
123
+ return value.empty? if value.respond_to?(:empty?)
124
+
125
+ false
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Encoding` catalog. Singleton — load once, consult during
9
+ # dispatch.
10
+ #
11
+ # Encoding instances are deep-frozen value objects: once
12
+ # registered, their `name` / `dummy?` / `ascii_compatible?`
13
+ # slots never change and the C bodies for the per-instance
14
+ # methods are pure. The C-body classifier therefore lands
15
+ # every instance method as `:leaf` correctly.
16
+ #
17
+ # The blocklist focuses on the *singleton* surface where the
18
+ # hidden state is the process-wide encoding registry. Every
19
+ # method classified `:leaf` on the singleton actually reads
20
+ # (or, for the setters, writes) a global, so a hypothetical
21
+ # `Constant<Encoding>`-class receiver MUST NOT fold them
22
+ # against the analyzer process's registry — what UTF-8's
23
+ # alias list is in the analyzer is not necessarily what it
24
+ # is in the analysed program.
25
+ ENCODING_CATALOG = MethodCatalog.new(
26
+ path: File.expand_path(
27
+ "../../../../data/builtins/ruby_core/encoding.yml",
28
+ __dir__
29
+ ),
30
+ mutating_selectors: {
31
+ "Encoding" => Set[
32
+ # Defence-in-depth: mirrors range_catalog.rb /
33
+ # complex_catalog.rb. Encoding does not currently
34
+ # expose a public `initialize_copy` (Encoding objects
35
+ # are deep-frozen and #dup is a no-op), but the
36
+ # convention keeps the door closed against future
37
+ # CRuby changes that would leak a copy-mutator.
38
+ :initialize_copy,
39
+ :hash,
40
+ :eql?,
41
+ # `Encoding.find(name)` walks the global encoding
42
+ # registry. Pure with respect to its argument but
43
+ # the registry itself can drift (load-order, locale,
44
+ # process-wide `default_external=` calls), so a
45
+ # constant-fold would lock in the analyzer's view.
46
+ :find,
47
+ # `Encoding.list` / `Encoding.aliases` /
48
+ # `Encoding.name_list` enumerate the same global
49
+ # registry. Same reasoning as `find` — the values
50
+ # are not guaranteed to match the analysed program's
51
+ # registry.
52
+ :list,
53
+ :aliases,
54
+ :name_list,
55
+ # Global-default mutators. `MethodCatalog#blocked?`
56
+ # only auto-blocks `!`-suffixed selectors, so we MUST
57
+ # list these explicitly: each writes the process-wide
58
+ # default-encoding slot read by `default_external` /
59
+ # `default_internal`.
60
+ :default_external=,
61
+ :default_internal=
62
+ ]
63
+ }
64
+ )
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Exception` catalog. Singleton — load once, consult during
9
+ # dispatch.
10
+ #
11
+ # Exception is the base of every Ruby error class (RuntimeError,
12
+ # StandardError, KeyError, …). The Init_Exception block in
13
+ # `references/ruby/error.c` registers the entire hierarchy in
14
+ # one pass, so the YAML carries 27 classes — but only the base
15
+ # `Exception` row is wired into `CATALOG_BY_CLASS` for v0.0.5.
16
+ # A `RuntimeError` receiver hits the Exception arm via
17
+ # `is_a?(Exception)` and the catalog answers with the base-class
18
+ # entries; subclass-specific methods (`KeyError#receiver`,
19
+ # `NameError#name`, …) intentionally miss the lookup until a
20
+ # later slice routes per-subclass class_names.
21
+ #
22
+ # The catalog tier here is *defence in depth* — every base
23
+ # method that could plausibly fold has been weighed against the
24
+ # robustness principle (strict on returns) and either left
25
+ # `:dispatch` / `:mutates_self` (in which case the catalog
26
+ # already declines) or blocklisted because the static classifier
27
+ # missed an indirect side effect. The remaining `:leaf` method
28
+ # that DOES fold is `#cause`, a pure accessor.
29
+ EXCEPTION_CATALOG = MethodCatalog.new(
30
+ path: File.expand_path(
31
+ "../../../../data/builtins/ruby_core/exception.yml",
32
+ __dir__
33
+ ),
34
+ mutating_selectors: {
35
+ "Exception" => Set[
36
+ # `exc_initialize` writes `mesg` / `backtrace` ivars on
37
+ # self via `rb_ivar_set` — the C-body classifier missed
38
+ # the indirect mutator because the helpers are not in
39
+ # its regex. Blocklisted so a hypothetical future
40
+ # `Constant<Exception>` carrier cannot fold an aliasing
41
+ # constructor through the catalog.
42
+ :initialize,
43
+ # `exc_exception` either returns self (no-arg) or calls
44
+ # `rb_obj_clone` + `exc_initialize_internal` on the
45
+ # clone — the clone branch mutates fresh state through
46
+ # the same indirect helpers as `:initialize`. Conservative
47
+ # blocklist; the cost is one folded no-arg call.
48
+ :exception,
49
+ # `exc_detailed_message` formats with platform / locale
50
+ # data (highlight markers depend on `$stderr.tty?` via
51
+ # the keyword arg default and `rb_io_tty_p`). Folding
52
+ # would freeze a value that depends on the calling
53
+ # process's stderr state.
54
+ :detailed_message,
55
+ # `exc_backtrace` reads the captured frame list, which
56
+ # depends on where the exception was raised — context
57
+ # the static fold tier cannot reproduce.
58
+ :backtrace,
59
+ # Same rationale as `:backtrace`; `Thread::Backtrace::Location`
60
+ # objects are runtime artefacts.
61
+ :backtrace_locations,
62
+ # `exc_set_backtrace` mutates the @backtrace ivar via
63
+ # `rb_ivar_set` — another indirect mutator the classifier
64
+ # missed.
65
+ :set_backtrace,
66
+ # `initialize_copy` is blocklisted by convention so a
67
+ # hypothetical future `Constant<Exception>` carrier
68
+ # cannot fold an aliasing copy through the catalog.
69
+ :initialize_copy,
70
+ # Defensive entries for the universal mutation surface.
71
+ # Object-identity hashing on a constant carrier is fine,
72
+ # but `eql?` on Exception delegates to `==` (dispatch);
73
+ # blocking both keeps the constant-fold tier honest.
74
+ :hash,
75
+ :eql?
76
+ ],
77
+ # `Exception.to_tty?` (singleton) calls
78
+ # `rb_io_tty_p($stderr)`; its return depends on the
79
+ # process's stderr state at runtime, never on compile-time
80
+ # arguments. The catalog tier today only consults
81
+ # `mutating_selectors` for instance-receiver dispatches via
82
+ # `CATALOG_BY_CLASS`, so this row is documentation-grade —
83
+ # it records the soundness rationale for any future slice
84
+ # that wires the singleton path through the catalog.
85
+ "Exception.singleton" => Set[
86
+ :to_tty?
87
+ ]
88
+ }
89
+ )
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "method_catalog"
4
+
5
+ module Rigor
6
+ module Inference
7
+ module Builtins
8
+ # `Proc` / `Method` / `UnboundMethod` catalog. Singleton —
9
+ # load once, consult during dispatch.
10
+ #
11
+ # The three callable carriers are imported together because
12
+ # `Init_Proc` registers them in a single C init block. They
13
+ # share the same fundamental hazard at the catalog tier:
14
+ # most of their public methods invoke the wrapped Ruby code
15
+ # (the proc body, the bound method's receiver, …) and that
16
+ # code can do anything — read mutable state, call I/O, return
17
+ # different values on successive calls. The static C-body
18
+ # classifier marks these `:leaf` because the C functions
19
+ # themselves do not call `rb_funcall*` / `rb_yield` directly
20
+ # (they delegate through the VM's optimised call paths and
21
+ # method-entry table), but folding any of them at compile
22
+ # time would freeze a value the runtime never actually
23
+ # produces twice.
24
+ #
25
+ # The blocklist below errs aggressively on the side of
26
+ # caution: a hypothetical future `Constant<Proc>` /
27
+ # `Constant<Method>` / `Constant<UnboundMethod>` carrier
28
+ # would have very little to gain from these folds and a
29
+ # great deal to lose if user code ran behind the analyzer's
30
+ # back. Reflective readers (`#arity`, `#parameters`,
31
+ # `#source_location`, `#name`, `#owner`, `#receiver`) remain
32
+ # foldable; the RBS tier still resolves return types for
33
+ # the blocklisted methods so callers do not lose precision.
34
+ PROC_CATALOG = MethodCatalog.new(
35
+ path: File.expand_path(
36
+ "../../../../data/builtins/ruby_core/proc.yml",
37
+ __dir__
38
+ ),
39
+ mutating_selectors: {
40
+ "Proc" => Set[
41
+ # `#call` / `#[]` / `#===` / `#yield` invoke the proc
42
+ # body. The C body routes through
43
+ # `OPTIMIZED_METHOD_TYPE_CALL` (a VM fast path the
44
+ # classifier cannot see into); the proc body can do
45
+ # anything — read globals, mutate captured locals,
46
+ # raise. MUST decline to fold.
47
+ :call,
48
+ :[],
49
+ :===,
50
+ :yield,
51
+ # `#curry` / `#<<` / `#>>` allocate a fresh `Proc`
52
+ # that closes over the receiver (and, for `<<` /
53
+ # `>>`, over the argument). Folding would freeze a
54
+ # specific `Proc` instance whose identity the runtime
55
+ # never actually produces (object_id differs every
56
+ # call), so the catalog tier declines.
57
+ :curry,
58
+ :<<,
59
+ :>>,
60
+ # `#to_proc` returns `self` for `Proc` (cheap), but
61
+ # blocking it keeps the rule shape uniform across the
62
+ # three callable carriers (Method#to_proc allocates a
63
+ # fresh `Proc`).
64
+ :to_proc,
65
+ # Identity-based equality and hashing: `#hash` is
66
+ # derived from the underlying ISeq pointer; `#==` /
67
+ # `#eql?` compare ISeq + binding. Folding to a
68
+ # `Constant<Integer>` / `Constant<bool>` would freeze
69
+ # an answer that depends on memory layout. Defensive.
70
+ :hash,
71
+ :==,
72
+ :eql?,
73
+ # `initialize_copy` is blocklisted by convention so a
74
+ # hypothetical future `Constant<Proc>` carrier cannot
75
+ # fold an aliasing copy through the catalog.
76
+ :initialize_copy
77
+ ],
78
+ "Method" => Set[
79
+ # `#call` / `#[]` / `#===` invoke the bound method.
80
+ # Same hazard as `Proc#call`: arbitrary user code,
81
+ # arbitrary side effects.
82
+ :call,
83
+ :[],
84
+ :===,
85
+ # `#curry` / `#<<` / `#>>` allocate a fresh `Proc`
86
+ # that closes over the bound method.
87
+ :curry,
88
+ :<<,
89
+ :>>,
90
+ # `#to_proc` allocates a fresh `Proc` wrapping the
91
+ # bound method — folding would freeze its object_id.
92
+ # The classifier already marks it `:block_dependent`,
93
+ # but the explicit entry keeps the intent obvious.
94
+ :to_proc,
95
+ # `#unbind` allocates a fresh `UnboundMethod` whose
96
+ # identity differs every call.
97
+ :unbind,
98
+ # Identity-based equality and hashing.
99
+ :hash,
100
+ :==,
101
+ :eql?,
102
+ # `initialize_copy` is blocklisted by convention.
103
+ :initialize_copy
104
+ ],
105
+ "UnboundMethod" => Set[
106
+ # `#bind` allocates a fresh `Method` whose object_id
107
+ # differs every call; `#bind_call` invokes the bound
108
+ # method (already classified `:block_dependent`).
109
+ :bind,
110
+ :bind_call,
111
+ # Identity-based equality and hashing.
112
+ :hash,
113
+ :==,
114
+ :eql?,
115
+ # `initialize_copy` is blocklisted by convention.
116
+ :initialize_copy
117
+ ]
118
+ }
119
+ )
120
+ end
121
+ end
122
+ end