A custom golangci-lint module plugin providing Azure provider-specific linting rules built on Go's analysis framework.
go install github.com/katbyte/azproviderlint@latestThen run directly (all rules run by default):
azproviderlint ./...Each rule is also a flag, and setting any rule flag switches to running only the named rules; a category on its own (-AZG) runs every rule in that category:
azproviderlint -AZG001 ./...
azproviderlint -AZR001 -AZR003 ./...
azproviderlint -AZG ./...
azproviderlint -AZG -AZR001 ./...Add to your .custom-gcl.yml:
version: v2.12.2
plugins:
- module: "github.com/katbyte/azproviderlint"
import: "github.com/katbyte/azproviderlint/plugin"
version: v0.1.0Build the custom binary:
golangci-lint customThen enable in .golangci.yml:
linters:
enable:
- azproviderlint
settings:
custom:
azproviderlint:
type: moduleIndividual rules can be enabled/disabled via plugin settings (an empty enable list means all rules):
linters:
settings:
custom:
azproviderlint:
type: module
settings:
disable: [AZR002]To run just azproviderlint through the custom binary, skipping every other linter:
custom-gcl run --enable-only azproviderlint ./...There is no CLI flag for a single rule — combine --enable-only with an enable: [AZG001] list in the plugin settings above, or use the standalone binary's per-rule flags.
The plugin requires every consumer to build a custom golangci-lint binary (golangci-lint custom), but that one-time cost buys a lot on a codebase the size of a provider:
- One package-load instead of two. On azurerm this is the dominant cost — loading and type-checking the provider codebase (with its enormous vendor tree) takes minutes, and every separate analysis binary pays it again from scratch. Folding checks into golangci-lint amortizes it, and golangci-lint's result cache makes warm local re-runs dramatically faster; a standalone multichecker reloads the world every single time.
- Unified config and reporting.
.golangci.ymlpath exclusions (generated files,/sdk/,third_party— already curated in the provider repo) apply to these checks for free; one output stream, one CI job, SARIF/annotations, and--new-from-rev— the killer feature for a codebase with 22+ pre-existing findings per service, since checks can be enforced on new code only instead of azignoring a decade of history. //nolintworks uniformly alongside//azignore(see Ignoring Reports).
The standalone binary remains the right tool for one-off or single-rule runs (azproviderlint -AZG001 ./...), editor integrations that expect a plain analysis-style vet tool, and quick iteration while developing new checks.
Rules are named AZ<category letter><number>, aligned with tfproviderlint's category letters (R, S, V, AT) where they overlap.
| Rule | Description |
|---|---|
| AZG000 | //azignore directives must include a reason (//azignore:AZR001 - <reason>) documenting why the check does not apply — bare directives still suppress, but are themselves reported; disable this check to accept bare directives |
| AZG001 | err := SomeFunc() or _, err := SomeFunc() followed by if err != nil should be combined into a single if init statement |
| AZG002 | Error messages should describe the expected format instead of saying invalid format of ... |
| AZG003 | pointer.To(sdk.SomeEnum(v)) explicit go-azure-sdk enum conversions must use the generic pointer.ToEnum[sdk.SomeEnum](v) helper instead |
| AZG004 | y := <zero>; if x != nil { y = *x } zero-value initialization followed by a nil check and pointer dereference must use the generic pointer.From(x) helper instead |
| AZG005 | x := <expr> immediately followed by y = x or return x, with no other use of x, should be inlined into the consuming statement |
| Rule | Description |
|---|---|
| AZR001 | SetId must not be passed a dereferenced pointer (d.SetId(*read.ID)) — use a generated Resource ID Formatter/Parser and d.SetId(id.ID()) |
| AZR002 | Resources must register separate Create and Update methods instead of a combined CreateUpdate method |
| AZR003 | d.Get / metadata.ResourceData.Get must not be used inside a resource's Delete function, where it does not work as expected |
| AZR004 | Resource IDs must not be compared with ==/!= — use resourceids.Match |
| AZR005 | features.TreatUserSpecifiedSegmentsAsCaseInsensitive must not be set — the case-aware comparisons feature is not ready for use |
| AZR006 | ctx must not be assigned directly from meta.(*clients.Client).StopContext — use timeouts.ForCreate/ForRead/ForUpdate/ForDelete so Custom Timeouts work |
| AZR007 | StateChangeConf from github.com/hashicorp/terraform-plugin-sdk/v2/helper/retry must not be used — prefer a custom poller implementing pollers.PollerType driven via pollers.NewPoller(...).PollUntilDone(ctx) |
| Rule | Description |
|---|---|
| AZD001 | Data sources must return an error when a resource cannot be found, not call d.SetId("") |
| AZD002 | Data sources must return an error when a resource cannot be found, not call metadata.MarkAsGone |
| Rule | Description |
|---|---|
| AZS001 | Typed SDK model fields (tagged tfschema) must use 64-bit numeric types — int64 not int/int16/int32, float64 not float32 — including slices, maps, pointers, named types, and aliases of them |
| AZS002 | Schema Default values must match the declared Type — a bool default on a TypeInt schema only fails at plan time; named constants are resolved via the type checker |
| AZS003 | Optional/required TypeList blocks whose properties are all optional with no defaults allow foo {}, which can crash expand functions or cause spurious diffs — constrain with AtLeastOneOf/ExactlyOneOf, a Required property, or a Default |
| AZS004 | validation.StringInSlice with a hand-written list of SDK enum values must use the SDK's PossibleValuesFor<Enum>() helper instead — partial lists reject valid API values, and even complete lists go stale when the SDK adds new ones |
| AZS005 | Registered resources must have a data source of the same name — checked across untyped plugin SDK maps, typed SDK slices and framework wrapped slices, including feature-flagged conditional registration |
| AZS006 | Data sources must expose the properties of their same-named resource — compares recursively collected schema property names per registration flavour (untyped maps, typed Arguments()/Attributes(), framework Schema()) and reports resource properties absent from the data source |
| Rule | Description |
|---|---|
| AZC001 | Azure SDK (track1 & kermit) clients must be created via NewFoosClientWithBaseURI with the resource manager endpoint explicitly specified, not NewFoosClient(o.SubscriptionId) |
| Rule | Description |
|---|---|
| AZT001 | Acceptance test files (resource, data source, action, ephemeral — incl. list and generated variants) must use an external _test package to prevent circular dependencies |
| AZT002 | Tests (_test.go files only) must not obtain credentials via os.Getenv("ARM_CLIENT_ID"/"ARM_CLIENT_SECRET"/"ARM_CLIENT_SECRET_ALT") — create an azurerm_user_assigned_identity with minimal permissions instead |
No rules yet — reserved for property naming convention rules (e.g. percentage properties using a _percentage suffix rather than _in_percent).
No rules yet — reserved for missing/incorrect validation rules (e.g. string arguments without a ValidateFunc).
When run via golangci-lint, all azproviderlint reports on a line can be ignored with a //nolint:azproviderlint comment at the end of the offending line or on the line immediately preceding it.
To ignore a specific check — leaving the others active, and working under any driver including the standalone binary — use a //azignore:<Rule> - <reason> comment in the same positions. Multiple rules can be listed separated by commas, and the reason is free text after the rule list — the - separator (–/— also work) is optional:
d.SetId(*read.ID) //azignore:AZR001 - legacy resource, ID formatter tracked in #1234
//azignore:AZG001,AZR003 combined form obscures the retry loop here
err := client.Delete(ctx, id)The reason is required: directives without one still suppress their target checks, but are themselves reported by AZG000 (disable that check to drop the requirement).