Style, Linting and Static Analysis

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). Ruby has no compile step that rejects poor code, so a project relies on tools that read the source and complain before it runs: the interpreter’s own warnings, a style checker, smell and complexity detectors, and security scanners. Everything except the interpreter’s warnings is a community tool, labelled as such below; none of them is part of Ruby itself. The tools are cheap to run, so the practical question is not which one is best but which set a project runs, with what configuration, and where (in the editor, before a commit, in continuous integration).

The page covers, in order: the checks the ruby command already performs, the Ruby Style Guide that gives most linters their rules, RuboCop (the de facto linter and formatter) in depth, how to write and test your own RuboCop rule, the alternatives and complements (Standard, Reek, Flay, Flog, RubyCritic), a small static analyser written with Prism, editor integration, dependency and security scanners, and continuous integration. The running scenario is the shelf gem used throughout this section. Typing tools (RBS, Steep, Sorbet) are a different kind of static analysis and have their own page: Type Checking with RBS, TypeProf, Steep and Sorbet. The tests that style checks complement are in Testing with Minitest and RSpec and Test Doubles; naming and structure advice is in Idioms, Style and Clean Code; the gems and Bundler that install these tools are in RubyGems and Bundler; and the security-specific checks are in Security. For the same ideas elsewhere see Python lexical structure and style and Rust tooling: Clippy, rustfmt and rust-analyzer.

How the examples were checked. Every Ruby listing was syntax-checked with ruby -wc, and the ones that execute were run on Ruby 4.0.7 with -w (the listings that print warnings do so on purpose and show them). Every listing was linted with RuboCop 1.92.0 (running on Ruby 4.0.7, analysing as Ruby 4.0) using the configuration of the shelf gem: TargetRubyVersion: 4.0, NewCops: enable, double-quoted strings and Style/Documentation off. Listings that are deliberately bad say so, and the RuboCop output shown for them is real output. RuboCop’s own tool outputs list absolute paths of the machine that produced them; those are shortened to relative paths here. RSpec and ruby-parse (from the parser gem that ships with RuboCop) were run for real. Standard, Reek, Flay, Flog, RubyCritic, ruby-lsp, bundler-audit and Brakeman are not installed on the machine that produced this page: they are described from their official documentation and READMEs and never from a run, and the continuous-integration workflow is shown but was not executed.

The layers of checking

Layer Tool What it finds

Interpreter

ruby -c, ruby -w, ruby -W:category

Syntax errors, unused variables, misleading code, deprecated behaviour, unused blocks. Part of Ruby.

Style and lint

RuboCop (community), Standard (community, built on RuboCop)

Layout, naming, idiom, probable bugs, rule-of-thumb metrics. Autocorrects many findings.

Design smells and duplication

Reek, Flay (both community)

Feature envy, long parameter lists, structurally duplicated code.

Complexity and churn

Flog, RubyCritic (community), or a short Prism script

Which methods and files are the hardest to change, and which of those change most.

Dependencies and security

bundler-audit, Brakeman (community)

Known vulnerable gem versions in Gemfile.lock; Rails-specific vulnerabilities.

Types

RBS, TypeProf, Steep, Sorbet

Wrong argument and return types. Separate page.

The cost of a check decides where it runs. Cheap, local and mostly automatic checks (syntax, the formatter) belong in the editor; fast project-wide checks belong in the pre-commit hook or the default Rake task; slow or advisory ones (duplication, complexity reports, vulnerability scans that need a database) belong in continuous integration, where a failure blocks a merge or merely reports.

flowchart LR ed["Editor: ruby-lsp, RuboCop LSP, format on save"] --> hook["Before commit: rubocop on changed files"] hook --> ci["CI: rake rubocop, tests, bundler-audit"] ci --> report["Advisory reports: RubyCritic, Flay, Reek"] ci --> review["Code review: design, naming, behaviour"] report --> review

What the interpreter checks

Syntax checking and warnings

ruby -c file.rb parses the file and prints Syntax OK without running it. -w turns on warnings, and -wc does both: the parser reports warnings that it can find without executing anything, such as variables that are assigned and never read. A script that is parsed whole before it runs (since Ruby 3.4 by the Prism parser) therefore gets its warnings even on a branch that never executes.

# frozen_string_literal: true

def each_title(titles)
  titles.each { |t| t }
end

each_title(%w[a b]) { |t| t }
x = 1
puts "done"
$ ruby -wc warn.rb
warn.rb:8: warning: assigned but unused variable - x
Syntax OK

$ ruby -w warn.rb
warn.rb:8: warning: assigned but unused variable - x
warn.rb:7: warning: the block passed to 'Object#each_title' defined at warn.rb:3 may be ignored
done

Two different warnings appear. The unused variable is found at parse time, so -wc reports it. The ignored block is found at run time, when each_title is called with a block that the method never yields to, so only the run reports it. On Ruby 3.4.11 and 4.0.7 -w prints the second warning; Ruby 3.3.12 prints only the first. A plain ruby warn.rb (no flag) prints neither. The ignored-block warning is the one controlled by the strict_unused_block category, described next.

Warning categories

Warning.categories lists the categories; -W:category enables one and -W:no-category disables it, and Warning[:category] = true does the same from code.

Category Controls

deprecated

Deprecated behaviour that still works. Off by default; -w turns it on (Warning[:deprecated] is false in a plain run and true under -w).

experimental

Experimental features. On by default (Warning[:experimental] is true in a plain run); -W:no-experimental silences them.

performance

Performance hints, for example about object shapes.

strict_unused_block

Block passed to a method that does not use it, in the stricter form (3.4 and later).

On Ruby 4.0.7 and 3.4.11 Warning.categories returns [:deprecated, :experimental, :performance, :strict_unused_block]; on 3.3.12 the categories method does not exist (NoMethodError) and -W:strict_unused_block prints unknown warning category. That is a good example of why a project that supports several Ruby versions runs its warnings check on the oldest one too.

Chilled strings

Ruby 3.4 made string literals without a frozen_string_literal magic comment chilled: they behave as before but warn when mutated, and the warning is shown only when the deprecated category is on. Literals are still not frozen by default in 4.0.

