Skip to content

Proposal A — split by concern

Status: rejected — superseded by Proposal B — split by language, which the design record adopted directly. Kept for the reasoning. · See the section overview for the question both answer.

Why it was passed over. This was the accepted direction for a while — the conservative, low-migration path. Proposal B won on two points: the codegen ↔ runtime agreement becomes a same-repo, same-CI invariant instead of a hand-maintained version table, and "which languages are heavy enough to graduate" stops being a recurring judgement call. The de-rot that got the generators out of core already did the expensive part.

The idea: the generator and the runtime are different kinds of software, so draw the repo lines along that boundary rather than along languages. All the code and library generators live in one repo (generation). The runtime lives in another (runtime) that starts out holding everything and only spins a language's runtime off into its own repo once that language actually needs it.

The full options analysis is in the design record; this page is the case that was made for going this way.

Shape

Two repos, cut at the line between "language-agnostic core" and "per-language parts".

generation stays one repo, probably forever. Writing a code generator is mostly string formatting — the TypeScript one we already have is small and has no unusual dependencies. Library generation adds some manifest templating and, for dylib, a bit of FFI tooling (cbindgen, abi_stable). None of that is heavy, so there's no reason to split it up:

generation/
  crates/codegen        comline-codelib-gen — every `code` generator
                        (rust, typescript, python, lua, luau, …); pure string emission
  crates/lib-rust       `lib` / `dylib` for rust — cbindgen, abi_stable      ┐ non-default
  crates/lib-python     `lib` for python — pyproject templating              │ members, pulled
  crates/lib-lua        `lib` for lua — rockspec templating                  │ in by the CLI
  crates/lib-node       `lib` for node — package.json + napi glue            ┘ behind features

A plain cargo build builds codegen and nothing else; each lib-* crate is opt-in with its own CI job, so a change to the Python lib generator never builds the Lua one. The registry that maps a language name to its generator moves into the CLI behind cargo features once the first lib-* crate exists — details in Design → Generation.

runtime starts as one repo and sheds weight over time. A language binding stays in runtime as long as it's cheap to keep there. It moves out when it needs to ship to its own registry on its own schedule, or when someone outside the core team takes ownership of it:

Now → Step 1 (in place, one repo):
runtime/
  crates/runtime        comline-runtime — `std` is a feature; the core_no-std fork is gone
  crates/std-extra      comline-runtime-std-extra — opt-in, out of the core build graph
  langs/c  langs/lua  langs/luau  langs/python
                        opt-in members; one CI job per language
  conformance/          schema + expected-behaviour corpus every runtime must pass

End state:
runtime/                comline-runtime + the light bindings (c, lua, luau)
runtime-python/         graduated — pyo3, PyPI wheels, its own release schedule
runtime-node/           napi, npm (starts out on its own)
runtime-<lang>/         … as each earns it

So the org grows by about one repo per genuinely heavy language — Python, Node, maybe Go or Swift down the line — not one per language.

How the two stay in sync. Generated code imports the runtime, so the library generator for a language writes a manifest (Cargo.toml, pyproject.toml, and so on) that pins a compatible comline-runtime version. generation keeps a small per-language table of "runtime package name, version range" that tracks the core ↔ runtime contract. It's data in a table, not a code dependency between the repos:

runtime releases vX
core ↔ runtime contract doc:  "IR feature Y needs runtime ≥ vX"
lib generator for X emits:    depends on comline-runtime-X  ">= vX"

The case for it

It matches how the two are actually built and used. The generator is a build tool. It runs on a developer's machine during comline generate, writes some files, and is never shipped anywhere. The runtime is the opposite — it ends up as a dependency in someone's real application, published to PyPI or npm or crates.io. The two get changed for different reasons, released on different schedules, and a CVE in one means something completely different from a CVE in the other. Separate repos line up with that reality; one repo papers over it.

