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 if err := cmd.Run(); err != nil { return nil, fmt.Errorf("failed to run test coverage: %w", err) } 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 if _, err := fmt.Sscanf(countStr, "%d", &count); err != nil { continue } 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 } if _, err := fmt.Sscanf(parts[0], "%d", &start); err != nil { return 0, 0 } if _, err := fmt.Sscanf(parts[1], "%d", &end); err != nil { return 0, 0 } 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 if _, err := fmt.Sscanf(countStr, "%d", &count); err != nil { continue } 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 } if _, err := fmt.Sscanf(parts[0], "%d", &start); err != nil { return 0, 0 } if _, err := fmt.Sscanf(parts[1], "%d", &end); err != nil { return 0, 0 } 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 }