@entro314labs/ai-changelog-generator 3.0.5 → 3.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,458 @@
1
+ import colors from '../../shared/constants/colors.js';
2
+
3
+ /**
4
+ * Commit Message Validation Service
5
+ *
6
+ * Provides comprehensive commit message validation based on:
7
+ * - Conventional Commits specification
8
+ * - Configuration-based rules
9
+ * - Branch intelligence context
10
+ * - Best practices for readability and maintainability
11
+ */
12
+ export class CommitMessageValidationService {
13
+ constructor(configManager) {
14
+ this.configManager = configManager;
15
+ this.config = this.loadValidationConfig();
16
+ }
17
+
18
+ /**
19
+ * Load validation configuration from config files
20
+ */
21
+ loadValidationConfig() {
22
+ const config = this.configManager.getAll();
23
+
24
+ // Default validation rules
25
+ const defaults = {
26
+ commitTypes: ['feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'build', 'ci', 'chore', 'revert'],
27
+ commitScopes: [], // Empty means any scope is allowed
28
+ maxSubjectLength: 72,
29
+ minSubjectLength: 10,
30
+ requireScope: false,
31
+ requireBody: false,
32
+ requireFooter: false,
33
+ allowBreakingChanges: true,
34
+ subjectCase: 'lower', // 'lower', 'sentence', 'any'
35
+ subjectEndPunctuation: false, // Don't allow period at end
36
+ bodyLineLength: 100,
37
+ footerFormat: 'conventional' // 'conventional', 'any'
38
+ };
39
+
40
+ // Merge with config from ai-changelog.config.yaml
41
+ const yamlConfig = config.convention || {};
42
+
43
+ return {
44
+ ...defaults,
45
+ ...yamlConfig,
46
+ // Override with specific validation settings if they exist
47
+ commitTypes: yamlConfig.commitTypes || defaults.commitTypes,
48
+ commitScopes: yamlConfig.commitScopes || defaults.commitScopes
49
+ };
50
+ }
51
+
52
+ /**
53
+ * Comprehensive commit message validation
54
+ */
55
+ async validateCommitMessage(message, context = {}) {
56
+ if (!message || typeof message !== 'string') {
57
+ return this.createValidationResult(false, ['Commit message is required'], []);
58
+ }
59
+
60
+ const trimmedMessage = message.trim();
61
+ if (trimmedMessage.length === 0) {
62
+ return this.createValidationResult(false, ['Commit message cannot be empty'], []);
63
+ }
64
+
65
+ const lines = trimmedMessage.split('\n');
66
+ const subject = lines[0];
67
+ const body = lines.slice(2).join('\n').trim(); // Skip blank line after subject
68
+ const hasBlankLineAfterSubject = lines.length > 1 && lines[1].trim() === '';
69
+
70
+ const errors = [];
71
+ const warnings = [];
72
+ const suggestions = [];
73
+
74
+ // Parse conventional commit format
75
+ const conventionalCommit = this.parseConventionalCommit(subject);
76
+
77
+ // Subject validation
78
+ this.validateSubject(subject, conventionalCommit, errors, warnings, suggestions, context);
79
+
80
+ // Body validation
81
+ if (lines.length > 1) {
82
+ this.validateBody(body, hasBlankLineAfterSubject, lines, errors, warnings, suggestions);
83
+ }
84
+
85
+ // Footer validation
86
+ const footerLines = this.extractFooterLines(lines);
87
+ if (footerLines.length > 0) {
88
+ this.validateFooter(footerLines, errors, warnings, suggestions);
89
+ }
90
+
91
+ // Context-based validation (branch intelligence)
92
+ if (context.branchAnalysis) {
93
+ this.validateAgainstBranchContext(conventionalCommit, context.branchAnalysis, warnings, suggestions);
94
+ }
95
+
96
+ // Configuration-based validation
97
+ this.validateAgainstConfig(conventionalCommit, errors, warnings, suggestions);
98
+
99
+ const isValid = errors.length === 0;
100
+ const score = this.calculateValidationScore(errors, warnings, suggestions);
101
+
102
+ return this.createValidationResult(isValid, errors, warnings, suggestions, score, conventionalCommit);
103
+ }
104
+
105
+ /**
106
+ * Parse conventional commit format
107
+ */
108
+ parseConventionalCommit(subject) {
109
+ // Enhanced pattern to capture all parts
110
+ const conventionalPattern = /^([a-z]+)(\(([^)]+)\))?(!)?: (.+)$/;
111
+ const match = subject.match(conventionalPattern);
112
+
113
+ if (!match) {
114
+ return {
115
+ type: null,
116
+ scope: null,
117
+ breaking: false,
118
+ description: subject,
119
+ isConventional: false
120
+ };
121
+ }
122
+
123
+ return {
124
+ type: match[1],
125
+ scope: match[3] || null,
126
+ breaking: !!match[4], // Breaking change indicator (!)
127
+ description: match[5],
128
+ isConventional: true
129
+ };
130
+ }
131
+
132
+ /**
133
+ * Validate subject line
134
+ */
135
+ validateSubject(subject, parsed, errors, warnings, suggestions, context) {
136
+ // Length validation
137
+ if (subject.length < this.config.minSubjectLength) {
138
+ errors.push(`Subject too short (${subject.length} chars, minimum ${this.config.minSubjectLength})`);
139
+ suggestions.push('Add more detail about what was changed');
140
+ }
141
+
142
+ if (subject.length > this.config.maxSubjectLength) {
143
+ errors.push(`Subject too long (${subject.length} chars, maximum ${this.config.maxSubjectLength})`);
144
+ suggestions.push('Move additional details to the commit body');
145
+ }
146
+
147
+ // Conventional commit format validation
148
+ if (!parsed.isConventional) {
149
+ errors.push('Subject does not follow conventional commit format');
150
+ suggestions.push('Use format: type(scope): description (e.g., "feat: add new feature")');
151
+ return; // Skip further validation if not conventional
152
+ }
153
+
154
+ // Type validation
155
+ if (!this.config.commitTypes.includes(parsed.type)) {
156
+ errors.push(`Invalid commit type: "${parsed.type}"`);
157
+ suggestions.push(`Use one of: ${this.config.commitTypes.join(', ')}`);
158
+ }
159
+
160
+ // Scope validation
161
+ if (this.config.commitScopes.length > 0 && parsed.scope && !this.config.commitScopes.includes(parsed.scope)) {
162
+ warnings.push(`Unexpected scope: "${parsed.scope}"`);
163
+ suggestions.push(`Suggested scopes: ${this.config.commitScopes.join(', ')}`);
164
+ }
165
+
166
+ if (this.config.requireScope && !parsed.scope) {
167
+ errors.push('Scope is required for this repository');
168
+ suggestions.push('Add scope in parentheses: type(scope): description');
169
+ }
170
+
171
+ // Description validation
172
+ if (!parsed.description || parsed.description.trim().length === 0) {
173
+ errors.push('Description is required');
174
+ suggestions.push('Add a clear description of what was changed');
175
+ }
176
+
177
+ // Case validation
178
+ if (this.config.subjectCase === 'lower' && parsed.description) {
179
+ const firstChar = parsed.description.charAt(0);
180
+ if (firstChar !== firstChar.toLowerCase()) {
181
+ warnings.push('Description should start with lowercase letter');
182
+ suggestions.push(`Change "${firstChar}" to "${firstChar.toLowerCase()}"`);
183
+ }
184
+ }
185
+
186
+ // End punctuation validation
187
+ if (!this.config.subjectEndPunctuation && parsed.description && parsed.description.endsWith('.')) {
188
+ warnings.push('Subject should not end with a period');
189
+ suggestions.push('Remove the trailing period');
190
+ }
191
+
192
+ // Imperative mood validation
193
+ if (parsed.description) {
194
+ this.validateImperativeMood(parsed.description, warnings, suggestions);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Validate imperative mood
200
+ */
201
+ validateImperativeMood(description, warnings, suggestions) {
202
+ const imperativeVerbs = [
203
+ 'add', 'remove', 'fix', 'update', 'create', 'delete', 'implement', 'refactor',
204
+ 'improve', 'enhance', 'optimize', 'change', 'move', 'rename', 'replace',
205
+ 'upgrade', 'downgrade', 'install', 'uninstall', 'configure', 'setup',
206
+ 'initialize', 'clean', 'format', 'lint', 'test', 'document'
207
+ ];
208
+
209
+ const nonImperativeIndicators = [
210
+ 'added', 'removed', 'fixed', 'updated', 'created', 'deleted', 'implemented',
211
+ 'improved', 'enhanced', 'optimized', 'changed', 'moved', 'renamed', 'replaced',
212
+ 'upgraded', 'downgraded', 'installed', 'uninstalled', 'configured',
213
+ 'initialized', 'cleaned', 'formatted', 'linted', 'tested', 'documented'
214
+ ];
215
+
216
+ const firstWord = description.split(' ')[0].toLowerCase();
217
+
218
+ if (nonImperativeIndicators.includes(firstWord)) {
219
+ warnings.push('Use imperative mood in description');
220
+ // Try to suggest imperative form
221
+ const imperative = firstWord.replace(/ed$/, '').replace(/d$/, '');
222
+ if (imperativeVerbs.includes(imperative)) {
223
+ suggestions.push(`Change "${firstWord}" to "${imperative}"`);
224
+ } else {
225
+ suggestions.push('Use imperative mood (e.g., "fix bug" not "fixed bug")');
226
+ }
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Validate body
232
+ */
233
+ validateBody(body, hasBlankLine, lines, errors, warnings, suggestions) {
234
+ // Blank line separation
235
+ if (!hasBlankLine) {
236
+ errors.push('Missing blank line between subject and body');
237
+ suggestions.push('Add a blank line after the subject');
238
+ }
239
+
240
+ // Body line length
241
+ if (body) {
242
+ const bodyLines = body.split('\n');
243
+ bodyLines.forEach((line, index) => {
244
+ if (line.length > this.config.bodyLineLength) {
245
+ warnings.push(`Body line ${index + 1} too long (${line.length} chars, recommended max ${this.config.bodyLineLength})`);
246
+ }
247
+ });
248
+ }
249
+
250
+ // Required body
251
+ if (this.config.requireBody && (!body || body.trim().length === 0)) {
252
+ errors.push('Commit body is required');
253
+ suggestions.push('Add details about the changes in the commit body');
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Extract footer lines (last paragraph that contains key-value pairs)
259
+ */
260
+ extractFooterLines(lines) {
261
+ if (lines.length < 3) return [];
262
+
263
+ const footerLines = [];
264
+ for (let i = lines.length - 1; i >= 0; i--) {
265
+ const line = lines[i].trim();
266
+ if (line === '') {
267
+ break; // Empty line indicates end of footer
268
+ }
269
+ if (line.includes(':') || line.match(/^(BREAKING CHANGE|Closes?|Fixes?|Refs?)/i)) {
270
+ footerLines.unshift(line);
271
+ } else {
272
+ break; // Non-footer line
273
+ }
274
+ }
275
+
276
+ return footerLines;
277
+ }
278
+
279
+ /**
280
+ * Validate footer
281
+ */
282
+ validateFooter(footerLines, errors, warnings, suggestions) {
283
+ footerLines.forEach(line => {
284
+ // Validate footer format
285
+ if (this.config.footerFormat === 'conventional') {
286
+ if (!line.match(/^[A-Za-z-]+: .+/) && !line.match(/^BREAKING CHANGE: .+/)) {
287
+ warnings.push(`Footer line doesn't follow conventional format: "${line}"`);
288
+ suggestions.push('Use format: "Key: value" or "BREAKING CHANGE: description"');
289
+ }
290
+ }
291
+
292
+ // Validate breaking changes
293
+ if (line.startsWith('BREAKING CHANGE:')) {
294
+ if (!this.config.allowBreakingChanges) {
295
+ errors.push('Breaking changes are not allowed in this repository');
296
+ }
297
+ }
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Validate against branch context
303
+ */
304
+ validateAgainstBranchContext(parsed, branchAnalysis, warnings, suggestions) {
305
+ if (!branchAnalysis || branchAnalysis.confidence < 50) return;
306
+
307
+ // Type mismatch
308
+ if (branchAnalysis.type && parsed.type && branchAnalysis.type !== parsed.type) {
309
+ warnings.push(`Commit type "${parsed.type}" doesn't match branch type "${branchAnalysis.type}"`);
310
+ suggestions.push(`Consider using type "${branchAnalysis.type}" based on branch name`);
311
+ }
312
+
313
+ // Missing ticket reference
314
+ if (branchAnalysis.ticket && !this.containsTicketReference(parsed, branchAnalysis.ticket)) {
315
+ suggestions.push(`Consider adding ticket reference: ${branchAnalysis.ticket}`);
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Check if commit message contains ticket reference
321
+ */
322
+ containsTicketReference(parsed, ticket) {
323
+ const fullMessage = `${parsed.type}${parsed.scope ? `(${parsed.scope})` : ''}: ${parsed.description}`;
324
+ return fullMessage.includes(ticket);
325
+ }
326
+
327
+ /**
328
+ * Validate against configuration
329
+ */
330
+ validateAgainstConfig(parsed, errors, warnings, suggestions) {
331
+ // This is handled in other validation methods
332
+ // Additional config-specific validations can be added here
333
+ }
334
+
335
+ /**
336
+ * Calculate validation score
337
+ */
338
+ calculateValidationScore(errors, warnings, suggestions) {
339
+ let score = 100;
340
+ score -= errors.length * 25; // Major issues
341
+ score -= warnings.length * 10; // Minor issues
342
+ score -= suggestions.length * 5; // Improvements
343
+ return Math.max(0, score);
344
+ }
345
+
346
+ /**
347
+ * Create validation result object
348
+ */
349
+ createValidationResult(isValid, errors = [], warnings = [], suggestions = [], score = 0, parsed = null) {
350
+ return {
351
+ valid: isValid,
352
+ errors,
353
+ warnings,
354
+ suggestions,
355
+ score,
356
+ parsed,
357
+ summary: this.generateValidationSummary(isValid, errors, warnings, suggestions, score)
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Generate human-readable validation summary
363
+ */
364
+ generateValidationSummary(isValid, errors, warnings, suggestions, score) {
365
+ if (isValid && warnings.length === 0 && suggestions.length === 0) {
366
+ return '✅ Perfect commit message!';
367
+ }
368
+
369
+ const parts = [];
370
+
371
+ if (errors.length > 0) {
372
+ parts.push(`${errors.length} error${errors.length === 1 ? '' : 's'}`);
373
+ }
374
+
375
+ if (warnings.length > 0) {
376
+ parts.push(`${warnings.length} warning${warnings.length === 1 ? '' : 's'}`);
377
+ }
378
+
379
+ if (suggestions.length > 0) {
380
+ parts.push(`${suggestions.length} suggestion${suggestions.length === 1 ? '' : 's'}`);
381
+ }
382
+
383
+ const status = isValid ? '✅' : '❌';
384
+ return `${status} ${parts.join(', ')} (Score: ${score}/100)`;
385
+ }
386
+
387
+ /**
388
+ * Display validation results with colors
389
+ */
390
+ displayValidationResults(validationResult) {
391
+ const { valid, errors, warnings, suggestions, score, summary } = validationResult;
392
+
393
+ console.log(colors.infoMessage(`\n🔍 Commit Message Validation:`));
394
+ console.log(colors.secondary(`${summary}`));
395
+
396
+ if (errors.length > 0) {
397
+ console.log(colors.errorMessage('\n❌ Errors (must fix):'));
398
+ errors.forEach(error => {
399
+ console.log(colors.error(` • ${error}`));
400
+ });
401
+ }
402
+
403
+ if (warnings.length > 0) {
404
+ console.log(colors.warningMessage('\n⚠️ Warnings (recommended to fix):'));
405
+ warnings.forEach(warning => {
406
+ console.log(colors.warning(` • ${warning}`));
407
+ });
408
+ }
409
+
410
+ if (suggestions.length > 0) {
411
+ console.log(colors.infoMessage('\n💡 Suggestions (optional improvements):'));
412
+ suggestions.forEach(suggestion => {
413
+ console.log(colors.dim(` • ${suggestion}`));
414
+ });
415
+ }
416
+
417
+ return valid;
418
+ }
419
+
420
+ /**
421
+ * Interactive commit message improvement
422
+ */
423
+ async improveCommitMessage(message, context = {}) {
424
+ const validation = await this.validateCommitMessage(message, context);
425
+
426
+ if (validation.valid && validation.warnings.length === 0) {
427
+ return { improved: false, message, validation };
428
+ }
429
+
430
+ // Generate improved message based on validation results
431
+ let improved = message;
432
+
433
+ // Fix common issues automatically
434
+ if (validation.parsed) {
435
+ const { type, scope, description, breaking } = validation.parsed;
436
+
437
+ // Fix case issues
438
+ if (description) {
439
+ let fixedDescription = description;
440
+
441
+ // Fix capitalization
442
+ if (this.config.subjectCase === 'lower') {
443
+ fixedDescription = fixedDescription.charAt(0).toLowerCase() + fixedDescription.slice(1);
444
+ }
445
+
446
+ // Remove trailing period
447
+ if (!this.config.subjectEndPunctuation && fixedDescription.endsWith('.')) {
448
+ fixedDescription = fixedDescription.slice(0, -1);
449
+ }
450
+
451
+ // Reconstruct subject
452
+ improved = `${type}${scope ? `(${scope})` : ''}${breaking ? '!' : ''}: ${fixedDescription}`;
453
+ }
454
+ }
455
+
456
+ return { improved: improved !== message, message: improved, validation };
457
+ }
458
+ }
@@ -271,15 +271,37 @@ class Colors {
271
271
  return `${this.info(`[${bar}]`)} ${this.percentage(`${percentage}%`)} ${label}`;
272
272
  }
273
273
 
274
- // Box drawing for sections
275
- box(title, content, width = 80) {
274
+ // Helper to get actual visible length of string (without ANSI codes)
275
+ getVisibleLength(str) {
276
+ // Remove ANSI escape sequences to get actual visible length
277
+ return str.replace(/\x1b\[[0-9;]*m/g, '').length;
278
+ }
279
+
280
+ // Box drawing for sections with dynamic width calculation
281
+ box(title, content, minWidth = 60) {
282
+ const lines = content.split('\n');
283
+ const titleVisibleLength = this.getVisibleLength(title);
284
+
285
+ // Calculate required width based on content
286
+ const maxContentLength = Math.max(
287
+ titleVisibleLength + 4, // title + padding
288
+ ...lines.map(line => this.getVisibleLength(line) + 4), // content + padding
289
+ minWidth
290
+ );
291
+
292
+ const width = Math.min(maxContentLength, 80); // Cap at 80 chars for readability
293
+
276
294
  const topBorder = '┌' + '─'.repeat(width - 2) + '┐';
277
295
  const bottomBorder = '└' + '─'.repeat(width - 2) + '┘';
278
- const titleLine = `│ ${this.header(title)}${' '.repeat(Math.max(0, width - title.length - 3))}│`;
279
296
 
280
- const contentLines = content.split('\n').map(line => {
281
- const paddedLine = line.padEnd(width - 4);
282
- return `│ ${paddedLine} │`;
297
+ // Title line with proper padding accounting for ANSI codes
298
+ const titlePadding = width - titleVisibleLength - 3;
299
+ const titleLine = `│ ${this.header(title)}${' '.repeat(Math.max(0, titlePadding))}│`;
300
+
301
+ const contentLines = lines.map(line => {
302
+ const visibleLength = this.getVisibleLength(line);
303
+ const padding = width - visibleLength - 4;
304
+ return `│ ${line}${' '.repeat(Math.max(0, padding))} │`;
283
305
  });
284
306
 
285
307
  return [