Module: Rigor::Inference::Builtins

Defined in:
lib/rigor/inference/builtins/re_catalog.rb,
lib/rigor/inference/builtins/set_catalog.rb,
lib/rigor/inference/builtins/date_catalog.rb,
lib/rigor/inference/builtins/hash_catalog.rb,
lib/rigor/inference/builtins/proc_catalog.rb,
lib/rigor/inference/builtins/time_catalog.rb,
lib/rigor/inference/builtins/array_catalog.rb,
lib/rigor/inference/builtins/range_catalog.rb,
lib/rigor/inference/builtins/method_catalog.rb,
lib/rigor/inference/builtins/random_catalog.rb,
lib/rigor/inference/builtins/string_catalog.rb,
lib/rigor/inference/builtins/struct_catalog.rb,
lib/rigor/inference/builtins/complex_catalog.rb,
lib/rigor/inference/builtins/numeric_catalog.rb,
lib/rigor/inference/builtins/encoding_catalog.rb,
lib/rigor/inference/builtins/pathname_catalog.rb,
lib/rigor/inference/builtins/rational_catalog.rb,
lib/rigor/inference/builtins/exception_catalog.rb,
lib/rigor/inference/builtins/comparable_catalog.rb,
lib/rigor/inference/builtins/enumerable_catalog.rb

Defined Under Namespace

Classes: MethodCatalog, NumericCatalog

Constant Summary collapse

REGEXP_CATALOG =

Regexp / MatchData catalog. Singleton — load once, consult during dispatch.

