Skip to content

feat(linters): add stringsconcatloop — detect string += concatenation inside loops - #47894

Merged
pelikhan merged 6 commits into
mainfrom
copilot/add-stringsconcatloop-linter
Jul 25, 2026
Merged

feat(linters): add stringsconcatloop — detect string += concatenation inside loops#47894
pelikhan merged 6 commits into
mainfrom
copilot/add-stringsconcatloop-linter

Conversation

Copilot AI commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Adds a new stringsconcatloop Go analysis linter that flags string += inside for/range loop bodies. Each iteration allocates a full string copy, yielding O(n²) memory; the idiomatic fix is strings.Builder.

// flagged
result := ""
for _, p := range parts {
    result += p  // string concatenation with += inside a loop causes O(n²) allocations; use strings.Builder instead
}

// ok
var sb strings.Builder
for _, p := range parts {
    sb.WriteString(p)
}

Implementation

  • pkg/linters/stringsconcatloop/ — analyzer walks *ast.AssignStmt nodes via cursor-based inspector, checks token.ADD_ASSIGN with string underlying type on LHS, then verifies enclosure in a for/range loop without crossing a func literal boundary. Skips generated files; honours //nolint:stringsconcatloop.
  • pkg/linters/registry.go — registered in All()
  • pkg/linters/doc.go, README.md, spec_test.go — doc surfaces updated (count 57→58)

Run: https://github.com/github/gh-aw/actions/runs/30143146258

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 8.03 AIC · ⌖ 8.45 AIC · ⊞ 7.1K ·
Comment /souschef to run again

…loops

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Add stringsconcatloop linter for detecting string += in loops feat(linters): add stringsconcatloop — detect string += concatenation inside loops Jul 25, 2026
Copilot AI requested a review from pelikhan July 25, 2026 03:34
@pelikhan
pelikhan marked this pull request as ready for review July 25, 2026 03:35
Copilot AI review requested due to automatic review settings July 25, 2026 03:35

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.

Pull request overview

Adds the stringsconcatloop analyzer to detect inefficient string concatenation inside loops.

Changes:

  • Implements and registers the analyzer.
  • Adds analysistest coverage.
  • Updates linter documentation and analyzer counts.
Show a summary per file
File Description
pkg/linters/stringsconcatloop/stringsconcatloop.go Implements loop-concatenation detection.
pkg/linters/stringsconcatloop/stringsconcatloop_test.go Runs analyzer tests.
pkg/linters/stringsconcatloop/testdata/src/stringsconcatloop/stringsconcatloop.go Provides positive and negative fixtures.
pkg/linters/registry.go Registers the analyzer.
pkg/linters/spec_test.go Adds documentation synchronization metadata.
pkg/linters/README.md Documents the analyzer.
pkg/linters/doc.go Updates package documentation and count.

Review details

Tip

Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comments suppressed due to low confidence (1)

