Skip to content

Add review pattern lint analyzers - #23

Merged
Peyton-Spencer merged 5 commits into
mainfrom
codex/review-pattern-lints
Jul 9, 2026
Merged

Add review pattern lint analyzers#23
Peyton-Spencer merged 5 commits into
mainfrom
codex/review-pattern-lints

Conversation

@Peyton-Spencer

@Peyton-Spencer Peyton-Spencer commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Summary

  • expand stringbuilderlint so it covers supported primitive fmt.Sprintf calls with suggested fixes
  • keep straight string-concat reporting conservative: default cutoff is now 32 dynamic operands, based on benchmark data showing Go 1.26 optimizes smaller expression concats well
  • add suggested fixes that rewrite feasible cases into pre-grown strings.Builder expressions
  • add benchmark coverage for concat/Sprintf rewrites and lookup-table map-vs-switch behavior
  • remove uncheckederrlint from this PR after research found errcheck with check-blank: true is the better commodity-linter path

Stringbuilder Fix Shape

The generated fix uses an inline anonymous function so it can replace expressions in-place:

func() string {
    builderPart0 := name
    builderPart2 := strconv.FormatInt(int64(count), 10)
    var sb strings.Builder
    sb.Grow(len(builderPart0) + 1 + len(builderPart2))
    sb.WriteString(builderPart0)
    sb.WriteString("/")
    sb.WriteString(builderPart2)
    return sb.String()
}()

Dynamic operands are evaluated exactly once into generated locals before Grow/writes. Unsupported fmt.Sprintf formats and non-primitive arguments are skipped.

Cutoffs

  • string concat: report at 32 dynamic operands
  • primitive fmt.Sprintf: report when the builder output has at least two pieces, e.g. literal text plus a primitive conversion

Benchmark Sample

Apple M4 Pro, go test -run '^$' -bench ... -benchmem -count=3:

  • 3-part concat: concat 15.6-16.0 ns/op, builder 14.7-14.8 ns/op, both 1 alloc
  • 4-part concat: concat 17.4-18.0 ns/op, builder 16.8-17.0 ns/op, both 1 alloc
  • 5-part concat: about tied, concat 20.3-20.4 ns/op, builder 20.2-20.4 ns/op, both 1 alloc
  • 8-part concat: concat wins, 26.4-26.7 ns/op vs builder 29.7-30.0 ns/op
  • 16-part concat: concat wins, 46.9-47.4 ns/op vs builder 60.6-61.1 ns/op
  • 32-part concat: builder first wins locally, concat 91.0 ns/op vs builder 87.2-87.6 ns/op
  • fmt.Sprintf("id=%d", ...): 33-34 ns/op, 2 allocs; builder+strconv 18.7-19.2 ns/op, 2 allocs
  • fmt.Sprintf("%s/%d/%t", ...): 63.8-65.1 ns/op, 3 allocs; builder+strconv 25.3-25.4 ns/op, 2 allocs
  • lookup map for 2/4/8/16 entries: roughly 5-9 ns/op
  • switch predicate for 2/4/8/16 entries: roughly 1.6-1.9 ns/op

The benchmark files are committed so CI/devs can rerun on their machines.

Tests

  • PATH=/opt/homebrew/bin:$PATH go test ./... in stringbuilderlint
  • PATH=/opt/homebrew/bin:$PATH go test ./... in lookuptablelint
  • PATH=/opt/homebrew/bin:$PATH go test -run '^$' -bench 'Benchmark(Concat|Builder|Sprintf)' -benchmem -count=3 in stringbuilderlint
  • PATH=/opt/homebrew/bin:$PATH go test -run '^$' -bench 'Benchmark(Map|Switch)Lookup' -benchmem -count=3 in lookuptablelint

@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Peyton-Spencer, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a2b3ac80-9462-4374-895a-b3e1f4696593

📥 Commits

Reviewing files that changed from the base of the PR and between 7e865d9 and 74a03a5.

📒 Files selected for processing (7)
  • .github/workflows/go.yml
  • stringbuilderlint/analyzer.go
  • stringbuilderlint/benchmark_test.go
  • stringbuilderlint/testdata/src/a/a.go
  • stringbuilderlint/testdata/src/a/a.go.golden
  • stringbuilderlint/testdata/src/sprintf/sprintf.go
  • stringbuilderlint/testdata/src/sprintf/sprintf.go.golden
📝 Walkthrough

Walkthrough

