-
Notifications
You must be signed in to change notification settings - Fork 431
feat(linters): add deferinloop analyzer — flags defer inside for-loop bodies #40679
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
Changes from all commits
ea66bd2
8be16f1
3271e19
d66e6c0
b4ead51
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,39 @@ | ||
| # ADR-40679: Add `deferinloop` linter for `defer` inside `for`/`range` loop bodies | ||
|
|
||
| **Date**: 2026-06-21 | ||
| **Status**: Draft | ||
|
|
||
| ## Context | ||
|
|
||
| The codebase maintains a custom `go/analysis` linter suite (`pkg/linters/*`, registered in `cmd/linters/main.go`) to enforce house style and catch anti-patterns that off-the-shelf golangci-lint rules miss. A `defer` placed directly inside a `for` or `range` loop body does not run at the end of each iteration — it runs only when the enclosing function returns, which can leak resources (file handles, connections) and produce surprising LIFO cleanup ordering. The upstream `gocritic` rule that covers this is disabled because of golangci-lint v2 bugs, leaving the pattern undetected. Any new rule must integrate with the established analyzer framework and keep false positives low. | ||
|
|
||
| ## Decision | ||
|
|
||
| We will add a new `go/analysis` analyzer package `pkg/linters/deferinloop` and register it in `cmd/linters/main.go`. The analyzer walks each `*ast.DeferStmt` via `inspector.Cursor.Enclosing`, flagging the statement when its nearest relevant ancestor is a `ForStmt` or `RangeStmt`, and exempting it when a `FuncLit` is encountered first (a function literal forms a new scope, so its `defer` runs on the literal's return). Test files are skipped via `filecheck.IsTestFile`, and `//nolint:deferinloop` directives are honored via the shared `nolint` helper. | ||
|
|
||
| ## Alternatives Considered | ||
|
|
||
| ### Alternative 1: Re-enable `gocritic` / use an off-the-shelf linter | ||
| The `gocritic` rule that flags this pattern is disabled because of golangci-lint v2 integration bugs. Re-enabling it would reintroduce those failures and pull in unrelated checks, while waiting on an upstream fix is slow and out of our control. Rejected in favor of a focused local analyzer that fits the existing `pkg/linters` framework. | ||
|
|
||
| ### Alternative 2: Flag every `defer` inside a loop, including those in function literals | ||
| The analyzer could report any `defer` lexically nested under a loop regardless of intervening `FuncLit` boundaries. This would misreport the common and correct closure idiom (`for ... { func() { defer ... }() }`) used to scope per-iteration cleanup, producing false positives. Rejected; the `FuncLit` boundary check preserves a high signal-to-noise ratio. | ||
|
|
||
| ## Consequences | ||
|
|
||
| ### Positive | ||
| - Catches a real resource-leak anti-pattern that no currently enabled linter detects, restoring coverage lost when `gocritic` was disabled. | ||
| - Correctly exempts the function-literal idiom, keeping false positives low. | ||
| - Follows the established analyzer pattern, so it is consistent with the rest of the suite and covered by `analysistest`-based tests (range, classic, infinite, and nested loops; closure, explicit-close, and nolint exemptions). | ||
|
|
||
| ### Negative | ||
| - Adds another analyzer to maintain and run, marginally increasing lint time and the registration surface in `cmd/linters/main.go`. | ||
| - Only direct lexical nesting is analyzed; a `defer` whose loop association is obscured through indirection is not in scope. | ||
|
|
||
| ### Neutral | ||
| - The rule is purely diagnostic (`pass.ReportRangef`); it offers no automated fix/suggested edit. | ||
| - Authors who intentionally want a function-scoped `defer` inside a loop can suppress with `//nolint:deferinloop`. | ||
|
|
||
| --- | ||
|
|
||
| *This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27915387610) workflow. The PR author must review, complete, and finalize this document before the PR can merge.* |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| // Package deferinloop implements a Go analysis linter that flags defer | ||
| // statements placed directly inside for or range loop bodies. A defer inside | ||
| // a loop does not execute at the end of each iteration — it runs when the | ||
| // enclosing function returns, which can cause resource leaks and unexpected | ||
| // cleanup ordering. | ||
| package deferinloop | ||
|
|
||
| import ( | ||
| "go/ast" | ||
|
|
||
| "golang.org/x/tools/go/analysis" | ||
| "golang.org/x/tools/go/analysis/passes/inspect" | ||
| "golang.org/x/tools/go/ast/inspector" | ||
|
|
||
| "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 defer-in-loop analysis pass. | ||
| var Analyzer = &analysis.Analyzer{ | ||
| Name: "deferinloop", | ||
| Doc: "reports defer statements enclosed anywhere within a for or range loop body; a function literal between a defer and an enclosing loop is treated as a new scope boundary, making the defer exempt; test files are not checked", | ||
| URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/deferinloop", | ||
| Requires: []*analysis.Analyzer{inspect.Analyzer}, | ||
| Run: run, | ||
| } | ||
|
|
||
| func run(pass *analysis.Pass) (any, error) { | ||
| insp, err := astutil.Inspector(pass) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| noLintLinesByFile := nolint.BuildLineIndex(pass, "deferinloop") | ||
|
|
||
| for cur := range insp.Root().Preorder((*ast.DeferStmt)(nil)) { | ||
| deferStmt, ok := cur.Node().(*ast.DeferStmt) | ||
| if !ok { | ||
| continue | ||
| } | ||
|
|
||
| pos := pass.Fset.PositionFor(deferStmt.Pos(), false) | ||
| if filecheck.IsTestFile(pos.Filename) { | ||
| continue | ||
| } | ||
| if nolint.HasDirective(pos, noLintLinesByFile) { | ||
| continue | ||
| } | ||
|
|
||
| if !isInsideLoop(cur) { | ||
| continue | ||
| } | ||
|
|
||
| pass.ReportRangef(deferStmt, | ||
| "defer inside a loop does not execute at the end of each iteration; it runs when the enclosing function returns, which can cause resource leaks") | ||
| } | ||
|
|
||
| return nil, nil | ||
| } | ||
|
|
||
| // isInsideLoop reports whether cur (a DeferStmt) is enclosed anywhere within a | ||
| // for or range loop body, without crossing a function literal boundary. | ||
|
Contributor
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. [/grill-with-docs] "Directly enclosed by a for or range loop body" is inaccurate — the function actually flags \n
💡 Suggested fix\n\ngo\n// isInsideLoop reports whether cur (a DeferStmt) has a for or range loop\n// anywhere in its ancestor chain before crossing a function literal boundary.\n// Defers inside func literals are exempt because they form a new function scope\n// and execute when the literal returns, not the outer function.\n\n
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. Fixed — |
||
| // Defers inside func literals are exempt because they form a new function scope | ||
| // and execute when the literal returns, not the outer function. | ||
|
Contributor
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.
💡 Suggested fixCurrent comment:
A maintainer who reads "directly enclosed" might refactor Suggested: // isInsideLoop reports whether cur (a DeferStmt) is enclosed anywhere within a
// for or range loop body, without crossing a function literal boundary.
// Defers inside func literals are exempt because they form a new function scope
// and execute when the literal returns, not the outer function.
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. Fixed — |
||
| func isInsideLoop(cur inspector.Cursor) bool { | ||
| for encl := range cur.Enclosing( | ||
| (*ast.ForStmt)(nil), | ||
| (*ast.RangeStmt)(nil), | ||
| (*ast.FuncLit)(nil), | ||
| ) { | ||
| switch encl.Node().(type) { | ||
| case *ast.ForStmt, *ast.RangeStmt: | ||
| return true | ||
| case *ast.FuncLit: | ||
| return false | ||
| } | ||
| } | ||
| return false | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| //go:build !integration | ||
|
|
||
| package deferinloop_test | ||
|
|
||
| import ( | ||
| "testing" | ||
|
|
||
| "golang.org/x/tools/go/analysis/analysistest" | ||
|
|
||
| "github.com/github/gh-aw/pkg/linters/deferinloop" | ||
| ) | ||
|
|
||
| func TestAnalyzer(t *testing.T) { | ||
| testdata := analysistest.TestData() | ||
| analysistest.Run(t, testdata, deferinloop.Analyzer, "deferinloop") | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| package deferinloop | ||
|
|
||
| import "os" | ||
|
|
||
| // BadForRange flags defer inside a range loop — resource leak. | ||
| func BadForRange(paths []string) { | ||
| for _, p := range paths { | ||
| f, _ := os.Open(p) | ||
| defer f.Close() // want `defer inside a loop does not execute at the end of each iteration; it runs when the enclosing function returns, which can cause resource leaks` | ||
|
Contributor
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
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. The diagnostic message contains no literal dots, so there is nothing to escape. The |
||
| } | ||
| } | ||
|
|
||
| // BadForStmt flags defer inside a classic for loop. | ||
| func BadForStmt(paths []string) { | ||
| for i := 0; i < len(paths); i++ { | ||
| f, _ := os.Open(paths[i]) | ||
| defer f.Close() // want `defer inside a loop does not execute at the end of each iteration; it runs when the enclosing function returns, which can cause resource leaks` | ||
| } | ||
| } | ||
|
|
||
| // BadForever flags defer inside an infinite for loop. | ||
| func BadForever() { | ||
| for { | ||
| f, _ := os.Open("file") | ||
| defer f.Close() // want `defer inside a loop does not execute at the end of each iteration; it runs when the enclosing function returns, which can cause resource leaks` | ||
| break | ||
| } | ||
| } | ||
|
|
||
| // BadNestedLoop flags defer inside a nested loop. | ||
| func BadNestedLoop(matrix [][]string) { | ||
| for _, row := range matrix { | ||
| for _, p := range row { | ||
| f, _ := os.Open(p) | ||
| defer f.Close() // want `defer inside a loop does not execute at the end of each iteration; it runs when the enclosing function returns, which can cause resource leaks` | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // GoodExplicitClose is fine — explicit close each iteration. | ||
| func GoodExplicitClose(paths []string) { | ||
| for _, p := range paths { | ||
| f, _ := os.Open(p) | ||
| f.Close() | ||
| } | ||
| } | ||
|
|
||
| // BadIfInLoop flags defer inside an if block within a for loop. | ||
| func BadIfInLoop(paths []string, cond bool) { | ||
| for _, p := range paths { | ||
| if cond { | ||
| f, _ := os.Open(p) | ||
| defer f.Close() // want `defer inside a loop does not execute at the end of each iteration; it runs when the enclosing function returns, which can cause resource leaks` | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // BadSelectInLoop flags defer inside a select inside a for loop. | ||
| func BadSelectInLoop(ch <-chan string) { | ||
| for { | ||
| select { | ||
| case p := <-ch: | ||
| f, _ := os.Open(p) | ||
| defer f.Close() // want `defer inside a loop does not execute at the end of each iteration; it runs when the enclosing function returns, which can cause resource leaks` | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // GoodFuncLitInsideLoop is fine — defer is inside a closure (new scope). | ||
| func GoodFuncLitInsideLoop(paths []string) { | ||
| for _, p := range paths { | ||
| func() { | ||
| f, _ := os.Open(p) | ||
| defer f.Close() // FuncLit boundary — not flagged | ||
| }() | ||
| } | ||
| } | ||
|
|
||
| // GoodGoFuncLitInsideLoop is fine — goroutine func literal also forms a new scope. | ||
| func GoodGoFuncLitInsideLoop(paths []string) { | ||
| for _, p := range paths { | ||
| go func() { | ||
| f, _ := os.Open(p) | ||
| defer f.Close() // FuncLit boundary — not flagged | ||
| }() | ||
| } | ||
| } | ||
|
|
||
| // GoodDeferOutsideLoop is fine — defer is not inside a loop. | ||
| func GoodDeferOutsideLoop() { | ||
| f, _ := os.Open("file") | ||
| defer f.Close() | ||
| } | ||
|
|
||
| // GoodNolintSameLine: suppressed with a nolint directive on the same line. | ||
| func GoodNolintSameLine(paths []string) { | ||
| for _, p := range paths { | ||
| f, _ := os.Open(p) | ||
| defer f.Close() //nolint:deferinloop | ||
| } | ||
| } | ||
|
|
||
| // GoodNolintPreviousLine: suppressed with a nolint directive on the previous line. | ||
| func GoodNolintPreviousLine(paths []string) { | ||
| for _, p := range paths { | ||
| f, _ := os.Open(p) | ||
| //nolint:deferinloop | ||
| defer f.Close() | ||
| } | ||
| } | ||
|
Contributor
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 test cases for \n
💡 Suggested additions\n\nAdd these exempt/flagged cases to verify the ancestor-walk is correct regardless of intermediate AST nodes:\n\ngo\n// BadSelectInLoop flags defer inside a select inside a for loop.\nfunc BadSelectInLoop(ch <-chan string) {\n\tfor {\n\t\tselect {\n\t\tcase p := <-ch:\n\t\t\tf, _ := os.Open(p)\n\t\t\tdefer f.Close() // want `defer inside a loop...`\n\t\t}\n\t}\n}\n\n// BadIfInLoop flags defer inside an if block inside a for loop.\nfunc BadIfInLoop(paths []string, cond bool) {\n\tfor _, p := range paths {\n\t\tif cond {\n\t\t\tf, _ := os.Open(p)\n\t\t\tdefer f.Close() // want `defer inside a loop...`\n\t\t}\n\t}\n}\n\n// GoodGoFuncLitInsideLoop is fine — goroutine func literal forms a new scope.\nfunc GoodGoFuncLitInsideLoop(paths []string) {\n\tfor _, p := range paths {\n\t\tgo func() {\n\t\t\tf, _ := os.Open(p)\n\t\t\tdefer f.Close()\n\t\t}()\n\t}\n}\n\n\nThe implementation should handle all these correctly (the ancestor walk skips non-filter nodes), but without tests a future refactor has no safety net.\n
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. Added
Contributor
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. No test case for defer nested inside a conditional ( 💡 Suggested addition// BadDeferInIfInsideLoop flags defer inside an if block within a loop.
func BadDeferInIfInsideLoop(paths []string, cond bool) {
for _, p := range paths {
if cond {
f, _ := os.Open(p)
defer f.Close() // want `defer inside a loop does not execute at the end of each iteration; it runs when the enclosing function returns, which can cause resource leaks`
}
}
}The current implementation is correct —
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. Added |
||
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.
Done —
pkg/linters/doc.goupdated (count 29→30,deferinloopadded alphabetically) andpkg/linters/spec_test.goupdated with the import and entry indocumentedAnalyzers().