Skip to content
moovfinancialPublic

About

Custom Go analyzers for Moov engineering conventions

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

moovlint — custom Go analyzers for Moov engineering conventions

Custom golangci-lint module plugin that enforces Moov-specific Go coding standards.

Analyzers

Analyzer Status Description
spanevents shipping Detects logger.Info().Log()/logger.Warn().Log() calls in service/repo code and suggests telemetry.AddEvent or telemetry.RecordError. Only applies where a context.Context is in scope; boot and shutdown helpers without one keep their logs.
spanrequired shipping Checks exported methods on service structs taking context.Context have a telemetry.StartSpan call. Exemptions keep it aligned with the telemetry guide's "Common Pitfalls": a method that's already auto-instrumented (event production, database access) is skipped, since a manual span there duplicates the library's span; calls are followed through same-package helpers and into same-package implementations of interfaces (service -> Repository -> SQL implementation), and helpers that start their own span count as traced. A thin wrapper that hands off to a single delegate (no loop) is skipped, since the span belongs to the delegate. So is a method that enriches an existing span via telemetry.SetAttributes/AddEvent/SpanFromContext, the guide's recommended alternative to a child span at a non-entry point. Methods on test doubles (ex. a mock/fake/stub prefix or suffix) are skipped and are not followed as interface implementations, since the span belongs to the real method. RecordError alone is not treated as instrumentation.
spanlifecycle shipping Checks that spans created with telemetry.StartSpan or StartLinkedRootSpan are ended with defer span.End().
spancontext shipping Detects End() or SetName() calls on spans retrieved from context via trace.SpanFromContext. The variable match is scoped to the enclosing function.
mockcheck shipping Detects test replacements passed to same-module interfaces, including embedded-interface overrides across packages. Retains the same-package mock*/fake*/stub* check. Client interfaces are allowed by default; the exclusion is configurable.
validationflag shipping Checks that Validate() error methods wrap mvalidation.ValidateStruct returns with errors.Flag(..., errors.NotValid).
grpcstatus shipping Checks that gRPC handler methods return errors through GrpcErrorStatus.
grpcserver shipping Checks that gRPC controller structs embed their generated Unimplemented*Server type. A struct counts as a controller only when one of its methods matches a method of a cross-package *Server interface and takes that package's request type; ordinary (context.Context, T) (R, error) methods do not trigger it.
httpdecodeflag shipping Checks that HTTP request body decode errors are wrapped with errors.Flag(..., errors.NotSerializable).
midusage shipping Detects mid.MustParseID outside test files and direct equality comparisons on mid.ID; use Equals.
oteltags shipping Checks that otel struct tags use lower snake case and do not include omitempty; flags map/slice-of-struct/nested types. otel:"-" is the skip marker and is allowed, as are time.Time fields and types implementing AttributeStringer or a Value() method, which record one scalar attribute. Struct types with a value-receiver TraceAttributes() method (e.g. money.Amount) are also allowed, since they supply their own attributes. Structs whose otel-tagged fields all record one scalar attribute (e.g. spec.CardAcceptorNameAndLocation) are allowed, since they add one level of flat attributes.
controllerassert shipping Checks that HTTP controller structs with AppendRoutes have a compile-time interface assertion.
repoerrorflags advisory, opt-in Checks that repository methods flag expected database errors (AlreadyExists→NotUnique, NotFound→NotFound) with the correct errors.Flag.
timeinject shipping Detects time.Now()/time.Since()/time.Until() calls in service methods that have a stime.TimeService field on their receiver, and time.Now passed as a clock value instead of an injected clock.
contextcancel shipping Checks that context.WithCancel/WithTimeout/WithDeadline results have a corresponding defer cancel(). A cancel value that is returned or captured by a shutdown closure is managed by its receiver and is not flagged; a blank discard is.
nolintguard shipping Checks that //nolint directives target a specific linter and include an explanation.
blankdiscard shipping Detects _ = blank discards of error and sql.Result returns without an inline justification comment.
uuidgen shipping Detects uuid.New* used for ID generation in mid-based services; requires mid.NewRandomID so entity IDs carry their type.
requiregoroutine shipping Detects require.* and t.Fatal/FailNow calls inside goroutine closures (go statements, httptest handlers, callbacks) in test files.
spanname shipping Checks span names passed to telemetry.StartSpan/StartLinkedRootSpan/SetName are lower-kebab-case.
testsleep advisory, opt-in Detects time.Sleep used for synchronization in test files; suggests require.Eventually or an injected clock.
logformat shipping Detects %w verbs in Moov logger format strings; wrapping verbs are only valid in fmt.Errorf and LogErrorf.
moneyfloat shipping Detects float types used for monetary values (fields and params named amount, balance, fee, or total).
spanerrors advisory, opt-in Checks that functions which create a span record returned errors with telemetry.RecordError before returning.
mapderef advisory, opt-in Detects m[k].Field dereferences on maps of pointers or interfaces without a comma-ok check.
subtestassert shipping Detects assertion objects created from the outer test's t used inside t.Run closures.
ctornilguard advisory, opt-in Checks exported New* constructors nil-check pointer and interface dependencies before storing them. Also flags method-level checks of dependencies validated by a private implementation's constructor.
enumcast advisory, opt-in Detects unchecked conversions of raw strings to enum-like named string types outside validation and mapper functions.
fixtureplacement opt-in Flags test helpers that build same-module data models outside configured fixture packages.
modelplacement opt-in Flags exported request, response, and row models in service or repository files. File and type conventions are configurable.
writegate advisory, opt-in Flags observability SQL writes (go-libs/observability/sql and go-observability/v2/pkg/sql Exec/ExecContext/ExecContextRetryable, and methods that reach them) from API handlers: HTTP (http.ResponseWriter) and gRPC (types embedding Unimplemented*Server). API handlers should produce an event; consumers in both active regions apply the write. Writes are allowed in events consumer handlers (eventing.EventHandlerContext, RecordHandler, EventMessageHandler, matching *events.Event signatures, and Wrath of Cron *cron.Trigger / *cron.ScheduleTrigger handlers). HTTP ops endpoints that call those write methods are still flagged. Detection is by type, not file name. Enable with writegate.enabled.