Init_Regexp in references/ruby/re.c registers BOTH classes in a single C init block, so the catalog carries both — Regexp (the pattern carrier) plus MatchData (the result-of-match carrier produced by Regexp#match / String#match and consulted via $~). The catalog wiring therefore mostly governs:

  1. The reader surface on each class (Regexp#source, Regexp#options, Regexp#casefold?, MatchData#size, MatchData#captures, etc.) — RBS-declared returns are preserved through dispatch.
  2. The blocklist below, which keeps methods that touch process-global state (the $~ backref) from being folded. Regexp matching is observably stateful: Regexp#=~, #=== and #~ all call rb_backref_set (writing $~ and the $1..$N / $& / $` / $' aliases). A constant-fold that dropped those calls would silently change the visible state of the program, so they MUST decline through to the RBS tier.

Regexp.last_match and Regexp.timeout / Regexp.timeout= are class-level (singleton) methods that also touch process-global state, but the dispatcher's catalog lookup only consults :instance entries today — class-method calls on a Singleton receiver type take the meta_* path in MethodDispatcher rather than walking CATALOG_BY_CLASS — so listing them here would be dead code. Their RBS-tier signatures already widen the answer enough to keep the behaviour sound; revisit if the dispatcher ever grows a singleton-aware catalog path.

MethodCatalog.for_topic(
  "re",
  mutating_selectors: {
    "Regexp" => Set[
      # Defensive: aliasing-copy semantics already covered by the `:mutates_self` classifier,
      # listed here for symmetry with String / Array / Hash / Range / Set.
      :initialize_copy,
      # `=~`, `===`, `~` all run `rb_reg_search` (or call `rb_backref_set(Qnil)` directly) — every
      # successful OR failing match writes `$~` and the `$1..$N` / `$&` / `` $` `` / `$'` aliases.
      # Folding would discard the visible side effect.
      :=~,
      :"===",
      :~,
      # `match` is already `:block_dependent` (the C body yields), but it ALSO writes `$~`
      # regardless of the block. Listed here so a future extractor that reclassifies it as `:leaf`
      # (because the yield is behind a helper) does not silently fold it.
      :match
    ],
    "MatchData" => Set[
      # Defensive entry mirroring the other catalogs. `match_init_copy` is already `:leaf` per the
      # extractor (it copies the regs slot in place but uses no helper the C-body regex flags as a
      # mutator); blocked so a future `Constant<MatchData>` carrier never folds an aliasing copy
      # through the catalog.
      :initialize_copy
    ]
  }
)
SET_CATALOG =

Set catalog. Singleton — load once, consult during dispatch.

Set was rewritten in C and folded into CRuby for Ruby 3.2+; the reference branch (ruby_4_0) ships the implementation in references/ruby/set.c with Init_Set registering every method directly. There is no set.rb prelude — the trailing rb_provide("set.rb") makes require "set" a no-op against the built-in.

The blocklist below catches the catalog :leaf entries the C-body classifier mis-attributes. Set's iteration helpers (set_iter, RETURN_SIZED_ENUMERATOR) and its identity-mode and reset paths drive into helpers the regex classifier does not yet recognise as block-yielding or mutating.

MethodCatalog.for_topic(
  "set",
  mutating_selectors: {
    "Set" => Set[
      # Indirect mutators classified `:leaf` because the C classifier did not follow the helper
      # functions:
      #
      # - `initialize_copy` calls `set_copy` to overwrite the receiver's table.
      # - `compare_by_identity` swaps the internal hash type via `set_reset_table_with_type`.
      # - `reset` rebuilds the internal table to dedup after element mutation.
      :initialize_copy, :compare_by_identity, :reset,
      # Block-dependent methods classified `:leaf` because the C body uses `set_iter` /
      # `RETURN_SIZED_ENUMERATOR` rather than calling `rb_yield` directly:
      :each, :classify, :divide,
      # `disjoint?` delegates into `set_i_intersect`, which for non-Set enumerables uses
      # `rb_funcall(other, :any?, ...)` — that is user-redefinable dispatch the classifier missed
      # because the call site is in a sibling function.
      :disjoint?
    ]
  }
)
DATE_CATALOG =

Date / DateTime catalog. Singleton — load once, consult during dispatch.

Date and DateTime both come from CRuby's bundled date gem (references/ruby/ext/date/date_core.c). A single Init_date_core function registers them, so the catalog carries both classes — Date plus the DateTime subclass whose own Init block extends with hour / min / strftime / iso8601 etc. The Ruby-side prelude (lib/date.rb) only contributes Date#infinite? and the nested Date::Infinity class; the bulk of the surface is in C.

Date / DateTime receivers are not lifted to a Constant carrier today (there is no Date literal node — the closest is Date.today / Date.parse(...), which produce Nominal[Date]). The catalog wiring therefore mostly governs:

  1. The Integer-typed reader surface (#year, #month, #day, #wday, #hour, #min, #sec) — RBS-declared Integer is preserved through dispatch.
  2. The blocklist below, which keeps mutator-style methods that the C-body classifier already flagged (mutates_self) from being missed by a future Constant<Date> carrier, plus a defensive :initialize_copy entry for symmetry with the other catalogs.

The non-bang #next_day / #prev_day / #next_month / #prev_month / #next_year / #prev_year / #>> / #<< selectors all RETURN brand-new Date objects rather than mutating the receiver — they intentionally stay catalog-eligible. The two real mutators (#initialize_copy, #marshal_load) are already classified :mutates_self by the C-body regex, so they fall out of MethodCatalog#safe_for_folding? without an explicit blocklist entry; the entries below are defense-in-depth against indirect mutators the regex might miss in a future CRuby bump.

MethodCatalog.for_topic(
  "date",
  mutating_selectors: {
    "Date" => Set[
      # `d_lite_initialize_copy` is already classed `:mutates_self` by the regex (it calls
      # `rb_check_frozen` and rewrites the receiver's internal `dat` slots). Listed here for
      # symmetry with String / Array / Range / Set / Time and to keep the blocklist
      # self-documenting.
      :initialize_copy,
      # `d_lite_fill` is a `#ifndef NDEBUG` debug method that warms the receiver's cached `simple` /
      # `complex` fields via the `get_s_*` / `get_c_*` macros. The macros perform in-place writes on
      # the receiver's internal `dat` struct but use no helper the C-body regex recognises, so the
      # classifier mis-flags it `:leaf`. Blocked so a future `Constant<Date>` carrier never folds it.
      :fill
    ],
    "DateTime" => Set[
      # `DateTime` inherits the bulk of its surface from `Date`. The dedicated DateTime-side methods
      # are all readers (`hour`, `min`, …) plus formatting converters (`strftime`, `iso8601`, …);
      # none mutate the receiver. The single defensive entry mirrors the Date side so that the
      # inherited `Date#initialize_copy` (registered against `cDateTime` through subclassing) cannot
      # fold through the catalog if a future `Constant<DateTime>` carrier ever lands.
      :initialize_copy
    ]
  }
)
HASH_CATALOG =

Hash catalog. Singleton — load once, consult during dispatch.

Hash mirrors Array's mutation pattern: nearly every iteration method yields through rb_hash_foreach plus a per-pair static callback (each_value_i, keep_if_i, …), and the C-body classifier does not follow into the callback so it lands as :leaf despite being block-dependent. The blocklist below captures every false-positive :leaf we have spotted in the generated YAML — bias toward conservatism so a missed fold is acceptable but a folded mutator/yielder is not.

MethodCatalog.for_topic(
  "hash",
  mutating_selectors: {
    "Hash" => Set[
      # Block-dependent iteration — yields via `rb_hash_foreach` plus a per-pair callback that the
      # regex classifier does not follow:
      :each, :each_pair, :each_key, :each_value,
      :select, :filter, :reject,
      :transform_values,
      # Block-dependent merge — `rb_hash_merge` delegates into `rb_hash_update`, which yields per
      # conflict when a block is given:
      :merge
    ]
  }
)
PROC_CATALOG =

Proc / Method / UnboundMethod catalog. Singleton — load once, consult during dispatch.

The three callable carriers are imported together because Init_Proc registers them in a single C init block. They share the same fundamental hazard at the catalog tier: most of their public methods invoke the wrapped Ruby code (the proc body, the bound method's receiver, …) and that code can do anything — read mutable state, call I/O, return different values on successive calls. The static C-body classifier marks these :leaf because the C functions themselves do not call rb_funcall* / rb_yield directly (they delegate through the VM's optimised call paths and method-entry table), but folding any of them at compile time would freeze a value the runtime never actually produces twice.

The blocklist below errs aggressively on the side of caution: a hypothetical future Constant<Proc> / Constant<Method> / Constant<UnboundMethod> carrier would have very little to gain from these folds and a great deal to lose if user code ran behind the analyzer's back. Reflective readers (#arity, #parameters, #source_location, #name, #owner, #receiver) remain foldable; the RBS tier still resolves return types for the blocklisted methods so callers do not lose precision.

MethodCatalog.for_topic(
  "proc",
  mutating_selectors: {
    "Proc" => Set[
      # `#call` / `#[]` / `#===` / `#yield` invoke the proc body. The C body routes through
      # `OPTIMIZED_METHOD_TYPE_CALL` (a VM fast path the classifier cannot see into); the proc body
      # can do anything — read globals, mutate captured locals, raise. MUST decline to fold.
      :call,
      :[],
      :===,
      :yield,
      # `#curry` / `#<<` / `#>>` allocate a fresh `Proc` that closes over the receiver (and, for
      # `<<` / `>>`, over the argument). Folding would freeze a specific `Proc` instance whose
      # identity the runtime never actually produces (object_id differs every call), so the catalog
      # tier declines.
      :curry,
      :<<,
      :>>,
      # `#to_proc` returns `self` for `Proc` (cheap), but blocking it keeps the rule shape uniform
      # across the three callable carriers (Method#to_proc allocates a fresh `Proc`).
      :to_proc,
      # Identity-based equality and hashing: `#hash` is derived from the underlying ISeq pointer;
      # `#==` / `#eql?` compare ISeq + binding. Folding to a `Constant<Integer>` / `Constant<bool>`
      # would freeze an answer that depends on memory layout. Defensive.
      :hash,
      :==,
      :eql?,
      # `initialize_copy` is blocklisted by convention so a hypothetical future `Constant<Proc>`
      # carrier cannot fold an aliasing copy through the catalog.
      :initialize_copy
    ],
    "Method" => Set[
      # `#call` / `#[]` / `#===` invoke the bound method. Same hazard as `Proc#call`: arbitrary user
      # code, arbitrary side effects.
      :call,
      :[],
      :===,
      # `#curry` / `#<<` / `#>>` allocate a fresh `Proc` that closes over the bound method.
      :curry,
      :<<,
      :>>,
      # `#to_proc` allocates a fresh `Proc` wrapping the bound method — folding would freeze its
      # object_id. The classifier already marks it `:block_dependent`, but the explicit entry keeps
      # the intent obvious.
      :to_proc,
      # `#unbind` allocates a fresh `UnboundMethod` whose identity differs every call.
      :unbind,
      # Identity-based equality and hashing.
      :hash,
      :==,
      :eql?,
      # `initialize_copy` is blocklisted by convention.
      :initialize_copy
    ],
    "UnboundMethod" => Set[
      # `#bind` allocates a fresh `Method` whose object_id differs every call; `#bind_call` invokes
      # the bound method (already classified `:block_dependent`).
      :bind,
      :bind_call,
      # Identity-based equality and hashing.
      :hash,
      :==,
      :eql?,
      # `initialize_copy` is blocklisted by convention.
      :initialize_copy
    ]
  }
)
TIME_CATALOG =

Time catalog. Singleton — load once, consult during dispatch.

Time is a pure-C built-in: the Init block in references/ruby/time.c registers the bulk of the surface, and the Ruby-side prelude references/ruby/timev.rb contributes the class-side constructors (Time.now, Time.at, Time.new) through Primitive cexpr stubs.

Time receivers are not lifted to a Constant carrier today (there is no Time literal node — the closest is Time.now / Time.new(...), which produce Nominal[Time]). The catalog wiring therefore mostly governs:

  1. The size-projection-equivalent reader surface (#year, #month, #hour, #sec, #wday, …) — RBS-declared Integer is preserved through dispatch.
  2. The blocklist below, which keeps the indirect-mutator methods that the C-body classifier mis-flagged as :leaf from ever folding through a hypothetical future Constant<Time> carrier.

The blocklist captures the false-positive :leaf entries whose helper functions the regex classifier did not recognise as mutators.

MethodCatalog.for_topic(
  "time",
  mutating_selectors: {
    "Time" => Set[
      # `time_init_copy` writes the `timew` and `vtm` slots on the receiver via `time_set_timew` /
      # `time_set_vtm`. Classed `:leaf` because those setters are not in the mutator regex's helper
      # list. Blocked for symmetry with String / Array / Range / Set initialize_copy entries.
      :initialize_copy,
      # `time_localtime_m` -> `time_localtime` calls `time_modify(time)` to mark the receiver
      # mutable before rewriting its `vtm` cache and `tzmode`. The docstring is explicit ("converts
      # time to local time in place"). The C-body classifier mis-flagged it as `:leaf` because
      # `time_modify` is not in its mutator regex.
      :localtime,
      # `time_gmtime` (registered as both `gmtime` and `utc` against `rb_cTime`) follows the same
      # in-place pattern as `time_localtime`: `time_modify(time)` then a `time_set_vtm` write and
      # `TZMODE_SET_UTC`. Both selectors share the cfunc, so both must be blocked.
      :gmtime, :utc,
      # `getlocal` is not a mutator — it returns a fresh Time — but the fresh Time is pinned to the
      # *analysis machine's* timezone. Folding it through a `Constant[Time]` carrier (which only
      # ever holds a UTC literal from `Time.utc`) would bake a host-dependent wall clock /
      # `utc_offset` into the inferred type. Blocked so the fold stays machine-independent; the RBS
      # tier answers `Nominal[Time]`. `getutc` / `getgm` stay foldable — their result is UTC.
      :getlocal
    ]
  }
)
ARRAY_CATALOG =

Array catalog. Singleton — load once, consult during dispatch.

Array has more mutation surface than String: every method that logically reshapes the array tends to call rb_ary_modify or an internal helper (ary_replace, ary_resize, ary_pop, ary_push_internal, …) that the classifier does not yet recognise. The blocklist captures the methods we have specifically observed flowing as :leaf despite mutating.

MethodCatalog.for_topic(
  "array",
  mutating_selectors: {
    "Array" => Set[
      # Mutators classified `:leaf` by the C-body heuristic
      :<<, :push, :replace, :clear, :concat, :insert, :"[]=",
      :unshift, :prepend, :pop, :shift, :delete_at, :slice!,
      :compact!, :flatten!, :uniq!, :sort!, :reverse!,
      :rotate!, :keep_if, :delete_if, :select!, :filter!,
      :reject!, :collect!, :map!, :assoc, :rassoc,
      :fill, :delete, :transpose,
      # Methods that yield (block-dependent) — classifier
      # may mark them leaf when the block call is gated:
      :each, :each_with_index, :each_index, :each_slice,
      :each_cons, :each_with_object,
      # Identity/comparison methods that take a block too
      :max, :min, :max_by, :min_by, :minmax, :minmax_by,
      :sort_by, :group_by, :partition, :all?, :any?, :none?,
      :one?, :find, :detect, :find_all, :find_index,
      :reduce, :inject, :flat_map, :collect_concat,
      :zip, :product, :combination, :permutation,
      :chunk_while, :slice_when, :tally
    ]
  }
)
RANGE_CATALOG =

Range catalog. Singleton — load once, consult during dispatch.

Range is largely immutable: begin, end, and excl are set at construction by range_initialize and never mutated afterwards. The blocklist below therefore stays small. The entries we DO need are the iteration methods whose C body routes through a helper the block/yield regex does not recognise, so the classifier mis-flags them as :leaf despite yielding to a block.

MethodCatalog.for_topic(
  "range",
  mutating_selectors: {
    "Range" => Set[
      # `range_initialize` / `range_initialize_copy` write `begin`/`end`/`excl` slots on the
      # receiver; classed `:leaf` because the writes go through the struct accessor not
      # `rb_check_frozen`. Blocked for symmetry with String / Array.
      :initialize, :initialize_copy,
      # `range_reverse_each` yields to its block via `range_each_func` -> caller's block; the regex
      # classifier follows direct `rb_yield*` calls only.
      :reverse_each,
      # `range_percent_step` returns an Enumerator unless a block is supplied, in which case it
      # yields. Treated as block-dependent so the fold tier never invokes it against a literal Range
      # and tries to materialise an Enumerator into a Constant.
      :%
    ]
  }
)
RANDOM_CATALOG =

Random catalog. Singleton — load once, consult during dispatch.

The static classifier marks most Random methods :leaf because their C bodies do not call rb_funcall* / rb_yield / rb_check_frozen directly. Random is the canonical case where that heuristic under-counts: every call to #rand / #bytes / Random.rand / Random.bytes advances the receiver's Mersenne-Twister state through a helper (rand_random -> random_real / random_ulong_limited), so folding any of them statically is unsound. Random.new_seed and Random.urandom are non-deterministic (different output every call); even though they are functionally pure they would produce a misleading constant at fold time. The whole class is conservative-by-default at the catalog tier; precision flows through the RBS layer.

MethodCatalog.for_topic(
  "random",
  mutating_selectors: {
    "Random" => Set[
      # `rand_random` -> `random_real` / `random_ulong_limited` advance the MT state on the receiver
      # (instance #rand) and on `Random::DEFAULT` (singleton .rand). The classifier misses the
      # indirect mutator.
      :rand,
      # `random_bytes` / `random_s_bytes` consume MT output the same way #rand does — every call
      # mutates the underlying generator.
      :bytes,
      # Non-deterministic: each call produces a fresh seed via `with_random_seed` reading platform
      # entropy. Folding to a constant would freeze a value that the runtime never actually returns
      # twice.
      :new_seed,
      # Non-deterministic: reads from platform CSPRNG (e.g. /dev/urandom). Folding is unsound for
      # the same reason as `new_seed`.
      :urandom,
      # `initialize_copy` is blocklisted by convention so a hypothetical future `Constant<Random>`
      # carrier cannot fold an aliasing copy through the catalog.
      :initialize_copy
    ]
  }
)
STRING_CATALOG =

String and Symbol catalog. Singleton — load once, consult during dispatch.

The blocklist below is the curated set of catalog :leaf entries the C-body classifier mis-attributes (the body of rb_str_replace calls str_modifiable / str_discard which the regex-based classifier does not recognise as mutation primitives). Adding to the blocklist is the corrective surface for false positives until the classifier learns the helper functions.

MethodCatalog.for_topic(
  "string",
  mutating_selectors: {
    "String" => Set[
      :replace, :initialize, :initialize_copy, :clear, :<<, :concat, :insert,
      :prepend, :force_encoding, :encode, :scrub, :unicode_normalize, :"[]=",
      :upto, :each_byte, :each_char, :each_codepoint,
      :each_grapheme_cluster, :each_line, :bytesplice,
      # `crypt` is not a mutator but is blocked from folding for the same "do not bake a non-pure
      # result into a Constant" reason: `rb_str_crypt` delegates to the platform `crypt(3)`, whose
      # output (algorithm and digest) varies by libc / OS, so `"x".crypt("ab")` is not deterministic
      # across the platforms an analyzed project may target. The catalog classifies it `:leaf` from
      # its C body; this entry overrides that.
      :crypt
    ],
    "Symbol" => Set[
      # Symbol is immutable in Ruby; the classifier mis-flags `inspect` because `rb_sym_inspect`
      # builds a temporary mutable buffer. Allow it.
    ]
  }
)
STRUCT_CATALOG =

Struct catalog. Singleton — load once, consult during dispatch.

Struct is a meta-class: Struct.new(*members) returns a fresh anonymous subclass — never a Struct value. Today Rigor never produces a Constant<Struct> carrier (a literal struct instance), so the catalog is defensive: it documents the shape and forbids unsafe folds in case a future tier learns to lift literal struct instances into the value lattice.

Subclasses define their own writers (name=) at class-build time, so per-instance member accessors do not appear in this YAML — only the generic [] / []= pair on the base class. []= is already classified :mutates_self; [] reads a member but the answer depends on the subclass's member definition, which the catalog does not see, so we blocklist it defensively.

MethodCatalog.for_topic(
  "struct",
  mutating_selectors: {
    "Struct" => Set[
      # Defensive: aliasing-copy semantics on a hypothetical `Constant<Struct>` carrier. Convention
      # across the other catalogs (Range, Random, Pathname).
      :initialize_copy,
      # `rb_struct_hash` mixes member values via `rb_hash` -> `rb_funcall(:hash, ...)`. The
      # classifier sees no direct dispatch because the recursion goes through `rb_hash` (a helper),
      # but the answer depends on the member values' `#hash` — user-redefinable. Block to avoid
      # folding a hash that would diverge from the runtime once a member overrides `#hash`.
      :hash,
      # `rb_struct_aref` reads a member by name or index; the answer depends on the subclass's
      # member layout, which the catalog does not carry. Folding without knowing the layout would
      # be unsound.
      :[]
    ]
  }
)
COMPLEX_CATALOG =

Complex catalog. Singleton — load once, consult during dispatch.

Complex is a fully-immutable value type in Ruby: once a complex number is constructed (via Complex(real, imag) or Complex.rect / Complex.polar) its real and imag slots are never rewritten. Every public instance method either returns self unchanged or builds a fresh Complex / Numeric. The C-body classifier already correctly flags the four :dispatch methods (<=>, to_s, inspect, rationalize) so there are no false-positive :leaf entries to override. The blocklist therefore carries only the conventional :initialize_copy defence-in-depth entry so a hypothetical future Constant<Complex> carrier cannot fold an aliasing copy through the catalog (mirrors range_catalog.rb, time_catalog.rb, date_catalog.rb).

MethodCatalog.for_topic(
  "complex",
  mutating_selectors: {
    "Complex" => Set[
      # Defence in depth: `Complex` does not currently expose a public `initialize_copy`, but
      # blocking it keeps the convention identical to every other catalog so future CRuby additions
      # cannot leak a copy-mutator through.
      :initialize_copy
    ]
  }
)
NUMERIC_CATALOG =

Numeric family catalog (Integer, Float, Rational, Complex, Numeric). Singleton — load once, consult during dispatch.

The catalog is produced offline by tool/extract_builtin_catalog.rb from the CRuby reference checkout under references/ruby plus the RBS core signatures under references/rbs. The loader is the runtime bridge: callers ask "is Integer#+ safe to invoke during constant folding?" and the answer comes straight from the offline classification (leaf / trivial / leaf_when_numeric are foldable; everything else is not).

No mutation blocklist is needed. The numeric classes expose no foldable bang or indirect-mutator method that the static C classifier mis-attributes (every :leaf numeric method is a pure value computation), so the generic MethodCatalog loader — shared with the eighteen other per-class catalogs — covers it directly. This previously hand-rolled its own safe_for_folding? / method_entry / load_catalog copy of MethodCatalog; folding it onto the shared loader also picks up alias resolution (e.g. Integer#magnitudeabs, Integer#inspectto_s), which the old bespoke loader silently dropped.

MethodCatalog.for_topic("numeric")
ENCODING_CATALOG =

Encoding catalog. Singleton — load once, consult during dispatch.

Encoding instances are deep-frozen value objects: once registered, their name / dummy? / ascii_compatible? slots never change and the C bodies for the per-instance methods are pure. The C-body classifier therefore lands every instance method as :leaf correctly.

The blocklist focuses on the singleton surface where the hidden state is the process-wide encoding registry. Every method classified :leaf on the singleton actually reads (or, for the setters, writes) a global, so a hypothetical Constant<Encoding>-class receiver MUST NOT fold them against the analyzer process's registry — what UTF-8's alias list is in the analyzer is not necessarily what it is in the analysed program.

MethodCatalog.for_topic(
  "encoding",
  mutating_selectors: {
    "Encoding" => Set[
      # Defence-in-depth: mirrors range_catalog.rb / complex_catalog.rb. Encoding does not currently
      # expose a public `initialize_copy` (Encoding objects are deep-frozen and #dup is a no-op),
      # but the convention keeps the door closed against future CRuby changes that would leak a
      # copy-mutator.
      :initialize_copy,
      :hash,
      :eql?,
      # `Encoding.find(name)` walks the global encoding registry. Pure with respect to its argument
      # but the registry itself can drift (load-order, locale, process-wide `default_external=`
      # calls), so a constant-fold would lock in the analyzer's view.
      :find,
      # `Encoding.list` / `Encoding.aliases` / `Encoding.name_list` enumerate the same global
      # registry. Same reasoning as `find` — the values are not guaranteed to match the analysed
      # program's registry.
      :list,
      :aliases,
      :name_list,
      # Global-default mutators. `MethodCatalog#blocked?` only auto-blocks `!`-suffixed selectors,
      # so we MUST list these explicitly: each writes the process-wide default-encoding slot read by
      # `default_external` / `default_internal`.
      :default_external=,
      :default_internal=
    ]
  }
)
PATHNAME_CATALOG =

Pathname catalog. Singleton — load once, consult during dispatch.

TODO(blocklist curation): read data/builtins/ruby_core/pathname.yml and add per-method blocklist entries for any :leaf classifications that are actually mutators or otherwise unsafe to fold. Each entry SHOULD carry a one-line comment naming the indirect mutator helper that triggered the false positive (see string_catalog.rb, array_catalog.rb, time_catalog.rb for the canonical shape).

MethodCatalog.for_topic(
  "pathname",
  mutating_selectors: {
    "Pathname" => Set[
    # initialize_copy is blocklisted by convention so a hypothetical future `Constant<Pathname>`
    # carrier cannot fold an aliasing copy through the catalog.
    :initialize_copy
    ]
  }
)
RATIONAL_CATALOG =

Rational catalog. Singleton — load once, consult during dispatch.

Rational is fully immutable: numerator / denominator slots are written once during nurat_s_new_internal and the C body never reaches for rb_check_frozen. Every catalog entry classifies cleanly (:leaf, :leaf_when_numeric, or :dispatch for the two methods that delegate into user-redefinable == / Float()nurat_eqeq_p and nurat_fdiv). Bang-suffixed mutators do not exist on Rational.

The blocklist therefore stays minimal. initialize_copy is added defensively (mirrors Range / Set) so a hypothetical future Constant<Rational> carrier cannot fold an aliasing copy through the catalog and surface a shared mutable handle.

MethodCatalog.for_topic(
  "rational",
  mutating_selectors: {
    "Rational" => Set[
      :initialize_copy
    ]
  }
)
EXCEPTION_CATALOG =

Exception catalog. Singleton — load once, consult during dispatch.

Exception is the base of every Ruby error class (RuntimeError, StandardError, KeyError, …). The Init_Exception block in references/ruby/error.c registers the entire hierarchy in one pass, so the YAML carries 27 classes — but only the base Exception row is wired into CATALOG_BY_CLASS for v0.0.5. A RuntimeError receiver hits the Exception arm via is_a?(Exception) and the catalog answers with the base-class entries; subclass-specific methods (KeyError#receiver, NameError#name, …) intentionally miss the lookup until a later slice routes per-subclass class_names.

The catalog tier here is defence in depth — every base method that could plausibly fold has been weighed against the robustness principle (strict on returns) and either left :dispatch / :mutates_self (in which case the catalog already declines) or blocklisted because the static classifier missed an indirect side effect. The remaining :leaf method that DOES fold is #cause, a pure accessor.

MethodCatalog.for_topic(
  "exception",
  mutating_selectors: {
    "Exception" => Set[
      # `exc_initialize` writes `mesg` / `backtrace` ivars on self via `rb_ivar_set` — the C-body
      # classifier missed the indirect mutator because the helpers are not in its regex.
      # Blocklisted so a hypothetical future `Constant<Exception>` carrier cannot fold an aliasing
      # constructor through the catalog.
      :initialize,
      # `exc_exception` either returns self (no-arg) or calls `rb_obj_clone` +
      # `exc_initialize_internal` on the clone — the clone branch mutates fresh state through the
      # same indirect helpers as `:initialize`. Conservative blocklist; the cost is one folded
      # no-arg call.
      :exception,
      # `exc_detailed_message` formats with platform / locale data (highlight markers depend on
      # `$stderr.tty?` via the keyword arg default and `rb_io_tty_p`). Folding would freeze a value
      # that depends on the calling process's stderr state.
      :detailed_message,
      # `exc_backtrace` reads the captured frame list, which depends on where the exception was
      # raised — context the static fold tier cannot reproduce.
      :backtrace,
      # Same rationale as `:backtrace`; `Thread::Backtrace::Location` objects are runtime artefacts.
      :backtrace_locations,
      # `exc_set_backtrace` mutates the @backtrace ivar via `rb_ivar_set` — another indirect
      # mutator the classifier missed.
      :set_backtrace,
      # `initialize_copy` is blocklisted by convention so a hypothetical future
      # `Constant<Exception>` carrier cannot fold an aliasing copy through the catalog.
      :initialize_copy,
      # Defensive entries for the universal mutation surface. Object-identity hashing on a constant
      # carrier is fine, but `eql?` on Exception delegates to `==` (dispatch); blocking both keeps
      # the constant-fold tier honest.
      :hash,
      :eql?
    ],
    # `Exception.to_tty?` (singleton) calls `rb_io_tty_p($stderr)`; its return depends on the
    # process's stderr state at runtime, never on compile-time arguments. The catalog tier today
    # only consults `mutating_selectors` for instance-receiver dispatches via `CATALOG_BY_CLASS`, so
    # this row is documentation-grade — it records the soundness rationale for any future slice that
    # wires the singleton path through the catalog.
    "Exception.singleton" => Set[
      :to_tty?
    ]
  }
)
COMPARABLE_CATALOG =

Comparable module catalog. Singleton — load once.

Comparable is a Ruby module, not a class, so the catalog is NOT routed through MethodDispatcher::ConstantFolding::CATALOG_BY_CLASS (which dispatches on the receiver's concrete class). The data is wired into MODULE_CATALOGS in MethodDispatcher::ConstantFolding (ancestor-chain lookup).

MethodCatalog.for_topic(
  "comparable",
  mutating_selectors: {
    "Comparable" => Set[]
  }
)
ENUMERABLE_CATALOG =

Enumerable module catalog. Singleton — load once.

Enumerable is a Ruby module, not a class, so the catalog is NOT routed through MethodDispatcher::ConstantFolding::CATALOG_BY_CLASS (which dispatches on the receiver's concrete class). The data is wired into MODULE_CATALOGS in MethodDispatcher::ConstantFolding (ancestor-chain lookup).

MethodCatalog.for_topic(
  "enumerable",
  mutating_selectors: {
    "Enumerable" => Set[]
  }
)