cli-changescribe 0.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/src/commit.js ADDED
@@ -0,0 +1,714 @@
1
+ const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { config } = require('dotenv');
6
+ const Groq = require('groq-sdk').default;
7
+
8
+ // Load environment variables from .env.local
9
+ config({ path: '.env.local' });
10
+
11
+ // Regex patterns for cleaning commit messages
12
+ const OPENING_CODE_BLOCK_REGEX = /^```[\s\S]*?\n/;
13
+ const CLOSING_CODE_BLOCK_REGEX = /\n```$/;
14
+ const NEWLINE_SPLIT_RE = /\r?\n/;
15
+ const BULLET_LINE_RE = /^[-*]\s+/;
16
+ const CODE_FILE_EXTENSION_RE = /\.(ts|tsx|js|jsx|css|json|md)$/;
17
+ const TITLE_RE =
18
+ /^(?<type>chore|deprecate|feat|fix|release)(?<breaking>!)?:\s*(?<subject>.+)$/gim;
19
+ const TITLE_VALIDATION_RE = /^(chore|deprecate|feat|fix|release)!?:\s+/;
20
+
21
+ /**
22
+ * Analyze all code changes comprehensively for AI review
23
+ */
24
+ function analyzeAllChanges() {
25
+ console.log('🔍 Analyzing all code changes...');
26
+
27
+ // Use larger buffer for git commands that may produce large output
28
+ const LARGE_BUFFER_SIZE = 10 * 1024 * 1024; // 10MB
29
+
30
+ // Check if there are any changes to analyze
31
+ let gitStatus;
32
+ try {
33
+ gitStatus = execSync('git status --porcelain', {
34
+ encoding: 'utf8',
35
+ maxBuffer: LARGE_BUFFER_SIZE,
36
+ });
37
+ if (!gitStatus.trim()) {
38
+ return { hasChanges: false };
39
+ }
40
+ } catch (error) {
41
+ throw new Error(`Failed to get git status: ${error.message}`);
42
+ }
43
+
44
+ // Stage all changes if nothing is staged
45
+ let gitDiff = execSync('git diff --staged --name-status', {
46
+ encoding: 'utf8',
47
+ maxBuffer: LARGE_BUFFER_SIZE,
48
+ });
49
+ if (!gitDiff.trim()) {
50
+ console.log('📝 Staging all changes for analysis...');
51
+ execSync('git add .', {
52
+ encoding: 'utf8',
53
+ maxBuffer: LARGE_BUFFER_SIZE,
54
+ });
55
+ gitDiff = execSync('git diff --staged --name-status', {
56
+ encoding: 'utf8',
57
+ maxBuffer: LARGE_BUFFER_SIZE,
58
+ });
59
+ }
60
+
61
+ // Get list of modified files first (needed for other operations)
62
+ const modifiedFiles = execSync('git diff --staged --name-only', {
63
+ encoding: 'utf8',
64
+ maxBuffer: LARGE_BUFFER_SIZE,
65
+ })
66
+ .trim()
67
+ .split('\n')
68
+ .filter(Boolean);
69
+
70
+ // Try to get detailed diff, but handle buffer overflow gracefully
71
+ let detailedDiff = '';
72
+ try {
73
+ // Use minimal context (U3) and limit to text files to reduce size
74
+ detailedDiff = execSync('git diff --staged -U3 --diff-filter=ACMRT', {
75
+ encoding: 'utf8',
76
+ maxBuffer: LARGE_BUFFER_SIZE,
77
+ });
78
+ // Truncate if still too large (keep first 5MB)
79
+ if (detailedDiff.length > 5 * 1024 * 1024) {
80
+ detailedDiff = `${detailedDiff.slice(0, 5 * 1024 * 1024)}\n...[truncated due to size]...`;
81
+ }
82
+ } catch (error) {
83
+ if (
84
+ error.message.includes('ENOBUFS') ||
85
+ error.message.includes('maxBuffer')
86
+ ) {
87
+ console.log('⚠️ Diff too large, using summary only');
88
+ // Fallback: get diff stats only
89
+ detailedDiff =
90
+ 'Diff too large to include. See file changes summary above.';
91
+ } else {
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ // Get comprehensive change information
97
+ const changes = {
98
+ // Summary of file changes
99
+ fileChanges: gitDiff,
100
+
101
+ // Detailed code diff (may be truncated or empty if too large)
102
+ detailedDiff,
103
+
104
+ // List of modified files
105
+ modifiedFiles,
106
+
107
+ // Get diff stats (additions/deletions)
108
+ diffStats: execSync('git diff --staged --stat', {
109
+ encoding: 'utf8',
110
+ maxBuffer: LARGE_BUFFER_SIZE,
111
+ }),
112
+
113
+ // Get commit info context
114
+ lastCommit: execSync('git log -1 --oneline', {
115
+ encoding: 'utf8',
116
+ maxBuffer: LARGE_BUFFER_SIZE,
117
+ }).trim(),
118
+
119
+ // Branch information
120
+ currentBranch: execSync('git branch --show-current', {
121
+ encoding: 'utf8',
122
+ maxBuffer: LARGE_BUFFER_SIZE,
123
+ }).trim(),
124
+ };
125
+
126
+ // Analyze each modified file individually for better context
127
+ const fileAnalysis = [];
128
+ for (const file of changes.modifiedFiles.slice(0, 10)) {
129
+ // Limit to 10 files and skip binary/large files
130
+ const fileType = getFileType(file);
131
+ if (fileType === 'Code' && !CODE_FILE_EXTENSION_RE.test(file)) {
132
+ continue; // Skip non-code files
133
+ }
134
+
135
+ try {
136
+ const fileDiff = execSync(`git diff --staged -U3 -- "${file}"`, {
137
+ encoding: 'utf8',
138
+ maxBuffer: 1024 * 1024, // 1MB per file
139
+ });
140
+ fileAnalysis.push({
141
+ file,
142
+ changes: fileDiff.slice(0, 2000), // Limit per file for API
143
+ type: fileType,
144
+ });
145
+ } catch (error) {
146
+ if (
147
+ error.message.includes('ENOBUFS') ||
148
+ error.message.includes('maxBuffer')
149
+ ) {
150
+ // File too large, skip it
151
+ continue;
152
+ }
153
+ console.error(`⚠️ Failed to analyze file ${file}:`, error.message);
154
+ }
155
+ }
156
+
157
+ return {
158
+ hasChanges: true,
159
+ summary: {
160
+ totalFiles: changes.modifiedFiles.length,
161
+ stats: changes.diffStats,
162
+ branch: changes.currentBranch,
163
+ lastCommit: changes.lastCommit,
164
+ },
165
+ fileChanges: changes.fileChanges,
166
+ detailedDiff: changes.detailedDiff,
167
+ fileAnalysis,
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Determine file type for better AI analysis
173
+ */
174
+ function getFileType(filename) {
175
+ const ext = filename.split('.').pop()?.toLowerCase();
176
+ const typeMap = {
177
+ tsx: 'React TypeScript Component',
178
+ ts: 'TypeScript',
179
+ jsx: 'React JavaScript Component',
180
+ js: 'JavaScript',
181
+ css: 'Stylesheet',
182
+ json: 'Configuration',
183
+ md: 'Documentation',
184
+ yml: 'Configuration',
185
+ yaml: 'Configuration',
186
+ };
187
+ return typeMap[ext] || 'Code';
188
+ }
189
+
190
+ /**
191
+ * Generate an AI-powered commit message based on git changes
192
+ */
193
+ async function generateCommitMessage(argv) {
194
+ try {
195
+ const isDryRun = argv.includes('--dry-run');
196
+ // Check if GROQ_API_KEY is set
197
+ if (!process.env.GROQ_API_KEY || process.env.GROQ_API_KEY === '') {
198
+ console.error('❌ GROQ_API_KEY environment variable is required');
199
+ console.log('💡 Get your API key from: https://console.groq.com/keys');
200
+ console.log('💡 Set it in .env.local file: GROQ_API_KEY="your-key-here"');
201
+ process.exit(1);
202
+ }
203
+
204
+ // Initialize Groq client
205
+ const groq = new Groq({
206
+ apiKey: process.env.GROQ_API_KEY,
207
+ });
208
+
209
+ // Get comprehensive analysis of all changes
210
+ let changeAnalysis;
211
+ try {
212
+ changeAnalysis = analyzeAllChanges();
213
+ } catch (error) {
214
+ console.error('❌ Failed to analyze changes:', error.message);
215
+ process.exit(1);
216
+ }
217
+
218
+ if (!changeAnalysis.hasChanges) {
219
+ console.log('✅ No changes to commit');
220
+ return;
221
+ }
222
+
223
+ console.log('🤖 Generating commit message with AI...');
224
+
225
+ // Generate commit message using Groq with comprehensive analysis
226
+ const completion = await createCompletionSafe(
227
+ groq,
228
+ buildChatMessages(changeAnalysis),
229
+ 'openai/gpt-oss-120b'
230
+ );
231
+
232
+ const rawContent = completion?.choices?.[0]?.message?.content
233
+ ?.trim()
234
+ .replace(OPENING_CODE_BLOCK_REGEX, '') // Remove opening code block
235
+ .replace(CLOSING_CODE_BLOCK_REGEX, '') // Remove closing code block
236
+ .trim();
237
+
238
+ // Some models put useful text in a nonstandard `reasoning` field
239
+ const reasoning =
240
+ completion?.choices?.[0]?.message?.reasoning?.toString?.().trim?.() ?? '';
241
+
242
+ // Build a robust Conventional Commit from either content or reasoning
243
+ let built = buildConventionalCommit(rawContent || '', reasoning || '');
244
+
245
+ if (!built) {
246
+ logCompletionFailureAndExit(completion);
247
+ }
248
+
249
+ const violations = findCommitViolations(built);
250
+ if (violations.length > 0) {
251
+ const repairCompletion = await createCompletionSafe(
252
+ groq,
253
+ buildRepairMessages(rawContent || '', reasoning || '', built, violations),
254
+ 'openai/gpt-oss-120b'
255
+ );
256
+ const repairedContent = repairCompletion?.choices?.[0]?.message?.content
257
+ ?.trim()
258
+ .replace(OPENING_CODE_BLOCK_REGEX, '')
259
+ .replace(CLOSING_CODE_BLOCK_REGEX, '')
260
+ .trim();
261
+ const repairedReasoning =
262
+ repairCompletion?.choices?.[0]?.message?.reasoning
263
+ ?.toString?.()
264
+ .trim?.() ?? '';
265
+ built = buildConventionalCommit(repairedContent || '', repairedReasoning || '');
266
+ if (!built) {
267
+ logCompletionFailureAndExit(repairCompletion);
268
+ }
269
+ const remaining = findCommitViolations(built);
270
+ if (remaining.length > 0) {
271
+ console.error('❌ Commit message failed validation:');
272
+ console.error(formatViolations(remaining));
273
+ process.exit(1);
274
+ }
275
+ }
276
+
277
+ console.log(`✨ Generated commit message: "${built.title}"`);
278
+ if (isDryRun) {
279
+ const preview = buildFullMessage(built);
280
+ console.log('\n--- Commit message preview (dry run) ---');
281
+ console.log(preview);
282
+ return;
283
+ }
284
+
285
+ // Write full commit message to a temporary file to avoid shell quoting issues
286
+ const tmpFile = path.join(
287
+ os.tmpdir(),
288
+ `commit-msg-${Date.now().toString()}.txt`
289
+ );
290
+ const fullMessage = buildFullMessage(built);
291
+ fs.writeFileSync(tmpFile, fullMessage, 'utf8');
292
+
293
+ // Commit using -F to read message from file
294
+ try {
295
+ execSync(`git commit -F "${tmpFile}"`, { encoding: 'utf8' });
296
+ console.log('✅ Changes committed successfully');
297
+ } catch (error) {
298
+ console.error('❌ Failed to commit changes:', error.message);
299
+ process.exit(1);
300
+ }
301
+ // Cleanup temp file (best-effort)
302
+ try {
303
+ fs.unlinkSync(tmpFile);
304
+ } catch {
305
+ // ignore cleanup errors
306
+ }
307
+
308
+ // Push to remote
309
+ try {
310
+ const currentBranch = execSync('git branch --show-current', {
311
+ encoding: 'utf8',
312
+ }).trim();
313
+ execSync(`git push origin ${currentBranch}`, { encoding: 'utf8' });
314
+ console.log(`🚀 Changes pushed to origin/${currentBranch}`);
315
+ } catch (error) {
316
+ console.error('❌ Failed to push changes:', error.message);
317
+ console.log('💡 You may need to push manually with: git push');
318
+ process.exit(1);
319
+ }
320
+ } catch (error) {
321
+ console.error('❌ Error generating commit message:');
322
+ console.error(formatError(error));
323
+ process.exit(1);
324
+ }
325
+ }
326
+
327
+ async function createCompletionSafe(groq, messages, model) {
328
+ try {
329
+ return await groq.chat.completions.create({
330
+ messages,
331
+ model,
332
+ temperature: 0.3,
333
+ max_tokens: 16_384,
334
+ reasoning_effort: 'high',
335
+ });
336
+ } catch (error) {
337
+ console.error('❌ Groq API error while creating completion');
338
+ console.error(formatError(error));
339
+ process.exit(1);
340
+ }
341
+ }
342
+
343
+ function buildChatMessages(changeAnalysis) {
344
+ const filesBlock = changeAnalysis.fileAnalysis
345
+ .map((file) => `### ${file.file} (${file.type})\n${file.changes}`)
346
+ .join('\n\n');
347
+
348
+ // Estimate token count: ~4 characters per token, leave room for system prompt and other content
349
+ // Target: ~6000 tokens max for user content (leaving ~2000 for system prompt and overhead)
350
+ const MAX_USER_CONTENT_CHARS = 24_000; // ~6000 tokens
351
+
352
+ // Build user content
353
+ const summarySection =
354
+ 'Summary:\n' +
355
+ `- Modified ${changeAnalysis.summary.totalFiles} files\n` +
356
+ `- Branch: ${changeAnalysis.summary.branch}\n` +
357
+ `- Previous commit: ${changeAnalysis.summary.lastCommit}\n\n`;
358
+
359
+ const filesSection = `Files:\n${changeAnalysis.fileChanges}\n\n`;
360
+ const statsSection = `Stats:\n${changeAnalysis.summary.stats}\n\n`;
361
+ const detailsSection = `Details:\n${filesBlock}\n\n`;
362
+
363
+ // Calculate remaining space for diff
364
+ const usedChars =
365
+ summarySection.length +
366
+ filesSection.length +
367
+ statsSection.length +
368
+ detailsSection.length;
369
+ const diffMaxChars = Math.max(0, MAX_USER_CONTENT_CHARS - usedChars - 500); // 500 char buffer
370
+
371
+ const diffSection =
372
+ diffMaxChars > 0
373
+ ? `Full diff (truncated):\n${changeAnalysis.detailedDiff.slice(0, diffMaxChars)}\n`
374
+ : '';
375
+
376
+ const userContent =
377
+ 'Analyze these changes and produce a single conventional commit:\n\n' +
378
+ summarySection +
379
+ filesSection +
380
+ statsSection +
381
+ detailsSection +
382
+ diffSection;
383
+
384
+ return [
385
+ {
386
+ role: 'system',
387
+ content:
388
+ 'You are an AI assistant tasked with generating a git commit message based on the staged code changes provided below. Follow these rules.\n\n' +
389
+ 'Commit message format: <type>: <description>\n' +
390
+ 'Types (lowercase, required): chore, deprecate, feat, fix, release\n' +
391
+ 'Breaking changes: append ! after the type (e.g., fix!: ...)\n' +
392
+ 'Description: required, under 256 characters, imperative mood, no trailing period\n' +
393
+ 'Body: optional. If present, use exactly three bullets in this order:\n' +
394
+ '- change: ...\n' +
395
+ '- why: ...\n' +
396
+ '- risk: ...\n' +
397
+ 'Body lines must be under 256 characters each.\n' +
398
+ 'Footer: optional lines after a blank line following the body. Each line must be under 256 characters.\n' +
399
+ 'Do not include a scope.\n' +
400
+ 'Output only the final commit text, no markdown or code fences.',
401
+ },
402
+ {
403
+ role: 'user',
404
+ content: userContent,
405
+ },
406
+ ];
407
+ }
408
+
409
+ function logCompletionFailureAndExit(completion) {
410
+ console.error(
411
+ '❌ Failed to generate commit message. Raw completion payload:'
412
+ );
413
+ const raw = safeStringify(completion);
414
+ console.error(
415
+ raw.length > 10_000 ? `${raw.slice(0, 10_000)}\n...[truncated]...` : raw
416
+ );
417
+ if (completion?.usage) {
418
+ console.error('Usage:', safeStringify(completion.usage));
419
+ }
420
+ if (completion?.choices) {
421
+ console.error(
422
+ 'Choices meta:',
423
+ safeStringify(
424
+ completion.choices.map((c) => ({
425
+ finish_reason: c.finish_reason,
426
+ index: c.index,
427
+ }))
428
+ )
429
+ );
430
+ }
431
+ process.exit(1);
432
+ }
433
+
434
+ function buildConventionalCommit(content, reasoning) {
435
+ const text = sanitizeText([content, reasoning].filter(Boolean).join('\n'));
436
+ if (!text.trim()) {
437
+ return null;
438
+ }
439
+
440
+ let titleMatch = null;
441
+ for (const m of text.matchAll(TITLE_RE)) {
442
+ if (m?.groups?.subject) {
443
+ titleMatch = m;
444
+ break;
445
+ }
446
+ }
447
+
448
+ if (!titleMatch) {
449
+ return null;
450
+ }
451
+
452
+ const { type } = titleMatch.groups;
453
+ const breaking = titleMatch.groups.breaking ? '!' : '';
454
+ let subject = titleMatch.groups.subject.trim();
455
+ subject = stripWrappingQuotes(subject);
456
+
457
+ const title = `${type}${breaking}: ${subject}`;
458
+
459
+ const { body, footer } = buildStructuredBody(text);
460
+
461
+ return { title, body, footer };
462
+ }
463
+
464
+ function sanitizeText(s) {
465
+ if (!s) {
466
+ return '';
467
+ }
468
+ return s
469
+ .replace(OPENING_CODE_BLOCK_REGEX, '')
470
+ .replace(CLOSING_CODE_BLOCK_REGEX, '')
471
+ .replace(/`/g, '') // remove inline backticks to prevent shell substitutions in logs
472
+ .trim();
473
+ }
474
+
475
+ function stripWrappingQuotes(s) {
476
+ if (!s) {
477
+ return s;
478
+ }
479
+ // remove wrapping single or double quotes if present
480
+ if (
481
+ (s.startsWith('"') && s.endsWith('"')) ||
482
+ (s.startsWith("'") && s.endsWith("'"))
483
+ ) {
484
+ return s.slice(1, -1);
485
+ }
486
+ return s;
487
+ }
488
+
489
+ function buildStructuredBody(text) {
490
+ const lines = text.split(NEWLINE_SPLIT_RE);
491
+ const extracted = {
492
+ change: '',
493
+ why: '',
494
+ risk: '',
495
+ };
496
+ const footerLines = [];
497
+
498
+ for (const line of lines) {
499
+ const trimmed = line.trim();
500
+ if (!trimmed) {
501
+ continue;
502
+ }
503
+ const withoutBullet = BULLET_LINE_RE.test(trimmed)
504
+ ? trimmed.replace(BULLET_LINE_RE, '')
505
+ : trimmed;
506
+ const lower = withoutBullet.toLowerCase();
507
+ if (lower.startsWith('change:')) {
508
+ extracted.change = withoutBullet.slice('change:'.length).trim();
509
+ } else if (lower.startsWith('why:')) {
510
+ extracted.why = withoutBullet.slice('why:'.length).trim();
511
+ } else if (lower.startsWith('risk:')) {
512
+ extracted.risk = withoutBullet.slice('risk:'.length).trim();
513
+ } else if (lower.startsWith('testing:') && !extracted.risk) {
514
+ extracted.risk = `testing: ${withoutBullet.slice('testing:'.length).trim()}`;
515
+ } else if (
516
+ withoutBullet.startsWith('Footer:') ||
517
+ withoutBullet.startsWith('Refs:') ||
518
+ withoutBullet.startsWith('Ref:') ||
519
+ withoutBullet.startsWith('Fixes:') ||
520
+ withoutBullet.startsWith('BREAKING CHANGE:')
521
+ ) {
522
+ footerLines.push(withoutBullet);
523
+ }
524
+ }
525
+
526
+ const hasBody =
527
+ Boolean(extracted.change) ||
528
+ Boolean(extracted.why) ||
529
+ Boolean(extracted.risk);
530
+ if (!hasBody && footerLines.length === 0) {
531
+ return { body: '', footer: '' };
532
+ }
533
+ const change = extracted.change || '(not provided)';
534
+ const why = extracted.why || '(not provided)';
535
+ const risk = extracted.risk || '(not provided)';
536
+
537
+ const body = [`- change: ${change}`, `- why: ${why}`, `- risk: ${risk}`].join(
538
+ '\n'
539
+ );
540
+ const footer = footerLines.join('\n');
541
+ return { body, footer };
542
+ }
543
+
544
+ function buildRepairMessages(content, reasoning, built, violations) {
545
+ const issueList = violations.map((v) => `- ${v}`).join('\n');
546
+ const raw = sanitizeText([content, reasoning].filter(Boolean).join('\n'));
547
+ return [
548
+ {
549
+ role: 'system',
550
+ content:
551
+ 'You fix commit messages to match these rules exactly.\n\n' +
552
+ 'Commit message format: <type>: <description>\n' +
553
+ 'Types (lowercase, required): chore, deprecate, feat, fix, release\n' +
554
+ 'Breaking changes: append ! after the type (e.g., fix!: ...)\n' +
555
+ 'Description: required, under 256 characters, imperative mood, no trailing period\n' +
556
+ 'Body: optional. If present, use exactly three bullets in this order:\n' +
557
+ '- change: ...\n' +
558
+ '- why: ...\n' +
559
+ '- risk: ...\n' +
560
+ 'Body lines must be under 256 characters each.\n' +
561
+ 'Footer: optional lines after a blank line following the body. Each line must be under 256 characters.\n' +
562
+ 'Do not include a scope.\n' +
563
+ 'Output only the final commit text, no markdown or code fences.',
564
+ },
565
+ {
566
+ role: 'user',
567
+ content: [
568
+ 'Violations:',
569
+ issueList,
570
+ '',
571
+ 'Current commit:',
572
+ buildFullMessage(built),
573
+ '',
574
+ 'Original model output (for context):',
575
+ raw || '(not provided)',
576
+ ].join('\n'),
577
+ },
578
+ ];
579
+ }
580
+
581
+ function findCommitViolations(built) {
582
+ const violations = [];
583
+ const title = built.title || '';
584
+ if (!title) {
585
+ violations.push('title is missing');
586
+ }
587
+ if (title.length > 256) {
588
+ violations.push('title exceeds 256 characters');
589
+ }
590
+ if (!TITLE_VALIDATION_RE.test(title)) {
591
+ violations.push('title does not match required type format');
592
+ }
593
+ if (title.includes('(')) {
594
+ violations.push('title includes a scope');
595
+ }
596
+ const subject = title.split(':').slice(1).join(':').trim();
597
+ if (!subject) {
598
+ violations.push('description is empty');
599
+ }
600
+ if (subject.endsWith('.')) {
601
+ violations.push('description ends with a period');
602
+ }
603
+
604
+ if (built.body) {
605
+ const lines = built.body.split(NEWLINE_SPLIT_RE).filter(Boolean);
606
+ if (lines.length !== 3) {
607
+ violations.push('body must have exactly three bullets');
608
+ }
609
+ const expectedPrefixes = ['- change:', '- why:', '- risk:'];
610
+ for (const [index, line] of lines.entries()) {
611
+ if (line.length > 256) {
612
+ violations.push('body line exceeds 256 characters');
613
+ break;
614
+ }
615
+ const expected = expectedPrefixes[index];
616
+ if (!(expected && line.startsWith(expected))) {
617
+ violations.push('body bullets must be change/why/risk in order');
618
+ break;
619
+ }
620
+ }
621
+ }
622
+
623
+ if (built.footer) {
624
+ const footerLines = built.footer.split(NEWLINE_SPLIT_RE).filter(Boolean);
625
+ for (const line of footerLines) {
626
+ if (line.length > 256) {
627
+ violations.push('footer line exceeds 256 characters');
628
+ break;
629
+ }
630
+ }
631
+ }
632
+
633
+ return violations;
634
+ }
635
+
636
+ function formatViolations(violations) {
637
+ return violations.map((violation) => `- ${violation}`).join('\n');
638
+ }
639
+
640
+ function buildFullMessage(built) {
641
+ const sections = [built.title];
642
+ if (built.body) {
643
+ sections.push(built.body);
644
+ }
645
+ if (built.footer) {
646
+ sections.push(built.footer);
647
+ }
648
+ return sections.join('\n\n');
649
+ }
650
+
651
+ function safeStringify(obj) {
652
+ try {
653
+ const seen = new WeakSet();
654
+ return JSON.stringify(
655
+ obj,
656
+ (_key, value) => {
657
+ if (typeof value === 'object' && value !== null) {
658
+ if (seen.has(value)) {
659
+ return '[Circular]';
660
+ }
661
+ seen.add(value);
662
+ }
663
+ if (typeof value === 'bigint') {
664
+ return value.toString();
665
+ }
666
+ return value;
667
+ },
668
+ 2
669
+ );
670
+ } catch {
671
+ // Fallback to best-effort string conversion when JSON serialization fails
672
+ try {
673
+ return String(obj);
674
+ } catch {
675
+ return '[Unstringifiable]';
676
+ }
677
+ }
678
+ }
679
+
680
+ function formatError(error) {
681
+ // Try to include as much structured information as possible
682
+ const plain = {};
683
+ for (const key of Object.getOwnPropertyNames(error)) {
684
+ // include standard fields: name, message, stack, cause, status, code, response
685
+ // avoid huge nested response bodies without control
686
+ if (key === 'response' && error.response) {
687
+ plain.response = {
688
+ status: error.response.status,
689
+ statusText: error.response.statusText,
690
+ headers: error.response.headers || undefined,
691
+ data: error.response.data || undefined,
692
+ };
693
+ } else {
694
+ try {
695
+ // Copy other enumerable properties safely
696
+ // eslint-disable-next-line no-param-reassign
697
+ plain[key] = error[key];
698
+ } catch {
699
+ // ignore non-readable props
700
+ }
701
+ }
702
+ }
703
+ return safeStringify(plain);
704
+ }
705
+
706
+ async function runCommit(argv = process.argv.slice(2)) {
707
+ await generateCommitMessage(argv);
708
+ }
709
+
710
+ if (require.main === module) {
711
+ runCommit();
712
+ }
713
+
714
+ module.exports = { runCommit };