first commit

This commit is contained in:
Tomas Dvorak
2026-02-22 10:42:17 +01:00
commit 55885a0e8f
239 changed files with 103690 additions and 0 deletions
+427
View File
@@ -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
}
+471
View File
@@ -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
}
+601
View File
@@ -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
}
+97
View File
@@ -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
}
+212
View File
@@ -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
}
+358
View File
@@ -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
}
+256
View File
@@ -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, ";"),
},
}
}
+301
View File
@@ -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"`
}
+176
View File
@@ -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"
}
+438
View File
@@ -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
}
+363
View File
@@ -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())
}
+163
View File
@@ -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
}
+117
View File
@@ -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
}
+315
View File
@@ -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
}
+233
View File
@@ -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
}
+331
View File
@@ -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
}
+229
View File
@@ -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
}
+136
View File
@@ -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
}
+203
View File
@@ -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,
}
}
+371
View File
@@ -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()
}
+111
View File
@@ -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"`
}