Skip to content

Commit ff1850a

Browse files
[linter-miner] add lenstringzero linter (#37618)
1 parent e869261 commit ff1850a

5 files changed

Lines changed: 198 additions & 0 deletions

File tree

cmd/linters/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/github/gh-aw/pkg/linters/fprintlnsprintf"
2727
"github.com/github/gh-aw/pkg/linters/jsonmarshalignoredeerror"
2828
"github.com/github/gh-aw/pkg/linters/largefunc"
29+
"github.com/github/gh-aw/pkg/linters/lenstringzero"
2930
"github.com/github/gh-aw/pkg/linters/manualmutexunlock"
3031
"github.com/github/gh-aw/pkg/linters/osexitinlibrary"
3132
"github.com/github/gh-aw/pkg/linters/ossetenvlibrary"
@@ -60,6 +61,7 @@ func main() {
6061
seenmapbool.Analyzer,
6162
strconvparseignorederror.Analyzer,
6263
jsonmarshalignoredeerror.Analyzer,
64+
lenstringzero.Analyzer,
6365
tolowerequalfold.Analyzer,
6466
uncheckedtypeassertion.Analyzer,
6567
)
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ADR-37618: Add a dedicated `lenstringzero` analysis-pass linter
2+
3+
**Date**: 2026-06-07
4+
**Status**: Draft
5+
6+
## Context
7+
8+
The codebase maintains a set of custom `golang.org/x/tools/go/analysis` linters under `pkg/linters/`, each implemented as its own single-responsibility package and registered centrally in `cmd/linters/main.go`. A recurring non-idiomatic pattern was observed: `len(s) == 0` and `len(s) != 0` comparisons on **string** values, where the idiomatic Go form is `s == ""` / `s != ""`. The off-the-shelf linters that would normally catch this — `gocritic`'s `sloppyLen` and `revive` — are both explicitly disabled in `.golangci.yml` due to golangci-lint v2 configuration bugs, so the pattern is currently unguarded. Code-pattern scanning surfaced the idiom across 20+ non-test source files in `pkg/`.
9+
10+
## Decision
11+
12+
We will add a new standalone analysis-pass linter, `lenstringzero`, in its own package `pkg/linters/lenstringzero/`, and register it in the central analyzer list in `cmd/linters/main.go`. The analyzer inspects every `*ast.BinaryExpr` using `==` / `!=`, detects when one operand is a single-argument `len(...)` call and the other is the integer literal `0` (handling both operand orders), and reports a diagnostic only when the `len()` argument type-checks (via `pass.TypesInfo`) to an underlying `string`. `[]byte`, arrays, and other non-string types are excluded, as are comparisons that are not against `0`. Test files are skipped via `filecheck.IsTestFile`, and the package ships `analysistest`-based fixtures with `// want` annotations, consistent with sibling linters.
13+
14+
## Alternatives Considered
15+
16+
### Alternative 1: Re-enable `gocritic`/`revive` in `.golangci.yml`
17+
The `sloppyLen` (gocritic) and `revive` rules already implement this check. They were not chosen because both are currently disabled due to golangci-lint v2 configuration bugs; re-enabling them is blocked on an upstream/config fix outside this PR's scope, and doing so would re-activate a broad set of other rules rather than this single targeted check. *[TODO: verify whether a narrower re-enable was attempted and rejected.]*
18+
19+
### Alternative 2: Fold the check into an existing linter package
20+
The string-empty check could have been added to a broader existing analyzer rather than introducing a new package. It was not chosen because each rule in `pkg/linters/` is intentionally a single-responsibility, independently testable, and individually toggleable package, which keeps rules discoverable and consistent with the established one-package-per-linter convention.
21+
22+
## Consequences
23+
24+
### Positive
25+
- Automated, repeatable prevention of the `len(s) == 0` / `len(s) != 0`-on-string idiom, restoring coverage lost when `gocritic`/`revive` were disabled.
26+
- Type-aware: the `string` underlying-type check avoids false positives on `[]byte`, arrays, slices, and maps, which legitimately use `len(x) == 0`.
27+
- Consistent with the established one-package-per-linter convention, so the addition is low-friction for maintainers.
28+
29+
### Negative
30+
- Adds another custom linter to maintain in-house rather than leveraging the upstream-maintained `gocritic`/`revive` equivalents (duplication that should be retired if those are ever re-enabled).
31+
- Detection is limited to direct `len(...)` comparisons against the literal `0`; an equivalent length stored in an intermediate variable (`n := len(s); n == 0`) is not flagged (false negatives).
32+
- The existing 20+ occurrences are not auto-fixed by this PR; they must be remediated separately once the linter is enforced in CI.
33+
34+
### Neutral
35+
- The analyzer requires the `inspect` pass and type information, matching the resource profile of sibling linters.
36+
- The diagnostic is report-only (no suggested-fix/autofix is emitted in this PR).
37+
- The rule deliberately does not flag length comparisons using other operators (`>`, `>=`, `< `) or against non-zero literals, preserving genuine length-threshold checks.
38+
39+
---
40+
41+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27100525416) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Package lenstringzero implements a Go analysis linter that flags len(s) == 0
2+
// and len(s) != 0 comparisons on string values that should use == "" or != "" instead.
3+
package lenstringzero
4+
5+
import (
6+
"fmt"
7+
"go/ast"
8+
"go/token"
9+
"go/types"
10+
11+
"golang.org/x/tools/go/analysis"
12+
"golang.org/x/tools/go/analysis/passes/inspect"
13+
"golang.org/x/tools/go/ast/inspector"
14+
15+
"github.com/github/gh-aw/pkg/linters/internal/filecheck"
16+
)
17+
18+
var Analyzer = &analysis.Analyzer{
19+
Name: "lenstringzero",
20+
Doc: "reports len(s) == 0 and len(s) != 0 comparisons on string values that should use == \"\" or != \"\" instead",
21+
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/lenstringzero",
22+
Requires: []*analysis.Analyzer{inspect.Analyzer},
23+
Run: run,
24+
}
25+
26+
func run(pass *analysis.Pass) (any, error) {
27+
insp, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
28+
if !ok {
29+
return nil, fmt.Errorf("inspect analyzer result has unexpected type %T", pass.ResultOf[inspect.Analyzer])
30+
}
31+
32+
nodeFilter := []ast.Node{(*ast.BinaryExpr)(nil)}
33+
34+
insp.Preorder(nodeFilter, func(n ast.Node) {
35+
expr, ok := n.(*ast.BinaryExpr)
36+
if !ok {
37+
return
38+
}
39+
if expr.Op != token.EQL && expr.Op != token.NEQ {
40+
return
41+
}
42+
43+
pos := pass.Fset.PositionFor(expr.Pos(), false)
44+
if filecheck.IsTestFile(pos.Filename) {
45+
return
46+
}
47+
48+
var lenArg ast.Expr
49+
if isLenCall(expr.X) && isIntZero(expr.Y) {
50+
lenArg = lenCallArg(expr.X)
51+
} else if isIntZero(expr.X) && isLenCall(expr.Y) {
52+
lenArg = lenCallArg(expr.Y)
53+
}
54+
if lenArg == nil {
55+
return
56+
}
57+
58+
t := pass.TypesInfo.TypeOf(lenArg)
59+
if t == nil {
60+
return
61+
}
62+
basic, ok := t.Underlying().(*types.Basic)
63+
if !ok || basic.Kind() != types.String {
64+
return
65+
}
66+
67+
op := expr.Op.String()
68+
var cmpVerb string
69+
if expr.Op == token.EQL {
70+
cmpVerb = "empty"
71+
} else {
72+
cmpVerb = "non-empty"
73+
}
74+
pass.ReportRangef(expr,
75+
`use s %s "" to check for %s string instead of len(s) %s 0`,
76+
op, cmpVerb, op)
77+
})
78+
79+
return nil, nil
80+
}
81+
82+
func isLenCall(expr ast.Expr) bool {
83+
call, ok := expr.(*ast.CallExpr)
84+
if !ok || len(call.Args) != 1 {
85+
return false
86+
}
87+
ident, ok := call.Fun.(*ast.Ident)
88+
return ok && ident.Name == "len"
89+
}
90+
91+
func lenCallArg(expr ast.Expr) ast.Expr {
92+
return expr.(*ast.CallExpr).Args[0]
93+
}
94+
95+
func isIntZero(expr ast.Expr) bool {
96+
lit, ok := expr.(*ast.BasicLit)
97+
return ok && lit.Kind == token.INT && lit.Value == "0"
98+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
//go:build !integration
2+
3+
package lenstringzero_test
4+
5+
import (
6+
"testing"
7+
8+
"golang.org/x/tools/go/analysis/analysistest"
9+
10+
"github.com/github/gh-aw/pkg/linters/lenstringzero"
11+
)
12+
13+
func TestLenStringZero(t *testing.T) {
14+
testdata := analysistest.TestData()
15+
analysistest.Run(t, testdata, lenstringzero.Analyzer, "lenstringzero")
16+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package lenstringzero
2+
3+
func isEmpty(s string) bool {
4+
return len(s) == 0 // want `use s == "" to check for empty string instead of len\(s\) == 0`
5+
}
6+
7+
func isNotEmpty(s string) bool {
8+
return len(s) != 0 // want `use s != "" to check for non-empty string instead of len\(s\) != 0`
9+
}
10+
11+
func flippedEmpty(s string) bool {
12+
return 0 == len(s) // want `use s == "" to check for empty string instead of len\(s\) == 0`
13+
}
14+
15+
func flippedNotEmpty(s string) bool {
16+
return 0 != len(s) // want `use s != "" to check for non-empty string instead of len\(s\) != 0`
17+
}
18+
19+
func alreadyGoodEmpty(s string) bool {
20+
return s == ""
21+
}
22+
23+
func alreadyGoodNotEmpty(s string) bool {
24+
return s != ""
25+
}
26+
27+
func sliceNotFlagged(s []byte) bool {
28+
return len(s) == 0
29+
}
30+
31+
func arrayNotFlagged(s [1]byte) bool {
32+
return len(s) != 0
33+
}
34+
35+
func lenNotZeroOp(s string) bool {
36+
return len(s) > 0
37+
}
38+
39+
func lenNotComparedToZero(s string) bool {
40+
return len(s) == 1
41+
}

0 commit comments

Comments
 (0)