Configurable checks

Placement checks and advisory checks are disabled by default. Enable them for a review pass before adding them to CI. Once enabled, findings fail the command like other analyzers; the Go analysis API has no separate warning severity.

moovlint -fixtureplacement -fixtureplacement.enabled \
  -modelplacement -modelplacement.enabled \
  -ctornilguard -ctornilguard.enabled ./...

The CLI also accepts -mockcheck.allow-interfaces, -fixtureplacement.fixture-packages, -modelplacement.model-files, -modelplacement.implementation-files, and -modelplacement.model-types. The advisory analyzers accept -<name>.enabled: ctornilguard, spanerrors, enumcast, repoerrorflags, testsleep, and mapderef. Each value is a Go regular expression. File patterns match base names; package patterns match import paths.

Set the same options in the custom plugin configuration:

linters:
  enable:
    - moovlint
  settings:
    custom:
      moovlint:
        type: module
        settings:
          mockcheck:
            allow-interfaces: 'Client$'
          fixtureplacement:
            enabled: true
            fixture-packages: '(^|/)(fixtures|testfixtures|testutil)(/|$)'
          ctornilguard:
            enabled: true
          modelplacement:
            enabled: true
            model-files: '^models?(_.*)?\.go$'
            implementation-files: '^(service|repository|repo)(_.+)?\.go$'
            model-types: '(Request|Response|Row|Record|Result|Query|Outcome|Page)$'

mockcheck.allow-interfaces matches import/path.Interface. Use a specific boundary name to permit another external dependency, or ^$ to allow none. Cross-package matching needs module metadata from the analyzer driver. Without it, the check uses same-package interfaces only. Forwarding wrappers that only embed an interface are not test replacements unless they override a method.

Placement checks skip generated code, unexported types, service implementations, and structs with behavior other than Validate. Fixture helpers must return one populated model, with an optional error. Helpers that also return a test environment are excluded. Model placement checks only package-level declarations.

The method-level constructor check uses direct returns of private struct literals after an early nil-error guard. Other construction paths, dependency writes, or an exposed field address suppress the finding. It does not infer contracts from complex constructor control flow.

Repository checks

Non-Go file checks run via moovlint repo [path]:

Checker Description
migrations Sequential naming, no IF NOT EXISTS, no direct renames, no NOT NULL without DEFAULT, and empty-string CHECK constraints on key TEXT NOT NULL columns.
structtags JSON tags use camelCase, preserve ID casing, timestamps use On/At suffix.
protobuf Field numbers are permanent and unique; gaps between numbers are reserved; PII-named fields carry the sensitive option or a Non-PII justification comment.
sqlnow No now()/CURRENT_TIMESTAMP in sqlc query files; pass a timestamp parameter so values are testable.
gomodreplace replace directives in go.mod carry a tracking ticket comment (// TODO(LINEAR-123)).

Development

make check         # Run lint + test (CI gate)
make test          # Run analyzer tests (analysistest)
make build         # Compile everything
make custom-gcl    # Build custom golangci-lint binary with moovlint plugins
moovlint repo .    # Run repository-level checks (migrations, structtags, protobuf)

Adding an analyzer

  1. Create analyzers/<name>/<name>.go with an analysis.Analyzer
  2. Register it in analyzers.go, shared by the plugin and CLI
  3. Add testdata under testdata/<name>/ with // want comments
  4. make test

Registering in a repo

# .custom-gcl.yml
version: v2.11.4
plugins:
  - module: 'github.com/moovfinancial/moovlint'
    version: v0.2.0  # use path: /path/to/moovlint to test unreleased analyzers

# .golangci.yml
linters:
  enable:
    - moovlint
  settings:
    custom:
      moovlint:
        type: module
        description: Moov engineering conventions
        settings: {}

The new placement checks and the mock/constructor extensions require a build from this source until the next release. For a separate moovlint-only config, run ./custom-gcl run --config=.golangci-moovlint.yml ./... so subpackages are included. Build the custom binary with a Go toolchain at least as new as the target module's Go version.

About

Custom Go analyzers for Moov engineering conventions

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages