mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-04 04:23:02 +00:00
426 lines
12 KiB
Go
426 lines
12 KiB
Go
package analyzers
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"go/ast"
|
|
"go/token"
|
|
"strings"
|
|
|
|
"github.com/yourorg/devour/internal/quality"
|
|
"golang.org/x/tools/go/packages"
|
|
)
|
|
|
|
type ControlFlowAnalyzer struct {
|
|
maxComplexity int
|
|
maxNesting int
|
|
maxFunctionLength int
|
|
}
|
|
|
|
func NewControlFlowAnalyzer() *ControlFlowAnalyzer {
|
|
return &ControlFlowAnalyzer{
|
|
maxComplexity: 15,
|
|
maxNesting: 4,
|
|
maxFunctionLength: 50,
|
|
}
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) Name() string {
|
|
return "controlflow"
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) Severity() quality.Severity {
|
|
return quality.SeverityT3
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
|
cfg := &packages.Config{
|
|
Mode: packages.NeedName | packages.NeedFiles | packages.NeedSyntax,
|
|
Dir: path,
|
|
}
|
|
|
|
pkgs, err := packages.Load(cfg, "./...")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load packages: %w", err)
|
|
}
|
|
|
|
var findings []quality.Finding
|
|
|
|
for _, pkg := range pkgs {
|
|
for _, file := range pkg.Syntax {
|
|
pos := pkg.Fset.Position(file.Pos())
|
|
findings = append(findings, a.analyzeFile(pkg.Fset, file, pos.Filename)...)
|
|
}
|
|
}
|
|
|
|
return findings, nil
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) analyzeFile(fset *token.FileSet, file *ast.File, filename string) []quality.Finding {
|
|
var findings []quality.Finding
|
|
|
|
ast.Inspect(file, func(n ast.Node) bool {
|
|
switch node := n.(type) {
|
|
case *ast.FuncDecl:
|
|
findings = append(findings, a.analyzeFunction(fset, node, filename)...)
|
|
case *ast.IfStmt:
|
|
findings = append(findings, a.checkUnreachableCode(fset, node, filename)...)
|
|
case *ast.SwitchStmt:
|
|
findings = append(findings, a.analyzeSwitch(fset, node, filename)...)
|
|
case *ast.ForStmt:
|
|
findings = append(findings, a.analyzeLoop(fset, node, filename)...)
|
|
case *ast.RangeStmt:
|
|
findings = append(findings, a.analyzeRange(fset, node, filename)...)
|
|
}
|
|
return true
|
|
})
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) analyzeFunction(fset *token.FileSet, fn *ast.FuncDecl, filename string) []quality.Finding {
|
|
var findings []quality.Finding
|
|
|
|
complexity := a.calculateCyclomaticComplexity(fn.Body)
|
|
startPos := fset.Position(fn.Pos())
|
|
endPos := fset.Position(fn.End())
|
|
loc := endPos.Line - startPos.Line + 1
|
|
|
|
if complexity > a.maxComplexity {
|
|
severity := quality.SeverityT3
|
|
score := complexity - a.maxComplexity
|
|
if complexity > a.maxComplexity*2 {
|
|
severity = quality.SeverityT4
|
|
score = (complexity - a.maxComplexity) * 2
|
|
}
|
|
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("cyclomatic-complexity::%s::%d", filename, startPos.Line),
|
|
Type: "complexity",
|
|
Title: fmt.Sprintf("High cyclomatic complexity in %s", fn.Name.Name),
|
|
Description: fmt.Sprintf("Function '%s' has cyclomatic complexity of %d (max: %d). Consider breaking it into smaller functions.", fn.Name.Name, complexity, a.maxComplexity),
|
|
File: filename,
|
|
Line: startPos.Line,
|
|
EndLine: endPos.Line,
|
|
Severity: severity,
|
|
Score: score,
|
|
Status: quality.StatusOpen,
|
|
Metadata: map[string]string{
|
|
"function": fn.Name.Name,
|
|
"complexity": fmt.Sprintf("%d", complexity),
|
|
"max": fmt.Sprintf("%d", a.maxComplexity),
|
|
},
|
|
})
|
|
}
|
|
|
|
if loc > a.maxFunctionLength {
|
|
severity := quality.SeverityT2
|
|
if loc > a.maxFunctionLength*2 {
|
|
severity = quality.SeverityT3
|
|
}
|
|
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("function-length::%s::%d", filename, startPos.Line),
|
|
Type: "complexity",
|
|
Title: fmt.Sprintf("Function too long: %s", fn.Name.Name),
|
|
Description: fmt.Sprintf("Function '%s' is %d lines (max: %d). Consider breaking it into smaller functions.", fn.Name.Name, loc, a.maxFunctionLength),
|
|
File: filename,
|
|
Line: startPos.Line,
|
|
Severity: severity,
|
|
Score: (loc - a.maxFunctionLength) / 10,
|
|
Status: quality.StatusOpen,
|
|
Metadata: map[string]string{
|
|
"function": fn.Name.Name,
|
|
"loc": fmt.Sprintf("%d", loc),
|
|
"max": fmt.Sprintf("%d", a.maxFunctionLength),
|
|
},
|
|
})
|
|
}
|
|
|
|
maxNesting := a.calculateMaxNesting(fn.Body)
|
|
if maxNesting > a.maxNesting {
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("deep-nesting::%s::%d", filename, startPos.Line),
|
|
Type: "complexity",
|
|
Title: fmt.Sprintf("Deep nesting in %s", fn.Name.Name),
|
|
Description: fmt.Sprintf("Function '%s' has nesting depth of %d (max: %d). Extract nested code into separate functions.", fn.Name.Name, maxNesting, a.maxNesting),
|
|
File: filename,
|
|
Line: startPos.Line,
|
|
Severity: quality.SeverityT3,
|
|
Score: maxNesting - a.maxNesting,
|
|
Status: quality.StatusOpen,
|
|
Metadata: map[string]string{
|
|
"function": fn.Name.Name,
|
|
"nesting": fmt.Sprintf("%d", maxNesting),
|
|
},
|
|
})
|
|
}
|
|
|
|
findings = append(findings, a.checkEarlyReturn(fset, fn, filename)...)
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) calculateCyclomaticComplexity(node ast.Node) int {
|
|
complexity := 1
|
|
|
|
ast.Inspect(node, func(n ast.Node) bool {
|
|
switch n := n.(type) {
|
|
case *ast.IfStmt:
|
|
complexity++
|
|
case *ast.ForStmt:
|
|
complexity++
|
|
case *ast.RangeStmt:
|
|
complexity++
|
|
case *ast.CaseClause:
|
|
complexity++
|
|
case *ast.BinaryExpr:
|
|
if n.Op == token.LAND || n.Op == token.LOR {
|
|
complexity++
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
return complexity
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) calculateMaxNesting(node ast.Node) int {
|
|
return a.nestingDepth(node, 0)
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) nestingDepth(node ast.Node, current int) int {
|
|
maxDepth := current
|
|
|
|
ast.Inspect(node, func(n ast.Node) bool {
|
|
var childNode ast.Node
|
|
|
|
switch stmt := n.(type) {
|
|
case *ast.IfStmt:
|
|
childNode = stmt.Body
|
|
case *ast.ForStmt:
|
|
childNode = stmt.Body
|
|
case *ast.RangeStmt:
|
|
childNode = stmt.Body
|
|
case *ast.SelectStmt:
|
|
childNode = stmt.Body
|
|
case *ast.SwitchStmt:
|
|
childNode = stmt.Body
|
|
case *ast.TypeSwitchStmt:
|
|
childNode = stmt.Body
|
|
case *ast.BlockStmt:
|
|
childNode = nil
|
|
default:
|
|
return true
|
|
}
|
|
|
|
if childNode != nil {
|
|
depth := a.nestingDepth(childNode, current+1)
|
|
if depth > maxDepth {
|
|
maxDepth = depth
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
return maxDepth
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) checkEarlyReturn(fset *token.FileSet, fn *ast.FuncDecl, filename string) []quality.Finding {
|
|
var findings []quality.Finding
|
|
|
|
if fn.Body == nil || len(fn.Body.List) < 2 {
|
|
return findings
|
|
}
|
|
|
|
ifStmt, ok := fn.Body.List[0].(*ast.IfStmt)
|
|
if !ok || ifStmt.Else == nil {
|
|
return findings
|
|
}
|
|
|
|
if _, ok := ifStmt.Else.(*ast.BlockStmt); ok && len(fn.Body.List) > 1 {
|
|
startPos := fset.Position(ifStmt.Pos())
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("early-return::%s::%d", filename, startPos.Line),
|
|
Type: "quality",
|
|
Title: fmt.Sprintf("Use early return pattern in %s", fn.Name.Name),
|
|
Description: "Consider using early return instead of if-else to reduce nesting and improve readability.",
|
|
File: filename,
|
|
Line: startPos.Line,
|
|
Severity: quality.SeverityT1,
|
|
Score: 1,
|
|
Status: quality.StatusOpen,
|
|
Metadata: map[string]string{
|
|
"function": fn.Name.Name,
|
|
},
|
|
})
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) checkUnreachableCode(fset *token.FileSet, stmt *ast.IfStmt, filename string) []quality.Finding {
|
|
var findings []quality.Finding
|
|
|
|
a.checkUnreachableInBranch(fset, stmt.Body, filename, &findings)
|
|
if stmt.Else != nil {
|
|
if elseBlock, ok := stmt.Else.(*ast.BlockStmt); ok {
|
|
a.checkUnreachableInBranch(fset, elseBlock, filename, &findings)
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) checkUnreachableInBranch(fset *token.FileSet, block *ast.BlockStmt, filename string, findings *[]quality.Finding) {
|
|
hasReturn := false
|
|
for _, stmt := range block.List {
|
|
if hasReturn {
|
|
pos := fset.Position(stmt.Pos())
|
|
*findings = append(*findings, quality.Finding{
|
|
ID: fmt.Sprintf("unreachable::%s::%d", filename, pos.Line),
|
|
Type: "dead_code",
|
|
Title: "Unreachable code after return",
|
|
Description: "Code after return statement will never be executed.",
|
|
File: filename,
|
|
Line: pos.Line,
|
|
Severity: quality.SeverityT2,
|
|
Score: 3,
|
|
Status: quality.StatusOpen,
|
|
})
|
|
break
|
|
}
|
|
if _, ok := stmt.(*ast.ReturnStmt); ok {
|
|
hasReturn = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) analyzeSwitch(fset *token.FileSet, stmt *ast.SwitchStmt, filename string) []quality.Finding {
|
|
var findings []quality.Finding
|
|
|
|
pos := fset.Position(stmt.Pos())
|
|
|
|
hasDefault := false
|
|
caseCount := 0
|
|
|
|
for _, s := range stmt.Body.List {
|
|
if clause, ok := s.(*ast.CaseClause); ok {
|
|
caseCount++
|
|
if clause.List == nil {
|
|
hasDefault = true
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasDefault && caseCount > 0 {
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("switch-no-default::%s::%d", filename, pos.Line),
|
|
Type: "quality",
|
|
Title: "Switch without default case",
|
|
Description: "Switch statement lacks a default case. Consider handling unexpected values explicitly.",
|
|
File: filename,
|
|
Line: pos.Line,
|
|
Severity: quality.SeverityT1,
|
|
Score: 1,
|
|
Status: quality.StatusOpen,
|
|
})
|
|
}
|
|
|
|
if caseCount > 10 {
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("switch-too-many-cases::%s::%d", filename, pos.Line),
|
|
Type: "complexity",
|
|
Title: "Switch with too many cases",
|
|
Description: fmt.Sprintf("Switch has %d cases. Consider using a map or polymorphism instead.", caseCount),
|
|
File: filename,
|
|
Line: pos.Line,
|
|
Severity: quality.SeverityT2,
|
|
Score: caseCount / 5,
|
|
Status: quality.StatusOpen,
|
|
Metadata: map[string]string{
|
|
"case_count": fmt.Sprintf("%d", caseCount),
|
|
},
|
|
})
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) analyzeLoop(fset *token.FileSet, stmt *ast.ForStmt, filename string) []quality.Finding {
|
|
var findings []quality.Finding
|
|
|
|
pos := fset.Position(stmt.Pos())
|
|
|
|
if stmt.Cond == nil && stmt.Post == nil {
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("infinite-loop::%s::%d", filename, pos.Line),
|
|
Type: "quality",
|
|
Title: "Potential infinite loop",
|
|
Description: "For loop has no condition and no post statement. Ensure there's a break inside.",
|
|
File: filename,
|
|
Line: pos.Line,
|
|
Severity: quality.SeverityT3,
|
|
Score: 4,
|
|
Status: quality.StatusOpen,
|
|
})
|
|
}
|
|
|
|
if strings.Contains(fmt.Sprintf("%v", stmt.Cond), "== true") {
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("redundant-bool-compare::%s::%d", filename, pos.Line),
|
|
Type: "quality",
|
|
Title: "Redundant boolean comparison",
|
|
Description: "Comparing to 'true' is redundant. Use the boolean value directly.",
|
|
File: filename,
|
|
Line: pos.Line,
|
|
Severity: quality.SeverityT1,
|
|
Score: 1,
|
|
Status: quality.StatusOpen,
|
|
})
|
|
}
|
|
|
|
return findings
|
|
}
|
|
|
|
func (a *ControlFlowAnalyzer) analyzeRange(fset *token.FileSet, stmt *ast.RangeStmt, filename string) []quality.Finding {
|
|
var findings []quality.Finding
|
|
|
|
pos := fset.Position(stmt.Pos())
|
|
|
|
if stmt.Key != nil {
|
|
if ident, ok := stmt.Key.(*ast.Ident); ok && ident.Name == "_" {
|
|
} else if stmt.Body != nil {
|
|
used := false
|
|
keyName := ""
|
|
if ident, ok := stmt.Key.(*ast.Ident); ok {
|
|
keyName = ident.Name
|
|
}
|
|
ast.Inspect(stmt.Body, func(n ast.Node) bool {
|
|
if ident, ok := n.(*ast.Ident); ok && ident.Name == keyName {
|
|
used = true
|
|
}
|
|
return true
|
|
})
|
|
if !used && keyName != "" {
|
|
findings = append(findings, quality.Finding{
|
|
ID: fmt.Sprintf("unused-range-key::%s::%d", filename, pos.Line),
|
|
Type: "quality",
|
|
Title: "Unused range key",
|
|
Description: fmt.Sprintf("Range key '%s' is not used. Use '_' to ignore it explicitly.", keyName),
|
|
File: filename,
|
|
Line: pos.Line,
|
|
Severity: quality.SeverityT1,
|
|
Score: 1,
|
|
Status: quality.StatusOpen,
|
|
Metadata: map[string]string{
|
|
"variable": keyName,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return findings
|
|
}
|