Skip to content
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

feat_: LogOnPanic linter #5969

Merged
merged 6 commits into from
Oct 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,10 @@ canary-test: node-canary
# TODO: uncomment that!
#_assets/scripts/canary_test_mailservers.sh ./config/cli/fleet-eth.prod.json

lint: generate
lint-panics: generate
go run ./cmd/lint-panics -root="$(call sh, pwd)" -skip=./cmd -test=false ./...
osmaczko marked this conversation as resolved.
Show resolved Hide resolved

lint: generate lint-panics
golangci-lint run ./...

ci: generate lint canary-test test-unit test-e2e ##@tests Run all linters and tests at once
Expand Down
245 changes: 245 additions & 0 deletions cmd/lint-panics/analyzer/analyzer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
package analyzer

import (
"context"
"fmt"
"go/ast"
"os"

"go.uber.org/zap"

goparser "go/parser"
gotoken "go/token"
"strings"

"github.com/pkg/errors"
"golang.org/x/tools/go/analysis"
"golang.org/x/tools/go/analysis/passes/inspect"
"golang.org/x/tools/go/ast/inspector"

"github.com/status-im/status-go/cmd/lint-panics/gopls"
"github.com/status-im/status-go/cmd/lint-panics/utils"
)

const Pattern = "LogOnPanic"

type Analyzer struct {
logger *zap.Logger
lsp LSP
cfg *Config
}

type LSP interface {
Definition(context.Context, string, int, int) (string, int, error)
}

func New(ctx context.Context, logger *zap.Logger) (*analysis.Analyzer, error) {
cfg := Config{}
flags, err := cfg.ParseFlags()
if err != nil {
return nil, err

Check warning on line 40 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L40

Added line #L40 was not covered by tests
}

logger.Info("creating analyzer", zap.String("root", cfg.RootDir))

goplsClient := gopls.NewGoplsClient(ctx, logger, cfg.RootDir)
processor := newAnalyzer(logger, goplsClient, &cfg)

analyzer := &analysis.Analyzer{
Name: "logpanics",
Doc: fmt.Sprintf("reports missing defer call to %s", Pattern),
Flags: flags,
Requires: []*analysis.Analyzer{inspect.Analyzer},
Run: func(pass *analysis.Pass) (interface{}, error) {
return processor.Run(ctx, pass)
},
}

return analyzer, nil
}

func newAnalyzer(logger *zap.Logger, lsp LSP, cfg *Config) *Analyzer {
return &Analyzer{
logger: logger.Named("processor"),
lsp: lsp,
cfg: cfg.WithAbsolutePaths(),
}
}

func (p *Analyzer) Run(ctx context.Context, pass *analysis.Pass) (interface{}, error) {
inspected, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
if !ok {
return nil, errors.New("analyzer is not type *inspector.Inspector")

Check warning on line 72 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L72

Added line #L72 was not covered by tests
}

// Create a nodes filter for goroutines (GoStmt represents a 'go' statement)
nodeFilter := []ast.Node{
(*ast.GoStmt)(nil),
}

// Inspect go statements
inspected.Preorder(nodeFilter, func(n ast.Node) {
p.ProcessNode(ctx, pass, n)
})

return nil, nil
}

func (p *Analyzer) ProcessNode(ctx context.Context, pass *analysis.Pass, n ast.Node) {
goStmt, ok := n.(*ast.GoStmt)
if !ok {
panic("unexpected node type")

Check warning on line 91 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L91

Added line #L91 was not covered by tests
}

switch fun := goStmt.Call.Fun.(type) {
case *ast.FuncLit: // anonymous function
pos := pass.Fset.Position(fun.Pos())
logger := p.logger.With(
utils.ZapURI(pos.Filename, pos.Line),
zap.Int("column", pos.Column),
)

logger.Debug("found anonymous goroutine")
if err := p.checkGoroutine(fun.Body); err != nil {
p.logLinterError(pass, fun.Pos(), fun.Pos(), err)
}

case *ast.SelectorExpr: // method call
pos := pass.Fset.Position(fun.Sel.Pos())
p.logger.Info("found method call as goroutine",
zap.String("methodName", fun.Sel.Name),
utils.ZapURI(pos.Filename, pos.Line),
zap.Int("column", pos.Column),
)

defPos, err := p.checkGoroutineDefinition(ctx, pos, pass)
if err != nil {
p.logLinterError(pass, defPos, fun.Sel.Pos(), err)
}

case *ast.Ident: // function call
pos := pass.Fset.Position(fun.Pos())
p.logger.Info("found function call as goroutine",
zap.String("functionName", fun.Name),
utils.ZapURI(pos.Filename, pos.Line),
zap.Int("column", pos.Column),
)

defPos, err := p.checkGoroutineDefinition(ctx, pos, pass)
if err != nil {
p.logLinterError(pass, defPos, fun.Pos(), err)
}

default:
p.logger.Error("unexpected goroutine type",
zap.String("type", fmt.Sprintf("%T", fun)),
)

Check warning on line 136 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L133-L136

Added lines #L133 - L136 were not covered by tests
}
}

