Skip to content

[linter-miner] linter: add sprintferrorsnew — flag errors.New(fmt.Sprintf(...))#40490

Merged
pelikhan merged 2 commits into
mainfrom
linter-miner/sprintferrorsnew-de7948de12c6577b
Jun 20, 2026
Merged

[linter-miner] linter: add sprintferrorsnew — flag errors.New(fmt.Sprintf(...))#40490
pelikhan merged 2 commits into
mainfrom
linter-miner/sprintferrorsnew-de7948de12c6577b

Conversation

@github-actions

@github-actions github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a new sprintferrorsnew Go static-analysis linter that flags the anti-pattern errors.New(fmt.Sprintf(...)) and recommends using fmt.Errorf instead. The linter is fully wired into the analyzer registry, covered by an analysistest-based unit test, and documented with a draft ADR.


What changed

New: sprintferrorsnew analyzer

pkg/linters/sprintferrorsnew/sprintferrorsnew.go (added, high impact)
Implements a go/analysis pass that detects call expressions of the form
errors.New(fmt.Sprintf(...)) and emits a diagnostic recommending fmt.Errorf
instead. Non-flagged patterns (bare string literals, pre-formatted variables,
existing fmt.Errorf calls) are explicitly left untouched.

Registration

cmd/linters/main.go (modified, medium impact)
Imports the new package and appends the sprintferrorsnew analyzer to the
main analyzer slice, making it active in all linter runs.

Tests & fixtures

pkg/linters/sprintferrorsnew/sprintferrorsnew_test.go (added, low impact)
Thin analysistest driver that runs the analyzer against the fixture package
and asserts expected diagnostics.

pkg/linters/sprintferrorsnew/testdata/src/sprintferrorsnew/sprintferrorsnew.go (added, low impact)
Annotated fixture covering:

  • ✅ Flagged: errors.New(fmt.Sprintf(...))
  • ✅ Not flagged: fmt.Errorf, variable-held format string, plain string literal

Documentation

docs/adr/40490-add-sprintferrorsnew-linter.md (added, low impact)
Draft ADR capturing the rationale for the new linter, alternatives considered,
and expected consequences.


Impact

Area Impact Breaking
Linter behaviour (new diagnostic) High No
Analyzer registry wiring Medium No
Tests / ADR / fixtures Low No

No breaking changes. Existing code that triggers the new diagnostic will receive
a lint error but no automatic fixes are applied.


Checklist

  • Analyzer implemented with go/analysis
  • Registered in cmd/linters/main.go
  • analysistest unit test added
  • Testdata fixtures cover flagged and non-flagged patterns
  • ADR draft added (ADR-40490)

Generated by PR Description Updater for issue #40490 · 58.8 AIC · ⌖ 7.36 AIC · ⊞ 4.5K ·

Adds a new custom go/analysis linter that detects the inline anti-pattern
of wrapping fmt.Sprintf inside errors.New:

    errors.New(fmt.Sprintf("invalid engine: %s", id))

This should always be replaced with:

    fmt.Errorf("invalid engine: %s", id)

Using fmt.Errorf is shorter, avoids a redundant heap allocation for the
intermediate string, and supports %w for error wrapping when needed.

Evidence (found by linter-miner run #44):
- Code-pattern scan identified 4 instances across pkg/workflow:
  engine.go, engine_definition.go, engine_validation.go,
  runtime_validation.go
- Not covered by any commonly-enabled golangci-lint rule

Implementation follows the largefunc conventions:
- pkg/linters/sprintferrorsnew/sprintferrorsnew.go  (Analyzer)
- pkg/linters/sprintferrorsnew/sprintferrorsnew_test.go
- pkg/linters/sprintferrorsnew/testdata/src/sprintferrorsnew/ (fixtures)
- cmd/linters/main.go updated to register the analyzer

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions github-actions Bot added automation cookie Issue Monster Loves Cookies! go-linters labels Jun 20, 2026
@pelikhan pelikhan marked this pull request as ready for review June 20, 2026 18:32
Copilot AI review requested due to automatic review settings June 20, 2026 18:32
@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

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

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

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

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 20, 2026

Copy link
Copy Markdown
Contributor Author

Test Quality Sentinel completed test quality analysis.

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 a new go/analysis linter (sprintferrorsnew) to detect the inline anti-pattern errors.New(fmt.Sprintf(...)) and registers it in the cmd/linters multichecker so it can be run with the existing linter driver.

Changes:

  • Introduces pkg/linters/sprintferrorsnew analyzer to report errors.New(fmt.Sprintf(...)) and recommend fmt.Errorf.
  • Adds analysistest coverage + test fixtures for flagged and allowed cases.
  • Registers the new analyzer in cmd/linters/main.go.
Show a summary per file
File Description
pkg/linters/sprintferrorsnew/sprintferrorsnew.go New analyzer implementation that detects errors.New(fmt.Sprintf(...)).
pkg/linters/sprintferrorsnew/sprintferrorsnew_test.go analysistest harness for the analyzer.
pkg/linters/sprintferrorsnew/testdata/src/sprintferrorsnew/sprintferrorsnew.go Fixture cases for diagnostics and non-diagnostics.
cmd/linters/main.go Registers sprintferrorsnew.Analyzer in the linter driver.

Copilot's findings

Tip

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

  • Files reviewed: 4/4 changed files
  • Comments generated: 2

Comment on lines +56 to +60
// Check if the sole argument is a direct fmt.Sprintf(...) call.
argCall, ok := call.Args[0].(*ast.CallExpr)
if !ok {
return
}
Comment thread cmd/linters/main.go
Comment on lines 73 to 78
ssljson.Analyzer,
seenmapbool.Analyzer,
sortslice.Analyzer,
sprintferrdot.Analyzer,
sprintferrorsnew.Analyzer,
strconvparseignorederror.Analyzer,
@github-actions

Copy link
Copy Markdown
Contributor Author

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (123 new lines in pkg/, above the 100-line threshold) but does not have a linked Architecture Decision Record (ADR).

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

📋 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 couldn't infer, refine the decision rationale, and confirm the alternatives reflect what you actually 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-40490: Add sprintferrorsnew 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

ADRs create a searchable, permanent record of why the codebase looks the way it does. Adding a new linter is a small but real design decision (scope, precision trade-offs, where it lives) — capturing it helps future contributors understand the suite's conventions.

📋 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/ numbered by PR number.

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

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 84.8 AIC · ⌖ 10.2 AIC · ⊞ 6.9K ·

@github-actions github-actions Bot left a comment

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.

Skills-Based Review 🧠

Applied /tdd and /grill-with-docs — commenting with improvement suggestions, no blocking issues.

📋 Key Themes & Highlights

Key Themes

  • Test coverage gaps: the test-file exemption (filecheck.IsTestFile) has no fixture locking it in; one of the two "bad" cases tests an identical AST path rather than an independent edge case.
  • Missing auto-fix: the transformation is 100% mechanical and an ideal candidate for analysis.SuggestedFixes — without it developers must manually refactor every flagged site.
  • Diagnostic message: the PR body highlights %w support as a motivating reason to prefer fmt.Errorf, but the emitted message omits this hint.

Positive Highlights

  • ✅ Clean implementation using existing astutil / filecheck helpers — consistent with sibling linters.
  • ✅ Intentionally narrow scope (inline form only) is well-documented and a sound design decision.
  • goodVariable fixture explicitly documents the purposeful exclusion of the two-step pattern.
  • ✅ PR body includes solid gap analysis confirming no existing rule covers this pattern.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · 66.5 AIC · ⌖ 10 AIC · ⊞ 6.9K

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.

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.

}

