-
Notifications
You must be signed in to change notification settings - Fork 495
feat(linters): add errortypeassertion analyzer for error-to-concrete assertions #42323
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1b7538f
fead98b
067a699
ccd8e50
25cf1a0
aea9c62
35c7e56
c9e41f8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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.* |
| 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", | ||
| 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") | ||
| } | ||
| 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 | ||
| } | ||
| 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") | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] 💡 Suggested additionAdd a 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
}
@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) { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The function name implies type switches are handled, but it only covers the interface-assertion 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in aea9c62. Added |
||
| switch e := err.(type) { | ||
| case interface{ Temporary() bool }: | ||
| fmt.Println(e.Temporary()) | ||
| } | ||
| } | ||
|
Comment on lines
+20
to
+25
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing test for concrete type in type switch. func GoodTypeSwitchConcrete(err error) {
switch e := err.(type) {
case *os.PathError:
fmt.Println(e.Path) // should not be flagged
}
}@copilot please address this.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in aea9c62. Added |
||
|
|
||
| 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` | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [/tdd] All three "bad" fixtures use 💡 Suggested additiontype 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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, | ||
|
|
@@ -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}, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[/grill-with-docs] The
Docfield only describes what's flagged. Pergo/analysisconvention, it should also document what's intentionally exempt, since IDEs andgo vetsurface this string directly to users.💡 Suggested wording
This prevents confusion when users see a diagnostic and wonder why their interface assertion wasn't flagged.
@copilot please address this.