Compare commits
2 Commits
6dd78f2487
...
ab1f29269e
| Author | SHA1 | Date | |
|---|---|---|---|
| ab1f29269e | |||
| 286db51fcd |
2
Gemfile
2
Gemfile
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
23
app/controllers/whetrails/application_controller.rb
Normal file
23
app/controllers/whetrails/application_controller.rb
Normal 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
|
||||||
16
app/controllers/whetrails/models_controller.rb
Normal file
16
app/controllers/whetrails/models_controller.rb
Normal 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
|
||||||
20
app/helpers/whetrails/models_helper.rb
Normal file
20
app/helpers/whetrails/models_helper.rb
Normal 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}" : "<proc>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def validator_type_label(type)
|
||||||
|
type.split("::").last.sub("Validator", "").downcase
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
210
app/views/layouts/whetrails/application.html.erb
Normal file
210
app/views/layouts/whetrails/application.html.erb
Normal 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>
|
||||||
5
app/views/whetrails/models/index.html.erb
Normal file
5
app/views/whetrails/models/index.html.erb
Normal 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 %>
|
||||||
76
app/views/whetrails/models/show.html.erb
Normal file
76
app/views/whetrails/models/show.html.erb
Normal 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 %>
|
||||||
@@ -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.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
9
lib/whetrails/engine.rb
Normal 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
|
||||||
4
lib/whetrails/engine_routes.rb
Normal file
4
lib/whetrails/engine_routes.rb
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Whetrails::Engine.routes.draw do
|
||||||
|
resources :models, only: [:index, :show]
|
||||||
|
root to: "models#index"
|
||||||
|
end
|
||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user