mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-04 04:23:02 +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
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package fixers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"github.com/yourorg/devour/internal/quality/plugins"
|
||||
)
|
||||
|
||||
type DeadCodeFixer struct{}
|
||||
|
||||
func NewDeadCodeFixer() *DeadCodeFixer {
|
||||
return &DeadCodeFixer{}
|
||||
}
|
||||
|
||||
func (f *DeadCodeFixer) Name() string {
|
||||
return "dead_code"
|
||||
}
|
||||
|
||||
func (f *DeadCodeFixer) Description() string {
|
||||
return "Comments out or removes unused exported functions/types"
|
||||
}
|
||||
|
||||
func (f *DeadCodeFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "dead_code" && finding.Severity == quality.SeverityT1
|
||||
}
|
||||
|
||||
func (f *DeadCodeFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
name := finding.Metadata["name"]
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("no function/type name in metadata")
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, finding.File, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would comment out unused '%s' in %s", name, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var targetDecl ast.Decl
|
||||
for _, decl := range node.Decls {
|
||||
switch d := decl.(type) {
|
||||
case *ast.FuncDecl:
|
||||
if d.Name.Name == name {
|
||||
targetDecl = d
|
||||
}
|
||||
case *ast.GenDecl:
|
||||
for _, spec := range d.Specs {
|
||||
if ts, ok := spec.(*ast.TypeSpec); ok && ts.Name.Name == name {
|
||||
targetDecl = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if targetDecl != nil {
|
||||
comment := &ast.CommentGroup{
|
||||
List: []*ast.Comment{
|
||||
{Text: "// DEPRECATED: This code is unused and should be removed"},
|
||||
},
|
||||
}
|
||||
|
||||
if targetDecl.(*ast.FuncDecl) != nil {
|
||||
targetDecl.(*ast.FuncDecl).Doc = comment
|
||||
} else if targetDecl.(*ast.GenDecl) != nil {
|
||||
targetDecl.(*ast.GenDecl).Doc = comment
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if targetDecl == nil {
|
||||
return &plugins.FixResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Could not find '%s' in file", name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if err := format.Node(&output, fset, node); err != nil {
|
||||
return nil, fmt.Errorf("format error: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(output.String()), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Marked '%s' as deprecated in %s", name, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type ComplexityHintFixer struct{}
|
||||
|
||||
func NewComplexityHintFixer() *ComplexityHintFixer {
|
||||
return &ComplexityHintFixer{}
|
||||
}
|
||||
|
||||
func (f *ComplexityHintFixer) Name() string {
|
||||
return "complexity_hint"
|
||||
}
|
||||
|
||||
func (f *ComplexityHintFixer) Description() string {
|
||||
return "Adds complexity warning comments to complex functions"
|
||||
}
|
||||
|
||||
func (f *ComplexityHintFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "complexity" || finding.Type == "complexity_ast"
|
||||
}
|
||||
|
||||
func (f *ComplexityHintFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
funcName := finding.Metadata["function"]
|
||||
if funcName == "" {
|
||||
return nil, fmt.Errorf("no function name in metadata")
|
||||
}
|
||||
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, finding.File, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would add complexity warning to '%s' in %s", funcName, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == funcName {
|
||||
complexity := finding.Metadata["complexity"]
|
||||
|
||||
warning := fmt.Sprintf("// FIXME: High complexity (%s). Consider breaking into smaller functions.", complexity)
|
||||
|
||||
comment := &ast.CommentGroup{
|
||||
List: []*ast.Comment{
|
||||
{Text: warning},
|
||||
},
|
||||
}
|
||||
fn.Doc = comment
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if err := format.Node(&output, fset, node); err != nil {
|
||||
return nil, fmt.Errorf("format error: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(output.String()), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Added complexity warning to '%s' in %s", funcName, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type IoutilFixer struct{}
|
||||
|
||||
func NewIoutilFixer() *IoutilFixer {
|
||||
return &IoutilFixer{}
|
||||
}
|
||||
|
||||
func (f *IoutilFixer) Name() string {
|
||||
return "ioutil"
|
||||
}
|
||||
|
||||
func (f *IoutilFixer) Description() string {
|
||||
return "Replaces deprecated io/ioutil with modern equivalents"
|
||||
}
|
||||
|
||||
func (f *IoutilFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "deprecated" && strings.Contains(finding.Title, "io/ioutil")
|
||||
}
|
||||
|
||||
func (f *IoutilFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
data, err := os.ReadFile(finding.File)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read error: %w", err)
|
||||
}
|
||||
|
||||
content := string(data)
|
||||
|
||||
replacements := map[string]string{
|
||||
`"io/ioutil"`: "",
|
||||
`ioutil.ReadFile`: `os.ReadFile`,
|
||||
`ioutil.WriteFile`: `os.WriteFile`,
|
||||
`ioutil.ReadDir`: `os.ReadDir`,
|
||||
`ioutil.TempDir`: `os.MkdirTemp`,
|
||||
`ioutil.TempFile`: `os.CreateTemp`,
|
||||
`ioutil.NopCloser`: `io.NopCloser`,
|
||||
`ioutil.ReadAll`: `io.ReadAll`,
|
||||
`ioutil.Discard`: `io.Discard`,
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would replace io/ioutil usage in %s", finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
for old, new := range replacements {
|
||||
content = strings.ReplaceAll(content, old, new)
|
||||
}
|
||||
|
||||
if strings.Contains(content, "os.ReadFile") || strings.Contains(content, "os.WriteFile") ||
|
||||
strings.Contains(content, "os.ReadDir") || strings.Contains(content, "os.MkdirTemp") ||
|
||||
strings.Contains(content, "os.CreateTemp") {
|
||||
if !strings.Contains(content, `"os"`) {
|
||||
content = strings.Replace(content, "package ", "import \"os\"\n\npackage ", 1)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(content, "io.NopCloser") || strings.Contains(content, "io.ReadAll") ||
|
||||
strings.Contains(content, "io.Discard") {
|
||||
if !strings.Contains(content, `"io"`) {
|
||||
content = strings.Replace(content, "package ", "import \"io\"\n\npackage ", 1)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(content), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Replaced io/ioutil in %s", finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type DocCommentFixer struct{}
|
||||
|
||||
func NewDocCommentFixer() *DocCommentFixer {
|
||||
return &DocCommentFixer{}
|
||||
}
|
||||
|
||||
func (f *DocCommentFixer) Name() string {
|
||||
return "doc_comment"
|
||||
}
|
||||
|
||||
func (f *DocCommentFixer) Description() string {
|
||||
return "Adds TODO comments for missing documentation on exported items"
|
||||
}
|
||||
|
||||
func (f *DocCommentFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "naming" || finding.Type == "god_struct" || finding.Type == "god_function"
|
||||
}
|
||||
|
||||
func (f *DocCommentFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
return &plugins.FixResult{
|
||||
Success: false,
|
||||
Message: "Documentation fixer requires manual intervention",
|
||||
Warnings: []string{
|
||||
fmt.Sprintf("Add documentation for: %s", finding.Title),
|
||||
fmt.Sprintf("Location: %s:%d", finding.File, finding.Line),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package fixers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/format"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"github.com/yourorg/devour/internal/quality/plugins"
|
||||
)
|
||||
|
||||
type UnusedImportFixer struct{}
|
||||
|
||||
func NewUnusedImportFixer() *UnusedImportFixer {
|
||||
return &UnusedImportFixer{}
|
||||
}
|
||||
|
||||
func (f *UnusedImportFixer) Name() string {
|
||||
return "unused_import"
|
||||
}
|
||||
|
||||
func (f *UnusedImportFixer) Description() string {
|
||||
return "Removes unused import statements"
|
||||
}
|
||||
|
||||
func (f *UnusedImportFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "unused_import"
|
||||
}
|
||||
|
||||
func (f *UnusedImportFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, finding.File, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
importToRemove := finding.Metadata["import_path"]
|
||||
if importToRemove == "" {
|
||||
return nil, fmt.Errorf("no import_path in finding metadata")
|
||||
}
|
||||
|
||||
var newImports []*ast.ImportSpec
|
||||
for _, imp := range node.Imports {
|
||||
path := strings.Trim(imp.Path.Value, `"`)
|
||||
if path != importToRemove {
|
||||
newImports = append(newImports, imp)
|
||||
}
|
||||
}
|
||||
|
||||
node.Imports = newImports
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would remove import '%s' from %s", importToRemove, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if err := format.Node(&output, fset, node); err != nil {
|
||||
return nil, fmt.Errorf("format error: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(output.String()), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Removed unused import '%s' from %s", importToRemove, finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
type FormattingFixer struct{}
|
||||
|
||||
func NewFormattingFixer() *FormattingFixer {
|
||||
return &FormattingFixer{}
|
||||
}
|
||||
|
||||
func (f *FormattingFixer) Name() string {
|
||||
return "format"
|
||||
}
|
||||
|
||||
func (f *FormattingFixer) Description() string {
|
||||
return "Formats Go source files using gofmt style"
|
||||
}
|
||||
|
||||
func (f *FormattingFixer) CanFix(finding quality.Finding) bool {
|
||||
return finding.Type == "formatting" || finding.Type == "style"
|
||||
}
|
||||
|
||||
func (f *FormattingFixer) Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*plugins.FixResult, error) {
|
||||
fset := token.NewFileSet()
|
||||
node, err := parser.ParseFile(fset, finding.File, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Would format %s", finding.File),
|
||||
}, nil
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
if err := format.Node(&output, fset, node); err != nil {
|
||||
return nil, fmt.Errorf("format error: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(finding.File, []byte(output.String()), 0644); err != nil {
|
||||
return nil, fmt.Errorf("write error: %w", err)
|
||||
}
|
||||
|
||||
return &plugins.FixResult{
|
||||
Success: true,
|
||||
Message: fmt.Sprintf("Formatted %s", finding.File),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
package goplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"go/types"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
"github.com/yourorg/devour/internal/quality/plugins"
|
||||
"github.com/yourorg/devour/internal/quality/plugins/go/analyzers"
|
||||
"github.com/yourorg/devour/internal/quality/plugins/go/fixers"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
type GoPlugin struct{}
|
||||
|
||||
func New() *GoPlugin {
|
||||
return &GoPlugin{}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) Name() string {
|
||||
return "go"
|
||||
}
|
||||
|
||||
func (p *GoPlugin) Extensions() []string {
|
||||
return []string{".go"}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) MarkerFiles() []string {
|
||||
return []string{"go.mod", "go.sum"}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) DefaultSrcDir() string {
|
||||
return "."
|
||||
}
|
||||
|
||||
func (p *GoPlugin) CreateDetectors(finder quality.FileFinder) []quality.Detector {
|
||||
return []quality.Detector{
|
||||
analyzers.NewDeadCodeDetector(finder),
|
||||
analyzers.NewEnhancedDeadCodeDetector(finder),
|
||||
analyzers.NewUnusedImportDetector(finder),
|
||||
analyzers.NewCycleDetector(finder),
|
||||
analyzers.NewSecurityDetector(finder),
|
||||
analyzers.NewComplexityASTDetector(finder),
|
||||
analyzers.NewLargeFileDetector(finder),
|
||||
analyzers.NewGodStructDetector(finder),
|
||||
analyzers.NewGodFunctionDetector(finder),
|
||||
analyzers.NewDebugLogDetector(finder),
|
||||
analyzers.NewSingleUseDetector(finder),
|
||||
analyzers.NewCouplingDetector(finder),
|
||||
analyzers.NewTestCoverageDetector(finder),
|
||||
analyzers.NewUntestedFuncDetector(finder),
|
||||
analyzers.NewOrphanedFileDetector(finder),
|
||||
analyzers.NewDeprecatedUsageDetector(finder),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) CreateFixers() []plugins.Fixer {
|
||||
return []plugins.Fixer{
|
||||
fixers.NewUnusedImportFixer(),
|
||||
fixers.NewFormattingFixer(),
|
||||
fixers.NewDeadCodeFixer(),
|
||||
fixers.NewComplexityHintFixer(),
|
||||
fixers.NewIoutilFixer(),
|
||||
fixers.NewDocCommentFixer(),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *GoPlugin) AnalyzeFile(ctx context.Context, path string, config *quality.Config) (*plugins.FileAnalysis, error) {
|
||||
fset := token.NewFileSet()
|
||||
|
||||
node, err := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.AllErrors)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse error: %w", err)
|
||||
}
|
||||
|
||||
analysis := &plugins.FileAnalysis{
|
||||
Path: path,
|
||||
Package: node.Name.Name,
|
||||
LOC: countLOC(path),
|
||||
}
|
||||
|
||||
analysis.Imports = p.extractImports(node, fset)
|
||||
analysis.Functions = p.extractFunctions(node, path, fset)
|
||||
analysis.Types = p.extractTypes(node, path, fset)
|
||||
analysis.Variables = p.extractVariables(node, path, fset)
|
||||
analysis.Comments = p.extractComments(node, path, fset)
|
||||
|
||||
return analysis, nil
|
||||
}
|
||||
|
||||
func (p *GoPlugin) BuildDependencyGraph(ctx context.Context, rootPath string) (*plugins.DependencyGraph, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedImports | packages.NeedFiles,
|
||||
Dir: rootPath,
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, "./...")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load packages: %w", err)
|
||||
}
|
||||
|
||||
graph := &plugins.DependencyGraph{
|
||||
Packages: make(map[string]*plugins.PackageNode),
|
||||
Edges: []plugins.DependencyEdge{},
|
||||
}
|
||||
|
||||
for _, pkg := range pkgs {
|
||||
node := &plugins.PackageNode{
|
||||
Name: pkg.Name,
|
||||
Path: pkg.PkgPath,
|
||||
Files: pkg.GoFiles,
|
||||
IsLocal: true,
|
||||
}
|
||||
|
||||
for _, imp := range pkg.Imports {
|
||||
node.Imports = append(node.Imports, imp.PkgPath)
|
||||
graph.Edges = append(graph.Edges, plugins.DependencyEdge{
|
||||
From: pkg.PkgPath,
|
||||
To: imp.PkgPath,
|
||||
Type: plugins.EdgeTypeImport,
|
||||
})
|
||||
}
|
||||
|
||||
graph.Packages[pkg.PkgPath] = node
|
||||
}
|
||||
|
||||
graph.Cycles = p.detectCycles(graph)
|
||||
|
||||
return graph, nil
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractImports(node *ast.File, fset *token.FileSet) []plugins.ImportInfo {
|
||||
var imports []plugins.ImportInfo
|
||||
for _, imp := range node.Imports {
|
||||
info := plugins.ImportInfo{
|
||||
Path: strings.Trim(imp.Path.Value, `"`),
|
||||
Line: fset.Position(imp.Pos()).Line,
|
||||
}
|
||||
if imp.Name != nil {
|
||||
info.Alias = imp.Name.Name
|
||||
}
|
||||
imports = append(imports, info)
|
||||
}
|
||||
return imports
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractFunctions(node *ast.File, path string, fset *token.FileSet) []quality.FunctionInfo {
|
||||
var functions []quality.FunctionInfo
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
fn, ok := decl.(*ast.FuncDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
info := quality.FunctionInfo{
|
||||
Name: fn.Name.Name,
|
||||
File: path,
|
||||
Line: fset.Position(fn.Pos()).Line,
|
||||
EndLine: fset.Position(fn.End()).Line,
|
||||
}
|
||||
|
||||
info.LOC = info.EndLine - info.Line + 1
|
||||
|
||||
var params []string
|
||||
if fn.Type.Params != nil {
|
||||
for _, field := range fn.Type.Params.List {
|
||||
for _, name := range field.Names {
|
||||
params = append(params, name.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
info.Params = params
|
||||
|
||||
if fn.Type.Results != nil {
|
||||
info.ReturnAnnotation = fmt.Sprintf("%v", fn.Type.Results)
|
||||
}
|
||||
|
||||
functions = append(functions, info)
|
||||
}
|
||||
|
||||
return functions
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractTypes(node *ast.File, path string, fset *token.FileSet) []plugins.TypeInfo {
|
||||
var typeInfos []plugins.TypeInfo
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || gen.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
typeSpec, ok := spec.(*ast.TypeSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
info := plugins.TypeInfo{
|
||||
Name: typeSpec.Name.Name,
|
||||
File: path,
|
||||
Line: fset.Position(typeSpec.Pos()).Line,
|
||||
IsExported: ast.IsExported(typeSpec.Name.Name),
|
||||
}
|
||||
|
||||
switch t := typeSpec.Type.(type) {
|
||||
case *ast.StructType:
|
||||
info.Underlying = "struct"
|
||||
case *ast.InterfaceType:
|
||||
info.Underlying = "interface"
|
||||
default:
|
||||
info.Underlying = fmt.Sprintf("%T", t)
|
||||
}
|
||||
|
||||
typeInfos = append(typeInfos, info)
|
||||
}
|
||||
}
|
||||
|
||||
return typeInfos
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractVariables(node *ast.File, path string, fset *token.FileSet) []plugins.VariableInfo {
|
||||
var variables []plugins.VariableInfo
|
||||
|
||||
for _, decl := range node.Decls {
|
||||
gen, ok := decl.(*ast.GenDecl)
|
||||
if !ok || (gen.Tok != token.VAR && gen.Tok != token.CONST) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, spec := range gen.Specs {
|
||||
valueSpec, ok := spec.(*ast.ValueSpec)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, name := range valueSpec.Names {
|
||||
info := plugins.VariableInfo{
|
||||
Name: name.Name,
|
||||
File: path,
|
||||
Line: fset.Position(name.Pos()).Line,
|
||||
IsExported: ast.IsExported(name.Name),
|
||||
}
|
||||
|
||||
if valueSpec.Type != nil {
|
||||
info.Type = fmt.Sprintf("%v", valueSpec.Type)
|
||||
}
|
||||
|
||||
variables = append(variables, info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return variables
|
||||
}
|
||||
|
||||
func (p *GoPlugin) extractComments(node *ast.File, path string, fset *token.FileSet) []plugins.CommentInfo {
|
||||
var comments []plugins.CommentInfo
|
||||
|
||||
for _, group := range node.Comments {
|
||||
for _, comment := range group.List {
|
||||
info := plugins.CommentInfo{
|
||||
Text: comment.Text,
|
||||
File: path,
|
||||
Line: fset.Position(comment.Pos()).Line,
|
||||
IsDoc: strings.HasPrefix(comment.Text, "//"),
|
||||
}
|
||||
comments = append(comments, info)
|
||||
}
|
||||
}
|
||||
|
||||
return comments
|
||||
}
|
||||
|
||||
func (p *GoPlugin) detectCycles(graph *plugins.DependencyGraph) [][]string {
|
||||
var cycles [][]string
|
||||
visited := make(map[string]bool)
|
||||
recStack := make(map[string]bool)
|
||||
path := []string{}
|
||||
|
||||
var dfs func(pkg string) bool
|
||||
dfs = func(pkg string) bool {
|
||||
visited[pkg] = true
|
||||
recStack[pkg] = true
|
||||
path = append(path, pkg)
|
||||
|
||||
node, exists := graph.Packages[pkg]
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, imp := range node.Imports {
|
||||
if !visited[imp] {
|
||||
if dfs(imp) {
|
||||
return true
|
||||
}
|
||||
} else if recStack[imp] {
|
||||
cycleStart := -1
|
||||
for i, p := range path {
|
||||
if p == imp {
|
||||
cycleStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if cycleStart >= 0 {
|
||||
cycle := make([]string, len(path)-cycleStart)
|
||||
copy(cycle, path[cycleStart:])
|
||||
cycles = append(cycles, cycle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path = path[:len(path)-1]
|
||||
recStack[pkg] = false
|
||||
return false
|
||||
}
|
||||
|
||||
for pkg := range graph.Packages {
|
||||
if !visited[pkg] {
|
||||
dfs(pkg)
|
||||
}
|
||||
}
|
||||
|
||||
return cycles
|
||||
}
|
||||
|
||||
func (p *GoPlugin) LoadTypesInfo(ctx context.Context, path string) (*types.Info, *token.FileSet, error) {
|
||||
cfg := &packages.Config{
|
||||
Mode: packages.NeedName | packages.NeedTypes | packages.NeedTypesInfo,
|
||||
Dir: filepath.Dir(path),
|
||||
}
|
||||
|
||||
pkgs, err := packages.Load(cfg, filepath.Base(path))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if len(pkgs) == 0 {
|
||||
return nil, nil, fmt.Errorf("no packages found")
|
||||
}
|
||||
|
||||
return pkgs[0].TypesInfo, pkgs[0].Fset, nil
|
||||
}
|
||||
|
||||
func countLOC(path string) int {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return strings.Count(string(data), "\n") + 1
|
||||
}
|
||||
|
||||
func init() {
|
||||
plugins.Register(New())
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"go/ast"
|
||||
"go/token"
|
||||
"go/types"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
// LanguagePlugin defines the interface for language-specific analysis plugins
|
||||
type LanguagePlugin interface {
|
||||
// Name returns the plugin name (e.g., "go", "typescript")
|
||||
Name() string
|
||||
|
||||
// Extensions returns file extensions this plugin handles
|
||||
Extensions() []string
|
||||
|
||||
// MarkerFiles returns files that indicate this language (e.g., "go.mod")
|
||||
MarkerFiles() []string
|
||||
|
||||
// DefaultSrcDir returns the default source directory
|
||||
DefaultSrcDir() string
|
||||
|
||||
// CreateDetectors creates language-specific detectors
|
||||
CreateDetectors(finder quality.FileFinder) []quality.Detector
|
||||
|
||||
// CreateFixers creates language-specific auto-fixers
|
||||
CreateFixers() []Fixer
|
||||
|
||||
// AnalyzeFile performs AST analysis on a single file
|
||||
AnalyzeFile(ctx context.Context, path string, config *quality.Config) (*FileAnalysis, error)
|
||||
|
||||
// BuildDependencyGraph builds the import dependency graph
|
||||
BuildDependencyGraph(ctx context.Context, path string) (*DependencyGraph, error)
|
||||
}
|
||||
|
||||
// FileAnalysis represents the result of analyzing a single file
|
||||
type FileAnalysis struct {
|
||||
Path string
|
||||
Package string
|
||||
Imports []ImportInfo
|
||||
Functions []quality.FunctionInfo
|
||||
Classes []quality.ClassInfo
|
||||
Variables []VariableInfo
|
||||
Types []TypeInfo
|
||||
Comments []CommentInfo
|
||||
LOC int
|
||||
Complexity int
|
||||
Issues []quality.Finding
|
||||
}
|
||||
|
||||
// ImportInfo represents import information
|
||||
type ImportInfo struct {
|
||||
Path string
|
||||
Alias string
|
||||
Line int
|
||||
Used bool
|
||||
Position token.Position
|
||||
}
|
||||
|
||||
// VariableInfo represents variable information
|
||||
type VariableInfo struct {
|
||||
Name string
|
||||
Type string
|
||||
File string
|
||||
Line int
|
||||
IsExported bool
|
||||
IsUsed bool
|
||||
}
|
||||
|
||||
// TypeInfo represents type information
|
||||
type TypeInfo struct {
|
||||
Name string
|
||||
Underlying string
|
||||
Methods []string
|
||||
File string
|
||||
Line int
|
||||
IsExported bool
|
||||
}
|
||||
|
||||
// CommentInfo represents comment information
|
||||
type CommentInfo struct {
|
||||
Text string
|
||||
File string
|
||||
Line int
|
||||
IsDoc bool
|
||||
Attached string // What it's attached to (function name, type name, etc.)
|
||||
}
|
||||
|
||||
// DependencyGraph represents the import dependency graph
|
||||
type DependencyGraph struct {
|
||||
Packages map[string]*PackageNode
|
||||
Edges []DependencyEdge
|
||||
Cycles [][]string
|
||||
}
|
||||
|
||||
// PackageNode represents a node in the dependency graph
|
||||
type PackageNode struct {
|
||||
Name string
|
||||
Path string
|
||||
Imports []string
|
||||
ImportedBy []string
|
||||
Files []string
|
||||
IsLocal bool
|
||||
}
|
||||
|
||||
// DependencyEdge represents an edge in the dependency graph
|
||||
type DependencyEdge struct {
|
||||
From string
|
||||
To string
|
||||
Type EdgeType
|
||||
}
|
||||
|
||||
// EdgeType represents the type of dependency edge
|
||||
type EdgeType string
|
||||
|
||||
const (
|
||||
EdgeTypeImport EdgeType = "import"
|
||||
EdgeTypeEmbed EdgeType = "embed"
|
||||
EdgeTypeInternal EdgeType = "internal"
|
||||
)
|
||||
|
||||
// Fixer defines the interface for auto-fixers
|
||||
type Fixer interface {
|
||||
// Name returns the fixer name
|
||||
Name() string
|
||||
|
||||
// Description returns a human-readable description
|
||||
Description() string
|
||||
|
||||
// CanFix checks if this fixer can fix the given finding
|
||||
CanFix(finding quality.Finding) bool
|
||||
|
||||
// Fix applies the fix and returns the patches
|
||||
Fix(ctx context.Context, finding quality.Finding, dryRun bool) (*FixResult, error)
|
||||
}
|
||||
|
||||
// FixResult represents the result of a fix operation
|
||||
type FixResult struct {
|
||||
Success bool
|
||||
Patches []Patch
|
||||
Message string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// Patch represents a single file patch
|
||||
type Patch struct {
|
||||
File string
|
||||
OldText string
|
||||
NewText string
|
||||
Start int
|
||||
End int
|
||||
}
|
||||
|
||||
// ASTInfo represents AST information for analysis
|
||||
type ASTInfo struct {
|
||||
File *ast.File
|
||||
Fset *token.FileSet
|
||||
Types *types.Info
|
||||
Package *types.Package
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Registry manages language plugin registration
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
plugins map[string]LanguagePlugin
|
||||
}
|
||||
|
||||
// Global registry instance
|
||||
var globalRegistry = &Registry{
|
||||
plugins: make(map[string]LanguagePlugin),
|
||||
}
|
||||
|
||||
// Register registers a language plugin
|
||||
func Register(plugin LanguagePlugin) error {
|
||||
globalRegistry.mu.Lock()
|
||||
defer globalRegistry.mu.Unlock()
|
||||
|
||||
name := plugin.Name()
|
||||
if _, exists := globalRegistry.plugins[name]; exists {
|
||||
return fmt.Errorf("plugin %s already registered", name)
|
||||
}
|
||||
|
||||
globalRegistry.plugins[name] = plugin
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get retrieves a plugin by name
|
||||
func Get(name string) (LanguagePlugin, bool) {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
plugin, ok := globalRegistry.plugins[name]
|
||||
return plugin, ok
|
||||
}
|
||||
|
||||
// All returns all registered plugins
|
||||
func All() []LanguagePlugin {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
plugins := make([]LanguagePlugin, 0, len(globalRegistry.plugins))
|
||||
for _, p := range globalRegistry.plugins {
|
||||
plugins = append(plugins, p)
|
||||
}
|
||||
|
||||
// Sort by name for consistent ordering
|
||||
sort.Slice(plugins, func(i, j int) bool {
|
||||
return plugins[i].Name() < plugins[j].Name()
|
||||
})
|
||||
|
||||
return plugins
|
||||
}
|
||||
|
||||
// Names returns all registered plugin names
|
||||
func Names() []string {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
names := make([]string, 0, len(globalRegistry.plugins))
|
||||
for name := range globalRegistry.plugins {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
|
||||
// DetectLanguage attempts to detect the language from a path
|
||||
func DetectLanguage(path string) string {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
// Check marker files for each plugin
|
||||
for _, plugin := range globalRegistry.plugins {
|
||||
for _, marker := range plugin.MarkerFiles() {
|
||||
// Check if marker file exists
|
||||
if fileExists(path + "/" + marker) {
|
||||
return plugin.Name()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to first registered plugin
|
||||
for name := range globalRegistry.plugins {
|
||||
return name
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetForExtension returns the plugin for a given file extension
|
||||
func GetForExtension(ext string) LanguagePlugin {
|
||||
globalRegistry.mu.RLock()
|
||||
defer globalRegistry.mu.RUnlock()
|
||||
|
||||
for _, plugin := range globalRegistry.plugins {
|
||||
for _, pluginExt := range plugin.Extensions() {
|
||||
if pluginExt == ext {
|
||||
return plugin
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileExists is a simple helper - will be replaced with proper implementation
|
||||
func fileExists(path string) bool {
|
||||
// This will be replaced with actual file existence check
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user