intertwingly

It’s just data

An IDE You Don't Install


Two recent posts made promises. Hover Over the Difference ended by admitting Roundhouse's emit was calibrated to one small fixture, and that the only way to learn how far the subset extends "is to feed it bigger applications — which will be the subject of a later post, not this one." Live Types for Rails named the stretch target: Mastodon, seventy-five thousand lines of application code.

This is that post. It comes with an artifact instead of an argument: rubys.github.io/roundhouse/ide/.

Open it and you get Monaco — the editor component inside VS Code — with Mastodon's source preloaded and the Roundhouse analyzer running in a Web Worker beside it. Nothing executes on a server. Nothing installs. The status bar, when the worker finishes, reads:

analyzed 1173 files in 2.3s · 721 errors · 3806 warnings · 6600 coverage notes · 304 ingest gaps

I want to walk through what's behind that line, because every number in it is load-bearing — including the ones that don't flatter me. Especially those.

What to try first

The page opens on statuses_controller.rb. Hover @status anywhere in it: the tooltip says Status. That answer is harder than it looks, and the difficulty is most of the story behind this post. @status is assigned by set_status, a private method run by a before_action. Its body reads @account, which no code in this file assigns — it comes from AccountOwnedConcern, a module whose included do block declares the filter and whose method calls Account.find_local!, a finder that itself lives in a class_methods do block of Account::FinderConcern. Three concern hops, none of them visible in the file you're looking at, all of them resolved statically.

