Add core inspector, CLI, and formatter
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / scan_js (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled
CI / system-test (push) Has been cancelled

- Inspector extracts callbacks and validators from any AR model,
  resolves source locations, and filters Rails internals
- CLI: `whetrails inspect <ModelName>` boots Rails and renders chain
- Formatter produces clean lifecycle-ordered terminal output
- Order model added as development fixture

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 10:24:09 -06:00
parent 551867d2b2
commit df3f7e1c75
11 changed files with 400 additions and 0 deletions

9
lib/whetrails.rb Normal file
View File

@@ -0,0 +1,9 @@
require_relative "whetrails/version"
require_relative "whetrails/chain"
require_relative "whetrails/inspector"
module Whetrails
def self.inspect_model(model_class)
Inspector.inspect_model(model_class)
end
end

18
lib/whetrails/chain.rb Normal file
View File

@@ -0,0 +1,18 @@
module Whetrails
CallbackEntry = Data.define(:event, :kind, :filter, :conditions, :source_file, :source_line, :source_module)
ValidatorEntry = Data.define(:type, :attributes, :conditions, :source_file, :source_line)
class ModelChain
attr_reader :model_name, :callbacks, :validators
def initialize(model_name:, callbacks:, validators:)
@model_name = model_name
@callbacks = callbacks
@validators = validators
end
def callbacks_for(event)
callbacks.select { |c| c.event == event }
end
end
end

91
lib/whetrails/cli.rb Normal file
View File

@@ -0,0 +1,91 @@
require_relative "inspector"
require_relative "formatter"
module Whetrails
class CLI
def self.run(argv)
new(argv).run
end
def initialize(argv)
@argv = argv
end
def run
command = @argv.shift
case command
when "inspect"
run_inspect
when "version", "--version", "-v"
require_relative "version"
puts Whetrails::VERSION
else
puts usage
exit 1
end
end
private
def run_inspect
model_name = @argv.shift
if model_name.nil?
$stderr.puts "Usage: whetrails inspect <ModelName>"
exit 1
end
boot_rails
model_class = resolve_model(model_name)
chain = Inspector.inspect_model(model_class)
puts Formatter.render(chain)
end
def resolve_model(name)
Object.const_get(name)
rescue NameError
# Try eager loading if autoload hasn't picked it up yet
Rails.application.eager_load! if defined?(Rails)
Object.const_get(name)
rescue NameError
$stderr.puts "Error: could not find model '#{name}'. Is this a Rails app directory?"
exit 1
end
def boot_rails
rails_root = find_rails_root
unless rails_root
$stderr.puts "Error: not inside a Rails app (no config/application.rb found)"
exit 1
end
require File.join(rails_root, "config", "application")
Rails.application.initialize! unless Rails.application.initialized?
rescue => e
$stderr.puts "Error booting Rails: #{e.message}"
exit 1
end
def find_rails_root
dir = Dir.pwd
loop do
return dir if File.exist?(File.join(dir, "config", "application.rb"))
parent = File.dirname(dir)
return nil if parent == dir
dir = parent
end
end
def usage
<<~USAGE
Whetrails ActiveRecord callback & validation chain inspector
Usage:
whetrails inspect <ModelName> Show the full chain for a model
whetrails version Print version
USAGE
end
end
end

View File

@@ -0,0 +1,67 @@
module Whetrails
class Formatter
LINE = ("" * 60).freeze
def self.render(chain)
new(chain).render
end
def initialize(chain)
@chain = chain
end
def render
lines = []
lines << ""
lines << " #{@chain.model_name}"
lines << " #{LINE}"
lines << ""
if @chain.callbacks.any?
lines << " CALLBACKS"
@chain.callbacks.each do |cb|
lines << format_callback(cb)
end
lines << ""
end
if @chain.validators.any?
lines << " VALIDATORS"
@chain.validators.each do |v|
lines << format_validator(v)
end
lines << ""
end
lines.join("\n")
end
private
def format_callback(cb)
event = cb.event.to_s.ljust(20)
filter = ":#{cb.filter}".ljust(28)
src = cb.source_file ? "#{File.basename(cb.source_file)}:#{cb.source_line}" : ""
cond = format_conditions(cb.conditions)
mod = cb.source_module && cb.source_module != @chain.model_name ? " [#{cb.source_module}]" : ""
" #{event} #{filter} #{src}#{mod}#{cond}"
end
def format_validator(v)
type = v.type.split("::").last.sub("Validator", "").downcase.ljust(20)
attrs = v.attributes.map(&:inspect).join(", ").ljust(28)
cond = format_conditions(v.conditions)
" #{type} #{attrs}#{cond}"
end
def format_conditions(conditions)
return "" if conditions.empty?
parts = []
parts << "if: #{conditions[:if].map { |c| c.is_a?(Symbol) ? ":#{c}" : "<proc>" }.join(", ")}" if conditions[:if]
parts << "unless: #{conditions[:unless].map { |c| c.is_a?(Symbol) ? ":#{c}" : "<proc>" }.join(", ")}" if conditions[:unless]
" (#{parts.join(" ")})"
end
end
end

125
lib/whetrails/inspector.rb Normal file
View File

@@ -0,0 +1,125 @@
require_relative "chain"
module Whetrails
class Inspector
LIFECYCLE = [
{ event: :before_validation, chain: :_validation_callbacks, kind: :before },
{ event: :after_validation, chain: :_validation_callbacks, kind: :after },
{ event: :before_save, chain: :_save_callbacks, kind: :before },
{ event: :before_create, chain: :_create_callbacks, kind: :before },
{ event: :before_update, chain: :_update_callbacks, kind: :before },
{ event: :after_create, chain: :_create_callbacks, kind: :after },
{ event: :after_update, chain: :_update_callbacks, kind: :after },
{ event: :after_save, chain: :_save_callbacks, kind: :after },
{ event: :after_commit, chain: :_commit_callbacks, kind: :after },
{ event: :after_rollback, chain: :_rollback_callbacks, kind: :after },
{ event: :before_destroy, chain: :_destroy_callbacks, kind: :before },
{ event: :after_destroy, chain: :_destroy_callbacks, kind: :after },
].freeze
def self.inspect_model(model_class)
new(model_class).call
end
def initialize(model_class)
@model = model_class
end
def call
@model.load_schema if @model.respond_to?(:load_schema)
ModelChain.new(
model_name: @model.name,
callbacks: extract_callbacks,
validators: extract_validators
)
end
private
def extract_callbacks
entries = []
LIFECYCLE.each do |step|
chain = @model.public_send(step[:chain]) rescue next
chain.each do |cb|
next unless cb.kind == step[:kind]
next unless cb.filter.is_a?(Symbol) || cb.filter.is_a?(Proc)
next if cb.filter.is_a?(Symbol) && cb.filter.to_s.start_with?("_")
next if rails_internal_callback?(cb)
file, line = source_location_for(cb)
source_mod = source_module_for(cb)
entries << CallbackEntry.new(
event: step[:event],
kind: step[:kind],
filter: filter_name(cb),
conditions: extract_conditions(cb),
source_file: file,
source_line: line,
source_module: source_mod
)
end
end
entries
end
def extract_validators
@model.validators.map do |v|
ValidatorEntry.new(
type: v.class.name,
attributes: v.attributes,
conditions: extract_validator_conditions(v),
source_file: nil,
source_line: nil
)
end
end
def filter_name(cb)
cb.filter.is_a?(Symbol) ? cb.filter : "<proc>"
end
def source_location_for(cb)
return cb.filter.source_location if cb.filter.is_a?(Proc)
method = @model.instance_method(cb.filter) rescue nil
method&.source_location
end
def source_module_for(cb)
return nil unless cb.filter.is_a?(Symbol)
@model.instance_method(cb.filter).owner.name rescue nil
end
def rails_internal_callback?(cb)
return false unless cb.filter.is_a?(Symbol)
file, = source_location_for(cb)
return false unless file
file.include?("/gems/activerecord-") || file.include?("/gems/activemodel-")
end
def extract_conditions(cb)
conditions = {}
ifs = Array(cb.instance_variable_get(:@if)).reject { |c| rails_internal_condition?(c) }
unlesses = Array(cb.instance_variable_get(:@unless)).reject { |c| rails_internal_condition?(c) }
conditions[:if] = ifs unless ifs.empty?
conditions[:unless] = unlesses unless unlesses.empty?
conditions
end
def rails_internal_condition?(condition)
condition.class.name&.start_with?("ActiveSupport::Callbacks::Conditionals")
end
def extract_validator_conditions(v)
conditions = {}
if_cond = Array(v.options[:if])
unless_cond = Array(v.options[:unless])
conditions[:if] = if_cond unless if_cond.empty?
conditions[:unless] = unless_cond unless unless_cond.empty?
conditions
end
end
end

3
lib/whetrails/version.rb Normal file
View File

@@ -0,0 +1,3 @@
module Whetrails
VERSION = "0.1.0"
end