Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor: consolidate shared linter AST helpers
Co-authored-by: gh-aw-bot <259018956+gh-aw-bot@users.noreply.github.com>
  • Loading branch information
Copilot and gh-aw-bot authored Jun 9, 2026
commit a477f0d6a4287dbe4efbdee061dca7c202c8c81b
17 changes: 3 additions & 14 deletions pkg/linters/errormessage/errormessage.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

"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"
)
Expand Down Expand Up @@ -118,18 +119,6 @@ func extractLiteralErrorMessage(call *ast.CallExpr) (string, bool) {
return unquoted, true
}

func isFmtErrorf(call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
ident, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
return ident.Name == "fmt" && sel.Sel.Name == "Errorf"
}

func isNewValidationErrorCall(call *ast.CallExpr) bool {
switch fun := call.Fun.(type) {
case *ast.Ident:
Expand All @@ -142,7 +131,7 @@ func isNewValidationErrorCall(call *ast.CallExpr) bool {
}

func checkValidationFmtErrorf(pass *analysis.Pass, call *ast.CallExpr, filename string) {
if !strings.HasSuffix(filename, "_validation.go") || !isFmtErrorf(call) {
if !strings.HasSuffix(filename, "_validation.go") || !astutil.IsFmtErrorf(pass, call) {
return
}
pass.ReportRangef(call, "use NewValidationError(...) instead of fmt.Errorf(...) in validation files")
Expand Down Expand Up @@ -182,7 +171,7 @@ func checkNewValidationSuggestion(pass *analysis.Pass, call *ast.CallExpr) {
}

func checkFailedToErrorfWrap(pass *analysis.Pass, call *ast.CallExpr, msg string) {
if !isFmtErrorf(call) {
if !astutil.IsFmtErrorf(pass, call) {
return
}
if strings.HasPrefix(strings.ToLower(msg), "failed to ") && strings.Contains(msg, ": %w") {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package errormessage

import "fmt"
import "errors"
import (
"errors"
"fmt"
fmtalias "fmt"
)
Comment on lines +3 to +6

func NewValidationError(field, value, reason, suggestion string) error {
return errors.New(reason)
Expand All @@ -22,3 +25,16 @@ func badValidationErrorObject() error {
func badValidationSuggestionNoExample() error {
return NewValidationError("tools.github", "remote", "unsupported", "Set mode to local") // want `NewValidationError\(\.\.\.\) suggestion should include an example \(for example: YAML snippet\)`
}

func badValidationFormatAliased() error {
return fmtalias.Errorf("invalid value") // want `use NewValidationError\(\.\.\.\) instead of fmt\.Errorf\(\.\.\.\) in validation files` `error message uses negative language without constructive guidance; include expected/requires/should/example details`

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] Good coverage for checkValidationFmtErrorf with an aliased import — this is the more important of the two astutil.IsFmtErrorf call sites to cover since errormessage was using the weaker name-only check before.

The other call site, checkFailedToErrorfWrap (errormessage.go:174), also calls astutil.IsFmtErrorf but has no alias-import test. Consider adding to general.go:

func badFailedToWrapAliased(err error) error {
    return fmtalias.Errorf("failed to parse config: %w", err) // want `avoid generic .failed to`
}

This completes the aliased-import coverage for both call sites.

}

type formatter struct{}

func (formatter) Errorf(msg string, _ ...any) error { return errors.New(msg) }

func localShadowNotFmtPackage() error {
fmt := formatter{}
return fmt.Errorf("all good")
}
5 changes: 2 additions & 3 deletions pkg/linters/errstringmatch/errstringmatch.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ package errstringmatch
import (
"fmt"
"go/ast"
"go/token"
"go/types"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

"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"
)
Expand Down Expand Up @@ -114,8 +114,7 @@ func isErrDotError(pass *analysis.Pass, expr ast.Expr) bool {

// isStringLiteral returns true when expr is a string literal or untyped string constant.
func isStringLiteral(pass *analysis.Pass, expr ast.Expr) bool {

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.

[/zoom-out] There are now two functions with the base name isStringLiteral in the linter ecosystem: astutil.IsStringLiteral (no pass, pure *ast.BasicLit check) and this local isStringLiteral (needs *analysis.Pass, also handles constant string identifiers). A future contributor writing a new linter who reaches for astutil.IsStringLiteral thinking it covers the constant case will introduce a silent false-negative.

Renaming this local function to isStringValue or isStringLiteralOrConst would make the semantic distinction self-documenting at the call site.

lit, ok := expr.(*ast.BasicLit)
if ok && lit.Kind == token.STRING {
if astutil.IsStringLiteral(expr) {
return true
}
// Also accept typed/untyped string constants (e.g. a const identifier).
Expand Down
28 changes: 2 additions & 26 deletions pkg/linters/fmterrorfnoverbs/fmterrorfnoverbs.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import (
"fmt"
"go/ast"
"go/token"
"go/types"
"strings"

"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

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

Expand Down Expand Up @@ -43,7 +43,7 @@ func run(pass *analysis.Pass) (any, error) {
return
}

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

Expand Down Expand Up @@ -73,27 +73,3 @@ func run(pass *analysis.Pass) (any, error) {

return nil, nil
}

// isFmtErrorf returns true if the call expression is a call to fmt.Errorf.
func isFmtErrorf(pass *analysis.Pass, call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
if sel.Sel.Name != "Errorf" {
return false
}
pkgIdent, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
obj := pass.TypesInfo.ObjectOf(pkgIdent)
if obj == nil {
return false
}
pkgName, ok := obj.(*types.PkgName)
if !ok {
return false
}
return pkgName.Imported().Path() == "fmt"
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package fmterrorfnoverbs
import (
"errors"
"fmt"
fmtalias "fmt"
)
Comment on lines 4 to 7

func bad() error {
Expand Down Expand Up @@ -34,3 +35,16 @@ func suppressedPreviousLine() error {
func suppressedSameLine() error {
return fmt.Errorf("this is intentionally static") //nolint:fmterrorfnoverbs
}

func badAliasedImport() error {
return fmtalias.Errorf("alias import static message") // want `fmt\.Errorf called with no format verbs; use errors\.New`
}

type shadowFormatter struct{}

func (shadowFormatter) Errorf(msg string, _ ...any) error { return errors.New(msg) }

func localShadowNotFlagged() error {
fmt := shadowFormatter{}
return fmt.Errorf("not fmt package")
}
67 changes: 67 additions & 0 deletions pkg/linters/internal/astutil/astutil.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Package astutil provides shared AST/type helper functions used by linters.
package astutil

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

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

// IsLocalObject reports whether obj is a local (non-package-scope) object.
func IsLocalObject(obj types.Object) bool {
if obj == nil {
return false
}
parent := obj.Parent()
if parent == nil {
return false
}
pkg := obj.Pkg()
return pkg == nil || parent != pkg.Scope()
}

// RhsExprForIndex returns the RHS expression mapped to idx when available.
func RhsExprForIndex(rhs []ast.Expr, idx int) (ast.Expr, bool) {
switch {
case len(rhs) == 0:
return nil, false
case len(rhs) == 1 && idx == 0:

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.

[/improve-codebase-architecture] The len(rhs) == 1 && idx == 0 arm is logically redundant: for any idx == 0 and len(rhs) >= 1, the following idx < len(rhs) case would also return rhs[0], true. The PR description frames this as the "stricter" implementation that resolves behavioral drift vs lenstringzero, but both original implementations were equivalent — lenstringzero's idx < len(rhs) arm already handled idx == 0, len == 1.

If the arm is retained for explicitness (documenting the multi-return-function invariant where a, b = f() gives len(rhs)==1, idx>0 → nil), a comment like // Single-RHS multi-return (a, b = f()): only lhs[0] maps to rhs[0]. would prevent future readers from dismissing it as dead code.

return rhs[0], true
case idx < len(rhs):
return rhs[idx], true
default:
return nil, false
}
}

// IsStringLiteral reports whether expr is a string literal.
func IsStringLiteral(expr ast.Expr) bool {

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.

[/zoom-out] IsStringLiteral only checks *ast.BasicLit (syntax-level), while errstringmatch maintains a local isStringLiteral that also accepts typed/untyped constant identifiers via TypesInfo.TypeOf. A future linter author reaching for astutil.IsStringLiteral may silently miss the constant-identifier case and introduce a false-negative.

Consider adding a doc note like: // This checks only raw string literals. For constant string identifiers, additionally check TypesInfo.TypeOf(expr). — or promote a IsStringValue(pass *analysis.Pass, expr ast.Expr) bool helper that covers both and retire the narrow form.

lit, ok := expr.(*ast.BasicLit)
return ok && lit.Kind == token.STRING
}

// IsFmtErrorf reports whether call is a call to fmt.Errorf (including aliases).
func IsFmtErrorf(pass *analysis.Pass, call *ast.CallExpr) bool {
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return false
}
if sel.Sel.Name != "Errorf" {
return false
}
pkgIdent, ok := sel.X.(*ast.Ident)
if !ok {
return false
}
obj := pass.TypesInfo.ObjectOf(pkgIdent)
if obj == nil {
return false
}
pkgName, ok := obj.(*types.PkgName)
if !ok {
return false
}
return pkgName.Imported().Path() == "fmt"
}
32 changes: 5 additions & 27 deletions pkg/linters/lenstringzero/lenstringzero.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

"github.com/github/gh-aw/pkg/linters/internal/astutil"
"github.com/github/gh-aw/pkg/linters/internal/filecheck"
)

Expand Down Expand Up @@ -142,7 +143,7 @@ func collectLenStringAliasesFromAssignStmt(pass *analysis.Pass, stmt *ast.Assign
continue
}
obj := pass.TypesInfo.ObjectOf(ident)
if obj == nil || !isLocalObject(obj) {
if obj == nil || !astutil.IsLocalObject(obj) {
continue
}

Expand All @@ -152,7 +153,7 @@ func collectLenStringAliasesFromAssignStmt(pass *analysis.Pass, stmt *ast.Assign
delete(aliases, obj)
continue
}
rhs, ok := rhsExprForIndex(stmt.Rhs, i)
rhs, ok := astutil.RhsExprForIndex(stmt.Rhs, i)
if !ok {
delete(aliases, obj)
continue
Expand All @@ -174,10 +175,10 @@ func collectLenStringAliasesFromValueSpec(pass *analysis.Pass, spec *ast.ValueSp
continue
}
obj := pass.TypesInfo.ObjectOf(name)
if obj == nil || !isLocalObject(obj) {
if obj == nil || !astutil.IsLocalObject(obj) {
continue
}
rhs, ok := rhsExprForIndex(spec.Values, i)
rhs, ok := astutil.RhsExprForIndex(spec.Values, i)
if !ok {
delete(aliases, obj)
continue
Expand Down Expand Up @@ -222,33 +223,10 @@ func lenStringArg(pass *analysis.Pass, expr ast.Expr) (ast.Expr, bool) {
return arg, true
}

func rhsExprForIndex(rhs []ast.Expr, idx int) (ast.Expr, bool) {
switch {
case len(rhs) == 0:
return nil, false
case idx < len(rhs):
return rhs[idx], true
default:
return nil, false
}
}

func deleteLenStringAliasForExpr(pass *analysis.Pass, aliases map[types.Object]ast.Expr, expr ast.Expr) {
ident, ok := expr.(*ast.Ident)
if !ok {
return
}
delete(aliases, pass.TypesInfo.ObjectOf(ident))
}

func isLocalObject(obj types.Object) bool {
if obj == nil {
return false
}
parent := obj.Parent()
if parent == nil {
return false
}
pkg := obj.Pkg()
return pkg == nil || parent != pkg.Scope()
}
Loading