Adds a lookuptablelint benchmark suite comparing map and switch-based lookups, plus a new stringbuilderlint Go analyzer package detecting inefficient string concatenation and fmt.Sprintf usage, complete with suggested fixes, suppression handling, module config, tests, benchmarks, and testdata fixtures.

Changes

Lookup Table Benchmark

Layer / File(s) Summary
Lookup benchmark suite
lookuptablelint/benchmark_test.go
Adds benchmarks comparing map-based lookups against switch-based lookups across keyset sizes 2, 4, 8, and 16.

stringbuilderlint Analyzer

Layer / File(s) Summary
Analyzer entry point and detection logic
stringbuilderlint/analyzer.go, stringbuilderlint/doc.go
Defines the Analyzer and run traversal, detects multi-part string concatenation and primitive fmt.Sprintf calls, and documents the package's purpose.
Format parsing and suggested-fix generation
stringbuilderlint/analyzer.go
Parses Sprintf format strings, maps primitive types to conversions, and generates strings.Builder/strconv rewrite code with Grow sizing.
Rewrite safety and import handling
stringbuilderlint/analyzer.go
Adds identifier collision avoidance, concat decomposition, parent-context checks, node rendering, and import-edit generation for suggested fixes.
Suppression and generated-file handling
stringbuilderlint/analyzer.go, stringbuilderlint/go.mod
Implements generated-file skipping, lint:ignore/lint:file-ignore directive parsing, comment normalization, and module dependency setup.
Analyzer tests and fixtures
stringbuilderlint/analyzer_test.go, stringbuilderlint/testdata/src/a/*, stringbuilderlint/testdata/src/fileignore/*, stringbuilderlint/testdata/src/sprintf/*
Adds TestAnalyzer running analysistest.RunWithSuggestedFixes against fixture packages covering concatenation, file-level suppression, and Sprintf cases with golden outputs.
Analyzer benchmarks
stringbuilderlint/benchmark_test.go
Adds benchmarks comparing direct concatenation and fmt.Sprintf against strings.Builder-based alternatives for 3/5-part strings and primitive formatting.

Estimated code review effort: 3 (Moderate) | ~30 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Pass as analysis.Pass
  participant Run as run(pass)
  participant CheckConcat
  participant CheckSprintf
  participant FixGen as SuggestedFix generator
  participant Report as pass.Report

  Pass->>Run: inspect AST nodes
  Run->>CheckConcat: visit BinaryExpr (+)
  CheckConcat->>CheckConcat: filter unsupported/suppressed cases
  CheckConcat->>FixGen: build strings.Builder rewrite
  FixGen->>Report: Diagnostic with SuggestedFix

  Run->>CheckSprintf: visit fmt.Sprintf CallExpr
  CheckSprintf->>CheckSprintf: parse format string, validate verbs
  CheckSprintf->>FixGen: build strconv/Builder rewrite
  FixGen->>Report: Diagnostic with SuggestedFix
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and accurately describes the PR’s main goal of adding review-pattern lint analyzers.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/review-pattern-lints

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Peyton-Spencer
Peyton-Spencer marked this pull request as ready for review July 9, 2026 19:57
Comment thread uncheckederrlint/testdata/src/a/a.go Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick comments (6)
stringbuilderlint/benchmark_test.go (1)

74-76: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Non-sequential variable names reduce readability.

part0, part2, part4 (skipping part1/part3) matches the analyzer's piece indexing where literal segments don't receive temp variables, but in standalone benchmark code this looks like a typo. Consider adding a brief comment explaining the gap, or renaming to sequential part0/part1/part2 since the benchmark doesn't need to match internal indexing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stringbuilderlint/benchmark_test.go` around lines 74 - 76, The benchmark
setup in benchmark_test.go uses non-sequential temp names in the string assembly
path, which reads like a typo. Update the variables used around the benchmark
input construction to either use sequential names or add a brief comment near
the relevant code to explain that the gaps mirror the analyzer’s piece indexing;
reference the benchmark setup around the
benchA/strconv.FormatInt/strconv.FormatBool assignments so it’s easy to locate.
stringbuilderlint/analyzer.go (4)

79-85: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Ignore check should precede stringConcatParts decomposition.

The suppression check at line 83 happens after stringConcatParts at line 79, meaning the decomposition work is wasted for ignored expressions. Moving the ignore check before the decomposition avoids unnecessary work.

♻️ Proposed refactor
 	if r.pass.TypesInfo.Types[expr].Value != nil && r.pass.TypesInfo.Types[expr].Value.Kind() == constant.String {
 		return
 	}
+	if r.ignores.ignored(expr.Pos()) {
+		return
+	}
 	parts := stringConcatParts(r.pass, expr)
 	if len(parts) <= 2 {
 		return
 	}
-	if r.ignores.ignored(expr.Pos()) {
-		return
-	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stringbuilderlint/analyzer.go` around lines 79 - 85, The ignore guard in the
analyzer currently runs after stringConcatParts, so ignored expressions still
get decomposed unnecessarily. In the logic around
stringbuilderlint/analyzer.go’s handling of expr, move the
r.ignores.ignored(expr.Pos()) check before calling stringConcatParts, then keep
the existing len(parts) validation and early return behavior unchanged. This
keeps the suppression path cheap while preserving the current analysis flow.

460-465: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Fallback to unformatted source on format.Source failure may produce invalid Go.

When format.Source fails at line 461, the raw buffer content is returned as-is (line 462). This unformatted code may not be syntactically valid Go if the template wrapping was the issue. Consider logging the formatting error or returning false to signal that no safe fix could be generated.

♻️ Proposed refactor
 	formatted, err := format.Source([]byte("package p\n\nvar _ = " + buf.String()))
 	if err != nil {
-		return buf.String(), true
+		// If formatting fails, the generated code is likely invalid;
+		// signal that no safe fix can be produced.
+		return "", false
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stringbuilderlint/analyzer.go` around lines 460 - 465, The fallback in the
formatting path can return raw, potentially invalid Go when format.Source fails.
Update the logic around the format.Source call in the analyzer function so a
formatting failure does not emit unsafe output: either return false to indicate
no fix can be generated, or record the formatting error and avoid using
buf.String() as a replacement. Keep the handling scoped to the code that trims
the prefix and returns the formatted string.

766-782: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

directiveApplies requires a reason text for lint:ignore directives.

The len(fields) < 3 check at line 769 means //lint:ignore stringbuilderlint (without a reason) is silently ignored. While requiring a justification is good practice, this behavior is undocumented and may surprise users who follow the common convention of bare ignore directives. Consider either documenting this requirement or relaxing the minimum to 2 fields.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stringbuilderlint/analyzer.go` around lines 766 - 782, The directiveApplies
helper currently rejects bare lint:ignore and lint:file-ignore directives
because it requires at least three fields from commentText(raw), so a directive
like lint:ignore stringbuilderlint is ignored. Update directiveApplies in
analyzer.go to either accept directives with only the kind and analyzer list, or
keep the reason requirement but document it clearly; use the existing
directiveApplies logic and strings.Fields/commentText parsing to make the
behavior consistent with the intended policy.

176-222: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

sprintfPieces doesn't reject format strings with zero arguments.

fmt.Sprintf("hello") (a constant format with no args) passes all checks: parseFormat returns one literal segment, argIndex (1) equals len(call.Args) (1), and the function returns a single-literal pieces slice. This generates a strings.Builder fix that wraps a constant string in an IIFE — an unnecessary transformation that degrades readability with no performance benefit. Consider requiring at least one non-literal segment.

♻️ Proposed fix
 	if argIndex != len(call.Args) {
 		return nil, false
 	}
+	hasExpr := false
+	for _, piece := range pieces {
+		if piece.expr != nil {
+			hasExpr = true
+			break
+		}
+	}
+	if !hasExpr {
+		return nil, false
+	}
 	return pieces, true
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stringbuilderlint/analyzer.go` around lines 176 - 222, The sprintfPieces
helper currently treats constant-only format strings like a valid rewrite
target, which causes pointless builder transformations for calls such as
fmt.Sprintf with no dynamic arguments. Update reporter.sprintfPieces to reject
cases where parseFormat returns only literal segments, and only return true when
there is at least one non-literal conversion piece to rewrite. Keep the check
localized in sprintfPieces so the existing callers in analyzer.go continue to
use the same path without changing other behavior.
stringbuilderlint/analyzer_test.go (1)

9-11: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding unit tests for parseFormat and primitiveConversion.

The integration test via analysistest covers end-to-end behavior but doesn't isolate edge cases in format parsing (e.g., trailing %, empty format, %% only, precision boundaries). Table-driven unit tests for these functions would catch regressions more precisely and document expected behavior for unsupported formats.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@stringbuilderlint/analyzer_test.go` around lines 9 - 11, Add focused unit
tests for parseFormat and primitiveConversion in addition to TestAnalyzer, since
analysistest only covers end-to-end behavior. Create table-driven tests that
exercise edge cases like trailing %, empty format strings, %% only, and
precision boundary cases, and assert the expected handling of unsupported
formats. Use the parseFormat and primitiveConversion symbols directly so the
tests document their standalone behavior and catch regressions more precisely.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@stringbuilderlint/analyzer_test.go`:
- Around line 9-11: Add focused unit tests for parseFormat and
primitiveConversion in addition to TestAnalyzer, since analysistest only covers
end-to-end behavior. Create table-driven tests that exercise edge cases like
trailing %, empty format strings, %% only, and precision boundary cases, and
assert the expected handling of unsupported formats. Use the parseFormat and
primitiveConversion symbols directly so the tests document their standalone
behavior and catch regressions more precisely.

In `@stringbuilderlint/analyzer.go`:
- Around line 79-85: The ignore guard in the analyzer currently runs after
stringConcatParts, so ignored expressions still get decomposed unnecessarily. In
the logic around stringbuilderlint/analyzer.go’s handling of expr, move the
r.ignores.ignored(expr.Pos()) check before calling stringConcatParts, then keep
the existing len(parts) validation and early return behavior unchanged. This
keeps the suppression path cheap while preserving the current analysis flow.
- Around line 460-465: The fallback in the formatting path can return raw,
potentially invalid Go when format.Source fails. Update the logic around the
format.Source call in the analyzer function so a formatting failure does not
emit unsafe output: either return false to indicate no fix can be generated, or
record the formatting error and avoid using buf.String() as a replacement. Keep
the handling scoped to the code that trims the prefix and returns the formatted
string.
- Around line 766-782: The directiveApplies helper currently rejects bare
lint:ignore and lint:file-ignore directives because it requires at least three
fields from commentText(raw), so a directive like lint:ignore stringbuilderlint
is ignored. Update directiveApplies in analyzer.go to either accept directives
with only the kind and analyzer list, or keep the reason requirement but
document it clearly; use the existing directiveApplies logic and
strings.Fields/commentText parsing to make the behavior consistent with the
intended policy.
- Around line 176-222: The sprintfPieces helper currently treats constant-only
format strings like a valid rewrite target, which causes pointless builder
transformations for calls such as fmt.Sprintf with no dynamic arguments. Update
reporter.sprintfPieces to reject cases where parseFormat returns only literal
segments, and only return true when there is at least one non-literal conversion
piece to rewrite. Keep the check localized in sprintfPieces so the existing
callers in analyzer.go continue to use the same path without changing other
behavior.

In `@stringbuilderlint/benchmark_test.go`:
- Around line 74-76: The benchmark setup in benchmark_test.go uses
non-sequential temp names in the string assembly path, which reads like a typo.
Update the variables used around the benchmark input construction to either use
sequential names or add a brief comment near the relevant code to explain that
the gaps mirror the analyzer’s piece indexing; reference the benchmark setup
around the benchA/strconv.FormatInt/strconv.FormatBool assignments so it’s easy
to locate.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 7b7cc965-de05-4aca-a797-925297ccd5ec

📥 Commits

Reviewing files that changed from the base of the PR and between 6142078 and 7e865d9.

⛔ Files ignored due to path filters (2)
  • go.work is excluded by !**/*.work
  • stringbuilderlint/go.sum is excluded by !**/*.sum
📒 Files selected for processing (11)
  • lookuptablelint/benchmark_test.go
  • stringbuilderlint/analyzer.go
  • stringbuilderlint/analyzer_test.go
  • stringbuilderlint/benchmark_test.go
  • stringbuilderlint/doc.go
  • stringbuilderlint/go.mod
  • stringbuilderlint/testdata/src/a/a.go
  • stringbuilderlint/testdata/src/a/a.go.golden
  • stringbuilderlint/testdata/src/fileignore/fileignore.go
  • stringbuilderlint/testdata/src/sprintf/sprintf.go
  • stringbuilderlint/testdata/src/sprintf/sprintf.go.golden

@Peyton-Spencer
Peyton-Spencer force-pushed the codex/review-pattern-lints branch from 286be32 to 74a03a5 Compare July 9, 2026 21:11
@Peyton-Spencer
Peyton-Spencer merged commit 4e0881e into main Jul 9, 2026
8 checks passed
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.

1 participant