Skip to content

Repository files navigation

plaid-lint

Go Reference

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

Install from source:

go install github.com/conductorone/plaid-lint/cmd/plaid-lint@latest

Build locally:

go build -o ./plaid-lint ./cmd/plaid-lint

Run with Docker after an image has been published:

docker run --rm -v "$PWD":/src -w /src ghcr.io/conductorone/plaid-lint:latest run ./...

Usage

Run against the current module:

plaid-lint run ./...

Inspect the resolved linter set:

plaid-lint linters --json

Manage the local cache:

plaid-lint cache status
plaid-lint cache clean

Output format selection uses --out-format and supports text, json, sarif, checkstyle, codeclimate, junit-xml, tab, html, and teamcity.

Unit Mode (build-system actions)

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 protocol

unit.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.mode is full (default), facts_only (fact-producing analyzers only, no diagnostics — for dependencies excluded from the lint scope, like nogo's -facts_only), or module (go.mod-scoped linters such as gomoddirectives; run once per module).
  • Findings are results, not failures: they are recorded in the SARIF output and never affect the exit code. Exit codes: 0 analysis completed, 2 bad flags, 3 unusable inputs or internal error, 7 invalid .golangci config.
  • Every declared output is written on every success — including packages that fail to type-check, which surface as typecheck findings with an empty fact set.
  • All caching is the build system's concern: unit mode reads and writes no plaid-lint caches.

Bazel

@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 PlaidLint action: sources + direct deps' export data + direct deps' .plaidfacts in, .plaidfacts + SARIF out. Content-keyed, incremental, remote-cacheable, remote-executable; an unchanged package is never re-linted.
  • Findings fail the build through Bazel validations. PlaidLint records findings as SARIF and never fails; a separate ValidatePlaidLint action (the _validation output group) fails on them. --norun_validations flips to report-only while still producing SARIF, and --keep_going aggregates findings across targets. unused findings are excluded from per-target validation by default (see the doc in defs.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 own PlaidLint actions (rules_go's synthesized testmain does not). At aggregation time, plaid-lint collect applies the test-variant supersede rule: the internal archive analyzes a strict superset of the library's files, so the library run's unused findings about test-only symbols are dropped.
  • Persistent worker mode. plaid_lint_aspect(use_worker = True) runs PlaidLint actions 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 distinct output_suffix so their declared outputs don't collide in one build.
  • Module lint. go.mod-scoped linters (gomoddirectives) run once per module via the plaid_module_lint rule.

Aggregate enforcement (plaid_lint_suite_test)

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:

  • unused is enforced — after supersession. A test target's internal archive analyzes a strict superset of its library's files, so the library run's unused findings 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 build is report-only. Building the suite target runs the lint and collect actions and publishes <name>.plaid.sarif + <name>.plaid-report.txt to bazel-bin, but only bazel test enforces the verdict — a red gate never breaks a plain build.
  • ignore_linters prints but never fails on the named linters' findings.

Adopting in a large monorepo

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 as pkg/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/pb
    

    The flag is repeatable and matches whole path segments (pkg/pb covers pkg/pb/..., never pkg/pbx). facts_only packages still produce facts for their importers — cross-package analysis (printf wrappers, unused supersession) is unaffected — but they are never lint subjects and can never gate the suite: both the per-target validation and the suite verdict only consume mode == "full" reports (pinned by e2e assertion (p)). A package's test archives are covered too, whatever namespace rules_go synthesizes their importpaths in (<importpath>_test for 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:config names; an unused-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_test roots may need lint-scoped visibility relief. Gazelle-generated test targets are private; a suite in //bazel/lint cannot 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 targets entry is incompatible with the target platform (e.g. a wasm-only binary), Bazel marks the whole suite test SKIPPED — that is Bazel's target_compatible_with contract for tests, not a plaid-lint decision, and aspects cannot filter it away. Keep such roots out of targets (or wrap the list in a select() keyed on the platform).

  • Non-Go targets in targets are a loud analysis error, never a silent scope reduction. If a wrapper macro hands the suite a non-GoArchive target, 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.

Cache Configuration

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.

Shared Cache Trust Model

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.

Runtime Memory Ceiling

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.

Development

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.

Contributing

Issues and pull requests are welcome. See CONTRIBUTING.md for the local development workflow.

License

Apache License 2.0.

About

Faster drop-in replacement for golangci-lint with incremental L1/L2/L3 cache, parallel-safe by default.

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages