first commit

This commit is contained in:
Tomas Dvorak
2026-02-22 10:42:17 +01:00
commit 55885a0e8f
239 changed files with 103690 additions and 0 deletions
@@ -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
}
+363
View File
@@ -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())
}