Repository navigation
Take the audit's ergonomics decisions: presence, to_s, strict equality, try, map_or, each - #73
Merged
Merged
Conversation
`presence` on an Option answers the inner value of a Some and nil for a
None, which is exactly what unwrap_or(nil) says and what a Rails
developer already reaches for, so it is supported rather than nudged
toward a combinator. It follows the discriminant like every other
presence question here: Some("") answers "" where Object#presence
answers nil.
The nudge from the remaining soft-deprecated spellings now fires once
per process per method. A soft deprecation is a message to a developer,
and one per call turns a hot path into a stderr flood.
to_s answers what inspect answers: "Some(1)", "None", "Ok(1)", "Err(:x)". Rust gives these types a Debug and no Display, so refusing was a faithful reading, but a to_s that raises replaces the real exception while a rescue builds its log line, which is the worst place to be strict about a wrapper. The rendered form leaks nothing silently: a Some(1) in a log says plainly that a wrapper arrived where a value was meant. to_json and as_json still raise, because a payload has no such tell.
A message that names what is missing usually interpolates the thing
that is absent, and an eager argument pays for that on every call that
succeeds. A block is called only on the branch that raises, so
`tier.expect! { "no tier for #{account.id}" }` costs nothing on the
happy path. present_or_raise! takes one on the same terms, and the
positional message keeps working.
A wrapper compared to a plain value answers false, quietly, because that is what every Ruby object does across types and because raising would break Array#include?, assertion diffs and dirty tracking. Errgonomic.strict_equality = true makes ==, != and eql? raise a TypeMismatchError instead, naming both classes and the spelling to reach for, and nil counts as another type so None() == nil points at none?. Two Options still compare, and hash is untouched, so an Option stays a usable Hash key. It is meant for a test suite: rake test:strict runs this gem's Rails integration suite with it on, and nothing under ActiveRecord compares a wrapper cross-type today.
ActiveSupport's Object#try asks respond_to? first, and an Option answers false for anything it does not define, so try on a wrapper was a quiet nil for every method and a block was handed the wrapper rather than the value. Both forms now reach the value: a Some tries what it holds, a None answers nil, and a method the value does not have is nil as it is for any other receiver. try! is the strict variant, raising for that. They live with the rest of the ActiveSupport-dependent patches, so a checkout without Rails is unchanged.
map wraps whatever the block hands back, which is Rust's semantics and stays. The nesting is invisible until something reaches for the inner value, so the README and the method now say it outright and name and_then as the spelling for a block that returns an Option. The docstring also dropped a pedantic runtime check it describes and does not do.
map_or and map_or_else are the exit from an Option: they take a default for the absent case precisely so the caller can stop handling one. Both wrapped their answer, so a caller got Some(4) where Rust gives 4 and had to unwrap the thing it had just supplied a default for. They now hand back what the default or the block gives. map is the combinator that stays inside the Option. This is a breaking change for anything reading their return value.
An Option is a collection of zero or one, and each says so: a Some yields its value once, a None yields nothing, and without a block you get an Enumerator, which is where the rest of Enumerable lives. Enumerable itself stays out. filter and first are already Option's own and answer Options; Enumerable's answer plain values, and one name cannot mean both.
take, replace, insert and get_or_insert each write through an &mut Option, and an Option here is a value rather than a slot. That reasoning was a comment at the foot of the class, where nobody deciding whether the gem covers Rust's surface would look for it; the README's Option section is where that question is asked. Removing the comment takes the access modifier that carried it, and the lint offense that came with it.
Some(1) == Ok(1) raised with a remedy that read "compare to Some(Ok(1))", which is not what anyone writing that line meant. An Option and a Result are different containers and comparing them is a type mistake, so it keeps raising, but the message now names both and says to unwrap whichever one was meant. != also names itself. Ruby derives it from ==, so the message used to report an operator the caller had not written. The README says which comparisons the check reaches: the wrapper as receiver, and a left operand like Integer that hands the comparison to its right-hand side, while NilClass and String answer for themselves and stay quietly false.
test:strict spawned a bare ruby, so --seed reached rake test and not the strict suite; TESTOPTS is split and forwarded now. An Option's Enumerator knows its size, so each.size answers 1 or 0 rather than nil. The nudge names present_or_raise! with the bang the caller wrote. The doctests that swap $stderr restore it from an ensure, so an example that fails partway cannot take the rest of the run's warnings with it.
nz
force-pushed
the
nz/audit-ergonomics
branch
from
September 9, 2026 02:15
d59ae96 to
78afbcd
Compare
nz
enabled auto-merge
September 9, 2026 02:15
lutzcc1
approved these changes
Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Seven decisions from the Sprout conversion audit (2026-09-08), one commit each, all specified as doctests.
presenceis supported again as the Rails spelling ofunwrap_or(nil), with the discriminant semantics the README states (Some("").presenceis""). The remaining soft-deprecated presence helpers nudge once per process instead of on every call.to_son an Option or Result renders asinspectdoes (Some(1),None,Ok(1),Err(:x)) instead of raising. Rust givesOptionDebugbut notDisplay, so raising was faithful, but ato_sthat raises while arescuebuilds a log line replaces the real exception.to_jsonandas_jsonstill raise.expect!andpresent_or_raise!take a block for a lazily built message so interpolating an Option into an eager message cannot raise on the success path.Errgonomic.strict_equality = trueorwith_strict_equality { }raisesTypeMismatchErroron==,!=, andeql?between a container and anything that is not the same kind of container, nil included, naming the spelling to use. It is the systematic detector for the silent-equality class the audit found (Some(x) == xquietly false incase,include?, and guards). The gem's own Rails suite runs under it viarake test:strict, in the default task and CI.Option#tryandtry!forward to the inner value when ActiveSupport is loaded, sosome.try(:m)no longer answers nil.mapkeeps Rust semantics (a block returning an Option nests); the docs nameand_thenas the spelling.map_orandmap_or_elsereturn the bare value, as Rust's do; they returnedSome(x).Option#eachyields the inner value once forSomeand nothing forNone, without includingEnumerable. The README states thattake,replace,insert, andget_or_insertare deliberately omitted and why.Also found on the way: the default Rake task never ran anything listed after
yard:doctest, which exits the process; the order is now tests, strict tests, doctests.Verified locally: rubocop (no new offenses; one pre-existing offense removed), rake test, rake test:strict, yard doctest across seeds,
nix build .#errgonomic,nix flake check --all-systems.