Skip to content

Changelog — 0.0.x archive

Archived release notes for the 0.0.x development cycle.

The format follows Keep a Changelog, and the project adheres to Semantic Versioning.

This file is the static archive for 0.0.1 through 0.0.9, moved out of the main CHANGELOG.md per the project’s archival rule: at the first release after a leading-digit bump (e.g. 0.1.1, the first release after the 0.0.x0.1.x bump), the entire previous-digit range moves into a docs/CHANGELOG-<old-prefix>.md archive file.

The current cycle’s release notes live in CHANGELOG.md; the v0.1.0 release notes also stay there. When the next leading-digit bump lands (e.g. 0.2.x0.3.x triggered at 0.3.1), the 0.2.x block will move into docs/CHANGELOG-0.2.x.md following the same rule.

The ninth preview. Theme: finish the cache surface, broaden the type vocabulary, and lock the public API ahead of the v0.1.0 plugin contract. v0.0.9 closes every remaining pre-0.1.0 substrate slice: the persistent cache is wired into rigor check end-to-end (warm runs hit disk-backed tables; --cache-stats reports real hit/miss/write counts; --no-cache toggles it off), the type vocabulary picks up paired-complement ~T narrowing and literal-string flow tracking through interpolation / + / * / <<, the RBS::Extended directive surface rolls every recognised directive on a method into a single Rigor::FlowContribution bundle, and six new built-in catalogues cover Random, Struct (+ Data), Encoding, Regexp / MatchData, Proc / Method / UnboundMethod, and Exception.

The next release after 0.0.9 is 0.1.0 — single-digit version-component policy, no 0.0.10. v0.1.0 starts the plugin contract proper; v0.0.9 ships the substrate that contract is designed against.

  • Analysis::Runner.cache_store surface + rigor check --no-cache. Runner defaults to a Cache::Store rooted at .rigor/cache; the CLI flag threads nil through to disable. Environment.for_project(cache_store:) plumbs the Store down to the underlying RbsLoader.
  • First end-to-end cached producer — RbsLoader#constant_type reads from RbsConstantTable. Cold runs build the translated constant-type table once and persist it; warm runs (and a separate loader sharing the same Store) skip the env walk entirely and pay only a Marshal.load of the table.
  • Five more cache producersRbsKnownClassNames (Set), RbsClassAncestorTable (Hash<String, Array>), RbsClassTypeParamNames (Hash<String, Array>), and RbsEnvironment (the full RBS::Environment). The fifth producer (RbsEnvironment) caches the biggest cold-start cost — RBS::EnvironmentLoader#load + Environment.from_loader + resolve_type_names — by adding minimal _dump/_load Marshal hooks to the rbs gem’s C-extension RBS::Location. The patch is purely additive and idempotent; RBS::Location is never read from any analysis path so the lost source-position metadata is inert.
  • Cache::Store#stats + --cache-stats runtime breakdown. In-process hits / misses / writes counters (per-producer breakdown) bumped inside fetch_or_compute; rigor check --cache-stats prints an on-disk inventory followed by a “this run:” section. Under --no-cache the section is omitted.
  • Cache::Store#fetch_or_compute(serialize:, deserialize:) callable surface. Producers whose return values are not Marshal-clean (RBS-native objects with RBS::Location members, raw IO, …) can register custom round-trip callables. Default stays at Marshal.dump / Marshal.load. Deserialiser exceptions become cache misses. RbsEnvironment rides this surface.
  • Shared Rigor::Cache::RbsDescriptor. Every RBS-derived producer attaches the same descriptor (rbs gem locked version + :digest entries for every .rbs file under signature_paths + a rbs.libraries configs entry), so a signature change or rbs gem bump invalidates them in lockstep.
  • Paired-complement narrowing for Refined[base, predicate]. Type::Refined::COMPLEMENT_PAIRS registers bidirectional pairs; the narrowing tier returns Refined[base, complement] instead of the imprecise Difference[base, refined] fallback. Three pairs land in v0.0.9: lowercase ↔ not_lowercase, uppercase ↔ not_uppercase, numeric ↔ not_numeric. Positive carriers non-lowercase-string, non-uppercase-string, non-numeric-string join Builtins::ImportedRefinements::REGISTRY so users can write them directly.
  • literal-string carrier and non-empty-literal-string composition. A String known to come from a source-code literal (or a composition of literals). Tracked through string interpolation "#{...}" (lifts to literal-string when every part is literal-bearing) and through the new LiteralStringFolding dispatcher tier covering String#+, String#*, String#<<, String#concat (lifts when every operand is literal-bearing).
  • Six new built-in catalogues — Random, Struct (+ Data), Encoding, Regexp + MatchData, Proc / Method / UnboundMethod, Exception. Each catalog drives the fold dispatcher with per-class blocklists for indirect mutators (Random’s MT-state-advancing methods, Regexp’s $~-writing matchers, Proc / Method’s :call / :[] execution paths, Exception’s runtime-state readers, etc.).
  • Numeric#clone reclassified. numeric topic’s c_index_paths now includes references/ruby/object.c, so Numeric#clone’s alias to rb_immutable_obj_clone is found by the C-body classifier and the entry moves from purity: unknown to purity: leaf.

Pre-v0.1.0 substrate (locks the surface the plugin contract attaches to)

