Files
2025-12-28 05:38:14 -05:00

338 lines
12 KiB
C#

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(@"^(?<type>\w+)(?:\((?<scope>[^)]+)\))?!?:\s*(?<subject>.+)$", 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));
}
/// <summary>
/// Analyze without context - uses default context with 0 files.
/// </summary>
public MessageQualityScore Analyze(string message) => Analyze(message, new CommitContext());
/// <summary>
/// Analyze with full context about the changes for smarter detection.
/// </summary>
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<QualityIssue>();
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
};
}
}