// 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.

@github-actions github-actions Bot left a comment

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.

Non-blocking observations

The implementation is correct for the stated scope — errors.New(fmt.Sprintf(...)) is detected accurately using type-aware import-path matching, and the detection logic has no false-positive or panic risk.

Observations (non-blocking)

1. pass.Fset.Position() vs PositionFor(false) for test-file exclusion — Using the adjusted form (which follows //line directives) is inconsistent with sprintferrdot and the majority of linters in this codebase. In generated code that uses //line directives, this can produce false negatives or unexpected behavior. One-line fix: use PositionFor(call.Pos(), false) to match the established pattern.

2. Missing test case for zero-variadic-arg fmt.Sprintferrors.New(fmt.Sprintf("static string")) is correctly flagged, but the diagnostic message "use fmt.Errorf instead" is slightly misleading for this shape; the better fix is errors.New("static string"). The test fixture does not cover this case, leaving diagnostic quality for this edge case unverified.

🔎 Code quality review by PR Code Quality Reviewer · 83 AIC · ⌖ 7.75 AIC · ⊞ 5.1K

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.

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


// 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.

@github-actions

Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

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

📊 Metrics & Test Classification (1 test analyzed)
Metric Value
New/modified tests analyzed 1
✅ Design tests (behavioral contracts) 1 (100%)
⚠️ Implementation tests (low value) 0 (0%)
Tests with error/edge cases 1 (100%)
Duplicate test clusters 0
Test inflation detected No
🚨 Coding-guideline violations 0
Test File Classification Issues Detected
TestSprintfErrorsNew pkg/linters/sprintferrorsnew/sprintferrorsnew_test.go:14 ✅ Design

Go: 1 (*_test.go); JavaScript: 0. No other languages detected.

Notes: TestSprintfErrorsNew uses the idiomatic analysistest.Run pattern for Go analysis linters. Assertions are expressed as // want annotations in testdata/src/sprintferrorsnew/sprintferrorsnew.go, which analysistest.Run validates at runtime. Zero explicit assert.* calls is correct and expected for this style.

Testdata coverage:

  • bad() → expects use fmt.Errorf instead of errors.New(fmt.Sprintf(...))
  • badWithMultipleArgs() → expects same diagnostic ✅
  • goodErrorf() → no diagnostic expected ✅
  • goodVariable() → no diagnostic expected ✅ (variable indirection edge case)
  • goodPlainString() → no diagnostic expected ✅

Test inflation: 17 test lines vs 73 production lines (ratio 0.23:1 — well within 2:1 threshold).

Verdict

Check passed. 0% implementation tests (threshold: 30%). The test enforces a behavioral contract via analysistest.Run with // want annotations covering two bad-pattern cases and three true-negative cases (approved alternatives that must not be flagged).

🧪 Test quality analysis by Test Quality Sentinel · 107.6 AIC · ⊞ 8.3K ·

@github-actions github-actions Bot left a comment

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 Quality Sentinel: 100/100. Test quality is excellent — 0% of new tests are implementation tests (threshold: 30%).

@pelikhan pelikhan merged commit 3de78f1 into main Jun 20, 2026
29 checks passed
@pelikhan pelikhan deleted the linter-miner/sprintferrorsnew-de7948de12c6577b branch June 20, 2026 19:00
@github-actions github-actions Bot mentioned this pull request Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

automation cookie Issue Monster Loves Cookies! go-linters

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants