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 }