-
Notifications
You must be signed in to change notification settings - Fork 457
[linter-miner] feat(linters): add mapclearloop linter — replace range-delete loops with clear(m) #46060
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[linter-miner] feat(linters): add mapclearloop linter — replace range-delete loops with clear(m) #46060
Changes from all commits
c8bde90
e63de39
8e95b34
4ac1747
8bd6db9
f0e3733
597819b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| # ADR-46060: Add mapclearloop Linter to Detect Range-Delete Loops Replaceable with clear(m) | ||
|
|
||
| **Date**: 2026-07-16 | ||
| **Status**: Draft | ||
| **Deciders**: Unknown (automated PR — pelikhan) | ||
|
|
||
| --- | ||
|
|
||
| ### Context | ||
|
|
||
| Go 1.21 introduced the built-in `clear(m)` function, which atomically clears all entries from a map in a single call. Before Go 1.21, the idiomatic way to clear a map was a range-over-map loop with a `delete` call per entry: `for k := range m { delete(m, k) }`. Codebases that have been upgraded to Go 1.21+ frequently retain the old loop pattern due to habit or incremental migration. This project maintains a suite of custom static analysis linters (in `pkg/linters/`) that enforce idiomatic Go style across the codebase. A linter for this pattern is a natural addition: it is high-signal (the pattern is unambiguous), auto-fixable, and complements the existing `mapdeletecheck` linter which targets related map/delete idioms. | ||
|
|
||
| ### Decision | ||
|
|
||
| We will add a new `mapclearloop` analysis pass (`pkg/linters/mapclearloop/`) that detects range-over-map loops whose sole body statement is `delete(m, k)` targeting the same map and key variable from the range, and flags them as candidates for replacement with `clear(m)`. The analyzer emits a `SuggestedFix` enabling automatic rewriting. It will be registered in `cmd/linters/main.go` alongside all other linters in this project. | ||
|
|
||
| ### Alternatives Considered | ||
|
|
||
| #### Alternative 1: Extend the existing `mapdeletecheck` linter | ||
|
|
||
| The `mapdeletecheck` linter already handles a related idiom. Extending it to also catch the range-delete-entire-map pattern would avoid creating a new package and keep map-related lint rules in one place. | ||
|
|
||
| This was not chosen because `mapdeletecheck` addresses a distinct class of problems (likely incorrect individual deletes, e.g., deleting while iterating with possible side effects). Merging two semantically different checks into one package would blur responsibility boundaries, complicate testing, and make the codebase harder to navigate. The project's existing convention is one package per linter. | ||
|
|
||
| #### Alternative 2: Rely on an upstream linter (e.g., golangci-lint, staticcheck, revive) | ||
|
|
||
| Rather than maintaining a custom analyzer, the team could wait for the broader Go tooling ecosystem to provide this check, or configure an existing tool that already detects it. | ||
|
|
||
| This was not chosen because the project requires tight integration with its own linter infrastructure (nolint directives, filecheck for generated file skipping, the shared `astutil` helpers, and registration in the project's own linter runner). Upstream tools may not respect project-local nolint conventions or may have false positives in the project's specific patterns. Additionally, having the linter in-tree allows immediate fixes through `SuggestedFix` and ensures the check is versioned with the codebase. | ||
|
|
||
| ### Consequences | ||
|
|
||
| #### Positive | ||
| - Code that clears maps will be more concise and express intent clearly via `clear(m)`. | ||
| - The linter has a high signal-to-noise ratio: it only fires when the range body is exactly one `delete` call on the same map with the same key, verified at the type level using `go/types` (e.g., shadowed `delete` identifiers are correctly excluded). | ||
| - The `SuggestedFix` enables automatic code rewriting, reducing developer toil when fixing violations. | ||
| - The check skips generated files via `filecheck`, avoiding noisy violations in machine-generated code. | ||
|
|
||
| #### Negative | ||
| - Maintainers must maintain another custom linter package indefinitely. | ||
| - Existing code in repositories using this linter suite will gain new lint failures on upgrade and must be updated to use `clear(m)`. | ||
| - The linter only targets Go 1.21+ idiom; it would produce confusing violations in codebases that cannot use Go 1.21 — though in practice this project already requires a modern Go version. | ||
|
|
||
| #### Neutral | ||
| - The linter follows the same structural conventions as all other linters in `pkg/linters/`: `Analyzer` var, `run` function, `testdata/` fixture with `.golden` file, `analysistest` test harness. | ||
| - No new external dependencies are introduced; `golang.org/x/tools/go/analysis` is already a project dependency. | ||
|
|
||
| --- | ||
|
|
||
| *ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| // Package mapclearloop implements a Go analysis linter that flags | ||
| // range-over-map loops whose only body statement is delete(m, k), | ||
| // which should be replaced by the built-in clear(m) introduced in Go 1.21. | ||
| package mapclearloop | ||
|
|
||
| import ( | ||
| "go/ast" | ||
| "go/token" | ||
| "go/types" | ||
|
|
||
| "golang.org/x/tools/go/analysis" | ||
| "golang.org/x/tools/go/analysis/passes/inspect" | ||
|
|
||
| "github.com/github/gh-aw/pkg/linters/internal/astutil" | ||
| "github.com/github/gh-aw/pkg/linters/internal/filecheck" | ||
| "github.com/github/gh-aw/pkg/linters/internal/nolint" | ||
| ) | ||
|
|
||
| // Analyzer is the map-clear-loop analysis pass. | ||
| var Analyzer = &analysis.Analyzer{ | ||
| Name: "mapclearloop", | ||
| Doc: "reports range-over-map loops that delete every entry and can be replaced with clear(m)", | ||
| URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/mapclearloop", | ||
| Requires: []*analysis.Analyzer{inspect.Analyzer, nolint.Analyzer, filecheck.Analyzer}, | ||
| Run: run, | ||
| } | ||
|
|
||
| func run(pass *analysis.Pass) (any, error) { | ||
| insp, err := astutil.Inspector(pass) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| noLintIndex, err := nolint.Index(pass) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| generatedFiles, err := filecheck.Index(pass) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| nodeFilter := []ast.Node{(*ast.RangeStmt)(nil)} | ||
|
|
||
| insp.Preorder(nodeFilter, func(n ast.Node) { | ||
| rangeStmt, ok := n.(*ast.RangeStmt) | ||
| if !ok { | ||
| return | ||
| } | ||
|
|
||
| pos := pass.Fset.PositionFor(rangeStmt.Pos(), false) | ||
| if filecheck.ShouldSkipFilename(pos.Filename, generatedFiles) { | ||
| return | ||
| } | ||
| if nolint.HasDirectiveForLinter(pos, noLintIndex, "mapclearloop") { | ||
| return | ||
| } | ||
|
|
||
| // The range expression must be a map type. | ||
| mapType := pass.TypesInfo.TypeOf(rangeStmt.X) | ||
| if mapType == nil { | ||
| return | ||
| } | ||
| if _, ok := mapType.Underlying().(*types.Map); !ok { | ||
| return | ||
| } | ||
|
|
||
| // The key variable must be present (not blank or absent). | ||
| keyIdent, ok := rangeStmt.Key.(*ast.Ident) | ||
| if !ok || keyIdent.Name == "_" { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing test case: The value-blank branch (lines 77-81) that permits @copilot please address this. |
||
| return | ||
| } | ||
| keyObj := pass.TypesInfo.Defs[keyIdent] | ||
| if keyObj == nil { | ||
| keyObj = pass.TypesInfo.Uses[keyIdent] | ||
| } | ||
| if keyObj == nil { | ||
| return | ||
| } | ||
|
Comment on lines
+72
to
+78
|
||
|
|
||
| // The value variable must be absent or blank. | ||
| if rangeStmt.Value != nil { | ||
| valueIdent, ok := rangeStmt.Value.(*ast.Ident) | ||
| if !ok || valueIdent.Name != "_" { | ||
| return | ||
| } | ||
| } | ||
|
|
||
| // The body must contain exactly one statement: delete(m, k). | ||
| if len(rangeStmt.Body.List) != 1 { | ||
| return | ||
| } | ||
| exprStmt, ok := rangeStmt.Body.List[0].(*ast.ExprStmt) | ||
| if !ok { | ||
| return | ||
| } | ||
| callExpr, ok := exprStmt.X.(*ast.CallExpr) | ||
| if !ok { | ||
| return | ||
| } | ||
| delIdent, ok := callExpr.Fun.(*ast.Ident) | ||
| if !ok || delIdent.Name != "delete" { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. False negative: 💡 Details
The same issue appears in the delete-key comparison: you compare keyObj := pass.TypesInfo.Defs[keyIdent]
if keyObj == nil {
keyObj = pass.TypesInfo.Uses[keyIdent] // re-assigned existing var
}
if keyObj == nil {
return
} |
||
| return | ||
| } | ||
| delBuiltin, ok := pass.TypesInfo.Uses[delIdent].(*types.Builtin) | ||
| if !ok || delBuiltin.Name() != "delete" { | ||
| return | ||
| } | ||
| if len(callExpr.Args) != 2 { | ||
| return | ||
| } | ||
|
|
||
| // First arg to delete must be the same map as the range expression. | ||
| if !sameObject(pass, callExpr.Args[0], rangeStmt.X) { | ||
| return | ||
| } | ||
|
|
||
| // Second arg to delete must be the key variable from the range. | ||
| delKeyIdent, ok := callExpr.Args[1].(*ast.Ident) | ||
| if !ok { | ||
| return | ||
| } | ||
| delKeyObj := pass.TypesInfo.Uses[delKeyIdent] | ||
| if delKeyObj == nil || delKeyObj != keyObj { | ||
| return | ||
| } | ||
|
|
||
| mText := astutil.NodeText(pass.Fset, rangeStmt.X) | ||
| if mText == "" { | ||
| return | ||
| } | ||
| if !builtinVisibleAtPos(pass.Pkg, rangeStmt.Pos(), "clear") { | ||
| return | ||
| } | ||
|
|
||
| diag := analysis.Diagnostic{ | ||
| Pos: rangeStmt.Pos(), | ||
| End: rangeStmt.End(), | ||
| Message: "range-delete loop over map can be replaced with clear(" + mText + ")", | ||
| } | ||
| if !hasOverlappingComment(pass.Files, rangeStmt.Pos(), rangeStmt.End()) { | ||
| diag.SuggestedFixes = []analysis.SuggestedFix{{ | ||
| Message: "Replace range-delete loop with clear", | ||
| TextEdits: []analysis.TextEdit{{ | ||
| Pos: rangeStmt.Pos(), | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] The 💡 Example// fixture – should this be preserved?
for k := range m {
// clear all cache entries
delete(m, k)
}After autofix the comment is gone. Consider prepending it before @copilot please address this. |
||
| End: rangeStmt.End(), | ||
| NewText: []byte("clear(" + mText + ")"), | ||
|
Comment on lines
+144
to
+146
|
||
| }}, | ||
| }} | ||
| } | ||
| pass.Report(diag) | ||
| }) | ||
|
|
||
| return nil, nil | ||
| } | ||
|
|
||
| // builtinVisibleAtPos reports whether name resolves to a builtin object at pos. | ||
| func builtinVisibleAtPos(pkg *types.Package, pos token.Pos, name string) bool { | ||
| if pkg == nil { | ||
| return false | ||
| } | ||
| scope := pkg.Scope().Innermost(pos) | ||
| if scope == nil { | ||
| return false | ||
| } | ||
| _, obj := scope.LookupParent(name, pos) | ||
| if obj == nil { | ||
| return false | ||
| } | ||
| builtin, ok := obj.(*types.Builtin) | ||
| return ok && builtin.Name() == name | ||
| } | ||
|
|
||
| // hasOverlappingComment reports whether any comment group overlaps [start, end). | ||
| func hasOverlappingComment(files []*ast.File, start, end token.Pos) bool { | ||
| for _, file := range files { | ||
| if end <= file.Pos() || start >= file.End() { | ||
| continue | ||
| } | ||
| for _, group := range file.Comments { | ||
| if group.Pos() < end && start < group.End() { | ||
| return true | ||
| } | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // sameObject reports whether expr refers to the same declared object as ref. | ||
| // ref is expected to be an *ast.Ident or *ast.SelectorExpr. | ||
| func sameObject(pass *analysis.Pass, expr, ref ast.Expr) bool { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/codebase-design] 💡 Suggested inline comment// sameObject only handles the common cases (plain identifiers and qualified
// selectors like pkg.Var or s.field). Complex expressions (index, call, etc.)
// return false conservatively — no false positive risk.
func sameObject(...) bool {@copilot please address this. |
||
| switch r := ref.(type) { | ||
| case *ast.Ident: | ||
| e, ok := expr.(*ast.Ident) | ||
| if !ok { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
💡 Details and fix
Guard before reporting: mText := astutil.NodeText(pass.Fset, rangeStmt.X)
if mText == "" {
return
}
// ... pass.Report(...) |
||
| return false | ||
| } | ||
| return pass.TypesInfo.Uses[e] == pass.TypesInfo.Uses[r] | ||
| case *ast.SelectorExpr: | ||
| e, ok := expr.(*ast.SelectorExpr) | ||
| if !ok { | ||
| return false | ||
| } | ||
| return pass.TypesInfo.Uses[e.Sel] == pass.TypesInfo.Uses[r.Sel] && | ||
| sameObject(pass, e.X, r.X) | ||
| default: | ||
| return false | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| //go:build !integration | ||
|
|
||
| package mapclearloop_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "golang.org/x/tools/go/analysis/analysistest" | ||
|
|
||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two handled code paths in 💡 Missing test cases1. Explicit blank value (lines 103–108 of the implementation) The code explicitly handles // should be flagged
for k, _ := range m { // want `range-delete loop...`
delete(m, k)
}2. Struct-field map — There is no test that exercises the selector path, so any bug there is invisible: type S struct{ m map[string]int }
var s S
// should be flagged
for k := range s.m { // want `range-delete loop...`
delete(s.m, k)
}
// should NOT be flagged (different receiver)
var s2 S
for k := range s.m {
delete(s2.m, k)
}Add both positive and negative cases to the testdata fixture and corresponding golden output. |
||
| "github.com/github/gh-aw/pkg/linters/mapclearloop" | ||
| ) | ||
|
|
||
| func TestAnalyzer(t *testing.T) { | ||
| testdata := analysistest.TestData() | ||
| analysistest.RunWithSuggestedFixes(t, testdata, mapclearloop.Analyzer, "mapclearloop") | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| package mapclearloop | ||
|
|
||
| func bad() { | ||
| m := map[string]int{"a": 1, "b": 2} | ||
|
|
||
| for k := range m { // want `range-delete loop over map can be replaced with clear\(m\)` | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] No positive test for a struct-field map ( 💡 Suggested fixture additiontype S struct{ m map[string]int }
func badSelector(s S) {
for k := range s.m { // want `range-delete loop over map can be replaced with clear\(s\.m\)`
delete(s.m, k)
}
}@copilot please address this. |
||
| delete(m, k) | ||
| } | ||
|
|
||
| m2 := map[int]string{1: "x"} | ||
| for k := range m2 { // want `range-delete loop over map can be replaced with clear\(m2\)` | ||
| delete(m2, k) | ||
| } | ||
|
|
||
| m3 := map[string]int{"c": 3} | ||
| for k, _ := range m3 { // want `range-delete loop over map can be replaced with clear\(m3\)` | ||
| delete(m3, k) | ||
| } | ||
|
|
||
| var k string | ||
| for k = range m { // want `range-delete loop over map can be replaced with clear\(m\)` | ||
| delete(m, k) | ||
| } | ||
| _ = k | ||
|
|
||
| m4 := map[string]int{"d": 4} | ||
| for k := range m4 { // want `range-delete loop over map can be replaced with clear\(m4\)` | ||
| // keep this comment in place by omitting the suggested fix | ||
| delete(m4, k) | ||
| } | ||
| } | ||
|
|
||
| func good() { | ||
| m := map[string]int{"a": 1} | ||
|
|
||
| // Only ranging over value – not flagged. | ||
| for _, v := range m { | ||
|
Comment on lines
+36
to
+37
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] Missing negative test case: a range with a blank key ( 💡 Suggested additions to the good() fixture// No key variable – not flagged.
for range m {
}This guards against future regressions if the blank-key check is accidentally widened. @copilot please address this. |
||
| _ = v | ||
| } | ||
|
|
||
| // Body has more than one statement – not flagged. | ||
| for k := range m { | ||
| delete(m, k) | ||
| _ = k | ||
| } | ||
|
|
||
| // Deleting into a different map – not flagged. | ||
| m2 := map[string]int{"b": 2} | ||
| for k := range m { | ||
| delete(m2, k) | ||
| } | ||
|
|
||
| // delete is shadowed – not flagged. | ||
| delete := func(_ map[string]int, _ string) {} | ||
| for k := range m { | ||
| delete(m, k) | ||
| } | ||
|
|
||
| // clear is shadowed – not flagged. | ||
| clear := func(_ map[string]int) {} | ||
| for k := range m { | ||
| delete(m, k) | ||
| } | ||
| clear(m) | ||
|
|
||
| // Ranging over a slice – not flagged. | ||
| s := []int{1, 2, 3} | ||
| for i := range s { | ||
| _ = i | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No Go version guard — the suggested fix produces uncompilable code on modules targeting Go < 1.21.
💡 Details and fix
clear()was introduced in Go 1.21. The analyzer fires unconditionally regardless of the module'sgodirective. On ago 1.20(or earlier) module, the emittedSuggestedFixrewrites the loop toclear(m)— silently breaking the caller's build when applied.Add a version check in
run(), or add a minimum-version annotation on theAnalyzer:Or guard inside
run()beforepass.Report:At minimum, document the Go 1.21 requirement prominently in the
Docstring.