4.13.2 Case Study: Reading the rules_lint Architecture

recommended

After 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

  1. Start with docs/formatting.md. Find format_multirun and its bazel run invocation. That is the checkpoint that formatting is exposed as a dedicated runnable workflow with a separate check mode.
  2. Inspect format/defs.bzl. Find the format_multirun macro composing formatter commands into multirun targets. The absence of an application-target aspect in this path rejects the hypothesis that formatting is attached to every configured target.
  3. Contrast that with docs/linting.md and one representative linter definition, such as lint/eslint.bzl. Find the documented --aspects invocation, then the public lint_eslint_aspect factory and the _eslint_aspect_impl implementation whose registered actions it configures. Finally inspect lint/private/lint_aspect.bzl for the shared helpers that publish rules_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:

  1. Does the operation require configured-target information, optional dependency propagation, or only a file selection?
  2. Are its tools declared inputs, where does each supply path record version provenance, and are variants selected for the execution platform?
  3. Who controls the underlying rules, and therefore should the action be attached by an aspect or by rule-owned validation?
  4. Are action attachment and result consumption separated through appropriate output groups?
  5. 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.

key takeaway

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

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

  2. rules_lint repository map — current public guides and focused implementation paths for tracing formatting targets, lint aspects, actions, and output groups 1 2