Files
Tomas Dvorak 409acd2e08 updage
2026-02-22 15:41:27 +01:00

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
}