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
48 changes: 48 additions & 0 deletions docs/adr/47894-add-stringsconcatloop-linter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ADR-47894: Add stringsconcatloop Linter to Detect O(n²) String Concatenation in Loops

**Date**: 2026-07-25
**Status**: Draft
**Deciders**: Unknown

---

### Context

Go's `string` type is immutable. Every `string +=` inside a loop allocates a brand-new string and copies the previous content, yielding O(n²) time and memory as the loop count grows. The idiomatic fix — `strings.Builder` — avoids re-allocation by accumulating bytes and materialising the final string once. The `gh-aw` repository maintains a collection of custom `go/analysis` analyzers to enforce code-quality conventions at lint time; adding a dedicated analyzer for this pattern ensures the issue is caught before code merges rather than discovered in production profiling.

### Decision

We will add a new `stringsconcatloop` analyzer under `pkg/linters/stringsconcatloop/` that walks `*ast.AssignStmt` nodes with `token.ADD_ASSIGN`, verifies the LHS has a `string` underlying type via the type-checker, and reports a diagnostic when the assignment is enclosed within a `for` or `range` loop body — stopping at `func` literal boundaries to avoid false positives on closures. The analyzer honours `//nolint:stringsconcatloop` directives and skips generated files via the existing `filecheck` and `nolint` infrastructure already used by other linters in the collection.

### Alternatives Considered

#### Alternative 1: Rely on Runtime Profiling and Benchmarks

Profile-guided discovery (pprof, benchmarks) would catch hot O(n²) concat paths only after the code is merged and exercised. This defers detection to a later, more expensive phase of the development cycle and misses cold-path code that is rarely profiled. Static analysis at lint time prevents the pattern from entering the codebase at all.

#### Alternative 2: Adopt a Third-Party Linter (gocritic / staticcheck)

`gocritic` (rule `appendAssign`) and `staticcheck` include heuristics that overlap with this pattern, but neither covers the full set of cases the repository cares about (e.g., named string types, cursor-based AST traversal for accurate loop ancestry). Importing a third-party tool would also add a binary dependency and version-management burden, whereas a custom analyzer integrates cleanly with the existing `go/analysis` harness, `nolint` index, and `filecheck` generated-file detection already shared across all 58 analyzers in this collection.

#### Alternative 3: Extend an Existing String-Manipulation Linter

Folding this check into `stringbytesroundtrip` or another existing `string*` analyzer was considered but rejected: each analyzer in the collection has a single, clearly scoped responsibility, and conflating unrelated patterns makes the diagnostic messages ambiguous and the test surface harder to reason about.

### Consequences

#### Positive
- String O(n²) concat patterns are caught at `golangci-lint` / CI run time, before merging.
- The implementation reuses the shared `filecheck`, `nolint`, and `astutil.IsStringType` infrastructure, keeping the new analyzer consistent with the rest of the linter collection.
- Named-string-type aliases (e.g., `type myString string`) are also flagged, closing a gap that a purely syntax-level check would miss.

#### Negative
- Short loops (two or three iterations) incur no meaningful allocation overhead; the analyzer will flag these and require either a `//nolint` suppression or a `strings.Builder` rewrite that is arguably less readable in that context.
- Developers unfamiliar with the linter collection must learn one more rule and its suppression mechanism.

