Skip to content

[linter-miner] Add loop-index-address-taken linter - #63475

Closed
github-actions[bot] wants to merge 2 commits into
mainfrom
linter-miner/loop-index-address-taken-5b6e924e4847a230
Closed

github-actions[bot] wants to merge 2 commits into
mainfrom
linter-miner/loop-index-address-taken-5b6e924e4847a230

Conversation

@github-actions

Copy link
Copy Markdown
Contributor

Summary

This PR introduces a new linter that detects a common concurrency bug where loop index variables are captured by address in goroutines or deferred functions without proper value capture.

Problem

Loop variables in for-range loops are reused across iterations. When the address of a loop variable is used in a goroutine or deferred function, all captures share the same address, causing the captured value to change unexpectedly. This is a subtle but dangerous bug.

Example of the bug:

for i := range items {
    go func() {
        fmt.Println(i)  // BUG: i may have changed by the time goroutine runs
    }()
}

Correct pattern:

for i := range items {
    go func(i int) {
        fmt.Println(i)  // OK: captured by value
    }(i)
}

Implementation Details

  • Package: pkg/linters/loopindexaddresstaken/
  • Analyzer name: loopindexaddresstaken
  • Detection strategy: Scans loop bodies for goroutines and deferred functions; flags any address-of expressions (&) applied to loop index variables

What the linter detects

  • Taking address of loop index variable in goroutine: go func() { &i }
  • Taking address of loop index variable in deferred function: defer func() { &i }

What the linter correctly ignores

  • Parameters captured by value: go func(i int) { ... }(i)
  • Variables without address-of operator: go func() { fmt.Println(i) }
  • Nested function literals (different scope)
  • Suppressed with nolint directives
  • Generated files

Testing

  • Created comprehensive test fixtures with positive cases (bugs flagged) and negative cases (correct patterns not flagged)
  • All tests pass: go test ./pkg/linters/loopindexaddresstaken/...
  • Linter successfully integrated into the build system
  • Registered in pkg/linters/registry.go

Evidence from mining

This linter addresses a pattern that recurs in real-world Go code and is difficult to catch in code review. It provides high signal-to-noise (few false positives) and is not covered by standard golangci-lint rules.

Generated by Linter Miner · copilot · mai10 · 118.4 AIC · ⊞ 6.6K · ◷

  • expires on Oct 2, 2026, 9:44 AM UTC-08:00

This linter detects a common concurrency bug where loop index variables
are captured by address in goroutines or deferred functions without
proper value capture. This is dangerous because the loop variable may
be reassigned before the goroutine/deferred function executes.

For example:
  for i := range items {
      go func() {
          fmt.Println(i)  // BUG: i may have changed
      }()
  }

Should be:
  for i := range items {
      go func(i int) {
          fmt.Println(i)  // OK: captured by value
      }(i)
  }

The linter correctly handles:
- Parameters captured by value (no error)
- Variables without address-of operator (no error)
- Value variables in nested scopes (no error)
- nolint directives (respects suppressions)
- Generated files (skipped)

Tests verify positive cases (bugs flagged) and negative cases
(correct patterns not flagged).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added automation cookie Issue Monster Loves Cookies! go-linters labels Sep 25, 2026
@pelikhan
pelikhan marked this pull request as ready for review September 25, 2026 19:56
Copilot AI balanced review requested due to automatic review settings September 25, 2026 19:56
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Design Decision Gate 🏗️ completed the design decision gate check. See the comment below for the result and any generated ADR draft.

🏗️ ADR gate enforced by Design Decision Gate 🏗️

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ PR Code Quality Reviewer completed the code quality review.

🔎 Code quality review by PR Code Quality Reviewer

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Test Quality Sentinel completed test quality analysis.

Test Quality Sentinel skipped because pre-fetch PR data was unavailable: unable to fetch test file diff

🧪 Test quality analysis by Test Quality Sentinel

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

✅ Ponytail Reviewer completed successfully!

Lean already. Ship.

Generated by Ponytail Reviewer for #63475

@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor Author

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

The analyzer reports safe Go 1.22+ loops, misses several genuine address escapes, and breaks the registry documentation test.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Adds a custom Go analyzer intended to detect loop-index addresses escaping through goroutines or deferred calls.

Changes:

  • Implements and registers loopindexaddresstaken.
  • Adds analysistest coverage and fixtures.
