mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
633 lines
17 KiB
Go
633 lines
17 KiB
Go
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, ok := functionKey(obj)
|
|
if !ok {
|
|
continue
|
|
}
|
|
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:
|
|
key, ok := functionKey(obj)
|
|
if !ok {
|
|
continue
|
|
}
|
|
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 def.Exported || isLikelyEntrypointFile(def.File) {
|
|
continue
|
|
}
|
|
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 def.Exported || isLikelyEntrypointFile(def.File) {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(def.Name, "Error") || strings.HasSuffix(def.Name, "Options") {
|
|
continue
|
|
}
|
|
if strings.HasSuffix(def.Name, "Config") || strings.HasSuffix(def.Name, "Params") {
|
|
continue
|
|
}
|
|
if !strings.Contains(def.Underlying, "struct") && !strings.Contains(def.Underlying, "interface") {
|
|
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, fmt.Errorf("parse %s for function loc lookup: %w", file, 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
|
|
}
|
|
|
|
func functionKey(fn *types.Func) (string, bool) {
|
|
if fn == nil || fn.Pkg() == nil {
|
|
return "", false
|
|
}
|
|
sig, ok := fn.Type().(*types.Signature)
|
|
if ok && sig.Recv() != nil {
|
|
return "", false
|
|
}
|
|
return fn.Pkg().Path() + "." + fn.Name(), true
|
|
}
|
|
|
|
func isLikelyEntrypointFile(path string) bool {
|
|
p := filepath.ToSlash(path)
|
|
return strings.HasPrefix(p, "cmd/") || strings.Contains(p, "/cmd/") || strings.HasSuffix(p, "/main.go") || strings.HasSuffix(p, "_test.go")
|
|
}
|
|
|
|
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: 20, // Increased from 10 to 20 for more realistic threshold
|
|
}
|
|
}
|
|
|
|
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)
|
|
// Skip standard library packages from fan-in analysis
|
|
if d.isStandardLibraryPackage(pkg) {
|
|
continue
|
|
}
|
|
if fanIn > d.maxFanOut*3 { // Increased threshold for fan-in
|
|
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/10 - d.maxFanOut/10, // Reduced scoring
|
|
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) isStandardLibraryPackage(pkgPath string) bool {
|
|
// Standard library packages that commonly have high fan-in
|
|
standardLibs := []string{
|
|
"fmt", "time", "strings", "context", "os", "io", "net/http",
|
|
"encoding/json", "path/filepath", "sync", "math", "regexp",
|
|
}
|
|
|
|
for _, lib := range standardLibs {
|
|
if strings.Contains(pkgPath, lib) && !strings.Contains(pkgPath, "github.com") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
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(),
|
|
PackageName: pkg.Name,
|
|
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(),
|
|
PackageName: pkg.Name,
|
|
Exported: obj.Exported(),
|
|
Underlying: o.Type().Underlying().String(),
|
|
}
|
|
case *types.Var:
|
|
if obj.Exported() && !o.IsField() {
|
|
defs[key] = ObjInfo{
|
|
Name: obj.Name(),
|
|
Type: "variable",
|
|
File: pos.Filename,
|
|
Line: pos.Line,
|
|
Package: obj.Pkg().Path(),
|
|
PackageName: pkg.Name,
|
|
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.Contains(def.Package, "/internal/") || def.PackageName == "main" {
|
|
continue
|
|
}
|
|
if isLikelyEntrypointFile(def.File) {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(def.Name, "Test") || strings.HasPrefix(def.Name, "Benchmark") || strings.HasPrefix(def.Name, "Fuzz") {
|
|
continue
|
|
}
|
|
if def.Type == "function" && strings.HasPrefix(def.Name, "New") {
|
|
continue
|
|
}
|
|
if def.Type == "type" && (strings.HasSuffix(def.Name, "Config") || strings.HasSuffix(def.Name, "Options")) {
|
|
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
|
|
PackageName string
|
|
Exported bool
|
|
Signature string
|
|
Underlying string
|
|
}
|