mirror of
https://github.com/OffchainLabs/prysm.git
synced 2026-01-08 23:18:15 -05:00
Add log capitalization analyzer and apply changes (#15452)
* Add log capitalization analyzer and apply fixes across codebase Implements a new nogo analyzer to enforce proper log message capitalization and applies the fixes to all affected log statements throughout the beacon chain, validator, and supporting components. Co-Authored-By: Claude <noreply@anthropic.com> * Radek's feedback --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
26
tools/analyzers/logcapitalization/BUILD.bazel
Normal file
26
tools/analyzers/logcapitalization/BUILD.bazel
Normal file
@@ -0,0 +1,26 @@
|
||||
load("@prysm//tools/go:def.bzl", "go_library", "go_test")
|
||||
|
||||
go_library(
|
||||
name = "go_default_library",
|
||||
srcs = ["analyzer.go"],
|
||||
importpath = "github.com/OffchainLabs/prysm/v6/tools/analyzers/logcapitalization",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"@org_golang_x_tools//go/analysis:go_default_library",
|
||||
"@org_golang_x_tools//go/analysis/passes/inspect:go_default_library",
|
||||
"@org_golang_x_tools//go/ast/inspector:go_default_library",
|
||||
],
|
||||
)
|
||||
|
||||
go_test(
|
||||
name = "go_default_test",
|
||||
srcs = ["analyzer_test.go"],
|
||||
data = glob(["testdata/**"]) + [
|
||||
"@go_sdk//:files",
|
||||
],
|
||||
deps = [
|
||||
":go_default_library",
|
||||
"//build/bazel:go_default_library",
|
||||
"@org_golang_x_tools//go/analysis/analysistest:go_default_library",
|
||||
],
|
||||
)
|
||||
333
tools/analyzers/logcapitalization/analyzer.go
Normal file
333
tools/analyzers/logcapitalization/analyzer.go
Normal file
@@ -0,0 +1,333 @@
|
||||
// Package logcapitalization implements a static analyzer to ensure all log messages
|
||||
// start with a capitalized letter for consistent log formatting.
|
||||
package logcapitalization
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/tools/go/analysis"
|
||||
"golang.org/x/tools/go/analysis/passes/inspect"
|
||||
"golang.org/x/tools/go/ast/inspector"
|
||||
)
|
||||
|
||||
// Doc explaining the tool.
|
||||
const Doc = "Tool to enforce that all log messages start with a capitalized letter"
|
||||
|
||||
var errLogNotCapitalized = errors.New("log message should start with a capitalized letter for consistent formatting")
|
||||
|
||||
// Analyzer runs static analysis.
|
||||
var Analyzer = &analysis.Analyzer{
|
||||
Name: "logcapitalization",
|
||||
Doc: Doc,
|
||||
Requires: []*analysis.Analyzer{inspect.Analyzer},
|
||||
Run: run,
|
||||
}
|
||||
|
||||
func run(pass *analysis.Pass) (interface{}, error) {
|
||||
inspection, ok := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
|
||||
if !ok {
|
||||
return nil, errors.New("analyzer is not type *inspector.Inspector")
|
||||
}
|
||||
|
||||
nodeFilter := []ast.Node{
|
||||
(*ast.CallExpr)(nil),
|
||||
(*ast.File)(nil),
|
||||
}
|
||||
|
||||
// Track imports that might be used for logging
|
||||
hasLogImport := false
|
||||
logPackageAliases := make(map[string]bool)
|
||||
|
||||
// Common logging functions that output messages
|
||||
logFunctions := []string{
|
||||
// logrus
|
||||
"Info", "Infof", "InfoWithFields",
|
||||
"Debug", "Debugf", "DebugWithFields",
|
||||
"Warn", "Warnf", "WarnWithFields",
|
||||
"Error", "ErrorWithFields",
|
||||
"Fatal", "Fatalf", "FatalWithFields",
|
||||
"Panic", "Panicf", "PanicWithFields",
|
||||
"Print", "Printf", "Println",
|
||||
"Log", "Logf",
|
||||
// standard log
|
||||
"Print", "Printf", "Println",
|
||||
"Fatal", "Fatalf", "Fatalln",
|
||||
"Panic", "Panicf", "Panicln",
|
||||
// fmt excluded - often used for user prompts, not logging
|
||||
}
|
||||
|
||||
inspection.Preorder(nodeFilter, func(node ast.Node) {
|
||||
switch stmt := node.(type) {
|
||||
case *ast.File:
|
||||
// Reset per file
|
||||
hasLogImport = false
|
||||
logPackageAliases = make(map[string]bool)
|
||||
|
||||
// Check imports for logging packages
|
||||
for _, imp := range stmt.Imports {
|
||||
if imp.Path != nil {
|
||||
path := strings.Trim(imp.Path.Value, "\"")
|
||||
if isLoggingPackage(path) {
|
||||
hasLogImport = true
|
||||
|
||||
// Track package alias
|
||||
if imp.Name != nil {
|
||||
logPackageAliases[imp.Name.Name] = true
|
||||
} else {
|
||||
// Default package name from path
|
||||
parts := strings.Split(path, "/")
|
||||
if len(parts) > 0 {
|
||||
logPackageAliases[parts[len(parts)-1]] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case *ast.CallExpr:
|
||||
if !hasLogImport {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a logging function call
|
||||
if !isLoggingCall(stmt, logFunctions, logPackageAliases) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check the first argument (message) for capitalization
|
||||
if len(stmt.Args) > 0 {
|
||||
firstArg := stmt.Args[0]
|
||||
|
||||
// Check if it's a format function (like Printf, Infof)
|
||||
if isFormatFunction(stmt) {
|
||||
checkFormatStringCapitalization(firstArg, pass, node)
|
||||
} else {
|
||||
checkMessageCapitalization(firstArg, pass, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// isLoggingPackage checks if the import path is a logging package
|
||||
func isLoggingPackage(path string) bool {
|
||||
loggingPaths := []string{
|
||||
"github.com/sirupsen/logrus",
|
||||
"log",
|
||||
"github.com/rs/zerolog",
|
||||
"go.uber.org/zap",
|
||||
"github.com/golang/glog",
|
||||
"k8s.io/klog",
|
||||
}
|
||||
|
||||
for _, logPath := range loggingPaths {
|
||||
if strings.Contains(path, logPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isLoggingCall checks if the call expression is a logging function
|
||||
func isLoggingCall(call *ast.CallExpr, logFunctions []string, aliases map[string]bool) bool {
|
||||
var functionName string
|
||||
var packageName string
|
||||
|
||||
switch fun := call.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
// Direct function call
|
||||
functionName = fun.Name
|
||||
case *ast.SelectorExpr:
|
||||
// Package.Function call
|
||||
functionName = fun.Sel.Name
|
||||
if ident, ok := fun.X.(*ast.Ident); ok {
|
||||
packageName = ident.Name
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a logging function
|
||||
for _, logFunc := range logFunctions {
|
||||
if functionName == logFunc {
|
||||
// If no package specified, could be a logging call
|
||||
if packageName == "" {
|
||||
return true
|
||||
}
|
||||
// Check if package is a known logging package alias
|
||||
if aliases[packageName] {
|
||||
return true
|
||||
}
|
||||
// Check for common logging package names
|
||||
if isCommonLogPackage(packageName) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isCommonLogPackage checks for common logging package names
|
||||
func isCommonLogPackage(pkg string) bool {
|
||||
common := []string{"log", "logrus", "zerolog", "zap", "glog", "klog"}
|
||||
for _, c := range common {
|
||||
if pkg == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isFormatFunction checks if this is a format function (ending with 'f')
|
||||
func isFormatFunction(call *ast.CallExpr) bool {
|
||||
switch fun := call.Fun.(type) {
|
||||
case *ast.Ident:
|
||||
return strings.HasSuffix(fun.Name, "f")
|
||||
case *ast.SelectorExpr:
|
||||
return strings.HasSuffix(fun.Sel.Name, "f")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// checkFormatStringCapitalization checks if format strings start with capital letter
|
||||
func checkFormatStringCapitalization(expr ast.Expr, pass *analysis.Pass, node ast.Node) {
|
||||
if basicLit, ok := expr.(*ast.BasicLit); ok && basicLit.Kind == token.STRING {
|
||||
if len(basicLit.Value) >= 3 { // At least quotes + one character
|
||||
unquoted, err := strconv.Unquote(basicLit.Value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !isCapitalized(unquoted) {
|
||||
pass.Reportf(expr.Pos(),
|
||||
"%s: format string should start with a capital letter (found: %q)",
|
||||
errLogNotCapitalized.Error(),
|
||||
getFirstWord(unquoted))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkMessageCapitalization checks if message strings start with capital letter
|
||||
func checkMessageCapitalization(expr ast.Expr, pass *analysis.Pass, node ast.Node) {
|
||||
switch e := expr.(type) {
|
||||
case *ast.BasicLit:
|
||||
if e.Kind == token.STRING && len(e.Value) >= 3 {
|
||||
unquoted, err := strconv.Unquote(e.Value)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if !isCapitalized(unquoted) {
|
||||
pass.Reportf(expr.Pos(),
|
||||
"%s: log message should start with a capital letter (found: %q)",
|
||||
errLogNotCapitalized.Error(),
|
||||
getFirstWord(unquoted))
|
||||
}
|
||||
}
|
||||
case *ast.BinaryExpr:
|
||||
// For string concatenation, check the first part
|
||||
if e.Op == token.ADD {
|
||||
checkMessageCapitalization(e.X, pass, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isCapitalized checks if a string starts with a capital letter
|
||||
func isCapitalized(s string) bool {
|
||||
if len(s) == 0 {
|
||||
return true // Empty strings are OK
|
||||
}
|
||||
|
||||
// Skip leading whitespace
|
||||
trimmed := strings.TrimLeft(s, " \t\n\r")
|
||||
if len(trimmed) == 0 {
|
||||
return true // Only whitespace is OK
|
||||
}
|
||||
|
||||
// Get the first character
|
||||
firstRune := []rune(trimmed)[0]
|
||||
|
||||
// Check for special cases that are acceptable
|
||||
if isAcceptableStart(firstRune, trimmed) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Must be uppercase letter
|
||||
return unicode.IsUpper(firstRune)
|
||||
}
|
||||
|
||||
// isAcceptableStart checks for acceptable ways to start log messages
|
||||
func isAcceptableStart(firstRune rune, s string) bool {
|
||||
// Numbers are OK
|
||||
if unicode.IsDigit(firstRune) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Special characters that are OK to start with
|
||||
acceptableChars := []rune{'%', '$', '/', '\\', '[', '(', '{', '"', '\'', '`', '-'}
|
||||
for _, char := range acceptableChars {
|
||||
if firstRune == char {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// URLs/paths are OK
|
||||
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "file://") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Command line flags are OK (--flag, -flag)
|
||||
if strings.HasPrefix(s, "--") || (strings.HasPrefix(s, "-") && len(s) > 1 && unicode.IsLetter([]rune(s)[1])) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Configuration keys or technical terms in lowercase are sometimes OK
|
||||
if strings.Contains(s, "=") || strings.Contains(s, ":") {
|
||||
// Looks like a key=value or key: value format
|
||||
return true
|
||||
}
|
||||
|
||||
// Technical keywords that are acceptable in lowercase
|
||||
technicalKeywords := []string{"gRPC"}
|
||||
|
||||
// Check if the string starts with any technical keyword
|
||||
lowerS := strings.ToLower(s)
|
||||
for _, keyword := range technicalKeywords {
|
||||
if strings.HasPrefix(lowerS, strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// getFirstWord extracts the first few characters for error reporting
|
||||
func getFirstWord(s string) string {
|
||||
trimmed := strings.TrimLeft(s, " \t\n\r")
|
||||
if len(trimmed) == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
words := strings.Fields(trimmed)
|
||||
if len(words) > 0 {
|
||||
if len(words[0]) > 20 {
|
||||
return words[0][:20] + "..."
|
||||
}
|
||||
return words[0]
|
||||
}
|
||||
|
||||
// Fallback to first 20 characters
|
||||
if len(trimmed) > 20 {
|
||||
return trimmed[:20] + "..."
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
21
tools/analyzers/logcapitalization/analyzer_test.go
Normal file
21
tools/analyzers/logcapitalization/analyzer_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package logcapitalization_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/tools/go/analysis/analysistest"
|
||||
|
||||
"github.com/OffchainLabs/prysm/v6/build/bazel"
|
||||
"github.com/OffchainLabs/prysm/v6/tools/analyzers/logcapitalization"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if bazel.BuiltWithBazel() {
|
||||
bazel.SetGoEnv()
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzer(t *testing.T) {
|
||||
testdata := analysistest.TestData()
|
||||
analysistest.RunWithSuggestedFixes(t, testdata, logcapitalization.Analyzer, "a")
|
||||
}
|
||||
65
tools/analyzers/logcapitalization/testdata/src/a/a.go
vendored
Normal file
65
tools/analyzers/logcapitalization/testdata/src/a/a.go
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
package testdata
|
||||
|
||||
import (
|
||||
logrus "log" // Use standard log package as alias to simulate logrus
|
||||
)
|
||||
|
||||
func BadCapitalization() {
|
||||
// These should trigger the analyzer
|
||||
logrus.Print("hello world") // want "log message should start with a capital letter"
|
||||
logrus.Printf("starting the process") // want "format string should start with a capital letter"
|
||||
|
||||
// Simulating logrus-style calls
|
||||
Info("connection failed") // want "log message should start with a capital letter"
|
||||
Infof("failed to process %d blocks", 5) // want "format string should start with a capital letter"
|
||||
Error("low disk space") // want "log message should start with a capital letter"
|
||||
Debug("processing attestation") // want "log message should start with a capital letter"
|
||||
|
||||
// More examples
|
||||
Warn("validator not found") // want "log message should start with a capital letter"
|
||||
}
|
||||
|
||||
func GoodCapitalization() {
|
||||
// These should NOT trigger the analyzer
|
||||
logrus.Print("Hello world")
|
||||
logrus.Printf("Starting the beacon chain process")
|
||||
|
||||
// Simulating logrus-style calls with proper capitalization
|
||||
Info("Connection established successfully")
|
||||
Infof("Processing %d blocks in epoch %d", 5, 100)
|
||||
Error("Connection failed with timeout")
|
||||
Errorf("Failed to process %d blocks", 5)
|
||||
Warn("Low disk space detected")
|
||||
Debug("Processing attestation for validator")
|
||||
|
||||
// Fun blockchain-specific examples with proper capitalization
|
||||
Info("Validator activated successfully")
|
||||
Info("New block mined with hash 0x123abc")
|
||||
Info("Checkpoint finalized at epoch 50000")
|
||||
Info("Sync committee duties assigned")
|
||||
Info("Fork choice updated to new head")
|
||||
|
||||
// Acceptable edge cases - these should NOT trigger
|
||||
Info("404 validator not found") // Numbers are OK
|
||||
Info("/eth/v1/beacon/blocks endpoint") // Paths are OK
|
||||
Info("config=mainnet") // Config format is OK
|
||||
Info("https://beacon-node.example.com") // URLs are OK
|
||||
Infof("%s network started", "mainnet") // Format specifiers are OK
|
||||
Debug("--weak-subjectivity-checkpoint not provided") // CLI flags are OK
|
||||
Debug("-v flag enabled") // Single dash flags are OK
|
||||
Info("--datadir=/tmp/beacon") // Flags with values are OK
|
||||
|
||||
// Empty or whitespace
|
||||
Info("") // Empty is OK
|
||||
Info(" ") // Just whitespace is OK
|
||||
}
|
||||
|
||||
// Mock logrus-style functions for testing
|
||||
func Info(msg string) { logrus.Print(msg) }
|
||||
func Infof(format string, args ...any) { logrus.Printf(format, args...) }
|
||||
func Error(msg string) { logrus.Print(msg) }
|
||||
func Errorf(format string, args ...any) { logrus.Printf(format, args...) }
|
||||
func Warn(msg string) { logrus.Print(msg) }
|
||||
func Warnf(format string, args ...any) { logrus.Printf(format, args...) }
|
||||
func Debug(msg string) { logrus.Print(msg) }
|
||||
func Debugf(format string, args ...any) { logrus.Printf(format, args...) }
|
||||
Reference in New Issue
Block a user