Then type. Add a line in show, type Status. and the completion list offers 89 entries with their types — find_by → Status?, and the scopes: recent → Array[Status], with_accounts → Array[Status]. Type Status.find_by( and you get the model's columns as keyword arguments, each typed: uri: → String, visibility: → Integer. Type @account. and the associations appear — statuses → Array[Status], followers → Array[Account] — associations that are declared in a concern, inside two nested with_options blocks. The completions arrive in a few milliseconds, while the buffer you just edited is still up to two and a half seconds away from being re-analyzed, because the answers come from the last completed analysis. The receiver you're completing existed before the keystroke; staleness of one edit is the trade every fast language server makes.

Open app/views/statuses/show.html.haml — a HAML template — and hover @status there. Same answer, inside the view. Press ⌘⇧R and the "related files" list shows the views this controller's actions actually feed, the concerns it includes, its model. That list is not filename convention; it's the render graph the analyzer built while it was inferring the types — RubyMine's Related-Symbol jump, except derived instead of guessed.

Everything above also works in VS Code against your own checkout — the browser page and the language server are two skins over the same query layer, and completion answers in about two milliseconds there. The page is just the skin you don't have to install.

The correction

Live Types for Rails published a performance table. Mastodon's row said 200 milliseconds per whole-app analysis, and I flagged the number as flattered because the HAML views — 87% of Mastodon's UI — weren't being ingested at all.

The flattery went deeper than I knew. Roundhouse's directory walk was not recursive. Rails autoloads nested directories — app/controllers/admin/, app/controllers/api/v1/, app/models/concerns/ — and the walk listed one level. 306 of Mastodon's 337 controller files were invisible. So were 114 of its 248 model files, including every model concern. No gap was recorded anywhere, because the files were never seen; there was nothing to record a gap against. The post about silent coverage gaps — the one that said "a type checker that silently skips a file and then reports 'no problems' is lying" — was published while a silent gap sat under it, one level of abstraction down.

The honest numbers, over the full surface: about 1.5 seconds per whole-app pass natively, 2.3 seconds in WebAssembly in the browser. The 200ms figure measured the tenth of the application the tool could see. I'm leaving the old post as written — it says what I believed with the instruments I had, and its own caveat ("coverage has to be visible, or it's worse than useless") turned out to be the review criterion its successor needed. Fixing the walk also surfaced a second bug of the same species: with lib/ now visible, a bare Account in app code started resolving to Mastodon::CLI::Maintenance::Account — a stub class inside a maintenance script — because the constant resolver preferred the only namespace-qualified match over the exact bare one. Every bare model reference that stub shadowed was silently mistyped until it was fixed. Neither bug was findable on the demo blog. Both were waiting in the first codebase big enough to have nested directories and a maintenance script.

What the four numbers mean

304 ingest gaps is the count of constructs and files Roundhouse recognized it could not model — alias_method, class << self in odd positions, a .rss.ruby template. Since Live Types for Rails, recovery got finer-grained: an unsupported item now costs exactly itself, not its whole file. (One spelled-out lambda { } scope used to silently drop the entire Status model.) Click "coverage" in the header and the list is right there. It is the tool's punch list, on screen, in the product.

6600 coverage notes are new, and they're the piece I care most about. When ingest skips a construct, everything downstream of it fails to resolve — and a naive tool reports each downstream failure as an error in your code. When this round of work started, pointing Roundhouse at Mastodon produced 1,442 such accusations; roughly ninety percent traced mechanically back to one of the recorded gaps. Those now render as notes, each carrying its root cause: "@report has no known type — likely roundhouse coverage, not an app error (ingest gap in application_controller.rb: defined? only supports bareword targets today)." In the editor they're faint dots, not red squiggles. The distinction is the difference between a tool that says "your code is broken" 1,400 times and a tool that says "here is the boundary of what I can see, and why."

721 errors are what's left after that attribution: sites where inference genuinely came up empty and no recorded gap explains it. They are not 721 bugs in Mastodon. They are 721 places where the resolution chain ends in something Roundhouse doesn't model yet — an I18n.t surface, a service object in a shape it doesn't recognize. I'll be honest that I'm not settled on the word "errors" for these. The razor I keep coming back to is that a diagnostic's severity should encode what action it asks of the reader — and most of these ask nothing of a Mastodon developer. Expect that word to change.

3806 warnings are the gradual-typing escapes — expressions that resolve to untyped and are honestly labeled as such.

The demo path itself — the statuses and accounts controllers, their models, the statuses views — renders zero errors. That's not because the counts were massaged; it's because that slice got the modeling attention first, and the last three accusations on it were retired by fixes (walking app/lib, modeling include Singleton, cataloging Addressable::URI) rather than suppression.

What made it hard: concerns

If one Rails idiom dominated the gap between the demo blog and Mastodon, it's ActiveSupport::Concern. Mastodon's Account model includes twenty-one of them. Its associations live in Account::Associations, inside an included do, inside nested with_options blocks. Its finders live in class_methods do blocks. Controllers get their before_action filters — and therefore their instance variables — from concern modules the controller file never shows you.

All of that now resolves: the included do filters splice into each includer's filter chain, class_methods definitions become class methods of the includer, concern-declared associations and scopes register exactly as if written inline, and the whole surface flows into hover, completion, and nil-safety. It's why the @status hover at the top of this post works. And here is the residual, stated plainly: with_options inside a model's own body is still opaque — Mastodon declares belongs_to :reblog that way, so @status.reblog doesn't complete. Same walker, different position; it's in the ledger.

The honest part

The page has limits beyond the analyzer's. The worker holds about 360MB of memory once warm — acceptable for a desktop tab, unapologetically heavy. Re-analysis after an edit takes the full 2.3 seconds; queries that arrive mid-pass wait for it. Completion is stale by one edit, by design. And unlike the studio, nothing you edit runs — this surface analyzes; it doesn't execute. The Mastodon sources are pinned to a specific commit with the AGPL license text embedded in the bundle — redistribution of unmodified source, the most compliant thing you can do with AGPL code — so the page won't drift under this post's claims. A CI job drives the published page in a headless browser and asserts the specific behaviors described above, so if a regression mutes one of these demo beats, the build fails before the page updates.

One more disclosure, because the commit log makes it checkable: the distance from the first 1,442-accusation run to the page you're looking at — the attribution tier, typed completion, the concern modeling, the recursive-walk correction, the related-files navigation, the WebAssembly build, the page itself, and the CI job that guards it — is nine commits, timestamped between 3:45pm and 11:12pm of a single day. How that pace is possible is the other strand of this blog; the seams show in the log too — one of those nine commits exists only because the one before it broke the WebAssembly build in CI.

And the deepest caveat is unchanged from Live Types for Rails: where Roundhouse understands your code, it gives you answers nobody else can; the four numbers in the status bar are it telling you, precisely, where it doesn't.

What's next

Two near-term explorations are queued, filed as issues so the reasoning is public before the work starts — and behind them, a follow-on substantial enough that everything in this post is prerequisite to it.

Traceroute. Everything a request traverses — route, the effective filter chain with each filter's defining concern and its conditions, the action, the instance variables it binds with their types, the view, its partials, the layout, the database reads and writes along the way — as one ordered, clickable chain. For humans, a panel in this IDE. For LLM agents, an MCP tool that returns the same chain as structured data, collapsing the "where does @status come from" investigation into one call. Most of the edges already exist in the IR; the honest assessment is that this one is composition, not research — it can't fail, which also means it can't surprise.

Static N+1 detection. The classic Rails performance bug: iterate a collection, touch an association the query didn't preload, issue a query per row. Bullet and Prosopite catch this at runtime, on the traffic you happened to generate. Roundhouse types the query chain, knows the associations, and carries the collection's type from the controller into the template — so it should be able to prove the pattern from source, pre-deploy, pointing at both the query and the access with the one-line fix. This one can fail: it needs relation types to carry preload information, and its worth depends entirely on precision — a detector that cries wolf a third of the time burns the exact trust the coverage notes were built to protect. The validation plan is to run it over Mastodon and lobste.rs and check the findings against the N+1 fixes in those projects' own commit histories. If the numbers hold up, that's the next post. If they don't, that will be too.

And the long arc: an honest Futamura projection. The JRuby post claimed the dividend of the first Futamura projection — specialize the interpreter (Rails) with respect to the program it interprets (your app), keep the residue — while conceding that Roundhouse reaches it "by hand-built pattern recognizers rather than automatic specialization." The substantial follow-on is to earn the term: a real specializer, initially Ruby to Ruby — Rails and the application go in, the residual program comes out, without a hand-written lowering rule per Rails feature — and then Ruby to Spinel, where the residue meets an ahead-of-time compiler. JRuby falls out rather than being built: residual Ruby runs there unchanged, and the 5–6× JIT dividend stops being a number calibrated to one demo fixture.

The reason this post is prerequisite rather than preamble: a specializer is exactly as good as the analysis feeding it is complete and correct. Every one of those 721 unresolved sites is a decision the specializer must either leave in the residue or get wrong; every ingest gap is a region it cannot touch. The IDE is those limits made visible and clickable — the same ledger, consumed by a person today and by a specializer later. And given the size of that effort, it starts on lobste.rs, not Mastodon: eight and a half thousand lines, seventeen unresolved sites rather than seven hundred, and a benchmark heritage that means success has a number attached — the YJIT team's own Rails benchmark, served by the residue.


Roundhouse is open source: dual-licensed MIT / Apache-2.0. Issues and discussion welcome.