Compare commits

..

2 Commits

Author SHA1 Message Date
ab1f29269e Update handoff docs for completed engine layer
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
Mark engine as done in overview and build order. Update next-steps to
focus on test gap analysis. Add engine file map, data flow, and dev
setup quirks (Zeitwerk conflict, engine routes conflict) to architecture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:14:19 -06:00
286db51fcd Add Rails engine with browser UI at /whetrails
Mounts a Rails engine that lists app models in a sidebar and renders
the full callback/validator chain for any model. Source locations are
linked via vscode:// URIs. Condition badges and concern module tags
distinguish at-a-glance where each callback comes from.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 20:12:24 -06:00
17 changed files with 470 additions and 60 deletions

View File

@@ -1,5 +1,7 @@
source "https://rubygems.org" source "https://rubygems.org"
gem "whetrails", path: "."
# Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main" # Bundle edge Rails instead: gem "rails", github: "rails/rails", branch: "main"
gem "rails", "~> 8.1.3" gem "rails", "~> 8.1.3"
# The modern asset pipeline for Rails [https://github.com/rails/propshaft] # The modern asset pipeline for Rails [https://github.com/rails/propshaft]

View File

@@ -1,3 +1,10 @@
PATH
remote: .
specs:
whetrails (0.1.0)
activerecord (>= 7.0)
railties (>= 7.0)
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
@@ -420,6 +427,7 @@ DEPENDENCIES
turbo-rails turbo-rails
tzinfo-data tzinfo-data
web-console web-console
whetrails!
BUNDLED WITH BUNDLED WITH
2.5.22 2.5.22

View File

@@ -0,0 +1,23 @@
module Whetrails
class ApplicationController < ActionController::Base
layout "whetrails/application"
protect_from_forgery with: :exception
before_action :load_all_models
private
def load_all_models
::Rails.application.eager_load!
app_root = ::Rails.root.to_s
@all_models = ::ActiveRecord::Base.descendants
.reject(&:abstract_class?)
.reject { |m| m.name.nil? }
.select { |m|
file = Object.const_source_location(m.name)&.first rescue nil
file&.start_with?(app_root)
}
.sort_by(&:name)
end
end
end

View File

@@ -0,0 +1,16 @@
module Whetrails
class ModelsController < ApplicationController
def index
if @all_models.any?
redirect_to model_path(@all_models.first.name)
end
end
def show
@model_class = params[:id].constantize
@chain = ::Whetrails::Inspector.inspect_model(@model_class)
rescue NameError
head :not_found
end
end
end

View File

@@ -0,0 +1,20 @@
module Whetrails
module ModelsHelper
def vscode_uri(file, line)
"vscode://file/#{file}:#{line}" if file
end
def short_path(file)
return nil unless file
file.sub("#{::Rails.root}/", "")
end
def condition_label(c)
c.is_a?(Symbol) ? ":#{c}" : "&lt;proc&gt;"
end
def validator_type_label(type)
type.split("::").last.sub("Validator", "").downcase
end
end
end

View File

