Multi-version generation
Status: implemented (core#26, cli#12, cli#13 all merged) · Follows Consumer generation configuration
Shipped: [generate].package_versions / [[generate.target]].package_versions =
"latest" (default) | "all" | version-or-hash list; latest uses the live
context, the rest walk the CAS chain (history module) and require a prior
build; {{package_version}} is a real layout var and mandatory when >1 version
is selected. Not shipped: --version one-off flag, COMLINE_GENERATE_VERSIONS,
per-historical-version {{spec_version}} (frozen config not in CAS).
Decisions (2026-08-30): (1) drop package_versions from the congregation —
selection lives only in comline.toml; (2) {{package_version}} for the live
working tree = the last commit's version (empty if never built); (3)
"all" is supported = every committed version, cost documented.
Use case
A package is at 0.5.0 today. A consumer's app was written against 0.3.0 and
also talks to a service still on 0.4.0. They want bindings for several
historical versions side by side:
That is what package_versions = [all] in the congregation and {{version}} in
the dead default_path template were always gesturing at. Neither is wired to
anything.
What already exists
Everything the feature needs is in place; it has just never been connected.
- CAS history walk.
cli/src/commands/diff.rsalready does the whole job for two versions:refs::read_ref(main_ref())→walk_history(store, head)(follow first parent, newest-first) →resolve(&history, spec)(HEAD| version string | full/short commit hash) →load_schemas(store, commit)→Vec<Vec<FrozenUnit>>viacas::blob::load_schema_from_tree. - Namespace from frozen units.
comline_core::schema::ir::frozen::unit::schema_namespace_as_path(&[FrozenUnit]) -> Option<String>(::→/).build_tree_from_schemastores every unit includingFrozenUnit::Namespace, so a CAS-loaded historical schema carries its own namespace — no liveSchemaContextneeded. - The generator loop.
gen_config::ResolvedTarget+find_generator+dest_foralready turn(namespace, ext, layout)into a path and write it. package_versions. Parsed intoLanguageDetails.versions: Vec<String>and frozen asFrozenUnit::CodeGeneration { name, versions }.[all]becomesversions = ["all"]. Nothing reads it.
Not blocked
Earlier notes listed this as "blocked — no CAS-historical read path." That was
wrong: diff.rs is the read path. The only genuinely missing work is wiring, a
version-selection design, and one behaviour decision about the live/dirty tree.
Design
1. Where the version list comes from
Consistent with the output-config split: the consumer chooses, in
comline.toml:
[[generate.target]]
language = "rust"
package_versions = "all" # or ["0.3.0", "0.4.0"] or "latest" (default)
- unset /
"latest"→ the working tree only (today's behaviour) ["0.3.0", "0.4.0"]→ exactly those committed versions"all"→ every committed version in the chain- (later, maybe) a semver range
">=0.3, <0.6", or"latest-3"
[generate] may set a default package_versions for every target.
2. The congregation's package_versions — dropped (decided)
Same mistake as generation_path: which historical versions to realise is a
consumer decision, and freezing it into FrozenUnit::CodeGeneration bakes a
consumer choice into the package's content hash.
- Remove
versionsfromLanguageDetails;FrozenUnit::CodeGenerationbecomes{ name }(just the declaredlanguage#lang_version). freezing.rs::interpret_assignment_code_generationstops accepting thepackage_versionsdetail key — a language's detail dict now takes no keys (rust#1.70.0 = {}); revisit whether the= { … }syntax is still worth keeping.comline newscaffold:rust#1.70.0 = {}. Update theconfig.idpfixtures in both repos and the.frozen/CAS goldens if any encode it.- Every
package_versions=[all]in docs/examples goes away.
A second small breaking change to the congregation, same spirit as core#25 — its
own comline-core PR, landed before the CLI work so the CLI builds against the
new FrozenUnit shape.
3. Live vs historical
"latest"keeps usingcompile_package()— the working tree, possibly ahead of the last commit, no CAS needed.- Any pinned version or
"all"requires.comline/(abuildmust have happened) and goes through thediff.rshistory machinery.
{{package_version}} for the live case (decided): the last commit's
version (walk_history first entry) if .comline/ exists, otherwise the empty
string. It can be stale if the working tree has uncommitted schema edits; that is
acceptable for v1 and revisited with the reproducibility work.
4. {{package_version}} in layout
- It becomes a real template variable (drop the "not available yet" guard in
ResolvedTarget::dest_for). - If more than one version is selected and
layoutdoes not contain{{package_version}}, that is an error — otherwise every version writes to the same paths and clobbers.
5. Which generator runs on old schemas
Today's generator (find_generator(language, lang_version) from the current
comline.toml / declared language), not a version-matched one. You want current,
idiomatic output for an old schema shape. No per-historical-version generator
selection.
6. CLI surface
Config-driven is enough; a --version <spec> flag is a nice one-off
(comline generate --target rust --version 0.3.0), overriding
package_versions for that run, subject to the same "needs --target when
several targets" rule as the other overrides. Env: COMLINE_GENERATE_VERSIONS
for parity, low priority.
7. comline clean
clean resolves the same config; if dest_for learns {{package_version}} and
clean passes the selected versions through, it prunes the multi-version tree
correctly. In practice removing the out root (the common case) already covers
it.
8. Factoring
walk_history / resolve / load_schemas are private to cli/src/commands/diff.rs.
Multi-version generate needs the same three. Move them to a shared cli module
(src/history.rs) that both diff and generate use. All the comline-core
API they touch is already public (cas::objects, cas::storage::Hash,
cas::{refs, ObjectStore}, frozen::cas::blob::load_schema_from_tree), so this
stays CLI-side — no comline-core change required, though a
comline_core::package::history module would be a reasonable home if other tools
want it.
Open questions (remaining)
- Semver-range selection (
">=0.3, <0.6","latest-3") — later, not v1. - With
package_versionsgone, isrust#1.70.0 = {}(empty dict) still the right syntax, or should a declared language just be a barerust#1.70.0/rust#1.70.0 = true? Grammar question for the congregation PR. - Does
diff.rs's position-paired schema model matter here? No — each version generates independently; no pairing.
Phasing
comline-corePR — dropversionsfromLanguageDetails/FrozenUnit::CodeGeneration; stop parsingpackage_versions; update scaffold references, fixtures, goldens. (Breaking; lands first.)cliPR —history.rs— factorwalk_history/resolve/load_schemasout ofcommands/diff.rsintosrc/history.rs;diffuses it. Pure refactor, no behaviour change.cliPR — selection —[[generate.target]].package_versions(+[generate]default) parsing"latest"(default) /["x","y"]/"all"ingen_config; resolve againsthistory.rs.cliPR — generate the versions — for"latest", unchanged (compile_package,{{package_version}}= last commit's version). For the rest: walk history,load_schemasper resolved version, run the generator loop with{{package_version}} = commit.versionand the namespace fromschema_namespace_as_path. Require{{package_version}}inlayoutwhen the selection resolves to more than one version; drop the guard indest_for. Pinned/allselections error without.comline/.cliPR — edges —--version <spec>one-off override;cleanthreads the selected versions throughdest_for; docs.