Static analysis — gerpolint¶
gerpo's WHERE operators (EQ, In, Contains, and the 20 other methods on types.WhereOperation) accept any. That is a deliberate trade-off — methods on Go interfaces cannot be generic — but it means the compiler will not catch a mismatch like:
h.Where().Field(&m.Age).EQ("18") // field is int, arg is string
h.Where().Field(&m.Age).Contains("a") // Contains requires string/*string
Both fail at runtime (either when gerpo rejects the option or when PostgreSQL refuses the coercion). gerpolint is a go/analysis checker that catches these at go vet time, either as a standalone binary or as a golangci-lint plugin.
The rule¶
- Field
T→ argument must be assignable toT. - Field
*T→ argument may beT,*T, or untypednil. - Untyped constants use spec-level representability:
EQ(18)is fine ontype Age int, butEQ(3.14)onintis rejected.
gerpolint identifies gerpo calls by package path (github.com/insei/gerpo/types) plus receiver-method shape, so unrelated EQ / In methods in other packages are left alone.
Rules¶
| ID | Trigger | Example |
|---|---|---|
GPL001 |
Scalar operator, argument type mismatch | Field(&m.Age).EQ("18") |
GPL002 |
Variadic operator, element type mismatch | Field(&m.Age).In(1, "2", 3) |
GPL003 |
String-only operator on non-string field | Field(&m.Age).Contains("x") |
GPL004 |
Field pointer cannot be resolved statically (e.g., via a variable) | p := &m.Age; Field(p).EQ(...) |
GPL005 |
Argument's static type is any — static check skipped |
var v any = 18; EQ(v) |
Standalone binary¶
From a clone, make lint-gerpolint does the same via go run ./cmd/gerpolint ./....
Flags:
| Flag | Values | Default | Purpose |
|---|---|---|---|
-unresolved-field |
skip / warn / error |
skip |
How to treat Field(ptr) whose argument cannot be resolved to a concrete field |
-any-arg |
skip / warn / error |
warn |
How to treat arguments whose static type is any |
-disabled-rules |
GPL001,GPL002,… |
(empty) | Comma-separated rule IDs to skip entirely |
Example:
golangci-lint plugin¶
gerpolint registers as a golangci-lint v2 module plugin. The golangci-lint custom subcommand builds a bespoke binary with gerpolint embedded alongside your other linters.
1. Point golangci-lint at the plugin¶
Drop .custom-gcl.yml at your repo root:
version: v2.5.0 # your golangci-lint version
name: custom-gcl
destination: ./bin
plugins:
- module: github.com/insei/gerpo
import: github.com/insei/gerpo/gerpolintplugin
version: latest
2. Enable the linter¶
version: "2"
linters:
enable:
- gerpolint
settings:
custom:
gerpolint:
type: module
description: Type-safe check of gerpo WHERE filter arguments.
original-url: https://github.com/insei/gerpo
settings:
unresolved-field: skip # skip | warn | error
any-arg: warn # skip | warn | error
disabled-rules: [] # e.g. [GPL004, GPL005]
3. Build and run¶
From a clone of insei/gerpo, make lint-gerpolint-plugin wraps both steps.
Diagnostics surface with category prefixes so you can filter on them in CI, e.g. | grep GPL001 to fail a build only on scalar mismatches.
Directives¶
Suppress diagnostics inline without changing the linter configuration:
| Directive | Scope |
|---|---|
//gerpolint:disable-line[=GPL001,…] |
the current line |
//gerpolint:disable-next-line[=GPL001,…] |
the line below |
//gerpolint:disable[=GPL001,…] |
from here until //gerpolint:enable or EOF |
//gerpolint:enable |
close the most recent disable block |
Without =…, the directive disables all gerpolint rules on its scope. Unknown rule IDs trigger a one-shot GPL-DIRECTIVE-UNKNOWN warning — the directive itself is ignored so the underlying rule keeps firing.
// Legitimate []any spread — static types are erased by design, so skip GPL005.
h.Where().Field(&m.ID).In(wanted...) //gerpolint:disable-line=GPL005
//gerpolint:disable
// Generated code below — bypass gerpolint wholesale.
...
//gerpolint:enable
[]any spread recovery¶
In(xs) / In(xs...) where xs is a slice would lose element types at
the call site, so gerpolint recovers them from the backing composite
literal or append-chain. gerpo itself auto-unwraps a single slice
argument for In/NotIn (see types/filters.go), so the linter treats
In(xs) and In(xs...) identically. Three shapes are understood:
// (1) Inline composite literal — with or without the spread operator.
h.Where().Field(&m.ID).In([]any{id1, id2, id3}...)
h.Where().Field(&m.ID).In([]any{id1, id2, id3})
// (2) Single-assignment local variable.
wanted := []any{id1, "oops", id3}
h.Where().Field(&m.ID).In(wanted)
// ^ GPL002: argument type string is not compatible with field type uuid.UUID
// (3) Accumulator-style append chain.
var t []any
t = append(t, id1)
t = append(t, "oops") // ← GPL002 fires here
t = append(t, id3)
h.Where().Field(&m.ID).In(t...)
The following usages break static tracking and fall back to GPL005:
- Taking the address of the slice (
mutate(&t)). - Reassigning from a function call or another variable (
t = getSlice()). - Writing elements by index (
t[1] = x). append(t, xs...)wherexsis a named slice variable (only inlineappend(t, []any{a, b}...)is expanded).
When to reach for which knob¶
- CI-fail on real bugs: leave defaults;
GPL001–GPL003fire on concrete type errors. - Field-pointer helpers: if your code routes field pointers through helper functions, gerpolint cannot resolve them — either keep
-unresolved-field=skip(default) or flip toerrorto force inlined usage. []anythrough a helper orappend: gerpolint cannot see the elements, so it will emitGPL005. Either refactor to a single-site literal (caught automatically) or add a targeted//gerpolint:disable-line=GPL005.- Generated code: bracket the file with
//gerpolint:disable///gerpolint:enableat the top and bottom.
What gerpolint does not do¶
- It does not check
OrderBy().Field(...)— there is no value to type-check. - It does not validate that a field pointer resolves to a column configured in the repository builder; a runtime "option is not available" error remains the safety net for misconfigured columns.
- It does not run without type information.
analysis.LoadModeisTypesInfo; for golangci-lint that is already the case.