depdrift
Version:
A tool to analyze dependency drift in JavaScript projects
641 lines (569 loc) • 21.8 kB
JavaScript
/**
* Table formatter for dependency drift and security output
* @module formatters/tableFormatter
*/
import Table from 'cli-table3';
import chalk from 'chalk';
import { formatDriftLevel, formatSecuritySeverity } from '../utils/formatters.js';
/**
* Get terminal width
* @returns {number} Width of terminal in columns
*/
function getTerminalWidth () {
return process.stdout.columns || 80;
}
/**
* Format a date as "time ago"
* @param {Date} date - Date to format
* @returns {string} Formatted time ago string
*/
function formatTimeAgo (date) {
const now = new Date();
const diffMs = now - date;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return 'Today';
} else if (diffDays === 1) {
return 'Yesterday';
} else if (diffDays < 30) {
return `${diffDays} days ago`;
} else if (diffDays < 365) {
const months = Math.floor(diffDays / 30);
return `${months} ${months === 1 ? 'month' : 'months'} ago`;
} else {
const years = Math.floor(diffDays / 365);
return `${years} ${years === 1 ? 'year' : 'years'} ago`;
}
}
/**
* Create a colored table of dependencies with drift and security information
* @param {Object} analysis - The analysis results
* @param {Object} options - Table formatting options
* @param {boolean} [options.compact=false] - Whether to use a compact view
* @param {boolean} [options.includeSecurity=true] - Whether to include security columns
* @param {string} [options.sortBy='drift'] - Sort by: 'drift', 'security', 'name', 'days'
* @param {string} [options.sortDirection='desc'] - Sort direction: 'asc' or 'desc'
* @param {boolean} [options.useImprovedFormat=true] - Whether to use the improved table format
* @param {boolean} [options.showAll=false] - Whether to show all dependencies or only issues
* @returns {string} Table output as string
*/
function createDependencyTable (analysis, options = {}) {
const {
compact = false,
includeSecurity = true,
sortBy = 'drift',
sortDirection = 'desc',
useImprovedFormat = true,
showAll = false
} = options;
// Define table columns based on options
const columns = useImprovedFormat
? ['Package', 'Current', 'Latest', 'Update Status', 'Last Published', 'Drift']
: compact
? ['Package', 'Current', 'Latest', 'Drift']
: ['Package', 'Current', 'Latest', 'Days Behind', 'Drift'];
// Add security columns if requested
if (includeSecurity) {
columns.push('Security');
}
// Calculate available terminal width
const terminalWidth = getTerminalWidth();
// Calculate column widths proportionally based on typical content size and terminal width
const tableWidth = terminalWidth - 5; // Allow some padding
// Define column width ratios
let colWidths;
if (useImprovedFormat) {
// Package, Current, Latest, Update Status, Last Published, Drift, [Security]
if (includeSecurity) {
colWidths = calculateColumnWidths(tableWidth, [3, 1.5, 1.5, 3, 2, 1.5, 2.5]);
} else {
colWidths = calculateColumnWidths(tableWidth, [3, 1.5, 1.5, 3, 2, 1.5]);
}
} else if (compact) {
// Package, Current, Latest, Drift, [Security]
if (includeSecurity) {
colWidths = calculateColumnWidths(tableWidth, [4, 2, 2, 2, 3]);
} else {
colWidths = calculateColumnWidths(tableWidth, [4, 2, 2, 2]);
}
} else {
// Package, Current, Latest, Days Behind, Drift, [Security]
if (includeSecurity) {
colWidths = calculateColumnWidths(tableWidth, [3, 1.5, 1.5, 2, 1.5, 2.5]);
} else {
colWidths = calculateColumnWidths(tableWidth, [3, 1.5, 1.5, 2, 1.5]);
}
}
// Create table instance with border styling and column widths
const table = new Table({
head: columns.map(col => chalk.cyan.bold(col)),
colWidths: colWidths,
wordWrap: true,
chars: {
'top': '═', 'top-mid': '╤', 'top-left': '╔', 'top-right': '╗',
'bottom': '═', 'bottom-mid': '╧', 'bottom-left': '╚', 'bottom-right': '╝',
'left': '║', 'left-mid': '╟', 'mid': '─', 'mid-mid': '┼',
'right': '║', 'right-mid': '╢', 'middle': '│'
},
style: {
head: [], // No additional styling for header
border: [] // No additional styling for border
}
});
// Sort dependencies
const sortedDeps = [...analysis.dependencies];
switch (sortBy) {
case 'drift':
// Sort by drift level (critical first)
const driftOrder = { critical: 0, high: 1, medium: 2, low: 3, none: 4, unknown: 5 };
sortedDeps.sort((a, b) => {
const orderA = driftOrder[a.driftLevel] || 5;
const orderB = driftOrder[b.driftLevel] || 5;
return orderA - orderB || a.name.localeCompare(b.name);
});
break;
case 'security':
// Sort by security severity (critical first)
const securityOrder = { critical: 0, high: 1, medium: 2, low: 3, none: 4 };
sortedDeps.sort((a, b) => {
const secA = a.security || { highestSeverity: 'none' };
const secB = b.security || { highestSeverity: 'none' };
const orderA = securityOrder[secA.highestSeverity] || 4;
const orderB = securityOrder[secB.highestSeverity] || 4;
return orderA - orderB || a.name.localeCompare(b.name);
});
break;
case 'days':
// Sort by days behind (most days first)
sortedDeps.sort((a, b) => {
const daysA = a.daysBehind || 0;
const daysB = b.daysBehind || 0;
return daysB - daysA || a.name.localeCompare(b.name);
});
break;
case 'name':
default:
// Sort by name
sortedDeps.sort((a, b) => a.name.localeCompare(b.name));
}
// Apply sort direction if 'asc' is specified (default is already 'desc')
if (sortDirection === 'asc') {
sortedDeps.reverse();
}
// Filter dependencies if not showing all
const depsToShow = showAll
? sortedDeps
: sortedDeps.filter(dep =>
dep.driftLevel && dep.driftLevel !== 'none' ||
(dep.security && dep.security.vulnerable)
);
// If no dependencies to show after filtering
if (depsToShow.length === 0) {
if (!showAll) {
return 'No outdated dependencies found. All packages are up to date.';
}
return 'No dependencies found.';
}
// Add rows to table
depsToShow.forEach(dep => {
const row = [];
// Package name column (with dev indicator if needed)
let nameDisplay = dep.isDevDependency
? `${dep.name} ${chalk.cyan('[dev]')}`
: dep.isPeerDependency
? `${dep.name} ${chalk.magenta('[peer]')}`
: dep.isOptionalDependency
? `${dep.name} ${chalk.yellow('[optional]')}`
: dep.name;
// Highlight package name if it has vulnerabilities
if (dep.security && dep.security.vulnerable) {
nameDisplay = chalk.bold.red(nameDisplay);
}
row.push(nameDisplay);
// Current version column
row.push(getColoredVersion(dep.currentVersion, dep.driftLevel));
// Latest version column
row.push(chalk.cyan(dep.latestVersion || 'unknown'));
if (useImprovedFormat) {
// Update Status column - clearly indicates if update is needed
if (dep.currentVersion !== dep.latestVersion) {
row.push(chalk.yellow(`Needs update (${dep.daysBehind} days behind)`));
} else {
row.push(chalk.green('Up to date'));
}
// Last Published column
const lastPublished = dep.latestPublishDate ?
formatTimeAgo(new Date(dep.latestPublishDate)) :
formatTimeAgo(new Date(Date.now() - (dep.daysBehind * 24 * 60 * 60 * 1000)));
row.push(getDaysBehindDisplay(lastPublished, dep.daysBehind));
} else if (!compact) {
// Traditional Days behind column (if not compact mode)
row.push(getDaysBehindDisplay(dep.daysBehind));
}
// Drift level column
row.push(formatDriftLevel(dep.driftLevel));
// Security column (if requested)
if (includeSecurity) {
const security = dep.security || { vulnerable: false, highestSeverity: 'none', vulnerabilities: [] };
if (security.vulnerable) {
// Create enhanced security display with vulnerability count
const vulnCount = security.vulnerabilities?.length || 0;
const securityColor = getSecurityColor(security.highestSeverity);
const vulnCountText = vulnCount > 0 ? ` (${vulnCount})` : '';
const securityDisplay = `${securityColor(security.highestSeverity.toUpperCase())}${chalk.bold.red(vulnCountText)}`;
row.push(securityDisplay);
} else {
// Standard "none" for non-vulnerable packages
row.push(chalk.green('none'));
}
}
table.push(row);
});
return table.toString();
}
/**
* Calculate proportional column widths based on available space and weight ratios
* @param {number} totalWidth - Total available width
* @param {Array<number>} ratios - Weight ratios for each column
* @returns {Array<number>} Column widths
*/
function calculateColumnWidths (totalWidth, ratios) {
// Calculate total ratio
const totalRatio = ratios.reduce((sum, ratio) => sum + ratio, 0);
// Calculate column widths based on ratios, ensuring minimum width
const columnWidths = ratios.map(ratio => {
// Allocate percentage of available width based on ratio
const width = Math.floor((ratio / totalRatio) * totalWidth);
// Ensure minimum width of 3 characters
return Math.max(width, 3);
});
// Adjust last column to account for any rounding errors
const allocatedWidth = columnWidths.reduce((sum, width) => sum + width, 0);
if (allocatedWidth < totalWidth) {
columnWidths[columnWidths.length - 1] += (totalWidth - allocatedWidth);
}
return columnWidths;
}
/**
* Create a colored summary table with overall assessment
* @param {Object} analysis - The analysis results
* @returns {string} Summary table as string
*/
function createSummaryTable (analysis) {
const terminalWidth = getTerminalWidth();
const table = new Table({
colWidths: [
Math.floor(terminalWidth * 0.4),
Math.floor(terminalWidth * 0.6) - 5
],
wordWrap: true,
chars: {
'top': '═', 'top-mid': '╤', 'top-left': '╔', 'top-right': '╗',
'bottom': '═', 'bottom-mid': '╧', 'bottom-left': '╚', 'bottom-right': '╝',
'left': '║', 'left-mid': '╟', 'mid': '─', 'mid-mid': '┼',
'right': '║', 'right-mid': '╢', 'middle': '│'
},
style: {
head: [], // No additional styling for header
border: [] // No additional styling for border
}
});
if (!analysis.overallAssessment) {
return 'No assessment data available';
}
const { overallAssessment } = analysis;
// Get status display with color
const getStatusDisplay = status => {
switch (status) {
case 'excellent': return chalk.green.bold('Excellent');
case 'good': return chalk.blue.bold('Good');
case 'fair': return chalk.yellow.bold('Fair');
case 'poor': return chalk.red.bold('Poor');
case 'critical': return chalk.bgRed.white.bold('Critical');
default: return status;
}
};
// Get score display with color
const getScoreDisplay = score => {
if (score === null || score === undefined) {
return chalk.gray('N/A');
}
if (score < 10) {
return chalk.green(score);
} else if (score < 30) {
return chalk.blue(score);
} else if (score < 50) {
return chalk.yellow(score);
} else if (score < 70) {
return chalk.red(score);
} else {
return chalk.bgRed.white(score);
}
};
// Add rows to table
table.push(
[chalk.cyan.bold('Status'), getStatusDisplay(overallAssessment.status)],
[chalk.cyan.bold('Drift Score'), getScoreDisplay(overallAssessment.driftScore)],
[chalk.cyan.bold('Security Score'), getScoreDisplay(overallAssessment.securityScore)],
[chalk.cyan.bold('Combined Score'), getScoreDisplay(overallAssessment.combinedScore)],
[chalk.cyan.bold('Outdated Dependencies'), `${overallAssessment.outdatedDependencies} of ${analysis.dependencies.length}`],
[chalk.cyan.bold('Vulnerable Dependencies'), `${overallAssessment.vulnerableDependencies || 0} of ${analysis.dependencies.length}`]
);
return table.toString();
}
/**
* Create a table with recommendations for addressing drift issues
* @param {Array<Object>} recommendations - Recommendations from analysis
* @returns {string} Recommendations table as string
*/
function createRecommendationsTable (recommendations) {
// Handle missing or invalid recommendations
if (!recommendations || !Array.isArray(recommendations) || recommendations.length === 0) {
return 'No specific recommendations available.';
}
// Create table with columns
const table = new Table({
head: [
chalk.cyan.bold('#'),
chalk.cyan.bold('Package'),
chalk.cyan.bold('Current'),
chalk.cyan.bold('Recommendation'),
chalk.cyan.bold('Reason')
],
wordWrap: true,
chars: {
'top': '═', 'top-mid': '╤', 'top-left': '╔', 'top-right': '╗',
'bottom': '═', 'bottom-mid': '╧', 'bottom-left': '╚', 'bottom-right': '╝',
'left': '║', 'left-mid': '╟', 'mid': '─', 'mid-mid': '┼',
'right': '║', 'right-mid': '╢', 'middle': '│'
},
style: {
head: [], // No additional styling for header
border: [] // No additional styling for border
}
});
// Convert recommendations to standard format if they're not already
const standardizedRecs = recommendations.map(rec => {
// If it's already in the expected format
if (rec.dependencyName && rec.currentVersion && rec.recommendation && rec.details) {
return rec;
}
// Convert from alternative format
return {
dependencyName: rec.dependencyName || rec.package || rec.name || 'Unknown package',
currentVersion: rec.currentVersion || rec.version || 'unknown',
recommendation: rec.recommendation ||
(rec.recommendedVersion ? `Update to ${rec.recommendedVersion}` : 'Update recommended'),
details: rec.details || rec.reason ||
(rec.reason ? `${rec.reason} (${rec.priority || 'medium'} priority)` :
'Dependency needs attention'),
hasSecurity: rec.hasSecurity || rec.security || false
};
});
// Add rows to the table
standardizedRecs.forEach((rec, index) => {
// Update terminology in details to be more accurate
let detailsDisplay = rec.details
.replace(/(days behind)/i, chalk.yellow('days since latest release'))
.replace(/(critical|high|medium|low)/i, match => {
const lowerMatch = match.toLowerCase();
if (lowerMatch === 'critical') return chalk.bgRed.white(match);
if (lowerMatch === 'high') return chalk.red(match);
if (lowerMatch === 'medium') return chalk.yellow(match);
if (lowerMatch === 'low') return chalk.blue(match);
return match;
});
// Add security indicator if this recommendation is based on security
if (rec.hasSecurity || (rec.details && rec.details.toLowerCase().includes('security'))) {
detailsDisplay = `${chalk.bgRed.white(' SECURITY ')} ${detailsDisplay}`;
}
// Format the package name to highlight security issues
const packageDisplay = rec.hasSecurity ?
chalk.bold.red(rec.dependencyName) :
chalk.bold(rec.dependencyName);
// Add recommendation with package version
let recommendationDisplay = rec.recommendation;
if (rec.targetVersion && !rec.recommendation.includes(rec.targetVersion)) {
recommendationDisplay = `Update to ${chalk.green(rec.targetVersion)}`;
}
table.push([
chalk.white.bold(index + 1),
packageDisplay,
chalk.cyan(rec.currentVersion),
recommendationDisplay,
detailsDisplay
]);
});
return table.toString();
}
/**
* Create an explanation block to help users understand the improved table
* @returns {string} Explanation text
*/
function createExplanationBlock () {
return `
${chalk.cyan.bold('📋 Understanding the Table:')}
• ${chalk.cyan.bold('Package:')} Dependency name and type
• ${chalk.cyan.bold('Current:')} The version you currently have installed
• ${chalk.cyan.bold('Latest:')} The latest version available
• ${chalk.cyan.bold('Update Status:')} Whether you need to update the package
- ${chalk.green('Up to date:')} You have the latest version (even if that version is old)
- ${chalk.yellow('Needs update:')} A newer version is available
• ${chalk.cyan.bold('Last Published:')} When the latest version was published
- A package can be ${chalk.green('Up to date')} but still show ${chalk.yellow('Last Published: 1 year ago')}
- This means you have the latest version, but that version itself is old
• ${chalk.cyan.bold('Drift:')} The version drift level calculated from version difference
• ${chalk.cyan.bold('Security:')} Whether the package has security vulnerabilities
`;
}
/**
* Format the full analysis as tables
* @param {Object} analysis - The analysis results
* @param {Object} options - Formatting options
* @param {boolean} [options.includeSecurity=true] - Whether to include security columns
* @param {string} [options.sortBy='drift'] - Sort by: 'drift', 'security', 'name', 'days'
* @param {string} [options.sortDirection='desc'] - Sort direction: 'asc' or 'desc'
* @param {boolean} [options.useImprovedFormat=true] - Whether to use the improved table format
* @param {boolean} [options.showAll=false] - Whether to show all dependencies or only outdated ones
* @returns {string} Formatted tables as string
*/
function formatAnalysisAsTables (analysis, options = {}) {
const {
includeSecurity = true,
sortBy = 'drift',
sortDirection = 'desc',
useImprovedFormat = true,
showAll = false
} = options;
// Handle missing analysis
if (!analysis) {
return 'No analysis results available.';
}
// Generate header info
const header = `
${chalk.bold.blue('DepDrift Analysis Results')}
${chalk.cyan('Project:')} ${chalk.white(analysis.projectName)}@${chalk.white(analysis.projectVersion)}
${chalk.cyan('Path:')} ${chalk.white(analysis.packageJsonPath || analysis.projectPath)}
${chalk.cyan('Analyzed on:')} ${chalk.white(analysis.timestamp ? new Date(analysis.timestamp).toLocaleString() : new Date().toLocaleString())}
`;
// Generate summary table
const summaryTable = createSummaryTable(analysis);
// Generate dependencies table with showAll option
const dependenciesTable = createDependencyTable(analysis, {
includeSecurity,
sortBy,
sortDirection,
useImprovedFormat,
compact: options.compact || false,
showAll
});
// Generate recommendations table if recommendations exist
const recommendationsTable = analysis.recommendations ?
createRecommendationsTable(analysis.recommendations) :
'No recommendations available.';
// Add explanation block if using improved format
const explanation = useImprovedFormat ? createExplanationBlock() : '';
// Combine all sections
return `${header}
${chalk.bold.blue('Overall Assessment')}
${summaryTable}
${explanation}
${chalk.bold.blue('Dependencies')}
${dependenciesTable}
${chalk.bold.blue('Recommendations')}
${recommendationsTable}
`;
}
/**
* Helper function to get colored version string
* @private
* @param {string} version - Version string
* @param {string} driftLevel - Drift level
* @returns {string} Colored version string
*/
function getColoredVersion (version, driftLevel) {
switch (driftLevel) {
case 'none':
return chalk.green(version);
case 'low':
return chalk.blue(version);
case 'medium':
return chalk.yellow(version);
case 'high':
return chalk.red(version);
case 'critical':
return chalk.bgRed.white(version);
default:
return version;
}
}
/**
* Helper function to get colored days behind string
* @private
* @param {string|number} value - Days behind or formatted date string
* @param {number} [days] - Days behind (only needed if first param is string)
* @returns {string} Colored days behind string
*/
function getDaysBehindDisplay (value, days) {
// If value is a number, it's the traditional days behind
if (typeof value === 'number') {
if (!value || value <= 0) {
return chalk.green('0 days');
}
if (value <= 30) {
return chalk.blue(`${value} days`);
} else if (value <= 90) {
return chalk.yellow(`${value} days`);
} else if (value <= 180) {
return chalk.red(`${value} days`);
} else {
return chalk.bgRed.white(`${value} days`);
}
}
// If value is a string, it's the formatted date string
if (!days || days <= 30) {
return chalk.green(value);
} else if (days <= 90) {
return chalk.blue(value);
} else if (days <= 180) {
return chalk.yellow(value);
} else if (days <= 365) {
return chalk.red(value);
} else {
return chalk.bgRed.white(value);
}
}
/**
* Get appropriate color function for security severity
* @private
* @param {string} severity - Security severity level
* @returns {Function} Chalk color function
*/
function getSecurityColor (severity) {
switch (severity) {
case 'critical': return chalk.bgRed.white;
case 'high': return chalk.bold.red;
case 'medium': return chalk.bold.yellow;
case 'low': return chalk.blue;
default: return chalk.green;
}
}
/**
* Generate a table representation of the analysis results
* @param {Object} results - Analysis results
* @returns {Object} Table object that can be converted to a string
*/
function generateTable(results) {
const output = formatAnalysisAsTables(results);
return {
toString: () => output
};
}
export {
formatAnalysisAsTables,
createDependencyTable,
createSummaryTable,
createRecommendationsTable,
createExplanationBlock,
generateTable
};