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
2 changes: 2 additions & 0 deletions cmd/linters/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"golang.org/x/tools/go/analysis/multichecker"

"github.com/github/gh-aw/pkg/linters/appendbytestring"
"github.com/github/gh-aw/pkg/linters/bytescomparestring"
"github.com/github/gh-aw/pkg/linters/contextcancelnotdeferred"
"github.com/github/gh-aw/pkg/linters/ctxbackground"
"github.com/github/gh-aw/pkg/linters/deferinloop"
Expand Down Expand Up @@ -64,6 +65,7 @@ import (
func main() {
multichecker.Main(
appendbytestring.Analyzer,
bytescomparestring.Analyzer,
contextcancelnotdeferred.Analyzer,
Comment on lines 66 to 69
ctxbackground.Analyzer,
deferinloop.Analyzer,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-44389: Add `bytescomparestring` Linter to Flag Allocating `[]byte` Equality Checks

**Date**: 2026-07-08
**Status**: Accepted
**Deciders**: Unknown (automated PR by Linter Miner)

---

### Context

Comparing two `[]byte` values via `string(a) == string(b)` or `string(a) != string(b)` compiles successfully but causes two unnecessary heap allocations per comparison — one for each `string()` conversion. The allocation-free idiomatic alternative is `bytes.Equal(a, b)` and `!bytes.Equal(a, b)`. A code-pattern scan found 3 live instances of this pattern in the repo (`pkg/parser/import_bfs.go:566`, `pkg/parser/tools_merger.go:279`, `pkg/cli/packages.go:146`). The existing custom linter suite (`pkg/linters/`) is the established mechanism for enforcing repo-wide Go idioms at compile time with automatic suggested fixes.

### Decision

We will add a new `go/analysis` linter named `bytescomparestring` that detects `string(a) == string(b)` and `string(a) != string(b)` expressions where both `a` and `b` have underlying type `[]byte`, reports a diagnostic, and emits a `SuggestedFix` that rewrites the expression to `bytes.Equal(a, b)` or `!bytes.Equal(a, b)`. The linter is registered in `cmd/linters/main.go` and documented in `pkg/linters/doc.go`. False-positive rate is zero: the linter only fires when both sides are `string(x)` type-conversions with a `[]byte` argument.

### Alternatives Considered

#### Alternative 1: Rely on an External Tool (staticcheck / golangci-lint)

Staticcheck's `SA4010` and similar rules cover some allocation patterns, but none specifically target the `string([]byte) == string([]byte)` form with auto-fix support. Adopting an external tool for this single pattern would add a build-tool dependency and complicate CI configuration, while the project already maintains a bespoke linter framework with consistent nolint directives, file-skip logic, and suggested-fix plumbing. Not chosen because the marginal benefit of a third-party tool does not justify adding a dependency.

#### Alternative 2: Use `slices.Equal` or `reflect.DeepEqual`

`slices.Equal` (Go 1.21+) and `reflect.DeepEqual` are functionally correct alternatives to `bytes.Equal` for `[]byte` comparisons. However, `reflect.DeepEqual` is significantly slower due to reflection overhead, and `slices.Equal` is a generic function that is less recognizable in idiomatic byte-handling code than `bytes.Equal`. The Go standard library convention for byte-slice equality is `bytes.Equal`; the linter's suggested fix should produce idiomatic output.

### Consequences

#### Positive
- Eliminates two unnecessary heap allocations per `[]byte` equality comparison at the 3 known sites and any future occurrences.
- Provides an auto-applicable `SuggestedFix` compatible with `gopls` and `go fix`, reducing friction to fix violations.
- Zero false-positive rate: the linter's type-aware check (`pass.TypesInfo`) ensures it only fires when both sides are `string()` conversions of `[]byte` arguments.
- Consistent enforcement: future `string(a) == string(b)` regressions are caught at lint time rather than discovered in code review.

#### Negative
- Adds a new linter to maintain: API-breaking changes in `golang.org/x/tools/go/analysis` would require updating this analyzer alongside the 43 others.

#### Neutral
- The linter skips test files (`filecheck.IsTestFile`) consistent with the project's convention; `string(a) == string(b)` patterns in test code are not flagged.
- The linter count in `pkg/linters/doc.go` increases from 43 to 44; this counter must be updated whenever analyzers are added or removed.

---

*ADR finalized: implementation complete, all known violations fixed, and suggested fixes include the `bytes` import when not already present.*
Binary file modified linters
Binary file not shown.
3 changes: 2 additions & 1 deletion pkg/cli/packages.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bufio"
"bytes"
"fmt"
"os"
"path/filepath"
Expand Down Expand Up @@ -143,7 +144,7 @@ func copyIncludeDependenciesFromPackageWithForce(dependencies []IncludeDependenc
if existingContent, err := os.ReadFile(targetPath); err == nil {
fileExists = true
// File exists, compare contents
if string(existingContent) == string(sourceContent) {
if bytes.Equal(existingContent, sourceContent) {
// Contents are the same, skip
if verbose {
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Include file %s already exists with same content, skipping", dep.TargetPath)))
Expand Down
205 changes: 205 additions & 0 deletions pkg/linters/bytescomparestring/bytescomparestring.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
// Package bytescomparestring implements a Go analysis linter that flags
// string(a) == string(b) and string(a) != string(b) comparisons where both
// a and b are []byte values, which should use bytes.Equal(a, b) instead to
// avoid unnecessary allocations.
package bytescomparestring

import (
"fmt"
"go/ast"
"go/token"
"go/types"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"

"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"
)

const bytesPkg = "bytes"

// Analyzer is the bytes-compare-string analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "bytescomparestring",
Doc: "reports string(a) == string(b) and string(a) != string(b) comparisons where a and b are []byte values that should use bytes.Equal instead",
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/bytescomparestring",
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, "bytescomparestring")

nodeFilter := []ast.Node{
(*ast.BinaryExpr)(nil),
}

insp.Preorder(nodeFilter, func(n ast.Node) {
bin, ok := n.(*ast.BinaryExpr)
if !ok {
return
}

// Only flag == and != operators.
if bin.Op != token.EQL && bin.Op != token.NEQ {
return
}

pos := pass.Fset.PositionFor(bin.Pos(), false)
if filecheck.IsTestFile(pos.Filename) {
return
}
if nolint.HasDirective(pos, noLintLinesByFile) {
return
}

// Both sides must be string(x) conversions where x is []byte.
lhsArg, ok := extractByteSliceStringConv(pass, bin.X)
if !ok {
return
}
rhsArg, ok := extractByteSliceStringConv(pass, bin.Y)
if !ok {
return
}

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] Missing edge case: what happens when either lText or rText is empty (line 69 guard returns early) — the diagnostic is silently suppressed. Add a test fixture with a complex expression like string(f()) or string(buf.Bytes()) to verify the linter fires even when astutil.NodeText might produce multi-token text, and confirm the early-return is never reachable for valid Go ASTs.\n\n@copilot please address this.

lText := astutil.NodeText(pass.Fset, lhsArg)
rText := astutil.NodeText(pass.Fset, rhsArg)
if lText == "" || rText == "" {
return
}

op := bin.Op.String()

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.

[/codebase-design] The if/else on bin.Op (lines 77–105) duplicates the analysis.Diagnostic structure; both branches differ only in message template and replacement text prefix. Extracting a small helper reduces the divergence surface.

💡 Suggested refactor

Extract a helper that accepts the op-specific strings and returns analysis.Diagnostic, then call it from both branches. This mirrors how appendbytestring keeps its reporting in a single buildFix function.

@copilot please address this.

if bin.Op == token.EQL {
pass.Report(analysis.Diagnostic{
Pos: bin.Pos(),
End: bin.End(),
Message: fmt.Sprintf("string(%s) == string(%s) allocates; use bytes.Equal(%s, %s) instead", lText, rText, lText, rText),
SuggestedFixes: buildFix(pass, bin, fmt.Sprintf("bytes.Equal(%s, %s)", lText, rText)),
})
} else {
pass.Report(analysis.Diagnostic{
Pos: bin.Pos(),
End: bin.End(),
Message: fmt.Sprintf("string(%s) %s string(%s) allocates; use !bytes.Equal(%s, %s) instead", lText, op, rText, lText, rText),
SuggestedFixes: buildFix(pass, bin, fmt.Sprintf("!bytes.Equal(%s, %s)", lText, rText)),
})
}
})

return nil, nil
}

// buildFix returns the SuggestedFix for rewriting bin to replacement, adding a
// "bytes" import TextEdit when the file containing bin does not yet import it.
func buildFix(pass *analysis.Pass, bin *ast.BinaryExpr, replacement string) []analysis.SuggestedFix {
edits := []analysis.TextEdit{{
Pos: bin.Pos(),
End: bin.End(),
NewText: []byte(replacement),
}}
if importEdit, ok := addBytesImportEdit(pass, bin.Pos()); ok {
edits = append(edits, importEdit)
}
return []analysis.SuggestedFix{{
Message: "Replace with " + replacement,
TextEdits: edits,
}}
}

// addBytesImportEdit returns a TextEdit that inserts an import for "bytes" into
// the file containing pos, unless "bytes" is already imported in that file.
// Returns (TextEdit{}, false) when no edit is needed.
func addBytesImportEdit(pass *analysis.Pass, pos token.Pos) (analysis.TextEdit, bool) {
var file *ast.File
for _, f := range pass.Files {
if f.Pos() <= pos && pos <= f.End() {
file = f
break
}
}
if file == nil {
return analysis.TextEdit{}, false
}

// Check if "bytes" is already imported in this file.
for _, imp := range file.Imports {
if imp.Path.Value == `"`+bytesPkg+`"` {
return analysis.TextEdit{}, false
}
}

// Find an existing grouped import declaration to add into.
for _, decl := range file.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.IMPORT || !genDecl.Lparen.IsValid() {
continue
}
// Insert "bytes" before the closing paren of the import block.
return analysis.TextEdit{
Pos: genDecl.Rparen,
End: genDecl.Rparen,
NewText: []byte("\t\"" + bytesPkg + "\"\n"),
}, true
}

// No grouped import block; insert a standalone import after the package name.
return analysis.TextEdit{
Pos: file.Name.End(),
End: file.Name.End(),
NewText: []byte("\n\nimport \"" + bytesPkg + "\""),
}, true
}

// extractByteSliceStringConv checks whether expr is a string(x) conversion
// where x has underlying type []byte. If so, it returns x and true.
func extractByteSliceStringConv(pass *analysis.Pass, expr ast.Expr) (ast.Expr, bool) {

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.

SuggestedFix emits bytes.Equal(...) without adding the "bytes" import, producing uncompilable code on every real violation in this repo.

💡 Details and fix

The TextEdit replaces the binary expression with bytes.Equal(a, b) or !bytes.Equal(a, b), but never inserts an import "bytes" declaration. Applying the fix to any file that does not already import "bytes" results in a compile error (undefined: bytes).

All three live callsites identified in the PR description have been verified to lack the "bytes" import:

  • pkg/parser/import_bfs.go — imports encoding/json, errors, fmt, maps, path, path/filepath, stringsno "bytes"
  • pkg/parser/tools_merger.go — imports encoding/json, fmt, maps, stringsno "bytes"
  • pkg/cli/packages.go — imports bufio, fmt, os, path/filepath, regexp, stringsno "bytes"

So every suggested fix the linter would propose in this codebase is broken out of the box.

Fix: The analysis.SuggestedFix can include multiple TextEdit entries. Add a second edit that inserts "bytes" into the file's import block. Use golang.org/x/tools/go/ast/astutil.AddImport to locate the right position and generate the edit, or locate the end of the existing import (...) block and inject \t\"bytes\"\n there.

Note: the appendbytestring peer linter avoids this entirely because its fix removes a conversion rather than adding a new package reference.

call, ok := expr.(*ast.CallExpr)
if !ok || len(call.Args) != 1 {
return nil, false

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.

Blocking: The SuggestedFix rewrites the expression to bytes.Equal(...) but does not add a "bytes" import if the file doesn't already have one. All 3 real-world instances flagged in the PR description (import_bfs.go, tools_merger.go, packages.go) do not import "bytes", so applying this fix via go fix would produce non-compiling code.

The test fixture passes only because it pre-imports "bytes", masking this gap.

Either:

  1. Add a second TextEdit that inserts the "bytes" import (use golang.org/x/tools/go/ast/astutil.AddImport to get the correct position), or
  2. Add a test case where bytes is not pre-imported to confirm analysistest.RunWithSuggestedFixes works end-to-end.

Without this, go fix ./... will break compilation for the very instances this linter flags.

@copilot please address this.

}

// Must be a type conversion, not a function call.
funInfo, ok := pass.TypesInfo.Types[call.Fun]
if !ok || !funInfo.IsType() {
return nil, false
}

// The outer conversion must produce a string.
resultInfo, ok := pass.TypesInfo.Types[call]
if !ok {
return nil, false
}
basic, ok := resultInfo.Type.Underlying().(*types.Basic)
if !ok || basic.Kind() != types.String {
return nil, false
}

// The argument must be []byte (or []uint8).
arg := call.Args[0]
if !isByteSlice(pass, arg) {
return nil, false
}

return arg, true
}

// isByteSlice reports whether expr has underlying type []byte ([]uint8).
func isByteSlice(pass *analysis.Pass, expr ast.Expr) bool {
t := pass.TypesInfo.TypeOf(expr)
if t == nil {
return false
}
sl, ok := t.Underlying().(*types.Slice)
if !ok {
return false
}
elem, ok := sl.Elem().(*types.Basic)
return ok && elem.Kind() == types.Byte
}
16 changes: 16 additions & 0 deletions pkg/linters/bytescomparestring/bytescomparestring_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !integration

package bytescomparestring_test

import (
"testing"

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

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

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

import "bytes"

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.

Test fixture masks the missing-import bug: it already imports "bytes", so RunWithSuggestedFixes never exercises the broken path.

💡 Details and suggested test

The testdata file imports "bytes" on line 3 solely because goodBytesEqual uses bytes.Equal. This means analysistest.RunWithSuggestedFixes applies the generated TextEdits to a file that already has the import, and the golden-file comparison passes even though the linter never emits an import-adding edit.

There is no test for the real-world scenario where the file with the bad pattern has no pre-existing "bytes" import — which is true of all three live violations in this repo.

Suggested addition: add a second fixture package (e.g. testdata/src/noimport/noimport.go) that has the bad pattern but no "bytes" import:

package noimport

func bad(a, b []byte) bool {
    return string(a) == string(b) // want `string\(a\) == string\(b\) allocates; use bytes\.Equal\(a, b\) instead`
}

And a corresponding .golden file that adds the "bytes" import:

package noimport

import "bytes"

func bad(a, b []byte) bool {
    return bytes.Equal(a, b) // want `string\(a\) == string\(b\) allocates; use bytes\.Equal\(a, b\) instead`
}

This test will fail until the import-insertion TextEdit is added to the SuggestedFix, enforcing the fix is correct end-to-end.


func badEqual(a, b []byte) bool {
return string(a) == string(b) // want `string\(a\) == string\(b\) allocates; use bytes\.Equal\(a, b\) instead`
}

func badNotEqual(a, b []byte) bool {
return string(a) != string(b) // want `string\(a\) != string\(b\) allocates; use !bytes\.Equal\(a, b\) instead`
}

type myBytes []byte

func badNamedType(a, b myBytes) bool {
return string(a) == string(b) // want `string\(a\) == string\(b\) allocates; use bytes\.Equal\(a, b\) instead`
}

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] The test covers named types (myBytes) for the == case but not for !=. A badNamedTypeNotEqual case would close that gap and confirm the operator-dispatch logic is symmetric for non-[]byte underlying types.\n\n@copilot please address this.


func goodBytesEqual(a, b []byte) bool {
// Correct usage — no diagnostic expected.
return bytes.Equal(a, b)
}

func goodStringLiteral(a []byte) bool {
// Only one side is string([]byte); not flagged.
return string(a) == "hello"
}

func goodStringVars(a, b string) bool {
// Neither side is a []byte conversion; not flagged.
return a == b
}

func goodMixedOneSideString(a []byte, b string) bool {
// One side is a string variable, not string([]byte); not flagged.
return string(a) == b
}

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] Missing negative test: string(a) == string(b) where one argument is []uint8 and the other is a named []byte alias. The isByteSlice helper accepts both because it checks types.Byte, but having a fixture case makes that guarantee explicit and prevents future regressions.\n\n@copilot please address this.

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package bytescomparestring

import "bytes"

func badEqual(a, b []byte) bool {
return bytes.Equal(a, b) // want `string\(a\) == string\(b\) allocates; use bytes\.Equal\(a, b\) instead`
}

func badNotEqual(a, b []byte) bool {
return !bytes.Equal(a, b) // want `string\(a\) != string\(b\) allocates; use !bytes\.Equal\(a, b\) instead`
}

type myBytes []byte

func badNamedType(a, b myBytes) bool {
return bytes.Equal(a, b) // want `string\(a\) == string\(b\) allocates; use bytes\.Equal\(a, b\) instead`
}

func goodBytesEqual(a, b []byte) bool {
// Correct usage — no diagnostic expected.
return bytes.Equal(a, b)
}

func goodStringLiteral(a []byte) bool {
// Only one side is string([]byte); not flagged.
return string(a) == "hello"
}

func goodStringVars(a, b string) bool {
// Neither side is a []byte conversion; not flagged.
return a == b
}

func goodMixedOneSideString(a []byte, b string) bool {
// One side is a string variable, not string([]byte); not flagged.
return string(a) == b
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package bytescomparestring

func noImportEqual(a, b []byte) bool {
return string(a) == string(b) // want `string\(a\) == string\(b\) allocates; use bytes\.Equal\(a, b\) instead`
}

func noImportNotEqual(a, b []byte) bool {
return string(a) != string(b) // want `string\(a\) != string\(b\) allocates; use !bytes\.Equal\(a, b\) instead`
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package bytescomparestring

import "bytes"

func noImportEqual(a, b []byte) bool {
return bytes.Equal(a, b) // want `string\(a\) == string\(b\) allocates; use bytes\.Equal\(a, b\) instead`
}

func noImportNotEqual(a, b []byte) bool {
return !bytes.Equal(a, b) // want `string\(a\) != string\(b\) allocates; use !bytes\.Equal\(a, b\) instead`
}
Loading
Loading