using System.Text.RegularExpressions; using MarketAlly.GitCommitEditor.Models; using MarketAlly.GitCommitEditor.Options; using MarketAlly.GitCommitEditor.Resources; namespace MarketAlly.GitCommitEditor.Services; public sealed partial class CommitMessageAnalyzer : ICommitMessageAnalyzer { private readonly CommitMessageRules _rules; // Vague/lazy words that don't describe what actually changed private static readonly string[] VagueWords = [ "updates", "update", "changes", "change", "stuff", "things", "misc", "various", "some", "minor", "edits", "tweaks", "wip", "work in progress", "modifications", "adjustments" ]; // Pattern: "ProjectName updates" or "ProjectName changes" - lazy naming [GeneratedRegex(@"^\w+\s+(updates?|changes?|edits?|tweaks?|modifications?)$", RegexOptions.IgnoreCase)] private static partial Regex LazyProjectNamePattern(); [GeneratedRegex(@"^(?\w+)(?:\((?[^)]+)\))?!?:\s*(?.+)$", RegexOptions.Singleline)] private static partial Regex ConventionalCommitPattern(); [GeneratedRegex(@"#\d+|[A-Z]{2,}-\d+", RegexOptions.IgnoreCase)] private static partial Regex IssueReferencePattern(); [GeneratedRegex(@"\w+\([^)]+\):\s+.+")] private static partial Regex ScopePattern(); public CommitMessageAnalyzer(CommitMessageRules rules) { _rules = rules ?? throw new ArgumentNullException(nameof(rules)); } /// /// Analyze without context - uses default context with 0 files. /// public MessageQualityScore Analyze(string message) => Analyze(message, new CommitContext()); /// /// Analyze with full context about the changes for smarter detection. /// public MessageQualityScore Analyze(string message, CommitContext context) { if (string.IsNullOrWhiteSpace(message)) { return new MessageQualityScore { OverallScore = 0, Issues = [ new QualityIssue { Severity = IssueSeverity.Error, Code = "EMPTY_MESSAGE", Message = Str.Analyzer_MessageEmpty } ] }; } var issues = new List(); var lines = message.Split('\n', StringSplitOptions.None); var subject = lines[0].Trim(); var body = lines.Length > 2 ? string.Join('\n', lines.Skip(2)).Trim() : string.Empty; int score = 100; // Subject length checks if (subject.Length < _rules.MinSubjectLength) { issues.Add(new QualityIssue { Severity = IssueSeverity.Error, Code = "SUBJECT_TOO_SHORT", Message = Str.Analyzer_SubjectTooShort(subject.Length, _rules.MinSubjectLength) }); score -= 25; } if (subject.Length > _rules.MaxSubjectLength) { issues.Add(new QualityIssue { Severity = IssueSeverity.Warning, Code = "SUBJECT_TOO_LONG", Message = Str.Analyzer_SubjectTooLong(subject.Length, _rules.MaxSubjectLength) }); score -= 10; } // Banned phrases check // For conventional commits (type: subject), check the subject part after the colon // For non-conventional commits, check the whole subject var lowerSubject = subject.ToLowerInvariant(); var conventionalMatch = ConventionalCommitPattern().Match(subject); var textToCheck = conventionalMatch.Success ? conventionalMatch.Groups["subject"].Value.ToLowerInvariant().Trim() : lowerSubject; foreach (var banned in _rules.BannedPhrases) { var bannedLower = banned.ToLowerInvariant(); if (textToCheck == bannedLower || textToCheck.StartsWith(bannedLower + " ") || textToCheck.StartsWith(bannedLower + ":") || textToCheck.StartsWith(bannedLower + ".")) { issues.Add(new QualityIssue { Severity = IssueSeverity.Error, Code = "BANNED_PHRASE", Message = Str.Analyzer_BannedPhrase(banned) }); score -= 30; break; } } // Conventional commit check (conventionalMatch already computed above) if (_rules.RequireConventionalCommit) { if (!conventionalMatch.Success) { issues.Add(new QualityIssue { Severity = IssueSeverity.Error, Code = "NOT_CONVENTIONAL", Message = Str.Analyzer_NotConventional }); score -= 20; } else if (!_rules.ConventionalTypes.Contains(conventionalMatch.Groups["type"].Value.ToLower())) { issues.Add(new QualityIssue { Severity = IssueSeverity.Warning, Code = "UNKNOWN_TYPE", Message = Str.Analyzer_UnknownType(conventionalMatch.Groups["type"].Value) }); score -= 10; } } // Issue reference check if (_rules.RequireIssueReference) { if (!IssueReferencePattern().IsMatch(message)) { issues.Add(new QualityIssue { Severity = IssueSeverity.Warning, Code = "NO_ISSUE_REF", Message = Str.Analyzer_NoIssueRef }); score -= 10; } } // Capitalization (skip if conventional commit) if (subject.Length > 0 && !conventionalMatch.Success) { if (char.IsLower(subject[0])) { issues.Add(new QualityIssue { Severity = IssueSeverity.Info, Code = "LOWERCASE_START", Message = Str.Analyzer_CapitalLetter }); score -= 5; } } // Trailing period if (subject.EndsWith('.')) { issues.Add(new QualityIssue { Severity = IssueSeverity.Info, Code = "TRAILING_PERIOD", Message = Str.Analyzer_NoPeriod }); score -= 5; } // Imperative mood check (simple heuristic) var pastTenseIndicators = new[] { "added", "fixed", "updated", "changed", "removed", "deleted", "created" }; var firstWord = subject.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.ToLower() ?? ""; if (pastTenseIndicators.Contains(firstWord)) { issues.Add(new QualityIssue { Severity = IssueSeverity.Info, Code = "NOT_IMPERATIVE", Message = Str.Analyzer_ImperativeMood(firstWord, firstWord.TrimEnd('d', 'e')) }); score -= 5; } // Body length check if (_rules.MinBodyLength > 0 && body.Length < _rules.MinBodyLength) { issues.Add(new QualityIssue { Severity = IssueSeverity.Warning, Code = "BODY_TOO_SHORT", Message = Str.Analyzer_BodyTooShort(body.Length, _rules.MinBodyLength) }); score -= 10; } // Blank line between subject and body if (lines.Length > 1 && !string.IsNullOrWhiteSpace(lines[1])) { issues.Add(new QualityIssue { Severity = IssueSeverity.Info, Code = "NO_BLANK_LINE", Message = Str.Analyzer_BlankLine }); score -= 5; } // === CONTEXT-AWARE CHECKS === // These checks consider the actual scope of changes if (context.FilesChanged > 0) { var totalLinesChanged = context.LinesAdded + context.LinesDeleted; // Check for lazy "ProjectName updates" pattern if (LazyProjectNamePattern().IsMatch(subject)) { issues.Add(new QualityIssue { Severity = IssueSeverity.Error, Code = "LAZY_MESSAGE", Message = Str.Analyzer_NotDescriptive(subject, context.FilesChanged) }); score -= 35; } // Check for vague words with significant changes var subjectWords = subject.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries); var vagueWordCount = subjectWords.Count(w => VagueWords.Contains(w)); var hasOnlyVagueContent = vagueWordCount > 0 && subjectWords.Length <= 3; if (hasOnlyVagueContent && context.FilesChanged >= 3) { issues.Add(new QualityIssue { Severity = IssueSeverity.Error, Code = "VAGUE_FOR_SCOPE", Message = Str.Analyzer_TooVague(context.FilesChanged) }); score -= 30; } // Large changes need descriptive messages if (context.FilesChanged >= 10 && subject.Length < 30) { issues.Add(new QualityIssue { Severity = IssueSeverity.Warning, Code = "LARGE_CHANGE_SHORT_MSG", Message = Str.Analyzer_LargeChange(context.FilesChanged, totalLinesChanged) }); score -= 15; } // Very large changes should have a body explaining context if (context.FilesChanged >= 20 && body.Length < 20) { issues.Add(new QualityIssue { Severity = IssueSeverity.Warning, Code = "LARGE_CHANGE_NO_BODY", Message = Str.Analyzer_MajorChange(context.FilesChanged) }); score -= 10; } // Message doesn't mention any file types or areas changed // Skip if message already has a scope pattern like feat(api): description if (context.FilesChanged >= 5 && context.FileNames.Count > 0) { // Check: word(scope): description AND body has content var hasScope = ScopePattern().IsMatch(subject); var bodyWords = body.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length; var hasBody = bodyWords >= 3; // Only check for scope hint if no proper scope+body present if (!(hasScope && hasBody)) { var extensions = context.FileNames .Select(f => Path.GetExtension(f).TrimStart('.').ToLower()) .Where(e => !string.IsNullOrEmpty(e)) .Distinct() .ToList(); var folders = context.FileNames .Select(f => Path.GetDirectoryName(f)?.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).LastOrDefault()) .Where(d => !string.IsNullOrEmpty(d)) .Distinct() .Take(5) .ToList(); // Check if message mentions any relevant context var messageLower = message.ToLowerInvariant(); var mentionsFileType = extensions.Any(e => messageLower.Contains(e)); var mentionsFolder = folders.Any(f => messageLower.Contains(f!.ToLowerInvariant())); if (!mentionsFileType && !mentionsFolder && extensions.Count > 1) { issues.Add(new QualityIssue { Severity = IssueSeverity.Info, Code = "NO_SCOPE_HINT", Message = Str.Analyzer_MentionArea(string.Join(", ", extensions.Take(3))) }); score -= 5; } } } } return new MessageQualityScore { OverallScore = Math.Max(0, score), Issues = issues }; } }