plaid-lint is a Go linter CLI compatible with golangci-lint v2 config and command shapes, backed by an incremental analysis engine tuned for large workspaces.
It reads .golangci.yml, .golangci.yaml, or .golangci.json automatically and supports the familiar run, linters, version, cache, config, and help subcommands.
Install from source:
go install github.com/conductorone/plaid-lint/cmd/plaid-lint@latestBuild locally:
go build -o ./plaid-lint ./cmd/plaid-lintRun with Docker after an image has been published:
docker run --rm -v "$PWD":/src -w /src ghcr.io/conductorone/plaid-lint:latest run ./...Run against the current module:
plaid-lint run ./...Inspect the resolved linter set:
plaid-lint linters --jsonManage the local cache:
plaid-lint cache status
plaid-lint cache cleanOutput format selection uses --out-format and supports text, json, sarif, checkstyle, codeclimate, junit-xml, tab, html, and teamcity.
plaid-lint unit analyzes exactly one package from declared inputs — no go list, no module
resolution, no Go toolchain, no network. It is the execution mode a build system (Bazel, a REAPI
executor) invokes per package: dependency types come from compiler export data named by an
importcfg, cross-package analysis facts flow through .plaidfacts files along dependency edges,
and diagnostics are written as SARIF 2.1.0 (including suggested-fix edits).
plaid-lint unit --cfg unit.json
plaid-lint unit --worker # Bazel persistent-worker JSON protocolunit.json (schema 1):
{
"schema": 1,
"package": {
"path": "example.com/mod/pkg/foo",
"go_files": ["pkg/foo/a.go"],
"goos": "linux", "goarch": "arm64", "go_version": "1.26"
},
"deps": {
"importcfg": "foo.importcfg",
"facts": {"example.com/mod/pkg/bar": "bar.plaidfacts"}
},
"module": {"go_mod": "go.mod", "path": "example.com/mod"},
"analysis": {"config": ".golangci.yml", "mode": "full"},
"out": {"facts": "foo.plaidfacts", "sarif": "foo.plaid.sarif"}
}analysis.modeisfull(default),facts_only(fact-producing analyzers only, no diagnostics — for dependencies excluded from the lint scope, like nogo's-facts_only), ormodule(go.mod-scoped linters such asgomoddirectives; run once per module).- Findings are results, not failures: they are recorded in the SARIF output and never affect the
exit code. Exit codes:
0analysis completed,2bad flags,3unusable inputs or internal error,7invalid.golangciconfig. - Every declared output is written on every success — including packages that fail to
type-check, which surface as
typecheckfindings with an empty fact set. - All caching is the build system's concern: unit mode reads and writes no plaid-lint caches.
@plaid_lint//bazel:defs.bzl packages unit mode as a Bazel aspect plus a module-scoped
rule, so bazel build lints every Go package as native, cacheable, remote-executable
actions — the nogo model, with golangci-lint config compatibility.
Setup:
# MODULE.bazel
bazel_dep(name = "plaid_lint", version = "0.0.0")
# Until plaid_lint is published to the Bazel Central Registry, point
# the dep at a checkout (or use archive_override / git_override):
local_path_override(
module_name = "plaid_lint",
path = "third_party/plaid-lint",
)# tools/lint/linters.bzl — bind the aspect to your config once
# (command-line aspects cannot take parameters).
load("@plaid_lint//bazel:defs.bzl", "plaid_lint_aspect")
plaid = plaid_lint_aspect(
config = Label("//:.golangci.yml"),
module_path = "example.com/yourmodule",
)# .bazelrc
build:lint --aspects=//tools/lint:linters.bzl%plaid
build:lint --output_groups=+plaid_report
Then bazel build --config=lint //... (and bazel test --config=lint ...) lints
everything it builds. What you get:
- Per-package actions, cached like compiles. Each Go package gets one
PlaidLintaction: sources + direct deps' export data + direct deps'.plaidfactsin,.plaidfacts+ SARIF out. Content-keyed, incremental, remote-cacheable, remote-executable; an unchanged package is never re-linted. - Findings fail the build through Bazel validations.
PlaidLintrecords findings as SARIF and never fails; a separateValidatePlaidLintaction (the_validationoutput group) fails on them.--norun_validationsflips to report-only while still producing SARIF, and--keep_goingaggregates findings across targets.unusedfindings are excluded from per-target validation by default (see the doc indefs.bzl); generated-only packages (e.g. rules_go's synthesized test main) and external-repository deps contribute facts but are not lint subjects. - Test sources are covered. A
go_test's internal and external test archives get their ownPlaidLintactions (rules_go's synthesizedtestmaindoes not). At aggregation time,plaid-lint collectapplies the test-variant supersede rule: the internal archive analyzes a strict superset of the library's files, so the library run'sunusedfindings about test-only symbols are dropped. - Persistent worker mode.
plaid_lint_aspect(use_worker = True)runsPlaidLintactions through Bazel's JSON persistent-worker protocol, amortizing process startup and config parsing across actions. Output is byte-identical to one-shot execution. Give each additionally-configured aspect variant a distinctoutput_suffixso their declared outputs don't collide in one build. - Module lint.
go.mod-scoped linters (gomoddirectives) run once per module via theplaid_module_lintrule.
Per-target validation cannot enforce unused: whether a library symbol is dead
depends on the test archives that also analyze it, which live in other targets.
plaid_lint_suite_test closes that gap — it is a test rule that runs the plaid
aspect over targets (and their transitive Go deps/embeds), aggregates every
SARIF report with plaid-lint collect, applies the test-variant supersede rule,
and fails on what survives.
The suite aspect rides a rule attribute, so it cannot take parameters the way a
--aspects factory aspect does; it is configured through build settings instead:
# .bazelrc — suite configuration (build settings are global per invocation)
common --@plaid_lint//bazel:config=//:.golangci.yml
common --@plaid_lint//bazel:module_path=example.com/yourmodule
# optional: --@plaid_lint//bazel:facts_only=<pkg,...> --@plaid_lint//bazel:use_worker=true
# BUILD.bazel
load("@plaid_lint//bazel:defs.bzl", "plaid_lint_suite_test")
plaid_lint_suite_test(
name = "lint",
go_mod = ":go.mod", # optional: adds module-scoped linters
module_path = "example.com/yourmodule", # required with go_mod
targets = [
"//app",
"//lib",
"//lib:lib_test", # include test targets: their archives supersede
],
)Semantics:
unusedis enforced — after supersession. A test target's internal archive analyzes a strict superset of its library's files, so the library run'sunusedfindings about test-only symbols are dropped at collect time and everything that survives fails the test. Exported symbols are treated as used (unused's exported-is-used mode), so the suite never flags a package's public API.- Failure classes stay distinct. Findings fail the test (the runner prints the report and gates on the verdict's enforced count); an unreadable or malformed SARIF report fails the PlaidCollect action; a bad lint config fails the PlaidLint actions themselves. A red gate is never confusable with broken infrastructure.
bazel buildis report-only. Building the suite target runs the lint and collect actions and publishes<name>.plaid.sarif+<name>.plaid-report.txtto bazel-bin, but onlybazel testenforces the verdict — a red gate never breaks a plain build.ignore_lintersprints but never fails on the named linters' findings.
Patterns proven on a ~2,000-package repo (28k-action cold suite in ~12.5 min, fully cached and incremental after that):
-
Exclude generated trees with
facts_only. Checked-in generated code (protoc/plugin output such aspkg/pb/**) is not auto-detected — only Bazel-generated files are. Configure the import-path prefix:common --@plaid_lint//bazel:facts_only=example.com/yourmodule/pkg/pbThe flag is repeatable and matches whole path segments (
pkg/pbcoverspkg/pb/..., neverpkg/pbx).facts_onlypackages still produce facts for their importers — cross-package analysis (printf wrappers,unusedsupersession) is unaffected — but they are never lint subjects and can never gate the suite: both the per-target validation and the suite verdict only consumemode == "full"reports (pinned by e2e assertion (p)). A package's test archives are covered too, whatever namespace rules_go synthesizes their importpaths in (<importpath>_testfor embed-based tests, label-derived paths for no-embed tests): test archives also match by their label package, bare and module-qualified. -
A dedicated, narrower config is fine. The suite reads whatever
--@plaid_lint//bazel:confignames; anunused-only config for the aggregate gate while per-target validation runs the full set (or vice versa) is a supported shape — configs bind per invocation, not per rule. -
Listing generated
go_testroots may need lint-scoped visibility relief. Gazelle-generated test targets are private; a suite in//bazel/lintcannot see them. Scope the relief to the lint config so normal builds keep enforcement:test:lint --check_visibility=false(or grant visibility on the test targets). -
Platform-incompatible roots must be excluded by the caller. If any
targetsentry is incompatible with the target platform (e.g. a wasm-only binary), Bazel marks the whole suite test SKIPPED — that is Bazel'starget_compatible_withcontract for tests, not a plaid-lint decision, and aspects cannot filter it away. Keep such roots out oftargets(or wrap the list in aselect()keyed on the platform). -
Non-Go targets in
targetsare a loud analysis error, never a silent scope reduction. If a wrapper macro hands the suite a non-GoArchivetarget, the build fails naming it.
See examples/bazel for a complete consumer workspace — seeded
findings, worker variant, module lint — exercised end to end by its e2e.sh.
By default, all cache tiers use the local filesystem under the platform cache directory with a plaid-lint suffix.
Override the cache location with PLAID_CACHE_DIR=<path>. The path is used verbatim with no suffix appended.
Override the cache backend with:
| Variable | Purpose |
|---|---|
PLAID_CACHE_BACKEND |
Global backend default. Values: local, gocacheprog. |
PLAID_L0_CACHE_BACKEND |
Per-tier override for diagnostic and facts streams. |
PLAID_L1_CACHE_BACKEND |
Per-tier override for per-analyzer package results. |
PLAID_L2_CACHE_BACKEND |
Per-tier override for export data and package facts. |
PLAID_L0_GOCACHEPROG |
Helper command for the L0 cache tier. |
PLAID_L1_GOCACHEPROG |
Helper command for the L1 cache tier. |
PLAID_L2_GOCACHEPROG |
Helper command for the L2 cache tier. |
When the global backend is gocacheprog, L0 and L2 route through the helper while L1 stays local unless PLAID_L1_CACHE_BACKEND=gocacheprog is set explicitly.
When a tier selects the gocacheprog backend, its PLAID_<TIER>_GOCACHEPROG helper command takes precedence over GOCACHEPROG; tiers without their own helper continue to use GOCACHEPROG. The helper variable does not select a backend by itself. For example, a separate L1 helper requires both PLAID_L1_CACHE_BACKEND=gocacheprog and PLAID_L1_GOCACHEPROG=....
Location resolution order is:
PLAID_CACHE_DIR
GOLANGCI_LINT_CACHE
$XDG_CACHE_HOME/plaid-lint
os.UserCacheDir()/plaid-lint
$TMPDIR/plaid-lint-cache
If any tier resolves to gocacheprog, its PLAID_<TIER>_GOCACHEPROG or GOCACHEPROG value must point at a helper implementing the Go cache program protocol.
A shared cache is a performance layer, not a security boundary. plaid-lint verifies helper-returned bodies against their content digest to catch corruption, but a writer that can control both the action record and the body can still make them match.
Use separate writable namespaces, bucket prefixes, helper configuration, or IAM policies for jobs with different trust levels. Protected branch CI should not read shared-cache entries that untrusted fork jobs, lower-trust repositories, or unrelated tenants can write.
On Linux, plaid-lint auto-configures GOMEMLIMIT from the cgroup memory limit and sets the Go runtime soft ceiling to 75% of that value.
The auto ceiling is skipped when GOMEMLIMIT is already set, when PLAID_DISABLE_AUTO_GOMEMLIMIT=1, or when no finite cgroup limit can be detected.
Run the local validation gates from the repository root:
go build ./...
go vet ./...
go test -p 1 $(go list ./... | grep -Ev '/internal/gopls/internal/(expect|gcimporter|imports)$')The repository has no Makefile and no vendored dependencies. The excluded test packages are copied upstream gopls tests whose fixture/proxy data is not present in this fork.
Issues and pull requests are welcome. See CONTRIBUTING.md for the local development workflow.
Apache License 2.0.