4.8.3 Validation Actions vs Aspects
A linter, a static analyzer, or a style checker has to read source files, so the checker has to run as a Bazel action. Bazel offers two analysis-time places to wire that execution-phase check: a validation action registered by the rule, or an aspect layered onto existing rules that registers its own action. Both can produce the same effect — "every relevant target was checked, builds fail if a check fails" — but they attach the check in two very different places. The choice is mostly about responsibility and modifiability, not about what the linter itself can detect.1,2
This article assumes the validation-action mechanics from 4.2.9 OutputGroupInfo & Output Groups (the special _validation output group) and the aspect mechanics from 4.8.1 Aspects — Cross-Cutting Graph Traversal and 4.8.2 Aspect Implementation Basics are familiar. Here we focus on the decision: when should a quality check live inside the rule, and when should it live in a sideways aspect overlay?
Formatting Is Not Linting
Before the validation-vs-aspect question, there is a more important split: formatters and linters are different problems and should not share an integration story.3
| Property | Formatting | Linting |
|---|---|---|
| Tools per language | one canonical formatter | multiple linters welcome |
| Output | rewrites the file | reports a violation |
| Scope | files selected by a dedicated workflow | targets selected for analysis. Sometimes their dependencies |
| Speed | fast enough for pre-commit | can be slower than compilation |
| Natural trigger | bazel run //:format, commit, or editor save | a build action attached to analyzed targets |
Formatting is usually file-local and deterministic. It does not need an action
attached to every analyzed application target. Bazel can still provision and
launch the tools through a dedicated bazel run //:format target that wraps the
project formatters (Buildifier, gofumpt, Prettier, Black,
google-java-format, …), with a commit hook for developers and an idempotency
check in CI. 3.4.4 Code Quality Integration covers that integration
plumbing.3,4
Linting benefits from target-aware inputs, configuration, and tools. Some
linters also need dependency information, but that does not imply that every
lint aspect propagates through dependency attributes. The common boundary is
that lint actions attach to the selected analyzed targets. Traversal beyond
those targets occurs only when a concrete aspect declares propagation such as
attr_aspects = ["deps"].3 That attachment choice is where validation actions
and aspects start competing.
Conflating the two is what produces the patterns that go wrong: formatters attached as actions to every application target, or target-aware linters bolted onto pre-commit, where they lose Bazel's configured-target context and can time out the commit hook. Give formatting a dedicated runnable workflow. Attach linting to analyzed targets. The rest of the section then becomes much smaller.
Validation Actions Are Rule-Owned Quality Gates
When the rule author owns the check, the cleanest path is a validation action. The rule's implementation declares a normal action that reads the target's sources, writes a marker file, and exits non-zero on violation. That marker output goes into OutputGroupInfo(_validation = depset([...])) instead of DefaultInfo.1 4.2.9 OutputGroupInfo & Output Groups explains the marker contract: it is normally requested, kept off the critical path, skipped on tool, implicit, and exec edges, and gated globally by --run_validations. The distinction here is who decides that the action exists.
def _java_lib_impl(ctx):
classes = ctx.actions.declare_file(ctx.label.name + ".jar")
lint_marker = ctx.actions.declare_file(ctx.label.name + ".lint")
ctx.actions.run(
outputs = [classes],
inputs = ctx.files.srcs,
executable = ctx.executable._javac,
arguments = [...],
)
ctx.actions.run(
outputs = [lint_marker],
inputs = ctx.files.srcs,
executable = ctx.executable._linter,
mnemonic = "AndroidLint",
arguments = [...],
)
return [
DefaultInfo(files = depset([classes])),
OutputGroupInfo(_validation = depset([lint_marker])),
]
The decision-relevant property is the one in the last return: the lint marker is part of the rule's analysis output. Normal target dependencies on a java_lib get the lint check automatically, including over time as the lint set evolves. Rule consumers do not configure anything, and there is no parallel "are you running the linter?" config knob to forget. Bazel skips validation actions on documented tool, implicit, and execution-configuration edges. A rule author can also set the temporary skip_validations = True mitigation on a dependency attribute.1,5 Subject to those explicit exceptions, the check becomes part of the rule's correctness contract, with the same lifecycle as the rule's other behavior.2
The cost of embedding the check is on the rule author. Adding a new check means a rule release, and a BUILD-file consumer cannot disable it with a target-level setting. --run_validations=false disables validation actions for the whole invocation. A rule author can temporarily suppress transitive validations across a particular dependency attribute with skip_validations = True, but the API documents that escape hatch as a temporary mitigation rather than a durable per-consumer policy. Every ordinary user of the rule therefore pays for the check on the next build whether they asked for it or not.1,5
Aspects Are Infrastructure-Owned Overlays
The other side of the trade-off is what happens when you cannot — or do not want to — modify the rule. Maybe the rule lives in a third-party ruleset like rules_python or rules_go. Maybe a platform team is rolling out a new linter across many existing libraries and pull-requesting every ruleset is not realistic. Maybe the lint configuration has to vary per linter, per repo, or per team without touching the rules at all. Aspects exist for this case.
The mechanics — aspect() declaration, (target, ctx) implementation, optional
attr_aspects propagation, and the aspect-factory form needed to inject
label-typed config through private _… attrs — are the subject of
4.8.2 Aspect Implementation Basics. The decision-relevant pattern is the shape of
an aspect-driven linter: a Starlark file (commonly linters.bzl) exports one
configured aspect per linter. Each aspect can inspect a selected existing
target, register a normal lint action, and write the report and captured exit
code into named output groups. A particular linter may additionally propagate
over attributes such as deps. Output groups returned by the aspect are merged
with whatever the underlying rule returns as long as the group names do not
collide,6 which lets lint reports live next to a rule's normal DefaultInfo
outputs without rule cooperation.
Two patterns from rules_lint are worth naming because they are why aspects are popular for linting in practice.3 The aspect factory is a top-level function that returns the aspect() value with a label-typed config stored in a private _config attr. Public aspect parameters are string-valued in the current API, so label inputs must use underscore-prefixed private attrs.6
The exit-code-capture pattern keeps the action itself succeeding at the OS level and writes the linter's real exit status to a file. It then exposes that file inside report-oriented output groups such as rules_lint_human for terminal-style reports, rules_lint_machine for SARIF/structured output, and rules_lint_patch when the linter can emit fix proposals. Together those patterns let one configured aspect expose several user-facing modes. A concrete implementation may still register separate actions for different report formats or for fix mode. The output-group contract does not promise one linter execution.
To trace that design rather than only read about it, start at rules_lint's
public linting guide, choose one language workspace under examples/, and then
read its language definition—for example lint/eslint.bzl—for the aspect factory
and actions. Descend into lint/private/lint_aspect.bzl only for shared source
filtering and report, patch, and _validation output helpers.7 That route
keeps supported configuration and consumption modes distinct from reusable
private implementation details.
Three modes follow naturally from that one aspect:3
bazel build //... \
--aspects=//tools/lint:linters.bzl%ruff \
--@aspect_rules_lint//lint:fail_on_violation
This first form turns the lint output into a build failure, equivalent to a compiler error for any consumer of bazel build //.... The second form keeps the same aspect but wraps it in a lint_test rule whose only assertion is "the captured exit code file is 0." This is useful when CI gates merges with a test rather than a build step.
The third form is the report mode demonstrated in aspect-rules-lint's own lint.sh. The build runs with --norun_validations --output_groups=rules_lint_human, or adds rules_lint_machine when tooling wants structured output. A small wrapper script reads the resulting reports from the build event protocol and presents them.3 When the linter supports --fix, the same build can also request rules_lint_patch. Tools such as the aspect lint CLI (lint --fix) or the Marvin code-review bot consume those patches alongside the human or machine reports and present findings as review comments or suggested edits rather than as warnings or errors.4
The user-visible cost of that flexibility is more analysis work per visited target. Bazel evaluates the aspect for every target the propagation rules reach, and the concrete linter registers the actions required for its outputs in addition to the rule's own work. On most repos this is negligible. Aspects also add a small amount of memory and analysis-graph overhead compared to a validation action that lives inside the rule.2 For a ruleset author who already controls the rule, that trade-off favors validation actions for built-in checks.
The Trade-off In One Table
| Question | Validation action | Aspect |
|---|---|---|
| Who has to change the rule? | rule-set author | nobody — runs over existing targets |
| Where does the check live? | inside rule(implementation = …) | in linters.bzl, applied via --aspects |
| Who decides the check runs? | every consumer of the rule, automatically | the team or job that requests the aspect |
| Adding a new check | rule release with a bazel_dep bump | add an aspect entry, redeploy CI config |
| Per-consumer opt-out | no target-level consumer switch. Global --run_validations=false, or temporary rule-authored skip_validations on a dependency edge1,5 | omit the aspect from the invocation |
| Action-graph cost per target | implementation-dependent validation action(s) | linter-specific action(s), plus the aspect's analysis overlay |
| Skipped on tool / implicit / exec edges | yes, by design1 | not by default — depends on attr_aspects and provider filters |
| Natural fit for | checks intrinsic to the rule's correctness | cross-cutting, retrofit, multi-team rollouts |
The two modes are not exclusive. The same repo can use validation actions inside its first-party rules (where the rule author wants every consumer to get the check) and aspects on top of third-party rules (where modifying them is impractical), with different teams responsible for each layer.
Choosing One
A useful rule of thumb is to ask whether the failure mode is a property of the rule or a property of the project.
If the check is a property of the rule — "an Android library with nullness violations is broken regardless of who depends on it" — it belongs in a validation action. The rule author commits to the gate, every downstream consumer inherits it, and the cost stays close to the action graph: implementation-dependent validation action(s) per target, with _validation semantics keeping their outputs off the critical path.2 Android Lint, security taint checks integrated into a *_binary rule, and consistency checks on generated code (manifest matches the schema, for instance) are typical fits.
If the check is a property of the project — "this monorepo has chosen ESLint for TypeScript and Ruff for Python. Everyone gets it" — and especially if the relevant rules are third-party, it belongs in an aspect. The infrastructure team controls the linter set, the configuration files, and the rollout cadence in linters.bzl without coordinating with each ruleset author. The cost is paid only when the aspect is requested. Targets built without the aspect see no extra work.3
If both apply, you do not have to pick once. Run validation actions inside the rule for the checks the rule cannot be correct without. Run aspects on top for the checks the project chooses to enforce. The aspects can deliberately not re-run the rule's intrinsic validators, which keeps the build event log honest about what came from where.
Source-rewriting formatters create a different integration shape from either
kind of validation gate. Tools such as clang-format and goimports usually
belong behind a dedicated bazel run //:format workflow plus pre-commit, as
3.4.4 Code Quality Integration describes, rather than behind actions attached
to every normal application target. Repository-scale refactorings are different
again: they may use aspects to discover target-aware work, but expose patches or
transformed artifacts instead of _validation gates.
4.8.4 Large-Scale Refactoring via Aspects develops that aspect-driven transformation
pattern. The deciding question is therefore not merely whether a tool rewrites
sources, but whether it needs the analyzed target graph to select and configure
the transformation.
Compare the two mechanisms in one project: the rule-owned _validation action and the infrastructure-owned glyph_metadata_aspect.
Use validation actions when the check is intrinsic to the rule's contract — ordinary target consumers get it by default, subject to --run_validations and the documented edge exceptions. Their outputs go into OutputGroupInfo(_validation = …) so the checks stay off the critical path. Validation-action cardinality per target is implementation-dependent.
Use aspects when the check is a project-wide concern layered onto existing rules — linters.bzl plus --aspects=... configures the rollout without touching ruleset code, and output groups such as rules_lint_human, rules_lint_machine, and rules_lint_patch expose build-failure, test, code-review, and fix workflows through one configured aspect. Individual formats or fix modes may still require separate actions.
Formatters are a different problem: expose them as a dedicated runnable Bazel workflow, not as a per-target overlay on ordinary analyzed application targets.
Check your understanding · 4 questions
1.A team owns a custom android_library rule and wants every consumer of that rule to get an Android Lint check automatically, off the build's critical path. Which mechanism fits best?
Select one answer
2.True or false: formatting and linting in Bazel.
Choose True or False for each sentence
bazel run //:format workflow rather than an action attached to every analyzed application target.clang-format action to every normal application target is preferable to a dedicated bazel run //:format workflow.3.Match each rollout characteristic to validation actions or aspects.
Drag each answer onto the matching prompt, or click an answer and then click a prompt
*_library targets via --aspects=...--run_validations=false4.Why does rules_lint use a top-level *aspect-factory* function (def ruff_aspect(config): return aspect(...)) instead of declaring the lint config directly as a public aspect parameter?
Select one answer
Footnotes
-
Rules — Validation Actions section: artifact-content checks, the
_validationoutput group, "always requested" semantics, and the documented skip cases (tool, implicit, exec configuration). ↩1 ↩2 ↩3 ↩4 ↩5 ↩6 -
Validation actions: correct builds off the critical path — historical dummy-output workarounds and the Q&A claim that validation actions avoid the extra Skyframe node, memory, and analysis-time cost of an equivalent aspect. ↩1 ↩2 ↩3 ↩4
-
Rules_lint: Formatting and Linting All Languages - Alex Eagle, Aspect Build Systems — formatting-vs-linting properties table, aspect factory pattern with underscore-prefixed config attrs, three lint consumption modes (build failure / test / lint.sh report), and the explicit aspects-vs-validation-actions Q&A. ↩1 ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Announcing Linting for Bazel — three layers of lint integration (rules_lint,
aspect lintCLI, Marvin code-review bot) and the reframing of lint as code-review comments rather than warnings or errors. ↩1 ↩2 -
attr — label dependency attributes document
skip_validationsas a temporary rule-authored mitigation that suppresses validation actions from transitive dependencies reached through that attribute. ↩1 ↩2 ↩3 -
Aspects — aspect declaration,
attr_aspectspropagation, output groups returned from aspects, and rule/aspect output-group merging when names do not collide. .bzl files — currentaspect()API restrictions for public string parameters versus underscore-prefixed private label attrs. ↩1 ↩2 -
rules_lint repository map —
docs/linting.mddocuments the supported modes,examples/shows complete language setups,lint/eslint.bzlis a representative aspect factory with actions, andlint/private/lint_aspect.bzlsupplies shared filtering and output helpers. ↩