From df3f7e1c75f367f8fecc7cdfec2115b664405d44 Mon Sep 17 00:00:00 2001 From: bill Date: Mon, 8 Jun 2026 10:24:09 -0600 Subject: [PATCH] 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 ` 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 --- app/models/order.rb | 31 +++++ db/migrate/20260608161002_create_orders.rb | 10 ++ db/schema.rb | 20 ++++ exe/whetrails | 6 + lib/whetrails.rb | 9 ++ lib/whetrails/chain.rb | 18 +++ lib/whetrails/cli.rb | 91 +++++++++++++++ lib/whetrails/formatter.rb | 67 +++++++++++ lib/whetrails/inspector.rb | 125 +++++++++++++++++++++ lib/whetrails/version.rb | 3 + whetrails.gemspec | 20 ++++ 11 files changed, 400 insertions(+) create mode 100644 app/models/order.rb create mode 100644 db/migrate/20260608161002_create_orders.rb create mode 100644 db/schema.rb create mode 100755 exe/whetrails create mode 100644 lib/whetrails.rb create mode 100644 lib/whetrails/chain.rb create mode 100644 lib/whetrails/cli.rb create mode 100644 lib/whetrails/formatter.rb create mode 100644 lib/whetrails/inspector.rb create mode 100644 lib/whetrails/version.rb create mode 100644 whetrails.gemspec diff --git a/app/models/order.rb b/app/models/order.rb new file mode 100644 index 0000000..71a5443 --- /dev/null +++ b/app/models/order.rb @@ -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 diff --git a/db/migrate/20260608161002_create_orders.rb b/db/migrate/20260608161002_create_orders.rb new file mode 100644 index 0000000..4ccc9b3 --- /dev/null +++ b/db/migrate/20260608161002_create_orders.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb new file mode 100644 index 0000000..bcbb222 --- /dev/null +++ b/db/schema.rb @@ -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 diff --git a/exe/whetrails b/exe/whetrails new file mode 100755 index 0000000..90609da --- /dev/null +++ b/exe/whetrails @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +$LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) +require "whetrails/cli" + +Whetrails::CLI.run(ARGV) diff --git a/lib/whetrails.rb b/lib/whetrails.rb new file mode 100644 index 0000000..88db6bf --- /dev/null +++ b/lib/whetrails.rb @@ -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 diff --git a/lib/whetrails/chain.rb b/lib/whetrails/chain.rb new file mode 100644 index 0000000..ec3dafa --- /dev/null +++ b/lib/whetrails/chain.rb @@ -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 diff --git a/lib/whetrails/cli.rb b/lib/whetrails/cli.rb new file mode 100644 index 0000000..808858a --- /dev/null +++ b/lib/whetrails/cli.rb @@ -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 " + 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 Show the full chain for a model + whetrails version Print version + USAGE + end + end +end diff --git a/lib/whetrails/formatter.rb b/lib/whetrails/formatter.rb new file mode 100644 index 0000000..b156a23 --- /dev/null +++ b/lib/whetrails/formatter.rb @@ -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}" : "" }.join(", ")}" if conditions[:if] + parts << "unless: #{conditions[:unless].map { |c| c.is_a?(Symbol) ? ":#{c}" : "" }.join(", ")}" if conditions[:unless] + " (#{parts.join(" ")})" + end + end +end diff --git a/lib/whetrails/inspector.rb b/lib/whetrails/inspector.rb new file mode 100644 index 0000000..1e098df --- /dev/null +++ b/lib/whetrails/inspector.rb @@ -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 : "" + 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 diff --git a/lib/whetrails/version.rb b/lib/whetrails/version.rb new file mode 100644 index 0000000..d9e09cb --- /dev/null +++ b/lib/whetrails/version.rb @@ -0,0 +1,3 @@ +module Whetrails + VERSION = "0.1.0" +end diff --git a/whetrails.gemspec b/whetrails.gemspec new file mode 100644 index 0000000..776c054 --- /dev/null +++ b/whetrails.gemspec @@ -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