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.x → 0.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.x → 0.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.
0.0.9 - 2026-05-05
Section titled “0.0.9 - 2026-05-05”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.
Cache layer wired through to rigor check
Section titled “Cache layer wired through to rigor check”Analysis::Runner.cache_storesurface +rigor check --no-cache. Runner defaults to aCache::Storerooted at.rigor/cache; the CLI flag threadsnilthrough to disable.Environment.for_project(cache_store:)plumbs the Store down to the underlyingRbsLoader.- First end-to-end cached producer —
RbsLoader#constant_typereads fromRbsConstantTable. 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 aMarshal.loadof the table. - Five more cache producers —
RbsKnownClassNames(Set), RbsClassAncestorTable(Hash<String, Array>), RbsClassTypeParamNames(Hash<String, Array>), and RbsEnvironment(the fullRBS::Environment). The fifth producer (RbsEnvironment) caches the biggest cold-start cost —RBS::EnvironmentLoader#load + Environment.from_loader + resolve_type_names— by adding minimal_dump/_loadMarshal hooks to the rbs gem’s C-extensionRBS::Location. The patch is purely additive and idempotent;RBS::Locationis never read from any analysis path so the lost source-position metadata is inert. Cache::Store#stats+--cache-statsruntime breakdown. In-process hits / misses / writes counters (per-producer breakdown) bumped insidefetch_or_compute;rigor check --cache-statsprints an on-disk inventory followed by a “this run:” section. Under--no-cachethe section is omitted.Cache::Store#fetch_or_compute(serialize:, deserialize:)callable surface. Producers whose return values are not Marshal-clean (RBS-native objects withRBS::Locationmembers, rawIO, …) can register custom round-trip callables. Default stays atMarshal.dump/Marshal.load. Deserialiser exceptions become cache misses.RbsEnvironmentrides this surface.- Shared
Rigor::Cache::RbsDescriptor. Every RBS-derived producer attaches the same descriptor (rbs gem locked version +:digestentries for every.rbsfile undersignature_paths+ arbs.librariesconfigs entry), so a signature change or rbs gem bump invalidates them in lockstep.
Type vocabulary
Section titled “Type vocabulary”- Paired-complement narrowing for
Refined[base, predicate].Type::Refined::COMPLEMENT_PAIRSregisters bidirectional pairs; the narrowing tier returnsRefined[base, complement]instead of the impreciseDifference[base, refined]fallback. Three pairs land in v0.0.9:lowercase ↔ not_lowercase,uppercase ↔ not_uppercase,numeric ↔ not_numeric. Positive carriersnon-lowercase-string,non-uppercase-string,non-numeric-stringjoinBuiltins::ImportedRefinements::REGISTRYso users can write them directly. literal-stringcarrier andnon-empty-literal-stringcomposition. AStringknown to come from a source-code literal (or a composition of literals). Tracked through string interpolation"#{...}"(lifts toliteral-stringwhen every part is literal-bearing) and through the newLiteralStringFoldingdispatcher tier coveringString#+,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#clonereclassified.numerictopic’sc_index_pathsnow includesreferences/ruby/object.c, soNumeric#clone’s alias torb_immutable_obj_cloneis found by the C-body classifier and the entry moves frompurity: unknowntopurity: 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::FlowContributionbundle struct. Eight content slots (return_type,truthy_facts,falsey_facts,post_return_facts,mutations,invalidations,exceptional,role_conformance) plus aProvenanceData 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 oneFlowContributionwith:rbs_extendedsource 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 atspec/rigor/public_api_drift_spec.rbpins 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).
Internal
Section titled “Internal”- The cache layer’s public read shape grows to cover all six producers in
docs/internal-spec/cache.md:Descriptor,Store(with the newserialize:/deserialize:kwargs andStore#stats),RbsConstantTable,RbsKnownClassNames,RbsClassAncestorTable,RbsClassTypeParamNames,RbsEnvironment, the sharedRbsDescriptorbuilder, and theRBS::LocationMarshal patch. Rigor::FlowContributiondocumented indocs/internal-spec/flow-contribution.mdwith the slot table, equality /to_h/empty?semantics,RbsExtended.read_flow_contributionmapping (predicate-if-* →truthy_facts/falsey_facts,assert*→post_return_facts,return:→return_type), and the deferred element-list flattening note.
0.0.8 - 2026-05-04
Section titled “0.0.8 - 2026-05-04”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::Descriptorvalue object. Pure-value four-slot schema (files,gems,plugins,configs) perdocs/design/20260505-cache-slice-taxonomy.md. Each slot holds typed, frozen entries;FileEntryvalidates 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 raisesDescriptor::Conflicton disagreeing values.descriptor.cache_key_for(producer_id:, params:)derives the canonical hex SHA-256 over the composed inputs;to_canonical_bytesproduces sorted, deterministic JSON so equivalent descriptors round-trip to identical bytes.Rigor::Cache::Storefilesystem 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 withflock(LOCK_EX)on the destination andfsyncon 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 aHash<String, Rigor::Type>mapping every RBS-declared constant (e.g."::Math::PI") to its translatedRigor::Type. Descriptor: therbsgem with its locked version,:digestentries for every.rbsfile undersignature_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 discoveredRBS::Environmentis not Marshal-clean (RBS::Locationis a C-extension class without_dump_data). ADR-6 § 8 documents the finding; the slice caches a post-translation artefact instead.RbsLoader#constant_namesis 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 newRigor::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/cachedirectory (CWD-relative) before the analysis run. PrintsCleared cache: .rigor/cacheorCache already empty: .rigor/cache. The check itself runs to completion regardless.- Diagnostic source-family provenance.
Rigor::Analysis::Diagnosticgains asource_family:keyword (default:builtin) and aqualified_ruleaccessor returning"#{source_family}.#{rule}"for non-default families and justrulefor builtin diagnostics. JSON output (to_h) carries bothsource_familyand the bareruleside-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.
Internal
Section titled “Internal”- New normative spec
docs/internal-spec/cache.mdtracks the cache layer’s public read shape (Descriptor API, Store API, file format, atomicity & locking, schema-version mismatch behaviour, disk inventory, diagnostic provenance).
0.0.7 - 2026-05-05
Section titled “0.0.7 - 2026-05-05”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.
Type-language type functions
Section titled “Type-language type functions”key_of[T]/value_of[T]project the type-level union of known keys (resp. values) forHashShape,Tuple,Nominal[Hash, [K, V]],Nominal[Array, [E]], and finite-boundConstant<Range>. Reachable throughRBS::Extendeddirective payloads. The parser also acceptslower_snakeheads alongsidekebab-caserefinements and lets nominal arguments carry their own type-args, sokey_of[Hash[Symbol, Integer]]parses toSymbol.int_mask[1, 2, 4]/int_mask_of[T]compute the bitwise-OR closure over a finite integer literal set, returning aUnion[Constant<Integer>…]for small closures and a coveringIntegerRangepast the cardinality cap. Integer literals are now accepted as parser arguments.T[K]indexed-access operator projects the type at index / keyKfrom a structuredT. Reachable from RBS::Extended directive payloads through trailing[K]segments after a parsed type, including chainedT[K1][K2]. The parser’s top-level entry now accepts class-name-headed types directly, soHash[Symbol, Integer][Symbol]parses toSymbol.
Constant-carrier coverage
Section titled “Constant-carrier coverage”Rational/Complexliteral lift.Prism::ImaginaryNode(1i) andPrism::RationalNode(1.5r) type asConstant<Complex>/Constant<Rational>;Kernel#Rational(num, den)andKernel#Complex(re, im)calls fold to the same precise constants when every argument is a numeric Constant.foldable_constant_value?widens to acceptRational/Complex, unblocking every catalog-tierRational#…/Complex#…fold against constant receivers.Regexpliteral lift. Non-interpolatedPrism::RegularExpressionNodelifts toConstant<Regexp>(preserving source and option flags); interpolated regexes keep the conservativeNominal[Regexp]. Activates the newConstant<String>#scan(/regex/)fold path end-to-end.- Pathname delegation.
PathnamejoinsType::Constant::SCALAR_CLASSES;Pathname.new(Constant<String>)lifts via aMethodDispatcher#meta_newconstant-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.
Constant precision
Section titled “Constant precision”to_alifts to a per-positionTuple[…]for finite integer ranges (capped at 16 elements);first/last/min/maxandcount/size/lengthfold to preciseConstant<Integer>values for the no-arg form, bypassing the catalog’s:block_dependentclassification 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 toConstant[bool]per the tuple’s arity and element truthiness.include?(needle)folds to a precise bool when the needle is aConstantand the tuple’s elements are allConstant.sum/min/maxfold to numeric / comparable extremes for all-Constant elements.sort/reversereturn per-position Tuples in the appropriate order.to_areturns the receiver Tuple unchanged.zippairs the receiver’s per-position elements with the per-position elements of each other Tuple-shaped argument; short other-Tuples pad withConstant[nil]; multi-argzipproduces wider per-position Tuples (capped at 8).
HashShape precision
Section titled “HashShape precision”keys/valuesfold to per-position Tuples preserving declaration order.count/lengthmatch the existingsizehandler.empty?/any?(no-arg, no-block) fold toConstant[bool]per the shape’s emptiness.first/flatten/compactfor closed shapes with no optional keys:firstreturns the[k, v]2-Tuple of the first pair;flattenproduces the per-position[k_1, v_1, k_2, v_2, …]Tuple;compactdrops every entry whose value isConstant[nil].- Tuple ↔ HashShape conversions —
Tuple#to_h,HashShape#to_a,HashShape#to_h,HashShape#invert(Symbol-/String-valued shapes only),HashShape#merge(other)for closed-shape × closed-shape merges.
String precision
Section titled “String precision”- Format-string fold over
Tuple/HashShapearguments."%d / %d" % [1, 2]folds toConstant<"1 / 2">;"%{name} is %{age}" % {name: "Alice", age: 30}folds toConstant<"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, orConstant<Regexp>pattern) /s.scanlift the resulting Array to a per-positionTuple[Constant…]when every element is a foldable scalar and the cardinality fits within 32. Larger results decline so the RBS tier widens.
Refinement narrowing
Section titled “Refinement narrowing”~Refined[base, predicate]narrows throughDifference[base, refined]instead of falling back tocurrent_typeunchanged.assert value is ~lowercase-stringnow narrowsStringtoDifference[String, lowercase-string]. The De Morgan composition for Intersection refinements also tightens.
Empty literal carriers
Section titled “Empty literal carriers”{}→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-positionTuple[…]whennis a smallConstant<Integer>(capped at 16). Oversizenfalls back toNominal[Array].
Pre-v0.1.0 substrate
Section titled “Pre-v0.1.0 substrate”Rigor::Reflectionread-side facade joins Rigor’s three reflection sources (ClassRegistry+RbsLoader+Scopediscovered 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 atdocs/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 rawscope.environment.rbs_loaderaccess to the facade. The facade gainsrbs_class_known?,instance_definition/singleton_definition,class_type_param_names, and anenvironment:kwarg variant for dispatcher call paths that don’t have aScopein 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 (FileEntrywith:digest/:mtime/:existscomparators,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.
0.0.6 - 2026-05-05
Section titled “0.0.6 - 2026-05-05”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 }, orarr.any? { false }collapse to the precise endpoint when the block’s inferred return type is a Ruby-truthy or Ruby-falseyConstant. Filter methods (select/filter/reject/take_while/drop_while) fold to either the receiver orTuple[]; predicate methods (all?/any?/none?) fold toConstant[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 againstTuple,HashShape,Constant<Array|Hash|String|Range>, and the importednon-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/collectproduceTuple[U_1..U_n].[1, 2, 3].map { |n| n.to_s }resolves to["1", "2", "3"]instead ofArray["1" | "2" | "3"].filter_mapdropsConstant[nil]/Constant[false]positions and concatenates the survivors into a Tuple.flat_mapconcatenates per-positionTupleresults, treating per-positionConstantscalars as single-element contributions and declining on opaque carriers.find/detectreturn the receiver element at the first truthy position (orConstant[nil]when every position is falsey).find_index/indexreturn the index of the first truthy position (orConstant[nil]). The value-search formsindex(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 correspondingConstant<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 toConstant[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 aType::Constantdrop 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 toTuple[Constant["2"]]. &&/||short-circuit elision on Constant-shaped left operands. When the left operand of&&/||folds to aType::Constant, the result type follows Ruby’s actual short-circuit semantics:Constant[truthy] && rhsis the right operand’s type,Constant[falsey] && rhskeeps 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 toConstant[nil];count { false }folds toConstant[0];count { true }folds toConstant[size]when the receiver pins a finite size (Tuple, HashShape, orConstant<Range>with finite integer endpoints). The value-search formsindex(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-argtry_fold_ternarypath now accepts anIntegerRangereceiver paired with two scalarConstant<Integer>args.int<3, 7>.between?(0, 10)folds toConstant[true];int<3, 7>.clamp(4, 6)folds toint<4, 6>(collapsing to aConstantwhen 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 toTuple[]. The empty array literal previously typed asNominal[Array]; v0.0.6 switches it to the emptyTuple[]carrier so the per-element block fold can concatenate cleanly across all-empty positions like[1, 2, 3].flat_map { |_| [] }(now folds toTuple[]). Both carriers erase to plainArrayon the RBS-interop path. - Pathname catalog import.
data/builtins/ruby_core/pathname.yml(102 instance methods, 2 singletons, 5 aliases) and the matchingBuiltins::PATHNAME_CATALOGjoin the catalog tier. Pathname is a thin wrapper that mostly delegates toFile/Dir/FileTest, so the user-visible payoff is narrower than Numeric or String — the import buys receiver-class recognition forPathname.new(...), a defensive:initialize_copyblocklist entry, and catalog folding for the lone:leafmethod (<=>).
tool/extract_builtin_catalog.rbrescue-on-def classifier crash.PreludeParser#analyse_bodypreviously raisedNoMethodErroron Ruby methods written with the rescue-on-def idiom (def foo; …; rescue; …; end) because Prism wraps the body in aBeginNoderather than aStatementsNode. The classifier now descends into the begin-block’sstatementsfor that case. The bug surfaced importing Pathname (whose prelude hasdef initialize(path); @path = …; rescue TypeError; …; end); every catalog regenerates cleanly undermake extract-builtin-catalogs.
0.0.5 - 2026-05-03
Section titled “0.0.5 - 2026-05-03”-
Rational and Complex built-in catalog imports. New loaders
RATIONAL_CATALOGandCOMPLEX_CATALOGjoin theCATALOG_BY_CLASStable; the corresponding YAMLs underdata/builtins/ruby_core/{rational,complex}.ymlare generated fromreferences/ruby/{rational,complex}.cviatool/extract_builtin_catalog.rb. Both classes are fully immutable in Ruby, so the per-classmutating_selectorsblocklists carry only the conventional defence-in-depth:initialize_copyentry. Rigor today has noConstant<Rational>/Constant<Complex>literal lift (Prism::ImaginaryNodeandRational(...)/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 aNominal[<class>]receiver. The blocklist becomes load-bearing once a future slice teaches the typer to lift these literals intoConstant<…>. -
Const = Data.define(*Symbol)discovery.Inference::ScopeIndexer.record_declarationsnow registersConst(qualified by the surrounding class / module path) as a discovered class whose constant resolves toSingleton[<qualified-name>]. PreviouslyConst.new(...)returned the un-narrowedDynamic[top]envelope; with the constant registered,meta_newresolves it to a freshNominal[<qualified-name>], and member accessors flow through the user-class fallback without false-positives. Both the bare formData.define(:x, :y)and the block-override formData.define(:x, :y) do; def initialize(x:, y:); …; end endare recognised; non-symbol arguments and non-Datareceivers are rejected. Worked example:TargetandFactinlib/rigor/analysis/fact_store.rbnow type assingleton(Rigor::Analysis::FactStore::Target)andsingleton(Rigor::Analysis::FactStore::Fact)respectively. -
Kernel#Arrayprecision tier (MethodDispatcher::KernelDispatch). A new precision-tier dispatcher foldsArray(arg)into a preciseArray[E]whenever the argument’s value-lattice shape lets us prove the element type. The rules mirror Ruby’s coercion contract —Array(nil) -> [], an existingArray[E]preserves its element, a Tuple materialises toArray[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: inlib/rigor/analysis/fact_store.rb#fact_targets,Array(fact.target)overfact.target: Target | Array[Target]previously typed asArray[Dynamic[top]]; it now types asArray[Target]. -
Branch-aware scope propagation for expression-position conditionals.
Inference::ScopeIndexer.propagatenow special-casesPrism::IfNodeandPrism::UnlessNode, threading the predicate’s narrowed truthy / falsey scopes into the corresponding branch subtrees. Previously, when anif/unlesssat in expression position (e.g. as a call argument or the RHS of an[]=), the indexer never routed it througheval_if’s narrowing path, so inner nodes inherited the un-narrowed entry scope and downstream rules (possible-nil-receiver, type-of probes) saw spuriousT | nil. Worked example:cache[k] = if x; x.foo; else; default; endnow seesxnarrowed to its non-nil fragment inside the truthy branch, matching the behaviour for the statement-level formif x; cache[k] = x.foo; else; cache[k] = default; end. Specs atspec/rigor/inference/scope_indexer_spec.rb#narrows IfNode branches when the conditional sits in expression position(and theUnlessNodemirror) bind both shapes. -
RbsLoader#instance_definition/#singleton_definitionnow declared asuntyped?. 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’snil-on-unknown-class return contract. -
Two-argument constant-fold dispatch.
MethodDispatcher::ConstantFolding#try_foldpreviously switched onargs.sizeand only handled the 0- and 1-arg shapes; 2-arg leaf methods likeComparable#between?(min, max), the explicit-bounds form ofComparable#clamp(min, max), andInteger#pow(exp, mod)all bailed to the RBS-widened tier. The dispatch now grows awhen 2arm routed throughtry_fold_ternary, which folds the cartesian product of receiver × arg0 × arg1 when every operand is aConstant(orUnion[Constant…]) and the catalog classifies the method:leaf/:trivial. The sameUNION_FOLD_INPUT_LIMITcap 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 toConstant[true],100.clamp(0, 10)folds toConstant[10],100.pow(50, 17)folds toConstant[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 twodata/builtins/ruby_core/<topic>.ymlsnapshots — per-class additions / removals / purity changes / cfunc renames / arity changes. The motivating use is areferences/rubysubmodule bump where the full YAML diff is noisy because it interleaves prose comments, RBS pulls, anddefined_atline 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_frozenwrappers as mutators. Per-class wrappers liketime_modify(time)/time_gmtime(time)whose entire body is one or morerb_check_frozen(...)calls used to be classified:leafeven though they centralise the mutation gate of the receiver.CBodyIndex#mutator_helpersnow returns the set of indexed cfuncs whose body matches the pure-frozen-check pattern, andCBodyClassifier.classifyflips the:mutateeffect on when a method calls one of those helpers. The pattern is intentionally narrow — naive transitive propagation over-flagged legitimate non-mutators likeArray#to_a, so only bodies that consist solely ofrb_check_frozencalls qualify. Re-extraction flips two Time methods (#gmtime,#utc, both bound totime_gmtime) from:leafto: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’sModule#ancestorsand 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 (itsrb_define_methodregistration 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 aninclude Comparable/include Enumerablemixin without a directrb_define_methodregistration now fold. Worked example:5.clamp(0..10)folds toConstant[5],100.clamp(0..10)folds toConstant[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.ymlandenumerable.ymlgenerated bytool/extract_builtin_catalog.rbfromInit_Comparable(compar.c) andInit_Enumerable(enum.c). Catalog stats: Comparable ships with 7 instance methods (the</<=/==/>=/>/between?/clampfamily); Enumerable ships with 58 instance methods (47:block_dependent, 9:leaf, 2:mutates_self). The matchingBuiltins::COMPARABLE_CATALOG/Builtins::ENUMERABLE_CATALOGsingletons are loaded at boot but NOT registered inMethodDispatcher::ConstantFolding::CATALOG_BY_CLASSbecause 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 theCATALOG_BY_CLASSrow, the fixture stub, and the integrationdescribeblock — 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) recognisesrb_mFoo = rb_define_module("Foo");registrations and records modules in the per-topicclassesmap withparent: "Module". Two previously-dropped module registrations (FileTestin Init_File,UnicodeNormalizein Init_String) now surface as empty-bucket class entries in their respective YAMLs. -
~refinementnegation extends to IntegerRange and Intersection.Narrowing.narrow_not_refinementpreviously only handledDifference[base, Constant[v]]; the algebra now covers two more carrier kinds:Type::IntegerRange[a, b]— complement is the two open halvesint<min, a-1>andint<b+1, max>, each intersected with the integer-domain parts ofcurrent_type. Non-integer parts of a Union receiver survive unchanged.assert n is ~int<5, 10>overn: Integernarrows toint<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) contributecurrent_typeitself, so the union may widen.~non-empty-lowercase-stringoverStringtherefore yieldsConstant[""] | Nominal[String]rather than the tighterConstant[""]we’d get with predicate-aware complement.Refined[base, predicate]keeps its conservativecurrent_typeanswer (predicate complements are not finite-carrier-expressible).
-
~refinementnegation inassert:/predicate-if-*:directives. The<target> is <RHS>right-hand side now accepts the~Tnegation prefix on the refinement arm in addition to the existing class-name arm. The narrowing tier introducesNarrowing.narrow_not_refinementfor 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) -> voidendAfter
v.assert_empty!(name)overname: String | nil, the narrowed type isConstant[""] | NilClass— the only inhabitants of the original union that are NOT non-empty strings. Other refinement carriers (Refined,Intersection,IntegerRange, andDifferencewhose removed is not a Constant) returncurrent_typeunchanged 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_consblock-parameter projections (placeholder; future plugin). RBS already binds these methods correctly for plainArray[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 (orTuple[K, V]pair) rather than the projectedArray[union]widening.group_by/partitionyield a single element;each_sliceandeach_consyieldArray[element](the slice-size argument is ignored at the dispatcher tier — a tighter Tuple-of-ncarrier 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.
IteratorDispatchcovers#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.ymlgenerated fromInit_date_coreinreferences/ruby/ext/date/date_core.cplus thelib/date.rbprelude. Both classes land in a single topic — DateTime inherits from Date and the same Init function registers both, sotool/extract_builtin_catalog.rbcarries 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_dependentclassifications. The blocklist inlib/rigor/inference/builtins/date_catalog.rbcovers:initialize_copy(defensive symmetry with String / Array / Range / Set / Time) and Date’s#ifndef NDEBUG-only:fillhelper, plus a mirrored:initialize_copyentry for the DateTime side.MethodDispatcher::ConstantFoldingroutesDateandDateTimereceivers through the newDATE_CATALOG; the DateTime row precedes Date inCATALOG_BY_CLASSso subclass receivers hit their dedicated entry first. Self-asserting fixturespec/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. NoRBS::Extended rigor:v1:return:overrides this slice — the reader surface is in the same situation as Time, where per-method ranges (#month∈int<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_continuationswalks the Init function body line by line and tracks paren depth to merge multi-line registration macros into a single logical line. The previousstrip_line_commentshelper only stripped/* … */runs that fit on one line, so multi-line rdoc blocks (very common above arb_define_classcall —cDateTime = rb_define_class("DateTime", cDate);indate_core.cis 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 sawDate.
0.0.4 - 2026-05-02
Section titled “0.0.4 - 2026-05-02”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.
OQ3 refinement carriers
Section titled “OQ3 refinement carriers”Type::Refinedcarrier (predicate-subset half). Sibling ofType::Difference. Wraps(base, predicate_id)wherepredicate_idis a Symbol drawn fromType::Refined::PREDICATES. Construction goes throughType::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 conservativeaccepts_differencepolicy — same-predicateRefinedplus recognisedConstantvalues get:yes, every other shape gets:no.Type::Intersectioncarrier — composed refinement names. Closes the OQ3 carrier strategy by adding the Intersection peer alongsideUnion/Difference/Refined. The carrier represents the meet of its members’ value sets. Construction performs the deterministic normalisation indocs/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_intersectioncombines per-member projections through an IntegerRange meet when every result is bounded-integer, so(non_empty_string ∩ lowercase_string).sizeresolves topositive-intrather than the loosernon-negative-int.- Fourteen imported built-in refinement names. All resolvable
through
Builtins::ImportedRefinements(and through the per-name factories onType::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_intand:hex_intREQUIRE their conventional prefix (0o/0O/ leading0;0x/0X), so a bare"755"isdecimal-int-string, notoctal-int-string. - Composed Intersection (new):
non-empty-lowercase-string,non-empty-uppercase-string.
- Point-removal (already in v0.0.3):
- Catalog-tier projections over
Refined[String, …].String#downcase/String#upcasefold per predicate: case-fold idempotence for:lowercase/:uppercase/:numericand the three base-N int-string predicates, plus the liftlowercase ↔ uppercasefor the cross calls. Size-tier projections still apply through the predicate carrier soString#sizeover aRefined[String, *]tightens tonon-negative-int. - Self-asserting fixtures.
predicate_refinement/,intersection_refinement/,parameterised_refinement/, plus the existingrefinement_return_override/from v0.0.3.
RBS::Extended directive surface
Section titled “RBS::Extended directive surface”-
rigor:v1:return:accepts parameterised refinement payloads. In addition to the bare-name shapes, the directive now acceptsnon-empty-array[T]/non-empty-hash[K, V](type-arg payloads whereT/K/Vmay be a kebab-case refinement name or a Capitalised RBS class name) andint<min, max>(bounded-integer range with signed integer literals). Parsing lives in a newBuiltins::ImportedRefinements::Parserrecursive-descent parser exposed throughImportedRefinements.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 thereturn:route landed in v0.0.3 and feature-complete on both sides of the method boundary:- Call-site half.
OverloadSelectorand theargument-type-mismatchcheck rule consultRbsExtended.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.
MethodParameterBinderreads the same override map and replaces the RBS-translated parameter binding with the refinement, so projections through the carrier (e.g.id.sizeresolving topositive-intover anon-empty-stringparameter) are observable inside the method body during inference.
The optional
isglue word matches the existingassert/predicate-if-*surface; authors MAY writeparam: id non-empty-stringinstead. End-to-end fixture:spec/integration/fixtures/param_extended/. - Call-site half.
-
rigor:v1:assert:andrigor: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. BothAssertEffectandPredicateEffectgain arefinement_typefield; the narrowing tier substitutes the carrier when present, keeping the legacynarrow_classpath for class-name directives. Refinement-form directives do not yet support~Tnegation — that would require a difference-against-refinement algebra and is reserved for a future slice.
CLI / display
Section titled “CLI / display”- CLI
type-ofconfirms the kebab-case canonical-name contract. New regression specs inspec/rigor/cli_spec.rbdrivebundle exec exe/rigor type-ofthrough the harness over both aDifference-backed refinement (non-empty-string) andRefined-backed refinements (lowercase-string,numeric-string), and assert that human-readable text and--format=jsonoutput both render the refinement in its kebab-case spelling while erasure folds back to the base nominal.
Built-in catalog imports
Section titled “Built-in catalog imports”Hashjoins the catalog-driven inference pipeline.data/builtins/ruby_core/hash.ymlis generated fromreferences/ruby/hash.c.Builtins::HASH_CATALOGconsumes 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.Rangejoins the catalog-driven inference pipeline.data/builtins/ruby_core/range.ymlcovers 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 asblock_dependentand is blocked by the foldable-purity check. The Range slice also taughttool/extract_builtin_catalog.rbto recogniserb_struct_define_without_accessorso future struct-defined topics become drop-in additions.Setjoins the catalog-driven inference pipeline.data/builtins/ruby_core/set.ymlis generated fromInit_Setinreferences/ruby/set.c(Set was rewritten in C and folded into CRuby for Ruby 3.2+). Per-class blocklist drops false-positive:leafclassifications for the indirect mutators (initialize_copy,compare_by_identity,reset), the block-yielding helpers (each,classify,divide), anddisjoint?.Timejoins the catalog-driven inference pipeline.data/builtins/ruby_core/time.ymlis generated fromInit_Timeinreferences/ruby/time.cplus thereferences/ruby/timev.rbprelude (compiled intotimev.rbincand#included at the bottom oftime.c); the prelude path carriesTime.now/Time.at/Time.newinto the singleton-method bucket. The catalog records 58 instance methods (48:leaf, 8:dispatch, 3:mutates_self, 3:unknown), 4 singleton methods, and theiso8601↔xmlschemaalias. Per-class blocklist catcheslocaltime/gmtime/utc(all calltime_modify(time)to mark the receiver mutable but the C-body classifier mis-flags them:leaf).
Enumerable-aware projections
Section titled “Enumerable-aware projections”#each_with_indexblock-parameter typing.IteratorDispatchgeneralises 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-intover the RBS-declaredInteger. Self-asserting fixture:spec/integration/fixtures/each_with_index.rb.
Tooling
Section titled “Tooling”tool/scaffold_builtin_catalog.rb. Automates the mechanical 70 % of a new built-in catalog import: writes the TOPICS entry, the optionalBASE_CLASS_VARSrow, the loader file with a TODO blocklist marker, theCATALOG_BY_CLASSrowrequire_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-runpreviews the planned edits;--init-fn/--rbs/--rb-preludeoverride defaults for upstream layouts that diverge. Documented as Stage 0 of therigor-builtin-importskill.
Changed
Section titled “Changed”MethodDispatcher::ConstantFolding#catalog_foris table- driven. ACATALOG_BY_CLASSarray of(receiver_class, [catalog, class_name])pairs replaces the growingcasestatement. Adding a class catalog is now a one-line addition rather than anotherwhenarm, and the dispatcher’s cyclomatic complexity stays bounded as the catalogue grows.
accepts_nominalprojects refinement carriers to base. A Nominal accepting aDifferenceorRefinedpreviously fell through to:nobecauseaccepts_nominal’s case statement had no branch for refinement kinds. The carrier’s value set is contained in its base nominal’s, so projecting toother.baseand 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 fromR, which never holds (a Nominal base contains the removed value by construction). Same-removednow suffices because the disjointness is exhibited at the inner difference layer.
0.0.3 - 2026-05-02
Section titled “0.0.3 - 2026-05-02”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::ConstantFoldinginvokes the real Ruby method onConstantreceivers 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 throughType::Combinator.constant_of. Combined with inter-procedural inference (v0.0.2 #5):class Paritydef is_odd(n) = n.odd?endParity.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 preciseUnion[Constant…]result. Bounded byUNION_FOLD_INPUT_LIMIT = 32andUNION_FOLD_OUTPUT_LIMIT = 8; when the output cap is exceeded for an Integer-only result set, the analyzer gracefully widens to the boundingIntegerRange[min, max]instead of giving up. -
Type::IntegerRangecarrier and range arithmetic. PHPStan- styleint<min, max>family with named aliasespositive-int(1..),non-negative-int(0..),negative-int(..-1),non-positive-int(..0), andint<a, b>. Erases toIntegerin RBS. Binary+,-,*,/,%and unarysucc/pred/abs/-@/even?/odd?/bit_length/zero?/positive?/negative?all fold precisely. Single-point intersections (int<5, 5>) collapse toConstant[5]. -
Scope-level range narrowing through comparisons and predicates.
if x > 0 ... endnarrowsxtopositive-inton the truthy edge,non-positive-inton the falsey edge. Same for<,<=,>=, the reversed forms (0 < x),x.positive?/x.negative?/x.zero?/x.nonzero?, andx.between?(a, b). The narrowing intersects with an existingIntegerRangebound when one is already in scope. -
case/wheninteger-range narrowing.case n when 1..10 then …narrowsntoint<1, 10>inside the body;when 1...10narrows toint<1, 9>(exclusive end);when (100..)narrows toint<100, max>;when (..-1)narrows tonegative-int;when 0narrows toConstant[0]. -
Iterator block-parameter typing.
5.times { |i| … }typesiasint<0, 4>;1.times { |i| … }collapses toConstant[0];3.upto(7) { |i| … }and7.downto(3) { |i| … }both typeiasint<3, 7>. Wider Integer receivers (Nominal[Integer],positive-int) fall back tonon-negative-int. -
Branch elision on provably-truthy/falsey predicates.
if 4.even? ; :even ; else ; :odd ; endresolves toConstant[:even]only — the dead branch is skipped — when the predicate’s narrow_truthy / narrow_falsey collapses one side toBot.Constant[true]/Constant[false]/Nominal[Integer](always truthy) all qualify;Union[true, false]keeps both branches active as before. -
Tuple-shapedInteger#divmod/Float#divmodfolds.5.divmod(3)lifts toTuple[Constant[1], Constant[2]]so multi-target destructuring threads the per-slot type into locals (q, r = 11.divmod(4)bindsq: 2,r: 3). Float / mixed Integer-Float divmod produces a mixedTuple[Constant<Integer>, Constant<Float>]. -
Built-in method catalog extraction pipeline.
tool/extract_builtin_catalog.rbparses CRuby’sInit_<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 matchingreferences/rbs/core/*.rbssignatures. Output lives atdata/builtins/ruby_core/<topic>.yml(regenerated viamake extract-builtin-catalogs). Generated YAML ships with the gem.Rigor::Inference::Builtins::NumericCatalog/STRING_CATALOG/ARRAY_CATALOGconsume 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 likerb_str_replace→str_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::IntegerRangereturns from container#size/#length/#bytesize.Nominal[Array]#size,Nominal[String]#length,Nominal[Hash]#size,Nominal[Set]#size,Nominal[Range]#sizenow returnnon_negative_intinstead of the RBS-declaredInteger. Composes with the comparison-narrowing tier soif arr.size > 0narrows the local topositive-intandarr.size - 1evaluates asnon-negative-int. -
Filepath-manipulation folding (opt-in).File.basename,#dirname,#extname,#join,#split,#absolute_path?overConstant<String>arguments fold to a preciseConstant(orTuple[Constant, Constant]forsplit) whenfold_platform_specific_paths: trueis set in.rigor.yml. Default mode is platform-agnostic — these methods readFile::SEPARATOR/ALT_SEPARATORand would otherwise bake the analyzer-host’s platform into the inferred type — so the RBS tier answers withNominal[String]/Tuple[String, String]/bool. Single-platform projects opt in for the precision payoff; cross-platform projects keep the safe envelope. -
Type::Differencecarrier (OQ3 point-removal half).Difference[base, removed]representsbaseminus 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: onlyConstantand same-removedDifferencecandidates can be proved disjoint from the removed set, soDifference[String, ""].accepts(Nominal[String])correctly returnsno(the wider Nominal could be"").MethodDispatcher::ShapeDispatchprojects the empty-removal case directly:nes.size→positive-int,nes.empty?→Constant[false],nzi.zero?→Constant[false]. Erases to the base nominal in RBS. -
Rigor::Builtins::ImportedRefinementsregistry. 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 forRBS::Extendedand for future tokeniser slices. -
rigor:v1:return:RBS::Extendeddirective. 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: () -> IntegerendAt call sites the override propagates:
User.new.name.sizeispositive-int,User.new.name.empty?isConstant[false],User.new.age.zero?isConstant[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-raisesdiagnostic rule.5 / 0,5 % 0,5.div(0),5.modulo(0),5.divmod(0), andrand(100) / 0all surface as:errordiagnostics under rulealways-raises(“always raises ZeroDivisionError”). Float arithmetic (5.0 / 0returnsInfinity) andInteger#fdiv(0)stay silent. Suppressible per-line via# rigor:disable always-raises. -
Implicit-self calls prefer in-source
defover RBS dispatch. Whennode.receiveris nil (true implicit self) and the file has a same-named top-leveldef(or DSL-block-nesteddef, e.g. insideRSpec.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 returnsDynamic[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_nildropNilClassfromx’s type;expect(x).to be_a(C)/be_kind_of(C)narrowxtoC(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_pathsconfiguration option. Boolean in.rigor.yml, defaultfalse. 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 —Differencefor point-removal,Refinedfor predicate-subset; the latter ships in v0.0.4).
-
Rigor::Analysis::CheckRulesarity_eligible?/argument_check_eligible?no longer raise when the RBS function isRBS::Types::UntypedFunction(e.g.(?) ->or certain stdlib variadic sigs). Both predicates now returnfalsefor 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 foldedUnion[Constant[String], Constant[nil]].nil?toConstant[true]becauseString#nil?was not inSTRING_UNARYand 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.
0.0.2 - 2026-05-01
Section titled “0.0.2 - 2026-05-01”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 --explainmode. Surfaces fail-soft inference fallbacks as:infodiagnostics so users can see where the engine degraded toDynamic[Top]. Driven byRigor::Inference::CoverageScannerso each event is attributable to the leaf node that triggered it (pass-through wrappers likeProgramNode/StatementsNode/ParenthesesNodeare not double-counted). Each diagnostic carriesrule: "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.ymllibraries:andsignature_paths:keys. The configuration layer now passes through toRigor::Environment.for_project:libraries:lists stdlib libraries to load on top ofEnvironment::DEFAULT_LIBRARIES(e.g.["csv", "set"]). Each entry must be a name accepted byRBS::EnvironmentLoader#has_library?; unknown libraries fail-soft.signature_paths:is an explicit list ofsig/-style directories. Leaving the key unset (ornull) preserves the auto-detect-<root>/sigdefault;[]disables project-RBS loading entirely.
Wired through
rigor check,rigor type-of, andrigor type-scan(the latter two gain a--config=PATHoption matchingcheck). -
Per-rule diagnostic suppression. Two mechanisms compose:
- Project-level:
.rigor.yml’s newdisable:key accepts a list ofrigor checkrule 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 allsuppresses every rule on that line.
Rigor::Analysis::Diagnosticgains arule:field carrying the source rule’s stable identifier. Parse errors / path errors / internal analyzer errors leaveruleasniland stay unsuppressible. - Project-level:
-
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 instancedef, 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. Theuser_methods.rbintegration fixture now resolvesParity.new.is_odd(3)tofalse | true(wasDynamic[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 toDynamic[Top]rather than infinite-looping. -
rigor checkships an argument-type-mismatch rule. For every explicit-receiverPrism::CallNodewhose method has exactly one RBS overload (norest_positionals, no required keywords, no trailing positionals), the rule routes each positional argument’s inferred type throughRigor::Inference::Acceptance.accepts(parameter, argument, mode: :gradual)and emits an:errorfor the first argument the parameter does not accept. Argument or parameter types known only asDynamicskip the check (the call cannot be statically refuted). The receiver must beNominal/Singleton/Constant; user-class fallback / shape carriers behave as in the wrong-arity rule. The rule respects RBS even when the user has both adefand a sig: the sig is the authoritative parameter contract. -
Rigor::Inference::Acceptancenow treatsSingleton[T]as a subtype ofModule,Class,Object, andBasicObject. Without this rule a method whose parameter is typedClass | Module(e.g.Object#is_a?,Module#define_method) rejected every singleton receiver, producing systemic false positives across bothlib/andspec/. -
RBS::Extendedtarget: selfdirectives 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 anif/unlesspredicate.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::Extendedrecognises negation in predicate / assert directives via the~ClassNamesyntax:predicate-if-true value is ~NilClassnarrowsvalueAWAY fromNilClasson the truthy edge.assert value is ~NilClassnarrowsvalueAWAY fromNilClassin the post-call scope.
Rigor::RbsExtended::PredicateEffect#negative?andAssertEffect#negative?are new boolean predicates; the parser sets them when the directive’s type literal starts with~. The engine routes negative effects throughNarrowing.narrow_not_classinstead ofnarrow_classso the union loses the named class on the active edge. -
RBS::Extendedrecognises three additional directives:rigor:v1:assert <target> is <Class>— refines the matching argument’s local in the post-call scope unconditionally. Wires throughStatementEvaluator#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 throughNarrowing.predicate_scopesalongsidepredicate-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 themust_be_string!/validate!/valid_string?/integer?patterns common in Ruby.Rigor::RbsExtended::AssertEffectis the new data class returned byRbsExtended.read_assert_effects(method_def). -
Rigor::Environment::DEFAULT_LIBRARIESnow includestmpdir,stringio,forwardable,digest, andsecurerandom. 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.
Changed
Section titled “Changed”Rigor::Analysis::CheckRulesdump_type/assert_typerules are suppressed when the call site’sself_typeisRigororRigor::Testing. The reflexiveTesting.dump_type(value)/Testing.assert_type(...)calls inside Rigor’s own stub no longer surface diagnostics onrigor check lib.
0.0.1 - 2026-05-01
Section titled “0.0.1 - 2026-05-01”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 checkend-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 (YAML→Psych), 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], andHashShapecarriers with required / optional / read-only key policies.Trinary(yes/no/maybe) andAcceptsResult. - Inference engine. Local, instance, class, and global
variable bindings tracked through
Rigor::Scope. Cross-method ivar / cvar accumulators populated by aScopeIndexerpre-pass; program-wide globals. - Compound writes (
||=,&&=,+=,-=,*=, …) thread through scope for every variable kind, with operator dispatch viaMethodDispatcher. selftyping. Class- and method-body boundaries injectSingleton[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, andcase/whenintegration. - 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 toArray[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 aclosure_escapefact in the FactStore. RBS::Extendedpredicate effects. Methods whose RBS signature carries%a{rigor:v1:predicate-if-true target is T}/predicate-if-falseannotations narrow the matching argument on the corresponding edge.- PHPStan-style typing helpers.
Rigor::Testing.dump_typesurfaces the inferred type as an:infodiagnostic;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.
Known limitations (deferred to v0.0.2)
Section titled “Known limitations (deferred to v0.0.2)”- Inter-procedural inference for user-defined methods. A
helper like
def is_odd(n) = n.odd?types correctly inside the def, but the caller observesDynamic[top]until an RBS sig is supplied. Thespec/integration/fixtures/user_methods*pair pins both shapes (no sig vs project sig). RBS::Extendedships only the predicate-effect surface.assert/assert-if-true/assert-if-false, negation (~T), self-targeted narrowing, intersection / union refinements,param/return/conforms-todirectives are deferred.- No persistent cache — every
rigor checkrun re-parses and re-types the project. - No plugin contribution layer past the bundled
RBS::Extendedreader. - Per-rule severity is hard-coded to
:error(with:inforeserved fordump_type); per-rule configuration and suppression comments are deferred.
© 2026 TypedDuck. Licensed under CC BY-SA 4.0.