mirror of
https://github.com/Dvorinka/Devour.git
synced 2026-06-04 12:33:04 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
// ComplexityDetector detects complexity issues in source code
|
||||
type ComplexityDetector struct {
|
||||
*quality.BaseDetector
|
||||
signals []ComplexitySignal
|
||||
}
|
||||
|
||||
// ComplexitySignal represents a complexity pattern to detect
|
||||
type ComplexitySignal struct {
|
||||
Name string
|
||||
Pattern *regexp.Regexp
|
||||
Weight int
|
||||
Threshold int
|
||||
Compute func(content string, lines []string) (int, string)
|
||||
}
|
||||
|
||||
// NewComplexityDetector creates a new complexity detector
|
||||
func NewComplexityDetector(finder quality.FileFinder) *ComplexityDetector {
|
||||
detector := &ComplexityDetector{
|
||||
BaseDetector: quality.NewBaseDetector("complexity", quality.SeverityT2, finder),
|
||||
signals: []ComplexitySignal{
|
||||
{
|
||||
Name: "nested if statements",
|
||||
Pattern: regexp.MustCompile(`^\s*if\s+.*\{\s*$`),
|
||||
Weight: 2,
|
||||
Threshold: 3,
|
||||
},
|
||||
{
|
||||
Name: "nested for loops",
|
||||
Pattern: regexp.MustCompile(`^\s*for\s+.*\{\s*$`),
|
||||
Weight: 3,
|
||||
Threshold: 2,
|
||||
},
|
||||
{
|
||||
Name: "switch statements",
|
||||
Pattern: regexp.MustCompile(`^\s*switch\s+.*\{\s*$`),
|
||||
Weight: 1,
|
||||
Threshold: 5,
|
||||
},
|
||||
{
|
||||
Name: "function calls",
|
||||
Pattern: regexp.MustCompile(`\w+\(`),
|
||||
Weight: 1,
|
||||
Threshold: 20,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Add Go-specific complexity signals
|
||||
detector.addGoSignals()
|
||||
|
||||
return detector
|
||||
}
|
||||
|
||||
// addGoSignals adds Go-specific complexity signals
|
||||
func (d *ComplexityDetector) addGoSignals() {
|
||||
goSignals := []ComplexitySignal{
|
||||
{
|
||||
Name: "goroutines",
|
||||
Pattern: regexp.MustCompile(`go\s+\w+\(`),
|
||||
Weight: 2,
|
||||
Threshold: 3,
|
||||
},
|
||||
{
|
||||
Name: "channels",
|
||||
Pattern: regexp.MustCompile(`make\s*\(\s*chan`),
|
||||
Weight: 2,
|
||||
Threshold: 3,
|
||||
},
|
||||
{
|
||||
Name: "select statements",
|
||||
Pattern: regexp.MustCompile(`^\s*select\s*\{`),
|
||||
Weight: 3,
|
||||
Threshold: 2,
|
||||
},
|
||||
{
|
||||
Name: "defer statements",
|
||||
Pattern: regexp.MustCompile(`^\s*defer\s+`),
|
||||
Weight: 1,
|
||||
Threshold: 5,
|
||||
},
|
||||
}
|
||||
|
||||
d.signals = append(d.signals, goSignals...)
|
||||
}
|
||||
|
||||
// Name returns the detector name
|
||||
func (d *ComplexityDetector) Name() string {
|
||||
return "complexity"
|
||||
}
|
||||
|
||||
// Severity returns the default severity
|
||||
func (d *ComplexityDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT2
|
||||
}
|
||||
|
||||
// Detect runs complexity detection on the given path
|
||||
func (d *ComplexityDetector) 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)
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
|
||||
for _, file := range files {
|
||||
if quality.ShouldExclude(file, config.Exclude) {
|
||||
continue
|
||||
}
|
||||
|
||||
fileFindings, err := d.analyzeFile(file, config)
|
||||
if err != nil {
|
||||
log.Printf("Failed to analyze file %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
|
||||
findings = append(findings, fileFindings...)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
// analyzeFile analyzes a single file for complexity issues
|
||||
func (d *ComplexityDetector) analyzeFile(filePath string, config *quality.Config) ([]quality.Finding, error) {
|
||||
content, err := filepath.Abs(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Read file content
|
||||
fileContent, err := os.ReadFile(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentStr := string(fileContent)
|
||||
lines := strings.Split(contentStr, "\n")
|
||||
loc := len(lines)
|
||||
|
||||
if loc < config.MinLOC {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var findings []quality.Finding
|
||||
score := 0
|
||||
var signals []string
|
||||
|
||||
// Check each complexity signal
|
||||
for _, signal := range d.signals {
|
||||
var count int
|
||||
var label string
|
||||
|
||||
if signal.Compute != nil {
|
||||
c, l := signal.Compute(contentStr, lines)
|
||||
if c > 0 {
|
||||
count = c
|
||||
label = l
|
||||
}
|
||||
} else if signal.Pattern != nil {
|
||||
matches := signal.Pattern.FindAllString(contentStr, -1)
|
||||
count = len(matches)
|
||||
if count > signal.Threshold {
|
||||
label = fmt.Sprintf("%d %s", count, signal.Name)
|
||||
}
|
||||
}
|
||||
|
||||
if count > signal.Threshold {
|
||||
signals = append(signals, label)
|
||||
excess := count - signal.Threshold
|
||||
if signal.Threshold == 0 {
|
||||
excess = count
|
||||
}
|
||||
score += excess * signal.Weight
|
||||
}
|
||||
}
|
||||
|
||||
// Create finding if score exceeds threshold
|
||||
if score >= config.Threshold && len(signals) > 0 {
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("complexity-%s-%d", filepath.Base(filePath), score),
|
||||
Type: "complexity",
|
||||
Title: "High complexity detected",
|
||||
Description: fmt.Sprintf("File has complexity score of %d with signals: %s", score, strings.Join(signals, ", ")),
|
||||
File: filePath,
|
||||
Line: 1,
|
||||
Severity: d.Severity(),
|
||||
Score: score,
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"loc": strconv.Itoa(loc),
|
||||
"signals": strings.Join(signals, ";"),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package detectors
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/yourorg/devour/internal/quality"
|
||||
)
|
||||
|
||||
// DuplicationDetector detects duplicate and near-duplicate code
|
||||
type DuplicationDetector struct {
|
||||
*quality.BaseDetector
|
||||
similarityThreshold float64
|
||||
}
|
||||
|
||||
// DuplicateCluster represents a cluster of similar functions
|
||||
type DuplicateCluster struct {
|
||||
Functions []quality.FunctionInfo `json:"functions"`
|
||||
Similarity float64 `json:"similarity"`
|
||||
Representative string `json:"representative"`
|
||||
}
|
||||
|
||||
// NewDuplicationDetector creates a new duplication detector
|
||||
func NewDuplicationDetector(finder quality.FileFinder) *DuplicationDetector {
|
||||
return &DuplicationDetector{
|
||||
BaseDetector: quality.NewBaseDetector("duplication", quality.SeverityT3, finder),
|
||||
similarityThreshold: 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
// Name returns the detector name
|
||||
func (d *DuplicationDetector) Name() string {
|
||||
return "duplication"
|
||||
}
|
||||
|
||||
// Severity returns the default severity
|
||||
func (d *DuplicationDetector) Severity() quality.Severity {
|
||||
return quality.SeverityT3
|
||||
}
|
||||
|
||||
// Detect runs duplication detection on the given path
|
||||
func (d *DuplicationDetector) 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)
|
||||
}
|
||||
|
||||
// Extract functions from all files
|
||||
var allFunctions []quality.FunctionInfo
|
||||
for _, file := range files {
|
||||
if quality.ShouldExclude(file, config.Exclude) {
|
||||
continue
|
||||
}
|
||||
|
||||
functions, err := d.extractFunctions(file)
|
||||
if err != nil {
|
||||
log.Printf("Failed to extract functions from %s: %v", file, err)
|
||||
continue
|
||||
}
|
||||
|
||||
allFunctions = append(allFunctions, functions...)
|
||||
}
|
||||
|
||||
// Find duplicates
|
||||
clusters := d.findDuplicates(allFunctions)
|
||||
|
||||
// Convert clusters to findings
|
||||
var findings []quality.Finding
|
||||
for i, cluster := range clusters {
|
||||
if len(cluster.Functions) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
finding := quality.Finding{
|
||||
ID: fmt.Sprintf("duplication-cluster-%d", i),
|
||||
Type: "duplication",
|
||||
Title: "Code duplication detected",
|
||||
Description: fmt.Sprintf("Found %d similar functions with %.2f similarity",
|
||||
len(cluster.Functions), cluster.Similarity),
|
||||
File: cluster.Functions[0].File,
|
||||
Line: cluster.Functions[0].Line,
|
||||
Severity: d.Severity(),
|
||||
Score: len(cluster.Functions) * 2, // Score based on cluster size
|
||||
Status: quality.StatusOpen,
|
||||
Metadata: map[string]string{
|
||||
"cluster_size": fmt.Sprintf("%d", len(cluster.Functions)),
|
||||
"similarity": fmt.Sprintf("%.2f", cluster.Similarity),
|
||||
"functions": d.formatFunctionList(cluster.Functions),
|
||||
},
|
||||
}
|
||||
findings = append(findings, finding)
|
||||
}
|
||||
|
||||
return findings, nil
|
||||
}
|
||||
|
||||
// extractFunctions extracts functions from a source file
|
||||
func (d *DuplicationDetector) extractFunctions(filePath string) ([]quality.FunctionInfo, error) {
|
||||
content, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
contentStr := string(content)
|
||||
lines := strings.Split(contentStr, "\n")
|
||||
|
||||
var functions []quality.FunctionInfo
|
||||
|
||||
// Simple function extraction for Go (can be enhanced with AST parsing)
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "func ") {
|
||||
funcInfo := d.parseFunctionLine(trimmed, filePath, i+1, contentStr)
|
||||
if funcInfo != nil {
|
||||
functions = append(functions, *funcInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return functions, nil
|
||||
}
|
||||
|
||||
// parseFunctionLine parses a function declaration line
|
||||
func (d *DuplicationDetector) parseFunctionLine(line, filePath string, lineNum int, content string) *quality.FunctionInfo {
|
||||
// Extract function name
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) < 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
funcName := parts[1]
|
||||
// Remove parentheses and receiver if present
|
||||
if idx := strings.Index(funcName, "("); idx != -1 {
|
||||
funcName = funcName[:idx]
|
||||
}
|
||||
|
||||
// Find function body
|
||||
lines := strings.Split(content, "\n")
|
||||
startLine := lineNum - 1
|
||||
endLine := d.findFunctionEnd(lines, startLine)
|
||||
|
||||
if endLine <= startLine {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Extract function body
|
||||
bodyLines := lines[startLine:endLine]
|
||||
body := strings.Join(bodyLines, "\n")
|
||||
loc := endLine - startLine
|
||||
|
||||
// Create normalized version for comparison
|
||||
normalized := d.normalizeFunction(body)
|
||||
bodyHash := d.hashFunction(normalized)
|
||||
|
||||
return &quality.FunctionInfo{
|
||||
Name: funcName,
|
||||
File: filePath,
|
||||
Line: lineNum,
|
||||
EndLine: endLine,
|
||||
LOC: loc,
|
||||
Body: body,
|
||||
Normalized: normalized,
|
||||
BodyHash: bodyHash,
|
||||
}
|
||||
}
|
||||
|
||||
// findFunctionEnd finds the end line of a function
|
||||
func (d *DuplicationDetector) findFunctionEnd(lines []string, startLine int) int {
|
||||
if startLine >= len(lines) {
|
||||
return startLine
|
||||
}
|
||||
|
||||
braceCount := 0
|
||||
for i := startLine; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
braceCount += strings.Count(line, "{")
|
||||
braceCount += strings.Count(line, "}")
|
||||
|
||||
if braceCount == 0 && i > startLine {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return len(lines)
|
||||
}
|
||||
|
||||
// normalizeFunction normalizes a function for comparison
|
||||
func (d *DuplicationDetector) normalizeFunction(body string) string {
|
||||
// Remove comments
|
||||
body = regexp.MustCompile(`//.*`).ReplaceAllString(body, "")
|
||||
body = regexp.MustCompile(`/\*[\s\S]*?\*/`).ReplaceAllString(body, "")
|
||||
|
||||
// Normalize whitespace
|
||||
body = regexp.MustCompile(`\s+`).ReplaceAllString(body, " ")
|
||||
body = strings.TrimSpace(body)
|
||||
|
||||
// Normalize variable names (simple approach)
|
||||
body = regexp.MustCompile(`\b[a-z][a-zA-Z0-9]*\b`).ReplaceAllString(body, "VAR")
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
// hashFunction creates a hash of the normalized function
|
||||
func (d *DuplicationDetector) hashFunction(normalized string) string {
|
||||
hash := sha256.Sum256([]byte(normalized))
|
||||
return fmt.Sprintf("%x", hash)
|
||||
}
|
||||
|
||||
// findDuplicates finds duplicate functions using similarity analysis
|
||||
func (d *DuplicationDetector) findDuplicates(functions []quality.FunctionInfo) []DuplicateCluster {
|
||||
var clusters []DuplicateCluster
|
||||
|
||||
// Group by exact hash first
|
||||
hashGroups := make(map[string][]quality.FunctionInfo)
|
||||
for _, fn := range functions {
|
||||
hashGroups[fn.BodyHash] = append(hashGroups[fn.BodyHash], fn)
|
||||
}
|
||||
|
||||
// Create clusters from exact duplicates
|
||||
for _, group := range hashGroups {
|
||||
if len(group) >= 2 {
|
||||
cluster := DuplicateCluster{
|
||||
Functions: group,
|
||||
Similarity: 1.0,
|
||||
Representative: group[0].Name,
|
||||
}
|
||||
clusters = append(clusters, cluster)
|
||||
}
|
||||
}
|
||||
|
||||
// Find near-duplicates using similarity
|
||||
processed := make(map[int]bool)
|
||||
for i, fn1 := range functions {
|
||||
if processed[i] {
|
||||
continue
|
||||
}
|
||||
|
||||
var similar []quality.FunctionInfo
|
||||
similar = append(similar, fn1)
|
||||
|
||||
for j, fn2 := range functions {
|
||||
if i == j || processed[j] {
|
||||
continue
|
||||
}
|
||||
|
||||
similarity := d.calculateSimilarity(fn1.Normalized, fn2.Normalized)
|
||||
if similarity >= d.similarityThreshold {
|
||||
similar = append(similar, fn2)
|
||||
processed[j] = true
|
||||
}
|
||||
}
|
||||
|
||||
if len(similar) >= 2 {
|
||||
cluster := DuplicateCluster{
|
||||
Functions: similar,
|
||||
Similarity: d.similarityThreshold,
|
||||
Representative: similar[0].Name,
|
||||
}
|
||||
clusters = append(clusters, cluster)
|
||||
}
|
||||
|
||||
processed[i] = true
|
||||
}
|
||||
|
||||
return clusters
|
||||
}
|
||||
|
||||
// calculateSimilarity calculates similarity between two strings
|
||||
func (d *DuplicationDetector) calculateSimilarity(s1, s2 string) float64 {
|
||||
if s1 == s2 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// Simple Levenshtein distance-based similarity
|
||||
distance := d.levenshteinDistance(s1, s2)
|
||||
maxLen := max(len(s1), len(s2))
|
||||
if maxLen == 0 {
|
||||
return 1.0
|
||||
}
|
||||
|
||||
return 1.0 - float64(distance)/float64(maxLen)
|
||||
}
|
||||
|
||||
// levenshteinDistance calculates the Levenshtein distance between two strings
|
||||
func (d *DuplicationDetector) levenshteinDistance(s1, s2 string) int {
|
||||
m, n := len(s1), len(s2)
|
||||
if m < n {
|
||||
s1, s2 = s2, s1
|
||||
m, n = n, m
|
||||
}
|
||||
|
||||
if n == 0 {
|
||||
return m
|
||||
}
|
||||
|
||||
prev := make([]int, n+1)
|
||||
for i := range prev {
|
||||
prev[i] = i
|
||||
}
|
||||
|
||||
for i := 1; i <= m; i++ {
|
||||
current := make([]int, n+1)
|
||||
current[0] = i
|
||||
|
||||
for j := 1; j <= n; j++ {
|
||||
cost := 0
|
||||
if s1[i-1] != s2[j-1] {
|
||||
cost = 1
|
||||
}
|
||||
|
||||
current[j] = min(
|
||||
prev[j]+1, // deletion
|
||||
current[j-1]+1, // insertion
|
||||
prev[j-1]+cost, // substitution
|
||||
)
|
||||
}
|
||||
|
||||
prev = current
|
||||
}
|
||||
|
||||
return prev[n]
|
||||
}
|
||||
|
||||
// formatFunctionList formats a list of functions for metadata
|
||||
func (d *DuplicationDetector) formatFunctionList(functions []quality.FunctionInfo) string {
|
||||
var names []string
|
||||
for _, fn := range functions {
|
||||
names = append(names, fmt.Sprintf("%s:%d", fn.Name, fn.Line))
|
||||
}
|
||||
return strings.Join(names, ",")
|
||||
}
|
||||
|
||||
// min returns the minimum of three integers
|
||||
func min(a, b, c int) int {
|
||||
if a < b {
|
||||
if a < c {
|
||||
return a
|
||||
}
|
||||
return c
|
||||
}
|
||||
if b < c {
|
||||
return b
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// max returns the maximum of two integers
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
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, ";"),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user