Skip to content

[linter-miner] feat(linters): add sprintferrdot — flag redundant .Error() calls in fmt format functions#40371

Merged
pelikhan merged 2 commits into
mainfrom
linter-miner/sprintferrdot-ad55365ceeaea9fe
Jun 19, 2026
Merged

[linter-miner] feat(linters): add sprintferrdot — flag redundant .Error() calls in fmt format functions#40371
pelikhan merged 2 commits into
mainfrom
linter-miner/sprintferrdot-ad55365ceeaea9fe

Conversation

@github-actions

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

Copy link
Copy Markdown
Contributor

Summary

Adds sprintferrdot, a new go/analysis linter that flags redundant zero-argument .Error() calls on error-typed values passed to fmt format functions (Sprintf, Errorf, Printf, Fprintf) under %s or %v verbs. Because fmt already invokes .Error() implicitly through the error/Stringer interface, the explicit call is dead code that obscures error-chain intent. A source scan found 6 live production instances of this pattern not caught by any existing linter.

ADR: docs/adr/40371-flag-redundant-error-calls-in-fmt-linter.md


What changed and why

pkg/linters/sprintferrdot/sprintferrdot.go (new)

Core analyzer implementation following the project's established pkg/linters/ pattern:

  • Detection: walks all *ast.CallExpr nodes; identifies calls to fmt.{Sprintf,Errorf,Printf,Fprintf} via astutil.IsPkgSelector; resolves the format-string argument positionally; maps variadic arguments against parsed verbs.
  • Verb parsing (parseSimpleFormatVerbs): hand-rolled percent-sequence parser that handles flags, width, and precision digits. Deliberately returns nil (skips the call) for explicit argument indices (%[n]v) or * width/precision, preventing false positives on complex format strings.
  • Error-type check (isErrorDotCall): confirms the receiver implements the built-in error interface via go/types (types.Implements), so non-error .Error() methods are not flagged.
  • Test-file exclusion: uses filecheck.IsTestFile to skip _test.go files, matching sibling linters.

pkg/linters/sprintferrdot/sprintferrdot_test.go (new)

analysistest-driven test entry point. Runs the analyzer against the fixture package via analysistest.Run.

pkg/linters/sprintferrdot/testdata/src/sprintferrdot/sprintferrdot.go (new)

Fixture package with annotated // want directives covering:

Case Function Expected
fmt.Sprintf + %s + .Error() badSprintfS flagged
fmt.Sprintf + %v + .Error() badSprintfV flagged
fmt.Errorf + %s + .Error() badErrorf flagged
fmt.Fprintf + %s + .Error() badFprintf flagged
Pass error directly (%s, %w) goodSprintfS, goodSprintfW clean
.Error() outside a format call goodStandaloneError clean
Non-%s/%v verb at error position goodMultiVerb clean
Non-Error() method call goodNonErrorDot clean

cmd/linters/main.go (modified)

Imports pkg/linters/sprintferrdot and appends sprintferrdot.Analyzer to the analyzer slice passed to main, making it active in the project-wide lint pass.

docs/adr/40371-flag-redundant-error-calls-in-fmt-linter.md (new — draft)

Draft ADR documenting:

  • Context: 6 live occurrences; no existing rule covers this smell.
  • Decision: dedicated one-smell-per-package analyzer (rejects folding into errorfwrapv; rejects relying solely on golangci-lint/staticcheck).
  • Consequences: reports only (no auto-fix); intentional false-negative policy on complex format strings.

⚠️ This ADR is marked Draft and must be finalized before merge per the Design Decision Gate requirement.


Behavior

Before: No lint diagnostic. Both of these compile and run silently.

fmt.Sprintf("failed: %s", err.Error())   // redundant — fmt calls .Error() itself
fmt.Errorf("wrapped: %s", err.Error())   // same

After: sprintferrdot reports:

redundant .Error() call: pass the error value directly with %s
redundant .Error() call: pass the error value directly with %s

Intended fix in each case:

fmt.Sprintf("failed: %s", err)
fmt.Errorf("wrapped: %s", err)

Deliberate non-coverage (by design):

  • %[n]v / * width/precision format strings → skipped (complex positional mapping)
  • .Error() called outside a fmt format argument → not flagged (legitimate use)
  • %w verb → not in scope (handled by errorfwrapv)
  • _test.go files → excluded via filecheck.IsTestFile

Relationship to existing linters

Linter Scope
errorfwrapv fmt.Errorf with %v where %w should be used
sprintferrdot (this PR) fmt.{Sprintf,Errorf,Printf,Fprintf} with .Error() under %s/%v

The two analyzers are complementary and deliberately kept separate per the project's one-analyzer-per-smell convention.