pkg/linters/stringsconcatloop/stringsconcatloop.go:89

  • A ForStmt also encloses its Init statement, which runs only once. For valid code such as for result += prefix; cond; { ... }, this branch reports quadratic loop concatenation even though the assignment is not repeated. Skip the current assignment when it is the loop's Init (while continuing to inspect any outer loop); the Post statement should remain reportable because it runs per iteration.
		switch encl.Node().(type) {
		case *ast.ForStmt, *ast.RangeStmt:
			return true
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Medium

Comment on lines +71 to +72
pass.ReportRangef(assign,
"string concatenation with += inside a loop causes O(n²) allocations; use strings.Builder instead")
@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

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

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

ADR for the decision to add a static analysis linter that detects
O(n²) string += concatenation inside for/range loops.
@github-actions

Copy link
Copy Markdown
Contributor

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (192 new lines in pkg/linters/) but does not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/47894-add-stringsconcatloop-linter.md — review and complete it before merging.

🔒 This PR cannot merge until an ADR is linked in the PR body.

📋 What to do next
  1. Review the draft ADR committed to your branch — it was generated from the PR diff
  2. Complete the missing sections — add context the AI could not infer, refine the decision rationale, and list real alternatives you considered
  3. Commit the finalized ADR to docs/adr/ on your branch
  4. Reference the ADR in this PR body by adding a line such as:

    ADR: ADR-47894: Add stringsconcatloop Linter

Once an ADR is linked in the PR body, this gate will re-run and verify the implementation matches the decision.

❓ Why ADRs Matter

"AI made me procrastinate on key design decisions. Because refactoring was cheap, I could always say 'I'll deal with this later.' Deferring decisions corroded my ability to think clearly."

ADRs create a searchable, permanent record of why the codebase looks the way it does. Future contributors (and your future self) will thank you.

📋 Michael Nygard ADR Format Reference

An ADR must contain these four sections to be considered complete:

  • Context — What is the problem? What forces are at play?
  • Decision — What did you decide? Why?
  • Alternatives Considered — What else could have been done?
  • Consequences — What are the trade-offs (positive and negative)?

All ADRs are stored in docs/adr/ as Markdown files numbered by PR number (e.g., 47894-add-stringsconcatloop-linter.md for PR #47894).

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · sonnet46 · 48.6 AIC · ⌖ 12.8 AIC · ⊞ 8.5K ·
Comment /review to run again

@github-actions

Copy link
Copy Markdown
Contributor

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 2 test(s): 2 design, 0 implementation, 0 violation(s).

📊 Metrics (2 tests)
Metric Value
Analyzed 2 (Go: 2, JS: 0)
✅ Design 2 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 2 (100%)
Duplicate clusters 0
Inflation No (16/95 = 17%)
🚨 Violations 0
Test File Classification Issues
TestAnalyzer stringsconcatloop_test.go Design / behavioral ✅ Validates analyzer correctness via testdata framework
TestRegistryMatchesDocumentation spec_test.go (modified) Design / contract ✅ Validates registry/doc sync for stringsconcatloop

Analysis Summary

stringsconcatloop_test.go uses the canonical golang.org/x/tools/go/analysis/analysistest framework. The test validates:

  • ✅ Analyzer correctly flags string += in for/range loops (3 positive cases)
  • ✅ Analyzer respects nolint directives (//nolint:stringsconcatloop)
  • ✅ Analyzer handles scope boundaries (func literals create new scope)
  • ✅ Negative cases pass (strings.Builder, += outside loops, non-string types)
  • ✅ Build tag present: //go:build !integration

spec_test.go modifications maintain the contract:

  • Import added for stringsconcatloop package
  • Entry added to documentedAnalyzers() list
  • Existing TestRegistryMatchesDocumentation now validates bidirectional sync

Test inflation: 16 test lines / 95 production lines = 0.17 (well under 2:1 threshold)

Verdict

Passed. 0% implementation tests (threshold: 30%). All tests validate user-visible behavioral contracts without coupling to implementation details.

🧪 Test quality analysis by Test Quality Sentinel · haiku45 · 14.9 AIC · ⌖ 13.1 AIC · ⊞ 7.1K ·
Comment /review to run again

@github-actions github-actions Bot 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.

Test Quality Sentinel: 100/100. 0% implementation tests (threshold: 30%). All tests validate user-visible behavioral contracts without coupling to implementation details.

@github-actions github-actions Bot 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.

Two issues need fixing before merge.

### Findings

[high] nolint directive placement is broken for multi-statement loop bodies (line 60): The suppression check uses pos.Line-1 to match a directive on the enclosing for line, but pos is derived from assign.Pos() — the += line itself. Any loop body with statements before the += will silently ignore the //nolint:stringsconcatloop directive. Testdata only covers the degenerate one-statement case.

[medium] IIFE false negative (line 68): isInsideLoop stops at any *ast.FuncLit, including immediately-invoked closures. An IIFE called on every loop iteration allocates identically to direct +=, but is never flagged. The testdata comment "not flagged (new scope)" is misleading — the scope argument does not apply to performance. This should be documented as a known limitation at minimum.

🔎 Code quality review by PR Code Quality Reviewer · sonnet46 · 44.2 AIC · ⌖ 4.89 AIC · ⊞ 5.7K
Comment /review to run again

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 !isInsideLoop(cur) {
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.

@github-actions github-actions Bot 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.

Review: stringsconcatloop linter

The implementation is well-structured and follows established patterns in the codebase. The cursor-based Enclosing traversal correctly handles the func-literal boundary exemption, nolint directive handling matches existing linters, and testdata coverage is thorough.

One existing comment (line 72 of the main Go file) flags an inaccuracy in the diagnostic message: O(n2) allocations conflates number-of-allocations (O(n)) with total bytes allocated (O(n2)). That wording should be corrected to avoid misleading consumers of the diagnostic.

No other blocking issues found.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 34.9 AIC · ⌖ 4.52 AIC · ⊞ 5K

@github-actions github-actions Bot 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.

Skills-Based Review 🧠

Applied /tdd and /codebase-design — requesting changes on two correctness concerns and one clarity gap.

📋 Key Themes & Highlights

Key Themes

  • Nolint placement vs. report site mismatch — the suppression comment is on the for line but the diagnostic fires on the inner +=; this may silently not suppress.
  • Dead guardlen(assign.Lhs) == 0 is unreachable for token.ADD_ASSIGN; remove to avoid misleading readers.
  • Undocumented design decision — func-literal exemption is a real false-negative trade-off that should be explained in code.

Positive Highlights

  • ✅ Solid cursor-based AST traversal using the inspector.Cursor API consistently with the rest of the codebase
  • ✅ Excellent testdata coverage: range, classic for, named types, integers, Builder, nolint, func literals — all present
  • ✅ Correct scope-aware isInsideLoop that stops at function literal boundaries
  • ✅ Full registration + documentation update with accurate count (57→58)

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 40.2 AIC · ⌖ 4.84 AIC · ⊞ 6.7K
Comment /matt to run again

}

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.

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.

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.

@gh-aw-bot

Copy link
Copy Markdown
Collaborator

@copilot please run the skill, confirm the branch is up to date, address unresolved review feedback, and rerun or retrigger checks as needed on the latest HEAD.

Unresolved review feedback:

Run: https://github.com/github/gh-aw/actions/runs/30143146258

Generated by 👨‍🍳 PR Sous Chef · gpt54 · 8.03 AIC · ⌖ 8.45 AIC · ⊞ 7.1K ·
Comment /souschef to run again

…ncatloop-linter

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
…suppression

Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>

Copilot AI commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

@copilot please run the skill, confirm the branch is up to date, address unresolved review feedback, and rerun or retrigger checks as needed on the latest HEAD.

Unresolved review feedback:...

Addressed on latest HEAD 4843454.

  • Synced branch with origin/main (now up to date).
  • Fixed unresolved review feedback in stringsconcatloop:
    • clarified diagnostic/package wording to accurately describe O(n²) total allocated bytes,
    • removed unreachable len(assign.Lhs)==0 guard,
    • made //nolint:stringsconcatloop on loop headers work for multi-statement loop bodies,
    • added test coverage for the multi-statement nolint case and clarified func-literal boundary behavior.
  • Re-ran local validation (make agent-report-progress), targeted analyzer tests, secret scan, and parallel validation.

I reviewed prior run 30143146258 logs (no failed jobs). I did not retrigger workflows from this cloud-agent run.

Copilot AI requested a review from gh-aw-bot July 25, 2026 04:20
@pelikhan
pelikhan merged commit 5a54eaa into main Jul 25, 2026
23 checks passed
@pelikhan
pelikhan deleted the copilot/add-stringsconcatloop-linter branch July 25, 2026 05:04
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.83.3

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[linter-miner] linter: add stringsconcatloop — detect string += concatenation inside loops

4 participants