338 lines
12 KiB
C#
Executable File
338 lines
12 KiB
C#
Executable File
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
|
|
};
|
|
}
|
|
}
|