We had 27 packages on Bazel out of 104. That’s 26% coverage. And for months, the pitch was “migrate more packages” — get BUILD files passing, get tests running, inch the number up. We got it to 54. Good progress. Real work.
But bazel query only sees packages with BUILD files. And if your graph has holes, you can’t ask “what does changing lib/fortknox affect?” because half the answers are invisible. A graph with holes isn’t a graph. It’s a few disconnected islands.
Today I stopped trying to fill in the holes by migrating packages. I filled them by cheating.
The trick nobody talks about
Bazel has a tag called manual. A target with tags = ["manual"] is:
- Visible to
bazel query— the dependency graph sees it - Invisible to
bazel build //...andbazel test //...— wildcards skip it
Read that again. You get the graph without any obligation to actually build or test anything. The target is a ghost — it exists for the graph and nothing else.
# AUTO-GENERATED stub for dep graph analysis. DO NOT EDIT.
# gazelle:ignore
load("@aspect_rules_py//py:defs.bzl", "py_library")
py_library(
name = "mai_config",
srcs = glob(
["**/*.py"],
allow_empty = True,
),
imports = [".."],
tags = ["manual"],
visibility = ["//visibility:public"],
deps = [
"//bazel/stubs:torch",
"@pypi//hydra_core",
"@pypi//pydantic",
],
)
This BUILD file contributes zero to Bazel build/test coverage. But bazel query rdeps(//..., //mai_config/...) now correctly reports every package that depends on mai_config — and transitively, every package that depends on torch.
The dep graph doesn’t care if a target actually builds. It only cares that the edges are declared. So I declared them — for every package in the monorepo.
What I actually built
A generator script that reads pyproject.toml and emits BUILD files with tags = ["manual"]. The whole thing.
The generator
bazel/tools/generate_manual_builds.py does three things:
- Read a package’s
pyproject.toml - Classify each dependency into one of three buckets
- Emit a
py_librarytarget with the right labels
The classification is the interesting part:
| Bucket | What | Bazel label |
|---|---|---|
| Workspace dep | Another monorepo package | //path/src/import_name |
| System dep | torch, triton, vllm, etc. | //bazel/stubs:torch |
| External dep | Everything on PyPI | @pypi//package_name |
Workspace deps need their import name, not their package name. mai-config lives at mai_config/src/mai_config — the import is mai_config, not mai-config. The generator discovers import names by listing {pkg}/src/*/ on disk — whatever directories exist under src/ are the import names.
External deps need normalization for @pypi// labels. pyproject.toml might say mai-config or mai_config or Mai-Config — they’re all the same package. Normalize to lowercase, replace hyphens and dots with underscores to match @pypi// label conventions, done.
System stubs
The clever bit. Torch, triton, flash-attn, vllm — these are system-installed in Docker, not in uv.lock. So @pypi//torch doesn’t resolve. And you can’t just omit them — then the graph is missing those edges.
Solution: empty stub targets.
# bazel/stubs/BUILD.bazel
py_library(
name = "torch",
tags = ["manual"],
visibility = ["//visibility:public"],
)
That’s it. A py_library with no srcs, no real deps. A placeholder that exists only so //bazel/stubs:torch resolves. Fourteen of these for the system-installed packages: torch, triton, flash_attn, vllm, sglang, xgrammar, and friends.
The stub says: “torch exists. I am not it. But if you need to draw an edge to torch, point it here.” The graph gets the topology right even though the target has no substance.
Gazelle doesn’t know about the stubs
Here’s a gap I didn’t notice until someone asked the obvious question: what happens when a fully-migrated package imports torch?
For manual stubs, # gazelle:ignore means Gazelle never touches the BUILD file. We write //bazel/stubs:torch by hand, done. But for Gazelle-managed packages — the ones we actually migrated — Gazelle is in charge of the deps list. And Gazelle has no idea what import torch maps to.
Torch isn’t in gazelle_python.yaml (the manifest that maps Python imports to Bazel targets). There’s no gazelle:resolve directive for it anywhere in the repo. So when Gazelle encounters import torch, it shrugs. The developer either annotates the import with # gazelle:ignore torch or manually adds //bazel/stubs:torch to the BUILD file and hopes the next Gazelle run doesn’t clobber it.
The evidence is scattered across the codebase: only 3 files have # gazelle:ignore torch annotations. 15+ BUILD files have manually added //bazel/stubs:torch deps. Zero gazelle:resolve directives for torch exist. Everyone reinvents the workaround independently.
The fix is one line in the root BUILD.bazel:
# gazelle:resolve py torch //bazel/stubs:torch
That tells Gazelle: “when you see import torch, resolve it to the stub.” Works globally for every package. Same trick works for all 14 system stubs — triton, flash_attn, vllm, the whole set.
One nice thing falls out of this for free: transitive deps just work. If mai_config depends on //bazel/stubs:torch and mai_cluster depends on mai_config, Bazel propagates the torch edge through the graph automatically. mai_cluster doesn’t need its own stub dep. The py_venv_test with include_system_site_packages=True handles runtime availability — the graph just needs the edges, and transitivity gives them.
The .bazelignore problem
Here’s where it got hairy.
Bazel has a .bazelignore file — directories listed there are completely invisible. Not just “not built.” Invisible. Can’t query them. Can’t reference them. They don’t exist.
We had ~70 packages in .bazelignore. Every unmigrated package was ignored, because if Bazel wandered into those directories and found no BUILD file, it would error. Reasonable at the time.
But now I’m adding BUILD files to those directories. Which means they need to come OUT of .bazelignore. All ~70 of them.
This is where you hold your breath and see if anything breaks. (Spoiler: the existing builds still pass. Because tags = ["manual"] means these new targets are invisible to bazel build //.... The graph changed. The build didn’t.)
Intermediate BUILD files
One gotcha: directories that were in .bazelignore might not have BUILD files at any level. Once you remove them from .bazelignore, Bazel can wander into them. The monorepo has directories like agents/, apps/, lib/, posttraining/ that contain packages but aren’t packages themselves. Adding an empty BUILD file to each makes them proper Bazel packages — clean, explicit, no surprises:
# agents/BUILD.bazel
# Intermediate BUILD file for Bazel package resolution.
Literally empty. Just enough to make the directory a proper Bazel package.
Edge cases that bit me
Every monorepo script has a graveyard of edge cases. Here are the ones that drew blood:
codeshield — most packages have their source at {pkg}/src/{import_name}/. codeshield has it at lib/codeshield/src/codeshield/. The BUILD file needs to go at the import root, not the package root. The generator handles this by actually looking at what’s on disk rather than assuming the layout.
Self-referential deps — a package’s pyproject.toml sometimes lists itself. research_utils depends on research-utils. That’s a cycle of length 1. The generator filters these out.
Packages without pyproject.toml — in a monorepo, each package directory has its own pyproject.toml declaring its dependencies. Some directories look like packages — they’re sitting in lib/ or apps/ — but have no pyproject.toml, no source. The generator skips them and moves on.
# gazelle:ignore — every generated BUILD file gets this directive at the top. Without it, Gazelle (Bazel’s auto-generator for fully migrated packages) would overwrite the manual stubs with whatever it thinks the BUILD file should be. The stubs are intentionally incomplete — Gazelle would “fix” them by adding deps it can’t resolve, breaking everything.
The numbers
Before and after, same repo, same code:
| Metric | Before | After |
|---|---|---|
| Packages with BUILD files | 27 (26%) | 104+ (~100%) |
| Total Bazel targets | ~400 | 986 |
| Manual stub targets | 0 | 341 |
bazel query coverage |
26% | ~100% |
Some queries I couldn’t run before:
# "What depends on mai_config?"
$ bazel query 'rdeps(//..., //mai_config/...)' | wc -l
65
# "What depends on mai_nano?"
$ bazel query 'rdeps(//..., //lib/mai_nano/...)' | wc -l
319
# "What depends on fortknox?"
$ bazel query 'rdeps(//..., //lib/fortknox/...)' | wc -l
60
# "How many test targets exist?"
$ bazel query 'kind(py_test, //...)' | wc -l
166
That rdeps on fortknox is the one that matters for the pitch. Change fortknox → 60 out of 986 targets are affected → 94% of targets can be skipped. That’s the headline number.
And the existing builds? Still green:
$ bazel build //ci/... //lib/mai_nano/...
# ✅ passes — manual targets are invisible to build wildcards
Proving it works
People hear “manual stubs” and “generated BUILD files” and their brain goes: “okay but does any of this actually work?” Fair. Here’s exactly what needs to pass, what we tested, and why the bar for success is lower than you think.
The key insight
The goal is a complete dependency graph queryable via bazel query rdeps(). Not building. Not testing. The graph. So the validation question isn’t “does everything build?” — it’s “does the graph load, and can you traverse it?”
Think of it like a city map. You don’t need every building to be structurally sound to draw the map. You just need the addresses and connections to be correct. The stubs are pins on a map — they mark “this package exists, and it connects to these other packages.” That’s enough for routing.
What MUST pass (CI-gated)
These run on every PR. If any fail, the graph is broken.
1. bazel query //... loads without errors. This is the foundational check. If even one BUILD file has a typo, missing dep label, or bad glob, this fails. Every target in the workspace must parse. We loaded 986 targets across ~290 BUILD files.
2. bazel build //... still passes. Proves the manual stubs don’t break existing real builds. The tags = ["manual"] should exclude stubs from build wildcards — that’s the whole contract. 1174 actions, all pass.
3. bazel test //... --test_tag_filters=-integration,-torch still passes. Same argument. The stubs are invisible to test wildcards. 121/121 tests pass, 1858/2051 test cases pass.
What MUST work (proven locally)
These aren’t CI-gated yet, but they’re the actual proof of the goal.
4. rdeps() queries resolve across packages. This is THE validation. If you can query “what depends on mai_config” and get 65 packages back, the graph works. Not “a graph works.” The graph — the one that covers the entire monorepo.
5. Cross-package dep chains resolve. rocket → mai_config → torch stub. The chain traverses workspace deps, through to system stubs. This is what makes targeted test selection possible — the query engine can follow edges across package boundaries, through real targets and stubs alike.
What does NOT need to pass
This is where people’s intuition is wrong.
6. The stubs don’t need to build. They’re manual tagged. bazel build //rocket/src/rocket would fail because the stub is incomplete — it has the dep edges for the graph, but it’s not a fully wired-up build target. That’s fine. We never intend to build these. The BUILD file exists only for bazel query to read its deps = [...] attribute.
7. @pypi// labels resolve via the hub, not at build time. External deps like @pypi//pydantic are registered through uv.lock and resolve during bazel query. But the manual stubs never build those deps — the targets are manual tagged, so the resolved labels are just edges in the graph. No fetching, no compilation.
The receipts
| Check | Where | Result | What it proves |
|---|---|---|---|
bazel query //... |
Local + CI | ✅ 986 targets, ~290 BUILD files | BUILD files all parse |
bazel build //... |
Local + CI | ✅ 1174 actions | Stubs don’t break real builds |
bazel test //... |
Local + CI | ✅ 121/121 tests | Stubs don’t break real tests |
rdeps(mai_config) |
Local | ✅ 65 packages | Graph traversal works |
rdeps(mai_nano) |
Local | ✅ 319 packages | Deep transitive deps resolve |
rdeps(fortknox) |
Local | ✅ 60 packages | Cross-lib deps work |
deps(rocket) |
Local | ✅ Full chain | Workspace → system → external all resolve |
The first three run in CI every PR. The last four are the “graph works” proof — we ran them locally, and the CI annotation step will exercise rdeps() on every PR going forward.
The bar for success was lower than expected. You don’t need builds to pass. You need the graph to load. And it does.
What this actually enables
This was never about BUILD files. It was always about what you can ask once the graph is complete.
Targeted test selection. Change mai_config? Run tests in the 65 packages that depend on it. Not all 166 test targets. The current system (find_changed) does package-level DFS through pyproject.toml deps. Bazel does target-level precision. The difference is “run all tests in every affected package” vs “run the specific tests whose transitive deps changed.”
Scoped pyright. The merge queue currently runs pyright on the entire repo. 25 minutes. With the dep graph, scope it to affected packages. Leaf package change? 2-3 minutes. Core lib change? 8-12 minutes. Still better than 25.
GPU test targeting. GPU runners are expensive. Don’t spin one up because someone edited a README. The graph knows which changes touch GPU-tagged test targets and which don’t.
Impact analysis in PRs. I added a CI annotation step — informational, continue-on-error: true — that shows affected targets in the PR summary. “This change affects 60/986 targets, ~94% skip rate.” Before you merge, you know the blast radius.
Migration planning. This is the recursive one. The graph tells you which packages are ready for full Bazel migration — all their deps are already migrated. No spreadsheet. Just a query.
The superset property
One thing I worried about: the dep graph isn’t the same across environments. On Linux with GPU, mai_kernels imports CUDA extensions. On macOS, those imports don’t exist. Three different runtime dependency graphs.
The BUILD file graph is a superset. It declares every dependency that exists in any environment. If package A depends on B only on Linux GPU, the BUILD file still says deps = ["//B"].
That means bazel query rdeps() might occasionally over-include — telling you to run a test that would actually be skipped at runtime. But it will never under-include.
Over-inclusion is fine. Under-inclusion breaks CI. The superset is a feature.
What could go wrong
I’d be lying if I said this was clean.
BUILD file drift. When someone adds a dependency to pyproject.toml, the generated BUILD file is now stale. For fully-migrated packages, Gazelle handles this. For manual stubs, nobody does. We need a CI check: “does the BUILD file match pyproject.toml?” Run the generator, diff the output, fail if they diverge. Without this, the graph silently rots.
Circular dependencies. Python tolerates circular imports at runtime. Bazel does not tolerate circular deps. If A deps B and B deps A, you can’t express it. A few of these exist in the monorepo. The generator currently handles them by… not handling them. They’ll need manual resolution.
lib/bus and the C++ chain. The stubs work for Python-level deps. But lib/bus is C++/Cython — its real deps can’t be captured in a py_library target. The stub captures its Python-level deps (what its consumers import), not its build-level deps. Incomplete, but better than absent.
Gazelle conflicts. If someone runs Gazelle on a package that has a manual stub, Gazelle will try to regenerate the BUILD file. The # gazelle:ignore directive prevents this, but only if Gazelle respects it. Gazelle does respect it, but it’s one more thing to know.
The metric shift
Here’s what I think the team should be tracking:
| Old metric | New metric |
|---|---|
| Packages on Bazel (54/101) | Packages in dep graph (104/104) |
| Bazel test coverage | CI skip rate (% of tests correctly skipped) |
| Migration velocity | Time-to-feedback on merge queue |
A package with tags = ["manual"] scores zero on the old metrics and 100% on the new ones. That’s not a contradiction — it’s a reframe. The old metrics measure migration progress. The new metrics measure what the migration is for.
The punchline
We spent months migrating packages to Bazel. Good work. Necessary work. But the value was never “now we build with Bazel.” The value is “now Bazel knows our dependency graph, and that graph makes CI smart.”
A generator script, 14 stub targets, and removing 70 lines from .bazelignore — that’s what it took to go from 26% graph coverage to ~100%. The manual-tagged targets cost nothing to run (they don’t run), break nothing (they’re invisible to build //...), and are cheap to maintain (regenerate from pyproject.toml).
The full graph is the product. Everything else is scaffolding.
Stop counting packages on Bazel. Start counting edges in the graph.
Previous posts in this series: How Bazel Resolves Python Deps (and Why torch Breaks Everything), Dependency Graph Cascade Analysis, Your Monorepo Is a Graph. Those covered the theory and the analysis. This one covers what happened when we actually built it.