Flow Analysis for Rails
Live Types for Rails made an observation: the type inference Roundhouse builds because transpilation demands it — what class is @status, what does this scope return, can this be nil — is worth having on its own.
This post is the next step of that observation. You can't infer types in a Rails app without tracking flow. What class @account is depends on which before_action assigned it — which depends on the filter chain, assembled from the controller, its ancestors, and every concern they include. Whether a template's loop is over Array[Status] depends on which controller actions feed that template. Whether a query has preloaded :account by the time something iterates it depends on everything that touched the relation along the way.
So the flow graph gets built, because the types are unreachable without it. And it turns out to be worth having on its own too. Two features now sit directly on top of it — a work in progress, and already producing real value.
The N+1, before it ships
The most famous Rails performance bug: load a collection, touch an association inside the loop the query didn't preload, issue one query per row. The existing tools — Bullet, Prosopite — are runtime tools: they watch queries execute, so they find N+1s in whatever traffic you happened to generate, after the queries already ran.
Flow analysis enables the static version. The relation's type carries what has been preloaded; the iteration site knows the element type and the association being read; the controller-to-view channel carries the query across the boundary where most real N+1s live — query in the controller, loop in the template. When the pieces line up, the warning names both sites and the one-line fix, whole-app, pre-deploy, in the editor's diagnostics and in the same MCP report an LLM agent reads.
Run it over Mastodon and it reports two findings. Checked by hand, both are false positives — unsurprising for a work in progress; each traces to a specific pattern the analyzer doesn't model yet, and a false positive here just means the implementation is incomplete. What's more interesting is what checking one of them turned up. The detector flagged the admin collections page for reading each collection item's account without a preload; Mastodon's authors were ahead of it — the controller preloads exactly that association. But the accounts on that page render through a shared partial that reads two more associations per row — follower and post counts through account_stat, confirmation and sign-in state through user — and those aren't preloaded, though Mastodon's own admin accounts index preloads exactly these for the same partial. A real N+1, two associations deeper than the flagged one, now fixed upstream in mastodon#39738. The metal detector beeped in the right place; the shovel found the treasure a foot to the left.
Incomplete-but-honest is a design position, and it shows up in the output. These findings are warnings, never errors, and each names both of its sites, so even a wrong claim costs one click to triage. When the analyzer can't verify a chain it says nothing — and says that out loud. Every report ends with its own denominator:
missing_preload coverage (app-wide): checked 12 query chain(s), 2 finding(s), 39 chain(s) unverifiable (opaque to the static chain harvest — no claim made)
A chain that flows through a method the analyzer can't model is opaque — excluded, never reported as "no preloads," because not-modeled is not the same as absent. Runtime tools have a denominator too; it's just implicit — the traffic you generated. This one is explicit and unflattering: thirty-nine of fifty-one chains on Mastodon are beyond the harvest today, most behind render partial: … collection: and custom finder methods — the same modeling additions, as it happens, that would have caught the upstream bug directly. That's the difference between "0 findings" and "couldn't check," stated in the report.
The machinery, rendered whole
Look at what the detector needed for that cross-procedure claim: which filters actually run for an action, gated by what; which instance variables each assigns, with what types; which template the action renders, through which partials. That knowledge is the request path. The second feature just renders it whole.
Every Rails developer knows the investigation it replaces: which before_actions actually run here? Where is @account assigned — because it certainly isn't in this file? Why does this filter run here but not there? The answers are knowable but expensive — filters come from concerns the controller never shows you, gated by conditions declared far away, with skips subtracting at a distance. Mastodon's StatusesController#show runs twenty-one filters from six concerns and two controllers. Nobody holds that in their head.
Traceroute makes it one query. In the browser IDE — Mastodon preloaded, nothing to install — press ⌘⇧T and pick a route. The panel that pins to the right is the whole request in order: route, filter chain, action, view with partials, layout. Filters group by where they're defined; each shows the instance variables it assigns, with types; gated-out filters render struck through with the gate that removed them; every row jumps to its file:line, and the panel highlights whichever hop your cursor is inside. The two features meet where you'd want: trace the admin collections route and the missing-preload finding rides its view hop as a clickable badge.
The same answer is available in VS Code — the extension speaks to the identical query layer — and to LLM agents. An agent re-derives request flow the expensive way: read the controller, notice the concern, read the concern, miss the skip_before_action, hallucinate the rest. With an MCP server pointed at the checkout, "trace StatusesController#show" is one tool call returning the chain as structured data — every hop with its file, line, conditions, typed assignments, and database effects.
Saying what it doesn't know
The denominator discipline applies to traces too, because static analysis of real Rails apps always hits constructs it can't see through — a Devise method defined in a gem, a metaprogrammed helper.
Each trace ends with a coverage claim — 19/20 hops resolved — and an itemized footer for the remainder, split by whose problem it is. A hop blocked by an untyped boundary is your lever: the footer prices it (how many hops it blocks) and, when inference already holds a candidate, hands you the RBS signature pre-filled — one copy button away from sig/. A hop blocked by a construct Roundhouse doesn't model is labeled the opposite way: tool coverage, our ledger, not your code.
This inverts the usual annotation bargain: you never type your app up front; the tool asks for one signature at the moment it can demonstrate what that signature buys. Agents get the same footer as structured data — blocking boundary and candidate signature in one payload — and the per-hop resolution marks mean an agent knows which segments of a trace are proven and which are best-effort.
Where this is
A work in progress, stated plainly: twelve of fifty-one N+1 chains checkable on Mastodon today, two false positives each traced to a named modeling gap, the coverage levers counted (#64). And already: a real bug found and fixed upstream by following the detector's pointer; request traces resolving nineteen of twenty hops through six concerns' worth of filter chain; and a signature loop that moves the coverage numbers without anyone retyping their app.
The larger point is the one Live Types started: none of this required running the application, generating traffic, or annotating anything. The type inference was built because transpilation needs it; the flow analysis was built because the inference needs it; and each layer turns out to answer questions developers — and now their agents — were already asking. N+1 detection and traceroute are the first two features paid for by the flow graph. They won't be the last.
Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.