Built-in method typing — boilerplate / pain-point audit — 2026-06-03
Motivation
Section titled “Motivation”Internal-optimisation theme for the next version. This note inventories
the repetition, boilerplate, and hand-maintenance pain points in the
mechanism that assigns types to Ruby built-in methods, so the cleanup
can proceed in ease-of-implementation order without re-deriving the map
each session. Findings only — no behaviour change is proposed here; the
false-positive discipline rule means every cleanup below must be a
container-only refactor that leaves folded results bit-for-bit identical
(verified by make verify).
The mechanism in three layers
Section titled “The mechanism in three layers”- Catalog-loader layer —
lib/rigor/inference/builtins/*_catalog.rb(20 files). Each wraps a generated YAML catalogue (data/builtins/ruby_core/<topic>.yml) in aMethodCatalogsingleton plus a curated mutation blocklist. - Tier-D singleton-folding layer —
lib/rigor/inference/method_dispatcher/*_folding.rb. One module per pure stdlib singleton (CGI, URI, Shellwords, Math, Time, Regexp, Set, File) that evaluates the call at inference time onConstantreceivers/arguments. - Dispatch chain —
lib/rigor/inference/method_dispatcher.rbwires the tiers together in precedence order.
Each layer has accumulated structural repetition. Findings are ordered by return-on-investment (impact ÷ risk).
Finding 1 — Tier-D folding modules are a copied skeleton
Section titled “Finding 1 — Tier-D folding modules are a copied skeleton”Eight *_folding.rb modules replicate a near-identical shape:
dispatch_target?is the same one line in 9 places:receiver.is_a?(Type::Singleton) && receiver.class_name == "X"(cgi_folding.rb:74,uri_folding.rb:51,shellwords_folding.rb:81,math_folding.rb:74,time_folding.rb:51,regexp_folding.rb:42,set_folding.rb:43,file_folding.rb:110; plus theClassvariant initerator_dispatch.rb:69).- The body shape repeats:
module_function,try_dispatch(receiver:, method_name:, args:), method-nameSetearly-return guard, thenType::Combinator.constant_of(Foo.public_send(method_name, str))with arescue StandardError → nilfloor.uri_folding.rb:43-63andcgi_folding.rb:66-90are nearly character-for-character identical.
Pain. Adding a new pure singleton (Base64, Digest, …) means writing
60–120 lines of boilerplate. Easy to forget the rescue floor or
mis-pass the constant_of argument.
Direction. A single SingletonFunctionFolder driven by a
declaration table — { "URI" => %i[encode_www_form_component …], "CGI" => {…} } — covering the “single String arg, fold via public_send”
case. Keep variant handlers (CGI element escape, Shellwords Tuple
lifting) as per-entry overrides. 8 files → 1 harness + data.
Finding 2 — The dispatch chain is a hand-written || ladder
Section titled “Finding 2 — The dispatch chain is a hand-written || ladder”method_dispatcher.rb:713-720 (singleton-folding chain) and :697-704
(dispatch_precise_tiers) repeat the same receiver:/method_name:/args:
keyword triple 15+ times:
FileFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) || ShellwordsFolding.try_dispatch(receiver: receiver_type, method_name: method_name, args: arg_types) || MathFolding.try_dispatch(receiver: ...) || … # 8 entriesPain. A new tier means writing the module and hand-splicing it into the ladder. Order is significant (documented in comments) but not viewable as a list.
Direction. SINGLETON_FOLDERS = [FileFolding, ShellwordsFolding, …]
driven by SINGLETON_FOLDERS.lazy.filter_map { |m| m.try_dispatch(**ctx) }.first. Order = array order. Requires Finding 3 first.
Finding 3 — Entry-point names and signatures are not uniform
Section titled “Finding 3 — Entry-point names and signatures are not uniform”try_dispatch (cgi/uri/file/math/regexp/set/shellwords/kernel/shape)
vs try_fold (constant_folding.rb:135, block_folding.rb:72) vs
try_forward/try_backward (method_folding.rb). Signatures diverge
too: args: only / + block_type: / + environment:, call_node:, scope: (rbs_dispatch.rb:121 carries
# rubocop:disable Metrics/ParameterLists).
Pain. This is the root blocker for Finding 2’s generic iteration; the tier interface is implicit.
Direction. Unify the pure tiers on try_dispatch(receiver:, method_name:, args:) (alias-migrate). Context-hungry tiers take one
context: object, retiring the ParameterLists suppression.
Finding 4 — Catalog loaders are boilerplate + a 3-site edit
Section titled “Finding 4 — Catalog loaders are boilerplate + a 3-site edit”Each *_catalog.rb is effectively one statement
(random_catalog.rb:24, set_catalog.rb:22, …):
XXX_CATALOG = MethodCatalog.new( path: File.expand_path("../../../../data/builtins/ruby_core/<topic>.yml", __dir__), mutating_selectors: { … })File.expand_path("../../../../data/builtins/ruby_core/…", __dir__)— the../../../../is copied in 20 files; a layout change breaks all at once.- A new catalogue is a 3-site hand edit: the
require_relativeline (constant_folding.rb:4-22), theCATALOG_BY_CLASSrow (:1212-1236), and the loader file itself. The scaffold tool papers over this, but the duplication is structural.
Direction. (a) Collapse path resolution into
MethodCatalog.for_topic("set"), isolating ../../../../ to one place.
(b) Let loaders self-register (MethodCatalog.register(String, "String", topic: "string")) so the require list and CATALOG_BY_CLASS merge.
⚠️ The
mutating_selectors:blocklists and their per-selector comments (e.g.set_catalog.rb:38-48,random_catalog.rb:30-53) are genuine design knowledge — not boilerplate. They record exactly which:leafentries the static C classifier mis-attributes and why. Preserve them verbatim; only thin the container.
Finding 5 — Fixture escaping, size caps, and rescue floors re-implemented by hand
Section titled “Finding 5 — Fixture escaping, size caps, and rescue floors re-implemented by hand”Flagged by the rigor-type-coverage-uplift skill itself:
assert_typebackslash multi-escaping — fixtures hand-count'"hello\\\\ world"'; error-prone on every fold-result assertion.- Three-parameter handler convention —
def h(tuple, args)silently bindsmethod_nameintoargs. - Size caps /
rescue → nilre-implemented per folder (shellwords_folding.rbSPLIT_LIMIT = 64mirrors ConstantFolding’sSTRING_ARRAY_LIFT_LIMITby intent but is a separate constant).
Direction. Absorb caps + rescue into the Finding 1 harness. Replace hand-escaped fixture strings with a helper that derives the assert string from a Ruby value.
Summary (ROI order)
Section titled “Summary (ROI order)”| # | Pain point | Scale | Risk |
|---|---|---|---|
| 1 | Tier-D folder skeleton (8 modules, dispatch_target? ×9) | large | low (pure fns, well-tested) |
| 2 | Hand-written || dispatch ladder (triple ×15) | medium | low–med (order is a requirement) |
| 3 | Non-uniform entry names / signatures | medium | med (broad alias migration) |
| 4 | Catalog loader boilerplate + 3-site edit + ../../../../×20 | medium | low |
| 5 | Fixture escaping / cap / rescue re-implementation | small–med | low |
Biggest lever is folding 1+2+3 together: pure singleton folders behind one interface, a declaration table, and array-driven dispatch.
Execution order (ease-of-implementation)
Section titled “Execution order (ease-of-implementation)”- Finding 4 — smallest, safest, no interface change. Path helper + self-registration.
- Finding 1 —
SingletonFunctionFolderharness; migrate URI / CGI / Shellwords first as the proof, then Math / Regexp / Set / Time / File. - Finding 3 — unify entry-point interface (unblocks 2).
- Finding 2 — array-driven dispatch once entry points are uniform.
- Finding 5 — fixture-helper + cap/rescue absorption, opportunistic alongside 1.
Every step gated by make verify (test / lint / check / check-plugins)
with folded results unchanged.
Progress log (2026-06-03 → 2026-06-04)
Section titled “Progress log (2026-06-03 → 2026-06-04)”- Finding 4 — DONE (path helper). Added
MethodCatalog.for_topic(topic, mutating_selectors:)resolving under a singleDATA_ROOT; migrated all 18 instance loaders off the copiedFile.expand_path("../../../../…", __dir__). Blocklists untouched.- Self-registration sub-part — WON’T DO (decision). Merging the
require_relativelist withCATALOG_BY_CLASSvia load-order self-registration is not a clean win:CATALOG_BY_CLASSis walked in declaration order for subclass disambiguation (DateTimebeforeDate,MatchData/Regexpsharing one catalog), and that order does not matchrequireorder. Self-registration would still need explicit ordering hints, so it trades one table for another plus a load-order coupling. Left as the explicit 3-site edit. NumericCatalogconsolidation — DONE. It was the last per-class catalog with its own hand-rolledsafe_for_folding?/method_entry/load_catalogcopy ofMethodCatalog(it predated the generic loader). Replaced withNUMERIC_CATALOG = MethodCatalog.for_topic( "numeric");CATALOG_BY_CLASSInteger/Float rows repointed. Bang gate is a no-op (no foldable numeric bang methods); MethodCatalog’s alias resolution adds five sound folds (magnitude→abs,inspect→to_s, …) with no snapshot movement. ractor-readiness check now asserts the instance is shareable via theCATALOG_BY_CLASSdeep-freeze.
- Self-registration sub-part — WON’T DO (decision). Merging the
- Finding 1 — DONE. Extracted
MethodDispatcher::SingletonFolding(receiver?+constant_string); migrated all 9 receiver predicates (CGI/URI/Shellwords/Math/Time/Regexp/Set/File + iterator_dispatch’sClasscheck) and the 4 string-foldingConstant[String]unwraps onto it. Concluded a full declaration-table harness is over-engineering — the fold bodies (Math numeric, File platform gate, Set constructor, Time arity, Shellwords cap) are too varied to table-drive without burying domain logic. Extracted only the genuinely-identical gate. - Finding 2 — DONE (singleton chain).
dispatch_stdlib_module_tiersis nowSTDLIB_MODULE_FOLDERS.each-driven (8 folders already sharedtry_dispatch, so no Finding 3 dependency for this chain). Thedispatch_precise_tiersladder (697-705) still mixestry_fold/try_dispatch/try_forward/ block_folding and does need Finding 3 first — not started. - Finding 3 — DONE (full interface-ization). Every dispatch tier now
takes a single immutable
CallContext(Data.define) and conforms to one interface,try_dispatch(CallContext) -> Type::t?:- Slice A —
CallContextvalue object (9 fields: the call quartet- block_type/environment/call_node/scope/self_type_override/public_only)
with a
.buildkeyword factory carrying the singleMetrics/ParameterListsdisable that retires the per-tier ones.
- block_type/environment/call_node/scope/self_type_override/public_only)
with a
- Slice B1 — precise tiers (ConstantFolding
try_fold→try_dispatch, BlockFoldingtry_fold→try_dispatch, MethodFolding forwardtry_forward→try_dispatch, LiteralString/Shape/Kernel + the eight singleton folders).dispatch_precise_tiersbuilds the context once and drives a singlePRECISE_TIERSlist, absorbing theSTDLIB_MODULE_FOLDERSsub-list from Finding 2. - Slice B2 — context-heavy tiers (RbsDispatch
try_dispatch— ParameterLists disable removed — +block_param_types, MethodFoldingtry_backward, IteratorDispatchblock_param_types).dispatchbuilds the context once at the top; the two derived RBS sites useCallContext.build. - Slice B3 —
interface _DispatchTier+CallContextclass declared insig/rigor/inference.rbs(typed withType::t).make steep-checkgreen. Declarative only — no forced per-tier conformance, matching the engine’s partial-signature idiom. - Tier entry points are called only from
method_dispatcher.rb+ the tier unit specs; specs migrated via a newcc(...)support helper (Slice B’s 102 call sites). Publicdispatch/expected_block_param_typeskeep their keyword signatures (external callers unaffected). - Performance: neutral. Interleaved A/B of
rigor check --no-cache lib(pre-Finding-3 d153403d vs post 4545dc63, 4 pairs) — pre mean 7.58s, post mean 7.70s (~1.6%), well inside the run-to-run variance (6.93–8.40s, ±15%; one pair shows post faster). The per-dispatchCallContextallocation does not move the needle. - Caught in the full suite, not the unit/inference specs:
ShapeDispatch.dispatch_intersectionre-enterstry_dispatchonce per Intersection member; that intra-tier recursion still passed the old keyword Hash and raisedNoMethodErrorpost-migration. The tier-caller survey excludedmethod_dispatcher/itself, so it was missed — only theintersection_refinementintegration fixture exercises the path. Lesson: when changing a tier-entry signature, grep the tier directory itself for recursive self-calls, and always run the fullrspec(not just the unit +spec/rigor/inferencesubset) before claiming behaviour-neutral. Fixed; 5406 examples, 0 failures.
- Slice A —
- Finding 5 — NOT STARTED. Fixture-escaping helper + cap/rescue absorption.
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.