Infrastructure without enforcement is just documentation.
That’s the uncomfortable truth I ran into this week. We spent the last post getting excited about what a full dep graph enables — scoped pyright, GPU test targeting, precise test selection. All of it built on one assumption: the BUILD files are correct.
They’re not.
The quiet rot
Here’s how it happens. A developer adds azure-storage-blob to their pyproject.toml because they need it at runtime. They run their tests locally — works fine, because uv resolves everything from the lockfile. They push a PR. CI passes. They merge.
Nobody runs bazel run //bazel:gazelle. Nobody regenerates the BUILD files. The @pypi//azure_storage_blob edge never appears in the Bazel dep graph.
And nothing breaks. Not today. The BUILD files are wrong, and nothing breaks, because nothing was querying the BUILD files for correctness. Bazel builds still pass (the affected target might be manual-tagged, or the dep is transitive through something else). Tests still pass (they run via pytest, not Bazel). The graph silently diverges from reality.
This is fine when BUILD files are a side project. When 26% of your monorepo has BUILD files and the dep graph is a curiosity, a wrong edge is trivia. Nobody’s making decisions based on it.
But we’re not there anymore. The graph is 100% now. CI is starting to use rdeps() for test selection. Pyright scoping queries the graph. GPU targeting will query the graph. Every wrong edge is a wrong answer to a real question.
A map of 26% of the city is a tourist guide — wrong streets don’t matter much. A map of 100% that your GPS relies on? Every wrong street causes a wrong turn.
What I found on main
I wrote a script to check. bazel/tools/check_build_drift.py — compares pyproject.toml declared deps against @pypi// deps in BUILD files for every migrated package. Ran it against current main.
Six packages. Thirteen missing dep edges.
| Package | Errors | What’s Missing |
|---|---|---|
caas_utils |
7 | azure-identity, azure-storage-blob, 4x opentelemetry, pyte |
ci |
1 | py-redis |
dataprocessing/datautils |
2 | azure-storage-blob, fire |
dataprocessing/item_response_theory |
1 | pandas |
lib/mai_nano |
1 | msgspec |
service_utils |
1 | opentelemetry-instrumentation-redis |
Out of 26 checked packages, 6 had errors. That’s a 23% drift rate. On a repo that’s been actively maintaining BUILD files for months.
Now — caveat. These are pyproject.toml-vs-BUILD divergences, not necessarily bugs. Gazelle generates deps from imports, not from pyproject.toml. If caas_utils declares pyte in pyproject.toml but never does import pyte anywhere, Gazelle correctly omits it from the BUILD file. The dep is a runtime thing — maybe loaded dynamically, maybe only used in some optional code path.
So the 13 “errors” are a mix of:
- Actual drift (dep added to pyproject.toml, BUILD not regenerated)
- Intentional divergence (dep declared but not imported)
- False positives from the pyproject-vs-import mismatch
The point isn’t that all 13 are bugs. The point is that nobody knew until we checked. Six months of merges, and nobody had a way to ask “are the BUILD files still telling the truth?”
Two layers of enforcement
The drift detector has two modes because there are two questions:
Layer 1: Is gazelle up to date? Run gazelle. Diff the BUILD files against what’s committed. If they differ, someone changed imports without regenerating. This is ground truth — gazelle is the canonical generator, so if its output differs from what’s checked in, the checked-in version is stale.
This catches everything. It’s also slow — you need Bazel, you need the full repo, gazelle has to actually run.
Layer 2: Do pyproject.toml and BUILD agree? Static comparison. Parse pyproject.toml deps, parse @pypi// labels from BUILD files, diff the sets. No Bazel needed. Fast enough to run on every PR.
This catches the common case — “I added a dep to pyproject.toml and forgot to regenerate.” It misses things gazelle would catch (like import changes without pyproject.toml changes). But it runs in seconds instead of minutes, and it gives you categorized output: this dep is missing, this dep is a known system package (torch), this dep is a type stub.
class DriftSeverity(StrEnum):
ERROR = "error" # true drift, should block CI
WARNING = "warning" # expected divergence (torch, type stubs)
INFO = "info" # informational
The severity classification matters. torch showing up as “in pyproject.toml but not in BUILD” isn’t drift — it’s expected. Torch is system-installed, lives behind //bazel/stubs:torch, and Gazelle resolves it via # gazelle:resolve. The script knows this and classifies it as a warning, not an error. Same for type stub packages like types-jsonschema — they’re for pyright, never imported at runtime, correctly absent from BUILD files.
Without the classification, every run would have a pile of false positives drowning out the real drift. With it, errors mean “go fix this” and warnings mean “we know, it’s fine.”
The CI integration
The existing Bazel CI workflow (.github/workflows/bazel_build_test.yml) already runs a gazelle freshness check. But it only triggered on changes to .bazel files and bazel/**. See the gap?
If you change pyproject.toml — add a dependency — but don’t touch any BUILD file, the Bazel CI never runs. The drift is invisible until the next nightly build or until someone manually runs the check.
Fix: add **/pyproject.toml and uv.lock to the trigger paths. Now any dependency change triggers the full Bazel workflow, including the gazelle freshness check.
The enhanced error reporting emits per-file GitHub Actions annotations:
::error file=caas_utils/pyproject.toml::
'azure-identity>=1.12.0' in pyproject.toml but no @pypi//azure_identity in BUILD files
These show up inline in the PR diff, right next to the pyproject.toml change that caused the drift. The developer doesn’t have to dig through CI logs — the problem is annotated exactly where it happened.
The immune system analogy
Here’s the thing that took me a while to articulate. The drift detector doesn’t use the dep graph. It doesn’t run bazel query. It doesn’t traverse rdeps(). It’s not one of the “look what the graph enables” tools from the previous post.
It protects the dep graph.
Every other tool in the series — test selection, pyright scoping, GPU targeting — assumes the graph is accurate. They query it, they trust the answer, they make CI decisions based on it. If the graph is wrong, they’re wrong. Silently wrong. The kind of wrong where tests that should run don’t run, and you find out when something breaks in production.
The drift detector is the immune system. It doesn’t do any of the interesting work. It just makes sure the infrastructure that does the interesting work stays honest.
This is the boring maintenance post. After the exciting “look what the graph enables” post, here’s the unsexy work that makes the sexy work reliable. It’s the least interesting tool in the series and arguably the most important one.
Open questions
This isn’t fully solved. Some things I’m still thinking about:
False positive rate. Gazelle works from imports. pyproject.toml has declared-but-not-imported deps. These are real divergences in a strict sense — pyproject.toml says “I need this” and BUILD says “no you don’t.” But they’re not bugs. Do we need a # drift:ignore annotation in pyproject.toml for intentional divergences? Or do we just tune the severity to warnings?
Where does it run? Right now the gazelle freshness check is inline in the Bazel CI workflow. The static analysis script (check_build_drift.py --analysis-only) could be a separate, faster CI step — it doesn’t need Bazel, so it could run on a lightweight runner. But that’s another workflow to maintain.
How strict? The gazelle freshness check is the ground truth gate — if gazelle output differs from committed BUILD files, that’s definitively stale. The static analysis is advisory. Should the static analysis also be blocking? Or is one enforcing gate enough?
My instinct is: gazelle freshness as the hard gate, static analysis as the helpful annotation. One catches everything, the other explains what drifted. Belt and suspenders, but with the belt doing the structural work and the suspenders providing the error messages humans actually read.
The maintenance tax
Every piece of infrastructure creates a maintenance obligation. The dep graph created an obligation to keep BUILD files accurate. The drift detector is how we pay that tax.
The roadmap table listed this as “~1 week effort.” That was about right for the script, tests, and CI integration. The ongoing cost is approximately zero — it runs automatically, fails loudly, and the fix is always the same: bazel run //bazel:gazelle && git commit.
That’s the best kind of maintenance tool. One where the remediation is a single command, not a judgment call.
The punchline
You can build the most sophisticated dep graph in the world. Target-level precision. Full monorepo coverage. Scoped pyright, GPU targeting, the whole roadmap. But if nobody enforces that the graph matches reality, you’ve built a GPS that confidently navigates with last month’s map.
The dep graph is only as good as its least-maintained BUILD file.
So you add a drift detector. Not because it’s interesting — it’s not. Because infrastructure without enforcement is just documentation. And documentation lies.
This is part of a series on Bazel dependency graph work: Your Monorepo Has Two Dependency Graphs (the two-graph problem), When Homegrown Tools Hit the Ceiling (the ceiling), The Dep Graph Is the Product (getting the graph), What a Full Dep Graph Actually Unlocks (what it enables). This one covers the boring part — keeping it honest.