mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-03 20:13:03 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user