The Transpiler That Reads Your Whole App
Most transpilers see one file at a time. Feed them a source file, get back a target file. TypeScript, Babel, CoffeeScript — they all work this way. It's clean, parallelizable, and easy to reason about.
It's also insufficient for Rails.
Rails encodes meaning across files. A has_many declaration in a model determines whether a method call in a test needs await. A group_by in a controller determines whether bracket access in a view becomes .get(). A fixture YAML file references records defined in other fixture files, which must be created in the right order. A file-at-a-time transpiler can't see any of this.
Juntos reads the whole application. Here's how.
The Metadata Pipeline
Juntos processes files in dependency order. Each stage captures metadata into a shared object that downstream stages can read:
Models/Concerns → associations, scopes, enums, instance methods, constants
Helpers → exported method names
Controllers ← model metadata for async/sync decisions
→ instance variable types per action
Routes → route helper → controller mappings
Views ← controller types for Map/Array/Hash disambiguation
Tests ← model metadata for async/sync, fixtures, calling conventions
← route metadata for standalone path helpers
No annotations required. The metadata is inferred from standard Rails patterns that every Rails developer already writes.
Type Inference: Controller → View
The most visible result. When a controller assigns instance variables, Juntos infers their types and passes that to the corresponding view:
def summary
@people_by_type = Person.all.group_by(&:type)
@count = Person.count
end
The controller filter records: people_by_type is a Map, count is a number. When the view is transpiled:
<% @people_by_type.keys.sort.each do |type| %>
<% members = @people_by_type[type] %>
<%# ... %>
<% end %>
becomes:
for (let type of Array.from(people_by_type.keys()).sort()) {
let members = people_by_type.get(type);
// ...
}
Without the metadata, @people_by_type[type] would transpile to bracket access — correct for objects, wrong for Maps. With it, the transpiler generates .get(), .keys() returns an Array (not an iterator), and .each becomes for...of. No pragmas needed.
The inference covers literals ({} → hash, [] → array), method return types (to_a → array, count → number, group_by → map), block methods (select/map/reject → array), and local variable propagation — types flow through intermediate assignments. For the full list, see the Cross-File Metadata docs.
Async Decisions: Model → Test
The test filter faces a different problem: which method calls need await?
In the JavaScript output, database operations are async. Attribute access is sync. Association access is async. Enum predicates are sync. The model filter captures all of this — associations, enums, instance methods that touch the database, methods with parameters — and the test filter uses it:
test "article with comments" do
article = articles(:one)
assert article.draft? # sync (enum predicate)
assert_equal "Rails", article.title # sync (attribute)
comments = article.comments # async (association)
assert_equal 2, comments.length
article.published! # sync (enum bang)
end
Transpiles to:
test("article with comments", async () => {
let article = _fixtures.articles_one;
expect(article.draft()).toBeTruthy(); // no await
expect(article.title).toBe("Rails"); // no await
let comments = await article.comments; // await
expect(comments.length).toBe(2);
article.published_(); // no await
});
Four different calling conventions in five lines, all determined by metadata from the model file. Get any of them wrong and the test either hangs, returns a Promise object instead of a value, or throws a runtime error.
Import Resolution
Rails models routinely reference each other. Article has many Comments; Comment belongs to Article. In Ruby, autoloading handles this transparently. In JavaScript, circular imports are a real problem — every major ORM has had to solve it. Sequelize has sequelize.models, Mongoose has mongoose.model(), TypeORM uses lazy callbacks in decorators.
Juntos generates a model registry. Each model registers itself at startup, and cross-model references go through the registry instead of direct imports:
// Generated models index
import { Article } from 'app/models/article.rb';
import { Comment } from 'app/models/comment.rb';
const models = { Article, Comment };
Object.assign(modelRegistry, models);
When a model references another — Article.find(id) inside Comment's code — the import resolver detects the pattern and rewrites it to modelRegistry.Article.find(id). The developer writes normal Ruby; the transpiler handles the cycle prevention.
This extends to name collisions. If both app/models/card.rb and app/models/features/card.rb exist, Juntos detects the collision and qualifies the nested one as FeaturesCard — the same disambiguation a Rails developer would reach for manually.
Fixture Generation
Rails fixtures are YAML files that describe test data. They reference each other by name — a comment fixture points to an article fixture, which points to an account fixture. Turning this into JavaScript test setup requires reading the whole fixture directory, understanding the associations between models, and creating records in the right order.
Juntos handles this in three stages:
- Parse — Read all fixture YAML files, evaluate ERB expressions like
ActiveRecord::FixtureSet.identify("label", :uuid)to generate deterministic IDs. - Map associations — Extract
belongs_toandhas_manydeclarations from transpiled models to build a dependency graph. When a comment fixture hasarticle: one, the pipeline knowsarticleis abelongs_tothat references thearticlestable. - Sort — Topologically sort the tables so that foreign key dependencies are satisfied. Articles are created before comments, accounts before articles.
The result is a beforeEach block injected into each test file that sets up exactly the fixtures that test references, in an order that guarantees every foreign key resolves.
Reading Rails Configuration
Rails applications configure behavior through convention files. Juntos reads these too.
Custom inflections — config/initializers/inflections.rb defines irregular pluralization rules (inflect.irregular "leaf", "leaves"). Juntos registers these with both the transpile-time and runtime inflectors, so table names, model lookups, and route helpers all use the same pluralization as Rails. Without this, Leaf would produce leafs instead of leaves.
Import maps — config/importmap.rb maps bare module names to directories (pin_all_from "app/javascript/helpers", under: "helpers"). Juntos parses these entries and generates equivalent Vite aliases. It also translates Rails gem names to npm equivalents — @hotwired/turbo-rails becomes @hotwired/turbo — and replaces Rails' importmap-only Stimulus loader with Vite's import.meta.glob.
These are small things individually, but they add up. Every config file Juntos reads is one fewer manual translation a developer has to maintain.
When Inference Fails
It does. Custom methods have unknown return types. Partial locals lose type context. Reassignment to a different type takes the last assignment. For these cases, pragmas — inline comments that hint the transpiler — override inference:
<% data << item # Pragma: array %>
But pragmas are the escape hatch, not the default. For standard Rails patterns — which cover the vast majority of application code — the metadata pipeline handles it.
Why This Matters
A file-at-a-time transpiler forces developers to annotate cross-file knowledge that Rails conventions already encode. "This variable is a Map." "This method is async." "This call needs parentheses." "Create articles before comments." These annotations duplicate information that exists in the codebase — just in a different file.
Reading the whole application eliminates that duplication. The transpiler becomes convention-aware the same way Rails itself is. Name a controller ArticlesController and Rails knows to look for views in app/views/articles/. Declare has_many :comments and Rails knows to generate SQL joins. Juntos extends that same principle to transpilation: declare your associations once, and every downstream file gets the right JavaScript.
Juntos is open source: github.com/ruby2js/ruby2js