Skip to content

feat(cli): add baml bridge with passive hash-based staleness - #4394

Open
hellovai wants to merge 4 commits into
vbv/b-1145-output-typefrom
vbv/b-1145
Open

hellovai wants to merge 4 commits into
vbv/b-1145-output-typefrom
vbv/b-1145

Conversation

@hellovai

Copy link
Copy Markdown
Contributor

Closes B-1145.

The codebase, the concept docs, the agent skill, and every runtime crate call the generated client libraries bridges. The CLI never said the word: baml generate was a bare top-level verb with nowhere to hang related operations. Three things land together because they share one mechanism.

1. baml bridge

Command Behavior
baml bridge generate what baml generate does today
baml bridge generate --check exit non-zero if a bridge is stale; writes nothing
baml bridge add <TARGET> write [generator.<name>], then print how to install it
baml bridge install print (never run) the pinned install command
baml bridge list configured bridges, output dirs, freshness

baml generate and baml generate add keep working as hidden aliases; they appear in existing scripts and CI jobs.

2. The install command is discoverable

A bridge runtime must match the CLI's CANONICAL_VERSION exactly, but that mapping lived in four unsynchronized places and none printed "run exactly this". bridge/install.rs is now the single table: package identity, registry version spelling, peer packages, and ranked install candidates with the lockfile evidence that chooses one. A contract test compares it against scripts/baml-language-version across release and nightly versions (mutation-checked), so a release-time rename fails the build instead of printing an uninstallable command.

It prints and never executes. The correct host-manifest edit is unknowable (uv vs poetry vs pdm vs pipenv vs pip; pnpm vs yarn vs bun vs npm) and on its own is never sufficient, because it must be followed by a lock-resolving step. A manifest edited without its lockfile fails at import time and in CI, which is worse than printing a line. No update verb: the version is pinned exactly, so install and upgrade are the same string.

Peer packages are why this needs a table. The Python runtime cannot depend on pydantic (that would pick v1 or v2 for the user), but generated code emits import pydantic — so installing the bridge alone leaves a project that fails at import. The requirement travels in the printed command.

3. Staleness compares inputs, never regenerated output

The manifest already written into every bridge goes to schema 3, carrying an input fingerprint, the toolchain version, and the generator name. The fingerprint is a length-framed sha256 over baml.toml's verbatim bytes plus every .baml file keyed by its /-separated relative path.

Two consequences, both the point:

  • Codegen determinism stops mattering. A gofmt upgrade that reflows generated code leaves the inputs untouched, where a git diff guard would false-positive. (Go codegen genuinely is not byte-deterministic — it shells out to the host gofmt.)
  • No compile is needed, so --check is viable in a pre-commit hook and the passive check stays off the baml check latency budget. Measured warm, 12 runs: 745 ms with a configured bridge vs 734 ms without.

Excluded from the hash: generator config (already inside the hashed baml.toml, embedded verbatim into every bridge, so whole-file hashing is exact rather than over-broad); output_dir, which changes where a bridge lands and not what is in it; and the toolchain version, stored as its own field so skew reports "built by BAML 0.14.2 but this toolchain is 0.15.0" rather than an opaque hash mismatch.

One comparison function serves --check, bridge list, and the passive warning, so they cannot disagree about what stale means. They differ only in depth: the manifest check is one small file read; the verify-files check also re-hashes every recorded file, turning "assume the generated files are untouched" into a verified fact.

Every added manifest field is optional, so a schema-2 manifest still loads and reports provenance unknown — treated as cannot prove stale, never as stale, or every project that hadn't regenerated since upgrading would start failing CI. Likewise "never generated" is not "out of date": --check reports it, the passive warning does not, so a fresh project isn't nagged before it opts in.

The passive warning follows skill_check.rs: an RAII guard working on a background thread and printing in Drop, whitelisted to check/run/test/pack/fmt/describe. playground and lsp are excluded as long-running editor surfaces; bridge excludes itself. No env-var opt-out; if one is wanted it belongs in baml.toml.

--check exits 6 (ExitCode::BridgeStale) rather than the generic 4, so CI can tell a stale bridge from a project that doesn't compile.

Adds [bridge] vcs = "ignore" | "commit", defaulting to today's behavior. In commit mode the writer emits a header-only .gitignore so the tree and its manifest are both tracked — which is what lets --check run from a clean checkout, the case that motivated this work.

Verification

571 tests pass (503 lib + 8 new bridge e2e + existing e2e suites); fmt and clippy clean. Manual pass in a scratch project confirmed each transition: never-generated → exit 6, generate → exit 0, source edit → passive warning on baml check + exit 6, hand-edited generated file → caught at verify depth only, regenerate → quiet.

