Skip to content
2 changes: 2 additions & 0 deletions cmd/linters/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (

"github.com/github/gh-aw/pkg/linters/contextcancelnotdeferred"
"github.com/github/gh-aw/pkg/linters/ctxbackground"
"github.com/github/gh-aw/pkg/linters/errorfwrapv"
"github.com/github/gh-aw/pkg/linters/errormessage"
"github.com/github/gh-aw/pkg/linters/errstringmatch"
"github.com/github/gh-aw/pkg/linters/excessivefuncparams"
Expand Down Expand Up @@ -53,6 +54,7 @@ func main() {
errormessage.Analyzer,
fprintlnsprintf.Analyzer,
errstringmatch.Analyzer,
errorfwrapv.Analyzer,
execcommandwithoutcontext.Analyzer,
excessivefuncparams.Analyzer,
fileclosenotdeferred.Analyzer,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# ADR-39263: Ship a Custom `errorfwrapv` Linter for `fmt.Errorf` Error Wrapping with `%v`

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

## Context

`fmt.Errorf("...: %v", err)` formats an `error` argument with `%v`, which renders the error's text but discards the wrapped-error reference. Only the `%w` verb records the cause in the chain that `errors.Is` and `errors.As` traverse, so a `%v` wrap silently breaks sentinel- and type-based error inspection by downstream callers — a correctness bug that is easy to overlook in review. No commonly enabled linter flags this distinction: `go vet`'s `printf` pass validates verb/argument type compatibility but treats `%v` on an error as valid. A scan of this repository (run #38) found a real occurrence in `pkg/workflow/compiler_orchestrator_frontmatter.go:50` (suppressed with a `nolint`), so the project needs an automated guard that fits its existing in-repo linter suite (`pkg/linters/*` registered in `cmd/linters/main.go`).

## Decision

We will add a bespoke `go/analysis` analyzer, `errorfwrapv`, to the project's custom linter suite rather than relying on an external linter. The analyzer inspects `fmt.Errorf` calls whose format argument is a string literal, parses the format string to map each positional verb to its argument index, and reports when a `%v` verb corresponds to an argument whose type implements the `error` interface (verified via `types.Implements` against the universe `error` type, not a syntactic match). It reports at most once per call, honors `//nolint:errorfwrapv` suppression for intentional non-wrapping, skips test files, and is registered in the multichecker in `cmd/linters/main.go`.

## Alternatives Considered

### Alternative 1: Rely on `go vet` / `golangci-lint` defaults
The project already runs standard linters, so extending their configuration would avoid new code. Rejected because no default rule distinguishes `%v` from `%w` for error arguments — `go vet`'s `printf` pass considers `%v` on an `error` correct — so the chain-breaking pattern goes uncaught.

### Alternative 2: Document the convention and rely on code review
A contributor guideline plus manual review requires no tooling. Rejected because the `%v` vs `%w` distinction is subtle and easily missed — an instance already reached production with a `nolint` — and manual review provides no repeatable, enforceable guard.

### Alternative 3: Syntactic (string-match) detection of `%v` next to an `err` identifier
A simpler analyzer could flag any `%v` whose argument is named `err`. Rejected because it would both miss errors stored under other names and false-positive on non-error values; type-based `error`-interface checking is more precise at modest extra complexity.

## Consequences

### Positive
- Catches a real, otherwise-undetected correctness pattern (lost error chains) automatically in CI.
- Follows the established in-repo linter convention, so it composes with the existing `cmd/linters` multichecker and shared `internal` helpers (`astutil`, `filecheck`, `nolint`).
- Precise: `types.Implements` identity checking and per-argument verb mapping target genuine error wraps while allowing `%v` on non-error arguments.

### Negative
- Adds a custom analyzer the team must maintain, including a hand-rolled format-string verb parser that must track Go's `fmt` flag/width/precision/explicit-index (`%[n]`) syntax as it evolves.
- Limited to string-literal format arguments: `fmt.Errorf` calls with a non-literal (variable or concatenated) format string are not analyzed, so some misses remain uncaught and may create a false sense of full coverage.

### Neutral
- Suppression is available via `//nolint:errorfwrapv` for intentional non-wrapping cases.
- Test files are excluded from analysis, matching the suite's existing conventions.

---

*This is a DRAFT ADR generated by the [Design Decision Gate](https://github.com/github/gh-aw/actions/runs/27507782045) workflow. The PR author must review, complete, and finalize this document before the PR can merge.*
6 changes: 6 additions & 0 deletions pkg/linters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ This package currently provides custom Go analyzers in the following subpackages

- `contextcancelnotdeferred` — reports context cancel functions that are called directly instead of deferred.
- `ctxbackground` — reports `context.Background()` calls inside functions that already receive a `context.Context` parameter.
- `errorfwrapv` — reports `fmt.Errorf` calls that format error arguments with `%v` instead of `%w`.
- `excessivefuncparams` — reports function declarations that exceed a configurable parameter-count threshold.
- `errormessage` — reports non-actionable error-message patterns in changed files.
- `errstringmatch` — reports `strings.Contains(err.Error(), "...")` patterns and recommends `errors.Is` / `errors.As`.
Expand All @@ -16,6 +17,7 @@ This package currently provides custom Go analyzers in the following subpackages
- `fmterrorfnoverbs` — reports `fmt.Errorf` calls whose format string contains no verbs, recommending `errors.New` instead.
- `fprintlnsprintf` — reports `fmt.Fprintln(..., fmt.Sprintf(...))` patterns and recommends direct formatting calls.
- `hardcodedfilepath` — reports hard-coded file path string literals that match known path constants or should be extracted into named constants; also annotates paths that appear in log/print calls.
- `httpnoctx` — reports HTTP client and package-level HTTP calls that do not accept a `context.Context`.
- `jsonmarshalignoredeerror` — reports `json.Marshal` and `json.Unmarshal` calls where the error return is discarded with `_`.
- `largefunc` — reports function bodies that exceed a configurable line-count threshold.
- `lenstringzero` — reports `len(s) == 0` / `len(s) != 0` comparisons on string values that should use `s == ""` / `s != ""`.
Expand All @@ -30,6 +32,7 @@ This package currently provides custom Go analyzers in the following subpackages
- `ssljson` — validates `ssl.json` skill artifacts found in `.github/skills/` against the SSL spec (enum membership, graph integrity, transition targets, entry pointer validity).
- `strconvparseignorederror` — reports `strconv` parsing calls (`Atoi`, `ParseInt`, etc.) where the error return is discarded with `_`.
- `timeafterleak` — reports `time.After` calls used as the channel-receive expression in a `select` case inside a `for` or `range` loop that leak a timer channel on each iteration when another case fires first.
- `timesleepnocontext` — reports `time.Sleep` calls inside functions that already receive a `context.Context`, where a context-aware `select` should be used instead.
- `tolowerequalfold` — reports case-insensitive string comparisons using `strings.ToLower`/`ToUpper` that should use `strings.EqualFold`.
- `uncheckedtypeassertion` — reports single-value type assertions where unchecked panics are possible.
- `internal` — shared helper packages for analyzers (file checks and `nolint` handling).
Expand All @@ -42,6 +45,7 @@ This package currently provides custom Go analyzers in the following subpackages
|------------|-------------|
| `contextcancelnotdeferred` | Custom `go/analysis` analyzer that flags context cancel functions called directly instead of deferred |
| `ctxbackground` | Custom `go/analysis` analyzer that flags `context.Background()` calls inside functions that already receive a context parameter |
| `errorfwrapv` | Custom `go/analysis` analyzer that flags `fmt.Errorf` calls that format error arguments with `%v` instead of `%w` |
| `excessivefuncparams` | Custom `go/analysis` analyzer that flags function declarations with too many positional parameters |
| `errormessage` | Custom `go/analysis` analyzer that flags non-actionable error message patterns in changed files |
| `errstringmatch` | Custom `go/analysis` analyzer that flags brittle `strings.Contains(err.Error(), "...")` checks |
Expand All @@ -50,6 +54,7 @@ This package currently provides custom Go analyzers in the following subpackages
| `fmterrorfnoverbs` | Custom `go/analysis` analyzer that flags `fmt.Errorf` calls with no format verbs, recommending `errors.New` |
| `fprintlnsprintf` | Custom `go/analysis` analyzer that flags `fmt.Fprintln(..., fmt.Sprintf(...))` patterns |
| `hardcodedfilepath` | Custom `go/analysis` analyzer that flags hard-coded file path string literals that match known path constants or should be extracted as named constants; annotates paths in log/print calls |
| `httpnoctx` | Custom `go/analysis` analyzer that flags HTTP calls that do not accept a `context.Context` |
| `jsonmarshalignoredeerror` | Custom `go/analysis` analyzer that flags `json.Marshal`/`json.Unmarshal` calls where the error return is discarded with `_` |
| `largefunc` | Custom `go/analysis` analyzer that flags large functions with actionable diagnostics |
| `lenstringzero` | Custom `go/analysis` analyzer that flags `len(s) == 0` / `len(s) != 0` on string values that should use `s == ""` / `s != ""` |
Expand All @@ -64,6 +69,7 @@ This package currently provides custom Go analyzers in the following subpackages
| `ssljson` | Custom `go/analysis` analyzer that validates SSL JSON skill artifacts in `.github/skills/` |
| `strconvparseignorederror` | Custom `go/analysis` analyzer that flags `strconv` parsing calls where the error return is discarded with `_` |
| `timeafterleak` | Custom `go/analysis` analyzer that flags `time.After` in `select` cases inside loops that leak a timer channel on each iteration when another case fires first |
| `timesleepnocontext` | Custom `go/analysis` analyzer that flags `time.Sleep` calls in context-aware functions |
| `tolowerequalfold` | Custom `go/analysis` analyzer that flags case-insensitive comparisons via `strings.ToLower`/`ToUpper` that should use `strings.EqualFold` |
| `uncheckedtypeassertion` | Custom `go/analysis` analyzer that flags unchecked single-value type assertions |
| `internal` | Shared helper subpackages used by analyzers (`internal/filecheck`, `internal/nolint`) |
Expand Down
5 changes: 4 additions & 1 deletion pkg/linters/doc.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
// Package linters is a namespace for gh-aw's custom Go analysis linters.
//
// The actual analyzers are implemented in subpackages. All 25 active analyzers:
// The actual analyzers are implemented in subpackages. All 29 active analyzers:
//
// - contextcancelnotdeferred — flags context cancel functions called directly instead of deferred
// - ctxbackground — flags context.Background() inside functions that already receive a context
// - errorfwrapv — flags fmt.Errorf calls that format error arguments with %v instead of %w
// - errormessage — flags non-actionable error message patterns in changed files
// - errstringmatch — flags brittle strings.Contains(err.Error(), "...") checks
// - excessivefuncparams — flags function declarations with too many positional parameters
// - execcommandwithoutcontext — flags exec.Command calls inside functions that already receive context.Context
// - fileclosenotdeferred — flags file Close() calls that are not deferred
// - fmterrorfnoverbs — flags fmt.Errorf calls with no format verbs, recommending errors.New
// - fprintlnsprintf — flags fmt.Fprintln(..., fmt.Sprintf(...)) patterns
// - httpnoctx — flags HTTP calls that do not accept a context.Context
// - jsonmarshalignoredeerror — flags json.Marshal/Unmarshal calls where the error is discarded with _
// - largefunc — flags function bodies that exceed a configurable line-count threshold
// - lenstringzero — flags len(s) == 0 / len(s) != 0 on string values that should use s == "" / s != ""
Expand All @@ -25,6 +27,7 @@
// - ssljson — validates ssl.json skill artifacts in .github/skills/ against the SSL spec
// - strconvparseignorederror — flags strconv parsing calls where the error is discarded with _
// - timeafterleak — flags time.After in select cases inside loops that leak timer channels
// - timesleepnocontext — flags time.Sleep calls in context-aware functions that should propagate cancellation
// - tolowerequalfold — flags case-insensitive comparisons via ToLower/ToUpper that should use EqualFold
// - uncheckedtypeassertion — flags unchecked single-value type assertions
//
Expand Down
207 changes: 207 additions & 0 deletions pkg/linters/errorfwrapv/errorfwrapv.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// Package errorfwrapv implements a Go analysis linter that flags calls to
// fmt.Errorf that format error arguments with %v instead of %w, which breaks
// error-chain inspection via errors.Is and errors.As.
package errorfwrapv

import (
"errors"
"go/ast"
"go/token"
"go/types"
"strconv"

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

var errorIface = universeErrorInterface()

// universeErrorInterface returns the built-in error interface type, or nil if
// it cannot be resolved from types.Universe.
func universeErrorInterface() *types.Interface {
errorObj := types.Universe.Lookup("error")
if errorObj == nil {
return nil
}

iface, ok := errorObj.Type().Underlying().(*types.Interface)
if !ok {
return nil
}

return iface
}

type formatVerb struct {
argIdx int
verb rune
}

// Analyzer is the errorfwrapv analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "errorfwrapv",
Doc: "reports fmt.Errorf calls that format error arguments with %v instead of %w",
URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/errorfwrapv",
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: 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")
}

insp, err := astutil.Inspector(pass)
if err != nil {
return nil, err
}
noLintLinesByFile := nolint.BuildLineIndex(pass, "errorfwrapv")

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

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

position := pass.Fset.PositionFor(call.Pos(), false)
if filecheck.IsTestFile(position.Filename) {
return
}

if !astutil.IsFmtErrorf(pass, call) {
return
}

if len(call.Args) == 0 {
return
}

lit, ok := call.Args[0].(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return
}

verbs := parseFormatVerbs(lit.Value)
for _, fv := range verbs {
if fv.verb != 'v' {
continue
}
callArgIdx := fv.argIdx + 1
if callArgIdx >= len(call.Args) {
continue
}
tv, ok := pass.TypesInfo.Types[call.Args[callArgIdx]]
if !ok || tv.Type == nil {
continue
}
if !types.Implements(tv.Type, errorIface) {
continue
}
if nolint.HasDirective(position, noLintLinesByFile) {
return
}
pass.ReportRangef(call, "fmt.Errorf formats an error argument with %%v; use %%w to preserve the error chain")
return
}
})

return nil, nil
}

func parseFormatVerbs(s string) []formatVerb {
var verbs []formatVerb
if len(s) >= 2 {
s = s[1 : len(s)-1]
}

nextArgIdx := 0
for i := 0; i < len(s); i++ {
if s[i] != '%' {
continue
}
i++
if i >= len(s) {
break
}
if s[i] == '%' {
continue
}

valueArgIdx := 0
hasExplicitValueArg := false
if idx, nextPos, ok := parseFormatArgIndex(s, i); ok {
valueArgIdx = idx
nextArgIdx = idx + 1
hasExplicitValueArg = true
i = nextPos
}
for i < len(s) {
switch s[i] {
case '-', '+', '#', '0', ' ':
i++
default:
goto width
}
}

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] %*v (width taken from an argument, e.g. fmt.Errorf("%-*v: %v", 10, name, err)) is not handled. The parser exits the width loop on * and records * as the verb, consuming argIdx=0. The actual v verb is then skipped. For the second %v, the code maps argIdx=1 → call.Args[2] = name — missing err at call.Args[3] — false negative.

💡 Suggested fix

Handle * in the width (and precision) slot: treat it as consuming one argument index before reading the verb.

// width:
if i < len(s) && s[i] == '*' {
    argIdx++ // '*' consumes one call.Args slot
    i++
} else {
    for i < len(s) && s[i] >= '0' && s[i] <= '9' {
        i++
    }
}
// precision:
if i < len(s) && s[i] == '.' {
    i++
    if i < len(s) && s[i] == '*' {
        argIdx++
        i++
    } else {
        for i < len(s) && s[i] >= '0' && s[i] <= '9' {
            i++
        }
    }
}

Add a test fixture:

// BadDynamicWidthWrap uses dynamic width and %v for the error.
func BadDynamicWidthWrap(err error) error {
    return fmt.Errorf("%-*v", 10, err) // want `fmt\.Errorf formats an error argument with %v`
}

width:
i = consumeFormatWidthOrPrecision(s, i, &nextArgIdx)
if i < len(s) && s[i] == '.' {
i++
i = consumeFormatWidthOrPrecision(s, i, &nextArgIdx)
}
if i >= len(s) {
break
}
if !hasExplicitValueArg {
valueArgIdx = nextArgIdx
nextArgIdx++
}
verbs = append(verbs, formatVerb{argIdx: valueArgIdx, verb: rune(s[i])})
}

return verbs
}

func consumeFormatWidthOrPrecision(s string, i int, nextArgIdx *int) int {
if idx, nextPos, ok := parseFormatArgIndex(s, i); ok && nextPos < len(s) && s[nextPos] == '*' {
*nextArgIdx = idx + 1
return nextPos + 1
}
if i < len(s) && s[i] == '*' {
*nextArgIdx = *nextArgIdx + 1
return i + 1
}
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
return i
}

func parseFormatArgIndex(s string, i int) (int, int, bool) {
if i >= len(s) || s[i] != '[' {
return 0, i, false
}

j := i + 1
for j < len(s) && s[j] >= '0' && s[j] <= '9' {
j++
}
if j == i+1 || j >= len(s) || s[j] != ']' {
return 0, i, false
}

n, err := strconv.Atoi(s[i+1 : j])
if err != nil || n <= 0 {
return 0, i, false
}
return n - 1, j + 1, true
}
16 changes: 16 additions & 0 deletions pkg/linters/errorfwrapv/errorfwrapv_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !integration

package errorfwrapv_test

import (
"testing"

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

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

func TestErrorfWrapV(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, errorfwrapv.Analyzer, "errorfwrapv")
}
Loading
Loading