Add core inspector, CLI, and formatter
- 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:
9
lib/whetrails.rb
Normal file
9
lib/whetrails.rb
Normal 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
18
lib/whetrails/chain.rb
Normal 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
91
lib/whetrails/cli.rb
Normal 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
|
||||
67
lib/whetrails/formatter.rb
Normal file
67
lib/whetrails/formatter.rb
Normal 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
125
lib/whetrails/inspector.rb
Normal 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
3
lib/whetrails/version.rb
Normal file
@@ -0,0 +1,3 @@
|
||||
module Whetrails
|
||||
VERSION = "0.1.0"
|
||||
end
|
||||
Reference in New Issue
Block a user