What is Rubydex linter

Rubydex is a static analysis toolkit for Ruby. My teammate Vini introduced it in more detail in One engine, many tools — Introducing Rubydex. Rubydex linter is Rubydex’s built-in linter for writing project-specific structural rules that use Rubydex’s understanding of a codebase.

Why structural linting

Many checks can be decided by reading one file, as most RuboCop cops do. But in large codebases, we sometimes need to require or prohibit patterns that span several files.

Consider this example: a project has two modules, LegacyFooHelper and FooHelper, that provide different implementations of the same foo method.

module LegacyFooHelper
  def foo
    behaviour_for_legacy_models
  end
end

module FooHelper
  def foo
    new_behaviour
  end
end

We never want the same model class to include both helpers. But how can we check that?

We can write a RuboCop cop to prevent this direct case:

class User
  include LegacyFooHelper
  include FooHelper
end

But add a bit of inheritance, and the check becomes more difficult:

# user.rb
class User
  include LegacyFooHelper
end

# admin_user.rb
class AdminUser < User
  include FooHelper
end

The same conflict can also happen through a mixin:

# bar_helper.rb
module BarHelper
  include LegacyFooHelper
  # and does something else
end

# user.rb
class User
  include FooHelper
  include BarHelper # oops, now foo is overridden
end

To reliably detect every violation, we need the complete ancestor chain for every model class. We can then check whether both helpers appear in each chain.

A tool that only reads one file at a time does not have this information. Before Rubydex, we could only get it at runtime: load the entire codebase, then inspect every model class.

def test_no_duplicate_foo_helper_inclusion
  eager_load_the_app! # Slow, but necessary to load every model class.
  violated_models = model_classes.select do |model|
    include_both_foo_helpers?(model.ancestors)
  end
  assert_empty violated_models
end

Rubydex statically builds ancestor chains for the classes and modules it indexes, including dependencies. We can now prohibit this pattern without adding a slow runtime test.

How we use it in Shopify monolith

In Shopify’s Rails monolith, we found many tests that did the same work as test_no_duplicate_foo_helper_inclusion: they eager-loaded the whole application and then inspected class hierarchies, method definitions, constant definition locations, allowlists, etc.

For example, we use rules to check that controllers inherit from the correct shared controller class, serializers do not override prohibited methods, and allowlists still refer to real classes.

Those checks are important, but runtime tests are an expensive way to express them. Running each of those tests took minutes because they eager-loaded the whole app.

After we built the linter, we moved the checks that Rubydex can represent into more than 20 linter rules. They now run during local checks and CI without starting the application. The whole process, from indexing the monolith to running every rule, now takes about 30 to 45 seconds.

Write a rule for our example

Unlike most linters, Rubydex linter does not ship with a default set of structural rules for your application. This is because these structural rules are highly specific to individual projects. So the best way to try it is to write a new rule for your project!

First, add Rubydex to your project:

# Gemfile
gem "rubydex"

Then run bundle install.

Rubydex loads rules only from rubydex_linter/rules/ or lib/rubydex_linter/rules/, so create rubydex_linter/rules/no_conflicting_foo_helpers.rb:

# Prevents a class from including both FooHelper and LegacyFooHelper.
#
# This description appears in `rdx lint explain NoConflictingFooHelpers`.
class Rubydex::Linter::Rules::NoConflictingFooHelpers < Rubydex::Linter::CustomRule
  def self.default_severity = Rubydex::Severity::Error

  def lint
    # Find every class with FooHelper in its ancestor chain, including indirect ancestors.
    child_classes("FooHelper").each do |model|
      # Check whether LegacyFooHelper is also in the ancestor chain.
      next unless model.has_ancestor?("LegacyFooHelper")

      # Report the class definition when both helpers are present.
      definition = model.definitions.first

      add_diagnostic(
        "`#{model.name}` must not include both `FooHelper` and `LegacyFooHelper`.",
        diagnostic_location(definition),
      )
    end
  end
end

Once it’s there, run the rule with:

bundle exec rdx lint

Rules can use Rubydex::Linter::RuleTestCase for focused tests. It builds a small Ruby workspace for each example and lets you mark the expected diagnostic directly below the source code.

Here are a few directions you can explore for your codebase:

  1. Require certain ancestor relationships: if a class includes A, it must also include either B or C.
  2. Connect file names, class names, and inheritance: every file ending in _test.rb must define a class ending in Test that inherits from MyProjectTestCase.
  3. Prohibit certain method overrides: descendants of BaseSerializer must not define serializable_hash.
  4. Enforce dependency boundaries: files under app/domain/**/*.rb must not reference constants under Web::*.
  5. Restrict where classes can be reopened: User must not be defined outside app/models/user.rb.

Configure and share rules

By default, Rubydex recursively indexes every .rb, .rake, .rbs, and .ru file it finds in the workspace. Use the [graph] section of rubydex.toml to prevent generated or irrelevant files from being indexed:

[graph]
exclude = [
  "vendor",
  "**/*.generated.rb",
]

Graph exclusions are relative to the workspace root and support glob patterns. They prevent matching files and directories from being indexed.

Rubydex already skips these directories at the workspace root: .bundle, .claude, .git, .github, .ruby-lsp, .vscode, log, node_modules, and tmp. Entries under [graph].exclude are added to these defaults.

Rules can be configured separately:

[linter.rules.NoConflictingFooHelpers]
severity = "warning"
exclude = ["test/**"]
  • Loaded rules are enabled by default. Set enabled = false in a rule’s section to disable it.
  • severity overrides the rule’s default severity. Available values are hint, information, warning, and error. The process exits with code 1 only when the linter reports at least one diagnostic with error severity.
  • A rule-level exclude only suppresses diagnostics in matching files; it does not prevent those files from being indexed.

Running bundle exec rdx lint explain NoConflictingFooHelpers displays the documentation written above the rule. Rules can also be distributed in gems by placing them under lib/rubydex_linter/rules/.

Editor integration

At the time of writing, editor integration requires the Ruby LSP beta server, which you can enable in VS Code via:

{
  // VS Code settings. Enable the beta version of the server.
  "rubyLsp.featureFlags": {
    "betaServer": true
  }
}

For other editors, pass the betaServer flag through initialization options.

To learn more about writing rules for your project, see our linter guide.

Current scope and limitations

Rubydex does not yet understand every kind of Ruby code. It does not expose local variables or the context around conditions and rescue clauses, for example. It also does not understand what DSL calls such as belongs_to mean.

For some rules, we used Prism to parse the targeted files and collect the missing information. We aim to reduce these cases as we continue to expand the context Rubydex captures.

Conclusion

Rubydex linter is the first Ruby linter designed specifically for checking project-defined structural patterns across the whole codebase. We look forward to discovering and learning from the community about all the things we can use it for.

As One engine, many tools — Introducing Rubydex states: “one engine, many tools.” Rubydex linter is one of the tools we’re experimenting with on top of Rubydex, and we will share more in future posts.