@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
456 lines (455 loc) • 17.9 kB
JavaScript
import colors from '../../shared/constants/colors.js';
import { EnhancedConsole } from '../../shared/utils/cli-ui.js';
/**
* Commit Message Validation Service
*
* Provides comprehensive commit message validation based on:
* - Conventional Commits specification
* - Configuration-based rules
* - Branch intelligence context
* - Best practices for readability and maintainability
*/
export class CommitMessageValidationService {
constructor(configManager) {
this.configManager = configManager;
this.config = this.loadValidationConfig();
}
/**
* Load validation configuration from config files
*/
loadValidationConfig() {
const config = this.configManager.getAll() || {};
// Default validation rules
const defaults = {
commitTypes: [
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'build',
'ci',
'chore',
'revert',
],
commitScopes: [], // Empty means any scope is allowed
maxSubjectLength: 72,
minSubjectLength: 10,
requireScope: false,
requireBody: false,
requireFooter: false,
allowBreakingChanges: true,
subjectCase: 'lower', // 'lower', 'sentence', 'any'
subjectEndPunctuation: false, // Don't allow period at end
bodyLineLength: 100,
footerFormat: 'conventional', // 'conventional', 'any'
};
// Merge with config from ai-changelog.config.yaml
const yamlConfig = config.convention || {};
return {
...defaults,
...yamlConfig,
// Override with specific validation settings if they exist
commitTypes: yamlConfig.commitTypes || defaults.commitTypes,
commitScopes: yamlConfig.commitScopes || defaults.commitScopes,
};
}
/**
* Comprehensive commit message validation
*/
async validateCommitMessage(message, context = {}) {
if (!message || typeof message !== 'string') {
return this.createValidationResult(false, ['Commit message is required'], []);
}
const trimmedMessage = message.trim();
if (trimmedMessage.length === 0) {
return this.createValidationResult(false, ['Commit message cannot be empty'], []);
}
const lines = trimmedMessage.split('\n');
const subject = lines[0];
const body = lines.slice(2).join('\n').trim(); // Skip blank line after subject
const hasBlankLineAfterSubject = lines.length > 1 && lines[1].trim() === '';
const errors = [];
const warnings = [];
const suggestions = [];
// Parse conventional commit format
const conventionalCommit = this.parseConventionalCommit(subject);
// Subject validation
this.validateSubject(subject, conventionalCommit, errors, warnings, suggestions, context);
// Body validation
if (lines.length > 1) {
this.validateBody(body, hasBlankLineAfterSubject, lines, errors, warnings, suggestions);
}
// Footer validation
const footerLines = this.extractFooterLines(lines);
if (footerLines.length > 0) {
this.validateFooter(footerLines, errors, warnings, suggestions);
}
// Context-based validation (branch intelligence)
if (context.branchAnalysis) {
this.validateAgainstBranchContext(conventionalCommit, context.branchAnalysis, warnings, suggestions);
}
// Configuration-based validation
this.validateAgainstConfig(conventionalCommit, errors, warnings, suggestions);
const isValid = errors.length === 0;
const score = this.calculateValidationScore(errors, warnings, suggestions);
return this.createValidationResult(isValid, errors, warnings, suggestions, score, conventionalCommit);
}
/**
* Parse conventional commit format
*/
parseConventionalCommit(subject) {
// Enhanced pattern to capture all parts
const conventionalPattern = /^([a-z]+)(\(([^)]+)\))?(!)?: (.+)$/;
const match = subject.match(conventionalPattern);
if (!match) {
return {
type: null,
scope: null,
breaking: false,
description: subject,
isConventional: false,
};
}
return {
type: match[1],
scope: match[3] || null,
breaking: !!match[4], // Breaking change indicator (!)
description: match[5],
isConventional: true,
};
}
/**
* Validate subject line
*/
validateSubject(subject, parsed, errors, warnings, suggestions, _context) {
// Length validation
if (subject.length < this.config.minSubjectLength) {
errors.push(`Subject too short (${subject.length} chars, minimum ${this.config.minSubjectLength})`);
suggestions.push('Add more detail about what was changed');
}
if (subject.length > this.config.maxSubjectLength) {
warnings.push(`Subject too long (${subject.length} chars, maximum ${this.config.maxSubjectLength})`);
suggestions.push('Consider moving additional details to the commit body');
}
// Conventional commit format validation
if (!parsed.isConventional) {
errors.push('Subject does not follow conventional commit format');
suggestions.push('Use format: type(scope): description (e.g., "feat: add new feature")');
return; // Skip further validation if not conventional
}
// Type validation
if (!this.config.commitTypes.includes(parsed.type)) {
errors.push(`Invalid commit type: "${parsed.type}"`);
suggestions.push(`Use one of: ${this.config.commitTypes.join(', ')}`);
}
// Scope validation
if (this.config.commitScopes.length > 0 &&
parsed.scope &&
!this.config.commitScopes.includes(parsed.scope)) {
warnings.push(`Unexpected scope: "${parsed.scope}"`);
suggestions.push(`Suggested scopes: ${this.config.commitScopes.join(', ')}`);
}
if (this.config.requireScope && !parsed.scope) {
errors.push('Scope is required for this repository');
suggestions.push('Add scope in parentheses: type(scope): description');
}
// Description validation
if (!parsed.description || parsed.description.trim().length === 0) {
errors.push('Description is required');
suggestions.push('Add a clear description of what was changed');
}
// Case validation
if (this.config.subjectCase === 'lower' && parsed.description) {
const firstChar = parsed.description.charAt(0);
if (firstChar !== firstChar.toLowerCase()) {
warnings.push('Description should start with lowercase letter');
suggestions.push(`Change "${firstChar}" to "${firstChar.toLowerCase()}"`);
}
}
// End punctuation validation
if (!this.config.subjectEndPunctuation &&
parsed.description &&
parsed.description.endsWith('.')) {
warnings.push('Subject should not end with a period');
suggestions.push('Remove the trailing period');
}
// Imperative mood validation
if (parsed.description) {
this.validateImperativeMood(parsed.description, warnings, suggestions);
}
}
/**
* Validate imperative mood
*/
validateImperativeMood(description, warnings, suggestions) {
const imperativeVerbs = [
'add',
'remove',
'fix',
'update',
'create',
'delete',
'implement',
'refactor',
'improve',
'enhance',
'optimize',
'change',
'move',
'rename',
'replace',
'upgrade',
'downgrade',
'install',
'uninstall',
'configure',
'setup',
'initialize',
'clean',
'format',
'lint',
'test',
'document',
];
const nonImperativeIndicators = [
'added',
'removed',
'fixed',
'updated',
'created',
'deleted',
'implemented',
'improved',
'enhanced',
'optimized',
'changed',
'moved',
'renamed',
'replaced',
'upgraded',
'downgraded',
'installed',
'uninstalled',
'configured',
'initialized',
'cleaned',
'formatted',
'linted',
'tested',
'documented',
];
const firstWord = description.split(' ')[0].toLowerCase();
if (nonImperativeIndicators.includes(firstWord)) {
warnings.push('Use imperative mood in description');
// Try to suggest imperative form
const imperative = firstWord.replace(/ed$/, '').replace(/d$/, '');
if (imperativeVerbs.includes(imperative)) {
suggestions.push(`Change "${firstWord}" to "${imperative}"`);
}
else {
suggestions.push('Use imperative mood (e.g., "fix bug" not "fixed bug")');
}
}
}
/**
* Validate body
*/
validateBody(body, hasBlankLine, _lines, errors, warnings, suggestions) {
// Blank line separation
if (!hasBlankLine) {
errors.push('Missing blank line between subject and body');
suggestions.push('Add a blank line after the subject');
}
// Body line length
if (body) {
const bodyLines = body.split('\n');
bodyLines.forEach((line, index) => {
if (line.length > this.config.bodyLineLength) {
warnings.push(`Body line ${index + 1} too long (${line.length} chars, recommended max ${this.config.bodyLineLength})`);
}
});
}
// Required body
if (this.config.requireBody && (!body || body.trim().length === 0)) {
errors.push('Commit body is required');
suggestions.push('Add details about the changes in the commit body');
}
}
/**
* Extract footer lines (last paragraph that contains key-value pairs)
*/
extractFooterLines(lines) {
if (lines.length < 3) {
return [];
}
const footerLines = [];
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim();
if (line === '') {
break; // Empty line indicates end of footer
}
if (line.includes(':') || line.match(/^(BREAKING CHANGE|Closes?|Fixes?|Refs?)/i)) {
footerLines.unshift(line);
}
else {
break; // Non-footer line
}
}
return footerLines;
}
/**
* Validate footer
*/
validateFooter(footerLines, errors, warnings, suggestions) {
footerLines.forEach((line) => {
// Validate footer format
if (this.config.footerFormat === 'conventional' &&
!(line.match(/^[A-Za-z-]+: .+/) || line.match(/^BREAKING CHANGE: .+/))) {
warnings.push(`Footer line doesn't follow conventional format: "${line}"`);
suggestions.push('Use format: "Key: value" or "BREAKING CHANGE: description"');
}
// Validate breaking changes
if (line.startsWith('BREAKING CHANGE:') && !this.config.allowBreakingChanges) {
errors.push('Breaking changes are not allowed in this repository');
}
});
}
/**
* Validate against branch context
*/
validateAgainstBranchContext(parsed, branchAnalysis, warnings, suggestions) {
if (!branchAnalysis || branchAnalysis.confidence < 50) {
return;
}
// Type mismatch
if (branchAnalysis.type && parsed.type && branchAnalysis.type !== parsed.type) {
warnings.push(`Commit type "${parsed.type}" doesn't match branch type "${branchAnalysis.type}"`);
suggestions.push(`Consider using type "${branchAnalysis.type}" based on branch name`);
}
// Missing ticket reference
if (branchAnalysis.ticket && !this.containsTicketReference(parsed, branchAnalysis.ticket)) {
suggestions.push(`Consider adding ticket reference: ${branchAnalysis.ticket}`);
}
}
/**
* Check if commit message contains ticket reference
*/
containsTicketReference(parsed, ticket) {
const fullMessage = `${parsed.type}${parsed.scope ? `(${parsed.scope})` : ''}: ${parsed.description}`;
return fullMessage.includes(ticket);
}
/**
* Validate against configuration
*/
validateAgainstConfig(_parsed, _errors, _warnings, _suggestions) {
// This is handled in other validation methods
// Additional config-specific validations can be added here
}
/**
* Calculate validation score
*/
calculateValidationScore(errors, warnings, suggestions) {
let score = 100;
score -= errors.length * 25; // Major issues
score -= warnings.length * 10; // Minor issues
score -= suggestions.length * 5; // Improvements
return Math.max(0, score);
}
/**
* Create validation result object
*/
createValidationResult(isValid, errors = [], warnings = [], suggestions = [], score = 0, parsed = null) {
return {
valid: isValid,
errors,
warnings,
suggestions,
score,
parsed,
summary: this.generateValidationSummary(isValid, errors, warnings, suggestions, score),
};
}
/**
* Generate human-readable validation summary
*/
generateValidationSummary(isValid, errors, warnings, suggestions, score) {
if (isValid && warnings.length === 0 && suggestions.length === 0) {
return '✅ Perfect commit message!';
}
const parts = [];
if (errors.length > 0) {
parts.push(`${errors.length} error${errors.length === 1 ? '' : 's'}`);
}
if (warnings.length > 0) {
parts.push(`${warnings.length} warning${warnings.length === 1 ? '' : 's'}`);
}
if (suggestions.length > 0) {
parts.push(`${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'}`);
}
const status = isValid ? '✅' : '❌';
return `${status} ${parts.join(', ')} (Score: ${score}/100)`;
}
/**
* Display validation results with colors
*/
displayValidationResults(validationResult) {
const { valid, errors, warnings, suggestions, summary } = validationResult;
EnhancedConsole.section('🔍 Commit Message Validation');
console.log(colors.secondary(`${summary}`));
if (errors.length > 0) {
EnhancedConsole.space();
console.log(colors.statusSymbol('error', 'Errors (must fix):'));
errors.forEach((error) => {
console.log(` ${colors.symbols.bullet} ${colors.error(error)}`);
});
}
if (warnings.length > 0) {
EnhancedConsole.space();
console.log(colors.statusSymbol('warning', 'Warnings (recommended to fix):'));
warnings.forEach((warning) => {
console.log(` ${colors.symbols.bullet} ${colors.warning(warning)}`);
});
}
if (suggestions.length > 0) {
EnhancedConsole.space();
console.log(colors.statusSymbol('info', 'Suggestions (optional improvements):'));
suggestions.forEach((suggestion) => {
console.log(` ${colors.symbols.bullet} ${colors.dim(suggestion)}`);
});
}
return valid;
}
/**
* Interactive commit message improvement
*/
async improveCommitMessage(message, context = {}) {
const validation = await this.validateCommitMessage(message, context);
if (validation.valid && validation.warnings.length === 0) {
return { improved: false, message, validation };
}
// Generate improved message based on validation results
let improved = message;
// Fix common issues automatically
if (validation.parsed) {
const { type, scope, description, breaking } = validation.parsed;
// Fix case issues
if (description) {
let fixedDescription = description;
// Fix capitalization
if (this.config.subjectCase === 'lower') {
fixedDescription = fixedDescription.charAt(0).toLowerCase() + fixedDescription.slice(1);
}
// Remove trailing period
if (!this.config.subjectEndPunctuation && fixedDescription.endsWith('.')) {
fixedDescription = fixedDescription.slice(0, -1);
}
// Reconstruct subject
improved = `${type}${scope ? `(${scope})` : ''}${breaking ? '!' : ''}: ${fixedDescription}`;
}
}
return { improved: improved !== message, message: improved, validation };
}
}