@entro314labs/ai-changelog-generator
Version:
AI-powered changelog generator with MCP server support - works with most providers, online and local models
1,373 lines (1,363 loc) • 95.5 kB
JavaScript
/**
* Consolidated Utility Functions
*
* Provides comprehensive utility functions for AI changelog generation:
* - Data manipulation and conversion utilities
* - Format and presentation utilities
* - File analysis and categorization
* - Text processing and analysis
* - Commit analysis and changelog generation
*
* For advanced JSON operations with error detection, use JsonUtils from './json-utils.js'
* For specialized error handling, use error classes from './error-classes.js'
*/
import { execSync } from 'node:child_process';
import fs from 'node:fs';
import colors from '../constants/colors.js';
import { DiffProcessor } from './diff-processor.js';
import { AbstractMethodError, AIChangelogError, ProviderError } from './error-classes.js';
import JsonUtils from './json-utils.js';
// Note: Error classes have been moved to './error-classes.js' to avoid duplication
// and provide better organization. Import them from there instead of defining here.
export { AbstractMethodError, AIChangelogError, ProviderError };
// ========================================
// DATA MANIPULATION UTILITIES
// ========================================
/**
* Convert Sets to Arrays for JSON serialization
*/
export function convertSetsToArrays(obj) {
if (obj === null || typeof obj !== 'object') {
return obj;
}
if (obj instanceof Set) {
return Array.from(obj);
}
if (Array.isArray(obj)) {
return obj.map(convertSetsToArrays);
}
const result = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = convertSetsToArrays(value);
}
return result;
}
/**
* Enhanced conventional commit parsing
* Based on git-conventional-commits patterns with breaking change detection
*/
export function extractCommitScope(message) {
// Enhanced regex to match conventional commits format: type(scope)!: description
const conventionalMatch = message.match(/^(?<type>\w+)(?:\((?<scope>[^()]+)\))?(?<breaking>!)?:\s*(?<description>.+)/);
if (conventionalMatch) {
const { type, scope, breaking, description } = conventionalMatch.groups;
return {
type,
scope: scope || null,
description: description.trim(),
breaking: !!breaking,
isConventional: true,
};
}
// Fallback for non-conventional commits
return {
type: null,
scope: null,
description: message.trim(),
breaking: false,
isConventional: false,
};
}
/**
* Parse conventional commit message with full body analysis
*/
export function parseConventionalCommit(subject, body = '') {
const parsed = extractCommitScope(subject);
const fullMessage = `${subject}\n\n${body}`.trim();
// Enhanced breaking change detection from body
const breakingChanges = [];
// Check for BREAKING CHANGE: footer
const breakingMatch = fullMessage.match(/BREAKING CHANGE:\s*(.*?)(?:\n\n|\n[A-Z]|\n*$)/s);
if (breakingMatch) {
breakingChanges.push(breakingMatch[1].trim());
parsed.breaking = true;
}
// Check for BREAKING-CHANGE: footer (alternative format)
const breakingAltMatch = fullMessage.match(/BREAKING-CHANGE:\s*(.*?)(?:\n\n|\n[A-Z]|\n*$)/s);
if (breakingAltMatch) {
breakingChanges.push(breakingAltMatch[1].trim());
parsed.breaking = true;
}
// Extract issue references (GitHub/GitLab format)
const issueRefs = [];
const issueMatches = fullMessage.match(/#[0-9]+/g);
if (issueMatches) {
issueRefs.push(...issueMatches);
}
// Extract closes references
const closesMatches = fullMessage.match(/(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\s*#?([0-9]+)/gi);
const closesRefs = [];
if (closesMatches) {
closesMatches.forEach((match) => {
const num = match.match(/([0-9]+)/);
if (num) {
closesRefs.push(`#${num[1]}`);
}
});
}
return {
...parsed,
breakingChanges,
issueReferences: [...new Set(issueRefs)],
closesReferences: [...new Set(closesRefs)],
body: body.trim(),
revert: subject.toLowerCase().startsWith('revert'),
};
}
/**
* Generate markdown link for commit hash
*/
export function markdownCommitLink(commitHash, commitUrl, shortHash = true) {
if (!commitUrl) {
return shortHash ? commitHash.substring(0, 7) : commitHash;
}
const url = commitUrl.replace('%commit%', commitHash);
const displayHash = shortHash ? commitHash.substring(0, 7) : commitHash;
return `[${displayHash}](${url})`;
}
/**
* Generate markdown link for commit range
*/
export function markdownCommitRangeLink(fromCommit, toCommit, commitRangeUrl) {
if (!commitRangeUrl) {
return `${fromCommit.substring(0, 7)}...${toCommit.substring(0, 7)}`;
}
const url = commitRangeUrl.replace('%from%', fromCommit).replace('%to%', toCommit);
return `[${fromCommit.substring(0, 7)}...${toCommit.substring(0, 7)}](${url})`;
}
/**
* Generate markdown link for issue reference
*/
export function markdownIssueLink(issueId, issueUrl) {
if (!issueUrl) {
return issueId;
}
const cleanIssueId = issueId.replace('#', '');
const url = issueUrl.replace('%issue%', cleanIssueId);
return `[${issueId}](${url})`;
}
/**
* Process issue references in text and convert to markdown links
*/
export function processIssueReferences(text, issueUrl, issueRegex) {
if (!(issueUrl && text)) {
return text;
}
return text.replace(issueRegex, (match) => {
return markdownIssueLink(match, issueUrl);
});
}
/**
* Deep merge objects
*/
export function deepMerge(target, source) {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key] || {}, source[key]);
}
else {
result[key] = source[key];
}
}
return result;
}
// ========================================
// FORMAT UTILITIES
// ========================================
/**
* Format duration in human-readable format
*/
export function formatDuration(ms) {
if (ms < 1000) {
return `${ms}ms`;
}
if (ms < 60000) {
return `${(ms / 1000).toFixed(1)}s`;
}
if (ms < 3600000) {
return `${(ms / 60000).toFixed(1)}m`;
}
return `${(ms / 3600000).toFixed(1)}h`;
}
/**
* Interactive configuration prompt (simplified)
*/
export function promptForConfig(message = 'Configure settings', defaultValue = '') {
console.log(colors.infoMessage(message));
return Promise.resolve(defaultValue);
}
/**
* Get health status color
*/
export function getHealthColor(status) {
const colorMap = {
excellent: colors.successMessage,
good: colors.successMessage,
fair: colors.warningMessage,
poor: colors.errorMessage,
critical: colors.errorMessage,
};
return colorMap[status] || colors.infoMessage;
}
/**
* Format file size
*/
export function formatFileSize(bytes) {
if (bytes === 0) {
return '0 B';
}
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${Number.parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}
/**
* Format percentage
*/
export function formatPercentage(value, total) {
if (total === 0) {
return '0%';
}
return `${((value / total) * 100).toFixed(1)}%`;
}
// ========================================
// FILE UTILITIES
// ========================================
/**
* Categorize file by path and extension
*/
export function categorizeFile(filePath) {
if (!filePath || typeof filePath !== 'string') {
return 'other';
}
const path = filePath.toLowerCase();
const ext = path.split('.').pop();
// Configuration files
if (path.includes('package.json') ||
path.includes('yarn.lock') ||
path.includes('pnpm-lock') ||
path.includes('.gitignore') ||
ext === 'toml' ||
ext === 'yaml' ||
ext === 'yml' ||
path.includes('dockerfile') ||
path.includes('.env')) {
return 'configuration';
}
// Documentation
if (ext === 'md' ||
ext === 'txt' ||
ext === 'rst' ||
path.includes('readme') ||
path.includes('changelog') ||
path.includes('/docs/') ||
path.includes('/doc/')) {
return 'documentation';
}
// Test files
if (path.includes('/test/') ||
path.includes('/tests/') ||
path.includes('__tests__') ||
path.includes('.test.') ||
path.includes('.spec.') ||
ext === 'test' ||
ext === 'spec') {
return 'tests';
}
// Source code
const sourceExts = ['js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'cs', 'go', 'rs', 'php'];
if (ext && sourceExts.includes(ext)) {
if (path.includes('/src/') || path.includes('/lib/')) {
return 'source';
}
return 'source';
}
// Frontend/UI
const frontendExts = ['html', 'css', 'scss', 'sass', 'less', 'vue', 'svelte'];
if (ext && frontendExts.includes(ext)) {
return 'frontend';
}
// Assets
const assetExts = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'ico', 'webp'];
if (ext && assetExts.includes(ext)) {
return 'assets';
}
// Build/tooling
if (path.includes('webpack') ||
path.includes('rollup') ||
path.includes('vite') ||
path.includes('babel') ||
path.includes('eslint') ||
path.includes('prettier') ||
path.includes('/build/') ||
path.includes('/dist/')) {
return 'build';
}
return 'other';
}
/**
* Detect programming language from file extension
*/
export function detectLanguage(filePath) {
if (!filePath || typeof filePath !== 'string') {
return 'Unknown';
}
const ext = filePath.split('.').pop()?.toLowerCase();
const langMap = {
js: 'JavaScript',
jsx: 'JavaScript',
ts: 'TypeScript',
tsx: 'TypeScript',
py: 'Python',
java: 'Java',
cpp: 'C++',
c: 'C',
cs: 'C#',
go: 'Go',
rs: 'Rust',
php: 'PHP',
rb: 'Ruby',
swift: 'Swift',
kt: 'Kotlin',
scala: 'Scala',
html: 'HTML',
css: 'CSS',
scss: 'SCSS',
sass: 'Sass',
vue: 'Vue',
svelte: 'Svelte',
json: 'JSON',
xml: 'XML',
yaml: 'YAML',
yml: 'YAML',
toml: 'TOML',
md: 'Markdown',
sql: 'SQL',
};
return ext ? langMap[ext] || 'Unknown' : 'Unknown';
}
/**
* Assess file importance based on path and type
*/
export function assessFileImportance(filePath, status) {
if (!filePath || typeof filePath !== 'string') {
return 'medium';
}
const path = filePath.toLowerCase();
// Critical files
if (path.includes('package.json') ||
path.includes('pom.xml') ||
path.includes('cargo.toml') ||
path.includes('requirements.txt') ||
path.includes('dockerfile') ||
path.includes('docker-compose')) {
return 'critical';
}
// Core source files
if (path.includes('/src/') || path.includes('/lib/')) {
if (path.includes('index.') ||
path.includes('main.') ||
path.includes('app.') ||
path.includes('server.')) {
return 'critical';
}
return 'high';
}
// Configuration
if (categorizeFile(filePath) === 'configuration') {
return 'high';
}
// Tests
if (categorizeFile(filePath) === 'tests') {
return 'medium';
}
// Documentation
if (categorizeFile(filePath) === 'documentation') {
return 'low';
}
// File deletion is always important
if (status === 'D') {
return 'high';
}
return 'medium';
}
// ========================================
// TEXT PROCESSING UTILITIES
// ========================================
/**
* Assess overall complexity of changes
*/
export function assessOverallComplexity(diffContent, fileCount) {
const lines = diffContent.split('\n');
const addedLines = lines.filter((line) => line.startsWith('+')).length;
const deletedLines = lines.filter((line) => line.startsWith('-')).length;
const totalChanges = addedLines + deletedLines;
// Complexity factors
let complexity = 'low';
if (fileCount > 20 || totalChanges > 500) {
complexity = 'high';
}
else if (fileCount > 5 || totalChanges > 100) {
complexity = 'medium';
}
// Check for complex patterns
const complexPatterns = [
/class\s+\w+/g,
/function\s+\w+/g,
/async\s+function/g,
/try\s*{/g,
/catch\s*\(/g,
/interface\s+\w+/g,
/type\s+\w+/g,
];
const patternMatches = complexPatterns.reduce((count, pattern) => {
return count + (diffContent.match(pattern) || []).length;
}, 0);
if (patternMatches > 10) {
complexity = complexity === 'low' ? 'medium' : 'high';
}
return complexity;
}
/**
* Assess risk level of changes
*/
export function assessRisk(diffContent, fileCount, commitMessage) {
let risk = 'low';
// High-risk keywords in commit message
const highRiskKeywords = ['breaking', 'remove', 'delete', 'deprecated', 'migration'];
const mediumRiskKeywords = ['refactor', 'restructure', 'change', 'modify', 'update'];
if (!commitMessage || typeof commitMessage !== 'string') {
return risk; // Return early with default risk level
}
const message = commitMessage.toLowerCase();
if (highRiskKeywords.some((keyword) => message.includes(keyword))) {
risk = 'high';
}
else if (mediumRiskKeywords.some((keyword) => message.includes(keyword))) {
risk = 'medium';
}
// High-risk file patterns
const highRiskPatterns = [
/package\.json/,
/package-lock\.json/,
/yarn\.lock/,
/Dockerfile/,
/docker-compose/,
/database/,
/migration/,
/config/,
/env/,
];
const diffLines = diffContent.split('\n');
const hasHighRiskFiles = diffLines.some((line) => highRiskPatterns.some((pattern) => pattern.test(line)));
if (hasHighRiskFiles) {
risk = risk === 'low' ? 'medium' : 'high';
}
// Large changes are risky
if (fileCount > 15 || diffContent.length > 10000) {
risk = risk === 'low' ? 'medium' : 'high';
}
return risk;
}
/**
* Check if changes are breaking
*/
export function isBreakingChange(commitMessage, diffContent) {
if (!commitMessage || typeof commitMessage !== 'string') {
return false;
}
const message = commitMessage.toLowerCase();
// Check commit message for breaking indicators
if (message.includes('breaking') || message.includes('!:')) {
return true;
}
// Check diff for breaking patterns
const breakingPatterns = [
/function\s+\w+\s*\([^)]*\)\s*{[\s\S]*?}[\s\S]*?-/g, // Function signature changes
/class\s+\w+[\s\S]*?-/g, // Class changes
/interface\s+\w+[\s\S]*?-/g, // Interface changes
/export\s+[\s\S]*?-/g, // Export changes
];
return breakingPatterns.some((pattern) => pattern.test(diffContent));
}
/**
* Assess business relevance of changes
*/
export function assessBusinessRelevance(commitMessage, filePaths) {
if (!commitMessage || typeof commitMessage !== 'string') {
return 'low';
}
const message = commitMessage.toLowerCase();
// Business-relevant keywords
const businessKeywords = [
'feature',
'user',
'customer',
'client',
'business',
'revenue',
'payment',
'billing',
'subscription',
'auth',
'login',
'security',
];
const hasBusinessKeywords = businessKeywords.some((keyword) => message.includes(keyword));
// Business-relevant file paths
const businessPaths = [
'/api/',
'/service/',
'/controller/',
'/model/',
'/auth/',
'/payment/',
'/billing/',
'/user/',
];
const hasBusinessFiles = filePaths.some((path) => path &&
typeof path === 'string' &&
businessPaths.some((businessPath) => path.includes(businessPath)));
if (hasBusinessKeywords && hasBusinessFiles) {
return 'high';
}
if (hasBusinessKeywords || hasBusinessFiles) {
return 'medium';
}
return 'low';
}
// ========================================
// JSON UTILITIES
// ========================================
/**
* Safe JSON parse with fallback
*/
export function safeJsonParse(jsonString, fallback = null) {
// Use the advanced JsonUtils for better error handling
return JsonUtils.safeParse(jsonString, fallback);
}
/**
* Safe JSON stringify with formatting
*/
export function safeJsonStringify(obj, indent = 2) {
try {
return JSON.stringify(convertSetsToArrays(obj), null, indent);
}
catch (error) {
console.warn(colors.warningMessage(`JSON stringify error: ${error.message}`));
return '{}';
}
}
// ========================================
// UTILITY HELPERS
// ========================================
/**
* Sleep utility for rate limiting
*/
export function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Retry utility with exponential backoff
*/
export async function retry(fn, maxRetries = 3, baseDelay = 1000) {
let lastError;
for (let i = 0; i < maxRetries; i++) {
try {
return await fn();
}
catch (error) {
lastError = error;
if (i < maxRetries - 1) {
const delay = baseDelay * 2 ** i;
await sleep(delay);
}
}
}
throw lastError;
}
/**
* Debounce utility
*/
export function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
/**
* Throttle utility
*/
export function throttle(func, limit) {
let inThrottle;
return function executedFunction(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => {
inThrottle = false;
}, limit);
}
};
}
// ========================================
// ANALYSIS UTILITIES
// ========================================
/**
* Analyze semantic changes in code diffs
*/
export function analyzeSemanticChanges(diff, filePath) {
const analysis = {
changeType: 'modification',
patterns: new Set(),
frameworks: new Set(),
keywords: new Set(),
codeElements: new Set(),
apiChanges: [],
dataChanges: [],
};
if (!diff || diff === 'Binary file or diff unavailable') {
return convertSetsToArrays(analysis);
}
const _addedLines = diff
.split('\n')
.filter((line) => line.startsWith('+') && !line.startsWith('+++'));
const _removedLines = diff
.split('\n')
.filter((line) => line.startsWith('-') && !line.startsWith('---'));
// Framework detection (only if filePath is valid)
if (filePath &&
typeof filePath === 'string' &&
(filePath.includes('database/') ||
filePath.includes('sql/') ||
filePath.includes('migrations/'))) {
analysis.frameworks.add('Database');
if (diff.includes('CREATE TABLE') || diff.includes('ALTER TABLE')) {
analysis.changeType = 'schema_change';
analysis.patterns.add('database_schema');
}
if (diff.includes('CREATE POLICY') || diff.includes('ALTER POLICY')) {
analysis.patterns.add('security_policy');
}
}
if (filePath.endsWith('.tsx') || filePath.endsWith('.jsx')) {
analysis.frameworks.add('React');
if (diff.includes('useState') || diff.includes('useEffect')) {
analysis.patterns.add('react_hooks');
}
if (diff.includes('useCallback') || diff.includes('useMemo')) {
analysis.patterns.add('performance_optimization');
}
}
if (filePath &&
typeof filePath === 'string' &&
(filePath.includes('/api/') || filePath.includes('route.'))) {
analysis.frameworks.add('API');
analysis.changeType = 'api_change';
['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].forEach((method) => {
if (diff.includes(`export async function ${method}`) ||
diff.includes(`app.${method.toLowerCase()}`)) {
analysis.apiChanges.push(`${method} endpoint`);
analysis.patterns.add('api_endpoint');
}
});
}
// Code element detection
const codePatterns = {
function_definition: /(?:export\s+)?(?:async\s+)?function\s+(\w+)/g,
component_definition: /(?:export\s+)?(?:const|function)\s+(\w+Component|\w+Page|\w+Layout)/g,
hook_definition: /(?:export\s+)?(?:const|function)\s+(use\w+)/g,
type_definition: /(?:export\s+)?(?:type|interface)\s+(\w+)/g,
constant_definition: /(?:export\s+)?const\s+(\w+)/g,
};
Object.entries(codePatterns).forEach(([pattern, regex]) => {
const matches = [...diff.matchAll(regex)];
if (matches.length > 0) {
analysis.patterns.add(pattern);
matches.forEach((match) => {
analysis.codeElements.add(match[1]);
});
}
});
// Advanced pattern detection
const advancedPatterns = {
error_handling: [/try\s*{/, /catch\s*\(/, /throw\s+/, /Error\(/],
async_operations: [/async\s+/, /await\s+/, /Promise\./, /\.then\(/],
data_validation: [/validate/, /schema/, /validation/, /validator/i],
authentication: [/auth/, /login/, /logout/, /token/, /jwt/, /session/i],
authorization: [/permission/, /role/, /access/, /policy/, /guard/i],
caching: [/cache/, /memo/, /useMemo/, /useCallback/i],
testing: [/test/, /spec/, /mock/, /describe/, /it\(/],
styling: [/className/, /css/, /styled/, /style/],
state_management: [/useState/, /useReducer/, /store/, /state/i],
routing: [/router/, /navigate/, /redirect/, /route/, /Link/],
data_fetching: [/fetch/, /axios/, /useQuery/, /useMutation/, /api/i],
};
Object.entries(advancedPatterns).forEach(([pattern, regexes]) => {
if (regexes.some((regex) => regex.test(diff))) {
analysis.patterns.add(pattern);
}
});
return convertSetsToArrays(analysis);
}
/**
* Analyze functional impact of code changes
*/
export function analyzeFunctionalImpact(diff, filePath, status) {
const impact = {
scope: 'local',
severity: 'low',
affectedSystems: new Set(),
businessImpact: 'minimal',
technicalDebt: 'none',
migrationRequired: false,
backwardCompatible: true,
};
if (!diff || diff === 'Binary file or diff unavailable') {
return convertSetsToArrays(impact);
}
// File deletion has high impact
if (status === 'D') {
impact.severity = 'high';
impact.scope = 'global';
impact.backwardCompatible = false;
return convertSetsToArrays(impact);
}
const _addedLines = diff
.split('\n')
.filter((line) => line.startsWith('+') && !line.startsWith('+++'));
const _removedLines = diff
.split('\n')
.filter((line) => line.startsWith('-') && !line.startsWith('---'));
// Assess scope based on file type and changes (only if filePath is valid)
if (filePath && typeof filePath === 'string') {
if (filePath.includes('/api/') ||
filePath.includes('/server/') ||
filePath.includes('/backend/')) {
impact.scope = 'system';
impact.affectedSystems.add('backend');
if (diff.includes('export') || diff.includes('endpoint') || diff.includes('route')) {
impact.scope = 'global';
impact.severity = 'medium';
}
}
if (filePath.includes('package.json') || filePath.includes('package-lock.json')) {
impact.scope = 'global';
impact.severity = 'high';
impact.affectedSystems.add('dependencies');
impact.migrationRequired = true;
}
if (filePath.includes('database/') || filePath.includes('migrations/')) {
impact.scope = 'global';
impact.severity = 'high';
impact.affectedSystems.add('database');
impact.migrationRequired = true;
impact.backwardCompatible = false;
}
}
// Breaking change detection
const breakingPatterns = [
/export\s+(?:async\s+)?function\s+\w+\s*\([^)]*\)\s*{[\s\S]*?}-/g,
/export\s+(?:const|let|var)\s+\w+\s*=[\s\S]*?-/g,
/export\s+(?:class|interface|type)\s+\w+[\s\S]*?-/g,
];
if (breakingPatterns.some((pattern) => pattern.test(diff))) {
impact.backwardCompatible = false;
impact.severity = 'high';
impact.businessImpact = 'high';
}
// Performance impact
if (diff.includes('async') || diff.includes('await') || diff.includes('Promise')) {
impact.affectedSystems.add('performance');
}
// Security impact
if (diff.includes('auth') || diff.includes('permission') || diff.includes('security')) {
impact.affectedSystems.add('security');
impact.severity = 'high';
impact.businessImpact = 'high';
}
return convertSetsToArrays(impact);
}
/**
* Generate analysis summary from semantic and functional analysis
*/
export function generateAnalysisSummary(semanticAnalysis, functionalImpact) {
const summary = {
primaryChanges: [],
impactLevel: functionalImpact.severity || 'low',
technicalScope: functionalImpact.scope || 'local',
businessRelevance: functionalImpact.businessImpact || 'minimal',
frameworks: Array.from(semanticAnalysis.frameworks || []),
patterns: Array.from(semanticAnalysis.patterns || []),
recommendations: [],
riskFactors: [],
};
// Generate primary changes based on patterns
if (semanticAnalysis.patterns?.includes('api_endpoint')) {
summary.primaryChanges.push('API endpoint modifications');
}
if (semanticAnalysis.patterns?.includes('database_schema')) {
summary.primaryChanges.push('Database schema changes');
}
if (semanticAnalysis.patterns?.includes('react_hooks')) {
summary.primaryChanges.push('React component updates');
}
if (semanticAnalysis.patterns?.includes('function_definition')) {
summary.primaryChanges.push('Function implementations');
}
// Generate recommendations
if (functionalImpact.migrationRequired) {
summary.recommendations.push('Migration script required');
}
if (!functionalImpact.backwardCompatible) {
summary.recommendations.push('Breaking change - update dependent code');
}
if (functionalImpact.affectedSystems?.includes('security')) {
summary.recommendations.push('Security review recommended');
}
if (functionalImpact.affectedSystems?.includes('performance')) {
summary.recommendations.push('Performance testing advised');
}
// Identify risk factors
if (functionalImpact.severity === 'high') {
summary.riskFactors.push('High impact changes');
}
if (functionalImpact.scope === 'global') {
summary.riskFactors.push('System-wide effects');
}
if (semanticAnalysis.frameworks && semanticAnalysis.frameworks.length > 2) {
summary.riskFactors.push('Multiple framework dependencies');
}
return summary;
}
/**
* Perform semantic analysis on files and commit message
*/
export function performSemanticAnalysis(files, subject, body) {
const conventionalCommit = parseConventionalCommit(subject, body);
const analysis = {
commitType: conventionalCommit.type || 'unknown',
scope: conventionalCommit.scope,
description: conventionalCommit.description,
isConventional: conventionalCommit.isConventional,
breaking: conventionalCommit.breaking,
breakingChanges: conventionalCommit.breakingChanges,
issueReferences: conventionalCommit.issueReferences,
closesReferences: conventionalCommit.closesReferences,
affectedDomains: new Set(),
technicalElements: new Set(),
businessElements: new Set(),
complexity: 'low',
};
// Analyze files
files.forEach((file) => {
const filePath = file.filePath || file.path || '';
const category = categorizeFile(filePath);
const language = detectLanguage(filePath);
analysis.affectedDomains.add(category);
analysis.technicalElements.add(language);
// Business domain detection (only if filePath exists)
if (filePath && typeof filePath === 'string') {
if (filePath.includes('/user/') || filePath.includes('/customer/')) {
analysis.businessElements.add('user_management');
}
if (filePath.includes('/payment/') || filePath.includes('/billing/')) {
analysis.businessElements.add('financial_operations');
}
if (filePath.includes('/auth/') || filePath.includes('/security/')) {
analysis.businessElements.add('security_access');
}
if (filePath.includes('/analytics/') || filePath.includes('/metrics/')) {
analysis.businessElements.add('business_intelligence');
}
}
});
// Complexity assessment
if (files.length > 10 || analysis.affectedDomains.size > 3) {
analysis.complexity = 'high';
}
else if (files.length > 3 || analysis.affectedDomains.size > 1) {
analysis.complexity = 'medium';
}
return convertSetsToArrays(analysis);
}
// ========================================
// AI UTILITIES
// ========================================
/**
* Build enhanced prompt for AI analysis
*/
export function buildEnhancedPrompt(commitAnalysis, analysisMode = 'standard') {
const { subject, files = [], diffStats = {},
// semanticAnalysis, diffStats, complexity, riskAssessment - unused
} = commitAnalysis;
// Detect merge commits and upgrade analysis mode for better specificity
const isMergeCommit = subject?.toLowerCase().includes('merge');
const effectiveAnalysisMode = isMergeCommit && files.length > 10 ? 'detailed' : analysisMode;
// Debug logging for merge commits (disabled)
// if (isMergeCommit) {
// console.log('\n=== MERGE COMMIT DEBUG (EARLY) ===')
// console.log('Subject:', subject)
// console.log('Files count:', files.length)
// console.log('Effective analysis mode:', effectiveAnalysisMode)
// console.log('=== END EARLY DEBUG ===\n')
// }
// Initialize DiffProcessor with effective analysis mode
const diffProcessor = new DiffProcessor({
analysisMode: effectiveAnalysisMode,
enableFiltering: true,
enablePatternDetection: true,
});
// Process all files with intelligent diff compression
const processedResult = diffProcessor.processFiles(files);
const { processedFiles, patterns, filesProcessed, filesSkipped } = processedResult;
const insertions = diffStats.insertions || 0;
const deletions = diffStats.deletions || 0;
// Build pattern summary if patterns were detected
const patternSummary = Object.keys(patterns).length > 0
? `\n**BULK PATTERNS DETECTED:**\n${Object.values(patterns)
.map((p) => `- ${p.description}`)
.join('\n')}\n`
: '';
// Build files section with processed diffs
const filesSection = processedFiles
.map((file) => {
if (file.isSummary) {
return `\n**[REMAINING FILES SUMMARY]:**\n${file.diff}\n`;
}
const compressionInfo = file.compressionApplied
? ` [compressed from ${file.originalSize || 'unknown'} chars]`
: '';
const patternInfo = file.bulkPattern ? ` [${file.bulkPattern}]` : '';
return `\n**${file.filePath || file.path}** (${file.status})${compressionInfo}${patternInfo}:\n${file.diff}\n`;
})
.join('');
// Check if we have enhanced merge summary from GitService (isMergeCommit already detected above)
const enhancedMergeSummary = files.length > 0 && files[0].enhancedMergeSummary ? files[0].enhancedMergeSummary : null;
const prompt = `Analyze this git commit for changelog generation.
**COMMIT:** ${subject}${isMergeCommit ? ' (MERGE COMMIT - categorize as "merge")' : ''}
**FILES:** ${files.length} files (${filesProcessed} analyzed, ${filesSkipped} summarized), ${insertions + deletions} lines changed${patternSummary}${enhancedMergeSummary
? `
**ENHANCED MERGE SUMMARY:**
${enhancedMergeSummary}
**IMPORTANT FOR MERGE COMMITS:** Use the above enhanced summary as your description. Do NOT create a generic summary. Instead, convert the bullet points above into a flowing description that includes the specific file names, numbers, and technical details provided.`
: ''}
**TARGET AUDIENCE:** End users and project stakeholders who need to understand what changed and why it matters.
**YOUR ROLE:** You are a release manager translating technical changes into clear, user-focused release notes.
**ANALYSIS APPROACH:**
1. **First, categorize correctly** using the rules below
2. **Then, focus on user impact** - what can they do now that they couldn't before?
3. **Keep technical details minimal** - only what's necessary for understanding
4. **Be definitive and factual** - never use uncertain language like "likely", "probably", "appears to", "seems to", or "possibly"
5. **Base analysis on actual code changes** - only describe what you can verify from the diff content
6. **For merge commits** - ALWAYS categorize as "merge" regardless of content
7. **Use enhanced merge summary** - If an "ENHANCED MERGE SUMMARY" section is provided above, DO NOT analyze individual file diffs. Instead, directly incorporate the specific bullet points from the enhanced summary into your response. Use the exact technical details, file names, and numbers provided in the enhanced summary.
**PROCESSED DIFFS:**${filesSection}
**CATEGORIZATION RULES (STRICTLY ENFORCED):**
- **merge**: Any commit with "Merge" in the subject line (branch merges, pull request merges)
- **fix**: ONLY actual bug fixes - broken functionality now works correctly
- **feature**: New capabilities, tools, or major functionality additions (NOT merges)
- **refactor**: Code restructuring without changing what users can do
- **perf**: Performance improvements users will notice
- **docs**: Documentation updates only
- **build/chore**: Build system, dependencies, maintenance
**CRITICAL VALIDATION:**
- Commits with "Merge" in subject = "merge" category ALWAYS
- Large additions (>10 files OR >1000 lines) = "feature" or "refactor", NEVER "fix" (unless merge)
- New modules/classes/tools = "feature" (unless merge)
- Only actual bug repairs = "fix"
Provide a JSON response with ONLY these fields (use definitive language - NO "likely", "probably", "appears", etc.):
{
"summary": "${subject}",
"impact": "critical|high|medium|low|minimal",
"category": "feature|fix|security|breaking|docs|style|refactor|perf|test|chore",
"description": "One clear, factual sentence describing what users can now do (for features) or what now works correctly (for fixes)",
"technicalDetails": "Maximum 2 factual sentences about key technical changes - focus on the most important functions/modules/APIs added or modified",
"businessValue": "Brief, definitive user benefit in 1 sentence",
"riskFactors": ["minimal", "list"],
"recommendations": ["minimal", "list"],
"breakingChanges": false,
"migrationRequired": false
}`;
return prompt;
}
/**
* Validate and correct AI categorization and impact assessment based on commit characteristics
*/
export function validateCommitCategory(category, commitAnalysis) {
const { files = [], diffStats = {} } = commitAnalysis;
const fileCount = files.length;
const addedFiles = files.filter((f) => f.status === 'A' || f.status === '??').length;
const { insertions = 0, deletions = 0 } = diffStats;
// Rule 1: Large additions cannot be bug fixes
if (category === 'fix' && (fileCount > 10 || addedFiles > 5 || insertions > 1000)) {
// Check if it's likely a refactor vs new feature
if (deletions > insertions * 0.5) {
return 'refactor'; // Significant deletions suggest refactoring
}
return 'feature'; // More additions suggest new functionality
}
// Rule 2: New module/class additions are features
if (category === 'fix' && addedFiles > 0) {
const hasNewModules = files.some((f) => (f.status === 'A' || f.status === '??') &&
(f.filePath.includes('.js') || f.filePath.includes('.ts') || f.filePath.includes('.py')));
if (hasNewModules) {
return 'feature';
}
}
// Rule 3: Documentation-only changes
if (category !== 'docs' &&
files.every((f) => f.filePath.endsWith('.md') ||
f.filePath.endsWith('.txt') ||
f.filePath.includes('README') ||
f.filePath.includes('CHANGELOG'))) {
return 'docs';
}
// Rule 4: Test-only changes
if (category !== 'test' &&
files.every((f) => f.filePath.includes('test') ||
f.filePath.includes('spec') ||
f.filePath.endsWith('.test.js') ||
f.filePath.endsWith('.spec.js'))) {
return 'test';
}
return category; // No correction needed
}
/**
* Validate and correct impact assessment based on actual change magnitude
*/
export function validateImpactAssessment(impact, commitAnalysis) {
const { files = [], diffStats = {}, subject = '' } = commitAnalysis;
const fileCount = files.length;
const addedFiles = files.filter((f) => f.status === 'A' || f.status === '??').length;
const { insertions = 0, deletions = 0 } = diffStats;
const totalChanges = insertions + deletions;
// Rule 1: Large architectural changes cannot be "minimal" or "low"
if ((impact === 'minimal' || impact === 'low') && (fileCount > 50 || totalChanges > 5000)) {
return 'high';
}
// Rule 2: Major refactors or large features should be at least "medium"
if (impact === 'minimal' && (fileCount > 20 || totalChanges > 2000 || addedFiles > 10)) {
return 'medium';
}
// Rule 3: Small changes cannot be "critical" or "high" unless breaking
if ((impact === 'critical' || impact === 'high') && fileCount <= 3 && totalChanges <= 100) {
const hasBreakingIndicators = subject.includes('!') || subject.toLowerCase().includes('breaking');
if (!hasBreakingIndicators) {
return 'medium';
}
}
// Rule 4: Documentation-only changes should be low impact
if (files.every((f) => f.filePath.endsWith('.md') ||
f.filePath.endsWith('.txt') ||
f.filePath.includes('README') ||
f.filePath.includes('CHANGELOG')) &&
(impact === 'critical' || impact === 'high')) {
return 'low';
}
return impact; // No correction needed
}
/**
* Parse AI response content
*/
export function parseAIResponse(content, originalCommit = {}) {
if (!content) {
return {
summary: originalCommit.subject || 'Unknown change',
impact: 'low',
category: 'chore',
description: 'AI analysis unavailable - using pattern-based description',
technicalDetails: 'Analysis performed using file pattern recognition',
businessValue: 'Impact assessment based on file changes only',
riskFactors: [],
recommendations: ['Consider configuring AI provider for detailed analysis'],
breakingChanges: false,
migrationRequired: false,
};
}
try {
// Try to extract JSON from the response
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const parsed = safeJsonParse(jsonMatch[0]);
if (parsed) {
// Validate and correct the category and impact
const validatedCategory = validateCommitCategory(parsed.category || 'chore', originalCommit);
const validatedImpact = validateImpactAssessment(parsed.impact || 'low', originalCommit);
return {
summary: parsed.summary || originalCommit.subject || 'Unknown change',
impact: validatedImpact,
category: validatedCategory,
description: parsed.description || '',
technicalDetails: parsed.technicalDetails || '',
businessValue: parsed.businessValue || '',
riskFactors: Array.isArray(parsed.riskFactors) ? parsed.riskFactors : [],
recommendations: Array.isArray(parsed.recommendations) ? parsed.recommendations : [],
breakingChanges: Boolean(parsed.breakingChanges),
migrationRequired: Boolean(parsed.migrationRequired),
};
}
}
// Fallback to text parsing if JSON extraction fails
const safeContent = content && typeof content === 'string' ? content : '';
const lowerContent = safeContent.toLowerCase();
return {
summary: originalCommit.subject || 'Unknown change',
impact: lowerContent.includes('critical')
? 'critical'
: lowerContent.includes('high')
? 'high'
: lowerContent.includes('medium')
? 'medium'
: 'low',
category: extractCategoryFromText(safeContent),
description: safeContent.substring(0, 200) + (safeContent.length > 200 ? '...' : ''),
technicalDetails: safeContent,
businessValue: '',
riskFactors: [],
recommendations: [],
breakingChanges: lowerContent.includes('breaking'),
migrationRequired: lowerContent.includes('migration'),
};
}
catch (error) {
console.warn(colors.warningMessage(`AI response parsing error: ${error.message}`));
return {
summary: originalCommit.subject || 'Unknown change',
impact: 'low',
category: 'chore',
description: 'AI analysis failed',
technicalDetails: '',
businessValue: '',
riskFactors: [],
recommendations: [],
breakingChanges: false,
migrationRequired: false,
};
}
}
/**
* Extract category from text content
* Helper for parseAIResponse
*/
function extractCategoryFromText(content) {
if (!content || typeof content !== 'string') {
return 'chore';
}
const text = content.toLowerCase();
if (text.includes('feature') || text.includes('feat')) {
return 'feature';
}
if (text.includes('fix') || text.includes('bug')) {
return 'fix';
}
if (text.includes('security')) {
return 'security';
}
if (text.includes('breaking')) {
return 'breaking';
}
if (text.includes('doc')) {
return 'docs';
}
if (text.includes('style')) {
return 'style';
}
if (text.includes('refactor')) {
return 'refactor';
}
if (text.includes('perf') || text.includes('performance')) {
return 'perf';
}
if (text.includes('test')) {
return 'test';
}
return 'chore';
}
// ========================================
// CHANGELOG UTILITIES
// ========================================
/**
* Process working directory changes for changelog generation
*/
/**
* Get raw working directory changes from git status
*
* Note: This is a lightweight utility function that shells out to git directly
* for simple status checks. For comprehensive git operations, services should
* use GitService/GitManager, but this utility is kept for:
* - Performance (avoids service initialization overhead)
* - Simplicity (standalone function for basic status checks)
*
* @returns {Array} Array of change objects with status and filePath
*/
export function getWorkingDirectoryChanges(cwd = undefined) {
try {
const execOptions = { encoding: 'utf8' };
if (cwd) {
execOptions.cwd = cwd;
}
const result = execSync('git status --porcelain', execOptions);
if (!result.trim()) {
return [];
}
const lines = result.split('\n').filter((line) => line.trim());
const changes = lines.map((line) => {
const status = line.substring(0, 2).trim() || '??';
const filePath = line.substring(3);
return {
status,
filePath,
path: filePath, // alias for compatibility
diff: '', // Will be populated later if needed
additions: 0,
deletions: 0,
};
});
return changes;
}
catch (error) {
console.warn('Could not get git status:', error.message);
return [];
}
}
/**
* Get raw staged changes from git index.
*
* Uses the staged diff instead of the working tree so commit-message generation
* reflects the actual commit payload, including partially staged files.
*
* @returns {Array} Array of change objects with status and filePath
*/
export function getStagedChanges(cwd = undefined) {
try {
const execOptions = { encoding: 'utf8' };
if (cwd) {
execOptions.cwd = cwd;
}
const result = execSync('git diff --cached --name-status --find-renames --find-copies', execOptions);
if (!result.trim()) {
return [];
}
return result
.split('\n')
.filter((line) => line.trim())
.map((line) => {
const parts = line.split('\t');
const rawStatus = parts[0] || '';
const normalizedStatus = rawStatus.trim().charAt(0) || 'M';
const filePath = parts.length >= 3 ? parts[2] : parts[1];
return {
status: normalizedStatus,
rawStatus,
filePath,
path: filePath,
previousPath: parts.length >= 3 ? parts[1] : undefined,
diff: '',
additions: 0,
deletions: 0,
};
});
}
catch (error) {
console.warn('Could not get staged changes:', error.message);
return [];
}
}
export function processWorkingDirectoryChanges(workingDirChanges, _gitManager = null) {
// If no changes provided, get them from git status
if (!workingDirChanges) {
workingDirChanges = getWorkingDirectoryChanges();
}
if (!workingDirChanges || workingDirChanges.length === 0) {
return [];
}
const categorizedChanges = {
added: [],
modified: [],
deleted: [],
renamed: [],
unknown: [],
};
let totalFiles = 0;
workingDirChanges.forEach((change) => {
const category = categorizeFile(change.filePath);
const language = detectLanguage(change.filePath);
const importance = assessFileImportance(change.filePath, change.status);
const processedChange = {
filePath: change.filePath,
status: change.status,
category,
language,
importance,
diff: change.diff || '',
additions: change.additions || 0,
deletions: change.deletions || 0,
};
switch (change.status) {
case 'A':
categorizedChanges.added.push(processedChange);
break;
case 'M':
categorizedChanges.modified.push(processedChange);
break;
case 'D':
categorizedChanges.deleted.push(processedChange);
break;
case 'R':
categorizedChanges.renamed.push(processedChange);
break;
default:
categorizedChanges.unknown.push(processedChange);
}
totalFiles++;
});
// Generate summary
const summary = generateWorkspaceChangesSummary(categorizedChanges);
// Assess complexity
const complexity = assessWorkspaceComplexity(categorizedChanges, totalFiles);
return {
categorizedChanges,
summary,
totalFiles,
complexity,
};
}
/**
* Summarize file changes for changelog
*/
export function summarizeFileChanges(changes) {
if (!changes || changes.length === 0) {
return {
summary: 'No file changes detected',
categories: {},
stats: { added: 0, modified: 0, deleted: 0, renamed: 0 },
languages: {},
};
}
const stats = {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
};
const categories = {};
const languages = new Set();
changes.forEach((change) => {
// Handle statu