| name | expand-rubyzen |
| description | Add a new code concept to Rubyzen — a new Declaration, Provider, and Collection as a full vertical slice. Use this skill whenever the user wants to add support for a new Ruby language construct (e.g., case statements, loops, yield calls, begin/end blocks, lambda expressions), add a new API to the library, or extend Rubyzen's analysis capabilities. Also use when the user says "add support for X" or "I want to lint on X" where X is a code structure not yet modeled. |
Expanding Rubyzen with a New Code Concept
You are adding a new code concept to Rubyzen — a Ruby architectural linter that wraps RuboCop AST with a high-level API. Every new concept follows a vertical slice: Declaration → Provider → Collection → Tests → Wiring.
Step 0: Understand the Architecture First
Before writing any code, read these files to understand current patterns:
CLAUDE.md — full architecture guide
- An existing declaration similar to what you're adding (e.g.,
lib/rubyzen/declarations/call_site_declaration.rb)
- Its corresponding provider (e.g.,
lib/rubyzen/providers/call_site_provider.rb)
- Its corresponding collection (e.g.,
lib/rubyzen/collections/call_site_collection.rb)
- Its unit test (e.g.,
spec/declarations/call_site_declaration_spec.rb)
This gives you the exact patterns to follow. Do not invent new patterns.
Step 1: Identify the AST Node Type
Use RuboCop AST to determine which node type(s) represent the concept. You can test interactively:
source = "your ruby code here"
processed = RuboCop::AST::ProcessedSource.new(source, RUBY_VERSION.to_f)
processed.ast
Common node types: :send, :block, :if, :case, :while, :for, :yield, :return, :const, :casgn, :def, :defs, :class, :module, :resbody, :kwbegin.
Step 2: Create the Declaration
Create lib/rubyzen/declarations/<concept>_declaration.rb.
Mandatory structure:
module Rubyzen
module Declarations
class <Concept>Declaration
include Rubyzen::Providers::FilePathProvider
include Rubyzen::Providers::LineNumberProvider
include Rubyzen::Providers::ClassNameProvider
attr_reader :node
attr_reader :parent
def initialize(node, parent)
@node = node
@parent = parent
end
def name
end
end
end
end
Rules:
node and parent are always attr_reader — providers depend on them
FilePathProvider, LineNumberProvider, ClassNameProvider are required — the RSpec matchers and Minitest assertions use file_path, line, and class_name (via the shared ExpectationHelpers) for failure messages
- A
name method is required — both frameworks call it via element_name
- Zeitwerk autoloads — no
require statements needed (exception: if the file is loaded before Zeitwerk, add explicit require_relative)
Step 3: Create the Provider
Create lib/rubyzen/providers/<concepts>_provider.rb (plural name).
module Rubyzen
module Providers
module <Concepts>Provider
def <concepts>
matching_nodes = node.each_descendant(:<node_type>).select do |n|
true
end
declarations = matching_nodes.map do |n|
Declarations::<Concept>Declaration.new(n, self)
end
Collections::<Concepts>Collection.new(declarations)
end
end
end
end
Rules:
- The method name is plural (e.g.,
yields, case_statements, loops)
- Always returns a typed Collection, never a raw Array
- Uses
node.each_descendant to find relevant AST nodes
- Creates declarations with
self as parent so FilePathProvider can traverse upward
Step 4: Create the Collection
Create lib/rubyzen/collections/<concepts>_collection.rb.
module Rubyzen
module Collections
class <Concepts>Collection < BaseCollection
include Rubyzen::Providers::CollectionFilterProvider
def with_some_filter(some_value)
filter { |decl| decl.some_method == some_value }
end
end
end
end
Rules:
- Always extend
BaseCollection
- Always include
CollectionFilterProvider (provides with_name, without_name, etc.)
- Filter methods use
filter { }, never select or reject (they are undefined on BaseCollection)
- Filter methods return the same collection type (this happens automatically via
filter)
Step 5: Integrate
Include the provider in the declarations that should expose this concept:
include Rubyzen::Providers::<Concepts>Provider
Where to include it — think about where this concept appears in Ruby code:
- In method bodies → include in
MethodDeclaration
- In class bodies → include in
ClassDeclaration
- In blocks → include in
BlockDeclaration
- At file level → include in
FileDeclaration
- Multiple places → include in all relevant declarations
Add bridge methods to collections that should aggregate the data:
def <concepts>
<Concepts>Collection.new(flat_map(&:<concepts>))
end
Add bridge methods to every collection whose elements include the provider. For example:
- If
MethodDeclaration includes the provider → add bridge to MethodsCollection
- If
ClassDeclaration includes the provider → add bridge to ClassesCollection
- Users chain upward naturally:
classes.all_methods.<concepts> works without a bridge on ClassesCollection because the bridge is on MethodsCollection
Only add a bridge to a collection if its elements directly have the provider. Don't add redundant bridges that just delegate through another bridge.
Step 6: Write Tests
Declaration spec (spec/declarations/<concept>_declaration_spec.rb)
require 'spec_helper'
RSpec.describe Rubyzen::Declarations::<Concept>Declaration do
it 'returns the name' do
file = parse_ruby(<<~RUBY)
class Foo
def bar
# ruby code that contains the concept
end
end
RUBY
decl = file.classes.first.instance_methods.first.<concepts>.first
expect(decl.name).to eq('expected_name')
end
it 'returns the file path' do
file = parse_ruby(<<~RUBY, file_path: 'app/models/user.rb')
class User
def foo
# concept here
end
end
RUBY
decl = file.classes.first.instance_methods.first.<concepts>.first
expect(decl.file_path).to eq('app/models/user.rb')
end
end
Collection spec (spec/collections/<concepts>_collection_spec.rb)
require 'spec_helper'
RSpec.describe Rubyzen::Collections::<Concepts>Collection do
it 'filters by name' do
expect(collection.with_name('target')).to contain_exactly(...)
end
it 'filters with custom filter' do
end
it 'returns the same collection type from filter' do
expect(result).to be_a(described_class)
end
end
Critical testing gotcha
Single-statement AST root: When a Ruby snippet has only one statement, the AST root IS that statement. each_descendant won't find it because it only searches children, not the root itself. Always include at least two statements:
file = parse_ruby('require "json"')
file.requires
file = parse_ruby("require 'json'\nx = 1")
file.requires
This affects any provider that uses each_descendant on file-level nodes.
Step 7: Update Documentation
- Add the new declaration to the Declaration Reference table in
CLAUDE.md
- Add the new collection to the Data Flow tree in
CLAUDE.md
- Run
bundle exec rake to verify all tests pass (RSpec spec/ + Minitest test/)
Checklist
Before considering the work complete, verify: