How it works
A short tour of the pipeline that turns Ruby source into a Mermaid / Graphviz graph. The forward-looking design notes live in the design plan; current limitations live in the limitations doc.
The pipeline
In principle this is a static-analysis tool that turns Ruby source into a graph whose nodes are classes / modules / constants and whose edges are the references the language itself spells out.
-
The plugin’s
node_rules pick upClassNode/CallNode/ConstantReadNodeand friends. -
Each interesting node becomes one or more edges:
-
class A < B→A -> B / inherits -
include M→A -> M / include -
a
Moneyconstant reference →A -> Money / const_ref(gated oninclude_constant_refs: true) -
fromis the lexical owner, assembled by walkingcontext.ancestors— soclass Billing::InvoiceproducesBilling::Invoice, not justInvoice. -
tois resolved through a confidence ladder:syntax→zeitwerk(path agrees with the lexical name) →rigor_type(Rigor’sscope.type_ofreturned aSingleton[X]carrier). Whatever couldn’t be pinned down stays visible atconfidence: "unresolved"rather than being dropped. -
Every edge ships as a
Rigor:infodiagnostic. Thecollectsubcommand filters them onrule == "edge"and writes JSONL to.rigor/module_graph/edges.jsonl. -
DOT, SVG, Mermaid, Mermaid
classDiagram, cycle detection, and per-namespace statistics all read from that JSONL.
So this is not a tool that watches what Ruby does at runtime. It reads Ruby’s named structure and reconstructs, approximately, “which constants depend on which other constants”.
This is not a call graph
foo.bar‘s runtime target isn’t tracked. What is tracked: the fact that the Billing::Invoice name depends on the ApplicationRecord / Auditable / Money names. That is a nominal dependency graph — a compiler-front-end-style view of the project’s syntactic and lexical structure, projected into edges with explicit confidence.
Not re-implementing Ruby’s constant lookup is deliberate. For understanding a Rails codebase’s shape it’s more useful to leave each edge tagged syntax / zeitwerk / rigor_type / unresolved than to fake a resolved answer and silently get it wrong.
Edge format
The pipeline lands every edge in .rigor/module_graph/edges.jsonl, one JSON object per line:
{"from":"Billing::Invoice","to":"ApplicationRecord","kind":"inherits","path":"app/models/billing/invoice.rb","line":2,"column":3,"confidence":"syntax"}
Fields:
-
from,to— fully-qualified constant names. Absolute (::Foo) and relative names collapse to the same node. -
kind— one ofinherits/include/prepend/extend/const_ref/association. The last two carry extra context:const_refonly appears wheninclude_constant_refs: true, andassociationis paired with a cardinality (one/many) for Rails-stylehas_many/belongs_to/has_one/has_and_belongs_to_manycalls. -
path,line,column— extraction site. -
confidence—syntax/zeitwerk/rigor_type/unresolved. See the confidence ladder above. -
raw— present forunresolvededges; contains the source slice we couldn’t pin down, so a manual pass can sift without re-parsing.
The renderers dedupe by (from, to, kind, confidence), so two include Foo declarations of the same class across files collapse to one logical edge.
Why this design vs. the alternatives
| tool | unit | technique |
|---|---|---|
| rigor-module-graph | Ruby constant | static AST, confidence-tagged |
| Packwerk / Graphwerk | package | static, package-boundary lint |
| Rubrowser / RailRoady | method call | static, runtime-leaning |
Packwerk / Graphwerk look at package boundaries, which is the right unit when the project already has packages drawn. This tool’s angle is one level down — the nominal graph that exists in any Ruby project regardless of whether anyone drew packages.
Rubrowser / RailRoady aim at the call graph (who invokes whom); useful for tracing a specific execution path, less so for “what’s the shape of this codebase”. The confidence ladder is what lets us leave the unresolved edges in the picture without lying about them.