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 @@ -40,6 +40,7 @@ import (
"github.com/github/gh-aw/pkg/linters/seenmapbool"
"github.com/github/gh-aw/pkg/linters/sortslice"
"github.com/github/gh-aw/pkg/linters/sprintferrdot"
"github.com/github/gh-aw/pkg/linters/sprintferrorsnew"
"github.com/github/gh-aw/pkg/linters/ssljson"
"github.com/github/gh-aw/pkg/linters/strconvparseignorederror"
"github.com/github/gh-aw/pkg/linters/timeafterleak"
Expand Down Expand Up @@ -73,6 +74,7 @@ func main() {
seenmapbool.Analyzer,
sortslice.Analyzer,
sprintferrdot.Analyzer,
sprintferrorsnew.Analyzer,
strconvparseignorederror.Analyzer,
jsonmarshalignoredeerror.Analyzer,
lenstringzero.Analyzer,
Expand Down
39 changes: 39 additions & 0 deletions docs/adr/40490-add-sprintferrorsnew-linter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# ADR-40490: Add `sprintferrorsnew` linter for `errors.New(fmt.Sprintf(...))`

**Date**: 2026-06-20
**Status**: Draft

## Context

The codebase contains a custom `go/analysis` linter suite (`pkg/linters/*`, registered in `cmd/linters/main.go`) used to enforce house style and catch anti-patterns that off-the-shelf golangci-lint rules miss. A recurring pattern was found in `pkg/workflow` where error values are built with `errors.New(fmt.Sprintf(...))` instead of the idiomatic `fmt.Errorf(...)`. The inline form is longer, allocates an intermediate string on the heap, and cannot use `%w` for error wrapping. Existing enabled rules (`errcheck`, `errorlint`, `nilerr`) do not detect it. The constraint is that any new rule must integrate with the established analyzer framework and keep false positives low.

## Decision

We will add a new `go/analysis` analyzer package `pkg/linters/sprintferrorsnew` and register it in `cmd/linters/main.go`. The analyzer flags only the **inline** form `errors.New(fmt.Sprintf(...))` — a single-argument `errors.New` call whose sole argument is a direct `fmt.Sprintf` call — using AST inspection plus the shared `astutil.IsPkgSelector` helper to confirm package identity. Test files are skipped via `filecheck.IsTestFile`, and the variable-assignment form (`msg := fmt.Sprintf(...); errors.New(msg)`) is deliberately not flagged to preserve a high signal-to-noise ratio.

## Alternatives Considered

### Alternative 1: Extend an existing golangci-lint rule / use an off-the-shelf linter
No commonly enabled rule (`errcheck`, `errorlint`, `nilerr`, etc.) detects this pattern, and adding a third-party dependency or upstream change is heavier and slower than a focused local analyzer that fits the existing `pkg/linters` framework. Rejected as higher cost with less control.

### Alternative 2: Also flag the variable-assignment form
The analyzer could trace `errors.New(msg)` back to a nearby `msg := fmt.Sprintf(...)`. This requires dataflow tracking, increases false-positive risk (the variable may be reused or conditionally assigned), and adds complexity. Rejected to keep the rule simple and high-precision; only the unambiguous inline form is flagged.

## Consequences

### Positive
- Catches a real, repeated anti-pattern (4 instances in `pkg/workflow`) that existing linters miss.
- Encourages `fmt.Errorf`, enabling `%w` wrapping and avoiding a redundant allocation.
- Follows the established analyzer pattern, so it is consistent with the rest of the suite and covered by `analysistest`-based tests.

### Negative
- Adds another analyzer to maintain and run, marginally increasing lint time and the registration surface in `cmd/linters/main.go`.
- The deliberate scope limit means the variable-assignment form goes undetected, so some occurrences of the anti-pattern remain unflagged.

### Neutral
- Only the inline form is reported; authors who prefer the assignment form for readability are unaffected.
- The rule is purely diagnostic (`pass.Reportf`); it offers no automated fix/suggested edit.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27880137982) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
73 changes: 73 additions & 0 deletions pkg/linters/sprintferrorsnew/sprintferrorsnew.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Package sprintferrorsnew implements a Go analysis linter that flags
// errors.New(fmt.Sprintf(...)) calls that should use fmt.Errorf instead.
package sprintferrorsnew

import (
"go/ast"

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

// Analyzer is the sprintferrorsnew analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "sprintferrorsnew",
Doc: "reports errors.New(fmt.Sprintf(...)) calls that should use fmt.Errorf instead",
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/sprintferrorsnew",
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
}

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

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

if filecheck.IsTestFile(pass.Fset.Position(call.Pos()).Filename) {

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 filecheck.IsTestFile guard is a behavioral contract — but there is no fixture confirming that *_test.go files are actually exempt. A regression in IsTestFile would silently change the linter's scope.

💡 Suggested fixture addition

Add testdata/src/sprintferrorsnew/sprintferrorsnew_test.go containing the bad pattern without a // want annotation:

package sprintferrorsnew_test

import (
	"errors"
	"fmt"
	"testing"
)

func TestExample(t *testing.T) {
	// No diagnostic expected — test files are exempt.
	_ = errors.New(fmt.Sprintf("test value: %s", "x"))
}

analysistest treats the absence of a // want annotation on a flagged line as a test failure, so this fixture would lock in the exemption behaviour automatically.

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.

Adjusted filename used for test-file exclusion: pass.Fset.Position() follows //line directives and could misidentify whether a file is a test file.

💡 Details

The majority of linters in this codebase — including sprintferrdot, the nearest sibling — use pass.Fset.PositionFor(call.Pos(), false) (unadjusted) when feeding the filename to filecheck.IsTestFile. pass.Fset.Position() is equivalent to PositionFor(pos, true) and follows //line directives.

With adjusted positions:

  • A non-test file containing a //line ..._test.go:N directive would make the linter silently skip all errors.New(fmt.Sprintf(...)) calls in that file (false negatives).
  • A test file with a //line ...non_test.go:N directive would be analyzed when it should be excluded (false positives in generated code).

Generated code frequently uses //line directives to point back to source templates, so this is a realistic edge case.

Suggested fix:

-		if filecheck.IsTestFile(pass.Fset.Position(call.Pos()).Filename) {
+		if filecheck.IsTestFile(pass.Fset.PositionFor(call.Pos(), false).Filename) {

return
}

// Match errors.New(...)
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "New" {
return
}
if !astutil.IsPkgSelector(pass, sel, "errors") {
return
}
if len(call.Args) != 1 {
return
}

// Check if the sole argument is a direct fmt.Sprintf(...) call.
argCall, ok := call.Args[0].(*ast.CallExpr)
if !ok {
return
}
Comment on lines +56 to +60
argSel, ok := argCall.Fun.(*ast.SelectorExpr)
if !ok || argSel.Sel.Name != "Sprintf" {
return
}
if !astutil.IsPkgSelector(pass, argSel, "fmt") {
return
}

pass.Reportf(call.Pos(), "use fmt.Errorf instead of errors.New(fmt.Sprintf(...))")

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] This transformation is fully mechanical — errors.New(fmt.Sprintf(args...))fmt.Errorf(args...) — making it an ideal candidate for a SuggestedFix. Without one, developers must manually refactor every flagged site.

💡 Adding a SuggestedFix + richer message

Replace pass.Reportf(...) with pass.Report(analysis.Diagnostic{...}) and supply a SuggestedFixes slice. The fix needs to:

  1. Replace the entire errors.New(fmt.Sprintf(...)) expression with fmt.Errorf(...) reusing the same arguments.
  2. Remove the "errors" import if it becomes unused (the analysisutil.RemoveImport helper covers this).

Also consider enriching the message to surface the %w benefit — the PR body highlights this as a motivating reason but the emitted diagnostic omits it:

pass.Reportf(call.Pos(), "use fmt.Errorf instead of errors.New(fmt.Sprintf(...)); fmt.Errorf also supports %%w for error wrapping")

Sibling linter sprintferrdot is a good reference for how auto-fix is wired in this package.

})

