From 83d5b4d72413f57551bb3eccd4d0300b23ef9afa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Thu, 9 Apr 2026 15:12:42 +0000 Subject: [PATCH 1/3] feat: extend golang lint check --- Makefile | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index dcd4e6f8..27769124 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ # Use a single bash shell for each job, and immediately exit on failure -.SHELL := /usr/bin/env bash +SHELL := bash .SHELLFLAGS := -ceu .ONESHELL: @@ -13,6 +13,25 @@ ifndef VERBOSE .SILENT: endif +SHELL_SRC_FILES := $(shell find . -type f -name '*.sh' -not -path '*/.git/*') +GOLANGCI_LINT_VERSION ?= 2.11.4 +PARALLELTESTCTX_VERSION ?= 0.0.1 + +lint: lint/shellcheck lint/go +.PHONY: lint + +lint/go: + go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v$(GOLANGCI_LINT_VERSION) run + go run github.com/coder/paralleltestctx/cmd/paralleltestctx@v$(PARALLELTESTCTX_VERSION) -custom-funcs="testutil.Context" ./... +.PHONY: lint/go + +lint/shellcheck: + if test -n "$(strip $(SHELL_SRC_FILES))"; then \ + echo "--- shellcheck"; \ + shellcheck --external-sources $(SHELL_SRC_FILES); \ + fi +.PHONY: lint/shellcheck + test: go test -count=1 ./... @@ -48,4 +67,4 @@ endif .PHONY: fmt/go mocks: mcp/api.go - go generate ./mcpmock/ \ No newline at end of file + go generate ./mcpmock/ From 63d62bce4f8556c6d8b9c8024715662e43d0274e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Fri, 10 Apr 2026 10:14:22 +0000 Subject: [PATCH 2/3] feat: alignt lint check with coder/coder --- .golangci.yaml | 199 +++++++++++++++++++++++++++++++++++++++++++++++ Makefile | 4 +- go.mod | 2 + go.sum | 2 + scripts/rules.go | 171 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 376 insertions(+), 2 deletions(-) create mode 100644 .golangci.yaml create mode 100644 scripts/rules.go diff --git a/.golangci.yaml b/.golangci.yaml new file mode 100644 index 00000000..5137c542 --- /dev/null +++ b/.golangci.yaml @@ -0,0 +1,199 @@ +# Based on the Coder repo lint profile, trimmed to the checks that make sense +# for the standalone aibridge module. + +linters-settings: + dupl: + threshold: 412 + + exhaustruct: + include: + - 'httpmw\.\w+' + + gocognit: + min-complexity: 300 + + goconst: + min-len: 4 + min-occurrences: 3 + + gocritic: + enabled-checks: + - badLock + - badRegexp + - boolExprSimplify + - builtinShadowDecl + - commentedOutImport + - deferUnlambda + - dupImport + - emptyFallthrough + - hexLiteral + - indexAlloc + - initClause + - methodExprCall + - nilValReturn + - regexpPattern + - ruleguard + - sortSlice + - sprintfQuotedString + - sqlQuery + - truncateCmp + - typeAssertChain + - weakCond + settings: + ruleguard: + failOn: all + rules: "${configDir}/scripts/rules.go" + + staticcheck: + checks: ["all", "-SA1019"] + + goimports: + local-prefixes: coder.com,cdr.dev,go.coder.com,github.com/cdr,github.com/coder + + importas: + no-unaliased: true + + misspell: + locale: US + ignore-words: + - trialer + + nestif: + min-complexity: 20 + + revive: + ignore-generated-header: true + severity: warning + rules: + - name: atomic + - name: bare-return + - name: blank-imports + - name: bool-literal-in-expr + - name: call-to-gc + - name: confusing-naming + - name: confusing-results + - name: constant-logical-expr + - name: context-as-argument + - name: context-keys-type + - name: deep-exit + - name: defer + - name: dot-imports + - name: duplicated-imports + - name: early-return + - name: empty-block + - name: empty-lines + - name: error-naming + - name: error-return + - name: error-strings + - name: errorf + - name: exported + - name: flag-parameter + - name: get-return + - name: identical-branches + - name: if-return + - name: import-shadowing + - name: increment-decrement + - name: indent-error-flow + - name: modifies-value-receiver + - name: package-comments + - name: range + - name: receiver-naming + - name: redefines-builtin-id + - name: string-of-int + - name: struct-tag + - name: superfluous-else + - name: time-naming + - name: unconditional-recursion + - name: unexported-naming + - name: unexported-return + - name: unhandled-error + - name: unnecessary-stmt + - name: unreachable-code + - name: unused-parameter + exclude: "**/*_test.go" + - name: unused-receiver + - name: var-declaration + - name: var-naming + - name: waitgroup-by-value + + usetesting: + os-setenv: true + os-create-temp: false + os-mkdir-temp: false + os-temp-dir: false + os-chdir: false + context-background: false + context-todo: false + + govet: + disable: + - loopclosure + + gosec: + excludes: + - G601 + +issues: + fix: true + + exclude-dirs: + - .git + + exclude-files: + - scripts/rules.go + + exclude-rules: + - path: _test\.go + linters: + - errcheck + - forcetypeassert + - exhaustruct + - path: scripts/* + linters: + - exhaustruct + - path: scripts/rules.go + linters: + - ALL + + max-issues-per-linter: 0 + max-same-issues: 0 + +run: + timeout: 10m + +linters: + disable-all: true + enable: + - asciicheck + - bidichk + - bodyclose + - dogsled + - errcheck + - errname + - errorlint + - exhaustruct + - forcetypeassert + - gocognit + - gocritic + - goimports + - gomodguard + - gosec + - gosimple + - govet + - importas + - ineffassign + - makezero + - misspell + - nestif + - nilnil + - noctx + - paralleltest + - revive + - staticcheck + - testpackage + - tparallel + - typecheck + - unconvert + - unused + - usetesting + - dupl diff --git a/Makefile b/Makefile index 27769124..dcf974ff 100644 --- a/Makefile +++ b/Makefile @@ -14,14 +14,14 @@ ifndef VERBOSE endif SHELL_SRC_FILES := $(shell find . -type f -name '*.sh' -not -path '*/.git/*') -GOLANGCI_LINT_VERSION ?= 2.11.4 +GOLANGCI_LINT_VERSION ?= 1.64.8 PARALLELTESTCTX_VERSION ?= 0.0.1 lint: lint/shellcheck lint/go .PHONY: lint lint/go: - go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v$(GOLANGCI_LINT_VERSION) run + go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$(GOLANGCI_LINT_VERSION) run go run github.com/coder/paralleltestctx/cmd/paralleltestctx@v$(PARALLELTESTCTX_VERSION) -custom-funcs="testutil.Context" ./... .PHONY: lint/go diff --git a/go.mod b/go.mod index 9579d98f..668c3a32 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,8 @@ require ( go.opentelemetry.io/otel/trace v1.40.0 ) +require github.com/quasilyte/go-ruleguard/dsl v0.3.23 + require ( github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect diff --git a/go.sum b/go.sum index 35ab7cf2..8010e0c8 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/quasilyte/go-ruleguard/dsl v0.3.23 h1:lxjt5B6ZCiBeeNO8/oQsegE6fLeCzuMRoVWSkXC4uvY= +github.com/quasilyte/go-ruleguard/dsl v0.3.23/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis= diff --git a/scripts/rules.go b/scripts/rules.go new file mode 100644 index 00000000..4e906aa1 --- /dev/null +++ b/scripts/rules.go @@ -0,0 +1,171 @@ +// Package gorules defines custom ruleguard checks used by golangci-lint. +package gorules + +import ( + "github.com/quasilyte/go-ruleguard/dsl" + "github.com/quasilyte/go-ruleguard/dsl/types" +) + +// Use xerrors throughout the project to keep stack information. +// +//nolint:unused,deadcode,varnamelen +func xerrors(m dsl.Matcher) { + m.Import("errors") + m.Import("fmt") + m.Import("golang.org/x/xerrors") + + m.Match("fmt.Errorf($arg)"). + Suggest("xerrors.New($arg)"). + Report("Use xerrors to provide additional stacktrace information!") + + m.Match("fmt.Errorf($arg1, $*args)"). + Suggest("xerrors.Errorf($arg1, $args)"). + Report("Use xerrors to provide additional stacktrace information!") + + m.Match("errors.$_($msg)"). + Where(m["msg"].Type.Is("string")). + Suggest("xerrors.New($msg)"). + Report("Use xerrors to provide additional stacktrace information!") +} + +// useStandardTimeoutsAndDelaysInTests discourages magic timeout values in +// tests so timeout policy stays consistent across the suite. +// +//nolint:unused,deadcode,varnamelen +func useStandardTimeoutsAndDelaysInTests(m dsl.Matcher) { + m.Import("context") + m.Import("github.com/stretchr/testify/assert") + m.Import("github.com/stretchr/testify/require") + + m.Match(`context.WithTimeout($ctx, $duration)`). + Where( + m.File().Imports("testing") && + !m.File().PkgPath.Matches("testutil$") && + !m["duration"].Text.Matches("^testutil\\."), + ). + At(m["duration"]). + Report("Do not use magic numbers in test timeouts and delays. Use shared test timeout constants instead.") + + m.Match(` + $testify.$Eventually($t, func() bool { + $*_ + }, $timeout, $interval, $*_) + `). + Where( + (m["testify"].Text == "require" || m["testify"].Text == "assert") && + (m["Eventually"].Text == "Eventually" || m["Eventually"].Text == "Eventuallyf") && + !m["timeout"].Text.Matches("^testutil\\."), + ). + At(m["timeout"]). + Report("Do not use magic numbers in test timeouts and delays. Use shared test timeout constants instead.") + + m.Match(` + $testify.$Eventually($t, func() bool { + $*_ + }, $timeout, $interval, $*_) + `). + Where( + (m["testify"].Text == "require" || m["testify"].Text == "assert") && + (m["Eventually"].Text == "Eventually" || m["Eventually"].Text == "Eventuallyf") && + !m["interval"].Text.Matches("^testutil\\."), + ). + At(m["interval"]). + Report("Do not use magic numbers in test timeouts and delays. Use shared test timeout constants instead.") +} + +// FullResponseWriter ensures wrapper response writers still implement the +// interfaces most handlers expect. +func FullResponseWriter(m dsl.Matcher) { + m.Match(` + type $w struct { + $*_ + http.ResponseWriter + $*_ + } + `). + At(m["w"]). + Where(m["w"].Filter(notImplementsFullResponseWriter)). + Report("ResponseWriter \"$w\" must implement http.Flusher and http.Hijacker") +} + +func notImplementsFullResponseWriter(ctx *dsl.VarFilterContext) bool { + flusher := ctx.GetInterface(`net/http.Flusher`) + hijacker := ctx.GetInterface(`net/http.Hijacker`) + writer := ctx.GetInterface(`net/http.ResponseWriter`) + p := types.NewPointer(ctx.Type) + return !(types.Implements(p, writer) || types.Implements(ctx.Type, writer)) || + !(types.Implements(p, flusher) || types.Implements(ctx.Type, flusher)) || + !(types.Implements(p, hijacker) || types.Implements(ctx.Type, hijacker)) +} + +// slogFieldNameSnakeCase keeps structured log keys consistent. +func slogFieldNameSnakeCase(m dsl.Matcher) { + m.Import("cdr.dev/slog/v3") + m.Match(`slog.F($name, $value)`). + Where(m["name"].Const && !m["name"].Text.Matches(`^"[a-z]+(_[a-z]+)*"$`)). + Report("Field name $name must be snake_case.") +} + +// slogMessageFormat enforces sentence style for log messages. +func slogMessageFormat(m dsl.Matcher) { + m.Import("cdr.dev/slog/v3") + m.Match( + `logger.Error($ctx, $message, $*args)`, + `logger.Warn($ctx, $message, $*args)`, + `logger.Info($ctx, $message, $*args)`, + `logger.Debug($ctx, $message, $*args)`, + `$foo.logger.Error($ctx, $message, $*args)`, + `$foo.logger.Warn($ctx, $message, $*args)`, + `$foo.logger.Info($ctx, $message, $*args)`, + `$foo.logger.Debug($ctx, $message, $*args)`, + `Logger.Error($ctx, $message, $*args)`, + `Logger.Warn($ctx, $message, $*args)`, + `Logger.Info($ctx, $message, $*args)`, + `Logger.Debug($ctx, $message, $*args)`, + `$foo.Logger.Error($ctx, $message, $*args)`, + `$foo.Logger.Warn($ctx, $message, $*args)`, + `$foo.Logger.Info($ctx, $message, $*args)`, + `$foo.Logger.Debug($ctx, $message, $*args)`, + ). + Where( + m["message"].Text.Matches(`[.!?]"$`) || + (m["message"].Text.Matches(`^"[A-Z]{1}`) && + !m["message"].Text.Matches(`^"Prometheus`) && + !m["message"].Text.Matches(`^"X11`) && + !m["message"].Text.Matches(`^"CSP`) && + !m["message"].Text.Matches(`^"OIDC`)), + ). + Report(`Message $message must start with lowercase, and does not end with a special characters.`) +} + +// slogMessageLength enforces minimum length for important log messages. +func slogMessageLength(m dsl.Matcher) { + m.Import("cdr.dev/slog/v3") + m.Match( + `logger.Error($ctx, $message, $*args)`, + `logger.Warn($ctx, $message, $*args)`, + `logger.Info($ctx, $message, $*args)`, + `$foo.logger.Error($ctx, $message, $*args)`, + `$foo.logger.Warn($ctx, $message, $*args)`, + `$foo.logger.Info($ctx, $message, $*args)`, + `Logger.Error($ctx, $message, $*args)`, + `Logger.Warn($ctx, $message, $*args)`, + `Logger.Info($ctx, $message, $*args)`, + `$foo.Logger.Error($ctx, $message, $*args)`, + `$foo.Logger.Warn($ctx, $message, $*args)`, + `$foo.Logger.Info($ctx, $message, $*args)`, + ). + Where( + m["message"].Text.Matches(`^".{0,15}"$`) && + !m["message"].Text.Matches(`^"command exit"$`), + ). + Report(`Message $message is too short, it must be at least 16 characters long.`) +} + +// slogError requires error values to use slog.Error rather than slog.F. +func slogError(m dsl.Matcher) { + m.Import("cdr.dev/slog/v3") + m.Match(`slog.F($name, $value)`). + Where(m["name"].Const && m["value"].Type.Is("error") && !m["name"].Text.Matches(`^"internal_error"$`)). + Report(`Error should be logged using "slog.Error" instead.`) +} From 9c291ec483f7b5e8f50eaa458eaca467e98b8ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Banaszewski?= Date: Mon, 13 Apr 2026 11:26:54 +0000 Subject: [PATCH 3/3] review: move require go-ruleguard in go.mod --- go.mod | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 668c3a32..e464c34e 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/hashicorp/go-multierror v1.1.1 github.com/mark3labs/mcp-go v0.38.0 github.com/prometheus/client_golang v1.23.2 + github.com/quasilyte/go-ruleguard/dsl v0.3.23 github.com/sony/gobreaker/v2 v2.3.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.18.0 @@ -39,8 +40,6 @@ require ( go.opentelemetry.io/otel/trace v1.40.0 ) -require github.com/quasilyte/go-ruleguard/dsl v0.3.23 - require ( github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect