Files
Devour/internal/quality/plugins/go/analyzers/advanced.go
T
2026-02-24 12:10:13 +01:00

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
}