Section titled “Pre-v0.1.0 substrate (locks the surface the plugin contract attaches to)”
  • Rigor::FlowContribution bundle struct. Eight content slots (return_type, truthy_facts, falsey_facts, post_return_facts, mutations, invalidations, exceptional, role_conformance) plus a Provenance Data carrier (source_family, plugin_id, node, descriptor). Frozen on construction; collection slots duped + frozen. Public read shape per ADR-2 § “Flow Contribution Bundle”; element-list flattening deferred to v0.1.0 alongside the contribution merger that consumes it.
  • Rigor::RbsExtended.read_flow_contribution(method_def). Rolls every recognised directive on a single RBS method (predicate-if-(true|false), assert*, return:) into one FlowContribution with :rbs_extended source family. Internal narrowing keeps consuming the typed Data carriers; the bundle is the public packaging the v0.1.0 contribution merger reads.
  • Public-API drift specs for Rigor::Scope, Rigor::Environment, Rigor::Type::Combinator, Rigor::Reflection. Snapshot-style spec at spec/rigor/public_api_drift_spec.rb pins each namespace’s instance + singleton method set so accidental signature changes show up as test failures, not silent breakage. The four namespaces are the v0.1.0 plugin-contract attachment points.
  • docs/internal-spec/public-api.md. Public/internal stability boundary declared explicitly: which namespaces are drift-pinned today (Scope / Environment / Type::Combinator / Reflection), which are public-shape but still in flux until v0.1.0 (FlowContribution, Diagnostic, Cache::Store#fetch_or_compute, RbsExtended directive readers), and which stay strictly internal (Inference::, Analysis::FactStore / CheckRules / Runner, AST:: virtuals, Source / CLI / Configuration plumbing).
  • The cache layer’s public read shape grows to cover all six producers in docs/internal-spec/cache.md: Descriptor, Store (with the new serialize:/deserialize: kwargs and Store#stats), RbsConstantTable, RbsKnownClassNames, RbsClassAncestorTable, RbsClassTypeParamNames, RbsEnvironment, the shared RbsDescriptor builder, and the RBS::Location Marshal patch.
  • Rigor::FlowContribution documented in docs/internal-spec/flow-contribution.md with the slot table, equality / to_h / empty? semantics, RbsExtended.read_flow_contribution mapping (predicate-if-* → truthy_facts / falsey_facts, assert*post_return_facts, return:return_type), and the deferred element-list flattening note.

The eighth preview. Theme: first cache-related code slice — land the persistence layer that v0.0.7’s cache slice taxonomy design doc fixed the schema for, with a Marshal-clean producer wired through it end-to-end. Backend choice is fixed by ADR-6: a sharded directory of binary entries written through a custom canonical format, zero new gem dependencies.

  • Rigor::Cache::Descriptor value object. Pure-value four-slot schema (files, gems, plugins, configs) per docs/design/20260505-cache-slice-taxonomy.md. Each slot holds typed, frozen entries; FileEntry validates its comparator enum (:digest > :mtime > :exists); the rest accept already-canonical hashes. Descriptor.compose(*descriptors) unions slots by key, prefers the stricter comparator on file conflicts, and raises Descriptor::Conflict on disagreeing values. descriptor.cache_key_for(producer_id:, params:) derives the canonical hex SHA-256 over the composed inputs; to_canonical_bytes produces sorted, deterministic JSON so equivalent descriptors round-trip to identical bytes.
  • Rigor::Cache::Store filesystem backend. Sharded layout <root>/<producer-id>/<2-prefix>/<62-suffix>.entry, schema-version marker at <root>/schema_version.txt. Custom binary entry format ("RIGOR\x00\x01" magic, varint-prefixed descriptor and value, trailing SHA-256 integrity). Writes follow rename-into-place with flock(LOCK_EX) on the destination and fsync on the temp file. Reads tolerate any failure (missing file, bad magic, bad SHA-256, malformed varint, unmarshal-able payload) by falling through to a cache miss. Store#fetch_or_compute(producer_id:, params:, descriptor:) { ... } is the single producer-facing API; producer ids are constrained to [a-z][a-z0-9._-]* for filesystem safety.
  • First cached producer — Rigor::Cache::RbsConstantTable. Caches a Hash<String, Rigor::Type> mapping every RBS-declared constant (e.g. "::Math::PI") to its translated Rigor::Type. Descriptor: the rbs gem with its locked version, :digest entries for every .rbs file under signature_paths, and a configs entry for the libraries list. The slice plan originally named the RBS environment loader (build_env) as the first producer; implementation discovered RBS::Environment is not Marshal-clean (RBS::Location is a C-extension class without _dump_data). ADR-6 § 8 documents the finding; the slice caches a post-translation artefact instead. RbsLoader#constant_names is added to the public surface so the producer can enumerate constants without reaching into the loader’s private state.
  • rigor check --cache-stats. Prints an on-disk inventory at the end of the run (per-producer entry counts, total bytes, schema-version marker). Sourced from a new Rigor::Cache::Store.disk_inventory(root:) class method. Per-run hit/miss counters are deferred until production code wires the cache (no production caller in v0.0.8).
  • rigor check --clear-cache. Removes the .rigor/cache directory (CWD-relative) before the analysis run. Prints Cleared cache: .rigor/cache or Cache already empty: .rigor/cache. The check itself runs to completion regardless.
  • Diagnostic source-family provenance. Rigor::Analysis::Diagnostic gains a source_family: keyword (default :builtin) and a qualified_rule accessor returning "#{source_family}.#{rule}" for non-default families and just rule for builtin diagnostics. JSON output (to_h) carries both source_family and the bare rule side-by-side. Prepares ADR-2’s plugin-observability story without committing to the plugin API itself; no production caller in v0.0.8 sets a non-default source family.
  • New normative spec docs/internal-spec/cache.md tracks the cache layer’s public read shape (Descriptor API, Store API, file format, atomicity & locking, schema-version mismatch behaviour, disk inventory, diagnostic provenance).

The seventh preview. Theme: pre-plugin coverage push — close the gap between what the type-language and built-in-coverage specs already commit to and what the analyzer actually implements, so the plugin API designed against this surface in v0.1.0 has a complete substrate to attach to. The release is breadth-over-depth: many small fills, plus the first design output in the pre-v0.1.0 sequence.

  • key_of[T] / value_of[T] project the type-level union of known keys (resp. values) for HashShape, Tuple, Nominal[Hash, [K, V]], Nominal[Array, [E]], and finite-bound Constant<Range>. Reachable through RBS::Extended directive payloads. The parser also accepts lower_snake heads alongside kebab-case refinements and lets nominal arguments carry their own type-args, so key_of[Hash[Symbol, Integer]] parses to Symbol.
  • int_mask[1, 2, 4] / int_mask_of[T] compute the bitwise-OR closure over a finite integer literal set, returning a Union[Constant<Integer>…] for small closures and a covering IntegerRange past the cardinality cap. Integer literals are now accepted as parser arguments.
  • T[K] indexed-access operator projects the type at index / key K from a structured T. Reachable from RBS::Extended directive payloads through trailing [K] segments after a parsed type, including chained T[K1][K2]. The parser’s top-level entry now accepts class-name-headed types directly, so Hash[Symbol, Integer][Symbol] parses to Symbol.
  • Rational / Complex literal lift. Prism::ImaginaryNode (1i) and Prism::RationalNode (1.5r) type as Constant<Complex> / Constant<Rational>; Kernel#Rational(num, den) and Kernel#Complex(re, im) calls fold to the same precise constants when every argument is a numeric Constant. foldable_constant_value? widens to accept Rational / Complex, unblocking every catalog-tier Rational#… / Complex#… fold against constant receivers.
  • Regexp literal lift. Non-interpolated Prism::RegularExpressionNode lifts to Constant<Regexp> (preserving source and option flags); interpolated regexes keep the conservative Nominal[Regexp]. Activates the new Constant<String>#scan(/regex/) fold path end-to-end.
  • Pathname delegation. Pathname joins Type::Constant::SCALAR_CLASSES; Pathname.new(Constant<String>) lifts via a MethodDispatcher#meta_new constant-constructor table; a curated 14-method unary / 8-method binary fold table covers pure path manipulation (to_s, basename, dirname, extname, cleanpath, +, join, <=>, ==, relative_path_from, …). Filesystem-touching methods (exist?, file?, read, stat, …) are intentionally NOT folded.
  • to_a lifts to a per-position Tuple[…] for finite integer ranges (capped at 16 elements); first / last / min / max and count / size / length fold to precise Constant<Integer> values for the no-arg form, bypassing the catalog’s :block_dependent classification of the optional-block variants.

Tuple precision (eleven new ShapeDispatch handlers)

Section titled “Tuple precision (eleven new ShapeDispatch handlers)”
  • empty? / any? / all? / none? (no-block, no-arg) fold to Constant[bool] per the tuple’s arity and element truthiness.
  • include?(needle) folds to a precise bool when the needle is a Constant and the tuple’s elements are all Constant.
  • sum / min / max fold to numeric / comparable extremes for all-Constant elements.
  • sort / reverse return per-position Tuples in the appropriate order.
  • to_a returns the receiver Tuple unchanged.
  • zip pairs the receiver’s per-position elements with the per-position elements of each other Tuple-shaped argument; short other-Tuples pad with Constant[nil]; multi-arg zip produces wider per-position Tuples (capped at 8).
  • keys / values fold to per-position Tuples preserving declaration order.
  • count / length match the existing size handler.
  • empty? / any? (no-arg, no-block) fold to Constant[bool] per the shape’s emptiness.
  • first / flatten / compact for closed shapes with no optional keys: first returns the [k, v] 2-Tuple of the first pair; flatten produces the per-position [k_1, v_1, k_2, v_2, …] Tuple; compact drops every entry whose value is Constant[nil].
  • Tuple ↔ HashShape conversionsTuple#to_h, HashShape#to_a, HashShape#to_h, HashShape#invert (Symbol-/String-valued shapes only), HashShape#merge(other) for closed-shape × closed-shape merges.
  • Format-string fold over Tuple / HashShape arguments. "%d / %d" % [1, 2] folds to Constant<"1 / 2">; "%{name} is %{age}" % {name: "Alice", age: 30} folds to Constant<"Alice is 30">. Malformed format specs decline so the RBS tier widens.
  • Array-returning method lift. s.chars / s.bytes / s.lines / s.split (no-arg, separator, or Constant<Regexp> pattern) / s.scan lift the resulting Array to a per-position Tuple[Constant…] when every element is a foldable scalar and the cardinality fits within 32. Larger results decline so the RBS tier widens.
  • ~Refined[base, predicate] narrows through Difference[base, refined] instead of falling back to current_type unchanged. assert value is ~lowercase-string now narrows String to Difference[String, lowercase-string]. The De Morgan composition for Intersection refinements also tightens.
  • {}HashShape{} mirrors the v0.0.6 empty-array literal change. The new HashShape projections fold against it.
  • Array.new(n, value) / Array.new(n) lift to a per-position Tuple[…] when n is a small Constant<Integer> (capped at 16). Oversize n falls back to Nominal[Array].
  • Rigor::Reflection read-side facade joins Rigor’s three reflection sources (ClassRegistry + RbsLoader + Scope discovered facts) under one read API. Nine queries: class_known?, class_ordering, nominal_for_name, singleton_for_name, constant_type_for (in-source wins on collision with RBS), instance_method_definition, singleton_method_definition, discovered_class?, discovered_method?. Public read shape for v0.1.0 plugin-API readiness; spec at docs/internal-spec/reflection.md.
  • Reflection consumer migration. Five engine-internal callers (Analysis::CheckRules, Inference::Narrowing, Inference::StatementEvaluator, Inference::MethodDispatcher, Inference::MethodParameterBinder, Inference::MethodDispatcher::RbsDispatch) move from raw scope.environment.rbs_loader access to the facade. The facade gains rbs_class_known?, instance_definition / singleton_definition, class_type_param_names, and an environment: kwarg variant for dispatcher call paths that don’t have a Scope in scope. Mechanical refactor; no behaviour change.
  • v0.1.0 readiness design doc at docs/design/20260505-v0.1.0-readiness.md — maps every ADR-2 surface to today’s implementation, sequences the seven major pre-v0.1.0 work items, reconciles ADR-2’s open questions, and lists the items that can land as v0.0.x dot releases.
  • Cache slice taxonomy design doc at docs/design/20260505-cache-slice-taxonomy.md — fixes the per-slot entry shapes (FileEntry with :digest / :mtime / :exists comparators, GemEntry, PluginEntry, ConfigEntry), composition rules, canonical cache-key derivation, granularity guidance, and schema versioning. Prerequisite contract for the persistence layer that ships in v0.1.0.

The sixth preview. Theme: fold block-taking Enumerable methods through the constant-folding tier so iterator-shaped expressions over literal collections produce precise carriers instead of widening through RBS.

  • Block-shaped fold dispatch over constant-block predicates and filters. Calls like [1, 2, 3].select { false }, arr.all? { true }, or arr.any? { false } collapse to the precise endpoint when the block’s inferred return type is a Ruby-truthy or Ruby-falsey Constant. Filter methods (select / filter / reject / take_while / drop_while) fold to either the receiver or Tuple[]; predicate methods (all? / any? / none?) fold to Constant[true] / Constant[false] whenever the receiver-emptiness × block-truthiness combination is unconditional in Ruby’s semantics, including the vacuous-truth empty-receiver cases. Receiver-emptiness is recognised against Tuple, HashShape, Constant<Array|Hash|String|Range>, and the imported non-empty-array[T] carrier (Difference[Array, Tuple[]]).
  • Per-position block re-evaluation over Tuple receivers for map / collect / filter_map / flat_map / find / detect / find_index / index. The block body is type-checked once per Tuple position with the corresponding element bound to the block parameter, then assembled per-method:
    • map / collect produce Tuple[U_1..U_n]. [1, 2, 3].map { |n| n.to_s } resolves to ["1", "2", "3"] instead of Array["1" | "2" | "3"].
    • filter_map drops Constant[nil] / Constant[false] positions and concatenates the survivors into a Tuple.
    • flat_map concatenates per-position Tuple results, treating per-position Constant scalars as single-element contributions and declining on opaque carriers.
    • find / detect return the receiver element at the first truthy position (or Constant[nil] when every position is falsey).
    • find_index / index return the index of the first truthy position (or Constant[nil]). The value-search forms index(value) / find_index(value) decline so the RBS tier still owns those.
  • Per-position block fold over short Constant<Range> receivers up to a cardinality cap of 8 elements. Each integer in the range re-types the block body once with the corresponding Constant<Integer> bound to the parameter, so (1..3).map { |n| n.to_s } resolves to ["1", "2", "3"] and (1..5).find { |n| n.even? } resolves to Constant[2]. Larger ranges decline so the RBS tier widens, keeping block-typing cost bounded.
  • Branch elision for expression-position conditionals. if / unless / ternary expressions whose predicate folds to a Type::Constant drop the unreachable branch and adopt the live branch’s type. Statement-level branch elision was already present from v0.0.3; this slice covers expression-position uses (e.g. the right-hand side of an assignment, an argument expression, or a block body). Composes directly with the per-position fold, so [1, 2, 3].filter_map { |n| n.even? ? n.to_s : nil } resolves to Tuple[Constant["2"]].
  • && / || short-circuit elision on Constant-shaped left operands. When the left operand of && / || folds to a Type::Constant, the result type follows Ruby’s actual short-circuit semantics: Constant[truthy] && rhs is the right operand’s type, Constant[falsey] && rhs keeps the left, and the dual rule applies for ||. Non-Constant left operands keep the previous union-of-both-operands behaviour.
  • find { false } / detect { false } / find_index { false } / index { false } / count { … } short-circuit folds. The block-form falsey side of the find-family folds to Constant[nil]; count { false } folds to Constant[0]; count { true } folds to Constant[size] when the receiver pins a finite size (Tuple, HashShape, or Constant<Range> with finite integer endpoints). The value-search forms index(value) / count(value) carry a positional argument and decline so the RBS tier still answers them.
  • IntegerRange-aware ternary fold — Comparable#between? / Comparable#clamp. The 2-arg try_fold_ternary path now accepts an IntegerRange receiver paired with two scalar Constant<Integer> args. int<3, 7>.between?(0, 10) folds to Constant[true]; int<3, 7>.clamp(4, 6) folds to int<4, 6> (collapsing to a Constant when the intersection pins a single point). When the bracket is fully disjoint from the range — every receiver value would snap to one bracket bound — the fold declines so the RBS tier widens rather than the dispatcher inventing the snap point.
  • Empty array literal carrier — [] resolves to Tuple[]. The empty array literal previously typed as Nominal[Array]; v0.0.6 switches it to the empty Tuple[] carrier so the per-element block fold can concatenate cleanly across all-empty positions like [1, 2, 3].flat_map { |_| [] } (now folds to Tuple[]). Both carriers erase to plain Array on the RBS-interop path.
  • Pathname catalog import. data/builtins/ruby_core/pathname.yml (102 instance methods, 2 singletons, 5 aliases) and the matching Builtins::PATHNAME_CATALOG join the catalog tier. Pathname is a thin wrapper that mostly delegates to File / Dir / FileTest, so the user-visible payoff is narrower than Numeric or String — the import buys receiver-class recognition for Pathname.new(...), a defensive :initialize_copy blocklist entry, and catalog folding for the lone :leaf method (<=>).
  • tool/extract_builtin_catalog.rb rescue-on-def classifier crash. PreludeParser#analyse_body previously raised NoMethodError on Ruby methods written with the rescue-on-def idiom (def foo; …; rescue; …; end) because Prism wraps the body in a BeginNode rather than a StatementsNode. The classifier now descends into the begin-block’s statements for that case. The bug surfaced importing Pathname (whose prelude has def initialize(path); @path = …; rescue TypeError; …; end); every catalog regenerates cleanly under make extract-builtin-catalogs.
  • Rational and Complex built-in catalog imports. New loaders RATIONAL_CATALOG and COMPLEX_CATALOG join the CATALOG_BY_CLASS table; the corresponding YAMLs under data/builtins/ruby_core/{rational,complex}.yml are generated from references/ruby/{rational,complex}.c via tool/extract_builtin_catalog.rb. Both classes are fully immutable in Ruby, so the per-class mutating_selectors blocklists carry only the conventional defence-in-depth :initialize_copy entry. Rigor today has no Constant<Rational> / Constant<Complex> literal lift (Prism::ImaginaryNode and Rational(...) / Complex(...) Kernel-call folding stay deferred), so the catalog wiring is currently a defensive surface — every fixture assertion goes through the RBS-tier projection on a Nominal[<class>] receiver. The blocklist becomes load-bearing once a future slice teaches the typer to lift these literals into Constant<…>.

  • Const = Data.define(*Symbol) discovery. Inference::ScopeIndexer.record_declarations now registers Const (qualified by the surrounding class / module path) as a discovered class whose constant resolves to Singleton[<qualified-name>]. Previously Const.new(...) returned the un-narrowed Dynamic[top] envelope; with the constant registered, meta_new resolves it to a fresh Nominal[<qualified-name>], and member accessors flow through the user-class fallback without false-positives. Both the bare form Data.define(:x, :y) and the block-override form Data.define(:x, :y) do; def initialize(x:, y:); …; end end are recognised; non-symbol arguments and non-Data receivers are rejected. Worked example: Target and Fact in lib/rigor/analysis/fact_store.rb now type as singleton(Rigor::Analysis::FactStore::Target) and singleton(Rigor::Analysis::FactStore::Fact) respectively.

  • Kernel#Array precision tier (MethodDispatcher::KernelDispatch). A new precision-tier dispatcher folds Array(arg) into a precise Array[E] whenever the argument’s value-lattice shape lets us prove the element type. The rules mirror Ruby’s coercion contract — Array(nil) -> [], an existing Array[E] preserves its element, a Tuple materialises to Array[T1|T2|…], and a Union distributes element-wise and unifies. Opaque shapes (Top / Dynamic / Bot) fall through to the existing RBS-tier envelope. Worked example: in lib/rigor/analysis/fact_store.rb#fact_targets, Array(fact.target) over fact.target: Target | Array[Target] previously typed as Array[Dynamic[top]]; it now types as Array[Target].

  • Branch-aware scope propagation for expression-position conditionals. Inference::ScopeIndexer.propagate now special-cases Prism::IfNode and Prism::UnlessNode, threading the predicate’s narrowed truthy / falsey scopes into the corresponding branch subtrees. Previously, when an if / unless sat in expression position (e.g. as a call argument or the RHS of an []=), the indexer never routed it through eval_if’s narrowing path, so inner nodes inherited the un-narrowed entry scope and downstream rules (possible-nil-receiver, type-of probes) saw spurious T | nil. Worked example: cache[k] = if x; x.foo; else; default; end now sees x narrowed to its non-nil fragment inside the truthy branch, matching the behaviour for the statement-level form if x; cache[k] = x.foo; else; cache[k] = default; end. Specs at spec/rigor/inference/scope_indexer_spec.rb#narrows IfNode branches when the conditional sits in expression position (and the UnlessNode mirror) bind both shapes.

  • RbsLoader#instance_definition / #singleton_definition now declared as untyped?. The earlier sig form (untyped) was a workaround for the truthy-narrowing gap above; with that gap closed, the sig can faithfully reflect the impl’s nil-on-unknown-class return contract.

  • Two-argument constant-fold dispatch. MethodDispatcher::ConstantFolding#try_fold previously switched on args.size and only handled the 0- and 1-arg shapes; 2-arg leaf methods like Comparable#between?(min, max), the explicit-bounds form of Comparable#clamp(min, max), and Integer#pow(exp, mod) all bailed to the RBS-widened tier. The dispatch now grows a when 2 arm routed through try_fold_ternary, which folds the cartesian product of receiver × arg0 × arg1 when every operand is a Constant (or Union[Constant…]) and the catalog classifies the method :leaf / :trivial. The same UNION_FOLD_INPUT_LIMIT cap that gates the binary path guards the cartesian explosion. IntegerRange operands are reserved for a follow-up — any range receiver or arg short- circuits the ternary path so the RBS tier still answers. Worked examples: 5.between?(0, 10) folds to Constant[true], 100.clamp(0, 10) folds to Constant[10], 100.pow(50, 17) folds to Constant[4]. Direct payoff for the just-landed include-aware lookup: between? was the canonical 2-arg method blocked by the arity gate. End-to-end fixture: spec/integration/fixtures/two_arg_fold.rb.

  • tool/catalog_diff.rb + make catalog-diff. Prints the surface-level diff between two data/builtins/ruby_core/<topic>.yml snapshots — per-class additions / removals / purity changes / cfunc renames / arity changes. The motivating use is a references/ruby submodule bump where the full YAML diff is noisy because it interleaves prose comments, RBS pulls, and defined_at line numbers; this tool extracts the catalog-semantic deltas a reviewer has to look at. Default invocation: make catalog-diff BEFORE=… AFTER=….

  • C-body classifier detects pure rb_check_frozen wrappers as mutators. Per-class wrappers like time_modify(time) / time_gmtime(time) whose entire body is one or more rb_check_frozen(...) calls used to be classified :leaf even though they centralise the mutation gate of the receiver. CBodyIndex#mutator_helpers now returns the set of indexed cfuncs whose body matches the pure-frozen-check pattern, and CBodyClassifier.classify flips the :mutate effect on when a method calls one of those helpers. The pattern is intentionally narrow — naive transitive propagation over-flagged legitimate non-mutators like Array#to_a, so only bodies that consist solely of rb_check_frozen calls qualify. Re-extraction flips two Time methods (#gmtime, #utc, both bound to time_gmtime) from :leaf to :mutates_self; every other catalog regenerates byte-identically.

  • Include-aware module-catalog fallthrough activates the Comparable / Enumerable imports. MethodDispatcher::ConstantFolding#catalog_allows? walks the receiver class’s Module#ancestors and consults the imported module catalogs (COMPARABLE_CATALOG, ENUMERABLE_CATALOG) when the primary class catalog has no entry for the method. Resolution: primary class catalog first (its rb_define_method registration is authoritative even when the entry is classified :dispatch), module catalogs only when the primary has no entry. The user-visible payoff: methods that come purely from an include Comparable / include Enumerable mixin without a direct rb_define_method registration now fold. Worked example: 5.clamp(0..10) folds to Constant[5], 100.clamp(0..10) folds to Constant[10]. Comparable#between? and Enumerable’s block-shaped methods need the dispatch tier’s two-arg / block-parameter paths and remain unfolded (tracked as a follow-up). End-to-end fixture: spec/integration/fixtures/include_aware_clamp.rb.

  • Comparable and Enumerable module catalog imports. New data/builtins/ruby_core/comparable.yml and enumerable.yml generated by tool/extract_builtin_catalog.rb from Init_Comparable (compar.c) and Init_Enumerable (enum.c). Catalog stats: Comparable ships with 7 instance methods (the </<=/==/ >=/>/between?/clamp family); Enumerable ships with 58 instance methods (47 :block_dependent, 9 :leaf, 2 :mutates_self). The matching Builtins::COMPARABLE_CATALOG / Builtins::ENUMERABLE_CATALOG singletons are loaded at boot but NOT registered in MethodDispatcher::ConstantFolding::CATALOG_BY_CLASS because modules are not receiver classes the dispatcher routes through; the data is in place for a future include-aware lookup that walks the receiver’s ancestor chain.

  • tool/scaffold_builtin_catalog.rb --module. The scaffold script gains a module mode that skips the CATALOG_BY_CLASS row, the fixture stub, and the integration describe block — none of those make sense until include-aware dispatch ships. The loader file gets a module-aware banner; the require_relative is still inserted so the singleton is reachable. The associated extractor upgrade (MODULE_DEFINE_RE) recognises rb_mFoo = rb_define_module("Foo"); registrations and records modules in the per-topic classes map with parent: "Module". Two previously-dropped module registrations (FileTest in Init_File, UnicodeNormalize in Init_String) now surface as empty-bucket class entries in their respective YAMLs.

  • ~refinement negation extends to IntegerRange and Intersection. Narrowing.narrow_not_refinement previously only handled Difference[base, Constant[v]]; the algebra now covers two more carrier kinds:

    • Type::IntegerRange[a, b] — complement is the two open halves int<min, a-1> and int<b+1, max>, each intersected with the integer-domain parts of current_type. Non-integer parts of a Union receiver survive unchanged. assert n is ~int<5, 10> over n: Integer narrows to int<11, max> | int<min, 4>. End-to-end fixture: spec/integration/fixtures/assert_negation_integer_range/.
    • Type::Intersection[M1, M2, …] — De Morgan: D \ (M1 ∩ M2) = (D \ M1) ∪ (D \ M2). Each member’s complement is computed independently and unioned; members the algebra cannot complement (Refined, non-Constant Difference) contribute current_type itself, so the union may widen. ~non-empty-lowercase-string over String therefore yields Constant[""] | Nominal[String] rather than the tighter Constant[""] we’d get with predicate-aware complement. Refined[base, predicate] keeps its conservative current_type answer (predicate complements are not finite-carrier-expressible).
  • ~refinement negation in assert: / predicate-if-*: directives. The <target> is <RHS> right-hand side now accepts the ~T negation prefix on the refinement arm in addition to the existing class-name arm. The narrowing tier introduces Narrowing.narrow_not_refinement for the Difference + Constant-removed shape: it walks the current type’s union members, keeps each part disjoint from the refinement’s base, and adds the removed-value Constant exactly once when any current member covers it.

    class Validator
    %a{rigor:v1:assert value is ~non-empty-string}
    def assert_empty!: (::String value) -> void
    end

    After v.assert_empty!(name) over name: String | nil, the narrowed type is Constant[""] | NilClass — the only inhabitants of the original union that are NOT non-empty strings. Other refinement carriers (Refined, Intersection, IntegerRange, and Difference whose removed is not a Constant) return current_type unchanged for now; predicate-complement and bounded-range complement are follow-up slices. End-to-end fixture: spec/integration/fixtures/assert_negation_refinement/.

  • group_by / partition / each_slice / each_cons block-parameter projections (placeholder; future plugin). RBS already binds these methods correctly for plain Array[T] / Set[T] / Range[T] receivers via generic substitution; the new IteratorDispatch arms exist so Tuple- and HashShape-shaped receivers reach the block body with the precise per-position element union (or Tuple[K, V] pair) rather than the projected Array[union] widening. group_by / partition yield a single element; each_slice and each_cons yield Array[element] (the slice-size argument is ignored at the dispatcher tier — a tighter Tuple-of-n carrier is reserved for the plugin tier). The scope is intentionally narrow — the longer-term direction is to move Enumerable-aware projections into a plugin tier modelled after PHPStan’s extension API (ADR-2). The placeholder rules will be reimplemented and removed once the plugin surface ships. Self-asserting fixture: spec/integration/fixtures/enumerable_collect.rb.

  • Memo-typed Enumerable block-parameter projections. IteratorDispatch covers #each_with_object (yields (element, memo) where the memo type follows the second argument’s actual type) and #inject / #reduce (yields (memo, element)). The inject family handles three call shapes:

    • inject(seed) { |memo, elem| … }[seed_type, element_type].
    • inject { |memo, elem| … } — both block params bind to the receiver’s element type (Ruby’s first-element-as-memo semantics).
    • inject(:+) / inject(seed, :+) — Symbol-call forms have no block; the dispatcher recognises and declines.

    Self-asserting fixture: spec/integration/fixtures/enumerable_memo.rb.

  • Date / DateTime catalog import. New data/builtins/ruby_core/date.yml generated from Init_date_core in references/ruby/ext/date/date_core.c plus the lib/date.rb prelude. Both classes land in a single topic — DateTime inherits from Date and the same Init function registers both, so tool/extract_builtin_catalog.rb carries one entry with two RBS bindings (date.rbs, date_time.rbs). Catalog stats: 2 classes, 96 instance methods, 60 singleton methods, 149 :leaf / 2 :mutates_self / 3 :block_dependent classifications. The blocklist in lib/rigor/inference/builtins/date_catalog.rb covers :initialize_copy (defensive symmetry with String / Array / Range / Set / Time) and Date’s #ifndef NDEBUG-only :fill helper, plus a mirrored :initialize_copy entry for the DateTime side. MethodDispatcher::ConstantFolding routes Date and DateTime receivers through the new DATE_CATALOG; the DateTime row precedes Date in CATALOG_BY_CLASS so subclass receivers hit their dedicated entry first. Self-asserting fixture spec/integration/fixtures/date_catalog/ exercises the Integer-typed reader surface (#year / #month / #day / #wday / #hour / #min / #sec), the boolean predicate surface (#leap? / #julian? / #sunday?), the String-typed formatters (#to_s / #iso8601 / #strftime), and the navigation methods (#next_day / #prev_day / #next_month / #prev_year / #succ / #>> / #<<) that return brand-new Date objects rather than mutating the receiver. No RBS::Extended rigor:v1:return: overrides this slice — the reader surface is in the same situation as Time, where per-method ranges (#monthint<1, 12>) would need a parameterised IntegerRange overlay that’s out of scope.

  • Cross-line block comments in tool/extract_builtin_catalog.rb. CInitParser#join_continuations walks the Init function body line by line and tracks paren depth to merge multi-line registration macros into a single logical line. The previous strip_line_comments helper only stripped /* … */ runs that fit on one line, so multi-line rdoc blocks (very common above a rb_define_class call — cDateTime = rb_define_class("DateTime", cDate); in date_core.c is preceded by a 200-line /* … */ block) contributed unbalanced parens to the depth counter and made the next code line merge into a comment buffer. The fix pre-strips block comments from the entire C source while preserving newlines so per-line indexing remains valid. Without the fix DateTime’s class-registration line was silently dropped and the catalog only saw Date.

The fourth preview. Theme: finish the OQ3 refinement-carrier strategy and broaden the RBS::Extended directive surface.

The OQ3 carrier triple (Type::Difference from v0.0.3 plus the new Type::Refined and Type::Intersection) is feature-complete against the imported-built-in catalogue (docs/type-specification/imported-built-in-types.md), so authors can express the full set of refinement names from %a{rigor:v1:…} annotations and the analyzer projects them through method dispatch, acceptance, and the argument-type-mismatch check rule symmetrically.

The RBS::Extended directive surface picks up rigor:v1:param: (both at the call boundary and inside the method body via MethodParameterBinder) and the existing assert* / predicate-if-* family now accepts refinement payloads on the right-hand side.

The built-in catalog import pipeline gains four more classes (Hash / Range / Set / Time) plus a tool/scaffold_builtin_catalog.rb script that automates the mechanical 70 % of each new import.

Test count: 1148 → 1250 examples (+102), RuboCop clean, bundle exec exe/rigor check lib reports 0 diagnostics.

  • Type::Refined carrier (predicate-subset half). Sibling of Type::Difference. Wraps (base, predicate_id) where predicate_id is a Symbol drawn from Type::Refined::PREDICATES. Construction goes through Type::Combinator.refined(base, predicate_id) and the per-name factories listed below. RBS erasure folds the carrier back to its base nominal. Gradual-mode acceptance mirrors the conservative accepts_difference policy — same-predicate Refined plus recognised Constant values get :yes, every other shape gets :no.
  • Type::Intersection carrier — composed refinement names. Closes the OQ3 carrier strategy by adding the Intersection peer alongside Union / Difference / Refined. The carrier represents the meet of its members’ value sets. Construction performs the deterministic normalisation in docs/type-specification/value-lattice.md — flatten / drop-Top / Bot-absorb / dedupe / sort / 0-1 collapse — so two equal intersections compare equal regardless of construction order. Acceptance is conjunctive on the LHS and disjunctive on the RHS, plus a top-level structural-equality short-circuit. ShapeDispatch.dispatch_intersection combines per-member projections through an IntegerRange meet when every result is bounded-integer, so (non_empty_string ∩ lowercase_string).size resolves to positive-int rather than the looser non-negative-int.
  • Fourteen imported built-in refinement names. All resolvable through Builtins::ImportedRefinements (and through the per-name factories on Type::Combinator):
    • Point-removal (already in v0.0.3): non-empty-string, non-zero-int, non-empty-array[T], non-empty-hash[K, V].
    • IntegerRange aliases (already in v0.0.3): positive-int, non-negative-int, negative-int, non-positive-int.
    • Predicate (new): lowercase-string, uppercase-string, numeric-string, decimal-int-string, octal-int-string, hex-int-string. The base-N int-string predicates are disjoint by design — :octal_int and :hex_int REQUIRE their conventional prefix (0o / 0O / leading 0; 0x / 0X), so a bare "755" is decimal-int-string, not octal-int-string.
    • Composed Intersection (new): non-empty-lowercase-string, non-empty-uppercase-string.
  • Catalog-tier projections over Refined[String, …]. String#downcase / String#upcase fold per predicate: case-fold idempotence for :lowercase / :uppercase / :numeric and the three base-N int-string predicates, plus the lift lowercase ↔ uppercase for the cross calls. Size-tier projections still apply through the predicate carrier so String#size over a Refined[String, *] tightens to non-negative-int.
  • Self-asserting fixtures. predicate_refinement/, intersection_refinement/, parameterised_refinement/, plus the existing refinement_return_override/ from v0.0.3.
  • rigor:v1:return: accepts parameterised refinement payloads. In addition to the bare-name shapes, the directive now accepts non-empty-array[T] / non-empty-hash[K, V] (type-arg payloads where T / K / V may be a kebab-case refinement name or a Capitalised RBS class name) and int<min, max> (bounded-integer range with signed integer literals). Parsing lives in a new Builtins::ImportedRefinements::Parser recursive-descent parser exposed through ImportedRefinements.parse(payload). Failure is fail-soft — any parse miss returns nil and the directive site falls back to the RBS-declared type.

  • rigor:v1:param: <name> [is] <refinement> directive. Symmetric to the return: route landed in v0.0.3 and feature-complete on both sides of the method boundary:

    • Call-site half. OverloadSelector and the argument-type-mismatch check rule consult RbsExtended.param_type_override_map(method_def) and prefer the override over the RBS-translated type so a too-wide call site is flagged.
    • Body-side half. MethodParameterBinder reads the same override map and replaces the RBS-translated parameter binding with the refinement, so projections through the carrier (e.g. id.size resolving to positive-int over a non-empty-string parameter) are observable inside the method body during inference.

    The optional is glue word matches the existing assert / predicate-if-* surface; authors MAY write param: id non-empty-string instead. End-to-end fixture: spec/integration/fixtures/param_extended/.

  • rigor:v1:assert: and rigor:v1:predicate-if-*: accept refinement payloads. The <target> is <RHS> right-hand side now matches either a Capitalised class name (existing behaviour) or a kebab-case refinement payload. Both AssertEffect and PredicateEffect gain a refinement_type field; the narrowing tier substitutes the carrier when present, keeping the legacy narrow_class path for class-name directives. Refinement-form directives do not yet support ~T negation — that would require a difference-against-refinement algebra and is reserved for a future slice.

  • CLI type-of confirms the kebab-case canonical-name contract. New regression specs in spec/rigor/cli_spec.rb drive bundle exec exe/rigor type-of through the harness over both a Difference-backed refinement (non-empty-string) and Refined-backed refinements (lowercase-string, numeric-string), and assert that human-readable text and --format=json output both render the refinement in its kebab-case spelling while erasure folds back to the base nominal.
  • Hash joins the catalog-driven inference pipeline. data/builtins/ruby_core/hash.yml is generated from references/ruby/hash.c. Builtins::HASH_CATALOG consumes it; the constant-fold dispatcher routes Hash receivers through it. Pure readers (size / [] / include? / dig / invert / compact / …) clear the catalog tier; block-yielding helpers that the C-body classifier mis-flags as :leaf (each / select / transform_values / merge, …) are blocklisted.
  • Range joins the catalog-driven inference pipeline. data/builtins/ruby_core/range.yml covers 30 instance methods. Methods that fold today on a (begin..end) literal include #begin, #end, #size, #exclude_end?, #include?, #cover?, #member?. The block-iterating surface (#each, #step, #first, #min, #max, #minmax, #count) classifies as block_dependent and is blocked by the foldable-purity check. The Range slice also taught tool/extract_builtin_catalog.rb to recognise rb_struct_define_without_accessor so future struct-defined topics become drop-in additions.
  • Set joins the catalog-driven inference pipeline. data/builtins/ruby_core/set.yml is generated from Init_Set in references/ruby/set.c (Set was rewritten in C and folded into CRuby for Ruby 3.2+). Per-class blocklist drops false-positive :leaf classifications for the indirect mutators (initialize_copy, compare_by_identity, reset), the block-yielding helpers (each, classify, divide), and disjoint?.
  • Time joins the catalog-driven inference pipeline. data/builtins/ruby_core/time.yml is generated from Init_Time in references/ruby/time.c plus the references/ruby/timev.rb prelude (compiled into timev.rbinc and #included at the bottom of time.c); the prelude path carries Time.now / Time.at / Time.new into the singleton-method bucket. The catalog records 58 instance methods (48 :leaf, 8 :dispatch, 3 :mutates_self, 3 :unknown), 4 singleton methods, and the iso8601xmlschema alias. Per-class blocklist catches localtime / gmtime / utc (all call time_modify(time) to mark the receiver mutable but the C-body classifier mis-flags them :leaf).
  • #each_with_index block-parameter typing. IteratorDispatch generalises beyond Integer iteration to project the element type per receiver shape (Array / Set / Range nominals, Tuple, HashShape, Hash nominal, Constant, Constant) and tightens the index slot to non-negative-int over the RBS-declared Integer. Self-asserting fixture: spec/integration/fixtures/each_with_index.rb.
  • tool/scaffold_builtin_catalog.rb. Automates the mechanical 70 % of a new built-in catalog import: writes the TOPICS entry, the optional BASE_CLASS_VARS row, the loader file with a TODO blocklist marker, the CATALOG_BY_CLASS row
    • require_relative, the integration fixture stub, and the describe block. Manual follow-ups (blocklist curation, fixture body, CHANGELOG bullet) are printed as a checklist on exit. --dry-run previews the planned edits; --init-fn / --rbs / --rb-prelude override defaults for upstream layouts that diverge. Documented as Stage 0 of the rigor-builtin-import skill.
  • MethodDispatcher::ConstantFolding#catalog_for is table- driven. A CATALOG_BY_CLASS array of (receiver_class, [catalog, class_name]) pairs replaces the growing case statement. Adding a class catalog is now a one-line addition rather than another when arm, and the dispatcher’s cyclomatic complexity stays bounded as the catalogue grows.
  • accepts_nominal projects refinement carriers to base. A Nominal accepting a Difference or Refined previously fell through to :no because accepts_nominal’s case statement had no branch for refinement kinds. The carrier’s value set is contained in its base nominal’s, so projecting to other.base and re-running acceptance is sound — a latent bug surfaced while wiring the Intersection conjunction.
  • provably_disjoint_from_removed? for nested Difference. Difference[A, R].accepts(Difference[B, R]) previously required the inner difference’s BASE to be provably disjoint from R, which never holds (a Nominal base contains the removed value by construction). Same-removed now suffices because the disjointness is exhibited at the inner difference layer.

The third preview. v0.0.3 makes the inference engine “see literal values where it can prove them” across a far wider surface than v0.0.2: aggressive constant folding (unary + binary + Union[Constant] cartesian + integer-range arithmetic + Tuple-shaped divmod), a PHPStan-style imported-built-in refinement carrier (non-empty-string, positive-int, non-zero-int, non-empty-array[T], non-empty-hash[K, V], negative-int, non-positive-int, non-negative-int), an extracted built-in method catalog driving the fold dispatcher (Numeric / String / Symbol / Array / IO / File auto-extracted from CRuby), iterator- block-parameter typing, scope-level integer-range narrowing, case/when range narrowing, an always-raises diagnostic for provable Integer division-by-zero, and end-to-end opt-in of the new refinement carrier through RBS::Extended’s new rigor:v1:return: directive.

The robustness principle (Postel’s law for types — strict on returns, lenient on parameters) is now a normative section of the type specification with ADR-5 as the design rationale.

  • Aggressive constant folding through user methods. Rigor::Inference::MethodDispatcher::ConstantFolding invokes the real Ruby method on Constant receivers and arguments whenever the method is in a curated allow-list, the operation cannot raise on the receiver’s domain, and the result is a scalar that round-trips through Type::Combinator.constant_of. Combined with inter-procedural inference (v0.0.2 #5):

    class Parity
    def is_odd(n) = n.odd?
    end
    Parity.new.is_odd(3) # was `false | true` in v0.0.2
    # is now `Constant[true]`
  • Cartesian fold over Union[Constant…]. Binary arithmetic and comparison fold pairwise across Union receivers and arguments, deduplicate, and rebuild a precise Union[Constant…] result. Bounded by UNION_FOLD_INPUT_LIMIT = 32 and UNION_FOLD_OUTPUT_LIMIT = 8; when the output cap is exceeded for an Integer-only result set, the analyzer gracefully widens to the bounding IntegerRange[min, max] instead of giving up.

  • Type::IntegerRange carrier and range arithmetic. PHPStan- style int<min, max> family with named aliases positive-int (1..), non-negative-int (0..), negative-int (..-1), non-positive-int (..0), and int<a, b>. Erases to Integer in RBS. Binary +, -, *, /, % and unary succ / pred / abs / -@ / even? / odd? / bit_length / zero? / positive? / negative? all fold precisely. Single-point intersections (int<5, 5>) collapse to Constant[5].

  • Scope-level range narrowing through comparisons and predicates. if x > 0 ... end narrows x to positive-int on the truthy edge, non-positive-int on the falsey edge. Same for <, <=, >=, the reversed forms (0 < x), x.positive? / x.negative? / x.zero? / x.nonzero?, and x.between?(a, b). The narrowing intersects with an existing IntegerRange bound when one is already in scope.

  • case/when integer-range narrowing. case n when 1..10 then … narrows n to int<1, 10> inside the body; when 1...10 narrows to int<1, 9> (exclusive end); when (100..) narrows to int<100, max>; when (..-1) narrows to negative-int; when 0 narrows to Constant[0].

  • Iterator block-parameter typing. 5.times { |i| … } types i as int<0, 4>; 1.times { |i| … } collapses to Constant[0]; 3.upto(7) { |i| … } and 7.downto(3) { |i| … } both type i as int<3, 7>. Wider Integer receivers (Nominal[Integer], positive-int) fall back to non-negative-int.

  • Branch elision on provably-truthy/falsey predicates. if 4.even? ; :even ; else ; :odd ; end resolves to Constant[:even] only — the dead branch is skipped — when the predicate’s narrow_truthy / narrow_falsey collapses one side to Bot. Constant[true] / Constant[false] / Nominal[Integer] (always truthy) all qualify; Union[true, false] keeps both branches active as before.

  • Tuple-shaped Integer#divmod / Float#divmod folds. 5.divmod(3) lifts to Tuple[Constant[1], Constant[2]] so multi-target destructuring threads the per-slot type into locals (q, r = 11.divmod(4) binds q: 2, r: 3). Float / mixed Integer-Float divmod produces a mixed Tuple[Constant<Integer>, Constant<Float>].

  • Built-in method catalog extraction pipeline. tool/extract_builtin_catalog.rb parses CRuby’s Init_<Topic> blocks (Numeric / Integer / Float / String / Symbol / Array / IO / File), classifies each cfunc body statically (leaf / leaf-when-numeric / dispatch / block-dependent / mutates-self / raises / unknown), and joins the result with the matching references/rbs/core/*.rbs signatures. Output lives at data/builtins/ruby_core/<topic>.yml (regenerated via make extract-builtin-catalogs). Generated YAML ships with the gem.

    Rigor::Inference::Builtins::NumericCatalog / STRING_CATALOG / ARRAY_CATALOG consume the catalogs at runtime and gate the constant-fold dispatcher on per-method purity. Per-class blocklists guard against classifier false positives (the C-body regex does not follow indirect mutators like rb_str_replacestr_modifiable); bang-suffixed selectors are universally blocked.

    Folds unlocked in v0.0.3 include: Integer#**, &, |, ^, <<, >>, ===, div, fdiv, modulo, remainder, pow; Float#**; String#[], include?, start_with?, end_with?, index, count, inspect; Symbol#length, empty?, casecmp?.

  • Type::IntegerRange returns from container #size / #length / #bytesize. Nominal[Array]#size, Nominal[String]#length, Nominal[Hash]#size, Nominal[Set]#size, Nominal[Range]#size now return non_negative_int instead of the RBS-declared Integer. Composes with the comparison-narrowing tier so if arr.size > 0 narrows the local to positive-int and arr.size - 1 evaluates as non-negative-int.

  • File path-manipulation folding (opt-in). File.basename, #dirname, #extname, #join, #split, #absolute_path? over Constant<String> arguments fold to a precise Constant (or Tuple[Constant, Constant] for split) when fold_platform_specific_paths: true is set in .rigor.yml. Default mode is platform-agnostic — these methods read File::SEPARATOR / ALT_SEPARATOR and would otherwise bake the analyzer-host’s platform into the inferred type — so the RBS tier answers with Nominal[String] / Tuple[String, String] / bool. Single-platform projects opt in for the precision payoff; cross-platform projects keep the safe envelope.

  • Type::Difference carrier (OQ3 point-removal half). Difference[base, removed] represents base minus a finite removed value set, the structural primitive every imported-built-in refinement of the “non-empty / non-zero / non-empty-array / non-empty-hash” family uses. Acceptance is conservative: only Constant and same-removed Difference candidates can be proved disjoint from the removed set, so Difference[String, ""].accepts(Nominal[String]) correctly returns no (the wider Nominal could be ""). MethodDispatcher::ShapeDispatch projects the empty-removal case directly: nes.sizepositive-int, nes.empty?Constant[false], nzi.zero?Constant[false]. Erases to the base nominal in RBS.

  • Rigor::Builtins::ImportedRefinements registry. Maps every imported-built-in kebab-case name (non-empty-string, non-zero-int, non-empty-array, non-empty-hash, positive-int, non-negative-int, negative-int, non-positive-int) to its Rigor type carrier. Single integration point for RBS::Extended and for future tokeniser slices.

  • rigor:v1:return: RBS::Extended directive. Overrides a method’s RBS-declared return type with one of the imported-built-in refinements. Annotation in the sig file:

    class User
    %a{rigor:v1:return: non-empty-string}
    def name: () -> String
    %a{rigor:v1:return: positive-int}
    def age: () -> Integer
    end

    At call sites the override propagates: User.new.name.size is positive-int, User.new.name.empty? is Constant[false], User.new.age.zero? is Constant[false]. The RBS erasure stays at the base nominal so the round-trip to ordinary RBS is unaffected. Unknown refinement names degrade to the RBS-declared return (silent miss, no crash).

  • always-raises diagnostic rule. 5 / 0, 5 % 0, 5.div(0), 5.modulo(0), 5.divmod(0), and rand(100) / 0 all surface as :error diagnostics under rule always-raises (“always raises ZeroDivisionError”). Float arithmetic (5.0 / 0 returns Infinity) and Integer#fdiv(0) stay silent. Suppressible per-line via # rigor:disable always-raises.

  • Implicit-self calls prefer in-source def over RBS dispatch. When node.receiver is nil (true implicit self) and the file has a same-named top-level def (or DSL-block-nested def, e.g. inside RSpec.describe ... do ... end), the engine routes through inter-procedural inference on that body before consulting the receiver class’s RBS. When the local def’s parameter shape is too complex for the binder (kwargs / optionals / rest), the engine returns Dynamic[Top] instead of falling through to (incorrect) RBS dispatch.

  • RSpec matcher narrowing. The engine recognises a small catalogue of RSpec matcher patterns as assert-shaped narrows on the local passed to expect(...). expect(x).not_to be_nil / expect(x).to_not be_nil drop NilClass from x’s type; expect(x).to be_a(C) / be_kind_of(C) narrow x to C (subtype-permitting); be_an_instance_of(C) / be_instance_of(C) narrow exactly. Pattern matching is purely AST-shape — no RBS for RSpec is required.

  • fold_platform_specific_paths configuration option. Boolean in .rigor.yml, default false. Enables File path-manipulation folds (see above) for projects that target a single platform.

  • Robustness principle (Postel’s law) for types. New ADR (docs/adr/5-robustness-principle.md) and normative spec section (docs/type-specification/robustness-principle.md) document the asymmetric authorship rule: Rigor-authored return types should be as strict as can be proved; Rigor-authored parameter types should be as permissive as the body’s correct behaviour permits. Hand-written RBS authorship binds; the principle directs Rigor’s defaults only.

  • ADR-3 working decisions. OQ1 (Constant scalar shape): Option C (hybrid). OQ2 (Trinary-returning predicate naming): Option A (drop the ?). OQ3 (refinement carrier strategy): Option C (two-tier hybrid — Difference for point-removal, Refined for predicate-subset; the latter ships in v0.0.4).

  • Rigor::Analysis::CheckRules arity_eligible? / argument_check_eligible? no longer raise when the RBS function is RBS::Types::UntypedFunction (e.g. (?) -> or certain stdlib variadic sigs). Both predicates now return false for untyped functions — the conservative outcome — instead of crashing the file’s analysis.

  • ConstantFolding’s union fold no longer silently drops members for which the method is unsupported. The previous behaviour folded Union[Constant[String], Constant[nil]].nil? to Constant[true] because String#nil? was not in STRING_UNARY and the partial fold dropped the String pair. The fold now requires every receiver’s method to be in the allow set; partial coverage bails to RBS instead of producing a wrong answer.

The second preview. v0.0.2 closes the must-have envelope around the v0.0.1 pipeline: a richer RBS::Extended directive surface (assert / assert-if-true / assert-if-false, ~T negation, target: self), inter-procedural inference for user-defined methods, an argument-type-mismatch rule, per-rule diagnostic suppression (project-level + in-source comments), configuration passthrough for stdlib libraries and signature paths, and a --explain mode that surfaces fail-soft fallback events.

  • rigor check --explain mode. Surfaces fail-soft inference fallbacks as :info diagnostics so users can see where the engine degraded to Dynamic[Top]. Driven by Rigor::Inference::CoverageScanner so each event is attributable to the leaf node that triggered it (pass-through wrappers like ProgramNode / StatementsNode / ParenthesesNode are not double-counted). Each diagnostic carries rule: "fallback", severity: :info, and a short message naming the node class and the type the engine fell back to. Info diagnostics do not fail the run.

  • .rigor.yml libraries: and signature_paths: keys. The configuration layer now passes through to Rigor::Environment.for_project:

    • libraries: lists stdlib libraries to load on top of Environment::DEFAULT_LIBRARIES (e.g. ["csv", "set"]). Each entry must be a name accepted by RBS::EnvironmentLoader#has_library?; unknown libraries fail-soft.
    • signature_paths: is an explicit list of sig/-style directories. Leaving the key unset (or null) preserves the auto-detect-<root>/sig default; [] disables project-RBS loading entirely.

    Wired through rigor check, rigor type-of, and rigor type-scan (the latter two gain a --config=PATH option matching check).

  • Per-rule diagnostic suppression. Two mechanisms compose:

    • Project-level: .rigor.yml’s new disable: key accepts a list of rigor check rule identifiers (undefined-method, wrong-arity, argument-type-mismatch, possible-nil-receiver, dump-type, assert-type); matching diagnostics are silenced project-wide.
    • In-source: # rigor:disable <rule> (or <rule1>, <rule2>) at the end of an offending line silences per-line. # rigor:disable all suppresses every rule on that line.

    Rigor::Analysis::Diagnostic gains a rule: field carrying the source rule’s stable identifier. Parse errors / path errors / internal analyzer errors leave rule as nil and stay unsuppressible.

  • Inter-procedural inference for user-defined methods. When a call’s receiver is Nominal[T] for a user-defined class without an RBS sig and the method has been discovered as an instance def, the engine re-types the method’s body at the call site with the call’s argument types bound to the parameters and returns the body’s last-expression type. The user_methods.rb integration fixture now resolves Parity.new.is_odd(3) to false | true (was Dynamic[top] in v0.0.1) without requiring an RBS sig.

    First iteration accepts only the simplest parameter shape (required positionals, no optionals / rest / keywords / block params); receiver must be Nominal (not Singleton); recursion is guarded by a per-thread inference stack so mutually recursive helpers fall back to Dynamic[Top] rather than infinite-looping.

  • rigor check ships an argument-type-mismatch rule. For every explicit-receiver Prism::CallNode whose method has exactly one RBS overload (no rest_positionals, no required keywords, no trailing positionals), the rule routes each positional argument’s inferred type through Rigor::Inference::Acceptance.accepts(parameter, argument, mode: :gradual) and emits an :error for the first argument the parameter does not accept. Argument or parameter types known only as Dynamic skip the check (the call cannot be statically refuted). The receiver must be Nominal / Singleton / Constant; user-class fallback / shape carriers behave as in the wrong-arity rule. The rule respects RBS even when the user has both a def and a sig: the sig is the authoritative parameter contract.

  • Rigor::Inference::Acceptance now treats Singleton[T] as a subtype of Module, Class, Object, and BasicObject. Without this rule a method whose parameter is typed Class | Module (e.g. Object#is_a?, Module#define_method) rejected every singleton receiver, producing systemic false positives across both lib/ and spec/.

  • RBS::Extended target: self directives now actually narrow the receiver local on the matching edge (was: parser accepted but engine discarded). Covers all three rule shapes:

    • predicate-if-true self is LoggedInUser / predicate-if-false self is User — narrows the receiver local on the truthy / falsey edge of an if / unless predicate.
    • assert-if-true self is AdminUser — same shape, applied when the call is observed as a truthy predicate.
    • assert self is RegisteredUser — narrows the receiver local unconditionally at the post-call scope.

    Narrowing only fires when the call’s receiver is a Prism::LocalVariableReadNode (the engine’s narrowing surface) AND the receiver type is statically known (Nominal / Singleton / Constant — required for the engine to even resolve which class’s method carries the annotation).

  • RBS::Extended recognises negation in predicate / assert directives via the ~ClassName syntax:

    • predicate-if-true value is ~NilClass narrows value AWAY from NilClass on the truthy edge.
    • assert value is ~NilClass narrows value AWAY from NilClass in the post-call scope.

    Rigor::RbsExtended::PredicateEffect#negative? and AssertEffect#negative? are new boolean predicates; the parser sets them when the directive’s type literal starts with ~. The engine routes negative effects through Narrowing.narrow_not_class instead of narrow_class so the union loses the named class on the active edge.

  • RBS::Extended recognises three additional directives:

    • rigor:v1:assert <target> is <Class> — refines the matching argument’s local in the post-call scope unconditionally. Wires through StatementEvaluator#eval_call.
    • rigor:v1:assert-if-true <target> is <Class> — refines the argument when the call is observed as a truthy predicate (e.g. if call_node). Wires through Narrowing.predicate_scopes alongside predicate-if-*.
    • rigor:v1:assert-if-false <target> is <Class> — symmetric for falsey.

    The three directives complement predicate-if-true / predicate-if-false — together they cover the must_be_string! / validate! / valid_string? / integer? patterns common in Ruby. Rigor::RbsExtended::AssertEffect is the new data class returned by RbsExtended.read_assert_effects(method_def).

  • Rigor::Environment::DEFAULT_LIBRARIES now includes tmpdir, stringio, forwardable, digest, and securerandom. Common stdlib calls (Dir.mktmpdir, StringIO.new, Forwardable#def_delegator, Digest::SHA256.hexdigest, SecureRandom.hex) resolve through their RBS sigs without the user having to enumerate the libraries themselves.

  • Rigor::Analysis::CheckRules dump_type / assert_type rules are suppressed when the call site’s self_type is Rigor or Rigor::Testing. The reflexive Testing.dump_type(value) / Testing.assert_type(...) calls inside Rigor’s own stub no longer surface diagnostics on rigor check lib.

The first preview release. Rigor can be pointed at a real Ruby project, infer types end-to-end through a flow-sensitive scope, and emit diagnostics for a small but practical rule catalogue.

The gem is published to RubyGems as rigortype (the rigor name was already taken). The Ruby module name remains Rigor, so user code uses require "rigor" and references Rigor::Scope, Rigor::Testing, etc. — only the gem install / Gemfile line uses rigortype.

  • rigor check end-to-end pipeline. Parses Ruby through Prism, builds a per-node scope index, and runs a three-rule catalogue against it:
    • undefined method on a typed receiver,
    • wrong number of positional arguments,
    • possible nil receiver (with safe-navigation and early-return narrowing exclusions). False positives on reopened classes, define_method-defined methods, constant-decl-aliased classes (YAMLPsych), and dynamic / unknown receivers are suppressed.
  • rigor type-of FILE:LINE:COL — probes the inferred type at any source position.
  • rigor type-scan PATH... — coverage report over a tree.
  • rigor init — writes a header-commented .rigor.yml.
  • Type model. Top, Bot, Dynamic[T], Constant[v], Nominal[Class, type_args], Singleton[Class], Union[A, B, ...], Tuple[T1, ..., Tn], and HashShape carriers with required / optional / read-only key policies. Trinary (yes/no/maybe) and AcceptsResult.
  • Inference engine. Local, instance, class, and global variable bindings tracked through Rigor::Scope. Cross-method ivar / cvar accumulators populated by a ScopeIndexer pre-pass; program-wide globals.
  • Compound writes (||=, &&=, +=, -=, *=, …) thread through scope for every variable kind, with operator dispatch via MethodDispatcher.
  • self typing. Class- and method-body boundaries inject Singleton[T] / Nominal[T]; implicit-self call dispatch routes through the enclosing class’s RBS.
  • Lexical constant lookup. Project sig, RBS-core, common stdlib bundle (pathname, optparse, json, yaml, fileutils, tempfile, uri, logger, date, prism, rbs), in-source class discovery, and in-source constant value tracking.
  • Predicate narrowing. Truthiness, nil?, is_a? / kind_of? / instance_of?, finite-literal equality, case-equality (===) for Class / Module / Range / Regexp, and case / when integration.
  • Block parameter binding including destructuring (|(a, b), c|) and numbered parameters (_1, _2, …). Block-return-type uplift through generic methods so [1, 2, 3].map { |n| n.to_s } resolves to Array[String].
  • Closure escape analysis. A core-and-stdlib catalogue of block-accepting methods is classified as :non_escaping (Array#each / map / select / …), :escaping (Module#define_method, Thread.new, Proc.new, …), or :unknown. Escaping calls drop narrowed types of captured outer locals the block can rebind and record a closure_escape fact in the FactStore.
  • RBS::Extended predicate effects. Methods whose RBS signature carries %a{rigor:v1:predicate-if-true target is T} / predicate-if-false annotations narrow the matching argument on the corresponding edge.
  • PHPStan-style typing helpers. Rigor::Testing.dump_type surfaces the inferred type as an :info diagnostic; Rigor::Testing.assert_type("expected", value) errors when the inferred type’s short description does not match. Use in fixtures to make them self-asserting.
  • Self-asserting integration suite. Fixture-driven examples under spec/integration/fixtures/ covering parity / case-when / compound writes / is_a? narrowing / Tuple and HashShape access / Array#map block-return uplift / early-return narrowing / RBS::Extended predicates / user-defined method dispatch.
  • Inter-procedural inference for user-defined methods. A helper like def is_odd(n) = n.odd? types correctly inside the def, but the caller observes Dynamic[top] until an RBS sig is supplied. The spec/integration/fixtures/user_methods* pair pins both shapes (no sig vs project sig).
  • RBS::Extended ships only the predicate-effect surface. assert / assert-if-true / assert-if-false, negation (~T), self-targeted narrowing, intersection / union refinements, param / return / conforms-to directives are deferred.
  • No persistent cache — every rigor check run re-parses and re-types the project.
  • No plugin contribution layer past the bundled RBS::Extended reader.
  • Per-rule severity is hard-coded to :error (with :info reserved for dump_type); per-rule configuration and suppression comments are deferred.

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.