What a Full Dep Graph Actually Unlocks

2026/03/09

BUILDbazelcidep-graphdevexmonorepo

Yesterday’s post was about getting the graph. Manual stubs, tags = ["manual"], 26% to ~100% coverage, the whole trick. The PR landed. CI is green. Now what?

Now the interesting part. A complete dependency graph is infrastructure. It doesn’t do anything by itself. It enables things that were previously impossible, impractical, or held together with 1,600 lines of custom Python.

This post is about what those things are.

The incumbent: find_changed

Before we talk about what the graph enables, we need to talk about what we already have. Because we already have a CI test selector. It works. It’s been running for months. Every PR goes through it.

find_changed is ~1,600 lines of Python that answers: “given these changed files, which test packages should run?” It builds two parallel graphs:

Then it DFS-walks the reverse dependencies. Changed foo.py → trace upward → affected packages → test matrix for GitHub Actions.

It also handles things Python imports can’t see: Dockerfiles, C++ sources via package_extra_dependencies.yml, conftest.py propagation, inline TOML annotations for runtime file deps, external version bumps in the lockfile. These are real dependencies that matter for correctness, and the tool catches them.

Here’s the thing: find_changed is package-granularity. Change one file in mai_layers, and every test in mai_layers runs. Every test in every package that depends on mai_layers runs. That’s the smallest unit it can reason about — the package.

The new option: Bazel rdeps

Bazel reasons about targets. A target is a py_library, a py_test, a specific thing with specific deps. When you ask Bazel “what depends on this?”, you get back individual targets, not whole packages.

# "What is affected by a change to mai_config?"
$ bazel query 'rdeps(//..., //mai_config/...)' | wc -l
65

# "What TEST targets are affected?"
$ bazel query 'kind(py_test, rdeps(//..., //mai_config/...))' | wc -l
# Only the test targets — not libraries, not binaries

That second query is the key. You’re not asking “which packages.” You’re asking “which specific test targets have a transitive dependency on this change.” If a package has 20 tests and only 3 of them transitively depend on the thing you changed, you run 3.

The show_affected.sh script that does this in CI is 20 lines of bash. find_changed is 1,600 lines of Python. That’s not a flex — find_changed does more things. But for the core question of “what depends on what,” the graph already has the answer.

Head-to-head: test selection

Let’s compare what happens when you change a leaf package — say fortknox, which nothing critical depends on.