Checklist

  • New analyzer follows pkg/linters/ conventions (astutil, filecheck, analysistest)
  • Registered in cmd/linters/main.go
  • analysistest fixture covers all four target functions and representative clean cases
  • ADR docs/adr/40371-... finalized (currently Draft — author action required)

Generated by PR Description Updater for issue #40371 · 60.3 AIC · ⌖ 10.7 AIC · ⊞ 4.5K ·

… format calls

The sprintferrdot analyzer flags calls where err.Error() is passed as a
variadic argument to fmt.Sprintf/Errorf/Printf/Fprintf when the corresponding
format verb is %s or %v. Those verbs already invoke .Error() implicitly, so
the explicit call is redundant and should be removed.

Evidence from source scan: 6 instances found in pkg/ (frontmatter_error.go,
model_alias_validation.go, actions_build_command.go,
generate_action_metadata_command.go).

New files:
  pkg/linters/sprintferrdot/sprintferrdot.go        — analyzer
  pkg/linters/sprintferrdot/sprintferrdot_test.go   — tests
  pkg/linters/sprintferrdot/testdata/src/…          — fixtures
  cmd/linters/main.go                               — registration

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 19, 2026
@pelikhan pelikhan marked this pull request as ready for review June 19, 2026 18:25
Copilot AI review requested due to automatic review settings June 19, 2026 18:25
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

Test Quality Sentinel completed test quality analysis.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

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

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

PR Code Quality Reviewer completed the code quality review.

@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor Author

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

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 custom Go analysis linter (sprintferrdot) to the gh-aw linter suite to detect redundant .Error() calls when passing errors to fmt formatting functions with %s / %v, and wires it into the cmd/linters multichecker.

Changes:

  • Introduces the sprintferrdot analyzer implementation that inspects fmt.*printf-style calls and reports redundant .Error() usage for %s / %v.
  • Adds analysistest-based unit tests and testdata fixtures for the new analyzer.
  • Registers the new analyzer in cmd/linters/main.go so it runs with the full custom linter set.
Show a summary per file
File Description
pkg/linters/sprintferrdot/sprintferrdot.go New analyzer implementation for detecting redundant .Error() in fmt formatting calls.
pkg/linters/sprintferrdot/sprintferrdot_test.go analysistest harness to validate expected diagnostics.
pkg/linters/sprintferrdot/testdata/src/sprintferrdot/sprintferrdot.go Test fixtures (want-comments) covering flagged and allowed patterns.
cmd/linters/main.go Registers sprintferrdot.Analyzer in the multichecker.

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 +112 to +116
switch sel.Sel.Name {
case "Sprintf", "Errorf", "Printf":
return 0, 1, true
case "Fprintf", "Fscanf":
return 1, 2, true
Comment on lines +48 to +51
// goodMultiVerb has a %d verb for the error position — no diagnostic expected.
func goodMultiVerb(n int, err error) string {
return fmt.Sprintf("code %d: %T", n, err.Error())
}
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor Author

🏗️ Design Decision Gate — ADR Required

This PR makes significant changes to core business logic (287 new lines under pkg/) but did not have a linked Architecture Decision Record (ADR).

📄 Draft ADR committed: docs/adr/40371-flag-redundant-error-calls-in-fmt-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 (decision: adopt a dedicated go/analysis linter sprintferrdot following the established pkg/linters/ pattern).
  2. Complete / refine the sections — confirm the alternatives reflect what was actually weighed (e.g. extending errorfwrapv vs. a new package), and adjust the consequences if needed.
  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-40371: Flag Redundant .Error() Calls in fmt Format Functions

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. Future contributors (and your future self) will thank you for capturing the decision behind each new linter rather than leaving it implicit in the diff.

📋 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., 40371-...md for PR #40371).

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

🏗️ ADR gate enforced by Design Decision Gate 🏗️ · 79.5 AIC · ⌖ 10.3 AIC · ⊞ 13.6K ·

@github-actions

Copy link
Copy Markdown
Contributor Author

🧪 Test Quality Sentinel Report

Test Quality Score: 100/100 — Excellent

Analyzed 1 test: 1 design test, 0 implementation tests, 0 guideline violations.

📊 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 (17 test lines / 208 production lines = 0.08 ratio)
🚨 Coding-guideline violations 0
Test File Classification Issues Detected
TestSprintfErrDot pkg/linters/sprintferrdot/sprintferrdot_test.go:14 ✅ Design

Go: 1 (*_test.go); JavaScript: 0. Other languages detected but not scored.

Notes on TestSprintfErrDot:

  • Uses analysistest.Run — the canonical Go analysis linter testing framework. // want regex directives in the testdata file serve as the assertion mechanism; the framework calls t.Errorf internally on mismatches.
  • //go:build !integration build tag is present on line 1 ✅
  • 4 positive cases (bad patterns → diagnostic expected) and 6 negative cases (good patterns → no diagnostic) are exercised via the testdata fixture.
  • No mock libraries (gomock, testify/mock, .EXPECT(), .On()) ✅

Verdict

Check passed. 0% implementation tests (threshold: 30%). The single test function uses analysistest.Run to verify the behavioral contract of the linter — it exercises 4 diagnostic-trigger cases and 6 no-false-positive cases via // want directives, covering the primary bad and good patterns.

🧪 Test quality analysis by Test Quality Sentinel · 66.4 AIC · ⌖ 11.8 AIC · ⊞ 8.4K ·

@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%). The TestSprintfErrDot function correctly uses analysistest.Run to verify the linter behavioral contract with 4 positive and 6 negative test cases.

