From 62e4f372708e6b03f2c6d14337222fae3ac4fb75 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 13 Jun 2026 18:14:35 +0000
Subject: [PATCH 1/6] =?UTF-8?q?linter:=20add=20timeafterleak=20=E2=80=94?=
 =?UTF-8?q?=20flag=20time.After=20in=20for+select=20cases?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Adds a new custom go/analysis linter that flags time.After(...) calls
used as the channel-receive expression in a select CommClause inside a
for or range loop.

## What it catches

time.After(d) creates a new *time.Timer whose goroutine keeps running
until the timer fires, even if another select case is chosen first.
In a hot loop this causes a timer goroutine leak that accumulates every
iteration:

    for {
        select {
        case <-time.After(timeout):  // ← flagged: new timer every iteration
            ...
        case <-ctx.Done():
            return
        }
    }

The correct pattern is to allocate time.NewTimer once outside the loop
and Reset/Stop it each iteration.

## Evidence

Found in 3 production sites during code scanning:
- pkg/cli/add_interactive_workflow.go
- pkg/cli/docker_images.go
- pkg/cli/mcp_inspect_mcp_scripts_server.go

## Implementation notes

- Uses the inspector Cursor API (cur.Parent() + clauseCur.Enclosing())
  to precisely detect only Comm-position receives, not receives in
  case-body statements.
- Stops at FuncLit/FuncDecl boundaries so goroutine closures launched
  in a loop are not false-positively flagged.
- Supports //nolint:timeafterleak suppression.
- Registered in cmd/linters/main.go alongside existing analyzers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
 cmd/linters/main.go                           |   2 +
 .../src/timeafterleak/timeafterleak.go        | 102 +++++++++++++
 pkg/linters/timeafterleak/timeafterleak.go    | 134 ++++++++++++++++++
 .../timeafterleak/timeafterleak_test.go       |  16 +++
 4 files changed, 254 insertions(+)
 create mode 100644 pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
 create mode 100644 pkg/linters/timeafterleak/timeafterleak.go
 create mode 100644 pkg/linters/timeafterleak/timeafterleak_test.go

