@boilerbuilder/deps-analyzer
Version:
CLI tool to analyze dependency evolution and release frequency
1,033 lines (882 loc) • 43.3 kB
JavaScript
const fs = require('fs');
const path = require('path');
/**
* Generate JSON report from analysis data
* @param {Object} analysisData - Complete analysis results
* @returns {Object} Structured JSON report
*/
function generateJSON(analysisData) {
// Calculate unique dependencies across all projects
const uniqueDependencies = new Map();
for (const project of analysisData.projects) {
if (project.dependencyAnalysis) {
for (const [depName, depData] of Object.entries(project.dependencyAnalysis)) {
// Only store the first occurrence of each unique dependency
if (!uniqueDependencies.has(depName)) {
uniqueDependencies.set(depName, depData);
}
}
}
}
// Determine period structure from the first dependency with periods data
let periodLabels = [];
for (const [depName, depData] of uniqueDependencies) {
if (depData.periods && Array.isArray(depData.periods)) {
periodLabels = depData.periods.map(p => p.label);
break;
}
}
// Initialize dynamic global totals (completely dynamic)
const globalPeriodTotals = {};
const globalPeriodSemver = {};
periodLabels.forEach(label => {
globalPeriodTotals[label] = 0;
globalPeriodSemver[label] = { major: 0, minor: 0, patch: 0 };
});
let globalTotalReleases = 0;
// Calculate global drift/pulse metrics
let globalTotalDrift = 0;
let globalTotalPulse = 0;
let globalDependenciesWithDriftPulse = 0;
for (const [depName, depData] of uniqueDependencies) {
// Use dynamic periods structure (npm-client.js always provides this now)
if (depData.periods && Array.isArray(depData.periods)) {
for (const period of depData.periods) {
globalPeriodTotals[period.label] += period.releases || 0;
globalPeriodSemver[period.label].major += period.semver?.major || 0;
globalPeriodSemver[period.label].minor += period.semver?.minor || 0;
globalPeriodSemver[period.label].patch += period.semver?.patch || 0;
globalTotalReleases += period.releases || 0;
}
}
// Accumulate drift/pulse metrics for global calculations
if (depData.drift !== undefined && depData.pulse !== undefined) {
globalTotalDrift += depData.drift || 0;
globalTotalPulse += depData.pulse || 0;
globalDependenciesWithDriftPulse++;
}
}
// Build dynamic periods structure for global data
const globalPeriods = [];
const globalSemverBreakdown = {};
periodLabels.forEach(label => {
const totalReleases = globalPeriodTotals[label] || 0;
globalPeriods.push({
label,
releases: totalReleases,
avgPerMonth: parseFloat((totalReleases / 12).toFixed(2)),
semver: {
major: globalPeriodSemver[label]?.major || 0,
minor: globalPeriodSemver[label]?.minor || 0,
patch: globalPeriodSemver[label]?.patch || 0,
avgMajorPerMonth: parseFloat(((globalPeriodSemver[label]?.major || 0) / 12).toFixed(2)),
avgMinorPerMonth: parseFloat(((globalPeriodSemver[label]?.minor || 0) / 12).toFixed(2)),
avgPatchPerMonth: parseFloat(((globalPeriodSemver[label]?.patch || 0) / 12).toFixed(2))
}
});
});
const report = {
metadata: {
generatedAt: new Date().toISOString(),
period: `${analysisData.options?.months || 24} months`,
totalProjects: analysisData.projects.length,
totalDependencies: analysisData.projects.reduce((sum, project) =>
sum + Object.keys(project.dependencyAnalysis || {}).length, 0
),
uniqueDependencies: uniqueDependencies.size,
scopeFilter: analysisData.metadata?.options?.scope || 'all',
periods: periodLabels // Dynamic periods list
},
globalUniqueReleases: {
periods: globalPeriods,
totalReleases: globalTotalReleases
},
globalDriftPulse: {
totalDrift: parseFloat(globalTotalDrift.toFixed(3)),
totalPulse: parseFloat(globalTotalPulse.toFixed(3)),
avgDriftPerProject: analysisData.projects.length > 0 ? parseFloat((globalTotalDrift / analysisData.projects.length).toFixed(3)) : 0,
avgPulsePerProject: analysisData.projects.length > 0 ? parseFloat((globalTotalPulse / analysisData.projects.length).toFixed(3)) : 0,
avgDriftPerDependency: globalDependenciesWithDriftPulse > 0 ? parseFloat((globalTotalDrift / globalDependenciesWithDriftPulse).toFixed(3)) : 0,
avgPulsePerDependency: globalDependenciesWithDriftPulse > 0 ? parseFloat((globalTotalPulse / globalDependenciesWithDriftPulse).toFixed(3)) : 0,
totalDependenciesWithDriftPulse: globalDependenciesWithDriftPulse
},
projects: {}
};
// Process each project
for (const project of analysisData.projects) {
const projectData = {
packagePath: project.packagePath,
dependencyCount: project.dependencyCount,
dependencies: {},
periods: periodLabels // Include dynamic periods structure
};
// Calculate dynamic project metadata
const projectPeriodTotals = {};
const projectPeriodSemver = {};
let totalReleases = 0;
let weightedDays = 0;
let totalDependencies = 0;
// Initialize dynamic totals
periodLabels.forEach((label, index) => {
projectPeriodTotals[index] = 0;
projectPeriodSemver[index] = { major: 0, minor: 0, patch: 0 };
});
// Add dependency analysis if available
if (project.dependencyAnalysis) {
for (const [depName, depData] of Object.entries(project.dependencyAnalysis)) {
const total = depData.total || 0;
const avgDays = depData.avgDaysBetweenReleases || 0;
// Accumulate dynamic period totals
if (depData.periods && Array.isArray(depData.periods)) {
depData.periods.forEach((period, index) => {
projectPeriodTotals[index] += period.releases || 0;
projectPeriodSemver[index].major += period.semver?.major || 0;
projectPeriodSemver[index].minor += period.semver?.minor || 0;
projectPeriodSemver[index].patch += period.semver?.patch || 0;
totalReleases += period.releases || 0;
});
}
// Weighted average for days between releases (weight by total releases)
if (total > 0) {
weightedDays += avgDays * total;
totalDependencies += total;
}
// Clean dependency structure - only dynamic periods
projectData.dependencies[depName] = {
specifiedVersion: depData.specifiedVersion || 'unknown',
installedVersion: depData.installedVersion || 'unknown',
latestVersion: depData.currentVersion || 'unknown',
totalReleases: total,
avgDaysBetweenReleases: avgDays,
periods: depData.periods || [], // Dynamic periods structure
monthlyBreakdown: depData.monthlyBreakdown || {},
mostActiveMonth: getMostActiveMonth(depData.monthlyBreakdown || {}),
drift: depData.drift || 0,
pulse: depData.pulse || 0,
targetVersion: depData.targetVersion || depData.currentVersion || 'unknown',
constrainedByMaxVersion: depData.constrainedByMaxVersion || false
};
}
}
// Build dynamic project metadata
const projectPeriods = [];
periodLabels.forEach((label, index) => {
const releases = projectPeriodTotals[index] || 0;
projectPeriods.push({
label,
releases,
avgPerMonth: parseFloat((releases / 12).toFixed(2)),
semver: {
major: projectPeriodSemver[index]?.major || 0,
minor: projectPeriodSemver[index]?.minor || 0,
patch: projectPeriodSemver[index]?.patch || 0,
avgMajorPerMonth: parseFloat(((projectPeriodSemver[index]?.major || 0) / 12).toFixed(2)),
avgMinorPerMonth: parseFloat(((projectPeriodSemver[index]?.minor || 0) / 12).toFixed(2)),
avgPatchPerMonth: parseFloat(((projectPeriodSemver[index]?.patch || 0) / 12).toFixed(2))
}
});
});
projectData.metadata = {
periods: projectPeriods,
totalReleases,
avgDaysBetweenReleases: totalDependencies > 0 ? parseFloat((weightedDays / totalDependencies).toFixed(1)) : 0
};
// Add project drift/pulse summary if available
if (project.driftPulseSummary) {
projectData.driftPulseSummary = {
totalDrift: project.driftPulseSummary.totalDrift,
totalPulse: project.driftPulseSummary.totalPulse,
avgDriftPerDep: project.driftPulseSummary.avgDriftPerDep,
avgPulsePerDep: project.driftPulseSummary.avgPulsePerDep,
dependenciesWithDriftPulse: project.driftPulseSummary.dependenciesWithDriftPulse
};
}
report.projects[project.projectName] = projectData;
}
return report;
}
/**
* Generate Markdown report from analysis data
* @param {Object} analysisData - Complete analysis results
* @returns {string} Markdown formatted report
*/
function generateMarkdown(analysisData) {
const metadata = analysisData.metadata || {};
const projects = analysisData.projects || [];
// Generate JSON to get the calculated metadata
const jsonReport = generateJSON(analysisData);
const globalData = jsonReport.metadata.globalUniqueReleases;
let markdown = `# Dependencies Evolution Report`;
// Add scope filter to title if not 'all'
if (jsonReport.metadata.scopeFilter && jsonReport.metadata.scopeFilter !== 'all') {
const scopeTitle = jsonReport.metadata.scopeFilter === 'internal' ?
'Internal Dependencies Only' : 'External Dependencies Only';
markdown += ` (${scopeTitle})`;
}
markdown += `\n\n`;
// Summary section
markdown += `## Summary\n`;
markdown += `- **Generated at:** ${new Date().toLocaleString()}\n`;
markdown += `- **Analysis period:** ${metadata.options?.months || 24} months\n`;
if (jsonReport.metadata.scopeFilter && jsonReport.metadata.scopeFilter !== 'all') {
markdown += `- **Scope filter:** ${jsonReport.metadata.scopeFilter}\n`;
}
markdown += `- **Projects analyzed:** ${projects.length}\n`;
markdown += `- **Total dependencies:** ${projects.reduce((sum, project) =>
sum + Object.keys(project.dependencyAnalysis || {}).length, 0)}\n`;
markdown += `- **Unique dependencies:** ${jsonReport.metadata.uniqueDependencies}\n`;
// Add global drift/pulse metrics to Summary section
if (jsonReport.globalDriftPulse) {
const driftPulse = jsonReport.globalDriftPulse;
markdown += `- **Total Portfolio Drift:** ${driftPulse.totalDrift} years\n`;
markdown += `- **Total Portfolio Pulse:** ${driftPulse.totalPulse} years\n`;
markdown += `- **Average Drift per Project:** ${driftPulse.avgDriftPerProject} years\n`;
markdown += `- **Average Pulse per Project:** ${driftPulse.avgPulsePerProject} years\n`;
}
markdown += `\n`;
// Global Unique Dependencies Summary (Dynamic)
if (globalData && globalData.periods && globalData.periods.length > 0) {
markdown += `## 📊 Global Unique Dependencies Summary\n\n`;
markdown += `### Release Activity (Unique Dependencies Only)\n`;
// Dynamic period activity summary
globalData.periods.forEach((period, index) => {
const periodLabel = period.label;
const releases = period.releases || 0;
const avgPerMonth = period.avgPerMonth || 0;
if (index === 0) {
markdown += `- **Total releases (${periodLabel}):** ${releases} (${avgPerMonth}/month)\n`;
} else {
markdown += `- **Total releases (${periodLabel} ago):** ${releases} (${avgPerMonth}/month)\n`;
}
});
// Activity change comparison (first vs second period if available)
if (globalData.periods.length >= 2) {
const current = globalData.periods[0];
const previous = globalData.periods[1];
const change = current.releases - previous.releases;
markdown += `- **Activity change:** ${change > 0 ? '+' : ''}${change} releases\n\n`;
} else {
markdown += `\n`;
}
// Dynamic Semver Breakdown
if (globalData.periods.some(p => p.semver)) {
markdown += `### 🔄 Global Semver Breakdown\n\n`;
// Build dynamic header
const periodHeaders = globalData.periods.map(p => `${p.label} | Avg/Month`).join(' | ');
markdown += `| Type | ${periodHeaders} | Trend |\n`;
markdown += `|------|${globalData.periods.map(() => '-----------|----------').join('|')}|-------|\n`;
// Build dynamic rows for each semver type
['major', 'minor', 'patch'].forEach(type => {
const emoji = type === 'major' ? '🔴' : type === 'minor' ? '🟡' : '🟢';
const typeUpper = type.toUpperCase();
const periodData = globalData.periods.map(period => {
const count = period.semver?.[type] || 0;
const avgPerMonth = period.semver?.[`avg${type.charAt(0).toUpperCase() + type.slice(1)}PerMonth`] || 0;
return `${count} | ${avgPerMonth}`;
}).join(' | ');
// Calculate trend between first two periods
let trendIcon = '➡️';
let trendText = 'Stable';
if (globalData.periods.length >= 2) {
const current = globalData.periods[0].semver?.[type] || 0;
const previous = globalData.periods[1].semver?.[type] || 0;
trendIcon = getTrendIcon(current, previous);
trendText = getTrendText(current, previous);
}
markdown += `| ${emoji} ${typeUpper} | ${periodData} | ${trendIcon} ${trendText} |\n`;
});
markdown += `\n`;
// Key insights using first period data
const firstPeriod = globalData.periods[0];
if (firstPeriod && firstPeriod.semver) {
const majorAvg = firstPeriod.semver.avgMajorPerMonth || 0;
const minorAvg = firstPeriod.semver.avgMinorPerMonth || 0;
const patchAvg = firstPeriod.semver.avgPatchPerMonth || 0;
markdown += `**📈 Key Insights:**\n`;
markdown += `- **Breaking changes (MAJOR):** ${majorAvg}/month - ${majorAvg > (globalData.periods[1]?.semver?.avgMajorPerMonth || 0) ? '⚠️ Increasing' : '✅ Stable/Decreasing'}\n`;
markdown += `- **New features (MINOR):** ${minorAvg}/month\n`;
markdown += `- **Bug fixes (PATCH):** ${patchAvg}/month\n`;
markdown += `- **Overall stability:** ${majorAvg < 3 ? '🟢 Stable ecosystem' : majorAvg < 6 ? '🟡 Moderate churn' : '🔴 High churn'}\n\n`;
}
}
}
// Global Drift/Pulse Analysis
if (jsonReport.metadata.globalDriftPulse) {
const driftPulse = jsonReport.metadata.globalDriftPulse;
markdown += `## 🕐 Global Drift/Pulse Analysis\n\n`;
markdown += `### System Age Assessment\n`;
markdown += `- **Total System Drift:** ${driftPulse.totalDrift} years - How far behind latest versions\n`;
markdown += `- **Total System Pulse:** ${driftPulse.totalPulse} years - Combined release activity lag\n`;
markdown += `- **Average Drift per Project:** ${driftPulse.avgDriftPerProject} years\n`;
markdown += `- **Average Pulse per Project:** ${driftPulse.avgPulsePerProject} years\n`;
markdown += `- **Average Drift per Dependency:** ${driftPulse.avgDriftPerDependency} years\n`;
markdown += `- **Average Pulse per Dependency:** ${driftPulse.avgPulsePerDependency} years\n`;
markdown += `- **Dependencies with Drift/Pulse Data:** ${driftPulse.totalDependenciesWithDriftPulse}\n\n`;
// Add drift/pulse interpretation
markdown += `### 📊 Drift/Pulse Interpretation\n`;
markdown += `- **Drift**: Time since dependencies were last updated (how outdated)\n`;
markdown += `- **Pulse**: Average time between releases (ecosystem activity)\n`;
const driftStatus = driftPulse.avgDriftPerDependency < 0.5 ? '🟢 Very Fresh' :
driftPulse.avgDriftPerDependency < 1.0 ? '🟡 Moderately Fresh' :
driftPulse.avgDriftPerDependency < 2.0 ? '🟠 Getting Stale' : '🔴 Very Stale';
const pulseStatus = driftPulse.avgPulsePerDependency < 0.5 ? '🔥 Highly Active' :
driftPulse.avgPulsePerDependency < 1.0 ? '🚀 Active' :
driftPulse.avgPulsePerDependency < 2.0 ? '📈 Moderate Activity' : '💤 Low Activity';
markdown += `- **Overall Drift Status:** ${driftStatus} (${driftPulse.avgDriftPerDependency} years average)\n`;
markdown += `- **Overall Pulse Status:** ${pulseStatus} (${driftPulse.avgPulsePerDependency} years average)\n\n`;
}
// Critical Dependencies Analysis (across all projects)
markdown += generateCriticalDependenciesSection(projects);
// Process each project
for (const project of projects) {
markdown += `## Project: ${project.projectName}\n\n`;
markdown += `**Package path:** \`${project.packagePath}\` \n`;
markdown += `**Total dependencies:** ${project.dependencyCount}\n\n`;
if (project.dependencyAnalysis && Object.keys(project.dependencyAnalysis).length > 0) {
// Add project-specific critical dependencies
markdown += generateProjectCriticalDeps(project);
// Add activity trends section
markdown += generateActivityTrends(project);
// Add project drift/pulse summary
const projectDriftData = jsonReport.projects[project.projectName];
if (projectDriftData && projectDriftData.driftPulseSummary) {
markdown += generateProjectDriftPulse(projectDriftData.driftPulseSummary);
}
markdown += `### 📋 Complete Dependencies List\n\n`;
// Get first dependency to determine dynamic period structure for headers
const firstDep = Object.values(project.dependencyAnalysis)[0];
const periodHeaders = firstDep.periods.map(p => p.label).join(' | ');
markdown += `| Package | Specified | Installed | Latest | ${periodHeaders} | Semver (first) | Drift | Pulse | Stability | Activity | Most Active |\n`;
markdown += `|---------|-----------|-----------|--------|${firstDep.periods.map(() => '-----').join('|')}|--------------|-------|-------|-----------|----------|-------------|\n`;
// Sort dependencies by activity (first period descending)
const sortedDeps = Object.entries(project.dependencyAnalysis).sort((a, b) => {
const aFirstPeriod = a[1].periods[0]?.releases || 0;
const bFirstPeriod = b[1].periods[0]?.releases || 0;
return bFirstPeriod - aFirstPeriod;
});
// Calculate totals for summary row (dynamic)
const periodTotals = {};
const periodSemver = {};
for (const [depName, depData] of sortedDeps) {
const mostActive = getMostActiveMonth(depData.monthlyBreakdown || {});
// Use dynamic periods structure (npm-client always provides this now)
const periodCells = depData.periods.map(period => period.releases || 0).join(' | ');
const firstPeriodReleases = depData.periods[0]?.releases || 0;
const secondPeriodReleases = depData.periods[1]?.releases || 0;
const major = depData.periods[0]?.semver?.major || 0;
const minor = depData.periods[0]?.semver?.minor || 0;
const patch = depData.periods[0]?.semver?.patch || 0;
// Accumulate dynamic totals
depData.periods.forEach((period, index) => {
if (!periodTotals[index]) periodTotals[index] = 0;
if (!periodSemver[index]) periodSemver[index] = { major: 0, minor: 0, patch: 0 };
periodTotals[index] += period.releases || 0;
periodSemver[index].major += period.semver?.major || 0;
periodSemver[index].minor += period.semver?.minor || 0;
periodSemver[index].patch += period.semver?.patch || 0;
});
semverBreakdown = `${major}/${minor}/${patch}`;
const stability = getStabilityIcon(major, minor);
const activity = getActivityLevel(firstPeriodReleases);
const activityTrend = getTrendIcon(firstPeriodReleases, secondPeriodReleases);
const driftFormatted = (depData.drift || 0).toFixed(2) + 'y';
const pulseFormatted = (depData.pulse || 0).toFixed(2) + 'y';
markdown += `| ${depName} | ${depData.specifiedVersion || 'unknown'} | ${depData.installedVersion || 'unknown'} | ${depData.currentVersion || 'unknown'} | ${periodCells} | ${semverBreakdown} | ${driftFormatted} | ${pulseFormatted} | ${stability} | ${activity} ${activityTrend} | ${mostActive} |\n`;
}
// Add totals row (dynamic)
const totalPeriodCells = Object.keys(periodTotals).map(index => `**${periodTotals[index]}**`).join(' | ');
const totalSemver = `**${periodSemver[0]?.major || 0}/${periodSemver[0]?.minor || 0}/${periodSemver[0]?.patch || 0}**`;
const separatorCells = firstDep?.periods?.map(() => '-----').join('|') || '-----|------';
markdown += `|---------|-----------|-----------|--------|${separatorCells}|--------------|-------|-------|-----------|----------|-------------|\n`;
markdown += `| **TOTAL** | - | - | - | ${totalPeriodCells} | ${totalSemver} | - | - | - | - | - |\n`;
markdown += `\n`;
// Add dynamic project semver breakdown from metadata periods
const projectReportData = jsonReport.projects[project.projectName];
if (projectReportData && projectReportData.metadata && projectReportData.metadata.periods && projectReportData.metadata.periods.length > 0) {
const periods = projectReportData.metadata.periods;
markdown += `### 🔄 Project Semver Breakdown\n\n`;
// Build dynamic header
const periodHeaders = periods.map(p => `${p.label} | Avg/Month`).join(' | ');
markdown += `| Type | ${periodHeaders} | Trend |\n`;
markdown += `|------|${periods.map(() => '-----------|----------').join('|')}|-------|\n`;
// Build dynamic rows for each semver type
['major', 'minor', 'patch'].forEach(type => {
const emoji = type === 'major' ? '🔴' : type === 'minor' ? '🟡' : '🟢';
const typeUpper = type.toUpperCase();
const periodData = periods.map(period => {
const count = period.semver?.[type] || 0;
const avgPerMonth = period.semver?.[`avg${type.charAt(0).toUpperCase() + type.slice(1)}PerMonth`] || 0;
return `${count} | ${avgPerMonth}`;
}).join(' | ');
// Calculate trend between first two periods
let trendIcon = '➡️';
let trendText = 'Stable';
if (periods.length >= 2) {
const current = periods[0].semver?.[type] || 0;
const previous = periods[1].semver?.[type] || 0;
trendIcon = getTrendIcon(current, previous);
const change = Math.abs(current - previous);
trendText = `${trendIcon === '📈' ? '+' : trendIcon === '📉' ? '-' : ''}${change}`;
}
markdown += `| ${emoji} ${typeUpper} | ${periodData} | ${trendIcon} ${trendText} |\n`;
});
markdown += `\n`;
// Dynamic health assessment using first period
const firstPeriod = periods[0];
if (firstPeriod && firstPeriod.semver) {
const majorAvg = firstPeriod.semver.avgMajorPerMonth || 0;
const minorAvg = firstPeriod.semver.avgMinorPerMonth || 0;
const patchAvg = firstPeriod.semver.avgPatchPerMonth || 0;
markdown += `**📊 Project Health Assessment:**\n`;
markdown += `- **Stability rating:** ${getProjectStabilityRatingDynamic(majorAvg)}\n`;
markdown += `- **Maintenance effort:** ${getMaintenanceEffort(majorAvg)} (${majorAvg}/month breaking changes)\n`;
markdown += `- **Development velocity:** ${(minorAvg + patchAvg).toFixed(2)}/month (features + fixes)\n`;
markdown += `- **Update recommendation:** ${getUpdateRecommendationDynamic(majorAvg, periods)}\n\n`;
}
}
// Add dynamic summary stats for the project using periods
const projectSummaryData = jsonReport.projects[project.projectName];
if (projectSummaryData && projectSummaryData.metadata && projectSummaryData.metadata.periods && projectSummaryData.metadata.periods.length > 0) {
const periods = projectSummaryData.metadata.periods;
const firstPeriod = periods[0];
const secondPeriod = periods[1];
markdown += `**📈 Project Activity Summary:**\n`;
markdown += `- Total releases (${firstPeriod.label}): **${firstPeriod.releases}** ${secondPeriod ? getTrendIcon(firstPeriod.releases, secondPeriod.releases) : ''}\n`;
if (secondPeriod) {
markdown += `- Total releases (${secondPeriod.label}): **${secondPeriod.releases}**\n`;
markdown += `- Activity change: **${firstPeriod.releases > secondPeriod.releases ? '+' : ''}${firstPeriod.releases - secondPeriod.releases}** releases (${getPercentageChange(firstPeriod.releases, secondPeriod.releases)})\n`;
}
markdown += `- Average daily releases: **${(firstPeriod.releases / 365).toFixed(2)}**\n\n`;
}
} else {
markdown += `*No dependency analysis data available for this project.*\n\n`;
}
markdown += `---\n\n`;
}
// Add explanation section
markdown += generateExplanationSection();
return markdown;
}
/**
* Get the most active month from monthly breakdown
* @param {Object} monthlyBreakdown - Monthly release versions (arrays)
* @returns {string} Most active month with count
*/
function getMostActiveMonth(monthlyBreakdown) {
if (!monthlyBreakdown || Object.keys(monthlyBreakdown).length === 0) {
return 'N/A';
}
const [maxMonth, maxVersions] = Object.entries(monthlyBreakdown).reduce((max, [month, versions]) => {
const count = Array.isArray(versions) ? versions.length : versions;
const maxCount = Array.isArray(max[1]) ? max[1].length : max[1];
return count > maxCount ? [month, versions] : max;
}, ['', []]);
const maxCount = Array.isArray(maxVersions) ? maxVersions.length : maxVersions;
return maxCount > 0 ? `${maxMonth} (${maxCount})` : 'N/A';
}
/**
* Write reports to files
* @param {Object} analysisData - Complete analysis results
* @param {string} outputPath - Base output path (without extension)
* @returns {Promise<Object>} File paths written
*/
async function writeReports(analysisData, outputPath = 'dependency-report') {
try {
const jsonReport = generateJSON(analysisData);
const markdownReport = generateMarkdown(analysisData);
const jsonPath = `${outputPath}.json`;
const mdPath = `${outputPath}.md`;
// Ensure output directory exists
const outputDir = path.dirname(path.resolve(jsonPath));
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Write JSON report
fs.writeFileSync(jsonPath, JSON.stringify(jsonReport, null, 2), 'utf8');
console.log(`📄 JSON report written: ${path.resolve(jsonPath)}`);
// Write Markdown report
fs.writeFileSync(mdPath, markdownReport, 'utf8');
console.log(`📄 Markdown report written: ${path.resolve(mdPath)}`);
return {
json: path.resolve(jsonPath),
markdown: path.resolve(mdPath),
success: true
};
} catch (error) {
console.error('❌ Error writing reports:', error.message);
throw error;
}
}
/**
* Get trend icon based on comparison between two values
* @param {number} current - Current period value
* @param {number} previous - Previous period value
* @returns {string} Trend icon
*/
function getTrendIcon(current, previous) {
if (current > previous * 1.1) return '📈';
if (current < previous * 0.9) return '📉';
return '➡️';
}
/**
* Get trend text based on comparison between two values
* @param {number} current - Current period value
* @param {number} previous - Previous period value
* @returns {string} Trend description
*/
function getTrendText(current, previous) {
const change = ((current - previous) / Math.max(previous, 1)) * 100;
if (Math.abs(change) < 10) return 'Stable';
return change > 0 ? `+${change.toFixed(0)}%` : `${change.toFixed(0)}%`;
}
/**
* Generate complete dependencies overview section across all projects
* @param {Array} projects - Array of project data
* @returns {string} Markdown section
*/
function generateCriticalDependenciesSection(projects) {
const allDeps = new Map();
// Collect all dependencies across projects
for (const project of projects) {
if (project.dependencyAnalysis) {
for (const [depName, depData] of Object.entries(project.dependencyAnalysis)) {
if (!allDeps.has(depName)) {
allDeps.set(depName, depData);
}
}
}
}
// Get ALL unique dependencies, sort by first period activity (descending)
const allUniqueDeps = Array.from(allDeps.entries())
.sort((a, b) => {
const aFirstPeriod = a[1].periods?.[0]?.releases || 0;
const bFirstPeriod = b[1].periods?.[0]?.releases || 0;
return bFirstPeriod - aFirstPeriod;
});
let section = `## 📋 Complete Dependencies Overview\n\n`;
section += `### All Unique Dependencies Across Projects (${allUniqueDeps.length} total)\n\n`;
// Dynamic header based on periods
let headerRow = `| Package `;
let separatorRow = `|---------|`;
if (allUniqueDeps.length > 0 && allUniqueDeps[0][1].periods) {
const periods = allUniqueDeps[0][1].periods;
periods.forEach(period => {
headerRow += `| ${period.label} `;
separatorRow += `-------|`;
});
}
headerRow += `| Risk Assessment |\n`;
separatorRow += `------------------|\n`;
section += headerRow;
section += separatorRow;
for (const [depName, depData] of allUniqueDeps) {
let rowData = `| ${depName} `;
let totalFirstPeriod = 0;
let majorFirstPeriod = 0;
if (depData.periods && Array.isArray(depData.periods)) {
depData.periods.forEach(period => {
const releases = period.releases || 0;
const major = period.semver?.major || 0;
const minor = period.semver?.minor || 0;
const patch = period.semver?.patch || 0;
rowData += `| ${major}/${minor}/${patch} (${releases}) `;
// Use first period for risk assessment
if (period === depData.periods[0]) {
totalFirstPeriod = releases;
majorFirstPeriod = major;
}
});
} else {
rowData += `| No data `;
}
// Dynamic risk assessment
let riskAssessment;
if (majorFirstPeriod >= 3) {
riskAssessment = '🔴 High Risk - Frequent breaking changes';
} else if (totalFirstPeriod >= 100) {
riskAssessment = majorFirstPeriod >= 1 ?
'🔴 High Risk - High volume + breaking changes' :
'🟡 High Volume - No breaking changes';
} else if (majorFirstPeriod >= 2) {
riskAssessment = '🟡 Moderate Risk - Multiple breaking changes';
} else if (totalFirstPeriod >= 50) {
riskAssessment = majorFirstPeriod >= 1 ?
'🟡 Moderate Risk - Active with breaking changes' :
'🟡 Moderate Activity - No breaking changes';
} else if (majorFirstPeriod >= 1) {
riskAssessment = '🟠 Low Risk - Occasional breaking changes';
} else if (totalFirstPeriod >= 10) {
riskAssessment = '🟢 Low Activity - Stable (no major releases)';
} else if (totalFirstPeriod >= 1) {
riskAssessment = '🟢 Very Low Activity - Stable';
} else {
riskAssessment = '⚪ No Activity';
}
rowData += `| ${riskAssessment} |\n`;
section += rowData;
}
// Add summary insights using first period
const withMajorReleases = allUniqueDeps.filter(([name, data]) =>
(data.periods?.[0]?.semver?.major || 0) >= 1
).length;
const highActivity = allUniqueDeps.filter(([name, data]) =>
(data.periods?.[0]?.releases || 0) >= 50
).length;
const stable = allUniqueDeps.filter(([name, data]) =>
(data.periods?.[0]?.semver?.major || 0) === 0
).length;
section += `\n**📊 Ecosystem Summary:**\n`;
section += `- **Dependencies with breaking changes:** ${withMajorReleases}/${allUniqueDeps.length} (${((withMajorReleases/allUniqueDeps.length)*100).toFixed(0)}%)\n`;
section += `- **High activity dependencies (50+ releases):** ${highActivity}/${allUniqueDeps.length} (${((highActivity/allUniqueDeps.length)*100).toFixed(0)}%)\n`;
section += `- **Stable dependencies (no major releases):** ${stable}/${allUniqueDeps.length} (${((stable/allUniqueDeps.length)*100).toFixed(0)}%)\n`;
section += `- **Average releases per dependency:** ${(allUniqueDeps.reduce((sum, [name, data]) =>
sum + (data.periods?.[0]?.releases || 0), 0) / allUniqueDeps.length).toFixed(1)}\n\n`;
if (withMajorReleases > 0) {
section += `**⚠️ Recommendations:**\n`;
section += `- Monitor ${withMajorReleases} dependencies with breaking changes closely\n`;
section += `- Consider version pinning for critical dependencies\n`;
section += `- Plan extra testing time when updating packages with major releases\n`;
section += `- Review changelogs carefully before updating\n\n`;
}
return section;
}
/**
* Generate project-specific critical dependencies (using dynamic periods)
* @param {Object} project - Project data
* @returns {string} Markdown section
*/
function generateProjectCriticalDeps(project) {
const criticalDeps = Object.entries(project.dependencyAnalysis || {})
.filter(([name, data]) => (data.periods?.[0]?.semver?.major || 0) >= 1)
.sort((a, b) => (b[1].periods?.[0]?.semver?.major || 0) - (a[1].periods?.[0]?.semver?.major || 0));
if (criticalDeps.length === 0) {
return `### 🟢 Project Stability\n\n*Excellent! No dependencies with major releases in the first period.*\n\n`;
}
let section = `### ⚠️ Dependencies with Breaking Changes\n\n`;
for (const [depName, depData] of criticalDeps.slice(0, 5)) { // Show top 5
const firstPeriod = depData.periods?.[0];
const major = firstPeriod?.semver?.major || 0;
const minor = firstPeriod?.semver?.minor || 0;
const patch = firstPeriod?.semver?.patch || 0;
const periodLabel = firstPeriod?.label || '0-12m';
section += `- **${depName}**: ${major} major, ${minor} minor, ${patch} patch releases (${periodLabel})\n`;
}
section += `\n`;
return section;
}
/**
* Generate activity trends section (using dynamic periods)
* @param {Object} project - Project data
* @returns {string} Markdown section
*/
function generateActivityTrends(project) {
const deps = Object.entries(project.dependencyAnalysis || {});
let increasingActivity = 0;
let decreasingActivity = 0;
for (const [name, data] of deps) {
const firstPeriodReleases = data.periods?.[0]?.releases || 0;
const secondPeriodReleases = data.periods?.[1]?.releases || 0;
if (firstPeriodReleases > secondPeriodReleases * 1.2) {
increasingActivity++;
} else if (firstPeriodReleases < secondPeriodReleases * 0.8) {
decreasingActivity++;
}
}
let section = `### 📈 Activity Trends\n\n`;
section += `- **Increasing activity:** ${increasingActivity} dependencies (${((increasingActivity/deps.length)*100).toFixed(0)}%)\n`;
section += `- **Decreasing activity:** ${decreasingActivity} dependencies (${((decreasingActivity/deps.length)*100).toFixed(0)}%)\n`;
section += `- **Stable activity:** ${deps.length - increasingActivity - decreasingActivity} dependencies\n\n`;
return section;
}
/**
* Get stability icon based on major/minor releases
* @param {number} major - Major releases
* @param {number} minor - Minor releases
* @returns {string} Stability icon
*/
function getStabilityIcon(major, minor) {
if (major >= 3) return '🔴';
if (major >= 2) return '🟡';
if (major >= 1) return '🟠';
if (minor >= 10) return '🟡';
return '🟢';
}
/**
* Get activity level description
* @param {number} releases - Number of releases
* @returns {string} Activity level
*/
function getActivityLevel(releases) {
if (releases >= 50) return '🔥 Very High';
if (releases >= 20) return '🚀 High';
if (releases >= 10) return '📈 Moderate';
if (releases >= 5) return '📊 Low';
return '💤 Very Low';
}
/**
* Get project stability rating
* @param {Object} semver - Semver breakdown data
* @returns {string} Stability rating
*/
function getProjectStabilityRating(semver) {
const majorPerMonth = semver.avgMajorPerMonthLast12m;
if (majorPerMonth >= 5) return '🔴 High Risk - Many breaking changes';
if (majorPerMonth >= 3) return '🟡 Moderate Risk - Regular breaking changes';
if (majorPerMonth >= 1) return '🟠 Low Risk - Occasional breaking changes';
return '🟢 Stable - Minimal breaking changes';
}
/**
* Get maintenance effort assessment
* @param {number} avgMajorPerMonth - Average major releases per month
* @returns {string} Maintenance effort
*/
function getMaintenanceEffort(avgMajorPerMonth) {
if (avgMajorPerMonth >= 5) return '🔴 Very High';
if (avgMajorPerMonth >= 3) return '🟡 High';
if (avgMajorPerMonth >= 1) return '🟠 Moderate';
return '🟢 Low';
}
/**
* Get update recommendation
* @param {Object} semver - Semver breakdown data
* @returns {string} Update recommendation
*/
function getUpdateRecommendation(semver) {
const majorPerMonth = semver.avgMajorPerMonthLast12m;
const trend = semver.totalMajorLast12m - semver.totalMajorLast12to24m;
if (majorPerMonth >= 3 && trend > 0) {
return '🚨 Delay updates - High breaking change risk';
}
if (majorPerMonth >= 2) {
return '⚠️ Cautious updates - Test thoroughly';
}
if (majorPerMonth < 1 && trend <= 0) {
return '✅ Safe to update - Stable ecosystem';
}
return '🟡 Standard updates - Monitor for issues';
}
/**
* Get percentage change between two values
* @param {number} current - Current value
* @param {number} previous - Previous value
* @returns {string} Percentage change with sign
*/
function getPercentageChange(current, previous) {
if (previous === 0) return current > 0 ? '+∞%' : '0%';
const change = ((current - previous) / previous * 100).toFixed(1);
return change > 0 ? `+${change}%` : `${change}%`;
}
/**
* Generate project drift/pulse summary section
* @param {Object} driftPulseSummary - Project drift/pulse summary data
* @returns {string} Markdown section
*/
function generateProjectDriftPulse(driftPulseSummary) {
if (!driftPulseSummary) {
return '';
}
let section = `### 🕐 Project Drift/Pulse Summary\n\n`;
section += `- **Total Project Drift:** ${driftPulseSummary.totalDrift} years\n`;
section += `- **Total Project Pulse:** ${driftPulseSummary.totalPulse} years\n`;
section += `- **Average Drift per Dependency:** ${driftPulseSummary.avgDriftPerDep} years\n`;
section += `- **Average Pulse per Dependency:** ${driftPulseSummary.avgPulsePerDep} years\n`;
section += `- **Dependencies with Drift/Pulse Data:** ${driftPulseSummary.dependenciesWithDriftPulse}\n\n`;
// Add project-specific drift/pulse interpretation
const driftStatus = driftPulseSummary.avgDriftPerDep < 0.5 ? '🟢 Very Fresh Project' :
driftPulseSummary.avgDriftPerDep < 1.0 ? '🟡 Moderately Fresh Project' :
driftPulseSummary.avgDriftPerDep < 2.0 ? '🟠 Getting Stale Project' : '🔴 Very Stale Project';
const pulseStatus = driftPulseSummary.avgPulsePerDep < 0.5 ? '🔥 Highly Active Ecosystem' :
driftPulseSummary.avgPulsePerDep < 1.0 ? '🚀 Active Ecosystem' :
driftPulseSummary.avgPulsePerDep < 2.0 ? '📈 Moderate Activity Ecosystem' : '💤 Low Activity Ecosystem';
section += `**📊 Project Status:**\n`;
section += `- **Drift Status:** ${driftStatus}\n`;
section += `- **Pulse Status:** ${pulseStatus}\n\n`;
return section;
}
/**
* Get project stability rating (dynamic version using periods)
* @param {number} avgMajorPerMonth - Average major releases per month
* @returns {string} Stability rating
*/
function getProjectStabilityRatingDynamic(avgMajorPerMonth) {
if (avgMajorPerMonth >= 5) return '🔴 High Risk - Many breaking changes';
if (avgMajorPerMonth >= 3) return '🟡 Moderate Risk - Regular breaking changes';
if (avgMajorPerMonth >= 1) return '🟠 Low Risk - Occasional breaking changes';
return '🟢 Stable - Minimal breaking changes';
}
/**
* Get update recommendation (dynamic version using periods)
* @param {number} majorAvg - Average major releases per month for first period
* @param {Array} periods - Array of period objects
* @returns {string} Update recommendation
*/
function getUpdateRecommendationDynamic(majorAvg, periods) {
const trend = periods.length >= 2 ?
(periods[0].semver?.major || 0) - (periods[1].semver?.major || 0) : 0;
if (majorAvg >= 3 && trend > 0) {
return '🚨 Delay updates - High breaking change risk';
}
if (majorAvg >= 2) {
return '⚠️ Cautious updates - Test thoroughly';
}
if (majorAvg < 1 && trend <= 0) {
return '✅ Safe to update - Stable ecosystem';
}
return '🟡 Standard updates - Monitor for issues';
}
/**
* Generate explanation section
* @returns {string} Explanation markdown
*/
function generateExplanationSection() {
return `## 📚 Understanding the Report
### 🏷️ Semver Classification
- **🔴 MAJOR**: Breaking changes (1.0.0 → 2.0.0)
- **🟡 MINOR**: New features, backward compatible (1.0.0 → 1.1.0)
- **🟢 PATCH**: Bug fixes, backward compatible (1.0.0 → 1.0.1)
### 📊 Stability Indicators
- **🟢 Green**: Stable, minimal breaking changes
- **🟡 Yellow**: Moderate activity, some breaking changes
- **🔴 Red**: High activity, frequent breaking changes
### 🔍 Smart Filtering
This analysis automatically filters out:
- Development versions (\`-dev\`, \`-nightly\`)
- Alpha/Beta releases (\`-alpha\`, \`-beta\`)
- Release candidates (\`-rc\`)
- Experimental builds (\`-experimental\`, \`-canary\`)
- Malformed versions (\`0.0.0-*\`)
### 📈 Backport Detection
**Why Total ≠ Semver Sum?**
- **Total releases**: All stable versions (including backports)
- **Semver breakdown**: Only progressive version increases
- **Difference**: Maintenance releases for older versions (backports)
*Example: Release sequence 2.1.0 → 2.2.0 → 2.1.1 (backport)*
- Total: 3 releases
- Semver: 2 releases (2.1.1 backport is skipped)
### 🎯 Activity Trends
- **📈 Increasing**: 20%+ more releases than previous period
- **📉 Decreasing**: 20%+ fewer releases than previous period
- **➡️ Stable**: Within 20% of previous period
---
*Generated by Dependencies Evolution Analyzer*
`;
}
module.exports = {
generateJSON,
generateMarkdown,
writeReports,
getMostActiveMonth,
getTrendIcon,
getTrendText,
generateCriticalDependenciesSection,
generateProjectCriticalDeps,
generateActivityTrends,
generateProjectDriftPulse,
getStabilityIcon,
getActivityLevel,
getProjectStabilityRating,
getMaintenanceEffort,
getUpdateRecommendation,
getPercentageChange,
generateExplanationSection
};