If you've been following along, you know I've been on a journey of Bazelifying this my Pedalboard monorepo piece by piece. In my previous post I went through the process of collapsing the pnpm -> Bazel indirection for tests: switching per-package scripts from bazel run //packages/hooks:jest to the cleaner bazel test :jest, using --test_tag_filters to separate test targets from lint targets, and letting the sandbox enforce what was always supposed to be declared. At the very end of that post I left a note: "Still to come: looking at whether the same approach can clean up the lint scripts in each package the same way we just did for test." Well, here we are.
The short version: yes, it can. And it's almost embarrassingly simple once you see why.
Why bazel run was wrong for lint in the first place
When we set up the lint target in each package's BUILD.bazel, we used lint_test() from aspect_rules_lint:
# tools/lint/linters.bzl
load("@aspect_rules_lint//lint:eslint.bzl", "lint_eslint_aspect")
load("@aspect_rules_lint//lint:lint_test.bzl", "lint_test")
eslint = lint_eslint_aspect(
binary = Label("//tools/lint:eslint"),
configs = [Label("//:eslintrc")],
)
eslint_test = lint_test(aspect = eslint)
Notice what we imported: lint_test. Not lint_binary, not a plain executable. A test rule. That's what each package calls when it does:
# packages/hooks/BUILD.bazel
eslint_test(
name = "lint",
timeout = "short",
srcs = [":sources", ":test_sources"],
)
So the lint target is a proper Bazel test target. And yet, what were the per-package npm scripts doing?
"lint": "bazel run //packages/hooks:lint"
bazel run is for executable targets: binaries, scripts, things you invoke to do something. bazel test is for test targets: things you invoke to verify something. We had the right rule type, but the wrong invocation. It worked, mostly, but it was semantically off and left the sandbox enforcement on the table (same story as the test scripts before we fixed those).
Tagging lint targets
Before we could use --test_tag_filters=lint to select lint targets, we needed every lint target to actually carry the lint tag. The right place to do that is in linters.bzl, the shared macro file that all 7 packages import. That way no individual BUILD.bazel needs touching.
For eslint_test, that meant wrapping the direct rule assignment in a function:
_eslint_test = lint_test(aspect = eslint)
def eslint_test(name, srcs, **kwargs):
_eslint_test(name = name, srcs = srcs, tags = ["lint"], **kwargs)
For stylelint_test, adding the tag was a one-liner:
def stylelint_test(name, srcs, config):
_stylelint_bin.stylelint_test(
name = name,
tags = ["lint"],
args = [...],
data = srcs + [config],
)
Every package that calls either macro now gets the lint tag for free.
The fix: one script, one tag
With the tags in place, the per-package lint scripts could follow exactly the same pattern as the test scripts. Use ... to discover all targets in the package directory and filter by tag.
Here's what the lint scripts looked like before, across all 7 packages:
"lint": "bazel run //packages/components:lint"
"lint": "bazel run //packages/hooks:lint"
"lint": "bazel run //packages/git-hooks:lint"
"lint": "bazel run //packages/media-loader:lint"
"lint": "bazel run //packages/scripts:lint"
"lint": "bazel run //packages/eslint-plugin-craftsmanlint:lint"
"lint": "bazel run //packages/stylelint-plugin-craftsmanlint:lint"
And here's what they all look like now:
"lint": "bazel test ... --test_tag_filters=lint"
Seven packages, one consistent script. The ... wildcard resolves relative to the package directory, so no hardcoded paths. For six of the packages this picks up just the ESLint target. For components, which has both eslint_test and stylelint_test in its BUILD.bazel, it picks up both automatically because they both carry the lint tag.
That's the real payoff of the tag approach: the components package doesn't need a composite script or separate lint:code / lint:style entries. The tag filter does the aggregation, and Bazel runs both linters in parallel.
Closing the loop at the root
With all 7 per-package lint scripts sorted, the last piece was the monorepo root. This is what it was before:
"lint": "pnpm -r run lint"
Classic pnpm workspace traversal: walk every package, invoke its lint script, collect results. It worked, but it meant Bazel was being invoked 7 separate times in 7 separate processes, each with no awareness of the others. No shared cache hits across packages, no parallel scheduling across the dependency graph.
Replacing it is a one-liner:
"lint": "bazel test //packages/... --test_tag_filters=lint"
Same pattern as the root test script. Bazel sees the full picture, schedules everything it can in parallel, and uses the shared cache. The //packages/... wildcard discovers every lint target across all packages in one invocation.
There was also a lint:since script that used pnpm's --filter '...[origin/master]' to lint only changed packages. A reasonable optimization back when each pnpm lint meant a cold Bazel invocation per package. With Bazel's shared cache, lint:since is redundant: unchanged packages are cache hits and finish instantly. So lint:since got removed, and the CI workflow (npm-publish.yml) was updated to just call pnpm run lint.
The sandbox bites again: postcss-scss
If this sounds familiar, it should. The previous post spent a whole section on undeclared deps that were invisible inside bazel test's sandbox but worked fine with bazel run. The stylelint target had exactly the same trap waiting.
Running pnpm lint in components after the switch to bazel test gave:
Error: Cannot resolve custom syntax module "postcss-scss".
Cannot find module 'postcss-scss'
Stylelint uses postcss-scss as a custom syntax parser for SCSS files. It was already declared as a dep of the stylelintrc js_library:
js_library(
name = "stylelintrc",
srcs = [".stylelintrc.json"],
deps = [
":node_modules/@pedalboard/stylelint-plugin-craftsmanlint",
":node_modules/postcss-scss",
],
)
But the sandbox doesn't work that way. deps on a js_library are build-time dependencies. They don't automatically land in the test's runfiles. For postcss-scss to be available at runtime, it needs to be in data.
The trickier part: even adding :node_modules/postcss-scss (the package-local link) to data didn't fix it. The package-local node_modules end up at packages/components/node_modules/ in the runfiles tree, but stylelint resolves from node_modules/.aspect_rules_js/stylelint@14.16.1/... and walks up to node_modules/ at the root of the runfiles, never into the package subdirectory. Wrong level.
The fix was to hoist postcss-scss to the root node_modules level using public_hoist_packages in MODULE.bazel:
npm.npm_translate_lock(
name = "npm",
pnpm_lock = "//:pnpm-lock.yaml",
public_hoist_packages = {
"eslint": [""],
"typescript-eslint": [""],
"eslint-plugin-react": [""],
"stylelint@14.16.1": [""],
"postcss-scss": [""], # hoisted so stylelint can resolve it
},
)
public_hoist_packages tells aspect_rules_js to link the package at the root node_modules/ level in the runfiles, exactly where stylelint's Node.js resolution can find it. Then it just needs to be in the test's data via the root-level label:
stylelint_test(
name = "stylelint",
srcs = [":scss_sources"],
config = ":stylelintrc",
data = ["//:node_modules/postcss-scss"],
)
Note the //: prefix: root-level, not package-local. After that, both lint targets pass cleanly.
Wrapping up
Lint is now a first-class citizen in the Bazel setup. Every target is tagged, every package uses bazel test ... --test_tag_filters=lint, the root delegates to bazel test //packages/... instead of pnpm, and the components package covers both ESLint and Stylelint with one script. The sandbox caught one undeclared dep (postcss-scss) that bazel run was quietly hiding, and public_hoist_packages in MODULE.bazel fixed it cleanly. The whole lint story now reads exactly like the test story, which is kind of the point.
Related reading:
-
Creating a Custom ESLint Rule with TDD: how the
no-namespace-importsESLint rule that lives in this monorepo was built -
Enforcing Your CSS Standards with a Custom Stylelint Plugin: the origin story of
@pedalboard/stylelint-plugin-craftsmanlint - Testing Your Stylelint Plugin: writing tests for that Stylelint plugin
-
Enhancing a Stylelint plugin (with some TDD love): adding the
no-hardcoded-valuesrule to the same plugin - Bazel For a Frontend Monorepo: where the Bazel setup in this monorepo began
- Mastering Your Frontend Build with Bazel: Testing: the direct predecessor to this post
Top comments (0)