- govulncheck JSON output parsing with structured VulnFinding types (13 tests) - Cyclomatic complexity analysis via go/ast with configurable threshold (21 tests) - Coverage snapshot persistence and regression detection with CoverageStore (19 tests) 53 new tests, 68 total devkit tests. All pass with -race. Co-Authored-By: Virgil <virgil@lethean.io>
200 lines
4.7 KiB
Go
200 lines
4.7 KiB
Go
// Package devkit provides a developer toolkit for common automation commands.
|
|
// LEK-1 | lthn.ai | EUPL-1.2
|
|
package devkit
|
|
|
|
import (
|
|
"fmt"
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
// ComplexityConfig controls cyclomatic complexity analysis.
|
|
type ComplexityConfig struct {
|
|
Threshold int // Minimum complexity to report (default 15)
|
|
Path string // Directory or file path to analyse
|
|
}
|
|
|
|
// ComplexityResult represents a single function with its cyclomatic complexity.
|
|
type ComplexityResult struct {
|
|
FuncName string
|
|
Package string
|
|
File string
|
|
Line int
|
|
Complexity int
|
|
}
|
|
|
|
// DefaultComplexityConfig returns a config with sensible defaults.
|
|
func DefaultComplexityConfig() ComplexityConfig {
|
|
return ComplexityConfig{
|
|
Threshold: 15,
|
|
Path: ".",
|
|
}
|
|
}
|
|
|
|
// AnalyseComplexity walks Go source files and returns functions exceeding the
|
|
// configured complexity threshold. Uses native go/ast parsing — no external tools.
|
|
func AnalyseComplexity(cfg ComplexityConfig) ([]ComplexityResult, error) {
|
|
if cfg.Threshold <= 0 {
|
|
cfg.Threshold = 15
|
|
}
|
|
if cfg.Path == "" {
|
|
cfg.Path = "."
|
|
}
|
|
|
|
var results []ComplexityResult
|
|
|
|
info, err := os.Stat(cfg.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat %s: %w", cfg.Path, err)
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
// Single file
|
|
fileResults, err := analyseFile(cfg.Path, cfg.Threshold)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results = append(results, fileResults...)
|
|
return results, nil
|
|
}
|
|
|
|
// Walk directory for .go files
|
|
err = filepath.Walk(cfg.Path, func(path string, fi os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if fi.IsDir() {
|
|
// Skip vendor and hidden directories
|
|
name := fi.Name()
|
|
if name == "vendor" || strings.HasPrefix(name, ".") {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
|
return nil
|
|
}
|
|
fileResults, err := analyseFile(path, cfg.Threshold)
|
|
if err != nil {
|
|
return nil // Skip files that fail to parse
|
|
}
|
|
results = append(results, fileResults...)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("walk %s: %w", cfg.Path, err)
|
|
}
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// AnalyseComplexitySource parses Go source code from a string and returns
|
|
// complexity results. Useful for testing without file I/O.
|
|
func AnalyseComplexitySource(src string, filename string, threshold int) ([]ComplexityResult, error) {
|
|
if threshold <= 0 {
|
|
threshold = 15
|
|
}
|
|
|
|
fset := token.NewFileSet()
|
|
f, err := parser.ParseFile(fset, filename, src, parser.ParseComments)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", filename, err)
|
|
}
|
|
|
|
var results []ComplexityResult
|
|
pkgName := f.Name.Name
|
|
|
|
ast.Inspect(f, func(n ast.Node) bool {
|
|
switch fn := n.(type) {
|
|
case *ast.FuncDecl:
|
|
complexity := calculateComplexity(fn)
|
|
if complexity >= threshold {
|
|
pos := fset.Position(fn.Pos())
|
|
funcName := fn.Name.Name
|
|
if fn.Recv != nil && len(fn.Recv.List) > 0 {
|
|
funcName = receiverType(fn.Recv.List[0].Type) + "." + funcName
|
|
}
|
|
results = append(results, ComplexityResult{
|
|
FuncName: funcName,
|
|
Package: pkgName,
|
|
File: pos.Filename,
|
|
Line: pos.Line,
|
|
Complexity: complexity,
|
|
})
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
|
|
return results, nil
|
|
}
|
|
|
|
// analyseFile parses a single Go file and returns functions exceeding the threshold.
|
|
func analyseFile(path string, threshold int) ([]ComplexityResult, error) {
|
|
src, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
return AnalyseComplexitySource(string(src), path, threshold)
|
|
}
|
|
|
|
// calculateComplexity computes the cyclomatic complexity of a function.
|
|
// Starts at 1, increments for each branching construct.
|
|
func calculateComplexity(fn *ast.FuncDecl) int {
|
|
if fn.Body == nil {
|
|
return 1
|
|
}
|
|
|
|
complexity := 1
|
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
|
switch node := n.(type) {
|
|
case *ast.IfStmt:
|
|
complexity++
|
|
case *ast.ForStmt:
|
|
complexity++
|
|
case *ast.RangeStmt:
|
|
complexity++
|
|
case *ast.CaseClause:
|
|
// Each case adds a branch (except default, which is the "else")
|
|
if node.List != nil {
|
|
complexity++
|
|
}
|
|
case *ast.CommClause:
|
|
// Select case
|
|
if node.Comm != nil {
|
|
complexity++
|
|
}
|
|
case *ast.BinaryExpr:
|
|
if node.Op == token.LAND || node.Op == token.LOR {
|
|
complexity++
|
|
}
|
|
case *ast.TypeSwitchStmt:
|
|
complexity++
|
|
case *ast.SelectStmt:
|
|
complexity++
|
|
}
|
|
return true
|
|
})
|
|
|
|
return complexity
|
|
}
|
|
|
|
// receiverType extracts the type name from a method receiver.
|
|
func receiverType(expr ast.Expr) string {
|
|
switch t := expr.(type) {
|
|
case *ast.StarExpr:
|
|
return receiverType(t.X)
|
|
case *ast.Ident:
|
|
return t.Name
|
|
case *ast.IndexExpr:
|
|
return receiverType(t.X)
|
|
default:
|
|
return "?"
|
|
}
|
|
}
|
|
|
|
// LEK-1 | lthn.ai | EUPL-1.2
|