@pelikhan pelikhan merged commit aa926e3 into main Jun 19, 2026
@pelikhan pelikhan deleted the linter-miner/sprintferrdot-ad55365ceeaea9fe branch June 19, 2026 18:35

@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 /diagnose, /tdd, and /grill-with-docs — requesting changes for one correctness bug and a few test coverage gaps.

📋 Key Themes & Highlights

Blocking Issue

  • Fscanf is a scan function, not a format function (line 115 of sprintferrdot.go): fmt.Fscanf reads formatted text FROM an io.Reader; its variadic args are output pointers. The linter will produce a false positive if it encounters fmt.Fscanf. Remove it from the case arm — Fprintf alone belongs there.

Test Coverage Gaps

  • No fixture for multiple errors in one format call (%s: %s with two .Error() args) — the loop in run could have a silent off-by-one.
  • No fixture for a concrete pointer-receiver struct implementing error — the types.Implements(types.NewPointer(t), errorIface) branch in isErrorDotCall is untested.
  • fmt.Printf is registered but has no positive test case.

Minor Naming

  • goodNilErr doesn't test nil errors; rename to goodPassErrDirectly or goodSentinelDirect.

Positive Highlights

  • parseSimpleFormatVerbs conservatively returns nil on %[n] and * width/precision — zero false positives for complex format strings.
  • isErrorDotCall checks both T and *T receiver paths — solid defensive type-checking.
  • ✅ Good motivation: 6 real production hits cited in the PR body, with clear pairing to the existing errorfwrapv linter.
  • ✅ Test file skips generated/test files via filecheck.IsTestFile — consistent with other linters in the codebase.

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

switch sel.Sel.Name {
case "Sprintf", "Errorf", "Printf":
return 0, 1, true
case "Fprintf", "Fscanf":

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.

[/diagnose] Fscanf is a scanning function — it reads formatted input FROM an io.Reader, it does not write formatted output. Its presence here alongside Fprintf is a semantic error that will produce false positives on code that uses fmt.Fscanf.

💡 What goes wrong & suggested fix

fmt.Fscanf(r, "%s", ptr) stores scanned input into ptr. If someone writes fmt.Fscanf(r, "%s", someErr.Error()) the analyzer will incorrectly report a "redundant .Error() call" diagnostic, even though the error relationship here is entirely different from the formatting context this linter is designed for.

Fix: remove Fscanf from this case arm:

case "Fprintf":
    return 1, 2, true

Also add a negative test fixture to confirm Fscanf is never flagged.

// badFprintf calls fmt.Fprintf with err.Error() and a %s verb — should be flagged.
func badFprintf(w io.Writer, err error) {
fmt.Fprintf(w, "error: %s\n", err.Error()) // want `redundant \.Error\(\) call: pass the error value directly with %s`
}

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: no fixture tests multiple error arguments in a single format string. Without this, an off-by-one in the variadicArgs slice loop could silently miss second-and-later errors.

💡 Suggested fixture addition
// badSprintfMultiErr verifies both .Error() calls are flagged in one format call.
func badSprintfMultiErr(err1, err2 error) string {
    return fmt.Sprintf("%s: %s", err1.Error(), err2.Error()) // want `redundant \.Error\(\) call` `redundant \.Error\(\) call`
}

"os"
)

var sentinel = errors.New("sentinel")

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] All positive fixtures use the error interface directly; there's no test with a concrete pointer-receiver struct that implements error. This would exercise the types.Implements(types.NewPointer(t), errorIface) branch in isErrorDotCall that is otherwise untested.

💡 Suggested fixture addition
type myErr struct{ msg string }
func (e *myErr) Error() string { return e.msg }

func badConcreteErr() string {
    err := &myErr{"boom"}
    return fmt.Sprintf("failed: %s", err.Error()) // want `redundant \.Error\(\) call: pass the error value directly with %s`
}

func badErrorf(err error) error {
return fmt.Errorf("wrapped: %s", err.Error()) // want `redundant \.Error\(\) call: pass the error value directly with %s`
}

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] fmt.Printf is registered in fmtFormatCallInfo (alongside Sprintf and Errorf) but has no positive test case in the fixture. Add a badPrintf case for symmetry and to guard against accidental removal from the switch.