diff --git a/cmd/linters/main.go b/cmd/linters/main.go
index daa5ba0c10c..9c1f95b8647 100644
--- a/cmd/linters/main.go
+++ b/cmd/linters/main.go
@@ -40,6 +40,7 @@ import (
 	"github.com/github/gh-aw/pkg/linters/sortslice"
 	"github.com/github/gh-aw/pkg/linters/ssljson"
 	"github.com/github/gh-aw/pkg/linters/strconvparseignorederror"
+	"github.com/github/gh-aw/pkg/linters/timeafterleak"
 	"github.com/github/gh-aw/pkg/linters/timesleepnocontext"
 	"github.com/github/gh-aw/pkg/linters/tolowerequalfold"
 	"github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion"
@@ -71,6 +72,7 @@ func main() {
 		strconvparseignorederror.Analyzer,
 		jsonmarshalignoredeerror.Analyzer,
 		lenstringzero.Analyzer,
+		timeafterleak.Analyzer,
 		timesleepnocontext.Analyzer,
 		tolowerequalfold.Analyzer,
 		uncheckedtypeassertion.Analyzer,
diff --git a/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go b/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
new file mode 100644
index 00000000000..ec5f9dc40ae
--- /dev/null
+++ b/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
@@ -0,0 +1,102 @@
+package timeafterleak
+
+import (
+	"context"
+	"time"
+)
+
+// BadForLoop is the canonical timer-leak: time.After in a for+select Comm.
+func BadForLoop(ctx context.Context) {
+	for {
+		select {
+		case <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration`
+			doWork()
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
+// BadForLoopAssign also leaks: the receive is the Comm of the case clause.
+func BadForLoopAssign(ctx context.Context) {
+	for {
+		select {
+		case t := <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration`
+			_ = t
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
+// BadRangeLoop flags time.After in a range-based loop select.
+func BadRangeLoop(items []string, ctx context.Context) {
+	for range items {
+		select {
+		case <-time.After(time.Millisecond): // want `time\.After creates a new timer on each loop iteration`
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
+// GoodNoLoop is fine: time.After in a select that is not inside a loop.
+func GoodNoLoop(ctx context.Context) {
+	select {
+	case <-time.After(time.Second):
+		doWork()
+	case <-ctx.Done():
+		return
+	}
+}
+
+// GoodNewTimer uses the correct pattern: a single timer reused each iteration.
+func GoodNewTimer(ctx context.Context) {
+	t := time.NewTimer(time.Second)
+	defer t.Stop()
+	for {
+		if !t.Stop() {
+			select {
+			case <-t.C:
+			default:
+			}
+		}
+		t.Reset(time.Second)
+		select {
+		case <-t.C:
+			doWork()
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
+// GoodTimeAfterInBody calls time.After inside the case body, not as the Comm.
+// The timer fires exactly once here, so there is no accumulation leak.
+func GoodTimeAfterInBody(ctx context.Context, ch <-chan struct{}) {
+	for {
+		select {
+		case <-ch:
+			<-time.After(time.Second) // in Body, not Comm — not flagged
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
+// GoodFuncLitInsideLoop: the select is inside a goroutine closure launched
+// per iteration; the for loop does not directly enclose the CommClause.
+func GoodFuncLitInsideLoop(ctx context.Context) {
+	for {
+		go func() {
+			select {
+			case <-time.After(time.Second): // FuncLit boundary — not flagged
+			case <-ctx.Done():
+			}
+		}()
+		<-ctx.Done()
+		return
+	}
+}
+
+func doWork() {}
diff --git a/pkg/linters/timeafterleak/timeafterleak.go b/pkg/linters/timeafterleak/timeafterleak.go
new file mode 100644
index 00000000000..7340a7fcd8b
--- /dev/null
+++ b/pkg/linters/timeafterleak/timeafterleak.go
@@ -0,0 +1,134 @@
+// Package timeafterleak implements a Go analysis linter that flags
+// time.After calls used as select case channel receives inside loops,
+// which allocate a new timer on every iteration that is not garbage
+// collected until it fires when another case is selected first.
+package timeafterleak
+
+import (
+	"go/ast"
+	"go/token"
+	"go/types"
+
+	"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 time-after-leak analysis pass.
+var Analyzer = &analysis.Analyzer{
+	Name:     "timeafterleak",
+	Doc:      "reports time.After calls in select cases inside loops that leak a timer goroutine on each iteration when another case fires first",
+	URL:      "https://github.com/github/gh-aw/tree/main/pkg/linters/timeafterleak",
+	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, "timeafterleak")
+
+	for cur := range insp.Root().Preorder((*ast.CallExpr)(nil)) {
+		call, ok := cur.Node().(*ast.CallExpr)
+		if !ok {
+			continue
+		}
+		if !isTimeAfterCall(pass, call) {
+			continue
+		}
+
+		pos := pass.Fset.PositionFor(call.Pos(), false)
+		if filecheck.IsTestFile(pos.Filename) {
+			continue
+		}
+		if nolint.HasDirective(pos, noLintLinesByFile) {
+			continue
+		}
+
+		if !isInsideLoopSelectComm(cur) {
+			continue
+		}
+
+		pass.ReportRangef(call,
+			"time.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time.NewTimer with Reset and Stop instead")
+	}
+
+	return nil, nil
+}
+
+// isTimeAfterCall reports whether call is an invocation of time.After.
+func isTimeAfterCall(pass *analysis.Pass, call *ast.CallExpr) bool {
+	sel, ok := call.Fun.(*ast.SelectorExpr)
+	if !ok || sel.Sel.Name != "After" {
+		return false
+	}
+	ident, ok := sel.X.(*ast.Ident)
+	if !ok {
+		return false
+	}
+	obj := pass.TypesInfo.ObjectOf(ident)
+	if obj == nil {
+		return false
+	}
+	pkgName, ok := obj.(*types.PkgName)
+	if !ok {
+		return false
+	}
+	return pkgName.Imported().Path() == "time"
+}
+
+// isInsideLoopSelectComm reports whether cur is a time.After call used as the
+// channel receive expression in the Comm field of a select CommClause that is
+// enclosed by a for or range loop, without crossing a function literal boundary.
+func isInsideLoopSelectComm(cur inspector.Cursor) bool {
+	// The immediate parent of time.After(...) must be a channel-receive UnaryExpr.
+	recvCur := cur.Parent()
+	unary, ok := recvCur.Node().(*ast.UnaryExpr)
+	if !ok || unary.Op != token.ARROW {
+		return false
+	}
+
+	// The parent of the receive expression must be the Comm statement of a CommClause.
+	// Comm is an ExprStmt (case <-ch:) or AssignStmt (case v := <-ch:).
+	commStmtCur := recvCur.Parent()
+	var commStmt ast.Stmt
+	switch s := commStmtCur.Node().(type) {
+	case *ast.ExprStmt:
+		commStmt = s
+	case *ast.AssignStmt:
+		commStmt = s
+	default:
+		return false
+	}
+
+	// The parent of the Comm statement must be a CommClause, and commStmt must
+	// be the Comm field (not a statement in the Body).
+	clauseCur := commStmtCur.Parent()
+	cc, ok := clauseCur.Node().(*ast.CommClause)
+	if !ok || cc.Comm != commStmt {
+		return false
+	}
+
+	// Walk up from the CommClause to find an enclosing for or range loop,
+	// stopping at any function literal or declaration boundary.
+	for encl := range clauseCur.Enclosing(
+		(*ast.ForStmt)(nil),
+		(*ast.RangeStmt)(nil),
+		(*ast.FuncLit)(nil),
+		(*ast.FuncDecl)(nil),
+	) {
+		switch encl.Node().(type) {
+		case *ast.ForStmt, *ast.RangeStmt:
+			return true
+		case *ast.FuncLit, *ast.FuncDecl:
+			return false
+		}
+	}
+	return false
+}
diff --git a/pkg/linters/timeafterleak/timeafterleak_test.go b/pkg/linters/timeafterleak/timeafterleak_test.go
new file mode 100644
index 00000000000..9d245b2b162
--- /dev/null
+++ b/pkg/linters/timeafterleak/timeafterleak_test.go
@@ -0,0 +1,16 @@
+//go:build !integration
+
+package timeafterleak_test
+
+import (
+	"testing"
+
+	"golang.org/x/tools/go/analysis/analysistest"
+
+	"github.com/github/gh-aw/pkg/linters/timeafterleak"
+)
+
+func TestAnalyzer(t *testing.T) {
+	testdata := analysistest.TestData()
+	analysistest.Run(t, testdata, timeafterleak.Analyzer, "timeafterleak")
+}

From b933cf3b3cf7ebca942a1b42ea23af80173b8bdb Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
 <41898282+github-actions[bot]@users.noreply.github.com>
Date: Sat, 13 Jun 2026 18:40:52 +0000
Subject: [PATCH 2/6] docs(adr): add draft ADR-39133 for timeafterleak linter

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 ...om-linter-for-time-after-leaks-in-loops.md | 42 +++++++++++++++++++
 1 file changed, 42 insertions(+)
 create mode 100644 docs/adr/39133-custom-linter-for-time-after-leaks-in-loops.md

diff --git a/docs/adr/39133-custom-linter-for-time-after-leaks-in-loops.md b/docs/adr/39133-custom-linter-for-time-after-leaks-in-loops.md
new file mode 100644
index 00000000000..e0570358bcf
--- /dev/null
+++ b/docs/adr/39133-custom-linter-for-time-after-leaks-in-loops.md
@@ -0,0 +1,42 @@
+# ADR-39133: Ship a Custom `timeafterleak` Linter for `time.After` in Loop Selects
+
+**Date**: 2026-06-13
+**Status**: Draft
+
+## Context
+
+`time.After(d)` allocates a fresh `*time.Timer` (and its backing runtime timer) on every call; the timer is not reclaimed until it fires. When a `time.After` call is the channel-receive of a `select` case inside a `for`/`range` loop, every iteration where another case fires first leaves a timer alive until its full duration elapses, leaking goroutine/heap resources in hot loops. This is a documented Go gotcha but is not flagged by any commonly enabled linter: `staticcheck` SA6001 only covers `time.Tick`, not `time.After` in select cases. A code scan of this repository found three production occurrences of the pattern, so the project needs an automated guard that fits its existing in-repo linter suite (`pkg/linters/*` registered in `cmd/linters/main.go`).
+
+## Decision
+
+We will add a bespoke `go/analysis` analyzer, `timeafterleak`, to the project's custom linter suite rather than relying on an external linter. The analyzer uses `inspector.Cursor` parent-chain traversal to flag a `time.After` call only when it is the `Comm` receive expression of a `select` `CommClause` enclosed by a `for` or `range` loop, stopping at `FuncLit`/`FuncDecl` boundaries and verifying the `time` package identity via type information rather than a syntactic name match. It honors `//nolint:timeafterleak` suppression and skips test files, and is registered in the multichecker in `cmd/linters/main.go`.
+
+## Alternatives Considered
+
+### Alternative 1: Rely on `staticcheck` / `golangci-lint` defaults
+The project already runs standard linters, so extending their configuration would avoid new code. Rejected because no default rule (including SA6001) detects `time.After` inside select cases; the specific leak pattern would go uncaught.
+
+### Alternative 2: Document the gotcha and rely on code review
+A contributor guideline plus manual review requires no tooling. Rejected because manual review is unreliable for an easily overlooked pattern — three instances already reached production — and provides no enforceable, repeatable guard.
+
+### Alternative 3: Use a syntactic (string-match) detector instead of type-checked detection
+A simpler analyzer could match the identifier text `time.After` directly. Rejected because it would produce false positives for shadowed identifiers or unrelated packages; type-based identity checking is more precise at modest extra complexity.
+
+## Consequences
+
+### Positive
+- Catches a real, otherwise-undetected resource-leak pattern automatically in CI.
+- Follows the established in-repo linter convention, so it composes with the existing `cmd/linters` multichecker and shared `internal` helpers.
+- Precise: type-checked `time` identity and Comm-position scoping minimize false positives; `FuncLit` boundary handling avoids flagging per-iteration goroutine closures.
+
+### Negative
+- Adds a custom analyzer that the team must maintain, including keeping pace with `go/ast`/`golang.org/x/tools` API changes.
+- Narrow scope: only flags the loop-select Comm position, so other `time.After` misuse patterns remain uncaught and may create a false sense of full coverage.
+
+### Neutral
+- Suppression is available via `//nolint:timeafterleak` for intentional cases.
+- Test files are excluded from analysis, matching the suite's existing conventions.
+
+---
+
+*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27475470275) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*

From c62860a6630364f2d59d183de26f06f8877f3c34 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 13 Jun 2026 19:01:08 +0000
Subject: [PATCH 3/6] linter: address review feedback on timeafterleak
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

- Fix Doc string: remove goroutine claim, use timer channel phrasing,
  expand with exclusion rules
- Remove dead *ast.FuncDecl from Enclosing stop-set (FuncDecl cannot
  appear inside a function body in Go)
- Fix false positive: skip single-case select with no default — the
  timer must fire so no timer accumulation is possible
- Fix misleading 'fires exactly once' comment in testdata
- Update want patterns to full diagnostic message (prevents silent
  drift when message changes)
- Add nolint test cases: previous-line and same-line suppression
- Add BadNestedLoop test case
- Add GoodSingleCaseSelect and BadSingleCaseWithDefault test cases
- Update pkg/linters/doc.go: add timeafterleak, increment count 24→25
- Update pkg/linters/README.md: add timeafterleak to overview list,
  subpackages table, and dependencies list

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 linters                                       | Bin 10379543 -> 10403285 bytes
 pkg/linters/README.md                         |   3 +
 pkg/linters/doc.go                            |   3 +-
 .../src/timeafterleak/timeafterleak.go        |  68 ++++++++++++++++--
 pkg/linters/timeafterleak/timeafterleak.go    |  28 ++++++--
 5 files changed, 93 insertions(+), 9 deletions(-)

diff --git a/linters b/linters
index c336ba6b6faacc5910c53a71d9be43cf15b76dae..c320f67ac0775c3807f2b5e0796784ec1a008a9b 100755
GIT binary patch
diff --git a/pkg/linters/README.md b/pkg/linters/README.md
index 4a54805ad8d..c8074cb0efc 100644
--- a/pkg/linters/README.md
+++ b/pkg/linters/README.md
@@ -29,6 +29,7 @@ This package currently provides custom Go analyzers in the following subpackages
 - `sortslice` — reports `sort.Slice` / `sort.SliceStable` calls that should use `slices.SortFunc` / `slices.SortStableFunc`.
 - `ssljson` — validates `ssl.json` skill artifacts found in `.github/skills/` against the SSL spec (enum membership, graph integrity, transition targets, entry pointer validity).
 - `strconvparseignorederror` — reports `strconv` parsing calls (`Atoi`, `ParseInt`, etc.) where the error return is discarded with `_`.
+- `timeafterleak` — reports `time.After` calls used as the channel-receive expression in a `select` case inside a `for` or `range` loop that leak a timer channel on each iteration when another case fires first.
 - `tolowerequalfold` — reports case-insensitive string comparisons using `strings.ToLower`/`ToUpper` that should use `strings.EqualFold`.
 - `uncheckedtypeassertion` — reports single-value type assertions where unchecked panics are possible.
 - `internal` — shared helper packages for analyzers (file checks and `nolint` handling).
@@ -62,6 +63,7 @@ This package currently provides custom Go analyzers in the following subpackages
 | `sortslice` | Custom `go/analysis` analyzer that flags `sort.Slice` / `sort.SliceStable` calls that should use `slices.SortFunc` / `slices.SortStableFunc` |
 | `ssljson` | Custom `go/analysis` analyzer that validates SSL JSON skill artifacts in `.github/skills/` |
 | `strconvparseignorederror` | Custom `go/analysis` analyzer that flags `strconv` parsing calls where the error return is discarded with `_` |
+| `timeafterleak` | Custom `go/analysis` analyzer that flags `time.After` in `select` cases inside loops that leak a timer channel on each iteration when another case fires first |
 | `tolowerequalfold` | Custom `go/analysis` analyzer that flags case-insensitive comparisons via `strings.ToLower`/`ToUpper` that should use `strings.EqualFold` |
 | `uncheckedtypeassertion` | Custom `go/analysis` analyzer that flags unchecked single-value type assertions |
 | `internal` | Shared helper subpackages used by analyzers (`internal/filecheck`, `internal/nolint`) |
@@ -139,6 +141,7 @@ _ = ssljson.Analyzer
 - `github.com/github/gh-aw/pkg/linters/sortslice` — sort-slice analyzer subpackage
 - `github.com/github/gh-aw/pkg/linters/ssljson` — ssl-json analyzer subpackage
 - `github.com/github/gh-aw/pkg/linters/strconvparseignorederror` — strconv-parse-ignored-error analyzer subpackage
+- `github.com/github/gh-aw/pkg/linters/timeafterleak` — time-after-leak analyzer subpackage
 - `github.com/github/gh-aw/pkg/linters/tolowerequalfold` — to-lower-equal-fold analyzer subpackage
 - `github.com/github/gh-aw/pkg/linters/uncheckedtypeassertion` — unchecked-type-assertion analyzer subpackage
 
diff --git a/pkg/linters/doc.go b/pkg/linters/doc.go
index 986ed0639cf..cb67fd23611 100644
--- a/pkg/linters/doc.go
+++ b/pkg/linters/doc.go
@@ -1,6 +1,6 @@
 // Package linters is a namespace for gh-aw's custom Go analysis linters.
 //
-// The actual analyzers are implemented in subpackages. All 24 active analyzers:
+// The actual analyzers are implemented in subpackages. All 25 active analyzers:
 //
 //   - contextcancelnotdeferred — flags context cancel functions called directly instead of deferred
 //   - ctxbackground — flags context.Background() inside functions that already receive a context
@@ -24,6 +24,7 @@
 //   - sortslice — flags sort.Slice / sort.SliceStable calls that should use slices.SortFunc / slices.SortStableFunc
 //   - ssljson — validates ssl.json skill artifacts in .github/skills/ against the SSL spec
 //   - strconvparseignorederror — flags strconv parsing calls where the error is discarded with _
+//   - timeafterleak — flags time.After in select cases inside loops that leak timer channels
 //   - tolowerequalfold — flags case-insensitive comparisons via ToLower/ToUpper that should use EqualFold
 //   - uncheckedtypeassertion — flags unchecked single-value type assertions
 //
diff --git a/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go b/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
index ec5f9dc40ae..a3bf484f3b8 100644
--- a/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
+++ b/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
@@ -9,7 +9,7 @@ import (
 func BadForLoop(ctx context.Context) {
 	for {
 		select {
-		case <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration`
+		case <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`
 			doWork()
 		case <-ctx.Done():
 			return
@@ -21,7 +21,7 @@ func BadForLoop(ctx context.Context) {
 func BadForLoopAssign(ctx context.Context) {
 	for {
 		select {
-		case t := <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration`
+		case t := <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`
 			_ = t
 		case <-ctx.Done():
 			return
@@ -33,13 +33,36 @@ func BadForLoopAssign(ctx context.Context) {
 func BadRangeLoop(items []string, ctx context.Context) {
 	for range items {
 		select {
-		case <-time.After(time.Millisecond): // want `time\.After creates a new timer on each loop iteration`
+		case <-time.After(time.Millisecond): // want `time\.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`
 		case <-ctx.Done():
 			return
 		}
 	}
 }
 
+// BadNestedLoop: the inner select is still inside a loop regardless of nesting depth.
+func BadNestedLoop(ctx context.Context) {
+	for {
+		for {
+			select {
+			case <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`
+			case <-ctx.Done():
+				return
+			}
+		}
+	}
+}
+
+// BadSingleCaseWithDefault: a default clause can preempt the timer — still flagged.
+func BadSingleCaseWithDefault(ctx context.Context) {
+	for {
+		select {
+		case <-time.After(time.Second): // want `time\.After creates a new timer on each loop iteration that is not garbage collected until it fires; use time\.NewTimer with Reset and Stop instead`
+		default:
+		}
+	}
+}
+
 // GoodNoLoop is fine: time.After in a select that is not inside a loop.
 func GoodNoLoop(ctx context.Context) {
 	select {
@@ -72,7 +95,8 @@ func GoodNewTimer(ctx context.Context) {
 }
 
 // GoodTimeAfterInBody calls time.After inside the case body, not as the Comm.
-// The timer fires exactly once here, so there is no accumulation leak.
+// Because <-time.After(...) is a blocking receive, the timer is always drained
+// before the loop continues — no timers accumulate across iterations.
 func GoodTimeAfterInBody(ctx context.Context, ch <-chan struct{}) {
 	for {
 		select {
@@ -99,4 +123,40 @@ func GoodFuncLitInsideLoop(ctx context.Context) {
 	}
 }
 
+// GoodSingleCaseSelect: the select has only one case and no default, so the
+// timer must fire before the loop continues — no timer accumulation is possible.
+func GoodSingleCaseSelect() {
+	for {
+		select {
+		case <-time.After(time.Second): // single case, no default — not flagged
+			doWork()
+		}
+	}
+}
+
+// GoodNolintPreviousLine: suppressed with a nolint directive on the previous line.
+func GoodNolintPreviousLine(ctx context.Context) {
+	for {
+		select {
+		//nolint:timeafterleak
+		case <-time.After(time.Second):
+			doWork()
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
+// GoodNolintSameLine: suppressed with a nolint directive on the same line.
+func GoodNolintSameLine(ctx context.Context) {
+	for {
+		select {
+		case <-time.After(time.Second): //nolint:timeafterleak
+			doWork()
+		case <-ctx.Done():
+			return
+		}
+	}
+}
+
 func doWork() {}
diff --git a/pkg/linters/timeafterleak/timeafterleak.go b/pkg/linters/timeafterleak/timeafterleak.go
index 7340a7fcd8b..325cab0bbf3 100644
--- a/pkg/linters/timeafterleak/timeafterleak.go
+++ b/pkg/linters/timeafterleak/timeafterleak.go
@@ -21,7 +21,7 @@ import (
 // Analyzer is the time-after-leak analysis pass.
 var Analyzer = &analysis.Analyzer{
 	Name:     "timeafterleak",
-	Doc:      "reports time.After calls in select cases inside loops that leak a timer goroutine on each iteration when another case fires first",
+	Doc:      "reports time.After calls used as the channel-receive expression in a select CommClause that is enclosed by a for or range loop; does not flag receives inside case bodies, single-case selects without a default, or selects enclosed only by a function literal boundary",
 	URL:      "https://github.com/github/gh-aw/tree/main/pkg/linters/timeafterleak",
 	Requires: []*analysis.Analyzer{inspect.Analyzer},
 	Run:      run,
@@ -86,6 +86,8 @@ func isTimeAfterCall(pass *analysis.Pass, call *ast.CallExpr) bool {
 // isInsideLoopSelectComm reports whether cur is a time.After call used as the
 // channel receive expression in the Comm field of a select CommClause that is
 // enclosed by a for or range loop, without crossing a function literal boundary.
+// Single-case selects without a default are not flagged because the timer must
+// fire before the loop can continue — no timer accumulation is possible.
 func isInsideLoopSelectComm(cur inspector.Cursor) bool {
 	// The immediate parent of time.After(...) must be a channel-receive UnaryExpr.
 	recvCur := cur.Parent()
@@ -115,18 +117,36 @@ func isInsideLoopSelectComm(cur inspector.Cursor) bool {
 		return false
 	}
 
+	// If the enclosing SelectStmt has no other CommClause (no other channel case
+	// and no default), the timer must fire — it cannot be preempted, so there is
+	// no accumulation. A default clause (CommClause with nil Comm) can preempt
+	// the timer and is still flagged.
+	for selCur := range clauseCur.Enclosing((*ast.SelectStmt)(nil)) {
+		sel := selCur.Node().(*ast.SelectStmt)
+		hasOther := false
+		for _, stmt := range sel.Body.List {
+			if other, isComm := stmt.(*ast.CommClause); isComm && other != cc {
+				hasOther = true
+				break
+			}
+		}
+		if !hasOther {
+			return false
+		}
+		break
+	}
+
 	// Walk up from the CommClause to find an enclosing for or range loop,
-	// stopping at any function literal or declaration boundary.
+	// stopping at any function literal boundary.
 	for encl := range clauseCur.Enclosing(
 		(*ast.ForStmt)(nil),
 		(*ast.RangeStmt)(nil),
 		(*ast.FuncLit)(nil),
-		(*ast.FuncDecl)(nil),
 	) {
 		switch encl.Node().(type) {
 		case *ast.ForStmt, *ast.RangeStmt:
 			return true
-		case *ast.FuncLit, *ast.FuncDecl:
+		case *ast.FuncLit:
 			return false
 		}
 	}

From 3eecc1c5f7a8615930e0f2e7b4f9cd1b35889c71 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 13 Jun 2026 19:02:55 +0000
Subject: [PATCH 4/6] linter: clarify GoodTimeAfterInBody comment in
 timeafterleak testdata

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 .../testdata/src/timeafterleak/timeafterleak.go              | 5 +++--
 1 file changed, 3 insertions(+), 2 deletions(-)

diff --git a/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go b/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
index a3bf484f3b8..25e4f8cbe47 100644
--- a/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
+++ b/pkg/linters/timeafterleak/testdata/src/timeafterleak/timeafterleak.go
@@ -95,8 +95,9 @@ func GoodNewTimer(ctx context.Context) {
 }
 
 // GoodTimeAfterInBody calls time.After inside the case body, not as the Comm.
-// Because <-time.After(...) is a blocking receive, the timer is always drained
-// before the loop continues — no timers accumulate across iterations.
+// When time.After is used in the case body rather than as the Comm expression,
+// the goroutine blocks on the receive until the timer fires — each timer is
+// fully consumed before the loop can continue, so no timers accumulate.
 func GoodTimeAfterInBody(ctx context.Context, ch <-chan struct{}) {
 	for {
 		select {

From 89c7f443029d615305436bc9275e3196b83dbfb3 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 13 Jun 2026 19:11:21 +0000
Subject: [PATCH 5/6] Apply remaining changes

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 linters | Bin 10403285 -> 10403285 bytes
 1 file changed, 0 insertions(+), 0 deletions(-)

diff --git a/linters b/linters
index c320f67ac0775c3807f2b5e0796784ec1a008a9b..82546d8f26de5712915f57cf602c4c348998c46c 100755
GIT binary patch

From 94009ae8ec161ddf6369781e66ab01a35c360b5d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 13 Jun 2026 19:39:26 +0000
Subject: [PATCH 6/6] fix: use two-value type assertion for SelectStmt to
 satisfy uncheckedtypeassertion linter

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
---
 linters                                    | Bin 10403285 -> 10403261 bytes
 pkg/linters/timeafterleak/timeafterleak.go |   5 ++++-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/linters b/linters
index 82546d8f26de5712915f57cf602c4c348998c46c..ec4b5b3b381a8f2634dc6674b60c036a6b5a3492 100755
GIT binary patch
diff --git a/pkg/linters/timeafterleak/timeafterleak.go b/pkg/linters/timeafterleak/timeafterleak.go
index 325cab0bbf3..38c69dbbaae 100644
--- a/pkg/linters/timeafterleak/timeafterleak.go
+++ b/pkg/linters/timeafterleak/timeafterleak.go
@@ -122,7 +122,10 @@ func isInsideLoopSelectComm(cur inspector.Cursor) bool {
 	// no accumulation. A default clause (CommClause with nil Comm) can preempt
 	// the timer and is still flagged.
 	for selCur := range clauseCur.Enclosing((*ast.SelectStmt)(nil)) {
-		sel := selCur.Node().(*ast.SelectStmt)
+		sel, ok := selCur.Node().(*ast.SelectStmt)
+		if !ok {
+			break
+		}
 		hasOther := false
 		for _, stmt := range sel.Body.List {
 			if other, isComm := stmt.(*ast.CommClause); isComm && other != cc {
