Files
Devour/internal/quality/detectors/naming.go
T
Tomas Dvorak 55885a0e8f first commit
2026-02-22 10:42:17 +01:00

257 lines
7.0 KiB
Go

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, ";"),
},
}
}