| name | register-custom-linter |
| description | Register a new custom golangci-lint linter in this Terraform provider repo. Use when adding a new linter under tools/linters/ and wiring it into the plugin system and .golangci.yml config. |
Register a Custom Linter
This skill covers the three registration steps needed after creating a new analyzer under tools/linters/<name>/analyzer.go. All three are required — missing any one causes "unknown linters" errors at runtime.
Prerequisites
- The analyzer package exists at
tools/linters/<name>/analyzer.go
- It exports
var Analyzer = &analysis.Analyzer{Name: "<name>", ...}
- Its tests pass:
cd tools/linters && go test ./<name>/... -v
Step 1: Register in plugin.go
File: tools/linters/plugin.go
Add two things:
1a. Import
import (
"github.com/doitintl/terraform-provider-doit/tools/linters/<name>"
)
1b. Plugin registration in init()
Add inside the init() function, following the existing pattern:
register.Plugin("<name>", func(_ any) (register.LinterPlugin, error) {
return &analyzerPlugin{analyzers: []*analysis.Analyzer{<name>.Analyzer}}, nil
})
[!IMPORTANT]
The string passed to register.Plugin() must exactly match the Name field in your analysis.Analyzer.
Step 2: Configure .golangci.yml
File: .golangci.yml
Three sub-sections need updating:
2a. Enable the linter
Under linters.enable:, add the linter name:
linters:
enable:
- <name>
2b. Declare as custom module (CRITICAL — easy to forget!)
Under settings.custom:, add the linter declaration. This is the step that causes "unknown linters" if missed:
settings:
custom:
<name>:
type: "module"
description: "<one-line description>"
[!CAUTION]
Without this settings.custom entry, golangci-lint v2 will report unknown linters: '<name>' even though the code is compiled into the binary. The type: "module" declaration is what tells golangci-lint to look for the plugin in the custom binary.
2c. Add exclusion rules
Custom linters typically need exclusions for:
- Generated files (
_gen.go): Under the existing path: _gen\.go exclusion block
- Test files (
_test.go): Under the existing path: _test\.go exclusion block
- Data sources (
_data_source.go): If the linter is resource-only
- Provider config (
provider.go): If the linter only applies to resources/data sources
Find each exclusion block and add - <name> to the linters: list.
Example — for a resource-only linter, add to ALL of these blocks:
exclusions:
rules:
- path: _gen\.go
linters:
- <name>
- path: _test\.go
linters:
- <name>
- path: _data_source\.go
linters:
- <name>
- path: provider\.go
linters:
- <name>
Step 3: Update Go modules
cd tools/linters && go mod tidy
Step 4: Rebuild and verify
[!CAUTION]
golangci-lint aggressively caches analyzer results. When developing or debugging a new linter, you must clean the cache first. Without this, golangci-lint reuses stale results from before your linter existed, causing it to appear as if findings are missing or the linter isn't running.
cd /path/to/repo
rm -f custom-gcl
golangci-lint custom
./custom-gcl cache clean
./custom-gcl run ./internal/provider/... 2>&1 | grep <name>
Debugging tips
-
Always run ./custom-gcl cache clean after any linter code change. The Go build cache (go clean -cache) is separate from golangci-lint's analysis cache. You need BOTH if the binary seems stale.
-
The binary may rebuild but use cached analyzer results. golangci-lint custom rebuilds the binary from source via the path: directive in .custom-gcl.yml. But the analysis framework caches fact/result data separately. The binary can contain new code while golangci-lint serves stale analyzer outputs.
-
pass.Reportf() diagnostics on the same file+line get deduplicated. If your linter reports multiple findings at the same position (e.g., all at fn.Pos()), only one survives. Use unique positions per finding — either from AST nodes in the function body, or synthetic offsets from fn.Body.Lbrace.
-
golangci-lint captures stderr. fmt.Fprintf(os.Stderr, ...) from analyzer code is NOT visible in terminal output. For debug logging, write to a temp file instead:
f, _ := os.OpenFile("/tmp/linter_debug.log", os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
fmt.Fprintf(f, "debug: %v\n", value)
f.Close()
-
generated: lax suppresses diagnostics, not analysis. Gen files are still analyzed and produce facts/results. Diagnostics reported at gen file positions are silently dropped.
Checklist
Reference: Existing Linters
See tools/linters/plugin.go for all registered linters and .golangci.yml for the full config.