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 @@ -21,6 +21,7 @@ import (
"github.com/github/gh-aw/pkg/linters/deferinloop"
"github.com/github/gh-aw/pkg/linters/errorfwrapv"
"github.com/github/gh-aw/pkg/linters/errormessage"
"github.com/github/gh-aw/pkg/linters/errortypeassertion"
"github.com/github/gh-aw/pkg/linters/errstringmatch"
"github.com/github/gh-aw/pkg/linters/excessivefuncparams"
"github.com/github/gh-aw/pkg/linters/execcommandwithoutcontext"
Expand Down Expand Up @@ -61,6 +62,7 @@ func main() {
ctxbackground.Analyzer,
deferinloop.Analyzer,
errormessage.Analyzer,
errortypeassertion.Analyzer,
fprintlnsprintf.Analyzer,
errstringmatch.Analyzer,
errorfwrapv.Analyzer,
Expand Down
44 changes: 44 additions & 0 deletions docs/adr/42323-add-errortypeassertion-linter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# ADR-42323: Add errortypeassertion Custom Go Analyzer

**Date**: 2026-06-29
**Status**: Draft
**Deciders**: Unknown

---

### Context

Go 1.13 introduced error wrapping via `fmt.Errorf("...: %w", err)` and the `errors.As` / `errors.Is` traversal API. However, callsites that use direct type assertions — `err.(*os.PathError)` — silently fail when the error value is wrapped, because a type assertion checks the concrete dynamic type of the outermost value and does not unwrap the chain. This creates correctness bugs that are easy to miss in code review: the assertion compiles and runs without panic, but always produces a zero value or `ok == false` for wrapped errors. The `gh-aw` codebase already has a suite of custom `go/analysis` analyzers in `pkg/linters/` that enforce similar error-handling patterns (e.g., `errorfwrapv`, `errstringmatch`), and adding enforcement here follows the same pattern of static analysis at build time rather than relying solely on reviewer attention.

### Decision

We will add a new custom `go/analysis` analyzer, `errortypeassertion`, that flags any `TypeAssertExpr` where the asserted-from expression has the built-in `error` type and the asserted-to type is a concrete (non-interface) type, and emits a diagnostic recommending `errors.As`. Interface assertions (e.g., `err.(interface{ Timeout() bool })`) and type-switch guards are intentionally excluded because they represent valid behavior checks, not wrapped-error traversal. The analyzer is registered in `cmd/linters/main.go` and in the spec test, consistent with all other analyzers in this suite.

### Alternatives Considered

#### Alternative 1: Code Review and Documentation Only

Rely on PR reviewers and developer documentation to catch direct error type assertions. This costs nothing to implement but provides no automated enforcement, so violations persist whenever reviewers miss them. In a large codebase with many contributors, manual review is insufficient for systematic enforcement of a subtle correctness invariant.

#### Alternative 2: Use an Existing Third-Party Linter (e.g., `errorlint --errorlint-assertion`)

The `errorlint` linter from `golangci-lint` has an `--errorlint-assertion` flag that flags exactly this pattern. This avoids building and maintaining a custom analyzer. However, it introduces an external dependency outside the existing custom analyzer framework, would not integrate with the project's internal `nolint`, `filecheck`, and `astutil` helpers, and may flag patterns the team intentionally wants to allow — requiring either upstream configuration or wrapper logic that approaches the complexity of a custom analyzer.

### Consequences

#### Positive
- Correctness bugs caused by direct error type assertions bypassing wrapped error chains are caught at static analysis time, before runtime.
- The new analyzer reuses all existing internal infrastructure (`astutil.Inspector`, `nolint.BuildLineIndex`, `filecheck.IsTestFile`), keeping enforcement uniform and the implementation small (72 lines).
- Developers receive an actionable diagnostic pointing them to `errors.As`, reducing the learning curve for the error wrapping pattern.

#### Negative
- Adds a custom analyzer that must be maintained alongside the internal helper packages; if the shared helpers change their API, this analyzer must be updated.
- Any new legitimate error assertion pattern not covered by the current exclusion rules (e.g., future patterns that are not interface assertions) would produce false positives until the analyzer is updated.

#### Neutral
- The analyzer is suppressed by `//nolint:errortypeassertion` for cases where the caller knowingly uses direct assertion (e.g., in code that cannot use `errors.As` due to interface constraints). This matches the nolint convention used by all other analyzers in the suite.
- Test files are excluded from analysis by the `filecheck.IsTestFile` helper, consistent with the suite's test-exclusion policy.

---

*ADR created by [adr-writer agent]. Review and finalize before changing status from Draft to Accepted.*
3 changes: 3 additions & 0 deletions pkg/linters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ This package currently provides custom Go analyzers in the following subpackages
- `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.
- `errortypeassertion` — reports type assertions from `error` to concrete types and recommends `errors.As`.
- `errstringmatch` — reports `strings.Contains(err.Error(), "...")` patterns and recommends `errors.Is` / `errors.As`.
- `fileclosenotdeferred` — reports non-deferred file `Close()` calls that can leak resources.
- `execcommandwithoutcontext` — reports `exec.Command(...)` calls inside functions that already receive `context.Context` and should use `exec.CommandContext(...)`.
Expand Down Expand Up @@ -57,6 +58,7 @@ This package currently provides custom Go analyzers in the following subpackages
| `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 |
| `errortypeassertion` | Custom `go/analysis` analyzer that flags type assertions from `error` to concrete types and recommends `errors.As` |
| `errstringmatch` | Custom `go/analysis` analyzer that flags brittle `strings.Contains(err.Error(), "...")` checks |
| `execcommandwithoutcontext` | Custom `go/analysis` analyzer that flags `exec.Command(...)` calls that should use `exec.CommandContext(...)` in context-receiving functions |
| `fileclosenotdeferred` | Custom `go/analysis` analyzer that flags file `Close()` calls that are not deferred immediately |
Expand Down Expand Up @@ -147,6 +149,7 @@ _ = ssljson.Analyzer
- `github.com/github/gh-aw/pkg/linters/contextcancelnotdeferred` — context-cancel-not-deferred analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/ctxbackground` — context-background analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/errormessage` — error-message analyzer subpackage (also re-exported as `ErrorMessageAnalyzer`)
- `github.com/github/gh-aw/pkg/linters/errortypeassertion` — error-type-assertion analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/errstringmatch` — err-string-match analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/execcommandwithoutcontext` — exec-command-without-context analyzer subpackage
- `github.com/github/gh-aw/pkg/linters/excessivefuncparams` — excessive-func-params analyzer subpackage
Expand Down
148 changes: 148 additions & 0 deletions pkg/linters/errortypeassertion/errortypeassertion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
// Package errortypeassertion implements a Go analysis linter that flags type
// assertions on values typed as the built-in error interface when asserting to
// concrete types, and recommends errors.As for wrapped error traversal.
package errortypeassertion

import (
"go/ast"
"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"
)

// Analyzer is the error-type-assertion analysis pass.
var Analyzer = &analysis.Analyzer{
Name: "errortypeassertion",
Doc: "reports type assertions from error to concrete types; use errors.As for wrapped errors",

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.

[/grill-with-docs] The Doc field only describes what's flagged. Per go/analysis convention, it should also document what's intentionally exempt, since IDEs and go vet surface this string directly to users.

💡 Suggested wording
Doc: "reports type assertions from the built-in error interface to concrete types; " +
    "use errors.As for wrapped-error traversal. " +
    "Interface-target assertions (err.(interface{ Timeout() bool })) and type-switch guards are not flagged.",

This prevents confusion when users see a diagnostic and wonder why their interface assertion wasn't flagged.

@copilot please address this.

URL: "https://github.com/github/gh-aw/tree/main/pkg/linters/errortypeassertion",
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, "errortypeassertion")

builtinErrorObj := types.Universe.Lookup("error")
if builtinErrorObj == nil {
// types.Universe always contains "error"; this branch indicates a broken
// Go toolchain setup and should never be reached in practice.
panic("errortypeassertion: types.Universe does not contain built-in error type")

Check failure on line 38 in pkg/linters/errortypeassertion/errortypeassertion.go

View workflow job for this annotation

GitHub Actions / lint-go

avoid panic in library code; return an error instead
}
builtinErrorType := builtinErrorObj.Type()

insp.Preorder([]ast.Node{(*ast.TypeAssertExpr)(nil), (*ast.TypeSwitchStmt)(nil)}, func(n ast.Node) {
switch node := n.(type) {
case *ast.TypeAssertExpr:
checkTypeAssertExpr(pass, noLintLinesByFile, builtinErrorType, node)
case *ast.TypeSwitchStmt:
checkTypeSwitchStmt(pass, noLintLinesByFile, builtinErrorType, node)
}
})

return nil, nil
}

// checkTypeAssertExpr flags direct type assertions of the form err.(ConcreteType).
func checkTypeAssertExpr(pass *analysis.Pass, noLintLinesByFile map[string]map[int]struct{}, builtinErrorType types.Type, typeAssert *ast.TypeAssertExpr) {
// Type-switch guards have nil Type; skip them (handled by checkTypeSwitchStmt).
if typeAssert.Type == nil {
return
}

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

// types.Identical matches only the exact built-in error type. Named interface
// types that embed error (e.g. "type MyErr interface { error }") are
// intentionally excluded: they carry additional methods that may justify a
// direct assertion.
assertedFrom := pass.TypesInfo.TypeOf(typeAssert.X)
if assertedFrom == nil || !types.Identical(assertedFrom, builtinErrorType) {
return
}

assertedTo := pass.TypesInfo.TypeOf(typeAssert.Type)
if assertedTo == nil {
return
}
if _, isInterface := assertedTo.Underlying().(*types.Interface); isInterface {
return
}

pass.ReportRangef(
typeAssert,
"type assertion on error to %s bypasses wrapped errors; use errors.As instead",
assertedTo,
)
}

// checkTypeSwitchStmt flags concrete-type case arms in type switches on error,
// e.g. "case *os.PathError:" inside "switch err.(type)".
func checkTypeSwitchStmt(pass *analysis.Pass, noLintLinesByFile map[string]map[int]struct{}, builtinErrorType types.Type, stmt *ast.TypeSwitchStmt) {
x := typeSwitchX(stmt)
if x == nil {
return
}

// types.Identical matches only the exact built-in error type (see
// checkTypeAssertExpr for rationale).
assertedFrom := pass.TypesInfo.TypeOf(x)
if assertedFrom == nil || !types.Identical(assertedFrom, builtinErrorType) {
return
}

for _, clause := range stmt.Body.List {
cc, ok := clause.(*ast.CaseClause)
if !ok {
continue
}
for _, typeExpr := range cc.List {
pos := pass.Fset.PositionFor(typeExpr.Pos(), false)
if filecheck.IsTestFile(pos.Filename) || nolint.HasDirective(pos, noLintLinesByFile) {
continue
}

assertedTo := pass.TypesInfo.TypeOf(typeExpr)
if assertedTo == nil {
continue
}
if _, isInterface := assertedTo.Underlying().(*types.Interface); isInterface {
continue
}

pass.ReportRangef(
typeExpr,
"type assertion on error to %s bypasses wrapped errors; use errors.As instead",
assertedTo,
)
}
}
}

// typeSwitchX returns the expression being switched on in a TypeSwitchStmt.
func typeSwitchX(stmt *ast.TypeSwitchStmt) ast.Expr {
switch a := stmt.Assign.(type) {
case *ast.AssignStmt:
if len(a.Rhs) == 1 {
if ta, ok := a.Rhs[0].(*ast.TypeAssertExpr); ok {
return ta.X
}
}
case *ast.ExprStmt:
if ta, ok := a.X.(*ast.TypeAssertExpr); ok {
return ta.X
}
}
return nil
}
16 changes: 16 additions & 0 deletions pkg/linters/errortypeassertion/errortypeassertion_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//go:build !integration

package errortypeassertion_test

import (
"testing"

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

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

func TestAnalyzer(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, errortypeassertion.Analyzer, "errortypeassertion")

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.

[/tdd] IsTestFile suppression is a deliberate feature (line 47 of the analyzer) but there's no fixture to verify it. If IsTestFile were accidentally removed or broken, no test would catch the regression.

💡 Suggested addition

Add a testdata/src/errortypeassertion/errortypeassertion_test.go fixture with a "bad" assertion that carries no // want annotation:

package errortypeassertion_test

import "os"

// Assertions in _test.go files should not be flagged.
func bad_in_test_file(err error) {
    _ = err.(*os.PathError) // no want comment — suppressed by IsTestFile
}

analysistest will fail if an unexpected diagnostic appears, making this a regression guard.

@copilot please address this.

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package errortypeassertion

import (
"errors"
"fmt"
"os"
)

func GoodErrorsAs(err error) {
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println(pathErr.Path)
}
}

func GoodInterfaceAssertion(err error) {
_, _ = err.(interface{ Timeout() bool })
}

func GoodTypeSwitch(err error) {

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.

GoodTypeSwitch only tests interface-type cases — it does not document or test the concrete-type case gap.

The function name implies type switches are handled, but it only covers the interface-assertion case (interface{ Temporary() bool }), which is already excluded by the isInterface check. The more important boundary to document is what happens with a concrete-type case:

// This is also NOT flagged (case arms are CaseClause entries, not TypeAssertExpr):
func NotFlaggedTypeSwitch(err error) {
    switch e := err.(type) {
    case *os.PathError:
        fmt.Println(e.Path)
    }
}

Without a test fixture covering this, future maintainers cannot tell whether the omission is intentional or a bug. Add a NotFlaggedTypeSwitch style function with no // want comment to explicitly document the scope boundary.

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.

Fixed in aea9c62. Added BadTypeSwitch to the fixture with case *os.PathError: carrying a // want annotation, alongside case interface{ Temporary() bool }: which is expected to remain unflagged.

switch e := err.(type) {
case interface{ Temporary() bool }:
fmt.Println(e.Temporary())
}
}
Comment on lines +20 to +25

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.

Missing test for concrete type in type switch. GoodTypeSwitch only covers the interface-case branch. Adding a concrete-type case (e.g. case *os.PathError:) would explicitly document that type-switch case clauses are not false-positives — since those are ast.CaseClause nodes, not TypeAssertExpr, they're structurally excluded but the test doesn't demonstrate this.

func GoodTypeSwitchConcrete(err error) {
    switch e := err.(type) {
    case *os.PathError:
        fmt.Println(e.Path) // should not be flagged
    }
}

@copilot please address this.

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.

Fixed in aea9c62. Added BadTypeSwitch to the fixture with a case *os.PathError: arm carrying a // want annotation to verify the new TypeSwitchStmt inspection fires correctly.


func BadTypeSwitch(err error) {
switch e := err.(type) {
case *os.PathError: // want `type assertion on error to \*os\.PathError bypasses wrapped errors; use errors\.As instead`
fmt.Println(e.Path)
case interface{ Temporary() bool }:
fmt.Println(e.Temporary())
}
}

func BadSingleValue(err error) {
_ = err.(*os.PathError) // want `type assertion on error to \*os\.PathError bypasses wrapped errors; use errors\.As instead`
}

func BadTwoValue(err error) {
if pathErr, ok := err.(*os.PathError); ok { // want `type assertion on error to \*os\.PathError bypasses wrapped errors; use errors\.As instead`
fmt.Println(pathErr.Path)
}
}

type errorAlias = error

func BadAlias(err errorAlias) {
_ = err.(*os.PathError) // want `type assertion on error to \*os\.PathError bypasses wrapped errors; use errors\.As instead`
}

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.

[/tdd] All three "bad" fixtures use *os.PathError (pointer to concrete type). A value-receiver concrete struct is not covered — the assertedTo.Underlying().(*types.Interface) branch for a plain struct type is never exercised.

💡 Suggested addition
type ValueTypeError struct{ code int }
func (e ValueTypeError) Error() string { return "error" }

func BadValueType(err error) {
	_ = err.(ValueTypeError) // want `type assertion on error to errortypeassertion\.ValueTypeError bypasses...`
}

This gives confidence the non-pointer code path is exercised and not accidentally gated behind a pointer-only check.

@copilot please address this.


func SuppressedPreviousLine(err error) {
//nolint:errortypeassertion
_ = err.(*os.PathError)
}

func SuppressedSameLine(err error) {
_ = err.(*os.PathError) //nolint:errortypeassertion
}
6 changes: 4 additions & 2 deletions pkg/linters/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/github/gh-aw/pkg/linters/deferinloop"
"github.com/github/gh-aw/pkg/linters/errorfwrapv"
"github.com/github/gh-aw/pkg/linters/errormessage"
"github.com/github/gh-aw/pkg/linters/errortypeassertion"
"github.com/github/gh-aw/pkg/linters/errstringmatch"
"github.com/github/gh-aw/pkg/linters/excessivefuncparams"
"github.com/github/gh-aw/pkg/linters/execcommandwithoutcontext"
Expand Down Expand Up @@ -61,14 +62,14 @@ type docAnalyzer struct {
}

// documentedAnalyzers returns the analyzer subpackages documented in the README
// "Public API > Subpackages" table. The README documents 35 analyzers
// "Public API > Subpackages" table. The README documents 36 analyzers
// subpackages (the non-analyzer `internal` helper subpackage is excluded because
// it exposes no Analyzer).
//
// Spec (README "Public API > Subpackages"):
//
// contextcancelnotdeferred, ctxbackground, deferinloop, errorfwrapv, excessivefuncparams, errormessage,
// errstringmatch, execcommandwithoutcontext, fileclosenotdeferred, fmterrorfnoverbs, fprintlnsprintf,
// errortypeassertion, errstringmatch, execcommandwithoutcontext, fileclosenotdeferred, fmterrorfnoverbs, fprintlnsprintf,
Comment on lines 69 to +72

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.

Fixed in aea9c62 — updated the count from 35 to 36.

// hardcodedfilepath, httpnoctx, jsonmarshalignoredeerror, largefunc, lenstringsplit, lenstringzero,
// manualmutexunlock, osexitinlibrary, ossetenvlibrary, panic-in-library-code, rawloginlib,
// regexpcompileinfunction, seenmapbool, sortslice, sprintferrdot, sprintferrorsnew, ssljson,
Expand All @@ -82,6 +83,7 @@ func documentedAnalyzers() []docAnalyzer {
{"errorfwrapv", errorfwrapv.Analyzer},
{"excessivefuncparams", excessivefuncparams.Analyzer},
{"errormessage", errormessage.Analyzer},
{"errortypeassertion", errortypeassertion.Analyzer},
{"errstringmatch", errstringmatch.Analyzer},
{"execcommandwithoutcontext", execcommandwithoutcontext.Analyzer},
{"fileclosenotdeferred", fileclosenotdeferred.Analyzer},
Expand Down
Loading