mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user