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

PropertyFormattingLinting
Tools per languageone canonical formattermultiple linters welcome
Outputrewrites the filereports a violation
Scopefiles selected by a dedicated workflowtargets selected for analysis. Sometimes their dependencies
Speedfast enough for pre-commitcan be slower than compilation
Natural triggerbazel run //:format, commit, or editor savea 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

QuestionValidation actionAspect
Who has to change the rule?rule-set authornobody — 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, automaticallythe team or job that requests the aspect
Adding a new checkrule release with a bazel_dep bumpadd an aspect entry, redeploy CI config
Per-consumer opt-outno target-level consumer switch. Global --run_validations=false, or temporary rule-authored skip_validations on a dependency edge1,5omit the aspect from the invocation
Action-graph cost per targetimplementation-dependent validation action(s)linter-specific action(s), plus the aspect's analysis overlay
Skipped on tool / implicit / exec edgesyes, by design1not by default — depends on attr_aspects and provider filters
Natural fit forchecks intrinsic to the rule's correctnesscross-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.

key takeaway

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

Formatting fits a dedicated bazel run //:format workflow rather than an action attached to every analyzed application target.
A lint aspect can attach target-aware actions without traversing dependencies unless that concrete aspect declares propagation.
Only validation actions can make lint violations fail a Bazel build.
Attaching a 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

Answers
Ships inside the rule implementation. Consumers get the check without extra flags
Runs over existing third-party *_library targets via --aspects=...
Turn off all validation-action gates at once with --run_validations=false
Enable or swap linters per CI job by changing the aspect list on the command line

4.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

0 of 4 answered

Footnotes

  1. Rules — Validation Actions section: artifact-content checks, the _validation output group, "always requested" semantics, and the documented skip cases (tool, implicit, exec configuration). 1 2 3 4 5 6

  2. 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

  3. 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

  4. Announcing Linting for Bazel — three layers of lint integration (rules_lint, aspect lint CLI, Marvin code-review bot) and the reframing of lint as code-review comments rather than warnings or errors. 1 2

  5. attr — label dependency attributes document skip_validations as a temporary rule-authored mitigation that suppresses validation actions from transitive dependencies reached through that attribute. 1 2 3

  6. Aspects — aspect declaration, attr_aspects propagation, output groups returned from aspects, and rule/aspect output-group merging when names do not collide. .bzl files — current aspect() API restrictions for public string parameters versus underscore-prefixed private label attrs. 1 2

  7. rules_lint repository mapdocs/linting.md documents the supported modes, examples/ shows complete language setups, lint/eslint.bzl is a representative aspect factory with actions, and lint/private/lint_aspect.bzl supplies shared filtering and output helpers.