package detectors import ( "context" "fmt" "path/filepath" "strings" "github.com/yourorg/devour/internal/quality" ) // NamingConvention represents a naming convention type NamingConvention string const ( ConventionKebabCase NamingConvention = "kebab-case" ConventionPascalCase NamingConvention = "PascalCase" ConventionCamelCase NamingConvention = "camelCase" ConventionSnakeCase NamingConvention = "snake_case" ConventionFlatLower NamingConvention = "flat_lower" ) // NamingDetector detects naming inconsistencies type NamingDetector struct { *quality.BaseDetector skipNames map[string]bool skipDirs map[string]bool } // NamingAnalysis represents naming analysis for a directory type NamingAnalysis struct { Directory string `json:"directory"` Conventions map[NamingConvention]int `json:"conventions"` TotalFiles int `json:"total_files"` Minority NamingConvention `json:"minority"` MinorityCount int `json:"minority_count"` MinorityPercent float64 `json:"minority_percent"` } // NewNamingDetector creates a new naming detector func NewNamingDetector(finder quality.FileFinder) *NamingDetector { skipNames := map[string]bool{ "README.md": true, "LICENSE": true, "Makefile": true, "Dockerfile": true, "go.mod": true, "go.sum": true, } skipDirs := map[string]bool{ ".git": true, "node_modules": true, "vendor": true, ".vscode": true, ".idea": true, } return &NamingDetector{ BaseDetector: quality.NewBaseDetector("naming", quality.SeverityT2, finder), skipNames: skipNames, skipDirs: skipDirs, } } // Name returns the detector name func (d *NamingDetector) Name() string { return "naming" } // Severity returns the default severity func (d *NamingDetector) Severity() quality.Severity { return quality.SeverityT2 } // Detect runs naming inconsistency detection func (d *NamingDetector) Detect(ctx context.Context, path string, config *quality.Config) ([]quality.Finding, error) { files, err := d.FindFiles(path, config.Language) if err != nil { return nil, fmt.Errorf("failed to find files: %w", err) } // Group files by directory dirFiles := make(map[string][]string) for _, file := range files { if quality.ShouldExclude(file, config.Exclude) { continue } dir := filepath.Dir(file) dirFiles[dir] = append(dirFiles[dir], file) } var findings []quality.Finding // Analyze each directory for dir, files := range dirFiles { analysis := d.analyzeDirectory(dir, files) if d.shouldReport(analysis) { finding := d.createFinding(analysis) findings = append(findings, finding) } } return findings, nil } // analyzeDirectory analyzes naming conventions in a directory func (d *NamingDetector) analyzeDirectory(dir string, files []string) NamingAnalysis { conventions := make(map[NamingConvention]int) totalFiles := 0 for _, file := range files { filename := filepath.Base(file) // Skip certain files if d.skipNames[filename] { continue } // Check if we should skip this directory if d.skipDirs[filepath.Base(dir)] { continue } convention := d.classifyConvention(filename) if convention != "" { conventions[convention]++ totalFiles++ } } // Find minority convention minority, minorityCount, minorityPercent := d.findMinorityConvention(conventions, totalFiles) return NamingAnalysis{ Directory: dir, Conventions: conventions, TotalFiles: totalFiles, Minority: minority, MinorityCount: minorityCount, MinorityPercent: minorityPercent, } } // classifyConvention classifies a filename into a naming convention func (d *NamingDetector) classifyConvention(filename string) NamingConvention { // Remove extension stem := filename if idx := strings.LastIndex(filename, "."); idx != -1 { stem = filename[:idx] } if stem == "" { return "" } // Check each convention if strings.Contains(stem, "-") && stem == strings.ToLower(stem) { return ConventionKebabCase } if len(stem) > 0 && strings.ToUpper(string(stem[0])) == string(stem[0]) && !strings.Contains(stem, "-") { return ConventionPascalCase } if len(stem) > 0 && strings.ToLower(string(stem[0])) == string(stem[0]) && d.hasUpper(stem) && !strings.Contains(stem, "-") { return ConventionCamelCase } if strings.Contains(stem, "_") && stem == strings.ToLower(stem) { return ConventionSnakeCase } if stem == strings.ToLower(stem) && !strings.Contains(stem, "-") { return ConventionFlatLower } return "" } // hasUpper checks if a string contains uppercase letters func (d *NamingDetector) hasUpper(s string) bool { for _, r := range s { if r >= 'A' && r <= 'Z' { return true } } return false } // findMinorityConvention finds the minority naming convention func (d *NamingDetector) findMinorityConvention(conventions map[NamingConvention]int, totalFiles int) (NamingConvention, int, float64) { if len(conventions) < 2 { return "", 0, 0 } var minority NamingConvention minorityCount := 0 minCount := totalFiles for convention, count := range conventions { if count < minCount { minCount = count minorityCount = count minority = convention } } // Check thresholds minorityPercent := float64(minorityCount) / float64(totalFiles) * 100 // Only report if minority has >= 5 files and >= 15% of total if minorityCount >= 5 && minorityPercent >= 15 { return minority, minorityCount, minorityPercent } return "", 0, 0 } // shouldReport determines if the analysis should be reported func (d *NamingDetector) shouldReport(analysis NamingAnalysis) bool { return analysis.Minority != "" && analysis.MinorityCount >= 5 && analysis.MinorityPercent >= 15 } // createFinding creates a finding from analysis func (d *NamingDetector) createFinding(analysis NamingAnalysis) quality.Finding { conventionList := make([]string, 0, len(analysis.Conventions)) for conv, count := range analysis.Conventions { conventionList = append(conventionList, fmt.Sprintf("%s (%d)", conv, count)) } return quality.Finding{ ID: fmt.Sprintf("naming-%s", strings.ReplaceAll(analysis.Directory, "/", "-")), Type: "naming", Title: "Naming inconsistency detected", Description: fmt.Sprintf("Directory '%s' has mixed naming conventions. Minority: %s with %d files (%.1f%%). All conventions: %s", analysis.Directory, analysis.Minority, analysis.MinorityCount, analysis.MinorityPercent, strings.Join(conventionList, ", ")), File: analysis.Directory, Line: 1, Severity: d.Severity(), Score: int(analysis.MinorityPercent), // Score based on percentage Status: quality.StatusOpen, Metadata: map[string]string{ "directory": analysis.Directory, "minority": string(analysis.Minority), "minority_count": fmt.Sprintf("%d", analysis.MinorityCount), "minority_percent": fmt.Sprintf("%.1f", analysis.MinorityPercent), "total_files": fmt.Sprintf("%d", analysis.TotalFiles), "conventions": strings.Join(conventionList, ";"), }, } }