func (p *Analyzer) parseFile(path string, pass *analysis.Pass) (*ast.File, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be somewhere else to reduce friction to implement a new linter in the future? Seems reusable, not specific of the panic linter.

Well, I guess it might be better to wait for another custom linter to decide on what/how to best split and reuse code.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, better to wait for the need for it 👍
Also, this one is quite small, so I'm not sure if we need to extract it.

logger := p.logger.With(zap.String("path", path))

src, err := os.ReadFile(path)
if err != nil {
logger.Error("failed to open file", zap.Error(err))

Check warning on line 145 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L145

Added line #L145 was not covered by tests
}

file, err := goparser.ParseFile(pass.Fset, path, src, 0)
if err != nil {
logger.Error("failed to parse file", zap.Error(err))
return nil, err

Check warning on line 151 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L150-L151

Added lines #L150 - L151 were not covered by tests
}

return file, nil
}

func (p *Analyzer) checkGoroutine(body *ast.BlockStmt) error {
if body == nil {
p.logger.Warn("missing function body")
return nil

Check warning on line 160 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L159-L160

Added lines #L159 - L160 were not covered by tests
}
if len(body.List) == 0 {
// empty goroutine is weird, but it never panics, so not a linter error
return nil
}

deferStatement, ok := body.List[0].(*ast.DeferStmt)
if !ok {
return errors.New("first statement is not defer")
}

selectorExpr, ok := deferStatement.Call.Fun.(*ast.SelectorExpr)
if !ok {
return errors.New("first statement call is not a selector")

Check warning on line 174 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L174

Added line #L174 was not covered by tests
}

firstLineFunName := selectorExpr.Sel.Name
if firstLineFunName != Pattern {
return errors.Errorf("first statement is not %s", Pattern)
}

return nil
}

func (p *Analyzer) getFunctionBody(node ast.Node, lineNumber int, pass *analysis.Pass) (body *ast.BlockStmt, pos gotoken.Pos) {
ast.Inspect(node, func(n ast.Node) bool {
// Check if the node is a function declaration
funcDecl, ok := n.(*ast.FuncDecl)
if !ok {
return true
}

if pass.Fset.Position(n.Pos()).Line != lineNumber {
return true
}

body = funcDecl.Body
pos = n.Pos()
return false
})

return body, pos

}

func (p *Analyzer) checkGoroutineDefinition(ctx context.Context, pos gotoken.Position, pass *analysis.Pass) (gotoken.Pos, error) {
defFilePath, defLineNumber, err := p.lsp.Definition(ctx, pos.Filename, pos.Line, pos.Column)
if err != nil {
p.logger.Error("failed to find function definition", zap.Error(err))
return 0, err

Check warning on line 210 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L209-L210

Added lines #L209 - L210 were not covered by tests
}

file, err := p.parseFile(defFilePath, pass)
if err != nil {
p.logger.Error("failed to parse file", zap.Error(err))
return 0, err

Check warning on line 216 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L215-L216

Added lines #L215 - L216 were not covered by tests
}

body, defPosition := p.getFunctionBody(file, defLineNumber, pass)
return defPosition, p.checkGoroutine(body)
}