@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html>
<head>
<title>Whetrails</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
background: #f5f5f7;
color: #1a1a2e;
}
.layout { display: flex; min-height: 100vh; }
/* Sidebar */
.sidebar {
width: 220px;
background: #1a1a2e;
color: #c8c8e0;
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: 18px 16px 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #fff;
border-bottom: 1px solid rgba(255,255,255,0.08);
}
.sidebar-section {
padding: 10px 16px 4px;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #5555aa;
}
.sidebar-model {
display: block;
padding: 6px 16px;
color: #a0a0cc;
text-decoration: none;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
border-left: 3px solid transparent;
}
.sidebar-model:hover { background: rgba(255,255,255,0.06); color: #fff; }
.sidebar-model.active {
background: rgba(100,100,220,0.15);
color: #fff;
border-left-color: #6666dd;
}
.sidebar-empty {
padding: 8px 16px;
color: #555577;
font-size: 12px;
font-style: italic;
}
/* Main */
.main { flex: 1; padding: 36px 40px; overflow: auto; }
h1.model-title {
font-size: 24px;
font-weight: 700;
font-family: "SF Mono", "Fira Code", monospace;
margin-bottom: 6px;
color: #1a1a2e;
}
.divider {
border: none;
border-top: 2px solid #e0e0e8;
margin: 16px 0 24px;
}
.section-heading {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: #8888aa;
margin-bottom: 12px;
}
/* Callback chain */
.stage { margin-bottom: 20px; }
.stage-label {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
font-weight: 700;
color: #4444aa;
padding: 4px 0;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.stage-label::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #4444aa;
flex-shrink: 0;
}
.stage-label.stage-destroy { color: #aa3333; }
.stage-label.stage-destroy::before { background: #aa3333; }
.stage-label.stage-commit { color: #226622; }
.stage-label.stage-commit::before { background: #226622; }
.cb-list {
border-left: 2px solid #e0e0e8;
margin-left: 3px;
padding-left: 0;
}
.cb-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
padding: 5px 12px;
border-bottom: 1px solid #f0f0f4;
}
.cb-row:last-child { border-bottom: none; }
.cb-filter {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
min-width: 180px;
}
.cb-filter.is-proc { color: #888; font-style: italic; font-weight: 400; }
.cb-src {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 11px;
color: #999;
}
.cb-src a { color: #5555cc; text-decoration: none; }
.cb-src a:hover { text-decoration: underline; }
.badges { display: flex; gap: 4px; flex-wrap: wrap; }
.badge {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 11px;
padding: 1px 7px;
border-radius: 4px;
}
.badge-cond-if { background: #fff8e1; color: #7a5c00; border: 1px solid #ffe082; }
.badge-cond-unless { background: #fce4ec; color: #8b1a2f; border: 1px solid #f48fb1; }
.badge-module { background: #e8f4fd; color: #1a5276; border: 1px solid #aed6f1; }
/* Validators */
.validator-list { margin-bottom: 24px; }
.v-row {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
padding: 5px 0;
border-bottom: 1px solid #f0f0f4;
}
.v-row:last-child { border-bottom: none; }
.v-type {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 13px;
font-weight: 600;
color: #1a1a2e;
min-width: 140px;
}
.v-attrs {
font-family: "SF Mono", "Fira Code", monospace;
font-size: 12px;
color: #555;
min-width: 160px;
}
.empty-state { color: #aaa; font-style: italic; padding: 8px 0; font-size: 13px; }
</style>
</head>
<body>
<div class="layout">
<aside class="sidebar">
<div class="sidebar-brand">Whetrails</div>
<% if @all_models.any? %>
<div class="sidebar-section">Models</div>
<% @all_models.each do |m| %>
<%= link_to m.name, model_path(m.name),
class: "sidebar-model #{'active' if defined?(@model_class) && @model_class == m}" %>
<% end %>
<% else %>
<div class="sidebar-empty">No models found</div>
<% end %>
</aside>
<main class="main">
<%= yield %>
</main>
</div>
</body>
</html>

View File

@@ -0,0 +1,5 @@
<% if @all_models.empty? %>
<div class="empty-state">
No ActiveRecord models found. Make sure your app is loaded.
</div>
<% end %>

View File

@@ -0,0 +1,76 @@
<h1 class="model-title"><%= @chain.model_name %></h1>
<hr class="divider">
<% if @chain.callbacks.any? %>
<div class="section-heading">Lifecycle Callbacks</div>
<% @chain.callbacks.group_by(&:event).each do |event, callbacks| %>
<div class="stage">
<%
stage_class = case event.to_s
when /destroy/ then "stage-destroy"
when /commit|rollback/ then "stage-commit"
else ""
end
%>
<div class="stage-label <%= stage_class %>"><%= event %></div>
<div class="cb-list">
<% callbacks.each do |cb| %>
<div class="cb-row">
<span class="cb-filter <%= 'is-proc' if cb.filter == '<proc>' %>">:<%= cb.filter %></span>
<% if cb.source_file %>
<span class="cb-src">
<% uri = vscode_uri(cb.source_file, cb.source_line) %>
<% label = "#{short_path(cb.source_file)}:#{cb.source_line}" %>
<% if uri %>
<%= link_to label, uri %>
<% else %>
<%= label %>
<% end %>
</span>
<% end %>
<span class="badges">
<% if cb.source_module && cb.source_module != @chain.model_name %>
<span class="badge badge-module"><%= cb.source_module %></span>
<% end %>
<% Array(cb.conditions[:if]).each do |c| %>
<span class="badge badge-cond-if">if: <%= condition_label(c) %></span>
<% end %>
<% Array(cb.conditions[:unless]).each do |c| %>
<span class="badge badge-cond-unless">unless: <%= condition_label(c) %></span>
<% end %>
</span>
</div>
<% end %>
</div>
</div>
<% end %>
<% else %>
<p class="empty-state">No callbacks registered.</p>
<% end %>
<% if @chain.validators.any? %>
<div class="section-heading" style="margin-top: 32px;">Validators</div>
<div class="validator-list">
<% @chain.validators.each do |v| %>
<div class="v-row">
<span class="v-type"><%= validator_type_label(v.type) %></span>
<span class="v-attrs"><%= v.attributes.map { |a| ":#{a}" }.join(", ") %></span>
<span class="badges">
<% Array(v.conditions[:if]).each do |c| %>
<span class="badge badge-cond-if">if: <%= condition_label(c) %></span>
<% end %>
<% Array(v.conditions[:unless]).each do |c| %>
<span class="badge badge-cond-unless">unless: <%= condition_label(c) %></span>
<% end %>
</span>
</div>
<% end %>
</div>
<% else %>
<p class="empty-state">No validators registered.</p>
<% end %>

View File

@@ -14,7 +14,8 @@ module Whetrails
# Please, add to the `ignore` list any other `lib` subdirectories that do # Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded. # not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example. # Common ones are `templates`, `generators`, or `middleware`, for example.
config.autoload_lib(ignore: %w[assets tasks]) # lib/whetrails/**/* uses manual requires (gem source), not Zeitwerk autoloading
config.autoload_lib(ignore: %w[assets tasks whetrails.rb whetrails])
# Configuration for the application, engines, and railties goes here. # Configuration for the application, engines, and railties goes here.
# #

View File

@@ -1,4 +1,6 @@
Rails.application.routes.draw do Rails.application.routes.draw do
mount Whetrails::Engine, at: "/whetrails"
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.

View File

@@ -4,13 +4,28 @@
``` ```
lib/ lib/
whetrails.rb Public entry point. Exposes Whetrails.inspect_model(ModelClass) whetrails.rb Public entry point. Requires engine if Rails is loaded.
whetrails/ whetrails/
version.rb VERSION constant (0.1.0) version.rb VERSION constant (0.1.0)
chain.rb Data structures (ModelChain, CallbackEntry, ValidatorEntry) chain.rb Data structures (ModelChain, CallbackEntry, ValidatorEntry)
inspector.rb Core engine — reads Rails internals, builds ModelChain inspector.rb Core engine — reads Rails internals, builds ModelChain
formatter.rb Terminal renderer — formats ModelChain as plain text formatter.rb Terminal renderer — formats ModelChain as plain text
cli.rb CLI logic — boots Rails, resolves model name, calls inspector cli.rb CLI logic — boots Rails, resolves model name, calls inspector
engine.rb Rails::Engine definition (isolate_namespace Whetrails)
engine_routes.rb Engine routes — resources :models + root
app/
controllers/whetrails/
application_controller.rb Base: before_action loads all user-defined AR models
models_controller.rb index (redirect to first model), show (render chain)
helpers/whetrails/
models_helper.rb vscode_uri, short_path, condition_label, validator_type_label
views/
layouts/whetrails/
application.html.erb Layout: sidebar model list + main content area
whetrails/models/
index.html.erb Empty — index action always redirects
show.html.erb Lifecycle timeline + validators table
exe/ exe/
whetrails Executable entry point — calls Whetrails::CLI.run(ARGV) whetrails Executable entry point — calls Whetrails::CLI.run(ARGV)
@@ -22,6 +37,8 @@ app/models/order.rb Development fixture model with sample callbacks/
## Data flow ## Data flow
### CLI
``` ```
exe/whetrails exe/whetrails
→ CLI#run_inspect → CLI#run_inspect
@@ -40,6 +57,23 @@ exe/whetrails
→ Formatter#render prints to stdout → Formatter#render prints to stdout
``` ```
### Engine (browser UI)
```
GET /whetrails/
→ ModelsController#index
→ eager_load! + discover user models via Object.const_source_location
→ redirect_to first model
GET /whetrails/models/:id (:id is the model name, e.g. "Order")
→ ModelsController#show
→ params[:id].constantize → model class
→ Inspector#inspect_model → ModelChain
→ renders show.html.erb: lifecycle timeline grouped by event,
each callback shows filter name, vscode:// source link, condition badges,
module badge if defined in a concern
```
## Key Rails API details ## Key Rails API details
These are the actual Rails internals the inspector uses — documented here because they are These are the actual Rails internals the inspector uses — documented here because they are
@@ -88,6 +122,34 @@ validator.options[:if] # => conditions (NOT instance variables — different
validator.options[:unless] validator.options[:unless]
``` ```
## Dev setup quirks
This repo is both the gem source and the development Rails app. That creates two issues
that were solved and should not be undone:
**1. Zeitwerk autoloading conflict**
`config/application.rb` has `config.autoload_lib(ignore: %w[assets tasks whetrails.rb whetrails])`.
The `whetrails.rb` and `whetrails` entries are critical: without them, Zeitwerk tries to
autoload `lib/whetrails/chain.rb` as `Whetrails::Chain` (wrong constant name), crashing on
every request. The gem files use manual `require_relative` — they must not be Zeitwerk-autoloaded.
**2. Engine routes conflict**
`lib/whetrails/engine.rb` overrides the engine's routing path:
```ruby
config.paths.add "config/routes.rb", with: "lib/whetrails/engine_routes.rb"
```
Without this, Rails uses `config/routes.rb` as the engine's routes file (same directory),
which causes `mount Whetrails::Engine` to execute twice and fail with a duplicate route name error.
Engine routes live in `lib/whetrails/engine_routes.rb` instead.
**3. Gem not in Gemfile by default**
The gem is listed as `gem "whetrails", path: "."` in `Gemfile`. This is intentional —
`Bundler.require` must explicitly load the gem so `Whetrails::Engine` is defined before
`config/routes.rb` tries to mount it.
## Known limitations ## Known limitations
- **Proc source location**: Proc-based callbacks (e.g. `before_save { do_something }`) report - **Proc source location**: Proc-based callbacks (e.g. `before_save { do_something }`) report

View File

@@ -1,64 +1,34 @@
# Next Steps # Next Steps
## Immediate: Rails Engine (browser UI) ## Immediate: Test Gap Analysis
Mount a UI at `/whetrails` inside the host Rails app. A developer adds the gem to their The engine is done. The next layer uses the extracted chain as input to find uncovered
Gemfile, mounts the engine in `config/routes.rb`, starts the server, and visits `/whetrails`. callbacks and validators.
### What the UI needs to show ### How it works
- A list of all models in the app (left sidebar or index page) 1. Run the host app's test suite with SimpleCov to get line coverage data
- Click a model → full chain view (same data as CLI, but visual) 2. For each `CallbackEntry` in the `ModelChain`, check whether `source_file:source_line`
- Lifecycle timeline: each stage as a row, callbacks as nodes within it is covered in the SimpleCov report
- Source file/line as a clickable link (opens in editor via `vscode://` URI scheme) 3. For each `ValidatorEntry`, check whether the validation path is exercised
- Conditions rendered inline (`if: :paid?` shown as a badge)
- Callbacks from concerns/gems visually distinguished from model-defined ones
### Technical approach for the engine
```ruby
# lib/whetrails/engine.rb
module Whetrails
class Engine < ::Rails::Engine
isolate_namespace Whetrails
end
end
```
Rails engine conventions:
- Routes go in `config/routes.rb` inside the gem
- Controllers in `app/controllers/whetrails/`
- Views in `app/views/whetrails/`
- The host app mounts it: `mount Whetrails::Engine, at: "/whetrails"`
The engine controllers call `Whetrails::Inspector` directly — no duplication of logic.
### Model discovery
The engine index page needs to list all AR models in the host app:
```ruby
Rails.application.eager_load!
models = ApplicationRecord.descendants # or ActiveRecord::Base.descendants
```
## After engine: Test Gap Analysis
Once the chain is extracted, gap analysis is:
1. Run the test suite with SimpleCov (or equivalent) to get line coverage data
2. For each callback method in the chain, check whether its source lines are covered
3. For each validator, check whether the validation path is exercised
4. Report: "these 3 callbacks have no test coverage" 4. Report: "these 3 callbacks have no test coverage"
The chain data structure (`ModelChain`) is already the right shape for this — each The `ModelChain` data structure is already the right shape — each `CallbackEntry` has
`CallbackEntry` has `source_file` and `source_line`, which can be cross-referenced `source_file` and `source_line`, ready to cross-reference against SimpleCov output.
against SimpleCov's coverage report.
## Product / distribution ### Integration point
- Publish to RubyGems.org once the engine is working Surface this in the engine UI: add a "Coverage" column to each callback row, showing
- The gem should be dev/test-only (add to Gemfile under `group :development`) green/red/unknown based on whether SimpleCov data is available and whether the line
is covered.
SimpleCov writes JSON to `coverage/.resultset.json` after a test run. Parse that file
directly — no need to instrument the test suite beyond what SimpleCov already does.
## After gap analysis: distribution
- Publish to RubyGems.org
- The gem should be dev/test-only (add under `group :development`)
- Pricing model TBD — could be open source with a paid cloud version, or MIT with - Pricing model TBD — could be open source with a paid cloud version, or MIT with
a commercial license for teams a commercial license for teams
@@ -70,3 +40,5 @@ against SimpleCov's coverage report.
- Dev database: SQLite at `storage/development.sqlite3` - Dev database: SQLite at `storage/development.sqlite3`
- Gitea running in Docker on localhost:3000 (container name: `gitea`) - Gitea running in Docker on localhost:3000 (container name: `gitea`)
- Gitea API token for `bill`: `c2948775b80bcaa0ba65c6359658d0d48c340c4e` - Gitea API token for `bill`: `c2948775b80bcaa0ba65c6359658d0d48c340c4e`
- Dev server runs on port 3100 (`bin/rails server -p 3100`)
- Visit `http://localhost:3100/whetrails` to see the engine UI

View File

@@ -10,20 +10,19 @@ This is the first half of a two-part tool. The second half (not yet built) uses
## Current state (as of 2026-06-08) ## Current state (as of 2026-06-08)
Two layers are complete: Three layers are complete:
1. **Core inspector** — extracts the full chain from any ActiveRecord model class at runtime 1. **Core inspector** — extracts the full chain from any ActiveRecord model class at runtime
2. **CLI**`whetrails inspect <ModelName>` boots the host Rails app and renders the chain to the terminal 2. **CLI**`whetrails inspect <ModelName>` boots the host Rails app and renders the chain to the terminal
3. **Rails engine** — browser UI mounted at `/whetrails`, sidebar model list, lifecycle timeline with vscode source links
The next layer (Rails engine / browser UI) has not been started.
## Build order ## Build order
``` ```
[DONE] 1. Gem core lib/whetrails/inspector.rb [DONE] 1. Gem core lib/whetrails/inspector.rb
[DONE] 2. CLI exe/whetrails + lib/whetrails/cli.rb [DONE] 2. CLI exe/whetrails + lib/whetrails/cli.rb
[NEXT] 3. Rails engine browser UI mounted at /whetrails [DONE] 3. Rails engine browser UI mounted at /whetrails
4. Test gap layer uses chain data to find uncovered callbacks/validators [NEXT] 4. Test gap layer uses chain data to find uncovered callbacks/validators
``` ```
## Repository ## Repository

View File

@@ -1,6 +1,7 @@
require_relative "whetrails/version" require_relative "whetrails/version"
require_relative "whetrails/chain" require_relative "whetrails/chain"
require_relative "whetrails/inspector" require_relative "whetrails/inspector"
require_relative "whetrails/engine" if defined?(Rails)
module Whetrails module Whetrails
def self.inspect_model(model_class) def self.inspect_model(model_class)

9
lib/whetrails/engine.rb Normal file
View File

@@ -0,0 +1,9 @@
module Whetrails
class Engine < ::Rails::Engine
isolate_namespace Whetrails
# In the gem's dev setup, config/routes.rb belongs to the host app.
# Point the engine's routing path to a dedicated file.
config.paths.add "config/routes.rb", with: "lib/whetrails/engine_routes.rb"
end
end

View File

@@ -0,0 +1,4 @@
Whetrails::Engine.routes.draw do
resources :models, only: [:index, :show]
root to: "models#index"
end

View File

@@ -12,7 +12,7 @@ Gem::Specification.new do |spec|
spec.required_ruby_version = ">= 3.0" spec.required_ruby_version = ">= 3.0"
spec.files = Dir["lib/**/*.rb", "exe/*", "LICENSE", "README.md"] spec.files = Dir["lib/**/*.rb", "app/**/*.rb", "app/views/**/*.erb", "config/**/*.rb", "exe/*", "LICENSE", "README.md"]
spec.executables = ["whetrails"] spec.executables = ["whetrails"]
spec.add_dependency "railties", ">= 7.0" spec.add_dependency "railties", ">= 7.0"