mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-04 12:33:04 +00:00
369 lines
8.5 KiB
Go
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
|
|
}
|