mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +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
|
||||
}
|
||||
@@ -0,0 +1,471 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
type DataFlowAnalyzer struct {
|
||||
fset *token.FileSet
|
||||
pkgs []*packages.Package
|
||||
findings []quality.Finding
|
||||
taintSrcs map[string][]TaintSource
|
||||
sanitizeFn map[string]bool
|
||||
}
|
||||
|
||||
type TaintSource struct {
|
||||
Name string
|
||||
Package string
|
||||
Category string
|
||||
Description string
|
||||
}
|
||||
|
||||
type DataFlowFinding struct {
|
||||
Source string
|
||||
Sink string
|
||||
Path []string
|
||||
Line int
|
||||
File string
|
||||
Severity quality.Severity
|
||||
Description string
|
||||
}
|
||||
|
||||
func NewDataFlowAnalyzer() *DataFlowAnalyzer {
|
||||
d := &DataFlowAnalyzer{
|
||||
findings: make([]quality.Finding, 0),
|
||||
taintSrcs: make(map[string][]TaintSource),
|
||||
}
|
||||
d.initTaintSources()
|
||||
d.initSanitizers()
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) initTaintSources() {
|
||||
d.taintSrcs["net/http"] = []TaintSource{
|
||||
{Name: "FormValue", Package: "net/http", Category: "http-input", Description: "HTTP form value - user controlled"},
|
||||
{Name: "PostFormValue", Package: "net/http", Category: "http-input", Description: "HTTP POST form value - user controlled"},
|
||||
{Name: "FormFile", Package: "net/http", Category: "http-input", Description: "HTTP uploaded file - user controlled"},
|
||||
{Name: "Cookie", Package: "net/http", Category: "http-input", Description: "HTTP cookie - user controlled"},
|
||||
{Name: "Header", Package: "net/http", Category: "http-input", Description: "HTTP header - user controlled"},
|
||||
{Name: "URL", Package: "net/http", Category: "http-input", Description: "Request URL - user controlled"},
|
||||
{Name: "Body", Package: "net/http", Category: "http-input", Description: "Request body - user controlled"},
|
||||
}
|
||||
d.taintSrcs["os"] = []TaintSource{
|
||||
{Name: "Getenv", Package: "os", Category: "env", Description: "Environment variable - environment controlled"},
|
||||
{Name: "Args", Package: "os", Category: "cli", Description: "Command line arguments - user controlled"},
|
||||
{Name: "Stdin", Package: "os", Category: "io", Description: "Standard input - user controlled"},
|
||||
}
|
||||
d.taintSrcs["bufio"] = []TaintSource{
|
||||
{Name: "ReadString", Package: "bufio", Category: "io", Description: "Reader input - potentially user controlled"},
|
||||
{Name: "ReadBytes", Package: "bufio", Category: "io", Description: "Reader input - potentially user controlled"},
|
||||
{Name: "ReadLine", Package: "bufio", Category: "io", Description: "Reader input - potentially user controlled"},
|
||||
}
|
||||
d.taintSrcs["io"] = []TaintSource{
|
||||
{Name: "ReadAll", Package: "io", Category: "io", Description: "Read all from reader - potentially user controlled"},
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) initSanitizers() {
|
||||
d.sanitizeFn = map[string]bool{
|
||||
"html.EscapeString": true,
|
||||
"template.HTMLEscape": true,
|
||||
"template.JSEscape": true,
|
||||
"url.QueryEscape": true,
|
||||
"url.PathEscape": true,
|
||||
"sql.Named": true,
|
||||
"regexp.QuoteMeta": true,
|
||||
"strconv.Quote": true,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) Name() string {
|
||||
return "dataflow"
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedFiles | packages.NeedSyntax,
|
||||
Dir: path,
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load packages: %w", err)
|
||||
}
|
||||
|
||||
d.pkgs = pkgs
|
||||
d.fset = pkgs[0].Fset
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
d.analyzePackage(pkg)
|
||||
}
|
||||
|
||||
return d.findings, nil
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) analyzePackage(pkg *packages.Package) {
|
||||
for _, file := range pkg.Syntax {
|
||||
d.analyzeFile(pkg, file)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) analyzeFile(pkg *packages.Package, file *ast.File) {
|
||||
tainted := make(map[string]TaintSource)
|
||||
propagations := make(map[string][]string)
|
||||
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
switch node := n.(type) {
|
||||
case *ast.AssignStmt:
|
||||
d.analyzeAssignment(pkg, node, tainted, propagations)
|
||||
case *ast.CallExpr:
|
||||
d.analyzeCall(pkg, node, tainted, file)
|
||||
case *ast.ValueSpec:
|
||||
d.analyzeValueSpec(pkg, node, tainted)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) analyzeAssignment(pkg *packages.Package, node *ast.AssignStmt, tainted map[string]TaintSource, propagations map[string][]string) {
|
||||
for i, expr := range node.Lhs {
|
||||
if ident, ok := expr.(*ast.Ident); ok {
|
||||
if i < len(node.Rhs) {
|
||||
if source := d.getTaintSource(pkg, node.Rhs[i]); source != nil {
|
||||
tainted[ident.Name] = *source
|
||||
}
|
||||
if rhsIdent, ok := node.Rhs[i].(*ast.Ident); ok {
|
||||
if t, exists := tainted[rhsIdent.Name]; exists {
|
||||
tainted[ident.Name] = t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) analyzeCall(pkg *packages.Package, node *ast.CallExpr, tainted map[string]TaintSource, file *ast.File) {
|
||||
fnName := d.getCallName(node)
|
||||
|
||||
if d.isDangerousSink(fnName) {
|
||||
for _, arg := range node.Args {
|
||||
if ident, ok := arg.(*ast.Ident); ok {
|
||||
if source, exists := tainted[ident.Name]; exists {
|
||||
pos := d.fset.Position(node.Pos())
|
||||
d.findings = append(d.findings, quality.Finding{
|
||||
ID: fmt.Sprintf("taint-flow::%s::%d", pos.Filename, pos.Line),
|
||||
Type: "security",
|
||||
Title: fmt.Sprintf("Tainted data flows to dangerous sink: %s", fnName),
|
||||
Description: fmt.Sprintf("User-controlled input from %s flows to %s without sanitization. This may lead to injection vulnerabilities.", source.Description, fnName),
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 8,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"source": source.Name,
|
||||
"source_type": source.Category,
|
||||
"sink": fnName,
|
||||
"variable": ident.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, arg := range node.Args {
|
||||
d.checkSQLInjection(pkg, arg, tainted, node)
|
||||
d.checkCommandInjection(pkg, arg, tainted, node)
|
||||
d.checkPathTraversal(pkg, arg, tainted, node)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) getTaintSource(pkg *packages.Package, expr ast.Expr) *TaintSource {
|
||||
call, ok := expr.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
sel, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkgIdent, ok := sel.X.(*ast.Ident)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
pkgName := pkgIdent.Name
|
||||
fnName := sel.Sel.Name
|
||||
|
||||
if sources, exists := d.taintSrcs[pkgName]; exists {
|
||||
for _, src := range sources {
|
||||
if src.Name == fnName {
|
||||
return &src
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if obj := pkg.TypesInfo.Uses[pkgIdent]; obj != nil {
|
||||
if pkgObj, ok := obj.(*types.PkgName); ok {
|
||||
if sources, exists := d.taintSrcs[pkgObj.Imported().Path()]; exists {
|
||||
for _, src := range sources {
|
||||
if src.Name == fnName {
|
||||
return &src
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) getCallName(node *ast.CallExpr) string {
|
||||
switch fn := node.Fun.(type) {
|
||||
case *ast.SelectorExpr:
|
||||
if ident, ok := fn.X.(*ast.Ident); ok {
|
||||
return ident.Name + "." + fn.Sel.Name
|
||||
}
|
||||
return fn.Sel.Name
|
||||
case *ast.Ident:
|
||||
return fn.Name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) isDangerousSink(fnName string) bool {
|
||||
dangerousSinks := map[string]bool{
|
||||
"exec.Command": true,
|
||||
"exec.CommandContext": true,
|
||||
"os/exec.Command": true,
|
||||
"db.Exec": true,
|
||||
"db.Query": true,
|
||||
"db.QueryRow": true,
|
||||
"sql.DB.Exec": true,
|
||||
"sql.DB.Query": true,
|
||||
"os.WriteFile": true,
|
||||
"os.Create": true,
|
||||
"os.OpenFile": true,
|
||||
"ioutil.WriteFile": true,
|
||||
"template.Parse": true,
|
||||
"html.template.Parse": true,
|
||||
"fmt.Fprintf": true,
|
||||
"fmt.Printf": true,
|
||||
"fmt.Sprintf": true,
|
||||
}
|
||||
return dangerousSinks[fnName]
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) checkSQLInjection(pkg *packages.Package, arg ast.Expr, tainted map[string]TaintSource, node *ast.CallExpr) {
|
||||
fnName := d.getCallName(node)
|
||||
if !strings.Contains(fnName, "Exec") && !strings.Contains(fnName, "Query") {
|
||||
return
|
||||
}
|
||||
|
||||
if basic, ok := arg.(*ast.BasicLit); ok {
|
||||
query := strings.Trim(basic.Value, "`\"")
|
||||
if strings.Contains(query, "%s") || strings.Contains(query, "%v") || strings.Contains(query, "+") {
|
||||
pos := d.fset.Position(node.Pos())
|
||||
d.findings = append(d.findings, quality.Finding{
|
||||
ID: fmt.Sprintf("sql-injection::%s::%d", pos.Filename, pos.Line),
|
||||
Type: "security",
|
||||
Title: "Potential SQL injection vulnerability",
|
||||
Description: "SQL query constructed with string formatting. Use parameterized queries instead.",
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 10,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"vulnerability": "sql-injection",
|
||||
"pattern": "string-formatting-in-query",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) checkCommandInjection(pkg *packages.Package, arg ast.Expr, tainted map[string]TaintSource, node *ast.CallExpr) {
|
||||
fnName := d.getCallName(node)
|
||||
if !strings.Contains(fnName, "exec.Command") {
|
||||
return
|
||||
}
|
||||
|
||||
if ident, ok := arg.(*ast.Ident); ok {
|
||||
if _, exists := tainted[ident.Name]; exists {
|
||||
pos := d.fset.Position(node.Pos())
|
||||
d.findings = append(d.findings, quality.Finding{
|
||||
ID: fmt.Sprintf("command-injection::%s::%d", pos.Filename, pos.Line),
|
||||
Type: "security",
|
||||
Title: "Potential command injection vulnerability",
|
||||
Description: "User-controlled input flows to exec.Command. Sanitize or validate input before use.",
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 10,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"vulnerability": "command-injection",
|
||||
"variable": ident.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) checkPathTraversal(pkg *packages.Package, arg ast.Expr, tainted map[string]TaintSource, node *ast.CallExpr) {
|
||||
fnName := d.getCallName(node)
|
||||
pathFunctions := map[string]bool{
|
||||
"os.Open": true,
|
||||
"os.OpenFile": true,
|
||||
"os.Create": true,
|
||||
"os.WriteFile": true,
|
||||
"os.ReadFile": true,
|
||||
"ioutil.ReadFile": true,
|
||||
"ioutil.WriteFile": true,
|
||||
"filepath.Join": true,
|
||||
"filepath.Walk": true,
|
||||
}
|
||||
|
||||
if !pathFunctions[fnName] {
|
||||
return
|
||||
}
|
||||
|
||||
if ident, ok := arg.(*ast.Ident); ok {
|
||||
if _, exists := tainted[ident.Name]; exists {
|
||||
pos := d.fset.Position(node.Pos())
|
||||
d.findings = append(d.findings, quality.Finding{
|
||||
ID: fmt.Sprintf("path-traversal::%s::%d", pos.Filename, pos.Line),
|
||||
Type: "security",
|
||||
Title: "Potential path traversal vulnerability",
|
||||
Description: "User-controlled input used in file path operation. Validate and sanitize paths.",
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 8,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"vulnerability": "path-traversal",
|
||||
"variable": ident.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataFlowAnalyzer) analyzeValueSpec(pkg *packages.Package, node *ast.ValueSpec, tainted map[string]TaintSource) {
|
||||
for i, name := range node.Names {
|
||||
if i < len(node.Values) {
|
||||
if source := d.getTaintSource(pkg, node.Values[i]); source != nil {
|
||||
tainted[name.Name] = *source
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type SecretsDetector struct {
|
||||
patterns []SecretPattern
|
||||
}
|
||||
|
||||
type SecretPattern struct {
|
||||
Name string
|
||||
Pattern *regexp.Regexp
|
||||
Severity quality.Severity
|
||||
}
|
||||
|
||||
func NewSecretsDetector() *SecretsDetector {
|
||||
d := &SecretsDetector{
|
||||
patterns: []SecretPattern{
|
||||
{Name: "AWS Access Key", Pattern: regexp.MustCompile(`AKIA[0-9A-Z]{16}`), Severity: quality.SeverityT4},
|
||||
{Name: "AWS Secret Key", Pattern: regexp.MustCompile(`(?i)aws(.{0,20})?['\"][0-9a-zA-Z/+=]{40}['\"]`), Severity: quality.SeverityT4},
|
||||
{Name: "GitHub Token", Pattern: regexp.MustCompile(`ghp_[0-9a-zA-Z]{36}`), Severity: quality.SeverityT4},
|
||||
{Name: "GitHub OAuth", Pattern: regexp.MustCompile(`gho_[0-9a-zA-Z]{36}`), Severity: quality.SeverityT4},
|
||||
{Name: "GitHub App Token", Pattern: regexp.MustCompile(`(ghu|ghs)_[0-9a-zA-Z]{36}`), Severity: quality.SeverityT4},
|
||||
{Name: "Slack Token", Pattern: regexp.MustCompile(`xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9]{24}`), Severity: quality.SeverityT4},
|
||||
{Name: "RSA Private Key", Pattern: regexp.MustCompile(`-----BEGIN RSA PRIVATE KEY-----`), Severity: quality.SeverityT4},
|
||||
{Name: "Private Key", Pattern: regexp.MustCompile(`-----BEGIN PRIVATE KEY-----`), Severity: quality.SeverityT4},
|
||||
{Name: "JWT", Pattern: regexp.MustCompile(`eyJ[a-zA-Z0-9_-]*\.eyJ[a-zA-Z0-9_-]*\.[a-zA-Z0-9_-]*`), Severity: quality.SeverityT3},
|
||||
{Name: "Generic API Key", Pattern: regexp.MustCompile(`(?i)(api_key|apikey|secret|password|token)\s*[=:]\s*['"][^'"]{8,}['"]`), Severity: quality.SeverityT3},
|
||||
{Name: "DB Connection String", Pattern: regexp.MustCompile(`(?i)(mysql|postgres|mongodb)://[^:]+:[^@]+@[^/]+`), Severity: quality.SeverityT4},
|
||||
},
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *SecretsDetector) Name() string {
|
||||
return "secrets"
|
||||
}
|
||||
|
||||
func (d *SecretsDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT4
|
||||
}
|
||||
|
||||
func (d *SecretsDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
var findings []quality.Finding
|
||||
|
||||
err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
ext := filepath.Ext(filePath)
|
||||
if ext != ".go" && ext != ".ts" && ext != ".js" && ext != ".py" && ext != ".java" && ext != ".yaml" && ext != ".yml" && ext != ".json" && ext != ".env" && ext != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.Contains(filePath, "_test.go") || strings.Contains(filePath, "vendor/") || strings.Contains(filePath, "node_modules/") {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
for _, pattern := range d.patterns {
|
||||
matches := pattern.Pattern.FindAllStringIndex(content, -1)
|
||||
for _, match := range matches {
|
||||
line := strings.Count(content[:match[0]], "\n") + 1
|
||||
findings = append(findings, quality.Finding{
|
||||
ID: fmt.Sprintf("secret::%s::%d::%s", filePath, line, pattern.Name),
|
||||
Type: "security",
|
||||
Title: fmt.Sprintf("Potential %s detected", pattern.Name),
|
||||
Description: fmt.Sprintf("A potential %s was found in source code. Remove it and use environment variables or secret management.", pattern.Name),
|
||||
File: filePath,
|
||||
Line: line,
|
||||
Severity: pattern.Severity,
|
||||
Score: 10,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"secret_type": pattern.Name,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type BestPractice struct {
|
||||
ID string
|
||||
Category string // security, architecture, performance, quality
|
||||
Title string
|
||||
Description string
|
||||
Pattern string
|
||||
Language string
|
||||
Framework string
|
||||
Severity string
|
||||
Reference string
|
||||
CodeExample string
|
||||
}
|
||||
|
||||
type PracticesFetcher struct {
|
||||
cache map[string][]BestPractice
|
||||
cacheMu sync.RWMutex
|
||||
docsPath string
|
||||
language string
|
||||
frameworks []string
|
||||
}
|
||||
|
||||
func NewPracticesFetcher() *PracticesFetcher {
|
||||
return &PracticesFetcher{
|
||||
cache: make(map[string][]BestPractice),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) DetectLanguage(path string) string {
|
||||
markers := map[string]string{
|
||||
"go.mod": "go",
|
||||
"go.sum": "go",
|
||||
"package.json": "javascript",
|
||||
"tsconfig.json": "typescript",
|
||||
"requirements.txt": "python",
|
||||
"pyproject.toml": "python",
|
||||
"setup.py": "python",
|
||||
"Cargo.toml": "rust",
|
||||
"pom.xml": "java",
|
||||
"build.gradle": "java",
|
||||
"composer.json": "php",
|
||||
"Gemfile": "ruby",
|
||||
}
|
||||
|
||||
for file, lang := range markers {
|
||||
if _, err := os.Stat(filepath.Join(path, file)); err == nil {
|
||||
f.language = lang
|
||||
return lang
|
||||
}
|
||||
}
|
||||
return "go"
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) DetectFrameworks(path, language string) []string {
|
||||
frameworks := []string{}
|
||||
|
||||
switch language {
|
||||
case "go":
|
||||
if f.hasImport(path, "github.com/gin-gonic") {
|
||||
frameworks = append(frameworks, "gin")
|
||||
}
|
||||
if f.hasImport(path, "github.com/labstack/echo") {
|
||||
frameworks = append(frameworks, "echo")
|
||||
}
|
||||
if f.hasImport(path, "github.com/gofiber/fiber") {
|
||||
frameworks = append(frameworks, "fiber")
|
||||
}
|
||||
if f.hasImport(path, "gorm.io") {
|
||||
frameworks = append(frameworks, "gorm")
|
||||
}
|
||||
if f.hasImport(path, "github.com/spf13/cobra") {
|
||||
frameworks = append(frameworks, "cobra")
|
||||
}
|
||||
if f.hasImport(path, "k8s.io/client-go") {
|
||||
frameworks = append(frameworks, "kubernetes")
|
||||
}
|
||||
|
||||
case "typescript", "javascript":
|
||||
pkgPath := filepath.Join(path, "package.json")
|
||||
if data, err := os.ReadFile(pkgPath); err == nil {
|
||||
content := string(data)
|
||||
if strings.Contains(content, `"react"`) || strings.Contains(content, `"next"`) {
|
||||
frameworks = append(frameworks, "react")
|
||||
}
|
||||
if strings.Contains(content, `"vue"`) {
|
||||
frameworks = append(frameworks, "vue")
|
||||
}
|
||||
if strings.Contains(content, `"express"`) {
|
||||
frameworks = append(frameworks, "express")
|
||||
}
|
||||
if strings.Contains(content, `"nestjs"`) || strings.Contains(content, `"@nestjs"`) {
|
||||
frameworks = append(frameworks, "nestjs")
|
||||
}
|
||||
}
|
||||
|
||||
case "python":
|
||||
reqPath := filepath.Join(path, "requirements.txt")
|
||||
if data, err := os.ReadFile(reqPath); err == nil {
|
||||
content := strings.ToLower(string(data))
|
||||
if strings.Contains(content, "django") {
|
||||
frameworks = append(frameworks, "django")
|
||||
}
|
||||
if strings.Contains(content, "flask") {
|
||||
frameworks = append(frameworks, "flask")
|
||||
}
|
||||
if strings.Contains(content, "fastapi") {
|
||||
frameworks = append(frameworks, "fastapi")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
f.frameworks = frameworks
|
||||
return frameworks
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) hasImport(path, importPath string) bool {
|
||||
err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil || info.IsDir() || !strings.HasSuffix(filePath, ".go") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if strings.Contains(string(data), importPath) {
|
||||
return fmt.Errorf("found")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return err != nil
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) FetchPractices(ctx context.Context, language string, frameworks []string) ([]BestPractice, error) {
|
||||
cacheKey := language + ":" + strings.Join(frameworks, ",")
|
||||
|
||||
f.cacheMu.RLock()
|
||||
if practices, ok := f.cache[cacheKey]; ok {
|
||||
f.cacheMu.RUnlock()
|
||||
return practices, nil
|
||||
}
|
||||
f.cacheMu.RUnlock()
|
||||
|
||||
practices := f.getBuiltInPractices(language, frameworks)
|
||||
|
||||
f.cacheMu.Lock()
|
||||
f.cache[cacheKey] = practices
|
||||
f.cacheMu.Unlock()
|
||||
|
||||
return practices, nil
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) getBuiltInPractices(language string, frameworks []string) []BestPractice {
|
||||
var practices []BestPractice
|
||||
|
||||
practices = append(practices, f.getLanguagePractices(language)...)
|
||||
|
||||
for _, fw := range frameworks {
|
||||
practices = append(practices, f.getFrameworkPractices(fw)...)
|
||||
}
|
||||
|
||||
practices = append(practices, f.getSecurityPractices(language)...)
|
||||
practices = append(practices, f.getArchitecturePractices()...)
|
||||
practices = append(practices, f.getPerformancePractices(language)...)
|
||||
|
||||
return practices
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) getLanguagePractices(lang string) []BestPractice {
|
||||
var practices []BestPractice
|
||||
|
||||
switch lang {
|
||||
case "go":
|
||||
practices = append(practices, []BestPractice{
|
||||
{
|
||||
ID: "go:error-handling",
|
||||
Category: "quality",
|
||||
Title: "Always handle errors explicitly",
|
||||
Description: "Never ignore errors. Each error should be handled, wrapped with context, or explicitly logged.",
|
||||
Pattern: `if err != nil`,
|
||||
Language: "go",
|
||||
Severity: "high",
|
||||
Reference: "https://go.dev/blog/error-handling-and-go",
|
||||
},
|
||||
{
|
||||
ID: "go:defer-in-loop",
|
||||
Category: "performance",
|
||||
Title: "Avoid defer in loops",
|
||||
Description: "defer in loops causes resources to be held until function returns. Move loop body to a separate function.",
|
||||
Pattern: `for.*\{[\s\S]*defer`,
|
||||
Language: "go",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "go:context-first",
|
||||
Category: "architecture",
|
||||
Title: "context.Context should be first parameter",
|
||||
Description: "Functions that use context should accept it as the first parameter.",
|
||||
Pattern: `func\s+\w+\([^)]*context\.Context`,
|
||||
Language: "go",
|
||||
Severity: "low",
|
||||
},
|
||||
{
|
||||
ID: "go:interface-location",
|
||||
Category: "architecture",
|
||||
Title: "Define interfaces where they are used",
|
||||
Description: "Interfaces should be defined by the consumer, not the implementer. This promotes loose coupling.",
|
||||
Language: "go",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "go:exported-comments",
|
||||
Category: "quality",
|
||||
Title: "Exported symbols must have documentation comments",
|
||||
Description: "All exported functions, types, and variables should have doc comments starting with their name.",
|
||||
Language: "go",
|
||||
Severity: "low",
|
||||
Reference: "https://go.dev/doc/comment",
|
||||
},
|
||||
{
|
||||
ID: "go:receiver-type",
|
||||
Category: "architecture",
|
||||
Title: "Use pointer receivers consistently",
|
||||
Description: "If any method has a pointer receiver, all methods should have pointer receivers. Use value receivers for small immutable types.",
|
||||
Language: "go",
|
||||
Severity: "low",
|
||||
},
|
||||
{
|
||||
ID: "go:goroutine-leak",
|
||||
Category: "performance",
|
||||
Title: "Goroutines must have a termination path",
|
||||
Description: "Every goroutine should have a clear termination condition, typically via context cancellation or a done channel.",
|
||||
Language: "go",
|
||||
Severity: "high",
|
||||
},
|
||||
}...)
|
||||
|
||||
case "typescript", "javascript":
|
||||
practices = append(practices, []BestPractice{
|
||||
{
|
||||
ID: "ts:async-await",
|
||||
Category: "quality",
|
||||
Title: "Prefer async/await over raw Promises",
|
||||
Description: "async/await provides better readability and error handling than .then() chains.",
|
||||
Language: "typescript",
|
||||
Severity: "low",
|
||||
},
|
||||
{
|
||||
ID: "ts:any-type",
|
||||
Category: "quality",
|
||||
Title: "Avoid the any type",
|
||||
Description: "Use specific types or unknown instead of any to maintain type safety.",
|
||||
Pattern: `:\s*any\b`,
|
||||
Language: "typescript",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "ts:null-check",
|
||||
Category: "quality",
|
||||
Title: "Use strict null checks",
|
||||
Description: "Enable strictNullChecks in tsconfig.json and handle null/undefined explicitly.",
|
||||
Language: "typescript",
|
||||
Severity: "medium",
|
||||
},
|
||||
}...)
|
||||
|
||||
case "python":
|
||||
practices = append(practices, []BestPractice{
|
||||
{
|
||||
ID: "py:type-hints",
|
||||
Category: "quality",
|
||||
Title: "Use type hints for function signatures",
|
||||
Description: "Add type annotations to function parameters and return values for better documentation and tooling.",
|
||||
Language: "python",
|
||||
Severity: "low",
|
||||
},
|
||||
{
|
||||
ID: "py:context-manager",
|
||||
Category: "quality",
|
||||
Title: "Use context managers for resource handling",
|
||||
Description: "Always use 'with' statements for files, connections, and other resources.",
|
||||
Pattern: `with\s+\w+`,
|
||||
Language: "python",
|
||||
Severity: "medium",
|
||||
},
|
||||
}...)
|
||||
}
|
||||
|
||||
return practices
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) getFrameworkPractices(framework string) []BestPractice {
|
||||
var practices []BestPractice
|
||||
|
||||
switch framework {
|
||||
case "gin", "echo", "fiber", "express":
|
||||
practices = append(practices, []BestPractice{
|
||||
{
|
||||
ID: "web:input-validation",
|
||||
Category: "security",
|
||||
Title: "Validate all user input",
|
||||
Description: "Never trust user input. Validate and sanitize all request parameters, body, and headers.",
|
||||
Severity: "critical",
|
||||
Framework: framework,
|
||||
},
|
||||
{
|
||||
ID: "web:error-exposure",
|
||||
Category: "security",
|
||||
Title: "Don't expose internal errors to users",
|
||||
Description: "Log detailed errors internally but return generic error messages to users.",
|
||||
Severity: "high",
|
||||
Framework: framework,
|
||||
},
|
||||
{
|
||||
ID: "web:rate-limiting",
|
||||
Category: "security",
|
||||
Title: "Implement rate limiting",
|
||||
Description: "Protect endpoints with rate limiting to prevent abuse and DoS attacks.",
|
||||
Severity: "high",
|
||||
Framework: framework,
|
||||
},
|
||||
{
|
||||
ID: "web:security-headers",
|
||||
Category: "security",
|
||||
Title: "Set security headers",
|
||||
Description: "Include X-Content-Type-Options, X-Frame-Options, Content-Security-Policy headers.",
|
||||
Severity: "medium",
|
||||
Framework: framework,
|
||||
},
|
||||
}...)
|
||||
|
||||
case "react", "vue":
|
||||
practices = append(practices, []BestPractice{
|
||||
{
|
||||
ID: "frontend:xss-prevention",
|
||||
Category: "security",
|
||||
Title: "Prevent XSS vulnerabilities",
|
||||
Description: "Never use dangerouslySetInnerHTML/v-html with user content. Sanitize all user input.",
|
||||
Severity: "critical",
|
||||
Framework: framework,
|
||||
},
|
||||
{
|
||||
ID: "frontend:dependency-audit",
|
||||
Category: "security",
|
||||
Title: "Audit dependencies regularly",
|
||||
Description: "Run npm audit or yarn audit regularly and update vulnerable packages.",
|
||||
Severity: "high",
|
||||
Framework: framework,
|
||||
},
|
||||
}...)
|
||||
|
||||
case "django", "fastapi", "flask":
|
||||
practices = append(practices, []BestPractice{
|
||||
{
|
||||
ID: "django:sql-injection",
|
||||
Category: "security",
|
||||
Title: "Use ORM to prevent SQL injection",
|
||||
Description: "Never use raw string formatting in SQL queries. Always use parameterized queries or ORM methods.",
|
||||
Severity: "critical",
|
||||
Framework: framework,
|
||||
},
|
||||
{
|
||||
ID: "django:csrf-protection",
|
||||
Category: "security",
|
||||
Title: "Enable CSRF protection",
|
||||
Description: "Ensure CSRF middleware is enabled for all state-changing operations.",
|
||||
Severity: "high",
|
||||
Framework: framework,
|
||||
},
|
||||
}...)
|
||||
}
|
||||
|
||||
return practices
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) getSecurityPractices(lang string) []BestPractice {
|
||||
return []BestPractice{
|
||||
{
|
||||
ID: "sec:hardcoded-secrets",
|
||||
Category: "security",
|
||||
Title: "No hardcoded secrets",
|
||||
Description: "Never commit secrets, API keys, passwords, or tokens in source code. Use environment variables or secret management.",
|
||||
Pattern: `(password|secret|api_key|apikey|token)\s*[=:]\s*['"][^'"]+['"]`,
|
||||
Severity: "critical",
|
||||
Reference: "https://owasp.org/www-project-web-security-testing-guide/",
|
||||
},
|
||||
{
|
||||
ID: "sec:sql-injection",
|
||||
Category: "security",
|
||||
Title: "Prevent SQL injection",
|
||||
Description: "Use parameterized queries or prepared statements. Never concatenate user input into SQL strings.",
|
||||
Severity: "critical",
|
||||
Reference: "https://owasp.org/www-community/attacks/SQL_Injection",
|
||||
},
|
||||
{
|
||||
ID: "sec:xss-prevention",
|
||||
Category: "security",
|
||||
Title: "Prevent Cross-Site Scripting (XSS)",
|
||||
Description: "Encode output, validate input, use Content-Security-Policy headers.",
|
||||
Severity: "critical",
|
||||
Reference: "https://owasp.org/www-community/attacks/xss/",
|
||||
},
|
||||
{
|
||||
ID: "sec:insecure-deserialization",
|
||||
Category: "security",
|
||||
Title: "Avoid insecure deserialization",
|
||||
Description: "Don't deserialize untrusted data. Validate and sanitize all serialized input.",
|
||||
Severity: "critical",
|
||||
Reference: "https://owasp.org/www-community/vulnerabilities/Deserialization_of_untrusted_data",
|
||||
},
|
||||
{
|
||||
ID: "sec:weak-crypto",
|
||||
Category: "security",
|
||||
Title: "Use strong cryptography",
|
||||
Description: "Use modern algorithms (AES-256-GCM, SHA-256+, RSA-2048+). Never use MD5, SHA1 for security purposes.",
|
||||
Pattern: `(md5|sha1)\s*\(`,
|
||||
Severity: "high",
|
||||
},
|
||||
{
|
||||
ID: "sec:logging-sensitive",
|
||||
Category: "security",
|
||||
Title: "Don't log sensitive data",
|
||||
Description: "Never log passwords, tokens, credit cards, or PII. Mask or redact sensitive fields.",
|
||||
Severity: "high",
|
||||
},
|
||||
{
|
||||
ID: "sec:auth-checks",
|
||||
Category: "security",
|
||||
Title: "Implement proper authentication checks",
|
||||
Description: "Verify authentication on every protected endpoint. Don't rely on client-side checks.",
|
||||
Severity: "critical",
|
||||
},
|
||||
{
|
||||
ID: "sec:input-validation",
|
||||
Category: "security",
|
||||
Title: "Validate all input on the server",
|
||||
Description: "Client-side validation is for UX. Server-side validation is for security.",
|
||||
Severity: "critical",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) getArchitecturePractices() []BestPractice {
|
||||
return []BestPractice{
|
||||
{
|
||||
ID: "arch:single-responsibility",
|
||||
Category: "architecture",
|
||||
Title: "Single Responsibility Principle",
|
||||
Description: "Each module/class should have one reason to change. Split large modules into focused ones.",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "arch:dependency-injection",
|
||||
Category: "architecture",
|
||||
Title: "Use dependency injection",
|
||||
Description: "Inject dependencies rather than creating them internally. This improves testability and flexibility.",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "arch:layer-separation",
|
||||
Category: "architecture",
|
||||
Title: "Separate concerns by layer",
|
||||
Description: "Keep presentation, business logic, and data access layers separate.",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "arch:interface-segregation",
|
||||
Category: "architecture",
|
||||
Title: "Prefer small, focused interfaces",
|
||||
Description: "Clients shouldn't depend on methods they don't use. Split large interfaces.",
|
||||
Severity: "low",
|
||||
},
|
||||
{
|
||||
ID: "arch:avoid-god-classes",
|
||||
Category: "architecture",
|
||||
Title: "Avoid god classes/modules",
|
||||
Description: "Classes with too many responsibilities should be split. Watch for high method/field counts.",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "arch:circular-dependencies",
|
||||
Category: "architecture",
|
||||
Title: "Eliminate circular dependencies",
|
||||
Description: "Circular dependencies indicate tight coupling. Refactor to use dependency inversion.",
|
||||
Severity: "high",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) getPerformancePractices(lang string) []BestPractice {
|
||||
practices := []BestPractice{
|
||||
{
|
||||
ID: "perf:n-plus-one",
|
||||
Category: "performance",
|
||||
Title: "Avoid N+1 query patterns",
|
||||
Description: "When iterating over results, avoid making separate queries for each item. Use JOINs or batch loading.",
|
||||
Severity: "high",
|
||||
},
|
||||
{
|
||||
ID: "perf:unbounded-results",
|
||||
Category: "performance",
|
||||
Title: "Limit query results",
|
||||
Description: "Always paginate or limit query results to prevent memory exhaustion.",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "perf:connection-pooling",
|
||||
Category: "performance",
|
||||
Title: "Use connection pooling",
|
||||
Description: "Don't create new connections per request. Use connection pools for databases and HTTP clients.",
|
||||
Severity: "high",
|
||||
},
|
||||
{
|
||||
ID: "perf:caching",
|
||||
Category: "performance",
|
||||
Title: "Cache expensive operations",
|
||||
Description: "Cache frequently accessed, rarely changing data. Consider memoization for expensive computations.",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "perf:blocking-in-hot-path",
|
||||
Category: "performance",
|
||||
Title: "Avoid blocking operations in hot paths",
|
||||
Description: "Move I/O, network calls, and heavy computations out of request handlers when possible.",
|
||||
Severity: "medium",
|
||||
},
|
||||
}
|
||||
|
||||
if lang == "go" {
|
||||
practices = append(practices, []BestPractice{
|
||||
{
|
||||
ID: "go:perf:string-concat",
|
||||
Category: "performance",
|
||||
Title: "Use strings.Builder for string concatenation",
|
||||
Description: "In loops, use strings.Builder instead of += for efficient string concatenation.",
|
||||
Pattern: `for[\s\S]*\+=.*["` + "`" + `]`,
|
||||
Language: "go",
|
||||
Severity: "medium",
|
||||
},
|
||||
{
|
||||
ID: "go:perf:slice-prealloc",
|
||||
Category: "performance",
|
||||
Title: "Pre-allocate slices when size is known",
|
||||
Description: "Use make([]T, 0, capacity) when you know the final size to avoid reallocations.",
|
||||
Language: "go",
|
||||
Severity: "low",
|
||||
},
|
||||
{
|
||||
ID: "go:perf:json-marshal",
|
||||
Category: "performance",
|
||||
Title: "Consider streaming JSON for large payloads",
|
||||
Description: "For large JSON, use json.Encoder/Decoder instead of Marshal/Unmarshal to reduce allocations.",
|
||||
Language: "go",
|
||||
Severity: "low",
|
||||
},
|
||||
}...)
|
||||
}
|
||||
|
||||
return practices
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) GetPracticesByCategory(category string) []BestPractice {
|
||||
f.cacheMu.RLock()
|
||||
defer f.cacheMu.RUnlock()
|
||||
|
||||
var result []BestPractice
|
||||
for _, practices := range f.cache {
|
||||
for _, p := range practices {
|
||||
if p.Category == category {
|
||||
result = append(result, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (f *PracticesFetcher) GetAllPractices() []BestPractice {
|
||||
f.cacheMu.RLock()
|
||||
defer f.cacheMu.RUnlock()
|
||||
|
||||
var result []BestPractice
|
||||
seen := make(map[string]bool)
|
||||
for _, practices := range f.cache {
|
||||
for _, p := range practices {
|
||||
if !seen[p.ID] {
|
||||
result = append(result, p)
|
||||
seen[p.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Detector interface defines the contract for code quality detectors
|
||||
type Detector interface {
|
||||
// Name returns the detector name
|
||||
Name() string
|
||||
|
||||
// Detect runs the detector on the given path
|
||||
Detect(ctx context.Context, path string, config *Config) ([]Finding, error)
|
||||
|
||||
// Severity returns the default severity for findings from this detector
|
||||
Severity() Severity
|
||||
}
|
||||
|
||||
// LanguageDetector interface extends Detector for language-specific detectors
|
||||
type LanguageDetector interface {
|
||||
Detector
|
||||
|
||||
// SupportedLanguages returns the languages this detector supports
|
||||
SupportedLanguages() []string
|
||||
|
||||
// ExtractFunctions extracts function information from source files
|
||||
ExtractFunctions(ctx context.Context, files []string) ([]FunctionInfo, error)
|
||||
|
||||
// ExtractClasses extracts class information from source files
|
||||
ExtractClasses(ctx context.Context, files []string) ([]ClassInfo, error)
|
||||
}
|
||||
|
||||
// FileFinder interface for finding files of a specific language
|
||||
type FileFinder interface {
|
||||
// FindFiles returns source files for the given path and language
|
||||
FindFiles(path string, language string) ([]string, error)
|
||||
|
||||
// IsSourceFile checks if a file is a source file for the language
|
||||
IsSourceFile(path string, language string) bool
|
||||
}
|
||||
|
||||
// BaseDetector provides common functionality for detectors
|
||||
type BaseDetector struct {
|
||||
name string
|
||||
severity Severity
|
||||
finder FileFinder
|
||||
}
|
||||
|
||||
// NewBaseDetector creates a new base detector
|
||||
func NewBaseDetector(name string, severity Severity, finder FileFinder) *BaseDetector {
|
||||
return &BaseDetector{
|
||||
name: name,
|
||||
severity: severity,
|
||||
finder: finder,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the detector name
|
||||
func (d *BaseDetector) Name() string {
|
||||
return d.name
|
||||
}
|
||||
|
||||
// Severity returns the default severity
|
||||
func (d *BaseDetector) Severity() Severity {
|
||||
return d.severity
|
||||
}
|
||||
|
||||
// FindFiles finds source files using the file finder
|
||||
func (d *BaseDetector) FindFiles(path string, language string) ([]string, error) {
|
||||
if d.finder != nil {
|
||||
return d.finder.FindFiles(path, language)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// ShouldExclude checks if a path should be excluded based on config
|
||||
func ShouldExclude(path string, excludes []string) bool {
|
||||
if len(excludes) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, pattern := range excludes {
|
||||
matched, err := filepath.Match(pattern, path)
|
||||
if err == nil && matched {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check directory exclusion
|
||||
matched, err = filepath.Match(pattern, filepath.Base(path))
|
||||
if err == nil && matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
// ComplexityDetector detects complexity issues in source code
|
||||
type ComplexityDetector struct {
|
||||
*quality.BaseDetector
|
||||
signals []ComplexitySignal
|
||||
}
|
||||
|
||||
// ComplexitySignal represents a complexity pattern to detect
|
||||
type ComplexitySignal struct {
|
||||
Name string
|
||||
Pattern *regexp.Regexp
|
||||
Weight int
|
||||
Threshold int
|
||||
Compute func(content string, lines []string) (int, string)
|
||||
}
|
||||
|
||||
// NewComplexityDetector creates a new complexity detector
|
||||
func NewComplexityDetector(finder quality.FileFinder) *ComplexityDetector {
|
||||
detector := &ComplexityDetector{
|
||||
BaseDetector: quality.NewBaseDetector("complexity", quality.SeverityT2, finder),
|
||||
signals: []ComplexitySignal{
|
||||
{
|
||||
Name: "nested if statements",
|
||||
Pattern: regexp.MustCompile(`^\s*if\s+.*\{\s*$`),
|
||||
Weight: 2,
|
||||
Threshold: 3,
|
||||
},
|
||||
{
|
||||
Name: "nested for loops",
|
||||
Pattern: regexp.MustCompile(`^\s*for\s+.*\{\s*$`),
|
||||
Weight: 3,
|
||||
Threshold: 2,
|
||||
},
|
||||
{
|
||||
Name: "switch statements",
|
||||
Pattern: regexp.MustCompile(`^\s*switch\s+.*\{\s*$`),
|
||||
Weight: 1,
|
||||
Threshold: 5,
|
||||
},
|
||||
{
|
||||
Name: "function calls",
|
||||
Pattern: regexp.MustCompile(`\w+\(`),
|
||||
Weight: 1,
|
||||
Threshold: 20,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add Go-specific complexity signals
|
||||
detector.addGoSignals()
|
||||
|
||||
return detector
|
||||
}
|
||||
|
||||
// addGoSignals adds Go-specific complexity signals
|
||||
func (d *ComplexityDetector) addGoSignals() {
|
||||
goSignals := []ComplexitySignal{
|
||||
{
|
||||
Name: "goroutines",
|
||||
Pattern: regexp.MustCompile(`go\s+\w+\(`),
|
||||
Weight: 2,
|
||||
Threshold: 3,
|
||||
},
|
||||
{
|
||||
Name: "channels",
|
||||
Pattern: regexp.MustCompile(`make\s*\(\s*chan`),
|
||||
Weight: 2,
|
||||
Threshold: 3,
|
||||
},
|
||||
{
|
||||
Name: "select statements",
|
||||
Pattern: regexp.MustCompile(`^\s*select\s*\{`),
|
||||
Weight: 3,
|
||||
Threshold: 2,
|
||||
},
|
||||
{
|
||||
Name: "defer statements",
|
||||
Pattern: regexp.MustCompile(`^\s*defer\s+`),
|
||||
Weight: 1,
|
||||
Threshold: 5,
|
||||
},
|
||||
}
|
||||
|
||||
d.signals = append(d.signals, goSignals...)
|
||||
}
|
||||
|
||||
// Name returns the detector name
|
||||
func (d *ComplexityDetector) Name() string {
|
||||
return "complexity"
|
||||
}
|
||||
|
||||
// Severity returns the default severity
|
||||
func (d *ComplexityDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT2
|
||||
}
|
||||
|
||||
// Detect runs complexity detection on the given path
|
||||
func (d *ComplexityDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, config.Language)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find files: %w", err)
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for _, file := range files {
|
||||
if quality.ShouldExclude(file, config.Exclude) {
|
||||
continue
|
||||
}
|
||||
|
||||
fileFindings, err := d.analyzeFile(file, config)
|
||||
if err != nil {
|
||||
log.Printf("Failed to analyze file %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
// analyzeFile analyzes a single file for complexity issues
|
||||
func (d *ComplexityDetector) analyzeFile(filePath string, config *quality.Config) ([]quality.Finding, error) {
|
||||
content, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read file content
|
||||
fileContent, err := os.ReadFile(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentStr := string(fileContent)
|
||||
lines := strings.Split(contentStr, "\n")
|
||||
loc := len(lines)
|
||||
|
||||
if loc < config.MinLOC {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
score := 0
|
||||
var signals []string
|
||||
|
||||
// Check each complexity signal
|
||||
for _, signal := range d.signals {
|
||||
var count int
|
||||
var label string
|
||||
|
||||
if signal.Compute != nil {
|
||||
c, l := signal.Compute(contentStr, lines)
|
||||
if c > 0 {
|
||||
count = c
|
||||
label = l
|
||||
}
|
||||
} else if signal.Pattern != nil {
|
||||
matches := signal.Pattern.FindAllString(contentStr, -1)
|
||||
count = len(matches)
|
||||
if count > signal.Threshold {
|
||||
label = fmt.Sprintf("%d %s", count, signal.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if count > signal.Threshold {
|
||||
signals = append(signals, label)
|
||||
excess := count - signal.Threshold
|
||||
if signal.Threshold == 0 {
|
||||
excess = count
|
||||
}
|
||||
score += excess * signal.Weight
|
||||
}
|
||||
}
|
||||
|
||||
// Create finding if score exceeds threshold
|
||||
if score >= config.Threshold && len(signals) > 0 {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("complexity-%s-%d", filepath.Base(filePath), score),
|
||||
Type: "complexity",
|
||||
Title: "High complexity detected",
|
||||
Description: fmt.Sprintf("File has complexity score of %d with signals: %s", score, strings.Join(signals, ", ")),
|
||||
File: filePath,
|
||||
Line: 1,
|
||||
Severity: d.Severity(),
|
||||
Score: score,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"loc": strconv.Itoa(loc),
|
||||
"signals": strings.Join(signals, ";"),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
// DuplicationDetector detects duplicate and near-duplicate code
|
||||
type DuplicationDetector struct {
|
||||
*quality.BaseDetector
|
||||
similarityThreshold float64
|
||||
}
|
||||
|
||||
// DuplicateCluster represents a cluster of similar functions
|
||||
type DuplicateCluster struct {
|
||||
Functions []quality.FunctionInfo `json:"functions"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Representative string `json:"representative"`
|
||||
}
|
||||
|
||||
// NewDuplicationDetector creates a new duplication detector
|
||||
func NewDuplicationDetector(finder quality.FileFinder) *DuplicationDetector {
|
||||
return &DuplicationDetector{
|
||||
BaseDetector: quality.NewBaseDetector("duplication", quality.SeverityT3, finder),
|
||||
similarityThreshold: 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the detector name
|
||||
func (d *DuplicationDetector) Name() string {
|
||||
return "duplication"
|
||||
}
|
||||
|
||||
// Severity returns the default severity
|
||||
func (d *DuplicationDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
// Detect runs duplication detection on the given path
|
||||
func (d *DuplicationDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, config.Language)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find files: %w", err)
|
||||
}
|
||||
|
||||
// Extract functions from all files
|
||||
var allFunctions []quality.FunctionInfo
|
||||
for _, file := range files {
|
||||
if quality.ShouldExclude(file, config.Exclude) {
|
||||
continue
|
||||
}
|
||||
|
||||
functions, err := d.extractFunctions(file)
|
||||
if err != nil {
|
||||
log.Printf("Failed to extract functions from %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
|
||||
allFunctions = append(allFunctions, functions...)
|
||||
}
|
||||
|
||||
// Find duplicates
|
||||
clusters := d.findDuplicates(allFunctions)
|
||||
|
||||
// Convert clusters to findings
|
||||
var findings []quality.Finding
|
||||
for i, cluster := range clusters {
|
||||
if len(cluster.Functions) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("duplication-cluster-%d", i),
|
||||
Type: "duplication",
|
||||
Title: "Code duplication detected",
|
||||
Description: fmt.Sprintf("Found %d similar functions with %.2f similarity",
|
||||
len(cluster.Functions), cluster.Similarity),
|
||||
File: cluster.Functions[0].File,
|
||||
Line: cluster.Functions[0].Line,
|
||||
Severity: d.Severity(),
|
||||
Score: len(cluster.Functions) * 2, // Score based on cluster size
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"cluster_size": fmt.Sprintf("%d", len(cluster.Functions)),
|
||||
"similarity": fmt.Sprintf("%.2f", cluster.Similarity),
|
||||
"functions": d.formatFunctionList(cluster.Functions),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
// extractFunctions extracts functions from a source file
|
||||
func (d *DuplicationDetector) extractFunctions(filePath string) ([]quality.FunctionInfo, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
lines := strings.Split(contentStr, "\n")
|
||||
|
||||
var functions []quality.FunctionInfo
|
||||
|
||||
// Simple function extraction for Go (can be enhanced with AST parsing)
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "func ") {
|
||||
funcInfo := d.parseFunctionLine(trimmed, filePath, i+1, contentStr)
|
||||
if funcInfo != nil {
|
||||
functions = append(functions, *funcInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return functions, nil
|
||||
}
|
||||
|
||||
// parseFunctionLine parses a function declaration line
|
||||
func (d *DuplicationDetector) parseFunctionLine(line, filePath string, lineNum int, content string) *quality.FunctionInfo {
|
||||
// Extract function name
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
funcName := parts[1]
|
||||
// Remove parentheses and receiver if present
|
||||
if idx := strings.Index(funcName, "("); idx != -1 {
|
||||
funcName = funcName[:idx]
|
||||
}
|
||||
|
||||
// Find function body
|
||||
lines := strings.Split(content, "\n")
|
||||
startLine := lineNum - 1
|
||||
endLine := d.findFunctionEnd(lines, startLine)
|
||||
|
||||
if endLine <= startLine {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract function body
|
||||
bodyLines := lines[startLine:endLine]
|
||||
body := strings.Join(bodyLines, "\n")
|
||||
loc := endLine - startLine
|
||||
|
||||
// Create normalized version for comparison
|
||||
normalized := d.normalizeFunction(body)
|
||||
bodyHash := d.hashFunction(normalized)
|
||||
|
||||
return &quality.FunctionInfo{
|
||||
Name: funcName,
|
||||
File: filePath,
|
||||
Line: lineNum,
|
||||
EndLine: endLine,
|
||||
LOC: loc,
|
||||
Body: body,
|
||||
Normalized: normalized,
|
||||
BodyHash: bodyHash,
|
||||
}
|
||||
}
|
||||
|
||||
// findFunctionEnd finds the end line of a function
|
||||
func (d *DuplicationDetector) findFunctionEnd(lines []string, startLine int) int {
|
||||
if startLine >= len(lines) {
|
||||
return startLine
|
||||
}
|
||||
|
||||
braceCount := 0
|
||||
for i := startLine; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
braceCount += strings.Count(line, "{")
|
||||
braceCount += strings.Count(line, "}")
|
||||
|
||||
if braceCount == 0 && i > startLine {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return len(lines)
|
||||
}
|
||||
|
||||
// normalizeFunction normalizes a function for comparison
|
||||
func (d *DuplicationDetector) normalizeFunction(body string) string {
|
||||
// Remove comments
|
||||
body = regexp.MustCompile(`//.*`).ReplaceAllString(body, "")
|
||||
body = regexp.MustCompile(`/\*[\s\S]*?\*/`).ReplaceAllString(body, "")
|
||||
|
||||
// Normalize whitespace
|
||||
body = regexp.MustCompile(`\s+`).ReplaceAllString(body, " ")
|
||||
body = strings.TrimSpace(body)
|
||||
|
||||
// Normalize variable names (simple approach)
|
||||
body = regexp.MustCompile(`\b[a-z][a-zA-Z0-9]*\b`).ReplaceAllString(body, "VAR")
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// hashFunction creates a hash of the normalized function
|
||||
func (d *DuplicationDetector) hashFunction(normalized string) string {
|
||||
hash := sha256.Sum256([]byte(normalized))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// findDuplicates finds duplicate functions using similarity analysis
|
||||
func (d *DuplicationDetector) findDuplicates(functions []quality.FunctionInfo) []DuplicateCluster {
|
||||
var clusters []DuplicateCluster
|
||||
|
||||
// Group by exact hash first
|
||||
hashGroups := make(map[string][]quality.FunctionInfo)
|
||||
for _, fn := range functions {
|
||||
hashGroups[fn.BodyHash] = append(hashGroups[fn.BodyHash], fn)
|
||||
}
|
||||
|
||||
// Create clusters from exact duplicates
|
||||
for _, group := range hashGroups {
|
||||
if len(group) >= 2 {
|
||||
cluster := DuplicateCluster{
|
||||
Functions: group,
|
||||
Similarity: 1.0,
|
||||
Representative: group[0].Name,
|
||||
}
|
||||
clusters = append(clusters, cluster)
|
||||
}
|
||||
}
|
||||
|
||||
// Find near-duplicates using similarity
|
||||
processed := make(map[int]bool)
|
||||
for i, fn1 := range functions {
|
||||
if processed[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
var similar []quality.FunctionInfo
|
||||
similar = append(similar, fn1)
|
||||
|
||||
for j, fn2 := range functions {
|
||||
if i == j || processed[j] {
|
||||
continue
|
||||
}
|
||||
|
||||
similarity := d.calculateSimilarity(fn1.Normalized, fn2.Normalized)
|
||||
if similarity >= d.similarityThreshold {
|
||||
similar = append(similar, fn2)
|
||||
processed[j] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(similar) >= 2 {
|
||||
cluster := DuplicateCluster{
|
||||
Functions: similar,
|
||||
Similarity: d.similarityThreshold,
|
||||
Representative: similar[0].Name,
|
||||
}
|
||||
clusters = append(clusters, cluster)
|
||||
}
|
||||
|
||||
processed[i] = true
|
||||
}
|
||||
|
||||
return clusters
|
||||
}
|
||||
|
||||
// calculateSimilarity calculates similarity between two strings
|
||||
func (d *DuplicationDetector) calculateSimilarity(s1, s2 string) float64 {
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Simple Levenshtein distance-based similarity
|
||||
distance := d.levenshteinDistance(s1, s2)
|
||||
maxLen := max(len(s1), len(s2))
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func (d *DuplicationDetector) levenshteinDistance(s1, s2 string) int {
|
||||
m, n := len(s1), len(s2)
|
||||
if m < n {
|
||||
s1, s2 = s2, s1
|
||||
m, n = n, m
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return m
|
||||
}
|
||||
|
||||
prev := make([]int, n+1)
|
||||
for i := range prev {
|
||||
prev[i] = i
|
||||
}
|
||||
|
||||
for i := 1; i <= m; i++ {
|
||||
current := make([]int, n+1)
|
||||
current[0] = i
|
||||
|
||||
for j := 1; j <= n; j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
|
||||
current[j] = min(
|
||||
prev[j]+1, // deletion
|
||||
current[j-1]+1, // insertion
|
||||
prev[j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
|
||||
prev = current
|
||||
}
|
||||
|
||||
return prev[n]
|
||||
}
|
||||
|
||||
// formatFunctionList formats a list of functions for metadata
|
||||
func (d *DuplicationDetector) formatFunctionList(functions []quality.FunctionInfo) string {
|
||||
var names []string
|
||||
for _, fn := range functions {
|
||||
names = append(names, fmt.Sprintf("%s:%d", fn.Name, fn.Line))
|
||||
}
|
||||
return strings.Join(names, ",")
|
||||
}
|
||||
|
||||
// min returns the minimum of three integers
|
||||
func min(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
// NamingConvention represents a naming convention
|
||||
type NamingConvention string
|
||||
|
||||
const (
|
||||
ConventionKebabCase NamingConvention = "kebab-case"
|
||||
ConventionPascalCase NamingConvention = "PascalCase"
|
||||
ConventionCamelCase NamingConvention = "camelCase"
|
||||
ConventionSnakeCase NamingConvention = "snake_case"
|
||||
ConventionFlatLower NamingConvention = "flat_lower"
|
||||
)
|
||||
|
||||
// NamingDetector detects naming inconsistencies
|
||||
type NamingDetector struct {
|
||||
*quality.BaseDetector
|
||||
skipNames map[string]bool
|
||||
skipDirs map[string]bool
|
||||
}
|
||||
|
||||
// NamingAnalysis represents naming analysis for a directory
|
||||
type NamingAnalysis struct {
|
||||
Directory string `json:"directory"`
|
||||
Conventions map[NamingConvention]int `json:"conventions"`
|
||||
TotalFiles int `json:"total_files"`
|
||||
Minority NamingConvention `json:"minority"`
|
||||
MinorityCount int `json:"minority_count"`
|
||||
MinorityPercent float64 `json:"minority_percent"`
|
||||
}
|
||||
|
||||
// NewNamingDetector creates a new naming detector
|
||||
func NewNamingDetector(finder quality.FileFinder) *NamingDetector {
|
||||
skipNames := map[string]bool{
|
||||
"README.md": true,
|
||||
"LICENSE": true,
|
||||
"Makefile": true,
|
||||
"Dockerfile": true,
|
||||
"go.mod": true,
|
||||
"go.sum": true,
|
||||
}
|
||||
|
||||
skipDirs := map[string]bool{
|
||||
".git": true,
|
||||
"node_modules": true,
|
||||
"vendor": true,
|
||||
".vscode": true,
|
||||
".idea": true,
|
||||
}
|
||||
|
||||
return &NamingDetector{
|
||||
BaseDetector: quality.NewBaseDetector("naming", quality.SeverityT2, finder),
|
||||
skipNames: skipNames,
|
||||
skipDirs: skipDirs,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the detector name
|
||||
func (d *NamingDetector) Name() string {
|
||||
return "naming"
|
||||
}
|
||||
|
||||
// Severity returns the default severity
|
||||
func (d *NamingDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT2
|
||||
}
|
||||
|
||||
// Detect runs naming inconsistency detection
|
||||
func (d *NamingDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, config.Language)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find files: %w", err)
|
||||
}
|
||||
|
||||
// Group files by directory
|
||||
dirFiles := make(map[string][]string)
|
||||
for _, file := range files {
|
||||
if quality.ShouldExclude(file, config.Exclude) {
|
||||
continue
|
||||
}
|
||||
|
||||
dir := filepath.Dir(file)
|
||||
dirFiles[dir] = append(dirFiles[dir], file)
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
// Analyze each directory
|
||||
for dir, files := range dirFiles {
|
||||
analysis := d.analyzeDirectory(dir, files)
|
||||
if d.shouldReport(analysis) {
|
||||
finding := d.createFinding(analysis)
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
// analyzeDirectory analyzes naming conventions in a directory
|
||||
func (d *NamingDetector) analyzeDirectory(dir string, files []string) NamingAnalysis {
|
||||
conventions := make(map[NamingConvention]int)
|
||||
totalFiles := 0
|
||||
|
||||
for _, file := range files {
|
||||
filename := filepath.Base(file)
|
||||
|
||||
// Skip certain files
|
||||
if d.skipNames[filename] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we should skip this directory
|
||||
if d.skipDirs[filepath.Base(dir)] {
|
||||
continue
|
||||
}
|
||||
|
||||
convention := d.classifyConvention(filename)
|
||||
if convention != "" {
|
||||
conventions[convention]++
|
||||
totalFiles++
|
||||
}
|
||||
}
|
||||
|
||||
// Find minority convention
|
||||
minority, minorityCount, minorityPercent := d.findMinorityConvention(conventions, totalFiles)
|
||||
|
||||
return NamingAnalysis{
|
||||
Directory: dir,
|
||||
Conventions: conventions,
|
||||
TotalFiles: totalFiles,
|
||||
Minority: minority,
|
||||
MinorityCount: minorityCount,
|
||||
MinorityPercent: minorityPercent,
|
||||
}
|
||||
}
|
||||
|
||||
// classifyConvention classifies a filename into a naming convention
|
||||
func (d *NamingDetector) classifyConvention(filename string) NamingConvention {
|
||||
// Remove extension
|
||||
stem := filename
|
||||
if idx := strings.LastIndex(filename, "."); idx != -1 {
|
||||
stem = filename[:idx]
|
||||
}
|
||||
|
||||
if stem == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check each convention
|
||||
if strings.Contains(stem, "-") && stem == strings.ToLower(stem) {
|
||||
return ConventionKebabCase
|
||||
}
|
||||
|
||||
if len(stem) > 0 && strings.ToUpper(string(stem[0])) == string(stem[0]) && !strings.Contains(stem, "-") {
|
||||
return ConventionPascalCase
|
||||
}
|
||||
|
||||
if len(stem) > 0 && strings.ToLower(string(stem[0])) == string(stem[0]) &&
|
||||
d.hasUpper(stem) && !strings.Contains(stem, "-") {
|
||||
return ConventionCamelCase
|
||||
}
|
||||
|
||||
if strings.Contains(stem, "_") && stem == strings.ToLower(stem) {
|
||||
return ConventionSnakeCase
|
||||
}
|
||||
|
||||
if stem == strings.ToLower(stem) && !strings.Contains(stem, "-") {
|
||||
return ConventionFlatLower
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// hasUpper checks if a string contains uppercase letters
|
||||
func (d *NamingDetector) hasUpper(s string) bool {
|
||||
for _, r := range s {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findMinorityConvention finds the minority naming convention
|
||||
func (d *NamingDetector) findMinorityConvention(conventions map[NamingConvention]int, totalFiles int) (NamingConvention, int, float64) {
|
||||
if len(conventions) < 2 {
|
||||
return "", 0, 0
|
||||
}
|
||||
|
||||
var minority NamingConvention
|
||||
minorityCount := 0
|
||||
minCount := totalFiles
|
||||
|
||||
for convention, count := range conventions {
|
||||
if count < minCount {
|
||||
minCount = count
|
||||
minorityCount = count
|
||||
minority = convention
|
||||
}
|
||||
}
|
||||
|
||||
// Check thresholds
|
||||
minorityPercent := float64(minorityCount) / float64(totalFiles) * 100
|
||||
|
||||
// Only report if minority has >= 5 files and >= 15% of total
|
||||
if minorityCount >= 5 && minorityPercent >= 15 {
|
||||
return minority, minorityCount, minorityPercent
|
||||
}
|
||||
|
||||
return "", 0, 0
|
||||
}
|
||||
|
||||
// shouldReport determines if the analysis should be reported
|
||||
func (d *NamingDetector) shouldReport(analysis NamingAnalysis) bool {
|
||||
return analysis.Minority != "" &&
|
||||
analysis.MinorityCount >= 5 &&
|
||||
analysis.MinorityPercent >= 15
|
||||
}
|
||||
|
||||
// createFinding creates a finding from analysis
|
||||
func (d *NamingDetector) createFinding(analysis NamingAnalysis) quality.Finding {
|
||||
conventionList := make([]string, 0, len(analysis.Conventions))
|
||||
for conv, count := range analysis.Conventions {
|
||||
conventionList = append(conventionList, fmt.Sprintf("%s (%d)", conv, count))
|
||||
}
|
||||
|
||||
return quality.Finding{
|
||||
ID: fmt.Sprintf("naming-%s", strings.ReplaceAll(analysis.Directory, "/", "-")),
|
||||
Type: "naming",
|
||||
Title: "Naming inconsistency detected",
|
||||
Description: fmt.Sprintf("Directory '%s' has mixed naming conventions. Minority: %s with %d files (%.1f%%). All conventions: %s",
|
||||
analysis.Directory, analysis.Minority, analysis.MinorityCount, analysis.MinorityPercent, strings.Join(conventionList, ", ")),
|
||||
File: analysis.Directory,
|
||||
Line: 1,
|
||||
Severity: d.Severity(),
|
||||
Score: int(analysis.MinorityPercent), // Score based on percentage
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"directory": analysis.Directory,
|
||||
"minority": string(analysis.Minority),
|
||||
"minority_count": fmt.Sprintf("%d", analysis.MinorityCount),
|
||||
"minority_percent": fmt.Sprintf("%.1f", analysis.MinorityPercent),
|
||||
"total_files": fmt.Sprintf("%d", analysis.TotalFiles),
|
||||
"conventions": strings.Join(conventionList, ";"),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Dimension represents a quality dimension category
|
||||
type Dimension string
|
||||
|
||||
const (
|
||||
DimensionFileHealth Dimension = "File health"
|
||||
DimensionCodeQuality Dimension = "Code quality"
|
||||
DimensionDuplication Dimension = "Duplication"
|
||||
DimensionTestHealth Dimension = "Test health"
|
||||
DimensionSecurity Dimension = "Security"
|
||||
DimensionNamingQuality Dimension = "Naming Quality"
|
||||
DimensionErrorConsistency Dimension = "Error Consistency"
|
||||
DimensionAbstractionFit Dimension = "Abstraction Fit"
|
||||
DimensionLogicClarity Dimension = "Logic Clarity"
|
||||
DimensionAIGeneratedDebt Dimension = "AI Generated Debt"
|
||||
DimensionTypeSafety Dimension = "Type Safety"
|
||||
DimensionContractCoherence Dimension = "Contract Coherence"
|
||||
DimensionElegance Dimension = "Elegance"
|
||||
DimensionContracts Dimension = "Contracts"
|
||||
)
|
||||
|
||||
// DetectorMetrics represents metrics for a specific detector
|
||||
type DetectorMetrics struct {
|
||||
Potential int `json:"potential"`
|
||||
PassRate float64 `json:"pass_rate"`
|
||||
Issues int `json:"issues"`
|
||||
WeightedFailures float64 `json:"weighted_failures"`
|
||||
}
|
||||
|
||||
// DimensionScore represents the score for a quality dimension
|
||||
type DimensionScore struct {
|
||||
Score float64 `json:"score"`
|
||||
Strict float64 `json:"strict"`
|
||||
Checks int `json:"checks"`
|
||||
Issues int `json:"issues"`
|
||||
Tier int `json:"tier"`
|
||||
Detectors map[string]*DetectorMetrics `json:"detectors"`
|
||||
}
|
||||
|
||||
// ScanStats represents scanning statistics
|
||||
type ScanStats struct {
|
||||
Total int `json:"total"`
|
||||
Open int `json:"open"`
|
||||
Fixed int `json:"fixed"`
|
||||
AutoResolved int `json:"auto_resolved"`
|
||||
Wontfix int `json:"wontfix"`
|
||||
FalsePositive int `json:"false_positive"`
|
||||
ByTier map[string]*TierStats `json:"by_tier"`
|
||||
}
|
||||
|
||||
// TierStats represents statistics for a severity tier
|
||||
type TierStats struct {
|
||||
Open int `json:"open"`
|
||||
Fixed int `json:"fixed"`
|
||||
AutoResolved int `json:"auto_resolved"`
|
||||
Wontfix int `json:"wontfix"`
|
||||
FalsePositive int `json:"false_positive"`
|
||||
}
|
||||
|
||||
// DetectorTransparency represents transparency information for detectors
|
||||
type DetectorTransparency struct {
|
||||
Rows []DetectorRow `json:"rows"`
|
||||
Totals DetectorTotals `json:"totals"`
|
||||
}
|
||||
|
||||
// DetectorRow represents a single detector's transparency data
|
||||
type DetectorRow struct {
|
||||
Detector string `json:"detector"`
|
||||
Visible int `json:"visible"`
|
||||
Suppressed int `json:"suppressed"`
|
||||
Excluded int `json:"excluded"`
|
||||
TotalDetected int `json:"total_detected"`
|
||||
}
|
||||
|
||||
// DetectorTotals represents totals for detector transparency
|
||||
type DetectorTotals struct {
|
||||
Visible int `json:"visible"`
|
||||
Suppressed int `json:"suppressed"`
|
||||
Excluded int `json:"excluded"`
|
||||
Detectors int `json:"detectors"`
|
||||
}
|
||||
|
||||
// Potentials represents potential scores by language
|
||||
type Potentials struct {
|
||||
Languages map[string]*LanguagePotentials `json:"languages"`
|
||||
}
|
||||
|
||||
// LanguagePotentials represents potential scores for a language
|
||||
type LanguagePotentials struct {
|
||||
Logs int `json:"logs"`
|
||||
Unused int `json:"unused"`
|
||||
Exports int `json:"exports"`
|
||||
Deprecated int `json:"deprecated"`
|
||||
Structural int `json:"structural"`
|
||||
FlatDirs int `json:"flat_dirs"`
|
||||
Props int `json:"props"`
|
||||
SingleUse int `json:"single_use"`
|
||||
Coupling int `json:"coupling"`
|
||||
Cycles int `json:"cycles"`
|
||||
Orphaned int `json:"orphaned"`
|
||||
Patterns int `json:"patterns"`
|
||||
Naming int `json:"naming"`
|
||||
Facade int `json:"facade"`
|
||||
TestCoverage int `json:"test_coverage"`
|
||||
Smells int `json:"smells"`
|
||||
React int `json:"react"`
|
||||
Security int `json:"security"`
|
||||
SubjectiveReview int `json:"subjective_review"`
|
||||
Dupes int `json:"dupes"`
|
||||
}
|
||||
|
||||
// CodebaseMetrics represents metrics about the codebase
|
||||
type CodebaseMetrics struct {
|
||||
Languages map[string]*LanguageMetrics `json:"languages"`
|
||||
}
|
||||
|
||||
// LanguageMetrics represents metrics for a specific language
|
||||
type LanguageMetrics struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalLOC int `json:"total_loc"`
|
||||
TotalDirectories int `json:"total_directories"`
|
||||
}
|
||||
|
||||
// StrictTarget represents the target scoring information
|
||||
type StrictTarget struct {
|
||||
Target float64 `json:"target"`
|
||||
Current float64 `json:"current"`
|
||||
Gap float64 `json:"gap"`
|
||||
State string `json:"state"`
|
||||
Warning *string `json:"warning"`
|
||||
}
|
||||
|
||||
// Narrative represents the analysis narrative
|
||||
type Narrative struct {
|
||||
Phase string `json:"phase"`
|
||||
Headline string `json:"headline"`
|
||||
Dimensions *NarrativeDimensions `json:"dimensions"`
|
||||
Actions []string `json:"actions"`
|
||||
Strategy *NarrativeStrategy `json:"strategy"`
|
||||
Tools *NarrativeTools `json:"tools"`
|
||||
Debt *NarrativeDebt `json:"debt"`
|
||||
Milestone string `json:"milestone"`
|
||||
PrimaryAction *string `json:"primary_action"`
|
||||
WhyNow string `json:"why_now"`
|
||||
VerificationStep *string `json:"verification_step"`
|
||||
RiskFlags []string `json:"risk_flags"`
|
||||
StrictTarget *StrictTarget `json:"strict_target"`
|
||||
Reminders []string `json:"reminders"`
|
||||
ReminderHistory *ReminderHistory `json:"reminder_history"`
|
||||
}
|
||||
|
||||
// NarrativeDimensions represents dimension analysis in narrative
|
||||
type NarrativeDimensions struct {
|
||||
LowestDimensions []*DimensionInfo `json:"lowest_dimensions"`
|
||||
BiggestGapDimensions []*DimensionInfo `json:"biggest_gap_dimensions"`
|
||||
StagnantDimensions []*DimensionInfo `json:"stagnant_dimensions"`
|
||||
}
|
||||
|
||||
// DimensionInfo represents information about a dimension
|
||||
type DimensionInfo struct {
|
||||
Name string `json:"name"`
|
||||
Strict float64 `json:"strict"`
|
||||
Issues int `json:"issues"`
|
||||
Impact float64 `json:"impact"`
|
||||
Subjective bool `json:"subjective"`
|
||||
ImpactDescription string `json:"impact_description"`
|
||||
StuckScans *int `json:"stuck_scans,omitempty"`
|
||||
Lenient *float64 `json:"lenient,omitempty"`
|
||||
Gap *float64 `json:"gap,omitempty"`
|
||||
WontfixCount *int `json:"wontfix_count,omitempty"`
|
||||
}
|
||||
|
||||
// NarrativeStrategy represents strategy information
|
||||
type NarrativeStrategy struct {
|
||||
FixerLeverage *FixerLeverage `json:"fixer_leverage"`
|
||||
Lanes map[string]interface{} `json:"lanes"`
|
||||
CanParallelize bool `json:"can_parallelize"`
|
||||
Hint string `json:"hint"`
|
||||
}
|
||||
|
||||
// FixerLeverage represents fixer leverage information
|
||||
type FixerLeverage struct {
|
||||
AutoFixableCount int `json:"auto_fixable_count"`
|
||||
TotalCount int `json:"total_count"`
|
||||
Coverage float64 `json:"coverage"`
|
||||
ImpactRatio float64 `json:"impact_ratio"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
}
|
||||
|
||||
// NarrativeTools represents available tools
|
||||
type NarrativeTools struct {
|
||||
Fixers []interface{} `json:"fixers"`
|
||||
Move *MoveTool `json:"move"`
|
||||
Plan *PlanTool `json:"plan"`
|
||||
Badge *BadgeTool `json:"badge"`
|
||||
}
|
||||
|
||||
// MoveTool represents the move tool
|
||||
type MoveTool struct {
|
||||
Available bool `json:"available"`
|
||||
Relevant bool `json:"relevant"`
|
||||
Reason *string `json:"reason"`
|
||||
Usage string `json:"usage"`
|
||||
}
|
||||
|
||||
// PlanTool represents the plan tool
|
||||
type PlanTool struct {
|
||||
Command string `json:"command"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// BadgeTool represents the badge tool
|
||||
type BadgeTool struct {
|
||||
Generated bool `json:"generated"`
|
||||
InReadme bool `json:"in_readme"`
|
||||
Path string `json:"path"`
|
||||
Recommendation *string `json:"recommendation"`
|
||||
}
|
||||
|
||||
// NarrativeDebt represents debt analysis
|
||||
type NarrativeDebt struct {
|
||||
OverallGap float64 `json:"overall_gap"`
|
||||
WontfixCount int `json:"wontfix_count"`
|
||||
WorstDimension string `json:"worst_dimension"`
|
||||
WorstGap float64 `json:"worst_gap"`
|
||||
Trend string `json:"trend"`
|
||||
}
|
||||
|
||||
// ReminderHistory represents reminder history
|
||||
type ReminderHistory struct {
|
||||
ReportScores int `json:"report_scores"`
|
||||
AutoFixersAvailable int `json:"auto_fixers_available"`
|
||||
DryRunFirst int `json:"dry_run_first"`
|
||||
ZoneClassification int `json:"zone_classification"`
|
||||
FPCalibrationExportsProduction int `json:"fp_calibration_exports_production"`
|
||||
FeedbackNudge int `json:"feedback_nudge"`
|
||||
WontfixGrowing int `json:"wontfix_growing"`
|
||||
StagnantNudge int `json:"stagnant_nudge"`
|
||||
ReviewNotRun int `json:"review_not_run"`
|
||||
BadgeRecommendation int `json:"badge_recommendation"`
|
||||
}
|
||||
|
||||
// QualityConfig represents enhanced quality configuration
|
||||
type QualityConfig struct {
|
||||
ReviewMaxAgeDays int `json:"review_max_age_days"`
|
||||
HolisticMaxAgeDays int `json:"holistic_max_age_days"`
|
||||
GenerateScorecard bool `json:"generate_scorecard"`
|
||||
BadgePath string `json:"badge_path"`
|
||||
Exclude []string `json:"exclude"`
|
||||
Ignore []string `json:"ignore"`
|
||||
IgnoreMetadata map[string]interface{} `json:"ignore_metadata"`
|
||||
ZoneOverrides map[string]interface{} `json:"zone_overrides"`
|
||||
ReviewDimensions []string `json:"review_dimensions"`
|
||||
ReviewAllowCustomDimensions bool `json:"review_allow_custom_dimensions"`
|
||||
ReviewCustomDimensions []string `json:"review_custom_dimensions"`
|
||||
LargeFilesThreshold int `json:"large_files_threshold"`
|
||||
PropsThreshold int `json:"props_threshold"`
|
||||
FindingNoiseBudget int `json:"finding_noise_budget"`
|
||||
FindingNoiseGlobalBudget int `json:"finding_noise_global_budget"`
|
||||
TargetStrictScore int `json:"target_strict_score"`
|
||||
Languages map[string]interface{} `json:"languages"`
|
||||
}
|
||||
|
||||
// EnhancedStatus represents the comprehensive status response
|
||||
type EnhancedStatus struct {
|
||||
Command string `json:"command"`
|
||||
OverallScore float64 `json:"overall_score"`
|
||||
ObjectiveScore float64 `json:"objective_score"`
|
||||
StrictScore float64 `json:"strict_score"`
|
||||
StrictAllDetected float64 `json:"strict_all_detected"`
|
||||
DimensionScores map[Dimension]*DimensionScore `json:"dimension_scores"`
|
||||
Stats *ScanStats `json:"stats"`
|
||||
ScanCount int `json:"scan_count"`
|
||||
LastScan time.Time `json:"last_scan"`
|
||||
ByTier map[string]*TierStats `json:"by_tier"`
|
||||
Ignores []string `json:"ignores"`
|
||||
Suppression *SuppressionInfo `json:"suppression"`
|
||||
DetectorTransparency *DetectorTransparency `json:"detector_transparency"`
|
||||
Potentials *Potentials `json:"potentials"`
|
||||
CodebaseMetrics *CodebaseMetrics `json:"codebase_metrics"`
|
||||
StrictTarget *StrictTarget `json:"strict_target"`
|
||||
Narrative *Narrative `json:"narrative"`
|
||||
Config *QualityConfig `json:"config"`
|
||||
}
|
||||
|
||||
// SuppressionInfo represents suppression information
|
||||
type SuppressionInfo struct {
|
||||
LastIgnored int `json:"last_ignored"`
|
||||
LastRawFindings int `json:"last_raw_findings"`
|
||||
LastSuppressedPct float64 `json:"last_suppressed_pct"`
|
||||
LastIgnorePatterns int `json:"last_ignore_patterns"`
|
||||
RecentScans int `json:"recent_scans"`
|
||||
RecentIgnored int `json:"recent_ignored"`
|
||||
RecentRawFindings int `json:"recent_raw_findings"`
|
||||
RecentSuppressedPct float64 `json:"recent_suppressed_pct"`
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LanguageConfig represents configuration for a programming language
|
||||
type LanguageConfig struct {
|
||||
Name string `json:"name"`
|
||||
Extensions []string `json:"extensions"`
|
||||
MarkerFiles []string `json:"marker_files"`
|
||||
DefaultSrc string `json:"default_src"`
|
||||
}
|
||||
|
||||
// GetSupportedLanguages returns all supported languages
|
||||
func GetSupportedLanguages() []LanguageConfig {
|
||||
return []LanguageConfig{
|
||||
{
|
||||
Name: "go",
|
||||
Extensions: []string{".go"},
|
||||
MarkerFiles: []string{"go.mod", "go.sum"},
|
||||
DefaultSrc: ".",
|
||||
},
|
||||
{
|
||||
Name: "typescript",
|
||||
Extensions: []string{".ts", ".tsx"},
|
||||
MarkerFiles: []string{"package.json", "tsconfig.json"},
|
||||
DefaultSrc: "src",
|
||||
},
|
||||
{
|
||||
Name: "python",
|
||||
Extensions: []string{".py"},
|
||||
MarkerFiles: []string{"requirements.txt", "setup.py", "pyproject.toml"},
|
||||
DefaultSrc: ".",
|
||||
},
|
||||
{
|
||||
Name: "java",
|
||||
Extensions: []string{".java"},
|
||||
MarkerFiles: []string{"pom.xml", "build.gradle"},
|
||||
DefaultSrc: "src/main/java",
|
||||
},
|
||||
{
|
||||
Name: "rust",
|
||||
Extensions: []string{".rs"},
|
||||
MarkerFiles: []string{"Cargo.toml"},
|
||||
DefaultSrc: "src",
|
||||
},
|
||||
{
|
||||
Name: "javascript",
|
||||
Extensions: []string{".js", ".jsx"},
|
||||
MarkerFiles: []string{"package.json"},
|
||||
DefaultSrc: "src",
|
||||
},
|
||||
{
|
||||
Name: "csharp",
|
||||
Extensions: []string{".cs"},
|
||||
MarkerFiles: []string{"*.csproj", "*.sln"},
|
||||
DefaultSrc: ".",
|
||||
},
|
||||
{
|
||||
Name: "dart",
|
||||
Extensions: []string{".dart"},
|
||||
MarkerFiles: []string{"pubspec.yaml"},
|
||||
DefaultSrc: "lib",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultFileFinder implements FileFinder interface
|
||||
type DefaultFileFinder struct{}
|
||||
|
||||
// NewDefaultFileFinder creates a new default file finder
|
||||
func NewDefaultFileFinder() *DefaultFileFinder {
|
||||
return &DefaultFileFinder{}
|
||||
}
|
||||
|
||||
// FindFiles returns source files for the given path and language
|
||||
func (f *DefaultFileFinder) FindFiles(path string, language string) ([]string, error) {
|
||||
languages := GetSupportedLanguages()
|
||||
var extensions []string
|
||||
|
||||
// Find language config
|
||||
for _, lang := range languages {
|
||||
if lang.Name == language {
|
||||
extensions = lang.Extensions
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Go extensions if not found
|
||||
if len(extensions) == 0 {
|
||||
extensions = []string{".go"}
|
||||
}
|
||||
|
||||
var files []string
|
||||
err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if info.IsDir() {
|
||||
// Skip hidden directories and common exclude dirs
|
||||
base := filepath.Base(filePath)
|
||||
if strings.HasPrefix(base, ".") || base == "node_modules" || base == "vendor" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := filepath.Ext(filePath)
|
||||
for _, langExt := range extensions {
|
||||
if ext == langExt {
|
||||
files = append(files, filePath)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return files, err
|
||||
}
|
||||
|
||||
// IsSourceFile checks if a file is a source file for the language
|
||||
func (f *DefaultFileFinder) IsSourceFile(path string, language string) bool {
|
||||
languages := GetSupportedLanguages()
|
||||
var extensions []string
|
||||
|
||||
// Find language config
|
||||
for _, lang := range languages {
|
||||
if lang.Name == language {
|
||||
extensions = lang.Extensions
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Go extensions if not found
|
||||
if len(extensions) == 0 {
|
||||
extensions = []string{".go"}
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
for _, langExt := range extensions {
|
||||
if ext == langExt {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// DetectLanguage attempts to auto-detect the project language from marker files
|
||||
func DetectLanguage(path string) string {
|
||||
languages := GetSupportedLanguages()
|
||||
|
||||
// Check for marker files in order of specificity
|
||||
for _, lang := range languages {
|
||||
for _, marker := range lang.MarkerFiles {
|
||||
markerPath := filepath.Join(path, marker)
|
||||
if _, err := filepath.Glob(markerPath); err == nil {
|
||||
// Check if any files match the pattern
|
||||
matches, _ := filepath.Glob(markerPath)
|
||||
if len(matches) > 0 {
|
||||
return lang.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Go if no markers found
|
||||
return "go"
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type NarrativeGenerator struct {
|
||||
targetScore int
|
||||
}
|
||||
|
||||
func NewNarrativeGenerator(targetScore int) *NarrativeGenerator {
|
||||
if targetScore <= 0 {
|
||||
targetScore = 95
|
||||
}
|
||||
return &NarrativeGenerator{targetScore: targetScore}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) Generate(findings []Finding, scorecard *Scorecard, history []StateSnapshot) *Narrative {
|
||||
phase := g.determinePhase(findings, scorecard)
|
||||
headline := g.generateHeadline(phase, scorecard)
|
||||
dimensions := g.analyzeDimensions(findings)
|
||||
actions := g.generateActions(findings, phase)
|
||||
strategy := g.generateStrategy(findings, dimensions)
|
||||
tools := g.generateTools(findings)
|
||||
debt := g.analyzeDebt(findings, scorecard)
|
||||
strictTarget := g.calculateStrictTarget(scorecard)
|
||||
reminders := g.generateReminders(findings, history)
|
||||
riskFlags := g.identifyRisks(findings, history)
|
||||
|
||||
return &Narrative{
|
||||
Phase: phase,
|
||||
Headline: headline,
|
||||
Dimensions: dimensions,
|
||||
Actions: actions,
|
||||
Strategy: strategy,
|
||||
Tools: tools,
|
||||
Debt: debt,
|
||||
Milestone: g.generateMilestone(phase, scorecard),
|
||||
WhyNow: g.explainWhyNow(phase, findings),
|
||||
RiskFlags: riskFlags,
|
||||
StrictTarget: strictTarget,
|
||||
Reminders: reminders,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) determinePhase(findings []Finding, scorecard *Scorecard) string {
|
||||
openCount := 0
|
||||
t4Count := 0
|
||||
t3Count := 0
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen {
|
||||
openCount++
|
||||
if f.Severity == SeverityT4 {
|
||||
t4Count++
|
||||
} else if f.Severity == SeverityT3 {
|
||||
t3Count++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if openCount == 0 {
|
||||
return "maintenance"
|
||||
}
|
||||
|
||||
if t4Count > 0 {
|
||||
return "critical"
|
||||
}
|
||||
|
||||
if t3Count > 5 || openCount > 20 {
|
||||
return "debt_reduction"
|
||||
}
|
||||
|
||||
if openCount > 5 {
|
||||
return "cleanup"
|
||||
}
|
||||
|
||||
return "polish"
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) generateHeadline(phase string, scorecard *Scorecard) string {
|
||||
switch phase {
|
||||
case "maintenance":
|
||||
return "Codebase is healthy! Focus on preventing new debt."
|
||||
case "critical":
|
||||
return fmt.Sprintf("Critical issues detected (%d strict score). Address T4 findings first.", scorecard.StrictScore)
|
||||
case "debt_reduction":
|
||||
return fmt.Sprintf("Significant technical debt (%d open issues). Systematic cleanup recommended.", scorecard.TotalScore)
|
||||
case "cleanup":
|
||||
return fmt.Sprintf("Minor issues detected (%d open). Quick wins available.", scorecard.TotalScore)
|
||||
default:
|
||||
return fmt.Sprintf("Codebase in good shape (%d open issues).", scorecard.TotalScore)
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) analyzeDimensions(findings []Finding) *NarrativeDimensions {
|
||||
dimensionScores := make(map[Dimension][]Finding)
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen {
|
||||
dim := g.classifyDimension(f)
|
||||
dimensionScores[dim] = append(dimensionScores[dim], f)
|
||||
}
|
||||
}
|
||||
|
||||
var lowest []*DimensionInfo
|
||||
var biggestGap []*DimensionInfo
|
||||
var stagnant []*DimensionInfo
|
||||
|
||||
for dim, dimFindings := range dimensionScores {
|
||||
info := &DimensionInfo{
|
||||
Name: string(dim),
|
||||
Issues: len(dimFindings),
|
||||
}
|
||||
|
||||
impact := 0
|
||||
for _, f := range dimFindings {
|
||||
impact += f.Score * int(f.Severity)
|
||||
}
|
||||
info.Impact = float64(impact)
|
||||
|
||||
lowest = append(lowest, info)
|
||||
}
|
||||
|
||||
sort.Slice(lowest, func(i, j int) bool {
|
||||
return lowest[i].Impact > lowest[j].Impact
|
||||
})
|
||||
|
||||
if len(lowest) > 5 {
|
||||
lowest = lowest[:5]
|
||||
}
|
||||
|
||||
return &NarrativeDimensions{
|
||||
LowestDimensions: lowest,
|
||||
BiggestGapDimensions: biggestGap,
|
||||
StagnantDimensions: stagnant,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) classifyDimension(f Finding) Dimension {
|
||||
switch f.Type {
|
||||
case "complexity", "complexity_ast":
|
||||
return DimensionCodeQuality
|
||||
case "duplication", "dupes":
|
||||
return DimensionDuplication
|
||||
case "dead_code", "unused_import", "unused":
|
||||
return DimensionFileHealth
|
||||
case "security":
|
||||
return DimensionSecurity
|
||||
case "naming":
|
||||
return DimensionNamingQuality
|
||||
case "import_cycle", "cycles":
|
||||
return DimensionAbstractionFit
|
||||
default:
|
||||
return DimensionCodeQuality
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) generateActions(findings []Finding, phase string) []string {
|
||||
var actions []string
|
||||
|
||||
t1AutoFixable := 0
|
||||
t2Quick := 0
|
||||
t3Judgment := 0
|
||||
t4Major := 0
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Status != StatusOpen {
|
||||
continue
|
||||
}
|
||||
switch f.Severity {
|
||||
case SeverityT1:
|
||||
t1AutoFixable++
|
||||
case SeverityT2:
|
||||
t2Quick++
|
||||
case SeverityT3:
|
||||
t3Judgment++
|
||||
case SeverityT4:
|
||||
t4Major++
|
||||
}
|
||||
}
|
||||
|
||||
if t4Major > 0 {
|
||||
actions = append(actions, fmt.Sprintf("Address %d T4 (major refactor) issues - these require architectural changes", t4Major))
|
||||
}
|
||||
|
||||
if t3Judgment > 0 {
|
||||
actions = append(actions, fmt.Sprintf("Review %d T3 (needs judgment) issues - decide if they need fixing", t3Judgment))
|
||||
}
|
||||
|
||||
if t1AutoFixable > 0 {
|
||||
actions = append(actions, fmt.Sprintf("Run auto-fixer for %d T1 (auto-fixable) issues", t1AutoFixable))
|
||||
}
|
||||
|
||||
if t2Quick > 0 {
|
||||
actions = append(actions, fmt.Sprintf("Quick manual fixes available for %d T2 issues", t2Quick))
|
||||
}
|
||||
|
||||
if len(actions) == 0 {
|
||||
actions = append(actions, "No immediate actions required - maintain code quality")
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) generateStrategy(findings []Finding, dimensions *NarrativeDimensions) *NarrativeStrategy {
|
||||
autoFixable := 0
|
||||
total := 0
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen {
|
||||
total++
|
||||
if f.Severity == SeverityT1 {
|
||||
autoFixable++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var recommendation string
|
||||
var coverage float64
|
||||
if total > 0 {
|
||||
coverage = float64(autoFixable) / float64(total) * 100
|
||||
}
|
||||
|
||||
if coverage > 50 {
|
||||
recommendation = "Use auto-fixers first, then address remaining issues manually"
|
||||
} else if autoFixable > 0 {
|
||||
recommendation = "Start with auto-fixers for quick wins, then prioritize by impact"
|
||||
} else {
|
||||
recommendation = "Prioritize by severity and impact, starting with T4 issues"
|
||||
}
|
||||
|
||||
return &NarrativeStrategy{
|
||||
FixerLeverage: &FixerLeverage{
|
||||
AutoFixableCount: autoFixable,
|
||||
TotalCount: total,
|
||||
Coverage: coverage,
|
||||
Recommendation: recommendation,
|
||||
},
|
||||
CanParallelize: len(findings) > 3,
|
||||
Hint: g.generateHint(findings),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) generateHint(findings []Finding) string {
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen && f.Severity == SeverityT1 {
|
||||
return "T1 issues can be auto-fixed with 'devour quality fix'"
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen && f.Severity == SeverityT4 {
|
||||
return "T4 issues require planning - consider creating a dedicated branch"
|
||||
}
|
||||
}
|
||||
|
||||
return "Focus on one category at a time for best results"
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) generateTools(findings []Finding) *NarrativeTools {
|
||||
fixers := []interface{}{}
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen && f.Severity == SeverityT1 {
|
||||
fixers = append(fixers, map[string]string{
|
||||
"name": f.Type,
|
||||
"description": fmt.Sprintf("Fix %s issues", f.Type),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return &NarrativeTools{
|
||||
Fixers: fixers,
|
||||
Plan: &PlanTool{
|
||||
Command: "devour quality plan",
|
||||
Description: "Generate prioritized action plan",
|
||||
},
|
||||
Badge: &BadgeTool{
|
||||
Generated: true,
|
||||
InReadme: false,
|
||||
Path: "scorecard.png",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) analyzeDebt(findings []Finding, scorecard *Scorecard) *NarrativeDebt {
|
||||
wontfixCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusWontfix {
|
||||
wontfixCount++
|
||||
}
|
||||
}
|
||||
|
||||
var worstDimension string
|
||||
var worstGap float64
|
||||
|
||||
dimensionImpact := make(map[string]float64)
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen {
|
||||
dim := string(g.classifyDimension(f))
|
||||
dimensionImpact[dim] += float64(f.Score * int(f.Severity))
|
||||
}
|
||||
}
|
||||
|
||||
for dim, impact := range dimensionImpact {
|
||||
if impact > worstGap {
|
||||
worstGap = impact
|
||||
worstDimension = dim
|
||||
}
|
||||
}
|
||||
|
||||
return &NarrativeDebt{
|
||||
OverallGap: float64(scorecard.StrictScore),
|
||||
WontfixCount: wontfixCount,
|
||||
WorstDimension: worstDimension,
|
||||
WorstGap: worstGap,
|
||||
Trend: "stable",
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) calculateStrictTarget(scorecard *Scorecard) *StrictTarget {
|
||||
gap := float64(scorecard.StrictScore) / float64(g.targetScore) * 100
|
||||
|
||||
var state string
|
||||
var warning *string
|
||||
|
||||
switch {
|
||||
case gap >= 100:
|
||||
state = "at_target"
|
||||
case gap >= 80:
|
||||
state = "near_target"
|
||||
case gap >= 50:
|
||||
state = "in_progress"
|
||||
w := "Significant gap to target - consider focused effort"
|
||||
warning = &w
|
||||
default:
|
||||
state = "needs_work"
|
||||
w := "Large gap to target - prioritize high-impact fixes"
|
||||
warning = &w
|
||||
}
|
||||
|
||||
return &StrictTarget{
|
||||
Target: float64(g.targetScore),
|
||||
Current: float64(scorecard.StrictScore),
|
||||
Gap: gap,
|
||||
State: state,
|
||||
Warning: warning,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) generateReminders(findings []Finding, history []StateSnapshot) []string {
|
||||
var reminders []string
|
||||
|
||||
autoFixable := 0
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen && f.Severity == SeverityT1 {
|
||||
autoFixable++
|
||||
}
|
||||
}
|
||||
|
||||
if autoFixable > 0 {
|
||||
reminders = append(reminders, fmt.Sprintf("%d auto-fixable issues available - use 'devour quality fix'", autoFixable))
|
||||
}
|
||||
|
||||
if len(history) > 0 {
|
||||
latest := history[len(history)-1]
|
||||
if latest.Findings == len(findings) {
|
||||
reminders = append(reminders, "No progress since last scan - consider tackling a specific category")
|
||||
}
|
||||
}
|
||||
|
||||
return reminders
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) identifyRisks(findings []Finding, history []StateSnapshot) []string {
|
||||
var risks []string
|
||||
|
||||
t4Count := 0
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen && f.Severity == SeverityT4 {
|
||||
t4Count++
|
||||
}
|
||||
}
|
||||
|
||||
if t4Count > 3 {
|
||||
risks = append(risks, fmt.Sprintf("High number of T4 issues (%d) indicates architectural debt", t4Count))
|
||||
}
|
||||
|
||||
if len(history) >= 3 {
|
||||
trend := 0
|
||||
for i := len(history) - 3; i < len(history); i++ {
|
||||
trend += history[i].Findings
|
||||
}
|
||||
avg := trend / 3
|
||||
if len(findings) > int(float64(avg)*1.2) {
|
||||
risks = append(risks, "Finding count is trending upward - debt is accumulating")
|
||||
}
|
||||
}
|
||||
|
||||
return risks
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) generateMilestone(phase string, scorecard *Scorecard) string {
|
||||
switch phase {
|
||||
case "maintenance":
|
||||
return "Maintain current quality level"
|
||||
case "critical":
|
||||
return "Reduce T4 issues to zero"
|
||||
case "debt_reduction":
|
||||
return fmt.Sprintf("Reduce strict score below %d", g.targetScore)
|
||||
case "cleanup":
|
||||
return "Clear all T1 and T2 issues"
|
||||
default:
|
||||
return "Continue quality improvement"
|
||||
}
|
||||
}
|
||||
|
||||
func (g *NarrativeGenerator) explainWhyNow(phase string, findings []Finding) string {
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen && f.Severity == SeverityT4 {
|
||||
return "T4 issues compound over time - addressing them early prevents architectural decay"
|
||||
}
|
||||
}
|
||||
|
||||
t1Count := 0
|
||||
for _, f := range findings {
|
||||
if f.Status == StatusOpen && f.Severity == SeverityT1 {
|
||||
t1Count++
|
||||
}
|
||||
}
|
||||
|
||||
if t1Count > 5 {
|
||||
return "Quick wins available - auto-fixers can clear low-hanging fruit in minutes"
|
||||
}
|
||||
|
||||
return "Consistent small improvements compound into significant quality gains"
|
||||
}
|
||||
@@ -0,0 +1,565 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
type SingleUseDetector struct {
|
||||
*quality.BaseDetector
|
||||
minLOC int
|
||||
}
|
||||
|
||||
func NewSingleUseDetector(finder quality.FileFinder) *SingleUseDetector {
|
||||
return &SingleUseDetector{
|
||||
BaseDetector: quality.NewBaseDetector("single_use", quality.SeverityT3, finder),
|
||||
minLOC: 10,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SingleUseDetector) Name() string {
|
||||
return "single_use"
|
||||
}
|
||||
|
||||
func (d *SingleUseDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *SingleUseDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedFiles | packages.NeedSyntax,
|
||||
Dir: path,
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load packages: %w", err)
|
||||
}
|
||||
|
||||
callCounts := make(map[string]int)
|
||||
funcDefs := make(map[string]FuncDef)
|
||||
typeUsages := make(map[string]int)
|
||||
typeDefs := make(map[string]TypeDef)
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for _, obj := range pkg.TypesInfo.Uses {
|
||||
if obj == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch obj := obj.(type) {
|
||||
case *types.Func:
|
||||
key := obj.Pkg().Path() + "." + obj.Name()
|
||||
callCounts[key]++
|
||||
case *types.TypeName:
|
||||
if obj.Pkg() != nil {
|
||||
key := obj.Pkg().Path() + "." + obj.Name()
|
||||
typeUsages[key]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, obj := range pkg.TypesInfo.Defs {
|
||||
if obj == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
switch obj := obj.(type) {
|
||||
case *types.Func:
|
||||
if obj.Pkg() != nil {
|
||||
key := obj.Pkg().Path() + "." + obj.Name()
|
||||
pos := pkg.Fset.Position(obj.Pos())
|
||||
funcDefs[key] = FuncDef{
|
||||
Name: obj.Name(),
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Package: obj.Pkg().Path(),
|
||||
Exported: obj.Exported(),
|
||||
Signature: obj.Type().String(),
|
||||
}
|
||||
}
|
||||
case *types.TypeName:
|
||||
if obj.Pkg() != nil {
|
||||
key := obj.Pkg().Path() + "." + obj.Name()
|
||||
pos := pkg.Fset.Position(obj.Pos())
|
||||
typeDefs[key] = TypeDef{
|
||||
Name: obj.Name(),
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Package: obj.Pkg().Path(),
|
||||
Exported: obj.Exported(),
|
||||
Underlying: obj.Type().Underlying().String(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
entryPoints := d.findEntryPoints(pkgs)
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for key, def := range funcDefs {
|
||||
if strings.HasSuffix(def.Name, "Test") || strings.HasPrefix(def.Name, "Test") {
|
||||
continue
|
||||
}
|
||||
if strings.HasSuffix(def.Name, "Handler") || strings.HasSuffix(def.Name, "Middleware") {
|
||||
continue
|
||||
}
|
||||
|
||||
count := callCounts[key]
|
||||
if count == 1 && !d.isEntryPoint(def.Name, entryPoints) {
|
||||
loc, _ := d.getFuncLOC(def.File, def.Line)
|
||||
if loc >= d.minLOC {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("single_use_func::%s::%s", def.File, def.Name),
|
||||
Type: "single_use",
|
||||
Title: fmt.Sprintf("Single-use function: %s", def.Name),
|
||||
Description: fmt.Sprintf("Function '%s' is only used once. Consider inlining it or documenting its purpose.", def.Name),
|
||||
File: def.File,
|
||||
Line: def.Line,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: 3,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"name": def.Name,
|
||||
"usage_count": fmt.Sprintf("%d", count),
|
||||
"loc": fmt.Sprintf("%d", loc),
|
||||
"exported": fmt.Sprintf("%v", def.Exported),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, def := range typeDefs {
|
||||
if strings.HasSuffix(def.Name, "Error") || strings.HasSuffix(def.Name, "Options") {
|
||||
continue
|
||||
}
|
||||
|
||||
count := typeUsages[key]
|
||||
if count == 1 {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("single_use_type::%s::%s", def.File, def.Name),
|
||||
Type: "single_use",
|
||||
Title: fmt.Sprintf("Single-use type: %s", def.Name),
|
||||
Description: fmt.Sprintf("Type '%s' is only used once. Consider if this abstraction is necessary.", def.Name),
|
||||
File: def.File,
|
||||
Line: def.Line,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: 4,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"name": def.Name,
|
||||
"usage_count": fmt.Sprintf("%d", count),
|
||||
"exported": fmt.Sprintf("%v", def.Exported),
|
||||
"underlying": def.Underlying,
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *SingleUseDetector) findEntryPoints(pkgs []*packages.Package) map[string]bool {
|
||||
entryPoints := make(map[string]bool)
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for _, file := range pkg.Syntax {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
switch node := n.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if node.Name.Name == "main" {
|
||||
entryPoints[pkg.PkgPath+".main"] = true
|
||||
}
|
||||
if node.Name.Name == "init" {
|
||||
entryPoints[pkg.PkgPath+".init"] = true
|
||||
}
|
||||
if node.Recv == nil {
|
||||
for _, decl := range node.Type.Params.List {
|
||||
if d.isHTTPHandlerType(decl.Type) {
|
||||
entryPoints[pkg.PkgPath+"."+node.Name.Name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return entryPoints
|
||||
}
|
||||
|
||||
func (d *SingleUseDetector) isHTTPHandlerType(expr ast.Expr) bool {
|
||||
if sel, ok := expr.(*ast.SelectorExpr); ok {
|
||||
if ident, ok := sel.X.(*ast.Ident); ok {
|
||||
return (ident.Name == "http" && (sel.Sel.Name == "Handler" || sel.Sel.Name == "HandlerFunc" || sel.Sel.Name == "ResponseWriter"))
|
||||
}
|
||||
}
|
||||
if star, ok := expr.(*ast.StarExpr); ok {
|
||||
return d.isHTTPHandlerType(star.X)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (d *SingleUseDetector) isEntryPoint(name string, entryPoints map[string]bool) bool {
|
||||
return entryPoints[name] || name == "main" || name == "init"
|
||||
}
|
||||
|
||||
func (d *SingleUseDetector) getFuncLOC(file string, startLine int) (int, error) {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, file, nil, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
loc := 0
|
||||
ast.Inspect(node, func(n ast.Node) bool {
|
||||
if fn, ok := n.(*ast.FuncDecl); ok {
|
||||
pos := fset.Position(fn.Pos())
|
||||
if pos.Line == startLine {
|
||||
end := fset.Position(fn.End())
|
||||
loc = end.Line - pos.Line + 1
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return loc, nil
|
||||
}
|
||||
|
||||
type FuncDef struct {
|
||||
Name string
|
||||
File string
|
||||
Line int
|
||||
Package string
|
||||
Exported bool
|
||||
Signature string
|
||||
}
|
||||
|
||||
type TypeDef struct {
|
||||
Name string
|
||||
File string
|
||||
Line int
|
||||
Package string
|
||||
Exported bool
|
||||
Underlying string
|
||||
}
|
||||
|
||||
type CouplingDetector struct {
|
||||
*quality.BaseDetector
|
||||
maxFanOut int
|
||||
}
|
||||
|
||||
func NewCouplingDetector(finder quality.FileFinder) *CouplingDetector {
|
||||
return &CouplingDetector{
|
||||
BaseDetector: quality.NewBaseDetector("coupling", quality.SeverityT3, finder),
|
||||
maxFanOut: 10,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *CouplingDetector) Name() string {
|
||||
return "coupling"
|
||||
}
|
||||
|
||||
func (d *CouplingDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *CouplingDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedImports | packages.NeedFiles,
|
||||
Dir: path,
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load packages: %w", err)
|
||||
}
|
||||
|
||||
pkgImports := make(map[string][]string)
|
||||
pkgImportedBy := make(map[string][]string)
|
||||
pkgFiles := make(map[string]int)
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
pkgFiles[pkg.PkgPath] = len(pkg.GoFiles)
|
||||
for _, imp := range pkg.Imports {
|
||||
pkgImports[pkg.PkgPath] = append(pkgImports[pkg.PkgPath], imp.PkgPath)
|
||||
pkgImportedBy[imp.PkgPath] = append(pkgImportedBy[imp.PkgPath], pkg.PkgPath)
|
||||
}
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for pkg, imports := range pkgImports {
|
||||
fanOut := len(imports)
|
||||
if fanOut > d.maxFanOut {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("coupling_fanout::%s", pkg),
|
||||
Type: "coupling",
|
||||
Title: fmt.Sprintf("High fan-out coupling: %s", filepath.Base(pkg)),
|
||||
Description: fmt.Sprintf("Package '%s' imports %d packages (max: %d). Consider reducing dependencies.", pkg, fanOut, d.maxFanOut),
|
||||
File: pkg,
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: fanOut - d.maxFanOut,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"package": pkg,
|
||||
"fan_out": fmt.Sprintf("%d", fanOut),
|
||||
"imports": strings.Join(imports, ","),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
for pkg, importedBy := range pkgImportedBy {
|
||||
fanIn := len(importedBy)
|
||||
if fanIn > d.maxFanOut*2 {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("coupling_fanin::%s", pkg),
|
||||
Type: "coupling",
|
||||
Title: fmt.Sprintf("High fan-in coupling: %s", filepath.Base(pkg)),
|
||||
Description: fmt.Sprintf("Package '%s' is imported by %d packages. Ensure it's stable and well-documented.", pkg, fanIn),
|
||||
File: pkg,
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT2,
|
||||
Score: fanIn/5 - d.maxFanOut/5,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"package": pkg,
|
||||
"fan_in": fmt.Sprintf("%d", fanIn),
|
||||
"imported_by": strings.Join(importedBy, ","),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
findings = append(findings, d.detectHubPackages(pkgImports, pkgImportedBy)...)
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *CouplingDetector) detectHubPackages(pkgImports, pkgImportedBy map[string][]string) []quality.Finding {
|
||||
var findings []quality.Finding
|
||||
|
||||
for pkg, imports := range pkgImports {
|
||||
importedBy := pkgImportedBy[pkg]
|
||||
centrality := len(imports) + len(importedBy)
|
||||
|
||||
if centrality > d.maxFanOut*3 {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("coupling_hub::%s", pkg),
|
||||
Type: "coupling",
|
||||
Title: fmt.Sprintf("Hub package detected: %s", filepath.Base(pkg)),
|
||||
Description: fmt.Sprintf("Package '%s' is a coupling hub with %d connections (%d imports, %d imported by). Consider splitting.", pkg, centrality, len(imports), len(importedBy)),
|
||||
File: pkg,
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: centrality / 5,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"package": pkg,
|
||||
"centrality": fmt.Sprintf("%d", centrality),
|
||||
"fan_out": fmt.Sprintf("%d", len(imports)),
|
||||
"fan_in": fmt.Sprintf("%d", len(importedBy)),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
type EnhancedDeadCodeDetector struct {
|
||||
*quality.BaseDetector
|
||||
}
|
||||
|
||||
func NewEnhancedDeadCodeDetector(finder quality.FileFinder) *EnhancedDeadCodeDetector {
|
||||
return &EnhancedDeadCodeDetector{
|
||||
BaseDetector: quality.NewBaseDetector("dead_code_enhanced", quality.SeverityT2, finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *EnhancedDeadCodeDetector) Name() string {
|
||||
return "dead_code_enhanced"
|
||||
}
|
||||
|
||||
func (d *EnhancedDeadCodeDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT2
|
||||
}
|
||||
|
||||
func (d *EnhancedDeadCodeDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedFiles | packages.NeedSyntax,
|
||||
Dir: path,
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load packages: %w", err)
|
||||
}
|
||||
|
||||
used := make(map[string]bool)
|
||||
defs := make(map[string]ObjInfo)
|
||||
entryPoints := make(map[string]bool)
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
if pkg.Name == "main" {
|
||||
for _, file := range pkg.Syntax {
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
if fn, ok := n.(*ast.FuncDecl); ok {
|
||||
if fn.Name.Name == "main" || fn.Name.Name == "init" {
|
||||
entryPoints[pkg.PkgPath+"."+fn.Name.Name] = true
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, obj := range pkg.TypesInfo.Uses {
|
||||
if obj != nil && obj.Pkg() != nil {
|
||||
used[obj.Pkg().Path()+"."+obj.Name()] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, obj := range pkg.TypesInfo.Defs {
|
||||
if obj == nil || obj.Pkg() == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
key := obj.Pkg().Path() + "." + obj.Name()
|
||||
pos := pkg.Fset.Position(obj.Pos())
|
||||
|
||||
switch o := obj.(type) {
|
||||
case *types.Func:
|
||||
defs[key] = ObjInfo{
|
||||
Name: obj.Name(),
|
||||
Type: "function",
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Package: obj.Pkg().Path(),
|
||||
Exported: obj.Exported(),
|
||||
Signature: o.Type().String(),
|
||||
}
|
||||
case *types.TypeName:
|
||||
defs[key] = ObjInfo{
|
||||
Name: obj.Name(),
|
||||
Type: "type",
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Package: obj.Pkg().Path(),
|
||||
Exported: obj.Exported(),
|
||||
Underlying: o.Type().Underlying().String(),
|
||||
}
|
||||
case *types.Var:
|
||||
if obj.Exported() {
|
||||
defs[key] = ObjInfo{
|
||||
Name: obj.Name(),
|
||||
Type: "variable",
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Package: obj.Pkg().Path(),
|
||||
Exported: obj.Exported(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testPkgs := make(map[string]bool)
|
||||
for _, pkg := range pkgs {
|
||||
if strings.HasSuffix(pkg.PkgPath, "_test") || strings.Contains(pkg.Name, "test") {
|
||||
testPkgs[pkg.PkgPath] = true
|
||||
}
|
||||
for _, file := range pkg.GoFiles {
|
||||
if strings.HasSuffix(file, "_test.go") {
|
||||
testPkgs[pkg.PkgPath] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for key, def := range defs {
|
||||
if entryPoints[key] {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(def.Name, "Test") || strings.HasPrefix(def.Name, "Benchmark") || strings.HasPrefix(def.Name, "Fuzz") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasSuffix(def.Name, "Error") && def.Type == "type" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(def.File, "_test.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !used[key] && def.Exported {
|
||||
severity := quality.SeverityT2
|
||||
score := 5
|
||||
|
||||
if strings.HasSuffix(def.File, "/cmd/") || strings.Contains(def.File, "/cmd/") {
|
||||
severity = quality.SeverityT3
|
||||
score = 3
|
||||
}
|
||||
|
||||
if def.Type == "type" {
|
||||
severity = quality.SeverityT3
|
||||
score = 4
|
||||
}
|
||||
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("dead_code::%s::%s", def.File, def.Name),
|
||||
Type: "dead_code",
|
||||
Title: fmt.Sprintf("Unused exported %s: %s", def.Type, def.Name),
|
||||
Description: fmt.Sprintf("The exported %s '%s' is never used. Consider removing it or if it's part of a public API, document it.", def.Type, def.Name),
|
||||
File: def.File,
|
||||
Line: def.Line,
|
||||
Severity: severity,
|
||||
Score: score,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"name": def.Name,
|
||||
"obj_type": def.Type,
|
||||
"package": def.Package,
|
||||
"exported": "true",
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
type ObjInfo struct {
|
||||
Name string
|
||||
Type string
|
||||
File string
|
||||
Line int
|
||||
Package string
|
||||
Exported bool
|
||||
Signature string
|
||||
Underlying string
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
type DeadCodeDetector struct {
|
||||
*quality.BaseDetector
|
||||
}
|
||||
|
||||
func NewDeadCodeDetector(finder quality.FileFinder) *DeadCodeDetector {
|
||||
return &DeadCodeDetector{
|
||||
BaseDetector: quality.NewBaseDetector("dead_code", quality.SeverityT2, finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeadCodeDetector) Name() string {
|
||||
return "dead_code"
|
||||
}
|
||||
|
||||
func (d *DeadCodeDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT2
|
||||
}
|
||||
|
||||
func (d *DeadCodeDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo | packages.NeedFiles,
|
||||
Dir: path,
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load packages: %w", err)
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
used := make(map[string]bool)
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for _, obj := range pkg.TypesInfo.Uses {
|
||||
if obj != nil && obj.Pkg() != nil {
|
||||
used[obj.Pkg().Path()+"."+obj.Name()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
for _, obj := range pkg.TypesInfo.Defs {
|
||||
if obj == nil || obj.Pkg() == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if !obj.Exported() {
|
||||
continue
|
||||
}
|
||||
|
||||
key := obj.Pkg().Path() + "." + obj.Name()
|
||||
if !used[key] {
|
||||
pos := pkg.Fset.Position(obj.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("dead_code::%s::%s", pos.Filename, obj.Name()),
|
||||
Type: "dead_code",
|
||||
Title: fmt.Sprintf("Unused exported identifier: %s", obj.Name()),
|
||||
Description: fmt.Sprintf("The exported %s '%s' is never used in the codebase. Consider removing it or documenting its intended use.", obj.Type(), obj.Name()),
|
||||
File: pos.Filename,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT2,
|
||||
Score: 5,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"name": obj.Name(),
|
||||
"type": obj.Type().String(),
|
||||
"package": obj.Pkg().Path(),
|
||||
"exported": "true",
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
type UnusedImportDetector struct {
|
||||
*quality.BaseDetector
|
||||
}
|
||||
|
||||
func NewUnusedImportDetector(finder quality.FileFinder) *UnusedImportDetector {
|
||||
return &UnusedImportDetector{
|
||||
BaseDetector: quality.NewBaseDetector("unused_import", quality.SeverityT1, finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *UnusedImportDetector) Name() string {
|
||||
return "unused_import"
|
||||
}
|
||||
|
||||
func (d *UnusedImportDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT1
|
||||
}
|
||||
|
||||
func (d *UnusedImportDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for _, file := range files {
|
||||
fileFindings, err := d.analyzeFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *UnusedImportDetector) analyzeFile(path string) ([]quality.Finding, error) {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly|parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
imports := make(map[string]string)
|
||||
for _, imp := range node.Imports {
|
||||
pkgPath := strings.Trim(imp.Path.Value, `"`)
|
||||
name := ""
|
||||
if imp.Name != nil {
|
||||
name = imp.Name.Name
|
||||
} else {
|
||||
parts := strings.Split(pkgPath, "/")
|
||||
name = parts[len(parts)-1]
|
||||
}
|
||||
imports[pkgPath] = name
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
contentStr := string(content)
|
||||
|
||||
var findings []quality.Finding
|
||||
for _, imp := range node.Imports {
|
||||
pkgPath := strings.Trim(imp.Path.Value, `"`)
|
||||
name := ""
|
||||
if imp.Name != nil {
|
||||
name = imp.Name.Name
|
||||
} else {
|
||||
parts := strings.Split(pkgPath, "/")
|
||||
name = parts[len(parts)-1]
|
||||
}
|
||||
|
||||
if name == "_" || name == "." {
|
||||
continue
|
||||
}
|
||||
|
||||
pattern := name + "."
|
||||
if !strings.Contains(contentStr, pattern) {
|
||||
pos := fset.Position(imp.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("unused_import::%s::%s", path, pkgPath),
|
||||
Type: "unused_import",
|
||||
Title: fmt.Sprintf("Unused import: %s", pkgPath),
|
||||
Description: fmt.Sprintf("The import '%s' is not used in this file. Remove it to clean up the code.", pkgPath),
|
||||
File: path,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT1,
|
||||
Score: 2,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"import_path": pkgPath,
|
||||
"alias": name,
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
type CycleDetector struct {
|
||||
*quality.BaseDetector
|
||||
}
|
||||
|
||||
func NewCycleDetector(finder quality.FileFinder) *CycleDetector {
|
||||
return &CycleDetector{
|
||||
BaseDetector: quality.NewBaseDetector("import_cycle", quality.SeverityT4, finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *CycleDetector) Name() string {
|
||||
return "import_cycle"
|
||||
}
|
||||
|
||||
func (d *CycleDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT4
|
||||
}
|
||||
|
||||
func (d *CycleDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedImports,
|
||||
Dir: path,
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load packages: %w", err)
|
||||
}
|
||||
|
||||
localPkgs := make(map[string]bool)
|
||||
for _, pkg := range pkgs {
|
||||
localPkgs[pkg.PkgPath] = true
|
||||
}
|
||||
|
||||
graph := make(map[string][]string)
|
||||
for _, pkg := range pkgs {
|
||||
for _, imp := range pkg.Imports {
|
||||
if localPkgs[imp.PkgPath] {
|
||||
graph[pkg.PkgPath] = append(graph[pkg.PkgPath], imp.PkgPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cycles := d.findCycles(graph)
|
||||
|
||||
var findings []quality.Finding
|
||||
for i, cycle := range cycles {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("import_cycle::%d", i),
|
||||
Type: "import_cycle",
|
||||
Title: "Import cycle detected",
|
||||
Description: fmt.Sprintf("Circular import dependency: %s", strings.Join(cycle, " → ")),
|
||||
File: cycle[0],
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 20,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"cycle": strings.Join(cycle, ","),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *CycleDetector) findCycles(graph map[string][]string) [][]string {
|
||||
var cycles [][]string
|
||||
visited := make(map[string]bool)
|
||||
recStack := make(map[string]bool)
|
||||
|
||||
var dfs func(node string, path []string)
|
||||
dfs = func(node string, path []string) {
|
||||
visited[node] = true
|
||||
recStack[node] = true
|
||||
path = append(path, node)
|
||||
|
||||
for _, neighbor := range graph[node] {
|
||||
if !visited[neighbor] {
|
||||
dfs(neighbor, path)
|
||||
} else if recStack[neighbor] {
|
||||
cycleStart := -1
|
||||
for i, n := range path {
|
||||
if n == neighbor {
|
||||
cycleStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cycleStart >= 0 {
|
||||
cycle := make([]string, len(path)-cycleStart)
|
||||
copy(cycle, path[cycleStart:])
|
||||
cycles = append(cycles, cycle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path = path[:len(path)-1]
|
||||
recStack[node] = false
|
||||
}
|
||||
|
||||
for node := range graph {
|
||||
if !visited[node] {
|
||||
dfs(node, []string{})
|
||||
}
|
||||
}
|
||||
|
||||
return cycles
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
type LargeFileDetector struct {
|
||||
*quality.BaseDetector
|
||||
maxLOC int
|
||||
}
|
||||
|
||||
func NewLargeFileDetector(finder quality.FileFinder) *LargeFileDetector {
|
||||
return &LargeFileDetector{
|
||||
BaseDetector: quality.NewBaseDetector("large_file", quality.SeverityT3, finder),
|
||||
maxLOC: 500,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *LargeFileDetector) Name() string {
|
||||
return "large_file"
|
||||
}
|
||||
|
||||
func (d *LargeFileDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *LargeFileDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for _, file := range files {
|
||||
loc, err := countLines(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if loc > d.maxLOC {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("large_file::%s", file),
|
||||
Type: "large_file",
|
||||
Title: fmt.Sprintf("Large file detected: %d lines", loc),
|
||||
Description: fmt.Sprintf("File '%s' has %d lines (max: %d). Consider splitting into smaller, focused files.", filepath.Base(file), loc, d.maxLOC),
|
||||
File: file,
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: (loc - d.maxLOC) / 50,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"loc": fmt.Sprintf("%d", loc),
|
||||
"max_loc": fmt.Sprintf("%d", d.maxLOC),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
type GodStructDetector struct {
|
||||
*quality.BaseDetector
|
||||
maxFields int
|
||||
maxMethods int
|
||||
}
|
||||
|
||||
func NewGodStructDetector(finder quality.FileFinder) *GodStructDetector {
|
||||
return &GodStructDetector{
|
||||
BaseDetector: quality.NewBaseDetector("god_struct", quality.SeverityT3, finder),
|
||||
maxFields: 15,
|
||||
maxMethods: 20,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *GodStructDetector) Name() string {
|
||||
return "god_struct"
|
||||
}
|
||||
|
||||
func (d *GodStructDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *GodStructDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for _, file := range files {
|
||||
fileFindings := d.analyzeFile(file)
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *GodStructDetector) analyzeFile(path string) []quality.Finding {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
methodCounts := make(map[string]int)
|
||||
for _, decl := range node.Decls {
|
||||
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Recv != nil {
|
||||
for _, field := range fn.Recv.List {
|
||||
for _, name := range field.Names {
|
||||
methodCounts[name.Name]++
|
||||
}
|
||||
if len(field.Names) == 0 {
|
||||
if star, ok := field.Type.(*ast.StarExpr); ok {
|
||||
if ident, ok := star.X.(*ast.Ident); ok {
|
||||
methodCounts[ident.Name]++
|
||||
}
|
||||
} else if ident, ok := field.Type.(*ast.Ident); ok {
|
||||
methodCounts[ident.Name]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for _, decl := range node.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
structType, ok := typeSpec.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldCount := len(structType.Fields.List)
|
||||
methodCount := methodCounts[typeSpec.Name.Name]
|
||||
|
||||
if fieldCount > d.maxFields {
|
||||
pos := fset.Position(typeSpec.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("god_struct::%s::%s", path, typeSpec.Name.Name),
|
||||
Type: "god_struct",
|
||||
Title: fmt.Sprintf("God struct detected: %s", typeSpec.Name.Name),
|
||||
Description: fmt.Sprintf("Struct '%s' has %d fields (max: %d). Consider breaking it into smaller, focused structs.", typeSpec.Name.Name, fieldCount, d.maxFields),
|
||||
File: path,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: (fieldCount - d.maxFields) * 2,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"struct_name": typeSpec.Name.Name,
|
||||
"field_count": fmt.Sprintf("%d", fieldCount),
|
||||
"max_fields": fmt.Sprintf("%d", d.maxFields),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
if methodCount > d.maxMethods {
|
||||
pos := fset.Position(typeSpec.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("god_struct_methods::%s::%s", path, typeSpec.Name.Name),
|
||||
Type: "god_struct",
|
||||
Title: fmt.Sprintf("God struct (methods): %s", typeSpec.Name.Name),
|
||||
Description: fmt.Sprintf("Struct '%s' has %d methods (max: %d). Consider splitting responsibilities.", typeSpec.Name.Name, methodCount, d.maxMethods),
|
||||
File: path,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: (methodCount - d.maxMethods) * 2,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"struct_name": typeSpec.Name.Name,
|
||||
"method_count": fmt.Sprintf("%d", methodCount),
|
||||
"max_methods": fmt.Sprintf("%d", d.maxMethods),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
type DebugLogDetector struct {
|
||||
*quality.BaseDetector
|
||||
}
|
||||
|
||||
func NewDebugLogDetector(finder quality.FileFinder) *DebugLogDetector {
|
||||
return &DebugLogDetector{
|
||||
BaseDetector: quality.NewBaseDetector("debug_log", quality.SeverityT1, finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DebugLogDetector) Name() string {
|
||||
return "debug_log"
|
||||
}
|
||||
|
||||
func (d *DebugLogDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT1
|
||||
}
|
||||
|
||||
func (d *DebugLogDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for _, file := range files {
|
||||
fileFindings := d.analyzeFile(file)
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *DebugLogDetector) analyzeFile(path string) []quality.Finding {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
debugPatterns := []string{
|
||||
"log.Print",
|
||||
"log.Println",
|
||||
"log.Printf",
|
||||
"log.Fatal",
|
||||
"log.Fatalf",
|
||||
"log.Fatalln",
|
||||
}
|
||||
|
||||
cliPatterns := []string{
|
||||
"fmt.Print",
|
||||
"fmt.Println",
|
||||
"fmt.Printf",
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
ast.Inspect(node, func(n ast.Node) bool {
|
||||
call, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
callStr := exprToString(call.Fun)
|
||||
|
||||
for _, pattern := range debugPatterns {
|
||||
if callStr == pattern || strings.HasPrefix(callStr, pattern) {
|
||||
if strings.Contains(path, "_test.go") {
|
||||
return true
|
||||
}
|
||||
|
||||
pos := fset.Position(call.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("debug_log::%s::%d", path, pos.Line),
|
||||
Type: "debug_log",
|
||||
Title: "Debug log statement detected",
|
||||
Description: fmt.Sprintf("Found '%s' statement. Consider using structured logging instead.", callStr),
|
||||
File: path,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT1,
|
||||
Score: 2,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"call": callStr,
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(path, "/cmd/") {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, pattern := range cliPatterns {
|
||||
if callStr == pattern || strings.HasPrefix(callStr, pattern) {
|
||||
pos := fset.Position(call.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("debug_log::%s::%d", path, pos.Line),
|
||||
Type: "debug_log",
|
||||
Title: "Potential debug print in non-CLI code",
|
||||
Description: fmt.Sprintf("Found '%s' in library code. Consider using structured logging or returning errors.", callStr),
|
||||
File: path,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT1,
|
||||
Score: 2,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"call": callStr,
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
type GodFunctionDetector struct {
|
||||
*quality.BaseDetector
|
||||
maxLOC int
|
||||
maxParams int
|
||||
maxReturns int
|
||||
maxNesting int
|
||||
}
|
||||
|
||||
func NewGodFunctionDetector(finder quality.FileFinder) *GodFunctionDetector {
|
||||
return &GodFunctionDetector{
|
||||
BaseDetector: quality.NewBaseDetector("god_function", quality.SeverityT3, finder),
|
||||
maxLOC: 50,
|
||||
maxParams: 5,
|
||||
maxReturns: 3,
|
||||
maxNesting: 4,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *GodFunctionDetector) Name() string {
|
||||
return "god_function"
|
||||
}
|
||||
|
||||
func (d *GodFunctionDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *GodFunctionDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for _, file := range files {
|
||||
fileFindings := d.analyzeFile(file)
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *GodFunctionDetector) analyzeFile(path string) []quality.Finding {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for _, decl := range node.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
startLine := fset.Position(fn.Pos()).Line
|
||||
endLine := fset.Position(fn.End()).Line
|
||||
loc := endLine - startLine + 1
|
||||
|
||||
paramCount := 0
|
||||
if fn.Type.Params != nil {
|
||||
for _, field := range fn.Type.Params.List {
|
||||
paramCount += len(field.Names)
|
||||
if len(field.Names) == 0 {
|
||||
paramCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
returnCount := 0
|
||||
if fn.Type.Results != nil {
|
||||
returnCount = len(fn.Type.Results.List)
|
||||
}
|
||||
|
||||
nestingDepth := d.calculateNesting(fn)
|
||||
|
||||
var issues []string
|
||||
if loc > d.maxLOC {
|
||||
issues = append(issues, fmt.Sprintf("%d lines (max %d)", loc, d.maxLOC))
|
||||
}
|
||||
if paramCount > d.maxParams {
|
||||
issues = append(issues, fmt.Sprintf("%d params (max %d)", paramCount, d.maxParams))
|
||||
}
|
||||
if returnCount > d.maxReturns {
|
||||
issues = append(issues, fmt.Sprintf("%d returns (max %d)", returnCount, d.maxReturns))
|
||||
}
|
||||
if nestingDepth > d.maxNesting {
|
||||
issues = append(issues, fmt.Sprintf("nesting depth %d (max %d)", nestingDepth, d.maxNesting))
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("god_function::%s::%s", path, fn.Name.Name),
|
||||
Type: "god_function",
|
||||
Title: fmt.Sprintf("God function: %s", fn.Name.Name),
|
||||
Description: fmt.Sprintf("Function '%s' has issues: %s", fn.Name.Name, strings.Join(issues, ", ")),
|
||||
File: path,
|
||||
Line: startLine,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: len(issues) * 3,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"function": fn.Name.Name,
|
||||
"loc": fmt.Sprintf("%d", loc),
|
||||
"params": fmt.Sprintf("%d", paramCount),
|
||||
"returns": fmt.Sprintf("%d", returnCount),
|
||||
"nesting_depth": fmt.Sprintf("%d", nestingDepth),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings
|
||||
}
|
||||
|
||||
func (d *GodFunctionDetector) calculateNesting(fn *ast.FuncDecl) int {
|
||||
maxDepth := 0
|
||||
|
||||
var visit func(n ast.Node, depth int)
|
||||
visit = func(n ast.Node, depth int) {
|
||||
if depth > maxDepth {
|
||||
maxDepth = depth
|
||||
}
|
||||
|
||||
switch stmt := n.(type) {
|
||||
case *ast.IfStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
if stmt.Else != nil {
|
||||
visit(stmt.Else, depth+1)
|
||||
}
|
||||
case *ast.ForStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
case *ast.RangeStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
case *ast.SwitchStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
case *ast.SelectStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
case *ast.BlockStmt:
|
||||
for _, s := range stmt.List {
|
||||
visit(s, depth)
|
||||
}
|
||||
case *ast.CaseClause:
|
||||
for _, s := range stmt.Body {
|
||||
visit(s, depth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if fn.Body != nil {
|
||||
visit(fn.Body, 0)
|
||||
}
|
||||
|
||||
return maxDepth
|
||||
}
|
||||
|
||||
func exprToString(expr ast.Expr) string {
|
||||
switch e := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return e.Name
|
||||
case *ast.SelectorExpr:
|
||||
return exprToString(e.X) + "." + e.Sel.Name
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func countLines(path string) (int, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return strings.Count(string(data), "\n") + 1, nil
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
type SecurityDetector struct {
|
||||
*quality.BaseDetector
|
||||
patterns []SecurityPattern
|
||||
}
|
||||
|
||||
type SecurityPattern struct {
|
||||
Name string
|
||||
Description string
|
||||
Pattern *regexp.Regexp
|
||||
Severity quality.Severity
|
||||
Score int
|
||||
}
|
||||
|
||||
func NewSecurityDetector(finder quality.FileFinder) *SecurityDetector {
|
||||
d := &SecurityDetector{
|
||||
BaseDetector: quality.NewBaseDetector("security", quality.SeverityT3, finder),
|
||||
patterns: []SecurityPattern{
|
||||
{
|
||||
Name: "hardcoded_password",
|
||||
Description: "Hardcoded password or secret detected",
|
||||
Pattern: regexp.MustCompile(`(?i)(password|passwd|pwd|secret|api_key|apikey|token)\s*[:=]\s*["'][^"']+["']`),
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 30,
|
||||
},
|
||||
{
|
||||
Name: "sql_injection_risk",
|
||||
Description: "Potential SQL injection - string concatenation in query",
|
||||
Pattern: regexp.MustCompile(`fmt\.Sprintf.*SELECT|fmt\.Sprintf.*INSERT|fmt\.Sprintf.*UPDATE|fmt\.Sprintf.*DELETE`),
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 25,
|
||||
},
|
||||
{
|
||||
Name: "unsafe_sql_exec",
|
||||
Description: "Direct string interpolation in SQL execution",
|
||||
Pattern: regexp.MustCompile(`db\.(Exec|Query).*\+|db\.(Exec|Query).*fmt\.Sprintf`),
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 25,
|
||||
},
|
||||
{
|
||||
Name: "weak_random",
|
||||
Description: "Using math/rand for security-sensitive operations",
|
||||
Pattern: regexp.MustCompile(`math/rand.*token|math/rand.*password|math/rand.*secret|math/rand.*key`),
|
||||
Severity: quality.SeverityT3,
|
||||
Score: 15,
|
||||
},
|
||||
{
|
||||
Name: "todo_security",
|
||||
Description: "TODO/FIXME related to security",
|
||||
Pattern: regexp.MustCompile(`(?i)(TODO|FIXME|XXX).*security|(?i)(TODO|FIXME|XXX).*auth|(?i)(TODO|FIXME|XXX).*password`),
|
||||
Severity: quality.SeverityT2,
|
||||
Score: 5,
|
||||
},
|
||||
{
|
||||
Name: "os_exec_shell",
|
||||
Description: "Command execution with potential shell injection",
|
||||
Pattern: regexp.MustCompile(`exec\.Command.*sh.*-c|exec\.Command.*bash.*-c`),
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) Name() string {
|
||||
return "security"
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for _, file := range files {
|
||||
fileFindings, err := d.analyzeFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) analyzeFile(filePath string) ([]quality.Finding, error) {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
ast.Inspect(node, func(n ast.Node) bool {
|
||||
switch x := n.(type) {
|
||||
case *ast.CallExpr:
|
||||
d.checkCallExpr(x, fset, filePath, &findings)
|
||||
case *ast.AssignStmt:
|
||||
d.checkAssignStmt(x, fset, filePath, &findings)
|
||||
case *ast.ValueSpec:
|
||||
d.checkValueSpec(x, fset, filePath, &findings)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
d.checkComments(node, fset, filePath, &findings)
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) checkCallExpr(expr *ast.CallExpr, fset *token.FileSet, file string, findings *[]quality.Finding) {
|
||||
exprStr := d.nodeToString(expr)
|
||||
pos := fset.Position(expr.Pos())
|
||||
|
||||
for _, pattern := range d.patterns {
|
||||
if pattern.Pattern.MatchString(exprStr) {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("security::%s::%d", file, pos.Line),
|
||||
Type: "security",
|
||||
Title: pattern.Name,
|
||||
Description: pattern.Description,
|
||||
File: file,
|
||||
Line: pos.Line,
|
||||
Severity: pattern.Severity,
|
||||
Score: pattern.Score,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"pattern": pattern.Name,
|
||||
"match": exprStr,
|
||||
"severity": fmt.Sprintf("%d", pattern.Severity),
|
||||
},
|
||||
}
|
||||
*findings = append(*findings, finding)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) checkAssignStmt(stmt *ast.AssignStmt, fset *token.FileSet, file string, findings *[]quality.Finding) {
|
||||
for _, expr := range stmt.Lhs {
|
||||
if ident, ok := expr.(*ast.Ident); ok {
|
||||
if strings.Contains(strings.ToLower(ident.Name), "password") ||
|
||||
strings.Contains(strings.ToLower(ident.Name), "secret") ||
|
||||
strings.Contains(strings.ToLower(ident.Name), "token") {
|
||||
for _, val := range stmt.Rhs {
|
||||
if basicLit, ok := val.(*ast.BasicLit); ok && basicLit.Kind == token.STRING {
|
||||
pos := fset.Position(stmt.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("security::%s::%d", file, pos.Line),
|
||||
Type: "security",
|
||||
Title: "hardcoded_credential",
|
||||
Description: fmt.Sprintf("Hardcoded credential in variable '%s'", ident.Name),
|
||||
File: file,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 30,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"variable": ident.Name,
|
||||
},
|
||||
}
|
||||
*findings = append(*findings, finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) checkValueSpec(spec *ast.ValueSpec, fset *token.FileSet, file string, findings *[]quality.Finding) {
|
||||
for i, name := range spec.Names {
|
||||
lowerName := strings.ToLower(name.Name)
|
||||
if strings.Contains(lowerName, "password") ||
|
||||
strings.Contains(lowerName, "secret") ||
|
||||
strings.Contains(lowerName, "apikey") ||
|
||||
strings.Contains(lowerName, "token") {
|
||||
if len(spec.Values) > i {
|
||||
if basicLit, ok := spec.Values[i].(*ast.BasicLit); ok && basicLit.Kind == token.STRING {
|
||||
pos := fset.Position(spec.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("security::%s::%d", file, pos.Line),
|
||||
Type: "security",
|
||||
Title: "hardcoded_credential",
|
||||
Description: fmt.Sprintf("Hardcoded credential in variable '%s'", name.Name),
|
||||
File: file,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT4,
|
||||
Score: 30,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"variable": name.Name,
|
||||
},
|
||||
}
|
||||
*findings = append(*findings, finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) checkComments(node *ast.File, fset *token.FileSet, file string, findings *[]quality.Finding) {
|
||||
for _, group := range node.Comments {
|
||||
for _, comment := range group.List {
|
||||
text := comment.Text
|
||||
for _, pattern := range d.patterns {
|
||||
if pattern.Pattern.MatchString(text) {
|
||||
pos := fset.Position(comment.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("security::%s::%d", file, pos.Line),
|
||||
Type: "security",
|
||||
Title: pattern.Name,
|
||||
Description: pattern.Description,
|
||||
File: file,
|
||||
Line: pos.Line,
|
||||
Severity: pattern.Severity,
|
||||
Score: pattern.Score,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"pattern": pattern.Name,
|
||||
"in_comment": "true",
|
||||
},
|
||||
}
|
||||
*findings = append(*findings, finding)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *SecurityDetector) nodeToString(node ast.Node) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprint(&b, node)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
type ComplexityASTDetector struct {
|
||||
*quality.BaseDetector
|
||||
maxComplexity int
|
||||
maxNesting int
|
||||
}
|
||||
|
||||
func NewComplexityASTDetector(finder quality.FileFinder) *ComplexityASTDetector {
|
||||
return &ComplexityASTDetector{
|
||||
BaseDetector: quality.NewBaseDetector("complexity_ast", quality.SeverityT2, finder),
|
||||
maxComplexity: 15,
|
||||
maxNesting: 4,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *ComplexityASTDetector) Name() string {
|
||||
return "complexity_ast"
|
||||
}
|
||||
|
||||
func (d *ComplexityASTDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT2
|
||||
}
|
||||
|
||||
func (d *ComplexityASTDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for _, file := range files {
|
||||
fileFindings, err := d.analyzeFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *ComplexityASTDetector) analyzeFile(filePath string) ([]quality.Finding, error) {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, filePath, nil, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
if fn, ok := decl.(*ast.FuncDecl); ok {
|
||||
complexity := d.calculateCyclomaticComplexity(fn)
|
||||
nesting := d.calculateNestingDepth(fn)
|
||||
|
||||
if complexity > d.maxComplexity {
|
||||
pos := fset.Position(fn.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("complexity::%s::%s", filePath, fn.Name.Name),
|
||||
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, d.maxComplexity),
|
||||
File: filePath,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT2,
|
||||
Score: complexity - d.maxComplexity,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"function": fn.Name.Name,
|
||||
"complexity": fmt.Sprintf("%d", complexity),
|
||||
"max_complexity": fmt.Sprintf("%d", d.maxComplexity),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
if nesting > d.maxNesting {
|
||||
pos := fset.Position(fn.Pos())
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("nesting::%s::%s", filePath, fn.Name.Name),
|
||||
Type: "complexity",
|
||||
Title: fmt.Sprintf("Deep nesting in %s", fn.Name.Name),
|
||||
Description: fmt.Sprintf("Function '%s' has nesting depth of %d (max: %d). Consider extracting logic into helper functions.", fn.Name.Name, nesting, d.maxNesting),
|
||||
File: filePath,
|
||||
Line: pos.Line,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: (nesting - d.maxNesting) * 3,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"function": fn.Name.Name,
|
||||
"nesting": fmt.Sprintf("%d", nesting),
|
||||
"max_nesting": fmt.Sprintf("%d", d.maxNesting),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *ComplexityASTDetector) calculateCyclomaticComplexity(fn *ast.FuncDecl) int {
|
||||
complexity := 1
|
||||
|
||||
ast.Inspect(fn, func(n ast.Node) bool {
|
||||
switch n.(type) {
|
||||
case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt:
|
||||
complexity++
|
||||
case *ast.CaseClause:
|
||||
complexity++
|
||||
case *ast.BinaryExpr:
|
||||
complexity++
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return complexity
|
||||
}
|
||||
|
||||
func (d *ComplexityASTDetector) calculateNestingDepth(fn *ast.FuncDecl) int {
|
||||
maxDepth := 0
|
||||
|
||||
var visit func(n ast.Node, depth int)
|
||||
visit = func(n ast.Node, depth int) {
|
||||
if depth > maxDepth {
|
||||
maxDepth = depth
|
||||
}
|
||||
|
||||
switch stmt := n.(type) {
|
||||
case *ast.IfStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
if stmt.Else != nil {
|
||||
visit(stmt.Else, depth+1)
|
||||
}
|
||||
case *ast.ForStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
case *ast.RangeStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
case *ast.SwitchStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
case *ast.SelectStmt:
|
||||
visit(stmt.Body, depth+1)
|
||||
case *ast.BlockStmt:
|
||||
for _, s := range stmt.List {
|
||||
visit(s, depth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visit(fn.Body, 0)
|
||||
return maxDepth
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
package analyzers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
type TestCoverageDetector struct {
|
||||
*quality.BaseDetector
|
||||
minCoverage float64
|
||||
}
|
||||
|
||||
func NewTestCoverageDetector(finder quality.FileFinder) *TestCoverageDetector {
|
||||
return &TestCoverageDetector{
|
||||
BaseDetector: quality.NewBaseDetector("test_coverage", quality.SeverityT3, finder),
|
||||
minCoverage: 50.0,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *TestCoverageDetector) Name() string {
|
||||
return "test_coverage"
|
||||
}
|
||||
|
||||
func (d *TestCoverageDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *TestCoverageDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
coverFile := filepath.Join(path, "coverage.out")
|
||||
|
||||
_, err := exec.LookPath("go")
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if _, err := os.Stat(coverFile); os.IsNotExist(err) {
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "-coverprofile=coverage.out", "-covermode=atomic", "./...")
|
||||
cmd.Dir = path
|
||||
cmd.Run()
|
||||
|
||||
if _, err := os.Stat(coverFile); os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
coverage, err := d.parseCoverageFile(coverFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for file, cov := range coverage {
|
||||
if cov.TotalLines == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
coveragePercent := float64(cov.CoveredLines) / float64(cov.TotalLines) * 100
|
||||
|
||||
if coveragePercent < d.minCoverage {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("test_coverage::%s", file),
|
||||
Type: "test_coverage",
|
||||
Title: fmt.Sprintf("Low test coverage: %s (%.1f%%)", filepath.Base(file), coveragePercent),
|
||||
Description: fmt.Sprintf("File '%s' has only %.1f%% test coverage (minimum: %.1f%%). Add more tests.", file, coveragePercent, d.minCoverage),
|
||||
File: file,
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: int((d.minCoverage - coveragePercent) / 10),
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"coverage_percent": fmt.Sprintf("%.1f", coveragePercent),
|
||||
"covered_lines": fmt.Sprintf("%d", cov.CoveredLines),
|
||||
"total_lines": fmt.Sprintf("%d", cov.TotalLines),
|
||||
"min_coverage": fmt.Sprintf("%.1f", d.minCoverage),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
zeroCoverage := []string{}
|
||||
for file, cov := range coverage {
|
||||
if cov.CoveredLines == 0 && cov.TotalLines > 0 {
|
||||
zeroCoverage = append(zeroCoverage, file)
|
||||
}
|
||||
}
|
||||
|
||||
if len(zeroCoverage) > 0 && len(zeroCoverage) <= 10 {
|
||||
for _, file := range zeroCoverage {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("no_test_coverage::%s", file),
|
||||
Type: "test_coverage",
|
||||
Title: fmt.Sprintf("No test coverage: %s", filepath.Base(file)),
|
||||
Description: fmt.Sprintf("File '%s' has 0%% test coverage. Consider adding tests.", file),
|
||||
File: file,
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT2,
|
||||
Score: 5,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"coverage_percent": "0",
|
||||
"total_lines": fmt.Sprintf("%d", coverage[file].TotalLines),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *TestCoverageDetector) parseCoverageFile(path string) (map[string]CoverageInfo, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
coverage := make(map[string]CoverageInfo)
|
||||
lines := strings.Split(string(data), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" || strings.HasPrefix(line, "mode:") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(line, " ")
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
fileRange := parts[0]
|
||||
colonIdx := strings.LastIndex(fileRange, ":")
|
||||
if colonIdx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
file := fileRange[:colonIdx]
|
||||
rangeStr := fileRange[colonIdx+1:]
|
||||
|
||||
countStr := parts[2]
|
||||
var count int
|
||||
fmt.Sscanf(countStr, "%d", &count)
|
||||
|
||||
start, end := d.parseRange(rangeStr)
|
||||
lines := end - start + 1
|
||||
|
||||
info := coverage[file]
|
||||
info.TotalLines += lines
|
||||
if count > 0 {
|
||||
info.CoveredLines += lines
|
||||
}
|
||||
coverage[file] = info
|
||||
}
|
||||
|
||||
return coverage, nil
|
||||
}
|
||||
|
||||
func (d *TestCoverageDetector) parseRange(s string) (start, end int) {
|
||||
parts := strings.Split(s, ",")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
fmt.Sscanf(parts[0], "%d", &start)
|
||||
fmt.Sscanf(parts[1], "%d", &end)
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
type CoverageInfo struct {
|
||||
TotalLines int
|
||||
CoveredLines int
|
||||
}
|
||||
|
||||
type UntestedFuncDetector struct {
|
||||
*quality.BaseDetector
|
||||
}
|
||||
|
||||
func NewUntestedFuncDetector(finder quality.FileFinder) *UntestedFuncDetector {
|
||||
return &UntestedFuncDetector{
|
||||
BaseDetector: quality.NewBaseDetector("untested_func", quality.SeverityT2, finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *UntestedFuncDetector) Name() string {
|
||||
return "untested_func"
|
||||
}
|
||||
|
||||
func (d *UntestedFuncDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT2
|
||||
}
|
||||
|
||||
func (d *UntestedFuncDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
coverFile := filepath.Join(path, "coverage.out")
|
||||
data, err := os.ReadFile(coverFile)
|
||||
if err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
uncoveredFuncs := make(map[string][]UncoveredFunc)
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" || strings.HasPrefix(line, "mode:") {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
countStr := parts[len(parts)-1]
|
||||
var count int
|
||||
fmt.Sscanf(countStr, "%d", &count)
|
||||
|
||||
if count == 0 {
|
||||
fileRange := parts[0]
|
||||
colonIdx := strings.LastIndex(fileRange, ":")
|
||||
if colonIdx == -1 {
|
||||
continue
|
||||
}
|
||||
|
||||
file := fileRange[:colonIdx]
|
||||
rangeStr := fileRange[colonIdx+1:]
|
||||
|
||||
start, _ := d.parseRange(rangeStr)
|
||||
|
||||
funcName := d.findFuncAtLine(file, start)
|
||||
if funcName != "" {
|
||||
uncoveredFuncs[file] = append(uncoveredFuncs[file], UncoveredFunc{
|
||||
Name: funcName,
|
||||
Line: start,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for file, funcs := range uncoveredFuncs {
|
||||
seen := make(map[string]bool)
|
||||
for _, fn := range funcs {
|
||||
if seen[fn.Name] {
|
||||
continue
|
||||
}
|
||||
seen[fn.Name] = true
|
||||
|
||||
if strings.HasPrefix(fn.Name, "Test") || fn.Name == "main" || fn.Name == "init" {
|
||||
continue
|
||||
}
|
||||
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("untested_func::%s::%s", file, fn.Name),
|
||||
Type: "test_coverage",
|
||||
Title: fmt.Sprintf("Untested function: %s", fn.Name),
|
||||
Description: fmt.Sprintf("Function '%s' in %s has no test coverage.", fn.Name, filepath.Base(file)),
|
||||
File: file,
|
||||
Line: fn.Line,
|
||||
Severity: quality.SeverityT2,
|
||||
Score: 3,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"function": fn.Name,
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func (d *UntestedFuncDetector) parseRange(s string) (start, end int) {
|
||||
parts := strings.Split(s, ",")
|
||||
if len(parts) != 2 {
|
||||
return 0, 0
|
||||
}
|
||||
fmt.Sscanf(parts[0], "%d", &start)
|
||||
fmt.Sscanf(parts[1], "%d", &end)
|
||||
return start, end
|
||||
}
|
||||
|
||||
func (d *UntestedFuncDetector) findFuncAtLine(file string, line int) string {
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
if line > len(lines) {
|
||||
return ""
|
||||
}
|
||||
|
||||
for i := line - 1; i >= 0 && i >= line-20; i-- {
|
||||
l := lines[i]
|
||||
if strings.HasPrefix(strings.TrimSpace(l), "func ") {
|
||||
parts := strings.Fields(strings.TrimSpace(l))
|
||||
if len(parts) >= 2 {
|
||||
name := parts[1]
|
||||
if idx := strings.Index(name, "("); idx > 0 {
|
||||
name = name[:idx]
|
||||
}
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type UncoveredFunc struct {
|
||||
Name string
|
||||
Line int
|
||||
}
|
||||
|
||||
type OrphanedFileDetector struct {
|
||||
*quality.BaseDetector
|
||||
}
|
||||
|
||||
func NewOrphanedFileDetector(finder quality.FileFinder) *OrphanedFileDetector {
|
||||
return &OrphanedFileDetector{
|
||||
BaseDetector: quality.NewBaseDetector("orphaned_file", quality.SeverityT3, finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *OrphanedFileDetector) Name() string {
|
||||
return "orphaned_file"
|
||||
}
|
||||
|
||||
func (d *OrphanedFileDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
func (d *OrphanedFileDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
testFiles := make(map[string]bool)
|
||||
for _, file := range files {
|
||||
if strings.HasSuffix(file, "_test.go") {
|
||||
base := strings.TrimSuffix(filepath.Base(file), "_test.go")
|
||||
dir := filepath.Dir(file)
|
||||
testFiles[filepath.Join(dir, base+".go")] = true
|
||||
}
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
for _, file := range files {
|
||||
if strings.HasSuffix(file, "_test.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.Contains(file, "/cmd/") || strings.Contains(file, "\\cmd\\") {
|
||||
continue
|
||||
}
|
||||
|
||||
base := filepath.Base(file)
|
||||
if strings.HasPrefix(base, "main.go") || strings.HasPrefix(base, "doc.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !testFiles[file] {
|
||||
dir := filepath.Dir(file)
|
||||
files, _ := os.ReadDir(dir)
|
||||
goCount := 0
|
||||
testCount := 0
|
||||
for _, f := range files {
|
||||
if strings.HasSuffix(f.Name(), ".go") && !strings.HasSuffix(f.Name(), "_test.go") {
|
||||
goCount++
|
||||
}
|
||||
if strings.HasSuffix(f.Name(), "_test.go") {
|
||||
testCount++
|
||||
}
|
||||
}
|
||||
|
||||
if goCount > 1 && testCount > 0 {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("orphaned_file::%s", file),
|
||||
Type: "orphaned_file",
|
||||
Title: fmt.Sprintf("File without dedicated tests: %s", filepath.Base(file)),
|
||||
Description: fmt.Sprintf("File '%s' has no corresponding _test.go file, but sibling files do. Consider adding tests.", file),
|
||||
File: file,
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT3,
|
||||
Score: 2,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"sibling_tests": fmt.Sprintf("%d", testCount),
|
||||
"sibling_go": fmt.Sprintf("%d", goCount),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
type DeprecatedUsageDetector struct {
|
||||
*quality.BaseDetector
|
||||
}
|
||||
|
||||
func NewDeprecatedUsageDetector(finder quality.FileFinder) *DeprecatedUsageDetector {
|
||||
return &DeprecatedUsageDetector{
|
||||
BaseDetector: quality.NewBaseDetector("deprecated", quality.SeverityT2, finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DeprecatedUsageDetector) Name() string {
|
||||
return "deprecated"
|
||||
}
|
||||
|
||||
func (d *DeprecatedUsageDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT2
|
||||
}
|
||||
|
||||
func (d *DeprecatedUsageDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) {
|
||||
files, err := d.FindFiles(path, "go")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for _, file := range files {
|
||||
if strings.HasSuffix(file, "_test.go") {
|
||||
continue
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
deprecatedPatterns := []struct {
|
||||
pattern string
|
||||
alt string
|
||||
}{
|
||||
{"io/ioutil", "io and os packages"},
|
||||
{"context.WithDeadline", "context.WithTimeout for relative times"},
|
||||
{"interface{}", "any"},
|
||||
}
|
||||
|
||||
for _, p := range deprecatedPatterns {
|
||||
if strings.Contains(content, p.pattern) {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("deprecated::%s::%s", file, p.pattern),
|
||||
Type: "deprecated",
|
||||
Title: fmt.Sprintf("Deprecated usage: %s", p.pattern),
|
||||
Description: fmt.Sprintf("Found deprecated '%s'. Use %s instead.", p.pattern, p.alt),
|
||||
File: file,
|
||||
Line: 1,
|
||||
Severity: quality.SeverityT2,
|
||||
Score: 3,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"deprecated": p.pattern,
|
||||
"alternative": p.alt,
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
func ParseGoTestJSON(output []byte) ([]TestResult, error) {
|
||||
var results []TestResult
|
||||
lines := strings.Split(string(output), "\n")
|
||||
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var event TestEvent
|
||||
if err := json.Unmarshal([]byte(line), &event); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if event.Action == "pass" || event.Action == "fail" {
|
||||
results = append(results, TestResult{
|
||||
Package: event.Package,
|
||||
Test: event.Test,
|
||||
Elapsed: event.Elapsed,
|
||||
Action: event.Action,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type TestEvent struct {
|
||||
Time string `json:"Time"`
|
||||
Action string `json:"Action"`
|
||||
Package string `json:"Package"`
|
||||
Test string `json:"Test"`
|
||||
Elapsed float64 `json:"Elapsed"`
|
||||
Output string `json:"Output"`
|
||||
}
|
||||
|
||||
type TestResult struct {
|
||||
Package string
|
||||
Test string
|
||||
Elapsed float64
|
||||
Action string
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package fixers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"github.com/yourorg/devour/internal/quality/plugins"
|
||||
)
|
||||
|
||||
type DeadCodeFixer struct{}
|
||||
|
||||
func NewDeadCodeFixer() *DeadCodeFixer {
|
||||
return &DeadCodeFixer{}
|
||||
}
|
||||
|
||||
func (f *DeadCodeFixer) Name() string {
|
||||
return "dead_code"
|
||||
}
|
||||
|
||||
func (f *DeadCodeFixer) Description() string {
|
||||
return "Comments out or removes unused exported functions/types"
|
||||
}
|
||||
|
||||
func (f *DeadCodeFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "dead_code" && finding.Severity == quality.SeverityT1
|
||||
}
|
||||
|
||||
func (f *DeadCodeFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
name := finding.Metadata["name"]
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("no function/type name in metadata")
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, finding.File, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would comment out unused '%s' in %s", name, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var targetDecl ast.Decl
|
||||
for _, decl := range node.Decls {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if d.Name.Name == name {
|
||||
targetDecl = d
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
for _, spec := range d.Specs {
|
||||
if ts, ok := spec.(*ast.TypeSpec); ok && ts.Name.Name == name {
|
||||
targetDecl = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targetDecl != nil {
|
||||
comment := &ast.CommentGroup{
|
||||
List: []*ast.Comment{
|
||||
{Text: "// DEPRECATED: This code is unused and should be removed"},
|
||||
},
|
||||
}
|
||||
|
||||
if targetDecl.(*ast.FuncDecl) != nil {
|
||||
targetDecl.(*ast.FuncDecl).Doc = comment
|
||||
} else if targetDecl.(*ast.GenDecl) != nil {
|
||||
targetDecl.(*ast.GenDecl).Doc = comment
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetDecl == nil {
|
||||
return &plugins.FixResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Could not find '%s' in file", name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if err := format.Node(&output, fset, node); err != nil {
|
||||
return nil, fmt.Errorf("format error: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(output.String()), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Marked '%s' as deprecated in %s", name, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ComplexityHintFixer struct{}
|
||||
|
||||
func NewComplexityHintFixer() *ComplexityHintFixer {
|
||||
return &ComplexityHintFixer{}
|
||||
}
|
||||
|
||||
func (f *ComplexityHintFixer) Name() string {
|
||||
return "complexity_hint"
|
||||
}
|
||||
|
||||
func (f *ComplexityHintFixer) Description() string {
|
||||
return "Adds complexity warning comments to complex functions"
|
||||
}
|
||||
|
||||
func (f *ComplexityHintFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "complexity" || finding.Type == "complexity_ast"
|
||||
}
|
||||
|
||||
func (f *ComplexityHintFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
funcName := finding.Metadata["function"]
|
||||
if funcName == "" {
|
||||
return nil, fmt.Errorf("no function name in metadata")
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, finding.File, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would add complexity warning to '%s' in %s", funcName, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == funcName {
|
||||
complexity := finding.Metadata["complexity"]
|
||||
|
||||
warning := fmt.Sprintf("// FIXME: High complexity (%s). Consider breaking into smaller functions.", complexity)
|
||||
|
||||
comment := &ast.CommentGroup{
|
||||
List: []*ast.Comment{
|
||||
{Text: warning},
|
||||
},
|
||||
}
|
||||
fn.Doc = comment
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if err := format.Node(&output, fset, node); err != nil {
|
||||
return nil, fmt.Errorf("format error: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(output.String()), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Added complexity warning to '%s' in %s", funcName, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type IoutilFixer struct{}
|
||||
|
||||
func NewIoutilFixer() *IoutilFixer {
|
||||
return &IoutilFixer{}
|
||||
}
|
||||
|
||||
func (f *IoutilFixer) Name() string {
|
||||
return "ioutil"
|
||||
}
|
||||
|
||||
func (f *IoutilFixer) Description() string {
|
||||
return "Replaces deprecated io/ioutil with modern equivalents"
|
||||
}
|
||||
|
||||
func (f *IoutilFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "deprecated" && strings.Contains(finding.Title, "io/ioutil")
|
||||
}
|
||||
|
||||
func (f *IoutilFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
data, err := os.ReadFile(finding.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read error: %w", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
replacements := map[string]string{
|
||||
`"io/ioutil"`: "",
|
||||
`ioutil.ReadFile`: `os.ReadFile`,
|
||||
`ioutil.WriteFile`: `os.WriteFile`,
|
||||
`ioutil.ReadDir`: `os.ReadDir`,
|
||||
`ioutil.TempDir`: `os.MkdirTemp`,
|
||||
`ioutil.TempFile`: `os.CreateTemp`,
|
||||
`ioutil.NopCloser`: `io.NopCloser`,
|
||||
`ioutil.ReadAll`: `io.ReadAll`,
|
||||
`ioutil.Discard`: `io.Discard`,
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would replace io/ioutil usage in %s", finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
for old, new := range replacements {
|
||||
content = strings.ReplaceAll(content, old, new)
|
||||
}
|
||||
|
||||
if strings.Contains(content, "os.ReadFile") || strings.Contains(content, "os.WriteFile") ||
|
||||
strings.Contains(content, "os.ReadDir") || strings.Contains(content, "os.MkdirTemp") ||
|
||||
strings.Contains(content, "os.CreateTemp") {
|
||||
if !strings.Contains(content, `"os"`) {
|
||||
content = strings.Replace(content, "package ", "import \"os\"\n\npackage ", 1)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(content, "io.NopCloser") || strings.Contains(content, "io.ReadAll") ||
|
||||
strings.Contains(content, "io.Discard") {
|
||||
if !strings.Contains(content, `"io"`) {
|
||||
content = strings.Replace(content, "package ", "import \"io\"\n\npackage ", 1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(content), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Replaced io/ioutil in %s", finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type DocCommentFixer struct{}
|
||||
|
||||
func NewDocCommentFixer() *DocCommentFixer {
|
||||
return &DocCommentFixer{}
|
||||
}
|
||||
|
||||
func (f *DocCommentFixer) Name() string {
|
||||
return "doc_comment"
|
||||
}
|
||||
|
||||
func (f *DocCommentFixer) Description() string {
|
||||
return "Adds TODO comments for missing documentation on exported items"
|
||||
}
|
||||
|
||||
func (f *DocCommentFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "naming" || finding.Type == "god_struct" || finding.Type == "god_function"
|
||||
}
|
||||
|
||||
func (f *DocCommentFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
return &plugins.FixResult{
|
||||
Success: false,
|
||||
Message: "Documentation fixer requires manual intervention",
|
||||
Warnings: []string{
|
||||
fmt.Sprintf("Add documentation for: %s", finding.Title),
|
||||
fmt.Sprintf("Location: %s:%d", finding.File, finding.Line),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package fixers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"github.com/yourorg/devour/internal/quality/plugins"
|
||||
)
|
||||
|
||||
type UnusedImportFixer struct{}
|
||||
|
||||
func NewUnusedImportFixer() *UnusedImportFixer {
|
||||
return &UnusedImportFixer{}
|
||||
}
|
||||
|
||||
func (f *UnusedImportFixer) Name() string {
|
||||
return "unused_import"
|
||||
}
|
||||
|
||||
func (f *UnusedImportFixer) Description() string {
|
||||
return "Removes unused import statements"
|
||||
}
|
||||
|
||||
func (f *UnusedImportFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "unused_import"
|
||||
}
|
||||
|
||||
func (f *UnusedImportFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, finding.File, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
importToRemove := finding.Metadata["import_path"]
|
||||
if importToRemove == "" {
|
||||
return nil, fmt.Errorf("no import_path in finding metadata")
|
||||
}
|
||||
|
||||
var newImports []*ast.ImportSpec
|
||||
for _, imp := range node.Imports {
|
||||
path := strings.Trim(imp.Path.Value, `"`)
|
||||
if path != importToRemove {
|
||||
newImports = append(newImports, imp)
|
||||
}
|
||||
}
|
||||
|
||||
node.Imports = newImports
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would remove import '%s' from %s", importToRemove, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if err := format.Node(&output, fset, node); err != nil {
|
||||
return nil, fmt.Errorf("format error: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(output.String()), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Removed unused import '%s' from %s", importToRemove, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type FormattingFixer struct{}
|
||||
|
||||
func NewFormattingFixer() *FormattingFixer {
|
||||
return &FormattingFixer{}
|
||||
}
|
||||
|
||||
func (f *FormattingFixer) Name() string {
|
||||
return "format"
|
||||
}
|
||||
|
||||
func (f *FormattingFixer) Description() string {
|
||||
return "Formats Go source files using gofmt style"
|
||||
}
|
||||
|
||||
func (f *FormattingFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "formatting" || finding.Type == "style"
|
||||
}
|
||||
|
||||
func (f *FormattingFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, finding.File, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would format %s", finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if err := format.Node(&output, fset, node); err != nil {
|
||||
return nil, fmt.Errorf("format error: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(output.String()), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Formatted %s", finding.File),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package goplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"github.com/yourorg/devour/internal/quality/plugins"
|
||||
"github.com/yourorg/devour/internal/quality/plugins/go/analyzers"
|
||||
"github.com/yourorg/devour/internal/quality/plugins/go/fixers"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
type GoPlugin struct{}
|
||||
|
||||
func New() *GoPlugin {
|
||||
return &GoPlugin{}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) Name() string {
|
||||
return "go"
|
||||
}
|
||||
|
||||
func (p *GoPlugin) Extensions() []string {
|
||||
return []string{".go"}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) MarkerFiles() []string {
|
||||
return []string{"go.mod", "go.sum"}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) DefaultSrcDir() string {
|
||||
return "."
|
||||
}
|
||||
|
||||
func (p *GoPlugin) CreateDetectors(finder quality.FileFinder) []quality.Detector {
|
||||
return []quality.Detector{
|
||||
analyzers.NewDeadCodeDetector(finder),
|
||||
analyzers.NewEnhancedDeadCodeDetector(finder),
|
||||
analyzers.NewUnusedImportDetector(finder),
|
||||
analyzers.NewCycleDetector(finder),
|
||||
analyzers.NewSecurityDetector(finder),
|
||||
analyzers.NewComplexityASTDetector(finder),
|
||||
analyzers.NewLargeFileDetector(finder),
|
||||
analyzers.NewGodStructDetector(finder),
|
||||
analyzers.NewGodFunctionDetector(finder),
|
||||
analyzers.NewDebugLogDetector(finder),
|
||||
analyzers.NewSingleUseDetector(finder),
|
||||
analyzers.NewCouplingDetector(finder),
|
||||
analyzers.NewTestCoverageDetector(finder),
|
||||
analyzers.NewUntestedFuncDetector(finder),
|
||||
analyzers.NewOrphanedFileDetector(finder),
|
||||
analyzers.NewDeprecatedUsageDetector(finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) CreateFixers() []plugins.Fixer {
|
||||
return []plugins.Fixer{
|
||||
fixers.NewUnusedImportFixer(),
|
||||
fixers.NewFormattingFixer(),
|
||||
fixers.NewDeadCodeFixer(),
|
||||
fixers.NewComplexityHintFixer(),
|
||||
fixers.NewIoutilFixer(),
|
||||
fixers.NewDocCommentFixer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) AnalyzeFile(ctx context.Context, path string, config *quality.Config) (*plugins.FileAnalysis, error) {
|
||||
fset := token.NewFileSet()
|
||||
|
||||
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.AllErrors)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
analysis := &plugins.FileAnalysis{
|
||||
Path: path,
|
||||
Package: node.Name.Name,
|
||||
LOC: countLOC(path),
|
||||
}
|
||||
|
||||
analysis.Imports = p.extractImports(node, fset)
|
||||
analysis.Functions = p.extractFunctions(node, path, fset)
|
||||
analysis.Types = p.extractTypes(node, path, fset)
|
||||
analysis.Variables = p.extractVariables(node, path, fset)
|
||||
analysis.Comments = p.extractComments(node, path, fset)
|
||||
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
func (p *GoPlugin) BuildDependencyGraph(ctx context.Context, rootPath string) (*plugins.DependencyGraph, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedImports | packages.NeedFiles,
|
||||
Dir: rootPath,
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load packages: %w", err)
|
||||
}
|
||||
|
||||
graph := &plugins.DependencyGraph{
|
||||
Packages: make(map[string]*plugins.PackageNode),
|
||||
Edges: []plugins.DependencyEdge{},
|
||||
}
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
node := &plugins.PackageNode{
|
||||
Name: pkg.Name,
|
||||
Path: pkg.PkgPath,
|
||||
Files: pkg.GoFiles,
|
||||
IsLocal: true,
|
||||
}
|
||||
|
||||
for _, imp := range pkg.Imports {
|
||||
node.Imports = append(node.Imports, imp.PkgPath)
|
||||
graph.Edges = append(graph.Edges, plugins.DependencyEdge{
|
||||
From: pkg.PkgPath,
|
||||
To: imp.PkgPath,
|
||||
Type: plugins.EdgeTypeImport,
|
||||
})
|
||||
}
|
||||
|
||||
graph.Packages[pkg.PkgPath] = node
|
||||
}
|
||||
|
||||
graph.Cycles = p.detectCycles(graph)
|
||||
|
||||
return graph, nil
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractImports(node *ast.File, fset *token.FileSet) []plugins.ImportInfo {
|
||||
var imports []plugins.ImportInfo
|
||||
for _, imp := range node.Imports {
|
||||
info := plugins.ImportInfo{
|
||||
Path: strings.Trim(imp.Path.Value, `"`),
|
||||
Line: fset.Position(imp.Pos()).Line,
|
||||
}
|
||||
if imp.Name != nil {
|
||||
info.Alias = imp.Name.Name
|
||||
}
|
||||
imports = append(imports, info)
|
||||
}
|
||||
return imports
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractFunctions(node *ast.File, path string, fset *token.FileSet) []quality.FunctionInfo {
|
||||
var functions []quality.FunctionInfo
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
info := quality.FunctionInfo{
|
||||
Name: fn.Name.Name,
|
||||
File: path,
|
||||
Line: fset.Position(fn.Pos()).Line,
|
||||
EndLine: fset.Position(fn.End()).Line,
|
||||
}
|
||||
|
||||
info.LOC = info.EndLine - info.Line + 1
|
||||
|
||||
var params []string
|
||||
if fn.Type.Params != nil {
|
||||
for _, field := range fn.Type.Params.List {
|
||||
for _, name := range field.Names {
|
||||
params = append(params, name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
info.Params = params
|
||||
|
||||
if fn.Type.Results != nil {
|
||||
info.ReturnAnnotation = fmt.Sprintf("%v", fn.Type.Results)
|
||||
}
|
||||
|
||||
functions = append(functions, info)
|
||||
}
|
||||
|
||||
return functions
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractTypes(node *ast.File, path string, fset *token.FileSet) []plugins.TypeInfo {
|
||||
var typeInfos []plugins.TypeInfo
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
info := plugins.TypeInfo{
|
||||
Name: typeSpec.Name.Name,
|
||||
File: path,
|
||||
Line: fset.Position(typeSpec.Pos()).Line,
|
||||
IsExported: ast.IsExported(typeSpec.Name.Name),
|
||||
}
|
||||
|
||||
switch t := typeSpec.Type.(type) {
|
||||
case *ast.StructType:
|
||||
info.Underlying = "struct"
|
||||
case *ast.InterfaceType:
|
||||
info.Underlying = "interface"
|
||||
default:
|
||||
info.Underlying = fmt.Sprintf("%T", t)
|
||||
}
|
||||
|
||||
typeInfos = append(typeInfos, info)
|
||||
}
|
||||
}
|
||||
|
||||
return typeInfos
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractVariables(node *ast.File, path string, fset *token.FileSet) []plugins.VariableInfo {
|
||||
var variables []plugins.VariableInfo
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || (gen.Tok != token.VAR && gen.Tok != token.CONST) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, name := range valueSpec.Names {
|
||||
info := plugins.VariableInfo{
|
||||
Name: name.Name,
|
||||
File: path,
|
||||
Line: fset.Position(name.Pos()).Line,
|
||||
IsExported: ast.IsExported(name.Name),
|
||||
}
|
||||
|
||||
if valueSpec.Type != nil {
|
||||
info.Type = fmt.Sprintf("%v", valueSpec.Type)
|
||||
}
|
||||
|
||||
variables = append(variables, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractComments(node *ast.File, path string, fset *token.FileSet) []plugins.CommentInfo {
|
||||
var comments []plugins.CommentInfo
|
||||
|
||||
for _, group := range node.Comments {
|
||||
for _, comment := range group.List {
|
||||
info := plugins.CommentInfo{
|
||||
Text: comment.Text,
|
||||
File: path,
|
||||
Line: fset.Position(comment.Pos()).Line,
|
||||
IsDoc: strings.HasPrefix(comment.Text, "//"),
|
||||
}
|
||||
comments = append(comments, info)
|
||||
}
|
||||
}
|
||||
|
||||
return comments
|
||||
}
|
||||
|
||||
func (p *GoPlugin) detectCycles(graph *plugins.DependencyGraph) [][]string {
|
||||
var cycles [][]string
|
||||
visited := make(map[string]bool)
|
||||
recStack := make(map[string]bool)
|
||||
path := []string{}
|
||||
|
||||
var dfs func(pkg string) bool
|
||||
dfs = func(pkg string) bool {
|
||||
visited[pkg] = true
|
||||
recStack[pkg] = true
|
||||
path = append(path, pkg)
|
||||
|
||||
node, exists := graph.Packages[pkg]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, imp := range node.Imports {
|
||||
if !visited[imp] {
|
||||
if dfs(imp) {
|
||||
return true
|
||||
}
|
||||
} else if recStack[imp] {
|
||||
cycleStart := -1
|
||||
for i, p := range path {
|
||||
if p == imp {
|
||||
cycleStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cycleStart >= 0 {
|
||||
cycle := make([]string, len(path)-cycleStart)
|
||||
copy(cycle, path[cycleStart:])
|
||||
cycles = append(cycles, cycle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path = path[:len(path)-1]
|
||||
recStack[pkg] = false
|
||||
return false
|
||||
}
|
||||
|
||||
for pkg := range graph.Packages {
|
||||
if !visited[pkg] {
|
||||
dfs(pkg)
|
||||
}
|
||||
}
|
||||
|
||||
return cycles
|
||||
}
|
||||
|
||||
func (p *GoPlugin) LoadTypesInfo(ctx context.Context, path string) (*types.Info, *token.FileSet, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo,
|
||||
Dir: filepath.Dir(path),
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, filepath.Base(path))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if len(pkgs) == 0 {
|
||||
return nil, nil, fmt.Errorf("no packages found")
|
||||
}
|
||||
|
||||
return pkgs[0].TypesInfo, pkgs[0].Fset, nil
|
||||
}
|
||||
|
||||
func countLOC(path string) int {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(string(data), "\n") + 1
|
||||
}
|
||||
|
||||
func init() {
|
||||
plugins.Register(New())
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"go/types"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
// LanguagePlugin defines the interface for language-specific analysis plugins
|
||||
type LanguagePlugin interface {
|
||||
// Name returns the plugin name (e.g., "go", "typescript")
|
||||
Name() string
|
||||
|
||||
// Extensions returns file extensions this plugin handles
|
||||
Extensions() []string
|
||||
|
||||
// MarkerFiles returns files that indicate this language (e.g., "go.mod")
|
||||
MarkerFiles() []string
|
||||
|
||||
// DefaultSrcDir returns the default source directory
|
||||
DefaultSrcDir() string
|
||||
|
||||
// CreateDetectors creates language-specific detectors
|
||||
CreateDetectors(finder quality.FileFinder) []quality.Detector
|
||||
|
||||
// CreateFixers creates language-specific auto-fixers
|
||||
CreateFixers() []Fixer
|
||||
|
||||
// AnalyzeFile performs AST analysis on a single file
|
||||
AnalyzeFile(ctx context.Context, path string, config *quality.Config) (*FileAnalysis, error)
|
||||
|
||||
// BuildDependencyGraph builds the import dependency graph
|
||||
BuildDependencyGraph(ctx context.Context, path string) (*DependencyGraph, error)
|
||||
}
|
||||
|
||||
// FileAnalysis represents the result of analyzing a single file
|
||||
type FileAnalysis struct {
|
||||
Path string
|
||||
Package string
|
||||
Imports []ImportInfo
|
||||
Functions []quality.FunctionInfo
|
||||
Classes []quality.ClassInfo
|
||||
Variables []VariableInfo
|
||||
Types []TypeInfo
|
||||
Comments []CommentInfo
|
||||
LOC int
|
||||
Complexity int
|
||||
Issues []quality.Finding
|
||||
}
|
||||
|
||||
// ImportInfo represents import information
|
||||
type ImportInfo struct {
|
||||
Path string
|
||||
Alias string
|
||||
Line int
|
||||
Used bool
|
||||
Position token.Position
|
||||
}
|
||||
|
||||
// VariableInfo represents variable information
|
||||
type VariableInfo struct {
|
||||
Name string
|
||||
Type string
|
||||
File string
|
||||
Line int
|
||||
IsExported bool
|
||||
IsUsed bool
|
||||
}
|
||||
|
||||
// TypeInfo represents type information
|
||||
type TypeInfo struct {
|
||||
Name string
|
||||
Underlying string
|
||||
Methods []string
|
||||
File string
|
||||
Line int
|
||||
IsExported bool
|
||||
}
|
||||
|
||||
// CommentInfo represents comment information
|
||||
type CommentInfo struct {
|
||||
Text string
|
||||
File string
|
||||
Line int
|
||||
IsDoc bool
|
||||
Attached string // What it's attached to (function name, type name, etc.)
|
||||
}
|
||||
|
||||
// DependencyGraph represents the import dependency graph
|
||||
type DependencyGraph struct {
|
||||
Packages map[string]*PackageNode
|
||||
Edges []DependencyEdge
|
||||
Cycles [][]string
|
||||
}
|
||||
|
||||
// PackageNode represents a node in the dependency graph
|
||||
type PackageNode struct {
|
||||
Name string
|
||||
Path string
|
||||
Imports []string
|
||||
ImportedBy []string
|
||||
Files []string
|
||||
IsLocal bool
|
||||
}
|
||||
|
||||
// DependencyEdge represents an edge in the dependency graph
|
||||
type DependencyEdge struct {
|
||||
From string
|
||||
To string
|
||||
Type EdgeType
|
||||
}
|
||||
|
||||
// EdgeType represents the type of dependency edge
|
||||
type EdgeType string
|
||||
|
||||
const (
|
||||
EdgeTypeImport EdgeType = "import"
|
||||
EdgeTypeEmbed EdgeType = "embed"
|
||||
EdgeTypeInternal EdgeType = "internal"
|
||||
)
|
||||
|
||||
// Fixer defines the interface for auto-fixers
|
||||
type Fixer interface {
|
||||
// Name returns the fixer name
|
||||
Name() string
|
||||
|
||||
// Description returns a human-readable description
|
||||
Description() string
|
||||
|
||||
// CanFix checks if this fixer can fix the given finding
|
||||
CanFix(finding quality.Finding) bool
|
||||
|
||||
// Fix applies the fix and returns the patches
|
||||
Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*FixResult, error)
|
||||
}
|
||||
|
||||
// FixResult represents the result of a fix operation
|
||||
type FixResult struct {
|
||||
Success bool
|
||||
Patches []Patch
|
||||
Message string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// Patch represents a single file patch
|
||||
type Patch struct {
|
||||
File string
|
||||
OldText string
|
||||
NewText string
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
// ASTInfo represents AST information for analysis
|
||||
type ASTInfo struct {
|
||||
File *ast.File
|
||||
Fset *token.FileSet
|
||||
Types *types.Info
|
||||
Package *types.Package
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Registry manages language plugin registration
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
plugins map[string]LanguagePlugin
|
||||
}
|
||||
|
||||
// Global registry instance
|
||||
var globalRegistry = &Registry{
|
||||
plugins: make(map[string]LanguagePlugin),
|
||||
}
|
||||
|
||||
// Register registers a language plugin
|
||||
func Register(plugin LanguagePlugin) error {
|
||||
globalRegistry.mu.Lock()
|
||||
defer globalRegistry.mu.Unlock()
|
||||
|
||||
name := plugin.Name()
|
||||
if _, exists := globalRegistry.plugins[name]; exists {
|
||||
return fmt.Errorf("plugin %s already registered", name)
|
||||
}
|
||||
|
||||
globalRegistry.plugins[name] = plugin
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a plugin by name
|
||||
func Get(name string) (LanguagePlugin, bool) {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
plugin, ok := globalRegistry.plugins[name]
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
// All returns all registered plugins
|
||||
func All() []LanguagePlugin {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
plugins := make([]LanguagePlugin, 0, len(globalRegistry.plugins))
|
||||
for _, p := range globalRegistry.plugins {
|
||||
plugins = append(plugins, p)
|
||||
}
|
||||
|
||||
// Sort by name for consistent ordering
|
||||
sort.Slice(plugins, func(i, j int) bool {
|
||||
return plugins[i].Name() < plugins[j].Name()
|
||||
})
|
||||
|
||||
return plugins
|
||||
}
|
||||
|
||||
// Names returns all registered plugin names
|
||||
func Names() []string {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(globalRegistry.plugins))
|
||||
for name := range globalRegistry.plugins {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// DetectLanguage attempts to detect the language from a path
|
||||
func DetectLanguage(path string) string {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
// Check marker files for each plugin
|
||||
for _, plugin := range globalRegistry.plugins {
|
||||
for _, marker := range plugin.MarkerFiles() {
|
||||
// Check if marker file exists
|
||||
if fileExists(path + "/" + marker) {
|
||||
return plugin.Name()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to first registered plugin
|
||||
for name := range globalRegistry.plugins {
|
||||
return name
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetForExtension returns the plugin for a given file extension
|
||||
func GetForExtension(ext string) LanguagePlugin {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
for _, plugin := range globalRegistry.plugins {
|
||||
for _, pluginExt := range plugin.Extensions() {
|
||||
if pluginExt == ext {
|
||||
return plugin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileExists is a simple helper - will be replaced with proper implementation
|
||||
func fileExists(path string) bool {
|
||||
// This will be replaced with actual file existence check
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package review
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
type ReviewPacket struct {
|
||||
Generated time.Time `json:"generated"`
|
||||
ProjectPath string `json:"project_path"`
|
||||
Language string `json:"language"`
|
||||
Scorecard *quality.Scorecard `json:"scorecard"`
|
||||
Findings []FindingReview `json:"findings"`
|
||||
Context ReviewContext `json:"context"`
|
||||
Questions []ReviewQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
type FindingReview struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
Severity quality.Severity `json:"severity"`
|
||||
Score int `json:"score"`
|
||||
Status quality.Status `json:"status"`
|
||||
NeedsReview bool `json:"needs_review"`
|
||||
Context string `json:"context"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
}
|
||||
|
||||
type ReviewContext struct {
|
||||
TotalFiles int `json:"total_files"`
|
||||
TotalLOC int `json:"total_loc"`
|
||||
FindingsByDim map[string]int `json:"findings_by_dimension"`
|
||||
TopIssues []string `json:"top_issues"`
|
||||
Trends map[string]string `json:"trends"`
|
||||
}
|
||||
|
||||
type ReviewQuestion struct {
|
||||
ID string `json:"id"`
|
||||
Category string `json:"category"`
|
||||
Question string `json:"question"`
|
||||
Options []string `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type PacketGenerator struct {
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func NewPacketGenerator(dataDir string) *PacketGenerator {
|
||||
return &PacketGenerator{dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) Generate(findings []quality.Finding, scorecard *quality.Scorecard, lang string) (*ReviewPacket, error) {
|
||||
packet := &ReviewPacket{
|
||||
Generated: time.Now(),
|
||||
ProjectPath: g.dataDir,
|
||||
Language: lang,
|
||||
Scorecard: scorecard,
|
||||
Findings: g.convertFindings(findings),
|
||||
Context: g.buildContext(findings),
|
||||
Questions: g.generateQuestions(findings),
|
||||
}
|
||||
|
||||
return packet, nil
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) convertFindings(findings []quality.Finding) []FindingReview {
|
||||
var reviews []FindingReview
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Status != quality.StatusOpen {
|
||||
continue
|
||||
}
|
||||
|
||||
review := FindingReview{
|
||||
ID: f.ID,
|
||||
Type: f.Type,
|
||||
Title: f.Title,
|
||||
Description: f.Description,
|
||||
File: f.File,
|
||||
Line: f.Line,
|
||||
Severity: f.Severity,
|
||||
Score: f.Score,
|
||||
Status: f.Status,
|
||||
NeedsReview: f.Severity >= quality.SeverityT3,
|
||||
Metadata: f.Metadata,
|
||||
}
|
||||
|
||||
review.Context = g.generateContext(f)
|
||||
reviews = append(reviews, review)
|
||||
}
|
||||
|
||||
return reviews
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) generateContext(f quality.Finding) string {
|
||||
switch f.Type {
|
||||
case "complexity", "complexity_ast":
|
||||
return "This function may be difficult to maintain. Consider if it can be simplified or broken down."
|
||||
case "duplication":
|
||||
return "Similar code exists elsewhere. Consider extracting common functionality."
|
||||
case "dead_code":
|
||||
return "This code appears unused. Verify before removing - it may be called via reflection or external tools."
|
||||
case "security":
|
||||
return "Potential security concern. Review carefully and consider security implications."
|
||||
case "import_cycle":
|
||||
return "Circular dependency detected. This can cause initialization issues and makes code harder to understand."
|
||||
default:
|
||||
return "Review this finding and decide if it needs addressing."
|
||||
}
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) buildContext(findings []quality.Finding) ReviewContext {
|
||||
byDim := make(map[string]int)
|
||||
var topIssues []string
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Status == quality.StatusOpen {
|
||||
dim := g.classifyDimension(f)
|
||||
byDim[dim]++
|
||||
}
|
||||
}
|
||||
|
||||
topCount := 0
|
||||
for _, f := range findings {
|
||||
if f.Status == quality.StatusOpen && topCount < 5 {
|
||||
topIssues = append(topIssues, fmt.Sprintf("%s: %s", f.Type, f.Title))
|
||||
topCount++
|
||||
}
|
||||
}
|
||||
|
||||
return ReviewContext{
|
||||
FindingsByDim: byDim,
|
||||
TopIssues: topIssues,
|
||||
Trends: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) classifyDimension(f quality.Finding) string {
|
||||
switch f.Type {
|
||||
case "complexity", "complexity_ast":
|
||||
return "Code Quality"
|
||||
case "duplication":
|
||||
return "Duplication"
|
||||
case "dead_code", "unused_import", "unused":
|
||||
return "File Health"
|
||||
case "security":
|
||||
return "Security"
|
||||
case "naming":
|
||||
return "Naming Quality"
|
||||
case "import_cycle":
|
||||
return "Architecture"
|
||||
default:
|
||||
return "Other"
|
||||
}
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) generateQuestions(findings []quality.Finding) []ReviewQuestion {
|
||||
var questions []ReviewQuestion
|
||||
|
||||
hasDupes := false
|
||||
hasComplex := false
|
||||
hasDead := false
|
||||
|
||||
for _, f := range findings {
|
||||
if f.Status != quality.StatusOpen {
|
||||
continue
|
||||
}
|
||||
switch f.Type {
|
||||
case "duplication":
|
||||
hasDupes = true
|
||||
case "complexity", "complexity_ast":
|
||||
hasComplex = true
|
||||
case "dead_code":
|
||||
hasDead = true
|
||||
}
|
||||
}
|
||||
|
||||
if hasDupes {
|
||||
questions = append(questions, ReviewQuestion{
|
||||
ID: "dupe_strategy",
|
||||
Category: "duplication",
|
||||
Question: "How should duplicated code be consolidated?",
|
||||
Options: []string{
|
||||
"Extract to shared utility",
|
||||
"Keep separate (different use cases)",
|
||||
"Refactor to common interface",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if hasComplex {
|
||||
questions = append(questions, ReviewQuestion{
|
||||
ID: "complexity_strategy",
|
||||
Category: "complexity",
|
||||
Question: "What's the best approach for complex functions?",
|
||||
Options: []string{
|
||||
"Break into smaller functions",
|
||||
"Introduce helper types",
|
||||
"Accept current complexity",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if hasDead {
|
||||
questions = append(questions, ReviewQuestion{
|
||||
ID: "dead_code_strategy",
|
||||
Category: "maintenance",
|
||||
Question: "Should unused code be removed?",
|
||||
Options: []string{
|
||||
"Remove if truly unused",
|
||||
"Keep for future use",
|
||||
"Mark as deprecated",
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
questions = append(questions, ReviewQuestion{
|
||||
ID: "priority",
|
||||
Category: "planning",
|
||||
Question: "Which area should be prioritized for improvement?",
|
||||
Options: []string{
|
||||
"Security issues first",
|
||||
"Complexity reduction",
|
||||
"Dead code cleanup",
|
||||
"Architecture improvements",
|
||||
},
|
||||
})
|
||||
|
||||
return questions
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) Save(packet *ReviewPacket, filename string) error {
|
||||
reviewDir := filepath.Join(g.dataDir, "review")
|
||||
if err := os.MkdirAll(reviewDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create review directory: %w", err)
|
||||
}
|
||||
|
||||
path := filepath.Join(reviewDir, filename)
|
||||
data, err := json.MarshalIndent(packet, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal packet: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write packet: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) Load(filename string) (*ReviewPacket, error) {
|
||||
path := filepath.Join(g.dataDir, "review", filename)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read packet: %w", err)
|
||||
}
|
||||
|
||||
var packet ReviewPacket
|
||||
if err := json.Unmarshal(data, &packet); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse packet: %w", err)
|
||||
}
|
||||
|
||||
return &packet, nil
|
||||
}
|
||||
|
||||
func (g *PacketGenerator) ImportReview(filename string, responses map[string]string) error {
|
||||
_, err := g.Load(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
findingsPath := filepath.Join(g.dataDir, "quality", "status.json")
|
||||
data, err := os.ReadFile(findingsPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read findings: %w", err)
|
||||
}
|
||||
|
||||
var state struct {
|
||||
Findings []quality.Finding `json:"findings"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return fmt.Errorf("failed to parse findings: %w", err)
|
||||
}
|
||||
|
||||
for _, f := range state.Findings {
|
||||
if response, ok := responses[f.ID]; ok {
|
||||
if f.Metadata == nil {
|
||||
f.Metadata = make(map[string]string)
|
||||
}
|
||||
f.Metadata["review_response"] = response
|
||||
f.Metadata["reviewed_at"] = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
}
|
||||
|
||||
updatedData, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal updated findings: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(findingsPath, updatedData, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write updated findings: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Scanner orchestrates the code quality scanning process
|
||||
type Scanner struct {
|
||||
detectors map[string]Detector
|
||||
finder FileFinder
|
||||
config *Config
|
||||
}
|
||||
|
||||
// NewScanner creates a new quality scanner
|
||||
func NewScanner(config *Config) *Scanner {
|
||||
return &Scanner{
|
||||
detectors: make(map[string]Detector),
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterDetector registers a detector with the scanner
|
||||
func (s *Scanner) RegisterDetector(detector Detector) {
|
||||
s.detectors[detector.Name()] = detector
|
||||
}
|
||||
|
||||
// SetFileFinder sets the file finder for the scanner
|
||||
func (s *Scanner) SetFileFinder(finder FileFinder) {
|
||||
s.finder = finder
|
||||
}
|
||||
|
||||
// Scan performs a comprehensive quality scan
|
||||
func (s *Scanner) Scan(ctx context.Context) (*ScanResult, error) {
|
||||
start := time.Now()
|
||||
|
||||
log.Printf("Starting quality scan for path: %s", s.config.Path)
|
||||
|
||||
allFindings := make([]Finding, 0)
|
||||
filesChecked := 0
|
||||
|
||||
// Determine language if not specified
|
||||
language := s.config.Language
|
||||
if language == "" {
|
||||
language = s.detectLanguage(s.config.Path)
|
||||
log.Printf("Auto-detected language: %s", language)
|
||||
}
|
||||
|
||||
// Get source files
|
||||
files, err := s.getSourceFiles(s.config.Path, language)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get source files: %w", err)
|
||||
}
|
||||
|
||||
filesChecked = len(files)
|
||||
log.Printf("Found %d source files to analyze", filesChecked)
|
||||
|
||||
// Run all detectors
|
||||
for name, detector := range s.detectors {
|
||||
log.Printf("Running detector: %s", name)
|
||||
|
||||
// Skip language-specific detectors for different languages
|
||||
if langDetector, ok := detector.(LanguageDetector); ok {
|
||||
supported := langDetector.SupportedLanguages()
|
||||
if !contains(supported, language) {
|
||||
log.Printf("Skipping detector %s for language %s", name, language)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
findings, err := detector.Detect(ctx, s.config.Path, s.config)
|
||||
if err != nil {
|
||||
log.Printf("Detector %s failed: %v", name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Filter findings based on exclude patterns
|
||||
filtered := s.filterFindings(findings)
|
||||
allFindings = append(allFindings, filtered...)
|
||||
|
||||
log.Printf("Detector %s found %d issues", name, len(filtered))
|
||||
}
|
||||
|
||||
// Calculate scores
|
||||
score, strictScore := s.calculateScores(allFindings)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
result := &ScanResult{
|
||||
Findings: allFindings,
|
||||
Score: score,
|
||||
StrictScore: strictScore,
|
||||
FilesChecked: filesChecked,
|
||||
Duration: duration.String(),
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
log.Printf("Scan completed in %s: %d findings, score: %d (strict: %d)",
|
||||
duration, len(allFindings), score, strictScore)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// detectLanguage attempts to auto-detect the project language
|
||||
func (s *Scanner) detectLanguage(path string) string {
|
||||
// Check for marker files
|
||||
markers := map[string]string{
|
||||
"go.mod": "go",
|
||||
"package.json": "typescript",
|
||||
"tsconfig.json": "typescript",
|
||||
"requirements.txt": "python",
|
||||
"setup.py": "python",
|
||||
"pyproject.toml": "python",
|
||||
"pom.xml": "java",
|
||||
"build.gradle": "java",
|
||||
"Cargo.toml": "rust",
|
||||
"composer.json": "php",
|
||||
}
|
||||
|
||||
for file, lang := range markers {
|
||||
if _, err := filepath.Abs(filepath.Join(path, file)); err == nil {
|
||||
if _, err := filepath.Glob(filepath.Join(path, file)); err == nil {
|
||||
return lang
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to Go if no markers found
|
||||
return "go"
|
||||
}
|
||||
|
||||
// getSourceFiles gets all source files for the given language and path
|
||||
func (s *Scanner) getSourceFiles(path, language string) ([]string, error) {
|
||||
if s.finder != nil {
|
||||
return s.finder.FindFiles(path, language)
|
||||
}
|
||||
|
||||
// Fallback to basic file extension matching
|
||||
extensions := map[string][]string{
|
||||
"go": {".go"},
|
||||
"typescript": {".ts", ".tsx"},
|
||||
"python": {".py"},
|
||||
"java": {".java"},
|
||||
"rust": {".rs"},
|
||||
"javascript": {".js", ".jsx"},
|
||||
}
|
||||
|
||||
langExts, ok := extensions[language]
|
||||
if !ok {
|
||||
langExts = []string{".go"} // default to Go
|
||||
}
|
||||
|
||||
var files []string
|
||||
err := filepath.Walk(path, func(filePath string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
// Skip hidden directories and common exclude dirs
|
||||
base := filepath.Base(filePath)
|
||||
if strings.HasPrefix(base, ".") || base == "node_modules" || base == "vendor" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := filepath.Ext(filePath)
|
||||
for _, langExt := range langExts {
|
||||
if ext == langExt {
|
||||
if !ShouldExclude(filePath, s.config.Exclude) {
|
||||
files = append(files, filePath)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return files, err
|
||||
}
|
||||
|
||||
// filterFindings filters findings based on exclude patterns
|
||||
func (s *Scanner) filterFindings(findings []Finding) []Finding {
|
||||
if len(s.config.Exclude) == 0 {
|
||||
return findings
|
||||
}
|
||||
|
||||
var filtered []Finding
|
||||
for _, finding := range findings {
|
||||
if !ShouldExclude(finding.File, s.config.Exclude) {
|
||||
filtered = append(filtered, finding)
|
||||
}
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
// calculateScores calculates quality scores based on findings
|
||||
func (s *Scanner) calculateScores(findings []Finding) (int, int) {
|
||||
totalScore := 0
|
||||
strictScore := 0
|
||||
|
||||
for _, finding := range findings {
|
||||
weight := int(finding.Severity)
|
||||
score := finding.Score * weight
|
||||
totalScore += score
|
||||
|
||||
// Strict score includes open and wontfix findings
|
||||
if finding.Status == StatusOpen || finding.Status == StatusWontfix {
|
||||
strictScore += score
|
||||
}
|
||||
}
|
||||
|
||||
return totalScore, strictScore
|
||||
}
|
||||
|
||||
// contains checks if a slice contains a string
|
||||
func contains(slice []string, item string) bool {
|
||||
for _, s := range slice {
|
||||
if s == item {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
package scorecard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
type Dimension struct {
|
||||
Name string
|
||||
Score float64
|
||||
Strict float64
|
||||
Count int
|
||||
}
|
||||
|
||||
type ScorecardData struct {
|
||||
ProjectName string
|
||||
Version string
|
||||
OverallScore float64
|
||||
StrictScore float64
|
||||
Grade string
|
||||
FindingsTotal int
|
||||
FindingsOpen int
|
||||
LastScan time.Time
|
||||
Dimensions []Dimension
|
||||
FindByType map[string]int
|
||||
FindByTier map[string]int
|
||||
}
|
||||
|
||||
func Generate(data *ScorecardData, outputPath string) error {
|
||||
width := 780 * Scale
|
||||
leftPanelWidth := 260 * Scale
|
||||
frameInset := 5 * Scale
|
||||
|
||||
rowCount := len(data.Dimensions)
|
||||
if rowCount < 4 {
|
||||
rowCount = 4
|
||||
}
|
||||
cols := 2
|
||||
rowsPerCol := (rowCount + cols - 1) / cols
|
||||
rowH := 20 * Scale
|
||||
tableContentH := 14*Scale + 4*Scale + 6*Scale + rowsPerCol*rowH
|
||||
contentH := max(tableContentH+28*Scale, 150*Scale)
|
||||
height := 12*Scale + contentH
|
||||
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
dc := NewDrawContext(img, Scale)
|
||||
|
||||
dc.FillBackground(BG)
|
||||
dc.DrawDoubleFrame(0, 0, width-1, height-1, FRAME, BORDER, 2*Scale, 1)
|
||||
|
||||
contentTop := frameInset + Scale
|
||||
contentBot := height - frameInset - Scale
|
||||
contentMidY := (contentTop + contentBot) / 2
|
||||
dividerX := leftPanelWidth
|
||||
|
||||
drawLeftPanel(dc, data, frameInset+11*Scale, dividerX-11*Scale, contentTop+4*Scale, contentBot-4*Scale)
|
||||
dc.DrawVertRuleWithOrnament(dividerX, contentTop+12*Scale, contentBot-12*Scale, contentMidY, BORDER, ACCENT)
|
||||
drawRightPanel(dc, data, dividerX+11*Scale, width-frameInset-11*Scale, contentTop+4*Scale, contentBot-4*Scale)
|
||||
|
||||
dir := filepath.Dir(outputPath)
|
||||
if dir != "" {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
f, err := os.Create(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
return fmt.Errorf("failed to encode PNG: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func drawLeftPanel(dc *DrawContext, data *ScorecardData, lpLeft, lpRight, lpTop, lpBot int) {
|
||||
lpCenter := (lpLeft + lpRight) / 2
|
||||
panelWidth := lpRight - lpLeft
|
||||
panelHeight := lpBot - lpTop
|
||||
|
||||
dc.DrawRoundedRect(lpLeft, lpTop, panelWidth, panelHeight, 4*Scale, BGScore)
|
||||
dc.DrawRect(lpLeft, lpTop, lpRight, lpBot, BORDER, 1)
|
||||
|
||||
versionText := "version unknown"
|
||||
if data.Version != "" {
|
||||
versionText = "v" + data.Version
|
||||
}
|
||||
fontVersion := GetFont()
|
||||
versionW, _, versionOffY := dc.TextBounds(versionText, fontVersion)
|
||||
versionY := lpTop + 12*Scale - versionOffY
|
||||
dc.DrawText(versionText, lpCenter-versionW/2, versionY, fontVersion, DIM)
|
||||
|
||||
title := "DEVOUR SCORE"
|
||||
fontTitle := GetFont()
|
||||
titleW, titleH, _ := dc.TextBounds(title, fontTitle)
|
||||
titleY := lpTop + 28*Scale
|
||||
dc.DrawText(title, lpCenter-titleW/2, titleY, fontTitle, TEXT)
|
||||
|
||||
ruleY := titleY + titleH + 7*Scale
|
||||
dc.DrawRuleWithOrnament(ruleY, lpLeft+28*Scale, lpRight-28*Scale, lpCenter, BORDER, ACCENT)
|
||||
|
||||
scoreText := FmtScore(data.OverallScore)
|
||||
fontBig := GetFont()
|
||||
scoreW, scoreH, scoreOffY := dc.TextBounds(scoreText, fontBig)
|
||||
scoreY := ruleY + 6*Scale + 7*Scale - scoreOffY
|
||||
scoreColor := GetScoreColor(int(data.OverallScore))
|
||||
dc.DrawText(scoreText, lpCenter-scoreW/2, scoreY, fontBig, scoreColor)
|
||||
|
||||
strictLabel := "strict"
|
||||
strictValue := FmtScore(data.StrictScore) + "%"
|
||||
fontStrictLabel := GetFont()
|
||||
fontStrictVal := GetFont()
|
||||
labelW, _, labelOffY := dc.TextBounds(strictLabel, fontStrictLabel)
|
||||
valueW, _, valueOffY := dc.TextBounds(strictValue, fontStrictVal)
|
||||
gap := 5 * Scale
|
||||
strictY := scoreY + scoreH + 6*Scale
|
||||
strictX := lpCenter - (labelW+gap+valueW)/2
|
||||
dc.DrawText(strictLabel, strictX, strictY-labelOffY, fontStrictLabel, DIM)
|
||||
strictColor := GetScoreColorMuted(int(data.StrictScore))
|
||||
dc.DrawText(strictValue, strictX+labelW+gap, strictY-valueOffY, fontStrictVal, strictColor)
|
||||
|
||||
projectName := data.ProjectName
|
||||
if projectName == "" {
|
||||
projectName = "project"
|
||||
}
|
||||
fontProject := GetFont()
|
||||
projectW, projectH, _ := dc.TextBounds(projectName, fontProject)
|
||||
pillPadX := 8 * Scale
|
||||
pillPadY := 3 * Scale
|
||||
pillHeight := projectH + 2*pillPadY
|
||||
pillTop := strictY + projectH + 8*Scale
|
||||
pillLeft := lpCenter - projectW/2 - pillPadX
|
||||
pillRight := lpCenter + projectW/2 + pillPadX
|
||||
dc.DrawRoundedRect(pillLeft, pillTop, pillRight-pillLeft, pillHeight, 3*Scale, BG)
|
||||
dc.DrawRect(pillLeft, pillTop, pillRight, pillTop+pillHeight, BORDER, 1)
|
||||
projectY := pillTop + pillPadY
|
||||
dc.DrawText(projectName, lpCenter-projectW/2, projectY, fontProject, DIM)
|
||||
}
|
||||
|
||||
func drawRightPanel(dc *DrawContext, data *ScorecardData, tableX1, tableX2, tableTop, tableBot int) {
|
||||
fontRow := GetFont()
|
||||
fontStrict := GetFont()
|
||||
rowCount := len(data.Dimensions)
|
||||
|
||||
cols := 2
|
||||
rowsPerCol := (rowCount + cols - 1) / cols
|
||||
gridGap := 8 * Scale
|
||||
gridWidth := (tableX2 - tableX1 - gridGap) / cols
|
||||
rowH := 20 * Scale
|
||||
|
||||
for colIndex := 0; colIndex < cols; colIndex++ {
|
||||
gridX1 := tableX1 + colIndex*(gridWidth+gridGap)
|
||||
gridX2 := gridX1 + gridWidth
|
||||
|
||||
dc.DrawRoundedRect(gridX1, tableTop, gridWidth, tableBot-tableTop, 4*Scale, BGTable)
|
||||
dc.DrawRect(gridX1, tableTop, gridX2, tableBot, BORDER, 1)
|
||||
|
||||
nameColWidth := 120 * Scale
|
||||
valueColGap := 4 * Scale
|
||||
valueColWidth := 34 * Scale
|
||||
totalContentWidth := nameColWidth + valueColGap + valueColWidth + valueColGap + valueColWidth
|
||||
blockLeft := gridX1 + (gridWidth-totalContentWidth)/2
|
||||
nameColX := blockLeft
|
||||
healthColX := nameColX + nameColWidth + valueColGap
|
||||
strictColX := healthColX + valueColWidth + valueColGap + 4*Scale
|
||||
|
||||
thisColRows := rowsPerCol
|
||||
if colIndex == 1 && rowCount%2 != 0 {
|
||||
thisColRows = rowsPerCol - 1
|
||||
}
|
||||
if colIndex*rowsPerCol+thisColRows > rowCount {
|
||||
thisColRows = rowCount - colIndex*rowsPerCol
|
||||
}
|
||||
|
||||
contentHeight := thisColRows * rowH
|
||||
contentTop := (tableTop+tableBot)/2 - contentHeight/2
|
||||
|
||||
_, rowTextH, rowTextOff := dc.TextBounds("Xg", fontRow)
|
||||
|
||||
startIdx := colIndex * rowsPerCol
|
||||
for rowIdx := 0; rowIdx < thisColRows; rowIdx++ {
|
||||
dimIdx := startIdx + rowIdx
|
||||
if dimIdx >= rowCount {
|
||||
break
|
||||
}
|
||||
dim := data.Dimensions[dimIdx]
|
||||
|
||||
bandTop := contentTop + rowIdx*rowH
|
||||
|
||||
if rowIdx%2 == 1 {
|
||||
dc.FillRect(gridX1+1, bandTop, gridWidth-2, rowH, BGRowAlt)
|
||||
}
|
||||
|
||||
textY := bandTop + (rowH-rowTextH)/2 - rowTextOff + Scale
|
||||
|
||||
maxNameWidth := nameColWidth - 2*Scale
|
||||
name := dc.TruncateText(dim.Name, maxNameWidth, fontRow)
|
||||
dc.DrawText(name, nameColX, textY, fontRow, TEXT)
|
||||
|
||||
score := dim.Score
|
||||
if score == 0 {
|
||||
score = 100
|
||||
}
|
||||
scoreText := FmtScore(score) + "%"
|
||||
dc.DrawText(scoreText, healthColX, textY, fontRow, GetScoreColor(int(score)))
|
||||
|
||||
strict := dim.Strict
|
||||
if strict == 0 {
|
||||
strict = score
|
||||
}
|
||||
strictText := FmtScore(strict) + "%"
|
||||
_, strictTextH, strictOff := dc.TextBounds(strictText, fontStrict)
|
||||
strictY := bandTop + (rowH-strictTextH)/2 - strictOff
|
||||
dc.DrawText(strictText, strictColX, strictY, fontStrict, GetScoreColorMuted(int(strict)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FromQualityState creates ScorecardData from quality state
|
||||
func FromQualityState(state *quality.State, projectName, version string) *ScorecardData {
|
||||
data := &ScorecardData{
|
||||
ProjectName: projectName,
|
||||
Version: version,
|
||||
FindingsTotal: len(state.Findings),
|
||||
LastScan: state.LastScan,
|
||||
FindByType: make(map[string]int),
|
||||
FindByTier: make(map[string]int),
|
||||
}
|
||||
|
||||
// Get score from scorecard
|
||||
if state.Scorecard != nil {
|
||||
data.OverallScore = float64(state.Scorecard.TotalScore)
|
||||
data.StrictScore = float64(state.Scorecard.StrictScore)
|
||||
data.FindByType = state.Scorecard.FindingsByType
|
||||
data.FindByTier = make(map[string]int)
|
||||
for sev, count := range state.Scorecard.FindingsByTier {
|
||||
data.FindByTier[fmt.Sprintf("T%d", sev)] = count
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate grade
|
||||
data.Grade = GetScoreGrade(int(data.OverallScore))
|
||||
|
||||
// Count open findings
|
||||
for _, f := range state.Findings {
|
||||
if f.Status == quality.StatusOpen {
|
||||
data.FindingsOpen++
|
||||
}
|
||||
}
|
||||
|
||||
// Build dimensions from findings by type
|
||||
data.Dimensions = buildDimensions(state)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// buildDimensions builds dimension list from quality state
|
||||
func buildDimensions(state *quality.State) []Dimension {
|
||||
dims := []Dimension{}
|
||||
|
||||
byType := make(map[string]*Dimension)
|
||||
|
||||
for _, f := range state.Findings {
|
||||
if f.Status == quality.StatusOpen {
|
||||
if _, exists := byType[f.Type]; !exists {
|
||||
byType[f.Type] = &Dimension{
|
||||
Name: formatDimensionName(f.Type),
|
||||
Score: 100,
|
||||
Count: 0,
|
||||
}
|
||||
}
|
||||
byType[f.Type].Count++
|
||||
byType[f.Type].Score -= float64(f.Severity)
|
||||
if byType[f.Type].Score < 0 {
|
||||
byType[f.Type].Score = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, dim := range byType {
|
||||
dim.Strict = dim.Score
|
||||
dims = append(dims, *dim)
|
||||
}
|
||||
|
||||
sort.Slice(dims, func(i, j int) bool {
|
||||
return dims[i].Count > dims[j].Count
|
||||
})
|
||||
|
||||
if len(dims) > 12 {
|
||||
dims = dims[:12]
|
||||
}
|
||||
|
||||
return dims
|
||||
}
|
||||
|
||||
// formatDimensionName formats a dimension name for display
|
||||
func formatDimensionName(name string) string {
|
||||
// Map internal names to display names
|
||||
nameMap := map[string]string{
|
||||
"complexity": "Complexity",
|
||||
"duplication": "Duplication",
|
||||
"naming": "Naming",
|
||||
"security": "Security",
|
||||
"dead_code": "Dead Code",
|
||||
"unused_import": "Unused Import",
|
||||
"unused_var": "Unused Variable",
|
||||
"god_component": "God Component",
|
||||
"mixed_concerns": "Mixed Concerns",
|
||||
"test_coverage": "Test Coverage",
|
||||
}
|
||||
|
||||
if display, ok := nameMap[name]; ok {
|
||||
return display
|
||||
}
|
||||
|
||||
if len(name) > 0 {
|
||||
return string(name[0]-32) + name[1:]
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
package scorecard
|
||||
|
||||
import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"strconv"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/basicfont"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
type DrawContext struct {
|
||||
Img *image.RGBA
|
||||
Scale int
|
||||
}
|
||||
|
||||
func NewDrawContext(img *image.RGBA, scale int) *DrawContext {
|
||||
return &DrawContext{Img: img, Scale: scale}
|
||||
}
|
||||
|
||||
func (dc *DrawContext) S(v int) int {
|
||||
return v * dc.Scale
|
||||
}
|
||||
|
||||
func (dc *DrawContext) FillRect(x, y, w, h int, c color.RGBA) {
|
||||
for dy := 0; dy < h; dy++ {
|
||||
for dx := 0; dx < w; dx++ {
|
||||
px, py := x+dx, y+dy
|
||||
if px >= 0 && px < dc.Img.Bounds().Dx() && py >= 0 && py < dc.Img.Bounds().Dy() {
|
||||
dc.Img.Set(px, py, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawRect(x1, y1, x2, y2 int, c color.RGBA, width int) {
|
||||
for i := 0; i < width; i++ {
|
||||
dc.DrawHLine(x1, y1+i, x2, c)
|
||||
dc.DrawHLine(x1, y2-i, x2, c)
|
||||
dc.DrawVLine(x1+i, y1, y2, c)
|
||||
dc.DrawVLine(x2-i, y1, y2, c)
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawHLine(x1, y, x2 int, c color.RGBA) {
|
||||
if y < 0 || y >= dc.Img.Bounds().Dy() {
|
||||
return
|
||||
}
|
||||
if x1 > x2 {
|
||||
x1, x2 = x2, x1
|
||||
}
|
||||
for x := x1; x <= x2; x++ {
|
||||
if x >= 0 && x < dc.Img.Bounds().Dx() {
|
||||
dc.Img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawVLine(x, y1, y2 int, c color.RGBA) {
|
||||
if x < 0 || x >= dc.Img.Bounds().Dx() {
|
||||
return
|
||||
}
|
||||
if y1 > y2 {
|
||||
y1, y2 = y2, y1
|
||||
}
|
||||
for y := y1; y <= y2; y++ {
|
||||
if y >= 0 && y < dc.Img.Bounds().Dy() {
|
||||
dc.Img.Set(x, y, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawRoundedRect(x, y, w, h, r int, c color.RGBA) {
|
||||
dc.FillRect(x+r, y, w-2*r, h, c)
|
||||
dc.FillRect(x, y+r, w, h-2*r, c)
|
||||
for dy := -r; dy <= 0; dy++ {
|
||||
for dx := -r; dx <= 0; dx++ {
|
||||
if dx*dx+dy*dy >= r*r {
|
||||
continue
|
||||
}
|
||||
dc.Img.Set(x+r+dx, y+r+dy, c)
|
||||
dc.Img.Set(x+w-r-1-dx, y+r+dy, c)
|
||||
dc.Img.Set(x+r+dx, y+h-r-1-dy, c)
|
||||
dc.Img.Set(x+w-r-1-dx, y+h-r-1-dy, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawRoundedRectWithOutline(x, y, w, h, r int, fill, outline color.RGBA, outlineWidth int) {
|
||||
dc.DrawRoundedRect(x, y, w, h, r, fill)
|
||||
rr := r - outlineWidth
|
||||
if rr < 0 {
|
||||
rr = 0
|
||||
}
|
||||
for i := 0; i < outlineWidth; i++ {
|
||||
ri := r - i
|
||||
if ri < 0 {
|
||||
ri = 0
|
||||
}
|
||||
dc.DrawHLine(x+ri, y+i, x+w-ri-1, outline)
|
||||
dc.DrawHLine(x+ri, y+h-i-1, x+w-ri-1, outline)
|
||||
dc.DrawVLine(x+i, y+ri, y+h-ri-1, outline)
|
||||
dc.DrawVLine(x+w-i-1, y+ri, y+h-ri-1, outline)
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawDiamond(cx, cy, size int, c color.RGBA) {
|
||||
for dy := -size; dy <= size; dy++ {
|
||||
for dx := -size; dx <= size; dx++ {
|
||||
if abs(dx)+abs(dy) <= size {
|
||||
px, py := cx+dx, cy+dy
|
||||
if px >= 0 && px < dc.Img.Bounds().Dx() && py >= 0 && py < dc.Img.Bounds().Dy() {
|
||||
dc.Img.Set(px, py, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawRuleWithOrnament(y, x1, x2, cx int, lineColor, ornamentColor color.RGBA) {
|
||||
gap := dc.S(8)
|
||||
dc.DrawHLine(x1, y, cx-gap, lineColor)
|
||||
dc.DrawHLine(cx+gap, y, x2, lineColor)
|
||||
dc.DrawDiamond(cx, y, dc.S(3), ornamentColor)
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawVertRuleWithOrnament(x, y1, y2, cy int, lineColor, ornamentColor color.RGBA) {
|
||||
gap := dc.S(8)
|
||||
dc.DrawVLine(x, y1, cy-gap, lineColor)
|
||||
dc.DrawVLine(x, cy+gap, y2, lineColor)
|
||||
dc.DrawDiamond(x, cy, dc.S(3), ornamentColor)
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawText(text string, x, y int, face font.Face, c color.RGBA) {
|
||||
d := font.Drawer{
|
||||
Dst: dc.Img,
|
||||
Src: &image.Uniform{c},
|
||||
Face: face,
|
||||
Dot: fixed.Point26_6{X: fixed.I(x), Y: fixed.I(y)},
|
||||
}
|
||||
d.DrawString(text)
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawCenteredText(text string, cx, y int, face font.Face, c color.RGBA) {
|
||||
advance := font.MeasureString(face, text)
|
||||
x := cx - (advance.Ceil() / 2)
|
||||
dc.DrawText(text, x, y, face, c)
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawRightAlignedText(text string, rx, y int, face font.Face, c color.RGBA) {
|
||||
advance := font.MeasureString(face, text)
|
||||
x := rx - advance.Ceil()
|
||||
dc.DrawText(text, x, y, face, c)
|
||||
}
|
||||
|
||||
func (dc *DrawContext) FillBackground(c color.RGBA) {
|
||||
draw.Draw(dc.Img, dc.Img.Bounds(), &image.Uniform{c}, image.Point{}, draw.Src)
|
||||
}
|
||||
|
||||
func (dc *DrawContext) DrawDoubleFrame(x1, y1, x2, y2 int, outerColor, innerColor color.RGBA, outerWidth, innerWidth int) {
|
||||
dc.DrawRect(x1, y1, x2, y2, outerColor, outerWidth)
|
||||
innerX1 := x1 + outerWidth + 2
|
||||
innerY1 := y1 + outerWidth + 2
|
||||
innerX2 := x2 - outerWidth - 2
|
||||
innerY2 := y2 - outerWidth - 2
|
||||
dc.DrawRect(innerX1, innerY1, innerX2, innerY2, innerColor, innerWidth)
|
||||
}
|
||||
|
||||
func (dc *DrawContext) TextWidth(text string, face font.Face) int {
|
||||
return font.MeasureString(face, text).Ceil()
|
||||
}
|
||||
|
||||
func (dc *DrawContext) TextBounds(text string, face font.Face) (width, height, offsetY int) {
|
||||
advance := font.MeasureString(face, text)
|
||||
width = advance.Ceil()
|
||||
metrics := face.Metrics()
|
||||
height = (metrics.Ascent + metrics.Descent).Ceil()
|
||||
offsetY = -metrics.Ascent.Ceil()
|
||||
return
|
||||
}
|
||||
|
||||
func (dc *DrawContext) TruncateText(text string, maxWidth int, face font.Face) string {
|
||||
if dc.TextWidth(text, face) <= maxWidth {
|
||||
return text
|
||||
}
|
||||
ellipsis := "…"
|
||||
ellipsisWidth := dc.TextWidth(ellipsis, face)
|
||||
for len(text) > 0 {
|
||||
text = text[:len(text)-1]
|
||||
if dc.TextWidth(text, face)+ellipsisWidth <= maxWidth {
|
||||
return text + ellipsis
|
||||
}
|
||||
}
|
||||
return ellipsis
|
||||
}
|
||||
|
||||
func GetFont() font.Face {
|
||||
return basicfont.Face7x13
|
||||
}
|
||||
|
||||
func FmtScore(score float64) string {
|
||||
if score == float64(int(score)) {
|
||||
return strconv.Itoa(int(score))
|
||||
}
|
||||
return strconv.FormatFloat(score, 'f', 1, 64)
|
||||
}
|
||||
|
||||
func abs(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package scorecard
|
||||
|
||||
import "image/color"
|
||||
|
||||
// Scale for retina/high-DPI rendering
|
||||
const Scale = 2
|
||||
|
||||
// Theme colors for the scorecard badge - warm earth-tone palette
|
||||
var (
|
||||
// BG is the main background (warm cream)
|
||||
BG = color.RGBA{R: 247, G: 240, B: 228, A: 255}
|
||||
// BGScore is the score panel background
|
||||
BGScore = color.RGBA{R: 240, G: 232, B: 217, A: 255}
|
||||
// BGTable is the table background
|
||||
BGTable = color.RGBA{R: 240, G: 233, B: 220, A: 255}
|
||||
// BGRowAlt is the alternate row background
|
||||
BGRowAlt = color.RGBA{R: 234, G: 226, B: 212, A: 255}
|
||||
// TEXT is the main text color (dark brown)
|
||||
TEXT = color.RGBA{R: 58, G: 48, B: 38, A: 255}
|
||||
// DIM is the dimmed text color (warm gray)
|
||||
DIM = color.RGBA{R: 138, G: 122, B: 102, A: 255}
|
||||
// BORDER is the inner border color (warm tan)
|
||||
BORDER = color.RGBA{R: 192, G: 176, B: 152, A: 255}
|
||||
// ACCENT is the accent color (warm brown)
|
||||
ACCENT = color.RGBA{R: 148, G: 112, B: 82, A: 255}
|
||||
// FRAME is the outer frame color (warm tan)
|
||||
FRAME = color.RGBA{R: 172, G: 152, B: 126, A: 255}
|
||||
)
|
||||
|
||||
// Score grade colors - gradient from sage to rose
|
||||
var (
|
||||
// GradeA is for scores 90-100% (deep sage green)
|
||||
GradeA = color.RGBA{R: 68, G: 120, B: 68, A: 255}
|
||||
// GradeB is for scores 70-89% (olive green)
|
||||
GradeB = color.RGBA{R: 120, G: 140, B: 72, A: 255}
|
||||
// GradeC is for scores 50-69% (yellow-green)
|
||||
GradeC = color.RGBA{R: 145, G: 155, B: 80, A: 255}
|
||||
// GradeD is for scores 30-49% (mustard)
|
||||
GradeD = color.RGBA{R: 180, G: 150, B: 70, A: 255}
|
||||
// GradeF is for scores 0-29% (dusty rose)
|
||||
GradeF = color.RGBA{R: 170, G: 110, B: 90, A: 255}
|
||||
)
|
||||
|
||||
// Muted score colors for strict column (pastel orange/peach shades)
|
||||
var (
|
||||
// GradeAMuted is muted version of GradeA
|
||||
GradeAMuted = color.RGBA{R: 195, G: 160, B: 115, A: 255} // light sandy peach
|
||||
// GradeBMuted is muted version of GradeB
|
||||
GradeBMuted = color.RGBA{R: 200, G: 148, B: 100, A: 255} // warm apricot
|
||||
// GradeCMuted is muted version of GradeC
|
||||
GradeCMuted = color.RGBA{R: 195, G: 125, B: 95, A: 255} // soft coral
|
||||
// GradeDMuted is muted version of GradeD
|
||||
GradeDMuted = color.RGBA{R: 190, G: 130, B: 100, A: 255}
|
||||
// GradeFMuted is muted version of GradeF
|
||||
GradeFMuted = color.RGBA{R: 185, G: 120, B: 100, A: 255}
|
||||
)
|
||||
|
||||
// Severity colors for findings
|
||||
var (
|
||||
SeverityT1Color = color.RGBA{R: 100, G: 180, B: 255, A: 255}
|
||||
SeverityT2Color = color.RGBA{R: 255, G: 200, B: 100, A: 255}
|
||||
SeverityT3Color = color.RGBA{R: 255, G: 140, B: 80, A: 255}
|
||||
SeverityT4Color = color.RGBA{R: 255, G: 80, B: 80, A: 255}
|
||||
)
|
||||
|
||||
func GetGradeColor(grade string) color.RGBA {
|
||||
switch grade {
|
||||
case "A":
|
||||
return GradeA
|
||||
case "B":
|
||||
return GradeB
|
||||
case "C":
|
||||
return GradeC
|
||||
case "D":
|
||||
return GradeD
|
||||
default:
|
||||
return GradeF
|
||||
}
|
||||
}
|
||||
|
||||
func GetGradeColorMuted(grade string) color.RGBA {
|
||||
switch grade {
|
||||
case "A":
|
||||
return GradeAMuted
|
||||
case "B":
|
||||
return GradeBMuted
|
||||
case "C":
|
||||
return GradeCMuted
|
||||
case "D":
|
||||
return GradeDMuted
|
||||
default:
|
||||
return GradeFMuted
|
||||
}
|
||||
}
|
||||
|
||||
func GetScoreGrade(score int) string {
|
||||
switch {
|
||||
case score >= 90:
|
||||
return "A"
|
||||
case score >= 70:
|
||||
return "B"
|
||||
case score >= 50:
|
||||
return "C"
|
||||
case score >= 30:
|
||||
return "D"
|
||||
default:
|
||||
return "F"
|
||||
}
|
||||
}
|
||||
|
||||
func GetScoreColor(score int) color.RGBA {
|
||||
return GetGradeColor(GetScoreGrade(score))
|
||||
}
|
||||
|
||||
func GetScoreColorMuted(score int) color.RGBA {
|
||||
return GetGradeColorMuted(GetScoreGrade(score))
|
||||
}
|
||||
|
||||
func GetSeverityColor(severity int) color.RGBA {
|
||||
switch severity {
|
||||
case 1:
|
||||
return SeverityT1Color
|
||||
case 2:
|
||||
return SeverityT2Color
|
||||
case 3:
|
||||
return SeverityT3Color
|
||||
case 4:
|
||||
return SeverityT4Color
|
||||
default:
|
||||
return DIM
|
||||
}
|
||||
}
|
||||
|
||||
func ScaleValue(v int) int {
|
||||
return v * Scale
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Scorer calculates quality scores and generates scorecards
|
||||
type Scorer struct {
|
||||
targetScore int
|
||||
}
|
||||
|
||||
// NewScorer creates a new scorer with the given target score
|
||||
func NewScorer(targetScore int) *Scorer {
|
||||
if targetScore <= 0 {
|
||||
targetScore = 95 // Default target
|
||||
}
|
||||
return &Scorer{
|
||||
targetScore: targetScore,
|
||||
}
|
||||
}
|
||||
|
||||
// CalculateScore calculates the quality score from findings
|
||||
func (s *Scorer) CalculateScore(findings []Finding) (int, int) {
|
||||
totalScore := 0
|
||||
strictScore := 0
|
||||
|
||||
for _, finding := range findings {
|
||||
weight := int(finding.Severity)
|
||||
score := finding.Score * weight
|
||||
totalScore += score
|
||||
|
||||
// Strict score includes open and wontfix findings
|
||||
if finding.Status == StatusOpen || finding.Status == StatusWontfix {
|
||||
strictScore += score
|
||||
}
|
||||
}
|
||||
|
||||
return totalScore, strictScore
|
||||
}
|
||||
|
||||
// GenerateScorecard creates a scorecard from scan results
|
||||
func (s *Scorer) GenerateScorecard(findings []Finding, lastScan time.Time) *Scorecard {
|
||||
totalScore, strictScore := s.CalculateScore(findings)
|
||||
|
||||
// Group findings by type and tier
|
||||
findingsByType := make(map[string]int)
|
||||
findingsByTier := make(map[Severity]int)
|
||||
statusByType := make(map[string]int)
|
||||
|
||||
for _, finding := range findings {
|
||||
findingsByType[finding.Type]++
|
||||
findingsByTier[finding.Severity]++
|
||||
statusByType[string(finding.Status)]++
|
||||
}
|
||||
|
||||
return &Scorecard{
|
||||
TotalScore: totalScore,
|
||||
StrictScore: strictScore,
|
||||
TargetScore: s.targetScore,
|
||||
FindingsByType: findingsByType,
|
||||
FindingsByTier: findingsByTier,
|
||||
StatusByType: statusByType,
|
||||
LastScan: lastScan,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHealthGrade returns a health grade based on score
|
||||
func (s *Scorer) GetHealthGrade(score int) string {
|
||||
percentage := s.getScorePercentage(score)
|
||||
|
||||
switch {
|
||||
case percentage >= 90:
|
||||
return "A"
|
||||
case percentage >= 80:
|
||||
return "B"
|
||||
case percentage >= 70:
|
||||
return "C"
|
||||
case percentage >= 60:
|
||||
return "D"
|
||||
default:
|
||||
return "F"
|
||||
}
|
||||
}
|
||||
|
||||
// getScorePercentage converts score to percentage (inverted - lower is better)
|
||||
func (s *Scorer) getScorePercentage(score int) int {
|
||||
// Invert score so lower debt = higher percentage
|
||||
maxPossibleScore := 1000 // Arbitrary high value for normalization
|
||||
percentage := 100 - (score * 100 / maxPossibleScore)
|
||||
if percentage < 0 {
|
||||
percentage = 0
|
||||
}
|
||||
return percentage
|
||||
}
|
||||
|
||||
// FormatScorecard formats the scorecard for display
|
||||
func (s *Scorer) FormatScorecard(card *Scorecard) string {
|
||||
grade := s.GetHealthGrade(card.StrictScore)
|
||||
percentage := s.getScorePercentage(card.StrictScore)
|
||||
|
||||
output := fmt.Sprintf(`
|
||||
Code Quality Scorecard
|
||||
=======================================
|
||||
|
||||
Overall Health: %s (%d%%)
|
||||
Target Score: %d
|
||||
Current Score: %d (strict: %d)
|
||||
|
||||
Findings by Type:
|
||||
`, grade, percentage, card.TargetScore, card.TotalScore, card.StrictScore)
|
||||
|
||||
for ftype, count := range card.FindingsByType {
|
||||
output += fmt.Sprintf(" - %s: %d\n", ftype, count)
|
||||
}
|
||||
|
||||
output += "\nFindings by Severity:\n"
|
||||
tierNames := map[Severity]string{
|
||||
SeverityT1: "T1 (Auto-fixable)",
|
||||
SeverityT2: "T2 (Quick manual)",
|
||||
SeverityT3: "T3 (Needs judgment)",
|
||||
SeverityT4: "T4 (Major refactor)",
|
||||
}
|
||||
|
||||
for severity, count := range card.FindingsByTier {
|
||||
if name, ok := tierNames[severity]; ok {
|
||||
output += fmt.Sprintf(" - %s: %d\n", name, count)
|
||||
}
|
||||
}
|
||||
|
||||
output += "\nStatus Breakdown:\n"
|
||||
for status, count := range card.StatusByType {
|
||||
output += fmt.Sprintf(" - %s: %d\n", status, count)
|
||||
}
|
||||
|
||||
output += fmt.Sprintf("\nLast Scan: %s\n", card.LastScan.Format("2006-01-02 15:04:05"))
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
// GetNextPriority returns the next highest priority finding to fix
|
||||
func (s *Scorer) GetNextPriority(findings []Finding) *Finding {
|
||||
if len(findings) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var highest *Finding
|
||||
highestWeight := 0
|
||||
|
||||
for _, finding := range findings {
|
||||
if finding.Status != StatusOpen {
|
||||
continue
|
||||
}
|
||||
|
||||
weight := int(finding.Severity) * finding.Score
|
||||
if weight > highestWeight {
|
||||
highestWeight = weight
|
||||
highest = &finding
|
||||
}
|
||||
}
|
||||
|
||||
return highest
|
||||
}
|
||||
|
||||
// GetFindingsByTier returns findings grouped by severity tier
|
||||
func (s *Scorer) GetFindingsByTier(findings []Finding) map[Severity][]Finding {
|
||||
result := make(map[Severity][]Finding)
|
||||
|
||||
for _, finding := range findings {
|
||||
if finding.Status == StatusOpen {
|
||||
result[finding.Severity] = append(result[finding.Severity], finding)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// GetProgressMetrics returns progress metrics for the scan
|
||||
func (s *Scorer) GetProgressMetrics(findings []Finding) map[string]interface{} {
|
||||
total := len(findings)
|
||||
open := 0
|
||||
fixed := 0
|
||||
wontfix := 0
|
||||
|
||||
for _, finding := range findings {
|
||||
switch finding.Status {
|
||||
case StatusOpen:
|
||||
open++
|
||||
case StatusFixed:
|
||||
fixed++
|
||||
case StatusWontfix:
|
||||
wontfix++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"total": total,
|
||||
"open": open,
|
||||
"fixed": fixed,
|
||||
"wontfix": wontfix,
|
||||
"progress": float64(fixed) / float64(total) * 100,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StateManager manages quality analysis state with diff tracking
|
||||
type StateManager struct {
|
||||
dataDir string
|
||||
stateFile string
|
||||
historyDir string
|
||||
}
|
||||
|
||||
// State represents the persisted quality state
|
||||
type State struct {
|
||||
Findings []Finding `json:"findings"`
|
||||
Scorecard *Scorecard `json:"scorecard"`
|
||||
LastScan time.Time `json:"last_scan"`
|
||||
ScanCount int `json:"scan_count"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
History []StateSnapshot `json:"history,omitempty"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
// StateSnapshot represents a historical state snapshot
|
||||
type StateSnapshot struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Hash string `json:"hash"`
|
||||
Score int `json:"score"`
|
||||
StrictScore int `json:"strict_score"`
|
||||
Findings int `json:"findings"`
|
||||
File string `json:"file"`
|
||||
}
|
||||
|
||||
// StateDiff represents the difference between two states
|
||||
type StateDiff struct {
|
||||
Added []Finding `json:"added"`
|
||||
Removed []Finding `json:"removed"`
|
||||
Changed []Finding `json:"changed"`
|
||||
Resolved []Finding `json:"resolved"`
|
||||
Regressions []Finding `json:"regressions"`
|
||||
}
|
||||
|
||||
// NewStateManager creates a new state manager
|
||||
func NewStateManager(dataDir string) *StateManager {
|
||||
return &StateManager{
|
||||
dataDir: dataDir,
|
||||
stateFile: filepath.Join(dataDir, "state.json"),
|
||||
historyDir: filepath.Join(dataDir, "history"),
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads the current state from disk
|
||||
func (sm *StateManager) Load() (*State, error) {
|
||||
data, err := os.ReadFile(sm.stateFile)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &State{
|
||||
Findings: []Finding{},
|
||||
Metadata: make(map[string]string),
|
||||
}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to read state: %w", err)
|
||||
}
|
||||
|
||||
var state State
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse state: %w", err)
|
||||
}
|
||||
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
// Save saves the state to disk
|
||||
func (sm *StateManager) Save(state *State) error {
|
||||
// Ensure directory exists
|
||||
if err := os.MkdirAll(sm.dataDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create data directory: %w", err)
|
||||
}
|
||||
|
||||
// Calculate content hash
|
||||
state.ContentHash = sm.calculateHash(state.Findings)
|
||||
|
||||
// Save history snapshot
|
||||
if err := sm.saveHistory(state); err != nil {
|
||||
// Log but don't fail
|
||||
fmt.Fprintf(os.Stderr, "Warning: failed to save history: %v\n", err)
|
||||
}
|
||||
|
||||
// Marshal state
|
||||
data, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal state: %w", err)
|
||||
}
|
||||
|
||||
// Write to temp file first
|
||||
tmpFile := sm.stateFile + ".tmp"
|
||||
if err := os.WriteFile(tmpFile, data, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write state: %w", err)
|
||||
}
|
||||
|
||||
// Rename to final location (atomic on most filesystems)
|
||||
if err := os.Rename(tmpFile, sm.stateFile); err != nil {
|
||||
return fmt.Errorf("failed to rename state file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge merges new findings with existing state
|
||||
func (sm *StateManager) Merge(state *State, newFindings []Finding) *StateDiff {
|
||||
diff := &StateDiff{
|
||||
Added: []Finding{},
|
||||
Removed: []Finding{},
|
||||
Changed: []Finding{},
|
||||
Resolved: []Finding{},
|
||||
}
|
||||
|
||||
// Create lookup maps
|
||||
existingMap := make(map[string]Finding)
|
||||
for _, f := range state.Findings {
|
||||
existingMap[f.ID] = f
|
||||
}
|
||||
|
||||
newMap := make(map[string]Finding)
|
||||
for _, f := range newFindings {
|
||||
newMap[f.ID] = f
|
||||
}
|
||||
|
||||
// Find added and changed findings
|
||||
for _, new := range newFindings {
|
||||
if existing, ok := existingMap[new.ID]; ok {
|
||||
// Check if changed
|
||||
if !findingsEqual(existing, new) {
|
||||
diff.Changed = append(diff.Changed, new)
|
||||
}
|
||||
} else {
|
||||
// New finding
|
||||
diff.Added = append(diff.Added, new)
|
||||
}
|
||||
}
|
||||
|
||||
// Find removed findings (these are resolved)
|
||||
for _, existing := range state.Findings {
|
||||
if _, ok := newMap[existing.ID]; !ok {
|
||||
if existing.Status == StatusOpen {
|
||||
diff.Resolved = append(diff.Resolved, existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update state
|
||||
state.Findings = newFindings
|
||||
state.LastScan = time.Now()
|
||||
state.ScanCount++
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
// Diff compares two states
|
||||
func (sm *StateManager) Diff(old, new *State) *StateDiff {
|
||||
diff := &StateDiff{
|
||||
Added: []Finding{},
|
||||
Removed: []Finding{},
|
||||
Changed: []Finding{},
|
||||
Resolved: []Finding{},
|
||||
Regressions: []Finding{},
|
||||
}
|
||||
|
||||
oldMap := make(map[string]Finding)
|
||||
for _, f := range old.Findings {
|
||||
oldMap[f.ID] = f
|
||||
}
|
||||
|
||||
newMap := make(map[string]Finding)
|
||||
for _, f := range new.Findings {
|
||||
newMap[f.ID] = f
|
||||
}
|
||||
|
||||
for _, n := range new.Findings {
|
||||
if o, ok := oldMap[n.ID]; ok {
|
||||
if !findingsEqual(o, n) {
|
||||
diff.Changed = append(diff.Changed, n)
|
||||
// Check for regression (resolved -> open)
|
||||
if o.Status != StatusOpen && n.Status == StatusOpen {
|
||||
diff.Regressions = append(diff.Regressions, n)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
diff.Added = append(diff.Added, n)
|
||||
}
|
||||
}
|
||||
|
||||
for _, o := range old.Findings {
|
||||
if _, ok := newMap[o.ID]; !ok {
|
||||
diff.Removed = append(diff.Removed, o)
|
||||
}
|
||||
}
|
||||
|
||||
return diff
|
||||
}
|
||||
|
||||
// calculateHash calculates a content hash for findings
|
||||
func (sm *StateManager) calculateHash(findings []Finding) string {
|
||||
// Sort findings for consistent hashing
|
||||
sort.Slice(findings, func(i, j int) bool {
|
||||
return findings[i].ID < findings[j].ID
|
||||
})
|
||||
|
||||
// Create hash from findings
|
||||
data, _ := json.Marshal(findings)
|
||||
hash := sha256.Sum256(data)
|
||||
return fmt.Sprintf("%x", hash)[:16]
|
||||
}
|
||||
|
||||
// saveHistory saves a historical snapshot
|
||||
func (sm *StateManager) saveHistory(state *State) error {
|
||||
if err := os.MkdirAll(sm.historyDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create snapshot
|
||||
snapshot := StateSnapshot{
|
||||
Timestamp: time.Now(),
|
||||
Hash: state.ContentHash,
|
||||
Score: state.Scorecard.TotalScore,
|
||||
StrictScore: state.Scorecard.StrictScore,
|
||||
Findings: len(state.Findings),
|
||||
File: fmt.Sprintf("%s.json", state.ContentHash),
|
||||
}
|
||||
|
||||
// Save snapshot file
|
||||
snapshotFile := filepath.Join(sm.historyDir, snapshot.File)
|
||||
snapshotData, _ := json.MarshalIndent(state, "", " ")
|
||||
if err := os.WriteFile(snapshotFile, snapshotData, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update history in state (keep last 50 snapshots)
|
||||
state.History = append(state.History, snapshot)
|
||||
if len(state.History) > 50 {
|
||||
// Remove old snapshots
|
||||
for _, old := range state.History[:len(state.History)-50] {
|
||||
oldFile := filepath.Join(sm.historyDir, old.File)
|
||||
os.Remove(oldFile) // Ignore errors
|
||||
}
|
||||
state.History = state.History[len(state.History)-50:]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolveFinding updates a finding's status
|
||||
func (sm *StateManager) ResolveFinding(state *State, id string, status Status, note string) error {
|
||||
for i, f := range state.Findings {
|
||||
if f.ID == id {
|
||||
state.Findings[i].Status = status
|
||||
state.Findings[i].UpdatedAt = time.Now()
|
||||
if state.Findings[i].Metadata == nil {
|
||||
state.Findings[i].Metadata = make(map[string]string)
|
||||
}
|
||||
state.Findings[i].Metadata["resolution_note"] = note
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("finding not found: %s", id)
|
||||
}
|
||||
|
||||
// GetFinding retrieves a finding by ID
|
||||
func (sm *StateManager) GetFinding(state *State, id string) *Finding {
|
||||
for _, f := range state.Findings {
|
||||
if f.ID == id {
|
||||
return &f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetOpenFindings returns all open findings
|
||||
func (sm *StateManager) GetOpenFindings(state *State) []Finding {
|
||||
var open []Finding
|
||||
for _, f := range state.Findings {
|
||||
if f.Status == StatusOpen {
|
||||
open = append(open, f)
|
||||
}
|
||||
}
|
||||
return open
|
||||
}
|
||||
|
||||
// GetFindingsByTier returns findings grouped by severity
|
||||
func (sm *StateManager) GetFindingsByTier(state *State) map[Severity][]Finding {
|
||||
result := make(map[Severity][]Finding)
|
||||
for _, f := range state.Findings {
|
||||
result[f.Severity] = append(result[f.Severity], f)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetTrend returns the trend over the last N scans
|
||||
func (sm *StateManager) GetTrend(state *State, n int) []StateSnapshot {
|
||||
if len(state.History) < n {
|
||||
return state.History
|
||||
}
|
||||
return state.History[len(state.History)-n:]
|
||||
}
|
||||
|
||||
// findingsEqual checks if two findings are equal (excluding timestamps)
|
||||
func findingsEqual(a, b Finding) bool {
|
||||
return a.ID == b.ID &&
|
||||
a.Type == b.Type &&
|
||||
a.Title == b.Title &&
|
||||
a.File == b.File &&
|
||||
a.Line == b.Line &&
|
||||
a.Severity == b.Severity &&
|
||||
a.Score == b.Score &&
|
||||
a.Status == b.Status
|
||||
}
|
||||
|
||||
// FormatDiff formats a state diff for display
|
||||
func FormatDiff(diff *StateDiff) string {
|
||||
var sb strings.Builder
|
||||
|
||||
if len(diff.Added) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("[+] Added: %d findings\n", len(diff.Added)))
|
||||
for _, f := range diff.Added {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
||||
}
|
||||
}
|
||||
|
||||
if len(diff.Removed) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("[-] Removed: %d findings\n", len(diff.Removed)))
|
||||
for _, f := range diff.Removed {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
||||
}
|
||||
}
|
||||
|
||||
if len(diff.Changed) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("[~] Changed: %d findings\n", len(diff.Changed)))
|
||||
for _, f := range diff.Changed {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
||||
}
|
||||
}
|
||||
|
||||
if len(diff.Resolved) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("[OK] Resolved: %d findings\n", len(diff.Resolved)))
|
||||
for _, f := range diff.Resolved {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
||||
}
|
||||
}
|
||||
|
||||
if len(diff.Regressions) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("[!] Regressions: %d findings\n", len(diff.Regressions)))
|
||||
for _, f := range diff.Regressions {
|
||||
sb.WriteString(fmt.Sprintf(" - %s: %s\n", f.ID, f.Title))
|
||||
}
|
||||
}
|
||||
|
||||
if sb.Len() == 0 {
|
||||
sb.WriteString("No changes detected\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package quality
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Severity represents the severity level of a finding
|
||||
type Severity int
|
||||
|
||||
const (
|
||||
SeverityT1 Severity = iota + 1 // Auto-fixable (unused imports, debug logs)
|
||||
SeverityT2 // Quick manual (unused vars, dead exports)
|
||||
SeverityT3 // Needs judgment (near-dupes, single_use abstractions)
|
||||
SeverityT4 // Major refactor (god components, mixed concerns)
|
||||
)
|
||||
|
||||
// Status represents the status of a finding
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusOpen Status = "open"
|
||||
StatusFixed Status = "fixed"
|
||||
StatusWontfix Status = "wontfix"
|
||||
StatusFalsePositive Status = "false_positive"
|
||||
StatusIgnored Status = "ignored"
|
||||
)
|
||||
|
||||
// Finding represents a code quality issue
|
||||
type Finding struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
EndLine int `json:"end_line,omitempty"`
|
||||
Severity Severity `json:"severity"`
|
||||
Score int `json:"score"`
|
||||
Status Status `json:"status"`
|
||||
Metadata map[string]string `json:"metadata,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// FunctionInfo represents extracted function/method information
|
||||
type FunctionInfo struct {
|
||||
Name string `json:"name"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
EndLine int `json:"end_line"`
|
||||
LOC int `json:"loc"`
|
||||
Body string `json:"body"`
|
||||
Normalized string `json:"normalized"`
|
||||
BodyHash string `json:"body_hash"`
|
||||
Params []string `json:"params"`
|
||||
ReturnAnnotation string `json:"return_annotation,omitempty"`
|
||||
}
|
||||
|
||||
// ClassInfo represents extracted class/component information
|
||||
type ClassInfo struct {
|
||||
Name string `json:"name"`
|
||||
File string `json:"file"`
|
||||
Line int `json:"line"`
|
||||
LOC int `json:"loc"`
|
||||
Methods []FunctionInfo `json:"methods"`
|
||||
Attributes []string `json:"attributes"`
|
||||
BaseClasses []string `json:"base_classes"`
|
||||
Metrics map[string]int `json:"metrics"`
|
||||
}
|
||||
|
||||
// ScanResult represents the result of a quality scan
|
||||
type ScanResult struct {
|
||||
Findings []Finding `json:"findings"`
|
||||
Score int `json:"score"`
|
||||
StrictScore int `json:"strict_score"`
|
||||
FilesChecked int `json:"files_checked"`
|
||||
Duration string `json:"duration"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Scorecard represents the health scorecard
|
||||
type Scorecard struct {
|
||||
TotalScore int `json:"total_score"`
|
||||
StrictScore int `json:"strict_score"`
|
||||
TargetScore int `json:"target_score"`
|
||||
FindingsByType map[string]int `json:"findings_by_type"`
|
||||
FindingsByTier map[Severity]int `json:"findings_by_tier"`
|
||||
StatusByType map[string]int `json:"status_by_type"`
|
||||
LastScan time.Time `json:"last_scan"`
|
||||
}
|
||||
|
||||
// Language represents a programming language configuration
|
||||
type Language struct {
|
||||
Name string `json:"name"`
|
||||
Extensions []string `json:"extensions"`
|
||||
MarkerFiles []string `json:"marker_files"`
|
||||
DefaultSrc string `json:"default_src"`
|
||||
}
|
||||
|
||||
// Config represents the quality analysis configuration
|
||||
type Config struct {
|
||||
Path string `json:"path"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
Threshold int `json:"threshold,omitempty"`
|
||||
MinLOC int `json:"min_loc,omitempty"`
|
||||
TargetScore int `json:"target_score,omitempty"`
|
||||
ResetSubjective bool `json:"reset_subjective,omitempty"`
|
||||
NoBadge bool `json:"no_badge,omitempty"`
|
||||
BadgePath string `json:"badge_path,omitempty"`
|
||||
}
|
||||
Reference in New Issue
Block a user