return nil, nil
}
17 changes: 17 additions & 0 deletions pkg/linters/sprintferrorsnew/sprintferrorsnew_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//go:build !integration

// Package sprintferrorsnew_test provides tests for the sprintferrorsnew analyzer.
package sprintferrorsnew_test

import (
"testing"

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

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

func TestSprintfErrorsNew(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, sprintferrorsnew.Analyzer, "sprintferrorsnew")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Package sprintferrorsnew is the test fixture for the sprintferrorsnew analyzer.
package sprintferrorsnew

import (
"errors"
"fmt"
)

// bad demonstrates the flagged pattern: errors.New wrapping a fmt.Sprintf call.
func bad() error {
return errors.New(fmt.Sprintf("invalid engine: %s", "claude")) // want `use fmt\.Errorf instead of errors\.New\(fmt\.Sprintf\(\.\.\.\)\)`

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.

Missing test case: errors.New(fmt.Sprintf("static")) — no variadic args: the linter will flag this but the suggested message is slightly misleading for this shape.

💡 Details

The test fixture covers two bad cases, both with at least one format argument. There is no case for:

return errors.New(fmt.Sprintf("something went wrong"))  // no variadic args

The linter will correctly flag this (it matches errors.New(<fmt.Sprintf call>)), but the diagnostic "use fmt.Errorf instead of errors.New(fmt.Sprintf(...))" is subtly misleading: when fmt.Sprintf has no format arguments, the best fix is errors.New("something went wrong") — eliminating fmt.Sprintf entirely — not fmt.Errorf("something went wrong").

Two options to address this:

  1. Add a test fixture case to explicitly document the current behavior and detect any future regression.
  2. Detect the zero-variadic-arg case in the analyzer and emit a different message (e.g., "use errors.New("...") directly — fmt.Sprintf with no format arguments is a no-op").

Option 1 is lower cost; option 2 makes the fix more actionable for users.

}

// badWithMultipleArgs demonstrates the flagged pattern with multiple format args.
func badWithMultipleArgs() error {

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] badWithMultipleArgs exercises the same AST path as bad — the analyzer never inspects how many arguments are passed to fmt.Sprintf, so varying the argument count adds no coverage.

💡 More distinctive edge cases

Consider replacing this with a case that actually exercises a different branch, such as a dynamic format string:

// badDynamicFormat: format string is a variable, not a literal — should still be flagged.
func badDynamicFormat(format string) error {
	return errors.New(fmt.Sprintf(format)) // want `use fmt\.Errorf instead of errors\.New\(fmt\.Sprintf\(\.\.\.\)\)`
}

This makes explicit that the linter fires on any fmt.Sprintf call as the sole argument, regardless of the inner argument types, and gives the fixture clearer specification value.

return errors.New(fmt.Sprintf("invalid value %q for flag %s", "x", "-n")) // want `use fmt\.Errorf instead of errors\.New\(fmt\.Sprintf\(\.\.\.\)\)`
}

// goodErrorf uses fmt.Errorf directly — no diagnostic expected.
func goodErrorf() error {
return fmt.Errorf("invalid engine: %s", "claude")
}

// goodVariable uses a pre-built string variable — not flagged by this linter.
func goodVariable() error {
msg := fmt.Sprintf("invalid engine: %s", "claude")
return errors.New(msg)
}

// goodPlainString uses a string literal — no diagnostic expected.
func goodPlainString() error {
return errors.New("something went wrong")
}
Loading