#### Neutral
- The linter count increments from 57 to 58; `doc.go`, `registry.go`, `spec_test.go`, and `README.md` all receive corresponding mechanical updates.
- The `//nolint:stringsconcatloop` escape hatch is available for the rare case where `+=` is intentional and the allocation cost is acceptable.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
2 changes: 2 additions & 0 deletions pkg/linters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ This package currently provides custom Go analyzers in the following subpackages
- `strconvparseignorederror` — reports `strconv` parsing calls (`Atoi`, `ParseInt`, etc.) where the error return is discarded with `_`.
- `stringbytesroundtrip` — reports redundant `string([]byte(s))` or `[]byte(string(b))` round-trip conversions that produce a wasteful intermediate copy.
- `stringreplaceminusone` — reports `strings.Replace` calls whose `n` argument is `-1`, which should use the more readable `strings.ReplaceAll`.
- `stringsconcatloop` — reports `string +=` concatenation inside `for`/`range` loop bodies, which allocates a new string copy on every iteration (O(n²) memory); use `strings.Builder` instead.
- `stringscountcontains` — reports `strings.Count(s, sub)` comparisons with `0` or `1` (e.g. `> 0`, `>= 1`, `== 0`, `!= 0`, `< 1`, `<= 0`) and their yoda-order variants that should use `strings.Contains(s, sub)` or `!strings.Contains(s, sub)` instead.
- `stringsindexcontains` — reports `strings.Index(s, substr)` comparisons with `-1` or `0` (e.g. `!= -1`, `>= 0`, `> -1`, `== -1`, `< 0`, `<= -1`) and their yoda-order variants that should use `strings.Contains(s, substr)` or `!strings.Contains(s, substr)` instead.
- `stringsjoinone` — reports `strings.Join([]string{s}, sep)` calls with a single-element slice literal where the separator is never used and the call is equivalent to just `s`.
Expand Down Expand Up @@ -117,6 +118,7 @@ This package currently provides custom Go analyzers in the following subpackages
| `strconvparseignorederror` | Custom `go/analysis` analyzer that flags `strconv` parsing calls where the error return is discarded with `_` |
| `stringbytesroundtrip` | Custom `go/analysis` analyzer that flags redundant `string([]byte(s))` or `[]byte(string(b))` round-trip conversions that produce a wasteful intermediate copy |
| `stringreplaceminusone` | Custom `go/analysis` analyzer that flags `strings.Replace` calls with `n=-1` that should use `strings.ReplaceAll` |
| `stringsconcatloop` | Custom `go/analysis` analyzer that flags `string +=` concatenation inside `for`/`range` loops that should use `strings.Builder` |
| `stringscountcontains` | Custom `go/analysis` analyzer that flags `strings.Count(s, sub)` comparisons with `0` or `1` that should use `strings.Contains` or `!strings.Contains` |
| `stringsindexcontains` | Custom `go/analysis` analyzer that flags `strings.Index(s, substr)` comparisons with `-1` or `0` that should use `strings.Contains` or `!strings.Contains` |
| `stringsjoinone` | Custom `go/analysis` analyzer that flags `strings.Join([]string{s}, sep)` calls with a single-element slice literal where the separator is unused and the call is equivalent to just `s` |
Expand Down
3 changes: 2 additions & 1 deletion pkg/linters/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package linters is a namespace for gh-aw's custom Go analysis linters.
//
// All 57 active analyzers:
// All 58 active analyzers:
//
// - appendbytestring — flags append(b, []byte(s)...) calls where s is a string that can be simplified to append(b, s...)
// - appendoneelement — flags append(s, []T{x}...) calls where a single-element slice literal is spread and can be simplified to append(s, x)
Expand Down Expand Up @@ -48,6 +48,7 @@
// - strconvparseignorederror — flags strconv parsing calls where the error is discarded with _
// - stringbytesroundtrip — reports redundant string/[]byte round-trip conversions such as string([]byte(s)) or []byte(string(b)) that produce a wasteful intermediate copy
// - stringreplaceminusone — flags strings.Replace calls with n=-1 that should use strings.ReplaceAll
// - stringsconcatloop — flags string += concatenation inside for/range loops that should use strings.Builder
// - stringscountcontains — reports strings.Count(s, sub) comparisons with 0 or 1 (e.g. > 0, >= 1, == 0, != 0, < 1, <= 0) and their yoda-order variants that should use strings.Contains(s, sub) or !strings.Contains(s, sub)
// - stringsindexcontains — flags strings.Index(s, substr) comparisons that should use strings.Contains
// - stringsjoinone — flags strings.Join([]string{s}, sep) calls with a single-element slice literal where the separator is unused and the call is equivalent to just s
Expand Down
2 changes: 2 additions & 0 deletions pkg/linters/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import (
"github.com/github/gh-aw/pkg/linters/strconvparseignorederror"
"github.com/github/gh-aw/pkg/linters/stringbytesroundtrip"
"github.com/github/gh-aw/pkg/linters/stringreplaceminusone"
"github.com/github/gh-aw/pkg/linters/stringsconcatloop"
"github.com/github/gh-aw/pkg/linters/stringscountcontains"
"github.com/github/gh-aw/pkg/linters/stringsindexcontains"
"github.com/github/gh-aw/pkg/linters/stringsjoinone"
Expand Down Expand Up @@ -112,6 +113,7 @@ func All() []*analysis.Analyzer {
strconvparseignorederror.Analyzer,
stringbytesroundtrip.Analyzer,
stringreplaceminusone.Analyzer,
stringsconcatloop.Analyzer,
stringsindexcontains.Analyzer,
stringsjoinone.Analyzer,
stringscountcontains.Analyzer,
Expand Down
6 changes: 4 additions & 2 deletions pkg/linters/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import (
"github.com/github/gh-aw/pkg/linters/strconvparseignorederror"
"github.com/github/gh-aw/pkg/linters/stringbytesroundtrip"
"github.com/github/gh-aw/pkg/linters/stringreplaceminusone"
"github.com/github/gh-aw/pkg/linters/stringsconcatloop"
"github.com/github/gh-aw/pkg/linters/stringscountcontains"
"github.com/github/gh-aw/pkg/linters/stringsindexcontains"
"github.com/github/gh-aw/pkg/linters/stringsjoinone"
Expand All @@ -83,7 +84,7 @@ type docAnalyzer struct {
}

// documentedAnalyzers returns the analyzer subpackages documented in the README
// "Public API > Subpackages" table. The README documents 57 analyzers
// "Public API > Subpackages" table. The README documents 58 analyzers
// subpackages (the non-analyzer `internal` helper subpackage is excluded because
// it exposes no Analyzer).
//
Expand All @@ -94,7 +95,7 @@ type docAnalyzer struct {
// hardcodedfilepath, httpnoctx, httprespbodyclose, httpstatuscode, ioutildeprecated, jsonmarshalignoredeerror, largefunc, lenstringsplit, lenstringzero,
// logfatallibrary, manualmutexunlock, mapclearloop, mapdeletecheck, nilctxpassed, osexitinlibrary, osgetenvlibrary, ossetenvlibrary, panic-in-library-code, rawloginlib,
// regexpcompileinfunction, seenmapbool, sortslice, sprintferrdot, sprintferrorsnew, sprintfbool, sprintfint, ssljson,
// strconvparseignorederror, stringbytesroundtrip, stringreplaceminusone, stringscountcontains, stringsindexcontains, stringsjoinone, timeafterleak, timesleepnocontext, timenowsub,
// strconvparseignorederror, stringbytesroundtrip, stringreplaceminusone, stringsconcatloop, stringscountcontains, stringsindexcontains, stringsjoinone, timeafterleak, timesleepnocontext, timenowsub,
// tolowerequalfold, trimleftright, uncheckedtypeassertion, wgdonenotdeferred, writebytestring
func documentedAnalyzers() []docAnalyzer {
return []docAnalyzer{
Expand Down Expand Up @@ -144,6 +145,7 @@ func documentedAnalyzers() []docAnalyzer {
{"strconvparseignorederror", strconvparseignorederror.Analyzer},
{"stringbytesroundtrip", stringbytesroundtrip.Analyzer},
{"stringreplaceminusone", stringreplaceminusone.Analyzer},
{"stringsconcatloop", stringsconcatloop.Analyzer},
{"stringscountcontains", stringscountcontains.Analyzer},
{"stringsindexcontains", stringsindexcontains.Analyzer},
{"stringsjoinone", stringsjoinone.Analyzer},
Expand Down
96 changes: 96 additions & 0 deletions pkg/linters/stringsconcatloop/stringsconcatloop.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Package stringsconcatloop implements a Go analysis linter that flags
// string += concatenation inside for/range loop bodies, which allocates a new
// string on every iteration and can lead to O(n²) total allocated bytes. The
// idiomatic fix is to use strings.Builder.
package stringsconcatloop

import (
"go/ast"
"go/token"

"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 string-concat-in-loop analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "stringsconcatloop",
Doc: "reports string += concatenation inside for/range loops that should use strings.Builder",
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/stringsconcatloop",
Requires: []*analysis.Analyzer{inspect.Analyzer, nolint.Analyzer, filecheck.Analyzer},
Run: run,
}

func run(pass *analysis.Pass) (any, error) {
root, err := astutil.Root(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
}

for cur := range root.Preorder((*ast.AssignStmt)(nil)) {
assign, ok := cur.Node().(*ast.AssignStmt)
if !ok {
continue

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.

[/codebase-design] len(assign.Lhs) == 0 is a dead guard — token.ADD_ASSIGN always has exactly one LHS operand and the Go parser will not produce a zero-length LHS for a compound-assignment. Remove it to avoid misleading future readers into thinking this case is reachable.

@copilot please address this.

}
if assign.Tok != token.ADD_ASSIGN {
continue
}

pos := pass.Fset.PositionFor(assign.Pos(), false)
if filecheck.ShouldSkipFilename(pos.Filename, generatedFiles) {

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.

[/tdd] The nolint directive in the testdata is on the for line, but the diagnostic fires on the += assignment — these are different line numbers. Confirm nolint.HasDirectiveForLinter checks the reported node's line, not the loop header; if it only checks the loop line the suppression silently fails.

💡 Detail

In testdata/.../stringsconcatloop.go the //nolint:stringsconcatloop comment is on the for _, p := range parts { line, while pass.ReportRangef(assign, ...) fires on result += p which is on the next line. If the nolint lookup is line-exact, the suppression is broken.

Consider adding an explicit assertion (e.g., an analysistest-style // no want comment, or a separate sub-test that verifies the diagnostic count is zero) to catch this.

@copilot please address this.

continue
}

loopPos, inLoop := enclosingLoopPosition(pass, cur)
if !inLoop {
continue
}
if nolint.HasDirectiveForLinter(pos, noLintIndex, "stringsconcatloop") {
continue

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.

//nolint on the for line silently fails for multi-statement loop bodies: the directive is matched by checking pos.Line and pos.Line-1, but pos comes from assign.Pos() — the += line. If the += is not on the immediately following line, the nolint directive is ignored and the diagnostic fires.

💡 Detail

The testdata exercises only the single-statement body case:

for _, p := range parts { (nolint/redacted):stringsconcatloop
    result += p  // line N+1 — pos.Line-1 matches ✓
}

But silently breaks when there are preceding statements:

for _, p := range parts { (nolint/redacted):stringsconcatloop
    doSomething()         // line N+1
    result += p           // line N+2 — pos.Line-1 = N+1, no match ✗ → diagnostic still fires
}

Users placing //nolint:stringsconcatloop on the for line with any preceding statement in the body will find suppression ineffective with no diagnostic to explain why.

Fix options: (a) look up the nolint on the += line itself rather than the enclosing for line, (b) expand the lookup to cover the entire loop body range, or (c) document explicitly that the directive must be on the += assignment line. Add a testdata case covering a multi-statement body to catch regressions.

}
if nolint.HasDirectiveForLinter(loopPos, noLintIndex, "stringsconcatloop") {
continue
}

if !astutil.IsStringType(pass, assign.Lhs[0]) {
continue

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.

IIFE closure false negative: acc += p inside an immediately-invoked func(){}() is still O(n2) but is never flagged: isInsideLoop returns false on the first *ast.FuncLit ancestor, treating all func literals as exempt. IIFEs are called inline on every loop iteration and share the same performance characteristic as direct +=.

💡 Detail

The testdata comment says "not flagged (new scope)" but the scope claim only applies to variable declarations — the performance problem is identical:

acc := ""
for _, p := range parts {
    func() {
        acc += p  // still O(n2): called on every iteration, allocates a new string each time
    }()
}

The FuncLit exemption is intentional for non-IIFE cases (e.g. goroutines, callbacks passed elsewhere), but applying it unconditionally means the most trivially refactorable IIFE pattern is silently skipped.

At minimum, the Doc string and/or README should acknowledge this limitation so users aren't misled. Alternatively, detect IIFEs by checking whether the FuncLit's parent node is a CallExpr and the callee is the FuncLit itself — in that case, continue traversal instead of stopping.

}

pass.ReportRangef(assign,
"string concatenation with += inside a loop allocates O(n) strings and O(n²) total bytes; use strings.Builder instead")
}

return nil, nil
}

// enclosingLoopPosition returns the nearest enclosing for/range statement
// position for cur (an AssignStmt), without crossing a function literal
// boundary. Assignments inside func literals are intentionally exempt.
func enclosingLoopPosition(pass *analysis.Pass, cur inspector.Cursor) (token.Position, 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 pass.Fset.PositionFor(encl.Node().Pos(), false), true
case *ast.FuncLit:
return token.Position{}, false
}
}
return token.Position{}, false
}
16 changes: 16 additions & 0 deletions pkg/linters/stringsconcatloop/stringsconcatloop_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !integration

package stringsconcatloop_test

import (
"testing"

"golang.org/x/tools/go/analysis/analysistest"

"github.com/github/gh-aw/pkg/linters/stringsconcatloop"
)

func TestAnalyzer(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, stringsconcatloop.Analyzer, "stringsconcatloop")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package stringsconcatloop

import "strings"

func bad() {
parts := []string{"a", "b", "c"}

// Basic range loop – should be flagged.
result := ""
for _, p := range parts {
result += p // want `string concatenation with \+= inside a loop`
}
_ = result

// Classic for loop – should be flagged.
s := ""
for i := 0; i < len(parts); i++ {
s += parts[i] // want `string concatenation with \+= inside a loop`
}
_ = s

// Named string type – should also be flagged.
type myString string
var ms myString
for _, p := range parts {
ms += myString(p) // want `string concatenation with \+= inside a loop`
}
_ = ms
}

func good() {
parts := []string{"a", "b", "c"}

// Using strings.Builder – not flagged.
var sb strings.Builder
for _, p := range parts {
sb.WriteString(p)
}
_ = sb.String()

// += outside any loop – not flagged.
result := "prefix"
result += "suffix"
_ = result

// Integer += inside a loop – not flagged.
n := 0
for i := range parts {
n += i
}
_ = n

// String += inside a func literal inside a loop – not flagged. The linter
// intentionally stops at func literal boundaries.
acc := ""
for _, p := range parts {
func() {
acc += p

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.

[/tdd] The func-literal exemption is a silent false negative that deserves a code comment or doc note explaining the trade-off. acc += p inside the goroutine/closure does cause O(n2) allocations — it's just not flagged because the implementation stops at function literal boundaries. A reader of the testdata or the linter output might wonder why it's skipped.

💡 Suggestion

Add a comment in the testdata and/or the isInsideLoop function explaining why func-literal-enclosed concatenations are intentionally out of scope (e.g., the assignment may run zero or more times depending on call site, making the loop relationship non-obvious).

@copilot please address this.

}()
}
_ = acc
}

func nolintDirective() {
parts := []string{"a", "b", "c"}

result := ""
for _, p := range parts { //nolint:stringsconcatloop
result += p
}
_ = result

result2 := ""
for _, p := range parts { //nolint:stringsconcatloop
_ = strings.TrimSpace(p)
result2 += p
}
_ = result2
}
Loading