Files
Devour/internal/quality/plugins/go/analyzers/detectors.go
T
Tomas Dvorak 898a3c303f update
2026-02-24 10:33:59 +01:00

505 lines
13 KiB
Go

package analyzers
import (
"context"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"github.com/yourorg/devour/internal/quality"
)
type LargeFileDetector struct {
*quality.BaseDetector
maxLOC int
}
func NewLargeFileDetector(finder quality.FileFinder) *LargeFileDetector {
return &LargeFileDetector{
BaseDetector: quality.NewBaseDetector("large_file", quality.SeverityT3, finder),
maxLOC: 500,
}
}
func (d *LargeFileDetector) Name() string {
return "large_file"
}
func (d *LargeFileDetector) Severity() quality.Severity {
return quality.SeverityT3
}
func (d *LargeFileDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
files, err := d.FindFiles(path, "go")
if err != nil {
return nil, err
}
var findings []quality.Finding
for _, file := range files {
loc, err := countLines(file)
if err != nil {
continue
}
if loc > d.maxLOC {
finding := quality.Finding{
ID: fmt.Sprintf("large_file::%s", file),
Type: "large_file",
Title: fmt.Sprintf("Large file detected: %d lines", loc),
Description: fmt.Sprintf("File '%s' has %d lines (max: %d). Consider splitting into smaller, focused files.", filepath.Base(file), loc, d.maxLOC),
File: file,
Line: 1,
Severity: quality.SeverityT3,
Score: (loc - d.maxLOC) / 50,
Status: quality.StatusOpen,
Metadata: map[string]string{
"loc": fmt.Sprintf("%d", loc),
"max_loc": fmt.Sprintf("%d", d.maxLOC),
},
}
findings = append(findings, finding)
}
}
return findings, nil
}
type GodStructDetector struct {
*quality.BaseDetector
maxFields int
maxMethods int
}
func NewGodStructDetector(finder quality.FileFinder) *GodStructDetector {
return &GodStructDetector{
BaseDetector: quality.NewBaseDetector("god_struct", quality.SeverityT3, finder),
maxFields: 15,
maxMethods: 20,
}
}
func (d *GodStructDetector) Name() string {
return "god_struct"
}
func (d *GodStructDetector) Severity() quality.Severity {
return quality.SeverityT3
}
func (d *GodStructDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
files, err := d.FindFiles(path, "go")
if err != nil {
return nil, err
}
var findings []quality.Finding
for _, file := range files {
fileFindings := d.analyzeFile(file)
findings = append(findings, fileFindings...)
}
return findings, nil
}
func (d *GodStructDetector) analyzeFile(path string) []quality.Finding {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
methodCounts := make(map[string]int)
for _, decl := range node.Decls {
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv != nil {
for _, field := range fn.Recv.List {
for _, name := range field.Names {
methodCounts[name.Name]++
}
if len(field.Names) == 0 {
if star, ok := field.Type.(*ast.StarExpr); ok {
if ident, ok := star.X.(*ast.Ident); ok {
methodCounts[ident.Name]++
}
} else if ident, ok := field.Type.(*ast.Ident); ok {
methodCounts[ident.Name]++
}
}
}
}
}
var findings []quality.Finding
for _, decl := range node.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.TYPE {
continue
}
for _, spec := range gen.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
fieldCount := len(structType.Fields.List)
methodCount := methodCounts[typeSpec.Name.Name]
if fieldCount > d.maxFields {
pos := fset.Position(typeSpec.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("god_struct::%s::%s", path, typeSpec.Name.Name),
Type: "god_struct",
Title: fmt.Sprintf("God struct detected: %s", typeSpec.Name.Name),
Description: fmt.Sprintf("Struct '%s' has %d fields (max: %d). Consider breaking it into smaller, focused structs.", typeSpec.Name.Name, fieldCount, d.maxFields),
File: path,
Line: pos.Line,
Severity: quality.SeverityT3,
Score: (fieldCount - d.maxFields) * 2,
Status: quality.StatusOpen,
Metadata: map[string]string{
"struct_name": typeSpec.Name.Name,
"field_count": fmt.Sprintf("%d", fieldCount),
"max_fields": fmt.Sprintf("%d", d.maxFields),
},
}
findings = append(findings, finding)
}
if methodCount > d.maxMethods {
pos := fset.Position(typeSpec.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("god_struct_methods::%s::%s", path, typeSpec.Name.Name),
Type: "god_struct",
Title: fmt.Sprintf("God struct (methods): %s", typeSpec.Name.Name),
Description: fmt.Sprintf("Struct '%s' has %d methods (max: %d). Consider splitting responsibilities.", typeSpec.Name.Name, methodCount, d.maxMethods),
File: path,
Line: pos.Line,
Severity: quality.SeverityT3,
Score: (methodCount - d.maxMethods) * 2,
Status: quality.StatusOpen,
Metadata: map[string]string{
"struct_name": typeSpec.Name.Name,
"method_count": fmt.Sprintf("%d", methodCount),
"max_methods": fmt.Sprintf("%d", d.maxMethods),
},
}
findings = append(findings, finding)
}
}
}
return findings
}
type DebugLogDetector struct {
*quality.BaseDetector
}
func NewDebugLogDetector(finder quality.FileFinder) *DebugLogDetector {
return &DebugLogDetector{
BaseDetector: quality.NewBaseDetector("debug_log", quality.SeverityT1, finder),
}
}
func (d *DebugLogDetector) Name() string {
return "debug_log"
}
func (d *DebugLogDetector) Severity() quality.Severity {
return quality.SeverityT1
}
func (d *DebugLogDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
files, err := d.FindFiles(path, "go")
if err != nil {
return nil, err
}
var findings []quality.Finding
for _, file := range files {
fileFindings := d.analyzeFile(file)
findings = append(findings, fileFindings...)
}
return findings, nil
}
func (d *DebugLogDetector) analyzeFile(path string) []quality.Finding {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
normPath := filepath.ToSlash(path)
if strings.Contains(normPath, "internal/ui/") || strings.Contains(normPath, "examples/") {
return nil
}
debugPatterns := []string{
"log.Print",
"log.Println",
"log.Printf",
"log.Fatal",
"log.Fatalf",
"log.Fatalln",
}
cliPatterns := []string{
"fmt.Print",
"fmt.Println",
"fmt.Printf",
}
var findings []quality.Finding
ast.Inspect(node, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
callStr := exprToString(call.Fun)
for _, pattern := range debugPatterns {
if callStr == pattern || strings.HasPrefix(callStr, pattern) {
if strings.HasSuffix(normPath, "_test.go") || strings.HasPrefix(normPath, "cmd/") || strings.Contains(normPath, "/cmd/") {
return true
}
pos := fset.Position(call.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("debug_log::%s::%d", path, pos.Line),
Type: "debug_log",
Title: "Debug log statement detected",
Description: fmt.Sprintf("Found '%s' statement. Consider using structured logging instead.", callStr),
File: path,
Line: pos.Line,
Severity: quality.SeverityT1,
Score: 2,
Status: quality.StatusOpen,
Metadata: map[string]string{
"call": callStr,
},
}
findings = append(findings, finding)
break
}
}
if strings.HasPrefix(normPath, "cmd/") || strings.Contains(normPath, "/cmd/") {
return true
}
for _, pattern := range cliPatterns {
if callStr == pattern || strings.HasPrefix(callStr, pattern) {
pos := fset.Position(call.Pos())
finding := quality.Finding{
ID: fmt.Sprintf("debug_log::%s::%d", path, pos.Line),
Type: "debug_log",
Title: "Potential debug print in non-CLI code",
Description: fmt.Sprintf("Found '%s' in library code. Consider using structured logging or returning errors.", callStr),
File: path,
Line: pos.Line,
Severity: quality.SeverityT1,
Score: 2,
Status: quality.StatusOpen,
Metadata: map[string]string{
"call": callStr,
},
}
findings = append(findings, finding)
break
}
}
return true
})
return findings
}
type GodFunctionDetector struct {
*quality.BaseDetector
maxLOC int
maxParams int
maxReturns int
maxNesting int
}
func NewGodFunctionDetector(finder quality.FileFinder) *GodFunctionDetector {
return &GodFunctionDetector{
BaseDetector: quality.NewBaseDetector("god_function", quality.SeverityT3, finder),
maxLOC: 50,
maxParams: 5,
maxReturns: 3,
maxNesting: 4,
}
}
func (d *GodFunctionDetector) Name() string {
return "god_function"
}
func (d *GodFunctionDetector) Severity() quality.Severity {
return quality.SeverityT3
}
func (d *GodFunctionDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
files, err := d.FindFiles(path, "go")
if err != nil {
return nil, err
}
var findings []quality.Finding
for _, file := range files {
fileFindings := d.analyzeFile(file)
findings = append(findings, fileFindings...)
}
return findings, nil
}
func (d *GodFunctionDetector) analyzeFile(path string) []quality.Finding {
fset := token.NewFileSet()
node, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
return nil
}
var findings []quality.Finding
for _, decl := range node.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok {
continue
}
startLine := fset.Position(fn.Pos()).Line
endLine := fset.Position(fn.End()).Line
loc := endLine - startLine + 1
paramCount := 0
if fn.Type.Params != nil {
for _, field := range fn.Type.Params.List {
paramCount += len(field.Names)
if len(field.Names) == 0 {
paramCount++
}
}
}
returnCount := 0
if fn.Type.Results != nil {
returnCount = len(fn.Type.Results.List)
}
nestingDepth := d.calculateNesting(fn)
var issues []string
if loc > d.maxLOC {
issues = append(issues, fmt.Sprintf("%d lines (max %d)", loc, d.maxLOC))
}
if paramCount > d.maxParams {
issues = append(issues, fmt.Sprintf("%d params (max %d)", paramCount, d.maxParams))
}
if returnCount > d.maxReturns {
issues = append(issues, fmt.Sprintf("%d returns (max %d)", returnCount, d.maxReturns))
}
if nestingDepth > d.maxNesting {
issues = append(issues, fmt.Sprintf("nesting depth %d (max %d)", nestingDepth, d.maxNesting))
}
if len(issues) > 0 {
finding := quality.Finding{
ID: fmt.Sprintf("god_function::%s::%s", path, fn.Name.Name),
Type: "god_function",
Title: fmt.Sprintf("God function: %s", fn.Name.Name),
Description: fmt.Sprintf("Function '%s' has issues: %s", fn.Name.Name, strings.Join(issues, ", ")),
File: path,
Line: startLine,
Severity: quality.SeverityT3,
Score: len(issues) * 3,
Status: quality.StatusOpen,
Metadata: map[string]string{
"function": fn.Name.Name,
"loc": fmt.Sprintf("%d", loc),
"params": fmt.Sprintf("%d", paramCount),
"returns": fmt.Sprintf("%d", returnCount),
"nesting_depth": fmt.Sprintf("%d", nestingDepth),
},
}
findings = append(findings, finding)
}
}
return findings
}
func (d *GodFunctionDetector) calculateNesting(fn *ast.FuncDecl) int {
maxDepth := 0
var visit func(n ast.Node, depth int)
visit = func(n ast.Node, depth int) {
if depth > maxDepth {
maxDepth = depth
}
switch stmt := n.(type) {
case *ast.IfStmt:
visit(stmt.Body, depth+1)
if stmt.Else != nil {
visit(stmt.Else, depth+1)
}
case *ast.ForStmt:
visit(stmt.Body, depth+1)
case *ast.RangeStmt:
visit(stmt.Body, depth+1)
case *ast.SwitchStmt:
visit(stmt.Body, depth+1)
case *ast.SelectStmt:
visit(stmt.Body, depth+1)
case *ast.BlockStmt:
for _, s := range stmt.List {
visit(s, depth)
}
case *ast.CaseClause:
for _, s := range stmt.Body {
visit(s, depth)
}
}
}
if fn.Body != nil {
visit(fn.Body, 0)
}
return maxDepth
}
func exprToString(expr ast.Expr) string {
switch e := expr.(type) {
case *ast.Ident:
return e.Name
case *ast.SelectorExpr:
return exprToString(e.X) + "." + e.Sel.Name
default:
return ""
}
}
func countLines(path string) (int, error) {
data, err := os.ReadFile(path)
if err != nil {
return 0, err
}
return strings.Count(string(data), "\n") + 1, nil
}