4.13.2 Case Study: Reading the rules_lint Architecture
recommendedAfter building a small ruleset in 4.13.1 Build a Ruleset End-to-End, the next
skill is reading a production ruleset without mistaking its file layout for its
architecture. rules_lint is useful because one apparently simple goal—format
and lint a polyglot repository—forces a sequence of decisions about the build
graph, tools, execution, and responsibility. The point of this case study is not
to reproduce its implementation. It is to learn how to test whether those
decisions form one coherent system.
Start with the graph boundary
Ask first what information the tool needs. A deterministic formatter can operate
on one source file without knowing which target owns it or what that target
depends on. Attaching that operation to every configured application target
would add per-target traversal and actions without adding useful information.
rules_lint still uses Bazel to supply and launch the tools, but collects the
formatters behind a dedicated runnable target instead of overlaying them on the
application target graph. Developers can invoke that workflow before commit,
and CI can run its check mode.1,2
3.4.4 Code Quality Integration continues with that project workflow and its
adoption details.
A linter can need the opposite boundary. It may need a target's sources,
generated configuration, dependency metadata, or the transitive context reached
through deps. That work benefits from Bazel's declared inputs, caching,
parallel scheduling, and remote execution, so the lint operation should run
inside the analyzed graph.1 The distinction is not “fast tool versus slow tool.” It
is file-local operation versus graph-informed operation. That question should
be answered before choosing a Starlark API.
Verify the boundary in the repository
Do not take the architecture label on trust. Follow one short path through the
repository revision verified for this article (38e324e):2
- Start with
docs/formatting.md. Findformat_multirunand itsbazel runinvocation. That is the checkpoint that formatting is exposed as a dedicated runnable workflow with a separate check mode. - Inspect
format/defs.bzl. Find theformat_multirunmacro composing formatter commands intomultiruntargets. The absence of an application-target aspect in this path rejects the hypothesis that formatting is attached to every configured target. - Contrast that with
docs/linting.mdand one representative linter definition, such aslint/eslint.bzl. Find the documented--aspectsinvocation, then the publiclint_eslint_aspectfactory and the_eslint_aspect_implimplementation whose registered actions it configures. Finally inspectlint/private/lint_aspect.bzlfor the shared helpers that publishrules_lint_human,rules_lint_machine, and_validation. These symbols establish target attachment and result exposure independently of dependency propagation.
Those observations are enough to test the main claim: both workflows use Bazel,
but linting attaches target-aware actions to the selected analyzed targets.
Dependency traversal is a separate decision: it occurs only when a concrete
linter aspect declares propagation, for example with attr_aspects = ["deps"].
The public guides establish the supported interface. The linter definition and
shared helper show the attachment and output mechanisms behind it.
Make the tool an execution input
Once linting enters the graph, “install the right binary on every developer
machine” is no longer an adequate tool contract. The action must receive a
declared executable just as it receives declared source inputs. Declaration
makes the tool visible to the action, but version provenance comes from the
supply path. Some integrations download platform-specific tools whose bytes are
fixed by an integrity value or checksum. Factories such as
lint_eslint_aspect instead accept a consumer-supplied executable label,
commonly from the consumer's npm repository. In that path, the npm lockfile
controls the resolved tool version. In both cases the executable is an action
input instead of ambient machine state.1 This applies the cache-correctness
boundary from 2.3.1 Hermeticity: changing a tool must be visible to
the build rather than hidden in PATH.
A declared tool still leaves a placement question. It must run on the action's
execution platform, which may be a remote worker rather than the target platform
or the developer's host machine. In the ESLint factory, the private executable
attribute uses cfg = "exec". Integrations that own platform-specific downloads
must likewise select a variant compatible with where the action runs. This does
not imply that every rules_lint integration downloads its own binary or uses
Bazel's toolchain API. The three platform roles and their rule-author
consequences are covered by
4.6.1 Platform Model for Rule Authors. 4.6.2 Defining, Registering & Accessing Toolchains
covers the generalized toolchain interface. The architectural test is simple:
can the action obtain the correct executable without consulting ambient PATH
or assuming that target and execution machines are identical?
Choose who is allowed to attach the lint action
Only after locating the work inside the graph should you choose between an
aspect and a validation action. rules_lint needs to integrate with many
language rulesets it does not control. An aspect can visit selected existing
targets and register lint actions without wrapping every rule or requiring each
language ruleset to change.1 Some linter aspects also propagate across
declared attributes. Others operate only on targets to which the invocation
applies them. The implementation mechanics—optional propagation edges, private
tool attributes, and returned providers—are covered in
4.8.2 Aspect Implementation Basics.
That interoperability benefit is also the limitation that clarifies the choice.
If the author of a language rule considers a check intrinsic to every build of
that rule, the rule can register the action itself. If an independent quality
ruleset must overlay third-party rules, an aspect is the compatible attachment
point. Either a rule or an aspect may publish outputs, including the special
_validation group. That output-group choice controls gating and consumption,
not who was allowed to attach the action. 4.8.3 Validation Actions vs Aspects gives the
full decision framework. This case adds the production reading habit: look for
who controls the visited rule before judging either mechanism superior.
Separate producing a result from consuming it
Configuring one lint aspect does not imply one user experience. It may expose
build gating, tests, patches, and human- or machine-readable reports.
rules_lint uses named output groups to make those artifacts requestable without
placing them in normal default outputs.1 Do not infer that every mode consumes
one physical action: the representative ESLint implementation registers
separate lint actions for human and machine output and follows the machine result
with SARIF conversion.
This is why “aspect or output group?” is the wrong comparison. The aspect answers where and how the action is attached to the graph. The output group answers how its artifacts are surfaced. A validation output group adds default gating semantics, while ordinary named groups support optional report workflows. 4.2.9 OutputGroupInfo & Output Groups explains those output-selection rules. Keeping attachment, action production, and consumption separate lets one configured aspect support several policies even when particular formats or fix modes need different actions.
Keep the multi-language layer thin
The cross-language core should standardize only the contract that truly repeats: how a target is visited, how a tool is supplied, how inputs and arguments reach the action, and how results are exposed. Each language adapter still has to know its rule providers, source kinds, tool invocation, and report conventions. Those differences are not evidence that Bazel Core needs a universal lint command. They are exactly the ecosystem-specific behavior that rulesets add above the generic graph and execution engine described in P.2.3 Core vs Rulesets.
This boundary also protects the public API. Adding a language should extend a documented adapter contract rather than leak every analyzer's flags into one monolithic rule or require BUILD authors to understand internal traversal. 4.4.1 Rule Public API Design develops the general ruleset API criteria. Here the criterion is whether a new language can be added without changing the meaning of existing integrations.
Transfer the reading method
When evaluating another cross-language ruleset, trace the decisions in the same order:
- Does the operation require configured-target information, optional dependency propagation, or only a file selection?
- Are its tools declared inputs, where does each supply path record version provenance, and are variants selected for the execution platform?
- Who controls the underlying rules, and therefore should the action be attached by an aspect or by rule-owned validation?
- Are action attachment and result consumption separated through appropriate output groups?
- Does Bazel Core remain the generic graph/execution engine while small ruleset adapters own language-specific policy?
An architecture is convincing when each answer constrains the next. If a tool needs only a selected file set, a per-target overlay is suspicious. If it runs as an action, ambient installation is suspicious. If an aspect propagates over dependencies that its analysis does not need, that traversal is suspicious. If third-party rules must be modified merely to opt into an overlay, the extension point is suspicious. This chain turns a repository tour into a reviewable design argument.
Read a production ruleset from need to boundary to mechanism. File-local work fits a dedicated runnable workflow. Graph-informed work fits target-aware actions whose propagation is no wider than their evidence requires. Then verify that tools are hermetic and execution-platform compatible, attachment follows who controls the underlying rules, and output groups separate producing results from consuming them. The architecture holds only when public docs and implementation paths support each boundary while the shared core stays generic.
Footnotes
-
Rules_lint: Formatting and Linting All Languages - Alex Eagle, Aspect Build Systems — architecture of formatting, graph-aware linting, hermetic tool distribution, aspects, and lint result consumption ↩1 ↩2 ↩3 ↩4 ↩5
-
rules_lint repository map — current public guides and focused implementation paths for tracing formatting targets, lint aspects, actions, and output groups ↩1 ↩2