The expensive parts stay quarantined. Almost all the weight is on the runtime side. A Python runtime needs pyo3, CPython headers, and a wheel build matrix across Python versions and operating systems. A Python generator needs none of that. If they share a workspace, everyone who touches the toolchain pays the CPython cost for no reason. Keep them apart and the generator repo stays light no matter how long the language list gets.

No repo explosion. Plenty of bindings are cheap to maintain — Lua, Luau, C. Those never need their own repo; they sit in runtime behind a feature flag and a CI job. Only the heavy ones move out. Twelve languages doesn't mean twelve repos, it means runtime plus two or three spin-offs.

Each side moves at its own pace. The generator can ship a fix without waiting on a runtime release. A graduated runtime-python can cut a patch release for a new-CPython wheel without anything else being involved.

The case against

Some changes naturally want both repos at once. Add an IR feature like streaming responses and you probably need the generator to emit new code and the runtime to handle the new call shape. Here that's two PRs across two repos plus a bump to the contract doc, done in order. One repo would have been one PR.

The link between the two is soft. The generator writes "depends on comline-runtime >= 1.4" into a manifest, but nothing verifies that's true at build time. If the generator and the runtime drift apart, the only thing that notices is the conformance suite. A hand-maintained "which runtime version does this generator target" table is exactly the kind of thing that quietly goes stale.

"Where's the Python code" has two answers. The generator is in generation, the runtime is in runtime or runtime-python. Anyone chasing an end-to-end Python bug is working across repos and keeping two checkouts aligned.

"Has this language earned its own repo yet" is a judgement call every time. There's no bright line, so each new heavy language reopens the same discussion.

Example projects

Scored against the same six traits: ✅ yes · 🟡 some languages · ❌ no.

gRPC is organised almost exactly this way. grpc/grpc holds the C core and the C++, Python, Ruby, PHP and Objective-C libraries that wrap it — the ones that are cheap to keep together. Java, Go, Node, C#, Swift, Kotlin, Dart and grpc-web each got their own repository (grpc/grpc-java, grpc/grpc-go, grpc/grpc-swift, and so on) once they had a real ecosystem and toolchain of their own, and each of those pairs the language's protoc plugin with its runtime. Lightweight bindings stay in the core repo; heavyweight ones graduate. That's Step 1 and Step 3 of this proposal, and gRPC has run it for years.

Trait
Neutral core in its own repo
All code generators in one repo
One repo per target language 🟡
Codegen + runtime together, per language
Heavy languages graduate; light ones stay bundled
One repo for the compiler + every language

Protocol Buffers does the same. protocolbuffers/protobuf ships the protoc compiler together with the C++, Java, Python, C#, Ruby, PHP and Objective-C runtimes. Go lives in protocolbuffers/protobuf-go and JavaScript was moved out into protocolbuffers/protobuf-javascript — a compiler-plus-core-runtimes mono-repo with specific ecosystems split off, each split repo carrying that language's plugin and runtime together.

Trait
Neutral core in its own repo
All code generators in one repo 🟡
One repo per target language 🟡
Codegen + runtime together, per language 🟡
Heavy languages graduate; light ones stay bundled
One repo for the compiler + every language

Apache Thrift is the counter-example. apache/thrift keeps the compiler and every lib/<language> implementation in one repository, so building it or contributing to it means dealing with the union of every language's toolchain at once. That single-tree-for-everything arrangement is exactly what this proposal's Step 1 moves away from.

Trait
Neutral core in its own repo
All code generators in one repo
One repo per target language
Codegen + runtime together, per language
Heavy languages graduate; light ones stay bundled
One repo for the compiler + every language

prost / tonic show the same split at crate granularity inside one Rust project: prost is the runtime, prost-build is the build-time code generator, and they're deliberately separate crates with separate dependency sets.

When this would be the right call

This is the conservative option, and it fits where the project is now: a core team that owns the whole toolchain, a language list that's mostly lightweight bindings, and no appetite for standing up a repo, a release pipeline, and a CI matrix for every language. It keeps per-language repos available as a later move without committing to them up front. If the language list is going to stay small-to-medium and mostly light, this is the pragmatic choice.