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:
31
app/models/order.rb
Normal file
31
app/models/order.rb
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
class Order < ApplicationRecord
|
||||||
|
validates :status, presence: true
|
||||||
|
validates :total, numericality: { greater_than: 0 }, if: :paid?
|
||||||
|
|
||||||
|
before_validation :normalize_status
|
||||||
|
before_save :calculate_total
|
||||||
|
after_create :send_confirmation
|
||||||
|
after_commit :notify_warehouse
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_status
|
||||||
|
self.status = status&.strip&.downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_total
|
||||||
|
# placeholder
|
||||||
|
end
|
||||||
|
|
||||||
|
def send_confirmation
|
||||||
|
# placeholder
|
||||||
|
end
|
||||||
|
|
||||||
|
def notify_warehouse
|
||||||
|
# placeholder
|
||||||
|
end
|
||||||
|
|
||||||
|
def paid?
|
||||||
|
status == "paid"
|
||||||
|
end
|
||||||
|
end
|
||||||
10
db/migrate/20260608161002_create_orders.rb
Normal file
10
db/migrate/20260608161002_create_orders.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
class CreateOrders < ActiveRecord::Migration[8.1]
|
||||||
|
def change
|
||||||
|
create_table :orders do |t|
|
||||||
|
t.string :status
|
||||||
|
t.decimal :total
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
20
db/schema.rb
generated
Normal file
20
db/schema.rb
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# This file is auto-generated from the current state of the database. Instead
|
||||||
|
# of editing this file, please use the migrations feature of Active Record to
|
||||||
|
# incrementally modify your database, and then regenerate this schema definition.
|
||||||
|
#
|
||||||
|
# This file is the source Rails uses to define your schema when running `bin/rails
|
||||||
|
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
|
||||||
|
# be faster and is potentially less error prone than running all of your
|
||||||
|
# migrations from scratch. Old migrations may fail to apply correctly if those
|
||||||
|
# migrations use external dependencies or application code.
|
||||||
|
#
|
||||||
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
|
ActiveRecord::Schema[8.1].define(version: 2026_06_08_161002) do
|
||||||
|
create_table "orders", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.string "status"
|
||||||
|
t.decimal "total"
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
end
|
||||||
|
end
|
||||||
6
exe/whetrails
Executable file
6
exe/whetrails
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/usr/bin/env ruby
|
||||||
|
|
||||||
|
$LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
|
||||||
|
require "whetrails/cli"
|
||||||
|
|
||||||
|
Whetrails::CLI.run(ARGV)
|
||||||
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
|
||||||
20
whetrails.gemspec
Normal file
20
whetrails.gemspec
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
require_relative "lib/whetrails/version"
|
||||||
|
|
||||||
|
Gem::Specification.new do |spec|
|
||||||
|
spec.name = "whetrails"
|
||||||
|
spec.version = Whetrails::VERSION
|
||||||
|
spec.authors = ["Bill Holcombe"]
|
||||||
|
spec.email = ["billholcombe30@gmail.com"]
|
||||||
|
spec.summary = "Makes ActiveRecord callback and validation chains visible"
|
||||||
|
spec.description = "Inspects Rails models and surfaces the full ordered callback and validation chain, including source locations and conditions."
|
||||||
|
spec.homepage = "https://github.com/whetforge/whetrails"
|
||||||
|
spec.license = "MIT"
|
||||||
|
|
||||||
|
spec.required_ruby_version = ">= 3.0"
|
||||||
|
|
||||||
|
spec.files = Dir["lib/**/*.rb", "exe/*", "LICENSE", "README.md"]
|
||||||
|
spec.executables = ["whetrails"]
|
||||||
|
|
||||||
|
spec.add_dependency "railties", ">= 7.0"
|
||||||
|
spec.add_dependency "activerecord", ">= 7.0"
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user