Third of three stacked PRs. Based on #4393 — review #4392 and #4393 first.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Jm6FCeSHxhtM2S6oTQR5B

@linear

linear Bot commented Aug 13, 2026

Copy link
Copy Markdown

B-1145

@vercel

vercel Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
beps Ready Ready Preview Aug 19, 2026 10:03pm
promptfiddle2 Ready Ready Preview Aug 19, 2026 10:03pm

Request Review

@coderabbitai

coderabbitai Bot commented Aug 13, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f59782bb-40ec-473b-91ab-1315524f89f6

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 13, 2026 06:31 Inactive
@github-actions

github-actions Bot commented Aug 13, 2026 •

Copy link
Copy Markdown

Binary size checks passed

✅ 7 passed

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 31.9 MB 12.7 MB file 31.7 MB +198.9 KB (+0.6%) OK
✅ packed-program Linux 🔒 25.0 MB 9.2 MB file 24.9 MB +176.8 KB (+0.7%) OK
✅ baml-cli macOS 🔒 25.6 MB 11.2 MB file 25.5 MB +115.0 KB (+0.5%) OK
✅ packed-program macOS 🔒 20.8 MB 8.2 MB file 20.6 MB +207.1 KB (+1.0%) OK
✅ baml-cli Windows 🔒 27.3 MB 11.4 MB file 27.2 MB +186.8 KB (+0.7%) OK
✅ packed-program Windows 🔒 21.9 MB 8.3 MB file 21.7 MB +151.0 KB (+0.7%) OK
✅ bridge_wasm WASM 21.4 MB 🔒 5.4 MB gzip 5.3 MB +70.5 KB (+1.3%) OK

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.


Generated by cargo size-gate · workflow run

@vercel
vercel Bot temporarily deployed to Preview – beps August 13, 2026 06:54 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 13, 2026 07:02 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 13, 2026 16:18 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 13, 2026 16:25 Inactive
@hellovai
hellovai temporarily deployed to boundary-tools-prod August 13, 2026 17:26 — with GitHub Actions Inactive
@hellovai
hellovai temporarily deployed to boundary-tools-prod August 13, 2026 17:26 — with GitHub Actions Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 13, 2026 17:27 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 13, 2026 17:34 Inactive
@hellovai
hellovai temporarily deployed to boundary-tools-prod August 13, 2026 17:45 — with GitHub Actions Inactive
@hellovai
hellovai temporarily deployed to boundary-tools-prod August 13, 2026 17:45 — with GitHub Actions Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 13, 2026 17:46 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 13, 2026 17:49 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 13, 2026 17:56 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 13, 2026 22:15 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 13, 2026 22:21 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 14, 2026 07:58 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 14, 2026 08:04 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 15, 2026 00:33 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 15, 2026 00:42 Inactive
Closes B-1145.

The codebase, the concept docs, the agent skill, and every runtime crate call
the generated client libraries **bridges**. The CLI never said the word:
`baml generate` was a bare top-level verb with nowhere to hang related
operations. Three things land together because they share one mechanism.

**1. `baml bridge`.**

    baml bridge generate           write each configured bridge
    baml bridge generate --check   exit non-zero if one is stale; writes nothing
    baml bridge add <TARGET>       write [generator.<name>], then print how to install it
    baml bridge install            print (never run) the pinned install command
    baml bridge list               configured bridges, output dirs, freshness

`baml generate` stays registered but no longer generates: it exits non-zero
naming its replacement (`baml generate add` names `baml bridge add`
specifically). It swallows every old argument so the message is what people
see, rather than clap's bare "unrecognized subcommand" — which would leave
someone with a broken CI job and no pointer to the fix.

The rename is not contained to the CLI. Sixteen in-repo callers invoked
`baml generate` — two GitHub workflows, the integ-test runners,
`scripts/smoke-go-release.py`, `tools/bump-version.py`, the C# consumer
`verify.sh` scripts, and several READMEs — and all now call `baml bridge
generate`. The Python generator banner told users to re-run `baml generate`
in every emitted file; it names the new command too.

**2. The install command is discoverable.** A bridge runtime must match the
CLI's `CANONICAL_VERSION` exactly, but that mapping lived in four
unsynchronized places and none of them printed "run exactly this".
`bridge/install.rs` is now the single table: package identity, registry
version spelling, peer packages, and ranked install candidates with the
lockfile evidence that chooses one. A contract test compares it against
`scripts/baml-language-version` across release *and* nightly versions, so a
release-time rename fails the build instead of printing an uninstallable
command.

