| name | write-lint-rule |
| description | Write an architectural lint rule for a Ruby project using the Rubyzen API. Use this skill when the user wants to create a new lint rule, enforce an architectural constraint, add a code quality check, or write an RSpec or Minitest test that validates code structure. Also trigger when the user says things like "controllers shouldn't do X", "models must always Y", "enforce that Z", or "add a rule for". |
Writing a Lint Rule with Rubyzen
You are writing an architectural lint rule as an RSpec test using Rubyzen's high-level API. Lint rules enforce structural constraints on a Ruby codebase without touching raw AST.
Step 0: Understand the Available API
Read CLAUDE.md to understand the full API surface. Pay special attention to:
- The Data Flow tree (what collections are available and how they chain)
- The Declaration Reference table (what methods each declaration exposes)
- The Matchers section (what assertions are available)
Also read sample_project/spec/spec_helper.rb to see how collections are set up.
Step 1: Translate the Rule to API Calls
Every lint rule follows this mental model:
SCOPE → FILTER → EXTRACT → ASSERT
- SCOPE: Start from
project.files and narrow to the right files
- FILTER: Chain collection methods to isolate the code you care about
- EXTRACT: Get the specific declarations to check
- ASSERT: Use a matcher to enforce the constraint
Examples of translating English rules to API calls:
| English Rule | Rubyzen API |
|---|
"Controllers must not call .where" | controllers.all_methods.call_sites.with_name('where') → zen_empty |
"Models must not define methods ending with ?" | models.all_methods.with_name_ending_with('?') → zen_empty |
| "All service classes must inherit from BaseService" | services → zen_true { |k| k.superclass_name == 'BaseService' } |
| "Presenters must not use repository classes" | presenters.all_methods.call_sites.with_receiver('UserRepo') → zen_empty |
| "No class should have more than 200 lines" | all_classes → zen_true { |k| k.lines_of_code <= 200 } |
| "Every public method must have at most 3 parameters" | methods.filter(&:public?) → zen_true { |m| m.parameters.size <= 3 } |
Step 2: Set Up the Shared Context
Lint rules use a shared context in spec_helper.rb that provides pre-built collections. Read the existing spec_helper.rb in the target project's spec directory. If it doesn't exist, create one:
require 'rubyzen/rspec'
RSpec.configure do |config|
RSpec.shared_context 'project_config' do
let(:project) { Rubyzen::Project.new }
let(:files) { project.files.with_paths('src/') }
let(:controllers) { project.files.with_paths('src/controllers/').classes }
let(:models) { project.files.with_paths('src/models/').classes }
let(:services) { project.files.with_paths('src/services/').classes }
let(:repos) { project.files.with_paths('src/repos/').without_paths('/spec/').classes }
let(:test_files) { project.files.with_paths('spec/') }
end
config.include_context 'project_config'
end
Step 3: Write the Lint Rule
Create a spec file in the appropriate directory. If the shared context is auto-included via config.include_context, you don't need to include it manually:
require_relative '../spec_helper'
RSpec.describe 'Rule: Controllers must not call ActiveRecord directly' do
it 'controllers do not use .where' do
violations = controllers
.all_methods
.call_sites
.with_name('where')
expect(violations).to zen_empty
end
it 'controllers do not use .find_by' do
violations = controllers
.all_methods
.call_sites
.with_name('find_by')
expect(violations).to zen_empty
end
end
Available Matchers
zen_empty
Asserts the collection has no elements. Use for "must not" / "should never" rules. Supports allowlist: and baseline: for gradual adoption.
expect(violations).to zen_empty
expect(violations).to zen_empty("Custom failure message explaining the rule")
expect(violations).to zen_empty(allowlist: ['LegacyController'])
expect(violations).to zen_empty(baseline: ['OldController', 'AncientController'])
Entries match against name, class_name, file_path, or file_path:line. Stale entries (listed but no longer violating) cause test failure — this prevents the baseline from going stale.
zen_true { |item| ... }
Asserts the block returns true for ALL elements. Use for "must always" / "every X should" rules. Supports allowlist: and baseline: for items that fail the check.
expect(services).to zen_true { |klass| klass.superclass_name == 'BaseService' }
expect(services).to zen_true(baseline: ['LegacyService']) { |klass| klass.superclass_name == 'BaseService' }
IMPORTANT: Use { } braces, NOT do...end. Due to Ruby operator precedence, do...end binds to expect() instead of the matcher, causing a silent bug.
zen_false { |item| ... }
Asserts the block returns false for ALL elements. Inverse of zen_true. Supports allowlist: and baseline: for items that fail the check.
expect(models).to zen_false { |m| m.name.end_with?('Helper') }
expect(models).to zen_false(baseline: ['OldHelper']) { |m| m.name.end_with?('Helper') }
Minitest variant
Rubyzen includes a Minitest adapter alongside the RSpec matchers. The SCOPE → FILTER → EXTRACT → ASSERT model is identical — only the assertion call changes. The assertions mirror the matchers one-to-one, including the same allowlist: / baseline: support and the same rich failure messages.
Shared context — define a test/test_helper.rb that requires the Minitest adapter and provides a base class with pre-built collections (the Minitest counterpart of the RSpec shared context):
require 'rubyzen/minitest'
require 'minitest/autorun'
class LintTestCase < Minitest::Test
def project = @project ||= Rubyzen::Project.new
def controllers = @controllers ||= project.files.with_paths('src/controllers/').classes
def models = @models ||= project.files.with_paths('src/models/').classes
def services = @services ||= project.files.with_paths('src/services/').classes
end
Assertion equivalents:
| RSpec matcher | Minitest assertion |
|---|
expect(c).to zen_empty | assert_zen_empty(c) |
expect(c).to zen_empty("msg") | assert_zen_empty(c, message: "msg") |
expect(c).to zen_empty(allowlist: [...], baseline: [...]) | assert_zen_empty(c, allowlist: [...], baseline: [...]) |
expect(c).to zen_true { |x| ... } | assert_zen_true(c) { |x| ... } |
expect(c).to zen_false { |x| ... } | assert_zen_false(c) { |x| ... } |
Note the custom message is a positional arg in RSpec but the message: keyword in Minitest.
Example:
require 'test_helper'
class ControllersTest < LintTestCase
def test_controllers_do_not_use_where
assert_zen_empty(controllers.all_methods.call_sites.with_name('where'))
end
def test_controllers_inherit_from_application_controller
assert_zen_true(controllers) { |c| c.superclass_name == 'ApplicationController' }
end
end
The { }-vs-do...end precedence caveat does not apply here (the block binds to the assertion method call either way), but { } is still recommended for consistency.
Common API Patterns
Filtering by file path
files.with_paths('app/controllers/')
files.without_paths('/spec/', '/test/')
Filtering by name
classes.with_name('UsersController')
classes.with_name_ending_with('Controller')
classes.with_name_starting_with('Admin')
classes.with_name_including('Service')
classes.without_name('BaseController')
Traversing the hierarchy
classes.all_methods
classes.all_methods.call_sites
classes.all_methods.parameters
classes.attributes
classes.macros
classes.macros.with_name('belongs_to')
Call site analysis
call_sites.with_receiver('User')
call_sites.with_name('where')
call_sites.with_symbol(:admin)
call_sites.with_keyword_arg(:foreign_key)
Method properties
methods.filter(&:private?)
methods.filter(&:public?)
method.lines_of_code
method.parameters
method.call_sites
method.if_statements
method.rescues
method.raises
Class properties
klass.superclass_name
klass.superclass_prefix?('Base')
klass.top_level_module
klass.instance_methods
klass.class_methods
Combining collections
(jobs + services).all_methods.call_sites
(controllers + presenters).all_methods
Custom filtering with filter
For complex rules that can't be expressed with built-in filters, use filter with a block:
deliver_calls = call_sites
.with_receiver('Relay')
.with_method_name('deliver')
violations = deliver_calls.filter do |site|
site.keyword_args.include?(:request_guid) &&
!site.keyword_args.include?(:remote_addr)
end
expect(violations).to zen_empty
Block analysis
file.blocks
file.blocks.with_method_name('describe')
block.call_sites
Failure Messages
Rubyzen matchers automatically format failure messages with:
- Element name (method name, class name, etc.)
- Class name (parent class)
- File path and line number
This makes violations immediately actionable — developers see exactly where the violation is.
Checklist