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
|
||||
}
|
||||
Reference in New Issue
Block a user