format-commit 1.0.0 → 1.1.0

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.
package/lib/utils.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execSync } from 'child_process';
2
2
  import kleur from 'kleur';
3
+ import { magenta } from 'kleur/colors';
3
4
 
4
5
 
5
6
  const { gray, bold, red, green, yellow } = kleur;
@@ -53,7 +54,199 @@ const validVersion = (version) => {
53
54
  return true;
54
55
  };
55
56
 
56
- const formatCommitTitle = (type, title, format, scope = '*') => {
57
+ const applyCasing = (value, casing) => {
58
+ switch (casing) {
59
+ case 'lower': return value.toLowerCase();
60
+ case 'upper': return value.toUpperCase();
61
+ case 'capitalize': return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
62
+ default: return value;
63
+ }
64
+ };
65
+
66
+ const detectCasing = (word) => {
67
+ if (word === word.toLowerCase()) { return 'lower'; }
68
+ if (word === word.toUpperCase()) { return 'upper'; }
69
+ return 'capitalize';
70
+ };
71
+
72
+ const parseCustomFormat = (pattern) => {
73
+ const regex = /\{([^}]+)\}|\b(type|scope|description)\b/gi;
74
+ const segments = [];
75
+ let lastIndex = 0;
76
+ let match;
77
+
78
+ while ((match = regex.exec(pattern)) !== null) {
79
+ // Add literal text before this match
80
+ if (match.index > lastIndex) {
81
+ segments.push({ type: 'literal', value: pattern.slice(lastIndex, match.index) });
82
+ }
83
+
84
+ if (match[1] !== undefined) {
85
+ // {field} placeholder
86
+ segments.push({ type: 'field', label: match[1] });
87
+ } else {
88
+ // keyword (type, scope, description)
89
+ segments.push({
90
+ type: 'keyword',
91
+ keyword: match[2].toLowerCase(),
92
+ case: detectCasing(match[2]),
93
+ });
94
+ }
95
+
96
+ lastIndex = match.index + match[0].length;
97
+ }
98
+
99
+ // Add trailing literal text
100
+ if (lastIndex < pattern.length) {
101
+ segments.push({ type: 'literal', value: pattern.slice(lastIndex) });
102
+ }
103
+
104
+ return segments;
105
+ };
106
+
107
+ const customFormatHasScope = (pattern) => /\bscope\b/i.test(pattern);
108
+
109
+ const customBranchFormatHasScope = (pattern) => customFormatHasScope(pattern);
110
+
111
+ const getCustomFields = (pattern) => {
112
+ const fields = [];
113
+ const regex = /\{([^}]+)\}/g;
114
+ let match;
115
+ while ((match = regex.exec(pattern)) !== null) {
116
+ fields.push(match[1]);
117
+ }
118
+ return fields;
119
+ };
120
+
121
+ const validateCustomFormatPattern = (pattern) => {
122
+ if (!pattern || !pattern.trim()) {
123
+ return 'Pattern cannot be empty';
124
+ }
125
+ if (!/\btype\b/i.test(pattern)) {
126
+ return 'Pattern must contain the "type" keyword';
127
+ }
128
+ if (!/\bdescription\b/i.test(pattern)) {
129
+ return 'Pattern must contain the "description" keyword';
130
+ }
131
+ // Check balanced braces
132
+ let depth = 0;
133
+ for (const ch of pattern) {
134
+ if (ch === '{') { depth++; }
135
+ if (ch === '}') { depth--; }
136
+ if (depth < 0) { return 'Unbalanced braces in pattern'; }
137
+ }
138
+ if (depth !== 0) { return 'Unbalanced braces in pattern'; }
139
+ return true;
140
+ };
141
+
142
+ const parseCustomBranchFormat = (pattern) => {
143
+ const regex = /\{([^}]+)\}|\b(type|scope|description)\b/gi;
144
+ const segments = [];
145
+ let lastIndex = 0;
146
+ let match;
147
+
148
+ while ((match = regex.exec(pattern)) !== null) {
149
+ if (match.index > lastIndex) {
150
+ segments.push({ type: 'literal', value: pattern.slice(lastIndex, match.index) });
151
+ }
152
+
153
+ if (match[1] !== undefined) {
154
+ segments.push({ type: 'field', label: match[1] });
155
+ } else {
156
+ segments.push({
157
+ type: 'keyword',
158
+ keyword: match[2].toLowerCase(),
159
+ case: detectCasing(match[2]),
160
+ });
161
+ }
162
+
163
+ lastIndex = match.index + match[0].length;
164
+ }
165
+
166
+ if (lastIndex < pattern.length) {
167
+ segments.push({ type: 'literal', value: pattern.slice(lastIndex) });
168
+ }
169
+
170
+ return segments;
171
+ };
172
+
173
+ const validateCustomBranchFormatPattern = (pattern) => {
174
+ if (!pattern || !pattern.trim()) {
175
+ return 'Pattern cannot be empty';
176
+ }
177
+ if (!/\btype\b/i.test(pattern)) {
178
+ return 'Pattern must contain the "type" keyword';
179
+ }
180
+ if (!/\bdescription\b/i.test(pattern)) {
181
+ return 'Pattern must contain the "description" keyword';
182
+ }
183
+ // Check balanced braces
184
+ let depth = 0;
185
+ for (const ch of pattern) {
186
+ if (ch === '{') { depth++; }
187
+ if (ch === '}') { depth--; }
188
+ if (depth < 0) { return 'Unbalanced braces in pattern'; }
189
+ }
190
+ if (depth !== 0) { return 'Unbalanced braces in pattern'; }
191
+ // Validate literal parts (separators) are branch-safe
192
+ const segments = parseCustomBranchFormat(pattern);
193
+ const invalidBranchChars = /[~^:?*[\\\s]/;
194
+ for (const seg of segments) {
195
+ if (seg.type !== 'literal') { continue; }
196
+ if (invalidBranchChars.test(seg.value)) {
197
+ return 'Pattern contains characters invalid in branch names (spaces, ~, ^, :, ?, *, [, \\)';
198
+ }
199
+ if (seg.value.includes('..')) {
200
+ return 'Pattern cannot contain ".." (invalid in branch names)';
201
+ }
202
+ if (seg.value.includes('//')) {
203
+ return 'Pattern cannot contain "//" (invalid in branch names)';
204
+ }
205
+ }
206
+ return true;
207
+ };
208
+
209
+ const getCustomBranchFields = (pattern) => getCustomFields(pattern);
210
+
211
+ const sanitizeBranchPart = (value) => {
212
+ return value
213
+ .replace(/\s+/g, '-')
214
+ .replace(/[^a-zA-Z0-9-]/g, '')
215
+ .replace(/-+/g, '-')
216
+ .replace(/^-|-$/g, '');
217
+ };
218
+
219
+ const formatCustomBranchName = (type, description, segments, scope, customFieldValues = {}) => {
220
+ return segments.map(seg => {
221
+ if (seg.type === 'literal') { return seg.value; }
222
+ if (seg.type === 'field') { return sanitizeBranchPart(customFieldValues[seg.label] || ''); }
223
+ if (seg.type === 'keyword') {
224
+ switch (seg.keyword) {
225
+ case 'type': return applyCasing(type, seg.case);
226
+ case 'scope': return applyCasing(scope || '', seg.case);
227
+ case 'description': return applyCasing(sanitizeBranchPart(description), seg.case);
228
+ }
229
+ }
230
+ return '';
231
+ }).join('');
232
+ };
233
+
234
+ const formatCustomCommitTitle = (type, description, segments, scope, customFieldValues = {}) => {
235
+ return segments.map(seg => {
236
+ if (seg.type === 'literal') { return seg.value; }
237
+ if (seg.type === 'field') { return customFieldValues[seg.label] || ''; }
238
+ if (seg.type === 'keyword') {
239
+ switch (seg.keyword) {
240
+ case 'type': return applyCasing(type, seg.case);
241
+ case 'scope': return applyCasing(scope || '', seg.case);
242
+ case 'description': return applyCasing(description, seg.case);
243
+ }
244
+ }
245
+ return '';
246
+ }).join('');
247
+ };
248
+
249
+ const formatCommitTitle = (type, title, format, scope = '*', customFormat, customFieldValues) => {
57
250
  // Handle empty title
58
251
  if (!title || title.trim().length === 0) {
59
252
  return '';
@@ -61,6 +254,11 @@ const formatCommitTitle = (type, title, format, scope = '*') => {
61
254
 
62
255
  const trimmedTitle = title.trim();
63
256
 
257
+ if (format === 'custom' && customFormat) {
258
+ const segments = parseCustomFormat(customFormat);
259
+ return formatCustomCommitTitle(type, trimmedTitle, segments, scope, customFieldValues);
260
+ }
261
+
64
262
  switch (format) {
65
263
  case 1:
66
264
  default:
@@ -104,29 +302,43 @@ const log = (message, type) => {
104
302
  case 'warning':
105
303
  msg = yellow(msg);
106
304
  break;
305
+ case 'debug':
306
+ msg = magenta(msg);
307
+ break;
107
308
  }
108
309
  console.log(`${date} ${type === 'error' ? red(msg) : (type === 'success' ? green(msg) : msg)}`);
109
310
  };
110
311
 
111
- const validBranchDescription = (description, maxLength) => {
112
- if (description.length < 1) {
113
- return 'Branch description cannot be empty';
114
- }
115
- if (description.length > maxLength) {
116
- return `Branch description too long (max ${maxLength} characters)`;
312
+ const validBranchCustomField = (value, label) => {
313
+ if (!value || value.trim().length < 1) {
314
+ return `${label} cannot be empty`;
117
315
  }
118
316
  const invalidChars = /[~^:?*[\\\s]/;
119
- if (invalidChars.test(description)) {
120
- return 'Branch description contains invalid characters (spaces, ~, ^, :, ?, *, [, \\)';
317
+ if (invalidChars.test(value)) {
318
+ return `${label} contains invalid characters (spaces, ~, ^, :, ?, *, [, \\)`;
121
319
  }
122
- if (description.startsWith('.') || description.startsWith('-') ||
123
- description.endsWith('.') || description.endsWith('-')) {
124
- return 'Branch description cannot start or end with . or -';
320
+ if (value.startsWith('.') || value.startsWith('-') ||
321
+ value.endsWith('.') || value.endsWith('-')) {
322
+ return `${label} cannot start or end with . or -`;
125
323
  }
126
324
  return true;
127
325
  };
128
326
 
129
- const formatBranchName = (type, description, format, scope = null) => {
327
+ const validBranchDescription = (description, maxLength) => {
328
+ const base = validBranchCustomField(description, 'Branch description');
329
+ if (base !== true) { return base; }
330
+ if (description.length > maxLength) {
331
+ return `Branch description too long (max ${maxLength} characters)`;
332
+ }
333
+ return true;
334
+ };
335
+
336
+ const formatBranchName = (type, description, format, scope = null, customBranchFormat = null, customFieldValues = {}) => {
337
+ if (format === 'custom' && customBranchFormat) {
338
+ const segments = parseCustomBranchFormat(customBranchFormat);
339
+ return formatCustomBranchName(type, description, segments, scope, customFieldValues);
340
+ }
341
+
130
342
  const cleanDescription = description
131
343
  .toLowerCase()
132
344
  .replace(/\s+/g, '-')
@@ -159,6 +371,167 @@ const checkBranchExists = (branchName) => {
159
371
  }
160
372
  };
161
373
 
374
+ /** Parse and validate commit title format, auto-correct case */
375
+ const parseAndNormalizeCommitTitle = (title, config, customFieldValues = {}) => {
376
+ // Custom format parsing
377
+ if (config.format === 'custom' && config.customFormat) {
378
+ const segments = parseCustomFormat(config.customFormat);
379
+
380
+ // Build dynamic regex from segments
381
+ const captureNames = [];
382
+ let regexParts = [];
383
+ const captureSegments = segments.filter(s => s.type === 'keyword' || s.type === 'field');
384
+
385
+ for (let i = 0; i < segments.length; i++) {
386
+ const seg = segments[i];
387
+ if (seg.type === 'literal') {
388
+ regexParts.push(seg.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
389
+ } else if (seg.type === 'keyword' || seg.type === 'field') {
390
+ const isLast = captureSegments.indexOf(seg) === captureSegments.length - 1;
391
+ captureNames.push(seg);
392
+ regexParts.push(isLast ? '(.+)' : '(.+?)');
393
+ }
394
+ }
395
+
396
+ const dynamicRegex = new RegExp('^' + regexParts.join('') + '$');
397
+ const match = title.match(dynamicRegex);
398
+
399
+ if (!match) {
400
+ const exampleTitle = formatCommitTitle(
401
+ config.types[0].value, 'description', config.format,
402
+ config.scopes?.[0]?.value, config.customFormat, customFieldValues
403
+ );
404
+ return { error: `Wrong format. Expected: "${exampleTitle}"` };
405
+ }
406
+
407
+ let type, scope, message;
408
+ const fieldValues = {};
409
+
410
+ for (let i = 0; i < captureNames.length; i++) {
411
+ const seg = captureNames[i];
412
+ const val = match[i + 1].trim();
413
+ if (seg.type === 'keyword') {
414
+ switch (seg.keyword) {
415
+ case 'type': type = val; break;
416
+ case 'scope': scope = val; break;
417
+ case 'description': message = val; break;
418
+ }
419
+ } else if (seg.type === 'field') {
420
+ fieldValues[seg.label] = val;
421
+ }
422
+ }
423
+
424
+ // Validate type
425
+ if (!type) {
426
+ return { error: 'Could not detect type in commit title' };
427
+ }
428
+ const validType = config.types.find(t => t.value.toLowerCase() === type.toLowerCase());
429
+ if (!validType) {
430
+ const validTypes = config.types.map(t => t.value).join(', ');
431
+ return { error: `Invalid type "${type}". Valid types: ${validTypes}` };
432
+ }
433
+
434
+ // Validate scope if present
435
+ let validScope = scope;
436
+ if (scope) {
437
+ if (!config.scopes || config.scopes.length === 0) {
438
+ return { error: 'Scope not allowed in current format configuration' };
439
+ }
440
+ const foundScope = config.scopes.find(s => s.value.toLowerCase() === scope.toLowerCase());
441
+ if (!foundScope) {
442
+ const validScopes = config.scopes.map(s => s.value).join(', ');
443
+ return { error: `Invalid scope "${scope}". Valid scopes: ${validScopes}` };
444
+ }
445
+ validScope = foundScope.value;
446
+ }
447
+
448
+ const normalized = formatCustomCommitTitle(
449
+ validType.value, message, segments, validScope, fieldValues
450
+ );
451
+ return { normalized };
452
+ }
453
+
454
+ let type, scope, message, detectedFormatGroup;
455
+
456
+ // Try different format patterns
457
+ const format7_8 = /^([^(]+)\(([^)]+)\):\s*(.+)$/; // type(scope): message
458
+ const format5_6 = /^([^(]+)\(([^)]+)\)\s+(.+)$/; // type(scope) message
459
+ const format3_4 = /^([^:]+):\s*(.+)$/; // type: message
460
+ const format1_2 = /^\(([^)]+)\)\s+(.+)$/; // (type) message
461
+
462
+ let match;
463
+
464
+ if ((match = title.match(format7_8))) {
465
+ [, type, scope, message] = match;
466
+ detectedFormatGroup = 'type(scope):';
467
+ } else if ((match = title.match(format5_6))) {
468
+ [, type, scope, message] = match;
469
+ detectedFormatGroup = 'type(scope)';
470
+ } else if ((match = title.match(format3_4))) {
471
+ [, type, message] = match;
472
+ detectedFormatGroup = 'type:';
473
+ } else if ((match = title.match(format1_2))) {
474
+ [, type, message] = match;
475
+ detectedFormatGroup = '(type)';
476
+ } else {
477
+ return { error: 'Invalid commit format. Expected format with type prefix.' };
478
+ }
479
+
480
+ // Verify format matches config
481
+ let expectedFormatGroup;
482
+ if (config.format >= 7) {
483
+ expectedFormatGroup = 'type(scope):';
484
+ } else if (config.format >= 5) {
485
+ expectedFormatGroup = 'type(scope)';
486
+ } else if (config.format >= 3) {
487
+ expectedFormatGroup = 'type:';
488
+ } else {
489
+ expectedFormatGroup = '(type)';
490
+ }
491
+
492
+ if (detectedFormatGroup !== expectedFormatGroup) {
493
+ const exampleTitle = formatCommitTitle(
494
+ config.types[0].value,
495
+ 'description',
496
+ config.format,
497
+ config.scopes?.[0]?.value
498
+ );
499
+ return { error: `Wrong format. Expected: "${exampleTitle}"` };
500
+ }
501
+
502
+ type = type.trim();
503
+ message = message.trim();
504
+ if (scope) {
505
+ scope = scope.trim();
506
+ }
507
+
508
+ // Validate type exists (case-insensitive)
509
+ const validType = config.types.find(t => t.value.toLowerCase() === type.toLowerCase());
510
+ if (!validType) {
511
+ const validTypes = config.types.map(t => t.value).join(', ');
512
+ return { error: `Invalid type "${type}". Valid types: ${validTypes}` };
513
+ }
514
+
515
+ // Validate scope if present (case-insensitive)
516
+ let validScope = scope;
517
+ if (scope) {
518
+ if (!config.scopes || config.scopes.length === 0) {
519
+ return { error: 'Scope not allowed in current format configuration' };
520
+ }
521
+ const foundScope = config.scopes.find(s => s.value.toLowerCase() === scope.toLowerCase());
522
+ if (!foundScope) {
523
+ const validScopes = config.scopes.map(s => s.value).join(', ');
524
+ return { error: `Invalid scope "${scope}". Valid scopes: ${validScopes}` };
525
+ }
526
+ validScope = foundScope.value;
527
+ }
528
+
529
+ // Re-format with correct case
530
+ const normalized = formatCommitTitle(validType.value, message, config.format, validScope);
531
+
532
+ return { normalized };
533
+ };
534
+
162
535
 
163
536
  export {
164
537
  askForVersion,
@@ -167,10 +540,21 @@ export {
167
540
  validCommitTitle,
168
541
  validCommitTitleSetupLength,
169
542
  validBranchDescription,
543
+ validBranchCustomField,
170
544
  validVersion,
171
545
  formatCommitTitle,
172
546
  formatBranchName,
173
547
  checkBranchExists,
548
+ parseAndNormalizeCommitTitle,
549
+ parseCustomFormat,
550
+ customFormatHasScope,
551
+ getCustomFields,
552
+ validateCustomFormatPattern,
553
+ parseCustomBranchFormat,
554
+ validateCustomBranchFormatPattern,
555
+ formatCustomBranchName,
556
+ customBranchFormatHasScope,
557
+ getCustomBranchFields,
174
558
  handleCmdExec,
175
559
  log,
176
560
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "format-commit",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Lightweight CLI to standardize commit messages with AI-powered suggestions",
5
5
  "license": "MIT",
6
6
  "author": "Thomas BARKATS",