You fix a typo in a docstring. One file, one package, four characters. CI kicks off, and pyright dutifully type-checks 106 execution environments across 104 packages. Five minutes later, it’s done. Everything passes. Obviously. You changed a docstring.
This happens a hundred times a week in our monorepo. And every single run does the exact same thing: install everything, check everything, report that everything is fine.
The numbers
Here’s what the typechecker job actually does on every PR:
uv sync --all-packages --frozen ~90s (install all 104 packages)
uv run pyright --level error ~155s (scan 106 execution environments)
Runner: ubuntu-2404-x64-8c.32g.300g. Eight cores, 32 gigs of RAM. Not exactly underpowered.
No caching between runs. No incremental mode. No awareness of what actually changed. Every PR gets the full scan. Every time.
The math is grim. Five minutes per run, roughly 100 PRs per week, 50 weeks a year. That’s 417 hours of CI time spent type-checking packages nobody touched. A full-time engineer’s annual output, just… watching pyright.
And that’s just wall-clock time. The real cost is the 50 developers sitting there, waiting, context-switching, losing the thread of whatever they were working on. Five minutes doesn’t sound bad until it’s five minutes every time you push.
Why pyright can’t fix this
Pyright is a great type checker. It is not a build system.
It doesn’t know your dependency graph. It doesn’t know which packages changed. It doesn’t have a content-addressed cache. It doesn’t have an incremental mode for monorepos. You point it at a pyrightconfig.json with 106 execution environments and it checks all 106. That’s the contract.
Our config is a root-level pyrightconfig.json — basic checking mode, Python 3.10, all packages in scope. This made sense when the repo had 20 packages. It does not make sense at 104.
There’s a pre-commit hook for pyright too. It’s SKIP’d in CI. Instead, there’s a dedicated typechecker job that does the whole-repo scan. The reasoning was probably “let’s do it properly in CI.” The result is “let’s do it maximally in CI.”
The infrastructure was already there
Here’s the part that stings.
aspect_rules_lint v2.1.0 is already in our MODULE.bazel. It’s been there for a while. We use it for Starlark formatting.
The linting framework that could run per-package pyright targets with full Bazel caching was already installed. For formatting .bzl files. The equivalent of owning a CNC machine and using it as a paperweight.
Per-package pyright via Bazel
The fix is conceptually simple: make pyright a Bazel test action.
A pyright_test macro that takes sources, deps, and an optional config — generates a package-scoped pyrightconfig.json, runs pyright against just that package, and lets Bazel handle the rest:
# lib/mai_config/BUILD.bazel
load("//bazel:pyright.bzl", "pyright_test")
pyright_test(
name = "pyright",
srcs = glob(["src/**/*.py"]),
deps = [
"//lib/mai_layers",
"@pypi//pydantic",
"@pypi//numpy",
],
)
Change one file in mai_config? Bazel runs pyright for mai_config. Everything else is a cache hit. Fifteen seconds instead of five minutes.
Change nothing in mai_config but push a new commit? Content hash hasn’t changed. Cache hit. Zero seconds.
Share a remote cache across all developers? First person to check a package pays the cost. Everyone else gets a free ride.
Change 1 file in 1 package → ~15-30s (one pyright invocation)
Unchanged packages → 0s (cache hit)
Remote cache → shared across all devs and CI
That’s an 80-95% reduction for a typical PR.
The hard parts
It’s not just “wrap pyright in a shell command.” There are real problems to solve.
Execution environments. The root pyrightconfig.json has 106 of them because different packages need different Python path configurations. Each pyright_test target needs its own config fragment — the right pythonPath, the right extraPaths, the right stubs. You’re essentially decomposing one monolithic config into 104 scoped configs.
Cross-package type resolution. When mai_config imports from mai_layers, pyright needs to see mai_layers’s source files to resolve types. The deps in the BUILD file must accurately reflect the import graph — not just for runtime, but for static analysis. If your BUILD deps are wrong, pyright can’t see the types, and you get false errors.
But here’s the thing — this is exactly what Bazel is designed to do. Express your dependency graph honestly, and the build system handles caching, invalidation, and parallel execution. You’re not teaching Bazel something new. You’re just giving it another action to manage.
What this actually looks like
Developer changes lib/mai_config/src/mai_config/settings.py:
$ bazel test //lib/mai_config:pyright
INFO: 1 test target(s)
//lib/mai_config:pyright PASSED in 18.2s
Executed 1 out of 104 tests: 1 test passes.
103 cache hits. One actual check. Eighteen seconds.
Run bazel test //... to check everything? Same deal — only packages whose source or deps changed since the last run get checked. CI goes from “always five minutes” to “proportional to what you actually changed.”
The punchline
Pyright isn’t broken. It does exactly what you tell it to do — check everything you point it at. The problem is we’re pointing it at everything, every time, because that’s the only integration we built.
The dependency graph exists. Bazel already knows it. The linting framework is already installed. The per-package targets are a macro away.
We’re burning 417 hours a year because the wiring between the type checker and the build system doesn’t exist yet. Not because it can’t — because nobody’s plugged it in.