It prints and never executes. The correct host-manifest edit is unknowable
(uv vs poetry vs pdm vs pipenv vs pip; pnpm vs yarn vs bun vs npm) and on its
own is never sufficient, because it must be followed by a lock-resolving step.
A manifest edited without its lockfile fails at import time and in CI, which
is worse than printing a line. There is no `update` verb: the version is
pinned exactly, so install and upgrade are the same string.

Peer packages are why this needs a table. The Python runtime cannot depend on
`pydantic` (that would pick v1 or v2 for the user), but generated code emits
`import pydantic`, so installing the bridge alone leaves a project that fails
at import. The requirement travels in the printed command.

**3. Staleness compares inputs, never regenerated output.** The manifest
already written into every bridge gains an input fingerprint, the toolchain
version, and the generator name. The fingerprint is a length-framed sha256
over `baml.toml`'s verbatim bytes plus every `.baml` file keyed by its
`/`-separated relative path.

Two consequences, both the point. Codegen determinism stops mattering: a
`gofmt` upgrade that reflows generated code leaves the inputs untouched, where
a `git diff` guard would false-positive. And no compile is needed, so
`--check` is viable in a pre-commit hook and the passive check stays off the
`baml check` latency budget — measured warm on an 800-file project, `baml
check` costs 1087 ms with a configured bridge vs 1117 ms without, and `baml
fmt` 582 ms vs 571 ms.

The fingerprint is taken from the `ResolvedProject` the compiler just loaded,
not re-derived from the settings root. Re-deriving names a different file set
whenever `--project` points at a source tree that is not `root/baml_src`,
which reported the bridge out of date immediately after generating it, with no
way to converge.

Excluded from the hash: generator config (already inside the hashed
`baml.toml`, embedded verbatim into every bridge, so whole-file hashing is
exact rather than over-broad); `output_dir`, which changes where a bridge
lands and not what is in it; and the toolchain version, stored as its own
field so skew reports "built by BAML 0.14.2 but this toolchain is 0.15.0"
rather than an opaque hash mismatch.

One comparison function serves `--check`, `bridge list`, and the passive
warning, so they cannot disagree about what stale means. They differ only in
depth: the manifest check is one small file read; the verify-files check also
re-hashes every recorded file, turning "assume the generated files are
untouched" into a verified fact.

A bridge is fresh or it is stale — there is no third answer. A tree that
records no provenance is one we cannot vouch for, so it reports stale rather
than "passed, but I could not verify", which is the weakest thing a CI gate
can say. Schema 2 stays readable for ownership so the first regenerate after
upgrading does not fail on an existing bridge; the schema-1 string inventories
are gone.

The passive warning follows `skill_check.rs`: an RAII guard working on a
background thread and printing in `Drop`, whitelisted to
check/run/test/pack/fmt/describe. `playground` and `lsp` are excluded as
long-running editor surfaces; `bridge` excludes itself. It also reports a
configured-but-absent bridge, which is the fresh-clone case — the default
`vcs = "ignore"` keeps the tree out of the repo, so a clone has no bridge at
all. A newly initialized project is still never nagged, because `baml init`
writes its generator lines commented out and the no-generators check returns
first. No env-var opt-out; if one is wanted it belongs in `baml.toml`.

`--check` exits 6 (`ExitCode::BridgeStale`) rather than the generic 4, so CI
can tell a stale bridge from a project that does not compile.

`bridge list` and `--check` count `[generator]` sections that failed
validation and say so, instead of silently showing fewer bridges than are
configured. Full spanned diagnostics stay with `bridge generate`, which fails
on them.

Adds `[bridge] vcs = "ignore" | "commit"`, defaulting to today's behavior. In
`commit` mode the writer emits a header-only `.gitignore` so the tree and its
manifest are both tracked — which is what lets `--check` run from a clean
checkout, the case that motivated this work.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_018Jm6FCeSHxhtM2S6oTQR5B
@vercel
vercel Bot temporarily deployed to Preview – beps August 19, 2026 20:20 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 19, 2026 20:28 Inactive
@vercel
vercel Bot temporarily deployed to Preview – beps August 19, 2026 21:56 Inactive
@vercel
vercel Bot temporarily deployed to Preview – promptfiddle2 August 19, 2026 22:03 Inactive

This branch was previously deployed

3 inactive (1 outdated) deployments
Preview – promptfiddle2 — a7503d42 Deployed Aug 19, 2026 by vercel[bot]
Preview – beps — a7503d42 Deployed Aug 19, 2026 by vercel[bot]
boundary-tools-prod — 490b553b Deployed Aug 13, 2026 by hellovai via review #38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants