Custom ruleguard rules for modernizing Go code to use Go 1.20+ through Go 1.26+ features.
# Recommended: binary installation
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.8.0Note:
go installis not recommended as it may produce unreliable builds. See the official installation docs for details.
-
Clone or copy the rules to your project:
git clone https://github.com/yourorg/moderngo.git # Or copy the rules/ directory to your project -
Add to your
.golangci.yml:version: "2" linters: enable: - gocritic settings: gocritic: enabled-checks: - ruleguard settings: ruleguard: rules: "${config-path}/rules/*.go"
-
Run the linter:
golangci-lint run ./...
When you modify rules, clear the cache to ensure changes take effect:
golangci-lint cache clean && golangci-lint run ./...To run only moderngo rules (via gocritic/ruleguard):
golangci-lint run --enable-only gocritic ./...main.go:15:2: ruleguard: suggestion: use slices.Sort instead of sort.Ints (gocritic)
main.go:23:5: ruleguard: suggestion: use time.DateTime constant instead of "2006-01-02 15:04:05" (gocritic)
main.go:31:2: ruleguard: suggestion: use range over int (Go 1.22+) (gocritic)
Rules are organized by package/topic rather than Go version for easier maintenance.
| File | Topic | Rules |
|---|---|---|
| errors.go | Error handling | errors.AsType |
| strings.go | String iteration | Lines, SplitSeq, FieldsSeq |
| time.go | Time formatting & timers | DateTime constants, Timer len(), deferred time.Since |
| slices.go | Slice operations | Sort, Clone, Backward, map keys/values, bytes.Clone |
| sync.go | Synchronization | WaitGroup.Go |
| builtins.go | Built-in functions | min/max, clear(), range-over-int, append no-op, new(expr) |
| reflect.go | Reflection | TypeAssert, PointerTo, TypeFor, deprecated headers, Fields/Methods/Ins/Outs iterators |
| random.go | Random numbers | math/rand/v2 migration, Seed/Read deprecation |
| testing.go | Testing utilities | b.Loop, t.Context, ArtifactDir |
| net.go | Network & paths | JoinHostPort, filepath.IsLocal, error before use, ReverseProxy.Director |
| crypto.go | Cryptography | Cipher modes, RSA key size, elliptic deprecation, PKCS#1 v1.5 |
| runtime.go | Runtime functions | SetFinalizer, GOROOT deprecation |
Error handling patterns.
See: errors.AsType
Old pattern:
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
fmt.Println(pathErr.Path)
}New pattern:
if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
fmt.Println(pathErr.Path)
}Benefits:
- Type-safe: checked at compile time, no risk of passing wrong pointer type
- Faster: avoids reflection internally
- Reduces LOC: no separate variable declaration needed
- Scopes the variable to the
ifblock
String iteration patterns using Go 1.23+ iterator APIs.
See: strings package
Old pattern:
for _, line := range strings.Split(s, "\n") {
process(line)
}New pattern (Go 1.23+):
for line := range strings.Lines(s) {
process(line)
}Benefits:
- No intermediate slice allocation
- Handles both
\nand\r\nline endings automatically
Old pattern:
for _, part := range strings.Split(s, ",") {
process(part)
}New pattern (Go 1.23+):
for part := range strings.SplitSeq(s, ",") {
process(part)
}Old pattern:
for _, field := range strings.Fields(s) {
process(field)
}New pattern (Go 1.23+):
for field := range strings.FieldsSeq(s) {
process(field)
}Time formatting and timer patterns.
See: time package
Old pattern:
t.Format("2006-01-02 15:04:05")
t.Format("2006-01-02")
t.Format("15:04:05")New pattern:
t.Format(time.DateTime)
t.Format(time.DateOnly)
t.Format(time.TimeOnly)Broken pattern (Go 1.23+):
timer := time.NewTimer(1 * time.Second)
if len(timer.C) > 0 { // Always false - channels are now unbuffered
<-timer.C
}Correct pattern:
timer := time.NewTimer(1 * time.Second)
select {
case <-timer.C:
// timer fired
default:
// timer not yet fired
}Why: In Go 1.23+, timer and ticker channels have capacity 0 (unbuffered).
Broken pattern:
func foo() {
start := time.Now()
defer log.Println(time.Since(start)) // Evaluated NOW, not at exit!
// ... work ...
}Correct pattern:
func foo() {
start := time.Now()
defer func() { log.Println(time.Since(start)) }()
// ... work ...
}Slice operations using Go 1.21-1.23+ APIs.
See: slices package, maps package
Old patterns:
sort.Ints(nums)
sort.Strings(strs)
sort.Float64s(floats)New pattern:
slices.Sort(nums)
slices.Sort(strs)
slices.Sort(floats)Old patterns:
clone := append([]T(nil), original...)
clone := append([]T{}, original...)New pattern:
clone := slices.Clone(original)Old patterns:
clone := append([]byte(nil), original...)
clone := append([]byte{}, original...)New pattern:
clone := bytes.Clone(original)Old pattern:
for i := len(s) - 1; i >= 0; i-- {
process(s[i])
}New pattern:
for i, v := range slices.Backward(s) {
process(v)
}Old pattern:
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}New pattern:
keys := slices.Collect(maps.Keys(m))
// Or for sorted keys:
keys := slices.Sorted(maps.Keys(m))Synchronization patterns.
See: sync.WaitGroup.Go
Old pattern:
wg.Add(1)
go func() {
defer wg.Done()
doSomething()
}()New pattern:
wg.Go(func() {
doSomething()
})Note: Only flags simple patterns without closure parameters. Patterns with closure parameters cannot be directly converted since wg.Go() only accepts func().
Built-in function patterns.
Old pattern:
result := int(math.Min(float64(a), float64(b)))
result := int(math.Max(float64(a), float64(b)))New pattern:
result := min(a, b)
result := max(a, b)Old pattern:
for k := range m {
delete(m, k)
}New pattern:
clear(m)Old pattern:
for i := 0; i < n; i++ {
process(i)
}New pattern:
for i := range n {
process(i)
}Broken pattern:
slice = append(slice) // No effectOld pattern (slice hack):
field := &[]string{"hello"}[0]
field := &[]int{42}[0]New pattern:
field := new("hello")
field := new(42)Benefits:
- Eliminates the obscure slice-literal-index hack
- Clearer intent: "pointer to this value"
- No intermediate slice allocation
- Works with any expression, including function calls
Reflection patterns.
See: reflect.TypeAssert, reflect.PointerTo, reflect.TypeFor
Old pattern (allocates):
val := v.Interface().(string)New pattern (no allocation):
val := reflect.TypeAssert[string](v)Deprecated pattern:
ptrType := reflect.PtrTo(t)New pattern:
ptrType := reflect.PointerTo(t)Old pattern:
t := reflect.TypeOf((*MyType)(nil)).Elem()New pattern:
t := reflect.TypeFor[MyType]()See: unsafe.Slice, unsafe.String
Deprecated pattern:
sh := (*reflect.SliceHeader)(unsafe.Pointer(&slice))
hdr := (*reflect.StringHeader)(unsafe.Pointer(&str))New pattern:
slice := unsafe.Slice(ptr, len)
str := unsafe.String(ptr, len)Old pattern:
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
// use f
}New pattern:
for f := range t.Fields() { // reflect.Type
// use f (StructField)
}
for sf, v := range val.Fields() { // reflect.Value
// use sf (StructField) and v (Value)
}Old pattern:
for i := 0; i < t.NumMethod(); i++ {
m := t.Method(i)
// use m
}New pattern:
for m := range t.Methods() { // reflect.Type
// use m (Method)
}
for m, v := range val.Methods() { // reflect.Value
// use m (Method) and v (Value)
}Old pattern:
for i := 0; i < t.NumIn(); i++ {
param := t.In(i)
}
for i := 0; i < t.NumOut(); i++ {
ret := t.Out(i)
}New pattern:
for param := range t.Ins() {
// use param
}
for ret := range t.Outs() {
// use ret
}Benefits (all reflect iterators):
- Cleaner, more idiomatic Go iteration
- No off-by-one risk
- Consistent with Go 1.23+ iterator patterns
- Reduces boilerplate
Random number generation patterns.
See: math/rand/v2
Method renames:
rand.Intn(n)→rand.IntN(n)rand.Int31()→rand.Int32()rand.Int31n(n)→rand.Int32N(n)rand.Int63()→rand.Int64()rand.Int63n(n)→rand.Int64N(n)
Deprecated patterns:
rand.Seed(time.Now().UnixNano()) // Auto-seeded since 1.20
rand.Read(buf) // Use crypto/rand for securityNew patterns:
// For reproducibility, use a local source
r := rand.New(rand.NewSource(42))
// For cryptographic randomness
crypto_rand.Read(buf)Testing utilities.
See: testing.B.Loop, testing.T.Context
Old pattern:
func BenchmarkFoo(b *testing.B) {
for i := 0; i < b.N; i++ {
// work
}
}New pattern:
func BenchmarkFoo(b *testing.B) {
for b.Loop() {
// work
}
}Benefits:
- Setup/cleanup executes only once per
-count - Compiler cannot optimize away the loop body
Old pattern:
func TestFoo(t *testing.T) {
ctx := context.Background()
result, err := doSomething(ctx)
}New pattern:
func TestFoo(t *testing.T) {
ctx := t.Context()
result, err := doSomething(ctx)
}Benefits:
- Context is automatically canceled when test completes
- Resources are released promptly on test failure
Old pattern:
func TestFoo(t *testing.T) {
dir, err := os.MkdirTemp("", "test-output-*")
if err != nil { t.Fatal(err) }
defer os.RemoveAll(dir)
// write test artifacts to dir
}New pattern:
func TestFoo(t *testing.T) {
dir := t.ArtifactDir()
// write test artifacts to dir
// directory persists after test for inspection
}Benefits:
- No error handling needed
- Automatically named after the test
- Survives test cleanup (unlike
t.TempDir) - Location reported with
-artifactsflag
Note: ArtifactDir is for test output files (golden files, debug output, snapshots), not for temporary scratch space. If you need a directory that is cleaned up after the test, continue using t.TempDir().
Network and path utilities.
See: net.JoinHostPort, filepath.IsLocal
Old pattern:
addr := fmt.Sprintf("%s:%d", host, port)New pattern:
addr := net.JoinHostPort(host, strconv.Itoa(port))Why: net.JoinHostPort properly handles IPv6 addresses by wrapping them in brackets.
Old pattern:
if strings.Contains(userPath, "..") {
return errors.New("invalid path")
}New pattern:
if !filepath.IsLocal(userPath) {
return errors.New("invalid path")
}Benefits:
- Comprehensive path validation
- Handles OS-specific path separators
- Prevents directory traversal attacks
Deprecated pattern:
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = "https"
req.URL.Host = "backend:8080"
},
}New pattern:
proxy := &httputil.ReverseProxy{
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(targetURL)
r.SetXForwarded()
},
}Security issue: When using Director, a malicious client can designate security headers (e.g., X-Forwarded-For) as hop-by-hop headers via the Connection header. The proxy strips hop-by-hop headers after Director runs, effectively removing headers that Director set. Rewrite operates on a copy where hop-by-hop headers have already been removed.
Broken pattern:
f, err := os.Open(path)
name := f.Name() // PANICS if err != nil
if err != nil { ... }Correct pattern:
f, err := os.Open(path)
if err != nil { ... }
name := f.Name()Cryptography patterns.
See: crypto/cipher, crypto/ecdh
Deprecated:
cipher.NewOFB- OFB modecipher.NewCFBEncrypter- CFB encryptioncipher.NewCFBDecrypter- CFB decryption
New pattern:
// Authenticated encryption (preferred)
aead, _ := cipher.NewGCM(block)
ciphertext := aead.Seal(nil, nonce, plaintext, additionalData)
// Stream cipher without authentication
stream := cipher.NewCTR(block, iv)Weak pattern:
key, _ := rsa.GenerateKey(rand.Reader, 1024) // Weak
key, _ := rsa.GenerateKey(rand.Reader, 512) // Rejected in Go 1.24+Recommended:
key, _ := rsa.GenerateKey(rand.Reader, 2048) // Minimum recommended
key, _ := rsa.GenerateKey(rand.Reader, 4096) // For long-term securityDeprecated pattern:
import "crypto/elliptic"
key, _ := elliptic.GenerateKey(curve, rand.Reader)New pattern:
import "crypto/ecdh"
key, _ := ecdh.P256().GenerateKey(rand.Reader)Deprecated:
key, _ := rsa.GenerateMultiPrimeKey(rand.Reader, nprimes, bits)Use instead:
key, _ := rsa.GenerateKey(rand.Reader, bits)Deprecated:
ciphertext, _ := rsa.EncryptPKCS1v15(rand.Reader, pub, plaintext)
plaintext, _ := rsa.DecryptPKCS1v15(rand.Reader, priv, ciphertext)
rsa.DecryptPKCS1v15SessionKey(rand.Reader, priv, ciphertext, key)New pattern:
// OAEP encryption (preferred)
ciphertext, _ := rsa.EncryptOAEP(sha256.New(), rand.Reader, pub, plaintext, nil)
plaintext, _ := rsa.DecryptOAEP(sha256.New(), rand.Reader, priv, ciphertext, nil)
// OAEP with separate MGF1 hash (Go 1.26+)
ciphertext, _ := rsa.EncryptOAEPWithOptions(rand.Reader, pub, plaintext, nil,
&rsa.OAEPOptions{Hash: crypto.SHA256, MGFHash: crypto.SHA1})Security issue: PKCS#1 v1.5 encryption is vulnerable to Bleichenbacher's chosen-ciphertext attack, which allows an attacker to decrypt ciphertexts by observing padding errors. OAEP provides provable security against this class of attack.
Runtime function patterns.
See: runtime.AddCleanup
Old pattern:
runtime.SetFinalizer(obj, func(o *Type) { cleanup(o) })New pattern:
runtime.AddCleanup(obj, func(arg ArgType) { cleanup(arg) }, arg)Benefits of AddCleanup:
- Multiple cleanups per object
- Can attach to interior pointers
- No cycle leaks (SetFinalizer can leak cycles)
- Doesn't delay object freeing
Deprecated:
root := runtime.GOROOT()New pattern:
go env GOROOTWhy: runtime.GOROOT() may not reflect the actual GOROOT when the binary is moved or when using toolchains.
The rules are configured in .golangci.yml (golangci-lint v2):
version: "2"
linters:
settings:
gocritic:
enabled-checks:
- ruleguard
settings:
ruleguard:
rules: "${config-path}/rules/*.go"The ${config-path} variable resolves to the directory containing .golangci.yml.
Run the automated test suite:
./test.sh # run all tests
./test.sh -v # verbose output (shows golangci-lint output)The test suite uses fixture files in testdata/ with // want: "fragment" annotations:
// testdata/errors_check.go
errors.As(err, &target) // want: "use errors.AsType"The test runner verifies that:
- Every annotated line produces a diagnostic containing the expected fragment
- No unannotated lines produce unexpected diagnostics (false positives)
The testdata/ directory requires the go-ruleguard/dsl dependency:
cd testdata && go get github.com/quasilyte/go-ruleguard/dsl@latest- Create or update a file in
rules/with//go:build ruleguardconstraint - Import
github.com/quasilyte/go-ruleguard/dsl - Write rule functions that take
dsl.Matcherparameter - Add Go doc reference links in comments (e.g.,
// See: https://pkg.go.dev/...) - Document the old and new patterns clearly
- Run
golangci-lint cache clean && golangci-lint runto test
See go-ruleguard documentation for pattern syntax.
Ruleguard rules match syntactic patterns and cannot always determine semantic intent. We manage false positives through three strategies:
Where possible, rules use .Where(m["x"].Type.Is(...)) to restrict matches to the correct types. For example, MapKeysCollection uses .Where(m["m"].Type.Is("map[$k]$v")) to only match actual maps, preventing false positives on channels and iterators that share the same for v := range x syntax.
When false positives can't be eliminated in the DSL, the report message includes a caveat that makes it self-evident when the match is spurious. Ruleguard expands metavariables in the message, so the developer (or LLM) sees the actual expression and can immediately judge correctness.
Example: The SliceRepeat rule appends "; false positive if $s depends on the loop variable". When it fires on a flatMap pattern, the expanded message reads:
use slices.Repeat(rp.buildCombinedResults(&batchResponse.Analyses[i], ...), batchResponse.Analyses)
instead of manual repetition loop (Go 1.23+); false positive if
rp.buildCombinedResults(&batchResponse.Analyses[i], ...) depends on the loop variable
The reference to [i] in the expanded expression makes the false positive obvious.
Rules with unfixable false positives are documented in CLAUDE.md under "Known False Positives" with:
- The pattern that triggers the false positive
- Why it's not fixable in ruleguard DSL
- What mitigation is applied (caveat message, type guard, etc.)
| Rule | Trigger | Mitigation |
|---|---|---|
FilepathIsLocal |
strings.Contains(x, "..") on non-path strings |
Not fixable -- DSL can't distinguish path strings from other strings |
TestingContext |
context.Background() in test helpers without *testing.T |
Not fixable -- DSL can't check enclosing function signature |
SliceRepeat |
FlatMap patterns where appended expression depends on loop variable | Caveat in report message with expanded expression |
ReflectFieldsIterator |
reflect.Type pattern when loop index is used for reflect.Value access |
Caveat in report message suggesting to range over Value instead |
Rules are tested against real-world codebases to catch false positives before release. Across 3 projects (99 total findings), only 2 mitigated false positives remained after adding type guards.