Files
Devour/internal/quality/plugins/go/analyzers/deadcode.go
T
Tomas Dvorak 898a3c303f update
2026-02-24 10:33:59 +01:00

369 lines
8.5 KiB
Go

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) shouldSkipExport(name, objType string) bool {
// Skip common API surface exports that might be used externally
skipPatterns := []string{
"Version", "License", "APIKey", "Config", "Options",
"Client", "Server", "Handler", "Service", "Manager",
"Store", "Cache", "Index", "Search", "Query",
}
// Skip type definitions and constants
if strings.Contains(objType, "type") ||
strings.Contains(objType, "const") ||
strings.Contains(objType, "var") {
return true
}
// Skip common naming patterns
for _, pattern := range skipPatterns {
if name == pattern {
return true
}
}
return false
}
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
}
// Skip unexported objects - they're internal
if !obj.Exported() {
continue
}
key := obj.Pkg().Path() + "." + obj.Name()
if !used[key] {
// Skip certain types of exports that are commonly legitimate
if d.shouldSkipExport(obj.Name(), obj.Type().String()) {
continue
}
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 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 {
name = inferImportName(pkgPath)
}
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 {
name = inferImportName(pkgPath)
}
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
}
func inferImportName(pkgPath string) string {
parts := strings.Split(pkgPath, "/")
if len(parts) == 0 {
return pkgPath
}
last := parts[len(parts)-1]
if isSemverSegment(last) && len(parts) >= 2 {
last = parts[len(parts)-2]
}
if idx := strings.Index(last, ".v"); idx > 0 && isDigits(last[idx+2:]) {
last = last[:idx]
}
return last
}
func isSemverSegment(segment string) bool {
if len(segment) < 2 || segment[0] != 'v' {
return false
}
return isDigits(segment[1:])
}
func isDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return true
}
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)
}
}
}
recStack[node] = false
}
for node := range graph {
if !visited[node] {
dfs(node, []string{})
}
}
return cycles
}