Promptheus/rules53 rule sets · CC0Promptheus hub ↗

Language · Ruby 4.0 · RuboCop 1.88 · RSpec 3.13 · RBS/Sorbet

Ruby

Blocks, enumerable, duck typing — expressive, idiomatic Ruby.

rubyidiomatic

Updated 5 Jul 2026 · CC0

AGENTS.mdrepo root

You 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.0 in .ruby-version and gemspec required_ruby_version. Prism is the default parser since 3.4 — don't depend on Ripper.
  • YJIT for production (RUBY_YJIT_ENABLE=1 or ruby --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.lock for apps; committing it for gems too is now the common default (reproducible CI, bundle install --frozen). Pin with pessimistic ~>.
  • Version manager: mise (preferred) or rbenv/chruby. Never rvm, never system Ruby.
  • Lint/format: RuboCop 1.88 with rubocop-performance and rubocop-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 debug gem (bundled since 3.1) — binding.break, rdbg. Not byebug, not pry-byebug.

Project conventions

  • Layout: lib/ (gem code), app/ (app code), exe/ (CLI entrypoints), bin/ (dev scripts), spec/ or test/, sig/ (RBS). One public class/module per file.
  • Path mirrors namespace: lib/foo/bar_baz.rb defines Foo::BarBaz. Directory = module.
  • Naming: snake_case for files/methods/variables, CamelCase for classes/modules, SCREAMING_SNAKE for constants. Predicates end in ?, mutating/dangerous variants in ! (only when a safe pair exists).
  • Requires: inside a gem use require_relative for internal files; apps use Zeitwerk autoloading (don't hand-write requires). Never require a relative path with require.
  • Every .rb file starts with # frozen_string_literal: true.
  • Formatting: 2-space indent, no tabs, no trailing whitespace, ~120 col. Let RuboCop autocorrect (rubocop -A for 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_object when building a mutable accumulator; reduce/inject only for true folds.
  • it is the implicit first block param (3.4); _1/_2 for multiple. Keep to trivial blocks — name params when the block is nontrivial.
  • Safe navigation user&.address&.city. Memoize with @x ||= compute (but not for false/nil values — use defined? there).
  • Symbol-to-proc &:upcase; pass methods with method(: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)
  • Set is core since 3.2 — no require "set".

Objects and data

  • Small, single-responsibility classes. Compose objects; prefer duck typing (respond_to?) over is_a? checks. Reserve inheritance for genuine is-a with shared implementation.
  • Immutable value objects: Data.define (3.2), not Struct, 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_reader by default; add writers only when mutation is part of the contract. Never attr_accessor reflexively. 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 with Ractor.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 => e catches StandardError implicitly — acceptable at a top-level boundary, but prefer naming the class inside library code.
  • Never rescue Exception (swallows SignalException, NoMemoryError, Interrupt).
  • Never rescue nil or an empty rescue — 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
  • ensure for cleanup (files, sockets, locks). Prefer block forms that clean up for you: File.open(path) { it.read }, db.transaction { ... }.
  • Re-raise with a bare raise inside a rescue to preserve the original backtrace. Wrap with raise NewError, "msg", cause: e only when adding context.
  • Don't use exceptions for normal control flow. Bound retry with 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 &block only when you must store or forward it.
  • Lambdas check arity strictly and return exits only the lambda. Procs ignore extra args and return exits the enclosing method — a common footgun. Default to ->(x) { } lambdas; use procs deliberately.
  • Prefer &:method and method(:name) over wrapper blocks that just forward arguments.

Immutability and concurrency

  • # frozen_string_literal: true in 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); Ractor for 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 async gem (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_method over method_missing. If you do implement method_missing, you MUST implement respond_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/Enumerable by defining <=>/each rather than reimplementing comparison/iteration.

Style and types

  • Guard clauses over nested conditionals; return/next/raise early. Keep methods short (target under ~10 lines; RuboCop Metrics/MethodLength, AbcSize).
  • No commented-out code, no puts debugging left in — use the debug gem or a logger.
  • Types are optional but encouraged for libraries and boundaries: RBS in sig/ verified by steep check, or Sorbet sig { params(id: Integer).returns(User) } verified by srb tc. Generate RBI/stubs with Tapioca; keep signatures in CI.

Testing

  • RSpec: describe/context/it, use described_class and subject, let for lazy setup (avoid let! and instance vars). Use the expect syntax only — never should.
  • Verify doubles: instance_double(Foo), class_double, object_double — they fail if the real method doesn't exist. Never allow_any_instance_of or a plain double for 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_failures to assert multiple expectations; change, raise_error(SpecificError), have_attributes matchers. Test behavior and edge cases, not private methods.
  • Factories via factory_bot (build over create when persistence isn't needed) or fixtures. SimpleCov for coverage, but coverage % is not the goal — meaningful assertions are.
  • Minitest alternative: Minitest::Test subclasses, 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/%x with any external input.
  • YAML.safe_load / Psych.safe_load (not YAML.load) for untrusted YAML. Marshal.load only on fully trusted data — never on user input.
  • Never eval/instance_eval/class_eval/send with attacker-controlled strings. Use public_send and 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 dotenv in dev; scan with gitleaks.
  • Tokens/IDs from SecureRandom (uuid_v7, hex), never rand/Time. Compare secrets with OpenSSL.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-audit and (for Rails/Rack) brakeman in CI; keep dependencies patched.

Do

  • Add # frozen_string_literal: true and freeze constant collections.
  • Model values with Data.define; expose behavior via attr_reader + methods.
  • Chain map/select/filter_map/each_with_object/tally instead of manual loops.
  • Rescue specific, namespaced error classes; clean up in ensure or 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 => e used to swallow, rescue nil, or rescue Exception → rescue the specific class and handle or re-raise.
  • Struct.new for immutable values → Data.define. OpenStruct anywhere (slow, unsafe) → a real class or Data.
  • attr_accessor by default → attr_reader; add writers only when needed.
  • Global monkey-patching of core classes → refinements or wrapper modules.
  • method_missing without respond_to_missing?; send on user input → public_send + allow-list.
  • YAML.load, Marshal.load, eval, and string-interpolated system on untrusted input.
  • should syntax, allow_any_instance_of, real network calls in specs.
  • Long methods, deep nesting, mutable global/class variables, leftover puts debugging.

When you code

  • Make small, focused diffs; touch one concern per change. Match the file's existing style and structure.
  • After editing, run rubocop (or standardrb) and the relevant specs/tests; if types are set up, run steep check or srb tc. Fix warnings — don't add # rubocop:disable to 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.

Back to top ↑