mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-04 20:43:05 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,427 @@
|
||||
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.(type) {
|
||||
case *ast.IfStmt:
|
||||
complexity++
|
||||
case *ast.ForStmt:
|
||||
complexity++
|
||||
case *ast.RangeStmt:
|
||||
complexity++
|
||||
case *ast.CaseClause:
|
||||
complexity++
|
||||
case *ast.BinaryExpr:
|
||||
if e, ok := n.(*ast.BinaryExpr); ok {
|
||||
if e.Op == token.LAND || e.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
|
||||
}
|
||||
Reference in New Issue
Block a user