💡 Suggested fixture addition
// badPrintf calls fmt.Printf with err.Error() and a %s verb — should be flagged.
func badPrintf(err error) {
    fmt.Printf("error: %s\n", err.Error()) // want `redundant \.Error\(\) call: pass the error value directly with %s`
}

return fmt.Sprintf("file: %s", name)
}

// goodNilErr explicitly avoids the pattern.

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.

[/grill-with-docs] goodNilErr is a misleading name — this function doesn't test nil-error behavior; it tests passing a non-nil error directly (without .Error()) to confirm the linter stays quiet. Consider goodPassErrDirectly or goodSentinelDirect so the intent is immediately clear to future readers.

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

REQUEST_CHANGES — two blocking issues before this can merge.

Blocking

1. No //nolint support (high): pass.Reportf fires unconditionally. //nolint:sprintferrdot directives are silently ignored, giving developers no escape hatch for intentional patterns. 19 of ~30 peer linters in this tree implement nolint.BuildLineIndex — this one must too.

2. Silent no-op when errorIface == nil (medium): if types.Universe.Lookup("error") fails at init, the analyzer runs without error and reports nothing — indistinguishable from a clean codebase. The peer errorfwrapv explicitly return nil, errors.New(...) guards this. Add the same.

Non-blocking

  • fmt.Printf is listed in fmtFormatCallInfo but has zero test fixture coverage — easy to regress silently.
  • fmt.Fscanf is included in the format-function dispatch (flagged in separate comment): it is a scanner, not a printer, and should be removed.
  • goodMultiVerb fixture comment says %d verb is at the error position but the format string uses %T there (flagged in separate comment).

🔎 Code quality review by PR Code Quality Reviewer · 81.1 AIC · ⌖ 7.6 AIC · ⊞ 5.1K

continue
}
if isErrorDotCall(pass, arg) {
pass.Reportf(arg.Pos(),

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.

Findings fire unconditionally — //nolint:sprintferrdot will be silently ignored, making unsuppressible CI failures the only outcome for any intentional pattern.

💡 Suggested fix

About 19 of the ~30 peer linters in this tree use nolint.BuildLineIndex to honour suppression directives. Without it, a developer who deliberately calls .Error() for type-safe string extraction (or wants to temporarily suppress a false-positive) has no escape hatch — the linter hard-fails with no workaround.

Add the index at the top of run() and gate pass.Reportf behind it:

import "github.com/github/gh-aw/pkg/linters/internal/nolint"

func run(pass *analysis.Pass) (any, error) {
    noLintLines := nolint.BuildLineIndex(pass, "sprintferrdot")
    // ...
    // in Preorder callback, just before pass.Reportf:
    argPos := pass.Fset.PositionFor(arg.Pos(), false)
    if !nolint.HasDirective(argPos, noLintLines) {
        pass.Reportf(arg.Pos(),
            "redundant .Error() call: pass the error value directly with %%%c", verbs[i])
    }

See pkg/linters/errorfwrapv/errorfwrapv.go for the exact pattern used by the closest peer linter.

Run: run,
}

func run(pass *analysis.Pass) (any, 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.

run() does not guard against errorIface == nil: if types.Universe.Lookup("error") ever fails at init, the analyzer silently becomes a no-op — every invocation succeeds with zero findings, indistinguishable from a clean codebase.

💡 Suggested fix

The peer linter errorfwrapv explicitly fails fast at the top of run():

func run(pass *analysis.Pass) (any, error) {
    if errorIface == nil {
        return nil, errors.New("failed to resolve built-in error interface from types.Universe")
    }
    // ...

Without this guard, a misconfigured or unusual toolchain build where types.Universe is empty will silently pass all files, making the linter undetectable as broken. Returning an error surfaces the failure immediately in CI output.


// badFprintf calls fmt.Fprintf with err.Error() and a %s verb — should be flagged.
func badFprintf(w io.Writer, err error) {
fmt.Fprintf(w, "error: %s\n", err.Error()) // want `redundant \.Error\(\) call: pass the error value directly with %s`

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.

fmt.Printf is registered in fmtFormatCallInfo but has no test fixture coverage: a regression that removes or mishandles Printf would go undetected.

💡 Suggested fix

Add a badPrintf case alongside the existing badFprintf:

// badPrintf calls fmt.Printf with err.Error() and a %s verb — should be flagged.
func badPrintf(err error) {
	fmt.Printf("error: %s\n", err.Error()) // want `redundant \.Error\(\) call: pass the error value directly with %s`
}

Each function listed in fmtFormatCallInfo should have at least one positive fixture to prove the detection path is live.

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