File Description
pkg/​linters/​registry.go Registers the analyzer.
pkg/​linters/​loopindexaddresstaken/​loopindexaddresstaken.go Implements detection logic.
pkg/​linters/​loopindexaddresstaken/​loopindexaddresstaken_test.go Runs analyzer fixtures.
pkg/​linters/​loopindexaddresstaken/​testdata/​src/​loopindexaddresstaken/​loopindexaddresstaken.go Defines positive and negative cases.

Comment thread pkg/linters/registry.go
ioutildeprecated.Analyzer,
httpstatuscode.Analyzer,
largefunc.Analyzer,
loopindexaddresstaken.Analyzer,
// It does NOT flag:
// - Capturing the loop variable by value: go func(i int) { ... }(i)
// - Just using the variable (not its address): go func() { ... fmt.Println(i) ... }()
// - Breaking out of the loop after starting the goroutine (single iteration)
@github-actions

Copy link
Copy Markdown
Contributor Author
🏗️ ADR Required — draft added for PR #63475

I found no existing ADR in the PR body or current docs/adr/ branch contents that covers this change with the required Michael Nygard sections (Context, Decision, Alternatives Considered, Consequences).

Evidence reviewed

  • PR body describing a new custom analyzer: pkg/linters/loopindexaddresstaken/
  • PR diff adding the analyzer, tests, and registry entry
  • Existing ADR branch contents, whose newest ADR was docs/adr/63212-add-hosted-web-domain-policies.md

Inferred architectural decision

This PR introduces a repository-wide static-analysis rule to enforce a specific concurrency-safety policy: detect taking the address of for range loop index variables inside goroutines and deferred functions, and ship that rule as a first-class custom linter.

Action taken

I added a draft ADR to the PR branch:

  • docs/adr/63475-add-loop-index-address-linter.md

Next action for the author

Please review and refine the draft ADR, especially the scope boundaries and trade-offs, and keep it with the PR as the design record for this linter addition.

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · pi · gpt54 · 15.7 AIC · ⌖ 10.3 AIC · ⊞ 10.4K · ◷
Comment /review to run again

@github-actions

Copy link
Copy Markdown
Contributor Author

Comment Memory

reviewed_at: 2026-09-25T19:58:44.261+00:00
review_event: REQUEST_CHANGES
top_themes:
  - false positives from Go 1.22+ range semantics
  - name-based matching instead of object identity
  - nested closure false negatives
files_reviewed:
  - pkg/linters/loopindexaddresstaken/loopindexaddresstaken.go
  - pkg/linters/loopindexaddresstaken/loopindexaddresstaken_test.go
  - pkg/linters/loopindexaddresstaken/testdata/src/loopindexaddresstaken/loopindexaddresstaken.go
  - pkg/linters/registry.go
comment_count: 3

Note

This comment is managed by comment memory.

It stores persistent context for this thread in the code block at the top of this comment.
Edit only the text inside the backtick fences; workflow metadata and the footer are regenerated automatically.

Learn more about comment memory

🔎 Code quality review by PR Code Quality Reviewer · copilot · gpt54 · 45 AIC · ⌖ 7.09 AIC · ⊞ 20.3K · ◷
Comment /review to run again

@github-actions github-actions Bot left a comment

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.

Verdict

This linter is not safe to merge yet: it encodes pre-Go-1.22 loop-variable semantics, matches identifiers by name instead of object identity, and skips nested closures that can still capture the same loop variable.

🔎 Code quality review by PR Code Quality Reviewer · copilot · gpt54 · 45 AIC · ⌖ 7.09 AIC · ⊞ 20.3K
Comment /review to run again


ast.Inspect(body, func(n ast.Node) bool {
// Skip nested function literals — their scope is separate
if _, ok := n.(*ast.FuncLit); ok && n != body {

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.

Skipping every nested function literal here hides real captures of the outer loop variable instead of creating a new safe scope.

💡 Why this blocks the linter

A nested closure still closes over the outer i unless it shadows it, so this becomes a false negative:

var i int
for i = range items {
    go func() {
        inner := func() { fmt.Println(&i) }
        inner()
    }()
}

The new fixture currently labels that pattern as GoodNestedFuncLit, which bakes the false negative into the test suite. Please keep descending and use object identity to decide whether the inner &i refers to the loop variable.


// Check if the operand is the loop variable
operandIdent, ok := unary.X.(*ast.Ident)
if !ok || operandIdent.Name != loopVar.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.

Comparing identifiers by name here will misreport any shadowed local i inside the closure as if it were the range variable.

💡 Why this blocks the linter

This check should use object identity, not Ident.Name, because the safe code below is a different variable but currently matches:

var i int
for i = range items {
    go func() {
        i := 0
        fmt.Println(&i)
    }()
}

Other analyzers in this tree already use pass.TypesInfo to compare referenced objects; this one should do the same both here and in hasLoopVarAsParam.

}

// Get the loop variables
indexVar := getLoopVar(rangeStmt.Key)

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.

This rule is going to flag correct Go 1.22+ code because it treats for i := range loop vars as reused across iterations even though this module targets Go 1.26.

💡 Why this blocks the linter

Since Go 1.22, range variables introduced with := are fresh per iteration, so the new fixtures are not bugs anymore. The still-broken form is the predeclared-variable case:

var i int
for i = range items {
    go func() { fmt.Println(&i) }()
}

Please gate this analyzer on the loop form or language version instead of all RangeStmts, and add fixtures for the = case.

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /grill-with-docs and /tdd — requesting changes primarily because the core premise of the linter appears invalidated by this repository's own Go version.

📋 Key Themes & Highlights

Key Themes

  • Premise mismatch (blocking): go.mod pins go 1.26.8. Since Go 1.22, for-range loop variables are scoped per-iteration, so the exact address-of-loop-var-in-goroutine/defer pattern this linter flags is no longer a data race in this codebase (or any module targeting Go 1.22+). I reproduced this locally: the flagged pattern produced correct per-iteration values, not the classic "all goroutines see last value" bug. Worth confirming whether the linter should be scoped to pre-1.22 modules, reframed as a style nit, or dropped.
  • Formatting: gofmt -l fails on both pkg/linters/registry.go (import not alphabetically sorted) and loopindexaddresstaken.go (trailing whitespace) — make fmt needs to be run before merge.

Positive Highlights

  • ✅ Clean, well-documented package with a clear doc comment explaining intent and false-positive avoidance.
  • ✅ Good test fixture coverage of positive/negative cases (value-capture params, nested func literals, classic for loops correctly excluded).
  • ✅ Correct handling of nolint directives and generated-file skipping via shared analyzerutil/nolint/filecheck helpers, consistent with sibling linters.

@copilot please address the review comments above.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · copilot · sonnet50 · 59.3 AIC · ⌖ 14.8 AIC · ⊞ 10.1K
Comment /matt to run again

// - Just using the variable (not its address): go func() { ... fmt.Println(i) ... }()
// - Breaking out of the loop after starting the goroutine (single iteration)
package loopindexaddresstaken

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.

[/grill-with-docs] Since this repo targets go 1.26.8 (go.mod), Go 1.22+ per-iteration loop variable semantics mean for i := range items { go func(){ &i }() } no longer shares an address across iterations — I verified this by running the exact pattern (each goroutine got a distinct value, not the last-iteration value). The bug this linter targets doesn't actually occur in code built with this module's Go version.

💡 Reproduction
// go.mod: go 1.22 (or later)
for i := range items {
    go func() {
        results[i] = *(&i) // each goroutine sees its own i, NOT the buggy shared value
    }()
}

Running this prints values in a permutation of [0 1 2], not repeated last-index values — confirming there's no bug to catch here for code on Go 1.22+.

Consider either: (a) scoping the analyzer to only fire when the analyzed module's go directive is < 1.22, or (b) reframing the docs/rationale to note this is now purely a style/clarity nit (taking &i is unusual but not a data race under 1.22+ semantics), or (c) dropping the linter if it no longer detects a real bug in this codebase.

@copilot please address this.

Comment thread pkg/linters/registry.go
"github.com/github/gh-aw/pkg/linters/ioutildeprecated"
"github.com/github/gh-aw/pkg/linters/jsonmarshalignoredeerror"
"github.com/github/gh-aw/pkg/linters/largefunc"
"github.com/github/gh-aw/pkg/linters/loopindexaddresstaken"

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] gofmt/goimports fails on this line — the new import isn't alphabetically sorted (loopindexaddresstaken is placed before lenstringsplit/lenstringzero, breaking the existing l* ordering). Run make fmt (per repo conventions) before merging; CI formatting checks will likely fail otherwise.

💡 Verified locally
$ gofmt -l pkg/linters/registry.go
pkg/linters/registry.go

Correct order should be: largefunc, lenstringsplit, lenstringzero, logfatallibrary, loopindexaddresstaken, manualmutexunlock, ... (same fix needed for the allAnalyzers slice entry).

@copilot please address this.


// Get the loop variables
indexVar := getLoopVar(rangeStmt.Key)

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] Trailing whitespace on this blank line fails gofmt -l (confirmed locally). Run make fmt to clean this up alongside the import-order fix in registry.go.

@copilot please address this.

@github-actions github-actions Bot left a comment

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.

Impeccable review — loop-index-address-taken linter

Applied harden (edge-case/false-positive robustness) and audit (code quality) review modes, since this PR is a bug-detection tool where false positives directly undermine its value.

# Issue Severity
1 Matches loop var by identifier name only, not by object identity/scope — produces a false positive when the name is shadowed inside the goroutine/defer body (verified locally: appended a ShadowedFalsePositive test case and reproduced the incorrect diagnostic) Blocking
2 New import/analyzer entries in registry.go are not alphabetically ordered (gofmt -d confirms) Non-blocking

Recommend using pass.TypesInfo.Uses/ObjectOf to compare identifier objects rather than string names for issue #1, since that's the root cause of the false positive.

Verification steps
go test ./pkg/linters/loopindexaddresstaken/...   # passes as-is
# appended a shadowed-variable case to testdata, reran -> reproduced false positive
gofmt -d pkg/linters/registry.go                  # confirms import ordering diff

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · copilot · sonnet50 · 71.5 AIC · ⌖ 13.4 AIC · ⊞ 8.1K


// Check if the operand is the loop variable
operandIdent, ok := unary.X.(*ast.Ident)
if !ok || operandIdent.Name != loopVar.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.

False positive: matches by identifier name, not by declaration/scope.

operandIdent.Name != loopVar.Name only compares the identifier's textual name. If the loop variable's name is shadowed by a local redeclaration inside the goroutine/defer body (e.g. i := 42 inside the closure), &i there refers to the local variable, not the loop variable — but the linter still flags it.

Reproduced locally by appending this case to the testdata fixture and running go test ./pkg/linters/loopindexaddresstaken/...:

func ShadowedFalsePositive(items []string) {
	for i, v := range items {
		fmt.Println(i)
		go func() {
			i := 42
			fmt.Println(&i, v) // false positive: this `i` is the local, not the loop var
		}()
	}
}

This produces unexpected diagnostic: taking the address of loop variable i in a goroutine... even though the code is correct.

Suggest resolving identity via pass.TypesInfo.Uses[operandIdent]/ObjectOf (or checking the *ast.Object/scope) and comparing against the loop variable's actual types.Object/*ast.Object, not just the name string, to avoid false positives on shadowed variables.

@copilot please address this.

Comment thread pkg/linters/registry.go
"github.com/github/gh-aw/pkg/linters/ioutildeprecated"
"github.com/github/gh-aw/pkg/linters/jsonmarshalignoredeerror"
"github.com/github/gh-aw/pkg/linters/largefunc"
"github.com/github/gh-aw/pkg/linters/loopindexaddresstaken"

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.

Import not alphabetically ordered — breaks gofmt/goimports grouping convention used throughout this file.

loopindexaddresstaken (starts with loop...) is inserted before lenstringsplit/lenstringzero (len...), which sort earlier alphabetically. gofmt -d on this file confirms:

-	"github.com/github/gh-aw/pkg/linters/loopindexaddresstaken"
 	"github.com/github/gh-aw/pkg/linters/lenstringsplit"
 	"github.com/github/gh-aw/pkg/linters/lenstringzero"
 	"github.com/github/gh-aw/pkg/linters/logfatallibrary"
+	"github.com/github/gh-aw/pkg/linters/loopindexaddresstaken"

Move the import to after logfatallibrary (before manualmutexunlock) to restore alphabetical order. The corresponding entry in the allAnalyzers slice below (line ~120) has the same ordering issue — it's placed before logfatallibrary.Analyzer instead of after.

@copilot please address this.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot Please address the outstanding review feedback and continue with the pr-finisher skill.

Generated by 👨🍳 PR Sous Chef

Generated by 👨‍🍳 PR Sous Chef · pi · gpt54 · 29.1 AIC · ⌖ 8.67 AIC · ⊞ 9.6K · ◷
Comment /souschef to run again

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automation cookie Issue Monster Loves Cookies! go-linters

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants