Language · Ruby 4.0 · RuboCop 1.88 · RSpec 3.13 · RBS/Sorbet
Ruby
Blocks, enumerable, duck typing — expressive, idiomatic Ruby.
Updated 5 Jul 2026 · CC0
AGENTS.mdrepo rootYou are writing idiomatic, modern Ruby (MRI/CRuby 4.0). "Good" here means small composable objects, expressive Enumerable pipelines over hand-rolled loops, explicit error handling, frozen-by-intent immutability, and code that passes RuboCop and the test suite with zero warnings.
Stack
- Ruby 4.0.5 (MRI/CRuby, current stable 4.0 line). Target
>= 4.0in.ruby-versionand gemspecrequired_ruby_version. Prism is the default parser since 3.4 — don't depend on Ripper. - YJIT for production (
RUBY_YJIT_ENABLE=1orruby --yjit). ZJIT (new in 4.0) is experimental and not yet faster than YJIT — do not enable it in prod. - Bundler 4.x (RubyGems 4; 4.0.3+ ships with Ruby 4.0). Always commit
Gemfile.lockfor apps; committing it for gems too is now the common default (reproducible CI,bundle install --frozen). Pin with pessimistic~>. - Version manager:
mise(preferred) orrbenv/chruby. Neverrvm, never system Ruby. - Lint/format: RuboCop 1.88 with
rubocop-performanceandrubocop-rspec, OR Standard 1.x (standardrb) for zero-config teams. Pick one; don't run both. - Tests: RSpec 3.13 (RSpec 4.0 is beta — don't adopt yet) or Minitest 5.x (bundled). One per project.
- Types (optional): RBS 4.x signatures in
sig/checked with Steep, or Sorbet (sorbet+sorbet-runtime) with Tapioca for RBI generation. Sorbet now reads inline RBS comments. - Debugging: the
debuggem (bundled since 3.1) —binding.break,rdbg. Notbyebug, notpry-byebug.
Project conventions
- Layout:
lib/(gem code),app/(app code),exe/(CLI entrypoints),bin/(dev scripts),spec/ortest/,sig/(RBS). One public class/module per file. - Path mirrors namespace:
lib/foo/bar_baz.rbdefinesFoo::BarBaz. Directory = module. - Naming:
snake_casefor files/methods/variables,CamelCasefor classes/modules,SCREAMING_SNAKEfor constants. Predicates end in?, mutating/dangerous variants in!(only when a safe pair exists). - Requires: inside a gem use
require_relativefor internal files; apps use Zeitwerk autoloading (don't hand-write requires). Neverrequirea relative path withrequire. - Every
.rbfile starts with# frozen_string_literal: true. - Formatting: 2-space indent, no tabs, no trailing whitespace, ~120 col. Let RuboCop autocorrect (
rubocop -Afor safe, review unsafe). Run in CI with--fail-level. - Gemfile: group dev/test deps under
group :development, :test; pin app-critical gems with~>.
Idioms
Reach for Enumerable before writing a for/while loop or manual accumulator:
users.select(&:active?).map(&:email) # not each + <<
counts = words.tally # not each { |w| h[w] += 1 }
totals = orders.sum(&:amount) # not reduce(0) { ... } for sums
by_role = staff.group_by(&:role)
emails = people.filter_map { it.email } # map + compact in one pass (2.7)
config = pairs.each_with_object({}) { |(k, v), h| h[k] = v.strip }
- Use
each_with_objectwhen building a mutable accumulator;reduce/injectonly for true folds. itis the implicit first block param (3.4);_1/_2for multiple. Keep to trivial blocks — name params when the block is nontrivial.- Safe navigation
user&.address&.city. Memoize with@x ||= compute(but not forfalse/nilvalues — usedefined?there). - Symbol-to-proc
&:upcase; pass methods withmethod(:parse). - Hash value shorthand
{ name:, email: }(3.1). Anonymous forwarding:def wrap(...) = inner(...),&/*/**forwarding (3.1/3.2). - Endless methods for one-liners:
def full_name = "#{first} #{last}"(3.0). - Pattern matching for structured data, not a chain of
is_a?:
case response
in { status: 200, body: { token: String => token } } then store(token)
in { status: 400..499 => code } then raise ClientError, code
in { status: 500.. } then retry_later
end
config => { host:, port: Integer => port } # one-line destructure (rightward)
Setis core since 3.2 — norequire "set".
Objects and data
- Small, single-responsibility classes. Compose objects; prefer duck typing (
respond_to?) overis_a?checks. Reserve inheritance for genuine is-a with shared implementation. - Immutable value objects:
Data.define(3.2), notStruct, unless you need mutability:
Point = Data.define(:x, :y) do
def distance_to(other) = Math.hypot(x - other.x, y - other.y)
end
p2 = point.with(x: 10) # returns a new instance
- Use
Struct.new(..., keyword_init: true)only when instances must be mutated after creation. attr_readerby default; add writers only when mutation is part of the contract. Neverattr_accessorreflexively. Expose behavior, not raw state.- Keyword arguments for anything with >1 parameter or any boolean; they document call sites and avoid positional mistakes. Give defaults where sensible.
- Freeze constants, especially collections:
STATUSES = %i[open closed].freeze. Deep-freeze shareable config withRactor.make_shareable. - Extract POROs / service objects (
CreateInvoice.new(order).call) instead of growing fat models or god objects.
Error handling
- Rescue the narrowest class that matters. A bare
rescue => ecatchesStandardErrorimplicitly — acceptable at a top-level boundary, but prefer naming the class inside library code. - Never
rescue Exception(swallowsSignalException,NoMemoryError,Interrupt). - Never
rescue nilor an emptyrescue— swallowing errors silently is forbidden. If you truly ignore, log with context and comment why. - Define a namespaced hierarchy so callers can rescue coarsely or finely:
module Billing
class Error < StandardError; end
class PaymentDeclined < Error; end
end
ensurefor cleanup (files, sockets, locks). Prefer block forms that clean up for you:File.open(path) { it.read },db.transaction { ... }.- Re-raise with a bare
raiseinside a rescue to preserve the original backtrace. Wrap withraise NewError, "msg", cause: eonly when adding context. - Don't use exceptions for normal control flow. Bound
retrywith a counter. For expected-failure paths return a result object or use#then/pattern matching.
Blocks, procs, and lambdas
- Yield with
yield/block_given?; capture with&blockonly when you must store or forward it. - Lambdas check arity strictly and
returnexits only the lambda. Procs ignore extra args andreturnexits the enclosing method — a common footgun. Default to->(x) { }lambdas; use procs deliberately. - Prefer
&:methodandmethod(:name)over wrapper blocks that just forward arguments.
Immutability and concurrency
# frozen_string_literal: truein every file. In 3.4+ literals in files without it are "chilled" and warn on mutation under-W:deprecated— treat that warning as an error to fix.- Freeze module-level config and constants. Build new objects (
Data#with,merge,dup.tap) instead of mutating shared state. Never mutate a global/class variable from concurrent code. - Concurrency model: threads/fibers for I/O-bound work (GVL is released during I/O);
Ractorfor CPU-bound parallelism — but Ractors require shareable (frozen/immutable) objects and most gems aren't Ractor-safe, so scope them tightly.Ractor::Port(new in 4.0) is the current messaging primitive. - For high-concurrency I/O prefer the
asyncgem (fiber scheduler) over a thread pool. - Avoid class/global variables (
@@x,$x) entirely for shared mutable state.
Metaprogramming
Use it sparingly and only when it removes real duplication.
- Prefer plain methods and
define_methodovermethod_missing. If you do implementmethod_missing, you MUST implementrespond_to_missing?too. - Do not monkey-patch core classes globally. If you must extend
String/Array/etc., use a refinement (using) scoped to a file, or a helper module — never reopen core classes with app logic. - Mix in
Comparable/Enumerableby defining<=>/eachrather than reimplementing comparison/iteration.
Style and types
- Guard clauses over nested conditionals; return/
next/raiseearly. Keep methods short (target under ~10 lines; RuboCopMetrics/MethodLength,AbcSize). - No commented-out code, no
putsdebugging left in — use thedebuggem or a logger. - Types are optional but encouraged for libraries and boundaries: RBS in
sig/verified bysteep check, or Sorbetsig { params(id: Integer).returns(User) }verified bysrb tc. Generate RBI/stubs with Tapioca; keep signatures in CI.
Testing
- RSpec:
describe/context/it, usedescribed_classandsubject,letfor lazy setup (avoidlet!and instance vars). Use theexpectsyntax only — nevershould. - Verify doubles:
instance_double(Foo),class_double,object_double— they fail if the real method doesn't exist. Neverallow_any_instance_ofor a plaindoublefor owned classes. - Don't mock what you don't own; wrap third-party libs and stub your wrapper. Stub HTTP with WebMock/VCR — no real network in specs.
aggregate_failuresto assert multiple expectations;change,raise_error(SpecificError),have_attributesmatchers. Test behavior and edge cases, not private methods.- Factories via
factory_bot(build over create when persistence isn't needed) or fixtures.SimpleCovfor coverage, but coverage % is not the goal — meaningful assertions are. - Minitest alternative:
Minitest::Testsubclasses,assert_equal expected, actual(expected first),assert_raises(Error) { ... },Minitest::Mock/stub. Keep tests parallelizable (Minitest.parallel_executor).
Security
- Shell out with an argument array, never an interpolated string:
system("git", "clone", url)— string form + user input is command injection. Avoid backticks/%xwith any external input. YAML.safe_load/Psych.safe_load(notYAML.load) for untrusted YAML.Marshal.loadonly on fully trusted data — never on user input.- Never
eval/instance_eval/class_eval/sendwith attacker-controlled strings. Usepublic_sendand allow-list method names. - SQL: parameterized queries / bind params only; never interpolate input into query strings.
- Secrets from ENV or encrypted credentials, never committed. Use
dotenvin dev; scan withgitleaks. - Tokens/IDs from
SecureRandom(uuid_v7,hex), neverrand/Time. Compare secrets withOpenSSL.fixed_length_secure_compare(constant-time), never==. - Guard regexes against ReDoS (avoid nested quantifiers on untrusted input; prefer
String#match?and anchored patterns). - Run
bundler-auditand (for Rails/Rack)brakemanin CI; keep dependencies patched.
Do
- Add
# frozen_string_literal: trueand freeze constant collections. - Model values with
Data.define; expose behavior viaattr_reader+ methods. - Chain
map/select/filter_map/each_with_object/tallyinstead of manual loops. - Rescue specific, namespaced error classes; clean up in
ensureor a block form. - Use keyword arguments, guard clauses,
&.,&:sym, and pattern matching. - Prefer lambdas; keep methods and classes small and single-purpose.
- Enable YJIT in production; run RuboCop and the full suite before every commit.
Avoid
for i in .../ manual index loops → Enumerable methods.rescue => eused to swallow,rescue nil, orrescue Exception→ rescue the specific class and handle or re-raise.Struct.newfor immutable values →Data.define.OpenStructanywhere (slow, unsafe) → a real class orData.attr_accessorby default →attr_reader; add writers only when needed.- Global monkey-patching of core classes → refinements or wrapper modules.
method_missingwithoutrespond_to_missing?;sendon user input →public_send+ allow-list.YAML.load,Marshal.load,eval, and string-interpolatedsystemon untrusted input.shouldsyntax,allow_any_instance_of, real network calls in specs.- Long methods, deep nesting, mutable global/class variables, leftover
putsdebugging.
When you code
- Make small, focused diffs; touch one concern per change. Match the file's existing style and structure.
- After editing, run
rubocop(orstandardrb) and the relevant specs/tests; if types are set up, runsteep checkorsrb tc. Fix warnings — don't add# rubocop:disableto hide them unless justified with a comment. - Never introduce a new gem, change Ruby version constraints, or touch
Gemfile.lock/CI config without flagging it first. - If a public API, error class, or data shape is ambiguous, ask before inventing one. Add or update tests for any behavior you change.
Drop it in your repo
Save these rules as AGENTS.md, CLAUDE.md, .cursorrules, .windsurfrules or .github/copilot-instructions.md — your agent instantly codes to the same standard on Ruby 4.0 · RuboCop 1.88 · RSpec 3.13 · RBS/Sorbet.