Skip to content

Commit e32c22f

Browse files
github-actions[bot]Copilotclaudepelikhan
authored
[linter-miner] feat(linters): add seenmapbool linter — flag map[string]bool used as a set (#36313)
* feat(linters): add seenmapbool linter Flags map[string]bool variables used purely as sets (values always true), suggesting map[string]struct{} to avoid allocating a bool per entry. Evidence from code scanning: 18 occurrences across 18 files in pkg/ and cmd/. Corroborated by issues #36022, #36127, #36160 flagging hand-rolled set patterns as refactoring targets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(adr): add draft ADR-36313 for seenmapbool linter Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Peli de Halleux <pelikhan@users.noreply.github.com>
1 parent 9cb4361 commit e32c22f

5 files changed

Lines changed: 330 additions & 0 deletions

File tree

‎cmd/linters/main.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import (
3232
panicinlibrarycode "github.com/github/gh-aw/pkg/linters/panic-in-library-code"
3333
"github.com/github/gh-aw/pkg/linters/rawloginlib"
3434
"github.com/github/gh-aw/pkg/linters/regexpcompileinfunction"
35+
"github.com/github/gh-aw/pkg/linters/seenmapbool"
3536
"github.com/github/gh-aw/pkg/linters/ssljson"
3637
"github.com/github/gh-aw/pkg/linters/strconvparseignorederror"
3738
"github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion"
@@ -55,6 +56,7 @@ func main() {
5556
rawloginlib.Analyzer,
5657
regexpcompileinfunction.Analyzer,
5758
ssljson.Analyzer,
59+
seenmapbool.Analyzer,
5860
strconvparseignorederror.Analyzer,
5961
jsonmarshalignoredeerror.Analyzer,
6062
uncheckedtypeassertion.Analyzer,
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ADR-36313: Add a dedicated `seenmapbool` analysis-pass linter
2+
3+
**Date**: 2026-06-01
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 package and registered centrally in `cmd/linters/main.go`. A recurring memory-allocation anti-pattern was observed: `map[string]bool` is frequently used purely as a membership set (every write assigns the literal `true`), where `map[string]struct{}` is the idiomatic, zero-value-per-entry alternative. Code scanning surfaced 18 occurrences across `pkg/` and `cmd/`, and discussion mining (issues #36022, #36127, #36160) independently flagged the hand-rolled deduplication pattern that relies on this idiom. There was no automated guard preventing the pattern from recurring.
9+
10+
## Decision
11+
12+
We will add a new standalone analysis-pass linter, `seenmapbool`, in its own package `pkg/linters/seenmapbool/`, and register it in the central analyzer list in `cmd/linters/main.go`. The analyzer walks each function body (`*ast.FuncDecl` and `*ast.FuncLit`), collects local `map[string]bool` declarations via type information (`pass.TypesInfo`), and reports a variable only when **every** indexed write assigns the boolean literal `true`. Maps that ever assign `false` (genuine tri-state bool maps) are excluded. Test files are skipped via `filecheck.IsTestFile`, and the package ships `analysistest`-based fixtures, consistent with sibling linters.
13+
14+
## Alternatives Considered
15+
16+
### Alternative 1: Rely on an off-the-shelf linter (e.g. golangci-lint / gocritic)
17+
Third-party linter aggregators offer overlapping set-vs-bool heuristics. It was not chosen because the repository has deliberately standardized on a homegrown `pkg/linters/` analysis-pass framework, giving full control over rule semantics, diagnostic wording, and CI integration without taking on an external linter aggregator dependency. *[TODO: verify whether an off-the-shelf equivalent was explicitly evaluated and rejected.]*
18+
19+
### Alternative 2: Add the check to an existing linter package
20+
The set-detection rule could have been folded into 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 `map[string]bool`-as-set pattern, removing reliance on ad-hoc review.
26+
- Memory savings: `map[string]struct{}` allocates no value byte per entry, which matters in hot deduplication loops.
27+
- Consistent with the established one-package-per-linter convention, so the addition is low-friction for maintainers.
28+
29+
### Negative
30+
- Detection is scoped to locally declared maps written via direct `m[k] = true` index assignments; maps passed as parameters, stored in structs, or written through aliases/closures are not tracked (false negatives).
31+
- Value analysis only recognizes the bare identifier `true`; a `true` produced via a constant, function call, or boolean expression is treated as a non-set write, which can suppress a legitimate diagnostic. *[TODO: verify intended handling of constant/computed true values.]*
32+
- Adds another custom linter to maintain in-house rather than leveraging an upstream-maintained equivalent.
33+
34+
### Neutral
35+
- The existing 18 occurrences are not auto-fixed by this PR; they must be remediated separately once the linter is enforced in CI.
36+
- The analyzer requires the `inspect` pass and type information, matching the resource profile of sibling linters.
37+
- The rule deliberately does not flag `map[string]bool` whose values are sometimes `false`, preserving genuine bool-map use cases.
38+
39+
---
40+
41+
*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/26777234073) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
// Package seenmapbool implements a Go analysis linter that flags "seen" maps
2+
// declared as map[string]bool (using true as sentinel) that should use
3+
// map[string]struct{} to avoid allocating a bool per entry.
4+
package seenmapbool
5+
6+
import (
7+
"fmt"
8+
"go/ast"
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+
// Analyzer is the seen-map-bool analysis pass.
19+
var Analyzer = &analysis.Analyzer{
20+
Name: "seenmapbool",
21+
Doc: "reports map[string]bool used as a set (values always true) where map[string]struct{} should be used instead",
22+
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/seenmapbool",
23+
Requires: []*analysis.Analyzer{inspect.Analyzer},
24+
Run: run,
25+
}
26+
27+
func run(pass *analysis.Pass) (any, error) {
28+
insp, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
29+
if !ok {
30+
return nil, fmt.Errorf("inspect analyzer result has unexpected type %T", pass.ResultOf[inspect.Analyzer])
31+
}
32+
33+
nodeFilter := []ast.Node{
34+
(*ast.FuncDecl)(nil),
35+
(*ast.FuncLit)(nil),
36+
}
37+
38+
insp.Preorder(nodeFilter, func(n ast.Node) {
39+
var body *ast.BlockStmt
40+
switch fn := n.(type) {
41+
case *ast.FuncDecl:
42+
if fn.Body == nil {
43+
return
44+
}
45+
pos := pass.Fset.PositionFor(fn.Pos(), false)
46+
if filecheck.IsTestFile(pos.Filename) {
47+
return
48+
}
49+
body = fn.Body
50+
case *ast.FuncLit:
51+
if fn.Body == nil {
52+
return
53+
}
54+
body = fn.Body
55+
}
56+
inspectBody(pass, body)
57+
})
58+
59+
return nil, nil
60+
}
61+
62+
// inspectBody walks a function body and reports map[string]bool variables
63+
// that are only ever assigned the literal true (i.e., used as a set).
64+
func inspectBody(pass *analysis.Pass, body *ast.BlockStmt) {
65+
// Collect map[string]bool local variables defined in this scope.
66+
candidates := make(map[types.Object]ast.Node) // object -> declaration node for reporting
67+
68+
// First pass: collect declarations of map[string]bool locals.
69+
ast.Inspect(body, func(n ast.Node) bool {
70+
switch stmt := n.(type) {
71+
case *ast.AssignStmt:
72+
// seen := make(map[string]bool) or seen := map[string]bool{}
73+
if stmt.Tok.String() != ":=" {
74+
return true
75+
}
76+
for i, lhs := range stmt.Lhs {
77+
if i >= len(stmt.Rhs) {
78+
break
79+
}
80+
ident, ok := lhs.(*ast.Ident)
81+
if !ok || ident.Name == "_" {
82+
continue
83+
}
84+
obj := pass.TypesInfo.ObjectOf(ident)
85+
if obj == nil {
86+
continue
87+
}
88+
if isMapStringBool(pass.TypesInfo.TypeOf(ident)) && isMapStringBoolExpr(stmt.Rhs[i]) {
89+
candidates[obj] = ident
90+
}
91+
}
92+
case *ast.DeclStmt:
93+
// var seen map[string]bool
94+
genDecl, ok := stmt.Decl.(*ast.GenDecl)
95+
if !ok {
96+
return true
97+
}
98+
for _, spec := range genDecl.Specs {
99+
valSpec, ok := spec.(*ast.ValueSpec)
100+
if !ok {
101+
continue
102+
}
103+
for _, name := range valSpec.Names {
104+
if name.Name == "_" {
105+
continue
106+
}
107+
obj := pass.TypesInfo.ObjectOf(name)
108+
if obj == nil {
109+
continue
110+
}
111+
if isMapStringBool(pass.TypesInfo.TypeOf(name)) {
112+
candidates[obj] = name
113+
}
114+
}
115+
}
116+
}
117+
return true
118+
})
119+
120+
if len(candidates) == 0 {
121+
return
122+
}
123+
124+
// Second pass: check that every write to these maps only assigns true.
125+
// If any non-true assignment is found, remove the map from candidates.
126+
nonSetMaps := make(map[types.Object]bool)
127+
128+
ast.Inspect(body, func(n ast.Node) bool {
129+
assign, ok := n.(*ast.AssignStmt)
130+
if !ok {
131+
return true
132+
}
133+
for i, lhs := range assign.Lhs {
134+
indexExpr, ok := lhs.(*ast.IndexExpr)
135+
if !ok {
136+
continue
137+
}
138+
ident, ok := indexExpr.X.(*ast.Ident)
139+
if !ok {
140+
continue
141+
}
142+
obj := pass.TypesInfo.ObjectOf(ident)
143+
if obj == nil {
144+
continue
145+
}
146+
if _, isCandidate := candidates[obj]; !isCandidate {
147+
continue
148+
}
149+
// Check the value being assigned.
150+
if i < len(assign.Rhs) {
151+
if !isBoolTrue(assign.Rhs[i]) {
152+
nonSetMaps[obj] = true
153+
}
154+
}
155+
}
156+
return true
157+
})
158+
159+
// Report remaining candidates that are pure sets.
160+
for obj, declNode := range candidates {
161+
if nonSetMaps[obj] {
162+
continue
163+
}
164+
pass.ReportRangef(
165+
declNode,
166+
"map[string]bool %q used as a set; use map[string]struct{} to avoid allocating a bool per entry",
167+
obj.Name(),
168+
)
169+
}
170+
}
171+
172+
// isMapStringBool returns true if t is map[string]bool.
173+
func isMapStringBool(t types.Type) bool {
174+
if t == nil {
175+
return false
176+
}
177+
m, ok := t.Underlying().(*types.Map)
178+
if !ok {
179+
return false
180+
}
181+
key, ok := m.Key().(*types.Basic)
182+
if !ok || key.Kind() != types.String {
183+
return false
184+
}
185+
val, ok := m.Elem().(*types.Basic)
186+
return ok && val.Kind() == types.Bool
187+
}
188+
189+
// isMapStringBoolExpr reports whether expr is a make(map[string]bool, ...) call
190+
// or a map[string]bool{...} composite literal.
191+
func isMapStringBoolExpr(expr ast.Expr) bool {
192+
switch e := expr.(type) {
193+
case *ast.CallExpr:
194+
ident, ok := e.Fun.(*ast.Ident)
195+
if !ok || ident.Name != "make" {
196+
return false
197+
}
198+
if len(e.Args) == 0 {
199+
return false
200+
}
201+
return isMapStringBoolTypeExpr(e.Args[0])
202+
case *ast.CompositeLit:
203+
return isMapStringBoolTypeExpr(e.Type)
204+
}
205+
return false
206+
}
207+
208+
// isMapStringBoolTypeExpr reports whether the AST node represents map[string]bool.
209+
func isMapStringBoolTypeExpr(expr ast.Expr) bool {
210+
mapType, ok := expr.(*ast.MapType)
211+
if !ok {
212+
return false
213+
}
214+
keyIdent, ok := mapType.Key.(*ast.Ident)
215+
if !ok || keyIdent.Name != "string" {
216+
return false
217+
}
218+
valIdent, ok := mapType.Value.(*ast.Ident)
219+
return ok && valIdent.Name == "bool"
220+
}
221+
222+
// isBoolTrue reports whether expr is the boolean literal true.
223+
func isBoolTrue(expr ast.Expr) bool {
224+
ident, ok := expr.(*ast.Ident)
225+
return ok && ident.Name == "true"
226+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//go:build !integration
2+
3+
package seenmapbool
4+
5+
import (
6+
"testing"
7+
8+
"golang.org/x/tools/go/analysis/analysistest"
9+
)
10+
11+
func TestSeenMapBool(t *testing.T) {
12+
testdata := analysistest.TestData()
13+
analysistest.Run(t, testdata, Analyzer, "seenmapbool")
14+
}
15+
16+
func TestAnalyzerFields(t *testing.T) {
17+
if Analyzer.Name != "seenmapbool" {
18+
t.Errorf("expected Name %q, got %q", "seenmapbool", Analyzer.Name)
19+
}
20+
if Analyzer.Doc == "" {
21+
t.Error("Doc must not be empty")
22+
}
23+
if Analyzer.URL == "" {
24+
t.Error("URL must not be empty")
25+
}
26+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package seenmapbool
2+
3+
func BadSetBool() {
4+
seen := make(map[string]bool) // want `map\[string\]bool "seen" used as a set`
5+
items := []string{"a", "b", "a"}
6+
var result []string
7+
for _, item := range items {
8+
if !seen[item] {
9+
seen[item] = true
10+
result = append(result, item)
11+
}
12+
}
13+
_ = result
14+
}
15+
16+
func BadSetBoolLiteral() {
17+
seen := map[string]bool{} // want `map\[string\]bool "seen" used as a set`
18+
seen["x"] = true
19+
_ = seen
20+
}
21+
22+
func GoodSetStruct() {
23+
// Using map[string]struct{} is the correct pattern — no diagnostic expected.
24+
seen := make(map[string]struct{})
25+
seen["x"] = struct{}{}
26+
_, _ = seen["x"]
27+
}
28+
29+
func GoodBoolMapWithFalse() {
30+
// Map whose values are sometimes false — it's a real bool map, not a set.
31+
flags := make(map[string]bool)
32+
flags["enabled"] = true
33+
flags["disabled"] = false
34+
_ = flags
35+
}

0 commit comments

Comments
 (0)