title = "ruby"
title << " 4.0"
puts title
$ ruby -W:deprecated chilled.rb     # Ruby 3.3.12: prints only "ruby 4.0"
$ ruby -W:deprecated chilled.rb     # Ruby 3.4.11 and 4.0.7:
chilled.rb:2: warning: literal string will be frozen in the future (run with --debug-frozen-string-literal for more information)
ruby 4.0
$ ruby chilled.rb                   # Ruby 4.0.7, default flags
ruby 4.0

A linter closes the same gap statically: RuboCop’s Style/FrozenStringLiteralComment reports the missing magic comment (Missing frozen string literal comment) without running the file, and autocorrects it. Books written before Ruby 3.4 sometimes say that literals are already frozen; they are not, and the magic comment remains the way to freeze them.

Other interpreter-side helpers

  • syntax_suggest (a default gem, 2.0.3 in Ruby 4.0.7, which Ruby loads when a script raises a SyntaxError) points at the likely missing end and prints the lines around it.

  • ruby -ce 'code' checks a one-liner.

The Ruby Style Guide

The community-maintained Ruby Style Guide (https://rubystyle.guide/) is a long list of conventions for writing Ruby. It states its own philosophy: prefer readability and established practice, and remember that consistency within a project matters more than strict adherence to the guide; it explicitly allows breaking a rule when following it would hurt readability or conflict with surrounding code. RuboCop describes itself as a static analyser and formatter based on this guide, and most of its cops link to the rule they enforce (rubocop --show-docs-url Style/GuardClause prints https://docs.rubocop.org/rubocop/cops_style.html#styleguardclause; the offense output can show the guide link with -S). Two consequences follow. A cop is a rule of thumb that a team has agreed to follow, not a statement of what Ruby requires; and the guide is a good tiebreaker in code review, because it gives a reason rather than a taste. The idioms behind many rules (guard clauses, fetch instead of [], Enumerable instead of loops) are explained in Idioms, Style and Clean Code.

RuboCop

RuboCop is a community static analyser and formatter, installed as a gem (gem install rubocop, or an entry in the Gemfile). It parses each file into a syntax tree and runs cops over it. A cop is a class that checks one rule; cops are grouped into departments by their name prefix.

Department Contents

Layout

Whitespace, indentation, line length, blank lines. What a formatter would do.

Lint

Probable bugs: useless assignments, unreachable code, suspicious constructs, redundant directives.

Style

Idioms and conventions from the style guide.

Naming

Method, variable, constant and file names.

Metrics

Size and complexity limits: method length, ABC size, cyclomatic and perceived complexity, parameter count.

Security

Dangerous calls: eval, Kernel#open, Marshal.load, YAML.load, JSON.load, some IO methods.

Gemspec, Bundler

Rules for .gemspec files and Gemfile.

Migration

Rules for moving between versions of RuboCop itself.

Extension gems add departments (Performance, Minitest, RSpec, Rake, Rails); see the plugins section.

Running it

rubocop                       # every Ruby file in the tree, per .rubocop.yml
rubocop lib/shelf/fines.rb    # specific files or directories
rubocop -l                    # lint cops only (--lint)
rubocop --only Style/GuardClause,Lint
rubocop --except Metrics
rubocop -L                    # list the files it would inspect
rubocop --list-enabled-cops-for lib/shelf/fines.rb
rubocop --show-cops Style/NumericPredicate    # configuration of a cop
rubocop --explain Style/NumericPredicate      # what a cop does and whether it corrects
rubocop --changed             # only files that differ from a git revision (HEAD by default)

The exit status is 0 when no offense reaches the fail level and 1 when there is one, which is what makes it usable in scripts and CI; --fail-level (severities A, I, R, C, W, E, F) changes the threshold. The first letter in each offense line is the severity: R refactor, C convention, W warning, E error, F fatal, I info. Here is a deliberately poor file from the shelf gem, and what RuboCop and the interpreter say about it.

# frozen_string_literal: true

module Shelf
  # Prices and lateness rules for loans.
  class Fines
    def initialize(rate)
      @rate = rate
      unused = 1
    end

    def for_days(days)
      if days > 0
        return days * @rate
      else
        return 0
      end
    end

    def label(days)
      amount = for_days(days)
      'Fine: ' + amount.to_s
    end

    def waived?(days) = days.zero? ? true : false
  end
end
$ ruby -wc messy.rb
messy.rb:8: warning: assigned but unused variable - unused
Syntax OK
$ rubocop messy.rb
Inspecting 1 file
W

Offenses:

messy.rb:8:7: W: [Correctable] Lint/UselessAssignment: Useless assignment to variable - unused.
messy.rb:12:7: C: [Correctable] Style/GuardClause: Use a guard clause (return days * @rate if days > 0) instead of wrapping the code inside a conditional expression.
messy.rb:12:10: C: [Correctable] Style/NumericPredicate: Use days.positive? instead of days > 0.
messy.rb:13:9: C: [Correctable] Style/RedundantReturn: Redundant return detected.
messy.rb:15:9: C: [Correctable] Style/RedundantReturn: Redundant return detected.
messy.rb:21:7: C: [Correctable] Style/StringConcatenation: Prefer string interpolation to string concatenation.
messy.rb:21:7: C: [Correctable] Style/StringLiterals: Prefer double-quoted strings unless you need single quotes to avoid extra backslashes for escaping.
messy.rb:24:35: C: [Correctable] Style/IfWithBooleanLiteralBranches: Remove redundant ternary operator with boolean literal branches.
messy.rb:24:36: C: [Correctable] Style/RedundantCondition: Use double pipes || instead.

1 file inspected, 9 offenses detected, 9 offenses autocorrectable

(Each offense is followed in the real output by the source line and carets under the offending range; they are omitted here.) Notice what each tool saw. The interpreter found the unused variable, and RuboCop found the same thing as Lint/UselessAssignment (the two overlap deliberately), plus idiom and style findings the interpreter knows nothing about. Two offenses sit on the same ternary: one cop says the ternary is redundant, another proposes a rewrite, and applying them one after another produces a result that deserves a second look, as the next section shows.

Autocorrect: -a versus -A

Many cops can fix what they report. Each cop is marked safe or unsafe for autocorrection (Safe and the autocorrect line in --explain show which): a safe correction never changes behaviour, an unsafe one usually does not but can. rubocop -a (--autocorrect) applies only the safe corrections; rubocop -A (--autocorrect-all) applies unsafe ones too; -x (--fix-layout) runs only layout cops with correction on; and --diff prints what autocorrection would change without writing anything. The older spellings --auto-correct, --safe-auto-correct and --auto-correct-all still work as deprecated aliases.

--explain shows why two of the offenses above survive -a:

$ rubocop --explain Style/NumericPredicate
Style/NumericPredicate

  Checks for the use of predicate- or comparison methods for numeric comparisons.

Properties

  Enabled:     true
  Safe:        false
  Autocorrect: unsafe, applied by -A only
  Added:       0.42
  Changed:     0.59
  Excludes:    spec/**/*

Style/NumericPredicate is unsafe because days > 0 and days.positive? differ when days is not a Numeric (a nil or a custom object that defines >). Style/StringConcatenation is also marked unsafe (its --explain output says so). Running -a on a copy of the file and diffing it against the original shows the safe pass:

--- messy.rb
+++ safe.rb
@@ -5,22 +5,19 @@
   class Fines
     def initialize(rate)
       @rate = rate
-      unused = 1
     end

     def for_days(days)
-      if days > 0
-        return days * @rate
-      else
-        return 0
-      end
+      return days * @rate if days > 0
+
+      0
     end

     def label(days)
       amount = for_days(days)
-      'Fine: ' + amount.to_s
+      "Fine: " + amount.to_s
     end

-    def waived?(days) = days.zero? ? true : false
+    def waived?(days) = days.zero? || false
   end
 end

The summary line said 15 offenses detected, 13 offenses corrected, 2 more offenses can be corrected with rubocop -A (15 because correcting one thing exposes the next: layout cops run again on the rewritten code). A second run with -A then finishes the job, and the last two differences from the safe result are exactly the unsafe ones:

-      return days * @rate if days > 0
+      return days * @rate if days.positive?
...
-      "Fine: " + amount.to_s
+      "Fine: #{amount}"
...
-    def waived?(days) = days.zero? || false
+    def waived?(days) = days.zero?

The result is clean (ruby -wc says Syntax OK and RuboCop reports no offenses). Two habits follow from this experiment. Commit before autocorrecting and read the diff: the days.zero? || false step was valid but silly, and only a human noticed. Prefer -a in automated settings and keep -A for an interactive session where you read the result.

Disabling cops in the source

A cop can be turned off for a region with directive comments. # rubocop:disable Cop/Name …​ # rubocop:enable Cop/Name disables it for the lines between; # rubocop:disable Cop/Name at the end of a line disables it for that line; # rubocop:disable-next Cop/Name (see below) covers the next line; and # rubocop:todo Cop/Name works like disable but marks the exception as debt to be cleaned up. A directive at the top of a file with no matching enable disables the cop to the end of the file.

# frozen_string_literal: true

module Shelf
  # Directive comments in practice.
  class Directives
    # rubocop:disable Style/NumericPredicate
    def positive?(days) = days > 0
    # rubocop:enable Style/NumericPredicate

    def label(days)
      "Fine: " + days.to_s # rubocop:todo Style/StringConcatenation
    end

    def nothing_to_hide
      1 + 1 # rubocop:disable Style/NumericPredicate
    end
  end
end
$ rubocop directives.rb
directives.rb:6:5: C: [Correctable] Style/DirectiveScope: Use disable-next instead of a disable/enable pair around a single statement.
directives.rb:15:13: W: [Correctable] Lint/RedundantCopDisableDirective: Unnecessary disabling of Style/NumericPredicate.

1 file inspected, 2 offenses detected, 2 offenses autocorrectable

The two disabled offenses (days > 0 and the concatenation) are not reported. Two cops police the directives themselves: Style/DirectiveScope (in the pending set in this version; it appears here because the configuration has NewCops: enable) says a disable/enable pair around one statement should be a disable-next, and Lint/RedundantCopDisableDirective reports a directive that disables something that was not being reported, so disables do not outlive their reason. rubocop --display-suppressed also lists the suppressed offenses, marked [Suppressed], and --ignore-disable-comments reports them as if the directives were absent (in this file, four offenses instead of two).

Configuration: .rubocop.yml

RuboCop looks for .rubocop.yml starting in the directory of the inspected file and then in its parent directories; a file in a subdirectory takes precedence over one above it. rubocop --init creates a starting file. The shelf gem’s own file is short:

AllCops:
  TargetRubyVersion: 4.0
  NewCops: enable
  SuggestExtensions: false
Style/Documentation:
  Enabled: false
Style/StringLiterals:
  EnforcedStyle: double_quotes

A larger project adds exclusions and tunes limits. The configuration below was run on two copies of the file above under lib/shelf/:

AllCops:
  TargetRubyVersion: 4.0
  NewCops: enable
  SuggestExtensions: false
  Exclude:
    - "vendor/**/*"
    - "tmp/**/*"

Style/Documentation:
  Enabled: false

Style/StringLiterals:
  EnforcedStyle: double_quotes

Metrics/MethodLength:
  Max: 12

Layout/LineLength:
  Max: 100
  AllowedPatterns: ['\A\s*# https?://']

Metrics/BlockLength:
  Exclude:
    - "spec/**/*"
    - "*.gemspec"

The settings that matter most:

Setting Meaning

AllCops: TargetRubyVersion

The oldest Ruby the code must run on. Cops that would suggest newer syntax stay quiet below the version that introduced it. If unset, RuboCop reads .tool-versions, .ruby-version, then Gemfile.lock, and falls back to the oldest version it supports.

AllCops: NewCops

enable, disable or pending (default). New cops arrive as pending; without a decision RuboCop prints a long notice listing them (observed on this machine: dozens of lines of Enabled: true suggestions). NewCops: enable opts in to every new cop; disable opts out. Use --enable-pending-cops or --disable-pending-cops for one run.

AllCops: Exclude, Include

Files to skip or add. --force-exclusion applies Exclude even to files named on the command line, which an editor or a hook needs.

AllCops: SuggestExtensions

Whether to print hints for extension gems that match the Gemfile.

AllCops: ParserEngine

default, parser_whitequark or parser_prism. The Parser gem recognises up to Ruby 3.4 syntax; Prism covers 3.3 and later. With TargetRubyVersion: 4.0 on this machine rubocop -V reports Prism 1.8.1 ... analyzing as Ruby 4.0, and the standalone ruby-parse of the parser gem warns that it is loading its Ruby 3.3 grammar on Ruby 4.0.7.

Cop/Name: Enabled

Turn a cop on or off. Enabled: pending is the new-cop state.

Cop/Name: Include, Exclude

Restrict a cop to, or exempt it from, paths. Cop-level globs override the global ones for that cop.

Cop parameters (Max, EnforcedStyle, AllowedMethods, AllowedPatterns, CountAsOne)

Each cop documents its own; rubocop --show-cops Cop/Name prints the effective values.

inherit_from

Other YAML files (local paths or URLs) merged underneath this one; later files win. Where a team’s shared configuration lives.

inherit_gem

Config files shipped inside a gem.

plugins, require

Load extensions and custom cops. See below.

Two configuration behaviours cause most of the confusion. First, a list such as Exclude in a file that inherits from another replaces the parent’s list unless inherit_mode names it for merging. Second, a cop configured in .rubocop.yml wins over the same cop in an inherited file, which matters for the generated todo file described next.

Adopting RuboCop on existing code: the todo file

Turning on RuboCop for a mature project reports thousands of offenses. rubocop --auto-gen-config records the current state as a baseline instead of fixing everything at once: it writes .rubocop_todo.yml, adds inherit_from: .rubocop_todo.yml to .rubocop.yml, and each offending cop gets an Exclude list of the files that currently break it (or a Max raised to today’s worst value, or a changed EnforcedStyle), preceded by the offense count. Running it on the two-file example above:

$ rubocop --auto-gen-config --no-auto-gen-timestamp
Added inheritance from `.rubocop_todo.yml` in `.rubocop.yml`.
Phase 1 of 2: run Layout/LineLength cop (skipped because the default Layout/LineLength:Max is overridden)
Phase 2 of 2: run all cops
Inspecting 2 files
WW

2 files inspected, 18 offenses detected, 18 offenses autocorrectable
Created .rubocop_todo.yml.

An excerpt of the generated file:

# Offense count: 2
# This cop supports safe autocorrection (--autocorrect).
Lint/UselessAssignment:
  Exclude:
    - 'lib/shelf/fines.rb'
    - 'lib/shelf/late_fees.rb'

# Offense count: 2
# This cop supports unsafe autocorrection (--autocorrect-all).
# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns.
# SupportedStyles: predicate, comparison
Style/NumericPredicate:
  Exclude:
    - 'spec/**/*'
    - 'lib/shelf/fines.rb'
    - 'lib/shelf/late_fees.rb'

After this, rubocop reports only the offense the todo file could not absorb (below), and new code is held to the rules while old code is grandfathered. The workflow is to remove entries one at a time: delete a cop’s block, run rubocop -a (or fix by hand), and commit. --regenerate-todo rewrites the file from the current state, --report-unused-todo-entries fails when an entry is no longer needed, --exclude-limit controls how many files a cop lists before it is disabled outright (default 15), and --no-auto-gen-timestamp keeps the file stable between regenerations so it does not create diff noise.

One gotcha appeared in this run. The generated file contained Style/StringLiterals: EnforcedStyle: single_quotes because the sample code used single quotes in the majority, while .rubocop.yml says double_quotes. The main file wins, so the two 'Fine: ' offenses stayed reported instead of being swallowed. Review the generated EnforcedStyle lines against the style you really want before committing the file.

Metrics cops

The Metrics department measures size and complexity, and its limits are the part of RuboCop teams argue about most. Defaults in RuboCop 1.92.0:

Cop Default Max Measures

Metrics/MethodLength

10

Lines of the method body (comments are not counted by default).

Metrics/ClassLength, Metrics/ModuleLength

100

Lines of the body. CountAsOne lets arrays, hashes or heredocs count as one line.

Metrics/BlockLength

25

Lines of a block. Commonly excluded for spec files and gemspecs.

Metrics/ParameterLists

5

Parameters per method (MaxOptionalParameters is 3).

Metrics/AbcSize

17

The ABC measure: assignments, branches (calls) and conditions combined as a magnitude, sqrt(A^2 + B^2 + C^2).

Metrics/CyclomaticComplexity

7

Decision points in a method plus one.

Metrics/PerceivedComplexity

8

Like cyclomatic complexity, but a case or an if/else chain is weighted by how hard it reads.

Metrics/BlockNesting

3

Depth of nested conditionals and loops.

The measures are only meaningful relative to a baseline, so lower the limits on a copy of the configuration to see how they work (limits of 1, 3 and 3 on fines.rb):

$ rubocop -c metrics.yml --only Metrics fines.rb
fines.rb:11:5: R: Metrics/AbcSize: Assignment Branch Condition size for for_days is too high. [<0, 1, 3> 3.16/3]
fines.rb:11:5: R: Metrics/CyclomaticComplexity: Cyclomatic complexity for for_days is too high. [2/1]
fines.rb:11:5: R: Metrics/MethodLength: Method has too many lines. [5/3]
fines.rb:19:5: R: Metrics/AbcSize: Assignment Branch Condition size for label is too high. [<1, 3, 0> 3.16/3]
fines.rb:24:5: R: Metrics/CyclomaticComplexity: Cyclomatic complexity for waived? is too high. [2/1]

The bracketed numbers are the vector <assignments, branches, conditions> and its magnitude over the limit. Most Metrics offenses have no autocorrection: the fix is to extract a method or an object, which is a design decision (see SOLID and Design Patterns). Raise a limit deliberately and in the configuration file, with a comment, rather than disabling a cop in the code.

Formatters, output and caching

-f/--format selects the output format and can be repeated with -o to write each to a file: progress (default), simple, clang, emacs, files, offenses, worst, fuubar, pacman, quiet, tap, json, junit, html, markdown, sarif (for code-scanning dashboards), github (annotations in GitHub Actions) and autogenconf; a class name loads a custom formatter. -D/--[no-]display-cop-names toggles cop names (on by default), -E adds extra details and -S the style-guide URL. Results are cached per file so a re-run only inspects changed files (-C false disables the cache, --cache-root moves it), and -P/--[no-]parallel uses all CPU cores.

Plugins and extensions

Extension gems add cops for a library or a practice. Since RuboCop 1.72 they are loaded with plugins: (built on the lint_roller gem); older configurations used require:, which still loads plain Ruby files such as a project’s custom cops, and a gem that supports the plugin API makes RuboCop warn to switch. The commonly used extensions, all community-maintained under the rubocop organisation, are rubocop-performance (idioms that run faster), rubocop-rspec, rubocop-minitest, rubocop-rake and, for Rails applications (out of scope in this section), rubocop-rails. None of them is installed on the machine that produced this page, so the following is the documented form, not output:

plugins:
  - rubocop-performance
  - rubocop-minitest
  - rubocop-rake
# frozen_string_literal: true

group :development do
  gem "rubocop", require: false
  gem "rubocop-performance", require: false
  gem "rubocop-minitest", require: false
  gem "rubocop-rake", require: false
end

require: false keeps Bundler from loading the tool into the application at runtime; the tool is invoked as bundle exec rubocop. Pinning RuboCop (or at least committing Gemfile.lock) matters, because every release can add cops and change defaults.

Editors, servers and other modes

  • rubocop --lsp starts a Language Server Protocol server on standard input, so an editor shows offenses and offers quick fixes as you type.

  • rubocop --mcp starts a Model Context Protocol server over standard input and output (listed in the 1.92.0 help text; not exercised here).

  • rubocop --server starts a background process so repeated runs skip Ruby’s start-up cost (--stop-server, --restart-server and --server-status manage it).

  • rubocop -s FILE (--stdin) reads source from standard input and reports under the file name FILE, which is how editors and some hooks run it; add --editor-mode for editor-friendly behaviour.

  • --parallel, --cache and --fail-fast trade completeness for speed.

Writing a custom cop

Teams eventually have a rule no published cop covers: a project convention, a deprecated internal API, a pattern that caused an incident. RuboCop makes writing one cheap, and the cop then runs everywhere the other cops run. The example enforces a shelf convention: library code reports through a Logger, never through Kernel#puts, print or p.

The syntax tree and NodePattern

A cop matches shapes in the syntax tree. ruby-parse -e 'code' (from the parser gem installed with RuboCop) prints the tree in a Lisp-like notation:

$ ruby-parse -e 'puts "hi #{x}"'
(send nil :puts
  (dstr
    (str "hi ")
    (begin
      (send nil :x))))
$ ruby-parse -e 'logger.info("x")'
(send
  (send nil :logger) :info
  (str "x"))

A method call is a send node: receiver (nil when there is none), method name, then the arguments. That is the whole difference between puts "x" and $stdout.puts "x" that the cop must see. NodePattern is RuboCop’s small language for matching such shapes: (send nil? {:puts :print :p} ...) reads "a send whose receiver is nil, whose method name is one of these symbols, followed by anything"; nil? is a predicate on the receiver, {a b} is alternation, ... matches any remaining children, $ captures a node and _ matches any one node.

The cop

# frozen_string_literal: true

module RuboCop
  module Cop
    module Shelf
      # Library code reports through a Logger, not through Kernel#puts.
      #
      # @example
      #   # bad
      #   puts "added #{book.title}"
      #
      #   # good
      #   logger.info("added #{book.title}")
      class NoKernelOutput < Base
        MSG = "Log through a Logger instead of `%<method>s`."

        RESTRICT_ON_SEND = %i[puts print p].freeze

        # @!method kernel_output?(node)
        def_node_matcher :kernel_output?, <<~PATTERN
          (send nil? {:puts :print :p} ...)
        PATTERN

        def on_send(node)
          return unless kernel_output?(node)

          add_offense(node, message: format(MSG, method: node.method_name))
        end
      end
    end
  end
end

The parts:

  • Base is the current base class for cops; a cop subclasses RuboCop::Cop::Base and implements on_<node type> callbacks that RuboCop calls while walking the tree. A cop that autocorrects also does extend AutoCorrector and passes a block to add_offense that edits the source.

  • RESTRICT_ON_SEND limits on_send to calls of those method names, which makes the cop fast.

  • def_node_matcher generates a predicate from a NodePattern; the @!method tag documents the generated method.

  • add_offense(node, message:) registers the finding.

Registering and configuring it

A project loads a custom cop with require: (not plugins:, which is for gems that implement the plugin API). The entry file only requires the cop:

# frozen_string_literal: true

require_relative "rubocop/cop/shelf/no_kernel_output"
require:
  - ./lib/rubocop_shelf

AllCops:
  TargetRubyVersion: 4.0
  NewCops: enable
  SuggestExtensions: false

Style/StringLiterals:
  EnforcedStyle: double_quotes

Shelf/NoKernelOutput:
  Description: "Library code logs through a Logger."
  Enabled: true
  Include:
    - "lib/shelf/**/*.rb"

A cop outside RuboCop’s own tree needs a configuration entry under its department name (Shelf/NoKernelOutput) with Enabled: true; the Include list scopes it to library code, so tests and scripts may still print. Running it against a file with two violations:

# frozen_string_literal: true

module Shelf
  # Imports titles from a list.
  class Importer
    def initialize(logger: nil)
      @logger = logger
    end

    def import(titles)
      titles.each do |title|
        puts "importing #{title}"
        @logger&.info("imported #{title}")
      end
      p titles.size
    end
  end
end
$ rubocop
Inspecting 3 files
..C

Offenses:

lib/shelf/importer.rb:12:9: C: Shelf/NoKernelOutput: Log through a Logger instead of puts.
        puts "importing #{title}"
        ^^^^^^^^^^^^^^^^^^^^^^^^^
lib/shelf/importer.rb:15:7: C: Shelf/NoKernelOutput: Log through a Logger instead of p.
      p titles.size
      ^^^^^^^^^^^^^

3 files inspected, 2 offenses detected

The backticks in the message are formatting for tools that render them; the terminal output drops them, but the specification below compares the exact message text, backticks included. The logger call with @logger&.info is not flagged because it has an explicit receiver, and --list-enabled-cops-for lib/shelf/importer.rb shows Shelf/NoKernelOutput among the cops that apply to the file.

Testing a cop

RuboCop ships RSpec helpers for cops in rubocop/rspec/support: expect_offense takes a source heredoc in which carets under the offending range are followed by the message, and expect_no_offenses and expect_correction cover the other cases.

# frozen_string_literal: true

require "rubocop"
require "rubocop/rspec/support"
require_relative "../lib/rubocop_shelf"

RSpec.describe RuboCop::Cop::Shelf::NoKernelOutput, :config do
  it "registers an offense for puts" do
    expect_offense(<<~RUBY)
      puts "hello"
      ^^^^^^^^^^^^ Log through a Logger instead of `puts`.
    RUBY
  end

  it "registers an offense for p without a receiver" do
    expect_offense(<<~RUBY)
      p value
      ^^^^^^^ Log through a Logger instead of `p`.
    RUBY
  end

  it "accepts an explicit receiver" do
    expect_no_offenses(<<~RUBY)
      $stdout.puts "hello"
      logger.info("hello")
    RUBY
  end
end
$ rspec spec
...

Finished in 0.04334 seconds (files took 0.24053 seconds to load)
3 examples, 0 failures

The first version of this specification failed twice, which is typical of the first run of such a test: the caret line was one character too long, and the expected message lacked the backticks of the constant. The failure output is a diff between the expected and the actual annotation (a line starting with - for what the specification expected and one starting with + for what the cop produced), which makes the mistake easy to see.

The specification is itself a Ruby file, so it was linted too (rubocop spec lib/rubocop reported no offenses). RuboCop ships a cop generator for contributions to RuboCop itself (bundle exec rake new_cop[Style/Name] inside its repository); a project-local cop is simply a file plus a spec, as above.

Standard

Standard (community, https://github.com/standardrb/standard) is a Ruby linter and formatter built on RuboCop. It provides a fixed rule set that the maintainers update as RuboCop adds rules and includes the rubocop-performance rules. Its goal is to end style debates by giving every project the same defaults, and its README calls this an "unconfigurable configuration": the value of a linter comes from using one consistently.

Item Detail

Commands

standardrb lints; standardrb --fix applies safe fixes; standardrb --fix-unsafely also applies unsafe ones; standardrb --generate-todo writes .standard_todo.yml to adopt it gradually. rake standard and rake standard:fix run the same checks.

.standard.yml

Optional, searched in the current directory and then its parents. Options: ignore (files or globs, optionally limited to certain rules), ruby_version (the oldest supported Ruby; libraries should set it), plugins (for example standard-rails), extend_config (extra RuboCop config files for additional rules), and others such as fix, parallel, format, default_ignores.

Relationship to RuboCop

A curated RuboCop configuration. Plugins and extend_config can add rules but cannot change Standard’s defaults. Running its rules directly through the rubocop command is possible but not supported.

Choose Standard when the team wants no configuration debate and accepts its choices; choose plain RuboCop when you need to tune cops or write custom ones, as above. Older descriptions say Standard cannot be configured at all and skip files with a .standardrbignore file; the current way to skip files is the ignore option in .standard.yml. These are descriptions from the project’s README, not output from a run.

Design smells and duplication

These tools look for design problems rather than formatting, and produce advice rather than mechanical fixes. All are community tools, described from their READMEs (none was run).

Reek

Reek (https://github.com/troessner/reek) detects code smells in classes, modules and methods: Feature Envy (a method more interested in another object’s data than its own), Long Parameter List, Too Many Statements, Uncommunicative Name, Unused Parameters and others, each documented in the project’s Code Smells reference. It is configured by .reek.yml, found via -c, the current directory, its parents or the home directory. The file has three top-level keys: detectors (turn a smell on or off, set its options, add exclusions), directories (different settings for particular directories) and exclude_paths. Reek validates the file at start-up and reports misspelled keys. reek --todo lib/ generates a configuration that suppresses the current warnings, for one-time adoption on an existing codebase, and a comment of the form # :reek:SmellName suppresses one smell in the source. Output formats include text (default), HTML, YAML, JSON, XML and a GitHub format. Reek’s checks overlap with RuboCop’s Metrics cops in spirit, but they name a design problem (Feature Envy) where RuboCop counts lines.

Flay

Flay (https://github.com/seattlerb/flay) finds structural duplication: it ignores differences in literal values, variable and method names, whitespace and block style, so code that is shaped the same is reported even when written differently. Each match has a mass, roughly the size of the duplicated structure, and identical matches (not merely similar) are weighted higher. flay lib scans files or directories; --diff prints a diff of the matched code (it needs ruby2ruby), --liberal and --fuzzy change how aggressively matches are pruned, -v is verbose, a .flayignore file skips paths, and there is a FlayTask for Rakefiles.

Flog

Flog (https://github.com/seattlerb/flog) scores Ruby code by complexity and reports a "pain" figure per method, sorted from highest to lowest, with a total and an average per method at the top (the README’s example runs flog -g lib). Higher numbers mean more tortured code; the README does not document the formula, so treat the numbers as a ranking, not an absolute measure.

RubyCritic

RubyCritic (https://github.com/whitesmith/rubycritic) wraps Reek, Flay and Flog and turns their output into one report: a project overview, an index of files with their smell counts, and an index of smells with code context. It compares uncommitted changes with the last commit using Git, Mercurial or Perforce, which is what makes it useful in code review. Common options include -p/--path (where the report goes, default tmp/rubycritic), -f/--format (html default, json, console, lint), -s/--minimum-score (fail below a score), -m/--mode-ci (analyse only the diff against a base branch, main by default) with -b/--branch and -t/--maximum-decrease, and --no-browser. Settings can live in .rubycritic.yml, and there is a Rake task. Because the figures are advisory, teams usually run RubyCritic in CI as a report and gate only on a score that must not fall, not on an absolute grade.

Complexity and churn with Prism

A number such as "cyclomatic complexity 12" tells you little by itself. The most useful question is where complexity and change meet: a complicated file that nobody touches is stable and can wait, a simple file that changes weekly is not a problem of design, but a complicated file that changes weekly is where bugs and cost accumulate. Churn times complexity ranks files by that product. RubyCritic reports churn for this reason; this section shows the idea with the tools Ruby gives you, which also demonstrates how a static analyser is built.

Prism, the parser Ruby 4.0 uses by default (the Prism module is part of Ruby), parses source into a tree of node classes and offers Prism::Visitor to walk it. The script counts decision points per method: every if, unless, when, in, loop, rescue, and and or adds one to a base of one, which is the cyclomatic idea.

# frozen_string_literal: true

require "prism"

# Counts decision points per method with a Prism visitor: a rough
# cyclomatic-complexity measure, in the spirit of Flog and RuboCop's
# Metrics/CyclomaticComplexity.
class ComplexityVisitor < Prism::Visitor
  BRANCHES = %i[if unless when in while until for rescue and or].freeze

  attr_reader :scores

  def initialize
    super
    @scores = {}
    @current = nil
  end

  def visit_def_node(node)
    outer = @current
    @current = node.name
    @scores[@current] = 1
    super
    @current = outer
  end

  Prism::Visitor.instance_methods.grep(/\Avisit_(\w+)_node\z/) do
    kind = Regexp.last_match(1).to_sym
    next unless BRANCHES.include?(kind)

    define_method(:"visit_#{kind}_node") do |node|
      @scores[@current] += 1 if @current
      super(node)
    end
  end
end

def complexity_of(path)
  visitor = ComplexityVisitor.new
  Prism.parse_file(path).value.accept(visitor)
  visitor.scores
end

ARGV.each do |path|
  complexity_of(path).each { |name, score| puts "#{path}##{name} #{score}" }
end
$ ruby -w complexity.rb messy.rb
messy.rb#initialize 1
messy.rb#for_days 2
messy.rb#label 1
messy.rb#waived? 2

The scores agree with the Metrics/CyclomaticComplexity offenses shown earlier (for_days 2, waived? 2). The visitor works because every Prism node class has a visit_<name>_node method on Prism::Visitor; the script redefines the ones it counts and calls super so traversal continues. (This is a toy: it counts only the node types in BRANCHES and does not handle nested method definitions specially.)

Churn comes from version control: how many commits touched each file. The second script multiplies the two:

# frozen_string_literal: true

require_relative "complexity"

# Churn (commits touching a file) multiplied by complexity (sum of method
# scores) ranks the files most worth refactoring first.
def churn
  names = IO.popen(["git", "log", "--name-only", "--format="], &:read)
  names.split("\n").reject(&:empty?).tally
end

rows = churn.map do |path, commits|
  score = complexity_of(path).values.sum
  { path:, commits:, complexity: score, hotspot: commits * score }
end

rows.sort_by { -it[:hotspot] }.each do |row|
  puts format("%-16<path>s churn=%<commits>d complexity=%<complexity>d hotspot=%<hotspot>d", **row)
end

Run in a scratch Git repository with six synthetic commits (the files are copies of the shelf catalog.rb, loan.rb, book.rb and the fines.rb above; three of the commits touch fines.rb):

$ ruby -w ../hotspots.rb
lib/fines.rb     churn=4 complexity=6 hotspot=24
lib/loan.rb      churn=2 complexity=6 hotspot=12
lib/catalog.rb   churn=2 complexity=4 hotspot=8
lib/book.rb      churn=1 complexity=1 hotspot=1

The numbers are synthetic and only show the mechanics. On a real project the same list, taken from months of history, is a refactoring backlog in priority order. Two details: IO.popen with an array of arguments passes no shell, so file names with spaces or metacharacters are safe, and it (Ruby 3.4) names the single block parameter.

Editor integration: ruby-lsp

Ruby LSP (community, maintained by Shopify, https://shopify.github.io/ruby-lsp/) is a Language Server Protocol implementation for Ruby. It gives editors hover, completion, go to definition, native syntax-error diagnostics, and linting and formatting through the tools the project already uses. RuboCop is optional but, when present (version 1.4.0 or newer is required), Ruby LSP uses it for diagnostics and formatting; Syntax Tree formatting is also supported. Installation differs by editor: VS Code has a Ruby LSP extension (the documentation says not to install the gem manually there), and other editors (Emacs, Neovim, Sublime Text, Zed) run gem install ruby-lsp and launch ruby-lsp without bundle exec, so the server can see the project’s own dependencies. This is a summary of the project’s documentation; ruby-lsp was not installed to produce this page. RuboCop’s built-in --lsp mode (above) is the alternative when only linting is wanted.

Dependency and security scanners

  • bundler-audit (community, https://github.com/rubysec/bundler-audit) checks Gemfile.lock (not the Gemfile) against the ruby-advisory-db of known vulnerabilities and flags insecure gem sources (http:// and git://). bundle-audit check audits the lockfile (--update refreshes the database first, --ignore ADVISORY-ID skips an advisory, --gemfile-lock PATH selects another lockfile, --format json and --output FILE produce a report); bundle-audit update refreshes the database. A .bundler-audit.yml file lists advisories to ignore, and Rake tasks bundle:audit and bundle:audit:update can be added. It needs the advisory database to be current, which is why CI runs the update step.

  • Brakeman (community, https://brakemanscanner.org/) is a static-analysis vulnerability scanner for Ruby on Rails applications (SQL injection, cross-site scripting, command injection). It understands Rails conventions and is not a general scanner for plain Ruby; Rails itself is outside this section.

  • RuboCop’s own Security department catches the plain-Ruby dangerous calls (eval, Marshal.load, YAML.load, Kernel#open with input, JSON.load); the reasoning behind them is in Security.

Both scanners are summarised from their documentation, not run here.

Continuous integration

The tools are only as good as the habit of running them, and the habit is a single command. A Rake task makes it one name for people and CI alike; RuboCop::RakeTask is part of RuboCop:

# frozen_string_literal: true

require "rake/testtask"
require "rubocop/rake_task"

Rake::TestTask.new(:test) do |t|
  t.libs << "test" << "lib"
  t.pattern = "test/**/*_test.rb"
end

RuboCop::RakeTask.new(:rubocop) do |task|
  task.options = %w[--display-cop-names --format simple]
end

desc "Everything CI runs"
task ci: %i[rubocop test]

task default: :ci
$ rake -T
rake ci                       # Everything CI runs
rake rubocop                  # Run RuboCop
rake rubocop:autocorrect      # Autocorrect RuboCop offenses (only when it's safe)
rake rubocop:autocorrect_all  # Autocorrect RuboCop offenses (safe and unsafe)
rake test                     # Run tests
$ rake rubocop        # on a tree containing the poor file
== lib/shelf/fines.rb ==
W:  8:  7: [Correctable] Lint/UselessAssignment: Useless assignment to variable - unused.
C: 12:  7: [Correctable] Style/GuardClause: Use a guard clause (return days * @rate if days > 0) instead of wrapping the code inside a conditional expression.
...
4 files inspected, 9 offenses detected, 9 offenses autocorrectable
$ echo $?
1

task.options passes command-line flags; the task creates the autocorrect and autocorrect_all variants for free; and a non-zero exit status fails the pipeline. The pre-commit variant runs the same tool on only what changed (rubocop --changed, or git diff --name-only --diff-filter=d | xargs rubocop --force-exclusion).

The workflow below is the minimal shape for GitHub Actions, taken from the ruby/setup-ruby README (it was not executed here). bundler-cache: true runs bundle install and caches the gems, and ruby-version can be omitted when the repository has a .ruby-version file:

name: CI
on: [push, pull_request]
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: ruby/setup-ruby@v1
        with:
          ruby-version: "4.0"
          bundler-cache: true
      - run: bundle exec rake ci
      - run: bundle exec bundle-audit check --update

Where a project supports several Rubies, run the tests on all of them and the linter on one: the linter’s answer does not depend on the interpreter, and the TargetRubyVersion setting already makes it check against the oldest.

Code review and automation

Automation settles the mechanical questions so that review can spend its time on design, naming and behaviour. Practical rules that hold up:

  • Run the formatter and linter in CI and fail on offenses, so a reviewer never has to comment on spacing or quotes.

  • Do not let a linter argue with the team’s own convention: change the configuration once, in a reviewed commit, instead of adding disable comments in every file.

  • Record exceptions with a reason (# rubocop:todo for debt, a comment for a permanent exception) and let Lint/RedundantCopDisableDirective remove stale ones.

  • Treat advisory tools (RubyCritic, Flay, Reek) as conversation starters in review, not as gates, unless the team agrees on a threshold.

  • Land a mass autocorrection as a commit of its own, with no behaviour change, so git blame and review stay readable.

Pitfalls

Pitfall What happens and what to do

Autocorrecting with -A without reading the diff

Unsafe corrections can change behaviour. Commit first, run, read the diff.

Unpinned RuboCop

A new release adds pending cops, and CI fails on a commit that did not change. Commit Gemfile.lock, set NewCops explicitly and upgrade on purpose.

NewCops left unset

RuboCop prints a long notice about pending cops on every run until you choose enable or disable.

TargetRubyVersion missing or too low

Cops do not suggest newer syntax, and files using newer syntax may fail to parse. Set it to the oldest Ruby you support.

A .rubocop_todo.yml that fossilises

Grandfathered offenses are never fixed. Remove a block per change and use --report-unused-todo-entries.

Generated todo changes a style

--auto-gen-config may write an EnforcedStyle that disagrees with your main file. Review it.

Disabling a cop for a whole file

Hides new violations. Prefer disable-next, a narrow region, or a configuration Exclude with a reason.

Treating Metrics limits as law

They are heuristics. A long, flat case may be clearer than five small methods; raise the limit with a comment.

Linting only changed files forever

Cross-file cops and configuration changes need a full run. Do a full run in CI.

Running ruby -w only on the newest Ruby

Warnings differ by version (see the ignored-block warning above). Check the oldest supported version too.

Trusting a tool you did not read

Every cop has a documentation page (--show-docs-url); read it before accepting or disabling a rule.

Where older material is outdated

The books this section draws on predate several of the current behaviours; the current forms are what this page uses.

Older description Current

Standard has no configuration and ignores files through a .standardrbignore file

Skipped files are listed with ignore in .standard.yml, together with ruby_version, plugins and extend_config. The "unconfigurable" part refers to its rules, not its file selection.

bundler-audit reads the Gemfile

It reads Gemfile.lock, so run bundle install (or commit the lockfile) first.

Strings are frozen by default since Ruby 3.4

They are chilled: mutation warns when the deprecated category is on. The magic comment is still the way to freeze.

rubocop --auto-correct

--autocorrect and --autocorrect-all (the old spellings are deprecated aliases).

Extensions loaded with require: rubocop-performance

plugins: rubocop-performance since RuboCop 1.72.

Cops unlisted in .rubocop.yml are simply off or on

New cops are pending until you choose with NewCops or Enabled.

A custom cop that subclasses RuboCop::Cop::Cop

That is the legacy base class (it is itself a subclass of Base in 1.92.0). Subclass RuboCop::Cop::Base as shown above, and test it with expect_offense.

Error messages undefined method `x' for nil:NilClass

In Ruby 3.4 and later: undefined method 'x' for nil. Cop messages that quote Ruby’s text change with it.