Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/linters/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import (
"github.com/github/gh-aw/pkg/linters/lenstringzero"
"github.com/github/gh-aw/pkg/linters/logfatallibrary"
"github.com/github/gh-aw/pkg/linters/manualmutexunlock"
"github.com/github/gh-aw/pkg/linters/mapclearloop"
"github.com/github/gh-aw/pkg/linters/mapdeletecheck"
"github.com/github/gh-aw/pkg/linters/nilctxpassed"
"github.com/github/gh-aw/pkg/linters/osexitinlibrary"
Expand Down Expand Up @@ -93,6 +94,7 @@ func main() {
largefunc.Analyzer,
logfatallibrary.Analyzer,
manualmutexunlock.Analyzer,
mapclearloop.Analyzer,
mapdeletecheck.Analyzer,
nilctxpassed.Analyzer,
osexitinlibrary.Analyzer,
Expand Down
50 changes: 50 additions & 0 deletions docs/adr/46060-add-mapclearloop-linter.md
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.*
208 changes: 208 additions & 0 deletions pkg/linters/mapclearloop/mapclearloop.go
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",

Copy link
Copy Markdown
Contributor Author

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's go directive. On a go 1.20 (or earlier) module, the emitted SuggestedFix rewrites the loop to clear(m) — silently breaking the caller's build when applied.

Add a version check in run(), or add a minimum-version annotation on the Analyzer:

var Analyzer = &analysis.Analyzer{
    // ...
    // clear() requires Go 1.21; skip on older module targets.
}

Or guard inside run() before pass.Report:

if pass.Module != nil && !moduleRequiresAtLeast(pass.Module.GoVersion, 1, 21) {
    return nil, nil
}

At minimum, document the Go 1.21 requirement prominently in the Doc string.

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 == "_" {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test case: for k, _ := range m { delete(m, k) }

The value-blank branch (lines 77-81) that permits for k, _ := range m is never exercised by the test fixture — only the no-value form for k := range m is tested. Add a positive test case with an explicit blank value to testdata/src/mapclearloop/mapclearloop.go and its .golden counterpart.

@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" {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

False negative: TypesInfo.Defs[keyIdent] is nil when the loop key is an existing variable (not freshly declared), silently skipping valid clear candidates.

💡 Details

TypesInfo.Defs maps an identifier to the object it defines. For a for k := range m where k is a fresh declaration, Defs[keyIdent] returns the *types.Var. But when k was declared in an enclosing scope and the range clause re-assigns it (e.g. for k = range m), Defs[keyIdent] is nil — the guard at line 98 returns early, dropping the diagnostic even though clear(m) is correct.

The same issue appears in the delete-key comparison: you compare delKeyObj != keyObj where keyObj came from Defs and delKeyObj came from Uses. For the re-assignment case keyObj is nil so the early return fires, but conceptually the comparison should use Uses[keyIdent] as a fallback.

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(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The SuggestedFix replaces the full range statement span, silently dropping any comments inside the loop body. Add a fixture case with an interior comment to document (and test) this behaviour.

💡 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 clear(m) or at least noting this in the diagnostic message.

@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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/codebase-design] sameObject only handles *ast.Ident and *ast.SelectorExpr. If the range expression is an index expression (e.g. maps[i]) or a function call returning a map, sameObject silently returns false — which is safe (no false positive) but worth documenting with a comment.

💡 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 {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NodeText can return an empty string — clear() (no args) is invalid Go and will break the caller's build if the suggested fix is applied.

💡 Details and fix

astutil.NodeText calls printer.Fprint internally and returns "" on error. If mText is empty, the diagnostic message becomes range-delete loop over map can be replaced with clear() and the SuggestedFix emits clear() — syntactically invalid Go.

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
}
}
16 changes: 16 additions & 0 deletions pkg/linters/mapclearloop/mapclearloop_test.go
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"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two handled code paths in mapclearloop.go have zero test coverage: explicit blank value (for k, _ := range m) and struct-field maps (sameObject SelectorExpr branch).

💡 Missing test cases

1. Explicit blank value (lines 103–108 of the implementation)

The code explicitly handles rangeStmt.Value != nil with a blank identifier. This path is never exercised:

// should be flagged
for k, _ := range m {  // want `range-delete loop...`
    delete(m, k)
}

2. Struct-field map — sameObject SelectorExpr branch (lines 179–185)

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")
}
71 changes: 71 additions & 0 deletions pkg/linters/mapclearloop/testdata/src/mapclearloop/mapclearloop.go
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\)`

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] No positive test for a struct-field map (s.m) or a package-level map (pkg.M). The sameObject selector path is exercised by negative cases but never fires a true positive — add at least one // want line for the SelectorExpr path to confirm the fix emits clear(s.m) correctly.

💡 Suggested fixture addition
type 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] Missing negative test case: a range with a blank key (for _ := range m) is correctly skipped, but so is for range m { delete(m, k) } (no key variable). Confirm both are covered or add fixture lines to make the intent explicit.

💡 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
}
}
Loading
Loading