func (p *Analyzer) logLinterError(pass *analysis.Pass, errPos gotoken.Pos, callPos gotoken.Pos, err error) {
osmaczko marked this conversation as resolved.
Show resolved Hide resolved
errPosition := pass.Fset.Position(errPos)
callPosition := pass.Fset.Position(callPos)

if p.skip(errPosition.Filename) || p.skip(callPosition.Filename) {
return

Check warning on line 228 in cmd/lint-panics/analyzer/analyzer.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/analyzer.go#L228

Added line #L228 was not covered by tests
}

message := fmt.Sprintf("missing %s()", Pattern)
p.logger.Warn(message,
utils.ZapURI(errPosition.Filename, errPosition.Line),
zap.String("details", err.Error()))

if callPos == errPos {
pass.Reportf(errPos, "missing defer call to %s", Pattern)
} else {
pass.Reportf(callPos, "missing defer call to %s", Pattern)
}
}

func (p *Analyzer) skip(filepath string) bool {
return p.cfg.SkipDir != "" && strings.HasPrefix(filepath, p.cfg.SkipDir)
}
28 changes: 28 additions & 0 deletions cmd/lint-panics/analyzer/analyzer_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package analyzer

import (
"context"
"testing"
"time"

"github.com/stretchr/testify/require"
"go.uber.org/zap"

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

"github.com/status-im/status-go/cmd/lint-panics/utils"
)

func TestMethods(t *testing.T) {
t.Parallel()

logger := utils.BuildLogger(zap.DebugLevel)

ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute)
defer cancel()

a, err := New(ctx, logger)
require.NoError(t, err)

analysistest.Run(t, analysistest.TestData(), a, "functions")
}
60 changes: 60 additions & 0 deletions cmd/lint-panics/analyzer/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package analyzer

import (
"flag"
"io"
"os"
"path"
"strings"
)

type Config struct {
RootDir string
SkipDir string
}

var workdir string

func init() {
var err error
workdir, err = os.Getwd()
if err != nil {
panic(err)

Check warning on line 22 in cmd/lint-panics/analyzer/config.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/config.go#L22

Added line #L22 was not covered by tests
}
}

func (c *Config) ParseFlags() (flag.FlagSet, error) {
flags := flag.NewFlagSet("lint-panics", flag.ContinueOnError)
flags.SetOutput(io.Discard) // Otherwise errors are printed to stderr
flags.StringVar(&c.RootDir, "root", workdir, "root directory to run gopls")
flags.StringVar(&c.SkipDir, "skip", "", "skip paths with this suffix")

// We parse the flags here to have `rootDir` before the call to `singlechecker.Main(analyzer)`
// For same reasons we discard the output and skip the undefined flag error.
err := flags.Parse(os.Args[1:])
if err == nil {
return *flags, nil

Check warning on line 36 in cmd/lint-panics/analyzer/config.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/config.go#L36

Added line #L36 was not covered by tests
}

if strings.Contains(err.Error(), "flag provided but not defined") {
err = nil
} else if strings.Contains(err.Error(), "help requested") {
err = nil

Check warning on line 42 in cmd/lint-panics/analyzer/config.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/config.go#L42

Added line #L42 was not covered by tests
}

return *flags, err
}

func (c *Config) WithAbsolutePaths() *Config {
out := *c

if !path.IsAbs(out.RootDir) {
out.RootDir = path.Join(workdir, out.RootDir)

Check warning on line 52 in cmd/lint-panics/analyzer/config.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/config.go#L52

Added line #L52 was not covered by tests
}

if out.SkipDir != "" && !path.IsAbs(out.SkipDir) {
out.SkipDir = path.Join(out.RootDir, out.SkipDir)

Check warning on line 56 in cmd/lint-panics/analyzer/config.go

View check run for this annotation

Codecov / codecov/patch

cmd/lint-panics/analyzer/config.go#L56

Added line #L56 was not covered by tests
}

return &out
}
5 changes: 5 additions & 0 deletions cmd/lint-panics/analyzer/testdata/src/common/common.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package common

func LogOnPanic() {
// do nothing
}
24 changes: 24 additions & 0 deletions cmd/lint-panics/analyzer/testdata/src/functions/anonymous.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package functions

import (
"common"
"fmt"
)

func init() {
go func() {
defer common.LogOnPanic()
}()

go func() {

}()

go func() { // want "missing defer call to LogOnPanic"
fmt.Println("anon")
}()

go func() { // want "missing defer call to LogOnPanic"
common.LogOnPanic()
}()
}
Loading