UNPKG

@boilerbuilder/deps-analyzer

Version:

CLI tool to analyze dependency evolution and release frequency

1,033 lines (882 loc) 43.3 kB
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 };