find_changed Bazel rdeps
Granularity Package-level Target-level
Query DFS through uv.lock + file graph bazel query rdeps(//..., //lib/fortknox/...)
Result All tests in all affected packages 60/986 targets (~94% skip rate)
Extra deps Dockerfiles, C++ sources, conftest, runtime files Not yet (Python deps only)
Version bump detection Yes (lockfile diffing) No (not a Bazel concept)
Code to maintain ~1,600 lines ~20 lines + BUILD files

For a core library like mai_nano:

find_changed Bazel rdeps
Affected Most of the repo 319/986 targets
Precision Every test in every affected package Only tests with transitive dep

The precision gap compounds. A package with 50 tests where 5 are affected: find_changed runs 50, Bazel runs 5. Multiply by the number of affected packages, and you’re looking at potentially 3-10x fewer test invocations for the same correctness guarantee.

Head-to-head: pyright

This one’s less obvious but possibly higher-impact for developer experience.

The current state

Pyright runs on every PR. It type-checks the entire repo: 106 execution environments, all 104 packages. Every time. No caching between runs, no incremental mode. Takes about 5 minutes.

Five minutes doesn’t sound bad until you do the math:

Metric Value
PRs per week ~100
Average pyright time ~5 min
Dev-hours/year waiting for pyright ~417
Cost per PR (dev waiting) ~5 min of “is it green yet”

Four hundred hours a year of engineers watching a progress bar. For a type checker that re-checks 103 packages that didn’t change.

What scoped pyright looks like

Yipu’s PR #22032 already adds pyright_targets — a tool that does BFS on the dep graph to scope pyright to affected packages. The full Bazel dep graph makes this trivial:

# "Which packages need pyright after changing fortknox?"
$ bazel query 'rdeps(//..., //lib/fortknox/...)' \
    --output package | sort -u
# → ~6 packages instead of 104

Change a leaf package → pyright runs on that package → 15-30 seconds instead of 5 minutes. Change a core lib → pyright runs on the affected subgraph → maybe 2 minutes. Still better than 5.

The endgame: per-package pyright in Bazel

The real endgame is pyright_test targets in BUILD files. aspect_rules_lint is already in our MODULE.bazel (we use it for Starlark formatting). Extending it to Python linting means:

Scenario Current Scoped (query-based) Bazel-native
Leaf package change 5 min 15-30 sec 15-30 sec (cached: 0)
Core lib change 5 min 2-3 min 2-3 min (cached partials: ~1 min)
No Python changes 5 min Skip Skip

The “no Python changes” row is the silent killer. Someone edits a markdown file. Pyright runs for 5 minutes. With the dep graph, the query returns zero affected Python targets, and pyright doesn’t run at all.

GPU test targeting

GPU runners are expensive. Like, “we have a finite budget and every minute counts” expensive. Right now, whether a change needs GPU tests is determined by a hardcoded list in the workflow file:

# run_gpu_tests.yml (simplified)
GPU_TEST_PKGS: "rocket mai_layers mai_distributed ..."
# ~13 manually maintained entries

Change anything in those packages → spin up a GPU runner. Change anything else → don’t. The list is maintained by humans who sometimes forget to add new GPU-dependent packages and sometimes forget to remove ones that no longer need GPU.

With the dep graph, you don’t need a list. You need a query:

# "Does this change affect any torch-tagged test targets?"
$ bazel query 'attr(tags, "torch", rdeps(//..., //changed/package/...))'

If the result is empty, no GPU runner. If it’s not empty, spin one up — and you know exactly which tests need it, not “run everything in these 13 packages.”

Current With dep graph
Selection method Manual list of ~13 packages attr(tags, torch, rdeps(...))
Maintenance Human-updated YAML Zero (follows the graph)
False positives Whole package runs for any change Only affected torch tests
False negatives New torch-dependent package not in list Graph catches all transitively

Beyond CI: things we couldn’t do before

Test selection, pyright scoping, GPU targeting — those are the immediate CI wins. But a complete dep graph is a data structure, and data structures have more than three use cases.

Impact analysis in PRs

Already shipping. The CI annotation step shows affected targets in the PR summary:

This change affects 60/986 targets (~94% skip rate)

Before you merge, you know the blast radius. Before a reviewer opens the diff, they know if this is a leaf change or a core lib change. Context before code.

Migration planning

The graph knows which packages are fully migrated (real BUILD files) and which are stubs (tags = ["manual"]). It also knows which stub packages have all their deps already migrated. That’s your migration queue — automatically, no spreadsheet.

# "Which manual-tagged packages have all deps already migrated?"
# → These are ready for full migration with minimal effort

Hub detection

mai_nano has 319 reverse dependencies. That’s a hub. Hubs need:

The graph gives you fan-in counts for free. Sort by fan-in, and you have a prioritized list of “packages that deserve the most engineering attention.”

Leaf detection

The inverse. Packages with zero or near-zero reverse dependencies are safe to:

Decoupling analysis

Connected components. Minimum cuts. “If we split the monorepo along this boundary, how many cross-boundary edges would we sever?” These are graph theory 101 questions that you literally cannot ask without a complete graph.

Critical path analysis

The longest dependency chain in the graph is the minimum parallel build time. Shortening the critical path is the highest-leverage optimization for build speed. The graph tells you exactly where to look.

BUILD file drift detection

This is the maintenance story. When someone adds a dependency to pyproject.toml, the generated BUILD file is stale. Run the generator, diff the output, fail CI if they diverge. The graph stays accurate because CI enforces accuracy.

The two-graph story, resolved

A previous post explored how find_changed and Bazel see different dependency graphs. They read the same files. They arrive at different conclusions. Both are correct — they’re answering different questions.

The full dep graph changes this. Bazel now sees every package, every edge. The two graphs still disagree on details — find_changed knows about Dockerfiles and runtime file deps that Bazel doesn’t — but the structural disagreement is gone. Bazel is no longer blind to 74% of the monorepo.

The path forward is clear: migrate find_changed’s extra-dependency knowledge into Bazel (non-Python deps as filegroups, runtime deps as data attributes), and you converge the two graphs into one. Not today. But the architecture makes it possible, and it wasn’t possible a week ago.

What this doesn’t solve

Intellectual honesty moment. The dep graph is not magic.

find_changed’s non-Python deps. Bazel’s dep graph is Python-level. Dockerfiles, C++ sources, cmake configs — find_changed tracks these via package_extra_dependencies.yml. Bazel doesn’t know about them yet. Until those relationships are expressed as Bazel deps, find_changed catches things Bazel misses.

Version bump detection. find_changed diffs the lockfile. If numpy goes from 1.24 to 1.25, every package using numpy gets retested. Bazel doesn’t do this — @pypi//numpy is @pypi//numpy regardless of version. This matters for correctness.

The lib/bus problem. C++/Cython packages can’t be fully represented as py_library stubs. The Python-level dep edges are there, but the native build deps aren’t. lib/bus affects ~28 downstream packages, and the graph captures the Python side but not the C++ side.

Migration is still required. The stubs give you the graph. They don’t give you sandboxed builds, hermetic tests, or cache-aware execution. Those require real BUILD files — the full migration. The graph tells you what to migrate and in what order. It doesn’t do the migration for you.

These are real gaps. They’re also scoped gaps — we know exactly what’s missing, and we know how to fill each one. That’s progress.

The roadmap

Here’s what becomes possible, roughly in order of effort:

Phase What Effort Impact
Now PR impact annotations Done (shipping) Context for reviewers
Next Scoped pyright via dep graph ~1 week 80-95% faster type checking
Next GPU test targeting via attr(tags, torch, ...) ~1 week Fewer GPU runner minutes
Soon find_changed → Bazel query migration ~2-4 weeks Retire 1,600 lines of custom code
Soon BUILD file drift CI check ~1 week Graph accuracy enforcement
Later Non-Python deps in Bazel (filegroups, data) Ongoing Converge the two graphs
Later Per-package pyright_test with caching ~2-3 weeks Zero-second cache hits

The first three are weeks of work, not months. The graph is the hard part, and the graph is done.

The punchline

Yesterday’s post ended with “stop counting packages on Bazel, start counting edges in the graph.” Today’s answer to “why?” is concrete:

A dependency graph that covers 26% of your monorepo is a curiosity. A dependency graph that covers 100% of your monorepo is infrastructure. Infrastructure you build everything else on.

The graph is done. Now we build on it.


This is a follow-up to The Dep Graph Is the Product, which covered how we got to 100% graph coverage. Previous posts in the series: Your Monorepo Has Two Dependency Graphs (and They Disagree), When Homegrown Tools Hit the Ceiling.