3.4.4 Code Quality Integration
recommendedBazel ships a bazel coverage command but has no bazel lint1. The omission is intentional: Google's internal static-analysis service, Tricorder, filled that role, and when Bazel was open-sourced the gap was left for the ecosystem to fill1. For maintainers, that means code quality — formatters, linters, pre-commit hooks — is not a built-in concern like bazel test. It is something you wire into the build on top of the foundations from 0.3 Starlark Syntax Basics for BUILD files, using tools that sit alongside the BUILD-file editors covered in 3.4.3 Buildozer rather than on top of them.
Formatting and Linting Are Different Problems
Before picking tools, recognize the architectural split2. Formatters and linters look similar from the outside — both read source files and report on them — but they have opposite Bazel-integration needs.
| Property | Formatting | Linting |
|---|---|---|
| Tools per language | One | Multiple allowed |
| Scope | Single file, deterministic | Cross-file, follows dependency graph |
| Safety | Modifications always safe | Fixes may need human review |
| Speed | Must be fast (pre-commit) | May be slow |
| Bazel role | bazel run task outside the graph | Actions over the graph |
The practical consequence2: formatting runs best as a bazel run target triggered by git commit, because it touches single files on disk and needs no analysis. Linting runs best as Bazel actions driven by aspects, because it needs the dependency graph, benefits from caching, and scales to remote execution. Conflating the two — for example, running Prettier as an aspect — gives you slow formatting. Running ESLint as a pre-commit hook gives you incomplete linting. rules_lint treats them as two rulesets shipped together precisely because they are two problems2.
Buildifier — The Starlark Formatter
Every Bazel project uses one code-quality tool regardless of its source language: Buildifier, the BUILD/Starlark formatter3. It was rewritten in Go and rolled out across Google in 2012 to eliminate whitespace discussion from BUILD-file code review and to unlock machine edits on hundreds of thousands of files4. Its design choices — always reformat the whole file, no configurability, no line-length awareness — are deliberate4. Configurable formatting would allow every team to reopen the same style decisions.
A practical setup that holds up across teams3:
- Use prebuilt binaries. Buildifier is written in Go. Compiling it on every developer machine is a waste. The upstream
examples/bzlmod/BUILDdefines separate check and test targets backed by the ruleset's prebuilt setup.5 Those target names are ruleset-specific. The portable workflow is to pin the formatter once, expose a developer check, and run a non-mutating test or check in CI. - Put it on
PATH. Pairdirenvwith 3.5.3 Developer Tool Management'sbazel_env.bzlsobuildifieris available in the shell when youcdinto the workspace. - Format on save.
bazel_env.bzlalso produces a stable path (e.g../bazel-out/bazel_env-opt/bin/tools/bazel_env/bin/buildifier) that editor plugins can reference. - Enforce on CI. A CI check that rejects unformatted files prevents style drift.
Buildifier also has a lint mode with roughly 100 checks — some universal, many specific to Bazel's Starlark idioms3. The full list lives in WARNINGS.md. Enable checks one at a time following the ratchet principle: turning everything on at once produces an unreviewable mega-PR. Enabling one check, fixing its hits, and moving on keeps the change stream manageable3.
The build-maintenance check_build_files helper and its companion print_format_plan helper sketch this gate at Level 3 scale: a tiny in-repo style check (trailing whitespace, tab indentation) plus a printed buildifier -r . plan. The example deliberately does not vendor buildifier, buildifier-prebuilt, or rules_lint — it shows the shape of the formatting workflow so a real repo can plug a pinned binary into the same slot.
Polyglot Formatting via rules_lint
Most repos have more than BUILD files. rules_lint ships a format_multirun macro that assembles per-language formatters into a single bazel run //:format target1,2. A typical declaration looks like:
First pin the ruleset in MODULE.bazel. The current BCR entry at review time is:6
bazel_dep(name = "aspect_rules_lint", version = "2.7.2")
Review the available BCR version when adopting or upgrading it, and keep the chosen version checked in. Then wire the repository target:
load("@aspect_rules_lint//format:defs.bzl", "format_multirun")
format_multirun(
name = "format",
go = "@aspect_rules_lint//format:gofumpt",
python = ":ruff",
javascript = ":prettier",
starlark = "@buildifier_prebuilt//:buildifier",
)
File discovery uses the GitHub Linguist extension table, so you don't invent your own language mapping2. Parallel execution across languages comes from rules_multi_run underneath — formatting a large tree stays fast2.
rules_lint 2.0 covers 30 languages with this pattern, including Go (gofumpt), Java (google-java-format), JavaScript/TypeScript (Prettier), Python (Ruff/Black), Starlark (Buildifier), and prose formats like Markdown, JSON, and YAML7.
Pre-Commit — The Natural Formatting Trigger
Formatters are fast enough and safe enough to run on git commit2. The recommended wiring uses pre-commit.com and a local hook that calls bazel run //:format2:
repos:
- repo: local
hooks:
- id: format
name: Format
language: system
entry: bazel run //:format
files: .*
Two rules of thumb matter here2:
- Don't auto-install the git hook. A repository that silently installs
.git/hooks/pre-commitsurprises developers and survivesgit clonepoorly. Ship the config, and let each developer opt in. - Check formatting on CI as a backstop. rules_lint exposes a
format.checktarget that exits non-zero when files are unformatted3. A failing CI job should tell the developer how to install the pre-commit hook, not just complain.
When you first format an existing repo, record the rollout commit in .git-blame-ignore-revs3,4. Git's blame view will then look past the mass-reformat so future reviewers still see the real author of each line.
Linting — Aspects Over the Graph
A linter that understands cross-file references (imports, type graphs, call sites) cannot work on single files2. In Bazel it runs as an action over the dependency graph — the same *_library graph you already declared. The mechanism is an aspect that visits each library target and emits a lint report and an exit-code file2:
py_library A ──deps──> py_library B ──deps──> py_library C
│ │ │
▼ ▼ ▼
lint A lint B lint C
Aspects are the subject of 4.8 Aspects. For the maintainer, what matters is the design choice they enable: no BUILD file changes2. You don't wrap py_library with a lint macro, you don't grow a Gazelle extension, you don't fork rule sets. A linters.bzl file registers the aspects using the per-language factory functions (each takes a binary label plus tool-specific config)7:
load("@aspect_rules_lint//lint:ruff.bzl", "lint_ruff_aspect")
load("@aspect_rules_lint//lint:shellcheck.bzl", "lint_shellcheck_aspect")
load("@aspect_rules_lint//lint:clang_tidy.bzl", "lint_clang_tidy_aspect")
ruff = lint_ruff_aspect(
binary = "@aspect_rules_lint//lint:ruff_bin",
configs = ["//:.ruff.toml"],
)
shellcheck = lint_shellcheck_aspect(
binary = "@multitool//tools/shellcheck",
config = "//:.shellcheckrc",
)
clang_tidy = lint_clang_tidy_aspect(
binary = "//tools/lint:clang_tidy",
configs = ["//:.clang-tidy"],
)
A build invocation then runs them across //...7:
bazel build //... \
--aspects=//tools/lint:linters.bzl%ruff \
--norun_validations \
--output_groups=rules_lint_human
Each aspect declares output groups — rules_lint_human for terminal-style reports, rules_lint_machine for structured output, and rules_lint_patch when fixes are requested — plus a _validation group that honors the exit code when --@aspect_rules_lint//lint:fail_on_violation is set7. That split decouples configuring the lint integration from how you consume its results2. One configured aspect can expose several modes, but a particular report format, SARIF conversion, or fix path can require its own action rather than reusing one lint action.
Three Ways to Consume Lint Results
The per-aspect exit-code file plus output-group split lets one configured aspect support three consumption modes2:
- Build failure. Add
--@aspect_rules_lint//lint:fail_on_violationto treat any non-zero lint exit code as a build failure via the aspect's validation action2. Compiler-like ergonomics — best for rules where lint is intrinsic to the build (e.g. a security-sensitive internal ruleset). - Failing test. Wrap the aspect output in a
lint_testtarget that asserts the exit-code file contains0. Thenbazel test //...catches style violations alongside functional failures2. Popular for "set and forget" CI setups, and the recipe your conspect item'scontentfield points at. - Code-review comments. Request the
rules_lint_human(orrules_lint_machine) output group and stream the reports to a code-review bot — Aspect's Marvin is one such, Google's Tricorder is the internal equivalent1,7. The review bot posts suggestions as PR comments, with one-click apply when the tool provides fixes. This is the "third way" between warnings (ignored) and errors (suppressed) — lint rendered as a conversation1.
When to Use a Validation Action Instead
Python type checking shows why gradual adoption matters. The
rules_mypy map points to an
examples/opt-in/
workspace where only selected targets join the aspect-based check. That is a
safer migration shape than enabling a whole repository at once: generated
imports, third-party stubs, and provider propagation can be corrected without
turning every pre-existing type error into one undifferentiated CI failure.
Upstream also warns that propagated type metadata can increase local and remote
cache volume, so graph-aware checking is a quality/cost trade-off rather than a
free wrapper around mypy.
Aspects aren't the only integration path. A rule set author can embed linting directly inside their rule as a validation action, turning a quality check into an intrinsic part of the rule's contract. The decision between the two approaches is covered in depth in 4.8.3 Validation Actions vs Aspects: use aspects when the quality team is independent of the rule-set authors. Use validation actions when you own the rule set and want every consumer of your rule to inherit the check automatically. For a project-level maintainer integrating existing tools, aspects are almost always the right starting point — they don't require modifying anything in the rule sets you already depend on.
Wiring It All Together
A recommended end-state for a Level 3 project:
tools/format/BUILD.bazel—format_multirunfor every source language in the repo.tools/lint/linters.bzl—ruff,eslint,clang-tidy,shellcheck,buildifieraspects as your mix requires..pre-commit-config.yaml— one hook callingbazel run //:format.- A
.bazelrcblock (per 3.2.1 .bazelrc Hierarchy) for the lint build invocation —common:lint --aspects=//tools/lint:linters.bzl%ruff --norun_validations --output_groups=rules_lint_human. - A CI job that runs
bazel run //tools/format:format.checkandbazel build --config=lint //..., failing loudly on either. .git-blame-ignore-revsentries for every mass-reformat commit, so blame stays useful.
Formatting and linting belong outside the core build graph in the sense described by 3.7 Workflow Orchestration (Outside the Graph) — they are orchestration layered on top of Bazel, not Bazel's own responsibility. That framing helps when you hit the edges: a coverage report spanning many targets, a style policy evaluated per PR, or a custom lint tied to an internal framework. Those are task-runner problems built on top of the same primitives — aspects, tests, bazel run targets — that this article covers.
Formatters are single-file and deterministic — wire them as a bazel run //:format target plus a pre-commit hook plus a CI check. Linters cross files — wire them as aspects over your *_library graph and pick a consumption mode (build failure, test, or code review comment) that matches how strict you want the signal to be. rules_lint packages both halves as one ruleset, and Buildifier is the Starlark formatter every Bazel repo already needs regardless of language.
Why Bazel Has No bazel lint
Bazel's command surface covers build, test, run, query, and coverage. Linting is conspicuously missing. The reason is historical: the Bazel core team worked inside Google, where Tricorder — an internal static-analysis platform integrated with Critique code review — already covered linting for every language1. bazel lint would have duplicated Tricorder. When Bazel was open-sourced, Tricorder was not, and the ecosystem was left to rebuild the missing piece1.
This is the pattern any Bazel consumer eventually learns. Core Bazel stays narrow. Language and workflow support comes from rule sets and tooling maintained outside the core. rules_lint is one such community response — Apache 2.0, distributed via BCR7. Marvin and Aspect Workflows are proprietary layers built on the same open foundation for code-review integration1.
Check your understanding · 3 questions
1.Why should formatters and linters use different Bazel integration approaches?
Select one answer
2.Which are valid ways to consume lint results from rules_lint aspects? Select all that apply.
Select all that apply
3.True or false about code quality integration:
Choose True or False for each sentence
Footnotes
-
Announcing Linting for Bazel — Tricorder backstory, rules_lint layered architecture (ruleset, CLI, review bot), and the "third way" framing of lint as code-review comments rather than warnings or errors ↩1 ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8
-
Rules_lint: Formatting and Linting All Languages — BazelCon 2024 talk with detailed architectural treatment of formatting vs linting, GitHub Linguist extension mapping, aspect factory pattern, output-group-based consumption modes, and pre-commit + CI wiring ↩1 ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 ↩11 ↩12 ↩13 ↩14 ↩15 ↩16
-
Starlark linter: Buildifier — Buildifier setup recipe (buildifier-prebuilt, bazel_env + direnv, editor-on-save, CI format.check, ratchet principle for enabling lint checks) ↩1 ↩2 ↩3 ↩4 ↩5 ↩6
-
The Story of Reformatting 100k Files at Google in 2012 — Buildifier's origin (rewritten in Go by Russ Cox on top of Nilton Volpato's earlier version), design rationale (full-file reformat, no line-length handling, no configurability), and the "no exceptions, no settings" enforcement philosophy ↩1 ↩2 ↩3
-
buildifier-prebuilt — prebuilt Buildifier and Buildozer toolchains — the public rules, Bzlmod example, version pinning, and private prebuilt-toolchain implementation boundary ↩
-
aspect_rules_lint in the Bazel Central Registry — current Bzlmod installation declaration, compatibility, and generated API documentation ↩
-
Aspect's rules_lint Reaches 2.0 — Thirty-language coverage, AXL-driven
aspect lintCLI, Bzlmod module_extension setup, formatting-as-git-pre-commit split, and lint-as-Bazel-action positioning ↩1 ↩2 ↩3 ↩4 ↩5 ↩6